mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 08:03:12 +00:00
* 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
129 lines
5.5 KiB
TypeScript
129 lines
5.5 KiB
TypeScript
/**
|
|
* Keeps the remote relay's Unix socket path inside `sockaddr_un.sun_path`.
|
|
*
|
|
* The default endpoint is `$HOME/.orca-remote/relay-<fullVersion>/relay-<id>.sock`,
|
|
* whose fixed suffix already costs ~66 bytes. A managed-hosting `$HOME` such as
|
|
* `/var/www/<uuid>` pushes the whole path past the kernel cap and libuv reports only
|
|
* `listen EINVAL`, so the relay never starts (#10726). When that happens the socket
|
|
* moves to a fixed-length base whose length no longer depends on `$HOME`.
|
|
*
|
|
* Windows relays bind named pipes (`\\.\pipe\...`), which have no `sun_path` limit.
|
|
*/
|
|
import { createHash } from 'node:crypto'
|
|
import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform'
|
|
|
|
/**
|
|
* `sizeof(sun_path)` per remote OS, including the terminating NUL: 108 on Linux,
|
|
* 104 on macOS/BSD. Compared against byte length, not character count — a non-ASCII
|
|
* `$HOME` costs more bytes than characters.
|
|
*/
|
|
const SUN_PATH_SIZE: Record<'linux' | 'darwin', number> = { linux: 108, darwin: 104 }
|
|
|
|
export function remoteUnixSocketPathByteLimit(host: RemoteHostPlatform): number | null {
|
|
if (isWindowsRemoteHost(host)) {
|
|
return null
|
|
}
|
|
return SUN_PATH_SIZE[host.os === 'darwin' ? 'darwin' : 'linux'] - 1
|
|
}
|
|
|
|
export function remoteSocketPathFitsLimit(host: RemoteHostPlatform, sockPath: string): boolean {
|
|
const limit = remoteUnixSocketPathByteLimit(host)
|
|
return limit === null || Buffer.byteLength(sockPath, 'utf8') <= limit
|
|
}
|
|
|
|
/** Fixed-length, per-uid base. `/tmp` is the only POSIX directory whose length is not user-dependent. */
|
|
export const SHORT_RELAY_SOCKET_DIR_PREFIX = '/tmp/.orca-relay-'
|
|
|
|
export function shortRelaySocketDirForUid(uid: string): string {
|
|
return `${SHORT_RELAY_SOCKET_DIR_PREFIX}${uid}`
|
|
}
|
|
|
|
/**
|
|
* The version segment the relocated socket lives under, named to match the version
|
|
* directories in `$HOME/.orca-remote` so one sweep pattern covers both bases.
|
|
*
|
|
* Why it has to exist: `relaySocketNameForInstanceId` hashes the *target*, not the
|
|
* build, so the filename alone is version-independent. Under `$HOME` the enclosing
|
|
* `relay-<fullVersion>` directory supplies that dimension; without it here, the next
|
|
* Orca build would bind the exact path the previous build's relay still holds. The
|
|
* daemon handshake compares build hashes exactly, so that meeting is a version
|
|
* mismatch — and if the incumbent holds live work, `resolveRelayEndpointBeforeRelaunch`
|
|
* raises `RelayEndpointHeldError` and the user cannot connect at all until the old
|
|
* relay is stopped. The version is hashed rather than spelled out because the whole
|
|
* point of this base is a bounded length.
|
|
*/
|
|
export function shortRelayVersionSegment(relayVersionDirName: string): string {
|
|
return `relay-${createHash('sha256').update(relayVersionDirName).digest('hex').slice(0, 12)}`
|
|
}
|
|
|
|
/**
|
|
* The whole hashed socket name is kept — shortening happens by replacing the
|
|
* variable-length directory, never by truncating the hash, so two targets on one
|
|
* host can never land on the same socket.
|
|
*/
|
|
export function shortRelaySocketPath(shortVersionDir: string, sockName: string): string {
|
|
return `${shortVersionDir}/${sockName}`
|
|
}
|
|
|
|
const SHORT_DIR_MARKER = 'ORCA-RELAY-SHORT-SOCKET-DIR'
|
|
|
|
/**
|
|
* Create (or adopt) the per-uid short socket directory and its version segment, and
|
|
* print the segment's path.
|
|
*
|
|
* Validate before mutating, never the other way round: an unconditional `chmod` follows a
|
|
* symlink, so a path planted by another user would have its *target's* mode rewritten before
|
|
* the owner check could reject it. A fresh `mkdir` under `umask 077` already yields 0700 and
|
|
* proves we own it, so the only path that adopts an existing entry is the one that first
|
|
* proves — via `ls -ldn`, which reports the entry itself rather than what it points at — that
|
|
* it is a real directory, owned by this uid, already 0700. Nothing else is touched.
|
|
*/
|
|
export function resolveShortRelaySocketDirCommand(versionSegment: string): string {
|
|
return [
|
|
'uid=$(id -u) || exit 1',
|
|
`dir="${SHORT_RELAY_SOCKET_DIR_PREFIX}$uid"`,
|
|
'umask 077',
|
|
...adoptOwnedDirectoryCommand('$dir'),
|
|
// The version segment is validated the same way rather than trusted: `$dir` being
|
|
// 0700 and ours does not prove what an earlier run left inside it still is.
|
|
`ver="$dir/${versionSegment}"`,
|
|
...adoptOwnedDirectoryCommand('$ver'),
|
|
`printf '%s %s\n' '${SHORT_DIR_MARKER}' "$ver"`
|
|
].join('\n')
|
|
}
|
|
|
|
function adoptOwnedDirectoryCommand(target: string): string[] {
|
|
return [
|
|
`if mkdir "${target}" 2>/dev/null; then`,
|
|
' :',
|
|
'else',
|
|
// Why the sub(): ls decorates the mode with a trailing marker for extended attributes (@),
|
|
// ACLs (+) or an SELinux context (.), so an exact match would refuse a directory we own.
|
|
` entry=$(ls -ldn "${target}" 2>/dev/null | awk 'NR==1{sub(/[.@+]$/, "", $1); print $1" "$3}')`,
|
|
' case "$entry" in',
|
|
' "drwx------ $uid") ;;',
|
|
' *) exit 1 ;;',
|
|
' esac',
|
|
'fi'
|
|
]
|
|
}
|
|
|
|
/** Tolerates login-shell banner noise ahead of the marker line. */
|
|
export function parseShortRelaySocketDir(output: string, versionSegment: string): string | null {
|
|
for (const line of output.split('\n')) {
|
|
const trimmed = line.trim()
|
|
if (!trimmed.startsWith(`${SHORT_DIR_MARKER} `)) {
|
|
continue
|
|
}
|
|
const dir = trimmed.slice(SHORT_DIR_MARKER.length + 1).trim()
|
|
if (
|
|
dir.startsWith(`${SHORT_RELAY_SOCKET_DIR_PREFIX}`) &&
|
|
dir.endsWith(`/${versionSegment}`) &&
|
|
!/[\r\n]/.test(dir)
|
|
) {
|
|
return dir
|
|
}
|
|
}
|
|
return null
|
|
}
|