mirror of
https://github.com/danielmiessler/Fabric.git
synced 2026-01-08 22:08:03 -05:00
Merge pull request #1802 from nickarino/input-extension-bug-fix
fix: improve template extension handling for {{input}} and add examples
This commit is contained in:
@@ -11,8 +11,6 @@ import (
|
||||
"github.com/danielmiessler/fabric/internal/util"
|
||||
)
|
||||
|
||||
const inputSentinel = "__FABRIC_INPUT_SENTINEL_TOKEN__"
|
||||
|
||||
type PatternsEntity struct {
|
||||
*StorageEntity
|
||||
SystemPatternFile string
|
||||
@@ -96,18 +94,18 @@ func (o *PatternsEntity) applyVariables(
|
||||
|
||||
// Temporarily replace {{input}} with a sentinel token to protect it
|
||||
// from recursive variable resolution
|
||||
withSentinel := strings.ReplaceAll(pattern.Pattern, "{{input}}", inputSentinel)
|
||||
withSentinel := strings.ReplaceAll(pattern.Pattern, "{{input}}", template.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
|
||||
}
|
||||
|
||||
// Finally, replace our sentinel with the actual user input
|
||||
// The input has already been processed for variables if InputHasVars was true
|
||||
pattern.Pattern = strings.ReplaceAll(processed, inputSentinel, input)
|
||||
pattern.Pattern = strings.ReplaceAll(processed, template.InputSentinel, input)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
|
||||
# Fabric Extensions: Complete Guide
|
||||
|
||||
## Important: Extensions Only Work in Patterns
|
||||
|
||||
**Extensions are ONLY processed when used within pattern files, not via direct piping to fabric.**
|
||||
|
||||
```bash
|
||||
# ❌ This DOES NOT WORK - extensions are not processed in stdin
|
||||
echo "{{ext:word-generator:generate:3}}" | fabric
|
||||
|
||||
# ✅ This WORKS - extensions are processed within patterns
|
||||
fabric -p my-pattern-with-extensions.md
|
||||
```
|
||||
|
||||
When you pipe directly to fabric without a pattern, the input goes straight to the LLM without template processing. Extensions are only evaluated during pattern template processing via `ApplyTemplate()`.
|
||||
|
||||
## Understanding Extension Architecture
|
||||
|
||||
### Registry Structure
|
||||
|
||||
The extension registry is stored at `~/.config/fabric/extensions/extensions.yaml` and tracks registered extensions:
|
||||
|
||||
```yaml
|
||||
@@ -17,6 +32,7 @@ extensions:
|
||||
The registry maintains security through hash verification of both configs and executables.
|
||||
|
||||
### Extension Configuration
|
||||
|
||||
Each extension requires a YAML configuration file with the following structure:
|
||||
|
||||
```yaml
|
||||
@@ -42,8 +58,10 @@ config: # Output configuration
|
||||
```
|
||||
|
||||
### Directory Structure
|
||||
|
||||
Recommended organization:
|
||||
```
|
||||
|
||||
```text
|
||||
~/.config/fabric/extensions/
|
||||
├── bin/ # Extension executables
|
||||
├── configs/ # Extension YAML configs
|
||||
@@ -51,9 +69,11 @@ Recommended organization:
|
||||
```
|
||||
|
||||
## Example 1: Python Wrapper (Word Generator)
|
||||
|
||||
A simple example wrapping a Python script.
|
||||
|
||||
### 1. Position Files
|
||||
|
||||
```bash
|
||||
# Create directories
|
||||
mkdir -p ~/.config/fabric/extensions/{bin,configs}
|
||||
@@ -64,7 +84,9 @@ chmod +x ~/.config/fabric/extensions/bin/word-generator.py
|
||||
```
|
||||
|
||||
### 2. Configure
|
||||
|
||||
Create `~/.config/fabric/extensions/configs/word-generator.yaml`:
|
||||
|
||||
```yaml
|
||||
name: word-generator
|
||||
executable: "~/.config/fabric/extensions/bin/word-generator.py"
|
||||
@@ -83,22 +105,26 @@ config:
|
||||
```
|
||||
|
||||
### 3. Register & Run
|
||||
|
||||
```bash
|
||||
# Register
|
||||
fabric --addextension ~/.config/fabric/extensions/configs/word-generator.yaml
|
||||
|
||||
# Run (generate 3 random words)
|
||||
echo "{{ext:word-generator:generate:3}}" | fabric
|
||||
# Extensions must be used within patterns (see "Extensions in patterns" section below)
|
||||
# Direct piping to fabric will NOT process extension syntax
|
||||
```
|
||||
|
||||
## Example 2: Direct Executable (SQLite3)
|
||||
|
||||
Using a system executable directly.
|
||||
|
||||
copy the memories to your home directory
|
||||
~/memories.db
|
||||
|
||||
### 1. Configure
|
||||
|
||||
Create `~/.config/fabric/extensions/configs/memory-query.yaml`:
|
||||
|
||||
```yaml
|
||||
name: memory-query
|
||||
executable: "/usr/bin/sqlite3"
|
||||
@@ -123,19 +149,19 @@ config:
|
||||
```
|
||||
|
||||
### 2. Register & Run
|
||||
|
||||
```bash
|
||||
# Register
|
||||
fabric --addextension ~/.config/fabric/extensions/configs/memory-query.yaml
|
||||
|
||||
# Run queries
|
||||
echo "{{ext:memory-query:all}}" | fabric
|
||||
echo "{{ext:memory-query:byid:3}}" | fabric
|
||||
# Extensions must be used within patterns (see "Extensions in patterns" section below)
|
||||
# Direct piping to fabric will NOT process extension syntax
|
||||
```
|
||||
|
||||
|
||||
## Extension Management Commands
|
||||
|
||||
### Add Extension
|
||||
|
||||
```bash
|
||||
fabric --addextension ~/.config/fabric/extensions/configs/memory-query.yaml
|
||||
```
|
||||
@@ -143,25 +169,29 @@ fabric --addextension ~/.config/fabric/extensions/configs/memory-query.yaml
|
||||
Note : if the executable or config file changes, you must re-add the extension.
|
||||
This will recompute the hash for the extension.
|
||||
|
||||
|
||||
### List Extensions
|
||||
|
||||
```bash
|
||||
fabric --listextensions
|
||||
```
|
||||
|
||||
Shows all registered extensions with their status and configuration details.
|
||||
|
||||
### Remove Extension
|
||||
|
||||
```bash
|
||||
fabric --rmextension <extension-name>
|
||||
```
|
||||
Removes an extension from the registry.
|
||||
|
||||
Removes an extension from the registry.
|
||||
|
||||
## Extensions in patterns
|
||||
|
||||
```
|
||||
Create a pattern that use multiple extensions.
|
||||
**IMPORTANT**: Extensions are ONLY processed when used within pattern files, not via direct piping to fabric.
|
||||
|
||||
Create a pattern file (e.g., `test_pattern.md`):
|
||||
|
||||
```markdown
|
||||
These are my favorite
|
||||
{{ext:word-generator:generate:3}}
|
||||
|
||||
@@ -171,8 +201,30 @@ These are my least favorite
|
||||
what does this say about me?
|
||||
```
|
||||
|
||||
Run the pattern:
|
||||
|
||||
```bash
|
||||
./fabric -p ./plugins/template/Examples/test_pattern.md
|
||||
fabric -p ./internal/plugins/template/Examples/test_pattern.md
|
||||
```
|
||||
|
||||
## Passing {{input}} to extensions inside patterns
|
||||
|
||||
```text
|
||||
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
|
||||
@@ -197,6 +249,7 @@ what does this say about me?
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Registration Failures**
|
||||
- Verify file permissions
|
||||
- Check executable paths
|
||||
@@ -214,10 +267,10 @@ what does this say about me?
|
||||
- Monitor disk space for file operations
|
||||
|
||||
### Debug Tips
|
||||
|
||||
1. Enable verbose logging when available
|
||||
2. Check system logs for execution errors
|
||||
3. Verify extension dependencies
|
||||
4. Test extensions with minimal configurations first
|
||||
|
||||
|
||||
Would you like me to expand on any particular section or add more examples?
|
||||
Would you like me to expand on any particular section or add more examples?
|
||||
|
||||
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: "/path/to/your/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
|
||||
5
internal/plugins/template/constants.go
Normal file
5
internal/plugins/template/constants.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package template
|
||||
|
||||
// InputSentinel is used to temporarily replace {{input}} during template processing
|
||||
// to prevent recursive variable resolution
|
||||
const InputSentinel = "__FABRIC_INPUT_SENTINEL_TOKEN__"
|
||||
@@ -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,
|
||||
|
||||
@@ -37,152 +37,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 +116,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)
|
||||
}
|
||||
}
|
||||
275
internal/plugins/template/template_sentinel_test.go
Normal file
275
internal/plugins/template/template_sentinel_test.go
Normal file
@@ -0,0 +1,275 @@
|
||||
package template
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// withTestExtension creates a temporary test extension and runs the test function
|
||||
func withTestExtension(t *testing.T, name string, scriptContent string, testFunc func(*ExtensionManager, string)) {
|
||||
t.Helper()
|
||||
|
||||
// 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, name+".sh")
|
||||
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, name+".yaml")
|
||||
configContent := fmt.Sprintf(`name: %s
|
||||
executable: %s
|
||||
type: executable
|
||||
timeout: "5s"
|
||||
description: "Test extension"
|
||||
version: "1.0.0"
|
||||
|
||||
operations:
|
||||
echo:
|
||||
cmd_template: "{{executable}} {{value}}"
|
||||
|
||||
config:
|
||||
output:
|
||||
method: stdout
|
||||
`, name, scriptPath)
|
||||
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
|
||||
mgr := NewExtensionManager(configDir)
|
||||
|
||||
// Register the test extension
|
||||
err = mgr.RegisterExtension(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to register extension: %v", err)
|
||||
}
|
||||
|
||||
// Run the test
|
||||
testFunc(mgr, name)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
scriptContent := `#!/bin/bash
|
||||
echo "RECEIVED: $@"
|
||||
`
|
||||
|
||||
withTestExtension(t, "echo-test", scriptContent, func(mgr *ExtensionManager, name string) {
|
||||
// Save and restore global extension manager
|
||||
oldManager := extensionManager
|
||||
defer func() { extensionManager = oldManager }()
|
||||
extensionManager = mgr
|
||||
|
||||
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) {
|
||||
scriptContent := `#!/bin/bash
|
||||
# Output each argument on a separate line
|
||||
for arg in "$@"; do
|
||||
echo "ARG: $arg"
|
||||
done
|
||||
`
|
||||
|
||||
withTestExtension(t, "arg-test", scriptContent, func(mgr *ExtensionManager, name string) {
|
||||
// Save and restore global extension manager
|
||||
oldManager := extensionManager
|
||||
defer func() { extensionManager = oldManager }()
|
||||
extensionManager = mgr
|
||||
|
||||
// Test that sentinel token in extension value gets replaced
|
||||
template := "{{ext:arg-test:echo: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) {
|
||||
scriptContent := `#!/bin/bash
|
||||
echo "NESTED_TEST: $*"
|
||||
`
|
||||
|
||||
withTestExtension(t, "nested-test", scriptContent, func(mgr *ExtensionManager, name string) {
|
||||
// Save and restore global extension manager
|
||||
oldManager := extensionManager
|
||||
defer func() { extensionManager = oldManager }()
|
||||
extensionManager = mgr
|
||||
|
||||
// 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:echo:{{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