mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 00:03:20 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/agent-row-conversation-name
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,18 +22,21 @@
|
||||
"../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",
|
||||
"../src/main/codex/codex-managed-trust-reconciliation.ts",
|
||||
"../src/main/codex/codex-process-exit-deadline.ts",
|
||||
"../src/main/codex/codex-trust-config-rollback.ts",
|
||||
"../src/main/codex/codex-trust-grant-telemetry.ts",
|
||||
"../src/main/codex/codex-trust-grant-host.ts",
|
||||
"../src/main/codex/codex-trust-grant-ledger.ts",
|
||||
"../src/main/codex/codex-user-hook-trust-rebase-client.ts",
|
||||
"../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.
|
||||
@@ -9,13 +9,14 @@ import {
|
||||
Alert
|
||||
} from 'react-native'
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { ChevronLeft, Check, RefreshCw, User } from 'lucide-react-native'
|
||||
import { loadHosts } from '../../../src/transport/host-store'
|
||||
import { useHostClient } from '../../../src/transport/client-context'
|
||||
import type { RpcSuccess } from '../../../src/transport/types'
|
||||
import { colors, spacing } from '../../../src/theme/mobile-theme'
|
||||
import { styles } from './accounts-screen-styles'
|
||||
import { useNow } from '../../../src/hooks/use-now'
|
||||
import { ClaudeIcon, OpenAIIcon } from '../../../src/components/AgentIcons'
|
||||
import {
|
||||
type AccountsSnapshot,
|
||||
@@ -40,14 +41,16 @@ export default function AccountsScreen() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [busyAccountId, setBusyAccountId] = useState<string | null>(null)
|
||||
const [clockEnabled, setClockEnabled] = useState(false)
|
||||
|
||||
// Why: the reset countdown must stay fresh while the screen sits open —
|
||||
// snapshot pushes only arrive when the desktop's rate-limit poll completes.
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), 60_000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
setClockEnabled(true)
|
||||
return () => setClockEnabled(false)
|
||||
}, [])
|
||||
)
|
||||
// Why: snapshot pushes only arrive when the desktop's rate-limit poll completes.
|
||||
const now = useNow(60_000, clockEnabled)
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostId) {
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
useForceReconnect
|
||||
} from '../../../src/transport/client-context'
|
||||
import { useWorktreeResync } from '../../../src/transport/use-worktree-resync'
|
||||
import { startHostWorktreeRefresh } from '../../../src/worktree/host-worktree-refresh'
|
||||
import {
|
||||
useLastConnectedAt,
|
||||
useReconnectAttempt
|
||||
@@ -61,7 +62,7 @@ import { buildWorktreeNavigationActions } from '../../../src/agent-history/workt
|
||||
import { floatingWorkspaceSessionPath } from '../../../src/session/floating-workspace'
|
||||
import { ConfirmModal } from '../../../src/components/ConfirmModal'
|
||||
import { BottomDrawer } from '../../../src/components/BottomDrawer'
|
||||
import { ProtocolBlockScreen } from '../../../src/components/ProtocolBlockScreen'
|
||||
import { useHostProtocolGates } from '../../../src/components/HostProtocolGate'
|
||||
import { AuthFailedBanner } from '../../../src/components/AuthFailedBanner'
|
||||
import { MobileSearchField } from '../../../src/components/MobileSearchField'
|
||||
import { WorkspaceDetailPlaceholder } from '../../../src/components/WorkspaceDetailPlaceholder'
|
||||
@@ -70,7 +71,6 @@ import { setCachedRepos } from '../../../src/cache/repo-cache'
|
||||
import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
|
||||
import { useResponsiveLayout } from '../../../src/layout/responsive-layout'
|
||||
import { leaveHostRoute } from '../../../src/host-route-exit'
|
||||
import { useHostStatusGates } from '../../../src/transport/host-status-gates'
|
||||
import { loadPinnedIds, savePinnedIds } from '../../../src/storage/preferences'
|
||||
import {
|
||||
createInitialHostRouteActionState,
|
||||
@@ -141,7 +141,8 @@ export function HostScreen({
|
||||
const lastConnectedAt = useLastConnectedAt(hostId)
|
||||
const clientRef = useRef<RpcClient | null>(null)
|
||||
const fetchWorktreesInFlightRef = useRef(false)
|
||||
const fetchRepoMetadataInFlightRef = useRef(false)
|
||||
const fetchRepoMetadataInFlightRef = useRef(new WeakSet<RpcClient>())
|
||||
const fetchRepoMetadataPendingRef = useRef(new WeakSet<RpcClient>())
|
||||
const repoMetadataFetchedAtRef = useRef(0)
|
||||
const newWorktreeModalRef = useRef<{ open: () => void }>(null)
|
||||
const newWorktreeModalVisibleRef = useRef(false)
|
||||
@@ -176,11 +177,7 @@ export function HostScreen({
|
||||
const [showGroupPicker, setShowGroupPicker] = useState(false)
|
||||
const [showFilterModal, setShowFilterModal] = useState(false)
|
||||
const [actionTarget, setActionTarget] = useState<Worktree | null>(null)
|
||||
const { hostCapabilities, floatingWorkspaceEnabled, compatVerdict } = useHostStatusGates({
|
||||
hostId,
|
||||
client,
|
||||
connState
|
||||
})
|
||||
const { hostCapabilities, floatingWorkspaceEnabled } = useHostProtocolGates()
|
||||
const [confirmDelete, setConfirmDelete] = useState<Worktree | null>(null)
|
||||
const [confirmRemoveHost, setConfirmRemoveHost] = useState(false)
|
||||
const [routeActionState, setRouteActionState] = useState(() =>
|
||||
@@ -356,48 +353,54 @@ export function HostScreen({
|
||||
}, [hostId])
|
||||
|
||||
const fetchRepoMetadata = useCallback(
|
||||
async (options: { force?: boolean } = {}) => {
|
||||
async (options: { force?: boolean; queueIfInFlight?: boolean } = {}) => {
|
||||
if (!client || connState !== 'connected' || !hostId) {
|
||||
return
|
||||
}
|
||||
if (fetchRepoMetadataInFlightRef.current) {
|
||||
if (fetchRepoMetadataInFlightRef.current.has(client)) {
|
||||
if (options.queueIfInFlight) {
|
||||
fetchRepoMetadataPendingRef.current.add(client)
|
||||
}
|
||||
return
|
||||
}
|
||||
const now = Date.now()
|
||||
if (!options.force && now - repoMetadataFetchedAtRef.current < REPO_METADATA_REFRESH_MS) {
|
||||
return
|
||||
}
|
||||
fetchRepoMetadataInFlightRef.current = true
|
||||
fetchRepoMetadataInFlightRef.current.add(client)
|
||||
const requestClient = client,
|
||||
requestHostId = hostId
|
||||
try {
|
||||
const repoResponse = await requestClient.sendRequest('repo.list')
|
||||
if (clientRef.current !== requestClient || hostId !== requestHostId || !repoResponse.ok) {
|
||||
return
|
||||
}
|
||||
const repoResult = (repoResponse as RpcSuccess).result as { repos: RepoSummary[] }
|
||||
repoMetadataFetchedAtRef.current = Date.now()
|
||||
setCachedRepos(requestHostId, repoResult.repos)
|
||||
setRepoColorsByName(
|
||||
new Map(
|
||||
repoResult.repos.map((repo) => [
|
||||
repo.displayName,
|
||||
repo.badgeColor || repoColor(repo.displayName)
|
||||
])
|
||||
)
|
||||
)
|
||||
setRepoIconsByName(
|
||||
new Map(
|
||||
repoResult.repos.flatMap((repo) =>
|
||||
repo.repoIcon ? [[repo.displayName, repo.repoIcon] as const] : []
|
||||
do {
|
||||
fetchRepoMetadataPendingRef.current.delete(requestClient)
|
||||
const repoResponse = await requestClient.sendRequest('repo.list')
|
||||
if (clientRef.current !== requestClient || hostId !== requestHostId || !repoResponse.ok) {
|
||||
return
|
||||
}
|
||||
const repoResult = (repoResponse as RpcSuccess).result as { repos: RepoSummary[] }
|
||||
repoMetadataFetchedAtRef.current = Date.now()
|
||||
setCachedRepos(requestHostId, repoResult.repos)
|
||||
setRepoColorsByName(
|
||||
new Map(
|
||||
repoResult.repos.map((repo) => [
|
||||
repo.displayName,
|
||||
repo.badgeColor || repoColor(repo.displayName)
|
||||
])
|
||||
)
|
||||
)
|
||||
)
|
||||
setRepoIdsByName(new Map(repoResult.repos.map((repo) => [repo.displayName, repo.id])))
|
||||
setRepoIconsByName(
|
||||
new Map(
|
||||
repoResult.repos.flatMap((repo) =>
|
||||
repo.repoIcon ? [[repo.displayName, repo.repoIcon] as const] : []
|
||||
)
|
||||
)
|
||||
)
|
||||
setRepoIdsByName(new Map(repoResult.repos.map((repo) => [repo.displayName, repo.id])))
|
||||
} while (fetchRepoMetadataPendingRef.current.has(requestClient))
|
||||
} catch {
|
||||
// Repo metadata is decorative; the next throttled refresh can retry.
|
||||
// Repo metadata is decorative; the next refresh can retry.
|
||||
} finally {
|
||||
fetchRepoMetadataInFlightRef.current = false
|
||||
fetchRepoMetadataInFlightRef.current.delete(requestClient)
|
||||
}
|
||||
},
|
||||
[client, connState, hostId]
|
||||
@@ -494,39 +497,29 @@ export function HostScreen({
|
||||
}, [])
|
||||
)
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
// The embedded sidebar isn't a routed screen (focus never fires); it polls via the mount effect below.
|
||||
if (embedded || connState !== 'connected') {
|
||||
return
|
||||
}
|
||||
void fetchWorktrees()
|
||||
void fetchRepoMetadata()
|
||||
// Pull desktop's shared view settings on focus so desktop changes show up without a manual refresh.
|
||||
void syncViewSettingsFromDesktop()
|
||||
// Why: React Navigation keeps prior screens mounted; only poll while this route is visible.
|
||||
const interval = setInterval(() => {
|
||||
void fetchWorktrees()
|
||||
void fetchRepoMetadata()
|
||||
}, 3000)
|
||||
return () => clearInterval(interval)
|
||||
}, [embedded, connState, fetchWorktrees, fetchRepoMetadata, syncViewSettingsFromDesktop])
|
||||
)
|
||||
|
||||
// Why: the embedded sidebar is never the focused route, so useFocusEffect never polls; mirror it from a mount effect.
|
||||
useEffect(() => {
|
||||
if (!embedded || connState !== 'connected') {
|
||||
const startWorktreeRefresh = useCallback(() => {
|
||||
if (!client || connState !== 'connected') {
|
||||
return
|
||||
}
|
||||
void fetchWorktrees()
|
||||
void fetchRepoMetadata()
|
||||
void syncViewSettingsFromDesktop()
|
||||
const interval = setInterval(() => {
|
||||
void fetchWorktrees()
|
||||
void fetchRepoMetadata()
|
||||
}, 3000)
|
||||
return () => clearInterval(interval)
|
||||
}, [embedded, connState, fetchWorktrees, fetchRepoMetadata, syncViewSettingsFromDesktop])
|
||||
return startHostWorktreeRefresh({ client, fetchWorktrees, fetchRepoMetadata })
|
||||
}, [client, connState, fetchWorktrees, fetchRepoMetadata, syncViewSettingsFromDesktop])
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
// The embedded sidebar isn't a routed screen (focus never fires); it refreshes via the mount effect below.
|
||||
if (!embedded) {
|
||||
return startWorktreeRefresh()
|
||||
}
|
||||
}, [embedded, startWorktreeRefresh])
|
||||
)
|
||||
|
||||
// Why: the embedded sidebar is never the focused route, so wire its refresh lifecycle from a mount effect.
|
||||
useEffect(() => {
|
||||
if (embedded) {
|
||||
return startWorktreeRefresh()
|
||||
}
|
||||
}, [embedded, startWorktreeRefresh])
|
||||
|
||||
// Why (#8498): steady-state polls miss the transition INTO 'connected' after background/sleep, when the cache is stalest.
|
||||
const { refreshing, onRefresh } = useWorktreeResync({
|
||||
@@ -745,15 +738,17 @@ export function HostScreen({
|
||||
const toggleCollapsed = useCallback(
|
||||
(key: string) => {
|
||||
const next = new Set(viewStateRef.current.collapsedGroups)
|
||||
if (next.has(key)) {
|
||||
next.delete(key)
|
||||
} else {
|
||||
if (!next.delete(key)) {
|
||||
next.add(key)
|
||||
}
|
||||
persistViewSettings({ collapsedGroups: [...next] })
|
||||
},
|
||||
[persistViewSettings]
|
||||
)
|
||||
const toggleWorktreeLineage = useCallback(
|
||||
(item: Worktree) => toggleCollapsed(getMobileWorkspaceLineageGroupKey(item.worktreeId)),
|
||||
[toggleCollapsed]
|
||||
)
|
||||
const { sections, rawSections, uniqueRepos, uniqueRepoColors } = useWorkspaceSections({
|
||||
displayWorktrees,
|
||||
sortMode,
|
||||
@@ -780,10 +775,6 @@ export function HostScreen({
|
||||
)
|
||||
}
|
||||
|
||||
if (compatVerdict.kind === 'blocked') {
|
||||
return <ProtocolBlockScreen verdict={compatVerdict} />
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<View style={styles.topChrome}>
|
||||
@@ -1200,9 +1191,7 @@ export function HostScreen({
|
||||
hideRepo={groupMode === 'repo'}
|
||||
onPress={openWorktreeSession}
|
||||
onLongPress={item.workspaceKind === 'folder-workspace' ? undefined : setActionTarget}
|
||||
onToggleLineage={(row) =>
|
||||
toggleCollapsed(getMobileWorkspaceLineageGroupKey(row.worktreeId))
|
||||
}
|
||||
onToggleLineage={toggleWorktreeLineage}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
@@ -2706,14 +2710,25 @@ export default function SessionScreen() {
|
||||
if (connState !== 'connected') {
|
||||
return
|
||||
}
|
||||
void fetchSessionTabs()
|
||||
void fetchTerminals()
|
||||
// Why: live subscription keeps stream ownership, but the fallback list poll should stop while this route is hidden.
|
||||
const interval = setInterval(() => {
|
||||
const refreshOnForeground = () => {
|
||||
if (AppState.currentState !== 'active') {
|
||||
return
|
||||
}
|
||||
void fetchSessionTabs()
|
||||
void fetchTerminals()
|
||||
}, 2000)
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
const appStateSubscription = AppState.addEventListener('change', (state) => {
|
||||
if (state === 'active') {
|
||||
refreshOnForeground()
|
||||
}
|
||||
})
|
||||
// Why: live subscription keeps stream ownership, but the fallback list poll should stop while this route is hidden or backgrounded.
|
||||
const interval = setInterval(refreshOnForeground, 2000)
|
||||
refreshOnForeground()
|
||||
return () => {
|
||||
clearInterval(interval)
|
||||
appStateSubscription.remove()
|
||||
}
|
||||
}, [connState, fetchSessionTabs, fetchTerminals])
|
||||
)
|
||||
|
||||
@@ -4514,14 +4529,24 @@ export default function SessionScreen() {
|
||||
>
|
||||
<Plus size={16} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
{quickCommandsSupported === true ? (
|
||||
<QuickCommandsTabButton
|
||||
disabled={
|
||||
creating || creatingBrowser || creatingMarkdown || connState !== 'connected'
|
||||
{/* Why: stable placement matters, while old hosts must stay gated because they strip agentPrompt. */}
|
||||
<QuickCommandsTabButton
|
||||
disabled={
|
||||
creating || creatingBrowser || creatingMarkdown || connState !== 'connected'
|
||||
}
|
||||
onPress={() => {
|
||||
if (quickCommandsSupported === true) {
|
||||
setShowQuickCommands(true)
|
||||
return
|
||||
}
|
||||
onPress={() => setShowQuickCommands(true)}
|
||||
/>
|
||||
) : null}
|
||||
showToast(
|
||||
quickCommandsSupported === false
|
||||
? 'Desktop update required for quick commands'
|
||||
: 'Checking desktop capabilities — try again in a moment',
|
||||
1600
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
|
||||
+18
-15
@@ -10,6 +10,7 @@ import {
|
||||
loadHostSidebarWidth,
|
||||
saveHostSidebarWidth
|
||||
} from '../../src/storage/preferences'
|
||||
import { HostProtocolGate } from '../../src/components/HostProtocolGate'
|
||||
import { HostScreen } from './[hostId]/index'
|
||||
|
||||
// Keep at least this much room for the detail pane when resizing the sidebar.
|
||||
@@ -138,23 +139,25 @@ export default function HostGroupLayout() {
|
||||
// changes so a fold/rotation doesn't remount the navigator and reset the
|
||||
// navigation stack — only the sidebar pane toggles in and out.
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
{showSidebar && sidebarOpen ? (
|
||||
<View style={[styles.sidebar, { width: sidebarWidth }]}>
|
||||
<HostScreen
|
||||
embedded
|
||||
hostId={hostId}
|
||||
action={action}
|
||||
onHideSidebar={canCollapseSidebar ? hideSidebar : undefined}
|
||||
/>
|
||||
{/* Dedicated drag handle straddling the right border — see resizer note. */}
|
||||
<View style={styles.resizeHandle} {...resizer.panHandlers} />
|
||||
<HostProtocolGate hostId={hostId}>
|
||||
<View style={styles.row}>
|
||||
{showSidebar && sidebarOpen ? (
|
||||
<View style={[styles.sidebar, { width: sidebarWidth }]}>
|
||||
<HostScreen
|
||||
embedded
|
||||
hostId={hostId}
|
||||
action={action}
|
||||
onHideSidebar={canCollapseSidebar ? hideSidebar : undefined}
|
||||
/>
|
||||
{/* Dedicated drag handle straddling the right border — see resizer note. */}
|
||||
<View style={styles.resizeHandle} {...resizer.panHandlers} />
|
||||
</View>
|
||||
) : null}
|
||||
<View style={styles.detail}>
|
||||
<HostStack animation={showSidebar ? 'none' : 'default'} />
|
||||
</View>
|
||||
) : null}
|
||||
<View style={styles.detail}>
|
||||
<HostStack animation={showSidebar ? 'none' : 'default'} />
|
||||
</View>
|
||||
</View>
|
||||
</HostProtocolGate>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+11
-12
@@ -28,6 +28,7 @@ import { loadHosts } from '../src/transport/host-store'
|
||||
import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle'
|
||||
import { pickResumeWorktree } from '../src/worktree/resume-worktree'
|
||||
import type { RpcClient } from '../src/transport/rpc-client'
|
||||
import { sendSingleFlightRequest } from '../src/transport/request-single-flight'
|
||||
import {
|
||||
useAllHostClients,
|
||||
useCloseHost,
|
||||
@@ -140,11 +141,11 @@ function clientKey(client: RpcClient): number {
|
||||
|
||||
function fetchStats(
|
||||
client: RpcClient,
|
||||
hostId: string,
|
||||
setStats: (s: StatsSummary) => void,
|
||||
disposed: () => boolean
|
||||
) {
|
||||
client
|
||||
.sendRequest('stats.summary')
|
||||
sendSingleFlightRequest(client, hostId, 'stats.summary')
|
||||
.then((response) => {
|
||||
if (disposed()) {
|
||||
return
|
||||
@@ -182,9 +183,8 @@ function fetchWorktreeInfo(
|
||||
})
|
||||
}
|
||||
|
||||
client
|
||||
// Why: worktree.ps defaults to 200 and silently truncates; request all so counts are accurate.
|
||||
.sendRequest('worktree.ps', { limit: 10000 })
|
||||
// Why: worktree.ps defaults to 200 and silently truncates; request all so counts are accurate.
|
||||
sendSingleFlightRequest(client, hostId, 'worktree.ps', { limit: 10000 })
|
||||
.then((response) => {
|
||||
if (disposed()) {
|
||||
return
|
||||
@@ -225,8 +225,7 @@ function fetchAccountsSnapshot(
|
||||
) => void,
|
||||
disposed: () => boolean
|
||||
) {
|
||||
client
|
||||
.sendRequest('accounts.list')
|
||||
sendSingleFlightRequest(client, hostId, 'accounts.list')
|
||||
.then((response) => {
|
||||
if (disposed()) {
|
||||
return
|
||||
@@ -248,9 +247,9 @@ function fetchTaskProviders(
|
||||
disposed: () => boolean
|
||||
) {
|
||||
Promise.all([
|
||||
client.sendRequest('settings.get'),
|
||||
client.sendRequest('preflight.check'),
|
||||
client.sendRequest('linear.status')
|
||||
sendSingleFlightRequest(client, hostId, 'settings.get'),
|
||||
sendSingleFlightRequest(client, hostId, 'preflight.check'),
|
||||
sendSingleFlightRequest(client, hostId, 'linear.status')
|
||||
])
|
||||
.then(([settingsResponse, preflightResponse, linearResponse]) => {
|
||||
if (disposed()) {
|
||||
@@ -405,7 +404,7 @@ export default function HomeScreen() {
|
||||
})
|
||||
for (const entry of allClientsRef.current) {
|
||||
if (entry.client.getState() === 'connected') {
|
||||
fetchStats(entry.client, setStats, () => stale)
|
||||
fetchStats(entry.client, entry.hostId, setStats, () => stale)
|
||||
fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => stale)
|
||||
fetchAccountsSnapshot(entry.client, entry.hostId, setAccountsByHost, () => stale)
|
||||
fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => stale)
|
||||
@@ -512,7 +511,7 @@ export default function HomeScreen() {
|
||||
}
|
||||
if (!statsFetched) {
|
||||
statsFetched = true
|
||||
fetchStats(entry.client, setStats, () => false)
|
||||
fetchStats(entry.client, entry.hostId, setStats, () => false)
|
||||
fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => false)
|
||||
fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => false)
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
View
|
||||
} from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useFocusEffect, useRouter } from 'expo-router'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react-native'
|
||||
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
|
||||
import { loadHosts } from '../src/transport/host-store'
|
||||
@@ -18,6 +18,7 @@ import { useAllHostClients } from '../src/transport/client-context'
|
||||
import type { RpcClient } from '../src/transport/rpc-client'
|
||||
import { BottomDrawer } from '../src/components/BottomDrawer'
|
||||
import { VoiceModelList } from '../src/components/VoiceModelList'
|
||||
import { useDictationSetupPoller } from '../src/dictation/use-dictation-setup-poller'
|
||||
import {
|
||||
deleteDictationModel,
|
||||
downloadDictationModel,
|
||||
@@ -58,44 +59,45 @@ export default function VoiceSettingsScreen(): React.JSX.Element {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busyAction, setBusyAction] = useState<ModelBusyAction | null>(null)
|
||||
const [modelDrawerOpen, setModelDrawerOpen] = useState(false)
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const [routeFocused, setRouteFocused] = useState(false)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
setRouteFocused(true)
|
||||
return () => setRouteFocused(false)
|
||||
}, [])
|
||||
)
|
||||
|
||||
const refresh = useCallback(async (): Promise<boolean | undefined> => {
|
||||
if (!client) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
try {
|
||||
setSetup(await fetchDictationSetup(client))
|
||||
const next = await fetchDictationSetup(client)
|
||||
setSetup(next)
|
||||
setError(null)
|
||||
return next.models.some(isModelInFlight)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load voice settings')
|
||||
return undefined
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [client])
|
||||
|
||||
// Initial load once a connected client is available.
|
||||
useEffect(() => {
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
void refresh().finally(() => setLoading(false))
|
||||
}, [client, refresh])
|
||||
const polling = setup?.models.some(isModelInFlight) ?? false
|
||||
const refreshSetup = useDictationSetupPoller({
|
||||
visible: routeFocused && client !== null,
|
||||
polling,
|
||||
refresh,
|
||||
intervalMs: POLL_INTERVAL_MS
|
||||
})
|
||||
|
||||
// Poll only while a model is downloading/extracting; stop otherwise.
|
||||
useEffect(() => {
|
||||
const inFlight = setup?.models.some(isModelInFlight) ?? false
|
||||
if (inFlight && client) {
|
||||
pollRef.current = setInterval(() => void refresh(), POLL_INTERVAL_MS)
|
||||
return () => {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
}
|
||||
}
|
||||
if (routeFocused && client && setup === null) {
|
||||
setLoading(true)
|
||||
}
|
||||
return undefined
|
||||
}, [setup, client, refresh])
|
||||
}, [routeFocused, client, setup])
|
||||
|
||||
const handleToggleEnabled = useCallback(
|
||||
async (enabled: boolean) => {
|
||||
@@ -109,10 +111,10 @@ export default function VoiceSettingsScreen(): React.JSX.Element {
|
||||
setSetup(await setDictationConfig(client, { enabled }))
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Could not update')
|
||||
void refresh()
|
||||
void refreshSetup()
|
||||
}
|
||||
},
|
||||
[client, refresh]
|
||||
[client, refreshSetup]
|
||||
)
|
||||
|
||||
const handleSelectMode = useCallback(
|
||||
@@ -126,10 +128,10 @@ export default function VoiceSettingsScreen(): React.JSX.Element {
|
||||
setSetup(await setDictationConfig(client, { dictationMode }))
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Could not update')
|
||||
void refresh()
|
||||
void refreshSetup()
|
||||
}
|
||||
},
|
||||
[client, refresh]
|
||||
[client, refreshSetup]
|
||||
)
|
||||
|
||||
const handleUseModel = useCallback(
|
||||
@@ -160,14 +162,14 @@ export default function VoiceSettingsScreen(): React.JSX.Element {
|
||||
setError(null)
|
||||
try {
|
||||
await downloadDictationModel(client, model.id)
|
||||
await refresh()
|
||||
await refreshSetup()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Download failed')
|
||||
} finally {
|
||||
setBusyAction(null)
|
||||
}
|
||||
},
|
||||
[client, refresh]
|
||||
[client, refreshSetup]
|
||||
)
|
||||
|
||||
const handleDelete = useCallback(
|
||||
|
||||
Generated
+50
-83
@@ -1494,6 +1494,12 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@eslint-community/eslint-utils@4.10.1':
|
||||
resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
peerDependencies:
|
||||
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
|
||||
|
||||
'@eslint-community/eslint-utils@4.9.1':
|
||||
resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
@@ -1942,56 +1948,48 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-arm64-musl@0.52.0':
|
||||
resolution: {integrity: sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxfmt/binding-linux-ppc64-gnu@0.52.0':
|
||||
resolution: {integrity: sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-riscv64-gnu@0.52.0':
|
||||
resolution: {integrity: sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-riscv64-musl@0.52.0':
|
||||
resolution: {integrity: sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxfmt/binding-linux-s390x-gnu@0.52.0':
|
||||
resolution: {integrity: sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-x64-gnu@0.52.0':
|
||||
resolution: {integrity: sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-x64-musl@0.52.0':
|
||||
resolution: {integrity: sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxfmt/binding-openharmony-arm64@0.52.0':
|
||||
resolution: {integrity: sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ==}
|
||||
@@ -2064,56 +2062,48 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-arm64-musl@1.71.0':
|
||||
resolution: {integrity: sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-linux-ppc64-gnu@1.71.0':
|
||||
resolution: {integrity: sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-riscv64-gnu@1.71.0':
|
||||
resolution: {integrity: sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-riscv64-musl@1.71.0':
|
||||
resolution: {integrity: sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-linux-s390x-gnu@1.71.0':
|
||||
resolution: {integrity: sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-x64-gnu@1.71.0':
|
||||
resolution: {integrity: sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-x64-musl@1.71.0':
|
||||
resolution: {integrity: sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-openharmony-arm64@1.71.0':
|
||||
resolution: {integrity: sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw==}
|
||||
@@ -2575,42 +2565,36 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-arm64-musl@1.1.3':
|
||||
resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.1.3':
|
||||
resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-s390x-gnu@1.1.3':
|
||||
resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-gnu@1.1.3':
|
||||
resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-musl@1.1.3':
|
||||
resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-openharmony-arm64@1.1.3':
|
||||
resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==}
|
||||
@@ -3167,11 +3151,11 @@ packages:
|
||||
resolution: {integrity: sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==}
|
||||
engines: {node: '>= 5.10.0'}
|
||||
|
||||
brace-expansion@1.1.15:
|
||||
resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==}
|
||||
brace-expansion@1.1.16:
|
||||
resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==}
|
||||
|
||||
brace-expansion@5.0.6:
|
||||
resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
|
||||
brace-expansion@5.0.7:
|
||||
resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
braces@3.0.3:
|
||||
@@ -4175,8 +4159,8 @@ packages:
|
||||
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
flatted@3.4.2:
|
||||
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
|
||||
flatted@3.4.3:
|
||||
resolution: {integrity: sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==}
|
||||
|
||||
flow-enums-runtime@0.0.6:
|
||||
resolution: {integrity: sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==}
|
||||
@@ -4910,28 +4894,24 @@ packages:
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.32.0:
|
||||
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.32.0:
|
||||
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-x64-musl@1.32.0:
|
||||
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.32.0:
|
||||
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
|
||||
@@ -5910,8 +5890,8 @@ packages:
|
||||
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
shell-quote@1.8.4:
|
||||
resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==}
|
||||
shell-quote@1.10.0:
|
||||
resolution: {integrity: sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
side-channel-list@1.0.1:
|
||||
@@ -6585,6 +6565,18 @@ packages:
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
ws@7.5.13:
|
||||
resolution: {integrity: sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==}
|
||||
engines: {node: '>=8.3.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: ^5.0.2
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
ws@8.21.0:
|
||||
resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
@@ -8098,6 +8090,11 @@ snapshots:
|
||||
'@esbuild/win32-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@eslint-community/eslint-utils@4.10.1(eslint@9.39.4)':
|
||||
dependencies:
|
||||
eslint: 9.39.4
|
||||
eslint-visitor-keys: 3.4.3
|
||||
|
||||
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)':
|
||||
dependencies:
|
||||
eslint: 9.39.4
|
||||
@@ -9674,7 +9671,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/yargs-parser': 21.0.3
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@5.9.3)':
|
||||
'@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
'@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3)
|
||||
@@ -9694,7 +9691,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@typescript-eslint/scope-manager': 8.59.2
|
||||
'@typescript-eslint/types': 8.59.2
|
||||
'@typescript-eslint/typescript-estree': 8.59.2(typescript@6.0.3)
|
||||
'@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3)
|
||||
'@typescript-eslint/visitor-keys': 8.59.2
|
||||
debug: 4.4.3
|
||||
eslint: 9.39.4
|
||||
@@ -9711,15 +9708,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/project-service@8.59.2(typescript@6.0.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3)
|
||||
'@typescript-eslint/types': 8.59.2
|
||||
debug: 4.4.3
|
||||
typescript: 6.0.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/scope-manager@8.59.2':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.59.2
|
||||
@@ -9729,10 +9717,6 @@ snapshots:
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.59.2(typescript@6.0.3)':
|
||||
dependencies:
|
||||
typescript: 6.0.3
|
||||
|
||||
'@typescript-eslint/type-utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.59.2
|
||||
@@ -9762,21 +9746,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/typescript-estree@8.59.2(typescript@6.0.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/project-service': 8.59.2(typescript@6.0.3)
|
||||
'@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3)
|
||||
'@typescript-eslint/types': 8.59.2
|
||||
'@typescript-eslint/visitor-keys': 8.59.2
|
||||
debug: 4.4.3
|
||||
minimatch: 10.2.5
|
||||
semver: 7.7.4
|
||||
tinyglobby: 0.2.17
|
||||
ts-api-utils: 2.5.0(typescript@6.0.3)
|
||||
typescript: 6.0.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4)
|
||||
@@ -10236,12 +10205,12 @@ snapshots:
|
||||
dependencies:
|
||||
big-integer: 1.6.52
|
||||
|
||||
brace-expansion@1.1.15:
|
||||
brace-expansion@1.1.16:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
concat-map: 0.0.1
|
||||
|
||||
brace-expansion@5.0.6:
|
||||
brace-expansion@5.0.7:
|
||||
dependencies:
|
||||
balanced-match: 4.0.4
|
||||
|
||||
@@ -10839,11 +10808,11 @@ snapshots:
|
||||
|
||||
eslint-config-universe@15.0.4(eslint@9.39.4)(prettier@2.8.8)(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@5.9.3)
|
||||
'@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)
|
||||
'@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3)
|
||||
eslint: 9.39.4
|
||||
eslint-config-prettier: 9.1.2(eslint@9.39.4)
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)
|
||||
eslint-plugin-n: 17.24.0(eslint@9.39.4)(typescript@5.9.3)
|
||||
eslint-plugin-node: 11.1.0(eslint@9.39.4)
|
||||
eslint-plugin-prettier: 5.5.5(eslint-config-prettier@9.1.2(eslint@9.39.4))(eslint@9.39.4)(prettier@2.8.8)
|
||||
@@ -10867,7 +10836,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4):
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4):
|
||||
dependencies:
|
||||
debug: 3.2.7
|
||||
optionalDependencies:
|
||||
@@ -10890,7 +10859,7 @@ snapshots:
|
||||
eslint-utils: 2.1.0
|
||||
regexpp: 3.2.0
|
||||
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4):
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4):
|
||||
dependencies:
|
||||
'@rtsao/scc': 1.1.0
|
||||
array-includes: 3.1.9
|
||||
@@ -10901,7 +10870,7 @@ snapshots:
|
||||
doctrine: 2.1.0
|
||||
eslint: 9.39.4
|
||||
eslint-import-resolver-node: 0.3.10
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4)
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4)
|
||||
hasown: 2.0.3
|
||||
is-core-module: 2.16.2
|
||||
is-glob: 4.0.3
|
||||
@@ -10998,7 +10967,7 @@ snapshots:
|
||||
|
||||
eslint@9.39.4:
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4)
|
||||
'@eslint-community/eslint-utils': 4.10.1(eslint@9.39.4)
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
'@eslint/config-array': 0.21.2
|
||||
'@eslint/config-helpers': 0.4.2
|
||||
@@ -11519,10 +11488,10 @@ snapshots:
|
||||
|
||||
flat-cache@4.0.1:
|
||||
dependencies:
|
||||
flatted: 3.4.2
|
||||
flatted: 3.4.3
|
||||
keyv: 4.5.4
|
||||
|
||||
flatted@3.4.2: {}
|
||||
flatted@3.4.3: {}
|
||||
|
||||
flow-enums-runtime@0.0.6: {}
|
||||
|
||||
@@ -12933,7 +12902,7 @@ snapshots:
|
||||
serialize-error: 2.1.0
|
||||
source-map: 0.5.7
|
||||
throat: 5.0.0
|
||||
ws: 7.5.11
|
||||
ws: 7.5.13
|
||||
yargs: 17.7.3
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
@@ -12967,11 +12936,11 @@ snapshots:
|
||||
|
||||
minimatch@10.2.5:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.6
|
||||
brace-expansion: 5.0.7
|
||||
|
||||
minimatch@3.1.5:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.15
|
||||
brace-expansion: 1.1.16
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
@@ -13342,7 +13311,7 @@ snapshots:
|
||||
|
||||
react-devtools-core@6.1.5:
|
||||
dependencies:
|
||||
shell-quote: 1.8.4
|
||||
shell-quote: 1.10.0
|
||||
ws: 7.5.11
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
@@ -13769,7 +13738,7 @@ snapshots:
|
||||
|
||||
shebang-regex@3.0.0: {}
|
||||
|
||||
shell-quote@1.8.4: {}
|
||||
shell-quote@1.10.0: {}
|
||||
|
||||
side-channel-list@1.0.1:
|
||||
dependencies:
|
||||
@@ -14071,10 +14040,6 @@ snapshots:
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
ts-api-utils@2.5.0(typescript@6.0.3):
|
||||
dependencies:
|
||||
typescript: 6.0.3
|
||||
|
||||
ts-declaration-location@1.0.7(typescript@5.9.3):
|
||||
dependencies:
|
||||
picomatch: 4.0.4
|
||||
@@ -14405,6 +14370,8 @@ snapshots:
|
||||
|
||||
ws@7.5.11: {}
|
||||
|
||||
ws@7.5.13: {}
|
||||
|
||||
ws@8.21.0: {}
|
||||
|
||||
xcode@3.0.1:
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { HostProtocolGate, useHostProtocolGates } from './HostProtocolGate'
|
||||
|
||||
const nativeTestState = vi.hoisted(() => ({
|
||||
openUrl: vi.fn(),
|
||||
platform: { OS: 'ios' as 'ios' | 'android' }
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
Linking: { openURL: nativeTestState.openUrl },
|
||||
Platform: nativeTestState.platform,
|
||||
Pressable: 'Pressable',
|
||||
StyleSheet: { create: <T>(styles: T) => styles },
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
vi.mock('expo-router', () => ({
|
||||
router: { replace: vi.fn() }
|
||||
}))
|
||||
|
||||
// Why: mock only client acquisition; the gate must exercise the real
|
||||
// useHostStatusGates → evaluateCompat → ProtocolBlockScreen wiring.
|
||||
const hostClient = vi.hoisted(() => ({
|
||||
current: { client: null as RpcClient | null, state: 'disconnected' as string }
|
||||
}))
|
||||
vi.mock('../transport/client-context', () => ({
|
||||
useHostClient: () => hostClient.current
|
||||
}))
|
||||
|
||||
function clientWithStatus(result: Record<string, unknown>): RpcClient {
|
||||
return { sendRequest: vi.fn().mockResolvedValue({ ok: true, result }) } as unknown as RpcClient
|
||||
}
|
||||
|
||||
function GateConsumer() {
|
||||
const { hostCapabilities } = useHostProtocolGates()
|
||||
return createElement('GateStatus', null, hostCapabilities.join(','))
|
||||
}
|
||||
|
||||
function gateElement() {
|
||||
return createElement(
|
||||
HostProtocolGate,
|
||||
{ hostId: 'host-1' },
|
||||
createElement('HostContent', null, createElement(GateConsumer))
|
||||
)
|
||||
}
|
||||
|
||||
async function renderGate(): Promise<ReactTestRenderer> {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
await act(async () => {
|
||||
renderer = create(gateElement())
|
||||
await Promise.resolve()
|
||||
})
|
||||
return renderer as unknown as ReactTestRenderer
|
||||
}
|
||||
|
||||
function renderedText(renderer: ReactTestRenderer): string {
|
||||
return JSON.stringify(renderer.toJSON())
|
||||
}
|
||||
|
||||
describe('HostProtocolGate', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
nativeTestState.openUrl.mockClear()
|
||||
nativeTestState.platform.OS = 'ios'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('replaces the host UI with the block screen when mobile is too old', async () => {
|
||||
// Why: blocked warns to console; keep test output clean without hiding other errors.
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
hostClient.current = {
|
||||
client: clientWithStatus({ protocolVersion: 5, minCompatibleMobileVersion: 999 }),
|
||||
state: 'connected'
|
||||
}
|
||||
renderer = await renderGate()
|
||||
const output = renderedText(renderer)
|
||||
expect(output).toContain('Update Orca Mobile')
|
||||
expect(output).toContain('Open App Store')
|
||||
expect(output).not.toContain('HostContent')
|
||||
})
|
||||
|
||||
it('routes Android mobile updates to GitHub Releases', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
nativeTestState.platform.OS = 'android'
|
||||
hostClient.current = {
|
||||
client: clientWithStatus({ protocolVersion: 5, minCompatibleMobileVersion: 999 }),
|
||||
state: 'connected'
|
||||
}
|
||||
renderer = await renderGate()
|
||||
const output = renderedText(renderer)
|
||||
expect(output).toContain('Update Orca Mobile')
|
||||
expect(output).toContain('Update Orca Mobile from GitHub Releases')
|
||||
expect(output).toContain('Open GitHub Releases')
|
||||
expect(output).not.toContain('mobile app store')
|
||||
expect(output).not.toContain('HostContent')
|
||||
act(() => renderer?.root.findAllByType('Pressable')[0]?.props.onPress())
|
||||
expect(nativeTestState.openUrl).toHaveBeenCalledWith(
|
||||
'https://github.com/stablyai/orca/releases'
|
||||
)
|
||||
})
|
||||
|
||||
it('replaces the host UI with the block screen when desktop is too old', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
hostClient.current = {
|
||||
client: clientWithStatus({ protocolVersion: 0, minCompatibleMobileVersion: 0 }),
|
||||
state: 'connected'
|
||||
}
|
||||
renderer = await renderGate()
|
||||
const output = renderedText(renderer)
|
||||
expect(output).toContain('Update Orca on your computer')
|
||||
expect(output).toContain('Open GitHub Releases')
|
||||
expect(output).not.toContain('HostContent')
|
||||
})
|
||||
|
||||
it('renders the host UI when the verdict is ok', async () => {
|
||||
const client = clientWithStatus({
|
||||
protocolVersion: 5,
|
||||
minCompatibleMobileVersion: 0,
|
||||
capabilities: ['browser.screencast.v1']
|
||||
})
|
||||
hostClient.current = {
|
||||
client,
|
||||
state: 'connected'
|
||||
}
|
||||
renderer = await renderGate()
|
||||
const output = renderedText(renderer)
|
||||
expect(output).toContain('HostContent')
|
||||
expect(output).toContain('browser.screencast.v1')
|
||||
expect(output).not.toContain('Update Orca')
|
||||
expect(client.sendRequest).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('renders the host UI while the host connection is still pending', async () => {
|
||||
hostClient.current = { client: null, state: 'connecting' }
|
||||
renderer = await renderGate()
|
||||
expect(renderedText(renderer)).toContain('HostContent')
|
||||
})
|
||||
|
||||
it('does not mount host routes before a connected host passes the compatibility probe', async () => {
|
||||
const client = {
|
||||
sendRequest: vi.fn().mockReturnValue(new Promise(() => {}))
|
||||
} as unknown as RpcClient
|
||||
hostClient.current = { client, state: 'connected' }
|
||||
renderer = await renderGate()
|
||||
const output = renderedText(renderer)
|
||||
expect(output).toContain('Checking host compatibility')
|
||||
expect(output).not.toContain('HostContent')
|
||||
expect(client.sendRequest).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps an already-validated host route mounted while reconnect status is pending', async () => {
|
||||
const client = {
|
||||
sendRequest: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: { protocolVersion: 5, minCompatibleMobileVersion: 0 }
|
||||
})
|
||||
.mockReturnValueOnce(new Promise(() => {}))
|
||||
} as unknown as RpcClient
|
||||
hostClient.current = { client, state: 'connected' }
|
||||
renderer = await renderGate()
|
||||
|
||||
await act(async () => {
|
||||
hostClient.current = { client, state: 'disconnected' }
|
||||
renderer?.update(gateElement())
|
||||
})
|
||||
await act(async () => {
|
||||
hostClient.current = { client, state: 'connected' }
|
||||
renderer?.update(gateElement())
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(renderedText(renderer)).toContain('HostContent')
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('fails open when a connected host cannot answer the status probe', async () => {
|
||||
hostClient.current = {
|
||||
client: {
|
||||
sendRequest: vi.fn().mockResolvedValue({ ok: false, error: { message: 'unavailable' } })
|
||||
} as unknown as RpcClient,
|
||||
state: 'connected'
|
||||
}
|
||||
renderer = await renderGate()
|
||||
expect(renderedText(renderer)).toContain('HostContent')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createContext, useContext, useRef, type ReactNode } from 'react'
|
||||
import { ActivityIndicator, StyleSheet, View } from 'react-native'
|
||||
import { useHostClient } from '../transport/client-context'
|
||||
import { useHostStatusGates, type HostStatusGates } from '../transport/host-status-gates'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { ProtocolBlockScreen } from './ProtocolBlockScreen'
|
||||
|
||||
type Props = {
|
||||
hostId: string | undefined
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const HostStatusGatesContext = createContext<HostStatusGates | null>(null)
|
||||
|
||||
export function useHostProtocolGates(): HostStatusGates {
|
||||
const gates = useContext(HostStatusGatesContext)
|
||||
if (!gates) {
|
||||
throw new Error('useHostProtocolGates must be used inside <HostProtocolGate>')
|
||||
}
|
||||
return gates
|
||||
}
|
||||
|
||||
// Why: single choke point above every /h/[hostId] route so a blocked verdict replaces the
|
||||
// whole host UI (sidebar + detail stack) while the host list and other hosts stay usable.
|
||||
export function HostProtocolGate({ hostId, children }: Props) {
|
||||
const { client, state } = useHostClient(hostId)
|
||||
const gates = useHostStatusGates({ hostId, client, connState: state })
|
||||
const { compatVerdict, statusPending } = gates
|
||||
const resolvedHostIdRef = useRef<string | null>(null)
|
||||
const hostKey = hostId ?? null
|
||||
if (state === 'connected' && client && !statusPending) {
|
||||
resolvedHostIdRef.current = hostKey
|
||||
}
|
||||
if (statusPending && resolvedHostIdRef.current !== hostKey) {
|
||||
// Why: child routes may call newer RPCs on mount, so wait until compatibility is known.
|
||||
return (
|
||||
<View style={styles.pending}>
|
||||
<ActivityIndicator
|
||||
color={colors.textSecondary}
|
||||
accessibilityLabel="Checking host compatibility"
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
if (compatVerdict.kind === 'blocked') {
|
||||
return <ProtocolBlockScreen verdict={compatVerdict} />
|
||||
}
|
||||
// Why: the host sidebar needs the same status fields; sharing the result avoids a second status.get per route.
|
||||
return <HostStatusGatesContext.Provider value={gates}>{children}</HostStatusGatesContext.Provider>
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
pending: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.bgBase
|
||||
}
|
||||
})
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { ActivityIndicator, Pressable, StyleSheet, Switch, Text, View } from 'react-native'
|
||||
import { Check, Download } from 'lucide-react-native'
|
||||
import { BottomDrawer } from './BottomDrawer'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { triggerError, triggerSuccess } from '../platform/haptics'
|
||||
import { useDictationSetupPoller } from '../dictation/use-dictation-setup-poller'
|
||||
import {
|
||||
downloadDictationModel,
|
||||
fetchDictationSetup,
|
||||
@@ -37,40 +38,34 @@ export function MobileDictationSetupSheet({ visible, client, onClose, onReady }:
|
||||
const [setup, setSetup] = useState<MobileSpeechSetup | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const refresh = useCallback(async (): Promise<boolean | undefined> => {
|
||||
if (!client) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
try {
|
||||
setSetup(await fetchDictationSetup(client))
|
||||
const next = await fetchDictationSetup(client)
|
||||
setSetup(next)
|
||||
setError(null)
|
||||
return next.models.some(isModelInFlight)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load')
|
||||
return undefined
|
||||
}
|
||||
}, [client])
|
||||
|
||||
const polling = setup?.models.some(isModelInFlight) ?? false
|
||||
const refreshSetup = useDictationSetupPoller({
|
||||
visible: visible && client !== null,
|
||||
polling,
|
||||
refresh,
|
||||
intervalMs: POLL_INTERVAL_MS
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setError(null)
|
||||
void refresh()
|
||||
}
|
||||
}, [visible, refresh])
|
||||
|
||||
// Poll only while something is downloading/extracting; stop otherwise.
|
||||
useEffect(() => {
|
||||
const inFlight = setup?.models.some(isModelInFlight) ?? false
|
||||
if (visible && inFlight && client) {
|
||||
pollRef.current = setInterval(() => void refresh(), POLL_INTERVAL_MS)
|
||||
return () => {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}, [visible, setup, client, refresh])
|
||||
}, [visible])
|
||||
|
||||
const handleDownload = useCallback(
|
||||
async (model: MobileSpeechModel) => {
|
||||
@@ -81,7 +76,7 @@ export function MobileDictationSetupSheet({ visible, client, onClose, onReady }:
|
||||
setError(null)
|
||||
try {
|
||||
await downloadDictationModel(client, model.id)
|
||||
await refresh()
|
||||
await refreshSetup()
|
||||
} catch (err) {
|
||||
triggerError()
|
||||
setError(err instanceof Error ? err.message : 'Download failed')
|
||||
@@ -89,7 +84,7 @@ export function MobileDictationSetupSheet({ visible, client, onClose, onReady }:
|
||||
setBusy(null)
|
||||
}
|
||||
},
|
||||
[client, refresh]
|
||||
[client, refreshSetup]
|
||||
)
|
||||
|
||||
const handleUseModel = useCallback(
|
||||
|
||||
@@ -12,14 +12,13 @@ type Props = {
|
||||
|
||||
export function ProtocolBlockScreen({ verdict }: Props) {
|
||||
const isMobileTooOld = verdict.reason === 'mobile-too-old'
|
||||
// Why: Android APKs ship through GitHub Releases until a Play Store listing exists.
|
||||
const mobileUpdateTarget =
|
||||
Platform.OS === 'ios'
|
||||
? { label: 'Open App Store', url: IOS_APP_STORE_URL, storeName: 'the App Store' }
|
||||
: { label: null, url: null, storeName: 'your mobile app store' }
|
||||
: { label: 'Open GitHub Releases', url: RELEASES_URL, storeName: 'GitHub Releases' }
|
||||
const primaryAction = isMobileTooOld
|
||||
? mobileUpdateTarget.url && mobileUpdateTarget.label
|
||||
? { label: mobileUpdateTarget.label, url: mobileUpdateTarget.url }
|
||||
: null
|
||||
? { label: mobileUpdateTarget.label, url: mobileUpdateTarget.url }
|
||||
: { label: 'Open GitHub Releases', url: RELEASES_URL }
|
||||
|
||||
const title = isMobileTooOld ? 'Update Orca Mobile' : 'Update Orca on your computer'
|
||||
@@ -34,18 +33,14 @@ export function ProtocolBlockScreen({ verdict }: Props) {
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Text style={styles.body}>{body}</Text>
|
||||
{/* Why: desktop updates come from GitHub; mobile update links depend
|
||||
on the native store available for this platform. */}
|
||||
{primaryAction ? (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.primaryButton, pressed && styles.pressed]}
|
||||
onPress={() => {
|
||||
void Linking.openURL(primaryAction.url)
|
||||
}}
|
||||
>
|
||||
<Text style={styles.primaryButtonText}>{primaryAction.label}</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.primaryButton, pressed && styles.pressed]}
|
||||
onPress={() => {
|
||||
void Linking.openURL(primaryAction.url)
|
||||
}}
|
||||
>
|
||||
<Text style={styles.primaryButtonText}>{primaryAction.label}</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.secondaryButton, pressed && styles.pressed]}
|
||||
onPress={() => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { memo } from 'react'
|
||||
import { StyleSheet, Text, View } from 'react-native'
|
||||
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
|
||||
import { colors, spacing } from '../theme/mobile-theme'
|
||||
@@ -18,7 +19,7 @@ type Props = {
|
||||
|
||||
// One inline agent row: state dot → identity → last message/prompt → time ago.
|
||||
// Mirrors desktop DashboardAgentRow's compact in-card layout.
|
||||
export function WorktreeAgentRow({ agent, depth, now, unvisited }: Props) {
|
||||
function WorktreeAgentRowComponent({ agent, depth, now, unvisited }: Props) {
|
||||
const dotState = agentDotState(agent, now)
|
||||
const label = agentDisplayLabel(agent, now)
|
||||
const ts = formatTimeAgo(agent.stateStartedAt, now)
|
||||
@@ -37,6 +38,8 @@ export function WorktreeAgentRow({ agent, depth, now, unvisited }: Props) {
|
||||
)
|
||||
}
|
||||
|
||||
export const WorktreeAgentRow = memo(WorktreeAgentRowComponent)
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import {
|
||||
createElement,
|
||||
Fragment,
|
||||
useCallback,
|
||||
useState,
|
||||
type Dispatch,
|
||||
type SetStateAction
|
||||
} from 'react'
|
||||
import { Text } from 'react-native'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
|
||||
import { WorktreeAgentRow } from './WorktreeAgentRow'
|
||||
import { WorktreeListRow, type WorktreeListRowItem } from './WorktreeListRow'
|
||||
|
||||
const { agentSpinnerRender, agentStateDotRender } = vi.hoisted(() => ({
|
||||
agentSpinnerRender: vi.fn(),
|
||||
agentStateDotRender: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
Pressable: 'Pressable',
|
||||
StyleSheet: { create: <T>(styles: T) => styles },
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
Bell: 'Bell',
|
||||
ChevronDown: 'ChevronDown',
|
||||
ChevronRight: 'ChevronRight',
|
||||
GitBranch: 'GitBranch',
|
||||
GitPullRequest: 'GitPullRequest'
|
||||
}))
|
||||
|
||||
vi.mock('../platform/haptics', () => ({ triggerMediumImpact: vi.fn() }))
|
||||
vi.mock('./AgentSpinner', () => ({
|
||||
AgentSpinner: (props: unknown) => {
|
||||
agentSpinnerRender(props)
|
||||
return null
|
||||
}
|
||||
}))
|
||||
vi.mock('./AgentStateDot', () => ({
|
||||
AgentStateDot: (props: unknown) => {
|
||||
agentStateDotRender(props)
|
||||
return null
|
||||
}
|
||||
}))
|
||||
vi.mock('./MobileAgentIcon', () => ({ MobileAgentIcon: () => null }))
|
||||
vi.mock('./MobileRepoIcon', () => ({ MobileRepoIcon: () => null }))
|
||||
vi.mock('./WorktreeAgentList', () => ({ WorktreeAgentList: () => null }))
|
||||
vi.mock('./WorktreeMetaGlyphs', () => ({
|
||||
prStateColor: () => '#000000',
|
||||
WorktreeMetaGlyphs: () => null
|
||||
}))
|
||||
|
||||
type TestItem = WorktreeListRowItem & {
|
||||
status: 'working' | 'active' | 'permission' | 'done' | 'inactive'
|
||||
lastOutputAt: number
|
||||
}
|
||||
|
||||
const stableRepoIcon = { type: 'emoji', emoji: 'o' } as const
|
||||
let updateSibling: Dispatch<SetStateAction<number>> = () => undefined
|
||||
|
||||
function ListRowHarness({ item, now }: { item: TestItem; now: number }) {
|
||||
const [sibling, setSibling] = useState(0)
|
||||
updateSibling = setSibling
|
||||
const onPress = useCallback(() => undefined, [])
|
||||
const onLongPress = useCallback(() => undefined, [])
|
||||
const onToggleLineage = useCallback(() => undefined, [])
|
||||
|
||||
// Sibling state changes re-render the harness without changing the row's props,
|
||||
// exercising the row's React.memo bailout.
|
||||
return createElement(
|
||||
Fragment,
|
||||
null,
|
||||
createElement(Text, null, sibling),
|
||||
createElement(WorktreeListRow, {
|
||||
item,
|
||||
isReadOnly: false,
|
||||
now,
|
||||
repoColor: '#000000',
|
||||
repoIcon: stableRepoIcon,
|
||||
hideRepo: false,
|
||||
status: item.status,
|
||||
onPress,
|
||||
onLongPress,
|
||||
onToggleLineage
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function agent(overrides: Partial<RuntimeWorktreeAgentRow> = {}): RuntimeWorktreeAgentRow {
|
||||
return {
|
||||
paneKey: 'agent-1',
|
||||
parentPaneKey: null,
|
||||
state: 'working',
|
||||
agentType: null,
|
||||
prompt: 'Fix the list',
|
||||
taskTitle: null,
|
||||
displayName: null,
|
||||
lastAssistantMessage: null,
|
||||
toolName: null,
|
||||
toolInput: null,
|
||||
interrupted: false,
|
||||
stateStartedAt: 1_000,
|
||||
updatedAt: 1_000,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const baseItem: TestItem = {
|
||||
worktreeId: 'worktree-1',
|
||||
repo: 'orca',
|
||||
branch: 'feature/mobile-list',
|
||||
displayName: 'mobile-list',
|
||||
liveTerminalCount: 1,
|
||||
preview: 'Waiting',
|
||||
unread: false,
|
||||
linkedPR: null,
|
||||
agents: [agent()],
|
||||
status: 'active',
|
||||
lastOutputAt: 1_000
|
||||
}
|
||||
|
||||
describe('memoized worktree rows', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
agentSpinnerRender.mockClear()
|
||||
agentStateDotRender.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
it('skips an unrelated parent render but updates for live item fields and time', async () => {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(ListRowHarness, { item: baseItem, now: 2_000 }))
|
||||
})
|
||||
expect(agentSpinnerRender).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => updateSibling((value) => value + 1))
|
||||
expect(agentSpinnerRender).toHaveBeenCalledTimes(1)
|
||||
|
||||
let expectedRenders = 1
|
||||
const liveUpdates: TestItem[] = [
|
||||
{ ...baseItem, preview: 'Running tests' },
|
||||
{ ...baseItem, unread: true },
|
||||
{ ...baseItem, lastOutputAt: 2_000 },
|
||||
{ ...baseItem, agents: [agent({ state: 'waiting', updatedAt: 2_000 })] },
|
||||
{ ...baseItem, status: 'working' }
|
||||
]
|
||||
for (const liveUpdate of liveUpdates) {
|
||||
await act(async () =>
|
||||
renderer!.update(createElement(ListRowHarness, { item: liveUpdate, now: 2_000 }))
|
||||
)
|
||||
expect(agentSpinnerRender).toHaveBeenCalledTimes(++expectedRenders)
|
||||
await act(async () =>
|
||||
renderer!.update(createElement(ListRowHarness, { item: baseItem, now: 2_000 }))
|
||||
)
|
||||
expect(agentSpinnerRender).toHaveBeenCalledTimes(++expectedRenders)
|
||||
}
|
||||
|
||||
await act(async () =>
|
||||
renderer!.update(createElement(ListRowHarness, { item: baseItem, now: 32_000 }))
|
||||
)
|
||||
expect(agentSpinnerRender).toHaveBeenCalledTimes(++expectedRenders)
|
||||
})
|
||||
|
||||
it('memoizes agent rows without hiding agent updates', async () => {
|
||||
const firstAgent = agent()
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(WorktreeAgentRow, {
|
||||
agent: firstAgent,
|
||||
depth: 0,
|
||||
now: 2_000,
|
||||
unvisited: false
|
||||
})
|
||||
)
|
||||
})
|
||||
expect(agentStateDotRender).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => {
|
||||
renderer!.update(
|
||||
createElement(WorktreeAgentRow, {
|
||||
agent: firstAgent,
|
||||
depth: 0,
|
||||
now: 2_000,
|
||||
unvisited: false
|
||||
})
|
||||
)
|
||||
})
|
||||
expect(agentStateDotRender).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => {
|
||||
renderer!.update(
|
||||
createElement(WorktreeAgentRow, {
|
||||
agent: agent({ state: 'done', updatedAt: 2_000 }),
|
||||
depth: 0,
|
||||
now: 2_000,
|
||||
unvisited: false
|
||||
})
|
||||
)
|
||||
})
|
||||
expect(agentStateDotRender).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { memo } from 'react'
|
||||
import { Bell, ChevronDown, ChevronRight, GitBranch, GitPullRequest } from 'lucide-react-native'
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import type { RepoIcon } from '../../../src/shared/repo-icon'
|
||||
@@ -57,7 +58,7 @@ type Props<T extends WorktreeListRowItem> = {
|
||||
onToggleLineage?: (item: T) => void
|
||||
}
|
||||
|
||||
export function WorktreeListRow<T extends WorktreeListRowItem>({
|
||||
function WorktreeListRowComponent<T extends WorktreeListRowItem>({
|
||||
item,
|
||||
isReadOnly,
|
||||
now,
|
||||
@@ -195,6 +196,8 @@ export function WorktreeListRow<T extends WorktreeListRowItem>({
|
||||
)
|
||||
}
|
||||
|
||||
export const WorktreeListRow = memo(WorktreeListRowComponent) as typeof WorktreeListRowComponent
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
worktreeRow: {
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DictationSetupPollController } from './dictation-setup-poll-controller'
|
||||
|
||||
const POLL_INTERVAL_MS = 1500
|
||||
|
||||
async function flushPromises(): Promise<void> {
|
||||
// Why: the refresh mock wraps its result in `.finally()` and the resume path chains
|
||||
// runRefresh → requestRefresh → runRefresh, so the follow-up refresh is several microtask
|
||||
// hops deep — drain generously rather than a fixed two ticks.
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
function deferred<T>(): {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
} {
|
||||
let resolve!: (value: T) => void
|
||||
return {
|
||||
promise: new Promise<T>((next) => {
|
||||
resolve = next
|
||||
}),
|
||||
resolve
|
||||
}
|
||||
}
|
||||
|
||||
describe('DictationSetupPollController', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('does not refresh while hidden, unfocused, or backgrounded', async () => {
|
||||
const refresh = vi.fn().mockResolvedValue(true)
|
||||
const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS)
|
||||
poller.setPolling(true)
|
||||
|
||||
poller.setForeground(true)
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2)
|
||||
expect(refresh).not.toHaveBeenCalled()
|
||||
|
||||
poller.setVisible(true)
|
||||
expect(refresh).toHaveBeenCalledOnce()
|
||||
await flushPromises()
|
||||
poller.setVisible(false)
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2)
|
||||
expect(refresh).toHaveBeenCalledOnce()
|
||||
|
||||
poller.setVisible(true)
|
||||
expect(refresh).toHaveBeenCalledTimes(2)
|
||||
await flushPromises()
|
||||
poller.setForeground(false)
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2)
|
||||
expect(refresh).toHaveBeenCalledTimes(2)
|
||||
|
||||
poller.dispose()
|
||||
})
|
||||
|
||||
it('keeps slow refreshes single-flight and waits a full delay after each response', async () => {
|
||||
const requests = [deferred<boolean>(), deferred<boolean>(), deferred<boolean>()]
|
||||
let active = 0
|
||||
let maxActive = 0
|
||||
const refresh = vi.fn(() => {
|
||||
const request = requests[refresh.mock.calls.length - 1]
|
||||
active += 1
|
||||
maxActive = Math.max(maxActive, active)
|
||||
return request.promise.finally(() => {
|
||||
active -= 1
|
||||
})
|
||||
})
|
||||
const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS)
|
||||
poller.setPolling(true)
|
||||
poller.setVisible(true)
|
||||
poller.setForeground(true)
|
||||
|
||||
expect(refresh).toHaveBeenCalledOnce()
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 4)
|
||||
expect(refresh).toHaveBeenCalledOnce()
|
||||
|
||||
requests[0].resolve(true)
|
||||
await flushPromises()
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS)
|
||||
expect(refresh).toHaveBeenCalledTimes(2)
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 4)
|
||||
expect(refresh).toHaveBeenCalledTimes(2)
|
||||
|
||||
requests[1].resolve(true)
|
||||
await flushPromises()
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS - 1)
|
||||
expect(refresh).toHaveBeenCalledTimes(2)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(refresh).toHaveBeenCalledTimes(3)
|
||||
expect(maxActive).toBe(1)
|
||||
|
||||
requests[2].resolve(false)
|
||||
await flushPromises()
|
||||
poller.dispose()
|
||||
})
|
||||
|
||||
it('coalesces an immediate resume refresh behind a slow request', async () => {
|
||||
const requests = [deferred<boolean>(), deferred<boolean>()]
|
||||
let active = 0
|
||||
let maxActive = 0
|
||||
const refresh = vi.fn(() => {
|
||||
const request = requests[refresh.mock.calls.length - 1]
|
||||
active += 1
|
||||
maxActive = Math.max(maxActive, active)
|
||||
return request.promise.finally(() => {
|
||||
active -= 1
|
||||
})
|
||||
})
|
||||
const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS)
|
||||
poller.setPolling(true)
|
||||
poller.setVisible(true)
|
||||
poller.setForeground(true)
|
||||
|
||||
poller.setForeground(false)
|
||||
poller.setForeground(true)
|
||||
expect(refresh).toHaveBeenCalledOnce()
|
||||
|
||||
requests[0].resolve(true)
|
||||
await flushPromises()
|
||||
expect(refresh).toHaveBeenCalledTimes(2)
|
||||
expect(maxActive).toBe(1)
|
||||
|
||||
requests[1].resolve(false)
|
||||
await flushPromises()
|
||||
poller.dispose()
|
||||
})
|
||||
|
||||
it('refreshes immediately when visibility or foreground eligibility resumes', async () => {
|
||||
const refresh = vi.fn().mockResolvedValue(true)
|
||||
const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS)
|
||||
poller.setPolling(true)
|
||||
poller.setVisible(true)
|
||||
poller.setForeground(true)
|
||||
expect(refresh).toHaveBeenCalledOnce()
|
||||
await flushPromises()
|
||||
|
||||
poller.setForeground(false)
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2)
|
||||
poller.setForeground(true)
|
||||
expect(refresh).toHaveBeenCalledTimes(2)
|
||||
await flushPromises()
|
||||
|
||||
poller.setVisible(false)
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2)
|
||||
poller.setVisible(true)
|
||||
expect(refresh).toHaveBeenCalledTimes(3)
|
||||
|
||||
poller.dispose()
|
||||
})
|
||||
|
||||
it('stops after setup leaves the download or extraction lifecycle', async () => {
|
||||
const refresh = vi.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(false)
|
||||
const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS)
|
||||
poller.setPolling(true)
|
||||
poller.setVisible(true)
|
||||
poller.setForeground(true)
|
||||
await flushPromises()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS)
|
||||
expect(refresh).toHaveBeenCalledTimes(2)
|
||||
await flushPromises()
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 4)
|
||||
expect(refresh).toHaveBeenCalledTimes(2)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
poller.dispose()
|
||||
})
|
||||
|
||||
it('does not resurrect polling when an in-flight refresh resolves true after setPolling(false)', async () => {
|
||||
const request = deferred<boolean>()
|
||||
const refresh = vi.fn(() => request.promise)
|
||||
const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS)
|
||||
poller.setPolling(true)
|
||||
poller.setVisible(true)
|
||||
poller.setForeground(true)
|
||||
expect(refresh).toHaveBeenCalledOnce()
|
||||
|
||||
// Explicit stop lands while the read is still on the wire.
|
||||
poller.setPolling(false)
|
||||
// The stale read then resolves "keep polling" — the fence must drop it, not restart the poll.
|
||||
request.resolve(true)
|
||||
await flushPromises()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 4)
|
||||
expect(refresh).toHaveBeenCalledOnce()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
poller.dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,153 @@
|
||||
type PollState = {
|
||||
visible: boolean
|
||||
foreground: boolean
|
||||
polling: boolean
|
||||
}
|
||||
|
||||
type RefreshResult = boolean | undefined
|
||||
|
||||
export class DictationSetupPollController {
|
||||
private state: PollState = { visible: false, foreground: false, polling: false }
|
||||
private timer: ReturnType<typeof setTimeout> | null = null
|
||||
private inFlight = false
|
||||
private immediateRefreshPending = false
|
||||
private refreshWaiters: Array<() => void> = []
|
||||
private disposed = false
|
||||
// Why: an explicit setPolling is a newer lifecycle intent than a read that was already on the wire.
|
||||
// Bumped on every setPolling so an in-flight refresh resolving after an explicit stop/start can be
|
||||
// fenced out instead of clobbering that intent (e.g. a late `true` resurrecting a just-stopped poll).
|
||||
private pollingRevision = 0
|
||||
|
||||
constructor(
|
||||
private readonly refresh: () => Promise<RefreshResult>,
|
||||
private readonly intervalMs: number
|
||||
) {}
|
||||
|
||||
setVisible(visible: boolean): void {
|
||||
this.update({ visible })
|
||||
}
|
||||
|
||||
setForeground(foreground: boolean): void {
|
||||
this.update({ foreground })
|
||||
}
|
||||
|
||||
setPolling(polling: boolean): void {
|
||||
this.pollingRevision += 1
|
||||
this.update({ polling })
|
||||
}
|
||||
|
||||
refreshNow(): Promise<void> {
|
||||
if (this.disposed || !this.isEligible()) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
this.refreshWaiters.push(resolve)
|
||||
this.requestRefresh(true)
|
||||
})
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true
|
||||
this.immediateRefreshPending = false
|
||||
this.clearTimer()
|
||||
this.resolveRefreshWaiters()
|
||||
}
|
||||
|
||||
private update(next: Partial<PollState>): void {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
const wasEligible = this.isEligible()
|
||||
const wasPolling = this.state.polling
|
||||
this.state = { ...this.state, ...next }
|
||||
|
||||
if (!this.isEligible()) {
|
||||
this.immediateRefreshPending = false
|
||||
this.clearTimer()
|
||||
return
|
||||
}
|
||||
if (!wasEligible) {
|
||||
this.requestRefresh(true)
|
||||
return
|
||||
}
|
||||
if (!this.state.polling) {
|
||||
this.clearTimer()
|
||||
return
|
||||
}
|
||||
if (!wasPolling) {
|
||||
this.scheduleRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
private isEligible(): boolean {
|
||||
return this.state.visible && this.state.foreground
|
||||
}
|
||||
|
||||
private requestRefresh(immediate: boolean): void {
|
||||
if (this.inFlight) {
|
||||
this.immediateRefreshPending ||= immediate
|
||||
return
|
||||
}
|
||||
this.clearTimer()
|
||||
this.inFlight = true
|
||||
void this.runRefresh()
|
||||
}
|
||||
|
||||
private async runRefresh(): Promise<void> {
|
||||
// Snapshot the lifecycle intent this read is answering; an explicit setPolling during the read makes
|
||||
// its result stale.
|
||||
const revisionAtStart = this.pollingRevision
|
||||
let shouldContinue: RefreshResult
|
||||
try {
|
||||
shouldContinue = await this.refresh()
|
||||
} catch {
|
||||
// A transient read failure preserves the current lifecycle for a later retry.
|
||||
shouldContinue = undefined
|
||||
} finally {
|
||||
this.inFlight = false
|
||||
}
|
||||
|
||||
// Fence: only let the read drive polling if no explicit setPolling superseded it mid-flight, so a
|
||||
// late `true` can't resurrect a poll the caller just stopped (nor a late `false` cancel a restart).
|
||||
if (shouldContinue !== undefined && this.pollingRevision === revisionAtStart) {
|
||||
this.state.polling = shouldContinue
|
||||
}
|
||||
if (this.disposed || !this.isEligible()) {
|
||||
this.resolveRefreshWaiters()
|
||||
return
|
||||
}
|
||||
if (this.immediateRefreshPending) {
|
||||
this.immediateRefreshPending = false
|
||||
this.requestRefresh(true)
|
||||
return
|
||||
}
|
||||
this.resolveRefreshWaiters()
|
||||
if (this.state.polling) {
|
||||
this.scheduleRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleRefresh(): void {
|
||||
if (this.timer !== null || this.inFlight || !this.isEligible() || !this.state.polling) {
|
||||
return
|
||||
}
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null
|
||||
this.requestRefresh(false)
|
||||
}, this.intervalMs)
|
||||
}
|
||||
|
||||
private clearTimer(): void {
|
||||
if (this.timer !== null) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
}
|
||||
|
||||
private resolveRefreshWaiters(): void {
|
||||
const waiters = this.refreshWaiters.splice(0)
|
||||
for (const resolve of waiters) {
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { AppState } from 'react-native'
|
||||
import { DictationSetupPollController } from './dictation-setup-poll-controller'
|
||||
|
||||
type PollerOptions = {
|
||||
visible: boolean
|
||||
polling: boolean
|
||||
refresh: () => Promise<boolean | undefined>
|
||||
intervalMs: number
|
||||
}
|
||||
|
||||
export function useDictationSetupPoller({
|
||||
visible,
|
||||
polling,
|
||||
refresh,
|
||||
intervalMs
|
||||
}: PollerOptions): () => Promise<void> {
|
||||
const refreshRef = useRef(refresh)
|
||||
refreshRef.current = refresh
|
||||
const poller = useMemo(
|
||||
() => new DictationSetupPollController(() => refreshRef.current(), intervalMs),
|
||||
[intervalMs]
|
||||
)
|
||||
|
||||
useEffect(() => () => poller.dispose(), [poller])
|
||||
|
||||
useEffect(() => {
|
||||
void poller.refreshNow()
|
||||
}, [poller, refresh])
|
||||
|
||||
useEffect(() => {
|
||||
poller.setPolling(polling)
|
||||
}, [poller, polling])
|
||||
|
||||
useEffect(() => {
|
||||
poller.setVisible(visible)
|
||||
if (!visible) {
|
||||
poller.setForeground(false)
|
||||
return undefined
|
||||
}
|
||||
|
||||
poller.setForeground(AppState.currentState === 'active')
|
||||
const subscription = AppState.addEventListener('change', (state) => {
|
||||
poller.setForeground(state === 'active')
|
||||
})
|
||||
return () => {
|
||||
subscription.remove()
|
||||
poller.setVisible(false)
|
||||
poller.setForeground(false)
|
||||
}
|
||||
}, [poller, visible])
|
||||
|
||||
return useCallback(() => poller.refreshNow(), [poller])
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
|
||||
|
||||
const appState = vi.hoisted(() => ({
|
||||
current: 'active',
|
||||
listener: null as ((nextState: string) => void) | null,
|
||||
remove: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
AppState: {
|
||||
get currentState(): string {
|
||||
return appState.current
|
||||
},
|
||||
addEventListener: (_event: string, listener: (nextState: string) => void) => {
|
||||
appState.listener = listener
|
||||
return { remove: appState.remove }
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
import { useNow } from './use-now'
|
||||
|
||||
describe('useNow', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let latest = 0
|
||||
let consoleSpy: MockInstance
|
||||
|
||||
function Harness({ enabled = true }: { enabled?: boolean }): null {
|
||||
latest = useNow(1_000, enabled)
|
||||
return null
|
||||
}
|
||||
|
||||
function changeAppState(nextState: string): void {
|
||||
act(() => {
|
||||
appState.current = nextState
|
||||
appState.listener?.(nextState)
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
appState.current = 'active'
|
||||
appState.listener = null
|
||||
appState.remove.mockClear()
|
||||
latest = 0
|
||||
const original = console.error
|
||||
consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
})
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
vi.useRealTimers()
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('ticks while active, pauses in the background, and refreshes immediately on resume', () => {
|
||||
expect(latest).toBe(1_000)
|
||||
|
||||
act(() => vi.advanceTimersByTime(1_000))
|
||||
expect(latest).toBe(2_000)
|
||||
|
||||
changeAppState('background')
|
||||
act(() => vi.advanceTimersByTime(5_000))
|
||||
expect(latest).toBe(2_000)
|
||||
|
||||
changeAppState('active')
|
||||
expect(latest).toBe(7_000)
|
||||
|
||||
act(() => vi.advanceTimersByTime(1_000))
|
||||
expect(latest).toBe(8_000)
|
||||
})
|
||||
|
||||
it('stops while disabled and refreshes immediately when re-enabled', () => {
|
||||
act(() => renderer?.update(createElement(Harness, { enabled: false })))
|
||||
act(() => vi.advanceTimersByTime(5_000))
|
||||
expect(latest).toBe(1_000)
|
||||
|
||||
act(() => renderer?.update(createElement(Harness, { enabled: true })))
|
||||
expect(latest).toBe(6_000)
|
||||
})
|
||||
|
||||
it('removes the shared AppState listener after the last caller unmounts', () => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
|
||||
expect(appState.remove).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,14 +1,52 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import { AppState, type AppStateStatus } from 'react-native'
|
||||
|
||||
// One shared interval per caller, mirroring desktop's useNow: relative
|
||||
// timestamps ("Xm") need a periodic re-render to stay honest. The worktree list
|
||||
// owns a single tick that drives every visible agent row, rather than each row
|
||||
// running its own interval.
|
||||
export function useNow(intervalMs = 30_000): number {
|
||||
const appStateListeners = new Set<() => void>()
|
||||
let currentAppState: AppStateStatus | null = AppState.currentState
|
||||
let appStateSubscription: ReturnType<typeof AppState.addEventListener> | null = null
|
||||
|
||||
function subscribeToAppState(listener: () => void): () => void {
|
||||
appStateListeners.add(listener)
|
||||
if (!appStateSubscription) {
|
||||
currentAppState = AppState.currentState
|
||||
appStateSubscription = AppState.addEventListener('change', (nextState) => {
|
||||
currentAppState = nextState
|
||||
appStateListeners.forEach((notify) => notify())
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
appStateListeners.delete(listener)
|
||||
if (appStateListeners.size === 0) {
|
||||
appStateSubscription?.remove()
|
||||
appStateSubscription = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isAppActive(): boolean {
|
||||
return (appStateSubscription ? currentAppState : AppState.currentState) === 'active'
|
||||
}
|
||||
|
||||
// A list-level caller's single tick drives every visible relative-time label.
|
||||
export function useNow(intervalMs = 30_000, enabled = true): number {
|
||||
const appActive = useSyncExternalStore(subscribeToAppState, isAppActive, isAppActive)
|
||||
const running = appActive && enabled
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const wasRunningRef = useRef(running)
|
||||
|
||||
useEffect(() => {
|
||||
const resumed = running && !wasRunningRef.current
|
||||
wasRunningRef.current = running
|
||||
if (!running) {
|
||||
return
|
||||
}
|
||||
if (resumed) {
|
||||
setNow(Date.now())
|
||||
}
|
||||
const id = setInterval(() => setNow(Date.now()), intervalMs)
|
||||
return () => clearInterval(id)
|
||||
}, [intervalMs])
|
||||
}, [intervalMs, running])
|
||||
|
||||
return now
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ export const MOBILE_SESSION_STATUS_LABELS: Record<ConnectionState, string> = {
|
||||
connected: 'Connected',
|
||||
disconnected: 'Disconnected',
|
||||
reconnecting: 'Reconnecting',
|
||||
'auth-failed': 'Auth failed'
|
||||
'auth-failed': 'Pairing invalid'
|
||||
}
|
||||
|
||||
export const TERMINAL_GESTURE_INPUT_BUCKET_CAPACITY = 64
|
||||
|
||||
@@ -29,6 +29,26 @@ describe('mobile session startup', () => {
|
||||
expect(autoCreateEffect).toContain('void handleCreateTerminal()')
|
||||
})
|
||||
|
||||
it('stops fallback list polling in the background and reconciles on resume', () => {
|
||||
const pollEffect = sliceBetween(
|
||||
"const refreshOnForeground = () => {\n if (AppState.currentState !== 'active')",
|
||||
'// Why: pick up Settings → Terminal text size on return'
|
||||
)
|
||||
|
||||
expect(pollEffect).toContain(
|
||||
"if (AppState.currentState !== 'active') {\n return\n }\n void fetchSessionTabs()"
|
||||
)
|
||||
expect(pollEffect).toContain('void fetchTerminals()')
|
||||
expect(pollEffect).toContain("AppState.addEventListener('change'")
|
||||
expect(pollEffect).toContain("if (state === 'active') {\n refreshOnForeground()")
|
||||
expect(pollEffect).toContain('const interval = setInterval(refreshOnForeground, 2000)')
|
||||
expect(pollEffect.lastIndexOf('\n refreshOnForeground()')).toBeGreaterThan(
|
||||
pollEffect.indexOf("AppState.addEventListener('change'")
|
||||
)
|
||||
expect(pollEffect).toContain('clearInterval(interval)')
|
||||
expect(pollEffect).toContain('appStateSubscription.remove()')
|
||||
})
|
||||
|
||||
it('loads session tabs without waiting for desktop activation', () => {
|
||||
const startupEffect = sliceBetween(
|
||||
'void (async () => {',
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import ts from 'typescript'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const fileUrl = new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url)
|
||||
const source = readFileSync(fileUrl, 'utf8')
|
||||
const sourceFile = ts.createSourceFile(
|
||||
fileUrl.href,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TSX
|
||||
)
|
||||
|
||||
function findQuickCommandsTabButtons(): ts.JsxSelfClosingElement[] {
|
||||
const matches: ts.JsxSelfClosingElement[] = []
|
||||
|
||||
function visit(node: ts.Node): void {
|
||||
if (
|
||||
ts.isJsxSelfClosingElement(node) &&
|
||||
node.tagName.getText(sourceFile) === 'QuickCommandsTabButton'
|
||||
) {
|
||||
matches.push(node)
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
|
||||
visit(sourceFile)
|
||||
return matches
|
||||
}
|
||||
|
||||
function getQuickCommandsTabSource(): string {
|
||||
const start = source.indexOf('accessibilityLabel="New tab"')
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
const end = source.indexOf('{/* Content-row host', start)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
return source.slice(start, end)
|
||||
}
|
||||
|
||||
describe('quick-commands tab stability', () => {
|
||||
it('keeps the button mounted while preserving the capability gate', () => {
|
||||
const tabSource = getQuickCommandsTabSource()
|
||||
const buttons = findQuickCommandsTabButtons()
|
||||
|
||||
expect(buttons).toHaveLength(1)
|
||||
const tabBar = buttons[0].parent
|
||||
expect(ts.isJsxElement(tabBar)).toBe(true)
|
||||
if (!ts.isJsxElement(tabBar)) {
|
||||
return
|
||||
}
|
||||
expect(tabBar.openingElement.tagName.getText(sourceFile)).toBe('View')
|
||||
const style = tabBar.openingElement.attributes.properties.find(
|
||||
(attribute): attribute is ts.JsxAttribute =>
|
||||
ts.isJsxAttribute(attribute) && attribute.name.getText(sourceFile) === 'style'
|
||||
)
|
||||
expect(style?.initializer?.getText(sourceFile)).toBe('{styles.tabBar}')
|
||||
expect(tabSource).toContain('if (quickCommandsSupported === true)')
|
||||
expect(tabSource).toContain('setShowQuickCommands(true)')
|
||||
expect(tabSource).toContain('Desktop update required for quick commands')
|
||||
expect(tabSource).toContain('Checking desktop capabilities — try again in a moment')
|
||||
})
|
||||
|
||||
it('only presents the sheet after support is confirmed', () => {
|
||||
expect(source).toContain('visible={showQuickCommands && quickCommandsSupported === true}')
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
@@ -185,12 +186,45 @@ describe('useMobileNativeChatAnswerSend', () => {
|
||||
expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '2', enter: false })
|
||||
})
|
||||
|
||||
it('submits a non-Claude answer as pasted label text with a single Enter', async () => {
|
||||
it('submits a Codex answer by option-number keystroke like Claude', async () => {
|
||||
const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
|
||||
await mount({ sendRequest } as unknown as RpcClient, vi.fn(), 'codex')
|
||||
|
||||
await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true)
|
||||
// Codex's question tool commits the pasted answer: label text + one Enter.
|
||||
// Codex's request_user_input card ignores pasted labels; the digit selects AND commits.
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1)
|
||||
expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '2', enter: false })
|
||||
})
|
||||
|
||||
it('does not send a trailing Enter after Codex submits a multi-question answer', async () => {
|
||||
const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
|
||||
await mount({ sendRequest } as unknown as RpcClient, vi.fn(), 'codex')
|
||||
const prompt: AskPrompt = {
|
||||
questions: [
|
||||
{ question: 'q1', multiSelect: false, options: [{ label: 'A' }, { label: 'B' }] },
|
||||
{ question: 'q2', multiSelect: false, options: [{ label: 'C' }, { label: 'D' }] }
|
||||
]
|
||||
}
|
||||
|
||||
let result: Promise<boolean> | undefined
|
||||
await act(async () => {
|
||||
result = answerSend?.answerAsk(prompt, [{ indices: [1] }, { indices: [0] }])
|
||||
})
|
||||
await act(async () => vi.runAllTimersAsync())
|
||||
|
||||
await expect(result).resolves.toBe(true)
|
||||
expect(sendRequest.mock.calls.map((call) => call[1])).toEqual([
|
||||
expect.objectContaining({ text: '2', enter: false }),
|
||||
expect.objectContaining({ text: '1', enter: false })
|
||||
])
|
||||
})
|
||||
|
||||
it('submits a non-selector answer as pasted label text with a single Enter', async () => {
|
||||
const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
|
||||
await mount({ sendRequest } as unknown as RpcClient, vi.fn(), 'grok')
|
||||
|
||||
await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true)
|
||||
// Grok's question tool commits the pasted answer: label text + one Enter.
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1)
|
||||
expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: 'Spaces', enter: true })
|
||||
})
|
||||
@@ -210,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())
|
||||
|
||||
@@ -3,16 +3,20 @@ import type { RpcClient } from '../transport/rpc-client'
|
||||
import { MOBILE_NATIVE_CHAT_QUESTION_STEP_MS } from './mobile-native-chat-answer-stepping'
|
||||
import {
|
||||
buildAskAnswerKeys,
|
||||
buildCodexAskAnswerKeys,
|
||||
formatAskAnswer,
|
||||
hasAskAnswer,
|
||||
type AskAnswerSelection,
|
||||
type AskPrompt
|
||||
} from './mobile-native-chat-ask'
|
||||
import { sendMobileNativeChatMessage } from './mobile-native-chat-send'
|
||||
import { shouldStepNativeChatAskAnswer } from '../../../src/shared/native-chat-agent-support'
|
||||
import { sendMobileNativeChatMessageWithOutcome } from './mobile-native-chat-send'
|
||||
import {
|
||||
resolveNativeChatTranscriptAgent,
|
||||
shouldStepNativeChatAskAnswer
|
||||
} from '../../../src/shared/native-chat-agent-support'
|
||||
|
||||
/** Sends an AskUserQuestion answer to the active chat pane. Claude's selector is
|
||||
* answered by option-number keystrokes; other agents get pasted label text.
|
||||
/** Sends an ask-user answer to the active chat pane. Claude and Codex selectors
|
||||
* use their agent-specific keystrokes; other agents get pasted label text.
|
||||
* Extracted from the session route to keep that file under its line cap and to
|
||||
* own the pending-timer lifecycle in one place. */
|
||||
export type MobileNativeChatAnswerSend = {
|
||||
@@ -32,8 +36,8 @@ function sanitizeAskFreeText(text: string): string {
|
||||
/**
|
||||
* Owns the ask-answer send sequence for the mobile native chat. Reads the live
|
||||
* pane/agent through refs (the route already keeps them current) so the returned
|
||||
* callbacks stay stable. Claude answers are delivered as `buildAskAnswerKeys`
|
||||
* keystroke groups written one selector-step apart over the EXISTING
|
||||
* callbacks stay stable. Selector answers are delivered as keystroke groups
|
||||
* written one step apart over the EXISTING
|
||||
* `terminal.send` passthrough (raw text, no enter) — same contract the
|
||||
* permission card already uses, so old runtimes replay them verbatim (no new
|
||||
* RPC; keystrokes are built client-side). The scheduled wait chain is cancelled
|
||||
@@ -98,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 ||
|
||||
@@ -107,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,
|
||||
@@ -118,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) => {
|
||||
@@ -132,18 +141,26 @@ 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
|
||||
}
|
||||
// Non-Claude question tools commit a pasted answer, so send the label text
|
||||
// with one Enter. Claude's arrow-navigate selector ignores pasted labels
|
||||
// (STA-1860): drive it by option-number keystrokes instead, one group per
|
||||
// selector step so each renders before the next lands.
|
||||
// Grok commits pasted labels; Claude and Codex need their selector-specific
|
||||
// keystrokes paced so each step renders before the next lands.
|
||||
if (!shouldStepNativeChatAskAnswer(agentRef.current)) {
|
||||
return (await sendTerminal(formatAskAnswer(prompt, selections), true)) || fail()
|
||||
}
|
||||
const groups = buildAskAnswerKeys(prompt, selections)
|
||||
const groups =
|
||||
resolveNativeChatTranscriptAgent(agentRef.current) === 'codex'
|
||||
? buildCodexAskAnswerKeys(prompt, selections)
|
||||
: buildAskAnswerKeys(prompt, selections)
|
||||
for (let index = 0; index < groups.length; index += 1) {
|
||||
if (generationRef.current !== generation) {
|
||||
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,
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { classifyConnection, verdictDisplayLabel } from './connection-health'
|
||||
|
||||
describe('classifyConnection auth-failed verdict', () => {
|
||||
it('tells the user to re-pair instead of showing a generic auth error', () => {
|
||||
const verdict = classifyConnection({
|
||||
state: 'auth-failed',
|
||||
reconnectAttempts: 0,
|
||||
lastConnectedAt: null,
|
||||
nowMs: 1_000_000
|
||||
})
|
||||
expect(verdict.kind).toBe('auth-failed')
|
||||
expect(verdictDisplayLabel(verdict)).toBe('Pairing invalid — re-pair with your desktop')
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyConnection Tailscale hint', () => {
|
||||
const base = {
|
||||
state: 'reconnecting' as const,
|
||||
|
||||
@@ -55,8 +55,10 @@ export function classifyConnection(args: {
|
||||
const now = args.nowMs ?? Date.now()
|
||||
const hint = isTailscaleEndpoint(args.endpoint) ? TAILSCALE_HINT : undefined
|
||||
|
||||
// Why: auth-failed means the desktop no longer recognizes this pairing (e.g. it
|
||||
// lost its device registry) — retrying can't fix it, only re-pairing can, so say so.
|
||||
if (state === 'auth-failed') {
|
||||
return { kind: 'auth-failed', label: 'Auth failed' }
|
||||
return { kind: 'auth-failed', label: 'Pairing invalid — re-pair with your desktop' }
|
||||
}
|
||||
|
||||
// Connected / connecting / handshaking are normal.
|
||||
|
||||
@@ -8,6 +8,7 @@ export type HostStatusGates = {
|
||||
hostCapabilities: string[]
|
||||
floatingWorkspaceEnabled: boolean
|
||||
compatVerdict: CompatVerdict
|
||||
statusPending: boolean
|
||||
}
|
||||
|
||||
type LoadedHostStatusGates = HostStatusGates & {
|
||||
@@ -42,6 +43,14 @@ export function useHostStatusGates(args: {
|
||||
return
|
||||
}
|
||||
if (!response.ok) {
|
||||
setLoaded({
|
||||
hostId,
|
||||
client: requestClient,
|
||||
hostCapabilities: [],
|
||||
floatingWorkspaceEnabled: false,
|
||||
compatVerdict: { kind: 'ok' },
|
||||
statusPending: false
|
||||
})
|
||||
return
|
||||
}
|
||||
const status = (response as RpcSuccess).result as DesktopStatus & {
|
||||
@@ -56,7 +65,8 @@ export function useHostStatusGates(args: {
|
||||
client: requestClient,
|
||||
hostCapabilities: status.capabilities ?? [],
|
||||
floatingWorkspaceEnabled: status.floatingWorkspaceEnabled === true,
|
||||
compatVerdict: verdict
|
||||
compatVerdict: verdict,
|
||||
statusPending: false
|
||||
})
|
||||
if (verdict.kind === 'blocked') {
|
||||
// Why: support breadcrumb to confirm a block fired vs a render bug; no PII, just version ints.
|
||||
@@ -68,7 +78,17 @@ export function useHostStatusGates(args: {
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Why: sendRequest can throw on transport tear-down; the fail-closed return below keeps gated actions hidden.
|
||||
// Why: a transient status failure must not trap navigation; conservative feature gates remain disabled.
|
||||
if (!cancelled) {
|
||||
setLoaded({
|
||||
hostId,
|
||||
client: requestClient,
|
||||
hostCapabilities: [],
|
||||
floatingWorkspaceEnabled: false,
|
||||
compatVerdict: { kind: 'ok' },
|
||||
statusPending: false
|
||||
})
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
@@ -87,12 +107,14 @@ export function useHostStatusGates(args: {
|
||||
return {
|
||||
hostCapabilities: EMPTY_HOST_CAPABILITIES,
|
||||
floatingWorkspaceEnabled: false,
|
||||
compatVerdict: { kind: 'ok' }
|
||||
compatVerdict: { kind: 'ok' },
|
||||
statusPending: connState === 'connected' && client !== null
|
||||
}
|
||||
}
|
||||
return {
|
||||
hostCapabilities: loaded.hostCapabilities,
|
||||
floatingWorkspaceEnabled: loaded.floatingWorkspaceEnabled,
|
||||
compatVerdict: loaded.compatVerdict
|
||||
compatVerdict: loaded.compatVerdict,
|
||||
statusPending: false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from './rpc-client'
|
||||
import type { RpcResponse } from './types'
|
||||
import { sendSingleFlightRequest } from './request-single-flight'
|
||||
|
||||
function makeResponse(id: string): RpcResponse {
|
||||
return { id, ok: true, result: {}, _meta: { runtimeId: 'runtime-1' } }
|
||||
}
|
||||
|
||||
const response = makeResponse('request-1')
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise
|
||||
reject = rejectPromise
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function rpcClient(sendRequest: RpcClient['sendRequest']): RpcClient {
|
||||
return { sendRequest } as RpcClient
|
||||
}
|
||||
|
||||
describe('sendSingleFlightRequest', () => {
|
||||
it('coalesces triggers that arrive during an in-flight read into one trailing follow-up', async () => {
|
||||
const leading = deferred<RpcResponse>()
|
||||
const trailing = deferred<RpcResponse>()
|
||||
const leadingResponse = makeResponse('leading')
|
||||
const trailingResponse = makeResponse('trailing')
|
||||
const sendRequest = vi
|
||||
.fn<() => Promise<RpcResponse>>()
|
||||
.mockReturnValueOnce(leading.promise)
|
||||
.mockReturnValueOnce(trailing.promise)
|
||||
const client = rpcClient(sendRequest)
|
||||
|
||||
const first = sendSingleFlightRequest(client, 'host-1', 'worktree.ps', { limit: 10000 })
|
||||
// Two more triggers arrive while the read is on the wire: no duplicate now, and they share ONE
|
||||
// trailing follow-up (not the older in-flight response).
|
||||
const second = sendSingleFlightRequest(client, 'host-1', 'worktree.ps', { limit: 10000 })
|
||||
const third = sendSingleFlightRequest(client, 'host-1', 'worktree.ps', { limit: 10000 })
|
||||
|
||||
expect(second).toBe(third)
|
||||
expect(second).not.toBe(first)
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1)
|
||||
|
||||
leading.resolve(leadingResponse)
|
||||
expect(await first).toBe(leadingResponse)
|
||||
|
||||
// The leading read settled → the coalesced follow-up fires exactly once.
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
trailing.resolve(trailingResponse)
|
||||
expect(await second).toBe(trailingResponse)
|
||||
expect(await third).toBe(trailingResponse)
|
||||
})
|
||||
|
||||
it('starts a fresh request once nothing is in flight', async () => {
|
||||
const leading = deferred<RpcResponse>()
|
||||
const sendRequest = vi
|
||||
.fn<() => Promise<RpcResponse>>()
|
||||
.mockReturnValueOnce(leading.promise)
|
||||
.mockResolvedValueOnce(response)
|
||||
const client = rpcClient(sendRequest)
|
||||
|
||||
const first = sendSingleFlightRequest(client, 'host-1', 'worktree.ps', { limit: 10000 })
|
||||
leading.resolve(response)
|
||||
await first
|
||||
|
||||
const next = sendSingleFlightRequest(client, 'host-1', 'worktree.ps', { limit: 10000 })
|
||||
expect(next).not.toBe(first)
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
await next
|
||||
})
|
||||
|
||||
it('rejects the leading caller on failure but still runs a queued follow-up', async () => {
|
||||
const leading = deferred<RpcResponse>()
|
||||
const failure = new Error('request failed')
|
||||
const sendRequest = vi
|
||||
.fn<() => Promise<RpcResponse>>()
|
||||
.mockReturnValueOnce(leading.promise)
|
||||
.mockResolvedValueOnce(response)
|
||||
const client = rpcClient(sendRequest)
|
||||
|
||||
const first = sendSingleFlightRequest(client, 'host-1', 'accounts.list')
|
||||
const second = sendSingleFlightRequest(client, 'host-1', 'accounts.list')
|
||||
|
||||
leading.reject(failure)
|
||||
await expect(first).rejects.toBe(failure)
|
||||
// A trigger that arrived mid-flight is not poisoned by the leading failure: its follow-up runs.
|
||||
await expect(second).resolves.toBe(response)
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('clears a failed leading request so the next call retries', async () => {
|
||||
const failure = new Error('request failed')
|
||||
const sendRequest = vi
|
||||
.fn<() => Promise<RpcResponse>>()
|
||||
.mockRejectedValueOnce(failure)
|
||||
.mockResolvedValueOnce(response)
|
||||
const client = rpcClient(sendRequest)
|
||||
|
||||
await expect(sendSingleFlightRequest(client, 'host-1', 'accounts.list')).rejects.toBe(failure)
|
||||
await expect(sendSingleFlightRequest(client, 'host-1', 'accounts.list')).resolves.toBe(response)
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not share requests across clients, hosts, or request kinds', async () => {
|
||||
const pending = deferred<RpcResponse>()
|
||||
const firstSend = vi.fn(() => pending.promise)
|
||||
const secondSend = vi.fn(() => pending.promise)
|
||||
const firstClient = rpcClient(firstSend)
|
||||
const secondClient = rpcClient(secondSend)
|
||||
|
||||
const requests = [
|
||||
sendSingleFlightRequest(firstClient, 'host-1', 'settings.get'),
|
||||
sendSingleFlightRequest(firstClient, 'host-2', 'settings.get'),
|
||||
sendSingleFlightRequest(firstClient, 'host-1', 'preflight.check'),
|
||||
sendSingleFlightRequest(secondClient, 'host-1', 'settings.get')
|
||||
]
|
||||
|
||||
expect(firstSend).toHaveBeenCalledTimes(3)
|
||||
expect(secondSend).toHaveBeenCalledTimes(1)
|
||||
pending.resolve(response)
|
||||
await Promise.all(requests)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { RpcClient } from './rpc-client'
|
||||
import type { RpcResponse } from './types'
|
||||
|
||||
type Deferred = {
|
||||
promise: Promise<RpcResponse>
|
||||
resolve: (value: RpcResponse) => void
|
||||
reject: (reason?: unknown) => void
|
||||
}
|
||||
|
||||
type SingleFlightEntry = {
|
||||
// The request currently on the wire for this (client, host, kind).
|
||||
current: Promise<RpcResponse>
|
||||
// At most one trailing follow-up: every trigger that arrives while `current` is in flight coalesces
|
||||
// here so a refresh requested mid-read still re-reads the latest state (latest params win) instead of
|
||||
// being silently answered by the older in-flight response.
|
||||
followUp: { deferred: Deferred; params: unknown } | null
|
||||
}
|
||||
|
||||
const inFlightRequests = new WeakMap<RpcClient, Map<string, Map<string, SingleFlightEntry>>>()
|
||||
|
||||
function makeDeferred(): Deferred {
|
||||
let resolve!: (value: RpcResponse) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<RpcResponse>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise
|
||||
reject = rejectPromise
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
export function sendSingleFlightRequest(
|
||||
client: RpcClient,
|
||||
hostId: string,
|
||||
requestKind: string,
|
||||
params?: unknown
|
||||
): Promise<RpcResponse> {
|
||||
let requestsByHost = inFlightRequests.get(client)
|
||||
if (!requestsByHost) {
|
||||
requestsByHost = new Map()
|
||||
inFlightRequests.set(client, requestsByHost)
|
||||
}
|
||||
let requestsByKind = requestsByHost.get(hostId)
|
||||
if (!requestsByKind) {
|
||||
requestsByKind = new Map()
|
||||
requestsByHost.set(hostId, requestsByKind)
|
||||
}
|
||||
|
||||
const send = (): Promise<RpcResponse> => {
|
||||
try {
|
||||
return client.sendRequest(requestKind, params)
|
||||
} catch (error) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
const existing = requestsByKind.get(requestKind)
|
||||
if (existing) {
|
||||
// A read is already on the wire: don't fire a duplicate now, but don't drop this trigger either.
|
||||
// Record (or refresh) a single trailing follow-up that runs once the current read settles.
|
||||
if (existing.followUp) {
|
||||
existing.followUp.params = params
|
||||
} else {
|
||||
existing.followUp = { deferred: makeDeferred(), params }
|
||||
}
|
||||
return existing.followUp.deferred.promise
|
||||
}
|
||||
|
||||
const entry: SingleFlightEntry = { current: send(), followUp: null }
|
||||
requestsByKind.set(requestKind, entry)
|
||||
|
||||
const cleanup = (): void => {
|
||||
if (requestsByKind.get(requestKind) !== entry) {
|
||||
return
|
||||
}
|
||||
requestsByKind.delete(requestKind)
|
||||
if (requestsByKind.size === 0) {
|
||||
requestsByHost.delete(hostId)
|
||||
}
|
||||
if (requestsByHost.size === 0) {
|
||||
inFlightRequests.delete(client)
|
||||
}
|
||||
}
|
||||
|
||||
// Chain each settled request into either its queued follow-up (delivering the fresh result to every
|
||||
// caller that awaited it) or entry teardown. Recurses so triggers arriving during a follow-up queue
|
||||
// the next one.
|
||||
const onSettled = (): void => {
|
||||
const followUp = entry.followUp
|
||||
if (!followUp) {
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
entry.followUp = null
|
||||
let next: Promise<RpcResponse>
|
||||
try {
|
||||
next = client.sendRequest(requestKind, followUp.params)
|
||||
} catch (error) {
|
||||
next = Promise.reject(error)
|
||||
}
|
||||
entry.current = next
|
||||
next.then(
|
||||
(response) => followUp.deferred.resolve(response),
|
||||
(error) => followUp.deferred.reject(error)
|
||||
)
|
||||
void next.then(onSettled, onSettled)
|
||||
}
|
||||
|
||||
void entry.current.then(onSettled, onSettled)
|
||||
return entry.current
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// Why: separate from rpc-client.test.ts — that file sits at its max-lines cap,
|
||||
// and the silent-4001 mapping (desktop lost its device registry / regenerated
|
||||
// its keypair, so the encrypted e2ee_error never decrypts) is its own scenario.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { connect } from './rpc-client'
|
||||
|
||||
vi.mock('./e2ee', () => ({
|
||||
generateKeyPair: () => ({
|
||||
publicKey: new Uint8Array(32),
|
||||
secretKey: new Uint8Array(32)
|
||||
}),
|
||||
deriveSharedKey: () => new Uint8Array(32),
|
||||
publicKeyFromBase64: () => new Uint8Array(32),
|
||||
publicKeyToBase64: () => 'client-public-key',
|
||||
encrypt: (plaintext: string) => `encrypted:${plaintext}`,
|
||||
decrypt: (raw: string) => (raw === 'undecryptable' ? null : raw.replace(/^encrypted:/, '')),
|
||||
decryptBytes: (bytes: Uint8Array) => bytes
|
||||
}))
|
||||
|
||||
class MockWebSocket {
|
||||
static CONNECTING = 0
|
||||
static OPEN = 1
|
||||
static CLOSING = 2
|
||||
static CLOSED = 3
|
||||
|
||||
readonly CONNECTING = MockWebSocket.CONNECTING
|
||||
readonly OPEN = MockWebSocket.OPEN
|
||||
readonly CLOSING = MockWebSocket.CLOSING
|
||||
readonly CLOSED = MockWebSocket.CLOSED
|
||||
|
||||
readyState = MockWebSocket.CONNECTING
|
||||
onopen: (() => void) | null = null
|
||||
onclose: ((event?: { code?: number; reason?: string; wasClean?: boolean }) => void) | null = null
|
||||
onmessage: ((event: { data: unknown }) => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
sent: string[] = []
|
||||
close = vi.fn(() => {
|
||||
if (this.readyState === MockWebSocket.CLOSED) {
|
||||
return
|
||||
}
|
||||
this.readyState = MockWebSocket.CLOSED
|
||||
this.onclose?.()
|
||||
})
|
||||
|
||||
constructor(readonly endpoint: string) {
|
||||
mockSockets.push(this)
|
||||
}
|
||||
|
||||
send(payload: string): void {
|
||||
this.sent.push(payload)
|
||||
}
|
||||
|
||||
open(): void {
|
||||
this.readyState = MockWebSocket.OPEN
|
||||
this.onopen?.()
|
||||
}
|
||||
|
||||
receive(payload: unknown): void {
|
||||
this.onmessage?.({ data: payload })
|
||||
}
|
||||
|
||||
serverClose(code: number, reason = ''): void {
|
||||
this.readyState = MockWebSocket.CLOSED
|
||||
this.onclose?.({ code, reason, wasClean: true })
|
||||
}
|
||||
}
|
||||
|
||||
const mockSockets: MockWebSocket[] = []
|
||||
const originalWebSocket = globalThis.WebSocket
|
||||
|
||||
function lastSocket(): MockWebSocket {
|
||||
return mockSockets[mockSockets.length - 1]!
|
||||
}
|
||||
|
||||
describe('unauthorized close-code mapping (silent 4001)', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
mockSockets.length = 0
|
||||
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
globalThis.WebSocket = originalWebSocket
|
||||
})
|
||||
|
||||
it('counts a bare 4001 close against the auth retry budget and latches auth-failed', async () => {
|
||||
const client = connect('ws://desktop.invalid', 'token', 'server-key')
|
||||
|
||||
// A desktop with a regenerated keypair can't send a decryptable e2ee_error —
|
||||
// the phone only ever sees the 4001 close. Three of those must latch.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (i > 0) {
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
}
|
||||
const socket = lastSocket()
|
||||
socket.open()
|
||||
socket.receive(JSON.stringify({ type: 'e2ee_ready' }))
|
||||
socket.serverClose(4001, 'Unauthorized')
|
||||
}
|
||||
|
||||
expect(client.getState()).toBe('auth-failed')
|
||||
expect(mockSockets).toHaveLength(3)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10 * 60_000)
|
||||
expect(mockSockets).toHaveLength(3)
|
||||
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('recovers when a 4001 close was transient and the next handshake succeeds', async () => {
|
||||
const client = connect('ws://desktop.invalid', 'token', 'server-key')
|
||||
const first = mockSockets[0]!
|
||||
first.open()
|
||||
first.receive(JSON.stringify({ type: 'e2ee_ready' }))
|
||||
first.serverClose(4001, 'Unauthorized')
|
||||
expect(client.getState()).toBe('reconnecting')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
const next = lastSocket()
|
||||
next.open()
|
||||
next.receive(JSON.stringify({ type: 'e2ee_ready' }))
|
||||
next.receive('encrypted:{"type":"e2ee_authenticated"}')
|
||||
expect(client.getState()).toBe('connected')
|
||||
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('shares one budget between decrypted e2ee_error rejections and 4001 closes', async () => {
|
||||
const client = connect('ws://desktop.invalid', 'token', 'server-key')
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (i > 0) {
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
}
|
||||
const socket = lastSocket()
|
||||
socket.open()
|
||||
socket.receive(JSON.stringify({ type: 'e2ee_ready' }))
|
||||
if (i < 2) {
|
||||
socket.receive('encrypted:{"type":"e2ee_error","error":{"code":"unauthorized"}}')
|
||||
} else {
|
||||
socket.serverClose(4001, 'Unauthorized')
|
||||
}
|
||||
}
|
||||
|
||||
expect(client.getState()).toBe('auth-failed')
|
||||
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('keeps the generic reconnect loop for non-4001 closes', async () => {
|
||||
const client = connect('ws://desktop.invalid', 'token', 'server-key')
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (i > 0) {
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
}
|
||||
const socket = lastSocket()
|
||||
socket.open()
|
||||
socket.receive(JSON.stringify({ type: 'e2ee_ready' }))
|
||||
socket.serverClose(1006)
|
||||
}
|
||||
|
||||
expect(client.getState()).toBe('reconnecting')
|
||||
|
||||
client.close()
|
||||
})
|
||||
})
|
||||
@@ -98,6 +98,8 @@ const GIVE_UP_AFTER_ATTEMPTS = 12
|
||||
const TRICKLE_RECONNECT_DELAY_MS = 90_000
|
||||
// Why: one unauthorized isn't proof the pairing is dead (issue #5200) — retry the handshake this many times before latching auth-failed.
|
||||
const AUTH_RETRY_BUDGET = 3
|
||||
// Why: a desktop that regenerated its E2EE keypair sends an e2ee_error we can't decrypt — the 4001 close code is the only surviving auth-failure signal.
|
||||
const UNAUTHORIZED_CLOSE_CODE = 4001
|
||||
const REQUEST_TIMEOUT_MS = 30_000
|
||||
const CONNECT_TIMEOUT_MS = 12_000
|
||||
const HANDSHAKE_TIMEOUT_MS = 5_000
|
||||
@@ -603,7 +605,7 @@ export function connect(
|
||||
})
|
||||
lastWsClosedAt = closeAt
|
||||
currentWsOpenedAt = null
|
||||
handleSocketClosed(openingWs)
|
||||
handleSocketClosed(openingWs, { closeCode: e?.code })
|
||||
}
|
||||
|
||||
ws.onerror = (event) => {
|
||||
@@ -623,7 +625,10 @@ export function connect(
|
||||
}
|
||||
}
|
||||
|
||||
function handleSocketClosed(closedWs: WebSocket, opts: { timedOut?: boolean } = {}) {
|
||||
function handleSocketClosed(
|
||||
closedWs: WebSocket,
|
||||
opts: { timedOut?: boolean; closeCode?: number } = {}
|
||||
) {
|
||||
if (ws !== closedWs) {
|
||||
console.log('[net] handleSocketClosed STALE — ignoring (ws already swapped)', {
|
||||
state,
|
||||
@@ -648,6 +653,16 @@ export function connect(
|
||||
rejectAllPending('Connection closed', { deliveryUnknown: true })
|
||||
return
|
||||
}
|
||||
// Why: a bare 4001 close means the desktop rejected our pairing but the encrypted
|
||||
// e2ee_error never arrived (or was undecryptable) — count it against the auth
|
||||
// retry budget instead of looping the generic reconnect forever.
|
||||
if (opts.closeCode === UNAUTHORIZED_CLOSE_CODE) {
|
||||
console.log('[net] handleSocketClosed — unauthorized close code', {
|
||||
attempt: reconnectAttempt
|
||||
})
|
||||
handleAuthRejection('Unauthorized — pairing may be revoked')
|
||||
return
|
||||
}
|
||||
console.log('[net] handleSocketClosed → reconnect', {
|
||||
timedOut: !!opts.timedOut,
|
||||
pendingCount: pending.size,
|
||||
|
||||
@@ -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')
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ export function useWorktreeResync(args: {
|
||||
client: RpcClient | null
|
||||
connState: ConnectionState
|
||||
fetchWorktrees: (opts?: { allowDuringModal?: boolean }) => Promise<void>
|
||||
fetchRepoMetadata: () => Promise<void>
|
||||
fetchRepoMetadata: (options?: { force?: boolean; queueIfInFlight?: boolean }) => Promise<void>
|
||||
}): { refreshing: boolean; onRefresh: () => Promise<void> } {
|
||||
const { client, connState, fetchWorktrees, fetchRepoMetadata } = args
|
||||
|
||||
@@ -22,9 +22,11 @@ export function useWorktreeResync(args: {
|
||||
prevConnStateRef.current = connState
|
||||
if (prev !== 'connected' && connState === 'connected' && client) {
|
||||
void fetchWorktrees({ allowDuringModal: true })
|
||||
void fetchRepoMetadata()
|
||||
}
|
||||
}, [connState, client, fetchWorktrees, fetchRepoMetadata])
|
||||
// Why: repo metadata refetch on reconnect is owned by startHostWorktreeRefresh (mount +
|
||||
// reposChanged + stream replay); this effect only refetches worktrees, so fetchRepoMetadata
|
||||
// is intentionally not a dependency here.
|
||||
}, [connState, client, fetchWorktrees])
|
||||
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
// Why (#8498): let the user force a fresh snapshot instead of the possibly-poisoned cache.
|
||||
@@ -35,7 +37,7 @@ export function useWorktreeResync(args: {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
await fetchWorktrees({ allowDuringModal: true })
|
||||
await fetchRepoMetadata()
|
||||
await fetchRepoMetadata({ force: true })
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { startHostWorktreeRefresh } from './host-worktree-refresh'
|
||||
|
||||
const appState = vi.hoisted(() => ({
|
||||
currentState: 'active',
|
||||
listener: null as ((state: string) => void) | null,
|
||||
remove: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
AppState: {
|
||||
get currentState() {
|
||||
return appState.currentState
|
||||
},
|
||||
addEventListener: (_event: string, listener: (state: string) => void) => {
|
||||
appState.listener = listener
|
||||
return { remove: appState.remove }
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
describe('startHostWorktreeRefresh', () => {
|
||||
let eventListener: ((payload: unknown) => void) | null
|
||||
let fetchWorktrees: ReturnType<typeof vi.fn>
|
||||
let fetchRepoMetadata: ReturnType<typeof vi.fn>
|
||||
let unsubscribe: ReturnType<typeof vi.fn>
|
||||
let stop: (() => void) | null
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
appState.currentState = 'active'
|
||||
appState.listener = null
|
||||
appState.remove.mockClear()
|
||||
eventListener = null
|
||||
fetchWorktrees = vi.fn().mockResolvedValue(undefined)
|
||||
fetchRepoMetadata = vi.fn().mockResolvedValue(undefined)
|
||||
unsubscribe = vi.fn()
|
||||
stop = null
|
||||
})
|
||||
|
||||
function start(): void {
|
||||
const client = {
|
||||
subscribe: vi.fn(
|
||||
(_method: string, _params: unknown, listener: (payload: unknown) => void) => {
|
||||
eventListener = listener
|
||||
return unsubscribe
|
||||
}
|
||||
)
|
||||
} as unknown as RpcClient
|
||||
stop = startHostWorktreeRefresh({ client, fetchWorktrees, fetchRepoMetadata })
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
stop?.()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('keeps the worktree poll active but skips ticks while backgrounded', async () => {
|
||||
start()
|
||||
expect(fetchWorktrees).toHaveBeenCalledTimes(1)
|
||||
|
||||
appState.currentState = 'background'
|
||||
await vi.advanceTimersByTimeAsync(6_000)
|
||||
expect(fetchWorktrees).toHaveBeenCalledTimes(1)
|
||||
|
||||
appState.currentState = 'active'
|
||||
await vi.advanceTimersByTimeAsync(3_000)
|
||||
expect(fetchWorktrees).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('refreshes both snapshots immediately on foreground return', () => {
|
||||
start()
|
||||
fetchWorktrees.mockClear()
|
||||
fetchRepoMetadata.mockClear()
|
||||
|
||||
appState.currentState = 'active'
|
||||
appState.listener?.('active')
|
||||
|
||||
expect(fetchWorktrees).toHaveBeenCalledWith({ allowDuringModal: true })
|
||||
expect(fetchRepoMetadata).toHaveBeenCalledWith({ queueIfInFlight: true })
|
||||
})
|
||||
|
||||
it('polls repo metadata on the interval while foregrounded and skips it while backgrounded', async () => {
|
||||
start()
|
||||
// Mount force-fetch.
|
||||
expect(fetchRepoMetadata).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Foregrounded: repo.list rides the interval as a convergence safety-net for desktop
|
||||
// Settings edits that never emit a runtime reposChanged (the callee self-throttles).
|
||||
await vi.advanceTimersByTimeAsync(9_000)
|
||||
expect(fetchWorktrees).toHaveBeenCalledTimes(4)
|
||||
expect(fetchRepoMetadata).toHaveBeenCalledTimes(4)
|
||||
|
||||
// Backgrounded: neither snapshot is polled.
|
||||
fetchWorktrees.mockClear()
|
||||
fetchRepoMetadata.mockClear()
|
||||
appState.currentState = 'background'
|
||||
await vi.advanceTimersByTimeAsync(9_000)
|
||||
expect(fetchWorktrees).not.toHaveBeenCalled()
|
||||
expect(fetchRepoMetadata).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('force-refreshes repo metadata on reposChanged', () => {
|
||||
start()
|
||||
fetchRepoMetadata.mockClear()
|
||||
|
||||
eventListener?.({ type: 'reposChanged' })
|
||||
|
||||
expect(fetchRepoMetadata).toHaveBeenCalledOnce()
|
||||
expect(fetchRepoMetadata).toHaveBeenCalledWith({ force: true, queueIfInFlight: true })
|
||||
})
|
||||
|
||||
it('refreshes worktrees on worktreesChanged and both snapshots after stream replay', () => {
|
||||
start()
|
||||
fetchWorktrees.mockClear()
|
||||
fetchRepoMetadata.mockClear()
|
||||
|
||||
eventListener?.({ type: 'worktreesChanged', repoId: 'repo-1' })
|
||||
expect(fetchWorktrees).toHaveBeenCalledTimes(1)
|
||||
|
||||
eventListener?.({ type: 'ready', subscriptionId: 'events-1' })
|
||||
eventListener?.({ type: 'ready', subscriptionId: 'events-2' })
|
||||
expect(fetchWorktrees).toHaveBeenCalledTimes(2)
|
||||
expect(fetchRepoMetadata).toHaveBeenCalledWith({ force: true, queueIfInFlight: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import { AppState } from 'react-native'
|
||||
import type { RuntimeClientEventStreamMessage } from '../../../src/shared/runtime-client-events'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
|
||||
const WORKTREE_REFRESH_MS = 3000
|
||||
|
||||
type WorktreeRefreshOptions = { allowDuringModal?: boolean }
|
||||
type RepoRefreshOptions = { force?: boolean; queueIfInFlight?: boolean }
|
||||
|
||||
type HostWorktreeRefreshArgs = {
|
||||
client: RpcClient
|
||||
fetchWorktrees: (options?: WorktreeRefreshOptions) => Promise<void>
|
||||
fetchRepoMetadata: (options?: RepoRefreshOptions) => Promise<void>
|
||||
}
|
||||
|
||||
export function startHostWorktreeRefresh({
|
||||
client,
|
||||
fetchWorktrees,
|
||||
fetchRepoMetadata
|
||||
}: HostWorktreeRefreshArgs): () => void {
|
||||
let stale = false
|
||||
let eventStreamReady = false
|
||||
|
||||
const refreshOnForeground = (): void => {
|
||||
if (AppState.currentState !== 'active') {
|
||||
return
|
||||
}
|
||||
void fetchWorktrees({ allowDuringModal: true })
|
||||
void fetchRepoMetadata({ queueIfInFlight: true })
|
||||
}
|
||||
|
||||
const appStateSubscription = AppState.addEventListener('change', (state) => {
|
||||
if (state === 'active') {
|
||||
refreshOnForeground()
|
||||
}
|
||||
})
|
||||
const interval = setInterval(() => {
|
||||
if (AppState.currentState !== 'active') {
|
||||
return
|
||||
}
|
||||
void fetchWorktrees()
|
||||
// Why: desktop Settings repo edits (icon/color/name, repo removal) notify only the
|
||||
// renderer IPC, not the runtime clientEvents stream, so `reposChanged` never reaches
|
||||
// mobile. Keep a periodic repo.list as the convergence safety-net; fetchRepoMetadata
|
||||
// self-throttles to REPO_METADATA_REFRESH_MS (60s), so this is ~1 request/min while
|
||||
// foregrounded — the AppState gate is what removes the waste (both stop while backgrounded).
|
||||
void fetchRepoMetadata()
|
||||
}, WORKTREE_REFRESH_MS)
|
||||
const unsubscribe = client.subscribe(
|
||||
'runtime.clientEvents.subscribe',
|
||||
null,
|
||||
(payload: unknown) => {
|
||||
if (stale || !payload || typeof payload !== 'object') {
|
||||
return
|
||||
}
|
||||
const event = payload as RuntimeClientEventStreamMessage | { type: 'error' }
|
||||
if (event.type === 'ready') {
|
||||
const replayedAfterReconnect = eventStreamReady
|
||||
eventStreamReady = true
|
||||
if (replayedAfterReconnect) {
|
||||
// Why: client events are not queued while disconnected, so re-read both snapshots after replay.
|
||||
void fetchWorktrees()
|
||||
void fetchRepoMetadata({ force: true, queueIfInFlight: true })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event.type === 'end' || event.type === 'error') {
|
||||
eventStreamReady = false
|
||||
return
|
||||
}
|
||||
if (event.type === 'reposChanged') {
|
||||
void fetchRepoMetadata({ force: true, queueIfInFlight: true })
|
||||
} else if (event.type === 'worktreesChanged') {
|
||||
void fetchWorktrees()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
void fetchWorktrees()
|
||||
void fetchRepoMetadata({ force: true, queueIfInFlight: true })
|
||||
|
||||
return () => {
|
||||
stale = true
|
||||
clearInterval(interval)
|
||||
appStateSubscription.remove()
|
||||
unsubscribe()
|
||||
}
|
||||
}
|
||||
+9
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orca",
|
||||
"version": "1.4.151-rc.0",
|
||||
"version": "1.4.151-rc.1",
|
||||
"description": "Next-gen IDE for parallel agentic development",
|
||||
"homepage": "https://github.com/stablyai/orca",
|
||||
"author": "stablyai",
|
||||
@@ -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",
|
||||
@@ -114,7 +115,7 @@
|
||||
"@xterm/addon-serialize": "0.15.0-beta.287",
|
||||
"@xterm/headless": "6.1.0-beta.287",
|
||||
"agent-browser": "~0.27.0",
|
||||
"electron-updater": "^6.8.3",
|
||||
"electron-updater": "^6.8.9",
|
||||
"i18next": "^26.3.1",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"node-pty": "^1.1.0",
|
||||
@@ -176,9 +177,10 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dompurify": "^3.4.11",
|
||||
"dompurify": "^3.4.12",
|
||||
"electron": "^43.1.0",
|
||||
"electron-builder": "^26.8.1",
|
||||
"electron-builder": "^26.15.3",
|
||||
"electron-builder-squirrel-windows": "^26.15.3",
|
||||
"electron-vite": "^5.0.0",
|
||||
"emoji-picker-react": "^4.19.1",
|
||||
"happy-dom": "^20.9.0",
|
||||
@@ -213,7 +215,7 @@
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
"shadcn": "^4.7.0",
|
||||
"shadcn": "^4.13.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
@@ -251,7 +253,8 @@
|
||||
"packageManager": "pnpm@10.24.0+sha512.01ff8ae71b4419903b65c60fb2dc9d34cf8bb6e06d03bde112ef38f7a34d6904c424ba66bea5cdcf12890230bf39f9580473140ed9c946fef328b6e5238a345a",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"monaco-editor>dompurify": "3.4.11"
|
||||
"@modelcontextprotocol/sdk>@hono/node-server": "2.0.10",
|
||||
"monaco-editor>dompurify": "3.4.12"
|
||||
},
|
||||
"supportedArchitectures": {
|
||||
"os": [
|
||||
|
||||
Generated
+463
-858
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@
|
||||
{
|
||||
"name": "computer-use",
|
||||
"sourcePath": "skills/computer-use",
|
||||
"releaseRevision": 6,
|
||||
"releaseRevision": 8,
|
||||
"packageDigest": "d1b4850c9a9ee9a32b855176c31cd357608bfedc845319c97e89960296303430",
|
||||
"gitTreeSha": "2072384f53670cb61d93f4f6264ad2d8f6b5239c",
|
||||
"files": [
|
||||
@@ -22,7 +22,7 @@
|
||||
{
|
||||
"name": "linear-tickets",
|
||||
"sourcePath": "skills/linear-tickets",
|
||||
"releaseRevision": 8,
|
||||
"releaseRevision": 10,
|
||||
"packageDigest": "cbb9496d069da8a2490343c44967a9086698102806b2312ec9fba313be960bf3",
|
||||
"gitTreeSha": "1047772e2422647d8c36f850f22d4182f9f87c61",
|
||||
"files": [
|
||||
@@ -58,7 +58,7 @@
|
||||
{
|
||||
"name": "orca-emulator",
|
||||
"sourcePath": "skills/orca-emulator",
|
||||
"releaseRevision": 5,
|
||||
"releaseRevision": 7,
|
||||
"packageDigest": "cdfb39ffae0cfcab33d57bc279776d3a18fcbf975331dd64cdab757148173a49",
|
||||
"gitTreeSha": "ad1ecea6dfda6c0c79b06c2b87df290ba97cea2c",
|
||||
"files": [
|
||||
@@ -76,7 +76,7 @@
|
||||
{
|
||||
"name": "orca-emulator-android",
|
||||
"sourcePath": "skills/orca-emulator-android",
|
||||
"releaseRevision": 3,
|
||||
"releaseRevision": 5,
|
||||
"packageDigest": "cd0b1a4c017e1f98fff073b80396c7f852ab793ecdae96e8ad63f580e2a2ed6e",
|
||||
"gitTreeSha": "9e270499eef6bc00c1d578f527ab005fc32e18e2",
|
||||
"files": [
|
||||
@@ -94,7 +94,7 @@
|
||||
{
|
||||
"name": "orca-linear",
|
||||
"sourcePath": "skills/orca-linear",
|
||||
"releaseRevision": 6,
|
||||
"releaseRevision": 8,
|
||||
"packageDigest": "363e10f9fb00616d983fe19905a0d85d60a6a1b522e5313f625a1b1dc801e890",
|
||||
"gitTreeSha": "091d9bcc279d7ec7f4d3f63929f01f8b9e3db68d",
|
||||
"files": [
|
||||
@@ -112,7 +112,7 @@
|
||||
{
|
||||
"name": "orca-per-workspace-env",
|
||||
"sourcePath": "skills/orca-per-workspace-env",
|
||||
"releaseRevision": 3,
|
||||
"releaseRevision": 5,
|
||||
"packageDigest": "9c96ed37a89d4959d05ab1565a81fc80d68f00174c2873b2efb81e20daef8e1d",
|
||||
"gitTreeSha": "942b9397139f9d5b6cd4164339c965c35494985d",
|
||||
"files": [
|
||||
@@ -130,7 +130,7 @@
|
||||
{
|
||||
"name": "orchestration",
|
||||
"sourcePath": "skills/orchestration",
|
||||
"releaseRevision": 26,
|
||||
"releaseRevision": 28,
|
||||
"packageDigest": "ef5d5a744cdc700c51b4870cd2536b65b0b33d19413dfe238d43efdd01b5d14c",
|
||||
"gitTreeSha": "9aa26fde93c0592e5983cdca1ccd33b402802255",
|
||||
"files": [
|
||||
|
||||
@@ -587,6 +587,45 @@
|
||||
"orca-per-workspace-env": 2,
|
||||
"orchestration": 25
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.151-rc.1",
|
||||
"skills": {
|
||||
"computer-use": 6,
|
||||
"linear-tickets": 8,
|
||||
"orca-cli": 35,
|
||||
"orca-emulator": 5,
|
||||
"orca-emulator-android": 3,
|
||||
"orca-linear": 6,
|
||||
"orca-per-workspace-env": 3,
|
||||
"orchestration": 26
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.151",
|
||||
"skills": {
|
||||
"computer-use": 7,
|
||||
"linear-tickets": 9,
|
||||
"orca-cli": 35,
|
||||
"orca-emulator": 6,
|
||||
"orca-emulator-android": 4,
|
||||
"orca-linear": 7,
|
||||
"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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -979,6 +979,38 @@
|
||||
"identitySha256": "9ca228137b9a442b98c761aa07adecc2265708132ab175ad7e22b163fdc0bd7f"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"releaseRevision": 27,
|
||||
"packageDigest": "c19171d213e827bdf5364b733b67889566aaa2fb3667ebe029db87044d08f908",
|
||||
"gitTreeSha": "da346803bccae7fb1fdade31bbe9b4d851b25e38",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 22676,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "0cfb6a082625edc0d474bae430eb22c28bbe484e54fbfebedb4ff89d96e36305",
|
||||
"textNormalizedSha256": "0cfb6a082625edc0d474bae430eb22c28bbe484e54fbfebedb4ff89d96e36305",
|
||||
"identitySha256": "0cfb6a082625edc0d474bae430eb22c28bbe484e54fbfebedb4ff89d96e36305"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"releaseRevision": 28,
|
||||
"packageDigest": "ef5d5a744cdc700c51b4870cd2536b65b0b33d19413dfe238d43efdd01b5d14c",
|
||||
"gitTreeSha": "9aa26fde93c0592e5983cdca1ccd33b402802255",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 4220,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "9ca228137b9a442b98c761aa07adecc2265708132ab175ad7e22b163fdc0bd7f",
|
||||
"textNormalizedSha256": "9ca228137b9a442b98c761aa07adecc2265708132ab175ad7e22b163fdc0bd7f",
|
||||
"identitySha256": "9ca228137b9a442b98c761aa07adecc2265708132ab175ad7e22b163fdc0bd7f"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"mobile-fit-debug": [
|
||||
@@ -1095,6 +1127,38 @@
|
||||
"identitySha256": "c4a11596b7c0338f4c991b24ba7ba453d93fb8dc045c642c517e7ae6d3c88467"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"releaseRevision": 7,
|
||||
"packageDigest": "cd2809474d57fd7277adb277448e6fa446810d3cbad71ac0b473b9e8ff1bad68",
|
||||
"gitTreeSha": "306c0f8cb63bcac265a5b7975dc2f855be4f1344",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 11241,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39",
|
||||
"textNormalizedSha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39",
|
||||
"identitySha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"releaseRevision": 8,
|
||||
"packageDigest": "d1b4850c9a9ee9a32b855176c31cd357608bfedc845319c97e89960296303430",
|
||||
"gitTreeSha": "2072384f53670cb61d93f4f6264ad2d8f6b5239c",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 3667,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "c4a11596b7c0338f4c991b24ba7ba453d93fb8dc045c642c517e7ae6d3c88467",
|
||||
"textNormalizedSha256": "c4a11596b7c0338f4c991b24ba7ba453d93fb8dc045c642c517e7ae6d3c88467",
|
||||
"identitySha256": "c4a11596b7c0338f4c991b24ba7ba453d93fb8dc045c642c517e7ae6d3c88467"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"orca-emulator": [
|
||||
@@ -1177,6 +1241,38 @@
|
||||
"identitySha256": "796f2135824e0ecdfe4f6e8f8bd4690788c1816933df4104b2f9846ffe9a41e0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"releaseRevision": 6,
|
||||
"packageDigest": "453b1d9aa20b51b8a4d32c7b6def6a93f7ef9c730de32abbcbc1788ad1b1820b",
|
||||
"gitTreeSha": "66be6abe99f1807da85934aee0e22daefc8f7656",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 11527,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429",
|
||||
"textNormalizedSha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429",
|
||||
"identitySha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"releaseRevision": 7,
|
||||
"packageDigest": "cdfb39ffae0cfcab33d57bc279776d3a18fcbf975331dd64cdab757148173a49",
|
||||
"gitTreeSha": "ad1ecea6dfda6c0c79b06c2b87df290ba97cea2c",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 3724,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "796f2135824e0ecdfe4f6e8f8bd4690788c1816933df4104b2f9846ffe9a41e0",
|
||||
"textNormalizedSha256": "796f2135824e0ecdfe4f6e8f8bd4690788c1816933df4104b2f9846ffe9a41e0",
|
||||
"identitySha256": "796f2135824e0ecdfe4f6e8f8bd4690788c1816933df4104b2f9846ffe9a41e0"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"linear-tickets": [
|
||||
@@ -1307,6 +1403,38 @@
|
||||
"identitySha256": "d2dec89eca8c71c820ee2dbd7bae4fb8528775554dbc6c7a71ed8a3422f53d23"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"releaseRevision": 9,
|
||||
"packageDigest": "ff9f085631f753f059c631d874177ddd4fa847c5eca85a420dc85fb2bece6ff6",
|
||||
"gitTreeSha": "e35ac3c0c583661983d3fc1352ff3aec74e67e8c",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 12466,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "ea2a508c60ab145981f5b16fbed949c4a4c167ec4df16888cf1703fd4c6056c0",
|
||||
"textNormalizedSha256": "ea2a508c60ab145981f5b16fbed949c4a4c167ec4df16888cf1703fd4c6056c0",
|
||||
"identitySha256": "ea2a508c60ab145981f5b16fbed949c4a4c167ec4df16888cf1703fd4c6056c0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"releaseRevision": 10,
|
||||
"packageDigest": "cbb9496d069da8a2490343c44967a9086698102806b2312ec9fba313be960bf3",
|
||||
"gitTreeSha": "1047772e2422647d8c36f850f22d4182f9f87c61",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 4148,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "d2dec89eca8c71c820ee2dbd7bae4fb8528775554dbc6c7a71ed8a3422f53d23",
|
||||
"textNormalizedSha256": "d2dec89eca8c71c820ee2dbd7bae4fb8528775554dbc6c7a71ed8a3422f53d23",
|
||||
"identitySha256": "d2dec89eca8c71c820ee2dbd7bae4fb8528775554dbc6c7a71ed8a3422f53d23"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"orca-linear": [
|
||||
@@ -1405,6 +1533,38 @@
|
||||
"identitySha256": "39241e0aa2929344e3b38407215d737fb35de8421b4efb5cf2c767f91d0e7a9b"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"releaseRevision": 7,
|
||||
"packageDigest": "5e9622bd3883c0f53e6bd349758096deafceebd2fa260d3e90d677e64d06416d",
|
||||
"gitTreeSha": "f3727995a4719fd522119eca6d1b57542cb5fe23",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 12190,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "af855a87af929e2da19d51c46e5f2bf156b026c6f3b9cfbf23708a0d53b6a764",
|
||||
"textNormalizedSha256": "af855a87af929e2da19d51c46e5f2bf156b026c6f3b9cfbf23708a0d53b6a764",
|
||||
"identitySha256": "af855a87af929e2da19d51c46e5f2bf156b026c6f3b9cfbf23708a0d53b6a764"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"releaseRevision": 8,
|
||||
"packageDigest": "363e10f9fb00616d983fe19905a0d85d60a6a1b522e5313f625a1b1dc801e890",
|
||||
"gitTreeSha": "091d9bcc279d7ec7f4d3f63929f01f8b9e3db68d",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 3902,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "39241e0aa2929344e3b38407215d737fb35de8421b4efb5cf2c767f91d0e7a9b",
|
||||
"textNormalizedSha256": "39241e0aa2929344e3b38407215d737fb35de8421b4efb5cf2c767f91d0e7a9b",
|
||||
"identitySha256": "39241e0aa2929344e3b38407215d737fb35de8421b4efb5cf2c767f91d0e7a9b"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"orca-emulator-android": [
|
||||
@@ -1455,6 +1615,38 @@
|
||||
"identitySha256": "41d9cae07abd03a39236733884332058316bcf816e4b5b2d411c01b3a16ac8a6"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"releaseRevision": 4,
|
||||
"packageDigest": "12272cf82e0731f11e424822b961882457034e730358cc65ea28e4eb9c8ff7f5",
|
||||
"gitTreeSha": "f7b0fc8cbf5cd78ca5156f6bbe3a20f1462d8f83",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 8886,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332",
|
||||
"textNormalizedSha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332",
|
||||
"identitySha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"releaseRevision": 5,
|
||||
"packageDigest": "cd0b1a4c017e1f98fff073b80396c7f852ab793ecdae96e8ad63f580e2a2ed6e",
|
||||
"gitTreeSha": "9e270499eef6bc00c1d578f527ab005fc32e18e2",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 3529,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "41d9cae07abd03a39236733884332058316bcf816e4b5b2d411c01b3a16ac8a6",
|
||||
"textNormalizedSha256": "41d9cae07abd03a39236733884332058316bcf816e4b5b2d411c01b3a16ac8a6",
|
||||
"identitySha256": "41d9cae07abd03a39236733884332058316bcf816e4b5b2d411c01b3a16ac8a6"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"orca-per-workspace-env": [
|
||||
@@ -1505,6 +1697,38 @@
|
||||
"identitySha256": "a7ae9a0d22b8bc14a6cb3bdb6fc6ebf1f11cc25ab489d1cc63928bd025d7dddc"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"releaseRevision": 4,
|
||||
"packageDigest": "fa3b65a1a107fca3f0375c696852477b62f58c154b9eb5c0663c41edc4bcd30d",
|
||||
"gitTreeSha": "354e775b79ea6952ec63acac4d3ee8a9ae07a650",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 43769,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7",
|
||||
"textNormalizedSha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7",
|
||||
"identitySha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"releaseRevision": 5,
|
||||
"packageDigest": "9c96ed37a89d4959d05ab1565a81fc80d68f00174c2873b2efb81e20daef8e1d",
|
||||
"gitTreeSha": "942b9397139f9d5b6cd4164339c965c35494985d",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 4222,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "a7ae9a0d22b8bc14a6cb3bdb6fc6ebf1f11cc25ab489d1cc63928bd025d7dddc",
|
||||
"textNormalizedSha256": "a7ae9a0d22b8bc14a6cb3bdb6fc6ebf1f11cc25ab489d1cc63928bd025d7dddc",
|
||||
"identitySha256": "a7ae9a0d22b8bc14a6cb3bdb6fc6ebf1f11cc25ab489d1cc63928bd025d7dddc"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -117,8 +117,9 @@ Use `--json` for agent-friendly output. Coordinates are **normalized 0..1**
|
||||
not. For unicode-heavy input, use the app UI directly.
|
||||
- `gesture` is a straight swipe between the first and last point (adb limitation);
|
||||
fine for scroll/swipe, not for true multi-touch paths.
|
||||
- Capability verbs (`install/launch/permissions/ax/logcat`) are **Android-only**;
|
||||
running them against an iOS device fails with `emulator_unsupported`.
|
||||
- Capability verbs `install/launch/permissions/logcat` are **Android-only** and
|
||||
fail against an iOS device with `emulator_unsupported`. `ax` works on both,
|
||||
with backend-specific output (uiautomator tree vs serve-sim AX snapshot).
|
||||
- No camera/sensor injection yet.
|
||||
|
||||
## Targeting devices & worktrees
|
||||
|
||||
@@ -99,7 +99,7 @@ Use `--json` for agent-friendly output. Commands are workspace-scoped by default
|
||||
| Rotate device | `ORCA emulator rotate landscape_left` | Remembers orientation for subsequent gestures. |
|
||||
| Camera injection | `ORCA emulator camera com.acme.App --webcam` | Or --file, placeholder. Hot-swap with switch. May (re)launch app. |
|
||||
| Permissions | `ORCA emulator permissions grant camera com.acme.App` | grant/revoke/reset/list. See full subcommand help. |
|
||||
| Accessibility tree | `ORCA emulator ax [--device <id>]` | Or via exec for raw endpoint. |
|
||||
| Accessibility tree | `ORCA emulator ax [--device <id>]` | Raw serve-sim AX snapshot (screen + elements). Needs an active session. |
|
||||
| Raw / advanced | `ORCA emulator exec --command "tap 0.5 0.7"` | Or "ca-debug blended on", "memory-warning", full serve-sim subcommands (no "serve-sim" prefix needed in the command string). Bridge injects active device context. |
|
||||
| Stop | `ORCA emulator kill [--device <id>]` | Or let pane close / Orca quit clean up. |
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ export const EMULATOR_COMMAND_SPECS: CommandSpec[] = [
|
||||
},
|
||||
{
|
||||
path: ['emulator', 'ax'],
|
||||
summary: 'Dump the Android accessibility (uiautomator) tree',
|
||||
summary: 'Dump the device accessibility tree (uiautomator on Android, serve-sim AX on iOS)',
|
||||
usage: 'orca emulator ax [--device <id>] [--worktree <selector>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'device', 'emulator', 'worktree']
|
||||
},
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { types } from 'node:util'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildGuestOverlayScript } from './grab-guest-script'
|
||||
import { clampGrabPayload } from './browser-grab-payload'
|
||||
|
||||
describe('buildGuestOverlayScript', () => {
|
||||
it('returns a non-empty string for arm action', () => {
|
||||
@@ -158,3 +161,199 @@ describe('buildGuestOverlayScript', () => {
|
||||
expect(script).not.toContain('ariaLabelledBy.split(/\\s+/)')
|
||||
})
|
||||
})
|
||||
|
||||
// Regression coverage for issue #9947: Zone.js swaps the global Promise for a
|
||||
// non-native thenable, which executeJavaScript won't unwrap — so a page-global
|
||||
// `new Promise(...)` crossed as its raw `__zone_symbol__*` wrapper, not { page, target }.
|
||||
describe('awaitClick under a Zone.js-patched global Promise', () => {
|
||||
type Executor<T> = (resolve: (value: T) => void, reject: (reason: unknown) => void) => void
|
||||
|
||||
// Native promises are kept off the instance so its own enumerable keys match
|
||||
// Zone.js exactly: ['__zone_symbol__state', '__zone_symbol__value'].
|
||||
const nativeOf = new WeakMap<object, Promise<unknown>>()
|
||||
|
||||
/** A non-native thenable that mimics Zone.js's ZoneAwarePromise wrapper. */
|
||||
class ZoneAwarePromiseLike<T = unknown> {
|
||||
__zone_symbol__state: unknown = null
|
||||
__zone_symbol__value: unknown = undefined
|
||||
|
||||
constructor(executor: Executor<T>) {
|
||||
nativeOf.set(
|
||||
this,
|
||||
new Promise<T>((res, rej) => {
|
||||
executor(
|
||||
(value) => {
|
||||
this.__zone_symbol__state = true
|
||||
this.__zone_symbol__value = value
|
||||
res(value)
|
||||
},
|
||||
(reason) => {
|
||||
this.__zone_symbol__state = false
|
||||
this.__zone_symbol__value = reason
|
||||
rej(reason)
|
||||
}
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Why: real Zone.js defines `get [Symbol.toStringTag]() { return 'Promise' }`,
|
||||
// so an Object.prototype.toString check is fooled into seeing a promise. The
|
||||
// boundary below deliberately uses the brand-based V8 check instead, and the
|
||||
// control test asserts this tag does NOT let the wrapper masquerade as native.
|
||||
get [Symbol.toStringTag](): string {
|
||||
return 'Promise'
|
||||
}
|
||||
|
||||
// oxlint-disable-next-line unicorn/no-thenable -- intentionally a non-native thenable modeling Zone.js's ZoneAwarePromise
|
||||
then(
|
||||
onFulfilled?: ((value: unknown) => unknown) | null,
|
||||
onRejected?: ((reason: unknown) => unknown) | null
|
||||
): Promise<unknown> {
|
||||
const native = nativeOf.get(this) as Promise<unknown>
|
||||
return native.then(onFulfilled ?? undefined, onRejected ?? undefined)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Models Electron's boundary: it awaits only genuine V8 promises and
|
||||
* serializes anything else — including non-native thenables — by value.
|
||||
* `util.types.isPromise` is V8's brand-based IsPromise (what Electron uses):
|
||||
* realm-independent and unforgeable, unlike an Object.prototype.toString /
|
||||
* Symbol.toStringTag check that a ZoneAwarePromise would defeat.
|
||||
*/
|
||||
async function crossExecuteJavaScriptBoundary(completionValue: unknown): Promise<unknown> {
|
||||
if (types.isPromise(completionValue)) {
|
||||
return await (completionValue as Promise<unknown>)
|
||||
}
|
||||
return { ...(completionValue as Record<string, unknown>) }
|
||||
}
|
||||
|
||||
const validPayload = (): Record<string, unknown> => ({
|
||||
// A minimal but structurally valid payload — page + target are what
|
||||
// clampGrabPayload requires and what the bug stripped away.
|
||||
page: { title: 'Angular App' },
|
||||
target: { tagName: 'button' },
|
||||
nearbyText: [],
|
||||
ancestorPath: [],
|
||||
screenshot: null
|
||||
})
|
||||
|
||||
function armGrabHarness(options?: {
|
||||
extractPayload?: () => unknown
|
||||
getCurrentElement?: () => unknown
|
||||
}): {
|
||||
window: { __orcaGrab: Record<string, unknown> }
|
||||
click: () => void
|
||||
contextmenu: () => void
|
||||
cancel: () => void
|
||||
} {
|
||||
const handlers: Record<string, (event: unknown) => void> = {}
|
||||
const noopEvent = {
|
||||
preventDefault(): void {},
|
||||
stopPropagation(): void {},
|
||||
stopImmediatePropagation(): void {}
|
||||
}
|
||||
const grab: Record<string, unknown> = {
|
||||
host: {
|
||||
addEventListener(type: string, fn: (event: unknown) => void): void {
|
||||
handlers[type] = fn
|
||||
},
|
||||
removeEventListener(): void {}
|
||||
},
|
||||
extractPayload: options?.extractPayload ?? validPayload,
|
||||
getCurrentElement: options?.getCurrentElement ?? ((): unknown => ({})),
|
||||
freezeHighlight(): void {},
|
||||
cleanup(): void {}
|
||||
}
|
||||
const window = { __orcaGrab: grab }
|
||||
return {
|
||||
window,
|
||||
click: () => handlers.click?.(noopEvent),
|
||||
contextmenu: () => handlers.contextmenu?.(noopEvent),
|
||||
// cancelAwait is installed on __orcaGrab by the script itself at runtime.
|
||||
cancel: () => (window.__orcaGrab.cancelAwait as (() => void) | undefined)?.()
|
||||
}
|
||||
}
|
||||
|
||||
const runAwaitClick = (harness: ReturnType<typeof armGrabHarness>): unknown =>
|
||||
runInNewContext(buildGuestOverlayScript('awaitClick'), {
|
||||
window: harness.window,
|
||||
Promise: ZoneAwarePromiseLike,
|
||||
Error
|
||||
})
|
||||
|
||||
it('returns the payload through a native async promise, not the page-global Promise', () => {
|
||||
const script = buildGuestOverlayScript('awaitClick')
|
||||
expect(script).toContain('(async function()')
|
||||
expect(script).toContain('return await new Promise(')
|
||||
})
|
||||
|
||||
it('resolves { page, target } across the boundary despite ZoneAwarePromise', async () => {
|
||||
const harness = armGrabHarness()
|
||||
const completion = runAwaitClick(harness)
|
||||
|
||||
// The async IIFE hands Electron an intrinsic promise even though the global
|
||||
// Promise is a non-native thenable — so the boundary unwraps it.
|
||||
expect(types.isPromise(completion)).toBe(true)
|
||||
|
||||
harness.click()
|
||||
const received = await crossExecuteJavaScriptBoundary(completion)
|
||||
|
||||
expect(received).toHaveProperty('page')
|
||||
expect(received).toHaveProperty('target')
|
||||
expect(received).not.toHaveProperty('__zone_symbol__value')
|
||||
expect(clampGrabPayload(received)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('resolves the context-menu marker across the boundary despite ZoneAwarePromise', async () => {
|
||||
const harness = armGrabHarness()
|
||||
const completion = runAwaitClick(harness)
|
||||
|
||||
harness.contextmenu()
|
||||
const received = (await crossExecuteJavaScriptBoundary(completion)) as Record<string, unknown>
|
||||
|
||||
expect(received).toHaveProperty('__orcaContextMenu', true)
|
||||
expect(received.payload).toHaveProperty('page')
|
||||
expect(clampGrabPayload(received.payload)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('resolves the teardown cancel marker across the boundary despite ZoneAwarePromise', async () => {
|
||||
const harness = armGrabHarness()
|
||||
const completion = runAwaitClick(harness)
|
||||
|
||||
harness.cancel()
|
||||
const received = await crossExecuteJavaScriptBoundary(completion)
|
||||
|
||||
expect(received).toEqual({ __orcaCancelled: true })
|
||||
})
|
||||
|
||||
it('rejects across the boundary when selection fails despite ZoneAwarePromise', async () => {
|
||||
// getCurrentElement -> null drives onClick's reject(new Error('cancelled')),
|
||||
// which must surface as a rejected intrinsic promise (not a serialized value)
|
||||
// so the controller classifies it as a cancellation rather than a payload.
|
||||
const harness = armGrabHarness({ getCurrentElement: () => null })
|
||||
const completion = runAwaitClick(harness)
|
||||
|
||||
harness.click()
|
||||
await expect(crossExecuteJavaScriptBoundary(completion)).rejects.toThrow('cancelled')
|
||||
})
|
||||
|
||||
it('control: a bare page-global new Promise would cross as the raw wrapper', async () => {
|
||||
// Proves the harness detects the regression: without the async wrapper the
|
||||
// completion value is the ZoneAwarePromise itself, which the boundary
|
||||
// serializes to __zone_symbol__* fields with no page/target.
|
||||
const bare = runInNewContext('new Promise(function(r){ r({ page: {}, target: {} }); })', {
|
||||
Promise: ZoneAwarePromiseLike
|
||||
})
|
||||
// Symbol.toStringTag='Promise' fools a toString check — exactly why the
|
||||
// boundary must use the brand-based IsPromise, which still rejects it.
|
||||
expect(Object.prototype.toString.call(bare)).toBe('[object Promise]')
|
||||
expect(types.isPromise(bare)).toBe(false)
|
||||
|
||||
const received = await crossExecuteJavaScriptBoundary(bare)
|
||||
expect(received).not.toHaveProperty('page')
|
||||
expect(received).toHaveProperty('__zone_symbol__value')
|
||||
expect(clampGrabPayload(received)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -823,80 +823,90 @@ const ARM_SCRIPT = `(function() {
|
||||
})()`
|
||||
|
||||
// awaitClick: resolve when the user clicks the overlay; stopPropagation + pointer-events:all keep the click off the page.
|
||||
const AWAIT_CLICK_SCRIPT = `new Promise(function(resolve, reject) {
|
||||
'use strict';
|
||||
var grab = window.__orcaGrab;
|
||||
if (!grab) {
|
||||
reject(new Error('Grab not armed'));
|
||||
return;
|
||||
}
|
||||
|
||||
function extractSelectedPayload(el) {
|
||||
try {
|
||||
return grab.extractPayload(el);
|
||||
} catch (error) {
|
||||
grab.cleanup();
|
||||
reject(error instanceof Error ? error : new Error('Failed to extract element context'));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function onClick(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
grab.host.removeEventListener('click', onClick, true);
|
||||
grab.host.removeEventListener('contextmenu', onContext, true);
|
||||
var el = grab.getCurrentElement();
|
||||
if (!el) {
|
||||
grab.cleanup();
|
||||
reject(new Error('cancelled'));
|
||||
const AWAIT_CLICK_SCRIPT = `(async function() {
|
||||
// Why: hand the click result to executeJavaScript through a native (intrinsic)
|
||||
// Promise. On pages that replace the global Promise with a non-native thenable
|
||||
// — e.g. Angular Zone.js's ZoneAwarePromise — a bare \`new Promise(...)\` is not
|
||||
// recognized as a promise by Electron, so its raw wrapper object (exposing
|
||||
// __zone_symbol__state/__value instead of { page, target }) crosses the boundary
|
||||
// and main rejects it as an invalid payload structure. An async function's
|
||||
// promise comes from the engine intrinsic that page code cannot reassign, so
|
||||
// Electron always unwraps it to the resolved payload.
|
||||
return await new Promise(function(resolve, reject) {
|
||||
'use strict';
|
||||
var grab = window.__orcaGrab;
|
||||
if (!grab) {
|
||||
reject(new Error('Grab not armed'));
|
||||
return;
|
||||
}
|
||||
var payload = extractSelectedPayload(el);
|
||||
if (!payload) return;
|
||||
// Why: freeze the highlight instead of removing it so the user sees
|
||||
// which element was selected while the copy menu is shown. Teardown
|
||||
// happens later when the renderer calls setGrabMode(false) or re-arms.
|
||||
grab.freezeHighlight();
|
||||
resolve(payload);
|
||||
}
|
||||
|
||||
function onContext(e) {
|
||||
// Why: right-click resolves with the payload wrapped in a context-menu
|
||||
// marker so the renderer can show the full action dropdown instead of
|
||||
// auto-copying. This gives users a deliberate path to screenshot and
|
||||
// other secondary actions while keeping left-click as the fast copy path.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
grab.host.removeEventListener('click', onClick, true);
|
||||
grab.host.removeEventListener('contextmenu', onContext, true);
|
||||
var el = grab.getCurrentElement();
|
||||
if (!el) {
|
||||
grab.cleanup();
|
||||
reject(new Error('cancelled'));
|
||||
return;
|
||||
function extractSelectedPayload(el) {
|
||||
try {
|
||||
return grab.extractPayload(el);
|
||||
} catch (error) {
|
||||
grab.cleanup();
|
||||
reject(error instanceof Error ? error : new Error('Failed to extract element context'));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
var payload = extractSelectedPayload(el);
|
||||
if (!payload) return;
|
||||
grab.freezeHighlight();
|
||||
resolve({ __orcaContextMenu: true, payload: payload });
|
||||
}
|
||||
|
||||
grab.host.addEventListener('click', onClick, true);
|
||||
grab.host.addEventListener('contextmenu', onContext, true);
|
||||
function onClick(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
grab.host.removeEventListener('click', onClick, true);
|
||||
grab.host.removeEventListener('contextmenu', onContext, true);
|
||||
var el = grab.getCurrentElement();
|
||||
if (!el) {
|
||||
grab.cleanup();
|
||||
reject(new Error('cancelled'));
|
||||
return;
|
||||
}
|
||||
var payload = extractSelectedPayload(el);
|
||||
if (!payload) return;
|
||||
// Why: freeze the highlight instead of removing it so the user sees
|
||||
// which element was selected while the copy menu is shown. Teardown
|
||||
// happens later when the renderer calls setGrabMode(false) or re-arms.
|
||||
grab.freezeHighlight();
|
||||
resolve(payload);
|
||||
}
|
||||
|
||||
// Store cancel hook so teardown can settle the Promise
|
||||
grab.cancelAwait = function() {
|
||||
grab.host.removeEventListener('click', onClick, true);
|
||||
grab.host.removeEventListener('contextmenu', onContext, true);
|
||||
grab.cleanup();
|
||||
// Why: teardown cancellation is a normal user flow; resolving a marker
|
||||
// avoids a noisy guest-console Error while main still treats it as cancel.
|
||||
resolve({ __orcaCancelled: true });
|
||||
};
|
||||
})`
|
||||
function onContext(e) {
|
||||
// Why: right-click resolves with the payload wrapped in a context-menu
|
||||
// marker so the renderer can show the full action dropdown instead of
|
||||
// auto-copying. This gives users a deliberate path to screenshot and
|
||||
// other secondary actions while keeping left-click as the fast copy path.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
grab.host.removeEventListener('click', onClick, true);
|
||||
grab.host.removeEventListener('contextmenu', onContext, true);
|
||||
var el = grab.getCurrentElement();
|
||||
if (!el) {
|
||||
grab.cleanup();
|
||||
reject(new Error('cancelled'));
|
||||
return;
|
||||
}
|
||||
var payload = extractSelectedPayload(el);
|
||||
if (!payload) return;
|
||||
grab.freezeHighlight();
|
||||
resolve({ __orcaContextMenu: true, payload: payload });
|
||||
}
|
||||
|
||||
grab.host.addEventListener('click', onClick, true);
|
||||
grab.host.addEventListener('contextmenu', onContext, true);
|
||||
|
||||
// Store cancel hook so teardown can settle the Promise
|
||||
grab.cancelAwait = function() {
|
||||
grab.host.removeEventListener('click', onClick, true);
|
||||
grab.host.removeEventListener('contextmenu', onContext, true);
|
||||
grab.cleanup();
|
||||
// Why: teardown cancellation is a normal user flow; resolving a marker
|
||||
// avoids a noisy guest-console Error while main still treats it as cancel.
|
||||
resolve({ __orcaCancelled: true });
|
||||
};
|
||||
});
|
||||
})()`
|
||||
|
||||
const FINALIZE_SCRIPT = `(function() {
|
||||
'use strict';
|
||||
@@ -933,8 +943,9 @@ const TEARDOWN_SCRIPT = `(function() {
|
||||
'use strict';
|
||||
var grab = window.__orcaGrab;
|
||||
if (!grab) return true;
|
||||
// If there's an active awaitClick Promise, cancel it so the
|
||||
// executeJavaScript call in main rejects and settles the grab op.
|
||||
// If there's an active awaitClick Promise, cancel it: cancelAwait resolves
|
||||
// it with the __orcaCancelled marker so the executeJavaScript call in main
|
||||
// settles the grab op as a cancellation.
|
||||
if (grab.cancelAwait) {
|
||||
grab.cancelAwait();
|
||||
} else {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '../../shared/wsl-login-shell-command'
|
||||
|
||||
export const WSL_CODEX_AVAILABILITY_TIMEOUT_MS = 5_000
|
||||
export const WSL_CODEX_NOT_FOUND_MESSAGE = 'Codex CLI not found in the WSL login-shell PATH.'
|
||||
|
||||
export function buildWslCodexAvailabilityArgs(distro: string): string[] {
|
||||
const command = [buildCodexPathLookup(), '[ -n "$resolved" ]'].join('\n')
|
||||
@@ -16,7 +17,7 @@ export function buildWslCodexIdentityArgs(distro: string): string[] {
|
||||
const command = [
|
||||
buildCodexPathLookup(),
|
||||
'if [ -z "$resolved" ]; then',
|
||||
" printf '%s\\n' 'Codex CLI not found in the WSL login-shell PATH.' >&2",
|
||||
` printf '%s\\n' '${WSL_CODEX_NOT_FOUND_MESSAGE}' >&2`,
|
||||
' exit 127',
|
||||
'fi',
|
||||
'printf \'%s\\n\' "$resolved"',
|
||||
@@ -29,7 +30,7 @@ export function buildWslCodexAppServerArgs(distro: string, linuxHomePath: string
|
||||
const command = [
|
||||
buildCodexPathLookup(),
|
||||
'if [ -z "$resolved" ]; then',
|
||||
" printf '%s\\n' 'Codex CLI not found in the WSL login-shell PATH.' >&2",
|
||||
` printf '%s\\n' '${WSL_CODEX_NOT_FOUND_MESSAGE}' >&2`,
|
||||
' exit 127',
|
||||
'fi',
|
||||
`export CODEX_HOME=${quotePosixShell(linuxHomePath)}`,
|
||||
|
||||
@@ -321,7 +321,7 @@ describe('runCodexHookTrustGrantSession', () => {
|
||||
})
|
||||
|
||||
const result = await runCodexHookTrustGrantSession(request)
|
||||
expect(result.outcome).toBe('verify-failed')
|
||||
expect(result).toMatchObject({ outcome: 'verify-failed', reasonClass: 'list-mismatch' })
|
||||
})
|
||||
|
||||
it('rejects duplicate normalized aliases that conceal a missing expected key', async () => {
|
||||
@@ -337,7 +337,8 @@ describe('runCodexHookTrustGrantSession', () => {
|
||||
})
|
||||
|
||||
await expect(runCodexHookTrustGrantSession(request)).resolves.toMatchObject({
|
||||
outcome: 'verify-failed'
|
||||
outcome: 'verify-failed',
|
||||
reasonClass: 'list-mismatch'
|
||||
})
|
||||
expect(existsSync(recordFile)).toBe(false)
|
||||
})
|
||||
|
||||
@@ -41,6 +41,13 @@ export type CodexGrantedHookTrust = {
|
||||
trustedHash: string
|
||||
}
|
||||
|
||||
/** Closed verify-failure taxonomy — crosses the grant-bridge JSON envelope, so
|
||||
* telemetry never has to parse the free-form `reason` diagnostics string. */
|
||||
export type CodexTrustGrantSessionVerifyClass =
|
||||
| 'list-mismatch'
|
||||
| 'post-grant-untrusted'
|
||||
| 'post-grant-mismatch'
|
||||
|
||||
export type CodexHookTrustGrantSessionResult =
|
||||
| {
|
||||
outcome: 'granted'
|
||||
@@ -48,7 +55,7 @@ export type CodexHookTrustGrantSessionResult =
|
||||
/** False when every expected entry was already trusted (no write). */
|
||||
wroteTrust: boolean
|
||||
}
|
||||
| { outcome: 'verify-failed'; reason: string }
|
||||
| { outcome: 'verify-failed'; reason: string; reasonClass: CodexTrustGrantSessionVerifyClass }
|
||||
|
||||
type CodexHookListing = {
|
||||
key: string
|
||||
@@ -117,7 +124,8 @@ export async function runCodexHookTrustGrantSession(
|
||||
) {
|
||||
return {
|
||||
outcome: 'verify-failed',
|
||||
reason: `hooks/list reported ${managedListings.length} entries covering ${managedKeyCoverage.size} of ${expectedKeys.size} expected managed entries`
|
||||
reason: `hooks/list reported ${managedListings.length} entries covering ${managedKeyCoverage.size} of ${expectedKeys.size} expected managed entries`,
|
||||
reasonClass: 'list-mismatch'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,13 +152,17 @@ export async function runCodexHookTrustGrantSession(
|
||||
!setContainsEvery(verifiedKeyCoverage, expectedKeys) ||
|
||||
untrusted.length > 0
|
||||
) {
|
||||
return {
|
||||
outcome: 'verify-failed',
|
||||
reason:
|
||||
untrusted.length > 0
|
||||
? `post-grant verify left ${untrusted.length} entries ${untrusted[0].trustStatus}`
|
||||
: `post-grant verify reported ${verifiedListings.length} entries covering ${verifiedKeyCoverage.size} of ${expectedKeys.size} expected entries`
|
||||
}
|
||||
return untrusted.length > 0
|
||||
? {
|
||||
outcome: 'verify-failed',
|
||||
reason: `post-grant verify left ${untrusted.length} entries ${untrusted[0].trustStatus}`,
|
||||
reasonClass: 'post-grant-untrusted'
|
||||
}
|
||||
: {
|
||||
outcome: 'verify-failed',
|
||||
reason: `post-grant verify reported ${verifiedListings.length} entries covering ${verifiedKeyCoverage.size} of ${expectedKeys.size} expected entries`,
|
||||
reasonClass: 'post-grant-mismatch'
|
||||
}
|
||||
}
|
||||
return {
|
||||
outcome: 'granted',
|
||||
|
||||
@@ -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'}`
|
||||
}
|
||||
@@ -13,9 +13,9 @@ import {
|
||||
CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS,
|
||||
getCodexTrustGrantDiagnostics,
|
||||
grantManagedCodexHookTrust,
|
||||
setCodexTrustGrantTelemetry,
|
||||
type CodexManagedTrustGrantPlan
|
||||
} from './codex-hook-trust-grant'
|
||||
import { setCodexTrustGrantTelemetry } from './codex-trust-grant-telemetry'
|
||||
import { readCodexTrustGrantLedgerHome } from './codex-trust-grant-ledger'
|
||||
import {
|
||||
computeTrustKey,
|
||||
@@ -76,7 +76,8 @@ function buildPlan(entries: CodexTrustEntry[]): CodexManagedTrustGrantPlan {
|
||||
tomlPath: join(runtimeHomeDir, 'config.toml'),
|
||||
managedCommand: MANAGED_COMMAND,
|
||||
managedEntries: entries,
|
||||
host: { kind: 'native' }
|
||||
host: { kind: 'native' },
|
||||
telemetryLane: 'real-home'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,7 +249,11 @@ describe('grantManagedCodexHookTrust', () => {
|
||||
|
||||
it('falls back on verify-failed without marking unsupported', () => {
|
||||
const entries = [managedEntry('session_start')]
|
||||
const runner = vi.fn(() => ({ outcome: 'verify-failed' as const, reason: 'missing entries' }))
|
||||
const runner = vi.fn(() => ({
|
||||
outcome: 'verify-failed' as const,
|
||||
reason: 'missing entries',
|
||||
reasonClass: 'list-mismatch' as const
|
||||
}))
|
||||
_internals.setGrantSessionRunnerSync(runner)
|
||||
|
||||
expect(grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({
|
||||
@@ -307,7 +312,11 @@ describe('grantManagedCodexHookTrust', () => {
|
||||
mkdirSync(runtimeHomeDir, { recursive: true })
|
||||
_internals.setGrantSessionRunnerSync(() => {
|
||||
writeFileSync(plan.tomlPath, '[hooks.state."rpc-partial"]\ntrusted_hash = "changed"\n')
|
||||
return { outcome: 'verify-failed', reason: 'post-write listing failed' }
|
||||
return {
|
||||
outcome: 'verify-failed',
|
||||
reason: 'post-write listing failed',
|
||||
reasonClass: 'post-grant-mismatch'
|
||||
}
|
||||
})
|
||||
|
||||
expect(grantManagedCodexHookTrust(plan)).toMatchObject({
|
||||
@@ -347,3 +356,93 @@ describe('grantManagedCodexHookTrust', () => {
|
||||
expect(request.hooksListCwd).toBe('/home/alice/.codex-runtime')
|
||||
})
|
||||
})
|
||||
|
||||
describe('trust-grant telemetry detail', () => {
|
||||
type CapturedEvent = Record<string, unknown>
|
||||
|
||||
function captureTelemetry(): CapturedEvent[] {
|
||||
const events: CapturedEvent[] = []
|
||||
setCodexTrustGrantTelemetry((event) => {
|
||||
events.push(event)
|
||||
})
|
||||
return events
|
||||
}
|
||||
|
||||
it('attributes the plan lane on granted events', () => {
|
||||
const events = captureTelemetry()
|
||||
const entries = [managedEntry('session_start')]
|
||||
_internals.setGrantSessionRunnerSync(() => grantedSessionResult(entries))
|
||||
|
||||
expect(grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({ lane: 'rpc' })
|
||||
expect(events).toEqual([{ outcome: 'granted', hostKind: 'native', lane: 'real-home' }])
|
||||
})
|
||||
|
||||
it('reports the managed lane independently of host kind', () => {
|
||||
const events = captureTelemetry()
|
||||
const entries = [managedEntry('session_start')]
|
||||
_internals.setGrantSessionRunnerSync(() => grantedSessionResult(entries))
|
||||
|
||||
grantManagedCodexHookTrust({ ...buildPlan(entries), telemetryLane: 'managed' })
|
||||
expect(events).toEqual([{ outcome: 'granted', hostKind: 'native', lane: 'managed' }])
|
||||
})
|
||||
|
||||
it('classifies error fallbacks on the wire', () => {
|
||||
const events = captureTelemetry()
|
||||
const entries = [managedEntry('session_start')]
|
||||
_internals.setGrantSessionRunnerSync(() => {
|
||||
throw new Error('spawn codex ENOENT')
|
||||
})
|
||||
|
||||
expect(grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({
|
||||
lane: 'fallback',
|
||||
reason: 'error'
|
||||
})
|
||||
expect(events).toEqual([
|
||||
{
|
||||
outcome: 'fallback',
|
||||
hostKind: 'native',
|
||||
lane: 'real-home',
|
||||
reason: 'error',
|
||||
errorClass: 'binary-missing'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('carries the session verify class through the fallback event', () => {
|
||||
const events = captureTelemetry()
|
||||
const entries = [managedEntry('session_start')]
|
||||
_internals.setGrantSessionRunnerSync(() => ({
|
||||
outcome: 'verify-failed' as const,
|
||||
reason: 'post-grant verify left 1 entries untrusted',
|
||||
reasonClass: 'post-grant-untrusted' as const
|
||||
}))
|
||||
|
||||
grantManagedCodexHookTrust(buildPlan(entries))
|
||||
expect(events).toEqual([
|
||||
{
|
||||
outcome: 'verify_failed',
|
||||
hostKind: 'native',
|
||||
lane: 'real-home',
|
||||
reason: 'verify-failed',
|
||||
verifyClass: 'post-grant-untrusted'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('classifies module-detected verify failures', () => {
|
||||
const events = captureTelemetry()
|
||||
const entries = [managedEntry('session_start'), managedEntry('stop')]
|
||||
_internals.setGrantSessionRunnerSync(() => grantedSessionResult([entries[0]!, entries[0]!]))
|
||||
|
||||
grantManagedCodexHookTrust(buildPlan(entries))
|
||||
expect(events).toEqual([
|
||||
{
|
||||
outcome: 'verify_failed',
|
||||
hostKind: 'native',
|
||||
lane: 'real-home',
|
||||
reason: 'verify-failed',
|
||||
verifyClass: 'duplicate-key'
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,13 @@ import {
|
||||
type CodexHookTrustGrantRequest,
|
||||
type CodexHookTrustGrantSessionResult
|
||||
} from './codex-app-server-client'
|
||||
import {
|
||||
classifyCodexTrustGrantError,
|
||||
emitCodexTrustGrantTelemetry,
|
||||
type CodexTrustGrantFallbackReason,
|
||||
type CodexTrustGrantTelemetryLane,
|
||||
type CodexTrustGrantVerifyClass
|
||||
} from './codex-trust-grant-telemetry'
|
||||
import { runCodexHookTrustGrantSessionSync } from './codex-app-server-grant-bridge'
|
||||
import {
|
||||
codexAppServerCapabilityCache,
|
||||
@@ -46,69 +53,31 @@ export type CodexManagedTrustGrantPlan = {
|
||||
/** Managed trust identities Orca just wrote (no trustedHash). */
|
||||
managedEntries: readonly CodexTrustEntry[]
|
||||
host: CodexTrustGrantHost
|
||||
telemetryLane: CodexTrustGrantTelemetryLane
|
||||
/** Match a pane where CODEX_HOME is absent instead of an explicit managed home. */
|
||||
useDefaultCodexHome?: boolean
|
||||
}
|
||||
|
||||
export type CodexTrustGrantFallbackReason =
|
||||
| 'disabled'
|
||||
| 'no-managed-entries'
|
||||
| 'unsupported'
|
||||
| 'unsupported-cached'
|
||||
| 'verify-failed'
|
||||
| 'retry-cached'
|
||||
| 'error'
|
||||
export type { CodexTrustGrantFallbackReason, CodexTrustGrantTelemetryLane }
|
||||
|
||||
export type CodexManagedTrustGrantOutcome =
|
||||
| { lane: 'rpc'; entries: CodexTrustEntry[] }
|
||||
| { lane: 'fallback'; reason: CodexTrustGrantFallbackReason }
|
||||
|
||||
export type CodexTrustGrantDiagnostics = {
|
||||
granted: number
|
||||
ledgerHits: number
|
||||
fellBack: number
|
||||
verifyFailed: number
|
||||
lastFallbackReason: CodexTrustGrantFallbackReason | null
|
||||
}
|
||||
|
||||
const diagnostics: CodexTrustGrantDiagnostics = {
|
||||
const diagnostics = {
|
||||
granted: 0,
|
||||
ledgerHits: 0,
|
||||
fellBack: 0,
|
||||
verifyFailed: 0,
|
||||
lastFallbackReason: null
|
||||
lastFallbackReason: null as CodexTrustGrantFallbackReason | null
|
||||
}
|
||||
export type CodexTrustGrantDiagnostics = typeof diagnostics
|
||||
const transientRetryAfterByHost = new Map<string, number>()
|
||||
|
||||
export function getCodexTrustGrantDiagnostics(): CodexTrustGrantDiagnostics {
|
||||
return { ...diagnostics }
|
||||
}
|
||||
|
||||
type CodexTrustGrantTelemetry = (event: {
|
||||
outcome: 'granted' | 'fallback' | 'verify_failed'
|
||||
hostKind: 'native' | 'wsl'
|
||||
reason?: CodexTrustGrantFallbackReason
|
||||
}) => void
|
||||
|
||||
// Why: hook-service is bundled into plain-node CLI entries where electron
|
||||
// (and therefore the telemetry client) cannot load; the Electron main process
|
||||
// injects the tracker at startup instead of a static import.
|
||||
let telemetry: CodexTrustGrantTelemetry = () => {}
|
||||
|
||||
export function setCodexTrustGrantTelemetry(tracker: CodexTrustGrantTelemetry): void {
|
||||
telemetry = tracker
|
||||
}
|
||||
|
||||
function emitTelemetry(event: Parameters<CodexTrustGrantTelemetry>[0]): void {
|
||||
try {
|
||||
telemetry(event)
|
||||
} catch (error) {
|
||||
// Why: observability must never turn a verified grant into fallback or
|
||||
// violate this launch-prep API's no-throw contract.
|
||||
console.warn('[codex-trust-grant] failed to emit telemetry', error)
|
||||
}
|
||||
}
|
||||
|
||||
type GrantSessionRunnerSync = (
|
||||
request: CodexHookTrustGrantRequest
|
||||
) => CodexHookTrustGrantSessionResult
|
||||
@@ -118,7 +87,8 @@ let runSessionSync: GrantSessionRunnerSync = runCodexHookTrustGrantSessionSync
|
||||
function fallback(
|
||||
plan: CodexManagedTrustGrantPlan,
|
||||
reason: CodexTrustGrantFallbackReason,
|
||||
detail?: unknown
|
||||
detail?: unknown,
|
||||
verifyClass?: CodexTrustGrantVerifyClass
|
||||
): CodexManagedTrustGrantOutcome {
|
||||
diagnostics.fellBack += 1
|
||||
diagnostics.lastFallbackReason = reason
|
||||
@@ -129,10 +99,13 @@ function fallback(
|
||||
`[codex-trust-grant] falling back to self-computed trust (reason=${reason}, host=${plan.host.kind})`,
|
||||
detail ?? ''
|
||||
)
|
||||
emitTelemetry({
|
||||
emitCodexTrustGrantTelemetry({
|
||||
outcome: reason === 'verify-failed' ? 'verify_failed' : 'fallback',
|
||||
hostKind: plan.host.kind,
|
||||
reason
|
||||
lane: plan.telemetryLane,
|
||||
reason,
|
||||
...(reason === 'error' ? { errorClass: classifyCodexTrustGrantError(detail) } : {}),
|
||||
...(verifyClass !== undefined ? { verifyClass } : {})
|
||||
})
|
||||
return { lane: 'fallback', reason }
|
||||
}
|
||||
@@ -272,7 +245,7 @@ export function grantManagedCodexHookTrust(
|
||||
hostKey,
|
||||
Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS
|
||||
)
|
||||
return fallback(plan, 'verify-failed', result.reason)
|
||||
return fallback(plan, 'verify-failed', result.reason, result.reasonClass)
|
||||
}
|
||||
|
||||
const byNormalizedKey = new Map(expected.map((item) => [item.normalizedKey, item]))
|
||||
@@ -287,7 +260,12 @@ export function grantManagedCodexHookTrust(
|
||||
hostKey,
|
||||
Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS
|
||||
)
|
||||
return fallback(plan, 'verify-failed', `unexpected granted key ${granted.key}`)
|
||||
return fallback(
|
||||
plan,
|
||||
'verify-failed',
|
||||
`unexpected granted key ${granted.key}`,
|
||||
'unexpected-key'
|
||||
)
|
||||
}
|
||||
if (seenNormalizedKeys.has(granted.normalizedKey)) {
|
||||
restoreCodexTrustConfig(plan.tomlPath, configSnapshot)
|
||||
@@ -295,7 +273,12 @@ export function grantManagedCodexHookTrust(
|
||||
hostKey,
|
||||
Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS
|
||||
)
|
||||
return fallback(plan, 'verify-failed', `duplicate granted key ${granted.key}`)
|
||||
return fallback(
|
||||
plan,
|
||||
'verify-failed',
|
||||
`duplicate granted key ${granted.key}`,
|
||||
'duplicate-key'
|
||||
)
|
||||
}
|
||||
seenNormalizedKeys.add(granted.normalizedKey)
|
||||
grantedEntries.push({ ...match.entry, trustedHash: granted.trustedHash })
|
||||
@@ -310,7 +293,12 @@ export function grantManagedCodexHookTrust(
|
||||
hostKey,
|
||||
Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS
|
||||
)
|
||||
return fallback(plan, 'verify-failed', 'granted entry set did not cover expected entries')
|
||||
return fallback(
|
||||
plan,
|
||||
'verify-failed',
|
||||
'granted entry set did not cover expected entries',
|
||||
'coverage'
|
||||
)
|
||||
}
|
||||
transientRetryAfterByHost.delete(hostKey)
|
||||
try {
|
||||
@@ -327,7 +315,11 @@ export function grantManagedCodexHookTrust(
|
||||
`[codex-trust-grant] granted ${grantedEntries.length} managed hook entries via codex app-server ` +
|
||||
`(host=${plan.host.kind}, wrote=${result.wroteTrust}, ${Date.now() - startedAtMs}ms)`
|
||||
)
|
||||
emitTelemetry({ outcome: 'granted', hostKind: plan.host.kind })
|
||||
emitCodexTrustGrantTelemetry({
|
||||
outcome: 'granted',
|
||||
hostKind: plan.host.kind,
|
||||
lane: plan.telemetryLane
|
||||
})
|
||||
return { lane: 'rpc', entries: grantedEntries }
|
||||
} catch (error) {
|
||||
return fallback(plan, 'error', error)
|
||||
|
||||
@@ -196,6 +196,7 @@ function installRealHomeCodexHook(userDataPath: string): RealHomeCodexHookLane {
|
||||
managedCommand: material.command,
|
||||
managedEntries,
|
||||
host: { kind: 'native' },
|
||||
telemetryLane: 'real-home',
|
||||
useDefaultCodexHome: true
|
||||
})
|
||||
if (grant.lane === 'rpc') {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { WSL_CODEX_NOT_FOUND_MESSAGE } from '../codex-accounts/wsl-codex-command'
|
||||
import { CodexAppServerTimeoutError } from './codex-app-server-client'
|
||||
import { classifyCodexTrustGrantError } from './codex-trust-grant-telemetry'
|
||||
|
||||
describe('classifyCodexTrustGrantError', () => {
|
||||
it.each([
|
||||
[new CodexAppServerTimeoutError('entry exceeded 20000ms session deadline'), 'timeout'],
|
||||
[new Error('spawn codex ENOENT'), 'binary-missing'],
|
||||
[new Error('spawn /Users/ada/.local/bin/codex ENOENT'), 'binary-missing'],
|
||||
[
|
||||
new Error(
|
||||
`codex app-server exited before completing the session: ${WSL_CODEX_NOT_FOUND_MESSAGE}`
|
||||
),
|
||||
'binary-missing'
|
||||
],
|
||||
[new Error('spawn wsl.exe ENOENT'), 'unexpected'],
|
||||
[
|
||||
new Error("ENOENT: no such file or directory, open '/home/ada/.codex/config.toml'"),
|
||||
'unexpected'
|
||||
],
|
||||
[new Error('codex trust-grant entry bundle not found'), 'entry-failed'],
|
||||
[new Error('codex trust-grant entry produced no result (exit 1)'), 'entry-failed'],
|
||||
[
|
||||
new Error('codex app-server exited before completing the session: panicked at main.rs'),
|
||||
'early-exit'
|
||||
],
|
||||
[new Error('codex app-server config/batchWrite failed: unknown key'), 'rpc-failed'],
|
||||
[new Error('write EPIPE'), 'unexpected'],
|
||||
['not an error object', 'unexpected']
|
||||
] as const)('classifies %s as %s', (error, expected) => {
|
||||
expect(classifyCodexTrustGrantError(error)).toBe(expected)
|
||||
})
|
||||
|
||||
it('keeps an ENOENT-mentioning stderr tail classified as early-exit', () => {
|
||||
expect(
|
||||
classifyCodexTrustGrantError(
|
||||
new Error('codex app-server exited before completing the session: ENOENT in codex output')
|
||||
)
|
||||
).toBe('early-exit')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { CodexTrustGrantSessionVerifyClass } from './codex-app-server-client'
|
||||
import { WSL_CODEX_NOT_FOUND_MESSAGE } from '../codex-accounts/wsl-codex-command'
|
||||
|
||||
/** Which install surface asked for the grant: the system-default real ~/.codex
|
||||
* or a managed (mirror/per-account) home. Telemetry attribution only — the
|
||||
* grant behaves identically; host_kind alone cannot distinguish the lanes
|
||||
* (native hosts grant for both surfaces). */
|
||||
export type CodexTrustGrantTelemetryLane = 'real-home' | 'managed'
|
||||
|
||||
export type CodexTrustGrantFallbackReason =
|
||||
| 'disabled'
|
||||
| 'no-managed-entries'
|
||||
| 'unsupported'
|
||||
| 'unsupported-cached'
|
||||
| 'verify-failed'
|
||||
| 'retry-cached'
|
||||
| 'error'
|
||||
|
||||
/** Closed classification of `reason: 'error'` fallbacks. Errors cross the
|
||||
* grant-bridge envelope as message text (only timeout/unsupported keep their
|
||||
* name), so classes are matched on the bounded message shapes each layer
|
||||
* produces — never forwarded raw. */
|
||||
export type CodexTrustGrantErrorClass =
|
||||
| 'binary-missing'
|
||||
| 'timeout'
|
||||
| 'entry-failed'
|
||||
| 'early-exit'
|
||||
| 'rpc-failed'
|
||||
| 'unexpected'
|
||||
|
||||
export type CodexTrustGrantVerifyClass =
|
||||
| CodexTrustGrantSessionVerifyClass
|
||||
| 'unexpected-key'
|
||||
| 'duplicate-key'
|
||||
| 'coverage'
|
||||
|
||||
export function classifyCodexTrustGrantError(error: unknown): CodexTrustGrantErrorClass {
|
||||
if (!(error instanceof Error)) {
|
||||
return 'unexpected'
|
||||
}
|
||||
if (error.name === 'CodexAppServerTimeoutError') {
|
||||
return 'timeout'
|
||||
}
|
||||
const message = error.message
|
||||
if (message.includes('codex trust-grant entry')) {
|
||||
return 'entry-failed'
|
||||
}
|
||||
if (
|
||||
/^spawn (?:.*[\\/])?codex(?:\.(?:cmd|exe|bat))? ENOENT$/.test(message) ||
|
||||
message.includes(WSL_CODEX_NOT_FOUND_MESSAGE)
|
||||
) {
|
||||
return 'binary-missing'
|
||||
}
|
||||
if (message.includes('exited before completing the session')) {
|
||||
return 'early-exit'
|
||||
}
|
||||
if (/codex app-server \S+ failed:/.test(message)) {
|
||||
return 'rpc-failed'
|
||||
}
|
||||
return 'unexpected'
|
||||
}
|
||||
|
||||
export type CodexTrustGrantTelemetryEvent = {
|
||||
outcome: 'granted' | 'fallback' | 'verify_failed'
|
||||
hostKind: 'native' | 'wsl'
|
||||
lane: CodexTrustGrantTelemetryLane
|
||||
reason?: CodexTrustGrantFallbackReason
|
||||
errorClass?: CodexTrustGrantErrorClass
|
||||
verifyClass?: CodexTrustGrantVerifyClass
|
||||
}
|
||||
|
||||
type CodexTrustGrantTelemetry = (event: CodexTrustGrantTelemetryEvent) => void
|
||||
|
||||
// Why: hook-service is bundled into plain-node CLI entries where electron
|
||||
// (and therefore the telemetry client) cannot load; the Electron main process
|
||||
// injects the tracker at startup instead of a static import.
|
||||
let telemetry: CodexTrustGrantTelemetry = () => {}
|
||||
|
||||
export function setCodexTrustGrantTelemetry(tracker: CodexTrustGrantTelemetry): void {
|
||||
telemetry = tracker
|
||||
}
|
||||
|
||||
export function emitCodexTrustGrantTelemetry(event: CodexTrustGrantTelemetryEvent): void {
|
||||
try {
|
||||
telemetry(event)
|
||||
} catch (error) {
|
||||
// Why: observability must never turn a verified grant into fallback or
|
||||
// violate the launch-prep no-throw contract of the grant lane.
|
||||
console.warn('[codex-trust-grant] failed to emit telemetry', error)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -916,7 +916,8 @@ function installManagedHooksIntoWslRuntime(
|
||||
tomlPath: plan.tomlPath,
|
||||
managedCommand: command,
|
||||
managedEntries: trustEntries,
|
||||
host: { kind: 'wsl', distro: plan.wslDistro, linuxRuntimeHome: plan.linuxRuntimeHome }
|
||||
host: { kind: 'wsl', distro: plan.wslDistro, linuxRuntimeHome: plan.linuxRuntimeHome },
|
||||
telemetryLane: 'managed'
|
||||
})
|
||||
if (grant.lane === 'fallback') {
|
||||
// Why: WSL runtime homes may carry user hook approvals we did not rebuild
|
||||
@@ -1353,7 +1354,8 @@ export class CodexHookService {
|
||||
tomlPath,
|
||||
managedCommand: command,
|
||||
managedEntries: managedTrustEntries,
|
||||
host: { kind: 'native' }
|
||||
host: { kind: 'native' },
|
||||
telemetryLane: 'managed'
|
||||
})
|
||||
if (grant.lane === 'rpc') {
|
||||
recentGrantEntries = grant.entries
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { readFileSync, mkdtempSync, rmSync } from 'node:fs'
|
||||
import { connect, type Socket } from 'node:net'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DaemonServer } from './daemon-server'
|
||||
import { getDaemonSocketPath } from './daemon-spawner'
|
||||
import { encodeNdjson } from './ndjson'
|
||||
import type { SubprocessHandle } from './session'
|
||||
import { PROTOCOL_VERSION } from './types'
|
||||
|
||||
function unusedSubprocess(): SubprocessHandle {
|
||||
throw new Error('Test must not create a PTY')
|
||||
}
|
||||
|
||||
describe('daemon authenticated client activity', () => {
|
||||
let dir: string
|
||||
let socketPath: string
|
||||
let tokenPath: string
|
||||
let server: DaemonServer
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'daemon-client-activity-'))
|
||||
socketPath = getDaemonSocketPath(dir)
|
||||
tokenPath = join(dir, 'daemon.token')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.shutdown()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function connectHello(role: 'control' | 'stream', clientId: string): Promise<Socket> {
|
||||
const socket = connect(socketPath)
|
||||
await new Promise<void>((resolve) => socket.once('connect', resolve))
|
||||
socket.write(
|
||||
encodeNdjson({
|
||||
type: 'hello',
|
||||
version: PROTOCOL_VERSION,
|
||||
token: readFileSync(tokenPath, 'utf8').trim(),
|
||||
clientId,
|
||||
role
|
||||
})
|
||||
)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once('data', (data) => {
|
||||
const response = JSON.parse(data.toString().trim()) as { ok?: boolean; error?: string }
|
||||
if (response.ok) {
|
||||
resolve()
|
||||
} else {
|
||||
reject(new Error(response.error ?? 'hello rejected'))
|
||||
}
|
||||
})
|
||||
socket.once('error', reject)
|
||||
})
|
||||
return socket
|
||||
}
|
||||
|
||||
it('excludes control-only health probes and reports one complete app pair', async () => {
|
||||
const onAuthenticatedClientPair = vi.fn()
|
||||
server = new DaemonServer({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
spawnSubprocess: unusedSubprocess,
|
||||
onAuthenticatedClientPair
|
||||
})
|
||||
await server.start()
|
||||
|
||||
await connectHello('control', 'resolver-health-check')
|
||||
expect(onAuthenticatedClientPair).not.toHaveBeenCalled()
|
||||
|
||||
await connectHello('control', 'app-client')
|
||||
expect(onAuthenticatedClientPair).not.toHaveBeenCalled()
|
||||
await connectHello('stream', 'app-client')
|
||||
expect(onAuthenticatedClientPair).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -97,4 +97,17 @@ describe('daemon-entry parseArgs', () => {
|
||||
it('still requires --socket and --token when --log-file is given', () => {
|
||||
expect(() => parseArgs(['--log-file', '/tmp/daemon.log'])).toThrow('Usage:')
|
||||
})
|
||||
|
||||
it('parses the GUI-only --login-session-watch flag and omits it when absent', () => {
|
||||
expect(
|
||||
parseArgs(['--socket', '/tmp/t.sock', '--token', '/tmp/t.token', '--login-session-watch'])
|
||||
).toEqual({
|
||||
socketPath: '/tmp/t.sock',
|
||||
tokenPath: '/tmp/t.token',
|
||||
loginSessionWatch: true
|
||||
})
|
||||
expect(parseArgs(['--socket', '/tmp/t.sock', '--token', '/tmp/t.token'])).not.toHaveProperty(
|
||||
'loginSessionWatch'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,19 +6,27 @@
|
||||
* Signals readiness to parent via IPC: { type: 'ready' }
|
||||
* Shuts down cleanly on SIGTERM.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { startDaemon, type DaemonHandle } from './daemon-main'
|
||||
import { createPtySubprocess } from './pty-subprocess'
|
||||
import { warmWindowsConptyOnce } from './windows-conpty-warmup'
|
||||
import { warmPwshAvailabilityCache } from '../pwsh'
|
||||
import { createDaemonFileLog, createNoopDaemonFileLog } from './daemon-file-log'
|
||||
import { PROTOCOL_VERSION } from './types'
|
||||
import { prepareMacosTccLoginShell } from '../providers/macos-tcc-login-shell'
|
||||
import {
|
||||
prepareMacosTccLoginShell,
|
||||
probeMacosLoginSessionAlive
|
||||
} from '../providers/macos-tcc-login-shell'
|
||||
import { MacosLoginSessionDeathWatch } from './macos-login-session-death-watch'
|
||||
import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health'
|
||||
|
||||
export type ParsedDaemonArgs = {
|
||||
socketPath: string
|
||||
tokenPath: string
|
||||
pidPath?: string
|
||||
launchNonce?: string
|
||||
/** GUI-spawned daemons only — headless serve/SSH daemons must survive session loss. */
|
||||
loginSessionWatch?: boolean
|
||||
/** Optional — absent for adopted old daemons and tests, which log nothing. */
|
||||
logFilePath?: string
|
||||
}
|
||||
@@ -29,6 +37,7 @@ export function parseArgs(argv: string[]): ParsedDaemonArgs {
|
||||
let logFilePath = ''
|
||||
let pidPath = ''
|
||||
let launchNonce = ''
|
||||
let loginSessionWatch = false
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
if (argv[i] === '--socket' && argv[i + 1]) {
|
||||
@@ -46,6 +55,8 @@ export function parseArgs(argv: string[]): ParsedDaemonArgs {
|
||||
} else if (argv[i] === '--launch-nonce' && argv[i + 1]) {
|
||||
launchNonce = argv[i + 1]
|
||||
i++
|
||||
} else if (argv[i] === '--login-session-watch') {
|
||||
loginSessionWatch = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +72,7 @@ export function parseArgs(argv: string[]): ParsedDaemonArgs {
|
||||
socketPath,
|
||||
tokenPath,
|
||||
...(pidPath ? { pidPath, launchNonce } : {}),
|
||||
...(loginSessionWatch ? { loginSessionWatch } : {}),
|
||||
...(logFilePath ? { logFilePath } : {})
|
||||
}
|
||||
}
|
||||
@@ -73,7 +85,7 @@ async function main(): Promise<void> {
|
||||
// an otherwise healthy detached daemon. Swallow it: stderr is diagnostic only.
|
||||
process.stderr.on('error', () => {})
|
||||
|
||||
const { socketPath, tokenPath, pidPath, launchNonce, logFilePath } = parseArgs(
|
||||
const { socketPath, tokenPath, pidPath, launchNonce, loginSessionWatch, logFilePath } = parseArgs(
|
||||
process.argv.slice(2)
|
||||
)
|
||||
const startedAtMs = Date.now() - process.uptime() * 1000
|
||||
@@ -123,6 +135,7 @@ async function main(): Promise<void> {
|
||||
})
|
||||
|
||||
let daemon: DaemonHandle | null = null
|
||||
let deathWatch: MacosLoginSessionDeathWatch | null = null
|
||||
let shuttingDown = false
|
||||
// Bound the wait so a wedged native shutdown can't leave the daemon running
|
||||
// forever on SIGTERM/SIGINT (it would then survive a real quit, not just updates).
|
||||
@@ -134,6 +147,7 @@ async function main(): Promise<void> {
|
||||
return
|
||||
}
|
||||
shuttingDown = true
|
||||
deathWatch?.stop()
|
||||
daemonLog.log('shutdown', { reason })
|
||||
try {
|
||||
if (daemon) {
|
||||
@@ -157,6 +171,63 @@ async function main(): Promise<void> {
|
||||
process.on('SIGTERM', () => void shutdown('SIGTERM'))
|
||||
process.on('SIGINT', () => void shutdown('SIGINT'))
|
||||
|
||||
// Why: a dead macOS login session cannot be fabricated without root (PAM owns
|
||||
// audit-session teardown), so e2e drives the oracles from a verdict file:
|
||||
// 'alive' → accepted/healthy, 'dead' → rejected/unhealthy, 'hang' →
|
||||
// timeout-inconclusive/unhealthy (the fail-safe path), else inconclusive.
|
||||
const e2eProbeFile = process.env.ORCA_E2E_LOGIN_SESSION_PROBE_FILE
|
||||
const readE2eVerdict = (): string => {
|
||||
try {
|
||||
return readFileSync(e2eProbeFile as string, 'utf8').trim()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
deathWatch =
|
||||
loginSessionWatch && process.platform === 'darwin'
|
||||
? new MacosLoginSessionDeathWatch({
|
||||
probeLoginSession: e2eProbeFile
|
||||
? async () => {
|
||||
const verdict = readE2eVerdict()
|
||||
if (verdict === 'alive') {
|
||||
return { ok: true, conclusive: true, reason: 'accepted' }
|
||||
}
|
||||
if (verdict === 'dead') {
|
||||
return { ok: false, conclusive: true, reason: 'rejected' }
|
||||
}
|
||||
return { ok: false, conclusive: false, reason: 'timeout' }
|
||||
}
|
||||
: probeMacosLoginSessionAlive,
|
||||
readResolverHealth: e2eProbeFile
|
||||
? async () => {
|
||||
const verdict = readE2eVerdict()
|
||||
return verdict === 'dead' || verdict === 'hang' ? 'unhealthy' : 'healthy'
|
||||
}
|
||||
: readCurrentProcessMacSystemResolverHealth,
|
||||
...(e2eProbeFile
|
||||
? {
|
||||
timing: {
|
||||
periodicProbeMs: 2_000,
|
||||
rejectionRecheckMs: 500,
|
||||
ptyExitDebounceMs: 200,
|
||||
clientActivityMinGapMs: 1_000,
|
||||
minProbeGapMs: 100
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
log: daemonLog,
|
||||
onRetire: (details) => {
|
||||
shuttingDown = true
|
||||
daemonLog.log('login-session-dead-retire', details)
|
||||
daemonLog.close()
|
||||
// Why: crash-style exit (no PTY teardown) keeps session meta unclean so the
|
||||
// replacement daemon cold-restores scrollback; stale socket/pid files ride
|
||||
// the existing dead-endpoint recovery.
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
: null
|
||||
|
||||
daemon = await startDaemon({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
@@ -165,14 +236,22 @@ async function main(): Promise<void> {
|
||||
...(pidPath ? { startedAtMs } : {}),
|
||||
log: daemonLog,
|
||||
preparePtySpawn: runMacosLoginPreflight,
|
||||
...(deathWatch
|
||||
? {
|
||||
onPtySessionExit: () => deathWatch.notifyPtyExit(),
|
||||
onAuthenticatedClientPair: () => deathWatch.notifyClientActivity()
|
||||
}
|
||||
: {}),
|
||||
spawnSubprocess: (opts) => createPtySubprocess(opts),
|
||||
onIdleShutdown: () => {
|
||||
deathWatch?.stop()
|
||||
shuttingDown = true
|
||||
daemonLog.log('shutdown', { reason: 'idle' })
|
||||
daemonLog.close()
|
||||
process.exit(0)
|
||||
}
|
||||
})
|
||||
deathWatch?.start()
|
||||
|
||||
// Signal readiness to parent via IPC (if available)
|
||||
if (process.send) {
|
||||
|
||||
@@ -1086,7 +1086,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
|
||||
it('respawns instead of reusing a healthy daemon launched from another app path', async () => {
|
||||
const mod = await importFresh()
|
||||
await mod.initDaemonPtyProvider()
|
||||
await mod.initDaemonPtyProvider(undefined, { macosLoginSessionWatch: true })
|
||||
|
||||
const launcher = spawnerInstances[0].launcher as (
|
||||
socketPath: string,
|
||||
@@ -1137,6 +1137,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
'/fake/socket',
|
||||
'--token',
|
||||
'/fake/token',
|
||||
'--login-session-watch',
|
||||
'--log-file',
|
||||
join(FAKE_USER_DATA_PATH, 'logs', 'daemon.log')
|
||||
]),
|
||||
@@ -1513,6 +1514,9 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
|
||||
await launcher('/fake/socket', '/fake/token')
|
||||
|
||||
const launchedArgsWithoutWatch = forkMock.mock.calls.at(-1)?.[1] as string[]
|
||||
expect(launchedArgsWithoutWatch).not.toContain('--login-session-watch')
|
||||
|
||||
expect(offMock).toHaveBeenCalledWith('message', expect.any(Function))
|
||||
expect(offMock).toHaveBeenCalledWith('error', expect.any(Function))
|
||||
expect(offMock).toHaveBeenCalledWith('exit', expect.any(Function))
|
||||
|
||||
@@ -321,7 +321,10 @@ async function shouldPreserveDaemonWithLiveSessions(
|
||||
return true
|
||||
}
|
||||
|
||||
function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
|
||||
function createOutOfProcessLauncher(
|
||||
runtimeDir: string,
|
||||
macosLoginSessionWatch = false
|
||||
): DaemonLauncher {
|
||||
return async (socketPath, tokenPath, suppliedPidPath, suppliedLaunchNonce) => {
|
||||
const entryPath = getDaemonEntryPath()
|
||||
const pidPath = suppliedPidPath ?? getDaemonPidPath(runtimeDir)
|
||||
@@ -448,6 +451,7 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
|
||||
pidPath,
|
||||
'--launch-nonce',
|
||||
launchNonce,
|
||||
...(macosLoginSessionWatch ? ['--login-session-watch'] : []),
|
||||
...daemonLogArgs()
|
||||
],
|
||||
{
|
||||
@@ -625,7 +629,10 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
|
||||
}
|
||||
}
|
||||
|
||||
export async function initDaemonPtyProvider(signal?: AbortSignal): Promise<void> {
|
||||
export async function initDaemonPtyProvider(
|
||||
signal?: AbortSignal,
|
||||
options: { macosLoginSessionWatch?: boolean } = {}
|
||||
): Promise<void> {
|
||||
logDaemonMilestone('daemon-init-start')
|
||||
// Why: e2e coverage for the startup PTY gate (#5232) needs a daemon init that deterministically outlasts the first-window timeout.
|
||||
const e2eInitDelayMs = Number(process.env.ORCA_E2E_DAEMON_INIT_DELAY_MS)
|
||||
@@ -636,7 +643,7 @@ export async function initDaemonPtyProvider(signal?: AbortSignal): Promise<void>
|
||||
|
||||
const newSpawner = new DaemonSpawner({
|
||||
runtimeDir,
|
||||
launcher: createOutOfProcessLauncher(runtimeDir)
|
||||
launcher: createOutOfProcessLauncher(runtimeDir, options.macosLoginSessionWatch ?? false)
|
||||
})
|
||||
|
||||
// Why: assign the module-level spawner/adapter only after both succeed, so a failed ensureRunning() leaves no stale spawner.
|
||||
|
||||
@@ -11,6 +11,8 @@ export type DaemonStartOptions = {
|
||||
protocolVersion?: number
|
||||
spawnSubprocess: DaemonServerOptions['spawnSubprocess']
|
||||
preparePtySpawn?: DaemonServerOptions['preparePtySpawn']
|
||||
onPtySessionExit?: DaemonServerOptions['onPtySessionExit']
|
||||
onAuthenticatedClientPair?: DaemonServerOptions['onAuthenticatedClientPair']
|
||||
log?: DaemonFileLog
|
||||
onIdleShutdown?: () => void
|
||||
initialAdoptionTestConfig?: DaemonServerOptions['initialAdoptionTestConfig']
|
||||
@@ -30,6 +32,10 @@ export async function startDaemon(opts: DaemonStartOptions): Promise<DaemonHandl
|
||||
...(opts.protocolVersion !== undefined ? { protocolVersion: opts.protocolVersion } : {}),
|
||||
spawnSubprocess: opts.spawnSubprocess,
|
||||
...(opts.preparePtySpawn ? { preparePtySpawn: opts.preparePtySpawn } : {}),
|
||||
...(opts.onPtySessionExit ? { onPtySessionExit: opts.onPtySessionExit } : {}),
|
||||
...(opts.onAuthenticatedClientPair
|
||||
? { onAuthenticatedClientPair: opts.onAuthenticatedClientPair }
|
||||
: {}),
|
||||
...(opts.log ? { log: opts.log } : {}),
|
||||
...(opts.onIdleShutdown ? { onIdleShutdown: opts.onIdleShutdown } : {}),
|
||||
...(opts.initialAdoptionTestConfig
|
||||
|
||||
@@ -57,6 +57,9 @@ export type DaemonServerOptions = {
|
||||
}
|
||||
ptySpawnHealthCheck?: () => Promise<void>
|
||||
preparePtySpawn?: () => Promise<void>
|
||||
// Why: login-session death detection (#7936) probes on PTY-exit bursts and fresh app connections.
|
||||
onPtySessionExit?: (sessionId: string) => void
|
||||
onAuthenticatedClientPair?: () => void
|
||||
log?: DaemonFileLog
|
||||
spawnSubprocess: (opts: {
|
||||
sessionId: string
|
||||
@@ -101,6 +104,7 @@ export class DaemonServer {
|
||||
private startedAtMs: number | null
|
||||
private protocolVersion: number
|
||||
private onIdleShutdown: () => void
|
||||
private onAuthenticatedClientPair: () => void
|
||||
private ptySpawnHealthCheck: () => Promise<void>
|
||||
private preparePtySpawn: () => Promise<void>
|
||||
private log: DaemonFileLog
|
||||
@@ -180,7 +184,11 @@ export class DaemonServer {
|
||||
now: () => Date.now()
|
||||
}
|
||||
this.token = randomUUID()
|
||||
this.host = new TerminalHost({ spawnSubprocess: opts.spawnSubprocess })
|
||||
this.onAuthenticatedClientPair = opts.onAuthenticatedClientPair ?? (() => {})
|
||||
this.host = new TerminalHost({
|
||||
spawnSubprocess: opts.spawnSubprocess,
|
||||
...(opts.onPtySessionExit ? { onSessionReaped: opts.onPtySessionExit } : {})
|
||||
})
|
||||
this.ptySpawnHealthCheck = opts.ptySpawnHealthCheck ?? checkPtySpawnHealth
|
||||
this.preparePtySpawn = opts.preparePtySpawn ?? (() => Promise.resolve())
|
||||
this.stopStreamBacklogProbe = startDaemonStreamBacklogProbe(() => ({
|
||||
@@ -481,6 +489,8 @@ export class DaemonServer {
|
||||
}
|
||||
this.setupStreamSocket(socket, client)
|
||||
client.authenticatedPairEstablished = true
|
||||
// Why: one-shot health probes authenticate only a control socket; they are not fresh app activity.
|
||||
this.onAuthenticatedClientPair()
|
||||
// A complete app connection (unlike a probe) re-owns the endpoint and cancels pending retirement.
|
||||
this.initialAdoptionDeadlineMs = null
|
||||
this.retirementRequested = false
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { LoginPreflightOutcome } from '../providers/macos-tcc-login-shell'
|
||||
import { createNoopDaemonFileLog } from './daemon-file-log'
|
||||
import {
|
||||
MacosLoginSessionDeathWatch,
|
||||
type MacosLoginSessionDeathWatchOptions
|
||||
} from './macos-login-session-death-watch'
|
||||
import type { SystemResolverHealth } from './types'
|
||||
|
||||
const ACCEPTED: LoginPreflightOutcome = { ok: true, conclusive: true, reason: 'accepted' }
|
||||
const REJECTED: LoginPreflightOutcome = { ok: false, conclusive: true, reason: 'rejected' }
|
||||
const INCONCLUSIVE: LoginPreflightOutcome = { ok: false, conclusive: false, reason: 'timeout' }
|
||||
|
||||
type FakeTimer = { at: number; callback: () => void; cleared: boolean }
|
||||
|
||||
class FakeClock {
|
||||
private timers: FakeTimer[] = []
|
||||
private nowMs = 0
|
||||
|
||||
setTimeout = (callback: () => void, delayMs: number): unknown => {
|
||||
const timer: FakeTimer = { at: this.nowMs + delayMs, callback, cleared: false }
|
||||
this.timers.push(timer)
|
||||
return timer
|
||||
}
|
||||
|
||||
clearTimeout = (handle: unknown): void => {
|
||||
;(handle as FakeTimer).cleared = true
|
||||
}
|
||||
|
||||
now = (): number => this.nowMs
|
||||
|
||||
async advance(ms: number): Promise<void> {
|
||||
const target = this.nowMs + ms
|
||||
for (;;) {
|
||||
const due = this.timers
|
||||
.filter((t) => !t.cleared && t.at <= target)
|
||||
.sort((a, b) => a.at - b.at)[0]
|
||||
if (!due) {
|
||||
break
|
||||
}
|
||||
this.nowMs = due.at
|
||||
due.cleared = true
|
||||
due.callback()
|
||||
// Why: probes are async; let their promise chains settle before firing the next timer.
|
||||
await drainMicrotasks()
|
||||
}
|
||||
this.nowMs = target
|
||||
}
|
||||
|
||||
pendingCount(): number {
|
||||
return this.timers.filter((t) => !t.cleared).length
|
||||
}
|
||||
}
|
||||
|
||||
async function drainMicrotasks(): Promise<void> {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
function createWatch(
|
||||
overrides: Partial<MacosLoginSessionDeathWatchOptions> & {
|
||||
outcomes?: (LoginPreflightOutcome | null)[]
|
||||
} = {}
|
||||
): {
|
||||
watch: MacosLoginSessionDeathWatch
|
||||
clock: FakeClock
|
||||
onRetire: ReturnType<typeof vi.fn>
|
||||
probe: ReturnType<typeof vi.fn>
|
||||
setResolverHealth: (health: SystemResolverHealth) => void
|
||||
} {
|
||||
const clock = new FakeClock()
|
||||
const onRetire = vi.fn()
|
||||
const outcomes = overrides.outcomes ?? []
|
||||
// Why length-check, not `??`: an explicit null outcome (wrapper not applicable) must reach the watch.
|
||||
const probe = vi.fn(async () => (outcomes.length ? outcomes.shift()! : ACCEPTED))
|
||||
let resolverHealth: SystemResolverHealth = 'unhealthy'
|
||||
const watch = new MacosLoginSessionDeathWatch({
|
||||
probeLoginSession: overrides.probeLoginSession ?? probe,
|
||||
readResolverHealth: overrides.readResolverHealth ?? (async () => resolverHealth),
|
||||
onRetire: overrides.onRetire ?? onRetire,
|
||||
log: overrides.log ?? createNoopDaemonFileLog(),
|
||||
clock,
|
||||
timing: {
|
||||
periodicProbeMs: 120_000,
|
||||
rejectionRecheckMs: 10_000,
|
||||
ptyExitDebounceMs: 2_000,
|
||||
clientActivityMinGapMs: 30_000,
|
||||
minProbeGapMs: 5_000,
|
||||
...overrides.timing
|
||||
}
|
||||
})
|
||||
return {
|
||||
watch,
|
||||
clock,
|
||||
onRetire,
|
||||
probe,
|
||||
setResolverHealth: (health) => {
|
||||
resolverHealth = health
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('MacosLoginSessionDeathWatch', () => {
|
||||
it('retires after consecutive conclusive rejections once armed, with a degraded resolver', async () => {
|
||||
const { watch, clock, onRetire } = createWatch({
|
||||
outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED]
|
||||
})
|
||||
watch.start()
|
||||
await drainMicrotasks()
|
||||
expect(onRetire).not.toHaveBeenCalled()
|
||||
|
||||
await clock.advance(120_000) // periodic → rejection 1
|
||||
await clock.advance(10_000) // recheck → rejection 2
|
||||
expect(onRetire).not.toHaveBeenCalled()
|
||||
await clock.advance(10_000) // recheck → rejection 3 → retire
|
||||
expect(onRetire).toHaveBeenCalledWith({
|
||||
cause: 'pam-rejections',
|
||||
rejections: 3,
|
||||
resolverHealth: 'unhealthy'
|
||||
})
|
||||
})
|
||||
|
||||
it('never retires when the session was never conclusively accepted', async () => {
|
||||
const { watch, clock, onRetire } = createWatch({
|
||||
outcomes: [REJECTED, REJECTED, REJECTED, REJECTED, REJECTED]
|
||||
})
|
||||
watch.start()
|
||||
await drainMicrotasks()
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await clock.advance(120_000)
|
||||
}
|
||||
expect(onRetire).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resets the rejection streak on a conclusive acceptance', async () => {
|
||||
const { watch, clock, onRetire } = createWatch({
|
||||
outcomes: [ACCEPTED, REJECTED, REJECTED, ACCEPTED, REJECTED, REJECTED]
|
||||
})
|
||||
watch.start()
|
||||
await drainMicrotasks()
|
||||
await clock.advance(120_000) // rejection 1
|
||||
await clock.advance(10_000) // rejection 2
|
||||
await clock.advance(10_000) // acceptance → reset
|
||||
await clock.advance(120_000) // rejection 1
|
||||
await clock.advance(10_000) // rejection 2
|
||||
expect(onRetire).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the rejection streak across interleaved inconclusive probes', async () => {
|
||||
const { watch, clock, onRetire } = createWatch({
|
||||
outcomes: [ACCEPTED, REJECTED, INCONCLUSIVE, REJECTED, INCONCLUSIVE, REJECTED]
|
||||
})
|
||||
watch.start()
|
||||
await drainMicrotasks()
|
||||
await clock.advance(120_000) // rejection 1
|
||||
await clock.advance(10_000) // inconclusive timeout — rejection streak holds
|
||||
await clock.advance(120_000) // rejection 2
|
||||
await clock.advance(10_000) // inconclusive
|
||||
await clock.advance(120_000) // rejection 3 → retire
|
||||
expect(onRetire).toHaveBeenCalledTimes(1)
|
||||
expect(onRetire.mock.calls[0][0].rejections).toBe(3)
|
||||
expect(onRetire.mock.calls[0][0].cause).toBe('pam-rejections')
|
||||
})
|
||||
|
||||
it('keeps repeated inconclusive timeouts on the bounded periodic cadence', async () => {
|
||||
const readResolverHealth = vi.fn(async () => 'unhealthy' as const)
|
||||
const { watch, clock, onRetire, probe } = createWatch({
|
||||
outcomes: [ACCEPTED, ...Array.from({ length: 10 }, () => INCONCLUSIVE)],
|
||||
readResolverHealth
|
||||
})
|
||||
watch.start()
|
||||
await drainMicrotasks()
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await clock.advance(120_000)
|
||||
}
|
||||
expect(probe).toHaveBeenCalledTimes(11)
|
||||
expect(readResolverHealth).not.toHaveBeenCalled()
|
||||
expect(onRetire).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['healthy', 'unknown'] as const)(
|
||||
'suppresses retirement while resolver health is %s, then retires on explicit degradation',
|
||||
async (initialResolverHealth) => {
|
||||
const { watch, clock, onRetire, probe, setResolverHealth } = createWatch({
|
||||
outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED, REJECTED]
|
||||
})
|
||||
setResolverHealth(initialResolverHealth)
|
||||
watch.start()
|
||||
await drainMicrotasks()
|
||||
await clock.advance(120_000)
|
||||
await clock.advance(10_000)
|
||||
await clock.advance(10_000) // threshold reached but resolver did not corroborate death
|
||||
expect(onRetire).not.toHaveBeenCalled()
|
||||
const probesAtSuppression = probe.mock.calls.length
|
||||
setResolverHealth('unhealthy')
|
||||
await clock.advance(10_000)
|
||||
expect(probe).toHaveBeenCalledTimes(probesAtSuppression)
|
||||
await clock.advance(110_000) // suppressed states return to the bounded periodic cadence
|
||||
expect(onRetire).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
)
|
||||
|
||||
it('debounces a sustained PTY-exit burst into one trailing probe', async () => {
|
||||
const { watch, clock, probe } = createWatch({ outcomes: [ACCEPTED, ACCEPTED] })
|
||||
watch.start()
|
||||
await drainMicrotasks()
|
||||
await clock.advance(60_000)
|
||||
const before = probe.mock.calls.length
|
||||
watch.notifyPtyExit()
|
||||
await clock.advance(1_500)
|
||||
watch.notifyPtyExit()
|
||||
await clock.advance(1_500)
|
||||
watch.notifyPtyExit()
|
||||
await clock.advance(1_999)
|
||||
expect(probe).toHaveBeenCalledTimes(before)
|
||||
await clock.advance(1)
|
||||
expect(probe.mock.calls.length).toBe(before + 1)
|
||||
})
|
||||
|
||||
it('probes on client activity only after the min gap', async () => {
|
||||
const { watch, clock, probe } = createWatch({
|
||||
outcomes: [ACCEPTED, ACCEPTED, ACCEPTED]
|
||||
})
|
||||
watch.start()
|
||||
await drainMicrotasks()
|
||||
const after = probe.mock.calls.length
|
||||
watch.notifyClientActivity() // too soon after startup probe, so retain one deferred probe
|
||||
await clock.advance(29_999)
|
||||
expect(probe.mock.calls.length).toBe(after)
|
||||
await clock.advance(1)
|
||||
expect(probe.mock.calls.length).toBe(after + 1)
|
||||
})
|
||||
|
||||
it('defers a PTY-exit trigger that lands inside the global probe gap', async () => {
|
||||
const { watch, clock, probe } = createWatch({ outcomes: [ACCEPTED, ACCEPTED] })
|
||||
watch.start()
|
||||
await drainMicrotasks()
|
||||
|
||||
watch.notifyPtyExit()
|
||||
await clock.advance(4_999)
|
||||
expect(probe).toHaveBeenCalledOnce()
|
||||
await clock.advance(1)
|
||||
expect(probe).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('retains one follow-up when a logout signal arrives during a probe', async () => {
|
||||
let resolveStartup!: (outcome: LoginPreflightOutcome) => void
|
||||
const startup = new Promise<LoginPreflightOutcome>((resolve) => {
|
||||
resolveStartup = resolve
|
||||
})
|
||||
const probe = vi
|
||||
.fn<MacosLoginSessionDeathWatchOptions['probeLoginSession']>()
|
||||
.mockReturnValueOnce(startup)
|
||||
.mockResolvedValue(ACCEPTED)
|
||||
const { watch, clock } = createWatch({ probeLoginSession: probe })
|
||||
watch.start()
|
||||
watch.notifyPtyExit()
|
||||
await clock.advance(2_000)
|
||||
expect(probe).toHaveBeenCalledOnce()
|
||||
|
||||
resolveStartup(ACCEPTED)
|
||||
await drainMicrotasks()
|
||||
await clock.advance(2_999)
|
||||
expect(probe).toHaveBeenCalledOnce()
|
||||
await clock.advance(1)
|
||||
expect(probe).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('retains client activity that arrives during an armed periodic probe', async () => {
|
||||
let resolvePeriodic!: (outcome: LoginPreflightOutcome) => void
|
||||
const periodic = new Promise<LoginPreflightOutcome>((resolve) => {
|
||||
resolvePeriodic = resolve
|
||||
})
|
||||
const probe = vi
|
||||
.fn<MacosLoginSessionDeathWatchOptions['probeLoginSession']>()
|
||||
.mockResolvedValueOnce(ACCEPTED)
|
||||
.mockReturnValueOnce(periodic)
|
||||
.mockResolvedValue(ACCEPTED)
|
||||
const { watch, clock } = createWatch({ probeLoginSession: probe })
|
||||
watch.start()
|
||||
await drainMicrotasks()
|
||||
await clock.advance(120_000)
|
||||
|
||||
watch.notifyClientActivity()
|
||||
resolvePeriodic(ACCEPTED)
|
||||
await drainMicrotasks()
|
||||
await clock.advance(29_999)
|
||||
expect(probe).toHaveBeenCalledTimes(2)
|
||||
await clock.advance(1)
|
||||
expect(probe).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('disables itself when the wrapper machinery does not apply', async () => {
|
||||
const { watch, clock, probe } = createWatch({ outcomes: [null] })
|
||||
watch.start()
|
||||
await drainMicrotasks()
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
await clock.advance(600_000)
|
||||
watch.notifyPtyExit()
|
||||
watch.notifyClientActivity()
|
||||
await clock.advance(600_000)
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stop() cancels all pending timers', async () => {
|
||||
const { watch, clock } = createWatch({ outcomes: [ACCEPTED] })
|
||||
watch.start()
|
||||
await drainMicrotasks()
|
||||
watch.notifyPtyExit()
|
||||
watch.stop()
|
||||
expect(clock.pendingCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('stop() aborts an in-flight subprocess probe', async () => {
|
||||
let probeSignal: AbortSignal | undefined
|
||||
const probe = vi.fn((signal?: AbortSignal) => {
|
||||
probeSignal = signal
|
||||
return new Promise<LoginPreflightOutcome>(() => {})
|
||||
})
|
||||
const { watch } = createWatch({ probeLoginSession: probe })
|
||||
watch.start()
|
||||
expect(probeSignal?.aborted).toBe(false)
|
||||
|
||||
watch.stop()
|
||||
|
||||
expect(probeSignal?.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('stop() prevents an in-flight resolver check from retiring the daemon', async () => {
|
||||
let resolveHealth!: (health: SystemResolverHealth) => void
|
||||
let resolverSignal: AbortSignal | undefined
|
||||
const resolverHealth = new Promise<SystemResolverHealth>((resolve) => {
|
||||
resolveHealth = resolve
|
||||
})
|
||||
const { watch, clock, onRetire } = createWatch({
|
||||
outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED],
|
||||
readResolverHealth: (signal) => {
|
||||
resolverSignal = signal
|
||||
return resolverHealth
|
||||
}
|
||||
})
|
||||
watch.start()
|
||||
await drainMicrotasks()
|
||||
await clock.advance(120_000)
|
||||
await clock.advance(10_000)
|
||||
await clock.advance(10_000) // retirement is now waiting on resolver health
|
||||
|
||||
watch.stop()
|
||||
expect(resolverSignal?.aborted).toBe(true)
|
||||
resolveHealth('unhealthy')
|
||||
await drainMicrotasks()
|
||||
|
||||
expect(onRetire).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('logs and reschedules when a probe throws instead of surfacing a rejection', async () => {
|
||||
const outcomes: (() => Promise<LoginPreflightOutcome>)[] = [
|
||||
async () => ACCEPTED,
|
||||
async () => {
|
||||
throw new Error('launchctl exploded')
|
||||
},
|
||||
async () => ACCEPTED
|
||||
]
|
||||
const probe = vi.fn(() => (outcomes.shift() ?? (async () => ACCEPTED))())
|
||||
const { watch, clock, onRetire } = createWatch({ probeLoginSession: probe })
|
||||
watch.start()
|
||||
await drainMicrotasks()
|
||||
await clock.advance(120_000) // throwing probe
|
||||
await clock.advance(120_000) // rescheduled probe still runs
|
||||
expect(probe).toHaveBeenCalledTimes(3)
|
||||
expect(onRetire).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user