Skip to content

fix(extensions): compose local source with repo_path - #4839

Open
VascoSch92 wants to merge 3 commits into
mainfrom
vasco/oss-10405-local-repo-path
Open

fix(extensions): compose local source with repo_path#4839
VascoSch92 wants to merge 3 commits into
mainfrom
vasco/oss-10405-local-repo-path

Conversation

@VascoSch92

@VascoSch92 VascoSch92 commented Sep 3, 2026

Copy link
Copy Markdown
Member

HUMAN:

BE needed to install extensions from local path


AGENT:

Why

Installing a canvas extension by pointing Source at a repository and Path at the
extension's subdirectory always fails. fetch_with_resolution() rejects
repo_path outright for SourceType.LOCAL before the subpath is ever applied,
so the composition that already works for git and GitHub sources is unreachable
for a local checkout.

The failure is also unreadable. The install route catches ExtensionFetchError
and replaces it with one fixed sentence about the source, so a typo in Path is
reported as a bad Source. On origin/main six of seven distinct mistakes return
the exact same message.

Reported as OSS-10405 and as OpenHands/OpenHands#17048.

Summary

  • fetch.py: local sources now compose with repo_path the same way git sources
    do, via _apply_subpath(), instead of raising.
  • _apply_subpath() gained containment. A subpath that climbs out of the base
    through .. or a symlink is rejected rather than resolved.
  • canvas_extensions_router.py: the install route forwards the specific fetch
    reason (credential-redacted) and answers a missing manifest with its own 422
    telling the user that Source plus Path must name the extension's own directory.
  • That 400 is worded "Could not read", not "Failed to fetch". The canvas client
    treats any message containing "failed to fetch" as a network outage and
    replaces it with a "Disconnected, check your network" toast, which hid the
    reason. A test asserts the phrase stays out.

Issue Number

OpenHands/OpenHands#17048, Linear OSS-10405.

How to Test

The ticket's own reproduction, run end to end against the real installer
(install_canvas_extension), with a fixture repository holding
canvas-extensions/canvas-pulse/canvas-extension.json:

$ uv run --frozen python evidence.py

On origin/main (e266832):

  FAIL  Source=/path/canvas-extensions/  Path=/canvas-pulse
          ExtensionFetchError: repo_path is not supported for local extension sources...
  FAIL  Source=/path/canvas-extensions/  Path=canvas-pulse
          ExtensionFetchError: repo_path is not supported for local extension sources...
  FAIL  Source=/path/canvas-extensions   Path=/canvas-pulse
          ExtensionFetchError: repo_path is not supported for local extension sources...
  FAIL  Source=/path/canvas-extensions   Path=canvas-pulse
          ExtensionFetchError: repo_path is not supported for local extension sources...
  PASS  Source=/path/canvas-extensions/canvas-pulse  (no Path)
          installed 'canvas-pulse' v0.1.0

On this branch:

  PASS  Source=/path/canvas-extensions/  Path=/canvas-pulse
          installed 'canvas-pulse' v0.1.0
  PASS  Source=/path/canvas-extensions/  Path=canvas-pulse
          installed 'canvas-pulse' v0.1.0
  PASS  Source=/path/canvas-extensions   Path=/canvas-pulse
          installed 'canvas-pulse' v0.1.0
  PASS  Source=/path/canvas-extensions   Path=canvas-pulse
          installed 'canvas-pulse' v0.1.0
  PASS  Source=/path/canvas-extensions/canvas-pulse  (no Path)
          installed 'canvas-pulse' v0.1.0

Installed content was checked, not just the return value: only the named
subdirectory is copied (a sibling other-extension/ in the same repository is
not), repo_path is recorded on the installed record, and the extension stays
enabled=False as before.

Second, what the HTTP API says for each realistic mistake, driven through
TestClient against canvas_extensions_router.

On origin/main:

400  happy: parent + subdir            -> Failed to fetch canvas extension source. Check that the source is valid.
400  happy: parent/ + /subdir          -> Failed to fetch canvas extension source. Check that the source is valid.
400  typo in Path                      -> Failed to fetch canvas extension source. Check that the source is valid.
400  Path at dir without manifest      -> Failed to fetch canvas extension source. Check that the source is valid.
422  Path left empty, Source=repo root -> Invalid canvas extension. Ensure the manifest is well-formed.
400  Source typo                       -> Failed to fetch canvas extension source. Check that the source is valid.
400  traversal                         -> Failed to fetch canvas extension source. Check that the source is valid.

On this branch:

200  happy: parent + subdir            -> installed
409  happy: parent/ + /subdir          -> Canvas extension already installed. Use force=true to overwrite.
400  typo in Path                      -> Could not read canvas extension source: Subdirectory 'canvas-puls' not found in local source '/tmp/.../canvas-extensions'
422  Path at dir without manifest      -> No canvas-extension.json found at the resolved location. Source plus Path must point at the extension's own directory, not the repository root.
422  Path left empty, Source=repo root -> No canvas-extension.json found at the resolved location. Source plus Path must point at the extension's own directory, not the repository root.
400  Source typo                       -> Could not read canvas extension source: Local extension path does not exist: /tmp/.../nope
400  traversal                         -> Could not read canvas extension source: Subdirectory '../..' escapes local source '/tmp/.../canvas-extensions'

The 409 on the second row is the store rejecting a duplicate name, because the
first row already installed canvas-pulse into the shared fixture store. Slash
tolerance is covered on its own by the parametrized router test.

Test suites:

$ uv run --frozen pytest tests/agent_server          # 2068 passed
$ uv run --frozen pytest tests/sdk/extensions tests/sdk/plugin tests/sdk/skill
                                                     # 677 passed, 2 skipped
$ pre-commit run --all-files                          # all hooks pass

Each new regression test was confirmed to fail with its fix reverted.

Browser verification

Ran the Canvas dev stack against this branch's agent-server:

OH_AGENT_SERVER_LOCAL_PATH=<this checkout> npm run dev   # from OpenHands/OpenHands

with a fixture at /tmp/repository-name/demo-extension plus a sibling
other-extension. Installing through the UI with Source /tmp/repository-name
and Path demo-extension succeeded, and the resulting record proves the
composition rather than a full-path install:

name=demo-extension  source=/tmp/repository-name  repo_path=demo-extension  enabled=false
install dir contains: demo-extension        # the sibling was not copied

The agent-server log for the failure cases, taken from the same session:

HTTPException 400 on POST /api/canvas-extensions/install: Could not read canvas extension source: Subdirectory 'demo-extensio' not found in local source '/tmp/repository-name'
HTTPException 422 on POST /api/canvas-extensions/install: No canvas-extension.json found at the resolved location. Source plus Path must point at the extension's own directory, not the repository root.
HTTPException 400 on POST /api/canvas-extensions/install: Could not read canvas extension source: Subdirectory '../..' escapes local source '/tmp/repository-name'

This browser run is what surfaced the "failed to fetch" wording collision: the
server logged the correct 400 while the UI showed a network-outage toast. The
unit test did not catch it because it used a stand-in for the client's error
class.

Video/Screenshots

Terminal output above is the reproduction evidence: the four failing ticket cases
on origin/main, the same four passing here, and the before/after API messages.

Type

  • Bug fix
  • Feature
  • Refactor
  • Breaking change
  • Docs / chore

Notes

  • Caught while testing this in the browser, not by the unit tests: with the old
    wording the improved 400 was still displayed as a network outage, because
    isCorsOrNetworkErrorMessage() in the canvas frontend matches the substring
    "failed to fetch". Reworded here so every consumer benefits, not just the one
    hook the companion PR touches.
  • fetch.py is shared by Plugins, Skills and Canvas Extensions, so Plugins and
    Skills gain the same local Source + Path composition. Two plugin tests that
    asserted the old rejection were rewritten to the new contract.
  • The containment check in _apply_subpath() also tightens git and GitHub
    sources, which previously accepted a traversing repo_path.
  • The Canvas UI change that actually renders these messages is a separate PR in
    fix(canvas-extensions): show the install failure reason OpenHands#17120. It shows the improved text only once this lands.

🐳 Agent Server images for this PR — GHCR package, pull/run commands, and all pushed tags (click to expand)

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-slim amd64, arm64 nikolaik/python-nodejs:python3.13-nodejs22-slim 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:423fbe6-python

Run

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

All tags pushed for this build

ghcr.io/openhands/agent-server:423fbe6-golang-amd64
ghcr.io/openhands/agent-server:423fbe6a3e43f0c904374d5711e3169661d759ca-golang-amd64
ghcr.io/openhands/agent-server:vasco-oss-10405-local-repo-path-golang-amd64
ghcr.io/openhands/agent-server:423fbe6-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:423fbe6-golang-arm64
ghcr.io/openhands/agent-server:423fbe6a3e43f0c904374d5711e3169661d759ca-golang-arm64
ghcr.io/openhands/agent-server:vasco-oss-10405-local-repo-path-golang-arm64
ghcr.io/openhands/agent-server:423fbe6-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:423fbe6-java-amd64
ghcr.io/openhands/agent-server:423fbe6a3e43f0c904374d5711e3169661d759ca-java-amd64
ghcr.io/openhands/agent-server:vasco-oss-10405-local-repo-path-java-amd64
ghcr.io/openhands/agent-server:423fbe6-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:423fbe6-java-arm64
ghcr.io/openhands/agent-server:423fbe6a3e43f0c904374d5711e3169661d759ca-java-arm64
ghcr.io/openhands/agent-server:vasco-oss-10405-local-repo-path-java-arm64
ghcr.io/openhands/agent-server:423fbe6-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:423fbe6-python-amd64
ghcr.io/openhands/agent-server:423fbe6a3e43f0c904374d5711e3169661d759ca-python-amd64
ghcr.io/openhands/agent-server:vasco-oss-10405-local-repo-path-python-amd64
ghcr.io/openhands/agent-server:423fbe6-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-amd64
ghcr.io/openhands/agent-server:423fbe6-python-arm64
ghcr.io/openhands/agent-server:423fbe6a3e43f0c904374d5711e3169661d759ca-python-arm64
ghcr.io/openhands/agent-server:vasco-oss-10405-local-repo-path-python-arm64
ghcr.io/openhands/agent-server:423fbe6-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-arm64
ghcr.io/openhands/agent-server:423fbe6-python-slim-amd64
ghcr.io/openhands/agent-server:423fbe6a3e43f0c904374d5711e3169661d759ca-python-slim-amd64
ghcr.io/openhands/agent-server:vasco-oss-10405-local-repo-path-python-slim-amd64
ghcr.io/openhands/agent-server:423fbe6-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-slim-amd64
ghcr.io/openhands/agent-server:423fbe6-python-slim-arm64
ghcr.io/openhands/agent-server:423fbe6a3e43f0c904374d5711e3169661d759ca-python-slim-arm64
ghcr.io/openhands/agent-server:vasco-oss-10405-local-repo-path-python-slim-arm64
ghcr.io/openhands/agent-server:423fbe6-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-slim-arm64
ghcr.io/openhands/agent-server:423fbe6-golang
ghcr.io/openhands/agent-server:423fbe6a3e43f0c904374d5711e3169661d759ca-golang
ghcr.io/openhands/agent-server:vasco-oss-10405-local-repo-path-golang
ghcr.io/openhands/agent-server:423fbe6-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:423fbe6-java
ghcr.io/openhands/agent-server:423fbe6a3e43f0c904374d5711e3169661d759ca-java
ghcr.io/openhands/agent-server:vasco-oss-10405-local-repo-path-java
ghcr.io/openhands/agent-server:423fbe6-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:423fbe6-python-slim
ghcr.io/openhands/agent-server:423fbe6a3e43f0c904374d5711e3169661d759ca-python-slim
ghcr.io/openhands/agent-server:vasco-oss-10405-local-repo-path-python-slim
ghcr.io/openhands/agent-server:423fbe6-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-slim
ghcr.io/openhands/agent-server:423fbe6-python
ghcr.io/openhands/agent-server:423fbe6a3e43f0c904374d5711e3169661d759ca-python
ghcr.io/openhands/agent-server:vasco-oss-10405-local-repo-path-python
ghcr.io/openhands/agent-server:423fbe6-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim

About Multi-Architecture Support

  • Each variant tag (e.g., 423fbe6-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., 423fbe6-python-amd64) are also available if needed

A local source plus repo_path was rejected outright, so a Canvas
extension in a monorepo could only be installed by passing its full
directory as the source. The subpath join already existed for git
sources; local sources now use it too, with a containment check so a
traversing repo_path cannot escape the source directory.

The install endpoint also replaced every fetch failure with one generic
"check that the source is valid" message, which misattributed a bad
repo_path to the source. It now passes the specific reason through
(credential-redacted) and names the manifest when none is found at the
resolved location.
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Python API breakage checks — ✅ PASSED

Result:PASSED

Action log

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

REST API breakage checks (OpenAPI) — ✅ PASSED

Result:PASSED

Action log

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Coverage

Coverage Report •
FileStmtsMissCoverMissing
TOTAL42035768482% 
report-only-changed-files is enabled. No files were changed during this commit :)

The canvas client classifies any message containing 'failed to fetch' as
a network outage, so the install failure reason was replaced by a
'Disconnected, check your network' toast. Word it 'Could not read' and
assert the phrase stays out.
@VascoSch92
VascoSch92 marked this pull request as ready for review September 3, 2026 06:59
@VascoSch92 VascoSch92 changed the title fix(extensions): compose local source with repo_path (OSS-10405) fix(extensions): compose local source with repo_path Sep 3, 2026
@all-hands-bot

Copy link
Copy Markdown
Collaborator

🚦 CI is currently failing on this PR's latest commit.

Please fix the failing checks before OpenHands reviews it - this is re-checked automatically once you push a new commit. (A maintainer can also request @all-hands-bot as a reviewer to have it reviewed regardless of CI status.)

This is an automated check - no AI was used to generate this comment.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants