mirror of
https://github.com/simstudioai/sim.git
synced 2026-08-31 01:11:53 +08:00
improvement(provenance): cleanup secrets boundary (#6374)
* fix(secrets): preserve raw outputs with durable provenance * improvement(provenance): cleanup boundary * fix copy resources * fix fork copies to work with provenance * address comments * fix
This commit is contained in:
committed by
GitHub
parent
40c0a571fd
commit
5cf1f9be84
@@ -590,7 +590,7 @@ describe('Function Execute API Route', () => {
|
||||
createMockRequest(
|
||||
'POST',
|
||||
{
|
||||
code: 'return environmentVariables.API_KEY',
|
||||
code: 'return {{API_KEY}}',
|
||||
envVars: { API_KEY: 'secret-at-the-end' },
|
||||
workflowId: 'workflow-1',
|
||||
workspaceId: 'workspace-1',
|
||||
@@ -686,7 +686,7 @@ describe('Function Execute API Route', () => {
|
||||
createMockRequest(
|
||||
'POST',
|
||||
{
|
||||
code: 'print("done")',
|
||||
code: 'print("{{API_KEY}}")',
|
||||
language: 'python',
|
||||
workspaceId: 'workspace-1',
|
||||
envVars: { API_KEY: 'secret-value' },
|
||||
@@ -821,6 +821,54 @@ describe('Function Execute API Route', () => {
|
||||
expect(mockExecuteInSandbox).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('runs with authenticated incomplete mount provenance and marks exported bytes unknown', async () => {
|
||||
envFlagsMock.isRemoteSandboxEnabled = true
|
||||
mockExecuteInSandbox.mockResolvedValueOnce({
|
||||
result: 'raw result',
|
||||
stdout: '',
|
||||
sandboxId: 'sandbox-123',
|
||||
exportedFiles: { '/home/user/output.txt': 'raw output' },
|
||||
})
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest(
|
||||
'POST',
|
||||
{
|
||||
code: 'print("done")',
|
||||
language: 'python',
|
||||
workspaceId: 'workspace-1',
|
||||
outputs: {
|
||||
files: [
|
||||
{
|
||||
path: 'files/output.txt',
|
||||
sandboxPath: '/home/user/output.txt',
|
||||
mimeType: 'text/plain',
|
||||
},
|
||||
],
|
||||
},
|
||||
[PRIVATE_SECRET_PROVENANCE_FIELD]: {
|
||||
version: 1,
|
||||
complete: false,
|
||||
selections: [],
|
||||
},
|
||||
},
|
||||
{ [PRIVATE_SECRET_PROVENANCE_HEADER]: PRIVATE_SECRET_PROVENANCE_BUNDLE_V1 }
|
||||
)
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect((await response.json()).output.result).toEqual(
|
||||
expect.objectContaining({ fileId: 'wf_output_txt', vfsPath: 'files/output.txt' })
|
||||
)
|
||||
expect(mockExecuteInSandbox).toHaveBeenCalledOnce()
|
||||
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
buffer: Buffer.from('raw output'),
|
||||
secretProvenance: { status: 'unknown' },
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not rewrite a static export path that happens to equal a resolved secret', async () => {
|
||||
envFlagsMock.isRemoteSandboxEnabled = true
|
||||
mockExecuteInSandbox.mockResolvedValueOnce({
|
||||
@@ -853,6 +901,7 @@ describe('Function Execute API Route', () => {
|
||||
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
target: expect.objectContaining({ path: 'files/report-secret-value.txt' }),
|
||||
secretProvenance: { status: 'exact', entries: [] },
|
||||
})
|
||||
)
|
||||
expect(JSON.stringify(data)).toContain('files/report-secret-value.txt')
|
||||
@@ -890,7 +939,7 @@ describe('Function Execute API Route', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a binary export unknown when files were mounted without a provenance envelope', async () => {
|
||||
it('classifies a binary export exact-empty when ordinary files were mounted without secret provenance', async () => {
|
||||
envFlagsMock.isRemoteSandboxEnabled = true
|
||||
mockExecuteInSandbox.mockResolvedValueOnce({
|
||||
result: 'done',
|
||||
@@ -919,7 +968,7 @@ describe('Function Execute API Route', () => {
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ secretProvenance: { status: 'unknown' } })
|
||||
expect.objectContaining({ secretProvenance: { status: 'exact', entries: [] } })
|
||||
)
|
||||
})
|
||||
|
||||
@@ -987,7 +1036,7 @@ describe('Function Execute API Route', () => {
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('POST', {
|
||||
code: 'print("done")',
|
||||
code: 'print("{{API_KEY}}")',
|
||||
language: 'python',
|
||||
workspaceId: 'workspace-1',
|
||||
envVars: { API_KEY: 'secret-value' },
|
||||
@@ -2002,6 +2051,117 @@ describe('Function Execute API Route', () => {
|
||||
expect(Object.values(request.contextVariables)).not.toContain('must-not-bind')
|
||||
})
|
||||
|
||||
it('does not infer provenance from an unused low-entropy environment value', async () => {
|
||||
mockExecuteInIsolatedVM.mockResolvedValueOnce({ result: 'Box eSign', stdout: '' })
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest(
|
||||
'POST',
|
||||
{
|
||||
code: 'return "Box eSign"',
|
||||
envVars: { SERVICENOW_PASSWORD: 'x' },
|
||||
},
|
||||
{ 'x-sim-request-private-tool-metadata': 'resolved-secret-names-v1' }
|
||||
)
|
||||
)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.output.result).toBe('Box eSign')
|
||||
expect(data.__resolvedSecretNames).toEqual([])
|
||||
})
|
||||
|
||||
it('does not build provenance matchers for unused oversized environment values', async () => {
|
||||
mockExecuteInIsolatedVM.mockResolvedValueOnce({ result: 'safe', stdout: '' })
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest(
|
||||
'POST',
|
||||
{
|
||||
code: 'return "safe"',
|
||||
envVars: { UNUSED: 'x'.repeat(65 * 1024) },
|
||||
},
|
||||
{ 'x-sim-request-private-tool-metadata': 'resolved-secret-names-v1' }
|
||||
)
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect((await response.json()).__resolvedSecretNames).toEqual([])
|
||||
})
|
||||
|
||||
it('conservatively reports only compiled secrets when bounded output classification is exceeded', async () => {
|
||||
const result = Array.from({ length: 100_001 }, () => 'ordinary')
|
||||
mockExecuteInIsolatedVM.mockResolvedValueOnce({ result, stdout: '' })
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest(
|
||||
'POST',
|
||||
{
|
||||
code: 'const key = {{API_KEY}}; return params.items',
|
||||
params: { items: result },
|
||||
envVars: { API_KEY: 'secret-value', UNUSED: 'x' },
|
||||
},
|
||||
{ 'x-sim-request-private-tool-metadata': 'resolved-secret-names-v1' }
|
||||
)
|
||||
)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('x-sim-private-tool-metadata')).toBe('resolved-secret-names-v1')
|
||||
expect(data.output.result).toHaveLength(100_001)
|
||||
expect(data.output.result[0]).toBe('ordinary')
|
||||
expect(data.__resolvedSecretNames).toEqual(['API_KEY'])
|
||||
})
|
||||
|
||||
it('conservatively reports a compiled secret whose value exceeds matcher capacity', async () => {
|
||||
mockExecuteInIsolatedVM.mockResolvedValueOnce({ result: 'ordinary', stdout: '' })
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest(
|
||||
'POST',
|
||||
{
|
||||
code: 'const key = {{OVERSIZED_SECRET}}; return "ordinary"',
|
||||
envVars: { OVERSIZED_SECRET: 's'.repeat(64 * 1024 + 1), UNUSED: 'x' },
|
||||
},
|
||||
{ 'x-sim-request-private-tool-metadata': 'resolved-secret-names-v1' }
|
||||
)
|
||||
)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.output.result).toBe('ordinary')
|
||||
expect(data.__resolvedSecretNames).toEqual(['OVERSIZED_SECRET'])
|
||||
})
|
||||
|
||||
it('tracks only compiled names when configured secrets share the same value', async () => {
|
||||
mockExecuteInIsolatedVM.mockResolvedValueOnce({ result: 'true', stdout: '' })
|
||||
const oneResponse = await POST(
|
||||
createMockRequest(
|
||||
'POST',
|
||||
{
|
||||
code: 'return {{SECOND}}',
|
||||
envVars: { FIRST: 'true', SECOND: 'true' },
|
||||
},
|
||||
{ 'x-sim-request-private-tool-metadata': 'resolved-secret-names-v1' }
|
||||
)
|
||||
)
|
||||
|
||||
mockExecuteInIsolatedVM.mockResolvedValueOnce({ result: 'true', stdout: '' })
|
||||
const bothResponse = await POST(
|
||||
createMockRequest(
|
||||
'POST',
|
||||
{
|
||||
code: 'const first = {{FIRST}}; return {{SECOND}}',
|
||||
envVars: { FIRST: 'true', SECOND: 'true' },
|
||||
},
|
||||
{ 'x-sim-request-private-tool-metadata': 'resolved-secret-names-v1' }
|
||||
)
|
||||
)
|
||||
|
||||
expect((await oneResponse.json()).__resolvedSecretNames).toEqual(['SECOND'])
|
||||
expect((await bothResponse.json()).__resolvedSecretNames).toEqual(['FIRST', 'SECOND'])
|
||||
})
|
||||
|
||||
it('lowers missing shell placeholders while preserving comments and heredoc delimiters', async () => {
|
||||
envFlagsMock.isRemoteSandboxEnabled = true
|
||||
const response = await POST(
|
||||
@@ -2134,7 +2294,7 @@ describe('Function Execute API Route', () => {
|
||||
expect(mockExecuteInSandbox).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports exact secret values returned through placeholders and the environment map', async () => {
|
||||
it('reports exact secret values returned through placeholders without inferring direct environment reads', async () => {
|
||||
mockExecuteInIsolatedVM.mockResolvedValueOnce({
|
||||
result: 'secret-valueother-secret',
|
||||
stdout: '',
|
||||
@@ -2171,14 +2331,15 @@ describe('Function Execute API Route', () => {
|
||||
const directData = await directResponse.json()
|
||||
|
||||
expect(envData.__resolvedSecretNames).toEqual(['ENV_ONLY', 'SHARED'])
|
||||
expect(directData.__resolvedSecretNames).toEqual(['API_KEY'])
|
||||
expect(directData.output.result).toBe('secret-value')
|
||||
expect(directData.__resolvedSecretNames).toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ name: 'numeric', secret: '123', result: 123 },
|
||||
{ name: 'boolean', secret: 'true', result: true },
|
||||
])(
|
||||
'records provenance for a typed $name secret returned through direct environment access',
|
||||
'preserves a typed $name value returned through legacy direct environment access without inferred provenance',
|
||||
async ({ secret, result }) => {
|
||||
mockExecuteInIsolatedVM.mockResolvedValueOnce({ result, stdout: '' })
|
||||
|
||||
@@ -2197,11 +2358,11 @@ describe('Function Execute API Route', () => {
|
||||
const data = await response.json()
|
||||
|
||||
expect(data.output.result).toBe(result)
|
||||
expect(data.__resolvedSecretNames).toEqual(['API_KEY'])
|
||||
expect(data.__resolvedSecretNames).toEqual([])
|
||||
}
|
||||
)
|
||||
|
||||
it('reports shell substitutions and exact secret output from direct environment access', async () => {
|
||||
it('reports placeholder output without inferring provenance from legacy shell environment access', async () => {
|
||||
envFlagsMock.isRemoteSandboxEnabled = true
|
||||
mockExecuteShellInSandbox.mockResolvedValueOnce({
|
||||
result: null,
|
||||
@@ -2245,7 +2406,8 @@ describe('Function Execute API Route', () => {
|
||||
const directData = await directResponse.json()
|
||||
|
||||
expect(referencedData.__resolvedSecretNames).toEqual(['API_KEY'])
|
||||
expect(directData.__resolvedSecretNames).toEqual(['API_KEY'])
|
||||
expect(directData.output.stdout).toBe('secret-value')
|
||||
expect(directData.__resolvedSecretNames).toEqual([])
|
||||
})
|
||||
|
||||
it('returns nonzero shell stderr as a visible 422 error and diagnostic output', async () => {
|
||||
@@ -2289,8 +2451,8 @@ describe('Function Execute API Route', () => {
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect((await response.json()).__resolvedSecretNames).toBeUndefined()
|
||||
expect(response.headers.get('x-sim-private-tool-metadata')).toBeNull()
|
||||
expect((await response.json()).__resolvedSecretNames).toEqual([])
|
||||
expect(response.headers.get('x-sim-private-tool-metadata')).toBe('resolved-secret-names-v1')
|
||||
expect(mockExecuteInIsolatedVM).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -113,8 +113,6 @@ const TAG_PATTERN = createReferencePattern()
|
||||
const E2B_JS_WRAPPER_LINES = 3
|
||||
const E2B_PYTHON_WRAPPER_LINES = 1
|
||||
const MAX_SANDBOX_OUTPUT_FILES = 20
|
||||
const MAX_PRIVATE_RESOLVED_SECRET_NAMES = 10_000
|
||||
const MAX_PRIVATE_RESOLVED_SECRET_NAMES_BYTES = 1024 * 1024
|
||||
const MAX_PRIVATE_FILE_SECRET_MATCH_EVENTS = 1_000_000
|
||||
const SANDBOX_RUNTIME_PAYLOAD_PATH_ENV = '__SIM_RUNTIME_PAYLOAD_PATH'
|
||||
|
||||
@@ -984,12 +982,10 @@ interface FunctionRouteExecutionContext {
|
||||
resolvedSecretNames: Set<string>
|
||||
includePrivateResolvedSecretNames: boolean
|
||||
privateResolvedSecretNamesMetadataType?: ResolvedSecretNamesMetadataType
|
||||
outputProvenanceComplete: boolean
|
||||
outputSecretMatcher?: ResolvedSecretMatcher
|
||||
outputSecretNamesByScanLiteral: Map<string, string[]>
|
||||
outputSecretPlaintextsByName: Map<string, string>
|
||||
mountedFileSecretProvenanceScanner?: MountedFileSecretProvenanceScanner
|
||||
hasMountedSandboxFiles: boolean
|
||||
}
|
||||
|
||||
type ResolvedSecretNamesMetadataType =
|
||||
@@ -1007,10 +1003,16 @@ function inspectMountedWorkspaceFileProvenance(
|
||||
): MountedWorkspaceFileProvenanceInspection {
|
||||
const inspection = inspectPrivateSecretProvenanceRequest(headers, body)
|
||||
if (inspection.status === 'unsupported') return { status: 'none' }
|
||||
if (inspection.status !== 'verified' || !isPrivateSecretProvenanceBundleV1(inspection.value)) {
|
||||
return { status: 'invalid' }
|
||||
}
|
||||
if (!inspection.value.complete) {
|
||||
return {
|
||||
status: 'verified',
|
||||
provenance: { version: 1, complete: false, entries: [] },
|
||||
}
|
||||
}
|
||||
if (
|
||||
inspection.status !== 'verified' ||
|
||||
!isPrivateSecretProvenanceBundleV1(inspection.value) ||
|
||||
!inspection.value.complete ||
|
||||
inspection.value.selections.length !== 1 ||
|
||||
inspection.value.selections[0]?.key !== MOUNTED_WORKSPACE_FILES_PROVENANCE_KEY
|
||||
) {
|
||||
@@ -1188,7 +1190,10 @@ function activateOutputSecretProvenance(
|
||||
body: unknown,
|
||||
context: FunctionRouteExecutionContext
|
||||
): void {
|
||||
if (!context.outputSecretMatcher) return
|
||||
if (!context.outputSecretMatcher) {
|
||||
activateCompiledSecretProvenance(context)
|
||||
return
|
||||
}
|
||||
|
||||
const matchedPlaintexts = new Set<string>()
|
||||
const projection = projectResolvedSecretContent(
|
||||
@@ -1200,7 +1205,7 @@ function activateOutputSecretProvenance(
|
||||
}
|
||||
)
|
||||
if (!projection.safe) {
|
||||
context.outputProvenanceComplete = false
|
||||
activateCompiledSecretProvenance(context)
|
||||
return
|
||||
}
|
||||
for (const plaintext of matchedPlaintexts) {
|
||||
@@ -1211,19 +1216,24 @@ function activateOutputSecretProvenance(
|
||||
}
|
||||
|
||||
/**
|
||||
* True when any secret material was in scope for this execution — a mounted environment secret, or
|
||||
* a secret carried by a mounted input file. When false, nothing secret ever reached the sandbox, so
|
||||
* no export of any kind can carry one.
|
||||
*
|
||||
* Mounted bytes are classified from the caller's provenance envelope. Files mounted *without* one
|
||||
* are unclassifiable rather than clean: absence of an envelope is absence of evidence, not evidence
|
||||
* the mount carried nothing. Those fail closed here so the classification can never be stronger
|
||||
* than what the caller actually attested to.
|
||||
* Conservatively activates only secrets whose placeholders were compiled for this invocation.
|
||||
* This fallback is used when the bounded output classifier cannot inspect a result; it never
|
||||
* considers configured-but-unused environment values and never mutates the functional result.
|
||||
*/
|
||||
function activateCompiledSecretProvenance(context: FunctionRouteExecutionContext): void {
|
||||
for (const name of context.outputSecretPlaintextsByName.keys()) {
|
||||
context.resolvedSecretNames.add(name)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this execution compiled a secret placeholder or received a mounted file with verified
|
||||
* secret provenance. Ordinary mounts without a provenance envelope are user data, not evidence that
|
||||
* a Sim secret was resolved in this call.
|
||||
*/
|
||||
function hasSecretMaterialInScope(context: FunctionRouteExecutionContext): boolean {
|
||||
if (context.outputSecretPlaintextsByName.size > 0) return true
|
||||
const scanner = context.mountedFileSecretProvenanceScanner
|
||||
return scanner ? scanner.hasSecrets : context.hasMountedSandboxFiles
|
||||
return context.mountedFileSecretProvenanceScanner?.hasSecrets ?? false
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1268,7 +1278,6 @@ async function getOutputFileSecretProvenance(
|
||||
MAX_PRIVATE_FILE_SECRET_MATCH_EVENTS
|
||||
)
|
||||
} catch {
|
||||
context.outputProvenanceComplete = false
|
||||
return { status: 'unknown' }
|
||||
}
|
||||
|
||||
@@ -1293,17 +1302,8 @@ async function getOutputFileSecretProvenance(
|
||||
}
|
||||
}
|
||||
|
||||
function getPrivateResolvedSecretNames(context: FunctionRouteExecutionContext): string[] | null {
|
||||
if (!context.outputProvenanceComplete) return null
|
||||
if (context.resolvedSecretNames.size > MAX_PRIVATE_RESOLVED_SECRET_NAMES) return null
|
||||
|
||||
const names = Array.from(context.resolvedSecretNames).sort()
|
||||
let bytes = 0
|
||||
for (const name of names) {
|
||||
bytes += Buffer.byteLength(name, 'utf8')
|
||||
if (bytes > MAX_PRIVATE_RESOLVED_SECRET_NAMES_BYTES) return null
|
||||
}
|
||||
return names
|
||||
function getPrivateResolvedSecretNames(context: FunctionRouteExecutionContext): string[] {
|
||||
return Array.from(context.resolvedSecretNames).sort()
|
||||
}
|
||||
|
||||
async function appendResolvedSecretNames(
|
||||
@@ -1327,21 +1327,17 @@ async function appendPrivateResolvedSecretNames(
|
||||
): Promise<NextResponse> {
|
||||
if (!names || !metadataType) return response
|
||||
|
||||
try {
|
||||
const body = (await response.clone().json()) as Record<string, unknown>
|
||||
const headers = new Headers(response.headers)
|
||||
headers.delete('content-length')
|
||||
headers.set(PRIVATE_TOOL_METADATA_RESPONSE_HEADER, metadataType)
|
||||
return NextResponse.json(
|
||||
{
|
||||
...body,
|
||||
[RESOLVED_SECRET_NAMES_FIELD]: names,
|
||||
},
|
||||
{ status: response.status, statusText: response.statusText, headers }
|
||||
)
|
||||
} catch {
|
||||
return response
|
||||
}
|
||||
const body = (await response.json()) as Record<string, unknown>
|
||||
const headers = new Headers(response.headers)
|
||||
headers.delete('content-length')
|
||||
headers.set(PRIVATE_TOOL_METADATA_RESPONSE_HEADER, metadataType)
|
||||
return NextResponse.json(
|
||||
{
|
||||
...body,
|
||||
[RESOLVED_SECRET_NAMES_FIELD]: names,
|
||||
},
|
||||
{ status: response.status, statusText: response.statusText, headers }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2026,33 +2022,9 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
|
||||
resolvedSecretNames: new Set<string>(),
|
||||
includePrivateResolvedSecretNames,
|
||||
privateResolvedSecretNamesMetadataType,
|
||||
outputProvenanceComplete: true,
|
||||
outputSecretNamesByScanLiteral: new Map(),
|
||||
outputSecretPlaintextsByName: new Map(),
|
||||
mountedFileSecretProvenanceScanner,
|
||||
hasMountedSandboxFiles: (_sandboxFiles?.length ?? 0) > 0,
|
||||
}
|
||||
for (const [name, plaintext] of Object.entries(envVars)) {
|
||||
if (!plaintext) continue
|
||||
routeContext.outputSecretPlaintextsByName.set(name, plaintext)
|
||||
const scanLiterals = new Set([plaintext, JSON.stringify(plaintext).slice(1, -1)])
|
||||
for (const scanLiteral of scanLiterals) {
|
||||
const names = routeContext.outputSecretNamesByScanLiteral.get(scanLiteral) ?? []
|
||||
names.push(name)
|
||||
routeContext.outputSecretNamesByScanLiteral.set(scanLiteral, names)
|
||||
}
|
||||
}
|
||||
if (routeContext.outputSecretNamesByScanLiteral.size > 0) {
|
||||
try {
|
||||
routeContext.outputSecretMatcher = createResolvedSecretMatcher(
|
||||
[...routeContext.outputSecretNamesByScanLiteral].map(([plaintext, names]) => ({
|
||||
plaintext,
|
||||
replacement: `{{${names[0]}}}`,
|
||||
}))
|
||||
)
|
||||
} catch {
|
||||
routeContext.outputProvenanceComplete = false
|
||||
}
|
||||
}
|
||||
|
||||
const lang = isValidCodeLanguage(language) ? language : DEFAULT_CODE_LANGUAGE
|
||||
@@ -2081,6 +2053,30 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
|
||||
environmentVariables: envVars,
|
||||
reservedNames: Object.keys(contextVariables),
|
||||
})
|
||||
for (const name of compilation.resolvedSecretNames) {
|
||||
if (!Object.hasOwn(envVars, name)) continue
|
||||
const plaintext = envVars[name]
|
||||
if (!plaintext) continue
|
||||
routeContext.outputSecretPlaintextsByName.set(name, plaintext)
|
||||
const scanLiterals = new Set([plaintext, JSON.stringify(plaintext).slice(1, -1)])
|
||||
for (const scanLiteral of scanLiterals) {
|
||||
const names = routeContext.outputSecretNamesByScanLiteral.get(scanLiteral) ?? []
|
||||
names.push(name)
|
||||
routeContext.outputSecretNamesByScanLiteral.set(scanLiteral, names)
|
||||
}
|
||||
}
|
||||
if (routeContext.outputSecretNamesByScanLiteral.size > 0) {
|
||||
try {
|
||||
routeContext.outputSecretMatcher = createResolvedSecretMatcher(
|
||||
[...routeContext.outputSecretNamesByScanLiteral].map(([plaintext, names]) => ({
|
||||
plaintext,
|
||||
replacement: `{{${[...names].sort()[0]}}}`,
|
||||
}))
|
||||
)
|
||||
} catch {
|
||||
activateCompiledSecretProvenance(routeContext)
|
||||
}
|
||||
}
|
||||
resolvedCode = compilation.code
|
||||
compilerInternalIdentifiers = [...compilation.internalIdentifiers]
|
||||
compilerPrivateInputs = [...compilation.privateInputs]
|
||||
|
||||
@@ -115,11 +115,11 @@ describe('POST /api/guardrails/validate', () => {
|
||||
},
|
||||
})
|
||||
mockValidateHallucination.mockResolvedValue({ passed: true, score: 8 })
|
||||
mockImportProvenance.mockResolvedValue(true)
|
||||
mockImportProvenance.mockResolvedValue({ success: true, matched: true })
|
||||
mockRegistryIsComplete.mockReturnValue(true)
|
||||
mockPrepareCopilotEnvironmentContext.mockResolvedValue({
|
||||
resolvedSecretTraceRegistry: {
|
||||
importProvenanceForValue: mockImportProvenance,
|
||||
importProvenanceForValueAtInputPath: mockImportProvenance,
|
||||
isComplete: mockRegistryIsComplete,
|
||||
},
|
||||
})
|
||||
@@ -242,7 +242,7 @@ describe('POST /api/guardrails/validate', () => {
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockImportProvenance).toHaveBeenCalledWith(provenance, 'secret value', {
|
||||
expect(mockImportProvenance).toHaveBeenCalledWith(provenance, 'secret value', ['input'], {
|
||||
trusted: true,
|
||||
})
|
||||
})
|
||||
@@ -371,7 +371,7 @@ describe('POST /api/guardrails/validate', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a headerless internal hallucination check before model execution', async () => {
|
||||
it('preserves a headerless legacy internal hallucination check', async () => {
|
||||
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
@@ -388,9 +388,9 @@ describe('POST /api/guardrails/validate', () => {
|
||||
})
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockImportProvenance).not.toHaveBeenCalled()
|
||||
expect(mockValidateHallucination).not.toHaveBeenCalled()
|
||||
expect(mockValidateHallucination).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects invalid internal billing attribution as a protocol error', async () => {
|
||||
|
||||
@@ -257,25 +257,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
if (provenanceInspection.status === 'invalid') {
|
||||
return NextResponse.json({ error: 'Invalid model input provenance' }, { status: 400 })
|
||||
}
|
||||
if (
|
||||
provenanceInspection.status === 'unsupported' &&
|
||||
auth.authType === AuthType.INTERNAL_JWT
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Model input provenance is unavailable' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
if (provenanceInspection.status === 'verified' && auth.authType !== AuthType.INTERNAL_JWT) {
|
||||
return NextResponse.json({ error: 'Invalid model input provenance' }, { status: 400 })
|
||||
}
|
||||
const provenanceReady =
|
||||
provenanceInspection.status === 'verified'
|
||||
? await resolvedSecretTraceRegistry.importProvenanceForValue(
|
||||
provenanceInspection.value,
|
||||
inputStr,
|
||||
{ trusted: true }
|
||||
)
|
||||
? (
|
||||
await resolvedSecretTraceRegistry.importProvenanceForValueAtInputPath(
|
||||
provenanceInspection.value,
|
||||
inputStr,
|
||||
['input'],
|
||||
{ trusted: true }
|
||||
)
|
||||
).success
|
||||
: true
|
||||
if (!provenanceReady || !resolvedSecretTraceRegistry.isComplete()) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -230,7 +230,7 @@ describe('knowledge write secret provenance', () => {
|
||||
if (!result.success) expect(result.response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects an unavailable verified selection before a write can start', () => {
|
||||
it('persists authenticated unavailable selection lineage as unknown', () => {
|
||||
const bundle = {
|
||||
version: 1 as const,
|
||||
complete: true,
|
||||
@@ -257,7 +257,111 @@ describe('knowledge write secret provenance', () => {
|
||||
selectionKeys: ['document-source:0'],
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) expect(result.response.status).toBe(400)
|
||||
expect(result).toEqual({ success: true, provenances: [{ status: 'unknown' }] })
|
||||
})
|
||||
|
||||
it('persists an authenticated incomplete document bundle as unknown', () => {
|
||||
const payload = {
|
||||
[PRIVATE_SECRET_PROVENANCE_FIELD]: {
|
||||
version: 1 as const,
|
||||
complete: false,
|
||||
selections: [],
|
||||
},
|
||||
}
|
||||
|
||||
const result = resolveKnowledgeDocumentWriteSecretProvenance({
|
||||
request: createRequest(payload),
|
||||
payload,
|
||||
authType: AuthType.INTERNAL_JWT,
|
||||
userId: 'user-1',
|
||||
workspaceId: 'workspace-1',
|
||||
documents: [{ documentTagsData: JSON.stringify([{ tagName: 'region', value: 'west' }]) }],
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
provenances: [
|
||||
{
|
||||
filename: { status: 'unknown' },
|
||||
content: { status: 'unknown' },
|
||||
tags: [{ tagName: 'region', provenance: { status: 'unknown' } }],
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps persisted tag names raw while retaining tag-value provenance', () => {
|
||||
const documentTagsData = JSON.stringify([{ tagName: 'private-name', value: 'west' }])
|
||||
const payload = {
|
||||
documents: [{ filename: 'doc.md', documentTagsData }],
|
||||
[PRIVATE_SECRET_PROVENANCE_FIELD]: {
|
||||
version: 1 as const,
|
||||
complete: true,
|
||||
selections: [
|
||||
{
|
||||
key: 'document-filename:0',
|
||||
provenance: {
|
||||
version: 1 as const,
|
||||
complete: true,
|
||||
entries: [],
|
||||
scope: PRIVATE_PROVENANCE_SCOPE,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'document-content:0',
|
||||
provenance: {
|
||||
version: 1 as const,
|
||||
complete: true,
|
||||
entries: [],
|
||||
scope: PRIVATE_PROVENANCE_SCOPE,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'document-tag-value:0:0',
|
||||
provenance: {
|
||||
version: 1 as const,
|
||||
complete: true,
|
||||
entries: [{ name: 'TAG_VALUE', encryptedValue: 'encrypted-tag-value' }],
|
||||
scope: PRIVATE_PROVENANCE_SCOPE,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const result = resolveKnowledgeDocumentWriteSecretProvenance({
|
||||
request: createRequest(payload),
|
||||
payload,
|
||||
authType: AuthType.INTERNAL_JWT,
|
||||
userId: 'user-1',
|
||||
workspaceId: 'workspace-1',
|
||||
documents: [{ documentTagsData }],
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
provenances: [
|
||||
{
|
||||
filename: { status: 'exact', entries: [] },
|
||||
content: { status: 'exact', entries: [] },
|
||||
tags: [
|
||||
{
|
||||
tagName: 'private-name',
|
||||
provenance: {
|
||||
status: 'exact',
|
||||
entries: [
|
||||
{
|
||||
name: 'TAG_VALUE',
|
||||
encryptedValue: 'encrypted-tag-value',
|
||||
sourceUserId: 'user-1',
|
||||
sourceWorkspaceId: 'workspace-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
import {
|
||||
knowledgeDocumentContentSelectionKey,
|
||||
knowledgeDocumentFilenameSelectionKey,
|
||||
knowledgeDocumentTagNameSelectionKey,
|
||||
knowledgeDocumentTagValueSelectionKey,
|
||||
parseKnowledgeDocumentTagProvenanceTargets,
|
||||
} from '@/lib/knowledge/secret-provenance-selection'
|
||||
@@ -59,11 +58,16 @@ export function resolveKnowledgeWriteSecretProvenance(options: {
|
||||
if (inspection.status !== 'verified' || options.authType !== AuthType.INTERNAL_JWT) {
|
||||
return { success: false, response: invalidKnowledgeProvenanceResponse() }
|
||||
}
|
||||
if (
|
||||
!isPrivateSecretProvenanceBundleV1(inspection.value) ||
|
||||
!inspection.value.complete ||
|
||||
inspection.value.selections.length !== options.selectionKeys.length
|
||||
) {
|
||||
if (!isPrivateSecretProvenanceBundleV1(inspection.value)) {
|
||||
return { success: false, response: invalidKnowledgeProvenanceResponse() }
|
||||
}
|
||||
if (!inspection.value.complete) {
|
||||
return {
|
||||
success: true,
|
||||
provenances: options.selectionKeys.map(() => ({ status: 'unknown' })),
|
||||
}
|
||||
}
|
||||
if (inspection.value.selections.length !== options.selectionKeys.length) {
|
||||
return { success: false, response: invalidKnowledgeProvenanceResponse() }
|
||||
}
|
||||
const provenances = options.selectionKeys.map((selectionKey) =>
|
||||
@@ -72,9 +76,7 @@ export function resolveKnowledgeWriteSecretProvenance(options: {
|
||||
...(options.workspaceId ? { workspaceId: options.workspaceId } : {}),
|
||||
})
|
||||
)
|
||||
if (
|
||||
provenances.some((provenance) => provenance === undefined || provenance.status === 'unknown')
|
||||
) {
|
||||
if (provenances.some((provenance) => provenance === undefined)) {
|
||||
return { success: false, response: invalidKnowledgeProvenanceResponse() }
|
||||
}
|
||||
return { success: true, provenances: provenances as DurableSecretProvenance[] }
|
||||
@@ -84,7 +86,7 @@ type KnowledgeDocumentWriteProvenanceResolution =
|
||||
| { success: true; provenances?: KnowledgeDocumentWriteSecretProvenance[] }
|
||||
| { success: false; response: NextResponse }
|
||||
|
||||
/** Resolves field-separated document input provenance and rejects dynamic secret tag names. */
|
||||
/** Resolves provenance for durable document fields; persisted tag names remain raw and untracked. */
|
||||
export function resolveKnowledgeDocumentWriteSecretProvenance(options: {
|
||||
request: NextRequest
|
||||
payload: unknown
|
||||
@@ -99,10 +101,9 @@ export function resolveKnowledgeDocumentWriteSecretProvenance(options: {
|
||||
const selectionKeys = options.documents.flatMap((_document, documentIndex) => [
|
||||
knowledgeDocumentFilenameSelectionKey(documentIndex),
|
||||
knowledgeDocumentContentSelectionKey(documentIndex),
|
||||
...tagTargets[documentIndex].flatMap((_tag, tagIndex) => [
|
||||
knowledgeDocumentTagNameSelectionKey(documentIndex, tagIndex),
|
||||
knowledgeDocumentTagValueSelectionKey(documentIndex, tagIndex),
|
||||
]),
|
||||
...tagTargets[documentIndex].map((_tag, tagIndex) =>
|
||||
knowledgeDocumentTagValueSelectionKey(documentIndex, tagIndex)
|
||||
),
|
||||
])
|
||||
const resolved = resolveKnowledgeWriteSecretProvenance({
|
||||
request: options.request,
|
||||
@@ -122,11 +123,7 @@ export function resolveKnowledgeDocumentWriteSecretProvenance(options: {
|
||||
const content = resolved.provenances[provenanceIndex++]
|
||||
const tagProvenances: KnowledgeDocumentWriteSecretProvenance['tags'][number][] = []
|
||||
for (const tag of tags) {
|
||||
const tagName = resolved.provenances[provenanceIndex++]
|
||||
const tagValue = resolved.provenances[provenanceIndex++]
|
||||
if (tagName.status !== 'exact' || tagName.entries.length > 0) {
|
||||
return { success: false, response: invalidKnowledgeProvenanceResponse() }
|
||||
}
|
||||
tagProvenances.push({ tagName: tag.tagName, provenance: tagValue })
|
||||
}
|
||||
provenances.push({ filename, content, tags: tagProvenances })
|
||||
|
||||
@@ -1008,7 +1008,7 @@ describe('MCP Serve Route', () => {
|
||||
expect(JSON.stringify(body)).not.toContain(RESOLVED_SECRET_PROVENANCE_FIELD)
|
||||
})
|
||||
|
||||
it('fails closed when a successful workflow MCP response omits private provenance', async () => {
|
||||
it('preserves a successful legacy workflow MCP response without private provenance', async () => {
|
||||
dbChainMockFns.limit
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
@@ -1041,9 +1041,8 @@ describe('MCP Serve Route', () => {
|
||||
const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
expect(body.error.message).toBe('Tool execution failed')
|
||||
expect(JSON.stringify(body)).not.toContain('secret-value')
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.result.content[0].text).toBe('"secret-value"')
|
||||
})
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -256,8 +256,7 @@ interface WorkflowExecutionProvenance {
|
||||
|
||||
async function consumeWorkflowExecutionProvenance(
|
||||
response: Response,
|
||||
value: unknown,
|
||||
scope: { userId: string; workspaceId: string }
|
||||
value: unknown
|
||||
): Promise<WorkflowExecutionProvenance> {
|
||||
const inspection = inspectPrivateToolMetadataEnvelope(
|
||||
response.headers,
|
||||
@@ -265,8 +264,7 @@ async function consumeWorkflowExecutionProvenance(
|
||||
RESOLVED_SECRET_PROVENANCE_METADATA_V1
|
||||
)
|
||||
if (inspection.status === 'unsupported') {
|
||||
if (!response.ok) return { value, hasPrivateProvenance: false }
|
||||
throw new Error('MCP workflow execution provenance is unavailable')
|
||||
return { value, hasPrivateProvenance: false }
|
||||
}
|
||||
if (inspection.status === 'invalid' || !isJsonObject(value)) {
|
||||
throw new Error('MCP workflow execution provenance is invalid')
|
||||
@@ -912,10 +910,7 @@ async function handleToolsCall(
|
||||
})
|
||||
|
||||
const rawExecuteResult = await readWorkflowExecutionResult(response, abortSignal.signal)
|
||||
const provenance = await consumeWorkflowExecutionProvenance(response, rawExecuteResult, {
|
||||
userId: actorUserId,
|
||||
workspaceId: wf.workspaceId,
|
||||
})
|
||||
const provenance = await consumeWorkflowExecutionProvenance(response, rawExecuteResult)
|
||||
const executeResult = provenance.value
|
||||
const executeResultObject = isJsonObject(executeResult) ? executeResult : null
|
||||
|
||||
@@ -959,17 +954,12 @@ async function handleToolsCall(
|
||||
: executeResultObject && hasResponseField(executeResultObject, 'output')
|
||||
? executeResultObject.output
|
||||
: executeResult
|
||||
if (!provenance.hasPrivateProvenance) {
|
||||
throw new Error('MCP workflow execution provenance is unavailable')
|
||||
}
|
||||
const projectedToolOutput = await projectWorkflowMcpModelContent(
|
||||
toolOutput,
|
||||
provenance.privateProvenance,
|
||||
{
|
||||
userId: actorUserId,
|
||||
workspaceId: wf.workspaceId,
|
||||
}
|
||||
)
|
||||
const projectedToolOutput = provenance.hasPrivateProvenance
|
||||
? await projectWorkflowMcpModelContent(toolOutput, provenance.privateProvenance, {
|
||||
userId: actorUserId,
|
||||
workspaceId: wf.workspaceId,
|
||||
})
|
||||
: toolOutput
|
||||
const result: CallToolResult = {
|
||||
content: [{ type: 'text', text: serializeToolText(projectedToolOutput) }],
|
||||
isError: executeResultObject?.success === false,
|
||||
|
||||
@@ -9,17 +9,11 @@ const {
|
||||
mockDiscoverServerTools,
|
||||
mockExecuteTool,
|
||||
mockGetExecutionTimeout,
|
||||
mockReadResponseToBufferWithLimit,
|
||||
} = vi.hoisted(() => ({
|
||||
mockCapExecutionTimeoutMs: vi.fn((_policy: number, requested?: number) => requested ?? 0),
|
||||
mockDiscoverServerTools: vi.fn(),
|
||||
mockExecuteTool: vi.fn(),
|
||||
mockGetExecutionTimeout: vi.fn(() => 0),
|
||||
mockReadResponseToBufferWithLimit: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/utils/stream-limits', () => ({
|
||||
readResponseToBufferWithLimit: mockReadResponseToBufferWithLimit,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/mcp/middleware', () => ({
|
||||
@@ -102,47 +96,26 @@ describe('MCP tool execution private secret provenance', () => {
|
||||
vi.clearAllMocks()
|
||||
mockDiscoverServerTools.mockResolvedValue([{ name: 'example_tool', inputSchema: {} }])
|
||||
mockExecuteTool.mockResolvedValue({ content: [{ type: 'text', text: 'ok' }] })
|
||||
mockReadResponseToBufferWithLimit.mockImplementation(async (response: Response) =>
|
||||
Buffer.from(await response.arrayBuffer())
|
||||
)
|
||||
})
|
||||
|
||||
it('returns fail-closed scoped provenance only to an authenticated internal caller', async () => {
|
||||
it('returns provenance activated by this MCP transport call', async () => {
|
||||
mockDiscoverServerTools.mockImplementationOnce(
|
||||
async (
|
||||
_userId: string,
|
||||
_serverId: string,
|
||||
_workspaceId: string,
|
||||
_forceRefresh: boolean,
|
||||
report: (value: unknown) => void
|
||||
recordProvenance?: (provenance: unknown) => void
|
||||
) => {
|
||||
report({
|
||||
recordProvenance?.({
|
||||
version: 1,
|
||||
complete: false,
|
||||
entries: [],
|
||||
complete: true,
|
||||
entries: [{ name: 'MCP_TOKEN', encryptedValue: 'encrypted-mcp-token' }],
|
||||
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
|
||||
})
|
||||
return [{ name: 'example_tool', inputSchema: {} }]
|
||||
}
|
||||
)
|
||||
mockExecuteTool.mockImplementationOnce(
|
||||
async (
|
||||
_userId: string,
|
||||
_serverId: string,
|
||||
_toolCall: unknown,
|
||||
_workspaceId: string,
|
||||
_headers: unknown,
|
||||
report: (value: unknown) => void
|
||||
) => {
|
||||
report({
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [{ name: 'NEW_TOKEN', encryptedValue: 'encrypted-v2' }],
|
||||
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
|
||||
})
|
||||
return { content: [{ type: 'text', text: 'ok' }] }
|
||||
}
|
||||
)
|
||||
const request = createRequest({
|
||||
'x-sim-request-private-tool-metadata': 'resolved-secret-provenance-v1',
|
||||
})
|
||||
@@ -155,10 +128,12 @@ describe('MCP tool execution private secret provenance', () => {
|
||||
)
|
||||
expect(body.__resolvedSecretTraceProvenance).toEqual({
|
||||
version: 1,
|
||||
complete: false,
|
||||
entries: [],
|
||||
complete: true,
|
||||
entries: [{ name: 'MCP_TOKEN', encryptedValue: 'encrypted-mcp-token' }],
|
||||
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
|
||||
})
|
||||
expect(mockDiscoverServerTools.mock.calls[0]?.[4]).toEqual(expect.any(Function))
|
||||
expect(mockExecuteTool.mock.calls[0]?.[5]).toEqual(expect.any(Function))
|
||||
})
|
||||
|
||||
it('does not expose private provenance metadata to a session caller', async () => {
|
||||
@@ -176,10 +151,10 @@ describe('MCP tool execution private secret provenance', () => {
|
||||
expect(mockExecuteTool.mock.calls[0]?.[5]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves the functional response when private provenance cannot be attached', async () => {
|
||||
mockReadResponseToBufferWithLimit.mockRejectedValueOnce(new Error('Response exceeds limit'))
|
||||
it('preserves MCP error status and message when attaching private provenance', async () => {
|
||||
mockExecuteTool.mockResolvedValueOnce({
|
||||
content: [{ type: 'text', text: 'unchanged' }],
|
||||
isError: true,
|
||||
content: [{ type: 'text', text: 'Provider rejected the request' }],
|
||||
})
|
||||
const request = createRequest({
|
||||
'x-sim-request-private-tool-metadata': 'resolved-secret-provenance-v1',
|
||||
@@ -188,10 +163,54 @@ describe('MCP tool execution private secret provenance', () => {
|
||||
const response = await POST(request, {})
|
||||
const body = (await response.json()) as Record<string, unknown>
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(response.headers.get('x-sim-private-tool-metadata')).toBe(
|
||||
'resolved-secret-provenance-v1'
|
||||
)
|
||||
expect(body).toMatchObject({
|
||||
success: false,
|
||||
error: 'Provider rejected the request',
|
||||
__resolvedSecretTraceProvenance: {
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [],
|
||||
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('attaches private provenance without imposing a second functional response limit', async () => {
|
||||
const largeText = 'x'.repeat(10 * 1024 * 1024 + 1)
|
||||
mockExecuteTool.mockResolvedValueOnce({
|
||||
content: [{ type: 'text', text: largeText }],
|
||||
})
|
||||
const request = createRequest({
|
||||
'x-sim-request-private-tool-metadata': 'resolved-secret-provenance-v1',
|
||||
})
|
||||
|
||||
const response = await (async () => {
|
||||
const responseJsonSpy = vi.spyOn(Response.prototype, 'json')
|
||||
try {
|
||||
const result = await POST(request, {})
|
||||
expect(responseJsonSpy).not.toHaveBeenCalled()
|
||||
return result
|
||||
} finally {
|
||||
responseJsonSpy.mockRestore()
|
||||
}
|
||||
})()
|
||||
const body = (await response.json()) as Record<string, unknown>
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.ok).toBe(true)
|
||||
expect(response.headers.has('x-sim-private-tool-metadata')).toBe(false)
|
||||
expect(body).not.toHaveProperty('__resolvedSecretTraceProvenance')
|
||||
expect(response.headers.get('x-sim-private-tool-metadata')).toBe(
|
||||
'resolved-secret-provenance-v1'
|
||||
)
|
||||
expect(body.__resolvedSecretTraceProvenance).toEqual({
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [],
|
||||
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
|
||||
})
|
||||
expect(body).toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
@@ -201,7 +220,7 @@ describe('MCP tool execution private secret provenance', () => {
|
||||
})
|
||||
expect(
|
||||
(body.data as { output: { content: Array<{ text?: unknown }> } }).output.content[0]?.text
|
||||
).toBe('unchanged')
|
||||
).toBe(largeText)
|
||||
})
|
||||
|
||||
it('uses the remaining workflow deadline for trusted internal tool calls', async () => {
|
||||
|
||||
@@ -11,15 +11,13 @@ import {
|
||||
} from '@/lib/billing/core/billing-attribution'
|
||||
import { capExecutionTimeoutMs, getExecutionTimeout } from '@/lib/core/execution-limits'
|
||||
import type { SubscriptionPlan } from '@/lib/core/rate-limiter/types'
|
||||
import { readResponseToBufferWithLimit } from '@/lib/core/utils/stream-limits'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { SIM_VIA_HEADER } from '@/lib/execution/call-chain'
|
||||
import { parseRemainingExecutionDeadlineMs } from '@/lib/execution/execution-deadline-header'
|
||||
import {
|
||||
PRIVATE_TOOL_METADATA_RESPONSE_HEADER,
|
||||
RESOLVED_SECRET_PROVENANCE_FIELD,
|
||||
RESOLVED_SECRET_PROVENANCE_METADATA_V1,
|
||||
requestsPrivateToolMetadata,
|
||||
serializePrivateToolMetadataResponseEnvelope,
|
||||
} from '@/lib/execution/private-tool-metadata'
|
||||
import {
|
||||
mcpBodyReadErrorResponse,
|
||||
@@ -34,7 +32,7 @@ import {
|
||||
type McpToolCall,
|
||||
type McpToolResult,
|
||||
} from '@/lib/mcp/types'
|
||||
import { categorizeError, createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||
import { categorizeError } from '@/lib/mcp/utils'
|
||||
import {
|
||||
assertPermissionsAllowed,
|
||||
McpToolsNotAllowedError,
|
||||
@@ -45,7 +43,6 @@ import {
|
||||
} from '@/executor/utils/resolved-secret-trace-registry'
|
||||
|
||||
const logger = createLogger('McpToolExecutionAPI')
|
||||
const MAX_PRIVATE_MCP_RESPONSE_BYTES = 10 * 1024 * 1024
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -68,33 +65,21 @@ function hasType(prop: unknown): prop is SchemaProperty {
|
||||
return typeof prop === 'object' && prop !== null && 'type' in prop
|
||||
}
|
||||
|
||||
async function attachPrivateProvenance(
|
||||
response: NextResponse,
|
||||
provenance: ResolvedSecretTraceProvenanceAccumulator
|
||||
): Promise<NextResponse> {
|
||||
let payload: Record<string, unknown>
|
||||
try {
|
||||
const body = await readResponseToBufferWithLimit(response.clone(), {
|
||||
maxBytes: MAX_PRIVATE_MCP_RESPONSE_BYTES,
|
||||
label: 'MCP private metadata response',
|
||||
allowNoBodyFallback: true,
|
||||
})
|
||||
const parsed: unknown = JSON.parse(body.toString('utf8'))
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('MCP response is not a JSON object')
|
||||
}
|
||||
payload = parsed as Record<string, unknown>
|
||||
} catch {
|
||||
return response
|
||||
function createToolExecutionResponse(
|
||||
body: Record<string, unknown>,
|
||||
status: number,
|
||||
provenance: ResolvedSecretTraceProvenanceAccumulator | undefined
|
||||
): NextResponse {
|
||||
if (!provenance) {
|
||||
return NextResponse.json(body, { status })
|
||||
}
|
||||
|
||||
const headers = new Headers(response.headers)
|
||||
headers.delete('content-length')
|
||||
headers.set(PRIVATE_TOOL_METADATA_RESPONSE_HEADER, RESOLVED_SECRET_PROVENANCE_METADATA_V1)
|
||||
return NextResponse.json(
|
||||
{ ...payload, [RESOLVED_SECRET_PROVENANCE_FIELD]: provenance.exportProvenance() },
|
||||
{ status: response.status, headers }
|
||||
const envelope = serializePrivateToolMetadataResponseEnvelope(
|
||||
body,
|
||||
RESOLVED_SECRET_PROVENANCE_METADATA_V1,
|
||||
provenance.exportProvenance()
|
||||
)
|
||||
return NextResponse.json(envelope.body, { status, headers: envelope.headers })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,13 +100,22 @@ export const POST = withRouteHandler(
|
||||
resolvedSecretTraceProvenance.record(provenance)
|
||||
}
|
||||
: undefined
|
||||
const response = await (async (): Promise<NextResponse> => {
|
||||
const errorResponse = (message: string, status: number): NextResponse =>
|
||||
createToolExecutionResponse(
|
||||
{ success: false, error: message },
|
||||
status,
|
||||
resolvedSecretTraceProvenance
|
||||
)
|
||||
const successResponse = <T>(data: T, status = 200): NextResponse =>
|
||||
createToolExecutionResponse({ success: true, data }, status, resolvedSecretTraceProvenance)
|
||||
|
||||
return (async (): Promise<NextResponse> => {
|
||||
try {
|
||||
const rawBody = await readMcpJsonBodyWithLimit(request)
|
||||
const parsedBody = mcpToolExecutionBodySchema.safeParse(rawBody)
|
||||
|
||||
if (!parsedBody.success) {
|
||||
return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400)
|
||||
return errorResponse('Invalid request format', 400)
|
||||
}
|
||||
|
||||
const body = parsedBody.data
|
||||
@@ -148,7 +142,7 @@ export const POST = withRouteHandler(
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof McpToolsNotAllowedError) {
|
||||
return createMcpErrorResponse(err, err.message, 403)
|
||||
return errorResponse(err.message, 403)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
@@ -172,11 +166,7 @@ export const POST = withRouteHandler(
|
||||
logger.warn(`[${requestId}] Tool ${toolName} not found on server ${serverId}`, {
|
||||
availableTools: tools.map((t) => t.name),
|
||||
})
|
||||
return createMcpErrorResponse(
|
||||
new Error('Tool not found'),
|
||||
'Tool not found on the specified server',
|
||||
404
|
||||
)
|
||||
return errorResponse('Tool not found on the specified server', 404)
|
||||
}
|
||||
|
||||
if (tool.inputSchema?.properties) {
|
||||
@@ -241,11 +231,7 @@ export const POST = withRouteHandler(
|
||||
const validationError = validateToolArguments(tool, args)
|
||||
if (validationError) {
|
||||
logger.warn(`[${requestId}] Tool validation failed: ${validationError}`)
|
||||
return createMcpErrorResponse(
|
||||
new Error(`Invalid arguments for tool ${toolName}: ${validationError}`),
|
||||
'Invalid tool arguments',
|
||||
400
|
||||
)
|
||||
return errorResponse('Invalid tool arguments', 400)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,11 +303,7 @@ export const POST = withRouteHandler(
|
||||
logger.warn(
|
||||
`[${requestId}] Tool execution returned error for ${toolName} on ${serverId}`
|
||||
)
|
||||
return createMcpErrorResponse(
|
||||
transformedResult,
|
||||
transformedResult.error || 'Tool execution failed',
|
||||
400
|
||||
)
|
||||
return errorResponse(transformedResult.error || 'Tool execution failed', 400)
|
||||
}
|
||||
logger.info(`[${requestId}] Successfully executed tool ${toolName} on server ${serverId}`)
|
||||
|
||||
@@ -342,7 +324,7 @@ export const POST = withRouteHandler(
|
||||
})
|
||||
}
|
||||
|
||||
return createMcpSuccessResponse(transformedResult)
|
||||
return successResponse(transformedResult)
|
||||
} catch (error) {
|
||||
if (getErrorMessage(error) === 'Tool execution timeout') {
|
||||
resolvedSecretTraceProvenance?.markIncomplete()
|
||||
@@ -359,27 +341,24 @@ export const POST = withRouteHandler(
|
||||
logger.warn(`[${requestId}] OAuth re-authorization required for MCP tool execution`, {
|
||||
serverId: errorServerId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
return createToolExecutionResponse(
|
||||
{
|
||||
success: false,
|
||||
error: 'OAuth re-authorization required',
|
||||
code: 'reauth_required',
|
||||
serverId: errorServerId,
|
||||
},
|
||||
{ status: 401 }
|
||||
401,
|
||||
resolvedSecretTraceProvenance
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error executing MCP tool:`, error)
|
||||
|
||||
const { message, status } = categorizeError(error)
|
||||
return createMcpErrorResponse(new Error(message), message, status)
|
||||
return errorResponse(message, status)
|
||||
}
|
||||
})()
|
||||
|
||||
return resolvedSecretTraceProvenance
|
||||
? attachPrivateProvenance(response, resolvedSecretTraceProvenance)
|
||||
: response
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -77,14 +77,19 @@ describe('memory write secret provenance', () => {
|
||||
expect(result).toEqual({ success: true })
|
||||
})
|
||||
|
||||
it('rejects an unavailable verified selection before persistence', () => {
|
||||
it('persists authenticated unavailable selection lineage as unknown', () => {
|
||||
const bundle = {
|
||||
version: 1 as const,
|
||||
complete: true,
|
||||
selections: [
|
||||
{
|
||||
key: 'data',
|
||||
provenance: { version: 1 as const, complete: false, entries: [] },
|
||||
provenance: {
|
||||
version: 1 as const,
|
||||
complete: false,
|
||||
entries: [],
|
||||
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -103,8 +108,32 @@ describe('memory write secret provenance', () => {
|
||||
workspaceId: 'workspace-1',
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) expect(result.response.status).toBe(400)
|
||||
expect(result).toEqual({ success: true, provenance: { status: 'unknown' } })
|
||||
})
|
||||
|
||||
it('persists an authenticated incomplete bundle as unknown', () => {
|
||||
const payload = {
|
||||
[PRIVATE_SECRET_PROVENANCE_FIELD]: {
|
||||
version: 1 as const,
|
||||
complete: false,
|
||||
selections: [],
|
||||
},
|
||||
}
|
||||
const request = new NextRequest('http://localhost/api/memory', {
|
||||
method: 'POST',
|
||||
headers: { [PRIVATE_SECRET_PROVENANCE_HEADER]: PRIVATE_SECRET_PROVENANCE_BUNDLE_V1 },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveMemoryWriteSecretProvenance({
|
||||
request,
|
||||
payload,
|
||||
authType: AuthType.INTERNAL_JWT,
|
||||
userId: 'user-1',
|
||||
workspaceId: 'workspace-1',
|
||||
})
|
||||
).toEqual({ success: true, provenance: { status: 'unknown' } })
|
||||
})
|
||||
|
||||
it('accepts exact-empty provenance from the workflow owner in the actor workspace', () => {
|
||||
|
||||
@@ -57,18 +57,20 @@ export function resolveMemoryWriteSecretProvenance(options: {
|
||||
if (inspection.status !== 'verified' || options.authType !== AuthType.INTERNAL_JWT) {
|
||||
return { success: false, response: invalidMemoryProvenanceResponse() }
|
||||
}
|
||||
if (
|
||||
!isPrivateSecretProvenanceBundleV1(inspection.value) ||
|
||||
!inspection.value.complete ||
|
||||
inspection.value.selections.length !== 1
|
||||
) {
|
||||
if (!isPrivateSecretProvenanceBundleV1(inspection.value)) {
|
||||
return { success: false, response: invalidMemoryProvenanceResponse() }
|
||||
}
|
||||
if (!inspection.value.complete) {
|
||||
return { success: true, provenance: { status: 'unknown' } }
|
||||
}
|
||||
if (inspection.value.selections.length !== 1) {
|
||||
return { success: false, response: invalidMemoryProvenanceResponse() }
|
||||
}
|
||||
const provenance = durableSecretProvenanceFromPrivateBundle(inspection.value, 'data', {
|
||||
userId: options.userId,
|
||||
workspaceId: options.workspaceId,
|
||||
})
|
||||
return provenance?.status === 'exact'
|
||||
return provenance
|
||||
? { success: true, provenance }
|
||||
: { success: false, response: invalidMemoryProvenanceResponse() }
|
||||
}
|
||||
|
||||
@@ -275,12 +275,7 @@ describe('mothership private trace provenance transport', () => {
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockBuildTaggedMcpToolSchemas).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
'workspace-1',
|
||||
['123'],
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(mockBuildTaggedMcpToolSchemas).toHaveBeenCalledWith('user-1', 'workspace-1', ['123'])
|
||||
expect(mockProcessContextsServer).toHaveBeenCalledWith(
|
||||
[
|
||||
{
|
||||
@@ -415,7 +410,7 @@ describe('mothership private trace provenance transport', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('returns encrypted provenance on a marker-gated successful request', async () => {
|
||||
it('returns exact-empty output provenance on a marker-gated successful request', async () => {
|
||||
mockRunHeadlessCopilotLifecycle.mockImplementation(
|
||||
async (_payload: Record<string, unknown>, options: CopilotLifecycleOptions) => {
|
||||
expect(options.environmentContext).not.toHaveProperty('decryptedEnvVars')
|
||||
@@ -447,48 +442,31 @@ describe('mothership private trace provenance transport', () => {
|
||||
expect(body.__resolvedSecretTraceProvenance).toEqual({
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [{ name: 'API_KEY', encryptedValue: 'encrypted-secret' }],
|
||||
entries: [],
|
||||
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
|
||||
})
|
||||
expect(JSON.stringify(body.__resolvedSecretTraceProvenance)).not.toContain('secret-value')
|
||||
expect(mockGetPersonalAndWorkspaceEnv).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('imports only MCP provenance present in the discovered schemas', async () => {
|
||||
const provenance = {
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [
|
||||
{ name: 'API_KEY', encryptedValue: 'encrypted-secret' },
|
||||
{ name: 'UNRELATED', encryptedValue: 'encrypted-unrelated' },
|
||||
],
|
||||
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
|
||||
}
|
||||
mockDecryptSecret.mockImplementation(async (encryptedValue: string) => ({
|
||||
decrypted: encryptedValue === 'encrypted-secret' ? 'secret-value' : 'unrelated-value',
|
||||
}))
|
||||
mockBuildTaggedMcpToolSchemas.mockImplementationOnce(
|
||||
async (
|
||||
_userId: string,
|
||||
_workspaceId: string,
|
||||
_serverIds: string[],
|
||||
report: (value: unknown) => void
|
||||
) => {
|
||||
report(provenance)
|
||||
return [{ name: 'mcp-docs', description: 'Uses secret-value' }]
|
||||
}
|
||||
)
|
||||
it('keeps discovered MCP schemas raw without activating matching configured secrets', async () => {
|
||||
mockBuildTaggedMcpToolSchemas.mockResolvedValueOnce([
|
||||
{ name: 'mcp-docs', description: 'Uses secret-value' },
|
||||
])
|
||||
mockRunHeadlessCopilotLifecycle.mockImplementation(
|
||||
async (payload: Record<string, unknown>, options: CopilotLifecycleOptions) => {
|
||||
const registry =
|
||||
options.environmentContext?.resolvedSecretTraceRegistry ??
|
||||
options.resolvedSecretTraceRegistry
|
||||
expect(registry?.exportProvenance()).toEqual({
|
||||
...provenance,
|
||||
entries: [{ name: 'API_KEY', encryptedValue: 'encrypted-secret' }],
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [],
|
||||
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
|
||||
})
|
||||
expect(JSON.stringify(payload)).not.toContain('encrypted-secret')
|
||||
expect(JSON.stringify(payload)).not.toContain('__resolvedSecretTraceProvenance')
|
||||
expect(payload.mothershipTools).toEqual([
|
||||
{ name: 'mcp-docs', description: 'Uses secret-value' },
|
||||
])
|
||||
return successResult()
|
||||
}
|
||||
)
|
||||
@@ -514,62 +492,15 @@ describe('mothership private trace provenance transport', () => {
|
||||
expect({ status: response.status, provenance: body.__resolvedSecretTraceProvenance }).toEqual({
|
||||
status: 200,
|
||||
provenance: {
|
||||
...provenance,
|
||||
entries: [{ name: 'API_KEY', encryptedValue: 'encrypted-secret' }],
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [],
|
||||
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('omits MCP tools with malformed discovery provenance without poisoning the lifecycle', async () => {
|
||||
mockBuildTaggedMcpToolSchemas.mockImplementationOnce(
|
||||
async (
|
||||
_userId: string,
|
||||
_workspaceId: string,
|
||||
_serverIds: string[],
|
||||
report: (value: unknown) => void
|
||||
) => {
|
||||
report({ version: 1, complete: true, entries: 'invalid' })
|
||||
return []
|
||||
}
|
||||
)
|
||||
mockRunHeadlessCopilotLifecycle.mockImplementation(
|
||||
async (payload: Record<string, unknown>, options: CopilotLifecycleOptions) => {
|
||||
const registry =
|
||||
options.environmentContext?.resolvedSecretTraceRegistry ??
|
||||
options.resolvedSecretTraceRegistry
|
||||
expect(registry?.isComplete()).toBe(true)
|
||||
expect(payload).not.toHaveProperty('mothershipTools')
|
||||
return successResult()
|
||||
}
|
||||
)
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest(
|
||||
'POST',
|
||||
{
|
||||
...requestBody,
|
||||
contexts: [{ kind: 'mcp', label: 'Docs', serverId: 'server-1' }],
|
||||
},
|
||||
{
|
||||
Authorization: 'Bearer internal',
|
||||
'x-sim-billing-attribution': 'billing',
|
||||
'x-sim-request-private-tool-metadata': 'resolved-secret-provenance-v1',
|
||||
},
|
||||
'http://localhost:3000/api/mothership/execute'
|
||||
)
|
||||
)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.__resolvedSecretTraceProvenance).toEqual({
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [],
|
||||
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
|
||||
})
|
||||
})
|
||||
|
||||
it('returns encrypted provenance with marker-gated failures', async () => {
|
||||
it('returns exact-empty provenance for an already projected marker-gated failure', async () => {
|
||||
mockRunHeadlessCopilotLifecycle.mockImplementation(
|
||||
async (_payload: Record<string, unknown>, options: CopilotLifecycleOptions) => {
|
||||
activateSecret(options)
|
||||
@@ -601,12 +532,10 @@ describe('mothership private trace provenance transport', () => {
|
||||
'resolved-secret-provenance-v1'
|
||||
)
|
||||
expect(body.content).toBe('secret-value')
|
||||
expect(body.__resolvedSecretTraceProvenance.entries).toEqual([
|
||||
{ name: 'API_KEY', encryptedValue: 'encrypted-secret' },
|
||||
])
|
||||
expect(body.__resolvedSecretTraceProvenance.entries).toEqual([])
|
||||
})
|
||||
|
||||
it('places encrypted provenance only on the terminal streamed event', async () => {
|
||||
it('places exact-empty provenance only on the terminal streamed event', async () => {
|
||||
mockRunHeadlessCopilotLifecycle.mockImplementation(
|
||||
async (_payload: Record<string, unknown>, options: CopilotLifecycleOptions) => {
|
||||
activateSecret(options)
|
||||
@@ -642,7 +571,7 @@ describe('mothership private trace provenance transport', () => {
|
||||
data: {
|
||||
content: 'secret-value',
|
||||
__resolvedSecretTraceProvenance: {
|
||||
entries: [{ name: 'API_KEY', encryptedValue: 'encrypted-secret' }],
|
||||
entries: [],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
} from '@/lib/workspaces/permissions/utils'
|
||||
import {
|
||||
createIncompleteResolvedSecretTraceRegistry,
|
||||
ResolvedSecretTraceProvenanceAccumulator,
|
||||
type ResolvedSecretTraceRegistry,
|
||||
} from '@/executor/utils/resolved-secret-trace-registry'
|
||||
import type { ChatContext } from '@/stores/panel'
|
||||
@@ -60,7 +59,9 @@ function withPrivateProvenance<T extends Record<string, unknown>>(
|
||||
return {
|
||||
...payload,
|
||||
...(include && registry
|
||||
? { [RESOLVED_SECRET_PROVENANCE_FIELD]: registry.exportProvenance() }
|
||||
? {
|
||||
[RESOLVED_SECRET_PROVENANCE_FIELD]: registry.exportCommittedProvenanceForInputPaths([]),
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
@@ -201,14 +202,6 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
|
||||
activeResolvedSecretTraceRegistry = createIncompleteResolvedSecretTraceRegistry(scope)
|
||||
}
|
||||
resolvedSecretTraceRegistry = activeResolvedSecretTraceRegistry
|
||||
const mcpDiscoveryProvenance = new ResolvedSecretTraceProvenanceAccumulator({
|
||||
userId,
|
||||
workspaceId,
|
||||
})
|
||||
const recordMcpDiscoveryProvenance = (provenance: unknown): void => {
|
||||
mcpDiscoveryProvenance.record(provenance)
|
||||
}
|
||||
|
||||
const effectiveChatId = chatId || generateId()
|
||||
messageId = providedMessageId || generateId()
|
||||
requestId = providedRequestId || generateId()
|
||||
@@ -227,39 +220,15 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
|
||||
const nonMcpAgentMentions = agentMentions?.filter((context) => context.kind !== 'mcp')
|
||||
const userPermission = workspaceAccess.permission
|
||||
const mothershipToolsPromise = Promise.allSettled([
|
||||
buildSelectedMcpToolSchemas(
|
||||
userId,
|
||||
workspaceId,
|
||||
mcpTools ?? [],
|
||||
recordMcpDiscoveryProvenance
|
||||
),
|
||||
buildTaggedMcpToolSchemas(
|
||||
userId,
|
||||
workspaceId,
|
||||
taggedMcpServerIds,
|
||||
recordMcpDiscoveryProvenance
|
||||
),
|
||||
]).then(async (results) => {
|
||||
buildSelectedMcpToolSchemas(userId, workspaceId, mcpTools ?? []),
|
||||
buildTaggedMcpToolSchemas(userId, workspaceId, taggedMcpServerIds),
|
||||
]).then((results) => {
|
||||
const groups = results.map((result) => {
|
||||
if (result.status === 'rejected') throw result.reason
|
||||
return result.value
|
||||
})
|
||||
const byName = new Map(groups.flat().map((tool) => [tool.name, tool]))
|
||||
const tools = [...byName.values()]
|
||||
if (activeResolvedSecretTraceRegistry) {
|
||||
const discoveryRegistry = activeResolvedSecretTraceRegistry.forkForToolInput(tools)
|
||||
const imported = await discoveryRegistry.importProvenanceForValue(
|
||||
mcpDiscoveryProvenance.exportProvenance(),
|
||||
tools,
|
||||
{ trusted: true }
|
||||
)
|
||||
if (!imported || !discoveryRegistry.isComplete()) {
|
||||
reqLogger.warn('Omitting MCP tools with unverifiable secret provenance')
|
||||
return []
|
||||
}
|
||||
activeResolvedSecretTraceRegistry.mergeToolCallRegistry(discoveryRegistry)
|
||||
}
|
||||
return tools
|
||||
return [...byName.values()]
|
||||
})
|
||||
const [workspaceContext, integrationTools, mothershipTools, entitlements, agentContexts] =
|
||||
await Promise.all([
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
*/
|
||||
import { createMockRequest, hybridAuthMockFns } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
PRIVATE_MODEL_INPUT_STATE_HEADER,
|
||||
PROJECTED_MODEL_INPUT_PATHS_V1,
|
||||
} from '@/lib/execution/model-input-provenance'
|
||||
|
||||
const {
|
||||
mockExecuteProviderRequest,
|
||||
@@ -10,18 +14,18 @@ const {
|
||||
mockCheckWorkspaceAccess,
|
||||
mockAuthorizeCredentialUse,
|
||||
mockPrepareCopilotEnvironmentContext,
|
||||
mockCollectProviderModelInputProvenanceValues,
|
||||
mockImportProvenance,
|
||||
mockRegistryIsComplete,
|
||||
mockProjectResolvedSecretModelContent,
|
||||
} = vi.hoisted(() => ({
|
||||
mockExecuteProviderRequest: vi.fn(),
|
||||
mockRequireBillingAttributionHeader: vi.fn(),
|
||||
mockCheckWorkspaceAccess: vi.fn(),
|
||||
mockAuthorizeCredentialUse: vi.fn(),
|
||||
mockPrepareCopilotEnvironmentContext: vi.fn(),
|
||||
mockCollectProviderModelInputProvenanceValues: vi.fn(),
|
||||
mockImportProvenance: vi.fn(),
|
||||
mockRegistryIsComplete: vi.fn(),
|
||||
mockProjectResolvedSecretModelContent: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/providers', () => ({
|
||||
@@ -45,8 +49,8 @@ vi.mock('@/lib/copilot/environment-context', () => ({
|
||||
prepareCopilotEnvironmentContext: mockPrepareCopilotEnvironmentContext,
|
||||
}))
|
||||
|
||||
vi.mock('@/providers/model-input-provenance', () => ({
|
||||
collectProviderModelInputProvenanceValues: mockCollectProviderModelInputProvenanceValues,
|
||||
vi.mock('@/executor/utils/resolved-secret-content-projection', () => ({
|
||||
projectResolvedSecretModelContent: mockProjectResolvedSecretModelContent,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/auth/oauth/utils', () => ({
|
||||
@@ -89,6 +93,7 @@ function createProviderRequest(
|
||||
},
|
||||
{
|
||||
'x-sim-private-model-input-provenance': 'resolved-secret-provenance-v1',
|
||||
[PRIVATE_MODEL_INPUT_STATE_HEADER]: PROJECTED_MODEL_INPUT_PATHS_V1,
|
||||
...headers,
|
||||
}
|
||||
)
|
||||
@@ -109,12 +114,15 @@ describe('POST /api/providers', () => {
|
||||
model: 'gpt-4o',
|
||||
tokens: { input: 1, output: 1, total: 2 },
|
||||
})
|
||||
mockCollectProviderModelInputProvenanceValues.mockReturnValue(['selected-model-input'])
|
||||
mockImportProvenance.mockResolvedValue(true)
|
||||
mockRegistryIsComplete.mockReturnValue(true)
|
||||
mockProjectResolvedSecretModelContent.mockImplementation((value) => ({
|
||||
safe: true,
|
||||
value,
|
||||
}))
|
||||
mockPrepareCopilotEnvironmentContext.mockResolvedValue({
|
||||
resolvedSecretTraceRegistry: {
|
||||
importProvenanceForValue: mockImportProvenance,
|
||||
importProvenance: mockImportProvenance,
|
||||
isComplete: mockRegistryIsComplete,
|
||||
},
|
||||
})
|
||||
@@ -200,9 +208,91 @@ describe('POST /api/providers', () => {
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockImportProvenance).toHaveBeenCalledWith(provenance, expect.any(Array), {
|
||||
trusted: true,
|
||||
expect(mockImportProvenance).toHaveBeenCalledWith(provenance, { trusted: true })
|
||||
})
|
||||
|
||||
it('projects legacy private prompt provenance on the provider-facing copy', async () => {
|
||||
mockProjectResolvedSecretModelContent.mockReturnValue({
|
||||
safe: true,
|
||||
value: {
|
||||
systemPrompt: 'Use {{TOKEN}} safely',
|
||||
context: '[{"role":"user","content":"{{TOKEN}}"}]',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await POST(
|
||||
createMockRequest(
|
||||
'POST',
|
||||
{
|
||||
provider: 'openai',
|
||||
model: 'gpt-4o',
|
||||
workspaceId: 'ws-1',
|
||||
systemPrompt: 'Use secret-value safely',
|
||||
context: '[{"role":"user","content":"secret-value"}]',
|
||||
__resolvedSecretTraceProvenance: { version: 1, complete: true, entries: [] },
|
||||
},
|
||||
{ 'x-sim-private-model-input-provenance': 'resolved-secret-provenance-v1' }
|
||||
)
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockExecuteProviderRequest).toHaveBeenCalledWith(
|
||||
'openai',
|
||||
expect.objectContaining({
|
||||
systemPrompt: 'Use {{TOKEN}} safely',
|
||||
context: '[{"role":"user","content":"{{TOKEN}}"}]',
|
||||
}),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('does not re-project an explicitly projected private request', async () => {
|
||||
mockProjectResolvedSecretModelContent.mockReturnValue({
|
||||
safe: true,
|
||||
value: { systemPrompt: 'Bo{{TOKEN}}', context: undefined },
|
||||
})
|
||||
|
||||
const res = await POST(
|
||||
createProviderRequest({
|
||||
provider: 'openai',
|
||||
model: 'gpt-4o',
|
||||
workspaceId: 'ws-1',
|
||||
systemPrompt: 'Box',
|
||||
})
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockProjectResolvedSecretModelContent).not.toHaveBeenCalled()
|
||||
expect(mockExecuteProviderRequest).toHaveBeenCalledWith(
|
||||
'openai',
|
||||
expect.objectContaining({ systemPrompt: 'Box' }),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a projected marker without a private provenance envelope', async () => {
|
||||
const res = await POST(
|
||||
createMockRequest(
|
||||
'POST',
|
||||
{ provider: 'openai', model: 'gpt-4o', workspaceId: 'ws-1' },
|
||||
{ [PRIVATE_MODEL_INPUT_STATE_HEADER]: PROJECTED_MODEL_INPUT_PATHS_V1 }
|
||||
)
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(mockExecuteProviderRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an unknown private projection marker', async () => {
|
||||
const res = await POST(
|
||||
createProviderRequest(
|
||||
{ provider: 'openai', model: 'gpt-4o', workspaceId: 'ws-1' },
|
||||
{ [PRIVATE_MODEL_INPUT_STATE_HEADER]: 'unknown-projection' }
|
||||
)
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(mockExecuteProviderRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a partial private provenance envelope', async () => {
|
||||
@@ -218,7 +308,7 @@ describe('POST /api/providers', () => {
|
||||
expect(mockExecuteProviderRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an internal request without the private provenance envelope', async () => {
|
||||
it('preserves legacy internal requests without the private provenance envelope', async () => {
|
||||
const res = await POST(
|
||||
createMockRequest('POST', {
|
||||
provider: 'openai',
|
||||
@@ -227,9 +317,9 @@ describe('POST /api/providers', () => {
|
||||
})
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockImportProvenance).not.toHaveBeenCalled()
|
||||
expect(mockExecuteProviderRequest).not.toHaveBeenCalled()
|
||||
expect(mockExecuteProviderRequest).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('omits provisional stream output from the execution header', async () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { db } from '@sim/db'
|
||||
import { account } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage, toError } from '@sim/utils/errors'
|
||||
import { isPlainRecord } from '@sim/utils/object'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { executeProviderContract } from '@/lib/api/contracts/providers'
|
||||
@@ -16,7 +17,10 @@ import {
|
||||
import { prepareCopilotEnvironmentContext } from '@/lib/copilot/environment-context'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { inspectModelInputProvenanceRequest } from '@/lib/execution/model-input-provenance'
|
||||
import {
|
||||
inspectModelInputProjectionState,
|
||||
inspectModelInputProvenanceRequest,
|
||||
} from '@/lib/execution/model-input-provenance'
|
||||
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||
import {
|
||||
getServiceAccountToken,
|
||||
@@ -30,8 +34,8 @@ import {
|
||||
ProviderNotAllowedError,
|
||||
} from '@/ee/access-control/utils/permission-check'
|
||||
import type { StreamingExecution } from '@/executor/types'
|
||||
import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection'
|
||||
import { executeProviderRequest } from '@/providers'
|
||||
import { collectProviderModelInputProvenanceValues } from '@/providers/model-input-provenance'
|
||||
import { projectStreamingExecutionToByteStream } from '@/providers/stream-pump'
|
||||
import type { ProviderRequest } from '@/providers/types'
|
||||
|
||||
@@ -224,7 +228,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
hasBillingAttribution: !!billingAttribution,
|
||||
})
|
||||
|
||||
const providerRequest: ProviderRequest = {
|
||||
let providerRequest: ProviderRequest = {
|
||||
model,
|
||||
systemPrompt,
|
||||
context,
|
||||
@@ -254,22 +258,54 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
verbosity,
|
||||
}
|
||||
const provenanceInspection = inspectModelInputProvenanceRequest(request.headers, body)
|
||||
if (provenanceInspection.status === 'unsupported') {
|
||||
return NextResponse.json({ error: 'Model input provenance is unavailable' }, { status: 400 })
|
||||
}
|
||||
if (provenanceInspection.status === 'invalid') {
|
||||
const projectionState = inspectModelInputProjectionState(request.headers)
|
||||
if (
|
||||
provenanceInspection.status === 'invalid' ||
|
||||
projectionState === 'invalid' ||
|
||||
(projectionState === 'projected' && provenanceInspection.status !== 'verified')
|
||||
) {
|
||||
return NextResponse.json({ error: 'Invalid model input provenance' }, { status: 400 })
|
||||
}
|
||||
|
||||
const providerRuntimeContext = await prepareCopilotEnvironmentContext(auth.userId, workspaceId)
|
||||
const provenanceReady =
|
||||
await providerRuntimeContext.resolvedSecretTraceRegistry.importProvenanceForValue(
|
||||
provenanceInspection.value,
|
||||
collectProviderModelInputProvenanceValues(providerRequest, provider),
|
||||
{ trusted: true }
|
||||
)
|
||||
if (!provenanceReady || !providerRuntimeContext.resolvedSecretTraceRegistry.isComplete()) {
|
||||
return NextResponse.json({ error: 'Model input provenance is unavailable' }, { status: 400 })
|
||||
if (provenanceInspection.status === 'verified') {
|
||||
const provenanceReady =
|
||||
await providerRuntimeContext.resolvedSecretTraceRegistry.importProvenance(
|
||||
provenanceInspection.value,
|
||||
{ trusted: true }
|
||||
)
|
||||
if (!provenanceReady || !providerRuntimeContext.resolvedSecretTraceRegistry.isComplete()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Model input provenance is unavailable' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (projectionState === 'unmarked') {
|
||||
const projection = projectResolvedSecretModelContent(
|
||||
{ systemPrompt: providerRequest.systemPrompt, context: providerRequest.context },
|
||||
providerRuntimeContext.resolvedSecretTraceRegistry
|
||||
)
|
||||
if (!projection.safe || !isPlainRecord(projection.value)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Model input provenance is unavailable' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const projectedSystemPrompt = projection.value.systemPrompt
|
||||
const projectedContext = projection.value.context
|
||||
if (
|
||||
(projectedSystemPrompt !== undefined && typeof projectedSystemPrompt !== 'string') ||
|
||||
(projectedContext !== undefined && typeof projectedContext !== 'string')
|
||||
) {
|
||||
return NextResponse.json({ error: 'Invalid model input provenance' }, { status: 400 })
|
||||
}
|
||||
providerRequest = {
|
||||
...providerRequest,
|
||||
systemPrompt: projectedSystemPrompt,
|
||||
context: projectedContext,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await executeProviderRequest(provider, providerRequest, providerRuntimeContext)
|
||||
|
||||
@@ -347,6 +347,39 @@ describe('POST /api/tools/file/manage content provenance', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('persists an authenticated file write with unavailable lineage as unknown', async () => {
|
||||
const response = await POST(
|
||||
createMockRequest(
|
||||
'POST',
|
||||
{
|
||||
operation: 'write',
|
||||
workspaceId: 'workspace-1',
|
||||
fileName: 'new.txt',
|
||||
content: 'possibly secret',
|
||||
__privateSecretProvenance: {
|
||||
version: 1,
|
||||
complete: false,
|
||||
selections: [],
|
||||
},
|
||||
},
|
||||
PRIVATE_SECRET_PROVENANCE_HEADER
|
||||
)
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockUploadWorkspaceFile).toHaveBeenCalledWith(
|
||||
'workspace-1',
|
||||
'user-1',
|
||||
Buffer.from('possibly secret'),
|
||||
'new.txt',
|
||||
'text/plain',
|
||||
{
|
||||
folderId: null,
|
||||
secretProvenance: { status: 'unknown' },
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('atomically binds append provenance to the exact predecessor version', async () => {
|
||||
const existing = workspaceFile('file-1')
|
||||
mockResolveWorkspaceFileReference.mockResolvedValue(existing)
|
||||
@@ -487,7 +520,7 @@ describe('POST /api/tools/file/manage content provenance', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a secret-bearing archive before downloading or extracting it', async () => {
|
||||
it('extracts a secret-bearing archive with unknown output provenance', async () => {
|
||||
const zip = new JSZip()
|
||||
zip.file('child.txt', 'secret-value')
|
||||
mockDownloadFileFromStorage.mockResolvedValue(
|
||||
@@ -511,9 +544,19 @@ describe('POST /api/tools/file/manage content provenance', () => {
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.status).toBe(422)
|
||||
expect(mockDownloadFileFromStorage).not.toHaveBeenCalled()
|
||||
expect(mockUploadWorkspaceFile).not.toHaveBeenCalled()
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockDownloadFileFromStorage).toHaveBeenCalledTimes(1)
|
||||
expect(mockUploadWorkspaceFile).toHaveBeenCalledWith(
|
||||
'workspace-1',
|
||||
'user-1',
|
||||
Buffer.from('secret-value'),
|
||||
'child.txt',
|
||||
'text/plain',
|
||||
{
|
||||
folderId: null,
|
||||
secretProvenance: { status: 'unknown' },
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('omits source scope when canonical files have different owners', async () => {
|
||||
|
||||
@@ -330,26 +330,37 @@ function resolveFileMutationSecretProvenance(options: {
|
||||
if (
|
||||
inspection.status !== 'verified' ||
|
||||
options.authType !== AuthType.INTERNAL_JWT ||
|
||||
!isPrivateSecretProvenanceBundleV1(inspection.value) ||
|
||||
!inspection.value.complete ||
|
||||
inspection.value.selections.length !== options.selectionKeys.length
|
||||
!isPrivateSecretProvenanceBundleV1(inspection.value)
|
||||
) {
|
||||
return { success: false, error: 'Invalid file secret provenance' }
|
||||
}
|
||||
|
||||
const destinationScope = { userId: options.userId, workspaceId: options.workspaceId }
|
||||
const provenanceBySelection = new Map<string, WorkspaceFileSecretProvenance>()
|
||||
if (!inspection.value.complete) {
|
||||
for (const selectionKey of options.selectionKeys) {
|
||||
provenanceBySelection.set(selectionKey, { status: 'unknown' })
|
||||
}
|
||||
return { success: true, provenanceBySelection }
|
||||
}
|
||||
if (inspection.value.selections.length !== options.selectionKeys.length) {
|
||||
return { success: false, error: 'Invalid file secret provenance' }
|
||||
}
|
||||
|
||||
const destinationScope = { userId: options.userId, workspaceId: options.workspaceId }
|
||||
for (const selectionKey of options.selectionKeys) {
|
||||
const provenance = durableSecretProvenanceFromPrivateBundle(
|
||||
inspection.value,
|
||||
selectionKey,
|
||||
destinationScope
|
||||
)
|
||||
if (
|
||||
!provenance ||
|
||||
provenance.status === 'unknown' ||
|
||||
provenance.entries.some((entry) => !entry.name || !entry.sourceUserId)
|
||||
) {
|
||||
if (!provenance) {
|
||||
return { success: false, error: 'Invalid file secret provenance' }
|
||||
}
|
||||
if (provenance.status === 'unknown') {
|
||||
provenanceBySelection.set(selectionKey, provenance)
|
||||
continue
|
||||
}
|
||||
if (provenance.entries.some((entry) => !entry.name || !entry.sourceUserId)) {
|
||||
return { success: false, error: 'Invalid file secret provenance' }
|
||||
}
|
||||
provenanceBySelection.set(selectionKey, {
|
||||
@@ -383,7 +394,7 @@ function resolveFileWriteSecretProvenance(options: {
|
||||
})
|
||||
if (!resolution.success || !resolution.provenanceBySelection) return resolution
|
||||
const content = resolution.provenanceBySelection.get('content')
|
||||
if (!content || content.status !== 'exact') {
|
||||
if (!content) {
|
||||
return { success: false, error: 'Invalid file secret provenance' }
|
||||
}
|
||||
return { success: true, contentProvenance: content }
|
||||
@@ -1128,16 +1139,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
targetOwnerUserId: userId,
|
||||
sources: canonicalArchiveSource.concat(selectedArchiveSource),
|
||||
})
|
||||
if (archiveProvenance.status === 'unknown' || archiveProvenance.entries.length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error:
|
||||
'Archive cannot be decompressed because its secret provenance is not exact-empty',
|
||||
},
|
||||
{ status: 422 }
|
||||
)
|
||||
}
|
||||
|
||||
const archiveBuffer = await downloadFileFromStorage(archive, requestId, logger, {
|
||||
maxBytes: MAX_ARCHIVE_BYTES,
|
||||
|
||||
@@ -93,7 +93,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
headers: request.headers,
|
||||
payload: body,
|
||||
isInternalRequest: true,
|
||||
allowLegacyWithoutEnvelope: true,
|
||||
})
|
||||
if (!modelInputProvenance.success) {
|
||||
return errorResponse(modelInputProvenance.error, modelInputProvenance.status)
|
||||
|
||||
@@ -360,6 +360,7 @@ function createInternalProvenanceRequest(
|
||||
useDraftState?: boolean
|
||||
provenance?: typeof WORKFLOW_INPUT_PROVENANCE
|
||||
selectionKey?: string
|
||||
bundleComplete?: boolean
|
||||
includeHeader?: boolean
|
||||
includeField?: boolean
|
||||
} = {}
|
||||
@@ -371,6 +372,7 @@ function createInternalProvenanceRequest(
|
||||
useDraftState,
|
||||
provenance = WORKFLOW_INPUT_PROVENANCE,
|
||||
selectionKey = 'input',
|
||||
bundleComplete = true,
|
||||
includeHeader = true,
|
||||
includeField = true,
|
||||
} = options
|
||||
@@ -387,8 +389,8 @@ function createInternalProvenanceRequest(
|
||||
? {
|
||||
[PRIVATE_SECRET_PROVENANCE_FIELD]: {
|
||||
version: 1,
|
||||
complete: true,
|
||||
selections: [{ key: selectionKey, provenance }],
|
||||
complete: bundleComplete,
|
||||
selections: bundleComplete ? [{ key: selectionKey, provenance }] : [],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
@@ -496,6 +498,9 @@ describe('workflow execute async route', () => {
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
})
|
||||
loggingSessionMockFns.mockWaitForPostExecution.mockReset().mockResolvedValue(undefined)
|
||||
loggingSessionMockFns.mockExportResolvedSecretTraceProvenanceForValue
|
||||
.mockReset()
|
||||
.mockReturnValue({ version: 1, complete: false, entries: [] })
|
||||
mockExecuteWorkflowJob.mockReset().mockResolvedValue({ success: true })
|
||||
encryptionMockFns.mockDecryptSecret.mockReset().mockImplementation(async (value: string) => ({
|
||||
decrypted: value === 'encrypted-token' ? 'secret-value' : 'other-secret',
|
||||
@@ -532,6 +537,26 @@ describe('workflow execute async route', () => {
|
||||
expect(executionOptions.snapshot.input).toEqual({ hello: 'world' })
|
||||
})
|
||||
|
||||
it('runs authenticated incomplete workflow input with incomplete downstream lineage', async () => {
|
||||
configureExecutionCaller(EXECUTION_CALLERS[4])
|
||||
|
||||
const response = await POST(createInternalProvenanceRequest({ bundleComplete: false }), {
|
||||
params: Promise.resolve({ id: 'workflow-1' }),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const executionOptions = mockExecuteWorkflowCore.mock.calls[0]?.[0]
|
||||
expect(executionOptions).toMatchObject({
|
||||
trustedInitialResolvedSecretTraceProvenance: {
|
||||
version: 1,
|
||||
complete: false,
|
||||
entries: [],
|
||||
},
|
||||
})
|
||||
expect(executionOptions.snapshot.input).toEqual({ input: { token: 'secret-value' } })
|
||||
expect(executionOptions.snapshot.input).not.toHaveProperty(PRIVATE_SECRET_PROVENANCE_FIELD)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ name: 'standard stream', useDraftState: false },
|
||||
{ name: 'manual event stream', useDraftState: true },
|
||||
@@ -1955,19 +1980,29 @@ describe('workflow execute async route', () => {
|
||||
expect(runFromBlock?.sourceSnapshot).not.toHaveProperty('resolvedSecretTraceProvenance')
|
||||
})
|
||||
|
||||
it('returns encrypted resolution provenance only to an authenticated internal tool caller', async () => {
|
||||
it('exports exact provenance for the final response body to an authenticated internal caller', async () => {
|
||||
const caller = EXECUTION_CALLERS[4]
|
||||
configureExecutionCaller(caller)
|
||||
const provenance = {
|
||||
const runProvenance = {
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [{ name: 'UNRELATED_SECRET', encryptedValue: 'encrypted-unrelated-secret' }],
|
||||
}
|
||||
const responseProvenance = {
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [{ name: 'CHILD_SECRET', encryptedValue: 'encrypted-child-secret' }],
|
||||
}
|
||||
loggingSessionMockFns.mockExportResolvedSecretTraceProvenanceForValue.mockReturnValueOnce(
|
||||
responseProvenance
|
||||
)
|
||||
mockExecuteWorkflowCore.mockResolvedValueOnce({
|
||||
success: true,
|
||||
status: 'completed',
|
||||
output: { ok: true },
|
||||
executionState: { resolvedSecretTraceProvenance: provenance },
|
||||
executionState: {
|
||||
resolvedSecretTraceProvenance: runProvenance,
|
||||
},
|
||||
metadata: {
|
||||
duration: 100,
|
||||
startTime: '2026-01-01T00:00:00Z',
|
||||
@@ -1985,8 +2020,48 @@ describe('workflow execute async route', () => {
|
||||
)
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
output: { ok: true },
|
||||
__resolvedSecretTraceProvenance: provenance,
|
||||
__resolvedSecretTraceProvenance: responseProvenance,
|
||||
})
|
||||
expect(
|
||||
loggingSessionMockFns.mockExportResolvedSecretTraceProvenanceForValue
|
||||
).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
success: true,
|
||||
executionId: 'execution-123',
|
||||
output: { ok: true },
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('includes a thrown execution error in the exact response-provenance boundary', async () => {
|
||||
const caller = EXECUTION_CALLERS[4]
|
||||
configureExecutionCaller(caller)
|
||||
const responseProvenance = {
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [{ name: 'ERROR_SECRET', encryptedValue: 'encrypted-error-secret' }],
|
||||
}
|
||||
loggingSessionMockFns.mockExportResolvedSecretTraceProvenanceForValue.mockReturnValueOnce(
|
||||
responseProvenance
|
||||
)
|
||||
mockExecuteWorkflowCore.mockRejectedValueOnce(new Error('resolved error value'))
|
||||
const request = createCallerExecutionRequest(caller, undefined, 'sync')
|
||||
request.headers.set('x-sim-request-private-tool-metadata', 'resolved-secret-provenance-v1')
|
||||
|
||||
const response = await POST(request, { params: Promise.resolve({ id: 'workflow-1' }) })
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
expect(body).toMatchObject({
|
||||
success: false,
|
||||
error: 'resolved error value',
|
||||
__resolvedSecretTraceProvenance: responseProvenance,
|
||||
})
|
||||
expect(
|
||||
loggingSessionMockFns.mockExportResolvedSecretTraceProvenanceForValue
|
||||
).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ success: false, error: 'resolved error value' })
|
||||
)
|
||||
})
|
||||
|
||||
it('does not expose private provenance metadata to non-internal callers', async () => {
|
||||
|
||||
@@ -157,12 +157,7 @@ import type {
|
||||
IterationContext,
|
||||
SerializableExecutionState,
|
||||
} from '@/executor/execution/types'
|
||||
import type {
|
||||
BlockLog,
|
||||
ExecutionResult,
|
||||
NormalizedBlockOutput,
|
||||
StreamingExecution,
|
||||
} from '@/executor/types'
|
||||
import type { BlockLog, NormalizedBlockOutput, StreamingExecution } from '@/executor/types'
|
||||
import { getExecutionErrorStatus, hasExecutionResult } from '@/executor/utils/errors'
|
||||
import type { ResolvedSecretTraceProvenanceV1 } from '@/executor/utils/resolved-secret-trace-registry'
|
||||
import { Serializer } from '@/serializer'
|
||||
@@ -202,7 +197,7 @@ function createExecutionJsonResponse(
|
||||
body: Record<string, unknown>,
|
||||
init: ResponseInit | undefined,
|
||||
includePrivateProvenance: boolean,
|
||||
result?: ExecutionResult
|
||||
loggingSession?: LoggingSession
|
||||
): NextResponse {
|
||||
if (!includePrivateProvenance) {
|
||||
return NextResponse.json(body, init)
|
||||
@@ -213,11 +208,12 @@ function createExecutionJsonResponse(
|
||||
return NextResponse.json(
|
||||
{
|
||||
...body,
|
||||
[RESOLVED_SECRET_PROVENANCE_FIELD]: result?.executionState?.resolvedSecretTraceProvenance ?? {
|
||||
version: 1,
|
||||
complete: false,
|
||||
entries: [],
|
||||
},
|
||||
[RESOLVED_SECRET_PROVENANCE_FIELD]:
|
||||
loggingSession?.exportResolvedSecretTraceProvenanceForValue(body) ?? {
|
||||
version: 1,
|
||||
complete: false,
|
||||
entries: [],
|
||||
},
|
||||
},
|
||||
{ ...init, headers }
|
||||
)
|
||||
@@ -1583,7 +1579,7 @@ async function handleExecutePost(
|
||||
},
|
||||
{ status: 408 },
|
||||
includePrivateTraceProvenance,
|
||||
result
|
||||
loggingSession
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1654,7 +1650,7 @@ async function handleExecutePost(
|
||||
filteredResult,
|
||||
undefined,
|
||||
includePrivateTraceProvenance,
|
||||
result
|
||||
loggingSession
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
const executionTimedOut = didExecutionTimeOut(error)
|
||||
@@ -1716,7 +1712,7 @@ async function handleExecutePost(
|
||||
},
|
||||
{ status },
|
||||
includePrivateTraceProvenance,
|
||||
executionResult
|
||||
loggingSession
|
||||
)
|
||||
} finally {
|
||||
requestAbort.cleanup()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isPlainRecord } from '@sim/utils/object'
|
||||
import { PackageSearchIcon } from '@/components/icons'
|
||||
import { DEFAULT_RERANKER_MODEL, SUPPORTED_RERANKER_MODELS } from '@/lib/knowledge/reranker-models'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
@@ -377,6 +378,7 @@ export const KnowledgeBlock: BlockConfig = {
|
||||
}
|
||||
},
|
||||
params: (params) => {
|
||||
params = { ...params }
|
||||
const knowledgeBaseId = params.knowledgeBaseId ? String(params.knowledgeBaseId).trim() : ''
|
||||
if (!knowledgeBaseId) {
|
||||
throw new Error('Knowledge base ID is required')
|
||||
@@ -428,6 +430,19 @@ export const KnowledgeBlock: BlockConfig = {
|
||||
params.documentId = String(params.upsertDocumentId).trim()
|
||||
}
|
||||
|
||||
if (
|
||||
(params.operation === 'create_document' || params.operation === 'upsert_document') &&
|
||||
typeof params.documentTags === 'string' &&
|
||||
params.documentTags.trim().length > 0
|
||||
) {
|
||||
try {
|
||||
const documentTags: unknown = JSON.parse(params.documentTags)
|
||||
if (Array.isArray(documentTags) || isPlainRecord(documentTags)) {
|
||||
params.documentTags = documentTags
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Convert enabled dropdown string to boolean for update_chunk
|
||||
if (params.operation === 'update_chunk' && typeof params.enabled === 'string') {
|
||||
params.enabled = params.enabled === 'true'
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { knowledgeBase, workflow, workflowBlocks, workflowDeploymentVersion } from '@sim/db/schema'
|
||||
import {
|
||||
document,
|
||||
knowledgeBase,
|
||||
workflow,
|
||||
workflowBlocks,
|
||||
workflowDeploymentVersion,
|
||||
} from '@sim/db/schema'
|
||||
import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing'
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -269,6 +275,31 @@ describe('cleanup-failed', () => {
|
||||
expect(updates()).toHaveLength(0)
|
||||
expect(mockInvalidateDeployedStateCache).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('attempts every workflow, then reports a workflow-scoped cleanup failure', async () => {
|
||||
queueTableRows(workflowDeploymentVersion, [
|
||||
{ id: 'dv-failed', version: 5, state: versionState('failed-kb') },
|
||||
])
|
||||
queueTableRows(workflowDeploymentVersion, [
|
||||
{ id: 'dv-cleaned', version: 5, state: versionState('failed-kb') },
|
||||
])
|
||||
dbChainMockFns.set.mockImplementationOnce(() => {
|
||||
throw new Error('first workflow update failed')
|
||||
})
|
||||
|
||||
await expect(
|
||||
clearFailedReferencesInDeploymentVersions(
|
||||
new Set(['wf-failed', 'wf-cleaned']),
|
||||
failedByKind(),
|
||||
'test'
|
||||
)
|
||||
).rejects.toThrow('Failed to clear deployment-version references for 1 workflow(s)')
|
||||
|
||||
// The second workflow is still processed after the first workflow's update fails.
|
||||
expect(dbChainMockFns.update).toHaveBeenCalledTimes(2)
|
||||
expect(mockInvalidateDeployedStateCache).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidateDeployedStateCache).toHaveBeenCalledWith('dv-cleaned')
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearFailedForkResourceReferences', () => {
|
||||
@@ -316,6 +347,75 @@ describe('cleanup-failed', () => {
|
||||
expect(deletes()[0].table).toBe(knowledgeBase)
|
||||
})
|
||||
|
||||
it('keeps a failed copied knowledge base when it contains a non-fork document', async () => {
|
||||
queueTableRows(knowledgeBase, [{ id: 'failed-kb' }])
|
||||
|
||||
const cleaned = await clearFailedForkResourceReferences({
|
||||
childWorkspaceId: 'child-ws',
|
||||
failures: [{ kind: 'knowledge-base', childId: 'failed-kb', documentChildIds: [] }],
|
||||
requestId: 'test',
|
||||
})
|
||||
|
||||
expect(cleaned).toEqual({ cleared: 0, clearingFailed: false })
|
||||
expect(dbChainMockFns.update).not.toHaveBeenCalled()
|
||||
expect(dbChainMockFns.delete).not.toHaveBeenCalled()
|
||||
expect(mockInvalidateDeployedStateCache).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears only failed fork-document references when a user document keeps the KB alive', async () => {
|
||||
queueTableRows(knowledgeBase, [{ id: 'failed-kb' }])
|
||||
queueTableRows(workflow, [{ id: 'wf-1' }])
|
||||
queueTableRows(workflowBlocks, [
|
||||
{
|
||||
...draftBlockRow('failed-kb'),
|
||||
subBlocks: {
|
||||
...draftBlockRow('failed-kb').subBlocks,
|
||||
documentId: {
|
||||
id: 'documentId',
|
||||
type: 'document-selector',
|
||||
value: 'fork_document_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const cleaned = await clearFailedForkResourceReferences({
|
||||
childWorkspaceId: 'child-ws',
|
||||
failures: [
|
||||
{
|
||||
kind: 'knowledge-base',
|
||||
childId: 'failed-kb',
|
||||
documentChildIds: ['fork_document_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'],
|
||||
},
|
||||
],
|
||||
requestId: 'test',
|
||||
})
|
||||
|
||||
expect(cleaned).toEqual({ cleared: 1, clearingFailed: false })
|
||||
const cleared = updates()[0].values.subBlocks as Record<string, { value: unknown }>
|
||||
expect(cleared.knowledgeBaseId.value).toBe('failed-kb')
|
||||
expect(cleared.documentId.value).toBe('')
|
||||
expect(deletes().map(({ table }) => table)).toEqual([document])
|
||||
})
|
||||
|
||||
it('guards the final KB delete against a non-fork document inserted during cleanup', async () => {
|
||||
queueTableRows(workflow, [])
|
||||
|
||||
await clearFailedForkResourceReferences({
|
||||
childWorkspaceId: 'child-ws',
|
||||
failures: [{ kind: 'knowledge-base', childId: 'failed-kb', documentChildIds: [] }],
|
||||
requestId: 'test',
|
||||
})
|
||||
|
||||
const deletePredicate = dbChainMockFns.where.mock.calls.at(-1)?.[0]
|
||||
expect(deletePredicate).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'and',
|
||||
conditions: expect.arrayContaining([expect.objectContaining({ type: 'notExists' })]),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('sweeps a deployed target version even when no draft referenced the failed id', async () => {
|
||||
// Draft is clean (other-kb), but a deployed target version still points at the dropped
|
||||
// placeholder - the deployed-target scope (not draft divergence) catches it.
|
||||
@@ -376,5 +476,26 @@ describe('cleanup-failed', () => {
|
||||
// The drop is skipped, so the placeholder row survives (no delete issued).
|
||||
expect(dbChainMockFns.delete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps placeholders when a deployed-version cleanup fails after draft cleanup succeeds', async () => {
|
||||
queueTableRows(workflow, [{ id: 'wf-1' }])
|
||||
queueTableRows(workflowBlocks, [draftBlockRow('other-kb')])
|
||||
queueTableRows(workflowDeploymentVersion, [
|
||||
{ id: 'dv-failed', version: 5, state: versionState('failed-kb') },
|
||||
])
|
||||
dbChainMockFns.set.mockImplementationOnce(() => {
|
||||
throw new Error('deployment update failed')
|
||||
})
|
||||
|
||||
const cleaned = await clearFailedForkResourceReferences({
|
||||
childWorkspaceId: 'child-ws',
|
||||
failures: [{ kind: 'knowledge-base', childId: 'failed-kb', documentChildIds: [] }],
|
||||
deployedTargetWorkflowIds: ['wf-deployed'],
|
||||
requestId: 'test',
|
||||
})
|
||||
|
||||
expect(cleaned).toEqual({ cleared: 0, clearingFailed: true })
|
||||
expect(dbChainMockFns.delete).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,10 +9,13 @@ import {
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { and, asc, eq, gt, inArray } from 'drizzle-orm'
|
||||
import { and, asc, eq, exists, gt, inArray, isNull, notExists, sql } from 'drizzle-orm'
|
||||
import { isRecord, type SubBlockRecord } from '@/lib/workflows/persistence/remap-internal-ids'
|
||||
import { invalidateDeployedStateCache } from '@/lib/workflows/persistence/utils'
|
||||
import type { ForkFailedResource } from '@/ee/workspace-forking/lib/copy/copy-resources'
|
||||
import {
|
||||
FORK_DOCUMENT_ID_PATTERN,
|
||||
type ForkFailedResource,
|
||||
} from '@/ee/workspace-forking/lib/copy/copy-resources'
|
||||
import type { ForkCopyResolver } from '@/ee/workspace-forking/lib/remap/fork-bootstrap'
|
||||
import {
|
||||
clearDependentsOnRemap,
|
||||
@@ -28,6 +31,30 @@ const WORKFLOW_PAGE = 200
|
||||
/** Deployment versions loaded per page so a workflow with many versions never loads all at once. */
|
||||
const DEPLOYMENT_VERSION_PAGE = 100
|
||||
|
||||
async function findKnowledgeBasesWithNonForkDocuments(ids: string[]): Promise<Set<string>> {
|
||||
if (ids.length === 0) return new Set()
|
||||
const rows = await db
|
||||
.select({ id: knowledgeBase.id })
|
||||
.from(knowledgeBase)
|
||||
.where(and(inArray(knowledgeBase.id, ids), exists(liveNonForkDocumentQuery())))
|
||||
.limit(ids.length)
|
||||
return new Set(rows.map(({ id }) => id))
|
||||
}
|
||||
|
||||
function liveNonForkDocumentQuery() {
|
||||
return db
|
||||
.select({ id: document.id })
|
||||
.from(document)
|
||||
.where(
|
||||
and(
|
||||
eq(document.knowledgeBaseId, knowledgeBase.id),
|
||||
sql`${document.id} !~ ${FORK_DOCUMENT_ID_PATTERN}`,
|
||||
isNull(document.deletedAt),
|
||||
isNull(document.archivedAt)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** Identity-or-clear resolver: a failed id resolves to null (cleared), any other id to itself. */
|
||||
function buildFailedResolver(failedByKind: Map<ForkRemapKind, Set<string>>): ForkCopyResolver {
|
||||
return (kind, id) => (failedByKind.get(kind)?.has(id) ? null : id)
|
||||
@@ -77,12 +104,9 @@ function clearFailedSubBlockReferences(
|
||||
* is the count of failed resources whose references were cleared.
|
||||
*
|
||||
* Storage accounting: this cleanup never decrements storage usage because it never removes
|
||||
* anything that was counted. Copied file blobs are the only counted copies (incremented in
|
||||
* `executeForkFileBlobCopies` only after the blob lands), and a failed file's blob never
|
||||
* landed - its metadata row is intentionally left re-uploadable, and nothing was charged. The
|
||||
* dropped table/KB/document placeholders are DB rows the upload path never counts, and any KB
|
||||
* blobs copied before their KB failed are left in storage (rows only are dropped here) but
|
||||
* uncounted - mirroring the KB upload path, which never counts KB blobs.
|
||||
* anything that remains counted. A failed file copy is not charged and leaves its metadata row
|
||||
* re-uploadable. A failed KB copy reverses its usage and retires its active file-ownership rows
|
||||
* before reaching this cleanup; deterministic blobs remain available for a safe retry.
|
||||
*/
|
||||
export async function clearFailedForkResourceReferences(params: {
|
||||
childWorkspaceId: string
|
||||
@@ -94,6 +118,12 @@ export async function clearFailedForkResourceReferences(params: {
|
||||
const { childWorkspaceId, failures, requestId = 'unknown' } = params
|
||||
if (failures.length === 0) return { cleared: 0, clearingFailed: false }
|
||||
|
||||
const failedKnowledgeBaseIds = failures.flatMap((failure) =>
|
||||
failure.kind === 'knowledge-base' ? [failure.childId] : []
|
||||
)
|
||||
const retainedKnowledgeBaseIds =
|
||||
await findKnowledgeBasesWithNonForkDocuments(failedKnowledgeBaseIds)
|
||||
|
||||
const failedByKind = new Map<ForkRemapKind, Set<string>>()
|
||||
const markFailed = (kind: ForkRemapKind, id: string) => {
|
||||
const set = failedByKind.get(kind)
|
||||
@@ -105,24 +135,43 @@ export async function clearFailedForkResourceReferences(params: {
|
||||
// Standalone documents copied into an already-existing target KB (the doc-into-mapped-KB sync
|
||||
// path) - dropped individually, since their KB is not ours to remove.
|
||||
const docIds: string[] = []
|
||||
let cleanupCount = 0
|
||||
for (const failure of failures) {
|
||||
if (failure.kind === 'table') {
|
||||
markFailed('table', failure.childId)
|
||||
tableIds.push(failure.childId)
|
||||
cleanupCount += 1
|
||||
} else if (failure.kind === 'knowledge-document') {
|
||||
markFailed('knowledge-document', failure.childId)
|
||||
docIds.push(failure.childId)
|
||||
cleanupCount += 1
|
||||
} else if (failure.kind === 'file') {
|
||||
// A failed file blob: clear `file-upload` references to its copied storage key. No row to
|
||||
// drop - the metadata row is left in place so the user can re-upload the missing blob.
|
||||
markFailed('file', failure.childKey)
|
||||
cleanupCount += 1
|
||||
} else {
|
||||
if (retainedKnowledgeBaseIds.has(failure.childId)) {
|
||||
for (const docId of failure.documentChildIds) {
|
||||
markFailed('knowledge-document', docId)
|
||||
docIds.push(docId)
|
||||
}
|
||||
if (failure.documentChildIds.length > 0) cleanupCount += 1
|
||||
logger.warn(
|
||||
`[${requestId}] Keeping a failed copied knowledge base that contains non-fork documents`,
|
||||
{ childWorkspaceId, childKnowledgeBaseId: failure.childId }
|
||||
)
|
||||
continue
|
||||
}
|
||||
markFailed('knowledge-base', failure.childId)
|
||||
for (const docId of failure.documentChildIds) markFailed('knowledge-document', docId)
|
||||
kbIds.push(failure.childId)
|
||||
cleanupCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
if (failedByKind.size === 0) return { cleared: 0, clearingFailed: false }
|
||||
|
||||
// Whether BOTH reference-clear phases completed without throwing. The placeholder drop below is
|
||||
// gated on this: if clearing threw, a workflow (draft or deployed version) may still reference
|
||||
// the failed id, so dropping its placeholder would create a dangling reference to a deleted row.
|
||||
@@ -185,7 +234,9 @@ export async function clearFailedForkResourceReferences(params: {
|
||||
await db.delete(userTableDefinitions).where(inArray(userTableDefinitions.id, tableIds))
|
||||
}
|
||||
if (kbIds.length > 0) {
|
||||
await db.delete(knowledgeBase).where(inArray(knowledgeBase.id, kbIds))
|
||||
await db
|
||||
.delete(knowledgeBase)
|
||||
.where(and(inArray(knowledgeBase.id, kbIds), notExists(liveNonForkDocumentQuery())))
|
||||
}
|
||||
if (docIds.length > 0) {
|
||||
await db.delete(document).where(inArray(document.id, docIds))
|
||||
@@ -197,7 +248,7 @@ export async function clearFailedForkResourceReferences(params: {
|
||||
})
|
||||
}
|
||||
|
||||
return { cleared: failures.length, clearingFailed: false }
|
||||
return { cleared: cleanupCount, clearingFailed: false }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -304,6 +355,9 @@ export function rewriteDeploymentVersionState(
|
||||
* no-op. After a version is rewritten its cached deployed state is evicted so execute/serve rebuilds
|
||||
* from the cleaned snapshot. Bounded work (no long transaction): per-version short UPDATEs, versions
|
||||
* keyset-paginated, and a per-workflow failure is logged without aborting the other workflows.
|
||||
* After every workflow has been attempted, any failures are reported to the caller so it can keep
|
||||
* the failed resource placeholders in place rather than deleting rows a deployed version may still
|
||||
* reference.
|
||||
*/
|
||||
export async function clearFailedReferencesInDeploymentVersions(
|
||||
workflowIds: ReadonlySet<string>,
|
||||
@@ -312,6 +366,7 @@ export async function clearFailedReferencesInDeploymentVersions(
|
||||
): Promise<void> {
|
||||
if (workflowIds.size === 0) return
|
||||
const resolve = buildFailedResolver(failedByKind)
|
||||
const failures: unknown[] = []
|
||||
|
||||
for (const workflowId of workflowIds) {
|
||||
try {
|
||||
@@ -355,10 +410,18 @@ export async function clearFailedReferencesInDeploymentVersions(
|
||||
afterVersion = versions[versions.length - 1].version
|
||||
}
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
logger.error(`[${requestId}] Failed to clear references in deployment versions`, {
|
||||
workflowId,
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(
|
||||
failures,
|
||||
`Failed to clear deployment-version references for ${failures.length} workflow(s)`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,13 +14,26 @@ import {
|
||||
userTableRowSecretProvenance,
|
||||
userTableRows,
|
||||
workflowMcpServer,
|
||||
workspaceFiles,
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { sha256Hex } from '@sim/security/hash'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { omit } from '@sim/utils/object'
|
||||
import { and, asc, eq, gt, inArray, isNotNull, isNull, type SQL, sql } from 'drizzle-orm'
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
eq,
|
||||
exists,
|
||||
gt,
|
||||
inArray,
|
||||
isNotNull,
|
||||
isNull,
|
||||
or,
|
||||
type SQL,
|
||||
sql,
|
||||
} from 'drizzle-orm'
|
||||
import {
|
||||
decrementStorageUsageForBillingContextInTx,
|
||||
incrementStorageUsageForBillingContextInTx,
|
||||
@@ -29,7 +42,10 @@ import {
|
||||
} from '@/lib/billing/storage'
|
||||
import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import type { DurableSecretProvenance } from '@/lib/execution/durable-secret-provenance'
|
||||
import {
|
||||
type DurableSecretProvenance,
|
||||
hashDurableSecretProvenanceValue,
|
||||
} from '@/lib/execution/durable-secret-provenance'
|
||||
import {
|
||||
createKnowledgeDocumentSourceValue,
|
||||
type KnowledgeDocumentSourceValue,
|
||||
@@ -50,11 +66,17 @@ import {
|
||||
headObject,
|
||||
uploadFile,
|
||||
} from '@/lib/uploads/core/storage-service'
|
||||
import {
|
||||
type KnowledgeBaseFileOwnership,
|
||||
recordKnowledgeBaseFileOwnership,
|
||||
} from '@/lib/uploads/server/metadata'
|
||||
import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation'
|
||||
import { isRecord } from '@/lib/workflows/persistence/remap-internal-ids'
|
||||
import type {
|
||||
ForkMappingUpsert,
|
||||
ForkResourceType,
|
||||
import {
|
||||
deleteCopiedResourceMappingsByTargets,
|
||||
type ForkMappingUpsert,
|
||||
type ForkResourceType,
|
||||
persistCopiedResourceMappings,
|
||||
} from '@/ee/workspace-forking/lib/mapping/mapping-store'
|
||||
import type { ForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity'
|
||||
import {
|
||||
@@ -97,6 +119,7 @@ function isForkProvenancePageWithinBudget(sidecars: readonly { entries: unknown
|
||||
* processes one page at a time, so peak concurrency stays at this cap regardless of KB size.
|
||||
*/
|
||||
const KB_DOCUMENT_COPY_CONCURRENCY = 5
|
||||
export const FORK_DOCUMENT_ID_PATTERN = '^fork_document_[0-9a-f]{40}$'
|
||||
|
||||
function deriveCopyIdentity(
|
||||
kind: 'document' | 'embedding',
|
||||
@@ -107,9 +130,50 @@ function deriveCopyIdentity(
|
||||
return `fork_${kind}_${digest}`
|
||||
}
|
||||
|
||||
/** Stable object key so a replay overwrites or reuses the same copied KB blob. */
|
||||
function deriveKbDocumentStorageKey(childDocumentId: string): string {
|
||||
return `kb/fork-${childDocumentId}`
|
||||
/**
|
||||
* Stable legacy key prefix for a copied KB document. New blobs append their content digest so
|
||||
* retries of different source snapshots cannot overwrite one another; the unsuffixed form remains
|
||||
* valid for copies finalized by an older worker during a rolling deployment.
|
||||
*/
|
||||
function deriveKbDocumentStorageKey(childDocumentId: string, contentHash?: string): string {
|
||||
const prefix = `kb/fork-${childDocumentId}`
|
||||
return contentHash ? `${prefix}-${contentHash}` : prefix
|
||||
}
|
||||
|
||||
function isKbDocumentStorageKey(key: string, childDocumentId: string): boolean {
|
||||
const prefix = deriveKbDocumentStorageKey(childDocumentId)
|
||||
if (key === prefix) return true
|
||||
if (!key.startsWith(`${prefix}-`)) return false
|
||||
return /^[0-9a-f]{64}$/.test(key.slice(prefix.length + 1))
|
||||
}
|
||||
|
||||
interface TargetDocumentExpectation {
|
||||
childDocumentId: string
|
||||
childKnowledgeBaseId: string
|
||||
}
|
||||
|
||||
interface TargetDocumentState {
|
||||
id: string
|
||||
knowledgeBaseId: string
|
||||
storageKey: string | null
|
||||
archivedAt: Date | null
|
||||
deletedAt: Date | null
|
||||
}
|
||||
|
||||
function validateTargetDocumentState(
|
||||
row: TargetDocumentState,
|
||||
expected: TargetDocumentExpectation
|
||||
): 'active' | 'archived' {
|
||||
if (row.id !== expected.childDocumentId) {
|
||||
throw new Error(`Copied document ${row.id} has an unexpected identity`)
|
||||
}
|
||||
if (row.knowledgeBaseId !== expected.childKnowledgeBaseId || row.deletedAt) {
|
||||
throw new Error(`Copied document ${row.id} has conflicting storage identity`)
|
||||
}
|
||||
if (row.storageKey !== null && !isKbDocumentStorageKey(row.storageKey, row.id)) {
|
||||
throw new Error(`Copied document ${row.id} has conflicting storage`)
|
||||
}
|
||||
return row.archivedAt ? 'archived' : 'active'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,6 +221,13 @@ export interface CopyResourcesParams {
|
||||
* omits it, defaulting to the deterministic derive (a fresh child has no pairs).
|
||||
*/
|
||||
resolveBlockId?: ForkBlockIdResolver
|
||||
/** Canonical fork-edge orientation for document identities completed by the background copy. */
|
||||
documentMappingContext: ForkDocumentMappingContext
|
||||
}
|
||||
|
||||
export interface ForkDocumentMappingContext {
|
||||
edgeChildWorkspaceId: string
|
||||
sourceIsParent: boolean
|
||||
}
|
||||
|
||||
export interface ForkContentPlanEntry {
|
||||
@@ -198,7 +269,10 @@ export interface ForkContentDocumentEntry {
|
||||
sourceDocId: string
|
||||
childDocId: string
|
||||
childKnowledgeBaseId: string
|
||||
/** Source blob fields captured at placeholder time, for the post-commit blob re-key. */
|
||||
/**
|
||||
* Source blob fields retained in the serialized payload for rolling-deploy and queued-job
|
||||
* compatibility. Current workers re-read the live source row before copying it.
|
||||
*/
|
||||
storageKey: string | null
|
||||
fileUrl: string
|
||||
fileSize: number
|
||||
@@ -217,6 +291,11 @@ export interface ForkContentPlan {
|
||||
skills: ForkContentSkillEntry[]
|
||||
/** Documents copied into an already-existing target KB (sync-only; empty at fork create). */
|
||||
documents: ForkContentDocumentEntry[]
|
||||
/**
|
||||
* Optional only so workers deployed during a rollout can still consume already-queued payloads.
|
||||
* Every newly planned fork/sync includes it.
|
||||
*/
|
||||
documentMappingContext?: ForkDocumentMappingContext
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -301,6 +380,7 @@ export async function copyForkResourceContainers(
|
||||
knowledgeBases: [],
|
||||
skills: [],
|
||||
documents: [],
|
||||
documentMappingContext: params.documentMappingContext,
|
||||
}
|
||||
const names: ForkCopiedResourceNames = {
|
||||
tables: [],
|
||||
@@ -739,12 +819,13 @@ export async function planForkMappedKbDocumentCopies(params: {
|
||||
resolver: ForkReferenceResolver
|
||||
referencedDocumentIds: string[]
|
||||
alreadyCopiedSourceDocIds: Set<string>
|
||||
now: Date
|
||||
}): Promise<{
|
||||
documents: ForkContentDocumentEntry[]
|
||||
docIdMap: Map<string, string>
|
||||
mappingEntries: ForkMappingUpsert[]
|
||||
}> {
|
||||
const { tx, resolver, referencedDocumentIds, alreadyCopiedSourceDocIds } = params
|
||||
const { tx, resolver, referencedDocumentIds, alreadyCopiedSourceDocIds, now } = params
|
||||
const documents: ForkContentDocumentEntry[] = []
|
||||
const docIdMap = new Map<string, string>()
|
||||
const mappingEntries: ForkMappingUpsert[] = []
|
||||
@@ -768,41 +849,74 @@ export async function planForkMappedKbDocumentCopies(params: {
|
||||
)
|
||||
)
|
||||
|
||||
const planned = docs.flatMap((doc) => {
|
||||
const targetKbId = resolver('knowledge-base', doc.knowledgeBaseId)
|
||||
if (targetKbId == null) return []
|
||||
return [{ doc, targetKbId, childDocId: deriveCopyIdentity('document', targetKbId, doc.id) }]
|
||||
})
|
||||
const existingTargets =
|
||||
planned.length === 0
|
||||
? []
|
||||
: await tx
|
||||
.select({
|
||||
id: document.id,
|
||||
knowledgeBaseId: document.knowledgeBaseId,
|
||||
storageKey: document.storageKey,
|
||||
archivedAt: document.archivedAt,
|
||||
deletedAt: document.deletedAt,
|
||||
})
|
||||
.from(document)
|
||||
.where(
|
||||
inArray(
|
||||
document.id,
|
||||
planned.map(({ childDocId }) => childDocId)
|
||||
)
|
||||
)
|
||||
const existingTargetById = new Map(existingTargets.map((target) => [target.id, target]))
|
||||
const inserts: (typeof document.$inferInsert)[] = []
|
||||
for (const doc of docs) {
|
||||
for (const { doc, targetKbId, childDocId } of planned) {
|
||||
// The parent KB must already exist in the target. The resolver returns a target KB id only
|
||||
// for a mapped, still-existing KB (validTargetIdsByKind), so this is FK-safe; a doc whose KB
|
||||
// isn't mapped resolves null here and is left for its reference to be cleared.
|
||||
const targetKbId = resolver('knowledge-base', doc.knowledgeBaseId)
|
||||
if (targetKbId == null) continue
|
||||
const childDocId = deriveCopyIdentity('document', targetKbId, doc.id)
|
||||
inserts.push({
|
||||
...doc,
|
||||
id: childDocId,
|
||||
knowledgeBaseId: targetKbId,
|
||||
connectorId: null,
|
||||
storageKey: null,
|
||||
fileUrl: '',
|
||||
fileSize: 0,
|
||||
deletedAt: null,
|
||||
archivedAt: new Date(),
|
||||
})
|
||||
const existingTarget = existingTargetById.get(childDocId)
|
||||
const expectedTarget = {
|
||||
childDocumentId: childDocId,
|
||||
childKnowledgeBaseId: targetKbId,
|
||||
}
|
||||
const existingTargetState = existingTarget
|
||||
? validateTargetDocumentState(existingTarget, expectedTarget)
|
||||
: null
|
||||
if (!existingTarget) {
|
||||
inserts.push({
|
||||
...doc,
|
||||
id: childDocId,
|
||||
knowledgeBaseId: targetKbId,
|
||||
connectorId: null,
|
||||
storageKey: null,
|
||||
fileUrl: '',
|
||||
fileSize: 0,
|
||||
deletedAt: null,
|
||||
archivedAt: now,
|
||||
})
|
||||
}
|
||||
docIdMap.set(doc.id, childDocId)
|
||||
mappingEntries.push({
|
||||
resourceType: 'knowledge_document',
|
||||
parentResourceId: doc.id,
|
||||
childResourceId: childDocId,
|
||||
})
|
||||
documents.push({
|
||||
sourceDocId: doc.id,
|
||||
childDocId,
|
||||
childKnowledgeBaseId: targetKbId,
|
||||
storageKey: doc.storageKey,
|
||||
fileUrl: doc.fileUrl,
|
||||
fileSize: doc.fileSize,
|
||||
filename: doc.filename,
|
||||
mimeType: doc.mimeType,
|
||||
})
|
||||
if (!existingTarget || existingTargetState === 'archived') {
|
||||
documents.push({
|
||||
sourceDocId: doc.id,
|
||||
childDocId,
|
||||
childKnowledgeBaseId: targetKbId,
|
||||
storageKey: doc.storageKey,
|
||||
fileUrl: doc.fileUrl,
|
||||
fileSize: doc.fileSize,
|
||||
filename: doc.filename,
|
||||
mimeType: doc.mimeType,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (inserts.length > 0) await tx.insert(document).values(inserts)
|
||||
return { documents, docIdMap, mappingEntries }
|
||||
@@ -1002,15 +1116,18 @@ export async function copyForkResourceContent(params: {
|
||||
kb.documentIdMap[source.id] ?? deriveCopyIdentity('document', kb.childId, source.id),
|
||||
}))
|
||||
const activeTargetDocumentIds = await getActiveTargetDocumentIds(
|
||||
documentCopies.map(({ childDocumentId }) => childDocumentId)
|
||||
documentCopies.map(({ childDocumentId }) => ({
|
||||
childDocumentId,
|
||||
childKnowledgeBaseId: kb.childId,
|
||||
}))
|
||||
)
|
||||
const documentsToCopy = documentCopies.filter(
|
||||
({ childDocumentId }) => !activeTargetDocumentIds.has(childDocumentId)
|
||||
)
|
||||
// Copy the page's documents with bounded concurrency. The mapper never rejects
|
||||
// (it captures its error), so all in-flight work settles before this resolves - no
|
||||
// orphaned writes survive a failure - and a captured error is rethrown after to keep
|
||||
// the KB ALL-OR-NOTHING (any failed doc fails the whole KB -> cleanup below).
|
||||
// (it captures its error), so all in-flight work settles before this resolves and a
|
||||
// captured error is rethrown after to keep the KB ALL-OR-NOTHING (any failed doc fails
|
||||
// the whole KB -> cleanup below).
|
||||
if (documentsToCopy.length > 0) {
|
||||
const resolvedBillingContext = await getBillingContext()
|
||||
const docErrors = await mapWithConcurrency(
|
||||
@@ -1035,6 +1152,22 @@ export async function copyForkResourceContent(params: {
|
||||
const docError = docErrors.find((error) => error != null)
|
||||
if (docError) throw docError
|
||||
}
|
||||
const mappingContext = contentPlan.documentMappingContext
|
||||
if (mappingContext) {
|
||||
await db.transaction(async (tx) => {
|
||||
await persistCopiedResourceMappings({
|
||||
executor: tx,
|
||||
edgeChildWorkspaceId: mappingContext.edgeChildWorkspaceId,
|
||||
userId,
|
||||
sourceIsParent: mappingContext.sourceIsParent,
|
||||
entries: documentCopies.map(({ source, childDocumentId }) => ({
|
||||
resourceType: 'knowledge_document',
|
||||
parentResourceId: source.id,
|
||||
childResourceId: childDocumentId,
|
||||
})),
|
||||
})
|
||||
})
|
||||
}
|
||||
afterDocId = docs[docs.length - 1].id
|
||||
if (docs.length < CONTENT_PAGE) break
|
||||
}
|
||||
@@ -1052,6 +1185,19 @@ export async function copyForkResourceContent(params: {
|
||||
{ cause: rollbackError }
|
||||
)
|
||||
}
|
||||
if (contentPlan.documentMappingContext) {
|
||||
try {
|
||||
await deleteFailedKnowledgeBaseDocumentMappings(
|
||||
kb.childId,
|
||||
contentPlan.documentMappingContext
|
||||
)
|
||||
} catch (mappingCleanupError) {
|
||||
logger.error(`[${requestId}] Failed to clean mappings for a failed copied KB`, {
|
||||
childKnowledgeBaseId: kb.childId,
|
||||
error: getErrorMessage(mappingCleanupError),
|
||||
})
|
||||
}
|
||||
}
|
||||
failedResources += 1
|
||||
failures.push({
|
||||
kind: 'knowledge-base',
|
||||
@@ -1072,50 +1218,54 @@ export async function copyForkResourceContent(params: {
|
||||
// own documents are never touched.
|
||||
for (const docEntry of contentPlan.documents) {
|
||||
try {
|
||||
const active = await isActiveTargetDocument(docEntry.childDocId)
|
||||
const active = await isActiveTargetDocument({
|
||||
childDocumentId: docEntry.childDocId,
|
||||
childKnowledgeBaseId: docEntry.childKnowledgeBaseId,
|
||||
})
|
||||
if (active) {
|
||||
copiedResources += 1
|
||||
continue
|
||||
}
|
||||
const [source] = await db
|
||||
.select()
|
||||
.from(document)
|
||||
.where(
|
||||
and(
|
||||
eq(document.id, docEntry.sourceDocId),
|
||||
isNull(document.deletedAt),
|
||||
isNull(document.archivedAt)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
if (!source) {
|
||||
throw new Error(`Source document ${docEntry.sourceDocId} is missing`)
|
||||
}
|
||||
const resolvedBillingContext = await getBillingContext()
|
||||
const blob = await copyKbDocumentBlob(
|
||||
{
|
||||
storageKey: docEntry.storageKey,
|
||||
filename: docEntry.filename,
|
||||
mimeType: docEntry.mimeType,
|
||||
},
|
||||
await copyKbDocument({
|
||||
source,
|
||||
childDocumentId: docEntry.childDocId,
|
||||
childKnowledgeBaseId: docEntry.childKnowledgeBaseId,
|
||||
childWorkspaceId,
|
||||
userId,
|
||||
docEntry.childDocId
|
||||
)
|
||||
try {
|
||||
await copyDocumentEmbeddings(
|
||||
docEntry.sourceDocId,
|
||||
docEntry.childDocId,
|
||||
docEntry.childKnowledgeBaseId
|
||||
)
|
||||
await finalizeKbDocument({
|
||||
childDocumentId: docEntry.childDocId,
|
||||
childKnowledgeBaseId: docEntry.childKnowledgeBaseId,
|
||||
billingContext: resolvedBillingContext,
|
||||
bytes: blob ? docEntry.fileSize : 0,
|
||||
values: {
|
||||
knowledgeBaseId: docEntry.childKnowledgeBaseId,
|
||||
connectorId: null,
|
||||
storageKey: blob?.storageKey ?? null,
|
||||
fileUrl: blob?.fileUrl ?? docEntry.fileUrl,
|
||||
fileSize: docEntry.fileSize,
|
||||
archivedAt: null,
|
||||
deletedAt: null,
|
||||
uploadedBy: userId,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (blob) await cleanupCopiedKbBlob(blob.storageKey)
|
||||
throw error
|
||||
}
|
||||
billingContext: resolvedBillingContext,
|
||||
})
|
||||
copiedResources += 1
|
||||
} catch (error) {
|
||||
if (contentPlan.documentMappingContext) {
|
||||
try {
|
||||
await deleteCopiedResourceMappingsByTargets({
|
||||
executor: db,
|
||||
edgeChildWorkspaceId: contentPlan.documentMappingContext.edgeChildWorkspaceId,
|
||||
sourceIsParent: contentPlan.documentMappingContext.sourceIsParent,
|
||||
targets: [{ resourceType: 'knowledge_document', resourceId: docEntry.childDocId }],
|
||||
})
|
||||
} catch (mappingCleanupError) {
|
||||
logger.error(`[${requestId}] Failed to clean mapping for a failed copied document`, {
|
||||
childDocumentId: docEntry.childDocId,
|
||||
error: getErrorMessage(mappingCleanupError),
|
||||
})
|
||||
}
|
||||
}
|
||||
failedResources += 1
|
||||
failures.push({ kind: 'knowledge-document', childId: docEntry.childDocId })
|
||||
logger.warn(`[${requestId}] Failed to copy document into mapped KB during sync`, {
|
||||
@@ -1172,24 +1322,38 @@ export async function copyForkResourceContent(params: {
|
||||
return { copied: copiedResources, failed: failedResources, failures }
|
||||
}
|
||||
|
||||
async function getActiveTargetDocumentIds(childDocumentIds: string[]): Promise<Set<string>> {
|
||||
if (childDocumentIds.length === 0) return new Set()
|
||||
const active = await db
|
||||
.select({ id: document.id })
|
||||
async function getActiveTargetDocumentIds(
|
||||
expectations: TargetDocumentExpectation[]
|
||||
): Promise<Set<string>> {
|
||||
if (expectations.length === 0) return new Set()
|
||||
const expectedById = new Map(expectations.map((expected) => [expected.childDocumentId, expected]))
|
||||
const existing = await db
|
||||
.select({
|
||||
id: document.id,
|
||||
knowledgeBaseId: document.knowledgeBaseId,
|
||||
storageKey: document.storageKey,
|
||||
archivedAt: document.archivedAt,
|
||||
deletedAt: document.deletedAt,
|
||||
})
|
||||
.from(document)
|
||||
.where(
|
||||
and(
|
||||
inArray(document.id, childDocumentIds),
|
||||
isNull(document.deletedAt),
|
||||
isNull(document.archivedAt)
|
||||
inArray(
|
||||
document.id,
|
||||
expectations.map(({ childDocumentId }) => childDocumentId)
|
||||
)
|
||||
)
|
||||
.limit(childDocumentIds.length)
|
||||
return new Set(active.map((row) => row.id))
|
||||
.limit(expectations.length)
|
||||
const activeIds = new Set<string>()
|
||||
for (const row of existing) {
|
||||
const expected = expectedById.get(row.id)
|
||||
if (!expected) throw new Error(`Copied document ${row.id} was not requested`)
|
||||
if (validateTargetDocumentState(row, expected) === 'active') activeIds.add(row.id)
|
||||
}
|
||||
return activeIds
|
||||
}
|
||||
|
||||
async function isActiveTargetDocument(childDocumentId: string): Promise<boolean> {
|
||||
return (await getActiveTargetDocumentIds([childDocumentId])).has(childDocumentId)
|
||||
async function isActiveTargetDocument(expectation: TargetDocumentExpectation): Promise<boolean> {
|
||||
return (await getActiveTargetDocumentIds([expectation])).has(expectation.childDocumentId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1231,19 +1395,21 @@ async function finalizeKbDocument(params: {
|
||||
billingContext: StorageBillingContext
|
||||
bytes: number
|
||||
values: Partial<typeof document.$inferInsert>
|
||||
fileOwnership?: KnowledgeBaseFileOwnership
|
||||
secretProvenance?: DurableSecretProvenance
|
||||
provenanceSource?: KnowledgeDocumentSourceValue
|
||||
}): Promise<void> {
|
||||
}): Promise<string | null> {
|
||||
const {
|
||||
childDocumentId,
|
||||
childKnowledgeBaseId,
|
||||
billingContext,
|
||||
bytes,
|
||||
values,
|
||||
fileOwnership,
|
||||
secretProvenance,
|
||||
provenanceSource,
|
||||
} = params
|
||||
await db.transaction(async (tx) => {
|
||||
return db.transaction(async (tx) => {
|
||||
const [lockedKnowledgeBase] = await tx
|
||||
.select({ workspaceId: knowledgeBase.workspaceId })
|
||||
.from(knowledgeBase)
|
||||
@@ -1257,6 +1423,11 @@ async function finalizeKbDocument(params: {
|
||||
`Copied document knowledge base ${childKnowledgeBaseId} moved from workspace ${billingContext.workspaceId}; refusing stale storage charge`
|
||||
)
|
||||
}
|
||||
if (fileOwnership && fileOwnership.workspaceId !== lockedKnowledgeBase.workspaceId) {
|
||||
throw new Error(
|
||||
`Copied document ${childDocumentId} ownership does not match its knowledge base workspace`
|
||||
)
|
||||
}
|
||||
|
||||
const [activated] = await tx
|
||||
.update(document)
|
||||
@@ -1264,6 +1435,7 @@ async function finalizeKbDocument(params: {
|
||||
.where(
|
||||
and(
|
||||
eq(document.id, childDocumentId),
|
||||
eq(document.knowledgeBaseId, childKnowledgeBaseId),
|
||||
isNull(document.deletedAt),
|
||||
isNotNull(document.archivedAt)
|
||||
)
|
||||
@@ -1272,7 +1444,15 @@ async function finalizeKbDocument(params: {
|
||||
|
||||
if (!activated) {
|
||||
const [active] = await tx
|
||||
.select({ id: document.id })
|
||||
.select({
|
||||
id: document.id,
|
||||
knowledgeBaseId: document.knowledgeBaseId,
|
||||
storageKey: document.storageKey,
|
||||
filename: document.filename,
|
||||
mimeType: document.mimeType,
|
||||
fileSize: document.fileSize,
|
||||
uploadedBy: document.uploadedBy,
|
||||
})
|
||||
.from(document)
|
||||
.where(
|
||||
and(
|
||||
@@ -1282,8 +1462,32 @@ async function finalizeKbDocument(params: {
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
if (active) return
|
||||
throw new Error(`Copied document placeholder ${childDocumentId} is missing`)
|
||||
if (!active) throw new Error(`Copied document placeholder ${childDocumentId} is missing`)
|
||||
if (active.knowledgeBaseId !== childKnowledgeBaseId) {
|
||||
throw new Error(`Copied document ${childDocumentId} has conflicting active storage`)
|
||||
}
|
||||
if (fileOwnership) {
|
||||
const activeStorageKey = active.storageKey
|
||||
if (!activeStorageKey || !isKbDocumentStorageKey(activeStorageKey, childDocumentId)) {
|
||||
throw new Error(`Copied document ${childDocumentId} has conflicting active storage`)
|
||||
}
|
||||
await recordKnowledgeBaseFileOwnership(
|
||||
{
|
||||
key: activeStorageKey,
|
||||
userId: active.uploadedBy ?? fileOwnership.userId,
|
||||
workspaceId: fileOwnership.workspaceId,
|
||||
originalName: active.filename,
|
||||
contentType: active.mimeType,
|
||||
size: active.fileSize,
|
||||
},
|
||||
tx
|
||||
)
|
||||
}
|
||||
return active.storageKey
|
||||
}
|
||||
|
||||
if (fileOwnership) {
|
||||
await recordKnowledgeBaseFileOwnership(fileOwnership, tx)
|
||||
}
|
||||
|
||||
if (secretProvenance && provenanceSource) {
|
||||
@@ -1296,6 +1500,7 @@ async function finalizeKbDocument(params: {
|
||||
}
|
||||
|
||||
await incrementStorageUsageForBillingContextInTx(tx, billingContext, bytes)
|
||||
return fileOwnership?.key ?? null
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1320,51 +1525,77 @@ async function copyKbDocument(params: {
|
||||
userId,
|
||||
billingContext,
|
||||
} = params
|
||||
await ensureKbDocumentPlaceholder(source, childDocumentId, childKnowledgeBaseId, userId)
|
||||
const sourceSecretContext = await loadKnowledgeDocumentDurableSecretProvenance(source.id)
|
||||
const sourceSnapshotHash = hashDurableSecretProvenanceValue(
|
||||
createKnowledgeDocumentSourceValue(source)
|
||||
)
|
||||
const provenanceSnapshotHash = hashDurableSecretProvenanceValue(sourceSecretContext.source)
|
||||
if (!sourceSnapshotHash || sourceSnapshotHash !== provenanceSnapshotHash) {
|
||||
throw new Error(`Knowledge document ${source.id} changed while preparing its fork copy`)
|
||||
}
|
||||
await ensureKbDocumentPlaceholder(source, childDocumentId, childKnowledgeBaseId, userId)
|
||||
|
||||
const blob = await copyKbDocumentBlob(source, childWorkspaceId, userId, childDocumentId)
|
||||
try {
|
||||
await copyDocumentEmbeddings(source.id, childDocumentId, childKnowledgeBaseId)
|
||||
const copiedValues = {
|
||||
...omit(source, ['id', 'knowledgeBaseId']),
|
||||
knowledgeBaseId: childKnowledgeBaseId,
|
||||
connectorId: null,
|
||||
storageKey: blob?.storageKey ?? null,
|
||||
fileUrl: blob?.fileUrl ?? source.fileUrl,
|
||||
archivedAt: null,
|
||||
deletedAt: null,
|
||||
uploadedBy: userId,
|
||||
secretProvenanceVersion: sourceSecretContext.tracked ? 1 : null,
|
||||
await copyDocumentEmbeddings(source.id, childDocumentId, childKnowledgeBaseId)
|
||||
const copiedValues = {
|
||||
...omit(source, ['id', 'knowledgeBaseId']),
|
||||
knowledgeBaseId: childKnowledgeBaseId,
|
||||
connectorId: null,
|
||||
storageKey: blob?.storageKey ?? null,
|
||||
fileUrl: blob?.fileUrl ?? source.fileUrl,
|
||||
archivedAt: null,
|
||||
deletedAt: null,
|
||||
uploadedBy: userId,
|
||||
secretProvenanceVersion: sourceSecretContext.tracked ? 1 : null,
|
||||
}
|
||||
const copiedSource = createKnowledgeDocumentSourceValue(copiedValues)
|
||||
const finalizedStorageKey = await finalizeKbDocument({
|
||||
childDocumentId,
|
||||
childKnowledgeBaseId,
|
||||
billingContext,
|
||||
bytes: blob ? source.fileSize : 0,
|
||||
values: copiedValues,
|
||||
...(blob
|
||||
? {
|
||||
fileOwnership: {
|
||||
key: blob.storageKey,
|
||||
userId,
|
||||
workspaceId: childWorkspaceId,
|
||||
originalName: source.filename,
|
||||
contentType: source.mimeType,
|
||||
size: source.fileSize,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(sourceSecretContext.tracked
|
||||
? {
|
||||
secretProvenance: rebindKnowledgeDocumentSecretProvenance(
|
||||
sourceSecretContext.provenance,
|
||||
sourceSecretContext.source,
|
||||
copiedSource
|
||||
),
|
||||
provenanceSource: copiedSource,
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
if (blob && finalizedStorageKey !== blob.storageKey) {
|
||||
try {
|
||||
await deleteFile({ key: blob.storageKey, context: 'knowledge-base' })
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to remove an unreferenced losing fork document blob`, {
|
||||
childDocumentId,
|
||||
storageKey: blob.storageKey,
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
}
|
||||
const copiedSource = createKnowledgeDocumentSourceValue(copiedValues)
|
||||
await finalizeKbDocument({
|
||||
childDocumentId,
|
||||
childKnowledgeBaseId,
|
||||
billingContext,
|
||||
bytes: blob ? source.fileSize : 0,
|
||||
values: copiedValues,
|
||||
...(sourceSecretContext.tracked
|
||||
? {
|
||||
secretProvenance: rebindKnowledgeDocumentSecretProvenance(
|
||||
sourceSecretContext.provenance,
|
||||
sourceSecretContext.source,
|
||||
copiedSource
|
||||
),
|
||||
provenanceSource: copiedSource,
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
} catch (error) {
|
||||
if (blob) await cleanupCopiedKbBlob(blob.storageKey)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse any documents already activated for a KB when a later document fails,
|
||||
* preserving the existing all-or-nothing KB failure semantics without a long
|
||||
* parent transaction. The aggregate keeps memory bounded regardless of KB size.
|
||||
* parent transaction. Accounting reversal and archival are limited to reserved
|
||||
* deterministic fork identities rather than every document in the target KB.
|
||||
*/
|
||||
async function rollbackCopiedKbDocuments(
|
||||
childKnowledgeBaseId: string,
|
||||
@@ -1389,6 +1620,7 @@ async function rollbackCopiedKbDocuments(
|
||||
.where(
|
||||
and(
|
||||
eq(document.knowledgeBaseId, childKnowledgeBaseId),
|
||||
sql`${document.id} ~ ${FORK_DOCUMENT_ID_PATTERN}`,
|
||||
isNull(document.deletedAt),
|
||||
isNull(document.archivedAt),
|
||||
isNotNull(document.storageKey)
|
||||
@@ -1396,12 +1628,41 @@ async function rollbackCopiedKbDocuments(
|
||||
)
|
||||
const bytes = Number(usage?.total ?? 0)
|
||||
await decrementStorageUsageForBillingContextInTx(tx, billingContext, bytes)
|
||||
await tx
|
||||
.update(workspaceFiles)
|
||||
.set({ deletedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceFiles.workspaceId, childWorkspaceId),
|
||||
eq(workspaceFiles.context, 'knowledge-base'),
|
||||
isNull(workspaceFiles.deletedAt),
|
||||
exists(
|
||||
tx
|
||||
.select({ id: document.id })
|
||||
.from(document)
|
||||
.where(
|
||||
and(
|
||||
eq(document.knowledgeBaseId, childKnowledgeBaseId),
|
||||
sql`${document.id} ~ ${FORK_DOCUMENT_ID_PATTERN}`,
|
||||
isNull(document.deletedAt),
|
||||
isNull(document.archivedAt),
|
||||
eq(document.storageKey, workspaceFiles.key),
|
||||
or(
|
||||
eq(workspaceFiles.key, sql<string>`'kb/fork-' || ${document.id}`),
|
||||
sql`${workspaceFiles.key} ~ ('^kb/fork-' || ${document.id} || '-[0-9a-f]{64}$')`
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
await tx
|
||||
.update(document)
|
||||
.set({ archivedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(document.knowledgeBaseId, childKnowledgeBaseId),
|
||||
sql`${document.id} ~ ${FORK_DOCUMENT_ID_PATTERN}`,
|
||||
isNull(document.deletedAt),
|
||||
isNull(document.archivedAt)
|
||||
)
|
||||
@@ -1409,6 +1670,50 @@ async function rollbackCopiedKbDocuments(
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the identity rows completed for earlier pages of a KB whose later page failed. Target
|
||||
* document ids are keyset-paged from the failed KB, so cleanup never retains the whole KB in app
|
||||
* memory. The target side is selected from the same serialized edge orientation used to write the
|
||||
* mappings.
|
||||
*/
|
||||
async function deleteFailedKnowledgeBaseDocumentMappings(
|
||||
childKnowledgeBaseId: string,
|
||||
mappingContext: ForkDocumentMappingContext
|
||||
): Promise<void> {
|
||||
let afterId: string | null = null
|
||||
for (;;) {
|
||||
const rows = await db
|
||||
.select({ id: document.id })
|
||||
.from(document)
|
||||
.where(
|
||||
afterId == null
|
||||
? and(
|
||||
eq(document.knowledgeBaseId, childKnowledgeBaseId),
|
||||
sql`${document.id} ~ ${FORK_DOCUMENT_ID_PATTERN}`
|
||||
)
|
||||
: and(
|
||||
eq(document.knowledgeBaseId, childKnowledgeBaseId),
|
||||
sql`${document.id} ~ ${FORK_DOCUMENT_ID_PATTERN}`,
|
||||
gt(document.id, afterId)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(document.id))
|
||||
.limit(CONTENT_PAGE)
|
||||
if (rows.length === 0) break
|
||||
await deleteCopiedResourceMappingsByTargets({
|
||||
executor: db,
|
||||
edgeChildWorkspaceId: mappingContext.edgeChildWorkspaceId,
|
||||
sourceIsParent: mappingContext.sourceIsParent,
|
||||
targets: rows.map(({ id }) => ({
|
||||
resourceType: 'knowledge_document' as const,
|
||||
resourceId: id,
|
||||
})),
|
||||
})
|
||||
if (rows.length < CONTENT_PAGE) break
|
||||
afterId = rows[rows.length - 1].id
|
||||
}
|
||||
}
|
||||
|
||||
async function copyDocumentEmbeddings(
|
||||
sourceDocumentId: string,
|
||||
childDocumentId: string,
|
||||
@@ -1488,48 +1793,50 @@ async function copyDocumentEmbeddings(
|
||||
* `verifyKBFileAccess` grants a child-workspace member - without it the copied object is
|
||||
* download-denied (no binding = deny). Returns the new `storageKey` + serve `fileUrl`, or null
|
||||
* when there is no internal blob to copy (external/`data:` docs have a null `storageKey`) or the
|
||||
* copy fails. A stored source blob is required to copy successfully; callers
|
||||
* keep the target placeholder archived and report the existing resource failure.
|
||||
* copy fails. A stored source blob is required to copy successfully; callers keep the target
|
||||
* placeholder archived and report the existing resource failure. The content digest in the key
|
||||
* makes reuse safe for identical retries and prevents a later source snapshot from adopting or
|
||||
* overwriting bytes left by an earlier failed attempt. Ownership is recorded before storage I/O,
|
||||
* matching the presigned-upload lifecycle: successful finalization reuses the immutable binding,
|
||||
* while the existing orphan-binding sweep eventually reclaims an abandoned object or reservation.
|
||||
*/
|
||||
async function copyKbDocumentBlob(
|
||||
doc: { storageKey: string | null; filename: string; mimeType: string },
|
||||
doc: { storageKey: string | null; filename: string; mimeType: string; fileSize: number },
|
||||
childWorkspaceId: string,
|
||||
userId: string,
|
||||
childDocumentId: string
|
||||
): Promise<{ storageKey: string; fileUrl: string } | null> {
|
||||
if (!doc.storageKey) return null
|
||||
const targetKey = deriveKbDocumentStorageKey(childDocumentId)
|
||||
try {
|
||||
const existing = await headObject(targetKey, 'knowledge-base')
|
||||
if (!existing) {
|
||||
const buffer = await downloadFile({
|
||||
key: doc.storageKey,
|
||||
context: 'knowledge-base',
|
||||
maxBytes: MAX_FILE_SIZE,
|
||||
})
|
||||
await uploadFile({
|
||||
file: buffer,
|
||||
fileName: doc.filename,
|
||||
contentType: doc.mimeType,
|
||||
context: 'knowledge-base',
|
||||
customKey: targetKey,
|
||||
preserveKey: true,
|
||||
persistMetadata: false,
|
||||
metadata: {
|
||||
userId,
|
||||
workspaceId: childWorkspaceId,
|
||||
originalName: doc.filename,
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
await cleanupCopiedKbBlob(targetKey)
|
||||
throw error
|
||||
const buffer = await downloadFile({
|
||||
key: doc.storageKey,
|
||||
context: 'knowledge-base',
|
||||
maxBytes: MAX_FILE_SIZE,
|
||||
})
|
||||
const targetKey = deriveKbDocumentStorageKey(childDocumentId, sha256Hex(buffer))
|
||||
await recordKnowledgeBaseFileOwnership({
|
||||
key: targetKey,
|
||||
userId,
|
||||
workspaceId: childWorkspaceId,
|
||||
originalName: doc.filename,
|
||||
contentType: doc.mimeType,
|
||||
size: doc.fileSize,
|
||||
})
|
||||
const existing = await headObject(targetKey, 'knowledge-base')
|
||||
if (!existing) {
|
||||
await uploadFile({
|
||||
file: buffer,
|
||||
fileName: doc.filename,
|
||||
contentType: doc.mimeType,
|
||||
context: 'knowledge-base',
|
||||
customKey: targetKey,
|
||||
preserveKey: true,
|
||||
persistMetadata: false,
|
||||
metadata: {
|
||||
userId,
|
||||
workspaceId: childWorkspaceId,
|
||||
originalName: doc.filename,
|
||||
},
|
||||
})
|
||||
}
|
||||
return { storageKey: targetKey, fileUrl: `/api/files/serve/${encodeURIComponent(targetKey)}` }
|
||||
}
|
||||
|
||||
/** Best-effort orphan cleanup after DB finalization or embedding copy fails. */
|
||||
async function cleanupCopiedKbBlob(storageKey: string): Promise<void> {
|
||||
await deleteFile({ key: storageKey, context: 'knowledge-base' }).catch(() => {})
|
||||
}
|
||||
|
||||
@@ -200,6 +200,14 @@ describe('createFork storage headroom gate', () => {
|
||||
bytes: 500,
|
||||
})
|
||||
expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1)
|
||||
expect(mockCopyForkResourceContainers).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
documentMappingContext: {
|
||||
edgeChildWorkspaceId: result.workspace.id,
|
||||
sourceIsParent: true,
|
||||
},
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('seeds identity mappings for copied FILES by storage key (a later sync must not re-offer them)', async () => {
|
||||
|
||||
@@ -257,6 +257,10 @@ export async function createFork(params: CreateForkParams): Promise<CreateForkRe
|
||||
},
|
||||
workflowIdMap,
|
||||
referencedDocumentIds: Array.from(referencedDocumentIds),
|
||||
documentMappingContext: {
|
||||
edgeChildWorkspaceId: childWorkspaceId,
|
||||
sourceIsParent: true,
|
||||
},
|
||||
})
|
||||
forkedResourceNames = resourceResult.names
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildForkResolver,
|
||||
type ForkMappingRow,
|
||||
type ForkMappingUpsert,
|
||||
orientCopiedResourceMappings,
|
||||
} from '@/ee/workspace-forking/lib/mapping/mapping-store'
|
||||
|
||||
const credentialRow: ForkMappingRow = {
|
||||
@@ -15,6 +17,40 @@ const credentialRow: ForkMappingRow = {
|
||||
childResourceId: 'cred-child',
|
||||
}
|
||||
|
||||
const copiedEntry: ForkMappingUpsert = {
|
||||
resourceType: 'knowledge_document',
|
||||
parentResourceId: 'runtime-source-doc',
|
||||
childResourceId: 'runtime-target-doc',
|
||||
}
|
||||
|
||||
describe('orientCopiedResourceMappings', () => {
|
||||
it('keeps fork/pull source-parent mappings in canonical orientation', () => {
|
||||
expect(orientCopiedResourceMappings(true, [copiedEntry])).toEqual({
|
||||
entries: [copiedEntry],
|
||||
deleteKeys: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('swaps push source-child mappings and removes the prior row keyed by that child', () => {
|
||||
expect(orientCopiedResourceMappings(false, [copiedEntry])).toEqual({
|
||||
entries: [
|
||||
{
|
||||
resourceType: 'knowledge_document',
|
||||
parentResourceId: 'runtime-target-doc',
|
||||
childResourceId: 'runtime-source-doc',
|
||||
},
|
||||
],
|
||||
deleteKeys: [{ resourceType: 'knowledge_document', childResourceId: 'runtime-source-doc' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('does not produce a push mapping for an unmapped null target', () => {
|
||||
expect(
|
||||
orientCopiedResourceMappings(false, [{ ...copiedEntry, childResourceId: null }])
|
||||
).toEqual({ entries: [], deleteKeys: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildForkResolver', () => {
|
||||
it('resolves source->target for a pull (source is parent)', () => {
|
||||
const resolve = buildForkResolver([credentialRow], { sourceIsParent: true })
|
||||
|
||||
@@ -29,6 +29,21 @@ export interface ForkMappingUpsert {
|
||||
childResourceId: string | null
|
||||
}
|
||||
|
||||
export interface PersistCopiedResourceMappingsParams {
|
||||
executor: DbOrTx
|
||||
edgeChildWorkspaceId: string
|
||||
userId: string
|
||||
/** Whether the runtime copy source is the canonical parent side of the fork edge. */
|
||||
sourceIsParent: boolean
|
||||
/** Runtime source -> runtime target identities produced by the copy operation. */
|
||||
entries: ForkMappingUpsert[]
|
||||
}
|
||||
|
||||
export interface OrientedCopiedResourceMappings {
|
||||
entries: ForkMappingUpsert[]
|
||||
deleteKeys: Array<{ resourceType: ForkResourceType; childResourceId: string }>
|
||||
}
|
||||
|
||||
const RESOURCE_TYPE_TO_FORK_KIND: Record<ForkResourceType, ForkRemapKind | null> = {
|
||||
workflow: null,
|
||||
oauth_credential: 'credential',
|
||||
@@ -216,6 +231,56 @@ export async function upsertEdgeMappings(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Orient runtime source -> target copy identities into the edge's canonical parent -> child
|
||||
* storage shape. Pull/fork copies already have that orientation. Push copies run child -> parent,
|
||||
* so their pairs are swapped and the old row keyed by the source-child identity is removed before
|
||||
* the replacement is upserted.
|
||||
*/
|
||||
export function orientCopiedResourceMappings(
|
||||
sourceIsParent: boolean,
|
||||
entries: ForkMappingUpsert[]
|
||||
): OrientedCopiedResourceMappings {
|
||||
if (sourceIsParent) return { entries, deleteKeys: [] }
|
||||
|
||||
const oriented: ForkMappingUpsert[] = []
|
||||
const deleteKeys: OrientedCopiedResourceMappings['deleteKeys'] = []
|
||||
for (const entry of entries) {
|
||||
if (entry.childResourceId == null) continue
|
||||
oriented.push({
|
||||
resourceType: entry.resourceType,
|
||||
parentResourceId: entry.childResourceId,
|
||||
childResourceId: entry.parentResourceId,
|
||||
})
|
||||
deleteKeys.push({
|
||||
resourceType: entry.resourceType,
|
||||
childResourceId: entry.parentResourceId,
|
||||
})
|
||||
}
|
||||
return { entries: oriented, deleteKeys }
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist identities created by a copy operation through one shared orientation boundary. Used by
|
||||
* both the promote transaction and the post-commit full-KB document copier so those paths cannot
|
||||
* disagree about parent/child direction.
|
||||
*/
|
||||
export async function persistCopiedResourceMappings({
|
||||
executor,
|
||||
edgeChildWorkspaceId,
|
||||
userId,
|
||||
sourceIsParent,
|
||||
entries,
|
||||
}: PersistCopiedResourceMappingsParams): Promise<void> {
|
||||
if (entries.length === 0) return
|
||||
const oriented = orientCopiedResourceMappings(sourceIsParent, entries)
|
||||
if (oriented.entries.length === 0) return
|
||||
if (oriented.deleteKeys.length > 0) {
|
||||
await deleteEdgeMappingsByChildResources(executor, edgeChildWorkspaceId, oriented.deleteKeys)
|
||||
}
|
||||
await upsertEdgeMappings(executor, edgeChildWorkspaceId, userId, oriented.entries)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove mapping rows matched by their child-side (source) resource id, grouped by
|
||||
* resource type into a single OR-of-INs - one query for the whole push save (the
|
||||
@@ -226,19 +291,53 @@ export async function deleteEdgeMappingsByChildResources(
|
||||
tx: DbOrTx,
|
||||
childWorkspaceId: string,
|
||||
pairs: Array<{ resourceType: ForkResourceType; childResourceId: string }>
|
||||
): Promise<void> {
|
||||
await deleteEdgeMappingsByResourceIds(
|
||||
tx,
|
||||
childWorkspaceId,
|
||||
'child',
|
||||
pairs.map(({ resourceType, childResourceId }) => ({
|
||||
resourceType,
|
||||
resourceId: childResourceId,
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
/** Remove copy mappings whose runtime target resources are being discarded after a failed fill. */
|
||||
export async function deleteCopiedResourceMappingsByTargets(params: {
|
||||
executor: DbOrTx
|
||||
edgeChildWorkspaceId: string
|
||||
sourceIsParent: boolean
|
||||
targets: Array<{ resourceType: ForkResourceType; resourceId: string }>
|
||||
}): Promise<void> {
|
||||
const { executor, edgeChildWorkspaceId, sourceIsParent, targets } = params
|
||||
await deleteEdgeMappingsByResourceIds(
|
||||
executor,
|
||||
edgeChildWorkspaceId,
|
||||
sourceIsParent ? 'child' : 'parent',
|
||||
targets
|
||||
)
|
||||
}
|
||||
|
||||
async function deleteEdgeMappingsByResourceIds(
|
||||
tx: DbOrTx,
|
||||
childWorkspaceId: string,
|
||||
side: 'parent' | 'child',
|
||||
pairs: Array<{ resourceType: ForkResourceType; resourceId: string }>
|
||||
): Promise<void> {
|
||||
if (pairs.length === 0) return
|
||||
const idsByType = new Map<ForkResourceType, string[]>()
|
||||
for (const { resourceType, childResourceId } of pairs) {
|
||||
for (const { resourceType, resourceId } of pairs) {
|
||||
const list = idsByType.get(resourceType)
|
||||
if (list) list.push(childResourceId)
|
||||
else idsByType.set(resourceType, [childResourceId])
|
||||
if (list) list.push(resourceId)
|
||||
else idsByType.set(resourceType, [resourceId])
|
||||
}
|
||||
const resourceColumn =
|
||||
side === 'parent'
|
||||
? workspaceForkResourceMap.parentResourceId
|
||||
: workspaceForkResourceMap.childResourceId
|
||||
const conditions = Array.from(idsByType, ([resourceType, ids]) =>
|
||||
and(
|
||||
eq(workspaceForkResourceMap.resourceType, resourceType),
|
||||
inArray(workspaceForkResourceMap.childResourceId, ids)
|
||||
)
|
||||
and(eq(workspaceForkResourceMap.resourceType, resourceType), inArray(resourceColumn, ids))
|
||||
)
|
||||
await tx
|
||||
.delete(workspaceForkResourceMap)
|
||||
|
||||
@@ -9,22 +9,19 @@ import {
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
|
||||
const {
|
||||
mockUpsertEdgeMappings,
|
||||
mockDeleteEdgeMappingsByChildResources,
|
||||
mockPersistCopiedResourceMappings,
|
||||
mockCopyForkResourceContainers,
|
||||
mockPlanForkMappedKbDocumentCopies,
|
||||
mockPlanForkFileCopies,
|
||||
} = vi.hoisted(() => ({
|
||||
mockUpsertEdgeMappings: vi.fn(),
|
||||
mockDeleteEdgeMappingsByChildResources: vi.fn(),
|
||||
mockPersistCopiedResourceMappings: vi.fn(),
|
||||
mockCopyForkResourceContainers: vi.fn(),
|
||||
mockPlanForkMappedKbDocumentCopies: vi.fn(),
|
||||
mockPlanForkFileCopies: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/ee/workspace-forking/lib/mapping/mapping-store', () => ({
|
||||
upsertEdgeMappings: mockUpsertEdgeMappings,
|
||||
deleteEdgeMappingsByChildResources: mockDeleteEdgeMappingsByChildResources,
|
||||
persistCopiedResourceMappings: mockPersistCopiedResourceMappings,
|
||||
resourceTypeToForkKind: vi.fn(),
|
||||
}))
|
||||
|
||||
@@ -40,14 +37,12 @@ vi.mock('@/ee/workspace-forking/lib/copy/copy-files', () => ({
|
||||
}))
|
||||
|
||||
import type { ForkEdge } from '@/ee/workspace-forking/lib/lineage/lineage'
|
||||
import type { ForkMappingUpsert } from '@/ee/workspace-forking/lib/mapping/mapping-store'
|
||||
import {
|
||||
augmentForkResolver,
|
||||
buildPromoteCopySelection,
|
||||
copyPromoteUnmappedResources,
|
||||
FORK_COPYABLE_KIND_TO_SELECTION_KEY,
|
||||
hasPromoteCopySelection,
|
||||
persistPromoteCopiedMappings,
|
||||
} from '@/ee/workspace-forking/lib/promote/copy-unmapped'
|
||||
import { isForkCopyableKind } from '@/ee/workspace-forking/lib/promote/promote-plan'
|
||||
import type { ForkRemapKind } from '@/ee/workspace-forking/lib/remap/remap-references'
|
||||
@@ -237,51 +232,6 @@ describe('augmentForkResolver', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('persistPromoteCopiedMappings', () => {
|
||||
const tx = {} as DbOrTx
|
||||
const entry: ForkMappingUpsert = {
|
||||
resourceType: 'knowledge_base',
|
||||
parentResourceId: 'src-kb',
|
||||
childResourceId: 'dst-kb',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('pull keeps the source(parent)->target(child) orientation as-is', async () => {
|
||||
await persistPromoteCopiedMappings(tx, 'edge-child', 'user-1', 'pull', [entry])
|
||||
expect(mockUpsertEdgeMappings).toHaveBeenCalledWith(tx, 'edge-child', 'user-1', [entry])
|
||||
expect(mockDeleteEdgeMappingsByChildResources).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('push swaps to target(parent)->source(child) and deletes the prior row keyed on the source child', async () => {
|
||||
await persistPromoteCopiedMappings(tx, 'edge-child', 'user-1', 'push', [entry])
|
||||
// Delete keys on the source child resource (the swapped child id = the original parent id).
|
||||
expect(mockDeleteEdgeMappingsByChildResources).toHaveBeenCalledWith(tx, 'edge-child', [
|
||||
{ resourceType: 'knowledge_base', childResourceId: 'src-kb' },
|
||||
])
|
||||
// The swap flips parent/child: the new copy (dst) becomes the parent side on push.
|
||||
expect(mockUpsertEdgeMappings).toHaveBeenCalledWith(tx, 'edge-child', 'user-1', [
|
||||
{ resourceType: 'knowledge_base', parentResourceId: 'dst-kb', childResourceId: 'src-kb' },
|
||||
])
|
||||
})
|
||||
|
||||
it('push skips an entry with a null child id (the narrowing guard, no bogus mapping)', async () => {
|
||||
await persistPromoteCopiedMappings(tx, 'edge-child', 'user-1', 'push', [
|
||||
{ resourceType: 'knowledge_base', parentResourceId: 'src-kb', childResourceId: null },
|
||||
])
|
||||
expect(mockDeleteEdgeMappingsByChildResources).not.toHaveBeenCalled()
|
||||
expect(mockUpsertEdgeMappings).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns without writing when there are no entries', async () => {
|
||||
await persistPromoteCopiedMappings(tx, 'edge-child', 'user-1', 'push', [])
|
||||
expect(mockDeleteEdgeMappingsByChildResources).not.toHaveBeenCalled()
|
||||
expect(mockUpsertEdgeMappings).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('copyPromoteUnmappedResources - files + folder content-refs', () => {
|
||||
const tx = {} as DbOrTx
|
||||
// Only edge.childWorkspaceId is read by the copy path.
|
||||
@@ -320,6 +270,46 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('threads push orientation through the shared container and mapping boundaries', async () => {
|
||||
await copyPromoteUnmappedResources({
|
||||
tx,
|
||||
edge,
|
||||
sourceWorkspaceId: 'child-source-ws',
|
||||
targetWorkspaceId: 'parent-target-ws',
|
||||
direction: 'push',
|
||||
userId: 'user-1',
|
||||
now: new Date(),
|
||||
selection: {
|
||||
customTools: [],
|
||||
skills: [],
|
||||
tables: [],
|
||||
knowledgeBases: [],
|
||||
files: [],
|
||||
mcpServers: [],
|
||||
},
|
||||
workflowIdMap: new Map(),
|
||||
folderIdMap: new Map(),
|
||||
resolver: () => null,
|
||||
resolveBlockId,
|
||||
referencedDocumentIds: [],
|
||||
})
|
||||
|
||||
expect(mockCopyForkResourceContainers).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
documentMappingContext: {
|
||||
edgeChildWorkspaceId: 'edge-child',
|
||||
sourceIsParent: false,
|
||||
},
|
||||
})
|
||||
)
|
||||
expect(mockPersistCopiedResourceMappings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
edgeChildWorkspaceId: 'edge-child',
|
||||
sourceIsParent: false,
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('copies selected files (keyMap + blobTasks), persists the file mapping, and threads file + folder content-ref maps', async () => {
|
||||
mockPlanForkFileCopies.mockResolvedValue({
|
||||
keyMap: new Map([['workspace/SRC/a.png', 'workspace/DST/a.png']]),
|
||||
@@ -351,6 +341,7 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => {
|
||||
tables: [],
|
||||
knowledgeBases: [],
|
||||
files: ['workspace/SRC/a.png'],
|
||||
mcpServers: [],
|
||||
},
|
||||
workflowIdMap: new Map(),
|
||||
folderIdMap: new Map([['fld-src', 'fld-dst']]),
|
||||
@@ -371,13 +362,19 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => {
|
||||
)
|
||||
// The file mapping is persisted (pull keeps source(parent)->target(child) orientation) so a
|
||||
// re-sync resolves the copy instead of re-copying it.
|
||||
expect(mockUpsertEdgeMappings).toHaveBeenCalledWith(tx, 'edge-child', 'user-1', [
|
||||
{
|
||||
resourceType: 'file',
|
||||
parentResourceId: 'workspace/SRC/a.png',
|
||||
childResourceId: 'workspace/DST/a.png',
|
||||
},
|
||||
])
|
||||
expect(mockPersistCopiedResourceMappings).toHaveBeenCalledWith({
|
||||
executor: tx,
|
||||
edgeChildWorkspaceId: 'edge-child',
|
||||
userId: 'user-1',
|
||||
sourceIsParent: true,
|
||||
entries: [
|
||||
{
|
||||
resourceType: 'file',
|
||||
parentResourceId: 'workspace/SRC/a.png',
|
||||
childResourceId: 'workspace/DST/a.png',
|
||||
},
|
||||
],
|
||||
})
|
||||
// The folder map AND the file key/id maps reach the in-content rewriter.
|
||||
expect(result.contentRefMaps.folders).toEqual({ 'fld-src': 'fld-dst' })
|
||||
expect(result.contentRefMaps.fileKeys).toEqual({ 'workspace/SRC/a.png': 'workspace/DST/a.png' })
|
||||
@@ -430,6 +427,7 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => {
|
||||
tables: ['tbl-unref'],
|
||||
knowledgeBases: [],
|
||||
files: [],
|
||||
mcpServers: [],
|
||||
},
|
||||
workflowIdMap: new Map(),
|
||||
folderIdMap: new Map(),
|
||||
@@ -438,9 +436,15 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => {
|
||||
referencedDocumentIds: [],
|
||||
})
|
||||
|
||||
expect(mockUpsertEdgeMappings).toHaveBeenCalledWith(tx, 'edge-child', 'user-1', [
|
||||
{ resourceType: 'table', parentResourceId: 'tbl-unref', childResourceId: 'tbl-copy' },
|
||||
])
|
||||
expect(mockPersistCopiedResourceMappings).toHaveBeenCalledWith({
|
||||
executor: tx,
|
||||
edgeChildWorkspaceId: 'edge-child',
|
||||
userId: 'user-1',
|
||||
sourceIsParent: true,
|
||||
entries: [
|
||||
{ resourceType: 'table', parentResourceId: 'tbl-unref', childResourceId: 'tbl-copy' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('threads the plan-provided referencedDocumentIds into both doc-copy paths (no in-tx re-scan)', async () => {
|
||||
@@ -478,10 +482,17 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => {
|
||||
// The promote-built block-id resolver reaches the table remap unchanged, so copied
|
||||
// tables' workflow-group outputs use the persisted-pair ids, not the derive.
|
||||
resolveBlockId,
|
||||
documentMappingContext: {
|
||||
edgeChildWorkspaceId: 'edge-child',
|
||||
sourceIsParent: true,
|
||||
},
|
||||
})
|
||||
)
|
||||
expect(mockPlanForkMappedKbDocumentCopies).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ referencedDocumentIds: ['doc-1', 'doc-2'] })
|
||||
expect.objectContaining({
|
||||
referencedDocumentIds: ['doc-1', 'doc-2'],
|
||||
now: expect.any(Date),
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,10 +16,9 @@ import {
|
||||
} from '@/ee/workspace-forking/lib/copy/copy-resources'
|
||||
import type { ForkEdge } from '@/ee/workspace-forking/lib/lineage/lineage'
|
||||
import {
|
||||
deleteEdgeMappingsByChildResources,
|
||||
type ForkMappingUpsert,
|
||||
persistCopiedResourceMappings,
|
||||
resourceTypeToForkKind,
|
||||
upsertEdgeMappings,
|
||||
} from '@/ee/workspace-forking/lib/mapping/mapping-store'
|
||||
import type { ForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity'
|
||||
import type {
|
||||
@@ -230,6 +229,10 @@ export async function copyPromoteUnmappedResources(params: {
|
||||
// rewritten through the same plan resolver that remaps subblock-value env refs.
|
||||
resolveEnvName: (key) => resolver('env-var', key),
|
||||
resolveBlockId,
|
||||
documentMappingContext: {
|
||||
edgeChildWorkspaceId: edge.childWorkspaceId,
|
||||
sourceIsParent: direction === 'pull',
|
||||
},
|
||||
})
|
||||
|
||||
// Copy the selected workspace files (keyed by storage key) - metadata inserts in the tx, blob
|
||||
@@ -259,6 +262,7 @@ export async function copyPromoteUnmappedResources(params: {
|
||||
resolver,
|
||||
referencedDocumentIds,
|
||||
alreadyCopiedSourceDocIds: new Set(containerDocMap.keys()),
|
||||
now,
|
||||
})
|
||||
result.contentPlan.documents.push(...mappedKbDocs.documents)
|
||||
|
||||
@@ -272,11 +276,13 @@ export async function copyPromoteUnmappedResources(params: {
|
||||
childResourceId: child,
|
||||
})
|
||||
)
|
||||
await persistPromoteCopiedMappings(tx, edge.childWorkspaceId, userId, direction, [
|
||||
...result.mappingEntries,
|
||||
...fileMappingEntries,
|
||||
...mappedKbDocs.mappingEntries,
|
||||
])
|
||||
await persistCopiedResourceMappings({
|
||||
executor: tx,
|
||||
edgeChildWorkspaceId: edge.childWorkspaceId,
|
||||
userId,
|
||||
sourceIsParent: direction === 'pull',
|
||||
entries: [...result.mappingEntries, ...fileMappingEntries, ...mappedKbDocs.mappingEntries],
|
||||
})
|
||||
|
||||
const copyIdMapByKind = new Map<ForkRemapKind, Map<string, string>>()
|
||||
for (const [resourceType, sourceToTarget] of result.idMap) {
|
||||
@@ -313,44 +319,3 @@ export async function copyPromoteUnmappedResources(params: {
|
||||
blobTasks: fileResult.blobTasks,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the copied resources' id mappings for the edge. The copy returns entries oriented
|
||||
* source(parent)->target(child); a pull matches that orientation directly (fill-null upsert), a
|
||||
* push swaps it (the parent side is the new TARGET) and first drops any prior row keyed on the
|
||||
* source child resource so a changed target can't leak a second mapping.
|
||||
*/
|
||||
export async function persistPromoteCopiedMappings(
|
||||
tx: DbOrTx,
|
||||
childWorkspaceId: string,
|
||||
userId: string,
|
||||
direction: 'push' | 'pull',
|
||||
entries: ForkMappingUpsert[]
|
||||
): Promise<void> {
|
||||
if (entries.length === 0) return
|
||||
if (direction === 'pull') {
|
||||
await upsertEdgeMappings(tx, childWorkspaceId, userId, entries)
|
||||
return
|
||||
}
|
||||
// Push: re-key on the source child resource. Skip any entry with a null child id (copy entries
|
||||
// always carry one; the guard narrows the type so neither the swap nor the delete needs a cast).
|
||||
// After the swap every childResourceId is the original (non-null) parent id, keyed for the
|
||||
// delete-then-insert that prevents a changed target from leaking a second mapping.
|
||||
const swapped: ForkMappingUpsert[] = []
|
||||
const deleteKeys: Array<{
|
||||
resourceType: ForkMappingUpsert['resourceType']
|
||||
childResourceId: string
|
||||
}> = []
|
||||
for (const entry of entries) {
|
||||
if (entry.childResourceId == null) continue
|
||||
swapped.push({
|
||||
resourceType: entry.resourceType,
|
||||
parentResourceId: entry.childResourceId,
|
||||
childResourceId: entry.parentResourceId,
|
||||
})
|
||||
deleteKeys.push({ resourceType: entry.resourceType, childResourceId: entry.parentResourceId })
|
||||
}
|
||||
if (swapped.length === 0) return
|
||||
await deleteEdgeMappingsByChildResources(tx, childWorkspaceId, deleteKeys)
|
||||
await upsertEdgeMappings(tx, childWorkspaceId, userId, swapped)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { loggerMock } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache'
|
||||
import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata'
|
||||
import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref'
|
||||
import { projectTraceSpansForSecrets } from '@/lib/logs/execution/trace-secret-projection'
|
||||
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
|
||||
import { BlockType, EDGE } from '@/executor/constants'
|
||||
@@ -146,6 +147,64 @@ describe('BlockExecutor', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('carries complete encrypted candidates through large-output compaction', async () => {
|
||||
const block = createBlock()
|
||||
const workflow: SerializedWorkflow = {
|
||||
version: '1',
|
||||
blocks: [block],
|
||||
connections: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
}
|
||||
const state = new ExecutionState()
|
||||
const resolver = new VariableResolver(workflow, {}, state)
|
||||
const onBlockComplete = vi.fn(async () => {})
|
||||
const registry = new ResolvedSecretTraceRegistry(
|
||||
[{ name: 'API_KEY', plaintext: 'secret-value', encryptedValue: 'encrypted-secret' }],
|
||||
{ userId: 'user-1', workspaceId: 'workspace-1' }
|
||||
)
|
||||
const handler: BlockHandler = {
|
||||
canHandle: () => true,
|
||||
execute: async (blockContext) => {
|
||||
blockContext.resolvedSecretTraceRegistry?.recordResolved('API_KEY', 'secret-value')
|
||||
return {
|
||||
result: {
|
||||
huge: 'p'.repeat(9 * 1024 * 1024),
|
||||
public: 'ok',
|
||||
secret: 'secret-value',
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
const executor = new BlockExecutor([handler], resolver, { onBlockComplete }, state)
|
||||
const ctx = createContext(state)
|
||||
ctx.resolvedSecretTraceRegistry = registry
|
||||
|
||||
await executor.execute(ctx, createNode(block), block)
|
||||
await vi.waitFor(() => expect(onBlockComplete).toHaveBeenCalledOnce())
|
||||
|
||||
const storedOutput = state.getBlockOutput(block.id)
|
||||
const storedResult = storedOutput?.result as Record<string, unknown>
|
||||
expect(isLargeValueRef(storedResult.huge)).toBe(true)
|
||||
expect(storedResult.public).toBe('ok')
|
||||
expect(storedResult.secret).toBe('secret-value')
|
||||
|
||||
const expectedProvenance = {
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [{ name: 'API_KEY', encryptedValue: 'encrypted-secret' }],
|
||||
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
|
||||
}
|
||||
expect(state.getBlockState(block.id)?.resolvedSecretTraceProvenance).toEqual(expectedProvenance)
|
||||
expect(onBlockComplete.mock.calls[0]?.[3]?.resolvedSecretTraceProvenance).toEqual(
|
||||
expectedProvenance
|
||||
)
|
||||
expect(onBlockComplete.mock.calls[0]?.[3]?.displayResolvedSecretTraceProvenance).toEqual(
|
||||
expectedProvenance
|
||||
)
|
||||
expect(JSON.stringify(expectedProvenance)).not.toContain('secret-value')
|
||||
})
|
||||
|
||||
it('persists stable outer-branch aliases for completed parallel branch outputs', async () => {
|
||||
const block = createBlock()
|
||||
const workflow: SerializedWorkflow = {
|
||||
@@ -327,7 +386,7 @@ describe('BlockExecutor', () => {
|
||||
|
||||
it('projects lifecycle callback diagnostics without changing block execution', async () => {
|
||||
const secret = 'lifecycle-secret-7f3a91'
|
||||
const startError = new Error(`start failed ${secret} __var_API_KEY`)
|
||||
const startError = new Error('start failed __var_API_KEY')
|
||||
const completionError = new Error(`completion failed ${secret} __sim_code_6_binding_0`)
|
||||
const block = createBlock()
|
||||
const workflow: SerializedWorkflow = {
|
||||
@@ -341,7 +400,15 @@ describe('BlockExecutor', () => {
|
||||
const resolver = new VariableResolver(workflow, {}, state)
|
||||
const output = { result: `raw ${secret}` }
|
||||
const executor = new BlockExecutor(
|
||||
[{ canHandle: () => true, execute: async () => output }],
|
||||
[
|
||||
{
|
||||
canHandle: () => true,
|
||||
execute: async (blockContext) => {
|
||||
blockContext.resolvedSecretTraceRegistry?.recordResolved('API_KEY', secret)
|
||||
return output
|
||||
},
|
||||
},
|
||||
],
|
||||
resolver,
|
||||
{
|
||||
workspaceId: 'workspace-1',
|
||||
@@ -382,8 +449,7 @@ describe('BlockExecutor', () => {
|
||||
expect.objectContaining({
|
||||
blockId: block.id,
|
||||
blockType: BlockType.FUNCTION,
|
||||
errorType: 'error',
|
||||
hasStack: true,
|
||||
error: 'completion failed {{API_KEY}} [RUNTIME_BINDING]',
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -392,7 +458,7 @@ describe('BlockExecutor', () => {
|
||||
expect.objectContaining({
|
||||
blockId: block.id,
|
||||
blockType: BlockType.FUNCTION,
|
||||
error: 'start failed {{API_KEY}} {{API_KEY}}',
|
||||
error: 'start failed [REDACTED_SECRET]',
|
||||
})
|
||||
)
|
||||
const loggerPayload = JSON.stringify(executionLogger?.warn.mock.calls)
|
||||
@@ -400,7 +466,7 @@ describe('BlockExecutor', () => {
|
||||
expect(loggerPayload).not.toContain(secret)
|
||||
expect(loggerPayload).not.toContain('__var_')
|
||||
expect(loggerPayload).not.toContain('__sim_')
|
||||
expect(startError.message).toContain(secret)
|
||||
expect(startError.message).toContain('__var_API_KEY')
|
||||
expect(completionError.message).toContain(secret)
|
||||
})
|
||||
|
||||
@@ -795,7 +861,8 @@ describe('BlockExecutor', () => {
|
||||
const resolver = new VariableResolver(workflow, {}, state)
|
||||
const handler: BlockHandler = {
|
||||
canHandle: () => true,
|
||||
execute: async () => {
|
||||
execute: async (blockContext) => {
|
||||
blockContext.resolvedSecretTraceRegistry?.recordResolved('API_KEY', secret)
|
||||
throw new Error(rawError)
|
||||
},
|
||||
}
|
||||
@@ -900,10 +967,17 @@ describe('BlockExecutor streaming pump', () => {
|
||||
failAfterText?: string
|
||||
streamError?: Error
|
||||
onFullContent?: (content: string) => void | Promise<void>
|
||||
resolvedSecret?: { name: string; value: string }
|
||||
}): BlockHandler {
|
||||
return {
|
||||
canHandle: () => true,
|
||||
execute: async () => {
|
||||
execute: async (blockContext) => {
|
||||
if (options.resolvedSecret) {
|
||||
blockContext.resolvedSecretTraceRegistry?.recordResolved(
|
||||
options.resolvedSecret.name,
|
||||
options.resolvedSecret.value
|
||||
)
|
||||
}
|
||||
const timeSegment: Record<string, unknown> = {
|
||||
type: 'model',
|
||||
name: 'claude-test',
|
||||
@@ -1031,6 +1105,7 @@ describe('BlockExecutor streaming pump', () => {
|
||||
const handler = createAgentEventsStreamingHandler({
|
||||
failAfterText: 'partial',
|
||||
streamError: rawError,
|
||||
resolvedSecret: { name: 'API_KEY', value: secret },
|
||||
})
|
||||
const { executor, block, state } = createExecutor(handler)
|
||||
const ctx = createContext(state)
|
||||
@@ -1079,6 +1154,7 @@ describe('BlockExecutor streaming pump', () => {
|
||||
onFullContent: async () => {
|
||||
throw callbackError
|
||||
},
|
||||
resolvedSecret: { name: 'API_KEY', value: secret },
|
||||
})
|
||||
const { executor, block, state } = createExecutor(handler)
|
||||
block.config.params = { responseFormat: 'json' }
|
||||
|
||||
@@ -101,8 +101,9 @@ export class BlockExecutor {
|
||||
}
|
||||
|
||||
const parentResolvedSecretTraceRegistry = ctx.resolvedSecretTraceRegistry
|
||||
const blockResolvedSecretTraceRegistry =
|
||||
parentResolvedSecretTraceRegistry?.forkForToolInputValues([])
|
||||
const blockResolvedSecretTraceRegistry = parentResolvedSecretTraceRegistry?.forkForInputPaths(
|
||||
[]
|
||||
)
|
||||
const blockCtx = blockResolvedSecretTraceRegistry
|
||||
? { ...ctx, resolvedSecretTraceRegistry: blockResolvedSecretTraceRegistry }
|
||||
: ctx
|
||||
|
||||
@@ -1009,10 +1009,12 @@ describe('ExecutionEngine', () => {
|
||||
startNode.outgoingEdges.set('edge1', { target: 'error-node' })
|
||||
|
||||
const dag = createMockDAG([startNode, errorNode])
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'API_KEY', plaintext: secret, encryptedValue: 'encrypted-api-key' },
|
||||
])
|
||||
registry.recordResolved('API_KEY', secret)
|
||||
const context = createMockContext({
|
||||
resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry([
|
||||
{ name: 'API_KEY', plaintext: secret, encryptedValue: 'encrypted-api-key' },
|
||||
]),
|
||||
resolvedSecretTraceRegistry: registry,
|
||||
})
|
||||
const edgeManager = createMockEdgeManager((node) => {
|
||||
if (node.id === 'start') return ['error-node']
|
||||
|
||||
@@ -106,7 +106,7 @@ export interface SerializableExecutionState {
|
||||
workflowVariableResolvedSecretTraceProvenance?: Record<string, ResolvedSecretTraceProvenanceV1>
|
||||
/** Exact-value provenance for the persisted workflow input. Absence means legacy/untracked. */
|
||||
workflowInputResolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceV1
|
||||
/** Exact-value provenance for the persisted terminal output. Absence means legacy/untracked. */
|
||||
/** Encrypted candidates for the persisted terminal output. Absence means legacy/untracked. */
|
||||
finalOutputResolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceV1
|
||||
/** Presence distinguishes current checkpoints from legacy states that predate provenance. */
|
||||
resolvedSecretTraceCheckpointVersion?: 1
|
||||
@@ -170,11 +170,11 @@ export interface BlockCompletionCallbackData {
|
||||
input?: unknown
|
||||
output: NormalizedBlockOutput
|
||||
/**
|
||||
* Encrypted provenance filtered to this exact block output. Internal durable
|
||||
* consumers use it when the raw output crosses a storage boundary.
|
||||
* Encrypted candidates active in this block call. Internal durable consumers
|
||||
* filter them against the exact value that crosses a storage boundary.
|
||||
*/
|
||||
resolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceV1
|
||||
/** Internal display-only provenance filtered to this callback's input/output envelope. */
|
||||
/** Internal encrypted candidates filtered against the display envelope during projection. */
|
||||
displayResolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceV1
|
||||
executionTime: number
|
||||
startedAt: string
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -266,20 +266,34 @@ describe('Memory', () => {
|
||||
expect(mockRedactObjectStrings).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not write memory when projection fails closed', async () => {
|
||||
it('persists raw memory with unknown lineage when provenance is unavailable', async () => {
|
||||
const registry = new ResolvedSecretTraceRegistry()
|
||||
registry.markIncomplete()
|
||||
const appendMessage = vi
|
||||
.spyOn(memoryService as any, 'appendMessage')
|
||||
.mockResolvedValue(undefined)
|
||||
|
||||
await expect(
|
||||
memoryService.appendToMemory(createContext(registry) as never, inputs, {
|
||||
role: 'user',
|
||||
content: 'possibly secret',
|
||||
})
|
||||
).rejects.toThrow('Memory content could not be safely projected')
|
||||
expect(appendMessage).not.toHaveBeenCalled()
|
||||
const message = { role: 'user' as const, content: 'possibly secret' }
|
||||
await memoryService.appendToMemory(createContext(registry) as never, inputs, message)
|
||||
|
||||
expect(appendMessage).toHaveBeenCalledWith('workspace-1', 'conversation-1', message, {
|
||||
status: 'unknown',
|
||||
})
|
||||
})
|
||||
|
||||
it('seeds raw memory with unknown lineage when provenance is unavailable', async () => {
|
||||
const registry = new ResolvedSecretTraceRegistry()
|
||||
registry.markIncomplete()
|
||||
const seedMemoryRecord = vi
|
||||
.spyOn(memoryService as any, 'seedMemoryRecord')
|
||||
.mockResolvedValue(undefined)
|
||||
const message = { role: 'assistant' as const, content: 'possibly secret' }
|
||||
|
||||
await memoryService.seedMemory(createContext(registry) as never, inputs, [message])
|
||||
|
||||
expect(seedMemoryRecord).toHaveBeenCalledWith('workspace-1', 'conversation-1', [message], {
|
||||
status: 'unknown',
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves legacy stored messages when no current resolution activated the value', async () => {
|
||||
@@ -351,7 +365,7 @@ describe('Memory', () => {
|
||||
}
|
||||
|
||||
const projected = (memoryService as any).projectMessageForModel(
|
||||
createContext(registry),
|
||||
registry,
|
||||
message
|
||||
) as Message
|
||||
|
||||
@@ -387,9 +401,16 @@ describe('Memory', () => {
|
||||
messages: [message],
|
||||
provenance: {
|
||||
status: 'exact',
|
||||
entries: [{ name: 'TOKEN', encryptedValue: 'ciphertext' }],
|
||||
entries: [
|
||||
{
|
||||
name: 'TOKEN',
|
||||
encryptedValue: 'ciphertext',
|
||||
sourceValueHash: hashDurableSecretProvenanceValue(message),
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
mockDecryptSecret.mockResolvedValue({ decrypted: secret })
|
||||
const [fetched] = await memoryService.fetchMemoryMessages(
|
||||
createContext(registry) as never,
|
||||
inputs
|
||||
@@ -402,7 +423,7 @@ describe('Memory', () => {
|
||||
)
|
||||
|
||||
it.each(['name', 'functionName', 'toolCallId', 'toolName'] as const)(
|
||||
'rejects an active resolved secret in the %s control field',
|
||||
'does not plaintext-scan the %s control field',
|
||||
(field) => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'TOKEN', plaintext: 'control-secret', encryptedValue: 'ciphertext' },
|
||||
@@ -428,12 +449,28 @@ describe('Memory', () => {
|
||||
],
|
||||
} as Message
|
||||
|
||||
expect(() =>
|
||||
(memoryService as any).projectMessageForModel(createContext(registry), message)
|
||||
).toThrow('Memory content could not be safely projected')
|
||||
expect((memoryService as any).projectMessageForModel(registry, message)).toEqual(message)
|
||||
}
|
||||
)
|
||||
|
||||
it('does not project unrelated active secrets into legacy memory', async () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'TOKEN', plaintext: 'x', encryptedValue: 'ciphertext' },
|
||||
])
|
||||
registry.recordResolved('TOKEN', 'x')
|
||||
vi.spyOn(memoryService as any, 'fetchMemory').mockResolvedValueOnce({
|
||||
messages: [{ role: 'assistant', content: 'Box' }],
|
||||
provenance: { status: 'exact', entries: [] },
|
||||
})
|
||||
|
||||
const messages = await memoryService.fetchMemoryMessages(
|
||||
createContext(registry) as never,
|
||||
inputs
|
||||
)
|
||||
|
||||
expect(messages).toEqual([{ role: 'assistant', content: 'Box' }])
|
||||
})
|
||||
|
||||
it('does not activate provenance from a message dropped by the selected window', async () => {
|
||||
const oldSecretMessage: Message = { role: 'user', content: 'same-value' }
|
||||
const retainedPublicMessage: Message = { role: 'assistant', content: 'same-value' }
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
projectResolvedSecretModelContent,
|
||||
projectResolvedSecretModelJsonStrings,
|
||||
} from '@/executor/utils/resolved-secret-content-projection'
|
||||
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
|
||||
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
|
||||
import { PROVIDER_DEFINITIONS } from '@/providers/models'
|
||||
|
||||
const logger = createLogger('Memory')
|
||||
@@ -85,7 +85,21 @@ export class Memory {
|
||||
throw new Error('Memory content could not be safely projected')
|
||||
}
|
||||
|
||||
return messages.map((message) => this.projectMessageForModel(ctx, message))
|
||||
return Promise.all(
|
||||
messages.map(async (message) => {
|
||||
const messageProvenance = filterDurableSecretProvenanceBySourceValues(selectedProvenance, [
|
||||
message,
|
||||
])
|
||||
const modelRegistry = new ResolvedSecretTraceRegistry(
|
||||
[],
|
||||
ctx.resolvedSecretTraceRegistry?.exportProvenance().scope
|
||||
)
|
||||
if (!(await importDurableSecretProvenance(modelRegistry, messageProvenance, message))) {
|
||||
throw new Error('Memory content could not be safely projected')
|
||||
}
|
||||
return this.projectMessageForModel(modelRegistry, message)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
private captureMessagesProvenance(
|
||||
@@ -122,9 +136,6 @@ export class Memory {
|
||||
const provenance = ctx.resolvedSecretTraceRegistry
|
||||
? this.captureMessagesProvenance(ctx.resolvedSecretTraceRegistry, [message])
|
||||
: undefined
|
||||
if (provenance?.status === 'unknown') {
|
||||
throw new Error('Memory content could not be safely projected')
|
||||
}
|
||||
|
||||
await this.appendMessage(workspaceId, key, message, provenance)
|
||||
|
||||
@@ -172,9 +183,6 @@ export class Memory {
|
||||
const provenance = ctx.resolvedSecretTraceRegistry
|
||||
? this.captureMessagesProvenance(ctx.resolvedSecretTraceRegistry, messagesToStore)
|
||||
: undefined
|
||||
if (provenance?.status === 'unknown') {
|
||||
throw new Error('Memory content could not be safely projected')
|
||||
}
|
||||
await this.seedMemoryRecord(workspaceId, key, messagesToStore, provenance)
|
||||
|
||||
logger.debug('Seeded memory', {
|
||||
@@ -204,23 +212,7 @@ export class Memory {
|
||||
}
|
||||
}
|
||||
|
||||
private projectMessageForModel(ctx: ExecutionContext, message: Message): Message {
|
||||
const controlValues = this.readModelControlValues(message)
|
||||
const controlProjection = projectResolvedSecretModelContent(
|
||||
controlValues,
|
||||
ctx.resolvedSecretTraceRegistry
|
||||
)
|
||||
if (
|
||||
!controlProjection.safe ||
|
||||
!Array.isArray(controlProjection.value) ||
|
||||
controlProjection.value.length !== controlValues.length ||
|
||||
controlProjection.value.some(
|
||||
(value, index) => typeof value !== 'string' || value !== controlValues[index]
|
||||
)
|
||||
) {
|
||||
throw new Error('Memory content could not be safely projected')
|
||||
}
|
||||
|
||||
private projectMessageForModel(registry: ResolvedSecretTraceRegistry, message: Message): Message {
|
||||
const functionArguments = this.readFunctionCallArguments(message.function_call)
|
||||
const toolArguments = message.tool_calls?.map((toolCall) => {
|
||||
if (!isPlainRecord(toolCall)) {
|
||||
@@ -228,13 +220,10 @@ export class Memory {
|
||||
}
|
||||
return this.readFunctionCallArguments(toolCall.function)
|
||||
})
|
||||
const contentProjection = projectResolvedSecretModelContent(
|
||||
message.content,
|
||||
ctx.resolvedSecretTraceRegistry
|
||||
)
|
||||
const contentProjection = projectResolvedSecretModelContent(message.content, registry)
|
||||
const argumentProjection = projectResolvedSecretModelJsonStrings(
|
||||
[functionArguments, ...(toolArguments ?? [])],
|
||||
ctx.resolvedSecretTraceRegistry
|
||||
registry
|
||||
)
|
||||
if (
|
||||
!contentProjection.safe ||
|
||||
@@ -306,54 +295,6 @@ export class Memory {
|
||||
return functionCall.arguments
|
||||
}
|
||||
|
||||
private readModelControlValues(message: Message): string[] {
|
||||
if (!isPlainRecord(message)) {
|
||||
throw new Error('Memory content could not be safely projected')
|
||||
}
|
||||
const controls: string[] = []
|
||||
for (const key of ['name', 'tool_call_id'] as const) {
|
||||
if (!(key in message)) continue
|
||||
const value = message[key]
|
||||
if (value !== undefined && value !== null) {
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error('Memory content could not be safely projected')
|
||||
}
|
||||
controls.push(value)
|
||||
}
|
||||
}
|
||||
|
||||
if (message.function_call !== undefined && message.function_call !== null) {
|
||||
if (!isPlainRecord(message.function_call)) {
|
||||
throw new Error('Memory content could not be safely projected')
|
||||
}
|
||||
const name = message.function_call.name
|
||||
if (name !== undefined && name !== null) {
|
||||
if (typeof name !== 'string') {
|
||||
throw new Error('Memory content could not be safely projected')
|
||||
}
|
||||
controls.push(name)
|
||||
}
|
||||
}
|
||||
|
||||
for (const toolCall of message.tool_calls ?? []) {
|
||||
if (!isPlainRecord(toolCall)) {
|
||||
throw new Error('Memory content could not be safely projected')
|
||||
}
|
||||
for (const value of [
|
||||
toolCall.id,
|
||||
isPlainRecord(toolCall.function) ? toolCall.function.name : undefined,
|
||||
]) {
|
||||
if (value !== undefined && value !== null) {
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error('Memory content could not be safely projected')
|
||||
}
|
||||
controls.push(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return controls
|
||||
}
|
||||
|
||||
private requireWorkspaceId(ctx: ExecutionContext): string {
|
||||
if (!ctx.workspaceId) {
|
||||
throw new Error('workspaceId is required for memory operations')
|
||||
|
||||
@@ -32,7 +32,11 @@ vi.mock('@/lib/model-router/resolve', () => ({
|
||||
SIM_AUTO_SYSTEM_PREAMBLE: 'Sim auto system preamble',
|
||||
}))
|
||||
|
||||
import { PRIVATE_MODEL_INPUT_PROVENANCE_HEADER } from '@/lib/execution/model-input-provenance'
|
||||
import {
|
||||
PRIVATE_MODEL_INPUT_PROVENANCE_HEADER,
|
||||
PRIVATE_MODEL_INPUT_STATE_HEADER,
|
||||
PROJECTED_MODEL_INPUT_PATHS_V1,
|
||||
} from '@/lib/execution/model-input-provenance'
|
||||
import {
|
||||
RESOLVED_SECRET_PROVENANCE_FIELD,
|
||||
RESOLVED_SECRET_PROVENANCE_METADATA_V1,
|
||||
@@ -223,8 +227,18 @@ describe('EvaluatorBlockHandler', () => {
|
||||
encryptedValue: 'encrypted-evaluator-credential',
|
||||
},
|
||||
])
|
||||
registry.recordResolved('CONTENT_SECRET', contentSecret)
|
||||
registry.recordResolved('METRIC_SECRET', metricSecret)
|
||||
registry.recordResolvedAtInputPath('CONTENT_SECRET', contentSecret, ['content'])
|
||||
registry.recordResolvedInputProjection(['content'], contentSecret, '{{CONTENT_SECRET}}')
|
||||
registry.recordResolvedAtInputPath('METRIC_SECRET', metricSecret, [
|
||||
'metrics',
|
||||
'0',
|
||||
'description',
|
||||
])
|
||||
registry.recordResolvedInputProjection(
|
||||
['metrics', '0', 'description'],
|
||||
metricSecret,
|
||||
'{{METRIC_SECRET}}'
|
||||
)
|
||||
registry.recordResolved('API_KEY', credentialSecret)
|
||||
mockContext.resolvedSecretTraceRegistry = registry
|
||||
|
||||
@@ -246,6 +260,9 @@ describe('EvaluatorBlockHandler', () => {
|
||||
expect((request.headers as Headers).get(PRIVATE_MODEL_INPUT_PROVENANCE_HEADER)).toBe(
|
||||
RESOLVED_SECRET_PROVENANCE_METADATA_V1
|
||||
)
|
||||
expect((request.headers as Headers).get(PRIVATE_MODEL_INPUT_STATE_HEADER)).toBe(
|
||||
PROJECTED_MODEL_INPUT_PATHS_V1
|
||||
)
|
||||
expect(requestBody[RESOLVED_SECRET_PROVENANCE_FIELD]).toEqual({
|
||||
version: 1,
|
||||
complete: true,
|
||||
@@ -263,6 +280,96 @@ describe('EvaluatorBlockHandler', () => {
|
||||
expect(requestBody.apiKey).toBe(credentialSecret)
|
||||
})
|
||||
|
||||
it('projects every model-bound metric leaf and maps the score back to the raw metric name', async () => {
|
||||
const rawMetric = {
|
||||
name: 'private-metric-key',
|
||||
description: 'private metric instructions',
|
||||
range: { min: 'private minimum', max: 'private maximum' },
|
||||
}
|
||||
const projectedMetric = {
|
||||
name: '{{METRIC_NAME_SECRET}}',
|
||||
description: '{{METRIC_DESCRIPTION_SECRET}}',
|
||||
range: { min: '{{METRIC_MIN_SECRET}}', max: '{{METRIC_MAX_SECRET}}' },
|
||||
}
|
||||
const secrets = [
|
||||
{
|
||||
name: 'METRIC_NAME_SECRET',
|
||||
plaintext: rawMetric.name,
|
||||
encryptedValue: 'encrypted-metric-name',
|
||||
path: ['metrics', '0', 'name'],
|
||||
projected: projectedMetric.name,
|
||||
},
|
||||
{
|
||||
name: 'METRIC_DESCRIPTION_SECRET',
|
||||
plaintext: rawMetric.description,
|
||||
encryptedValue: 'encrypted-metric-description',
|
||||
path: ['metrics', '0', 'description'],
|
||||
projected: projectedMetric.description,
|
||||
},
|
||||
{
|
||||
name: 'METRIC_MIN_SECRET',
|
||||
plaintext: rawMetric.range.min,
|
||||
encryptedValue: 'encrypted-metric-min',
|
||||
path: ['metrics', '0', 'range', 'min'],
|
||||
projected: projectedMetric.range.min,
|
||||
},
|
||||
{
|
||||
name: 'METRIC_MAX_SECRET',
|
||||
plaintext: rawMetric.range.max,
|
||||
encryptedValue: 'encrypted-metric-max',
|
||||
path: ['metrics', '0', 'range', 'max'],
|
||||
projected: projectedMetric.range.max,
|
||||
},
|
||||
] as const
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
...secrets.map(({ name, plaintext, encryptedValue }) => ({
|
||||
name,
|
||||
plaintext,
|
||||
encryptedValue,
|
||||
})),
|
||||
{ name: 'UNUSED_SECRET', plaintext: 'x', encryptedValue: 'encrypted-unused' },
|
||||
])
|
||||
for (const secret of secrets) {
|
||||
registry.recordResolvedAtInputPath(secret.name, secret.plaintext, secret.path)
|
||||
registry.recordResolvedInputProjection(secret.path, secret.plaintext, secret.projected)
|
||||
}
|
||||
mockContext.resolvedSecretTraceRegistry = registry
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
content: JSON.stringify({ [projectedMetric.name.toLowerCase()]: 7 }),
|
||||
model: 'mock-model',
|
||||
tokens: {},
|
||||
cost: 0,
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await handler.execute(mockContext, mockBlock, {
|
||||
content: 'Public x remains public.',
|
||||
metrics: [rawMetric],
|
||||
model: 'gpt-4o',
|
||||
apiKey: 'test-api-key',
|
||||
})
|
||||
|
||||
const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body)
|
||||
const serializedRequest = JSON.stringify(requestBody)
|
||||
for (const secret of secrets) {
|
||||
expect(serializedRequest).not.toContain(secret.plaintext)
|
||||
expect(requestBody.systemPrompt).toContain(secret.projected)
|
||||
}
|
||||
expect(requestBody.systemPrompt).toContain('Public x remains public.')
|
||||
expect(requestBody.responseFormat.schema.properties).toEqual({
|
||||
[projectedMetric.name.toLowerCase()]: { type: 'number' },
|
||||
})
|
||||
expect(
|
||||
requestBody[RESOLVED_SECRET_PROVENANCE_FIELD].entries
|
||||
.map((entry: { name: string }) => entry.name)
|
||||
.sort()
|
||||
).toEqual(secrets.map((secret) => secret.name).sort())
|
||||
expect(result).toMatchObject({ [rawMetric.name.toLowerCase()]: 7 })
|
||||
})
|
||||
|
||||
it('keeps the evaluator request shape when no provenance registry exists', async () => {
|
||||
await handler.execute(mockContext, mockBlock, {
|
||||
content: 'Public evaluator content',
|
||||
@@ -275,6 +382,7 @@ describe('EvaluatorBlockHandler', () => {
|
||||
const requestBody = JSON.parse(request.body)
|
||||
expect(Object.hasOwn(requestBody, RESOLVED_SECRET_PROVENANCE_FIELD)).toBe(false)
|
||||
expect((request.headers as Headers).get(PRIVATE_MODEL_INPUT_PROVENANCE_HEADER)).toBeNull()
|
||||
expect((request.headers as Headers).get(PRIVATE_MODEL_INPUT_STATE_HEADER)).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves sim-auto before executing evaluator and preserves its public identity', async () => {
|
||||
|
||||
@@ -2,6 +2,8 @@ import { createLogger } from '@sim/logger'
|
||||
import {
|
||||
addModelInputProvenanceToRequest,
|
||||
createModelInputProvenanceRequestMetadata,
|
||||
markModelInputProjected,
|
||||
projectResolvedModelInput,
|
||||
} from '@/lib/execution/model-input-provenance'
|
||||
import {
|
||||
type AutoRoutingResult,
|
||||
@@ -16,10 +18,12 @@ import type { BlockHandler, ExecutionContext } from '@/executor/types'
|
||||
import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http'
|
||||
import { isJSONString, parseJSON, stringifyJSON } from '@/executor/utils/json'
|
||||
import { projectResolvedSecretDiagnosticError } from '@/executor/utils/resolved-secret-content-projection'
|
||||
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
|
||||
import type {
|
||||
ResolvedSecretInputPath,
|
||||
ResolvedSecretTraceRegistry,
|
||||
} from '@/executor/utils/resolved-secret-trace-registry'
|
||||
import { resolveVertexCredential } from '@/executor/utils/vertex-credential'
|
||||
import { resolveProxiedModelCost } from '@/providers/cost-policy'
|
||||
import { collectProviderModelInputProvenanceValues } from '@/providers/model-input-provenance'
|
||||
import { isAutoModel, SIM_AUTO_MODEL_ID } from '@/providers/models'
|
||||
import type { ProviderRequest } from '@/providers/types'
|
||||
import { getProviderFromModel } from '@/providers/utils'
|
||||
@@ -51,8 +55,6 @@ export class EvaluatorBlockHandler implements BlockHandler {
|
||||
bedrockRegion: inputs.bedrockRegion,
|
||||
}
|
||||
|
||||
const processedContent = this.processContent(inputs.content)
|
||||
|
||||
let systemPromptObj: { systemPrompt: string; responseFormat: any } = {
|
||||
systemPrompt: '',
|
||||
responseFormat: null,
|
||||
@@ -64,15 +66,43 @@ export class EvaluatorBlockHandler implements BlockHandler {
|
||||
} else {
|
||||
metrics = []
|
||||
}
|
||||
const modelInputPaths: ResolvedSecretInputPath[] = [
|
||||
['content'],
|
||||
...metrics.flatMap((_, index) => [
|
||||
['metrics', String(index), 'name'],
|
||||
['metrics', String(index), 'description'],
|
||||
['metrics', String(index), 'range', 'min'],
|
||||
['metrics', String(index), 'range', 'max'],
|
||||
]),
|
||||
]
|
||||
const modelInputProjection = projectResolvedModelInput(
|
||||
ctx.resolvedSecretTraceRegistry,
|
||||
{ content: inputs.content, metrics: inputs.metrics },
|
||||
modelInputPaths
|
||||
)
|
||||
if (!modelInputProjection.complete) {
|
||||
throw new Error('Evaluator model input could not be safely projected')
|
||||
}
|
||||
const processedContent = this.processContent(modelInputProjection.value.content)
|
||||
const projectedMetrics = Array.isArray(modelInputProjection.value.metrics)
|
||||
? modelInputProjection.value.metrics
|
||||
: []
|
||||
const metricDescriptions = metrics
|
||||
.filter((m: any) => m?.name && m.range)
|
||||
.map((m: any) => `"${m.name}" (${m.range.min}-${m.range.max}): ${m.description || ''}`)
|
||||
.map((metric: any, index: number) => ({ metric, projected: projectedMetrics[index] }))
|
||||
.filter(({ metric, projected }) =>
|
||||
Boolean(metric?.name && metric.range && projected?.name && projected.range)
|
||||
)
|
||||
.map(
|
||||
({ projected }) =>
|
||||
`"${projected.name}" (${projected.range.min}-${projected.range.max}): ${projected.description || ''}`
|
||||
)
|
||||
.join('\n')
|
||||
|
||||
const responseProperties: Record<string, any> = {}
|
||||
metrics.forEach((m: any, metricIndex: number) => {
|
||||
if (m?.name) {
|
||||
responseProperties[m.name.toLowerCase()] = { type: 'number' }
|
||||
const projectedMetric = projectedMetrics[metricIndex]
|
||||
if (m?.name && projectedMetric?.name) {
|
||||
responseProperties[projectedMetric.name.toLowerCase()] = { type: 'number' }
|
||||
} else {
|
||||
logger.warn('Skipping invalid metric entry during response format generation', {
|
||||
metricIndex,
|
||||
@@ -96,7 +126,10 @@ export class EvaluatorBlockHandler implements BlockHandler {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: responseProperties,
|
||||
required: metrics.filter((m: any) => m?.name).map((m: any) => m.name.toLowerCase()),
|
||||
required: metrics.flatMap((m: any, metricIndex: number) => {
|
||||
const projectedName = projectedMetrics[metricIndex]?.name
|
||||
return m?.name && projectedName ? [projectedName.toLowerCase()] : []
|
||||
}),
|
||||
additionalProperties: false,
|
||||
},
|
||||
strict: true,
|
||||
@@ -181,14 +214,16 @@ export class EvaluatorBlockHandler implements BlockHandler {
|
||||
}
|
||||
|
||||
const headers = new Headers(await buildAuthHeaders(ctx.userId))
|
||||
const modelInputMetadata = createModelInputProvenanceRequestMetadata(
|
||||
modelInputProjection.registry,
|
||||
modelInputPaths
|
||||
)
|
||||
const requestBody = addModelInputProvenanceToRequest(
|
||||
{ provider: providerId, ...providerRequest },
|
||||
headers,
|
||||
createModelInputProvenanceRequestMetadata(
|
||||
ctx.resolvedSecretTraceRegistry,
|
||||
collectProviderModelInputProvenanceValues(providerRequest, providerId)
|
||||
)
|
||||
modelInputMetadata
|
||||
)
|
||||
if (modelInputMetadata) markModelInputProjected(headers)
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'POST',
|
||||
headers,
|
||||
@@ -207,7 +242,7 @@ export class EvaluatorBlockHandler implements BlockHandler {
|
||||
ctx.resolvedSecretTraceRegistry
|
||||
)
|
||||
|
||||
const metricScores = this.extractMetricScores(parsedContent, inputs.metrics)
|
||||
const metricScores = this.extractMetricScores(parsedContent, metrics, projectedMetrics)
|
||||
|
||||
const inputTokens = result.tokens?.input || result.tokens?.prompt || DEFAULTS.TOKENS.PROMPT
|
||||
const outputTokens =
|
||||
@@ -297,7 +332,8 @@ export class EvaluatorBlockHandler implements BlockHandler {
|
||||
|
||||
private extractMetricScores(
|
||||
parsedContent: Record<string, any>,
|
||||
metrics: any
|
||||
metrics: any,
|
||||
projectedMetrics: any
|
||||
): Record<string, number> {
|
||||
const metricScores: Record<string, number> = {}
|
||||
let validMetrics: any[]
|
||||
@@ -316,6 +352,7 @@ export class EvaluatorBlockHandler implements BlockHandler {
|
||||
return metricScores
|
||||
}
|
||||
|
||||
const validProjectedMetrics = Array.isArray(projectedMetrics) ? projectedMetrics : []
|
||||
validMetrics.forEach((metric: any, metricIndex: number) => {
|
||||
if (!metric?.name) {
|
||||
logger.warn('Skipping invalid metric entry', {
|
||||
@@ -325,7 +362,11 @@ export class EvaluatorBlockHandler implements BlockHandler {
|
||||
return
|
||||
}
|
||||
|
||||
const score = this.findMetricScore(parsedContent, metric.name)
|
||||
const projectedName = validProjectedMetrics[metricIndex]?.name
|
||||
const score = this.findMetricScore(
|
||||
parsedContent,
|
||||
typeof projectedName === 'string' && projectedName ? projectedName : metric.name
|
||||
)
|
||||
metricScores[metric.name.toLowerCase()] = score
|
||||
})
|
||||
|
||||
|
||||
@@ -200,6 +200,49 @@ describe('FunctionBlockHandler', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('forwards an explicit selected secret scope without changing legacy unset blocks', async () => {
|
||||
await handler.execute(mockContext, mockBlock, {
|
||||
code: 'return {{API_KEY}}',
|
||||
secretScope: 'selected',
|
||||
mountedSecrets: [' API_KEY ', 42, 'SECOND_KEY', '', 'API_KEY'],
|
||||
})
|
||||
|
||||
expect(mockExecuteTool).toHaveBeenCalledWith(
|
||||
'function_execute',
|
||||
expect.objectContaining({
|
||||
secretScope: 'selected',
|
||||
mountedSecrets: ['API_KEY', 'SECOND_KEY'],
|
||||
}),
|
||||
{ executionContext: mockContext }
|
||||
)
|
||||
|
||||
vi.clearAllMocks()
|
||||
mockExecuteTool.mockResolvedValue({ success: true, output: { result: 'Success' } })
|
||||
|
||||
await handler.execute(mockContext, mockBlock, { code: 'return {{API_KEY}}' })
|
||||
|
||||
const legacyParams = mockExecuteTool.mock.calls[0][1]
|
||||
expect(legacyParams).not.toHaveProperty('secretScope')
|
||||
expect(legacyParams).not.toHaveProperty('mountedSecrets')
|
||||
})
|
||||
|
||||
it('fails closed for an invalid explicit secret scope', async () => {
|
||||
await handler.execute(mockContext, mockBlock, {
|
||||
code: 'return {{API_KEY}}',
|
||||
secretScope: 'invalid',
|
||||
mountedSecrets: ['API_KEY'],
|
||||
})
|
||||
|
||||
expect(mockExecuteTool).toHaveBeenCalledWith(
|
||||
'function_execute',
|
||||
expect.objectContaining({
|
||||
secretScope: 'selected',
|
||||
mountedSecrets: [],
|
||||
}),
|
||||
{ executionContext: mockContext }
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle execution errors from the tool', async () => {
|
||||
const inputs = { code: 'throw new Error("Code failed");' }
|
||||
const errorResult = { success: false, error: 'Function execution failed: Code failed' }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizeSecretMountPolicy } from '@/lib/copilot/secret-mount-policy'
|
||||
import { getRemainingExecutionMs } from '@/lib/core/execution-limits'
|
||||
import {
|
||||
normalizeRecord,
|
||||
@@ -68,6 +69,13 @@ export class FunctionBlockHandler implements BlockHandler {
|
||||
? remainingExecutionMs
|
||||
: Math.min(requestedTimeout, remainingExecutionMs)
|
||||
)
|
||||
const secretMountPolicy =
|
||||
inputs.secretScope === undefined
|
||||
? undefined
|
||||
: normalizeSecretMountPolicy({
|
||||
secretScope: inputs.secretScope,
|
||||
mountedSecrets: inputs.mountedSecrets,
|
||||
})
|
||||
|
||||
const toolParams = {
|
||||
code: codeContent,
|
||||
@@ -75,6 +83,7 @@ export class FunctionBlockHandler implements BlockHandler {
|
||||
language: inputs.language || DEFAULT_CODE_LANGUAGE,
|
||||
timeout,
|
||||
...(inputs.sandboxId ? { sandboxId: inputs.sandboxId } : {}),
|
||||
...(secretMountPolicy ?? {}),
|
||||
envVars: normalizeStringRecord(ctx.environmentVariables),
|
||||
workflowVariables: normalizeWorkflowVariables(ctx.workflowVariables),
|
||||
blockData: {},
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import '@sim/testing/mocks/executor'
|
||||
|
||||
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
|
||||
import { KnowledgeBlock } from '@/blocks/blocks/knowledge'
|
||||
import { getBlock } from '@/blocks/index'
|
||||
import { BlockType } from '@/executor/constants'
|
||||
import { GenericBlockHandler } from '@/executor/handlers/generic/generic-handler'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
import { executeTool } from '@/tools'
|
||||
import { selectKnowledgeDocumentWriteSecretProvenance } from '@/tools/knowledge/secret-provenance'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { getTool } from '@/tools/utils'
|
||||
|
||||
const mockGetBlock = vi.mocked(getBlock)
|
||||
const mockGetTool = vi.mocked(getTool)
|
||||
const mockExecuteTool = executeTool as Mock
|
||||
|
||||
@@ -60,6 +65,7 @@ describe('GenericBlockHandler', () => {
|
||||
|
||||
// Reset mocks using vi
|
||||
vi.clearAllMocks()
|
||||
mockGetBlock.mockReturnValue(undefined)
|
||||
|
||||
// Set up mockGetTool to return mockTool
|
||||
mockGetTool.mockImplementation((toolId) => {
|
||||
@@ -98,6 +104,392 @@ describe('GenericBlockHandler', () => {
|
||||
expect(result).toEqual(expectedOutput)
|
||||
})
|
||||
|
||||
it('preserves exact secret provenance when block params rename a selected input', async () => {
|
||||
mockTool.request.modelInput = {
|
||||
mode: 'private-provenance',
|
||||
inputPaths: () => [['filePath']],
|
||||
}
|
||||
mockGetBlock.mockReturnValue({
|
||||
tools: {
|
||||
access: ['some_custom_tool'],
|
||||
config: {
|
||||
tool: () => 'some_custom_tool',
|
||||
params: (params: Record<string, unknown>) => ({
|
||||
filePath: String(params.document).trim(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
inputs: {
|
||||
document: { type: 'string', description: 'Document URL' },
|
||||
},
|
||||
} as never)
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{
|
||||
name: 'FILE_SECRET',
|
||||
plaintext: 'secret-url',
|
||||
encryptedValue: 'encrypted-file-secret',
|
||||
},
|
||||
])
|
||||
registry.recordResolvedAtInputPath('FILE_SECRET', 'secret-url', ['document'])
|
||||
registry.recordResolvedInputProjection(['document'], ' secret-url ', ' {{FILE_SECRET}} ')
|
||||
mockContext.resolvedSecretTraceRegistry = registry
|
||||
|
||||
await handler.execute(mockContext, mockBlock, { document: ' secret-url ' })
|
||||
|
||||
expect(mockExecuteTool).toHaveBeenCalledWith(
|
||||
'some_custom_tool',
|
||||
expect.objectContaining({
|
||||
document: ' secret-url ',
|
||||
filePath: 'secret-url',
|
||||
}),
|
||||
{ executionContext: mockContext }
|
||||
)
|
||||
expect(registry.exportCommittedProvenanceForInputPaths([['filePath']])).toMatchObject({
|
||||
complete: true,
|
||||
entries: [{ name: 'FILE_SECRET', encryptedValue: 'encrypted-file-secret' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('traces each secret path without letting secret-valued controls change another path', async () => {
|
||||
mockTool.request.modelInput = {
|
||||
mode: 'private-provenance',
|
||||
inputPaths: () => [['input']],
|
||||
}
|
||||
mockGetBlock.mockReturnValue({
|
||||
tools: {
|
||||
access: ['some_custom_tool'],
|
||||
config: {
|
||||
tool: () => 'some_custom_tool',
|
||||
params: (params: Record<string, unknown>) =>
|
||||
params.operation === 'deep_research' ? { input: params.research_input } : {},
|
||||
},
|
||||
},
|
||||
inputs: {
|
||||
operation: { type: 'string', description: 'Operation' },
|
||||
research_input: { type: 'string', description: 'Research input' },
|
||||
},
|
||||
} as never)
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{
|
||||
name: 'OPERATION',
|
||||
plaintext: 'deep_research',
|
||||
encryptedValue: 'encrypted-operation',
|
||||
},
|
||||
{ name: 'QUERY', plaintext: 'secret query', encryptedValue: 'encrypted-query' },
|
||||
])
|
||||
registry.recordResolvedAtInputPath('OPERATION', 'deep_research', ['operation'])
|
||||
registry.recordResolvedInputProjection(['operation'], 'deep_research', '{{OPERATION}}')
|
||||
registry.recordResolvedAtInputPath('QUERY', 'secret query', ['research_input'])
|
||||
registry.recordResolvedInputProjection(['research_input'], 'secret query', '{{QUERY}}')
|
||||
mockContext.resolvedSecretTraceRegistry = registry
|
||||
|
||||
await handler.execute(mockContext, mockBlock, {
|
||||
operation: 'deep_research',
|
||||
research_input: 'secret query',
|
||||
})
|
||||
|
||||
expect(mockExecuteTool).toHaveBeenCalledWith(
|
||||
'some_custom_tool',
|
||||
expect.objectContaining({
|
||||
operation: 'deep_research',
|
||||
research_input: 'secret query',
|
||||
input: 'secret query',
|
||||
}),
|
||||
{ executionContext: mockContext }
|
||||
)
|
||||
expect(registry.exportCommittedProvenanceForInputPaths([['input']])).toMatchObject({
|
||||
complete: true,
|
||||
entries: [{ name: 'QUERY', encryptedValue: 'encrypted-query' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves exact table leaf provenance across legacy unquoted JSON placeholders', async () => {
|
||||
mockTool.request.secretProvenance = {
|
||||
request: () => [{ key: 'data', inputPaths: [['data']] }],
|
||||
}
|
||||
mockGetBlock.mockReturnValue({
|
||||
tools: {
|
||||
access: ['some_custom_tool'],
|
||||
config: {
|
||||
tool: () => 'some_custom_tool',
|
||||
params: (params: Record<string, unknown>) => ({
|
||||
data: typeof params.data === 'string' ? JSON.parse(params.data) : params.data,
|
||||
}),
|
||||
},
|
||||
},
|
||||
inputs: {
|
||||
data: { type: 'json', description: 'Row data' },
|
||||
},
|
||||
} as never)
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: '1BOOLEAN_SECRET', plaintext: 'true', encryptedValue: 'encrypted-boolean' },
|
||||
{ name: 'UNUSED', plaintext: 'true', encryptedValue: 'encrypted-unused' },
|
||||
])
|
||||
registry.recordResolvedAtInputPath('1BOOLEAN_SECRET', 'true', ['data'])
|
||||
registry.recordResolvedInputProjection(
|
||||
['data'],
|
||||
'{"secret":true,"public":true}',
|
||||
'{"secret":{{1BOOLEAN_SECRET}},"public":true}'
|
||||
)
|
||||
mockContext.resolvedSecretTraceRegistry = registry
|
||||
|
||||
await handler.execute(mockContext, mockBlock, {
|
||||
data: '{"secret":true,"public":true}',
|
||||
})
|
||||
|
||||
expect(mockExecuteTool).toHaveBeenCalledWith(
|
||||
'some_custom_tool',
|
||||
expect.objectContaining({ data: { secret: true, public: true } }),
|
||||
{ executionContext: mockContext }
|
||||
)
|
||||
expect(registry.exportCommittedProvenanceForInputPaths([['data', 'secret']])).toMatchObject({
|
||||
complete: true,
|
||||
entries: [{ name: '1BOOLEAN_SECRET', encryptedValue: 'encrypted-boolean' }],
|
||||
})
|
||||
expect(registry.exportCommittedProvenanceForInputPaths([['data', 'public']])).toMatchObject({
|
||||
complete: true,
|
||||
entries: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes legacy JSON-string knowledge tags without mutating inputs or overbinding tag names', async () => {
|
||||
mockTool.request.secretProvenance = {
|
||||
request: selectKnowledgeDocumentWriteSecretProvenance,
|
||||
}
|
||||
mockGetBlock.mockReturnValue(KnowledgeBlock)
|
||||
mockBlock.metadata = { id: 'knowledge', name: 'Knowledge' }
|
||||
const documentTags = '[{"tagName":"team","value":"support"}]'
|
||||
const projectedTags = '[{"tagName":"team","value":"{{TAG_VALUE}}"}]'
|
||||
const inputs = {
|
||||
operation: 'create_document',
|
||||
knowledgeBaseId: 'kb-1',
|
||||
name: 'doc.md',
|
||||
content: 'content',
|
||||
documentTags,
|
||||
}
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'TAG_VALUE', plaintext: 'support', encryptedValue: 'encrypted-tag' },
|
||||
])
|
||||
registry.recordResolvedAtInputPath('TAG_VALUE', 'support', ['documentTags'])
|
||||
registry.recordResolvedInputProjection(['documentTags'], documentTags, projectedTags)
|
||||
mockContext.resolvedSecretTraceRegistry = registry
|
||||
|
||||
await handler.execute(mockContext, mockBlock, inputs)
|
||||
|
||||
expect(inputs.documentTags).toBe(documentTags)
|
||||
expect(mockExecuteTool).toHaveBeenCalledWith(
|
||||
'some_custom_tool',
|
||||
expect.objectContaining({
|
||||
documentTags: [{ tagName: 'team', value: 'support' }],
|
||||
}),
|
||||
{ executionContext: mockContext }
|
||||
)
|
||||
expect(
|
||||
registry.exportCommittedProvenanceForInputPaths([['documentTags', '0', 'tagName']])
|
||||
).toMatchObject({ complete: true, entries: [] })
|
||||
expect(
|
||||
registry.exportCommittedProvenanceForInputPaths([['documentTags', '0', 'value']])
|
||||
).toMatchObject({
|
||||
complete: true,
|
||||
entries: [{ name: 'TAG_VALUE', encryptedValue: 'encrypted-tag' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves a whole structured secret without changing the raw parsed value', async () => {
|
||||
mockTool.request.secretProvenance = {
|
||||
request: () => [{ key: 'data', inputPaths: [['data']] }],
|
||||
}
|
||||
mockGetBlock.mockReturnValue({
|
||||
tools: {
|
||||
access: ['some_custom_tool'],
|
||||
config: {
|
||||
tool: () => 'some_custom_tool',
|
||||
params: (params: Record<string, unknown>) => ({
|
||||
data: typeof params.data === 'string' ? JSON.parse(params.data) : params.data,
|
||||
}),
|
||||
},
|
||||
},
|
||||
inputs: {
|
||||
data: { type: 'json', description: 'Row data' },
|
||||
},
|
||||
} as never)
|
||||
const rawStructuredSecret = '{"nested":"value","url":"https://example.com/data","count":1}'
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{
|
||||
name: 'JSON_SECRET',
|
||||
plaintext: rawStructuredSecret,
|
||||
encryptedValue: 'encrypted-json',
|
||||
},
|
||||
])
|
||||
registry.recordResolvedAtInputPath('JSON_SECRET', rawStructuredSecret, ['data'])
|
||||
registry.recordResolvedInputProjection(['data'], rawStructuredSecret, '{{JSON_SECRET}}')
|
||||
mockContext.resolvedSecretTraceRegistry = registry
|
||||
|
||||
await handler.execute(mockContext, mockBlock, { data: rawStructuredSecret })
|
||||
|
||||
expect(mockExecuteTool).toHaveBeenCalledWith(
|
||||
'some_custom_tool',
|
||||
expect.objectContaining({
|
||||
data: { nested: 'value', url: 'https://example.com/data', count: 1 },
|
||||
}),
|
||||
{ executionContext: mockContext }
|
||||
)
|
||||
expect(registry.exportCommittedProvenanceForInputPaths([['data', 'nested']])).toMatchObject({
|
||||
complete: true,
|
||||
entries: [{ name: 'JSON_SECRET', encryptedValue: 'encrypted-json' }],
|
||||
})
|
||||
expect(registry.exportCommittedProvenanceForInputPaths([['data', 'count']])).toMatchObject({
|
||||
complete: true,
|
||||
entries: [{ name: 'JSON_SECRET', encryptedValue: 'encrypted-json' }],
|
||||
})
|
||||
expect(registry.exportCommittedProvenanceForInputPaths([['data', 'url']])).toMatchObject({
|
||||
complete: true,
|
||||
entries: [{ name: 'JSON_SECRET', encryptedValue: 'encrypted-json' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves structured message roles while projecting only model-visible content', async () => {
|
||||
const parseMessages = (value: unknown) => {
|
||||
const parsed = typeof value === 'string' ? JSON.parse(value) : value
|
||||
if (!Array.isArray(parsed)) throw new Error('Messages must be an array')
|
||||
return parsed.map((message) => {
|
||||
if (
|
||||
!message ||
|
||||
typeof message !== 'object' ||
|
||||
!['user', 'assistant', 'system'].includes(String(message.role))
|
||||
) {
|
||||
throw new Error('Invalid message role')
|
||||
}
|
||||
return { role: String(message.role), content: String(message.content) }
|
||||
})
|
||||
}
|
||||
mockTool.request.modelInput = {
|
||||
mode: 'project',
|
||||
select: (params) => ({
|
||||
messages: parseMessages(params.messages).map((message) => message.content),
|
||||
}),
|
||||
applyProjected: (selectedParams, projectedSelection) => ({
|
||||
messages: parseMessages(selectedParams.messages).map((message, index) => ({
|
||||
...message,
|
||||
content: (projectedSelection.messages as unknown[])[index],
|
||||
})),
|
||||
}),
|
||||
}
|
||||
mockGetBlock.mockReturnValue({
|
||||
tools: {
|
||||
access: ['some_custom_tool'],
|
||||
config: {
|
||||
tool: () => 'some_custom_tool',
|
||||
params: (params: Record<string, unknown>) => ({
|
||||
messages: parseMessages(params.messages),
|
||||
}),
|
||||
},
|
||||
},
|
||||
inputs: {
|
||||
messages: { type: 'json', description: 'Messages' },
|
||||
},
|
||||
} as never)
|
||||
const rawMessages = '[{"role":"user","content":"hello"}]'
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'MESSAGES', plaintext: rawMessages, encryptedValue: 'encrypted-messages' },
|
||||
])
|
||||
registry.recordResolvedAtInputPath('MESSAGES', rawMessages, ['messages'])
|
||||
registry.recordResolvedInputProjection(['messages'], rawMessages, '{{MESSAGES}}')
|
||||
mockContext.resolvedSecretTraceRegistry = registry
|
||||
|
||||
await handler.execute(mockContext, mockBlock, { messages: rawMessages })
|
||||
|
||||
expect(mockExecuteTool).toHaveBeenCalledWith(
|
||||
'some_custom_tool',
|
||||
expect.objectContaining({ messages: [{ role: 'user', content: 'hello' }] }),
|
||||
{ executionContext: mockContext }
|
||||
)
|
||||
expect(
|
||||
registry.exportCommittedProvenanceForInputPaths([['messages', '0', 'role']])
|
||||
).toMatchObject({ complete: true, entries: [] })
|
||||
expect(
|
||||
registry.exportCommittedProvenanceForInputPaths([['messages', '0', 'content']])
|
||||
).toMatchObject({
|
||||
complete: true,
|
||||
entries: [{ name: 'MESSAGES', encryptedValue: 'encrypted-messages' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves raw file execution while binding whole serialized descriptors to the file boundary', async () => {
|
||||
mockTool.params.audioFile = { type: 'file' }
|
||||
mockTool.params.audioUrl = { type: 'string' }
|
||||
mockTool.request.modelInput = {
|
||||
mode: 'private-provenance',
|
||||
inputPaths: () => [['audioUrl']],
|
||||
}
|
||||
mockGetBlock.mockReturnValue({
|
||||
tools: {
|
||||
access: ['some_custom_tool'],
|
||||
config: {
|
||||
tool: () => 'some_custom_tool',
|
||||
params: (params: Record<string, unknown>) => {
|
||||
const file =
|
||||
typeof params.audioFile === 'string' ? JSON.parse(params.audioFile) : params.audioFile
|
||||
if (!file || typeof file !== 'object' || !String(file.url).startsWith('https://')) {
|
||||
throw new Error('A valid HTTPS audio file is required')
|
||||
}
|
||||
return { audioUrl: String(file.url), audioFile: undefined }
|
||||
},
|
||||
},
|
||||
},
|
||||
inputs: {
|
||||
audioFile: { type: 'json', description: 'Audio file' },
|
||||
},
|
||||
} as never)
|
||||
const rawFile = '{"name":"audio.mp3","size":4,"url":"https://files.example/audio.mp3"}'
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'FILE', plaintext: rawFile, encryptedValue: 'encrypted-file' },
|
||||
])
|
||||
registry.recordResolvedAtInputPath('FILE', rawFile, ['audioFile'])
|
||||
registry.recordResolvedInputProjection(['audioFile'], rawFile, '{{FILE}}')
|
||||
mockContext.resolvedSecretTraceRegistry = registry
|
||||
|
||||
await handler.execute(mockContext, mockBlock, { audioFile: rawFile })
|
||||
|
||||
expect(mockExecuteTool).toHaveBeenCalledWith(
|
||||
'some_custom_tool',
|
||||
expect.objectContaining({
|
||||
audioFile: undefined,
|
||||
audioUrl: 'https://files.example/audio.mp3',
|
||||
}),
|
||||
{ executionContext: mockContext }
|
||||
)
|
||||
expect(registry.isComplete()).toBe(true)
|
||||
expect(registry.exportCommittedProvenanceForInputPaths([['audioUrl']])).toMatchObject({
|
||||
complete: true,
|
||||
entries: [{ name: 'FILE', encryptedValue: 'encrypted-file' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('does not replay block transforms for configured but unused secrets', async () => {
|
||||
mockTool.request.modelInput = {
|
||||
mode: 'project',
|
||||
select: (params) => ({ param1: params.param1 }),
|
||||
}
|
||||
const transform = vi.fn((params: Record<string, unknown>) => params)
|
||||
mockGetBlock.mockReturnValue({
|
||||
tools: {
|
||||
access: ['some_custom_tool'],
|
||||
config: { tool: () => 'some_custom_tool', params: transform },
|
||||
},
|
||||
inputs: {
|
||||
param1: { type: 'string', description: 'Value' },
|
||||
},
|
||||
} as never)
|
||||
mockContext.resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'UNUSED', plaintext: 'value1', encryptedValue: 'encrypted-unused' },
|
||||
])
|
||||
|
||||
await handler.execute(mockContext, mockBlock, { param1: 'value1' })
|
||||
|
||||
expect(transform).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should throw error if the associated tool is not found', async () => {
|
||||
const inputs = { param1: 'value' }
|
||||
|
||||
|
||||
@@ -1,15 +1,154 @@
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { isPlainRecord } from '@sim/utils/object'
|
||||
import { getBlock } from '@/blocks/index'
|
||||
import { isMcpTool } from '@/executor/constants'
|
||||
import type { BlockHandler, ExecutionContext } from '@/executor/types'
|
||||
import { readStatusCode } from '@/executor/utils/errors'
|
||||
import { prepareResolvedSecretProjectedInputs } from '@/executor/utils/resolved-secret-input-projection'
|
||||
import type { ResolvedSecretInputPath } from '@/executor/utils/resolved-secret-trace-registry'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
import { executeTool } from '@/tools'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { getTool } from '@/tools/utils'
|
||||
|
||||
const logger = createLogger('GenericBlockHandler')
|
||||
|
||||
interface BlockBoundaryPaths {
|
||||
paths: ResolvedSecretInputPath[]
|
||||
requiredProjectionRoots: Set<string>
|
||||
}
|
||||
|
||||
function selectBlockBoundaryPaths(
|
||||
tool: ToolConfig,
|
||||
params: Record<string, unknown>
|
||||
): BlockBoundaryPaths | undefined {
|
||||
try {
|
||||
const paths: ResolvedSecretInputPath[] = []
|
||||
const requiredProjectionRoots = new Set<string>()
|
||||
const modelInput = tool.request.modelInput
|
||||
if (modelInput?.mode === 'project') {
|
||||
const selected = modelInput.select(params)
|
||||
if (!isPlainRecord(selected)) return undefined
|
||||
for (const key of Object.keys(selected)) {
|
||||
requiredProjectionRoots.add(key)
|
||||
paths.push([key])
|
||||
}
|
||||
const privateInputPaths = modelInput.privateInputPaths?.(params) ?? []
|
||||
paths.push(...privateInputPaths)
|
||||
for (const path of privateInputPaths) {
|
||||
if (path[0]) requiredProjectionRoots.add(path[0])
|
||||
}
|
||||
} else if (modelInput?.mode === 'private-provenance') {
|
||||
const privateInputPaths = modelInput.inputPaths(params)
|
||||
paths.push(...privateInputPaths)
|
||||
for (const path of privateInputPaths) {
|
||||
if (path[0]) requiredProjectionRoots.add(path[0])
|
||||
}
|
||||
}
|
||||
const opaqueInputPaths = tool.request.opaqueModelInput?.inputPaths(params) ?? []
|
||||
paths.push(...opaqueInputPaths)
|
||||
for (const path of opaqueInputPaths) {
|
||||
if (path[0]) requiredProjectionRoots.add(path[0])
|
||||
}
|
||||
for (const selection of tool.request.secretProvenance?.request?.(params) ?? []) {
|
||||
paths.push(...selection.inputPaths)
|
||||
for (const path of selection.inputPaths) {
|
||||
if (path[0]) requiredProjectionRoots.add(path[0])
|
||||
}
|
||||
}
|
||||
|
||||
const uniquePaths = new Map<string, ResolvedSecretInputPath>()
|
||||
for (const path of paths) {
|
||||
if (path.length > 0) uniquePaths.set(JSON.stringify(path), path)
|
||||
}
|
||||
return { paths: [...uniquePaths.values()], requiredProjectionRoots }
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalPlaceholder(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined
|
||||
const match = /^\{\{([A-Za-z0-9_]+)\}\}$/.exec(value.trim())
|
||||
return match ? value.trim() : undefined
|
||||
}
|
||||
|
||||
function isFileBoundaryPath(tool: ToolConfig, path: ResolvedSecretInputPath): boolean {
|
||||
return Boolean(path[0] && tool.params[path[0]]?.type === 'file')
|
||||
}
|
||||
|
||||
function projectScalarLeaves(
|
||||
value: unknown,
|
||||
placeholder: string
|
||||
): { value: unknown; projectedLeaves: number } | undefined {
|
||||
if (value === null || typeof value !== 'object') {
|
||||
return { value: placeholder, projectedLeaves: 1 }
|
||||
}
|
||||
if (!Array.isArray(value) && !isPlainRecord(value)) return undefined
|
||||
|
||||
const root: unknown[] | Record<string, unknown> = Array.isArray(value) ? [] : {}
|
||||
const pending: Array<{
|
||||
source: unknown[] | Record<string, unknown>
|
||||
target: unknown[] | Record<string, unknown>
|
||||
}> = [{ source: value as unknown[] | Record<string, unknown>, target: root }]
|
||||
const visited = new WeakSet<object>()
|
||||
let projectedLeaves = 0
|
||||
while (pending.length > 0) {
|
||||
const { source, target } = pending.pop()!
|
||||
if (visited.has(source)) return undefined
|
||||
visited.add(source)
|
||||
for (const [key, child] of Object.entries(source)) {
|
||||
if (child !== null && typeof child === 'object') {
|
||||
if (!Array.isArray(child) && !isPlainRecord(child)) return undefined
|
||||
const projectedChild: unknown[] | Record<string, unknown> = Array.isArray(child) ? [] : {}
|
||||
;(target as Record<string, unknown>)[key] = projectedChild
|
||||
pending.push({
|
||||
source: child as unknown[] | Record<string, unknown>,
|
||||
target: projectedChild,
|
||||
})
|
||||
} else {
|
||||
;(target as Record<string, unknown>)[key] = placeholder
|
||||
projectedLeaves += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return { value: root, projectedLeaves }
|
||||
}
|
||||
|
||||
function createStructuredModelProjection(
|
||||
tool: ToolConfig,
|
||||
finalInputs: Record<string, unknown>,
|
||||
sourcePath: ResolvedSecretInputPath,
|
||||
projectedSourceValue: unknown
|
||||
): Record<string, unknown> | undefined {
|
||||
const modelInput = tool.request.modelInput
|
||||
const sourceKey = sourcePath.length === 1 ? sourcePath[0] : undefined
|
||||
const placeholder = canonicalPlaceholder(projectedSourceValue)
|
||||
if (modelInput?.mode !== 'project' || !modelInput.applyProjected || !sourceKey || !placeholder) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
const selected = modelInput.select(finalInputs)
|
||||
if (!isPlainRecord(selected) || !Object.hasOwn(selected, sourceKey)) return undefined
|
||||
const projectedValue = projectScalarLeaves(selected[sourceKey], placeholder)
|
||||
if (!projectedValue || projectedValue.projectedLeaves === 0) return undefined
|
||||
const projectedSelection = { ...selected, [sourceKey]: projectedValue.value }
|
||||
const selectedParams = Object.fromEntries(
|
||||
Object.keys(selected).map((key) => [key, finalInputs[key]])
|
||||
)
|
||||
const patch = modelInput.applyProjected(structuredClone(selectedParams), projectedSelection)
|
||||
if (!isPlainRecord(patch)) return undefined
|
||||
const projectedInputs = { ...finalInputs, ...patch }
|
||||
if (!isDeepStrictEqual(modelInput.select(projectedInputs), projectedSelection)) return undefined
|
||||
return projectedInputs
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export class GenericBlockHandler implements BlockHandler {
|
||||
canHandle(block: SerializedBlock): boolean {
|
||||
return true
|
||||
@@ -35,6 +174,8 @@ export class GenericBlockHandler implements BlockHandler {
|
||||
const blockType = block.metadata?.id
|
||||
if (blockType) {
|
||||
const blockConfig = getBlock(blockType)
|
||||
const registry = ctx.resolvedSecretTraceRegistry
|
||||
|
||||
if (blockConfig?.tools?.config?.params) {
|
||||
const transformedParams = blockConfig.tools.config.params(inputs)
|
||||
finalInputs = { ...inputs, ...transformedParams }
|
||||
@@ -57,6 +198,74 @@ export class GenericBlockHandler implements BlockHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const boundary = tool ? selectBlockBoundaryPaths(tool, finalInputs) : undefined
|
||||
const projectedInputs =
|
||||
boundary && boundary.paths.length > 0 && registry?.hasResolvedInputProjections()
|
||||
? registry.projectResolvedInputSelections(inputs)
|
||||
: undefined
|
||||
if (projectedInputs?.complete === false) registry?.markIncomplete()
|
||||
|
||||
if (projectedInputs?.complete && boundary && tool && registry) {
|
||||
for (const projection of projectedInputs.values) {
|
||||
const preserveFileDescriptorGrammar =
|
||||
isFileBoundaryPath(tool, projection.path) ||
|
||||
boundary.paths.some((path) => isFileBoundaryPath(tool, path))
|
||||
let projectedFinalInputs = prepareResolvedSecretProjectedInputs(
|
||||
projection.value,
|
||||
blockConfig?.inputs,
|
||||
inputs,
|
||||
{ preserveFileDescriptorGrammar }
|
||||
)
|
||||
try {
|
||||
if (blockConfig?.tools?.config?.params) {
|
||||
projectedFinalInputs = {
|
||||
...projectedFinalInputs,
|
||||
...blockConfig.tools.config.params(projectedFinalInputs),
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
const structuredProjection = createStructuredModelProjection(
|
||||
tool,
|
||||
finalInputs,
|
||||
projection.path,
|
||||
projection.projectedValue
|
||||
)
|
||||
if (structuredProjection) {
|
||||
registry.recordTransformedInputProjection(finalInputs, structuredProjection, {
|
||||
targetPaths: boundary.paths,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (boundary.requiredProjectionRoots.has(projection.path[0])) {
|
||||
registry.markIncomplete()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (blockConfig?.inputs) {
|
||||
projectedFinalInputs = prepareResolvedSecretProjectedInputs(
|
||||
projectedFinalInputs,
|
||||
blockConfig.inputs,
|
||||
finalInputs,
|
||||
{ preserveFileDescriptorGrammar }
|
||||
)
|
||||
for (const [key, inputSchema] of Object.entries(blockConfig.inputs)) {
|
||||
const value = projectedFinalInputs[key]
|
||||
if (typeof value !== 'string' || value.trim().length === 0) continue
|
||||
const inputType = typeof inputSchema === 'object' ? inputSchema.type : inputSchema
|
||||
if (inputType !== 'json' && inputType !== 'array') continue
|
||||
try {
|
||||
projectedFinalInputs[key] = JSON.parse(value.trim())
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
registry.recordTransformedInputProjection(finalInputs, projectedFinalInputs, {
|
||||
targetPaths: boundary.paths,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -5,8 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { BlockType } from '@/executor/constants'
|
||||
import { MothershipBlockHandler } from '@/executor/handlers/mothership/mothership-handler'
|
||||
import type { ExecutionContext, StreamingExecution } from '@/executor/types'
|
||||
import { createResolvedSecretMatcher } from '@/executor/utils/resolved-secret-content-projection'
|
||||
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
|
||||
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
|
||||
const BILLING_ATTRIBUTION = {
|
||||
@@ -121,21 +120,16 @@ async function readStreamText(stream: ReadableStream): Promise<string> {
|
||||
}
|
||||
|
||||
function createTraceRegistryMock(): ResolvedSecretTraceRegistry & {
|
||||
getModelEgressRevision: ReturnType<typeof vi.fn>
|
||||
getModelEgressSnapshot: ReturnType<typeof vi.fn>
|
||||
importProvenanceForValue: ReturnType<typeof vi.fn>
|
||||
markIncomplete: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
return {
|
||||
getModelEgressRevision: vi.fn().mockReturnValue(0),
|
||||
getModelEgressSnapshot: vi
|
||||
.fn()
|
||||
.mockReturnValue({ complete: true, matches: [], matcher: undefined }),
|
||||
importProvenanceForValue: vi.fn().mockResolvedValue(true),
|
||||
markIncomplete: vi.fn(),
|
||||
} as unknown as ResolvedSecretTraceRegistry & {
|
||||
getModelEgressRevision: ReturnType<typeof vi.fn>
|
||||
getModelEgressSnapshot: ReturnType<typeof vi.fn>
|
||||
const registry = new ResolvedSecretTraceRegistry()
|
||||
return Object.assign(registry, {
|
||||
importProvenanceForValue: vi
|
||||
.spyOn(registry, 'importProvenanceForValue')
|
||||
.mockResolvedValue(true),
|
||||
markIncomplete: vi.spyOn(registry, 'markIncomplete'),
|
||||
}) as ResolvedSecretTraceRegistry & {
|
||||
importProvenanceForValue: ReturnType<typeof vi.fn>
|
||||
markIncomplete: ReturnType<typeof vi.fn>
|
||||
}
|
||||
@@ -299,19 +293,19 @@ describe('MothershipBlockHandler', () => {
|
||||
expect(JSON.stringify(result)).not.toContain('encrypted-secret')
|
||||
})
|
||||
|
||||
it('projects parent execution secrets before sending a Mothership prompt', async () => {
|
||||
const registry = createTraceRegistryMock()
|
||||
const matches = [
|
||||
{
|
||||
plaintext: 'cross-workspace-secret',
|
||||
replacement: '[REDACTED_SECRET]',
|
||||
},
|
||||
]
|
||||
registry.getModelEgressSnapshot.mockReturnValue({
|
||||
complete: true,
|
||||
matches,
|
||||
matcher: createResolvedSecretMatcher(matches),
|
||||
})
|
||||
it('projects only secrets resolved at the Mothership prompt input path', async () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'PROMPT_SECRET', plaintext: 'prompt-secret', encryptedValue: 'prompt-ciphertext' },
|
||||
{ name: 'UNUSED', plaintext: 'x', encryptedValue: 'unused-ciphertext' },
|
||||
])
|
||||
registry.recordResolvedAtInputPath('PROMPT_SECRET', 'prompt-secret', ['prompt'])
|
||||
registry.recordResolvedInputProjection(
|
||||
['prompt'],
|
||||
'Use prompt-secret while Box stays unchanged',
|
||||
'Use {{PROMPT_SECRET}} while Box stays unchanged'
|
||||
)
|
||||
registry.recordResolved('UNUSED', 'x')
|
||||
vi.spyOn(registry, 'importProvenanceForValue').mockResolvedValue(true)
|
||||
context.resolvedSecretTraceRegistry = registry
|
||||
mockGenerateId
|
||||
.mockReturnValueOnce('chat-uuid')
|
||||
@@ -320,25 +314,45 @@ describe('MothershipBlockHandler', () => {
|
||||
fetchMock.mockResolvedValue(createJsonResponse({ content: 'done', toolCalls: [] }))
|
||||
|
||||
await handler.execute(context, block, {
|
||||
prompt: 'Use cross-workspace-secret and __var_FOREIGN',
|
||||
prompt: 'Use prompt-secret while Box stays unchanged',
|
||||
})
|
||||
|
||||
const [, options] = fetchMock.mock.calls[0] as [string, RequestInit]
|
||||
const body = String(options.body)
|
||||
expect(body).toContain('[REDACTED_SECRET]')
|
||||
expect(body).not.toContain('cross-workspace-secret')
|
||||
expect(body).not.toContain('prompt-secret')
|
||||
expect(JSON.parse(body)).toMatchObject({
|
||||
messages: [{ content: 'Use [REDACTED_SECRET] and __var_FOREIGN' }],
|
||||
messages: [{ content: 'Use {{PROMPT_SECRET}} while Box stays unchanged' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('drops a legacy response without poisoning later provenance', async () => {
|
||||
it('preserves a headerless legacy JSON response without poisoning later calls', async () => {
|
||||
const registry = createTraceRegistryMock()
|
||||
context.resolvedSecretTraceRegistry = registry
|
||||
mockGenerateId
|
||||
.mockReturnValueOnce('chat-uuid')
|
||||
.mockReturnValueOnce('message-uuid')
|
||||
.mockReturnValueOnce('request-uuid')
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
content: 'unchanged output',
|
||||
toolCalls: [],
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
)
|
||||
|
||||
await expect(handler.execute(context, block, { prompt: 'Hello' })).resolves.toMatchObject({
|
||||
content: 'unchanged output',
|
||||
})
|
||||
|
||||
expect(registry.importProvenanceForValue).not.toHaveBeenCalled()
|
||||
expect(registry.markIncomplete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a headerless response that contains a partial private envelope', async () => {
|
||||
const registry = createTraceRegistryMock()
|
||||
context.resolvedSecretTraceRegistry = registry
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
@@ -351,11 +365,11 @@ describe('MothershipBlockHandler', () => {
|
||||
)
|
||||
|
||||
await expect(handler.execute(context, block, { prompt: 'Hello' })).rejects.toThrow(
|
||||
'does not support private provenance metadata'
|
||||
'provenance metadata is invalid'
|
||||
)
|
||||
|
||||
expect(registry.importProvenanceForValue).not.toHaveBeenCalled()
|
||||
expect(registry.markIncomplete).not.toHaveBeenCalled()
|
||||
expect(registry.markIncomplete).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('poisons provenance when a declared response omits its private field', async () => {
|
||||
@@ -379,13 +393,55 @@ describe('MothershipBlockHandler', () => {
|
||||
expect(registry.markIncomplete).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('fails closed before the request when the model-egress registry is unavailable', async () => {
|
||||
it('preserves legacy request and response behavior when no registry is available', async () => {
|
||||
context.resolvedSecretTraceRegistry = undefined
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ content: 'legacy output', toolCalls: [] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
)
|
||||
|
||||
await expect(handler.execute(context, block, { prompt: 'Hello' })).resolves.toMatchObject({
|
||||
content: 'legacy output',
|
||||
})
|
||||
expect(context.resolvedSecretTraceRegistry).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects an explicitly mismatched response metadata version', async () => {
|
||||
const registry = createTraceRegistryMock()
|
||||
context.resolvedSecretTraceRegistry = registry
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ content: 'unsafe output', toolCalls: [] }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-sim-private-tool-metadata': 'resolved-secret-provenance-v2',
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
await expect(handler.execute(context, block, { prompt: 'Hello' })).rejects.toThrow(
|
||||
'Mothership input could not be safely projected'
|
||||
'provenance metadata is invalid'
|
||||
)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(registry.markIncomplete).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves a headerless legacy upstream error without poisoning later calls', async () => {
|
||||
const registry = createTraceRegistryMock()
|
||||
context.resolvedSecretTraceRegistry = registry
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ error: 'legacy upstream error' }), {
|
||||
status: 502,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
)
|
||||
|
||||
await expect(handler.execute(context, block, { prompt: 'Hello' })).rejects.toThrow(
|
||||
'Sim execution failed: legacy upstream error'
|
||||
)
|
||||
expect(registry.importProvenanceForValue).not.toHaveBeenCalled()
|
||||
expect(registry.markIncomplete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('imports provenance from a terminal NDJSON error without forcing structural fallback', async () => {
|
||||
@@ -468,6 +524,100 @@ describe('MothershipBlockHandler', () => {
|
||||
expect(JSON.stringify(result.execution.output)).not.toContain('encrypted-secret')
|
||||
})
|
||||
|
||||
it('preserves a headerless legacy NDJSON final result without poisoning later calls', async () => {
|
||||
const registry = createTraceRegistryMock()
|
||||
context.resolvedSecretTraceRegistry = registry
|
||||
const encoder = new TextEncoder()
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`${JSON.stringify({
|
||||
type: 'final',
|
||||
data: { content: 'legacy final', toolCalls: [] },
|
||||
})}\n`
|
||||
)
|
||||
)
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/x-ndjson; charset=utf-8' } }
|
||||
)
|
||||
)
|
||||
|
||||
await expect(handler.execute(context, block, { prompt: 'Hello' })).resolves.toMatchObject({
|
||||
content: 'legacy final',
|
||||
})
|
||||
expect(registry.importProvenanceForValue).not.toHaveBeenCalled()
|
||||
expect(registry.markIncomplete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves headerless legacy selected-output streaming without poisoning later calls', async () => {
|
||||
const registry = createTraceRegistryMock()
|
||||
context.resolvedSecretTraceRegistry = registry
|
||||
context.stream = true
|
||||
context.selectedOutputs = [`${block.id}_content`]
|
||||
const encoder = new TextEncoder()
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode(`${JSON.stringify({ type: 'chunk', content: 'legacy chunk' })}\n`)
|
||||
)
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`${JSON.stringify({
|
||||
type: 'final',
|
||||
data: { content: 'legacy final', toolCalls: [] },
|
||||
})}\n`
|
||||
)
|
||||
)
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/x-ndjson; charset=utf-8' } }
|
||||
)
|
||||
)
|
||||
|
||||
const result = (await handler.execute(context, block, {
|
||||
prompt: 'Hello',
|
||||
})) as StreamingExecution
|
||||
await expect(readStreamText(result.stream)).resolves.toBe('legacy chunk')
|
||||
expect(result.execution.output).toMatchObject({ content: 'legacy final' })
|
||||
expect(registry.importProvenanceForValue).not.toHaveBeenCalled()
|
||||
expect(registry.markIncomplete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces a headerless legacy NDJSON terminal error without poisoning later calls', async () => {
|
||||
const registry = createTraceRegistryMock()
|
||||
context.resolvedSecretTraceRegistry = registry
|
||||
const encoder = new TextEncoder()
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`${JSON.stringify({ type: 'error', error: 'legacy terminal error' })}\n`
|
||||
)
|
||||
)
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/x-ndjson; charset=utf-8' } }
|
||||
)
|
||||
)
|
||||
|
||||
await expect(handler.execute(context, block, { prompt: 'Hello' })).rejects.toThrow(
|
||||
'Sim execution failed: legacy terminal error'
|
||||
)
|
||||
expect(registry.importProvenanceForValue).not.toHaveBeenCalled()
|
||||
expect(registry.markIncomplete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards workflow and execution metadata with generated UUID ids', async () => {
|
||||
mockGenerateId.mockReturnValueOnce('chat-uuid')
|
||||
mockGenerateId.mockReturnValueOnce('message-uuid')
|
||||
@@ -661,16 +811,12 @@ describe('MothershipBlockHandler', () => {
|
||||
expect(body.contexts).toEqual([{ kind: 'skill', skillId: 'skill-1', label: 'sales-playbook' }])
|
||||
})
|
||||
|
||||
it('projects proven model metadata without rewriting arbitrary attachment names or payloads', async () => {
|
||||
it('does not scan arbitrary Mothership metadata, attachment names, or payloads', async () => {
|
||||
const secret = 'boundary-secret'
|
||||
const replacement = '{{API_KEY}}'
|
||||
const registry = createTraceRegistryMock()
|
||||
const matches = [{ plaintext: secret, replacement }]
|
||||
registry.getModelEgressSnapshot.mockReturnValue({
|
||||
complete: true,
|
||||
matches,
|
||||
matcher: createResolvedSecretMatcher(matches),
|
||||
})
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'UNUSED_SECRET', plaintext: secret, encryptedValue: 'encrypted-unused-secret' },
|
||||
])
|
||||
vi.spyOn(registry, 'importProvenanceForValue').mockResolvedValue(true)
|
||||
context.resolvedSecretTraceRegistry = registry
|
||||
mockGenerateId
|
||||
.mockReturnValueOnce('chat-uuid')
|
||||
@@ -734,33 +880,366 @@ describe('MothershipBlockHandler', () => {
|
||||
usageControl: 'force',
|
||||
schema: {
|
||||
type: 'object',
|
||||
title: `Query ${replacement}`,
|
||||
description: `Search using ${replacement}`,
|
||||
title: `Query ${secret}`,
|
||||
description: `Search using ${secret}`,
|
||||
properties: {
|
||||
query: { type: 'string', description: `Find ${replacement}` },
|
||||
query: { type: 'string', description: `Find ${secret}` },
|
||||
},
|
||||
},
|
||||
params: {
|
||||
serverId: 'mcp-server-1',
|
||||
toolName: 'search',
|
||||
serverName: `Docs ${replacement}`,
|
||||
serverName: `Docs ${secret}`,
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(body.contexts).toEqual([
|
||||
{ kind: 'skill', skillId: 'skill-1', label: `Playbook ${replacement}` },
|
||||
{ kind: 'skill', skillId: 'skill-1', label: `Playbook ${secret}` },
|
||||
])
|
||||
const attachmentMetadata = {
|
||||
type: body.fileAttachments[0].type,
|
||||
})
|
||||
|
||||
it('projects only resolver-recorded model-visible MCP and skill metadata', async () => {
|
||||
const secrets = [
|
||||
{
|
||||
name: 'MCP_SERVER_LABEL',
|
||||
plaintext: 'private server label',
|
||||
encryptedValue: 'encrypted-server-label',
|
||||
path: ['tools', '0', 'params', 'serverName'],
|
||||
raw: 'Docs private server label',
|
||||
projected: 'Docs {{MCP_SERVER_LABEL}}',
|
||||
},
|
||||
{
|
||||
name: 'MCP_SCHEMA_DESCRIPTION',
|
||||
plaintext: 'private schema text',
|
||||
encryptedValue: 'encrypted-schema-description',
|
||||
path: ['tools', '0', 'schema', 'description'],
|
||||
raw: 'Search private schema text for Box',
|
||||
projected: 'Search {{MCP_SCHEMA_DESCRIPTION}} for Box',
|
||||
},
|
||||
{
|
||||
name: 'SKILL_LABEL',
|
||||
plaintext: 'private skill label',
|
||||
encryptedValue: 'encrypted-skill-label',
|
||||
path: ['skills', '0', 'name'],
|
||||
raw: 'Playbook private skill label',
|
||||
projected: 'Playbook {{SKILL_LABEL}}',
|
||||
},
|
||||
{
|
||||
name: 'DISABLED_SERVER_ID',
|
||||
plaintext: 'disabled-server-secret',
|
||||
encryptedValue: 'encrypted-disabled-server-id',
|
||||
path: ['tools', '1', 'params', 'serverId'],
|
||||
raw: 'disabled-server-secret',
|
||||
projected: '{{DISABLED_SERVER_ID}}',
|
||||
},
|
||||
] as const
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
...secrets.map(({ name, plaintext, encryptedValue }) => ({
|
||||
name,
|
||||
plaintext,
|
||||
encryptedValue,
|
||||
})),
|
||||
{ name: 'UNUSED_SECRET', plaintext: 'x', encryptedValue: 'encrypted-unused' },
|
||||
])
|
||||
for (const secret of secrets) {
|
||||
registry.recordResolvedAtInputPath(secret.name, secret.plaintext, secret.path)
|
||||
registry.recordResolvedInputProjection(secret.path, secret.raw, secret.projected)
|
||||
}
|
||||
expect(
|
||||
JSON.stringify({
|
||||
messages: body.messages,
|
||||
attachmentMetadata,
|
||||
mcpTools: body.mcpTools,
|
||||
contexts: body.contexts,
|
||||
vi.spyOn(registry, 'importProvenanceForValue').mockResolvedValue(true)
|
||||
context.resolvedSecretTraceRegistry = registry
|
||||
mockGenerateId
|
||||
.mockReturnValueOnce('chat-uuid')
|
||||
.mockReturnValueOnce('message-uuid')
|
||||
.mockReturnValueOnce('request-uuid')
|
||||
fetchMock.mockResolvedValue(createJsonResponse({ content: 'done', toolCalls: [] }))
|
||||
const tools = [
|
||||
{
|
||||
type: 'mcp',
|
||||
params: {
|
||||
serverId: 'mcp-server-1',
|
||||
toolName: 'search',
|
||||
serverName: 'Docs private server label',
|
||||
},
|
||||
schema: {
|
||||
type: 'object',
|
||||
description: 'Search private schema text for Box',
|
||||
properties: { query: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'mcp',
|
||||
usageControl: 'none',
|
||||
params: { serverId: 'disabled-server-secret', toolName: 'disabled' },
|
||||
},
|
||||
]
|
||||
const skills = [{ skillId: 'skill-1', name: 'Playbook private skill label' }]
|
||||
|
||||
await handler.execute(context, block, {
|
||||
prompt: 'Use Box without changing it',
|
||||
tools,
|
||||
skills,
|
||||
})
|
||||
|
||||
const [, options] = fetchMock.mock.calls[0] as [string, RequestInit]
|
||||
const body = JSON.parse(String(options.body))
|
||||
expect(body.mcpTools).toEqual([
|
||||
{
|
||||
type: 'mcp',
|
||||
schema: {
|
||||
type: 'object',
|
||||
description: 'Search {{MCP_SCHEMA_DESCRIPTION}} for Box',
|
||||
properties: { query: { type: 'string' } },
|
||||
},
|
||||
params: {
|
||||
serverId: 'mcp-server-1',
|
||||
toolName: 'search',
|
||||
serverName: 'Docs {{MCP_SERVER_LABEL}}',
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(body.contexts).toEqual([
|
||||
{ kind: 'skill', skillId: 'skill-1', label: 'Playbook {{SKILL_LABEL}}' },
|
||||
])
|
||||
expect(JSON.stringify(body)).not.toContain('private server label')
|
||||
expect(JSON.stringify(body)).not.toContain('private schema text')
|
||||
expect(JSON.stringify(body)).not.toContain('private skill label')
|
||||
expect(tools[0].params.serverName).toBe('Docs private server label')
|
||||
expect(tools[0].schema.description).toBe('Search private schema text for Box')
|
||||
expect(skills[0].name).toBe('Playbook private skill label')
|
||||
})
|
||||
|
||||
it('rejects only an enabled structural identifier with exact resolver provenance', async () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{
|
||||
name: 'MCP_SERVER_ID',
|
||||
plaintext: 'resolved-server-id',
|
||||
encryptedValue: 'encrypted-server-id',
|
||||
},
|
||||
])
|
||||
registry.recordResolvedAtInputPath('MCP_SERVER_ID', 'resolved-server-id', [
|
||||
'tools',
|
||||
'0',
|
||||
'params',
|
||||
'serverId',
|
||||
])
|
||||
registry.recordResolvedInputProjection(
|
||||
['tools', '0', 'params', 'serverId'],
|
||||
'resolved-server-id',
|
||||
'{{MCP_SERVER_ID}}'
|
||||
)
|
||||
context.resolvedSecretTraceRegistry = registry
|
||||
|
||||
await expect(
|
||||
handler.execute(context, block, {
|
||||
prompt: 'Use the selected tool',
|
||||
tools: [
|
||||
{
|
||||
type: 'mcp',
|
||||
params: { serverId: 'resolved-server-id', toolName: 'search' },
|
||||
},
|
||||
],
|
||||
})
|
||||
).not.toContain(secret)
|
||||
).rejects.toThrow('Mothership structural model inputs cannot contain secret references')
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a resolver-derived MCP enum under a property named description', async () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{
|
||||
name: 'ENUM_VALUE',
|
||||
plaintext: 'private-option',
|
||||
encryptedValue: 'encrypted-option',
|
||||
},
|
||||
])
|
||||
const inputPath = ['tools', '0', 'schema', 'properties', 'description', 'enum', '0'] as const
|
||||
registry.recordResolvedAtInputPath('ENUM_VALUE', 'private-option', inputPath)
|
||||
registry.recordResolvedInputProjection(inputPath, 'private-option', '{{ENUM_VALUE}}')
|
||||
context.resolvedSecretTraceRegistry = registry
|
||||
|
||||
await expect(
|
||||
handler.execute(context, block, {
|
||||
prompt: 'Use the selected tool',
|
||||
tools: [
|
||||
{
|
||||
type: 'mcp',
|
||||
params: { serverId: 'server-1', toolName: 'search' },
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
description: { type: 'string', enum: ['private-option'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
).rejects.toThrow('Mothership structural model inputs cannot contain secret references')
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('projects a resolver-recorded attachment name without changing file materialization', async () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'FILE_TOKEN', plaintext: 'x', encryptedValue: 'encrypted-file-token' },
|
||||
])
|
||||
registry.recordResolvedAtInputPath('FILE_TOKEN', 'x', ['files', '0', 'name'])
|
||||
registry.recordResolvedInputProjection(
|
||||
['files', '0', 'name'],
|
||||
'report-x.txt',
|
||||
'report-{{FILE_TOKEN}}.txt'
|
||||
)
|
||||
vi.spyOn(registry, 'importProvenanceForValue').mockResolvedValue(true)
|
||||
context.resolvedSecretTraceRegistry = registry
|
||||
mockGenerateId
|
||||
.mockReturnValueOnce('chat-uuid')
|
||||
.mockReturnValueOnce('message-uuid')
|
||||
.mockReturnValueOnce('request-uuid')
|
||||
const attachmentData = Buffer.from('ordinary bytes', 'utf8').toString('base64')
|
||||
mockReadUserFileContent.mockResolvedValueOnce(attachmentData)
|
||||
fetchMock.mockResolvedValue(createJsonResponse({ content: 'done', toolCalls: [] }))
|
||||
|
||||
await handler.execute(context, block, {
|
||||
prompt: 'Read the attachment',
|
||||
files: [
|
||||
{
|
||||
name: 'report-x.txt',
|
||||
key: 'workspace/workspace-1/report.txt',
|
||||
size: 32,
|
||||
type: 'text/plain',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(mockReadUserFileContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'report-x.txt',
|
||||
key: 'workspace/workspace-1/report.txt',
|
||||
}),
|
||||
expect.any(Object)
|
||||
)
|
||||
const [, options] = fetchMock.mock.calls[0] as [string, RequestInit]
|
||||
const body = JSON.parse(String(options.body))
|
||||
expect(body.fileAttachments).toEqual([
|
||||
{
|
||||
type: 'document',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'text/plain',
|
||||
data: attachmentData,
|
||||
},
|
||||
filename: 'report-{{FILE_TOKEN}}.txt',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects resolver-derived inline attachment bytes before materialization', async () => {
|
||||
const encodedSecret = 'aW1hZ2U='
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{
|
||||
name: 'FILE_BYTES',
|
||||
plaintext: encodedSecret,
|
||||
encryptedValue: 'encrypted-file-bytes',
|
||||
},
|
||||
])
|
||||
const inputPath = ['files', '0', 'base64'] as const
|
||||
registry.recordResolvedAtInputPath('FILE_BYTES', encodedSecret, inputPath)
|
||||
registry.recordResolvedInputProjection(inputPath, encodedSecret, '{{FILE_BYTES}}')
|
||||
context.resolvedSecretTraceRegistry = registry
|
||||
|
||||
await expect(
|
||||
handler.execute(context, block, {
|
||||
prompt: 'Read the attachment',
|
||||
files: [
|
||||
{
|
||||
name: 'example.png',
|
||||
key: 'workspace/workspace-1/example.png',
|
||||
size: 5,
|
||||
type: 'image/png',
|
||||
base64: encodedSecret,
|
||||
},
|
||||
],
|
||||
})
|
||||
).rejects.toThrow('Mothership inline file content cannot contain secret references')
|
||||
expect(mockReadUserFileContent).not.toHaveBeenCalled()
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects resolver-derived inline bytes inside a serialized file input', async () => {
|
||||
const rawFiles = JSON.stringify([
|
||||
{
|
||||
name: 'example.png',
|
||||
key: 'workspace/workspace-1/example.png',
|
||||
size: 5,
|
||||
type: 'image/png',
|
||||
base64: 'aW1hZ2U=',
|
||||
},
|
||||
])
|
||||
const projectedFiles = JSON.stringify([
|
||||
{
|
||||
name: 'example.png',
|
||||
key: 'workspace/workspace-1/example.png',
|
||||
size: 5,
|
||||
type: 'image/png',
|
||||
base64: '{{FILE_BYTES}}',
|
||||
},
|
||||
])
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{
|
||||
name: 'FILE_BYTES',
|
||||
plaintext: 'aW1hZ2U=',
|
||||
encryptedValue: 'encrypted-file-bytes',
|
||||
},
|
||||
])
|
||||
registry.recordResolvedAtInputPath('FILE_BYTES', 'aW1hZ2U=', ['files'])
|
||||
registry.recordResolvedInputProjection(['files'], rawFiles, projectedFiles)
|
||||
context.resolvedSecretTraceRegistry = registry
|
||||
|
||||
await expect(
|
||||
handler.execute(context, block, {
|
||||
prompt: 'Read the attachment',
|
||||
files: rawFiles,
|
||||
})
|
||||
).rejects.toThrow('Mothership inline file content cannot contain secret references')
|
||||
expect(mockReadUserFileContent).not.toHaveBeenCalled()
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps dormant inline attachment bytes unchanged', async () => {
|
||||
const encodedBytes = 'aW1hZ2U='
|
||||
context.resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry([
|
||||
{
|
||||
name: 'UNUSED_FILE_BYTES',
|
||||
plaintext: encodedBytes,
|
||||
encryptedValue: 'encrypted-unused-file-bytes',
|
||||
},
|
||||
])
|
||||
mockGenerateId
|
||||
.mockReturnValueOnce('chat-uuid')
|
||||
.mockReturnValueOnce('message-uuid')
|
||||
.mockReturnValueOnce('request-uuid')
|
||||
mockReadUserFileContent.mockResolvedValueOnce(encodedBytes)
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ content: 'done', toolCalls: [] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
)
|
||||
|
||||
await handler.execute(context, block, {
|
||||
prompt: 'Read the attachment',
|
||||
files: [
|
||||
{
|
||||
name: 'example.png',
|
||||
key: 'workspace/workspace-1/example.png',
|
||||
size: 5,
|
||||
type: 'image/png',
|
||||
base64: encodedBytes,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(mockReadUserFileContent).toHaveBeenCalledTimes(1)
|
||||
const [, options] = fetchMock.mock.calls[0] as [string, RequestInit]
|
||||
const body = JSON.parse(String(options.body))
|
||||
expect(body.fileAttachments[0].source.data).toBe(encodedBytes)
|
||||
})
|
||||
|
||||
it('rejects a canonical tracked file whose exact byte provenance is not model-safe', async () => {
|
||||
@@ -787,15 +1266,9 @@ describe('MothershipBlockHandler', () => {
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops Mothership selections whose protocol identifiers or schema semantics contain secrets', async () => {
|
||||
it('does not infer secret provenance from matching protocol or schema literals', async () => {
|
||||
const secret = 'boundary-secret'
|
||||
const registry = createTraceRegistryMock()
|
||||
const matches = [{ plaintext: secret, replacement: '{{API_KEY}}' }]
|
||||
registry.getModelEgressSnapshot.mockReturnValue({
|
||||
complete: true,
|
||||
matches,
|
||||
matcher: createResolvedSecretMatcher(matches),
|
||||
})
|
||||
context.resolvedSecretTraceRegistry = registry
|
||||
mockGenerateId
|
||||
.mockReturnValueOnce('chat-uuid')
|
||||
@@ -838,14 +1311,16 @@ describe('MothershipBlockHandler', () => {
|
||||
|
||||
const [, options] = fetchMock.mock.calls[0] as [string, RequestInit]
|
||||
const body = JSON.parse(String(options.body))
|
||||
expect(body.mcpTools).toEqual([
|
||||
{
|
||||
type: 'mcp',
|
||||
params: { serverId: 'mcp-server-1', toolName: 'search' },
|
||||
},
|
||||
expect(body.mcpTools).toHaveLength(5)
|
||||
expect(body.mcpTools[0]).toEqual({
|
||||
type: 'mcp',
|
||||
params: { serverId: secret, toolName: 'search' },
|
||||
})
|
||||
expect(body.mcpTools[2].schema).toEqual({ type: 'string', enum: [secret] })
|
||||
expect(body.contexts).toEqual([
|
||||
{ kind: 'skill', skillId: secret, label: 'Unsafe' },
|
||||
{ kind: 'skill', skillId: 'skill-1', label: 'Safe skill' },
|
||||
])
|
||||
expect(body.contexts).toEqual([{ kind: 'skill', skillId: 'skill-1', label: 'Safe skill' }])
|
||||
expect(JSON.stringify(body)).not.toContain(secret)
|
||||
})
|
||||
|
||||
it('consumes mothership execute heartbeat streams until the final result', async () => {
|
||||
|
||||
@@ -6,13 +6,14 @@ import {
|
||||
BILLING_ATTRIBUTION_HEADER,
|
||||
serializeBillingAttributionHeader,
|
||||
} from '@/lib/billing/core/billing-attribution'
|
||||
import {
|
||||
collectModelVisibleSchemaContent,
|
||||
restoreModelVisibleSchemaValues,
|
||||
} from '@/lib/copilot/model-visible-schema'
|
||||
import { normalizeSecretMountPolicy } from '@/lib/copilot/secret-mount-policy'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { isExecutionCancelled, isRedisCancellationEnabled } from '@/lib/execution/cancellation'
|
||||
import {
|
||||
projectModelSchemaAnnotations,
|
||||
projectResolvedModelInput,
|
||||
selectModelSchemaInputPaths,
|
||||
} from '@/lib/execution/model-input-provenance'
|
||||
import { readUserFileContent } from '@/lib/execution/payloads/materialization.server'
|
||||
import {
|
||||
inspectPrivateToolMetadataEnvelope,
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
processSingleFileToUserFile,
|
||||
type RawFileInput,
|
||||
} from '@/lib/uploads/utils/file-utils'
|
||||
import { selectModelBoundFileInputPaths } from '@/lib/uploads/utils/model-input'
|
||||
import type { BlockOutput } from '@/blocks/types'
|
||||
import { normalizeFileInput } from '@/blocks/utils'
|
||||
import { BlockType } from '@/executor/constants'
|
||||
@@ -41,11 +43,10 @@ import type {
|
||||
StreamingExecution,
|
||||
} from '@/executor/types'
|
||||
import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http'
|
||||
import {
|
||||
isResolvedSecretModelContentUnchanged,
|
||||
projectResolvedSecretModelContent,
|
||||
} from '@/executor/utils/resolved-secret-content-projection'
|
||||
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
|
||||
import type {
|
||||
ResolvedSecretInputPath,
|
||||
ResolvedSecretTraceRegistry,
|
||||
} from '@/executor/utils/resolved-secret-trace-registry'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
|
||||
const logger = createLogger('MothershipBlockHandler')
|
||||
@@ -75,6 +76,17 @@ interface MothershipSkillContext {
|
||||
label: string
|
||||
}
|
||||
|
||||
interface IndexedMothershipMcpToolSelection {
|
||||
inputIndex: number
|
||||
selection: MothershipMcpToolSelection
|
||||
}
|
||||
|
||||
interface IndexedMothershipSkillContext {
|
||||
inputIndex: number
|
||||
context: MothershipSkillContext
|
||||
hasExplicitLabel: boolean
|
||||
}
|
||||
|
||||
type MothershipExecuteResult = {
|
||||
content?: string
|
||||
model?: string
|
||||
@@ -92,67 +104,21 @@ type MothershipExecuteStreamEvent =
|
||||
Record<typeof RESOLVED_SECRET_PROVENANCE_FIELD, unknown>
|
||||
>)
|
||||
|
||||
function projectMothershipPrompt(
|
||||
prompt: string,
|
||||
registry: ResolvedSecretTraceRegistry | undefined
|
||||
): string {
|
||||
const projection = projectResolvedSecretModelContent(prompt, registry)
|
||||
if (!projection.safe || typeof projection.value !== 'string') {
|
||||
throw new Error('Mothership input could not be safely projected')
|
||||
}
|
||||
return projection.value
|
||||
}
|
||||
|
||||
function projectMothershipMcpTools(
|
||||
tools: unknown,
|
||||
registry: ResolvedSecretTraceRegistry | undefined
|
||||
): MothershipMcpToolSelection[] {
|
||||
function selectIndexedMothershipMcpTools(tools: unknown): IndexedMothershipMcpToolSelection[] {
|
||||
if (!Array.isArray(tools)) return []
|
||||
|
||||
return tools.flatMap((candidate) => {
|
||||
return tools.flatMap((candidate, inputIndex) => {
|
||||
if (!isPlainRecord(candidate) || candidate.type !== 'mcp') return []
|
||||
if (candidate.usageControl === 'none' || !isPlainRecord(candidate.params)) return []
|
||||
|
||||
const { serverId, toolName } = candidate.params
|
||||
if (
|
||||
typeof serverId !== 'string' ||
|
||||
!serverId ||
|
||||
typeof toolName !== 'string' ||
|
||||
!toolName ||
|
||||
!isResolvedSecretModelContentUnchanged([serverId, toolName], registry)
|
||||
) {
|
||||
if (typeof serverId !== 'string' || !serverId || typeof toolName !== 'string' || !toolName) {
|
||||
return []
|
||||
}
|
||||
|
||||
const serverName =
|
||||
typeof candidate.params.serverName === 'string' ? candidate.params.serverName : undefined
|
||||
const schema = isPlainRecord(candidate.schema) ? candidate.schema : undefined
|
||||
const schemaContent = schema
|
||||
? collectModelVisibleSchemaContent(schema)
|
||||
: { projectedValues: [], guardedValues: [] }
|
||||
if (!isResolvedSecretModelContentUnchanged(schemaContent.guardedValues, registry)) return []
|
||||
|
||||
const projection = projectResolvedSecretModelContent(
|
||||
[serverName, schemaContent.projectedValues],
|
||||
registry
|
||||
)
|
||||
if (!projection.safe || !Array.isArray(projection.value) || projection.value.length !== 2) {
|
||||
throw new Error('Mothership MCP tool metadata could not be safely projected')
|
||||
}
|
||||
const [projectedServerName, projectedSchemaValues] = projection.value
|
||||
if (serverName === undefined && projectedServerName !== undefined) {
|
||||
throw new Error('Mothership MCP tool metadata could not be safely projected')
|
||||
}
|
||||
if (serverName !== undefined && typeof projectedServerName !== 'string') {
|
||||
throw new Error('Mothership MCP tool metadata could not be safely projected')
|
||||
}
|
||||
|
||||
const projectedSchema = schema
|
||||
? restoreModelVisibleSchemaValues(schema, projectedSchemaValues)
|
||||
: undefined
|
||||
if (projectedSchema !== undefined && !isPlainRecord(projectedSchema)) {
|
||||
throw new Error('Mothership MCP tool metadata could not be safely projected')
|
||||
}
|
||||
|
||||
const usageControl =
|
||||
candidate.usageControl === 'auto' || candidate.usageControl === 'force'
|
||||
@@ -161,54 +127,116 @@ function projectMothershipMcpTools(
|
||||
const selection: MothershipMcpToolSelection = {
|
||||
type: 'mcp',
|
||||
...(usageControl ? { usageControl } : {}),
|
||||
...(projectedSchema ? { schema: projectedSchema } : {}),
|
||||
...(schema ? { schema } : {}),
|
||||
params: {
|
||||
serverId,
|
||||
toolName,
|
||||
...(projectedServerName !== undefined ? { serverName: projectedServerName } : {}),
|
||||
...(serverName !== undefined ? { serverName } : {}),
|
||||
},
|
||||
}
|
||||
return [selection]
|
||||
return [{ inputIndex, selection }]
|
||||
})
|
||||
}
|
||||
|
||||
function projectMothershipSkillContexts(
|
||||
skills: unknown,
|
||||
registry: ResolvedSecretTraceRegistry | undefined
|
||||
): MothershipSkillContext[] {
|
||||
function selectMothershipMcpTools(tools: unknown): MothershipMcpToolSelection[] {
|
||||
return selectIndexedMothershipMcpTools(tools).map(({ selection }) => selection)
|
||||
}
|
||||
|
||||
function selectIndexedMothershipSkillContexts(skills: unknown): IndexedMothershipSkillContext[] {
|
||||
if (!Array.isArray(skills)) return []
|
||||
|
||||
const selected = skills.flatMap((candidate) => {
|
||||
return skills.flatMap((candidate, inputIndex) => {
|
||||
if (!isPlainRecord(candidate) || typeof candidate.skillId !== 'string' || !candidate.skillId) {
|
||||
return []
|
||||
}
|
||||
if (!isResolvedSecretModelContentUnchanged(candidate.skillId, registry)) return []
|
||||
const explicitLabel = typeof candidate.name === 'string' ? candidate.name : undefined
|
||||
const hasExplicitLabel = explicitLabel !== undefined
|
||||
const label = explicitLabel ?? candidate.skillId
|
||||
return [
|
||||
{
|
||||
skillId: candidate.skillId,
|
||||
label: typeof candidate.name === 'string' ? candidate.name : candidate.skillId,
|
||||
inputIndex,
|
||||
hasExplicitLabel,
|
||||
context: {
|
||||
kind: 'skill' as const,
|
||||
skillId: candidate.skillId,
|
||||
label,
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
const projection = projectResolvedSecretModelContent(
|
||||
selected.map((skill) => skill.label),
|
||||
registry
|
||||
)
|
||||
if (!projection.safe) {
|
||||
throw new Error('Mothership skill metadata could not be safely projected')
|
||||
}
|
||||
const projectedLabels = projection.value
|
||||
if (!Array.isArray(projectedLabels) || projectedLabels.length !== selected.length) {
|
||||
throw new Error('Mothership skill metadata could not be safely projected')
|
||||
}
|
||||
|
||||
function selectMothershipSkillContexts(skills: unknown): MothershipSkillContext[] {
|
||||
return selectIndexedMothershipSkillContexts(skills).map(({ context }) => context)
|
||||
}
|
||||
|
||||
function selectMothershipMetadataModelInputPaths(
|
||||
tools: unknown,
|
||||
skills: unknown
|
||||
): {
|
||||
modelInputPaths: ResolvedSecretInputPath[]
|
||||
structuralInputPaths: ResolvedSecretInputPath[]
|
||||
} {
|
||||
const modelInputPaths: ResolvedSecretInputPath[] = []
|
||||
const structuralInputPaths: ResolvedSecretInputPath[] = []
|
||||
|
||||
for (const { inputIndex, selection } of selectIndexedMothershipMcpTools(tools)) {
|
||||
const root = ['tools', String(inputIndex)] as const
|
||||
structuralInputPaths.push([...root, 'params', 'serverId'], [...root, 'params', 'toolName'])
|
||||
if (selection.schema) {
|
||||
const schemaPaths = selectModelSchemaInputPaths(selection.schema, [...root, 'schema'])
|
||||
modelInputPaths.push(...schemaPaths.annotationInputPaths)
|
||||
structuralInputPaths.push(...schemaPaths.semanticInputPaths)
|
||||
}
|
||||
if (selection.params.serverName !== undefined) {
|
||||
modelInputPaths.push([...root, 'params', 'serverName'])
|
||||
}
|
||||
}
|
||||
|
||||
return selected.map((skill, index) => {
|
||||
const label = projectedLabels[index]
|
||||
if (typeof label !== 'string') {
|
||||
throw new Error('Mothership skill metadata could not be safely projected')
|
||||
for (const { inputIndex, hasExplicitLabel } of selectIndexedMothershipSkillContexts(skills)) {
|
||||
const root = ['skills', String(inputIndex)] as const
|
||||
structuralInputPaths.push([...root, 'skillId'])
|
||||
if (hasExplicitLabel) modelInputPaths.push([...root, 'name'])
|
||||
}
|
||||
|
||||
return { modelInputPaths, structuralInputPaths }
|
||||
}
|
||||
|
||||
function assertMothershipToolSchemaProjectionsAreSafe(
|
||||
registry: ResolvedSecretTraceRegistry,
|
||||
tools: unknown
|
||||
): void {
|
||||
if (!Array.isArray(tools)) return
|
||||
const projection = registry.projectResolvedInputSelection({ tools })
|
||||
if (!projection.complete || !Array.isArray(projection.value.tools)) {
|
||||
throw new Error('Mothership input could not be safely projected')
|
||||
}
|
||||
|
||||
for (const { inputIndex, selection } of selectIndexedMothershipMcpTools(tools)) {
|
||||
if (!selection.schema) continue
|
||||
const projectedCandidate = projection.value.tools[inputIndex]
|
||||
if (!isPlainRecord(projectedCandidate)) {
|
||||
throw new Error('Mothership input could not be safely projected')
|
||||
}
|
||||
return { kind: 'skill', skillId: skill.skillId, label }
|
||||
})
|
||||
const projectedSchema = projectedCandidate.schema ?? selection.schema
|
||||
const schemaProjection = projectModelSchemaAnnotations(selection.schema, projectedSchema)
|
||||
if (!schemaProjection.safe) {
|
||||
throw new Error('Mothership input could not be safely projected')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertMothershipStructuralInputsDoNotResolveSecrets(
|
||||
registry: ResolvedSecretTraceRegistry,
|
||||
inputPaths: readonly ResolvedSecretInputPath[]
|
||||
): void {
|
||||
const provenance = registry.exportCommittedProvenanceForInputPaths(inputPaths)
|
||||
if (!provenance.complete) {
|
||||
throw new Error('Mothership input could not be safely projected')
|
||||
}
|
||||
if (provenance.entries.length > 0) {
|
||||
throw new Error('Mothership structural model inputs cannot contain secret references')
|
||||
}
|
||||
}
|
||||
|
||||
async function consumeMothershipProvenance(
|
||||
@@ -216,8 +244,6 @@ async function consumeMothershipProvenance(
|
||||
response: Response,
|
||||
registry?: ResolvedSecretTraceRegistry
|
||||
): Promise<boolean> {
|
||||
if (!registry) throw new Error('Mothership model-egress provenance registry is unavailable')
|
||||
|
||||
const inspection = inspectPrivateToolMetadataEnvelope(
|
||||
response.headers,
|
||||
payload,
|
||||
@@ -226,31 +252,35 @@ async function consumeMothershipProvenance(
|
||||
const provenance = payload[RESOLVED_SECRET_PROVENANCE_FIELD]
|
||||
payload[RESOLVED_SECRET_PROVENANCE_FIELD] = undefined
|
||||
if (inspection.status === 'unsupported') {
|
||||
throw new Error('Mothership response does not support private provenance metadata')
|
||||
return false
|
||||
}
|
||||
if (inspection.status === 'invalid') {
|
||||
registry.markIncomplete()
|
||||
registry?.markIncomplete()
|
||||
throw new Error('Mothership response provenance metadata is invalid')
|
||||
}
|
||||
|
||||
if (!registry) return false
|
||||
|
||||
const imported = await registry.importProvenanceForValue(provenance, payload, { trusted: true })
|
||||
if (!imported) throw new Error('Mothership response provenance metadata is invalid')
|
||||
return true
|
||||
}
|
||||
|
||||
function assertMothershipResponseCapability(
|
||||
function inspectMothershipResponseCapability(
|
||||
response: Response,
|
||||
registry: ResolvedSecretTraceRegistry | undefined
|
||||
): void {
|
||||
if (!registry) throw new Error('Mothership model-egress provenance registry is unavailable')
|
||||
|
||||
): boolean {
|
||||
const capability = inspectPrivateToolMetadataResponseCapability(
|
||||
response.headers,
|
||||
RESOLVED_SECRET_PROVENANCE_METADATA_V1
|
||||
)
|
||||
if (capability.status === 'supported') return
|
||||
if (capability.status === 'mismatched') registry.markIncomplete()
|
||||
throw new Error('Mothership response does not support private provenance metadata')
|
||||
if (capability.status === 'supported') return true
|
||||
if (capability.status === 'unsupported') {
|
||||
return false
|
||||
}
|
||||
|
||||
registry?.markIncomplete()
|
||||
throw new Error('Mothership response provenance metadata is invalid')
|
||||
}
|
||||
|
||||
function parseMothershipExecuteStreamLine(line: string): MothershipExecuteStreamEvent | undefined {
|
||||
@@ -306,15 +336,18 @@ async function readMothershipExecuteResponse(
|
||||
response: Response,
|
||||
registry?: ResolvedSecretTraceRegistry
|
||||
): Promise<MothershipExecuteResult> {
|
||||
assertMothershipResponseCapability(response, registry)
|
||||
const expectsProvenance = inspectMothershipResponseCapability(response, registry)
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
if (!contentType.includes('application/x-ndjson')) {
|
||||
let result: MothershipExecuteResult
|
||||
try {
|
||||
result = (await response.json()) as MothershipExecuteResult
|
||||
} catch {
|
||||
registry?.markIncomplete()
|
||||
throw new Error('Mothership response provenance metadata is invalid')
|
||||
} catch (error) {
|
||||
if (expectsProvenance) {
|
||||
registry?.markIncomplete()
|
||||
throw new Error('Mothership response provenance metadata is invalid')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
await consumeMothershipProvenance(result, response, registry)
|
||||
return result
|
||||
@@ -374,7 +407,9 @@ async function readMothershipExecuteResponse(
|
||||
|
||||
return finalResult
|
||||
} finally {
|
||||
if (!finalResult && !receivedTerminalProvenance) registry?.markIncomplete()
|
||||
if (expectsProvenance && !finalResult && !receivedTerminalProvenance) {
|
||||
registry?.markIncomplete()
|
||||
}
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
@@ -389,7 +424,7 @@ function createMothershipStreamingExecution(
|
||||
registry?: ResolvedSecretTraceRegistry
|
||||
} = {}
|
||||
): StreamingExecution {
|
||||
assertMothershipResponseCapability(response, options.registry)
|
||||
const expectsProvenance = inspectMothershipResponseCapability(response, options.registry)
|
||||
if (!response.body) {
|
||||
throw new Error('Sim execution stream ended without a response body')
|
||||
}
|
||||
@@ -476,7 +511,9 @@ function createMothershipStreamingExecution(
|
||||
controller.error(error)
|
||||
}
|
||||
} finally {
|
||||
if (!sawFinal && !receivedTerminalProvenance) options.registry?.markIncomplete()
|
||||
if (expectsProvenance && !sawFinal && !receivedTerminalProvenance) {
|
||||
options.registry?.markIncomplete()
|
||||
}
|
||||
cleanup()
|
||||
reader?.releaseLock()
|
||||
}
|
||||
@@ -507,6 +544,7 @@ function createMothershipStreamingExecution(
|
||||
|
||||
async function buildMothershipFileAttachments(
|
||||
filesInput: unknown,
|
||||
projectedFilesInput: unknown,
|
||||
ctx: ExecutionContext,
|
||||
requestId: string
|
||||
): Promise<MothershipFileAttachment[] | undefined> {
|
||||
@@ -518,6 +556,10 @@ async function buildMothershipFileAttachments(
|
||||
if (!ctx.userId) {
|
||||
throw new Error('Mothership file attachments require an authenticated user.')
|
||||
}
|
||||
const projectedFiles = normalizeFileInput(projectedFilesInput)
|
||||
if (!projectedFiles || projectedFiles.length !== files.length) {
|
||||
throw new Error('Mothership input could not be safely projected')
|
||||
}
|
||||
|
||||
const userFiles = files.map((file) =>
|
||||
processSingleFileToUserFile(file as RawFileInput, requestId, logger)
|
||||
@@ -529,7 +571,17 @@ async function buildMothershipFileAttachments(
|
||||
if (!modelSafe) throw new Error(MODEL_UNSAFE_WORKSPACE_FILE_ERROR_MESSAGE)
|
||||
|
||||
const attachments: MothershipFileAttachment[] = []
|
||||
for (const userFile of userFiles) {
|
||||
for (let fileIndex = 0; fileIndex < userFiles.length; fileIndex++) {
|
||||
const userFile = userFiles[fileIndex]
|
||||
const rawFile = files[fileIndex]
|
||||
const projectedFile = projectedFiles[fileIndex]
|
||||
if (
|
||||
isPlainRecord(rawFile) &&
|
||||
isPlainRecord(projectedFile) &&
|
||||
!Object.is(rawFile.base64, projectedFile.base64)
|
||||
) {
|
||||
throw new Error('Mothership inline file content cannot contain secret references')
|
||||
}
|
||||
const base64 = await readUserFileContent(userFile, {
|
||||
encoding: 'base64',
|
||||
userId: ctx.userId,
|
||||
@@ -551,7 +603,11 @@ async function buildMothershipFileAttachments(
|
||||
throw new Error(`File type is not supported for Mothership attachments: ${userFile.name}`)
|
||||
}
|
||||
|
||||
attachments.push({ ...content, filename: userFile.name })
|
||||
const projectedName = isPlainRecord(projectedFile) ? projectedFile.name : undefined
|
||||
attachments.push({
|
||||
...content,
|
||||
filename: typeof projectedName === 'string' ? projectedName : userFile.name,
|
||||
})
|
||||
}
|
||||
|
||||
return attachments
|
||||
@@ -584,10 +640,34 @@ export class MothershipBlockHandler implements BlockHandler {
|
||||
if (!prompt || typeof prompt !== 'string') {
|
||||
throw new Error('Prompt input is required')
|
||||
}
|
||||
const metadataInputPaths = selectMothershipMetadataModelInputPaths(inputs.tools, inputs.skills)
|
||||
if (ctx.resolvedSecretTraceRegistry) {
|
||||
assertMothershipStructuralInputsDoNotResolveSecrets(
|
||||
ctx.resolvedSecretTraceRegistry,
|
||||
metadataInputPaths.structuralInputPaths
|
||||
)
|
||||
assertMothershipToolSchemaProjectionsAreSafe(ctx.resolvedSecretTraceRegistry, inputs.tools)
|
||||
}
|
||||
const modelInputPaths: ResolvedSecretInputPath[] = [
|
||||
['prompt'],
|
||||
...selectModelBoundFileInputPaths(inputs.files, ['files'], {
|
||||
includeInlineBase64: true,
|
||||
includeName: true,
|
||||
}),
|
||||
...metadataInputPaths.modelInputPaths,
|
||||
]
|
||||
const modelInputProjection = projectResolvedModelInput(
|
||||
ctx.resolvedSecretTraceRegistry,
|
||||
{ prompt, files: inputs.files, tools: inputs.tools, skills: inputs.skills },
|
||||
modelInputPaths
|
||||
)
|
||||
if (!modelInputProjection.complete || typeof modelInputProjection.value.prompt !== 'string') {
|
||||
throw new Error('Mothership input could not be safely projected')
|
||||
}
|
||||
const messages = [
|
||||
{
|
||||
role: 'user' as const,
|
||||
content: projectMothershipPrompt(prompt, ctx.resolvedSecretTraceRegistry),
|
||||
content: modelInputProjection.value.prompt,
|
||||
},
|
||||
]
|
||||
const providedConversationId =
|
||||
@@ -599,12 +679,14 @@ export class MothershipBlockHandler implements BlockHandler {
|
||||
secretScope: inputs.secretScope,
|
||||
mountedSecrets: inputs.mountedSecrets,
|
||||
})
|
||||
const fileAttachments = await buildMothershipFileAttachments(inputs.files, ctx, requestId)
|
||||
const mcpTools = projectMothershipMcpTools(inputs.tools, ctx.resolvedSecretTraceRegistry)
|
||||
const skillContexts = projectMothershipSkillContexts(
|
||||
inputs.skills,
|
||||
ctx.resolvedSecretTraceRegistry
|
||||
const fileAttachments = await buildMothershipFileAttachments(
|
||||
inputs.files,
|
||||
modelInputProjection.value.files,
|
||||
ctx,
|
||||
requestId
|
||||
)
|
||||
const mcpTools = selectMothershipMcpTools(modelInputProjection.value.tools)
|
||||
const skillContexts = selectMothershipSkillContexts(modelInputProjection.value.skills)
|
||||
|
||||
const url = buildAPIUrl('/api/mothership/execute')
|
||||
const headers = await buildAuthHeaders(ctx.userId)
|
||||
@@ -706,15 +788,20 @@ export class MothershipBlockHandler implements BlockHandler {
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
assertMothershipResponseCapability(response, ctx.resolvedSecretTraceRegistry)
|
||||
let payload: MothershipExecuteResult
|
||||
try {
|
||||
payload = (await response.clone().json()) as MothershipExecuteResult
|
||||
} catch {
|
||||
ctx.resolvedSecretTraceRegistry?.markIncomplete()
|
||||
throw new Error('Mothership response provenance metadata is invalid')
|
||||
const expectsProvenance = inspectMothershipResponseCapability(
|
||||
response,
|
||||
ctx.resolvedSecretTraceRegistry
|
||||
)
|
||||
if (expectsProvenance) {
|
||||
let payload: MothershipExecuteResult
|
||||
try {
|
||||
payload = (await response.clone().json()) as MothershipExecuteResult
|
||||
} catch {
|
||||
ctx.resolvedSecretTraceRegistry?.markIncomplete()
|
||||
throw new Error('Mothership response provenance metadata is invalid')
|
||||
}
|
||||
await consumeMothershipProvenance(payload, response, ctx.resolvedSecretTraceRegistry)
|
||||
}
|
||||
await consumeMothershipProvenance(payload, response, ctx.resolvedSecretTraceRegistry)
|
||||
const errorMsg = await extractAPIErrorMessage(response)
|
||||
throw new Error(`Sim execution failed: ${errorMsg}`)
|
||||
}
|
||||
|
||||
@@ -190,29 +190,39 @@ describe('PiBlockHandler', () => {
|
||||
it('projects activated task secrets at the final Pi input boundary', async () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'API_KEY', plaintext: 'secret-value', encryptedValue: 'ciphertext' },
|
||||
{ name: 'UNUSED', plaintext: 'x', encryptedValue: 'unused-ciphertext' },
|
||||
])
|
||||
registry.recordResolved('API_KEY', 'secret-value')
|
||||
registry.recordResolvedAtInputPath('API_KEY', 'secret-value', ['task'])
|
||||
registry.recordResolvedInputProjection(
|
||||
['task'],
|
||||
'Use secret-value without changing Box.',
|
||||
'Use {{API_KEY}} without changing Box.'
|
||||
)
|
||||
registry.recordResolved('UNUSED', 'x')
|
||||
|
||||
await handler.execute(
|
||||
ctx({ resolvedSecretTraceRegistry: registry }),
|
||||
block,
|
||||
localInputs({ task: 'Use secret-value without changing the rest.' })
|
||||
localInputs({ task: 'Use secret-value without changing Box.' })
|
||||
)
|
||||
|
||||
expect(mockRunLocal.mock.calls[0][0].task).toBe('Use {{API_KEY}} without changing the rest.')
|
||||
expect(mockRunLocal.mock.calls[0][0].task).toBe('Use {{API_KEY}} without changing Box.')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['missing', undefined],
|
||||
[
|
||||
'incomplete',
|
||||
(() => {
|
||||
const registry = new ResolvedSecretTraceRegistry()
|
||||
registry.markIncomplete()
|
||||
return registry
|
||||
})(),
|
||||
],
|
||||
])('fails closed when task provenance is %s', async (_label, registry) => {
|
||||
it('preserves legacy task behavior when no provenance registry exists', async () => {
|
||||
await handler.execute(
|
||||
ctx({ resolvedSecretTraceRegistry: undefined }),
|
||||
block,
|
||||
localInputs({ task: 'ordinary task' })
|
||||
)
|
||||
|
||||
expect(mockRunLocal.mock.calls[0][0].task).toBe('ordinary task')
|
||||
})
|
||||
|
||||
it('fails closed when task provenance is incomplete', async () => {
|
||||
const registry = new ResolvedSecretTraceRegistry()
|
||||
registry.markIncomplete()
|
||||
|
||||
await expect(
|
||||
handler.execute(
|
||||
ctx({ resolvedSecretTraceRegistry: registry }),
|
||||
@@ -682,7 +692,8 @@ describe('PiBlockHandler', () => {
|
||||
expect(mockBuildSearchTool).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ provider: 'exa', apiKey: 'search-key' },
|
||||
'local'
|
||||
'local',
|
||||
'search-key'
|
||||
)
|
||||
expect(mockRunLocal.mock.calls[0][0].search).toEqual({
|
||||
provider: 'exa',
|
||||
@@ -691,6 +702,29 @@ describe('PiBlockHandler', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('replays search-key normalization on the resolver-recorded projection', async () => {
|
||||
mockParseSearchProvider.mockReturnValue('exa')
|
||||
mockResolveSearchKey.mockImplementation(({ apiKey }: { apiKey?: string }) => apiKey?.trim())
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'SEARCH_KEY', plaintext: ' key\n', encryptedValue: 'ciphertext' },
|
||||
])
|
||||
registry.recordResolvedAtInputPath('SEARCH_KEY', ' key\n', ['searchApiKey'])
|
||||
registry.recordResolvedInputProjection(['searchApiKey'], ' key\n', '{{SEARCH_KEY}}')
|
||||
|
||||
await handler.execute(
|
||||
ctx({ resolvedSecretTraceRegistry: registry }),
|
||||
block,
|
||||
localInputs({ searchProvider: 'exa', searchApiKey: ' key\n' })
|
||||
)
|
||||
|
||||
expect(mockBuildSearchTool).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ provider: 'exa', apiKey: 'key' },
|
||||
'local',
|
||||
'{{SEARCH_KEY}}'
|
||||
)
|
||||
})
|
||||
|
||||
it('builds the host tool for Review Code too', async () => {
|
||||
mockParseSearchProvider.mockReturnValue('serper')
|
||||
|
||||
@@ -708,7 +742,8 @@ describe('PiBlockHandler', () => {
|
||||
expect(mockBuildSearchTool).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ provider: 'serper' }),
|
||||
'cloud_review'
|
||||
'cloud_review',
|
||||
'search-key'
|
||||
)
|
||||
expect(mockRunCloudReview.mock.calls[0][0].search.tool).toEqual({ name: 'web_search' })
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { projectResolvedModelInput } from '@/lib/execution/model-input-provenance'
|
||||
import type { BlockOutput } from '@/blocks/types'
|
||||
import { parseOptionalNumberInput } from '@/blocks/utils'
|
||||
import {
|
||||
@@ -50,7 +51,6 @@ import type {
|
||||
NormalizedBlockOutput,
|
||||
StreamingExecution,
|
||||
} from '@/executor/types'
|
||||
import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection'
|
||||
import { isPiSupportedProvider, resolvePiModelId } from '@/providers/pi-providers'
|
||||
import { getProviderFromModel } from '@/providers/utils'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
@@ -163,14 +163,15 @@ export class PiBlockHandler implements BlockHandler {
|
||||
const mode = parsePiMode(inputs.mode)
|
||||
const resolvedTask = asOptString(inputs.task)
|
||||
if (!resolvedTask) throw new Error('Task is required')
|
||||
const taskProjection = projectResolvedSecretModelContent(
|
||||
resolvedTask,
|
||||
ctx.resolvedSecretTraceRegistry
|
||||
const taskProjection = projectResolvedModelInput(
|
||||
ctx.resolvedSecretTraceRegistry,
|
||||
{ task: resolvedTask },
|
||||
[['task']]
|
||||
)
|
||||
if (!taskProjection.safe || typeof taskProjection.value !== 'string') {
|
||||
if (!taskProjection.complete || typeof taskProjection.value.task !== 'string') {
|
||||
throw new Error('Pi input could not be safely projected')
|
||||
}
|
||||
const task = taskProjection.value
|
||||
const task = taskProjection.value.task
|
||||
const model = asOptString(inputs.model) ?? DEFAULT_MODEL
|
||||
|
||||
const providerId = getProviderFromModel(model)
|
||||
@@ -400,15 +401,33 @@ export class PiBlockHandler implements BlockHandler {
|
||||
throw error
|
||||
}
|
||||
|
||||
const rawSearchApiKey = inputs.searchApiKey
|
||||
const apiKey = resolvePiSearchKey({
|
||||
provider,
|
||||
apiKey: asOptString(inputs.searchApiKey),
|
||||
apiKey: asOptString(rawSearchApiKey),
|
||||
})
|
||||
|
||||
const credentials = { provider, apiKey }
|
||||
return mode === 'cloud' || mode === 'cloud_branch'
|
||||
? credentials
|
||||
: { ...credentials, tool: buildPiSearchToolSpec(ctx, credentials, mode) }
|
||||
if (mode === 'cloud' || mode === 'cloud_branch') return credentials
|
||||
|
||||
const searchInputProjection = projectResolvedModelInput(
|
||||
ctx.resolvedSecretTraceRegistry,
|
||||
{ searchApiKey: rawSearchApiKey },
|
||||
[['searchApiKey']]
|
||||
)
|
||||
if (!searchInputProjection.complete) {
|
||||
throw new Error('Pi search input could not be safely projected')
|
||||
}
|
||||
const projectedApiKey = Object.is(searchInputProjection.value.searchApiKey, rawSearchApiKey)
|
||||
? apiKey
|
||||
: resolvePiSearchKey({
|
||||
provider,
|
||||
apiKey: asOptString(searchInputProjection.value.searchApiKey),
|
||||
})
|
||||
|
||||
return {
|
||||
...credentials,
|
||||
tool: buildPiSearchToolSpec(ctx, credentials, mode, projectedApiKey),
|
||||
}
|
||||
}
|
||||
|
||||
private isContentSelectedForStreaming(ctx: ExecutionContext, block: SerializedBlock): boolean {
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { encryptionMockFns } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockExecuteTool } = vi.hoisted(() => ({ mockExecuteTool: vi.fn() }))
|
||||
|
||||
vi.mock('@/tools', () => ({ executeTool: mockExecuteTool }))
|
||||
vi.mock('@/lib/core/security/encryption', () => ({
|
||||
decryptSecret: encryptionMockFns.mockDecryptSecret,
|
||||
}))
|
||||
|
||||
import {
|
||||
PI_SEARCH_BUDGET_MESSAGE,
|
||||
@@ -44,6 +48,7 @@ async function run(
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
encryptionMockFns.mockDecryptSecret.mockReset()
|
||||
})
|
||||
|
||||
describe('buildPiSearchToolSpec', () => {
|
||||
@@ -117,20 +122,30 @@ describe('buildPiSearchToolSpec', () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'SEARCH_QUERY', plaintext: secret, encryptedValue: 'ciphertext' },
|
||||
])
|
||||
registry.recordResolved('SEARCH_QUERY', secret)
|
||||
const mergeSpy = vi.spyOn(registry, 'mergeToolCallRegistry')
|
||||
const context = executionContext(registry)
|
||||
mockExecuteTool.mockResolvedValue({
|
||||
success: true,
|
||||
output: {
|
||||
results: [
|
||||
{
|
||||
title: secret,
|
||||
url: 'https://example.com/docs',
|
||||
text: `Bearer ${secret}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
encryptionMockFns.mockDecryptSecret.mockResolvedValue({ decrypted: secret })
|
||||
mockExecuteTool.mockImplementation(async (_toolId, _params, options) => {
|
||||
await options.resolvedSecretTraceRegistry.importProvenance(
|
||||
{
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [{ name: 'SEARCH_QUERY', encryptedValue: 'ciphertext' }],
|
||||
},
|
||||
{ trusted: true }
|
||||
)
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
results: [
|
||||
{
|
||||
title: secret,
|
||||
url: 'https://example.com/docs',
|
||||
text: `Bearer ${secret}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const result = await buildTool('exa', context).execute({ query: secret })
|
||||
@@ -176,13 +191,67 @@ describe('buildPiSearchToolSpec', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('projects only the exact resolver-recorded search key and leaves the raw result unchanged', async () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'SEARCH_KEY', plaintext: 'key-123', encryptedValue: 'search-ciphertext' },
|
||||
{ name: 'UNRELATED', plaintext: 'Test', encryptedValue: 'unrelated-ciphertext' },
|
||||
])
|
||||
registry.recordResolvedAtInputPath('SEARCH_KEY', 'key-123', ['searchApiKey'])
|
||||
registry.recordResolvedInputProjection(['searchApiKey'], 'key-123', '{{SEARCH_KEY}}')
|
||||
registry.recordResolvedAtInputPath('UNRELATED', 'Test', ['task'])
|
||||
registry.recordResolvedInputProjection(['task'], 'Test', '{{UNRELATED}}')
|
||||
const output = {
|
||||
results: [
|
||||
{
|
||||
title: 'key-123',
|
||||
url: 'https://example.com/docs',
|
||||
text: 'Test',
|
||||
},
|
||||
],
|
||||
}
|
||||
mockExecuteTool.mockResolvedValue({ success: true, output })
|
||||
|
||||
const result = await buildPiSearchToolSpec(
|
||||
executionContext(registry),
|
||||
{ provider: 'exa', apiKey: 'key-123' },
|
||||
'local',
|
||||
'{{SEARCH_KEY}}'
|
||||
).execute({ query: 'pi' })
|
||||
|
||||
expect(JSON.parse(result.text).results[0]).toEqual({
|
||||
title: '{{SEARCH_KEY}}',
|
||||
url: 'https://example.com/docs',
|
||||
snippet: 'Test',
|
||||
})
|
||||
expect(
|
||||
mockExecuteTool.mock.calls[0][2].resolvedSecretTraceRegistry
|
||||
.exportCommittedProvenanceForInputPaths([['apiKey']])
|
||||
.entries.map((entry: { name?: string }) => entry.name)
|
||||
).toEqual(['SEARCH_KEY'])
|
||||
expect(output).toEqual({
|
||||
results: [
|
||||
{
|
||||
title: 'key-123',
|
||||
url: 'https://example.com/docs',
|
||||
text: 'Test',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('projects anonymous provenance learned by the isolated search call', async () => {
|
||||
const registry = new ResolvedSecretTraceRegistry()
|
||||
encryptionMockFns.mockDecryptSecret.mockResolvedValue({ decrypted: 'foreign-secret' })
|
||||
mockExecuteTool.mockImplementation(async (_toolId, _params, options) => {
|
||||
vi.spyOn(options.resolvedSecretTraceRegistry, 'getModelEgressSnapshot').mockReturnValue({
|
||||
complete: true,
|
||||
matches: [{ plaintext: 'foreign-secret', replacement: '[REDACTED_SECRET]' }],
|
||||
})
|
||||
await options.resolvedSecretTraceRegistry.importProvenance(
|
||||
{
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [{ name: 'FOREIGN', encryptedValue: 'foreign-ciphertext' }],
|
||||
scope: { userId: 'foreign-user', workspaceId: 'foreign-workspace' },
|
||||
},
|
||||
{ trusted: true }
|
||||
)
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
@@ -202,24 +271,31 @@ describe('buildPiSearchToolSpec', () => {
|
||||
expect(JSON.parse(result.text).results[0].snippet).toBe('[REDACTED_SECRET]')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['missing', undefined],
|
||||
[
|
||||
'incomplete',
|
||||
(() => {
|
||||
const registry = new ResolvedSecretTraceRegistry()
|
||||
registry.markIncomplete()
|
||||
return registry
|
||||
})(),
|
||||
],
|
||||
])('fails closed before search when provenance is %s', async (_label, registry) => {
|
||||
const context = registry
|
||||
? executionContext(registry)
|
||||
: ({ executionId: 'exec-1', workspaceId: 'ws-1' } as ExecutionContext)
|
||||
it('preserves legacy search behavior when no provenance registry exists', async () => {
|
||||
const output = {
|
||||
results: [{ title: 'Docs', url: 'https://example.com/docs', text: 'Page text' }],
|
||||
}
|
||||
mockExecuteTool.mockResolvedValue({ success: true, output })
|
||||
const context = { executionId: 'exec-1', workspaceId: 'ws-1' } as ExecutionContext
|
||||
|
||||
const result = await buildTool('exa', context).execute({ query: 'pi' })
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
expect(JSON.parse(result.text).results[0].snippet).toBe('Page text')
|
||||
expect(mockExecuteTool.mock.calls[0][2].resolvedSecretTraceRegistry).toBeUndefined()
|
||||
expect(output.results[0].text).toBe('Page text')
|
||||
})
|
||||
|
||||
it('fails closed before search when provenance is incomplete', async () => {
|
||||
const registry = new ResolvedSecretTraceRegistry()
|
||||
registry.markIncomplete()
|
||||
|
||||
const result = await buildTool('exa', executionContext(registry)).execute({ query: 'pi' })
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.text).toContain('could not be returned safely')
|
||||
expect(result.text).toBe(
|
||||
'Web search settled, but its result could not be returned safely. Do not retry automatically.'
|
||||
)
|
||||
expect(mockExecuteTool).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -243,6 +319,33 @@ describe('buildPiSearchToolSpec', () => {
|
||||
expect(mergeSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the fixed unavailable message unchanged when active provenance contains one character', async () => {
|
||||
encryptionMockFns.mockDecryptSecret.mockResolvedValue({ decrypted: 'W' })
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'LETTER', plaintext: 'W', encryptedValue: 'encrypted-letter' },
|
||||
])
|
||||
const cyclic: Record<string, unknown> = {}
|
||||
cyclic.self = cyclic
|
||||
mockExecuteTool.mockImplementation(async (_toolId, _params, options) => {
|
||||
await options.resolvedSecretTraceRegistry.importProvenance(
|
||||
{
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [{ name: 'LETTER', encryptedValue: 'encrypted-letter' }],
|
||||
},
|
||||
{ trusted: true }
|
||||
)
|
||||
return { success: true, output: cyclic }
|
||||
})
|
||||
|
||||
const result = await buildTool('exa', executionContext(registry)).execute({ query: 'pi' })
|
||||
|
||||
expect(result).toEqual({
|
||||
text: 'Web search settled, but its result could not be returned safely. Do not retry automatically.',
|
||||
isError: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('reports an empty search as a successful no-results envelope', async () => {
|
||||
mockExecuteTool.mockResolvedValue({ success: true, output: { results: [] } })
|
||||
|
||||
|
||||
@@ -25,25 +25,19 @@ import {
|
||||
serializePiSearchEnvelope,
|
||||
} from '@/executor/handlers/pi/search/normalize'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import {
|
||||
projectResolvedSecretModelContent,
|
||||
projectResolvedSecretModelControlMessage,
|
||||
} from '@/executor/utils/resolved-secret-content-projection'
|
||||
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
|
||||
import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection'
|
||||
import { executeTool } from '@/tools'
|
||||
|
||||
const logger = createLogger('PiSearchTool')
|
||||
const SEARCH_RESULT_UNAVAILABLE_MESSAGE =
|
||||
'Web search settled, but its result could not be returned safely. Do not retry automatically.'
|
||||
|
||||
function unavailableSearchResult(registry: ResolvedSecretTraceRegistry | undefined): {
|
||||
function unavailableSearchResult(): {
|
||||
text: string
|
||||
isError: true
|
||||
} {
|
||||
return {
|
||||
text:
|
||||
projectResolvedSecretModelControlMessage(SEARCH_RESULT_UNAVAILABLE_MESSAGE, registry) ??
|
||||
SEARCH_RESULT_UNAVAILABLE_MESSAGE,
|
||||
text: SEARCH_RESULT_UNAVAILABLE_MESSAGE,
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
@@ -87,7 +81,8 @@ function describePiSearchFailure(label: string, status: unknown, error: unknown)
|
||||
export function buildPiSearchToolSpec(
|
||||
ctx: ExecutionContext,
|
||||
search: Pick<PiSearchConfig, 'provider' | 'apiKey'>,
|
||||
mode: 'local' | 'cloud_review'
|
||||
mode: 'local' | 'cloud_review',
|
||||
projectedApiKey = search.apiKey
|
||||
): PiToolSpec {
|
||||
const { label, toolId } = PI_SEARCH_PROVIDERS[search.provider]
|
||||
const logContext = {
|
||||
@@ -122,9 +117,18 @@ export function buildPiSearchToolSpec(
|
||||
timeout: PI_SEARCH_TIMEOUT_MS,
|
||||
}
|
||||
const registry = ctx.resolvedSecretTraceRegistry
|
||||
const toolCallRegistry = registry?.forkForToolInputValues(Object.values(providerParams))
|
||||
if (!registry || !toolCallRegistry?.isComplete()) {
|
||||
return unavailableSearchResult(toolCallRegistry)
|
||||
const toolCallRegistry = registry?.forkForInputPaths([['searchApiKey']], {
|
||||
propagated: true,
|
||||
})
|
||||
if (toolCallRegistry && !toolCallRegistry.isComplete()) {
|
||||
return unavailableSearchResult()
|
||||
}
|
||||
if (toolCallRegistry) {
|
||||
toolCallRegistry.recordTransformedInputProjection(providerParams, {
|
||||
...providerParams,
|
||||
apiKey: projectedApiKey,
|
||||
})
|
||||
if (!toolCallRegistry.isComplete()) return unavailableSearchResult()
|
||||
}
|
||||
|
||||
const result = await executeTool(toolId, providerParams, {
|
||||
@@ -133,18 +137,18 @@ export function buildPiSearchToolSpec(
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
if (!toolCallRegistry.isComplete()) {
|
||||
return unavailableSearchResult(toolCallRegistry)
|
||||
if (toolCallRegistry && !toolCallRegistry.isComplete()) {
|
||||
return unavailableSearchResult()
|
||||
}
|
||||
if (result.error === PARALLEL_EMPTY_RESULTS_ERROR) {
|
||||
logger.info('Pi search returned no results', { ...logContext, resultCount: 0 })
|
||||
registry.mergeToolCallRegistry(toolCallRegistry)
|
||||
if (registry && toolCallRegistry) registry.mergeToolCallRegistry(toolCallRegistry)
|
||||
return { text: serializePiSearchEnvelope([]), isError: false }
|
||||
}
|
||||
|
||||
const status = (result.output as { status?: unknown } | undefined)?.status
|
||||
logger.warn('Pi search failed', { ...logContext, status })
|
||||
registry.mergeToolCallRegistry(toolCallRegistry)
|
||||
if (registry && toolCallRegistry) registry.mergeToolCallRegistry(toolCallRegistry)
|
||||
return {
|
||||
// Classified rather than quoted: `result.error` can carry provider-response-derived text
|
||||
// for all four providers, which the untrusted-results guideline does not cover. Only the
|
||||
@@ -155,9 +159,14 @@ export function buildPiSearchToolSpec(
|
||||
}
|
||||
}
|
||||
|
||||
const outputProjection = projectResolvedSecretModelContent(result.output, toolCallRegistry)
|
||||
if (!outputProjection.safe || !toolCallRegistry.isComplete()) {
|
||||
return unavailableSearchResult(toolCallRegistry)
|
||||
const outputProjection = toolCallRegistry
|
||||
? projectResolvedSecretModelContent(
|
||||
result.output,
|
||||
toolCallRegistry.forkForPropagatedEntries()
|
||||
)
|
||||
: ({ safe: true, value: result.output } as const)
|
||||
if (!outputProjection.safe || (toolCallRegistry && !toolCallRegistry.isComplete())) {
|
||||
return unavailableSearchResult()
|
||||
}
|
||||
const results = normalizePiSearchRecords(
|
||||
search.provider,
|
||||
@@ -165,7 +174,7 @@ export function buildPiSearchToolSpec(
|
||||
numResults
|
||||
)
|
||||
logger.info('Pi search completed', { ...logContext, resultCount: results.length })
|
||||
registry.mergeToolCallRegistry(toolCallRegistry)
|
||||
if (registry && toolCallRegistry) registry.mergeToolCallRegistry(toolCallRegistry)
|
||||
return { text: serializePiSearchEnvelope(results), isError: false }
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { encryptionMockFns } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockTransformBlockTool, mockExecuteTool } = vi.hoisted(() => ({
|
||||
@@ -12,6 +13,9 @@ vi.mock('@/providers/utils', () => ({ transformBlockTool: mockTransformBlockTool
|
||||
vi.mock('@/tools', () => ({ executeTool: mockExecuteTool }))
|
||||
vi.mock('@/tools/utils', () => ({ getTool: vi.fn() }))
|
||||
vi.mock('@/tools/utils.server', () => ({ getToolAsync: vi.fn() }))
|
||||
vi.mock('@/lib/core/security/encryption', () => ({
|
||||
decryptSecret: encryptionMockFns.mockDecryptSecret,
|
||||
}))
|
||||
|
||||
import { buildSimToolSpecs } from '@/executor/handlers/pi/sim-tools'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
@@ -44,6 +48,7 @@ function mockToolAdapter(params: Record<string, unknown> = {}): void {
|
||||
describe('buildSimToolSpecs', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
encryptionMockFns.mockDecryptSecret.mockReset()
|
||||
})
|
||||
|
||||
it('names the Pi tool with the snake_case tool id, not the human label', async () => {
|
||||
@@ -122,9 +127,20 @@ describe('buildSimToolSpecs', () => {
|
||||
|
||||
it('projects named provenance in successful Sim tool output', async () => {
|
||||
mockToolAdapter({ apiKey: 'secret-value' })
|
||||
mockExecuteTool.mockResolvedValue({
|
||||
success: true,
|
||||
output: { authorization: 'Bearer secret-value' },
|
||||
encryptionMockFns.mockDecryptSecret.mockResolvedValue({ decrypted: 'secret-value' })
|
||||
mockExecuteTool.mockImplementation(async (_toolId, _params, options) => {
|
||||
await options.resolvedSecretTraceRegistry.importProvenance(
|
||||
{
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [{ name: 'API_KEY', encryptedValue: 'ciphertext' }],
|
||||
},
|
||||
{ trusted: true }
|
||||
)
|
||||
return {
|
||||
success: true,
|
||||
output: { authorization: 'Bearer secret-value' },
|
||||
}
|
||||
})
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'API_KEY', plaintext: 'secret-value', encryptedValue: 'ciphertext' },
|
||||
@@ -141,11 +157,17 @@ describe('buildSimToolSpecs', () => {
|
||||
|
||||
it('uses the anonymous fallback for cross-scope provenance', async () => {
|
||||
mockToolAdapter()
|
||||
encryptionMockFns.mockDecryptSecret.mockResolvedValue({ decrypted: 'foreign-secret' })
|
||||
mockExecuteTool.mockImplementation(async (_toolId, _params, options) => {
|
||||
vi.spyOn(options.resolvedSecretTraceRegistry, 'getModelEgressSnapshot').mockReturnValue({
|
||||
complete: true,
|
||||
matches: [{ plaintext: 'foreign-secret', replacement: '[REDACTED_SECRET]' }],
|
||||
})
|
||||
await options.resolvedSecretTraceRegistry.importProvenance(
|
||||
{
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [{ name: 'FOREIGN', encryptedValue: 'foreign-ciphertext' }],
|
||||
scope: { userId: 'foreign-user', workspaceId: 'foreign-workspace' },
|
||||
},
|
||||
{ trusted: true }
|
||||
)
|
||||
return {
|
||||
success: true,
|
||||
output: { token: 'foreign-secret' },
|
||||
@@ -189,54 +211,152 @@ describe('buildSimToolSpecs', () => {
|
||||
await expect(spec.execute({})).resolves.toEqual({ text: 'Test', isError: false })
|
||||
})
|
||||
|
||||
it('projects only the selected tool params by original array index and leaves raw output unchanged', async () => {
|
||||
const selectedTool = {
|
||||
type: 'exa',
|
||||
operation: 'exa_search',
|
||||
usageControl: 'auto',
|
||||
params: { apiKey: 'secret-value' },
|
||||
}
|
||||
const tools = [{ type: 'exa', operation: 'exa_search', usageControl: 'none' }, selectedTool]
|
||||
mockToolAdapter(selectedTool.params)
|
||||
const output = { selected: 'secret-value', unrelated: 'Test' }
|
||||
mockExecuteTool.mockResolvedValue({ success: true, output })
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'API_KEY', plaintext: 'secret-value', encryptedValue: 'ciphertext' },
|
||||
{ name: 'UNRELATED', plaintext: 'Test', encryptedValue: 'unrelated-ciphertext' },
|
||||
])
|
||||
registry.recordResolvedAtInputPath('API_KEY', 'secret-value', [
|
||||
'tools',
|
||||
'1',
|
||||
'params',
|
||||
'apiKey',
|
||||
])
|
||||
registry.recordResolvedInputProjection(
|
||||
['tools', '1', 'params', 'apiKey'],
|
||||
'secret-value',
|
||||
'{{API_KEY}}'
|
||||
)
|
||||
registry.recordResolvedAtInputPath('UNRELATED', 'Test', ['task'])
|
||||
registry.recordResolvedInputProjection(['task'], 'Test', '{{UNRELATED}}')
|
||||
|
||||
const [spec] = await buildSimToolSpecs(executionContext(registry), tools)
|
||||
|
||||
await expect(spec.execute({ query: 'pi' })).resolves.toEqual({
|
||||
text: JSON.stringify({ selected: '{{API_KEY}}', unrelated: 'Test' }),
|
||||
isError: false,
|
||||
})
|
||||
expect(
|
||||
mockExecuteTool.mock.calls[0][2].resolvedSecretTraceRegistry
|
||||
.exportCommittedProvenanceForInputPaths([['apiKey']])
|
||||
.entries.map((entry: { name?: string }) => entry.name)
|
||||
).toEqual(['API_KEY'])
|
||||
expect(output).toEqual({ selected: 'secret-value', unrelated: 'Test' })
|
||||
})
|
||||
|
||||
it('projects error text returned or thrown by a Sim tool', async () => {
|
||||
mockToolAdapter({ apiKey: 'secret-value' })
|
||||
encryptionMockFns.mockDecryptSecret.mockResolvedValue({ decrypted: 'secret-value' })
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'API_KEY', plaintext: 'secret-value', encryptedValue: 'ciphertext' },
|
||||
])
|
||||
registry.recordResolved('API_KEY', 'secret-value')
|
||||
const [spec] = await buildSimToolSpecs(executionContext(registry), toolInput)
|
||||
|
||||
mockExecuteTool.mockResolvedValueOnce({
|
||||
success: false,
|
||||
output: {},
|
||||
error: 'provider rejected secret-value',
|
||||
mockExecuteTool.mockImplementationOnce(async (_toolId, _params, options) => {
|
||||
await options.resolvedSecretTraceRegistry.importProvenance(
|
||||
{
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [{ name: 'API_KEY', encryptedValue: 'ciphertext' }],
|
||||
},
|
||||
{ trusted: true }
|
||||
)
|
||||
return {
|
||||
success: false,
|
||||
output: {},
|
||||
error: 'provider rejected secret-value',
|
||||
}
|
||||
})
|
||||
await expect(spec.execute({})).resolves.toEqual({
|
||||
text: 'provider rejected {{API_KEY}}',
|
||||
isError: true,
|
||||
})
|
||||
|
||||
mockExecuteTool.mockRejectedValueOnce(new Error('transport exposed secret-value'))
|
||||
mockExecuteTool.mockImplementationOnce(async (_toolId, _params, options) => {
|
||||
await options.resolvedSecretTraceRegistry.importProvenance(
|
||||
{
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [{ name: 'API_KEY', encryptedValue: 'ciphertext' }],
|
||||
},
|
||||
{ trusted: true }
|
||||
)
|
||||
throw new Error('transport exposed secret-value')
|
||||
})
|
||||
await expect(spec.execute({})).resolves.toEqual({
|
||||
text: 'transport exposed {{API_KEY}}',
|
||||
isError: true,
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['missing', undefined],
|
||||
[
|
||||
'incomplete',
|
||||
(() => {
|
||||
const registry = new ResolvedSecretTraceRegistry()
|
||||
registry.markIncomplete()
|
||||
return registry
|
||||
})(),
|
||||
],
|
||||
])('fails closed when Sim tool result provenance is %s', async (_label, registry) => {
|
||||
it('preserves legacy Sim tool behavior when no provenance registry exists', async () => {
|
||||
mockToolAdapter()
|
||||
const output = { result: 'ordinary output' }
|
||||
mockExecuteTool.mockResolvedValue({ success: true, output })
|
||||
const [spec] = await buildSimToolSpecs(executionContext(undefined), toolInput)
|
||||
|
||||
await expect(spec.execute({})).resolves.toEqual({
|
||||
text: JSON.stringify(output),
|
||||
isError: false,
|
||||
})
|
||||
expect(mockExecuteTool.mock.calls[0][2].resolvedSecretTraceRegistry).toBeUndefined()
|
||||
expect(output).toEqual({ result: 'ordinary output' })
|
||||
})
|
||||
|
||||
it('fails closed when Sim tool result provenance is incomplete', async () => {
|
||||
mockToolAdapter()
|
||||
mockExecuteTool.mockResolvedValue({
|
||||
success: true,
|
||||
output: { result: 'untrusted output' },
|
||||
})
|
||||
const registry = new ResolvedSecretTraceRegistry()
|
||||
registry.markIncomplete()
|
||||
const [spec] = await buildSimToolSpecs(executionContext(registry), toolInput)
|
||||
|
||||
const result = await spec.execute({})
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.text).toContain('could not be returned safely')
|
||||
expect(result.text).toBe(
|
||||
'Tool execution settled, but its result could not be returned safely. Do not retry a mutation automatically.'
|
||||
)
|
||||
expect(result.text).not.toContain('untrusted output')
|
||||
expect(mockExecuteTool).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the fixed unavailable message unchanged when active provenance contains one character', async () => {
|
||||
mockToolAdapter()
|
||||
encryptionMockFns.mockDecryptSecret.mockResolvedValue({ decrypted: 'T' })
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'LETTER', plaintext: 'T', encryptedValue: 'encrypted-letter' },
|
||||
])
|
||||
const cyclic: Record<string, unknown> = {}
|
||||
cyclic.self = cyclic
|
||||
mockExecuteTool.mockImplementation(async (_toolId, _params, options) => {
|
||||
await options.resolvedSecretTraceRegistry.importProvenance(
|
||||
{
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [{ name: 'LETTER', encryptedValue: 'encrypted-letter' }],
|
||||
},
|
||||
{ trusted: true }
|
||||
)
|
||||
return { success: true, output: cyclic }
|
||||
})
|
||||
const [spec] = await buildSimToolSpecs(executionContext(registry), toolInput)
|
||||
|
||||
await expect(spec.execute({})).resolves.toEqual({
|
||||
text: 'Tool execution settled, but its result could not be returned safely. Do not retry a mutation automatically.',
|
||||
isError: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,10 +14,7 @@ import { getAllBlocks } from '@/blocks/registry'
|
||||
import type { ToolInput } from '@/executor/handlers/agent/types'
|
||||
import type { PiToolResult, PiToolSpec } from '@/executor/handlers/pi/backend'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import {
|
||||
projectResolvedSecretModelContent,
|
||||
projectResolvedSecretModelControlMessage,
|
||||
} from '@/executor/utils/resolved-secret-content-projection'
|
||||
import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection'
|
||||
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
|
||||
import { transformBlockTool } from '@/providers/utils'
|
||||
import { executeTool } from '@/tools'
|
||||
@@ -30,12 +27,11 @@ import { getToolAsync } from '@/tools/utils.server'
|
||||
const logger = createLogger('PiSimTools')
|
||||
const TOOL_RESULT_UNAVAILABLE_MESSAGE =
|
||||
'Tool execution settled, but its result could not be returned safely. Do not retry a mutation automatically.'
|
||||
const TOOL_EXECUTION_FAILED_MESSAGE = 'Tool execution failed'
|
||||
|
||||
function unavailableToolResult(registry: ResolvedSecretTraceRegistry | undefined): PiToolResult {
|
||||
function unavailableToolResult(): PiToolResult {
|
||||
return {
|
||||
text:
|
||||
projectResolvedSecretModelControlMessage(TOOL_RESULT_UNAVAILABLE_MESSAGE, registry) ??
|
||||
TOOL_RESULT_UNAVAILABLE_MESSAGE,
|
||||
text: TOOL_RESULT_UNAVAILABLE_MESSAGE,
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
@@ -49,10 +45,28 @@ function projectToolResult(
|
||||
registry: ResolvedSecretTraceRegistry | undefined
|
||||
): PiToolResultProjection {
|
||||
try {
|
||||
if (!registry) {
|
||||
if (!result.success) {
|
||||
return {
|
||||
safe: true,
|
||||
result: {
|
||||
text: result.error || TOOL_EXECUTION_FAILED_MESSAGE,
|
||||
isError: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const text =
|
||||
typeof result.output === 'string' ? result.output : JSON.stringify(result.output ?? {})
|
||||
return typeof text === 'string'
|
||||
? { safe: true, result: { text, isError: false } }
|
||||
: { safe: false, result: unavailableToolResult() }
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
const projection = projectResolvedSecretModelContent(result.output, registry)
|
||||
if (!projection.safe) {
|
||||
return { safe: false, result: unavailableToolResult(registry) }
|
||||
return { safe: false, result: unavailableToolResult() }
|
||||
}
|
||||
|
||||
const text =
|
||||
@@ -61,18 +75,24 @@ function projectToolResult(
|
||||
: JSON.stringify(projection.value ?? {})
|
||||
return typeof text === 'string'
|
||||
? { safe: true, result: { text, isError: false } }
|
||||
: { safe: false, result: unavailableToolResult(registry) }
|
||||
: { safe: false, result: unavailableToolResult() }
|
||||
}
|
||||
|
||||
const projection = projectResolvedSecretModelContent(
|
||||
result.error || 'Tool execution failed',
|
||||
registry
|
||||
)
|
||||
if (!result.error) {
|
||||
return registry.isComplete()
|
||||
? {
|
||||
safe: true,
|
||||
result: { text: TOOL_EXECUTION_FAILED_MESSAGE, isError: true },
|
||||
}
|
||||
: { safe: false, result: unavailableToolResult() }
|
||||
}
|
||||
|
||||
const projection = projectResolvedSecretModelContent(result.error, registry)
|
||||
return projection.safe && typeof projection.value === 'string'
|
||||
? { safe: true, result: { text: projection.value, isError: true } }
|
||||
: { safe: false, result: unavailableToolResult(registry) }
|
||||
: { safe: false, result: unavailableToolResult() }
|
||||
} catch {
|
||||
return { safe: false, result: unavailableToolResult(registry) }
|
||||
return { safe: false, result: unavailableToolResult() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +108,7 @@ export async function buildSimToolSpecs(
|
||||
|
||||
const specs: PiToolSpec[] = []
|
||||
|
||||
for (const tool of inputTools as ToolInput[]) {
|
||||
for (const [toolIndex, tool] of (inputTools as ToolInput[]).entries()) {
|
||||
if ((tool.usageControl || 'auto') === 'none') continue
|
||||
if (!tool.type || tool.type === 'mcp' || tool.type === 'custom-tool') continue
|
||||
|
||||
@@ -123,9 +143,30 @@ export async function buildSimToolSpecs(
|
||||
execute: async (args) => {
|
||||
const params = mergeToolParameters(preseededParams, args as Record<string, unknown>)
|
||||
const registry = ctx.resolvedSecretTraceRegistry
|
||||
const toolCallRegistry = registry?.forkForToolInputValues(Object.values(params))
|
||||
if (!registry || !toolCallRegistry?.isComplete()) {
|
||||
return unavailableToolResult(toolCallRegistry)
|
||||
const sourcePath = ['tools', String(toolIndex), 'params'] as const
|
||||
const toolCallRegistry = registry?.forkForInputPaths([sourcePath], {
|
||||
propagated: true,
|
||||
})
|
||||
if (toolCallRegistry && !toolCallRegistry.isComplete()) {
|
||||
return unavailableToolResult()
|
||||
}
|
||||
|
||||
if (toolCallRegistry) {
|
||||
const inputProjection = toolCallRegistry.projectResolvedInputSelection({
|
||||
tools: inputTools,
|
||||
})
|
||||
const projectedTool = inputProjection.complete
|
||||
? (inputProjection.value.tools as ToolInput[] | undefined)?.[toolIndex]
|
||||
: undefined
|
||||
if (!inputProjection.complete || !projectedTool) {
|
||||
return unavailableToolResult()
|
||||
}
|
||||
const projectedParams = mergeToolParameters(
|
||||
projectedTool.params || {},
|
||||
args as Record<string, unknown>
|
||||
)
|
||||
toolCallRegistry.recordTransformedInputProjection(params, projectedParams)
|
||||
if (!toolCallRegistry.isComplete()) return unavailableToolResult()
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -156,8 +197,11 @@ export async function buildSimToolSpecs(
|
||||
resolvedSecretTraceRegistry: toolCallRegistry,
|
||||
}
|
||||
)
|
||||
const projection = projectToolResult(result, toolCallRegistry)
|
||||
if (projection.safe && toolCallRegistry.isComplete()) {
|
||||
const projection = projectToolResult(
|
||||
result,
|
||||
toolCallRegistry?.forkForPropagatedEntries()
|
||||
)
|
||||
if (projection.safe && registry && toolCallRegistry?.isComplete()) {
|
||||
registry.mergeToolCallRegistry(toolCallRegistry)
|
||||
}
|
||||
return projection.result
|
||||
@@ -168,9 +212,9 @@ export async function buildSimToolSpecs(
|
||||
output: {},
|
||||
error: getErrorMessage(error, 'Tool execution failed'),
|
||||
},
|
||||
toolCallRegistry
|
||||
toolCallRegistry?.forkForPropagatedEntries()
|
||||
)
|
||||
if (projection.safe && toolCallRegistry.isComplete()) {
|
||||
if (projection.safe && registry && toolCallRegistry?.isComplete()) {
|
||||
registry.mergeToolCallRegistry(toolCallRegistry)
|
||||
}
|
||||
return projection.result
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import '@sim/testing/mocks/executor'
|
||||
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { authOAuthUtilsMock, authOAuthUtilsMockFns } from '@sim/testing'
|
||||
import {
|
||||
authOAuthUtilsMock,
|
||||
authOAuthUtilsMockFns,
|
||||
encryptionMock,
|
||||
encryptionMockFns,
|
||||
} from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
|
||||
|
||||
const { mockResolveAutoModel } = vi.hoisted(() => ({
|
||||
@@ -9,6 +14,7 @@ const { mockResolveAutoModel } = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock)
|
||||
vi.mock('@/lib/core/security/encryption', () => encryptionMock)
|
||||
|
||||
vi.mock('@/lib/credentials/access', () => ({
|
||||
getCredentialActorContext: vi.fn().mockResolvedValue({
|
||||
@@ -32,7 +38,11 @@ vi.mock('@/lib/model-router/resolve', () => ({
|
||||
SIM_AUTO_SYSTEM_PREAMBLE: 'Sim auto system preamble',
|
||||
}))
|
||||
|
||||
import { PRIVATE_MODEL_INPUT_PROVENANCE_HEADER } from '@/lib/execution/model-input-provenance'
|
||||
import {
|
||||
PRIVATE_MODEL_INPUT_PROVENANCE_HEADER,
|
||||
PRIVATE_MODEL_INPUT_STATE_HEADER,
|
||||
PROJECTED_MODEL_INPUT_PATHS_V1,
|
||||
} from '@/lib/execution/model-input-provenance'
|
||||
import {
|
||||
RESOLVED_SECRET_PROVENANCE_FIELD,
|
||||
RESOLVED_SECRET_PROVENANCE_METADATA_V1,
|
||||
@@ -124,6 +134,7 @@ describe('RouterBlockHandler', () => {
|
||||
}
|
||||
|
||||
vi.clearAllMocks()
|
||||
encryptionMockFns.mockDecryptSecret.mockResolvedValue({ decrypted: 'test-decrypted' })
|
||||
|
||||
// unstubGlobals removes any module-scope fetch stub before each test, so re-stub here
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
@@ -258,7 +269,8 @@ describe('RouterBlockHandler', () => {
|
||||
encryptedValue: 'encrypted-router-credential',
|
||||
},
|
||||
])
|
||||
registry.recordResolved('PROMPT_SECRET', promptSecret)
|
||||
registry.recordResolvedAtInputPath('PROMPT_SECRET', promptSecret, ['prompt'])
|
||||
registry.recordResolvedInputProjection(['prompt'], promptSecret, '{{PROMPT_SECRET}}')
|
||||
registry.recordResolved('API_KEY', credentialSecret)
|
||||
mockContext.resolvedSecretTraceRegistry = registry
|
||||
|
||||
@@ -273,6 +285,9 @@ describe('RouterBlockHandler', () => {
|
||||
expect((request.headers as Headers).get(PRIVATE_MODEL_INPUT_PROVENANCE_HEADER)).toBe(
|
||||
RESOLVED_SECRET_PROVENANCE_METADATA_V1
|
||||
)
|
||||
expect((request.headers as Headers).get(PRIVATE_MODEL_INPUT_STATE_HEADER)).toBe(
|
||||
PROJECTED_MODEL_INPUT_PATHS_V1
|
||||
)
|
||||
expect(requestBody[RESOLVED_SECRET_PROVENANCE_FIELD]).toEqual({
|
||||
version: 1,
|
||||
complete: true,
|
||||
@@ -284,6 +299,100 @@ describe('RouterBlockHandler', () => {
|
||||
],
|
||||
})
|
||||
expect(requestBody.apiKey).toBe(credentialSecret)
|
||||
expect(mockGenerateRouterPrompt).toHaveBeenCalledWith('{{PROMPT_SECRET}}', expect.any(Array))
|
||||
})
|
||||
|
||||
it('omits a prior target state when only aggregate secret provenance is available', async () => {
|
||||
const stateSecret = 'x'
|
||||
const encryptedStateSecret = 'encrypted-router-state'
|
||||
const rawState = { result: stateSecret, ordinary: 'Box remains raw state' }
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{
|
||||
name: 'STATE_SECRET',
|
||||
plaintext: stateSecret,
|
||||
encryptedValue: encryptedStateSecret,
|
||||
},
|
||||
])
|
||||
mockContext.resolvedSecretTraceRegistry = registry
|
||||
mockContext.blockStates = new Map([
|
||||
[
|
||||
mockTargetBlock1.id,
|
||||
{
|
||||
output: rawState,
|
||||
executed: true,
|
||||
executionTime: 1,
|
||||
resolvedSecretTraceProvenance: {
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [{ name: 'STATE_SECRET', encryptedValue: encryptedStateSecret }],
|
||||
},
|
||||
},
|
||||
],
|
||||
])
|
||||
encryptionMockFns.mockDecryptSecret.mockImplementation(async (encryptedValue: string) => ({
|
||||
decrypted: encryptedValue === encryptedStateSecret ? stateSecret : 'test-decrypted',
|
||||
}))
|
||||
|
||||
await handler.execute(mockContext, mockBlock, {
|
||||
prompt: 'Choose the best option.',
|
||||
model: 'gpt-4o',
|
||||
})
|
||||
|
||||
expect(mockGenerateRouterPrompt).toHaveBeenCalledWith(
|
||||
'Choose the best option.',
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: mockTargetBlock1.id,
|
||||
subBlocks: expect.objectContaining({ p: 'a' }),
|
||||
currentState: undefined,
|
||||
}),
|
||||
])
|
||||
)
|
||||
expect(rawState).toEqual({ result: stateSecret, ordinary: 'Box remains raw state' })
|
||||
expect(mockTargetBlock1.config.params).toEqual({ p: 'a' })
|
||||
|
||||
const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body)
|
||||
expect(requestBody[RESOLVED_SECRET_PROVENANCE_FIELD]).toEqual({
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps an ordinary prior target state with exact-empty provenance unchanged', async () => {
|
||||
const rawState = { result: 'x', ordinary: 'Box remains raw state' }
|
||||
mockContext.resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry([])
|
||||
mockContext.blockStates = new Map([
|
||||
[
|
||||
mockTargetBlock1.id,
|
||||
{
|
||||
output: rawState,
|
||||
executed: true,
|
||||
executionTime: 1,
|
||||
resolvedSecretTraceProvenance: {
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
await handler.execute(mockContext, mockBlock, {
|
||||
prompt: 'Choose the best option.',
|
||||
model: 'gpt-4o',
|
||||
})
|
||||
|
||||
expect(mockGenerateRouterPrompt).toHaveBeenCalledWith(
|
||||
'Choose the best option.',
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: mockTargetBlock1.id,
|
||||
currentState: rawState,
|
||||
}),
|
||||
])
|
||||
)
|
||||
expect(rawState).toEqual({ result: 'x', ordinary: 'Box remains raw state' })
|
||||
})
|
||||
|
||||
it('keeps the legacy router request shape when no provenance registry exists', async () => {
|
||||
@@ -297,6 +406,7 @@ describe('RouterBlockHandler', () => {
|
||||
const requestBody = JSON.parse(request.body)
|
||||
expect(Object.hasOwn(requestBody, RESOLVED_SECRET_PROVENANCE_FIELD)).toBe(false)
|
||||
expect((request.headers as Headers).get(PRIVATE_MODEL_INPUT_PROVENANCE_HEADER)).toBeNull()
|
||||
expect((request.headers as Headers).get(PRIVATE_MODEL_INPUT_STATE_HEADER)).toBeNull()
|
||||
})
|
||||
|
||||
it('bills the cost the provider proxy decided rather than recomputing it', async () => {
|
||||
@@ -665,7 +775,8 @@ describe('RouterBlockHandler V2', () => {
|
||||
encryptedValue: 'encrypted-router-v2-credential',
|
||||
},
|
||||
])
|
||||
registry.recordResolved('CONTEXT_SECRET', contextSecret)
|
||||
registry.recordResolvedAtInputPath('CONTEXT_SECRET', contextSecret, ['context'])
|
||||
registry.recordResolvedInputProjection(['context'], contextSecret, '{{CONTEXT_SECRET}}')
|
||||
registry.recordResolved('API_KEY', credentialSecret)
|
||||
mockContext.resolvedSecretTraceRegistry = registry
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
@@ -690,6 +801,9 @@ describe('RouterBlockHandler V2', () => {
|
||||
expect((request.headers as Headers).get(PRIVATE_MODEL_INPUT_PROVENANCE_HEADER)).toBe(
|
||||
RESOLVED_SECRET_PROVENANCE_METADATA_V1
|
||||
)
|
||||
expect((request.headers as Headers).get(PRIVATE_MODEL_INPUT_STATE_HEADER)).toBe(
|
||||
PROJECTED_MODEL_INPUT_PATHS_V1
|
||||
)
|
||||
expect(requestBody[RESOLVED_SECRET_PROVENANCE_FIELD]).toEqual({
|
||||
version: 1,
|
||||
complete: true,
|
||||
@@ -701,6 +815,7 @@ describe('RouterBlockHandler V2', () => {
|
||||
],
|
||||
})
|
||||
expect(requestBody.apiKey).toBe(credentialSecret)
|
||||
expect(mockGenerateRouterV2Prompt).toHaveBeenCalledWith('{{CONTEXT_SECRET}}', expect.any(Array))
|
||||
})
|
||||
|
||||
it('keeps the router V2 request shape when no provenance registry exists', async () => {
|
||||
@@ -725,6 +840,7 @@ describe('RouterBlockHandler V2', () => {
|
||||
const requestBody = JSON.parse(request.body)
|
||||
expect(Object.hasOwn(requestBody, RESOLVED_SECRET_PROVENANCE_FIELD)).toBe(false)
|
||||
expect((request.headers as Headers).get(PRIVATE_MODEL_INPUT_PROVENANCE_HEADER)).toBeNull()
|
||||
expect((request.headers as Headers).get(PRIVATE_MODEL_INPUT_STATE_HEADER)).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves sim-auto before executing router V2 and preserves its public identity', async () => {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
|
||||
import {
|
||||
addModelInputProvenanceToRequest,
|
||||
createModelInputProvenanceRequestMetadata,
|
||||
markModelInputProjected,
|
||||
projectResolvedModelInput,
|
||||
} from '@/lib/execution/model-input-provenance'
|
||||
import {
|
||||
type AutoRoutingResult,
|
||||
@@ -22,9 +24,9 @@ import {
|
||||
} from '@/executor/constants'
|
||||
import type { BlockHandler, ExecutionContext } from '@/executor/types'
|
||||
import { buildAuthHeaders } from '@/executor/utils/http'
|
||||
import type { ResolvedSecretInputPath } from '@/executor/utils/resolved-secret-trace-registry'
|
||||
import { resolveVertexCredential } from '@/executor/utils/vertex-credential'
|
||||
import { resolveProxiedModelCost } from '@/providers/cost-policy'
|
||||
import { collectProviderModelInputProvenanceValues } from '@/providers/model-input-provenance'
|
||||
import { isAutoModel, SIM_AUTO_MODEL_ID } from '@/providers/models'
|
||||
import type { ProviderRequest } from '@/providers/types'
|
||||
import { getProviderFromModel } from '@/providers/utils'
|
||||
@@ -71,10 +73,19 @@ export class RouterBlockHandler implements BlockHandler {
|
||||
block: SerializedBlock,
|
||||
inputs: Record<string, any>
|
||||
): Promise<BlockOutput> {
|
||||
const promptModelInputPaths: ResolvedSecretInputPath[] = [['prompt']]
|
||||
const modelInputProjection = projectResolvedModelInput(
|
||||
ctx.resolvedSecretTraceRegistry,
|
||||
{ prompt: inputs.prompt },
|
||||
promptModelInputPaths
|
||||
)
|
||||
if (!modelInputProjection.complete) {
|
||||
throw new Error('Router model input could not be safely projected')
|
||||
}
|
||||
const targetBlocks = this.getTargetBlocks(ctx, block)
|
||||
|
||||
const routerConfig = {
|
||||
prompt: inputs.prompt,
|
||||
prompt: modelInputProjection.value.prompt,
|
||||
model: inputs.model || ROUTER.DEFAULT_MODEL,
|
||||
apiKey: inputs.apiKey,
|
||||
vertexProject: inputs.vertexProject,
|
||||
@@ -131,14 +142,16 @@ export class RouterBlockHandler implements BlockHandler {
|
||||
}
|
||||
|
||||
const headers = new Headers(await buildAuthHeaders(ctx.userId))
|
||||
const modelInputMetadata = createModelInputProvenanceRequestMetadata(
|
||||
modelInputProjection.registry,
|
||||
promptModelInputPaths
|
||||
)
|
||||
const requestBody = addModelInputProvenanceToRequest(
|
||||
{ provider: providerId, ...providerRequest },
|
||||
headers,
|
||||
createModelInputProvenanceRequestMetadata(
|
||||
ctx.resolvedSecretTraceRegistry,
|
||||
collectProviderModelInputProvenanceValues(providerRequest, providerId)
|
||||
)
|
||||
modelInputMetadata
|
||||
)
|
||||
if (modelInputMetadata) markModelInputProjected(headers)
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'POST',
|
||||
headers,
|
||||
@@ -226,8 +239,31 @@ export class RouterBlockHandler implements BlockHandler {
|
||||
throw new Error('No routes defined for router')
|
||||
}
|
||||
|
||||
const modelInputPaths: ResolvedSecretInputPath[] = [
|
||||
['context'],
|
||||
...(Array.isArray(inputs.routes)
|
||||
? inputs.routes.map((_, index) => ['routes', String(index), 'value'] as const)
|
||||
: [['routes'] as const]),
|
||||
]
|
||||
const modelInputProjection = projectResolvedModelInput(
|
||||
ctx.resolvedSecretTraceRegistry,
|
||||
{ context: inputs.context, routes: inputs.routes },
|
||||
modelInputPaths
|
||||
)
|
||||
if (!modelInputProjection.complete) {
|
||||
throw new Error('Router model input could not be safely projected')
|
||||
}
|
||||
const projectedRoutes = this.parseRoutes(modelInputProjection.value.routes)
|
||||
if (projectedRoutes.length !== routes.length) {
|
||||
throw new Error('Router model input could not be safely projected')
|
||||
}
|
||||
const modelRoutes = routes.map((route, index) => ({
|
||||
...route,
|
||||
value: projectedRoutes[index]?.value ?? route.value,
|
||||
}))
|
||||
|
||||
const routerConfig = {
|
||||
context: inputs.context,
|
||||
context: modelInputProjection.value.context,
|
||||
model: inputs.model || ROUTER.DEFAULT_MODEL,
|
||||
apiKey: inputs.apiKey,
|
||||
vertexProject: inputs.vertexProject,
|
||||
@@ -243,7 +279,7 @@ export class RouterBlockHandler implements BlockHandler {
|
||||
if (ctx.userId) url.searchParams.set('userId', ctx.userId)
|
||||
|
||||
const messages = [{ role: 'user', content: routerConfig.context }]
|
||||
const systemPrompt = generateRouterV2Prompt(routerConfig.context, routes)
|
||||
const systemPrompt = generateRouterV2Prompt(routerConfig.context, modelRoutes)
|
||||
const resolved = await this.resolveModel(
|
||||
ctx,
|
||||
block.id,
|
||||
@@ -303,14 +339,16 @@ export class RouterBlockHandler implements BlockHandler {
|
||||
}
|
||||
|
||||
const headers = new Headers(await buildAuthHeaders(ctx.userId))
|
||||
const modelInputMetadata = createModelInputProvenanceRequestMetadata(
|
||||
modelInputProjection.registry,
|
||||
modelInputPaths
|
||||
)
|
||||
const requestBody = addModelInputProvenanceToRequest(
|
||||
{ provider: providerId, ...providerRequest },
|
||||
headers,
|
||||
createModelInputProvenanceRequestMetadata(
|
||||
ctx.resolvedSecretTraceRegistry,
|
||||
collectProviderModelInputProvenanceValues(providerRequest, providerId)
|
||||
)
|
||||
modelInputMetadata
|
||||
)
|
||||
if (modelInputMetadata) markModelInputProjected(headers)
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'POST',
|
||||
headers,
|
||||
@@ -495,35 +533,45 @@ export class RouterBlockHandler implements BlockHandler {
|
||||
}
|
||||
|
||||
private getTargetBlocks(ctx: ExecutionContext, block: SerializedBlock) {
|
||||
return ctx.workflow?.connections
|
||||
.filter((conn) => conn.source === block.id)
|
||||
.map((conn) => {
|
||||
const targetBlock = ctx.workflow?.blocks.find((b) => b.id === conn.target)
|
||||
if (!targetBlock) {
|
||||
throw new Error(`Target block ${conn.target} not found`)
|
||||
}
|
||||
const targetBlocks = []
|
||||
const connections = ctx.workflow?.connections.filter((conn) => conn.source === block.id) ?? []
|
||||
|
||||
let systemPrompt = ''
|
||||
if (isAgentBlockType(targetBlock.metadata?.id)) {
|
||||
const paramsPrompt = targetBlock.config?.params?.systemPrompt
|
||||
const inputsPrompt = targetBlock.inputs?.systemPrompt
|
||||
systemPrompt =
|
||||
(typeof paramsPrompt === 'string' ? paramsPrompt : '') ||
|
||||
(typeof inputsPrompt === 'string' ? inputsPrompt : '') ||
|
||||
''
|
||||
}
|
||||
for (const conn of connections) {
|
||||
const targetBlock = ctx.workflow?.blocks.find((candidate) => candidate.id === conn.target)
|
||||
if (!targetBlock) {
|
||||
throw new Error(`Target block ${conn.target} not found`)
|
||||
}
|
||||
|
||||
return {
|
||||
id: targetBlock.id,
|
||||
type: targetBlock.metadata?.id,
|
||||
title: targetBlock.metadata?.name,
|
||||
description: targetBlock.metadata?.description,
|
||||
subBlocks: {
|
||||
...targetBlock.config.params,
|
||||
systemPrompt: systemPrompt,
|
||||
},
|
||||
currentState: ctx.blockStates.get(targetBlock.id)?.output,
|
||||
}
|
||||
let systemPrompt = ''
|
||||
if (isAgentBlockType(targetBlock.metadata?.id)) {
|
||||
const paramsPrompt = targetBlock.config?.params?.systemPrompt
|
||||
const inputsPrompt = targetBlock.inputs?.systemPrompt
|
||||
systemPrompt =
|
||||
(typeof paramsPrompt === 'string' ? paramsPrompt : '') ||
|
||||
(typeof inputsPrompt === 'string' ? inputsPrompt : '') ||
|
||||
''
|
||||
}
|
||||
|
||||
const targetState = ctx.blockStates.get(targetBlock.id)
|
||||
const stateProvenance = targetState?.resolvedSecretTraceProvenance
|
||||
const currentState =
|
||||
stateProvenance && (!stateProvenance.complete || stateProvenance.entries.length > 0)
|
||||
? undefined
|
||||
: targetState?.output
|
||||
|
||||
targetBlocks.push({
|
||||
id: targetBlock.id,
|
||||
type: targetBlock.metadata?.id,
|
||||
title: targetBlock.metadata?.name,
|
||||
description: targetBlock.metadata?.description,
|
||||
subBlocks: {
|
||||
...targetBlock.config.params,
|
||||
systemPrompt,
|
||||
},
|
||||
currentState,
|
||||
})
|
||||
}
|
||||
|
||||
return targetBlocks
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ describe('runCustomBlockTool', () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'API_KEY', plaintext: secret, encryptedValue: 'ciphertext' },
|
||||
])
|
||||
registry.recordResolved('API_KEY', secret)
|
||||
mockExecute.mockRejectedValue(new Error(message))
|
||||
|
||||
const projected = await runCustomBlockTool(
|
||||
|
||||
@@ -1247,6 +1247,7 @@ describe('WorkflowBlockHandler', () => {
|
||||
mockExecutorExecute.mockImplementationOnce(async () => {
|
||||
childRegistry = executorOptions.at(-1)?.contextExtensions
|
||||
.resolvedSecretTraceRegistry as ResolvedSecretTraceRegistry
|
||||
expect(childRegistry.recordResolved('SECRET', 'publisher-secret')).toBe(true)
|
||||
return {
|
||||
success: true,
|
||||
output: {},
|
||||
@@ -1273,7 +1274,9 @@ describe('WorkflowBlockHandler', () => {
|
||||
replacement: ANONYMOUS_SECRET_TRACE_REPLACEMENT,
|
||||
},
|
||||
])
|
||||
expect(childRegistry?.getActiveMatches()).toEqual([])
|
||||
expect(childRegistry?.getActiveMatches()).toEqual([
|
||||
{ plaintext: 'publisher-secret', replacement: '{{SECRET}}' },
|
||||
])
|
||||
expect(mockSetResolvedSecretTraceRegistry).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
|
||||
@@ -705,7 +705,7 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
const exposedOutput = this.projectCustomBlockOutput(executionResult, exposedOutputs)
|
||||
if (ctx.resolvedSecretTraceRegistry && childResolvedSecretTraceRegistry) {
|
||||
const crossingProvenance =
|
||||
childResolvedSecretTraceRegistry.exportCatalogProvenanceForValue(exposedOutput, {
|
||||
childResolvedSecretTraceRegistry.exportCommittedProvenanceForValue(exposedOutput, {
|
||||
anonymous: true,
|
||||
})
|
||||
await ctx.resolvedSecretTraceRegistry.importProvenance(crossingProvenance, {
|
||||
|
||||
@@ -143,7 +143,7 @@ export class LoopOrchestrator {
|
||||
}
|
||||
let items: any[]
|
||||
const parentRegistry = ctx.resolvedSecretTraceRegistry
|
||||
const resolutionRegistry = parentRegistry?.forkForToolInputValues([])
|
||||
const resolutionRegistry = parentRegistry?.forkForInputPaths([])
|
||||
const resolutionCtx = resolutionRegistry
|
||||
? { ...ctx, resolvedSecretTraceRegistry: resolutionRegistry }
|
||||
: ctx
|
||||
|
||||
@@ -71,7 +71,7 @@ export class ParallelOrchestrator {
|
||||
let branchCount: number
|
||||
let isEmpty = false
|
||||
const parentRegistry = ctx.resolvedSecretTraceRegistry
|
||||
const resolutionRegistry = parentRegistry?.forkForToolInputValues([])
|
||||
const resolutionRegistry = parentRegistry?.forkForInputPaths([])
|
||||
const resolutionCtx = resolutionRegistry
|
||||
? { ...ctx, resolvedSecretTraceRegistry: resolutionRegistry }
|
||||
: ctx
|
||||
|
||||
@@ -332,7 +332,7 @@ export interface BlockState {
|
||||
output: NormalizedBlockOutput
|
||||
executed: boolean
|
||||
executionTime: number
|
||||
/** Encrypted provenance filtered to this exact output. Absent means legacy/untracked state. */
|
||||
/** Encrypted candidates active in this block call. Consumers filter them to the selected value. */
|
||||
resolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceV1
|
||||
}
|
||||
|
||||
|
||||
@@ -133,6 +133,30 @@ describe('projectResolvedSecretModelContent', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not apply provenance traversal limits when no secret was active', () => {
|
||||
const registry = new ResolvedSecretTraceRegistry()
|
||||
const value = new Array<null>(100_001).fill(null)
|
||||
|
||||
const projection = projectResolvedSecretModelContent(value, registry)
|
||||
expect(projection.safe).toBe(true)
|
||||
if (projection.safe) expect(projection.value).toBe(value)
|
||||
expect(isResolvedSecretModelContentUnchanged(value, registry)).toBe(true)
|
||||
|
||||
const jsonProjection = projectResolvedSecretModelJsonContent(value, registry)
|
||||
expect(jsonProjection.safe).toBe(true)
|
||||
if (jsonProjection.safe) {
|
||||
expect(jsonProjection.value).toHaveLength(value.length)
|
||||
expect((jsonProjection.value as null[]).at(-1)).toBeNull()
|
||||
expect(jsonProjection.value).not.toBe(value)
|
||||
}
|
||||
|
||||
const jsonString = '{\n "preserve": true\n}'
|
||||
expect(projectResolvedSecretModelJsonStrings([jsonString, undefined], registry)).toEqual({
|
||||
safe: true,
|
||||
value: [jsonString, undefined],
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps longest-match semantics when a known opaque placeholder is nested in a secret', () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'Test', plaintext: 'Test', encryptedValue: 'test-ciphertext' },
|
||||
@@ -397,33 +421,32 @@ describe('projectResolvedSecretDiagnosticError', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses a known compiler alias as diagnostic-only provenance', () => {
|
||||
const secret = 'diagnostic-secret-value'
|
||||
it('sanitizes an inactive compiler alias without activating or scanning its secret', () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'API_KEY', plaintext: secret, encryptedValue: 'ciphertext' },
|
||||
{ name: 'X', plaintext: 'x', encryptedValue: 'ciphertext' },
|
||||
])
|
||||
const error = new Error(`request failed: ${secret} __var_API_KEY`)
|
||||
const error = new Error('Box __var_X')
|
||||
|
||||
expect(projectResolvedSecretDiagnosticError(error, registry)).toEqual(
|
||||
expect.objectContaining({ error: 'request failed: {{API_KEY}} {{API_KEY}}' })
|
||||
expect.objectContaining({ error: 'Box [REDACTED_SECRET]' })
|
||||
)
|
||||
expect(registry.getActiveMatches()).toEqual([])
|
||||
})
|
||||
|
||||
it('falls back to text-free diagnostics for unknown or runtime-only aliases', () => {
|
||||
it('lexically sanitizes unknown and runtime-only aliases without catalog inference', () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'API_KEY', plaintext: 'secret-value', encryptedValue: 'ciphertext' },
|
||||
])
|
||||
|
||||
expect(
|
||||
projectResolvedSecretDiagnosticError(new Error('secret-value __var_UNKNOWN'), registry)
|
||||
).toEqual({ errorType: 'error', hasStack: true })
|
||||
).toEqual(expect.objectContaining({ error: 'secret-value [REDACTED_SECRET]' }))
|
||||
expect(
|
||||
projectResolvedSecretDiagnosticError(
|
||||
new Error('secret-value __sim_code_1_binding_0'),
|
||||
registry
|
||||
)
|
||||
).toEqual({ errorType: 'error', hasStack: true })
|
||||
).toEqual(expect.objectContaining({ error: 'secret-value [RUNTIME_BINDING]' }))
|
||||
})
|
||||
|
||||
it('falls back to text-free structure when provenance is missing or incomplete', () => {
|
||||
|
||||
@@ -89,11 +89,6 @@ interface ProjectionState {
|
||||
maxBytes: number
|
||||
}
|
||||
|
||||
interface InternalDiagnosticIdentifierScan {
|
||||
aliases: Set<string>
|
||||
foundInternalIdentifier: boolean
|
||||
}
|
||||
|
||||
export interface ResolvedSecretContentProjectionOptions {
|
||||
/** Values already materialized and verified by a boundary-specific projector. */
|
||||
isOpaqueSafeObject?: (value: object) => boolean
|
||||
@@ -194,65 +189,6 @@ function* arrayDataEntries(value: readonly unknown[]): Generator<[number, unknow
|
||||
}
|
||||
}
|
||||
|
||||
function collectInternalDiagnosticIdentifiers(
|
||||
value: unknown
|
||||
): InternalDiagnosticIdentifierScan | undefined {
|
||||
const result: InternalDiagnosticIdentifierScan = {
|
||||
aliases: new Set<string>(),
|
||||
foundInternalIdentifier: false,
|
||||
}
|
||||
const ancestors = new WeakSet<object>()
|
||||
let nodes = 0
|
||||
|
||||
const scanString = (candidate: string): void => {
|
||||
for (const identifier of candidate.match(INTERNAL_DIAGNOSTIC_IDENTIFIER_PATTERN) ?? []) {
|
||||
result.foundInternalIdentifier = true
|
||||
if (identifier.startsWith('__var_')) result.aliases.add(identifier)
|
||||
}
|
||||
}
|
||||
|
||||
const visit = (candidate: unknown, depth: number): boolean => {
|
||||
nodes += 1
|
||||
if (nodes > MAX_CONTENT_NODES || depth > MAX_CONTENT_DEPTH) return false
|
||||
if (typeof candidate === 'string') {
|
||||
scanString(candidate)
|
||||
return true
|
||||
}
|
||||
if (
|
||||
candidate === null ||
|
||||
candidate === undefined ||
|
||||
typeof candidate === 'number' ||
|
||||
typeof candidate === 'boolean'
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (typeof candidate !== 'object') return false
|
||||
if (!Array.isArray(candidate) && !isPlainRecord(candidate)) return false
|
||||
if (ancestors.has(candidate)) return false
|
||||
|
||||
ancestors.add(candidate)
|
||||
try {
|
||||
if (Array.isArray(candidate)) {
|
||||
for (const [, item] of arrayDataEntries(candidate)) {
|
||||
if (!visit(item, depth + 1)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
for (const [key, item] of enumerableDataEntries(candidate)) {
|
||||
scanString(key)
|
||||
if (!visit(item, depth + 1)) return false
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
ancestors.delete(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
return visit(value, 0) ? result : undefined
|
||||
}
|
||||
|
||||
function sanitizeContent(
|
||||
value: unknown,
|
||||
matcher: ResolvedSecretMatcher | undefined,
|
||||
@@ -451,29 +387,6 @@ export function getResolvedSecretModelMatcher(
|
||||
}
|
||||
}
|
||||
|
||||
/** Produces a nonempty model control message only when the registry can prove it secret-free. */
|
||||
export function projectResolvedSecretModelControlMessage(
|
||||
message: string,
|
||||
registry: ResolvedSecretTraceRegistry | undefined
|
||||
): string | undefined {
|
||||
const projection = projectResolvedSecretModelContent(message, registry)
|
||||
if (projection.safe && typeof projection.value === 'string' && projection.value.length > 0) {
|
||||
return projection.value
|
||||
}
|
||||
|
||||
const snapshot = getResolvedSecretModelMatcher(registry)
|
||||
if (!snapshot.complete) return undefined
|
||||
for (let codePoint = 0x21; codePoint <= 0x10ffff; codePoint += 1) {
|
||||
if (codePoint >= 0xd800 && codePoint <= 0xdfff) {
|
||||
codePoint = 0xdfff
|
||||
continue
|
||||
}
|
||||
const candidate = String.fromCodePoint(codePoint)
|
||||
if (!snapshot.matcher || !containsResolvedSecret(candidate, snapshot.matcher)) return candidate
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Projects content that is about to become model-visible using committed active provenance.
|
||||
* Trusted runtime boundaries activate exact secret-bearing values before this point; unrelated
|
||||
@@ -488,6 +401,9 @@ export function projectResolvedSecretModelContent(
|
||||
): ResolvedSecretContentProjection {
|
||||
const snapshot = getResolvedSecretModelMatcher(registry)
|
||||
if (!snapshot.complete) return { safe: false }
|
||||
if (!snapshot.matcher && options.sanitizeInternalIdentifiers !== true) {
|
||||
return { safe: true, value }
|
||||
}
|
||||
|
||||
return projectContent(value, snapshot.matcher, maxBytes, {
|
||||
projectPrimitiveLiterals: true,
|
||||
@@ -506,7 +422,8 @@ export function projectResolvedSecretModelJsonContent(
|
||||
maxBytes = MAX_INLINE_MATERIALIZATION_BYTES,
|
||||
options: ResolvedSecretContentProjectionOptions = {}
|
||||
): ResolvedSecretContentProjection {
|
||||
if (!getResolvedSecretModelMatcher(registry).complete) return { safe: false }
|
||||
const snapshot = getResolvedSecretModelMatcher(registry)
|
||||
if (!snapshot.complete) return { safe: false }
|
||||
|
||||
try {
|
||||
const encoded = JSON.stringify(value)
|
||||
@@ -514,6 +431,9 @@ export function projectResolvedSecretModelJsonContent(
|
||||
return { safe: false }
|
||||
}
|
||||
const normalized: unknown = JSON.parse(encoded)
|
||||
if (!snapshot.matcher && options.sanitizeInternalIdentifiers !== true) {
|
||||
return { safe: true, value: normalized }
|
||||
}
|
||||
const projection = projectResolvedSecretModelContent(normalized, registry, maxBytes, options)
|
||||
if (!projection.safe) return projection
|
||||
|
||||
@@ -537,17 +457,7 @@ export function projectResolvedSecretDiagnosticContent(
|
||||
registry: ResolvedSecretTraceRegistry | undefined,
|
||||
maxBytes = MAX_INLINE_MATERIALIZATION_BYTES
|
||||
): ResolvedSecretContentProjection {
|
||||
const identifiers = collectInternalDiagnosticIdentifiers(value)
|
||||
if (!identifiers) return { safe: false }
|
||||
|
||||
let diagnosticRegistry = registry
|
||||
if (identifiers.foundInternalIdentifier) {
|
||||
if (!registry || identifiers.aliases.size === 0) return { safe: false }
|
||||
diagnosticRegistry = registry.forkForDiagnosticAliases(identifiers.aliases)
|
||||
if (!diagnosticRegistry) return { safe: false }
|
||||
}
|
||||
|
||||
return projectResolvedSecretModelContent(value, diagnosticRegistry, maxBytes, {
|
||||
return projectResolvedSecretModelContent(value, registry, maxBytes, {
|
||||
sanitizeInternalIdentifiers: true,
|
||||
})
|
||||
}
|
||||
@@ -597,6 +507,7 @@ export function isResolvedSecretModelContentUnchanged(
|
||||
): boolean {
|
||||
const snapshot = getResolvedSecretModelMatcher(registry)
|
||||
if (!snapshot.complete) return false
|
||||
if (!snapshot.matcher) return true
|
||||
|
||||
const projection = projectContent(value, snapshot.matcher, MAX_INLINE_MATERIALIZATION_BYTES, {
|
||||
projectPrimitiveLiterals: true,
|
||||
@@ -616,6 +527,15 @@ export function projectResolvedSecretModelJsonStrings(
|
||||
): ResolvedSecretContentProjection {
|
||||
const snapshot = getResolvedSecretModelMatcher(registry)
|
||||
if (!snapshot.complete) return { safe: false }
|
||||
if (!snapshot.matcher) {
|
||||
let outputBytes = 0
|
||||
for (const value of values) {
|
||||
if (value === undefined) continue
|
||||
outputBytes += Buffer.byteLength(value, 'utf8')
|
||||
if (outputBytes > maxBytes) return { safe: false }
|
||||
}
|
||||
return { safe: true, value: [...values] }
|
||||
}
|
||||
|
||||
const projected: Array<string | undefined> = []
|
||||
let outputBytes = 0
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
function makeCanonicalPlaceholdersJsonParseable(value: string): string {
|
||||
let result = ''
|
||||
let inString = false
|
||||
let escaped = false
|
||||
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const character = value[index]
|
||||
if (inString) {
|
||||
result += character
|
||||
if (escaped) {
|
||||
escaped = false
|
||||
} else if (character === '\\') {
|
||||
escaped = true
|
||||
} else if (character === '"') {
|
||||
inString = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (character === '"') {
|
||||
inString = true
|
||||
result += character
|
||||
continue
|
||||
}
|
||||
|
||||
if (character === '{' && value[index + 1] === '{') {
|
||||
const end = value.indexOf('}}', index + 2)
|
||||
if (end !== -1) {
|
||||
const placeholder = value.slice(index, end + 2)
|
||||
const name = value.slice(index + 2, end).trim()
|
||||
if (/^[A-Za-z0-9_]+$/.test(name)) {
|
||||
result += JSON.stringify(placeholder)
|
||||
index = end + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result += character
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function canonicalPlaceholder(value: string): string | undefined {
|
||||
const match = /^\{\{([^{}]+)\}\}$/.exec(value.trim())
|
||||
if (!match || !/^[A-Za-z0-9_]+$/.test(match[1].trim())) return undefined
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
function projectStructuredLeaves(value: unknown, placeholder: string): unknown {
|
||||
if (value === null || typeof value !== 'object') return placeholder
|
||||
|
||||
const projectedRoot: unknown[] | Record<string, unknown> = Array.isArray(value) ? [] : {}
|
||||
const pending: Array<{
|
||||
source: unknown[] | Record<string, unknown>
|
||||
target: unknown[] | Record<string, unknown>
|
||||
}> = [{ source: value as unknown[] | Record<string, unknown>, target: projectedRoot }]
|
||||
|
||||
while (pending.length > 0) {
|
||||
const { source, target } = pending.pop()!
|
||||
for (const [key, child] of Object.entries(source)) {
|
||||
if (child !== null && typeof child === 'object') {
|
||||
const projectedChild: unknown[] | Record<string, unknown> = Array.isArray(child) ? [] : {}
|
||||
;(target as Record<string, unknown>)[key] = projectedChild
|
||||
pending.push({
|
||||
source: child as unknown[] | Record<string, unknown>,
|
||||
target: projectedChild,
|
||||
})
|
||||
} else {
|
||||
;(target as Record<string, unknown>)[key] = placeholder
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return projectedRoot
|
||||
}
|
||||
|
||||
function projectFileReferenceString(
|
||||
key: string,
|
||||
value: string,
|
||||
placeholder: string
|
||||
): string | undefined {
|
||||
if (key !== 'key' && key !== 'path' && key !== 'url' && key !== 'base64') return undefined
|
||||
if ((key === 'url' || key === 'path') && value.startsWith('https://')) {
|
||||
return `https://${placeholder}`
|
||||
}
|
||||
if ((key === 'url' || key === 'path') && value.startsWith('http://')) {
|
||||
return `http://${placeholder}`
|
||||
}
|
||||
if (key === 'path' && value.startsWith('/')) return `/${placeholder}`
|
||||
return placeholder
|
||||
}
|
||||
|
||||
function isFileDescriptor(value: unknown): boolean {
|
||||
if (Array.isArray(value)) return value.length > 0 && value.every(isFileDescriptor)
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
['key', 'path', 'url'].some(
|
||||
(key) => typeof (value as Record<string, unknown>)[key] === 'string'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function projectFileReferenceLeaves(
|
||||
value: unknown,
|
||||
placeholder: string
|
||||
): { value: unknown; projectedReferences: number } | undefined {
|
||||
if (!isFileDescriptor(value)) return undefined
|
||||
|
||||
const projectedRoot: unknown[] | Record<string, unknown> = Array.isArray(value) ? [] : {}
|
||||
const pending: Array<{
|
||||
source: unknown[] | Record<string, unknown>
|
||||
target: unknown[] | Record<string, unknown>
|
||||
}> = [{ source: value as unknown[] | Record<string, unknown>, target: projectedRoot }]
|
||||
let projectedReferences = 0
|
||||
while (pending.length > 0) {
|
||||
const { source, target } = pending.pop()!
|
||||
for (const [key, child] of Object.entries(source)) {
|
||||
if (child !== null && typeof child === 'object') {
|
||||
const projectedChild: unknown[] | Record<string, unknown> = Array.isArray(child) ? [] : {}
|
||||
;(target as Record<string, unknown>)[key] = projectedChild
|
||||
pending.push({
|
||||
source: child as unknown[] | Record<string, unknown>,
|
||||
target: projectedChild,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const projectedReference =
|
||||
typeof child === 'string' ? projectFileReferenceString(key, child, placeholder) : undefined
|
||||
;(target as Record<string, unknown>)[key] = projectedReference ?? child
|
||||
if (projectedReference !== undefined) projectedReferences += 1
|
||||
}
|
||||
}
|
||||
return { value: projectedRoot, projectedReferences }
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes only schema-declared structured inputs parseable on a private placeholder projection.
|
||||
* Raw execution inputs are never passed to or changed by this helper.
|
||||
*/
|
||||
export function prepareResolvedSecretProjectedInputs(
|
||||
inputs: Record<string, unknown>,
|
||||
inputSchemas: Record<string, unknown> | undefined,
|
||||
rawInputs?: Record<string, unknown>,
|
||||
options: { preserveFileDescriptorGrammar?: boolean } = {}
|
||||
): Record<string, unknown> {
|
||||
if (!inputSchemas) return inputs
|
||||
const prepared = { ...inputs }
|
||||
for (const [key, inputSchema] of Object.entries(inputSchemas)) {
|
||||
const inputType =
|
||||
inputSchema && typeof inputSchema === 'object'
|
||||
? (inputSchema as { type?: unknown }).type
|
||||
: inputSchema
|
||||
if (inputType !== 'json' && inputType !== 'array') continue
|
||||
const value = prepared[key]
|
||||
if (typeof value === 'string') {
|
||||
const placeholder = canonicalPlaceholder(value)
|
||||
const rawValue = rawInputs?.[key]
|
||||
if (placeholder && typeof rawValue === 'string') {
|
||||
try {
|
||||
const parsedRawValue = JSON.parse(rawValue.trim())
|
||||
if (parsedRawValue !== null && typeof parsedRawValue === 'object') {
|
||||
const fileProjection = options.preserveFileDescriptorGrammar
|
||||
? projectFileReferenceLeaves(parsedRawValue, placeholder)
|
||||
: undefined
|
||||
prepared[key] = JSON.stringify(
|
||||
fileProjection && fileProjection.projectedReferences > 0
|
||||
? fileProjection.value
|
||||
: projectStructuredLeaves(parsedRawValue, placeholder)
|
||||
)
|
||||
continue
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
prepared[key] = makeCanonicalPlaceholdersJsonParseable(value)
|
||||
}
|
||||
}
|
||||
return prepared
|
||||
}
|
||||
@@ -8,11 +8,34 @@ import {
|
||||
OPAQUE_RESOLVED_SECRET_REPLACEMENT,
|
||||
sanitizeResolvedSecretPrimitive,
|
||||
sanitizeResolvedSecretString,
|
||||
scanResolvedSecretString,
|
||||
} from '@/executor/utils/resolved-secret-matcher'
|
||||
|
||||
const PRESERVE_NAMED_PROVENANCE = { preserveNamedProvenanceLabels: true } as const
|
||||
|
||||
describe('resolved secret matcher', () => {
|
||||
it('reports each matched literal once across large repeated content', () => {
|
||||
const matcher = createResolvedSecretMatcher([
|
||||
{ plaintext: 'x', replacement: '{{SHORT}}' },
|
||||
{ plaintext: 'xx', replacement: '{{OVERLAP}}' },
|
||||
{ plaintext: 'abc', replacement: '{{PREFIX}}' },
|
||||
{ plaintext: 'bc', replacement: '{{SUFFIX}}' },
|
||||
])
|
||||
const matches: string[] = []
|
||||
|
||||
expect(matcher).toBeDefined()
|
||||
if (!matcher) return
|
||||
expect(
|
||||
scanResolvedSecretString(
|
||||
`${'x'.repeat(1_000_001)}abcabc`,
|
||||
matcher,
|
||||
(match) => matches.push(match),
|
||||
4
|
||||
)
|
||||
).toBe(4)
|
||||
expect(matches).toEqual(['x', 'xx', 'abc', 'bc'])
|
||||
})
|
||||
|
||||
it('uses exact matching for typed primitive renderings', () => {
|
||||
const matcher = createResolvedSecretMatcher([{ plaintext: '23', replacement: '{{TOKEN}}' }])
|
||||
|
||||
|
||||
@@ -231,7 +231,7 @@ export function containsResolvedSecretLiteral(
|
||||
return false
|
||||
}
|
||||
|
||||
/** Visits exact secret literals with the same bounded automaton used by content projection. */
|
||||
/** Visits each distinct exact secret literal once with the content-projection automaton. */
|
||||
export function scanResolvedSecretString(
|
||||
value: string,
|
||||
matcher: ResolvedSecretMatcher,
|
||||
@@ -240,16 +240,40 @@ export function scanResolvedSecretString(
|
||||
): number {
|
||||
let node = matcher.root
|
||||
let matchEvents = 0
|
||||
const matchedPlaintexts = new Set<string>()
|
||||
const nextUnmatchedOutput = new WeakMap<SecretTrieNode, SecretTrieNode | null>()
|
||||
|
||||
const findNextUnmatchedOutput = (
|
||||
candidate: SecretTrieNode | undefined
|
||||
): SecretTrieNode | undefined => {
|
||||
let current = candidate
|
||||
const exhaustedPath: SecretTrieNode[] = []
|
||||
while (current?.replacement && matchedPlaintexts.has(current.replacement.plaintext)) {
|
||||
const cached = nextUnmatchedOutput.get(current)
|
||||
if (cached !== undefined) {
|
||||
current = cached ?? undefined
|
||||
continue
|
||||
}
|
||||
exhaustedPath.push(current)
|
||||
current = current.outputLink
|
||||
}
|
||||
for (const exhausted of exhaustedPath) {
|
||||
nextUnmatchedOutput.set(exhausted, current ?? null)
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
node = advanceMatcher(matcher, node, value[index])
|
||||
let outputNode: SecretTrieNode | undefined = node.replacement ? node : node.outputLink
|
||||
let outputNode = findNextUnmatchedOutput(node.replacement ? node : node.outputLink)
|
||||
while (outputNode?.replacement) {
|
||||
matchEvents += 1
|
||||
if (matchEvents > maxMatchEvents) {
|
||||
throw new ResolvedSecretMatcherError('Secret matcher event limit exceeded')
|
||||
}
|
||||
matchedPlaintexts.add(outputNode.replacement.plaintext)
|
||||
onMatch(outputNode.replacement.plaintext)
|
||||
outputNode = outputNode.outputLink
|
||||
outputNode = findNextUnmatchedOutput(outputNode)
|
||||
}
|
||||
}
|
||||
return matchEvents
|
||||
|
||||
@@ -178,6 +178,234 @@ describe('ResolvedSecretTraceRegistry', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('projects only resolver-recorded leaves and never rewrites sibling bytes or object keys', () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'TOKEN', plaintext: 'x', encryptedValue: 'encrypted-token' },
|
||||
])
|
||||
registry.recordResolvedAtInputPath('TOKEN', 'x', ['prompt'])
|
||||
registry.recordResolvedInputProjection(['prompt'], 'Box x', 'Box {{TOKEN}}')
|
||||
const unrelatedNonCloneableInput = () => 'unchanged'
|
||||
|
||||
expect(
|
||||
registry.projectResolvedInputSelection({
|
||||
prompt: 'Box x',
|
||||
auxiliary: 'xylophone',
|
||||
Box: 'unchanged',
|
||||
unrelatedNonCloneableInput,
|
||||
})
|
||||
).toEqual({
|
||||
complete: true,
|
||||
value: {
|
||||
prompt: 'Box {{TOKEN}}',
|
||||
auxiliary: 'xylophone',
|
||||
Box: 'unchanged',
|
||||
unrelatedNonCloneableInput,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps equal secret values causally bound to their own resolver paths', () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'FIRST', plaintext: 'true', encryptedValue: 'encrypted-first' },
|
||||
{ name: 'SECOND', plaintext: 'true', encryptedValue: 'encrypted-second' },
|
||||
])
|
||||
registry.recordResolvedAtInputPath('FIRST', 'true', ['first'])
|
||||
registry.recordResolvedInputProjection(['first'], 'true', '{{FIRST}}')
|
||||
registry.recordResolvedAtInputPath('SECOND', 'true', ['second'])
|
||||
registry.recordResolvedInputProjection(['second'], 'true', '{{SECOND}}')
|
||||
|
||||
expect(registry.projectResolvedInputSelection({ first: 'true', second: 'true' })).toEqual({
|
||||
complete: true,
|
||||
value: { first: '{{FIRST}}', second: '{{SECOND}}' },
|
||||
})
|
||||
expect(registry.exportCommittedProvenanceForInputPaths([['first']])).toMatchObject({
|
||||
complete: true,
|
||||
entries: [{ name: 'FIRST', encryptedValue: 'encrypted-first' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('isolates incomplete provenance to its known input path', async () => {
|
||||
const registry = new ResolvedSecretTraceRegistry()
|
||||
|
||||
expect(
|
||||
await registry.importProvenanceForValueAtInputPath(
|
||||
{ version: 1 },
|
||||
'unknown-value',
|
||||
['tools', '0', 'params', 'apiKey'],
|
||||
{ trusted: true }
|
||||
)
|
||||
).toEqual({ success: false, matched: false })
|
||||
|
||||
expect(registry.isComplete()).toBe(false)
|
||||
expect(registry.projectResolvedInputSelection({ userPrompt: 'Public prompt' })).toEqual({
|
||||
complete: true,
|
||||
value: { userPrompt: 'Public prompt' },
|
||||
})
|
||||
expect(registry.exportCommittedProvenanceForInputPaths([['userPrompt']])).toEqual({
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [],
|
||||
})
|
||||
expect(registry.forkForInputPaths([['userPrompt']]).isComplete()).toBe(true)
|
||||
})
|
||||
|
||||
it('propagates authenticated incomplete provenance without classifying it as malformed', async () => {
|
||||
const scope = { userId: 'user-1', workspaceId: 'workspace-1' }
|
||||
const registry = new ResolvedSecretTraceRegistry([], scope)
|
||||
|
||||
expect(
|
||||
await registry.importProvenanceForValueAtInputPath(
|
||||
{ version: 1, complete: false, entries: [], scope },
|
||||
'untrusted value',
|
||||
['toolResult'],
|
||||
{ trusted: true }
|
||||
)
|
||||
).toEqual({ success: true, matched: false })
|
||||
expect(registry.forkForInputPaths([['publicPrompt']]).isComplete()).toBe(true)
|
||||
expect(registry.forkForInputPaths([['toolResult']]).isComplete()).toBe(false)
|
||||
})
|
||||
|
||||
it('localizes a failed exact resolution when the resolver supplies its input path', () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'TOKEN', plaintext: 'expected', encryptedValue: 'encrypted-token' },
|
||||
])
|
||||
|
||||
expect(
|
||||
registry.recordResolvedAtInputPath('TOKEN', 'unexpected', ['tools', '0', 'params', 'apiKey'])
|
||||
).toBe(false)
|
||||
|
||||
expect(registry.projectResolvedInputSelection({ userPrompt: 'Public prompt' })).toEqual({
|
||||
complete: true,
|
||||
value: { userPrompt: 'Public prompt' },
|
||||
})
|
||||
expect(registry.forkForInputPaths([['userPrompt']]).isComplete()).toBe(true)
|
||||
expect(registry.forkForInputPaths([['tools', '0', 'params']]).isComplete()).toBe(false)
|
||||
expect(registry.getModelEgressSnapshot()).toEqual({ complete: false })
|
||||
})
|
||||
|
||||
it('fails closed for a selected unknown path and arbitrary output projection', async () => {
|
||||
const scope = { userId: 'user-1', workspaceId: 'workspace-1' }
|
||||
const registry = new ResolvedSecretTraceRegistry([], scope)
|
||||
await registry.importProvenanceForValueAtInputPath(
|
||||
{ version: 1 },
|
||||
'unknown-value',
|
||||
['tools', '0', 'params', 'apiKey'],
|
||||
{ trusted: true }
|
||||
)
|
||||
|
||||
expect(
|
||||
registry.projectResolvedInputSelection({ tools: [{ params: { apiKey: 'value' } }] })
|
||||
).toEqual({ complete: false })
|
||||
expect(registry.exportCommittedProvenanceForInputPaths([['tools', '0', 'params']])).toEqual({
|
||||
version: 1,
|
||||
complete: false,
|
||||
entries: [],
|
||||
scope,
|
||||
})
|
||||
expect(registry.forkForInputPaths([['tools', '0', 'params']]).isComplete()).toBe(false)
|
||||
expect(registry.getModelEgressSnapshot()).toEqual({ complete: false })
|
||||
expect(registry.exportProvenanceForValue('arbitrary output')).toEqual({
|
||||
version: 1,
|
||||
complete: false,
|
||||
entries: [],
|
||||
scope,
|
||||
})
|
||||
})
|
||||
|
||||
it('propagates only selected input-path entries when explicitly requested', () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'SELECTED', plaintext: 'selected', encryptedValue: 'encrypted-selected' },
|
||||
{ name: 'UNSELECTED', plaintext: 'unselected', encryptedValue: 'encrypted-unselected' },
|
||||
])
|
||||
registry.recordResolvedAtInputPath('SELECTED', 'selected', ['selected'])
|
||||
registry.recordResolvedAtInputPath('UNSELECTED', 'unselected', ['unselected'])
|
||||
|
||||
const ordinaryFork = registry.forkForInputPaths([['selected']])
|
||||
expect(ordinaryFork.forkForPropagatedEntries().exportProvenance().entries).toEqual([])
|
||||
|
||||
const propagatedFork = registry.forkForInputPaths([['selected']], { propagated: true })
|
||||
expect(propagatedFork.forkForPropagatedEntries().exportProvenance().entries).toEqual([
|
||||
{ name: 'SELECTED', encryptedValue: 'encrypted-selected' },
|
||||
])
|
||||
expect(propagatedFork.exportProvenance().entries).not.toContainEqual({
|
||||
name: 'UNSELECTED',
|
||||
encryptedValue: 'encrypted-unselected',
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves exact paths through renamed and parsed parameter transforms', () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'FIRST', plaintext: 'true', encryptedValue: 'encrypted-first' },
|
||||
{ name: 'SECOND', plaintext: 'true', encryptedValue: 'encrypted-second' },
|
||||
{ name: 'UNUSED', plaintext: 'true', encryptedValue: 'encrypted-unused' },
|
||||
])
|
||||
registry.recordResolvedAtInputPath('FIRST', 'true', ['rowTemplate'])
|
||||
registry.recordResolvedAtInputPath('SECOND', 'true', ['rowTemplate'])
|
||||
registry.recordResolvedInputProjection(
|
||||
['rowTemplate'],
|
||||
'{"first":true,"second":true,"public":true}',
|
||||
'{"first":{{FIRST}},"second":{{SECOND}},"public":true}'
|
||||
)
|
||||
|
||||
registry.recordTransformedInputProjection(
|
||||
{ data: { first: true, second: true, public: true } },
|
||||
{ data: { first: '{{FIRST}}', second: '{{SECOND}}', public: true } }
|
||||
)
|
||||
|
||||
expect(
|
||||
registry.projectResolvedInputSelection({
|
||||
data: { first: true, second: true, public: true },
|
||||
})
|
||||
).toEqual({
|
||||
complete: true,
|
||||
value: {
|
||||
data: { first: '{{FIRST}}', second: '{{SECOND}}', public: true },
|
||||
},
|
||||
})
|
||||
expect(registry.exportCommittedProvenanceForInputPaths([['data', 'first']])).toMatchObject({
|
||||
complete: true,
|
||||
entries: [{ name: 'FIRST', encryptedValue: 'encrypted-first' }],
|
||||
})
|
||||
expect(registry.exportCommittedProvenanceForInputPaths([['data', 'second']])).toMatchObject({
|
||||
complete: true,
|
||||
entries: [{ name: 'SECOND', encryptedValue: 'encrypted-second' }],
|
||||
})
|
||||
expect(registry.exportCommittedProvenanceForInputPaths([['data', 'public']])).toMatchObject({
|
||||
complete: true,
|
||||
entries: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed when independent secret paths collapse into one transformed string', () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'FIRST', plaintext: 'first', encryptedValue: 'encrypted-first' },
|
||||
{ name: 'SECOND', plaintext: 'second', encryptedValue: 'encrypted-second' },
|
||||
])
|
||||
registry.recordResolvedAtInputPath('FIRST', 'first', ['first'])
|
||||
registry.recordResolvedInputProjection(['first'], 'first', '{{FIRST}}')
|
||||
registry.recordResolvedAtInputPath('SECOND', 'second', ['second'])
|
||||
registry.recordResolvedInputProjection(['second'], 'second', '{{SECOND}}')
|
||||
|
||||
registry.recordTransformedInputProjection(
|
||||
{ combined: 'first:second' },
|
||||
{ combined: '{{FIRST}}:second' }
|
||||
)
|
||||
registry.recordTransformedInputProjection(
|
||||
{ combined: 'first:second' },
|
||||
{ combined: 'first:{{SECOND}}' }
|
||||
)
|
||||
|
||||
expect(registry.isComplete()).toBe(false)
|
||||
expect(registry.projectResolvedInputSelection({ unrelated: 'public' })).toEqual({
|
||||
complete: true,
|
||||
value: { unrelated: 'public' },
|
||||
})
|
||||
expect(registry.projectResolvedInputSelection({ combined: 'first:second' })).toEqual({
|
||||
complete: false,
|
||||
})
|
||||
expect(registry.getModelEgressSnapshot()).toEqual({ complete: false })
|
||||
})
|
||||
|
||||
it('does not invalidate the model matcher for duplicate activations', () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'API_KEY', plaintext: 'secret-value', encryptedValue: 'encrypted-value' },
|
||||
@@ -248,6 +476,42 @@ describe('ResolvedSecretTraceRegistry', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a named anonymous secret distinct from anonymous provenance', async () => {
|
||||
mockDecryptSecret.mockResolvedValueOnce({ decrypted: 'same-secret' })
|
||||
const registry = new ResolvedSecretTraceRegistry(
|
||||
[{ name: 'anonymous', plaintext: 'same-secret', encryptedValue: 'shared-ciphertext' }],
|
||||
{ userId: 'user-1', workspaceId: 'workspace-1' }
|
||||
)
|
||||
registry.recordResolved('anonymous', 'same-secret')
|
||||
await registry.importProvenance(
|
||||
{
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [{ encryptedValue: 'shared-ciphertext' }],
|
||||
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
|
||||
},
|
||||
{ trusted: true, anonymous: true }
|
||||
)
|
||||
|
||||
expect(registry.exportCommittedProvenanceForValue('same-secret')).toEqual({
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [
|
||||
{ encryptedValue: 'shared-ciphertext' },
|
||||
{ name: 'anonymous', encryptedValue: 'shared-ciphertext' },
|
||||
],
|
||||
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
|
||||
})
|
||||
const snapshot = registry.getModelEgressSnapshot()
|
||||
expect(snapshot.complete).toBe(true)
|
||||
if (snapshot.complete) {
|
||||
expect(snapshot.matches).toContainEqual({
|
||||
plaintext: 'same-secret',
|
||||
replacement: ANONYMOUS_SECRET_TRACE_REPLACEMENT,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('projects committed provenance while temporary activations are pending', () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'API_KEY', plaintext: 'secret-value', encryptedValue: 'encrypted-value' },
|
||||
@@ -292,45 +556,6 @@ describe('ResolvedSecretTraceRegistry', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('seeds a tool child only with active provenance present in that tool input', () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'INPUT', plaintext: 'input-secret', encryptedValue: 'input-ciphertext' },
|
||||
{ name: 'UNRELATED', plaintext: 'Test', encryptedValue: 'unrelated-ciphertext' },
|
||||
])
|
||||
registry.recordResolved('INPUT', 'input-secret')
|
||||
registry.recordResolved('UNRELATED', 'Test')
|
||||
|
||||
const child = registry.forkForToolInput({ authorization: 'Bearer input-secret' })
|
||||
|
||||
expect(child.getActiveMatches()).toEqual([
|
||||
{ plaintext: 'input-secret', replacement: '{{INPUT}}' },
|
||||
])
|
||||
expect(child.recordResolved('UNRELATED', 'Test')).toBe(true)
|
||||
})
|
||||
|
||||
it('forks independent roots without treating static param names or array indexes as data', () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'PROMPT', plaintext: 'prompt', encryptedValue: 'prompt-ciphertext' },
|
||||
{ name: 'ZERO', plaintext: '0', encryptedValue: 'zero-ciphertext' },
|
||||
{ name: 'VALUE', plaintext: 'input-secret', encryptedValue: 'value-ciphertext' },
|
||||
])
|
||||
registry.recordResolved('PROMPT', 'prompt')
|
||||
registry.recordResolved('ZERO', '0')
|
||||
registry.recordResolved('VALUE', 'input-secret')
|
||||
|
||||
const child = registry.forkForToolInputValues(['safe', { nested: 'input-secret' }])
|
||||
|
||||
expect(child.getActiveMatches()).toEqual([
|
||||
{ plaintext: 'input-secret', replacement: '{{VALUE}}' },
|
||||
])
|
||||
expect(registry.forkForToolInputValues([{ prompt: 'safe' }]).getActiveMatches()).toEqual([
|
||||
{ plaintext: 'prompt', replacement: '{{PROMPT}}' },
|
||||
])
|
||||
expect(registry.forkForToolInputValues([0]).getActiveMatches()).toEqual([
|
||||
{ plaintext: '0', replacement: '{{ZERO}}' },
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the workspace catalog entry when personal and workspace names conflict', async () => {
|
||||
const registry = await createResolvedSecretTraceRegistry({
|
||||
personalEncrypted: { SHARED: 'personal-encrypted' },
|
||||
@@ -701,6 +926,102 @@ describe('ResolvedSecretTraceRegistry', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('exports active provenance for a registered legacy runtime alias', () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'API-KEY', plaintext: 'secret-value', encryptedValue: 'present-ciphertext' },
|
||||
])
|
||||
registry.recordResolved('API-KEY', 'secret-value')
|
||||
|
||||
expect(registry.exportCommittedProvenanceForValue('prefix __var_API_KEY suffix')).toEqual({
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [{ name: 'API-KEY', encryptedValue: 'present-ciphertext' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores repeated unrelated runtime aliases without exhausting the scan budget', () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'API_KEY', plaintext: 'secret-value', encryptedValue: 'present-ciphertext' },
|
||||
])
|
||||
registry.recordResolved('API_KEY', 'secret-value')
|
||||
|
||||
expect(
|
||||
registry.exportCommittedProvenanceForValue(`${'__var_Z '.repeat(1_000_001)}__var_API_KEY`)
|
||||
).toEqual({
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [{ name: 'API_KEY', encryptedValue: 'present-ciphertext' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('matches legacy runtime aliases as complete tokens instead of prefixes', () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'A', plaintext: 'secret-a', encryptedValue: 'ciphertext-a' },
|
||||
{ name: 'API_KEY', plaintext: 'secret-api', encryptedValue: 'ciphertext-api' },
|
||||
])
|
||||
registry.recordResolved('A', 'secret-a')
|
||||
registry.recordResolved('API_KEY', 'secret-api')
|
||||
|
||||
expect(registry.exportCommittedProvenanceForValue('__var_API_KEY')).toEqual({
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [{ name: 'API_KEY', encryptedValue: 'ciphertext-api' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('conservatively retains every secret mapped to a colliding runtime alias', () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'API-KEY', plaintext: 'first-secret', encryptedValue: 'first-ciphertext' },
|
||||
{ name: 'API_KEY', plaintext: 'second-secret', encryptedValue: 'second-ciphertext' },
|
||||
])
|
||||
registry.recordResolved('API-KEY', 'first-secret')
|
||||
registry.recordResolved('API_KEY', 'second-secret')
|
||||
|
||||
expect(registry.exportCommittedProvenanceForValue('__var_API_KEY')).toEqual({
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [
|
||||
{ name: 'API-KEY', encryptedValue: 'first-ciphertext' },
|
||||
{ name: 'API_KEY', encryptedValue: 'second-ciphertext' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('retains an alias-specific entry when multiple names share one plaintext', () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'FIRST', plaintext: 'shared-secret', encryptedValue: 'first-ciphertext' },
|
||||
{ name: 'SECOND', plaintext: 'shared-secret', encryptedValue: 'second-ciphertext' },
|
||||
])
|
||||
registry.recordResolved('FIRST', 'shared-secret')
|
||||
registry.recordResolved('SECOND', 'shared-secret')
|
||||
|
||||
expect(registry.exportCommittedProvenanceForValue('__var_SECOND')).toEqual({
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [{ name: 'SECOND', encryptedValue: 'second-ciphertext' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('conservatively retains every active secret that shares a raw plaintext literal', () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'FIRST', plaintext: 'true', encryptedValue: 'first-ciphertext' },
|
||||
{ name: 'SECOND', plaintext: 'true', encryptedValue: 'second-ciphertext' },
|
||||
])
|
||||
registry.recordResolved('FIRST', 'true')
|
||||
registry.recordResolved('SECOND', 'true')
|
||||
|
||||
const expected = {
|
||||
version: 1 as const,
|
||||
complete: true,
|
||||
entries: [
|
||||
{ name: 'FIRST', encryptedValue: 'first-ciphertext' },
|
||||
{ name: 'SECOND', encryptedValue: 'second-ciphertext' },
|
||||
],
|
||||
}
|
||||
expect(registry.exportCommittedProvenanceForValue('true')).toEqual(expected)
|
||||
expect(registry.exportCommittedProvenanceForValue(true)).toEqual(expected)
|
||||
})
|
||||
|
||||
it('exports active numeric, boolean, and null literals crossing a value boundary', () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'NUMBER', plaintext: '1234', encryptedValue: 'number-ciphertext' },
|
||||
@@ -800,11 +1121,13 @@ describe('ResolvedSecretTraceRegistry', () => {
|
||||
registry.recordResolved('A_TOKEN', 'same')
|
||||
registry.recordResolved('EMPTY', '')
|
||||
|
||||
expect(registry.getActiveMatches()).toEqual([{ plaintext: 'same', replacement: '{{A_TOKEN}}' }])
|
||||
expect(registry.getActiveMatches()).toEqual([
|
||||
{ plaintext: 'same', replacement: ANONYMOUS_SECRET_TRACE_REPLACEMENT },
|
||||
])
|
||||
|
||||
registry.recordResolved('A', 'A')
|
||||
expect(registry.getActiveMatches()).toEqual([
|
||||
{ plaintext: 'same', replacement: '{{A_TOKEN}}' },
|
||||
{ plaintext: 'same', replacement: ANONYMOUS_SECRET_TRACE_REPLACEMENT },
|
||||
{ plaintext: 'A', replacement: '{{A}}' },
|
||||
])
|
||||
})
|
||||
@@ -825,6 +1148,22 @@ describe('ResolvedSecretTraceRegistry', () => {
|
||||
expect(registry.exportProvenance().entries).toEqual([])
|
||||
})
|
||||
|
||||
it('does not poison unrelated inputs when dormant catalog entries exceed the hard cap', () => {
|
||||
const entries = Array.from({ length: 10_001 }, (_, index) => ({
|
||||
name: `SECRET_${index}`,
|
||||
plaintext: `value-${index}`,
|
||||
encryptedValue: `ciphertext-${index}`,
|
||||
}))
|
||||
const registry = new ResolvedSecretTraceRegistry(entries)
|
||||
|
||||
expect(registry.isComplete()).toBe(true)
|
||||
expect(registry.recordResolvedAtInputPath('SECRET_10000', 'value-10000', ['userPrompt'])).toBe(
|
||||
false
|
||||
)
|
||||
expect(registry.forkForInputPaths([['systemPrompt']]).isComplete()).toBe(true)
|
||||
expect(registry.forkForInputPaths([['userPrompt']]).isComplete()).toBe(false)
|
||||
})
|
||||
|
||||
it('bounds provenance by serialized JSON bytes including control-character escapes', () => {
|
||||
const encryptedValue = '\u0000'.repeat(1_400_000)
|
||||
const provenance: ResolvedSecretTraceProvenanceV1 = {
|
||||
@@ -892,7 +1231,7 @@ describe('ResolvedSecretTraceRegistry', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('stops consuming a dormant catalog when its entry cap is exceeded', () => {
|
||||
it('does not let a large dormant catalog poison unrelated execution provenance', () => {
|
||||
let yieldedEntries = 0
|
||||
function* catalogEntries() {
|
||||
for (let index = 0; index < 20_000; index++) {
|
||||
@@ -908,11 +1247,11 @@ describe('ResolvedSecretTraceRegistry', () => {
|
||||
const registry = new ResolvedSecretTraceRegistry(catalogEntries())
|
||||
|
||||
expect(yieldedEntries).toBe(10_001)
|
||||
expect(registry.isComplete()).toBe(false)
|
||||
expect(registry.isComplete()).toBe(true)
|
||||
expect(registry.exportProvenance().entries).toEqual([])
|
||||
})
|
||||
|
||||
it('marks an oversized dormant catalog value incomplete without retaining it', () => {
|
||||
it('keeps an oversized dormant value inert until that exact secret is resolved', () => {
|
||||
const oversizedPlaintext = 'x'.repeat(8 * 1024 * 1024)
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{
|
||||
@@ -920,10 +1259,23 @@ describe('ResolvedSecretTraceRegistry', () => {
|
||||
plaintext: oversizedPlaintext,
|
||||
encryptedValue: 'ciphertext',
|
||||
},
|
||||
{
|
||||
name: 'NORMAL',
|
||||
plaintext: 'normal-secret',
|
||||
encryptedValue: 'normal-ciphertext',
|
||||
},
|
||||
])
|
||||
|
||||
expect(registry.isComplete()).toBe(false)
|
||||
expect(registry.recordResolved('OVERSIZED', oversizedPlaintext)).toBe(false)
|
||||
expect(registry.getActiveMatches()).toEqual([])
|
||||
expect(registry.isComplete()).toBe(true)
|
||||
expect(registry.getModelEgressSnapshot()).toEqual({ complete: true, matches: [] })
|
||||
expect(registry.recordResolvedAtInputPath('NORMAL', 'normal-secret', ['systemPrompt'])).toBe(
|
||||
true
|
||||
)
|
||||
expect(
|
||||
registry.recordResolvedAtInputPath('OVERSIZED', oversizedPlaintext, ['userPrompt'])
|
||||
).toBe(false)
|
||||
expect(registry.forkForInputPaths([['systemPrompt']]).isComplete()).toBe(true)
|
||||
expect(registry.forkForInputPaths([['userPrompt']]).isComplete()).toBe(false)
|
||||
expect(registry.getModelEgressSnapshot()).toEqual({ complete: false })
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
import { loggerMock } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { projectResolvedModelInput } from '@/lib/execution/model-input-provenance'
|
||||
import {
|
||||
LARGE_ARRAY_MANIFEST_VERSION,
|
||||
type LargeArrayManifest,
|
||||
@@ -26,6 +27,10 @@ vi.mock('@/lib/execution/payloads/store', () => ({
|
||||
materializeLargeValueRef: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/security/encryption', () => ({
|
||||
decryptSecret: vi.fn(async (encryptedValue: string) => ({ decrypted: encryptedValue })),
|
||||
}))
|
||||
|
||||
function createBlock(id: string, name: string, type: string, params = {}): SerializedBlock {
|
||||
return {
|
||||
id,
|
||||
@@ -115,6 +120,7 @@ function createResolver(
|
||||
return {
|
||||
block: functionBlock,
|
||||
ctx,
|
||||
state,
|
||||
resolver: new VariableResolver(workflow, {}, state, options),
|
||||
}
|
||||
}
|
||||
@@ -187,6 +193,109 @@ describe('VariableResolver function block inputs', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('binds propagated references to exact model-selected inputs without changing runtime values', async () => {
|
||||
const secret = 'x'
|
||||
const provenance = {
|
||||
version: 1 as const,
|
||||
complete: true,
|
||||
entries: [{ name: 'TOKEN', encryptedValue: secret }],
|
||||
}
|
||||
const producer = createBlock('producer', 'Producer', BlockType.API)
|
||||
const loop = createBlock('loop-1', 'Loop1', BlockType.LOOP)
|
||||
const parallel = createBlock('parallel-1', 'Parallel1', BlockType.PARALLEL)
|
||||
const consumer = createBlock('consumer', 'Consumer', BlockType.API)
|
||||
const workflowVariables = {
|
||||
'var-1': { id: 'var-1', name: 'token', type: 'string', value: secret },
|
||||
}
|
||||
const workflow: SerializedWorkflow = {
|
||||
version: '1',
|
||||
blocks: [producer, loop, parallel, consumer],
|
||||
connections: [],
|
||||
loops: {
|
||||
'loop-1': { id: 'loop-1', nodes: [], iterations: 1, loopType: 'for' },
|
||||
},
|
||||
parallels: {
|
||||
'parallel-1': {
|
||||
id: 'parallel-1',
|
||||
nodes: [],
|
||||
parallelType: 'count',
|
||||
count: 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
const state = new ExecutionState()
|
||||
state.setBlockOutput('producer', { result: secret }, 0, provenance)
|
||||
state.setBlockOutput('loop-1', { results: [secret] }, 0, provenance)
|
||||
state.setBlockOutput('parallel-1', { results: [secret] }, 0, provenance)
|
||||
const registry = new ResolvedSecretTraceRegistry()
|
||||
const ctx = {
|
||||
blockStates: state.getBlockStates(),
|
||||
blockLogs: [],
|
||||
environmentVariables: {},
|
||||
workflowVariables,
|
||||
workflowVariableResolvedSecretTraceProvenance: { 'var-1': provenance },
|
||||
resolvedSecretTraceRegistry: registry,
|
||||
decisions: { router: new Map(), condition: new Map() },
|
||||
loopExecutions: new Map(),
|
||||
parallelExecutions: new Map(),
|
||||
executedBlocks: new Set(),
|
||||
activeExecutionPath: new Set(),
|
||||
completedLoops: new Set(),
|
||||
metadata: {},
|
||||
} as ExecutionContext
|
||||
const resolver = new VariableResolver(workflow, workflowVariables, state, {
|
||||
navigatePathAsync,
|
||||
})
|
||||
const inputs = {
|
||||
blockPrompt: 'Box: <Producer.result>',
|
||||
workflowPrompt: 'Workflow: <variable.token>',
|
||||
loopPrompt: 'Loop: <Loop1.results[0]>',
|
||||
parallelPrompt: 'Parallel: <Parallel1.results[0]>',
|
||||
}
|
||||
|
||||
const resolved = await resolver.resolveInputs(ctx, consumer.id, inputs, consumer)
|
||||
|
||||
expect(resolved).toEqual({
|
||||
blockPrompt: `Box: ${secret}`,
|
||||
workflowPrompt: `Workflow: ${secret}`,
|
||||
loopPrompt: `Loop: ${secret}`,
|
||||
parallelPrompt: `Parallel: ${secret}`,
|
||||
})
|
||||
const projection = projectResolvedModelInput(
|
||||
registry,
|
||||
resolved,
|
||||
Object.keys(inputs).map((key) => [key])
|
||||
)
|
||||
expect(projection.complete).toBe(true)
|
||||
if (!projection.complete) throw new Error('Expected complete model projection')
|
||||
expect(projection.value).toEqual(inputs)
|
||||
})
|
||||
|
||||
it('preserves the destination path when resolving one whole reference directly', async () => {
|
||||
const secret = 'resolved-secret'
|
||||
const provenance = {
|
||||
version: 1 as const,
|
||||
complete: true,
|
||||
entries: [{ name: 'TOKEN', encryptedValue: secret }],
|
||||
}
|
||||
const { ctx, resolver, state } = createResolver()
|
||||
state.setBlockOutput('producer', { result: secret }, 0, provenance)
|
||||
ctx.blockStates = state.getBlockStates()
|
||||
const registry = new ResolvedSecretTraceRegistry()
|
||||
ctx.resolvedSecretTraceRegistry = registry
|
||||
|
||||
await expect(
|
||||
resolver.resolveSingleReference(ctx, 'function', '<Producer.result>', undefined, {
|
||||
inputPath: ['prompt'],
|
||||
})
|
||||
).resolves.toBe(secret)
|
||||
expect(registry.exportCommittedProvenanceForInputPaths([['prompt']])).toEqual(provenance)
|
||||
expect(registry.projectResolvedInputSelection({ prompt: secret })).toEqual({
|
||||
complete: true,
|
||||
value: { prompt: '<Producer.result>' },
|
||||
})
|
||||
})
|
||||
|
||||
it('returns empty inputs when params are missing', async () => {
|
||||
const { block, ctx, resolver } = createResolver()
|
||||
|
||||
|
||||
@@ -215,11 +215,15 @@ export class VariableResolver {
|
||||
resolved[key] = resolvedItems
|
||||
display[key] = displayItems
|
||||
} else {
|
||||
resolved[key] = await this.resolveValue(ctx, currentNodeId, value, undefined, block)
|
||||
resolved[key] = await this.resolveValue(ctx, currentNodeId, value, undefined, block, {
|
||||
inputPath: [key],
|
||||
})
|
||||
display[key] = resolved[key]
|
||||
}
|
||||
} else {
|
||||
resolved[key] = await this.resolveValue(ctx, currentNodeId, value, undefined, block)
|
||||
resolved[key] = await this.resolveValue(ctx, currentNodeId, value, undefined, block, {
|
||||
inputPath: [key],
|
||||
})
|
||||
display[key] = resolved[key]
|
||||
}
|
||||
}
|
||||
@@ -256,14 +260,20 @@ export class VariableResolver {
|
||||
|
||||
if (Array.isArray(conditions)) {
|
||||
resolved.conditions = await Promise.all(
|
||||
conditions.map(async (condition) => {
|
||||
conditions.map(async (condition, conditionIndex) => {
|
||||
if (!condition || typeof condition !== 'object') return condition
|
||||
const value = Reflect.get(condition, 'value')
|
||||
return {
|
||||
...condition,
|
||||
value:
|
||||
typeof value === 'string'
|
||||
? await this.resolveTemplateWithoutConditionFormatting(ctx, currentNodeId, value)
|
||||
? await this.resolveTemplateWithoutConditionFormatting(
|
||||
ctx,
|
||||
currentNodeId,
|
||||
value,
|
||||
undefined,
|
||||
['conditions', String(conditionIndex), 'value']
|
||||
)
|
||||
: value,
|
||||
}
|
||||
})
|
||||
@@ -274,7 +284,8 @@ export class VariableResolver {
|
||||
currentNodeId,
|
||||
conditions,
|
||||
undefined,
|
||||
block
|
||||
block,
|
||||
{ inputPath: ['conditions'] }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -285,6 +296,7 @@ export class VariableResolver {
|
||||
}
|
||||
resolved[key] = await this.resolveValue(ctx, currentNodeId, value, undefined, block, {
|
||||
allowLargeValueRefs: this.canResolveInputToLargeValueRef(block, key),
|
||||
inputPath: [key],
|
||||
})
|
||||
}
|
||||
return resolved
|
||||
@@ -307,7 +319,7 @@ export class VariableResolver {
|
||||
currentNodeId: string,
|
||||
reference: string,
|
||||
loopScope?: LoopScope,
|
||||
options: { allowLargeValueRefs?: boolean } = {}
|
||||
options: { allowLargeValueRefs?: boolean; inputPath?: readonly string[] } = {}
|
||||
): Promise<any> {
|
||||
if (typeof reference === 'string') {
|
||||
const trimmed = reference.trim()
|
||||
@@ -318,17 +330,21 @@ export class VariableResolver {
|
||||
currentNodeId,
|
||||
loopScope,
|
||||
allowLargeValueRefs: options.allowLargeValueRefs,
|
||||
inputPath: options.inputPath,
|
||||
}
|
||||
|
||||
const result = await this.resolveReference(trimmed, resolutionContext)
|
||||
if (result === RESOLVED_EMPTY) {
|
||||
return null
|
||||
}
|
||||
return result
|
||||
const resolved = result === RESOLVED_EMPTY ? null : result
|
||||
ctx.resolvedSecretTraceRegistry?.recordResolvedInputProjection(
|
||||
options.inputPath,
|
||||
resolved,
|
||||
trimmed
|
||||
)
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
|
||||
return this.resolveValue(ctx, currentNodeId, reference, loopScope)
|
||||
return this.resolveValue(ctx, currentNodeId, reference, loopScope, undefined, options)
|
||||
}
|
||||
|
||||
private async resolveValue(
|
||||
@@ -337,7 +353,7 @@ export class VariableResolver {
|
||||
value: any,
|
||||
loopScope?: LoopScope,
|
||||
block?: SerializedBlock,
|
||||
options: { allowLargeValueRefs?: boolean } = {}
|
||||
options: { allowLargeValueRefs?: boolean; inputPath?: readonly string[] } = {}
|
||||
): Promise<any> {
|
||||
if (value === null || value === undefined) {
|
||||
return value
|
||||
@@ -345,16 +361,24 @@ export class VariableResolver {
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return Promise.all(
|
||||
value.map((v) => this.resolveValue(ctx, currentNodeId, v, loopScope, block, options))
|
||||
value.map((v, index) =>
|
||||
this.resolveValue(ctx, currentNodeId, v, loopScope, block, {
|
||||
...options,
|
||||
inputPath: options.inputPath ? [...options.inputPath, String(index)] : undefined,
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
const entries = await Promise.all(
|
||||
Object.entries(value).map(async ([key, val]) => [
|
||||
key,
|
||||
await this.resolveValue(ctx, currentNodeId, val, loopScope, block, options),
|
||||
])
|
||||
Object.entries(value).map(async ([key, val]) => {
|
||||
const resolvedValue = await this.resolveValue(ctx, currentNodeId, val, loopScope, block, {
|
||||
...options,
|
||||
inputPath: options.inputPath ? [...options.inputPath, key] : undefined,
|
||||
})
|
||||
return [key, resolvedValue]
|
||||
})
|
||||
)
|
||||
return Object.fromEntries(entries)
|
||||
}
|
||||
@@ -1237,7 +1261,7 @@ export class VariableResolver {
|
||||
template: string,
|
||||
loopScope?: LoopScope,
|
||||
block?: SerializedBlock,
|
||||
options: { allowLargeValueRefs?: boolean } = {}
|
||||
options: { allowLargeValueRefs?: boolean; inputPath?: readonly string[] } = {}
|
||||
): Promise<string> {
|
||||
const resolutionContext: ResolutionContext = {
|
||||
executionContext: ctx,
|
||||
@@ -1245,6 +1269,7 @@ export class VariableResolver {
|
||||
currentNodeId,
|
||||
loopScope,
|
||||
allowLargeValueRefs: options.allowLargeValueRefs,
|
||||
inputPath: options.inputPath,
|
||||
}
|
||||
|
||||
let replacementError: Error | null = null
|
||||
@@ -1257,28 +1282,48 @@ export class VariableResolver {
|
||||
| undefined)
|
||||
: undefined
|
||||
|
||||
let result = await replaceValidReferencesAsync(template, async (match) => {
|
||||
let projectedReferenceResult = ''
|
||||
let projectedReferenceCursor = 0
|
||||
let result = await replaceValidReferencesAsync(template, async (match, index) => {
|
||||
if (replacementError) return match
|
||||
|
||||
projectedReferenceResult += template.slice(projectedReferenceCursor, index)
|
||||
projectedReferenceCursor = index + match.length
|
||||
let containsResolvedSecret = false
|
||||
const referenceContext: ResolutionContext = {
|
||||
...resolutionContext,
|
||||
onResolvedSecretReference: () => {
|
||||
containsResolvedSecret = true
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = await this.resolveReference(match, resolutionContext)
|
||||
const resolved = await this.resolveReference(match, referenceContext)
|
||||
if (resolved === undefined) {
|
||||
projectedReferenceResult += match
|
||||
return match
|
||||
}
|
||||
|
||||
if (resolved === RESOLVED_EMPTY) {
|
||||
if (blockType === BlockType.FUNCTION) {
|
||||
return this.blockResolver.formatValueForBlock(null, blockType, language)
|
||||
const formatted = this.blockResolver.formatValueForBlock(null, blockType, language)
|
||||
projectedReferenceResult += formatted
|
||||
return formatted
|
||||
}
|
||||
projectedReferenceResult += ''
|
||||
return ''
|
||||
}
|
||||
|
||||
return this.blockResolver.formatValueForBlock(resolved, blockType, language)
|
||||
const formatted = this.blockResolver.formatValueForBlock(resolved, blockType, language)
|
||||
projectedReferenceResult += containsResolvedSecret ? match : formatted
|
||||
return formatted
|
||||
} catch (error) {
|
||||
replacementError = toError(error)
|
||||
projectedReferenceResult += match
|
||||
return match
|
||||
}
|
||||
})
|
||||
projectedReferenceResult += template.slice(projectedReferenceCursor)
|
||||
|
||||
if (replacementError !== null) {
|
||||
throw replacementError
|
||||
@@ -1288,6 +1333,11 @@ export class VariableResolver {
|
||||
const resolved = await this.resolveReference(match, resolutionContext)
|
||||
return typeof resolved === 'string' ? resolved : match
|
||||
})
|
||||
ctx.resolvedSecretTraceRegistry?.recordResolvedInputProjection(
|
||||
options.inputPath,
|
||||
result,
|
||||
projectedReferenceResult
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1295,27 +1345,43 @@ export class VariableResolver {
|
||||
ctx: ExecutionContext,
|
||||
currentNodeId: string,
|
||||
template: string,
|
||||
loopScope?: LoopScope
|
||||
loopScope?: LoopScope,
|
||||
inputPath?: readonly string[]
|
||||
): Promise<string> {
|
||||
const resolutionContext: ResolutionContext = {
|
||||
executionContext: ctx,
|
||||
executionState: this.state,
|
||||
currentNodeId,
|
||||
loopScope,
|
||||
inputPath,
|
||||
}
|
||||
|
||||
let replacementError: Error | null = null
|
||||
|
||||
let result = await replaceValidReferencesAsync(template, async (match) => {
|
||||
let projectedReferenceResult = ''
|
||||
let projectedReferenceCursor = 0
|
||||
let result = await replaceValidReferencesAsync(template, async (match, index) => {
|
||||
if (replacementError) return match
|
||||
|
||||
projectedReferenceResult += template.slice(projectedReferenceCursor, index)
|
||||
projectedReferenceCursor = index + match.length
|
||||
let containsResolvedSecret = false
|
||||
const referenceContext: ResolutionContext = {
|
||||
...resolutionContext,
|
||||
onResolvedSecretReference: () => {
|
||||
containsResolvedSecret = true
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = await this.resolveReference(match, resolutionContext)
|
||||
const resolved = await this.resolveReference(match, referenceContext)
|
||||
if (resolved === undefined) {
|
||||
projectedReferenceResult += match
|
||||
return match
|
||||
}
|
||||
|
||||
if (resolved === RESOLVED_EMPTY) {
|
||||
projectedReferenceResult += 'null'
|
||||
return 'null'
|
||||
}
|
||||
|
||||
@@ -1327,17 +1393,25 @@ export class VariableResolver {
|
||||
.replace(/\r/g, '\\r')
|
||||
.replace(/\u2028/g, '\\u2028')
|
||||
.replace(/\u2029/g, '\\u2029')
|
||||
return `'${escaped}'`
|
||||
const formatted = `'${escaped}'`
|
||||
projectedReferenceResult += containsResolvedSecret ? match : formatted
|
||||
return formatted
|
||||
}
|
||||
if (typeof resolved === 'object' && resolved !== null) {
|
||||
return JSON.stringify(resolved)
|
||||
const formatted = JSON.stringify(resolved)
|
||||
projectedReferenceResult += containsResolvedSecret ? match : formatted
|
||||
return formatted
|
||||
}
|
||||
return String(resolved)
|
||||
const formatted = String(resolved)
|
||||
projectedReferenceResult += containsResolvedSecret ? match : formatted
|
||||
return formatted
|
||||
} catch (error) {
|
||||
replacementError = toError(error)
|
||||
projectedReferenceResult += match
|
||||
return match
|
||||
}
|
||||
})
|
||||
projectedReferenceResult += template.slice(projectedReferenceCursor)
|
||||
|
||||
if (replacementError !== null) {
|
||||
throw replacementError
|
||||
@@ -1347,6 +1421,11 @@ export class VariableResolver {
|
||||
const resolved = await this.resolveReference(match, resolutionContext)
|
||||
return typeof resolved === 'string' ? resolved : match
|
||||
})
|
||||
ctx.resolvedSecretTraceRegistry?.recordResolvedInputProjection(
|
||||
inputPath,
|
||||
result,
|
||||
projectedReferenceResult
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref'
|
||||
import { compactExecutionPayload } from '@/lib/execution/payloads/serializer'
|
||||
import { ExecutionState } from '@/executor/execution/state'
|
||||
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
|
||||
import { navigatePathAsync } from '@/executor/variables/resolvers/reference-async.server'
|
||||
import { BlockResolver } from './block'
|
||||
import { RESOLVED_EMPTY, type ResolutionContext } from './reference'
|
||||
|
||||
@@ -640,6 +642,58 @@ describe('BlockResolver', () => {
|
||||
{ plaintext: 'secret-value', replacement: '{{API_KEY}}' },
|
||||
])
|
||||
})
|
||||
|
||||
it('filters compacted block candidates against the exact selected leaf', async () => {
|
||||
const workflow = createTestWorkflow([{ id: 'source' }])
|
||||
const resolver = new BlockResolver(workflow, navigatePathAsync)
|
||||
const compacted = await compactExecutionPayload(
|
||||
{
|
||||
result: {
|
||||
huge: 'p'.repeat(9 * 1024 * 1024),
|
||||
public: 'ok',
|
||||
secret: 'secret-value',
|
||||
},
|
||||
},
|
||||
{
|
||||
preserveRoot: true,
|
||||
workspaceId: 'workspace-1',
|
||||
workflowId: 'workflow-1',
|
||||
executionId: 'execution-1',
|
||||
}
|
||||
)
|
||||
expect(isLargeValueRef(compacted.result.huge)).toBe(true)
|
||||
const candidateProvenance = {
|
||||
version: 1 as const,
|
||||
complete: true,
|
||||
entries: [{ name: 'API_KEY', encryptedValue: 'secret-value' }],
|
||||
}
|
||||
|
||||
const publicRegistry = new ResolvedSecretTraceRegistry()
|
||||
const publicContext = createTestContext('current')
|
||||
publicContext.inputPath = ['prompt']
|
||||
publicContext.executionContext.resolvedSecretTraceRegistry = publicRegistry
|
||||
publicContext.executionState.setBlockOutput('source', compacted, 0, candidateProvenance)
|
||||
|
||||
await expect(resolver.resolveAsync('<source.result.public>', publicContext)).resolves.toBe(
|
||||
'ok'
|
||||
)
|
||||
expect(publicRegistry.isComplete()).toBe(true)
|
||||
expect(publicRegistry.getActiveMatches()).toEqual([])
|
||||
|
||||
const secretRegistry = new ResolvedSecretTraceRegistry()
|
||||
const secretContext = createTestContext('current')
|
||||
secretContext.inputPath = ['prompt']
|
||||
secretContext.executionContext.resolvedSecretTraceRegistry = secretRegistry
|
||||
secretContext.executionState.setBlockOutput('source', compacted, 0, candidateProvenance)
|
||||
|
||||
await expect(resolver.resolveAsync('<source.result.secret>', secretContext)).resolves.toBe(
|
||||
'secret-value'
|
||||
)
|
||||
expect(secretRegistry.isComplete()).toBe(true)
|
||||
expect(secretRegistry.getActiveMatches()).toEqual([
|
||||
{ plaintext: 'secret-value', replacement: '{{API_KEY}}' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatValueForBlock', () => {
|
||||
|
||||
@@ -332,11 +332,14 @@ export class BlockResolver implements Resolver {
|
||||
) {
|
||||
return value
|
||||
}
|
||||
await context.executionContext.resolvedSecretTraceRegistry.importProvenanceForValue(
|
||||
state.resolvedSecretTraceProvenance,
|
||||
value,
|
||||
{ trusted: true }
|
||||
)
|
||||
const imported =
|
||||
await context.executionContext.resolvedSecretTraceRegistry.importProvenanceForValueAtInputPath(
|
||||
state.resolvedSecretTraceProvenance,
|
||||
value,
|
||||
context.inputPath,
|
||||
{ trusted: true }
|
||||
)
|
||||
if (imported.matched) context.onResolvedSecretReference?.()
|
||||
return value
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,11 @@ export class EnvResolver implements Resolver {
|
||||
return reference
|
||||
}
|
||||
if (Object.hasOwn(context.executionContext.environmentVariables, varName)) {
|
||||
context.executionContext.resolvedSecretTraceRegistry?.recordResolved(varName, value)
|
||||
context.executionContext.resolvedSecretTraceRegistry?.recordResolvedAtInputPath(
|
||||
varName,
|
||||
value,
|
||||
context.inputPath
|
||||
)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -279,7 +279,13 @@ export class LoopResolver implements Resolver {
|
||||
const resolvedValue = await value
|
||||
const registry = context.executionContext.resolvedSecretTraceRegistry
|
||||
if (!registry || !provenance) return resolvedValue
|
||||
await registry.importProvenanceForValue(provenance, resolvedValue, { trusted: true })
|
||||
const imported = await registry.importProvenanceForValueAtInputPath(
|
||||
provenance,
|
||||
resolvedValue,
|
||||
context.inputPath,
|
||||
{ trusted: true }
|
||||
)
|
||||
if (imported.matched) context.onResolvedSecretReference?.()
|
||||
return resolvedValue
|
||||
}
|
||||
|
||||
|
||||
@@ -386,7 +386,13 @@ export class ParallelResolver implements Resolver {
|
||||
const resolvedValue = await value
|
||||
const registry = context.executionContext.resolvedSecretTraceRegistry
|
||||
if (!registry || !provenance) return resolvedValue
|
||||
await registry.importProvenanceForValue(provenance, resolvedValue, { trusted: true })
|
||||
const imported = await registry.importProvenanceForValueAtInputPath(
|
||||
provenance,
|
||||
resolvedValue,
|
||||
context.inputPath,
|
||||
{ trusted: true }
|
||||
)
|
||||
if (imported.matched) context.onResolvedSecretReference?.()
|
||||
return resolvedValue
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface ResolutionContext {
|
||||
currentNodeId: string
|
||||
loopScope?: LoopScope
|
||||
allowLargeValueRefs?: boolean
|
||||
inputPath?: readonly string[]
|
||||
onResolvedSecretReference?: () => void
|
||||
}
|
||||
|
||||
export interface Resolver {
|
||||
|
||||
@@ -129,7 +129,13 @@ export class WorkflowResolver implements Resolver {
|
||||
context.executionContext.workflowVariableResolvedSecretTraceProvenance?.[variableId]
|
||||
if (!registry || !provenance) return value
|
||||
|
||||
await registry.importProvenanceForValue(provenance, value, { trusted: true })
|
||||
const imported = await registry.importProvenanceForValueAtInputPath(
|
||||
provenance,
|
||||
value,
|
||||
context.inputPath,
|
||||
{ trusted: true }
|
||||
)
|
||||
if (imported.matched) context.onResolvedSecretReference?.()
|
||||
return value
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { copilotChats } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { isPlainRecord } from '@sim/utils/object'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
@@ -66,8 +65,6 @@ import {
|
||||
isWorkspaceAccessDeniedError,
|
||||
type PermissionType,
|
||||
} from '@/lib/workspaces/permissions/utils'
|
||||
import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection'
|
||||
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
|
||||
import type { ChatContext } from '@/stores/panel'
|
||||
|
||||
export const maxDuration = 3600
|
||||
@@ -522,36 +519,6 @@ async function resolveAgentContexts(params: {
|
||||
return agentContexts
|
||||
}
|
||||
|
||||
function projectAgentContextInputs(
|
||||
message: string,
|
||||
contexts: UnifiedChatRequest['contexts'],
|
||||
registry: ResolvedSecretTraceRegistry | undefined
|
||||
): { message: string; contexts: UnifiedChatRequest['contexts'] } {
|
||||
const labels = (contexts ?? []).map((context) => context.label ?? null)
|
||||
const projection = projectResolvedSecretModelContent({ message, labels }, registry)
|
||||
if (!projection.safe || !isPlainRecord(projection.value)) {
|
||||
throw new Error('Agent context input could not be safely projected')
|
||||
}
|
||||
const projectedMessage = projection.value.message
|
||||
const projectedLabels = projection.value.labels
|
||||
if (
|
||||
typeof projectedMessage !== 'string' ||
|
||||
!Array.isArray(projectedLabels) ||
|
||||
projectedLabels.length !== labels.length ||
|
||||
!projectedLabels.every((label) => label === null || typeof label === 'string')
|
||||
) {
|
||||
throw new Error('Agent context input could not be safely projected')
|
||||
}
|
||||
|
||||
return {
|
||||
message: projectedMessage,
|
||||
contexts: contexts?.map((context, index) => ({
|
||||
...context,
|
||||
...(projectedLabels[index] === null ? {} : { label: projectedLabels[index] }),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async function persistUserMessage(params: {
|
||||
chatId?: string
|
||||
userMessageId: string
|
||||
@@ -1211,12 +1178,7 @@ export async function handleUnifiedChatPost(req: NextRequest) {
|
||||
}),
|
||||
activeOtelRoot.context
|
||||
)
|
||||
const agentContextsPromise = executionContextPromise.then((executionContext) => {
|
||||
const projected = projectAgentContextInputs(
|
||||
body.message,
|
||||
normalizedContexts,
|
||||
executionContext.resolvedSecretTraceRegistry
|
||||
)
|
||||
const agentContextsPromise = executionContextPromise.then(() => {
|
||||
return withCopilotSpan(
|
||||
TraceSpan.CopilotChatResolveAgentContexts,
|
||||
{
|
||||
@@ -1225,10 +1187,10 @@ export async function handleUnifiedChatPost(req: NextRequest) {
|
||||
},
|
||||
() =>
|
||||
resolveAgentContexts({
|
||||
contexts: projected.contexts,
|
||||
contexts: normalizedContexts,
|
||||
resourceAttachments: body.resourceAttachments,
|
||||
userId: authenticatedUserId,
|
||||
message: projected.message,
|
||||
message: body.message,
|
||||
workspaceId,
|
||||
chatId: actualChatId,
|
||||
requestId,
|
||||
|
||||
@@ -50,130 +50,39 @@ describe('mothership MCP tool schemas', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('reports tagged-server discovery provenance without placing it in tool schemas', async () => {
|
||||
const provenance = {
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [{ name: 'MCP_TOKEN', encryptedValue: 'encrypted-token' }],
|
||||
scope: { userId: 'user-1', workspaceId: 'ws-1' },
|
||||
}
|
||||
discoverServerTools.mockImplementationOnce(
|
||||
async (
|
||||
_userId: string,
|
||||
_serverId: string,
|
||||
_workspaceId: string,
|
||||
_forceRefresh: boolean,
|
||||
report: (value: unknown) => void
|
||||
) => {
|
||||
report(provenance)
|
||||
return [
|
||||
{
|
||||
serverId: 'mcp-server-1',
|
||||
name: 'search',
|
||||
description: 'Search docs',
|
||||
inputSchema: { type: 'object' },
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
const recordProvenance = vi.fn()
|
||||
|
||||
const tools = await buildTaggedMcpToolSchemas(
|
||||
'user-1',
|
||||
'ws-1',
|
||||
['mcp-server-1'],
|
||||
recordProvenance
|
||||
)
|
||||
|
||||
expect(discoverServerTools).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
'mcp-server-1',
|
||||
'ws-1',
|
||||
false,
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(recordProvenance).toHaveBeenCalledWith(provenance)
|
||||
expect(JSON.stringify(tools)).not.toContain('encrypted-token')
|
||||
expect(JSON.stringify(tools)).not.toContain('resolvedSecretTraceProvenance')
|
||||
})
|
||||
|
||||
it('reports incomplete provenance when tagged-server discovery returns no report', async () => {
|
||||
discoverServerTools.mockResolvedValue([])
|
||||
const recordProvenance = vi.fn()
|
||||
|
||||
await buildTaggedMcpToolSchemas('user-1', 'ws-1', ['mcp-server-1'], recordProvenance)
|
||||
|
||||
expect(recordProvenance).toHaveBeenCalledWith({
|
||||
version: 1,
|
||||
complete: false,
|
||||
entries: [],
|
||||
scope: { userId: 'user-1', workspaceId: 'ws-1' },
|
||||
})
|
||||
})
|
||||
|
||||
it('uses a selected block tool cached schema without discovering the server', async () => {
|
||||
const recordProvenance = vi.fn()
|
||||
const tools = await buildSelectedMcpToolSchemas(
|
||||
'user-1',
|
||||
'ws-1',
|
||||
[
|
||||
{
|
||||
type: 'mcp',
|
||||
params: { serverId: 'mcp-server-1', toolName: 'search', serverName: 'Docs' },
|
||||
schema: { type: 'object', properties: { query: { type: 'string' } } },
|
||||
},
|
||||
],
|
||||
recordProvenance
|
||||
)
|
||||
const tools = await buildSelectedMcpToolSchemas('user-1', 'ws-1', [
|
||||
{
|
||||
type: 'mcp',
|
||||
params: { serverId: 'mcp-server-1', toolName: 'search', serverName: 'Docs' },
|
||||
schema: { type: 'object', properties: { query: { type: 'string' } } },
|
||||
},
|
||||
])
|
||||
|
||||
expect(discoverServerTools).not.toHaveBeenCalled()
|
||||
expect(recordProvenance).not.toHaveBeenCalled()
|
||||
expect(tools[0]).toMatchObject({
|
||||
name: 'mcp-server-1-search',
|
||||
input_schema: { type: 'object', properties: { query: { type: 'string' } } },
|
||||
})
|
||||
})
|
||||
|
||||
it('reports provenance from selected tools that require server discovery', async () => {
|
||||
const provenance = {
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [{ name: 'MCP_TOKEN', encryptedValue: 'encrypted-token' }],
|
||||
scope: { userId: 'user-1', workspaceId: 'ws-1' },
|
||||
}
|
||||
discoverServerTools.mockImplementationOnce(
|
||||
async (
|
||||
_userId: string,
|
||||
_serverId: string,
|
||||
_workspaceId: string,
|
||||
_forceRefresh: boolean,
|
||||
report: (value: unknown) => void
|
||||
) => {
|
||||
report(provenance)
|
||||
return [
|
||||
{
|
||||
serverId: 'mcp-server-1',
|
||||
name: 'search',
|
||||
inputSchema: { type: 'object' },
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
const recordProvenance = vi.fn()
|
||||
it('discovers a selected legacy tool without a cached schema', async () => {
|
||||
discoverServerTools.mockResolvedValueOnce([
|
||||
{
|
||||
serverId: 'mcp-server-1',
|
||||
name: 'search',
|
||||
inputSchema: { type: 'object' },
|
||||
},
|
||||
])
|
||||
|
||||
const tools = await buildSelectedMcpToolSchemas(
|
||||
'user-1',
|
||||
'ws-1',
|
||||
[
|
||||
{
|
||||
type: 'mcp',
|
||||
params: { serverId: 'mcp-server-1', toolName: 'search' },
|
||||
},
|
||||
],
|
||||
recordProvenance
|
||||
)
|
||||
const tools = await buildSelectedMcpToolSchemas('user-1', 'ws-1', [
|
||||
{
|
||||
type: 'mcp',
|
||||
params: { serverId: 'mcp-server-1', toolName: 'search' },
|
||||
},
|
||||
])
|
||||
|
||||
expect(recordProvenance).toHaveBeenCalledWith(provenance)
|
||||
expect(discoverServerTools).toHaveBeenCalledWith('user-1', 'mcp-server-1', 'ws-1')
|
||||
expect(tools[0]).toMatchObject({ name: 'mcp-server-1-search' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,8 +8,6 @@ import type { ToolInput } from '@/executor/handlers/agent/types'
|
||||
|
||||
const logger = createLogger('CopilotMcpTools')
|
||||
|
||||
type ResolvedSecretTraceProvenanceCallback = (provenance: unknown) => void
|
||||
|
||||
function toMothershipMcpTool(tool: {
|
||||
serverId: string
|
||||
serverName?: string
|
||||
@@ -53,26 +51,11 @@ function dedupeMcpTools(tools: ToolSchema[]): ToolSchema[] {
|
||||
async function discoverServerTools(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
serverId: string,
|
||||
onResolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceCallback
|
||||
serverId: string
|
||||
): Promise<McpTool[]> {
|
||||
let provenanceReported = false
|
||||
try {
|
||||
const { mcpService } = await import('@/lib/mcp/service')
|
||||
if (!onResolvedSecretTraceProvenance) {
|
||||
return await mcpService.discoverServerTools(userId, serverId, workspaceId)
|
||||
}
|
||||
|
||||
return await mcpService.discoverServerTools(
|
||||
userId,
|
||||
serverId,
|
||||
workspaceId,
|
||||
false,
|
||||
(provenance) => {
|
||||
provenanceReported = true
|
||||
onResolvedSecretTraceProvenance(provenance)
|
||||
}
|
||||
)
|
||||
return await mcpService.discoverServerTools(userId, serverId, workspaceId)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to resolve tagged MCP server tools', {
|
||||
serverId,
|
||||
@@ -80,15 +63,6 @@ async function discoverServerTools(
|
||||
error: toError(error).message,
|
||||
})
|
||||
return []
|
||||
} finally {
|
||||
if (onResolvedSecretTraceProvenance && !provenanceReported) {
|
||||
onResolvedSecretTraceProvenance({
|
||||
version: 1,
|
||||
complete: false,
|
||||
entries: [],
|
||||
scope: { userId, workspaceId },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,17 +73,14 @@ async function discoverServerTools(
|
||||
export async function buildTaggedMcpToolSchemas(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
serverIds: string[],
|
||||
onResolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceCallback
|
||||
serverIds: string[]
|
||||
): Promise<ToolSchema[]> {
|
||||
const uniqueServerIds = [...new Set(serverIds.filter(Boolean))]
|
||||
if (uniqueServerIds.length === 0) return []
|
||||
|
||||
await validateMcpToolsAllowed(userId, workspaceId)
|
||||
const discovered = await Promise.all(
|
||||
uniqueServerIds.map((serverId) =>
|
||||
discoverServerTools(userId, workspaceId, serverId, onResolvedSecretTraceProvenance)
|
||||
)
|
||||
uniqueServerIds.map((serverId) => discoverServerTools(userId, workspaceId, serverId))
|
||||
)
|
||||
return dedupeMcpTools(discovered.flat().map(toMothershipMcpTool))
|
||||
}
|
||||
@@ -122,8 +93,7 @@ export async function buildTaggedMcpToolSchemas(
|
||||
export async function buildSelectedMcpToolSchemas(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
selections: ToolInput[],
|
||||
onResolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceCallback
|
||||
selections: ToolInput[]
|
||||
): Promise<ToolSchema[]> {
|
||||
const selected = selections.filter(
|
||||
(tool) =>
|
||||
@@ -160,12 +130,7 @@ export async function buildSelectedMcpToolSchemas(
|
||||
|
||||
let discovery = discoveredByServer.get(serverId)
|
||||
if (!discovery) {
|
||||
discovery = discoverServerTools(
|
||||
userId,
|
||||
workspaceId,
|
||||
serverId,
|
||||
onResolvedSecretTraceProvenance
|
||||
)
|
||||
discovery = discoverServerTools(userId, workspaceId, serverId)
|
||||
discoveredByServer.set(serverId, discovery)
|
||||
}
|
||||
const match = (await discovery).find((tool) => tool.name === toolName)
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
collectModelVisibleSchemaContent,
|
||||
restoreModelVisibleSchemaValues,
|
||||
} from '@/lib/copilot/model-visible-schema'
|
||||
|
||||
describe('model-visible schema classification', () => {
|
||||
it('projects display text, preserves public grammar, and guards dynamic semantics', () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tokenField: {
|
||||
type: 'string',
|
||||
title: 'Visible title',
|
||||
description: 'Visible description',
|
||||
enum: ['semantic-value'],
|
||||
default: 'semantic-default',
|
||||
},
|
||||
},
|
||||
required: ['tokenField'],
|
||||
}
|
||||
|
||||
const content = collectModelVisibleSchemaContent(schema)
|
||||
|
||||
expect(content.projectedValues).toEqual(['Visible title', 'Visible description'])
|
||||
expect(content.guardedValues).toEqual(
|
||||
expect.arrayContaining(['tokenField', ['semantic-value'], 'semantic-default', ['tokenField']])
|
||||
)
|
||||
expect(
|
||||
restoreModelVisibleSchemaValues(schema, ['Projected title', 'Projected description'])
|
||||
).toEqual({
|
||||
type: 'object',
|
||||
properties: {
|
||||
tokenField: {
|
||||
type: 'string',
|
||||
title: 'Projected title',
|
||||
description: 'Projected description',
|
||||
enum: ['semantic-value'],
|
||||
default: 'semantic-default',
|
||||
},
|
||||
},
|
||||
required: ['tokenField'],
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves validated controls while guarding arbitrary or invalid semantic values', () => {
|
||||
const schema = {
|
||||
type: ['object', 'null'],
|
||||
nullable: true,
|
||||
readOnly: false,
|
||||
format: 'secret-format',
|
||||
$schema: 'secret-schema-uri',
|
||||
contentEncoding: 'secret-encoding',
|
||||
contentMediaType: 'secret-media-type',
|
||||
properties: {
|
||||
invalidType: { type: 'secret-type' },
|
||||
invalidBoolean: { deprecated: 'secret-deprecated' },
|
||||
},
|
||||
}
|
||||
|
||||
expect(collectModelVisibleSchemaContent(schema).guardedValues).toEqual(
|
||||
expect.arrayContaining([
|
||||
'secret-format',
|
||||
'secret-schema-uri',
|
||||
'secret-encoding',
|
||||
'secret-media-type',
|
||||
'invalidType',
|
||||
'secret-type',
|
||||
'invalidBoolean',
|
||||
'secret-deprecated',
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['string', { type: 'string' }],
|
||||
['true', { nullable: true }],
|
||||
])('preserves the validated public control %s outside secret matching', (_secret, schema) => {
|
||||
expect(collectModelVisibleSchemaContent(schema).guardedValues).toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ items: 'not-a-schema' },
|
||||
{ allOf: ['not-a-schema'] },
|
||||
{ properties: { field: 'not-a-schema' } },
|
||||
])('rejects malformed child schemas instead of silently preserving them', (schema) => {
|
||||
expect(() => collectModelVisibleSchemaContent(schema)).toThrow(
|
||||
'Model-visible schema content could not be safely projected'
|
||||
)
|
||||
expect(() => restoreModelVisibleSchemaValues(schema, [])).toThrow(
|
||||
'Model-visible schema content could not be safely projected'
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts boolean schemas at every schema-child position', () => {
|
||||
const schema = {
|
||||
additionalProperties: false,
|
||||
allOf: [true],
|
||||
properties: { field: false },
|
||||
}
|
||||
|
||||
expect(collectModelVisibleSchemaContent(schema)).toEqual({
|
||||
projectedValues: [],
|
||||
guardedValues: ['field'],
|
||||
})
|
||||
expect(restoreModelVisibleSchemaValues(schema, [])).toEqual(schema)
|
||||
})
|
||||
|
||||
it('guards arbitrary keys at the root and within child schemas', () => {
|
||||
const schema = {
|
||||
'root-semantic-key': true,
|
||||
properties: {
|
||||
field: {
|
||||
'child-semantic-key': 'value',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
expect(collectModelVisibleSchemaContent(schema).guardedValues).toEqual(
|
||||
expect.arrayContaining(['root-semantic-key', 'child-semantic-key'])
|
||||
)
|
||||
})
|
||||
|
||||
it('restores safe canonical controls byte-for-byte', () => {
|
||||
const schema = {
|
||||
type: ['object', 'null'],
|
||||
nullable: true,
|
||||
readOnly: false,
|
||||
properties: { value: { type: 'string' } },
|
||||
}
|
||||
|
||||
expect(restoreModelVisibleSchemaValues(schema, [])).toEqual(schema)
|
||||
})
|
||||
|
||||
it('rejects oversized schema collections before duplicating them', () => {
|
||||
const oversized = new Array(100_001)
|
||||
const schema = { allOf: oversized }
|
||||
|
||||
expect(() => collectModelVisibleSchemaContent(schema)).toThrow(
|
||||
'Model-visible schema content could not be safely projected'
|
||||
)
|
||||
expect(() => restoreModelVisibleSchemaValues(schema, [])).toThrow(
|
||||
'Model-visible schema content could not be safely projected'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,361 +0,0 @@
|
||||
import { isPlainRecord } from '@sim/utils/object'
|
||||
|
||||
const MAX_SCHEMA_NODES = 100_000
|
||||
const MAX_SCHEMA_DEPTH = 100
|
||||
const SCHEMA_DISPLAY_KEYS = new Set(['description', 'title', '$comment', 'example', 'examples'])
|
||||
const SCHEMA_SINGLE_CHILD_KEYS = new Set([
|
||||
'additionalProperties',
|
||||
'contains',
|
||||
'contentSchema',
|
||||
'else',
|
||||
'if',
|
||||
'items',
|
||||
'not',
|
||||
'propertyNames',
|
||||
'then',
|
||||
'unevaluatedItems',
|
||||
'unevaluatedProperties',
|
||||
])
|
||||
const SCHEMA_ARRAY_CHILD_KEYS = new Set(['allOf', 'anyOf', 'oneOf', 'prefixItems'])
|
||||
const SCHEMA_MAP_CHILD_KEYS = new Set([
|
||||
'$defs',
|
||||
'definitions',
|
||||
'dependentSchemas',
|
||||
'patternProperties',
|
||||
'properties',
|
||||
])
|
||||
const SCHEMA_TYPE_NAMES = new Set([
|
||||
'array',
|
||||
'boolean',
|
||||
'integer',
|
||||
'null',
|
||||
'number',
|
||||
'object',
|
||||
'string',
|
||||
])
|
||||
const SCHEMA_BOOLEAN_CONTROL_KEYS = new Set([
|
||||
'deprecated',
|
||||
'nullable',
|
||||
'readOnly',
|
||||
'uniqueItems',
|
||||
'writeOnly',
|
||||
])
|
||||
const SCHEMA_NUMBER_CONTROL_KEYS = new Set([
|
||||
'exclusiveMaximum',
|
||||
'exclusiveMinimum',
|
||||
'maximum',
|
||||
'minimum',
|
||||
'multipleOf',
|
||||
])
|
||||
const SCHEMA_NONNEGATIVE_INTEGER_CONTROL_KEYS = new Set([
|
||||
'maxContains',
|
||||
'maxItems',
|
||||
'maxLength',
|
||||
'maxProperties',
|
||||
'minContains',
|
||||
'minItems',
|
||||
'minLength',
|
||||
'minProperties',
|
||||
])
|
||||
const SCHEMA_KNOWN_KEYS = new Set([
|
||||
'$anchor',
|
||||
'$comment',
|
||||
'$defs',
|
||||
'$dynamicAnchor',
|
||||
'$dynamicRef',
|
||||
'$id',
|
||||
'$ref',
|
||||
'$schema',
|
||||
'$vocabulary',
|
||||
'additionalProperties',
|
||||
'allOf',
|
||||
'anyOf',
|
||||
'const',
|
||||
'contains',
|
||||
'contentEncoding',
|
||||
'contentMediaType',
|
||||
'contentSchema',
|
||||
'default',
|
||||
'definitions',
|
||||
'deprecated',
|
||||
'dependentRequired',
|
||||
'dependentSchemas',
|
||||
'description',
|
||||
'else',
|
||||
'enum',
|
||||
'example',
|
||||
'examples',
|
||||
'exclusiveMaximum',
|
||||
'exclusiveMinimum',
|
||||
'format',
|
||||
'if',
|
||||
'items',
|
||||
'maxContains',
|
||||
'maxItems',
|
||||
'maxLength',
|
||||
'maxProperties',
|
||||
'maximum',
|
||||
'minContains',
|
||||
'minItems',
|
||||
'minLength',
|
||||
'minProperties',
|
||||
'minimum',
|
||||
'multipleOf',
|
||||
'not',
|
||||
'nullable',
|
||||
'oneOf',
|
||||
'pattern',
|
||||
'patternProperties',
|
||||
'prefixItems',
|
||||
'properties',
|
||||
'propertyNames',
|
||||
'readOnly',
|
||||
'required',
|
||||
'then',
|
||||
'title',
|
||||
'type',
|
||||
'unevaluatedItems',
|
||||
'unevaluatedProperties',
|
||||
'uniqueItems',
|
||||
'writeOnly',
|
||||
])
|
||||
|
||||
export type ModelVisibleSchemaAction =
|
||||
| 'preserve'
|
||||
| 'project'
|
||||
| 'traverse'
|
||||
| 'verify'
|
||||
| 'traverse-verify-key'
|
||||
| 'verify-key-value'
|
||||
|
||||
export class ModelVisibleSchemaError extends Error {
|
||||
constructor() {
|
||||
super('Model-visible schema content could not be safely projected')
|
||||
this.name = 'ModelVisibleSchemaError'
|
||||
}
|
||||
}
|
||||
|
||||
export function getModelVisibleSchemaAction(
|
||||
parentKey: string | undefined,
|
||||
key: string,
|
||||
value?: unknown
|
||||
): ModelVisibleSchemaAction {
|
||||
if (parentKey !== undefined && SCHEMA_MAP_CHILD_KEYS.has(parentKey)) {
|
||||
return isSchemaNode(value) ? 'traverse-verify-key' : 'verify-key-value'
|
||||
}
|
||||
if (SCHEMA_DISPLAY_KEYS.has(key)) return 'project'
|
||||
if (SCHEMA_SINGLE_CHILD_KEYS.has(key)) return isSchemaNode(value) ? 'traverse' : 'verify'
|
||||
if (SCHEMA_ARRAY_CHILD_KEYS.has(key)) return Array.isArray(value) ? 'traverse' : 'verify'
|
||||
if (SCHEMA_MAP_CHILD_KEYS.has(key)) return isPlainRecord(value) ? 'traverse' : 'verify'
|
||||
if (isPublicSchemaControl(key, value)) return 'preserve'
|
||||
return 'verify'
|
||||
}
|
||||
|
||||
interface SchemaTraversalState {
|
||||
nodes: number
|
||||
ancestors: WeakSet<object>
|
||||
}
|
||||
|
||||
function visitSchemaNode(state: SchemaTraversalState, depth: number): void {
|
||||
state.nodes += 1
|
||||
if (state.nodes > MAX_SCHEMA_NODES || depth > MAX_SCHEMA_DEPTH) {
|
||||
throw new ModelVisibleSchemaError()
|
||||
}
|
||||
}
|
||||
|
||||
function isSchemaNode(value: unknown): value is boolean | Record<string, unknown> {
|
||||
return typeof value === 'boolean' || isPlainRecord(value)
|
||||
}
|
||||
|
||||
function isPublicSchemaControl(key: string, value: unknown): boolean {
|
||||
if (key === 'type') {
|
||||
if (typeof value === 'string') return SCHEMA_TYPE_NAMES.has(value)
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
value.every((item) => typeof item === 'string' && SCHEMA_TYPE_NAMES.has(item))
|
||||
)
|
||||
}
|
||||
if (SCHEMA_BOOLEAN_CONTROL_KEYS.has(key)) return typeof value === 'boolean'
|
||||
if (SCHEMA_NUMBER_CONTROL_KEYS.has(key)) {
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
}
|
||||
if (SCHEMA_NONNEGATIVE_INTEGER_CONTROL_KEYS.has(key)) {
|
||||
return typeof value === 'number' && Number.isInteger(value) && value >= 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function schemaRecordEntries(value: Record<string, unknown>): Array<[string, unknown]> {
|
||||
const keys = Reflect.ownKeys(value)
|
||||
if (keys.length > MAX_SCHEMA_NODES) throw new ModelVisibleSchemaError()
|
||||
const entries: Array<[string, unknown]> = []
|
||||
for (const key of keys) {
|
||||
if (typeof key !== 'string') throw new ModelVisibleSchemaError()
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key)
|
||||
if (!descriptor?.enumerable || !('value' in descriptor)) {
|
||||
throw new ModelVisibleSchemaError()
|
||||
}
|
||||
entries.push([key, descriptor.value])
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
function schemaArrayValues(value: unknown[]): unknown[] {
|
||||
if (Object.getPrototypeOf(value) !== Array.prototype) throw new ModelVisibleSchemaError()
|
||||
if (value.length > MAX_SCHEMA_NODES) throw new ModelVisibleSchemaError()
|
||||
const values = new Array<unknown>(value.length)
|
||||
let entries = 0
|
||||
for (const key of Reflect.ownKeys(value)) {
|
||||
if (key === 'length') continue
|
||||
if (typeof key !== 'string') throw new ModelVisibleSchemaError()
|
||||
const index = Number(key)
|
||||
if (!Number.isInteger(index) || index < 0 || index >= value.length || String(index) !== key) {
|
||||
throw new ModelVisibleSchemaError()
|
||||
}
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key)
|
||||
if (!descriptor?.enumerable || !('value' in descriptor)) {
|
||||
throw new ModelVisibleSchemaError()
|
||||
}
|
||||
values[index] = descriptor.value
|
||||
entries += 1
|
||||
}
|
||||
if (entries !== value.length) throw new ModelVisibleSchemaError()
|
||||
return values
|
||||
}
|
||||
|
||||
function schemaChildren(key: string, value: unknown): unknown[] {
|
||||
if (SCHEMA_SINGLE_CHILD_KEYS.has(key)) return [value]
|
||||
if (SCHEMA_ARRAY_CHILD_KEYS.has(key)) {
|
||||
if (!Array.isArray(value)) throw new ModelVisibleSchemaError()
|
||||
return schemaArrayValues(value)
|
||||
}
|
||||
if (SCHEMA_MAP_CHILD_KEYS.has(key)) {
|
||||
if (!isPlainRecord(value)) throw new ModelVisibleSchemaError()
|
||||
return schemaRecordEntries(value).map(([, child]) => child)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export interface ModelVisibleSchemaContent {
|
||||
projectedValues: unknown[]
|
||||
guardedValues: unknown[]
|
||||
}
|
||||
|
||||
/** Splits schema display text from semantic fields whose exact bytes must remain unchanged. */
|
||||
export function collectModelVisibleSchemaContent(schema: unknown): ModelVisibleSchemaContent {
|
||||
const projectedValues: unknown[] = []
|
||||
const guardedValues: unknown[] = []
|
||||
const state: SchemaTraversalState = { nodes: 0, ancestors: new WeakSet<object>() }
|
||||
|
||||
const visit = (candidate: unknown, depth: number): void => {
|
||||
visitSchemaNode(state, depth)
|
||||
if (typeof candidate === 'boolean') return
|
||||
if (!isPlainRecord(candidate)) throw new ModelVisibleSchemaError()
|
||||
if (state.ancestors.has(candidate)) throw new ModelVisibleSchemaError()
|
||||
|
||||
state.ancestors.add(candidate)
|
||||
try {
|
||||
for (const [key, value] of schemaRecordEntries(candidate)) {
|
||||
const action = getModelVisibleSchemaAction(undefined, key, value)
|
||||
if (action === 'project') {
|
||||
projectedValues.push(value)
|
||||
continue
|
||||
}
|
||||
if (action === 'preserve') continue
|
||||
if (action === 'verify') {
|
||||
if (
|
||||
SCHEMA_SINGLE_CHILD_KEYS.has(key) ||
|
||||
SCHEMA_ARRAY_CHILD_KEYS.has(key) ||
|
||||
SCHEMA_MAP_CHILD_KEYS.has(key)
|
||||
) {
|
||||
throw new ModelVisibleSchemaError()
|
||||
}
|
||||
if (!SCHEMA_KNOWN_KEYS.has(key)) guardedValues.push(key)
|
||||
guardedValues.push(value)
|
||||
continue
|
||||
}
|
||||
if (action !== 'traverse') continue
|
||||
if (SCHEMA_MAP_CHILD_KEYS.has(key)) {
|
||||
if (!isPlainRecord(value)) throw new ModelVisibleSchemaError()
|
||||
for (const [childKey, child] of schemaRecordEntries(value)) {
|
||||
guardedValues.push(childKey)
|
||||
visit(child, depth + 1)
|
||||
}
|
||||
continue
|
||||
}
|
||||
for (const child of schemaChildren(key, value)) visit(child, depth + 1)
|
||||
}
|
||||
} finally {
|
||||
state.ancestors.delete(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
visit(schema, 0)
|
||||
return { projectedValues, guardedValues }
|
||||
}
|
||||
|
||||
export function collectModelVisibleSchemaValues(schema: unknown): unknown[] {
|
||||
return collectModelVisibleSchemaContent(schema).projectedValues
|
||||
}
|
||||
|
||||
export function restoreModelVisibleSchemaValues(schema: unknown, projected: unknown): unknown {
|
||||
if (!Array.isArray(projected)) throw new ModelVisibleSchemaError()
|
||||
const state: SchemaTraversalState = { nodes: 0, ancestors: new WeakSet<object>() }
|
||||
let cursor = 0
|
||||
|
||||
const visit = (candidate: unknown, depth: number): unknown => {
|
||||
visitSchemaNode(state, depth)
|
||||
if (typeof candidate === 'boolean') return candidate
|
||||
if (!isPlainRecord(candidate)) throw new ModelVisibleSchemaError()
|
||||
if (state.ancestors.has(candidate)) throw new ModelVisibleSchemaError()
|
||||
|
||||
state.ancestors.add(candidate)
|
||||
try {
|
||||
let restored = candidate
|
||||
for (const [key, value] of schemaRecordEntries(candidate)) {
|
||||
const action = getModelVisibleSchemaAction(undefined, key, value)
|
||||
let nextValue = value
|
||||
if (action === 'project') {
|
||||
if (cursor >= projected.length) throw new ModelVisibleSchemaError()
|
||||
nextValue = projected[cursor]
|
||||
cursor += 1
|
||||
} else if (
|
||||
action === 'verify' &&
|
||||
(SCHEMA_SINGLE_CHILD_KEYS.has(key) ||
|
||||
SCHEMA_ARRAY_CHILD_KEYS.has(key) ||
|
||||
SCHEMA_MAP_CHILD_KEYS.has(key))
|
||||
) {
|
||||
throw new ModelVisibleSchemaError()
|
||||
} else if (action === 'traverse' || action === 'traverse-verify-key') {
|
||||
if (SCHEMA_SINGLE_CHILD_KEYS.has(key)) {
|
||||
nextValue = visit(value, depth + 1)
|
||||
} else if (SCHEMA_ARRAY_CHILD_KEYS.has(key)) {
|
||||
if (!Array.isArray(value)) throw new ModelVisibleSchemaError()
|
||||
nextValue = schemaArrayValues(value).map((child) => visit(child, depth + 1))
|
||||
} else if (SCHEMA_MAP_CHILD_KEYS.has(key)) {
|
||||
if (!isPlainRecord(value)) throw new ModelVisibleSchemaError()
|
||||
nextValue = Object.fromEntries(
|
||||
schemaRecordEntries(value).map(([childKey, child]) => [
|
||||
childKey,
|
||||
visit(child, depth + 1),
|
||||
])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (nextValue !== value) {
|
||||
if (restored === candidate) restored = { ...candidate }
|
||||
restored[key] = nextValue
|
||||
}
|
||||
}
|
||||
return restored
|
||||
} finally {
|
||||
state.ancestors.delete(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
const restored = visit(schema, 0)
|
||||
if (cursor !== projected.length) throw new ModelVisibleSchemaError()
|
||||
return restored
|
||||
}
|
||||
@@ -473,7 +473,7 @@ describe('sse-handlers tool lifecycle', () => {
|
||||
expect(updated?.result?.output).toBe('done')
|
||||
})
|
||||
|
||||
it('projects resolved Function secrets before every Copilot-visible result sink', async () => {
|
||||
it('projects resolved Function output while leaving resource metadata unchanged', async () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{
|
||||
name: 'SECRET',
|
||||
@@ -485,7 +485,9 @@ describe('sse-handlers tool lifecycle', () => {
|
||||
execContext.resolvedSecretTraceRegistry = registry
|
||||
execContext.chatId = 'chat-1'
|
||||
executeTool.mockImplementationOnce(async (_name, _params, toolContext) => {
|
||||
toolContext.resolvedSecretTraceRegistry?.recordResolved('SECRET', 'secret-value')
|
||||
toolContext.resolvedSecretTraceRegistry?.recordResolved('SECRET', 'secret-value', {
|
||||
propagated: true,
|
||||
})
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
@@ -542,12 +544,11 @@ describe('sse-handlers tool lifecycle', () => {
|
||||
resource: {
|
||||
type: 'file',
|
||||
id: 'file-1',
|
||||
title: '{{SECRET}}.txt',
|
||||
title: 'secret-value.txt',
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(JSON.stringify(completeAsyncToolCall.mock.calls)).not.toContain('secret-value')
|
||||
expect(JSON.stringify(onEvent.mock.calls)).not.toContain('secret-value')
|
||||
})
|
||||
|
||||
it('emits a structural result for a detached background workflow tool', async () => {
|
||||
@@ -1661,6 +1662,58 @@ describe('sse-handlers tool lifecycle', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards workspace secret references unchanged to the resolved integration operation', async () => {
|
||||
isSimExecuted.mockReturnValue(false)
|
||||
executeTool.mockResolvedValueOnce({ success: true, output: { searchResults: [] } })
|
||||
|
||||
await sseHandlers.tool(
|
||||
{
|
||||
type: MothershipStreamV1EventType.tool,
|
||||
payload: {
|
||||
toolCallId: 'gateway-serper',
|
||||
toolName: 'call_integration_tool',
|
||||
executor: MothershipStreamV1ToolExecutor.go,
|
||||
mode: MothershipStreamV1ToolMode.sync,
|
||||
phase: MothershipStreamV1ToolPhase.call,
|
||||
status: 'generating',
|
||||
partial: true,
|
||||
},
|
||||
} satisfies StreamEvent,
|
||||
context,
|
||||
execContext,
|
||||
{ interactive: false, timeout: 1000 }
|
||||
)
|
||||
|
||||
await sseHandlers.tool(
|
||||
{
|
||||
type: MothershipStreamV1EventType.tool,
|
||||
payload: {
|
||||
toolCallId: 'gateway-serper',
|
||||
toolName: 'serper_search',
|
||||
arguments: {
|
||||
query: 'invoice',
|
||||
apiKey: '{{SERPER_API_KEY}}',
|
||||
},
|
||||
executor: MothershipStreamV1ToolExecutor.sim,
|
||||
mode: MothershipStreamV1ToolMode.async,
|
||||
phase: MothershipStreamV1ToolPhase.call,
|
||||
},
|
||||
} satisfies StreamEvent,
|
||||
context,
|
||||
execContext,
|
||||
{ interactive: false, timeout: 1000 }
|
||||
)
|
||||
|
||||
await sleep(0)
|
||||
|
||||
expect(executeTool).toHaveBeenCalledOnce()
|
||||
expect(executeTool).toHaveBeenCalledWith(
|
||||
'serper_search',
|
||||
{ query: 'invoice', apiKey: '{{SERPER_API_KEY}}' },
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('clears pending continuation state when a run resumes', async () => {
|
||||
context.awaitingAsyncContinuation = {
|
||||
checkpointId: 'cp-1',
|
||||
|
||||
@@ -142,7 +142,7 @@ import { runCopilotLifecycle } from '@/lib/copilot/request/lifecycle/run'
|
||||
|
||||
afterAll(resetEnvFlagsMock)
|
||||
|
||||
const ARBITRARY_SCHEMA_CONTROL_KEYS = [
|
||||
const SCHEMA_CONTROL_KEYS = [
|
||||
'$schema',
|
||||
'format',
|
||||
'contentEncoding',
|
||||
@@ -279,76 +279,56 @@ describe('runCopilotLifecycle', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('projects secrets in every model-visible initial Go payload field without rewriting foreign aliases', async () => {
|
||||
it('preserves ordinary initial Go payload fields that collide with a configured secret', async () => {
|
||||
const secret = 'mothership-secret'
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'TOKEN', plaintext: secret, encryptedValue: 'ciphertext' },
|
||||
])
|
||||
registry.recordResolved('TOKEN', secret)
|
||||
const payload = {
|
||||
message: `message ${secret} __var_FOREIGN`,
|
||||
messages: [{ role: 'user', content: secret }],
|
||||
context: [{ type: 'resource', content: secret }],
|
||||
contexts: [{ type: 'mcp', content: secret }],
|
||||
workspaceContext: `workspace ${secret}`,
|
||||
integrationTools: [{ name: 'tool', description: secret }],
|
||||
mothershipTools: [{ name: 'mcp', description: '__sim_code_2_binding_0' }],
|
||||
fileAttachments: [
|
||||
{
|
||||
name: `${secret}.txt`,
|
||||
key: 'raw-storage-key',
|
||||
source: { type: 'base64', data: 'c2FmZQ==' },
|
||||
},
|
||||
],
|
||||
workspaceId: 'ws-1',
|
||||
messageId: 'stream-model-projection',
|
||||
}
|
||||
let capturedRequestBody = ''
|
||||
mockRunStreamLoop.mockImplementationOnce(async (_url: string, request: RequestInit) => {
|
||||
capturedRequestBody = String(request.body)
|
||||
})
|
||||
|
||||
await runCopilotLifecycle(
|
||||
{
|
||||
message: `message ${secret} __var_FOREIGN`,
|
||||
messages: [{ role: 'user', content: secret }],
|
||||
context: [{ type: 'resource', content: secret }],
|
||||
contexts: [{ type: 'mcp', content: secret }],
|
||||
workspaceContext: `workspace ${secret}`,
|
||||
integrationTools: [{ name: 'tool', description: secret }],
|
||||
mothershipTools: [{ name: 'mcp', description: '__sim_code_2_binding_0' }],
|
||||
fileAttachments: [
|
||||
{
|
||||
name: `${secret}.txt`,
|
||||
key: 'raw-storage-key',
|
||||
source: { type: 'base64', data: 'c2FmZQ==' },
|
||||
},
|
||||
],
|
||||
workspaceId: 'ws-1',
|
||||
messageId: 'stream-model-projection',
|
||||
},
|
||||
{
|
||||
userId: 'user-1',
|
||||
workspaceId: 'ws-1',
|
||||
executionContext: {
|
||||
userId: 'user-1',
|
||||
workflowId: '',
|
||||
workspaceId: 'ws-1',
|
||||
},
|
||||
resolvedSecretTraceRegistry: registry,
|
||||
}
|
||||
)
|
||||
|
||||
expect(capturedRequestBody).not.toContain(secret)
|
||||
expect(capturedRequestBody).toContain('__var_FOREIGN')
|
||||
expect(capturedRequestBody).toContain('__sim_code_2_binding_0')
|
||||
expect(JSON.parse(capturedRequestBody)).toMatchObject({
|
||||
message: 'message {{TOKEN}} __var_FOREIGN',
|
||||
messages: [{ role: 'user', content: '{{TOKEN}}' }],
|
||||
workspaceContext: 'workspace {{TOKEN}}',
|
||||
mothershipTools: [{ name: 'mcp', description: '__sim_code_2_binding_0' }],
|
||||
await runCopilotLifecycle(payload, {
|
||||
userId: 'user-1',
|
||||
workspaceId: 'ws-1',
|
||||
fileAttachments: [
|
||||
{
|
||||
name: '{{TOKEN}}.txt',
|
||||
key: 'raw-storage-key',
|
||||
source: { type: 'base64', data: 'c2FmZQ==' },
|
||||
},
|
||||
],
|
||||
executionContext: {
|
||||
userId: 'user-1',
|
||||
workflowId: '',
|
||||
workspaceId: 'ws-1',
|
||||
},
|
||||
resolvedSecretTraceRegistry: registry,
|
||||
})
|
||||
|
||||
const { enterpriseByokEligible, ...sent } = JSON.parse(capturedRequestBody)
|
||||
expect(enterpriseByokEligible).toBe(false)
|
||||
expect(sent).toEqual(payload)
|
||||
})
|
||||
|
||||
it('projects large tool catalogs at the tool-definition boundary', async () => {
|
||||
it('preserves large ordinary tool catalogs without scanning configured secret values', async () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'TOKEN', plaintext: 'catalog-secret', encryptedValue: 'ciphertext' },
|
||||
])
|
||||
registry.recordResolved('TOKEN', 'catalog-secret')
|
||||
const toolCount = 4_000
|
||||
const propertiesPerTool = 8
|
||||
// These definitions are individually small, but flattening their semantic fields into one
|
||||
// synthetic projection value creates 108,001 traversal nodes and crosses the per-value budget.
|
||||
const integrationTools = Array.from({ length: toolCount }, (_, toolIndex) => {
|
||||
const properties = Object.fromEntries(
|
||||
Array.from({ length: propertiesPerTool }, (_, propertyIndex) => [
|
||||
@@ -392,17 +372,13 @@ describe('runCopilotLifecycle', () => {
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockRunStreamLoop).toHaveBeenCalledOnce()
|
||||
const sent = JSON.parse(capturedRequestBody)
|
||||
expect(sent.integrationTools).toHaveLength(integrationTools.length)
|
||||
expect(sent.integrationTools[0].description).toBe('Tool 0 uses {{TOKEN}}')
|
||||
expect(sent.integrationTools.at(-1).name).toBe(`tool_${toolCount - 1}`)
|
||||
expect(capturedRequestBody).not.toContain('catalog-secret')
|
||||
expect(sent.integrationTools).toEqual(integrationTools)
|
||||
})
|
||||
|
||||
it('projects selected JSON and attachment fields exactly once when plaintext overlaps its alias', async () => {
|
||||
it('preserves ordinary JSON and attachment fields when plaintext overlaps its alias', async () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'TOKEN', plaintext: 'TOKEN', encryptedValue: 'ciphertext' },
|
||||
])
|
||||
registry.recordResolved('TOKEN', 'TOKEN')
|
||||
let capturedRequestBody = ''
|
||||
mockRunStreamLoop.mockImplementationOnce(async (_url: string, request: RequestInit) => {
|
||||
capturedRequestBody = String(request.body)
|
||||
@@ -449,25 +425,23 @@ describe('runCopilotLifecycle', () => {
|
||||
)
|
||||
|
||||
const sent = JSON.parse(capturedRequestBody)
|
||||
expect(sent.message).toBe('{{TOKEN}}')
|
||||
expect(sent.message).toBe('TOKEN')
|
||||
expect(sent.messages[0]).toMatchObject({
|
||||
content: '{{TOKEN}}',
|
||||
function_call: { arguments: JSON.stringify({ value: '{{TOKEN}}' }) },
|
||||
content: 'TOKEN',
|
||||
function_call: { arguments: JSON.stringify({ value: 'TOKEN' }) },
|
||||
tool_calls: [
|
||||
{
|
||||
function: { arguments: JSON.stringify({ value: '{{TOKEN}}' }) },
|
||||
function: { arguments: JSON.stringify({ value: 'TOKEN' }) },
|
||||
},
|
||||
],
|
||||
files: [
|
||||
{
|
||||
name: '{{TOKEN}}.txt',
|
||||
context: 'Context {{TOKEN}}',
|
||||
name: 'TOKEN.txt',
|
||||
context: 'Context TOKEN',
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(sent.fileAttachments).toEqual([{ name: '{{TOKEN}}.txt', key: 'safe-key' }])
|
||||
expect(capturedRequestBody.replaceAll('{{TOKEN}}', '')).not.toContain('TOKEN')
|
||||
expect(capturedRequestBody).not.toContain('{{{{TOKEN}}}}')
|
||||
expect(sent.fileAttachments).toEqual([{ name: 'TOKEN.txt', key: 'safe-key' }])
|
||||
})
|
||||
|
||||
it('omits only unsafe durable attachments before the initial Go request', async () => {
|
||||
@@ -521,375 +495,242 @@ describe('runCopilotLifecycle', () => {
|
||||
})
|
||||
|
||||
it.each(['123', 'true'])(
|
||||
'keeps low-entropy Copilot JSON valid while separating content from controls (%s)',
|
||||
'preserves low-entropy configured-secret collisions across Copilot JSON (%s)',
|
||||
async (secret) => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'TOKEN', plaintext: secret, encryptedValue: 'ciphertext' },
|
||||
])
|
||||
registry.recordResolved('TOKEN', secret)
|
||||
const converted = secret === '123' ? 123 : true
|
||||
let capturedRequestBody = ''
|
||||
mockRunStreamLoop.mockImplementationOnce(async (_url: string, request: RequestInit) => {
|
||||
capturedRequestBody = String(request.body)
|
||||
})
|
||||
|
||||
await runCopilotLifecycle(
|
||||
{
|
||||
message: `Message ${secret}`,
|
||||
messages: [
|
||||
{
|
||||
id: secret,
|
||||
role: secret,
|
||||
name: 'assistant-safe',
|
||||
content: `Transcript ${secret}`,
|
||||
function_call: {
|
||||
name: 'legacy-safe',
|
||||
arguments: JSON.stringify({ value: secret, converted }),
|
||||
const payload = {
|
||||
message: `Message ${secret}`,
|
||||
messages: [
|
||||
{
|
||||
id: secret,
|
||||
role: secret,
|
||||
name: 'assistant-safe',
|
||||
content: `Transcript ${secret}`,
|
||||
function_call: {
|
||||
name: 'legacy-safe',
|
||||
arguments: JSON.stringify({ value: secret, converted }),
|
||||
},
|
||||
tool_calls: [
|
||||
{
|
||||
id: secret,
|
||||
type: secret,
|
||||
function: {
|
||||
name: 'tool-safe',
|
||||
arguments: JSON.stringify({ value: secret, converted }),
|
||||
},
|
||||
},
|
||||
tool_calls: [
|
||||
{
|
||||
],
|
||||
fileAttachments: [
|
||||
{
|
||||
id: secret,
|
||||
key: secret,
|
||||
filename: `${secret}.txt`,
|
||||
media_type: secret,
|
||||
},
|
||||
],
|
||||
contexts: [{ kind: secret, label: `Label ${secret}`, serverId: secret }],
|
||||
contentBlocks: [
|
||||
{
|
||||
type: secret,
|
||||
content: `Block ${secret}`,
|
||||
toolCall: {
|
||||
id: secret,
|
||||
type: secret,
|
||||
function: {
|
||||
name: 'tool-safe',
|
||||
arguments: JSON.stringify({ value: secret, converted }),
|
||||
},
|
||||
name: 'nested-tool-safe',
|
||||
state: secret,
|
||||
params: { value: secret },
|
||||
result: { success: true, output: { value: secret, converted } },
|
||||
display: { title: `Title ${secret}` },
|
||||
},
|
||||
],
|
||||
fileAttachments: [
|
||||
{
|
||||
id: secret,
|
||||
key: secret,
|
||||
filename: `${secret}.txt`,
|
||||
media_type: secret,
|
||||
},
|
||||
],
|
||||
contexts: [{ kind: secret, label: `Label ${secret}`, serverId: secret }],
|
||||
contentBlocks: [
|
||||
{
|
||||
type: secret,
|
||||
content: `Block ${secret}`,
|
||||
toolCall: {
|
||||
id: secret,
|
||||
name: 'nested-tool-safe',
|
||||
state: secret,
|
||||
params: { value: secret },
|
||||
result: { success: true, output: { value: secret, converted } },
|
||||
display: { title: `Title ${secret}` },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
context: [
|
||||
{ type: secret, tag: secret, path: secret, content: `Unsafe ${secret}` },
|
||||
{
|
||||
type: secret,
|
||||
tag: secret,
|
||||
path: 'files/safe.txt',
|
||||
content: `Context ${secret}`,
|
||||
},
|
||||
],
|
||||
contexts: [
|
||||
{
|
||||
kind: secret,
|
||||
serverId: secret,
|
||||
label: `Context label ${secret}`,
|
||||
},
|
||||
],
|
||||
integrationTools: [
|
||||
{
|
||||
name: 'safe_tool',
|
||||
description: `Description ${secret}`,
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
value: {
|
||||
type: 'string',
|
||||
title: `Title ${secret}`,
|
||||
description: `Field ${secret}`,
|
||||
enum: ['public'],
|
||||
},
|
||||
},
|
||||
required: ['value'],
|
||||
},
|
||||
params: { runtimeControl: secret },
|
||||
service: secret,
|
||||
operation: secret,
|
||||
oauth: { required: true, provider: secret },
|
||||
},
|
||||
{
|
||||
name: 'unsafe_schema_tool',
|
||||
description: 'Unsafe schema',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: { [secret]: { type: 'string' } },
|
||||
required: [secret],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: secret,
|
||||
description: 'Unsafe name',
|
||||
input_schema: { type: 'object', properties: {}, required: [] },
|
||||
},
|
||||
],
|
||||
responseFormat: {
|
||||
name: 'safe_response',
|
||||
schema: {
|
||||
],
|
||||
},
|
||||
],
|
||||
context: [
|
||||
{ type: secret, tag: secret, path: secret, content: `Unsafe ${secret}` },
|
||||
{
|
||||
type: secret,
|
||||
tag: secret,
|
||||
path: 'files/safe.txt',
|
||||
content: `Context ${secret}`,
|
||||
},
|
||||
],
|
||||
contexts: [
|
||||
{
|
||||
kind: secret,
|
||||
serverId: secret,
|
||||
label: `Context label ${secret}`,
|
||||
},
|
||||
],
|
||||
integrationTools: [
|
||||
{
|
||||
name: 'safe_tool',
|
||||
description: `Description ${secret}`,
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
value: {
|
||||
type: 'string',
|
||||
description: `Result ${secret}`,
|
||||
title: `Title ${secret}`,
|
||||
description: `Field ${secret}`,
|
||||
enum: ['public'],
|
||||
},
|
||||
},
|
||||
required: ['value'],
|
||||
},
|
||||
params: { runtimeControl: secret },
|
||||
service: secret,
|
||||
operation: secret,
|
||||
oauth: { required: true, provider: secret },
|
||||
},
|
||||
fileAttachments: [
|
||||
{
|
||||
id: secret,
|
||||
name: `${secret}.txt`,
|
||||
key: secret,
|
||||
mimeType: secret,
|
||||
},
|
||||
],
|
||||
vfs: {
|
||||
workspace: { id: secret, ownerId: secret, name: `Workspace ${secret}` },
|
||||
files: [
|
||||
{
|
||||
id: secret,
|
||||
path: secret,
|
||||
folderPath: secret,
|
||||
type: secret,
|
||||
name: `File ${secret}`,
|
||||
},
|
||||
{
|
||||
id: 'safe-file-id',
|
||||
path: 'files/safe.txt',
|
||||
folderPath: 'files',
|
||||
type: 'text/plain',
|
||||
name: `Safe ${secret}`,
|
||||
},
|
||||
],
|
||||
mcpServers: [
|
||||
{ id: secret, name: `Unsafe ${secret}`, url: `https://${secret}.example` },
|
||||
{
|
||||
id: 'safe-mcp-id',
|
||||
name: `Safe MCP ${secret}`,
|
||||
url: 'https://mcp.example',
|
||||
},
|
||||
],
|
||||
},
|
||||
userTimezone: secret,
|
||||
userMetadata: {
|
||||
name: `User ${secret}`,
|
||||
email: `owner+${secret}@example.com`,
|
||||
timezone: secret,
|
||||
},
|
||||
desktopCapabilities: {
|
||||
terminal: true,
|
||||
terminals: [
|
||||
{
|
||||
id: secret,
|
||||
cwd: `/workspace/${secret}`,
|
||||
running: `command ${secret}`,
|
||||
active: true,
|
||||
},
|
||||
{
|
||||
id: 'safe-terminal-id',
|
||||
cwd: '/workspace/safe',
|
||||
running: `safe command ${secret}`,
|
||||
active: true,
|
||||
},
|
||||
],
|
||||
browser: true,
|
||||
browserSessions: [
|
||||
{ hostname: secret, evidence: 'cookies', lastObservedAt: '2026-01-01T00:00:00.000Z' },
|
||||
{
|
||||
hostname: 'safe.example',
|
||||
evidence: 'sign-in-completed',
|
||||
lastObservedAt: '2026-02-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
workspaceId: 'ws-1',
|
||||
messageId: `stream-low-entropy-${secret}`,
|
||||
},
|
||||
{
|
||||
userId: 'user-1',
|
||||
workspaceId: 'ws-1',
|
||||
executionContext: {
|
||||
userId: 'user-1',
|
||||
workflowId: '',
|
||||
workspaceId: 'ws-1',
|
||||
},
|
||||
resolvedSecretTraceRegistry: registry,
|
||||
}
|
||||
)
|
||||
|
||||
const sent = JSON.parse(capturedRequestBody)
|
||||
expect(sent.messages[0]).toMatchObject({
|
||||
id: secret,
|
||||
role: secret,
|
||||
name: 'assistant-safe',
|
||||
content: 'Transcript {{TOKEN}}',
|
||||
function_call: {
|
||||
name: 'legacy-safe',
|
||||
},
|
||||
tool_calls: [
|
||||
{
|
||||
id: secret,
|
||||
type: secret,
|
||||
function: {
|
||||
name: 'tool-safe',
|
||||
name: 'unsafe_schema_tool',
|
||||
description: 'Unsafe schema',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: { [secret]: { type: 'string' } },
|
||||
required: [secret],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: secret,
|
||||
description: 'Unsafe name',
|
||||
input_schema: { type: 'object', properties: {}, required: [] },
|
||||
},
|
||||
],
|
||||
responseFormat: {
|
||||
name: 'safe_response',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
value: {
|
||||
type: 'string',
|
||||
description: `Result ${secret}`,
|
||||
enum: ['public'],
|
||||
},
|
||||
},
|
||||
required: ['value'],
|
||||
},
|
||||
},
|
||||
fileAttachments: [
|
||||
{
|
||||
id: secret,
|
||||
name: `${secret}.txt`,
|
||||
key: secret,
|
||||
filename: '{{TOKEN}}.txt',
|
||||
media_type: secret,
|
||||
mimeType: secret,
|
||||
},
|
||||
],
|
||||
contexts: [{ kind: secret, label: 'Label {{TOKEN}}', serverId: secret }],
|
||||
contentBlocks: [
|
||||
{
|
||||
type: secret,
|
||||
content: 'Block {{TOKEN}}',
|
||||
toolCall: {
|
||||
vfs: {
|
||||
workspace: { id: secret, ownerId: secret, name: `Workspace ${secret}` },
|
||||
files: [
|
||||
{
|
||||
id: secret,
|
||||
name: 'nested-tool-safe',
|
||||
state: secret,
|
||||
params: { value: '{{TOKEN}}' },
|
||||
result: {
|
||||
success: true,
|
||||
output: { value: '{{TOKEN}}', converted: '{{TOKEN}}' },
|
||||
},
|
||||
display: { title: 'Title {{TOKEN}}' },
|
||||
path: secret,
|
||||
folderPath: secret,
|
||||
type: secret,
|
||||
name: `File ${secret}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(JSON.parse(sent.messages[0].function_call.arguments)).toEqual({
|
||||
value: '{{TOKEN}}',
|
||||
converted: '{{TOKEN}}',
|
||||
})
|
||||
expect(JSON.parse(sent.messages[0].tool_calls[0].function.arguments)).toEqual({
|
||||
value: '{{TOKEN}}',
|
||||
converted: '{{TOKEN}}',
|
||||
})
|
||||
expect(sent.context).toEqual([
|
||||
{
|
||||
type: secret,
|
||||
tag: '{{TOKEN}}',
|
||||
path: 'files/safe.txt',
|
||||
content: 'Context {{TOKEN}}',
|
||||
},
|
||||
])
|
||||
expect(sent.contexts).toEqual([
|
||||
{
|
||||
kind: secret,
|
||||
serverId: secret,
|
||||
label: 'Context label {{TOKEN}}',
|
||||
},
|
||||
])
|
||||
expect(sent.integrationTools).toHaveLength(1)
|
||||
expect(sent.integrationTools[0]).toMatchObject({
|
||||
name: 'safe_tool',
|
||||
description: 'Description {{TOKEN}}',
|
||||
input_schema: {
|
||||
properties: {
|
||||
value: {
|
||||
title: 'Title {{TOKEN}}',
|
||||
description: 'Field {{TOKEN}}',
|
||||
enum: ['public'],
|
||||
{
|
||||
id: 'safe-file-id',
|
||||
path: 'files/safe.txt',
|
||||
folderPath: 'files',
|
||||
type: 'text/plain',
|
||||
name: `Safe ${secret}`,
|
||||
},
|
||||
},
|
||||
required: ['value'],
|
||||
],
|
||||
mcpServers: [
|
||||
{ id: secret, name: `Unsafe ${secret}`, url: `https://${secret}.example` },
|
||||
{
|
||||
id: 'safe-mcp-id',
|
||||
name: `Safe MCP ${secret}`,
|
||||
url: 'https://mcp.example',
|
||||
},
|
||||
],
|
||||
},
|
||||
params: { runtimeControl: secret },
|
||||
service: secret,
|
||||
operation: secret,
|
||||
oauth: { required: true, provider: secret },
|
||||
})
|
||||
expect(sent.responseFormat).toMatchObject({
|
||||
name: 'safe_response',
|
||||
schema: {
|
||||
properties: {
|
||||
value: { description: 'Result {{TOKEN}}', enum: ['public'] },
|
||||
},
|
||||
required: ['value'],
|
||||
userTimezone: secret,
|
||||
userMetadata: {
|
||||
name: `User ${secret}`,
|
||||
email: `owner+${secret}@example.com`,
|
||||
timezone: secret,
|
||||
},
|
||||
desktopCapabilities: {
|
||||
terminal: true,
|
||||
terminals: [
|
||||
{
|
||||
id: secret,
|
||||
cwd: `/workspace/${secret}`,
|
||||
running: `command ${secret}`,
|
||||
active: true,
|
||||
},
|
||||
{
|
||||
id: 'safe-terminal-id',
|
||||
cwd: '/workspace/safe',
|
||||
running: `safe command ${secret}`,
|
||||
active: true,
|
||||
},
|
||||
],
|
||||
browser: true,
|
||||
browserSessions: [
|
||||
{ hostname: secret, evidence: 'cookies', lastObservedAt: '2026-01-01T00:00:00.000Z' },
|
||||
{
|
||||
hostname: 'safe.example',
|
||||
evidence: 'sign-in-completed',
|
||||
lastObservedAt: '2026-02-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
workspaceId: 'ws-1',
|
||||
messageId: `stream-low-entropy-${secret}`,
|
||||
}
|
||||
|
||||
await runCopilotLifecycle(payload, {
|
||||
userId: 'user-1',
|
||||
workspaceId: 'ws-1',
|
||||
executionContext: {
|
||||
userId: 'user-1',
|
||||
workflowId: '',
|
||||
workspaceId: 'ws-1',
|
||||
},
|
||||
resolvedSecretTraceRegistry: registry,
|
||||
})
|
||||
expect(sent.fileAttachments[0]).toEqual({
|
||||
id: secret,
|
||||
name: '{{TOKEN}}.txt',
|
||||
key: secret,
|
||||
mimeType: secret,
|
||||
})
|
||||
expect(sent.vfs).toEqual({
|
||||
workspace: { id: secret, ownerId: secret, name: 'Workspace {{TOKEN}}' },
|
||||
files: [
|
||||
{
|
||||
id: 'safe-file-id',
|
||||
path: 'files/safe.txt',
|
||||
folderPath: 'files',
|
||||
type: 'text/plain',
|
||||
name: 'Safe {{TOKEN}}',
|
||||
},
|
||||
],
|
||||
mcpServers: [
|
||||
{
|
||||
id: 'safe-mcp-id',
|
||||
name: 'Safe MCP {{TOKEN}}',
|
||||
url: 'https://mcp.example',
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(sent).not.toHaveProperty('userTimezone')
|
||||
expect(sent.userMetadata).toEqual({
|
||||
name: 'User {{TOKEN}}',
|
||||
email: 'owner+{{TOKEN}}@example.com',
|
||||
})
|
||||
expect(sent.desktopCapabilities).toEqual({
|
||||
terminal: true,
|
||||
terminals: [
|
||||
{
|
||||
id: 'safe-terminal-id',
|
||||
cwd: '/workspace/safe',
|
||||
running: 'safe command {{TOKEN}}',
|
||||
active: true,
|
||||
},
|
||||
],
|
||||
browser: true,
|
||||
browserSessions: [
|
||||
{
|
||||
hostname: 'safe.example',
|
||||
evidence: 'sign-in-completed',
|
||||
lastObservedAt: '2026-02-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const { enterpriseByokEligible, ...sent } = JSON.parse(capturedRequestBody)
|
||||
expect(enterpriseByokEligible).toBe(false)
|
||||
expect(sent).toEqual(payload)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(ARBITRARY_SCHEMA_CONTROL_KEYS)(
|
||||
'guards arbitrary %s schema controls before initial Copilot model egress',
|
||||
it.each(SCHEMA_CONTROL_KEYS)(
|
||||
'preserves ordinary %s schema controls without scanning configured secret values',
|
||||
async (controlKey) => {
|
||||
const secret = `copilot-schema-control-secret-${controlKey}`
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'TOKEN', plaintext: secret, encryptedValue: 'ciphertext' },
|
||||
])
|
||||
registry.recordResolved('TOKEN', secret)
|
||||
const unsafeSchema = {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
[controlKey]: secret,
|
||||
}
|
||||
const integrationTools = [
|
||||
{
|
||||
name: 'schema_tool',
|
||||
description: 'Schema with an ordinary configured-secret collision',
|
||||
input_schema: schema,
|
||||
},
|
||||
{
|
||||
name: 'safe_tool',
|
||||
description: 'Safe schema',
|
||||
input_schema: { type: 'object', properties: {} },
|
||||
},
|
||||
]
|
||||
let capturedRequestBody = ''
|
||||
mockRunStreamLoop.mockImplementationOnce(async (_url: string, request: RequestInit) => {
|
||||
capturedRequestBody = String(request.body)
|
||||
@@ -899,18 +740,7 @@ describe('runCopilotLifecycle', () => {
|
||||
{
|
||||
message: 'Use a safe tool',
|
||||
messageId: `stream-schema-tool-${controlKey}`,
|
||||
integrationTools: [
|
||||
{
|
||||
name: 'unsafe_tool',
|
||||
description: 'Unsafe schema control',
|
||||
input_schema: unsafeSchema,
|
||||
},
|
||||
{
|
||||
name: 'safe_tool',
|
||||
description: 'Safe schema',
|
||||
input_schema: { type: 'object', properties: {} },
|
||||
},
|
||||
],
|
||||
integrationTools,
|
||||
},
|
||||
{
|
||||
userId: 'user-1',
|
||||
@@ -920,9 +750,7 @@ describe('runCopilotLifecycle', () => {
|
||||
}
|
||||
)
|
||||
|
||||
expect(JSON.parse(capturedRequestBody).integrationTools).toEqual([
|
||||
expect.objectContaining({ name: 'safe_tool' }),
|
||||
])
|
||||
expect(JSON.parse(capturedRequestBody).integrationTools).toEqual(integrationTools)
|
||||
|
||||
mockRunStreamLoop.mockClear()
|
||||
mockRunStreamLoop.mockImplementationOnce(async (_url: string, request: RequestInit) => {
|
||||
@@ -932,7 +760,7 @@ describe('runCopilotLifecycle', () => {
|
||||
{
|
||||
message: 'Use a response schema',
|
||||
messageId: `stream-schema-response-${controlKey}`,
|
||||
responseFormat: { name: 'unsafe_response', schema: unsafeSchema },
|
||||
responseFormat: { name: 'ordinary_response', schema },
|
||||
},
|
||||
{
|
||||
userId: 'user-1',
|
||||
@@ -943,13 +771,15 @@ describe('runCopilotLifecycle', () => {
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(JSON.parse(capturedRequestBody)).not.toHaveProperty('responseFormat')
|
||||
expect(capturedRequestBody).not.toContain(secret)
|
||||
expect(JSON.parse(capturedRequestBody).responseFormat).toEqual({
|
||||
name: 'ordinary_response',
|
||||
schema,
|
||||
})
|
||||
expect(mockRunStreamLoop).toHaveBeenCalledOnce()
|
||||
}
|
||||
)
|
||||
|
||||
it('omits malformed and oversized optional Copilot response schemas', async () => {
|
||||
it('does not couple optional Copilot response schemas to secret provenance', async () => {
|
||||
const registry = new ResolvedSecretTraceRegistry()
|
||||
for (const [index, schema] of [
|
||||
{ properties: { field: 'not-a-schema' } },
|
||||
@@ -976,7 +806,9 @@ describe('runCopilotLifecycle', () => {
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(JSON.parse(capturedRequestBody)).not.toHaveProperty('responseFormat')
|
||||
expect(JSON.stringify(JSON.parse(capturedRequestBody).responseFormat)).toBe(
|
||||
JSON.stringify({ name: 'unsafe_response', schema })
|
||||
)
|
||||
expect(mockRunStreamLoop).toHaveBeenCalledOnce()
|
||||
}
|
||||
})
|
||||
@@ -1082,7 +914,7 @@ describe('runCopilotLifecycle', () => {
|
||||
expect(JSON.parse(capturedRequestBody).responseFormat.schema).toEqual(schema)
|
||||
})
|
||||
|
||||
it('fails before the initial Go request when model projection is incomplete', async () => {
|
||||
it('does not block ordinary initial Go payloads on unrelated incomplete provenance', async () => {
|
||||
const registry = new ResolvedSecretTraceRegistry()
|
||||
registry.markIncomplete()
|
||||
|
||||
@@ -1100,11 +932,12 @@ describe('runCopilotLifecycle', () => {
|
||||
}
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
error: 'Copilot model input could not be safely projected',
|
||||
expect(result.success).toBe(true)
|
||||
expect(JSON.parse(String(mockRunStreamLoop.mock.calls[0]?.[1].body))).toMatchObject({
|
||||
message: 'possibly secret',
|
||||
messageId: 'stream-incomplete-projection',
|
||||
})
|
||||
expect(mockRunStreamLoop).not.toHaveBeenCalled()
|
||||
expect(mockRunStreamLoop).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
describe('tool permission feature flag', () => {
|
||||
@@ -1526,11 +1359,10 @@ describe('runCopilotLifecycle', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('fails closed instead of sending a secret-bearing tool name on resume', async () => {
|
||||
it('preserves a resume tool name that collides with a configured secret', async () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'TOKEN', plaintext: 'unsafe-tool', encryptedValue: 'ciphertext' },
|
||||
])
|
||||
registry.recordResolved('TOKEN', 'unsafe-tool')
|
||||
mockRunStreamLoop.mockImplementationOnce(
|
||||
async (
|
||||
_fetchUrl: string,
|
||||
@@ -1559,11 +1391,11 @@ describe('runCopilotLifecycle', () => {
|
||||
}
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
error: 'Copilot model input could not be safely projected',
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockRunStreamLoop).toHaveBeenCalledTimes(2)
|
||||
expect(JSON.parse(String(mockRunStreamLoop.mock.calls[1]?.[1].body))).toMatchObject({
|
||||
results: [{ callId: 'tool-1', name: 'unsafe-tool', success: true }],
|
||||
})
|
||||
expect(mockRunStreamLoop).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('runs legacy-v0 during Sim-first deployment without guessed billing aliases', async () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { PermissionType } from '@sim/platform-authz/workspace'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { sleep } from '@sim/utils/helpers'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { isPlainRecord, omit } from '@sim/utils/object'
|
||||
import { omit } from '@sim/utils/object'
|
||||
import {
|
||||
type AttributedBillingRequestEnvelope,
|
||||
assertBillingAttributionSnapshot,
|
||||
@@ -28,20 +28,6 @@ import {
|
||||
MothershipStreamV1RunKind,
|
||||
MothershipStreamV1ToolOutcome,
|
||||
} from '@/lib/copilot/generated/mothership-stream-v1'
|
||||
import {
|
||||
COPILOT_CONTEXT_MODEL_TEXT_KEYS,
|
||||
COPILOT_CONTEXT_ROUTING_KEYS,
|
||||
COPILOT_DESKTOP_MODEL_TEXT_KEYS,
|
||||
COPILOT_MESSAGE_DISPLAY_KEYS,
|
||||
COPILOT_USER_METADATA_MODEL_TEXT_KEYS,
|
||||
COPILOT_VFS_MODEL_TEXT_KEYS,
|
||||
COPILOT_VFS_ROUTING_KEYS,
|
||||
isCopilotModelTextKey,
|
||||
} from '@/lib/copilot/model-visible-content'
|
||||
import {
|
||||
collectModelVisibleSchemaContent,
|
||||
getModelVisibleSchemaAction,
|
||||
} from '@/lib/copilot/model-visible-schema'
|
||||
import { getAutoAllowedTools } from '@/lib/copilot/persistence/tool-permission/auto-allow'
|
||||
import { createStreamingContext } from '@/lib/copilot/request/context/request-context'
|
||||
import { buildToolCallSummaries } from '@/lib/copilot/request/context/result'
|
||||
@@ -83,63 +69,17 @@ import {
|
||||
isHosted,
|
||||
} from '@/lib/core/config/env-flags'
|
||||
import { filterModelSafeWorkspaceFileAttachments } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance'
|
||||
import {
|
||||
isResolvedSecretModelContentUnchanged,
|
||||
projectResolvedSecretModelContent,
|
||||
projectResolvedSecretModelJsonStrings,
|
||||
} from '@/executor/utils/resolved-secret-content-projection'
|
||||
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
|
||||
|
||||
const logger = createLogger('CopilotLifecycle')
|
||||
|
||||
const MAX_RESUME_ATTEMPTS = 3
|
||||
const RESUME_BACKOFF_MS = [250, 500, 1000] as const
|
||||
const MAX_SELECTED_CONTENT_NODES = 100_000
|
||||
const MAX_SELECTED_CONTENT_DEPTH = 100
|
||||
const SIMPLE_MODEL_CONTENT_KEYS = [
|
||||
'message',
|
||||
'systemPrompt',
|
||||
'workspaceContext',
|
||||
'commands',
|
||||
'implicitFeedback',
|
||||
'workflowName',
|
||||
] as const
|
||||
const TOOL_PAYLOAD_KEYS = ['tools', 'integrationTools', 'mothershipTools'] as const
|
||||
const TOOL_SCHEMA_KEYS = new Set(['input_schema', 'parameters', 'outputs'])
|
||||
const MOTHERSHIP_CODE_TOOL_ROUTES = new Set([
|
||||
'/api/copilot',
|
||||
'/api/mothership',
|
||||
'/api/mothership/execute',
|
||||
])
|
||||
const MESSAGE_CONTAINER_KEYS = new Set([
|
||||
'contentBlocks',
|
||||
'contexts',
|
||||
'display',
|
||||
'fileAttachments',
|
||||
'files',
|
||||
'function',
|
||||
'function_call',
|
||||
'result',
|
||||
'toolCall',
|
||||
'tool_calls',
|
||||
])
|
||||
const MESSAGE_OPAQUE_CONTENT_KEYS = new Set(['error', 'output', 'params'])
|
||||
const ATTACHMENT_PARENT_KEYS = new Set(['attachments', 'fileAttachments', 'files'])
|
||||
const MESSAGE_HANDLE_PARENT_KEYS = new Set(['function', 'function_call', 'toolCall'])
|
||||
|
||||
type SelectedContentAction =
|
||||
| 'preserve'
|
||||
| 'project'
|
||||
| 'project-json'
|
||||
| 'traverse'
|
||||
| 'traverse-verify-key'
|
||||
| 'verify-key-value'
|
||||
| 'verify'
|
||||
type SelectedContentSelector = (
|
||||
path: readonly string[],
|
||||
key: string,
|
||||
value: unknown
|
||||
) => SelectedContentAction
|
||||
|
||||
class CopilotModelContentProjectionError extends Error {
|
||||
constructor() {
|
||||
@@ -148,451 +88,6 @@ class CopilotModelContentProjectionError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
interface SelectedContentTraversalState {
|
||||
nodes: number
|
||||
ancestors: WeakSet<object>
|
||||
}
|
||||
|
||||
interface SelectedContentBuckets {
|
||||
projected: unknown[]
|
||||
jsonStrings: string[]
|
||||
guarded: unknown[]
|
||||
}
|
||||
|
||||
function visitSelectedContentNode(state: SelectedContentTraversalState, depth: number): void {
|
||||
state.nodes += 1
|
||||
if (state.nodes > MAX_SELECTED_CONTENT_NODES || depth > MAX_SELECTED_CONTENT_DEPTH) {
|
||||
throw new CopilotModelContentProjectionError()
|
||||
}
|
||||
}
|
||||
|
||||
function projectModelContent(value: unknown, registry: ResolvedSecretTraceRegistry): unknown {
|
||||
const projection = projectResolvedSecretModelContent(value, registry)
|
||||
if (!projection.safe) throw new CopilotModelContentProjectionError()
|
||||
return projection.value
|
||||
}
|
||||
|
||||
function collectSelectedContent(
|
||||
value: unknown,
|
||||
selector: SelectedContentSelector,
|
||||
selected: SelectedContentBuckets,
|
||||
path: readonly string[] = [],
|
||||
state: SelectedContentTraversalState = { nodes: 0, ancestors: new WeakSet<object>() },
|
||||
depth = 0
|
||||
): void {
|
||||
visitSelectedContentNode(state, depth)
|
||||
if (value === null || typeof value !== 'object') return
|
||||
if (state.ancestors.has(value)) throw new CopilotModelContentProjectionError()
|
||||
|
||||
state.ancestors.add(value)
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
collectSelectedContent(item, selector, selected, [...path, '*'], state, depth + 1)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!isPlainRecord(value)) throw new CopilotModelContentProjectionError()
|
||||
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
const action = selector(path, key, item)
|
||||
if (action === 'project') {
|
||||
selected.projected.push(item)
|
||||
} else if (action === 'project-json') {
|
||||
if (typeof item !== 'string') throw new CopilotModelContentProjectionError()
|
||||
selected.jsonStrings.push(item)
|
||||
} else if (action === 'verify') {
|
||||
selected.guarded.push(item)
|
||||
} else if (action === 'verify-key-value') {
|
||||
selected.guarded.push(key, item)
|
||||
} else if (action === 'traverse' || action === 'traverse-verify-key') {
|
||||
if (action === 'traverse-verify-key') selected.guarded.push(key)
|
||||
collectSelectedContent(item, selector, selected, [...path, key], state, depth + 1)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
state.ancestors.delete(value)
|
||||
}
|
||||
}
|
||||
|
||||
function restoreSelectedContent(
|
||||
value: unknown,
|
||||
selector: SelectedContentSelector,
|
||||
projected: readonly unknown[],
|
||||
projectedJsonStrings: readonly string[],
|
||||
cursor: { projected: number; jsonStrings: number },
|
||||
path: readonly string[] = [],
|
||||
state: SelectedContentTraversalState = { nodes: 0, ancestors: new WeakSet<object>() },
|
||||
depth = 0
|
||||
): unknown {
|
||||
visitSelectedContentNode(state, depth)
|
||||
if (value === null || typeof value !== 'object') return value
|
||||
if (state.ancestors.has(value)) throw new CopilotModelContentProjectionError()
|
||||
|
||||
state.ancestors.add(value)
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) =>
|
||||
restoreSelectedContent(
|
||||
item,
|
||||
selector,
|
||||
projected,
|
||||
projectedJsonStrings,
|
||||
cursor,
|
||||
[...path, '*'],
|
||||
state,
|
||||
depth + 1
|
||||
)
|
||||
)
|
||||
}
|
||||
if (!isPlainRecord(value)) throw new CopilotModelContentProjectionError()
|
||||
|
||||
const restored: Record<string, unknown> = { ...value }
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
const action = selector(path, key, item)
|
||||
if (action === 'project') {
|
||||
if (cursor.projected >= projected.length) throw new CopilotModelContentProjectionError()
|
||||
restored[key] = projected[cursor.projected]
|
||||
cursor.projected += 1
|
||||
} else if (action === 'project-json') {
|
||||
if (cursor.jsonStrings >= projectedJsonStrings.length) {
|
||||
throw new CopilotModelContentProjectionError()
|
||||
}
|
||||
restored[key] = projectedJsonStrings[cursor.jsonStrings]
|
||||
cursor.jsonStrings += 1
|
||||
} else if (action === 'traverse' || action === 'traverse-verify-key') {
|
||||
restored[key] = restoreSelectedContent(
|
||||
item,
|
||||
selector,
|
||||
projected,
|
||||
projectedJsonStrings,
|
||||
cursor,
|
||||
[...path, key],
|
||||
state,
|
||||
depth + 1
|
||||
)
|
||||
}
|
||||
}
|
||||
return restored
|
||||
} finally {
|
||||
state.ancestors.delete(value)
|
||||
}
|
||||
}
|
||||
|
||||
function projectSelectedContent(
|
||||
value: unknown,
|
||||
registry: ResolvedSecretTraceRegistry,
|
||||
selector: SelectedContentSelector
|
||||
): unknown {
|
||||
const selected: SelectedContentBuckets = { projected: [], jsonStrings: [], guarded: [] }
|
||||
collectSelectedContent(value, selector, selected)
|
||||
if (!isResolvedSecretModelContentUnchanged(selected.guarded, registry)) {
|
||||
throw new CopilotModelContentProjectionError()
|
||||
}
|
||||
|
||||
const projected = projectModelContent(selected.projected, registry)
|
||||
if (!Array.isArray(projected) || projected.length !== selected.projected.length) {
|
||||
throw new CopilotModelContentProjectionError()
|
||||
}
|
||||
const jsonProjection = projectResolvedSecretModelJsonStrings(selected.jsonStrings, registry)
|
||||
if (
|
||||
!jsonProjection.safe ||
|
||||
!Array.isArray(jsonProjection.value) ||
|
||||
!jsonProjection.value.every((item) => typeof item === 'string') ||
|
||||
jsonProjection.value.length !== selected.jsonStrings.length
|
||||
) {
|
||||
throw new CopilotModelContentProjectionError()
|
||||
}
|
||||
const cursor = { projected: 0, jsonStrings: 0 }
|
||||
const restored = restoreSelectedContent(value, selector, projected, jsonProjection.value, cursor)
|
||||
if (cursor.projected !== projected.length || cursor.jsonStrings !== jsonProjection.value.length) {
|
||||
throw new CopilotModelContentProjectionError()
|
||||
}
|
||||
return restored
|
||||
}
|
||||
|
||||
function projectStructuredContent(
|
||||
value: unknown,
|
||||
registry: ResolvedSecretTraceRegistry,
|
||||
selector: SelectedContentSelector,
|
||||
shape: 'array' | 'record' | 'record-or-array'
|
||||
): unknown {
|
||||
if (
|
||||
(shape === 'array' && !Array.isArray(value)) ||
|
||||
(shape === 'record' && !isPlainRecord(value)) ||
|
||||
(shape === 'record-or-array' && !Array.isArray(value) && !isPlainRecord(value))
|
||||
) {
|
||||
throw new CopilotModelContentProjectionError()
|
||||
}
|
||||
return projectSelectedContent(value, registry, selector)
|
||||
}
|
||||
|
||||
function schemaContentAction(
|
||||
path: readonly string[],
|
||||
key: string,
|
||||
value: unknown
|
||||
): SelectedContentAction {
|
||||
return getModelVisibleSchemaAction(path.at(-1), key, value)
|
||||
}
|
||||
|
||||
const toolContentSelector: SelectedContentSelector = (path, key, value) => {
|
||||
if (path.length === 0 && key === 'description') return 'project'
|
||||
if (path.length === 0 && key === 'name') return 'verify'
|
||||
if (path.length === 0 && TOOL_SCHEMA_KEYS.has(key)) return 'traverse'
|
||||
|
||||
const schemaRootIndex = path.findIndex((segment) => TOOL_SCHEMA_KEYS.has(segment))
|
||||
if (schemaRootIndex >= 0) {
|
||||
return schemaContentAction(path.slice(schemaRootIndex + 1), key, value)
|
||||
}
|
||||
return 'preserve'
|
||||
}
|
||||
|
||||
const contextContentSelector: SelectedContentSelector = (_path, key, value) => {
|
||||
if (isCopilotModelTextKey(COPILOT_CONTEXT_MODEL_TEXT_KEYS, key)) return 'project'
|
||||
return value !== null && typeof value === 'object' ? 'traverse' : 'preserve'
|
||||
}
|
||||
|
||||
function nearestPathContainer(path: readonly string[]): string | undefined {
|
||||
for (let index = path.length - 1; index >= 0; index -= 1) {
|
||||
if (path[index] !== '*') return path[index]
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const messageContentSelector: SelectedContentSelector = (path, key, value) => {
|
||||
if (key === 'content') {
|
||||
return value !== null && typeof value === 'object' ? 'traverse' : 'project'
|
||||
}
|
||||
if (MESSAGE_OPAQUE_CONTENT_KEYS.has(key)) return 'project'
|
||||
if (key === 'arguments') return 'project-json'
|
||||
if (isCopilotModelTextKey(COPILOT_MESSAGE_DISPLAY_KEYS, key)) return 'project'
|
||||
if (
|
||||
(key === 'name' || key === 'filename' || key === 'fileName') &&
|
||||
ATTACHMENT_PARENT_KEYS.has(nearestPathContainer(path) ?? '')
|
||||
) {
|
||||
return 'project'
|
||||
}
|
||||
if (
|
||||
key === 'name' &&
|
||||
(path.length === 1 || MESSAGE_HANDLE_PARENT_KEYS.has(nearestPathContainer(path) ?? ''))
|
||||
) {
|
||||
return 'verify'
|
||||
}
|
||||
if (key === 'name') return 'project'
|
||||
if (key === 'context' && nearestPathContainer(path) === 'files') return 'project'
|
||||
if (MESSAGE_CONTAINER_KEYS.has(key)) return 'traverse'
|
||||
return 'preserve'
|
||||
}
|
||||
|
||||
const responseFormatContentSelector: SelectedContentSelector = (path, key, value) => {
|
||||
if (path.length === 0 && (key === 'description' || key === 'instructions')) return 'project'
|
||||
if (path.length === 0 && key === 'name') return 'verify'
|
||||
if (path.length === 0 && key === 'schema') return 'traverse'
|
||||
if (path.length === 0) return schemaContentAction(path, key, value)
|
||||
if (path[0] === 'schema') return schemaContentAction(path.slice(1), key, value)
|
||||
return 'preserve'
|
||||
}
|
||||
|
||||
const vfsContentSelector: SelectedContentSelector = (_path, key, value) => {
|
||||
if (isCopilotModelTextKey(COPILOT_VFS_MODEL_TEXT_KEYS, key)) return 'project'
|
||||
return value !== null && typeof value === 'object' ? 'traverse' : 'preserve'
|
||||
}
|
||||
|
||||
const userMetadataContentSelector: SelectedContentSelector = (_path, key) =>
|
||||
isCopilotModelTextKey(COPILOT_USER_METADATA_MODEL_TEXT_KEYS, key) ? 'project' : 'preserve'
|
||||
|
||||
const desktopContentSelector: SelectedContentSelector = (_path, key, value) => {
|
||||
if (isCopilotModelTextKey(COPILOT_DESKTOP_MODEL_TEXT_KEYS, key)) return 'project'
|
||||
return value !== null && typeof value === 'object' ? 'traverse' : 'preserve'
|
||||
}
|
||||
|
||||
function projectModelSafeToolPayloads(
|
||||
value: unknown,
|
||||
registry: ResolvedSecretTraceRegistry
|
||||
): unknown[] {
|
||||
if (!Array.isArray(value)) throw new CopilotModelContentProjectionError()
|
||||
|
||||
const projected: unknown[] = []
|
||||
for (const candidate of value) {
|
||||
if (!isPlainRecord(candidate) || typeof candidate.name !== 'string') continue
|
||||
|
||||
try {
|
||||
for (const schemaKey of TOOL_SCHEMA_KEYS) {
|
||||
if (Object.hasOwn(candidate, schemaKey)) {
|
||||
collectModelVisibleSchemaContent(candidate[schemaKey])
|
||||
}
|
||||
}
|
||||
projected.push(projectStructuredContent(candidate, registry, toolContentSelector, 'record'))
|
||||
} catch {
|
||||
// Tool definitions are independent protocol entities. Reject an unsafe definition without
|
||||
// turning the entire catalog into one synthetic projection value or failing safe siblings.
|
||||
}
|
||||
}
|
||||
|
||||
// Projection completeness is a request-level invariant, even when every candidate was rejected.
|
||||
projectModelContent([], registry)
|
||||
return projected
|
||||
}
|
||||
|
||||
function hasModelSafeRoutingFields(
|
||||
value: Record<string, unknown>,
|
||||
routingKeys: readonly string[],
|
||||
registry: ResolvedSecretTraceRegistry
|
||||
): boolean {
|
||||
for (const routingKey of routingKeys) {
|
||||
if (!Object.hasOwn(value, routingKey)) continue
|
||||
const routingValue = value[routingKey]
|
||||
if (
|
||||
typeof routingValue !== 'string' ||
|
||||
!isResolvedSecretModelContentUnchanged(routingValue, registry)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function filterModelSafeContextPayload(
|
||||
value: unknown,
|
||||
registry: ResolvedSecretTraceRegistry
|
||||
): Record<string, unknown> | unknown[] | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter(
|
||||
(candidate) =>
|
||||
isPlainRecord(candidate) &&
|
||||
hasModelSafeRoutingFields(candidate, COPILOT_CONTEXT_ROUTING_KEYS, registry)
|
||||
)
|
||||
}
|
||||
if (!isPlainRecord(value)) throw new CopilotModelContentProjectionError()
|
||||
return hasModelSafeRoutingFields(value, COPILOT_CONTEXT_ROUTING_KEYS, registry)
|
||||
? value
|
||||
: undefined
|
||||
}
|
||||
|
||||
function filterModelSafeVfsPayload(
|
||||
value: unknown,
|
||||
registry: ResolvedSecretTraceRegistry
|
||||
): Record<string, unknown> {
|
||||
if (!isPlainRecord(value)) throw new CopilotModelContentProjectionError()
|
||||
const filtered: Record<string, unknown> = { ...value }
|
||||
|
||||
for (const [collectionKey, collection] of Object.entries(value)) {
|
||||
if (collectionKey === 'envVars') {
|
||||
if (!Array.isArray(collection) || !collection.every((item) => typeof item === 'string')) {
|
||||
throw new CopilotModelContentProjectionError()
|
||||
}
|
||||
filtered[collectionKey] = collection.filter((name) =>
|
||||
isResolvedSecretModelContentUnchanged(name, registry)
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (!Array.isArray(collection)) continue
|
||||
|
||||
filtered[collectionKey] = collection.filter((candidate) => {
|
||||
if (!isPlainRecord(candidate)) return false
|
||||
return hasModelSafeRoutingFields(candidate, COPILOT_VFS_ROUTING_KEYS, registry)
|
||||
})
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
function filterModelSafeUserMetadata(
|
||||
value: unknown,
|
||||
registry: ResolvedSecretTraceRegistry
|
||||
): Record<string, unknown> {
|
||||
if (!isPlainRecord(value)) throw new CopilotModelContentProjectionError()
|
||||
const filtered = { ...value }
|
||||
if (
|
||||
Object.hasOwn(filtered, 'timezone') &&
|
||||
(typeof filtered.timezone !== 'string' ||
|
||||
!isResolvedSecretModelContentUnchanged(filtered.timezone, registry))
|
||||
) {
|
||||
return omit(filtered, ['timezone'])
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
function filterModelSafeDesktopCapabilities(
|
||||
value: unknown,
|
||||
registry: ResolvedSecretTraceRegistry
|
||||
): Record<string, unknown> {
|
||||
if (!isPlainRecord(value)) throw new CopilotModelContentProjectionError()
|
||||
const filtered: Record<string, unknown> = { ...value }
|
||||
|
||||
if (Object.hasOwn(value, 'terminals')) {
|
||||
if (!Array.isArray(value.terminals)) throw new CopilotModelContentProjectionError()
|
||||
filtered.terminals = value.terminals.filter((terminal) => {
|
||||
if (!isPlainRecord(terminal)) return false
|
||||
return (
|
||||
!Object.hasOwn(terminal, 'cwd') ||
|
||||
(typeof terminal.cwd === 'string' &&
|
||||
isResolvedSecretModelContentUnchanged(terminal.cwd, registry))
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (Object.hasOwn(value, 'browserSessions')) {
|
||||
if (!Array.isArray(value.browserSessions)) throw new CopilotModelContentProjectionError()
|
||||
filtered.browserSessions = value.browserSessions.filter(
|
||||
(session) =>
|
||||
isPlainRecord(session) &&
|
||||
typeof session.hostname === 'string' &&
|
||||
isResolvedSecretModelContentUnchanged(session.hostname, registry)
|
||||
)
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
function projectAttachmentDisplayNames(
|
||||
payload: Record<string, unknown>,
|
||||
registry: ResolvedSecretTraceRegistry
|
||||
): Partial<Record<'attachments' | 'fileAttachments', unknown>> {
|
||||
const projected: Partial<Record<'attachments' | 'fileAttachments', unknown>> = {}
|
||||
for (const key of ['attachments', 'fileAttachments'] as const) {
|
||||
if (!Object.hasOwn(payload, key)) continue
|
||||
const attachments = payload[key]
|
||||
if (!Array.isArray(attachments)) throw new CopilotModelContentProjectionError()
|
||||
const displayNames = attachments.map((attachment) => {
|
||||
if (!isPlainRecord(attachment)) throw new CopilotModelContentProjectionError()
|
||||
return {
|
||||
...(Object.hasOwn(attachment, 'name') ? { name: attachment.name } : {}),
|
||||
...(Object.hasOwn(attachment, 'filename') ? { filename: attachment.filename } : {}),
|
||||
}
|
||||
})
|
||||
const projection = projectResolvedSecretModelContent(displayNames, registry)
|
||||
if (
|
||||
!projection.safe ||
|
||||
!Array.isArray(projection.value) ||
|
||||
projection.value.length !== attachments.length
|
||||
) {
|
||||
throw new CopilotModelContentProjectionError()
|
||||
}
|
||||
const projectedDisplayNames = projection.value
|
||||
projected[key] = attachments.map((attachment, index) => {
|
||||
const displayName = projectedDisplayNames[index]
|
||||
if (!isPlainRecord(attachment) || !isPlainRecord(displayName)) {
|
||||
throw new CopilotModelContentProjectionError()
|
||||
}
|
||||
const name = displayName.name
|
||||
const filename = displayName.filename
|
||||
if (name !== undefined && typeof name !== 'string') {
|
||||
throw new CopilotModelContentProjectionError()
|
||||
}
|
||||
if (filename !== undefined && typeof filename !== 'string') {
|
||||
throw new CopilotModelContentProjectionError()
|
||||
}
|
||||
return {
|
||||
...attachment,
|
||||
...(name !== undefined ? { name } : {}),
|
||||
...(filename !== undefined ? { filename } : {}),
|
||||
}
|
||||
})
|
||||
}
|
||||
return projected
|
||||
}
|
||||
|
||||
async function omitUnsafeInitialCopilotAttachments(
|
||||
payload: Record<string, unknown>,
|
||||
workspaceId?: string
|
||||
@@ -625,115 +120,11 @@ async function omitUnsafeInitialCopilotAttachments(
|
||||
return projected
|
||||
}
|
||||
|
||||
async function projectInitialCopilotPayload(
|
||||
async function filterInitialCopilotAttachmentsForModel(
|
||||
payload: Record<string, unknown>,
|
||||
registry: ResolvedSecretTraceRegistry,
|
||||
workspaceId?: string
|
||||
): Promise<Record<string, unknown>> {
|
||||
projectModelContent([], registry)
|
||||
let projectedPayload = { ...payload }
|
||||
const simpleContent: Record<string, unknown> = {}
|
||||
for (const key of SIMPLE_MODEL_CONTENT_KEYS) {
|
||||
if (Object.hasOwn(payload, key)) simpleContent[key] = payload[key]
|
||||
}
|
||||
const projectedSimpleContent = projectModelContent(simpleContent, registry)
|
||||
if (!isPlainRecord(projectedSimpleContent)) throw new CopilotModelContentProjectionError()
|
||||
for (const key of SIMPLE_MODEL_CONTENT_KEYS) {
|
||||
if (Object.hasOwn(payload, key) && Object.hasOwn(projectedSimpleContent, key)) {
|
||||
projectedPayload[key] = projectedSimpleContent[key]
|
||||
}
|
||||
}
|
||||
if (Object.hasOwn(payload, 'userTimezone')) {
|
||||
if (
|
||||
typeof payload.userTimezone === 'string' &&
|
||||
isResolvedSecretModelContentUnchanged(payload.userTimezone, registry)
|
||||
) {
|
||||
projectedPayload.userTimezone = payload.userTimezone
|
||||
} else {
|
||||
projectedPayload = omit(projectedPayload, ['userTimezone'])
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.hasOwn(payload, 'messages')) {
|
||||
projectedPayload.messages = projectStructuredContent(
|
||||
payload.messages,
|
||||
registry,
|
||||
messageContentSelector,
|
||||
'array'
|
||||
)
|
||||
}
|
||||
for (const key of ['context', 'contexts'] as const) {
|
||||
if (Object.hasOwn(payload, key)) {
|
||||
if (typeof payload[key] === 'string') {
|
||||
projectedPayload[key] = projectModelContent(payload[key], registry)
|
||||
continue
|
||||
}
|
||||
const safeContexts = filterModelSafeContextPayload(payload[key], registry)
|
||||
if (safeContexts === undefined) {
|
||||
projectedPayload = omit(projectedPayload, [key])
|
||||
} else {
|
||||
projectedPayload[key] = projectStructuredContent(
|
||||
safeContexts,
|
||||
registry,
|
||||
contextContentSelector,
|
||||
'record-or-array'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const key of TOOL_PAYLOAD_KEYS) {
|
||||
if (Object.hasOwn(payload, key)) {
|
||||
projectedPayload[key] = projectModelSafeToolPayloads(payload[key], registry)
|
||||
}
|
||||
}
|
||||
if (Object.hasOwn(payload, 'responseFormat')) {
|
||||
try {
|
||||
if (
|
||||
isPlainRecord(payload.responseFormat) &&
|
||||
Object.hasOwn(payload.responseFormat, 'schema')
|
||||
) {
|
||||
collectModelVisibleSchemaContent(payload.responseFormat.schema)
|
||||
}
|
||||
projectedPayload.responseFormat =
|
||||
typeof payload.responseFormat === 'string'
|
||||
? projectModelContent(payload.responseFormat, registry)
|
||||
: projectStructuredContent(
|
||||
payload.responseFormat,
|
||||
registry,
|
||||
responseFormatContentSelector,
|
||||
'record'
|
||||
)
|
||||
} catch {
|
||||
logger.warn('Omitting a Copilot response format with unsafe model-input provenance')
|
||||
projectedPayload = omit(projectedPayload, ['responseFormat'])
|
||||
}
|
||||
}
|
||||
if (Object.hasOwn(payload, 'vfs')) {
|
||||
projectedPayload.vfs = projectStructuredContent(
|
||||
filterModelSafeVfsPayload(payload.vfs, registry),
|
||||
registry,
|
||||
vfsContentSelector,
|
||||
'record'
|
||||
)
|
||||
}
|
||||
if (Object.hasOwn(payload, 'userMetadata')) {
|
||||
projectedPayload.userMetadata = projectStructuredContent(
|
||||
filterModelSafeUserMetadata(payload.userMetadata, registry),
|
||||
registry,
|
||||
userMetadataContentSelector,
|
||||
'record'
|
||||
)
|
||||
}
|
||||
if (Object.hasOwn(payload, 'desktopCapabilities')) {
|
||||
projectedPayload.desktopCapabilities = projectStructuredContent(
|
||||
filterModelSafeDesktopCapabilities(payload.desktopCapabilities, registry),
|
||||
registry,
|
||||
desktopContentSelector,
|
||||
'record'
|
||||
)
|
||||
}
|
||||
Object.assign(projectedPayload, projectAttachmentDisplayNames(payload, registry))
|
||||
return omitUnsafeInitialCopilotAttachments(projectedPayload, workspaceId)
|
||||
return omitUnsafeInitialCopilotAttachments(payload, workspaceId)
|
||||
}
|
||||
|
||||
async function ensureModelEgressRegistry(
|
||||
@@ -919,10 +310,9 @@ export async function runCopilotLifecycle(
|
||||
let onCompleteStarted = false
|
||||
|
||||
try {
|
||||
const modelEgressRegistry = await ensureModelEgressRegistry(execContext, lifecycleOptions)
|
||||
const modelSafeRequestPayload = await projectInitialCopilotPayload(
|
||||
await ensureModelEgressRegistry(execContext, lifecycleOptions)
|
||||
const modelSafeRequestPayload = await filterInitialCopilotAttachmentsForModel(
|
||||
requestPayload,
|
||||
modelEgressRegistry,
|
||||
lifecycleOptions.workspaceId
|
||||
)
|
||||
await runCheckpointLoop(
|
||||
@@ -1135,8 +525,7 @@ async function waitForToolIds(context: StreamingContext, toolIds: string[]): Pro
|
||||
function collectResultsForToolIds(
|
||||
context: StreamingContext,
|
||||
toolIds: string[],
|
||||
checkpointId: string,
|
||||
registry: ResolvedSecretTraceRegistry
|
||||
checkpointId: string
|
||||
): Array<{ callId: string; name: string; data: unknown; success: boolean }> {
|
||||
return toolIds.map((toolCallId) => {
|
||||
const tool = context.toolCalls.get(toolCallId)
|
||||
@@ -1146,9 +535,6 @@ function collectResultsForToolIds(
|
||||
)
|
||||
}
|
||||
const name = tool.name || ''
|
||||
if (!isResolvedSecretModelContentUnchanged(name, registry)) {
|
||||
throw new CopilotModelContentProjectionError()
|
||||
}
|
||||
return {
|
||||
callId: toolCallId,
|
||||
name,
|
||||
@@ -1238,9 +624,7 @@ async function driveOneChildChain(
|
||||
if (isAborted(options, context)) return null
|
||||
|
||||
await waitForToolIds(context, toolIds)
|
||||
const registry = execContext.resolvedSecretTraceRegistry
|
||||
if (!registry) throw new CopilotModelContentProjectionError()
|
||||
const results = collectResultsForToolIds(context, toolIds, checkpointId, registry)
|
||||
const results = collectResultsForToolIds(context, toolIds, checkpointId)
|
||||
|
||||
const leg = makeResumeLegContext(context)
|
||||
await runResumeLegWithRetry(
|
||||
@@ -1651,9 +1035,6 @@ async function runCheckpointLoop(
|
||||
throw new Error(`Cannot resume: missing result for pending tool call ${toolCallId}`)
|
||||
}
|
||||
const name = tool.name || ''
|
||||
if (!isResolvedSecretModelContentUnchanged(name, execContext.resolvedSecretTraceRegistry)) {
|
||||
throw new CopilotModelContentProjectionError()
|
||||
}
|
||||
results.push({
|
||||
callId: toolCallId,
|
||||
name,
|
||||
|
||||
@@ -325,7 +325,7 @@ describe('createSSEStream terminal error handling', () => {
|
||||
expect(lifecycleTraceparent).toMatch(/^00-[0-9a-f]{32}-[0-9a-f]{16}-0[0-9a-f]$/)
|
||||
})
|
||||
|
||||
it('projects title input using the execution-context secret registry', async () => {
|
||||
it('does not scan manually authored title input against unrelated active secrets', async () => {
|
||||
runCopilotLifecycle.mockResolvedValue({
|
||||
success: true,
|
||||
content: 'OK',
|
||||
@@ -362,7 +362,7 @@ describe('createSSEStream terminal error handling', () => {
|
||||
await vi.waitFor(() => expect(fetchGo).toHaveBeenCalled())
|
||||
const [, request] = fetchGo.mock.calls.at(-1) ?? []
|
||||
expect(JSON.parse(request.body)).toEqual(
|
||||
expect.objectContaining({ message: 'hello {{TOKEN}}' })
|
||||
expect.objectContaining({ message: 'hello secret-value' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,8 +53,6 @@ import { TraceCollector } from '@/lib/copilot/request/trace'
|
||||
import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { isCopilotBillingAttributionV1Enabled, isHosted } from '@/lib/core/config/env-flags'
|
||||
import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection'
|
||||
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
|
||||
|
||||
export { SSE_RESPONSE_HEADERS }
|
||||
|
||||
@@ -251,9 +249,6 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
|
||||
requestId,
|
||||
publisher,
|
||||
otelContext,
|
||||
resolvedSecretTraceRegistry:
|
||||
orchestrateOptions.resolvedSecretTraceRegistry ??
|
||||
orchestrateOptions.executionContext?.resolvedSecretTraceRegistry,
|
||||
})
|
||||
|
||||
try {
|
||||
@@ -442,7 +437,6 @@ function fireTitleGeneration(params: {
|
||||
requestId: string
|
||||
publisher: StreamWriter
|
||||
otelContext?: Context
|
||||
resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry
|
||||
}): void {
|
||||
const {
|
||||
chatId,
|
||||
@@ -457,18 +451,11 @@ function fireTitleGeneration(params: {
|
||||
requestId,
|
||||
publisher,
|
||||
otelContext,
|
||||
resolvedSecretTraceRegistry,
|
||||
} = params
|
||||
if (!chatId || currentChat?.title || !isNewChat) return
|
||||
|
||||
const projectedMessage = projectResolvedSecretModelContent(message, resolvedSecretTraceRegistry)
|
||||
if (!projectedMessage.safe || typeof projectedMessage.value !== 'string') {
|
||||
logger.warn(`[${requestId}] Skipping title generation because its input was not safe`)
|
||||
return
|
||||
}
|
||||
|
||||
requestChatTitle({
|
||||
message: projectedMessage.value,
|
||||
message,
|
||||
model: titleModel,
|
||||
provider: titleProvider,
|
||||
userId,
|
||||
|
||||
@@ -84,7 +84,7 @@ export async function waitForClientToolCompletion({
|
||||
const completion = await waitForToolCompletion(toolCallId, timeoutMs, abortSignal)
|
||||
if (!completion) return null
|
||||
|
||||
const toolRegistry = registry?.forkForToolInput(undefined)
|
||||
const toolRegistry = registry?.forkForInputPaths([])
|
||||
const genericMessage = getGenericCompletionMessage(completion.status)
|
||||
const binding = runId ? { toolCallId, runId, userId } : undefined
|
||||
const registryCanImport = toolRegistry !== undefined && !toolRegistry.isPermanentlyIncomplete()
|
||||
@@ -232,7 +232,7 @@ export async function waitForWorkflowToolCompletion({
|
||||
abortSignal,
|
||||
registry,
|
||||
}: WaitForWorkflowToolCompletionOptions): Promise<AsyncTerminalCompletionSnapshot | null> {
|
||||
const toolRegistry = registry?.forkForToolInput(undefined)
|
||||
const toolRegistry = registry?.forkForInputPaths([])
|
||||
const finishPendingActivation = toolRegistry?.beginPendingActivation()
|
||||
let completion: AsyncTerminalCompletionSnapshot | null = null
|
||||
let trustedExecution: Awaited<ReturnType<typeof getTrustedWorkflowToolExecution>> = null
|
||||
|
||||
@@ -202,7 +202,9 @@ describe('executeToolAndReport provenance isolation', () => {
|
||||
_params: Record<string, unknown>,
|
||||
toolContext: ExecutionContext
|
||||
) => {
|
||||
toolContext.resolvedSecretTraceRegistry?.recordResolved('TOKEN', 'secret-value')
|
||||
toolContext.resolvedSecretTraceRegistry?.recordResolved('TOKEN', 'secret-value', {
|
||||
propagated: true,
|
||||
})
|
||||
return { success: true, output: { value: 'secret-value' } }
|
||||
}
|
||||
)
|
||||
|
||||
@@ -277,9 +277,7 @@ export function buildToolExecutionContext(
|
||||
return {
|
||||
...execContext,
|
||||
toolCallId: toolCall.id,
|
||||
resolvedSecretTraceRegistry: execContext.resolvedSecretTraceRegistry?.forkForToolInput(
|
||||
toolCall.params
|
||||
),
|
||||
resolvedSecretTraceRegistry: execContext.resolvedSecretTraceRegistry?.forkForInputPaths([]),
|
||||
...(toolCall.parentToolCallId ? { parentToolCallId: toolCall.parentToolCallId } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockWriteWorkspaceFileByPath } = vi.hoisted(() => ({
|
||||
const { mockEncryptSecret, mockWriteWorkspaceFileByPath } = vi.hoisted(() => ({
|
||||
mockEncryptSecret: vi.fn(),
|
||||
mockWriteWorkspaceFileByPath: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/security/encryption', () => ({
|
||||
encryptSecret: mockEncryptSecret,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/vfs/resource-writer', () => ({
|
||||
writeWorkspaceFileByPath: mockWriteWorkspaceFileByPath,
|
||||
}))
|
||||
@@ -27,8 +32,8 @@ import {
|
||||
serializeOutputForFile,
|
||||
unwrapFunctionExecuteOutput,
|
||||
} from '@/lib/copilot/request/tools/files'
|
||||
import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result'
|
||||
import type { ExecutionContext } from '@/lib/copilot/request/types'
|
||||
import { MAX_INLINE_MATERIALIZATION_BYTES } from '@/lib/execution/payloads/limits'
|
||||
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
|
||||
|
||||
describe('unwrapFunctionExecuteOutput', () => {
|
||||
@@ -121,6 +126,7 @@ describe('maybeWriteOutputToFile', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockEncryptSecret.mockResolvedValue({ encrypted: 'encrypted-csv-representation', iv: 'iv' })
|
||||
mockWriteWorkspaceFileByPath.mockResolvedValue({
|
||||
id: 'file-1',
|
||||
name: 'report.csv',
|
||||
@@ -164,23 +170,104 @@ describe('maybeWriteOutputToFile', () => {
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledTimes(1)
|
||||
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ secretProvenance: { status: 'exact', entries: [] } })
|
||||
)
|
||||
})
|
||||
|
||||
it('persists canonical aliases and leaves unrelated low-entropy public values unchanged', async () => {
|
||||
const parentRegistry = new ResolvedSecretTraceRegistry([
|
||||
{
|
||||
name: 'OUTPUT_SECRET',
|
||||
plaintext: 'secret-value',
|
||||
encryptedValue: 'encrypted-output-secret',
|
||||
},
|
||||
{
|
||||
name: 'UNRELATED',
|
||||
plaintext: 'true',
|
||||
encryptedValue: 'encrypted-unrelated',
|
||||
},
|
||||
it('classifies large structured output from its serialized bytes instead of its object count', async () => {
|
||||
const registry = new ResolvedSecretTraceRegistry(
|
||||
[
|
||||
{ name: 'TOKEN', plaintext: 'secret-value', encryptedValue: 'encrypted-token' },
|
||||
{
|
||||
name: 'TOKEN_ALIAS',
|
||||
plaintext: 'secret-value',
|
||||
encryptedValue: 'encrypted-token-alias',
|
||||
},
|
||||
],
|
||||
{ userId: 'user-1', workspaceId: 'workspace-1' }
|
||||
)
|
||||
registry.recordResolved('TOKEN', 'secret-value')
|
||||
registry.recordResolved('TOKEN_ALIAS', 'secret-value')
|
||||
const rows = Array.from({ length: 10_000 }, (_, index) => ({
|
||||
id: index,
|
||||
name: `row-${index}`,
|
||||
status: 'ready',
|
||||
enabled: true,
|
||||
token: index === 9_999 ? 'secret-value' : 'public-value',
|
||||
}))
|
||||
|
||||
const result = await maybeWriteOutputToFile(
|
||||
FunctionExecute.id,
|
||||
{ outputs: { files: [{ path: 'files/report.json', mode: 'overwrite' }] } },
|
||||
{ success: true, output: { result: rows, stdout: '' } },
|
||||
buildContext({ resolvedSecretTraceRegistry: registry })
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
secretProvenance: {
|
||||
status: 'exact',
|
||||
entries: [
|
||||
{
|
||||
name: 'TOKEN',
|
||||
encryptedValue: 'encrypted-token',
|
||||
sourceUserId: 'user-1',
|
||||
sourceWorkspaceId: 'workspace-1',
|
||||
},
|
||||
{
|
||||
name: 'TOKEN_ALIAS',
|
||||
encryptedValue: 'encrypted-token-alias',
|
||||
sourceUserId: 'user-1',
|
||||
sourceWorkspaceId: 'workspace-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('writes raw output with unknown provenance when serialized output exceeds the scan budget', async () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'TOKEN', plaintext: 'secret-value', encryptedValue: 'encrypted-token' },
|
||||
])
|
||||
registry.recordResolved('TOKEN', 'secret-value')
|
||||
|
||||
const result = await maybeWriteOutputToFile(
|
||||
FunctionExecute.id,
|
||||
{ outputs: { files: [{ path: 'files/report.txt', mode: 'overwrite' }] } },
|
||||
{
|
||||
success: true,
|
||||
output: { result: 'x'.repeat(MAX_INLINE_MATERIALIZATION_BYTES + 1), stdout: '' },
|
||||
},
|
||||
buildContext({ resolvedSecretTraceRegistry: registry })
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ secretProvenance: { status: 'unknown' } })
|
||||
)
|
||||
})
|
||||
|
||||
it('persists raw bytes with exact provenance and leaves sibling literals unclassified', async () => {
|
||||
const parentRegistry = new ResolvedSecretTraceRegistry(
|
||||
[
|
||||
{
|
||||
name: 'OUTPUT_SECRET',
|
||||
plaintext: 'secret-value',
|
||||
encryptedValue: 'encrypted-output-secret',
|
||||
},
|
||||
{
|
||||
name: 'UNRELATED',
|
||||
plaintext: 'true',
|
||||
encryptedValue: 'encrypted-unrelated',
|
||||
},
|
||||
],
|
||||
{ userId: 'user-1', workspaceId: 'workspace-1' }
|
||||
)
|
||||
parentRegistry.recordResolved('UNRELATED', 'true')
|
||||
const toolRegistry = parentRegistry.forkForToolInput({ code: 'return {{OUTPUT_SECRET}}' })
|
||||
const toolRegistry = parentRegistry.forkForInputPaths([])
|
||||
toolRegistry.recordResolved('OUTPUT_SECRET', 'secret-value')
|
||||
const runtimeOutput = {
|
||||
result: { token: 'secret-value', publicLabel: 'true', enabled: true },
|
||||
@@ -195,26 +282,295 @@ describe('maybeWriteOutputToFile', () => {
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const persisted = mockWriteWorkspaceFileByPath.mock.calls[0][0].buffer.toString('utf8')
|
||||
const write = mockWriteWorkspaceFileByPath.mock.calls[0][0]
|
||||
const persisted = write.buffer.toString('utf8')
|
||||
expect(JSON.parse(persisted)).toEqual({
|
||||
token: '{{OUTPUT_SECRET}}',
|
||||
token: 'secret-value',
|
||||
publicLabel: 'true',
|
||||
enabled: true,
|
||||
})
|
||||
expect(write.secretProvenance).toEqual({
|
||||
status: 'exact',
|
||||
entries: [
|
||||
{
|
||||
name: 'OUTPUT_SECRET',
|
||||
encryptedValue: 'encrypted-output-secret',
|
||||
sourceUserId: 'user-1',
|
||||
sourceWorkspaceId: 'workspace-1',
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(runtimeOutput.result.token).toBe('secret-value')
|
||||
|
||||
const laterRead = projectToolResultForCopilot(
|
||||
{ success: true, output: { content: persisted } },
|
||||
new ResolvedSecretTraceRegistry()
|
||||
)
|
||||
expect(JSON.parse((laterRead.output as { content: string }).content)).toEqual({
|
||||
token: '{{OUTPUT_SECRET}}',
|
||||
publicLabel: 'true',
|
||||
enabled: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not write when exact persistence provenance is unavailable', async () => {
|
||||
it('tracks both logical and quote-escaped CSV representations', async () => {
|
||||
const secret = 'a"b\\c\nline'
|
||||
const registry = new ResolvedSecretTraceRegistry(
|
||||
[{ name: 'CSV_SECRET', plaintext: secret, encryptedValue: 'encrypted-csv-secret' }],
|
||||
{ userId: 'user-1', workspaceId: 'workspace-1' }
|
||||
)
|
||||
registry.recordResolved('CSV_SECRET', secret)
|
||||
|
||||
const result = await maybeWriteOutputToFile(
|
||||
FunctionExecute.id,
|
||||
{ outputs: { files: [{ path: 'files/report.csv', mode: 'overwrite' }] } },
|
||||
{ success: true, output: { result: [{ value: secret }], stdout: '' } },
|
||||
buildContext({ resolvedSecretTraceRegistry: registry })
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const write = mockWriteWorkspaceFileByPath.mock.calls[0][0]
|
||||
expect(write.buffer.toString('utf8')).toBe('value\n"a""b\\c\nline"')
|
||||
expect(write.secretProvenance).toEqual({
|
||||
status: 'exact',
|
||||
entries: [
|
||||
{
|
||||
name: 'CSV_SECRET',
|
||||
encryptedValue: 'encrypted-csv-representation',
|
||||
sourceUserId: 'user-1',
|
||||
sourceWorkspaceId: 'workspace-1',
|
||||
},
|
||||
{
|
||||
name: 'CSV_SECRET',
|
||||
encryptedValue: 'encrypted-csv-secret',
|
||||
sourceUserId: 'user-1',
|
||||
sourceWorkspaceId: 'workspace-1',
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(mockEncryptSecret).toHaveBeenCalledWith('a""b\\c\nline')
|
||||
})
|
||||
|
||||
it('deduplicates repeated CSV quote transformations across the table', async () => {
|
||||
const secret = 'secret"value'
|
||||
const registry = new ResolvedSecretTraceRegistry(
|
||||
[{ name: 'CSV_SECRET', plaintext: secret, encryptedValue: 'encrypted-csv-secret' }],
|
||||
{ userId: 'user-1', workspaceId: 'workspace-1' }
|
||||
)
|
||||
registry.recordResolved('CSV_SECRET', secret)
|
||||
const rows = Array.from({ length: 10_000 }, () => ({ first: secret, second: secret }))
|
||||
|
||||
const result = await maybeWriteOutputToFile(
|
||||
FunctionExecute.id,
|
||||
{ outputs: { files: [{ path: 'files/report.csv', mode: 'overwrite' }] } },
|
||||
{ success: true, output: { result: rows, stdout: '' } },
|
||||
buildContext({ resolvedSecretTraceRegistry: registry })
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockEncryptSecret).toHaveBeenCalledTimes(1)
|
||||
expect(mockEncryptSecret).toHaveBeenCalledWith('secret""value')
|
||||
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reuses serialized provenance for output files with the same format', async () => {
|
||||
const secret = 'a"b'
|
||||
const registry = new ResolvedSecretTraceRegistry(
|
||||
[{ name: 'CSV_SECRET', plaintext: secret, encryptedValue: 'encrypted-csv-secret' }],
|
||||
{ userId: 'user-1', workspaceId: 'workspace-1' }
|
||||
)
|
||||
registry.recordResolved('CSV_SECRET', secret)
|
||||
|
||||
const result = await maybeWriteOutputToFile(
|
||||
FunctionExecute.id,
|
||||
{
|
||||
outputs: {
|
||||
files: [
|
||||
{ path: 'files/one.csv', mode: 'overwrite' },
|
||||
{ path: 'files/two.csv', mode: 'overwrite' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ success: true, output: { result: [{ value: secret }], stdout: '' } },
|
||||
buildContext({ resolvedSecretTraceRegistry: registry })
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockEncryptSecret).toHaveBeenCalledTimes(1)
|
||||
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('preserves existing multi-file outputs beyond twenty declarations', async () => {
|
||||
const files = Array.from({ length: 21 }, (_, index) => ({
|
||||
path: `files/report-${index}.txt`,
|
||||
mode: 'overwrite' as const,
|
||||
}))
|
||||
|
||||
const result = await maybeWriteOutputToFile(
|
||||
FunctionExecute.id,
|
||||
{ outputs: { files } },
|
||||
{ success: true, output: { result: 'content', stdout: '' } },
|
||||
buildContext()
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledTimes(21)
|
||||
})
|
||||
|
||||
it('tracks a persisted legacy runtime alias', async () => {
|
||||
const registry = new ResolvedSecretTraceRegistry(
|
||||
[{ name: 'API_KEY', plaintext: 'secret-value', encryptedValue: 'encrypted-api-key' }],
|
||||
{ userId: 'user-1', workspaceId: 'workspace-1' }
|
||||
)
|
||||
registry.recordResolved('API_KEY', 'secret-value')
|
||||
|
||||
const result = await maybeWriteOutputToFile(
|
||||
FunctionExecute.id,
|
||||
{ outputs: { files: [{ path: 'files/report.txt', mode: 'overwrite' }] } },
|
||||
{ success: true, output: { result: '__var_API_KEY', stdout: '' } },
|
||||
buildContext({ resolvedSecretTraceRegistry: registry })
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
buffer: Buffer.from('__var_API_KEY'),
|
||||
secretProvenance: {
|
||||
status: 'exact',
|
||||
entries: [
|
||||
{
|
||||
name: 'API_KEY',
|
||||
encryptedValue: 'encrypted-api-key',
|
||||
sourceUserId: 'user-1',
|
||||
sourceWorkspaceId: 'workspace-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves anonymous provenance without inventing a secret name', async () => {
|
||||
const registry = {
|
||||
exportCommittedProvenanceForValue: vi.fn().mockReturnValue({
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [{ encryptedValue: 'encrypted-anonymous' }],
|
||||
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
|
||||
}),
|
||||
} as unknown as ResolvedSecretTraceRegistry
|
||||
|
||||
const result = await maybeWriteOutputToFile(
|
||||
FunctionExecute.id,
|
||||
{ outputs: { files: [{ path: 'files/report.txt', mode: 'overwrite' }] } },
|
||||
{ success: true, output: { result: 'anonymous-secret', stdout: '' } },
|
||||
buildContext({ resolvedSecretTraceRegistry: registry })
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
secretProvenance: {
|
||||
status: 'exact',
|
||||
entries: [
|
||||
{
|
||||
encryptedValue: 'encrypted-anonymous',
|
||||
sourceUserId: 'user-1',
|
||||
sourceWorkspaceId: 'workspace-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves the secret source when a different actor writes within the same workspace', async () => {
|
||||
const registry = new ResolvedSecretTraceRegistry(
|
||||
[
|
||||
{
|
||||
name: 'OUTPUT_SECRET',
|
||||
plaintext: 'secret-value',
|
||||
encryptedValue: 'encrypted-output-secret',
|
||||
},
|
||||
],
|
||||
{ userId: 'workflow-owner', workspaceId: 'workspace-1' }
|
||||
)
|
||||
registry.recordResolved('OUTPUT_SECRET', 'secret-value')
|
||||
|
||||
const result = await maybeWriteOutputToFile(
|
||||
FunctionExecute.id,
|
||||
{ outputs: { files: [{ path: 'files/report.txt', mode: 'overwrite' }] } },
|
||||
{ success: true, output: { result: 'secret-value', stdout: '' } },
|
||||
buildContext({ userId: 'billing-actor', resolvedSecretTraceRegistry: registry })
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 'billing-actor',
|
||||
secretProvenance: {
|
||||
status: 'exact',
|
||||
entries: [
|
||||
{
|
||||
name: 'OUTPUT_SECRET',
|
||||
encryptedValue: 'encrypted-output-secret',
|
||||
sourceUserId: 'workflow-owner',
|
||||
sourceWorkspaceId: 'workspace-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('writes raw output with unknown provenance when the source scope differs', async () => {
|
||||
const registry = new ResolvedSecretTraceRegistry(
|
||||
[
|
||||
{
|
||||
name: 'OUTPUT_SECRET',
|
||||
plaintext: 'secret-value',
|
||||
encryptedValue: 'encrypted-output-secret',
|
||||
},
|
||||
],
|
||||
{ userId: 'workflow-owner', workspaceId: 'workspace-2' }
|
||||
)
|
||||
registry.recordResolved('OUTPUT_SECRET', 'secret-value')
|
||||
|
||||
const result = await maybeWriteOutputToFile(
|
||||
FunctionExecute.id,
|
||||
{ outputs: { files: [{ path: 'files/report.txt', mode: 'overwrite' }] } },
|
||||
{ success: true, output: { result: 'secret-value', stdout: '' } },
|
||||
buildContext({ userId: 'billing-actor', resolvedSecretTraceRegistry: registry })
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ secretProvenance: { status: 'unknown' } })
|
||||
)
|
||||
})
|
||||
|
||||
it('prepares every file provenance and marks unavailable lineage unknown', async () => {
|
||||
const exportCommittedProvenanceForValue = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({ version: 1, complete: true, entries: [] })
|
||||
.mockReturnValueOnce({ version: 1, complete: false, entries: [] })
|
||||
const registry = {
|
||||
exportCommittedProvenanceForValue,
|
||||
} as unknown as ResolvedSecretTraceRegistry
|
||||
|
||||
const result = await maybeWriteOutputToFile(
|
||||
FunctionExecute.id,
|
||||
{
|
||||
outputs: {
|
||||
files: [
|
||||
{ path: 'files/report.json', mode: 'overwrite' },
|
||||
{ path: 'files/report.txt', mode: 'overwrite' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ success: true, output: { result: { token: 'value' }, stdout: '' } },
|
||||
buildContext({ resolvedSecretTraceRegistry: registry })
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(exportCommittedProvenanceForValue).toHaveBeenCalledTimes(2)
|
||||
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledTimes(2)
|
||||
expect(mockWriteWorkspaceFileByPath.mock.calls[1]?.[0]).toEqual(
|
||||
expect.objectContaining({ secretProvenance: { status: 'unknown' } })
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves legacy writes without a registry and marks their provenance unknown', async () => {
|
||||
const result = await maybeWriteOutputToFile(
|
||||
FunctionExecute.id,
|
||||
{ outputs: { files: [{ path: 'files/report.json', mode: 'overwrite' }] } },
|
||||
@@ -222,11 +578,10 @@ describe('maybeWriteOutputToFile', () => {
|
||||
buildContext({ resolvedSecretTraceRegistry: undefined })
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'Tool output could not be persisted safely because secret provenance was unavailable.',
|
||||
})
|
||||
expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled()
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ secretProvenance: { status: 'unknown' } })
|
||||
)
|
||||
})
|
||||
|
||||
it('fails loudly instead of silently skipping declared outputs when workspace context is missing', async () => {
|
||||
|
||||
@@ -7,15 +7,24 @@ import { TraceEvent } from '@/lib/copilot/generated/trace-events-v1'
|
||||
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
|
||||
import { withCopilotSpan } from '@/lib/copilot/request/otel'
|
||||
import { denyOutputWriteWithoutWritePermission } from '@/lib/copilot/request/tools/permissions'
|
||||
import {
|
||||
projectToolErrorMessageForCopilot,
|
||||
projectToolOutputForPersistence,
|
||||
} from '@/lib/copilot/request/tools/resolved-secret-result'
|
||||
import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result'
|
||||
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
|
||||
import { decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils'
|
||||
import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer'
|
||||
import {
|
||||
createWorkspaceFileSecretProvenanceFromRegistry,
|
||||
type WorkspaceFileSecretProvenance,
|
||||
type WorkspaceFileSecretProvenanceRepresentation,
|
||||
} from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance'
|
||||
import type { ResolvedSecretMatcher } from '@/executor/utils/resolved-secret-matcher'
|
||||
import {
|
||||
createResolvedSecretMatcher,
|
||||
scanResolvedSecretString,
|
||||
} from '@/executor/utils/resolved-secret-matcher'
|
||||
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
|
||||
|
||||
const logger = createLogger('CopilotToolResultFiles')
|
||||
const MAX_OUTPUT_FILE_PROVENANCE_REPRESENTATIONS = 10_000
|
||||
|
||||
export const OUTPUT_PATH_TOOLS: Set<string> = new Set([FunctionExecute.id, UserTable.id])
|
||||
|
||||
@@ -99,21 +108,7 @@ export function escapeCsvValue(value: unknown): string {
|
||||
}
|
||||
|
||||
export function convertRowsToCsv(rows: Record<string, unknown>[]): string {
|
||||
if (rows.length === 0) return ''
|
||||
|
||||
const headerSet = new Set<string>()
|
||||
for (const row of rows) {
|
||||
for (const key of Object.keys(row)) {
|
||||
headerSet.add(key)
|
||||
}
|
||||
}
|
||||
const headers = [...headerSet]
|
||||
|
||||
const lines = [headers.map(escapeCsvValue).join(',')]
|
||||
for (const row of rows) {
|
||||
lines.push(headers.map((h) => escapeCsvValue(row[h])).join(','))
|
||||
}
|
||||
return lines.join('\n')
|
||||
return convertRowsToCsvWithProvenance(rows).content
|
||||
}
|
||||
|
||||
export function normalizeOutputWorkspaceFileName(outputPath: string): string {
|
||||
@@ -131,19 +126,149 @@ export function resolveOutputFormat(fileName: string, explicit?: string): Output
|
||||
return EXT_TO_FORMAT[ext] ?? 'json'
|
||||
}
|
||||
|
||||
export function serializeOutputForFile(output: unknown, format: OutputFormat): string {
|
||||
interface SerializedOutputFile {
|
||||
content: string
|
||||
provenanceValue: unknown
|
||||
provenanceRepresentations?: readonly WorkspaceFileSecretProvenanceRepresentation[]
|
||||
provenanceRepresentationsComplete?: boolean
|
||||
}
|
||||
|
||||
function convertRowsToCsvWithProvenance(
|
||||
rows: Record<string, unknown>[],
|
||||
registry?: ResolvedSecretTraceRegistry
|
||||
): {
|
||||
content: string
|
||||
representations: readonly WorkspaceFileSecretProvenanceRepresentation[]
|
||||
representationSourceValues: readonly string[]
|
||||
representationsComplete: boolean
|
||||
} {
|
||||
if (rows.length === 0) {
|
||||
return {
|
||||
content: '',
|
||||
representations: [],
|
||||
representationSourceValues: [],
|
||||
representationsComplete: true,
|
||||
}
|
||||
}
|
||||
|
||||
const headerSet = new Set<string>()
|
||||
for (const row of rows) {
|
||||
for (const key of Object.keys(row)) {
|
||||
headerSet.add(key)
|
||||
}
|
||||
}
|
||||
const headers = [...headerSet]
|
||||
const representations = new Map<
|
||||
string,
|
||||
{
|
||||
representation: WorkspaceFileSecretProvenanceRepresentation
|
||||
sourceValue: string
|
||||
}
|
||||
>()
|
||||
let representationsComplete = true
|
||||
let csvQuoteTransformMatcher: ResolvedSecretMatcher | undefined
|
||||
if (registry) {
|
||||
try {
|
||||
const scanLiterals = new Set<string>()
|
||||
for (const { plaintext } of registry.getActiveMatches()) {
|
||||
const jsonEncoded = JSON.stringify(plaintext).slice(1, -1)
|
||||
if (plaintext.includes('"')) scanLiterals.add(plaintext)
|
||||
if (jsonEncoded.includes('"')) scanLiterals.add(jsonEncoded)
|
||||
}
|
||||
csvQuoteTransformMatcher = createResolvedSecretMatcher(
|
||||
[...scanLiterals].map((plaintext) => ({ plaintext, replacement: '' }))
|
||||
)
|
||||
} catch {
|
||||
representationsComplete = false
|
||||
}
|
||||
}
|
||||
const serializeCell = (sourceValue: unknown): string => {
|
||||
const persistedValue = escapeCsvValue(sourceValue)
|
||||
const serializedSource =
|
||||
sourceValue === null || sourceValue === undefined
|
||||
? ''
|
||||
: typeof sourceValue === 'object'
|
||||
? JSON.stringify(sourceValue)
|
||||
: String(sourceValue)
|
||||
if (
|
||||
registry &&
|
||||
csvQuoteTransformMatcher &&
|
||||
representationsComplete &&
|
||||
serializedSource.includes('"')
|
||||
) {
|
||||
try {
|
||||
scanResolvedSecretString(serializedSource, csvQuoteTransformMatcher, (scanLiteral) => {
|
||||
if (!representationsComplete) return
|
||||
const transformedLiteral = scanLiteral.replace(/"/g, '""')
|
||||
const sourceProvenance = registry.exportCommittedProvenanceForValue(scanLiteral)
|
||||
if (!sourceProvenance.complete) {
|
||||
representationsComplete = false
|
||||
return
|
||||
}
|
||||
if (sourceProvenance.entries.length === 0) return
|
||||
const representationKey = `${transformedLiteral}\u0000${sourceProvenance.entries
|
||||
.map((entry) => `${entry.name ?? ''}\u0000${entry.encryptedValue}`)
|
||||
.join('\u0001')}`
|
||||
if (representations.has(representationKey)) return
|
||||
if (representations.size >= MAX_OUTPUT_FILE_PROVENANCE_REPRESENTATIONS) {
|
||||
representationsComplete = false
|
||||
return
|
||||
}
|
||||
representations.set(representationKey, {
|
||||
representation: { sourceProvenance, persistedValue: transformedLiteral },
|
||||
sourceValue: scanLiteral,
|
||||
})
|
||||
})
|
||||
} catch {
|
||||
representationsComplete = false
|
||||
}
|
||||
}
|
||||
return persistedValue
|
||||
}
|
||||
|
||||
const lines = [headers.map(serializeCell).join(',')]
|
||||
for (const row of rows) {
|
||||
lines.push(headers.map((header) => serializeCell(row[header])).join(','))
|
||||
}
|
||||
return {
|
||||
content: lines.join('\n'),
|
||||
representations: [...representations.values()].map(({ representation }) => representation),
|
||||
representationSourceValues: [...representations.values()].map(({ sourceValue }) => sourceValue),
|
||||
representationsComplete,
|
||||
}
|
||||
}
|
||||
|
||||
function prepareOutputForFile(
|
||||
output: unknown,
|
||||
format: OutputFormat,
|
||||
registry?: ResolvedSecretTraceRegistry
|
||||
): SerializedOutputFile {
|
||||
const unwrapped = unwrapFunctionExecuteOutput(output)
|
||||
|
||||
if (typeof unwrapped === 'string') return unwrapped
|
||||
if (typeof unwrapped === 'string') {
|
||||
return { content: unwrapped, provenanceValue: unwrapped }
|
||||
}
|
||||
|
||||
if (format === 'csv') {
|
||||
const rows = extractTabularData(unwrapped)
|
||||
if (rows && rows.length > 0) {
|
||||
return convertRowsToCsv(rows)
|
||||
const { content, representations, representationSourceValues, representationsComplete } =
|
||||
convertRowsToCsvWithProvenance(rows, registry)
|
||||
return {
|
||||
content,
|
||||
provenanceValue: [content, ...representationSourceValues],
|
||||
provenanceRepresentations: representations,
|
||||
provenanceRepresentationsComplete: representationsComplete,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify(unwrapped, null, 2)
|
||||
const content = JSON.stringify(unwrapped, null, 2)
|
||||
return { content, provenanceValue: content }
|
||||
}
|
||||
|
||||
export function serializeOutputForFile(output: unknown, format: OutputFormat): string {
|
||||
return prepareOutputForFile(output, format).content
|
||||
}
|
||||
|
||||
export interface OutputFileDeclaration {
|
||||
@@ -213,7 +338,6 @@ export async function maybeWriteOutputToFile(
|
||||
|
||||
const outputFiles = getOutputFileDeclarations(params).filter((file) => !file.sandboxPath)
|
||||
if (outputFiles.length === 0) return result
|
||||
|
||||
// The tool declared workspace file outputs; passing the successful result
|
||||
// through without writing them would be a silent no-op the model reads as
|
||||
// "file written", so fail loudly instead — but keep the computed output so
|
||||
@@ -230,6 +354,7 @@ export async function maybeWriteOutputToFile(
|
||||
output: result.output,
|
||||
}
|
||||
}
|
||||
const { userId, workspaceId } = context
|
||||
|
||||
const outputObject =
|
||||
result.output && typeof result.output === 'object' && !Array.isArray(result.output)
|
||||
@@ -252,13 +377,7 @@ export async function maybeWriteOutputToFile(
|
||||
const denied = denyOutputWriteWithoutWritePermission(context)
|
||||
if (denied) return denied
|
||||
|
||||
const persistedOutput = projectToolOutputForPersistence(
|
||||
unwrapFunctionExecuteOutput(result.output),
|
||||
context.resolvedSecretTraceRegistry
|
||||
)
|
||||
if (!persistedOutput.safe) {
|
||||
return { success: false, error: persistedOutput.error }
|
||||
}
|
||||
const registry = context.resolvedSecretTraceRegistry
|
||||
|
||||
// Only span the actual write path (where we upload to storage). Fast
|
||||
// no-op returns above don't need a span — they'd just pad the trace
|
||||
@@ -267,27 +386,61 @@ export async function maybeWriteOutputToFile(
|
||||
TraceSpan.CopilotToolsWriteOutputFile,
|
||||
{
|
||||
[TraceAttr.ToolName]: toolName,
|
||||
[TraceAttr.WorkspaceId]: context.workspaceId,
|
||||
[TraceAttr.WorkspaceId]: workspaceId,
|
||||
},
|
||||
async (span) => {
|
||||
try {
|
||||
const writtenFiles = []
|
||||
const preparedByFormat = new Map<
|
||||
OutputFormat,
|
||||
Promise<{
|
||||
buffer: Buffer
|
||||
secretProvenance: WorkspaceFileSecretProvenance
|
||||
}>
|
||||
>()
|
||||
const preparedFiles = []
|
||||
for (const outputFile of outputFiles) {
|
||||
const fileName = normalizeOutputWorkspaceFileName(
|
||||
outputFile.formatPath ?? outputFile.path
|
||||
)
|
||||
const format = resolveOutputFormat(fileName, outputFile.format)
|
||||
const content = serializeOutputForFile(persistedOutput.value, format)
|
||||
const contentType = outputFile.mimeType || FORMAT_TO_CONTENT_TYPE[format]
|
||||
const buffer = Buffer.from(content, 'utf-8')
|
||||
let prepared = preparedByFormat.get(format)
|
||||
if (!prepared) {
|
||||
prepared = (async () => {
|
||||
const {
|
||||
content,
|
||||
provenanceValue,
|
||||
provenanceRepresentations,
|
||||
provenanceRepresentationsComplete,
|
||||
} = prepareOutputForFile(result.output, format, registry)
|
||||
const decision = await createWorkspaceFileSecretProvenanceFromRegistry(
|
||||
registry,
|
||||
content,
|
||||
{ userId, workspaceId },
|
||||
provenanceValue,
|
||||
provenanceRepresentations,
|
||||
provenanceRepresentationsComplete
|
||||
)
|
||||
return {
|
||||
buffer: Buffer.from(content, 'utf-8'),
|
||||
secretProvenance: decision.safe ? decision.provenance : { status: 'unknown' },
|
||||
}
|
||||
})()
|
||||
preparedByFormat.set(format, prepared)
|
||||
}
|
||||
const { buffer, secretProvenance } = await prepared
|
||||
preparedFiles.push({ outputFile, format, contentType, buffer, secretProvenance })
|
||||
}
|
||||
|
||||
const writtenFiles = []
|
||||
for (const { outputFile, format, contentType, buffer, secretProvenance } of preparedFiles) {
|
||||
if (context.abortSignal?.aborted) {
|
||||
throw new Error('Request aborted before tool mutation could be applied')
|
||||
}
|
||||
|
||||
const written = await writeWorkspaceFileByPath({
|
||||
workspaceId: context.workspaceId!,
|
||||
userId: context.userId!,
|
||||
workspaceId,
|
||||
userId,
|
||||
target: {
|
||||
path: outputFile.path,
|
||||
mode: outputFile.mode ?? 'create',
|
||||
@@ -295,6 +448,7 @@ export async function maybeWriteOutputToFile(
|
||||
},
|
||||
buffer,
|
||||
inferredMimeType: contentType,
|
||||
secretProvenance,
|
||||
})
|
||||
writtenFiles.push({
|
||||
...written,
|
||||
|
||||
@@ -24,7 +24,7 @@ describe('projectToolResultForCopilot', () => {
|
||||
'projects active exact and embedded secrets for %s without mutating runtime output',
|
||||
(toolName) => {
|
||||
const registry = createRegistry()
|
||||
registry.recordResolved('SECRET', 'secret-value')
|
||||
registry.recordResolved('SECRET', 'secret-value', { propagated: true })
|
||||
const runtimeResult = {
|
||||
success: true,
|
||||
output: {
|
||||
@@ -51,7 +51,7 @@ describe('projectToolResultForCopilot', () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'Test', plaintext: 'Test', encryptedValue: 'ciphertext' },
|
||||
])
|
||||
registry.recordResolved('Test', 'Test')
|
||||
registry.recordResolved('Test', 'Test', { propagated: true })
|
||||
const runtimeResult = {
|
||||
success: true,
|
||||
output: {
|
||||
@@ -81,7 +81,7 @@ describe('projectToolResultForCopilot', () => {
|
||||
|
||||
it('projects both output and error from a failed Function execution', () => {
|
||||
const registry = createRegistry()
|
||||
registry.recordResolved('SECRET', 'secret-value')
|
||||
registry.recordResolved('SECRET', 'secret-value', { propagated: true })
|
||||
|
||||
expect(
|
||||
projectToolResultForCopilot(
|
||||
@@ -99,9 +99,9 @@ describe('projectToolResultForCopilot', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('projects secret-bearing object keys and omits content when replacement collides', () => {
|
||||
it('projects the model-only copy without mutating raw structural object keys', () => {
|
||||
const registry = createRegistry()
|
||||
registry.recordResolved('SECRET', 'secret-value')
|
||||
registry.recordResolved('SECRET', 'secret-value', { propagated: true })
|
||||
|
||||
expect(
|
||||
projectToolResultForCopilot(
|
||||
@@ -116,6 +116,13 @@ describe('projectToolResultForCopilot', () => {
|
||||
output: { 'prefix-{{SECRET}}': 'safe' },
|
||||
})
|
||||
|
||||
const raw = {
|
||||
success: true,
|
||||
output: { 'prefix-secret-value': 'safe' },
|
||||
}
|
||||
projectToolResultForCopilot(raw, registry)
|
||||
expect(raw.output).toEqual({ 'prefix-secret-value': 'safe' })
|
||||
|
||||
expect(
|
||||
projectToolResultForCopilot(
|
||||
{
|
||||
@@ -133,9 +140,9 @@ describe('projectToolResultForCopilot', () => {
|
||||
{ name: 'BRACE', plaintext: '{', encryptedValue: 'encrypted-brace' },
|
||||
{ name: 'JOINED', plaintext: 'ac', encryptedValue: 'encrypted-ac' },
|
||||
])
|
||||
registry.recordResolved('MIDDLE', 'B')
|
||||
registry.recordResolved('BRACE', '{')
|
||||
registry.recordResolved('JOINED', 'ac')
|
||||
registry.recordResolved('MIDDLE', 'B', { propagated: true })
|
||||
registry.recordResolved('BRACE', '{', { propagated: true })
|
||||
registry.recordResolved('JOINED', 'ac', { propagated: true })
|
||||
|
||||
expect(projectToolResultForCopilot({ success: true, output: 'aBc' }, registry)).toEqual({
|
||||
success: true,
|
||||
@@ -147,7 +154,7 @@ describe('projectToolResultForCopilot', () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'F_SECRET', plaintext: 'F', encryptedValue: 'encrypted-f' },
|
||||
])
|
||||
registry.recordResolved('F_SECRET', 'F')
|
||||
registry.recordResolved('F_SECRET', 'F', { propagated: true })
|
||||
|
||||
const projected = projectToolResultForCopilot(
|
||||
{
|
||||
@@ -165,9 +172,21 @@ describe('projectToolResultForCopilot', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('emits the fixed missing-error message without projecting it as runtime content', () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'T_SECRET', plaintext: 'T', encryptedValue: 'encrypted-t' },
|
||||
])
|
||||
registry.recordResolved('T_SECRET', 'T', { propagated: true })
|
||||
|
||||
expect(projectToolResultForCopilot({ success: false }, registry)).toEqual({
|
||||
success: false,
|
||||
error: TOOL_RESULT_UNAVAILABLE_ERROR,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not project transformed values', () => {
|
||||
const registry = createRegistry()
|
||||
registry.recordResolved('SECRET', 'secret-value')
|
||||
registry.recordResolved('SECRET', 'secret-value', { propagated: true })
|
||||
const encoded = Buffer.from('secret-value').toString('base64')
|
||||
|
||||
expect(
|
||||
@@ -181,9 +200,9 @@ describe('projectToolResultForCopilot', () => {
|
||||
{ name: 'BOOLEAN', plaintext: 'true', encryptedValue: 'boolean-ciphertext' },
|
||||
{ name: 'NULL', plaintext: 'null', encryptedValue: 'null-ciphertext' },
|
||||
])
|
||||
registry.recordResolved('NUMBER', '123')
|
||||
registry.recordResolved('BOOLEAN', 'true')
|
||||
registry.recordResolved('NULL', 'null')
|
||||
registry.recordResolved('NUMBER', '123', { propagated: true })
|
||||
registry.recordResolved('BOOLEAN', 'true', { propagated: true })
|
||||
registry.recordResolved('NULL', 'null', { propagated: true })
|
||||
|
||||
expect(
|
||||
projectToolResultForCopilot(
|
||||
@@ -301,9 +320,9 @@ describe('projectToolResultForCopilot', () => {
|
||||
).toEqual({ success: false, error: TOOL_RESULT_UNAVAILABLE_ERROR })
|
||||
})
|
||||
|
||||
it('projects Copilot-visible resource metadata without changing the runtime result', () => {
|
||||
it('leaves resource metadata outside plaintext result projection', () => {
|
||||
const registry = createRegistry()
|
||||
registry.recordResolved('SECRET', 'secret-value')
|
||||
registry.recordResolved('SECRET', 'secret-value', { propagated: true })
|
||||
const result = {
|
||||
success: true,
|
||||
resources: [
|
||||
@@ -322,7 +341,7 @@ describe('projectToolResultForCopilot', () => {
|
||||
{
|
||||
type: 'file',
|
||||
id: 'file-1',
|
||||
title: '{{SECRET}}.txt',
|
||||
title: 'secret-value.txt',
|
||||
path: '/workspace/report.txt',
|
||||
},
|
||||
],
|
||||
@@ -343,9 +362,9 @@ describe('projectToolResultForCopilot', () => {
|
||||
title: 'report.txt',
|
||||
path: '/workspace/secret-value/report.txt',
|
||||
},
|
||||
])('omits resources whose routing controls contain a secret', (resource) => {
|
||||
])('leaves resource routing controls outside plaintext result projection', (resource) => {
|
||||
const registry = createRegistry()
|
||||
registry.recordResolved('SECRET', 'secret-value')
|
||||
registry.recordResolved('SECRET', 'secret-value', { propagated: true })
|
||||
const projected = projectToolResultForCopilot(
|
||||
{
|
||||
success: true,
|
||||
@@ -355,18 +374,17 @@ describe('projectToolResultForCopilot', () => {
|
||||
registry
|
||||
)
|
||||
|
||||
expect(projected).toEqual({ success: true, output: {}, resources: [] })
|
||||
expect(JSON.stringify(projected)).not.toContain('secret-value')
|
||||
expect(projected).toEqual({ success: true, output: {}, resources: [resource] })
|
||||
})
|
||||
|
||||
it('projects every tool result once provenance is active', () => {
|
||||
it('does not project a tool result from merely active input provenance', () => {
|
||||
const registry = createRegistry()
|
||||
registry.recordResolved('SECRET', 'secret-value')
|
||||
const result = { success: true, output: 'secret-value' }
|
||||
|
||||
expect(projectToolResultForCopilot(result, registry)).toEqual({
|
||||
success: true,
|
||||
output: '{{SECRET}}',
|
||||
output: 'secret-value',
|
||||
})
|
||||
expect(result).toEqual({ success: true, output: 'secret-value' })
|
||||
})
|
||||
|
||||
@@ -1,113 +1,23 @@
|
||||
import { isPlainRecord } from '@sim/utils/object'
|
||||
import type { MothershipResource } from '@/lib/copilot/resources/types'
|
||||
import type { ToolExecutionResult } from '@/lib/copilot/tool-executor/types'
|
||||
import {
|
||||
isResolvedSecretModelContentUnchanged,
|
||||
projectResolvedSecretModelContent,
|
||||
projectResolvedSecretModelControlMessage,
|
||||
projectResolvedSecretModelJsonContent,
|
||||
} from '@/executor/utils/resolved-secret-content-projection'
|
||||
import { projectResolvedSecretModelJsonContent } from '@/executor/utils/resolved-secret-content-projection'
|
||||
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
|
||||
|
||||
export const TOOL_RESULT_UNAVAILABLE_ERROR =
|
||||
'Tool execution settled, but its result could not be returned safely. Do not retry a mutation automatically.'
|
||||
export const TOOL_OUTPUT_PERSISTENCE_UNAVAILABLE_ERROR =
|
||||
'Tool output could not be persisted safely because secret provenance was unavailable.'
|
||||
|
||||
function structuralResult(result: ToolExecutionResult): ToolExecutionResult {
|
||||
return { success: result.success === true }
|
||||
}
|
||||
|
||||
function resourceContent(
|
||||
resources: MothershipResource[]
|
||||
): Array<{ type: string; id: string; title: string; path?: string }> {
|
||||
return resources.map((resource) => ({
|
||||
type: resource.type,
|
||||
id: resource.id,
|
||||
title: resource.title,
|
||||
...(resource.path !== undefined ? { path: resource.path } : {}),
|
||||
}))
|
||||
}
|
||||
|
||||
function modelSafeResources(
|
||||
resources: MothershipResource[],
|
||||
registry: ResolvedSecretTraceRegistry | undefined
|
||||
): MothershipResource[] {
|
||||
return resources.filter((resource) =>
|
||||
isResolvedSecretModelContentUnchanged([resource.type, resource.id, resource.path], registry)
|
||||
)
|
||||
}
|
||||
|
||||
function restoreProjectedResources(
|
||||
resources: MothershipResource[],
|
||||
projectedContent: unknown
|
||||
): MothershipResource[] | undefined {
|
||||
if (!Array.isArray(projectedContent) || projectedContent.length !== resources.length) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const projectedResources: MothershipResource[] = []
|
||||
for (let index = 0; index < resources.length; index += 1) {
|
||||
const resource = resources[index]
|
||||
const content = projectedContent[index]
|
||||
if (
|
||||
!isPlainRecord(content) ||
|
||||
typeof content.type !== 'string' ||
|
||||
typeof content.id !== 'string' ||
|
||||
typeof content.title !== 'string' ||
|
||||
(content.path !== undefined && typeof content.path !== 'string')
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
if (content.type !== resource.type || content.id !== resource.id) continue
|
||||
|
||||
projectedResources.push({
|
||||
type: resource.type,
|
||||
id: resource.id,
|
||||
title: content.title,
|
||||
...(content.path !== undefined ? { path: content.path } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
return projectedResources
|
||||
}
|
||||
|
||||
function omittedResult(
|
||||
result: ToolExecutionResult,
|
||||
registry: ResolvedSecretTraceRegistry | undefined
|
||||
): ToolExecutionResult {
|
||||
function omittedResult(result: ToolExecutionResult): ToolExecutionResult {
|
||||
if (result.success) return { success: true }
|
||||
|
||||
const error =
|
||||
projectResolvedSecretModelControlMessage(TOOL_RESULT_UNAVAILABLE_ERROR, registry) ??
|
||||
TOOL_RESULT_UNAVAILABLE_ERROR
|
||||
return { success: false, error }
|
||||
return { success: false, error: TOOL_RESULT_UNAVAILABLE_ERROR }
|
||||
}
|
||||
|
||||
export type CopilotToolResultProjection =
|
||||
| { safe: true; result: ToolExecutionResult }
|
||||
| { safe: false; result: ToolExecutionResult }
|
||||
|
||||
export type CopilotPersistedOutputProjection =
|
||||
| { safe: true; value: unknown }
|
||||
| { safe: false; error: string }
|
||||
|
||||
/**
|
||||
* Projects only the exact value a Copilot tool is about to persist. The isolated per-tool registry
|
||||
* makes this causal: values activated by this tool become canonical `{{NAME}}` aliases, while a
|
||||
* public low-entropy value cannot be rewritten merely because an earlier sibling activated the
|
||||
* same bytes. Persisting the alias makes later reads safe without a second provenance store.
|
||||
*/
|
||||
export function projectToolOutputForPersistence(
|
||||
value: unknown,
|
||||
registry: ResolvedSecretTraceRegistry | undefined
|
||||
): CopilotPersistedOutputProjection {
|
||||
const projection = projectResolvedSecretModelContent(value, registry)
|
||||
return projection.safe
|
||||
? { safe: true, value: projection.value }
|
||||
: { safe: false, error: TOOL_OUTPUT_PERSISTENCE_UNAVAILABLE_ERROR }
|
||||
}
|
||||
|
||||
/**
|
||||
* Projects terminal tool content and reports whether the complete content was safe to cross.
|
||||
* Callers that isolate provenance per tool call may merge that child registry only when `safe`
|
||||
@@ -119,15 +29,14 @@ export function inspectToolResultForCopilot(
|
||||
registry: ResolvedSecretTraceRegistry | undefined
|
||||
): CopilotToolResultProjection {
|
||||
try {
|
||||
const resultRegistry = registry?.forkForPropagatedEntries()
|
||||
const content: Record<string, unknown> = {}
|
||||
const resources =
|
||||
result.resources !== undefined ? modelSafeResources(result.resources, registry) : undefined
|
||||
const resources = result.resources
|
||||
if (Object.hasOwn(result, 'output')) content.output = result.output
|
||||
if (Object.hasOwn(result, 'error')) content.error = result.error
|
||||
if (resources !== undefined) content.resources = resourceContent(resources)
|
||||
const projection = projectResolvedSecretModelJsonContent(content, registry)
|
||||
const projection = projectResolvedSecretModelJsonContent(content, resultRegistry)
|
||||
if (!projection.safe || !projection.value || typeof projection.value !== 'object') {
|
||||
return { safe: false, result: omittedResult(result, registry) }
|
||||
return { safe: false, result: omittedResult(result) }
|
||||
}
|
||||
|
||||
const projectedContent = projection.value as Record<string, unknown>
|
||||
@@ -135,26 +44,19 @@ export function inspectToolResultForCopilot(
|
||||
if (Object.hasOwn(projectedContent, 'output')) projected.output = projectedContent.output
|
||||
if (Object.hasOwn(projectedContent, 'error')) {
|
||||
if (typeof projectedContent.error !== 'string') {
|
||||
return { safe: false, result: omittedResult(result, registry) }
|
||||
return { safe: false, result: omittedResult(result) }
|
||||
}
|
||||
projected.error = projectedContent.error
|
||||
}
|
||||
if (resources !== undefined) {
|
||||
const projectedResources = restoreProjectedResources(resources, projectedContent.resources)
|
||||
if (!projectedResources) {
|
||||
return { safe: false, result: omittedResult(result, registry) }
|
||||
}
|
||||
projected.resources = projectedResources
|
||||
projected.resources = resources
|
||||
}
|
||||
if (!projected.success && !projected.error) {
|
||||
projected.error = projectResolvedSecretModelControlMessage(
|
||||
TOOL_RESULT_UNAVAILABLE_ERROR,
|
||||
registry
|
||||
)
|
||||
projected.error = TOOL_RESULT_UNAVAILABLE_ERROR
|
||||
}
|
||||
return { safe: true, result: projected }
|
||||
} catch {
|
||||
return { safe: false, result: omittedResult(result, registry) }
|
||||
return { safe: false, result: omittedResult(result) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { loggerMock } from '@sim/testing'
|
||||
import { encryptionMock, encryptionMockFns, loggerMock } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TableDefinition } from '@/lib/table'
|
||||
|
||||
@@ -20,6 +20,8 @@ vi.mock('@/lib/table/rows/service', () => ({
|
||||
replaceTableRows: mockReplaceTableRows,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/security/encryption', () => encryptionMock)
|
||||
|
||||
vi.mock('@/lib/copilot/request/otel', () => ({
|
||||
withCopilotSpan: (
|
||||
_name: string,
|
||||
@@ -147,22 +149,25 @@ describe('maybeWriteOutputToTable', () => {
|
||||
expect(table.id).toBe('tbl_1')
|
||||
})
|
||||
|
||||
it('projects activated secrets before persistence without rewriting sibling literals', async () => {
|
||||
const parentRegistry = new ResolvedSecretTraceRegistry([
|
||||
{
|
||||
name: 'OUTPUT_SECRET',
|
||||
plaintext: 'secret-value',
|
||||
encryptedValue: 'encrypted-output-secret',
|
||||
},
|
||||
{
|
||||
name: 'UNRELATED',
|
||||
plaintext: 'true',
|
||||
encryptedValue: 'encrypted-unrelated',
|
||||
},
|
||||
])
|
||||
it('persists raw values with per-cell provenance without rewriting sibling literals', async () => {
|
||||
const parentRegistry = new ResolvedSecretTraceRegistry(
|
||||
[
|
||||
{
|
||||
name: 'OUTPUT_SECRET',
|
||||
plaintext: 'secret-value',
|
||||
encryptedValue: 'encrypted-output-secret',
|
||||
},
|
||||
{
|
||||
name: 'UNRELATED',
|
||||
plaintext: 'true',
|
||||
encryptedValue: 'encrypted-unrelated',
|
||||
},
|
||||
],
|
||||
{ userId: 'user-1', workspaceId: 'workspace-1' }
|
||||
)
|
||||
parentRegistry.recordResolved('UNRELATED', 'true')
|
||||
const toolRegistry = parentRegistry.forkForToolInput({ code: 'return {{OUTPUT_SECRET}}' })
|
||||
toolRegistry.recordResolved('OUTPUT_SECRET', 'secret-value')
|
||||
const toolRegistry = parentRegistry.forkForInputPaths([])
|
||||
toolRegistry.recordResolved('OUTPUT_SECRET', 'secret-value', { propagated: true })
|
||||
const runtimeRows = [{ name: 'secret-value', age: '123', status: 'true' }]
|
||||
|
||||
const result = await maybeWriteOutputToTable(
|
||||
@@ -173,9 +178,35 @@ describe('maybeWriteOutputToTable', () => {
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const persistedRows = mockReplaceTableRows.mock.calls[0][0].rows
|
||||
const persistedWrite = mockReplaceTableRows.mock.calls[0][0]
|
||||
const persistedRows = persistedWrite.rows
|
||||
expect(persistedRows).toEqual([
|
||||
{ col_name: '{{OUTPUT_SECRET}}', col_age: '123', col_status: 'true' },
|
||||
{ col_name: 'secret-value', col_age: '123', col_status: 'true' },
|
||||
])
|
||||
expect(persistedWrite.secretProvenance).toEqual([
|
||||
{
|
||||
complete: true,
|
||||
columns: {
|
||||
col_name: {
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [{ name: 'OUTPUT_SECRET', encryptedValue: 'encrypted-output-secret' }],
|
||||
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
|
||||
},
|
||||
col_age: {
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [],
|
||||
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
|
||||
},
|
||||
col_status: {
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [],
|
||||
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(runtimeRows).toEqual([{ name: 'secret-value', age: '123', status: 'true' }])
|
||||
|
||||
@@ -189,14 +220,164 @@ describe('maybeWriteOutputToTable', () => {
|
||||
},
|
||||
})
|
||||
|
||||
encryptionMockFns.mockDecryptSecret.mockResolvedValue({ decrypted: 'secret-value' })
|
||||
const readRegistry = new ResolvedSecretTraceRegistry([], {
|
||||
userId: 'user-1',
|
||||
workspaceId: 'workspace-1',
|
||||
})
|
||||
expect(
|
||||
await readRegistry.importCrossingProvenance(
|
||||
persistedWrite.secretProvenance[0].columns.col_name,
|
||||
persistedRows,
|
||||
{ trusted: true }
|
||||
)
|
||||
).toBe(true)
|
||||
const laterRead = projectToolResultForCopilot(
|
||||
{ success: true, output: { data: { rows: persistedRows } } },
|
||||
new ResolvedSecretTraceRegistry()
|
||||
readRegistry
|
||||
)
|
||||
expect(laterRead.output).toEqual({ data: { rows: persistedRows } })
|
||||
expect(laterRead.output).toEqual({
|
||||
data: {
|
||||
rows: [{ col_name: '{{OUTPUT_SECRET}}', col_age: '123', col_status: 'true' }],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('does not write when table persistence provenance is incomplete', async () => {
|
||||
it('never rewrites stored public text when an active secret has a common value', async () => {
|
||||
const registry = new ResolvedSecretTraceRegistry(
|
||||
[{ name: 'SHORT_SECRET', plaintext: 'x', encryptedValue: 'encrypted-short-secret' }],
|
||||
{ userId: 'user-1', workspaceId: 'workspace-1' }
|
||||
)
|
||||
registry.recordResolved('SHORT_SECRET', 'x')
|
||||
const rows = [{ name: 'Box eSign' }, { name: 'Brex' }, { name: 'hex' }]
|
||||
|
||||
const result = await maybeWriteOutputToTable(
|
||||
FunctionExecute.id,
|
||||
{ outputTable: 'tbl_1' },
|
||||
{ success: true, output: { result: rows } },
|
||||
buildContext({ resolvedSecretTraceRegistry: registry })
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockReplaceTableRows.mock.calls[0][0].rows).toEqual([
|
||||
{ col_name: 'Box eSign' },
|
||||
{ col_name: 'Brex' },
|
||||
{ col_name: 'hex' },
|
||||
])
|
||||
})
|
||||
|
||||
it('binds provenance to the values produced by table coercion', async () => {
|
||||
const registry = new ResolvedSecretTraceRegistry(
|
||||
[
|
||||
{ name: 'NUMBER_SECRET', plaintext: '123', encryptedValue: 'encrypted-number' },
|
||||
{ name: 'INVALID_SECRET', plaintext: 'not-a-number', encryptedValue: 'encrypted-invalid' },
|
||||
],
|
||||
{ userId: 'user-1', workspaceId: 'workspace-1' }
|
||||
)
|
||||
registry.recordResolved('NUMBER_SECRET', '123')
|
||||
registry.recordResolved('INVALID_SECRET', 'not-a-number')
|
||||
|
||||
const result = await maybeWriteOutputToTable(
|
||||
FunctionExecute.id,
|
||||
{ outputTable: 'tbl_1' },
|
||||
{
|
||||
success: true,
|
||||
output: { result: [{ age: '123' }, { age: 'not-a-number' }] },
|
||||
},
|
||||
buildContext({ resolvedSecretTraceRegistry: registry })
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockReplaceTableRows.mock.calls[0][0].secretProvenance).toEqual([
|
||||
{
|
||||
complete: true,
|
||||
columns: {
|
||||
col_age: {
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [{ name: 'NUMBER_SECRET', encryptedValue: 'encrypted-number' }],
|
||||
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
complete: true,
|
||||
columns: {
|
||||
col_age: {
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [],
|
||||
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('accepts same-workspace provenance from a different actor and preserves its source', async () => {
|
||||
const registry = new ResolvedSecretTraceRegistry(
|
||||
[{ name: 'OUTPUT_SECRET', plaintext: 'secret-value', encryptedValue: 'encrypted-secret' }],
|
||||
{ userId: 'workflow-owner', workspaceId: 'workspace-1' }
|
||||
)
|
||||
registry.recordResolved('OUTPUT_SECRET', 'secret-value')
|
||||
|
||||
const result = await maybeWriteOutputToTable(
|
||||
FunctionExecute.id,
|
||||
{ outputTable: 'tbl_1' },
|
||||
{ success: true, output: { result: [{ name: 'secret-value' }] } },
|
||||
buildContext({ userId: 'billing-actor', resolvedSecretTraceRegistry: registry })
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(
|
||||
mockReplaceTableRows.mock.calls[0][0].secretProvenance[0].columns.col_name.scope
|
||||
).toEqual({ userId: 'workflow-owner', workspaceId: 'workspace-1' })
|
||||
})
|
||||
|
||||
it('persists raw rows with unknown provenance when the source workspace differs', async () => {
|
||||
const registry = new ResolvedSecretTraceRegistry(
|
||||
[{ name: 'OUTPUT_SECRET', plaintext: 'secret-value', encryptedValue: 'encrypted-secret' }],
|
||||
{ userId: 'user-1', workspaceId: 'workspace-2' }
|
||||
)
|
||||
registry.recordResolved('OUTPUT_SECRET', 'secret-value')
|
||||
|
||||
const result = await maybeWriteOutputToTable(
|
||||
FunctionExecute.id,
|
||||
{ outputTable: 'tbl_1' },
|
||||
{ success: true, output: { result: [{ name: 'secret-value' }] } },
|
||||
buildContext({ resolvedSecretTraceRegistry: registry })
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockReplaceTableRows).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ secretProvenance: [{ complete: false, columns: {} }] }),
|
||||
expect.anything(),
|
||||
expect.any(String)
|
||||
)
|
||||
})
|
||||
|
||||
it('persists raw rows with unknown provenance when the source scope is unavailable', async () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'OUTPUT_SECRET', plaintext: 'secret-value', encryptedValue: 'encrypted-secret' },
|
||||
])
|
||||
registry.recordResolved('OUTPUT_SECRET', 'secret-value')
|
||||
|
||||
const result = await maybeWriteOutputToTable(
|
||||
FunctionExecute.id,
|
||||
{ outputTable: 'tbl_1' },
|
||||
{ success: true, output: { result: [{ name: 'secret-value' }] } },
|
||||
buildContext({ resolvedSecretTraceRegistry: registry })
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockReplaceTableRows).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ secretProvenance: [{ complete: false, columns: {} }] }),
|
||||
expect.anything(),
|
||||
expect.any(String)
|
||||
)
|
||||
})
|
||||
|
||||
it('persists raw rows with unknown provenance when lineage is incomplete', async () => {
|
||||
const registry = new ResolvedSecretTraceRegistry()
|
||||
registry.markIncomplete()
|
||||
|
||||
@@ -207,14 +388,15 @@ describe('maybeWriteOutputToTable', () => {
|
||||
buildContext({ resolvedSecretTraceRegistry: registry })
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'Tool output could not be persisted safely because secret provenance was unavailable.',
|
||||
})
|
||||
expect(mockReplaceTableRows).not.toHaveBeenCalled()
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockReplaceTableRows).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ secretProvenance: [{ complete: false, columns: {} }] }),
|
||||
expect.anything(),
|
||||
expect.any(String)
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves legacy table writes when execution provenance is unavailable', async () => {
|
||||
it('preserves legacy table writes without certifying unavailable provenance', async () => {
|
||||
const result = await maybeWriteOutputToTable(
|
||||
FunctionExecute.id,
|
||||
{ outputTable: 'tbl_1' },
|
||||
@@ -224,7 +406,10 @@ describe('maybeWriteOutputToTable', () => {
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockReplaceTableRows).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ rows: [{ col_name: 'unknown' }] }),
|
||||
expect.objectContaining({
|
||||
rows: [{ col_name: 'unknown' }],
|
||||
secretProvenance: [{ complete: false, columns: {} }],
|
||||
}),
|
||||
expect.anything(),
|
||||
expect.any(String)
|
||||
)
|
||||
@@ -271,10 +456,11 @@ describe('maybeWriteOutputToTable', () => {
|
||||
})
|
||||
|
||||
it('keeps raw errors for terminal projection but projects application logs and OTel events', async () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'SECRET', plaintext: 'secret-value', encryptedValue: 'encrypted-secret-value' },
|
||||
])
|
||||
registry.recordResolved('SECRET', 'secret-value')
|
||||
const registry = new ResolvedSecretTraceRegistry(
|
||||
[{ name: 'SECRET', plaintext: 'secret-value', encryptedValue: 'encrypted-secret-value' }],
|
||||
{ userId: 'user-1', workspaceId: 'workspace-1' }
|
||||
)
|
||||
registry.recordResolved('SECRET', 'secret-value', { propagated: true })
|
||||
mockReplaceTableRows.mockRejectedValue(new Error('Duplicate value "secret-value"'))
|
||||
|
||||
const result = await maybeWriteOutputToTable(
|
||||
@@ -343,11 +529,14 @@ describe('maybeWriteReadCsvToTable', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('projects active secret literals into string-compatible CSV columns', async () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'NUMBER', plaintext: '123', encryptedValue: 'encrypted-number' },
|
||||
{ name: 'BOOLEAN', plaintext: 'true', encryptedValue: 'encrypted-boolean' },
|
||||
])
|
||||
it('persists raw CSV cells with per-cell secret provenance', async () => {
|
||||
const registry = new ResolvedSecretTraceRegistry(
|
||||
[
|
||||
{ name: 'NUMBER', plaintext: '123', encryptedValue: 'encrypted-number' },
|
||||
{ name: 'BOOLEAN', plaintext: 'true', encryptedValue: 'encrypted-boolean' },
|
||||
],
|
||||
{ userId: 'user-1', workspaceId: 'workspace-1' }
|
||||
)
|
||||
registry.recordResolved('NUMBER', '123')
|
||||
registry.recordResolved('BOOLEAN', 'true')
|
||||
|
||||
@@ -363,8 +552,27 @@ describe('maybeWriteReadCsvToTable', () => {
|
||||
expect.objectContaining({
|
||||
rows: [
|
||||
{
|
||||
col_name: '{{NUMBER}}',
|
||||
col_status: '{{BOOLEAN}}',
|
||||
col_name: '123',
|
||||
col_status: 'true',
|
||||
},
|
||||
],
|
||||
secretProvenance: [
|
||||
{
|
||||
complete: true,
|
||||
columns: {
|
||||
col_name: {
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [{ name: 'NUMBER', encryptedValue: 'encrypted-number' }],
|
||||
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
|
||||
},
|
||||
col_status: {
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [{ name: 'BOOLEAN', encryptedValue: 'encrypted-boolean' }],
|
||||
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -373,32 +581,104 @@ describe('maybeWriteReadCsvToTable', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects active secret literals in number and boolean columns before mutation', async () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'NUMBER', plaintext: '123', encryptedValue: 'encrypted-number' },
|
||||
{ name: 'BOOLEAN', plaintext: 'true', encryptedValue: 'encrypted-boolean' },
|
||||
])
|
||||
it('retains original provenance after a quote-escaped CSV round trip', async () => {
|
||||
encryptionMockFns.mockDecryptSecret.mockImplementation(async (encryptedValue: string) => ({
|
||||
decrypted: encryptedValue === 'encrypted-original' ? 'a"b' : '"a""b"',
|
||||
}))
|
||||
const registry = new ResolvedSecretTraceRegistry([], {
|
||||
userId: 'user-1',
|
||||
workspaceId: 'workspace-1',
|
||||
})
|
||||
await expect(
|
||||
registry.importProvenance(
|
||||
{
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [
|
||||
{ name: 'CSV_SECRET', encryptedValue: 'encrypted-original' },
|
||||
{ name: 'CSV_SECRET', encryptedValue: 'encrypted-representation' },
|
||||
],
|
||||
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
|
||||
},
|
||||
{ trusted: true }
|
||||
)
|
||||
).resolves.toBe(true)
|
||||
|
||||
const result = await maybeWriteReadCsvToTable(
|
||||
ReadTool.id,
|
||||
{ outputTable: 'tbl_1', path: 'files/people.csv' },
|
||||
{ success: true, output: { content: 'name\n"a""b"' } },
|
||||
buildContext({ resolvedSecretTraceRegistry: registry })
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockReplaceTableRows).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rows: [{ col_name: 'a"b' }],
|
||||
secretProvenance: [
|
||||
{
|
||||
complete: true,
|
||||
columns: {
|
||||
col_name: {
|
||||
version: 1,
|
||||
complete: true,
|
||||
entries: [{ name: 'CSV_SECRET', encryptedValue: 'encrypted-original' }],
|
||||
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
expect.anything(),
|
||||
expect.any(String)
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves numeric and boolean cells while recording their provenance', async () => {
|
||||
const registry = new ResolvedSecretTraceRegistry(
|
||||
[
|
||||
{ name: 'NUMBER', plaintext: '123', encryptedValue: 'encrypted-number' },
|
||||
{ name: 'BOOLEAN', plaintext: 'true', encryptedValue: 'encrypted-boolean' },
|
||||
],
|
||||
{ userId: 'user-1', workspaceId: 'workspace-1' }
|
||||
)
|
||||
registry.recordResolved('NUMBER', '123')
|
||||
registry.recordResolved('BOOLEAN', 'true')
|
||||
|
||||
const result = await maybeWriteReadCsvToTable(
|
||||
ReadTool.id,
|
||||
{ outputTable: 'tbl_1', path: 'files/people.csv' },
|
||||
{ success: true, output: { content: 'name,age,active\nAlice,123,true' } },
|
||||
{ outputTable: 'tbl_1', path: 'files/people.json' },
|
||||
{
|
||||
success: true,
|
||||
output: { content: '[{"name":"Alice","age":123,"active":true}]' },
|
||||
},
|
||||
buildContext({ resolvedSecretTraceRegistry: registry })
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error:
|
||||
'Tool output could not be persisted safely because a resolved secret is incompatible with the target column type.',
|
||||
})
|
||||
expect(mockReplaceTableRows).not.toHaveBeenCalled()
|
||||
expect(JSON.stringify(result)).not.toContain('123')
|
||||
expect(JSON.stringify(result)).not.toContain('true')
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockReplaceTableRows).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rows: [{ col_name: 'Alice', col_age: 123, col_active: true }],
|
||||
secretProvenance: [
|
||||
expect.objectContaining({
|
||||
complete: true,
|
||||
columns: expect.objectContaining({
|
||||
col_age: expect.objectContaining({
|
||||
entries: [{ name: 'NUMBER', encryptedValue: 'encrypted-number' }],
|
||||
}),
|
||||
col_active: expect.objectContaining({
|
||||
entries: [{ name: 'BOOLEAN', encryptedValue: 'encrypted-boolean' }],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
expect.anything(),
|
||||
expect.any(String)
|
||||
)
|
||||
})
|
||||
|
||||
it('does not import CSV rows when persistence provenance is incomplete', async () => {
|
||||
it('imports raw CSV rows with unknown provenance when lineage is incomplete', async () => {
|
||||
const registry = new ResolvedSecretTraceRegistry()
|
||||
registry.markIncomplete()
|
||||
|
||||
@@ -409,14 +689,15 @@ describe('maybeWriteReadCsvToTable', () => {
|
||||
buildContext({ resolvedSecretTraceRegistry: registry })
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'Tool output could not be persisted safely because secret provenance was unavailable.',
|
||||
})
|
||||
expect(mockReplaceTableRows).not.toHaveBeenCalled()
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockReplaceTableRows).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ secretProvenance: [{ complete: false, columns: {} }] }),
|
||||
expect.anything(),
|
||||
expect.any(String)
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves legacy CSV imports when execution provenance is unavailable', async () => {
|
||||
it('preserves legacy CSV imports without certifying unavailable provenance', async () => {
|
||||
const result = await maybeWriteReadCsvToTable(
|
||||
ReadTool.id,
|
||||
{ outputTable: 'tbl_1', path: 'files/people.csv' },
|
||||
@@ -428,6 +709,7 @@ describe('maybeWriteReadCsvToTable', () => {
|
||||
expect(mockReplaceTableRows).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rows: [{ col_name: 'legacy-value', col_age: '123', col_active: 'true' }],
|
||||
secretProvenance: [{ complete: false, columns: {} }],
|
||||
}),
|
||||
expect.anything(),
|
||||
expect.any(String)
|
||||
@@ -462,10 +744,11 @@ describe('maybeWriteReadCsvToTable', () => {
|
||||
})
|
||||
|
||||
it('projects active secret literals in CSV-import log and OTel errors', async () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'SECRET', plaintext: 'secret-value', encryptedValue: 'encrypted-secret-value' },
|
||||
])
|
||||
registry.recordResolved('SECRET', 'secret-value')
|
||||
const registry = new ResolvedSecretTraceRegistry(
|
||||
[{ name: 'SECRET', plaintext: 'secret-value', encryptedValue: 'encrypted-secret-value' }],
|
||||
{ userId: 'user-1', workspaceId: 'workspace-1' }
|
||||
)
|
||||
registry.recordResolved('SECRET', 'secret-value', { propagated: true })
|
||||
mockReplaceTableRows.mockRejectedValue(new Error('Duplicate value "secret-value"'))
|
||||
|
||||
const result = await maybeWriteReadCsvToTable(
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
@@ -11,40 +10,22 @@ import { TraceEvent } from '@/lib/copilot/generated/trace-events-v1'
|
||||
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
|
||||
import { withCopilotSpan } from '@/lib/copilot/request/otel'
|
||||
import { denyOutputWriteWithoutWritePermission } from '@/lib/copilot/request/tools/permissions'
|
||||
import {
|
||||
projectToolErrorMessageForCopilot,
|
||||
projectToolOutputForPersistence,
|
||||
} from '@/lib/copilot/request/tools/resolved-secret-result'
|
||||
import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result'
|
||||
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
|
||||
import { isPrivateSecretProvenanceScopeCompatible } from '@/lib/execution/durable-secret-provenance'
|
||||
import type { RowData, TableDefinition } from '@/lib/table'
|
||||
import { buildIdByName, rowDataNameToId } from '@/lib/table/column-keys'
|
||||
import { columnTypeOf } from '@/lib/table/column-types'
|
||||
import { createExactEmptyTableRowSecretProvenance } from '@/lib/table/rows/secret-provenance'
|
||||
import {
|
||||
createTableRowSecretProvenanceFromRegistry,
|
||||
createUnknownTableRowSecretProvenance,
|
||||
} from '@/lib/table/rows/secret-provenance'
|
||||
import { replaceTableRows } from '@/lib/table/rows/service'
|
||||
import { getTableById } from '@/lib/table/service'
|
||||
import { coerceRowValues } from '@/lib/table/validation'
|
||||
|
||||
const logger = createLogger('CopilotToolResultTables')
|
||||
|
||||
const MAX_OUTPUT_TABLE_ROWS = 10_000
|
||||
const TABLE_SECRET_PROJECTION_UNSUPPORTED_ERROR =
|
||||
'Tool output could not be persisted safely because a resolved secret is incompatible with the target column type.'
|
||||
|
||||
function hasUnsupportedProjectedCell(
|
||||
table: TableDefinition,
|
||||
sourceRows: Array<Record<string, unknown>>,
|
||||
projectedRows: Array<Record<string, unknown>>
|
||||
): boolean {
|
||||
const columnsByName = new Map(table.schema.columns.map((column) => [column.name, column]))
|
||||
for (let rowIndex = 0; rowIndex < projectedRows.length; rowIndex += 1) {
|
||||
for (const [name, projectedValue] of Object.entries(projectedRows[rowIndex])) {
|
||||
const column = columnsByName.get(name)
|
||||
if (!column || isDeepStrictEqual(sourceRows[rowIndex]?.[name], projectedValue)) continue
|
||||
const type = columnTypeOf(column).id
|
||||
if (type !== 'string' && type !== 'json') return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces a table's rows with wire rows keyed by column name. Translates the
|
||||
@@ -57,37 +38,43 @@ async function replaceTableRowsFromWire(
|
||||
rows: Array<Record<string, unknown>>,
|
||||
context: ExecutionContext
|
||||
): Promise<{ error?: string }> {
|
||||
const persistenceProjection = context.resolvedSecretTraceRegistry
|
||||
? projectToolOutputForPersistence(rows, context.resolvedSecretTraceRegistry)
|
||||
: { safe: true as const, value: rows }
|
||||
if (!persistenceProjection.safe) return { error: persistenceProjection.error }
|
||||
if (
|
||||
!Array.isArray(persistenceProjection.value) ||
|
||||
!persistenceProjection.value.every(isPlainRecord)
|
||||
) {
|
||||
if (!rows.every(isPlainRecord)) {
|
||||
return { error: 'Table rows could not be persisted safely' }
|
||||
}
|
||||
if (hasUnsupportedProjectedCell(table, rows, persistenceProjection.value)) {
|
||||
return { error: TABLE_SECRET_PROJECTION_UNSUPPORTED_ERROR }
|
||||
}
|
||||
|
||||
const idByName = buildIdByName(table.schema)
|
||||
const idKeyedRows = persistenceProjection.value.map((row) =>
|
||||
rowDataNameToId(row as RowData, idByName)
|
||||
)
|
||||
const idKeyedRows = rows.map((row) => rowDataNameToId(row as RowData, idByName))
|
||||
const emptyIndex = idKeyedRows.findIndex((row) => Object.keys(row).length === 0)
|
||||
if (emptyIndex !== -1) {
|
||||
return {
|
||||
error: `Row ${emptyIndex + 1} has no keys matching columns on table "${table.name}" (columns: ${table.schema.columns.map((c) => c.name).join(', ')})`,
|
||||
}
|
||||
}
|
||||
const registry = context.resolvedSecretTraceRegistry
|
||||
const persistedRows = idKeyedRows.map((row) => {
|
||||
const persistedRow = { ...row }
|
||||
coerceRowValues(persistedRow, table.schema)
|
||||
return persistedRow
|
||||
})
|
||||
const destinationScope = { userId: context.userId, workspaceId: table.workspaceId }
|
||||
const secretProvenance = persistedRows.map((row) => {
|
||||
if (!registry) return createUnknownTableRowSecretProvenance()
|
||||
const provenance = createTableRowSecretProvenanceFromRegistry(row, registry)
|
||||
if (!provenance.complete) return createUnknownTableRowSecretProvenance()
|
||||
const compatible = Object.values(provenance.columns).every(
|
||||
(columnProvenance) =>
|
||||
columnProvenance.entries.length === 0 ||
|
||||
isPrivateSecretProvenanceScopeCompatible(columnProvenance.scope, destinationScope)
|
||||
)
|
||||
return compatible ? provenance : createUnknownTableRowSecretProvenance()
|
||||
})
|
||||
await replaceTableRows(
|
||||
{
|
||||
tableId: table.id,
|
||||
rows: idKeyedRows,
|
||||
workspaceId: table.workspaceId,
|
||||
userId: context.userId,
|
||||
secretProvenance: idKeyedRows.map(createExactEmptyTableRowSecretProvenance),
|
||||
secretProvenance,
|
||||
},
|
||||
table,
|
||||
generateId().slice(0, 8)
|
||||
|
||||
@@ -94,7 +94,7 @@ describe('copilot tool executor fallback', () => {
|
||||
const registry = new ResolvedSecretTraceRegistry([
|
||||
{ name: 'API_KEY', plaintext: secret, encryptedValue: 'encrypted-secret' },
|
||||
])
|
||||
registry.recordResolved('API_KEY', secret)
|
||||
registry.recordResolved('API_KEY', secret, { propagated: true })
|
||||
isKnownTool.mockReturnValue(true)
|
||||
isSimExecuted.mockReturnValue(true)
|
||||
isClientExecuted.mockReturnValue(false)
|
||||
|
||||
@@ -329,11 +329,14 @@ describe('executeDeployCustomBlock', () => {
|
||||
it('ingests a workspace-file icon into public icon storage', async () => {
|
||||
listWorkspaceFilesMock.mockResolvedValue([
|
||||
{
|
||||
id: 'file-1',
|
||||
workspaceId: 'ws-1',
|
||||
name: 'icon.png',
|
||||
folderPath: null,
|
||||
type: 'image/png',
|
||||
size: 1024,
|
||||
key: 'workspace/ws-1/123-abc-icon.png',
|
||||
storageContext: 'workspace',
|
||||
},
|
||||
])
|
||||
fetchWorkspaceFileBufferMock.mockResolvedValue(Buffer.from('png-bytes'))
|
||||
|
||||
@@ -68,7 +68,6 @@ async function resolveIconUrl(
|
||||
if (record.size > MAX_ICON_BYTES) {
|
||||
throw new CustomBlockValidationError('Icon file must be 5MB or smaller')
|
||||
}
|
||||
|
||||
const buffer = await fetchWorkspaceFileBuffer(record)
|
||||
const safeFileName = record.name.replace(/[^a-zA-Z0-9.-]/g, '_')
|
||||
const uploaded = await uploadFile({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user