fix(runtime): refuse SSH hosts in project setup instead of acting locally (#10799)

* fix(runtime): refuse SSH hosts in project setup instead of acting locally

projectHostSetup.clone and .setupExistingFolder threaded executionHostId all
the way down but never used it for routing: cloneRepo runs a local mkdir plus
a local gitSpawn, and addRepo probes the path with existsSync/statSync. An
`ssh:` host therefore cloned and validated on the *local* machine and then
registered the result as living on the SSH host.

It only failed loudly here because the remote path did not exist locally. With
a plausible destination the clone succeeds and writes a setup record pointing
at the wrong machine.

Nothing legitimate sends `ssh:` to these RPCs: the renderer maps every ssh
host (including ephemeral-VM `ssh:runtime-ssh-*`) to the desktop IPC path,
which dispatches to addRemoteRepoFromPath/cloneRemoteRepo, and the IPC handler
symmetrically rejects `runtime:`. Only the CLI can reach here with `ssh:`.

Fail closed until the RPC learns to route through the SSH providers.

* test(runtime): make the SSH guard test observe the corruption it names

The test asserted `gitSpawn` was never called and no repo was registered, but
neither assertion could fail. `/home/brennan` is unwritable on macOS, so the
pre-guard clone died at `mkdir` before reaching `gitSpawn`, and
`/home/brennan/orca` failed `isGitRepo` before reaching `addRepo` — the exact
side effects under test were unreachable either way. `rejects.toThrow` also
aborted the test before those lines ran.

Use a real temp destination and a real temp git repo, await both calls via
`.catch`, and assert the side effects before the wording. With the guard
disabled the test now fails on `gitSpawn` being called once with a real
`git clone`, and on a repo registered stamped `executionHostId: 'ssh:openclaw'`
— the silent local-clone-recorded-as-remote defect itself. `gitSpawn` is
stubbed so a regression records the call instead of hitting the network.

Also document the SSH restriction on `project setup-existing-folder`, which the
guard now rejects. `setup-clone` already carried that note; its sibling did not.
This commit is contained in:
Brennan Benson 2026-07-27 16:21:54 -07:00 committed by GitHub
parent 1fcbf8e5fe
commit 3baffb49ff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 81 additions and 1 deletions

View File

@ -27,7 +27,10 @@ export const PROJECT_COMMAND_SPECS: CommandSpec[] = [
usage:
'orca project setup-existing-folder --project <id> --host <host-id> --path <path> [--kind git|folder] [--display-name <name>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'project', 'host', 'path', 'kind', 'display-name'],
notes: ['For remote runtimes, --path must be an absolute path on the remote server.'],
notes: [
'For remote runtimes, --path must be an absolute path on the remote server.',
'SSH targets are set up through the desktop UI because the desktop client owns SSH connections.'
],
examples: [
'orca project setup-existing-folder --project github:stablyai/orca --host local --path ~/orca',
'orca project setup-existing-folder --project github:stablyai/orca --host runtime:gpu --path /home/me/orca --kind git --json'

View File

@ -7160,6 +7160,67 @@ describe('OrcaRuntimeService', () => {
}
})
it('refuses SSH hosts instead of setting the project up on the local machine', async () => {
// Why: both inputs must be paths the pre-guard code would have accepted. An unwritable
// destination fails at mkdir and a non-repo path fails at isGitRepo, which would leave the
// side-effect assertions below unable to observe the local clone/probe they exist to catch.
const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-ssh-guard-'))
const existingFolder = join(destination, 'orca')
mkdirSync(existingFolder, { recursive: true })
execFileSync('git', ['init'], { cwd: existingFolder, stdio: 'ignore' })
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn').mockImplementation(() => {
// Why: unreachable while the guard holds; stubbed so a regression records the call
// instead of shelling out to a real network clone.
const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
proc.stderr = new EventEmitter()
queueMicrotask(() => proc.emit('close', 1, null))
return proc as never
})
const repos: Record<string, unknown>[] = []
const runtimeStore = {
...store,
getRepos: () => [...repos] as never,
addRepo: (repo: Record<string, unknown>) => {
repos.push(repo)
}
}
const runtime = new OrcaRuntimeService(runtimeStore as never)
try {
const cloneError = await runtime
.setupProjectClone({
projectId: 'github:stablyai/orca',
hostId: 'ssh:openclaw',
url: 'https://example.com/orca.git',
destination
})
.catch((error: unknown) => error)
const existingFolderError = await runtime
.setupProjectExistingFolder({
projectId: 'github:stablyai/orca',
hostId: 'ssh:openclaw',
path: existingFolder,
kind: 'git'
})
.catch((error: unknown) => error)
// Why: the defect was a silent local clone/probe recorded as remote, not a bad message,
// so the absent side effects are asserted before the wording. Both calls are awaited
// first so a regression reports the corruption rather than stopping at the first throw.
expect(spawnSpy).not.toHaveBeenCalled()
expect(repos).toHaveLength(0)
expect(cloneError).toMatchObject({
message: expect.stringMatching(/SSH hosts are not supported/)
})
expect(existingFolderError).toMatchObject({
message: expect.stringMatching(/SSH hosts are not supported/)
})
} finally {
spawnSpy.mockRestore()
await rm(destination, { recursive: true, force: true })
}
})
it('adopts public clone repos into host-qualified project setup', async () => {
const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-project-clone-'))
const clonePath = join(destination, 'orca')

View File

@ -1823,6 +1823,19 @@ function runtimeRepoMatchesExecutionHost(
return repo.connectionId == null
}
// Why: this runtime only has local git and local fs, so an ssh: host here would clone and
// probe the wrong machine and then register the result as remote. SSH setup is owned by the
// desktop IPC path (addRemoteRepoFromPath / cloneRemoteRepo), which the renderer routes to;
// only `local` and `runtime:` legitimately reach these RPCs.
function assertProjectHostSetupHostIsSupported(hostId: ExecutionHostId | null | undefined): void {
if (parseExecutionHostId(hostId)?.kind !== 'ssh') {
return
}
throw new Error(
'SSH hosts are not supported by this operation. Set the project up from the Orca desktop app, which owns the SSH connection.'
)
}
function getRuntimeFolderWorkspaceInstanceId(repo: Repo, instanceId: string): string {
return `${getRuntimeFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}${instanceId}`
}
@ -15309,6 +15322,7 @@ export class OrcaRuntimeService {
if (!this.store) {
throw new Error('runtime_unavailable')
}
assertProjectHostSetupHostIsSupported(args.hostId)
let repo = await this.addRepo(args.path, args.kind === 'folder' ? 'folder' : 'git', args.hostId)
let setup = getProjectHostSetupForRepo(this.listProjectHostSetups(), repo)
if (setup.projectId !== args.projectId) {
@ -15351,6 +15365,8 @@ export class OrcaRuntimeService {
}
async setupProjectClone(args: ProjectHostSetupCloneArgs): Promise<ProjectHostSetupResult> {
// Why: guard before cloneRepo, which would otherwise clone to the local disk.
assertProjectHostSetupHostIsSupported(args.hostId)
const repo = await this.cloneRepo(args.url, args.destination, args.hostId)
return await this.setupProjectExistingFolder({
projectId: args.projectId,