Summary
When a single agent template file references more than one secret/path via more than one templating function call (e.g. a secret "<project>" "<env>" "<path>" range block followed by one or more getSecretByName "<project>" "<env>" "<path>" "<key>" calls, or several getSecretByName calls on different paths), the agent's change-detection etag is a single shared variable that gets overwritten by every function call evaluated during that render — not tracked per referenced secret/path, and not combined/merged across all functions referenced by the file.
As a consequence, on the next poll cycle the agent only compares the etag of the last function call executed during the previous render. If that particular secret/path hasn't changed — even though an earlier function call in the same file references a secret that has changed — the agent concludes nothing changed and skips re-rendering the destination file entirely. The poll loop keeps running at the configured polling-interval, but silently discards the fact that a re-render is needed.
Only a full agent restart (firstRun=true, which forces an unconditional render) picks up the change.
Affected versions
Confirmed on:
v0.43.84 (commit fc0ac97b73c0f97e1f24db9d7cd632595142ea9d)
v0.43.104 (commit 2d989880fa77222633d0207ed150255ee86ef3b5) — file is byte-identical to main
main at commit 20e251cead9014ba448f9b71ef7d12d1696d33c2 (2026-07-10)
The design (single shared etag pointer passed into every template function) is unchanged between v0.43.84 and main; only line numbers shift (~13 lines) due to an unrelated feature addition (dynamic secret "principals").
Root cause (confirmed by code reading)
packages/cmd/agent.go. Every template function that fetches secrets is handed the same *string pointer for etag tracking, and each one unconditionally overwrites it:
// secretTemplateFunction (listSecrets)
func secretTemplateFunction(accessToken string, currentEtag *string) func(...) {
return func(projectID, envSlug, secretPath string, args ...string) (...) {
res, err := util.GetPlainTextSecretsV4(...)
*currentEtag = res.Etag // overwrites the shared value
return res.Secrets, nil
}
}
// getSingleSecretTemplateFunction (getSecretByName)
func getSingleSecretTemplateFunction(accessToken string, currentEtag *string) func(...) {
return func(projectID, envSlug, secretPath, secretName string) (...) {
secret, etag, err := util.GetSinglePlainTextSecretByNameV3(...)
*currentEtag = etag // same pointer, overwrites the previous call's etag
return secret, nil
}
}
ProcessTemplate / ProcessLiteralTemplate / ProcessBase64Template all pass the same ¤tEtag into newTemplateFunctions, which wires it into secretTemplateFunction, secretTemplateByProjectSlugFunction, getSingleSecretTemplateFunction, and dynamicSecretTemplateFunction alike. Whichever of these functions is evaluated last during the Go template render wins the race and determines the etag used for the next poll's change-detection gate:
var existingEtag string
var currentEtag string // one shared string across the whole template render
var firstRun = true
...
processedTemplate, err = ProcessTemplate(templateId, ..., ¤tEtag, ...)
...
if (existingEtag != currentEtag) || firstRun { // only sees the LAST etag written
... // re-render
existingEtag = currentEtag
}
If a template calls multiple secret-fetching functions (a listSecrets range plus one or more getSecretByName calls, or several getSecretByName calls on different paths), only a change to whichever one is evaluated last in the template is ever detected after the first render.
Confirmed locations (main, commit 20e251cead9014ba448f9b71ef7d12d1696d33c2):
Same pattern on v0.43.84 (commit fc0ac97b73c0f97e1f24db9d7cd632595142ea9d), lines ~903/932/968 for the writes and ~1899-1908 for the gate.
Repro steps
- Configure an agent template that references more than one secret path, e.g.:
{{- with secret "myproject" "prod" "/" }}
{{- range . }}
{{ .Key }}={{ .Value }}
{{- end }}
{{- end }}
{{- with getSecretByName "myproject" "prod" "/app" "DB_PASSWORD" }}
{{ .Key }}={{ .Value }}
{{- end }}
- Start the agent (
firstRun=true) → the initial render is correct.
- Modify only a secret referenced by the first block (the
secret "/" range), not the one fetched by the trailing getSecretByName.
- Wait longer than the configured
polling-interval.
- Observe: the destination file is not re-rendered, even though the underlying secret changed (verifiable independently via
infisical secrets get).
- Restart the agent (
firstRun=true again) → the file is immediately correct.
Expected behavior
Any change to any secret referenced anywhere in a template should trigger a re-render on the next poll, regardless of the order in which the template's functions are evaluated.
Actual behavior
Only a change to the secret referenced by the last-evaluated template function in a given file's render triggers a re-render. Any other secret referenced earlier in the same template is effectively never polled for changes again after the first render — the destination file silently goes stale until the process is restarted.
Suggested fix
Replace the single shared *string etag with per-source tracking, e.g. a map[string]string keyed by (projectID, envSlug, secretPath[, secretName]), or accumulate a combined hash of all etags seen during a render (e.g. hash the sorted concatenation of every etag returned by every function call), and compare that combined value against the previous render's combined value at the gate. Either approach ensures a change to any referenced secret is detected, not just the last one evaluated.
Workaround currently in use
Until this is fixed, we force periodic unconditional re-renders by having an external timer issue a systemctl try-restart (or equivalent) on the agent process every few minutes, since only firstRun=true reliably picks up all changes. This is a blunt workaround and defeats the purpose of the poll-based change detection.
Summary
When a single agent template file references more than one secret/path via more than one templating function call (e.g. a
secret "<project>" "<env>" "<path>"range block followed by one or moregetSecretByName "<project>" "<env>" "<path>" "<key>"calls, or severalgetSecretByNamecalls on different paths), the agent's change-detection etag is a single shared variable that gets overwritten by every function call evaluated during that render — not tracked per referenced secret/path, and not combined/merged across all functions referenced by the file.As a consequence, on the next poll cycle the agent only compares the etag of the last function call executed during the previous render. If that particular secret/path hasn't changed — even though an earlier function call in the same file references a secret that has changed — the agent concludes nothing changed and skips re-rendering the destination file entirely. The poll loop keeps running at the configured
polling-interval, but silently discards the fact that a re-render is needed.Only a full agent restart (
firstRun=true, which forces an unconditional render) picks up the change.Affected versions
Confirmed on:
v0.43.84(commitfc0ac97b73c0f97e1f24db9d7cd632595142ea9d)v0.43.104(commit2d989880fa77222633d0207ed150255ee86ef3b5) — file is byte-identical tomainmainat commit20e251cead9014ba448f9b71ef7d12d1696d33c2(2026-07-10)The design (single shared etag pointer passed into every template function) is unchanged between
v0.43.84andmain; only line numbers shift (~13 lines) due to an unrelated feature addition (dynamic secret "principals").Root cause (confirmed by code reading)
packages/cmd/agent.go. Every template function that fetches secrets is handed the same*stringpointer for etag tracking, and each one unconditionally overwrites it:ProcessTemplate/ProcessLiteralTemplate/ProcessBase64Templateall pass the same¤tEtagintonewTemplateFunctions, which wires it intosecretTemplateFunction,secretTemplateByProjectSlugFunction,getSingleSecretTemplateFunction, anddynamicSecretTemplateFunctionalike. Whichever of these functions is evaluated last during the Go template render wins the race and determines the etag used for the next poll's change-detection gate:If a template calls multiple secret-fetching functions (a
listSecretsrange plus one or moregetSecretByNamecalls, or severalgetSecretByNamecalls on different paths), only a change to whichever one is evaluated last in the template is ever detected after the first render.Confirmed locations (
main, commit20e251cead9014ba448f9b71ef7d12d1696d33c2):secretTemplateFunctionetag write: agent.go#L916getSingleSecretTemplateFunctionetag write: agent.go#L945dynamicSecretTemplateFunctionetag write: agent.go#L984currentEtagdeclaration: agent.go#L1870Same pattern on
v0.43.84(commitfc0ac97b73c0f97e1f24db9d7cd632595142ea9d), lines ~903/932/968 for the writes and ~1899-1908 for the gate.Repro steps
firstRun=true) → the initial render is correct.secret "/"range), not the one fetched by the trailinggetSecretByName.polling-interval.infisical secrets get).firstRun=trueagain) → the file is immediately correct.Expected behavior
Any change to any secret referenced anywhere in a template should trigger a re-render on the next poll, regardless of the order in which the template's functions are evaluated.
Actual behavior
Only a change to the secret referenced by the last-evaluated template function in a given file's render triggers a re-render. Any other secret referenced earlier in the same template is effectively never polled for changes again after the first render — the destination file silently goes stale until the process is restarted.
Suggested fix
Replace the single shared
*stringetag with per-source tracking, e.g. amap[string]stringkeyed by(projectID, envSlug, secretPath[, secretName]), or accumulate a combined hash of all etags seen during a render (e.g. hash the sorted concatenation of every etag returned by every function call), and compare that combined value against the previous render's combined value at the gate. Either approach ensures a change to any referenced secret is detected, not just the last one evaluated.Workaround currently in use
Until this is fixed, we force periodic unconditional re-renders by having an external timer issue a
systemctl try-restart(or equivalent) on the agent process every few minutes, since onlyfirstRun=truereliably picks up all changes. This is a blunt workaround and defeats the purpose of the poll-based change detection.