Skip to content

fix(cli): route repo add through SSH hostId, not desktop paths - #9920

Open
connfy wants to merge 1 commit into
stablyai:mainfrom
connfy:fix/orch-r12-remote-host-repo-add
Open

fix(cli): route repo add through SSH hostId, not desktop paths#9920
connfy wants to merge 1 commit into
stablyai:mainfrom
connfy:fix/orch-r12-remote-host-repo-add

Conversation

@connfy

@connfy connfy commented Jul 22, 2026

Copy link
Copy Markdown

Summary

  • orca repo add now accepts --host ssh:<connectionId> (or a bare connectionId from repo list) and validates/imports the path on that SSH host instead of the Windows desktop filesystem.
  • Preserves remote POSIX absolute paths (no more C:\home\... mangling) and clarifies that --environment is for paired Orca servers, not SSH connectionIds.
  • Extracts shared addRemoteRepoFromPath for runtime RPC + IPC, with deterministic CLI/runtime regression coverage for the Linux-relay / Windows-desktop ORCH-R12 repro.

Test plan

  • npx vitest run src/cli/repo-add-host.test.ts src/cli/repo-path-arguments.test.ts src/cli/index.test.ts src/main/runtime/orca-runtime.test.ts src/main/ipc/repos-remote.test.ts (1087 passed)
  • Manual: from a Linux relay with an SSH host connected, run orca repo add --path /absolute/remote/repo --host <connectionId> --json and confirm the repo registers with that connectionId
  • Manual: on Windows desktop without --host, confirm a POSIX /home/... path errors with the --host ssh:<connectionId> hint instead of probing C:\home\...
  • Manual: confirm --environment <connectionId> still fails as unknown environment (expected), while --host <connectionId> succeeds

Made with Cursor

ELI5

orca repo add with an SSH host used to resolve paths on your desktop filesystem and mangle Linux paths. It now validates and imports on the SSH host itself, keeping remote absolute paths intact.

Windows desktop probes were turning remote POSIX paths into C:\home\... and
--environment was confused with SSH connectionIds. Accept --host ssh:<id>
(and bare connectionIds), validate on the SSH host, and keep absolute remote
paths intact for Linux-relay/Windows-desktop imports.

Co-authored-by: Cursor <cursoragent@cursor.com>
@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The orca repo add command now accepts an optional --host identifier, normalizes canonical and bare SSH values, and validates remote paths without resolving them against the desktop. The RPC schema forwards hostId to the runtime, which routes SSH imports through a shared remote repository module. Remote repository creation now handles home resolution, deduplication, git detection, persistence, and multiplexer registration in that module. Tests cover CLI validation, path handling, runtime routing, and disconnected SSH hosts.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description includes Summary and Testing, but it omits the required Screenshots, AI Review Report, Security Audit, and Notes sections. Add the missing template sections with screenshot status, checklist-based testing, AI review summary, security audit, and platform-specific notes.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. 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 summarizes the main change: routing repo add through SSH hostId instead of desktop paths.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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.

@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: 7


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 6ee54eb7-6ec5-4559-b389-c4525d9385de

📥 Commits

Reviewing files that changed from the base of the PR and between 39b124c and e2b465a.

📒 Files selected for processing (13)
  • src/cli/handlers/repo.ts
  • src/cli/help.ts
  • src/cli/index.test.ts
  • src/cli/repo-add-host.test.ts
  • src/cli/repo-add-host.ts
  • src/cli/repo-path-arguments.test.ts
  • src/cli/repo-path-arguments.ts
  • src/cli/specs/core.ts
  • src/main/ipc/repos.ts
  • src/main/repos/add-remote-repo-from-path.ts
  • src/main/runtime/orca-runtime.test.ts
  • src/main/runtime/orca-runtime.ts
  • src/main/runtime/rpc/methods/repo.ts

Comment thread src/cli/index.test.ts
Comment on lines +2751 to +2766
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode

await main(
['repo', 'add', '--path', '/home/me/orca', '--host', 'env:not-a-host', '--json'],
'/tmp/repo'
)

expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'--environment selects a paired Orca server'
)
expect(process.exitCode).toBe(1)

process.exitCode = priorExitCode

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Restore process.exitCode in a finally block.

A failed assertion skips Line 2766 and leaks exit code 1 into later tests.

Proposed fix
     const priorExitCode = process.exitCode
-
-    await main(
-      ['repo', 'add', '--path', '/home/me/orca', '--host', 'env:not-a-host', '--json'],
-      '/tmp/repo'
-    )
-
-    expect(callMock).not.toHaveBeenCalled()
-    expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
-      '--environment selects a paired Orca server'
-    )
-    expect(process.exitCode).toBe(1)
-
-    process.exitCode = priorExitCode
+    try {
+      await main(
+        ['repo', 'add', '--path', '/home/me/orca', '--host', 'env:not-a-host', '--json'],
+        '/tmp/repo'
+      )
+
+      expect(callMock).not.toHaveBeenCalled()
+      expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
+        '--environment selects a paired Orca server'
+      )
+      expect(process.exitCode).toBe(1)
+    } finally {
+      process.exitCode = priorExitCode
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
['repo', 'add', '--path', '/home/me/orca', '--host', 'env:not-a-host', '--json'],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'--environment selects a paired Orca server'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
try {
await main(
['repo', 'add', '--path', '/home/me/orca', '--host', 'env:not-a-host', '--json'],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'--environment selects a paired Orca server'
)
expect(process.exitCode).toBe(1)
} finally {
process.exitCode = priorExitCode
}

Comment on lines +18 to +71
export async function addRemoteRepoFromPath(
store: Store,
args: AddRemoteRepoFromPathArgs
): Promise<{ repo: Repo; alreadyExisted: boolean } | { error: string }> {
const gitProvider = getSshGitProvider(args.connectionId)
if (!gitProvider) {
return { error: `SSH connection "${args.connectionId}" not found or not connected` }
}

let repoKind: 'git' | 'folder' = args.kind ?? 'git'
let resolvedPath = await resolveRemoteHomePath(args.connectionId, args.remotePath)

const existing = store
.getRepos()
.find(
(repo) =>
repo.connectionId === args.connectionId &&
normalizeRuntimePathForComparison(repo.path) ===
normalizeRuntimePathForComparison(resolvedPath)
)
if (existing) {
return { repo: existing, alreadyExisted: true }
}

if (args.kind !== 'folder') {
try {
const check = await gitProvider.isGitRepoAsync(resolvedPath)
if (check.isRepo) {
repoKind = 'git'
if (check.rootPath) {
resolvedPath = check.rootPath
}
} else {
return { error: `Not a valid git repository: ${args.remotePath}` }
}
} catch (err) {
if (err instanceof Error && err.message.includes('Not a valid git repository')) {
return { error: err.message }
}
return { error: `Not a valid git repository: ${args.remotePath}` }
}
}

const existingAfterRootResolve = store
.getRepos()
.find(
(repo) =>
repo.connectionId === args.connectionId &&
normalizeRuntimePathForComparison(repo.path) ===
normalizeRuntimePathForComparison(resolvedPath)
)
if (existingAfterRootResolve) {
return { repo: existingAfterRootResolve, alreadyExisted: true }
}

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.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Dedup check is a TOCTOU race for concurrent adds of the same path.

Two await points (resolveRemoteHomePath, isGitRepoAsync, and later detectRepoIconAndUpstream) sit between each dedup lookup and store.addRepo. Two concurrent calls (e.g. CLI invoked twice, or CLI + UI) targeting the same connectionId/path can both pass both dedup checks before either calls addRepo, producing duplicate Repo entries. cloneRemoteRepo in src/main/ipc/repos.ts guards this exact scenario with remoteCloneInFlightByPath; this shared helper (used directly for CLI repo add, no directory-creation atomicity to fall back on) has no equivalent guard.

🔒 Suggested fix: in-flight guard keyed by connectionId+resolved path
+const inFlightByKey = new Set<string>()
+
 export async function addRemoteRepoFromPath(
   store: Store,
   args: AddRemoteRepoFromPathArgs
 ): Promise<{ repo: Repo; alreadyExisted: boolean } | { error: string }> {
   const gitProvider = getSshGitProvider(args.connectionId)
   if (!gitProvider) {
     return { error: `SSH connection "${args.connectionId}" not found or not connected` }
   }
+
+  const lockKey = `${args.connectionId}:${normalizeRuntimePathForComparison(args.remotePath)}`
+  if (inFlightByKey.has(lockKey)) {
+    return { error: 'An add operation is already in progress for this path' }
+  }
+  inFlightByKey.add(lockKey)
+  try {
     ... existing body ...
+  } finally {
+    inFlightByKey.delete(lockKey)
+  }
 }

Comment on lines +42 to +58
if (args.kind !== 'folder') {
try {
const check = await gitProvider.isGitRepoAsync(resolvedPath)
if (check.isRepo) {
repoKind = 'git'
if (check.rootPath) {
resolvedPath = check.rootPath
}
} else {
return { error: `Not a valid git repository: ${args.remotePath}` }
}
} catch (err) {
if (err instanceof Error && err.message.includes('Not a valid git repository')) {
return { error: err.message }
}
return { error: `Not a valid git repository: ${args.remotePath}` }
}

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Catch-all swallows the real error behind a generic "not a valid git repository" message.

Any thrown error from isGitRepoAsync (network failure, permission denied, SSH disconnect) that doesn't literally contain the string 'Not a valid git repository' is replaced with a generic message, hiding the actual cause from the user/CLI output.

🐛 Preserve the original error detail
     } catch (err) {
       if (err instanceof Error && err.message.includes('Not a valid git repository')) {
         return { error: err.message }
       }
-      return { error: `Not a valid git repository: ${args.remotePath}` }
+      const detail = err instanceof Error ? err.message : String(err)
+      return { error: `Not a valid git repository: ${args.remotePath} (${detail})` }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (args.kind !== 'folder') {
try {
const check = await gitProvider.isGitRepoAsync(resolvedPath)
if (check.isRepo) {
repoKind = 'git'
if (check.rootPath) {
resolvedPath = check.rootPath
}
} else {
return { error: `Not a valid git repository: ${args.remotePath}` }
}
} catch (err) {
if (err instanceof Error && err.message.includes('Not a valid git repository')) {
return { error: err.message }
}
return { error: `Not a valid git repository: ${args.remotePath}` }
}
if (args.kind !== 'folder') {
try {
const check = await gitProvider.isGitRepoAsync(resolvedPath)
if (check.isRepo) {
repoKind = 'git'
if (check.rootPath) {
resolvedPath = check.rootPath
}
} else {
return { error: `Not a valid git repository: ${args.remotePath}` }
}
} catch (err) {
if (err instanceof Error && err.message.includes('Not a valid git repository')) {
return { error: err.message }
}
const detail = err instanceof Error ? err.message : String(err)
return { error: `Not a valid git repository: ${args.remotePath} (${detail})` }
}

Comment on lines +118 to +129
const mux = getActiveMultiplexer(connectionId)
if (!mux) {
return path
}
try {
const result = (await mux.request('session.resolveHome', { path })) as { resolvedPath: string }
return result.resolvedPath
} catch {
// Why: older relays may not support this; return the original path so callers surface their own validation error.
return path
}
}

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.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Unchecked cast on the SSH relay response.

mux.request('session.resolveHome', ...) result is force-cast to { resolvedPath: string } without validating the shape. A relay returning an unexpected/malformed payload (without throwing) would silently propagate a non-string value into resolvedPath, breaking downstream normalizeRuntimePathForComparison/getRemoteRepoFolderName calls.

🛡️ Validate the response shape
-    const result = (await mux.request('session.resolveHome', { path })) as { resolvedPath: string }
-    return result.resolvedPath
+    const result = await mux.request('session.resolveHome', { path })
+    const resolvedPath = (result as { resolvedPath?: unknown } | undefined)?.resolvedPath
+    return typeof resolvedPath === 'string' && resolvedPath ? resolvedPath : path
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const mux = getActiveMultiplexer(connectionId)
if (!mux) {
return path
}
try {
const result = (await mux.request('session.resolveHome', { path })) as { resolvedPath: string }
return result.resolvedPath
} catch {
// Why: older relays may not support this; return the original path so callers surface their own validation error.
return path
}
}
const mux = getActiveMultiplexer(connectionId)
if (!mux) {
return path
}
try {
const result = await mux.request('session.resolveHome', { path })
const resolvedPath = (result as { resolvedPath?: unknown } | undefined)?.resolvedPath
return typeof resolvedPath === 'string' && resolvedPath ? resolvedPath : path
} catch {
// Why: older relays may not support this; return the original path so callers surface their own validation error.
return path
}
}

Comment on lines +6615 to +6624
// Local and legacy runtime repos both have null executionHostId/connectionId, so only a runtime host may backfill; local imports leave it untouched.
const repos: Record<string, unknown>[] = [
{
id: 'repo-local-1',
path: '/workspace',
displayName: 'workspace',
badgeColor: 'blue',
addedAt: 1,
kind: 'folder'
}

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert the actual legacy repo shape and returned value.

The comment says legacy repos contain executionHostId/connectionId: null, but the fixture omits both fields. Also, only the stored object is checked; a stamped returned copy would pass. Seed the null fields and assert that repo preserves them as well.

Also applies to: 6644-6649

Comment on lines +6652 to +6676
it('routes SSH hostId repo.add through the remote connection path without desktop path probing', async () => {
const isGitRepoAsync = vi.fn().mockResolvedValue({
isRepo: true,
rootPath: '/home/minhun/dev/orca-r11-reconnect'
})
sshGitProviders.set('ssh-1784350275544-cv3c0t', { isGitRepoAsync })
const repos: Record<string, unknown>[] = []
const runtimeStore = {
...store,
getRepos: () => [...repos] as never,
addRepo: (repo: Record<string, unknown>) => {
repos.push(repo)
},
getRepo: (id: string) => repos.find((repo) => repo.id === id) as never,
getSshTarget: vi.fn().mockReturnValue({ label: 'relay-host' })
}
const runtime = new OrcaRuntimeService(runtimeStore as never)

const repo = await runtime.addRepo(
'/home/minhun/dev/orca-r11-reconnect',
'git',
'ssh:ssh-1784350275544-cv3c0t'
)

expect(isGitRepoAsync).toHaveBeenCalledWith('/home/minhun/dev/orca-r11-reconnect')

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Verify that SSH imports skip desktop probing.

The remote-provider assertion proves the SSH path is used, but it does not prove that the desktop filesystem probe was not performed first. Spy on the local repository/path detector and assert it is not called for this POSIX SSH path.

Comment on lines +14425 to +14439
const parsedHost = parseExecutionHostId(executionHostId ?? null)
if (parsedHost?.kind === 'ssh') {
const result = await addRemoteRepoFromPath(this.store, {
connectionId: parsedHost.targetId,
remotePath: path,
kind
})
if ('error' in result) {
throw new Error(result.error)
}
this.invalidateResolvedWorktreeCache()
this.invalidateWorktreeScanCacheForRepo(result.repo.id)
this.notifyReposChanged()
return this.store.getRepo(result.repo.id) ?? result.repo
}

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.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect the shared remote repo helper's path validation
fd add-remote-repo-from-path
ast-grep outline src/main/repos/add-remote-repo-from-path.ts --items all 2>/dev/null

Repository: stablyai/orca

Length of output: 1015


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,220p' src/main/repos/add-remote-repo-from-path.ts | cat -n

Repository: stablyai/orca

Length of output: 5433


Validate SSH paths before importing
addRemoteRepoFromPath still accepts relative remotePath values: resolveRemoteHomePath returns non-~/ inputs unchanged, and the helper proceeds to isGitRepoAsync/detectRepoIconAndUpstream without an absolute-path guard. If this branch is meant to import only absolute POSIX paths, add that check here.

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

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants