diff --git a/.gitignore b/.gitignore index 1b3f81e72c0..00b6dd02894 100644 --- a/.gitignore +++ b/.gitignore @@ -92,6 +92,7 @@ docs/** !docs/reference/ !docs/reference/git-compatibility.md !docs/reference/headless-linux-server.md +!docs/reference/linux-glibc-compatibility.md # Stably CLI (only docs/ are tracked) .stably/* diff --git a/AGENTS.md b/AGENTS.md index eb43f911924..d4edf8dbaf1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,7 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh - **Keyboard shortcuts**: Never hardcode `e.metaKey`. Use a platform check (`navigator.userAgent.includes('Mac')`) to pick `metaKey` on Mac and `ctrlKey` on Linux/Windows. Electron menu accelerators should use `CmdOrCtrl`. - **Shortcut labels in UI**: Display `⌘` / `⇧` on Mac and `Ctrl+` / `Shift+` on other platforms. - **File paths**: Use `path.join` or Electron/Node path utilities — never assume `/` or `\`. +- **Linux native modules**: keep the glibc floor at Ubuntu 20.04 / glibc 2.31. A module compiled from source on a newer runner can reference symbol versions absent on the floor and crash the app on startup. See [`docs/reference/linux-glibc-compatibility.md`](./docs/reference/linux-glibc-compatibility.md); packaging fails if a bundled native binary needs newer glibc. ## SSH Use Case diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 50cfe7691e8..6d9db80c558 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -11,6 +11,7 @@ const { prunePackagedRuntimeNodeModules, verifyPackagedMainRuntimeDeps } = require('./packaged-runtime-node-modules.cjs') +const { verifyLinuxGlibcFloor } = require('./scripts/verify-linux-glibc-floor.cjs') const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1' @@ -133,6 +134,12 @@ module.exports = { 'node_modules/sherpa-onnx*/**' ], afterPack: async (context) => { + // Why: a Linux runner-image glibc bump silently shipped a node-pty pty.node + // requiring GLIBC_2.34, crashing the app on startup on Ubuntu 20.04 (#9902). + // Fail packaging if any bundled native binary exceeds the supported floor. + if (context.electronPlatformName === 'linux') { + verifyLinuxGlibcFloor(context.appOutDir) + } const resourcesDir = context.electronPlatformName === 'darwin' ? join( diff --git a/config/patches/node-pty@1.1.0.patch b/config/patches/node-pty@1.1.0.patch index e100def9346..0b0038ed630 100644 --- a/config/patches/node-pty@1.1.0.patch +++ b/config/patches/node-pty@1.1.0.patch @@ -1,5 +1,5 @@ diff --git a/binding.gyp b/binding.gyp -index 5f63978b07ab50aaf7523219a2170ec737a6b5db..b3309a07ef99dea7967d7bdd04b9fc3500acacae 100644 +index 5f63978b07ab50aaf7523219a2170ec737a6b5db..bbd9e06136e8922f40b5779e35d4fc835f1479ab 100644 --- a/binding.gyp +++ b/binding.gyp @@ -5,9 +5,6 @@ @@ -12,6 +12,23 @@ index 5f63978b07ab50aaf7523219a2170ec737a6b5db..b3309a07ef99dea7967d7bdd04b9fc35 'msvs_settings': { 'VCCLCompilerTool': { 'AdditionalOptions': [ +@@ -88,6 +85,16 @@ + 'libraries!': [ + '-lutil' + ] ++ }], ++ # Orca: pair with the .symver pins in pty.cc. Force the real ++ # libutil.so.1/libpthread.so.0 into DT_NEEDED (gcc's default ++ # --as-needed drops them because the pinned symbols resolve from ++ # libc's compat aliases at build time) so openpty/forkpty/ ++ # pthread_sigmask still resolve on Ubuntu 20.04 (glibc 2.31). ++ ['OS=="linux"', { ++ 'ldflags': [ ++ '-Wl,--no-as-needed,-l:libutil.so.1,-l:libpthread.so.0,--as-needed' ++ ] + }] + ] + } diff --git a/deps/winpty/src/winpty.gyp b/deps/winpty/src/winpty.gyp index 1ac5758bedd8cf54f32280dea4e4aeb5afdee30d..e619813759c6f14694838bdfbd0ea5f8360130ef 100644 --- a/deps/winpty/src/winpty.gyp @@ -54,6 +71,27 @@ index 1ac5758bedd8cf54f32280dea4e4aeb5afdee30d..e619813759c6f14694838bdfbd0ea5f8 'msvs_settings': { # Specify this setting here to override a setting from somewhere # else, such as node's common.gypi. +diff --git a/lib/conpty_console_list_agent.js b/lib/conpty_console_list_agent.js +index 8c4fca9022a6d6f015bca87f61625cde2278f428..0a01730616488119aa21ef441cf3c441e02a974c 100644 +--- a/lib/conpty_console_list_agent.js ++++ b/lib/conpty_console_list_agent.js +@@ -10,7 +10,14 @@ Object.defineProperty(exports, "__esModule", { value: true }); + var utils_1 = require("./utils"); + var getConsoleProcessList = utils_1.loadNativeModule('conpty_console_list').module.getConsoleProcessList; + var shellPid = parseInt(process.argv[2], 10); +-var consoleProcessList = getConsoleProcessList(shellPid); ++var consoleProcessList; ++try { ++ consoleProcessList = getConsoleProcessList(shellPid); ++} ++catch (_a) { ++ // Why: AttachConsole can fail after the shell exits; parent already has this fallback. ++ consoleProcessList = [shellPid]; ++} + process.send({ consoleProcessList: consoleProcessList }); + process.exit(0); + //# sourceMappingURL=conpty_console_list_agent.js.map +\ No newline at end of file diff --git a/lib/unixTerminal.js b/lib/unixTerminal.js index 1ec12f796a822c78fba9ad7f6448c3987e325c23..cec8b67aef02f8199e5606a0d257088bf1865877 100644 --- a/lib/unixTerminal.js @@ -73,31 +111,12 @@ index 1ec12f796a822c78fba9ad7f6448c3987e325c23..cec8b67aef02f8199e5606a0d257088b var DEFAULT_FILE = 'sh'; var DEFAULT_NAME = 'xterm'; var DESTROY_SOCKET_TIMEOUT_MS = 200; -diff --git a/lib/conpty_console_list_agent.js b/lib/conpty_console_list_agent.js -index ccc111c9e03a4a661ccfd5d8e8f0ee699571b5dd..f92c6bef7d46dc35c941c87ef186aa46d8ed9c44 100644 ---- a/lib/conpty_console_list_agent.js -+++ b/lib/conpty_console_list_agent.js -@@ -9,7 +9,14 @@ Object.defineProperty(exports, "__esModule", { value: true }); - var utils_1 = require("./utils"); - var getConsoleProcessList = utils_1.loadNativeModule('conpty_console_list').module.getConsoleProcessList; - var shellPid = parseInt(process.argv[2], 10); --var consoleProcessList = getConsoleProcessList(shellPid); -+var consoleProcessList; -+try { -+ consoleProcessList = getConsoleProcessList(shellPid); -+} -+catch (_a) { -+ // Why: AttachConsole can fail after the shell exits; parent already has this fallback. -+ consoleProcessList = [shellPid]; -+} - process.send({ consoleProcessList: consoleProcessList }); - process.exit(0); - //# sourceMappingURL=conpty_console_list_agent.js.map diff --git a/src/conpty_console_list_agent.ts b/src/conpty_console_list_agent.ts -index f6a653893e0b9b548c514db29d75599538ee1acb..1d5400489f200ef0161ca687e672e1cc02d29c95 100644 +index 181ccabbbe9c4948a9725fb1db907a68e9de01fc..67f31facf85562b67adbfbd04ce28ddd8eeb4a79 100644 --- a/src/conpty_console_list_agent.ts +++ b/src/conpty_console_list_agent.ts -@@ -11,5 +11,11 @@ import { loadNativeModule } from './utils'; +@@ -10,6 +10,12 @@ import { loadNativeModule } from './utils'; + const getConsoleProcessList = loadNativeModule('conpty_console_list').module.getConsoleProcessList; const shellPid = parseInt(process.argv[2], 10); -const consoleProcessList = getConsoleProcessList(shellPid); @@ -111,7 +130,7 @@ index f6a653893e0b9b548c514db29d75599538ee1acb..1d5400489f200ef0161ca687e672e1cc process.send!({ consoleProcessList }); process.exit(0); diff --git a/src/unix/pty.cc b/src/unix/pty.cc -index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca850564e6368d9 100644 +index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d15c4dd44 100644 --- a/src/unix/pty.cc +++ b/src/unix/pty.cc @@ -23,7 +23,9 @@ @@ -124,7 +143,33 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056 #include #include -@@ -237,13 +239,23 @@ pty_getproc(int, char *); +@@ -47,6 +49,25 @@ + #include + #endif + ++/* Orca: glibc 2.32-2.34 relocated pthread_sigmask/openpty/forkpty into libc ++ * under new symbol versions, so building on a newer glibc produces references ++ * (GLIBC_2.32/2.34) absent on Ubuntu 20.04 (glibc 2.31) and the app fails to ++ * launch. Pin these to the pre-merge version glibc still ships as a compat ++ * alias; the binding.gyp ldflags force libutil/libpthread into DT_NEEDED so ++ * those aliases are actually loaded on the target. */ ++#if defined(__linux__) ++# if defined(__x86_64__) ++# define ORCA_GLIBC_COMPAT_VERSION "GLIBC_2.2.5" ++# elif defined(__aarch64__) ++# define ORCA_GLIBC_COMPAT_VERSION "GLIBC_2.17" ++# endif ++# ifdef ORCA_GLIBC_COMPAT_VERSION ++__asm__(".symver openpty,openpty@" ORCA_GLIBC_COMPAT_VERSION); ++__asm__(".symver forkpty,forkpty@" ORCA_GLIBC_COMPAT_VERSION); ++__asm__(".symver pthread_sigmask,pthread_sigmask@" ORCA_GLIBC_COMPAT_VERSION); ++# endif ++#endif ++ + /* Some platforms name VWERASE and VDISCARD differently */ + #if !defined(VWERASE) && defined(VWERSE) + #define VWERASE VWERSE +@@ -237,13 +258,23 @@ pty_getproc(int, char *); #endif #if defined(__APPLE__) || defined(__OpenBSD__) @@ -149,7 +194,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056 #endif struct DelBuf { -@@ -367,10 +379,11 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) { +@@ -367,10 +398,11 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) { argv[i + 3] = strdup(arg.c_str()); } @@ -165,7 +210,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056 } if (pty_nonblock(master) == -1) { throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking."); -@@ -684,15 +697,73 @@ pty_getproc(int fd, char *tty) { +@@ -684,15 +716,73 @@ pty_getproc(int fd, char *tty) { #endif #if defined(__APPLE__) @@ -241,25 +286,25 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056 for (; count < 3; count++) { low_fds[count] = posix_openpt(O_RDWR); -@@ -706,80 +777,118 @@ pty_posix_spawn(char** argv, char** env, +@@ -706,80 +796,118 @@ pty_posix_spawn(char** argv, char** env, POSIX_SPAWN_SETSID; *master = posix_openpt(O_RDWR); if (*master == -1) { - return; + pty_set_spawn_error(err, "posix_openpt", errno); -+ goto done; -+ } -+ -+ res = grantpt(*master); -+ if (res == -1) { -+ pty_set_spawn_error(err, "grantpt", errno); + goto done; } - int res = grantpt(*master) || unlockpt(*master); -+ res = unlockpt(*master); ++ res = grantpt(*master); if (res == -1) { - return; ++ pty_set_spawn_error(err, "grantpt", errno); ++ goto done; ++ } ++ ++ res = unlockpt(*master); ++ if (res == -1) { + pty_set_spawn_error(err, "unlockpt", errno); + goto done; } diff --git a/config/scripts/run-headless-linux-pairing-docker.mjs b/config/scripts/run-headless-linux-pairing-docker.mjs index 15f1135bb53..635c66348cc 100644 --- a/config/scripts/run-headless-linux-pairing-docker.mjs +++ b/config/scripts/run-headless-linux-pairing-docker.mjs @@ -194,8 +194,21 @@ async function validateAuthenticatedPairing() { status?._meta?.runtimeId === payload.runtimeId, 'paired client runtime ID does not match ready contract' ) + assert( + typeof statusResult?.runtime?.appVersion === 'string', + 'paired server did not report its Orca app version' + ) + assert( + statusResult?.runtime?.capabilities?.includes('updater.remote-control.v1'), + 'paired server did not advertise remote updater capability' + ) + assert( + statusResult?.runtime?.remoteUpdateSupport?.automatic === false && + statusResult.runtime.remoteUpdateSupport.reason === 'manual-service-update-required', + 'direct headless server did not require a safe manual service update' + ) stopContainer(server.name) - console.log('PASS authenticated E2EE pairing from an independent Debian container') + console.log('PASS paired E2EE updater inventory and manual-service fallback') } async function validateUnreachableOffer() { diff --git a/config/scripts/run-nested-runtime-ssh-e2e.mjs b/config/scripts/run-nested-runtime-ssh-e2e.mjs new file mode 100644 index 00000000000..577fd40c6bb --- /dev/null +++ b/config/scripts/run-nested-runtime-ssh-e2e.mjs @@ -0,0 +1,32 @@ +import { spawnSync } from 'node:child_process' + +const result = spawnSync( + process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm', + [ + 'exec', + 'playwright', + 'test', + 'tests/e2e/nested-runtime-ssh-routing.spec.ts', + 'tests/e2e/nested-runtime-ssh-lifecycle.spec.ts', + '--config', + 'tests/playwright.config.ts', + '--project', + 'electron-headless', + '--workers=1' + ], + { + cwd: process.cwd(), + env: { + ...process.env, + ORCA_E2E_NESTED_RUNTIME_SSH: '1', + ORCA_E2E_SSH_DOCKER: '1', + ORCA_E2E_WEB_CLIENT: '1' + }, + stdio: 'inherit' + } +) + +if (result.error) { + throw result.error +} +process.exit(result.status ?? 1) diff --git a/config/scripts/verify-linux-glibc-floor.cjs b/config/scripts/verify-linux-glibc-floor.cjs new file mode 100644 index 00000000000..55ec8ca7724 --- /dev/null +++ b/config/scripts/verify-linux-glibc-floor.cjs @@ -0,0 +1,394 @@ +const { readdirSync, openSync, readSync, closeSync } = require('node:fs') +const { spawnSync } = require('node:child_process') +const { join, relative } = require('node:path') + +// Why: v1.4.150 shipped a Linux build whose node-pty pty.node required +// GLIBC_2.34 (openpty/forkpty were relocated into libc by glibc's +// libutil/libpthread merge), so the app crashed on startup on Ubuntu 20.04 +// (glibc 2.31) — the runner image silently bumped the build-host glibc. This +// gate fails Linux packaging if any bundled native binary requires a glibc (or +// libstdc++) symbol version newer than stock Ubuntu 20.04 ships, so a future +// runner bump or dependency change cannot reintroduce the regression unnoticed. +// See docs/reference/linux-glibc-compatibility.md. +const MIN_GLIBC = Object.freeze([2, 31]) + +// The symbol-version families this gate checks, each with the highest version +// node stock Ubuntu 20.04 provides. glibc is the #9902 launch-crash axis; +// libstdc++ (GLIBCXX_/CXXABI_) is the same crash class for C++ native modules +// against the system libstdc++ (Orca does not bundle one). +const VERSION_FLOORS = Object.freeze([ + Object.freeze({ prefix: 'GLIBC_', floor: MIN_GLIBC }), + Object.freeze({ prefix: 'GLIBCXX_', floor: Object.freeze([3, 4, 28]) }), + Object.freeze({ prefix: 'CXXABI_', floor: Object.freeze([1, 3, 12]) }) +]) +const FLOOR_LABEL = 'Ubuntu 20.04 (glibc 2.31 / libstdc++ GLIBCXX_3.4.28)' + +// Why: the sherpa-onnx speech prebuilt is a third-party manylinux binary that +// already requires GLIBCXX_3.4.29 (GCC 11 / Ubuntu 21.10+, 22.04 LTS). It loads +// lazily in the speech worker (src/main/speech/stt-worker.ts), never at app +// launch, so it cannot cause the #9902 startup crash. Exempt it from the +// libstdc++ floor (its glibc is still gated) rather than fail the release on a +// pre-existing, non-launch condition — speech needs libstdc++ >= GCC 11. +const LIBSTDCXX_FLOOR_EXEMPT = /(?:^|[/\\])sherpa-onnx/ + +// VER_FLG_WEAK: a version need whose references are all weak. The loader +// tolerates its absence (resolves to null and the caller's fallback runs) +// instead of refusing to load, so a weak need must not count as a requirement. +const VER_FLG_WEAK = 0x2 + +/** Parse a "2.34" / "3.4.28" version string into a numeric tuple. */ +function parseGlibcVersion(versionStr) { + return versionStr.split('.').map((part) => Number.parseInt(part, 10)) +} + +/** Compare two numeric version tuples; missing trailing parts are 0. */ +function compareGlibcVersions(a, b) { + const length = Math.max(a.length, b.length) + for (let i = 0; i < length; i += 1) { + const diff = (a[i] ?? 0) - (b[i] ?? 0) + if (diff !== 0) { + return diff < 0 ? -1 : 1 + } + } + return 0 +} + +/** + * Parse `objdump -p` "Version References" (the ELF `.gnu.version_r` section) + * into the version nodes this binary requires from each shared library. This is + * the authoritative load-time requirement list: unlike the dynamic symbol table + * (`objdump -T`), it also captures symbol-less ABI markers such as + * `GLIBC_ABI_DT_RELR` (packed relative relocations, glibc 2.36+) that still + * block loading on an older glibc. Each entry: `0xHASH 0xFLAGS `. + */ +function parseVersionNeeds(objdumpOutput) { + const needs = [] + let library = null + let inSection = false + for (const line of objdumpOutput.split('\n')) { + if (line.startsWith('Version References:')) { + inSection = true + continue + } + if (!inSection) { + continue + } + // Any new non-indented line ends the Version References block. + if (!/^\s/.test(line)) { + inSection = false + continue + } + const libraryMatch = line.match(/^\s+required from (\S+):/) + if (libraryMatch) { + library = libraryMatch[1] + continue + } + const entryMatch = line.match(/^\s+0x[0-9a-fA-F]+\s+0x([0-9a-fA-F]+)\s+\d+\s+(\S+)/) + if (entryMatch) { + const flags = Number.parseInt(entryMatch[1], 16) + needs.push({ library, name: entryMatch[2], weak: (flags & VER_FLG_WEAK) !== 0 }) + } + } + return needs +} + +/** + * Whether a version node is newer than the floor Ubuntu 20.04 provides. Numeric + * nodes (`GLIBC_2.34`, `GLIBCXX_3.4.29`) compare by version. Any non-numeric + * glibc node is rejected: `GLIBC_ABI_DT_RELR` is a 2.36+ marker, and + * `GLIBC_PRIVATE` is not a stable ABI contract — its symbols differ across + * glibc releases, so a binary needing one can fail to load on the floor even + * though the version node itself exists (a well-formed addon needs neither). + * Named libstdc++ nodes (`CXXABI_TM_1`, `GLIBCXX_LDBL_*`) ship on 20.04. + * Families we do not gate (`GCC_`, `NSS_`) return false. + */ +function isVersionNodeAboveFloor(name) { + for (const { prefix, floor } of VERSION_FLOORS) { + if (!name.startsWith(prefix)) { + continue + } + const rest = name.slice(prefix.length) + if (/^[0-9]+(?:\.[0-9]+)*$/.test(rest)) { + return compareGlibcVersions(parseGlibcVersion(rest), floor) > 0 + } + // Non-numeric suffix: reject every glibc node (ABI markers and PRIVATE). + return prefix === 'GLIBC_' + } + return false +} + +function isLibstdcxxNode(name) { + return name.startsWith('GLIBCXX_') || name.startsWith('CXXABI_') +} + +/** + * Version needs from `filePath` that would prevent loading on the floor OS. + * `sherpa-onnx` is exempt from the libstdc++ floor (see LIBSTDCXX_FLOOR_EXEMPT) + * but its glibc needs are still checked. + */ +function findFloorViolations(needs, filePath = '') { + const exemptLibstdcxx = LIBSTDCXX_FLOOR_EXEMPT.test(filePath) + return needs.filter( + (need) => + !need.weak && + isVersionNodeAboveFloor(need.name) && + !(exemptLibstdcxx && isLibstdcxxNode(need.name)) + ) +} + +// On stock Ubuntu 20.04 (glibc 2.31) these symbols live ONLY in these DSOs — +// glibc kept openpty/forkpty in libutil until the 2.34 merge. A binary that +// imports them must keep the DSO in DT_NEEDED or they will not resolve on the +// floor. This guards config/patches/node-pty@1.1.0.patch's forced +// `-l:libutil.so.1`: if a toolchain change ever dropped that ldflag, the pinned +// openpty@GLIBC_2.2.5 would still resolve from libc's compat alias at build time +// (so the version-floor check passes) yet fail to load on 20.04. libpthread +// (pthread_sigmask) is intentionally omitted — the Node/Electron host always +// loads it, so it resolves regardless of this addon's DT_NEEDED. +const RELOCATED_SYMBOL_PROVIDERS = Object.freeze({ + openpty: 'libutil.so.1', + forkpty: 'libutil.so.1' +}) + +/** + * Relocated symbols the binary imports whose providing DSO is absent from + * DT_NEEDED — meaning they resolve at build time but not on the floor OS. + */ +function findMissingProviderDeps(importedSymbols, neededLibraries) { + const missing = [] + for (const [symbol, library] of Object.entries(RELOCATED_SYMBOL_PROVIDERS)) { + if (importedSymbols.has(symbol) && !neededLibraries.has(library)) { + missing.push({ symbol, library }) + } + } + return missing +} + +function isElfFile(filePath) { + let fd + try { + fd = openSync(filePath, 'r') + const header = Buffer.alloc(4) + const bytesRead = readSync(fd, header, 0, 4, 0) + return bytesRead === 4 && header[0] === 0x7f && header.toString('latin1', 1, 4) === 'ELF' + } catch { + return false + } finally { + if (fd !== undefined) { + closeSync(fd) + } + } +} + +/** Recursively collect ELF native binaries (`.node`, `.so[.N]`, executables). */ +function collectNativeBinaries(rootDir) { + const binaries = [] + const walk = (dir) => { + let entries + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + const fullPath = join(dir, entry.name) + if (entry.isSymbolicLink()) { + continue + } + if (entry.isDirectory()) { + walk(fullPath) + continue + } + if (!entry.isFile()) { + continue + } + // Why: .node/.so are always native; extensionless files (the Electron + // executable, chrome-sandbox) are checked via the ELF magic so we cover + // every launch-critical binary without objdump-ing app.asar or assets. + const looksNative = entry.name.endsWith('.node') || /\.so(\.\d+)*$/.test(entry.name) + if (looksNative || !entry.name.includes('.')) { + if (isElfFile(fullPath)) { + binaries.push(fullPath) + } + } + } + } + walk(rootDir) + return binaries.sort() +} + +function resolveObjdump(explicitPath) { + const candidates = [explicitPath, 'objdump', 'llvm-objdump'].filter(Boolean) + for (const candidate of candidates) { + const probe = spawnSync(candidate, ['--version'], { encoding: 'utf8', env: cLocaleEnv() }) + if (!probe.error && probe.status === 0) { + return candidate + } + } + return null +} + +// Why: GNU objdump localizes its section headers ("Version References:") via +// gettext, and the parser anchors on the English text. Force the C locale so +// output stays deterministic on non-English packaging hosts (LC_ALL=C also +// disables LANGUAGE-based message translation). +function cLocaleEnv() { + return { ...process.env, LC_ALL: 'C', LANG: 'C' } +} + +/** + * Run objdump with one flag on `filePath`. Fail-closed: a spawn error, non-zero + * exit, or signal throws, because a silently-unreadable binary (truncated, + * corrupt, or an objdump that cannot decode its format) would let a too-new + * binary slip past the gate. + */ +function runObjdump(objdumpPath, flag, filePath) { + const result = spawnSync(objdumpPath, [flag, filePath], { + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + env: cLocaleEnv() + }) + if (result.error) { + throw new Error( + `[verify-linux-glibc-floor] could not run objdump on ${filePath}: ${result.error.message}` + ) + } + if (result.signal || result.status !== 0) { + throw new Error( + `[verify-linux-glibc-floor] objdump ${flag} failed for ${filePath} ` + + `(status ${result.status}, signal ${result.signal ?? 'none'}): ${(result.stderr || '').trim()}` + ) + } + return result.stdout || '' +} + +/** DT_NEEDED shared-library names from `objdump -p` (` NEEDED `). */ +function parseNeededLibraries(objdumpOutput) { + const needed = new Set() + for (const line of objdumpOutput.split('\n')) { + const match = line.match(/^\s+NEEDED\s+(\S+)/) + if (match) { + needed.add(match[1]) + } + } + return needed +} + +/** Undefined (imported) dynamic symbol base names from `objdump -T` (`*UND*`). */ +function parseImportedSymbols(objdumpOutput) { + const imported = new Set() + for (const line of objdumpOutput.split('\n')) { + if (!line.includes('*UND*')) { + continue + } + // The symbol name is the final token; strip any @VERSION suffix. + const token = line.trim().split(/\s+/).pop() + if (token) { + imported.add(token.split('@')[0]) + } + } + return imported +} + +/** Version needs + DT_NEEDED from a single `objdump -p` (fail-closed). */ +function readDynamicInfo(filePath, objdumpPath) { + const output = runObjdump(objdumpPath, '-p', filePath) + return { + versionNeeds: parseVersionNeeds(output), + neededLibraries: parseNeededLibraries(output) + } +} + +/** Imported (undefined) dynamic symbols from `objdump -T` (fail-closed). */ +function readImportedSymbols(filePath, objdumpPath) { + return parseImportedSymbols(runObjdump(objdumpPath, '-T', filePath)) +} + +/** + * Fail Linux packaging if any bundled native binary under `rootDir` requires a + * glibc/libstdc++ symbol version newer than the floor OS. No-op is not allowed + * on Linux: a missing objdump throws, because a silent skip would defeat the + * regression gate on exactly the host where it matters. + */ +function verifyLinuxGlibcFloor(rootDir, options = {}) { + const binaries = collectNativeBinaries(rootDir) + if (binaries.length === 0) { + console.log(`[verify-linux-glibc-floor] OK — no bundled native binaries under ${rootDir}`) + return + } + + // Why: resolve objdump only once there is something to inspect, so a fixture + // with no ELF binaries does not fail on a host that lacks binutils. + const objdumpPath = resolveObjdump(options.objdumpPath) + if (!objdumpPath) { + throw new Error( + '[verify-linux-glibc-floor] objdump not found. Install binutils on the Linux ' + + 'packaging host so the glibc-floor gate can inspect bundled native binaries.' + ) + } + + const offenders = [] + for (const filePath of binaries) { + const { versionNeeds, neededLibraries } = readDynamicInfo(filePath, objdumpPath) + const floorViolations = findFloorViolations(versionNeeds, filePath) + // Only pay for `objdump -T` when a relocated-symbol provider is not already + // in DT_NEEDED (the common, healthy case short-circuits without it). + const providerViolations = Object.values(RELOCATED_SYMBOL_PROVIDERS).some( + (library) => !neededLibraries.has(library) + ) + ? findMissingProviderDeps(readImportedSymbols(filePath, objdumpPath), neededLibraries) + : [] + if (floorViolations.length > 0 || providerViolations.length > 0) { + offenders.push({ filePath, floorViolations, providerViolations }) + } + } + + if (offenders.length > 0) { + const detail = offenders + .map(({ filePath, floorViolations, providerViolations }) => { + const reasons = [] + if (floorViolations.length > 0) { + const nodes = [...new Set(floorViolations.map((v) => v.name))].sort() + const libraries = [...new Set(floorViolations.map((v) => v.library).filter(Boolean))] + reasons.push( + `needs ${nodes.join(', ')}${libraries.length > 0 ? ` (from ${libraries.join(', ')})` : ''}` + ) + } + for (const { symbol, library } of providerViolations) { + reasons.push(`imports ${symbol} but ${library} is not in DT_NEEDED`) + } + return ` ${relative(rootDir, filePath) || filePath} ${reasons.join('; ')}` + }) + .join('\n') + throw new Error( + `[verify-linux-glibc-floor] ${offenders.length} bundled native binar${offenders.length === 1 ? 'y' : 'ies'} ` + + `will not load on ${FLOOR_LABEL}, so the app will crash on startup there:\n${detail}\n` + + 'See docs/reference/linux-glibc-compatibility.md — rebuild the offending module against an older ' + + 'toolchain or pin the relocated symbols (as config/patches/node-pty@1.1.0.patch does).' + ) + } + + console.log( + `[verify-linux-glibc-floor] OK — ${binaries.length} bundled native binaries all load on ${FLOOR_LABEL}` + ) +} + +module.exports = { + MIN_GLIBC, + VERSION_FLOORS, + FLOOR_LABEL, + RELOCATED_SYMBOL_PROVIDERS, + parseGlibcVersion, + compareGlibcVersions, + parseVersionNeeds, + parseNeededLibraries, + parseImportedSymbols, + isVersionNodeAboveFloor, + isLibstdcxxNode, + findFloorViolations, + findMissingProviderDeps, + collectNativeBinaries, + readDynamicInfo, + readImportedSymbols, + verifyLinuxGlibcFloor +} diff --git a/config/scripts/verify-linux-glibc-floor.test.mjs b/config/scripts/verify-linux-glibc-floor.test.mjs new file mode 100644 index 00000000000..603e4e85c00 --- /dev/null +++ b/config/scripts/verify-linux-glibc-floor.test.mjs @@ -0,0 +1,323 @@ +import { mkdtemp, mkdir, writeFile, symlink, rm } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const { + parseGlibcVersion, + compareGlibcVersions, + parseVersionNeeds, + parseNeededLibraries, + parseImportedSymbols, + isVersionNodeAboveFloor, + findFloorViolations, + findMissingProviderDeps, + collectNativeBinaries, + verifyLinuxGlibcFloor +} = require('./verify-linux-glibc-floor.cjs') + +// 0x7f 'E' 'L' 'F' + class/data/version padding — enough for the magic check. +const ELF_HEADER = Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01, 0x01, 0x00]) + +// Real `objdump -p` "Version References" shape (entry: 0xHASH 0xFLAGS NAME; +// flags 0x02 = VER_FLG_WEAK). Includes a symbol-less ABI marker, a weak need, +// and a libstdc++ need. +const OBJDUMP_P = [ + 'Dynamic Section:', + ' NEEDED libc.so.6', + '', + 'Version References:', + ' required from libc.so.6:', + ' 0x09691a75 0x00 06 GLIBC_2.2.5', + ' 0x069691b4 0x00 05 GLIBC_2.34', + ' 0x0d696914 0x02 04 GLIBC_2.18', + ' 0x00fd0e42 0x00 03 GLIBC_ABI_DT_RELR', + ' required from libstdc++.so.6:', + ' 0x0b481abc 0x00 07 GLIBCXX_3.4.29', + '' +].join('\n') + +describe('verify-linux-glibc-floor parsing', () => { + it('parses and compares numeric version tuples', () => { + expect(parseGlibcVersion('2.34')).toEqual([2, 34]) + expect(parseGlibcVersion('3.4.28')).toEqual([3, 4, 28]) + expect(compareGlibcVersions([2, 2, 5], [2, 14])).toBe(-1) + expect(compareGlibcVersions([2, 31], [2, 32])).toBe(-1) + expect(compareGlibcVersions([2, 34], [2, 31])).toBe(1) + expect(compareGlibcVersions([2, 31], [2, 31])).toBe(0) + expect(compareGlibcVersions([2, 31], [2, 31, 0])).toBe(0) + expect(compareGlibcVersions([3, 4, 29], [3, 4, 28])).toBe(1) + }) + + it('parses objdump -p Version References into per-library version needs', () => { + const needs = parseVersionNeeds(OBJDUMP_P) + expect(needs).toContainEqual({ library: 'libc.so.6', name: 'GLIBC_2.34', weak: false }) + expect(needs).toContainEqual({ library: 'libc.so.6', name: 'GLIBC_ABI_DT_RELR', weak: false }) + expect(needs).toContainEqual({ library: 'libc.so.6', name: 'GLIBC_2.18', weak: true }) + expect(needs).toContainEqual({ library: 'libstdc++.so.6', name: 'GLIBCXX_3.4.29', weak: false }) + }) + + it('classifies version nodes across glibc and libstdc++ families', () => { + expect(isVersionNodeAboveFloor('GLIBC_2.34')).toBe(true) + expect(isVersionNodeAboveFloor('GLIBC_2.31')).toBe(false) + expect(isVersionNodeAboveFloor('GLIBC_ABI_DT_RELR')).toBe(true) // symbol-less marker (2.36+) + // GLIBC_PRIVATE is not a stable ABI contract; a needed private symbol can be + // absent on the floor even though the version node exists — reject it. + expect(isVersionNodeAboveFloor('GLIBC_PRIVATE')).toBe(true) + expect(isVersionNodeAboveFloor('CXXABI_TM_1')).toBe(false) // named libstdc++ node on 20.04 + expect(isVersionNodeAboveFloor('GLIBCXX_3.4.29')).toBe(true) // GCC 11, above 20.04's 3.4.28 + expect(isVersionNodeAboveFloor('GLIBCXX_3.4.28')).toBe(false) + expect(isVersionNodeAboveFloor('CXXABI_1.3.13')).toBe(true) + expect(isVersionNodeAboveFloor('CXXABI_1.3.12')).toBe(false) + expect(isVersionNodeAboveFloor('GCC_3.0')).toBe(false) // family not gated + }) + + it('flags strong too-new glibc + libstdc++ needs, skipping weak and ungated families', () => { + const violations = findFloorViolations(parseVersionNeeds(OBJDUMP_P), '/opt/app/pty.node') + const names = violations.map((v) => v.name).sort() + // GLIBC_2.34, GLIBC_ABI_DT_RELR, GLIBCXX_3.4.29 fail; weak GLIBC_2.18 and + // GLIBC_2.2.5 are excluded. + expect(names).toEqual(['GLIBCXX_3.4.29', 'GLIBC_2.34', 'GLIBC_ABI_DT_RELR'].sort()) + }) + + it('exempts sherpa-onnx from the libstdc++ floor but still gates its glibc', () => { + const needs = [ + { library: 'libstdc++.so.6', name: 'GLIBCXX_3.4.29', weak: false }, + { library: 'libc.so.6', name: 'GLIBC_2.34', weak: false } + ] + // A launch-critical module: both are violations. + expect( + findFloorViolations(needs, '/opt/app/node_modules/node-pty/pty.node').map((v) => v.name) + ).toEqual(['GLIBCXX_3.4.29', 'GLIBC_2.34']) + // sherpa: GLIBCXX exempt (lazy speech prebuilt), glibc still enforced. + expect( + findFloorViolations( + needs, + '/opt/app/node_modules/sherpa-onnx-linux-x64/sherpa-onnx.node' + ).map((v) => v.name) + ).toEqual(['GLIBC_2.34']) + }) + + it('reports no violations when every strong need is at or below the floor', () => { + const needs = parseVersionNeeds( + [ + 'Version References:', + ' required from libc.so.6:', + ' 0x00 0x00 02 GLIBC_2.2.5', + ' 0x00 0x00 03 GLIBC_2.28', + ' required from libstdc++.so.6:', + ' 0x00 0x00 04 GLIBCXX_3.4.22' + ].join('\n') + ) + expect(findFloorViolations(needs, '/opt/app/pty.node')).toEqual([]) + }) +}) + +describe('DT_NEEDED provider check', () => { + const OBJDUMP_P_DYNAMIC = [ + 'Dynamic Section:', + ' NEEDED libutil.so.1', + ' NEEDED libpthread.so.0', + ' NEEDED libc.so.6', + '', + 'Version References:', + ' required from libc.so.6:', + ' 0x0 0x00 02 GLIBC_2.2.5' + ].join('\n') + + it('parses DT_NEEDED shared libraries from objdump -p', () => { + const needed = parseNeededLibraries(OBJDUMP_P_DYNAMIC) + expect([...needed].sort()).toEqual(['libc.so.6', 'libpthread.so.0', 'libutil.so.1']) + }) + + it('parses undefined imported symbols from objdump -T, stripping @VERSION', () => { + const output = [ + '0000000000000000 DF *UND*\t0000000000000000 (GLIBC_2.2.5) openpty', + '0000000000000000 w DF *UND*\t0000000000000000 __cxa_finalize@GLIBC_2.2.5', + '0000000000000000 DF .text\t0000000000000000 defined_symbol' + ].join('\n') + const imported = parseImportedSymbols(output) + expect(imported.has('openpty')).toBe(true) + expect(imported.has('__cxa_finalize')).toBe(true) + expect(imported.has('defined_symbol')).toBe(false) // not *UND* + }) + + it('flags a binary that imports openpty/forkpty without libutil.so.1 in DT_NEEDED', () => { + const importsPty = new Set(['openpty', 'forkpty', 'free']) + // Missing libutil.so.1 -> the pinned symbols would not resolve on the floor. + expect( + findMissingProviderDeps(importsPty, new Set(['libc.so.6'])).map((m) => m.symbol) + ).toEqual(['openpty', 'forkpty']) + // With libutil.so.1 present, no violation. + expect(findMissingProviderDeps(importsPty, new Set(['libc.so.6', 'libutil.so.1']))).toEqual([]) + // A binary that doesn't import the relocated symbols is never flagged. + expect(findMissingProviderDeps(new Set(['free']), new Set(['libc.so.6']))).toEqual([]) + }) +}) + +describe('collectNativeBinaries', () => { + it('collects only ELF .node/.so/executable files, skipping non-ELF and symlinks', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-glibc-collect-')) + try { + await mkdir(join(root, 'nested'), { recursive: true }) + await writeFile(join(root, 'addon.node'), ELF_HEADER) + await writeFile(join(root, 'nested', 'lib.so'), ELF_HEADER) + await writeFile(join(root, 'nested', 'lib.so.1'), ELF_HEADER) + await writeFile(join(root, 'orca-ide'), ELF_HEADER) // extensionless executable + await writeFile(join(root, 'script.js'), ELF_HEADER) // has extension, not native + await writeFile(join(root, 'text.node'), 'not an elf file') // native name, non-ELF + await writeFile(join(root, 'notes.md'), ELF_HEADER) + try { + await symlink(join(root, 'addon.node'), join(root, 'alias.node')) + } catch { + // Symlink creation can be restricted; the rest of the assertions still hold. + } + + const found = collectNativeBinaries(root).map((p) => p.slice(root.length + 1)) + expect(found).toContain('addon.node') + expect(found).toContain(join('nested', 'lib.so')) + expect(found).toContain(join('nested', 'lib.so.1')) + expect(found).toContain('orca-ide') + expect(found).not.toContain('script.js') + expect(found).not.toContain('text.node') + expect(found).not.toContain('notes.md') + expect(found).not.toContain('alias.node') + } finally { + await rm(root, { recursive: true, force: true }) + } + }) +}) + +describe.skipIf(process.platform === 'win32')('verifyLinuxGlibcFloor', () => { + // A stub objdump keyed on the inspected file's basename. Handles `-p` (Dynamic + // Section DT_NEEDED + Version References) and `-T` (undefined symbols). + // `*fail*` exits non-zero (fail-closed branch); `*noutil*` omits libutil.so.1 + // from DT_NEEDED; `*pty*` imports openpty. Match on basename only so the + // (random) temp-dir path cannot collide. + async function writeStubObjdump(dir) { + const stubPath = join(dir, 'objdump-stub.sh') + await writeFile( + stubPath, + [ + '#!/bin/sh', + 'if [ "$1" = "--version" ]; then echo "GNU objdump (stub)"; exit 0; fi', + 'f=$(basename "$2")', + 'case "$f" in', + ' *fail*) echo "objdump: $f: File format not recognized" >&2; exit 1 ;;', + 'esac', + 'if [ "$1" = "-T" ]; then', + ' case "$f" in', + ' *pty*) printf "0000 DF *UND* 0000 (GLIBC_2.2.5) openpty\\n" ;;', + ' esac', + ' exit 0', + 'fi', + 'printf "Dynamic Section:\\n NEEDED libc.so.6\\n"', + 'case "$f" in', + ' *noutil*) : ;;', + ' *) printf " NEEDED libutil.so.1\\n NEEDED libpthread.so.0\\n" ;;', + 'esac', + 'printf "\\nVersion References:\\n required from libc.so.6:\\n"', + 'case "$f" in', + ' *bad*) printf " 0x0 0x00 03 GLIBC_2.34\\n 0x0 0x00 04 GLIBC_2.2.5\\n" ;;', + ' *relr*) printf " 0x0 0x00 05 GLIBC_ABI_DT_RELR\\n 0x0 0x00 04 GLIBC_2.2.5\\n" ;;', + ' *weakonly*) printf " 0x0 0x02 06 GLIBC_2.32\\n 0x0 0x00 04 GLIBC_2.2.5\\n" ;;', + ' *cxx*|*sherpa*)', + ' printf " required from libstdc++.so.6:\\n 0x0 0x00 07 GLIBCXX_3.4.29\\n" ;;', + ' *) printf " 0x0 0x00 08 GLIBC_2.28\\n 0x0 0x00 04 GLIBC_2.2.5\\n" ;;', + 'esac', + 'exit 0' + ].join('\n'), + { mode: 0o755 } + ) + return stubPath + } + + it('throws listing binaries over the floor (glibc, DT_RELR marker, and libstdc++)', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-glibc-over-')) + try { + const objdumpPath = await writeStubObjdump(root) + await mkdir(join(root, 'app', 'resources'), { recursive: true }) + await writeFile(join(root, 'app', 'resources', 'bad-pty.node'), ELF_HEADER) + await writeFile(join(root, 'app', 'relr-exe.node'), ELF_HEADER) + await writeFile(join(root, 'app', 'cxx-addon.node'), ELF_HEADER) // launch-critical GLIBCXX_3.4.29 + await writeFile(join(root, 'app', 'good.so'), ELF_HEADER) + + let error + try { + verifyLinuxGlibcFloor(join(root, 'app'), { objdumpPath }) + } catch (e) { + error = e + } + expect(error).toBeDefined() + expect(error.message).toMatch(/bad-pty\.node needs GLIBC_2\.34/) + expect(error.message).toMatch(/relr-exe\.node needs GLIBC_ABI_DT_RELR/) + expect(error.message).toMatch(/cxx-addon\.node needs GLIBCXX_3\.4\.29/) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('throws when a pinned binary imports openpty without libutil.so.1 in DT_NEEDED', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-glibc-noutil-')) + try { + const objdumpPath = await writeStubObjdump(root) + await mkdir(join(root, 'app'), { recursive: true }) + // Below the version floor (so the version check passes) but libutil.so.1 + // is missing from DT_NEEDED — openpty would not resolve on Ubuntu 20.04. + await writeFile(join(root, 'app', 'noutil-pty.node'), ELF_HEADER) + + expect(() => verifyLinuxGlibcFloor(join(root, 'app'), { objdumpPath })).toThrow( + /noutil-pty\.node imports openpty but libutil\.so\.1 is not in DT_NEEDED/ + ) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('passes weak/at-floor needs and the exempt sherpa-onnx libstdc++ prebuilt', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-glibc-under-')) + try { + const objdumpPath = await writeStubObjdump(root) + const sherpaDir = join(root, 'app', 'node_modules', 'sherpa-onnx-linux-x64') + await mkdir(sherpaDir, { recursive: true }) + await writeFile(join(root, 'app', 'good-pty.node'), ELF_HEADER) + await writeFile(join(root, 'app', 'weakonly-lib.so'), ELF_HEADER) // weak GLIBC_2.32 → OK + await writeFile(join(root, 'app', 'orca-ide'), ELF_HEADER) + await writeFile(join(sherpaDir, 'sherpa-onnx.node'), ELF_HEADER) // GLIBCXX_3.4.29, exempt + + expect(() => verifyLinuxGlibcFloor(join(root, 'app'), { objdumpPath })).not.toThrow() + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('fails closed when objdump cannot read a binary (non-zero exit)', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-glibc-closed-')) + try { + const objdumpPath = await writeStubObjdump(root) + await mkdir(join(root, 'app'), { recursive: true }) + await writeFile(join(root, 'app', 'unreadable-fail.node'), ELF_HEADER) + + expect(() => verifyLinuxGlibcFloor(join(root, 'app'), { objdumpPath })).toThrow( + /objdump -p failed/ + ) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('is a no-op (no objdump needed) when there are no native binaries', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-glibc-empty-')) + try { + await mkdir(join(root, 'app'), { recursive: true }) + await writeFile(join(root, 'app', 'readme.txt'), 'no binaries here') + expect(() => + verifyLinuxGlibcFloor(join(root, 'app'), { objdumpPath: '/nonexistent/objdump' }) + ).not.toThrow() + } finally { + await rm(root, { recursive: true, force: true }) + } + }) +}) diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 0a79fb4d665..f8978764ccd 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -22,6 +22,7 @@ "../src/main/codex/codex-app-server-session.ts", "../src/main/codex/codex-config-mirror.ts", "../src/main/codex/codex-config-path-reference-rewrite.ts", + "../src/main/codex/codex-config-settings-upsert.ts", "../src/main/codex/codex-home-paths.ts", "../src/main/codex/codex-hook-identity.ts", "../src/main/codex/codex-hook-trust-grant.ts", @@ -35,6 +36,7 @@ "../src/main/codex/codex-user-hook-trust-rebase.ts", "../src/main/codex/codex-wsl-hook-install-plan.ts", "../src/main/codex/config-settings-promotion.ts", + "../src/main/codex/config-toml-key-path.ts", "../src/main/codex/config-toml-line-scan.ts", "../src/main/codex/config-toml-trust.ts", "../src/main/codex/hook-service.ts", diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 7cc5ece98ff..50ca9cde0e2 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 7.5m + + downloads: 7.6m @@ -15,7 +15,7 @@ downloads downloads - 7.5m - 7.5m + 7.6m + 7.6m diff --git a/docs/reference/headless-linux-server.md b/docs/reference/headless-linux-server.md index ef47c67916f..1f9ccd162cc 100644 --- a/docs/reference/headless-linux-server.md +++ b/docs/reference/headless-linux-server.md @@ -10,8 +10,10 @@ startup. Current Orca builds start Xvfb automatically for `orca serve` when no not required. When `DISPLAY` is set, Orca uses that display instead of starting a competing Xvfb process. -The supported deployment matrix covers Ubuntu 22.04 and 24.04 and current -Debian stable. Package names can differ on other Debian-derived releases. +The supported deployment matrix covers Ubuntu 20.04, 22.04, and 24.04 and +current Debian stable — anything with glibc 2.31 or newer (see +[Linux glibc compatibility](./linux-glibc-compatibility.md)). Package names can +differ on other Debian-derived releases. ## Ubuntu and Debian prerequisites diff --git a/docs/reference/linux-glibc-compatibility.md b/docs/reference/linux-glibc-compatibility.md new file mode 100644 index 00000000000..60d855c7372 --- /dev/null +++ b/docs/reference/linux-glibc-compatibility.md @@ -0,0 +1,99 @@ +# Linux glibc Compatibility + +Orca's Linux builds target **stock Ubuntu 20.04 and newer** — glibc 2.31 and +libstdc++ `GLIBCXX_3.4.28` (also Debian 11, RHEL 9), on both x64 and arm64. +Packaging enforces this floor automatically; keep it in mind when adding or +upgrading native dependencies. (The optional speech feature is the one +exception — see below.) + +## Why this needs attention + +A native module (`.node`) links against the glibc of the machine that compiled +it. Our release CI compiles node-pty from source on GitHub's `ubuntu-latest` +runner, whose glibc rises over time as the image is bumped. A binary compiled on +a newer glibc can reference symbol versions that do not exist on an older target, +and the dynamic loader then refuses to load it: + +``` +/lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found (required by .../pty.node) +``` + +Because the Orca main process loads node-pty at startup, that failure crashes the +whole app before a window appears — this is exactly what shipped in v1.4.150 and +broke launch on Ubuntu 20.04 ([#9902](https://github.com/stablyai/orca/issues/9902)). + +The specific trap is glibc's 2.32–2.34 "libpthread/libutil merge", which moved +several long-stable functions into libc under brand-new symbol versions: + +| Symbol | New version | node-pty use | +| ----------------- | ------------- | ----------------------- | +| `pthread_sigmask` | `GLIBC_2.32` | reset child signal mask | +| `openpty` | `GLIBC_2.34` | allocate the pty | +| `forkpty` | `GLIBC_2.34` | fork the shell | + +Electron itself (glibc 2.25) and the other bundled native modules +(`sherpa-onnx`, `@parcel/watcher`, both prebuilt on old glibc) stay well under +the floor, so node-pty was the sole blocker. + +## How we keep the floor + +**1. Pin the relocated symbols (the fix).** +[`config/patches/node-pty@1.1.0.patch`](../../config/patches/node-pty@1.1.0.patch) +adds a `.symver` shim in `src/unix/pty.cc` that binds `openpty`, `forkpty`, and +`pthread_sigmask` to their pre-merge version node — `GLIBC_2.2.5` on x64, +`GLIBC_2.17` on arm64 (each architecture's baseline glibc). glibc still ships +those as compatibility aliases, so the reference resolves on both new build hosts +and old targets. + +The catch: gcc defaults to `--as-needed` and, since the pinned symbols now +resolve from libc's compat aliases at build time, it drops `libutil`/`libpthread` +from `DT_NEEDED`. On the target those libraries are where the symbols actually +live, so the patch's `binding.gyp` `ldflags` force +`-Wl,--no-as-needed,-l:libutil.so.1,-l:libpthread.so.0` back into `DT_NEEDED`. +The shim is guarded by `#if defined(__linux__)`; macOS and Windows are untouched. + +**2. Gate packaging (the regression guard).** +[`config/scripts/verify-linux-glibc-floor.cjs`](../../config/scripts/verify-linux-glibc-floor.cjs) +runs in the electron-builder `afterPack` hook for Linux. It reads every bundled +native binary's version needs (`objdump -p` "Version References" — the +authoritative load-time list, which also captures symbol-less markers like +`GLIBC_ABI_DT_RELR`) and fails the build if any strong `GLIBC_`/`GLIBCXX_`/ +`CXXABI_` node is newer than stock Ubuntu 20.04 provides, naming the file and the +offending node. Weak needs are ignored (the loader tolerates them). It also +asserts the flip side of the `.symver` fix: any binary that imports +`openpty`/`forkpty` must keep `libutil.so.1` in `DT_NEEDED` — otherwise the +pinned `openpty@GLIBC_2.2.5` resolves from libc's compat alias at build time (so +the version check passes) yet fails to load on 20.04, where those functions live +only in libutil. A future runner bump, a new native dependency, or a dropped +ldflag therefore fails the release build instead of shipping a Linux app that +crashes on launch. + +> The gate is a static invariant, not an integration test. The load path was +> verified by hand for this fix (real Ubuntu 20.04, x64 + arm64: `require` +> node-pty and spawn a shell). A CI smoke test that loads the packaged +> `pty.node` in a glibc-2.31 container and spawns a shell is the recommended +> follow-up — it would make the load path self-verifying and stay valid even if +> the build ever moves to an old-glibc sysroot. + +The one carve-out is the `sherpa-onnx` speech prebuilt, which already requires +`GLIBCXX_3.4.29` (GCC 11). It loads lazily in the speech worker +(`src/main/speech/stt-worker.ts`), never at app launch, so it is exempt from the +libstdc++ floor — its glibc needs are still checked. Speech-to-text therefore +needs a host with libstdc++ from GCC 11+ (Ubuntu 21.10 / 22.04 LTS or newer); the +app itself still launches on stock 20.04. + +## Adding or upgrading a native dependency + +- Prefer packages that ship prebuilt binaries compiled against an old toolchain + (manylinux / `glibc 2.17`-class), like `@parcel/watcher`. +- For a module we compile from source, if the gate flags it, either pin the + offending symbols the way node-pty does, or build it in an old-glibc container. +- To check locally on a Linux host, list what a binary requires (skipping the + weak `0x02`-flagged needs the loader tolerates): + + ```bash + objdump -p path/to/module.node | sed -n '/Version References/,/^$/p' + ``` + + No strong `GLIBC_` node may exceed `2.31`, and no `GLIBCXX_`/`CXXABI_` node may + exceed `3.4.28`/`1.3.12` — what stock Ubuntu 20.04 ships. diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 11764a25189..71dde856ff0 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -1377,7 +1377,10 @@ export default function SessionScreen() { { terminal: handle, client: { id: deviceTokenRef.current!, type: 'mobile' as const }, - viewport: viewportRef.current ?? undefined, + viewport: nativeChatTerminalStream.mobileNativeChatSubscribeViewport( + covered, + viewportRef.current + ), capabilities: nativeChatTerminalStream.mobileNativeChatTerminalCapabilities(covered) }, (result) => { @@ -2455,6 +2458,7 @@ export default function SessionScreen() { terminalFrameHeightRef, viewportRef, viewportMeasuredRef, + nativeChatCoveredRef: showNativeChatRef, clientRef, deviceTokenRef, initializedHandlesRef, diff --git a/mobile/app/native-chat-settings.tsx b/mobile/app/native-chat-settings.tsx index bd358162d5b..1e2ebadd9ef 100644 --- a/mobile/app/native-chat-settings.tsx +++ b/mobile/app/native-chat-settings.tsx @@ -23,7 +23,7 @@ export default function NativeChatSettingsScreen() { > - Native chat + Chat UI DEFAULT VIEW Choose how supported agent sessions (Claude, Codex, and other chat-capable agents) open on - this device. Terminal shows the raw CLI; native chat shows a chat interface like the - desktop app. You can still switch any individual session from its long-press menu. + this device. Terminal shows the raw CLI; Chat UI shows a chat interface like the desktop + app. You can still switch any individual session from its long-press menu. - Open sessions in native chat + Open sessions in Chat UI {chatDefault ? 'On' : 'Off'} setDefaultView(next ? 'chat' : 'terminal')} trackColor={{ false: colors.bgRaised, true: colors.textSecondary }} diff --git a/mobile/app/settings.tsx b/mobile/app/settings.tsx index 7a406ed70ee..0e74b516a87 100644 --- a/mobile/app/settings.tsx +++ b/mobile/app/settings.tsx @@ -120,7 +120,7 @@ export default function SettingsScreen() { onPress={() => router.push('/native-chat-settings')} > - Native chat + Chat UI diff --git a/mobile/src/onboarding/MobileOnboardingPage.test.ts b/mobile/src/onboarding/MobileOnboardingPage.test.ts index f3683105160..a99052c3ec9 100644 --- a/mobile/src/onboarding/MobileOnboardingPage.test.ts +++ b/mobile/src/onboarding/MobileOnboardingPage.test.ts @@ -71,7 +71,7 @@ describe('MobileOnboardingPage', () => { it('renders the session choices and sends exactly one selected view', async () => { const callbacks = await renderPage('session-view') - act(() => button('Open sessions in native chat').props.onPress()) + act(() => button('Open sessions in Chat UI').props.onPress()) expect(callbacks.onSessionChoice).toHaveBeenCalledWith('chat') expect(callbacks.onNotificationChoice).not.toHaveBeenCalled() }) diff --git a/mobile/src/onboarding/MobileOnboardingPage.tsx b/mobile/src/onboarding/MobileOnboardingPage.tsx index f7bbff0cf0d..a5a6a07a3c6 100644 --- a/mobile/src/onboarding/MobileOnboardingPage.tsx +++ b/mobile/src/onboarding/MobileOnboardingPage.tsx @@ -51,7 +51,7 @@ export function MobileOnboardingPage({ {isSessionView - ? 'Choose whether supported agent sessions open in the terminal or native chat on this device. Press and hold a session tab to switch its view, or change the default later in Settings.' + ? 'Choose whether supported agent sessions open in the terminal or Chat UI on this device. Press and hold a session tab to switch its view, or change the default later in Settings.' : 'Get notified on this device when an agent needs your input or finishes a task.'} @@ -88,8 +88,8 @@ function SessionViewChoices({ return ( <> { @@ -16,7 +17,7 @@ describe('sendMobileNativeChatPermissionResponse', () => { deviceToken: 'phone', text: '1' }) - ).resolves.toBe(true) + ).resolves.toBe('accepted') expect(sendRequest).toHaveBeenCalledWith('terminal.send', { terminal: 'terminal', text: '1', @@ -24,4 +25,19 @@ describe('sendMobileNativeChatPermissionResponse', () => { client: { id: 'phone', type: 'mobile' } }) }) + + it('surfaces an ambiguous delivery as unknown instead of a definite failure', async () => { + const sendRequest = vi + .fn() + .mockRejectedValue(markRpcDeliveryUnknown(new Error('Connection closed'))) + + await expect( + sendMobileNativeChatPermissionResponse({ + client: { sendRequest } as unknown as RpcClient, + terminal: 'terminal', + deviceToken: null, + text: '1' + }) + ).resolves.toBe('unknown') + }) }) diff --git a/mobile/src/session/mobile-native-chat-permission-send.ts b/mobile/src/session/mobile-native-chat-permission-send.ts index 679be2d94ed..6c4fee4ff91 100644 --- a/mobile/src/session/mobile-native-chat-permission-send.ts +++ b/mobile/src/session/mobile-native-chat-permission-send.ts @@ -1,16 +1,19 @@ import { useCallback, type MutableRefObject } from 'react' import type { RpcClient } from '../transport/rpc-client' -import { sendMobileNativeChatMessage } from './mobile-native-chat-send' +import { + sendMobileNativeChatMessageWithOutcome, + type MobileNativeChatSendOutcome +} from './mobile-native-chat-send' export function sendMobileNativeChatPermissionResponse(args: { client: RpcClient terminal: string deviceToken: string | null text: string -}): Promise { +}): Promise { // Why: approval choices are already complete terminal control sequences; // appending Return changes both numbered choices and Escape denial. - return sendMobileNativeChatMessage({ + return sendMobileNativeChatMessageWithOutcome({ client: args.client, terminal: args.terminal, text: args.text, @@ -33,16 +36,20 @@ export function useMobileNativeChatPermissionSend(args: { args.onSendError('Response not sent (disconnected)') return false } - const accepted = await sendMobileNativeChatPermissionResponse({ + const outcome = await sendMobileNativeChatPermissionResponse({ client: args.client, terminal, deviceToken: args.deviceTokenRef.current, text }) - if (!accepted) { + if (outcome === 'unknown') { + // Why: the response may have been delivered (ack lost / path cutover) — + // a definite "not sent" would invite a double answer. + args.onSendError('Response unconfirmed — check chat before retrying') + } else if (outcome === 'rejected') { args.onSendError('Response not sent') } - return accepted + return outcome === 'accepted' }, [args.client, args.deviceTokenRef, args.enabled, args.handleRef, args.onSendError] ) diff --git a/mobile/src/session/mobile-native-chat-send.test.ts b/mobile/src/session/mobile-native-chat-send.test.ts index 2eea33ed41b..5384436ac1f 100644 --- a/mobile/src/session/mobile-native-chat-send.test.ts +++ b/mobile/src/session/mobile-native-chat-send.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' +import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client' import { sendMobileNativeChatMessage, sendMobileNativeChatMessageWithOutcome @@ -86,6 +87,32 @@ describe('sendMobileNativeChatMessage', () => { ).resolves.toBe(false) }) + it('reports an unknown outcome when a logical cutover interrupts the send', async () => { + const client = { + sendRequest: vi.fn().mockRejectedValue(new LogicalClientCutoverError()) + } as unknown as RpcClient + + await expect( + sendMobileNativeChatMessageWithOutcome({ client, terminal: 'term', text: 'hello' }) + ).resolves.toBe('unknown') + // The boolean wrapper still treats unknown as not-accepted (never retried here). + await expect( + sendMobileNativeChatMessage({ client, terminal: 'term', text: 'hello' }) + ).resolves.toBe(false) + }) + + it('treats a cross-bundle cutover error (matched by message) as unknown', async () => { + // Why: instanceof can miss across bundle copies, so cutover is also matched + // by its message — that path must still land on ambiguous, not rejected. + const client = { + sendRequest: vi.fn().mockRejectedValue(new Error('RPC interrupted by connection migration')) + } as unknown as RpcClient + + await expect( + sendMobileNativeChatMessageWithOutcome({ client, terminal: 'term', text: 'hello' }) + ).resolves.toBe('unknown') + }) + it('reports acceptance and host rejection as definite outcomes', async () => { const accepted = clientWithResponse({ id: 'request', diff --git a/mobile/src/session/mobile-native-chat-send.ts b/mobile/src/session/mobile-native-chat-send.ts index 64de7624c49..0e20931abcd 100644 --- a/mobile/src/session/mobile-native-chat-send.ts +++ b/mobile/src/session/mobile-native-chat-send.ts @@ -1,5 +1,6 @@ import type { RpcClient } from '../transport/rpc-client' import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' +import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client' import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response' type MobileTerminalClient = { @@ -15,9 +16,9 @@ type MobileNativeChatSendArgs = { mobileClient?: MobileTerminalClient } -/** 'unknown' = the RPC failed after the request hit the wire (relay drop or - * response timeout) — the desktop may have delivered the text and only the ack - * was lost, so callers must not present it as a definite send failure. */ +/** 'unknown' = the RPC failed without proof the request never reached the + * desktop (ack loss after a write, or a cutover that cannot tell whether the + * frame was written) — callers must not present it as a definite send failure. */ export type MobileNativeChatSendOutcome = 'accepted' | 'rejected' | 'unknown' export async function sendMobileNativeChatMessageWithOutcome( @@ -32,7 +33,14 @@ export async function sendMobileNativeChatMessageWithOutcome( }) return isTerminalSendRpcAccepted(response) ? 'accepted' : 'rejected' } catch (error) { - return isRpcDeliveryUnknown(error) ? 'unknown' : 'rejected' + // Why: a logical relay↔direct cutover rejects the in-flight send without + // knowing whether its frame reached the wire (the desktop may have delivered + // it), so treat it as delivery-ambiguous like physical ack-loss — never + // retry (double-send risk) and never a definite "not sent" that would hide + // a real delivery. + return isRpcDeliveryUnknown(error) || isLogicalClientCutoverError(error) + ? 'unknown' + : 'rejected' } } diff --git a/mobile/src/session/mobile-native-chat-terminal-stream.test.ts b/mobile/src/session/mobile-native-chat-terminal-stream.test.ts index cc92c6b3aca..9b751c5af75 100644 --- a/mobile/src/session/mobile-native-chat-terminal-stream.test.ts +++ b/mobile/src/session/mobile-native-chat-terminal-stream.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { isTerminalCoveredByNativeChat, + mobileNativeChatSubscribeViewport, mobileNativeChatTerminalCapabilities, resolveMobileNativeChatTerminalStreamAction } from './mobile-native-chat-terminal-stream' @@ -34,6 +35,17 @@ describe('mobile native-chat terminal stream lifecycle', () => { expect(mobileNativeChatTerminalCapabilities(false)).toEqual({ terminalBinaryStream: 1 }) }) + it('omits the viewport from a covered lease subscribe so the host keeps desktop dims', () => { + // Why: handleMobileSubscribe phone-fits the PTY whenever a viewport is present, + // even for a lease-only subscribe — entering chat must not resize the terminal. + expect(mobileNativeChatSubscribeViewport(true, { cols: 40, rows: 60 })).toBeUndefined() + expect(mobileNativeChatSubscribeViewport(false, { cols: 40, rows: 60 })).toEqual({ + cols: 40, + rows: 60 + }) + expect(mobileNativeChatSubscribeViewport(false, null)).toBeUndefined() + }) + it('records a cold-start cover before WebView readiness so return refreshes', () => { expect( resolveMobileNativeChatTerminalStreamAction({ diff --git a/mobile/src/session/mobile-native-chat-terminal-stream.ts b/mobile/src/session/mobile-native-chat-terminal-stream.ts index 387fe9c3580..d96ced2a6f6 100644 --- a/mobile/src/session/mobile-native-chat-terminal-stream.ts +++ b/mobile/src/session/mobile-native-chat-terminal-stream.ts @@ -35,3 +35,11 @@ export function mobileNativeChatTerminalCapabilities(covered: boolean): { ? { terminalBinaryStream: 1, mobileInputLeaseOnly: 1 } : { terminalBinaryStream: 1 } } + +// Why: a covered subscribe is only an input lease — carrying phone dims would make the host phone-fit a PTY native chat never renders. +export function mobileNativeChatSubscribeViewport( + covered: boolean, + viewport: { cols: number; rows: number } | null +): { cols: number; rows: number } | undefined { + return covered ? undefined : (viewport ?? undefined) +} diff --git a/mobile/src/session/use-mobile-native-chat-answer-send.test.ts b/mobile/src/session/use-mobile-native-chat-answer-send.test.ts index 0f3e7275976..680cccbea95 100644 --- a/mobile/src/session/use-mobile-native-chat-answer-send.test.ts +++ b/mobile/src/session/use-mobile-native-chat-answer-send.test.ts @@ -3,6 +3,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentType } from '../../../src/shared/native-chat-types' import type { RpcClient } from '../transport/rpc-client' +import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { MOBILE_NATIVE_CHAT_QUESTION_STEP_MS } from './mobile-native-chat-answer-stepping' import type { AskPrompt } from './mobile-native-chat-ask' import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send' @@ -243,6 +244,17 @@ describe('useMobileNativeChatAnswerSend', () => { expect(onSendError).toHaveBeenCalledWith('Answer not sent') }) + it('reports an ambiguous write as unconfirmed instead of a definite failure', async () => { + const onSendError = vi.fn() + const sendRequest = vi + .fn() + .mockRejectedValue(markRpcDeliveryUnknown(new Error('Connection closed'))) + await mount({ sendRequest } as unknown as RpcClient, onSendError) + + await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(false) + expect(onSendError).toHaveBeenCalledWith('Answer unconfirmed — check chat before retrying') + }) + it('rejects an empty selection without writing anything', async () => { const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) await mount({ sendRequest } as unknown as RpcClient, vi.fn()) diff --git a/mobile/src/session/use-mobile-native-chat-answer-send.ts b/mobile/src/session/use-mobile-native-chat-answer-send.ts index 937a3e0b21e..4cc58afdb87 100644 --- a/mobile/src/session/use-mobile-native-chat-answer-send.ts +++ b/mobile/src/session/use-mobile-native-chat-answer-send.ts @@ -9,7 +9,7 @@ import { type AskAnswerSelection, type AskPrompt } from './mobile-native-chat-ask' -import { sendMobileNativeChatMessage } from './mobile-native-chat-send' +import { sendMobileNativeChatMessageWithOutcome } from './mobile-native-chat-send' import { resolveNativeChatTranscriptAgent, shouldStepNativeChatAskAnswer @@ -102,7 +102,8 @@ export function useMobileNativeChatAnswerSend(args: { // A new answer supersedes any still-pending keystroke writes. cancelPending() const generation = generationRef.current - const sendTerminal = (body: string, enter: boolean): Promise => { + let sawUnknownOutcome = false + const sendTerminal = async (body: string, enter: boolean): Promise => { const activeRoute = activeRouteRef.current if ( !activeRoute.enabled || @@ -111,9 +112,9 @@ export function useMobileNativeChatAnswerSend(args: { activeRoute.streamIdentity !== streamIdentity || handleRef.current !== handle ) { - return Promise.resolve(false) + return false } - return sendMobileNativeChatMessage({ + const outcome = await sendMobileNativeChatMessageWithOutcome({ client, terminal: handle, text: body, @@ -122,6 +123,10 @@ export function useMobileNativeChatAnswerSend(args: { ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } : {}) }) + if (outcome === 'unknown') { + sawUnknownOutcome = true + } + return outcome === 'accepted' } const wait = (ms: number): Promise => new Promise((resolve) => { @@ -136,7 +141,14 @@ export function useMobileNativeChatAnswerSend(args: { }) const fail = (): false => { if (generationRef.current === generation) { - onSendError('Answer not sent') + // Why: keystrokes that may have landed (ack lost / path cutover) must + // not read as a definite failure — a blind resend could double-step + // the selector. + onSendError( + sawUnknownOutcome + ? 'Answer unconfirmed — check chat before retrying' + : 'Answer not sent' + ) } return false } diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 603073b1452..f10f41698f6 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -17,10 +17,7 @@ import { detectAgentPermission } from './mobile-native-chat-permission' import { parseAgentQuestion } from './mobile-native-chat-question' import { openMobileNativeChatFile } from './mobile-native-chat-open-file' import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send' -import { - sendMobileNativeChatMessage, - sendMobileNativeChatMessageWithOutcome -} from './mobile-native-chat-send' +import { sendMobileNativeChatMessageWithOutcome } from './mobile-native-chat-send' import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send' import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search' @@ -175,7 +172,7 @@ export function useMobileNativeChatController(args: { return false } cancelNativeChatAnswer() - const accepted = await sendMobileNativeChatMessage({ + const outcome = await sendMobileNativeChatMessageWithOutcome({ client, terminal: handle, text: String.fromCharCode(27), @@ -184,10 +181,14 @@ export function useMobileNativeChatController(args: { ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } : {}) }) - if (!accepted) { + if (outcome === 'unknown') { + // Why: the Escape may have landed (ack lost / path cutover) — a definite + // "not sent" would invite a second Escape into a changed prompt state. + onSendError('Cancel unconfirmed — check chat before retrying') + } else if (outcome === 'rejected') { onSendError('Cancel not sent') } - return accepted + return outcome === 'accepted' }, [ activeHandleRef, cancelNativeChatAnswer, diff --git a/mobile/src/tasks/worktree-create-retry.ts b/mobile/src/tasks/worktree-create-retry.ts index ed59cc6dd01..ccc46f2057b 100644 --- a/mobile/src/tasks/worktree-create-retry.ts +++ b/mobile/src/tasks/worktree-create-retry.ts @@ -1,6 +1,6 @@ import type { RpcClient } from '../transport/rpc-client' import type { RpcResponse, RpcSuccess } from '../transport/types' -import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client' +import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client' import { CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS, getClientWorktreeCreateCandidate, @@ -100,13 +100,6 @@ async function sendWorktreeCreateResilient( } } -function isLogicalClientCutoverError(error: unknown): boolean { - return ( - error instanceof LogicalClientCutoverError || - (error instanceof Error && error.message === 'RPC interrupted by connection migration') - ) -} - function defaultWorktreeCreateMutationId(): string { const randomPart = Math.random().toString(36).slice(2, 10) return `worktree-create:${Date.now().toString(36)}:${randomPart}` diff --git a/mobile/src/terminal/terminal-viewport-refit-state.ts b/mobile/src/terminal/terminal-viewport-refit-state.ts index 96995fd8ecd..dfa4daadf4c 100644 --- a/mobile/src/terminal/terminal-viewport-refit-state.ts +++ b/mobile/src/terminal/terminal-viewport-refit-state.ts @@ -7,6 +7,7 @@ export type TerminalViewportRefitTargetState = { expectedHandle: string currentRef: unknown expectedRef: unknown + nativeChatCovered: boolean disposed: boolean runSeq: number currentRunSeq: number @@ -94,6 +95,7 @@ export function isTerminalViewportRefitTargetCurrent( state: TerminalViewportRefitTargetState ): boolean { return ( + !state.nativeChatCovered && !state.disposed && state.runSeq === state.currentRunSeq && state.activeHandle === state.expectedHandle && diff --git a/mobile/src/terminal/terminal-viewport-refit.test.ts b/mobile/src/terminal/terminal-viewport-refit.test.ts index de63099155b..b472fb7847e 100644 --- a/mobile/src/terminal/terminal-viewport-refit.test.ts +++ b/mobile/src/terminal/terminal-viewport-refit.test.ts @@ -135,6 +135,18 @@ describe('terminal viewport refit', () => { expect(timerBody).toContain('if (!decision.shouldRefit)') }) + it('suppresses refits while native chat covers the active terminal', () => { + // Why: native chat renders the transcript, not the grid — a refit there would + // reflow the desktop PTY to phone dims the user never sees. + const timerStart = hookSource.indexOf('refitTimerRef.current = setTimeout(') + const coveredCheck = hookSource.indexOf('if (nativeChatCoveredRef.current)', timerStart) + const measureIndex = hookSource.indexOf('measureFitDimensions', timerStart) + expect(timerStart).toBeGreaterThanOrEqual(0) + expect(coveredCheck).toBeGreaterThan(timerStart) + expect(measureIndex).toBeGreaterThan(coveredCheck) + expect(sessionSource).toContain('nativeChatCoveredRef: showNativeChatRef') + }) + it('is wired into the session screen', () => { expect(sessionSource).toContain('useTerminalViewportRefit({') expect(sessionSource).toContain('tabStripVisible: terminals.length > 1') @@ -301,6 +313,7 @@ describe('terminal viewport refit', () => { expectedHandle: 'term-1', currentRef: expectedRef, expectedRef, + nativeChatCovered: false, disposed: false, runSeq: 2, currentRunSeq: 2 @@ -313,5 +326,8 @@ describe('terminal viewport refit', () => { ).toBe(false) expect(isTerminalViewportRefitTargetCurrent({ ...current, currentRunSeq: 3 })).toBe(false) expect(isTerminalViewportRefitTargetCurrent({ ...current, disposed: true })).toBe(false) + expect(isTerminalViewportRefitTargetCurrent({ ...current, nativeChatCovered: true })).toBe( + false + ) }) }) diff --git a/mobile/src/terminal/terminal-viewport-refit.ts b/mobile/src/terminal/terminal-viewport-refit.ts index 6194fabc675..8a9ab281455 100644 --- a/mobile/src/terminal/terminal-viewport-refit.ts +++ b/mobile/src/terminal/terminal-viewport-refit.ts @@ -23,6 +23,8 @@ type TerminalViewportRefitOptions = { terminalFrameHeightRef: RefObject viewportRef: RefObject viewportMeasuredRef: RefObject + // Why: while native chat covers the active terminal, a refit would push phone dims into a PTY nobody on this device is viewing. + nativeChatCoveredRef: RefObject clientRef: RefObject deviceTokenRef: RefObject initializedHandlesRef: RefObject> @@ -51,6 +53,7 @@ export function useTerminalViewportRefit( terminalFrameHeightRef, viewportRef, viewportMeasuredRef, + nativeChatCoveredRef, clientRef, deviceTokenRef, initializedHandlesRef, @@ -99,6 +102,10 @@ export function useTerminalViewportRefit( if (!handle) { return } + // Why: the trigger already marked the viewport stale, and the return-to-terminal resubscribe re-measures — refitting now would resize a covered PTY. + if (nativeChatCoveredRef.current) { + return + } const ref = terminalRefs.current.get(handle) if (!ref) { return @@ -109,6 +116,7 @@ export function useTerminalViewportRefit( expectedHandle: handle, currentRef: terminalRefs.current.get(handle), expectedRef: ref, + nativeChatCovered: nativeChatCoveredRef.current, disposed: disposedRef.current, runSeq, currentRunSeq: refitRunSeqRef.current @@ -171,6 +179,7 @@ export function useTerminalViewportRefit( terminalFrameHeightRef, viewportRef, viewportMeasuredRef, + nativeChatCoveredRef, clientRef, deviceTokenRef, initializedHandlesRef, diff --git a/mobile/src/transport/mobile-relay-rpc-session.test.ts b/mobile/src/transport/mobile-relay-rpc-session.test.ts index 9fa88dca9b8..54ae8387411 100644 --- a/mobile/src/transport/mobile-relay-rpc-session.test.ts +++ b/mobile/src/transport/mobile-relay-rpc-session.test.ts @@ -4,6 +4,7 @@ import { encodeBrowserScreencastFrame } from '../../../src/shared/browser-screencast-protocol' import { encodeTerminalStreamFrame, TerminalStreamOpcode } from './terminal-stream-protocol' +import { isRpcDeliveryUnknown } from './rpc-delivery-ambiguity' const fakes = vi.hoisted(() => ({ linkOptions: null as null | { @@ -202,6 +203,39 @@ describe('mobile relay RPC session', () => { fakes.linkOptions!.onError(new Error('relay transport error')) await expect(pending).rejects.toThrow('relay transport error') + // The frame reached the wire, so the failure must read as delivery-unknown. + await expect(pending.catch((error: unknown) => isRpcDeliveryUnknown(error))).resolves.toBe(true) expect(session.getState()).toBe('disconnected') }) + + it('marks in-flight requests delivery-unknown when the session closes', async () => { + const { session } = await authenticateSession() + const pending = session.sendRequest('terminal.send', { terminal: 'term', text: 'hi' }) + await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledOnce()) + session.close() + + await expect(pending).rejects.toThrow('Client closed') + await expect(pending.catch((error: unknown) => isRpcDeliveryUnknown(error))).resolves.toBe(true) + }) + + it('marks a relay RPC timeout delivery-unknown', async () => { + const { session } = await authenticateSession() + vi.useFakeTimers() + try { + const pending = session.sendRequest('terminal.send', { terminal: 'term', text: 'hi' }) + const outcome = pending.catch((error: unknown) => ({ + message: (error as Error).message, + unknown: isRpcDeliveryUnknown(error) + })) + // Let sendRequest pass its connected-check microtask and register the timer. + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(1_000) + await expect(outcome).resolves.toEqual({ + message: 'relay RPC timed out: terminal.send', + unknown: true + }) + } finally { + vi.useRealTimers() + } + }) }) diff --git a/mobile/src/transport/mobile-relay-rpc-session.ts b/mobile/src/transport/mobile-relay-rpc-session.ts index 5fe435ae0e5..a0e71a85c1b 100644 --- a/mobile/src/transport/mobile-relay-rpc-session.ts +++ b/mobile/src/transport/mobile-relay-rpc-session.ts @@ -6,6 +6,7 @@ import { import { MobileRelayE2eeLink } from './mobile-relay-e2ee-link' import { MobileRelayRpcStreams } from './mobile-relay-rpc-streams' import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel' +import { markRpcDeliveryUnknown } from './rpc-delivery-ambiguity' import { isRpcResponse } from './rpc-response-shape' import type { RpcClient } from './rpc-client' import type { ConnectionState, RpcResponse } from './types' @@ -148,7 +149,8 @@ export function connectMobileRelayRpcSession(args: { return new Promise((resolve, reject) => { const timer = setTimeout(() => { pending.delete(id) - reject(new Error(`relay RPC timed out: ${method}`)) + // Why: the frame was written long ago — the desktop may have processed it. + reject(markRpcDeliveryUnknown(new Error(`relay RPC timed out: ${method}`))) }, timeoutMs) pending.set(id, { resolve, reject, timer }) if (!sendFrame({ id, method, params })) { @@ -237,6 +239,13 @@ export function connectMobileRelayRpcSession(args: { } function rejectPending(error: Error): void { + if (pending.size === 0) { + return + } + // Why: pending entries only exist after their frame reached the authenticated + // link (sendFrame failures delete them synchronously), so the desktop may + // have processed them — mark the ambiguity for callers. + markRpcDeliveryUnknown(error) for (const request of pending.values()) { clearTimeout(request.timer) request.reject(error) diff --git a/mobile/src/transport/stable-logical-rpc-client.test.ts b/mobile/src/transport/stable-logical-rpc-client.test.ts index 8fe6dc25837..e0b8cb5eefe 100644 --- a/mobile/src/transport/stable-logical-rpc-client.test.ts +++ b/mobile/src/transport/stable-logical-rpc-client.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import type { ConnectionState, RpcResponse } from './types' import type { RpcClient } from './rpc-client' +import { isRpcDeliveryUnknown, markRpcDeliveryUnknown } from './rpc-delivery-ambiguity' import { createStableLogicalRpcClient, LogicalClientCutoverError @@ -56,10 +57,12 @@ function success(value: unknown): RpcResponse { function deferred() { let resolve!: (value: T) => void - const promise = new Promise((resolvePromise) => { + let reject!: (error: Error) => void + const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise + reject = rejectPromise }) - return { promise, resolve } + return { promise, resolve, reject } } describe('stable logical RPC client', () => { @@ -143,6 +146,44 @@ describe('stable logical RPC client', () => { await expect(client.sendRequest('status.get')).resolves.toEqual(success('next')) }) + it('lets the physical close settle in-flight requests on suspend, preserving delivery marks', async () => { + const session = new FakeSession('connected') + const inFlight = deferred() + session.sendRequest.mockReturnValue(inFlight.promise) + // Mirror the real physical contract: close() rejects post-write pendings + // with a delivery-unknown-marked error. + const closeError = markRpcDeliveryUnknown(new Error('Client closed')) + session.close.mockImplementation(() => inFlight.reject(closeError)) + const client = createStableLogicalRpcClient(session, 'relay') + const request = client.sendRequest('terminal.send', { terminal: 'term', text: 'hi' }) + + client.suspendActiveSession() + + await expect(request).rejects.toBe(closeError) + await expect(request.catch((error: unknown) => isRpcDeliveryUnknown(error))).resolves.toBe(true) + // New requests while suspended still fail definitively before any write. + await expect(client.sendRequest('status.get')).rejects.toThrow('Client suspended') + }) + + it('lets the physical close settle in-flight requests on close, keeping pre-write failures definite', async () => { + const session = new FakeSession('connected') + const inFlight = deferred() + session.sendRequest.mockReturnValue(inFlight.promise) + // A request still waiting for connect never wrote its frame — the physical + // layer rejects it unmarked and that must survive the logical close. + const preWriteError = new Error('Connection closed') + session.close.mockImplementation(() => inFlight.reject(preWriteError)) + const client = createStableLogicalRpcClient(session, 'lan') + const request = client.sendRequest('terminal.send', { terminal: 'term', text: 'hi' }) + + client.close() + + await expect(request).rejects.toBe(preWriteError) + await expect(request.catch((error: unknown) => isRpcDeliveryUnknown(error))).resolves.toBe( + false + ) + }) + it('closes a replacement that fails authentication and preserves the active session', async () => { const oldSession = new FakeSession('connected') const replacement = new FakeSession('connecting') diff --git a/mobile/src/transport/stable-logical-rpc-client.ts b/mobile/src/transport/stable-logical-rpc-client.ts index 90126376884..1fd19c9c01f 100644 --- a/mobile/src/transport/stable-logical-rpc-client.ts +++ b/mobile/src/transport/stable-logical-rpc-client.ts @@ -150,14 +150,13 @@ export function createStableLogicalRpcClient( closed = true activeStateUnsubscribe?.() activeStateUnsubscribe = null - for (const pending of pendingRequests) { - pending.reject(new Error('Client closed')) - } - pendingRequests.clear() for (const record of subscriptions.values()) { record.disposePhysical?.() } subscriptions.clear() + // Why: let the physical close settle in-flight requests — it knows which + // frames were written and marks those delivery-unknown; a blanket local + // reject would erase that distinction. activeSession.close() publishState('disconnected') }, @@ -169,14 +168,13 @@ export function createStableLogicalRpcClient( suspended = true activeStateUnsubscribe?.() activeStateUnsubscribe = null - for (const pending of pendingRequests) { - pending.reject(new Error('Client suspended')) - } - pendingRequests.clear() for (const record of subscriptions.values()) { record.disposePhysical?.() record.disposePhysical = null } + // Why: let the physical close settle in-flight requests — it knows which + // frames were written and marks those delivery-unknown (a suspend can cut + // over a half-open relay whose sends may already be delivered). activeSession.close() publishState('disconnected') }, diff --git a/package.json b/package.json index 1c02ffd66c8..ae3f9afa536 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "test:e2e:terminal-perf:html-report": "node config/scripts/generate-terminal-perf-html-report.mjs", "test:e2e:ssh-docker-perf": "node config/scripts/run-ssh-docker-perf-e2e.mjs", "test:e2e:ssh-docker-watcher-isolation": "node config/scripts/run-ssh-docker-watcher-isolation-e2e.mjs", + "test:e2e:nested-runtime-ssh": "node config/scripts/run-nested-runtime-ssh-e2e.mjs", "test:e2e:source-control-scale": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/source-control-large-file-count.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "win-update-e2e": "node tools/win-update-e2e/run.mjs", "win-crash-survival-e2e": "node tools/win-crash-survival-e2e/run.mjs", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e8adb6af64..ea1672e8937 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,7 +22,7 @@ patchedDependencies: hash: 9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c path: config/patches/@xterm__xterm@6.1.0-beta.287.patch node-pty@1.1.0: - hash: 407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282 + hash: 8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8 path: config/patches/node-pty@1.1.0.patch importers: @@ -64,7 +64,7 @@ importers: version: 3.3.1 node-pty: specifier: ^1.1.0 - version: 1.1.0(patch_hash=407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282) + version: 1.1.0(patch_hash=8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8) posthog-node: specifier: ^5.33.3 version: 5.33.3 @@ -11623,7 +11623,7 @@ snapshots: node-int64@0.4.0: {} - node-pty@1.1.0(patch_hash=407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282): + node-pty@1.1.0(patch_hash=8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8): dependencies: node-addon-api: 7.1.1 diff --git a/resources/skills/release-mapping.json b/resources/skills/release-mapping.json index 45d8f258fd0..006c78e0442 100644 --- a/resources/skills/release-mapping.json +++ b/resources/skills/release-mapping.json @@ -613,6 +613,19 @@ "orca-per-workspace-env": 4, "orchestration": 27 } + }, + { + "appVersion": "1.4.151-rc.2", + "skills": { + "computer-use": 8, + "linear-tickets": 10, + "orca-cli": 35, + "orca-emulator": 7, + "orca-emulator-android": 5, + "orca-linear": 8, + "orca-per-workspace-env": 5, + "orchestration": 28 + } } ] } diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts index db0744aaa86..097ec3534bc 100644 --- a/src/cli/runtime/client.ts +++ b/src/cli/runtime/client.ts @@ -126,7 +126,12 @@ export class RuntimeClient { runtime: { state: graphState === 'ready' ? 'ready' : 'graph_not_ready', reachable: true, - runtimeId: response.result.runtimeId + runtimeId: response.result.runtimeId, + ...(response.result.appVersion ? { appVersion: response.result.appVersion } : {}), + ...(response.result.remoteUpdateSupport + ? { remoteUpdateSupport: response.result.remoteUpdateSupport } + : {}), + ...(response.result.capabilities ? { capabilities: response.result.capabilities } : {}) }, graph: { state: graphState diff --git a/src/cli/runtime/status.ts b/src/cli/runtime/status.ts index 189297bf05f..53bb7f8da75 100644 --- a/src/cli/runtime/status.ts +++ b/src/cli/runtime/status.ts @@ -45,7 +45,12 @@ export async function getCliStatus( runtime: { state: graphState === 'ready' ? 'ready' : 'graph_not_ready', reachable: true, - runtimeId: response.result.runtimeId + runtimeId: response.result.runtimeId, + ...(response.result.appVersion ? { appVersion: response.result.appVersion } : {}), + ...(response.result.remoteUpdateSupport + ? { remoteUpdateSupport: response.result.remoteUpdateSupport } + : {}), + ...(response.result.capabilities ? { capabilities: response.result.capabilities } : {}) }, graph: { state: graphState diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts index 364fc05c508..530b20f1363 100644 --- a/src/cli/runtime/websocket-transport.test.ts +++ b/src/cli/runtime/websocket-transport.test.ts @@ -64,7 +64,15 @@ describe('CLI remote WebSocket transport', () => { }) it('accepts a bare pairing payload as well as the orca URL wrapper', async () => { - const runtime = await startTestRuntime('runtime-ws-2') + const runtime = await startTestRuntime('runtime-ws-2', { + appVersion: '1.5.0', + remoteUpdateSupport: { + installMode: 'unsupported-headless-serve', + automatic: false, + reason: 'manual-service-update-required' + }, + capabilities: ['updater.remote-control.v1'] + }) servers.push(runtime) const offer: PairingOffer = { v: 2, @@ -83,6 +91,11 @@ describe('CLI remote WebSocket transport', () => { expect(status.result.app).toEqual({ running: false, pid: null }) expect(status.result.runtime.reachable).toBe(true) expect(status.result.runtime.runtimeId).toBe('runtime-ws-2') + expect(status.result.runtime).toMatchObject({ + appVersion: '1.5.0', + remoteUpdateSupport: { automatic: false, reason: 'manual-service-update-required' }, + capabilities: ['updater.remote-control.v1'] + }) }) it('does not launch a local desktop app for remote-paired open', async () => { @@ -157,6 +170,13 @@ async function startTestRuntime( runtimeProtocolVersion?: number minCompatibleRuntimeClientVersion?: number desktopWindowStatus?: 'available' | 'openable' | 'initializing' | 'blocked' + appVersion?: string + remoteUpdateSupport?: { + installMode: 'unsupported-headless-serve' + automatic: false + reason: 'manual-service-update-required' + } + capabilities?: string[] } = {} ): Promise { const serverKeyPair = generateKeyPair() @@ -213,7 +233,10 @@ async function startTestRuntime( statusOverrides.runtimeProtocolVersion ?? RUNTIME_PROTOCOL_VERSION, minCompatibleRuntimeClientVersion: statusOverrides.minCompatibleRuntimeClientVersion ?? - MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + appVersion: statusOverrides.appVersion, + remoteUpdateSupport: statusOverrides.remoteUpdateSupport, + capabilities: statusOverrides.capabilities }, _meta: { runtimeId } } diff --git a/src/main/codex/codex-config-settings-upsert.ts b/src/main/codex/codex-config-settings-upsert.ts new file mode 100644 index 00000000000..963d46d239a --- /dev/null +++ b/src/main/codex/codex-config-settings-upsert.ts @@ -0,0 +1,322 @@ +import { + createTomlLineScanState, + getTomlTableHeader, + isTomlStructuralLine, + updateTomlLineScanState +} from './config-toml-line-scan' +import { parseTomlKeyPath, parseTomlTableHeaderPath } from './config-toml-key-path' + +const TUI_STRUCTURED_PREFIX = 'tui.' + +// Why: promoted [tui] settings are keyed by structured path (tui.) so their +// baseline/update entries can never collide with a top-level key of the same name. +export function tuiStructuredKey(key: string): string { + return `${TUI_STRUCTURED_PREFIX}${key}` +} + +export function isTuiStructuredKey(structuredKey: string): boolean { + return structuredKey.startsWith(TUI_STRUCTURED_PREFIX) +} + +export function tuiKeyFromStructuredKey(structuredKey: string): string { + return structuredKey.slice(TUI_STRUCTURED_PREFIX.length) +} + +// Why: promoted updates arrive keyed by structured path; the preamble and [tui] +// regions are disjoint, so a mixed batch (e.g. /model + a status-line change) +// composes in one rewrite — top-level keys land in the preamble, tui. +// entries wherever the [tui] placement rule puts them. +export function upsertPromotedSettingsInContent( + content: string, + updates: Map +): string { + const topLevelUpdates = new Map() + const tuiUpdates = new Map() + for (const [key, raw] of updates) { + if (isTuiStructuredKey(key)) { + tuiUpdates.set(tuiKeyFromStructuredKey(key), raw) + } else { + topLevelUpdates.set(key, raw) + } + } + let result = content + if (topLevelUpdates.size > 0) { + result = upsertTopLevelSettingsInContent(result, topLevelUpdates) + } + if (tuiUpdates.size > 0) { + result = upsertTuiSettingsInContent(result, tuiUpdates) + } + return result +} + +export function upsertTopLevelSettingsInContent( + content: string, + updates: Map +): string { + const lines = content.split('\n') + let state = createTomlLineScanState() + let preambleEnd = lines.length + const keyLineIndexes = new Map() + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] ?? '' + if (isTomlStructuralLine(state)) { + if (getTomlTableHeader(line)) { + preambleEnd = index + break + } + const parsed = parseTomlKeyPath(line) + const key = parsed?.segments.length === 1 ? parsed.segments[0] : null + if (parsed && line[parsed.end] === '=' && key && updates.has(key)) { + keyLineIndexes.set(key, index) + } + } + state = updateTomlLineScanState(state, line) + } + + // Why: CRLF configs keep a trailing \r after the split; new lines must use + // the file's existing endings or a Windows-owned config becomes mixed-EOL. + const usesCrlf = content.includes('\r\n') + const insertions: string[] = [] + for (const [key, raw] of updates) { + const existingIndex = keyLineIndexes.get(key) + const rendered = `${key} = ${raw}` + if (existingIndex !== undefined) { + lines[existingIndex] = lines[existingIndex]?.endsWith('\r') ? `${rendered}\r` : rendered + } else { + insertions.push(usesCrlf ? `${rendered}\r` : rendered) + } + } + if (insertions.length > 0) { + let insertAt = preambleEnd + while (insertAt > 0 && (lines[insertAt - 1] ?? '').trim() === '') { + insertAt -= 1 + } + if (insertAt === preambleEnd && preambleEnd < lines.length) { + insertions.push(usesCrlf ? '\r' : '') + } + lines.splice(insertAt, 0, ...insertions) + } + return joinPreservingTrailingNewline(lines, usesCrlf) +} + +type TuiPlacementScan = { + bareKeyIndexes: Map + dottedKeyIndexes: Map + hasBareTuiTable: boolean + hasDottedTuiKey: boolean + blocksNewTuiTable: boolean + blockedAbsentKeys: Set + bareBodyInsertIndex: number + lastDottedTuiIndex: number +} + +/** + * Upserts promoted `[tui]` keys (keyed by bare name) into the system config, + * placing each per the design's total placement rule: replace an existing key + * in place keeping its form; else insert bare into the first `[tui]` body; else + * dotted in the preamble beside existing dotted `tui.*` keys; else create one + * `[tui]` table at EOF for every key that reaches that branch. Rendering follows + * the destination — bare inside a table, dotted in the preamble — so no `tui` + * table is ever defined twice. + */ +export function upsertTuiSettingsInContent(content: string, updates: Map): string { + const lines = content.split('\n') + const scan = scanTuiPlacement(lines, updates) + const usesCrlf = content.includes('\r\n') + const bareBodyInserts: string[] = [] + const dottedPreambleInserts: string[] = [] + const newTableKeys: string[] = [] + + for (const [key, raw] of updates) { + const dottedIndex = scan.dottedKeyIndexes.get(key) + if (dottedIndex !== undefined) { + lines[dottedIndex] = withTrailingCr(lines[dottedIndex]!, `${tuiStructuredKey(key)} = ${raw}`) + continue + } + const bareIndex = scan.bareKeyIndexes.get(key) + if (bareIndex !== undefined) { + lines[bareIndex] = withTrailingCr(lines[bareIndex]!, `${key} = ${raw}`) + continue + } + // Why: adding a scalar beside an existing tui. descendant would turn valid TOML invalid. + if (scan.blockedAbsentKeys.has(key)) { + continue + } + if (scan.hasBareTuiTable) { + bareBodyInserts.push(`${key} = ${raw}`) + } else if (scan.hasDottedTuiKey) { + dottedPreambleInserts.push(`${tuiStructuredKey(key)} = ${raw}`) + } else if (!scan.blocksNewTuiTable) { + // Why: inline/array tui definitions block this branch because adding a + // plain [tui] beside either would make the config invalid. + newTableKeys.push(`${key} = ${raw}`) + } + } + + // Why: the config shape routes every absent key to the same branch, so at most + // one insert group is non-empty; still apply EOF→body→preamble so a splice + // never shifts a lower index a later splice depends on. + if (newTableKeys.length > 0) { + appendNewTuiTable(lines, newTableKeys, usesCrlf) + } + if (bareBodyInserts.length > 0) { + lines.splice( + scan.bareBodyInsertIndex, + 0, + ...bareBodyInserts.map((line) => withCrLine(line, usesCrlf)) + ) + } + if (dottedPreambleInserts.length > 0) { + lines.splice( + scan.lastDottedTuiIndex + 1, + 0, + ...dottedPreambleInserts.map((line) => withCrLine(line, usesCrlf)) + ) + } + return joinPreservingTrailingNewline(lines, usesCrlf) +} + +function scanTuiPlacement(lines: string[], updates: Map): TuiPlacementScan { + let state = createTomlLineScanState() + let inPreamble = true + let tuiTableSeen = false + let tuiBodyActive = false + let tuiBodyHeaderIndex = -1 + let tuiBodyEndIndex = -1 + let hasDottedTuiKey = false + let blocksNewTuiTable = false + let lastDottedTuiIndex = -1 + const bareKeyIndexes = new Map() + const dottedKeyIndexes = new Map() + const blockedAbsentKeys = new Set() + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] ?? '' + if (isTomlStructuralLine(state)) { + const header = getTomlTableHeader(line) + if (header) { + if (tuiBodyActive) { + tuiBodyEndIndex = index + tuiBodyActive = false + } + const table = parseTomlTableHeaderPath(header) + if ( + table && + !table.isArray && + table.segments.length === 1 && + table.segments[0] === 'tui' && + !tuiTableSeen + ) { + tuiTableSeen = true + tuiBodyActive = true + tuiBodyHeaderIndex = index + } + // Why: a root [[tui]] is already an array, so appending [tui] would + // redefine it and make an otherwise valid config unparseable. + if (table?.isArray && table.segments.length === 1 && table.segments[0] === 'tui') { + blocksNewTuiTable = true + } + const descendantKey = + table?.segments[0] === 'tui' && table.segments.length > 1 ? table.segments[1] : null + if (descendantKey && updates.has(descendantKey)) { + blockedAbsentKeys.add(descendantKey) + } + inPreamble = false + state = updateTomlLineScanState(state, line) + continue + } + if (inPreamble) { + // Why: any dotted `tui.*` key (allowlisted or not) already defines the + // implicit tui table, so a new `[tui]` table at EOF would duplicate it. + const parsed = parseTomlKeyPath(line) + const isAssignment = parsed && line[parsed.end] === '=' + if (isAssignment && parsed.segments[0] === 'tui' && parsed.segments.length > 1) { + hasDottedTuiKey = true + lastDottedTuiIndex = index + const promotedKey = parsed.segments.length === 2 ? parsed.segments[1] : null + if (promotedKey && updates.has(promotedKey)) { + dottedKeyIndexes.set(promotedKey, index) + } + const descendantKey = parsed.segments.length > 2 ? parsed.segments[1] : null + if (descendantKey && updates.has(descendantKey)) { + blockedAbsentKeys.add(descendantKey) + } + } else if (isAssignment && parsed.segments.length === 1 && parsed.segments[0] === 'tui') { + blocksNewTuiTable = true + } + } else if (tuiBodyActive) { + const parsed = parseTomlKeyPath(line) + const key = parsed?.segments.length === 1 ? parsed.segments[0] : null + if (parsed && line[parsed.end] === '=' && key && updates.has(key)) { + bareKeyIndexes.set(key, index) + } + const descendantKey = parsed && parsed.segments.length > 1 ? parsed.segments[0] : null + if (descendantKey && updates.has(descendantKey)) { + blockedAbsentKeys.add(descendantKey) + } + } + } + state = updateTomlLineScanState(state, line) + } + if (tuiBodyActive) { + tuiBodyEndIndex = lines.length + } + + return { + bareKeyIndexes, + dottedKeyIndexes, + hasBareTuiTable: tuiTableSeen, + hasDottedTuiKey, + blocksNewTuiTable, + blockedAbsentKeys, + bareBodyInsertIndex: computeBareBodyInsertIndex(lines, tuiBodyHeaderIndex, tuiBodyEndIndex), + lastDottedTuiIndex + } +} + +// Why: TOML forbids adding bare keys to `[tui]` after a `[tui.*]` subtable opens, +// so absent keys land at the body's end — before trailing blanks and before the +// next header — which is the only valid spot. +function computeBareBodyInsertIndex( + lines: string[], + headerIndex: number, + endIndex: number +): number { + if (headerIndex === -1) { + return -1 + } + let insertAt = endIndex + while (insertAt > headerIndex + 1 && (lines[insertAt - 1] ?? '').trim() === '') { + insertAt -= 1 + } + return insertAt +} + +function appendNewTuiTable(lines: string[], keyRenders: string[], usesCrlf: boolean): void { + let appendAt = lines.length + while (appendAt > 0 && (lines[appendAt - 1] ?? '').trim() === '') { + appendAt -= 1 + } + // Why: separate the new table from prior content with a blank line, unless the + // file was empty/blank, where a leading blank would be spurious. + const block = appendAt > 0 ? ['', '[tui]', ...keyRenders] : ['[tui]', ...keyRenders] + lines.splice(appendAt, 0, ...block.map((line) => withCrLine(line, usesCrlf))) +} + +function withTrailingCr(originalLine: string, rendered: string): string { + return originalLine.endsWith('\r') ? `${rendered}\r` : rendered +} + +function withCrLine(rendered: string, usesCrlf: boolean): string { + return usesCrlf ? `${rendered}\r` : rendered +} + +// Why: a missing trailing newline is restored in the file's own EOL so a +// preamble-only or table-appended rewrite matches the source's newline behavior. +function joinPreservingTrailingNewline(lines: string[], usesCrlf: boolean): string { + const result = lines.join('\n') + if (result.endsWith('\n') || result.length === 0) { + return result + } + return result.endsWith('\r') ? `${result}\n` : `${result}${usesCrlf ? '\r\n' : '\n'}` +} diff --git a/src/main/codex/config-settings-promotion.test.ts b/src/main/codex/config-settings-promotion.test.ts index f6ceabc362f..1f372fa7c84 100644 --- a/src/main/codex/config-settings-promotion.test.ts +++ b/src/main/codex/config-settings-promotion.test.ts @@ -19,7 +19,7 @@ import type * as CodexFsUtils from '../codex-accounts/fs-utils' const { homedirMock, promotionTestState } = vi.hoisted(() => ({ homedirMock: vi.fn<() => string>(), - promotionTestState: { failAtomicWrite: false } + promotionTestState: { failAtomicWrite: false, atomicWritePaths: [] as string[] } })) vi.mock('node:os', async (importOriginal) => { @@ -35,6 +35,7 @@ vi.mock('../codex-accounts/fs-utils', async (importOriginal) => { return { ...actual, writeFileAtomically: (...args: Parameters) => { + promotionTestState.atomicWritePaths.push(args[0]) if (promotionTestState.failAtomicWrite) { throw new Error('injected atomic write failure') } @@ -44,7 +45,15 @@ vi.mock('../codex-accounts/fs-utils', async (importOriginal) => { }) import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror' -import { upsertTopLevelSettingsInContent } from './config-settings-promotion' +import { + upsertPromotedSettingsInContent, + upsertTopLevelSettingsInContent +} from './codex-config-settings-upsert' + +// The exact [tui] block codex 0.144.6 writes via config/batchWrite (all four +// promoted keys single-line, theme a string). +const CODEX_TUI_BLOCK = + '[tui]\nstatus_line = ["model-with-reasoning", "task-progress"]\nstatus_line_use_colors = true\nterminal_title = ["model"]\ntheme = "dark-photon"\n' let tmpHome: string let userDataDir: string @@ -57,6 +66,7 @@ beforeEach(() => { process.env.ORCA_USER_DATA_PATH = userDataDir homedirMock.mockReturnValue(tmpHome) promotionTestState.failAtomicWrite = false + promotionTestState.atomicWritePaths.length = 0 // Why: promotion writes into homedir()/.codex — if the mock ever fails to // intercept, these tests would rewrite the developer's real Codex config. if (homedir() !== tmpHome) { @@ -117,6 +127,13 @@ function simulateCodexSettingWrite(key: string, rawValue: string): void { writeFileSync(runtimeConfigPath(), next, 'utf-8') } +// Codex reads then rewrites the whole runtime config; simulate that by writing +// a known runtime config directly (its EOL is normalized by the mirror anyway). +function setRuntimeConfig(content: string): void { + mkdirSync(runtimeHomeDir(), { recursive: true }) + writeFileSync(runtimeConfigPath(), content, 'utf-8') +} + function simulateCodexSettingRemoval(key: string): void { const existing = readFileSync(runtimeConfigPath(), 'utf-8') const linePattern = new RegExp(`^${key}[ \\t]*=.*\\n?`, 'm') @@ -402,6 +419,363 @@ describe('codex settings write-back promotion', () => { }) }) +describe('codex [tui] settings write-back promotion', () => { + it('promotes a runtime [tui] block (codex 0.144.6 shape) into ~/.codex and survives the remirror', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + + // The user customizes the status line/theme inside Orca-launched Codex. + writeFileSync(runtimeConfigPath(), `${readRuntimeConfig()}\n${CODEX_TUI_BLOCK}`, 'utf-8') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe(`model = "gpt-5"\n\n${CODEX_TUI_BLOCK}`) + const runtime = readRuntimeConfig() + expect(runtime).toContain('status_line = ["model-with-reasoning", "task-progress"]') + expect(runtime).toContain('status_line_use_colors = true') + expect(runtime).toContain('terminal_title = ["model"]') + expect(runtime).toContain('theme = "dark-photon"') + + const settledSystem = readSystemConfig() + const settledRuntime = readRuntimeConfig() + syncSystemConfigIntoManagedCodexHome() + expect(readSystemConfig()).toBe(settledSystem) + expect(readRuntimeConfig()).toBe(settledRuntime) + }) + + it('replaces a promoted key in an existing [tui] table, leaving non-promoted neighbors untouched', () => { + writeSystemConfig('model = "gpt-5"\n\n[tui]\nanimations = true\ntheme = "dark"\n') + syncSystemConfigIntoManagedCodexHome() + + setRuntimeConfig('model = "gpt-5"\n\n[tui]\nanimations = true\ntheme = "light"\n') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe( + 'model = "gpt-5"\n\n[tui]\nanimations = true\ntheme = "light"\n' + ) + }) + + it('promotes a changed status_line array value', () => { + writeSystemConfig('model = "gpt-5"\n\n[tui]\nstatus_line = ["model"]\n') + syncSystemConfigIntoManagedCodexHome() + + setRuntimeConfig( + 'model = "gpt-5"\n\n[tui]\nstatus_line = ["model-with-reasoning", "task-progress"]\n' + ) + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe( + 'model = "gpt-5"\n\n[tui]\nstatus_line = ["model-with-reasoning", "task-progress"]\n' + ) + }) + + it('promotes a model change and a status-line change in one pass into their regions', () => { + writeSystemConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark-photon"\n') + syncSystemConfigIntoManagedCodexHome() + + setRuntimeConfig('model = "o4"\n\n[tui]\ntheme = "dark-photon"\nstatus_line = ["model"]\n') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe( + 'model = "o4"\n\n[tui]\ntheme = "dark-photon"\nstatus_line = ["model"]\n' + ) + }) + + it('detects and replaces a dotted-form system tui key without creating a [tui] table', () => { + writeSystemConfig('model = "gpt-5"\ntui.theme = "dark"\n') + syncSystemConfigIntoManagedCodexHome() + + // toml_edit preserves the dotted form when codex rewrites the value. + setRuntimeConfig('model = "gpt-5"\ntui.theme = "light"\n') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('model = "gpt-5"\ntui.theme = "light"\n') + expect(readSystemConfig()).not.toContain('[tui]') + }) + + it('promotes through a quoted tui table without creating a duplicate table', () => { + writeSystemConfig('model = "gpt-5"\n\n["tui"]\ntheme = "dark"\n') + syncSystemConfigIntoManagedCodexHome() + + setRuntimeConfig('model = "gpt-5"\n\n["tui"]\ntheme = "light"\n') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('model = "gpt-5"\n\n["tui"]\ntheme = "light"\n') + expect(readSystemConfig()).not.toContain('\n[tui]\n') + }) + + it('inserts a second dotted tui key beside an existing dotted-only tui config', () => { + writeSystemConfig('model = "gpt-5"\ntui.theme = "dark"\n') + syncSystemConfigIntoManagedCodexHome() + + setRuntimeConfig('model = "gpt-5"\ntui.theme = "dark"\ntui.status_line = ["model"]\n') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe( + 'model = "gpt-5"\ntui.theme = "dark"\ntui.status_line = ["model"]\n' + ) + expect(readSystemConfig()).not.toContain('[tui]') + }) + + it('inserts dotted beside a non-promoted dotted tui key instead of creating a [tui] table', () => { + // Why: any dotted tui.* key already defines the implicit tui table, so a + // fresh [tui] table at EOF would be a duplicate-definition parse error. + writeSystemConfig('model = "gpt-5"\ntui.pet = "cat"\n') + syncSystemConfigIntoManagedCodexHome() + + setRuntimeConfig('model = "gpt-5"\ntui.pet = "cat"\ntui.theme = "dark-photon"\n') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('model = "gpt-5"\ntui.pet = "cat"\ntui.theme = "dark-photon"\n') + expect(readSystemConfig()).not.toContain('[tui]') + }) + + it('creates a [tui] table at EOF when the only tui presence is a subtable', () => { + writeSystemConfig('model = "gpt-5"\n\n[tui.notifications]\nenabled = true\n') + syncSystemConfigIntoManagedCodexHome() + + setRuntimeConfig( + 'model = "gpt-5"\n\n[tui.notifications]\nenabled = true\n\n[tui]\nstatus_line = ["model"]\n' + ) + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe( + 'model = "gpt-5"\n\n[tui.notifications]\nenabled = true\n\n[tui]\nstatus_line = ["model"]\n' + ) + }) + + it('creates exactly one [tui] table for two keys promoted in one pass', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + + setRuntimeConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark-photon"\nstatus_line = ["model"]\n') + syncSystemConfigIntoManagedCodexHome() + + const system = readSystemConfig() + expect(system.match(/^\[tui\]$/gm)?.length).toBe(1) + expect(system).toBe( + 'model = "gpt-5"\n\n[tui]\nstatus_line = ["model"]\ntheme = "dark-photon"\n' + ) + }) + + it('lets an outside ~/.codex [tui] edit win over a conflicting in-Codex tui change', () => { + writeSystemConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark"\n') + syncSystemConfigIntoManagedCodexHome() + + setRuntimeConfig('model = "gpt-5"\n\n[tui]\ntheme = "in-codex"\n') + writeSystemConfig('model = "gpt-5"\n\n[tui]\ntheme = "outside-edit"\n') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('model = "gpt-5"\n\n[tui]\ntheme = "outside-edit"\n') + expect(readRuntimeConfig()).toContain('theme = "outside-edit"') + }) + + it('does not promote a [tui] key deletion', () => { + writeSystemConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark"\n') + syncSystemConfigIntoManagedCodexHome() + + setRuntimeConfig('model = "gpt-5"\n\n[tui]\n') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toContain('theme = "dark"') + }) + + it('inserts a promoted key into a CRLF system [tui] table preserving CRLF', () => { + writeSystemConfig('model = "gpt-5"\r\n\r\n[tui]\r\ntheme = "dark"\r\n') + syncSystemConfigIntoManagedCodexHome() + + setRuntimeConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark"\nstatus_line = ["model"]\n') + syncSystemConfigIntoManagedCodexHome() + + const system = readSystemConfig() + expect(system).toContain('status_line = ["model"]\r\n') + expect(system).toBe( + 'model = "gpt-5"\r\n\r\n[tui]\r\ntheme = "dark"\r\nstatus_line = ["model"]\r\n' + ) + }) + + it('never appends a [tui] table when the system config defines tui inline', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + + // In-Codex tui change racing an outside edit that adds an inline tui table: + // appending [tui] would make the system config unparseable, so the change + // is dropped instead. + setRuntimeConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark-photon"\n') + writeSystemConfig('model = "gpt-5"\ntui = { animations = false }\n') + promotionTestState.atomicWritePaths.length = 0 + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('model = "gpt-5"\ntui = { animations = false }\n') + expect(promotionTestState.atomicWritePaths).not.toContain(systemConfigPath()) + }) + + it('ignores an allowlisted key nested under a [tui.*] subtable', () => { + writeSystemConfig('model = "gpt-5"\n\n[tui.notifications]\ntheme = "should-not-promote"\n') + syncSystemConfigIntoManagedCodexHome() + + setRuntimeConfig('model = "gpt-5"\n\n[tui.notifications]\ntheme = "changed-in-subtable"\n') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe( + 'model = "gpt-5"\n\n[tui.notifications]\ntheme = "should-not-promote"\n' + ) + }) +}) + +describe('upsertPromotedSettingsInContent', () => { + it('replaces a bare key in place inside the [tui] table', () => { + expect( + upsertPromotedSettingsInContent( + '[tui]\ntheme = "dark"\n', + new Map([['tui.theme', '"light"']]) + ) + ).toBe('[tui]\ntheme = "light"\n') + }) + + it('inserts a bare key at the end of the [tui] body, before a subtable', () => { + expect( + upsertPromotedSettingsInContent( + '[tui]\ntheme = "dark"\n\n[tui.notifications]\nenabled = true\n', + new Map([['tui.status_line', '["model"]']]) + ) + ).toBe( + '[tui]\ntheme = "dark"\nstatus_line = ["model"]\n\n[tui.notifications]\nenabled = true\n' + ) + }) + + it('replaces a dotted preamble tui key in place, keeping the dotted form', () => { + expect( + upsertPromotedSettingsInContent('tui.theme = "dark"\n', new Map([['tui.theme', '"light"']])) + ).toBe('tui.theme = "light"\n') + }) + + it('inserts a dotted tui key beside an existing dotted tui key', () => { + expect( + upsertPromotedSettingsInContent( + 'tui.theme = "dark"\n\n[features]\nx = 1\n', + new Map([['tui.status_line', '["model"]']]) + ) + ).toBe('tui.theme = "dark"\ntui.status_line = ["model"]\n\n[features]\nx = 1\n') + }) + + it('creates a [tui] table from empty content', () => { + expect(upsertPromotedSettingsInContent('', new Map([['tui.theme', '"dark"']]))).toBe( + '[tui]\ntheme = "dark"\n' + ) + }) + + it('drops an absent key instead of appending [tui] beside an inline tui table', () => { + expect( + upsertPromotedSettingsInContent( + 'tui = { animations = false }\n', + new Map([['tui.theme', '"dark"']]) + ) + ).toBe('tui = { animations = false }\n') + }) + + it('drops an absent key beside a quoted inline tui table', () => { + expect( + upsertPromotedSettingsInContent( + '"tui" = { animations = false }\n', + new Map([['tui.theme', '"dark"']]) + ) + ).toBe('"tui" = { animations = false }\n') + }) + + it('inserts beside a quoted dotted tui key instead of appending a table', () => { + expect( + upsertPromotedSettingsInContent('"tui" . "pet" = "cat"\n', new Map([['tui.theme', '"dark"']])) + ).toBe('"tui" . "pet" = "cat"\ntui.theme = "dark"\n') + }) + + it('creates a [tui] super-table at EOF after a [tui.*] subtable', () => { + expect( + upsertPromotedSettingsInContent( + '[tui.notifications]\nenabled = true\n', + new Map([['tui.theme', '"dark"']]) + ) + ).toBe('[tui.notifications]\nenabled = true\n\n[tui]\ntheme = "dark"\n') + }) + + it('drops an absent scalar that would redefine an existing tui key table', () => { + expect( + upsertPromotedSettingsInContent( + '[tui."theme"]\nvariant = "dark"\n', + new Map([['tui.theme', '"light"']]) + ) + ).toBe('[tui."theme"]\nvariant = "dark"\n') + }) + + it('drops an absent scalar that would redefine a dotted tui key table', () => { + expect( + upsertPromotedSettingsInContent( + 'tui.theme.variant = "dark"\n', + new Map([['tui.theme', '"light"']]) + ) + ).toBe('tui.theme.variant = "dark"\n') + }) + + it('does not mistake a dotted tui key inside an array table for a root key', () => { + expect( + upsertPromotedSettingsInContent( + '[[profiles]]\ntui.theme = "profile-theme"\n', + new Map([['tui.theme', '"root-theme"']]) + ) + ).toBe('[[profiles]]\ntui.theme = "profile-theme"\n\n[tui]\ntheme = "root-theme"\n') + }) + + it('does not append a table beside a root tui array-of-tables', () => { + expect( + upsertPromotedSettingsInContent( + '[[tui]]\ntheme = "array-theme"\n', + new Map([['tui.theme', '"root-theme"']]) + ) + ).toBe('[[tui]]\ntheme = "array-theme"\n') + }) + + it('does not append a table beside a quoted root tui array-of-tables', () => { + expect( + upsertPromotedSettingsInContent( + '[["tui"]]\ntheme = "array-theme"\n', + new Map([['tui.theme', '"root-theme"']]) + ) + ).toBe('[["tui"]]\ntheme = "array-theme"\n') + }) + + it('creates one [tui] table for multiple keys reaching the new-table branch', () => { + expect( + upsertPromotedSettingsInContent( + '', + new Map([ + ['tui.status_line', '["model"]'], + ['tui.theme', '"dark"'] + ]) + ) + ).toBe('[tui]\nstatus_line = ["model"]\ntheme = "dark"\n') + }) + + it('routes a mixed top-level + tui batch to its two regions in one rewrite', () => { + expect( + upsertPromotedSettingsInContent( + 'model = "gpt-5"\n\n[tui]\ntheme = "dark"\n', + new Map([ + ['model', '"o4"'], + ['tui.theme', '"light"'] + ]) + ) + ).toBe('model = "o4"\n\n[tui]\ntheme = "light"\n') + }) + + it('inserts into a CRLF [tui] table with CRLF endings', () => { + expect( + upsertPromotedSettingsInContent( + '[tui]\r\ntheme = "dark"\r\n', + new Map([['tui.status_line', '["model"]']]) + ) + ).toBe('[tui]\r\ntheme = "dark"\r\nstatus_line = ["model"]\r\n') + }) +}) + describe('upsertTopLevelSettingsInContent', () => { it('writes into empty content', () => { expect(upsertTopLevelSettingsInContent('', new Map([['model', '"x"']]))).toBe('model = "x"\n') @@ -428,6 +802,12 @@ describe('upsertTopLevelSettingsInContent', () => { ).toBe('# keep\nmodel = "new"\n\n[t]\nk = 1\n') }) + it('replaces a quoted top-level key instead of adding its bare equivalent', () => { + expect( + upsertTopLevelSettingsInContent('"model" = "old"\n', new Map([['model', '"new"']])) + ).toBe('model = "new"\n') + }) + it('inserts with CRLF endings into CRLF content', () => { expect( upsertTopLevelSettingsInContent('[features]\r\nhooks = true\r\n', new Map([['model', '"x"']])) diff --git a/src/main/codex/config-settings-promotion.ts b/src/main/codex/config-settings-promotion.ts index a7a1add6857..8a194fef99e 100644 --- a/src/main/codex/config-settings-promotion.ts +++ b/src/main/codex/config-settings-promotion.ts @@ -18,6 +18,8 @@ import { isTomlStructuralLine, updateTomlLineScanState } from './config-toml-line-scan' +import { parseTomlKeyPath, parseTomlTableHeaderPath } from './config-toml-key-path' +import { tuiStructuredKey, upsertPromotedSettingsInContent } from './codex-config-settings-upsert' // Why: the mirror reverts in-Codex config changes each launch; promotion salvages them by diffing the last baseline. @@ -29,6 +31,45 @@ export const PROMOTED_CODEX_SETTING_KEYS = [ 'sandbox_mode' ] as const +// Why: the [tui] keys the Codex TUI's user-facing pickers persist (status line, +// terminal title, theme). Like the top-level list, every key here gets written +// into the user's real ~/.codex/config.toml on promotion — grow it deliberately. +export const PROMOTED_CODEX_TUI_SETTING_KEYS = [ + 'status_line', + 'status_line_use_colors', + 'terminal_title', + 'theme' +] as const + +// Why: promotion diffs and upserts operate on structured keys — top-level keys +// keep their bare name, [tui] keys are namespaced tui. so their baseline +// entries cannot collide with a top-level key of the same name. +const PROMOTED_STRUCTURED_KEYS: readonly string[] = [ + ...PROMOTED_CODEX_SETTING_KEYS, + ...PROMOTED_CODEX_TUI_SETTING_KEYS.map(tuiStructuredKey) +] + +function isPromotedTuiKey(key: string): boolean { + return (PROMOTED_CODEX_TUI_SETTING_KEYS as readonly string[]).includes(key) +} + +// Returns the structured tui key a scanned line's key represents, or null. In +// the preamble it recognizes the dotted `tui.` form a user may hand-author; +// inside the first `[tui]` table body it recognizes the bare `` form Codex +// writes. Both map to the same structured key so either config shape promotes. +function matchTuiStructuredKey( + keyPath: string[], + inPreamble: boolean, + tuiBodyActive: boolean +): string | null { + if (inPreamble) { + const tuiKey = keyPath.length === 2 && keyPath[0] === 'tui' ? keyPath[1] : null + return tuiKey && isPromotedTuiKey(tuiKey) ? tuiStructuredKey(tuiKey) : null + } + const tuiKey = keyPath.length === 1 ? keyPath[0] : null + return tuiBodyActive && tuiKey && isPromotedTuiKey(tuiKey) ? tuiStructuredKey(tuiKey) : null +} + type TopLevelSettingValue = { raw: string // Why: a multiline string/array value can't be replaced line-by-line, so it's excluded from promotion. @@ -70,24 +111,67 @@ function readSettingsBaseline(runtimeHomePath: string): Map | nu } } -// Why: only top-level preamble keys are scanned; rewriting nested [profiles.*] tables isn't worth the risk here. -function readTopLevelSettingValues(configPath: string): Map { +function matchPromotedStructuredKey( + line: string, + inPreamble: boolean, + tuiBodyActive: boolean +): { structuredKey: string; raw: string } | null { + const parsed = parseTomlKeyPath(line) + if (!parsed || line[parsed.end] !== '=') { + return null + } + const raw = line.slice(parsed.end + 1).trim() + const topLevelKey = parsed.segments.length === 1 ? parsed.segments[0] : null + if ( + inPreamble && + topLevelKey && + (PROMOTED_CODEX_SETTING_KEYS as readonly string[]).includes(topLevelKey) + ) { + return { structuredKey: topLevelKey, raw } + } + const tuiKey = matchTuiStructuredKey(parsed.segments, inPreamble, tuiBodyActive) + return tuiKey ? { structuredKey: tuiKey, raw } : null +} + +// Why: top-level preamble scalars keep the historical behavior; [tui] keys are +// collected from the first bare [tui] table body or the dotted preamble form, +// keyed by structured path. Any table header (including [tui.*] subtables) ends +// the [tui] body, and [profiles.*]/other tables are still ignored. +function readPromotedSettingValues(configPath: string): Map { const result = new Map() if (!existsSync(configPath)) { return result } const lines = readFileSync(configPath, 'utf-8').split('\n') let state = createTomlLineScanState() + let inPreamble = true + let tuiTableSeen = false + let tuiBodyActive = false for (const line of lines) { if (isTomlStructuralLine(state)) { - if (getTomlTableHeader(line)) { - break + const header = getTomlTableHeader(line) + if (header) { + const table = parseTomlTableHeaderPath(header) + tuiBodyActive = + table !== null && + !table.isArray && + table.segments.length === 1 && + table.segments[0] === 'tui' && + !tuiTableSeen + if (tuiBodyActive) { + tuiTableSeen = true + } + inPreamble = false + state = updateTomlLineScanState(state, line) + continue } - const match = /^[ \t]*([A-Za-z0-9_-]+)[ \t]*=[ \t]*(.*?)[ \t\r]*$/.exec(line) - const key = match?.[1] - if (key && (PROMOTED_CODEX_SETTING_KEYS as readonly string[]).includes(key)) { + const matched = matchPromotedStructuredKey(line, inPreamble, tuiBodyActive) + if (matched) { const nextState = updateTomlLineScanState(state, line) - result.set(key, { raw: match?.[2] ?? '', multiline: !isTomlStructuralLine(nextState) }) + result.set(matched.structuredKey, { + raw: matched.raw, + multiline: !isTomlStructuralLine(nextState) + }) state = nextState continue } @@ -109,7 +193,7 @@ export function snapshotCodexRuntimeSettingsBaseline( const runtimeTomlPath = join(runtimeHomePath, 'config.toml') // Why: record an empty baseline even for a missing runtime config, so Codex's first write still diffs and promotes. const settings: Record = {} - for (const [key, value] of readTopLevelSettingValues(runtimeTomlPath)) { + for (const [key, value] of readPromotedSettingValues(runtimeTomlPath)) { if (!value.multiline) { settings[key] = value.raw } @@ -173,10 +257,9 @@ function promoteCodexRuntimeSettingsToSystemUnsafe(homes: CodexSettingsPromotion if (!baseline) { return } - const runtimeValues = readTopLevelSettingValues(runtimeTomlPath) - const systemValues = readTopLevelSettingValues(systemTomlPath) - const updates = new Map() - for (const key of PROMOTED_CODEX_SETTING_KEYS) { + const runtimeValues = readPromotedSettingValues(runtimeTomlPath) + const changedRuntimeValues = new Map() + for (const key of PROMOTED_STRUCTURED_KEYS) { const runtime = runtimeValues.get(key) if (!runtime || runtime.multiline) { continue @@ -185,6 +268,14 @@ function promoteCodexRuntimeSettingsToSystemUnsafe(homes: CodexSettingsPromotion // Orca mirrored this value and nothing touched it since — not a change. continue } + changedRuntimeValues.set(key, runtime.raw) + } + if (changedRuntimeValues.size === 0) { + return + } + const systemValues = readPromotedSettingValues(systemTomlPath) + const updates = new Map() + for (const [key, runtimeRaw] of changedRuntimeValues) { const system = systemValues.get(key) if (system?.multiline) { continue @@ -193,7 +284,7 @@ function promoteCodexRuntimeSettingsToSystemUnsafe(homes: CodexSettingsPromotion if (system?.raw !== baseline.get(key)) { continue } - updates.set(key, runtime.raw) + updates.set(key, runtimeRaw) } if (updates.size === 0) { return @@ -205,7 +296,10 @@ function promoteCodexRuntimeSettingsToSystemUnsafe(homes: CodexSettingsPromotion mkdirSync(dirname(writeTarget.path), { recursive: true, mode: 0o700 }) const targetExists = existsSync(writeTarget.path) const systemContent = targetExists ? readFileSync(writeTarget.path, 'utf-8') : '' - const nextContent = upsertTopLevelSettingsInContent(systemContent, updates) + const nextContent = upsertPromotedSettingsInContent(systemContent, updates) + if (nextContent === systemContent) { + return + } if (targetExists && parseWslUncPath(writeTarget.path)) { // Why: \\wsl$ 9P symlink metadata is unreliable; write through the existing file to preserve the WSL-side inode. writeFileSync(writeTarget.path, nextContent, 'utf-8') @@ -252,55 +346,3 @@ function resolveDanglingSymlinkTarget(linkPath: string): string { // Why: replacing any link in a cycle would destroy dotfile-manager state; abort instead. throw new Error(`Codex config symlink cycle at ${linkPath}`) } - -export function upsertTopLevelSettingsInContent( - content: string, - updates: Map -): string { - const lines = content.split('\n') - let state = createTomlLineScanState() - let preambleEnd = lines.length - const keyLineIndexes = new Map() - for (let index = 0; index < lines.length; index += 1) { - const line = lines[index] ?? '' - if (isTomlStructuralLine(state)) { - if (getTomlTableHeader(line)) { - preambleEnd = index - break - } - const match = /^[ \t]*([A-Za-z0-9_-]+)[ \t]*=/.exec(line) - if (match?.[1] && updates.has(match[1])) { - keyLineIndexes.set(match[1], index) - } - } - state = updateTomlLineScanState(state, line) - } - - // Why: match the file's existing EOL (CRLF split leaves a trailing \r) so a Windows config doesn't go mixed-EOL. - const usesCrlf = content.includes('\r\n') - const insertions: string[] = [] - for (const [key, raw] of updates) { - const existingIndex = keyLineIndexes.get(key) - const rendered = `${key} = ${raw}` - if (existingIndex !== undefined) { - lines[existingIndex] = lines[existingIndex]?.endsWith('\r') ? `${rendered}\r` : rendered - } else { - insertions.push(usesCrlf ? `${rendered}\r` : rendered) - } - } - if (insertions.length > 0) { - let insertAt = preambleEnd - while (insertAt > 0 && (lines[insertAt - 1] ?? '').trim() === '') { - insertAt -= 1 - } - if (insertAt === preambleEnd && preambleEnd < lines.length) { - insertions.push(usesCrlf ? '\r' : '') - } - lines.splice(insertAt, 0, ...insertions) - } - const result = lines.join('\n') - if (result.endsWith('\n') || result.length === 0) { - return result - } - return result.endsWith('\r') ? `${result}\n` : `${result}${usesCrlf ? '\r\n' : '\n'}` -} diff --git a/src/main/codex/config-toml-key-path.ts b/src/main/codex/config-toml-key-path.ts new file mode 100644 index 00000000000..25eaa4c98c6 --- /dev/null +++ b/src/main/codex/config-toml-key-path.ts @@ -0,0 +1,67 @@ +import { parseTomlSingleLineStringValue } from './config-toml-line-scan' + +export type ParsedTomlKeyPath = { + segments: string[] + end: number +} + +export type ParsedTomlTableHeaderPath = ParsedTomlKeyPath & { + isArray: boolean +} + +export function parseTomlTableHeaderPath(header: string): ParsedTomlTableHeaderPath | null { + const trimmed = header.trim() + let source: string + let isArray: boolean + if (trimmed.startsWith('[[')) { + if (!trimmed.endsWith(']]')) { + return null + } + source = trimmed.slice(2, -2) + isArray = true + } else { + if (!trimmed.startsWith('[') || !trimmed.endsWith(']') || trimmed.endsWith(']]')) { + return null + } + source = trimmed.slice(1, -1) + isArray = false + } + const parsed = parseTomlKeyPath(source) + if (!parsed || parsed.end !== source.length) { + return null + } + return { ...parsed, isArray } +} + +export function parseTomlKeyPath(source: string, offset = 0): ParsedTomlKeyPath | null { + const segments: string[] = [] + let index = skipTomlKeyWhitespace(source, offset) + while (index < source.length) { + const quoted = parseTomlSingleLineStringValue(source, index) + if (quoted) { + segments.push(quoted.value) + index = quoted.end + } else { + const bare = /^[A-Za-z0-9_-]+/.exec(source.slice(index)) + if (!bare) { + return null + } + segments.push(bare[0]) + index += bare[0].length + } + index = skipTomlKeyWhitespace(source, index) + if (source[index] !== '.') { + return { segments, end: index } + } + index = skipTomlKeyWhitespace(source, index + 1) + } + return null +} + +function skipTomlKeyWhitespace(source: string, offset: number): number { + let index = offset + while (source[index] === ' ' || source[index] === '\t') { + index += 1 + } + return index +} diff --git a/src/main/index.ts b/src/main/index.ts index c1e5c6d1d96..e98003b2051 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -56,7 +56,16 @@ import { registerAppMenu, rebuildAppMenu } from './menu/register-app-menu' -import { checkForUpdatesFromMenu, isQuittingForUpdate, resolveUpdateInstallMode } from './updater' +import { + checkForRemoteServerUpdate, + checkForUpdatesFromMenu, + downloadRemoteServerUpdate, + getRemoteServerUpdaterSnapshot, + installRemoteServerUpdate, + isQuittingForUpdate, + resolveUpdateInstallMode +} from './updater' +import { configureRemoteServerUpdater } from './runtime/remote-server-updater' import type { TuiAgent, UpdateCheckOptions } from '../shared/types' import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics' import { @@ -454,6 +463,12 @@ const devAgentHookEndpointNamespace = devInstanceIdentity.isDev installUncaughtPipeErrorGuard() // Why: expose the app version via process.env so main and the forked daemon can set TERM_PROGRAM_VERSION without importing electron. process.env.ORCA_APP_VERSION = app.getVersion() +configureRemoteServerUpdater({ + getSnapshot: getRemoteServerUpdaterSnapshot, + check: checkForRemoteServerUpdate, + download: downloadRemoteServerUpdate, + install: installRemoteServerUpdate +}) patchPackagedProcessPath() // Why: the sync seed above covers early IPC (homebrew/nix); the async login-shell probe below (packaged only) then adds the user's rc PATH. if (app.isPackaged && process.platform !== 'win32') { @@ -2261,6 +2276,11 @@ app.whenReady().then(async () => { }) // Why: parallel E2E Electron instances would race the fixed port (EADDRINUSE); port 0 gives each a random OS-assigned port. const isE2E = Boolean(process.env.ORCA_E2E_USER_DATA_DIR) + const requestedE2EWsPort = process.env.ORCA_E2E_RUNTIME_WS_PORT + const e2eWsPort = requestedE2EWsPort === undefined ? 0 : Number(requestedE2EWsPort) + if (isE2E && (!Number.isInteger(e2eWsPort) || e2eWsPort < 0 || e2eWsPort > 65_535)) { + throw new Error(`Invalid ORCA_E2E_RUNTIME_WS_PORT value: ${requestedE2EWsPort}`) + } // Why: pin dev to 6769 so `pnpm dev` doesn't race packaged Orca on 6768 and fall back to a random port, breaking deterministic mobile pairing/repro (STA-1511). const devWsPort = is.dev && !isE2E ? 6769 : undefined let serveOptions: ServeOptions | null = null @@ -2278,7 +2298,7 @@ app.whenReady().then(async () => { // Why: mobile pairing needs the stable pre-setName() path (getCanonicalUserDataPath), not a late app.getPath('userData') that drops paired devices across restarts. userDataPath: getCanonicalUserDataPath(), enableWebSocket: true, - ...(isE2E ? { wsPort: 0 } : {}), + ...(isE2E ? { wsPort: e2eWsPort } : {}), ...(devWsPort !== undefined ? { wsPort: devWsPort } : {}), ...(serveOptions?.wsPort !== undefined ? { diff --git a/src/main/ipc/ephemeral-vm-runtime-handlers.ts b/src/main/ipc/ephemeral-vm-runtime-handlers.ts index f46dbb22e5d..c76aacd25b6 100644 --- a/src/main/ipc/ephemeral-vm-runtime-handlers.ts +++ b/src/main/ipc/ephemeral-vm-runtime-handlers.ts @@ -30,6 +30,7 @@ import { removeRuntimeOwnedSshTarget } from '../ephemeral-vm-runtime-ssh' import { getRecipeRepo, getRuntimeRecipeContext } from './ephemeral-vm-recipe-context' +import { invalidateRuntimeEnvironmentTransport } from './runtime-environments' export type EphemeralVmCleanupCommandResult = { runtimeId: string @@ -192,6 +193,7 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void { updateEnvironmentFromPairingCode(userDataPath, runtime.runtimeEnvironmentId, { pairingCode }) + invalidateRuntimeEnvironmentTransport(runtime.runtimeEnvironmentId) } const connection = getEphemeralVmRecipeResultConnection(result.runtime.recipeResult) if (!result.skipped && connection.type === 'ssh') { diff --git a/src/main/ipc/ephemeral-vm.test.ts b/src/main/ipc/ephemeral-vm.test.ts index 071155271d3..765b1471115 100644 --- a/src/main/ipc/ephemeral-vm.test.ts +++ b/src/main/ipc/ephemeral-vm.test.ts @@ -12,14 +12,16 @@ const { getPathMock, connectRuntimeOwnedSshTargetMock, disconnectRuntimeOwnedSshTargetMock, - removeRuntimeOwnedSshTargetMock + removeRuntimeOwnedSshTargetMock, + invalidateRuntimeEnvironmentTransportMock } = vi.hoisted(() => ({ handleMock: vi.fn(), removeHandlerMock: vi.fn(), getPathMock: vi.fn(), connectRuntimeOwnedSshTargetMock: vi.fn(), disconnectRuntimeOwnedSshTargetMock: vi.fn(), - removeRuntimeOwnedSshTargetMock: vi.fn() + removeRuntimeOwnedSshTargetMock: vi.fn(), + invalidateRuntimeEnvironmentTransportMock: vi.fn() })) vi.mock('electron', () => ({ @@ -38,6 +40,10 @@ vi.mock('../ephemeral-vm-runtime-ssh', () => ({ removeRuntimeOwnedSshTarget: removeRuntimeOwnedSshTargetMock })) +vi.mock('./runtime-environments', () => ({ + invalidateRuntimeEnvironmentTransport: invalidateRuntimeEnvironmentTransportMock +})) + import { registerEphemeralVmHandlers } from './ephemeral-vm' const tempDirs: string[] = [] @@ -98,6 +104,7 @@ describe('registerEphemeralVmHandlers', () => { connectRuntimeOwnedSshTargetMock.mockReset() disconnectRuntimeOwnedSshTargetMock.mockReset() removeRuntimeOwnedSshTargetMock.mockReset() + invalidateRuntimeEnvironmentTransportMock.mockReset() connectRuntimeOwnedSshTargetMock.mockResolvedValue({ targetId: 'runtime-ssh-orca-instance-1', target: { @@ -493,6 +500,10 @@ describe('registerEphemeralVmHandlers', () => { expect(suspended).toEqual(expect.objectContaining({ status: 'suspended' })) expect(readFileSync(join(repoPath, 'suspend-mode.txt'), 'utf8')).toBe('suspend') + invalidateRuntimeEnvironmentTransportMock.mockImplementationOnce((environmentId: string) => { + const environment = listEnvironments(userDataPath).find((entry) => entry.id === environmentId) + expect(environment?.endpoints[0]?.endpoint).toBe('wss://resumed.example.com') + }) const resumed = await handlers.get('ephemeralVm:resumeWorkspace')?.(null, { workspaceId: 'workspace-1' } as never) @@ -507,6 +518,9 @@ describe('registerEphemeralVmHandlers', () => { (entry) => entry.id === provisioned.environment.id ) expect(environment?.endpoints[0]?.endpoint).toBe('wss://resumed.example.com') + expect(invalidateRuntimeEnvironmentTransportMock).toHaveBeenCalledWith( + provisioned.environment.id + ) }) it('returns a copyable cleanup command for a persisted runtime', async () => { diff --git a/src/main/ipc/filesystem-import-ssh-directory.ts b/src/main/ipc/filesystem-import-ssh-directory.ts index bb8abd027f4..d4dc794a7cc 100644 --- a/src/main/ipc/filesystem-import-ssh-directory.ts +++ b/src/main/ipc/filesystem-import-ssh-directory.ts @@ -45,7 +45,8 @@ export async function uploadSshImportDirectory( localDir: string, remoteDir: string, rootRealPath: string, - remotePathFlavor: RemotePathFlavor + remotePathFlavor: RemotePathFlavor, + assertCurrent?: () => void ): Promise { await assertLocalUploadPathInsideRoot(rootRealPath, localDir) const entries = await readdir(localDir, { withFileTypes: true }) @@ -63,6 +64,7 @@ export async function uploadSshImportDirectory( } if (statResult.isDirectory()) { + assertCurrent?.() await provider.createDirNoClobber(remotePath) await uploadSshImportDirectory( provider, @@ -70,10 +72,12 @@ export async function uploadSshImportDirectory( localPath, remotePath, rootRealPath, - remotePathFlavor + remotePathFlavor, + assertCurrent ) continue } + assertCurrent?.() await uploadSession.uploadFile(localPath, remotePath, { exclusive: true }) } } diff --git a/src/main/ipc/filesystem-import-ssh-ops.test.ts b/src/main/ipc/filesystem-import-ssh-ops.test.ts index 3046cbb620e..2c36fd9f2bb 100644 --- a/src/main/ipc/filesystem-import-ssh-ops.test.ts +++ b/src/main/ipc/filesystem-import-ssh-ops.test.ts @@ -34,6 +34,10 @@ vi.mock('fs/promises', () => ({ vi.mock('./ssh', () => ({ getSshConnectionManager: getConnMgrMock })) import { registerFilesystemMutationHandlers } from './filesystem-mutations' +import { + advanceSshConnectionGeneration, + resetSshConnectionGenerations +} from '../ssh/ssh-connection-generation' import { registerSshFilesystemProvider, unregisterSshFilesystemProvider @@ -112,7 +116,15 @@ describe('fs:importExternalPaths — SSH operations', () => { }) } const invoke = (args: Record) => - handlers.get('fs:importExternalPaths')!(null, args) as Promise<{ + handlers.get('fs:importExternalPaths')!(null, { + ...args, + ...(typeof args.connectionId === 'string' + ? { + expectedSshTargetId: args.expectedSshTargetId ?? args.connectionId, + expectedSshConnectionGeneration: args.expectedSshConnectionGeneration ?? 0 + } + : {}) + }) as Promise<{ results: Record[] }> @@ -145,6 +157,42 @@ describe('fs:importExternalPaths — SSH operations', () => { afterEach(() => { unregisterSshFilesystemProvider(connId) + resetSshConnectionGenerations() + }) + + it('rejects a staged upload when a restarted HUB reaches the same target counter', async () => { + resetSshConnectionGenerations(71) + const stagedGeneration = advanceSshConnectionGeneration(connId) + const sourcePath = path.resolve('/tmp/dropped/restart.txt') + lstatMock.mockImplementation(async (candidate: string) => { + if (candidate !== sourcePath) { + throw enoent() + } + resetSshConnectionGenerations(72) + advanceSshConnectionGeneration(connId) + return { + size: 12, + ino: 1, + dev: 1, + isFile: () => true, + isDirectory: () => false, + isSymbolicLink: () => false + } + }) + + const { results } = await invoke({ + sourcePaths: [sourcePath], + destDir, + connectionId: connId, + expectedSshTargetId: connId, + expectedSshConnectionGeneration: stagedGeneration + }) + + expect(results[0]).toMatchObject({ + status: 'failed', + reason: 'SSH connection changed; refresh and try again' + }) + expect(uploadSession.uploadFile).not.toHaveBeenCalled() }) it('deconflicts file names via provider stat', async () => { diff --git a/src/main/ipc/filesystem-import-ssh.test.ts b/src/main/ipc/filesystem-import-ssh.test.ts index f834dd904ad..6a5d502b14c 100644 --- a/src/main/ipc/filesystem-import-ssh.test.ts +++ b/src/main/ipc/filesystem-import-ssh.test.ts @@ -47,6 +47,7 @@ import { registerSshFilesystemProvider, unregisterSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { resetSshConnectionGenerations } from '../ssh/ssh-connection-generation' const store = { getRepos: () => [ @@ -113,12 +114,22 @@ describe('fs:importExternalPaths — SSH routing & connection', () => { }) } const invoke = (args: Record) => - handlers.get('fs:importExternalPaths')!(null, args) as Promise<{ + handlers.get('fs:importExternalPaths')!( + null, + typeof args.connectionId === 'string' + ? { + ...args, + expectedSshTargetId: args.connectionId, + expectedSshConnectionGeneration: 0 + } + : args + ) as Promise<{ results: Record[] }> beforeEach(() => { handlers.clear() + resetSshConnectionGenerations() ;[ handleMock, lstatMock, diff --git a/src/main/ipc/filesystem-import-ssh.ts b/src/main/ipc/filesystem-import-ssh.ts index afaabd179ba..39ac01af2a8 100644 --- a/src/main/ipc/filesystem-import-ssh.ts +++ b/src/main/ipc/filesystem-import-ssh.ts @@ -19,7 +19,7 @@ export async function importExternalPathsSsh( sourcePaths: string[], destDir: string, connectionId: string, - options?: { ensureDir?: boolean } + options?: { ensureDir?: boolean; assertCurrent?: () => void } ): Promise<{ results: ImportItemResult[] }> { if (sourcePaths.length === 0) { return { results: [] } @@ -45,7 +45,7 @@ export async function importExternalPathsSsh( // Why: terminal-drop staging needs `${worktree}/.orca/drops` to exist // before the first upload. .orca/ is reserved as Orca-owned remote state; // see docs/terminal-drop-ssh.md. - await ensureDropStagingDir(provider, destDir) + await ensureDropStagingDir(provider, destDir, options.assertCurrent) } const results: ImportItemResult[] = [] @@ -53,6 +53,7 @@ export async function importExternalPathsSsh( if (!provider.openFileUploadSession) { throw new Error('Remote file upload is unavailable. Reconnect the SSH target and retry.') } + options?.assertCurrent?.() const uploadSession = await provider.openFileUploadSession() // Why: filename legality follows the remote filesystem, not the client's OS. const remotePathFlavor: RemotePathFlavor = isWindowsAbsolutePathLike(destDir) @@ -66,7 +67,8 @@ export async function importExternalPathsSsh( sourcePath, destDir, reservedNames, - remotePathFlavor + remotePathFlavor, + options?.assertCurrent ) results.push(result) if (result.status === 'imported') { @@ -89,7 +91,8 @@ async function importOneSourceSsh( sourcePath: string, destDir: string, reservedNames: Set, - remotePathFlavor: RemotePathFlavor + remotePathFlavor: RemotePathFlavor, + assertCurrent?: () => void ): Promise { const resolvedSource = resolve(sourcePath) @@ -145,11 +148,20 @@ async function importOneSourceSsh( return { sourcePath, status: 'skipped', reason: 'symlink' } } - const finalName = await deconflictName(provider, destDir, originalName, reservedNames) + // Why: local inspection can outlive a HUB SSH session; revalidate before the first remote write. + assertCurrent?.() + const finalName = await deconflictName( + provider, + destDir, + originalName, + reservedNames, + assertCurrent + ) const destPath = `${destDir}/${finalName}` const renamed = finalName !== originalName if (isDir) { + assertCurrent?.() await provider.createDirNoClobber(destPath) createdDestDir = destPath await uploadSshImportDirectory( @@ -158,9 +170,11 @@ async function importOneSourceSsh( resolvedSource, destPath, rootRealPath!, - remotePathFlavor + remotePathFlavor, + assertCurrent ) } else { + assertCurrent?.() await uploadSession.uploadFile(resolvedSource, destPath, { exclusive: true }) } @@ -175,7 +189,12 @@ async function importOneSourceSsh( if (createdDestDir) { // Why: local directory imports roll back partial output; SSH imports // should not leave the no-clobber root after a nested upload failure. - await provider.deletePath(createdDestDir, true).catch(() => {}) + try { + assertCurrent?.() + await provider.deletePath(createdDestDir, true) + } catch { + // Best effort; a replacement session must never inherit cleanup from the retired owner. + } } return { sourcePath, @@ -189,8 +208,10 @@ async function deconflictName( provider: IFilesystemProvider, destDir: string, originalName: string, - reservedNames: Set + reservedNames: Set, + assertCurrent?: () => void ): Promise { + assertCurrent?.() if ( !(await remotePathExists(provider, `${destDir}/${originalName}`)) && !reservedNames.has(originalName) @@ -204,6 +225,7 @@ async function deconflictName( const ext = hasMeaningfulExt ? originalName.slice(dotIndex) : '' let candidate = `${stem} copy${ext}` + assertCurrent?.() if ( !(await remotePathExists(provider, `${destDir}/${candidate}`)) && !reservedNames.has(candidate) @@ -214,6 +236,7 @@ async function deconflictName( let counter = 2 while (counter < 10000) { candidate = `${stem} copy ${counter}${ext}` + assertCurrent?.() if ( !(await remotePathExists(provider, `${destDir}/${candidate}`)) && !reservedNames.has(candidate) @@ -228,13 +251,21 @@ async function deconflictName( ) } -async function ensureDropStagingDir(provider: IFilesystemProvider, destDir: string): Promise { +async function ensureDropStagingDir( + provider: IFilesystemProvider, + destDir: string, + assertCurrent?: () => void +): Promise { const parent = posix.dirname(destDir) + assertCurrent?.() await provider.createDir(parent) const gitignorePath = `${parent}/.gitignore` + assertCurrent?.() if (!(await remotePathExists(provider, gitignorePath))) { + assertCurrent?.() await provider.writeFile(gitignorePath, '*\n!.gitignore\n') } + assertCurrent?.() await provider.createDir(destDir) } diff --git a/src/main/ipc/filesystem-mutations.test.ts b/src/main/ipc/filesystem-mutations.test.ts index a5f16e7ba4a..dcaeb74b624 100644 --- a/src/main/ipc/filesystem-mutations.test.ts +++ b/src/main/ipc/filesystem-mutations.test.ts @@ -32,6 +32,10 @@ import { registerSshFilesystemProvider, unregisterSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { + resetSshConnectionGenerations, + setSshConnectionGeneration +} from '../ssh/ssh-connection-generation' // Why: paths are resolved via path.resolve() in production code, so test // data must use resolved paths to avoid Unix-vs-Windows mismatches. @@ -72,6 +76,7 @@ describe('registerFilesystemMutationHandlers', () => { renameMock.mockReset() writeFileMock.mockReset() realpathMock.mockReset() + resetSshConnectionGenerations() handleMock.mockImplementation((channel: string, handler: never) => { handlers.set(channel, handler) @@ -281,7 +286,9 @@ describe('registerFilesystemMutationHandlers', () => { await handlers.get('fs:rename')!(null, { oldPath: '/home/me/repo/old.ts', newPath: '/home/me/repo/new.ts', - connectionId: 'ssh-1' + connectionId: 'ssh-1', + expectedSshTargetId: 'ssh-1', + expectedSshConnectionGeneration: 0 }) } finally { unregisterSshFilesystemProvider('ssh-1') @@ -300,7 +307,9 @@ describe('registerFilesystemMutationHandlers', () => { handlers.get('fs:rename')!(null, { oldPath: '/home/me/repo/old.ts', newPath: '/home/me/repo/new.ts', - connectionId: 'ssh-1' + connectionId: 'ssh-1', + expectedSshTargetId: 'ssh-1', + expectedSshConnectionGeneration: 0 }) ).rejects.toThrow('destination exists') } finally { @@ -310,6 +319,120 @@ describe('registerFilesystemMutationHandlers', () => { expect(renameMock).not.toHaveBeenCalled() }) + it('rejects direct SSH rename without target-bound generation provenance', async () => { + const renameNoClobber = vi.fn().mockResolvedValue(undefined) + registerSshFilesystemProvider('ssh-1', { renameNoClobber } as never) + + try { + await expect( + handlers.get('fs:rename')!(null, { + oldPath: '/home/me/repo/old.ts', + newPath: '/home/me/repo/new.ts', + connectionId: 'ssh-1' + }) + ).rejects.toThrow('SSH connection changed') + } finally { + unregisterSshFilesystemProvider('ssh-1') + } + + expect(renameNoClobber).not.toHaveBeenCalled() + }) + + it('rejects equal-generation provenance for another direct SSH target', async () => { + const renameNoClobber = vi.fn().mockResolvedValue(undefined) + registerSshFilesystemProvider('ssh-b', { renameNoClobber } as never) + + try { + await expect( + handlers.get('fs:rename')!(null, { + oldPath: '/home/me/repo/old.ts', + newPath: '/home/me/repo/new.ts', + connectionId: 'ssh-b', + expectedSshTargetId: 'ssh-a', + expectedSshConnectionGeneration: 0 + }) + ).rejects.toThrow('SSH connection changed') + } finally { + unregisterSshFilesystemProvider('ssh-b') + } + + expect(renameNoClobber).not.toHaveBeenCalled() + }) + + it('rejects stale generation provenance for a direct SSH target', async () => { + const renameNoClobber = vi.fn().mockResolvedValue(undefined) + registerSshFilesystemProvider('ssh-1', { renameNoClobber } as never) + setSshConnectionGeneration('ssh-1', 8) + + try { + await expect( + handlers.get('fs:rename')!(null, { + oldPath: '/home/me/repo/old.ts', + newPath: '/home/me/repo/new.ts', + connectionId: 'ssh-1', + expectedSshTargetId: 'ssh-1', + expectedSshConnectionGeneration: 7 + }) + ).rejects.toThrow('SSH connection changed') + } finally { + unregisterSshFilesystemProvider('ssh-1') + } + + expect(renameNoClobber).not.toHaveBeenCalled() + }) + + it('rejects stale SSH provenance when a direct mutation resolves local', async () => { + await expect( + handlers.get('fs:rename')!(null, { + oldPath: path.resolve('/workspace/repo/old.ts'), + newPath: path.resolve('/workspace/repo/new.ts'), + expectedSshTargetId: 'ssh-1', + expectedSshConnectionGeneration: 0 + }) + ).rejects.toThrow('SSH connection changed') + + expect(renameMock).not.toHaveBeenCalled() + }) + + it.each([ + ['fs:createFile', { filePath: path.resolve('/workspace/repo/new.ts') }], + ['fs:createDir', { dirPath: path.resolve('/workspace/repo/new-dir') }], + [ + 'fs:rename', + { + oldPath: path.resolve('/workspace/repo/old.ts'), + newPath: path.resolve('/workspace/repo/new.ts') + } + ], + [ + 'fs:copy', + { + sourcePath: path.resolve('/workspace/repo/source.ts'), + destinationPath: path.resolve('/workspace/repo/copy.ts') + } + ], + [ + 'fs:importExternalPaths', + { sourcePaths: [path.resolve('/tmp/source.ts')], destDir: path.resolve('/workspace/repo') } + ], + [ + 'fs:resolveDroppedPathsForAgent', + { paths: [path.resolve('/tmp/source.ts')], worktreePath: path.resolve('/workspace/repo') } + ] + ])( + 'rejects %s before local fallback when the expected execution host is SSH', + async (channel, args) => { + await expect( + handlers.get(channel)!(null, { ...args, expectedExecutionHostId: 'ssh:ssh-1' }) + ).rejects.toThrow('Workspace host changed; refresh and try again') + + expect(writeFileMock).not.toHaveBeenCalled() + expect(mkdirMock).not.toHaveBeenCalled() + expect(renameMock).not.toHaveBeenCalled() + expect(copyFileMock).not.toHaveBeenCalled() + } + ) + // ── fs:copy ──────────────────────────────────────────────────── it('copies a file without overwriting an existing destination', async () => { @@ -330,7 +453,9 @@ describe('registerFilesystemMutationHandlers', () => { await handlers.get('fs:copy')!(null, { sourcePath: '/home/me/repo/source.ts', destinationPath: '/home/me/repo/source copy.ts', - connectionId: 'ssh-1' + connectionId: 'ssh-1', + expectedSshTargetId: 'ssh-1', + expectedSshConnectionGeneration: 0 }) } finally { unregisterSshFilesystemProvider('ssh-1') diff --git a/src/main/ipc/filesystem-mutations.ts b/src/main/ipc/filesystem-mutations.ts index a1b6a568463..e89ff761b63 100644 --- a/src/main/ipc/filesystem-mutations.ts +++ b/src/main/ipc/filesystem-mutations.ts @@ -22,6 +22,8 @@ import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispat import { resolveLocalDroppedPathsForAgent } from './dropped-path-resolution' import { importExternalPathsSsh } from './filesystem-import-ssh' import { assertNoClobberRenameDestinationAvailable } from '../../shared/filesystem-rename-collision' +import type { SshMutationExpectation } from '../../shared/ssh-types' +import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation' /** * Re-throw filesystem errors with user-friendly messages. @@ -70,7 +72,16 @@ async function assertNotExists(targetPath: string): Promise { export function registerFilesystemMutationHandlers(store: Store): void { ipcMain.handle( 'fs:createFile', - async (_event, args: { filePath: string; connectionId?: string }): Promise => { + async ( + _event, + args: { filePath: string; connectionId?: string } & SshMutationExpectation + ): Promise => { + assertSshMutationExpectation( + args.connectionId, + args.expectedSshTargetId, + args.expectedSshConnectionGeneration, + args.expectedExecutionHostId + ) if (args.connectionId) { const provider = requireSshFilesystemProvider(args.connectionId) return provider.createFile(args.filePath) @@ -88,7 +99,16 @@ export function registerFilesystemMutationHandlers(store: Store): void { ipcMain.handle( 'fs:createDir', - async (_event, args: { dirPath: string; connectionId?: string }): Promise => { + async ( + _event, + args: { dirPath: string; connectionId?: string } & SshMutationExpectation + ): Promise => { + assertSshMutationExpectation( + args.connectionId, + args.expectedSshTargetId, + args.expectedSshConnectionGeneration, + args.expectedExecutionHostId + ) if (args.connectionId) { const provider = requireSshFilesystemProvider(args.connectionId) return provider.createDir(args.dirPath) @@ -106,8 +126,14 @@ export function registerFilesystemMutationHandlers(store: Store): void { 'fs:rename', async ( _event, - args: { oldPath: string; newPath: string; connectionId?: string } + args: { oldPath: string; newPath: string; connectionId?: string } & SshMutationExpectation ): Promise => { + assertSshMutationExpectation( + args.connectionId, + args.expectedSshTargetId, + args.expectedSshConnectionGeneration, + args.expectedExecutionHostId + ) if (args.connectionId) { const provider = requireSshFilesystemProvider(args.connectionId) return provider.renameNoClobber(args.oldPath, args.newPath) @@ -129,8 +155,18 @@ export function registerFilesystemMutationHandlers(store: Store): void { 'fs:copy', async ( _event, - args: { sourcePath: string; destinationPath: string; connectionId?: string } + args: { + sourcePath: string + destinationPath: string + connectionId?: string + } & SshMutationExpectation ): Promise => { + assertSshMutationExpectation( + args.connectionId, + args.expectedSshTargetId, + args.expectedSshConnectionGeneration, + args.expectedExecutionHostId + ) if (args.connectionId) { const provider = requireSshFilesystemProvider(args.connectionId) return provider.copy(args.sourcePath, args.destinationPath) @@ -152,11 +188,29 @@ export function registerFilesystemMutationHandlers(store: Store): void { 'fs:importExternalPaths', async ( _event, - args: { sourcePaths: string[]; destDir: string; connectionId?: string; ensureDir?: boolean } + args: { + sourcePaths: string[] + destDir: string + connectionId?: string + ensureDir?: boolean + } & SshMutationExpectation ): Promise<{ results: ImportItemResult[] }> => { + assertSshMutationExpectation( + args.connectionId, + args.expectedSshTargetId, + args.expectedSshConnectionGeneration, + args.expectedExecutionHostId + ) if (args.connectionId) { return importExternalPathsSsh(args.sourcePaths, args.destDir, args.connectionId, { - ensureDir: args.ensureDir + ensureDir: args.ensureDir, + assertCurrent: () => + assertSshMutationExpectation( + args.connectionId, + args.expectedSshTargetId, + args.expectedSshConnectionGeneration, + args.expectedExecutionHostId + ) }) } @@ -205,8 +259,18 @@ export function registerFilesystemMutationHandlers(store: Store): void { 'fs:resolveDroppedPathsForAgent', async ( _event, - args: { paths: string[]; worktreePath: string; connectionId?: string } + args: { + paths: string[] + worktreePath: string + connectionId?: string + } & SshMutationExpectation ): Promise => { + assertSshMutationExpectation( + args.connectionId, + args.expectedSshTargetId, + args.expectedSshConnectionGeneration, + args.expectedExecutionHostId + ) // Why: `== null` (not `!args.connectionId`) so an empty string is // treated as a renderer error, not silently routed to the local branch. if (args.connectionId == null) { @@ -219,7 +283,14 @@ export function registerFilesystemMutationHandlers(store: Store): void { const worktreePath = args.worktreePath.replace(/\/+$/, '') const destDir = `${worktreePath}/.orca/drops` const { results } = await importExternalPathsSsh(args.paths, destDir, args.connectionId, { - ensureDir: true + ensureDir: true, + assertCurrent: () => + assertSshMutationExpectation( + args.connectionId, + args.expectedSshTargetId, + args.expectedSshConnectionGeneration, + args.expectedExecutionHostId + ) }) const resolvedPaths: string[] = [] const skipped: { sourcePath: string; reason: ImportSkipReason }[] = [] diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index 61f2dee6421..2f76c80791b 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -1129,6 +1129,24 @@ describe('registerFilesystemHandlers', () => { expect(writeFileMock).not.toHaveBeenCalled() }) + it.each([ + ['fs:writeFile', { filePath: path.resolve('/workspace/repo/file.txt'), content: 'data' }], + ['fs:deletePath', { targetPath: path.resolve('/workspace/repo/file.txt') }] + ])( + 'rejects %s before local mutation when the expected execution host is SSH', + async (channel, args) => { + registerFilesystemHandlers(store as never) + + await expect( + handlers.get(channel)!(null, { ...args, expectedExecutionHostId: 'ssh:ssh-1' }) + ).rejects.toThrow('Workspace host changed; refresh and try again') + + expect(writeFileMock).not.toHaveBeenCalled() + expect(trashItemMock).not.toHaveBeenCalled() + expect(tryDeleteWslUncPathMock).not.toHaveBeenCalled() + } + ) + it.each([ { ext: 'png', mime: 'image/png', data: [0x89, 0x50, 0x4e, 0x47, 0x00] }, { ext: 'pdf', mime: 'application/pdf', data: [0x25, 0x50, 0x44, 0x46, 0x00] }, diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 725b01c64d4..414b67516f1 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -29,6 +29,8 @@ import type { TuiAgent } from '../../shared/types' import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' +import type { SshMutationExpectation } from '../../shared/ssh-types' +import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation' import { buildRgArgs, createAccumulator, @@ -807,8 +809,14 @@ export function registerFilesystemHandlers( 'fs:writeFile', async ( _event, - args: { filePath: string; content: string; connectionId?: string } + args: { filePath: string; content: string; connectionId?: string } & SshMutationExpectation ): Promise => { + assertSshMutationExpectation( + args.connectionId, + args.expectedSshTargetId, + args.expectedSshConnectionGeneration, + args.expectedExecutionHostId + ) if (args.connectionId) { const provider = requireSshFilesystemProvider(args.connectionId) return provider.writeFile(args.filePath, args.content) @@ -834,8 +842,18 @@ export function registerFilesystemHandlers( 'fs:deletePath', async ( _event, - args: { targetPath: string; connectionId?: string; recursive?: boolean } + args: { + targetPath: string + connectionId?: string + recursive?: boolean + } & SshMutationExpectation ): Promise => { + assertSshMutationExpectation( + args.connectionId, + args.expectedSshTargetId, + args.expectedSshConnectionGeneration, + args.expectedExecutionHostId + ) if (args.connectionId) { const provider = requireSshFilesystemProvider(args.connectionId) return provider.deletePath(args.targetPath, args.recursive) diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 114670cbab0..09f2f4baaaf 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -694,10 +694,14 @@ describe('registerPtyHandlers', () => { function registerAgentClaimController(): { spawn: (args: Record) => Promise + write: (ptyId: string, data: string) => boolean + resize: (ptyId: string, cols: number, rows: number) => boolean } { let controller: | { spawn: (args: Record) => Promise + write: (ptyId: string, data: string) => boolean + resize: (ptyId: string, cols: number, rows: number) => boolean } | undefined const runtime = { @@ -714,6 +718,34 @@ describe('registerPtyHandlers', () => { return controller } + it('fails closed instead of routing encoded SSH PTY writes locally after disconnect', () => { + const connectionId = 'ssh-1' + const ptyId = `ssh:${connectionId}@@remote-pty` + const localProvider = createAgentClaimProvider({}) + const sshProvider = createAgentClaimProvider({}) + setLocalPtyProvider(localProvider as never) + registerSshPtyProvider(connectionId, sshProvider as never) + setPtyOwnership(ptyId, connectionId) + const controller = registerAgentClaimController() + + unregisterSshPtyProvider(connectionId) + clearPtyOwnershipForConnection(connectionId) + + expect(controller.write(ptyId, 'input')).toBe(false) + expect(controller.resize(ptyId, 100, 40)).toBe(false) + expect(localProvider.write).not.toHaveBeenCalled() + expect(localProvider.resize).not.toHaveBeenCalled() + + registerSshPtyProvider(connectionId, sshProvider as never) + expect(controller.write(ptyId, 'reconnected')).toBe(true) + expect(controller.resize(ptyId, 120, 50)).toBe(true) + expect(sshProvider.write).toHaveBeenCalledWith(ptyId, 'reconnected') + expect(sshProvider.resize).toHaveBeenCalledWith(ptyId, 120, 50) + + unregisterSshPtyProvider(connectionId) + clearProviderPtyState(ptyId) + }) + it('does not dispatch a runtime PTY spawn after its client disconnects', async () => { const provider = createAgentClaimProvider({}) setLocalPtyProvider(provider as never) @@ -3647,12 +3679,15 @@ describe('registerPtyHandlers', () => { state: 'attached' }) ) - expect(store.persistPtyBinding).toHaveBeenCalledWith({ - worktreeId: 'wt-1', - tabId: 'tab-1', - leafId, - ptyId: 'ssh-pty' - }) + expect(store.persistPtyBinding).toHaveBeenCalledWith( + { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId, + ptyId: 'ssh-pty' + }, + 'ssh:ssh-1' + ) store.upsertSshRemotePtyLease.mockClear() store.persistPtyBinding.mockClear() @@ -7103,12 +7138,15 @@ describe('registerPtyHandlers', () => { state: 'attached' }) ) - expect(store.persistPtyBinding).toHaveBeenCalledWith({ - worktreeId: 'wt-remote', - tabId: 'tab-remote', - leafId, - ptyId: 'ssh:ssh-1@@relay-pty' - }) + expect(store.persistPtyBinding).toHaveBeenCalledWith( + { + worktreeId: 'wt-remote', + tabId: 'tab-remote', + leafId, + ptyId: 'ssh:ssh-1@@relay-pty' + }, + 'ssh:ssh-1' + ) expect(store.persistPtyBinding.mock.invocationCallOrder[0]!).toBeLessThan( store.upsertSshRemotePtyLease.mock.invocationCallOrder[0]! ) @@ -7252,12 +7290,15 @@ describe('registerPtyHandlers', () => { persistHostSessionBinding: true }) - expect(store.persistPtyBinding).toHaveBeenCalledWith({ - worktreeId: 'wt-remote', - tabId: 'tab-remote', - leafId, - ptyId: 'ssh:ssh-reattach-ok@@relay-pty' - }) + expect(store.persistPtyBinding).toHaveBeenCalledWith( + { + worktreeId: 'wt-remote', + tabId: 'tab-remote', + leafId, + ptyId: 'ssh:ssh-reattach-ok@@relay-pty' + }, + 'ssh:ssh-reattach-ok' + ) expect(store.upsertSshRemotePtyLease).toHaveBeenCalledWith( expect.objectContaining({ targetId: 'ssh-reattach-ok', diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 9457f132dde..931cf8f271f 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -15,6 +15,7 @@ export { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-rea import type { OrcaRuntimeService } from '../runtime/orca-runtime' import type { Store } from '../persistence' import type { GlobalSettings, TuiAgent } from '../../shared/types' +import { toSshExecutionHostId } from '../../shared/execution-host' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { terminalOutputBacklogCapChars } from '../../shared/terminal-scrollback-policy' import type { @@ -462,6 +463,11 @@ function getProvider(connectionId: string | null | undefined): IPtyProvider { function getProviderForPty(ptyId: string): IPtyProvider { const connectionId = ptyOwnership.get(ptyId) if (connectionId === undefined) { + const parsedSshId = parseAppSshPtyId(ptyId) + if (parsedSshId) { + // Why: disconnected SSH PTYs retain their encoded owner and must never fall through to the HUB-local provider. + return getProvider(parsedSshId.connectionId) + } return localProvider } return getProvider(connectionId) @@ -3441,14 +3447,22 @@ export function registerPtyHandlers( } if (hostSessionBinding) { try { - hostSessionBinding.store.persistPtyBinding({ + const binding = { worktreeId: hostSessionBinding.worktreeId, tabId: hostSessionBinding.tabId, leafId: hostSessionBinding.leafId, ptyId: result.id, ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), ...(cwd ? { startupCwd: cwd } : {}) - }) + } + if (args.connectionId) { + hostSessionBinding.store.persistPtyBinding( + binding, + toSshExecutionHostId(args.connectionId) + ) + } else { + hostSessionBinding.store.persistPtyBinding(binding) + } } catch (err) { console.error('[pty] failed to persist runtime PTY binding after spawn:', err) deletePtyOwnership(result.id) @@ -3562,9 +3576,8 @@ export function registerPtyHandlers( } }, write: (ptyId, data) => { - const provider = getProviderForPty(ptyId) try { - provider.write(ptyId, data) + getProviderForPty(ptyId).write(ptyId, data) return true } catch { return false @@ -4507,14 +4520,19 @@ export function registerPtyHandlers( validatedLeafId !== null ) { try { - store.persistPtyBinding({ + const binding = { worktreeId: args.worktreeId, tabId: args.tabId, leafId: validatedLeafId, ptyId: result.id, ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), ...(cwd ? { startupCwd: cwd } : {}) - }) + } + if (args.connectionId) { + store.persistPtyBinding(binding, toSshExecutionHostId(args.connectionId)) + } else { + store.persistPtyBinding(binding) + } } catch (err) { console.error('[pty] failed to persist PTY binding after spawn:', err) if (!result.isReattach) { diff --git a/src/main/ipc/runtime-environment-request-connections.ts b/src/main/ipc/runtime-environment-request-connections.ts index 6b226813e26..3bfcf0649af 100644 --- a/src/main/ipc/runtime-environment-request-connections.ts +++ b/src/main/ipc/runtime-environment-request-connections.ts @@ -99,6 +99,10 @@ export function getRemoteRuntimeSharedControlDiagnostics( return sharedControlConnections.get(environmentId)?.connection.getDiagnostics() ?? null } +export function reconnectRemoteRuntimeSharedControlConnection(environmentId: string): void { + sharedControlConnections.get(environmentId)?.connection.reconnectNow() +} + function getSharedControlConnection( environmentId: string, pairing: PairingOffer diff --git a/src/main/ipc/runtime-environment-revision-guard.test.ts b/src/main/ipc/runtime-environment-revision-guard.test.ts new file mode 100644 index 00000000000..09870ab770a --- /dev/null +++ b/src/main/ipc/runtime-environment-revision-guard.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import type { KnownRuntimeEnvironment } from '../../shared/runtime-environments' +import { runtimeEnvironmentRevisionFailure } from './runtime-environment-revision-guard' + +const environment = { + id: 'hub-a', + runtimeId: 'runtime-b', + createdAt: 1, + pairingRevision: 20 +} as KnownRuntimeEnvironment + +describe('runtimeEnvironmentRevisionFailure', () => { + it('fails a queued call when the saved pairing changed under the same environment id', () => { + expect(runtimeEnvironmentRevisionFailure(environment, 10, 'worktree.rm')).toEqual({ + id: 'worktree.rm', + ok: false, + error: { + code: 'runtime_environment_changed', + message: 'Runtime environment pairing changed; refresh and try again' + }, + _meta: { runtimeId: 'runtime-b' } + }) + }) + + it('preserves mixed-version calls that provide no revision', () => { + expect(runtimeEnvironmentRevisionFailure(environment, undefined, 'repo.list')).toBeNull() + expect(runtimeEnvironmentRevisionFailure(environment, 20, 'repo.list')).toBeNull() + }) +}) diff --git a/src/main/ipc/runtime-environment-revision-guard.ts b/src/main/ipc/runtime-environment-revision-guard.ts new file mode 100644 index 00000000000..2ef7cb06ac3 --- /dev/null +++ b/src/main/ipc/runtime-environment-revision-guard.ts @@ -0,0 +1,24 @@ +import type { KnownRuntimeEnvironment } from '../../shared/runtime-environments' +import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' + +export function runtimeEnvironmentRevisionFailure( + environment: KnownRuntimeEnvironment, + expectedPairingRevision: number | undefined, + method: string +): RuntimeRpcResponse | null { + if ( + expectedPairingRevision === undefined || + (environment.pairingRevision ?? environment.createdAt) === expectedPairingRevision + ) { + return null + } + return { + id: method, + ok: false, + error: { + code: 'runtime_environment_changed', + message: 'Runtime environment pairing changed; refresh and try again' + }, + _meta: { runtimeId: environment.runtimeId } + } +} diff --git a/src/main/ipc/runtime-environment-tailscale-response.ts b/src/main/ipc/runtime-environment-tailscale-response.ts new file mode 100644 index 00000000000..71e594c91a2 --- /dev/null +++ b/src/main/ipc/runtime-environment-tailscale-response.ts @@ -0,0 +1,18 @@ +import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' +import { withRemoteRuntimeTailscaleHint } from '../../shared/remote-runtime-tailscale-hint' + +export function withTailscaleHintForResponse( + response: RuntimeRpcResponse, + endpoint: string +): RuntimeRpcResponse { + if (response.ok === true) { + return response + } + return { + ...response, + error: { + ...response.error, + message: withRemoteRuntimeTailscaleHint(response.error.message, endpoint) + } + } +} diff --git a/src/main/ipc/runtime-environment-transport-generation.ts b/src/main/ipc/runtime-environment-transport-generation.ts new file mode 100644 index 00000000000..e39c86a52df --- /dev/null +++ b/src/main/ipc/runtime-environment-transport-generation.ts @@ -0,0 +1,12 @@ +const generationByEnvironment = new Map() + +export function getRuntimeEnvironmentTransportGeneration(environmentId: string): number { + return generationByEnvironment.get(environmentId) ?? 0 +} + +export function advanceRuntimeEnvironmentTransportGeneration(environmentId: string): void { + generationByEnvironment.set( + environmentId, + getRuntimeEnvironmentTransportGeneration(environmentId) + 1 + ) +} diff --git a/src/main/ipc/runtime-environment-transport-routing.ts b/src/main/ipc/runtime-environment-transport-routing.ts index 0b4b268a2a3..46241a82521 100644 --- a/src/main/ipc/runtime-environment-transport-routing.ts +++ b/src/main/ipc/runtime-environment-transport-routing.ts @@ -14,40 +14,22 @@ import { import { withRemoteRuntimeTailscaleHint } from '../../shared/remote-runtime-tailscale-hint' import { enqueueRuntimeCall } from './runtime-environment-call-queue' import { + reconnectRemoteRuntimeSharedControlConnection, sendRemoteRuntimeConnectionRequest, sendRemoteRuntimeSharedControlRequest, subscribeRemoteRuntimeSharedControlRequest } from './runtime-environment-request-connections' import { attachRemoteControlDiagnostics } from './runtime-environment-status-diagnostics' +import { runtimeEnvironmentRevisionFailure } from './runtime-environment-revision-guard' +import { withTailscaleHintForResponse } from './runtime-environment-tailscale-response' const DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS = 15_000 const sharedControlSupport = new Map }>() -export function resetSharedControlSupport(): void { - sharedControlSupport.clear() -} +export const resetSharedControlSupport = (): void => sharedControlSupport.clear() -export function clearSharedControlSupport(environmentId: string): void { - sharedControlSupport.delete(environmentId) -} - -// Why: when a remote host is unreachable, point the user at Tailscale as the -// connectivity remedy; the helper no-ops on non-connectivity errors. -function withTailscaleHintForResponse( - response: RuntimeRpcResponse, - endpoint: string -): RuntimeRpcResponse { - if (response.ok === true) { - return response - } - return { - ...response, - error: { - ...response.error, - message: withRemoteRuntimeTailscaleHint(response.error.message, endpoint) - } - } -} +export const clearSharedControlSupport = (environmentId: string): void => + void sharedControlSupport.delete(environmentId) export async function getRuntimeEnvironmentStatus( userDataPath: string, @@ -85,6 +67,7 @@ export async function getRuntimeEnvironmentStatus( } if (response.ok === true) { markEnvironmentUsed(userDataPath, environment.id, { runtimeId: response._meta.runtimeId }) + reconnectRemoteRuntimeSharedControlConnection(environment.id) } return attachRemoteControlDiagnostics( withTailscaleHintForResponse(response, pairing.endpoint), @@ -97,7 +80,8 @@ export async function callRuntimeEnvironment( selector: string, method: string, params: unknown, - timeoutMs?: number + timeoutMs?: number, + expectedEnvironmentPairingRevision?: number ): Promise> { const environment = resolveEnvironment(userDataPath, selector) // Why: connection failures reject (they don't resolve as ok:false), so the @@ -109,6 +93,14 @@ export async function callRuntimeEnvironment( try { return await enqueueRuntimeCall(environment.id, method, async () => { const currentEnvironment = resolveEnvironment(userDataPath, environment.id) + const revisionFailure = runtimeEnvironmentRevisionFailure( + currentEnvironment, + expectedEnvironmentPairingRevision, + method + ) + if (revisionFailure) { + return revisionFailure + } const pairing = getPreferredPairingOffer(currentEnvironment) endpoint = pairing.endpoint const effectiveTimeoutMs = timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS @@ -125,6 +117,7 @@ export async function callRuntimeEnvironment( } if ( method !== 'status.get' && + !shouldUseOneShotRequest(method) && (await supportsSharedControl(userDataPath, currentEnvironment, pairing, effectiveTimeoutMs)) ) { const response = await sendRemoteRuntimeSharedControlRequest( @@ -245,6 +238,11 @@ function shouldUseCachedRequestConnection(method: string): boolean { return method === 'terminal.send' || method === 'terminal.updateViewport' } +function shouldUseOneShotRequest(method: string): boolean { + // Why: snapshot recovery must remain available while a retained shared-control stream is reconnecting after a HUB restart. + return method === 'session.tabs.list' || method === 'session.tabs.listAll' +} + function shouldKeepDedicatedSubscriptionSocket(method: string): boolean { return method === 'browser.screencast' || method === 'terminal.multiplex' } diff --git a/src/main/ipc/runtime-environments.test.ts b/src/main/ipc/runtime-environments.test.ts index 3dbcac7a7ec..9b94212c76a 100644 --- a/src/main/ipc/runtime-environments.test.ts +++ b/src/main/ipc/runtime-environments.test.ts @@ -20,6 +20,7 @@ const { sendRemoteRuntimeSharedControlRequestMock, subscribeRemoteRuntimeSharedControlRequestMock, getRemoteRuntimeSharedControlDiagnosticsMock, + reconnectRemoteRuntimeSharedControlConnectionMock, closeRemoteRuntimeRequestConnectionMock } = vi.hoisted(() => ({ handleMock: vi.fn(), @@ -33,6 +34,7 @@ const { sendRemoteRuntimeSharedControlRequestMock: vi.fn(), subscribeRemoteRuntimeSharedControlRequestMock: vi.fn(), getRemoteRuntimeSharedControlDiagnosticsMock: vi.fn(), + reconnectRemoteRuntimeSharedControlConnectionMock: vi.fn(), closeRemoteRuntimeRequestConnectionMock: vi.fn() })) @@ -56,10 +58,14 @@ vi.mock('./runtime-environment-request-connections', () => ({ sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, + reconnectRemoteRuntimeSharedControlConnection: reconnectRemoteRuntimeSharedControlConnectionMock, closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock })) -import { registerRuntimeEnvironmentHandlers } from './runtime-environments' +import { + invalidateRuntimeEnvironmentTransport, + registerRuntimeEnvironmentHandlers +} from './runtime-environments' function pairingCode(endpoint = 'ws://127.0.0.1:6768'): string { return encodePairingOffer({ @@ -108,6 +114,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { subscribeRemoteRuntimeSharedControlRequestMock.mockReset() getRemoteRuntimeSharedControlDiagnosticsMock.mockReset() getRemoteRuntimeSharedControlDiagnosticsMock.mockReturnValue(null) + reconnectRemoteRuntimeSharedControlConnectionMock.mockReset() closeRemoteRuntimeRequestConnectionMock.mockReset() }) @@ -267,6 +274,9 @@ describe('registerRuntimeEnvironmentHandlers', () => { undefined, 50 ) + expect(reconnectRemoteRuntimeSharedControlConnectionMock).toHaveBeenCalledWith( + added.environment.id + ) const resolve = handler<{ selector: string }, { id: string; runtimeId: string | null }>( 'runtimeEnvironments:resolve' @@ -708,6 +718,40 @@ describe('registerRuntimeEnvironmentHandlers', () => { ) }) + it('keeps session snapshot recovery on one-shot transport while shared control reconnects', async () => { + registerRuntimeEnvironmentHandlers(store as never) + sendRemoteRuntimeRequestMock.mockImplementation(async (_pairing, method) => ({ + id: method, + ok: true, + result: + method === 'status.get' + ? { + runtimeId: 'runtime-remote', + capabilities: [REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY] + } + : { snapshots: [] }, + _meta: { runtimeId: 'runtime-remote' } + })) + + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + await add(null, { name: 'desk', pairingCode: pairingCode() }) + const call = handler< + { selector: string; method: string; params?: unknown }, + { ok: true; result: unknown } + >('runtimeEnvironments:call') + + await expect( + call(null, { selector: 'desk', method: 'session.tabs.listAll' }) + ).resolves.toMatchObject({ ok: true, result: { snapshots: [] } }) + expect(sendRemoteRuntimeRequestMock.mock.calls.map((entry) => entry[1])).toEqual([ + 'session.tabs.listAll' + ]) + expect(sendRemoteRuntimeSharedControlRequestMock).not.toHaveBeenCalled() + }) + it('keeps browser and terminal heavy streams on dedicated subscription sockets', async () => { registerRuntimeEnvironmentHandlers(store as never) const close = vi.fn() @@ -1536,6 +1580,103 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) }) + it.each([ + { method: 'terminal.multiplex', includeExpectedRevision: true }, + { method: 'browser.screencast', includeExpectedRevision: true }, + { method: 'terminal.multiplex', includeExpectedRevision: false }, + { method: 'browser.screencast', includeExpectedRevision: false } + ])( + 'closes a pending $method subscription after same-id re-pair (expected revision: $includeExpectedRevision)', + async ({ method, includeExpectedRevision }) => { + registerRuntimeEnvironmentHandlers(store as never) + const close = vi.fn() + const sendBinary = vi.fn(() => true) + let emitRemoteBinary: (bytes: Uint8Array) => void = () => {} + let resolveSubscribe: (value: { + requestId: string + close: () => void + sendBinary: (bytes: Uint8Array) => boolean + }) => void = () => {} + subscribeRemoteRuntimeRequestMock.mockImplementation( + (_pairing, _method, _params, _timeoutMs, callbacks) => { + emitRemoteBinary = callbacks.onBinary + return new Promise((resolve) => { + resolveSubscribe = resolve + }) + } + ) + + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + const added = await add(null, { name: 'desk', pairingCode: pairingCode() }) + const savedEnvironment = environmentStore.resolveEnvironment( + userDataPath, + added.environment.id + ) + const pairingRevision = savedEnvironment.pairingRevision ?? savedEnvironment.createdAt + const senderSend = vi.fn() + const subscribe = handler< + { + selector: string + method: string + params?: unknown + subscriptionId: string + expectedEnvironmentPairingRevision?: number + }, + { subscriptionId: string; requestId: string } + >('runtimeEnvironments:subscribe') + const resultPromise = subscribe( + { + sender: { + id: 1, + isDestroyed: () => false, + send: senderSend, + once: vi.fn(), + removeListener: vi.fn() + } + }, + { + selector: added.environment.id, + method, + params: {}, + subscriptionId: `pending-${method}-${includeExpectedRevision ? 'current' : 'legacy'}`, + ...(includeExpectedRevision + ? { expectedEnvironmentPairingRevision: pairingRevision } + : {}) + } + ) + + await vi.waitFor(() => expect(subscribeRemoteRuntimeRequestMock).toHaveBeenCalledTimes(1)) + environmentStore.updateEnvironmentFromPairingCode(userDataPath, added.environment.id, { + pairingCode: pairingCode('ws://127.0.0.1:7678') + }) + invalidateRuntimeEnvironmentTransport(added.environment.id) + + emitRemoteBinary(new Uint8Array([1, 2, 3])) + expect(senderSend).not.toHaveBeenCalled() + resolveSubscribe({ requestId: 'retired-stream', close, sendBinary }) + + await expect(resultPromise).rejects.toThrow( + 'Runtime environment pairing changed; refresh and try again' + ) + expect(close).toHaveBeenCalledTimes(1) + + const binaryListener = onMock.mock.calls.find( + (call) => call[0] === 'runtimeEnvironments:subscriptionBinary' + )?.[1] as (_event: unknown, args: unknown) => void + binaryListener( + { sender: { id: 1 } }, + { + subscriptionId: `pending-${method}-${includeExpectedRevision ? 'current' : 'legacy'}`, + bytes: new Uint8Array([4, 5, 6]) + } + ) + expect(sendBinary).not.toHaveBeenCalled() + } + ) + it('removes the destroyed listener when streaming subscription setup rejects', async () => { registerRuntimeEnvironmentHandlers(store as never) subscribeRemoteRuntimeRequestMock.mockRejectedValue(new Error('connect failed')) diff --git a/src/main/ipc/runtime-environments.ts b/src/main/ipc/runtime-environments.ts index 0c1aca30cd5..f2c31beaf54 100644 --- a/src/main/ipc/runtime-environments.ts +++ b/src/main/ipc/runtime-environments.ts @@ -16,6 +16,10 @@ import type { RemoteRuntimeSubscription } from '../../shared/remote-runtime-clie import type { Store } from '../persistence' import { clearActiveRuntimeEnvironmentFocusIfMatches } from '../runtime-environment-focus-self-heal' import { closeRemoteRuntimeRequestConnection } from './runtime-environment-request-connections' +import { + advanceRuntimeEnvironmentTransportGeneration, + getRuntimeEnvironmentTransportGeneration +} from './runtime-environment-transport-generation' import { callRuntimeEnvironment, clearSharedControlSupport, @@ -42,14 +46,10 @@ type RetainedRemoteRuntimeSubscription = RemoteRuntimeSubscription & { removeDestroyedListener: () => void } const remoteRuntimeSubscriptions = new Map() - -function getUserDataPath(): string { - return app.getPath('userData') -} +const getUserDataPath = (): string => app.getPath('userData') function closeSubscriptionsForEnvironment(environmentId: string): void { - // Why: removing a saved runtime invalidates its streaming WebSockets too; - // otherwise terminal/browser subscriptions stay alive until renderer teardown. + // Why: removed runtimes must not retain terminal/browser WebSockets until renderer teardown. for (const [subscriptionId, subscription] of remoteRuntimeSubscriptions) { if (subscription.environmentId !== environmentId) { continue @@ -58,10 +58,16 @@ function closeSubscriptionsForEnvironment(environmentId: string): void { subscription.close() } } +export function invalidateRuntimeEnvironmentTransport(environmentId: string): void { + // Why: a same-id re-pair must retire every transport that still authenticates as the old peer. + advanceRuntimeEnvironmentTransportGeneration(environmentId) + closeRemoteRuntimeRequestConnection(environmentId) + clearSharedControlSupport(environmentId) + closeSubscriptionsForEnvironment(environmentId) +} function listPublicRuntimeEnvironments(): PublicKnownRuntimeEnvironment[] { - // Why: `source` is persisted on the env record, so read it directly instead of - // joining the VM store — a corrupt VM store must not break listing all envs. + // Why: a corrupt VM store must not break persisted environment listing. return listEnvironments(getUserDataPath()).map(redactRuntimeEnvironment) } @@ -95,14 +101,12 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { 'runtimeEnvironments:remove', (_event, args: { selector: string }): { removed: PublicKnownRuntimeEnvironment } => { const removed = removeEnvironment(getUserDataPath(), args.selector) - closeRemoteRuntimeRequestConnection(removed.id) - clearSharedControlSupport(removed.id) + invalidateRuntimeEnvironmentTransport(removed.id) if (args.selector !== removed.id) { closeRemoteRuntimeRequestConnection(args.selector) clearSharedControlSupport(args.selector) } clearActiveRuntimeEnvironmentFocusIfMatches(store, removed.id) - closeSubscriptionsForEnvironment(removed.id) return { removed: redactRuntimeEnvironment(removed) } } ) @@ -112,13 +116,11 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { const environment = resolveEnvironment(getUserDataPath(), args.selector) // Why: disconnect is intentionally non-destructive; it drops live // transport state while keeping the paired server available for later. - closeRemoteRuntimeRequestConnection(environment.id) - clearSharedControlSupport(environment.id) + invalidateRuntimeEnvironmentTransport(environment.id) if (args.selector !== environment.id) { closeRemoteRuntimeRequestConnection(args.selector) clearSharedControlSupport(args.selector) } - closeSubscriptionsForEnvironment(environment.id) return { disconnected: redactRuntimeEnvironment(environment) } } ) @@ -135,14 +137,21 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { 'runtimeEnvironments:call', async ( _event, - args: { selector: string; method: string; params?: unknown; timeoutMs?: number } + args: { + selector: string + method: string + params?: unknown + timeoutMs?: number + expectedEnvironmentPairingRevision?: number + } ): Promise> => { return callRuntimeEnvironment( getUserDataPath(), args.selector, args.method, args.params, - args.timeoutMs + args.timeoutMs, + args.expectedEnvironmentPairingRevision ) } ) @@ -156,6 +165,7 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { params?: unknown timeoutMs?: number subscriptionId?: string + expectedEnvironmentPairingRevision?: number } ): Promise<{ subscriptionId: string; requestId: string }> => { const subscriptionId = @@ -166,6 +176,16 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { throw new Error('Runtime environment subscription id already exists') } const environment = resolveEnvironment(getUserDataPath(), args.selector) + const pairingRevision = environment.pairingRevision ?? environment.createdAt + if ( + args.expectedEnvironmentPairingRevision !== undefined && + pairingRevision !== args.expectedEnvironmentPairingRevision + ) { + throw new Error('Runtime environment pairing changed; refresh and try again') + } + const transportGeneration = getRuntimeEnvironmentTransportGeneration(environment.id) + const transportIsCurrent = (): boolean => + getRuntimeEnvironmentTransportGeneration(environment.id) === transportGeneration const sender = event.sender const ownerWebContentsId = sender.id let senderDestroyed = sender.isDestroyed() @@ -200,7 +220,7 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { args.timeoutMs, { onEvent: (payload) => { - if (!sender.isDestroyed()) { + if (transportIsCurrent() && !sender.isDestroyed()) { sender.send('runtimeEnvironments:subscriptionEvent', { subscriptionId, ...payload @@ -218,6 +238,19 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { removeDestroyedListener() throw error } + let pairingIsCurrent = false + try { + const currentEnvironment = resolveEnvironment(getUserDataPath(), environment.id) + pairingIsCurrent = + (currentEnvironment.pairingRevision ?? currentEnvironment.createdAt) === pairingRevision + } catch { + pairingIsCurrent = false + } + if (!transportIsCurrent() || !pairingIsCurrent) { + removeDestroyedListener() + subscription.close() + throw new Error('Runtime environment pairing changed; refresh and try again') + } if (senderDestroyed || sender.isDestroyed()) { removeDestroyedListener() subscription.close() diff --git a/src/main/ipc/shell.test.ts b/src/main/ipc/shell.test.ts index 2213c924af4..7193da4beaa 100644 --- a/src/main/ipc/shell.test.ts +++ b/src/main/ipc/shell.test.ts @@ -542,6 +542,29 @@ describe('registerShellHandlers', () => { ]) }) + it('opens a manual port-22 target with a host-only authority when username is blank', async () => { + sshTargets.set( + 'ssh-1', + createSshTarget({ + source: 'manual', + configHost: 'builder.example.com', + host: 'builder.example.com', + username: '' + }) + ) + resolveCliCommandMock.mockReturnValueOnce('/usr/local/bin/code') + const handler = getHandler('shell:openInExternalEditor') + + await expect( + handler({}, { path: '/srv/project', command: 'code', connectionId: 'ssh-1' }) + ).resolves.toEqual({ ok: true }) + expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('/usr/local/bin/code', [ + '--remote', + 'ssh-remote+builder.example.com', + '/srv/project' + ]) + }) + it('rejects relative SSH paths before resolving or spawning a launcher', async () => { sshTargets.set('ssh-1', createSshTarget()) const handler = getHandler('shell:openInExternalEditor') diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index 7c7cc82edc1..909498882ee 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -225,6 +225,7 @@ import { getSshPtyProvider, getPtyIdsForConnection } from './pty' +import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation' describe('SSH IPC handlers', () => { const handlers = new Map unknown>() @@ -537,6 +538,7 @@ describe('SSH IPC handlers', () => { status: 'connected', error: null, reconnectAttempt: 0, + connectionGeneration: 1, remotePlatform: 'win32' }) expect(mockWindow.webContents.send).toHaveBeenCalledWith('ssh:state-changed', { @@ -546,6 +548,7 @@ describe('SSH IPC handlers', () => { status: 'connected', error: null, reconnectAttempt: 0, + connectionGeneration: 1, supportsFolderDownload: true, remotePlatform: 'win32' } @@ -586,14 +589,16 @@ describe('SSH IPC handlers', () => { targetId: 'ssh-1', status: 'reconnecting', error: 'Relay channel lost. Reconnecting...', - reconnectAttempt: 1 + reconnectAttempt: 1, + connectionGeneration: 1 } }) expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toEqual({ targetId: 'ssh-1', status: 'reconnecting', error: 'Relay channel lost. Reconnecting...', - reconnectAttempt: 1 + reconnectAttempt: 1, + connectionGeneration: 1 }) await vi.advanceTimersByTimeAsync(500) @@ -605,6 +610,7 @@ describe('SSH IPC handlers', () => { status: 'connected', error: null, reconnectAttempt: 0, + connectionGeneration: 1, supportsFolderDownload: true } }) @@ -612,13 +618,72 @@ describe('SSH IPC handlers', () => { targetId: 'ssh-1', status: 'connected', error: null, - reconnectAttempt: 0 + reconnectAttempt: 0, + connectionGeneration: 1 }) + expect(() => assertSshMutationExpectation('ssh-1', 'ssh-1', 1)).not.toThrow() } finally { vi.useRealTimers() } }) + it('rejects a staged mutation after the underlying SSH transport reconnects', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + const conn = {} + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue(conn) + mockConnectionManager.getConnection.mockReturnValue(conn) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + const stagedGeneration = 1 + const callbacks = mockConnectionManager.callbacksRef.current as { + onStateChange: (targetId: string, state: SshConnectionState) => void + } + + callbacks.onStateChange('ssh-1', { + targetId: 'ssh-1', + status: 'reconnecting', + error: null, + reconnectAttempt: 1 + }) + callbacks.onStateChange('ssh-1', { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + callbacks.onStateChange('ssh-1', { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + + expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toEqual({ + targetId: 'ssh-1', + status: 'reconnecting', + error: 'Relay channel reconnecting...', + reconnectAttempt: 0, + connectionGeneration: 2 + }) + expect(() => assertSshMutationExpectation('ssh-1', 'ssh-1', stagedGeneration)).toThrow( + 'SSH connection changed; refresh and try again' + ) + expect(() => assertSshMutationExpectation('ssh-1', 'ssh-1', 2)).not.toThrow() + }) + it('rebuilds instead of reusing a ready session while relay loss is pending', async () => { vi.useFakeTimers() const target: SshTarget = { @@ -651,7 +716,8 @@ describe('SSH IPC handlers', () => { targetId: 'ssh-1', status: 'reconnecting', error: 'Relay channel lost. Reconnecting...', - reconnectAttempt: 1 + reconnectAttempt: 1, + connectionGeneration: 1 }) mockDeployAndLaunchRelay.mockClear() @@ -661,7 +727,8 @@ describe('SSH IPC handlers', () => { targetId: 'ssh-1', status: 'connected', error: null, - reconnectAttempt: 0 + reconnectAttempt: 0, + connectionGeneration: 2 }) expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledWith('ssh-1') @@ -670,7 +737,8 @@ describe('SSH IPC handlers', () => { targetId: 'ssh-1', status: 'connected', error: null, - reconnectAttempt: 0 + reconnectAttempt: 0, + connectionGeneration: 2 }) } finally { vi.useRealTimers() @@ -709,7 +777,8 @@ describe('SSH IPC handlers', () => { targetId: 'ssh-1', status: 'connected', error: null, - reconnectAttempt: 0 + reconnectAttempt: 0, + connectionGeneration: 1 }) } @@ -719,7 +788,8 @@ describe('SSH IPC handlers', () => { targetId: 'ssh-1', status: 'error', error: 'Relay channel kept dropping. Click Reconnect on the SSH target before retrying.', - reconnectAttempt: 0 + reconnectAttempt: 0, + connectionGeneration: 1 }) } finally { vi.useRealTimers() @@ -756,7 +826,8 @@ describe('SSH IPC handlers', () => { targetId: 'ssh-1', status: 'connected', error: null, - reconnectAttempt: 0 + reconnectAttempt: 0, + connectionGeneration: 1 }) await vi.advanceTimersByTimeAsync(relayLostStabilizedMs + 1) @@ -767,7 +838,8 @@ describe('SSH IPC handlers', () => { targetId: 'ssh-1', status: 'connected', error: null, - reconnectAttempt: 0 + reconnectAttempt: 0, + connectionGeneration: 1 }) expect(mockPortForwardManager.removeAllForwards).not.toHaveBeenCalled() expect(mockDeployAndLaunchRelay).not.toHaveBeenCalled() @@ -823,7 +895,8 @@ describe('SSH IPC handlers', () => { const runtime = { onPtyData: vi.fn(), onPtyExit: vi.fn(), - notifySshStateChanged: vi.fn() + notifySshStateChanged: vi.fn(), + notifySshRelayReady: vi.fn() } registerSshHandlers(mockStore as never, () => mockWindow as never, runtime as never) const target: SshTarget = { @@ -850,6 +923,7 @@ describe('SSH IPC handlers', () => { 'ssh-1', expect.objectContaining({ targetId: 'ssh-1', status: 'connected' }) ) + expect(runtime.notifySshRelayReady).toHaveBeenCalledWith('ssh-1') }) it('keeps runtime-owned SSH state off the renderer while invalidating runtime scans', async () => { @@ -970,9 +1044,10 @@ describe('SSH IPC handlers', () => { mockDeployAndLaunchRelay.mockClear() mockPortForwardManager.removeAllForwards.mockClear() - await expect(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })).resolves.toEqual( - connectedState - ) + await expect(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })).resolves.toEqual({ + ...connectedState, + connectionGeneration: 1 + }) expect(mockDeployAndLaunchRelay).not.toHaveBeenCalled() expect(mockPortForwardManager.removeAllForwards).not.toHaveBeenCalled() expect(await handlers.get('ssh:listPortForwards')!(null, { targetId: 'ssh-1' })).toEqual([ @@ -1171,7 +1246,8 @@ describe('SSH IPC handlers', () => { targetId: 'ssh-1', status: 'error', error: 'network down', - reconnectAttempt: 0 + reconnectAttempt: 0, + connectionGeneration: 1 } }) expect(secondWindow.webContents.send).toHaveBeenCalledWith( @@ -1569,6 +1645,23 @@ describe('SSH IPC handlers', () => { error: null, reconnectAttempt: 0 }) + mockConnectionManager.reconnect.mockImplementation(async (targetId: string) => { + const callbacks = mockConnectionManager.callbacksRef.current as { + onStateChange: (id: string, state: SshConnectionState) => void + } + callbacks.onStateChange(targetId, { + targetId, + status: 'reconnecting', + error: null, + reconnectAttempt: 1 + }) + callbacks.onStateChange(targetId, { + targetId, + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + }) mockMux.probeLiveness.mockResolvedValue(false) await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) @@ -1581,6 +1674,9 @@ describe('SSH IPC handlers', () => { await vi.waitFor(() => expect(mockConnectionManager.reconnect).toHaveBeenCalledWith('ssh-1')) // Why: a failed first probe gets one retry before teardown (slow post-wake network). expect(mockMux.probeLiveness).toHaveBeenCalledTimes(2) + expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toMatchObject({ + connectionGeneration: 2 + }) }) it('skips reconnect on system resume when the relay link is still alive', async () => { @@ -1729,6 +1825,6 @@ describe('SSH IPC handlers', () => { mockConnectionManager.getState.mockReturnValue(state) const result = await handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' }) - expect(result).toEqual(state) + expect(result).toEqual({ ...state, connectionGeneration: 0 }) }) }) diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index a3cd63b8392..16029e22a6a 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -1,5 +1,6 @@ /* oxlint-disable max-lines -- Why: co-locates SSH IPC handlers, port-forward broadcasting, and session lifecycle to keep the data flow obvious. */ import { ipcMain, powerMonitor, type BrowserWindow } from 'electron' +import { appendFileSync } from 'node:fs' import type { Store } from '../persistence' import { SshConnectionStore } from '../ssh/ssh-connection-store' import { SshConnectionManager, type SshConnectionCallbacks } from '../ssh/ssh-connection' @@ -37,6 +38,12 @@ import { getSshPtyProvider } from './pty' import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import { + advanceSshConnectionGeneration, + getSshConnectionGeneration, + initializeSshConnectionGenerationSession, + resetSshConnectionGenerations +} from '../ssh/ssh-connection-generation' let sshStore: SshConnectionStore | null = null let connectionManager: SshConnectionManager | null = null @@ -182,14 +189,14 @@ type ConnectAttempt = { } const connectInFlight = new Map() -const connectGenerationByTarget = new Map() - +const pendingTransportReconnects = new Set() function currentConnectGeneration(targetId: string): number { - return connectGenerationByTarget.get(targetId) ?? 0 + return getSshConnectionGeneration(targetId) } function invalidateConnectAttempt(targetId: string): void { - connectGenerationByTarget.set(targetId, currentConnectGeneration(targetId) + 1) + advanceSshConnectionGeneration(targetId) + pendingTransportReconnects.delete(targetId) connectInFlight.delete(targetId) credentialRequestedForTarget.delete(targetId) } @@ -254,7 +261,11 @@ function broadcastSshState( function withSshRemotePlatform(targetId: string, state: SshConnectionState): SshConnectionState { const remotePlatform = activeSessions.get(targetId)?.getHostPlatform()?.os - return remotePlatform ? { ...state, remotePlatform } : state + return { + ...state, + connectionGeneration: currentConnectGeneration(targetId), + ...(remotePlatform ? { remotePlatform } : {}) + } } function publishRelayOverride( @@ -505,11 +516,35 @@ function createSshConnectionCallbacks(): SshConnectionCallbacks { // Why: an SSH reconnect must re-deploy the relay and rebuild providers; the guard below fires only for real reconnects, not an explicit connect's 'deploying'. const session = activeSessions.get(targetId) const sessionState = session?.getState() + if ( + state.status === 'reconnecting' && + (sessionState === 'ready' || sessionState === 'reconnecting') + ) { + pendingTransportReconnects.add(targetId) + } else if ( + state.status === 'disconnected' || + state.status === 'auth-failed' || + state.status === 'reconnection-failed' || + state.status === 'error' + ) { + pendingTransportReconnects.delete(targetId) + } + const completedTransportReconnect = + state.status === 'connected' && pendingTransportReconnects.delete(targetId) + if (completedTransportReconnect) { + // Why: staged mutations from the replaced SSH transport must fail even if its relay session disappeared before recovery completed. + advanceSshConnectionGeneration(targetId) + } const shouldReconnectRelay = session !== undefined && - state.status === 'connected' && + completedTransportReconnect && state.reconnectAttempt === 0 && (sessionState === 'ready' || sessionState === 'reconnecting') + const relayReconnectAlreadyInFlight = + !completedTransportReconnect && + state.status === 'connected' && + sessionState === 'reconnecting' && + relayStateOverrides.has(targetId) if (shouldReconnectRelay) { // Why: SSH connects before the relay providers rebuild; keep renderer actions gated until SshRelaySession reaches ready again. @@ -520,6 +555,9 @@ function createSshConnectionCallbacks(): SshConnectionCallbacks { 'Relay channel reconnecting...', state.reconnectAttempt ) + } else if (relayReconnectAlreadyInFlight) { + // Why: duplicate connected notifications belong to the same socket generation and must not expose providers before relay recovery finishes. + return } else { clearRelayStateOverride(targetId) broadcastSshState(getCurrentMainWindow, targetId, state) @@ -646,6 +684,7 @@ function configureRelaySessionCallbacks(session: SshRelaySession): void { supportsFolderDownload: connectionSupportsFolderDownload(tid) }) } + currentRuntime?.notifySshRelayReady?.(tid) void restorePortForwards(tid, getCurrentMainWindow) }) } @@ -671,6 +710,7 @@ export function registerSshHandlers( getMainWindow: () => BrowserWindow | null, runtime?: OrcaRuntimeService ): { connectionManager: SshConnectionManager; sshStore: SshConnectionStore } { + initializeSshConnectionGenerationSession() // Why: macOS re-activation re-calls this with a new BrowserWindow; ipcMain.handle() throws on a duplicate channel, so remove prior handlers first. for (const ch of SSH_IPC_CHANNELS) { ipcMain.removeHandler(ch) @@ -759,6 +799,11 @@ export function registerSshHandlers( // ── Connection lifecycle ─────────────────────────────────────────── async function connectTarget(targetId: string): Promise { + const e2eProbePath = process.env.ORCA_E2E_FORBID_LOCAL_SSH_CONNECT_PROBE + if (e2eProbePath) { + appendFileSync(e2eProbePath, `${JSON.stringify(targetId)}\n`) + throw new Error('e2e_forbidden_local_ssh_connect') + } const observedGeneration = currentConnectGeneration(targetId) const reset = resetRelayInFlight.get(targetId) if (reset) { @@ -774,10 +819,9 @@ export function registerSshHandlers( throw connectCancelledError() } - const generation = observedGeneration + 1 - connectGenerationByTarget.set(targetId, generation) - const promise = doConnect(targetId, generation) - const attempt = { generation, promise } + pendingTransportReconnects.delete(targetId) + const promise = doConnect(targetId) + const attempt = { generation: currentConnectGeneration(targetId), promise } connectInFlight.set(targetId, attempt) try { return await promise @@ -795,7 +839,7 @@ export function registerSshHandlers( return connectTarget(args.targetId) }) - async function doConnect(targetId: string, generation: number): Promise { + async function doConnect(targetId: string): Promise { const target = sshStore!.getTarget(targetId) if (!target) { throw new Error(`SSH target "${targetId}" not found`) @@ -815,9 +859,10 @@ export function registerSshHandlers( ) { // Why: BrowserWindow reactivation re-fires ssh:connect for already-live targets; treat as a refresh instead of tearing down the relay and its forwards. broadcastSshState(getCurrentMainWindow, targetId, existingState) - return existingState + return getPublicSshState(targetId)! } + const generation = advanceSshConnectionGeneration(targetId) clearRelayStateOverride(targetId) let conn // Why: tear down any existing session first to avoid leaking its multiplexer, providers, and timers (double-connect / reconnect-after-error). @@ -1231,7 +1276,8 @@ export async function resetSshHandlerStateForTests(): Promise { } relayStateOverrides.clear() connectInFlight.clear() - connectGenerationByTarget.clear() + pendingTransportReconnects.clear() + resetSshConnectionGenerations() resetRelayInFlight.clear() testingTargets.clear() credentialRequestedForTarget.clear() diff --git a/src/main/ipc/worktree-base-directory-poller.test.ts b/src/main/ipc/worktree-base-directory-poller.test.ts index d42ed105948..09cda0ed480 100644 --- a/src/main/ipc/worktree-base-directory-poller.test.ts +++ b/src/main/ipc/worktree-base-directory-poller.test.ts @@ -1,10 +1,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { mkdtemp, mkdir, realpath, rm, stat, utimes, writeFile } from 'node:fs/promises' +import { mkdtemp, mkdir, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { + createWorktreePollerWindowVisibility, startWorktreeBaseDirectoryPoller, - type WorktreeBasePollEvent + type WorktreeBasePollEvent, + type WorktreePollerWindowVisibility } from './worktree-base-directory-poller' import type { WorktreeBaseRepoWatchConfig, @@ -13,6 +15,37 @@ import type { const POLL_MS = 25 +type VisibilityHarness = { + source: WorktreePollerWindowVisibility + hide: () => void + show: () => void +} + +function createVisibilityHarness(initiallyVisible = true): VisibilityHarness { + let visible = initiallyVisible + let listener: (() => void) | null = null + return { + source: { + isWindowVisible: () => visible, + onWindowBecameVisible: (nextListener) => { + listener = nextListener + return () => { + if (listener === nextListener) { + listener = null + } + } + } + }, + hide: () => { + visible = false + }, + show: () => { + visible = true + listener?.() + } + } +} + function makeTarget( kind: 'base' | 'git-common', path: string, @@ -171,6 +204,95 @@ describe('worktree base directory poller', () => { expect(fullScans.length).toBeGreaterThan(0) }) + it('parks base scans while hidden and losslessly detects changes on resume', async () => { + const root = await makeRoot() + const visibility = createVisibilityHarness() + const received: WorktreeBasePollEvent[][] = [] + const fullScans: number[] = [] + const target = makeTarget('base', root) + const poller = await startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + (events) => received.push(events), + { + pollIntervalMs: POLL_MS, + visibility: visibility.source, + onFullScan: () => fullScans.push(Date.now()) + } + ) + cleanups.push(() => poller.unsubscribe()) + + visibility.hide() + await new Promise((resolve) => setTimeout(resolve, POLL_MS * 2)) + const worktree = join(root, 'added-while-hidden') + await mkdir(worktree) + await writeFile(join(worktree, '.git'), 'gitdir: elsewhere') + await new Promise((resolve) => setTimeout(resolve, POLL_MS * 2)) + + expect(received.flat()).toHaveLength(0) + expect(fullScans).toHaveLength(0) + + visibility.show() + expect(fullScans).toHaveLength(1) + await waitForEvents(received, (flat) => + flat.some((event) => event.type === 'create' && event.path === join(worktree, '.git')) + ) + }) + + it('keeps polling without a main window', async () => { + const root = await makeRoot() + const received: WorktreeBasePollEvent[][] = [] + const target = makeTarget('base', root) + const visibility = createWorktreePollerWindowVisibility(() => null) + const poller = await startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + (events) => received.push(events), + { pollIntervalMs: POLL_MS, visibility } + ) + cleanups.push(() => poller.unsubscribe()) + + const worktree = join(root, 'headless-add') + await mkdir(worktree) + await writeFile(join(worktree, '.git'), 'gitdir: elsewhere') + + await waitForEvents(received, (flat) => + flat.some((event) => event.type === 'create' && event.path === join(worktree, '.git')) + ) + expect(visibility.isWindowVisible()).toBe(true) + }) + + it('treats a destroyed window as absent instead of parking forever', () => { + const visibility = createWorktreePollerWindowVisibility(() => ({ isDestroyed: () => true })) + expect(visibility.isWindowVisible()).toBe(true) + }) + + it('keeps polling a live window that has never been shown (E2E headless)', () => { + // ORCA_E2E_HEADLESS keeps a live BrowserWindow that is never shown; no show/restore + // signal is coming to resume a parked poller, so a never-shown window must keep polling. + const visibility = createWorktreePollerWindowVisibility(() => ({ + isDestroyed: () => false, + isVisible: () => false, + isMinimized: () => false + })) + expect(visibility.isWindowVisible()).toBe(true) + expect(visibility.isWindowVisible()).toBe(true) + }) + + it('parks only after the window has been shown at least once', () => { + let visible = true + const visibility = createWorktreePollerWindowVisibility(() => ({ + isDestroyed: () => false, + isVisible: () => visible, + isMinimized: () => false + })) + // Shown at least once: a later reveal will fire the visibility signal to resume. + expect(visibility.isWindowVisible()).toBe(true) + // Now hidden — a previously-shown window parks (its show/restore will resume it). + visible = false + expect(visibility.isWindowVisible()).toBe(false) + }) + it('reports git-common entry creates, allowlisted leaf updates, and removals via polling', async () => { const commonDir = await makeRoot() const received: WorktreeBasePollEvent[][] = [] @@ -208,7 +330,7 @@ describe('worktree base directory poller', () => { ) }) - it('detects linked HEAD rewrites even when the entry directory mtime is restored', async () => { + it('detects an in-place linked HEAD rewrite that leaves the entry directory signature unchanged', async () => { const commonDir = await makeRoot() const entry = join(commonDir, 'worktrees', 'external-head') await mkdir(entry, { recursive: true }) @@ -224,10 +346,11 @@ describe('worktree base directory poller', () => { ) cleanups.push(() => poller.unsubscribe()) - const before = await stat(entry) + // Why: rewriting an existing HEAD in place changes only the file's own metadata, never the + // parent entry directory's mtime/ctime/ino/size — so HEAD (like the other structural leaves) + // must be re-stat'd every tick, not gated behind the entry-dir signature. await new Promise((resolve) => setTimeout(resolve, 10)) await writeFile(join(entry, 'HEAD'), 'ref: refs/heads/next') - await utimes(entry, before.atime, before.mtime) await waitForEvents(received, (flat) => flat.some((event) => event.type === 'update' && event.path === join(entry, 'HEAD')) @@ -335,6 +458,42 @@ describe('worktree base directory poller', () => { ) }) + it('detects a primary HEAD move immediately after resuming', async () => { + const commonDir = await makeRoot() + const headFile = join(commonDir, 'HEAD') + await writeFile(headFile, 'ref: refs/heads/main') + const visibility = createVisibilityHarness() + const received: WorktreeBasePollEvent[][] = [] + const fullScans: number[] = [] + const target = makeTarget('git-common', commonDir) + const poller = await startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + (events) => received.push(events), + { + pollIntervalMs: POLL_MS, + platform: 'linux', + visibility: visibility.source, + onFullScan: () => fullScans.push(Date.now()) + } + ) + cleanups.push(() => poller.unsubscribe()) + + visibility.hide() + await new Promise((resolve) => setTimeout(resolve, POLL_MS * 2)) + await writeFile(headFile, 'ref: refs/heads/feature') + await new Promise((resolve) => setTimeout(resolve, POLL_MS * 2)) + + expect(received.flat()).toHaveLength(0) + expect(fullScans).toHaveLength(0) + + visibility.show() + await waitForEvents(received, (flat) => + flat.some((event) => event.type === 'update' && event.path === headFile) + ) + expect(fullScans).toHaveLength(1) + }) + it('emits deletes for all known worktrees when the root vanishes', async () => { const root = await makeRoot() const worktree = join(root, 'external-5') diff --git a/src/main/ipc/worktree-base-directory-poller.ts b/src/main/ipc/worktree-base-directory-poller.ts index 3f4fc67571b..31a068146c8 100644 --- a/src/main/ipc/worktree-base-directory-poller.ts +++ b/src/main/ipc/worktree-base-directory-poller.ts @@ -1,6 +1,7 @@ import { readdir, stat } from 'node:fs/promises' import { join } from 'node:path' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { isMainWindowVisible, onMainWindowBecameVisible } from '../window/main-window-visibility' import type { WorktreeBaseRepoWatchConfig, WorktreeBaseWatchTarget @@ -11,9 +12,53 @@ export type WorktreeBasePollEvent = { type: 'create' | 'update' | 'delete'; path export type WorktreeBaseSubscription = { unsubscribe: () => Promise } +export type WorktreePollerWindowVisibility = { + isWindowVisible: () => boolean + onWindowBecameVisible: (listener: () => void) => () => void +} + +type WorktreePollerWindow = { + isDestroyed: () => boolean + isVisible?: () => boolean + isMinimized?: () => boolean +} + +const alwaysVisible: WorktreePollerWindowVisibility = { + isWindowVisible: () => true, + onWindowBecameVisible: () => () => {} +} + +export function createWorktreePollerWindowVisibility( + getWindow: () => WorktreePollerWindow | null +): WorktreePollerWindowVisibility { + // Why: only park a window that has actually been shown and is now hidden. A window + // that has NEVER been shown is either headless (ORCA_E2E_HEADLESS keeps a live but + // never-shown BrowserWindow) or still starting up — no show/restore signal is coming + // to resume it, so parking it would starve worktree freshness forever. Treat + // never-shown as visible and keep polling; only start parking once we've observed the + // window visible at least once. null/destroyed (serve/headless, macOS window-recreation + // gap) stay always-visible so a torn-down window never permanently parks the poller. + let hasBeenVisible = false + return { + isWindowVisible: () => { + const window = getWindow() + if (window === null || window.isDestroyed()) { + return true + } + if (isMainWindowVisible(window)) { + hasBeenVisible = true + return true + } + return !hasBeenVisible + }, + onWindowBecameVisible: onMainWindowBecameVisible + } +} + export type WorktreeBasePollerOptions = { pollIntervalMs?: number platform?: NodeJS.Platform + visibility?: WorktreePollerWindowVisibility /** Test hook: called whenever a full snapshot scan runs (vs. a gated skip). */ onFullScan?: () => void } @@ -145,6 +190,7 @@ async function startBasePoller( getRepos: () => ReadonlyMap, onEvents: (events: WorktreeBasePollEvent[]) => void, pollIntervalMs: number, + visibility: WorktreePollerWindowVisibility, onFullScan?: () => void ): Promise { let disposed = false @@ -152,6 +198,8 @@ async function startBasePoller( let tickCount = 0 let snapshot = await snapshotBase(target.path, getRepos()) let gateSignatures = await Promise.all(snapshot.gateDirs.map(dirSignature)) + let timer: ReturnType | null = null + let parkedWhileHidden = false // dir → tick when first seen without a `.git` marker const pendingMarkers = new Map() for (const [dir, marker] of snapshot.markers) { @@ -201,9 +249,9 @@ async function startBasePoller( } } - const tick = async (): Promise => { + const poll = async (forceFullScan = false): Promise => { tickCount++ - if (tickCount % WORKTREE_BASE_BACKSTOP_TICKS === 0) { + if (forceFullScan || tickCount % WORKTREE_BASE_BACKSTOP_TICKS === 0) { await fullScan() return } @@ -222,25 +270,62 @@ async function startBasePoller( } } - const timer = setInterval(() => { - if (disposed || ticking) { + const tick = async (forceFullScan = false): Promise => { + timer = null + if (disposed) { + return + } + if (!visibility.isWindowVisible()) { + parkedWhileHidden = true + return + } + if (ticking) { return } ticking = true - void tick() - .catch(() => { - // Transient fs error: keep the previous snapshot and retry next tick. - }) - .finally(() => { - ticking = false - }) - }, pollIntervalMs) + // Why: measure from tick start so the cadence is start-to-start (like the old setInterval), not + // gap-after-completion — otherwise each visible refresh lands a full scan-duration late every tick. + const startedAt = Date.now() + try { + await poll(forceFullScan) + } catch { + // Transient fs error: keep the previous snapshot and retry next tick. + } finally { + ticking = false + } + if (!disposed) { + // Why: clamp to [0, pollIntervalMs]. Date.now() is not monotonic — a backward wall-clock jump (NTP) would + // otherwise make elapsed negative and push the next tick out by the adjustment (suppressing refreshes for + // minutes); the upper clamp caps the wait at one interval, the lower clamp keeps a long scan from going negative. + const nextDelay = Math.max( + 0, + Math.min(pollIntervalMs, pollIntervalMs - (Date.now() - startedAt)) + ) + timer = setTimeout(() => void tick(), nextDelay) + timer.unref?.() + } + } + + const unsubscribeVisibility = visibility.onWindowBecameVisible(() => { + if (disposed || !parkedWhileHidden) { + return + } + parkedWhileHidden = false + // Why: the ordinary dir-signature gate can miss same-granule changes made + // while hidden; resume must diff a fresh full snapshot against the baseline. + void tick(true) + }) + + timer = setTimeout(() => void tick(), pollIntervalMs) timer.unref?.() return { unsubscribe: async () => { disposed = true - clearInterval(timer) + if (timer) { + clearTimeout(timer) + } + unsubscribeVisibility() } } } @@ -256,8 +341,16 @@ export async function startWorktreeBaseDirectoryPoller( ): Promise { const pollIntervalMs = options.pollIntervalMs ?? WORKTREE_BASE_POLL_INTERVAL_MS const platform = options.platform ?? process.platform + const visibility = options.visibility ?? alwaysVisible if (target.kind === 'git-common') { - return startGitCommonWatch(target, onEvents, pollIntervalMs, platform, options.onFullScan) + return startGitCommonWatch( + target, + onEvents, + pollIntervalMs, + platform, + visibility, + options.onFullScan + ) } - return startBasePoller(target, getRepos, onEvents, pollIntervalMs, options.onFullScan) + return startBasePoller(target, getRepos, onEvents, pollIntervalMs, visibility, options.onFullScan) } diff --git a/src/main/ipc/worktree-base-directory-watcher.test.ts b/src/main/ipc/worktree-base-directory-watcher.test.ts index c560b5e51aa..0a5773b13a0 100644 --- a/src/main/ipc/worktree-base-directory-watcher.test.ts +++ b/src/main/ipc/worktree-base-directory-watcher.test.ts @@ -10,6 +10,10 @@ vi.mock('fs/promises', () => ({ })) vi.mock('./worktree-base-directory-poller', () => ({ + createWorktreePollerWindowVisibility: vi.fn(() => ({ + isWindowVisible: () => true, + onWindowBecameVisible: () => () => {} + })), startWorktreeBaseDirectoryPoller: vi.fn() })) diff --git a/src/main/ipc/worktree-base-directory-watcher.ts b/src/main/ipc/worktree-base-directory-watcher.ts index 23352d688f1..85cd501a2eb 100644 --- a/src/main/ipc/worktree-base-directory-watcher.ts +++ b/src/main/ipc/worktree-base-directory-watcher.ts @@ -17,7 +17,10 @@ import { buildWorktreeBaseDirectoryWatchTargets, clearWorktreeBaseDirectoryWatchTargetWarnings } from './worktree-base-directory-watch-targets' -import { startWorktreeBaseDirectoryPoller } from './worktree-base-directory-poller' +import { + createWorktreePollerWindowVisibility, + startWorktreeBaseDirectoryPoller +} from './worktree-base-directory-poller' type ActiveWatch = WorktreeBaseWatchTarget & { mainWindow: BrowserWindow @@ -58,9 +61,8 @@ function scheduleNotification(watch: ActiveWatch, changes: PendingNotificationIn for (const repoId of changes.headIdentityRepoIds ?? []) { watch.pendingHeadIdentityRepoIds.add(repoId) } - if (watch.notifyTimer) { - clearTimeout(watch.notifyTimer) - } + // clearTimeout tolerates null (no-op), so no guard needed before rescheduling. + clearTimeout(watch.notifyTimer ?? undefined) watch.notifyTimer = setTimeout(() => { watch.notifyTimer = null if (watch.disposed || watch.mainWindow.isDestroyed()) { @@ -192,10 +194,14 @@ async function subscribeTarget( () => (activeWatches.get(target.key) ?? activeWatch)?.repos ?? target.repos, (events) => { const currentWatch = activeWatches.get(target.key) ?? activeWatch - if (!currentWatch || currentWatch.disposed) { - return + if (currentWatch && !currentWatch.disposed) { + handleLocalWatchEvents(currentWatch, null, events) } - handleLocalWatchEvents(currentWatch, null, events) + }, + { + visibility: createWorktreePollerWindowVisibility( + () => (activeWatches.get(target.key) ?? activeWatch)?.mainWindow ?? null + ) } ) activeWatch = createActiveWatch(target, mainWindow, subscription) @@ -241,9 +247,7 @@ async function removeWatch(key: string): Promise { } activeWatches.delete(key) watch.disposed = true - if (watch.notifyTimer) { - clearTimeout(watch.notifyTimer) - } + clearTimeout(watch.notifyTimer ?? undefined) clearPendingRepoIds(watch) await watch.subscription.unsubscribe().catch((error) => { console.warn(`[worktree-base-watcher] failed to unwatch ${watch.path}:`, error) diff --git a/src/main/ipc/worktree-git-common-polling.ts b/src/main/ipc/worktree-git-common-polling.ts index 8996c7b00c1..ed0043b3c5b 100644 --- a/src/main/ipc/worktree-git-common-polling.ts +++ b/src/main/ipc/worktree-git-common-polling.ts @@ -2,13 +2,12 @@ import { readdir, stat } from 'node:fs/promises' import { join } from 'node:path' import type { WorktreeBasePollEvent, - WorktreeBaseSubscription + WorktreeBaseSubscription, + WorktreePollerWindowVisibility } from './worktree-base-directory-poller' -// Shared with the darwin primary-metadata poll so the platforms cannot drift -// on which shallow leaves count as watchable metadata. `logs/HEAD` catches -// head moves that rewrite no other watched leaf (commit --amend, reset -// --soft); `config.worktree` carries the sparse flag. +// Shared with the darwin primary-metadata poll so platforms cannot drift. +// `logs/HEAD` catches head moves; `config.worktree` carries the sparse flag. export const PRIMARY_CHECKOUT_METADATA_FILES = [ 'HEAD', 'packed-refs', @@ -24,68 +23,87 @@ const LINKED_WORKTREE_HEAD_LOG_FILE = join('logs', 'HEAD') // same way the base poller's backstop rescan does. const INDEX_BACKSTOP_TICKS = 15 -function statSignature(s: { mtimeMs: number; ctimeMs: number; ino: number; size: number }): string { - return `${s.mtimeMs}:${s.ctimeMs}:${s.ino}:${s.size}` +function statSignature(s: { mtimeMs: number; ctimeMs: number; ino: number }): string { + return `${s.mtimeMs}:${s.ctimeMs}:${s.ino}` +} + +async function dirSignature(path: string): Promise { + try { + // Why: keep `size` — on a coarse-timestamp filesystem a same-granule directory + // allocation change would otherwise slip the readdir gate to the backstop. + const s = await stat(path) + return `${statSignature(s)}:${s.size}` + } catch { + return 'missing' + } } async function fileSignature(path: string): Promise { try { const s = await stat(path) - return s.isFile() ? statSignature(s) : null - } catch { - return null - } -} - -async function pathSignature(path: string): Promise { - try { - const s = await stat(path) - // Why: omitting ctime keeps unrelated metadata churn from re-opening the - // index gate, which would make the HEAD regression test vacuous. The gate - // is load-bearing for index-event emission between backstop ticks; the - // renderer's status poll is the ultimate freshness net. - return `${s.mtimeMs}:${s.ino}:${s.size}` + return s.isFile() ? `${statSignature(s)}:${s.size}` : null } catch { return null } } type GitCommonEntrySnapshot = { - dirSignature: string | null + dirSignature: string structuralSignatures: Map indexSignature: string | null headLogSignature: string | null } type GitCommonSnapshot = { - worktreesDirSignature: string | null + worktreesDirSignature: string entries: Map primarySignatures: Map + didFullScan: boolean } async function snapshotGitCommonEntry( entryPath: string, previous: GitCommonEntrySnapshot | undefined, - forceIndexRead: boolean + forceFullScan: boolean ): Promise { - const dirSignature = await pathSignature(entryPath) + // Why: HEAD, gitdir, locked, config.worktree and logs/HEAD are rewritten in place without bumping + // the entry-dir mtime, so — like the pre-idle-gate poller — they are re-stat'd EVERY tick, never + // gated behind the dir signature (else a raw HEAD/structural rewrite would slip to the ~30s + // backstop). Only `index` rides the entry-dir signature (its same-dir rewrites are index-backstop-bounded). const structuralSignatures = new Map() - await Promise.all( - LINKED_WORKTREE_STRUCTURAL_METADATA_FILES.map(async (name) => { - const signature = await fileSignature(join(entryPath, name)) - if (signature !== null) { - structuralSignatures.set(name, signature) + const [nextDirSignature, headLogSignature] = await Promise.all([ + dirSignature(entryPath), + fileSignature(join(entryPath, LINKED_WORKTREE_HEAD_LOG_FILE)), + Promise.all( + LINKED_WORKTREE_STRUCTURAL_METADATA_FILES.map(async (name) => { + const signature = await fileSignature(join(entryPath, name)) + if (signature !== null) { + structuralSignatures.set(name, signature) + } + }) + ) + ]) + if (nextDirSignature === 'missing') { + // A transient stat failure must not masquerade as a removal; the parent listing is authoritative. + return ( + previous ?? { + dirSignature: nextDirSignature, + structuralSignatures, + indexSignature: null, + headLogSignature } - }) - ) - // `logs/HEAD` lives in a subdirectory, so appends never bump the entry-dir - // mtime — it must be stat'd every tick rather than gated like `index`. - const headLogSignature = await fileSignature(join(entryPath, LINKED_WORKTREE_HEAD_LOG_FILE)) - const shouldReadIndex = forceIndexRead || !previous || previous.dirSignature !== dirSignature + ) + } + const shouldReadIndex = forceFullScan || !previous || previous.dirSignature !== nextDirSignature const indexSignature = shouldReadIndex ? await fileSignature(join(entryPath, LINKED_WORKTREE_INDEX_FILE)) : previous.indexSignature - return { dirSignature, structuralSignatures, indexSignature, headLogSignature } + return { + dirSignature: nextDirSignature, + structuralSignatures, + indexSignature, + headLogSignature + } } async function snapshotPrimaryCheckoutSignatures( @@ -107,41 +125,52 @@ async function snapshotGitCommon( commonDirPath: string, previous?: GitCommonSnapshot, includePrimary = true, - forceIndexRead = false + forceFullScan = false ): Promise { - const entriesByPath = new Map() const worktreesDir = join(commonDirPath, 'worktrees') - const worktreesDirSignature = await pathSignature(worktreesDir) - const primarySignatures = includePrimary - ? await snapshotPrimaryCheckoutSignatures(commonDirPath) - : new Map() - let entries + const [worktreesDirSignature, primarySignatures] = await Promise.all([ + dirSignature(worktreesDir), + includePrimary ? snapshotPrimaryCheckoutSignatures(commonDirPath) : new Map() + ]) + // Why: enumerate the worktrees dir EVERY tick rather than gating the readdir on its stat signature. + // A single readdir of a small dir is negligible next to the per-entry structural stats that already + // run each tick, and the signature gate could miss a same-granule add+remove on a coarse-mtime/FAT + // filesystem (its size/mtime/ino/ctime all collide), leaving a linked worktree add/remove undetected + // until the ~30s index backstop (#9882 review). The listing is the authoritative add/remove signal. + let entryPaths: string[] try { - entries = await readdir(worktreesDir, { withFileTypes: true }) - } catch { - // Missing worktrees dir is normal for repos without linked worktrees. - return { - worktreesDirSignature, - entries: entriesByPath, - primarySignatures + const entries = await readdir(worktreesDir, { withFileTypes: true }) + entryPaths = entries + .filter((entry) => entry.isDirectory()) + .map((entry) => join(worktreesDir, entry.name)) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + // Dir genuinely absent (no linked worktrees, or all removed) → authoritative empty listing. + entryPaths = [] + } else { + // Why: a TRANSIENT readdir failure (EIO/ESTALE/EMFILE, network/SSH hiccup) must not masquerade as + // "every worktree removed" — that would emit false delete events (and false creates next tick). + // Reuse the known entries so per-entry stats still run; a real removal surfaces as that entry's own + // stat miss (handled in snapshotGitCommonEntry), and the next successful readdir catches any add. + entryPaths = previous ? [...previous.entries.keys()] : [] } } + + const entries = new Map() await Promise.all( - entries.map(async (entry) => { - if (!entry.isDirectory()) { - return - } - const entryPath = join(worktreesDir, entry.name) - entriesByPath.set( - entryPath, - await snapshotGitCommonEntry(entryPath, previous?.entries.get(entryPath), forceIndexRead) - ) + entryPaths.map(async (entryPath) => { + const previousEntry = previous?.entries.get(entryPath) + entries.set(entryPath, await snapshotGitCommonEntry(entryPath, previousEntry, forceFullScan)) }) ) + // Why: the expensive per-entry `index` read stays gated on each entry's own dir signature; onFullScan + // now reflects an ungated index-metadata backstop fan-out (forceFullScan) — the real periodic cost — + // rather than the always-run worktrees-dir readdir. return { worktreesDirSignature, - entries: entriesByPath, - primarySignatures + entries, + primarySignatures, + didFullScan: forceFullScan } } @@ -228,6 +257,7 @@ export async function startGitCommonPolling( commonDirPath: string, onEvents: (events: WorktreeBasePollEvent[]) => void, pollIntervalMs: number, + visibility: WorktreePollerWindowVisibility, onFullScan?: () => void, includePrimary = true ): Promise { @@ -235,39 +265,83 @@ export async function startGitCommonPolling( let ticking = false let tickCount = 0 let snapshot = await snapshotGitCommon(commonDirPath, undefined, includePrimary) + let timer: ReturnType | null = null + let parkedWhileHidden = false - const timer = setInterval(() => { - if (disposed || ticking) { + const tick = async (forceFullScan = false): Promise => { + timer = null + if (disposed) { + return + } + if (!visibility.isWindowVisible()) { + parkedWhileHidden = true + return + } + if (ticking) { return } ticking = true + // Why: measure from tick start so cadence is start-to-start, not gap-after-completion (which would + // land each visible refresh a full scan-duration late every tick). + const startedAt = Date.now() tickCount++ - const forceIndexRead = tickCount % INDEX_BACKSTOP_TICKS === 0 - onFullScan?.() - void snapshotGitCommon(commonDirPath, snapshot, includePrimary, forceIndexRead) - .then((next) => { - if (disposed) { - return - } - const events = diffGitCommon(commonDirPath, snapshot, next) - snapshot = next - if (events.length > 0) { - onEvents(events) - } - }) - .catch(() => { - // Transient fs error: keep the previous snapshot and retry next tick. - }) - .finally(() => { - ticking = false - }) - }, pollIntervalMs) + const shouldForceFullScan = forceFullScan || tickCount % INDEX_BACKSTOP_TICKS === 0 + try { + const next = await snapshotGitCommon( + commonDirPath, + snapshot, + includePrimary, + shouldForceFullScan + ) + if (disposed) { + return + } + if (next.didFullScan) { + onFullScan?.() + } + const events = diffGitCommon(commonDirPath, snapshot, next) + snapshot = next + if (events.length > 0) { + onEvents(events) + } + } catch { + // Transient fs error: keep the previous snapshot and retry next tick. + } finally { + ticking = false + } + if (!disposed) { + // Why: clamp to [0, pollIntervalMs]. Date.now() is not monotonic — a backward wall-clock jump (NTP) would + // otherwise make elapsed negative and push the next tick out by the adjustment (suppressing refreshes for + // minutes); the upper clamp caps the wait at one interval, the lower clamp keeps a long scan from going negative. + const nextDelay = Math.max( + 0, + Math.min(pollIntervalMs, pollIntervalMs - (Date.now() - startedAt)) + ) + timer = setTimeout(() => void tick(), nextDelay) + timer.unref?.() + } + } + + const unsubscribeVisibility = visibility.onWindowBecameVisible(() => { + if (disposed || !parkedWhileHidden) { + return + } + parkedWhileHidden = false + // Why: a linked index can change without its parent dir signature moving; + // force the leaf read when diffing the retained pre-hide snapshot. + void tick(true) + }) + + timer = setTimeout(() => void tick(), pollIntervalMs) timer.unref?.() return { unsubscribe: async () => { disposed = true - clearInterval(timer) + if (timer) { + clearTimeout(timer) + } + unsubscribeVisibility() } } } diff --git a/src/main/ipc/worktree-git-common-watch.test.ts b/src/main/ipc/worktree-git-common-watch.test.ts index 9cc70f1d7e0..e4c508e45ff 100644 --- a/src/main/ipc/worktree-git-common-watch.test.ts +++ b/src/main/ipc/worktree-git-common-watch.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { mkdtemp, mkdir, realpath, rm } from 'node:fs/promises' +import { appendFile, mkdtemp, mkdir, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { subscribeViaWatcherProcess } from './parcel-watcher-process' @@ -8,7 +8,10 @@ import type { WatcherProcessHooks } from './parcel-watcher-process-subscription' import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' -import type { WorktreeBasePollEvent } from './worktree-base-directory-poller' +import type { + WorktreeBasePollEvent, + WorktreePollerWindowVisibility +} from './worktree-base-directory-poller' import { startGitCommonWatch } from './worktree-git-common-watch' vi.mock('./parcel-watcher-process', () => ({ @@ -17,6 +20,40 @@ vi.mock('./parcel-watcher-process', () => ({ const POLL_MS = 25 +const alwaysVisible: WorktreePollerWindowVisibility = { + isWindowVisible: () => true, + onWindowBecameVisible: () => () => {} +} + +function createVisibilityHarness(): { + source: WorktreePollerWindowVisibility + hide: () => void + show: () => void +} { + let visible = true + let listener: (() => void) | null = null + return { + source: { + isWindowVisible: () => visible, + onWindowBecameVisible: (nextListener) => { + listener = nextListener + return () => { + if (listener === nextListener) { + listener = null + } + } + } + }, + hide: () => { + visible = false + }, + show: () => { + visible = true + listener?.() + } + } +} + type ChildSubscription = { dir: string callback: WatcherProcessCallback @@ -67,7 +104,8 @@ describe('worktree git-common narrow watch (darwin)', () => { makeTarget(commonDir), (events) => received.push(events), POLL_MS, - 'darwin' + 'darwin', + alwaysVisible ) cleanups.push(() => watch.unsubscribe()) } @@ -167,6 +205,42 @@ describe('worktree git-common narrow watch (darwin)', () => { }) }) + it('keeps the native stream live while the primary poll is parked', async () => { + installSubscribeMock() + const commonDir = await makeCommonDir(true) + const headFile = join(commonDir, 'HEAD') + await writeFile(headFile, 'ref: refs/heads/main') + const visibility = createVisibilityHarness() + const received: WorktreeBasePollEvent[][] = [] + const fullScans: number[] = [] + const watch = await startGitCommonWatch( + makeTarget(commonDir), + (events) => received.push(events), + POLL_MS, + 'darwin', + visibility.source, + () => fullScans.push(Date.now()) + ) + cleanups.push(() => watch.unsubscribe()) + + visibility.hide() + await new Promise((resolve) => setTimeout(resolve, POLL_MS * 2)) + await writeFile(headFile, 'ref: refs/heads/feature') + await new Promise((resolve) => setTimeout(resolve, POLL_MS * 2)) + + expect(fullScans).toHaveLength(0) + const entryPath = join(commonDir, 'worktrees', 'native-while-hidden') + childSubscriptions[0].callback(null, [{ type: 'create', path: entryPath }]) + expect(received.flat()).toContainEqual({ type: 'create', path: entryPath }) + expect(childSubscriptions[0].unsubscribe).not.toHaveBeenCalled() + + visibility.show() + expect(fullScans).toHaveLength(1) + await vi.waitFor(() => { + expect(received.flat()).toContainEqual({ type: 'update', path: headFile }) + }) + }) + it('stops forwarding events and unsubscribes the child on dispose', async () => { installSubscribeMock() const commonDir = await makeCommonDir(true) @@ -175,7 +249,8 @@ describe('worktree git-common narrow watch (darwin)', () => { makeTarget(commonDir), (events) => received.push(events), POLL_MS, - 'darwin' + 'darwin', + alwaysVisible ) await watch.unsubscribe() expect(childSubscriptions[0].unsubscribe).toHaveBeenCalledTimes(1) @@ -187,3 +262,189 @@ describe('worktree git-common narrow watch (darwin)', () => { expect(received).toHaveLength(0) }) }) + +describe('worktree git-common polling gate (non-darwin)', () => { + const cleanups: (() => Promise)[] = [] + + afterEach(async () => { + await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())) + }) + + async function makePollingCommonDir(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-git-common-polling-')) + cleanups.push(() => rm(root, { recursive: true, force: true })) + const commonDir = await realpath(root) + await mkdir(join(commonDir, 'worktrees')) + return commonDir + } + + function makePollingTarget(path: string): WorktreeBaseWatchTarget { + return { + key: `git-common:local:${path}`, + kind: 'git-common', + path, + repos: new Map([['repo-1', { repoId: 'repo-1', repoName: 'project', nestWorkspaces: false }]]) + } + } + + async function startPollingWatch( + commonDir: string, + received: WorktreeBasePollEvent[][], + onFullScan?: () => void, + visibility: WorktreePollerWindowVisibility = alwaysVisible + ): Promise { + const watch = await startGitCommonWatch( + makePollingTarget(commonDir), + (events) => received.push(events), + POLL_MS, + 'linux', + visibility, + onFullScan + ) + cleanups.push(() => watch.unsubscribe()) + } + + it('skips the ungated index-metadata backstop on idle ticks', async () => { + // Why: idle ticks still re-stat structural leaves and list the (small) worktrees dir cheaply, but the + // heavier ungated per-entry index fan-out (onFullScan) must NOT run until the backstop — and no + // spurious events are emitted while nothing changes. + const commonDir = await makePollingCommonDir() + const entry = join(commonDir, 'worktrees', 'idle') + await mkdir(join(entry, 'logs'), { recursive: true }) + await writeFile(join(entry, 'HEAD'), 'ref: refs/heads/main') + await writeFile(join(entry, 'logs', 'HEAD'), 'baseline\n') + const received: WorktreeBasePollEvent[][] = [] + const fullScans = vi.fn() + + await startPollingWatch(commonDir, received, fullScans) + await new Promise((resolve) => setTimeout(resolve, POLL_MS * 6)) + + expect(fullScans).not.toHaveBeenCalled() + expect(received.flat()).toHaveLength(0) + }) + + it('detects linked worktree add and remove from the every-tick readdir', async () => { + // Why: the worktrees-dir listing runs every tick (not gated on its stat signature), so an add/remove + // surfaces within one poll interval even on a coarse-mtime filesystem whose dir signature would not + // move — without waiting on the index backstop (onFullScan). + const commonDir = await makePollingCommonDir() + const received: WorktreeBasePollEvent[][] = [] + const fullScans = vi.fn() + await startPollingWatch(commonDir, received, fullScans) + + const entry = join(commonDir, 'worktrees', 'added') + await mkdir(entry) + await vi.waitFor(() => { + expect(received.flat()).toContainEqual({ type: 'create', path: entry }) + }) + // The add is caught by the every-tick listing, NOT the 15-tick index backstop: detection lands well + // before a backstop could fire, so onFullScan must not have run. (On the old gated impl a coarse-FS + // signature collision would have deferred this to the backstop.) + expect(fullScans).not.toHaveBeenCalled() + + await rm(entry, { recursive: true }) + await vi.waitFor(() => { + expect(received.flat()).toContainEqual({ type: 'delete', path: entry }) + }) + }) + + it('does not fabricate worktree deletions when the readdir fails non-ENOENT (transient)', async () => { + // Why: a transient readdir failure (EIO/ESTALE/EMFILE/ENOTDIR, network/SSH hiccup) must not be read + // as "every linked worktree removed". Simulate a non-ENOENT failure by replacing the worktrees dir + // with a file so readdir throws ENOTDIR; the known entry must NOT be reported deleted. On the old + // catch-all (entryPaths = []) this emitted a false delete for every entry. + const commonDir = await makePollingCommonDir() + const entry = join(commonDir, 'worktrees', 'keep') + await mkdir(entry) + await writeFile(join(entry, 'HEAD'), 'ref: refs/heads/main') + const received: WorktreeBasePollEvent[][] = [] + await startPollingWatch(commonDir, received) + + await new Promise((resolve) => setTimeout(resolve, POLL_MS * 2)) + const worktreesDir = join(commonDir, 'worktrees') + await rm(worktreesDir, { recursive: true, force: true }) + await writeFile(worktreesDir, 'not-a-dir') + await new Promise((resolve) => setTimeout(resolve, POLL_MS * 4)) + + expect(received.flat()).not.toContainEqual({ type: 'delete', path: entry }) + await rm(worktreesDir, { force: true }) + }) + + it('detects an in-place structural (HEAD) write on a known entry every tick, without the index backstop', async () => { + // Why: a raw HEAD/gitdir/config.worktree rewrite does not bump the entry-dir mtime, so the + // structural leaves are re-stat'd every tick (never gated) — the change surfaces within one tick + // and does NOT require the ungated index-metadata backstop (onFullScan). + const commonDir = await makePollingCommonDir() + const entry = join(commonDir, 'worktrees', 'structural') + await mkdir(entry) + await writeFile(join(entry, 'HEAD'), 'ref: refs/heads/main') + const received: WorktreeBasePollEvent[][] = [] + const fullScans = vi.fn() + await startPollingWatch(commonDir, received, fullScans) + + const headPath = join(entry, 'HEAD') + // In-place rewrite: same file, different contents — no entry-dir mtime change. + await writeFile(headPath, 'ref: refs/heads/feature') + await vi.waitFor(() => { + expect(received.flat()).toContainEqual({ type: 'update', path: headPath }) + }) + expect(fullScans).not.toHaveBeenCalled() + }) + + it('polls linked logs/HEAD on every idle tick', async () => { + const commonDir = await makePollingCommonDir() + const entry = join(commonDir, 'worktrees', 'reflog') + await mkdir(join(entry, 'logs'), { recursive: true }) + const headLogPath = join(entry, 'logs', 'HEAD') + await writeFile(headLogPath, 'baseline\n') + const received: WorktreeBasePollEvent[][] = [] + const fullScans = vi.fn() + await startPollingWatch(commonDir, received, fullScans) + + await appendFile(headLogPath, 'next\n') + await vi.waitFor(() => { + expect(received.flat()).toContainEqual({ type: 'update', path: headLogPath }) + }) + expect(fullScans).not.toHaveBeenCalled() + }) + + it('forces a full scan on the 15-tick backstop', async () => { + const commonDir = await makePollingCommonDir() + const entry = join(commonDir, 'worktrees', 'backstop') + await mkdir(entry) + await writeFile(join(entry, 'index'), 'baseline') + const received: WorktreeBasePollEvent[][] = [] + const fullScans = vi.fn() + await startPollingWatch(commonDir, received, fullScans) + + await vi.waitFor(() => { + expect(fullScans).toHaveBeenCalledTimes(1) + }) + expect(received.flat()).toHaveLength(0) + }) + + it('forces a full fan-out when resuming after hidden', async () => { + const commonDir = await makePollingCommonDir() + const entry = join(commonDir, 'worktrees', 'resume') + await mkdir(entry) + const indexPath = join(entry, 'index') + await writeFile(indexPath, 'before') + const received: WorktreeBasePollEvent[][] = [] + const fullScans = vi.fn() + const visibility = createVisibilityHarness() + await startPollingWatch(commonDir, received, fullScans, visibility.source) + + visibility.hide() + await new Promise((resolve) => setTimeout(resolve, POLL_MS * 2)) + await writeFile(indexPath, 'after-longer') + await new Promise((resolve) => setTimeout(resolve, POLL_MS * 2)) + expect(fullScans).not.toHaveBeenCalled() + expect(received.flat()).toHaveLength(0) + + visibility.show() + await vi.waitFor(() => { + expect(received.flat()).toContainEqual({ type: 'update', path: indexPath }) + }) + expect(fullScans).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/ipc/worktree-git-common-watch.ts b/src/main/ipc/worktree-git-common-watch.ts index 705963ed726..a24adf8e357 100644 --- a/src/main/ipc/worktree-git-common-watch.ts +++ b/src/main/ipc/worktree-git-common-watch.ts @@ -4,7 +4,8 @@ import { subscribeViaWatcherProcess } from './parcel-watcher-process' import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' import type { WorktreeBasePollEvent, - WorktreeBaseSubscription + WorktreeBaseSubscription, + WorktreePollerWindowVisibility } from './worktree-base-directory-poller' import { PRIMARY_CHECKOUT_METADATA_FILES, @@ -69,42 +70,78 @@ async function startSnapshotDiffPoller( takeSnapshot: () => Promise>, onEvents: (events: WorktreeBasePollEvent[]) => void, pollIntervalMs: number, + visibility: WorktreePollerWindowVisibility, onFullScan?: () => void ): Promise { let disposed = false let ticking = false let snapshot = await takeSnapshot() + let timer: ReturnType | null = null + let parkedWhileHidden = false - const timer = setInterval(() => { - if (disposed || ticking) { + const tick = async (): Promise => { + timer = null + if (disposed) { + return + } + if (!visibility.isWindowVisible()) { + parkedWhileHidden = true + return + } + if (ticking) { return } ticking = true + // Why: measure from tick start so cadence is start-to-start, not gap-after-completion (which would + // land each visible refresh a full scan-duration late every tick). + const startedAt = Date.now() onFullScan?.() - void takeSnapshot() - .then((next) => { - if (disposed) { - return - } - const events = diffMtimeMap(snapshot, next) - snapshot = next - if (events.length > 0) { - onEvents(events) - } - }) - .catch(() => { - // Transient fs error: keep the previous snapshot and retry next tick. - }) - .finally(() => { - ticking = false - }) - }, pollIntervalMs) + try { + const next = await takeSnapshot() + if (disposed) { + return + } + const events = diffMtimeMap(snapshot, next) + snapshot = next + if (events.length > 0) { + onEvents(events) + } + } catch { + // Transient fs error: keep the previous snapshot and retry next tick. + } finally { + ticking = false + } + if (!disposed) { + // Why: clamp to [0, pollIntervalMs]. Date.now() is not monotonic — a backward wall-clock jump (NTP) would + // otherwise make elapsed negative and push the next tick out by the adjustment (suppressing refreshes for + // minutes); the upper clamp caps the wait at one interval, the lower clamp keeps a long scan from going negative. + const nextDelay = Math.max( + 0, + Math.min(pollIntervalMs, pollIntervalMs - (Date.now() - startedAt)) + ) + timer = setTimeout(() => void tick(), nextDelay) + timer.unref?.() + } + } + + const unsubscribeVisibility = visibility.onWindowBecameVisible(() => { + if (disposed || !parkedWhileHidden) { + return + } + parkedWhileHidden = false + void tick() + }) + + timer = setTimeout(() => void tick(), pollIntervalMs) timer.unref?.() return { unsubscribe: async () => { disposed = true - clearInterval(timer) + if (timer) { + clearTimeout(timer) + } + unsubscribeVisibility() } } } @@ -246,6 +283,7 @@ export async function startGitCommonWatch( onEvents: (events: WorktreeBasePollEvent[]) => void, pollIntervalMs: number, platform: NodeJS.Platform, + visibility: WorktreePollerWindowVisibility, onFullScan?: () => void ): Promise { if (platform === 'darwin') { @@ -255,6 +293,7 @@ export async function startGitCommonWatch( () => snapshotPrimaryCheckoutMetadata(target.path), onEvents, pollIntervalMs, + visibility, onFullScan ) ]) @@ -264,5 +303,5 @@ export async function startGitCommonWatch( } } } - return startGitCommonPolling(target.path, onEvents, pollIntervalMs, onFullScan) + return startGitCommonPolling(target.path, onEvents, pollIntervalMs, visibility, onFullScan) } diff --git a/src/main/native-chat/transcript-reader.ts b/src/main/native-chat/transcript-reader.ts index 85cd7357287..86353604e20 100644 --- a/src/main/native-chat/transcript-reader.ts +++ b/src/main/native-chat/transcript-reader.ts @@ -55,7 +55,7 @@ export async function readNativeChatTranscript( if (transcriptAgent === 'grok') { return { messages: await readTranscript(filePath, decodeGrokTranscriptLine) } } - return { error: `Unsupported agent for native chat transcript: ${agent}` } + return { error: `Unsupported agent for Chat UI transcript: ${agent}` } } catch (err) { // Why: ENOENT after a successful resolve is the same first-flush/rotation // race as an unresolved path — keep it retry-worthy (#8401). diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 5124b34c2da..8e0532a6753 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -10511,6 +10511,35 @@ describe('Store host-partitioned workspace sessions', () => { activeRepoId }) + const makeBoundHostSession = (ptyId: string | null): WorkspaceSessionState => ({ + ...getDefaultWorkspaceSession(), + activeRepoId: 'repo-1', + activeWorktreeId: 'repo-1::/worktree', + activeTabId: 'tab-1', + tabsByWorktree: { + 'repo-1::/worktree': [ + { + id: 'tab-1', + worktreeId: 'repo-1::/worktree', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId + } + ] + }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: ptyId ? { [TEST_LEAF_1]: ptyId } : {} + } + } + }) + it('migrates a legacy workspaceSession blob into the local partition', async () => { writeDataFile({ schemaVersion: 1, @@ -10652,6 +10681,81 @@ describe('Store host-partitioned workspace sessions', () => { ).toBe(7) }) + it('persists an SSH PTY binding only in the SSH host partition', async () => { + const store = await createStore() + store.setWorkspaceSession(makeBoundHostSession(null), 'local') + store.setWorkspaceSession(makeBoundHostSession(null), 'ssh:ssh-1') + + store.persistPtyBinding( + { + worktreeId: 'repo-1::/worktree', + tabId: 'tab-1', + leafId: TEST_LEAF_1, + ptyId: 'ssh:ssh-1@@remote-pty' + }, + 'ssh:ssh-1' + ) + + expect( + store.getWorkspaceSession('ssh:ssh-1').tabsByWorktree['repo-1::/worktree'][0]?.ptyId + ).toBe('ssh:ssh-1@@remote-pty') + expect( + store.getWorkspaceSession('local').tabsByWorktree['repo-1::/worktree'][0]?.ptyId + ).toBeNull() + }) + + it('rolls back a failed SSH PTY binding flush in the SSH host partition', async () => { + const store = await createStore() + store.setWorkspaceSession(makeBoundHostSession(null), 'local') + store.setWorkspaceSession(makeBoundHostSession(null), 'ssh:ssh-1') + const flush = vi.spyOn(store, 'flushOrThrow').mockImplementationOnce(() => { + throw new Error('disk unavailable') + }) + + expect(() => + store.persistPtyBinding( + { + worktreeId: 'repo-1::/worktree', + tabId: 'tab-1', + leafId: TEST_LEAF_1, + ptyId: 'ssh:ssh-1@@remote-pty' + }, + 'ssh:ssh-1' + ) + ).toThrow('disk unavailable') + flush.mockRestore() + + expect( + store.getWorkspaceSession('ssh:ssh-1').tabsByWorktree['repo-1::/worktree'][0]?.ptyId + ).toBeNull() + expect( + store.getWorkspaceSession('local').tabsByWorktree['repo-1::/worktree'][0]?.ptyId + ).toBeNull() + }) + + it('clears expired SSH PTY bindings from the SSH partition and legacy local copy', async () => { + const store = await createStore() + const ptyId = 'ssh:ssh-1@@remote-pty' + store.setWorkspaceSession(makeBoundHostSession(ptyId), 'local') + store.setWorkspaceSession(makeBoundHostSession(ptyId), 'ssh:ssh-1') + store.upsertSshRemotePtyLease({ + targetId: 'ssh-1', + ptyId: 'remote-pty', + worktreeId: 'repo-1::/worktree', + tabId: 'tab-1', + leafId: TEST_LEAF_1, + state: 'attached' + }) + + store.markSshRemotePtyLease('ssh-1', ptyId, 'expired') + + for (const hostId of ['local', 'ssh:ssh-1']) { + const session = store.getWorkspaceSession(hostId) + expect(session.tabsByWorktree['repo-1::/worktree'][0]?.ptyId).toBeNull() + expect(session.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId).toEqual({}) + } + }) + it('defaults an omitted hostId to the local partition', async () => { const store = await createStore() store.setWorkspaceSession(makeHostSession('repo-a'), 'runtime:env-a') diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 3eb82817212..0b070b79083 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -5910,17 +5910,24 @@ export class Store { } // Why: sync-flush the pty binding before pty:spawn returns to close the spawn/persist SIGKILL race (Issue #217). - persistPtyBinding(args: { - worktreeId: string - tabId: string - leafId: string - ptyId: string - incarnationId?: string - startupCwd?: string - }): void { - const session = this.state.workspaceSession - if (!session) { - return + persistPtyBinding( + args: { + worktreeId: string + tabId: string + leafId: string + ptyId: string + incarnationId?: string + startupCwd?: string + }, + hostId?: string | null + ): void { + const resolvedHostId = this.resolveHostId(hostId) + const session = this.getWorkspaceSession(resolvedHostId) + if (resolvedHostId !== LOCAL_EXECUTION_HOST_ID) { + this.state.workspaceSessionsByHostId = { + ...this.state.workspaceSessionsByHostId, + [resolvedHostId]: session + } } const sessionBeforeBinding = cloneWorkspaceSessionState(session) const paneKey = `${args.tabId}:${args.leafId}` @@ -5937,6 +5944,16 @@ export class Store { [repoId]: currentRevision + 1 } } + const restoreSession = (): void => { + if (resolvedHostId === LOCAL_EXECUTION_HOST_ID) { + this.state.workspaceSession = sessionBeforeBinding + } else { + this.state.workspaceSessionsByHostId = { + ...this.state.workspaceSessionsByHostId, + [resolvedHostId]: sessionBeforeBinding + } + } + } if (args.incarnationId) { session.terminalPtyIncarnationsByPaneKey = { ...session.terminalPtyIncarnationsByPaneKey, @@ -5980,7 +5997,7 @@ export class Store { try { this.flushOrThrow() } catch (err) { - this.state.workspaceSession = sessionBeforeBinding + restoreSession() throw err } return @@ -6028,7 +6045,7 @@ export class Store { try { this.flushOrThrow() } catch (err) { - this.state.workspaceSession = sessionBeforeBinding + restoreSession() throw err } } @@ -6393,54 +6410,61 @@ export class Store { targetId: string, leases: SshRemotePtyLease[] ): boolean { - const session = this.state.workspaceSession - if (!leases?.length || !session) { + if (!leases?.length) { return false } let changed = false - for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree ?? {})) { - for (const tab of tabs) { - if ( - tab.ptyId && - leases.some((lease) => - this.sshRemotePtyLeaseMayReferenceBinding(lease, { - ptyId: tab.ptyId!, - worktreeId, - targetId, - tabId: tab.id - }) - ) - ) { - tab.ptyId = null - changed = true - } - } - } - for (const [tabId, layout] of Object.entries(session.terminalLayoutsByTabId ?? {})) { - const bindings = layout.ptyIdsByLeafId - if (!bindings) { - continue - } - const worktreeId = Object.entries(session.tabsByWorktree ?? {}).find(([, tabs]) => - tabs.some((tab) => tab.id === tabId) - )?.[0] - const nextBindings = Object.fromEntries( - Object.entries(bindings).filter( - ([leafId, ptyId]) => - !leases.some((lease) => + const sessions = new Set( + [ + this.state.workspaceSession, + this.state.workspaceSessionsByHostId?.[toSshExecutionHostId(targetId)] + ].filter((session): session is WorkspaceSessionState => Boolean(session)) + ) + for (const session of sessions) { + for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree ?? {})) { + for (const tab of tabs) { + if ( + tab.ptyId && + leases.some((lease) => this.sshRemotePtyLeaseMayReferenceBinding(lease, { - ptyId, - targetId, + ptyId: tab.ptyId!, worktreeId, - tabId, - leafId + targetId, + tabId: tab.id }) ) + ) { + tab.ptyId = null + changed = true + } + } + } + for (const [tabId, layout] of Object.entries(session.terminalLayoutsByTabId ?? {})) { + const bindings = layout.ptyIdsByLeafId + if (!bindings) { + continue + } + const worktreeId = Object.entries(session.tabsByWorktree ?? {}).find(([, tabs]) => + tabs.some((tab) => tab.id === tabId) + )?.[0] + const nextBindings = Object.fromEntries( + Object.entries(bindings).filter( + ([leafId, ptyId]) => + !leases.some((lease) => + this.sshRemotePtyLeaseMayReferenceBinding(lease, { + ptyId, + targetId, + worktreeId, + tabId, + leafId + }) + ) + ) ) - ) - if (Object.keys(nextBindings).length !== Object.keys(bindings).length) { - layout.ptyIdsByLeafId = nextBindings - changed = true + if (Object.keys(nextBindings).length !== Object.keys(bindings).length) { + layout.ptyIdsByLeafId = nextBindings + changed = true + } } } if (changed) { diff --git a/src/main/providers/ssh-pty-notification-routing.ts b/src/main/providers/ssh-pty-notification-routing.ts new file mode 100644 index 00000000000..63721877f98 --- /dev/null +++ b/src/main/providers/ssh-pty-notification-routing.ts @@ -0,0 +1,56 @@ +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import { isPtyIncarnationId } from '../../shared/pty-incarnation' +import type { + SshPtyDataCallback, + SshPtyExitCallback, + SshPtyReplayCallback +} from './ssh-pty-provider-contract' + +export type { SshPtyDataCallback, SshPtyExitCallback, SshPtyReplayCallback } + +export function subscribeSshPtyNotifications(args: { + mux: SshChannelMultiplexer + toAppPtyId: (id: string) => string + dataListeners: Set + replayListeners: Set + exitListeners: Set + livePtyIds: Set + recordExit: (relayPtyId: string, incarnationId: unknown) => void +}): () => void { + return args.mux.onNotification((method, params) => { + const id = args.toAppPtyId(params.id as string) + if (method === 'pty.exit') { + args.recordExit(params.id as string, params.incarnationId) + args.livePtyIds.delete(id) + for (const listener of args.exitListeners) { + listener({ + id, + code: params.code as number, + ...(isPtyIncarnationId(params.incarnationId) + ? { incarnationId: params.incarnationId } + : {}) + }) + } + return + } + if (method !== 'pty.data' && method !== 'pty.replay') { + return + } + args.livePtyIds.add(id) + if (method === 'pty.replay') { + for (const listener of args.replayListeners) { + listener({ id, data: params.data as string }) + } + return + } + for (const listener of args.dataListeners) { + listener({ + id, + data: params.data as string, + ...(typeof params.rawLength === 'number' ? { sequenceChars: params.rawLength } : {}), + ...(params.transformed === true ? { transformed: true } : {}), + ...(typeof params.seq === 'number' ? { seq: params.seq } : {}) + }) + } + }) +} diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts index 1891d8b07d8..f204369c9ef 100644 --- a/src/main/providers/ssh-pty-provider.test.ts +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -268,6 +268,16 @@ describe('SshPtyProvider', () => { env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true' } }) expect(result).toEqual({ id: scopedPty1 }) + expect(provider.hasPty(scopedPty1)).toBe(true) + }) + + it('keeps a spawned PTY live across an overlapping stale process list', async () => { + mux.request.mockResolvedValueOnce({ id: 'pty-new' }).mockResolvedValueOnce([]) + + const result = await provider.spawn({ cols: 80, rows: 24 }) + await provider.listProcesses() + + expect(provider.hasPty(result.id)).toBe(true) }) it('gates fresh startup intent with the relay ingress capability version', async () => { diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index 8527c829712..02611e79d15 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -8,12 +8,12 @@ import type { SshPtyExitCallback, SshPtyReplayCallback } from './ssh-pty-provider-contract' +import { subscribeSshPtyNotifications } from './ssh-pty-notification-routing' import { validateClaimedSshSpawn } from './ssh-agent-session-claim-validation' import { assertSshAgentSessionCreateResult, requestSshAgentSessionCreate } from './ssh-agent-session-create-operation' -import { isPtyIncarnationId } from '../../shared/pty-incarnation' import { mapSshPtyProcessList } from './ssh-agent-session-process-list' import { parseSshPtyAttachResult, @@ -36,6 +36,7 @@ export class SshPtyProvider implements IPtyProvider { private dataListeners = new Set() private replayListeners = new Set() private exitListeners = new Set() + private livePtyIds = new Set() // Why: stale notification callbacks must not outlive a disconnected provider. private unsubscribeNotifications: (() => void) | null = null readonly getAppliedSize: NonNullable @@ -52,41 +53,15 @@ export class SshPtyProvider implements IPtyProvider { this.agentSessionCapabilities = new SshAgentSessionCapabilities(mux) this.getAppliedSize = createSshPtyAppliedSizeReader(mux, connectionId) - this.unsubscribeNotifications = mux.onNotification((method, params) => { - switch (method) { - case 'pty.data': - for (const cb of this.dataListeners) { - cb({ - id: this.toAppPtyId(params.id as string), - data: params.data as string, - ...(typeof params.rawLength === 'number' - ? { sequenceChars: params.rawLength as number } - : {}), - ...(params.transformed === true ? { transformed: true } : {}), - ...(typeof params.seq === 'number' ? { seq: params.seq as number } : {}) - }) - } - break - - case 'pty.replay': - for (const cb of this.replayListeners) { - cb({ id: this.toAppPtyId(params.id as string), data: params.data as string }) - } - break - - case 'pty.exit': - this.spawnExitRaces.recordExit(params.id as string, params.incarnationId) - for (const cb of this.exitListeners) { - cb({ - id: this.toAppPtyId(params.id as string), - code: params.code as number, - ...(isPtyIncarnationId(params.incarnationId) - ? { incarnationId: params.incarnationId } - : {}) - }) - } - break - } + this.unsubscribeNotifications = subscribeSshPtyNotifications({ + mux, + toAppPtyId: (id) => this.toAppPtyId(id), + dataListeners: this.dataListeners, + replayListeners: this.replayListeners, + exitListeners: this.exitListeners, + livePtyIds: this.livePtyIds, + recordExit: (relayPtyId, incarnationId) => + this.spawnExitRaces.recordExit(relayPtyId, incarnationId) }) } @@ -98,6 +73,7 @@ export class SshPtyProvider implements IPtyProvider { this.dataListeners.clear() this.replayListeners.clear() this.exitListeners.clear() + this.livePtyIds.clear() } getConnectionId = (): string => this.connectionId @@ -124,13 +100,15 @@ export class SshPtyProvider implements IPtyProvider { } } if (opts.sessionId) { - return await reattachSshPtySessionWithExitFence({ + const result = await reattachSshPtySessionWithExitFence({ mux: this.mux, connectionId: this.connectionId, sessionId: opts.sessionId, options: opts, exitRaceTracker: this.spawnExitRaces }) + this.livePtyIds.add(result.id) + return result } const supportsCreateOperation = opts.agentSessionCreateOperationId @@ -181,9 +159,11 @@ export class SshPtyProvider implements IPtyProvider { throw new Error(validation.error) } } + const id = this.toAppPtyId(spawnResult.id) + this.livePtyIds.add(id) return { ...spawnResult, - id: this.toAppPtyId(spawnResult.id), + id, ...(claimed ? { agentSessionEnsure: { @@ -259,6 +239,7 @@ export class SshPtyProvider implements IPtyProvider { }, relayTimeoutOptions(opts.deadlineMs) ) + this.livePtyIds.delete(id) } async sendSignal(id: string, signal: string): Promise { @@ -317,7 +298,15 @@ export class SshPtyProvider implements IPtyProvider { undefined, relayTimeoutOptions(opts?.deadlineMs) ) - return mapSshPtyProcessList(result as PtyProcessInfo[], (id) => this.toAppPtyId(id)) + const processes = mapSshPtyProcessList(result as PtyProcessInfo[], (id) => this.toAppPtyId(id)) + for (const process of processes) { + this.livePtyIds.add(process.id) + } + return processes + } + + hasPty(id: string): boolean { + return this.livePtyIds.has(id) } async getDefaultShell(): Promise { diff --git a/src/main/pty-descendant-termination.test.ts b/src/main/pty-descendant-termination.test.ts index 776384f69b5..593693491f7 100644 --- a/src/main/pty-descendant-termination.test.ts +++ b/src/main/pty-descendant-termination.test.ts @@ -90,10 +90,25 @@ describe('collectDescendantRows', () => { expect(snapshot.capturedAtMs).toBe(CAPTURED_AT_MS) }) - it('returns a null root pgid when the root row is already gone', () => { + it('sweeps nothing when the root row is already gone (a vacated PID has no findable tree)', () => { + // The root (10) is absent from the table: it has exited. Row 20 still points + // at ppid 10, but that is a PID-reuse coincidence, not a real descendant — + // never collect it. const snapshot = collectDescendantRows(10, [row(20, 10, 20)], CAPTURED_AT_MS) expect(snapshot.rootPgid).toBeNull() - expect(snapshot.descendants.map((r) => r.pid)).toEqual([20]) + expect(snapshot.descendants).toEqual([]) + }) + + it('does not sweep unrelated processes that merely reference a vacated root PID as ppid', () => { + // The PTY root (500) has exited, so its row is absent. Other live processes + // (501/502/503) still list ppid 500 — either not-yet-reparented orphans or, in + // the hazardous case, children of a process that recycled PID 500. The walk is + // seeded only by a stale number, so it cannot tell them apart and must sweep + // none. (A root PID recycled to a *live* process would appear in the table and + // is out of scope for this guard.) + const table = [row(501, 500, 500), row(502, 500, 500), row(503, 500, 500)] + const snapshot = collectDescendantRows(500, table, CAPTURED_AT_MS) + expect(snapshot.descendants).toEqual([]) }) }) @@ -115,6 +130,21 @@ describe('captureDescendantSnapshot', () => { expect(vi.getTimerCount()).toBe(0) }) + it('captures no descendants and signals nothing when the root PID was already recycled', async () => { + // End-to-end proof for the #9191-class hazard: the captured PTY root (500) is + // gone; only unrelated live processes reference its vacated PID. Neither the + // snapshot nor the terminator may touch them. + const readTable = vi + .fn() + .mockResolvedValue(tableCapture([row(501, 500, 500), row(502, 500, 500), row(503, 500, 500)])) + const result = await captureDescendantSnapshot(500, { readTable, platform: 'darwin' }) + expect(result?.descendants).toEqual([]) + const sendSignal = vi.fn() + terminateDescendantSnapshot(result!, { sendSignal }) + expect(sendSignal).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) + it('is a null no-op on Windows', async () => { const readTable = vi.fn() expect(await captureDescendantSnapshot(10, { readTable, platform: 'win32' })).toBeNull() diff --git a/src/main/pty-descendant-termination.ts b/src/main/pty-descendant-termination.ts index 6869a287b0e..3683df75332 100644 --- a/src/main/pty-descendant-termination.ts +++ b/src/main/pty-descendant-termination.ts @@ -162,6 +162,13 @@ export function collectDescendantRows( childrenByPpid.set(row.ppid, [row]) } } + // Why: a ppid walk is only meaningful while the root is alive in this snapshot. + // An absent root has already exited — its real descendants reparent to pid 1 and + // become unreachable by ppid, so any rows still pointing at the vacated PID are a + // PID-reuse coincidence. Sweeping them could signal an unrelated process, so bail. + if (!rootRow) { + return { rootPgid: null, descendants: [], capturedAtMs } + } const descendants: ProcessTableRow[] = [] const queue = [rootPid] const visited = new Set(queue) @@ -178,7 +185,7 @@ export function collectDescendantRows( queue.push(child.pid) } } - return { rootPgid: rootRow?.pgid ?? null, descendants, capturedAtMs } + return { rootPgid: rootRow.pgid, descendants, capturedAtMs } } type SnapshotDeps = { diff --git a/src/main/runtime/graph-sync-mobile-snapshot-gating.test.ts b/src/main/runtime/graph-sync-mobile-snapshot-gating.test.ts index 3efcbcc8010..dcf89894fb5 100644 --- a/src/main/runtime/graph-sync-mobile-snapshot-gating.test.ts +++ b/src/main/runtime/graph-sync-mobile-snapshot-gating.test.ts @@ -686,8 +686,10 @@ describe('graph-sync mobile snapshot gating', () => { ).toBe(true) // The persisted SSH binding is removed with no renderer-visible change, so - // the renderer resends the unchanged version 1 — the tab must still drop. + // the renderer resends the unchanged version 1 after the bounded HUB-restart + // recovery grace — the tab must still drop. setSession(makeSession()) + vi.advanceTimersByTime(30_001) sync([makeRendererSnapshot({ version: 1 })]) vi.advanceTimersByTime(60) expect( @@ -762,9 +764,10 @@ describe('graph-sync mobile snapshot gating', () => { ?.tabs.some((tab) => tab.type === 'terminal' && tab.parentTabId === 'ssh-tab') ).toBe(true) - // Once the SSH binding disappears from persistence (and no live PTY backs - // it), the next renderer revision must stop preserving it. + // Once the recovery grace expires and the SSH binding disappears from + // persistence (with no live PTY), the next revision must stop preserving it. setSession(makeSession()) + vi.advanceTimersByTime(30_001) sync([makeRendererSnapshot({ version: 3, title: 'Renamed again' })]) vi.advanceTimersByTime(60) expect( diff --git a/src/main/runtime/mobile-rpc-allowlist.test.ts b/src/main/runtime/mobile-rpc-allowlist.test.ts index 91c93306b32..5003a15df90 100644 --- a/src/main/runtime/mobile-rpc-allowlist.test.ts +++ b/src/main/runtime/mobile-rpc-allowlist.test.ts @@ -131,4 +131,13 @@ describe('mobile RPC allowlist', () => { expect(missing).toEqual([]) }) + + it('does not grant mobile credentials control over host updates', () => { + const allowed = mobileRpcAllowlist() + expect( + ['updater.getStatus', 'updater.check', 'updater.download', 'updater.install'].filter( + (method) => allowed.has(method) + ) + ).toEqual([]) + }) }) diff --git a/src/main/runtime/multi-client-navigation-isolation.integration.test.ts b/src/main/runtime/multi-client-navigation-isolation.integration.test.ts index d475d6aa08b..c061f19c26e 100644 --- a/src/main/runtime/multi-client-navigation-isolation.integration.test.ts +++ b/src/main/runtime/multi-client-navigation-isolation.integration.test.ts @@ -190,6 +190,10 @@ function activeTabId(response: Record): string | null { return (response.result as RuntimeMobileSessionTabsResult | undefined)?.activeTabId ?? null } +function snapshotVersion(response: Record): number { + return (response.result as RuntimeMobileSessionTabsResult | undefined)?.snapshotVersion ?? -1 +} + function seedSessionTabs(runtime: OrcaRuntimeService): void { const tabs = ['host-tab', 'client-a-tab', 'client-a2-tab', 'client-b-tab'].map((id, index) => ({ type: 'terminal' as const, @@ -410,11 +414,17 @@ describe('paired runtime navigation isolation', () => { notifyClients: false } }) - expect(activeTabId(await harness.readerA.next('select-a2'))).toBe('client-a2-tab') + const selectA2 = await harness.readerA.next('select-a2') + expect(activeTabId(selectA2)).toBe('client-a2-tab') harness.runtime.notifyMobileSessionTabsChanged(SESSION_WORKTREE_ID) const [updateA, updateB] = await Promise.all([ - harness.readerA.next('tabs-a', (response) => resultType(response) === 'updated'), + harness.readerA.next( + 'tabs-a', + (response) => + resultType(response) === 'updated' && + snapshotVersion(response) >= snapshotVersion(selectA2) + ), harness.readerB.next('tabs-b', (response) => resultType(response) === 'updated') ]) expect(activeTabId(updateA)).toBe('client-a2-tab') diff --git a/src/main/runtime/orca-runtime-files.test.ts b/src/main/runtime/orca-runtime-files.test.ts index 562dbb5602b..f92ebd6b656 100644 --- a/src/main/runtime/orca-runtime-files.test.ts +++ b/src/main/runtime/orca-runtime-files.test.ts @@ -99,6 +99,10 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ import { awaitRuntimeFileWatcherUnsubscribes, RuntimeFileCommands } from './orca-runtime-files' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { + resetSshConnectionGenerations, + setSshConnectionGeneration +} from '../ssh/ssh-connection-generation' import { SEARCH_TIMEOUT_MS } from '../../shared/text-search' type MockRuntimeSearchChild = EventEmitter & { @@ -207,6 +211,7 @@ describe('RuntimeFileCommands', () => { watchMock.mockReset() checkRgAvailableMock.mockReset() vi.mocked(getSshFilesystemProvider).mockReset() + resetSshConnectionGenerations() getLocalGitOptionsForRegisteredWorktreeMock.mockReset() wslAwareSpawnMock.mockReset() getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({}) @@ -367,11 +372,64 @@ describe('RuntimeFileCommands', () => { const { commands } = createRuntimeFileCommands() resolveAuthorizedPathMock.mockImplementation(async (p: string) => p) - await commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts') + await commands.renameFileExplorerPath( + 'id:wt-1', + 'old.ts', + 'new.ts', + undefined, + undefined, + 'local' + ) expect(renameMock).toHaveBeenCalledWith('/repo/old.ts', '/repo/new.ts') }) + it('rejects legacy paired local mutations before selecting a filesystem provider', async () => { + const { commands } = createRuntimeFileCommands() + + await expect(commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts')).rejects.toThrow( + 'newer Orca client' + ) + + expect(getSshFilesystemProvider).not.toHaveBeenCalled() + expect(renameMock).not.toHaveBeenCalled() + }) + + it('rejects legacy paired SSH mutations before selecting a filesystem provider', async () => { + const { commands, store } = createRuntimeFileCommands() + store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) + + await expect( + 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() + }) + + it('rejects a local expectation when the worktree moved to SSH', async () => { + const { commands, store } = createRuntimeFileCommands() + store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) + + await expect( + 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() + }) + + it('rejects an SSH expectation when the worktree moved to HUB-local', async () => { + const { commands } = createRuntimeFileCommands() + + await expect( + 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() + }) + it('allows runtime-local case-only rename with IPC parity guard behavior', async () => { const { commands } = createRuntimeFileCommands() mockLocalPathStats({ @@ -379,7 +437,14 @@ describe('RuntimeFileCommands', () => { '/repo/readme.md': [10, 100] }) - await commands.renameFileExplorerPath('id:wt-1', 'README.md', 'readme.md') + await commands.renameFileExplorerPath( + 'id:wt-1', + 'README.md', + 'readme.md', + undefined, + undefined, + 'local' + ) expect(renameMock).toHaveBeenCalledWith('/repo/README.md', '/repo/readme.md') }) @@ -391,9 +456,9 @@ describe('RuntimeFileCommands', () => { '/repo/new.ts': [11, 111] }) - await expect(commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts')).rejects.toThrow( - "A file or folder named 'new.ts' already exists in this location" - ) + await expect( + commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts', undefined, undefined, 'local') + ).rejects.toThrow("A file or folder named 'new.ts' already exists in this location") expect(renameMock).not.toHaveBeenCalled() }) @@ -406,7 +471,14 @@ describe('RuntimeFileCommands', () => { }) await expect( - commands.renameFileExplorerPath('id:wt-1', 'README.md', 'README-hardlink.md') + commands.renameFileExplorerPath( + 'id:wt-1', + 'README.md', + 'README-hardlink.md', + undefined, + undefined, + 'local' + ) ).rejects.toThrow("A file or folder named 'README-hardlink.md' already exists in this location") expect(renameMock).not.toHaveBeenCalled() @@ -420,7 +492,14 @@ describe('RuntimeFileCommands', () => { }) await expect( - commands.renameFileExplorerPath('id:wt-1', 'src/README.md', 'docs/readme.md') + commands.renameFileExplorerPath( + 'id:wt-1', + 'src/README.md', + 'docs/readme.md', + undefined, + undefined, + 'local' + ) ).rejects.toThrow("A file or folder named 'readme.md' already exists in this location") expect(renameMock).not.toHaveBeenCalled() @@ -432,9 +511,64 @@ describe('RuntimeFileCommands', () => { const { commands, store } = createRuntimeFileCommands() store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) - await commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts') + 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) + 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) + + 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() + }) + + 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' }) + + await expect( + commands.renameFileExplorerPath( + 'id:wt-1', + 'old.ts', + 'new.ts', + undefined, + 'ssh-1', + 'ssh:ssh-1' + ) + ).rejects.toThrow('SSH connection changed') + expect(renameNoClobber).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() + }) + + it('rejects a stale SSH expectation after the worktree becomes HUB-local', async () => { + const { commands } = createRuntimeFileCommands() + + await expect( + commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts', 0, 'ssh-1', 'ssh:ssh-1') + ).rejects.toThrow('Workspace host changed') expect(renameMock).not.toHaveBeenCalled() }) @@ -444,9 +578,9 @@ describe('RuntimeFileCommands', () => { const { commands, store } = createRuntimeFileCommands() store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) - await expect(commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts')).rejects.toThrow( - 'destination exists' - ) + 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() }) diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index 36bf393b157..c518a4ced3d 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -86,6 +86,8 @@ import { RuntimeMobileFilePathSearchCache } from './runtime-mobile-file-path-search' import { beginWatcherInstall } from '../ipc/watcher-removal-gate' +import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation' +import { toSshExecutionHostId } from '../../shared/execution-host' const MOBILE_FILE_LIST_LIMIT = 5000 const MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT = 20_000 @@ -97,8 +99,27 @@ const WINDOWS_RUNTIME_FILE_WATCH_DEBOUNCE_MS = 150 export const WINDOWS_RUNTIME_FILE_WATCH_CLOSE_DEADLINE_MS = 10_000 const TERMINAL_FILE_GRANT_TTL_MS = 10 * 60 * 1000 const OPEN_NOFOLLOW = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0 +const RUNTIME_FILE_MUTATION_UPDATE_REQUIRED = + 'Remote file changes require a newer Orca client. Update the paired client and try again.' + +function assertRuntimeFileMutationExpectation( + connectionId: string | undefined, + expectedExecutionHostId: string | undefined, + expectedSshTargetId: string | undefined, + expectedSshConnectionGeneration: number | undefined +): void { + if (!expectedExecutionHostId) { + throw new Error(RUNTIME_FILE_MUTATION_UPDATE_REQUIRED) + } + const actualExecutionHostId = connectionId ? toSshExecutionHostId(connectionId) : 'local' + if (expectedExecutionHostId !== actualExecutionHostId) { + throw new Error('Workspace host changed; refresh and try again') + } + assertSshMutationExpectation(connectionId, expectedSshTargetId, expectedSshConnectionGeneration) +} // Why: files.watch cleanup is synchronous RPC; track native Parcel unsubscribes so shutdown can drain them. const pendingRuntimeFileWatcherUnsubscribes = new Set>() + type RuntimeFileWatcherLease = { suspend(): Promise resume(): Promise @@ -1339,9 +1360,18 @@ export class RuntimeFileCommands { async writeFileExplorerFile( worktreeSelector: string, relativePath: string, - content: string + content: string, + expectedSshConnectionGeneration?: number, + expectedSshTargetId?: string, + expectedExecutionHostId?: string ): Promise<{ ok: true }> { const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) + assertRuntimeFileMutationExpectation( + target.connectionId, + expectedExecutionHostId, + expectedSshTargetId, + expectedSshConnectionGeneration + ) const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null if (target.connectionId) { if (!provider) { @@ -1369,9 +1399,18 @@ export class RuntimeFileCommands { async writeFileExplorerFileBase64( worktreeSelector: string, relativePath: string, - contentBase64: string + contentBase64: string, + expectedSshConnectionGeneration?: number, + expectedSshTargetId?: string, + expectedExecutionHostId?: string ): Promise<{ ok: true }> { const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) + assertRuntimeFileMutationExpectation( + target.connectionId, + expectedExecutionHostId, + expectedSshTargetId, + expectedSshConnectionGeneration + ) const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null const content = Buffer.from(contentBase64, 'base64') if (target.connectionId) { @@ -1392,9 +1431,18 @@ export class RuntimeFileCommands { worktreeSelector: string, relativePath: string, contentBase64: string, - append: boolean + append: boolean, + expectedSshConnectionGeneration?: number, + expectedSshTargetId?: string, + expectedExecutionHostId?: string ): Promise<{ ok: true }> { const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) + assertRuntimeFileMutationExpectation( + target.connectionId, + expectedExecutionHostId, + expectedSshTargetId, + expectedSshConnectionGeneration + ) const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null const content = Buffer.from(contentBase64, 'base64') if (target.connectionId) { @@ -1413,9 +1461,18 @@ export class RuntimeFileCommands { async createFileExplorerFile( worktreeSelector: string, - relativePath: string + relativePath: string, + expectedSshConnectionGeneration?: number, + expectedSshTargetId?: string, + expectedExecutionHostId?: string ): Promise<{ ok: true }> { const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) + assertRuntimeFileMutationExpectation( + target.connectionId, + expectedExecutionHostId, + expectedSshTargetId, + expectedSshConnectionGeneration + ) const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null if (target.connectionId) { if (!provider) { @@ -1437,9 +1494,18 @@ export class RuntimeFileCommands { async createFileExplorerDir( worktreeSelector: string, - relativePath: string + relativePath: string, + expectedSshConnectionGeneration?: number, + expectedSshTargetId?: string, + expectedExecutionHostId?: string ): Promise<{ ok: true }> { const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) + assertRuntimeFileMutationExpectation( + target.connectionId, + expectedExecutionHostId, + expectedSshTargetId, + expectedSshConnectionGeneration + ) const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null if (target.connectionId) { if (!provider) { @@ -1457,9 +1523,18 @@ export class RuntimeFileCommands { async createFileExplorerDirNoClobber( worktreeSelector: string, - relativePath: string + relativePath: string, + expectedSshConnectionGeneration?: number, + expectedSshTargetId?: string, + expectedExecutionHostId?: string ): Promise<{ ok: true }> { const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) + assertRuntimeFileMutationExpectation( + target.connectionId, + expectedExecutionHostId, + expectedSshTargetId, + expectedSshConnectionGeneration + ) const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null if (target.connectionId) { if (!provider) { @@ -1477,10 +1552,21 @@ export class RuntimeFileCommands { async commitFileExplorerUpload( worktreeSelector: string, tempRelativePath: string, - finalRelativePath: string + finalRelativePath: string, + expectedSshConnectionGeneration?: number, + expectedSshTargetId?: string, + expectedExecutionHostId?: string ): Promise<{ ok: true }> { - const tempTarget = await this.resolveFileExplorerPath(worktreeSelector, tempRelativePath) - const finalTarget = await this.resolveFileExplorerPath(worktreeSelector, finalRelativePath) + const [tempTarget, finalTarget] = await this.resolveFileExplorerPaths(worktreeSelector, [ + tempRelativePath, + finalRelativePath + ]) + assertRuntimeFileMutationExpectation( + tempTarget.connectionId, + expectedExecutionHostId, + expectedSshTargetId, + expectedSshConnectionGeneration + ) const provider = tempTarget.connectionId ? getSshFilesystemProvider(tempTarget.connectionId) : null @@ -1505,10 +1591,21 @@ export class RuntimeFileCommands { async renameFileExplorerPath( worktreeSelector: string, oldRelativePath: string, - newRelativePath: string + newRelativePath: string, + expectedSshConnectionGeneration?: number, + expectedSshTargetId?: string, + expectedExecutionHostId?: string ): Promise<{ ok: true }> { - const oldTarget = await this.resolveFileExplorerPath(worktreeSelector, oldRelativePath) - const newTarget = await this.resolveFileExplorerPath(worktreeSelector, newRelativePath) + const [oldTarget, newTarget] = await this.resolveFileExplorerPaths(worktreeSelector, [ + oldRelativePath, + newRelativePath + ]) + assertRuntimeFileMutationExpectation( + oldTarget.connectionId, + expectedExecutionHostId, + expectedSshTargetId, + expectedSshConnectionGeneration + ) const provider = oldTarget.connectionId ? getSshFilesystemProvider(oldTarget.connectionId) : null @@ -1531,12 +1628,20 @@ export class RuntimeFileCommands { async copyFileExplorerPath( worktreeSelector: string, sourceRelativePath: string, - destinationRelativePath: string + destinationRelativePath: string, + expectedSshConnectionGeneration?: number, + expectedSshTargetId?: string, + expectedExecutionHostId?: string ): Promise<{ ok: true }> { - const sourceTarget = await this.resolveFileExplorerPath(worktreeSelector, sourceRelativePath) - const destinationTarget = await this.resolveFileExplorerPath( + const [sourceTarget, destinationTarget] = await this.resolveFileExplorerPaths( worktreeSelector, - destinationRelativePath + [sourceRelativePath, destinationRelativePath] + ) + assertRuntimeFileMutationExpectation( + sourceTarget.connectionId, + expectedExecutionHostId, + expectedSshTargetId, + expectedSshConnectionGeneration ) const provider = sourceTarget.connectionId ? getSshFilesystemProvider(sourceTarget.connectionId) @@ -1565,9 +1670,18 @@ export class RuntimeFileCommands { async deleteFileExplorerPath( worktreeSelector: string, relativePath: string, - recursive?: boolean + recursive?: boolean, + expectedSshConnectionGeneration?: number, + expectedSshTargetId?: string, + expectedExecutionHostId?: string ): Promise<{ ok: true }> { const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) + assertRuntimeFileMutationExpectation( + target.connectionId, + expectedExecutionHostId, + expectedSshTargetId, + expectedSshConnectionGeneration + ) const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null if (target.connectionId) { if (!provider) { @@ -1768,13 +1882,23 @@ export class RuntimeFileCommands { worktreeSelector: string, relativePath: string ): Promise<{ worktree: ResolvedRuntimeFileWorktree; path: string; connectionId?: string }> { + const [target] = await this.resolveFileExplorerPaths(worktreeSelector, [relativePath]) + return target + } + + private async resolveFileExplorerPaths( + worktreeSelector: string, + relativePaths: readonly string[] + ): Promise<{ worktree: ResolvedRuntimeFileWorktree; path: string; connectionId?: string }[]> { const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) - const normalizedRelativePath = normalizeRuntimeRelativePath(relativePath) - return { + return relativePaths.map((relativePath) => ({ worktree: target.worktree, - path: joinWorktreeRelativePath(target.worktree.path, normalizedRelativePath), + path: joinWorktreeRelativePath( + target.worktree.path, + normalizeRuntimeRelativePath(relativePath) + ), connectionId: target.connectionId - } + })) } private async listRemoteMobileFiles( diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 897a2fcb8ec..4adce6c2154 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -63,7 +63,8 @@ import { RecentPtyOutputBuffer } from './recent-pty-output-buffer' import { HeadlessEmulator } from '../daemon/headless-emulator' import { HEADLESS_RUNTIME_WINDOW_ID, - type RuntimeMobileSessionTabsResult + type RuntimeMobileSessionTabsResult, + type RuntimeTerminalCreate } from '../../shared/runtime-types' import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' import type { RuntimeClientEvent } from '../../shared/runtime-client-events' @@ -407,7 +408,8 @@ vi.mock('../providers/ssh-git-dispatch', () => ({ })) vi.mock('../ipc/ssh', () => ({ - getActiveMultiplexer: getActiveMultiplexerMock + getActiveMultiplexer: getActiveMultiplexerMock, + getRegisteredSshState: () => ({ remotePlatform: 'linux' }) })) vi.mock('../ipc/preflight', () => ({ @@ -1251,6 +1253,29 @@ const store = { getProjects: () => [] } +function createRuntimeWithSshLease( + ptyId: string, + tabId: string, + state: 'expired' | 'terminated' = 'expired' +): OrcaRuntimeService { + const now = Date.now() + return new OrcaRuntimeService({ + ...store, + getSshRemotePtyLeases: () => [ + { + targetId: 'ssh-target', + ptyId, + worktreeId: TEST_WORKTREE_ID, + tabId, + leafId: HEADLESS_LEAF_ID, + state, + createdAt: now, + updatedAt: now + } + ] + }) +} + async function createExplicitAgentStatusHarness(options: { getForegroundProcess: (ptyId: string) => Promise confirmForegroundProcess?: (ptyId: string) => Promise @@ -1702,6 +1727,7 @@ describe('OrcaRuntimeService', () => { expect(status.capabilities).toContain('mobile.tasks.v1') expect(status.capabilities).toContain('terminal.quick-commands.v1') expect(status.capabilities).toContain('worktree.create-idempotency.v1') + expect(status.capabilities).toContain('files.mutation-ownership.v1') expect(status.capabilities).toContain('project-host-setup.v1') expect(status.capabilities).toContain('linear.issue-attribute-filter.v1') expect(status.capabilities).not.toContain('browser.screencast.v1') @@ -2331,6 +2357,221 @@ describe('OrcaRuntimeService', () => { }) }) + it('rejects pane resolution when leaf and PTY ownership disagree', () => { + const runtime = new OrcaRuntimeService(store) + const tabId = 'tab-1' + const leafId = HEADLESS_LEAF_ID + const paneKey = makePaneKey(tabId, leafId) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId, + worktreeId: TEST_WORKTREE_ID, + title: 'Codex', + activeLeafId: leafId, + layout: null + } + ], + leaves: [ + { + tabId, + worktreeId: TEST_WORKTREE_ID, + leafId, + paneRuntimeId: 1, + ptyId: 'pty-mismatched-owner' + } + ] + }) + runtime.registerPty('pty-mismatched-owner', `${TEST_REPO_ID}::/tmp/other-worktree`) + + expect(() => runtime.resolveTerminalPane(paneKey, TEST_WORKTREE_ID)).toThrow( + 'terminal_not_found' + ) + }) + + it('recovers a disconnected pane through one HUB-owned replacement', async () => { + const tabId = 'tab-recover' + const runtime = createRuntimeWithSshLease('pty-expired', tabId) + const paneKey = makePaneKey(tabId, HEADLESS_LEAF_ID) + runtime.registerPty('pty-expired', TEST_WORKTREE_ID, null, { + tabId, + leafId: HEADLESS_LEAF_ID + }) + const expiredHandle = runtime.resolveTerminalPane(paneKey, TEST_WORKTREE_ID).handle + runtime.onPtyExit('pty-expired', 0) + const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue({ + handle: 'term-replacement', + tabId, + paneKey, + ptyId: 'pty-replacement', + worktreeId: TEST_WORKTREE_ID, + title: null, + surface: 'background' + }) + + await expect( + runtime.recoverTerminalPane(paneKey, TEST_WORKTREE_ID, expiredHandle) + ).resolves.toMatchObject({ + handle: 'term-replacement', + tabId, + leafId: HEADLESS_LEAF_ID, + worktreeId: TEST_WORKTREE_ID + }) + expect(createTerminal).toHaveBeenCalledWith(`id:${TEST_WORKTREE_ID}`, { + tabId, + leafId: HEADLESS_LEAF_ID, + focus: false, + persistHostSessionBinding: true + }) + }) + + it('rejects missing host panes without authoritative expired binding evidence', async () => { + const runtime = new OrcaRuntimeService(store) + const tabId = 'tab-missing' + const paneKey = makePaneKey(tabId, HEADLESS_LEAF_ID) + const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue({ + handle: 'term-created', + tabId, + paneKey, + ptyId: 'pty-created', + worktreeId: TEST_WORKTREE_ID, + title: null, + surface: 'background' + }) + + await expect(runtime.recoverTerminalPane(paneKey, TEST_WORKTREE_ID)).rejects.toThrow( + 'terminal_not_found' + ) + expect(createTerminal).not.toHaveBeenCalled() + }) + + it('rejects recovery for live panes and mismatched worktrees', async () => { + const runtime = new OrcaRuntimeService(store) + const tabId = 'tab-live' + const paneKey = makePaneKey(tabId, HEADLESS_LEAF_ID) + runtime.registerPty('pty-live', TEST_WORKTREE_ID, null, { + tabId, + leafId: HEADLESS_LEAF_ID + }) + const liveHandle = runtime.resolveTerminalPane(paneKey, TEST_WORKTREE_ID).handle + const createTerminal = vi.spyOn(runtime, 'createTerminal') + + await expect( + runtime.recoverTerminalPane(paneKey, TEST_WORKTREE_ID, liveHandle) + ).rejects.toThrow('terminal_not_recoverable') + await expect( + runtime.recoverTerminalPane(paneKey, `${TEST_REPO_ID}::/other`, liveHandle) + ).rejects.toThrow('terminal_not_found') + expect(createTerminal).not.toHaveBeenCalled() + }) + + it('returns an already-connected replacement instead of spawning another pane', async () => { + const runtime = new OrcaRuntimeService(store) + const tabId = 'tab-cas' + const paneKey = makePaneKey(tabId, HEADLESS_LEAF_ID) + runtime.registerPty('pty-old', TEST_WORKTREE_ID, null, { + tabId, + leafId: HEADLESS_LEAF_ID + }) + const oldHandle = runtime.resolveTerminalPane(paneKey, TEST_WORKTREE_ID).handle + runtime.onPtyExit('pty-old', 0) + runtime.registerPty('pty-new', TEST_WORKTREE_ID, null, { + tabId, + leafId: HEADLESS_LEAF_ID + }) + const createTerminal = vi.spyOn(runtime, 'createTerminal') + + const recovered = await runtime.recoverTerminalPane(paneKey, TEST_WORKTREE_ID, oldHandle) + + expect(recovered.handle).not.toBe(oldHandle) + expect(recovered.ptyId).toBe('pty-new') + expect(createTerminal).not.toHaveBeenCalled() + }) + + it('deduplicates concurrent pane recovery across stale viewer handles', async () => { + const tabId = 'tab-concurrent' + const runtime = createRuntimeWithSshLease('pty-expired', tabId) + const paneKey = makePaneKey(tabId, HEADLESS_LEAF_ID) + runtime.registerPty('pty-expired', TEST_WORKTREE_ID, null, { + tabId, + leafId: HEADLESS_LEAF_ID + }) + const expiredHandle = runtime.resolveTerminalPane(paneKey, TEST_WORKTREE_ID).handle + runtime.onPtyExit('pty-expired', 0) + let finishCreate!: (result: RuntimeTerminalCreate) => void + const pendingCreate = new Promise((resolve) => { + finishCreate = resolve + }) + const createTerminal = vi.spyOn(runtime, 'createTerminal').mockReturnValue(pendingCreate) + + const first = runtime.recoverTerminalPane(paneKey, TEST_WORKTREE_ID, expiredHandle) + const second = runtime.recoverTerminalPane(paneKey, TEST_WORKTREE_ID, 'term-other-viewer') + finishCreate({ + handle: 'term-replacement', + tabId, + paneKey, + ptyId: 'pty-replacement', + worktreeId: TEST_WORKTREE_ID, + title: null, + surface: 'background' + }) + + await expect(first).resolves.toEqual(expect.objectContaining({ handle: 'term-replacement' })) + await expect(second).rejects.toThrow('terminal_not_found') + expect(createTerminal).toHaveBeenCalledOnce() + }) + + it('clears a failed pane recovery so a later reconnect can retry', async () => { + const tabId = 'tab-retry' + const runtime = createRuntimeWithSshLease('pty-expired', tabId) + const paneKey = makePaneKey(tabId, HEADLESS_LEAF_ID) + runtime.registerPty('pty-expired', TEST_WORKTREE_ID, null, { + tabId, + leafId: HEADLESS_LEAF_ID + }) + const expiredHandle = runtime.resolveTerminalPane(paneKey, TEST_WORKTREE_ID).handle + runtime.onPtyExit('pty-expired', 0) + const createTerminal = vi + .spyOn(runtime, 'createTerminal') + .mockRejectedValueOnce(new Error('relay_reconnecting')) + .mockResolvedValueOnce({ + handle: 'term-retry', + tabId, + paneKey, + ptyId: 'pty-retry', + worktreeId: TEST_WORKTREE_ID, + title: null, + surface: 'background' + }) + + await expect( + runtime.recoverTerminalPane(paneKey, TEST_WORKTREE_ID, expiredHandle) + ).rejects.toThrow('relay_reconnecting') + await expect( + runtime.recoverTerminalPane(paneKey, TEST_WORKTREE_ID, expiredHandle) + ).resolves.toMatchObject({ handle: 'term-retry' }) + expect(createTerminal).toHaveBeenCalledTimes(2) + }) + + it('does not recover a pane whose authoritative SSH lease was terminated', async () => { + const tabId = 'tab-terminated' + const runtime = createRuntimeWithSshLease('pty-terminated', tabId, 'terminated') + const paneKey = makePaneKey(tabId, HEADLESS_LEAF_ID) + runtime.registerPty('pty-terminated', TEST_WORKTREE_ID, null, { + tabId, + leafId: HEADLESS_LEAF_ID + }) + const handle = runtime.resolveTerminalPane(paneKey, TEST_WORKTREE_ID).handle + runtime.onPtyExit('pty-terminated', 0) + const createTerminal = vi.spyOn(runtime, 'createTerminal') + + await expect(runtime.recoverTerminalPane(paneKey, TEST_WORKTREE_ID, handle)).rejects.toThrow( + 'terminal_not_recoverable' + ) + expect(createTerminal).not.toHaveBeenCalled() + }) + it('drops a stale leaf when a woken agent PTY is re-keyed to a new leaf on renderer reload', async () => { const runtime = createRuntime() const tabId = 'tab-1' @@ -3141,6 +3382,7 @@ describe('OrcaRuntimeService', () => { worktrees: [ { id: mainId, + hostId: 'ssh:ssh-missing', path: '/home/user/repo', branch: '', isMainWorktree: true, @@ -3148,6 +3390,7 @@ describe('OrcaRuntimeService', () => { }, { id: childId, + hostId: 'ssh:ssh-missing', path: '/home/user/repo-child', branch: '', isMainWorktree: false, @@ -10854,13 +11097,17 @@ describe('OrcaRuntimeService', () => { }) try { - await runtime.createTerminal('path:C:/remote/repo', { + const terminal = await runtime.createTerminal('path:C:/remote/repo', { command: 'claude', title: 'worker' }) const spawnCall = spawn.mock.calls[0]?.[0] as { command?: string } | undefined expect(spawnCall?.command).toBe("claude '--dangerously-skip-permissions'") + expect(terminal).toMatchObject({ + executionHostId: 'ssh:ssh-1', + hostPlatform: 'linux' + }) } finally { unregisterSshGitProvider('ssh-1') } @@ -18069,6 +18316,54 @@ describe('OrcaRuntimeService', () => { ]) }) + it('does not invalidate a newly spawned SSH pane from an overlapping stale process list', async () => { + const runtime = new OrcaRuntimeService(store) + const ptyId = 'ssh:ssh-1@@pty-new' + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [], + hasPty: (candidate) => candidate === ptyId + }) + runtime.registerPty(ptyId, TEST_WORKTREE_ID, 'ssh-1', { + tabId: 'tab-1', + leafId: HEADLESS_LEAF_ID + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'ssh-spawn-list-race', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: `tab-1::${HEADLESS_LEAF_ID}`, + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: `tab-1::${HEADLESS_LEAF_ID}`, + parentTabId: 'tab-1', + leafId: HEADLESS_LEAF_ID, + title: 'SSH terminal', + ptyId, + isActive: true + } + ] + } + ] + }) + + const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(result.tabs).toEqual([ + expect.objectContaining({ ptyId, status: 'ready', terminal: expect.any(String) }) + ]) + }) + it('reattaches mobile terminal surfaces from saved PTY bindings when the PTY is connected', async () => { const runtime = new OrcaRuntimeService(store) runtime.attachWindow(1) @@ -19530,6 +19825,127 @@ describe('OrcaRuntimeService', () => { }) }) + it('hydrates an SSH worktree only from its SSH workspace-session partition', async () => { + const localSession = makeWorkspaceSessionWithHeadlessTerminal({ + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'local-decoy-tab', + ptyId: 'local-decoy-pty', + worktreeId: TEST_WORKTREE_ID, + title: 'Local decoy', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'local-decoy-tab': makeHeadlessTerminalLayout({ + [HEADLESS_LEAF_ID]: 'local-decoy-pty' + }) + } + }) + const sshPtyId = 'ssh:ssh-1@@remote-pty' + const sshSession = makeWorkspaceSessionWithHeadlessTerminal({ + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'ssh-host-tab', + ptyId: sshPtyId, + worktreeId: TEST_WORKTREE_ID, + title: 'SSH host terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'ssh-host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: sshPtyId }) + } + }) + const remoteRepo = { ...store.getRepo(TEST_REPO_ID)!, connectionId: 'ssh-1' } + const getWorkspaceSession = vi.fn((hostId?: string | null) => + hostId === 'ssh:ssh-1' ? sshSession : localSession + ) + const runtime = new OrcaRuntimeService({ + ...store, + getRepos: () => [remoteRepo], + getRepo: (id: string) => (id === TEST_REPO_ID ? remoteRepo : undefined), + getWorkspaceSession + } as never) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + + const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(listed.tabs).toEqual([ + expect.objectContaining({ parentTabId: 'ssh-host-tab', ptyId: sshPtyId }) + ]) + expect(listed.tabs).not.toEqual([expect.objectContaining({ parentTabId: 'local-decoy-tab' })]) + expect(getWorkspaceSession).toHaveBeenCalledWith('ssh:ssh-1') + }) + + it('closes a headless SSH tab only in its SSH workspace-session partition', async () => { + const sshPtyId = 'ssh:ssh-1@@remote-pty' + const localSession = makeWorkspaceSessionWithHeadlessTerminal() + let sshSession = makeWorkspaceSessionWithHeadlessTerminal({ + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'ssh-host-tab', + ptyId: sshPtyId, + worktreeId: TEST_WORKTREE_ID, + title: 'SSH host terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'ssh-host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: sshPtyId }) + } + }) + const remoteRepo = { ...store.getRepo(TEST_REPO_ID)!, connectionId: 'ssh-1' } + const setWorkspaceSession = vi.fn((session: WorkspaceSessionState, hostId?: string | null) => { + expect(hostId).toBe('ssh:ssh-1') + sshSession = session + }) + const kill = vi.fn(() => true) + const runtime = new OrcaRuntimeService({ + ...store, + getRepos: () => [remoteRepo], + getRepo: (id: string) => (id === TEST_REPO_ID ? remoteRepo : undefined), + getWorkspaceSession: (hostId?: string | null) => + hostId === 'ssh:ssh-1' ? sshSession : localSession, + setWorkspaceSession + } as never) + runtime.setPtyController({ + write: () => true, + kill, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + + await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'ssh-host-tab') + + expect(sshSession.tabsByWorktree[TEST_WORKTREE_ID]).toEqual([]) + expect(localSession.tabsByWorktree[TEST_WORKTREE_ID]).toHaveLength(1) + expect(setWorkspaceSession).toHaveBeenCalledTimes(1) + expect(kill).toHaveBeenCalledWith(sshPtyId) + }) + it('keeps live headless mobile session terminals when a desktop renderer publishes without them', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'serve-mobile-pty' }) const runtime = new OrcaRuntimeService(store) @@ -20014,7 +20430,9 @@ describe('OrcaRuntimeService', () => { it('operates PTY-backed mobile session terminals without a renderer graph', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'laptop-created-pty' }) const kill = vi.fn(() => true) + const closeTerminal = vi.fn() const runtime = new OrcaRuntimeService(store) + runtime.setNotifier({ closeTerminal } as never) runtime.setPtyController({ spawn, write: () => true, @@ -20043,6 +20461,7 @@ describe('OrcaRuntimeService', () => { ptyKilled: true }) expect(kill).toHaveBeenCalledWith('laptop-created-pty') + expect(closeTerminal).toHaveBeenCalledWith('laptop-tab') }) it('waits for renderer acknowledgement before returning a whole-tab close receipt', async () => { @@ -20794,6 +21213,467 @@ describe('OrcaRuntimeService', () => { expect(secondMerge.publicationEpoch.match(/:headless-merge:/g) ?? []).toHaveLength(1) }) + it('briefly preserves abnormal SSH exits for paired pane recovery', async () => { + vi.useFakeTimers() + try { + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')) + const runtime = new OrcaRuntimeService(store) + const ptyId = 'ssh:ssh-1@@pty-recover' + const tabId = 'host-tab' + runtime.registerPty(ptyId, TEST_WORKTREE_ID, 'ssh-1', { + tabId, + leafId: HEADLESS_LEAF_ID + }) + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'renderer-with-ssh-pane', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: `${tabId}::${HEADLESS_LEAF_ID}`, + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: `${tabId}::${HEADLESS_LEAF_ID}`, + parentTabId: tabId, + leafId: HEADLESS_LEAF_ID, + ptyId, + title: 'Terminal', + isActive: true + } + ] + } + ] + }) + runtime.onPtyExit(ptyId, -1) + + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'renderer-with-ssh-pane', + snapshotVersion: 2, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) + expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toEqual([ + expect.objectContaining({ parentTabId: tabId, status: 'pending-handle' }) + ]) + + vi.advanceTimersByTime(30_001) + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'renderer-with-ssh-pane', + snapshotVersion: 3, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) + expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toEqual([]) + } finally { + vi.useRealTimers() + } + }) + + it('briefly preserves an unregistered SSH pane while a restarted HUB rebuilds PTY state', async () => { + vi.useFakeTimers() + try { + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')) + const runtime = new OrcaRuntimeService(store) + const ptyId = 'ssh:ssh-1@@pty-restart' + const tabId = 'host-tab' + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'renderer-restarted-hub', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: `${tabId}::${HEADLESS_LEAF_ID}`, + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: `${tabId}::${HEADLESS_LEAF_ID}`, + parentTabId: tabId, + leafId: HEADLESS_LEAF_ID, + ptyId, + title: 'Terminal', + isActive: true + } + ] + } + ] + }) + + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'renderer-restarted-hub', + snapshotVersion: 2, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) + expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toEqual([ + expect.objectContaining({ parentTabId: tabId, status: 'pending-handle' }) + ]) + + vi.advanceTimersByTime(30_001) + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'renderer-restarted-hub', + snapshotVersion: 3, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) + expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toEqual([]) + } finally { + vi.useRealTimers() + } + }) + + it('hydrates a persisted SSH-owned pane before an attached renderer publishes its graph', async () => { + const ptyId = 'ssh:ssh-1@@pty-persisted' + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'host-tab', + ptyId, + worktreeId: TEST_WORKTREE_ID, + title: 'Persisted SSH Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: ptyId }) + } + }) + ) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'renderer-after-restart', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) + + expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toEqual([ + expect.objectContaining({ + parentTabId: 'host-tab', + leafId: HEADLESS_LEAF_ID, + ptyId, + status: 'pending-handle' + }) + ]) + }) + + it('hydrates a persisted SSH-owned pane when the restarted renderer has not published sessions', async () => { + const ptyId = 'ssh:ssh-1@@pty-persisted' + const sshSession = makeWorkspaceSessionWithHeadlessTerminal({ + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'host-tab', + ptyId, + worktreeId: TEST_WORKTREE_ID, + title: 'Persisted SSH Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: ptyId }) + } + }) + const localSession = getDefaultWorkspaceSession() + const remoteRepo = { ...store.getRepo(TEST_REPO_ID)!, connectionId: 'ssh-1' } + const getWorkspaceSession = vi.fn((hostId?: string | null) => + hostId === 'ssh:ssh-1' ? sshSession : localSession + ) + const runtime = new OrcaRuntimeService({ + ...store, + getRepos: () => [remoteRepo], + getRepo: (id: string) => (id === TEST_REPO_ID ? remoteRepo : undefined), + getWorkspaceSession + } as never) + + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + + expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toEqual([ + expect.objectContaining({ + parentTabId: 'host-tab', + leafId: HEADLESS_LEAF_ID, + ptyId, + status: 'pending-handle' + }) + ]) + expect(getWorkspaceSession).toHaveBeenCalledWith('ssh:ssh-1') + }) + + it('publishes a recovered SSH pane when its relay becomes ready after an empty restart replay', async () => { + const ptyId = 'ssh:ssh-1@@pty-recovered' + const sshSession = makeWorkspaceSessionWithHeadlessTerminal({ + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'host-tab', + ptyId, + worktreeId: TEST_WORKTREE_ID, + title: 'Recovered SSH Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: ptyId }) + } + }) + const localSession = getDefaultWorkspaceSession() + const remoteRepo = { ...store.getRepo(TEST_REPO_ID)!, connectionId: 'ssh-1' } + const runtime = new OrcaRuntimeService({ + ...store, + getRepos: () => [remoteRepo], + getRepo: (id: string) => (id === TEST_REPO_ID ? remoteRepo : undefined), + getWorkspaceSession: (hostId?: string | null) => + hostId === 'ssh:ssh-1' ? sshSession : localSession + } as never) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { id: ptyId, cwd: TEST_WORKTREE_PATH, title: 'Recovered SSH Terminal' } + ] + }) + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'renderer-empty-restart', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) + const events: RuntimeMobileSessionTabsResult[] = [] + runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + + runtime.notifySshRelayReady('ssh-1') + await vi.waitFor(() => + expect( + events.some((snapshot) => + snapshot.tabs.some( + (tab) => tab.type === 'terminal' && tab.ptyId === ptyId && tab.status === 'ready' + ) + ) + ).toBe(true) + ) + + expect(events.at(-1)?.tabs).toEqual([ + expect.objectContaining({ + parentTabId: 'host-tab', + ptyId, + status: 'ready', + terminal: expect.any(String) + }) + ]) + }) + + it('uses only a recent expired SSH lease as a bounded pane-recovery tombstone', async () => { + vi.useFakeTimers() + try { + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')) + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'host-tab', + ptyId: null, + worktreeId: TEST_WORKTREE_ID, + title: 'Expired SSH Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: undefined }) + } + }) + ) + let leaseState: 'expired' | 'terminated' = 'expired' + let leaseUpdatedAt = Date.now() + const getSshRemotePtyLeases = vi.fn(() => [ + { + targetId: 'ssh-1', + ptyId: 'pty-expired', + worktreeId: TEST_WORKTREE_ID, + tabId: 'host-tab', + leafId: HEADLESS_LEAF_ID, + state: leaseState, + createdAt: Date.now() - 1_000, + updatedAt: leaseUpdatedAt + } + ]) + const runtime = new OrcaRuntimeService({ + ...runtimeStore, + getSshRemotePtyLeases + } as never) + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents: { send: vi.fn() } + }) + const publishEmpty = (snapshotVersion: number): void => { + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'renderer-expired-lease', + snapshotVersion, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) + } + + publishEmpty(1) + expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toEqual([ + expect.objectContaining({ parentTabId: 'host-tab', status: 'pending-handle' }) + ]) + + vi.advanceTimersByTime(30_001) + publishEmpty(2) + expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toEqual([]) + + leaseState = 'terminated' + leaseUpdatedAt = Date.now() + publishEmpty(3) + expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toEqual([]) + } finally { + vi.useRealTimers() + } + }) + + it('does not preserve a normally exited SSH shell for pane recovery', async () => { + const runtime = new OrcaRuntimeService(store) + const ptyId = 'ssh:ssh-1@@pty-normal-exit' + runtime.registerPty(ptyId, TEST_WORKTREE_ID, 'ssh-1', { + tabId: 'host-tab', + leafId: HEADLESS_LEAF_ID + }) + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'renderer-normal-exit', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: `host-tab::${HEADLESS_LEAF_ID}`, + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: `host-tab::${HEADLESS_LEAF_ID}`, + parentTabId: 'host-tab', + leafId: HEADLESS_LEAF_ID, + ptyId, + title: 'Terminal', + isActive: true + } + ] + } + ] + }) + runtime.onPtyExit(ptyId, 0) + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'renderer-normal-exit', + snapshotVersion: 2, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) + + expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toEqual([]) + }) + it('hydrates persisted serve-owned mobile session terminals while a renderer is attached', async () => { const focusTerminal = vi.fn() const spawn = vi.fn().mockResolvedValue({ id: 'serve-persisted-pty', isReattach: true }) @@ -21367,6 +22247,94 @@ describe('OrcaRuntimeService', () => { expect(getSession().terminalLayoutsByTabId['host-tab']).toBeUndefined() }) + it('retires an SSH-owned surface when a stale renderer acknowledges close after relay recovery', async () => { + const ptyId = 'ssh:ssh-1@@relay-recovered-pty' + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'host-tab', + ptyId, + worktreeId: TEST_WORKTREE_ID, + title: 'Recovered SSH Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: ptyId }) + } + }) + ) + const closeTerminal = vi.fn() + const closeTerminalTab = vi.fn(async () => {}) + let runtime!: OrcaRuntimeService + const kill = vi.fn((closedPtyId: string) => { + runtime.onPtyExit(closedPtyId, 0) + return true + }) + runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.setNotifier({ closeTerminal, closeTerminalTab } as never) + runtime.setPtyController({ + write: () => true, + kill, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + runtime.registerPty(ptyId, TEST_WORKTREE_ID, 'ssh-1', { + tabId: 'host-tab', + leafId: HEADLESS_LEAF_ID + }) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'host-tab', + worktreeId: TEST_WORKTREE_ID, + title: 'Recovered SSH Terminal', + activeLeafId: HEADLESS_LEAF_ID, + layout: null + } + ], + leaves: [ + { + tabId: 'host-tab', + worktreeId: TEST_WORKTREE_ID, + leafId: HEADLESS_LEAF_ID, + paneRuntimeId: 1, + ptyId + }, + { + tabId: 'host-tab', + worktreeId: TEST_WORKTREE_ID, + leafId: HEADLESS_SECOND_LEAF_ID, + paneRuntimeId: 2, + ptyId: 'stale-renderer-pty' + } + ] + }) + const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + const terminal = listed.tabs.find((tab) => tab.type === 'terminal') + if (!terminal || terminal.type !== 'terminal' || !terminal.terminal) { + throw new Error('Expected a ready SSH terminal') + } + + await expect(runtime.closeTerminal(terminal.terminal)).resolves.toEqual({ + handle: terminal.terminal, + tabId: 'host-tab', + ptyKilled: true + }) + + expect(closeTerminalTab).toHaveBeenCalledWith('host-tab') + expect(closeTerminal).toHaveBeenCalledWith('host-tab') + expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([]) + expect(getSession().terminalLayoutsByTabId['host-tab']).toBeUndefined() + expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toEqual([]) + }) + it('keeps the renderer close transaction for an adopted runtime-owned tab', async () => { // The renderer pin state can be newer than the debounced session, so once adopted its live close guard must win over stale persisted metadata. const servePtyId = 'serve-adopted-1' @@ -24776,6 +25744,7 @@ describe('OrcaRuntimeService', () => { workspaceKind: 'git', worktreeId: 'repo-1::/tmp/worktree-a', repoId: 'repo-1', + hostId: 'local', terminalPlatform: process.platform, repo: 'repo', path: '/tmp/worktree-a', diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 5bab178d41b..6b69e3b06c6 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -169,10 +169,13 @@ import type { } from '../../shared/types' import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree-removal' import { + LOCAL_EXECUTION_HOST_ID, getRepoExecutionHostId, parseExecutionHostId, + toSshExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' +import { getRegisteredSshState } from '../ipc/ssh' import type { AgentProviderSessionMetadata, SleepingAgentLaunchConfig @@ -185,6 +188,7 @@ import { type RuntimeNavigationTarget } from '../../shared/runtime-navigation' import type { SshConnectionState } from '../../shared/ssh-types' +import { getPublicSshState } from './public-ssh-state' import { closeTerminalTabInWorkspaceSession } from '../../shared/workspace-session-terminal-tab-close' import type { LinearCurrentIssueContextHints, @@ -325,6 +329,7 @@ import { BROWSER_HEADLESS_RUNTIME_CAPABILITY, BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY, MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY, RUNTIME_CAPABILITIES, RUNTIME_PROTOCOL_VERSION, type RuntimeCapability @@ -412,7 +417,10 @@ import type { AutomationService } from '../automations/service' import { RuntimeBrowserCommands } from './orca-runtime-browser' import { RemoteRuntimeTerminalCreateIdempotency } from './remote-runtime-terminal-create-idempotency' import { deriveRemoteRuntimeTerminalCreateHandle } from './remote-runtime-terminal-create-identity' -import { buildHeadlessTerminalSplitLayout } from './headless-terminal-split-layout' +import { + buildHeadlessTerminalSplitLayout, + countTerminalLayoutLeaves +} from './headless-terminal-split-layout' import { RECENT_PTY_OUTPUT_LIMIT, RecentPtyOutputBuffer } from './recent-pty-output-buffer' import { buildHeadlessTabGroupMove, @@ -947,6 +955,7 @@ type RuntimeStore = { setWorkspaceSession?: Store['setWorkspaceSession'] flushOrThrow?: Store['flushOrThrow'] persistPtyBinding?: Store['persistPtyBinding'] + getSshRemotePtyLeases?: Store['getSshRemotePtyLeases'] getUI?: Store['getUI'] updateUI?: Store['updateUI'] recordFeatureInteraction?: Store['recordFeatureInteraction'] @@ -1484,6 +1493,7 @@ const MOBILE_TERMINAL_READY_FALLBACK_MS = 1000 const RECENT_PTY_PATH_CANDIDATE_LIMIT = 1024 const RECENT_PTY_PATH_CANDIDATE_MAX_BYTES = 4 * 1024 const RECENT_PTY_PATH_CANDIDATE_TOTAL_BYTES = 64 * 1024 +const SSH_PANE_RECOVERY_GRACE_MS = 30_000 function isClientDisconnectedError(error: unknown): boolean { return error instanceof Error && error.message === 'client_disconnected' @@ -2407,6 +2417,7 @@ export class OrcaRuntimeService { } >() private terminalSleepGeneration = 0 + private terminalPaneRecoveryByIdentity = new Map>() // Why: idempotency map for worktree.create — a create interrupted by a mobile // connection migration is retried with the same clientMutationId and returns // the in-flight (or just-finished) operation instead of a duplicate worktree. @@ -2844,6 +2855,7 @@ export class OrcaRuntimeService { | null private readonly agentSessionClaimSigner: AgentSessionClaimSigner private readonly agentSessionCreateOperations = new Map() + private sshRelayRecoveryGenerationByTargetId = new Map() private accountServices: RuntimeAccountServices | null = null private commitMessageAgentEnv: CommitMessageAgentEnvironmentResolvers | null = null private automationService: AutomationService | null = null @@ -3355,6 +3367,44 @@ export class OrcaRuntimeService { return this.startedAt } + private getWorkspaceSessionHostIdForWorktree(worktreeId: string): ExecutionHostId { + const repo = this.store?.getRepo?.(getRepoIdFromWorktreeId(worktreeId)) + return repo ? getRepoExecutionHostId(repo) : 'local' + } + + private getWorkspaceSessionForWorktree(worktreeId: string): WorkspaceSessionState | null { + return ( + this.store?.getWorkspaceSession?.(this.getWorkspaceSessionHostIdForWorktree(worktreeId)) ?? + null + ) + } + + private setWorkspaceSessionForWorktree(worktreeId: string, session: WorkspaceSessionState): void { + this.store?.setWorkspaceSession?.( + session, + this.getWorkspaceSessionHostIdForWorktree(worktreeId) + ) + } + + private getKnownWorkspaceSessionWorktreeIds(): Set { + const repos = this.store?.getRepos?.() ?? [] + const repoIds = new Set(repos.map((repo) => repo.id)) + const hostIds = new Set(['local']) + for (const repo of repos) { + hostIds.add(getRepoExecutionHostId(repo)) + } + const worktreeIds = new Set() + for (const hostId of hostIds) { + const session = this.store?.getWorkspaceSession?.(hostId) + for (const worktreeId of Object.keys(session?.tabsByWorktree ?? {})) { + if (repoIds.has(getRepoIdFromWorktreeId(worktreeId))) { + worktreeIds.add(worktreeId) + } + } + } + return worktreeIds + } + getStatus(): RuntimeStatus { // Why: browser panes need a backend that can create and stream a page. A // desktop renderer provides one via ; a headless serve provides one @@ -3366,7 +3416,11 @@ export class OrcaRuntimeService { const hasOffscreen = !hasRenderer && Boolean(this.offscreenBrowserBackend) const canBrowse = hasRenderer || hasOffscreen const capabilities: RuntimeCapability[] = RUNTIME_CAPABILITIES.filter( - (capability) => capability !== 'browser.screencast.v1' || canBrowse + (capability) => + (capability !== 'browser.screencast.v1' || canBrowse) && + // Why: the nested-runtime E2E needs a real legacy transport without maintaining an old binary fixture. + (process.env.ORCA_E2E_DISABLE_RUNTIME_SHARED_CONTROL !== '1' || + capability !== REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY) ) if (hasOffscreen) { capabilities.push(BROWSER_HEADLESS_RUNTIME_CAPABILITY) @@ -3498,8 +3552,70 @@ export class OrcaRuntimeService { // Why: SSH state changes originate in main's ssh handlers, not in runtime // methods, so they need a public entry point onto the client-event stream. notifySshStateChanged(targetId: string, state: SshConnectionState): void { + this.bumpSshRelayRecoveryGeneration(targetId) this.invalidateSshWorktreeScanCache(targetId) - this.emitClientEvent({ type: 'sshStateChanged', targetId, state }) + this.emitClientEvent({ type: 'sshStateChanged', targetId, state: getPublicSshState(state)! }) + } + + notifySshRelayReady(targetId: string): void { + const generation = this.bumpSshRelayRecoveryGeneration(targetId) + void this.publishRecoveredSshMobileSessionTabs(targetId, generation).catch((error) => { + if (this.sshRelayRecoveryGenerationByTargetId.get(targetId) !== generation) { + return + } + console.warn('[runtime] failed to publish recovered SSH session tabs', { + targetId, + error + }) + }) + } + + private bumpSshRelayRecoveryGeneration(targetId: string): number { + const generation = (this.sshRelayRecoveryGenerationByTargetId.get(targetId) ?? 0) + 1 + this.sshRelayRecoveryGenerationByTargetId.set(targetId, generation) + return generation + } + + private async publishRecoveredSshMobileSessionTabs( + targetId: string, + generation: number + ): Promise { + const repoIds = new Set( + (this.store?.getRepos() ?? []) + .filter((repo) => repo.connectionId === targetId) + .map((repo) => repo.id) + ) + if (repoIds.size === 0) { + return + } + const worktreeIds = new Set() + for (const worktreeId of [ + ...this.getKnownWorkspaceSessionWorktreeIds(), + ...this.mobileSessionTabsByWorktree.keys() + ]) { + const parsed = splitWorktreeId(worktreeId) + if (parsed && repoIds.has(parsed.repoId)) { + worktreeIds.add(worktreeId) + } + } + if (worktreeIds.size === 0) { + return + } + + // Why: relay readiness follows PTY reattach; rebuild the HUB-owned panes before paired clients consume the connected event. + for (const worktreeId of worktreeIds) { + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { + allowAttachedWindow: true, + onlyRuntimeOwnedTerminals: true + }) + } + await this.refreshMobileSessionPtyRecords() + if (this.sshRelayRecoveryGenerationByTargetId.get(targetId) !== generation) { + return + } + for (const worktreeId of worktreeIds) { + this.notifyMobileSessionTabsChangedNow(worktreeId) + } } invalidateSshWorktreeScanCache(targetId: string): void { @@ -3590,59 +3706,69 @@ export class OrcaRuntimeService { } private persistWindowlessPtyBindingsForDesktopAttach(): void { - const session = this.store?.getWorkspaceSession?.() - if (!session || !this.store?.setWorkspaceSession) { + if (!this.store?.getWorkspaceSession || !this.store.setWorkspaceSession) { return } - const promotablePtys = [...this.ptysById.values()].filter((pty) => { + const partitions = new Map< + ExecutionHostId, + { session: WorkspaceSessionState; ptys: RuntimePtyWorktreeRecord[] } + >() + for (const pty of this.ptysById.values()) { if (!pty.connected || !pty.tabId) { - return false + continue } + const hostId = this.getWorkspaceSessionHostIdForWorktree(pty.worktreeId) + const session = this.store.getWorkspaceSession(hostId) const tab = session.tabsByWorktree[pty.worktreeId]?.find( (candidate) => candidate.id === pty.tabId ) if (!tab) { - return false + continue } const layoutPtyIds = Object.values( session.terminalLayoutsByTabId[pty.tabId]?.ptyIdsByLeafId ?? {} ) - return tab.ptyId === pty.ptyId || layoutPtyIds.includes(pty.ptyId) - }) - if (promotablePtys.length === 0) { - return - } - - // Why: renderer hydration treats an explicitly-present shutdown list as - // authoritative. A windowless owner has no renderer shutdown pass, so seed - // that existing reattach contract before its next desktop window loads. - const activeWorktreeIdsOnShutdown = [ - ...new Set([ - ...(session.activeWorktreeIdsOnShutdown ?? []), - ...promotablePtys.map((pty) => pty.worktreeId) - ]) - ] - const activeConnectionIdsAtShutdown = [ - ...new Set([ - ...(session.activeConnectionIdsAtShutdown ?? []), - ...promotablePtys - .map((pty) => pty.connectionId) - .filter((connectionId): connectionId is string => connectionId !== null) - ]) - ] - const remoteSessionIdsByTabId = { ...session.remoteSessionIdsByTabId } - for (const pty of promotablePtys) { - if (pty.connectionId && pty.tabId) { - remoteSessionIdsByTabId[pty.tabId] = pty.ptyId + if (tab.ptyId !== pty.ptyId && !layoutPtyIds.includes(pty.ptyId)) { + continue } + const partition = partitions.get(hostId) ?? { session, ptys: [] } + partition.ptys.push(pty) + partitions.set(hostId, partition) } - this.store.setWorkspaceSession({ - ...session, - activeWorktreeIdsOnShutdown, - ...(activeConnectionIdsAtShutdown.length > 0 ? { activeConnectionIdsAtShutdown } : {}), - ...(Object.keys(remoteSessionIdsByTabId).length > 0 ? { remoteSessionIdsByTabId } : {}) - }) + for (const [hostId, { session, ptys }] of partitions) { + // Why: windowless SSH PTYs must be handed to the desktop through their SSH partition, never the local session. + const activeWorktreeIdsOnShutdown = [ + ...new Set([ + ...(session.activeWorktreeIdsOnShutdown ?? []), + ...ptys.map((pty) => pty.worktreeId) + ]) + ] + const activeConnectionIdsAtShutdown = [ + ...new Set([ + ...(session.activeConnectionIdsAtShutdown ?? []), + ...ptys + .map((pty) => pty.connectionId) + .filter((connectionId): connectionId is string => connectionId !== null) + ]) + ] + const remoteSessionIdsByTabId = { ...session.remoteSessionIdsByTabId } + for (const pty of ptys) { + if (pty.connectionId && pty.tabId) { + remoteSessionIdsByTabId[pty.tabId] = pty.ptyId + } + } + + this.store.setWorkspaceSession( + { + ...session, + activeWorktreeIdsOnShutdown, + ...(activeConnectionIdsAtShutdown.length > 0 ? { activeConnectionIdsAtShutdown } : {}), + ...(Object.keys(remoteSessionIdsByTabId).length > 0 ? { remoteSessionIdsByTabId } : {}) + }, + hostId + ) + } } syncWindowGraph(windowId: number, graph: RuntimeSyncWindowGraph): RuntimeSyncWindowGraphResult { @@ -3878,11 +4004,19 @@ export class OrcaRuntimeService { ): Promise { const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) if (explicitWorktreeId) { + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(explicitWorktreeId, { + allowAttachedWindow: true, + onlyRuntimeOwnedTerminals: true + }) this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(explicitWorktreeId) await this.refreshMobileSessionPtyRecords(explicitWorktreeId) return this.getMobileSessionTabsForWorktree(explicitWorktreeId, clientNavigationId) } const worktree = await this.resolveWorktreeSelector(worktreeSelector) + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktree.id, { + allowAttachedWindow: true, + onlyRuntimeOwnedTerminals: true + }) this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktree.id) await this.refreshMobileSessionPtyRecords() return this.getMobileSessionTabsForWorktree(worktree.id, clientNavigationId) @@ -3891,6 +4025,12 @@ export class OrcaRuntimeService { async listAllMobileSessionTabs( clientNavigationId?: string ): Promise { + for (const worktreeId of this.getKnownWorkspaceSessionWorktreeIds()) { + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { + allowAttachedWindow: true, + onlyRuntimeOwnedTerminals: true + }) + } this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession() await this.refreshMobileSessionPtyRecords() return [...this.mobileSessionTabsByWorktree.values()].map((snapshot) => @@ -3906,7 +4046,7 @@ export class OrcaRuntimeService { options: { force?: boolean allowAttachedWindow?: boolean - onlyServeOwnedTerminals?: boolean + onlyRuntimeOwnedTerminals?: boolean } = {} ): Set { // Why: report which worktrees were reconciled in place so callers don't @@ -3915,19 +4055,21 @@ export class OrcaRuntimeService { if (this.getAvailableAuthoritativeWindow() && options.allowAttachedWindow !== true) { return reconciledWorktreeIds } - const session = this.store?.getWorkspaceSession?.() + const session = worktreeId + ? this.getWorkspaceSessionForWorktree(worktreeId) + : this.store?.getWorkspaceSession?.() if (!session) { return reconciledWorktreeIds } - // Why: with no serve-owned ptyId anywhere in the session and no offscreen - // browser backend, the serve-only hydrate provably builds zero tabs for + // Why: with no runtime-owned candidate in the session and no offscreen + // browser backend, this hydrate provably builds zero tabs for // every worktree — skip the per-worktree rebuild entirely (hot on every - // graph sync). Scoped to onlyServeOwnedTerminals so full hydrates are + // graph sync). Scoped to onlyRuntimeOwnedTerminals so full hydrates are // untouched. if ( - options.onlyServeOwnedTerminals === true && + options.onlyRuntimeOwnedTerminals === true && !this.offscreenBrowserBackend && - !this.workspaceSessionHasServeOwnedPty(session) + !this.workspaceSessionHasRuntimeOwnedPtyCandidate(session) ) { return reconciledWorktreeIds } @@ -3941,7 +4083,7 @@ export class OrcaRuntimeService { existing && existing.tabs.length > 0 && options.force !== true && - options.onlyServeOwnedTerminals !== true + options.onlyRuntimeOwnedTerminals !== true ) { // Why: terminals are stable/persisted so we normally skip a rebuild, but // offscreen browser tabs are live and may have been created/closed since. @@ -3955,10 +4097,13 @@ export class OrcaRuntimeService { entryWorktreeId, persistedTabs ).filter( - (tab) => options.onlyServeOwnedTerminals !== true || this.hasServeOwnedPtyBinding(tab) + (tab) => + options.onlyRuntimeOwnedTerminals !== true || + this.hasServeOrSshOwnedBinding(tab) || + this.hasRecentExpiredSshLeasePane(entryWorktreeId, tab) ) // Why: offscreen browser panes are live-only (no persisted session entry), - // so include them on every hydrate regardless of the onlyServeOwnedTerminals + // so include them on every hydrate regardless of the onlyRuntimeOwnedTerminals // filter, which is about terminal PTY ownership and never applies to browsers. const browserTabs = this.buildHeadlessMobileSessionBrowserTabs(entryWorktreeId) const tabs: RuntimeMobileSessionSnapshotTab[] = [...terminalTabs, ...browserTabs] @@ -3972,7 +4117,7 @@ export class OrcaRuntimeService { ] const groupId = this.getHeadlessMobileSessionGroupId(entryWorktreeId) const mergedTabs = - options.onlyServeOwnedTerminals === true && existing + options.onlyRuntimeOwnedTerminals === true && existing ? this.mergeMobileSessionSnapshotTabs(existing.tabs, tabs) : tabs const mergedActiveTab = @@ -3992,7 +4137,7 @@ export class OrcaRuntimeService { const persistedGroups = session.tabGroups?.[entryWorktreeId] const persistedLayout = session.tabGroupLayouts?.[entryWorktreeId] const hasPersistedSplit = - options.onlyServeOwnedTerminals !== true && + options.onlyRuntimeOwnedTerminals !== true && persistedGroups !== undefined && persistedGroups.length > 1 const activeTopLevelId = mergedActiveTab @@ -4018,7 +4163,7 @@ export class OrcaRuntimeService { // browser's persisted group forward instead of coalescing left. this.collectBrowserGroupAssignment(persistedGroups, mergedBrowserOrder) ) - : options.onlyServeOwnedTerminals === true && existing?.tabGroups + : options.onlyRuntimeOwnedTerminals === true && existing?.tabGroups ? this.appendBrowserTabOrder( this.mergeMobileSessionTabGroups( entryWorktreeId, @@ -4043,7 +4188,7 @@ export class OrcaRuntimeService { // renderer later closes. Keep the renderer base epoch with a merge suffix // (idempotent) so ownership stays derivable from the epoch. const mergedIntoRendererPublication = - options.onlyServeOwnedTerminals === true && + options.onlyRuntimeOwnedTerminals === true && existing !== undefined && !this.isHeadlessBuiltMobileSessionPublicationBase(existing.publicationEpoch) const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { @@ -4056,19 +4201,19 @@ export class OrcaRuntimeService { activeTabId: mergedActiveTab?.id ?? null, activeTabType: mergedActiveTab?.type ?? null, tabGroups: nextTabGroups, - // Why: the serve-only rebuild runs on every graph sync — carry the + // Why: the runtime-owned rebuild runs on every graph sync — carry the // existing split layout forward or each sync drops it and fans out. ...(hasPersistedSplit && persistedLayout ? { tabGroupLayout: persistedLayout } - : options.onlyServeOwnedTerminals === true && existing?.tabGroupLayout + : options.onlyRuntimeOwnedTerminals === true && existing?.tabGroupLayout ? { tabGroupLayout: existing.tabGroupLayout } : {}), tabs: mergedTabs } - // Why: the serve-only hydrate runs on EVERY graph sync; when the rebuilt + // Why: the runtime-owned hydrate runs on EVERY graph sync; when the rebuilt // projection matches the existing snapshot, keep the existing object and // (epoch, version) untouched so identity-based change detection stays a - // pure no-op and unchanged serve/browser worktrees never fan out. + // pure no-op and unchanged runtime/browser worktrees never fan out. if (existing && this.headlessMobileSnapshotContentUnchanged(existing, nextSnapshot)) { continue } @@ -4257,36 +4402,61 @@ export class OrcaRuntimeService { return typeof ptyId === 'string' && ptyId.startsWith('serve-') } - // Why: strict superset of hasServeOwnedPtyBinding's inputs (tab.ptyId + - // layout leaf ptyIds are what the built tabs' bindings are derived from), so - // the serve-only hydrate fast-path can never hide a serve terminal. - private workspaceSessionHasServeOwnedPty(session: WorkspaceSessionState): boolean { + private isSshOwnedPtyId(ptyId: string | null | undefined): boolean { + return typeof ptyId === 'string' && parseAppSshPtyId(ptyId) !== null + } + + private workspaceSessionHasRuntimeOwnedPtyCandidate(session: WorkspaceSessionState): boolean { for (const tabs of Object.values(session.tabsByWorktree ?? {})) { for (const tab of tabs) { - if (this.isServeOwnedPtyId(tab.ptyId)) { + if (this.isServeOrSshOwnedPtyId(tab.ptyId)) { return true } const leafPtyIds = session.terminalLayoutsByTabId?.[tab.id]?.ptyIdsByLeafId if ( leafPtyIds && - Object.values(leafPtyIds).some((ptyId) => this.isServeOwnedPtyId(ptyId)) + Object.values(leafPtyIds).some((ptyId) => this.isServeOrSshOwnedPtyId(ptyId)) ) { return true } } } - return false + // Why: expiry clears the stale PTY id but retains pane coordinates so paired viewers can ask the HUB for a fresh shell. + return Object.entries(session.tabsByWorktree ?? {}).some(([worktreeId, tabs]) => + tabs.some((tab) => this.getRecentExpiredSshLease(worktreeId, tab.id, undefined) !== null) + ) } - private hasServeOwnedPtyBinding(tab: RuntimeMobileSessionTerminalTab): boolean { - if (this.isServeOwnedPtyId(tab.ptyId)) { - return true - } - return Object.values(tab.parentLayout?.ptyIdsByLeafId ?? {}).some((ptyId) => - this.isServeOwnedPtyId(ptyId) + private getRecentExpiredSshLease( + worktreeId: string, + tabId: string, + leafId: string | undefined, + ptyId?: string + ): ReturnType>[number] | null { + const now = Date.now() + return ( + this.store + ?.getSshRemotePtyLeases?.() + .find( + (lease) => + lease.state === 'expired' && + lease.worktreeId === worktreeId && + lease.tabId === tabId && + (ptyId === undefined || lease.ptyId === ptyId) && + (leafId === undefined || lease.leafId === undefined || lease.leafId === leafId) && + lease.updatedAt <= now && + now - lease.updatedAt <= SSH_PANE_RECOVERY_GRACE_MS + ) ?? null ) } + private hasRecentExpiredSshLeasePane( + worktreeId: string, + tab: RuntimeMobileSessionTerminalTab + ): boolean { + return this.getRecentExpiredSshLease(worktreeId, tab.parentTabId, tab.leafId) !== null + } + // Why: serve-* (local serve) and ssh:@@ (SSH relay) ids are minted // ONLY for runtime-owned terminals and are preserved/re-hydrated, so tear them // down even if the renderer adopted a view (else they resurrect). The daemon @@ -4294,10 +4464,7 @@ export class OrcaRuntimeService { // mints it for ordinary renderer-owned local terminals too, so id shape can't // classify ownership for that form — renderer-graph membership does (below). private isServeOrSshOwnedPtyId(ptyId: string | null | undefined): boolean { - return ( - this.isServeOwnedPtyId(ptyId) || - (typeof ptyId === 'string' && parseAppSshPtyId(ptyId) !== null) - ) + return this.isServeOwnedPtyId(ptyId) || this.isSshOwnedPtyId(ptyId) } private hasServeOrSshOwnedBinding(tab: RuntimeMobileSessionTerminalTab): boolean { @@ -4322,8 +4489,9 @@ export class OrcaRuntimeService { tab.ptyId, ...Object.values(tab.parentLayout?.ptyIdsByLeafId ?? {}) ].filter((ptyId): ptyId is string => this.isServeOrSshOwnedPtyId(ptyId)) + const boundSshPtyIds = boundPtyIds.filter((ptyId) => this.isSshOwnedPtyId(ptyId)) if (boundPtyIds.length === 0) { - return false + return this.hasRecentExpiredSshLeasePane(worktreeId, tab) } // Why: exited PTY records are archived in ptysById, so require a connected // record — a dead serve shell whose persisted binding is also gone must @@ -4331,7 +4499,33 @@ export class OrcaRuntimeService { if (boundPtyIds.some((ptyId) => this.ptysById.get(ptyId)?.connected === true)) { return true } - const session = this.store?.getWorkspaceSession?.() + const now = Date.now() + if ( + boundPtyIds.some((ptyId) => { + const pty = this.ptysById.get(ptyId) + return ( + pty?.connectionId != null && + pty.lastExitCode != null && + pty.lastExitCode < 0 && + pty.disconnectedAt != null && + now - pty.disconnectedAt <= SSH_PANE_RECOVERY_GRACE_MS + ) + }) + ) { + // Why: an abnormal SSH transport exit can beat paired-viewer recovery; retain its pane briefly so the HUB remains addressable. + return true + } + if ( + now - this.startedAt <= SSH_PANE_RECOVERY_GRACE_MS && + boundSshPtyIds.some((ptyId) => { + const pty = this.ptysById.get(ptyId) + return !pty || (!pty.connected && pty.lastExitCode === null) + }) + ) { + // Why: after a HUB restart, failed SSH reattach can remove persistence before the fresh runtime records an exit; keep the pane reachable for ensure. + return true + } + const session = this.getWorkspaceSessionForWorktree(worktreeId) if (!session) { return false } @@ -4810,7 +5004,7 @@ export class OrcaRuntimeService { worktreeId: string, persistedTabs: readonly TerminalTab[] ): RuntimeMobileSessionTerminalTab[] { - const session = this.store?.getWorkspaceSession?.() + const session = this.getWorkspaceSessionForWorktree(worktreeId) if (!session) { return [] } @@ -4960,11 +5154,9 @@ export class OrcaRuntimeService { tabId: string ): Pick | null { const tab = - this.store - ?.getWorkspaceSession?.() - ?.unifiedTabs?.[worktreeId]?.find( - (candidate) => candidate.id === tabId || candidate.entityId === tabId - ) ?? null + this.getWorkspaceSessionForWorktree(worktreeId)?.unifiedTabs?.[worktreeId]?.find( + (candidate) => candidate.id === tabId || candidate.entityId === tabId + ) ?? null return tab ? { color: tab.color, isPinned: tab.isPinned } : null } @@ -5041,7 +5233,7 @@ export class OrcaRuntimeService { leafId: string, layout: TerminalLayoutSnapshot | undefined ): boolean { - const session = this.store?.getWorkspaceSession?.() + const session = this.getWorkspaceSessionForWorktree(worktreeId) const activeTabId = session?.activeTabIdByWorktree?.[worktreeId] ?? session?.activeTabId return activeTabId === tabId && (!layout?.activeLeafId || layout.activeLeafId === leafId) } @@ -5219,7 +5411,7 @@ export class OrcaRuntimeService { } private removePersistedHeadlessTerminalTab(worktreeId: string, parentTabId: string): string[] { - const session = this.store?.getWorkspaceSession?.() + const session = this.getWorkspaceSessionForWorktree(worktreeId) if (!session || !this.store?.setWorkspaceSession) { throw new Error('workspace_session_unavailable') } @@ -5230,12 +5422,15 @@ export class OrcaRuntimeService { if (!result.closed) { throw new Error('tab_not_found') } - this.store.setWorkspaceSession(advanceTerminalTopologyRevision(result.session, worktreeId)) + this.setWorkspaceSessionForWorktree( + worktreeId, + advanceTerminalTopologyRevision(result.session, worktreeId) + ) return result.ptyIdsToKill } private persistHeadlessTerminalTabOrder(worktreeId: string, tabOrder: readonly string[]): void { - const session = this.store?.getWorkspaceSession?.() + const session = this.getWorkspaceSessionForWorktree(worktreeId) if (!session || !this.store?.setWorkspaceSession) { return } @@ -5251,7 +5446,7 @@ export class OrcaRuntimeService { ...tab, sortOrder: index })) - this.store.setWorkspaceSession({ + this.setWorkspaceSessionForWorktree(worktreeId, { ...session, tabsByWorktree: { ...session.tabsByWorktree, @@ -5516,7 +5711,7 @@ export class OrcaRuntimeService { ): boolean { return ( this.isHeadlessMobileSessionPublication(snapshot.publicationEpoch) || - this.hasServeOwnedPtyBinding(tab) + this.hasServeOrSshOwnedBinding(tab) ) } @@ -5567,13 +5762,14 @@ export class OrcaRuntimeService { // and collapsed the split. Persist the new split leaf into the workspace // session's terminalLayoutsByTabId so the split survives rebuilds. private persistHeadlessTerminalSplit(args: { + worktreeId: string tabId: string leafId: string ptyId: string splitFromLeafId: string direction: 'horizontal' | 'vertical' }): void { - const session = this.store?.getWorkspaceSession?.() + const session = this.getWorkspaceSessionForWorktree(args.worktreeId) if (!session || !this.store?.setWorkspaceSession) { return } @@ -5582,7 +5778,7 @@ export class OrcaRuntimeService { existing ? this.cloneTerminalLayoutSnapshot(existing) : undefined, args ) - this.store.setWorkspaceSession({ + this.setWorkspaceSessionForWorktree(args.worktreeId, { ...session, terminalLayoutsByTabId: { ...session.terminalLayoutsByTabId, @@ -5595,7 +5791,7 @@ export class OrcaRuntimeService { worktreeId: string, tab: RuntimeMobileSessionTerminalTab ): void { - const session = this.store?.getWorkspaceSession?.() + const session = this.getWorkspaceSessionForWorktree(worktreeId) if (!session || !this.store?.setWorkspaceSession) { return } @@ -5609,7 +5805,7 @@ export class OrcaRuntimeService { } } : session.terminalLayoutsByTabId - this.store.setWorkspaceSession({ + this.setWorkspaceSessionForWorktree(worktreeId, { ...session, activeTabId: tab.parentTabId, activeTabIdByWorktree: { @@ -5791,6 +5987,21 @@ export class OrcaRuntimeService { // Why: whole-tab close is a lifecycle transaction. The renderer reply // arrives only after canonical retirement and a forced session flush. await this.notifier.closeTerminalTab(tab.parentTabId) + const remainingSnapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + const remainingTab = remainingSnapshot?.tabs.find( + (candidate): candidate is RuntimeMobileSessionTerminalTab => + candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId + ) + if ( + remainingSnapshot && + remainingTab && + this.isRuntimeOwnedHeadlessMobileTab(worktreeId, remainingTab) + ) { + // Why: after relay recovery the renderer can acknowledge a tab it no longer mirrors; the HUB must still retire its SSH-owned surface. + this.closeHeadlessMobileTerminalTab(worktreeId, remainingSnapshot, remainingTab) + this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId) + this.store?.flushOrThrow?.() + } return { closed: true } } // Why: notifier implementations without the acknowledged relay may expose @@ -6092,7 +6303,7 @@ export class OrcaRuntimeService { ? (this.resolveMobileSessionHostTabId(snapshot, args.tabId) ?? args.tabId) : args.tabId const resolvedArgs = { ...args, tabId: hostTabId } - const acceptedLayout = this.persistHeadlessTerminalPaneLayout(resolvedArgs) + const acceptedLayout = this.persistHeadlessTerminalPaneLayout(worktreeId, resolvedArgs) if (acceptedLayout) { this.applyHeadlessTerminalPaneLayoutToSnapshot(worktreeId, { tabId: hostTabId, @@ -6138,7 +6349,7 @@ export class OrcaRuntimeService { tabId: string, props: { color?: string | null; isPinned?: boolean; viewMode?: 'terminal' | 'chat' } ): void { - const session = this.store?.getWorkspaceSession?.() + const session = this.getWorkspaceSessionForWorktree(worktreeId) if (!session || !this.store?.setWorkspaceSession) { return } @@ -6182,7 +6393,7 @@ export class OrcaRuntimeService { if (!changed) { return } - this.store.setWorkspaceSession(nextSession) + this.setWorkspaceSessionForWorktree(worktreeId, nextSession) } private applyHeadlessSessionTabPropsToSnapshot( @@ -6227,13 +6438,16 @@ export class OrcaRuntimeService { // Merge the client's pane structure into the persisted tab layout. PTY // bindings and active leaf stay host-owned; only ratios/expand/titles change. // terminalLayoutsByTabId is keyed by tab id (worktree-independent). - private persistHeadlessTerminalPaneLayout(args: { - tabId: string - root: TerminalPaneLayoutNode | null - expandedLeafId: string | null - titlesByLeafId?: Record - }): TerminalLayoutSnapshot | undefined { - const session = this.store?.getWorkspaceSession?.() + private persistHeadlessTerminalPaneLayout( + worktreeId: string, + args: { + tabId: string + root: TerminalPaneLayoutNode | null + expandedLeafId: string | null + titlesByLeafId?: Record + } + ): TerminalLayoutSnapshot | undefined { + const session = this.getWorkspaceSessionForWorktree(worktreeId) if (!session || !this.store?.setWorkspaceSession) { return undefined } @@ -6253,10 +6467,10 @@ export class OrcaRuntimeService { } } } - this.store.setWorkspaceSession(candidate) + this.setWorkspaceSessionForWorktree(worktreeId, candidate) // Why: persistence may reject stale membership while accepting its metadata; publish only that rebased layout. return ( - this.store.getWorkspaceSession?.()?.terminalLayoutsByTabId[args.tabId] ?? + this.getWorkspaceSessionForWorktree(worktreeId)?.terminalLayoutsByTabId[args.tabId] ?? candidate.terminalLayoutsByTabId[args.tabId] ) } @@ -6447,11 +6661,11 @@ export class OrcaRuntimeService { groups: readonly RuntimeMobileSessionTabGroup[], layout: TabGroupLayoutNode ): void { - const session = this.store?.getWorkspaceSession?.() + const session = this.getWorkspaceSessionForWorktree(worktreeId) if (!session || !this.store?.setWorkspaceSession) { return } - this.store.setWorkspaceSession({ + this.setWorkspaceSessionForWorktree(worktreeId, { ...session, tabGroups: { ...session.tabGroups, @@ -6477,7 +6691,7 @@ export class OrcaRuntimeService { tabId: string, title: string | null ): void { - const session = this.store?.getWorkspaceSession?.() + const session = this.getWorkspaceSessionForWorktree(worktreeId) if (!session || !this.store?.setWorkspaceSession) { return } @@ -6485,7 +6699,7 @@ export class OrcaRuntimeService { if (!tabs?.some((tab) => tab.id === tabId)) { return } - this.store.setWorkspaceSession({ + this.setWorkspaceSessionForWorktree(worktreeId, { ...session, tabsByWorktree: { ...session.tabsByWorktree, @@ -10529,7 +10743,10 @@ export class OrcaRuntimeService { this.resolvePtyExitWaiters(pty, ptyId) this.pruneDisconnectedPtyTranscript(pty) } - if (preservesIntentionalHandlelessSurface) { + const preservesAbnormalSshSurface = + this.isSshOwnedPtyId(ptyId) && pty?.connectionId != null && exitCode < 0 + if (preservesIntentionalHandlelessSurface || preservesAbnormalSshSurface) { + // Why: relay loss is recoverable; keep the HUB-owned pane addressable through the bounded reconnect grace. this.touchMobileSessionSnapshotsForPty(ptyId, { immediate: true }) } else { // Why: permanent process exit is absence, not a starting/sleeping tab. @@ -12605,7 +12822,7 @@ export class OrcaRuntimeService { return this.getPaneKeyForTerminalHandle(handle) } - resolveTerminalPane(paneKey: string): RuntimeTerminalResolvePane { + resolveTerminalPane(paneKey: string, expectedWorktreeId?: string): RuntimeTerminalResolvePane { // Why: the renderer context menu only knows the stable pane key; main owns // the runtime terminal handle that agents and CLI commands can address. const handle = this.getTerminalHandleForPaneKey(paneKey) @@ -12614,14 +12831,88 @@ export class OrcaRuntimeService { } const record = this.handles.get(handle) const parsed = parsePaneKey(paneKey) + const leaf = parsed ? this.leaves.get(this.getLeafKey(parsed.tabId, parsed.leafId)) : null + const pty = this.getPtyRecordForPaneKey(paneKey) + const candidateWorktreeIds = [leaf?.worktreeId, pty?.worktreeId].filter( + (worktreeId): worktreeId is string => Boolean(worktreeId) + ) + const worktreeId = candidateWorktreeIds[0] ?? null + if ( + (candidateWorktreeIds.length > 1 && new Set(candidateWorktreeIds).size > 1) || + (expectedWorktreeId && candidateWorktreeIds.some((id) => id !== expectedWorktreeId)) || + (expectedWorktreeId && candidateWorktreeIds.length === 0) + ) { + // Why: pane coordinates restored by a paired client must not cross workspace ownership. + throw new Error('terminal_not_found') + } return { handle, - tabId: record?.tabId ?? parsed?.tabId ?? '', - leafId: record?.leafId ?? parsed?.leafId ?? '', - ptyId: record?.ptyId ?? null + tabId: parsed?.tabId ?? record?.tabId ?? '', + leafId: parsed?.leafId ?? record?.leafId ?? '', + ptyId: record?.ptyId ?? null, + ...(worktreeId ? { worktreeId } : {}), + ...this.getPtyExecutionHostMetadata(record?.ptyId ?? pty?.ptyId ?? null) } } + async recoverTerminalPane( + paneKey: string, + expectedWorktreeId: string, + expectedHandle?: string + ): Promise { + const parsed = parsePaneKey(paneKey) + const pty = this.getPtyRecordForPaneKey(paneKey) + if ( + !parsed || + !pty || + !expectedHandle || + pty.worktreeId !== expectedWorktreeId || + this.getPaneKeyForTerminalHandle(expectedHandle) !== paneKey + ) { + throw new Error('terminal_not_found') + } + const recoveryKey = `${expectedWorktreeId}\0${paneKey}` + const pending = this.terminalPaneRecoveryByIdentity.get(recoveryKey) + if (pending) { + return pending + } + if (pty?.connected) { + const current = this.resolveTerminalPane(paneKey, expectedWorktreeId) + if (expectedHandle === undefined || current.handle !== expectedHandle) { + return current + } + throw new Error('terminal_not_recoverable') + } + if ( + !this.getRecentExpiredSshLease(expectedWorktreeId, parsed.tabId, parsed.leafId, pty.ptyId) + ) { + // Why: an explicit close leaves a terminated lease; only relay expiry authorizes shell recreation. + throw new Error('terminal_not_recoverable') + } + // Why: disconnected PTYs can reissue handles during graph cleanup; only a connected replacement satisfies the pane CAS. + const recovery = this.createTerminal(`id:${expectedWorktreeId}`, { + tabId: parsed.tabId, + leafId: parsed.leafId, + focus: false, + // Why: the HUB renderer may publish its exited layout while recovery is in flight; persist the replacement before that stale graph can orphan it. + persistHostSessionBinding: true + }).then((terminal) => ({ + handle: terminal.handle, + tabId: parsed.tabId, + leafId: parsed.leafId, + ptyId: terminal.ptyId ?? null, + worktreeId: expectedWorktreeId + })) + this.terminalPaneRecoveryByIdentity.set(recoveryKey, recovery) + const clearRecovery = (): void => { + if (this.terminalPaneRecoveryByIdentity.get(recoveryKey) === recovery) { + this.terminalPaneRecoveryByIdentity.delete(recoveryKey) + } + } + void recovery.then(clearRecovery, clearRecovery) + return recovery + } + async showTerminal(handle: string): Promise { const pty = this.getLivePtyForHandle(handle) if (pty) { @@ -13724,42 +14015,61 @@ export class OrcaRuntimeService { } } - for (const [worktreeId, tabs] of Object.entries(session?.browserTabsByWorktree ?? {})) { - if (tabs.length === 0) { - continue - } - const summary = this.getSummaryForRuntimeWorktreeId( - summaries, - runtimeWorktreeSummaryPathIndex, - missingRuntimeWorktreeIds, - worktreeId - ) - if (summary) { - // Why: desktop's sleeping predicate treats any open browser workspace - // as active, so the mobile host projection must preserve that parity. - summary.hasHostSidebarActivity = true - } - } - - // Why: surface the desktop's focused worktree so mobile can scroll it into - // view and highlight it. Resolve through getSummaryForRuntimeWorktreeId so - // SSH/remote path-projected ids match the same way tabsByWorktree does. - if (session?.activeWorktreeId) { - const activeSummary = this.getSummaryForRuntimeWorktreeId( - summaries, - runtimeWorktreeSummaryPathIndex, - missingRuntimeWorktreeIds, - session.activeWorktreeId - ) - if (activeSummary) { - activeSummary.isActive = true - } - } - const mirroredWorktreeIdByTabId = new Map() - for (const [worktreeId, tabs] of Object.entries(session?.tabsByWorktree ?? {})) { - for (const tab of tabs) { - mirroredWorktreeIdByTabId.set(tab.id, worktreeId) + const sessionsByHostId = new Map() + for (const summary of summaries.values()) { + const repo = repoById.get(summary.repoId) + const hostId = repo ? getRepoExecutionHostId(repo) : 'local' + const session = this.store?.getWorkspaceSession?.(hostId) + if (session) { + sessionsByHostId.set(hostId, session) + } + } + for (const session of sessionsByHostId.values()) { + for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree ?? {})) { + for (const tab of tabs) { + mirroredWorktreeIdByTabId.set(tab.id, worktreeId) + } + if (tabs.length === 0) { + continue + } + const summary = this.getSummaryForRuntimeWorktreeId( + summaries, + runtimeWorktreeSummaryPathIndex, + missingRuntimeWorktreeIds, + worktreeId + ) + if (!summary) { + continue + } + if (tabs.some((tab) => tab.ptyId !== null && this.ptysById.get(tab.ptyId)?.connected)) { + summary.hasHostSidebarActivity = true + } + } + for (const [worktreeId, tabs] of Object.entries(session.browserTabsByWorktree ?? {})) { + if (tabs.length === 0) { + continue + } + const summary = this.getSummaryForRuntimeWorktreeId( + summaries, + runtimeWorktreeSummaryPathIndex, + missingRuntimeWorktreeIds, + worktreeId + ) + if (summary) { + summary.hasHostSidebarActivity = true + } + } + if (session.activeWorktreeId) { + const activeSummary = this.getSummaryForRuntimeWorktreeId( + summaries, + runtimeWorktreeSummaryPathIndex, + missingRuntimeWorktreeIds, + session.activeWorktreeId + ) + if (activeSummary) { + activeSummary.isActive = true + } } } // Why: a live renderer graph may precede persistence, but persisted tab @@ -16586,7 +16896,10 @@ export class OrcaRuntimeService { const detected = scan.worktrees.map((gitWorktree) => { const worktreeId = `${repo.id}::${gitWorktree.path}` const meta = store.getWorktreeMeta(worktreeId) - const worktree = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) + const worktree = { + ...mergeWorktree(repo.id, gitWorktree, meta, repo.displayName), + hostId: meta?.hostId ?? getRepoExecutionHostId(repo) + } const detectedWorktree = this.toRuntimeDetectedWorktree( repo, worktree, @@ -17968,7 +18281,10 @@ export class OrcaRuntimeService { ...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}), ...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}) }) - const worktree = mergeWorktree(repo.id, created, meta) + const worktree = { + ...mergeWorktree(repo.id, created, meta), + hostId: meta.hostId ?? getRepoExecutionHostId(repo) + } const { lineage, workspaceLineage, @@ -20883,6 +21199,7 @@ export class OrcaRuntimeService { ptyId: result.id, worktreeId: workspace.id, title: launchOpts.title ?? null, + ...this.getPtyExecutionHostMetadata(result.id), surface, ...(result.agentSessionEnsure ? { agentSessionDisposition: result.agentSessionEnsure.disposition } @@ -20961,6 +21278,7 @@ export class OrcaRuntimeService { tabId: reply.tabId, worktreeId: worktreeId ?? '', title: reply.title, + ...this.getPtyExecutionHostMetadata(this.handles.get(handle)?.ptyId ?? null), surface: 'visible' } } @@ -21059,6 +21377,29 @@ export class OrcaRuntimeService { } } + private getPtyExecutionHostMetadata( + ptyId: string | null + ): Pick { + if (!ptyId) { + return {} + } + const pty = this.ptysById.get(ptyId) + if (!pty) { + return {} + } + if (pty.connectionId) { + const remotePlatform = getRegisteredSshState(pty.connectionId)?.remotePlatform + return { + executionHostId: toSshExecutionHostId(pty.connectionId), + ...(remotePlatform ? { hostPlatform: remotePlatform } : {}) + } + } + return { + executionHostId: LOCAL_EXECUTION_HOST_ID, + hostPlatform: pty.isWsl || pty.wslDistro ? 'linux' : process.platform + } + } + async launchAgentTerminal( worktreeSelector: string, opts: { agent: TuiAgent; prompt: string; title?: string } @@ -21671,6 +22012,22 @@ export class OrcaRuntimeService { return surface } + private findMobileTerminalSurfaceForPty( + worktreeId: string, + ptyId: string + ): RuntimeMobileSessionCreateTerminalResult | null { + const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + const tab = snapshot?.tabs.find( + (candidate) => + candidate.type === 'terminal' && + (candidate.ptyId === ptyId || + candidate.parentLayout?.ptyIdsByLeafId?.[candidate.leafId] === ptyId) + ) + return tab?.type === 'terminal' + ? this.findMobileTerminalSurface(worktreeId, tab.parentTabId) + : null + } + // Why: publish an in-flight mobile create main-side from the live PTY so it can't stall on graph sync and destroy the session (#7587). private ensurePtyBackedMobileSurfaceForRendererTab( worktreeId: string, @@ -21978,8 +22335,33 @@ export class OrcaRuntimeService { const pty = this.getLivePtyForHandle(handle) this.claudeAgentTeams.removeTeamForLeaderHandle(handle) if (pty) { + // Why: PTY exit can immediately replace a ready SSH publication with a pending one, so capture its durable HUB surface before killing it. + const surface = + (pty.pty.tabId + ? this.findMobileTerminalSurface(pty.pty.worktreeId, pty.pty.tabId) + : null) ?? this.findMobileTerminalSurfaceForPty(pty.pty.worktreeId, pty.pty.ptyId) + const tabId = surface?.tab.parentTabId ?? pty.pty.tabId ?? pty.record.tabId + // Why: relay recovery can leave stale renderer leaves; the persisted HUB layout defines whether closing this PTY closes the whole surface. + const siblingCount = surface?.tab.parentLayout + ? countTerminalLayoutLeaves(surface.tab.parentLayout.root) + : this.countLeavesInTab(tabId) const ptyKilled = this.ptyController?.kill(pty.pty.ptyId) ?? false - return { handle, tabId: pty.pty.tabId ?? pty.record.tabId, ptyKilled } + if (!ptyKilled || siblingCount <= 1) { + if (surface) { + // Why: paired viewers keep ended streams mounted until the HUB publishes removal, so explicit close uses the durable host-tab transaction instead of viewer-local exit handling. + try { + await this.closeMobileSessionTab(`id:${pty.pty.worktreeId}`, tabId) + } catch (error) { + if (!(error instanceof Error) || error.message !== 'workspace_session_unavailable') { + throw error + } + this.notifier?.closeTerminal(tabId) + } + } else { + this.notifier?.closeTerminal(tabId) + } + } + return { handle, tabId, ptyKilled } } this.assertGraphReady() const { leaf } = this.getLiveLeafForHandle(handle) @@ -22130,6 +22512,7 @@ export class OrcaRuntimeService { }) // Why: persist the split so a later snapshot rebuild keeps it instead of collapsing to a single pane. this.persistHeadlessTerminalSplit({ + worktreeId: workspace.id, tabId: parentTabId, leafId, ptyId: createdPty.ptyId, @@ -23586,7 +23969,10 @@ export class OrcaRuntimeService { isMainWorktree: repo ? areWorktreePathsEqual(parsed.worktreePath, repo.path) : false } const meta = this.store?.getWorktreeMeta(worktreeId) - const merged = mergeWorktree(parsed.repoId, git, meta, repo?.displayName) + const merged = { + ...mergeWorktree(parsed.repoId, git, meta, repo?.displayName), + ...(repo ? { hostId: meta?.hostId ?? getRepoExecutionHostId(repo) } : {}) + } return { ...merged, id: worktreeId, @@ -23683,6 +24069,7 @@ export class OrcaRuntimeService { if (isFolderRepo(repo)) { return listRuntimeFolderWorkspaces(this.requireStore(), repo).map((worktree) => ({ ...worktree, + hostId: worktree.hostId ?? getRepoExecutionHostId(repo), parentWorktreeId: null, childWorktreeIds: [], lineage: null, @@ -23715,7 +24102,10 @@ export class OrcaRuntimeService { existingMeta && existingMeta.instanceId ? existingMeta : this.store?.setWorktreeMeta(worktreeId, {}) - const merged = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) + const merged = { + ...mergeWorktree(repo.id, gitWorktree, meta, repo.displayName), + hostId: existingMeta?.hostId ?? meta?.hostId ?? getRepoExecutionHostId(repo) + } return { ...merged, parentWorktreeId: null, @@ -24183,6 +24573,19 @@ export class OrcaRuntimeService { } for (const pty of this.ptysById.values()) { if (!allLivePtyIds.has(pty.ptyId) && !this.leafExistsForPty(pty.ptyId)) { + if (this.ptyController.hasPty?.(pty.ptyId) === true) { + // Why: an SSH spawn can become addressable before an overlapping relay list includes it. + allLivePtyIds.add(pty.ptyId) + if ( + !targetWorktreeId || + (pty.worktreeId && runtimeWorktreeIdsEqual(pty.worktreeId, targetWorktreeId)) + ) { + selectedLivePtyIds.add(pty.ptyId) + } + pty.connected = true + pty.disconnectedAt = null + continue + } pty.connected = false pty.disconnectedAt ??= Date.now() } @@ -24431,11 +24834,17 @@ export class OrcaRuntimeService { // renderer resends before they replace an entry — so reference identity // before/after detects exactly the entries that actually changed. const before = new Map(this.mobileSessionTabsByWorktree) - // Why: renderer graphs own renderer tabs, but headless serve terminals never enter that graph unless we preserve their bindings. - this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(undefined, { - allowAttachedWindow: true, - onlyServeOwnedTerminals: true - }) + const worktreeIdsToHydrate = this.getKnownWorkspaceSessionWorktreeIds() + for (const snapshot of snapshots) { + worktreeIdsToHydrate.add(snapshot.worktree) + } + // Why: an empty renderer publication after HUB restart must not hide SSH panes persisted in this HUB's host partition. + for (const worktreeId of worktreeIdsToHydrate) { + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { + allowAttachedWindow: true, + onlyRuntimeOwnedTerminals: true + }) + } const nextWorktrees = new Set() for (const snapshot of snapshots) { nextWorktrees.add(snapshot.worktree) @@ -25413,35 +25822,41 @@ export class OrcaRuntimeService { private getTerminalHandleForPaneKey(paneKey: string): string | null { const parsed = parsePaneKey(paneKey) - if (parsed) { - const leaf = this.leaves.get(this.getLeafKey(parsed.tabId, parsed.leafId)) - if (leaf?.ptyId) { - return this.issueHandle(leaf) - } + const leaf = parsed ? this.leaves.get(this.getLeafKey(parsed.tabId, parsed.leafId)) : undefined + if (leaf?.ptyId && leaf.connected) { + return this.issueHandle(leaf) } - for (const pty of this.ptysById.values()) { - if (pty.paneKey === paneKey) { - return this.issuePtyHandle(pty) - } + const panePty = this.getPtyRecordForPaneKey(paneKey) + if (panePty?.connected) { + return this.issuePtyHandle(panePty) } - return null + if (leaf?.ptyId) { + return this.issueHandle(leaf) + } + return panePty ? this.issuePtyHandle(panePty) : null } private getPtyRecordForPaneKey(paneKey: string): RuntimePtyWorktreeRecord | null { const parsed = parsePaneKey(paneKey) + let leafPty: RuntimePtyWorktreeRecord | null = null if (parsed) { const leaf = this.leaves.get(this.getLeafKey(parsed.tabId, parsed.leafId)) const pty = leaf?.ptyId ? this.ptysById.get(leaf.ptyId) : undefined - if (pty) { + if (pty?.connected) { return pty } + leafPty = pty ?? null } + let newestMatch: RuntimePtyWorktreeRecord | null = null for (const pty of this.ptysById.values()) { if (pty.paneKey === paneKey) { - return pty + if (pty.connected) { + return pty + } + newestMatch = pty } } - return null + return leafPty ?? newestMatch } private getPaneKeyForTerminalHandle(handle: string): string | null { diff --git a/src/main/runtime/public-ssh-state.ts b/src/main/runtime/public-ssh-state.ts new file mode 100644 index 00000000000..4bd6c3e6d5b --- /dev/null +++ b/src/main/runtime/public-ssh-state.ts @@ -0,0 +1,9 @@ +import type { SshConnectionState } from '../../shared/ssh-types' + +export function getPublicSshError(status: SshConnectionState['status']): string { + return status === 'auth-failed' ? 'SSH authentication failed' : 'SSH connection unavailable' +} + +export function getPublicSshState(state: SshConnectionState | null): SshConnectionState | null { + return state ? { ...state, error: state.error ? getPublicSshError(state.status) : null } : null +} diff --git a/src/main/runtime/remote-server-updater.test.ts b/src/main/runtime/remote-server-updater.test.ts new file mode 100644 index 00000000000..814b5032b32 --- /dev/null +++ b/src/main/runtime/remote-server-updater.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest' +import { + checkRemoteServerUpdater, + configureRemoteServerUpdater, + downloadRemoteServerUpdater, + getRemoteServerUpdaterSnapshot, + installRemoteServerUpdater +} from './remote-server-updater' + +describe('remote server updater adapter', () => { + it('defaults to a safe manual-only implementation', () => { + expect(getRemoteServerUpdaterSnapshot('runtime-1')).toMatchObject({ + runtimeId: 'runtime-1', + support: { automatic: false, reason: 'updater-unavailable' } + }) + expect(() => checkRemoteServerUpdater('runtime-1')).toThrow('remote_update_manual_required') + expect(() => downloadRemoteServerUpdater('runtime-1')).toThrow('remote_update_manual_required') + expect(() => installRemoteServerUpdater('runtime-1')).toThrow('remote_update_manual_required') + }) + + it('passes the runtime identity through every configured operation', () => { + const snapshot = { + appVersion: '1.5.0', + runtimeId: 'runtime-2', + support: { installMode: 'interactive', automatic: true, reason: 'available' }, + status: { state: 'available', version: '1.5.1', changelog: null } + } as const + const getSnapshot = vi.fn(() => snapshot) + const check = vi.fn(() => snapshot) + const download = vi.fn(() => snapshot) + const install = vi.fn(() => ({ + accepted: true as const, + fromVersion: '1.5.0', + targetVersion: '1.5.1', + runtimeId: 'runtime-2' + })) + configureRemoteServerUpdater({ getSnapshot, check, download, install }) + + expect(getRemoteServerUpdaterSnapshot('runtime-2')).toBe(snapshot) + expect(checkRemoteServerUpdater('runtime-2')).toBe(snapshot) + expect(downloadRemoteServerUpdater('runtime-2')).toBe(snapshot) + expect(installRemoteServerUpdater('runtime-2').accepted).toBe(true) + expect([getSnapshot, check, download, install].map((fn) => fn.mock.calls[0])).toEqual([ + ['runtime-2'], + ['runtime-2'], + ['runtime-2'], + ['runtime-2'] + ]) + }) +}) diff --git a/src/main/runtime/remote-server-updater.ts b/src/main/runtime/remote-server-updater.ts new file mode 100644 index 00000000000..a61caa3d591 --- /dev/null +++ b/src/main/runtime/remote-server-updater.ts @@ -0,0 +1,59 @@ +import type { + RemoteServerUpdateInstallResult, + RemoteServerUpdaterSnapshot +} from '../../shared/remote-server-update' +import type { UpdateCheckOptions } from '../../shared/types' + +type RemoteServerUpdaterAdapter = { + getSnapshot: (runtimeId: string) => RemoteServerUpdaterSnapshot + check: (runtimeId: string, options?: UpdateCheckOptions) => RemoteServerUpdaterSnapshot + download: (runtimeId: string) => RemoteServerUpdaterSnapshot + install: (runtimeId: string) => RemoteServerUpdateInstallResult +} + +const unavailableSnapshot = (runtimeId: string): RemoteServerUpdaterSnapshot => ({ + appVersion: process.env.ORCA_APP_VERSION ?? '0.0.0-dev', + runtimeId, + support: { + installMode: 'unsupported-headless-serve', + automatic: false, + reason: 'updater-unavailable' + }, + status: { state: 'idle' } +}) + +let adapter: RemoteServerUpdaterAdapter = { + getSnapshot: unavailableSnapshot, + check: () => { + throw new Error('remote_update_manual_required') + }, + download: () => { + throw new Error('remote_update_manual_required') + }, + install: () => { + throw new Error('remote_update_manual_required') + } +} + +export function configureRemoteServerUpdater(next: RemoteServerUpdaterAdapter): void { + adapter = next +} + +export function getRemoteServerUpdaterSnapshot(runtimeId: string): RemoteServerUpdaterSnapshot { + return adapter.getSnapshot(runtimeId) +} + +export function checkRemoteServerUpdater( + runtimeId: string, + options?: UpdateCheckOptions +): RemoteServerUpdaterSnapshot { + return options ? adapter.check(runtimeId, options) : adapter.check(runtimeId) +} + +export function downloadRemoteServerUpdater(runtimeId: string): RemoteServerUpdaterSnapshot { + return adapter.download(runtimeId) +} + +export function installRemoteServerUpdater(runtimeId: string): RemoteServerUpdateInstallResult { + return adapter.install(runtimeId) +} diff --git a/src/main/runtime/rpc/errors.test.ts b/src/main/runtime/rpc/errors.test.ts index b1670590a9c..0e4519656cb 100644 --- a/src/main/runtime/rpc/errors.test.ts +++ b/src/main/runtime/rpc/errors.test.ts @@ -19,6 +19,17 @@ describe('mapRuntimeError', () => { } ) + it.each([ + 'remote_update_manual_required', + 'remote_update_not_available', + 'remote_update_not_downloaded' + ])('preserves remote updater failure %s', (code) => { + expect(mapRuntimeError('req_1', { runtimeId: 'runtime-1' }, new Error(code))).toMatchObject({ + ok: false, + error: { code, message: code } + }) + }) + it.each([ ['window_not_focused', 'keyboard input requires focus', 'restore-window'], ['permission_denied', 'missing DBUS_SESSION_BUS_ADDRESS', 'permissions'], diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index 823859e0be1..39190c6dfcb 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -51,6 +51,9 @@ const RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ 'repo_not_found', 'timeout', 'invalid_limit', + 'remote_update_manual_required', + 'remote_update_not_available', + 'remote_update_not_downloaded', ...AGENT_SESSION_RPC_ERROR_CODES ]) diff --git a/src/main/runtime/rpc/methods/client-events.ts b/src/main/runtime/rpc/methods/client-events.ts index 8bf00dccab3..9a73948255a 100644 --- a/src/main/runtime/rpc/methods/client-events.ts +++ b/src/main/runtime/rpc/methods/client-events.ts @@ -1,4 +1,6 @@ import { z } from 'zod' +import { getRegisteredSshState, listRegisteredSshTargets } from '../../../ipc/ssh' +import { getPublicSshState } from '../../public-ssh-state' import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' let clientEventSubscriptionSeq = 0 @@ -36,7 +38,12 @@ export const CLIENT_EVENT_METHODS: readonly RpcAnyMethod[] = [ for (const event of runtime.getTerminalSleepClientEventSnapshot?.() ?? []) { emit(event) } - emit({ type: 'ready', subscriptionId }) + const sshStates = listRegisteredSshTargets().flatMap((target) => { + const state = getPublicSshState(getRegisteredSshState(target.id) ?? null) + return state ? [{ targetId: target.id, state }] : [] + }) + // Why: attaching the listener before snapshotting closes the reload gap without exposing HUB-private target configuration. + emit({ type: 'ready', subscriptionId, snapshot: { sshStates } }) }) } }), diff --git a/src/main/runtime/rpc/methods/files.test.ts b/src/main/runtime/rpc/methods/files.test.ts index 24b415e751b..990689a741f 100644 --- a/src/main/runtime/rpc/methods/files.test.ts +++ b/src/main/runtime/rpc/methods/files.test.ts @@ -396,7 +396,7 @@ describe('file RPC methods', () => { } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) - const response = await dispatcher.dispatch( + await dispatcher.dispatch( makeRequest('files.writeTerminalArtifact', { worktree: 'id:wt-1', absolutePath: '/tmp/result.json', @@ -412,7 +412,6 @@ describe('file RPC methods', () => { '{}', undefined ) - expect(response).toMatchObject({ ok: true, result: { ok: true } }) }) it('reads a preview file for a selected worktree', async () => { @@ -775,7 +774,7 @@ describe('file RPC methods', () => { } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) - const response = await dispatcher.dispatch( + await dispatcher.dispatch( makeRequest('files.delete', { worktree: 'id:wt-1', relativePath: 'src', @@ -784,6 +783,34 @@ describe('file RPC methods', () => { ) expect(runtime.deleteFileExplorerPath).toHaveBeenCalledWith('id:wt-1', 'src', true) + }) + + it('forwards the captured SSH target and generation for destructive mutations', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + deleteFileExplorerPath: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('files.delete', { + worktree: 'id:wt-1', + relativePath: 'src', + recursive: true, + expectedExecutionHostId: 'ssh:ssh-1', + expectedSshTargetId: 'ssh-1', + expectedSshConnectionGeneration: 7 + }) + ) + + expect(runtime.deleteFileExplorerPath).toHaveBeenCalledWith( + 'id:wt-1', + 'src', + true, + 7, + 'ssh-1', + 'ssh:ssh-1' + ) expect(response).toMatchObject({ ok: true, result: { ok: true } }) }) diff --git a/src/main/runtime/rpc/methods/files.ts b/src/main/runtime/rpc/methods/files.ts index b9af390152b..06c2325913c 100644 --- a/src/main/runtime/rpc/methods/files.ts +++ b/src/main/runtime/rpc/methods/files.ts @@ -12,6 +12,29 @@ function isValidRuntimeFileBase64(value: unknown): value is string { ) } +type SshMutationParams = { + expectedExecutionHostId?: string + expectedSshTargetId?: string + expectedSshConnectionGeneration?: number +} + +function sshMutationArguments( + params: SshMutationParams +): [expectedGeneration?: number, expectedTargetId?: string, expectedExecutionHostId?: string] { + if ( + params.expectedExecutionHostId === undefined && + params.expectedSshTargetId === undefined && + params.expectedSshConnectionGeneration === undefined + ) { + return [] + } + return [ + params.expectedSshConnectionGeneration, + params.expectedSshTargetId, + params.expectedExecutionHostId + ] +} + const WorktreeSelector = z.object({ worktree: z .unknown() @@ -31,6 +54,12 @@ const FileOpen = WorktreeSelector.extend({ .pipe(z.string().min(1, 'Missing relative path')) }) +const FileMutationOpen = FileOpen.extend({ + expectedExecutionHostId: z.string().min(1).optional(), + expectedSshTargetId: z.string().min(1).optional(), + expectedSshConnectionGeneration: z.number().int().nonnegative().optional() +}) + const ResolveTerminalPath = WorktreeSelector.extend({ pathText: z .unknown() @@ -86,13 +115,13 @@ const ServerDirectoryBrowse = z.object({ // Why: write content must be a real string. Coercing a missing/non-string value // to '' silently truncated the target file to empty instead of erroring. An // explicit '' is still accepted (writing an empty file is legitimate). -const FileWrite = FileOpen.extend({ +const FileWrite = FileMutationOpen.extend({ content: z .unknown() .refine((v): v is string => typeof v === 'string', { message: 'Missing file content' }) }) -const FileWriteBase64 = FileOpen.extend({ +const FileWriteBase64 = FileMutationOpen.extend({ contentBase64: z .unknown() .refine((v): v is string => typeof v === 'string', { message: 'Missing file content' }) @@ -115,6 +144,9 @@ const FileReadChunk = FileOpen.extend({ }) const FileRename = WorktreeSelector.extend({ + expectedExecutionHostId: z.string().min(1).optional(), + expectedSshTargetId: z.string().min(1).optional(), + expectedSshConnectionGeneration: z.number().int().nonnegative().optional(), oldRelativePath: z .unknown() .transform((v) => (typeof v === 'string' ? v : '')) @@ -126,6 +158,9 @@ const FileRename = WorktreeSelector.extend({ }) const FileCopy = WorktreeSelector.extend({ + expectedExecutionHostId: z.string().min(1).optional(), + expectedSshTargetId: z.string().min(1).optional(), + expectedSshConnectionGeneration: z.number().int().nonnegative().optional(), sourceRelativePath: z .unknown() .transform((v) => (typeof v === 'string' ? v : '')) @@ -137,6 +172,9 @@ const FileCopy = WorktreeSelector.extend({ }) const FileCommitUpload = WorktreeSelector.extend({ + expectedExecutionHostId: z.string().min(1).optional(), + expectedSshTargetId: z.string().min(1).optional(), + expectedSshConnectionGeneration: z.number().int().nonnegative().optional(), tempRelativePath: z .unknown() .transform((v) => (typeof v === 'string' ? v : '')) @@ -147,7 +185,7 @@ const FileCommitUpload = WorktreeSelector.extend({ .pipe(z.string().min(1, 'Missing final path')) }) -const FileDelete = FileOpen.extend({ +const FileDelete = FileMutationOpen.extend({ recursive: z.boolean().optional() }) @@ -283,7 +321,12 @@ export const FILE_METHODS: RpcAnyMethod[] = [ name: 'files.write', params: FileWrite, handler: async (params, { runtime }) => - runtime.writeFileExplorerFile(params.worktree, params.relativePath, params.content) + runtime.writeFileExplorerFile( + params.worktree, + params.relativePath, + params.content, + ...sshMutationArguments(params) + ) }), defineMethod({ name: 'files.writeBase64', @@ -292,7 +335,8 @@ export const FILE_METHODS: RpcAnyMethod[] = [ runtime.writeFileExplorerFileBase64( params.worktree, params.relativePath, - params.contentBase64 + params.contentBase64, + ...sshMutationArguments(params) ) }), defineMethod({ @@ -303,26 +347,39 @@ export const FILE_METHODS: RpcAnyMethod[] = [ params.worktree, params.relativePath, params.contentBase64, - params.append === true + params.append === true, + ...sshMutationArguments(params) ) }), defineMethod({ name: 'files.createFile', - params: FileOpen, + params: FileMutationOpen, handler: async (params, { runtime }) => - runtime.createFileExplorerFile(params.worktree, params.relativePath) + runtime.createFileExplorerFile( + params.worktree, + params.relativePath, + ...sshMutationArguments(params) + ) }), defineMethod({ name: 'files.createDir', - params: FileOpen, + params: FileMutationOpen, handler: async (params, { runtime }) => - runtime.createFileExplorerDir(params.worktree, params.relativePath) + runtime.createFileExplorerDir( + params.worktree, + params.relativePath, + ...sshMutationArguments(params) + ) }), defineMethod({ name: 'files.createDirNoClobber', - params: FileOpen, + params: FileMutationOpen, handler: async (params, { runtime }) => - runtime.createFileExplorerDirNoClobber(params.worktree, params.relativePath) + runtime.createFileExplorerDirNoClobber( + params.worktree, + params.relativePath, + ...sshMutationArguments(params) + ) }), defineMethod({ name: 'files.commitUpload', @@ -331,7 +388,8 @@ export const FILE_METHODS: RpcAnyMethod[] = [ runtime.commitFileExplorerUpload( params.worktree, params.tempRelativePath, - params.finalRelativePath + params.finalRelativePath, + ...sshMutationArguments(params) ) }), defineMethod({ @@ -341,7 +399,8 @@ export const FILE_METHODS: RpcAnyMethod[] = [ runtime.renameFileExplorerPath( params.worktree, params.oldRelativePath, - params.newRelativePath + params.newRelativePath, + ...sshMutationArguments(params) ) }), defineMethod({ @@ -351,14 +410,20 @@ export const FILE_METHODS: RpcAnyMethod[] = [ runtime.copyFileExplorerPath( params.worktree, params.sourceRelativePath, - params.destinationRelativePath + params.destinationRelativePath, + ...sshMutationArguments(params) ) }), defineMethod({ name: 'files.delete', params: FileDelete, handler: async (params, { runtime }) => - runtime.deleteFileExplorerPath(params.worktree, params.relativePath, params.recursive) + runtime.deleteFileExplorerPath( + params.worktree, + params.relativePath, + params.recursive, + ...sshMutationArguments(params) + ) }), defineMethod({ name: 'files.search', diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index be9c828f117..7212729348b 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -35,6 +35,7 @@ import { CLIPBOARD_METHODS } from './clipboard' import { HOST_CAPABILITY_METHODS } from './host-capabilities' import { EMULATOR_METHODS } from './emulator' import { PAIRING_METHODS } from './pairing' +import { UPDATER_METHODS } from './updater' import { AGENT_SESSION_METHODS } from './agent-session' // Why: a flat manifest keeps registration order explicit and provides one @@ -77,5 +78,6 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ ...CLIENT_EVENT_METHODS, ...CLIENT_UI_METHODS, ...EMULATOR_METHODS, - ...PAIRING_METHODS + ...PAIRING_METHODS, + ...UPDATER_METHODS ] diff --git a/src/main/runtime/rpc/methods/ssh.test.ts b/src/main/runtime/rpc/methods/ssh.test.ts index 8efab508345..d19f5f85a53 100644 --- a/src/main/runtime/rpc/methods/ssh.test.ts +++ b/src/main/runtime/rpc/methods/ssh.test.ts @@ -72,15 +72,89 @@ describe('ssh RPC methods', () => { expect(response).toMatchObject({ ok: true, result: { state: null } }) }) - it('lists the registered SSH targets for paired clients', async () => { - const targets = [{ id: 'ssh-1', label: 'Dev box', host: 'dev', port: 22, username: 'me' }] + it('redacts HUB-private diagnostics from state and connect failures', async () => { + const privateMessage = 'identity /Users/hub/.ssh/private via bastion.internal failed' + getRegisteredSshStateMock.mockReturnValue({ + targetId: 'ssh-1', + status: 'auth-failed', + error: privateMessage, + reconnectAttempt: 0 + }) + connectRegisteredSshTargetMock.mockRejectedValueOnce(new Error(privateMessage)) + const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS }) + + const stateResponse = await dispatcher.dispatch( + makeRequest('ssh.getState', { targetId: 'ssh-1' }) + ) + const connectResponse = await dispatcher.dispatch( + makeRequest('ssh.connect', { targetId: 'ssh-1' }) + ) + + expect(stateResponse).toMatchObject({ + ok: true, + result: { state: { error: 'SSH authentication failed' } } + }) + expect(connectResponse).toMatchObject({ + ok: false, + error: { message: 'SSH authentication failed' } + }) + expect(JSON.stringify([stateResponse, connectResponse])).not.toContain(privateMessage) + }) + + it('lists redacted SSH target summaries for paired clients', async () => { + const targets = [ + { + id: 'ssh-1', + label: 'Dev box', + host: 'dev.internal', + port: 22, + username: 'me', + identityFile: '/secret/key', + jumpHost: 'bastion', + proxyCommand: 'private proxy' + } + ] + listRegisteredSshTargetsMock.mockReturnValueOnce(targets) + const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS }) + + const response = await dispatcher.dispatch(makeRequest('ssh.listTargetSummaries')) + + expect(response).toMatchObject({ + ok: true, + result: { targets: [{ id: 'ssh-1', label: 'Dev box' }] } + }) + expect(JSON.stringify(response)).not.toContain('dev.internal') + expect(JSON.stringify(response)).not.toContain('/secret/key') + expect(JSON.stringify(response)).not.toContain('bastion') + }) + + it('redacts the legacy target response for older clients', async () => { + const targets = [ + { + id: 'ssh-1', + label: 'Dev box', + host: 'dev.internal', + port: 22, + username: 'me', + identityFile: '/secret/key', + jumpHost: 'bastion' + } + ] listRegisteredSshTargetsMock.mockReturnValueOnce(targets) const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS }) const response = await dispatcher.dispatch(makeRequest('ssh.listTargets')) - expect(response).toMatchObject({ ok: true, result: { targets } }) + expect(response).toMatchObject({ + ok: true, + result: { targets: [{ id: 'ssh-1', label: 'Dev box' }] } + }) + expect(JSON.stringify(response)).not.toContain('dev.internal') + expect(JSON.stringify(response)).not.toContain('/secret/key') + expect(JSON.stringify(response)).not.toContain('bastion') }) it('lists removed-target labels for ghost-host display on paired clients', async () => { diff --git a/src/main/runtime/rpc/methods/ssh.ts b/src/main/runtime/rpc/methods/ssh.ts index 6de8634a881..0e970af6864 100644 --- a/src/main/runtime/rpc/methods/ssh.ts +++ b/src/main/runtime/rpc/methods/ssh.ts @@ -6,26 +6,47 @@ import { listRegisteredSshTargets } from '../../../ipc/ssh' import { defineMethod, type RpcMethod } from '../core' +import { getPublicSshError, getPublicSshState } from '../../public-ssh-state' const SshTarget = z.object({ targetId: z.string().min(1) }) +function listRegisteredSshTargetSummaries(): { id: string; label: string }[] { + return listRegisteredSshTargets().map(({ id, label }) => ({ id, label })) +} + export const SSH_METHODS: RpcMethod[] = [ defineMethod({ name: 'ssh.getState', params: SshTarget, - handler: (params) => ({ state: getRegisteredSshState(params.targetId) ?? null }) + handler: (params) => ({ + state: getPublicSshState(getRegisteredSshState(params.targetId) ?? null) + }) }), defineMethod({ name: 'ssh.connect', params: SshTarget, - handler: async (params) => ({ state: await connectRegisteredSshTarget(params.targetId) }) + handler: async (params) => { + try { + return { state: getPublicSshState(await connectRegisteredSshTarget(params.targetId)) } + } catch { + const state = getRegisteredSshState(params.targetId) + throw new Error(getPublicSshError(state?.status ?? 'error')) + } + } }), defineMethod({ name: 'ssh.listTargets', params: null, - handler: () => ({ targets: listRegisteredSshTargets() }) + // Why: legacy clients can call this method directly, so it must preserve the same HUB-private secret boundary. + handler: () => ({ targets: listRegisteredSshTargetSummaries() }) + }), + defineMethod({ + name: 'ssh.listTargetSummaries', + params: null, + // Why: paired clients need display identity only; SSH addresses, jump chains, and credentials remain HUB-private. + handler: () => ({ targets: listRegisteredSshTargetSummaries() }) }), defineMethod({ name: 'ssh.listRemovedTargetLabels', diff --git a/src/main/runtime/rpc/methods/status.ts b/src/main/runtime/rpc/methods/status.ts index 083b566e700..fe9cb78ce45 100644 --- a/src/main/runtime/rpc/methods/status.ts +++ b/src/main/runtime/rpc/methods/status.ts @@ -1,9 +1,17 @@ import { defineMethod, type RpcMethod } from '../core' +import { getRemoteServerUpdaterSnapshot } from '../../remote-server-updater' export const STATUS_METHODS: RpcMethod[] = [ defineMethod({ name: 'status.get', params: null, - handler: (_params, { runtime }) => runtime.getStatus() + handler: (_params, { runtime }) => { + const snapshot = getRemoteServerUpdaterSnapshot(runtime.getRuntimeId()) + return { + ...runtime.getStatus(), + appVersion: snapshot.appVersion, + remoteUpdateSupport: snapshot.support + } + } }) ] diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 02dd619336b..8f952fcc15f 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -839,7 +839,14 @@ const TerminalResolveActive = z.object({ }) const TerminalResolvePane = z.object({ - paneKey: requiredString('Missing pane key') + paneKey: requiredString('Missing pane key'), + worktreeId: OptionalString +}) + +const TerminalRecoverPane = z.object({ + paneKey: requiredString('Missing pane key'), + worktreeId: requiredString('Missing worktree ID'), + expectedTerminal: requiredString('Missing expected terminal handle').optional() }) const TerminalRead = TerminalHandle.extend({ @@ -1110,7 +1117,18 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ name: 'terminal.resolvePane', params: TerminalResolvePane, handler: async (params, { runtime }) => ({ - terminal: runtime.resolveTerminalPane(params.paneKey) + terminal: runtime.resolveTerminalPane(params.paneKey, params.worktreeId) + }) + }), + defineMethod({ + name: 'terminal.recoverPane', + params: TerminalRecoverPane, + handler: async (params, { runtime }) => ({ + terminal: await runtime.recoverTerminalPane( + params.paneKey, + params.worktreeId, + params.expectedTerminal + ) }) }), defineMethod({ @@ -2540,7 +2558,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ .then(() => runtime.cleanupSubscription(subscriptionId)) .catch(() => runtime.cleanupSubscription(subscriptionId)) try { - await runtime.handleMobileSubscribe(ptyId, clientId, params.viewport) + // Why: a lease-only subscriber has no terminal view, so its cached viewport must never phone-fit the PTY. + await runtime.handleMobileSubscribe(ptyId, clientId, undefined) if (closed || signal?.aborted) { // Why: a disconnect can win the awaited subscribe and resurrect mobile presence after cleanup already released it. runtime.handleMobileUnsubscribe(ptyId, clientId) diff --git a/src/main/runtime/rpc/methods/updater.test.ts b/src/main/runtime/rpc/methods/updater.test.ts new file mode 100644 index 00000000000..9c3ce0ef810 --- /dev/null +++ b/src/main/runtime/rpc/methods/updater.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { configureRemoteServerUpdater } from '../../remote-server-updater' +import { STATUS_METHODS } from './status' +import { UPDATER_METHODS } from './updater' + +const snapshot = { + appVersion: '1.5.0', + runtimeId: 'runtime-rpc', + support: { installMode: 'interactive', automatic: true, reason: 'available' }, + status: { state: 'available', version: '1.5.1', changelog: null } +} as const + +function handler(methods: typeof UPDATER_METHODS, name: string) { + const method = methods.find((candidate) => candidate.name === name) + if (!method) { + throw new Error(`Missing method ${name}`) + } + return method.handler +} + +describe('runtime updater RPC methods', () => { + const getSnapshot = vi.fn(() => snapshot) + const check = vi.fn(() => snapshot) + const download = vi.fn(() => snapshot) + const install = vi.fn(() => ({ + accepted: true as const, + fromVersion: '1.5.0', + targetVersion: '1.5.1', + runtimeId: 'runtime-rpc' + })) + const runtime = { + getRuntimeId: () => 'runtime-rpc', + getStatus: () => ({ runtimeId: 'runtime-rpc', liveTabCount: 2, liveLeafCount: 3 }) + } + + beforeEach(() => { + vi.clearAllMocks() + configureRemoteServerUpdater({ getSnapshot, check, download, install }) + }) + + it('exposes status and each update transition', async () => { + const context = { runtime } as never + expect(await handler(UPDATER_METHODS, 'updater.getStatus')(undefined, context)).toBe(snapshot) + expect( + await handler(UPDATER_METHODS, 'updater.check')( + { includePrerelease: false, includePerfPrerelease: true }, + context + ) + ).toBe(snapshot) + expect(await handler(UPDATER_METHODS, 'updater.download')(undefined, context)).toBe(snapshot) + expect(await handler(UPDATER_METHODS, 'updater.install')(undefined, context)).toMatchObject({ + accepted: true, + runtimeId: 'runtime-rpc' + }) + expect(check).toHaveBeenCalledWith('runtime-rpc', { + includePrerelease: false, + includePerfPrerelease: true + }) + }) + + it('enriches status.get without changing the runtime status source', async () => { + const result = await handler(STATUS_METHODS, 'status.get')(undefined, { runtime } as never) + expect(result).toEqual({ + runtimeId: 'runtime-rpc', + liveTabCount: 2, + liveLeafCount: 3, + appVersion: '1.5.0', + remoteUpdateSupport: snapshot.support + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/updater.ts b/src/main/runtime/rpc/methods/updater.ts new file mode 100644 index 00000000000..1baa2aff53b --- /dev/null +++ b/src/main/runtime/rpc/methods/updater.ts @@ -0,0 +1,34 @@ +import { defineMethod, type RpcMethod } from '../core' +import { z } from 'zod' +import { + checkRemoteServerUpdater, + downloadRemoteServerUpdater, + getRemoteServerUpdaterSnapshot, + installRemoteServerUpdater +} from '../../remote-server-updater' + +export const UPDATER_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'updater.getStatus', + params: null, + handler: (_params, { runtime }) => getRemoteServerUpdaterSnapshot(runtime.getRuntimeId()) + }), + defineMethod({ + name: 'updater.check', + params: z.object({ + includePrerelease: z.boolean().optional(), + includePerfPrerelease: z.boolean().optional() + }), + handler: (params, { runtime }) => checkRemoteServerUpdater(runtime.getRuntimeId(), params) + }), + defineMethod({ + name: 'updater.download', + params: null, + handler: (_params, { runtime }) => downloadRemoteServerUpdater(runtime.getRuntimeId()) + }), + defineMethod({ + name: 'updater.install', + params: null, + handler: (_params, { runtime }) => installRemoteServerUpdater(runtime.getRuntimeId()) + }) +] diff --git a/src/main/runtime/rpc/terminal-subscribe-lease-only.test.ts b/src/main/runtime/rpc/terminal-subscribe-lease-only.test.ts index fab3d5a09cd..3aba4b58200 100644 --- a/src/main/runtime/rpc/terminal-subscribe-lease-only.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-lease-only.test.ts @@ -12,12 +12,13 @@ const request: RpcRequest = { params: { terminal: 'terminal-1', client: { id: 'phone-1', type: 'mobile' }, + viewport: { cols: 40, rows: 20 }, capabilities: { terminalBinaryStream: 1, mobileInputLeaseOnly: 1 } } } describe('terminal lease-only subscription', () => { - it('keeps mobile input ownership without registering output delivery', async () => { + it('keeps mobile input ownership without viewport resize or output delivery', async () => { const messages: string[] = [] const cleanups = new Map void>() const runtime = { diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 0cf0aeaf10e..802956d1e99 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -3092,7 +3092,7 @@ describe('OrcaRuntimeRpcServer', () => { id: 'req_resolve_pane', authToken: metadata!.authToken, method: 'terminal.resolvePane', - params: { paneKey: `tab-right:${bottomLeaf}` } + params: { paneKey: `tab-right:${bottomLeaf}`, worktreeId } }) expect(resolvePaneResponse).toMatchObject({ id: 'req_resolve_pane', @@ -3102,10 +3102,23 @@ describe('OrcaRuntimeRpcServer', () => { handle: handleByLeaf.get(bottomLeaf), tabId: 'tab-right', leafId: bottomLeaf, - ptyId: 'pty-bottom' + ptyId: 'pty-bottom', + worktreeId } } }) + + const wrongOwnerResponse = await sendRequest(metadata!.transports[0]!.endpoint, { + id: 'req_resolve_pane_wrong_owner', + authToken: metadata!.authToken, + method: 'terminal.resolvePane', + params: { paneKey: `tab-right:${bottomLeaf}`, worktreeId: 'other-worktree' } + }) + expect(wrongOwnerResponse).toMatchObject({ + id: 'req_resolve_pane_wrong_owner', + ok: false, + error: { message: 'terminal_not_found' } + }) } finally { await server.stop() } diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 058dbeba7aa..57029a95762 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -341,6 +341,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'ssh.getState', 'ssh.listRemovedTargetLabels', 'ssh.listTargets', + 'ssh.listTargetSummaries', 'speech.dictation.cancel', 'speech.dictation.chunk', 'speech.dictation.finish', diff --git a/src/main/ssh/ssh-connection-generation.test.ts b/src/main/ssh/ssh-connection-generation.test.ts new file mode 100644 index 00000000000..a6c995bcab2 --- /dev/null +++ b/src/main/ssh/ssh-connection-generation.test.ts @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + advanceSshConnectionGeneration, + assertSshMutationExpectation, + getSshConnectionGeneration, + resetSshConnectionGenerations, + setSshConnectionGeneration +} from './ssh-connection-generation' + +const SESSION_COUNTER_STRIDE = 2 ** 13 +const MAX_SESSION_SCOPE = 2 ** 40 - 1 + +describe('SSH connection generation session scope', () => { + afterEach(() => resetSshConnectionGenerations()) + + it('does not reuse a target token when a restarted HUB reaches the same counter', () => { + resetSshConnectionGenerations(41) + const beforeRestart = advanceSshConnectionGeneration('ssh-a') + + resetSshConnectionGenerations(42) + const afterRestart = advanceSshConnectionGeneration('ssh-a') + + expect(afterRestart).not.toBe(beforeRestart) + expect(() => assertSshMutationExpectation('ssh-a', 'ssh-a', beforeRestart)).toThrow( + 'SSH connection changed; refresh and try again' + ) + expect(() => assertSshMutationExpectation('ssh-a', 'ssh-a', afterRestart)).not.toThrow() + }) + + it('keeps target counters independent within one HUB session', () => { + resetSshConnectionGenerations(7) + + expect(advanceSshConnectionGeneration('ssh-a')).toBe(advanceSshConnectionGeneration('ssh-b')) + expect(getSshConnectionGeneration('ssh-a')).toBe(getSshConnectionGeneration('ssh-b')) + }) + + it('rejects an SSH execution-host expectation when direct IPC resolves locally', () => { + expect(() => + assertSshMutationExpectation(undefined, undefined, undefined, 'ssh:ssh-a') + ).toThrow('Workspace host changed; refresh and try again') + }) + + it('rejects a local execution-host expectation when direct IPC resolves through SSH', () => { + expect(() => assertSshMutationExpectation('ssh-a', 'ssh-a', 0, 'local')).toThrow( + 'Workspace host changed; refresh and try again' + ) + }) + + it('rolls the session scope after counter exhaustion and keeps rotating', () => { + resetSshConnectionGenerations(7) + const exhaustedGeneration = 8 * SESSION_COUNTER_STRIDE - 1 + setSshConnectionGeneration('ssh-a', exhaustedGeneration) + + const rolledGeneration = advanceSshConnectionGeneration('ssh-a') + + expect(rolledGeneration).toBe(8 * SESSION_COUNTER_STRIDE + 1) + expect(advanceSshConnectionGeneration('ssh-a')).toBe(rolledGeneration + 1) + expect(() => assertSshMutationExpectation('ssh-a', 'ssh-a', exhaustedGeneration)).toThrow( + 'SSH connection changed; refresh and try again' + ) + }) + + it('invalidates other targets when exhaustion rolls the session scope', () => { + resetSshConnectionGenerations(11) + const otherTargetGeneration = advanceSshConnectionGeneration('ssh-b') + setSshConnectionGeneration('ssh-a', 12 * SESSION_COUNTER_STRIDE - 1) + + const rolledGeneration = advanceSshConnectionGeneration('ssh-a') + + expect(getSshConnectionGeneration('ssh-b')).toBe(12 * SESSION_COUNTER_STRIDE) + expect(rolledGeneration).toBe(12 * SESSION_COUNTER_STRIDE + 1) + expect(() => assertSshMutationExpectation('ssh-b', 'ssh-b', otherTargetGeneration)).toThrow( + 'SSH connection changed; refresh and try again' + ) + expect(() => assertSshMutationExpectation('ssh-a', 'ssh-a', rolledGeneration)).not.toThrow() + }) + + it('wraps the maximum safe numeric scope without reusing it', () => { + resetSshConnectionGenerations(MAX_SESSION_SCOPE) + setSshConnectionGeneration('ssh-a', Number.MAX_SAFE_INTEGER) + + const rolledGeneration = advanceSshConnectionGeneration('ssh-a') + + expect(rolledGeneration).toBe(1) + expect(Number.isSafeInteger(rolledGeneration)).toBe(true) + expect(() => assertSshMutationExpectation('ssh-a', 'ssh-a', Number.MAX_SAFE_INTEGER)).toThrow( + 'SSH connection changed; refresh and try again' + ) + }) +}) diff --git a/src/main/ssh/ssh-connection-generation.ts b/src/main/ssh/ssh-connection-generation.ts new file mode 100644 index 00000000000..96f1ae04c83 --- /dev/null +++ b/src/main/ssh/ssh-connection-generation.ts @@ -0,0 +1,101 @@ +import { randomBytes } from 'node:crypto' +import { toSshExecutionHostId } from '../../shared/execution-host' + +const SESSION_COUNTER_BITS = 13 +const SESSION_COUNTER_STRIDE = 2 ** SESSION_COUNTER_BITS +const MAX_SESSION_SCOPE = 2 ** (53 - SESSION_COUNTER_BITS) - 1 + +function createSessionScope(): number { + return randomBytes(5).readUIntBE(0, 5) +} + +let sessionGenerationBase = 0 +let sessionInitialized = false +const connectionGenerationByTarget = new Map() +const usedSessionScopes = new Set() + +function assertGenerationInCurrentSession(generation: number): void { + if ( + !Number.isSafeInteger(generation) || + generation < sessionGenerationBase || + generation - sessionGenerationBase >= SESSION_COUNTER_STRIDE + ) { + throw new Error('SSH connection generation exhausted for this runtime session') + } +} + +export function getSshConnectionGeneration(targetId: string): number { + return connectionGenerationByTarget.get(targetId) ?? sessionGenerationBase +} + +export function initializeSshConnectionGenerationSession(): void { + if (sessionInitialized) { + return + } + const sessionScope = createSessionScope() + // Why: randomize the process scope so a replacement HUB does not predictably reuse the prior target/counter token. + sessionGenerationBase = sessionScope * SESSION_COUNTER_STRIDE + usedSessionScopes.add(sessionScope) + sessionInitialized = true +} + +export function advanceSshConnectionGeneration(targetId: string): number { + let next = getSshConnectionGeneration(targetId) + 1 + if (next - sessionGenerationBase >= SESSION_COUNTER_STRIDE) { + let nextSessionScope = + (sessionGenerationBase / SESSION_COUNTER_STRIDE + 1) % (MAX_SESSION_SCOPE + 1) + while (usedSessionScopes.has(nextSessionScope)) { + nextSessionScope = (nextSessionScope + 1) % (MAX_SESSION_SCOPE + 1) + } + usedSessionScopes.add(nextSessionScope) + sessionGenerationBase = nextSessionScope * SESSION_COUNTER_STRIDE + // Why: changing the scope must revoke tokens for every target, not only the target that exhausted its counter. + connectionGenerationByTarget.clear() + next = sessionGenerationBase + 1 + } + assertGenerationInCurrentSession(next) + connectionGenerationByTarget.set(targetId, next) + return next +} + +export function setSshConnectionGeneration(targetId: string, generation: number): void { + assertGenerationInCurrentSession(generation) + connectionGenerationByTarget.set(targetId, generation) +} + +export function resetSshConnectionGenerations(sessionScope = 0): void { + if (!Number.isSafeInteger(sessionScope) || sessionScope < 0 || sessionScope > MAX_SESSION_SCOPE) { + throw new Error('Invalid SSH connection generation session scope') + } + sessionGenerationBase = sessionScope * SESSION_COUNTER_STRIDE + sessionInitialized = true + connectionGenerationByTarget.clear() + usedSessionScopes.clear() + usedSessionScopes.add(sessionScope) +} + +export function assertSshMutationExpectation( + connectionId: string | undefined, + expectedTargetId: string | undefined, + expectedGeneration: number | undefined, + expectedExecutionHostId?: string +): void { + const actualExecutionHostId = connectionId ? toSshExecutionHostId(connectionId) : 'local' + if (expectedExecutionHostId !== undefined && expectedExecutionHostId !== actualExecutionHostId) { + throw new Error('Workspace host changed; refresh and try again') + } + const hasExpectation = expectedTargetId !== undefined || expectedGeneration !== undefined + if (!connectionId) { + if (hasExpectation) { + throw new Error('SSH connection changed; refresh and try again') + } + return + } + if ( + expectedTargetId !== connectionId || + expectedGeneration === undefined || + expectedGeneration !== getSshConnectionGeneration(connectionId) + ) { + throw new Error('SSH connection changed; refresh and try again') + } +} diff --git a/src/main/ssh/vscode-ssh-authority.test.ts b/src/main/ssh/vscode-ssh-authority.test.ts index 862842b6ede..2816c1bc4bf 100644 --- a/src/main/ssh/vscode-ssh-authority.test.ts +++ b/src/main/ssh/vscode-ssh-authority.test.ts @@ -34,6 +34,16 @@ describe('resolveVsCodeSshAuthority', () => { ).toEqual({ ok: true, authority: 'ada@builder.example.com' }) }) + it.each(['', ' '])( + 'uses a host-only authority for a manual port-22 target without a username', + (username) => { + expect(resolveVsCodeSshAuthority(createTarget({ username }))).toEqual({ + ok: true, + authority: 'builder.example.com' + }) + } + ) + it('requires an alias for manual targets on non-default ports', () => { expect( resolveVsCodeSshAuthority( @@ -49,11 +59,12 @@ describe('resolveVsCodeSshAuthority', () => { it.each([ createTarget({ host: ' ' }), - createTarget({ username: '' }), createTarget({ host: 'builder\nmalicious' }), + createTarget({ username: '\n' }), + createTarget({ username: 'ada\nmalicious' }), createTarget({ source: 'ssh-config', configHost: '\u0000builder' }), createTarget({ port: 0 }) - ])('rejects incomplete or unsafe target fields', (target) => { + ])('rejects invalid or unsafe target fields', (target) => { expect(resolveVsCodeSshAuthority(target)).toEqual({ ok: false, reason: 'ssh-target-invalid' diff --git a/src/main/ssh/vscode-ssh-authority.ts b/src/main/ssh/vscode-ssh-authority.ts index d29bac64768..055baac65d4 100644 --- a/src/main/ssh/vscode-ssh-authority.ts +++ b/src/main/ssh/vscode-ssh-authority.ts @@ -6,9 +6,9 @@ export type VsCodeSshAuthorityResult = | { ok: false; reason: 'ssh-target-invalid' } | { ok: false; reason: 'ssh-alias-required'; host: string; port: number } -function isValidAuthorityPart(value: string): boolean { +function isValidAuthorityPart(value: string, allowEmpty = false): boolean { return ( - value.length > 0 && + (allowEmpty || value.trim().length > 0) && !Array.from(value).some((character) => { const codePoint = character.codePointAt(0) ?? 0 return codePoint <= 0x1f || codePoint === 0x7f @@ -18,17 +18,17 @@ function isValidAuthorityPart(value: string): boolean { export function resolveVsCodeSshAuthority(target: SshTarget): VsCodeSshAuthorityResult { if (isOpenSshConfigBackedTarget(target)) { - const configHost = target.configHost?.trim() ?? '' + const configHost = target.configHost ?? '' return isValidAuthorityPart(configHost) - ? { ok: true, authority: configHost } + ? { ok: true, authority: configHost.trim() } : { ok: false, reason: 'ssh-target-invalid' } } const host = target.host.trim() const username = target.username.trim() if ( - !isValidAuthorityPart(host) || - !isValidAuthorityPart(username) || + !isValidAuthorityPart(target.host) || + !isValidAuthorityPart(target.username, true) || !Number.isInteger(target.port) || target.port < 1 || target.port > 65_535 @@ -38,5 +38,5 @@ export function resolveVsCodeSshAuthority(target: SshTarget): VsCodeSshAuthority if (target.port !== 22) { return { ok: false, reason: 'ssh-alias-required', host, port: target.port } } - return { ok: true, authority: `${username}@${host}` } + return { ok: true, authority: username ? `${username}@${host}` : host } } diff --git a/src/main/updater-fallback.ts b/src/main/updater-fallback.ts index 9428550aa08..411ddbd0aba 100644 --- a/src/main/updater-fallback.ts +++ b/src/main/updater-fallback.ts @@ -1,4 +1,13 @@ import type { UpdateStatus } from '../shared/types' +import { + compareAppVersions, + isPrereleaseAppVersion, + isValidAppVersion +} from '../shared/app-version' + +export const compareVersions = compareAppVersions +export const isPrereleaseVersion = isPrereleaseAppVersion +export const isValidVersion = isValidAppVersion export function statusesEqual(left: UpdateStatus, right: UpdateStatus): boolean { switch (left.state) { @@ -88,102 +97,3 @@ export function isBenignCheckFailure(message: string): boolean { normalizedMessage.includes('no published versions on github') ) } - -type ParsedVersion = { - core: [number, number, number] - prerelease: string[] -} - -function parseVersion(value: string): ParsedVersion | null { - const normalized = value.trim().replace(/^v/i, '') - const match = normalized.match( - /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-.]+))?(?:\+([0-9A-Za-z-.]+))?$/ - ) - if (!match) { - return null - } - - return { - core: [Number(match[1]), Number(match[2]), Number(match[3])], - prerelease: match[4]?.split('.') ?? [] - } -} - -export function isValidVersion(value: string): boolean { - return parseVersion(value) !== null -} - -// Why: a user running a prerelease build (e.g. 1.3.17-rc.1) needs both: -// (1) the next RC (1.3.17-rc.2), which the default generic feed hides, and -// (2) the next stable release, which electron-updater's GitHubProvider -// channel filter hides when the running build is an RC. -// We detect prerelease builds here so the updater can mine GitHub's atom feed -// itself (any channel) and pin the generic provider at the newest tag. Without -// this detection, a prerelease user would be trapped on the RC they installed. -export function isPrereleaseVersion(value: string): boolean { - const parsed = parseVersion(value) - return parsed !== null && parsed.prerelease.length > 0 -} - -function compareIdentifiers(left: string, right: string): number { - const leftNumeric = /^\d+$/.test(left) - const rightNumeric = /^\d+$/.test(right) - - if (leftNumeric && rightNumeric) { - return Number(left) - Number(right) - } - if (leftNumeric) { - return -1 - } - if (rightNumeric) { - return 1 - } - return left.localeCompare(right) -} - -/** Returns negative if left < right, 0 if equal, positive if left > right. */ -export function compareVersions(left: string, right: string): number { - const leftVersion = parseVersion(left) - const rightVersion = parseVersion(right) - if (!leftVersion || !rightVersion) { - return 0 - } - - for (let index = 0; index < leftVersion.core.length; index += 1) { - const leftPart = leftVersion.core[index] - const rightPart = rightVersion.core[index] - if (leftPart !== rightPart) { - return leftPart - rightPart - } - } - - const leftPrerelease = leftVersion.prerelease - const rightPrerelease = rightVersion.prerelease - if (leftPrerelease.length === 0 && rightPrerelease.length === 0) { - return 0 - } - if (leftPrerelease.length === 0) { - return 1 - } - if (rightPrerelease.length === 0) { - return -1 - } - - for (let index = 0; index < Math.max(leftPrerelease.length, rightPrerelease.length); index += 1) { - const leftPart = leftPrerelease[index] - const rightPart = rightPrerelease[index] - if (leftPart === undefined) { - return -1 - } - if (rightPart === undefined) { - return 1 - } - - const comparison = compareIdentifiers(leftPart, rightPart) - if (comparison !== 0) { - return comparison - } - } - - return 0 -} diff --git a/src/main/updater.headless-serve-install.test.ts b/src/main/updater.headless-serve-install.test.ts index dcd38c2ed47..d3cd94f63f8 100644 --- a/src/main/updater.headless-serve-install.test.ts +++ b/src/main/updater.headless-serve-install.test.ts @@ -476,7 +476,12 @@ describe('headless serve update install handoff', () => { return Promise.resolve(null) }) - const { checkForUpdatesFromMenu, downloadUpdate, setupAutoUpdater } = await import('./updater') + const { + checkForUpdatesFromMenu, + downloadUpdate, + getRemoteServerUpdateSupport, + setupAutoUpdater + } = await import('./updater') setupAutoUpdater({ webContents: { send } } as never, { getLastUpdateCheckAt: () => Date.now(), installMode: 'interactive' @@ -488,10 +493,31 @@ describe('headless serve update install handoff', () => { expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(true) expect(autoUpdaterMock.autoRunAppAfterInstall).toBe(true) expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledTimes(1) + expect(getRemoteServerUpdateSupport()).toEqual({ + installMode: 'interactive', + automatic: true, + reason: 'available' + }) expect(recordUpdaterLifecycleMock).not.toHaveBeenCalledWith( 'headless_serve_install_deferred', expect.anything(), expect.anything() ) }) + + it('advertises remote update control only for safely restartable installs', async () => { + const { checkForRemoteServerUpdate, getRemoteServerUpdateSupport, setupAutoUpdater } = + await import('./updater') + setupAutoUpdater({ webContents: { send: vi.fn() } } as never, { + getLastUpdateCheckAt: () => Date.now(), + installMode: 'unsupported-headless-serve' + }) + + expect(getRemoteServerUpdateSupport()).toEqual({ + installMode: 'unsupported-headless-serve', + automatic: false, + reason: 'manual-service-update-required' + }) + expect(() => checkForRemoteServerUpdate('runtime-1')).toThrow('remote_update_manual_required') + }) }) diff --git a/src/main/updater.ts b/src/main/updater.ts index a61c7ad42ff..a1373af8a62 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -2,6 +2,11 @@ import { app, BrowserWindow, powerMonitor } from 'electron' import { is } from '@electron-toolkit/utils' import type { UpdateCheckOptions, UpdateStatus } from '../shared/types' +import type { + RemoteServerUpdateInstallResult, + RemoteServerUpdaterSnapshot, + RemoteServerUpdateSupport +} from '../shared/remote-server-update' import { isWindowsSignatureCheckUnavailableFailure } from '../shared/updater-windows-signature-check' import { killAllPty } from './ipc/pty' import { withUpdaterSpan } from './observability/instrumentation' @@ -839,6 +844,80 @@ export function getUpdateStatus(): UpdateStatus { return currentStatus } +export function getRemoteServerUpdateSupport(): RemoteServerUpdateSupport { + if (!app.isPackaged || is.dev) { + return { + installMode: updateInstallMode, + automatic: false, + reason: 'unpackaged-build' + } + } + if (!autoUpdaterInitialized) { + return { + installMode: updateInstallMode, + automatic: false, + reason: 'updater-unavailable' + } + } + if (updateInstallMode === 'unsupported-headless-serve') { + return { + installMode: updateInstallMode, + automatic: false, + reason: 'manual-service-update-required' + } + } + return { installMode: updateInstallMode, automatic: true, reason: 'available' } +} + +export function getRemoteServerUpdaterSnapshot(runtimeId: string): RemoteServerUpdaterSnapshot { + return { + appVersion: app.getVersion(), + runtimeId, + support: getRemoteServerUpdateSupport(), + status: getUpdateStatus() + } +} + +function assertRemoteServerUpdateAvailable(): void { + if (!getRemoteServerUpdateSupport().automatic) { + throw new Error('remote_update_manual_required') + } +} + +export function checkForRemoteServerUpdate( + runtimeId: string, + options?: UpdateCheckOptions +): RemoteServerUpdaterSnapshot { + assertRemoteServerUpdateAvailable() + checkForUpdatesFromMenu(options) + return getRemoteServerUpdaterSnapshot(runtimeId) +} + +export function downloadRemoteServerUpdate(runtimeId: string): RemoteServerUpdaterSnapshot { + assertRemoteServerUpdateAvailable() + if (currentStatus.state !== 'available') { + throw new Error('remote_update_not_available') + } + downloadUpdate() + return getRemoteServerUpdaterSnapshot(runtimeId) +} + +export function installRemoteServerUpdate(runtimeId: string): RemoteServerUpdateInstallResult { + assertRemoteServerUpdateAvailable() + if (currentStatus.state !== 'downloaded') { + throw new Error('remote_update_not_downloaded') + } + const targetVersion = currentStatus.version + const result: RemoteServerUpdateInstallResult = { + accepted: true, + fromVersion: app.getVersion(), + targetVersion, + runtimeId + } + quitAndInstall() + return result +} + let consecutiveAutomaticRetrySchedules = 0 function scheduleAutomaticUpdateCheck(delayMs: number): void { diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index c8c693392c8..e672cf3c198 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -32,6 +32,7 @@ import type { } from '../shared/terminal-render-desync-evidence' import type { MobileRelayStatus } from '../shared/mobile-relay-status' import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode' +import type { SshMutationExpectation } from '../shared/ssh-types' import type { CreateLocalOrcaProfileArgs, CreateLocalOrcaProfileResult, @@ -2503,20 +2504,32 @@ export type PreloadApi = { rootPath: string connectionId?: string }) => Promise - writeFile: (args: { filePath: string; content: string; connectionId?: string }) => Promise - createFile: (args: { filePath: string; connectionId?: string }) => Promise - createDir: (args: { dirPath: string; connectionId?: string }) => Promise - rename: (args: { oldPath: string; newPath: string; connectionId?: string }) => Promise - copy: (args: { - sourcePath: string - destinationPath: string - connectionId?: string - }) => Promise - deletePath: (args: { - targetPath: string - connectionId?: string - recursive?: boolean - }) => Promise + writeFile: ( + args: { filePath: string; content: string; connectionId?: string } & SshMutationExpectation + ) => Promise + createFile: ( + args: { filePath: string; connectionId?: string } & SshMutationExpectation + ) => Promise + createDir: ( + args: { dirPath: string; connectionId?: string } & SshMutationExpectation + ) => Promise + rename: ( + args: { oldPath: string; newPath: string; connectionId?: string } & SshMutationExpectation + ) => Promise + copy: ( + args: { + sourcePath: string + destinationPath: string + connectionId?: string + } & SshMutationExpectation + ) => Promise + deletePath: ( + args: { + targetPath: string + connectionId?: string + recursive?: boolean + } & SshMutationExpectation + ) => Promise authorizeExternalPath: (args: { targetPath: string }) => Promise stat: (args: { filePath: string @@ -2531,12 +2544,14 @@ export type PreloadApi = { }) => Promise cancelListFiles: (args: { requestToken: string }) => Promise search: (args: SearchOptions & { connectionId?: string }) => Promise - importExternalPaths: (args: { - sourcePaths: string[] - destDir: string - connectionId?: string - ensureDir?: boolean - }) => Promise<{ + importExternalPaths: ( + args: { + sourcePaths: string[] + destDir: string + connectionId?: string + ensureDir?: boolean + } & SshMutationExpectation + ) => Promise<{ results: ( | { sourcePath: string @@ -2581,11 +2596,13 @@ export type PreloadApi = { } )[] }> - resolveDroppedPathsForAgent: (args: { - paths: string[] - worktreePath: string - connectionId?: string - }) => Promise<{ + resolveDroppedPathsForAgent: ( + args: { + paths: string[] + worktreePath: string + connectionId?: string + } & SshMutationExpectation + ) => Promise<{ resolvedPaths: string[] skipped: { sourcePath: string @@ -3077,6 +3094,7 @@ export type PreloadApi = { method: string params?: unknown timeoutMs?: number + expectedEnvironmentPairingRevision?: number }) => Promise> subscribe: ( args: { @@ -3084,6 +3102,7 @@ export type PreloadApi = { method: string params?: unknown timeoutMs?: number + expectedEnvironmentPairingRevision?: number }, callbacks: { onResponse: (response: RuntimeRpcResponse) => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 8c64a4c5dd9..7ad4067708f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -20,6 +20,7 @@ import type { } from '../shared/agent-session-resume' import type { MobileRelayStatus } from '../shared/mobile-relay-status' import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode' +import type { SshMutationExpectation } from '../shared/ssh-types' import type { BaseRefSearchResult, BaseRefDefaultResult, @@ -2862,27 +2863,36 @@ const api = { connectionId?: string }): Promise<{ filePath: string; relativePath: string; basename: string; name: string }[]> => ipcRenderer.invoke('fs:listMarkdownDocuments', args), - writeFile: (args: { - filePath: string - content: string - connectionId?: string - }): Promise => ipcRenderer.invoke('fs:writeFile', args), - createFile: (args: { filePath: string; connectionId?: string }): Promise => - ipcRenderer.invoke('fs:createFile', args), - createDir: (args: { dirPath: string; connectionId?: string }): Promise => - ipcRenderer.invoke('fs:createDir', args), - rename: (args: { oldPath: string; newPath: string; connectionId?: string }): Promise => - ipcRenderer.invoke('fs:rename', args), - copy: (args: { - sourcePath: string - destinationPath: string - connectionId?: string - }): Promise => ipcRenderer.invoke('fs:copy', args), - deletePath: (args: { - targetPath: string - connectionId?: string - recursive?: boolean - }): Promise => ipcRenderer.invoke('fs:deletePath', args), + writeFile: ( + args: { + filePath: string + content: string + connectionId?: string + } & SshMutationExpectation + ): Promise => ipcRenderer.invoke('fs:writeFile', args), + createFile: ( + args: { filePath: string; connectionId?: string } & SshMutationExpectation + ): Promise => ipcRenderer.invoke('fs:createFile', args), + createDir: ( + args: { dirPath: string; connectionId?: string } & SshMutationExpectation + ): Promise => ipcRenderer.invoke('fs:createDir', args), + rename: ( + args: { oldPath: string; newPath: string; connectionId?: string } & SshMutationExpectation + ): Promise => ipcRenderer.invoke('fs:rename', args), + copy: ( + args: { + sourcePath: string + destinationPath: string + connectionId?: string + } & SshMutationExpectation + ): Promise => ipcRenderer.invoke('fs:copy', args), + deletePath: ( + args: { + targetPath: string + connectionId?: string + recursive?: boolean + } & SshMutationExpectation + ): Promise => ipcRenderer.invoke('fs:deletePath', args), authorizeExternalPath: (args: { targetPath: string }): Promise => ipcRenderer.invoke('fs:authorizeExternalPath', args), stat: (args: { @@ -2911,12 +2921,14 @@ const api = { maxResults?: number connectionId?: string }): Promise => ipcRenderer.invoke('fs:search', args), - importExternalPaths: (args: { - sourcePaths: string[] - destDir: string - connectionId?: string - ensureDir?: boolean - }): Promise<{ + importExternalPaths: ( + args: { + sourcePaths: string[] + destDir: string + connectionId?: string + ensureDir?: boolean + } & SshMutationExpectation + ): Promise<{ results: ( | { sourcePath: string @@ -2963,11 +2975,13 @@ const api = { } )[] }> => ipcRenderer.invoke('fs:stageExternalPathsForRuntimeUpload', args), - resolveDroppedPathsForAgent: (args: { - paths: string[] - worktreePath: string - connectionId?: string - }): Promise<{ + resolveDroppedPathsForAgent: ( + args: { + paths: string[] + worktreePath: string + connectionId?: string + } & SshMutationExpectation + ): Promise<{ resolvedPaths: string[] skipped: { sourcePath: string @@ -4068,6 +4082,7 @@ const api = { method: string params?: unknown timeoutMs?: number + expectedEnvironmentPairingRevision?: number }): Promise> => ipcRenderer.invoke('runtimeEnvironments:call', args), subscribe: async ( @@ -4076,6 +4091,7 @@ const api = { method: string params?: unknown timeoutMs?: number + expectedEnvironmentPairingRevision?: number }, callbacks: { onResponse: (response: RuntimeRpcResponse) => void diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index b228a47158f..d958e1fbf12 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -345,6 +345,9 @@ const SshPassphraseDialog = lazy(() => const UpdateCard = lazy(() => import('./components/UpdateCard').then((module) => ({ default: module.UpdateCard })) ) +const RemoteServerUpdateDialog = lazy( + () => import('./components/settings/RemoteServerUpdateDialog') +) const ContextualTourOverlay = lazy(() => import('./components/contextual-tours/ContextualTourOverlay').then((module) => ({ default: module.ContextualTourOverlay @@ -456,6 +459,7 @@ function App(): React.JSX.Element { setRightSidebarTab: s.setRightSidebarTab, showRightSidebarFiles: s.showRightSidebarFiles, showRightSidebarSearch: s.showRightSidebarSearch, + openDiffNotesSendMenuForActiveWorktree: s.openDiffNotesSendMenuForActiveWorktree, setActiveView: s.setActiveView, updateSettings: s.updateSettings, pruneLastVisitedTimestamps: s.pruneLastVisitedTimestamps, @@ -855,31 +859,71 @@ function App(): React.JSX.Element { hydratePersistedUI: actions.hydratePersistedUI }) ) - const startupRuntimeHostIds = await timeRendererStartupStep( + // Why: list-runtime-session-hosts reads no repo state, so overlap it with the repo scan + // instead of paying its IPC round-trip serially before repos. .catch marks rejections handled + // if an earlier await throws first; the value is awaited below and surfaces any error there. + const runtimeHostsPromise = timeRendererStartupStep( 'list-runtime-session-hosts', listRuntimeSessionHostIdsForStartup ) + runtimeHostsPromise.catch(() => {}) // Why: saved remote runtimes can spend the full connect timeout; load only the local catalog for first paint and refresh remotes after hydration. await timeRendererStartupStep('fetch-repos-local', () => actions.fetchReposForAllHosts({ remoteHosts: 'skip' }) ) - await timeRendererStartupStep('fetch-project-groups-local', () => - actions.fetchProjectGroupsForAllHosts({ remoteHosts: 'skip' }) - ) - await timeRendererStartupStep('fetch-folder-workspaces-local', () => - actions.fetchFolderWorkspacesForAllHosts({ remoteHosts: 'skip' }) - ) - await timeRendererStartupStep('fetch-worktrees', () => - actions.fetchAllWorktrees({ hydrationPurge: 'defer' }) - ) - // Why: include saved runtime host ids so per-host worktree session slices restore from local settings without waiting on network reachability; unreadable partitions skip. - const sessionRead = await timeRendererStartupStep('session-get', () => - fetchWorkspaceSessionWithRuntimeHostOwners( - window.api.session, - useAppStore.getState().repos, - startupRuntimeHostIds + // Why: folder workspaces merge against projectGroups (repos.ts fetchFolderWorkspacesForAllHosts), + // so keep this two-step catalog chain internally ordered; it is otherwise independent of + // repos/worktrees/session and overlaps the worktree scan below. + const localCatalogChain = (async () => { + await timeRendererStartupStep('fetch-project-groups-local', () => + actions.fetchProjectGroupsForAllHosts({ remoteHosts: 'skip' }) + ) + await timeRendererStartupStep('fetch-folder-workspaces-local', () => + actions.fetchFolderWorkspacesForAllHosts({ remoteHosts: 'skip' }) + ) + })() + // Why: chain session-get off runtimeHostsPromise instead of awaiting the host ids here, so + // fetch-worktrees and the catalog chain start immediately (neither needs the ids) — awaiting + // the host-list IPC first would re-serialize the worktree scan behind host discovery when the + // IPC is the slower of the two. Only session-get waits on the ids. + const sessionReadPromise = runtimeHostsPromise.then((startupRuntimeHostIds) => + // Why: include saved runtime host ids so per-host worktree session slices restore from local settings without waiting on network reachability; unreadable partitions skip. + timeRendererStartupStep('session-get', () => + fetchWorkspaceSessionWithRuntimeHostOwners( + window.api.session, + useAppStore.getState().repos, + startupRuntimeHostIds + ) ) ) + // Why: once repos is loaded, fetch-worktrees (snapshots repos), session-get (repos-independent + // local disk read), and the local catalog chain are mutually independent — run them concurrently + // so the two disk reads hide behind the O(repos) worktree git scan (the startup long pole). + // fetchAllWorktrees({hydrationPurge:'defer'}) returns before its folderWorkspaces read (the purge + // guard in worktrees.ts), so it needs no catalog ordering here. session-get is a pure read; + // hydrate-session-stores below still runs only after all three settle. See #18. + // Why (#18 review): join on allSettled, NOT fail-fast Promise.all. A fast rejection from one branch + // would drop into the catch/recovery path (which reconnects terminals and flips readiness) while a + // sibling hydration task is still in flight and mutating catalog/worktree state — the old serial flow + // guaranteed no hydration step ran during recovery. Wait for all three to settle, then surface the + // first rejection so recovery still triggers, but only once nothing is left writing to the store. + const [worktreesOutcome, sessionOutcome, catalogOutcome] = await Promise.allSettled([ + timeRendererStartupStep('fetch-worktrees', () => + actions.fetchAllWorktrees({ hydrationPurge: 'defer' }) + ), + sessionReadPromise, + localCatalogChain + ]) + if (worktreesOutcome.status === 'rejected') { + throw worktreesOutcome.reason + } + if (sessionOutcome.status === 'rejected') { + throw sessionOutcome.reason + } + if (catalogOutcome.status === 'rejected') { + throw catalogOutcome.reason + } + const sessionRead = sessionOutcome.value await keybindingsPromise if (!cancelled) { const sessionHydrationOptions = { @@ -1675,6 +1719,15 @@ function App(): React.JSX.Element { return } + // Unbound by default; opens the active worktree's Source Control notes send picker. Only consumes the chord when there are unsent notes. + if (matchShortcut('sourceControl.sendReviewNotes')) { + if (actions.openDiffNotesSendMenuForActiveWorktree()) { + input.preventDefault() + notifyTerminalCapture('sourceControl.sendReviewNotes') + return + } + } + if (matchShortcut('sidebar.checks.toggle')) { input.preventDefault() notifyTerminalCapture('sidebar.checks.toggle') @@ -2535,6 +2588,15 @@ function App(): React.JSX.Element { > + + + + + diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index 62f9040d31c..ad0097bff03 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -6,7 +6,10 @@ describe('renderer startup runtime routing', () => { it('hydrates persisted UI before local catalog and worktree hydration', () => { const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') const startupBlockStart = source.indexOf('void (async () => {') - const startupBlockEnd = source.indexOf("timeRendererStartupStep('session-get'") + // Why (#18): worktrees/session-get/catalog now run concurrently, so session-get is no longer the + // block terminator. End the slice at hydrate-session-stores (runs after the Promise.all settles) so + // every concurrent fetch falls inside the analyzed block. + const startupBlockEnd = source.indexOf("timeRendererStartupSyncStep('hydrate-session-stores'") const startupBlock = source.slice(startupBlockStart, startupBlockEnd) const settingsIndex = startupBlock.indexOf('actions.fetchSettings()') @@ -23,6 +26,7 @@ describe('renderer startup runtime routing', () => { const localFoldersIndex = startupBlock.indexOf( "actions.fetchFolderWorkspacesForAllHosts({ remoteHosts: 'skip' })" ) + const sessionGetIndex = startupBlock.indexOf("timeRendererStartupStep('session-get'") const localWorktreesIndex = startupBlock.indexOf( "actions.fetchAllWorktrees({ hydrationPurge: 'defer' })" ) @@ -30,13 +34,33 @@ describe('renderer startup runtime routing', () => { expect(settingsIndex).toBeGreaterThanOrEqual(0) expect(startupBlockEnd).toBeGreaterThan(startupBlockStart) + // Persisted UI hydrates before any local catalog/session/worktree read kicks off. expect(settingsIndex).toBeLessThan(uiGetIndex) expect(uiGetIndex).toBeLessThan(hydrateUiIndex) expect(hydrateUiIndex).toBeLessThan(localReposIndex) + // The local catalog chain stays internally ordered (folders merge against project groups). expect(localReposIndex).toBeLessThan(localGroupsIndex) expect(localGroupsIndex).toBeLessThan(localFoldersIndex) - expect(localFoldersIndex).toBeLessThan(localWorktreesIndex) + // Worktree scan and session read both start only after repos is loaded (they snapshot/route on it), + // but run concurrently with the catalog chain — see the Promise.all assertion below. + expect(localReposIndex).toBeLessThan(sessionGetIndex) + expect(localReposIndex).toBeLessThan(localWorktreesIndex) + // Lineage is deferred to post-hydration remote refresh, not part of the local hydration block. expect(lineageIndex).toBe(-1) + + // Guard the concurrency itself: fetch-worktrees, the session read, and the local catalog chain must be + // joined in a single allSettled so the two disk reads hide behind the O(repos) worktree scan — AND so a + // fast rejection can't enter recovery while a sibling is still mutating the store (allSettled, not + // fail-fast Promise.all). + const joinStart = startupBlock.indexOf('await Promise.allSettled([') + expect(joinStart).toBeGreaterThan(hydrateUiIndex) + const joinBlock = startupBlock.slice(joinStart) + expect(joinBlock).toContain("timeRendererStartupStep('fetch-worktrees'") + expect(joinBlock).toContain('sessionReadPromise') + expect(joinBlock).toContain('localCatalogChain') + // The join must not be fail-fast: a bare `Promise.all([` on these branches would re-introduce the + // recovery-during-hydration race. + expect(startupBlock).not.toContain('await Promise.all([') }) it('refreshes remote catalogs after startup hydration succeeds', () => { diff --git a/src/renderer/src/components/DetachedHeadBadge.tsx b/src/renderer/src/components/DetachedHeadBadge.tsx index bc03ce2ab52..58a12e6395e 100644 --- a/src/renderer/src/components/DetachedHeadBadge.tsx +++ b/src/renderer/src/components/DetachedHeadBadge.tsx @@ -12,15 +12,13 @@ type DetachedHeadBadgeProps = { label?: 'sidebar' | 'source-control' side?: React.ComponentProps['side'] className?: string - tabIndex?: number } export function DetachedHeadBadge({ display, label = 'source-control', side = 'right', - className, - tabIndex + className }: DetachedHeadBadgeProps): React.JSX.Element { const visibleLabel = label === 'sidebar' ? display.sidebarLabel : display.sourceControlLabel @@ -29,8 +27,6 @@ export function DetachedHeadBadge({ - {visibleLabel} + {visibleLabel} diff --git a/src/renderer/src/components/editor/CombinedDiffViewer.tsx b/src/renderer/src/components/editor/CombinedDiffViewer.tsx index f75e7025997..c8999e17e11 100644 --- a/src/renderer/src/components/editor/CombinedDiffViewer.tsx +++ b/src/renderer/src/components/editor/CombinedDiffViewer.tsx @@ -14,12 +14,12 @@ import { createProgrammaticScrollMarks } from '@/hooks/programmatic-scroll-marks import { joinPath } from '@/lib/path' import { detectLanguage } from '@/lib/language-detect' import { setWithLRU } from '@/lib/scroll-cache' -import { getConnectionIdForFile } from '@/lib/connection-context' import { getCombinedDiffSectionConnectionId } from './combined-diff-section-connection' import { findWorktreeById } from '@/store/slices/worktree-helpers' import { selectWorktreeDiffCommentsOrEmpty } from '@/store/worktree-diff-comments-selector' import { writeRuntimeFile } from '@/runtime/runtime-file-client' import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { getEditorFileOperationContext } from '@/lib/editor-file-operation-owner' import { formatDiffComments } from '@/lib/diff-comments-format' import { getDiffCommentLineLabel } from '@/lib/diff-comment-compat' import { @@ -1185,18 +1185,20 @@ export default function CombinedDiffViewer({ const content = modifiedEditor?.getValue() ?? section.modifiedContent const absolutePath = joinPath(file.filePath, section.path) try { - const connectionId = getConnectionIdForFile(file.worktreeId, absolutePath) ?? undefined const state = useAppStore.getState() const worktree = file.worktreeId ? findWorktreeById(state.worktreesByRepo, file.worktreeId) : null await writeRuntimeFile( - { - settings: settingsForRuntimeOwner(state.settings, file.runtimeEnvironmentId), - worktreeId: file.worktreeId, - worktreePath: worktree?.path ?? null, - connectionId - }, + getEditorFileOperationContext( + state, + { + worktreeId: file.worktreeId, + runtimeEnvironmentId: file.runtimeEnvironmentId, + operationProvenance: file.operationProvenance + }, + worktree?.path ?? null + ), absolutePath, content ) @@ -1237,7 +1239,7 @@ export default function CombinedDiffViewer({ console.error('Save failed:', err) } }, - [file.filePath, file.runtimeEnvironmentId, file.worktreeId, sections] + [file.filePath, file.operationProvenance, file.runtimeEnvironmentId, file.worktreeId, sections] ) const handleSectionSaveRef = useRef(handleSectionSave) diff --git a/src/renderer/src/components/editor/DiffNotesSendMenu.tsx b/src/renderer/src/components/editor/DiffNotesSendMenu.tsx index 32638d4faab..8fc9daefbe8 100644 --- a/src/renderer/src/components/editor/DiffNotesSendMenu.tsx +++ b/src/renderer/src/components/editor/DiffNotesSendMenu.tsx @@ -1,10 +1,14 @@ -import React, { useMemo } from 'react' +import React, { useCallback, useMemo } from 'react' import type { DiffComment } from '../../../../shared/types' import { useAppStore } from '@/store' import { formatDiffComments } from '@/lib/diff-comments-format' import { NotesSendMenu, type NotesSendMenuScope } from './NotesSendMenu' import { translate } from '@/i18n/i18n' +// Why: a keyboard open request the menu never got to consume (e.g. the user +// navigated away before it mounted) must not reopen the menu on a later remount. +const OPEN_REQUEST_TTL_MS = 5000 + export function DiffNotesSendMenu({ worktreeId, groupId, @@ -16,7 +20,8 @@ export function DiffNotesSendMenu({ triggerCount, actionLabel, iconClassName = 'size-3.5', - align = 'end' + align = 'end', + respondToOpenRequest = false }: { worktreeId: string groupId: string @@ -29,8 +34,23 @@ export function DiffNotesSendMenu({ actionLabel?: string iconClassName?: string align?: 'start' | 'center' | 'end' + // When set, this menu opens in response to the store's keyboard-shortcut open + // request. Enable on exactly one instance per worktree to avoid double-open. + respondToOpenRequest?: boolean }): React.JSX.Element { const clearDeliveredDiffComments = useAppStore((s) => s.clearDeliveredDiffComments) + const openRequest = useAppStore((s) => s.diffNotesSendMenuOpenRequest) + const consumeOpenRequest = useAppStore((s) => s.consumeDiffNotesSendMenuOpenRequest) + const openRequestNonce = + respondToOpenRequest && + openRequest?.worktreeId === worktreeId && + Date.now() - openRequest.issuedAt < OPEN_REQUEST_TTL_MS + ? openRequest.nonce + : null + const handleOpenRequestHandled = useCallback( + () => consumeOpenRequest(worktreeId), + [consumeOpenRequest, worktreeId] + ) const unsentNotes = useMemo(() => comments.filter((comment) => !comment.sentAt), [comments]) const unsentPrompt = useMemo(() => formatDiffComments(unsentNotes), [unsentNotes]) const fileNotes = useMemo( @@ -76,6 +96,8 @@ export function DiffNotesSendMenu({ actionLabel={actionLabel} iconClassName={iconClassName} align={align} + openRequestNonce={openRequestNonce} + onOpenRequestHandled={handleOpenRequestHandled} onDelivered={(notes) => void clearDeliveredDiffComments(worktreeId, notes)} /> ) diff --git a/src/renderer/src/components/editor/NotesSendMenu.test.tsx b/src/renderer/src/components/editor/NotesSendMenu.test.tsx index bb3f13e693c..d977ec5ae9b 100644 --- a/src/renderer/src/components/editor/NotesSendMenu.test.tsx +++ b/src/renderer/src/components/editor/NotesSendMenu.test.tsx @@ -361,6 +361,36 @@ describe('NotesSendMenu', () => { ) }) + it('opens and reports handled when an open request arrives with deliverable notes', () => { + const onOpenRequestHandled = vi.fn() + renderMenu({ openRequestNonce: 1, onOpenRequestHandled }) + + expect(storeMocks.openAgentSendPopoverTargetMode).toHaveBeenCalledWith( + expect.objectContaining({ prompt: 'prompt-all', label: 'All unsent notes' }) + ) + expect(onOpenRequestHandled).toHaveBeenCalledTimes(1) + }) + + it('reports the open request handled without opening when nothing is deliverable', () => { + const onOpenRequestHandled = vi.fn() + renderMenu({ + openRequestNonce: 1, + onOpenRequestHandled, + scopes: [{ id: 'all', label: 'All unsent notes', notes: [], prompt: '' }] + }) + + expect(storeMocks.openAgentSendPopoverTargetMode).not.toHaveBeenCalled() + expect(onOpenRequestHandled).toHaveBeenCalledTimes(1) + }) + + it('ignores a null open request', () => { + const onOpenRequestHandled = vi.fn() + renderMenu({ openRequestNonce: null, onOpenRequestHandled }) + + expect(storeMocks.openAgentSendPopoverTargetMode).not.toHaveBeenCalled() + expect(onOpenRequestHandled).not.toHaveBeenCalled() + }) + it('closes when another target mode becomes active and cleans up on unmount', () => { hookRuntime.states[0] = true storeMocks.state.agentSendPopoverTargetMode = { id: 'some-other-menu' } diff --git a/src/renderer/src/components/editor/NotesSendMenu.tsx b/src/renderer/src/components/editor/NotesSendMenu.tsx index 823a001dd66..247a01c4ac4 100644 --- a/src/renderer/src/components/editor/NotesSendMenu.tsx +++ b/src/renderer/src/components/editor/NotesSendMenu.tsx @@ -40,6 +40,10 @@ export type NotesSendMenuProps = { disabledTooltip?: string iconClassName?: string align?: 'start' | 'center' | 'end' + // A new nonce value asks this menu to open (e.g. from a keyboard shortcut). + // Only a single mounted instance should be driven this way. + openRequestNonce?: number | null + onOpenRequestHandled?: () => void onDelivered: (notes: readonly TNote[]) => void } @@ -64,6 +68,8 @@ export function NotesSendMenu({ disabledTooltip = 'All notes sent', iconClassName = 'size-3.5', align = 'end', + openRequestNonce = null, + onOpenRequestHandled, onDelivered }: NotesSendMenuProps): React.JSX.Element { const openAgentSendPopoverTargetMode = useAppStore((s) => s.openAgentSendPopoverTargetMode) @@ -138,6 +144,18 @@ export function NotesSendMenu({ [closeAgentSendPopoverTargetMode, targetModeId] ) + useEffect(() => { + if (openRequestNonce == null) { + return + } + // Why: only open when notes remain; either way clear the request so a stale + // nonce cannot reopen the menu on a later remount. + if (hasDeliverableNotes && defaultScope) { + handleOpenChange(true) + } + onOpenRequestHandled?.() + }, [openRequestNonce, hasDeliverableNotes, defaultScope, handleOpenChange, onOpenRequestHandled]) + return ( diff --git a/src/renderer/src/components/editor/editor-autosave-conflict-flow.test.ts b/src/renderer/src/components/editor/editor-autosave-conflict-flow.test.ts index 753836b65f6..72f2735f6a2 100644 --- a/src/renderer/src/components/editor/editor-autosave-conflict-flow.test.ts +++ b/src/renderer/src/components/editor/editor-autosave-conflict-flow.test.ts @@ -129,7 +129,9 @@ describe('editor autosave changed-on-disk conflict flow', () => { await vi.advanceTimersByTimeAsync(1500) expect(writeFile).toHaveBeenCalledWith({ filePath: '/repo/file.ts', - content: 'user edit' + content: 'user edit', + connectionId: undefined, + expectedExecutionHostId: 'local' }) } finally { cleanup() @@ -160,7 +162,9 @@ describe('editor autosave changed-on-disk conflict flow', () => { await vi.advanceTimersByTimeAsync(1500) expect(writeFile).toHaveBeenCalledWith({ filePath: '/repo/file.ts', - content: 'restored draft' + content: 'restored draft', + connectionId: undefined, + expectedExecutionHostId: 'local' }) } finally { cleanup() @@ -179,7 +183,9 @@ describe('editor autosave changed-on-disk conflict flow', () => { expect(writeFile).toHaveBeenCalledWith({ filePath: '/repo/file.ts', - content: 'user version' + content: 'user version', + connectionId: undefined, + expectedExecutionHostId: 'local' }) const file = store.getState().openFiles[0] expect(file?.isDirty).toBe(false) diff --git a/src/renderer/src/components/editor/editor-autosave-controller-test-fixture.ts b/src/renderer/src/components/editor/editor-autosave-controller-test-fixture.ts index 6ecf884091a..f159591496c 100644 --- a/src/renderer/src/components/editor/editor-autosave-controller-test-fixture.ts +++ b/src/renderer/src/components/editor/editor-autosave-controller-test-fixture.ts @@ -43,6 +43,16 @@ export function createEditorStore(): StoreApi { // eslint-disable-next-line @typescript-eslint/no-explicit-any return createStore()((...args: any[]) => ({ activeWorktreeId: 'wt-1', + repos: [], + worktreesByRepo: { + 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', hostId: 'local' }] + }, + detectedWorktreesByRepo: {}, + runtimeEnvironments: [], + runtimeEnvironmentCatalogHydrated: true, + removedRuntimeEnvironmentIds: new Set(), + sshConnectionStates: {}, + sshStateByEnvironment: {}, settings: { editorAutoSave: true, editorAutoSaveDelayMs: 1000 diff --git a/src/renderer/src/components/editor/editor-autosave-controller.test.ts b/src/renderer/src/components/editor/editor-autosave-controller.test.ts index 70abf77c657..734170badf1 100644 --- a/src/renderer/src/components/editor/editor-autosave-controller.test.ts +++ b/src/renderer/src/components/editor/editor-autosave-controller.test.ts @@ -52,6 +52,16 @@ function createEditorStore(): StoreApi { editorAutoSave: true, editorAutoSaveDelayMs: 1000 }, + repos: [], + worktreesByRepo: { + 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo', hostId: 'local' }] + }, + detectedWorktreesByRepo: {}, + runtimeEnvironments: [], + runtimeEnvironmentCatalogHydrated: true, + removedRuntimeEnvironmentIds: new Set(), + sshConnectionStates: new Map(), + sshStateByEnvironment: new Map(), ...createEditorSlice(...(args as Parameters)) })) as unknown as StoreApi } @@ -177,7 +187,9 @@ describe('attachEditorAutosaveController', () => { expect(writeFile).toHaveBeenCalledWith({ filePath: '/repo/file.ts', - content: 'edited' + content: 'edited', + connectionId: undefined, + expectedExecutionHostId: 'local' }) expect(store.getState().openFiles[0]?.isDirty).toBe(false) expect(store.getState().editorDrafts).toEqual({}) @@ -204,7 +216,30 @@ describe('attachEditorAutosaveController', () => { const store = createEditorStore() const workspaceKey = folderWorkspaceKey('folder-workspace-1') - mocks.getConnectionIdForFile.mockReturnValue('ssh-1') + store.setState({ + worktreesByRepo: { + 'folder-workspace-1': [ + { + id: workspaceKey, + repoId: 'folder-workspace-1', + path: '/home/neil/platform', + hostId: 'ssh:ssh-1' + } + ] as never + }, + sshConnectionStates: new Map([ + [ + 'ssh-1', + { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + connectionGeneration: 4 + } + ] + ]) + }) store.getState().openFile({ filePath: '/home/neil/platform/api/src/file.ts', relativePath: 'api/src/file.ts', @@ -219,14 +254,13 @@ describe('attachEditorAutosaveController', () => { try { await requestDirtyFileSave() - expect(mocks.getConnectionIdForFile).toHaveBeenCalledWith( - workspaceKey, - '/home/neil/platform/api/src/file.ts' - ) expect(writeFile).toHaveBeenCalledWith({ filePath: '/home/neil/platform/api/src/file.ts', content: 'edited', - connectionId: 'ssh-1' + connectionId: 'ssh-1', + expectedExecutionHostId: 'ssh:ssh-1', + expectedSshTargetId: 'ssh-1', + expectedSshConnectionGeneration: 4 }) expect(store.getState().openFiles[0]?.isDirty).toBe(false) } finally { @@ -289,7 +323,13 @@ describe('attachEditorAutosaveController', () => { expect(runtimeCall).toHaveBeenCalledWith({ selector: 'env-1', method: 'files.write', - params: { worktree: 'id:wt-1', relativePath: 'file.ts', content: 'edited' }, + expectedEnvironmentPairingRevision: undefined, + params: { + worktree: 'id:wt-1', + relativePath: 'file.ts', + content: 'edited', + expectedExecutionHostId: 'local' + }, timeoutMs: 15_000 }) expect(writeFile).not.toHaveBeenCalled() @@ -333,7 +373,9 @@ describe('attachEditorAutosaveController', () => { expect(writeFile).toHaveBeenCalledWith({ filePath: '/repo/file.md', - content: 'pending rich edit' + content: 'pending rich edit', + connectionId: undefined, + expectedExecutionHostId: 'local' }) expect(store.getState().openFiles[0]?.isDirty).toBe(false) expect(store.getState().editorDrafts).toEqual({}) @@ -568,7 +610,9 @@ describe('attachEditorAutosaveController', () => { await requestEditorFileSave({ fileId: '/repo/file.md' }) expect(writeFile).toHaveBeenCalledWith({ filePath: '/repo/file.md', - content: 'after save' + content: 'after save', + connectionId: undefined, + expectedExecutionHostId: 'local' }) expect(store.getState().openFiles[0]?.isDirty).toBe(false) } finally { diff --git a/src/renderer/src/components/editor/editor-autosave-controller.ts b/src/renderer/src/components/editor/editor-autosave-controller.ts index 22ae6e559c2..3326d93492d 100644 --- a/src/renderer/src/components/editor/editor-autosave-controller.ts +++ b/src/renderer/src/components/editor/editor-autosave-controller.ts @@ -6,7 +6,7 @@ import { getConnectionIdForFile } from '@/lib/connection-context' import { shouldPersistWorkspaceSession } from '@/lib/workspace-session' import { findWorktreeById } from '@/store/slices/worktree-helpers' import { writeRuntimeFile } from '@/runtime/runtime-file-client' -import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { getEditorFileOperationContext } from '@/lib/editor-file-operation-owner' import { canAutoSaveOpenFile, getOpenFilesForExternalFileChange, @@ -98,11 +98,11 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void { } const contentToSave = state.editorDrafts[file.id] ?? fallbackContent - const connectionId = - getConnectionIdForFile(liveFile.worktreeId, liveFile.filePath) ?? undefined const worktree = liveFile.worktreeId ? findWorktreeById(state.worktreesByRepo ?? {}, liveFile.worktreeId) : null + const fileContext = getEditorFileOperationContext(state, liveFile, worktree?.path ?? null) + const connectionId = fileContext.connectionId // Why: stamp before writing so useEditorExternalWatch ignores our own fs:changed echo (editor-self-write-registry). recordSelfWrite( liveFile.filePath, @@ -113,16 +113,7 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void { : undefined ) try { - await writeRuntimeFile( - { - settings: settingsForRuntimeOwner(state.settings, liveFile.runtimeEnvironmentId), - worktreeId: liveFile.worktreeId, - worktreePath: worktree?.path ?? null, - connectionId - }, - liveFile.filePath, - contentToSave - ) + await writeRuntimeFile(fileContext, liveFile.filePath, contentToSave) } catch (error) { // Why: the self-write stamp is only valid after a real write; clear on failure so it can't suppress a real update. clearSelfWrite(liveFile.filePath, liveFile.runtimeEnvironmentId) diff --git a/src/renderer/src/components/editor/rich-markdown-image-insert.test.ts b/src/renderer/src/components/editor/rich-markdown-image-insert.test.ts index 0379a925563..6eb68d924ad 100644 --- a/src/renderer/src/components/editor/rich-markdown-image-insert.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-image-insert.test.ts @@ -8,7 +8,7 @@ vi.mock('@/runtime/runtime-file-client', () => ({ })) vi.mock('@/lib/connection-context', () => ({ - getConnectionId: vi.fn(() => 'ssh-1') + getConnectionId: vi.fn(() => null) })) vi.mock('@/store', () => ({ diff --git a/src/renderer/src/components/editor/rich-markdown-image-insert.ts b/src/renderer/src/components/editor/rich-markdown-image-insert.ts index 216434bdeab..7051502f45c 100644 --- a/src/renderer/src/components/editor/rich-markdown-image-insert.ts +++ b/src/renderer/src/components/editor/rich-markdown-image-insert.ts @@ -4,7 +4,9 @@ import { dirname, basename } from '@/lib/path' import { getConnectionId } from '@/lib/connection-context' import { useAppStore } from '@/store' import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client' +import { getEditorFileOperationContext } from '@/lib/editor-file-operation-owner' import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { captureDirectSshMutationExpectation } from '@/lib/ssh-mutation-expectation' import { translate } from '@/i18n/i18n' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' import { extractIpcErrorMessage } from './rich-markdown-ipc-error-message' @@ -29,9 +31,30 @@ export async function insertRichMarkdownImageFromPath({ canInsert }: RichMarkdownImageInsertArgs): Promise { try { - const connectionId = getConnectionId(worktreeId) ?? undefined - const settings = settingsForRuntimeOwner(useAppStore.getState().settings, runtimeEnvironmentId) + const state = useAppStore.getState() const worktreePath = getWorktreePath(worktreeId) + const parsedWorkspace = worktreeId ? parseWorkspaceKey(worktreeId) : null + const resolvedConnectionId = getConnectionId(worktreeId) + if (parsedWorkspace?.type === 'folder' && resolvedConnectionId === undefined) { + throw new Error("Couldn't verify which host owns this file. Reopen the file and try again.") + } + const connectionId = resolvedConnectionId ?? undefined + const fileContext = + worktreeId && parsedWorkspace?.type !== 'folder' + ? getEditorFileOperationContext(state, { worktreeId, runtimeEnvironmentId }, worktreePath) + : { + settings: settingsForRuntimeOwner(state.settings, runtimeEnvironmentId), + worktreeId, + worktreePath, + connectionId, + expectedExecutionHostId: connectionId + ? (`ssh:${encodeURIComponent(connectionId)}` as const) + : ('local' as const), + ...(connectionId + ? captureDirectSshMutationExpectation(state, connectionId, runtimeEnvironmentId) + : {}) + } + const settings = fileContext.settings if (settings?.activeRuntimeEnvironmentId?.trim() && !worktreePath) { toast.error( translate( @@ -45,12 +68,7 @@ export async function insertRichMarkdownImageFromPath({ // Why: image bytes should live beside the note instead of inside markdown; // this keeps rich-mode size checks based on document text, not binary data. const { results } = await importExternalPathsToRuntime( - { - settings, - worktreeId, - worktreePath, - connectionId - }, + fileContext, [sourcePath], dirname(filePath) ) diff --git a/src/renderer/src/components/editor/useUntitledFileRename.ts b/src/renderer/src/components/editor/useUntitledFileRename.ts index 2453ab7af32..9b36ff18c5d 100644 --- a/src/renderer/src/components/editor/useUntitledFileRename.ts +++ b/src/renderer/src/components/editor/useUntitledFileRename.ts @@ -1,11 +1,10 @@ import { useCallback, useState } from 'react' -import { getConnectionId } from '@/lib/connection-context' import { dirname, joinPath } from '@/lib/path' import { useAppStore } from '@/store' import type { OpenFile } from '@/store/slices/editor' import { createRuntimePath, runtimePathExists } from '@/runtime/runtime-file-client' import { executeOpenEditorPathMove } from '@/lib/execute-open-editor-path-move' -import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { getEditorFileOperationContext } from '@/lib/editor-file-operation-owner' import { requestEditorFileSave, requestEditorSaveQuiesce } from './editor-autosave' import { getUntitledFileRoot } from './untitled-file-rename-path' @@ -46,16 +45,11 @@ export function useUntitledFileRename({ const oldPath = renameDialogFile.filePath const worktreeRoot = getUntitledFileRoot(renameDialogFile) const newPath = joinPath(worktreeRoot, newRelPath) - const connectionId = getConnectionId(renameDialogFile.worktreeId) ?? undefined - const fileContext = { - settings: settingsForRuntimeOwner( - useAppStore.getState().settings, - renameDialogFile.runtimeEnvironmentId - ), - worktreeId: renameDialogFile.worktreeId, - worktreePath: worktreeRoot, - connectionId - } + const fileContext = getEditorFileOperationContext( + useAppStore.getState(), + renameDialogFile, + worktreeRoot + ) if (newPath !== oldPath && (await runtimePathExists(fileContext, newPath))) { setRenameError('A file with that name already exists') diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts b/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts index 71528a948f4..5f84fc4b7ba 100644 --- a/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts @@ -50,6 +50,7 @@ function state(overrides: Partial = {}): AppState { projectGroups: [], repos: [{ id: 'repo', connectionId: null }], settings: { activeRuntimeEnvironmentId: null }, + sshConnectionStates: new Map(), tabsByWorktree: { 'wt-1': [terminalTab()] }, @@ -68,10 +69,20 @@ describe('resolveNativeChatAttachmentOwner', () => { it('resolves an SSH repo worktree to ssh with the worktree path', () => { expect( resolveNativeChatAttachmentOwner( - state({ repos: [{ id: 'repo', connectionId: 'conn-1' }] as never }), + state({ + repos: [{ id: 'repo', connectionId: 'conn-1' }] as never, + sshConnectionStates: new Map([['conn-1', { connectionGeneration: 4 } as never]]) + }), 'tab-1' ) - ).toEqual({ kind: 'ssh', connectionId: 'conn-1', worktreePath: '/repo/worktree' }) + ).toEqual({ + kind: 'ssh', + connectionId: 'conn-1', + worktreePath: '/repo/worktree', + expectedExecutionHostId: 'ssh:conn-1', + expectedSshTargetId: 'conn-1', + expectedSshConnectionGeneration: 4 + }) }) it('resolves a runtime-owned repo to runtime', () => { @@ -122,7 +133,14 @@ describe('resolveNativeChatAttachmentOwner', () => { }) describe('uploadNativeChatAttachmentPaths', () => { - const owner = { connectionId: 'conn-1', worktreePath: '/remote/worktree' } + const owner = { + kind: 'ssh' as const, + connectionId: 'conn-1', + worktreePath: '/remote/worktree', + expectedExecutionHostId: 'ssh:conn-1' as const, + expectedSshTargetId: 'conn-1', + expectedSshConnectionGeneration: 4 + } beforeEach(() => { vi.clearAllMocks() @@ -143,7 +161,10 @@ describe('uploadNativeChatAttachmentPaths', () => { expect(mocks.resolveDroppedPathsForAgent).toHaveBeenCalledWith({ paths: ['/local/a.txt'], worktreePath: '/remote/worktree', - connectionId: 'conn-1' + connectionId: 'conn-1', + expectedExecutionHostId: 'ssh:conn-1', + expectedSshTargetId: 'conn-1', + expectedSshConnectionGeneration: 4 }) expect(mocks.toastLoading).toHaveBeenCalledTimes(1) expect(mocks.toastDismiss).toHaveBeenCalledWith('toast-1') diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts index 2cbcf236fa8..76b3b6471c8 100644 --- a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts +++ b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts @@ -14,10 +14,20 @@ import { findTerminalTabWorktreeId, resolveNativeChatFileLinkContext } from './native-chat-file-link' +import { + captureDirectSshMutationExpectation, + type DirectSshMutationExpectation +} from '@/lib/ssh-mutation-expectation' + +export type NativeChatSshAttachmentOwner = DirectSshMutationExpectation & { + kind: 'ssh' + connectionId: string + worktreePath: string +} export type NativeChatAttachmentOwner = | { kind: 'local' } - | { kind: 'ssh'; connectionId: string; worktreePath: string } + | NativeChatSshAttachmentOwner /** Runtime-owned (`remote:`) panes keep the composer's existing * local-attachment block; runtime upload support is a separate seam. */ | { kind: 'runtime' } @@ -33,6 +43,7 @@ type NativeChatAttachmentOwnerState = Pick< | 'projectGroups' | 'repos' | 'settings' + | 'sshConnectionStates' | 'tabsByWorktree' | 'worktreesByRepo' > @@ -61,7 +72,12 @@ export function resolveNativeChatAttachmentOwner( if (!worktreePath) { return { kind: 'not-ready' } } - return { kind: 'ssh', connectionId, worktreePath } + return { + kind: 'ssh', + connectionId, + worktreePath, + ...captureDirectSshMutationExpectation(state, connectionId) + } } export function nativeChatWorktreeNotReadyNotice(): string { @@ -79,7 +95,7 @@ export function nativeChatWorktreeNotReadyNotice(): string { */ export async function uploadNativeChatAttachmentPaths( paths: string[], - owner: { connectionId: string; worktreePath: string } + owner: NativeChatSshAttachmentOwner ): Promise { const pending = toast.loading( translate( @@ -92,7 +108,10 @@ export async function uploadNativeChatAttachmentPaths( const { resolvedPaths, skipped, failed } = await window.api.fs.resolveDroppedPathsForAgent({ paths, worktreePath: owner.worktreePath, - connectionId: owner.connectionId + connectionId: owner.connectionId, + expectedExecutionHostId: owner.expectedExecutionHostId, + expectedSshTargetId: owner.expectedSshTargetId, + expectedSshConnectionGeneration: owner.expectedSshConnectionGeneration }) reportTerminalDropUploadSkipsAndFailures(skipped, failed) return resolvedPaths diff --git a/src/renderer/src/components/native-chat/native-chat-pending.test.ts b/src/renderer/src/components/native-chat/native-chat-pending.test.ts index 78ffa01b78f..b0cea78417d 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.test.ts @@ -134,6 +134,16 @@ describe('prunePendingSends', () => { expect(prunePendingSends(pending, [oldUser, oldAnswer])).toEqual(pending) }) + it('prunes a first send against a timestampless transcript turn (grok)', () => { + const pending = [{ ...pendingOf('p1', 'rename it'), afterMessageId: null }] + const transcript = [ + { ...userMessage('u1', 'rename it'), timestamp: null }, + { ...assistantMessage('a1', 'done'), timestamp: null } + ] + + expect(prunePendingSends(pending, transcript)).toEqual([]) + }) + it('prunes only one of two identical pending sends for one completed turn', () => { const pending = [pendingOf('p1', 'repeat'), pendingOf('p2', 'repeat')] expect( @@ -246,6 +256,14 @@ describe('pendingSendsAsMessages', () => { expect(prunePendingSends(pending, remoteTranscript)).toEqual([]) }) + it('hides a first send while its timestampless transcript turn is visible (grok)', () => { + const pending = [{ ...pendingOf('p1', 'rename it'), afterMessageId: null }] + + expect( + pendingSendsAsMessages(pending, [{ ...userMessage('u1', 'rename it'), timestamp: null }]) + ).toEqual([]) + }) + it('hides only one of two identical pending sends for one real user turn', () => { const pending = [pendingOf('p1', 'repeat'), pendingOf('p2', 'repeat')] expect(pendingSendsAsMessages(pending, [userMessage('u1', 'repeat')]).map((m) => m.id)).toEqual( @@ -340,6 +358,20 @@ describe('launchPromptAsMessage', () => { ).toBe(true) }) + // Grok transcripts carry no timestamps; before the null-matchable rule the + // seeded bubble was never hidden or pruned and sat rank-pinned at the list + // tail forever, reading as the conversation reordering. + it('hides and prunes the launch prompt against a timestampless transcript (grok)', () => { + const entry = { tabId: 'tab-1', agent: 'grok' as const, text: 'rename it', createdAt: 42 } + const transcript = [ + { ...userMessage('u1', 'rename it'), timestamp: null }, + { ...assistantMessage('a1', 'done'), timestamp: null } + ] + + expect(launchPromptAsMessage(entry, transcript)).toBeNull() + expect(shouldPruneLaunchPrompt(entry, transcript)).toBe(true) + }) + it('does not bind a launch prompt to an older identical completed turn', () => { const entry = { tabId: 'tab-1', diff --git a/src/renderer/src/components/native-chat/native-chat-pending.ts b/src/renderer/src/components/native-chat/native-chat-pending.ts index 2fb916c0f73..466ec16cd8c 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.ts @@ -111,8 +111,11 @@ function messageIsAfterPendingTimestamp( message: NativeChatMessage, pending: NativeChatPendingSend ): boolean { + // Why: some transcripts (e.g. Grok) never carry timestamps. Excluding their + // rows would make the echo unmatchable forever, stranding a rank-pinned + // bubble at the list tail — which reads as the conversation reordering. if (message.timestamp === null) { - return false + return true } const boundary = nativeChatPendingMatchingAfter(pending) // A transcript-clock boundary describes an existing message, so exclude ties. @@ -231,9 +234,11 @@ export function launchPromptAsMessage( if (!entry) { return null } + // Why: a launch prompt seeds a brand-new session, so a matching user turn + // with no timestamp (e.g. Grok transcripts) can only be its own delivery. const represented = matchingNativeChatUserContentCounts( existingMessages.filter( - (message) => message.timestamp !== null && message.timestamp >= entry.createdAt + (message) => message.timestamp === null || message.timestamp >= entry.createdAt ) ) if ((represented.get(nativeChatPendingContentKey(entry)) ?? 0) > 0) { @@ -256,7 +261,7 @@ export function shouldPruneLaunchPrompt( messages: NativeChatMessage[] ): boolean { const relevant = messages.filter( - (message) => message.timestamp !== null && message.timestamp >= entry.createdAt + (message) => message.timestamp === null || message.timestamp >= entry.createdAt ) return ( (advancedNativeChatUserContentCounts(relevant).get(nativeChatPendingContentKey(entry)) ?? 0) > 0 diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.test.tsx index 144385e76e7..1f24874cdb7 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.test.tsx @@ -121,7 +121,10 @@ function imagePasteEvent(): { const sshOwner: NativeChatAttachmentOwner = { kind: 'ssh', connectionId: 'conn-1', - worktreePath: '/remote/wt' + worktreePath: '/remote/wt', + expectedExecutionHostId: 'ssh:conn-1', + expectedSshTargetId: 'conn-1', + expectedSshConnectionGeneration: 4 } afterEach(() => { diff --git a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx index 92f6867682b..fc72a23adee 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx @@ -104,7 +104,10 @@ describe('useNativeChatExternalAttachments', () => { mocks.resolveNativeChatAttachmentOwner.mockReturnValue({ kind: 'ssh', connectionId: 'conn-1', - worktreePath: '/remote/wt' + worktreePath: '/remote/wt', + expectedExecutionHostId: 'ssh:conn-1', + expectedSshTargetId: 'conn-1', + expectedSshConnectionGeneration: 4 }) mocks.uploadNativeChatAttachmentPaths.mockResolvedValue(['/remote/wt/.orca/drops/a.txt']) const attachResolvedPaths = vi.fn() @@ -115,7 +118,10 @@ describe('useNativeChatExternalAttachments', () => { expect(mocks.uploadNativeChatAttachmentPaths).toHaveBeenCalledWith(['/local/a.txt'], { kind: 'ssh', connectionId: 'conn-1', - worktreePath: '/remote/wt' + worktreePath: '/remote/wt', + expectedExecutionHostId: 'ssh:conn-1', + expectedSshTargetId: 'conn-1', + expectedSshConnectionGeneration: 4 }) expect(attachResolvedPaths).toHaveBeenCalledWith(['/remote/wt/.orca/drops/a.txt']) }) @@ -136,7 +142,10 @@ describe('useNativeChatExternalAttachments', () => { mocks.resolveNativeChatAttachmentOwner.mockReturnValue({ kind: 'ssh', connectionId: 'conn-1', - worktreePath: '/remote/wt' + worktreePath: '/remote/wt', + expectedExecutionHostId: 'ssh:conn-1', + expectedSshTargetId: 'conn-1', + expectedSshConnectionGeneration: 4 }) let resolveUpload: (paths: string[]) => void = () => {} mocks.uploadNativeChatAttachmentPaths.mockReturnValue( diff --git a/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts b/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts index d57fa2110d9..96d3cbdf479 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts @@ -67,7 +67,7 @@ export function useNativeChatSessionOptionCommand(args: { cancelNativeChatPtySends(target.ptyId) await waitForNativeChatPtyIdle(target.ptyId) if (!mountedRef.current || sendController.signal.aborted) { - throw new Error('Native chat command was canceled because the composer closed.') + throw new Error('Chat UI command was canceled because the composer closed.') } const detectClaudeConfirmation = options?.detectAgentInteraction === 'claude-model-switch-confirmation' @@ -80,7 +80,7 @@ export function useNativeChatSessionOptionCommand(args: { activeObserversRef.current.add(observer) await observer.ready if (!mountedRef.current || sendController.signal.aborted) { - throw new Error('Native chat command was canceled because the composer closed.') + throw new Error('Chat UI command was canceled because the composer closed.') } // Why: arm only after the observer reaches the live PTY tail, then // submit immediately so historical output cannot satisfy the match. diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.tsx index d919cb1b62b..8cdaf62db0b 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorer.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorer.tsx @@ -300,7 +300,8 @@ function FileExplorerFiles(): React.JSX.Element { expanded, toggleDir, refreshDir, - scrollRef + scrollRef, + getOperationOwnerForPath: (path) => rowProjection.getRowByPath(path)?.operationOwner }) const lastResetWorktreePathRef = useRef(null) @@ -393,7 +394,8 @@ function FileExplorerFiles(): React.JSX.Element { refreshTree, inlineInput, dragSourcePath, - isNativeDragOver + isNativeDragOver, + operationOwner: rootCache?.operationOwner }) useFileExplorerImport({ @@ -401,7 +403,8 @@ function FileExplorerFiles(): React.JSX.Element { activeWorktreeId, refreshDir, clearNativeDragState, - setSelectedPath: setSingleSelectedPath + setSelectedPath: setSingleSelectedPath, + operationOwner: rootCache?.operationOwner }) const totalCount = visibleRowCount + (inlineInputIndex >= 0 ? 1 : 0) diff --git a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx index 4d260e09a02..2e1c3e24f0c 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx @@ -79,6 +79,7 @@ export type InlineInput = { depth: number existingName?: string existingPath?: string + operationOwner?: TreeNode['operationOwner'] } // ─── Inline Input Row ──────────────────────────────────────────── @@ -637,7 +638,10 @@ export function FileExplorerRow({ className={cn( 'truncate', isSelected && !nodeStatus && !isIgnored && 'text-accent-foreground', - isIgnored && 'italic' + // Why: italic glyphs overhang their advance width; truncate's + // overflow:hidden clips it, shaving the last char (".md" → ".ma"). + // pr-0.5 reserves room for the slant so the final letter survives. + isIgnored && 'italic pr-0.5' )} style={ nodeStatus diff --git a/src/renderer/src/components/right-sidebar/SourceControl.hosted-review-header-link.test.tsx b/src/renderer/src/components/right-sidebar/SourceControl.hosted-review-header-link.test.tsx new file mode 100644 index 00000000000..ee90c607ada --- /dev/null +++ b/src/renderer/src/components/right-sidebar/SourceControl.hosted-review-header-link.test.tsx @@ -0,0 +1,157 @@ +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { HostedReviewInfo } from '../../../../shared/hosted-review' +import { HostedReviewHeaderLink } from './hosted-review-header-chrome' + +const { openHttpLinkMock } = vi.hoisted(() => ({ openHttpLinkMock: vi.fn() })) + +vi.mock('@/lib/http-link-routing', () => ({ + openHttpLink: openHttpLinkMock, + registerHttpLinkStoreAccessor: vi.fn() +})) + +function makeReview(overrides: Partial = {}): HostedReviewInfo { + return { + provider: 'github', + number: 2192, + title: 'Open PR in checks', + state: 'open', + url: 'https://github.com/stablyai/orca/pull/2192', + status: 'pending', + updatedAt: '2026-05-17T00:00:00Z', + mergeable: 'UNKNOWN', + ...overrides + } +} + +type MinimalClickEvent = Pick< + React.MouseEvent, + 'nativeEvent' | 'stopPropagation' +> +type ClickModifiers = Partial> + +function clickEvent(modifiers: ClickModifiers = {}): MinimalClickEvent { + return { + nativeEvent: { + metaKey: modifiers.metaKey ?? false, + ctrlKey: modifiers.ctrlKey ?? false, + shiftKey: modifiers.shiftKey ?? false + } as MouseEvent, + stopPropagation: vi.fn() + } +} + +beforeEach(() => { + openHttpLinkMock.mockReset() +}) + +describe('HostedReviewHeaderLink', () => { + it('opens GitHub PRs in the Checks tab instead of rendering an external link', () => { + const onOpenHostedReviewInChecks = vi.fn() + const element = HostedReviewHeaderLink({ + review: makeReview(), + onOpenHostedReviewInChecks + }) + const markup = renderToStaticMarkup(element) + + expect(markup).toContain(' void)(event) + expect(event.stopPropagation).toHaveBeenCalledTimes(1) + expect(onOpenHostedReviewInChecks).toHaveBeenCalledTimes(1) + expect(openHttpLinkMock).not.toHaveBeenCalled() + }) + + it.each<[string, ClickModifiers]>([ + ['Cmd-click', { metaKey: true }], + ['Ctrl-click', { ctrlKey: true }], + ['Shift+Cmd-click', { metaKey: true, shiftKey: true }], + ['Shift+Ctrl-click', { ctrlKey: true, shiftKey: true }] + ])('opens GitHub PRs in the Checks tab on %s', (_label, modifiers) => { + const onOpenHostedReviewInChecks = vi.fn() + const element = HostedReviewHeaderLink({ + review: makeReview(), + onOpenHostedReviewInChecks + }) + + const event = clickEvent(modifiers) + ;(element.props.onClick as (event: MinimalClickEvent) => void)(event) + + expect(event.stopPropagation).toHaveBeenCalledTimes(1) + expect(onOpenHostedReviewInChecks).toHaveBeenCalledTimes(1) + expect(openHttpLinkMock).not.toHaveBeenCalled() + }) + + it('opens GitLab MRs in the Checks tab instead of rendering an external link', () => { + const onOpenHostedReviewInChecks = vi.fn() + const element = HostedReviewHeaderLink({ + review: makeReview({ + provider: 'gitlab', + number: 31, + url: 'https://gitlab.com/acme/widgets/-/merge_requests/31' + }), + onOpenHostedReviewInChecks + }) + const markup = renderToStaticMarkup(element) + + expect(markup).toContain(' void)(event) + expect(event.stopPropagation).toHaveBeenCalledTimes(1) + expect(onOpenHostedReviewInChecks).toHaveBeenCalledTimes(1) + expect(openHttpLinkMock).not.toHaveBeenCalled() + }) + + it.each<[string, ClickModifiers]>([ + ['Cmd-click', { metaKey: true }], + ['Ctrl-click', { ctrlKey: true }], + ['Shift+Cmd-click', { metaKey: true, shiftKey: true }], + ['Shift+Ctrl-click', { ctrlKey: true, shiftKey: true }] + ])('opens GitLab MRs in the Checks tab on %s', (_label, modifiers) => { + const onOpenHostedReviewInChecks = vi.fn() + const element = HostedReviewHeaderLink({ + review: makeReview({ + provider: 'gitlab', + number: 31, + url: 'https://gitlab.com/acme/widgets/-/merge_requests/31' + }), + onOpenHostedReviewInChecks + }) + + const event = clickEvent(modifiers) + ;(element.props.onClick as (event: MinimalClickEvent) => void)(event) + + expect(event.stopPropagation).toHaveBeenCalledTimes(1) + expect(onOpenHostedReviewInChecks).toHaveBeenCalledTimes(1) + expect(openHttpLinkMock).not.toHaveBeenCalled() + }) + + it('keeps other provider reviews as external hosted-review links', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain(' activeWorktreeId @@ -2802,6 +2806,11 @@ function SourceControlInner(): React.JSX.Element { ] ) + const openHostedReviewInChecks = useCallback(() => { + setRightSidebarOpen(true) + setRightSidebarTab('checks') + }, [setRightSidebarOpen, setRightSidebarTab]) + const handleBranchChangedByPullRequestGeneration = useCallback(async (): Promise => { // Why: AI PR detail generation may rebase before summarizing, so refresh status if HEAD moved before the user submits the draft. await refreshActiveGitStatusAfterMutation() @@ -4230,6 +4239,10 @@ function SourceControlInner(): React.JSX.Element { (!createPrHeaderAction.disabled || isCreatingPr || prGenerating) ? createPrHeaderAction : null + const visibleCreatePrHeaderAction = resolveVisibleCreatePrHeaderAction({ + createPrHeaderAction + }) + const dropdownItems: DropdownEntry[] = useMemo( () => resolveDropdownItems({ @@ -4697,6 +4710,19 @@ function SourceControlInner(): React.JSX.Element { runCreatePrIntent ]) + const handleCreatePrHeaderClick = useCallback((): void => { + if (!createPrHeaderAction || createPrHeaderAction.disabled) { + return + } + if (createPrHeaderAction.kind === 'create_pr') { + void handleCreatePullRequest() + return + } + if (createPrHeaderAction.kind === 'create_pr_intent') { + void runCreatePrIntent() + } + }, [createPrHeaderAction, handleCreatePullRequest, runCreatePrIntent]) + const branchCompareInFlightRef = useRef(false) const branchCompareRerunRef = useRef(false) const branchCompareRunPromiseRef = useRef | null>(null) @@ -5426,11 +5452,16 @@ function SourceControlInner(): React.JSX.Element { <>
+ {detachedHeadDisplay && ( +
+ +
+ )} + {/* Why: hidden when count is 0 — notes are created from the diff view, so an empty Notes shelf here is pure chrome. */} {activeWorktreeId && worktreePath && diffCommentCount > 0 && (
@@ -5488,6 +5525,7 @@ function SourceControlInner(): React.JSX.Element { groupId={activeGroupId ?? activeWorktreeId} comments={diffCommentsForActive} triggerClassName="size-6" + respondToOpenRequest /> {diffCommentCount > 0 && ( diff --git a/src/renderer/src/components/right-sidebar/file-explorer-delete-classification.ts b/src/renderer/src/components/right-sidebar/file-explorer-delete-classification.ts new file mode 100644 index 00000000000..4d72d354ab7 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-delete-classification.ts @@ -0,0 +1,27 @@ +import { translate } from '@/i18n/i18n' +import { + getFileExplorerOperationRoute, + getFileExplorerOwnerUnresolvedMessage +} from './file-explorer-operation-owner' +import type { TreeNode } from './file-explorer-types' + +export function needsRemoteDeleteConfirmation(node: TreeNode): boolean { + const owner = node.operationOwner ?? { kind: 'unresolved' as const } + return owner.kind !== 'local' && getFileExplorerOperationRoute(owner) !== null +} + +export function isLocalDeleteNode(node: TreeNode): boolean { + return (node.operationOwner ?? { kind: 'unresolved' as const }).kind === 'local' +} + +export function getFileDeleteErrorMessage(error: unknown): string | null { + if (!(error instanceof Error)) { + return null + } + return error.message === getFileExplorerOwnerUnresolvedMessage() + ? translate( + 'auto.components.right.sidebar.useFileDeletion.8b8ee9d22f', + "Couldn't determine which host owns this file. Check the workspace connection and try again." + ) + : error.message +} diff --git a/src/renderer/src/components/right-sidebar/file-explorer-operation-generation.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-operation-generation.test.ts new file mode 100644 index 00000000000..771dbed72b2 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-operation-generation.test.ts @@ -0,0 +1,166 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type { Worktree } from '../../../../shared/types' +import { useAppStore } from '@/store' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import { + captureFileExplorerOperationGuard, + getFileExplorerOperationOwner +} from './file-explorer-operation-owner' + +const initialState = useAppStore.getInitialState() +const worktreeId = 'repo-1::/srv/project' + +function worktree(hostId: Worktree['hostId'], runtimeOwnerEnvironmentId?: string): Worktree { + return { + id: worktreeId, + repoId: 'repo-1', + path: '/srv/project', + hostId, + runtimeOwnerEnvironmentId + } as Worktree +} + +afterEach(() => { + useAppStore.getState().setRuntimeEnvironments([]) + useAppStore.setState(initialState, true) +}) + +describe('file explorer operation generations', () => { + it('invalidates a nested SSH mutation when that target reconnects', () => { + useAppStore.setState({ + repos: [], + worktreesByRepo: { 'repo-1': [worktree('ssh:private-target', 'hub-a')] } + }) + useAppStore.getState().setEnvironmentSshConnectionState('hub-a', 'private-target', { + targetId: 'private-target', + status: 'connected', + error: null, + reconnectAttempt: 0, + connectionGeneration: 1 + }) + const owner = getFileExplorerOperationOwner(worktreeId) + const guard = captureFileExplorerOperationGuard(worktreeId, owner) + + useAppStore.getState().setEnvironmentSshConnectionState('hub-a', 'private-target', { + targetId: 'private-target', + status: 'disconnected', + error: null, + reconnectAttempt: 0, + connectionGeneration: 2 + }) + + expect(() => guard.assertCurrent()).toThrow("Couldn't determine which host owns") + }) + + it('invalidates a direct SSH mutation when that target reconnects', () => { + useAppStore.setState({ + repos: [], + worktreesByRepo: { 'repo-1': [worktree('ssh:client-target')] } + }) + useAppStore.getState().setSshConnectionState('client-target', { + targetId: 'client-target', + status: 'connected', + error: null, + reconnectAttempt: 0, + connectionGeneration: 1 + }) + const owner = getFileExplorerOperationOwner(worktreeId) + const guard = captureFileExplorerOperationGuard(worktreeId, owner) + + useAppStore.getState().setSshConnectionState('client-target', { + targetId: 'client-target', + status: 'disconnected', + error: null, + reconnectAttempt: 0, + connectionGeneration: 2 + }) + + expect(() => guard.assertCurrent()).toThrow("Couldn't determine which host owns") + }) + + it('invalidates a folder-workspace mutation when its SSH target reconnects', () => { + const folderWorkspaceId = 'folder-1' + const folderWorktreeId = folderWorkspaceKey(folderWorkspaceId) + useAppStore.setState({ + folderWorkspaces: [ + { + id: folderWorkspaceId, + projectGroupId: 'group-1', + connectionId: 'client-target' + } as never + ], + projectGroups: [{ id: 'group-1', connectionId: 'client-target' } as never], + repos: [], + worktreesByRepo: {} + }) + useAppStore.getState().setSshConnectionState('client-target', { + targetId: 'client-target', + status: 'connected', + error: null, + reconnectAttempt: 0, + connectionGeneration: 1 + }) + const owner = getFileExplorerOperationOwner(folderWorktreeId) + const guard = captureFileExplorerOperationGuard(folderWorktreeId, owner) + + useAppStore.getState().setSshConnectionState('client-target', { + targetId: 'client-target', + status: 'disconnected', + error: null, + reconnectAttempt: 0, + connectionGeneration: 2 + }) + + expect(() => guard.assertCurrent()).toThrow("Couldn't determine which host owns") + }) + + it('invalidates a mutation when the saved HUB points at a replacement runtime', () => { + useAppStore.setState({ + repos: [], + worktreesByRepo: { 'repo-1': [worktree('local', 'hub-a')] } + }) + useAppStore.getState().setRuntimeEnvironmentStatus('hub-a', { + status: { runtimeId: 'runtime-a' } as never, + checkedAt: 1 + }) + const owner = getFileExplorerOperationOwner(worktreeId) + const guard = captureFileExplorerOperationGuard(worktreeId, owner) + + useAppStore.getState().setRuntimeEnvironmentStatus('hub-a', { + status: { runtimeId: 'runtime-b' } as never, + checkedAt: 2 + }) + + expect(() => guard.assertCurrent()).toThrow("Couldn't determine which host owns") + }) + + it('fails closed when nested SSH ownership has no authoritative generation', () => { + useAppStore.setState({ + repos: [], + worktreesByRepo: { 'repo-1': [worktree('ssh:private-target', 'hub-a')] } + }) + const owner = getFileExplorerOperationOwner(worktreeId) + + expect(() => captureFileExplorerOperationGuard(worktreeId, owner)).toThrow( + "Couldn't determine which host owns" + ) + }) + + it('invalidates a mutation when the same saved HUB id is re-paired', () => { + useAppStore.setState({ + repos: [], + worktreesByRepo: { 'repo-1': [worktree('local', 'hub-a')] } + }) + useAppStore + .getState() + .setRuntimeEnvironments([{ id: 'hub-a', createdAt: 1, pairingRevision: 1 } as never]) + const owner = getFileExplorerOperationOwner(worktreeId) + const guard = captureFileExplorerOperationGuard(worktreeId, owner) + + useAppStore + .getState() + .setRuntimeEnvironments([{ id: 'hub-a', createdAt: 1, pairingRevision: 2 } as never]) + + expect(() => guard.assertCurrent()).toThrow("Couldn't determine which host owns") + }) +}) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts b/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts index 451d0fd807d..1d301916c4d 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts @@ -1,12 +1,7 @@ import { getConnectionIdFromState } from '@/lib/connection-context' import { useAppStore } from '@/store' -import { getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers' import type { AppState } from '@/store/types' -import { - getRepoExecutionHostId, - parseExecutionHostId, - type ExecutionHostId -} from '../../../../shared/execution-host' +import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' import { translate } from '@/i18n/i18n' import { @@ -14,13 +9,26 @@ import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' import type { FileExplorerOperationOwner } from './file-explorer-types' +import { + resolveWorktreeOperationRoute, + type WorktreeOperationRoute +} from '@/lib/worktree-operation-route' +import { captureWorktreeOperationGenerationGuard } from '@/lib/worktree-operation-generation' export type FileExplorerOperationRoute = { settings: { activeRuntimeEnvironmentId: string | null } connectionId?: string + expectedExecutionHostId?: 'local' | `ssh:${string}` + expectedSshTargetId?: string + expectedSshConnectionGeneration?: number } -type FileExplorerOwnerState = Pick< +export type FileExplorerOperationGuard = { + route: FileExplorerOperationRoute + assertCurrent: () => FileExplorerOperationRoute +} + +export type FileExplorerOwnerState = Pick< AppState, | 'settings' | 'repos' @@ -37,21 +45,20 @@ export function getFileExplorerOperationOwnerFromState( ): FileExplorerOperationOwner { const parsedWorkspace = worktreeId ? parseWorkspaceKey(worktreeId) : null if (worktreeId && parsedWorkspace?.type !== 'folder') { - const exactHostIds = getExactWorktreeHostIds(state, worktreeId) - if (exactHostIds.size > 1) { + const route = resolveWorktreeOperationRoute(state, worktreeId) + if (!route) { return { kind: 'unresolved' } } - const exactHostId = exactHostIds.values().next().value - if (exactHostId) { - return operationOwnerFromHostId(exactHostId) + if (route.runtimeEnvironmentId) { + return { + kind: 'runtime', + environmentId: route.runtimeEnvironmentId, + executionHostId: + route.executionHostId ?? `runtime:${encodeURIComponent(route.runtimeEnvironmentId)}` + } } - - const repoId = getRepoIdFromWorktreeId(worktreeId) - const repoHostIds = new Set( - state.repos.filter((repo) => repo.id === repoId).map(getRepoExecutionHostId) - ) - if (repoHostIds.size > 1) { - return { kind: 'unresolved' } + if (route.executionHostId) { + return operationOwnerFromHostId(route.executionHostId) } } @@ -70,7 +77,11 @@ export function getFileExplorerOperationOwnerFromState( ? null : settings.activeRuntimeEnvironmentId?.trim() if (runtimeEnvironmentId) { - return { kind: 'runtime', environmentId: runtimeEnvironmentId } + return { + kind: 'runtime', + environmentId: runtimeEnvironmentId, + executionHostId: `runtime:${encodeURIComponent(runtimeEnvironmentId)}` + } } if (connectionId === undefined) { return { kind: 'unresolved' } @@ -89,15 +100,142 @@ export function getFileExplorerOperationRoute( ): FileExplorerOperationRoute | null { switch (owner.kind) { case 'local': - return { settings: { activeRuntimeEnvironmentId: null } } + return { + settings: { activeRuntimeEnvironmentId: null }, + expectedExecutionHostId: 'local' + } case 'ssh': return { settings: { activeRuntimeEnvironmentId: null }, - connectionId: owner.connectionId + connectionId: owner.connectionId, + expectedExecutionHostId: `ssh:${encodeURIComponent(owner.connectionId)}` + } + case 'runtime': { + const host = parseExecutionHostId(owner.executionHostId) + return { + settings: { activeRuntimeEnvironmentId: owner.environmentId }, + ...(host?.kind === 'ssh' + ? { expectedExecutionHostId: host.id } + : { expectedExecutionHostId: 'local' as const }) + } + } + case 'unresolved': + return null + } +} + +export function requireFileExplorerOperationRoute( + worktreeId: string | null | undefined +): FileExplorerOperationRoute { + const route = getFileExplorerOperationRoute(getFileExplorerOperationOwner(worktreeId)) + if (!route) { + throw new Error(getFileExplorerOwnerUnresolvedMessage()) + } + return route +} + +export function requireMatchingFileExplorerOperationRoute( + worktreeId: string | null | undefined, + expectedOwner: FileExplorerOperationOwner | undefined +): FileExplorerOperationRoute { + if (!expectedOwner || expectedOwner.kind === 'unresolved') { + throw new Error(getFileExplorerOwnerUnresolvedMessage()) + } + const currentOwner = getFileExplorerOperationOwner(worktreeId) + if (JSON.stringify(currentOwner) !== JSON.stringify(expectedOwner)) { + throw new Error(getFileExplorerOwnerUnresolvedMessage()) + } + const route = getFileExplorerOperationRoute(expectedOwner) + if (!route) { + throw new Error(getFileExplorerOwnerUnresolvedMessage()) + } + return route +} + +export function captureFileExplorerOperationGuard( + worktreeId: string | null | undefined, + expectedOwner: FileExplorerOperationOwner | undefined +): FileExplorerOperationGuard { + if (!worktreeId) { + throw new Error(getFileExplorerOwnerUnresolvedMessage()) + } + const route = requireMatchingFileExplorerOperationRoute(worktreeId, expectedOwner) + const operationRoute = getFileExplorerGenerationRoute(expectedOwner) + if (!operationRoute) { + throw new Error(getFileExplorerOwnerUnresolvedMessage()) + } + const generationGuard = captureWorktreeOperationGenerationGuard( + useAppStore.getState, + worktreeId, + operationRoute, + () => new Error(getFileExplorerOwnerUnresolvedMessage()), + () => getFileExplorerGenerationRoute(getFileExplorerOperationOwner(worktreeId)) + ) + const expectedSshConnectionGeneration = getExpectedSshConnectionGeneration( + useAppStore.getState(), + operationRoute + ) + const operationHost = parseExecutionHostId(operationRoute.executionHostId) + if (!operationHost) { + throw new Error(getFileExplorerOwnerUnresolvedMessage()) + } + if (operationHost?.kind === 'ssh' && expectedSshConnectionGeneration === undefined) { + throw new Error(getFileExplorerOwnerUnresolvedMessage()) + } + const guardedRoute: FileExplorerOperationRoute = { + ...route, + expectedExecutionHostId: operationHost.kind === 'ssh' ? operationHost.id : 'local', + ...(operationHost?.kind === 'ssh' ? { expectedSshTargetId: operationHost.targetId } : {}), + ...(expectedSshConnectionGeneration === undefined ? {} : { expectedSshConnectionGeneration }) + } + return { + route: guardedRoute, + assertCurrent: () => { + generationGuard.assertCurrent() + if ( + getExpectedSshConnectionGeneration(useAppStore.getState(), operationRoute) !== + expectedSshConnectionGeneration + ) { + throw new Error(getFileExplorerOwnerUnresolvedMessage()) + } + return guardedRoute + } + } +} + +function getExpectedSshConnectionGeneration( + state: Pick, + route: WorktreeOperationRoute +): number | undefined { + const host = parseExecutionHostId(route.executionHostId) + if (host?.kind !== 'ssh') { + return undefined + } + return route.runtimeEnvironmentId + ? state.sshStateByEnvironment + .get(route.runtimeEnvironmentId) + ?.connectionStates.get(host.targetId)?.connectionGeneration + : state.sshConnectionStates.get(host.targetId)?.connectionGeneration +} + +function getFileExplorerGenerationRoute( + owner: FileExplorerOperationOwner | undefined +): WorktreeOperationRoute | null { + switch (owner?.kind) { + case 'local': + return { executionHostId: 'local', runtimeEnvironmentId: null } + case 'ssh': + return { + executionHostId: `ssh:${encodeURIComponent(owner.connectionId)}`, + runtimeEnvironmentId: null } case 'runtime': - return { settings: { activeRuntimeEnvironmentId: owner.environmentId } } + return { + executionHostId: owner.executionHostId, + runtimeEnvironmentId: owner.environmentId + } case 'unresolved': + case undefined: return null } } @@ -109,28 +247,6 @@ export function getFileExplorerOwnerUnresolvedMessage(): string { ) } -function getExactWorktreeHostIds( - state: Pick, - worktreeId: string -): Set { - const hostIds = new Set() - for (const worktrees of Object.values(state.worktreesByRepo)) { - for (const worktree of worktrees) { - if (worktree.id === worktreeId && worktree.hostId) { - hostIds.add(worktree.hostId) - } - } - } - for (const result of Object.values(state.detectedWorktreesByRepo)) { - for (const worktree of result.worktrees) { - if (worktree.id === worktreeId && worktree.hostId) { - hostIds.add(worktree.hostId) - } - } - } - return hostIds -} - function operationOwnerFromHostId(hostId: ExecutionHostId): FileExplorerOperationOwner { const parsed = parseExecutionHostId(hostId) switch (parsed?.kind) { @@ -139,7 +255,7 @@ function operationOwnerFromHostId(hostId: ExecutionHostId): FileExplorerOperatio case 'ssh': return { kind: 'ssh', connectionId: parsed.targetId } case 'runtime': - return { kind: 'runtime', environmentId: parsed.environmentId } + return { kind: 'runtime', environmentId: parsed.environmentId, executionHostId: hostId } case undefined: return { kind: 'unresolved' } } diff --git a/src/renderer/src/components/right-sidebar/file-explorer-runtime-owner-boundary.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-runtime-owner-boundary.test.ts index b4366a4d3c0..8814019ab7a 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-runtime-owner-boundary.test.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-runtime-owner-boundary.test.ts @@ -24,7 +24,7 @@ describe('right sidebar file/git runtime ownership boundaries', () => { const text = source(path) expect(text).toMatch( - /getRightSidebarWorktreeRuntimeSettings|getSettingsForWorktreeRuntimeOwner|getFileExplorerOperationOwner|getFileExplorerOperationRoute/ + /getRightSidebarWorktreeRuntimeSettings|getSettingsForWorktreeRuntimeOwner|getFileExplorerOperationOwner|getFileExplorerOperationRoute|captureFileExplorerOperationGuard|requireFileExplorerOperationRoute|requireMatchingFileExplorerOperationRoute/ ) expect(text).not.toContain('settings: useAppStore.getState().settings') expect(text).not.toContain('const settings = useAppStore.getState().settings') diff --git a/src/renderer/src/components/right-sidebar/file-explorer-types.ts b/src/renderer/src/components/right-sidebar/file-explorer-types.ts index d8a773d3f6c..7cf4bbb96d3 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-types.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-types.ts @@ -1,7 +1,9 @@ +import type { ExecutionHostId } from '../../../../shared/execution-host' + export type FileExplorerOperationOwner = | { kind: 'local' } | { kind: 'ssh'; connectionId: string } - | { kind: 'runtime'; environmentId: string } + | { kind: 'runtime'; environmentId: string; executionHostId: ExecutionHostId } | { kind: 'unresolved' } export type TreeNode = { @@ -18,4 +20,5 @@ export type TreeNode = { export type DirCache = { children: TreeNode[] loading: boolean + operationOwner?: FileExplorerOperationOwner } diff --git a/src/renderer/src/components/right-sidebar/hosted-review-header-chrome.tsx b/src/renderer/src/components/right-sidebar/hosted-review-header-chrome.tsx new file mode 100644 index 00000000000..66733779c77 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/hosted-review-header-chrome.tsx @@ -0,0 +1,74 @@ +import React from 'react' +import { GitMerge } from 'lucide-react' +import type { HostedReviewInfo } from '../../../../shared/hosted-review' +import { cn } from '@/lib/utils' +import { PullRequestIcon } from './checks-panel-content' + +function hostedReviewStateClass(review: HostedReviewInfo): string { + if (review.state === 'merged') { + return 'text-purple-500/80' + } + if (review.state === 'open') { + return 'text-emerald-500/80' + } + if (review.state === 'closed') { + return 'text-muted-foreground/60' + } + return 'text-muted-foreground/50' +} + +export function HostedReviewIcon({ + review, + className +}: { + review: HostedReviewInfo + className?: string +}): React.JSX.Element { + const Icon = review.provider === 'gitlab' ? GitMerge : PullRequestIcon + return +} + +function hostedReviewLabel(review: HostedReviewInfo): string { + return `${review.provider === 'gitlab' ? 'MR' : 'PR'} #${review.number}` +} + +export function HostedReviewHeaderLink({ + review, + onOpenHostedReviewInChecks +}: { + review: HostedReviewInfo + onOpenHostedReviewInChecks: () => void +}): React.JSX.Element { + const label = hostedReviewLabel(review) + const className = + 'shrink-0 border-0 bg-transparent p-0 text-left font-medium leading-none text-foreground underline decoration-border underline-offset-2 opacity-80 hover:text-foreground hover:decoration-foreground' + + if (review.provider === 'github' || review.provider === 'gitlab') { + return ( + + ) + } + + return ( + e.stopPropagation()} + > + {label} + + ) +} diff --git a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.test.ts b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.test.ts new file mode 100644 index 00000000000..b7214084d24 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { resolveVisibleCreatePrHeaderAction } from './source-control-create-pr-intent-state' +import type { PrimaryAction } from './source-control-primary-action-types' + +const disabledCreatePrAction: PrimaryAction = { + kind: 'create_pr', + label: 'Create PR', + title: 'Publish commits before creating a pull request.', + disabled: true +} + +const enabledCreatePrAction: PrimaryAction = { + kind: 'create_pr', + label: 'Create PR', + title: 'Create a pull request for this branch', + disabled: false +} + +describe('resolveVisibleCreatePrHeaderAction', () => { + it('returns null when no header action is available', () => { + expect( + resolveVisibleCreatePrHeaderAction({ + createPrHeaderAction: null + }) + ).toBeNull() + }) + + it('keeps a disabled Create PR header visible as a stable toolbar anchor', () => { + expect( + resolveVisibleCreatePrHeaderAction({ + createPrHeaderAction: disabledCreatePrAction + }) + ).toEqual(disabledCreatePrAction) + }) + + it('keeps an enabled Create PR header visible even when the body composer is open', () => { + expect( + resolveVisibleCreatePrHeaderAction({ + createPrHeaderAction: enabledCreatePrAction + }) + ).toEqual(enabledCreatePrAction) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.ts b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.ts index 82752fdac66..b40976642e1 100644 --- a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.ts +++ b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.ts @@ -1,3 +1,4 @@ +import type { PrimaryAction } from './source-control-primary-action-types' import { resolveCreateReviewIntentEligibility, type CreateReviewIntentEligibility, @@ -10,3 +11,13 @@ export type CreatePrIntentKind = CreateReviewIntentKind export type CreatePrIntentEligibility = CreateReviewIntentEligibility export const resolveCreatePrIntentEligibility = resolveCreateReviewIntentEligibility + +export function resolveVisibleCreatePrHeaderAction({ + createPrHeaderAction +}: { + createPrHeaderAction: PrimaryAction | null +}): PrimaryAction | null { + // Why: keep a stable header anchor; disable Create PR when the branch is not + // ready instead of hiding it and shifting the toolbar layout. + return createPrHeaderAction +} diff --git a/src/renderer/src/components/right-sidebar/source-control-header-toolbar.test.tsx b/src/renderer/src/components/right-sidebar/source-control-header-toolbar.test.tsx deleted file mode 100644 index 77ca1a6ac52..00000000000 --- a/src/renderer/src/components/right-sidebar/source-control-header-toolbar.test.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { renderToStaticMarkup } from 'react-dom/server' -import type { ReactNode } from 'react' -import { describe, expect, it, vi } from 'vitest' -import type { WorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display' -import { SourceControlHeaderToolbar } from './source-control-header-toolbar' - -vi.mock('@/components/ui/tooltip', () => ({ - Tooltip: ({ children }: { children: ReactNode }) => <>{children}, - TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, - TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} -})) - -vi.mock('./source-control-header-overflow-menu', () => ({ - SourceControlHeaderOverflowMenu: () => -})) - -vi.mock('./source-control-branch-context-row', () => ({ - shouldShowSourceControlBranchContextRow: () => false, - SourceControlBranchContextRow: () => null -})) - -function renderToolbar( - gitIdentityDisplay: WorktreeGitIdentityDisplay | null = { - kind: 'branch', - branchName: 'brennanb2025/source-control-branch-name' - } -): string { - return renderToStaticMarkup( - - ) -} - -describe('SourceControlHeaderToolbar', () => { - it('renders the truncating branch identity before source control actions', () => { - const markup = renderToolbar() - const branchIndex = markup.indexOf('brennanb2025/source-control-branch-name') - const filterIndex = markup.indexOf('data-testid="source-control-filter-toggle"') - - expect(branchIndex).toBeGreaterThan(-1) - expect(filterIndex).toBeGreaterThan(branchIndex) - expect(markup).toContain('aria-label="Current branch: brennanb2025/source-control-branch-name"') - expect(markup).toContain('tabindex="0"') - expect(markup).toContain('min-w-0 truncate') - expect(markup).not.toContain('Create PR') - }) - - it('renders detached HEAD in the same identity slot', () => { - const markup = renderToolbar({ - kind: 'detached', - shortHead: '8cec248', - sidebarLabel: 'Detached HEAD @ 8cec248', - sourceControlLabel: 'Detached HEAD · 8cec248', - tooltip: 'Detached HEAD at 8cec248. You are viewing a commit, not a branch.' - }) - const identityIndex = markup.indexOf('Detached HEAD · 8cec248') - const filterIndex = markup.indexOf('data-testid="source-control-filter-toggle"') - - expect(markup).not.toContain('aria-label="Current branch:') - expect(identityIndex).toBeGreaterThan(-1) - expect(filterIndex).toBeGreaterThan(identityIndex) - expect(markup).toContain( - 'aria-label="Detached HEAD at 8cec248. You are viewing a commit, not a branch."' - ) - expect(markup).toContain('tabindex="0"') - expect(markup).toContain('lucide-git-commit-horizontal') - }) - - it('keeps the identity slot empty until git identity is known', () => { - const markup = renderToolbar(null) - - expect(markup).not.toContain('aria-label="Current branch:') - expect(markup).not.toContain('Detached HEAD') - }) -}) diff --git a/src/renderer/src/components/right-sidebar/source-control-header-toolbar.tsx b/src/renderer/src/components/right-sidebar/source-control-header-toolbar.tsx index 9c8a32397fe..3cac51e1e9f 100644 --- a/src/renderer/src/components/right-sidebar/source-control-header-toolbar.tsx +++ b/src/renderer/src/components/right-sidebar/source-control-header-toolbar.tsx @@ -1,16 +1,17 @@ import React, { useCallback, useEffect, useRef } from 'react' -import { GitBranch, Search, X } from 'lucide-react' +import { GitPullRequestArrow, Loader2, Search, X } from 'lucide-react' import type { GitBranchCompareSummary, GitUpstreamStatus, SourceControlViewMode } from '../../../../shared/types' +import type { HostedReviewInfo } from '../../../../shared/hosted-review' +import type { PrimaryAction } from './source-control-primary-action' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' -import { DetachedHeadBadge } from '@/components/DetachedHeadBadge' -import type { WorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display' +import { HostedReviewHeaderLink, HostedReviewIcon } from './hosted-review-header-chrome' import { shouldShowSourceControlBranchContextRow, SourceControlBranchContextRow @@ -18,11 +19,16 @@ import { import { SourceControlHeaderOverflowMenu } from './source-control-header-overflow-menu' type SourceControlHeaderToolbarProps = { - gitIdentityDisplay: WorktreeGitIdentityDisplay | null filterQuery: string filterExpanded: boolean onFilterQueryChange: (value: string) => void onFilterExpandedChange: (expanded: boolean) => void + visibleCreatePrHeaderAction: PrimaryAction | null + hostedReview: HostedReviewInfo | null + isCreatePrIntentInFlight: boolean + isCreatingPr: boolean + onCreatePrHeaderClick: () => void + onOpenHostedReviewInChecks: () => void sourceControlViewMode: SourceControlViewMode viewModeToggleDisabled: boolean onToggleViewMode: () => void @@ -37,45 +43,65 @@ type SourceControlHeaderToolbarProps = { manualReviewUrl?: string | null } -function SourceControlGitIdentityLabel({ - display +function HostedReviewToolbarLink({ + review, + onOpenHostedReviewInChecks, + compact }: { - display: WorktreeGitIdentityDisplay + review: HostedReviewInfo + onOpenHostedReviewInChecks: () => void + compact?: boolean }): React.JSX.Element { - if (display.kind === 'detached') { - return ( - - - - ) - } - - const branchName = display.branchName - const label = translate( - 'auto.components.right.sidebar.SourceControl.a4e93c21d7', - 'Current branch: {{value0}}', - { value0: branchName } + return ( +
+ + +
) +} +function CreatePrHeaderButton({ + action, + isCreatePrIntentInFlight, + isCreatingPr, + onClick +}: { + action: PrimaryAction + isCreatePrIntentInFlight: boolean + isCreatingPr: boolean + onClick: () => void +}): React.JSX.Element { return ( - - - - {branchName} + + {action.title} ) @@ -98,11 +124,16 @@ function renderOverflowMenu( } export function SourceControlHeaderToolbar({ - gitIdentityDisplay, filterQuery, filterExpanded, onFilterQueryChange, onFilterExpandedChange, + visibleCreatePrHeaderAction, + hostedReview, + isCreatePrIntentInFlight, + isCreatingPr, + onCreatePrHeaderClick, + onOpenHostedReviewInChecks, sourceControlViewMode, viewModeToggleDisabled, onToggleViewMode, @@ -165,11 +196,25 @@ export function SourceControlHeaderToolbar({ > {showCollapsedToolbar ? ( <> - {gitIdentityDisplay ? ( - + {hostedReview ? ( + + ) : visibleCreatePrHeaderAction ? ( + ) : (
+

{summary}

+ + ) +} diff --git a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx index da1984cca0e..0e4b1675223 100644 --- a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx +++ b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx @@ -8,6 +8,7 @@ import { SearchableSetting } from './SearchableSetting' import { SettingsSubsectionHeader } from './SettingsFormControls' import { translate } from '@/i18n/i18n' import { getUpdateCheckClickOptions, getUpdateCheckHint } from '@/lib/update-check-click-options' +import { GeneralRemoteServerUpdates } from './GeneralRemoteServerUpdates' export function GeneralUpdateSettingsSection(): React.JSX.Element { const updateStatus = useAppStore((s) => s.updateStatus) @@ -240,6 +241,7 @@ export function GeneralUpdateSettingsSection(): React.JSX.Element { ))}

+ ) } diff --git a/src/renderer/src/components/settings/McpConfigSection.tsx b/src/renderer/src/components/settings/McpConfigSection.tsx index 8e775a5c69b..881cd3eb8bb 100644 --- a/src/renderer/src/components/settings/McpConfigSection.tsx +++ b/src/renderer/src/components/settings/McpConfigSection.tsx @@ -19,6 +19,7 @@ import { McpConfigFileRow, type LoadedMcpConfigInspection } from './McpConfigFil import { McpMissingConfigList } from './McpMissingConfigList' import { loadMcpConfigInspections } from './mcp-config-inspection' import { translate } from '@/i18n/i18n' +import { captureDirectSshMutationExpectation } from '@/lib/ssh-mutation-expectation' type McpConfigSectionProps = { repo: Repo @@ -192,9 +193,17 @@ export function McpConfigSection({ repo }: McpConfigSectionProps): React.JSX.Ele const target = joinPath(targetRootPath, '.mcp.json') try { + const sshExpectation = connectionId + ? captureDirectSshMutationExpectation(useAppStore.getState(), connectionId) + : {} // Why: v1 only creates the root workspace config so we do not need to // guess per-agent directory layouts or mutate agent-specific files. - await window.api.fs.writeFile({ filePath: target, content: MCP_STARTER_CONFIG, connectionId }) + await window.api.fs.writeFile({ + filePath: target, + content: MCP_STARTER_CONFIG, + connectionId, + ...sshExpectation + }) clearCreateConfirmResetTimer() if (mountedRef.current) { setCreateConfirm(false) diff --git a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx index a8c25191172..464b1ca7797 100644 --- a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx +++ b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx @@ -23,7 +23,7 @@ export function NativeChatExperimentalSetting({ return (

{translate( 'auto.components.settings.ExperimentalPane.nativeChat.copy', - 'Adds a native chat view you can switch to from supported agent terminal panes. Experimental while we tune transcript fidelity, streaming, and terminal parity.' + 'Adds a Chat UI view you can switch to from supported agent terminal panes. Experimental while we tune transcript fidelity, streaming, and terminal parity.' )}

@@ -48,7 +48,7 @@ export function NativeChatExperimentalSetting({ checked={nativeChatEnabled} ariaLabel={translate( 'auto.components.settings.ExperimentalPane.nativeChat.toggleLabel', - 'Toggle native chat' + 'Toggle Chat UI' )} onChange={() => updateSettings({ @@ -85,7 +85,7 @@ export function NativeChatExperimentalSetting({ {translate( 'auto.components.settings.ExperimentalPane.nativeChat.defaultViewNative', - 'Native chat' + 'Chat UI' )} diff --git a/src/renderer/src/components/settings/RemoteServerUpdateDialog.test.tsx b/src/renderer/src/components/settings/RemoteServerUpdateDialog.test.tsx new file mode 100644 index 00000000000..0daaaedf1bc --- /dev/null +++ b/src/renderer/src/components/settings/RemoteServerUpdateDialog.test.tsx @@ -0,0 +1,115 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { RemoteServerUpdateEntry } from '@/runtime/remote-server-update-coordinator' +import { RemoteServerUpdateDialog } from './RemoteServerUpdateDialog' + +const currentEntry: RemoteServerUpdateEntry = { + environmentId: 'server-a', + name: 'Test server A', + phase: 'current', + currentVersion: '1.4.150-rc.0', + targetVersion: null, + progress: null, + runtimeId: 'runtime-a', + liveTabCount: 0, + liveLeafCount: 0, + support: null, + error: null +} + +const storeMock = vi.hoisted(() => ({ + state: { + remoteServerUpdateDialogOpen: true, + remoteServerUpdates: new Map(), + remoteServerUpdatesChecking: false, + remoteServerUpdatesRunning: false, + setRemoteServerUpdateDialogOpen: vi.fn(), + refreshRemoteServerUpdates: vi.fn(async () => {}), + startRemoteServerUpdates: vi.fn(async () => {}) + } +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: typeof storeMock.state) => unknown) => selector(storeMock.state) +})) + +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) => + open ?
{children}
: null, + DialogContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogDescription: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogFooter: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: React.ReactNode }) =>
{children}
+})) + +describe('RemoteServerUpdateDialog', () => { + beforeEach(() => { + storeMock.state.remoteServerUpdates = new Map([[currentEntry.environmentId, currentEntry]]) + storeMock.state.remoteServerUpdatesChecking = false + storeMock.state.remoteServerUpdatesRunning = false + storeMock.state.refreshRemoteServerUpdates.mockClear() + storeMock.state.startRemoteServerUpdates.mockClear() + }) + + it('checks on open and only shows an update action when one is available', async () => { + const container = document.createElement('div') + const root = createRoot(container) + + await act(async () => root.render()) + + expect(storeMock.state.refreshRemoteServerUpdates).toHaveBeenCalledOnce() + expect(container.textContent).toContain('All servers are up to date.') + expect(container.textContent).not.toContain('Check for Server Updates') + expect(container.textContent).not.toContain('Update server') + + await act(async () => root.unmount()) + }) + + it('shows the update action after a check finds an available server', async () => { + storeMock.state.remoteServerUpdates = new Map([ + [ + currentEntry.environmentId, + { ...currentEntry, phase: 'available', targetVersion: '1.4.151' } + ] + ]) + const container = document.createElement('div') + const root = createRoot(container) + + await act(async () => root.render()) + + expect(container.textContent).not.toContain('Check for Server Updates') + expect(container.textContent).toContain('Update this server') + expect(container.textContent).not.toContain('Update all') + + await act(async () => root.unmount()) + }) + + it('offers an explicit batch action when multiple servers can update', async () => { + const secondEntry: RemoteServerUpdateEntry = { + ...currentEntry, + environmentId: 'server-b', + name: 'Test server B' + } + storeMock.state.remoteServerUpdates = new Map([ + [ + currentEntry.environmentId, + { ...currentEntry, phase: 'available', targetVersion: '1.4.151' } + ], + [secondEntry.environmentId, { ...secondEntry, phase: 'available', targetVersion: '1.4.151' }] + ]) + const container = document.createElement('div') + const root = createRoot(container) + + await act(async () => root.render()) + + const labels = [...container.querySelectorAll('button')].map((button) => button.textContent) + expect(labels.filter((label) => label === 'Update this server')).toHaveLength(2) + expect(labels).toContain('Update all 2 servers') + + await act(async () => root.unmount()) + }) +}) diff --git a/src/renderer/src/components/settings/RemoteServerUpdateDialog.tsx b/src/renderer/src/components/settings/RemoteServerUpdateDialog.tsx new file mode 100644 index 00000000000..918eec3344a --- /dev/null +++ b/src/renderer/src/components/settings/RemoteServerUpdateDialog.tsx @@ -0,0 +1,250 @@ +import type React from 'react' +import { AlertTriangle, Loader2 } from 'lucide-react' +import { useEffect } from 'react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Progress } from '@/components/ui/progress' +import { useAppStore } from '@/store' +import type { RemoteServerUpdateEntry } from '@/runtime/remote-server-update-coordinator' +import { translate } from '@/i18n/i18n' +import { + getRemoteServerManualUpdateHelp, + RemoteServerUpdateStatus +} from './RemoteServerUpdateStatus' + +function versionDescription(entry: RemoteServerUpdateEntry): string { + if (entry.currentVersion && entry.targetVersion && entry.currentVersion !== entry.targetVersion) { + return `${entry.currentVersion} → ${entry.targetVersion}` + } + if (entry.currentVersion) { + return `v${entry.currentVersion}` + } + return translate( + 'auto.components.settings.RemoteServerUpdateDialog.versionUnavailable', + 'Version unavailable' + ) +} + +function entryHelp(entry: RemoteServerUpdateEntry): string | null { + if (entry.error) { + return entry.error + } + if (entry.phase === 'manual') { + return getRemoteServerManualUpdateHelp(entry) + } + if (entry.phase === 'restarting') { + return translate( + 'auto.components.settings.RemoteServerUpdateDialog.restartingHelp', + 'Waiting for the replacement server to reconnect on the new version.' + ) + } + return null +} + +function ServerUpdateRow({ + entry, + disabled, + onUpdate +}: { + entry: RemoteServerUpdateEntry + disabled: boolean + onUpdate: () => void +}): React.JSX.Element { + const canUpdate = entry.phase === 'available' || entry.phase === 'failed' + const help = entryHelp(entry) + return ( +
+
+
+
+ {entry.name} + +
+

{versionDescription(entry)}

+
+ {canUpdate ? ( + + ) : null} +
+ {entry.phase === 'downloading' && entry.progress !== null ? ( + + ) : null} + {help ? ( +

+ {help} +

+ ) : null} +
+ ) +} + +export function RemoteServerUpdateDialog(): React.JSX.Element { + const open = useAppStore((state) => state.remoteServerUpdateDialogOpen) + const setOpen = useAppStore((state) => state.setRemoteServerUpdateDialogOpen) + const entryMap = useAppStore((state) => state.remoteServerUpdates) + const entries = [...entryMap.values()] + const checking = useAppStore((state) => state.remoteServerUpdatesChecking) + const running = useAppStore((state) => state.remoteServerUpdatesRunning) + const refresh = useAppStore((state) => state.refreshRemoteServerUpdates) + const start = useAppStore((state) => state.startRemoteServerUpdates) + const eligible = entries.filter( + (entry) => entry.phase === 'available' || entry.phase === 'failed' + ) + const allCurrent = + entries.length > 0 && + !checking && + !running && + entries.every((entry) => entry.phase === 'current' || entry.phase === 'updated') + const liveTabCount = eligible.reduce((total, entry) => total + entry.liveTabCount, 0) + const liveLeafCount = eligible.reduce((total, entry) => total + entry.liveLeafCount, 0) + const liveTabLabel = + liveTabCount === 1 + ? translate('auto.components.settings.RemoteServerUpdateDialog.liveTabOne', '1 live tab') + : translate( + 'auto.components.settings.RemoteServerUpdateDialog.liveTabs', + '{{value0}} live tabs', + { value0: liveTabCount } + ) + const liveLeafLabel = + liveLeafCount === 1 + ? translate('auto.components.settings.RemoteServerUpdateDialog.livePaneOne', '1 live pane') + : translate( + 'auto.components.settings.RemoteServerUpdateDialog.livePanes', + '{{value0}} live panes', + { value0: liveLeafCount } + ) + + useEffect(() => { + if (open) { + void refresh() + } + }, [open, refresh]) + + return ( + + + + + {translate( + 'auto.components.settings.RemoteServerUpdateDialog.title', + 'Update Remote Orca Servers' + )} + + + {translate( + 'auto.components.settings.RemoteServerUpdateDialog.description', + 'Review paired servers and update supported installs from this Orca client.' + )} + + + + {eligible.length > 0 && (liveTabCount > 0 || liveLeafCount > 0) ? ( +
+ +

+ {translate( + 'auto.components.settings.RemoteServerUpdateDialog.restartWarning', + 'Updating restarts these servers. {{value0}} and {{value1}} may briefly disconnect.', + { value0: liveTabLabel, value1: liveLeafLabel } + )} +

+
+ ) : null} + +
+ {entries.length === 0 ? ( +
+ {checking ? ( + + + {translate( + 'auto.components.settings.RemoteServerUpdateDialog.checking', + 'Checking paired servers…' + )} + + ) : ( + translate( + 'auto.components.settings.RemoteServerUpdateDialog.empty', + 'No paired Remote Orca Servers.' + ) + )} +
+ ) : ( +
+ {entries.map((entry) => ( + void start([entry.environmentId])} + /> + ))} +
+ )} +
+ + {checking ? ( +
+ + {translate( + 'auto.components.settings.RemoteServerUpdateDialog.checking', + 'Checking paired servers…' + )} +
+ ) : allCurrent ? ( +

+ {translate( + 'auto.components.settings.RemoteServerUpdateDialog.noUpdates', + 'All servers are up to date.' + )} +

+ ) : null} + + {eligible.length > 1 ? ( + + + + ) : null} +
+
+ ) +} + +export default RemoteServerUpdateDialog diff --git a/src/renderer/src/components/settings/RemoteServerUpdateStatus.tsx b/src/renderer/src/components/settings/RemoteServerUpdateStatus.tsx new file mode 100644 index 00000000000..40dc49dcc5f --- /dev/null +++ b/src/renderer/src/components/settings/RemoteServerUpdateStatus.tsx @@ -0,0 +1,112 @@ +import type React from 'react' +import { AlertCircle, CheckCircle2, Download, Loader2, ServerOff, Wrench } from 'lucide-react' +import type { + RemoteServerUpdateEntry, + RemoteServerUpdatePhase +} from '@/runtime/remote-server-update-coordinator' +import { Badge } from '@/components/ui/badge' +import { translate } from '@/i18n/i18n' + +export function getRemoteServerUpdatePhaseLabel(phase: RemoteServerUpdatePhase): string { + switch (phase) { + case 'checking': + return translate('auto.components.settings.RemoteServerUpdateStatus.checking', 'Checking…') + case 'available': + return translate( + 'auto.components.settings.RemoteServerUpdateStatus.available', + 'Update available' + ) + case 'current': + return translate('auto.components.settings.RemoteServerUpdateStatus.current', 'Up to date') + case 'manual': + return translate('auto.components.settings.RemoteServerUpdateStatus.manual', 'Manual update') + case 'offline': + return translate('auto.components.settings.RemoteServerUpdateStatus.offline', 'Offline') + case 'queued': + return translate('auto.components.settings.RemoteServerUpdateStatus.queued', 'Queued') + case 'checking-update': + return translate( + 'auto.components.settings.RemoteServerUpdateStatus.checkingUpdate', + 'Checking update…' + ) + case 'downloading': + return translate( + 'auto.components.settings.RemoteServerUpdateStatus.downloading', + 'Downloading…' + ) + case 'restarting': + return translate( + 'auto.components.settings.RemoteServerUpdateStatus.restarting', + 'Restarting…' + ) + case 'updated': + return translate('auto.components.settings.RemoteServerUpdateStatus.updated', 'Updated') + case 'failed': + return translate('auto.components.settings.RemoteServerUpdateStatus.failed', 'Update failed') + } +} + +function phaseIcon(phase: RemoteServerUpdatePhase): React.JSX.Element { + switch (phase) { + case 'checking': + case 'queued': + case 'checking-update': + case 'restarting': + return + case 'downloading': + return + case 'current': + case 'updated': + return + case 'manual': + return + case 'offline': + return + case 'failed': + return + case 'available': + return + } +} + +export function RemoteServerUpdateStatus({ + entry, + compact = false +}: { + entry: RemoteServerUpdateEntry + compact?: boolean +}): React.JSX.Element { + const progress = + entry.phase === 'downloading' && entry.progress !== null + ? ` ${Math.round(entry.progress)}%` + : '' + return ( + + {phaseIcon(entry.phase)} + {getRemoteServerUpdatePhaseLabel(entry.phase)} + {progress} + + ) +} + +export function getRemoteServerManualUpdateHelp(entry: RemoteServerUpdateEntry): string { + if (entry.support?.reason === 'manual-service-update-required') { + return translate( + 'auto.components.settings.RemoteServerUpdateStatus.serviceManagerHelp', + 'Update Orca through the service manager that starts this server.' + ) + } + if (entry.support?.reason === 'unpackaged-build') { + return translate( + 'auto.components.settings.RemoteServerUpdateStatus.unpackedHelp', + 'Development builds must be updated from their source checkout.' + ) + } + return translate( + 'auto.components.settings.RemoteServerUpdateStatus.legacyHelp', + 'Update this server manually once to enable remote updates.' + ) +} diff --git a/src/renderer/src/components/settings/RepositoryHostSetupsSection.test.tsx b/src/renderer/src/components/settings/RepositoryHostSetupsSection.test.tsx index 665ef0bcc7e..073f68a6565 100644 --- a/src/renderer/src/components/settings/RepositoryHostSetupsSection.test.tsx +++ b/src/renderer/src/components/settings/RepositoryHostSetupsSection.test.tsx @@ -69,11 +69,12 @@ afterEach(() => { useAppStore.setState(useAppStore.getInitialState(), true) }) -function renderSection(repo: Repo): void { +function renderSection(repo: Repo, selectedProjectSetupId?: string): void { act(() => { root.render( React.createElement(RepositoryHostSetupsSection, { repo, + selectedProjectSetupId, forceVisible: true, searchQuery: '', searchEntries: [] @@ -201,12 +202,142 @@ describe('RepositoryHostSetupsSection', () => { // The single project pane switches host in place — no navigation. expect(setSettingsProjectHostSelection).toHaveBeenCalledWith( 'github:stablyai/orca', - toSshExecutionHostId('openclaw 2') + toSshExecutionHostId('openclaw 2'), + 'remote-repo' ) expect(openSettingsPage).not.toHaveBeenCalled() expect(openSettingsTarget).not.toHaveBeenCalled() }) + it('keeps nested SSH setups distinct and derives readiness from their HUB owner', () => { + const remoteRepo = makeRepo({ + id: 'remote-repo', + displayName: 'Orca', + path: '/srv/orca', + executionHostId: 'runtime:hub' + }) + useAppStore.setState({ + repos: [remoteRepo], + projects: [makeProject({ id: 'github:stablyai/orca', sourceRepoIds: ['remote-repo'] })], + projectHostSetups: [ + makeSetup({ + id: 'direct-setup', + projectId: 'github:stablyai/orca', + repoId: 'remote-repo', + hostId: 'runtime:hub', + executionHostId: 'ssh:direct', + runtimeOwnerEnvironmentId: 'hub', + path: '/srv/orca' + }), + makeSetup({ + id: 'jump-setup', + projectId: 'github:stablyai/orca', + repoId: 'remote-repo', + hostId: 'runtime:hub', + executionHostId: 'ssh:jump', + runtimeOwnerEnvironmentId: 'hub', + path: '/srv/orca' + }) + ], + runtimeStatusByEnvironmentId: new Map([ + [ + 'hub', + { + checkedAt: 1, + appVersion: '1.8.0', + status: { + runtimeId: 'runtime-hub', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: 1, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: 1, + capabilities: [] + } + } + ] + ]), + sshStateByEnvironment: new Map([ + [ + 'hub', + { + connectionStates: new Map([ + [ + 'direct', + { + targetId: 'direct', + status: 'connected', + error: null, + reconnectAttempt: 0 + } + ], + [ + 'jump', + { + targetId: 'jump', + status: 'disconnected', + error: null, + reconnectAttempt: 0 + } + ] + ]), + targetLabels: new Map([ + ['direct', 'Direct box'], + ['jump', 'Jump box'] + ]), + removedTargetLabels: new Map(), + targetsHydrated: true + } + ] + ]) + }) + + renderSection(remoteRepo, 'jump-setup') + + expect(container.textContent).toContain('Direct box') + expect(container.textContent).toContain('Jump box') + expect(container.textContent).toContain('Ready') + expect(container.textContent).toContain('Disconnected') + expect(findButton('Open')).toBeTruthy() + const currentSetup = container.querySelector('[data-current="true"]') + expect(currentSetup?.textContent).toContain('Jump box') + expect(currentSetup?.textContent).toContain('Disconnected') + expect(currentSetup?.textContent).not.toContain('Direct box') + expect(currentSetup?.textContent).not.toContain('Ready') + }) + + it('shows HUB-local setups as disconnected when their owning runtime is unreachable', () => { + const remoteRepo = makeRepo({ + id: 'remote-repo', + displayName: 'Orca', + path: '/srv/orca', + executionHostId: 'runtime:hub' + }) + useAppStore.setState({ + repos: [remoteRepo], + projects: [makeProject({ id: 'github:stablyai/orca', sourceRepoIds: ['remote-repo'] })], + projectHostSetups: [ + makeSetup({ + id: 'hub-local-setup', + projectId: 'github:stablyai/orca', + repoId: 'remote-repo', + hostId: 'runtime:hub', + executionHostId: 'local', + runtimeOwnerEnvironmentId: 'hub', + path: '/srv/orca' + }) + ], + runtimeStatusByEnvironmentId: new Map([['hub', { checkedAt: 1, status: null }]]) + }) + + renderSection(remoteRepo) + + expect(container.textContent).toContain('Disconnected') + expect(container.textContent).not.toContain('Ready') + }) + it('removes independent setup metadata instead of opening an empty repo target', async () => { const deleteProjectHostSetup = vi.fn().mockResolvedValue({ project: makeProject({ id: 'github:stablyai/orca' }), diff --git a/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx b/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx index 4506d04a0a8..2008bd70154 100644 --- a/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx +++ b/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx @@ -1,12 +1,12 @@ import { useMemo, useState } from 'react' import { getExecutionHostLabel, - getRepoExecutionHostId, + parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' import { buildExecutionHostRegistry } from '../../../../shared/execution-host-registry' import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' -import type { Repo } from '../../../../shared/types' +import type { ProjectHostSetup, Repo } from '../../../../shared/types' import { useAppStore } from '../../store' import { getProjectHostSetupProjectionFromState } from '../../store/selectors' import { cn } from '../../lib/utils' @@ -20,16 +20,40 @@ import type { SettingsSearchEntry } from './settings-search' import { translate } from '@/i18n/i18n' import { buildSetupHostOptions, getSetupStateLabel } from './repository-host-setup-options' import { RepositoryHostSetupActions } from './RepositoryHostSetupActions' +import { + selectRuntimeAwareSshStatus, + selectRuntimeAwareSshTargetLabel +} from '@/store/slices/runtime-environment-ssh' type RepositoryHostSetupsSectionProps = { repo: Repo + selectedProjectSetupId?: string forceVisible: boolean searchQuery: string searchEntries: SettingsSearchEntry[] } +function setupsByOwnedExecutionHost( + setups: readonly ProjectHostSetup[], + selectedSetupId: string +): ProjectHostSetup[] { + const byHost = new Map() + for (const setup of setups) { + const key = JSON.stringify([ + setup.hostId, + setup.executionHostId ?? setup.hostId, + setup.runtimeOwnerEnvironmentId ?? null + ]) + if (!byHost.has(key) || setup.id === selectedSetupId) { + byHost.set(key, setup) + } + } + return [...byHost.values()] +} + export function RepositoryHostSetupsSection({ repo, + selectedProjectSetupId, forceVisible, searchQuery, searchEntries @@ -47,6 +71,9 @@ export function RepositoryHostSetupsSection({ const settings = useAppStore((state) => state.settings) const runtimeEnvironments = useAppStore((state) => state.runtimeEnvironments) const runtimeStatusByEnvironmentId = useAppStore((state) => state.runtimeStatusByEnvironmentId) + const sshStateByEnvironment = useAppStore((state) => state.sshStateByEnvironment) + const removedSshTargetLabels = useAppStore((state) => state.removedSshTargetLabels) + const sshTargetsHydrated = useAppStore((state) => state.sshTargetsHydrated) const hostLabelOverrides = useMemo(() => getHostDisplayLabelOverrides(settings), [settings]) const hostOptions = useMemo( () => @@ -72,16 +99,29 @@ export function RepositoryHostSetupsSection({ const projectHostSetupProjection = useAppStore((state) => getProjectHostSetupProjectionFromState(state) ) - const selectedHostId = getRepoExecutionHostId(repo) - const selectedProjectHostSetup = projectHostSetupProjection.setups.find( - (setup) => setup.repoId === repo.id && setup.hostId === selectedHostId + const repoProjectHostSetup = projectHostSetupProjection.setups.find( + (setup) => setup.repoId === repo.id ) + const selectedProjectHostSetup = + projectHostSetupProjection.setups.find( + (setup) => + setup.id === selectedProjectSetupId && + setup.repoId === repo.id && + setup.projectId === repoProjectHostSetup?.projectId + ) ?? repoProjectHostSetup const projectHostSetups = selectedProjectHostSetup - ? projectHostSetupProjection.setups.filter( - (setup) => setup.projectId === selectedProjectHostSetup.projectId + ? setupsByOwnedExecutionHost( + projectHostSetupProjection.setups.filter( + (setup) => setup.projectId === selectedProjectHostSetup.projectId + ), + selectedProjectHostSetup.id ) : [] const openableProjectHostSetups = projectHostSetups.filter((setup) => setup.repoId.trim()) + const switchableProjectHostSetups = setupsByOwnedExecutionHost( + openableProjectHostSetups, + selectedProjectHostSetup?.id ?? '' + ) const setupHostOptions = buildSetupHostOptions({ projectHostSetups, hostOptions @@ -96,6 +136,11 @@ export function RepositoryHostSetupsSection({ setSettingsProjectHostSelection(projectId, hostId) } } + const selectSetup = (setup: ProjectHostSetup) => { + if (projectId) { + setSettingsProjectHostSelection(projectId, setup.hostId, setup.id) + } + } if ( (projectHostSetups.length <= 1 && setupHostOptions.length === 0) || (!forceVisible && !matchesSettingsSearch(searchQuery, searchEntries)) @@ -119,29 +164,34 @@ export function RepositoryHostSetupsSection({ - {openableProjectHostSetups.length > 1 ? ( + {switchableProjectHostSetups.length > 1 ? (
{translate('auto.components.settings.RepositoryPane.viewingHost', 'Viewing host')}