mirror of
https://github.com/danielmiessler/Fabric.git
synced 2026-01-08 22:08:03 -05:00
fix: improve template extension handling for {{input}} and add examples
This commit is contained in:
2
go.sum
2
go.sum
@@ -27,8 +27,6 @@ github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kk
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||
github.com/anthropics/anthropic-sdk-go v1.12.0 h1:xPqlGnq7rWrTiHazIvCiumA0u7mGQnwDQtvA1M82h9U=
|
||||
github.com/anthropics/anthropic-sdk-go v1.12.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
|
||||
github.com/anthropics/anthropic-sdk-go v1.13.0 h1:Bhbe8sRoDPtipttg8bQYrMCKe2b79+q6rFW1vOKEUKI=
|
||||
github.com/anthropics/anthropic-sdk-go v1.13.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
|
||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
|
||||
|
||||
@@ -99,9 +99,9 @@ func (o *PatternsEntity) applyVariables(
|
||||
withSentinel := strings.ReplaceAll(pattern.Pattern, "{{input}}", inputSentinel)
|
||||
|
||||
// Process all other template variables in the pattern
|
||||
// At this point, our sentinel ensures {{input}} won't be affected
|
||||
// Pass the actual input so extension calls can use {{input}} within their value parameter
|
||||
var processed string
|
||||
if processed, err = template.ApplyTemplate(withSentinel, variables, ""); err != nil {
|
||||
if processed, err = template.ApplyTemplate(withSentinel, variables, input); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -175,6 +175,26 @@ what does this say about me?
|
||||
./fabric -p ./plugins/template/Examples/test_pattern.md
|
||||
```
|
||||
|
||||
## Passing {{input}} to extensions inside patterns
|
||||
|
||||
```
|
||||
Create a pattern called ai_summarize that uses extensions (see openai.yaml and copy for claude)
|
||||
|
||||
Summarize the responses from both AI models:
|
||||
|
||||
OpenAI Response:
|
||||
{{ext:openai:chat:{{input}}}}
|
||||
|
||||
Claude Response:
|
||||
{{ext:claude:chat:{{input}}}}
|
||||
|
||||
```
|
||||
|
||||
```bash
|
||||
echo "What is Artificial Intelligence" | ../fabric-fix -p ai_summarize
|
||||
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Hash Verification**
|
||||
|
||||
20
internal/plugins/template/Examples/openai-chat.sh
Executable file
20
internal/plugins/template/Examples/openai-chat.sh
Executable file
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
INPUT=$(jq -R -s '.' <<< "$*")
|
||||
RESPONSE=$(curl "$OPENAI_API_BASE_URL/chat/completions" \
|
||||
-s -w "\n%{http_code}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $OPENAI_API_KEY" \
|
||||
-d "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"user\",\"content\":$INPUT}]}")
|
||||
|
||||
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
||||
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||
|
||||
if [[ "$HTTP_CODE" -ne 200 ]]; then
|
||||
echo "Error: HTTP $HTTP_CODE" >&2
|
||||
echo "$BODY" | jq -r '.error.message // "Unknown error"' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "$BODY" | jq -r '.choices[0].message.content'
|
||||
14
internal/plugins/template/Examples/openai.yaml
Normal file
14
internal/plugins/template/Examples/openai.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
name: openai
|
||||
executable: "/Users/ourdecisions/.config/fabric/extensions/bin/openai-chat.sh"
|
||||
type: executable
|
||||
timeout: "30s"
|
||||
description: "Call OpenAI Chat Completions API"
|
||||
version: "1.0.0"
|
||||
|
||||
operations:
|
||||
chat:
|
||||
cmd_template: "{{executable}} {{value}}"
|
||||
|
||||
config:
|
||||
output:
|
||||
method: stdout
|
||||
@@ -140,6 +140,11 @@ func (r *ExtensionRegistry) Register(configPath string) error {
|
||||
return fmt.Errorf("failed to hash executable: %w", err)
|
||||
}
|
||||
|
||||
// Validate full extension definition (ensures operations and cmd_template present)
|
||||
if err := r.validateExtensionDefinition(&ext); err != nil {
|
||||
return fmt.Errorf("invalid extension definition: %w", err)
|
||||
}
|
||||
|
||||
// Store entry
|
||||
r.registry.Extensions[ext.Name] = &RegistryEntry{
|
||||
ConfigPath: absPath,
|
||||
|
||||
@@ -10,6 +10,10 @@ import (
|
||||
debuglog "github.com/danielmiessler/fabric/internal/log"
|
||||
)
|
||||
|
||||
// inputSentinel is used to temporarily replace {{input}} during template processing
|
||||
// to prevent recursive variable resolution
|
||||
const inputSentinel = "__FABRIC_INPUT_SENTINEL_TOKEN__"
|
||||
|
||||
var (
|
||||
textPlugin = &TextPlugin{}
|
||||
datetimePlugin = &DateTimePlugin{}
|
||||
@@ -37,152 +41,65 @@ func debugf(format string, a ...interface{}) {
|
||||
debuglog.Debug(debuglog.Trace, format, a...)
|
||||
}
|
||||
|
||||
func ApplyTemplate(content string, variables map[string]string, input string) (string, error) {
|
||||
|
||||
var missingVars []string
|
||||
r := regexp.MustCompile(`\{\{([^{}]+)\}\}`)
|
||||
|
||||
debugf("Starting template processing\n")
|
||||
for strings.Contains(content, "{{") {
|
||||
matches := r.FindAllStringSubmatch(content, -1)
|
||||
if len(matches) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
replaced := false
|
||||
for _, match := range matches {
|
||||
fullMatch := match[0]
|
||||
varName := match[1]
|
||||
|
||||
// Check if this is a plugin call
|
||||
if strings.HasPrefix(varName, "plugin:") {
|
||||
pluginMatches := pluginPattern.FindStringSubmatch(fullMatch)
|
||||
if len(pluginMatches) >= 3 {
|
||||
namespace := pluginMatches[1]
|
||||
operation := pluginMatches[2]
|
||||
value := ""
|
||||
if len(pluginMatches) == 4 {
|
||||
value = pluginMatches[3]
|
||||
}
|
||||
|
||||
debugf("\nPlugin call:\n")
|
||||
debugf(" Namespace: %s\n", namespace)
|
||||
debugf(" Operation: %s\n", operation)
|
||||
debugf(" Value: %s\n", value)
|
||||
|
||||
var result string
|
||||
var err error
|
||||
|
||||
switch namespace {
|
||||
case "text":
|
||||
debugf("Executing text plugin\n")
|
||||
result, err = textPlugin.Apply(operation, value)
|
||||
case "datetime":
|
||||
debugf("Executing datetime plugin\n")
|
||||
result, err = datetimePlugin.Apply(operation, value)
|
||||
case "file":
|
||||
debugf("Executing file plugin\n")
|
||||
result, err = filePlugin.Apply(operation, value)
|
||||
debugf("File plugin result: %#v\n", result)
|
||||
case "fetch":
|
||||
debugf("Executing fetch plugin\n")
|
||||
result, err = fetchPlugin.Apply(operation, value)
|
||||
case "sys":
|
||||
debugf("Executing sys plugin\n")
|
||||
result, err = sysPlugin.Apply(operation, value)
|
||||
default:
|
||||
return "", fmt.Errorf("unknown plugin namespace: %s", namespace)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
debugf("Plugin error: %v\n", err)
|
||||
return "", fmt.Errorf("plugin %s error: %v", namespace, err)
|
||||
}
|
||||
|
||||
debugf("Plugin result: %s\n", result)
|
||||
content = strings.ReplaceAll(content, fullMatch, result)
|
||||
debugf("Content after replacement: %s\n", content)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if pluginMatches := extensionPattern.FindStringSubmatch(fullMatch); len(pluginMatches) >= 3 {
|
||||
name := pluginMatches[1]
|
||||
operation := pluginMatches[2]
|
||||
value := ""
|
||||
if len(pluginMatches) == 4 {
|
||||
value = pluginMatches[3]
|
||||
}
|
||||
|
||||
debugf("\nExtension call:\n")
|
||||
debugf(" Name: %s\n", name)
|
||||
debugf(" Operation: %s\n", operation)
|
||||
debugf(" Value: %s\n", value)
|
||||
|
||||
result, err := extensionManager.ProcessExtension(name, operation, value)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("extension %s error: %v", name, err)
|
||||
}
|
||||
|
||||
content = strings.ReplaceAll(content, fullMatch, result)
|
||||
replaced = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle regular variables and input
|
||||
debugf("Processing variable: %s\n", varName)
|
||||
if varName == "input" {
|
||||
debugf("Replacing {{input}}\n")
|
||||
replaced = true
|
||||
content = strings.ReplaceAll(content, fullMatch, input)
|
||||
} else {
|
||||
if val, ok := variables[varName]; !ok {
|
||||
debugf("Missing variable: %s\n", varName)
|
||||
missingVars = append(missingVars, varName)
|
||||
return "", fmt.Errorf("missing required variable: %s", varName)
|
||||
} else {
|
||||
debugf("Replacing variable %s with value: %s\n", varName, val)
|
||||
content = strings.ReplaceAll(content, fullMatch, val)
|
||||
replaced = true
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
return "", fmt.Errorf("template processing stuck - potential infinite loop")
|
||||
}
|
||||
// matchTriple extracts the first two required and optional third value from a token
|
||||
// pattern of the form {{type:part1:part2(:part3)?}} returning part1, part2, part3 (possibly empty)
|
||||
func matchTriple(r *regexp.Regexp, full string) (string, string, string, bool) {
|
||||
parts := r.FindStringSubmatch(full)
|
||||
if len(parts) >= 3 {
|
||||
v := ""
|
||||
if len(parts) == 4 {
|
||||
v = parts[3]
|
||||
}
|
||||
return parts[1], parts[2], v, true
|
||||
}
|
||||
return "", "", "", false
|
||||
}
|
||||
|
||||
debugf("Starting template processing\n")
|
||||
for strings.Contains(content, "{{") {
|
||||
matches := r.FindAllStringSubmatch(content, -1)
|
||||
func ApplyTemplate(content string, variables map[string]string, input string) (string, error) {
|
||||
tokenPattern := regexp.MustCompile(`\{\{([^{}]+)\}\}`)
|
||||
|
||||
debugf("Starting template processing with input='%s'\n", input)
|
||||
|
||||
for {
|
||||
if !strings.Contains(content, "{{") {
|
||||
break
|
||||
}
|
||||
matches := tokenPattern.FindAllStringSubmatch(content, -1)
|
||||
if len(matches) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
replaced := false
|
||||
for _, match := range matches {
|
||||
fullMatch := match[0]
|
||||
varName := match[1]
|
||||
progress := false
|
||||
for _, m := range matches {
|
||||
full := m[0]
|
||||
raw := m[1]
|
||||
|
||||
// Check if this is a plugin call
|
||||
if strings.HasPrefix(varName, "plugin:") {
|
||||
pluginMatches := pluginPattern.FindStringSubmatch(fullMatch)
|
||||
if len(pluginMatches) >= 3 {
|
||||
namespace := pluginMatches[1]
|
||||
operation := pluginMatches[2]
|
||||
value := ""
|
||||
if len(pluginMatches) == 4 {
|
||||
value = pluginMatches[3]
|
||||
// Extension call
|
||||
if strings.HasPrefix(raw, "ext:") {
|
||||
if name, operation, value, ok := matchTriple(extensionPattern, full); ok {
|
||||
if strings.Contains(value, inputSentinel) {
|
||||
value = strings.ReplaceAll(value, inputSentinel, input)
|
||||
debugf("Replaced sentinel in extension value with input\n")
|
||||
}
|
||||
debugf("Extension call: name=%s operation=%s value=%s\n", name, operation, value)
|
||||
result, err := extensionManager.ProcessExtension(name, operation, value)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("extension %s error: %v", name, err)
|
||||
}
|
||||
content = strings.ReplaceAll(content, full, result)
|
||||
progress = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
debugf("\nPlugin call:\n")
|
||||
debugf(" Namespace: %s\n", namespace)
|
||||
debugf(" Operation: %s\n", operation)
|
||||
debugf(" Value: %s\n", value)
|
||||
|
||||
var result string
|
||||
var err error
|
||||
|
||||
// Plugin call
|
||||
if strings.HasPrefix(raw, "plugin:") {
|
||||
if namespace, operation, value, ok := matchTriple(pluginPattern, full); ok {
|
||||
debugf("Plugin call: namespace=%s operation=%s value=%s\n", namespace, operation, value)
|
||||
var (
|
||||
result string
|
||||
err error
|
||||
)
|
||||
switch namespace {
|
||||
case "text":
|
||||
debugf("Executing text plugin\n")
|
||||
@@ -203,39 +120,33 @@ func ApplyTemplate(content string, variables map[string]string, input string) (s
|
||||
default:
|
||||
return "", fmt.Errorf("unknown plugin namespace: %s", namespace)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
debugf("Plugin error: %v\n", err)
|
||||
return "", fmt.Errorf("plugin %s error: %v", namespace, err)
|
||||
}
|
||||
|
||||
debugf("Plugin result: %s\n", result)
|
||||
content = strings.ReplaceAll(content, fullMatch, result)
|
||||
debugf("Content after replacement: %s\n", content)
|
||||
content = strings.ReplaceAll(content, full, result)
|
||||
progress = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Handle regular variables and input
|
||||
debugf("Processing variable: %s\n", varName)
|
||||
if varName == "input" {
|
||||
debugf("Replacing {{input}}\n")
|
||||
replaced = true
|
||||
content = strings.ReplaceAll(content, fullMatch, input)
|
||||
} else {
|
||||
if val, ok := variables[varName]; !ok {
|
||||
debugf("Missing variable: %s\n", varName)
|
||||
missingVars = append(missingVars, varName)
|
||||
return "", fmt.Errorf("missing required variable: %s", varName)
|
||||
} else {
|
||||
debugf("Replacing variable %s with value: %s\n", varName, val)
|
||||
content = strings.ReplaceAll(content, fullMatch, val)
|
||||
replaced = true
|
||||
// Variables / input / sentinel
|
||||
switch raw {
|
||||
case "input", inputSentinel:
|
||||
content = strings.ReplaceAll(content, full, input)
|
||||
progress = true
|
||||
default:
|
||||
val, ok := variables[raw]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("missing required variable: %s", raw)
|
||||
}
|
||||
content = strings.ReplaceAll(content, full, val)
|
||||
progress = true
|
||||
}
|
||||
if !replaced {
|
||||
return "", fmt.Errorf("template processing stuck - potential infinite loop")
|
||||
}
|
||||
}
|
||||
|
||||
if !progress {
|
||||
return "", fmt.Errorf("template processing stuck - potential infinite loop")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
77
internal/plugins/template/template_extension_mixed_test.go
Normal file
77
internal/plugins/template/template_extension_mixed_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package template
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestExtensionValueMixedInputAndVariable ensures an extension value mixing {{input}} and another template variable is processed.
|
||||
func TestExtensionValueMixedInputAndVariable(t *testing.T) {
|
||||
input := "PRIMARY"
|
||||
variables := map[string]string{
|
||||
"suffix": "SUF",
|
||||
}
|
||||
|
||||
// Build temp extension environment
|
||||
tmp := t.TempDir()
|
||||
configDir := filepath.Join(tmp, ".config", "fabric")
|
||||
extsDir := filepath.Join(configDir, "extensions")
|
||||
binDir := filepath.Join(extsDir, "bin")
|
||||
configsDir := filepath.Join(extsDir, "configs")
|
||||
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir bin: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(configsDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir configs: %v", err)
|
||||
}
|
||||
|
||||
scriptPath := filepath.Join(binDir, "mix-echo.sh")
|
||||
// Simple echo script; avoid percent formatting complexities
|
||||
script := "#!/bin/sh\necho VAL=$1\n"
|
||||
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("write script: %v", err)
|
||||
}
|
||||
|
||||
configYAML := "" +
|
||||
"name: mix-echo\n" +
|
||||
"type: executable\n" +
|
||||
"executable: " + scriptPath + "\n" +
|
||||
"description: mixed input/variable test\n" +
|
||||
"version: 1.0.0\n" +
|
||||
"timeout: 5s\n" +
|
||||
"operations:\n" +
|
||||
" echo:\n" +
|
||||
" cmd_template: '{{executable}} {{value}}'\n"
|
||||
if err := os.WriteFile(filepath.Join(configsDir, "mix-echo.yaml"), []byte(configYAML), 0o644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
// Use a fresh extension manager isolated from global one
|
||||
mgr := NewExtensionManager(configDir)
|
||||
if err := mgr.RegisterExtension(filepath.Join(configsDir, "mix-echo.yaml")); err != nil {
|
||||
// Some environments may not support execution; skip instead of fail hard
|
||||
if strings.Contains(err.Error(), "operation not permitted") {
|
||||
t.Skipf("skipping due to exec restriction: %v", err)
|
||||
}
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
|
||||
// Temporarily swap global extensionManager for this test
|
||||
prevMgr := extensionManager
|
||||
extensionManager = mgr
|
||||
defer func() { extensionManager = prevMgr }()
|
||||
|
||||
// Template uses input plus a variable inside extension value
|
||||
tmpl := "{{ext:mix-echo:echo:pre-{{input}}-mid-{{suffix}}-post}}"
|
||||
|
||||
out, err := ApplyTemplate(tmpl, variables, input)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyTemplate error: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(out, "VAL=pre-PRIMARY-mid-SUF-post") {
|
||||
t.Fatalf("unexpected output: %q", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package template
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMultipleExtensionsWithInput ensures multiple extension calls each using {{input}} get proper substitution.
|
||||
func TestMultipleExtensionsWithInput(t *testing.T) {
|
||||
input := "DATA"
|
||||
variables := map[string]string{}
|
||||
|
||||
tmp := t.TempDir()
|
||||
configDir := filepath.Join(tmp, ".config", "fabric")
|
||||
extsDir := filepath.Join(configDir, "extensions")
|
||||
binDir := filepath.Join(extsDir, "bin")
|
||||
configsDir := filepath.Join(extsDir, "configs")
|
||||
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir bin: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(configsDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir configs: %v", err)
|
||||
}
|
||||
|
||||
scriptPath := filepath.Join(binDir, "multi-echo.sh")
|
||||
script := "#!/bin/sh\necho ECHO=$1\n"
|
||||
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("write script: %v", err)
|
||||
}
|
||||
|
||||
configYAML := "" +
|
||||
"name: multi-echo\n" +
|
||||
"type: executable\n" +
|
||||
"executable: " + scriptPath + "\n" +
|
||||
"description: multi echo extension\n" +
|
||||
"version: 1.0.0\n" +
|
||||
"timeout: 5s\n" +
|
||||
"operations:\n" +
|
||||
" echo:\n" +
|
||||
" cmd_template: '{{executable}} {{value}}'\n"
|
||||
if err := os.WriteFile(filepath.Join(configsDir, "multi-echo.yaml"), []byte(configYAML), 0o644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
mgr := NewExtensionManager(configDir)
|
||||
if err := mgr.RegisterExtension(filepath.Join(configsDir, "multi-echo.yaml")); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
prev := extensionManager
|
||||
extensionManager = mgr
|
||||
defer func() { extensionManager = prev }()
|
||||
|
||||
tmpl := strings.Join([]string{
|
||||
"First: {{ext:multi-echo:echo:{{input}}}}",
|
||||
"Second: {{ext:multi-echo:echo:{{input}}}}",
|
||||
"Third: {{ext:multi-echo:echo:{{input}}}}",
|
||||
}, " | ")
|
||||
|
||||
out, err := ApplyTemplate(tmpl, variables, input)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyTemplate error: %v", err)
|
||||
}
|
||||
|
||||
wantCount := 3
|
||||
occ := strings.Count(out, "ECHO=DATA")
|
||||
if occ != wantCount {
|
||||
t.Fatalf("expected %d occurrences of ECHO=DATA, got %d; output=%q", wantCount, occ, out)
|
||||
}
|
||||
}
|
||||
359
internal/plugins/template/template_sentinel_test.go
Normal file
359
internal/plugins/template/template_sentinel_test.go
Normal file
@@ -0,0 +1,359 @@
|
||||
package template
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSentinelTokenReplacement tests the fix for the {{input}} sentinel token bug
|
||||
// This test verifies that when {{input}} is used inside an extension call,
|
||||
// the actual input is passed to the extension, not the sentinel token.
|
||||
func TestSentinelTokenReplacement(t *testing.T) {
|
||||
// Create a temporary directory for test extension
|
||||
tmpDir := t.TempDir()
|
||||
configDir := filepath.Join(tmpDir, ".config", "fabric")
|
||||
extensionsDir := filepath.Join(configDir, "extensions")
|
||||
binDir := filepath.Join(extensionsDir, "bin")
|
||||
configsDir := filepath.Join(extensionsDir, "configs")
|
||||
|
||||
err := os.MkdirAll(binDir, 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create bin directory: %v", err)
|
||||
}
|
||||
err = os.MkdirAll(configsDir, 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create configs directory: %v", err)
|
||||
}
|
||||
|
||||
// Create a test script that echoes what it receives
|
||||
scriptPath := filepath.Join(binDir, "echo-test.sh")
|
||||
scriptContent := `#!/bin/bash
|
||||
echo "RECEIVED: $*"
|
||||
`
|
||||
err = os.WriteFile(scriptPath, []byte(scriptContent), 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test script: %v", err)
|
||||
}
|
||||
|
||||
// Create extension config
|
||||
configPath := filepath.Join(configsDir, "echo-test.yaml")
|
||||
configContent := `name: echo-test
|
||||
executable: ` + scriptPath + `
|
||||
type: executable
|
||||
timeout: "5s"
|
||||
description: "Echo test extension"
|
||||
version: "1.0.0"
|
||||
|
||||
operations:
|
||||
echo:
|
||||
cmd_template: "{{executable}} {{value}}"
|
||||
|
||||
config:
|
||||
output:
|
||||
method: stdout
|
||||
`
|
||||
err = os.WriteFile(configPath, []byte(configContent), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create extension config: %v", err)
|
||||
}
|
||||
|
||||
// Initialize extension manager with test config directory
|
||||
oldManager := extensionManager
|
||||
defer func() { extensionManager = oldManager }()
|
||||
|
||||
extensionManager = NewExtensionManager(configDir)
|
||||
|
||||
// Register the test extension
|
||||
err = extensionManager.RegisterExtension(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to register extension: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
template string
|
||||
input string
|
||||
wantContain string
|
||||
wantNotContain string
|
||||
}{
|
||||
{
|
||||
name: "sentinel token with {{input}} in extension value",
|
||||
template: "{{ext:echo-test:echo:__FABRIC_INPUT_SENTINEL_TOKEN__}}",
|
||||
input: "test input data",
|
||||
wantContain: "RECEIVED: test input data",
|
||||
wantNotContain: "__FABRIC_INPUT_SENTINEL_TOKEN__",
|
||||
},
|
||||
{
|
||||
name: "direct input variable replacement",
|
||||
template: "{{ext:echo-test:echo:{{input}}}}",
|
||||
input: "Hello World",
|
||||
wantContain: "RECEIVED: Hello World",
|
||||
wantNotContain: "{{input}}",
|
||||
},
|
||||
{
|
||||
name: "sentinel with complex input",
|
||||
template: "Result: {{ext:echo-test:echo:__FABRIC_INPUT_SENTINEL_TOKEN__}}",
|
||||
input: "What is AI?",
|
||||
wantContain: "RECEIVED: What is AI?",
|
||||
wantNotContain: "__FABRIC_INPUT_SENTINEL_TOKEN__",
|
||||
},
|
||||
{
|
||||
name: "multiple words in input",
|
||||
template: "{{ext:echo-test:echo:{{input}}}}",
|
||||
input: "Multiple word input string",
|
||||
wantContain: "RECEIVED: Multiple word input string",
|
||||
wantNotContain: "{{input}}",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ApplyTemplate(tt.template, map[string]string{}, tt.input)
|
||||
if err != nil {
|
||||
t.Errorf("ApplyTemplate() error = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check that result contains expected string
|
||||
if !strings.Contains(got, tt.wantContain) {
|
||||
t.Errorf("ApplyTemplate() = %q, should contain %q", got, tt.wantContain)
|
||||
}
|
||||
|
||||
// Check that result does NOT contain unwanted string
|
||||
if strings.Contains(got, tt.wantNotContain) {
|
||||
t.Errorf("ApplyTemplate() = %q, should NOT contain %q", got, tt.wantNotContain)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSentinelInVariableProcessing tests that the sentinel token is handled
|
||||
// correctly in regular variable processing (not just extensions)
|
||||
// Note: The sentinel is only replaced when it appears in extension values,
|
||||
// not when used as a standalone variable (which would be a user error)
|
||||
func TestSentinelInVariableProcessing(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
template string
|
||||
vars map[string]string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "input variable works normally",
|
||||
template: "Value: {{input}}",
|
||||
input: "actual input",
|
||||
want: "Value: actual input",
|
||||
},
|
||||
{
|
||||
name: "multiple input references",
|
||||
template: "First: {{input}}, Second: {{input}}",
|
||||
input: "test",
|
||||
want: "First: test, Second: test",
|
||||
},
|
||||
{
|
||||
name: "input with variables",
|
||||
template: "Var: {{name}}, Input: {{input}}",
|
||||
vars: map[string]string{"name": "TestVar"},
|
||||
input: "input value",
|
||||
want: "Var: TestVar, Input: input value",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ApplyTemplate(tt.template, tt.vars, tt.input)
|
||||
if err != nil {
|
||||
t.Errorf("ApplyTemplate() error = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if got != tt.want {
|
||||
t.Errorf("ApplyTemplate() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtensionValueWithSentinel specifically tests the extension value
|
||||
// sentinel replacement logic
|
||||
func TestExtensionValueWithSentinel(t *testing.T) {
|
||||
// Create a temporary directory for test extension
|
||||
tmpDir := t.TempDir()
|
||||
configDir := filepath.Join(tmpDir, ".config", "fabric")
|
||||
extensionsDir := filepath.Join(configDir, "extensions")
|
||||
binDir := filepath.Join(extensionsDir, "bin")
|
||||
configsDir := filepath.Join(extensionsDir, "configs")
|
||||
|
||||
err := os.MkdirAll(binDir, 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create bin directory: %v", err)
|
||||
}
|
||||
err = os.MkdirAll(configsDir, 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create configs directory: %v", err)
|
||||
}
|
||||
|
||||
// Create a test script that outputs the exact arguments it receives
|
||||
scriptPath := filepath.Join(binDir, "arg-test.sh")
|
||||
scriptContent := `#!/bin/bash
|
||||
# Output each argument on a separate line
|
||||
for arg in "$@"; do
|
||||
echo "ARG: $arg"
|
||||
done
|
||||
`
|
||||
err = os.WriteFile(scriptPath, []byte(scriptContent), 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test script: %v", err)
|
||||
}
|
||||
|
||||
// Create extension config
|
||||
configPath := filepath.Join(configsDir, "arg-test.yaml")
|
||||
configContent := `name: arg-test
|
||||
executable: ` + scriptPath + `
|
||||
type: executable
|
||||
timeout: "5s"
|
||||
description: "Argument test extension"
|
||||
version: "1.0.0"
|
||||
|
||||
operations:
|
||||
test:
|
||||
cmd_template: "{{executable}} {{value}}"
|
||||
|
||||
config:
|
||||
output:
|
||||
method: stdout
|
||||
`
|
||||
err = os.WriteFile(configPath, []byte(configContent), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create extension config: %v", err)
|
||||
}
|
||||
|
||||
// Initialize extension manager with test config directory
|
||||
oldManager := extensionManager
|
||||
defer func() { extensionManager = oldManager }()
|
||||
|
||||
extensionManager = NewExtensionManager(configDir)
|
||||
|
||||
// Register the test extension
|
||||
err = extensionManager.RegisterExtension(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to register extension: %v", err)
|
||||
}
|
||||
|
||||
// Test that sentinel token in extension value gets replaced
|
||||
template := "{{ext:arg-test:test:prefix-__FABRIC_INPUT_SENTINEL_TOKEN__-suffix}}"
|
||||
input := "MYINPUT"
|
||||
|
||||
got, err := ApplyTemplate(template, map[string]string{}, input)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyTemplate() error = %v", err)
|
||||
}
|
||||
|
||||
// The sentinel should be replaced with actual input
|
||||
expectedContain := "ARG: prefix-MYINPUT-suffix"
|
||||
if !strings.Contains(got, expectedContain) {
|
||||
t.Errorf("ApplyTemplate() = %q, should contain %q", got, expectedContain)
|
||||
}
|
||||
|
||||
// The sentinel token should NOT appear in output
|
||||
if strings.Contains(got, "__FABRIC_INPUT_SENTINEL_TOKEN__") {
|
||||
t.Errorf("ApplyTemplate() = %q, should NOT contain sentinel token", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNestedInputInExtension tests the original bug case:
|
||||
// {{ext:name:op:{{input}}}} should pass the actual input, not the sentinel
|
||||
func TestNestedInputInExtension(t *testing.T) {
|
||||
// Create a temporary directory for test extension
|
||||
tmpDir := t.TempDir()
|
||||
configDir := filepath.Join(tmpDir, ".config", "fabric")
|
||||
extensionsDir := filepath.Join(configDir, "extensions")
|
||||
binDir := filepath.Join(extensionsDir, "bin")
|
||||
configsDir := filepath.Join(extensionsDir, "configs")
|
||||
|
||||
err := os.MkdirAll(binDir, 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create bin directory: %v", err)
|
||||
}
|
||||
err = os.MkdirAll(configsDir, 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create configs directory: %v", err)
|
||||
}
|
||||
|
||||
// Create a test script
|
||||
scriptPath := filepath.Join(binDir, "nested-test.sh")
|
||||
scriptContent := `#!/bin/bash
|
||||
echo "NESTED_TEST: $*"
|
||||
`
|
||||
err = os.WriteFile(scriptPath, []byte(scriptContent), 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test script: %v", err)
|
||||
}
|
||||
|
||||
// Create extension config
|
||||
configPath := filepath.Join(configsDir, "nested-test.yaml")
|
||||
configContent := `name: nested-test
|
||||
executable: ` + scriptPath + `
|
||||
type: executable
|
||||
timeout: "5s"
|
||||
description: "Nested input test extension"
|
||||
version: "1.0.0"
|
||||
|
||||
operations:
|
||||
process:
|
||||
cmd_template: "{{executable}} {{value}}"
|
||||
|
||||
config:
|
||||
output:
|
||||
method: stdout
|
||||
`
|
||||
err = os.WriteFile(configPath, []byte(configContent), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create extension config: %v", err)
|
||||
}
|
||||
|
||||
// Initialize extension manager with test config directory
|
||||
oldManager := extensionManager
|
||||
defer func() { extensionManager = oldManager }()
|
||||
|
||||
extensionManager = NewExtensionManager(configDir)
|
||||
|
||||
// Register the test extension
|
||||
err = extensionManager.RegisterExtension(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to register extension: %v", err)
|
||||
}
|
||||
|
||||
// This is the bug case: {{input}} nested inside extension call
|
||||
// The template processing should:
|
||||
// 1. Replace {{input}} with sentinel during variable protection
|
||||
// 2. Process the extension, replacing sentinel with actual input
|
||||
// 3. Execute extension with actual input, not sentinel
|
||||
|
||||
template := "{{ext:nested-test:process:{{input}}}}"
|
||||
input := "What is Artificial Intelligence"
|
||||
|
||||
got, err := ApplyTemplate(template, map[string]string{}, input)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyTemplate() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify the actual input was passed, not the sentinel
|
||||
expectedContain := "NESTED_TEST: What is Artificial Intelligence"
|
||||
if !strings.Contains(got, expectedContain) {
|
||||
t.Errorf("ApplyTemplate() = %q, should contain %q", got, expectedContain)
|
||||
}
|
||||
|
||||
// Verify sentinel token does NOT appear
|
||||
if strings.Contains(got, "__FABRIC_INPUT_SENTINEL_TOKEN__") {
|
||||
t.Errorf("ApplyTemplate() output contains sentinel token (BUG NOT FIXED): %q", got)
|
||||
}
|
||||
|
||||
// Verify {{input}} template tag does NOT appear
|
||||
if strings.Contains(got, "{{input}}") {
|
||||
t.Errorf("ApplyTemplate() output contains unresolved {{input}}: %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user