From 4e9bc9b6c6eab43d2a16c3fe316d4b7b95af4493 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 10 Jun 2026 18:40:14 -0700 Subject: [PATCH] Gate project host setup runtime capability Co-authored-by: Orca --- ...oject-first-host-model-change-inventory.md | 10 +- .../project-first-host-model-conversation.md | 548 ++++++++++++++++++ notes/project-first-host-model-discussion.md | 10 +- notes/project-first-host-model-handoff.md | 9 +- notes/project-first-host-model.md | 22 +- src/main/runtime/orca-runtime.test.ts | 1 + .../runtime-compatibility-test-fixture.ts | 4 +- .../src/runtime/runtime-rpc-client.test.ts | 47 ++ .../src/runtime/runtime-rpc-client.ts | 30 + .../repos-project-host-capability.test.ts | 119 ++++ src/renderer/src/store/slices/repos.ts | 26 +- src/renderer/src/web/web-preload-api.test.ts | 12 + src/renderer/src/web/web-preload-api.ts | 5 + src/shared/protocol-version.ts | 5 +- 14 files changed, 829 insertions(+), 19 deletions(-) create mode 100644 notes/project-first-host-model-conversation.md create mode 100644 src/renderer/src/store/slices/repos-project-host-capability.test.ts diff --git a/notes/project-first-host-model-change-inventory.md b/notes/project-first-host-model-change-inventory.md index c8edaabbeec..fb46f839dde 100644 --- a/notes/project-first-host-model-change-inventory.md +++ b/notes/project-first-host-model-change-inventory.md @@ -406,8 +406,10 @@ Needs: - structured errors for unsupported old-server actions - old client / new server behavior that degrades safely -Current branch status: runtime compatibility exists; project/setup capability -gating needs to be layered on. +Current branch status: runtime compatibility exists. Runtimes now advertise +`project-host-setup.v1`; renderer reads fall back to repo-derived projection +when the capability is missing, and remote setup/clone mutations are blocked +before contacting older servers. Broader UI/CLI version-skew validation remains. ### 10. Caches And Local State @@ -489,6 +491,8 @@ Already partially landed: - inline `Run on` clone setup for not-yet-set-up local, runtime, and SSH hosts - repo-backed setup method metadata so imported/cloned setup methods survive compatibility projection and persistence sync +- project-host setup runtime capability advertisement, read fallback, and + remote mutation gating for older runtime servers Not complete yet: @@ -499,7 +503,7 @@ Not complete yet: - project settings split into global and host-specific ownership - host settings/capability UI aligned with project setup - complete cache/request ownership audit -- complete version-skew capability gating +- broader UI/CLI version-skew validation beyond the runtime capability guard - full Electron and SSH end-to-end validation ## Recommended Implementation Shape diff --git a/notes/project-first-host-model-conversation.md b/notes/project-first-host-model-conversation.md new file mode 100644 index 00000000000..647f17bbe47 --- /dev/null +++ b/notes/project-first-host-model-conversation.md @@ -0,0 +1,548 @@ +# Project-First Host Model Conversation + +## Purpose + +This document captures the VM / SSH / remote-server discussion so far and turns +it into a concrete change inventory. + +The product question was: + +```text +Should Orca organize work by machine first, or by project first? +``` + +The current answer is: + +```text +Project -> ProjectHostSetup -> Workspace +``` + +Hosts still matter. They are where code runs. But they should usually be +represented as places where a project is available, not as isolated silos that +own the user's projects. + +## TL;DR + +The durable model should be project-first. + +```text +Project + Host setup + Workspace +``` + +There are **12 major change surfaces** needed to fully fit this model. + +If we count smaller implementation tasks inside those surfaces, it is closer to +**35-45 concrete changes**, but the clean planning answer is 12 major areas. + +Some of the work is already partially implemented on this branch. The model is +not complete until data model, persistence, creation, setup, settings, sidebar, +runtime routing, cache ownership, CLI/API, version skew, SSH behavior, and +Electron validation all agree on the same project-first language. + +## Current Model + +The current multi-host direction makes hosts highly visible: + +```text +Local Mac + Orca + feature-a + +openclaw 2 + Orca + fix-ssh-agent-status +``` + +That is useful for operational awareness. It helps answer: + +- which hosts exist +- which hosts are online or disconnected +- where workspaces are running +- which SSH/remote machine might be causing a problem + +The weakness is that it makes the machine feel like the outermost product +concept. Users can end up feeling like they are switching into separate machine +silos, even when they are really working on the same project. + +## Desired Model + +Projects/repos remain the outermost durable concept: + +```text +Orca + Local Mac + feature-a + openclaw 2 + fix-ssh-agent-status +``` + +In plain English: + +```text +I am working on Project A. +Where should this workspace run? +``` + +not: + +```text +I am inside Machine B. +Which copy of Project A is here? +``` + +## Core Concepts + +### Project + +A `Project` is the durable repo/project identity the user recognizes. + +Examples: + +- Orca +- a Linux-only CUDA repo +- a work repo available only on a company host +- a personal repo available both locally and on an SSH server + +### Host + +A `Host` is a place where code can run. + +Examples: + +- local Mac +- SSH target +- remote server +- user-managed VM +- remote Orca runtime +- future Orca-provisioned cloud VM + +For now, "VM" is a loose product term. A VM can be modeled as a host with extra +capabilities, provisioning metadata, and eventually billing metadata. + +### ProjectHostSetup + +A `ProjectHostSetup` means: + +```text +this project is available on this host at this path with this setup state +``` + +Host-local facts belong here: + +- checkout path +- worktree base path +- setup status +- clone/import/provision method +- setup scripts +- host-specific project settings +- platform or capability constraints + +### Workspace + +A `Workspace` is a branch/task/worktree running from one project setup on one +host. + +It should eventually know: + +- `projectId` +- `hostId` +- `projectHostSetupId` +- host-local worktree path + +## Important Product Decisions + +### Host-First Is Still Useful + +Host-first grouping should not disappear completely. It is a useful operational +view/filter for troubleshooting, host health, and remote machine management. + +The important distinction is that host-first should be a view mode or filter, +not the only durable organization model. + +### Focused Host Mode Is A Filter + +Focused host mode is not a separate durable model. + +Selecting "Local Mac" or another host as a filter can satisfy the same use case. +If there is only one host and no meaningful host choice, the host UI should be +reduced or hidden. + +### Configured SSH Target Is Not Project Availability + +A configured SSH target should not automatically appear under every project. + +Recommended sidebar behavior: + +- show a disconnected host when it has relevant project setup history or + workspaces +- do not show a never-used SSH target in a project sidebar just because it is + configured +- keep SSH connection management in host/settings surfaces +- use clear disconnected labels/actions instead of relying only on a gray dot + +### A Remote-Only Project Is Normal + +Some projects only exist on one machine. That is fine. + +Examples: + +- a Linux-only project +- a GPU-heavy project +- a work project only available on a work machine +- a repo checked out only on an SSH server + +The UI should not imply that every project can or should run everywhere. + +## Workspace Creation Direction + +Creating a workspace should eventually ask: + +1. Which project? +2. Which host should run it? +3. What branch/task/workspace name? + +If the project is not available on the selected host, Orca should offer: + +- clone project to that host +- import an existing folder on that host +- select a different host + +The user should not need to understand old `repoId` compatibility records. + +## Project Setup Direction + +"Add project" and "make this project available on another host" are related but +different actions. + +Important flows: + +- import a local folder as a new project +- import an SSH folder as a new project +- set up an existing project on another host +- clone an existing project onto a selected host +- when adding a new host, optionally initialize one or more projects there +- later, provision an Orca cloud VM and materialize selected projects there + +## Settings Direction + +Settings need explicit ownership. + +| Setting type | Owner | Examples | +| --- | --- | --- | +| Client setting | desktop client | theme, local UI preferences | +| Host setting | machine/runtime | SSH connection, display name, health, server version | +| Project setting | durable project | project name, provider identity | +| Project-host setup setting | project on one host | checkout path, worktree base path, setup script | + +A host dropdown or host table inside project settings is probably sufficient for +host-specific project settings, similar to existing Windows/WSL-specific +settings patterns. + +## Version Skew Direction + +Remote servers may not match the desktop client version. + +Needed behavior: + +- new client + old server should degrade explicitly +- project/setup reads can fall back to compatibility projections where possible +- setup/create actions should be blocked before mutation if the server lacks + the required capability +- old client + new server should keep working through compatibility APIs where + possible + +The user-facing reasons should be concrete: + +- host is offline +- server version does not support project-host setup +- project is not set up on this host +- selected host does not support required platform/capability +- required runtime/agent is unavailable + +## Reference Comparison + +### Superset + +Superset is the closer reference for Orca's desired data model. + +Its conceptual model is effectively: + +```text +Project + Host -> Workspace +``` + +Specific lessons for Orca: + +- project identity is durable +- a host is where the project can be materialized +- workspace creation targets both project and host +- a project can be set up on multiple hosts +- if a project is not available on a host, the UI can block and offer setup +- project settings can contain host-specific path/worktree settings + +This maps well to Orca because Orca already has durable repos/projects, +worktrees, agents, terminals, source control, and host-aware runtimes. + +### Cmux + +Cmux is more session/workspace-first. + +Its conceptual model is closer to: + +```text +Workspace/session -> local or remote execution context +``` + +Specific lessons for Orca: + +- SSH should feel first class +- remote terminals, file views, browser panes, and localhost routing should + follow the remote execution context +- reconnect and persistence behavior matter +- remote execution should not feel bolted on + +Cmux is useful for SSH/session polish. It is less useful as the core durable +data model reference because it does not appear to center "this project is +available on these hosts" as the main object. + +## What Needs To Change + +There are **12 major change surfaces**. + +### 1. Shared Data Model + +Current `Repo` mixes durable project identity with host-local checkout details. +The new model needs first-class project/setup concepts. + +Needed: + +- `Project` +- `Host` +- `ProjectHostSetup` +- explicit workspace ownership by `projectId`, `hostId`, and + `projectHostSetupId` +- compatibility projection from old repo-shaped records + +### 2. Persistence And Migration + +Existing users need a boring migration. + +Needed: + +- derive one project per reliable durable identity +- derive one setup per existing repo checkout +- avoid merging same-name folders unless provider/setup identity is reliable +- preserve old ids or aliases where compatibility requires it +- backfill existing workspaces with project/setup ownership when safe + +### 3. Runtime And Request Ownership + +The UI can be project-first, but execution still happens on a host. + +Needed: + +- route terminals, agents, filesystem, browser, source control, hooks, and + automations through the workspace's owning host +- avoid using the currently focused host as a hidden global default for + workspace-owned operations +- scope cancellation and stale-response handling to the host/setup that owns the + request + +### 4. Workspace Creation + +Creation must target a project and host, not only a repo id. + +Needed: + +- project picker +- run-on host picker +- unavailable-host reasons +- inline clone/import setup actions +- compatibility resolver from `{ projectId, hostId }` to current repo/setup + internals while old APIs remain + +### 5. Project Setup Flow + +"Add repo" becomes a family of project/setup flows. + +Needed: + +- import existing folder on local host +- import existing folder over SSH +- clone project onto selected host +- set up an existing project on another host +- bulk setup when adding a new host +- future cloud provisioning hook + +### 6. Sidebar Row Model + +The sidebar should be built from projects, hosts, setups, and workspaces rather +than repo-only grouping. + +Needed: + +- project-first grouping +- host labels/subgroups only when useful +- host filters and online/offline status retained +- clear disconnected-host behavior +- drag/reorder rules for projects, host sections, and workspaces + +### 7. Project Settings + +Project settings need a global area plus host-specific setup sections. + +Needed: + +- project-global settings +- host-specific paths, worktree paths, setup scripts, and platform constraints +- host dropdown/table inside settings +- provider-neutral source-control settings + +### 8. Host Settings + +Host settings should describe the machine/runtime, not duplicate every project +setting. + +Needed: + +- connection details +- display name +- health/status +- server version and protocol compatibility +- platform/capabilities +- host-wide defaults and overrides + +### 9. Version And Capability Compatibility + +New clients and old servers will coexist. + +Needed: + +- host capability probing +- fallback projection when project/setup APIs are missing +- disabled states with specific reasons +- structured errors for unsupported old-server actions +- old client / new server behavior that degrades safely + +### 10. Caches And Local State + +Some caches are project-global; many are host/setup-local. + +Needed: + +- classify caches as project, host, setup, or workspace scoped +- include host/setup ids in cache keys for refs, git status, filesystem state, + capabilities, terminals, browser sessions, and remote results +- prevent a response from one host from overwriting another host's state for the + same project + +### 11. CLI And API + +External commands should speak project-first language. + +Needed: + +- `orca project list` +- `orca project setups` +- `orca project setup-existing-folder --project --host --path ` +- `orca worktree create --project --host ...` +- `orca worktree create --project-host-setup ...` +- compatibility aliases for old repo/worktree commands +- structured availability errors + +### 12. Tests And Verification + +This crosses storage, routing, UI, SSH, and compatibility. + +Needed: + +- migration tests +- selector/sidebar grouping tests +- create-workspace tests +- setup-on-host tests +- settings ownership tests +- SSH end-to-end validation +- version mismatch tests +- Electron validation for sidebar, creation, and settings + +## Current Branch Status + +Already partially landed: + +- shared `Project` and `ProjectHostSetup` types +- compatibility projection from existing `Repo[]` +- persisted compatibility fields for projects and project-host setups +- renderer hydration and fallback for older runtimes +- project-aware sidebar grouping in existing repo grouping paths +- default all-host Projects sidebar keeps projects outermost while preserving + host-section operational views for explicit host filters +- first-pass host context badges for mixed-host project groups +- project-host-aware workspace creation target resolver +- optional workspace metadata for `projectId`, `hostId`, and + `projectHostSetupId` +- discovery-time backfill for missing workspace ownership fields +- setup-existing-folder API plumbing through local IPC/preload/runtime paths +- project settings existing-folder setup form for known local, SSH, and active + runtime hosts +- CLI commands for listing projects/setups and creating worktrees by + project/setup +- composer `Run on` selector when a project has multiple ready setups +- setup-target `Run on` rows for known hosts where the selected project is not + set up yet +- inline `Run on` import-existing-folder setup +- inline `Run on` clone setup for not-yet-set-up local, runtime, and SSH hosts +- repo-backed setup method metadata so imported/cloned setup methods survive + compatibility projection and persistence sync +- project-host setup runtime capability advertisement, read fallback, and + remote mutation gating for older runtime servers + +Not complete yet: + +- independent project-host setup persistence beyond repo-backed compatibility + records +- bulk setup flows and setup for hosts that are not already known to the client +- SSH streamed clone progress parity +- full project settings split into global and host-specific ownership +- host settings/capability UI aligned with project setup +- complete cache/request ownership audit +- broader UI/CLI version-skew validation beyond the runtime capability guard +- full Electron and SSH end-to-end validation + +## Change Count + +Short answer: + +```text +12 major change surfaces +``` + +More practical engineering answer: + +```text +About 35-45 concrete implementation tasks +``` + +The reason for the difference is that each major surface has several necessary +subtasks. For example, "workspace creation" includes resolver changes, UI +changes, setup actions, unavailable-host reasons, runtime routing, persistence, +tests, and version-skew handling. + +The largest remaining areas are: + +1. independent `ProjectHostSetup` persistence +2. project-first creation/setup UI completion +3. project and host settings ownership split +4. cache/request ownership audit +5. broader version-skew validation +6. SSH parity and validation +7. full Electron validation of sidebar, creation, and settings + +## Final Position + +Keep hosts visible, but do not make them the durable outermost object. + +The long-term model should be project-first because it matches how users think +about repo/worktree/task work. Hosts, SSH machines, VMs, remote servers, and +future Orca cloud compute become first-class places where a project can run. diff --git a/notes/project-first-host-model-discussion.md b/notes/project-first-host-model-discussion.md index 5d25d00abbb..39d1f8e5c6c 100644 --- a/notes/project-first-host-model-discussion.md +++ b/notes/project-first-host-model-discussion.md @@ -445,8 +445,10 @@ Needs: - structured errors for unsupported old-server actions - old client / new server behavior that degrades safely -Current branch status: runtime compatibility exists; project/setup capability -gating needs to be layered on. +Current branch status: runtime compatibility exists. Runtimes now advertise +`project-host-setup.v1`; renderer reads fall back to repo-derived projection +when the capability is missing, and remote setup/clone mutations are blocked +before contacting older servers. Broader UI/CLI version-skew validation remains. ### 10. Caches And Local State @@ -528,6 +530,8 @@ Already partially landed: - inline `Run on` clone setup for not-yet-set-up local, runtime, and SSH hosts - repo-backed setup method metadata so imported and cloned setups survive compatibility projection and persistence sync +- project-host setup runtime capability advertisement, read fallback, and + remote mutation gating for older runtime servers Not complete yet: @@ -539,7 +543,7 @@ Not complete yet: - full project settings split into global and host-specific ownership - host settings/capability UI aligned with project setup - complete cache/request ownership audit -- complete version-skew capability gating +- broader UI/CLI version-skew validation beyond the runtime capability guard - full Electron and SSH end-to-end validation ## Recommended Implementation Shape diff --git a/notes/project-first-host-model-handoff.md b/notes/project-first-host-model-handoff.md index 3f8b3006b5b..a131c13a146 100644 --- a/notes/project-first-host-model-handoff.md +++ b/notes/project-first-host-model-handoff.md @@ -315,7 +315,10 @@ Needs: - structured errors for unsupported old-server actions - old client / new server behavior that degrades safely -Status: runtime compatibility exists; project/setup capability gating remains. +Status: runtime compatibility exists. Runtimes advertise +`project-host-setup.v1`; renderer reads fall back to repo-derived projection +when the capability is missing, and remote setup/clone mutations are blocked +before contacting older servers. Broader UI/CLI version-skew validation remains. ### 10. Caches And Local State @@ -410,6 +413,8 @@ Already partially landed: - inline clone setup from the composer for not-yet-set-up local, runtime, and SSH hosts - repo-backed setup method metadata for imported/cloned project-host setups +- project-host setup runtime capability advertisement, read fallback, and + remote mutation gating for older runtime servers Still needed: @@ -420,7 +425,7 @@ Still needed: - project settings split into global and host-specific ownership - host settings/capability UI aligned with project setup - complete cache/request ownership audit -- complete version-skew capability gating +- broader UI/CLI version-skew validation beyond the runtime capability guard - full Electron and SSH end-to-end validation ## Final Product Position diff --git a/notes/project-first-host-model.md b/notes/project-first-host-model.md index a8eb47e841b..476afe7347a 100644 --- a/notes/project-first-host-model.md +++ b/notes/project-first-host-model.md @@ -762,15 +762,20 @@ Landed so far: - Added repo-backed setup method metadata so imported and cloned setup flows survive persistence/projection sync as `imported-existing-folder` or `cloned` while `Repo` remains the compatibility source of truth. +- Added a runtime capability contract for project-host setup. New runtimes + advertise `project-host-setup.v1`; new clients fall back to repo-derived + project/setup projection for reads when that capability is missing and block + remote setup/clone mutations before contacting an older server. - Added tests for local repos, SSH repos, same-provider multi-host grouping, no-identity same-name non-grouping, selector cache behavior, persistence backfill, repo mutation synchronization, renderer hydration, runtime RPC - routing, setup method persistence, local/runtime/SSH clone setup composition, - remote clone IPC, and GitHub clone URL inference. Sidebar row-builder tests - now cover project-first multi-host grouping and same-name repo separation - without project identity. Workspace target tests cover local-only fallback, - focused-host setup selection, explicit project-plus-host resolution, same-name - non-merging, and unavailable setup reasons. + routing, project-host setup capability gating, setup method persistence, + local/runtime/SSH clone setup composition, remote clone IPC, and GitHub clone + URL inference. Sidebar row-builder tests now cover project-first multi-host + grouping and same-name repo separation without project identity. Workspace + target tests cover local-only fallback, focused-host setup selection, explicit + project-plus-host resolution, same-name non-merging, and unavailable setup + reasons. Important limitation: @@ -805,8 +810,9 @@ Remaining end-to-end work: handling for host/setup-local ownership - add project-first CLI/API commands with compatibility aliases for existing repo/worktree commands -- validate migration, creation, settings, sidebar, SSH, and version-skew flows - end to end +- broaden version-skew UI/CLI coverage beyond the current runtime capability + guard, and validate migration, creation, settings, sidebar, SSH, and + version-skew flows end to end ## Recommendation diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 6a08ce06039..0879e6df4f8 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -897,6 +897,7 @@ describe('OrcaRuntimeService', () => { expect(status.capabilities).toContain('terminal.binary-stream.v1') expect(status.capabilities).toContain('workspace-ports.v1') expect(status.capabilities).toContain('mobile.tasks.v1') + expect(status.capabilities).toContain('project-host-setup.v1') expect(typeof status.protocolVersion).toBe('number') expect(typeof status.minCompatibleMobileVersion).toBe('number') expect(status.protocolVersion).toBeGreaterThanOrEqual(1) diff --git a/src/renderer/src/runtime/runtime-compatibility-test-fixture.ts b/src/renderer/src/runtime/runtime-compatibility-test-fixture.ts index eb1bff0c680..ccb20944e58 100644 --- a/src/renderer/src/runtime/runtime-compatibility-test-fixture.ts +++ b/src/renderer/src/runtime/runtime-compatibility-test-fixture.ts @@ -2,6 +2,7 @@ import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import type { RuntimeStatus } from '../../../shared/runtime-types' import { MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + RUNTIME_CAPABILITIES, RUNTIME_PROTOCOL_VERSION } from '../../../shared/protocol-version' @@ -23,7 +24,8 @@ export function createCompatibleRuntimeStatusResponse( liveTabCount: 0, liveLeafCount: 0, runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, - minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: [...RUNTIME_CAPABILITIES] }, _meta: { runtimeId } } diff --git a/src/renderer/src/runtime/runtime-rpc-client.test.ts b/src/renderer/src/runtime/runtime-rpc-client.test.ts index 5e6869328e1..a7f9c225b89 100644 --- a/src/renderer/src/runtime/runtime-rpc-client.test.ts +++ b/src/renderer/src/runtime/runtime-rpc-client.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { callRuntimeRpc, + assertRuntimeEnvironmentCapability, clearRuntimeCompatibilityCacheForTests, getActiveRuntimeTarget, RuntimeRpcCallError, @@ -135,6 +136,52 @@ describe('runtime RPC client routing', () => { ]) }) + it('checks advertised runtime capabilities after protocol compatibility', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'status', + ok: true, + result: { + runtimeId: 'remote-runtime', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: ['project-host-setup.v1'] + }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await expect( + assertRuntimeEnvironmentCapability( + 'env-1', + 'project-host-setup.v1', + 'Project setup is unavailable.' + ) + ).resolves.toBeUndefined() + }) + + it('rejects missing advertised runtime capabilities with the caller message', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'status', + ok: true, + result: { + runtimeId: 'remote-runtime', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: [] + }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await expect( + assertRuntimeEnvironmentCapability( + 'env-1', + 'project-host-setup.v1', + 'Project setup is unavailable.' + ) + ).rejects.toThrow('Project setup is unavailable.') + }) + it('marks remote UI-owned runtime calls so feature interaction tracking can ignore them', async () => { runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { const result = diff --git a/src/renderer/src/runtime/runtime-rpc-client.ts b/src/renderer/src/runtime/runtime-rpc-client.ts index 79a15f8ac0d..74183cce258 100644 --- a/src/renderer/src/runtime/runtime-rpc-client.ts +++ b/src/renderer/src/runtime/runtime-rpc-client.ts @@ -1,6 +1,7 @@ import type { GlobalSettings } from '../../../shared/types' import type { RuntimeRpcFailure, RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import type { RuntimeStatus } from '../../../shared/runtime-types' +import type { RuntimeCapability } from '../../../shared/protocol-version' import { withBrowserPaneUiRuntimeRpcSource } from '../../../shared/runtime-rpc-feature-interaction-source' import { assertRuntimeStatusCompatible } from './runtime-protocol-compat' @@ -141,6 +142,35 @@ export function markRuntimeEnvironmentCompatible(environmentId: string): void { rememberRuntimeEnvironmentCompatibility(trimmed, Promise.resolve()) } +export async function getRuntimeEnvironmentStatus( + environmentId: string, + timeoutMs?: number +): Promise { + const response = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'status.get', + timeoutMs + }) + const status = unwrapRuntimeRpcResult( + response as RuntimeRpcResponse + ) + assertRuntimeStatusCompatible(status) + markRuntimeEnvironmentCompatible(environmentId) + return status +} + +export async function assertRuntimeEnvironmentCapability( + environmentId: string, + capability: RuntimeCapability, + message: string, + timeoutMs?: number +): Promise { + const status = await getRuntimeEnvironmentStatus(environmentId, timeoutMs) + if (!status.capabilities?.includes(capability)) { + throw new Error(message) + } +} + export function clearRuntimeCompatibilityCacheForTests(): void { clearRuntimeCompatibilityCache() } diff --git a/src/renderer/src/store/slices/repos-project-host-capability.test.ts b/src/renderer/src/store/slices/repos-project-host-capability.test.ts new file mode 100644 index 00000000000..ba0aaefc67b --- /dev/null +++ b/src/renderer/src/store/slices/repos-project-host-capability.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../shared/types' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' +import type { RuntimeEnvironmentCallRequest } from '../../runtime/runtime-compatibility-test-fixture' +import { createTestStore } from './store-test-helpers' + +const remoteRepo: Repo = { + id: 'remote-repo', + path: '/remote', + displayName: 'Remote', + badgeColor: '#111', + addedAt: 2 +} + +const reposList = vi.fn() +const reposClone = vi.fn() +const reposCloneRemote = vi.fn() +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() + +function runtimeStatusWithoutProjectHostSetup() { + return { + id: 'status', + ok: true, + result: { + runtimeId: 'runtime-remote', + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: [] + }, + _meta: { runtimeId: 'runtime-remote' } + } +} + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + reposList.mockReset() + reposClone.mockReset() + reposCloneRemote.mockReset() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'status.get') { + return runtimeStatusWithoutProjectHostSetup() + } + return runtimeEnvironmentCall(args) + }) + vi.stubGlobal('window', { + api: { + repos: { + list: reposList, + clone: reposClone, + cloneRemote: reposCloneRemote + }, + runtimeEnvironments: { call: runtimeEnvironmentTransportCall } + } + }) +}) + +describe('repo slice project-host setup runtime capability', () => { + it('falls back to repo-derived project setup state when a remote runtime lacks support', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { repos: [remoteRepo] }, + _meta: { runtimeId: 'runtime-remote' } + }) + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchRepos() + + expect(store.getState().projectHostSetups).toEqual([ + expect.objectContaining({ id: 'remote-repo', hostId: 'runtime:env-1' }) + ]) + expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(1) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'repo.list', + params: undefined, + timeoutMs: 15_000 + }) + }) + + it('blocks runtime project setup when the server does not advertise support', async () => { + const store = createTestStore() + + await expect( + store.getState().setupProjectExistingFolder({ + projectId: 'project-1', + hostId: 'runtime:env-1', + path: '/srv/project', + kind: 'git' + }) + ).resolves.toBeNull() + + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('blocks runtime project clone before mutating unsupported servers', async () => { + const store = createTestStore() + + await expect( + store.getState().setupProjectClone({ + projectId: 'project-1', + hostId: 'runtime:env-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/srv' + }) + ).resolves.toBeNull() + + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 1c4ba79d882..e642fd426d3 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -20,6 +20,7 @@ import { projectHostSetupProjectionFromRepos, type ProjectHostSetupProjection } from '../../../../shared/project-host-setup-projection' +import { PROJECT_HOST_SETUP_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { isGitRepoKind } from '../../../../shared/repo-kind' import { sanitizeRepoIcon } from '../../../../shared/repo-icon' import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color' @@ -27,7 +28,11 @@ import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups' import { getRepoIdFromWorktreeId } from './worktree-helpers' import { reconcileFetchedRepos } from './repo-identity-reconcile' import { splitRepoReorderByHost } from './repo-reorder-host-split' -import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import { + assertRuntimeEnvironmentCapability, + callRuntimeRpc, + getActiveRuntimeTarget +} from '../../runtime/runtime-rpc-client' import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector' import { buildDismissedOnboardingFolderAgentStartup } from '@/lib/onboarding-folder-agent-startup' import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist' @@ -184,6 +189,7 @@ async function fetchProjectHostSetupCompatibility( setups: await projectsApi.listHostSetups() } } + await assertProjectHostSetupRuntimeCapability(target) const [projectResponse, setupResponse] = await Promise.all([ callRuntimeRpc<{ projects: Project[] }>(target, 'project.list', undefined, { timeoutMs: 15_000 @@ -203,6 +209,20 @@ async function fetchProjectHostSetupCompatibility( } } +async function assertProjectHostSetupRuntimeCapability( + target: ReturnType +): Promise { + if (target.kind !== 'environment') { + return + } + await assertRuntimeEnvironmentCapability( + target.environmentId, + PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, + 'The selected Orca server does not support project host setup yet. Update Orca on the server and try again.', + 15_000 + ) +} + function projectCompatibilityFromRepos( repos: readonly Repo[] ): Pick { @@ -672,6 +692,7 @@ export const createRepoSlice: StateCreator = (set, setupProjectExistingFolder: async (args) => { try { const target = getProjectSetupRuntimeTarget(args.hostId) + await assertProjectHostSetupRuntimeCapability(target) const result = target.kind === 'local' ? await window.api.projects.setupExistingFolder(args) @@ -720,6 +741,9 @@ export const createRepoSlice: StateCreator = (set, try { const parsedHost = parseExecutionHostId(args.hostId) const target = getProjectSetupRuntimeTarget(args.hostId) + if (parsedHost?.kind !== 'ssh') { + await assertProjectHostSetupRuntimeCapability(target) + } const repo = parsedHost?.kind === 'ssh' ? await window.api.repos.cloneRemote({ diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index 5ebce3603e9..f357bdfb868 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -1056,6 +1056,18 @@ describe('web file preload API', () => { ).rejects.toThrow('Remote file download is unavailable in paired web clients.') }) + it('rejects SSH clone requests in paired web clients', async () => { + const { api } = await installApi('Linux') + + await expect( + api.repos.cloneRemote({ + connectionId: 'ssh-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/workspace' + }) + ).rejects.toThrow('SSH clone is unavailable in paired web clients.') + }) + it('returns false for runtime missing-path errors from fs.pathExists', async () => { const runtimeCalls: { method: string; params: unknown }[] = [] const worktree = { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 74be314455a..5e87164143e 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -997,6 +997,11 @@ function createReposApi(): NonNullable['repos']> { await callRuntimeResult<{ repo: Repo }>('repo.clone', { url, destination }, 10 * 60_000) ).repo }, + cloneRemote: async () => { + // Why: SSH relay cloning is owned by the desktop main process; paired web + // clients must not pretend they can run that local IPC path directly. + throw new Error('SSH clone is unavailable in paired web clients.') + }, cloneAbort: () => Promise.resolve(), addRemote: async ({ remotePath, displayName, kind }) => { invalidateRuntimeWorktreeCaches() diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 907e4259541..bb8e04a04ff 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -21,6 +21,8 @@ export const RUNTIME_PROTOCOL_VERSION = 3 export const MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION = 2 export const MIN_COMPATIBLE_RUNTIME_SERVER_VERSION = 3 +export const PROJECT_HOST_SETUP_RUNTIME_CAPABILITY = 'project-host-setup.v1' as const + export const RUNTIME_CAPABILITIES = [ 'runtime.status.compat.v1', 'runtime.environments.v1', @@ -28,7 +30,8 @@ export const RUNTIME_CAPABILITIES = [ 'terminal.binary-stream.v1', 'terminal.multiplex.v1', 'workspace-ports.v1', - 'mobile.tasks.v1' + 'mobile.tasks.v1', + PROJECT_HOST_SETUP_RUNTIME_CAPABILITY ] as const export type RuntimeCapability = (typeof RUNTIME_CAPABILITIES)[number] | (string & {})