Files
orca/src/main/ssh/relay-socket-path-limit-shell.integration.test.ts
T
Neil 7c6c8ef85e fix(ssh): stop a late SFTP stream error crashing main, and keep the relay socket inside sun_path (#17862)
* fix(ssh): stop SFTP stream errors crashing main and bound the relay socket path

inside the protocol parser. Every transfer removed its listener on settle, so a
STATUS reply that arrived late - the normal case behind a jump host that chroots
its SFTP subsystem - threw synchronously out of Socket.emit('data') and killed
the main process. Keep one durable listener per stream, and report a sandboxed
SFTP namespace with an actionable message instead of a bare 'file does not
exist'.

104 macOS) and bind failed with a bare 'listen EINVAL'. Fall back to a per-uid
base whose length does not depend on $HOME, keeping the hashed socket name
intact.

* fix(ssh): validate the short socket dir before mutating it

* fix(ssh): keep the SFTP session guarded, scope the relocated socket, narrow the chroot verdict

Three review findings.

The CLI-launcher install ran writeStringViaSftp in a loop over a bare conn.sftp().
That helper removes its own session 'error' listener at each settle, so between
files and after the last one the emitter carried none -- and ssh2 raises a late
STATUS reply synchronously out of Protocol.parse, which is the uncaught exception
that kills main (#15479). The inline loop it replaced leaked one listener per file
and covered this by accident. Extract writeStringsViaSftp, which owns the session
latch, and share that latch with runSftpFallbackTransfer.

SSH_FX_PERMISSION_DENIED is a mode/ownership refusal on a path the subsystem can
see, not evidence of a chroot; sftp-namespace-resolution already treats only
NO_SUCH_FILE as conclusive. Narrow the predicate to code 2 so a read-only home
stops being reported as a bastion misconfiguration.

The relocated socket had no version dimension. relaySocketNameForInstanceId hashes
the target, not the build, and under $HOME the enclosing relay-<fullVersion> dir
supplied the rest -- so the short form made the path stable across updates. The
next build would bind the path the previous relay still holds, the handshake would
mismatch, and a relay holding live work would raise RelayEndpointHeldError with no
way through. Add a hashed version segment under the short base, mirroring the
relay-*/<sock> shape so one pattern serves both, and teach the superseded sweep and
force-stop about that base. The relocated tree now also gets reclaimed: nothing
else walks it.

* fix(i18n): restore the activity-options key the rebase dropped

* fix(i18n): union en.json with main so the rebase cannot drop keys
2026-09-02 15:14:17 -07:00

104 lines
3.9 KiB
TypeScript

import { execFile } from 'node:child_process'
import { chmod, mkdtemp, mkdir, rm, symlink, stat } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
resolveShortRelaySocketDirCommand,
shortRelayVersionSegment
} from './relay-socket-path-limit'
const run = promisify(execFile)
// The generated script runs on the remote host's /bin/sh, so assert against a real shell rather
// than a string match: the hazard here is an ordering bug that only a filesystem can observe.
describe('short relay socket dir guard, against a real shell', () => {
let root: string
const VERSION_SEGMENT = shortRelayVersionSegment('relay-0.1.0+test')
// Retarget the generated script at a sandbox instead of the real /tmp path.
function scriptFor(dir: string): string {
return resolveShortRelaySocketDirCommand(VERSION_SEGMENT).replace(
/^dir=.*$/m,
`dir=${JSON.stringify(dir)}`
)
}
async function attempt(dir: string): Promise<{ ok: boolean; stdout: string }> {
try {
const { stdout } = await run('/bin/sh', ['-c', scriptFor(dir)])
return { ok: true, stdout }
} catch {
return { ok: false, stdout: '' }
}
}
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-relay-dir-guard-'))
})
afterEach(async () => {
await rm(root, { recursive: true, force: true })
})
it('creates the directory and its version segment when neither exists', async () => {
const dir = join(root, 'fresh')
const attempted = await attempt(dir)
expect(attempted.ok).toBe(true)
expect((await stat(dir)).mode & 0o777).toBe(0o700)
// The segment is what keeps a later build off the path this one binds.
expect((await stat(join(dir, VERSION_SEGMENT))).mode & 0o777).toBe(0o700)
expect(attempted.stdout.trim().endsWith(`${dir}/${VERSION_SEGMENT}`)).toBe(true)
})
it('refuses a planted symlink in the version segment without following it', async () => {
const dir = join(root, 'mine')
const victim = join(root, 'segment-victim')
await mkdir(dir)
await chmod(dir, 0o700)
await mkdir(victim)
await chmod(victim, 0o755)
await symlink(victim, join(dir, VERSION_SEGMENT))
const before = (await stat(victim)).mode & 0o777
expect((await attempt(dir)).ok).toBe(false)
expect((await stat(victim)).mode & 0o777).toBe(before)
})
it('adopts a directory we already own at 0700, so reconnects keep working', async () => {
// Regression guard: `ls` decorates the mode with @ (xattrs), + (ACL) or . (SELinux), and an
// exact match refused a directory we own — which would have broken every reconnect.
const dir = join(root, 'mine')
await mkdir(dir)
await chmod(dir, 0o700)
expect((await attempt(dir)).ok).toBe(true)
expect((await attempt(dir)).ok).toBe(true)
})
it('refuses a planted symlink without changing what it points at', async () => {
const victim = join(root, 'victim')
const link = join(root, 'link')
await mkdir(victim)
await chmod(victim, 0o755)
await symlink(victim, link)
const before = (await stat(victim)).mode & 0o777
expect((await attempt(link)).ok).toBe(false)
// The point of the ordering: an unconditional chmod would have followed the link and
// rewritten the victim's mode before the owner check ever ran.
expect((await stat(victim)).mode & 0o777).toBe(before)
})
it('refuses an existing directory that is not 0700', async () => {
const dir = join(root, 'loose')
await mkdir(dir)
// Explicit chmod: mkdir's mode is masked by the process umask, so the fixture would not
// actually be world-writable and the test would not be testing what it claims.
await chmod(dir, 0o777)
expect((await attempt(dir)).ok).toBe(false)
expect((await stat(dir)).mode & 0o777).toBe(0o777)
})
})