mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/renderer-heap-highwater
# Conflicts: # resources/skills/release-mapping.json
This commit is contained in:
@@ -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/*
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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 |
@@ -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,
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,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')
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+3
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"']]))
|
||||
|
||||
@@ -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'}`
|
||||
}
|
||||
|
||||
@@ -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
@@ -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') {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 }[] = []
|
||||
|
||||
@@ -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] },
|
||||
|
||||
@@ -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
@@ -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
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
}))
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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
+588
-173
File diff suppressed because it is too large
Load Diff
@@ -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']
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
@@ -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 } })
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -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 } })
|
||||
})
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user