From 29c24c83879230470d6626e397ec203442fbd88f Mon Sep 17 00:00:00 2001 From: Nick Skriloff Date: Mon, 20 Oct 2025 19:49:33 -0400 Subject: [PATCH] fix: improve template extension handling for {{input}} and add examples --- go.sum | 2 - internal/plugins/db/fsdb/patterns.go | 4 +- internal/plugins/template/Examples/README.md | 20 + .../plugins/template/Examples/openai-chat.sh | 20 + .../plugins/template/Examples/openai.yaml | 14 + .../plugins/template/extension_registry.go | 5 + internal/plugins/template/template.go | 229 ++++------- .../template/template_extension_mixed_test.go | 77 ++++ .../template_extension_multiple_test.go | 71 ++++ .../template/template_sentinel_test.go | 359 ++++++++++++++++++ 10 files changed, 638 insertions(+), 163 deletions(-) create mode 100755 internal/plugins/template/Examples/openai-chat.sh create mode 100644 internal/plugins/template/Examples/openai.yaml create mode 100644 internal/plugins/template/template_extension_mixed_test.go create mode 100644 internal/plugins/template/template_extension_multiple_test.go create mode 100644 internal/plugins/template/template_sentinel_test.go diff --git a/go.sum b/go.sum index 31c8ce0a..eb30912a 100644 --- a/go.sum +++ b/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= diff --git a/internal/plugins/db/fsdb/patterns.go b/internal/plugins/db/fsdb/patterns.go index dcbe7c59..2a99f209 100644 --- a/internal/plugins/db/fsdb/patterns.go +++ b/internal/plugins/db/fsdb/patterns.go @@ -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 } diff --git a/internal/plugins/template/Examples/README.md b/internal/plugins/template/Examples/README.md index 30731401..931f41f6 100644 --- a/internal/plugins/template/Examples/README.md +++ b/internal/plugins/template/Examples/README.md @@ -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** diff --git a/internal/plugins/template/Examples/openai-chat.sh b/internal/plugins/template/Examples/openai-chat.sh new file mode 100755 index 00000000..e8d65ee8 --- /dev/null +++ b/internal/plugins/template/Examples/openai-chat.sh @@ -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' diff --git a/internal/plugins/template/Examples/openai.yaml b/internal/plugins/template/Examples/openai.yaml new file mode 100644 index 00000000..804bef70 --- /dev/null +++ b/internal/plugins/template/Examples/openai.yaml @@ -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 diff --git a/internal/plugins/template/extension_registry.go b/internal/plugins/template/extension_registry.go index 77a20e68..67a1d155 100644 --- a/internal/plugins/template/extension_registry.go +++ b/internal/plugins/template/extension_registry.go @@ -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, diff --git a/internal/plugins/template/template.go b/internal/plugins/template/template.go index e42811d7..a686f260 100644 --- a/internal/plugins/template/template.go +++ b/internal/plugins/template/template.go @@ -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") } } diff --git a/internal/plugins/template/template_extension_mixed_test.go b/internal/plugins/template/template_extension_mixed_test.go new file mode 100644 index 00000000..3406ffdf --- /dev/null +++ b/internal/plugins/template/template_extension_mixed_test.go @@ -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) + } +} diff --git a/internal/plugins/template/template_extension_multiple_test.go b/internal/plugins/template/template_extension_multiple_test.go new file mode 100644 index 00000000..f64f9743 --- /dev/null +++ b/internal/plugins/template/template_extension_multiple_test.go @@ -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) + } +} diff --git a/internal/plugins/template/template_sentinel_test.go b/internal/plugins/template/template_sentinel_test.go new file mode 100644 index 00000000..e73b501e --- /dev/null +++ b/internal/plugins/template/template_sentinel_test.go @@ -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) + } +}