mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
refactor(runtime): runtime git and file callers drop SSH arms (#27501)
Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
@@ -1,22 +1,11 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
gitExecFileAsyncMock,
|
||||
getSshGitProviderMock,
|
||||
getSshGitProviderGenerationMock,
|
||||
readLocalGitConfigSignatureMock
|
||||
} = vi.hoisted(() => ({
|
||||
const { gitExecFileAsyncMock, readLocalGitConfigSignatureMock } = vi.hoisted(() => ({
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
getSshGitProviderMock: vi.fn(),
|
||||
getSshGitProviderGenerationMock: vi.fn(() => 0),
|
||||
readLocalGitConfigSignatureMock: vi.fn<() => Promise<string | undefined>>(async () => 'sig-1')
|
||||
}))
|
||||
|
||||
vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock }))
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProvider: getSshGitProviderMock,
|
||||
getSshGitProviderGeneration: getSshGitProviderGenerationMock
|
||||
}))
|
||||
vi.mock('../github/local-git-config-signature', () => ({
|
||||
readLocalGitConfigSignature: readLocalGitConfigSignatureMock
|
||||
}))
|
||||
@@ -38,9 +27,6 @@ describe('cached git remote name listing', () => {
|
||||
beforeEach(() => {
|
||||
_resetRemoteNameListingCache()
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
getSshGitProviderMock.mockReset()
|
||||
getSshGitProviderGenerationMock.mockReset()
|
||||
getSshGitProviderGenerationMock.mockReturnValue(0)
|
||||
readLocalGitConfigSignatureMock.mockReset()
|
||||
readLocalGitConfigSignatureMock.mockImplementation(async () => 'sig-1')
|
||||
})
|
||||
@@ -180,20 +166,7 @@ describe('cached git remote name listing', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('lists remotes through the SSH git provider', async () => {
|
||||
const exec = vi.fn(async () => ({ stdout: 'origin\n', stderr: '' }))
|
||||
getSshGitProviderMock.mockReturnValue({ exec })
|
||||
|
||||
await expect(shouldProbeGitRemote('/remote/repo', 'upstream', 'ssh-1')).resolves.toBe(false)
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
expect(exec).toHaveBeenCalledWith(['remote'], '/remote/repo', {
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
})
|
||||
|
||||
it('fails open when the SSH git provider is missing instead of listing locally', async () => {
|
||||
getSshGitProviderMock.mockReturnValue(undefined)
|
||||
|
||||
it('fails open for an SSH repo instead of listing locally', async () => {
|
||||
await expect(shouldProbeGitRemote('/remote/repo', 'upstream', 'ssh-1')).resolves.toBe(true)
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { readLocalGitConfigSignature } from '../github/local-git-config-signature'
|
||||
import { getSshGitProvider, getSshGitProviderGeneration } from '../providers/ssh-git-dispatch'
|
||||
import { runCoalescedProbe, type CoalescedProbes } from './coalesced-probe'
|
||||
import type { GitAdmissionTier } from './command-runner/git-exec-options'
|
||||
import { REMOTE_URL_PROBE_TIMEOUT_MS } from './remote-url-probe'
|
||||
@@ -38,13 +37,9 @@ function parseRemoteNames(stdout: string): string[] {
|
||||
|
||||
function remoteNameListingCacheKey(
|
||||
repoPath: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: RemoteNameListingGitOptions = {}
|
||||
): string {
|
||||
const runtimeKey = connectionId
|
||||
? `ssh:${connectionId}:${getSshGitProviderGeneration(connectionId)}`
|
||||
: `local:${localGitOptions.wslDistro ?? 'host'}`
|
||||
return `${runtimeKey}\0${repoPath}`
|
||||
return `local:${localGitOptions.wslDistro ?? 'host'}\0${repoPath}`
|
||||
}
|
||||
|
||||
function pruneRemoteNameListingCache(now: number): void {
|
||||
@@ -64,12 +59,11 @@ function pruneRemoteNameListingCache(now: number): void {
|
||||
|
||||
function listingGitConfigContext(
|
||||
repoPath: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: RemoteNameListingGitOptions = {}
|
||||
): { repoPath: string; connectionId: string | null; wslDistro?: string } {
|
||||
): { repoPath: string; connectionId: null; wslDistro?: string } {
|
||||
return {
|
||||
repoPath,
|
||||
connectionId: connectionId ?? null,
|
||||
connectionId: null,
|
||||
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
|
||||
}
|
||||
}
|
||||
@@ -77,21 +71,25 @@ function listingGitConfigContext(
|
||||
/**
|
||||
* `git remote` names for one repo/runtime. Failed listings are not cached: a
|
||||
* missed `upstream` would otherwise send issue/PR resolvers to origin on a
|
||||
* contributor clone (#7331).
|
||||
* contributor clone (#7331). An `ssh:` repo is not served here, so it lists as
|
||||
* failed (`null`), never as this machine's remotes.
|
||||
*/
|
||||
export async function listCachedRemoteNames(
|
||||
repoPath: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: RemoteNameListingGitOptions = {}
|
||||
): Promise<string[] | null> {
|
||||
const cacheKey = remoteNameListingCacheKey(repoPath, connectionId, localGitOptions)
|
||||
if (connectionId) {
|
||||
return null
|
||||
}
|
||||
const cacheKey = remoteNameListingCacheKey(repoPath, localGitOptions)
|
||||
const now = Date.now()
|
||||
pruneRemoteNameListingCache(now)
|
||||
const cached = remoteNameListingCache.get(cacheKey)
|
||||
if (cached && cached.expiresAt > now) {
|
||||
if (cached.configSignature !== undefined) {
|
||||
const currentSignature = await readLocalGitConfigSignature(
|
||||
listingGitConfigContext(repoPath, connectionId, localGitOptions)
|
||||
listingGitConfigContext(repoPath, localGitOptions)
|
||||
)
|
||||
if (currentSignature === cached.configSignature) {
|
||||
return cached.remotes
|
||||
@@ -103,9 +101,9 @@ export async function listCachedRemoteNames(
|
||||
}
|
||||
|
||||
return runCoalescedProbe(remoteNameListingInFlight, cacheKey, async (ownsKey) => {
|
||||
const configContext = listingGitConfigContext(repoPath, connectionId, localGitOptions)
|
||||
const configContext = listingGitConfigContext(repoPath, localGitOptions)
|
||||
const configSignatureBefore = await readLocalGitConfigSignature(configContext)
|
||||
const remotes = await listUncachedRemoteNames(repoPath, connectionId, localGitOptions)
|
||||
const remotes = await listUncachedRemoteNames(repoPath, localGitOptions)
|
||||
if (remotes === null) {
|
||||
return null
|
||||
}
|
||||
@@ -132,23 +130,8 @@ export async function listCachedRemoteNames(
|
||||
|
||||
async function listUncachedRemoteNames(
|
||||
repoPath: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: RemoteNameListingGitOptions = {}
|
||||
): Promise<string[] | null> {
|
||||
if (connectionId) {
|
||||
const provider = getSshGitProvider(connectionId)
|
||||
if (!provider) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const { stdout } = await provider.exec(['remote'], repoPath, {
|
||||
signal: AbortSignal.timeout(REMOTE_URL_PROBE_TIMEOUT_MS)
|
||||
})
|
||||
return parseRemoteNames(stdout)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
try {
|
||||
const { stdout } = await gitExecFileAsync(['remote'], {
|
||||
cwd: repoPath,
|
||||
|
||||
@@ -142,15 +142,16 @@ function makeRepo(fields: Pick<Repo, 'connectionId' | 'executionHostId'>): Repo
|
||||
)
|
||||
}
|
||||
|
||||
const localCalls = [
|
||||
mocks.hasHooks,
|
||||
mocks.localRead,
|
||||
mocks.localWrite,
|
||||
mocks.localFileRead,
|
||||
mocks.localStat,
|
||||
mocks.effectiveHooks
|
||||
]
|
||||
|
||||
function expectRoute(target: string | null): void {
|
||||
const localCalls = [
|
||||
mocks.hasHooks,
|
||||
mocks.localRead,
|
||||
mocks.localWrite,
|
||||
mocks.localFileRead,
|
||||
mocks.localStat,
|
||||
mocks.effectiveHooks
|
||||
]
|
||||
if (target) {
|
||||
expect(mocks.provider).toHaveBeenCalledWith(target)
|
||||
for (const [connectionId] of mocks.provider.mock.calls) {
|
||||
@@ -209,13 +210,15 @@ describe.each(owners)('runtime hooks: $label', ({ fields, target }) => {
|
||||
} else {
|
||||
await issue.write(repo.id, 'save')
|
||||
}
|
||||
expectRoute(target)
|
||||
if (method === 'write' && target) {
|
||||
expect(mocks.ignored).toHaveBeenCalledWith(repo.path, target)
|
||||
expect(mocks.remoteWrite).toHaveBeenCalledWith(
|
||||
'/remote/fixture/.orca/issue-command',
|
||||
'save\n'
|
||||
)
|
||||
if (target) {
|
||||
// The runtime serves no SSH filesystem: an SSH owner is neither read remotely nor locally.
|
||||
expect(mocks.provider).not.toHaveBeenCalled()
|
||||
expect(mocks.remoteWrite).not.toHaveBeenCalled()
|
||||
for (const method of localCalls) {
|
||||
expect(method).not.toHaveBeenCalled()
|
||||
}
|
||||
} else {
|
||||
expectRoute(target)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -43,17 +43,8 @@ function localWorkspaceScan(): WorkspacePortScanResult {
|
||||
}
|
||||
}
|
||||
|
||||
function deps(
|
||||
request?: (method: string) => Promise<unknown>,
|
||||
connected = true
|
||||
): WorkspacePortExecutionHostDeps & { request: ReturnType<typeof vi.fn> } {
|
||||
const requestMock = vi.fn(request ?? (async () => ({ ports: [], platform: 'linux' })))
|
||||
return {
|
||||
request: requestMock,
|
||||
getLocalProbes: async () => [LOCAL_PROBE],
|
||||
getSshMultiplexer: (targetId) =>
|
||||
connected && targetId === 'box' ? { request: requestMock } : undefined
|
||||
}
|
||||
function deps(): WorkspacePortExecutionHostDeps {
|
||||
return { getLocalProbes: async () => [LOCAL_PROBE] }
|
||||
}
|
||||
|
||||
describe('scanWorkspacePortsOnExecutionHost', () => {
|
||||
@@ -69,64 +60,16 @@ describe('scanWorkspacePortsOnExecutionHost', () => {
|
||||
const result = await scanWorkspacePortsOnExecutionHost('local', d)
|
||||
|
||||
expect(scanWorkspacePortsMock).toHaveBeenCalledWith([LOCAL_PROBE], undefined, undefined)
|
||||
expect(d.request).not.toHaveBeenCalled()
|
||||
expect(result).toMatchObject({ executionHostId: 'local', platform: 'linux' })
|
||||
expect(result.ports).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("scans the workspace's SSH host through its relay, never this host", async () => {
|
||||
const d = deps(async () => ({
|
||||
platform: 'linux',
|
||||
ports: [{ port: PORT, host: '0.0.0.0', pid: PID, processName: 'node' }]
|
||||
}))
|
||||
|
||||
const result = await scanWorkspacePortsOnExecutionHost('ssh:box', d)
|
||||
it('reports an SSH host as unavailable, not as an empty host, and never scans this host', async () => {
|
||||
const result = await scanWorkspacePortsOnExecutionHost('ssh:box', deps())
|
||||
|
||||
expect(scanWorkspacePortsMock).not.toHaveBeenCalled()
|
||||
expect(d.request).toHaveBeenCalledWith('ports.detect', undefined, expect.any(Object))
|
||||
expect(result).toMatchObject({
|
||||
executionHostId: 'ssh:box',
|
||||
platform: 'linux',
|
||||
ports: [
|
||||
{
|
||||
kind: 'external',
|
||||
bindHost: '0.0.0.0',
|
||||
connectHost: 'localhost',
|
||||
port: PORT,
|
||||
pid: PID,
|
||||
processName: 'node'
|
||||
}
|
||||
]
|
||||
})
|
||||
expect(result.unavailableReason).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reports a disconnected SSH host as unavailable, not as an empty host', async () => {
|
||||
const result = await scanWorkspacePortsOnExecutionHost('ssh:box', deps(undefined, false))
|
||||
|
||||
expect(result).toMatchObject({ executionHostId: 'ssh:box', ports: [] })
|
||||
expect(result.unavailableReason).toMatch(/not connected/)
|
||||
})
|
||||
|
||||
it('reports a failed or unsupported relay scan as unavailable', async () => {
|
||||
const failed = await scanWorkspacePortsOnExecutionHost(
|
||||
'ssh:box',
|
||||
deps(async () => {
|
||||
throw new Error('Request "ports.detect" timed out after 10000ms')
|
||||
})
|
||||
)
|
||||
const unsupported = await scanWorkspacePortsOnExecutionHost(
|
||||
'ssh:box',
|
||||
deps(async () => ({ ports: [], platform: 'darwin' }))
|
||||
)
|
||||
const malformed = await scanWorkspacePortsOnExecutionHost(
|
||||
'ssh:box',
|
||||
deps(async () => ({ ports: [{ port: 'x' }], platform: 'linux' }))
|
||||
)
|
||||
|
||||
expect(failed.unavailableReason).toMatch(/timed out/)
|
||||
expect(unsupported.unavailableReason).toMatch(/not supported/)
|
||||
expect(malformed.unavailableReason).toMatch(/invalid/)
|
||||
expect(result.unavailableReason).toMatch(/Reconnect/)
|
||||
})
|
||||
|
||||
it('refuses a workspace hosted by another server instead of scanning here', async () => {
|
||||
@@ -134,7 +77,6 @@ describe('scanWorkspacePortsOnExecutionHost', () => {
|
||||
const result = await scanWorkspacePortsOnExecutionHost('runtime:env-2', d)
|
||||
|
||||
expect(scanWorkspacePortsMock).not.toHaveBeenCalled()
|
||||
expect(d.request).not.toHaveBeenCalled()
|
||||
expect(result.unavailableReason).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -192,6 +134,5 @@ describe('killWorkspacePortOnExecutionHost', () => {
|
||||
reason: 'Stopping processes on an SSH host is not supported yet.'
|
||||
})
|
||||
expect(kill).not.toHaveBeenCalled()
|
||||
expect(d.request).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,34 +3,18 @@ import {
|
||||
parseExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../shared/execution-host'
|
||||
import type { DetectedPort } from '../../shared/ssh-types'
|
||||
import type {
|
||||
WorkspacePort,
|
||||
WorkspacePortHostKillRequest,
|
||||
WorkspacePortHostScanResult,
|
||||
WorkspacePortKillResult,
|
||||
WorkspacePortProbe
|
||||
} from '../../shared/workspace-ports'
|
||||
import type { SshMultiplexerRequestOptions } from '../ssh/ssh-channel-multiplexer'
|
||||
import { connectHostForBindHost } from './local-workspace-port-address'
|
||||
import { SSH_HOST_NOT_SERVED_MESSAGE } from '../providers/ssh-host-not-served-error'
|
||||
import { killWorkspacePort, scanWorkspacePortProbes } from './workspace-port-ownership'
|
||||
|
||||
// Why: the relay walks /proc/*/fd on demand; a slow host must answer "unavailable", not hang the RPC.
|
||||
const SSH_PORT_SCAN_TIMEOUT_MS = 10_000
|
||||
|
||||
type SshPortScanMultiplexer = {
|
||||
request: (
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
options?: SshMultiplexerRequestOptions
|
||||
) => Promise<unknown>
|
||||
}
|
||||
|
||||
export type WorkspacePortExecutionHostDeps = {
|
||||
/** Workspaces this host runs itself; the only processes Stop may signal here. */
|
||||
getLocalProbes: () => Promise<WorkspacePortProbe[]>
|
||||
/** Undefined means "not connected", never "the host's processes exited". */
|
||||
getSshMultiplexer: (targetId: string) => SshPortScanMultiplexer | undefined
|
||||
}
|
||||
|
||||
function unavailable(executionHostId: string, reason: string): WorkspacePortHostScanResult {
|
||||
@@ -43,73 +27,6 @@ function unavailable(executionHostId: string, reason: string): WorkspacePortHost
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
function isDetectedPort(value: unknown): value is DetectedPort {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
const port: { port?: unknown; host?: unknown; pid?: unknown; processName?: unknown } = value
|
||||
return (
|
||||
typeof port.port === 'number' &&
|
||||
Number.isSafeInteger(port.port) &&
|
||||
typeof port.host === 'string' &&
|
||||
(port.pid === undefined || typeof port.pid === 'number') &&
|
||||
(port.processName === undefined || typeof port.processName === 'string')
|
||||
)
|
||||
}
|
||||
|
||||
// Why `external`: the relay reports listeners without workspace attribution, so no row it returns
|
||||
// can be treated as workspace-owned (which is what makes a row stoppable).
|
||||
function toUnattributedPort(port: DetectedPort): WorkspacePort {
|
||||
return {
|
||||
id: `${port.host}:${port.port}:${port.pid ?? 'unknown'}`,
|
||||
kind: 'external',
|
||||
bindHost: port.host,
|
||||
connectHost: connectHostForBindHost(port.host),
|
||||
port: port.port,
|
||||
...(port.pid !== undefined ? { pid: port.pid } : {}),
|
||||
...(port.processName !== undefined ? { processName: port.processName } : {}),
|
||||
protocol: 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
async function scanSshExecutionHost(
|
||||
executionHostId: string,
|
||||
targetId: string,
|
||||
deps: WorkspacePortExecutionHostDeps
|
||||
): Promise<WorkspacePortHostScanResult> {
|
||||
let response: unknown
|
||||
try {
|
||||
const mux = deps.getSshMultiplexer(targetId)
|
||||
if (!mux) {
|
||||
return unavailable(executionHostId, 'The SSH host is not connected.')
|
||||
}
|
||||
response = await mux.request('ports.detect', undefined, {
|
||||
timeoutMs: SSH_PORT_SCAN_TIMEOUT_MS
|
||||
})
|
||||
} catch (error) {
|
||||
return unavailable(executionHostId, `Could not scan the SSH host: ${errorMessage(error)}`)
|
||||
}
|
||||
const result: { ports?: unknown; platform?: unknown } =
|
||||
response && typeof response === 'object' ? response : {}
|
||||
if (!Array.isArray(result.ports) || !result.ports.every(isDetectedPort)) {
|
||||
return unavailable(executionHostId, 'The SSH host returned an invalid port scan.')
|
||||
}
|
||||
// Why: the relay answers an empty list on platforms it cannot scan; that is "could not look".
|
||||
if (result.platform !== 'linux' && result.platform !== 'win32') {
|
||||
return unavailable(executionHostId, 'Port detection is not supported on this SSH host.')
|
||||
}
|
||||
return {
|
||||
executionHostId,
|
||||
platform: result.platform,
|
||||
scannedAt: Date.now(),
|
||||
ports: result.ports.map(toUnattributedPort)
|
||||
}
|
||||
}
|
||||
|
||||
/** Scans the execution host a workspace resolved to on this endpoint, and names it on the result. */
|
||||
export async function scanWorkspacePortsOnExecutionHost(
|
||||
executionHostId: ExecutionHostId,
|
||||
@@ -119,8 +36,12 @@ export async function scanWorkspacePortsOnExecutionHost(
|
||||
if (host?.kind === 'local') {
|
||||
return { ...(await scanWorkspacePortProbes(await deps.getLocalProbes())), executionHostId }
|
||||
}
|
||||
// Why: this process serves no SSH host, so its listeners are unavailable, never "none".
|
||||
if (host?.kind === 'ssh') {
|
||||
return scanSshExecutionHost(executionHostId, host.targetId, deps)
|
||||
return unavailable(
|
||||
executionHostId,
|
||||
`Could not scan the SSH host: ${SSH_HOST_NOT_SERVED_MESSAGE}`
|
||||
)
|
||||
}
|
||||
return unavailable(executionHostId, 'This workspace does not run on this server.')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { GitWorktreeInfo } from '../shared/worktree/types'
|
||||
import { verifyProvisionedRootCheckout } from './provisioned-root-checkout'
|
||||
|
||||
const projectRoot = '/workspace/orca'
|
||||
|
||||
function gitWorktree(path: string, overrides: Partial<GitWorktreeInfo> = {}): GitWorktreeInfo {
|
||||
return {
|
||||
path,
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/fix-sandbox',
|
||||
isBare: false,
|
||||
isMainWorktree: true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function verify(
|
||||
worktree: GitWorktreeInfo,
|
||||
request: Partial<Parameters<typeof verifyProvisionedRootCheckout>[0]['request']> = {},
|
||||
sparseCheckoutEnabled = false
|
||||
): GitWorktreeInfo {
|
||||
return verifyProvisionedRootCheckout({
|
||||
worktrees: [worktree],
|
||||
sparseCheckoutEnabled,
|
||||
projectRoot,
|
||||
request: { name: 'fix-sandbox', ...request }
|
||||
})
|
||||
}
|
||||
|
||||
describe('verifyProvisionedRootCheckout', () => {
|
||||
it('accepts the primary checkout on the requested branch', () => {
|
||||
expect(verify(gitWorktree(projectRoot)).path).toBe(projectRoot)
|
||||
})
|
||||
|
||||
it('rejects a checkout on a branch Orca did not request', () => {
|
||||
expect(() => verify(gitWorktree(projectRoot, { branch: 'refs/heads/wrong' }))).toThrow(
|
||||
'requested branch'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a checkout that did not start from the requested ref', () => {
|
||||
expect(() =>
|
||||
verify(gitWorktree(projectRoot, { head: 'wrong-head' }), {
|
||||
baseBranch: 'v1.2.3',
|
||||
expectedRefHead: 'expected-head'
|
||||
})
|
||||
).toThrow('requested ref')
|
||||
})
|
||||
|
||||
it('accepts a requested branch created from a tag or commit ref', () => {
|
||||
expect(
|
||||
verify(gitWorktree(projectRoot), { baseBranch: 'v1.2.3', expectedRefHead: 'abc123' }).branch
|
||||
).toBe('refs/heads/fix-sandbox')
|
||||
})
|
||||
|
||||
it('rejects a requested ref without its source-host commit identity', () => {
|
||||
expect(() => verify(gitWorktree(projectRoot), { baseBranch: 'missing-ref' })).toThrow(
|
||||
'ref identity is missing'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses an explicit branch override as the requested local branch', () => {
|
||||
expect(
|
||||
verify(gitWorktree(projectRoot, { branch: 'refs/heads/feature/review' }), {
|
||||
branchNameOverride: 'feature/review'
|
||||
}).branch
|
||||
).toBe('refs/heads/feature/review')
|
||||
})
|
||||
|
||||
it('rejects a linked worktree and a sparse checkout', () => {
|
||||
expect(() => verify(gitWorktree(projectRoot, { isMainWorktree: false }))).toThrow(
|
||||
'must be the repository primary checkout'
|
||||
)
|
||||
expect(() => verify(gitWorktree(projectRoot), {}, true)).toThrow(
|
||||
'cannot adopt a sparse checkout'
|
||||
)
|
||||
})
|
||||
|
||||
it('compares Windows checkout roots using runtime path semantics', () => {
|
||||
expect(
|
||||
verifyProvisionedRootCheckout({
|
||||
worktrees: [gitWorktree('c:/workspace/orca/')],
|
||||
sparseCheckoutEnabled: false,
|
||||
projectRoot: 'C:\\Workspace\\Orca',
|
||||
request: { name: 'fix-sandbox' }
|
||||
}).path
|
||||
).toBe('c:/workspace/orca/')
|
||||
})
|
||||
})
|
||||
@@ -6,16 +6,10 @@ import type { Store } from './persistence'
|
||||
import type { Repo } from '../shared/repo-types'
|
||||
import type { AdoptProvisionedRootArgs } from '../shared/worktree/create-types'
|
||||
import type { WorktreeMeta } from '../shared/worktree/meta-types'
|
||||
import type { GitWorktreeInfo } from '../shared/worktree/types'
|
||||
import {
|
||||
listEphemeralVmRuntimes,
|
||||
upsertEphemeralVmRuntime
|
||||
} from '../shared/ephemeral-vm-runtime-store'
|
||||
import { registerSshGitProvider, unregisterSshGitProvider } from './providers/ssh-git-dispatch'
|
||||
import {
|
||||
resetSshProviderAuthorities,
|
||||
rotateSshProviderAuthority
|
||||
} from './ssh/ssh-provider-authority'
|
||||
import { adoptProvisionedRootSshCheckout } from './provisioned-root-ssh-adoption'
|
||||
|
||||
const connectionId = 'runtime-ssh-test'
|
||||
@@ -26,88 +20,14 @@ describe('adoptProvisionedRootSshCheckout', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
userDataPath = mkdtempSync(join(tmpdir(), 'orca-provisioned-root-'))
|
||||
resetSshProviderAuthorities()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
unregisterSshGitProvider(connectionId)
|
||||
resetSshProviderAuthorities()
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('adopts the exact primary checkout, persists host metadata, and attaches the runtime', async () => {
|
||||
it('refuses a valid request because this process serves no SSH git, without writing state', async () => {
|
||||
seedRuntime(userDataPath, projectRoot)
|
||||
registerSshGitProvider(connectionId, {
|
||||
listWorktrees: vi.fn().mockResolvedValue([gitWorktree(projectRoot)]),
|
||||
exec: sparseCheckoutProbe(false)
|
||||
} as never)
|
||||
const { store, setWorktreeMeta } = makeStore()
|
||||
|
||||
const result = await adoptProvisionedRootSshCheckout({
|
||||
userDataPath,
|
||||
request: request(projectRoot),
|
||||
repo: repo(projectRoot),
|
||||
store,
|
||||
isRepoCurrent: () => true
|
||||
})
|
||||
|
||||
expect(result.worktree).toMatchObject({
|
||||
id: `repo-1::${projectRoot}`,
|
||||
path: projectRoot,
|
||||
isMainWorktree: true,
|
||||
hostId: `ssh:${connectionId}`,
|
||||
ephemeralVmCheckoutMode: 'provisioned-root',
|
||||
linkedGitLabIssue: 17
|
||||
})
|
||||
expect(setWorktreeMeta).toHaveBeenCalledWith(
|
||||
`repo-1::${projectRoot}`,
|
||||
expect.objectContaining({
|
||||
hostId: `ssh:${connectionId}`,
|
||||
ephemeralVmCheckoutMode: 'provisioned-root',
|
||||
linkedGitLabIssue: 17
|
||||
})
|
||||
)
|
||||
expect(listEphemeralVmRuntimes(userDataPath)[0]).toMatchObject({
|
||||
workspaceId: `repo-1::${projectRoot}`,
|
||||
status: 'running'
|
||||
})
|
||||
})
|
||||
|
||||
it('pins an explicit label even when it equals the adopted branch', async () => {
|
||||
seedRuntime(userDataPath, projectRoot)
|
||||
registerSshGitProvider(connectionId, {
|
||||
listWorktrees: vi.fn().mockResolvedValue([gitWorktree(projectRoot)]),
|
||||
exec: sparseCheckoutProbe(false)
|
||||
} as never)
|
||||
const { store, setWorktreeMeta } = makeStore()
|
||||
|
||||
const result = await adoptProvisionedRootSshCheckout({
|
||||
userDataPath,
|
||||
request: {
|
||||
...request(projectRoot),
|
||||
displayName: 'fix-sandbox',
|
||||
displayNameKind: 'user'
|
||||
},
|
||||
repo: repo(projectRoot),
|
||||
store,
|
||||
isRepoCurrent: () => true
|
||||
})
|
||||
|
||||
expect(result.worktree.displayName).toBe('fix-sandbox')
|
||||
expect(setWorktreeMeta).toHaveBeenCalledWith(
|
||||
`repo-1::${projectRoot}`,
|
||||
expect.objectContaining({ displayName: 'fix-sandbox', displayNameIsPinned: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a recipe checkout on a branch Orca did not request', async () => {
|
||||
seedRuntime(userDataPath, projectRoot)
|
||||
registerSshGitProvider(connectionId, {
|
||||
listWorktrees: vi
|
||||
.fn()
|
||||
.mockResolvedValue([gitWorktree(projectRoot, { branch: 'refs/heads/wrong-branch' })]),
|
||||
exec: sparseCheckoutProbe(false)
|
||||
} as never)
|
||||
const { store, setWorktreeMeta } = makeStore()
|
||||
|
||||
await expect(
|
||||
@@ -118,121 +38,15 @@ describe('adoptProvisionedRootSshCheckout', () => {
|
||||
store,
|
||||
isRepoCurrent: () => true
|
||||
})
|
||||
).rejects.toThrow('requested branch')
|
||||
).rejects.toThrow('no longer available')
|
||||
expect(setWorktreeMeta).not.toHaveBeenCalled()
|
||||
expect(listEphemeralVmRuntimes(userDataPath)[0]?.workspaceId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a recipe checkout that did not start from the requested ref', async () => {
|
||||
it('rejects sparse checkout requests', async () => {
|
||||
seedRuntime(userDataPath, projectRoot)
|
||||
const exec = sparseCheckoutProbe(false)
|
||||
registerSshGitProvider(connectionId, {
|
||||
listWorktrees: vi.fn().mockResolvedValue([gitWorktree(projectRoot, { head: 'wrong-head' })]),
|
||||
exec
|
||||
} as never)
|
||||
const { store, setWorktreeMeta } = makeStore()
|
||||
|
||||
await expect(
|
||||
adoptProvisionedRootSshCheckout({
|
||||
userDataPath,
|
||||
request: {
|
||||
...request(projectRoot),
|
||||
baseBranch: 'v1.2.3',
|
||||
expectedRefHead: 'expected-head'
|
||||
},
|
||||
repo: repo(projectRoot),
|
||||
store,
|
||||
isRepoCurrent: () => true
|
||||
})
|
||||
).rejects.toThrow('requested ref')
|
||||
expect(exec).toHaveBeenCalledOnce()
|
||||
expect(setWorktreeMeta).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts an attached requested branch created from a tag or commit ref', async () => {
|
||||
seedRuntime(userDataPath, projectRoot)
|
||||
const exec = sparseCheckoutProbe(false)
|
||||
registerSshGitProvider(connectionId, {
|
||||
listWorktrees: vi.fn().mockResolvedValue([gitWorktree(projectRoot)]),
|
||||
exec
|
||||
} as never)
|
||||
const { store } = makeStore()
|
||||
|
||||
await expect(
|
||||
adoptProvisionedRootSshCheckout({
|
||||
userDataPath,
|
||||
request: {
|
||||
...request(projectRoot),
|
||||
baseBranch: 'v1.2.3',
|
||||
expectedRefHead: 'abc123'
|
||||
},
|
||||
repo: repo(projectRoot),
|
||||
store,
|
||||
isRepoCurrent: () => true
|
||||
})
|
||||
).resolves.toMatchObject({ worktree: { branch: 'refs/heads/fix-sandbox' } })
|
||||
})
|
||||
|
||||
it('rejects a requested ref without its source-host commit identity', async () => {
|
||||
seedRuntime(userDataPath, projectRoot)
|
||||
registerSshGitProvider(connectionId, {
|
||||
listWorktrees: vi.fn().mockResolvedValue([gitWorktree(projectRoot)]),
|
||||
exec: sparseCheckoutProbe(false)
|
||||
} as never)
|
||||
const { store, setWorktreeMeta } = makeStore()
|
||||
|
||||
await expect(
|
||||
adoptProvisionedRootSshCheckout({
|
||||
userDataPath,
|
||||
request: { ...request(projectRoot), baseBranch: 'missing-ref' },
|
||||
repo: repo(projectRoot),
|
||||
store,
|
||||
isRepoCurrent: () => true
|
||||
})
|
||||
).rejects.toThrow('ref identity is missing')
|
||||
expect(setWorktreeMeta).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses an explicit branch override as the requested local branch', async () => {
|
||||
seedRuntime(userDataPath, projectRoot)
|
||||
registerSshGitProvider(connectionId, {
|
||||
listWorktrees: vi
|
||||
.fn()
|
||||
.mockResolvedValue([gitWorktree(projectRoot, { branch: 'refs/heads/feature/review' })]),
|
||||
exec: sparseCheckoutProbe(false)
|
||||
} as never)
|
||||
const { store } = makeStore()
|
||||
|
||||
await expect(
|
||||
adoptProvisionedRootSshCheckout({
|
||||
userDataPath,
|
||||
request: { ...request(projectRoot), branchNameOverride: 'feature/review' },
|
||||
repo: repo(projectRoot),
|
||||
store,
|
||||
isRepoCurrent: () => true
|
||||
})
|
||||
).resolves.toMatchObject({ worktree: { branch: 'refs/heads/feature/review' } })
|
||||
})
|
||||
|
||||
it('rejects a linked worktree and sparse checkout', async () => {
|
||||
seedRuntime(userDataPath, projectRoot)
|
||||
const listWorktrees = vi
|
||||
.fn()
|
||||
.mockResolvedValue([gitWorktree(projectRoot, { isMainWorktree: false })])
|
||||
registerSshGitProvider(connectionId, {
|
||||
listWorktrees,
|
||||
exec: sparseCheckoutProbe(false)
|
||||
} as never)
|
||||
const { store, setWorktreeMeta } = makeStore()
|
||||
|
||||
await expect(
|
||||
adoptProvisionedRootSshCheckout({
|
||||
userDataPath,
|
||||
request: request(projectRoot),
|
||||
repo: repo(projectRoot),
|
||||
store,
|
||||
isRepoCurrent: () => true
|
||||
})
|
||||
).rejects.toThrow('must be the repository primary checkout')
|
||||
await expect(
|
||||
adoptProvisionedRootSshCheckout({
|
||||
userDataPath,
|
||||
@@ -242,15 +56,10 @@ describe('adoptProvisionedRootSshCheckout', () => {
|
||||
isRepoCurrent: () => true
|
||||
})
|
||||
).rejects.toThrow('do not support sparse checkout')
|
||||
expect(setWorktreeMeta).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects path and runtime target mismatches', async () => {
|
||||
seedRuntime(userDataPath, projectRoot)
|
||||
registerSshGitProvider(connectionId, {
|
||||
listWorktrees: vi.fn().mockResolvedValue([gitWorktree(projectRoot)]),
|
||||
exec: sparseCheckoutProbe(false)
|
||||
} as never)
|
||||
const { store } = makeStore()
|
||||
|
||||
await expect(
|
||||
@@ -272,79 +81,6 @@ describe('adoptProvisionedRootSshCheckout', () => {
|
||||
})
|
||||
).rejects.toThrow('host does not match')
|
||||
})
|
||||
|
||||
it('rejects provider rotation during verification', async () => {
|
||||
seedRuntime(userDataPath, projectRoot)
|
||||
let resolveList: (value: ReturnType<typeof gitWorktree>[]) => void = () => undefined
|
||||
const listWorktrees = vi.fn(
|
||||
() =>
|
||||
new Promise<ReturnType<typeof gitWorktree>[]>((resolve) => {
|
||||
resolveList = resolve
|
||||
})
|
||||
)
|
||||
registerSshGitProvider(connectionId, {
|
||||
listWorktrees,
|
||||
exec: sparseCheckoutProbe(false)
|
||||
} as never)
|
||||
const { store } = makeStore()
|
||||
const adoption = adoptProvisionedRootSshCheckout({
|
||||
userDataPath,
|
||||
request: request(projectRoot),
|
||||
repo: repo(projectRoot),
|
||||
store,
|
||||
isRepoCurrent: () => true
|
||||
})
|
||||
await vi.waitFor(() => expect(listWorktrees).toHaveBeenCalledOnce())
|
||||
rotateSshProviderAuthority(connectionId)
|
||||
resolveList([gitWorktree(projectRoot)])
|
||||
|
||||
await expect(adoption).rejects.toThrow('changed during checkout verification')
|
||||
})
|
||||
|
||||
it('compares Windows checkout roots using runtime path semantics', async () => {
|
||||
const windowsRoot = 'C:\\Workspace\\Orca'
|
||||
seedRuntime(userDataPath, windowsRoot)
|
||||
registerSshGitProvider(connectionId, {
|
||||
listWorktrees: vi.fn().mockResolvedValue([gitWorktree('c:/workspace/orca/')]),
|
||||
exec: sparseCheckoutProbe(false)
|
||||
} as never)
|
||||
const { store } = makeStore()
|
||||
|
||||
const result = await adoptProvisionedRootSshCheckout({
|
||||
userDataPath,
|
||||
request: request('C:/WORKSPACE/ORCA'),
|
||||
repo: repo('c:\\workspace\\orca'),
|
||||
store,
|
||||
isRepoCurrent: () => true
|
||||
})
|
||||
|
||||
expect(result.worktree.path).toBe('c:/workspace/orca/')
|
||||
})
|
||||
|
||||
it('rejects sparse checkout enabled in the remote Git config', async () => {
|
||||
seedRuntime(userDataPath, projectRoot)
|
||||
const exec = sparseCheckoutProbe(true)
|
||||
registerSshGitProvider(connectionId, {
|
||||
listWorktrees: vi.fn().mockResolvedValue([gitWorktree(projectRoot)]),
|
||||
exec
|
||||
} as never)
|
||||
const { store, setWorktreeMeta } = makeStore()
|
||||
|
||||
await expect(
|
||||
adoptProvisionedRootSshCheckout({
|
||||
userDataPath,
|
||||
request: request(projectRoot),
|
||||
repo: repo(projectRoot),
|
||||
store,
|
||||
isRepoCurrent: () => true
|
||||
})
|
||||
).rejects.toThrow('cannot adopt a sparse checkout')
|
||||
expect(exec).toHaveBeenCalledWith(
|
||||
['config', '--bool', '--get', '--default=false', 'core.sparseCheckout'],
|
||||
projectRoot
|
||||
)
|
||||
expect(setWorktreeMeta).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
function seedRuntime(userDataPath: string, root: string): void {
|
||||
@@ -398,21 +134,6 @@ function request(expectedPath: string): AdoptProvisionedRootArgs {
|
||||
}
|
||||
}
|
||||
|
||||
function gitWorktree(path: string, overrides: Partial<GitWorktreeInfo> = {}): GitWorktreeInfo {
|
||||
return {
|
||||
path,
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/fix-sandbox',
|
||||
isBare: false,
|
||||
isMainWorktree: true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function sparseCheckoutProbe(enabled: boolean): ReturnType<typeof vi.fn> {
|
||||
return vi.fn().mockResolvedValue({ stdout: `${enabled}\n`, stderr: '' })
|
||||
}
|
||||
|
||||
function makeStore(): {
|
||||
store: Store
|
||||
setWorktreeMeta: ReturnType<typeof vi.fn>
|
||||
|
||||
@@ -12,18 +12,7 @@ import {
|
||||
} from '../shared/ephemeral-vm-recipes'
|
||||
import { listEphemeralVmRuntimes } from '../shared/ephemeral-vm-runtime-store'
|
||||
import type { EphemeralVmRuntimeRecord } from '../shared/ephemeral-vm-runtimes'
|
||||
import { getSshGitProvider } from './providers/ssh-git-dispatch'
|
||||
import {
|
||||
getSshProviderAuthority,
|
||||
isCurrentSshProviderAuthority
|
||||
} from './ssh/ssh-provider-authority'
|
||||
import { attachEphemeralVmRuntimeToWorkspace } from './ephemeral-vm-runtime-attachment'
|
||||
import { mergeWorktree } from './ipc/worktree-logic'
|
||||
import {
|
||||
buildProvisionedRootMeta,
|
||||
provisionedRootPathsEqual,
|
||||
verifyProvisionedRootCheckout
|
||||
} from './provisioned-root-checkout'
|
||||
import { provisionedRootPathsEqual } from './provisioned-root-checkout'
|
||||
|
||||
type AdoptionArgs = AdoptProvisionedRootArgs & {
|
||||
automationProvenance?: AutomationWorkspaceProvenance
|
||||
@@ -36,7 +25,7 @@ export async function adoptProvisionedRootSshCheckout(args: {
|
||||
store: Store
|
||||
isRepoCurrent: () => boolean
|
||||
}): Promise<CreateWorktreeResult> {
|
||||
const { request, repo, store } = args
|
||||
const { request, repo } = args
|
||||
if (request.sparseCheckout) {
|
||||
throw new Error('Provisioned-root recipes do not support sparse checkout.')
|
||||
}
|
||||
@@ -61,59 +50,8 @@ export async function adoptProvisionedRootSshCheckout(args: {
|
||||
throw new Error('The recipe projectRoot does not match the imported Git checkout root.')
|
||||
}
|
||||
|
||||
const provider = getSshGitProvider(connectionId)
|
||||
if (!provider) {
|
||||
throw new Error('The recipe-created SSH connection is no longer available.')
|
||||
}
|
||||
const authority = { ...getSshProviderAuthority(connectionId) }
|
||||
const [worktrees, sparseCheckoutEnabled] = await Promise.all([
|
||||
provider.listWorktrees(repo.path),
|
||||
isSparseCheckoutEnabled(provider, projectRoot)
|
||||
])
|
||||
if (
|
||||
getSshGitProvider(connectionId) !== provider ||
|
||||
!isCurrentSshProviderAuthority(authority) ||
|
||||
!args.isRepoCurrent()
|
||||
) {
|
||||
throw new Error('The recipe-created SSH connection changed during checkout verification.')
|
||||
}
|
||||
requireOwnedProvisionedRootRuntime(args.userDataPath, request.runtimeId, connectionId)
|
||||
|
||||
const gitWorktree = verifyProvisionedRootCheckout({
|
||||
worktrees,
|
||||
sparseCheckoutEnabled,
|
||||
projectRoot,
|
||||
request
|
||||
})
|
||||
|
||||
const worktreeId = `${repo.id}::${gitWorktree.path}`
|
||||
attachEphemeralVmRuntimeToWorkspace({
|
||||
userDataPath: args.userDataPath,
|
||||
runtimeId: request.runtimeId,
|
||||
workspaceId: worktreeId
|
||||
})
|
||||
const now = Date.now()
|
||||
const meta = store.setWorktreeMeta(
|
||||
worktreeId,
|
||||
buildProvisionedRootMeta(store, repo, request, {
|
||||
branchName: gitWorktree.branch.replace(/^refs\/heads\//, ''),
|
||||
now,
|
||||
hostId: args.request.executionHostId,
|
||||
source: 'ssh'
|
||||
})
|
||||
)
|
||||
return { worktree: mergeWorktree(repo.id, gitWorktree, meta) }
|
||||
}
|
||||
|
||||
async function isSparseCheckoutEnabled(
|
||||
provider: NonNullable<ReturnType<typeof getSshGitProvider>>,
|
||||
projectRoot: string
|
||||
): Promise<boolean> {
|
||||
const { stdout } = await provider.exec(
|
||||
['config', '--bool', '--get', '--default=false', 'core.sparseCheckout'],
|
||||
projectRoot
|
||||
)
|
||||
return stdout.trim() === 'true'
|
||||
// Why: this process serves no SSH git, so a provisioned SSH checkout is never adopted here.
|
||||
throw new Error('The recipe-created SSH connection is no longer available.')
|
||||
}
|
||||
|
||||
function requireOwnedProvisionedRootRuntime(
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { openMock, resolveAuthorizedPathMock } from './orca-runtime-files-mock-registry'
|
||||
import {
|
||||
createRuntimeFileCommands,
|
||||
useRuntimeFileCommandsLifecycle
|
||||
} from './orca-runtime-files-test-harness'
|
||||
import { SshHostNotServedError } from '../providers/ssh-host-not-served-error'
|
||||
|
||||
vi.mock('fs', async () => (await import('./orca-runtime-files-mock-registry')).fsModuleMock())
|
||||
vi.mock('fs/promises', async () =>
|
||||
(await import('./orca-runtime-files-mock-registry')).fsPromisesModuleMock()
|
||||
)
|
||||
vi.mock(
|
||||
'./file-watcher-host',
|
||||
async () => (await import('./orca-runtime-files-mock-registry')).fileWatcherHostMock
|
||||
)
|
||||
vi.mock('../ipc/filesystem-auth', async () =>
|
||||
(await import('./orca-runtime-files-mock-registry')).filesystemAuthModuleMock()
|
||||
)
|
||||
vi.mock('../git/runner', async () =>
|
||||
(await import('./orca-runtime-files-mock-registry')).gitRunnerModuleMock()
|
||||
)
|
||||
vi.mock(
|
||||
'../ipc/local-worktree-runtime-options',
|
||||
async () => (await import('./orca-runtime-files-mock-registry')).localWorktreeRuntimeOptionsMock
|
||||
)
|
||||
|
||||
describe('RuntimeFileCommands.readFileExplorerChunk', () => {
|
||||
useRuntimeFileCommandsLifecycle()
|
||||
|
||||
it('serves a local worktree from the local file handle', async () => {
|
||||
const contents = Buffer.from(Array.from({ length: 2048 }, (_, index) => (index * 31) % 251))
|
||||
const { commands } = createRuntimeFileCommands()
|
||||
resolveAuthorizedPathMock.mockResolvedValue('/repo/local.bin')
|
||||
const close = vi.fn(async () => undefined)
|
||||
openMock.mockResolvedValue({
|
||||
stat: vi.fn(async () => ({ isFile: () => true, size: contents.byteLength })),
|
||||
read: vi.fn(
|
||||
async (buffer: Buffer, bufferOffset: number, length: number, position: number) => {
|
||||
const bytesRead = contents.copy(buffer, bufferOffset, position, position + length)
|
||||
return { bytesRead }
|
||||
}
|
||||
),
|
||||
close
|
||||
})
|
||||
|
||||
const chunk = await commands.readFileExplorerChunk('id:wt-1', 'local.bin', 0, 1024)
|
||||
|
||||
expect(Buffer.from(chunk.contentBase64, 'base64')).toEqual(contents.subarray(0, 1024))
|
||||
expect(chunk.eof).toBe(false)
|
||||
expect(close).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses an SSH worktree without opening a local file', async () => {
|
||||
const { commands } = createRuntimeFileCommands({
|
||||
path: '/remote/repo',
|
||||
resolveRuntimeFileTarget: vi.fn(async () => ({
|
||||
worktree: { id: 'wt-1', repoId: 'repo-1', path: '/remote/repo' },
|
||||
executionHostId: 'ssh:ssh-1'
|
||||
}))
|
||||
})
|
||||
|
||||
await expect(commands.readFileExplorerChunk('id:wt-1', 'big.bin', 0, 1024)).rejects.toThrow(
|
||||
SshHostNotServedError
|
||||
)
|
||||
expect(openMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SshHostNotServedError } from '../providers/ssh-host-not-served-error'
|
||||
import type * as FileSystem from 'node:fs/promises'
|
||||
import {
|
||||
enoent,
|
||||
@@ -10,7 +11,6 @@ import {
|
||||
createRuntimeFileCommands,
|
||||
useRuntimeFileCommandsLifecycle
|
||||
} from './orca-runtime-files-test-harness'
|
||||
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
|
||||
vi.mock('fs', async () => (await import('./orca-runtime-files-mock-registry')).fsModuleMock())
|
||||
vi.mock('fs/promises', async () =>
|
||||
@@ -30,10 +30,6 @@ vi.mock(
|
||||
'../ipc/local-worktree-runtime-options',
|
||||
async () => (await import('./orca-runtime-files-mock-registry')).localWorktreeRuntimeOptionsMock
|
||||
)
|
||||
vi.mock(
|
||||
'../providers/ssh-filesystem-dispatch',
|
||||
async () => (await import('./orca-runtime-files-mock-registry')).sshFilesystemDispatchMock
|
||||
)
|
||||
|
||||
function dirEntry(args: { name: string; directory?: boolean; symlink?: boolean }) {
|
||||
return {
|
||||
@@ -181,42 +177,22 @@ describe('RuntimeFileCommands', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('names this desktop as the owner of SSH file and diff opens', async () => {
|
||||
it('refuses SSH file opens without creating an editor tab', async () => {
|
||||
const openFile = vi.fn()
|
||||
const openDiff = vi.fn()
|
||||
const resolveRuntimeFileTarget = vi.fn(async () => ({
|
||||
worktree: { id: 'wt-1', repoId: 'repo-1', path: '/remote/repo' },
|
||||
executionHostId: 'ssh:ssh-1'
|
||||
}))
|
||||
const { commands } = createRuntimeFileCommands({
|
||||
openFile,
|
||||
openDiff,
|
||||
path: '/remote/repo',
|
||||
resolveRuntimeFileTarget
|
||||
})
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the open path only calls `stat`.
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue({
|
||||
stat: vi.fn().mockResolvedValue({ type: 'file', size: 1, mtime: 0 })
|
||||
} as never)
|
||||
|
||||
await commands.openMobileFile('id:wt-1', 'docs/readme.md')
|
||||
await commands.openMobileDiff('id:wt-1', 'docs/readme.md', false)
|
||||
|
||||
expect(openFile).toHaveBeenCalledWith(
|
||||
'wt-1',
|
||||
'/remote/repo/docs/readme.md',
|
||||
'docs/readme.md',
|
||||
null,
|
||||
undefined
|
||||
)
|
||||
expect(openDiff).toHaveBeenCalledWith(
|
||||
'wt-1',
|
||||
'/remote/repo/docs/readme.md',
|
||||
'docs/readme.md',
|
||||
false,
|
||||
null,
|
||||
undefined
|
||||
await expect(commands.openMobileFile('id:wt-1', 'docs/readme.md')).rejects.toThrow(
|
||||
SshHostNotServedError
|
||||
)
|
||||
expect(openFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('names a paired server as the owner of a diff its catalog row is stamped with', async () => {
|
||||
@@ -288,31 +264,6 @@ describe('RuntimeFileCommands', () => {
|
||||
expect(openFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects missing remote files without creating an editor tab', async () => {
|
||||
const openFile = vi.fn()
|
||||
const resolveRuntimeFileTarget = vi.fn(async () => ({
|
||||
worktree: {
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
path: '/remote/repo'
|
||||
},
|
||||
executionHostId: 'ssh:ssh-1'
|
||||
}))
|
||||
const { commands } = createRuntimeFileCommands({
|
||||
openFile,
|
||||
path: '/remote/repo',
|
||||
resolveRuntimeFileTarget
|
||||
})
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue({
|
||||
stat: vi.fn().mockRejectedValue(new Error('ENOENT: no such file or directory'))
|
||||
} as never)
|
||||
|
||||
await expect(commands.openMobileFile('id:wt-1', 'docs/missing.md')).rejects.toThrow(
|
||||
"ENOENT: no such file or directory, open '/remote/repo/docs/missing.md'"
|
||||
)
|
||||
expect(openFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a local directory without creating an editor tab', async () => {
|
||||
const openFile = vi.fn()
|
||||
const { commands } = createRuntimeFileCommands({ openFile })
|
||||
@@ -325,32 +276,6 @@ describe('RuntimeFileCommands', () => {
|
||||
expect(openFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a remote directory without creating an editor tab', async () => {
|
||||
const openFile = vi.fn()
|
||||
const resolveRuntimeFileTarget = vi.fn(async () => ({
|
||||
worktree: {
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
path: '/remote/repo'
|
||||
},
|
||||
executionHostId: 'ssh:ssh-1'
|
||||
}))
|
||||
const { commands } = createRuntimeFileCommands({
|
||||
openFile,
|
||||
path: '/remote/repo',
|
||||
resolveRuntimeFileTarget
|
||||
})
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the open path only calls `stat`.
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue({
|
||||
stat: vi.fn().mockResolvedValue({ type: 'directory', size: 0, mtime: 0 })
|
||||
} as never)
|
||||
|
||||
await expect(commands.openMobileFile('id:wt-1', 'src')).rejects.toThrow(
|
||||
"EISDIR: illegal operation on a directory, open '/remote/repo/src'"
|
||||
)
|
||||
expect(openFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not follow symlinks when reading runtime-local file explorer dirs', async () => {
|
||||
const { commands } = createRuntimeFileCommands()
|
||||
resolveAuthorizedPathMock.mockResolvedValue('/repo')
|
||||
|
||||
@@ -7,10 +7,8 @@ import {
|
||||
createRuntimeFileCommands,
|
||||
useRuntimeFileCommandsLifecycle
|
||||
} from './orca-runtime-files-test-harness'
|
||||
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
import { RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES } from './orca-runtime-files'
|
||||
import { REMOTE_RPC_MAX_CONTENT_BYTES } from '../../shared/remote-rpc-content-budget'
|
||||
import { FileReadCapExceededError, StreamProtocolError } from '../ssh/ssh-filesystem-stream-reader'
|
||||
|
||||
vi.mock('fs', async () => (await import('./orca-runtime-files-mock-registry')).fsModuleMock())
|
||||
vi.mock('fs/promises', async () =>
|
||||
@@ -30,10 +28,6 @@ vi.mock(
|
||||
'../ipc/local-worktree-runtime-options',
|
||||
async () => (await import('./orca-runtime-files-mock-registry')).localWorktreeRuntimeOptionsMock
|
||||
)
|
||||
vi.mock(
|
||||
'../providers/ssh-filesystem-dispatch',
|
||||
async () => (await import('./orca-runtime-files-mock-registry')).sshFilesystemDispatchMock
|
||||
)
|
||||
|
||||
describe('RuntimeFileCommands', () => {
|
||||
useRuntimeFileCommandsLifecycle()
|
||||
@@ -106,152 +100,5 @@ describe('RuntimeFileCommands', () => {
|
||||
mimeType: 'image/png'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an SSH text preview past the decoded text limit the local branch enforces', async () => {
|
||||
const { commands, store } = createRuntimeFileCommands({ path: '/repo' })
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
|
||||
// NUL-free control bytes: sniffed as text, yet each escapes to six JSON bytes.
|
||||
const content = '\u0001'.repeat(1024 * 1024)
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue({
|
||||
stat: vi.fn().mockResolvedValue({ type: 'file', size: content.length }),
|
||||
readFile: vi.fn().mockResolvedValue({ content, isBinary: false })
|
||||
} as never)
|
||||
|
||||
await expect(commands.readFileExplorerPreview('id:wt-1', 'log.txt')).rejects.toThrow(
|
||||
'file_too_large'
|
||||
)
|
||||
})
|
||||
|
||||
it('still returns an SSH binary preview inside the base64 cap', async () => {
|
||||
const { commands, store } = createRuntimeFileCommands({ path: '/repo' })
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
|
||||
const preview = { content: 'a'.repeat(1024 * 1024), isBinary: true, isImage: true }
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue({
|
||||
stat: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ type: 'file', size: RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES }),
|
||||
readFile: vi.fn().mockResolvedValue(preview)
|
||||
} as never)
|
||||
|
||||
await expect(commands.readFileExplorerPreview('id:wt-1', 'logo.png')).resolves.toEqual(
|
||||
preview
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects an SSH binary result that grew past its request-scoped budget', async () => {
|
||||
const { commands, store } = createRuntimeFileCommands({ path: '/repo' })
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
|
||||
const readFile = vi.fn().mockResolvedValue({
|
||||
content: 'a'.repeat(13),
|
||||
isBinary: true,
|
||||
isImage: true
|
||||
})
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue({
|
||||
stat: vi.fn().mockResolvedValue({ type: 'file', size: 0 }),
|
||||
readFile
|
||||
} as never)
|
||||
|
||||
await expect(commands.readFileExplorerPreview('id:wt-1', 'logo.png', 12)).rejects.toThrow(
|
||||
'file_too_large'
|
||||
)
|
||||
expect(readFile).toHaveBeenCalledWith('/repo/logo.png', {
|
||||
maxBinaryBytes: 0,
|
||||
maxTextBytes: 512 * 1024
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects escape-dense SSH text beyond the request-scoped result budget', async () => {
|
||||
const { commands, store } = createRuntimeFileCommands({ path: '/repo' })
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue({
|
||||
stat: vi.fn().mockResolvedValue({ type: 'file', size: 64 }),
|
||||
readFile: vi.fn().mockResolvedValue({ content: '\u0001'.repeat(64), isBinary: false })
|
||||
} as never)
|
||||
|
||||
await expect(commands.readFileExplorerPreview('id:wt-1', 'log.txt', 128)).rejects.toThrow(
|
||||
'file_too_large'
|
||||
)
|
||||
})
|
||||
|
||||
// Why: without translation the reader's raw "exceeds client cap" string reaches the client as a
|
||||
// generic runtime_error, which neither the desktop nor the mobile preview arm recognizes.
|
||||
it('translates an over-cap stream read into file_too_large', async () => {
|
||||
const { commands, store } = createRuntimeFileCommands({ path: '/repo' })
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue({
|
||||
stat: vi.fn().mockResolvedValue({ type: 'file', size: 1024 }),
|
||||
readFile: vi
|
||||
.fn()
|
||||
.mockRejectedValue(
|
||||
new FileReadCapExceededError('Reported totalSize 900000 exceeds client cap 524288')
|
||||
)
|
||||
} as never)
|
||||
|
||||
await expect(commands.readFileExplorerPreview('id:wt-1', 'log.txt')).rejects.toThrow(
|
||||
'file_too_large'
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves a genuine stream protocol failure unmasked', async () => {
|
||||
const { commands, store } = createRuntimeFileCommands({ path: '/repo' })
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue({
|
||||
stat: vi.fn().mockResolvedValue({ type: 'file', size: 1024 }),
|
||||
readFile: vi.fn().mockRejectedValue(new StreamProtocolError('Malformed chunk for stream 4'))
|
||||
} as never)
|
||||
|
||||
await expect(commands.readFileExplorerPreview('id:wt-1', 'log.txt')).rejects.toThrow(
|
||||
'Malformed chunk'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects oversized SSH preview metadata with small content', async () => {
|
||||
const { commands, store } = createRuntimeFileCommands({ path: '/repo' })
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue({
|
||||
stat: vi.fn().mockResolvedValue({ type: 'file', size: 1 }),
|
||||
readFile: vi.fn().mockResolvedValue({
|
||||
content: 'a',
|
||||
isBinary: true,
|
||||
mimeType: 'x'.repeat(128)
|
||||
})
|
||||
} as never)
|
||||
|
||||
await expect(commands.readFileExplorerPreview('id:wt-1', 'logo.png', 128)).rejects.toThrow(
|
||||
'file_too_large'
|
||||
)
|
||||
})
|
||||
|
||||
it('sends preview authority to the SSH execution host in one read', async () => {
|
||||
const { commands, store } = createRuntimeFileCommands({ path: '/repo' })
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
|
||||
const readDocPreviewFile = vi.fn().mockResolvedValue({ content: 'ok', isBinary: false })
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue({ readDocPreviewFile } as never)
|
||||
|
||||
await expect(
|
||||
commands.readDocPreviewFile('id:wt-1', 'docs/index.html', 'docs/index.html', 'docs', [
|
||||
'assets'
|
||||
])
|
||||
).resolves.toEqual({ content: 'ok', isBinary: false })
|
||||
expect(readDocPreviewFile).toHaveBeenCalledWith({
|
||||
boundaryPath: '/repo',
|
||||
entryPath: '/repo/docs/index.html',
|
||||
implicitRootPath: '/repo/docs',
|
||||
authorizedRootPaths: ['/repo/assets'],
|
||||
targetPath: '/repo/docs/index.html',
|
||||
maxTextBytes: 512 * 1024,
|
||||
maxBinaryBytes: 10 * 1024 * 1024
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed when the SSH execution host lacks scoped preview reads', async () => {
|
||||
const { commands, store } = createRuntimeFileCommands({ path: '/repo' })
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue({} as never)
|
||||
|
||||
await expect(
|
||||
commands.readDocPreviewFile('id:wt-1', 'docs/index.html', 'docs/index.html', 'docs', [])
|
||||
).rejects.toThrow('newer SSH relay')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
createRuntimeFileCommands,
|
||||
useRuntimeFileCommandsLifecycle
|
||||
} from './orca-runtime-files-test-harness'
|
||||
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
import { SshHostNotServedError } from '../providers/ssh-host-not-served-error'
|
||||
import { setSshConnectionGeneration } from '../ssh/ssh-connection-generation'
|
||||
|
||||
vi.mock('fs', async () => (await import('./orca-runtime-files-mock-registry')).fsModuleMock())
|
||||
@@ -30,10 +30,6 @@ vi.mock(
|
||||
'../ipc/local-worktree-runtime-options',
|
||||
async () => (await import('./orca-runtime-files-mock-registry')).localWorktreeRuntimeOptionsMock
|
||||
)
|
||||
vi.mock(
|
||||
'../providers/ssh-filesystem-dispatch',
|
||||
async () => (await import('./orca-runtime-files-mock-registry')).sshFilesystemDispatchMock
|
||||
)
|
||||
|
||||
function mockStats(dev: number, ino: number) {
|
||||
return { dev, ino, isDirectory: () => false }
|
||||
@@ -76,7 +72,6 @@ describe('RuntimeFileCommands', () => {
|
||||
'newer Orca client'
|
||||
)
|
||||
|
||||
expect(getSshFilesystemProvider).not.toHaveBeenCalled()
|
||||
expect(renameMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -88,7 +83,6 @@ describe('RuntimeFileCommands', () => {
|
||||
commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts', 0, 'ssh-1')
|
||||
).rejects.toThrow('newer Orca client')
|
||||
|
||||
expect(getSshFilesystemProvider).not.toHaveBeenCalled()
|
||||
expect(renameMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -100,7 +94,6 @@ describe('RuntimeFileCommands', () => {
|
||||
commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts', undefined, undefined, 'local')
|
||||
).rejects.toThrow('Workspace host changed')
|
||||
|
||||
expect(getSshFilesystemProvider).not.toHaveBeenCalled()
|
||||
expect(renameMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -111,7 +104,6 @@ describe('RuntimeFileCommands', () => {
|
||||
commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts', 0, 'ssh-1', 'ssh:ssh-1')
|
||||
).rejects.toThrow('Workspace host changed')
|
||||
|
||||
expect(getSshFilesystemProvider).not.toHaveBeenCalled()
|
||||
expect(renameMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -190,22 +182,17 @@ describe('RuntimeFileCommands', () => {
|
||||
expect(renameMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes runtime remote rename through the SSH no-clobber provider method', async () => {
|
||||
const renameNoClobber = vi.fn().mockResolvedValue(undefined)
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue({ renameNoClobber } as never)
|
||||
it('refuses a matching SSH rename without renaming on this machine', async () => {
|
||||
const { commands, store } = createRuntimeFileCommands()
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
|
||||
|
||||
await commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts', 0, 'ssh-1', 'ssh:ssh-1')
|
||||
|
||||
expect(renameNoClobber).toHaveBeenCalledWith('/repo/old.ts', '/repo/new.ts')
|
||||
expect(store.getRepo).toHaveBeenCalledTimes(1)
|
||||
await expect(
|
||||
commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts', 0, 'ssh-1', 'ssh:ssh-1')
|
||||
).rejects.toThrow(SshHostNotServedError)
|
||||
expect(renameMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a mutation captured for an obsolete SSH connection generation', async () => {
|
||||
const renameNoClobber = vi.fn().mockResolvedValue(undefined)
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue({ renameNoClobber } as never)
|
||||
const { commands, store } = createRuntimeFileCommands()
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
|
||||
setSshConnectionGeneration('ssh-1', 8)
|
||||
@@ -213,12 +200,10 @@ describe('RuntimeFileCommands', () => {
|
||||
await expect(
|
||||
commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts', 7, 'ssh-1', 'ssh:ssh-1')
|
||||
).rejects.toThrow('SSH connection changed')
|
||||
expect(renameNoClobber).not.toHaveBeenCalled()
|
||||
expect(renameMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects nested SSH mutations from clients without generation support', async () => {
|
||||
const renameNoClobber = vi.fn().mockResolvedValue(undefined)
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue({ renameNoClobber } as never)
|
||||
const { commands, store } = createRuntimeFileCommands()
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
|
||||
|
||||
@@ -232,20 +217,17 @@ describe('RuntimeFileCommands', () => {
|
||||
'ssh:ssh-1'
|
||||
)
|
||||
).rejects.toThrow('SSH connection changed')
|
||||
expect(renameNoClobber).not.toHaveBeenCalled()
|
||||
expect(renameMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an equal-generation mutation captured for another SSH target', async () => {
|
||||
const renameNoClobber = vi.fn().mockResolvedValue(undefined)
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue({ renameNoClobber } as never)
|
||||
const { commands, store } = createRuntimeFileCommands()
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-b' })
|
||||
|
||||
await expect(
|
||||
commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts', 0, 'ssh-a', 'ssh:ssh-a')
|
||||
).rejects.toThrow('Workspace host changed')
|
||||
expect(getSshFilesystemProvider).not.toHaveBeenCalled()
|
||||
expect(renameNoClobber).not.toHaveBeenCalled()
|
||||
expect(renameMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a stale SSH expectation after the worktree becomes HUB-local', async () => {
|
||||
@@ -256,16 +238,4 @@ describe('RuntimeFileCommands', () => {
|
||||
).rejects.toThrow('Workspace host changed')
|
||||
expect(renameMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('propagates runtime remote no-clobber rename failures', async () => {
|
||||
const renameNoClobber = vi.fn().mockRejectedValue(new Error('destination exists'))
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue({ renameNoClobber } as never)
|
||||
const { commands, store } = createRuntimeFileCommands()
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
|
||||
|
||||
await expect(
|
||||
commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts', 0, 'ssh-1', 'ssh:ssh-1')
|
||||
).rejects.toThrow('destination exists')
|
||||
expect(renameMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@ import { EventEmitter } from 'node:events'
|
||||
import {
|
||||
bundledRipgrepCommandMock,
|
||||
getLocalGitOptionsForRegisteredWorktreeMock,
|
||||
getSshFilesystemProviderMock,
|
||||
resolveAuthorizedPathMock,
|
||||
wslAwareSpawnMock
|
||||
} from './orca-runtime-files-mock-registry'
|
||||
@@ -34,10 +33,6 @@ vi.mock(
|
||||
vi.mock('../ripgrep/bundled-ripgrep-path', async () =>
|
||||
(await import('./orca-runtime-files-mock-registry')).bundledRipgrepPathModuleMock()
|
||||
)
|
||||
vi.mock(
|
||||
'../providers/ssh-filesystem-dispatch',
|
||||
async () => (await import('./orca-runtime-files-mock-registry')).sshFilesystemDispatchMock
|
||||
)
|
||||
|
||||
type MockRuntimeSearchChild = EventEmitter & {
|
||||
stdout: EventEmitter & { setEncoding: ReturnType<typeof vi.fn> }
|
||||
@@ -63,18 +58,6 @@ async function flushRuntimeSearchMicrotasks(): Promise<void> {
|
||||
describe('RuntimeFileCommands', () => {
|
||||
useRuntimeFileCommandsLifecycle()
|
||||
|
||||
it('forwards cancellation across a nested SSH route', async () => {
|
||||
const controller = new AbortController()
|
||||
const search = vi.fn().mockResolvedValue({ files: [], totalMatches: 0, truncated: false })
|
||||
getSshFilesystemProviderMock.mockReturnValue({ search })
|
||||
const { commands } = createRuntimeFileCommands({ hostId: 'ssh:ssh-1' })
|
||||
await commands.searchRuntimeFiles('id:wt-1', { query: 'needle' }, { signal: controller.signal })
|
||||
expect(search).toHaveBeenCalledWith(
|
||||
{ rootPath: '/repo', query: 'needle' },
|
||||
{ signal: controller.signal }
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps concurrent runtime clients on the same root independent during cancellation', async () => {
|
||||
const { commands } = createRuntimeFileCommands()
|
||||
const firstChild = createRuntimeSearchChild()
|
||||
@@ -147,26 +130,6 @@ describe('RuntimeFileCommands', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps byte-budgeted legacy listings count-bounded across an SSH hop', async () => {
|
||||
const listFiles = vi.fn().mockResolvedValue(['src/index.ts'])
|
||||
getSshFilesystemProviderMock.mockReturnValue({ listFiles })
|
||||
const { commands } = createRuntimeFileCommands({
|
||||
resolveRuntimeFileTarget: vi.fn(async () => ({
|
||||
worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo' },
|
||||
executionHostId: 'ssh:ssh-1'
|
||||
}))
|
||||
})
|
||||
|
||||
await expect(commands.listRuntimeFiles('id:wt-1', { maxContentBytes: 1024 })).resolves.toEqual([
|
||||
'src/index.ts'
|
||||
])
|
||||
expect(listFiles).toHaveBeenCalledWith('/repo', {
|
||||
excludePaths: undefined,
|
||||
maxResults: 20_001,
|
||||
signal: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('settles and detaches runtime rg searches when timeout kill is ignored', async () => {
|
||||
const resolveRuntimeFileTarget = vi.fn(async () => ({
|
||||
worktree: {
|
||||
@@ -327,30 +290,4 @@ describe('RuntimeFileCommands', () => {
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps legacy SSH Quick Open replies within the frame-sized result bound', async () => {
|
||||
const resolveRuntimeFileTarget = vi.fn(async () => ({
|
||||
worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo' },
|
||||
executionHostId: 'ssh:ssh-1'
|
||||
}))
|
||||
const { commands } = createRuntimeFileCommands({ resolveRuntimeFileTarget })
|
||||
const listFiles = vi.fn(async () => ['src/target.ts'])
|
||||
getSshFilesystemProviderMock.mockReturnValue({
|
||||
supportsQuickOpenSearch: vi.fn(async () => false),
|
||||
listFiles
|
||||
})
|
||||
|
||||
await expect(commands.searchQuickOpenFilePaths('id:wt-1', 'target', 32)).resolves.toMatchObject(
|
||||
{
|
||||
files: [{ relativePath: 'src/target.ts' }],
|
||||
totalCount: 1,
|
||||
truncated: false
|
||||
}
|
||||
)
|
||||
expect(listFiles).toHaveBeenCalledWith('/repo', {
|
||||
excludePaths: undefined,
|
||||
maxResults: 32,
|
||||
signal: undefined
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,330 +0,0 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { enoent, openMock, resolveAuthorizedPathMock } from './orca-runtime-files-mock-registry'
|
||||
import {
|
||||
createRuntimeFileCommands,
|
||||
useRuntimeFileCommandsLifecycle
|
||||
} from './orca-runtime-files-test-harness'
|
||||
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
import { FileRangeReadUnsupportedError } from '../providers/types'
|
||||
import { MAX_FILE_RANGE_READ_BYTES } from '../../shared/file-range-read'
|
||||
import { BROWSER_CLIENT_FILE_CHANNEL_CHUNK_MAX_BYTES } from '../../shared/browser-client-file-channel-protocol'
|
||||
import type { RuntimeFileCommands } from './orca-runtime-files'
|
||||
|
||||
vi.mock('fs', async () => (await import('./orca-runtime-files-mock-registry')).fsModuleMock())
|
||||
vi.mock('fs/promises', async () =>
|
||||
(await import('./orca-runtime-files-mock-registry')).fsPromisesModuleMock()
|
||||
)
|
||||
vi.mock(
|
||||
'./file-watcher-host',
|
||||
async () => (await import('./orca-runtime-files-mock-registry')).fileWatcherHostMock
|
||||
)
|
||||
vi.mock('../ipc/filesystem-auth', async () =>
|
||||
(await import('./orca-runtime-files-mock-registry')).filesystemAuthModuleMock()
|
||||
)
|
||||
vi.mock('../git/runner', async () =>
|
||||
(await import('./orca-runtime-files-mock-registry')).gitRunnerModuleMock()
|
||||
)
|
||||
vi.mock(
|
||||
'../ipc/local-worktree-runtime-options',
|
||||
async () => (await import('./orca-runtime-files-mock-registry')).localWorktreeRuntimeOptionsMock
|
||||
)
|
||||
vi.mock(
|
||||
'../providers/ssh-filesystem-dispatch',
|
||||
async () => (await import('./orca-runtime-files-mock-registry')).sshFilesystemDispatchMock
|
||||
)
|
||||
|
||||
/**
|
||||
* LCG bytes, whose period is far past any fixture here. An index-derived pattern repeats on a
|
||||
* power-of-two stride that divides the chunk and window sizes, which makes a re-read of the wrong
|
||||
* window compare equal to the right one.
|
||||
*/
|
||||
function remoteFileBytes(size: number): Buffer {
|
||||
const bytes = Buffer.alloc(size)
|
||||
let state = 0x2f6e2b1
|
||||
for (let index = 0; index < size; index += 1) {
|
||||
state = (state * 1664525 + 1013904223) >>> 0
|
||||
bytes[index] = state >>> 24
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
function sshCommands() {
|
||||
return createRuntimeFileCommands({
|
||||
path: '/remote/repo',
|
||||
resolveRuntimeFileTarget: vi.fn(async () => ({
|
||||
worktree: { id: 'wt-1', repoId: 'repo-1', path: '/remote/repo' },
|
||||
executionHostId: 'ssh:ssh-1'
|
||||
}))
|
||||
}).commands
|
||||
}
|
||||
|
||||
function installSshFile(
|
||||
contents: Buffer,
|
||||
overrides?: { readFileRange?: unknown; stat?: unknown }
|
||||
): { readFileRange: ReturnType<typeof vi.fn>; stat: ReturnType<typeof vi.fn> } {
|
||||
const readFileRange = vi.fn(async (_filePath: string, position: number, length: number) => {
|
||||
const bytes = contents.subarray(position, position + length)
|
||||
return { bytes, bytesRead: bytes.byteLength }
|
||||
})
|
||||
const stat = vi.fn(async () => ({ size: contents.byteLength, type: 'file' as const, mtime: 0 }))
|
||||
const provider = {
|
||||
stat: overrides?.stat ?? stat,
|
||||
...(overrides && 'readFileRange' in overrides
|
||||
? { readFileRange: overrides.readFileRange }
|
||||
: { readFileRange })
|
||||
}
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue(provider as never)
|
||||
return { readFileRange, stat }
|
||||
}
|
||||
|
||||
/** Mirrors the `browser.upload` staging loop in browser-client-upload-transfer. */
|
||||
async function drainUploadChunks(
|
||||
commands: RuntimeFileCommands,
|
||||
relativePath: string
|
||||
): Promise<Buffer> {
|
||||
const parts: Buffer[] = []
|
||||
let offset = 0
|
||||
for (let iterations = 0; ; iterations += 1) {
|
||||
expect(iterations).toBeLessThan(64)
|
||||
const chunk = await commands.readFileExplorerChunk(
|
||||
'id:wt-1',
|
||||
relativePath,
|
||||
offset,
|
||||
BROWSER_CLIENT_FILE_CHANNEL_CHUNK_MAX_BYTES
|
||||
)
|
||||
const payload = Buffer.from(chunk.contentBase64, 'base64')
|
||||
// The real loop rejects a chunk whose count disagrees with its payload before staging it.
|
||||
expect(payload.byteLength).toBe(chunk.bytesRead)
|
||||
parts.push(payload)
|
||||
offset += chunk.bytesRead
|
||||
if (chunk.eof) {
|
||||
return Buffer.concat(parts)
|
||||
}
|
||||
expect(chunk.bytesRead).toBeGreaterThan(0)
|
||||
}
|
||||
}
|
||||
|
||||
/** Digest compare: a failed `toEqual` on a few-hundred-KB Buffer spends minutes rendering a diff. */
|
||||
function expectSameBytes(actual: Buffer, expected: Buffer): void {
|
||||
const digest = (bytes: Buffer) => ({
|
||||
byteLength: bytes.byteLength,
|
||||
sha256: createHash('sha256').update(bytes).digest('hex')
|
||||
})
|
||||
expect(digest(actual)).toEqual(digest(expected))
|
||||
}
|
||||
|
||||
/** The clamp and the cap together promise every request lands inside the statted file. */
|
||||
function expectRequestsWithinFile(readFileRange: ReturnType<typeof vi.fn>, fileSize: number): void {
|
||||
expect(readFileRange.mock.calls.length).toBeGreaterThan(0)
|
||||
for (const [, position, length] of readFileRange.mock.calls) {
|
||||
expect(length).toBeLessThanOrEqual(MAX_FILE_RANGE_READ_BYTES)
|
||||
expect(position + length).toBeLessThanOrEqual(fileSize)
|
||||
}
|
||||
}
|
||||
|
||||
// A size that is not a multiple of either the chunk or the window, so a page served from the wrong
|
||||
// offset cannot land on an identical-looking boundary.
|
||||
const REMOTE_FILE_BYTES = BROWSER_CLIENT_FILE_CHANNEL_CHUNK_MAX_BYTES * 3 + 4321
|
||||
// files.readChunk window the renderer's remote download loop uses (REMOTE_DOWNLOAD_CHUNK_BYTES).
|
||||
const REMOTE_DOWNLOAD_WINDOW_BYTES = 384 * 1024
|
||||
|
||||
describe('RuntimeFileCommands.readFileExplorerChunk over SSH', () => {
|
||||
useRuntimeFileCommandsLifecycle()
|
||||
|
||||
it('reassembles a multi-chunk SSH-workspace file byte-identically', async () => {
|
||||
const contents = remoteFileBytes(REMOTE_FILE_BYTES)
|
||||
const { readFileRange } = installSshFile(contents)
|
||||
|
||||
expectSameBytes(await drainUploadChunks(sshCommands(), 'docs/report.pdf'), contents)
|
||||
expect(readFileRange).toHaveBeenCalledWith(
|
||||
'/remote/repo/docs/report.pdf',
|
||||
0,
|
||||
expect.any(Number)
|
||||
)
|
||||
expectRequestsWithinFile(readFileRange, contents.byteLength)
|
||||
})
|
||||
|
||||
it('ends on the chunk boundary when the file size is an exact chunk multiple', async () => {
|
||||
const contents = remoteFileBytes(BROWSER_CLIENT_FILE_CHANNEL_CHUNK_MAX_BYTES * 2)
|
||||
installSshFile(contents)
|
||||
const commands = sshCommands()
|
||||
|
||||
const first = await commands.readFileExplorerChunk(
|
||||
'id:wt-1',
|
||||
'archive.zip',
|
||||
0,
|
||||
BROWSER_CLIENT_FILE_CHANNEL_CHUNK_MAX_BYTES
|
||||
)
|
||||
const second = await commands.readFileExplorerChunk(
|
||||
'id:wt-1',
|
||||
'archive.zip',
|
||||
BROWSER_CLIENT_FILE_CHANNEL_CHUNK_MAX_BYTES,
|
||||
BROWSER_CLIENT_FILE_CHANNEL_CHUNK_MAX_BYTES
|
||||
)
|
||||
|
||||
expect(first.eof).toBe(false)
|
||||
expect(second.bytesRead).toBe(BROWSER_CLIENT_FILE_CHANNEL_CHUNK_MAX_BYTES)
|
||||
expect(second.eof).toBe(true)
|
||||
expectSameBytes(
|
||||
Buffer.concat([
|
||||
Buffer.from(first.contentBase64, 'base64'),
|
||||
Buffer.from(second.contentBase64, 'base64')
|
||||
]),
|
||||
contents
|
||||
)
|
||||
})
|
||||
|
||||
it('pages a window wider than the host range cap instead of asking for an over-cap read', async () => {
|
||||
const contents = remoteFileBytes(REMOTE_FILE_BYTES)
|
||||
const { readFileRange } = installSshFile(contents)
|
||||
|
||||
const chunk = await sshCommands().readFileExplorerChunk(
|
||||
'id:wt-1',
|
||||
'big.bin',
|
||||
0,
|
||||
REMOTE_FILE_BYTES
|
||||
)
|
||||
|
||||
expectSameBytes(Buffer.from(chunk.contentBase64, 'base64'), contents)
|
||||
expect(chunk.bytesRead).toBe(REMOTE_FILE_BYTES)
|
||||
expect(chunk.eof).toBe(true)
|
||||
expect(readFileRange.mock.calls.map((call) => [call[1], call[2]])).toEqual([
|
||||
[0, MAX_FILE_RANGE_READ_BYTES],
|
||||
[MAX_FILE_RANGE_READ_BYTES, REMOTE_FILE_BYTES - MAX_FILE_RANGE_READ_BYTES]
|
||||
])
|
||||
})
|
||||
|
||||
// The upload chunk (128 KiB) never pages; the renderer's 384 KiB download window is the caller
|
||||
// that does, and it reaches SSH worktrees only because this seam stopped throwing.
|
||||
it('pages the renderer download window into one capped read and one remainder', async () => {
|
||||
const contents = remoteFileBytes(REMOTE_FILE_BYTES)
|
||||
const { readFileRange } = installSshFile(contents)
|
||||
|
||||
const chunk = await sshCommands().readFileExplorerChunk(
|
||||
'id:wt-1',
|
||||
'big.bin',
|
||||
0,
|
||||
REMOTE_DOWNLOAD_WINDOW_BYTES
|
||||
)
|
||||
|
||||
expectSameBytes(
|
||||
Buffer.from(chunk.contentBase64, 'base64'),
|
||||
contents.subarray(0, REMOTE_DOWNLOAD_WINDOW_BYTES)
|
||||
)
|
||||
expect(chunk.eof).toBe(false)
|
||||
expect(readFileRange.mock.calls.map((call) => [call[1], call[2]])).toEqual([
|
||||
[0, MAX_FILE_RANGE_READ_BYTES],
|
||||
[MAX_FILE_RANGE_READ_BYTES, REMOTE_DOWNLOAD_WINDOW_BYTES - MAX_FILE_RANGE_READ_BYTES]
|
||||
])
|
||||
})
|
||||
|
||||
it('clamps a window that overshoots EOF instead of requesting past the file', async () => {
|
||||
const contents = remoteFileBytes(MAX_FILE_RANGE_READ_BYTES + 4321)
|
||||
const { readFileRange } = installSshFile(contents)
|
||||
|
||||
const chunk = await sshCommands().readFileExplorerChunk(
|
||||
'id:wt-1',
|
||||
'big.bin',
|
||||
0,
|
||||
REMOTE_DOWNLOAD_WINDOW_BYTES
|
||||
)
|
||||
|
||||
expectSameBytes(Buffer.from(chunk.contentBase64, 'base64'), contents)
|
||||
expect(chunk.eof).toBe(true)
|
||||
expectRequestsWithinFile(readFileRange, contents.byteLength)
|
||||
})
|
||||
|
||||
it('never reads past the requested window', async () => {
|
||||
const contents = remoteFileBytes(4096)
|
||||
installSshFile(contents)
|
||||
|
||||
const chunk = await sshCommands().readFileExplorerChunk('id:wt-1', 'small.bin', 1000, 500)
|
||||
|
||||
expect(chunk.bytesRead).toBe(500)
|
||||
expect(Buffer.from(chunk.contentBase64, 'base64')).toEqual(contents.subarray(1000, 1500))
|
||||
expect(chunk.eof).toBe(false)
|
||||
})
|
||||
|
||||
// Not a stall guarantee: the caller's next chunk re-stats the now-smaller file and ends the
|
||||
// transfer short, exactly as the local branch does. Only the single chunk's flag is pinned here.
|
||||
it('stops on a window the host could not fill and reports it against the stale size', async () => {
|
||||
const contents = remoteFileBytes(4096)
|
||||
installSshFile(contents, {
|
||||
readFileRange: vi.fn(async () => ({ bytes: contents.subarray(0, 10), bytesRead: 10 }))
|
||||
})
|
||||
|
||||
const chunk = await sshCommands().readFileExplorerChunk('id:wt-1', 'short.bin', 0, 4096)
|
||||
|
||||
expect(chunk.bytesRead).toBe(10)
|
||||
expect(chunk.eof).toBe(false)
|
||||
})
|
||||
|
||||
it('fails loudly when the SSH host cannot serve ranged reads', async () => {
|
||||
installSshFile(remoteFileBytes(4096), {
|
||||
readFileRange: vi.fn(async () => {
|
||||
throw new FileRangeReadUnsupportedError()
|
||||
})
|
||||
})
|
||||
|
||||
await expect(
|
||||
sshCommands().readFileExplorerChunk('id:wt-1', 'docs/report.pdf', 0, 1024)
|
||||
).rejects.toThrow('ranged file reads')
|
||||
})
|
||||
|
||||
it('fails loudly when the SSH provider has no ranged-read method at all', async () => {
|
||||
installSshFile(remoteFileBytes(4096), { readFileRange: undefined })
|
||||
|
||||
await expect(
|
||||
sshCommands().readFileExplorerChunk('id:wt-1', 'docs/report.pdf', 0, 1024)
|
||||
).rejects.toThrow('ranged file reads')
|
||||
})
|
||||
|
||||
it('fails loudly when the remote file is missing', async () => {
|
||||
const { readFileRange } = installSshFile(remoteFileBytes(4096), {
|
||||
stat: vi.fn(async () => {
|
||||
throw enoent()
|
||||
})
|
||||
})
|
||||
|
||||
await expect(
|
||||
sshCommands().readFileExplorerChunk('id:wt-1', 'docs/missing.pdf', 0, 1024)
|
||||
).rejects.toThrow('ENOENT')
|
||||
expect(readFileRange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a remote directory before reading', async () => {
|
||||
const { readFileRange } = installSshFile(remoteFileBytes(0), {
|
||||
stat: vi.fn(async () => ({ size: 0, type: 'directory' as const, mtime: 0 }))
|
||||
})
|
||||
|
||||
await expect(sshCommands().readFileExplorerChunk('id:wt-1', 'docs', 0, 1024)).rejects.toThrow(
|
||||
'Cannot download a directory'
|
||||
)
|
||||
expect(readFileRange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still serves a non-SSH worktree from the local file handle', async () => {
|
||||
const contents = remoteFileBytes(2048)
|
||||
const { commands } = createRuntimeFileCommands()
|
||||
resolveAuthorizedPathMock.mockResolvedValue('/repo/local.bin')
|
||||
const close = vi.fn(async () => undefined)
|
||||
openMock.mockResolvedValue({
|
||||
stat: vi.fn(async () => ({ isFile: () => true, size: contents.byteLength })),
|
||||
read: vi.fn(
|
||||
async (buffer: Buffer, bufferOffset: number, length: number, position: number) => {
|
||||
const bytesRead = contents.copy(buffer, bufferOffset, position, position + length)
|
||||
return { bytesRead }
|
||||
}
|
||||
),
|
||||
close
|
||||
})
|
||||
|
||||
const chunk = await commands.readFileExplorerChunk('id:wt-1', 'local.bin', 0, 1024)
|
||||
|
||||
expect(Buffer.from(chunk.contentBase64, 'base64')).toEqual(contents.subarray(0, 1024))
|
||||
expect(chunk.eof).toBe(false)
|
||||
expect(close).toHaveBeenCalled()
|
||||
expect(getSshFilesystemProvider).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,6 @@ import { link, mkdtemp, readFile, realpath, symlink, writeFile } from 'node:fs/p
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { enoent, resolveAuthorizedPathMock, statMock } from './orca-runtime-files-mock-registry'
|
||||
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
import {
|
||||
createRuntimeFileCommands,
|
||||
useRuntimeFileCommandsLifecycle
|
||||
@@ -32,10 +31,6 @@ vi.mock(
|
||||
'../ipc/local-worktree-runtime-options',
|
||||
async () => (await import('./orca-runtime-files-mock-registry')).localWorktreeRuntimeOptionsMock
|
||||
)
|
||||
vi.mock(
|
||||
'../providers/ssh-filesystem-dispatch',
|
||||
async () => (await import('./orca-runtime-files-mock-registry')).sshFilesystemDispatchMock
|
||||
)
|
||||
|
||||
describe('RuntimeFileCommands', () => {
|
||||
useRuntimeFileCommandsLifecycle()
|
||||
@@ -376,77 +371,9 @@ describe('RuntimeFileCommands', () => {
|
||||
expect(statMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the canonical remote temp artifact path for the exact grant', async () => {
|
||||
const { commands, store } = createRuntimeFileCommands({ path: '/repo' })
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
|
||||
const stat = vi.fn().mockResolvedValue({ type: 'file', size: 2, mtime: 3 })
|
||||
const realpath = vi.fn(async (p: string) =>
|
||||
p === '/tmp/link-result.json' ? '/tmp/result.json' : p
|
||||
)
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue({ stat, realpath } as never)
|
||||
|
||||
const result = await resolveTerminalArtifactPath(commands, '/tmp/link-result.json')
|
||||
|
||||
expect(stat).toHaveBeenCalledWith('/tmp/result.json')
|
||||
expect(result).toMatchObject({
|
||||
relativePath: null,
|
||||
absolutePath: '/tmp/result.json',
|
||||
exists: true,
|
||||
openTarget: {
|
||||
kind: 'absolute-file',
|
||||
provider: 'ssh',
|
||||
absolutePath: '/tmp/result.json'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('does not grant hard-linked remote temp artifacts', async () => {
|
||||
const { commands, store } = createRuntimeFileCommands({ path: '/repo' })
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
|
||||
const stat = vi.fn().mockResolvedValue({ type: 'file', size: 2, mtime: 3, nlink: 2 })
|
||||
const realpath = vi.fn(async (p: string) => p)
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue({ stat, realpath } as never)
|
||||
|
||||
const result = await resolveTerminalArtifactPath(commands, '/tmp/result.json')
|
||||
|
||||
expect(result).toMatchObject({
|
||||
relativePath: null,
|
||||
absolutePath: '/tmp/result.json',
|
||||
exists: false
|
||||
})
|
||||
expect(result.openTarget).toBeUndefined()
|
||||
})
|
||||
|
||||
it('allows canonical remote macOS private temp artifacts', async () => {
|
||||
const { commands, store } = createRuntimeFileCommands({ path: '/repo' })
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
|
||||
const stat = vi.fn().mockResolvedValue({ type: 'file', size: 2, mtime: 3 })
|
||||
const realpath = vi.fn(async (p: string) => p)
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue({ stat, realpath } as never)
|
||||
|
||||
const result = await resolveTerminalArtifactPath(commands, '/private/tmp/result.json')
|
||||
|
||||
expect(stat).toHaveBeenCalledWith('/private/tmp/result.json')
|
||||
expect(result).toMatchObject({
|
||||
relativePath: null,
|
||||
absolutePath: '/private/tmp/result.json',
|
||||
exists: true,
|
||||
openTarget: {
|
||||
kind: 'absolute-file',
|
||||
provider: 'ssh',
|
||||
absolutePath: '/private/tmp/result.json'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('does not grant remote temp artifacts that resolve outside allowed temp roots', async () => {
|
||||
const { commands, store } = createRuntimeFileCommands({ path: '/repo' })
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
|
||||
const stat = vi.fn()
|
||||
const realpath = vi.fn(async (p: string) =>
|
||||
p === '/tmp/link-result.json' ? '/home/me/.ssh/config' : p
|
||||
)
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue({ stat, realpath } as never)
|
||||
|
||||
const result = await commands.resolveTerminalPath(
|
||||
'id:wt-1',
|
||||
@@ -462,7 +389,7 @@ describe('RuntimeFileCommands', () => {
|
||||
exists: false,
|
||||
isDirectory: false
|
||||
})
|
||||
expect(stat).not.toHaveBeenCalled()
|
||||
expect(statMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
resolveTerminalArtifactPath,
|
||||
useTerminalArtifactTempFiles
|
||||
} from './orca-runtime-files-terminal-artifact-fixtures'
|
||||
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
import { SshHostNotServedError } from '../providers/ssh-host-not-served-error'
|
||||
import { RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES } from './orca-runtime-files'
|
||||
import type { RuntimeFileCommands } from './orca-runtime-files'
|
||||
@@ -34,10 +33,6 @@ vi.mock(
|
||||
'../ipc/local-worktree-runtime-options',
|
||||
async () => (await import('./orca-runtime-files-mock-registry')).localWorktreeRuntimeOptionsMock
|
||||
)
|
||||
vi.mock(
|
||||
'../providers/ssh-filesystem-dispatch',
|
||||
async () => (await import('./orca-runtime-files-mock-registry')).sshFilesystemDispatchMock
|
||||
)
|
||||
|
||||
describe('RuntimeFileCommands', () => {
|
||||
useRuntimeFileCommandsLifecycle()
|
||||
@@ -45,42 +40,6 @@ describe('RuntimeFileCommands', () => {
|
||||
describe('resolveTerminalPath', () => {
|
||||
const { tempFile } = useTerminalArtifactTempFiles()
|
||||
|
||||
function createRemoteTerminalArtifactGrantFixture(
|
||||
artifactPath = '/tmp/result.json',
|
||||
nativeChat = false
|
||||
) {
|
||||
const { commands, store } = createRuntimeFileCommands({
|
||||
path: '/repo',
|
||||
...(nativeChat ? { hasRecentNativeChatOutputPath: vi.fn(() => true) } : {})
|
||||
})
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
|
||||
let realArtifactPath = artifactPath
|
||||
let artifactStat = { type: 'file', size: 11, mtime: 3 }
|
||||
const stat = vi.fn(async () => artifactStat)
|
||||
const readTerminalArtifact = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ content: '{"ok":true}', isBinary: false })
|
||||
const writeTerminalArtifact = vi.fn().mockResolvedValue({ type: 'file', size: 12, mtime: 4 })
|
||||
const realpath = vi.fn(async (p: string) => (p === artifactPath ? realArtifactPath : p))
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue({
|
||||
stat,
|
||||
readTerminalArtifact,
|
||||
realpath,
|
||||
writeTerminalArtifact
|
||||
} as never)
|
||||
return {
|
||||
commands,
|
||||
readTerminalArtifact,
|
||||
writeTerminalArtifact,
|
||||
moveArtifactTarget: (nextPath: string) => {
|
||||
realArtifactPath = nextPath
|
||||
},
|
||||
replaceArtifact: () => {
|
||||
artifactStat = { type: 'file', size: 12, mtime: 4 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRemoteNativeChatArtifact(commands: RuntimeFileCommands, artifactPath: string) {
|
||||
return commands.resolveTerminalPath('id:wt-1', artifactPath, null, 'client-a', null, true, {
|
||||
tabId: 'tab-1',
|
||||
@@ -405,129 +364,16 @@ describe('RuntimeFileCommands', () => {
|
||||
await expect(readFile(artifactPath, 'utf8')).resolves.toBe('%PDF text-looking bytes')
|
||||
})
|
||||
|
||||
it('rejects remote binary terminal artifact writes before changing the file', async () => {
|
||||
const { commands, store } = createRuntimeFileCommands({ path: '/repo' })
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
|
||||
const stat = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ type: 'file', size: 4, mtimeMs: 3, isDirectory: () => false })
|
||||
const writeTerminalArtifact = vi.fn().mockRejectedValue(new Error('binary_file'))
|
||||
const realpath = vi.fn(async (p: string) => p)
|
||||
const writeFile = vi.fn()
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue({
|
||||
stat,
|
||||
realpath,
|
||||
writeFile,
|
||||
writeTerminalArtifact
|
||||
} as never)
|
||||
|
||||
const result = await resolveTerminalArtifactPath(commands, '/tmp/result.txt')
|
||||
const grantId = result.openTarget?.kind === 'absolute-file' ? result.openTarget.grantId : ''
|
||||
|
||||
await expect(
|
||||
commands.writeTerminalArtifactFile(
|
||||
'id:wt-1',
|
||||
grantId,
|
||||
'/tmp/result.txt',
|
||||
'not binary anymore',
|
||||
'client-a'
|
||||
)
|
||||
).rejects.toThrow('binary_file')
|
||||
expect(writeFile).not.toHaveBeenCalled()
|
||||
expect(writeTerminalArtifact).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a native-chat artifact on an SSH target as not served', async () => {
|
||||
const { commands, readTerminalArtifact } = createRemoteTerminalArtifactGrantFixture(
|
||||
'/home/me/report.json',
|
||||
true
|
||||
)
|
||||
const { commands, store } = createRuntimeFileCommands({
|
||||
path: '/repo',
|
||||
hasRecentNativeChatOutputPath: vi.fn(() => true)
|
||||
})
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
|
||||
|
||||
await expect(
|
||||
resolveRemoteNativeChatArtifact(commands, '/home/me/report.json')
|
||||
).rejects.toThrow(SshHostNotServedError)
|
||||
expect(readTerminalArtifact).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects remote terminal artifact reads when a grant no longer resolves to the granted path', async () => {
|
||||
const { commands, readTerminalArtifact, moveArtifactTarget } =
|
||||
createRemoteTerminalArtifactGrantFixture()
|
||||
const result = await resolveTerminalArtifactPath(commands, '/tmp/result.json')
|
||||
const target = absoluteFileTarget(result)
|
||||
|
||||
moveArtifactTarget('/home/me/.ssh/config')
|
||||
|
||||
await expect(
|
||||
commands.readTerminalArtifactFile(
|
||||
'id:wt-1',
|
||||
target.grantId,
|
||||
target.absolutePath,
|
||||
'client-a'
|
||||
)
|
||||
).rejects.toThrow('terminal_file_grant_stale')
|
||||
expect(readTerminalArtifact).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects remote terminal artifact previews when a grant no longer resolves to the granted path', async () => {
|
||||
const { commands, readTerminalArtifact, moveArtifactTarget } =
|
||||
createRemoteTerminalArtifactGrantFixture('/tmp/result.png')
|
||||
const result = await resolveTerminalArtifactPath(commands, '/tmp/result.png')
|
||||
const target = absoluteFileTarget(result)
|
||||
|
||||
moveArtifactTarget('/tmp/other.png')
|
||||
|
||||
await expect(
|
||||
commands.readTerminalArtifactPreview(
|
||||
'id:wt-1',
|
||||
target.grantId,
|
||||
target.absolutePath,
|
||||
'client-a'
|
||||
)
|
||||
).rejects.toThrow('terminal_file_grant_stale')
|
||||
expect(readTerminalArtifact).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects additive remote terminal preview fields beyond the request budget', async () => {
|
||||
const { commands, readTerminalArtifact } =
|
||||
createRemoteTerminalArtifactGrantFixture('/tmp/result.png')
|
||||
const result = await resolveTerminalArtifactPath(commands, '/tmp/result.png')
|
||||
const target = absoluteFileTarget(result)
|
||||
readTerminalArtifact.mockResolvedValue({
|
||||
content: 'a',
|
||||
isBinary: true,
|
||||
futureMetadata: 'x'.repeat(128)
|
||||
})
|
||||
|
||||
await expect(
|
||||
commands.readTerminalArtifactPreview(
|
||||
'id:wt-1',
|
||||
target.grantId,
|
||||
target.absolutePath,
|
||||
'client-a',
|
||||
128
|
||||
)
|
||||
).rejects.toThrow('file_too_large')
|
||||
})
|
||||
|
||||
it('rejects remote terminal artifact writes when a grant no longer resolves to the granted path', async () => {
|
||||
const { commands, readTerminalArtifact, writeTerminalArtifact, moveArtifactTarget } =
|
||||
createRemoteTerminalArtifactGrantFixture()
|
||||
const result = await resolveTerminalArtifactPath(commands, '/tmp/result.json')
|
||||
const target = absoluteFileTarget(result)
|
||||
|
||||
moveArtifactTarget('/home/me/.ssh/config')
|
||||
|
||||
await expect(
|
||||
commands.writeTerminalArtifactFile(
|
||||
'id:wt-1',
|
||||
target.grantId,
|
||||
target.absolutePath,
|
||||
'{"ok":false}',
|
||||
'client-a'
|
||||
)
|
||||
).rejects.toThrow('terminal_file_grant_stale')
|
||||
expect(readTerminalArtifact).not.toHaveBeenCalled()
|
||||
expect(writeTerminalArtifact).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { SshHostNotServedError } from '../providers/ssh-host-not-served-error'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { realpath } from 'node:fs/promises'
|
||||
import { openMock, resolveAuthorizedPathMock, statMock } from './orca-runtime-files-mock-registry'
|
||||
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
import {
|
||||
createRuntimeFileCommands,
|
||||
useRuntimeFileCommandsLifecycle
|
||||
@@ -30,10 +30,6 @@ vi.mock(
|
||||
'../ipc/local-worktree-runtime-options',
|
||||
async () => (await import('./orca-runtime-files-mock-registry')).localWorktreeRuntimeOptionsMock
|
||||
)
|
||||
vi.mock(
|
||||
'../providers/ssh-filesystem-dispatch',
|
||||
async () => (await import('./orca-runtime-files-mock-registry')).sshFilesystemDispatchMock
|
||||
)
|
||||
|
||||
describe('RuntimeFileCommands', () => {
|
||||
useRuntimeFileCommandsLifecycle()
|
||||
@@ -182,7 +178,7 @@ describe('RuntimeFileCommands', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('opens host-qualified remote POSIX terminal links when the source terminal verified the host', async () => {
|
||||
it('refuses host-qualified terminal links on an SSH workspace instead of reading this machine', async () => {
|
||||
const resolveTerminalFileUriHostname = vi.fn(() => 'remote-host')
|
||||
const hasRecentTerminalOutputPath = vi.fn(() => true)
|
||||
const { commands, store } = createRuntimeFileCommands({
|
||||
@@ -191,69 +187,25 @@ describe('RuntimeFileCommands', () => {
|
||||
hasRecentTerminalOutputPath
|
||||
})
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
|
||||
const stat = vi.fn().mockResolvedValue({ type: 'file', size: 2, mtime: 3 })
|
||||
const realpath = vi.fn(async (p: string) => p)
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue({ stat, realpath } as never)
|
||||
|
||||
const result = await resolveTerminalArtifactPath(commands, '//remote-host/tmp/result.json')
|
||||
|
||||
expect(resolveTerminalFileUriHostname).toHaveBeenCalledWith('term-1')
|
||||
expect(hasRecentTerminalOutputPath).toHaveBeenCalledWith(
|
||||
'term-1',
|
||||
'//remote-host/tmp/result.json',
|
||||
'/tmp/result.json'
|
||||
)
|
||||
expect(stat).toHaveBeenCalledWith('/tmp/result.json')
|
||||
expect(result).toMatchObject({
|
||||
relativePath: null,
|
||||
absolutePath: '/tmp/result.json',
|
||||
exists: true,
|
||||
openTarget: {
|
||||
kind: 'absolute-file',
|
||||
provider: 'ssh',
|
||||
absolutePath: '/tmp/result.json'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('opens host-qualified Windows SSH worktree file URLs with a drive path', async () => {
|
||||
const resolveTerminalFileUriHostname = vi.fn(() => 'remote-host')
|
||||
const { commands, store } = createRuntimeFileCommands({
|
||||
path: 'C:/Users/me/repo',
|
||||
resolveTerminalFileUriHostname
|
||||
})
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
|
||||
const stat = vi.fn().mockResolvedValue({ type: 'file', size: 2, mtime: 3 })
|
||||
const realpath = vi.fn(async (p: string) => p)
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue({ stat, realpath } as never)
|
||||
|
||||
const result = await commands.resolveTerminalPath(
|
||||
'id:wt-1',
|
||||
'//remote-host/C:/Users/me/repo/src/app.ts',
|
||||
null,
|
||||
'client-a',
|
||||
'term-1'
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
relativePath: 'src/app.ts',
|
||||
absolutePath: 'C:/Users/me/repo/src/app.ts',
|
||||
exists: true,
|
||||
openTarget: {
|
||||
kind: 'worktree-file',
|
||||
provider: 'ssh',
|
||||
relativePath: 'src/app.ts',
|
||||
absolutePath: 'C:/Users/me/repo/src/app.ts'
|
||||
}
|
||||
})
|
||||
await expect(
|
||||
resolveTerminalArtifactPath(commands, '//remote-host/tmp/result.json')
|
||||
).rejects.toThrow(SshHostNotServedError)
|
||||
await expect(
|
||||
commands.resolveTerminalPath(
|
||||
'id:wt-1',
|
||||
'//remote-host/C:/Users/me/repo/src/app.ts',
|
||||
null,
|
||||
'client-a',
|
||||
'term-1'
|
||||
)
|
||||
).rejects.toThrow(SshHostNotServedError)
|
||||
expect(hasRecentTerminalOutputPath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects host-qualified remote POSIX terminal links without a verified host match', async () => {
|
||||
const { commands, store } = createRuntimeFileCommands({ path: '/home/me/repo' })
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
|
||||
const stat = vi.fn().mockResolvedValue({ type: 'file', size: 2, mtime: 3 })
|
||||
const realpath = vi.fn(async (p: string) => p)
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue({ stat, realpath } as never)
|
||||
|
||||
const result = await commands.resolveTerminalPath(
|
||||
'id:wt-1',
|
||||
@@ -262,7 +214,7 @@ describe('RuntimeFileCommands', () => {
|
||||
'client-a'
|
||||
)
|
||||
|
||||
expect(stat).not.toHaveBeenCalled()
|
||||
expect(statMock).not.toHaveBeenCalled()
|
||||
expect(result).toEqual({
|
||||
worktree: 'wt-1',
|
||||
relativePath: null,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SshHostNotServedError } from '../providers/ssh-host-not-served-error'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { resolveAuthorizedPathMock, statMock } from './orca-runtime-files-mock-registry'
|
||||
import {
|
||||
@@ -8,7 +9,6 @@ import {
|
||||
resolveTerminalArtifactPath,
|
||||
statAsFile
|
||||
} from './orca-runtime-files-terminal-artifact-fixtures'
|
||||
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
|
||||
vi.mock('fs', async () => (await import('./orca-runtime-files-mock-registry')).fsModuleMock())
|
||||
vi.mock('fs/promises', async () =>
|
||||
@@ -28,10 +28,6 @@ vi.mock(
|
||||
'../ipc/local-worktree-runtime-options',
|
||||
async () => (await import('./orca-runtime-files-mock-registry')).localWorktreeRuntimeOptionsMock
|
||||
)
|
||||
vi.mock(
|
||||
'../providers/ssh-filesystem-dispatch',
|
||||
async () => (await import('./orca-runtime-files-mock-registry')).sshFilesystemDispatchMock
|
||||
)
|
||||
|
||||
describe('RuntimeFileCommands', () => {
|
||||
useRuntimeFileCommandsLifecycle()
|
||||
@@ -173,99 +169,14 @@ describe('RuntimeFileCommands', () => {
|
||||
expect(hasRecentTerminalOutputPath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stats a sibling SSH workspace through its owning provider', async () => {
|
||||
const sibling = {
|
||||
id: 'wt-2',
|
||||
repoId: 'repo-2',
|
||||
path: '/sibling',
|
||||
hostId: 'ssh:ssh-1',
|
||||
git: {
|
||||
path: '/sibling',
|
||||
head: '',
|
||||
branch: '',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
}
|
||||
}
|
||||
const resolveKnownWorkspaceFileTarget = vi.fn(async () => ({
|
||||
worktree: sibling,
|
||||
executionHostId: 'ssh:ssh-1',
|
||||
relativePath: 'docs/readme.md'
|
||||
}))
|
||||
const { commands, store } = createRuntimeFileCommands({
|
||||
path: '/repo',
|
||||
resolveKnownWorkspaceFileTarget
|
||||
})
|
||||
it('refuses an SSH workspace path instead of stating this machine', async () => {
|
||||
const { commands, store } = createRuntimeFileCommands({ path: '/repo' })
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
|
||||
const remoteStat = vi.fn().mockResolvedValue({ type: 'file', size: 12, mtime: 3 })
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue({ stat: remoteStat } as never)
|
||||
|
||||
const result = await commands.resolveTerminalPath(
|
||||
'id:wt-1',
|
||||
'/sibling/docs/readme.md',
|
||||
null,
|
||||
undefined,
|
||||
null,
|
||||
true
|
||||
await expect(commands.resolveTerminalPath('id:wt-1', 'src/app.ts')).rejects.toThrow(
|
||||
SshHostNotServedError
|
||||
)
|
||||
|
||||
expect(resolveKnownWorkspaceFileTarget).toHaveBeenCalledWith(
|
||||
'/sibling/docs/readme.md',
|
||||
'ssh:ssh-1'
|
||||
)
|
||||
expect(remoteStat).toHaveBeenCalledWith('/sibling/docs/readme.md')
|
||||
expect(statMock).not.toHaveBeenCalled()
|
||||
expect(result).toMatchObject({
|
||||
worktree: 'wt-2',
|
||||
relativePath: 'docs/readme.md',
|
||||
exists: true,
|
||||
openTarget: { provider: 'ssh' }
|
||||
})
|
||||
})
|
||||
|
||||
it('stats an exact SSH sibling root as a directory through its owning provider', async () => {
|
||||
const sibling = {
|
||||
id: 'wt-2',
|
||||
repoId: 'repo-2',
|
||||
path: '/sibling',
|
||||
hostId: 'ssh:ssh-1',
|
||||
git: {
|
||||
path: '/sibling',
|
||||
head: '',
|
||||
branch: '',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
}
|
||||
}
|
||||
const resolveKnownWorkspaceFileTarget = vi.fn(async () => ({
|
||||
worktree: sibling,
|
||||
executionHostId: 'ssh:ssh-1',
|
||||
relativePath: ''
|
||||
}))
|
||||
const hasRecentTerminalOutputPath = vi.fn(() => true)
|
||||
const { commands, store } = createRuntimeFileCommands({
|
||||
path: '/repo',
|
||||
resolveKnownWorkspaceFileTarget,
|
||||
hasRecentTerminalOutputPath
|
||||
})
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
|
||||
const remoteStat = vi.fn().mockResolvedValue({ type: 'directory', size: 0, mtime: 3 })
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue({ stat: remoteStat } as never)
|
||||
|
||||
const result = await resolveTerminalArtifactPath(commands, '/sibling', null, 'client-a', true)
|
||||
|
||||
expect(resolveKnownWorkspaceFileTarget).toHaveBeenCalledWith('/sibling', 'ssh:ssh-1')
|
||||
expect(remoteStat).toHaveBeenCalledWith('/sibling')
|
||||
expect(statMock).not.toHaveBeenCalled()
|
||||
expect(result).toEqual({
|
||||
worktree: 'wt-2',
|
||||
relativePath: '',
|
||||
absolutePath: '/sibling',
|
||||
exists: true,
|
||||
isDirectory: true,
|
||||
openTarget: undefined
|
||||
})
|
||||
expect(hasRecentTerminalOutputPath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// The host was `runtime:env-a` until this process stopped dispatching runtime hosts at all
|
||||
@@ -445,13 +356,11 @@ describe('RuntimeFileCommands', () => {
|
||||
it('does not expand ~/ on a remote worktree (home is unknown)', async () => {
|
||||
const { commands, store } = createRuntimeFileCommands({ path: '/repo' })
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
|
||||
const stat = vi.fn()
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue({ stat } as never)
|
||||
|
||||
const result = await commands.resolveTerminalPath('id:wt-1', '~/notes.md')
|
||||
|
||||
expect(result).toMatchObject({ relativePath: null, exists: false })
|
||||
expect(stat).not.toHaveBeenCalled()
|
||||
expect(statMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still refuses a native-chat ~/ path on a remote worktree', async () => {
|
||||
@@ -475,27 +384,5 @@ describe('RuntimeFileCommands', () => {
|
||||
expect(result).toMatchObject({ relativePath: null, exists: false })
|
||||
expect(hasRecentNativeChatOutputPath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a missing remote file as not existing', async () => {
|
||||
const { commands, store } = createRuntimeFileCommands({ path: '/repo' })
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
|
||||
const stat = vi.fn().mockRejectedValue(new Error('ENOENT: no such file'))
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue({ stat } as never)
|
||||
|
||||
const result = await commands.resolveTerminalPath('id:wt-1', 'src/missing.ts')
|
||||
|
||||
expect(result).toMatchObject({ relativePath: 'src/missing.ts', exists: false })
|
||||
})
|
||||
|
||||
it('rethrows a remote transport error instead of reporting not-found', async () => {
|
||||
const { commands, store } = createRuntimeFileCommands({ path: '/repo' })
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
|
||||
const stat = vi.fn().mockRejectedValue(new Error('Remote connection dropped'))
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue({ stat } as never)
|
||||
|
||||
await expect(commands.resolveTerminalPath('id:wt-1', 'src/x.ts')).rejects.toThrow(
|
||||
'Remote connection dropped'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SshHostNotServedError } from '../providers/ssh-host-not-served-error'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import type { FsChangeEvent } from '../../shared/filesystem-entry-types'
|
||||
import {
|
||||
getSshFilesystemProviderMock,
|
||||
resolveAuthorizedPathMock,
|
||||
statMock,
|
||||
watchInWatcherProcessMock,
|
||||
@@ -12,7 +11,6 @@ import {
|
||||
createRuntimeFileCommands,
|
||||
useRuntimeFileCommandsLifecycle
|
||||
} from './orca-runtime-files-test-harness'
|
||||
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
import { awaitRuntimeFileWatcherUnsubscribes } from './orca-runtime-files'
|
||||
|
||||
vi.mock('fs', async () => (await import('./orca-runtime-files-mock-registry')).fsModuleMock())
|
||||
@@ -33,10 +31,6 @@ vi.mock(
|
||||
'../ipc/local-worktree-runtime-options',
|
||||
async () => (await import('./orca-runtime-files-mock-registry')).localWorktreeRuntimeOptionsMock
|
||||
)
|
||||
vi.mock(
|
||||
'../providers/ssh-filesystem-dispatch',
|
||||
async () => (await import('./orca-runtime-files-mock-registry')).sshFilesystemDispatchMock
|
||||
)
|
||||
|
||||
describe('RuntimeFileCommands', () => {
|
||||
useRuntimeFileCommandsLifecycle()
|
||||
@@ -102,80 +96,14 @@ describe('RuntimeFileCommands', () => {
|
||||
expect(dispose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps SSH runtime watches on the remote filesystem provider', async () => {
|
||||
const remoteDispose = vi.fn()
|
||||
const providerWatch = vi.fn(
|
||||
(_rootPath: string, _callback: (events: FsChangeEvent[]) => void) => remoteDispose
|
||||
)
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue({ watch: providerWatch } as never)
|
||||
it('refuses an SSH runtime watch without watching this machine', async () => {
|
||||
const { commands, store } = createRuntimeFileCommands({ path: '/remote/repo' })
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
|
||||
const onEvents = vi.fn()
|
||||
|
||||
const unsubscribe = await commands.watchFileExplorer('id:wt-1', onEvents)
|
||||
|
||||
expect(providerWatch).toHaveBeenCalledWith('/remote/repo', expect.any(Function), {
|
||||
signal: undefined,
|
||||
onTerminalError: expect.any(Function)
|
||||
})
|
||||
const callback = providerWatch.mock.calls[0]?.[1]
|
||||
callback?.([{ kind: 'update', absolutePath: '/remote/repo/current.ts' }])
|
||||
expect(onEvents).toHaveBeenCalledWith([
|
||||
{ kind: 'update', absolutePath: '/remote/repo/current.ts' }
|
||||
])
|
||||
await expect(commands.watchFileExplorer('id:wt-1', vi.fn())).rejects.toThrow(
|
||||
SshHostNotServedError
|
||||
)
|
||||
expect(watchInWatcherProcessMock).not.toHaveBeenCalled()
|
||||
await unsubscribe()
|
||||
expect(remoteDispose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('indexes SSH runtime watches so remote deletion can await them', async () => {
|
||||
let resolveDispose: () => void = () => {}
|
||||
const remoteDispose = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveDispose = resolve
|
||||
})
|
||||
)
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue({
|
||||
watch: vi.fn(() => remoteDispose)
|
||||
} as never)
|
||||
const { commands, store } = createRuntimeFileCommands({ path: '/remote/repo' })
|
||||
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
|
||||
await commands.watchFileExplorer('id:wt-1', vi.fn())
|
||||
|
||||
let closed = false
|
||||
const close = commands.closeFileExplorerWatchersForPath('/remote/repo', 'ssh-1').then(() => {
|
||||
closed = true
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(remoteDispose).toHaveBeenCalledTimes(1)
|
||||
expect(closed).toBe(false)
|
||||
|
||||
resolveDispose()
|
||||
await close
|
||||
})
|
||||
|
||||
it('scopes same-path runtime watcher teardown to its SSH execution host', async () => {
|
||||
const firstDispose = vi.fn()
|
||||
const secondDispose = vi.fn()
|
||||
const firstProvider = { watch: vi.fn(() => firstDispose) }
|
||||
const secondProvider = { watch: vi.fn(() => secondDispose) }
|
||||
getSshFilesystemProviderMock.mockImplementation((connectionId) =>
|
||||
connectionId === 'ssh-1' ? firstProvider : secondProvider
|
||||
)
|
||||
const first = createRuntimeFileCommands({ path: '/same/repo' })
|
||||
const second = createRuntimeFileCommands({ path: '/same/repo' })
|
||||
first.store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
|
||||
second.store.getRepo.mockReturnValue({ connectionId: 'ssh-2' })
|
||||
|
||||
await first.commands.watchFileExplorer('id:wt-1', vi.fn())
|
||||
await second.commands.watchFileExplorer('id:wt-1', vi.fn())
|
||||
await first.commands.closeFileExplorerWatchersForPath('/same/repo', 'ssh-1')
|
||||
|
||||
expect(firstDispose).toHaveBeenCalledTimes(1)
|
||||
expect(secondDispose).not.toHaveBeenCalled()
|
||||
|
||||
await second.commands.closeFileExplorerWatchersForPath('/same/repo', 'ssh-2')
|
||||
expect(secondDispose).toHaveBeenCalledTimes(1)
|
||||
expect(watchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,6 @@ import type * as Fs from 'node:fs'
|
||||
import type * as FsPromises from 'node:fs/promises'
|
||||
import type * as FilesystemAuth from '../ipc/filesystem-auth'
|
||||
import type { FsChangeEvent } from '../../shared/filesystem-entry-types'
|
||||
import type { IFilesystemProvider } from '../providers/types'
|
||||
import { WatcherProcessFailure } from '../ipc/parcel-watcher-process-failure'
|
||||
import { acquireWatcherRemovalGate } from '../ipc/watcher-removal-gate'
|
||||
|
||||
@@ -13,15 +12,13 @@ const {
|
||||
statMock,
|
||||
watchMock,
|
||||
watchInWatcherProcessMock,
|
||||
closeWatcherInWatcherProcessMock,
|
||||
getSshFilesystemProviderMock
|
||||
closeWatcherInWatcherProcessMock
|
||||
} = vi.hoisted(() => ({
|
||||
resolveAuthorizedPathMock: vi.fn(),
|
||||
statMock: vi.fn(),
|
||||
watchMock: vi.fn(),
|
||||
watchInWatcherProcessMock: vi.fn(),
|
||||
closeWatcherInWatcherProcessMock: vi.fn(),
|
||||
getSshFilesystemProviderMock: vi.fn()
|
||||
closeWatcherInWatcherProcessMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('fs', async () => {
|
||||
@@ -54,11 +51,6 @@ vi.mock('../ipc/filesystem-auth', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
|
||||
getSshFilesystemProvider: getSshFilesystemProviderMock,
|
||||
onSshFilesystemProviderRegistered: () => () => undefined
|
||||
}))
|
||||
|
||||
import {
|
||||
_getRuntimeFileWatcherReleaseCountForTests,
|
||||
_resetRuntimeFileWatcherLeasesForTests,
|
||||
@@ -109,7 +101,6 @@ describe('RuntimeFileCommands file watching', () => {
|
||||
watchMock.mockReset()
|
||||
watchInWatcherProcessMock.mockReset()
|
||||
closeWatcherInWatcherProcessMock.mockReset()
|
||||
getSshFilesystemProviderMock.mockReset()
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: originalPlatform
|
||||
@@ -543,63 +534,4 @@ describe('RuntimeFileCommands file watching', () => {
|
||||
await drainPromise
|
||||
expect(drained).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['abort', 'unsubscribe'] as const)(
|
||||
'forwards the SSH watch signal and fences terminal errors after %s',
|
||||
async (stop) => {
|
||||
const dispose = vi.fn()
|
||||
const watch = vi.fn<IFilesystemProvider['watch']>().mockResolvedValue(dispose)
|
||||
getSshFilesystemProviderMock.mockReturnValue({ watch })
|
||||
const store = { getRepo: vi.fn(() => ({ connectionId: 'ssh-1' })) }
|
||||
const commands = new RuntimeFileCommands({
|
||||
getRuntimeId: () => 'runtime-1',
|
||||
requireStore: () => store,
|
||||
resolveWorktreeSelector: vi.fn(async () => ({
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
path: '/remote/repo'
|
||||
})),
|
||||
resolveRuntimeFileTarget: vi.fn(async () => ({
|
||||
worktree: {
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
path: '/remote/repo'
|
||||
},
|
||||
executionHostId: 'ssh:ssh-1'
|
||||
})),
|
||||
resolveRuntimeGitTarget: vi.fn(),
|
||||
openFile: vi.fn()
|
||||
} as never)
|
||||
const controller = new AbortController()
|
||||
const onTerminalError = vi.fn()
|
||||
|
||||
const unsubscribe = await commands.watchFileExplorer(
|
||||
'id:wt-1',
|
||||
vi.fn(),
|
||||
onTerminalError,
|
||||
controller.signal
|
||||
)
|
||||
|
||||
expect(watch).toHaveBeenCalledWith('/remote/repo', expect.any(Function), {
|
||||
signal: controller.signal,
|
||||
onTerminalError: expect.any(Function)
|
||||
})
|
||||
const options = watch.mock.calls[0]?.[2]
|
||||
expect(options?.signal).toBe(controller.signal)
|
||||
const currentError = new Error('current remote watch failed')
|
||||
options?.onTerminalError?.(currentError)
|
||||
expect(onTerminalError).toHaveBeenCalledExactlyOnceWith(currentError)
|
||||
if (stop === 'abort') {
|
||||
controller.abort()
|
||||
} else {
|
||||
await unsubscribe()
|
||||
expect(controller.signal.aborted).toBe(false)
|
||||
}
|
||||
options?.onTerminalError?.(new Error('late remote watch failure'))
|
||||
expect(onTerminalError).toHaveBeenCalledExactlyOnceWith(currentError)
|
||||
await unsubscribe()
|
||||
expect(dispose).toHaveBeenCalledOnce()
|
||||
expect(watchInWatcherProcessMock).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -2,11 +2,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { GlobalSettings } from '../../shared/global-settings-types'
|
||||
import { RuntimeGitCommands, type ResolvedRuntimeGitWorktree } from './orca-runtime-git'
|
||||
|
||||
const getSshGitProviderMock = vi.hoisted(() => vi.fn())
|
||||
const getBranchDiffMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProvider: getSshGitProviderMock
|
||||
}))
|
||||
vi.mock('../git/status', () => ({ getBranchDiff: getBranchDiffMock }))
|
||||
|
||||
function makeWorktree(path: string): ResolvedRuntimeGitWorktree {
|
||||
const worktree = {
|
||||
@@ -27,10 +25,10 @@ function makeWorktree(path: string): ResolvedRuntimeGitWorktree {
|
||||
|
||||
describe('RuntimeGitCommands branch diff', () => {
|
||||
beforeEach(() => {
|
||||
getSshGitProviderMock.mockReset()
|
||||
getBranchDiffMock.mockReset()
|
||||
})
|
||||
|
||||
it('forwards the pinned head through normalized nested SSH paths', async () => {
|
||||
it('forwards the pinned head through normalized nested paths', async () => {
|
||||
const mergeBase = 'a'.repeat(40)
|
||||
const headOid = 'b'.repeat(40)
|
||||
const result = {
|
||||
@@ -40,12 +38,11 @@ describe('RuntimeGitCommands branch diff', () => {
|
||||
originalIsBinary: false,
|
||||
modifiedIsBinary: false
|
||||
}
|
||||
const provider = { getBranchDiff: vi.fn().mockResolvedValue([result]) }
|
||||
getSshGitProviderMock.mockReturnValue(provider)
|
||||
getBranchDiffMock.mockResolvedValue(result)
|
||||
const commands = new RuntimeGitCommands({
|
||||
resolveRuntimeGitTarget: async () => ({
|
||||
worktree: makeWorktree('/remote/repo'),
|
||||
executionHostId: 'ssh:conn-1'
|
||||
worktree: makeWorktree('/repo'),
|
||||
executionHostId: 'local'
|
||||
}),
|
||||
getRuntimeSettings: () => ({}) as GlobalSettings
|
||||
})
|
||||
@@ -59,11 +56,10 @@ describe('RuntimeGitCommands branch diff', () => {
|
||||
)
|
||||
).resolves.toEqual(result)
|
||||
|
||||
expect(provider.getBranchDiff).toHaveBeenCalledWith('/remote/repo', mergeBase, {
|
||||
includePatch: true,
|
||||
headOid,
|
||||
filePath: 'src/file.ts',
|
||||
oldPath: 'src/old-file.ts'
|
||||
})
|
||||
expect(getBranchDiffMock).toHaveBeenCalledWith(
|
||||
'/repo',
|
||||
{ mergeBase, headOid, filePath: 'src/file.ts', oldPath: 'src/old-file.ts' },
|
||||
{ admissionTier: 'interactive' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// Why: the cap lives in orca-runtime-git.ts so both branches of all three diff readers are covered —
|
||||
// an SSH host forwards its provider's payload verbatim, so an older relay cannot be relied on to clamp it.
|
||||
// Why: the cap lives in orca-runtime-git.ts so all three diff readers are covered.
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { REMOTE_RPC_MAX_CONTENT_BYTES } from '../../shared/remote-rpc-content-budget'
|
||||
import type { GitDiffResult } from '../../shared/git-diff-compare-types'
|
||||
@@ -8,16 +7,11 @@ import type * as GitStatusModule from '../git/status'
|
||||
import { RuntimeGitCommands, type ResolvedRuntimeGitWorktree } from './orca-runtime-git'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getSshGitProvider: vi.fn(),
|
||||
getDiff: vi.fn(),
|
||||
getBranchDiff: vi.fn(),
|
||||
getCommitDiff: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProvider: mocks.getSshGitProvider
|
||||
}))
|
||||
|
||||
vi.mock('../git/status', async () => ({
|
||||
...(await vi.importActual<typeof GitStatusModule>('../git/status')),
|
||||
getDiff: mocks.getDiff,
|
||||
@@ -44,10 +38,7 @@ function oversizedResult(): GitDiffResult {
|
||||
}
|
||||
}
|
||||
|
||||
function commands(
|
||||
connectionId?: string,
|
||||
localGitOptions?: { wslDistro: string }
|
||||
): RuntimeGitCommands {
|
||||
function commands(localGitOptions?: { wslDistro: string }): RuntimeGitCommands {
|
||||
const worktree = {
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
@@ -57,63 +48,20 @@ function commands(
|
||||
return new RuntimeGitCommands({
|
||||
resolveRuntimeGitTarget: async () => ({
|
||||
worktree,
|
||||
executionHostId: connectionId ? (`ssh:${connectionId}` as const) : ('local' as const),
|
||||
executionHostId: 'local' as const,
|
||||
...(localGitOptions ? { localGitOptions } : {})
|
||||
}),
|
||||
getRuntimeSettings: () => ({}) as GlobalSettings
|
||||
})
|
||||
}
|
||||
|
||||
function sshProvider(): {
|
||||
getDiff: ReturnType<typeof vi.fn>
|
||||
getBranchDiff: ReturnType<typeof vi.fn>
|
||||
getCommitDiff: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
return {
|
||||
getDiff: vi.fn().mockResolvedValue(oversizedResult()),
|
||||
getBranchDiff: vi.fn().mockResolvedValue([oversizedResult()]),
|
||||
getCommitDiff: vi.fn().mockResolvedValue(oversizedResult())
|
||||
}
|
||||
}
|
||||
|
||||
describe('runtime git diff transport budget', () => {
|
||||
beforeEach(() => {
|
||||
mocks.getSshGitProvider.mockReset()
|
||||
mocks.getDiff.mockReset().mockResolvedValue(oversizedResult())
|
||||
mocks.getBranchDiff.mockReset().mockResolvedValue(oversizedResult())
|
||||
mocks.getCommitDiff.mockReset().mockResolvedValue(oversizedResult())
|
||||
})
|
||||
|
||||
it('caps an SSH-forwarded diff that exceeds the budget', async () => {
|
||||
const provider = sshProvider()
|
||||
mocks.getSshGitProvider.mockReturnValue(provider)
|
||||
|
||||
await expect(
|
||||
commands('conn-1').getRuntimeGitDiff(
|
||||
'id:wt-1',
|
||||
'assets/logo.png',
|
||||
false,
|
||||
undefined,
|
||||
REMOTE_RPC_MAX_CONTENT_BYTES
|
||||
)
|
||||
).rejects.toMatchObject(TOO_LARGE)
|
||||
expect(provider.getDiff).toHaveBeenCalledWith(
|
||||
'/remote/repo',
|
||||
'assets/logo.png',
|
||||
false,
|
||||
undefined
|
||||
)
|
||||
expect(mocks.getDiff).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves an SSH-forwarded diff uncapped when no budget is supplied', async () => {
|
||||
mocks.getSshGitProvider.mockReturnValue(sshProvider())
|
||||
|
||||
await expect(
|
||||
commands('conn-1').getRuntimeGitDiff('id:wt-1', 'assets/logo.png', false)
|
||||
).resolves.toMatchObject({ modifiedContent: OVERSIZED_BASE64 })
|
||||
})
|
||||
|
||||
it('caps a local-repo diff that exceeds the budget', async () => {
|
||||
await expect(
|
||||
commands().getRuntimeGitDiff(
|
||||
@@ -125,7 +73,6 @@ describe('runtime git diff transport budget', () => {
|
||||
)
|
||||
).rejects.toMatchObject(TOO_LARGE)
|
||||
expect(mocks.getDiff).toHaveBeenCalled()
|
||||
expect(mocks.getSshGitProvider).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves a local-repo diff uncapped when no budget is supplied', async () => {
|
||||
@@ -134,23 +81,6 @@ describe('runtime git diff transport budget', () => {
|
||||
).resolves.toMatchObject({ modifiedContent: OVERSIZED_BASE64 })
|
||||
})
|
||||
|
||||
it('caps an SSH-forwarded branch diff that exceeds the budget', async () => {
|
||||
const provider = sshProvider()
|
||||
mocks.getSshGitProvider.mockReturnValue(provider)
|
||||
|
||||
await expect(
|
||||
commands('conn-1').getRuntimeGitBranchDiff(
|
||||
'id:wt-1',
|
||||
BRANCH_COMPARE,
|
||||
'assets/logo.png',
|
||||
undefined,
|
||||
REMOTE_RPC_MAX_CONTENT_BYTES
|
||||
)
|
||||
).rejects.toMatchObject(TOO_LARGE)
|
||||
expect(provider.getBranchDiff).toHaveBeenCalled()
|
||||
expect(mocks.getBranchDiff).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('caps a local-repo branch diff that exceeds the budget', async () => {
|
||||
await expect(
|
||||
commands().getRuntimeGitBranchDiff(
|
||||
@@ -170,21 +100,6 @@ describe('runtime git diff transport budget', () => {
|
||||
).resolves.toMatchObject({ modifiedContent: OVERSIZED_BASE64 })
|
||||
})
|
||||
|
||||
it('caps an SSH-forwarded commit diff that exceeds the budget', async () => {
|
||||
const provider = sshProvider()
|
||||
mocks.getSshGitProvider.mockReturnValue(provider)
|
||||
|
||||
await expect(
|
||||
commands('conn-1').getRuntimeGitCommitDiff(
|
||||
'id:wt-1',
|
||||
COMMIT_ARGS,
|
||||
REMOTE_RPC_MAX_CONTENT_BYTES
|
||||
)
|
||||
).rejects.toMatchObject(TOO_LARGE)
|
||||
expect(provider.getCommitDiff).toHaveBeenCalled()
|
||||
expect(mocks.getCommitDiff).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('caps a local-repo commit diff that exceeds the budget', async () => {
|
||||
await expect(
|
||||
commands().getRuntimeGitCommitDiff('id:wt-1', COMMIT_ARGS, REMOTE_RPC_MAX_CONTENT_BYTES)
|
||||
@@ -201,7 +116,7 @@ describe('runtime git diff transport budget', () => {
|
||||
})
|
||||
|
||||
it('prioritizes local file diff reads without losing WSL routing', async () => {
|
||||
const runtime = commands(undefined, { wslDistro: 'Ubuntu' })
|
||||
const runtime = commands({ wslDistro: 'Ubuntu' })
|
||||
|
||||
await runtime.getRuntimeGitBranchDiff('id:wt-1', BRANCH_COMPARE, 'assets/logo.png')
|
||||
await runtime.getRuntimeGitCommitDiff('id:wt-1', COMMIT_ARGS)
|
||||
|
||||
@@ -6,6 +6,10 @@ import type { GlobalSettings } from '../../shared/global-settings-types'
|
||||
import type * as GitStatusModule from '../git/status'
|
||||
import type * as CommitMessageTextGenerationModule from '../text-generation/commit-message-text-generation'
|
||||
import type * as PullRequestContextModule from '../text-generation/pull-request-context'
|
||||
import {
|
||||
SSH_HOST_NOT_SERVED_MESSAGE,
|
||||
SshHostNotServedError
|
||||
} from '../providers/ssh-host-not-served-error'
|
||||
import { RuntimeGitCommands, type ResolvedRuntimeGitWorktree } from './orca-runtime-git'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -19,7 +23,6 @@ const mocks = vi.hoisted(() => ({
|
||||
resolveCommitMessageSettings: vi.fn(),
|
||||
resolveHostedReviewBodyForGeneration: vi.fn(),
|
||||
loadPullRequestLinkedIssue: vi.fn(),
|
||||
getSshGitProvider: vi.fn(),
|
||||
getStatus: vi.fn()
|
||||
}))
|
||||
|
||||
@@ -51,10 +54,6 @@ vi.mock('../text-generation/pull-request-context', async () => ({
|
||||
getPullRequestDraftContext: mocks.getPullRequestDraftContext
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProvider: mocks.getSshGitProvider
|
||||
}))
|
||||
|
||||
vi.mock('../source-control/pull-request-template', () => ({
|
||||
resolveHostedReviewBodyForGeneration: mocks.resolveHostedReviewBodyForGeneration
|
||||
}))
|
||||
@@ -108,7 +107,6 @@ describe('RuntimeGitCommands', () => {
|
||||
mocks.resolveHostedReviewBodyForGeneration.mockImplementation(async ({ body }) => body)
|
||||
mocks.loadPullRequestLinkedIssue.mockReset()
|
||||
mocks.loadPullRequestLinkedIssue.mockResolvedValue(null)
|
||||
mocks.getSshGitProvider.mockReset()
|
||||
mocks.getStatus.mockReset()
|
||||
mocks.checkoutBranch.mockReset()
|
||||
mocks.discardChanges.mockReset()
|
||||
@@ -144,9 +142,7 @@ describe('RuntimeGitCommands', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not resolve shared link paths for a remote runtime status', async () => {
|
||||
const provider = { getStatus: vi.fn().mockResolvedValue({ entries: [] }) }
|
||||
mocks.getSshGitProvider.mockReturnValue(provider)
|
||||
it('refuses an SSH runtime status without running git here', async () => {
|
||||
const commands = new RuntimeGitCommands({
|
||||
resolveRuntimeGitTarget: async () => ({
|
||||
worktree: makeWorktree('/remote/repo'),
|
||||
@@ -156,9 +152,7 @@ describe('RuntimeGitCommands', () => {
|
||||
getRuntimeSettings: () => ({}) as GlobalSettings
|
||||
})
|
||||
|
||||
await commands.getRuntimeGitStatus('id:wt-1')
|
||||
|
||||
expect(provider.getStatus).toHaveBeenCalledWith('/remote/repo')
|
||||
await expect(commands.getRuntimeGitStatus('id:wt-1')).rejects.toThrow(SshHostNotServedError)
|
||||
expect(mocks.getStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -186,26 +180,6 @@ describe('RuntimeGitCommands', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('checks out a remote branch through the SSH git provider', async () => {
|
||||
const provider = { checkoutBranch: vi.fn().mockResolvedValue(undefined) }
|
||||
mocks.getSshGitProvider.mockReturnValue(provider)
|
||||
const commands = new RuntimeGitCommands({
|
||||
resolveRuntimeGitTarget: async () => ({
|
||||
worktree: makeWorktree('/remote/repo'),
|
||||
executionHostId: 'ssh:conn-1'
|
||||
}),
|
||||
getRuntimeSettings: () => ({}) as GlobalSettings
|
||||
})
|
||||
|
||||
await expect(commands.checkoutRuntimeGitBranch('id:wt-1', 'feature/x')).resolves.toEqual({
|
||||
ok: true,
|
||||
branch: 'feature/x'
|
||||
})
|
||||
|
||||
expect(provider.checkoutBranch).toHaveBeenCalledWith('/remote/repo', 'feature/x')
|
||||
expect(mocks.checkoutBranch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lists local branches through the resolved worktree', async () => {
|
||||
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
|
||||
tempDirs.push(worktreePath)
|
||||
@@ -238,45 +212,6 @@ describe('RuntimeGitCommands', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a remote single-file discard owned by the SSH provider', async () => {
|
||||
const provider = { discardChanges: vi.fn() }
|
||||
mocks.getSshGitProvider.mockReturnValue(provider)
|
||||
const commands = new RuntimeGitCommands({
|
||||
resolveRuntimeGitTarget: async () => ({
|
||||
worktree: makeWorktree('/remote/repo'),
|
||||
executionHostId: 'ssh:conn-1'
|
||||
}),
|
||||
getRuntimeSettings: () => ({}) as GlobalSettings
|
||||
})
|
||||
|
||||
await commands.discardRuntimeGitPath('id:wt-1', 'src/app.ts')
|
||||
|
||||
expect(provider.discardChanges).toHaveBeenCalledWith('/remote/repo', 'src/app.ts')
|
||||
expect(mocks.discardChanges).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lists remote local branches through the SSH git provider', async () => {
|
||||
const provider = {
|
||||
listLocalBranches: vi.fn().mockResolvedValue({ current: 'main', branches: ['main'] })
|
||||
}
|
||||
mocks.getSshGitProvider.mockReturnValue(provider)
|
||||
const commands = new RuntimeGitCommands({
|
||||
resolveRuntimeGitTarget: async () => ({
|
||||
worktree: makeWorktree('/remote/repo'),
|
||||
executionHostId: 'ssh:conn-1'
|
||||
}),
|
||||
getRuntimeSettings: () => ({}) as GlobalSettings
|
||||
})
|
||||
|
||||
await expect(commands.listRuntimeGitLocalBranches('id:wt-1')).resolves.toEqual({
|
||||
current: 'main',
|
||||
branches: ['main']
|
||||
})
|
||||
|
||||
expect(provider.listLocalBranches).toHaveBeenCalledWith('/remote/repo')
|
||||
expect(mocks.listLocalBranches).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects slash-only git mutation paths before they can target the worktree root', async () => {
|
||||
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
|
||||
tempDirs.push(worktreePath)
|
||||
@@ -582,24 +517,10 @@ describe('RuntimeGitCommands', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves remote commit-message settings against the SSH host cache', async () => {
|
||||
it('resolves SSH commit-message settings against the host cache, then reports it unserved', async () => {
|
||||
const worktreePath = '/remote/repo'
|
||||
const context = {
|
||||
branch: 'main',
|
||||
stagedSummary: 'M\tREADME.md',
|
||||
stagedPatch: '+hello'
|
||||
}
|
||||
const params = { agentId: 'cursor', model: 'remote-model' }
|
||||
mocks.resolveCommitMessageSettings.mockReturnValue({ ok: true, params })
|
||||
mocks.generateCommitMessageFromContext.mockResolvedValue({
|
||||
success: true,
|
||||
message: 'docs: update remote readme'
|
||||
})
|
||||
const provider = {
|
||||
getStagedCommitContext: vi.fn().mockResolvedValue(context),
|
||||
executeCommitMessagePlan: vi.fn()
|
||||
}
|
||||
mocks.getSshGitProvider.mockReturnValue(provider)
|
||||
const commands = new RuntimeGitCommands({
|
||||
resolveRuntimeGitTarget: async () => ({
|
||||
worktree: makeWorktree(worktreePath),
|
||||
@@ -616,8 +537,8 @@ describe('RuntimeGitCommands', () => {
|
||||
})
|
||||
|
||||
await expect(commands.generateRuntimeCommitMessage('id:wt-1')).resolves.toEqual({
|
||||
success: true,
|
||||
message: 'docs: update remote readme'
|
||||
success: false,
|
||||
error: SSH_HOST_NOT_SERVED_MESSAGE
|
||||
})
|
||||
|
||||
expect(mocks.resolveCommitMessageSettings).toHaveBeenCalledWith(
|
||||
@@ -626,14 +547,8 @@ describe('RuntimeGitCommands', () => {
|
||||
'commitMessage',
|
||||
null
|
||||
)
|
||||
expect(mocks.generateCommitMessageFromContext).toHaveBeenCalledWith(
|
||||
context,
|
||||
params,
|
||||
expect.objectContaining({
|
||||
kind: 'remote',
|
||||
cwd: worktreePath
|
||||
})
|
||||
)
|
||||
expect(mocks.getStagedCommitContext).not.toHaveBeenCalled()
|
||||
expect(mocks.generateCommitMessageFromContext).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('enriches the local commit context with the workspace linked issue', async () => {
|
||||
@@ -658,33 +573,6 @@ describe('RuntimeGitCommands', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('enriches the SSH commit context with the workspace linked issue', async () => {
|
||||
const worktreePath = '/home/tester/wt'
|
||||
const context = { branch: 'main', stagedSummary: 'M\tREADME.md', stagedPatch: '+hello' }
|
||||
const params = { agentId: 'cursor', model: 'remote-model' }
|
||||
mocks.resolveCommitMessageSettings.mockReturnValue({ ok: true, params })
|
||||
mocks.generateCommitMessageFromContext.mockResolvedValue({ success: true, message: 'docs' })
|
||||
mocks.getSshGitProvider.mockReturnValue({
|
||||
getStagedCommitContext: vi.fn().mockResolvedValue(context),
|
||||
executeCommitMessagePlan: vi.fn()
|
||||
})
|
||||
const commands = new RuntimeGitCommands({
|
||||
resolveRuntimeGitTarget: async () => ({
|
||||
worktree: makeWorktree(worktreePath, 77),
|
||||
executionHostId: 'ssh:conn-1'
|
||||
}),
|
||||
getRuntimeSettings: () => ({}) as GlobalSettings
|
||||
})
|
||||
|
||||
await commands.generateRuntimeCommitMessage('id:wt-1')
|
||||
|
||||
expect(mocks.generateCommitMessageFromContext).toHaveBeenCalledWith(
|
||||
{ ...context, linkedIssue: 77 },
|
||||
params,
|
||||
expect.objectContaining({ kind: 'remote' })
|
||||
)
|
||||
})
|
||||
|
||||
it('prefers live meta over the linked issue projected onto the resolved worktree', async () => {
|
||||
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
|
||||
tempDirs.push(worktreePath)
|
||||
@@ -806,53 +694,6 @@ describe('RuntimeGitCommands', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('shares one linked-issue attach across both pull-request branches', async () => {
|
||||
const context = {
|
||||
base: 'main',
|
||||
branch: 'feature/login',
|
||||
commitSummary: 'abc123 feat: test',
|
||||
changeSummary: 'M README.md',
|
||||
patch: '+hello',
|
||||
currentTitle: '',
|
||||
currentBody: '',
|
||||
currentDraft: false
|
||||
}
|
||||
const params = { agentId: 'codex' as const, model: 'gpt-5.5' }
|
||||
mocks.generatePullRequestFieldsFromContext.mockResolvedValue({ success: true, fields: {} })
|
||||
mocks.getSshGitProvider.mockReturnValue({
|
||||
exec: vi.fn(),
|
||||
executeCommitMessagePlan: vi.fn()
|
||||
})
|
||||
|
||||
for (const connectionId of [undefined, 'conn-1']) {
|
||||
const worktreePath = connectionId
|
||||
? '/home/tester/wt'
|
||||
: mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
|
||||
if (!connectionId) {
|
||||
tempDirs.push(worktreePath)
|
||||
}
|
||||
mocks.getPullRequestDraftContext.mockResolvedValue(context)
|
||||
const commands = new RuntimeGitCommands({
|
||||
resolveRuntimeGitTarget: async () => ({
|
||||
worktree: makeWorktree(worktreePath, 55),
|
||||
executionHostId: connectionId ? (`ssh:${connectionId}` as const) : ('local' as const)
|
||||
}),
|
||||
getRuntimeSettings: () => ({}) as GlobalSettings
|
||||
})
|
||||
|
||||
await commands.generateRuntimePullRequestFields(
|
||||
'id:wt-1',
|
||||
{ base: 'main', title: '', body: '', draft: false },
|
||||
{ sourceControlAiResolvedParams: params }
|
||||
)
|
||||
}
|
||||
|
||||
expect(mocks.generatePullRequestFieldsFromContext.mock.calls).toHaveLength(2)
|
||||
for (const call of mocks.generatePullRequestFieldsFromContext.mock.calls) {
|
||||
expect(call[0]).toEqual({ ...context, linkedIssue: 55 })
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves the pull-request context untouched when no issue is linked', async () => {
|
||||
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
|
||||
tempDirs.push(worktreePath)
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
scanWorkspacePortsOnExecutionHost,
|
||||
type WorkspacePortExecutionHostDeps
|
||||
} from '../ports/workspace-port-execution-host'
|
||||
import { getActiveMultiplexer } from '../ssh/ssh-target-registry'
|
||||
import { resolveWorktreeHostRouting } from './worktree-launch-host-repo'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
|
||||
|
||||
@@ -178,8 +177,7 @@ export class OrcaRuntimeWithListManagedWorktrees extends OrcaRuntimeWithRestoreS
|
||||
|
||||
protected getWorkspacePortHostDeps(): WorkspacePortExecutionHostDeps {
|
||||
return {
|
||||
getLocalProbes: () => this.getWorkspacePortProbes(),
|
||||
getSshMultiplexer: getActiveMultiplexer
|
||||
getLocalProbes: () => this.getWorkspacePortProbes()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { getSshFilesystemProviderMock } from './orca-runtime-files-mock-registry'
|
||||
import { readdirMock, wslAwareSpawnMock } from './orca-runtime-files-mock-registry'
|
||||
import {
|
||||
createRuntimeFileCommands,
|
||||
useRuntimeFileCommandsLifecycle
|
||||
@@ -26,214 +26,28 @@ vi.mock(
|
||||
vi.mock('../ripgrep/bundled-ripgrep-path', async () =>
|
||||
(await import('./orca-runtime-files-mock-registry')).bundledRipgrepPathModuleMock()
|
||||
)
|
||||
vi.mock(
|
||||
'../providers/ssh-filesystem-dispatch',
|
||||
async () => (await import('./orca-runtime-files-mock-registry')).sshFilesystemDispatchMock
|
||||
)
|
||||
|
||||
function remoteSearch(version: number | null) {
|
||||
const latePath = 'late/target.ts'
|
||||
const legacy = Array.from({ length: 32 }, (_, i) => `early/unrelated${i}.ts`)
|
||||
const listFiles = vi.fn(async (_root: string, options?: { searchQuery?: string }) =>
|
||||
options?.searchQuery ? [latePath] : legacy
|
||||
)
|
||||
const supportsQuickOpenSearch = vi.fn(
|
||||
async (options?: { minimumVersion?: number; signal?: AbortSignal }) => {
|
||||
options?.signal?.throwIfAborted()
|
||||
return version !== null && version >= (options?.minimumVersion ?? 3)
|
||||
}
|
||||
)
|
||||
getSshFilesystemProviderMock.mockReturnValue({
|
||||
listFiles,
|
||||
...(version === null ? {} : { supportsQuickOpenSearch })
|
||||
})
|
||||
return {
|
||||
...createRuntimeFileCommands({ hostId: 'ssh:ssh-1' }),
|
||||
listFiles,
|
||||
supportsQuickOpenSearch
|
||||
}
|
||||
}
|
||||
|
||||
describe('runtime SSH Quick Open capability requirements', () => {
|
||||
// Why: this process serves no SSH filesystem, so Quick Open on an `ssh:` workspace reports no
|
||||
// matches (version 0) instead of failing the keystroke, and never lists this machine.
|
||||
describe('runtime SSH Quick Open', () => {
|
||||
useRuntimeFileCommandsLifecycle()
|
||||
|
||||
it.each([1, 2, 3, 4])(
|
||||
'uses host search for a simple late match on version %i',
|
||||
async (version) => {
|
||||
const { commands, listFiles, supportsQuickOpenSearch } = remoteSearch(version)
|
||||
const controller = new AbortController()
|
||||
await expect(
|
||||
commands.searchQuickOpenFilePaths('id:wt-1', 'target', 7, ['nested'], controller.signal)
|
||||
).resolves.toMatchObject({ files: [{ relativePath: 'late/target.ts' }], truncated: false })
|
||||
expect(supportsQuickOpenSearch).toHaveBeenCalledWith({
|
||||
signal: controller.signal,
|
||||
minimumVersion: 1
|
||||
})
|
||||
expect(listFiles).toHaveBeenCalledWith('/repo', {
|
||||
excludePaths: ['nested'],
|
||||
maxResults: 8,
|
||||
searchQuery: 'target',
|
||||
signal: controller.signal
|
||||
})
|
||||
}
|
||||
)
|
||||
it('reports no matches and capability 0 without listing this machine', async () => {
|
||||
const { commands } = createRuntimeFileCommands({ hostId: 'ssh:ssh-1' })
|
||||
|
||||
it.each([0, null])(
|
||||
'keeps the bounded compatibility prefix only without v1 (%s)',
|
||||
async (version) => {
|
||||
const { commands, listFiles } = remoteSearch(version)
|
||||
await expect(
|
||||
commands.searchQuickOpenFilePaths('id:wt-1', 'target', 7)
|
||||
).resolves.toMatchObject({
|
||||
files: [],
|
||||
truncated: true
|
||||
})
|
||||
expect(listFiles).toHaveBeenCalledWith('/repo', {
|
||||
excludePaths: undefined,
|
||||
maxResults: 32,
|
||||
signal: undefined
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['package-lock', 'my_file', 'file name'])(
|
||||
'preserves existing filename search %s on older relays',
|
||||
async (query) => {
|
||||
for (const version of [1, 2, 3]) {
|
||||
const { commands, listFiles } = remoteSearch(version)
|
||||
await expect(commands.searchQuickOpenFilePaths('id:wt-1', query, 7)).resolves.toMatchObject(
|
||||
{
|
||||
files: [{ relativePath: 'late/target.ts' }]
|
||||
}
|
||||
)
|
||||
expect(listFiles).toHaveBeenCalledWith(
|
||||
'/repo',
|
||||
expect.objectContaining({ searchQuery: query, maxResults: 8 })
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('does not require v3 for whitespace only around a simple query', async () => {
|
||||
const { commands, supportsQuickOpenSearch } = remoteSearch(1)
|
||||
await commands.searchQuickOpenFilePaths('id:wt-1', ' target ', 7)
|
||||
expect(supportsQuickOpenSearch).toHaveBeenCalledWith({ signal: undefined, minimumVersion: 1 })
|
||||
})
|
||||
|
||||
it.each([{ includeIgnored: false }, { followSymlinks: true }])(
|
||||
'requires v2 for discovery %j',
|
||||
async (options) => {
|
||||
for (const version of [null, 0, 1]) {
|
||||
const { commands, listFiles } = remoteSearch(version)
|
||||
await expect(
|
||||
commands.searchQuickOpenFilePaths('id:wt-1', 'target', 7, undefined, undefined, options)
|
||||
).rejects.toThrow('listing options')
|
||||
expect(listFiles).not.toHaveBeenCalled()
|
||||
}
|
||||
const { commands, listFiles } = remoteSearch(2)
|
||||
await commands.searchQuickOpenFilePaths('id:wt-1', 'target', 7, undefined, undefined, options)
|
||||
expect(listFiles).toHaveBeenCalledWith(
|
||||
'/repo',
|
||||
expect.objectContaining({ ...options, searchQuery: 'target', maxResults: 8 })
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('forwards abort to the capability probe and starts no listing after rejection', async () => {
|
||||
const { commands, listFiles } = remoteSearch(1)
|
||||
const controller = new AbortController()
|
||||
controller.abort(new Error('abandoned'))
|
||||
await expect(commands.searchQuickOpenFilePaths('id:wt-1', 'target', 7)).resolves.toMatchObject({
|
||||
files: [],
|
||||
totalCount: 0,
|
||||
truncated: false,
|
||||
quickOpenSearchVersion: 0
|
||||
})
|
||||
await expect(
|
||||
commands.searchQuickOpenFilePaths('id:wt-1', 'target', 7, undefined, controller.signal)
|
||||
).rejects.toThrow('abandoned')
|
||||
expect(listFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('runtime SSH file-list capability requirements', () => {
|
||||
useRuntimeFileCommandsLifecycle()
|
||||
|
||||
it.each([{ includeIgnored: false }, { followSymlinks: true }])(
|
||||
'requires discovery v2 for %j',
|
||||
async (options) => {
|
||||
for (const version of [null, 0, 1]) {
|
||||
const { commands, listFiles } = remoteSearch(version)
|
||||
await expect(commands.listRuntimeFiles('id:wt-1', options)).rejects.toThrow(
|
||||
'listing options'
|
||||
)
|
||||
expect(listFiles).not.toHaveBeenCalled()
|
||||
}
|
||||
for (const version of [2, 3, 4]) {
|
||||
const { commands, listFiles, supportsQuickOpenSearch } = remoteSearch(version)
|
||||
const controller = new AbortController()
|
||||
await commands.listRuntimeFiles('id:wt-1', {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
maxResults: 9
|
||||
})
|
||||
expect(supportsQuickOpenSearch).toHaveBeenCalledWith({
|
||||
minimumVersion: 2,
|
||||
signal: controller.signal
|
||||
})
|
||||
expect(listFiles).toHaveBeenCalledWith(
|
||||
'/repo',
|
||||
expect.objectContaining({ ...options, signal: controller.signal, maxResults: 9 })
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('requires v3 for candidate validation even with default discovery settings', async () => {
|
||||
for (const version of [null, 0, 1, 2]) {
|
||||
const { commands, listFiles } = remoteSearch(version)
|
||||
await expect(
|
||||
commands.listRuntimeFiles('id:wt-1', { candidatePaths: ['recent.ts'] })
|
||||
).rejects.toThrow('validate Quick Open recent files')
|
||||
expect(listFiles).not.toHaveBeenCalled()
|
||||
}
|
||||
const { commands, supportsQuickOpenSearch, listFiles } = remoteSearch(3)
|
||||
await commands.listRuntimeFiles('id:wt-1', {
|
||||
candidatePaths: ['recent.ts'],
|
||||
includeIgnored: false
|
||||
commands.listRuntimeFiles('id:wt-1', { candidatePaths: ['recent.ts'], followSymlinks: true })
|
||||
).resolves.toEqual([])
|
||||
await expect(commands.listMobileFiles('id:wt-1')).resolves.toMatchObject({
|
||||
files: [],
|
||||
totalCount: 0
|
||||
})
|
||||
expect(supportsQuickOpenSearch).toHaveBeenCalledWith({ minimumVersion: 3, signal: undefined })
|
||||
expect(listFiles).toHaveBeenCalledWith(
|
||||
'/repo',
|
||||
expect.objectContaining({ candidatePaths: ['recent.ts'], includeIgnored: false })
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps default legacy listings and disconnected results unchanged', async () => {
|
||||
const { commands, supportsQuickOpenSearch, listFiles } = remoteSearch(0)
|
||||
await commands.listRuntimeFiles('id:wt-1', { includeIgnored: true, followSymlinks: false })
|
||||
expect(listFiles).toHaveBeenCalledOnce()
|
||||
expect(supportsQuickOpenSearch).not.toHaveBeenCalled()
|
||||
getSshFilesystemProviderMock.mockReturnValue(null)
|
||||
await expect(commands.listRuntimeFiles('id:wt-1', { followSymlinks: true })).resolves.toEqual(
|
||||
[]
|
||||
)
|
||||
expect(readdirMock).not.toHaveBeenCalled()
|
||||
expect(wslAwareSpawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it.each([0, 1, 2, 3, 4])(
|
||||
'reports execution relay matching capability %i through a paired runtime',
|
||||
async (version) => {
|
||||
const { commands } = remoteSearch(version)
|
||||
const result = await commands.searchQuickOpenFilePaths('id:wt-1', '', 7)
|
||||
expect(result.quickOpenSearchVersion).toBe(Math.min(version, 3))
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps inherited ignored-file visibility from breaking searches on an older relay', async () => {
|
||||
const { commands, listFiles } = remoteSearch(1)
|
||||
await expect(
|
||||
commands.searchQuickOpenFilePaths('id:wt-1', 'target', 7, undefined, undefined, {
|
||||
includeIgnored: false,
|
||||
allowLegacyIncludeIgnored: true
|
||||
})
|
||||
).resolves.toMatchObject({ files: [{ relativePath: 'late/target.ts' }] })
|
||||
expect(listFiles).toHaveBeenCalledWith(
|
||||
'/repo',
|
||||
expect.not.objectContaining({ includeIgnored: false })
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SshHostNotServedError } from '../../providers/ssh-host-not-served-error'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
ORIGIN_HEAD_COMPONENT,
|
||||
@@ -9,7 +10,6 @@ import {
|
||||
getGlabKnownHostsMock,
|
||||
getPullRequestPushTargetMock,
|
||||
gitRunner,
|
||||
registerSshGitProvider,
|
||||
setPlatform
|
||||
} from '../orca-runtime-test-mocks.spec'
|
||||
import {
|
||||
@@ -178,7 +178,7 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves SSH GitHub fork PR heads through the write-capable fetch RPC', async () => {
|
||||
it('refuses SSH GitHub fork PR heads instead of resolving them on this machine', async () => {
|
||||
const remoteRepo = {
|
||||
id: TEST_REPO_ID,
|
||||
path: '/remote/repo',
|
||||
@@ -193,54 +193,17 @@ describe('OrcaRuntimeService', () => {
|
||||
getRepos: () => [remoteRepo],
|
||||
getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined)
|
||||
}
|
||||
const provider = {
|
||||
exec: vi.fn(async (args: string[]) => {
|
||||
if (args[0] === 'remote' && args[1] === 'get-url') {
|
||||
return { stdout: `${ORIGIN_REMOTE_URL}\n`, stderr: '' }
|
||||
}
|
||||
if (args[0] === 'remote') {
|
||||
return { stdout: 'origin\nupstream\n', stderr: '' }
|
||||
}
|
||||
if (
|
||||
args[0] === 'rev-parse' &&
|
||||
args[2] === `refs/orca/pull/${ORIGIN_HEAD_COMPONENT}/42^{commit}`
|
||||
) {
|
||||
return { stdout: 'remote-fork-pr-sha\n', stderr: '' }
|
||||
}
|
||||
throw new Error(`unexpected git call: ${args.join(' ')}`)
|
||||
}),
|
||||
fetchGitHubPullRequestHead: vi
|
||||
.fn()
|
||||
.mockResolvedValue(`refs/orca/pull/${ORIGIN_HEAD_COMPONENT}/42`),
|
||||
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
registerSshGitProvider('ssh-1', provider as never)
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
|
||||
const result = await runtime.resolveManagedPrBase({
|
||||
repoSelector: 'id:repo-1',
|
||||
prNumber: 42,
|
||||
headRefName: 'contributor/fix',
|
||||
isCrossRepository: true
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
baseBranch: 'remote-fork-pr-sha',
|
||||
headSha: 'remote-fork-pr-sha',
|
||||
branchNameOverride: 'contributor/fix'
|
||||
})
|
||||
expect(provider.fetchGitHubPullRequestHead).toHaveBeenCalledWith('/remote/repo', 'origin', 42)
|
||||
expect(getPullRequestPushTargetMock).toHaveBeenCalledWith(
|
||||
'/remote/repo',
|
||||
42,
|
||||
'ssh-1',
|
||||
{},
|
||||
'origin'
|
||||
)
|
||||
expect(provider.exec).not.toHaveBeenCalledWith(
|
||||
expect.arrayContaining(['fetch']),
|
||||
'/remote/repo'
|
||||
)
|
||||
await expect(
|
||||
runtime.resolveManagedPrBase({
|
||||
repoSelector: 'id:repo-1',
|
||||
prNumber: 42,
|
||||
headRefName: 'contributor/fix',
|
||||
isCrossRepository: true
|
||||
})
|
||||
).rejects.toThrow(SshHostNotServedError)
|
||||
expect(getPullRequestPushTargetMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves local GitLab fork MR bases from the target project MR head ref', async () => {
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { SshHostNotServedError } from '../../providers/ssh-host-not-served-error'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
ORIGIN_HEAD_COMPONENT,
|
||||
ORIGIN_REMOTE_URL,
|
||||
OrcaRuntimeService,
|
||||
getGitLabProjectRefForRemoteMock,
|
||||
getGlabKnownHostsMock,
|
||||
gitRunner,
|
||||
registerSshGitProvider
|
||||
gitRunner
|
||||
} from '../orca-runtime-test-mocks.spec'
|
||||
import { TEST_REPO_ID, TEST_REPO_PATH, store } from '../orca-runtime-test-fixtures.spec'
|
||||
|
||||
describe('OrcaRuntimeService', () => {
|
||||
it('resolves SSH GitLab fork MR bases from the target project MR head ref', async () => {
|
||||
it('refuses SSH GitLab MR bases instead of resolving them on this machine', async () => {
|
||||
const remoteRepo = {
|
||||
id: TEST_REPO_ID,
|
||||
path: '/remote/repo',
|
||||
@@ -26,122 +24,21 @@ describe('OrcaRuntimeService', () => {
|
||||
getRepos: () => [remoteRepo],
|
||||
getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined)
|
||||
}
|
||||
const provider = {
|
||||
exec: vi.fn(async (args: string[]) => {
|
||||
if (args[0] === 'remote' && args[1] === 'get-url') {
|
||||
return { stdout: `${ORIGIN_REMOTE_URL}\n`, stderr: '' }
|
||||
}
|
||||
if (
|
||||
args[0] === 'rev-parse' &&
|
||||
args[1] === '--verify' &&
|
||||
args[2] === `refs/orca/merge-requests/${ORIGIN_HEAD_COMPONENT}/77^{commit}`
|
||||
) {
|
||||
return { stdout: 'remote-fork-mr-sha\n', stderr: '' }
|
||||
}
|
||||
throw new Error(`unexpected git call: ${args.join(' ')}`)
|
||||
}),
|
||||
fetchGitLabMergeRequestHead: vi
|
||||
.fn()
|
||||
.mockResolvedValue(`refs/orca/merge-requests/${ORIGIN_HEAD_COMPONENT}/77`),
|
||||
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
registerSshGitProvider('ssh-1', provider as never)
|
||||
getGlabKnownHostsMock.mockResolvedValue(['gitlab.com', 'git.internal'])
|
||||
const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync')
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
|
||||
const result = await runtime.resolveManagedMrBase({
|
||||
repoSelector: 'id:repo-1',
|
||||
mrIid: 77,
|
||||
sourceBranch: 'contrib/remote-fix',
|
||||
targetBranch: 'main',
|
||||
isCrossRepository: true
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
baseBranch: 'remote-fork-mr-sha',
|
||||
compareBaseRef: 'refs/remotes/origin/main'
|
||||
})
|
||||
expect(provider.fetchGitLabMergeRequestHead).toHaveBeenCalledWith('/remote/repo', 'origin', 77)
|
||||
expect(provider.fetchRemoteTrackingRef).toHaveBeenCalledWith(
|
||||
'/remote/repo',
|
||||
'origin',
|
||||
'main',
|
||||
'refs/remotes/origin/main'
|
||||
)
|
||||
expect(provider.exec).toHaveBeenCalledWith(
|
||||
['rev-parse', '--verify', `refs/orca/merge-requests/${ORIGIN_HEAD_COMPONENT}/77^{commit}`],
|
||||
'/remote/repo'
|
||||
)
|
||||
expect(getGitLabProjectRefForRemoteMock).toHaveBeenCalledWith(
|
||||
'/remote/repo',
|
||||
'origin',
|
||||
['gitlab.com', 'git.internal'],
|
||||
'ssh-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves SSH GitLab same-repo MR bases through remote-tracking fetches', async () => {
|
||||
const remoteRepo = {
|
||||
id: TEST_REPO_ID,
|
||||
path: '/remote/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1,
|
||||
connectionId: 'ssh-1',
|
||||
issueSourcePreference: 'origin' as const
|
||||
}
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getRepos: () => [remoteRepo],
|
||||
getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined)
|
||||
}
|
||||
const provider = {
|
||||
exec: vi.fn(async (args: string[]) => {
|
||||
if (args[0] === 'rev-parse' && args[1] === '--verify' && args[2] === 'origin/feature/fix') {
|
||||
return { stdout: 'same-repo-mr-sha\n', stderr: '' }
|
||||
}
|
||||
throw new Error(`unexpected git call: ${args.join(' ')}`)
|
||||
}),
|
||||
fetchGitLabMergeRequestHead: vi.fn().mockResolvedValue(undefined),
|
||||
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
registerSshGitProvider('ssh-1', provider as never)
|
||||
getGlabKnownHostsMock.mockResolvedValue(['gitlab.com', 'git.internal'])
|
||||
getGitLabProjectRefForRemoteMock.mockResolvedValue({
|
||||
host: 'gitlab.example',
|
||||
path: 'group/repo'
|
||||
})
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
|
||||
const result = await runtime.resolveManagedMrBase({
|
||||
repoSelector: 'id:repo-1',
|
||||
mrIid: 78,
|
||||
sourceBranch: 'feature/fix',
|
||||
targetBranch: 'main'
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
baseBranch: 'origin/feature/fix',
|
||||
compareBaseRef: 'refs/remotes/origin/main',
|
||||
pushTarget: { remoteName: 'origin', branchName: 'feature/fix' }
|
||||
})
|
||||
expect(provider.fetchRemoteTrackingRef).toHaveBeenCalledWith(
|
||||
'/remote/repo',
|
||||
'origin',
|
||||
'feature/fix',
|
||||
'refs/remotes/origin/feature/fix'
|
||||
)
|
||||
expect(provider.fetchRemoteTrackingRef).toHaveBeenCalledWith(
|
||||
'/remote/repo',
|
||||
'origin',
|
||||
'main',
|
||||
'refs/remotes/origin/main'
|
||||
)
|
||||
expect(provider.fetchGitLabMergeRequestHead).not.toHaveBeenCalled()
|
||||
expect(provider.exec).toHaveBeenCalledWith(
|
||||
['rev-parse', '--verify', 'origin/feature/fix'],
|
||||
'/remote/repo'
|
||||
)
|
||||
await expect(
|
||||
runtime.resolveManagedMrBase({
|
||||
repoSelector: 'id:repo-1',
|
||||
mrIid: 77,
|
||||
sourceBranch: 'feature',
|
||||
targetBranch: 'main',
|
||||
isCrossRepository: true
|
||||
})
|
||||
).rejects.toThrow(SshHostNotServedError)
|
||||
expect(getGlabKnownHostsMock).not.toHaveBeenCalled()
|
||||
expect(gitSpy).not.toHaveBeenCalled()
|
||||
gitSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('keeps the MR source base when the optional compare-base fetch fails', async () => {
|
||||
@@ -260,68 +157,4 @@ describe('OrcaRuntimeService', () => {
|
||||
gitSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a cross-repo fork MR compare base when the fetch fails but the local ref resolves', async () => {
|
||||
// Why: mirror the GitHub fork soft-fail-keep — a transient compare-base fetch
|
||||
// failure must not drop a base we already have on disk onto the fork MR head SHA.
|
||||
const remoteRepo = {
|
||||
id: TEST_REPO_ID,
|
||||
path: '/remote/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1,
|
||||
connectionId: 'ssh-1',
|
||||
issueSourcePreference: 'origin' as const
|
||||
}
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getRepos: () => [remoteRepo],
|
||||
getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined)
|
||||
}
|
||||
const durableLocalRef = `refs/orca/merge-requests/${ORIGIN_HEAD_COMPONENT}/77`
|
||||
const provider = {
|
||||
exec: vi.fn(async (args: string[]) => {
|
||||
if (args[0] === 'rev-parse' && args[2] === `${durableLocalRef}^{commit}`) {
|
||||
return { stdout: 'remote-fork-mr-sha\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args[2] === 'refs/remotes/origin/main^{commit}') {
|
||||
return { stdout: 'base-commit-sha\n', stderr: '' }
|
||||
}
|
||||
throw new Error(`unexpected git call: ${args.join(' ')}`)
|
||||
}),
|
||||
fetchGitLabMergeRequestHead: vi.fn().mockResolvedValue(durableLocalRef),
|
||||
fetchRemoteTrackingRef: vi.fn(async () => {
|
||||
throw new Error('fatal: unable to access repo: Could not resolve host: gitlab.example')
|
||||
})
|
||||
}
|
||||
registerSshGitProvider('ssh-1', provider as never)
|
||||
getGlabKnownHostsMock.mockResolvedValue(['gitlab.com', 'git.internal'])
|
||||
getGitLabProjectRefForRemoteMock.mockResolvedValue({
|
||||
host: 'gitlab.example',
|
||||
path: 'group/repo'
|
||||
})
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
|
||||
try {
|
||||
const result = await runtime.resolveManagedMrBase({
|
||||
repoSelector: 'id:repo-1',
|
||||
mrIid: 77,
|
||||
sourceBranch: 'contrib/remote-fix',
|
||||
targetBranch: 'main',
|
||||
isCrossRepository: true
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
baseBranch: 'remote-fork-mr-sha',
|
||||
compareBaseRef: 'refs/remotes/origin/main'
|
||||
})
|
||||
expect(provider.exec).toHaveBeenCalledWith(
|
||||
['rev-parse', '--verify', 'refs/remotes/origin/main^{commit}'],
|
||||
'/remote/repo'
|
||||
)
|
||||
} finally {
|
||||
warnSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
OrcaRuntimeService,
|
||||
addGitHubIssueCommentMock,
|
||||
@@ -15,12 +15,9 @@ import {
|
||||
listGitHubIssuesMock,
|
||||
listGitHubLabelsMock,
|
||||
listGitHubWorkItemsMock,
|
||||
parseOrcaYaml,
|
||||
registerSshFilesystemProvider,
|
||||
removeGitHubPRReviewersMock,
|
||||
requestGitHubPRReviewersMock,
|
||||
setPlatform,
|
||||
unregisterSshFilesystemProvider,
|
||||
updateGitHubIssueMock
|
||||
} from '../orca-runtime-test-mocks.spec'
|
||||
import { TEST_REPO_ID, TEST_REPO_PATH, store } from '../orca-runtime-test-fixtures.spec'
|
||||
@@ -52,26 +49,13 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('reports ok for a missing remote orca.yaml and error for any other read failure', async () => {
|
||||
const readFile = vi.fn()
|
||||
registerSshFilesystemProvider('ssh-1', { readFile } as never)
|
||||
it('reports an SSH orca.yaml as an error without reading any filesystem', async () => {
|
||||
const runtime = new OrcaRuntimeService(remoteStore as never)
|
||||
|
||||
try {
|
||||
readFile.mockRejectedValueOnce(Object.assign(new Error('missing'), { code: 'ENOENT' }))
|
||||
await expect(runtime.checkRepoHooks('id:repo-1')).resolves.toMatchObject({
|
||||
status: 'ok',
|
||||
hasHooks: false
|
||||
})
|
||||
|
||||
readFile.mockRejectedValueOnce(Object.assign(new Error('down'), { code: 'ECONNRESET' }))
|
||||
await expect(runtime.checkRepoHooks('id:repo-1')).resolves.toMatchObject({
|
||||
status: 'error',
|
||||
hasHooks: false
|
||||
})
|
||||
} finally {
|
||||
unregisterSshFilesystemProvider('ssh-1')
|
||||
}
|
||||
await expect(runtime.checkRepoHooks('id:repo-1')).resolves.toMatchObject({
|
||||
status: 'error',
|
||||
hasHooks: false
|
||||
})
|
||||
})
|
||||
|
||||
it('reports ok for a local repo hook check', async () => {
|
||||
@@ -81,7 +65,7 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves SSH issue commands from shared orca.yaml and deletes empty overrides', async () => {
|
||||
it('reads SSH issue commands as absent and drops writes without touching any filesystem', async () => {
|
||||
const remoteStore = {
|
||||
...store,
|
||||
getRepos: () => [
|
||||
@@ -95,48 +79,18 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
]
|
||||
}
|
||||
vi.mocked(parseOrcaYaml).mockReturnValue({
|
||||
scripts: {},
|
||||
issueCommand: 'claude -p "Fix #{{issue}}"'
|
||||
})
|
||||
const fsProvider = {
|
||||
readFile: vi.fn(async (filePath: string) => {
|
||||
if (filePath.endsWith('.orca/issue-command')) {
|
||||
throw Object.assign(new Error('missing'), { code: 'ENOENT' })
|
||||
}
|
||||
if (filePath.endsWith('orca.yaml')) {
|
||||
return { content: 'issueCommand: claude -p "Fix #{{issue}}"', isBinary: false }
|
||||
}
|
||||
return { content: '', isBinary: false }
|
||||
}),
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
createDir: vi.fn().mockResolvedValue(undefined),
|
||||
deletePath: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
registerSshFilesystemProvider('ssh-1', fsProvider as never)
|
||||
const runtime = new OrcaRuntimeService(remoteStore as never)
|
||||
|
||||
try {
|
||||
await expect(runtime.readRepoIssueCommand('id:repo-1')).resolves.toMatchObject({
|
||||
localContent: null,
|
||||
sharedContent: 'claude -p "Fix #{{issue}}"',
|
||||
effectiveContent: 'claude -p "Fix #{{issue}}"',
|
||||
localFilePath: '/remote/repo/.orca/issue-command',
|
||||
source: 'shared'
|
||||
})
|
||||
await expect(runtime.writeRepoIssueCommand('id:repo-1', ' ')).resolves.toEqual({
|
||||
ok: true
|
||||
})
|
||||
} finally {
|
||||
unregisterSshFilesystemProvider('ssh-1')
|
||||
}
|
||||
|
||||
expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/orca.yaml')
|
||||
expect(fsProvider.deletePath).toHaveBeenCalledWith('/remote/repo/.orca/issue-command', false)
|
||||
expect(fsProvider.writeFile).not.toHaveBeenCalledWith(
|
||||
'/remote/repo/.orca/issue-command',
|
||||
expect.anything()
|
||||
)
|
||||
await expect(runtime.readRepoIssueCommand('id:repo-1')).resolves.toMatchObject({
|
||||
localContent: null,
|
||||
sharedContent: null,
|
||||
effectiveContent: null,
|
||||
localFilePath: '/remote/repo/.orca/issue-command',
|
||||
source: 'none'
|
||||
})
|
||||
await expect(runtime.writeRepoIssueCommand('id:repo-1', ' ')).resolves.toEqual({
|
||||
ok: true
|
||||
})
|
||||
})
|
||||
|
||||
it('allows host integration slug helpers for SSH repos through provider-aware GitHub clients', async () => {
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
registerSshFilesystemProvider,
|
||||
registerSshGitProvider,
|
||||
rm,
|
||||
tmpdir,
|
||||
unregisterSshFilesystemProvider
|
||||
@@ -38,7 +37,7 @@ describe('OrcaRuntimeService', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('returns capped SSH refs for empty runtime repo searches', async () => {
|
||||
it('reports no refs for an SSH repo and still reports the execution cap', async () => {
|
||||
const remoteRepo = {
|
||||
id: 'remote-repo',
|
||||
path: '/home/user/repo',
|
||||
@@ -52,156 +51,16 @@ describe('OrcaRuntimeService', () => {
|
||||
getRepos: () => [remoteRepo],
|
||||
getRepo: () => remoteRepo
|
||||
}
|
||||
const provider = {
|
||||
exec: vi.fn().mockImplementation((argv: string[]) => {
|
||||
if (argv[0] === 'remote') {
|
||||
return Promise.resolve({ stdout: 'origin\nupstream\n', stderr: '' })
|
||||
}
|
||||
return Promise.resolve({
|
||||
stdout: [
|
||||
'refs/remotes/origin/main\0origin/main',
|
||||
'refs/remotes/upstream/feature-x\0upstream/feature-x',
|
||||
'refs/remotes/upstream/HEAD\0upstream/HEAD',
|
||||
'refs/heads/local-only\0local-only'
|
||||
].join('\n'),
|
||||
stderr: ''
|
||||
})
|
||||
})
|
||||
}
|
||||
registerSshGitProvider('ssh-1', provider as never)
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
|
||||
const result = await runtime.searchRepoRefs('id:remote-repo', '', 2)
|
||||
|
||||
expect(result).toEqual({
|
||||
refs: ['origin/main', 'upstream/feature-x'],
|
||||
refDetails: [
|
||||
{ refName: 'origin/main', localBranchName: 'main' },
|
||||
{ refName: 'upstream/feature-x', localBranchName: 'feature-x' }
|
||||
],
|
||||
truncated: true
|
||||
await expect(runtime.searchRepoRefs('id:remote-repo', '', 5)).resolves.toEqual({
|
||||
refs: [],
|
||||
refDetails: [],
|
||||
truncated: false
|
||||
})
|
||||
expect(provider.exec).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
'--exclude=refs/remotes/*/HEAD',
|
||||
'--count=12',
|
||||
'refs/heads/**/**',
|
||||
'refs/heads/**/**/**',
|
||||
'refs/remotes/**/**',
|
||||
'refs/remotes/**/**/**'
|
||||
]),
|
||||
'/home/user/repo'
|
||||
)
|
||||
expect(provider.exec).toHaveBeenCalledWith(['remote'], '/home/user/repo')
|
||||
})
|
||||
|
||||
it('clamps oversized SSH ref-search limits and reports the execution cap', async () => {
|
||||
const remoteRepo = {
|
||||
id: 'remote-repo-large-limit',
|
||||
path: '/home/user/repo',
|
||||
displayName: 'remote',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1,
|
||||
connectionId: 'ssh-large-limit'
|
||||
}
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getRepos: () => [remoteRepo],
|
||||
getRepo: () => remoteRepo
|
||||
}
|
||||
const provider = {
|
||||
exec: vi.fn().mockImplementation((argv: string[]) => {
|
||||
if (argv[0] === 'remote') {
|
||||
return Promise.resolve({ stdout: 'origin\n', stderr: '' })
|
||||
}
|
||||
return Promise.resolve({
|
||||
stdout: 'refs/remotes/origin/main\0origin/main',
|
||||
stderr: ''
|
||||
})
|
||||
})
|
||||
}
|
||||
registerSshGitProvider('ssh-large-limit', provider as never)
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
|
||||
const result = await runtime.searchRepoRefs(
|
||||
'id:remote-repo-large-limit',
|
||||
'',
|
||||
REPO_SEARCH_REFS_MAX_LIMIT + 1
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
refs: ['origin/main'],
|
||||
refDetails: [{ refName: 'origin/main', localBranchName: 'main' }],
|
||||
truncated: true
|
||||
})
|
||||
const forEachRefCall = provider.exec.mock.calls.find(
|
||||
(call) => (call[0] as string[])[0] === 'for-each-ref'
|
||||
)
|
||||
expect(forEachRefCall?.[0]).toContain('--count=4004')
|
||||
})
|
||||
|
||||
it('retries runtime SSH ref searches without --exclude for older git hosts', async () => {
|
||||
const remoteRepo = {
|
||||
id: 'remote-repo',
|
||||
path: '/home/user/repo',
|
||||
displayName: 'remote',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1,
|
||||
connectionId: 'ssh-1'
|
||||
}
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getRepos: () => [remoteRepo],
|
||||
getRepo: () => remoteRepo
|
||||
}
|
||||
const provider = {
|
||||
exec: vi.fn().mockImplementation((argv: string[]) => {
|
||||
if (argv[0] === 'remote') {
|
||||
return Promise.resolve({ stdout: 'origin\n', stderr: '' })
|
||||
}
|
||||
if (argv.some((arg) => arg.startsWith('--exclude=refs/remotes/'))) {
|
||||
return Promise.reject(
|
||||
Object.assign(new Error("unknown option `exclude'"), {
|
||||
stderr: "error: unknown option `exclude'"
|
||||
})
|
||||
)
|
||||
}
|
||||
return Promise.resolve({
|
||||
stdout: [
|
||||
'refs/remotes/origin/main\0origin/main',
|
||||
'refs/remotes/origin/HEAD\0origin/HEAD',
|
||||
'refs/remotes/origin/feature-x\0origin/feature-x'
|
||||
].join('\n'),
|
||||
stderr: ''
|
||||
})
|
||||
})
|
||||
}
|
||||
registerSshGitProvider('ssh-1', provider as never)
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
|
||||
const result = await runtime.searchRepoRefs('id:remote-repo', '', 1)
|
||||
const repeatedResult = await runtime.searchRepoRefs('id:remote-repo', '', 1)
|
||||
|
||||
expect(result).toEqual({
|
||||
refs: ['origin/main'],
|
||||
refDetails: [{ refName: 'origin/main', localBranchName: 'main' }],
|
||||
truncated: true
|
||||
})
|
||||
expect(repeatedResult).toEqual(result)
|
||||
const forEachRefCalls = provider.exec.mock.calls.filter(
|
||||
(call) => (call[0] as string[])[0] === 'for-each-ref'
|
||||
)
|
||||
expect(forEachRefCalls).toHaveLength(3)
|
||||
expect(
|
||||
(forEachRefCalls[0][0] as string[]).some((arg) => arg.startsWith('--exclude=refs/remotes/'))
|
||||
).toBe(true)
|
||||
expect(
|
||||
(forEachRefCalls[1][0] as string[]).some((arg) => arg.startsWith('--exclude=refs/remotes/'))
|
||||
).toBe(false)
|
||||
expect(forEachRefCalls[1][0]).toContain('--count=108')
|
||||
expect(
|
||||
(forEachRefCalls[2][0] as string[]).some((arg) => arg.startsWith('--exclude=refs/remotes/'))
|
||||
).toBe(false)
|
||||
await expect(
|
||||
runtime.searchRepoRefs('id:remote-repo', '', REPO_SEARCH_REFS_MAX_LIMIT + 1)
|
||||
).resolves.toEqual({ refs: [], refDetails: [], truncated: true })
|
||||
})
|
||||
|
||||
it('resolves SSH worktrees when manually updating lineage', async () => {
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
listWorktrees,
|
||||
loadHooks,
|
||||
muxRequestMock,
|
||||
parseOrcaYaml,
|
||||
registerSshFilesystemProvider,
|
||||
registerSshGitProvider,
|
||||
removeWorktree,
|
||||
@@ -537,7 +536,7 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(removeWorktree).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads SSH repo hooks through the SSH filesystem provider', async () => {
|
||||
it('reports SSH repo hooks as absent without reading this machine', async () => {
|
||||
const remoteStore = {
|
||||
...store,
|
||||
getRepos: () => [
|
||||
@@ -551,31 +550,13 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
]
|
||||
}
|
||||
const fsProvider = {
|
||||
readFile: vi.fn().mockResolvedValue({
|
||||
content: 'scripts:\n setup: pnpm install\n',
|
||||
isBinary: false
|
||||
})
|
||||
}
|
||||
vi.mocked(parseOrcaYaml).mockReturnValue({ scripts: { setup: 'pnpm install' } })
|
||||
registerSshFilesystemProvider('ssh-1', fsProvider as never)
|
||||
const runtime = new OrcaRuntimeService(remoteStore as never)
|
||||
|
||||
try {
|
||||
await expect(runtime.getRepoHooks('id:repo-1')).resolves.toMatchObject({
|
||||
hasHooksFile: true,
|
||||
hooks: { scripts: { setup: 'pnpm install' } },
|
||||
source: 'orca.yaml',
|
||||
setupTrust: {
|
||||
contentHash: '005d0b7e5c261dcc5e2f8568e69a0b30e889a3275b55b18ec20a7deef0081e90',
|
||||
scriptContent: 'pnpm install'
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
unregisterSshFilesystemProvider('ssh-1')
|
||||
}
|
||||
|
||||
expect(fsProvider.readFile).toHaveBeenCalledWith('C:\\remote\\repo\\orca.yaml')
|
||||
await expect(runtime.getRepoHooks('id:repo-1')).resolves.toMatchObject({
|
||||
hasHooksFile: false,
|
||||
hooks: null,
|
||||
source: null
|
||||
})
|
||||
expect(hasHooksFile).not.toHaveBeenCalled()
|
||||
expect(getEffectiveHooks).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
OrcaRuntimeService,
|
||||
registerSshFilesystemProvider,
|
||||
unregisterSshFilesystemProvider
|
||||
} from '../orca-runtime-test-mocks.spec'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { OrcaRuntimeService } from '../orca-runtime-test-mocks.spec'
|
||||
import { TEST_REPO_ID, store } from '../orca-runtime-test-fixtures.spec'
|
||||
|
||||
describe('OrcaRuntimeService', () => {
|
||||
it('uses remote path joins for SSH hook checks and issue-command files', async () => {
|
||||
it('uses remote path joins for SSH issue-command files and touches no filesystem', async () => {
|
||||
const remoteStore = {
|
||||
...store,
|
||||
getRepos: () => [
|
||||
@@ -21,49 +17,19 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
]
|
||||
}
|
||||
const fsProvider = {
|
||||
readFile: vi.fn(async (filePath: string) => ({
|
||||
content: filePath.endsWith('orca.yaml')
|
||||
? 'scripts:\n setup: pnpm install\n'
|
||||
: filePath.endsWith('.gitignore')
|
||||
? 'node_modules\n'
|
||||
: 'Fix it',
|
||||
isBinary: false
|
||||
})),
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
createDir: vi.fn().mockResolvedValue(undefined),
|
||||
deletePath: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
registerSshFilesystemProvider('ssh-1', fsProvider as never)
|
||||
const runtime = new OrcaRuntimeService(remoteStore as never)
|
||||
|
||||
try {
|
||||
await expect(runtime.checkRepoHooks('id:repo-1')).resolves.toMatchObject({
|
||||
hasHooks: true,
|
||||
mayNeedUpdate: false
|
||||
})
|
||||
await expect(runtime.readRepoIssueCommand('id:repo-1')).resolves.toMatchObject({
|
||||
localContent: 'Fix it',
|
||||
effectiveContent: 'Fix it',
|
||||
localFilePath: 'C:\\remote\\repo\\.orca\\issue-command'
|
||||
})
|
||||
await expect(runtime.writeRepoIssueCommand('id:repo-1', 'Ship it')).resolves.toEqual({
|
||||
ok: true
|
||||
})
|
||||
} finally {
|
||||
unregisterSshFilesystemProvider('ssh-1')
|
||||
}
|
||||
|
||||
expect(fsProvider.readFile).toHaveBeenCalledWith('C:\\remote\\repo\\orca.yaml')
|
||||
expect(fsProvider.readFile).toHaveBeenCalledWith('C:\\remote\\repo\\.orca\\issue-command')
|
||||
expect(fsProvider.createDir).toHaveBeenCalledWith('C:\\remote\\repo\\.orca')
|
||||
expect(fsProvider.writeFile).toHaveBeenCalledWith(
|
||||
'C:\\remote\\repo\\.orca\\issue-command',
|
||||
'Ship it\n'
|
||||
)
|
||||
expect(fsProvider.writeFile).toHaveBeenCalledWith(
|
||||
'C:\\remote\\repo\\.gitignore',
|
||||
'node_modules\n.orca\n'
|
||||
)
|
||||
await expect(runtime.checkRepoHooks('id:repo-1')).resolves.toMatchObject({
|
||||
status: 'error',
|
||||
hasHooks: false
|
||||
})
|
||||
await expect(runtime.readRepoIssueCommand('id:repo-1')).resolves.toMatchObject({
|
||||
localContent: null,
|
||||
effectiveContent: null,
|
||||
localFilePath: 'C:\\remote\\repo\\.orca\\issue-command'
|
||||
})
|
||||
await expect(runtime.writeRepoIssueCommand('id:repo-1', 'Ship it')).resolves.toEqual({
|
||||
ok: true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
-74
@@ -22,7 +22,6 @@ import {
|
||||
localWorktreeFilesystem,
|
||||
lstat,
|
||||
mkdir,
|
||||
registerSshGitProvider,
|
||||
removeWorktree,
|
||||
removeWorktreeLinkedPathsMock,
|
||||
restoreLocalWatcherAfterFailedRemovalMock,
|
||||
@@ -30,10 +29,8 @@ import {
|
||||
rm,
|
||||
runHook,
|
||||
setPlatform,
|
||||
unregisterSshGitProvider,
|
||||
writeFile
|
||||
} from '../orca-runtime-test-mocks.spec'
|
||||
import type { WorktreeMeta } from '../orca-runtime-test-mocks.spec'
|
||||
import {
|
||||
TEST_REPO_ID,
|
||||
TEST_REPO_PATH,
|
||||
@@ -609,75 +606,4 @@ describe('OrcaRuntimeService', () => {
|
||||
'def456'
|
||||
)
|
||||
})
|
||||
|
||||
it('force-deletes an SSH branch that was preserved by runtime worktree removal', async () => {
|
||||
const remoteRepo = {
|
||||
...store.getRepo(TEST_REPO_ID)!,
|
||||
path: '/remote/repo',
|
||||
connectionId: 'ssh-1'
|
||||
}
|
||||
const remoteWorktree = {
|
||||
path: '/remote/feature-wt',
|
||||
head: 'def456',
|
||||
branch: 'feature/test',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
const remoteWorktreeId = `${remoteRepo.id}::${remoteWorktree.path}`
|
||||
const metaById: Record<string, WorktreeMeta> = {
|
||||
[remoteWorktreeId]: makeWorktreeMeta()
|
||||
}
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getRepos: () => [remoteRepo],
|
||||
getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined),
|
||||
getAllWorktreeMeta: () => metaById,
|
||||
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
|
||||
setWorktreeMeta: (worktreeId: string, meta: Partial<WorktreeMeta>) => {
|
||||
metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta }
|
||||
return metaById[worktreeId]
|
||||
},
|
||||
removeWorktreeMeta: (worktreeId: string) => {
|
||||
delete metaById[worktreeId]
|
||||
}
|
||||
}
|
||||
const provider = {
|
||||
exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }),
|
||||
forceDeletePreservedBranch: vi.fn().mockResolvedValue(undefined),
|
||||
listWorktrees: vi.fn().mockResolvedValue([
|
||||
{
|
||||
path: remoteRepo.path,
|
||||
head: 'main',
|
||||
branch: 'main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
},
|
||||
remoteWorktree
|
||||
]),
|
||||
removeWorktree: vi.fn().mockResolvedValue({
|
||||
preservedBranch: { branchName: 'feature/test', head: 'def456' }
|
||||
})
|
||||
}
|
||||
registerSshGitProvider('ssh-1', provider as never)
|
||||
const runtime = createWorktreeRemovalRuntime(runtimeStore)
|
||||
|
||||
try {
|
||||
await runtime.removeManagedWorktree(remoteWorktreeId)
|
||||
const result = await runtime.forceDeletePreservedBranch(
|
||||
remoteWorktreeId,
|
||||
'feature/test',
|
||||
'def456'
|
||||
)
|
||||
|
||||
expect(result).toEqual({ deleted: true })
|
||||
expect(provider.forceDeletePreservedBranch).toHaveBeenCalledWith(
|
||||
'/remote/repo',
|
||||
'feature/test',
|
||||
'def456'
|
||||
)
|
||||
expect(forceDeleteLocalBranchMock).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
unregisterSshGitProvider('ssh-1')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
+6
-13
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
|
||||
import { resetWorktreeTestSshHostHome } from '../../worktree-removal-test-ssh-host-home'
|
||||
import { _settlePendingWorktreeRemovalsForTests } from '../../worktree-background-removal'
|
||||
import { SshHostNotServedError } from '../../providers/ssh-host-not-served-error'
|
||||
|
||||
import {
|
||||
OrcaRuntimeService,
|
||||
@@ -109,19 +110,11 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
expect(provider.removeWorktree).toHaveBeenCalledWith(TEST_WORKTREE_PATH, false)
|
||||
expect(metaById[TEST_WORKTREE_ID]?.hostId).toBe('local')
|
||||
const result = await runtime.forceDeletePreservedBranch(
|
||||
TEST_WORKTREE_ID,
|
||||
'feature/test',
|
||||
'def456',
|
||||
'ssh:ssh-1'
|
||||
)
|
||||
|
||||
expect(result).toEqual({ deleted: true })
|
||||
expect(provider.forceDeletePreservedBranch).toHaveBeenCalledWith(
|
||||
remoteRepo.path,
|
||||
'feature/test',
|
||||
'def456'
|
||||
)
|
||||
// The SSH host is not served here, so the qualified host refuses instead of deleting locally.
|
||||
await expect(
|
||||
runtime.forceDeletePreservedBranch(TEST_WORKTREE_ID, 'feature/test', 'def456', 'ssh:ssh-1')
|
||||
).rejects.toThrow(SshHostNotServedError)
|
||||
expect(provider.forceDeletePreservedBranch).not.toHaveBeenCalled()
|
||||
expect(forceDeleteLocalBranchMock).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
unregisterSshGitProvider('ssh-1')
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SshHostNotServedError } from '../../providers/ssh-host-not-served-error'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
FLOATING_TERMINAL_WORKTREE_ID,
|
||||
@@ -86,7 +87,7 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('routes SSH-backed forward-slash UNC file and git paths without collapsing the root', async () => {
|
||||
it('resolves SSH-backed forward-slash UNC paths without collapsing the root, then refuses the work', async () => {
|
||||
vi.mocked(listWorktrees).mockClear()
|
||||
vi.mocked(listWorktrees).mockRejectedValue(new Error('local git should not run for SSH repos'))
|
||||
const remoteStore = {
|
||||
@@ -134,8 +135,12 @@ describe('OrcaRuntimeService', () => {
|
||||
const runtime = new OrcaRuntimeService(remoteStore as never)
|
||||
|
||||
try {
|
||||
await runtime.readFileExplorerDir('path://server/share/repo', 'src')
|
||||
await runtime.getRuntimeGitStatus('path://server/share/repo')
|
||||
await expect(runtime.readFileExplorerDir('path://server/share/repo', 'src')).rejects.toThrow(
|
||||
SshHostNotServedError
|
||||
)
|
||||
await expect(runtime.getRuntimeGitStatus('path://server/share/repo')).rejects.toThrow(
|
||||
SshHostNotServedError
|
||||
)
|
||||
await expect(runtime.showRepo('path://server/share/repo')).resolves.toMatchObject({
|
||||
path: '//Server/Share/Repo'
|
||||
})
|
||||
@@ -146,8 +151,8 @@ describe('OrcaRuntimeService', () => {
|
||||
|
||||
expect(listWorktrees).not.toHaveBeenCalled()
|
||||
expect(gitProvider.listWorktrees).toHaveBeenCalledWith('//Server/Share/Repo')
|
||||
expect(fsProvider.readDir).toHaveBeenCalledWith('\\\\Server\\Share\\Repo\\src', {})
|
||||
expect(gitProvider.getStatus).toHaveBeenCalledWith('//Server/Share/Repo')
|
||||
expect(fsProvider.readDir).not.toHaveBeenCalled()
|
||||
expect(gitProvider.getStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -174,7 +179,7 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('routes SSH folder workspace file explorer paths through the filesystem provider', async () => {
|
||||
it('refuses SSH folder workspace file explorer reads instead of reading this machine', async () => {
|
||||
const folderPath = '/srv/platform'
|
||||
const fsProvider = {
|
||||
stat: vi.fn(async (pathValue: string) => ({
|
||||
@@ -201,24 +206,16 @@ describe('OrcaRuntimeService', () => {
|
||||
try {
|
||||
await expect(
|
||||
runtime.readFileExplorerDir(`id:${TEST_FOLDER_WORKSPACE_KEY}`, 'src')
|
||||
).resolves.toHaveLength(1)
|
||||
).rejects.toThrow(SshHostNotServedError)
|
||||
await expect(
|
||||
runtime.readFileExplorerPreview(`id:${TEST_FOLDER_WORKSPACE_KEY}`, 'src/app.ts')
|
||||
).resolves.toMatchObject({
|
||||
content: 'remote\n',
|
||||
isBinary: false
|
||||
})
|
||||
).rejects.toThrow(SshHostNotServedError)
|
||||
} finally {
|
||||
unregisterSshFilesystemProvider('ssh-folder')
|
||||
}
|
||||
|
||||
expect(fsProvider.stat).toHaveBeenCalledWith(folderPath)
|
||||
expect(fsProvider.readDir).toHaveBeenCalledWith('/srv/platform/src', {})
|
||||
expect(fsProvider.stat).toHaveBeenCalledWith('/srv/platform/src/app.ts')
|
||||
expect(fsProvider.readFile).toHaveBeenCalledWith('/srv/platform/src/app.ts', {
|
||||
maxBinaryBytes: 10 * 1024 * 1024,
|
||||
maxTextBytes: 512 * 1024
|
||||
})
|
||||
expect(fsProvider.readDir).not.toHaveBeenCalled()
|
||||
expect(fsProvider.readFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lists persisted SSH worktrees while the git provider is unavailable', async () => {
|
||||
|
||||
@@ -2,13 +2,8 @@ import './rpc/unused-default-rpc-methods.test-fixture'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { OrcaRuntimeWithListManagedWorktrees } from './orca-runtime-list-managed-worktrees'
|
||||
|
||||
const muxes = vi.hoisted(() => new Map<string, { request: ReturnType<typeof vi.fn> }>())
|
||||
const scanWorkspacePortsMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('../ssh/ssh-target-registry', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
getActiveMultiplexer: (targetId: string) => muxes.get(targetId)
|
||||
}))
|
||||
vi.mock('../ports/local-workspace-port-scanner', () => ({
|
||||
scanWorkspacePorts: scanWorkspacePortsMock
|
||||
}))
|
||||
@@ -31,29 +26,19 @@ function runtimeResolvingTo(executionHostId: string) {
|
||||
|
||||
describe('host-scoped workspace port scans on the server', () => {
|
||||
afterEach(() => {
|
||||
muxes.clear()
|
||||
scanWorkspacePortsMock.mockReset()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("scans a folder workspace's SSH host, not this server", async () => {
|
||||
const request = vi.fn(async () => ({ platform: 'linux', ports: [{ port: 3020, host: '::' }] }))
|
||||
muxes.set('box', { request })
|
||||
it("reports a folder workspace's SSH host unavailable instead of scanning this server", async () => {
|
||||
const runtime = runtimeResolvingTo('ssh:box')
|
||||
|
||||
const result = await runtime.scan('folder:fw-1')
|
||||
|
||||
expect(runtime.host.resolveRuntimeFileTarget).toHaveBeenCalledWith('folder:fw-1')
|
||||
expect(request).toHaveBeenCalledWith('ports.detect', undefined, expect.any(Object))
|
||||
expect(scanWorkspacePortsMock).not.toHaveBeenCalled()
|
||||
expect(result).toMatchObject({ executionHostId: 'ssh:box', ports: [{ port: 3020 }] })
|
||||
})
|
||||
|
||||
it('answers unavailable for an SSH workspace whose host is not connected', async () => {
|
||||
const result = await runtimeResolvingTo('ssh:box').scan('id:repo::/srv/app')
|
||||
|
||||
expect(result.unavailableReason).toMatch(/not connected/)
|
||||
expect(scanWorkspacePortsMock).not.toHaveBeenCalled()
|
||||
expect(result).toMatchObject({ executionHostId: 'ssh:box', ports: [] })
|
||||
expect(result.unavailableReason).toMatch(/Reconnect/)
|
||||
})
|
||||
|
||||
it('refuses Stop when the workspace now resolves to a different host than the row', async () => {
|
||||
|
||||
@@ -8,19 +8,11 @@ import {
|
||||
} from '../../../../shared/remote-rpc-content-budget'
|
||||
import { assertGitDiffWithinTransportBudget } from '../../../../shared/git-diff-transport-budget'
|
||||
import type { GitDiffResult } from '../../../../shared/git-diff-compare-types'
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { RuntimeGitCommands, type ResolvedRuntimeGitWorktree } from '../../orca-runtime-git'
|
||||
import type { RpcRequest, RpcResponse } from '../core'
|
||||
import { RpcDispatcher } from '../dispatcher'
|
||||
import { GIT_METHODS } from './git'
|
||||
|
||||
const sshMocks = vi.hoisted(() => ({ getSshGitProvider: vi.fn() }))
|
||||
|
||||
vi.mock('../../../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProvider: sshMocks.getSshGitProvider
|
||||
}))
|
||||
|
||||
const OVERSIZED_BASE64 = 'A'.repeat(REMOTE_RPC_MAX_CONTENT_BYTES + 1024)
|
||||
|
||||
const OVERSIZED_DIFF: GitDiffResult = {
|
||||
@@ -152,26 +144,4 @@ describe('remote git diff transport budget', () => {
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// Why: an SSH host forwards its provider's payload verbatim, so the cap cannot rely on the far
|
||||
// side clamping — this walks the real RuntimeGitCommands with an unclamped forwarded diff.
|
||||
it('caps an SSH-forwarded diff a remote client requested', async () => {
|
||||
sshMocks.getSshGitProvider.mockReturnValue({
|
||||
getDiff: vi.fn().mockResolvedValue(OVERSIZED_DIFF)
|
||||
})
|
||||
const commands = new RuntimeGitCommands({
|
||||
resolveRuntimeGitTarget: async () => ({
|
||||
worktree: { id: 'wt-1', path: '/remote/repo' } as unknown as ResolvedRuntimeGitWorktree,
|
||||
executionHostId: 'ssh:conn-1'
|
||||
}),
|
||||
getRuntimeSettings: () => ({}) as GlobalSettings
|
||||
})
|
||||
const runtime = Object.assign(commands, {
|
||||
getRuntimeId: () => 'test-runtime'
|
||||
}) as unknown as OrcaRuntimeService
|
||||
|
||||
const response = await dispatchRemote(runtime, 'git.diff', CASES[0]!.params, 'mobile')
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'diff_too_large' } })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,7 +22,6 @@ import { joinWorktreeRelativePath, normalizeRuntimeRelativePath } from './runtim
|
||||
import { isENOENT } from '../ipc/filesystem-path-containment'
|
||||
import {
|
||||
requireRuntimeFileProvider,
|
||||
runtimeFileRouteForTarget,
|
||||
type RuntimeFileExplorerPath
|
||||
} from './runtime-file-command-target'
|
||||
import { parseExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
|
||||
@@ -91,15 +90,11 @@ export class RuntimeFileCommandAccess {
|
||||
filePath: string,
|
||||
target: { executionHostId: ExecutionHostId }
|
||||
): Promise<void> {
|
||||
const route = runtimeFileRouteForTarget(target)
|
||||
let stats: FileStat
|
||||
try {
|
||||
stats = await requireRuntimeFileProvider(target, this.host, route).stat(filePath)
|
||||
stats = await requireRuntimeFileProvider(target, this.host).stat(filePath)
|
||||
} catch (error) {
|
||||
if (
|
||||
isENOENT(error) ||
|
||||
(route.kind === 'ssh' && RuntimeFileCommandAccess.isRemoteNotFoundErrorMessage(error))
|
||||
) {
|
||||
if (isENOENT(error)) {
|
||||
throw new Error(`ENOENT: no such file or directory, open '${filePath}'`)
|
||||
}
|
||||
throw error
|
||||
@@ -190,12 +185,6 @@ export class RuntimeFileCommandAccess {
|
||||
...(fileStat.ctimeMs === undefined ? {} : { ctime: fileStat.ctimeMs })
|
||||
}
|
||||
}
|
||||
|
||||
// The mux drops ErrnoException.code, so match not-found by message shape (vs transport/permission/provider errors).
|
||||
protected static isRemoteNotFoundErrorMessage(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return /\bENOENT\b|no such file|not found|does not exist/i.test(message)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: name the resolved owner; undefined would let the renderer fall back to the focused server.
|
||||
|
||||
@@ -10,11 +10,7 @@ import type {
|
||||
LocalFilesystemProvider,
|
||||
LocalFilesystemProviderOptions
|
||||
} from '../providers/local-filesystem-provider'
|
||||
import {
|
||||
getSshFilesystemProvider,
|
||||
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE
|
||||
} from '../providers/ssh-filesystem-dispatch'
|
||||
import type { IFilesystemProvider } from '../providers/types'
|
||||
import { SshHostNotServedError } from '../providers/ssh-host-not-served-error'
|
||||
|
||||
export type ResolvedRuntimeFileWorktree = Worktree & { git: GitWorktreeInfo }
|
||||
|
||||
@@ -49,11 +45,8 @@ export type RuntimeFileExplorerPath = {
|
||||
*/
|
||||
export type RuntimeFileRoute =
|
||||
| { kind: 'local'; createProvider: typeof createLocalFilesystemProvider }
|
||||
/** `provider: null` is "remote and currently unreachable" — never "read it here". */
|
||||
| { kind: 'ssh'; connectionId: string; provider: IFilesystemProvider | null }
|
||||
|
||||
/** The remote half of the route, for leaf helpers that only ever run against an SSH host. */
|
||||
export type RuntimeFileSshRoute = Extract<RuntimeFileRoute, { kind: 'ssh' }>
|
||||
/** An SSH target this process does not serve; it fails or reports empty, never reads here. */
|
||||
| { kind: 'ssh'; connectionId: string }
|
||||
|
||||
export function runtimeFileRouteForTarget(target: {
|
||||
executionHostId: ExecutionHostId
|
||||
@@ -63,11 +56,7 @@ export function runtimeFileRouteForTarget(target: {
|
||||
case 'local':
|
||||
return { kind: 'local', createProvider: route.createProvider }
|
||||
case 'ssh':
|
||||
return {
|
||||
kind: 'ssh',
|
||||
connectionId: route.connectionId,
|
||||
provider: getSshFilesystemProvider(route.connectionId) ?? null
|
||||
}
|
||||
return { kind: 'ssh', connectionId: route.connectionId }
|
||||
case 'runtime':
|
||||
throw new ExecutionHostNotDispatchableError(route.hostId)
|
||||
}
|
||||
@@ -76,32 +65,28 @@ export function runtimeFileRouteForTarget(target: {
|
||||
/** What a runtime command supplies for local work; path authorization is always the store's. */
|
||||
export type RuntimeLocalFileHost = Omit<LocalFilesystemProviderOptions, 'resolveAuthorizedPath'>
|
||||
|
||||
/**
|
||||
* A provider for the target's own host. Local authorizes against `host`'s store; an unreachable SSH
|
||||
* host and a `runtime:` host both throw.
|
||||
*/
|
||||
/** The local route for work this process runs itself; `ssh:` and `runtime:` hosts throw. */
|
||||
export function requireLocalRuntimeFileRoute(target: {
|
||||
executionHostId: ExecutionHostId
|
||||
}): Extract<RuntimeFileRoute, { kind: 'local' }> {
|
||||
const route = runtimeFileRouteForTarget(target)
|
||||
if (route.kind === 'ssh') {
|
||||
throw new SshHostNotServedError(route.connectionId)
|
||||
}
|
||||
return route
|
||||
}
|
||||
|
||||
/** A provider for the target's own host, authorized against `host`'s store. */
|
||||
export function requireRuntimeFileProvider(
|
||||
target: { executionHostId: ExecutionHostId },
|
||||
host: RuntimeLocalFileHost,
|
||||
route: RuntimeFileRoute = runtimeFileRouteForTarget(target)
|
||||
): LocalFilesystemProvider | IFilesystemProvider {
|
||||
if (route.kind === 'ssh') {
|
||||
return requireSshRuntimeFileProvider(route)
|
||||
}
|
||||
return route.createProvider({
|
||||
host: RuntimeLocalFileHost
|
||||
): LocalFilesystemProvider {
|
||||
return requireLocalRuntimeFileRoute(target).createProvider({
|
||||
requireStore: () => host.requireStore(),
|
||||
resolveAuthorizedPath
|
||||
})
|
||||
}
|
||||
|
||||
/** For the commands whose local arm keeps its own limits or result shape. */
|
||||
export function requireSshRuntimeFileProvider(route: RuntimeFileSshRoute): IFilesystemProvider {
|
||||
if (!route.provider) {
|
||||
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
return route.provider
|
||||
}
|
||||
|
||||
/**
|
||||
* The SSH target id for the leaf helpers that still address a connection by name — watcher release
|
||||
* keys, re-arm registration, remote path stats. `undefined` is `local`; a `runtime:` host throws
|
||||
|
||||
+3
-86
@@ -1,19 +1,12 @@
|
||||
// @ts-nocheck -- mechanically split class members.
|
||||
import { RuntimeFileCommandsWithWriteTerminalArtifactFile } from './runtime-file-commands-write-terminal-artifact-file'
|
||||
import type { TerminalFileGrant } from './runtime-file-commands-mobile-file-list-limit'
|
||||
import {
|
||||
runtimeFileWatcherLeasesByOwnerAndRoot,
|
||||
runtimeWatcherReleaseKey
|
||||
} from './runtime-file-commands-mobile-file-list-limit'
|
||||
import type { IFilesystemProvider } from '../providers/types'
|
||||
import {
|
||||
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE,
|
||||
getSshFilesystemProvider,
|
||||
requireSshFilesystemProvider
|
||||
} from '../providers/ssh-filesystem-dispatch'
|
||||
import {
|
||||
requireLocalRuntimeFileRoute,
|
||||
requireRuntimeFileProvider,
|
||||
runtimeFileRouteForTarget,
|
||||
runtimeFileSshTargetId
|
||||
} from './runtime-file-command-target'
|
||||
import { stat } from 'node:fs/promises'
|
||||
@@ -22,10 +15,6 @@ import { sortDirEntries } from '../../shared/file-name-sort'
|
||||
import { resolveAuthorizedPath } from '../ipc/filesystem-auth'
|
||||
import { watchWindowsRuntimeFileExplorer } from './runtime-file-command-host'
|
||||
import { beginWatcherInstall } from '../ipc/watcher-removal-gate'
|
||||
import {
|
||||
armSshFileExplorerWatchRearm,
|
||||
stopSshFileExplorerWatchRearms
|
||||
} from './runtime-file-commands-ssh-file-watcher-rearm'
|
||||
import {
|
||||
closeFileExplorerWatcherInWatcherProcess,
|
||||
watchFileExplorerInWatcherProcess
|
||||
@@ -33,34 +22,13 @@ import {
|
||||
import { registerRuntimeFileWatcherRelease } from './runtime-file-watcher-leases'
|
||||
|
||||
export class RuntimeFileCommandsWithAssertRemoteTerminalFileGrantPathStillCanonical extends RuntimeFileCommandsWithWriteTerminalArtifactFile {
|
||||
protected async assertRemoteTerminalFileGrantPathStillCanonical(
|
||||
grant: TerminalFileGrant
|
||||
): Promise<IFilesystemProvider> {
|
||||
if (!grant.connectionId) {
|
||||
throw new Error('terminal_file_grant_mismatch')
|
||||
}
|
||||
const provider = requireSshFilesystemProvider(grant.connectionId)
|
||||
const canonicalPath =
|
||||
grant.provenance === 'native-chat'
|
||||
? await provider.realpath(grant.absolutePath)
|
||||
: await this.resolveAllowedRemoteTerminalArtifactPath(
|
||||
grant.absolutePath,
|
||||
grant.connectionId
|
||||
)
|
||||
// Why: relay I/O follows symlinks, so re-canonicalize after the remote process can mutate the path.
|
||||
if (canonicalPath !== grant.absolutePath) {
|
||||
throw new Error('terminal_file_grant_stale')
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
async readFileExplorerDir(
|
||||
worktreeSelector: string,
|
||||
relativePath: string,
|
||||
options: { followSymlinks?: boolean } = {}
|
||||
): Promise<DirEntry[]> {
|
||||
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
||||
// Why: re-sort here — the remote relay may be an older build with lexicographic ordering.
|
||||
// Why: providers do not promise this order, so sort before returning.
|
||||
return sortDirEntries(
|
||||
await requireRuntimeFileProvider(target, this.host).readDir(target.path, options)
|
||||
)
|
||||
@@ -82,55 +50,7 @@ export class RuntimeFileCommandsWithAssertRemoteTerminalFileGrantPathStillCanoni
|
||||
}> => {
|
||||
const finishInstall = beginWatcherInstall(target.path, sshTargetId)
|
||||
try {
|
||||
// Re-resolved per open: a reconnect mints a fresh provider for the same target.
|
||||
const route = runtimeFileRouteForTarget(target)
|
||||
if (route.kind === 'ssh') {
|
||||
if (!route.provider) {
|
||||
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
const provider = route.provider
|
||||
const isCurrentInitialProvider = (): boolean =>
|
||||
!signal?.aborted && provider === getSshFilesystemProvider(route.connectionId)
|
||||
let initialCallbacks = {
|
||||
callback: (events: FsChangeEvent[]) => {
|
||||
if (isCurrentInitialProvider()) {
|
||||
callback(events)
|
||||
}
|
||||
},
|
||||
onTerminalError: (error: Error) => {
|
||||
if (isCurrentInitialProvider()) {
|
||||
onTerminalError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
// Initial callbacks join the rearm generation after setup succeeds.
|
||||
const close = await provider.watch(
|
||||
target.path,
|
||||
(events) => initialCallbacks.callback(events),
|
||||
{ signal, onTerminalError: (error) => initialCallbacks.onTerminalError(error) }
|
||||
)
|
||||
const rearm = armSshFileExplorerWatchRearm({
|
||||
runtimeId: this.host.getRuntimeId(),
|
||||
connectionId: route.connectionId,
|
||||
rootPath: target.path,
|
||||
callback,
|
||||
onTerminalError,
|
||||
signal,
|
||||
initialUnwatch: close,
|
||||
initialProvider: provider
|
||||
})
|
||||
initialCallbacks = rearm.initialCallbacks
|
||||
return { unsubscribe: rearm.unsubscribe, rootPaths: [target.path] }
|
||||
} catch (error) {
|
||||
initialCallbacks = {
|
||||
callback: () => undefined,
|
||||
onTerminalError: () => undefined
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
requireLocalRuntimeFileRoute(target)
|
||||
const rootPath = await resolveAuthorizedPath(target.path, this.host.requireStore())
|
||||
const rootStats = await stat(rootPath)
|
||||
if (!rootStats.isDirectory()) {
|
||||
@@ -189,9 +109,6 @@ export class RuntimeFileCommandsWithAssertRemoteTerminalFileGrantPathStillCanoni
|
||||
|
||||
forgetFileExplorerWatchersAfterRemoval(rootPath: string, connectionId?: string): void {
|
||||
const key = runtimeWatcherReleaseKey(this.host.getRuntimeId(), connectionId, rootPath)
|
||||
// Why: forget() never runs the lease's unsubscribe, so the re-arm would outlive a deleted
|
||||
// worktree and re-watch it on the next reconnect.
|
||||
stopSshFileExplorerWatchRearms(key)
|
||||
const leases = runtimeFileWatcherLeasesByOwnerAndRoot.get(key)
|
||||
if (leases) {
|
||||
for (const lease of Array.from(leases)) {
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
import { RuntimeFileCommandsWithWriteFileExplorerFile } from './runtime-file-commands-write-file-explorer-file'
|
||||
import { assertRuntimeFileMutationExpectation } from './runtime-file-commands-mobile-file-list-limit'
|
||||
import {
|
||||
requireRuntimeFileProvider,
|
||||
requireSshRuntimeFileProvider,
|
||||
runtimeFileRouteForTarget
|
||||
requireLocalRuntimeFileRoute,
|
||||
requireRuntimeFileProvider
|
||||
} from './runtime-file-command-target'
|
||||
import { resolveAuthorizedPath } from '../ipc/filesystem-auth'
|
||||
import { constants, copyFile, mkdir, rm } from 'node:fs/promises'
|
||||
@@ -47,15 +46,8 @@ export class RuntimeFileCommandsWithCreateFileExplorerDirNoClobber extends Runti
|
||||
expectedSshTargetId,
|
||||
expectedSshConnectionGeneration
|
||||
)
|
||||
const route = runtimeFileRouteForTarget(tempTarget)
|
||||
// Why: the local arm commits through the resolved (not symlink-preserving) temp and final paths.
|
||||
if (route.kind === 'ssh') {
|
||||
const provider = requireSshRuntimeFileProvider(route)
|
||||
await provider.copy(tempTarget.path, finalTarget.path)
|
||||
await provider.deletePath(tempTarget.path, false).catch(() => {})
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
requireLocalRuntimeFileRoute(tempTarget)
|
||||
// Why: commit through the resolved (not symlink-preserving) temp and final paths.
|
||||
const store = this.host.requireStore()
|
||||
const tempPath = await resolveAuthorizedPath(tempTarget.path, store)
|
||||
const finalPath = await resolveAuthorizedPath(finalTarget.path, store)
|
||||
|
||||
@@ -121,8 +121,6 @@ export const runtimeFileWatcherLeasesByOwnerAndRoot = new Map<
|
||||
Set<RuntimeFileWatcherLease>
|
||||
>()
|
||||
|
||||
export const sshFileExplorerWatchRearms = new Map<string, Set<() => void>>()
|
||||
|
||||
export const MOBILE_BINARY_EXTENSIONS = new Set([
|
||||
'.avif',
|
||||
'.bmp',
|
||||
@@ -164,8 +162,7 @@ export type TerminalFileGrant = {
|
||||
id: string
|
||||
worktreeId: string
|
||||
absolutePath: string
|
||||
provider: 'local' | 'ssh'
|
||||
connectionId?: string
|
||||
provider: 'local'
|
||||
clientId?: string
|
||||
expiresAt: number
|
||||
statIdentity: string | null
|
||||
|
||||
@@ -9,13 +9,11 @@ import {
|
||||
MOBILE_FILE_READ_MAX_BYTES,
|
||||
RUNTIME_PREVIEWABLE_BINARY_MIME_TYPES,
|
||||
assertPreviewWithinTransportBudget,
|
||||
previewableBinaryByteLimit,
|
||||
readPreviewFileWithinCap
|
||||
previewableBinaryByteLimit
|
||||
} from './runtime-file-commands-mobile-file-list-limit'
|
||||
import {
|
||||
requireRuntimeFileProvider,
|
||||
requireSshRuntimeFileProvider,
|
||||
runtimeFileRouteForTarget
|
||||
requireLocalRuntimeFileRoute,
|
||||
requireRuntimeFileProvider
|
||||
} from './runtime-file-command-target'
|
||||
import { readLocalFileRange } from '../ipc/filesystem/local-file-range-read'
|
||||
import { resolveAuthorizedPath } from '../ipc/filesystem-auth'
|
||||
@@ -29,7 +27,6 @@ import type {
|
||||
DocPreviewFileAccessRequest,
|
||||
DocPreviewFileAccessResult
|
||||
} from '../../shared/doc-preview-file-access'
|
||||
import { readSshFileExplorerChunk } from './ssh-file-explorer-chunk-read'
|
||||
|
||||
export class RuntimeFileCommandsWithReadFileExplorerPreview extends RuntimeFileCommandsWithAssertRemoteTerminalFileGrantPathStillCanonical {
|
||||
async readFileExplorerPreview(
|
||||
@@ -42,36 +39,8 @@ export class RuntimeFileCommandsWithReadFileExplorerPreview extends RuntimeFileC
|
||||
? LOCAL_PREVIEWABLE_BINARY_MAX_BYTES
|
||||
: previewableBinaryByteLimit(maxContentBytes)
|
||||
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
||||
const route = runtimeFileRouteForTarget(target)
|
||||
// Why: the local arm sizes its read by extension instead of a stat round trip.
|
||||
if (route.kind === 'ssh') {
|
||||
const provider = requireSshRuntimeFileProvider(route)
|
||||
const fileStats = await provider.stat(target.path)
|
||||
if (fileStats.size > binaryMaxBytes) {
|
||||
throw new Error('file_too_large')
|
||||
}
|
||||
const result = await readPreviewFileWithinCap(provider, target.path, {
|
||||
maxBinaryBytes: binaryMaxBytes,
|
||||
maxTextBytes: MOBILE_FILE_READ_MAX_BYTES
|
||||
})
|
||||
// Why: the stat gate sizes base64 binaries; text crosses the wire JSON-escaped (up to 6x), so
|
||||
// hold it to the same decoded limit the local branch enforces before reading.
|
||||
if (
|
||||
!result.isBinary &&
|
||||
Buffer.byteLength(result.content, 'utf8') > MOBILE_FILE_READ_MAX_BYTES
|
||||
) {
|
||||
throw new Error('file_too_large')
|
||||
}
|
||||
if (
|
||||
result.isBinary &&
|
||||
maxContentBytes !== undefined &&
|
||||
Buffer.byteLength(result.content, 'utf8') > maxContentBytes
|
||||
) {
|
||||
throw new Error('file_too_large')
|
||||
}
|
||||
return assertPreviewWithinTransportBudget(result, maxContentBytes)
|
||||
}
|
||||
|
||||
requireLocalRuntimeFileRoute(target)
|
||||
// Why: sized by extension instead of a stat round trip.
|
||||
const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore())
|
||||
const mimeType = RUNTIME_PREVIEWABLE_BINARY_MIME_TYPES[extname(filePath).toLowerCase()]
|
||||
const maxBytes = mimeType ? binaryMaxBytes : MOBILE_FILE_READ_MAX_BYTES
|
||||
@@ -156,16 +125,7 @@ export class RuntimeFileCommandsWithReadFileExplorerPreview extends RuntimeFileC
|
||||
length: number
|
||||
): Promise<RuntimeFileReadChunkResult> {
|
||||
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
|
||||
const route = runtimeFileRouteForTarget(target)
|
||||
if (route.kind === 'ssh') {
|
||||
const provider = requireSshRuntimeFileProvider(route)
|
||||
const fileStat = await provider.stat(target.path)
|
||||
if (fileStat.type === 'directory') {
|
||||
throw new Error('Cannot download a directory')
|
||||
}
|
||||
return readSshFileExplorerChunk(provider, target.path, fileStat.size, offset, length)
|
||||
}
|
||||
|
||||
requireLocalRuntimeFileRoute(target)
|
||||
const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore())
|
||||
return readLocalFileRange(filePath, offset, length)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import type { IFilesystemProvider } from '../providers/types'
|
||||
import { MOBILE_FILE_READ_MAX_BYTES } from './runtime-file-commands-mobile-file-list-limit'
|
||||
import { RuntimeFileCommandAccess } from './runtime-file-command-access'
|
||||
import type { RuntimeFileReadResult } from '../../shared/runtime-types'
|
||||
import { isMobileBinaryPath, isSafeMobileRelativePath } from './runtime-file-command-host'
|
||||
import { joinWorktreeRelativePath } from './runtime-relative-paths'
|
||||
import { readLocalMobileFile } from './runtime-file-commands-terminal-file-paths'
|
||||
import { truncateMobileFilePreview } from './runtime-file-commands-terminal-artifact-access'
|
||||
import {
|
||||
requireSshRuntimeFileProvider,
|
||||
runtimeFileRouteForTarget
|
||||
} from './runtime-file-command-target'
|
||||
import { requireLocalRuntimeFileRoute } from './runtime-file-command-target'
|
||||
|
||||
export class RuntimeFileCommandsWithReadMobileFile extends RuntimeFileCommandAccess {
|
||||
async readMobileFile(
|
||||
@@ -19,9 +14,8 @@ export class RuntimeFileCommandsWithReadMobileFile extends RuntimeFileCommandAcc
|
||||
const store = this.host.requireStore()
|
||||
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
|
||||
const { worktree } = target
|
||||
const route = runtimeFileRouteForTarget(target)
|
||||
// Why: an unreachable host fails before path validation, as it always has.
|
||||
const sshProvider = route.kind === 'ssh' ? requireSshRuntimeFileProvider(route) : undefined
|
||||
// Why: an `ssh:` host fails before path validation, as it always has.
|
||||
requireLocalRuntimeFileRoute(target)
|
||||
if (!isSafeMobileRelativePath(relativePath)) {
|
||||
throw new Error('invalid_relative_path')
|
||||
}
|
||||
@@ -30,9 +24,7 @@ export class RuntimeFileCommandsWithReadMobileFile extends RuntimeFileCommandAcc
|
||||
}
|
||||
|
||||
const filePath = joinWorktreeRelativePath(worktree.path, relativePath)
|
||||
const content = sshProvider
|
||||
? await this.readRemoteMobileFile(filePath, sshProvider)
|
||||
: await readLocalMobileFile(filePath, store)
|
||||
const content = await readLocalMobileFile(filePath, store)
|
||||
const truncated = truncateMobileFilePreview(content)
|
||||
|
||||
return {
|
||||
@@ -43,20 +35,4 @@ export class RuntimeFileCommandsWithReadMobileFile extends RuntimeFileCommandAcc
|
||||
byteLength: truncated.byteLength
|
||||
}
|
||||
}
|
||||
|
||||
protected async readRemoteMobileFile(
|
||||
filePath: string,
|
||||
provider: IFilesystemProvider
|
||||
): Promise<string> {
|
||||
const fileStat = await provider.stat(filePath)
|
||||
// Why: no ranged reads over SSH here, so reject oversized previews instead of streaming a whole file just to trim it.
|
||||
if (fileStat.size > MOBILE_FILE_READ_MAX_BYTES) {
|
||||
throw new Error('file_too_large')
|
||||
}
|
||||
const result = await provider.readFile(filePath)
|
||||
if (result.isBinary) {
|
||||
throw new Error('binary_file')
|
||||
}
|
||||
return result.content
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,14 +18,13 @@ import type {
|
||||
} from './runtime-file-commands-mobile-file-list-limit'
|
||||
import { TERMINAL_FILE_GRANT_TTL_MS } from './runtime-file-commands-mobile-file-list-limit'
|
||||
import type { RuntimeTerminalPathResolution } from '../../shared/runtime-types'
|
||||
import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import {
|
||||
runtimeFileSshTargetId,
|
||||
type ResolvedRuntimeFileTarget
|
||||
} from './runtime-file-command-target'
|
||||
import { requireReachableFilesystemRoute } from '../providers/execution-host-provider-dispatch'
|
||||
import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
import { SshHostNotServedError } from '../providers/ssh-host-not-served-error'
|
||||
import { getConnectionExecutionHostId } from '../../shared/execution-host'
|
||||
|
||||
export class RuntimeFileCommandsWithResolveAllowedTerminalArtifactPath extends RuntimeFileCommandsWithResolveTerminalPath {
|
||||
@@ -35,7 +34,7 @@ export class RuntimeFileCommandsWithResolveAllowedTerminalArtifactPath extends R
|
||||
worktreePath: string
|
||||
}): Promise<string | null> {
|
||||
if (args.connectionId) {
|
||||
return this.resolveAllowedRemoteTerminalArtifactPath(args.absolutePath, args.connectionId)
|
||||
throw new SshHostNotServedError(args.connectionId)
|
||||
}
|
||||
return resolveAllowedLocalTerminalArtifactPath(args.absolutePath, args.worktreePath)
|
||||
}
|
||||
@@ -52,20 +51,11 @@ export class RuntimeFileCommandsWithResolveAllowedTerminalArtifactPath extends R
|
||||
worktreeId: string
|
||||
artifactPath: string
|
||||
rejectedAbsolutePath?: string
|
||||
connectionId?: string
|
||||
clientId?: string
|
||||
readOnly?: boolean
|
||||
provenance?: TerminalFileGrant['provenance']
|
||||
}): Promise<RuntimeTerminalPathResolution> {
|
||||
let contentDigest: string | null = null
|
||||
let stats: RuntimeFileStatLike & { isDirectory: () => boolean }
|
||||
if (args.connectionId) {
|
||||
stats = await this.statRemoteTerminalPath(args.artifactPath, args.connectionId)
|
||||
} else {
|
||||
const local = await this.statLocalTerminalArtifact(args.artifactPath)
|
||||
stats = local.stats
|
||||
contentDigest = local.contentDigest
|
||||
}
|
||||
const { stats, contentDigest } = await this.statLocalTerminalArtifact(args.artifactPath)
|
||||
const isDirectory = stats.isDirectory()
|
||||
if (!isDirectory && isTerminalArtifactHardLinked(stats)) {
|
||||
return {
|
||||
@@ -81,8 +71,6 @@ export class RuntimeFileCommandsWithResolveAllowedTerminalArtifactPath extends R
|
||||
: this.createTerminalFileGrant({
|
||||
worktreeId: args.worktreeId,
|
||||
absolutePath: args.artifactPath,
|
||||
provider: args.connectionId ? 'ssh' : 'local',
|
||||
connectionId: args.connectionId,
|
||||
clientId: args.clientId,
|
||||
readOnly: args.readOnly === true,
|
||||
provenance: args.provenance ?? 'terminal-output',
|
||||
@@ -107,29 +95,6 @@ export class RuntimeFileCommandsWithResolveAllowedTerminalArtifactPath extends R
|
||||
}
|
||||
}
|
||||
|
||||
protected async resolveAllowedRemoteTerminalArtifactPath(
|
||||
absolutePath: string,
|
||||
connectionId: string
|
||||
): Promise<string | null> {
|
||||
const provider = requireSshFilesystemProvider(connectionId)
|
||||
const roots = ['/tmp', '/private/tmp']
|
||||
const providerTempDir = await provider.getTempDir?.().catch(() => null)
|
||||
if (providerTempDir) {
|
||||
roots.push(providerTempDir)
|
||||
}
|
||||
if (!roots.some((root) => isPathInsideOrEqual(root, absolutePath))) {
|
||||
return null
|
||||
}
|
||||
const [realArtifactPath, ...realRoots] = await Promise.all([
|
||||
provider.realpath(absolutePath),
|
||||
...roots.map((root) => provider.realpath(root).catch(() => root))
|
||||
])
|
||||
// Why: SSH I/O follows symlinks on the relay; grant the canonical target so a /tmp link can't escape the temp boundary.
|
||||
return realRoots.some((root) => isPathInsideOrEqual(root, realArtifactPath))
|
||||
? realArtifactPath
|
||||
: null
|
||||
}
|
||||
|
||||
protected async statLocalTerminalPath(
|
||||
absolutePath: string
|
||||
): Promise<RuntimeFileStatLike & { isDirectory: () => boolean }> {
|
||||
@@ -157,8 +122,6 @@ export class RuntimeFileCommandsWithResolveAllowedTerminalArtifactPath extends R
|
||||
protected createTerminalFileGrant(args: {
|
||||
worktreeId: string
|
||||
absolutePath: string
|
||||
provider: 'local' | 'ssh'
|
||||
connectionId?: string
|
||||
clientId?: string
|
||||
readOnly?: boolean
|
||||
provenance: TerminalFileGrant['provenance']
|
||||
@@ -170,8 +133,7 @@ export class RuntimeFileCommandsWithResolveAllowedTerminalArtifactPath extends R
|
||||
id: randomUUID(),
|
||||
worktreeId: args.worktreeId,
|
||||
absolutePath: args.absolutePath,
|
||||
provider: args.provider,
|
||||
...(args.connectionId ? { connectionId: args.connectionId } : {}),
|
||||
provider: 'local',
|
||||
...(args.clientId ? { clientId: args.clientId } : {}),
|
||||
expiresAt: Date.now() + TERMINAL_FILE_GRANT_TTL_MS,
|
||||
statIdentity: terminalFileStatIdentity(args.stats),
|
||||
@@ -203,7 +165,8 @@ export class RuntimeFileCommandsWithResolveAllowedTerminalArtifactPath extends R
|
||||
if (
|
||||
grant.worktreeId !== target.worktree.id ||
|
||||
grant.absolutePath !== absolutePath ||
|
||||
grant.connectionId !== runtimeFileSshTargetId(target) ||
|
||||
// Why: grants are only minted for local paths, so an `ssh:` target never matches one.
|
||||
runtimeFileSshTargetId(target) !== undefined ||
|
||||
grant.clientId !== clientId
|
||||
) {
|
||||
throw new Error('terminal_file_grant_mismatch')
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// @ts-nocheck -- mechanically split class members.
|
||||
import { RuntimeFileCommandsWithReadMobileFile } from './runtime-file-commands-read-mobile-file'
|
||||
import { RuntimeFileCommandAccess as RuntimeFileCommands } from './runtime-file-command-access'
|
||||
import type {
|
||||
RuntimeNativeChatFileContext,
|
||||
RuntimeTerminalPathResolution
|
||||
@@ -18,8 +17,6 @@ import {
|
||||
} from './runtime-file-command-target'
|
||||
import { isSafeMobileRelativePath } from './runtime-file-command-host'
|
||||
import { isENOENT } from '../ipc/filesystem-path-containment'
|
||||
import type { RuntimeFileStatLike } from './runtime-file-commands-mobile-file-list-limit'
|
||||
import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
|
||||
export class RuntimeFileCommandsWithResolveTerminalPath extends RuntimeFileCommandsWithReadMobileFile {
|
||||
// Resolves a mobile terminal tap to a worktree-relative path; relatives resolve against cwd, else the worktree root.
|
||||
@@ -91,11 +88,9 @@ export class RuntimeFileCommandsWithResolveTerminalPath extends RuntimeFileComma
|
||||
) {
|
||||
const isDirectory =
|
||||
(
|
||||
await requireRuntimeFileProvider(
|
||||
knownWorkspaceTarget ?? target,
|
||||
this.host,
|
||||
ownedRoute
|
||||
).stat(absolutePath)
|
||||
await requireRuntimeFileProvider(knownWorkspaceTarget ?? target, this.host).stat(
|
||||
absolutePath
|
||||
)
|
||||
).type === 'directory'
|
||||
return {
|
||||
worktree: ownedWorktree.id,
|
||||
@@ -127,7 +122,6 @@ export class RuntimeFileCommandsWithResolveTerminalPath extends RuntimeFileComma
|
||||
return await this.resolveAbsoluteFileGrant({
|
||||
worktreeId: worktree.id,
|
||||
artifactPath,
|
||||
connectionId,
|
||||
clientId,
|
||||
readOnly: true,
|
||||
provenance: 'native-chat'
|
||||
@@ -167,15 +161,11 @@ export class RuntimeFileCommandsWithResolveTerminalPath extends RuntimeFileComma
|
||||
worktreeId: worktree.id,
|
||||
artifactPath,
|
||||
rejectedAbsolutePath: absolutePath,
|
||||
connectionId,
|
||||
clientId
|
||||
})
|
||||
} catch (error) {
|
||||
// Report genuine not-found as missing; let transport/permission errors surface so remote taps aren't all reported missing.
|
||||
if (
|
||||
isENOENT(error) ||
|
||||
(ownedRoute.kind === 'ssh' && RuntimeFileCommands.isRemoteNotFoundErrorMessage(error))
|
||||
) {
|
||||
if (isENOENT(error)) {
|
||||
return {
|
||||
...empty,
|
||||
worktree: ownedWorktree.id,
|
||||
@@ -186,14 +176,4 @@ export class RuntimeFileCommandsWithResolveTerminalPath extends RuntimeFileComma
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Leaf helper: only ever reached from a route already resolved to `ssh`, so the id it takes is
|
||||
// this client's dialable target rather than a repo row's raw `connectionId`.
|
||||
protected async statRemoteTerminalPath(
|
||||
absolutePath: string,
|
||||
connectionId: string
|
||||
): Promise<RuntimeFileStatLike & { isDirectory: () => boolean }> {
|
||||
const stats = await requireSshFilesystemProvider(connectionId).stat(absolutePath)
|
||||
return { ...stats, isDirectory: () => stats.type === 'directory' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
// @ts-nocheck -- mechanically split class members.
|
||||
import { RuntimeFileCommandsWithResolveAllowedTerminalArtifactPath } from './runtime-file-commands-resolve-allowed-terminal-artifact-path'
|
||||
import type { TerminalFileGrant } from './runtime-file-commands-mobile-file-list-limit'
|
||||
import {
|
||||
MOBILE_FILE_READ_MAX_BYTES,
|
||||
assertPreviewWithinTransportBudget
|
||||
} from './runtime-file-commands-mobile-file-list-limit'
|
||||
import { assertPreviewWithinTransportBudget } from './runtime-file-commands-mobile-file-list-limit'
|
||||
import type { RuntimeFilePreviewResult, RuntimeFileReadResult } from '../../shared/runtime-types'
|
||||
import { isMobileBinaryPath } from './runtime-file-command-host'
|
||||
import {
|
||||
@@ -63,20 +60,11 @@ export class RuntimeFileCommandsWithRevokeTerminalFileGrantsForClient extends Ru
|
||||
throw new Error('binary_file')
|
||||
}
|
||||
let content: string
|
||||
if (grant.connectionId) {
|
||||
const provider = await this.assertRemoteTerminalFileGrantFreshForRead(grant)
|
||||
content = await this.readRemoteTerminalArtifactFile(
|
||||
provider,
|
||||
grant,
|
||||
MOBILE_FILE_READ_MAX_BYTES
|
||||
)
|
||||
} else {
|
||||
const handle = await openLocalTerminalArtifactGrant(grant, constants.O_RDONLY)
|
||||
try {
|
||||
content = await readLocalTerminalArtifactFileFromHandle(handle, grant)
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
const handle = await openLocalTerminalArtifactGrant(grant, constants.O_RDONLY)
|
||||
try {
|
||||
content = await readLocalTerminalArtifactFileFromHandle(handle, grant)
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
this.refreshTerminalFileGrant(grant)
|
||||
const truncated = truncateMobileFilePreview(content)
|
||||
@@ -103,14 +91,6 @@ export class RuntimeFileCommandsWithRevokeTerminalFileGrantsForClient extends Ru
|
||||
absolutePath,
|
||||
clientId
|
||||
)
|
||||
if (grant.connectionId) {
|
||||
const provider = await this.assertRemoteTerminalFileGrantFreshForRead(grant)
|
||||
this.refreshTerminalFileGrant(grant)
|
||||
return assertPreviewWithinTransportBudget(
|
||||
await this.readRemoteTerminalArtifactPreview(provider, grant, maxContentBytes),
|
||||
maxContentBytes
|
||||
)
|
||||
}
|
||||
const handle = await openLocalTerminalArtifactGrant(grant, constants.O_RDONLY)
|
||||
try {
|
||||
const preview = await readLocalTerminalArtifactPreviewFromHandle(
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
import type { FsChangeEvent } from '../../shared/filesystem-entry-types'
|
||||
import type { IFilesystemProvider } from '../providers/types'
|
||||
import { toRemoteRuntimeClientErrorLike } from '../../shared/remote-runtime-client-error-classification'
|
||||
import {
|
||||
runtimeWatcherReleaseKey,
|
||||
sshFileExplorerWatchRearms
|
||||
} from './runtime-file-commands-mobile-file-list-limit'
|
||||
import {
|
||||
getSshFilesystemProvider,
|
||||
onSshFilesystemProviderRegistered
|
||||
} from '../providers/ssh-filesystem-dispatch'
|
||||
|
||||
export function armSshFileExplorerWatchRearm(args: {
|
||||
runtimeId: string
|
||||
connectionId: string
|
||||
rootPath: string
|
||||
callback: (events: FsChangeEvent[]) => void
|
||||
onTerminalError: (error: Error) => void
|
||||
signal?: AbortSignal
|
||||
initialUnwatch: () => void
|
||||
initialProvider: Pick<IFilesystemProvider, 'watch'>
|
||||
}): {
|
||||
initialCallbacks: {
|
||||
callback: (events: FsChangeEvent[]) => void
|
||||
onTerminalError: (error: Error) => void
|
||||
}
|
||||
unsubscribe: () => Promise<void>
|
||||
} {
|
||||
const key = runtimeWatcherReleaseKey(args.runtimeId, args.connectionId, args.rootPath)
|
||||
let currentUnwatch = args.initialUnwatch
|
||||
let currentProvider = args.initialProvider
|
||||
let stopped = false
|
||||
let reinstalling: Promise<void> | null = null
|
||||
let providerGeneration = 0
|
||||
|
||||
const reportTerminalError = (error: unknown): void => {
|
||||
// Connection loss leaves the established stream armed for the next provider.
|
||||
if (toRemoteRuntimeClientErrorLike(error).code === 'CONNECTION_LOST') {
|
||||
return
|
||||
}
|
||||
args.onTerminalError(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
|
||||
const reinstall = async (
|
||||
generation: number,
|
||||
provider: Pick<IFilesystemProvider, 'watch'> | undefined
|
||||
): Promise<void> => {
|
||||
if (stopped || generation !== providerGeneration || !provider) {
|
||||
return
|
||||
}
|
||||
const isCurrent = (): boolean =>
|
||||
!stopped &&
|
||||
!args.signal?.aborted &&
|
||||
generation === providerGeneration &&
|
||||
provider === getSshFilesystemProvider(args.connectionId)
|
||||
let nextUnwatch: () => void
|
||||
try {
|
||||
nextUnwatch = await provider.watch(
|
||||
args.rootPath,
|
||||
(events) => {
|
||||
if (isCurrent()) {
|
||||
args.callback(events)
|
||||
}
|
||||
},
|
||||
{
|
||||
signal: args.signal,
|
||||
onTerminalError: (error) => {
|
||||
if (isCurrent()) {
|
||||
reportTerminalError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
if (!isCurrent()) {
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if (!isCurrent()) {
|
||||
nextUnwatch()
|
||||
return
|
||||
}
|
||||
// Why: a dead transport's handle must never unwatch the replacement transport's root.
|
||||
if (currentProvider === provider) {
|
||||
currentUnwatch()
|
||||
}
|
||||
currentUnwatch = nextUnwatch
|
||||
currentProvider = provider
|
||||
args.callback([{ kind: 'overflow', absolutePath: args.rootPath }])
|
||||
}
|
||||
|
||||
const scheduleReinstall = (): void => {
|
||||
if (stopped) {
|
||||
return
|
||||
}
|
||||
const generation = ++providerGeneration
|
||||
let attemptProvider: Pick<IFilesystemProvider, 'watch'> | undefined
|
||||
// Why: obsolete reconnect attempts cannot terminate the stream or publish a stale refresh.
|
||||
const attempt = (reinstalling ?? Promise.resolve())
|
||||
.then(() => {
|
||||
attemptProvider = getSshFilesystemProvider(args.connectionId)
|
||||
return reinstall(generation, attemptProvider)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (
|
||||
!stopped &&
|
||||
!args.signal?.aborted &&
|
||||
generation === providerGeneration &&
|
||||
attemptProvider === getSshFilesystemProvider(args.connectionId)
|
||||
) {
|
||||
reportTerminalError(error)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (reinstalling === attempt) {
|
||||
reinstalling = null
|
||||
}
|
||||
})
|
||||
reinstalling = attempt
|
||||
}
|
||||
const unsubscribeRearm = onSshFilesystemProviderRegistered((registeredId) => {
|
||||
if (registeredId === args.connectionId) {
|
||||
scheduleReinstall()
|
||||
}
|
||||
})
|
||||
|
||||
const stop = (): void => {
|
||||
stopped = true
|
||||
unsubscribeRearm()
|
||||
const rearms = sshFileExplorerWatchRearms.get(key)
|
||||
rearms?.delete(stop)
|
||||
if (rearms?.size === 0) {
|
||||
sshFileExplorerWatchRearms.delete(key)
|
||||
}
|
||||
}
|
||||
const rearms = sshFileExplorerWatchRearms.get(key) ?? new Set<() => void>()
|
||||
rearms.add(stop)
|
||||
sshFileExplorerWatchRearms.set(key, rearms)
|
||||
if (getSshFilesystemProvider(args.connectionId) !== args.initialProvider) {
|
||||
scheduleReinstall()
|
||||
}
|
||||
const isInitialCurrent = (): boolean =>
|
||||
!stopped &&
|
||||
!args.signal?.aborted &&
|
||||
providerGeneration === 0 &&
|
||||
args.initialProvider === getSshFilesystemProvider(args.connectionId)
|
||||
|
||||
return {
|
||||
initialCallbacks: {
|
||||
callback: (events) => {
|
||||
if (isInitialCurrent()) {
|
||||
args.callback(events)
|
||||
}
|
||||
},
|
||||
onTerminalError: (error) => {
|
||||
if (isInitialCurrent()) {
|
||||
reportTerminalError(error)
|
||||
}
|
||||
}
|
||||
},
|
||||
unsubscribe: () => {
|
||||
stop()
|
||||
const close = async (): Promise<void> => currentUnwatch()
|
||||
// Why: awaiting an absent reinstall costs a microtask, and removal gating relies on the
|
||||
// unwatch being issued on the same turn the lease releases it.
|
||||
return reinstalling ? reinstalling.catch(() => undefined).then(close) : close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function stopSshFileExplorerWatchRearms(key: string): void {
|
||||
for (const stop of Array.from(sshFileExplorerWatchRearms.get(key) ?? [])) {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,6 @@
|
||||
// @ts-nocheck -- mechanically split class members.
|
||||
import { RuntimeFileCommandsWithRevokeTerminalFileGrantsForClient } from './runtime-file-commands-revoke-terminal-file-grants-for-client'
|
||||
import type { TerminalFileGrant } from './runtime-file-commands-mobile-file-list-limit'
|
||||
import {
|
||||
LOCAL_PREVIEWABLE_BINARY_MAX_BYTES,
|
||||
MOBILE_FILE_READ_MAX_BYTES,
|
||||
previewableBinaryByteLimit
|
||||
} from './runtime-file-commands-mobile-file-list-limit'
|
||||
import { MOBILE_FILE_READ_MAX_BYTES } from './runtime-file-commands-mobile-file-list-limit'
|
||||
import { isBinaryBuffer } from '../../shared/binary-buffer'
|
||||
import { isMobileBinaryPath } from './runtime-file-command-host'
|
||||
import {
|
||||
@@ -18,8 +13,6 @@ import { openLocalTerminalArtifactGrant } from './runtime-file-commands-terminal
|
||||
import { chmod, constants, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { FileStat, IFilesystemProvider } from '../providers/types'
|
||||
import type { RuntimeFilePreviewResult } from '../../shared/runtime-types'
|
||||
|
||||
export class RuntimeFileCommandsWithWriteTerminalArtifactFile extends RuntimeFileCommandsWithRevokeTerminalFileGrantsForClient {
|
||||
async writeTerminalArtifactFile(
|
||||
@@ -44,27 +37,6 @@ export class RuntimeFileCommandsWithWriteTerminalArtifactFile extends RuntimeFil
|
||||
if (isMobileBinaryPath(grant.absolutePath)) {
|
||||
throw new Error('binary_file')
|
||||
}
|
||||
if (grant.connectionId) {
|
||||
const { provider, fileStat } = await this.assertRemoteTerminalFileGrantFresh(grant)
|
||||
if (fileStat.type === 'directory') {
|
||||
throw new Error('Cannot write to a directory')
|
||||
}
|
||||
if (fileStat.size > MOBILE_FILE_READ_MAX_BYTES) {
|
||||
throw new Error('file_too_large')
|
||||
}
|
||||
if (!provider.writeTerminalArtifact) {
|
||||
throw new Error('terminal_file_grant_unavailable')
|
||||
}
|
||||
const nextStat = await provider.writeTerminalArtifact(
|
||||
grant.absolutePath,
|
||||
content,
|
||||
this.terminalArtifactAccessOptions(grant, MOBILE_FILE_READ_MAX_BYTES)
|
||||
)
|
||||
grant.statIdentity = terminalFileStatIdentity(nextStat)
|
||||
this.refreshTerminalFileGrant(grant)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
let originalMode: number | null = null
|
||||
const handle = await openLocalTerminalArtifactGrant(grant, constants.O_RDONLY)
|
||||
try {
|
||||
@@ -114,83 +86,4 @@ export class RuntimeFileCommandsWithWriteTerminalArtifactFile extends RuntimeFil
|
||||
await rm(tempPath, { force: true }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
protected async readRemoteTerminalArtifactPreview(
|
||||
provider: IFilesystemProvider,
|
||||
grant: TerminalFileGrant,
|
||||
maxContentBytes: number | undefined
|
||||
): Promise<RuntimeFilePreviewResult> {
|
||||
const binaryMaxBytes =
|
||||
maxContentBytes === undefined
|
||||
? LOCAL_PREVIEWABLE_BINARY_MAX_BYTES
|
||||
: previewableBinaryByteLimit(maxContentBytes)
|
||||
const preview = await this.readRemoteTerminalArtifact(provider, grant, binaryMaxBytes)
|
||||
if (
|
||||
!preview.isBinary &&
|
||||
Buffer.byteLength(preview.content, 'utf8') > MOBILE_FILE_READ_MAX_BYTES
|
||||
) {
|
||||
throw new Error('file_too_large')
|
||||
}
|
||||
if (
|
||||
preview.isBinary &&
|
||||
maxContentBytes !== undefined &&
|
||||
Buffer.byteLength(preview.content, 'utf8') > maxContentBytes
|
||||
) {
|
||||
throw new Error('file_too_large')
|
||||
}
|
||||
return preview
|
||||
}
|
||||
|
||||
protected async readRemoteTerminalArtifactFile(
|
||||
provider: IFilesystemProvider,
|
||||
grant: TerminalFileGrant,
|
||||
maxBytes: number
|
||||
): Promise<string> {
|
||||
const result = await this.readRemoteTerminalArtifact(provider, grant, maxBytes)
|
||||
if (result.isBinary) {
|
||||
throw new Error('binary_file')
|
||||
}
|
||||
return result.content
|
||||
}
|
||||
|
||||
protected async readRemoteTerminalArtifact(
|
||||
provider: IFilesystemProvider,
|
||||
grant: TerminalFileGrant,
|
||||
maxBytes: number
|
||||
): Promise<RuntimeFilePreviewResult> {
|
||||
if (!provider.readTerminalArtifact) {
|
||||
throw new Error('terminal_file_grant_unavailable')
|
||||
}
|
||||
return provider.readTerminalArtifact(
|
||||
grant.absolutePath,
|
||||
this.terminalArtifactAccessOptions(grant, maxBytes)
|
||||
)
|
||||
}
|
||||
|
||||
protected terminalArtifactAccessOptions(
|
||||
grant: TerminalFileGrant,
|
||||
maxBytes: number
|
||||
): { expectedRealPath: string; expectedStatIdentity: string | null; maxBytes: number } {
|
||||
return {
|
||||
expectedRealPath: grant.absolutePath,
|
||||
expectedStatIdentity: grant.statIdentity,
|
||||
maxBytes
|
||||
}
|
||||
}
|
||||
|
||||
protected async assertRemoteTerminalFileGrantFreshForRead(
|
||||
grant: TerminalFileGrant
|
||||
): Promise<IFilesystemProvider> {
|
||||
const { provider } = await this.assertRemoteTerminalFileGrantFresh(grant)
|
||||
return provider
|
||||
}
|
||||
|
||||
protected async assertRemoteTerminalFileGrantFresh(
|
||||
grant: TerminalFileGrant
|
||||
): Promise<{ provider: IFilesystemProvider; fileStat: FileStat }> {
|
||||
const provider = await this.assertRemoteTerminalFileGrantPathStillCanonical(grant)
|
||||
const fileStat = await provider.stat(grant.absolutePath)
|
||||
assertTerminalFileGrantFresh(grant, fileStat)
|
||||
return { provider, fileStat }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,14 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { getSshFilesystemProviderMock } from './orca-runtime-files-mock-registry'
|
||||
import { SshHostNotServedError } from '../providers/ssh-host-not-served-error'
|
||||
import {
|
||||
createRuntimeFileCommands,
|
||||
useRuntimeFileCommandsLifecycle
|
||||
} from './orca-runtime-files-test-harness'
|
||||
const { localList } = vi.hoisted(() => ({ localList: vi.fn() }))
|
||||
vi.mock('../ipc/filesystem-list-files', () => ({ listQuickOpenFiles: localList }))
|
||||
vi.mock(
|
||||
'../providers/ssh-filesystem-dispatch',
|
||||
async () => (await import('./orca-runtime-files-mock-registry')).sshFilesystemDispatchMock
|
||||
)
|
||||
|
||||
function inventory(count: number) {
|
||||
const paths = Array.from({ length: count }, (_, index) => `src/file-${index}.ts`)
|
||||
return vi.fn(async (_root: string, options?: { maxResults?: number }) =>
|
||||
paths.slice(0, options?.maxResults)
|
||||
)
|
||||
}
|
||||
|
||||
describe('runtime producer listing budgets', () => {
|
||||
useRuntimeFileCommandsLifecycle()
|
||||
it.each([5000, 5001, 5002])(
|
||||
'passes the mobile sentinel budget on SSH for %i paths',
|
||||
async (count) => {
|
||||
const listFiles = inventory(count)
|
||||
getSshFilesystemProviderMock.mockReturnValue({ listFiles })
|
||||
const { commands } = createRuntimeFileCommands({ hostId: 'ssh:host' })
|
||||
const result = await commands.listMobileFiles('id:wt-1')
|
||||
expect(listFiles).toHaveBeenCalledWith('/repo', { maxResults: 5001, signal: undefined })
|
||||
expect(result.files).toHaveLength(5000)
|
||||
expect(result.totalCount).toBe(Math.min(count, 5001))
|
||||
expect(result.truncated).toBe(count > 5000)
|
||||
}
|
||||
)
|
||||
|
||||
it('passes the same sentinel budget before local enumeration', async () => {
|
||||
localList.mockImplementation(async (_root, _store, _excluded, _signal, maxResults) =>
|
||||
Array.from({ length: Math.min(5002, maxResults) }, (_, i) => `file-${i}.txt`)
|
||||
@@ -45,19 +20,6 @@ describe('runtime producer listing budgets', () => {
|
||||
expect(result.truncated).toBe(true)
|
||||
})
|
||||
|
||||
it.each([25002, 100000])(
|
||||
'keeps late files in an unqualified %i-file SSH inventory',
|
||||
async (count) => {
|
||||
const listFiles = inventory(count)
|
||||
getSshFilesystemProviderMock.mockReturnValue({ listFiles })
|
||||
const { commands } = createRuntimeFileCommands({ hostId: 'ssh:host' })
|
||||
const files = await commands.listRuntimeFiles('id:wt-1')
|
||||
expect(files).toHaveLength(count)
|
||||
expect(files.at(-1)).toBe(`src/file-${count - 1}.ts`)
|
||||
expect(listFiles.mock.calls[0][1]?.maxResults).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves the full local inventory and explicit caller limits', async () => {
|
||||
localList.mockClear()
|
||||
localList.mockImplementation(async (_root, _store, _excluded, _signal, maxResults) =>
|
||||
@@ -69,13 +31,12 @@ describe('runtime producer listing budgets', () => {
|
||||
expect(await commands.listRuntimeFiles('id:wt-1', { maxResults: 3 })).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('requests Markdown from its semantic producer, without retaining unrelated paths', async () => {
|
||||
const listFiles = vi.fn()
|
||||
const listMarkdownDocuments = vi.fn().mockResolvedValue([])
|
||||
getSshFilesystemProviderMock.mockReturnValue({ listFiles, listMarkdownDocuments })
|
||||
it('refuses SSH Markdown listings without listing this machine', async () => {
|
||||
localList.mockClear()
|
||||
const { commands } = createRuntimeFileCommands({ hostId: 'ssh:host' })
|
||||
await commands.listRuntimeMarkdownDocuments('id:wt-1')
|
||||
expect(listMarkdownDocuments).toHaveBeenCalledWith('/repo')
|
||||
expect(listFiles).not.toHaveBeenCalled()
|
||||
await expect(commands.listRuntimeMarkdownDocuments('id:wt-1')).rejects.toThrow(
|
||||
SshHostNotServedError
|
||||
)
|
||||
expect(localList).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,11 +5,8 @@ import {
|
||||
import type { RuntimeFileListResult } from '../../shared/runtime-types'
|
||||
import type { SearchOptions, SearchResult } from '../../shared/code-search-types'
|
||||
import { throwIfSignalAborted, waitForPromiseWithSignal } from '../../shared/abort-signal-reason'
|
||||
import { QUICK_OPEN_LISTING_MAX_RESULTS } from '../../shared/quick-open-listing-limits'
|
||||
import { limitQuickOpenFilesBySerializedBytes } from '../../shared/quick-open-transport-budget'
|
||||
import { listQuickOpenFiles } from '../ipc/filesystem-list-files'
|
||||
import { searchQuickOpenFilePaths as searchHostQuickOpenFilePaths } from '../ipc/filesystem-search-file-paths'
|
||||
import type { IFilesystemProvider } from '../providers/types'
|
||||
import { isMobileBinaryPath, isSafeMobileRelativePath } from './runtime-file-command-host'
|
||||
import {
|
||||
requireRuntimeFileProvider,
|
||||
@@ -28,7 +25,6 @@ import {
|
||||
RuntimeMobileFilePathSearchCache,
|
||||
rankRuntimeMobileFilePaths
|
||||
} from './runtime-mobile-file-path-search'
|
||||
import { searchRemoteQuickOpenFilePaths } from './runtime-remote-quick-open-search'
|
||||
|
||||
type RuntimeFileSearchTarget = {
|
||||
worktree: Pick<ResolvedRuntimeFileTarget['worktree'], 'id' | 'path'>
|
||||
@@ -56,14 +52,10 @@ export class RuntimeFileSearch {
|
||||
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
|
||||
const { worktree } = target
|
||||
const route = runtimeFileRouteForTarget(target)
|
||||
// Why: an `ssh:` host lists nothing rather than failing, and never lists this machine.
|
||||
const files =
|
||||
route.kind === 'ssh'
|
||||
? await this.listRemoteMobileFiles(
|
||||
worktree.path,
|
||||
route.provider,
|
||||
MOBILE_FILE_LIST_LIMIT + 1,
|
||||
options.signal
|
||||
)
|
||||
? []
|
||||
: await listQuickOpenFiles(
|
||||
worktree.path,
|
||||
store,
|
||||
@@ -105,11 +97,7 @@ export class RuntimeFileSearch {
|
||||
const inventory = await this.mobileFilePathSearchCache.get(cacheKey, async () => {
|
||||
const listed =
|
||||
route.kind === 'ssh'
|
||||
? await this.listRemoteMobileFiles(
|
||||
worktree.path,
|
||||
route.provider,
|
||||
MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT + 1
|
||||
)
|
||||
? []
|
||||
: await listQuickOpenFiles(
|
||||
worktree.path,
|
||||
store,
|
||||
@@ -155,36 +143,18 @@ export class RuntimeFileSearch {
|
||||
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
|
||||
const { worktree } = target
|
||||
const route = runtimeFileRouteForTarget(target)
|
||||
const quickOpenSearchVersion =
|
||||
route.kind !== 'ssh'
|
||||
? QUICK_OPEN_SEARCH_VERSION
|
||||
: (await route.provider?.supportsQuickOpenSearch?.({ signal, minimumVersion: 3 }))
|
||||
? 3
|
||||
: (await route.provider?.supportsQuickOpenSearch?.({ signal, minimumVersion: 2 }))
|
||||
? 2
|
||||
: (await route.provider?.supportsQuickOpenSearch?.({ signal, minimumVersion: 1 }))
|
||||
? 1
|
||||
: 0
|
||||
// Why: an `ssh:` host reports no matches (version 0) rather than failing the keystroke.
|
||||
const quickOpenSearchVersion = route.kind === 'ssh' ? 0 : QUICK_OPEN_SEARCH_VERSION
|
||||
const result =
|
||||
!query.trim() || isQuickOpenQueryTooLarge(query)
|
||||
!query.trim() || isQuickOpenQueryTooLarge(query) || route.kind === 'ssh'
|
||||
? { paths: [], totalCount: 0, truncated: false }
|
||||
: route.kind === 'ssh'
|
||||
? await searchRemoteQuickOpenFilePaths(
|
||||
worktree.path,
|
||||
route.provider,
|
||||
query,
|
||||
limit,
|
||||
excludePaths,
|
||||
signal,
|
||||
options
|
||||
)
|
||||
: await searchHostQuickOpenFilePaths(worktree.path, this.host.requireStore(), {
|
||||
...options,
|
||||
query,
|
||||
limit,
|
||||
excludePaths,
|
||||
signal
|
||||
})
|
||||
: await searchHostQuickOpenFilePaths(worktree.path, this.host.requireStore(), {
|
||||
...options,
|
||||
query,
|
||||
limit,
|
||||
excludePaths,
|
||||
signal
|
||||
})
|
||||
return {
|
||||
worktree: worktree.id,
|
||||
rootPath: worktree.path,
|
||||
@@ -231,40 +201,8 @@ export class RuntimeFileSearch {
|
||||
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
|
||||
const route = runtimeFileRouteForTarget(target)
|
||||
if (route.kind === 'ssh') {
|
||||
// Why: quick-open listings degrade to empty for an unreachable host rather than throwing.
|
||||
const provider = route.provider
|
||||
if (!provider) {
|
||||
return []
|
||||
}
|
||||
const maxResults =
|
||||
options.maxResults ??
|
||||
(options.maxContentBytes === undefined ? undefined : QUICK_OPEN_LISTING_MAX_RESULTS)
|
||||
if (
|
||||
(options.candidatePaths !== undefined ||
|
||||
options.includeIgnored === false ||
|
||||
options.followSymlinks) &&
|
||||
!(await provider.supportsQuickOpenSearch?.({
|
||||
signal: options.signal,
|
||||
minimumVersion: options.candidatePaths !== undefined ? 3 : 2
|
||||
}))
|
||||
) {
|
||||
throw new Error(
|
||||
options.candidatePaths !== undefined
|
||||
? 'Update the remote host to validate Quick Open recent files.'
|
||||
: 'Update the remote host to use Quick Open listing options.'
|
||||
)
|
||||
}
|
||||
const files = await provider.listFiles(target.worktree.path, {
|
||||
excludePaths: options.excludePaths,
|
||||
...(options.candidatePaths === undefined ? {} : { candidatePaths: options.candidatePaths }),
|
||||
includeIgnored: options.includeIgnored,
|
||||
followSymlinks: options.followSymlinks,
|
||||
maxResults,
|
||||
signal: options.signal
|
||||
})
|
||||
return options.maxContentBytes === undefined
|
||||
? files
|
||||
: limitQuickOpenFilesBySerializedBytes(files, options.maxContentBytes)
|
||||
// Why: quick-open listings degrade to empty for an `ssh:` host rather than throwing.
|
||||
return []
|
||||
}
|
||||
return listQuickOpenFiles(
|
||||
target.worktree.path,
|
||||
@@ -277,17 +215,4 @@ export class RuntimeFileSearch {
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
// `null` provider is the caller's "this host is unreachable" answer, not "list it here".
|
||||
private async listRemoteMobileFiles(
|
||||
rootPath: string,
|
||||
provider: IFilesystemProvider | null,
|
||||
maxResults?: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<string[]> {
|
||||
if (!provider) {
|
||||
return []
|
||||
}
|
||||
return provider.listFiles(rootPath, { maxResults, signal })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// `resolveRuntimeFileTarget` read `store.getRepo(worktree.repoId)?.connectionId` and never looked
|
||||
// at `worktree.hostId`, so one arbitrarily chosen row decided the execution host for ~30 downstream
|
||||
// filesystem dispatches. `undefined` there meant "runtime host", "unresolved" and "genuinely local"
|
||||
// at once (#11163). These cases pin all four answers end to end, through the real SSH filesystem
|
||||
// provider table. Companion to runtime-git-target-execution-host.test.ts.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
// at once (#11163). These cases pin all four answers end to end; an `ssh:` host is refused by
|
||||
// name. Companion to runtime-git-target-execution-host.test.ts.
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: { fromId: vi.fn(() => null) },
|
||||
@@ -22,10 +22,7 @@ vi.mock('../ipc/markdown-documents', async () => ({
|
||||
}))
|
||||
|
||||
import { ExecutionHostNotDispatchableError } from '../providers/execution-host-provider-dispatch'
|
||||
import {
|
||||
registerSshFilesystemProvider,
|
||||
unregisterSshFilesystemProvider
|
||||
} from '../providers/ssh-filesystem-dispatch'
|
||||
import { SshHostNotServedError } from '../providers/ssh-host-not-served-error'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
|
||||
const REMOTE_PATH = '/srv/app-feature'
|
||||
@@ -63,36 +60,25 @@ function makeRuntime(repos: readonly Record<string, unknown>[], hostId?: string)
|
||||
return runtime
|
||||
}
|
||||
|
||||
function stubProvider() {
|
||||
return { listMarkdownDocuments: vi.fn().mockResolvedValue([]) }
|
||||
async function expectRefusedFor(promise: Promise<unknown>, connectionId: string): Promise<void> {
|
||||
const error = await promise.then(
|
||||
() => null,
|
||||
(caught: unknown) => caught
|
||||
)
|
||||
expect(error).toBeInstanceOf(SshHostNotServedError)
|
||||
expect(error).toMatchObject({ connectionId })
|
||||
expect(mocks.listMarkdownDocuments).not.toHaveBeenCalled()
|
||||
}
|
||||
|
||||
describe('runtime file target execution host', () => {
|
||||
const registered: string[] = []
|
||||
|
||||
function register(connectionId: string) {
|
||||
const provider = stubProvider()
|
||||
registerSshFilesystemProvider(connectionId, provider as never)
|
||||
registered.push(connectionId)
|
||||
return provider
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
mocks.listMarkdownDocuments.mockReset().mockResolvedValue([])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const connectionId of registered.splice(0)) {
|
||||
unregisterSshFilesystemProvider(connectionId)
|
||||
}
|
||||
})
|
||||
|
||||
// The case whose absence let the original cross-host leak through review: two SSH hosts
|
||||
// registered at once, and the rival row is the one `getRepo` returns first.
|
||||
it('lists an ssh workspace from the host it names, not from a rival row on another ssh host', async () => {
|
||||
const openclaw = register('openclaw')
|
||||
const m4air = register('m4air')
|
||||
// The case whose absence let the original cross-host leak through review: two SSH hosts,
|
||||
// and the rival row is the one `getRepo` returns first.
|
||||
it('routes an ssh workspace to the host it names, not to a rival row on another ssh host', async () => {
|
||||
const runtime = makeRuntime(
|
||||
[
|
||||
{ id: 'repo-shared', path: '/home/me/app', connectionId: 'openclaw' },
|
||||
@@ -101,31 +87,21 @@ describe('runtime file target execution host', () => {
|
||||
'ssh:m4air'
|
||||
)
|
||||
|
||||
await runtime.listRuntimeMarkdownDocuments(`id:${WORKTREE_ID}`)
|
||||
|
||||
expect(m4air.listMarkdownDocuments).toHaveBeenCalledWith(REMOTE_PATH)
|
||||
expect(openclaw.listMarkdownDocuments).not.toHaveBeenCalled()
|
||||
expect(mocks.listMarkdownDocuments).not.toHaveBeenCalled()
|
||||
await expectRefusedFor(runtime.listRuntimeMarkdownDocuments(`id:${WORKTREE_ID}`), 'm4air')
|
||||
})
|
||||
|
||||
it("routes to the workspace's host even when the only repo row names a different ssh host", async () => {
|
||||
const openclaw = register('openclaw')
|
||||
const m4air = register('m4air')
|
||||
const runtime = makeRuntime(
|
||||
[{ id: 'repo-shared', path: '/home/me/app', connectionId: 'openclaw' }],
|
||||
'ssh:m4air'
|
||||
)
|
||||
|
||||
await runtime.listRuntimeMarkdownDocuments(`id:${WORKTREE_ID}`)
|
||||
|
||||
expect(m4air.listMarkdownDocuments).toHaveBeenCalledWith(REMOTE_PATH)
|
||||
expect(openclaw.listMarkdownDocuments).not.toHaveBeenCalled()
|
||||
await expectRefusedFor(runtime.listRuntimeMarkdownDocuments(`id:${WORKTREE_ID}`), 'm4air')
|
||||
})
|
||||
|
||||
// `local` has no SSH namespace to nest in, so a surviving `connectionId` is a row contradicting
|
||||
// itself. The old shape handed it out and read a local workspace off a remote host.
|
||||
it('ignores a stale connection on a row that declares itself local', async () => {
|
||||
const m4air = register('m4air')
|
||||
const runtime = makeRuntime(
|
||||
[
|
||||
{
|
||||
@@ -140,15 +116,11 @@ describe('runtime file target execution host', () => {
|
||||
|
||||
await runtime.listRuntimeMarkdownDocuments(`id:${WORKTREE_ID}`)
|
||||
|
||||
expect(m4air.listMarkdownDocuments).not.toHaveBeenCalled()
|
||||
expect(mocks.listMarkdownDocuments).toHaveBeenCalledWith(REMOTE_PATH, {})
|
||||
})
|
||||
|
||||
// A `runtime:` row's `connectionId` names a target in the *server's* namespace. Reading it here
|
||||
// reaches a same-named target on this client — a silent-wrong-host answer, worse than the
|
||||
// silent-local one it replaced.
|
||||
it('refuses a runtime host whose nested ssh target is also registered on this client', async () => {
|
||||
const impostor = register('nested-1')
|
||||
// A `runtime:` row's `connectionId` names a target in the *server's* namespace, never this client's.
|
||||
it('refuses a runtime host whose row carries a nested ssh target', async () => {
|
||||
const runtime = makeRuntime(
|
||||
[
|
||||
{
|
||||
@@ -164,7 +136,6 @@ describe('runtime file target execution host', () => {
|
||||
await expect(runtime.listRuntimeMarkdownDocuments(`id:${WORKTREE_ID}`)).rejects.toThrow(
|
||||
ExecutionHostNotDispatchableError
|
||||
)
|
||||
expect(impostor.listMarkdownDocuments).not.toHaveBeenCalled()
|
||||
expect(mocks.listMarkdownDocuments).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -187,7 +158,6 @@ describe('runtime file target execution host', () => {
|
||||
})
|
||||
|
||||
it('refuses rather than guessing when rival rows disagree and the workspace names no host', async () => {
|
||||
register('m4air')
|
||||
const runtime = makeRuntime([
|
||||
{ id: 'repo-shared', path: '/srv/app', connectionId: 'm4air' },
|
||||
{ id: 'repo-shared', path: '/home/me/app' }
|
||||
@@ -199,13 +169,10 @@ describe('runtime file target execution host', () => {
|
||||
expect(mocks.listMarkdownDocuments).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still answers from the single row when the workspace names no host', async () => {
|
||||
const m4air = register('m4air')
|
||||
it('still routes by the single row when the workspace names no host', async () => {
|
||||
const runtime = makeRuntime([{ id: 'repo-shared', path: '/srv/app', connectionId: 'm4air' }])
|
||||
|
||||
await runtime.listRuntimeMarkdownDocuments(`id:${WORKTREE_ID}`)
|
||||
|
||||
expect(m4air.listMarkdownDocuments).toHaveBeenCalledWith(REMOTE_PATH)
|
||||
await expectRefusedFor(runtime.listRuntimeMarkdownDocuments(`id:${WORKTREE_ID}`), 'm4air')
|
||||
})
|
||||
|
||||
// Losing contact with a remote host is never evidence that its files are here
|
||||
@@ -225,8 +192,6 @@ describe('runtime file target execution host', () => {
|
||||
// The mutation guard recomputed the host from `connectionId`, so a client could satisfy a host
|
||||
// expectation the workspace never named.
|
||||
it('rejects a mutation whose expected host is the row rather than the resolved one', async () => {
|
||||
register('m4air')
|
||||
register('openclaw')
|
||||
const runtime = makeRuntime(
|
||||
[{ id: 'repo-shared', path: '/home/me/app', connectionId: 'openclaw' }],
|
||||
'ssh:m4air'
|
||||
|
||||
@@ -4,11 +4,9 @@ import {
|
||||
pendingRuntimeFileWatcherUnsubscribes,
|
||||
runtimeFileWatcherLeasesByOwnerAndRoot,
|
||||
runtimeWatcherReleaseKey,
|
||||
sshFileExplorerWatchRearms,
|
||||
trackRuntimeFileWatcherUnsubscribe
|
||||
} from './runtime-file-commands-mobile-file-list-limit'
|
||||
import { isWatcherProcessFailure } from '../ipc/parcel-watcher-process-failure'
|
||||
import { stopSshFileExplorerWatchRearms } from './runtime-file-commands-ssh-file-watcher-rearm'
|
||||
|
||||
export function registerRuntimeFileWatcherRelease(
|
||||
runtimeId: string,
|
||||
@@ -170,8 +168,5 @@ export function _resetRuntimeFileWatcherLeasesForTests(): void {
|
||||
for (const lease of leases) {
|
||||
lease.forget()
|
||||
}
|
||||
for (const key of Array.from(sshFileExplorerWatchRearms.keys())) {
|
||||
stopSshFileExplorerWatchRearms(key)
|
||||
}
|
||||
runtimeFileWatcherLeasesByOwnerAndRoot.clear()
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RuntimeGitCommandHost, RuntimeGitTarget } from './runtime-git-command-target'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getBranchCompare: vi.fn(),
|
||||
getSshGitProvider: vi.fn()
|
||||
getBranchCompare: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../git/status', () => ({
|
||||
@@ -15,11 +14,8 @@ vi.mock('../git/status', () => ({
|
||||
}))
|
||||
vi.mock('../git/repo', () => ({ getRemoteCommitUrl: vi.fn(), getRemoteFileUrl: vi.fn() }))
|
||||
vi.mock('../git/runner', () => ({ awaitWindowsHostGitEnvironmentReady: vi.fn() }))
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProvider: mocks.getSshGitProvider,
|
||||
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'provider unavailable'
|
||||
}))
|
||||
|
||||
import { SshHostNotServedError } from '../providers/ssh-host-not-served-error'
|
||||
import { RuntimeGitDiffCommands } from './runtime-git-diff-commands'
|
||||
|
||||
function makeCommands(overrides: Partial<RuntimeGitTarget> = {}): RuntimeGitDiffCommands {
|
||||
@@ -69,16 +65,12 @@ describe('RuntimeGitDiffCommands branch-compare admission', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards background admission to the SSH execution host', async () => {
|
||||
const getBranchCompare = vi.fn().mockResolvedValue({ summary: {}, entries: [] })
|
||||
mocks.getSshGitProvider.mockReturnValue({ getBranchCompare })
|
||||
it('refuses an SSH execution host without running git here', async () => {
|
||||
const commands = makeCommands({ executionHostId: 'ssh:conn-1' })
|
||||
|
||||
await commands.getRuntimeGitBranchCompare('id:wt-1', 'origin/main', 'background')
|
||||
|
||||
expect(getBranchCompare).toHaveBeenCalledWith('C:\\repo', 'origin/main', {
|
||||
admissionTier: 'background'
|
||||
})
|
||||
await expect(
|
||||
commands.getRuntimeGitBranchCompare('id:wt-1', 'origin/main', 'background')
|
||||
).rejects.toThrow(SshHostNotServedError)
|
||||
expect(mocks.getBranchCompare).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ vi.mock('../git/status', () => ({ getStatus }))
|
||||
|
||||
import { ExecutionHostNotDispatchableError } from '../providers/execution-host-provider-dispatch'
|
||||
import { createLocalGitProvider } from '../providers/local-git-provider'
|
||||
import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import { SshHostNotServedError } from '../providers/ssh-host-not-served-error'
|
||||
import {
|
||||
localGitOptionsForTarget,
|
||||
requireRuntimeGitProvider,
|
||||
@@ -25,31 +25,8 @@ function target(overrides: Partial<RuntimeGitTarget>): RuntimeGitTarget {
|
||||
}
|
||||
|
||||
describe('runtime Git target routing', () => {
|
||||
const registered: string[] = []
|
||||
|
||||
function register(connectionId: string) {
|
||||
const provider = { getStatus: async () => ({ entries: [] }) }
|
||||
registerSshGitProvider(connectionId, provider as never)
|
||||
registered.push(connectionId)
|
||||
return provider
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const connectionId of registered.splice(0)) {
|
||||
unregisterSshGitProvider(connectionId)
|
||||
}
|
||||
})
|
||||
|
||||
it('routes each ssh host to its own provider', () => {
|
||||
const m4air = register('m4air')
|
||||
const openclaw = register('openclaw')
|
||||
|
||||
expect(runtimeGitRouteForTarget(target({ executionHostId: 'ssh:m4air' }))).toEqual({
|
||||
kind: 'ssh',
|
||||
connectionId: 'm4air',
|
||||
provider: m4air
|
||||
})
|
||||
expect(requireRuntimeGitProvider(target({ executionHostId: 'ssh:openclaw' }))).toBe(openclaw)
|
||||
getStatus.mockReset()
|
||||
})
|
||||
|
||||
it('answers `local` with a provider bound to this worktree, built per call', async () => {
|
||||
@@ -68,18 +45,17 @@ describe('runtime Git target routing', () => {
|
||||
})
|
||||
|
||||
// Loss of contact is never evidence of locality (docs/reference/ssh-execution-boundary.md).
|
||||
it('keeps an unreachable ssh host remote instead of degrading it to local', () => {
|
||||
it('keeps an ssh host remote instead of degrading it to local', () => {
|
||||
const route = runtimeGitRouteForTarget(target({ executionHostId: 'ssh:gone' }))
|
||||
|
||||
expect(route).toEqual({ kind: 'ssh', connectionId: 'gone', provider: null })
|
||||
expect(route).toEqual({ kind: 'ssh', connectionId: 'gone' })
|
||||
expect(() => requireRuntimeGitProvider(target({ executionHostId: 'ssh:gone' }))).toThrow(
|
||||
/Remote connection dropped/
|
||||
SshHostNotServedError
|
||||
)
|
||||
expect(getStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a runtime host even when a same-named target is registered here', () => {
|
||||
register('nested-1')
|
||||
|
||||
it('refuses a runtime host instead of routing it', () => {
|
||||
expect(() => runtimeGitRouteForTarget(target({ executionHostId: 'runtime:env-a' }))).toThrow(
|
||||
ExecutionHostNotDispatchableError
|
||||
)
|
||||
|
||||
@@ -9,8 +9,7 @@ import {
|
||||
resolveGitRouteForHost
|
||||
} from '../providers/execution-host-provider-dispatch'
|
||||
import type { createLocalGitProvider, LocalGitProvider } from '../providers/local-git-provider'
|
||||
import { getSshGitProvider, sshGitProviderMissingError } from '../providers/ssh-git-dispatch'
|
||||
import type { SshGitProvider } from '../providers/ssh-git-provider'
|
||||
import { SshHostNotServedError } from '../providers/ssh-host-not-served-error'
|
||||
import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment'
|
||||
import type { PullRequestLinkedIssueMeta } from '../source-control/pull-request-linked-issue'
|
||||
import { normalizeRuntimeRelativePath } from './runtime-relative-paths'
|
||||
@@ -62,8 +61,8 @@ export type RuntimeGitCommandHost = {
|
||||
*/
|
||||
export type RuntimeGitRoute =
|
||||
| { kind: 'local'; createProvider: typeof createLocalGitProvider }
|
||||
/** `provider: null` is "remote and currently unreachable" — never "run it here". */
|
||||
| { kind: 'ssh'; connectionId: string; provider: SshGitProvider | null }
|
||||
/** An SSH target this process does not serve; it fails or reports unavailable, never runs here. */
|
||||
| { kind: 'ssh'; connectionId: string }
|
||||
|
||||
export function runtimeGitRouteForTarget(target: RuntimeGitTarget): RuntimeGitRoute {
|
||||
const route = resolveGitRouteForHost(target.executionHostId)
|
||||
@@ -71,40 +70,31 @@ export function runtimeGitRouteForTarget(target: RuntimeGitTarget): RuntimeGitRo
|
||||
case 'local':
|
||||
return { kind: 'local', createProvider: route.createProvider }
|
||||
case 'ssh':
|
||||
return {
|
||||
kind: 'ssh',
|
||||
connectionId: route.connectionId,
|
||||
provider: getSshGitProvider(route.connectionId) ?? null
|
||||
}
|
||||
return { kind: 'ssh', connectionId: route.connectionId }
|
||||
case 'runtime':
|
||||
throw new ExecutionHostNotDispatchableError(route.hostId)
|
||||
}
|
||||
}
|
||||
|
||||
/** A provider for the target's own host; an unreachable SSH host and a `runtime:` host both throw. */
|
||||
export function requireRuntimeGitProvider(
|
||||
target: RuntimeGitTarget,
|
||||
route: RuntimeGitRoute = runtimeGitRouteForTarget(target)
|
||||
): LocalGitProvider {
|
||||
/** The local route for work this process runs itself; `ssh:` and `runtime:` hosts throw. */
|
||||
export function requireLocalRuntimeGitRoute(
|
||||
target: RuntimeGitTarget
|
||||
): Extract<RuntimeGitRoute, { kind: 'local' }> {
|
||||
const route = runtimeGitRouteForTarget(target)
|
||||
if (route.kind === 'ssh') {
|
||||
return requireSshRuntimeGitProvider(route)
|
||||
throw new SshHostNotServedError(route.connectionId)
|
||||
}
|
||||
return route.createProvider({
|
||||
return route
|
||||
}
|
||||
|
||||
/** A provider for the target's own host; an `ssh:` host and a `runtime:` host both throw. */
|
||||
export function requireRuntimeGitProvider(target: RuntimeGitTarget): LocalGitProvider {
|
||||
return requireLocalRuntimeGitRoute(target).createProvider({
|
||||
...localGitOptionsForTarget(target),
|
||||
getSharedLinkPaths: () => (target.repo ? getWorktreeSharedLinkPaths(target.repo) : [])
|
||||
})
|
||||
}
|
||||
|
||||
/** For the few commands whose SSH arm needs relay-only methods. */
|
||||
export function requireSshRuntimeGitProvider(
|
||||
route: Extract<RuntimeGitRoute, { kind: 'ssh' }>
|
||||
): SshGitProvider {
|
||||
if (!route.provider) {
|
||||
throw sshGitProviderMissingError(route.connectionId)
|
||||
}
|
||||
return route.provider
|
||||
}
|
||||
|
||||
export function localGitOptionsForTarget(target: RuntimeGitTarget): GitRuntimeOptions {
|
||||
// WSL routing describes *this* machine; no remote host may inherit it.
|
||||
return target.executionHostId === LOCAL_EXECUTION_HOST_ID ? (target.localGitOptions ?? {}) : {}
|
||||
|
||||
@@ -10,16 +10,15 @@ import { normalizeRuntimeRelativePath } from './runtime-relative-paths'
|
||||
import {
|
||||
localGitOptionsForTarget,
|
||||
normalizeRuntimeGitRelativePath,
|
||||
requireLocalRuntimeGitRoute,
|
||||
requireRuntimeGitProvider,
|
||||
requireSshRuntimeGitProvider,
|
||||
runtimeGitRouteForTarget,
|
||||
type RuntimeGitCommandHost
|
||||
} from './runtime-git-command-target'
|
||||
|
||||
export class RuntimeGitDiffCommands {
|
||||
constructor(private readonly host: RuntimeGitCommandHost) {}
|
||||
|
||||
// Why: cap both local and forwarded SSH payloads after execution-host dispatch.
|
||||
// Why: cap the payload after execution-host dispatch.
|
||||
async getRuntimeGitDiff(
|
||||
worktreeSelector: string,
|
||||
filePath: string,
|
||||
@@ -69,26 +68,7 @@ export class RuntimeGitDiffCommands {
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const relativePath = normalizeRuntimeGitRelativePath(filePath)
|
||||
const oldRelativePath = oldPath ? normalizeRuntimeGitRelativePath(oldPath) : undefined
|
||||
const route = runtimeGitRouteForTarget(target)
|
||||
if (route.kind === 'ssh') {
|
||||
const provider = requireSshRuntimeGitProvider(route)
|
||||
const results = await provider.getBranchDiff(target.worktree.path, compare.mergeBase, {
|
||||
includePatch: true,
|
||||
headOid: compare.headOid,
|
||||
filePath: relativePath,
|
||||
oldPath: oldRelativePath
|
||||
})
|
||||
return assertGitDiffWithinTransportBudget(
|
||||
results[0] ?? {
|
||||
kind: 'text',
|
||||
originalContent: '',
|
||||
modifiedContent: '',
|
||||
originalIsBinary: false,
|
||||
modifiedIsBinary: false
|
||||
},
|
||||
maxContentBytes
|
||||
)
|
||||
}
|
||||
requireLocalRuntimeGitRoute(target)
|
||||
return assertGitDiffWithinTransportBudget(
|
||||
await getBranchDiff(
|
||||
target.worktree.path,
|
||||
|
||||
@@ -8,7 +8,6 @@ const mocks = vi.hoisted(() => ({
|
||||
gitExecFileAsync: vi.fn(),
|
||||
getPullRequestDraftContext: vi.fn(),
|
||||
loadPullRequestLinkedIssue: vi.fn(),
|
||||
getSshGitProvider: vi.fn(),
|
||||
prepareLocalCommitMessageAgentEnv: vi.fn(),
|
||||
generateCommitMessageFromContext: vi.fn(),
|
||||
generatePullRequestFieldsFromContext: vi.fn(),
|
||||
@@ -29,10 +28,6 @@ vi.mock('../source-control/pull-request-linked-issue', () => ({
|
||||
return linkedLookup.run()
|
||||
}
|
||||
}))
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProvider: mocks.getSshGitProvider,
|
||||
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'provider unavailable'
|
||||
}))
|
||||
vi.mock('../text-generation/commit-message-agent-environment', () => ({
|
||||
prepareLocalCommitMessageAgentEnv: mocks.prepareLocalCommitMessageAgentEnv
|
||||
}))
|
||||
@@ -44,6 +39,7 @@ vi.mock('../source-control/pull-request-template', () => ({
|
||||
resolveHostedReviewBodyForGeneration: mocks.resolveHostedReviewBodyForGeneration
|
||||
}))
|
||||
|
||||
import { SSH_HOST_NOT_SERVED_MESSAGE } from '../providers/ssh-host-not-served-error'
|
||||
import { RuntimeGitGenerationCommands } from './runtime-git-generation-commands'
|
||||
|
||||
const params = { agentId: 'codex' as const, model: 'gpt-5.5' }
|
||||
@@ -140,49 +136,29 @@ describe('RuntimeGitGenerationCommands admission', () => {
|
||||
})
|
||||
expect(mocks.loadPullRequestLinkedIssue).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
connectionId: undefined,
|
||||
localGitOptions: { wslDistro: 'Ubuntu', admissionTier: 'interactive' }
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps SSH pull-request context reads on the remote provider', async () => {
|
||||
const exec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' })
|
||||
mocks.getSshGitProvider.mockReturnValue({ exec, executeCommitMessagePlan: vi.fn() })
|
||||
mocks.getPullRequestDraftContext.mockImplementation(async (execute) => {
|
||||
await execute(['log', '--oneline'])
|
||||
await execute(['show-ref', '--verify'], { maxBuffer: 456, timeoutMs: 789 })
|
||||
return pullRequestContext
|
||||
})
|
||||
const commands = makeCommands(
|
||||
makeTarget('/remote/repo', {
|
||||
executionHostId: 'ssh:conn-1',
|
||||
localGitOptions: { wslDistro: 'Ubuntu' }
|
||||
})
|
||||
)
|
||||
it('reports an SSH pull-request target unserved without reading git anywhere', async () => {
|
||||
const commands = makeCommands(makeTarget('/remote/repo', { executionHostId: 'ssh:conn-1' }))
|
||||
|
||||
await commands.generateRuntimePullRequestFields(
|
||||
'id:wt-1',
|
||||
{ base: 'main', title: '', body: '', draft: false },
|
||||
settingsOverride
|
||||
)
|
||||
|
||||
expect(exec).toHaveBeenCalledWith(['log', '--oneline'], '/remote/repo')
|
||||
expect(exec).toHaveBeenCalledWith(['show-ref', '--verify'], '/remote/repo', { timeoutMs: 789 })
|
||||
await expect(
|
||||
commands.generateRuntimePullRequestFields(
|
||||
'id:wt-1',
|
||||
{ base: 'main', title: '', body: '', draft: false },
|
||||
settingsOverride
|
||||
)
|
||||
).resolves.toEqual({ success: false, error: SSH_HOST_NOT_SERVED_MESSAGE })
|
||||
expect(mocks.getPullRequestDraftContext).not.toHaveBeenCalled()
|
||||
expect(mocks.gitExecFileAsync).not.toHaveBeenCalled()
|
||||
expect(mocks.loadPullRequestLinkedIssue).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ connectionId: 'conn-1', localGitOptions: {} })
|
||||
)
|
||||
expect(mocks.loadPullRequestLinkedIssue).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe.each(['local', 'SSH'])('linked-issue lifetime on %s', (host) => {
|
||||
describe('linked-issue lifetime', () => {
|
||||
function commands(): RuntimeGitGenerationCommands {
|
||||
mocks.getSshGitProvider.mockReturnValue({ exec: vi.fn(), executeCommitMessagePlan: vi.fn() })
|
||||
return makeCommands(
|
||||
makeTarget('/repo', {
|
||||
executionHostId: host === 'SSH' ? 'ssh:conn-1' : 'local'
|
||||
})
|
||||
)
|
||||
return makeCommands(makeTarget('/repo'))
|
||||
}
|
||||
|
||||
const input = { base: 'main', title: '', body: '', draft: false, provider: 'gitlab' as const }
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { HostedReviewProvider } from '../../shared/hosted-review'
|
||||
import { withLinkedIssueDraftContext } from '../../shared/source-control-ai-action-variables'
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
import { getStagedCommitContext } from '../git/status'
|
||||
import { SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE } from '../providers/ssh-git-dispatch'
|
||||
import { SSH_HOST_NOT_SERVED_MESSAGE } from '../providers/ssh-host-not-served-error'
|
||||
import { loadPullRequestLinkedIssue } from '../source-control/pull-request-linked-issue'
|
||||
import { resolveHostedReviewBodyForGeneration } from '../source-control/pull-request-template'
|
||||
import { prepareLocalCommitMessageAgentEnv } from '../text-generation/commit-message-agent-environment'
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
cancelGenerateCommitMessageLocal,
|
||||
cancelGeneratePullRequestFieldsLocal,
|
||||
discoverCommitMessageModelsLocal,
|
||||
discoverCommitMessageModelsRemote,
|
||||
generateCommitMessageFromContext,
|
||||
generatePullRequestFieldsFromContext,
|
||||
resolveCommitMessageSettings,
|
||||
@@ -65,28 +64,7 @@ export class RuntimeGitGenerationCommands {
|
||||
}
|
||||
|
||||
if (route.kind === 'ssh') {
|
||||
const provider = route.provider
|
||||
if (!provider) {
|
||||
return { success: false, error: SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE }
|
||||
}
|
||||
let context: CommitMessageDraftContext | null
|
||||
try {
|
||||
context = await provider.getStagedCommitContext(target.worktree.path)
|
||||
} catch (error) {
|
||||
console.error('[runtime-git] Failed to read remote staged commit context:', error)
|
||||
return { success: false, error: 'Failed to read staged changes.' }
|
||||
}
|
||||
if (!context) {
|
||||
return { success: false, error: 'No staged changes to summarize.' }
|
||||
}
|
||||
context = withLinkedIssueDraftContext(context, linkedIssueForTarget(this.host, target))
|
||||
return generateCommitMessageFromContext(context, resolvedSettings.params, {
|
||||
kind: 'remote',
|
||||
cwd: target.worktree.path,
|
||||
execute: (plan, cwd, timeoutMs, operation) =>
|
||||
provider.executeCommitMessagePlan(plan, cwd, timeoutMs, operation),
|
||||
missingBinaryLocation: 'remote PATH'
|
||||
})
|
||||
return { success: false, error: SSH_HOST_NOT_SERVED_MESSAGE }
|
||||
}
|
||||
|
||||
let context: CommitMessageDraftContext | null
|
||||
@@ -121,10 +99,9 @@ export class RuntimeGitGenerationCommands {
|
||||
async cancelRuntimeGenerateCommitMessage(worktreeSelector: string): Promise<{ ok: true }> {
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const route = runtimeGitRouteForTarget(target)
|
||||
// Why: an `ssh:` cancel is a no-op; the local registry is keyed by path and would abort an
|
||||
// unrelated generation running here for the same path.
|
||||
if (route.kind === 'ssh') {
|
||||
// Cancelling an unreachable host is a no-op, not a local cancel: the local registry is keyed
|
||||
// by path and would abort an unrelated generation running here for the same path.
|
||||
await route.provider?.cancelGenerateCommitMessage(target.worktree.path, 'commit-message')
|
||||
return { ok: true }
|
||||
}
|
||||
cancelGenerateCommitMessageLocal(target.worktree.path)
|
||||
@@ -164,23 +141,18 @@ export class RuntimeGitGenerationCommands {
|
||||
return { success: false, error: resolvedSettings.error }
|
||||
}
|
||||
|
||||
const provider = route.kind === 'ssh' ? route.provider : null
|
||||
if (route.kind === 'ssh' && !provider) {
|
||||
return { success: false, error: SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE }
|
||||
if (route.kind === 'ssh') {
|
||||
return { success: false, error: SSH_HOST_NOT_SERVED_MESSAGE }
|
||||
}
|
||||
const issueMeta = linkedIssueMetaForTarget(this.host, target)
|
||||
const linkedIssueDetailsPromise = loadPullRequestLinkedIssue({
|
||||
meta: issueMeta,
|
||||
provider: input.provider,
|
||||
repoPath: target.worktree.path,
|
||||
connectionId: route.kind === 'ssh' ? route.connectionId : undefined,
|
||||
localGitOptions:
|
||||
route.kind === 'ssh'
|
||||
? {}
|
||||
: {
|
||||
...localGitOptionsForTarget(target),
|
||||
admissionTier: 'interactive'
|
||||
}
|
||||
localGitOptions: {
|
||||
...localGitOptionsForTarget(target),
|
||||
admissionTier: 'interactive'
|
||||
}
|
||||
})
|
||||
void linkedIssueDetailsPromise.catch(() => undefined)
|
||||
let context: Awaited<ReturnType<typeof getPullRequestDraftContext>>
|
||||
@@ -188,11 +160,10 @@ export class RuntimeGitGenerationCommands {
|
||||
const currentBody = await resolveHostedReviewBodyForGeneration({
|
||||
body: input.body,
|
||||
repoPath: target.worktree.path,
|
||||
connectionId: route.kind === 'ssh' ? route.connectionId : undefined,
|
||||
provider: input.provider,
|
||||
useTemplate: input.useTemplate
|
||||
})
|
||||
context = await getPullRequestDraftContext(pullRequestDraftGitExec(target, route), {
|
||||
context = await getPullRequestDraftContext(pullRequestDraftGitExec(target), {
|
||||
base: input.base,
|
||||
currentTitle: input.title,
|
||||
currentBody,
|
||||
@@ -214,15 +185,6 @@ export class RuntimeGitGenerationCommands {
|
||||
...(linkedIssueDetails ? { linkedIssueDetails } : {})
|
||||
}
|
||||
|
||||
if (route.kind === 'ssh') {
|
||||
return generatePullRequestFieldsFromContext(context, resolvedSettings.params, {
|
||||
kind: 'remote',
|
||||
cwd: target.worktree.path,
|
||||
execute: (plan, cwd, timeoutMs, operation) =>
|
||||
provider!.executeCommitMessagePlan(plan, cwd, timeoutMs, operation),
|
||||
missingBinaryLocation: 'remote PATH'
|
||||
})
|
||||
}
|
||||
const localEnv = await prepareLocalCommitMessageAgentEnv(
|
||||
resolvedSettings.params.agentId,
|
||||
this.host.getCommitMessageAgentEnvironment?.(),
|
||||
@@ -242,7 +204,6 @@ export class RuntimeGitGenerationCommands {
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const route = runtimeGitRouteForTarget(target)
|
||||
if (route.kind === 'ssh') {
|
||||
await route.provider?.cancelGenerateCommitMessage(target.worktree.path, 'pull-request-fields')
|
||||
return { ok: true }
|
||||
}
|
||||
cancelGeneratePullRequestFieldsLocal(target.worktree.path)
|
||||
@@ -261,16 +222,7 @@ export class RuntimeGitGenerationCommands {
|
||||
this.host.getRuntimeSettings().agentCmdOverrides?.[typedAgentId]
|
||||
const route = runtimeGitRouteForTarget(target)
|
||||
if (route.kind === 'ssh') {
|
||||
const provider = route.provider
|
||||
if (!provider) {
|
||||
return { success: false, error: `No git provider for connection "${route.connectionId}"` }
|
||||
}
|
||||
return discoverCommitMessageModelsRemote(
|
||||
typedAgentId,
|
||||
target.worktree.path,
|
||||
(plan, cwd, timeoutMs) => provider.executeCommitMessagePlan(plan, cwd, timeoutMs),
|
||||
agentCommandOverride
|
||||
)
|
||||
return { success: false, error: `No git provider for connection "${route.connectionId}"` }
|
||||
}
|
||||
const localEnv = await prepareLocalCommitMessageAgentEnv(
|
||||
typedAgentId,
|
||||
|
||||
@@ -9,28 +9,16 @@ import type { SourceControlAiOperation } from '../../shared/source-control-ai-ty
|
||||
import type { CommitMessageAgentRuntimeTarget } from '../text-generation/commit-message-agent-environment'
|
||||
import type { CommitMessageGenerationTarget } from '../text-generation/commit-message-text-generation'
|
||||
import type { PullRequestLinkedIssueMeta } from '../source-control/pull-request-linked-issue'
|
||||
import { execSshReviewDraft } from '../providers/ssh-review-draft-context'
|
||||
import {
|
||||
localGitOptionsForTarget,
|
||||
type RuntimeGitCommandHost,
|
||||
type RuntimeGitRoute,
|
||||
type RuntimeGitTarget
|
||||
} from './runtime-git-command-target'
|
||||
|
||||
type PullRequestDraftGitExec = Parameters<typeof getPullRequestDraftContext>[0]
|
||||
|
||||
/** Runs the PR draft-context probes on whichever host `route` resolved to. */
|
||||
export function pullRequestDraftGitExec(
|
||||
target: RuntimeGitTarget,
|
||||
route: RuntimeGitRoute
|
||||
): PullRequestDraftGitExec {
|
||||
if (route.kind === 'ssh') {
|
||||
const provider = route.provider
|
||||
if (!provider) {
|
||||
throw new Error('ssh_git_provider_unavailable')
|
||||
}
|
||||
return (argv, options) => execSshReviewDraft(provider, argv, target.worktree.path, options)
|
||||
}
|
||||
/** Runs the PR draft-context probes on a local target. */
|
||||
export function pullRequestDraftGitExec(target: RuntimeGitTarget): PullRequestDraftGitExec {
|
||||
return (argv, options) =>
|
||||
gitExecFileAsync(argv, {
|
||||
cwd: target.worktree.path,
|
||||
|
||||
@@ -6,12 +6,12 @@ import type * as GitStatusModule from '../git/status'
|
||||
import type * as GitForkSyncModule from '../git/fork-sync'
|
||||
import type { ResolvedRuntimeGitWorktree } from './runtime-git-command-target'
|
||||
import { RuntimeGitSyncCommands } from './runtime-git-sync-commands'
|
||||
import { SshHostNotServedError } from '../providers/ssh-host-not-served-error'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
abortMerge: vi.fn(),
|
||||
abortRebase: vi.fn(),
|
||||
commitChanges: vi.fn(),
|
||||
getSshGitProvider: vi.fn(),
|
||||
gitSyncForkDefaultBranch: vi.fn(),
|
||||
gitFastForward: vi.fn(),
|
||||
gitFetch: vi.fn(),
|
||||
@@ -37,10 +37,6 @@ vi.mock('../git/remote', async () => ({
|
||||
gitPull: mocks.gitPull
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProvider: mocks.getSshGitProvider
|
||||
}))
|
||||
|
||||
const worktree = {
|
||||
id: 'wt-1',
|
||||
path: '/workspace/repo'
|
||||
@@ -94,17 +90,7 @@ describe('RuntimeGitSyncCommands admission', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps remote runtime git actions owned by the SSH provider', async () => {
|
||||
const provider = {
|
||||
abortMerge: vi.fn(),
|
||||
abortRebase: vi.fn(),
|
||||
commit: vi.fn().mockResolvedValue({ success: true }),
|
||||
fastForwardBranch: vi.fn(),
|
||||
fetchRemote: vi.fn(),
|
||||
syncForkDefaultBranch: vi.fn().mockResolvedValue({ status: 'up-to-date' }),
|
||||
pullBranch: vi.fn()
|
||||
}
|
||||
mocks.getSshGitProvider.mockReturnValue(provider)
|
||||
it('refuses every runtime git action on an SSH host without running git here', async () => {
|
||||
const commands = new RuntimeGitSyncCommands({
|
||||
resolveRuntimeGitTarget: async () => ({
|
||||
worktree,
|
||||
@@ -113,27 +99,19 @@ describe('RuntimeGitSyncCommands admission', () => {
|
||||
getRuntimeSettings: () => ({}) as GlobalSettings
|
||||
})
|
||||
|
||||
await commands.abortRuntimeGitMerge('id:wt-1')
|
||||
await commands.abortRuntimeGitRebase('id:wt-1')
|
||||
await commands.fetchRuntimeGit('id:wt-1', pushTarget)
|
||||
await commands.syncRuntimeGitForkDefaultBranch('id:wt-1', expectedUpstream)
|
||||
await commands.pullRuntimeGit('id:wt-1', pushTarget)
|
||||
await commands.fastForwardRuntimeGit('id:wt-1', pushTarget)
|
||||
await commands.commitRuntimeGit('id:wt-1', 'feat: keep execution remote')
|
||||
|
||||
expect(provider.abortMerge).toHaveBeenCalledWith(worktree.path)
|
||||
expect(provider.abortRebase).toHaveBeenCalledWith(worktree.path)
|
||||
expect(provider.fetchRemote).toHaveBeenCalledWith(worktree.path, pushTarget)
|
||||
expect(provider.syncForkDefaultBranch).toHaveBeenCalledWith(worktree.path, expectedUpstream)
|
||||
expect(provider.pullBranch).toHaveBeenCalledWith(worktree.path, pushTarget)
|
||||
expect(provider.fastForwardBranch).toHaveBeenCalledWith(worktree.path, pushTarget)
|
||||
expect(provider.commit).toHaveBeenCalledWith(worktree.path, 'feat: keep execution remote')
|
||||
await expect(commands.abortRuntimeGitMerge('id:wt-1')).rejects.toThrow(SshHostNotServedError)
|
||||
await expect(commands.fetchRuntimeGit('id:wt-1', pushTarget)).rejects.toThrow(
|
||||
SshHostNotServedError
|
||||
)
|
||||
await expect(commands.pullRuntimeGit('id:wt-1', pushTarget)).rejects.toThrow(
|
||||
SshHostNotServedError
|
||||
)
|
||||
await expect(commands.commitRuntimeGit('id:wt-1', 'feat: remote')).rejects.toThrow(
|
||||
SshHostNotServedError
|
||||
)
|
||||
expect(mocks.abortMerge).not.toHaveBeenCalled()
|
||||
expect(mocks.abortRebase).not.toHaveBeenCalled()
|
||||
expect(mocks.gitFetch).not.toHaveBeenCalled()
|
||||
expect(mocks.gitSyncForkDefaultBranch).not.toHaveBeenCalled()
|
||||
expect(mocks.gitPull).not.toHaveBeenCalled()
|
||||
expect(mocks.gitFastForward).not.toHaveBeenCalled()
|
||||
expect(mocks.commitChanges).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../../shared/git-fork-sync'
|
||||
import type { GitUpstreamStatus } from '../../shared/git-status-types'
|
||||
import type { GitPushTarget } from '../../shared/worktree/types'
|
||||
import {
|
||||
materializeWorktreePushTargetRemote,
|
||||
materializeWorktreePushTargetRemoteSsh
|
||||
} from '../ipc/worktree-remote'
|
||||
import { materializeWorktreePushTargetRemote } from '../ipc/worktree-remote'
|
||||
import type { LocalGitProvider } from '../providers/local-git-provider'
|
||||
import {
|
||||
localGitOptionsForTarget,
|
||||
requireRuntimeGitProvider,
|
||||
requireSshRuntimeGitProvider,
|
||||
runtimeGitRouteForTarget,
|
||||
type RuntimeGitCommandHost,
|
||||
type RuntimeGitTarget
|
||||
} from './runtime-git-command-target'
|
||||
@@ -30,31 +25,16 @@ export class RuntimeGitSyncCommands {
|
||||
target: RuntimeGitTarget,
|
||||
pushTarget: GitPushTarget | undefined
|
||||
): Promise<{ provider: LocalGitProvider; pushTarget: GitPushTarget | undefined }> {
|
||||
const route = runtimeGitRouteForTarget(target)
|
||||
let provider: LocalGitProvider
|
||||
let materialized: GitPushTarget | undefined
|
||||
if (route.kind === 'ssh') {
|
||||
const sshProvider = requireSshRuntimeGitProvider(route)
|
||||
provider = sshProvider
|
||||
materialized = pushTarget
|
||||
? await materializeWorktreePushTargetRemoteSsh(
|
||||
sshProvider,
|
||||
target.worktree.path,
|
||||
pushTarget
|
||||
)
|
||||
: undefined
|
||||
} else {
|
||||
provider = requireRuntimeGitProvider(target, route)
|
||||
materialized = pushTarget
|
||||
? await materializeWorktreePushTargetRemote(
|
||||
target.worktree.path,
|
||||
pushTarget,
|
||||
undefined,
|
||||
target.repo?.id,
|
||||
localGitOptionsForTarget(target)
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
const provider = requireRuntimeGitProvider(target)
|
||||
const materialized = pushTarget
|
||||
? await materializeWorktreePushTargetRemote(
|
||||
target.worktree.path,
|
||||
pushTarget,
|
||||
undefined,
|
||||
target.repo?.id,
|
||||
localGitOptionsForTarget(target)
|
||||
)
|
||||
: undefined
|
||||
if (materialized?.remoteCreated) {
|
||||
this.host.persistMaterializedPushTarget?.(target.worktree.id, materialized)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// `resolveRuntimeGitTarget` read `store.getRepo(worktree.repoId)?.connectionId` and never looked at
|
||||
// `worktree.hostId`, so one arbitrarily chosen row decided the execution host for ~36 downstream
|
||||
// Git dispatches. `undefined` there meant "runtime host", "unresolved" and "genuinely local" at
|
||||
// once (#11163). These cases pin all four answers end to end, through the real SSH provider table.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
// once (#11163). These cases pin all four answers end to end; an `ssh:` host is refused by name.
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: { fromId: vi.fn(() => null) },
|
||||
@@ -21,7 +21,7 @@ vi.mock('../git/status', async () => ({
|
||||
}))
|
||||
|
||||
import { ExecutionHostNotDispatchableError } from '../providers/execution-host-provider-dispatch'
|
||||
import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import { SshHostNotServedError } from '../providers/ssh-host-not-served-error'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
|
||||
const REMOTE_PATH = '/srv/app-feature'
|
||||
@@ -50,36 +50,25 @@ function makeRuntime(repos: readonly Record<string, unknown>[], hostId?: string)
|
||||
return runtime
|
||||
}
|
||||
|
||||
function stubProvider() {
|
||||
return { getStatus: vi.fn().mockResolvedValue({ entries: [] }) }
|
||||
async function expectRefusedFor(promise: Promise<unknown>, connectionId: string): Promise<void> {
|
||||
const error = await promise.then(
|
||||
() => null,
|
||||
(caught: unknown) => caught
|
||||
)
|
||||
expect(error).toBeInstanceOf(SshHostNotServedError)
|
||||
expect(error).toMatchObject({ connectionId })
|
||||
expect(mocks.getStatus).not.toHaveBeenCalled()
|
||||
}
|
||||
|
||||
describe('runtime Git target execution host', () => {
|
||||
const registered: string[] = []
|
||||
|
||||
function register(connectionId: string) {
|
||||
const provider = stubProvider()
|
||||
registerSshGitProvider(connectionId, provider as never)
|
||||
registered.push(connectionId)
|
||||
return provider
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
mocks.getStatus.mockReset().mockResolvedValue({ entries: [] })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const connectionId of registered.splice(0)) {
|
||||
unregisterSshGitProvider(connectionId)
|
||||
}
|
||||
})
|
||||
|
||||
// The case whose absence let the original cross-host leak through review: two SSH hosts, and the
|
||||
// rival row is the one `getRepo` returns first.
|
||||
it('serves an ssh worktree from the host it names, not from a rival row on another ssh host', async () => {
|
||||
const openclaw = register('openclaw')
|
||||
const m4air = register('m4air')
|
||||
it('routes an ssh worktree to the host it names, not to a rival row on another ssh host', async () => {
|
||||
const runtime = makeRuntime(
|
||||
[
|
||||
{ id: 'repo-shared', path: '/home/me/app', connectionId: 'openclaw' },
|
||||
@@ -88,31 +77,21 @@ describe('runtime Git target execution host', () => {
|
||||
'ssh:m4air'
|
||||
)
|
||||
|
||||
await runtime.getRuntimeGitStatus(`id:${WORKTREE_ID}`)
|
||||
|
||||
expect(m4air.getStatus).toHaveBeenCalledWith(REMOTE_PATH)
|
||||
expect(openclaw.getStatus).not.toHaveBeenCalled()
|
||||
expect(mocks.getStatus).not.toHaveBeenCalled()
|
||||
await expectRefusedFor(runtime.getRuntimeGitStatus(`id:${WORKTREE_ID}`), 'm4air')
|
||||
})
|
||||
|
||||
it("routes to the worktree's host even when the only repo row names a different ssh host", async () => {
|
||||
const openclaw = register('openclaw')
|
||||
const m4air = register('m4air')
|
||||
const runtime = makeRuntime(
|
||||
[{ id: 'repo-shared', path: '/home/me/app', connectionId: 'openclaw' }],
|
||||
'ssh:m4air'
|
||||
)
|
||||
|
||||
await runtime.getRuntimeGitStatus(`id:${WORKTREE_ID}`)
|
||||
|
||||
expect(m4air.getStatus).toHaveBeenCalledWith(REMOTE_PATH)
|
||||
expect(openclaw.getStatus).not.toHaveBeenCalled()
|
||||
await expectRefusedFor(runtime.getRuntimeGitStatus(`id:${WORKTREE_ID}`), 'm4air')
|
||||
})
|
||||
|
||||
// `local` has no SSH namespace to nest in, so a surviving `connectionId` is a row contradicting
|
||||
// itself. The old shape handed it out and dialled a remote host for a local workspace.
|
||||
it('ignores a stale connection on a row that declares itself local', async () => {
|
||||
const m4air = register('m4air')
|
||||
const runtime = makeRuntime(
|
||||
[
|
||||
{
|
||||
@@ -127,15 +106,13 @@ describe('runtime Git target execution host', () => {
|
||||
|
||||
await runtime.getRuntimeGitStatus(`id:${WORKTREE_ID}`)
|
||||
|
||||
expect(m4air.getStatus).not.toHaveBeenCalled()
|
||||
expect(mocks.getStatus).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// A `runtime:` row's `connectionId` names a target in the *server's* namespace. Dialling it here
|
||||
// reaches a same-named target on this client — a silent-wrong-host answer, worse than the
|
||||
// silent-local one it replaced.
|
||||
it('refuses a runtime host whose nested ssh target is also registered on this client', async () => {
|
||||
const impostor = register('nested-1')
|
||||
it('refuses a runtime host whose row carries a nested ssh target', async () => {
|
||||
const runtime = makeRuntime(
|
||||
[
|
||||
{
|
||||
@@ -151,7 +128,6 @@ describe('runtime Git target execution host', () => {
|
||||
await expect(runtime.getRuntimeGitStatus(`id:${WORKTREE_ID}`)).rejects.toThrow(
|
||||
ExecutionHostNotDispatchableError
|
||||
)
|
||||
expect(impostor.getStatus).not.toHaveBeenCalled()
|
||||
expect(mocks.getStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -168,7 +144,6 @@ describe('runtime Git target execution host', () => {
|
||||
})
|
||||
|
||||
it('refuses rather than guessing when rival rows disagree and the worktree names no host', async () => {
|
||||
register('m4air')
|
||||
const runtime = makeRuntime([
|
||||
{ id: 'repo-shared', path: '/srv/app', connectionId: 'm4air' },
|
||||
{ id: 'repo-shared', path: '/home/me/app' }
|
||||
@@ -180,13 +155,10 @@ describe('runtime Git target execution host', () => {
|
||||
expect(mocks.getStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still answers from the single row when the worktree names no host', async () => {
|
||||
const m4air = register('m4air')
|
||||
it('still routes by the single row when the worktree names no host', async () => {
|
||||
const runtime = makeRuntime([{ id: 'repo-shared', path: '/srv/app', connectionId: 'm4air' }])
|
||||
|
||||
await runtime.getRuntimeGitStatus(`id:${WORKTREE_ID}`)
|
||||
|
||||
expect(m4air.getStatus).toHaveBeenCalledWith(REMOTE_PATH)
|
||||
await expectRefusedFor(runtime.getRuntimeGitStatus(`id:${WORKTREE_ID}`), 'm4air')
|
||||
})
|
||||
|
||||
// Losing contact with a remote host is never evidence that its files are here
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
getLocalProjectGitExecOptions,
|
||||
getLocalProjectGhExecOptions
|
||||
} from '../project-runtime-git-options'
|
||||
import { requireSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import { SshHostNotServedError } from '../providers/ssh-host-not-served-error'
|
||||
import { isFolderRepo } from '../../shared/repo-kind'
|
||||
import type { Store } from '../persistence'
|
||||
|
||||
@@ -39,42 +39,24 @@ export async function resolveRuntimeGitHubWorktreeBase(
|
||||
if (isFolderRepo(repo)) {
|
||||
return { error: 'Folder mode does not support creating worktrees.' }
|
||||
}
|
||||
const sshGitProvider = repo.connectionId ? requireSshGitProvider(repo.connectionId) : null
|
||||
const localGitExecOptions = sshGitProvider
|
||||
? undefined
|
||||
: getLocalProjectGitExecOptions(deps.store, repo)
|
||||
const localGhOptions = sshGitProvider
|
||||
? repo.ghAccount
|
||||
? { ghAccount: repo.ghAccount }
|
||||
: undefined
|
||||
: getLocalProjectGhExecOptions(deps.store, repo)
|
||||
const gitExec = sshGitProvider
|
||||
? (gitArgs: string[]) => sshGitProvider.exec(gitArgs, repo.path)
|
||||
: (gitArgs: string[]) => gitExecFileAsync(gitArgs, localGitExecOptions ?? { cwd: repo.path })
|
||||
if (repo.connectionId) {
|
||||
throw new SshHostNotServedError(repo.connectionId)
|
||||
}
|
||||
const localGitExecOptions = getLocalProjectGitExecOptions(deps.store, repo)
|
||||
const localGhOptions = getLocalProjectGhExecOptions(deps.store, repo)
|
||||
const gitExec = (gitArgs: string[]) => gitExecFileAsync(gitArgs, localGitExecOptions)
|
||||
const resolveRemote = (): Promise<string> =>
|
||||
resolveGitHubReviewHeadRemote({
|
||||
repoPath: repo.path,
|
||||
issueSourcePreference: repo.issueSourcePreference,
|
||||
connectionId: repo.connectionId ?? null,
|
||||
connectionId: null,
|
||||
localGitOptions: localGhOptions,
|
||||
gitExec
|
||||
})
|
||||
const fetchRemoteTrackingRef = (remote: string, branch: string): Promise<void> =>
|
||||
fetchPrHeadTrackingRef(
|
||||
repo,
|
||||
sshGitProvider,
|
||||
remote,
|
||||
branch,
|
||||
localGitExecOptions ? { localGitExecOptions } : {}
|
||||
)
|
||||
fetchPrHeadTrackingRef(repo, null, remote, branch, { localGitExecOptions })
|
||||
const fetchPullRequestHeadRef = (remote: string, prNumber: number): Promise<string> =>
|
||||
fetchGitHubPullRequestHeadRef(
|
||||
repo,
|
||||
sshGitProvider,
|
||||
remote,
|
||||
prNumber,
|
||||
localGitExecOptions ? { localGitExecOptions } : {}
|
||||
)
|
||||
fetchGitHubPullRequestHeadRef(repo, null, remote, prNumber, { localGitExecOptions })
|
||||
return resolveGitHubPrStartPoint({
|
||||
repoPath: repo.path,
|
||||
prNumber: args.prNumber,
|
||||
@@ -82,7 +64,7 @@ export async function resolveRuntimeGitHubWorktreeBase(
|
||||
baseRefName: args.baseRefName,
|
||||
isCrossRepository: args.isCrossRepository,
|
||||
issueSourcePreference: repo.issueSourcePreference,
|
||||
connectionId: repo.connectionId ?? null,
|
||||
connectionId: null,
|
||||
localGitOptions: localGhOptions,
|
||||
gitExec,
|
||||
fetchRemoteTrackingRef,
|
||||
|
||||
@@ -5,14 +5,10 @@ import {
|
||||
} from '../../shared/review-head-tracking-ref'
|
||||
import { isTransientReviewHeadFetchError } from '../git/fetch-error-classification'
|
||||
import { fetchGitLabMergeRequestHeadRef } from '../gitlab/mr-head-tracking-ref'
|
||||
import type { requireSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
|
||||
type SshGitProvider = ReturnType<typeof requireSshGitProvider>
|
||||
type GitExec = (args: string[]) => Promise<{ stdout: string }>
|
||||
|
||||
export async function resolveRuntimeGitLabForkMrBase(args: {
|
||||
repo: Repo
|
||||
sshGitProvider: SshGitProvider | null
|
||||
remote: string
|
||||
mrIid: number
|
||||
localGitExecOptions?: { cwd: string; wslDistro?: string }
|
||||
@@ -53,7 +49,7 @@ export async function resolveRuntimeGitLabForkMrBase(args: {
|
||||
try {
|
||||
const localRef = await fetchGitLabMergeRequestHeadRef(
|
||||
args.repo,
|
||||
args.sshGitProvider,
|
||||
null,
|
||||
args.remote,
|
||||
args.mrIid,
|
||||
args.localGitExecOptions ? { localGitExecOptions: args.localGitExecOptions } : {}
|
||||
|
||||
@@ -2,20 +2,17 @@ import type { Repo } from '../../shared/repo-types'
|
||||
import { getDefaultRemote } from '../git/repo'
|
||||
import { getProjectRefForRemote } from '../gitlab/client'
|
||||
import { getGlabKnownHosts } from '../gitlab/gl-utils'
|
||||
import { requireSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import { pickPreferredGitRemote } from '../../shared/preferred-git-remote'
|
||||
|
||||
export async function resolveRuntimeGitLabIssueSourceRemote(
|
||||
repoPath: string,
|
||||
preference?: Repo['issueSourcePreference'],
|
||||
connectionId?: string | null,
|
||||
localGitOptions: { wslDistro?: string } = {}
|
||||
): Promise<string> {
|
||||
const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions)
|
||||
const knownHosts = await getGlabKnownHosts(null, localGitOptions)
|
||||
const localGitOptionArgs =
|
||||
Object.keys(localGitOptions).length > 0 ? ([localGitOptions] as const) : []
|
||||
const resolveProject = (remote: string) =>
|
||||
getProjectRefForRemote(repoPath, remote, knownHosts, connectionId, ...localGitOptionArgs)
|
||||
getProjectRefForRemote(repoPath, remote, knownHosts, null, ...localGitOptionArgs)
|
||||
if (preference === 'origin') {
|
||||
if (await resolveProject('origin')) {
|
||||
return 'origin'
|
||||
@@ -37,10 +34,5 @@ export async function resolveRuntimeGitLabIssueSourceRemote(
|
||||
if (await resolveProject('origin')) {
|
||||
return 'origin'
|
||||
}
|
||||
if (connectionId) {
|
||||
const provider = requireSshGitProvider(connectionId)
|
||||
const { stdout } = await provider.exec(['remote'], repoPath)
|
||||
return pickPreferredGitRemote(stdout.split('\n'))
|
||||
}
|
||||
return getDefaultRemote(repoPath, localGitOptions)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
getLocalProjectGitExecOptions,
|
||||
getLocalProjectWorktreeGitOptions
|
||||
} from '../project-runtime-git-options'
|
||||
import { requireSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import { SshHostNotServedError } from '../providers/ssh-host-not-served-error'
|
||||
import type { Store } from '../persistence'
|
||||
import { resolveRuntimeGitLabForkMrBase } from './runtime-gitlab-fork-mr-base'
|
||||
import { resolveRuntimeGitLabIssueSourceRemote } from './runtime-gitlab-issue-source-remote'
|
||||
@@ -42,16 +42,12 @@ export async function resolveRuntimeGitLabWorktreeBase(
|
||||
if (isFolderRepo(repo)) {
|
||||
return { error: 'Folder mode does not support creating worktrees.' }
|
||||
}
|
||||
const sshGitProvider = repo.connectionId ? requireSshGitProvider(repo.connectionId) : null
|
||||
const localGitExecOptions = sshGitProvider
|
||||
? undefined
|
||||
: getLocalProjectGitExecOptions(deps.store, repo)
|
||||
const localWorktreeGitOptions = sshGitProvider
|
||||
? {}
|
||||
: getLocalProjectWorktreeGitOptions(deps.store, repo)
|
||||
const gitExec = sshGitProvider
|
||||
? (gitArgs: string[]) => sshGitProvider.exec(gitArgs, repo.path)
|
||||
: (gitArgs: string[]) => gitExecFileAsync(gitArgs, localGitExecOptions ?? { cwd: repo.path })
|
||||
if (repo.connectionId) {
|
||||
throw new SshHostNotServedError(repo.connectionId)
|
||||
}
|
||||
const localGitExecOptions = getLocalProjectGitExecOptions(deps.store, repo)
|
||||
const localWorktreeGitOptions = getLocalProjectWorktreeGitOptions(deps.store, repo)
|
||||
const gitExec = (gitArgs: string[]) => gitExecFileAsync(gitArgs, localGitExecOptions)
|
||||
let sourceBranch = args.sourceBranch?.trim() ?? ''
|
||||
let targetBranch = args.targetBranch?.trim() ?? ''
|
||||
let isCrossRepository = args.isCrossRepository === true
|
||||
@@ -61,18 +57,17 @@ export async function resolveRuntimeGitLabWorktreeBase(
|
||||
discoveryRemote = await resolveRuntimeGitLabIssueSourceRemote(
|
||||
repo.path,
|
||||
repo.issueSourcePreference,
|
||||
repo.connectionId ?? null,
|
||||
localWorktreeGitOptions
|
||||
)
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : 'Could not resolve git remote.' }
|
||||
}
|
||||
const knownHosts = await getGlabKnownHosts(repo.connectionId ?? null, localWorktreeGitOptions)
|
||||
const knownHosts = await getGlabKnownHosts(null, localWorktreeGitOptions)
|
||||
const projectRef = await getProjectRefForRemote(
|
||||
repo.path,
|
||||
discoveryRemote,
|
||||
knownHosts,
|
||||
repo.connectionId ?? null,
|
||||
null,
|
||||
localWorktreeGitOptions
|
||||
)
|
||||
if (!projectRef) {
|
||||
@@ -83,7 +78,7 @@ export async function resolveRuntimeGitLabWorktreeBase(
|
||||
projectRef,
|
||||
args.mrIid,
|
||||
'mr',
|
||||
repo.connectionId ?? null,
|
||||
null,
|
||||
localWorktreeGitOptions
|
||||
)
|
||||
if (!item || item.type !== 'mr') {
|
||||
@@ -103,7 +98,6 @@ export async function resolveRuntimeGitLabWorktreeBase(
|
||||
remote = await resolveRuntimeGitLabIssueSourceRemote(
|
||||
repo.path,
|
||||
repo.issueSourcePreference,
|
||||
repo.connectionId ?? null,
|
||||
localWorktreeGitOptions
|
||||
)
|
||||
} catch (error) {
|
||||
@@ -111,9 +105,7 @@ export async function resolveRuntimeGitLabWorktreeBase(
|
||||
}
|
||||
const compareBaseRef = targetBranch ? `refs/remotes/${remote}/${targetBranch}` : undefined
|
||||
const fetchRemoteTrackingRef = async (branch: string, ref: string): Promise<void> => {
|
||||
await (sshGitProvider
|
||||
? sshGitProvider.fetchRemoteTrackingRef(repo.path, remote, branch, ref)
|
||||
: gitExec(['fetch', remote, `+refs/heads/${branch}:${ref}`]))
|
||||
await gitExec(['fetch', remote, `+refs/heads/${branch}:${ref}`])
|
||||
}
|
||||
const fetchCompareBaseRef = (): Promise<boolean> =>
|
||||
fetchCompareBaseRefWithLocalFallback({
|
||||
@@ -126,7 +118,6 @@ export async function resolveRuntimeGitLabWorktreeBase(
|
||||
if (isCrossRepository) {
|
||||
return resolveRuntimeGitLabForkMrBase({
|
||||
repo,
|
||||
sshGitProvider,
|
||||
remote,
|
||||
mrIid: args.mrIid,
|
||||
localGitExecOptions,
|
||||
|
||||
@@ -8,12 +8,9 @@ import { parseExecutionHostId, type ExecutionHostId } from '../../shared/executi
|
||||
import { isFolderRepo } from '../../shared/repo-kind'
|
||||
import { forceDeleteLocalBranch } from '../git/worktree'
|
||||
import { gitExecFileAsync } from '../git/runner'
|
||||
import {
|
||||
cleanupUnusedWorktreePushTargetRemote,
|
||||
cleanupUnusedWorktreePushTargetRemoteSsh
|
||||
} from '../ipc/worktree-remote'
|
||||
import { cleanupUnusedWorktreePushTargetRemote } from '../ipc/worktree-remote'
|
||||
import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options'
|
||||
import { requireSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import { SshHostNotServedError } from '../providers/ssh-host-not-served-error'
|
||||
import type { Store } from '../persistence'
|
||||
import { resolveWorktreeRemovalRepoOwner } from '../worktree-removal-repo-owner'
|
||||
import { parseExactWorktreeIdSelector } from './runtime-worktree-selection'
|
||||
@@ -124,34 +121,25 @@ export class RuntimePreservedBranchCleanup {
|
||||
}
|
||||
|
||||
if (repo.connectionId) {
|
||||
const provider = requireSshGitProvider(repo.connectionId)
|
||||
await provider.forceDeletePreservedBranch(repo.path, target.branchName, target.head)
|
||||
await cleanupUnusedWorktreePushTargetRemoteSsh(
|
||||
provider,
|
||||
repo.path,
|
||||
removalTarget.id,
|
||||
target.pushTarget,
|
||||
store
|
||||
)
|
||||
} else {
|
||||
const options = getLocalProjectWorktreeGitOptions(store, repo)
|
||||
await (Object.keys(options).length > 0
|
||||
? forceDeleteLocalBranch(
|
||||
repo.path,
|
||||
target.branchName,
|
||||
target.head,
|
||||
(argv, cwd) => gitExecFileAsync(argv, { cwd, ...options }),
|
||||
options
|
||||
)
|
||||
: forceDeleteLocalBranch(repo.path, target.branchName, target.head))
|
||||
await cleanupUnusedWorktreePushTargetRemote(
|
||||
repo.path,
|
||||
removalTarget.id,
|
||||
target.pushTarget,
|
||||
store,
|
||||
options
|
||||
)
|
||||
throw new SshHostNotServedError(repo.connectionId)
|
||||
}
|
||||
const options = getLocalProjectWorktreeGitOptions(store, repo)
|
||||
await (Object.keys(options).length > 0
|
||||
? forceDeleteLocalBranch(
|
||||
repo.path,
|
||||
target.branchName,
|
||||
target.head,
|
||||
(argv, cwd) => gitExecFileAsync(argv, { cwd, ...options }),
|
||||
options
|
||||
)
|
||||
: forceDeleteLocalBranch(repo.path, target.branchName, target.head))
|
||||
await cleanupUnusedWorktreePushTargetRemote(
|
||||
repo.path,
|
||||
removalTarget.id,
|
||||
target.pushTarget,
|
||||
store,
|
||||
options
|
||||
)
|
||||
this.delete(removalTarget.id, target.hostId)
|
||||
return { deleted: true }
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { resolveSshQuickOpenDiscoveryOptions } from '../providers/ssh-quick-open-discovery-options'
|
||||
import type { IFilesystemProvider } from '../providers/types'
|
||||
import { QUICK_OPEN_LEGACY_REMOTE_RESULT_LIMIT } from './runtime-file-commands-mobile-file-list-limit'
|
||||
import { QuickOpenPathRanker } from '../../shared/quick-open-path-search'
|
||||
|
||||
export async function searchRemoteQuickOpenFilePaths(
|
||||
rootPath: string,
|
||||
// `null` is "remote and currently unreachable": quick open reports no matches rather than
|
||||
// failing the keystroke, but it never falls back to searching this machine.
|
||||
provider: IFilesystemProvider | null,
|
||||
query: string,
|
||||
limit: number,
|
||||
excludePaths?: string[],
|
||||
signal?: AbortSignal,
|
||||
options: {
|
||||
includeIgnored?: boolean
|
||||
followSymlinks?: boolean
|
||||
allowLegacyIncludeIgnored?: boolean
|
||||
} = {}
|
||||
): Promise<{ paths: string[]; totalCount: number; truncated: boolean }> {
|
||||
if (!provider) {
|
||||
return { paths: [], totalCount: 0, truncated: false }
|
||||
}
|
||||
const discovery = await resolveSshQuickOpenDiscoveryOptions(provider, options, signal)
|
||||
if (!(await provider.supportsQuickOpenSearch?.({ signal, minimumVersion: 1 }))) {
|
||||
// Old relays ignore searchQuery. Keep the compatibility request below the
|
||||
// 4 MiB frame ceiling even when legacy paths are near the 64 KiB path cap.
|
||||
const legacyFiles = await provider.listFiles(rootPath, {
|
||||
excludePaths,
|
||||
maxResults: QUICK_OPEN_LEGACY_REMOTE_RESULT_LIMIT,
|
||||
signal
|
||||
})
|
||||
const ranker = new QuickOpenPathRanker(query, limit)
|
||||
for (const file of legacyFiles) {
|
||||
ranker.consider(file)
|
||||
}
|
||||
const result = ranker.result()
|
||||
return {
|
||||
...result,
|
||||
truncated:
|
||||
legacyFiles.length >= QUICK_OPEN_LEGACY_REMOTE_RESULT_LIMIT || result.totalCount > limit
|
||||
}
|
||||
}
|
||||
const files = await provider.listFiles(rootPath, {
|
||||
excludePaths,
|
||||
maxResults: limit + 1,
|
||||
...discovery,
|
||||
searchQuery: query,
|
||||
signal
|
||||
})
|
||||
return {
|
||||
paths: files.slice(0, limit),
|
||||
totalCount: files.length,
|
||||
truncated: files.length > limit
|
||||
}
|
||||
}
|
||||
@@ -2,19 +2,12 @@ import { getStoredRepoSshConnectionId } from '../repo-execution-host'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import {
|
||||
getEffectiveHooks,
|
||||
hasHooksFile,
|
||||
hasUnrecognizedOrcaYamlKeys,
|
||||
loadHooks,
|
||||
parseOrcaYaml
|
||||
} from '../hooks'
|
||||
import { getEffectiveHooks, hasHooksFile, hasUnrecognizedOrcaYamlKeys, loadHooks } from '../hooks'
|
||||
import {
|
||||
getDefaultTabCommandTrustContent,
|
||||
getEffectiveSetupRunPolicy
|
||||
} from '../effective-hook-config'
|
||||
import { isENOENT } from '../ipc/filesystem-auth'
|
||||
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
import { isFolderRepo } from '../../shared/repo-kind'
|
||||
import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-imports'
|
||||
import { joinWorktreeRelativePath } from './runtime-relative-paths'
|
||||
@@ -29,33 +22,13 @@ export class RuntimeRepositoryHooksCommands {
|
||||
async getRepoHooks(repoSelector: string) {
|
||||
const repo = await this.deps.resolveRepo(repoSelector)
|
||||
const connectionId = getStoredRepoSshConnectionId(repo)
|
||||
// Why: an `ssh:` repo is not served here; it reports no hooks file.
|
||||
if (connectionId) {
|
||||
const fsProvider = getSshFilesystemProvider(connectionId)
|
||||
if (!fsProvider) {
|
||||
return {
|
||||
hasHooksFile: false,
|
||||
hooks: null,
|
||||
setupRunPolicy: getEffectiveSetupRunPolicy(repo),
|
||||
source: null
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml'))
|
||||
const hooks = result.isBinary ? null : parseOrcaYaml(result.content)
|
||||
return {
|
||||
hasHooksFile: Boolean(hooks),
|
||||
hooks,
|
||||
setupRunPolicy: getEffectiveSetupRunPolicy(repo),
|
||||
source: hooks ? ('orca.yaml' as const) : null,
|
||||
setupTrust: setupTrust(repo, getDefaultTabCommandTrustContent(hooks))
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
hasHooksFile: false,
|
||||
hooks: null,
|
||||
setupRunPolicy: getEffectiveSetupRunPolicy(repo),
|
||||
source: null
|
||||
}
|
||||
return {
|
||||
hasHooksFile: false,
|
||||
hooks: null,
|
||||
setupRunPolicy: getEffectiveSetupRunPolicy(repo),
|
||||
source: null
|
||||
}
|
||||
}
|
||||
const hasFile = hasHooksFile(repo.path)
|
||||
@@ -77,29 +50,7 @@ export class RuntimeRepositoryHooksCommands {
|
||||
}
|
||||
const connectionId = getStoredRepoSshConnectionId(repo)
|
||||
if (connectionId) {
|
||||
const fsProvider = getSshFilesystemProvider(connectionId)
|
||||
if (!fsProvider) {
|
||||
return { status: 'error' as const, hasHooks: false, hooks: null, mayNeedUpdate: false }
|
||||
}
|
||||
try {
|
||||
const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml'))
|
||||
if (result.isBinary) {
|
||||
return { status: 'ok' as const, hasHooks: false, hooks: null, mayNeedUpdate: false }
|
||||
}
|
||||
return {
|
||||
status: 'ok' as const,
|
||||
hasHooks: true,
|
||||
hooks: parseOrcaYaml(result.content),
|
||||
mayNeedUpdate: false
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
status: isENOENT(error) ? ('ok' as const) : ('error' as const),
|
||||
hasHooks: false,
|
||||
hooks: null,
|
||||
mayNeedUpdate: false
|
||||
}
|
||||
}
|
||||
return { status: 'error' as const, hasHooks: false, hooks: null, mayNeedUpdate: false }
|
||||
}
|
||||
const has = hasHooksFile(repo.path)
|
||||
const hooks = has ? loadHooks(repo.path) : null
|
||||
@@ -118,18 +69,8 @@ export class RuntimeRepositoryHooksCommands {
|
||||
}
|
||||
return inspectSetupScriptImportCandidates(async (relativePath) => {
|
||||
const filePath = joinWorktreeRelativePath(repo.path, relativePath)
|
||||
const connectionId = getStoredRepoSshConnectionId(repo)
|
||||
if (connectionId) {
|
||||
const fsProvider = getSshFilesystemProvider(connectionId)
|
||||
if (!fsProvider) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const result = await fsProvider.readFile(filePath)
|
||||
return result.isBinary ? null : result.content
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (getStoredRepoSshConnectionId(repo)) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return await readFile(filePath, 'utf-8')
|
||||
|
||||
@@ -5,28 +5,14 @@ import * as issueCommandFile from '../issue-command-file'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { RuntimeRepositoryIssueCommand } from './runtime-repository-issue-command'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
localCheck: vi.fn(),
|
||||
remoteCheck: vi.fn(),
|
||||
requireGit: vi.fn(),
|
||||
fs: {
|
||||
createDir: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
deletePath: vi.fn()
|
||||
}
|
||||
}))
|
||||
const mocks = vi.hoisted(() => ({ localCheck: vi.fn() }))
|
||||
|
||||
vi.mock('../git/check-ignored-paths', () => ({ checkIgnoredPaths: mocks.localCheck }))
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({ requireSshGitProvider: mocks.requireGit }))
|
||||
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
|
||||
getSshFilesystemProvider: () => mocks.fs
|
||||
}))
|
||||
|
||||
describe('remote issue command ignore rules', () => {
|
||||
describe('SSH issue command', () => {
|
||||
const repo = {
|
||||
id: 'repo-ssh',
|
||||
path: '/remote/repo with spaces',
|
||||
path: '/remote/repo',
|
||||
displayName: 'remote',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
@@ -39,66 +25,27 @@ describe('remote issue command ignore rules', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
mocks.requireGit.mockReturnValue({ checkIgnoredPaths: mocks.remoteCheck })
|
||||
mocks.remoteCheck.mockResolvedValue([])
|
||||
mocks.fs.createDir.mockResolvedValue(undefined)
|
||||
mocks.fs.writeFile.mockResolvedValue(undefined)
|
||||
mocks.fs.deletePath.mockResolvedValue(undefined)
|
||||
mocks.fs.readFile.mockResolvedValue({ content: 'node_modules/\n', isBinary: false })
|
||||
})
|
||||
|
||||
it('uses the remote ignore rules and leaves .gitignore untouched', async () => {
|
||||
mocks.remoteCheck.mockResolvedValue(['.orca/issue-command'])
|
||||
it('reads as no command and never reads this machine', async () => {
|
||||
const read = vi.spyOn(issueCommandFile, 'readIssueCommand')
|
||||
await expect(commands.read(repo.id)).resolves.toEqual({
|
||||
localContent: null,
|
||||
sharedContent: null,
|
||||
effectiveContent: null,
|
||||
localFilePath: '/remote/repo/.orca/issue-command',
|
||||
source: 'none'
|
||||
})
|
||||
expect(read).not.toHaveBeenCalled()
|
||||
read.mockRestore()
|
||||
})
|
||||
|
||||
await commands.write(repo.id, 'local command')
|
||||
|
||||
expect(mocks.requireGit).toHaveBeenCalledWith('conn-1')
|
||||
expect(mocks.remoteCheck).toHaveBeenCalledWith(repo.path, ['.orca/issue-command'])
|
||||
it('drops a write instead of writing this machine', async () => {
|
||||
const write = vi.spyOn(issueCommandFile, 'writeIssueCommand')
|
||||
await expect(commands.write(repo.id, 'command')).resolves.toEqual({ ok: true })
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
expect(mocks.localCheck).not.toHaveBeenCalled()
|
||||
expect(mocks.fs.readFile).not.toHaveBeenCalled()
|
||||
expect(mocks.fs.writeFile).toHaveBeenCalledExactlyOnceWith(
|
||||
`${repo.path}/.orca/issue-command`,
|
||||
'local command\n'
|
||||
)
|
||||
})
|
||||
|
||||
it('adds the rule if the remote host does not ignore .orca', async () => {
|
||||
await commands.write(repo.id, 'local command')
|
||||
|
||||
expect(mocks.fs.writeFile).toHaveBeenCalledWith(
|
||||
`${repo.path}/.gitignore`,
|
||||
'node_modules/\n.orca\n'
|
||||
)
|
||||
expect(mocks.localCheck).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['unavailable', 'failed'])(
|
||||
'keeps the remote fallback when Git is %s',
|
||||
async (failure) => {
|
||||
if (failure === 'unavailable') {
|
||||
mocks.requireGit.mockImplementation(() => {
|
||||
throw new Error('remote Git unavailable')
|
||||
})
|
||||
} else {
|
||||
mocks.remoteCheck.mockRejectedValue(new Error('remote Git failed'))
|
||||
}
|
||||
|
||||
await expect(commands.write(repo.id, 'local command')).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(mocks.localCheck).not.toHaveBeenCalled()
|
||||
expect(mocks.fs.writeFile).toHaveBeenCalledWith(
|
||||
`${repo.path}/.gitignore`,
|
||||
'node_modules/\n.orca\n'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('does not inspect ignore rules when clearing an override', async () => {
|
||||
await commands.write(repo.id, ' ')
|
||||
|
||||
expect(mocks.requireGit).not.toHaveBeenCalled()
|
||||
expect(mocks.fs.writeFile).not.toHaveBeenCalled()
|
||||
expect(mocks.fs.deletePath).toHaveBeenCalledWith(`${repo.path}/.orca/issue-command`, false)
|
||||
write.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { getStoredRepoSshConnectionId } from '../repo-execution-host'
|
||||
import type { GitRuntimeOptions } from '../git/git-runtime-options'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import { parseOrcaYaml } from '../hooks'
|
||||
import {
|
||||
isIssueCommandIgnoredByGit,
|
||||
readIssueCommand,
|
||||
writeIssueCommand
|
||||
} from '../issue-command-file'
|
||||
import { isENOENT } from '../ipc/filesystem-auth'
|
||||
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
import type { IFilesystemProvider } from '../providers/types'
|
||||
import { readIssueCommand, writeIssueCommand } from '../issue-command-file'
|
||||
import { isFolderRepo } from '../../shared/repo-kind'
|
||||
import { joinWorktreeRelativePath } from './runtime-relative-paths'
|
||||
|
||||
@@ -36,29 +28,13 @@ export class RuntimeRepositoryIssueCommand {
|
||||
if (!connectionId) {
|
||||
return readIssueCommand(repo.path)
|
||||
}
|
||||
const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command')
|
||||
const fsProvider = getSshFilesystemProvider(connectionId)
|
||||
if (!fsProvider) {
|
||||
return {
|
||||
localContent: null,
|
||||
sharedContent: null,
|
||||
effectiveContent: null,
|
||||
localFilePath: issueCommandPath,
|
||||
source: 'none' as const
|
||||
}
|
||||
}
|
||||
const localContent = await readRemoteOverride(fsProvider, issueCommandPath)
|
||||
const sharedContent = await readRemoteShared(fsProvider, repo.path)
|
||||
// Why: an `ssh:` repo is not served here; it reads as having no command.
|
||||
return {
|
||||
localContent,
|
||||
sharedContent,
|
||||
effectiveContent: localContent ?? sharedContent,
|
||||
localFilePath: issueCommandPath,
|
||||
source: localContent
|
||||
? ('local' as const)
|
||||
: sharedContent
|
||||
? ('shared' as const)
|
||||
: ('none' as const)
|
||||
localContent: null,
|
||||
sharedContent: null,
|
||||
effectiveContent: null,
|
||||
localFilePath: joinWorktreeRelativePath(repo.path, '.orca/issue-command'),
|
||||
source: 'none' as const
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,85 +44,10 @@ export class RuntimeRepositoryIssueCommand {
|
||||
if (isFolderRepo(repo)) {
|
||||
return { ok: true }
|
||||
}
|
||||
const connectionId = getStoredRepoSshConnectionId(repo)
|
||||
if (!connectionId) {
|
||||
// Why: an `ssh:` repo is not served here; the write is dropped, as when it was unreachable.
|
||||
if (!getStoredRepoSshConnectionId(repo)) {
|
||||
await writeIssueCommand(repo.path, content, () => this.deps.getLocalGitArgs(repo)[0] ?? {})
|
||||
return { ok: true }
|
||||
}
|
||||
const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command')
|
||||
const fsProvider = getSshFilesystemProvider(connectionId)
|
||||
if (!fsProvider) {
|
||||
return { ok: true }
|
||||
}
|
||||
const trimmed = content.trim()
|
||||
if (!trimmed) {
|
||||
await fsProvider.deletePath(issueCommandPath, false).catch((error: unknown) => {
|
||||
if (!isENOENT(error)) {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
return { ok: true }
|
||||
}
|
||||
await fsProvider.createDir(joinWorktreeRelativePath(repo.path, '.orca'))
|
||||
if (!(await isIssueCommandIgnoredByGit(repo.path, connectionId))) {
|
||||
await ensureRemoteOrcaDirIgnored(fsProvider, repo.path)
|
||||
}
|
||||
await fsProvider.writeFile(issueCommandPath, `${trimmed}\n`)
|
||||
return { ok: true }
|
||||
}
|
||||
}
|
||||
|
||||
async function readRemoteOverride(
|
||||
fsProvider: IFilesystemProvider,
|
||||
issueCommandPath: string
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const result = await fsProvider.readFile(issueCommandPath)
|
||||
return result.isBinary ? null : result.content.trim() || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function readRemoteShared(
|
||||
fsProvider: IFilesystemProvider,
|
||||
repoPath: string
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const result = await fsProvider.readFile(joinWorktreeRelativePath(repoPath, 'orca.yaml'))
|
||||
return result.isBinary ? null : parseOrcaYaml(result.content)?.issueCommand?.trim() || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureRemoteOrcaDirIgnored(
|
||||
fsProvider: IFilesystemProvider,
|
||||
repoPath: string
|
||||
): Promise<void> {
|
||||
const gitignorePath = joinWorktreeRelativePath(repoPath, '.gitignore')
|
||||
let result: Awaited<ReturnType<IFilesystemProvider['readFile']>>
|
||||
try {
|
||||
result = await fsProvider.readFile(gitignorePath)
|
||||
} catch (error) {
|
||||
if (!isENOENT(error)) {
|
||||
console.warn('[runtime] Could not inspect remote .gitignore for .orca', error)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await fsProvider.writeFile(gitignorePath, '.orca\n')
|
||||
} catch (writeError) {
|
||||
console.warn('[runtime] Could not update remote .gitignore to exclude .orca', writeError)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (result.isBinary || /^\.orca\/?$/m.test(result.content)) {
|
||||
return
|
||||
}
|
||||
const separator = result.content.endsWith('\n') ? '' : '\n'
|
||||
try {
|
||||
await fsProvider.writeFile(gitignorePath, `${result.content}${separator}.orca\n`)
|
||||
} catch (writeError) {
|
||||
console.warn('[runtime] Could not update remote .gitignore to exclude .orca', writeError)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BaseRefSearchResult, Repo } from '../../shared/repo-types'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import type { RuntimeRepoSearchRefs } from '../../shared/runtime-types'
|
||||
import { isFolderRepo } from '../../shared/repo-kind'
|
||||
import {
|
||||
@@ -6,20 +6,7 @@ import {
|
||||
getRepoSearchRefsProbeLimit,
|
||||
isRepoSearchRefsRequestLimit
|
||||
} from '../../shared/repo-search-limits'
|
||||
import {
|
||||
buildSearchBaseRefsArgv,
|
||||
getBaseRefDefault,
|
||||
getRemoteCount,
|
||||
isForEachRefExcludeUnsupportedError,
|
||||
mergeBaseRefSearchResultGroups,
|
||||
normalizeRefSearchQuery,
|
||||
parseAndFilterSearchRefDetails,
|
||||
parseRemoteCount,
|
||||
resolveDefaultBaseRefViaExec,
|
||||
searchBaseRefDetails
|
||||
} from '../git/repo'
|
||||
import { getSshGitCapabilityCache } from '../git/git-capability-state'
|
||||
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import { getBaseRefDefault, getRemoteCount, searchBaseRefDetails } from '../git/repo'
|
||||
|
||||
type RuntimeRepositoryRefQueryDependencies = {
|
||||
resolveRepo: (selector: string) => Promise<Repo>
|
||||
@@ -43,8 +30,9 @@ export class RuntimeRepositoryRefQueries {
|
||||
if (isFolderRepo(repo)) {
|
||||
return { refs: [], truncated: false }
|
||||
}
|
||||
// Why: an `ssh:` repo is not served here; it reports no refs and never searches this machine.
|
||||
const refDetails = repo.connectionId
|
||||
? await this.searchRemote(repo, query, probeLimit, includeQualifiedRefs)
|
||||
? []
|
||||
: await searchBaseRefDetails(repo.path, query, probeLimit, includeQualifiedRefs)
|
||||
return {
|
||||
refs: refDetails.slice(0, effectiveLimit).map((entry) => entry.refName),
|
||||
@@ -64,7 +52,7 @@ export class RuntimeRepositoryRefQueries {
|
||||
return { defaultBaseRef: null, remoteCount: 0 }
|
||||
}
|
||||
if (repo.connectionId) {
|
||||
return this.getRemoteDefault(repo)
|
||||
return { defaultBaseRef: null, remoteCount: 0 }
|
||||
}
|
||||
const [defaultBaseRef, remoteCount] = await Promise.all([
|
||||
getBaseRefDefault(repo.path),
|
||||
@@ -72,103 +60,4 @@ export class RuntimeRepositoryRefQueries {
|
||||
])
|
||||
return { defaultBaseRef, remoteCount }
|
||||
}
|
||||
|
||||
private async getRemoteDefault(
|
||||
repo: Repo
|
||||
): Promise<{ defaultBaseRef: string | null; remoteCount: number }> {
|
||||
const provider = repo.connectionId ? getSshGitProvider(repo.connectionId) : null
|
||||
if (!provider) {
|
||||
return { defaultBaseRef: null, remoteCount: 0 }
|
||||
}
|
||||
const [defaultBaseRef, remoteCount] = await Promise.all([
|
||||
resolveDefaultBaseRefViaExec(async (argv) => {
|
||||
try {
|
||||
return await provider.exec(argv, repo.path)
|
||||
} catch (error) {
|
||||
if (argv[0] === 'symbolic-ref') {
|
||||
console.warn('[runtime:repo.baseRefDefault] SSH symbolic-ref failed', {
|
||||
path: repo.path,
|
||||
err: error
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}),
|
||||
provider
|
||||
.exec(['remote'], repo.path)
|
||||
.then((result) => parseRemoteCount(result.stdout))
|
||||
.catch((error) => {
|
||||
console.warn('[runtime:repo.baseRefDefault] SSH git remote count failed', {
|
||||
path: repo.path,
|
||||
err: error
|
||||
})
|
||||
return 0
|
||||
})
|
||||
])
|
||||
return { defaultBaseRef, remoteCount }
|
||||
}
|
||||
|
||||
private async searchRemote(
|
||||
repo: Repo,
|
||||
query: string,
|
||||
limit: number,
|
||||
includeQualifiedRefs: boolean
|
||||
): Promise<BaseRefSearchResult[]> {
|
||||
const provider = repo.connectionId ? getSshGitProvider(repo.connectionId) : null
|
||||
if (!provider) {
|
||||
return []
|
||||
}
|
||||
const normalizedQuery = normalizeRefSearchQuery(query)
|
||||
try {
|
||||
const remotesResult = await provider.exec(['remote'], repo.path).catch(() => ({ stdout: '' }))
|
||||
const remotes = remotesResult.stdout
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
const capabilities = getSshGitCapabilityCache(provider)
|
||||
const runSearch = async (patternGroup?: 'segmented' | 'branchRoot'): Promise<string> => {
|
||||
return capabilities.runWithFallback(
|
||||
'for-each-ref-exclude',
|
||||
async () =>
|
||||
(
|
||||
await provider.exec(
|
||||
buildSearchBaseRefsArgv(normalizedQuery, limit, {
|
||||
remoteNames: remotes,
|
||||
patternGroup
|
||||
}),
|
||||
repo.path
|
||||
)
|
||||
).stdout,
|
||||
async () =>
|
||||
(
|
||||
await provider.exec(
|
||||
buildSearchBaseRefsArgv(normalizedQuery, limit, {
|
||||
excludeRemoteHead: false,
|
||||
remoteNames: remotes,
|
||||
patternGroup
|
||||
}),
|
||||
repo.path
|
||||
)
|
||||
).stdout,
|
||||
isForEachRefExcludeUnsupportedError
|
||||
)
|
||||
}
|
||||
if (normalizedQuery.split('/').filter((token) => token.length > 0).length > 1) {
|
||||
const results = await Promise.all([runSearch('segmented'), runSearch('branchRoot')])
|
||||
return mergeBaseRefSearchResultGroups(
|
||||
results.map((stdout) =>
|
||||
parseAndFilterSearchRefDetails(stdout, limit, remotes, includeQualifiedRefs)
|
||||
),
|
||||
limit
|
||||
)
|
||||
}
|
||||
return parseAndFilterSearchRefDetails(await runSearch(), limit, remotes, includeQualifiedRefs)
|
||||
} catch (error) {
|
||||
console.warn('[runtime:repo.searchRefs] SSH for-each-ref failed', {
|
||||
path: repo.path,
|
||||
err: error
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,23 +3,12 @@ import type { GitPushTarget } from '../../shared/worktree/types'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import type { Store } from '../persistence'
|
||||
|
||||
const {
|
||||
materializeLocalMock,
|
||||
materializeSshMock,
|
||||
getSshGitProviderMock,
|
||||
getLocalProjectWorktreeGitOptionsMock
|
||||
} = vi.hoisted(() => ({
|
||||
const { materializeLocalMock, getLocalProjectWorktreeGitOptionsMock } = vi.hoisted(() => ({
|
||||
materializeLocalMock: vi.fn(),
|
||||
materializeSshMock: vi.fn(),
|
||||
getSshGitProviderMock: vi.fn(),
|
||||
getLocalProjectWorktreeGitOptionsMock: vi.fn()
|
||||
}))
|
||||
vi.mock('../ipc/worktree-remote', () => ({
|
||||
materializeWorktreePushTargetRemote: materializeLocalMock,
|
||||
materializeWorktreePushTargetRemoteSsh: materializeSshMock
|
||||
}))
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProvider: getSshGitProviderMock
|
||||
materializeWorktreePushTargetRemote: materializeLocalMock
|
||||
}))
|
||||
vi.mock('../project-runtime-git-options', () => ({
|
||||
getLocalProjectWorktreeGitOptions: getLocalProjectWorktreeGitOptionsMock
|
||||
@@ -51,8 +40,6 @@ describe('triggerTerminalSpawnPushTargetMaterialization', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
materializeLocalMock.mockReset().mockResolvedValue(undefined)
|
||||
materializeSshMock.mockReset().mockResolvedValue(undefined)
|
||||
getSshGitProviderMock.mockReset()
|
||||
getLocalProjectWorktreeGitOptionsMock.mockReset().mockReturnValue({})
|
||||
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
@@ -64,7 +51,6 @@ describe('triggerTerminalSpawnPushTargetMaterialization', () => {
|
||||
it('is a no-op when there is no push target', () => {
|
||||
triggerTerminalSpawnPushTargetMaterialization(WORKTREE_PATH, undefined, LOCAL_REPO, STORE)
|
||||
expect(materializeLocalMock).not.toHaveBeenCalled()
|
||||
expect(materializeSshMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('is a no-op for a same-repo push target with no remoteUrl', () => {
|
||||
@@ -108,41 +94,14 @@ describe('triggerTerminalSpawnPushTargetMaterialization', () => {
|
||||
{ wslDistro: 'Ubuntu' },
|
||||
'worktree-1'
|
||||
)
|
||||
expect(materializeSshMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('materializes over SSH when the repo has a connectionId and a provider is registered', () => {
|
||||
const provider = { exec: vi.fn() }
|
||||
getSshGitProviderMock.mockReturnValue(provider)
|
||||
const target = forkTarget()
|
||||
triggerTerminalSpawnPushTargetMaterialization(
|
||||
WORKTREE_PATH,
|
||||
target,
|
||||
SSH_REPO,
|
||||
STORE,
|
||||
REPO_ID,
|
||||
'worktree-1'
|
||||
)
|
||||
expect(getSshGitProviderMock).toHaveBeenCalledWith('conn-1')
|
||||
expect(materializeSshMock).toHaveBeenCalledWith(
|
||||
provider,
|
||||
WORKTREE_PATH,
|
||||
target,
|
||||
STORE,
|
||||
undefined,
|
||||
'worktree-1'
|
||||
)
|
||||
it('is a no-op for an SSH repo, never materializing on this machine', () => {
|
||||
triggerTerminalSpawnPushTargetMaterialization(WORKTREE_PATH, forkTarget(), SSH_REPO, STORE)
|
||||
expect(materializeLocalMock).not.toHaveBeenCalled()
|
||||
expect(getLocalProjectWorktreeGitOptionsMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('is a no-op when the SSH connection has dropped (no registered provider)', () => {
|
||||
getSshGitProviderMock.mockReturnValue(undefined)
|
||||
triggerTerminalSpawnPushTargetMaterialization(WORKTREE_PATH, forkTarget(), SSH_REPO, STORE)
|
||||
expect(materializeSshMock).not.toHaveBeenCalled()
|
||||
expect(materializeLocalMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to default git options when WSL project runtime resolution throws', () => {
|
||||
getLocalProjectWorktreeGitOptionsMock.mockImplementation(() => {
|
||||
throw new Error('repair-required')
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import {
|
||||
materializeWorktreePushTargetRemote,
|
||||
materializeWorktreePushTargetRemoteSsh
|
||||
} from '../ipc/worktree-remote'
|
||||
import { materializeWorktreePushTargetRemote } from '../ipc/worktree-remote'
|
||||
import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options'
|
||||
import type { GitPushTarget } from '../../shared/worktree/types'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
@@ -21,21 +17,18 @@ export function triggerTerminalSpawnPushTargetMaterialization(
|
||||
repoId?: string,
|
||||
worktreeId?: string
|
||||
): void {
|
||||
if (!pushTarget?.remoteUrl || pushTarget.remoteCreated) {
|
||||
// Why: an `ssh:` repo is not served here; its next sync action reports that itself.
|
||||
if (!pushTarget?.remoteUrl || pushTarget.remoteCreated || repo?.connectionId) {
|
||||
return
|
||||
}
|
||||
const connectionId = repo?.connectionId ?? undefined
|
||||
const materialized = connectionId
|
||||
? materializeOverSsh(connectionId, worktreePath, pushTarget, store, worktreeId)
|
||||
: materializeWorktreePushTargetRemote(
|
||||
worktreePath,
|
||||
pushTarget,
|
||||
store,
|
||||
repoId,
|
||||
localGitOptionsForTerminalSpawn(store, repo),
|
||||
worktreeId
|
||||
)
|
||||
materialized.catch((error: unknown) => {
|
||||
materializeWorktreePushTargetRemote(
|
||||
worktreePath,
|
||||
pushTarget,
|
||||
store,
|
||||
repoId,
|
||||
localGitOptionsForTerminalSpawn(store, repo),
|
||||
worktreeId
|
||||
).catch((error: unknown) => {
|
||||
console.warn(
|
||||
`[terminal-spawn] failed to materialize push target remote for ${worktreePath}:`,
|
||||
error
|
||||
@@ -43,28 +36,6 @@ export function triggerTerminalSpawnPushTargetMaterialization(
|
||||
})
|
||||
}
|
||||
|
||||
function materializeOverSsh(
|
||||
connectionId: string,
|
||||
worktreePath: string,
|
||||
pushTarget: GitPushTarget,
|
||||
store: Store | undefined,
|
||||
worktreeId: string | undefined
|
||||
): Promise<GitPushTarget> {
|
||||
const provider = getSshGitProvider(connectionId)
|
||||
if (!provider) {
|
||||
// Why: connection dropped -- the next Orca-driven sync action will retry via its own dispatch.
|
||||
return Promise.resolve(pushTarget)
|
||||
}
|
||||
return materializeWorktreePushTargetRemoteSsh(
|
||||
provider,
|
||||
worktreePath,
|
||||
pushTarget,
|
||||
store,
|
||||
undefined,
|
||||
worktreeId
|
||||
)
|
||||
}
|
||||
|
||||
function localGitOptionsForTerminalSpawn(
|
||||
store: Store | undefined,
|
||||
repo: Repo | null | undefined
|
||||
|
||||
Reference in New Issue
Block a user