Merge remote-tracking branch 'origin/main' into brennanb2025/renderer-heap-highwater

# Conflicts:
#	resources/skills/release-mapping.json
This commit is contained in:
Brennan Benson
2026-07-22 21:19:26 -07:00
373 changed files with 23594 additions and 2832 deletions
+1
View File
@@ -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/*
+1
View File
@@ -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
+7
View File
@@ -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(
+80 -35
View File
@@ -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 <thread>
#include <sys/types.h>
@@ -237,13 +239,23 @@ pty_getproc(int, char *);
@@ -47,6 +49,25 @@
#include <termios.h>
#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;
}
@@ -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() {
@@ -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)
+394
View File
@@ -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 <n> <NAME>`.
*/
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 <lib>`). */
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
}
@@ -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 <n> 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 })
}
})
})
+2
View File
@@ -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",
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 7.5m">
<title>downloads: 7.5m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 7.6m">
<title>downloads: 7.6m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">7.5m</text>
<text x="93.5" y="14">7.5m</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">7.6m</text>
<text x="93.5" y="14">7.6m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 943 B

After

Width:  |  Height:  |  Size: 943 B

+4 -2
View File
@@ -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
@@ -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.
@@ -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,
+5 -5
View File
@@ -23,7 +23,7 @@ export default function NativeChatSettingsScreen() {
>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<Text style={styles.heading}>Native chat</Text>
<Text style={styles.heading}>Chat UI</Text>
</View>
<ScrollView
@@ -33,17 +33,17 @@ export default function NativeChatSettingsScreen() {
<Text style={styles.groupHeading}>DEFAULT VIEW</Text>
<Text style={styles.groupDescription}>
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.
</Text>
<View style={[styles.section, styles.sectionTopGap]}>
<View style={styles.row}>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Open sessions in native chat</Text>
<Text style={styles.rowLabel}>Open sessions in Chat UI</Text>
<Text style={styles.rowSublabel}>{chatDefault ? 'On' : 'Off'}</Text>
</View>
<Switch
accessibilityLabel="Open sessions in native chat"
accessibilityLabel="Open sessions in Chat UI"
value={chatDefault}
onValueChange={(next) => setDefaultView(next ? 'chat' : 'terminal')}
trackColor={{ false: colors.bgRaised, true: colors.textSecondary }}
+1 -1
View File
@@ -120,7 +120,7 @@ export default function SettingsScreen() {
onPress={() => router.push('/native-chat-settings')}
>
<MessageSquare size={16} color={colors.textSecondary} />
<Text style={styles.rowLabel}>Native chat</Text>
<Text style={styles.rowLabel}>Chat UI</Text>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
<View style={styles.separator} />
@@ -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()
})
@@ -51,7 +51,7 @@ export function MobileOnboardingPage({
</Text>
<Text style={styles.body}>
{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.'}
</Text>
</View>
@@ -88,8 +88,8 @@ function SessionViewChoices({
return (
<>
<ChoiceButton
label="Use native chat"
accessibilityLabel="Open sessions in native chat"
label="Use Chat UI"
accessibilityLabel="Open sessions in Chat UI"
primary
busy={busyChoice === 'chat'}
disabled={disabled}
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
import { sendMobileNativeChatPermissionResponse } from './mobile-native-chat-permission-send'
describe('sendMobileNativeChatPermissionResponse', () => {
@@ -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')
})
})
@@ -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<boolean> {
}): Promise<MobileNativeChatSendOutcome> {
// 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]
)
@@ -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',
+12 -4
View File
@@ -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'
}
}
@@ -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({
@@ -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)
}
@@ -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())
@@ -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<boolean> => {
let sawUnknownOutcome = false
const sendTerminal = async (body: string, enter: boolean): Promise<boolean> => {
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<boolean> =>
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
}
@@ -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,
+1 -8
View File
@@ -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}`
@@ -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 &&
@@ -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
)
})
})
@@ -23,6 +23,8 @@ type TerminalViewportRefitOptions = {
terminalFrameHeightRef: RefObject<number>
viewportRef: RefObject<TerminalViewportDims | null>
viewportMeasuredRef: RefObject<boolean>
// 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<boolean>
clientRef: RefObject<RpcClient | null>
deviceTokenRef: RefObject<string | null>
initializedHandlesRef: RefObject<Set<string>>
@@ -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,
@@ -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()
}
})
})
@@ -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)
@@ -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<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise) => {
let reject!: (error: Error) => void
const promise = new Promise<T>((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<RpcResponse>()
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<RpcResponse>()
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')
@@ -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')
},
+1
View File
@@ -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",
+3 -3
View File
@@ -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
+13
View File
@@ -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
}
}
]
}
+6 -1
View File
@@ -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
+6 -1
View File
@@ -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
+25 -2
View File
@@ -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<TestRuntime> {
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 }
}
@@ -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.<key>) 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.<key>
// entries wherever the [tui] placement rule puts them.
export function upsertPromotedSettingsInContent(
content: string,
updates: Map<string, string>
): string {
const topLevelUpdates = new Map<string, string>()
const tuiUpdates = new Map<string, string>()
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, string>
): string {
const lines = content.split('\n')
let state = createTomlLineScanState()
let preambleEnd = lines.length
const keyLineIndexes = new Map<string, number>()
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<string, number>
dottedKeyIndexes: Map<string, number>
hasBareTuiTable: boolean
hasDottedTuiKey: boolean
blocksNewTuiTable: boolean
blockedAbsentKeys: Set<string>
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, string>): 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.<key> 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<string, string>): 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<string, number>()
const dottedKeyIndexes = new Map<string, number>()
const blockedAbsentKeys = new Set<string>()
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'}`
}
@@ -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<typeof actual.writeFileAtomically>) => {
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"']]))
+109 -67
View File
@@ -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.<key> 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.<key>` form a user may hand-author;
// inside the first `[tui]` table body it recognizes the bare `<key>` 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<string, string> | nu
}
}
// Why: only top-level preamble keys are scanned; rewriting nested [profiles.*] tables isn't worth the risk here.
function readTopLevelSettingValues(configPath: string): Map<string, TopLevelSettingValue> {
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<string, TopLevelSettingValue> {
const result = new Map<string, TopLevelSettingValue>()
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<string, string> = {}
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<string, string>()
for (const key of PROMOTED_CODEX_SETTING_KEYS) {
const runtimeValues = readPromotedSettingValues(runtimeTomlPath)
const changedRuntimeValues = new Map<string, string>()
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<string, string>()
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, string>
): string {
const lines = content.split('\n')
let state = createTomlLineScanState()
let preambleEnd = lines.length
const keyLineIndexes = new Map<string, number>()
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'}`
}
+67
View File
@@ -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
}
+22 -2
View File
@@ -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
? {
@@ -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') {
+16 -2
View File
@@ -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 () => {
@@ -45,7 +45,8 @@ export async function uploadSshImportDirectory(
localDir: string,
remoteDir: string,
rootRealPath: string,
remotePathFlavor: RemotePathFlavor
remotePathFlavor: RemotePathFlavor,
assertCurrent?: () => void
): Promise<void> {
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 })
}
}
+49 -1
View File
@@ -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<string, unknown>) =>
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<string, unknown>[]
}>
@@ -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 () => {
+12 -1
View File
@@ -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<string, unknown>) =>
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<string, unknown>[]
}>
beforeEach(() => {
handlers.clear()
resetSshConnectionGenerations()
;[
handleMock,
lstatMock,
+40 -9
View File
@@ -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<string>,
remotePathFlavor: RemotePathFlavor
remotePathFlavor: RemotePathFlavor,
assertCurrent?: () => void
): Promise<ImportItemResult> {
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<string>
reservedNames: Set<string>,
assertCurrent?: () => void
): Promise<string> {
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<void> {
async function ensureDropStagingDir(
provider: IFilesystemProvider,
destDir: string,
assertCurrent?: () => void
): Promise<void> {
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)
}
+128 -3
View File
@@ -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')
+79 -8
View File
@@ -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<void> {
export function registerFilesystemMutationHandlers(store: Store): void {
ipcMain.handle(
'fs:createFile',
async (_event, args: { filePath: string; connectionId?: string }): Promise<void> => {
async (
_event,
args: { filePath: string; connectionId?: string } & SshMutationExpectation
): Promise<void> => {
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<void> => {
async (
_event,
args: { dirPath: string; connectionId?: string } & SshMutationExpectation
): Promise<void> => {
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<void> => {
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<void> => {
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<ResolveDroppedPathsResult> => {
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 }[] = []
+18
View File
@@ -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] },
+20 -2
View File
@@ -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<void> => {
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<void> => {
assertSshMutationExpectation(
args.connectionId,
args.expectedSshTargetId,
args.expectedSshConnectionGeneration,
args.expectedExecutionHostId
)
if (args.connectionId) {
const provider = requireSshFilesystemProvider(args.connectionId)
return provider.deletePath(args.targetPath, args.recursive)
+59 -18
View File
@@ -694,10 +694,14 @@ describe('registerPtyHandlers', () => {
function registerAgentClaimController(): {
spawn: (args: Record<string, unknown>) => Promise<unknown>
write: (ptyId: string, data: string) => boolean
resize: (ptyId: string, cols: number, rows: number) => boolean
} {
let controller:
| {
spawn: (args: Record<string, unknown>) => Promise<unknown>
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',
+24 -6
View File
@@ -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) {
@@ -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
@@ -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()
})
})
@@ -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<never> | 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 }
}
}
@@ -0,0 +1,18 @@
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
import { withRemoteRuntimeTailscaleHint } from '../../shared/remote-runtime-tailscale-hint'
export function withTailscaleHintForResponse<TResult>(
response: RuntimeRpcResponse<TResult>,
endpoint: string
): RuntimeRpcResponse<TResult> {
if (response.ok === true) {
return response
}
return {
...response,
error: {
...response.error,
message: withRemoteRuntimeTailscaleHint(response.error.message, endpoint)
}
}
}
@@ -0,0 +1,12 @@
const generationByEnvironment = new Map<string, number>()
export function getRuntimeEnvironmentTransportGeneration(environmentId: string): number {
return generationByEnvironment.get(environmentId) ?? 0
}
export function advanceRuntimeEnvironmentTransportGeneration(environmentId: string): void {
generationByEnvironment.set(
environmentId,
getRuntimeEnvironmentTransportGeneration(environmentId) + 1
)
}
@@ -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<string, { cacheKey: string; check: Promise<boolean> }>()
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<TResult>(
response: RuntimeRpcResponse<TResult>,
endpoint: string
): RuntimeRpcResponse<TResult> {
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<RuntimeRpcResponse<unknown>> {
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'
}
+142 -1
View File
@@ -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<ArrayBufferLike>) => void = () => {}
let resolveSubscribe: (value: {
requestId: string
close: () => void
sendBinary: (bytes: Uint8Array<ArrayBufferLike>) => 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'))
+50 -17
View File
@@ -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<string, RetainedRemoteRuntimeSubscription>()
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<RuntimeRpcResponse<unknown>> => {
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()
+23
View File
@@ -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')
+112 -16
View File
@@ -225,6 +225,7 @@ import {
getSshPtyProvider,
getPtyIdsForConnection
} from './pty'
import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation'
describe('SSH IPC handlers', () => {
const handlers = new Map<string, (_event: unknown, args: unknown) => 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 })
})
})
+59 -13
View File
@@ -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<string, ConnectAttempt>()
const connectGenerationByTarget = new Map<string, number>()
const pendingTransportReconnects = new Set<string>()
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<SshConnectionState> {
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<SshConnectionState> {
async function doConnect(targetId: string): Promise<SshConnectionState> {
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<void> {
}
relayStateOverrides.clear()
connectInFlight.clear()
connectGenerationByTarget.clear()
pendingTransportReconnects.clear()
resetSshConnectionGenerations()
resetRelayInFlight.clear()
testingTargets.clear()
credentialRequestedForTarget.clear()
@@ -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')
+108 -15
View File
@@ -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<void> }
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<string, WorktreeBaseRepoWatchConfig>,
onEvents: (events: WorktreeBasePollEvent[]) => void,
pollIntervalMs: number,
visibility: WorktreePollerWindowVisibility,
onFullScan?: () => void
): Promise<WorktreeBaseSubscription> {
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<typeof setTimeout> | null = null
let parkedWhileHidden = false
// dir → tick when first seen without a `.git` marker
const pendingMarkers = new Map<string, number>()
for (const [dir, marker] of snapshot.markers) {
@@ -201,9 +249,9 @@ async function startBasePoller(
}
}
const tick = async (): Promise<void> => {
const poll = async (forceFullScan = false): Promise<void> => {
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<void> => {
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<WorktreeBaseSubscription> {
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)
}
@@ -10,6 +10,10 @@ vi.mock('fs/promises', () => ({
}))
vi.mock('./worktree-base-directory-poller', () => ({
createWorktreePollerWindowVisibility: vi.fn(() => ({
isWindowVisible: () => true,
onWindowBecameVisible: () => () => {}
})),
startWorktreeBaseDirectoryPoller: vi.fn()
}))
+14 -10
View File
@@ -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<void> {
}
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)
+159 -85
View File
@@ -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<string> {
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<string | null> {
try {
const s = await stat(path)
return s.isFile() ? statSignature(s) : null
} catch {
return null
}
}
async function pathSignature(path: string): Promise<string | null> {
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<string, string>
indexSignature: string | null
headLogSignature: string | null
}
type GitCommonSnapshot = {
worktreesDirSignature: string | null
worktreesDirSignature: string
entries: Map<string, GitCommonEntrySnapshot>
primarySignatures: Map<string, string>
didFullScan: boolean
}
async function snapshotGitCommonEntry(
entryPath: string,
previous: GitCommonEntrySnapshot | undefined,
forceIndexRead: boolean
forceFullScan: boolean
): Promise<GitCommonEntrySnapshot> {
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<string, string>()
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<GitCommonSnapshot> {
const entriesByPath = new Map<string, GitCommonEntrySnapshot>()
const worktreesDir = join(commonDirPath, 'worktrees')
const worktreesDirSignature = await pathSignature(worktreesDir)
const primarySignatures = includePrimary
? await snapshotPrimaryCheckoutSignatures(commonDirPath)
: new Map<string, string>()
let entries
const [worktreesDirSignature, primarySignatures] = await Promise.all([
dirSignature(worktreesDir),
includePrimary ? snapshotPrimaryCheckoutSignatures(commonDirPath) : new Map<string, string>()
])
// 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<string, GitCommonEntrySnapshot>()
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<WorktreeBaseSubscription> {
@@ -235,39 +265,83 @@ export async function startGitCommonPolling(
let ticking = false
let tickCount = 0
let snapshot = await snapshotGitCommon(commonDirPath, undefined, includePrimary)
let timer: ReturnType<typeof setTimeout> | null = null
let parkedWhileHidden = false
const timer = setInterval(() => {
if (disposed || ticking) {
const tick = async (forceFullScan = false): Promise<void> => {
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()
}
}
}
+265 -4
View File
@@ -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<void>)[] = []
afterEach(async () => {
await Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))
})
async function makePollingCommonDir(): Promise<string> {
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<void> {
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)
})
})
+62 -23
View File
@@ -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<Map<string, number>>,
onEvents: (events: WorktreeBasePollEvent[]) => void,
pollIntervalMs: number,
visibility: WorktreePollerWindowVisibility,
onFullScan?: () => void
): Promise<WorktreeBaseSubscription> {
let disposed = false
let ticking = false
let snapshot = await takeSnapshot()
let timer: ReturnType<typeof setTimeout> | null = null
let parkedWhileHidden = false
const timer = setInterval(() => {
if (disposed || ticking) {
const tick = async (): Promise<void> => {
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<WorktreeBaseSubscription> {
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)
}
+1 -1
View File
@@ -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).
+104
View File
@@ -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')
+77 -53
View File
@@ -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) {
@@ -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<SshPtyDataCallback>
replayListeners: Set<SshPtyReplayCallback>
exitListeners: Set<SshPtyExitCallback>
livePtyIds: Set<string>
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 } : {})
})
}
})
}
@@ -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 () => {
+28 -39
View File
@@ -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<SshPtyDataCallback>()
private replayListeners = new Set<SshPtyReplayCallback>()
private exitListeners = new Set<SshPtyExitCallback>()
private livePtyIds = new Set<string>()
// Why: stale notification callbacks must not outlive a disconnected provider.
private unsubscribeNotifications: (() => void) | null = null
readonly getAppliedSize: NonNullable<IPtyProvider['getAppliedSize']>
@@ -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<void> {
@@ -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<string> {
+32 -2
View File
@@ -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()
+8 -1
View File
@@ -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 = {
@@ -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(
@@ -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([])
})
})
@@ -190,6 +190,10 @@ function activeTabId(response: Record<string, unknown>): string | null {
return (response.result as RuntimeMobileSessionTabsResult | undefined)?.activeTabId ?? null
}
function snapshotVersion(response: Record<string, unknown>): 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')
+145 -11
View File
@@ -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()
})
+145 -21
View File
@@ -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<Promise<void>>()
type RuntimeFileWatcherLease = {
suspend(): Promise<void>
resume(): Promise<void>
@@ -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(
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+9
View File
@@ -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
}
@@ -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']
])
})
})
+59
View File
@@ -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)
}
+11
View File
@@ -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'],
+3
View File
@@ -51,6 +51,9 @@ const RUNTIME_PASSTHROUGH_CODES: ReadonlySet<string> = 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
])
@@ -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 } })
})
}
}),
+30 -3
View File
@@ -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 } })
})
+81 -16
View File
@@ -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',
+3 -1
View File
@@ -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
]
+77 -3
View File
@@ -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 () => {

Some files were not shown because too many files have changed in this diff Show More