Skip to content

fix(tools): fix stale secret-input decrypt cache reused across loop iterations - #40926

Open
chelsealong wants to merge 2 commits into
langgenius:mainfrom
chelsealong:fix/tool-secret-cache-loop-iteration
Open

fix(tools): fix stale secret-input decrypt cache reused across loop iterations#40926
chelsealong wants to merge 2 commits into
langgenius:mainfrom
chelsealong:fix/tool-secret-cache-loop-iteration

Conversation

@chelsealong

Copy link
Copy Markdown
Contributor

Summary

Fixes #40920

ToolParameterConfigurationManager.decrypt_tool_parameters (api/core/tools/utils/configuration.py) caches decrypted secret-input tool parameters in Redis, keyed only by tenant_id:provider:tool_name:identity_id (ToolParameterCache, 24h TTL). The cache key does not depend on the actual input values, so on a cache hit the entire cached parameter dict was returned unconditionally.

When a secret-input tool parameter is dynamically bound inside a loop/iteration node (e.g. a per-iteration webhook key resolved from the loop item), every iteration after the first silently reused the first iteration's decrypted secret instead of resolving its own — because identity_id is scoped to the node, not the call, and the cache had no way to tell that the inputs changed between iterations.

Fix

Fingerprint the secret-form input values (SHA-256 of the canonical JSON of just the secret-input parameters), and restrict the cache so a hit only ever supplies the decrypted secret field(s), merged onto the current call's own freshly resolved parameters:

  • Statically configured secrets keep the caching benefit (fingerprint stays stable across calls).
  • Dynamically bound secrets (loop/iteration) now decrypt fresh whenever the secret input actually changes.
  • The cache never returns a wholesale historical parameter dict. This matters because _convert_tool_parameters_type can resolve any form=FORM parameter — not just SECRET_INPUT ones — from a per-iteration variable selector, so a non-secret field (e.g. a loop-bound recipient/channel/URL) is just as able to vary per iteration as a secret one. Fingerprinting only the secret inputs while still returning the whole cached dict on a hit would leave that non-secret value servable stale whenever the co-located secret happens to stay constant across iterations. Restricting the cache to secret fields only closes that gap: non-secret values always come from the live call.
  • Legacy cache entries written before this change (no fingerprint) are treated as stale and recomputed, so existing deployments self-heal within the 24h TTL without any migration.

Changes

  • api/core/tools/utils/configuration.py: add _secret_input_fingerprint; decrypt_tool_parameters now caches/serves only the decrypted secret-input values (never the full parameter dict), gated on the fingerprint.
  • api/tests/unit_tests/core/tools/utils/test_configuration.py: update the existing cache hit/miss test for the new cache entry shape, add test_decrypt_tool_parameters_ignores_stale_cache_for_different_secret_input (a stale cache entry from a prior call with a different secret input must not be served for a new call), and add test_decrypt_tool_parameters_cache_hit_never_stales_non_secret_form_param (a non-secret FORM parameter that varies per call, alongside a secret that stays constant, must always reflect the current call, never a cached value from an earlier call).

Test plan

Confirmed the new/updated tests fail without the fix and pass with it:

$ git stash push -m temp -- api/core/tools/utils/configuration.py
$ uv run --project api pytest -o addopts='' api/tests/unit_tests/core/tools/utils/test_configuration.py -q
....F..
FAILED ...::test_decrypt_tool_parameters_cache_hit_never_stales_non_secret_form_param - KeyError: 'plain'
1 failed, 6 passed

$ git stash pop
$ uv run --project api pytest -o addopts='' api/tests/unit_tests/core/tools/utils/test_configuration.py -q
.......
7 passed

Full related suite after the fix:

$ uv run --project api pytest -o addopts='' api/tests/unit_tests/core/tools/ \
    api/tests/unit_tests/events/event_handlers/test_delete_tool_parameters_cache_when_sync_draft_workflow.py -q
352 passed

Lint / format / type-check on the changed files:

$ uv run --project api --dev ruff check api/core/tools/utils/configuration.py api/tests/unit_tests/core/tools/utils/test_configuration.py
All checks passed!

$ uv run --project api --dev ruff format --check api/core/tools/utils/configuration.py api/tests/unit_tests/core/tools/utils/test_configuration.py
2 files already formatted

$ uv run mypy --exclude-gitignore --check-untyped-defs --disable-error-code=import-untyped core/tools/utils/configuration.py
Success: no issues found in 1 source file

Disclosure

This PR was prepared with AI assistance (Claude).

…terations

ToolParameterConfigurationManager.decrypt_tool_parameters cached decrypted
secret-input values keyed only by tenant/provider/tool/node identity, with
no dependency on the actual input values. When a secret-input parameter is
dynamically bound inside a loop/iteration node, every iteration after the
first silently reused the first iteration's decrypted secret instead of its
own, because a cache hit returned the whole cached dict unconditionally.

Fingerprint the secret-form inputs and only serve a cache entry when it was
built from the same fingerprint, so static secrets keep the caching benefit
and dynamically bound secrets resolve per call. Legacy entries without a
fingerprint are treated as stale and recomputed.

Fixes langgenius#40920
decrypt_tool_parameters previously returned the entire cached parameter
dict wholesale on a fingerprint-matched cache hit, including any
non-secret FORM parameter values captured at cache-write time. Since
_convert_tool_parameters_type can resolve any FORM parameter (not just
SECRET_INPUT) from a per-iteration variable selector, a non-secret field
bound inside the same loop/iteration node was just as able to go stale
as a secret one, while a constant secret input kept the fingerprint
matching every iteration.

Restrict the cache to storing/serving only the decrypted secret-input
values, merged onto the current call's own freshly resolved parameters,
so non-secret FORM values always come from the live call and can never
be replayed from an earlier iteration.

Fixes langgenius#40920
@dosubot dosubot Bot added the size:M This PR changes 30-99 lines, ignoring generated files. label Aug 18, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Pyrefly Type Coverage

Metric Base PR Delta
Type coverage 59.48% 59.47% -0.00%
Strict coverage 59.06% 59.05% -0.00%
Typed symbols 39,637 39,636 -1
Untyped symbols 27,199 27,202 +3
Modules 3173 3173 0

@chelsealong

Copy link
Copy Markdown
Contributor Author

Checked the failing "Web Full-Stack E2E" check. The only failure is scenario "Run a minimal workflow app" (e2e/features/apps/workflow-run.feature:4), which times out waiting for a run-status SUCCESS element in a plain workflow-run smoke test:

Error: expect(locator).toBeVisible() failed
Locator: getByRole('status').getByText('SUCCESS', { exact: true })
Timeout: 55000ms

That scenario runs a minimal workflow with no tool node, so it never touches decrypt_tool_parameters/ToolParameterCache — the only code this PR changes (api/core/tools/utils/configuration.py). This same check has failed 1/4 times on other forked PRs recently and 0/9 times on same-repo PRs, consistent with a fork-runner environment issue rather than a regression here. All API tests pass (352 passed) and lint/format/type-check are clean per the PR description. No code changes made.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:M This PR changes 30-99 lines, ignoring generated files.

Projects

None yet

1 participant