Skip to content

feat(extensions): local-directory dev source (~/path) with runtime registry#1815

Draft
jpshackelford wants to merge 3 commits into
jps/ui-extensionsfrom
jps/ui-extensions-local-dir-source
Draft

feat(extensions): local-directory dev source (~/path) with runtime registry#1815
jpshackelford wants to merge 3 commits into
jps/ui-extensionsfrom
jps/ui-extensions-local-dir-source

Conversation

@jpshackelford

Copy link
Copy Markdown
Member

What & why

Adds a local development workflow to the experimental UI-extensions system: a developer running Agent Canvas locally can type a path to a directory on their own machine into the "Add extension" box and have that extension load and hot-reload — with no config edits and no server restart to add a new directory. Previously only remote sources (github:, npm:, https:// bundle URL) were installable.

Feature stays gated behind VITE_ENABLE_EXTENSIONS; the whole local path is dev-only.

Two-layer design (matches the existing architecture)

A. Browser (resolver) stays dumbsrc/extensions/sources/local-path.ts

  • Recognizes a local-path input (~/…, /abs, file:///abs), does light normalization, and rewrites it to a localhost dev URL served by the dev middleware.
  • Never touches the filesystem and never expands ~ (the server knows $HOME).
  • Rejects file://~/… with an actionable message.
  • Local sources are mutable/unpinned: represented as url-kind ArtifactDescriptors whose sourceRef is the raw path; served no-cache, so every Reload re-fetches.

B. Node dev middleware does the real worksrc/extensions/dev/local-extension-registry.ts + serve-local-extensions plugin in vite.config.ts (apply: "serve")

  • Fixed route /__ext-local/<id>/* over a runtime registry (this is what makes "no restart" true — registering a new directory is just an API call that mutates in-memory state).
  • POST /__ext-local/register { path }expanduser → realpath → assert-is-directory → confine → stores { id → resolvedRoot } → returns a deterministic id (hash of the resolved path, so re-registration is idempotent).
  • GET /__ext-local/<id>/<file> → registry lookup → same traversal guard + content types + CSP-nonce-stamped .html as the existing serve-example-extensions middleware (factored into a shared serveExtensionFile() helper).
  • Registry persists to a gitignored .agent-canvas/dev-extensions.json, loaded + re-validated on boot; entries whose directory no longer exists are silently dropped. The dev server is disposable; the registry is durable.

Invariants preserved

  • Consent is never skipped — local sources flow through the same detectSource → capability-consent card as remote ones (they resolve to a fetchable URL).
  • CSP is never weakened — local .html is served with a host-stamped nonce via the handler; no bare static mount.
  • ~ expands server-side only — the browser never resolves home or touches disk.
  • Resolve-then-confine — the resolved realpath is validated against registered roots on every register and every file request, so ~/../../etc/passwd (register) and /__ext-local/<id>/../../secret (file request) are both rejected.
  • Dev-only — the register endpoint and /__ext-local/ handler exist solely in the Vite serve build; never in a deployed/library build.
  • Does not touch the agent-side Plugins system.

Accepted / rejected input forms

  • Accept: ~/rel/to/home, bare absolute /abs/path, Windows absolute, and file:///abs/path.
  • Reject (actionable message): file://~/… — tilde-as-host is invalid URL grammar; the user is told to use ~/path without the file:// prefix, or file:///absolute/path.

SDK grammar parity

Mirrors software-agent-sdk::parse_extension_source's LOCAL handling: ~, /abs, and (server-side) Path(url).expanduser().resolve(). The dot-relative/bare-relative SDK cases are intentionally omitted in the UI because they are ambiguous with npm:/github: refs.

Design judgment calls

  • Represented local sources as url-kind descriptors (not a new BundleSource/parsed kind). A local dir is naturally a url descriptor whose baseUrl is http://<dev-host>/__ext-local/<id>, so detectSource/consent/toBundleSource work unchanged. "Mutable/unpinned" is expressed by no-cache headers + a raw-path sourceRef that re-resolves on Reload/restart (no version/SHA).
  • ~-expansion and confinement live entirely server-side in LocalExtensionRegistry; the browser half is filesystem-blind by construction.

Testing

  • npm run typecheck
  • eslint src ✅, prettier clean on all changed src/test files ✅
  • New unit tests: local-path recognition + rewrite, ~ NOT expanded in browser, file://~ rejected, registry register/lookup/persist/prune, traversal confinement (both ~/.. register and /__ext-local/<id>/../.. request rejected), modal local-path acceptance + file://~ badge, provider install/reload of a local source, Reload button UI.
  • Full extensions + i18n suite: 385 passed; translation completeness ✅.
  • Manual E2E against a real npx vite dev server with a throwaway bundle: registered a /tmp path and a ~/… path (confirmed ~ expanded to /home/openhands server-side); fetched extension.json (200) and panel.html (CSP header + <script nonce=…> stamped, Cache-Control: no-cache); traversal escapes → 403; unknown id → 403; bad paths → 400 with actionable messages; GET on register → 405; edited panel.html and re-fetched without restart → new bytes; restarted the dev server → same ids still serve (no re-add); removed a directory + restart → stale entry dropped. Throwaway bundle deleted before committing.

This PR was created by an AI agent (OpenHands) on behalf of the user.

@jpshackelford can click here to continue refining the PR

…ource

Add a Node-only LocalExtensionRegistry (expanduser -> realpath -> assert-dir
-> confine, deterministic id from the resolved path, persisted to a gitignored
.agent-canvas/dev-extensions.json) and wire a dev-only Vite plugin
(serve-local-extensions, apply: serve) that exposes:
  - POST /__ext-local/register { path } -> { id }
  - GET  /__ext-local/<id>/<file>  (traversal-confined, no-cache, CSP-nonce
    stamped HTML)

The file-serving logic is factored into a shared serveExtensionFile() helper
reused by the existing serve-example-extensions middleware, so local .html is
never served without a host-stamped nonce.

Co-authored-by: openhands <openhands@all-hands.dev>
Recognize a local filesystem path in the Add-extension box (~/path, /abs, and
file:///abs), reject the structurally invalid file://~ form with an actionable
message, and rewrite it to the localhost dev URL by POSTing the RAW path to the
register endpoint. The browser never expands ~ or touches disk.

Local sources are represented as url-kind ArtifactDescriptors whose sourceRef is
the raw path (mutable/unpinned), so restart re-resolution and a new
reloadExtension() action re-register and re-fetch the current bytes. The
AddExtensionModal accepts local paths (Local badge) and the installed-extension
card gains a Reload button for local sources. Adds i18n keys
(EXTENSIONS$LOCAL_BADGE, RELOAD_BUTTON, RELOADING, RELOAD_SUCCESS,
LOCAL_FILE_TILDE_INVALID) across all locales and updates SOURCE_HELP.

Co-authored-by: openhands <openhands@all-hands.dev>
Add a 'Local Development (recommended: add a directory)' section to the author
guide (accepted/rejected input forms, edit->rebuild->Reload loop, restart
resilience via the persistent registry, dev-only/localhost/consent guardrails),
a Local source row + two-layer design note in the architecture doc, and a
pointer in feature-flag.ts.

Co-authored-by: openhands <openhands@all-hands.dev>
@vercel

vercel Bot commented Jul 15, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agent-canvas Ready Ready Preview, Comment Jul 15, 2026 1:08pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Mock-LLM E2E Tests

60/60 passed

Commit: 58a704ef · Workflow run · Test artifacts

Details
Status Test Duration
automations/mock-llm-automation.spec.ts › mock-LLM automation lifecycle › step 1: setup LLM profile and register automation trajectory 7.6s
automations/mock-llm-automation.spec.ts › mock-LLM automation lifecycle › step 2: create automation and dispatch run via the UI 35.4s
automations/mock-llm-automation.spec.ts › mock-LLM automation lifecycle › step 3: verify automation and run on the automations page 6.2s
automations/mock-llm-preset-automation.spec.ts › preset automation → slash command conversation › automation card sends the correct slash command to a conversation 15.4s
automations/mock-llm-preset-automation.spec.ts › preset automation → slash command conversation › direct slash command from home page triggers skill activation 12.9s
backends/mock-llm-auth-modes.spec.ts › auth mode: fresh install with runtime-injected key › reaches the onboarding modal without pre-seeded localStorage 1.4s
backends/mock-llm-auth-modes.spec.ts › auth mode: non-public key rotation › recovers when localStorage has a stale session API key 5.4s
backends/mock-llm-auth-modes.spec.ts › auth mode: public gate › shows first-run onboarding before the auth screen when no key is configured 2.3s
backends/mock-llm-auth-modes.spec.ts › auth mode: public gate › rejects an incorrect key with an inline error 2.5s
backends/mock-llm-auth-modes.spec.ts › auth mode: public gate › allows access after pasting the correct key 1.8s
backends/mock-llm-auth-modes.spec.ts › auth mode: public gate › skips auth screen for returning user with valid stored key 738ms
backends/mock-llm-auth-modes.spec.ts › auth mode: public gate › re-prompts when the server rotates its key (stale localStorage) 1.5s
backends/mock-llm-cross-connect.spec.ts › cross-connect: frontend-only → backend-only › frontend-only connects to a separate backend-only instance 15.9s
backends/mock-llm-cross-connect.spec.ts › cross-connect: frontend-only → multiple backends › connects to two separate backends and switches between them 21.7s
backends/mock-llm-partial-stack.spec.ts › partial stack: --frontend-only › serves the frontend but returns 503 for backend routes 7.3s
backends/mock-llm-partial-stack.spec.ts › partial stack: --backend-only › serves backend APIs but returns 503 for the frontend root 14.1s
backends/mock-llm-partial-stack.spec.ts › partial stack: port conflict › fails with a clear error when the ingress port is occupied 105ms
backends/mock-llm-partial-stack.spec.ts › partial stack: port conflict › starts successfully on a free port after a conflict 6.0s
conversations/mock-llm-conversation.spec.ts › mock-LLM agent-server conversation › step 1: create an LLM profile pointing at the mock LLM server 6.3s
conversations/mock-llm-conversation.spec.ts › mock-LLM agent-server conversation › step 2: activate the mock-llm profile and verify settings API 6.2s
conversations/mock-llm-conversation.spec.ts › mock-LLM agent-server conversation › step 3: run a conversation with the mock LLM 8.9s
conversations/mock-llm-conversation.spec.ts › mock-LLM agent-server conversation › step 4: resume conversation from sidebar after navigating away 5.8s
conversations/mock-llm-image-upload.spec.ts › mock-LLM image upload › attaching an image embeds it as base64 in the LLM completion call 14.6s
files/mock-llm-files-and-git.spec.ts › files tab, git control bar, and browser tab › step 1: ensure mock LLM profile is configured 6.5s
files/mock-llm-files-and-git.spec.ts › files tab, git control bar, and browser tab › step 2: start conversation and attach workspace metadata 12.4s
files/mock-llm-files-and-git.spec.ts › files tab, git control bar, and browser tab › step 3: git control bar shows workspace pill and git actions 25.3s
files/mock-llm-files-and-git.spec.ts › files tab, git control bar, and browser tab › step 4: files tab defaults to diff view for attached workspace 5.9s
files/mock-llm-files-and-git.spec.ts › files tab, git control bar, and browser tab › step 5: browser tab shows empty state 6.3s
files/mock-llm-files-and-git.spec.ts › files tab, git control bar, and browser tab › step 6: files tab defaults to file-tree view without attached workspace 7.4s
home/mock-llm-folder-workspace.spec.ts › mock-LLM folder browser → workspace → conversation › step 1: browse to a folder, add it as a workspace, and launch a conversation with the correct working_dir 7.8s
mcp/mock-llm-mcp-github.spec.ts › MCP GitHub server install flow › step 1: GitHub card is visible on the MCP marketplace page 5.6s
mcp/mock-llm-mcp-github.spec.ts › MCP GitHub server install flow › step 2: clicking GitHub add control opens the install modal with correct fields 5.6s
mcp/mock-llm-mcp-github.spec.ts › MCP GitHub server install flow › step 3: full install flow — fill PAT, submit, verify installed 12.1s
mcp/mock-llm-mcp-github.spec.ts › MCP GitHub server install flow › step 4: installed GitHub server can be deleted 6.2s
mcp/mock-llm-mcp-slack-credentials.spec.ts › MCP Test Connection credential verification (Slack) › install: invalid Slack credentials are blocked with a credential-check error 5.8s
mcp/mock-llm-mcp-slack-credentials.spec.ts › MCP Test Connection credential verification (Slack) › install: a valid token missing only a scope still installs (missing_scope is not a credential failure) 5.9s
mcp/mock-llm-mcp-slack-credentials.spec.ts › MCP Test Connection credential verification (Slack) › install: an older agent server that omits tool_result still installs (compat) 5.9s
mcp/mock-llm-mcp-slack-credentials.spec.ts › MCP Test Connection credential verification (Slack) › edit: Test Connection verifies the stored credentials and surfaces a credential failure 8.3s
mcp/mock-llm-mcp-slack-credentials.spec.ts › MCP Test Connection credential verification (Slack) › edit: Test Connection reports success for valid stored credentials 5.8s
mcp/mock-llm-mcp-slack-credentials.spec.ts › MCP Test Connection credential verification (Slack) › custom (non-catalog) server: Test Connection attaches no verification probe 5.8s
onboarding/mock-llm-onboarding-happy-path.spec.ts › onboarding happy path › completes the full onboarding flow and launches a conversation 4.5s
onboarding/mock-llm-onboarding-regressions.spec.ts › onboarding recent regressions › keeps the modal open on backdrop click and Escape 1.5s
onboarding/mock-llm-onboarding-regressions.spec.ts › onboarding recent regressions › defaults the LLM setup step to OpenAI GPT-5.5 1.5s
regressions/mock-llm-ui-regressions.spec.ts › UI regressions › scopes standalone styles to the agent-server-ui shell 986ms
regressions/mock-llm-ui-regressions.spec.ts › UI regressions › renders critic results on agent messages and finish actions 3.6s
regressions/mock-llm-ui-regressions.spec.ts › UI regressions › loads older events when scrolling up 1.7s
regressions/mock-llm-ui-regressions.spec.ts › UI regressions › selected workspace persists after navigating away and returning 5.9s
regressions/mock-llm-ui-regressions.spec.ts › UI regressions › cleared sessionStorage yields empty workspace selection 966ms
settings/mock-llm-acp-agent.spec.ts › mock-LLM ACP agent conversation › step 1: configure ACP agent via Settings → Agent UI 13.2s
settings/mock-llm-acp-agent.spec.ts › mock-LLM ACP agent conversation › step 2: reload and verify ACP settings are persisted in UI 5.7s
settings/mock-llm-acp-agent.spec.ts › mock-LLM ACP agent conversation › step 3: start ACP conversation and verify agent reply 6.2s
settings/mock-llm-acp-agent.spec.ts › mock-LLM ACP agent conversation › step 4: resume ACP conversation from sidebar after navigating away 5.7s
settings/mock-llm-model-switch.spec.ts › mock-LLM /model slash command › step 1: configure LLM, create switch-target profile, register trajectory 15.3s
settings/mock-llm-model-switch.spec.ts › mock-LLM /model slash command › step 2: start conversation, switch profile via /model, verify switch 7.7s
settings/mock-llm-profile-management.spec.ts › active profile deletion + reconciliation › active profile is deletable and reconciliation activates another profile 8.2s
settings/mock-llm-profile-management.spec.ts › same-model profile identity › chat header shows the correct profile when two profiles share the same model 15.4s
settings/mock-llm-profile-management.spec.ts › OpenHands provider hidden base_url preservation › re-saving an OpenHands profile from Basic view preserves hidden base_url 7.6s
skills/mock-llm-skills.spec.ts › skill loading: project, user, and deletion › project skill in workspace/.agents/skills/ triggers on matching keyword 13.7s
skills/mock-llm-skills.spec.ts › skill loading: project, user, and deletion › user skill in ~/.openhands/skills/ triggers on matching keyword 13.6s
skills/mock-llm-skills.spec.ts › skill loading: project, user, and deletion › deleting a user skill removes it from subsequent conversations 13.6s

Posted by the Mock-LLM E2E workflow · results are deterministic (scripted LLM responses)

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Mock-LLM Docker E2E Test Results

0/0 passed

Commit: 58a704ef · Workflow run

Details
Status Test Duration

Posted by the Mock-LLM E2E workflow · results are deterministic (scripted LLM responses)

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

Labels

type: feat A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants