Skip to content

fix: expand per-conversation secrets in plugin source/ref before fetch#3758

Merged
jpshackelford merged 3 commits into
mainfrom
expand-secrets-in-plugin-source
Jun 17, 2026
Merged

fix: expand per-conversation secrets in plugin source/ref before fetch#3758
jpshackelford merged 3 commits into
mainfrom
expand-secrets-in-plugin-source

Conversation

@jpshackelford

@jpshackelford jpshackelford commented Jun 16, 2026

Copy link
Copy Markdown
Member

What & why

Expand ${VAR} placeholders in a plugin's source URL (and ref) against the per-conversation secret registry before the plugin is fetched, so a private plugin repository can be cloned by referencing a secret rather than only by hard-coding the raw token.

Closes #3755.

This is the missing sibling of secret expansion for MCP config (#2872 / #2873) and MCP tool parameters (#3277 / #3278). The plugin source path was the only secret-consuming site that never read from the registry, so today a placeholder like https://x-token-auth:${BITBUCKET_DATA_CENTER_TOKEN}@host/repo.git reaches git clone literally and fails (surfacing as a 500 at conversation start).

The four ways a customer can supply a git credential for a private plugin

# How the token is supplied Before After
1 Hard-code the literal token in the source URL
2 Reference a user-profile custom secret, e.g. ${MY_TOKEN}
3 Reference a secret passed in the conversation-start API (secrets field)
4 Reference an OpenHands-managed provider token, e.g. ${GITHUB_TOKEN}, ${GITLAB_TOKEN}, ${BITBUCKET_TOKEN}, ${BITBUCKET_DATA_CENTER_TOKEN}

Only scenario 1 worked before — and it is the least desirable option (raw token in the request payload and in not-yet-redacted spec serialization). Scenarios 2–4 are the patterns we want customers to use.

Why this one change unlocks scenarios 2–4: all three already land in the SDK SecretRegistry before plugins are fetched — they just were never consumed by the plugin path:

  • Scenario 2AgentContext.secrets, seeded into the registry in LocalConversation.__init__ (fill-if-absent).
  • Scenario 3StartConversationRequest.secrets, applied to the registry (higher priority).
  • Scenario 4 → OpenHands registers each connected provider token in that same secrets map under f"{PROVIDER}_TOKEN" (SaaS: lazy LookupSecret via webhook; OSS: StaticSecret).

In every case secret_registry.get_secret_value(name) returns the value at the moment _ensure_plugins_loaded() runs.

Implementation

In LocalConversation._ensure_plugins_loaded(), before fetch_plugin_with_resolution(...), expand spec.source and spec.ref via the generic expand_variable_references() helper extracted in #3278:

  • check_env=False — registered conversation secrets only, never host os.environ. Folding arbitrary host env vars into a URL sent to a remote git host would be a credential-exfiltration vector.
  • support_unbraced=False — braced ${VAR} only, so a literal $ in a token/password is never mangled.
  • expand_defaults=False — a missing secret leaves the placeholder untouched (no surprising defaults in a URL).
  • Persistence stays redactedResolvedPluginSource is still built from the original spec, so the persisted source keeps the ${VAR} placeholder, and from_plugin_source() continues to redact any inline credentials. Resume re-fetches via the resolved commit SHA, so the raw secret never lands in persisted state or logs.

Tests

tests/sdk/conversation/test_local_conversation_plugins.py::TestPluginSourceSecretExpansion:

  • test_source_secret_expanded_before_fetch${MY_TOKEN} in the source is expanded to the secret value handed to the fetcher; persisted resolved_plugins[0].source does not contain the raw secret.

  • test_host_env_not_expanded_in_source — a host env var (no matching registered secret) is not expanded; placeholder preserved verbatim.

  • test_ref_secret_expanded_before_fetch${VAR} in ref is expanded too.

  • test_unknown_var_with_default_left_untouched${MISSING:-fallback} (no matching secret) is left verbatim, proving expand_defaults=False (no silent default substitution inside a URL).

ruff check / ruff format clean on the changed files.

Evidence:

$ uv run pytest tests/sdk/conversation/test_local_conversation_plugins.py::TestPluginSourceSecretExpansion -v
::TestPluginSourceSecretExpansion::test_source_secret_expanded_before_fetch     PASSED [ 25%]
::TestPluginSourceSecretExpansion::test_host_env_not_expanded_in_source         PASSED [ 50%]
::TestPluginSourceSecretExpansion::test_unknown_var_with_default_left_untouched PASSED [ 75%]
::TestPluginSourceSecretExpansion::test_ref_secret_expanded_before_fetch        PASSED [100%]
======================== 4 passed in 0.06s =========================

$ uv run pytest tests/sdk/conversation/test_local_conversation_plugins.py -q
======================== 16 passed in 0.18s ========================

Scope / non-goals & security notes


This PR was created by an AI agent (OpenHands) on behalf of @jpshackelford.


Agent Server images for this PR

GHCR package: https://github.com/OpenHands/agent-sdk/pkgs/container/agent-server

Variants & Base Images

Variant Architectures Base Image Docs / Tags
java amd64, arm64 eclipse-temurin:17-jdk Link
python amd64, arm64 nikolaik/python-nodejs:python3.13-nodejs22-slim Link
golang amd64, arm64 golang:1.21-bookworm Link

Pull (multi-arch manifest)

# Each variant is a multi-arch manifest supporting both amd64 and arm64
docker pull ghcr.io/openhands/agent-server:88716f9-python

Run

docker run -it --rm \
  -p 8000:8000 \
  --name agent-server-88716f9-python \
  ghcr.io/openhands/agent-server:88716f9-python

All tags pushed for this build

ghcr.io/openhands/agent-server:88716f9-golang-amd64
ghcr.io/openhands/agent-server:88716f9f545410886f5a273d308f2106f181cd88-golang-amd64
ghcr.io/openhands/agent-server:expand-secrets-in-plugin-source-golang-amd64
ghcr.io/openhands/agent-server:88716f9-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:88716f9-golang-arm64
ghcr.io/openhands/agent-server:88716f9f545410886f5a273d308f2106f181cd88-golang-arm64
ghcr.io/openhands/agent-server:expand-secrets-in-plugin-source-golang-arm64
ghcr.io/openhands/agent-server:88716f9-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:88716f9-java-amd64
ghcr.io/openhands/agent-server:88716f9f545410886f5a273d308f2106f181cd88-java-amd64
ghcr.io/openhands/agent-server:expand-secrets-in-plugin-source-java-amd64
ghcr.io/openhands/agent-server:88716f9-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:88716f9-java-arm64
ghcr.io/openhands/agent-server:88716f9f545410886f5a273d308f2106f181cd88-java-arm64
ghcr.io/openhands/agent-server:expand-secrets-in-plugin-source-java-arm64
ghcr.io/openhands/agent-server:88716f9-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:88716f9-python-amd64
ghcr.io/openhands/agent-server:88716f9f545410886f5a273d308f2106f181cd88-python-amd64
ghcr.io/openhands/agent-server:expand-secrets-in-plugin-source-python-amd64
ghcr.io/openhands/agent-server:88716f9-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-amd64
ghcr.io/openhands/agent-server:88716f9-python-arm64
ghcr.io/openhands/agent-server:88716f9f545410886f5a273d308f2106f181cd88-python-arm64
ghcr.io/openhands/agent-server:expand-secrets-in-plugin-source-python-arm64
ghcr.io/openhands/agent-server:88716f9-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-arm64
ghcr.io/openhands/agent-server:88716f9-golang
ghcr.io/openhands/agent-server:88716f9f545410886f5a273d308f2106f181cd88-golang
ghcr.io/openhands/agent-server:expand-secrets-in-plugin-source-golang
ghcr.io/openhands/agent-server:88716f9-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:88716f9-java
ghcr.io/openhands/agent-server:88716f9f545410886f5a273d308f2106f181cd88-java
ghcr.io/openhands/agent-server:expand-secrets-in-plugin-source-java
ghcr.io/openhands/agent-server:88716f9-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:88716f9-python
ghcr.io/openhands/agent-server:88716f9f545410886f5a273d308f2106f181cd88-python
ghcr.io/openhands/agent-server:expand-secrets-in-plugin-source-python
ghcr.io/openhands/agent-server:88716f9-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim

About Multi-Architecture Support

  • Each variant tag (e.g., 88716f9-python) is a multi-arch manifest supporting both amd64 and arm64
  • Docker automatically pulls the correct architecture for your platform
  • Individual architecture tags (e.g., 88716f9-python-amd64) are also available if needed

Expand ${VAR} placeholders in a plugin's source URL and ref against the
per-conversation secret registry before fetching, so private plugin repos
can be cloned by referencing a user-profile secret, an API-passed secret,
or an OpenHands-managed provider token (e.g. ${BITBUCKET_DATA_CENTER_TOKEN}).

Secrets only (check_env=False); braced-only to avoid mangling literal '$'
in tokens; persistence built from the original spec so the placeholder
(not the secret) is stored and inline credentials stay redacted.

Closes #3755

Co-authored-by: openhands <openhands@all-hands.dev>
@jpshackelford jpshackelford added customer-support This item has been reported by a customer and is being tracked as a support ticket. plugins About plugins and their contents security-related labels Jun 16, 2026 — with OpenHands AI
@jpshackelford jpshackelford added plugins About plugins and their contents customer-support This item has been reported by a customer and is being tracked as a support ticket. labels Jun 16, 2026
@github-actions

github-actions Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Python API breakage checks — ✅ PASSED

Result:PASSED

Action log

@github-actions

github-actions Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

REST API breakage checks (OpenAPI) — ✅ PASSED

Result:PASSED

Action log

@github-actions

github-actions Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-sdk/openhands/sdk/conversation/impl
   local_conversation.py7816192%98, 436–437, 470, 487, 632, 706, 775, 791, 867, 1172–1173, 1250–1251, 1254, 1382, 1385–1386, 1410, 1443–1444, 1447, 1453, 1534, 1541, 1544, 1547, 1551–1552, 1556–1557, 1560, 1567, 1592, 1596, 1599, 1618, 1670, 1673, 1712, 1719–1720, 1728, 1732–1734, 1741, 1853, 1858, 1968, 1970, 1974–1975, 1986–1987, 2012, 2207, 2211, 2281, 2288–2289
TOTAL31212855572% 

@jpshackelford jpshackelford left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review (Roasted) — /codereview

Taste Rating: 🟢 Good taste

This is the kind of change I like to see: small (+155/-4, 2 files), it reuses an existing, well-understood helper instead of inventing a new expansion path, and every non-obvious decision is justified. It plugs a genuine hole — the plugin source/ref was the one secret-consuming site that never read the registry, so ${TOKEN} reached git clone literally and 500'd at conversation start. The fix is the obvious sibling of the MCP-config (#2873) and MCP-tool-param (#3278) work, and it sits right next to the MCP expansion it mirrors. No special cases, no new abstractions, no behavior change for the existing hard-coded-token path.

I tried to find something to roast. The codebase made it hard. Walking the blast radius:

  • Persistence stays clean (verified). ResolvedPluginSource.from_plugin_source(spec, …) is built from the original spec, not the expanded value, and then runs through redact_url_credentials(). I confirmed both forms redact to https://****@host/.... The test asserting "s3cr3t-value" not in resolved_plugins[0].source is the right guard.
  • Logging stays clean (verified). The logger.debug(... from {spec.source} ...) uses the original placeholder, and the lower-level run_git_command already redacts credentials via _redact_args_for_logging before any logger.error/raise. So even a clone failure with a freshly-expanded token doesn't spill the secret into logs. The PR rides on that existing safety net correctly.
  • Security defaults are correct (verified). check_env=False (no host-env exfiltration into a remote URL), support_unbraced=False (a literal $ in a token isn't mangled), expand_defaults=False (an unknown ${VAR} is preserved verbatim, not silently defaulted). The negative test test_host_env_not_expanded_in_source actually proves the check_env=False invariant rather than just asserting the happy path. Good.
  • No bypass paths. The only conversation-start fetch site is _ensure_plugins_loaded; Plugin.fetch() is a separate public API with an explicit caller-supplied source. _plugin_specs is seeded from the constructor (original placeholders), so resume re-expands freshly against the registry rather than from redacted persisted state.
  • Tests pass (15/15) and exercise the real expand_variable_references + SecretRegistry — only the network git clone is faked. That's a legitimate fake (ephemeral boundary), not the "mocks asserting mocks" anti-pattern.

[IMPROVEMENT OPPORTUNITIES] (minor, non-blocking)

  • local_conversation.py ~L657–L678 — light DRY. The expand_variable_references(..., check_env=False, support_unbraced=False, expand_defaults=False) call is duplicated verbatim for source and ref, differing only in the input. A tiny local closure would remove the repetition and guarantee the two calls can't drift apart:

    def expand(value):
        return expand_variable_references(
            value, get_secret=get_secret,
            check_env=False, support_unbraced=False, expand_defaults=False,
        )
    fetch_source = expand(spec.source)
    fetch_ref = expand(spec.ref) if spec.ref else spec.ref

    Purely cosmetic — current form is correct.

  • local_conversation.py ~L750 (pre-existing comment) — slightly inaccurate. The MCP block comments that plugin loading "preserves placeholders with expand_defaults=False to avoid double-expansion." source/ref never enter merged_mcp, so there's no actual double-expansion to avoid here; the real reasons are the ones in your new comment (no surprising defaults in a URL). Not introduced by this PR, not worth churning.

[STYLE NOTES]

None worth raising. And before anyone flags the ~14-line comment block on the expansion call as "noise": it documents the check_env=False / support_unbraced=False security rationale — exactly the kind of non-inferable invariant that should be commented. Keep it.

[TESTING GAPS]

None blocking. Coverage is well-chosen (positive source, negative host-env, ref). If you wanted gold-plating you could add a ${VAR:-default} case to pin down that expand_defaults=False leaves ${MISSING:-foo} untouched in a URL, but that's the helper's contract and already tested elsewhere — optional.

One soft note on the PR description: per the strict reading of our review rubric, test runs alone aren't "evidence." For internal SDK plumbing like this the deterministic tests are reasonable proof, but if you want to fully satisfy the bar, drop in the literal command + output (or the originating conversation URL) under an Evidence: heading.

[RISK ASSESSMENT]

  • [Overall PR] ⚠️ Risk Assessment: 🟢 LOW

Behavior is opt-in (only fires when a user authors a ${VAR} in source/ref), defaults are conservative (secrets-only, braced-only, no defaults), persisted state and git logging are both already redacted, and there's no change to the existing hard-coded-token path. The one residual is the documented and intentional #4 footgun — a user can embed ${GITHUB_TOKEN} into a URL for an unrelated host and send a managed token off-host. The PR correctly calls this out as user-authored and out of scope; a docs warning would be the right follow-up, not a blocker here.


VERDICT: ✅ Worth merging

Tight, correct, well-tested, and it leans on existing redaction infrastructure instead of reinventing it. Address the DRY nit if you feel like it; otherwise ship it.

KEY INSIGHT

The actual engineering win is restraint: the only real change is "expand source/ref through the same registry the sibling paths already use, with the safest possible flags," and the security correctness falls out of infrastructure (redact_url_credentials, _redact_args_for_logging, original-spec persistence) that already existed — the PR's job was to wire into it without poking a new hole, and it does exactly that.

Reviewed against PR head 0d2e7146: read the changed code plus surrounding _ensure_plugins_loaded, the expand_variable_references helper, ResolvedPluginSource.from_plugin_source, and the git command/redaction layer, and ran the test file (15/15 passing).


Improve this review? If any feedback above seems incorrect or irrelevant to this repository, add a .agents/skills/custom-codereview-guide.md file to your branch with the /codereview trigger and the missing context, then re-request a review. See the customization docs.

Resolve with AI? Install the iterate skill and run /iterate to drive this PR through CI, review, and QA until it's merge-ready.

Was this review helpful? React with 👍 or 👎.


This review was generated by an AI agent (OpenHands) on behalf of @jpshackelford.

Address non-blocking code-review feedback on the plugin source/ref secret
expansion: hoist the expand_variable_references(...) call into a single
local helper so the source/ref calls cannot drift, and add a regression
test pinning expand_defaults=False (an unresolved ${MISSING:-default} is
left verbatim, not substituted).

Co-authored-by: openhands <openhands@all-hands.dev>
@jpshackelford jpshackelford marked this pull request as ready for review June 17, 2026 14:41

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ QA Report: PASS

The PR achieves its stated goal: plugin source and ref placeholders are expanded from conversation secrets before fetch, while persisted resolved source remains redacted/placeholder-based.

Does this PR achieve its stated goal?

Yes. I exercised the SDK as a user by creating Conversation(...) instances with PluginSource(...), adding secrets through conversation.update_secrets(...), and calling conversation.send_message(...) to trigger real plugin fetching/loading. On origin/main, git attempted to clone/fetch the literal ${...} placeholders and raised PluginFetchError; on the PR head, the same operations loaded the plugin skills successfully, and the resolved source still preserved ${PLUGIN_SEGMENT} rather than the fake secret segment.

Phase Result
Environment Setup make build completed and created the project .venv
CI Status ✅ All visible PR checks are successful; cleanup-on-approval is skipped
Functional Verification ✅ Source URL secret expansion and ref secret expansion both verified before/after
Functional Verification

Test 1: Secret placeholder in plugin source is expanded before fetch

Step 1 — Reproduce / establish baseline (without the fix):
Ran git checkout --detach origin/main && uv run python /tmp/qa_plugin_secret_expansion.py.

Relevant output:

Cloning repository from file:///tmp/oh-plugin-secret-qa-ho7_7zh6/repos/${PLUGIN_SEGMENT}/private-plugin
fatal: '/tmp/oh-plugin-secret-qa-ho7_7zh6/repos/${PLUGIN_SEGMENT}/private-plugin' does not appear to be a git repository
RESULT=error
SOURCE_TEMPLATE=file:///tmp/oh-plugin-secret-qa-ho7_7zh6/repos/${PLUGIN_SEGMENT}/private-plugin
ACTUAL_PLUGIN_PATH=/tmp/oh-plugin-secret-qa-ho7_7zh6/repos/credential-segment/private-plugin
ERROR_TYPE=PluginFetchError
ERROR=Failed to fetch plugin from file:///tmp/oh-plugin-secret-qa-ho7_7zh6/repos/${PLUGIN_SEGMENT}/private-plugin

This confirms the bug exists on main: the registered conversation secret was ignored and the literal ${PLUGIN_SEGMENT} reached git, so the private-plugin path could not be fetched.

Step 2 — Apply the PR's changes:
Checked out the PR branch at 88716f9f545410886f5a273d308f2106f181cd88 with git checkout expand-secrets-in-plugin-source && git reset --hard origin/expand-secrets-in-plugin-source.

Step 3 — Re-run with the fix in place:
Ran uv run python /tmp/qa_plugin_secret_expansion.py.

Relevant output:

Cloning repository from file:///tmp/oh-plugin-secret-qa-n1o6h3xr/repos/credential-segment/private-plugin
Loaded 1 plugin(s) via Conversation
RESULT=success
SOURCE_TEMPLATE=file:///tmp/oh-plugin-secret-qa-n1o6h3xr/repos/${PLUGIN_SEGMENT}/private-plugin
ACTUAL_PLUGIN_PATH=/tmp/oh-plugin-secret-qa-n1o6h3xr/repos/credential-segment/private-plugin
SKILLS=['qa-private-skill']
RESOLVED_SOURCES=['file:///tmp/oh-plugin-secret-qa-n1o6h3xr/repos/${PLUGIN_SEGMENT}/private-plugin']
SECRET_IN_RESOLVED_SOURCE=False

This confirms the fix works for source: git fetched the expanded URL, the plugin skill became available, and resolved_plugins did not persist the secret-expanded segment.

Test 2: Secret placeholder in plugin ref is expanded before fetch

Step 1 — Reproduce / establish baseline (without the fix):
Ran git checkout --detach origin/main && uv run python /tmp/qa_plugin_ref_secret_expansion.py.

Relevant output:

Git command failed: git clone --depth 1 --branch '${PLUGIN_REF}' file:///tmp/oh-plugin-ref-qa-yj1xb5lo/repos/ref-plugin ...
fatal: Remote branch ${PLUGIN_REF} not found in upstream origin
RESULT=error
SOURCE=file:///tmp/oh-plugin-ref-qa-yj1xb5lo/repos/ref-plugin
REF_TEMPLATE=${PLUGIN_REF}
EXPECTED_REF=qa-secret-ref
ERROR_TYPE=PluginFetchError
ERROR=Failed to fetch plugin from file:///tmp/oh-plugin-ref-qa-yj1xb5lo/repos/ref-plugin

This confirms the baseline behavior for ref: the placeholder was passed literally as the branch name, so git could not find the branch even though the conversation secret specified qa-secret-ref.

Step 2 — Apply the PR's changes:
Checked out the PR branch again at 88716f9f545410886f5a273d308f2106f181cd88.

Step 3 — Re-run with the fix in place:
Ran uv run python /tmp/qa_plugin_ref_secret_expansion.py.

Relevant output:

Cloning repository from file:///tmp/oh-plugin-ref-qa-3hfdvkkb/repos/ref-plugin
Loaded 1 plugin(s) via Conversation
RESULT=success
SOURCE=file:///tmp/oh-plugin-ref-qa-3hfdvkkb/repos/ref-plugin
REF_TEMPLATE=${PLUGIN_REF}
EXPECTED_REF=qa-secret-ref
SKILLS=['qa-ref-skill']
RESOLVED_REFS=['663a01bac0abdd931eed6bb14a831170cbad9128']

This confirms the fix works for ref: the placeholder was expanded to the secret branch name before git fetch/clone, and the loaded plugin exposed the expected skill.

Issues Found

None.

This review was generated by an AI agent (OpenHands) on behalf of the user.

@juanmichelini juanmichelini left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@jpshackelford jpshackelford merged commit 3b127ba into main Jun 17, 2026
38 checks passed
@jpshackelford jpshackelford deleted the expand-secrets-in-plugin-source branch June 17, 2026 17:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

customer-support This item has been reported by a customer and is being tracked as a support ticket. plugins About plugins and their contents security-related

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Expand per-conversation secrets in plugin/extension source URLs before fetch (private-repo plugins)

4 participants