Skip to content

fix(skills): ignore client paths when installing on the SSH relay - #18357

Closed
fettpl wants to merge 6 commits into
stablyai:mainfrom
fettpl:fix/ssh-skill-install-host-paths
Closed

fettpl wants to merge 6 commits into
stablyai:mainfrom
fettpl:fix/ssh-skill-install-host-paths

Conversation

@fettpl

@fettpl fettpl commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

ELI5

When you install a skill onto an SSH machine, the remote host now picks the real workspace folder itself. A client can no longer say “put it in /etc or ~/.ssh” and have the host obey.

What Changed

  • Workspace skill-install destinations must sit inside the host home directory and must not be the home directory (requireContained(home, workspace)).
  • The SSH relay no longer uses the caller-supplied workspace.path as the install root. It looks the path up on the host: decode a path from the worktree id when present, then accept it only if git worktree list on that host lists that realpath.
  • Mixed-version clients still send path (schema unchanged). The relay ignores it; host lookup wins.

Why

Issue #18273 / TM-04 / TM-11: destination paths must be resolved by the runtime that owns the host. The relay previously returned the RPC workspace object as-is, so requireContained(workspace, workspace) was a no-op and a packaged skill tree could be written outside the intended worktree.

Linked Issue

Fixes #18273

Visual Proof

N/A — no visual or interaction change. This is host-side path resolution for SSH skill install.

Testing

  • Automated tests added/updated
  • I manually tested these changes locally (unit tests on macOS only; no live SSH install)

Commands from the plan (all passed):

  • pnpm test src/relay/skill-install-handler.test.ts
  • pnpm test src/main/skills/skill-install-destinations.test.ts
  • pnpm tc:node
  • pnpm run check:code-quality:changed

Coverage:

  • Workspace path outside home → admission failure; nothing written.
  • Workspace path equal to home → rejected.
  • Unlisted home subdirectory (~/.ssh analogue) → rejected even when the workspace id matches.
  • Listed git worktree under home → install writes under join(worktree, '.agents', 'skills', name).
  • Worktree id encodes the host path while workspace.path points outside home → host lookup wins; install goes to the listed worktree, not the caller path.

AI Disclosure

Review

  • Cross-platform: Node path + realpath run on the relay (the execution host OS). Git listing uses readRelayWorktreeList with GitCapabilityCache (worktree-list-z fallback for Git <2.36). Child git is runProcess, not raw child_process. Path compares use areRelayWorktreePathsEqual (Windows case).
  • SSH: Execution host owns destination resolution. Client path cannot win. Listing failure is fail-closed (workspace-not-found), not evidence of process death.
  • Security: Closes the TM-04 hole where a matching workspace id plus a caller path outside the worktree (including ~/.ssh / ~/.gnupg under home) became filesystem authority. Folder workspaces that are not listed git worktrees fail closed.
  • Wire: No new required JSON field, no new stream opcode. path stays required on the Zod schema so old clients still parse.
  • Performance: One git worktree list per workspace-scoped install; capability cache is process-scoped on the relay.

Agent skill upstream boundary

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

Ensure no issues in: Security, Cross-platform support (Linux, Windows, Mac), Remote SSH, Mobile, general backwards compatibility, performance

Desktop resolveSkillInstallDestination now also contains workspace dirs under authority.homeDirectory. Worktrees outside $HOME would be rejected; that matches the plan’s shared contain check.

Checklist

  • This PR is small and focused
  • I explained what changed and why (including ELI5)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

Plan verification ran pnpm tc:node, the two test files, and pnpm run check:code-quality:changed. Full pnpm lint / pnpm build were skipped per the issue plan.

@coderabbitai

coderabbitai Bot commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

Understand this PR’s impact

Explore downstream dependencies and potential security impact with Blast Radius.

View blast radius →

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: f4acde56-dcf4-4fca-a5d6-437ee5602619

📥 Commits

Reviewing files that changed from the base of the PR and between 1af71b9 and 306e4cb.

📒 Files selected for processing (1)
  • tests/e2e/ssh-skill-installation.spec.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 9 remain after this review.


📝 Walkthrough

Walkthrough

The destination authority now rejects workspace paths equal to home, filesystem roots, or ancestors of home. It accepts trusted worktrees outside home. The SSH relay resolves worktrees from the host Git worktree list and compares real paths instead of trusting client paths. Folder-scoped SSH installs now fail with skill-install-workspace-not-found. Renderer workspace choices exclude SSH folder workspaces. Tests cover valid worktrees, invalid destinations, path mismatches, and folder rejection.

Priority: ➖ Normal

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description includes the required sections, linked issue, testing details, security considerations, and checklist. However, it materially states that workspace destinations must be inside the host… Correct the What Changed and Notes sections to state that host-listed Git worktrees outside the home directory remain supported. Update the checklist to distinguish completed commands from commands deferred to CI, and remove the claim that …
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 11 functions across 8 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely identifies the main change: SSH relay skill installation no longer trusts client paths.
Linked Issues check ✅ Passed Issue #18273 coding requirements are met. SkillInstallHandler.authority() ignores the client workspace.path, derives a candidate from the worktree ID, and accepts it only after host-side Git workt…
Out of Scope Changes check ✅ Passed The changed relay, destination validation, renderer workspace filtering, unit tests, and end-to-end test directly support Issue #18273. The renderer change prevents the client from offering SSH folder…
Full details: Description check

Explanation

The description includes the required sections, linked issue, testing details, security considerations, and checklist. However, it materially states that workspace destinations must be inside the host home directory, while the implementation and objectives support listed worktrees outside home. It also checks off the full lint, typecheck, test, and build requirement even though the notes state that full lint and build were skipped.

Resolution

Correct the What Changed and Notes sections to state that host-listed Git worktrees outside the home directory remain supported. Update the checklist to distinguish completed commands from commands deferred to CI, and remove the claim that the full lint and build commands passed.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Important

Two regressions to resolve before merging: folder-workspace skill installs over SSH now always fail closed, and local worktrees/folders outside $HOME are now rejected.

Reviewed changes

  • SSH relay destination resolution — SkillInstallHandler.authority() now ignores the caller-supplied path and instead resolves a host worktree by running git worktree list against a path decoded from the worktree id (via splitWorktreeIdForFilesystem), realpath-matching the listing, with git-capability fallback (worktree-list-z) and fail-closed null on any error.
  • Shared containment check — resolveSkillInstallDestination now requires the workspace directory to be strictly inside homeDirectory (requireContained(home, workspace)), replacing the prior requireContained(workspace, workspace) no-op.
  • Tests — five new relay-handler cases (outside-home, home-itself, unlisted subdir, listed worktree, host-lookup-wins) plus a desktop destination test for outside-home/home-itself rejection.

The host-driven resolution for worktrees is sound: the relay's own git worktree list is authoritative, symlinks are neutralized by realpath, and client-supplied paths cannot win. Two concrete fallouts remain, both anchored inline below.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

Comment thread src/relay/skill-install-handler.ts Resolved

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Team

Run ID: 27316e71-1f35-4806-83a2-d2e42e24dbfd

📥 Commits

Reviewing files that changed from the base of the PR and between b52614c and c5ed0a7e35198d8df112c771c06371432677da8c.

📒 Files selected for processing (5)
  • src/main/runtime/runtime-skill-install-authority.test.ts
  • src/main/skills/skill-install-destinations.test.ts
  • src/main/skills/skill-install-destinations.ts
  • src/relay/skill-install-handler.test.ts
  • src/relay/skill-install-handler.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 6 remain after this review.

Comment thread src/relay/skill-install-handler.ts Outdated
resolveFolderWorkspace: async (id) =>
workspace?.kind === 'folder' && workspace.id === id ? workspace : null
workspace?.kind === 'folder' && workspace.id === id ? { id, path: workspace.path } : null

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Authorization Bypass (CWE-73)

Reachability: External · Exploitability: Moderate

Resolve SSH folder paths from host authority.

workspace.path is client-controlled and becomes the installation directory after only home containment. Resolve folder IDs through trusted host state, or reject SSH folder installs until that mapping exists.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

The folder-workspace restore re-opens the exact TM-04 hole this PR is meant to close: resolveFolderWorkspace trusts the caller-supplied path with no host-side validation.

Reviewed changes

  • Scoped the containment check — introduced mustContainInHome on SkillInstallDestinationAuthority and gated requireContained(home, workspace) behind it, so the local/desktop authority (which resolves paths from trusted host inventory) again accepts worktrees and folders outside $HOME.
  • Restored SSH folder installs — resolveFolderWorkspace now returns { id, path: workspace.path } verbatim instead of routing the folder id through the git worktree list lookup, and the relay sets mustContainInHome: true.
  • Added coverage — new tests for local out-of-home worktrees, and folder install/reject (under home vs. outside home) cases; worktree cases now use real repo::path ids.

The worktree branch is sound: the id encodes a host path, git worktree list is authoritative, and realpath neutralizes symlinks. The folder branch is not — see the inline comment. This is the same concern as the prior folder thread, now in the opposite direction: the author fixed "folders never install" by making the caller path the filesystem authority again, which the plan's STOP condition explicitly forbids.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

Comment thread src/relay/skill-install-handler.ts Resolved
@nwparker

Copy link
Copy Markdown
Contributor

Reviewed against the plan and the threat model. This is the stronger version of this fix — I had built an overlapping one at #19866 before triage pointed me here, and I'm closing mine in favour of this. Two things I'd raise:

1. mustContainInHome: true refuses worktrees outside $HOME, which is a supported layout.

requireContained(homeDirectory, workspaceDirectory) on the relay authority means a legitimate checkout at /opt/work/feature or /srv/checkouts/feature on the SSH host now fails as skill-install-destination-escape. Orca's workspace directory is user-configurable and repos live wherever the user put them, so this is reachable without anything unusual.

The plan's own STOP condition points the same way from the other side — "containing under home still allows writing into ~/.ssh / ~/.gnupg" — so home-containment is not what makes this safe anyway. listedWorktreePath() is: ~/.ssh is not in any git worktree list, so it is already refused with or without the containment check. I'd drop mustContainInHome for the worktree branch and keep the membership proof as the guard, and instead reject the degenerate cases directly — workspace equal to homeDirectory, workspace a filesystem root, workspace an ancestor of homeDirectory.

2. Folder workspaces still take the caller's path verbatim.

resolveFolderWorkspace returns { id, path: workspace.path }, so for folder ids the only guard left is mustContainInHome. That's a real reduction in blast radius, but it does mean a caller can still place .agents/skills/<name> anywhere under the host's home — including ~/.ssh/.agents/.... A folder workspace is not necessarily a Git repository, so the worktree proof can't be reused, and I don't think the relay has a folder-root registry to check against today. Probably a follow-up issue rather than something to solve here — worth a comment in the code saying the folder branch is knowingly weaker, so the next reader doesn't assume both branches are proven.

Small note: deriving the candidate from splitWorktreeIdForFilesystem(id) instead of workspace.path reads as "ignore the client path", but the id encodes the path, so it is exactly as caller-controlled. That's fine — listedWorktreePath() is doing the real work — but the comment above it could say so, since "use the id, not the path" isn't itself the security property.

@fettpl

fettpl commented Sep 13, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up for the home-containment and TM-04 notes:

  • Worktree installs keep host git worktree list membership as the proof and drop mustContainInHome, so a listed checkout at /opt/work or /srv/checkouts can install.
  • Destinations that are the home directory, a filesystem root, or an ancestor of home still fail closed as skill-install-destination-escape.
  • SSH folder installs fail closed (skill-install-workspace-not-found). Folder ids are client UUIDs and the relay has no folder registry, so a caller path cannot be proven — including ~/.ssh under home. Local folder installs are unchanged (desktop listFolderWorkspaces).

@fettpl
fettpl force-pushed the fix/ssh-skill-install-host-paths branch from 18960f4 to 0124f3a Compare September 18, 2026 11:39

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ℹ️ No new code issues in this delta — one deferred follow-up on SSH folder targets is inline.

Reviewed changes

  • Proved SSH worktrees by host listing — authority().resolveWorktree now decodes the path from the worktree id (splitWorktreeIdForFilesystem) and accepts it only when git worktree list on the relay lists its realpath (readRelayWorktreeList with the worktree-list-z capability fallback), failing closed on any error; the client path no longer wins.
  • Replaced home containment with degenerate-path rejection — rejectDegenerateWorkspace rejects a workspace equal to $HOME, a filesystem root, or an ancestor of $HOME, and otherwise accepts inside- and outside-home worktrees, restoring local out-of-home installs.
  • Failed SSH folder installs closed — resolveFolderWorkspace returns null, so a folder id resolves to skill-install-workspace-not-found instead of the caller path.
  • Coverage — relay cases for listed worktrees under and outside home, home-itself, an unlisted ~/.ssh-style subdir, a client path ignored in favour of the host path, and folder rejection; destination cases for home, root, ancestor, and trusted local out-of-home.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Comment thread src/relay/skill-install-handler.ts Resolved
Resolve SSH skill-install destinations from the host git-worktree catalog
and contain workspace directories under the relay home so a caller path
cannot become remote filesystem authority.
Folder workspace ids are UUIDs, so the relay uses the host folder path
instead of git worktree list. Home containment stays on the SSH relay
and is not applied to trusted local worktree and folder destinations.
Home containment rejected legitimate checkouts at /opt or /srv and still
left ~/.ssh reachable for folder installs. Keep git-worktree membership
as the host proof, reject home/root/ancestor destinations, and fail
closed on SSH folders until the relay has a folder registry.
Relay folder installs fail closed, so listing them only produces
skill-install-workspace-not-found after dispatch.
@fettpl
fettpl force-pushed the fix/ssh-skill-install-host-paths branch from 0124f3a to 1af71b9 Compare September 21, 2026 09:03

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Important

The renderer descope is correct, but it leaves the SSH-docker e2e spec still asserting that folder installs succeed — that lane will now fail.

Reviewed changes

  • Descoped SSH folder install targets — belongsToEnvironment returns !folder && hostId === machineId for ssh: environments, so skillInstallWorkspaceChoices no longer offers SSH folder workspaces while SSH worktrees remain; the folder case in the existing test flipped from one choice to zero, and a new case proves the worktree is kept alongside an omitted SSH folder.

The wire path is unchanged and the code previously reviewed at 0124f3a36 is untouched by this delta. The filter itself is correct: for ssh: environments folders are dropped, and the local and paired-runtime branches are unaffected.

⚠️ SSH-docker e2e still asserts folder installs succeed

tests/e2e/ssh-skill-installation.spec.ts creates a remote folder workspace and calls window.api.skills.installPackageVersion with { scope: 'workspace', folderWorkspaceId }, expecting status: 'installed' (lines 116-123), then asserts three package downloads at the end (lines 125-137). With resolveFolderWorkspace returning null, that call resolves to skill-install-workspace-not-found, so the spec fails at the folder step and the trailing assertions never run. The descope removed the capability from the picker but left the spec asserting it; the folder scenario should be dropped (or inverted to assert the fail-closed error) so the lane stays green and the spec keeps exercising the global and worktree installs.

Technical details
# SSH folder installs removed but e2e still expects success

## Affected sites
- `tests/e2e/ssh-skill-installation.spec.ts:116-123` — creates a remote folder workspace and expects the install to succeed.
- `tests/e2e/ssh-skill-installation.spec.ts:125-137` — download-count assertions expect three installs (global, worktree, folder).
- `src/relay/skill-install-handler.ts:223` — `resolveFolderWorkspace: async () => null` makes the folder dispatch fail closed.
- `config/scripts/run-ssh-docker-e2e.mjs:80` — the spec is claimed by the Docker SSH lane, which sets `ORCA_E2E_SSH_DOCKER=1`.

## Required outcome
- The spec reflects that SSH folder installs are unsupported: drop the folder install/remove scenario (and adjust the three-download assertions to two), or assert the `skill-install-workspace-not-found` failure explicitly.

## Open questions for the human
- Is the folder scenario removal acceptable, or does this signal the capability should be restored with a host-side folder registry?

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

The relay has no folder registry, and the picker no longer lists SSH
folders. Keep the docker lane green by expecting admission failure
instead of a successful third copy.
@fettpl

fettpl commented Sep 21, 2026

Copy link
Copy Markdown
Contributor Author

Addressed in 306e4cb — the SSH-docker spec now expects the folder install to fail closed with skill-install-workspace-not-found and leaves no .agents tree on the remote folder. Global and worktree copies still install and remove. The refused folder still mints a remote grant, so the download count stays at three.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Important

The inverted SSH folder scenario still asserts three package downloads, but the refused folder never downloads — see the inline comment.

Reviewed changes

  • Inverted the SSH folder e2e scenario — tests/e2e/ssh-skill-installation.spec.ts now calls installPackageVersion directly for a remote folder workspace and asserts the skill-install-workspace-not-found admission failure plus the absence of .agents, replacing the previous folder install/remove success path.

The relay, shared destination, and renderer code are unchanged by this delta (reviewed at 0124f3a36); the worktree host-listing proof and the folder fail-closed behavior remain sound.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

…tall

Admission fails before ingress, so the folder attempt never GETs
/package.tar.gz. The three POSTs stay: the grant is minted in IPC first.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes

  • Corrected the SSH folder e2e download count — the /package.tar.gz GET assertion in tests/e2e/ssh-skill-installation.spec.ts now expects 2 (global + worktree) instead of 3, since the refused folder install fails admission before ingress and never fetches the archive; the comment was reworded to scope to the 3-POST grant assertion, which remains correct.

All prior Pullfrog threads are resolved. The delta is a one-line test-count correction that matches the established control flow (resolveSkillInstallDestination at skill-install-request-service.ts:95 runs before resolveIngress at :101), and the relay, destination, and renderer code are unchanged since the previously reviewed 0124f3a36.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@OrcaWin

OrcaWin commented Oct 11, 2026

Copy link
Copy Markdown
Collaborator

Closing as obsolete after the merged SSH relay retirement in #27214 and #27215. This PR concerns the SSH relay server and its relay-only handlers. #27214 removed the desktop relay connection/fallback path; #27215 deleted the SSH relay server, deployment assets, and relay-only tests. SSH hosts now use the managed Orca server (orcad), and unsupported hosts fail explicitly, so this relay-specific work is no longer planned. This closes the proposed relay implementation; it does not claim that any independent local, WSL, or managed-server behavior was fixed.

@OrcaWin OrcaWin closed this Oct 11, 2026
@OrcaWin

OrcaWin commented Oct 11, 2026

Copy link
Copy Markdown
Collaborator

Thanks @fettpl — on current main the host resolves a workspace skill install from its own worktree/folder record by id (skill-install-destinations.ts), never a client-supplied path, and SSH installs run on the managed server (orcad). Closing as fixed.

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.

Resolve SSH skill-install destinations on the host, not from client path

3 participants