Merge remote-tracking branch 'origin/main' into brennanb2025/agent-row-conversation-name

This commit is contained in:
Brennan Benson
2026-07-22 21:27:46 -07:00
573 changed files with 36608 additions and 5086 deletions
+1
View File
@@ -92,6 +92,7 @@ docs/**
!docs/reference/
!docs/reference/git-compatibility.md
!docs/reference/headless-linux-server.md
!docs/reference/linux-glibc-compatibility.md
# Stably CLI (only docs/ are tracked)
.stably/*
+1
View File
@@ -27,6 +27,7 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh
- **Keyboard shortcuts**: Never hardcode `e.metaKey`. Use a platform check (`navigator.userAgent.includes('Mac')`) to pick `metaKey` on Mac and `ctrlKey` on Linux/Windows. Electron menu accelerators should use `CmdOrCtrl`.
- **Shortcut labels in UI**: Display `⌘` / `⇧` on Mac and `Ctrl+` / `Shift+` on other platforms.
- **File paths**: Use `path.join` or Electron/Node path utilities — never assume `/` or `\`.
- **Linux native modules**: keep the glibc floor at Ubuntu 20.04 / glibc 2.31. A module compiled from source on a newer runner can reference symbol versions absent on the floor and crash the app on startup. See [`docs/reference/linux-glibc-compatibility.md`](./docs/reference/linux-glibc-compatibility.md); packaging fails if a bundled native binary needs newer glibc.
## SSH Use Case
+7
View File
@@ -11,6 +11,7 @@ const {
prunePackagedRuntimeNodeModules,
verifyPackagedMainRuntimeDeps
} = require('./packaged-runtime-node-modules.cjs')
const { verifyLinuxGlibcFloor } = require('./scripts/verify-linux-glibc-floor.cjs')
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1'
const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1'
@@ -133,6 +134,12 @@ module.exports = {
'node_modules/sherpa-onnx*/**'
],
afterPack: async (context) => {
// Why: a Linux runner-image glibc bump silently shipped a node-pty pty.node
// requiring GLIBC_2.34, crashing the app on startup on Ubuntu 20.04 (#9902).
// Fail packaging if any bundled native binary exceeds the supported floor.
if (context.electronPlatformName === 'linux') {
verifyLinuxGlibcFloor(context.appOutDir)
}
const resourcesDir =
context.electronPlatformName === 'darwin'
? join(
+80 -35
View File
@@ -1,5 +1,5 @@
diff --git a/binding.gyp b/binding.gyp
index 5f63978b07ab50aaf7523219a2170ec737a6b5db..b3309a07ef99dea7967d7bdd04b9fc3500acacae 100644
index 5f63978b07ab50aaf7523219a2170ec737a6b5db..bbd9e06136e8922f40b5779e35d4fc835f1479ab 100644
--- a/binding.gyp
+++ b/binding.gyp
@@ -5,9 +5,6 @@
@@ -12,6 +12,23 @@ index 5f63978b07ab50aaf7523219a2170ec737a6b5db..b3309a07ef99dea7967d7bdd04b9fc35
'msvs_settings': {
'VCCLCompilerTool': {
'AdditionalOptions': [
@@ -88,6 +85,16 @@
'libraries!': [
'-lutil'
]
+ }],
+ # Orca: pair with the .symver pins in pty.cc. Force the real
+ # libutil.so.1/libpthread.so.0 into DT_NEEDED (gcc's default
+ # --as-needed drops them because the pinned symbols resolve from
+ # libc's compat aliases at build time) so openpty/forkpty/
+ # pthread_sigmask still resolve on Ubuntu 20.04 (glibc 2.31).
+ ['OS=="linux"', {
+ 'ldflags': [
+ '-Wl,--no-as-needed,-l:libutil.so.1,-l:libpthread.so.0,--as-needed'
+ ]
}]
]
}
diff --git a/deps/winpty/src/winpty.gyp b/deps/winpty/src/winpty.gyp
index 1ac5758bedd8cf54f32280dea4e4aeb5afdee30d..e619813759c6f14694838bdfbd0ea5f8360130ef 100644
--- a/deps/winpty/src/winpty.gyp
@@ -54,6 +71,27 @@ index 1ac5758bedd8cf54f32280dea4e4aeb5afdee30d..e619813759c6f14694838bdfbd0ea5f8
'msvs_settings': {
# Specify this setting here to override a setting from somewhere
# else, such as node's common.gypi.
diff --git a/lib/conpty_console_list_agent.js b/lib/conpty_console_list_agent.js
index 8c4fca9022a6d6f015bca87f61625cde2278f428..0a01730616488119aa21ef441cf3c441e02a974c 100644
--- a/lib/conpty_console_list_agent.js
+++ b/lib/conpty_console_list_agent.js
@@ -10,7 +10,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
var utils_1 = require("./utils");
var getConsoleProcessList = utils_1.loadNativeModule('conpty_console_list').module.getConsoleProcessList;
var shellPid = parseInt(process.argv[2], 10);
-var consoleProcessList = getConsoleProcessList(shellPid);
+var consoleProcessList;
+try {
+ consoleProcessList = getConsoleProcessList(shellPid);
+}
+catch (_a) {
+ // Why: AttachConsole can fail after the shell exits; parent already has this fallback.
+ consoleProcessList = [shellPid];
+}
process.send({ consoleProcessList: consoleProcessList });
process.exit(0);
//# sourceMappingURL=conpty_console_list_agent.js.map
\ No newline at end of file
diff --git a/lib/unixTerminal.js b/lib/unixTerminal.js
index 1ec12f796a822c78fba9ad7f6448c3987e325c23..cec8b67aef02f8199e5606a0d257088bf1865877 100644
--- a/lib/unixTerminal.js
@@ -73,31 +111,12 @@ index 1ec12f796a822c78fba9ad7f6448c3987e325c23..cec8b67aef02f8199e5606a0d257088b
var DEFAULT_FILE = 'sh';
var DEFAULT_NAME = 'xterm';
var DESTROY_SOCKET_TIMEOUT_MS = 200;
diff --git a/lib/conpty_console_list_agent.js b/lib/conpty_console_list_agent.js
index ccc111c9e03a4a661ccfd5d8e8f0ee699571b5dd..f92c6bef7d46dc35c941c87ef186aa46d8ed9c44 100644
--- a/lib/conpty_console_list_agent.js
+++ b/lib/conpty_console_list_agent.js
@@ -9,7 +9,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
var utils_1 = require("./utils");
var getConsoleProcessList = utils_1.loadNativeModule('conpty_console_list').module.getConsoleProcessList;
var shellPid = parseInt(process.argv[2], 10);
-var consoleProcessList = getConsoleProcessList(shellPid);
+var consoleProcessList;
+try {
+ consoleProcessList = getConsoleProcessList(shellPid);
+}
+catch (_a) {
+ // Why: AttachConsole can fail after the shell exits; parent already has this fallback.
+ consoleProcessList = [shellPid];
+}
process.send({ consoleProcessList: consoleProcessList });
process.exit(0);
//# sourceMappingURL=conpty_console_list_agent.js.map
diff --git a/src/conpty_console_list_agent.ts b/src/conpty_console_list_agent.ts
index f6a653893e0b9b548c514db29d75599538ee1acb..1d5400489f200ef0161ca687e672e1cc02d29c95 100644
index 181ccabbbe9c4948a9725fb1db907a68e9de01fc..67f31facf85562b67adbfbd04ce28ddd8eeb4a79 100644
--- a/src/conpty_console_list_agent.ts
+++ b/src/conpty_console_list_agent.ts
@@ -11,5 +11,11 @@ import { loadNativeModule } from './utils';
@@ -10,6 +10,12 @@ import { loadNativeModule } from './utils';
const getConsoleProcessList = loadNativeModule('conpty_console_list').module.getConsoleProcessList;
const shellPid = parseInt(process.argv[2], 10);
-const consoleProcessList = getConsoleProcessList(shellPid);
@@ -111,7 +130,7 @@ index f6a653893e0b9b548c514db29d75599538ee1acb..1d5400489f200ef0161ca687e672e1cc
process.send!({ consoleProcessList });
process.exit(0);
diff --git a/src/unix/pty.cc b/src/unix/pty.cc
index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca850564e6368d9 100644
index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d15c4dd44 100644
--- a/src/unix/pty.cc
+++ b/src/unix/pty.cc
@@ -23,7 +23,9 @@
@@ -124,7 +143,33 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
#include <thread>
#include <sys/types.h>
@@ -237,13 +239,23 @@ pty_getproc(int, char *);
@@ -47,6 +49,25 @@
#include <termios.h>
#endif
+/* Orca: glibc 2.32-2.34 relocated pthread_sigmask/openpty/forkpty into libc
+ * under new symbol versions, so building on a newer glibc produces references
+ * (GLIBC_2.32/2.34) absent on Ubuntu 20.04 (glibc 2.31) and the app fails to
+ * launch. Pin these to the pre-merge version glibc still ships as a compat
+ * alias; the binding.gyp ldflags force libutil/libpthread into DT_NEEDED so
+ * those aliases are actually loaded on the target. */
+#if defined(__linux__)
+# if defined(__x86_64__)
+# define ORCA_GLIBC_COMPAT_VERSION "GLIBC_2.2.5"
+# elif defined(__aarch64__)
+# define ORCA_GLIBC_COMPAT_VERSION "GLIBC_2.17"
+# endif
+# ifdef ORCA_GLIBC_COMPAT_VERSION
+__asm__(".symver openpty,openpty@" ORCA_GLIBC_COMPAT_VERSION);
+__asm__(".symver forkpty,forkpty@" ORCA_GLIBC_COMPAT_VERSION);
+__asm__(".symver pthread_sigmask,pthread_sigmask@" ORCA_GLIBC_COMPAT_VERSION);
+# endif
+#endif
+
/* Some platforms name VWERASE and VDISCARD differently */
#if !defined(VWERASE) && defined(VWERSE)
#define VWERASE VWERSE
@@ -237,13 +258,23 @@ pty_getproc(int, char *);
#endif
#if defined(__APPLE__) || defined(__OpenBSD__)
@@ -149,7 +194,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
#endif
struct DelBuf {
@@ -367,10 +379,11 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) {
@@ -367,10 +398,11 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) {
argv[i + 3] = strdup(arg.c_str());
}
@@ -165,7 +210,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
}
if (pty_nonblock(master) == -1) {
throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking.");
@@ -684,15 +697,73 @@ pty_getproc(int fd, char *tty) {
@@ -684,15 +716,73 @@ pty_getproc(int fd, char *tty) {
#endif
#if defined(__APPLE__)
@@ -241,25 +286,25 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
for (; count < 3; count++) {
low_fds[count] = posix_openpt(O_RDWR);
@@ -706,80 +777,118 @@ pty_posix_spawn(char** argv, char** env,
@@ -706,80 +796,118 @@ pty_posix_spawn(char** argv, char** env,
POSIX_SPAWN_SETSID;
*master = posix_openpt(O_RDWR);
if (*master == -1) {
- return;
+ pty_set_spawn_error(err, "posix_openpt", errno);
+ goto done;
+ }
+
+ res = grantpt(*master);
+ if (res == -1) {
+ pty_set_spawn_error(err, "grantpt", errno);
+ goto done;
}
- int res = grantpt(*master) || unlockpt(*master);
+ res = unlockpt(*master);
+ res = grantpt(*master);
if (res == -1) {
- return;
+ pty_set_spawn_error(err, "grantpt", errno);
+ goto done;
+ }
+
+ res = unlockpt(*master);
+ if (res == -1) {
+ pty_set_spawn_error(err, "unlockpt", errno);
+ goto done;
}
@@ -194,8 +194,21 @@ async function validateAuthenticatedPairing() {
status?._meta?.runtimeId === payload.runtimeId,
'paired client runtime ID does not match ready contract'
)
assert(
typeof statusResult?.runtime?.appVersion === 'string',
'paired server did not report its Orca app version'
)
assert(
statusResult?.runtime?.capabilities?.includes('updater.remote-control.v1'),
'paired server did not advertise remote updater capability'
)
assert(
statusResult?.runtime?.remoteUpdateSupport?.automatic === false &&
statusResult.runtime.remoteUpdateSupport.reason === 'manual-service-update-required',
'direct headless server did not require a safe manual service update'
)
stopContainer(server.name)
console.log('PASS authenticated E2EE pairing from an independent Debian container')
console.log('PASS paired E2EE updater inventory and manual-service fallback')
}
async function validateUnreachableOffer() {
@@ -0,0 +1,32 @@
import { spawnSync } from 'node:child_process'
const result = spawnSync(
process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
[
'exec',
'playwright',
'test',
'tests/e2e/nested-runtime-ssh-routing.spec.ts',
'tests/e2e/nested-runtime-ssh-lifecycle.spec.ts',
'--config',
'tests/playwright.config.ts',
'--project',
'electron-headless',
'--workers=1'
],
{
cwd: process.cwd(),
env: {
...process.env,
ORCA_E2E_NESTED_RUNTIME_SSH: '1',
ORCA_E2E_SSH_DOCKER: '1',
ORCA_E2E_WEB_CLIENT: '1'
},
stdio: 'inherit'
}
)
if (result.error) {
throw result.error
}
process.exit(result.status ?? 1)
+394
View File
@@ -0,0 +1,394 @@
const { readdirSync, openSync, readSync, closeSync } = require('node:fs')
const { spawnSync } = require('node:child_process')
const { join, relative } = require('node:path')
// Why: v1.4.150 shipped a Linux build whose node-pty pty.node required
// GLIBC_2.34 (openpty/forkpty were relocated into libc by glibc's
// libutil/libpthread merge), so the app crashed on startup on Ubuntu 20.04
// (glibc 2.31) — the runner image silently bumped the build-host glibc. This
// gate fails Linux packaging if any bundled native binary requires a glibc (or
// libstdc++) symbol version newer than stock Ubuntu 20.04 ships, so a future
// runner bump or dependency change cannot reintroduce the regression unnoticed.
// See docs/reference/linux-glibc-compatibility.md.
const MIN_GLIBC = Object.freeze([2, 31])
// The symbol-version families this gate checks, each with the highest version
// node stock Ubuntu 20.04 provides. glibc is the #9902 launch-crash axis;
// libstdc++ (GLIBCXX_/CXXABI_) is the same crash class for C++ native modules
// against the system libstdc++ (Orca does not bundle one).
const VERSION_FLOORS = Object.freeze([
Object.freeze({ prefix: 'GLIBC_', floor: MIN_GLIBC }),
Object.freeze({ prefix: 'GLIBCXX_', floor: Object.freeze([3, 4, 28]) }),
Object.freeze({ prefix: 'CXXABI_', floor: Object.freeze([1, 3, 12]) })
])
const FLOOR_LABEL = 'Ubuntu 20.04 (glibc 2.31 / libstdc++ GLIBCXX_3.4.28)'
// Why: the sherpa-onnx speech prebuilt is a third-party manylinux binary that
// already requires GLIBCXX_3.4.29 (GCC 11 / Ubuntu 21.10+, 22.04 LTS). It loads
// lazily in the speech worker (src/main/speech/stt-worker.ts), never at app
// launch, so it cannot cause the #9902 startup crash. Exempt it from the
// libstdc++ floor (its glibc is still gated) rather than fail the release on a
// pre-existing, non-launch condition — speech needs libstdc++ >= GCC 11.
const LIBSTDCXX_FLOOR_EXEMPT = /(?:^|[/\\])sherpa-onnx/
// VER_FLG_WEAK: a version need whose references are all weak. The loader
// tolerates its absence (resolves to null and the caller's fallback runs)
// instead of refusing to load, so a weak need must not count as a requirement.
const VER_FLG_WEAK = 0x2
/** Parse a "2.34" / "3.4.28" version string into a numeric tuple. */
function parseGlibcVersion(versionStr) {
return versionStr.split('.').map((part) => Number.parseInt(part, 10))
}
/** Compare two numeric version tuples; missing trailing parts are 0. */
function compareGlibcVersions(a, b) {
const length = Math.max(a.length, b.length)
for (let i = 0; i < length; i += 1) {
const diff = (a[i] ?? 0) - (b[i] ?? 0)
if (diff !== 0) {
return diff < 0 ? -1 : 1
}
}
return 0
}
/**
* Parse `objdump -p` "Version References" (the ELF `.gnu.version_r` section)
* into the version nodes this binary requires from each shared library. This is
* the authoritative load-time requirement list: unlike the dynamic symbol table
* (`objdump -T`), it also captures symbol-less ABI markers such as
* `GLIBC_ABI_DT_RELR` (packed relative relocations, glibc 2.36+) that still
* block loading on an older glibc. Each entry: `0xHASH 0xFLAGS <n> <NAME>`.
*/
function parseVersionNeeds(objdumpOutput) {
const needs = []
let library = null
let inSection = false
for (const line of objdumpOutput.split('\n')) {
if (line.startsWith('Version References:')) {
inSection = true
continue
}
if (!inSection) {
continue
}
// Any new non-indented line ends the Version References block.
if (!/^\s/.test(line)) {
inSection = false
continue
}
const libraryMatch = line.match(/^\s+required from (\S+):/)
if (libraryMatch) {
library = libraryMatch[1]
continue
}
const entryMatch = line.match(/^\s+0x[0-9a-fA-F]+\s+0x([0-9a-fA-F]+)\s+\d+\s+(\S+)/)
if (entryMatch) {
const flags = Number.parseInt(entryMatch[1], 16)
needs.push({ library, name: entryMatch[2], weak: (flags & VER_FLG_WEAK) !== 0 })
}
}
return needs
}
/**
* Whether a version node is newer than the floor Ubuntu 20.04 provides. Numeric
* nodes (`GLIBC_2.34`, `GLIBCXX_3.4.29`) compare by version. Any non-numeric
* glibc node is rejected: `GLIBC_ABI_DT_RELR` is a 2.36+ marker, and
* `GLIBC_PRIVATE` is not a stable ABI contract — its symbols differ across
* glibc releases, so a binary needing one can fail to load on the floor even
* though the version node itself exists (a well-formed addon needs neither).
* Named libstdc++ nodes (`CXXABI_TM_1`, `GLIBCXX_LDBL_*`) ship on 20.04.
* Families we do not gate (`GCC_`, `NSS_`) return false.
*/
function isVersionNodeAboveFloor(name) {
for (const { prefix, floor } of VERSION_FLOORS) {
if (!name.startsWith(prefix)) {
continue
}
const rest = name.slice(prefix.length)
if (/^[0-9]+(?:\.[0-9]+)*$/.test(rest)) {
return compareGlibcVersions(parseGlibcVersion(rest), floor) > 0
}
// Non-numeric suffix: reject every glibc node (ABI markers and PRIVATE).
return prefix === 'GLIBC_'
}
return false
}
function isLibstdcxxNode(name) {
return name.startsWith('GLIBCXX_') || name.startsWith('CXXABI_')
}
/**
* Version needs from `filePath` that would prevent loading on the floor OS.
* `sherpa-onnx` is exempt from the libstdc++ floor (see LIBSTDCXX_FLOOR_EXEMPT)
* but its glibc needs are still checked.
*/
function findFloorViolations(needs, filePath = '') {
const exemptLibstdcxx = LIBSTDCXX_FLOOR_EXEMPT.test(filePath)
return needs.filter(
(need) =>
!need.weak &&
isVersionNodeAboveFloor(need.name) &&
!(exemptLibstdcxx && isLibstdcxxNode(need.name))
)
}
// On stock Ubuntu 20.04 (glibc 2.31) these symbols live ONLY in these DSOs —
// glibc kept openpty/forkpty in libutil until the 2.34 merge. A binary that
// imports them must keep the DSO in DT_NEEDED or they will not resolve on the
// floor. This guards config/patches/node-pty@1.1.0.patch's forced
// `-l:libutil.so.1`: if a toolchain change ever dropped that ldflag, the pinned
// openpty@GLIBC_2.2.5 would still resolve from libc's compat alias at build time
// (so the version-floor check passes) yet fail to load on 20.04. libpthread
// (pthread_sigmask) is intentionally omitted — the Node/Electron host always
// loads it, so it resolves regardless of this addon's DT_NEEDED.
const RELOCATED_SYMBOL_PROVIDERS = Object.freeze({
openpty: 'libutil.so.1',
forkpty: 'libutil.so.1'
})
/**
* Relocated symbols the binary imports whose providing DSO is absent from
* DT_NEEDED — meaning they resolve at build time but not on the floor OS.
*/
function findMissingProviderDeps(importedSymbols, neededLibraries) {
const missing = []
for (const [symbol, library] of Object.entries(RELOCATED_SYMBOL_PROVIDERS)) {
if (importedSymbols.has(symbol) && !neededLibraries.has(library)) {
missing.push({ symbol, library })
}
}
return missing
}
function isElfFile(filePath) {
let fd
try {
fd = openSync(filePath, 'r')
const header = Buffer.alloc(4)
const bytesRead = readSync(fd, header, 0, 4, 0)
return bytesRead === 4 && header[0] === 0x7f && header.toString('latin1', 1, 4) === 'ELF'
} catch {
return false
} finally {
if (fd !== undefined) {
closeSync(fd)
}
}
}
/** Recursively collect ELF native binaries (`.node`, `.so[.N]`, executables). */
function collectNativeBinaries(rootDir) {
const binaries = []
const walk = (dir) => {
let entries
try {
entries = readdirSync(dir, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
const fullPath = join(dir, entry.name)
if (entry.isSymbolicLink()) {
continue
}
if (entry.isDirectory()) {
walk(fullPath)
continue
}
if (!entry.isFile()) {
continue
}
// Why: .node/.so are always native; extensionless files (the Electron
// executable, chrome-sandbox) are checked via the ELF magic so we cover
// every launch-critical binary without objdump-ing app.asar or assets.
const looksNative = entry.name.endsWith('.node') || /\.so(\.\d+)*$/.test(entry.name)
if (looksNative || !entry.name.includes('.')) {
if (isElfFile(fullPath)) {
binaries.push(fullPath)
}
}
}
}
walk(rootDir)
return binaries.sort()
}
function resolveObjdump(explicitPath) {
const candidates = [explicitPath, 'objdump', 'llvm-objdump'].filter(Boolean)
for (const candidate of candidates) {
const probe = spawnSync(candidate, ['--version'], { encoding: 'utf8', env: cLocaleEnv() })
if (!probe.error && probe.status === 0) {
return candidate
}
}
return null
}
// Why: GNU objdump localizes its section headers ("Version References:") via
// gettext, and the parser anchors on the English text. Force the C locale so
// output stays deterministic on non-English packaging hosts (LC_ALL=C also
// disables LANGUAGE-based message translation).
function cLocaleEnv() {
return { ...process.env, LC_ALL: 'C', LANG: 'C' }
}
/**
* Run objdump with one flag on `filePath`. Fail-closed: a spawn error, non-zero
* exit, or signal throws, because a silently-unreadable binary (truncated,
* corrupt, or an objdump that cannot decode its format) would let a too-new
* binary slip past the gate.
*/
function runObjdump(objdumpPath, flag, filePath) {
const result = spawnSync(objdumpPath, [flag, filePath], {
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024,
env: cLocaleEnv()
})
if (result.error) {
throw new Error(
`[verify-linux-glibc-floor] could not run objdump on ${filePath}: ${result.error.message}`
)
}
if (result.signal || result.status !== 0) {
throw new Error(
`[verify-linux-glibc-floor] objdump ${flag} failed for ${filePath} ` +
`(status ${result.status}, signal ${result.signal ?? 'none'}): ${(result.stderr || '').trim()}`
)
}
return result.stdout || ''
}
/** DT_NEEDED shared-library names from `objdump -p` (` NEEDED <lib>`). */
function parseNeededLibraries(objdumpOutput) {
const needed = new Set()
for (const line of objdumpOutput.split('\n')) {
const match = line.match(/^\s+NEEDED\s+(\S+)/)
if (match) {
needed.add(match[1])
}
}
return needed
}
/** Undefined (imported) dynamic symbol base names from `objdump -T` (`*UND*`). */
function parseImportedSymbols(objdumpOutput) {
const imported = new Set()
for (const line of objdumpOutput.split('\n')) {
if (!line.includes('*UND*')) {
continue
}
// The symbol name is the final token; strip any @VERSION suffix.
const token = line.trim().split(/\s+/).pop()
if (token) {
imported.add(token.split('@')[0])
}
}
return imported
}
/** Version needs + DT_NEEDED from a single `objdump -p` (fail-closed). */
function readDynamicInfo(filePath, objdumpPath) {
const output = runObjdump(objdumpPath, '-p', filePath)
return {
versionNeeds: parseVersionNeeds(output),
neededLibraries: parseNeededLibraries(output)
}
}
/** Imported (undefined) dynamic symbols from `objdump -T` (fail-closed). */
function readImportedSymbols(filePath, objdumpPath) {
return parseImportedSymbols(runObjdump(objdumpPath, '-T', filePath))
}
/**
* Fail Linux packaging if any bundled native binary under `rootDir` requires a
* glibc/libstdc++ symbol version newer than the floor OS. No-op is not allowed
* on Linux: a missing objdump throws, because a silent skip would defeat the
* regression gate on exactly the host where it matters.
*/
function verifyLinuxGlibcFloor(rootDir, options = {}) {
const binaries = collectNativeBinaries(rootDir)
if (binaries.length === 0) {
console.log(`[verify-linux-glibc-floor] OK — no bundled native binaries under ${rootDir}`)
return
}
// Why: resolve objdump only once there is something to inspect, so a fixture
// with no ELF binaries does not fail on a host that lacks binutils.
const objdumpPath = resolveObjdump(options.objdumpPath)
if (!objdumpPath) {
throw new Error(
'[verify-linux-glibc-floor] objdump not found. Install binutils on the Linux ' +
'packaging host so the glibc-floor gate can inspect bundled native binaries.'
)
}
const offenders = []
for (const filePath of binaries) {
const { versionNeeds, neededLibraries } = readDynamicInfo(filePath, objdumpPath)
const floorViolations = findFloorViolations(versionNeeds, filePath)
// Only pay for `objdump -T` when a relocated-symbol provider is not already
// in DT_NEEDED (the common, healthy case short-circuits without it).
const providerViolations = Object.values(RELOCATED_SYMBOL_PROVIDERS).some(
(library) => !neededLibraries.has(library)
)
? findMissingProviderDeps(readImportedSymbols(filePath, objdumpPath), neededLibraries)
: []
if (floorViolations.length > 0 || providerViolations.length > 0) {
offenders.push({ filePath, floorViolations, providerViolations })
}
}
if (offenders.length > 0) {
const detail = offenders
.map(({ filePath, floorViolations, providerViolations }) => {
const reasons = []
if (floorViolations.length > 0) {
const nodes = [...new Set(floorViolations.map((v) => v.name))].sort()
const libraries = [...new Set(floorViolations.map((v) => v.library).filter(Boolean))]
reasons.push(
`needs ${nodes.join(', ')}${libraries.length > 0 ? ` (from ${libraries.join(', ')})` : ''}`
)
}
for (const { symbol, library } of providerViolations) {
reasons.push(`imports ${symbol} but ${library} is not in DT_NEEDED`)
}
return ` ${relative(rootDir, filePath) || filePath} ${reasons.join('; ')}`
})
.join('\n')
throw new Error(
`[verify-linux-glibc-floor] ${offenders.length} bundled native binar${offenders.length === 1 ? 'y' : 'ies'} ` +
`will not load on ${FLOOR_LABEL}, so the app will crash on startup there:\n${detail}\n` +
'See docs/reference/linux-glibc-compatibility.md — rebuild the offending module against an older ' +
'toolchain or pin the relocated symbols (as config/patches/node-pty@1.1.0.patch does).'
)
}
console.log(
`[verify-linux-glibc-floor] OK — ${binaries.length} bundled native binaries all load on ${FLOOR_LABEL}`
)
}
module.exports = {
MIN_GLIBC,
VERSION_FLOORS,
FLOOR_LABEL,
RELOCATED_SYMBOL_PROVIDERS,
parseGlibcVersion,
compareGlibcVersions,
parseVersionNeeds,
parseNeededLibraries,
parseImportedSymbols,
isVersionNodeAboveFloor,
isLibstdcxxNode,
findFloorViolations,
findMissingProviderDeps,
collectNativeBinaries,
readDynamicInfo,
readImportedSymbols,
verifyLinuxGlibcFloor
}
@@ -0,0 +1,323 @@
import { mkdtemp, mkdir, writeFile, symlink, rm } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const {
parseGlibcVersion,
compareGlibcVersions,
parseVersionNeeds,
parseNeededLibraries,
parseImportedSymbols,
isVersionNodeAboveFloor,
findFloorViolations,
findMissingProviderDeps,
collectNativeBinaries,
verifyLinuxGlibcFloor
} = require('./verify-linux-glibc-floor.cjs')
// 0x7f 'E' 'L' 'F' + class/data/version padding — enough for the magic check.
const ELF_HEADER = Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01, 0x01, 0x00])
// Real `objdump -p` "Version References" shape (entry: 0xHASH 0xFLAGS <n> NAME;
// flags 0x02 = VER_FLG_WEAK). Includes a symbol-less ABI marker, a weak need,
// and a libstdc++ need.
const OBJDUMP_P = [
'Dynamic Section:',
' NEEDED libc.so.6',
'',
'Version References:',
' required from libc.so.6:',
' 0x09691a75 0x00 06 GLIBC_2.2.5',
' 0x069691b4 0x00 05 GLIBC_2.34',
' 0x0d696914 0x02 04 GLIBC_2.18',
' 0x00fd0e42 0x00 03 GLIBC_ABI_DT_RELR',
' required from libstdc++.so.6:',
' 0x0b481abc 0x00 07 GLIBCXX_3.4.29',
''
].join('\n')
describe('verify-linux-glibc-floor parsing', () => {
it('parses and compares numeric version tuples', () => {
expect(parseGlibcVersion('2.34')).toEqual([2, 34])
expect(parseGlibcVersion('3.4.28')).toEqual([3, 4, 28])
expect(compareGlibcVersions([2, 2, 5], [2, 14])).toBe(-1)
expect(compareGlibcVersions([2, 31], [2, 32])).toBe(-1)
expect(compareGlibcVersions([2, 34], [2, 31])).toBe(1)
expect(compareGlibcVersions([2, 31], [2, 31])).toBe(0)
expect(compareGlibcVersions([2, 31], [2, 31, 0])).toBe(0)
expect(compareGlibcVersions([3, 4, 29], [3, 4, 28])).toBe(1)
})
it('parses objdump -p Version References into per-library version needs', () => {
const needs = parseVersionNeeds(OBJDUMP_P)
expect(needs).toContainEqual({ library: 'libc.so.6', name: 'GLIBC_2.34', weak: false })
expect(needs).toContainEqual({ library: 'libc.so.6', name: 'GLIBC_ABI_DT_RELR', weak: false })
expect(needs).toContainEqual({ library: 'libc.so.6', name: 'GLIBC_2.18', weak: true })
expect(needs).toContainEqual({ library: 'libstdc++.so.6', name: 'GLIBCXX_3.4.29', weak: false })
})
it('classifies version nodes across glibc and libstdc++ families', () => {
expect(isVersionNodeAboveFloor('GLIBC_2.34')).toBe(true)
expect(isVersionNodeAboveFloor('GLIBC_2.31')).toBe(false)
expect(isVersionNodeAboveFloor('GLIBC_ABI_DT_RELR')).toBe(true) // symbol-less marker (2.36+)
// GLIBC_PRIVATE is not a stable ABI contract; a needed private symbol can be
// absent on the floor even though the version node exists — reject it.
expect(isVersionNodeAboveFloor('GLIBC_PRIVATE')).toBe(true)
expect(isVersionNodeAboveFloor('CXXABI_TM_1')).toBe(false) // named libstdc++ node on 20.04
expect(isVersionNodeAboveFloor('GLIBCXX_3.4.29')).toBe(true) // GCC 11, above 20.04's 3.4.28
expect(isVersionNodeAboveFloor('GLIBCXX_3.4.28')).toBe(false)
expect(isVersionNodeAboveFloor('CXXABI_1.3.13')).toBe(true)
expect(isVersionNodeAboveFloor('CXXABI_1.3.12')).toBe(false)
expect(isVersionNodeAboveFloor('GCC_3.0')).toBe(false) // family not gated
})
it('flags strong too-new glibc + libstdc++ needs, skipping weak and ungated families', () => {
const violations = findFloorViolations(parseVersionNeeds(OBJDUMP_P), '/opt/app/pty.node')
const names = violations.map((v) => v.name).sort()
// GLIBC_2.34, GLIBC_ABI_DT_RELR, GLIBCXX_3.4.29 fail; weak GLIBC_2.18 and
// GLIBC_2.2.5 are excluded.
expect(names).toEqual(['GLIBCXX_3.4.29', 'GLIBC_2.34', 'GLIBC_ABI_DT_RELR'].sort())
})
it('exempts sherpa-onnx from the libstdc++ floor but still gates its glibc', () => {
const needs = [
{ library: 'libstdc++.so.6', name: 'GLIBCXX_3.4.29', weak: false },
{ library: 'libc.so.6', name: 'GLIBC_2.34', weak: false }
]
// A launch-critical module: both are violations.
expect(
findFloorViolations(needs, '/opt/app/node_modules/node-pty/pty.node').map((v) => v.name)
).toEqual(['GLIBCXX_3.4.29', 'GLIBC_2.34'])
// sherpa: GLIBCXX exempt (lazy speech prebuilt), glibc still enforced.
expect(
findFloorViolations(
needs,
'/opt/app/node_modules/sherpa-onnx-linux-x64/sherpa-onnx.node'
).map((v) => v.name)
).toEqual(['GLIBC_2.34'])
})
it('reports no violations when every strong need is at or below the floor', () => {
const needs = parseVersionNeeds(
[
'Version References:',
' required from libc.so.6:',
' 0x00 0x00 02 GLIBC_2.2.5',
' 0x00 0x00 03 GLIBC_2.28',
' required from libstdc++.so.6:',
' 0x00 0x00 04 GLIBCXX_3.4.22'
].join('\n')
)
expect(findFloorViolations(needs, '/opt/app/pty.node')).toEqual([])
})
})
describe('DT_NEEDED provider check', () => {
const OBJDUMP_P_DYNAMIC = [
'Dynamic Section:',
' NEEDED libutil.so.1',
' NEEDED libpthread.so.0',
' NEEDED libc.so.6',
'',
'Version References:',
' required from libc.so.6:',
' 0x0 0x00 02 GLIBC_2.2.5'
].join('\n')
it('parses DT_NEEDED shared libraries from objdump -p', () => {
const needed = parseNeededLibraries(OBJDUMP_P_DYNAMIC)
expect([...needed].sort()).toEqual(['libc.so.6', 'libpthread.so.0', 'libutil.so.1'])
})
it('parses undefined imported symbols from objdump -T, stripping @VERSION', () => {
const output = [
'0000000000000000 DF *UND*\t0000000000000000 (GLIBC_2.2.5) openpty',
'0000000000000000 w DF *UND*\t0000000000000000 __cxa_finalize@GLIBC_2.2.5',
'0000000000000000 DF .text\t0000000000000000 defined_symbol'
].join('\n')
const imported = parseImportedSymbols(output)
expect(imported.has('openpty')).toBe(true)
expect(imported.has('__cxa_finalize')).toBe(true)
expect(imported.has('defined_symbol')).toBe(false) // not *UND*
})
it('flags a binary that imports openpty/forkpty without libutil.so.1 in DT_NEEDED', () => {
const importsPty = new Set(['openpty', 'forkpty', 'free'])
// Missing libutil.so.1 -> the pinned symbols would not resolve on the floor.
expect(
findMissingProviderDeps(importsPty, new Set(['libc.so.6'])).map((m) => m.symbol)
).toEqual(['openpty', 'forkpty'])
// With libutil.so.1 present, no violation.
expect(findMissingProviderDeps(importsPty, new Set(['libc.so.6', 'libutil.so.1']))).toEqual([])
// A binary that doesn't import the relocated symbols is never flagged.
expect(findMissingProviderDeps(new Set(['free']), new Set(['libc.so.6']))).toEqual([])
})
})
describe('collectNativeBinaries', () => {
it('collects only ELF .node/.so/executable files, skipping non-ELF and symlinks', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-glibc-collect-'))
try {
await mkdir(join(root, 'nested'), { recursive: true })
await writeFile(join(root, 'addon.node'), ELF_HEADER)
await writeFile(join(root, 'nested', 'lib.so'), ELF_HEADER)
await writeFile(join(root, 'nested', 'lib.so.1'), ELF_HEADER)
await writeFile(join(root, 'orca-ide'), ELF_HEADER) // extensionless executable
await writeFile(join(root, 'script.js'), ELF_HEADER) // has extension, not native
await writeFile(join(root, 'text.node'), 'not an elf file') // native name, non-ELF
await writeFile(join(root, 'notes.md'), ELF_HEADER)
try {
await symlink(join(root, 'addon.node'), join(root, 'alias.node'))
} catch {
// Symlink creation can be restricted; the rest of the assertions still hold.
}
const found = collectNativeBinaries(root).map((p) => p.slice(root.length + 1))
expect(found).toContain('addon.node')
expect(found).toContain(join('nested', 'lib.so'))
expect(found).toContain(join('nested', 'lib.so.1'))
expect(found).toContain('orca-ide')
expect(found).not.toContain('script.js')
expect(found).not.toContain('text.node')
expect(found).not.toContain('notes.md')
expect(found).not.toContain('alias.node')
} finally {
await rm(root, { recursive: true, force: true })
}
})
})
describe.skipIf(process.platform === 'win32')('verifyLinuxGlibcFloor', () => {
// A stub objdump keyed on the inspected file's basename. Handles `-p` (Dynamic
// Section DT_NEEDED + Version References) and `-T` (undefined symbols).
// `*fail*` exits non-zero (fail-closed branch); `*noutil*` omits libutil.so.1
// from DT_NEEDED; `*pty*` imports openpty. Match on basename only so the
// (random) temp-dir path cannot collide.
async function writeStubObjdump(dir) {
const stubPath = join(dir, 'objdump-stub.sh')
await writeFile(
stubPath,
[
'#!/bin/sh',
'if [ "$1" = "--version" ]; then echo "GNU objdump (stub)"; exit 0; fi',
'f=$(basename "$2")',
'case "$f" in',
' *fail*) echo "objdump: $f: File format not recognized" >&2; exit 1 ;;',
'esac',
'if [ "$1" = "-T" ]; then',
' case "$f" in',
' *pty*) printf "0000 DF *UND* 0000 (GLIBC_2.2.5) openpty\\n" ;;',
' esac',
' exit 0',
'fi',
'printf "Dynamic Section:\\n NEEDED libc.so.6\\n"',
'case "$f" in',
' *noutil*) : ;;',
' *) printf " NEEDED libutil.so.1\\n NEEDED libpthread.so.0\\n" ;;',
'esac',
'printf "\\nVersion References:\\n required from libc.so.6:\\n"',
'case "$f" in',
' *bad*) printf " 0x0 0x00 03 GLIBC_2.34\\n 0x0 0x00 04 GLIBC_2.2.5\\n" ;;',
' *relr*) printf " 0x0 0x00 05 GLIBC_ABI_DT_RELR\\n 0x0 0x00 04 GLIBC_2.2.5\\n" ;;',
' *weakonly*) printf " 0x0 0x02 06 GLIBC_2.32\\n 0x0 0x00 04 GLIBC_2.2.5\\n" ;;',
' *cxx*|*sherpa*)',
' printf " required from libstdc++.so.6:\\n 0x0 0x00 07 GLIBCXX_3.4.29\\n" ;;',
' *) printf " 0x0 0x00 08 GLIBC_2.28\\n 0x0 0x00 04 GLIBC_2.2.5\\n" ;;',
'esac',
'exit 0'
].join('\n'),
{ mode: 0o755 }
)
return stubPath
}
it('throws listing binaries over the floor (glibc, DT_RELR marker, and libstdc++)', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-glibc-over-'))
try {
const objdumpPath = await writeStubObjdump(root)
await mkdir(join(root, 'app', 'resources'), { recursive: true })
await writeFile(join(root, 'app', 'resources', 'bad-pty.node'), ELF_HEADER)
await writeFile(join(root, 'app', 'relr-exe.node'), ELF_HEADER)
await writeFile(join(root, 'app', 'cxx-addon.node'), ELF_HEADER) // launch-critical GLIBCXX_3.4.29
await writeFile(join(root, 'app', 'good.so'), ELF_HEADER)
let error
try {
verifyLinuxGlibcFloor(join(root, 'app'), { objdumpPath })
} catch (e) {
error = e
}
expect(error).toBeDefined()
expect(error.message).toMatch(/bad-pty\.node needs GLIBC_2\.34/)
expect(error.message).toMatch(/relr-exe\.node needs GLIBC_ABI_DT_RELR/)
expect(error.message).toMatch(/cxx-addon\.node needs GLIBCXX_3\.4\.29/)
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('throws when a pinned binary imports openpty without libutil.so.1 in DT_NEEDED', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-glibc-noutil-'))
try {
const objdumpPath = await writeStubObjdump(root)
await mkdir(join(root, 'app'), { recursive: true })
// Below the version floor (so the version check passes) but libutil.so.1
// is missing from DT_NEEDED — openpty would not resolve on Ubuntu 20.04.
await writeFile(join(root, 'app', 'noutil-pty.node'), ELF_HEADER)
expect(() => verifyLinuxGlibcFloor(join(root, 'app'), { objdumpPath })).toThrow(
/noutil-pty\.node imports openpty but libutil\.so\.1 is not in DT_NEEDED/
)
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('passes weak/at-floor needs and the exempt sherpa-onnx libstdc++ prebuilt', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-glibc-under-'))
try {
const objdumpPath = await writeStubObjdump(root)
const sherpaDir = join(root, 'app', 'node_modules', 'sherpa-onnx-linux-x64')
await mkdir(sherpaDir, { recursive: true })
await writeFile(join(root, 'app', 'good-pty.node'), ELF_HEADER)
await writeFile(join(root, 'app', 'weakonly-lib.so'), ELF_HEADER) // weak GLIBC_2.32 → OK
await writeFile(join(root, 'app', 'orca-ide'), ELF_HEADER)
await writeFile(join(sherpaDir, 'sherpa-onnx.node'), ELF_HEADER) // GLIBCXX_3.4.29, exempt
expect(() => verifyLinuxGlibcFloor(join(root, 'app'), { objdumpPath })).not.toThrow()
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('fails closed when objdump cannot read a binary (non-zero exit)', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-glibc-closed-'))
try {
const objdumpPath = await writeStubObjdump(root)
await mkdir(join(root, 'app'), { recursive: true })
await writeFile(join(root, 'app', 'unreadable-fail.node'), ELF_HEADER)
expect(() => verifyLinuxGlibcFloor(join(root, 'app'), { objdumpPath })).toThrow(
/objdump -p failed/
)
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('is a no-op (no objdump needed) when there are no native binaries', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-glibc-empty-'))
try {
await mkdir(join(root, 'app'), { recursive: true })
await writeFile(join(root, 'app', 'readme.txt'), 'no binaries here')
expect(() =>
verifyLinuxGlibcFloor(join(root, 'app'), { objdumpPath: '/nonexistent/objdump' })
).not.toThrow()
} finally {
await rm(root, { recursive: true, force: true })
}
})
})
+3
View File
@@ -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",
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 7.5m">
<title>downloads: 7.5m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 7.6m">
<title>downloads: 7.6m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">7.5m</text>
<text x="93.5" y="14">7.5m</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">7.6m</text>
<text x="93.5" y="14">7.6m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 943 B

After

Width:  |  Height:  |  Size: 943 B

+4 -2
View File
@@ -10,8 +10,10 @@ startup. Current Orca builds start Xvfb automatically for `orca serve` when no
not required. When `DISPLAY` is set, Orca uses that display instead of starting
a competing Xvfb process.
The supported deployment matrix covers Ubuntu 22.04 and 24.04 and current
Debian stable. Package names can differ on other Debian-derived releases.
The supported deployment matrix covers Ubuntu 20.04, 22.04, and 24.04 and
current Debian stable — anything with glibc 2.31 or newer (see
[Linux glibc compatibility](./linux-glibc-compatibility.md)). Package names can
differ on other Debian-derived releases.
## Ubuntu and Debian prerequisites
@@ -0,0 +1,99 @@
# Linux glibc Compatibility
Orca's Linux builds target **stock Ubuntu 20.04 and newer** — glibc 2.31 and
libstdc++ `GLIBCXX_3.4.28` (also Debian 11, RHEL 9), on both x64 and arm64.
Packaging enforces this floor automatically; keep it in mind when adding or
upgrading native dependencies. (The optional speech feature is the one
exception — see below.)
## Why this needs attention
A native module (`.node`) links against the glibc of the machine that compiled
it. Our release CI compiles node-pty from source on GitHub's `ubuntu-latest`
runner, whose glibc rises over time as the image is bumped. A binary compiled on
a newer glibc can reference symbol versions that do not exist on an older target,
and the dynamic loader then refuses to load it:
```
/lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found (required by .../pty.node)
```
Because the Orca main process loads node-pty at startup, that failure crashes the
whole app before a window appears — this is exactly what shipped in v1.4.150 and
broke launch on Ubuntu 20.04 ([#9902](https://github.com/stablyai/orca/issues/9902)).
The specific trap is glibc's 2.32–2.34 "libpthread/libutil merge", which moved
several long-stable functions into libc under brand-new symbol versions:
| Symbol | New version | node-pty use |
| ----------------- | ------------- | ----------------------- |
| `pthread_sigmask` | `GLIBC_2.32` | reset child signal mask |
| `openpty` | `GLIBC_2.34` | allocate the pty |
| `forkpty` | `GLIBC_2.34` | fork the shell |
Electron itself (glibc 2.25) and the other bundled native modules
(`sherpa-onnx`, `@parcel/watcher`, both prebuilt on old glibc) stay well under
the floor, so node-pty was the sole blocker.
## How we keep the floor
**1. Pin the relocated symbols (the fix).**
[`config/patches/node-pty@1.1.0.patch`](../../config/patches/node-pty@1.1.0.patch)
adds a `.symver` shim in `src/unix/pty.cc` that binds `openpty`, `forkpty`, and
`pthread_sigmask` to their pre-merge version node — `GLIBC_2.2.5` on x64,
`GLIBC_2.17` on arm64 (each architecture's baseline glibc). glibc still ships
those as compatibility aliases, so the reference resolves on both new build hosts
and old targets.
The catch: gcc defaults to `--as-needed` and, since the pinned symbols now
resolve from libc's compat aliases at build time, it drops `libutil`/`libpthread`
from `DT_NEEDED`. On the target those libraries are where the symbols actually
live, so the patch's `binding.gyp` `ldflags` force
`-Wl,--no-as-needed,-l:libutil.so.1,-l:libpthread.so.0` back into `DT_NEEDED`.
The shim is guarded by `#if defined(__linux__)`; macOS and Windows are untouched.
**2. Gate packaging (the regression guard).**
[`config/scripts/verify-linux-glibc-floor.cjs`](../../config/scripts/verify-linux-glibc-floor.cjs)
runs in the electron-builder `afterPack` hook for Linux. It reads every bundled
native binary's version needs (`objdump -p` "Version References" — the
authoritative load-time list, which also captures symbol-less markers like
`GLIBC_ABI_DT_RELR`) and fails the build if any strong `GLIBC_`/`GLIBCXX_`/
`CXXABI_` node is newer than stock Ubuntu 20.04 provides, naming the file and the
offending node. Weak needs are ignored (the loader tolerates them). It also
asserts the flip side of the `.symver` fix: any binary that imports
`openpty`/`forkpty` must keep `libutil.so.1` in `DT_NEEDED` — otherwise the
pinned `openpty@GLIBC_2.2.5` resolves from libc's compat alias at build time (so
the version check passes) yet fails to load on 20.04, where those functions live
only in libutil. A future runner bump, a new native dependency, or a dropped
ldflag therefore fails the release build instead of shipping a Linux app that
crashes on launch.
> The gate is a static invariant, not an integration test. The load path was
> verified by hand for this fix (real Ubuntu 20.04, x64 + arm64: `require`
> node-pty and spawn a shell). A CI smoke test that loads the packaged
> `pty.node` in a glibc-2.31 container and spawns a shell is the recommended
> follow-up — it would make the load path self-verifying and stay valid even if
> the build ever moves to an old-glibc sysroot.
The one carve-out is the `sherpa-onnx` speech prebuilt, which already requires
`GLIBCXX_3.4.29` (GCC 11). It loads lazily in the speech worker
(`src/main/speech/stt-worker.ts`), never at app launch, so it is exempt from the
libstdc++ floor — its glibc needs are still checked. Speech-to-text therefore
needs a host with libstdc++ from GCC 11+ (Ubuntu 21.10 / 22.04 LTS or newer); the
app itself still launches on stock 20.04.
## Adding or upgrading a native dependency
- Prefer packages that ship prebuilt binaries compiled against an old toolchain
(manylinux / `glibc 2.17`-class), like `@parcel/watcher`.
- For a module we compile from source, if the gate flags it, either pin the
offending symbols the way node-pty does, or build it in an old-glibc container.
- To check locally on a Linux host, list what a binary requires (skipping the
weak `0x02`-flagged needs the loader tolerates):
```bash
objdump -p path/to/module.node | sed -n '/Version References/,/^$/p'
```
No strong `GLIBC_` node may exceed `2.31`, and no `GLIBCXX_`/`CXXABI_` node may
exceed `3.4.28`/`1.3.12` — what stock Ubuntu 20.04 ships.
+11 -8
View File
@@ -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) {
+63 -74
View File
@@ -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}
/>
)}
/>
+39 -14
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
+5 -5
View File
@@ -23,7 +23,7 @@ export default function NativeChatSettingsScreen() {
>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<Text style={styles.heading}>Native chat</Text>
<Text style={styles.heading}>Chat UI</Text>
</View>
<ScrollView
@@ -33,17 +33,17 @@ export default function NativeChatSettingsScreen() {
<Text style={styles.groupHeading}>DEFAULT VIEW</Text>
<Text style={styles.groupDescription}>
Choose how supported agent sessions (Claude, Codex, and other chat-capable agents) open on
this device. Terminal shows the raw CLI; native chat shows a chat interface like the
desktop app. You can still switch any individual session from its long-press menu.
this device. Terminal shows the raw CLI; Chat UI shows a chat interface like the desktop
app. You can still switch any individual session from its long-press menu.
</Text>
<View style={[styles.section, styles.sectionTopGap]}>
<View style={styles.row}>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Open sessions in native chat</Text>
<Text style={styles.rowLabel}>Open sessions in Chat UI</Text>
<Text style={styles.rowSublabel}>{chatDefault ? 'On' : 'Off'}</Text>
</View>
<Switch
accessibilityLabel="Open sessions in native chat"
accessibilityLabel="Open sessions in Chat UI"
value={chatDefault}
onValueChange={(next) => setDefaultView(next ? 'chat' : 'terminal')}
trackColor={{ false: colors.bgRaised, true: colors.textSecondary }}
+1 -1
View File
@@ -120,7 +120,7 @@ export default function SettingsScreen() {
onPress={() => router.push('/native-chat-settings')}
>
<MessageSquare size={16} color={colors.textSecondary} />
<Text style={styles.rowLabel}>Native chat</Text>
<Text style={styles.rowLabel}>Chat UI</Text>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
<View style={styles.separator} />
+35 -33
View File
@@ -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(
+50 -83
View File
@@ -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(
+11 -16
View File
@@ -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={() => {
+4 -1
View File
@@ -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)
})
})
+4 -1
View File
@@ -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])
}
+101
View File
@@ -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)
})
})
+45 -7
View File
@@ -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',
+12 -4
View File
@@ -1,5 +1,6 @@
import type { RpcClient } from '../transport/rpc-client'
import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response'
type MobileTerminalClient = {
@@ -15,9 +16,9 @@ type MobileNativeChatSendArgs = {
mobileClient?: MobileTerminalClient
}
/** 'unknown' = the RPC failed after the request hit the wire (relay drop or
* response timeout) — the desktop may have delivered the text and only the ack
* was lost, so callers must not present it as a definite send failure. */
/** 'unknown' = the RPC failed without proof the request never reached the
* desktop (ack loss after a write, or a cutover that cannot tell whether the
* frame was written) — callers must not present it as a definite send failure. */
export type MobileNativeChatSendOutcome = 'accepted' | 'rejected' | 'unknown'
export async function sendMobileNativeChatMessageWithOutcome(
@@ -32,7 +33,14 @@ export async function sendMobileNativeChatMessageWithOutcome(
})
return isTerminalSendRpcAccepted(response) ? 'accepted' : 'rejected'
} catch (error) {
return isRpcDeliveryUnknown(error) ? 'unknown' : 'rejected'
// Why: a logical relay↔direct cutover rejects the in-flight send without
// knowing whether its frame reached the wire (the desktop may have delivered
// it), so treat it as delivery-ambiguous like physical ack-loss — never
// retry (double-send risk) and never a definite "not sent" that would hide
// a real delivery.
return isRpcDeliveryUnknown(error) || isLogicalClientCutoverError(error)
? 'unknown'
: 'rejected'
}
}
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
isTerminalCoveredByNativeChat,
mobileNativeChatSubscribeViewport,
mobileNativeChatTerminalCapabilities,
resolveMobileNativeChatTerminalStreamAction
} from './mobile-native-chat-terminal-stream'
@@ -34,6 +35,17 @@ describe('mobile native-chat terminal stream lifecycle', () => {
expect(mobileNativeChatTerminalCapabilities(false)).toEqual({ terminalBinaryStream: 1 })
})
it('omits the viewport from a covered lease subscribe so the host keeps desktop dims', () => {
// Why: handleMobileSubscribe phone-fits the PTY whenever a viewport is present,
// even for a lease-only subscribe — entering chat must not resize the terminal.
expect(mobileNativeChatSubscribeViewport(true, { cols: 40, rows: 60 })).toBeUndefined()
expect(mobileNativeChatSubscribeViewport(false, { cols: 40, rows: 60 })).toEqual({
cols: 40,
rows: 60
})
expect(mobileNativeChatSubscribeViewport(false, null)).toBeUndefined()
})
it('records a cold-start cover before WebView readiness so return refreshes', () => {
expect(
resolveMobileNativeChatTerminalStreamAction({
@@ -35,3 +35,11 @@ export function mobileNativeChatTerminalCapabilities(covered: boolean): {
? { terminalBinaryStream: 1, mobileInputLeaseOnly: 1 }
: { terminalBinaryStream: 1 }
}
// Why: a covered subscribe is only an input lease — carrying phone dims would make the host phone-fit a PTY native chat never renders.
export function mobileNativeChatSubscribeViewport(
covered: boolean,
viewport: { cols: number; rows: number } | null
): { cols: number; rows: number } | undefined {
return covered ? undefined : (viewport ?? undefined)
}
@@ -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 -8
View File
@@ -1,6 +1,6 @@
import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse, RpcSuccess } from '../transport/types'
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import {
CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS,
getClientWorktreeCreateCandidate,
@@ -100,13 +100,6 @@ async function sendWorktreeCreateResilient(
}
}
function isLogicalClientCutoverError(error: unknown): boolean {
return (
error instanceof LogicalClientCutoverError ||
(error instanceof Error && error.message === 'RPC interrupted by connection migration')
)
}
function defaultWorktreeCreateMutationId(): string {
const randomPart = Math.random().toString(36).slice(2, 10)
return `worktree-create:${Date.now().toString(36)}:${randomPart}`
@@ -7,6 +7,7 @@ export type TerminalViewportRefitTargetState = {
expectedHandle: string
currentRef: unknown
expectedRef: unknown
nativeChatCovered: boolean
disposed: boolean
runSeq: number
currentRunSeq: number
@@ -94,6 +95,7 @@ export function isTerminalViewportRefitTargetCurrent(
state: TerminalViewportRefitTargetState
): boolean {
return (
!state.nativeChatCovered &&
!state.disposed &&
state.runSeq === state.currentRunSeq &&
state.activeHandle === state.expectedHandle &&
@@ -135,6 +135,18 @@ describe('terminal viewport refit', () => {
expect(timerBody).toContain('if (!decision.shouldRefit)')
})
it('suppresses refits while native chat covers the active terminal', () => {
// Why: native chat renders the transcript, not the grid — a refit there would
// reflow the desktop PTY to phone dims the user never sees.
const timerStart = hookSource.indexOf('refitTimerRef.current = setTimeout(')
const coveredCheck = hookSource.indexOf('if (nativeChatCoveredRef.current)', timerStart)
const measureIndex = hookSource.indexOf('measureFitDimensions', timerStart)
expect(timerStart).toBeGreaterThanOrEqual(0)
expect(coveredCheck).toBeGreaterThan(timerStart)
expect(measureIndex).toBeGreaterThan(coveredCheck)
expect(sessionSource).toContain('nativeChatCoveredRef: showNativeChatRef')
})
it('is wired into the session screen', () => {
expect(sessionSource).toContain('useTerminalViewportRefit({')
expect(sessionSource).toContain('tabStripVisible: terminals.length > 1')
@@ -301,6 +313,7 @@ describe('terminal viewport refit', () => {
expectedHandle: 'term-1',
currentRef: expectedRef,
expectedRef,
nativeChatCovered: false,
disposed: false,
runSeq: 2,
currentRunSeq: 2
@@ -313,5 +326,8 @@ describe('terminal viewport refit', () => {
).toBe(false)
expect(isTerminalViewportRefitTargetCurrent({ ...current, currentRunSeq: 3 })).toBe(false)
expect(isTerminalViewportRefitTargetCurrent({ ...current, disposed: true })).toBe(false)
expect(isTerminalViewportRefitTargetCurrent({ ...current, nativeChatCovered: true })).toBe(
false
)
})
})
@@ -23,6 +23,8 @@ type TerminalViewportRefitOptions = {
terminalFrameHeightRef: RefObject<number>
viewportRef: RefObject<TerminalViewportDims | null>
viewportMeasuredRef: RefObject<boolean>
// Why: while native chat covers the active terminal, a refit would push phone dims into a PTY nobody on this device is viewing.
nativeChatCoveredRef: RefObject<boolean>
clientRef: RefObject<RpcClient | null>
deviceTokenRef: RefObject<string | null>
initializedHandlesRef: RefObject<Set<string>>
@@ -51,6 +53,7 @@ export function useTerminalViewportRefit(
terminalFrameHeightRef,
viewportRef,
viewportMeasuredRef,
nativeChatCoveredRef,
clientRef,
deviceTokenRef,
initializedHandlesRef,
@@ -99,6 +102,10 @@ export function useTerminalViewportRefit(
if (!handle) {
return
}
// Why: the trigger already marked the viewport stale, and the return-to-terminal resubscribe re-measures — refitting now would resize a covered PTY.
if (nativeChatCoveredRef.current) {
return
}
const ref = terminalRefs.current.get(handle)
if (!ref) {
return
@@ -109,6 +116,7 @@ export function useTerminalViewportRefit(
expectedHandle: handle,
currentRef: terminalRefs.current.get(handle),
expectedRef: ref,
nativeChatCovered: nativeChatCoveredRef.current,
disposed: disposedRef.current,
runSeq,
currentRunSeq: refitRunSeqRef.current
@@ -171,6 +179,7 @@ export function useTerminalViewportRefit(
terminalFrameHeightRef,
viewportRef,
viewportMeasuredRef,
nativeChatCoveredRef,
clientRef,
deviceTokenRef,
initializedHandlesRef,
@@ -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,
+3 -1
View File
@@ -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.
+26 -4
View File
@@ -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()
})
})
+17 -2
View File
@@ -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')
},
+6 -4
View File
@@ -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
View File
@@ -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": [
+463 -858
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -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": [
+39
View File
@@ -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
}
}
]
}
+224
View File
@@ -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"
}
]
}
]
}
+3 -2
View File
@@ -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
+1 -1
View File
@@ -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
+6 -1
View File
@@ -126,7 +126,12 @@ export class RuntimeClient {
runtime: {
state: graphState === 'ready' ? 'ready' : 'graph_not_ready',
reachable: true,
runtimeId: response.result.runtimeId
runtimeId: response.result.runtimeId,
...(response.result.appVersion ? { appVersion: response.result.appVersion } : {}),
...(response.result.remoteUpdateSupport
? { remoteUpdateSupport: response.result.remoteUpdateSupport }
: {}),
...(response.result.capabilities ? { capabilities: response.result.capabilities } : {})
},
graph: {
state: graphState
+6 -1
View File
@@ -45,7 +45,12 @@ export async function getCliStatus(
runtime: {
state: graphState === 'ready' ? 'ready' : 'graph_not_ready',
reachable: true,
runtimeId: response.result.runtimeId
runtimeId: response.result.runtimeId,
...(response.result.appVersion ? { appVersion: response.result.appVersion } : {}),
...(response.result.remoteUpdateSupport
? { remoteUpdateSupport: response.result.remoteUpdateSupport }
: {}),
...(response.result.capabilities ? { capabilities: response.result.capabilities } : {})
},
graph: {
state: graphState
+25 -2
View File
@@ -64,7 +64,15 @@ describe('CLI remote WebSocket transport', () => {
})
it('accepts a bare pairing payload as well as the orca URL wrapper', async () => {
const runtime = await startTestRuntime('runtime-ws-2')
const runtime = await startTestRuntime('runtime-ws-2', {
appVersion: '1.5.0',
remoteUpdateSupport: {
installMode: 'unsupported-headless-serve',
automatic: false,
reason: 'manual-service-update-required'
},
capabilities: ['updater.remote-control.v1']
})
servers.push(runtime)
const offer: PairingOffer = {
v: 2,
@@ -83,6 +91,11 @@ describe('CLI remote WebSocket transport', () => {
expect(status.result.app).toEqual({ running: false, pid: null })
expect(status.result.runtime.reachable).toBe(true)
expect(status.result.runtime.runtimeId).toBe('runtime-ws-2')
expect(status.result.runtime).toMatchObject({
appVersion: '1.5.0',
remoteUpdateSupport: { automatic: false, reason: 'manual-service-update-required' },
capabilities: ['updater.remote-control.v1']
})
})
it('does not launch a local desktop app for remote-paired open', async () => {
@@ -157,6 +170,13 @@ async function startTestRuntime(
runtimeProtocolVersion?: number
minCompatibleRuntimeClientVersion?: number
desktopWindowStatus?: 'available' | 'openable' | 'initializing' | 'blocked'
appVersion?: string
remoteUpdateSupport?: {
installMode: 'unsupported-headless-serve'
automatic: false
reason: 'manual-service-update-required'
}
capabilities?: string[]
} = {}
): Promise<TestRuntime> {
const serverKeyPair = generateKeyPair()
@@ -213,7 +233,10 @@ async function startTestRuntime(
statusOverrides.runtimeProtocolVersion ?? RUNTIME_PROTOCOL_VERSION,
minCompatibleRuntimeClientVersion:
statusOverrides.minCompatibleRuntimeClientVersion ??
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
appVersion: statusOverrides.appVersion,
remoteUpdateSupport: statusOverrides.remoteUpdateSupport,
capabilities: statusOverrides.capabilities
},
_meta: { runtimeId }
}
+1 -1
View File
@@ -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']
},
+199
View File
@@ -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()
})
})
+81 -70
View File
@@ -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 {
+3 -2
View File
@@ -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)
})
+21 -9
View File
@@ -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'}`
}
+103 -4
View File
@@ -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'
}
])
})
})
+43 -51
View File
@@ -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"']]))
+109 -67
View File
@@ -18,6 +18,8 @@ import {
isTomlStructuralLine,
updateTomlLineScanState
} from './config-toml-line-scan'
import { parseTomlKeyPath, parseTomlTableHeaderPath } from './config-toml-key-path'
import { tuiStructuredKey, upsertPromotedSettingsInContent } from './codex-config-settings-upsert'
// Why: the mirror reverts in-Codex config changes each launch; promotion salvages them by diffing the last baseline.
@@ -29,6 +31,45 @@ export const PROMOTED_CODEX_SETTING_KEYS = [
'sandbox_mode'
] as const
// Why: the [tui] keys the Codex TUI's user-facing pickers persist (status line,
// terminal title, theme). Like the top-level list, every key here gets written
// into the user's real ~/.codex/config.toml on promotion — grow it deliberately.
export const PROMOTED_CODEX_TUI_SETTING_KEYS = [
'status_line',
'status_line_use_colors',
'terminal_title',
'theme'
] as const
// Why: promotion diffs and upserts operate on structured keys — top-level keys
// keep their bare name, [tui] keys are namespaced tui.<key> so their baseline
// entries cannot collide with a top-level key of the same name.
const PROMOTED_STRUCTURED_KEYS: readonly string[] = [
...PROMOTED_CODEX_SETTING_KEYS,
...PROMOTED_CODEX_TUI_SETTING_KEYS.map(tuiStructuredKey)
]
function isPromotedTuiKey(key: string): boolean {
return (PROMOTED_CODEX_TUI_SETTING_KEYS as readonly string[]).includes(key)
}
// Returns the structured tui key a scanned line's key represents, or null. In
// the preamble it recognizes the dotted `tui.<key>` form a user may hand-author;
// inside the first `[tui]` table body it recognizes the bare `<key>` form Codex
// writes. Both map to the same structured key so either config shape promotes.
function matchTuiStructuredKey(
keyPath: string[],
inPreamble: boolean,
tuiBodyActive: boolean
): string | null {
if (inPreamble) {
const tuiKey = keyPath.length === 2 && keyPath[0] === 'tui' ? keyPath[1] : null
return tuiKey && isPromotedTuiKey(tuiKey) ? tuiStructuredKey(tuiKey) : null
}
const tuiKey = keyPath.length === 1 ? keyPath[0] : null
return tuiBodyActive && tuiKey && isPromotedTuiKey(tuiKey) ? tuiStructuredKey(tuiKey) : null
}
type TopLevelSettingValue = {
raw: string
// Why: a multiline string/array value can't be replaced line-by-line, so it's excluded from promotion.
@@ -70,24 +111,67 @@ function readSettingsBaseline(runtimeHomePath: string): Map<string, string> | nu
}
}
// Why: only top-level preamble keys are scanned; rewriting nested [profiles.*] tables isn't worth the risk here.
function readTopLevelSettingValues(configPath: string): Map<string, TopLevelSettingValue> {
function matchPromotedStructuredKey(
line: string,
inPreamble: boolean,
tuiBodyActive: boolean
): { structuredKey: string; raw: string } | null {
const parsed = parseTomlKeyPath(line)
if (!parsed || line[parsed.end] !== '=') {
return null
}
const raw = line.slice(parsed.end + 1).trim()
const topLevelKey = parsed.segments.length === 1 ? parsed.segments[0] : null
if (
inPreamble &&
topLevelKey &&
(PROMOTED_CODEX_SETTING_KEYS as readonly string[]).includes(topLevelKey)
) {
return { structuredKey: topLevelKey, raw }
}
const tuiKey = matchTuiStructuredKey(parsed.segments, inPreamble, tuiBodyActive)
return tuiKey ? { structuredKey: tuiKey, raw } : null
}
// Why: top-level preamble scalars keep the historical behavior; [tui] keys are
// collected from the first bare [tui] table body or the dotted preamble form,
// keyed by structured path. Any table header (including [tui.*] subtables) ends
// the [tui] body, and [profiles.*]/other tables are still ignored.
function readPromotedSettingValues(configPath: string): Map<string, TopLevelSettingValue> {
const result = new Map<string, TopLevelSettingValue>()
if (!existsSync(configPath)) {
return result
}
const lines = readFileSync(configPath, 'utf-8').split('\n')
let state = createTomlLineScanState()
let inPreamble = true
let tuiTableSeen = false
let tuiBodyActive = false
for (const line of lines) {
if (isTomlStructuralLine(state)) {
if (getTomlTableHeader(line)) {
break
const header = getTomlTableHeader(line)
if (header) {
const table = parseTomlTableHeaderPath(header)
tuiBodyActive =
table !== null &&
!table.isArray &&
table.segments.length === 1 &&
table.segments[0] === 'tui' &&
!tuiTableSeen
if (tuiBodyActive) {
tuiTableSeen = true
}
inPreamble = false
state = updateTomlLineScanState(state, line)
continue
}
const match = /^[ \t]*([A-Za-z0-9_-]+)[ \t]*=[ \t]*(.*?)[ \t\r]*$/.exec(line)
const key = match?.[1]
if (key && (PROMOTED_CODEX_SETTING_KEYS as readonly string[]).includes(key)) {
const matched = matchPromotedStructuredKey(line, inPreamble, tuiBodyActive)
if (matched) {
const nextState = updateTomlLineScanState(state, line)
result.set(key, { raw: match?.[2] ?? '', multiline: !isTomlStructuralLine(nextState) })
result.set(matched.structuredKey, {
raw: matched.raw,
multiline: !isTomlStructuralLine(nextState)
})
state = nextState
continue
}
@@ -109,7 +193,7 @@ export function snapshotCodexRuntimeSettingsBaseline(
const runtimeTomlPath = join(runtimeHomePath, 'config.toml')
// Why: record an empty baseline even for a missing runtime config, so Codex's first write still diffs and promotes.
const settings: Record<string, string> = {}
for (const [key, value] of readTopLevelSettingValues(runtimeTomlPath)) {
for (const [key, value] of readPromotedSettingValues(runtimeTomlPath)) {
if (!value.multiline) {
settings[key] = value.raw
}
@@ -173,10 +257,9 @@ function promoteCodexRuntimeSettingsToSystemUnsafe(homes: CodexSettingsPromotion
if (!baseline) {
return
}
const runtimeValues = readTopLevelSettingValues(runtimeTomlPath)
const systemValues = readTopLevelSettingValues(systemTomlPath)
const updates = new Map<string, string>()
for (const key of PROMOTED_CODEX_SETTING_KEYS) {
const runtimeValues = readPromotedSettingValues(runtimeTomlPath)
const changedRuntimeValues = new Map<string, string>()
for (const key of PROMOTED_STRUCTURED_KEYS) {
const runtime = runtimeValues.get(key)
if (!runtime || runtime.multiline) {
continue
@@ -185,6 +268,14 @@ function promoteCodexRuntimeSettingsToSystemUnsafe(homes: CodexSettingsPromotion
// Orca mirrored this value and nothing touched it since — not a change.
continue
}
changedRuntimeValues.set(key, runtime.raw)
}
if (changedRuntimeValues.size === 0) {
return
}
const systemValues = readPromotedSettingValues(systemTomlPath)
const updates = new Map<string, string>()
for (const [key, runtimeRaw] of changedRuntimeValues) {
const system = systemValues.get(key)
if (system?.multiline) {
continue
@@ -193,7 +284,7 @@ function promoteCodexRuntimeSettingsToSystemUnsafe(homes: CodexSettingsPromotion
if (system?.raw !== baseline.get(key)) {
continue
}
updates.set(key, runtime.raw)
updates.set(key, runtimeRaw)
}
if (updates.size === 0) {
return
@@ -205,7 +296,10 @@ function promoteCodexRuntimeSettingsToSystemUnsafe(homes: CodexSettingsPromotion
mkdirSync(dirname(writeTarget.path), { recursive: true, mode: 0o700 })
const targetExists = existsSync(writeTarget.path)
const systemContent = targetExists ? readFileSync(writeTarget.path, 'utf-8') : ''
const nextContent = upsertTopLevelSettingsInContent(systemContent, updates)
const nextContent = upsertPromotedSettingsInContent(systemContent, updates)
if (nextContent === systemContent) {
return
}
if (targetExists && parseWslUncPath(writeTarget.path)) {
// Why: \\wsl$ 9P symlink metadata is unreliable; write through the existing file to preserve the WSL-side inode.
writeFileSync(writeTarget.path, nextContent, 'utf-8')
@@ -252,55 +346,3 @@ function resolveDanglingSymlinkTarget(linkPath: string): string {
// Why: replacing any link in a cycle would destroy dotfile-manager state; abort instead.
throw new Error(`Codex config symlink cycle at ${linkPath}`)
}
export function upsertTopLevelSettingsInContent(
content: string,
updates: Map<string, string>
): string {
const lines = content.split('\n')
let state = createTomlLineScanState()
let preambleEnd = lines.length
const keyLineIndexes = new Map<string, number>()
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index] ?? ''
if (isTomlStructuralLine(state)) {
if (getTomlTableHeader(line)) {
preambleEnd = index
break
}
const match = /^[ \t]*([A-Za-z0-9_-]+)[ \t]*=/.exec(line)
if (match?.[1] && updates.has(match[1])) {
keyLineIndexes.set(match[1], index)
}
}
state = updateTomlLineScanState(state, line)
}
// Why: match the file's existing EOL (CRLF split leaves a trailing \r) so a Windows config doesn't go mixed-EOL.
const usesCrlf = content.includes('\r\n')
const insertions: string[] = []
for (const [key, raw] of updates) {
const existingIndex = keyLineIndexes.get(key)
const rendered = `${key} = ${raw}`
if (existingIndex !== undefined) {
lines[existingIndex] = lines[existingIndex]?.endsWith('\r') ? `${rendered}\r` : rendered
} else {
insertions.push(usesCrlf ? `${rendered}\r` : rendered)
}
}
if (insertions.length > 0) {
let insertAt = preambleEnd
while (insertAt > 0 && (lines[insertAt - 1] ?? '').trim() === '') {
insertAt -= 1
}
if (insertAt === preambleEnd && preambleEnd < lines.length) {
insertions.push(usesCrlf ? '\r' : '')
}
lines.splice(insertAt, 0, ...insertions)
}
const result = lines.join('\n')
if (result.endsWith('\n') || result.length === 0) {
return result
}
return result.endsWith('\r') ? `${result}\n` : `${result}${usesCrlf ? '\r\n' : '\n'}`
}
+67
View File
@@ -0,0 +1,67 @@
import { parseTomlSingleLineStringValue } from './config-toml-line-scan'
export type ParsedTomlKeyPath = {
segments: string[]
end: number
}
export type ParsedTomlTableHeaderPath = ParsedTomlKeyPath & {
isArray: boolean
}
export function parseTomlTableHeaderPath(header: string): ParsedTomlTableHeaderPath | null {
const trimmed = header.trim()
let source: string
let isArray: boolean
if (trimmed.startsWith('[[')) {
if (!trimmed.endsWith(']]')) {
return null
}
source = trimmed.slice(2, -2)
isArray = true
} else {
if (!trimmed.startsWith('[') || !trimmed.endsWith(']') || trimmed.endsWith(']]')) {
return null
}
source = trimmed.slice(1, -1)
isArray = false
}
const parsed = parseTomlKeyPath(source)
if (!parsed || parsed.end !== source.length) {
return null
}
return { ...parsed, isArray }
}
export function parseTomlKeyPath(source: string, offset = 0): ParsedTomlKeyPath | null {
const segments: string[] = []
let index = skipTomlKeyWhitespace(source, offset)
while (index < source.length) {
const quoted = parseTomlSingleLineStringValue(source, index)
if (quoted) {
segments.push(quoted.value)
index = quoted.end
} else {
const bare = /^[A-Za-z0-9_-]+/.exec(source.slice(index))
if (!bare) {
return null
}
segments.push(bare[0])
index += bare[0].length
}
index = skipTomlKeyWhitespace(source, index)
if (source[index] !== '.') {
return { segments, end: index }
}
index = skipTomlKeyWhitespace(source, index + 1)
}
return null
}
function skipTomlKeyWhitespace(source: string, offset: number): number {
let index = offset
while (source[index] === ' ' || source[index] === '\t') {
index += 1
}
return index
}
+4 -2
View File
@@ -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()
})
})
+13
View File
@@ -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'
)
})
})
+81 -2
View File
@@ -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) {
+5 -1
View File
@@ -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))
+10 -3
View File
@@ -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.
+6
View File
@@ -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
+11 -1
View File
@@ -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