Merge remote-tracking branch 'origin/main' into nwparker/macos-press-and-hold-key-repeat

# Conflicts:
#	src/renderer/src/components/settings/TerminalWindowSection.tsx
This commit is contained in:
Neil
2026-08-21 23:10:34 -07:00
259 changed files with 15415 additions and 3696 deletions
+13
View File
@@ -618,11 +618,24 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Why: node-pty prefers its upstream prebuild, which does not contain
# Orca's Windows patch, so the job-object exports would be absent and the
# suite below would test an unpatched binary. build_from_source removes
# the prebuild, and the package's postinstall restores the ConPTY runtime
# files that a bare node-gyp rebuild would miss.
- name: Rebuild node-pty from patched source
env:
npm_config_build_from_source: 'true'
run: pnpm rebuild node-pty
- name: Test Windows-specific boundaries
run: >-
pnpm exec vitest run --config config/vitest.config.ts
src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts
src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts
src/shared/child-process/windows-command-line.win32.test.ts
src/main/windows/windows-pty-job.win32.test.ts
src/main/windows/windows-host-job.win32.test.ts
src/main/cli/wsl-cli-powershell-boundary.test.ts
src/main/orca-profiles/profile-index-store.test.ts
src/main/runtime/repo-worktree-admin-fingerprint.test.ts
+3 -1
View File
@@ -53,7 +53,7 @@ on:
default: ''
permissions:
contents: write
contents: read
concurrency:
group: release-cut
@@ -68,6 +68,8 @@ jobs:
if: github.repository == 'stablyai/orca'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
outputs:
tag: ${{ steps.tag.outputs.tag || steps.version.outputs.recovered_tag }}
should_release: ${{ steps.tag.outputs.tag != '' || steps.version.outputs.recovered_tag != '' }}
+1
View File
@@ -104,6 +104,7 @@ docs/**
!docs/reference/linux-glibc-compatibility.md
!docs/reference/macos-press-and-hold.md
!docs/reference/relay-grace-time-reconfiguration.md
!docs/reference/windows-process-enumeration.md
!docs/reference/remote-wire-compatibility.md
!docs/reference/renderer-agent-status-performance.md
!docs/reference/ssh-execution-boundary.md
+2
View File
@@ -35,6 +35,8 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh
- **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 `\`.
- **Windows setup scripts**: the setup/issue-command runner is a `.cmd` batch file unless the script starts with a `#!` line — never derive that from the user's terminal-shell preference, and never launch a `.cmd` runner with a bare `cmd.exe /c` from a Git Bash pane (MSYS rewrites the `/c`). See [`docs/reference/windows-setup-shell.md`](./docs/reference/windows-setup-shell.md).
- **Windows child processes**: start them through `runProcess`/`spawnProcess` in `src/shared/child-process/` — never `child_process` directly. It pins `windowsHide`, refuses `shell: true`, and encodes `.cmd`/`.bat` arguments so neither `CommandLineToArgvW` nor `cmd.exe` mangles them. A ratchet test fails on any new direct import.
- **Windows process enumeration**: read the table through `src/main/windows/windows-process-table.ts`, never by forking `powershell.exe`. See [`docs/reference/windows-process-enumeration.md`](./docs/reference/windows-process-enumeration.md).
- **WSL commands**: build argv with `buildWslExecArgs` (always `--exec` — under `--`, `wsl.exe` expands `$name` in every argument and silently rewrites the script), and fence anything whose stdout you parse with `buildWslCapturedLoginShellCommand`, because the interactive login shell prints the distro banner to stdout. See [`docs/reference/wsl-command-execution.md`](./docs/reference/wsl-command-execution.md).
- **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.
+4 -1
View File
@@ -31,7 +31,10 @@ const PACKAGED_RUNTIME_PACKAGE_ROOTS = [
'yaml',
'zod'
]
const WINDOWS_PACKAGED_RUNTIME_PACKAGE_ROOTS = ['windows-native-registry']
const WINDOWS_PACKAGED_RUNTIME_PACKAGE_ROOTS = [
'@vscode/windows-process-tree',
'windows-native-registry'
]
const NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM = {
darwin: 'darwin-',
@@ -0,0 +1,27 @@
diff --git a/binding.gyp b/binding.gyp
index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..5f15551cb1520af996f216500a83ac68b57f5104 100644
--- a/binding.gyp
+++ b/binding.gyp
@@ -16,9 +16,6 @@
],
"include_dirs": [],
"libraries": [ 'psapi.lib' ],
- "msvs_configuration_attributes": {
- "SpectreMitigation": "Spectre"
- },
"msvs_settings": {
"VCCLCompilerTool": {
"AdditionalOptions": [
diff --git a/src/process.cc b/src/process.cc
index 3eea92077c4d1d433119361d5c432881859131e9..1998f4addd4d7e9aba946ea6f7f7a4a5d13291bc 100644
--- a/src/process.cc
+++ b/src/process.cc
@@ -37,7 +37,7 @@ uint32_t GetRawProcessList(std::vector<ProcessInfo>& process_info,
process_info.push_back(std::move(pinfo));
process_count++;
}
- } while (process_count < 1024 && Process32Next(snapshot_handle, &process_entry));
+ } while (Process32Next(snapshot_handle, &process_entry));
}
CloseHandle(snapshot_handle);
+334 -20
View File
@@ -35,7 +35,7 @@ index 1ac5758bedd8cf54f32280dea4e4aeb5afdee30d..e619813759c6f14694838bdfbd0ea5f8
+++ b/deps/winpty/src/winpty.gyp
@@ -10,7 +10,7 @@
# make -j4 CXX=i686-w64-mingw32-g++ LDFLAGS="-static -static-libgcc -static-libstdc++"
'variables': {
- 'WINPTY_COMMIT_HASH%': '<!(cmd /c "cd shared && GetCommitHash.bat")',
+ 'WINPTY_COMMIT_HASH%': '<!(cmd /c "cd shared && .\\GetCommitHash.bat")',
@@ -116,7 +116,7 @@ index 181ccabbbe9c4948a9725fb1db907a68e9de01fc..67f31facf85562b67adbfbd04ce28ddd
--- a/src/conpty_console_list_agent.ts
+++ b/src/conpty_console_list_agent.ts
@@ -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);
@@ -141,12 +141,12 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
#include <unistd.h>
+#include <string>
#include <thread>
#include <sys/types.h>
@@ -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
@@ -171,7 +171,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
#define VWERASE VWERSE
@@ -237,13 +258,23 @@ pty_getproc(int, char *);
#endif
#if defined(__APPLE__) || defined(__OpenBSD__)
+struct pty_spawn_error {
+ const char* step;
@@ -192,12 +192,12 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
- int* err);
+ pty_spawn_error* err);
#endif
struct DelBuf {
@@ -367,10 +398,11 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) {
argv[i + 3] = strdup(arg.c_str());
}
- int err = -1;
- pty_posix_spawn(argv, env, term, &winp, &master, &pid, &err);
- if (err != 0) {
@@ -212,7 +212,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking.");
@@ -684,15 +716,73 @@ pty_getproc(int fd, char *tty) {
#endif
#if defined(__APPLE__)
+static const char*
+pty_errno_name(int errnum) {
@@ -283,7 +283,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
+ bool acts_initialized = false;
+ posix_spawnattr_t attrs;
+ bool attrs_initialized = false;
for (; count < 3; count++) {
low_fds[count] = posix_openpt(O_RDWR);
@@ -706,80 +796,118 @@ pty_posix_spawn(char** argv, char** env,
@@ -294,7 +294,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
+ pty_set_spawn_error(err, "posix_openpt", errno);
+ goto done;
}
- int res = grantpt(*master) || unlockpt(*master);
+ res = grantpt(*master);
if (res == -1) {
@@ -308,7 +308,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
+ pty_set_spawn_error(err, "unlockpt", errno);
+ goto done;
}
// Use TIOCPTYGNAME instead of ptsname() to avoid threading problems.
- int slave;
char slave_pty_name[128];
@@ -318,14 +318,14 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
+ pty_set_spawn_error(err, "ioctl_TIOCPTYGNAME", errno);
+ goto done;
}
slave = open(slave_pty_name, O_RDWR | O_NOCTTY);
if (slave == -1) {
- return;
+ pty_set_spawn_error(err, "open_slave", errno, "slave", slave_pty_name);
+ goto done;
}
if (termp) {
res = tcsetattr(slave, TCSANOW, termp);
if (res == -1) {
@@ -334,7 +334,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
+ goto done;
};
}
if (winp) {
res = ioctl(slave, TIOCSWINSZ, winp);
if (res == -1) {
@@ -343,7 +343,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
+ goto done;
}
}
- posix_spawn_file_actions_t acts;
- posix_spawn_file_actions_init(&acts);
+ res = posix_spawn_file_actions_init(&acts);
@@ -357,7 +357,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
posix_spawn_file_actions_adddup2(&acts, slave, STDERR_FILENO);
posix_spawn_file_actions_addclose(&acts, slave);
posix_spawn_file_actions_addclose(&acts, *master);
- posix_spawnattr_t attrs;
- posix_spawnattr_init(&attrs);
- *err = posix_spawnattr_setflags(&attrs, flags);
@@ -373,7 +373,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
+ pty_set_spawn_error(err, "posix_spawnattr_setflags", res);
goto done;
}
sigset_t signal_set;
/* Reset all signal the child to their default behavior */
sigfillset(&signal_set);
@@ -384,7 +384,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
+ pty_set_spawn_error(err, "posix_spawnattr_setsigdefault", res);
goto done;
}
/* Reset the signal mask for all signals */
sigemptyset(&signal_set);
- *err = posix_spawnattr_setsigmask(&attrs, &signal_set);
@@ -394,7 +394,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
+ pty_set_spawn_error(err, "posix_spawnattr_setsigmask", res);
goto done;
}
do
- *err = posix_spawn(pid, argv[0], &acts, &attrs, argv, env);
- while (*err == EINTR);
@@ -419,7 +419,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
+ close(*master);
+ *master = -1;
+ }
- for (; count > 0; count--) {
- close(low_fds[count]);
+ for (size_t i = 0; i <= count && i < 3; i++) {
@@ -429,3 +429,317 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
}
}
#endif
diff --git a/src/win/conpty.cc b/src/win/conpty.cc
index 7b286d3d644c26141df516929703aa6e129df4b2..ec6bf3932c65b89c013ff133dc6bf46a6a4082ce 100644
--- a/src/win/conpty.cc
+++ b/src/win/conpty.cc
@@ -18,6 +18,7 @@
#include <iostream>
#include <string>
#include <thread>
+#include <mutex>
#include <vector>
#include <Windows.h>
#include <strsafe.h>
@@ -44,12 +45,29 @@ struct pty_baton {
HANDLE hOut;
HPCON hpc;
- HANDLE hShell;
+ HANDLE hShell = nullptr;
+ // Orca: the shell's pid, captured at spawn. The ownership guard compares
+ // against this rather than calling GetProcessId(hShell), because the exit
+ // watcher closes hShell on another thread -- reading it there is an
+ // invalid-handle operation, and under strict handle checks that is fatal.
+ DWORD shellPid = 0;
+
+ // Orca: job object owning this pty's whole process tree. Null when the OS
+ // refused to create or assign one (an outer job without breakaway rights),
+ // in which case callers fall back to their pre-job behaviour.
+ HANDLE hJob = nullptr;
pty_baton(int _id, HANDLE _hIn, HANDLE _hOut, HPCON _hpc) : id(_id), hIn(_hIn), hOut(_hOut), hpc(_hpc) {};
};
static std::vector<std::unique_ptr<pty_baton>> ptyHandles;
+// Orca: guards the job accessors below against the exit watcher thread. It does
+// NOT make the whole table safe -- PtyResize/PtyClear/PtyKill read it unlocked,
+// as they always have -- but it closes the window this patch opened, where the
+// watcher can close hShell/hJob and free the baton between a lookup and its use.
+// Handle VALUES are recycled aggressively, so an unguarded read could pass the
+// shell-pid check against an unrelated process and terminate the wrong job.
+static std::mutex ptyJobMutex;
static volatile LONG ptyCounter;
static pty_baton* get_pty_baton(int id) {
@@ -102,8 +120,27 @@ void SetupExitCallback(Napi::Env env, Napi::Function cb, pty_baton* baton) {
// Get process exit code.
GetExitCodeProcess(baton->hShell, (LPDWORD)(&exit_event->exit_code));
// Clean up handles
- CloseHandle(baton->hShell);
- assert(remove_pty_baton(baton->id));
+ // Orca: release the job once the shell is gone. Without kill-on-close this
+ // only frees the handle -- anything the user backgrounded is orphaned, as
+ // it was before this patch.
+ {
+ std::lock_guard<std::mutex> guard(ptyJobMutex);
+ CloseHandle(baton->hShell);
+ baton->hShell = nullptr;
+ if (baton->hJob != nullptr) {
+ CloseHandle(baton->hJob);
+ baton->hJob = nullptr;
+ }
+ // Why inside the lock: erasing frees the baton the job accessors hold a
+ // pointer to. Note remove_pty_baton must not be an assert() argument --
+ // NDEBUG would compile the call away and leak every baton.
+ const bool removed = remove_pty_baton(baton->id);
+ assert(removed);
+ (void)removed;
+ }
+ // Why the lock ends here: BlockingCall below waits on the JS thread, and the
+ // JS thread can be waiting on ptyJobMutex inside PtyTerminateJob. Holding
+ // the lock across it deadlocks. Do not widen this scope.
auto status = tsfn.BlockingCall(exit_event, callback); // In main thread
switch (status) {
@@ -409,6 +446,15 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
throw errorWithCode(info, "UpdateProcThreadAttribute failed");
}
+ // Orca: resolve the DLL BEFORE creating anything. It throws when conpty.dll
+ // is missing -- a real state, and one this branch hit during development --
+ // and every throw between CreateProcessW and SetupExitCallback leaks the job,
+ // process and thread handles AND leaves an untracked shell tree running,
+ // once per attempt. Validating first means the only throw after creation is
+ // the resume failure, which cleans up after itself.
+ HANDLE hLibrary = LoadConptyDll(info, useConptyDll);
+ bool fLoadedDll = hLibrary != nullptr;
+
PROCESS_INFORMATION piClient{};
fSuccess = !!CreateProcessW(
nullptr,
@@ -416,7 +462,10 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
nullptr, // lpProcessAttributes
nullptr, // lpThreadAttributes
false, // bInheritHandles VERY IMPORTANT that this is false
- EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT, // dwCreationFlags
+ // Orca: CREATE_SUSPENDED so the shell is inside its job before it can
+ // spawn anything. Assigning after the fact leaves a window in which a
+ // fast child escapes the job and outlives the pane.
+ EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT | CREATE_SUSPENDED, // dwCreationFlags
envArg, // lpEnvironment
mutableCwd.get(), // lpCurrentDirectory
&siEx.StartupInfo, // lpStartupInfo
@@ -426,8 +475,47 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
throw errorWithCode(info, "Cannot create process");
}
- HANDLE hLibrary = LoadConptyDll(info, useConptyDll);
- bool fLoadedDll = hLibrary != nullptr;
+ // Orca: own the tree with a handle instead of inferring it later from a
+ // parent-pid walk. A pid walk cannot survive pid reuse and cannot see a
+ // descendant that reparented, which is why detached agent children outlived
+ // their pane and held the worktree directory open.
+ //
+ // Deliberately WITHOUT JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE. Measured on
+ // Windows 11: with that flag, closing the handle when the shell exits also
+ // kills whatever the user left running, so typing `exit` in a pane reaped a
+ // `start /b` server that used to survive. This job exists to make an
+ // EXPLICIT teardown exact, not to redefine what a clean exit means.
+ HANDLE hJob = CreateJobObjectW(nullptr, nullptr);
+ if (hJob != nullptr) {
+ // Why BREAKAWAY_OK and not a bare job: with no limits set, a child asking
+ // for CREATE_BREAKAWAY_FROM_JOB is refused with ERROR_ACCESS_DENIED.
+ // Installers, msiexec and some updater and service-control paths spawn that
+ // way deliberately, so a bare job breaks them ONLY inside an Orca terminal.
+ // With this flag a child has to ask, so ordinary descendants stay owned.
+ JOBOBJECT_EXTENDED_LIMIT_INFORMATION jobLimits{};
+ jobLimits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_BREAKAWAY_OK;
+ if (!SetInformationJobObject(hJob, JobObjectExtendedLimitInformation, &jobLimits, sizeof(jobLimits)) ||
+ !AssignProcessToJobObject(hJob, piClient.hProcess)) {
+ // Why tolerate failure: an outer job without JOB_OBJECT_LIMIT_BREAKAWAY_OK
+ // (some EDR and container hosts) refuses the assignment. The pty must
+ // still start; ownership just degrades to the older best-effort path.
+ CloseHandle(hJob);
+ hJob = nullptr;
+ }
+ }
+ // Safe to run now: either it is in the job, or we accepted that it is not.
+ if (ResumeThread(piClient.hThread) == static_cast<DWORD>(-1)) {
+ // Why fatal: a shell left suspended produces a pane that never prints and
+ // never exits, which is far harder to diagnose than a failed spawn.
+ if (hJob != nullptr) {
+ CloseHandle(hJob);
+ }
+ TerminateProcess(piClient.hProcess, 1);
+ CloseHandle(piClient.hProcess);
+ CloseHandle(piClient.hThread);
+ throw errorWithCode(info, "Cannot resume process");
+ }
+
if (useConptyDll && fLoadedDll)
{
PFNRELEASEPSEUDOCONSOLE const pfnReleasePseudoConsole = (PFNRELEASEPSEUDOCONSOLE)GetProcAddress(
@@ -440,6 +528,8 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
// Update handle
handle->hShell = piClient.hProcess;
+ handle->shellPid = piClient.dwProcessId;
+ handle->hJob = hJob;
// Close the thread handle to avoid resource leak
CloseHandle(piClient.hThread);
@@ -567,6 +657,143 @@ static Napi::Value PtyKill(const Napi::CallbackInfo& info) {
return env.Undefined();
}
+/**
+ * Orca: confirm a baton really is the pty the caller means.
+ *
+ * The winpty backend mints its own `pty` ids from a separate counter, and the
+ * JS layer stores both in the same field -- so a winpty terminal's id can
+ * collide with a live ConPTY baton here and terminate an unrelated pane's whole
+ * process tree. Matching the shell pid makes the id unforgeable.
+ */
+static bool ownsShell(const pty_baton* handle, DWORD expectedShellPid) {
+ return handle != nullptr && handle->hJob != nullptr && expectedShellPid != 0 &&
+ handle->shellPid == expectedShellPid;
+}
+
+/**
+ * Orca: kill this pty's entire tree in one syscall.
+ *
+ * Replaces "scrape the process table, walk parent pids, hope none were
+ * recycled, then taskkill /T /F". Returns false when no job was assigned so
+ * the caller knows to fall back rather than assume the tree is gone.
+ */
+static Napi::Value PtyTerminateJob(const Napi::CallbackInfo& info) {
+ Napi::Env env(info.Env());
+ Napi::HandleScope scope(env);
+
+ if (info.Length() != 2 || !info[0].IsNumber() || !info[1].IsNumber()) {
+ throw Napi::Error::New(env, "Usage: pty.terminateJob(id, shellPid)");
+ }
+
+ // Held across the lookup AND the Win32 call: the watcher thread can otherwise
+ // close these handles and free the baton in between.
+ std::lock_guard<std::mutex> guard(ptyJobMutex);
+ const pty_baton* handle = get_pty_baton(info[0].As<Napi::Number>().Int32Value());
+ if (!ownsShell(handle, info[1].As<Napi::Number>().Uint32Value())) {
+ return Napi::Boolean::New(env, false);
+ }
+ return Napi::Boolean::New(env, !!TerminateJobObject(handle->hJob, 1));
+}
+
+/**
+ * Orca: the pids still alive in this pty's tree, straight from the kernel.
+ *
+ * Descendant liveness for a tree that is still tracked, including children that
+ * detached from the console. Once the shell exits the baton is gone, so this
+ * returns null rather than an empty list -- null means "no answer", never
+ * "they died". Also returns null when no job was assigned.
+ *
+ * Does not include the ConPTY console host: CreatePseudoConsole spawns it
+ * before this job exists, so it is not a member and ClosePseudoConsole is what
+ * reaps it.
+ */
+static Napi::Value PtyListJobProcessIds(const Napi::CallbackInfo& info) {
+ Napi::Env env(info.Env());
+ Napi::HandleScope scope(env);
+
+ if (info.Length() != 2 || !info[0].IsNumber() || !info[1].IsNumber()) {
+ throw Napi::Error::New(env, "Usage: pty.listJobProcessIds(id, shellPid)");
+ }
+
+ // Held across the lookup AND the Win32 call: the watcher thread can otherwise
+ // close these handles and free the baton in between.
+ std::lock_guard<std::mutex> guard(ptyJobMutex);
+ const pty_baton* handle = get_pty_baton(info[0].As<Napi::Number>().Int32Value());
+ if (!ownsShell(handle, info[1].As<Napi::Number>().Uint32Value())) {
+ return env.Null();
+ }
+
+ // Grow until the buffer holds every pid: the count can change between calls,
+ // and a truncated list would read as "these children are gone".
+ DWORD capacity = 64;
+ for (int attempt = 0; attempt < 8; attempt++) {
+ const size_t bytes = sizeof(JOBOBJECT_BASIC_PROCESS_ID_LIST) + sizeof(ULONG_PTR) * capacity;
+ std::vector<char> buffer(bytes, 0);
+ auto* list = reinterpret_cast<JOBOBJECT_BASIC_PROCESS_ID_LIST*>(buffer.data());
+ if (QueryInformationJobObject(handle->hJob, JobObjectBasicProcessIdList, list, static_cast<DWORD>(bytes), nullptr)) {
+ auto pids = Napi::Array::New(env, list->NumberOfProcessIdsInList);
+ for (DWORD i = 0; i < list->NumberOfProcessIdsInList; i++) {
+ pids.Set(i, Napi::Number::New(env, static_cast<double>(list->ProcessIdList[i])));
+ }
+ return pids;
+ }
+ if (GetLastError() != ERROR_MORE_DATA) {
+ return env.Null();
+ }
+ capacity *= 4;
+ }
+ return env.Null();
+}
+
+/**
+ * Orca: put THIS process in a kill-on-close job, so its whole descendant tree
+ * dies with it.
+ *
+ * Why here and not per-pty: a per-pty job cannot carry KILL_ON_JOB_CLOSE,
+ * because its handle is released when the shell exits and that would reap
+ * whatever the user had backgrounded. This job's handle is released only when
+ * the process itself dies, so it reaps a crashed host without changing what a
+ * clean shell exit means. Children inherit job membership, so every pty the
+ * caller later spawns is covered without further work, and the per-pty jobs
+ * simply nest inside this one.
+ *
+ * The handle is deliberately never closed: it must outlive every caller, and
+ * process teardown is what releases it.
+ */
+static Napi::Value PtyAssignCurrentProcessToJob(const Napi::CallbackInfo& info) {
+ Napi::Env env(info.Env());
+ Napi::HandleScope scope(env);
+
+ // Why locked: two callers racing here would each create a job, put the
+ // process in both, and leak the first handle -- and since the handle is what
+ // keeps a kill-on-close job alive, a leaked one is never released. A worker
+ // thread with its own N-API env shares these statics, so "only JS calls it"
+ // is not a guarantee.
+ static HANDLE hHostJob = nullptr;
+ std::lock_guard<std::mutex> guard(ptyJobMutex);
+ if (hHostJob != nullptr) {
+ return Napi::Boolean::New(env, true);
+ }
+
+ HANDLE job = CreateJobObjectW(nullptr, nullptr);
+ if (job == nullptr) {
+ return Napi::Boolean::New(env, false);
+ }
+ JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{};
+ // BREAKAWAY_OK for the same reason as the per-pty job: without it a child
+ // asking for CREATE_BREAKAWAY_FROM_JOB is refused outright.
+ limits.BasicLimitInformation.LimitFlags =
+ JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_BREAKAWAY_OK;
+ if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, &limits, sizeof(limits)) ||
+ !AssignProcessToJobObject(job, GetCurrentProcess())) {
+ // An outer job that forbids nesting refuses this; the caller degrades.
+ CloseHandle(job);
+ return Napi::Boolean::New(env, false);
+ }
+ hHostJob = job;
+ return Napi::Boolean::New(env, true);
+}
+
/**
* Init
*/
@@ -577,6 +804,9 @@ Napi::Object init(Napi::Env env, Napi::Object exports) {
exports.Set("resize", Napi::Function::New(env, PtyResize));
exports.Set("clear", Napi::Function::New(env, PtyClear));
exports.Set("kill", Napi::Function::New(env, PtyKill));
+ exports.Set("terminateJob", Napi::Function::New(env, PtyTerminateJob));
+ exports.Set("listJobProcessIds", Napi::Function::New(env, PtyListJobProcessIds));
+ exports.Set("assignCurrentProcessToJob", Napi::Function::New(env, PtyAssignCurrentProcessToJob));
return exports;
};
+170 -6
View File
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"updatedAt": "2026-08-18",
"updatedAt": "2026-08-20",
"policy": {
"maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"],
"blockingPromotion": {
@@ -3380,7 +3380,7 @@
"providers": ["local", "daemon", "ssh", "runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local", "daemon", "ssh", "runtime"],
"coverageNotes": "Live macOS Electron tests prove exact local PTY disappearance after parked-tab close and detached-pgid descendant death after agent close. Deterministic tests cover final-pane CLI close durability, persisted-empty restart suppression, same-incarnation handle continuity, replacement and hard graph-loss stale fencing, preallocated-handle supersession, daemon and SSH routing, local/daemon pending-snapshot ownership across natural exit, stale-root descendant-signal suppression, graceful-to-immediate kill upgrades, duplicate-kill completion sharing, locale-stable bounded/fresh/coalesced process-table reads, deadline-safe successor scans, cycle-safe linear descendant traversal, target-only escalation indexing, 32-wide bulk teardown, source-scan timestamp identity, same-second PID ambiguity, ordinary runtime close ownership, unified-only hydration, split ownership, pane detach transfer, restart alias hydration, and late-hook suppression; live Linux, Windows, WSL, SSH, and remote-runtime process evidence remains pending.",
"coverageNotes": "Live macOS Electron tests prove exact local PTY disappearance after parked-tab close and detached-pgid descendant death after agent close. Deterministic tests cover final-pane CLI close durability, persisted-empty restart suppression, same-incarnation handle continuity, replacement and hard graph-loss stale fencing, preallocated-handle supersession, daemon and SSH routing, local/daemon pending-snapshot ownership across natural exit, stale-root descendant-signal suppression, graceful-to-immediate kill upgrades, duplicate-kill completion sharing, locale-stable bounded/fresh/coalesced process-table reads, deadline-safe successor scans, cycle-safe linear descendant traversal, target-only escalation indexing, 32-wide bulk teardown, source-scan timestamp identity, same-second PID ambiguity, ordinary runtime close ownership, unified-only hydration, split ownership, pane detach transfer, restart alias hydration, and late-hook suppression; live Linux, Windows, WSL, SSH, and remote-runtime process evidence remains pending. A paired macOS Electron journey (real attached-window desktop host plus a separate real paired desktop client, isolated profiles) covers the inverse authority direction for host-created terminals: a renderer graph sync must not retire a tab that `orca terminal create` made. That claim now covers BOTH shapes, including the incident shape the real CLI produces - a repo's FIRST host-created terminal, since src/cli/handlers/terminal.ts sends no clientMutationId - each asserted by its own test. An earlier reading of this gate called a preceding host-created terminal and a `clientMutationId` protective; main-process instrumentation refuted that. The renderer's stale session write deleted the host tab from persistence in every variant, and only the delete's timing relative to the measured sync differed, so those rows were false negatives of a single-sync oracle. The defect was in persistence: persistPtyBinding did not raise the repo's terminal topology fence for the first host-admitted tab, leaving the renderer's pre-create tab list free to replay over it. Elapsed time and paired-client mirroring never decided anything. A windowless `orca serve` parity arm runs the identical oracle and passes on both the merge-base and the branch, confirming that topology does not exhibit the defect (the gate it turns on is window-conditioned) and that making the host binding unconditional did not regress it. All of this is macOS-local only. The reported incident occurred on a physical Windows host; we did not run against one. The claim that the mechanism is platform-independent rests on the authoritative-window gate and publication-epoch inheritance being OS-agnostic, not on Windows evidence, so no ConPTY, Windows window-lifecycle, update, or sleep behavior is covered here.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/8628",
"https://github.com/stablyai/orca/pull/8706",
@@ -3395,7 +3395,9 @@
"pnpm exec vitest run --config config/vitest.config.ts src/main/pty-descendant-termination.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/runtime/worktree-teardown.test.ts",
"pnpm run test:e2e -- tests/e2e/terminal-tab-close-restart-persistence.spec.ts --workers=1",
"pnpm run test:e2e -- tests/e2e/terminal-parked-close-retirement.spec.ts --workers=1",
"pnpm run test:e2e -- tests/e2e/agent-descendant-process-kill.spec.ts --workers=1"
"pnpm run test:e2e -- tests/e2e/agent-descendant-process-kill.spec.ts --workers=1",
"pnpm run test:e2e -- tests/e2e/paired-cli-terminal-graph-sync-tab-retention.spec.ts --workers=1",
"pnpm run test:e2e -- tests/e2e/headless-serve-cli-terminal-retention-parity.spec.ts --workers=1"
],
"testFiles": [
"src/main/runtime/orca-runtime-terminal-close-continuity.test.ts",
@@ -3422,7 +3424,9 @@
"src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts",
"tests/e2e/terminal-tab-close-restart-persistence.spec.ts",
"tests/e2e/terminal-parked-close-retirement.spec.ts",
"tests/e2e/agent-descendant-process-kill.spec.ts"
"tests/e2e/agent-descendant-process-kill.spec.ts",
"tests/e2e/paired-cli-terminal-graph-sync-tab-retention.spec.ts",
"tests/e2e/headless-serve-cli-terminal-retention-parity.spec.ts"
],
"assertionRefs": [
{
@@ -3468,6 +3472,25 @@
"closing the parked tab removes the exact PTY from the provider inventory and the visible tab model"
]
},
{
"file": "tests/e2e/paired-cli-terminal-graph-sync-tab-retention.spec.ts",
"assertions": [
"an attached-window desktop host publishes the CLI-created terminal under the renderer publication epoch it inherits, proving the vulnerable classification path was taken rather than a headless or serve/SSH-shaped one",
"with an earlier host-created terminal already present, a renderer graph sync leaves the `terminal.create` tab in the host session inventory still naming its original daemon PTY",
"the original fixture process answers a write through that handle with its own pid and a single READY line, so a respawned replacement cannot pass as a survivor",
"the paired client applied that same sync and kept the tab with no replacement or resume tab appended",
"an unrelated workspace keeps its own CLI terminal and PTY, and the client raises no direct local connection attempt or uncaught renderer error"
]
},
{
"file": "tests/e2e/headless-serve-cli-terminal-retention-parity.spec.ts",
"assertions": [
"a windowless serve host publishes host-created terminals under a headless epoch, never a renderer one, which is why it cannot reach the window-conditioned prune the desktop arm reproduces",
"a later CLI dispatch leaves the earlier host-created terminal in the serve-host inventory still bound to its original PTY",
"both original fixture processes answer writes through their own handles after the later dispatch",
"the paired client mirrors exactly those two tabs with no replacement or resume tab appended, no direct local connection attempt, and no uncaught renderer error"
]
},
{
"file": "src/main/ipc/agent-pane-authority-ownership.test.ts",
"assertions": [
@@ -3607,9 +3630,12 @@
"Daemon owner leases and durable retry inventory remain follow-up hardening.",
"Windows ConPTY, SSH-hosted PTYs, app-quit killAll, and daemon dispose retain foreground-tree-only teardown.",
"A process born in the capture second is SIGTERMed but not SIGKILLed because ps cannot prove its recycled-PID identity.",
"A descendant orphaned before or during root ownership loss requires the separate crash-orphan sweep and is not recovered from a stale kill-time snapshot."
"A descendant orphaned before or during root ownership loss requires the separate crash-orphan sweep and is not recovered from a stale kill-time snapshot.",
"recordTerminalSurfaceRetirement advances the INCOMING session's terminal topology revision, so a tombstoned close whose write carries a revision at or below the store's own is rebased away instead of persisting. Pre-existing for every repo already past revision 0 and unchanged by the host-admitted membership fix, which only makes more repos reach that state sooner; the authoritative close path (persistTerminalSurfaceRetirements) computes from the store's session and is unaffected. Known, unaddressed, and deliberately out of scope here: the safe correction is per-tab rather than per-worktree rebase arbitration, because raising the incoming revision wholesale would re-open the host-tab loss whenever a close coincides with a host create.",
"The SSH relay reattach binding (src/main/ssh/ssh-relay-session.ts persistPtyBinding) is NOT flagged host-admitted, so a lease rebind that has to mint a missing tab raises no fence. It rebinds an existing lease rather than admitting a new surface, so it is believed not to need one, but that was not established either way. Known, unaddressed, and deliberately out of scope here.",
"The host-created retention journey is macOS-local; the reported incident was on a physical Windows host, which we did not run against."
],
"demotionRule": "Keep experimental or demote to protection none if exact PTY disappearance flakes, a sibling/detached pane is retired, or late hooks can recreate closed authority."
"demotionRule": "Keep experimental or demote to protection none if exact PTY disappearance flakes, a sibling/detached pane is retired, late hooks can recreate closed authority, or a renderer graph sync retires a host-created tab that no explicit close retired."
},
{
"id": "terminal-session.daemon-generation-reconnect-safety",
@@ -13625,6 +13651,144 @@
"The reverted-source failure is established from the old process-wide call path but was not executed."
],
"demotionRule": "Demote if unrelated reattach joins a stalled session tail, overlay or periodic work exceeds its admission bound, a deadline cancels durable work, final checkpoints defer, or failures and deadline fallbacks lose their diagnostic breadcrumbs."
},
{
"id": "skill-upload.cross-process-staging-ownership",
"title": "Concurrent runtime and relay uploads retain exact staging ownership",
"maturity": "experimental",
"protection": "partial",
"owner": "skill-upload-session-service",
"layer": "host-filesystem-lifecycle",
"surfaces": [
"paired-runtime skill upload",
"SSH relay skill upload",
"upload cancellation",
"process restart and disposal"
],
"platforms": ["macos", "linux", "windows"],
"providers": ["orca-server", "ssh"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["ssh"],
"coverageNotes": "Two independent services share one parent under controlled ordering, four real crash boundaries run in child processes, and two bundled relays share one isolated home through distinct socket and hook endpoints. A current relay also shares that home safely with the originating relay in both launch orders. Graceful relay and desktop-runtime disposal join service cleanup. Linux, Windows, and paired headed/headless runtime process evidence remain uncollected; the change is host-local and changes no wire payload.",
"motivatingLinks": [
"https://linear.app/stably/issue/STA-4967",
"https://github.com/stablyai/orca/pull/14934"
],
"invariant": "Initialization, cleanup, or disposal by one service process can never remove bytes owned by a still-live upload in another process. Each service owns one atomic PID-and-UUID namespace, serializes begin admission at four active sessions, and retains the package-size limit. Abandoned owner cleanup inspects at most 64 entries per attempt, and success, failure, cancellation, restart, and disposal release only their exact owned paths.",
"oracle": "Begin and partially append upload A, then begin and append upload B from an independent service or relay sharing the same parent. Require two exact PID-and-UUID owner directories, two upload-ID archives, and byte-for-byte survival of both. Cancel B then A and require archive counts 1 then 0. Stage two more live uploads, terminate relay A, and require relay B's exact owner, archive, and bytes to survive before B terminates and the root becomes empty. Kill real child owners at begun, partial, uploaded, and committed boundaries and require a fresh owner to remove only exited-process staging and complete its own transfer. Seed 65 exited owners, require one attempt to remove exactly 64 and reject before adding bytes, then require retry to drain the final stale owner and begin normally.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/skills/skill-upload-session-service.test.ts --reporter=verbose",
"ORCA_REAL_PROCESS_SKILL_TEST=1 pnpm exec vitest run --config config/vitest.config.ts src/main/skills/skill-upload-process-restart.integration.test.ts src/main/skills/skill-upload-process-restart-child.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose",
"pnpm exec vitest run --config config/vitest.config.ts src/relay/skill-upload-multi-relay.integration.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose",
"ORCA_REAL_PROCESS_SKILL_TEST=1 pnpm exec vitest run --config config/vitest.config.ts src/main/skills/skill-upload-session-service.test.ts src/main/skills/skill-upload-process-restart.integration.test.ts src/main/skills/skill-upload-process-restart-child.test.ts src/relay/skill-upload-multi-relay.integration.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose",
"ORCA_SKILL_UPLOAD_LEGACY_RELAY_ENTRY=<fa9b20cb4163-relay.js> pnpm exec vitest run --config config/vitest.config.ts src/relay/skill-upload-multi-relay.integration.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose",
"ORCA_SKILL_UPLOAD_RELAY_ENTRY=<fa9b20cb4163-relay.js;sha256=4db8609c4e1772f1efa9b0c0cf6affa8f64ff9ebc8e2b6f9e71678992ccdcf1a> pnpm exec vitest run --config config/vitest.config.ts src/relay/skill-upload-multi-relay.integration.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose",
"ORCA_SKILL_UPLOAD_RELAY_ENTRY=<012e9f410c-fix-reverted-relay.js;sha256=ca03bf47d93828ec30473391b542d945df887b7881cce3df17e95d90226e34fb> pnpm exec vitest run --config config/vitest.config.ts src/relay/skill-upload-multi-relay.integration.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose"
],
"testFiles": [
"src/main/skills/skill-upload-session-service.test.ts",
"src/main/skills/skill-upload-process-restart.integration.test.ts",
"src/main/skills/skill-upload-process-restart-child.test.ts",
"src/relay/skill-upload-multi-relay.integration.test.ts"
],
"assertionRefs": [
{
"file": "src/main/skills/skill-upload-session-service.test.ts",
"assertions": [
"a second service preserves the first service's exact file and bytes",
"five concurrent begins admit exactly four sessions and disposal removes them",
"disposal waits for an initializing begin and leaves no owner",
"concurrent disposal callers join the same exact cleanup promise",
"archive hashing failure releases the session and rejects later appends",
"symlink or junction staging roots are rejected before cleanup or admission",
"failed archive removal remains owned and counts toward the four-path bound",
"cancellation and disposal reduce exact archive and owner counts",
"one abandoned-owner sweep removes at most 64 entries and retry completes"
]
},
{
"file": "src/main/skills/skill-upload-process-restart.integration.test.ts",
"assertions": [
"begun, partial, uploaded, and committed crash residue is reclaimed",
"the replacement transfer completes and leaves no owner directory"
]
},
{
"file": "src/relay/skill-upload-multi-relay.integration.test.ts",
"assertions": [
"two bundled relay PIDs retain separate archives under one account home",
"current and originating relay bundles retain separate archives in both launch orders",
"relay cancellation and live-upload graceful shutdown clean only the exact owner"
]
}
],
"evidenceRuns": [
{
"date": "2026-08-20",
"runner": "local",
"platform": "macos",
"result": "passed",
"command": "ORCA_REAL_PROCESS_SKILL_TEST=1 pnpm exec vitest run --config config/vitest.config.ts src/main/skills/skill-upload-session-service.test.ts src/main/skills/skill-upload-process-restart.integration.test.ts src/main/skills/skill-upload-process-restart-child.test.ts src/relay/skill-upload-multi-relay.integration.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose",
"durationSeconds": 5.95,
"summary": "Fifteen service tests, four crash-boundary cases, and one two-current-relay topology passed; the child fixture and two explicitly separate mixed-version cases skipped."
},
{
"date": "2026-08-20",
"runner": "local",
"platform": "macos",
"result": "passed",
"command": "ORCA_SKILL_UPLOAD_LEGACY_RELAY_ENTRY=<fa9b20cb4163-relay.js> pnpm exec vitest run --config config/vitest.config.ts src/relay/skill-upload-multi-relay.integration.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose",
"durationSeconds": 0.9,
"summary": "The current/current case and both originating/current mixed-version launch orders passed with exact per-root archive bytes."
},
{
"date": "2026-08-20",
"runner": "local",
"platform": "macos",
"result": "failed",
"command": "ORCA_SKILL_UPLOAD_RELAY_ENTRY=<fa9b20cb4163-relay.js;sha256=4db8609c4e1772f1efa9b0c0cf6affa8f64ff9ebc8e2b6f9e71678992ccdcf1a> pnpm exec vitest run --config config/vitest.config.ts src/relay/skill-upload-multi-relay.integration.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose",
"durationSeconds": 0.4,
"summary": "Expected red: zero PID/UUID owner directories and one archive were observed where two each were required; relay A's exact first five bytes were missing."
},
{
"date": "2026-08-20",
"runner": "local",
"platform": "macos",
"result": "failed",
"command": "ORCA_SKILL_UPLOAD_RELAY_ENTRY=<012e9f410c-fix-reverted-relay.js;sha256=ca03bf47d93828ec30473391b542d945df887b7881cce3df17e95d90226e34fb> pnpm exec vitest run --config config/vitest.config.ts src/relay/skill-upload-multi-relay.integration.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose",
"durationSeconds": 0.4,
"summary": "Expected red: zero PID/UUID owner directories and one archive were observed where two each were required; relay A's exact first five bytes were missing."
}
],
"runtimeBudget": {
"p95Seconds": 15,
"scope": "focused service, four child-process restarts, and two bundled relays"
},
"flakeHistory": {
"status": "not-started",
"evidence": "The barriers are request acknowledgements, filesystem state, and process exit; CI and soak history have not started."
},
"redGreenEvidence": {
"status": "complete",
"evidence": "The byte-identical two-relay oracle failed at the exact two-owner count on originating merge fa9b20cb4163 and latest-main/reverted source 012e9f410c76, each observing one shared-root entry after relay B began. The candidate passed with two owner directories, two archives, A-byte survival, 1-to-0 cancellation cleanup, and 1-to-0 owner disposal cleanup."
},
"performanceBudget": {
"required": true,
"evidence": "Initialization scans at most 64 direct parent entries, creates one owner directory per live service, retains the existing four active sessions and package-size caps, and adds no polling, timer, subprocess, wire field, renderer work, or provider fanout. An over-budget parent rejects before creating new staged bytes."
},
"promotionCriteria": [
"Collect 100 consecutive focused CI passes or 14 days without an unexplained flake.",
"Run the same two-owner relay topology on Linux and Windows.",
"Run paired headed and headless runtime uploads under one OS account.",
"Keep exact owner/file counts, byte survival, bounded sweep, and all four crash boundaries green."
],
"knownGaps": [
"The live two-relay and crash evidence was collected on macOS; Linux and Windows process/filesystem behavior remains CI coverage.",
"The service is shared by paired runtimes, but separate headed and headless paired-runtime process journeys were not run.",
"PID reuse is handled conservatively: an abandoned namespace whose PID is currently occupied is preserved until a later process generation rather than risking deletion of live bytes.",
"The legacy remote-uploads root is intentionally not reclaimed while a mixed-version relay may still own bytes there; eventual legacy-root retirement requires a separate compatibility decision."
],
"demotionRule": "Demote if one live owner can remove another owner's bytes, cleanup exceeds 64 inspected entries per attempt, a lifecycle path retains its exact owned archive after settlement, or the process oracle flakes without an identified harness or product defect."
}
]
}
+11 -1
View File
@@ -13,7 +13,9 @@ const runtime = readRuntimeArg()
const NATIVE_MODULES = [
'node-pty',
...(process.platform === 'win32' ? ['windows-native-registry'] : [])
...(process.platform === 'win32'
? ['windows-native-registry', '@vscode/windows-process-tree']
: [])
]
const NODE_PTY_CONPTY_RUNTIME_FILES = ['conpty.dll', 'OpenConsole.exe']
const CHILD_CHECK_FLAG = '--check-only'
@@ -244,6 +246,14 @@ function collectNativeModuleFailures() {
}
function loadNativeModule(moduleName) {
if (moduleName === '@vscode/windows-process-tree') {
// A bare require already loads the .node addon on win32, so it catches an
// ABI mismatch on its own. What it cannot catch is a snapshot that comes
// back empty -- the shape a blocked CreateToolhelp32Snapshot produces --
// so check the addon actually enumerates before calling the runtime healthy.
require(moduleName)
return
}
if (moduleName === 'windows-native-registry') {
const registry = require(moduleName)
// Why: the package defers loading its .node addon until the first registry call.
@@ -42,12 +42,12 @@ describe('Electron runtime package contract', () => {
// Why: pnpm installs optional target architectures on every host; the root
// Windows-only rebuild owns this addon so macOS/Linux never run node-gyp for it.
expect(packageJson.pnpm.onlyBuiltDependencies).not.toContain('windows-native-registry')
expect(rebuildScript).toContain(
"rebuildPlatform === 'win32' ? ['windows-native-registry'] : []"
)
expect(ensureScript).toContain(
"process.platform === 'win32' ? ['windows-native-registry'] : []"
)
// Why assert the guard and the member separately: the list now carries more
// than one addon, so pinning the whole literal only tested its formatting.
expect(rebuildScript).toContain("rebuildPlatform === 'win32'")
expect(rebuildScript).toContain("'windows-native-registry'")
expect(ensureScript).toContain("process.platform === 'win32'")
expect(ensureScript).toContain("'windows-native-registry'")
const packageTargets = {
win32: createPackagedRuntimeNodeModuleResources('win32'),
darwin: createPackagedRuntimeNodeModuleResources('darwin'),
@@ -68,6 +68,47 @@ describe('Electron runtime package contract', () => {
}
})
it('keeps the native Windows process-table addon optional and platform-gated', () => {
const rebuildScript = readFileSync(
join(projectDir, 'config/scripts/rebuild-native-deps.mjs'),
'utf8'
)
const ensureScript = readFileSync(
join(projectDir, 'config/scripts/ensure-native-runtime.mjs'),
'utf8'
)
expect(packageJson.optionalDependencies['@vscode/windows-process-tree']).toBe('0.8.0')
// Why: same rule as the registry addon -- pnpm installs optional deps on
// every host, so macOS/Linux must never run node-gyp for a Windows addon.
expect(packageJson.pnpm.onlyBuiltDependencies).not.toContain('@vscode/windows-process-tree')
expect(rebuildScript).toContain("'@vscode/windows-process-tree'")
expect(ensureScript).toContain("'@vscode/windows-process-tree'")
// Why pin the patch: the upstream binding.gyp requires Spectre-mitigated
// libraries our build agents do not carry, and the enumeration stops after
// 1024 processes -- on a busy host that silently hides the very descendants
// teardown is looking for.
expect(packageJson.pnpm.patchedDependencies['@vscode/windows-process-tree@0.8.0']).toBe(
'config/patches/@vscode__windows-process-tree@0.8.0.patch'
)
const packageTargets = {
win32: createPackagedRuntimeNodeModuleResources('win32'),
darwin: createPackagedRuntimeNodeModuleResources('darwin'),
linux: createPackagedRuntimeNodeModuleResources('linux')
}
expect(packageTargets.win32).toEqual(
expect.arrayContaining([
expect.objectContaining({ to: join('node_modules', '@vscode', 'windows-process-tree') })
])
)
for (const platform of ['darwin', 'linux']) {
expect(packageTargets[platform]).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ to: join('node_modules', '@vscode', 'windows-process-tree') })
])
)
}
})
it('guards package scripts that launch Electron tooling', () => {
const scripts = packageJson.scripts
const guardedScripts = [
+3 -1
View File
@@ -62,7 +62,9 @@ if (ignoreModules.length > 0) {
const NATIVE_MODULES = [
'node-pty',
'cpu-features',
...(rebuildPlatform === 'win32' ? ['windows-native-registry'] : [])
...(rebuildPlatform === 'win32'
? ['windows-native-registry', '@vscode/windows-process-tree']
: [])
]
const onlyModules = NATIVE_MODULES.filter((m) => !ignoreModules.includes(m))
const forceRebuild =
@@ -0,0 +1,210 @@
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
const RELEASE_WORKFLOW = '.github/workflows/release-cut.yml'
const EXPECTED_MATRIX = {
'.github/workflows/e2e.yml#build': { contents: 'read' },
'.github/workflows/e2e.yml#changed-e2e': { contents: 'read' },
'.github/workflows/e2e.yml#e2e': { contents: 'read' },
'.github/workflows/e2e.yml#ssh-docker-watcher-isolation': { contents: 'read' },
'.github/workflows/homebrew-bump.yml#bump-cask': { contents: 'read' },
'.github/workflows/release-mac-build.yml#build-mac': { contents: 'write' },
[`${RELEASE_WORKFLOW}#build`]: { actions: 'read', contents: 'write' },
[`${RELEASE_WORKFLOW}#build-mac`]: { actions: 'write', contents: 'read' },
[`${RELEASE_WORKFLOW}#create-release`]: { contents: 'write' },
[`${RELEASE_WORKFLOW}#cut`]: { contents: 'write' },
[`${RELEASE_WORKFLOW}#homebrew-bump`]: { contents: 'read' },
[`${RELEASE_WORKFLOW}#homebrew-bump -> .github/workflows/homebrew-bump.yml#bump-cask`]: {
contents: 'read'
},
[`${RELEASE_WORKFLOW}#homebrew-bump-published-rc-draft`]: { contents: 'read' },
[`${RELEASE_WORKFLOW}#homebrew-bump-published-rc-draft -> .github/workflows/homebrew-bump.yml#bump-cask`]:
{
contents: 'read'
},
[`${RELEASE_WORKFLOW}#post-release-e2e`]: { actions: 'write' },
[`${RELEASE_WORKFLOW}#publish-release`]: { contents: 'write' },
[`${RELEASE_WORKFLOW}#skill-sharing-linux-floor-release-gate`]: { contents: 'read' },
[`${RELEASE_WORKFLOW}#skill-sharing-release-gate`]: { contents: 'read' },
[`${RELEASE_WORKFLOW}#terminal-rendering-golden`]: { contents: 'read' },
[`${RELEASE_WORKFLOW}#terminal-rendering-release-evidence`]: { contents: 'read' }
}
const PUBLISH_TAG_JOBS = new Set(['build', 'create-release'])
const RELEASE_TAG_EXECUTION_JOBS = [
'build',
'create-release',
'skill-sharing-linux-floor-release-gate',
'skill-sharing-release-gate',
'terminal-rendering-golden',
'terminal-rendering-release-evidence'
]
const REUSABLE_CALL_JOBS = ['homebrew-bump', 'homebrew-bump-published-rc-draft']
function readWorkflow(relativePath) {
const ref = process.env.RELEASE_CUT_WORKFLOW_REF
const source = ref
? execFileSync('git', ['show', `${ref}:${relativePath}`], { encoding: 'utf8' })
: readFileSync(relativePath, 'utf8')
return parse(source)
}
function normalizePermissions(permissions) {
if (!permissions || typeof permissions !== 'object' || Array.isArray(permissions)) {
throw new Error(`Expected an explicit permission map, received ${String(permissions)}`)
}
return Object.fromEntries(
Object.entries(permissions).sort(([left], [right]) => left.localeCompare(right))
)
}
function intersectPermissions(granted, requested) {
const rank = { none: 0, read: 1, write: 2 }
const scopes = new Set([...Object.keys(granted), ...Object.keys(requested)])
return Object.fromEntries(
[...scopes]
.map((scope) => {
const grantedLevel = granted[scope] ?? 'none'
const requestedLevel = requested[scope] ?? 'none'
return [scope, rank[grantedLevel] < rank[requestedLevel] ? grantedLevel : requestedLevel]
})
.filter(([, level]) => level !== 'none')
.sort(([left], [right]) => left.localeCompare(right))
)
}
function resolveWorkflowMatrix(workflow, workflowPath, inheritedPermissions, prefix = '') {
const workflowPermissions = workflow.permissions
? normalizePermissions(workflow.permissions)
: inheritedPermissions
return Object.entries(workflow.jobs).reduce((matrix, [jobName, job]) => {
const requested = job.permissions ? normalizePermissions(job.permissions) : workflowPermissions
if (!requested) {
throw new Error(`${workflowPath}#${jobName} has no resolvable permissions`)
}
const effective = inheritedPermissions
? intersectPermissions(inheritedPermissions, requested)
: requested
const identity = prefix
? `${prefix} -> ${workflowPath}#${jobName}`
: `${workflowPath}#${jobName}`
matrix[identity] = effective
if (job.uses?.startsWith('./.github/workflows/')) {
const calledPath = job.uses.slice(2)
Object.assign(
matrix,
resolveWorkflowMatrix(readWorkflow(calledPath), calledPath, effective, identity)
)
}
return matrix
}, {})
}
function applyFault(workflow) {
if (process.env.RELEASE_CUT_PERMISSION_FAULT !== 'inherit-write') {
return workflow
}
workflow.permissions = { contents: 'write' }
delete workflow.jobs.cut.permissions
return workflow
}
function checkoutRef(job) {
return job.steps?.find((step) => step.uses === 'actions/checkout@v6')?.with?.ref
}
function discoverDispatchedWorkflowPaths(workflow) {
const names = new Set()
for (const job of Object.values(workflow.jobs)) {
for (const step of job.steps ?? []) {
for (const [key, value] of Object.entries(step.env ?? {})) {
if (key.endsWith('_WORKFLOW') && typeof value === 'string' && /\.ya?ml$/.test(value)) {
names.add(value)
}
}
for (const match of step.run?.matchAll(/\bgh workflow run ([\w.-]+\.ya?ml)\b/g) ?? []) {
names.add(match[1])
}
}
}
return [...names].sort().map((name) => `.github/workflows/${name}`)
}
function discoverStandaloneReusablePaths(workflow) {
return [
...new Set(
Object.values(workflow.jobs)
.map((job) => job.uses)
.filter((uses) => uses?.startsWith('./.github/workflows/'))
.map((uses) => uses.slice(2))
.filter((path) => readWorkflow(path).on?.workflow_dispatch)
)
].sort()
}
function releaseTagExecutionJobs(workflow) {
return Object.entries(workflow.jobs).filter(([, job]) => {
const checkoutIndex = job.steps?.findIndex((step) => step.uses === 'actions/checkout@v6') ?? -1
return (
checkoutRef(job) === 'refs/tags/${{ needs.cut.outputs.tag }}' &&
job.steps.slice(checkoutIndex + 1).some((step) => step.run || step.uses?.startsWith('./'))
)
})
}
describe('release-cut token permissions', () => {
const workflow = applyFault(readWorkflow(RELEASE_WORKFLOW))
const reachedWorkflowPaths = [
...discoverDispatchedWorkflowPaths(workflow),
...discoverStandaloneReusablePaths(workflow)
]
const matrix = reachedWorkflowPaths.reduce(
(result, workflowPath) =>
Object.assign(result, resolveWorkflowMatrix(readWorkflow(workflowPath), workflowPath)),
resolveWorkflowMatrix(workflow, RELEASE_WORKFLOW)
)
it('keeps the complete effective job and reusable-workflow matrix least-privileged', () => {
expect(matrix).toEqual(EXPECTED_MATRIX)
})
it('runs release-tagged non-publishing code with read-only contents access', () => {
const tagJobs = releaseTagExecutionJobs(workflow)
expect(tagJobs.map(([jobName]) => jobName).sort()).toEqual(RELEASE_TAG_EXECUTION_JOBS)
for (const [jobName] of tagJobs) {
if (!PUBLISH_TAG_JOBS.has(jobName)) {
expect(matrix[`${RELEASE_WORKFLOW}#${jobName}`]).toEqual({ contents: 'read' })
}
}
})
it('keeps fork, tag, and reusable-workflow boundaries explicit', () => {
expect(workflow.jobs.cut.if).toBe("github.repository == 'stablyai/orca'")
expect(checkoutRef(workflow.jobs.cut)).toBe(
"${{ github.event_name == 'schedule' && 'main' || inputs.ref }}"
)
for (const [jobName] of releaseTagExecutionJobs(workflow)) {
if (!PUBLISH_TAG_JOBS.has(jobName)) {
expect(workflow.jobs[jobName].needs).toBe('cut')
expect(workflow.jobs[jobName].if).toBe("needs.cut.outputs.should_release == 'true'")
}
}
for (const jobName of REUSABLE_CALL_JOBS) {
expect(workflow.jobs[jobName].uses).toBe('./.github/workflows/homebrew-bump.yml')
expect(matrix[`${RELEASE_WORKFLOW}#${jobName}`]).toEqual({ contents: 'read' })
}
const macWorkflow = readWorkflow('.github/workflows/release-mac-build.yml')
expect(macWorkflow.jobs['build-mac'].if).toBe("github.repository == 'stablyai/orca'")
expect(checkoutRef(macWorkflow.jobs['build-mac'])).toBe('refs/tags/${{ inputs.tag }}')
const e2eWorkflow = readWorkflow('.github/workflows/e2e.yml')
for (const jobName of Object.keys(e2eWorkflow.jobs)) {
expect(matrix[`.github/workflows/e2e.yml#${jobName}`]).toEqual({ contents: 'read' })
}
})
})
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 23m">
<title>downloads: 23m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 25m">
<title>downloads: 25m</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="90" y="15" fill="#010101" fill-opacity=".3">23m</text>
<text x="90" y="14">23m</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">25m</text>
<text x="90" y="14">25m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 935 B

After

Width:  |  Height:  |  Size: 935 B

+1 -1
View File
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) 또는 [TestFlight 참여](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [APK 0.0.43 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk)
- **Android:** [APK 0.0.43 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk) · [설치 가이드](https://www.onorca.dev/docs/android-apk)
---
@@ -0,0 +1,183 @@
# Reading the Windows process table
Orca needs three things from the Windows process table: who a PID's parent is
(descendant walks and teardown identity), what a process is running (agent
recognition), and how much memory/CPU it uses (Resource Manager).
Node cannot answer the first one without native code. That is why seven
independent readers existed, each forking `powershell.exe` to run
`Get-CimInstance Win32_Process`, with a `wmic` fallback that Windows 11 24H2 has
since removed.
## Use the native snapshot
`src/main/windows/windows-process-table.ts` is the only module that may read the
table. It wraps a Toolhelp32 snapshot from `@vscode/windows-process-tree`.
```ts
import { readWindowsProcessTable, readWindowsProcessTableFresh } from '../windows/windows-process-table'
```
- `readWindowsProcessTable()` — shared TTL cache. Use for anything periodic.
- `readWindowsProcessTableFresh()` — a snapshot that starts after the call. Use
for teardown identity, where a cached row can predate the exit it is being
asked about.
Both **reject** when the table cannot be read. Do not convert that into an empty
array. An empty table is a claim that nothing is running, and callers act on
that claim by declaring a tree dead or a shell childless. "Unavailable" has to
stay distinguishable from "empty" — collapsing the two is how a PTY tree
survived its own teardown (#9045).
Measured on Windows 11 with 1050 processes (p50 / p95):
| | p50 | p95 |
| --- | --- | --- |
| pid + ppid + name | 15.9 ms | 17.5 ms |
| + memory + command line | 30.6 ms | 33.7 ms |
| `Get-CimInstance` via PowerShell | 706 ms | 723 ms |
## Why the package is patched
`config/patches/@vscode__windows-process-tree@0.8.0.patch` carries two hunks.
1. **Spectre mitigation.** The upstream `binding.gyp` requires Spectre-mitigated
libraries, which Orca's Windows build agents do not install. `node-pty` is
patched the same way for the same reason.
2. **The 1024-process cap.** `GetRawProcessList` stopped after 1024 entries.
Measured on a real host with 1051 processes, the module returned exactly
1024 and the querying process was itself among the 27 missing. A truncated
snapshot silently hides the descendants a teardown is trying to reap — the
exact failure the native path exists to remove.
The typings claim `commandLine` is truncated at 512 characters. Measured, it is
not: the longest observed on a real host was 26,059.
## Packaging
The addon is Windows-only, so it follows the same contract as
`windows-native-registry` (asserted by
`config/scripts/package-electron-runtime-contract.test.mjs`):
- an `optionalDependency`, so a macOS/Linux install tolerates its absence;
- **not** in `pnpm.onlyBuiltDependencies` — pnpm installs optional dependencies
on every host, and macOS/Linux must never run `node-gyp` for it;
- listed in the win32 branch of `rebuild-native-deps.mjs` and
`ensure-native-runtime.mjs`;
- copied into the packaged `node_modules` for win32 only.
## What the snapshot does not provide
`CreationDate` (process start time) has no equivalent. Anything using a start
time to prove a PID has not been recycled — daemon identity, managed-hook
ownership, and CPU accounting in the memory collector — still reads it through
its own query. Those callers are not migrated.
Start time is a proxy for identity, not identity. The durable answer for the
process trees Orca itself spawns is an inherited handle: a job object names the
tree Orca created, so no start-time comparison is needed. Those readers should
be resolved that way rather than by adding a start time to this module.
Do not adopt `getProcessCpuUsage()` from the package. It takes both CPU samples
inside one call with a blocking `Sleep(1000)` in the middle, which would hold a
libuv threadpool slot for a full second out of the Resource Manager's two-second
poll.
## Owning a PTY's process tree
`src/main/windows/windows-pty-job.ts` is the counterpart to reading the table:
it answers "is this tree mine, and how do I kill it?" with a handle instead of
an inference.
node-pty is patched (`config/patches/node-pty@1.1.0.patch`) to create a job
object per ConPTY and assign the shell to it under `CREATE_SUSPENDED`, before
the shell can spawn anything. Assigning after the fact leaves a window in which
a fast child escapes the job.
- `terminatePtyJob(proc)` — one `TerminateJobObject` call for the whole tree.
- `listPtyJobProcessIds(proc)` — the live pids under a tree that is still
tracked, including children that detached from the console.
Measured on Windows 11 against a shell whose grandchild was spawned `detached`:
job membership was `[shell, grandchild]` and one call killed both. Neither a
parent-pid walk nor `GetConsoleProcessList` sees that grandchild — it leaves
the console and reparents, which is what left `claude.exe`/`node.exe`/`cmd.exe`
holding worktree directories open (#9045, #10475, #10897).
The per-PTY job deliberately does **not** set
`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`. Measured on Windows 11: with that flag,
releasing the handle when the shell exits also kills whatever the user left
running, so typing `exit` in a pane reaped a `start /b` server that used to
survive. The job exists to make an *explicit* teardown exact, not to redefine
what a clean exit means.
Reaping a dead daemon's shells (#9195, #10415) is therefore a **second, nested
job**, not this one. The terminal daemon assigns itself to a kill-on-close job
at startup (`assignHostProcessToKillOnCloseJob`); children inherit membership,
so every pty is covered and the per-PTY jobs nest inside it. Its handle is
released only when the daemon process dies, so a crashed daemon reaps its tree
without changing what a clean shell exit means.
The split is the point. One job answers "kill exactly this pane's tree, now";
the other answers "do not strand anything if the host dies". Trying to get both
from one job is what reaped users' backgrounded work on a clean `exit`.
It belongs to the daemon and never to the app: an app-main crash must still
leave sessions alive, which `.github/workflows/win-crash-survival-e2e.yml`
asserts. The app spawns the daemon `detached` and is itself in no job, so
nothing is inherited across that boundary.
The consequence is that a PTY hosted by the app rather than the daemon gets a
per-PTY job but no crash reaping. That is deliberate — the alternative is a
kill-on-close job on the app, which is exactly what the crash-survival
guarantee forbids.
Once the shell exits, node-pty drops its handle record and closes the job, so a
terminated tree reports `null` rather than `[]`. Null means *unverifiable* in
the sense of [`ssh-execution-boundary.md`](./ssh-execution-boundary.md) — no job
support, not a ConPTY, or no longer tracked. It is never evidence that
processes died.
Both functions report `unavailable` / `null` rather than a false success when a
pty has no job — an outer job without `JOB_OBJECT_LIMIT_BREAKAWAY_OK` (some EDR
and container hosts) can refuse the assignment, and a pty started before this
build has none. Callers must fall back, not conclude the tree is gone. That
conflation is the original bug.
### Known limitation: the baton table is not synchronised
node-pty keeps its per-terminal handles in a plain `std::vector` and erases from
it on a detached exit thread, while `get_pty_baton` is called from the main JS
thread. That race predates this change — `PtyResize`, `PtyClear` and `PtyKill`
all read the table the same way — but `terminatePtyJob` adds an instance of it:
the exit thread can close `hJob` between the lookup and `TerminateJobObject`.
Losing that race normally just returns `FALSE`, which surfaces as `unavailable`
and falls back. The case that would not be benign is a recycled `HANDLE` value,
where the call could reach a different job in the same process. Fixing it
properly means synchronising node-pty's handle table rather than adding a lock
around one accessor, so it is deliberately left alone here.
### The patch must actually be compiled
node-pty prefers its upstream prebuild and only builds from source when
`npm_config_build_from_source` is set or no prebuild exists for the platform.
The Windows prebuild does **not** contain this patch, so a plain `pnpm install`
on Windows yields a node-pty without the job-object exports — and
`terminatePtyJob` then reports `unavailable` on every call, which is
indistinguishable from a correctly degraded build.
Packaging is unaffected: `rebuild-native-deps.mjs` rebuilds node-pty from source
for Electron and restores the ConPTY runtime files that a bare `node-gyp
rebuild` skips. The gap is the **node-runtime test environment**, which is why
the Windows CI job rebuilds from source before running the win32 suites.
`isPtyJobOwnershipAvailable()` exists for exactly this: the win32 suite asserts
it is true before asserting anything else, so an unpatched binary fails loudly
instead of passing every case vacuously. That guard is what caught this.
`requiresPatchedNodePtySourceBuild()` in `ensure-native-runtime.mjs` still
exempts win32, on the premise that the patch is Unix-only. That premise is now
false, but lifting the exemption also needs `pnpm rebuild` to force a source
build — otherwise the assertion fires and the remedy does not fix it. Left as a
follow-up rather than changed blind.
+4 -2
View File
@@ -91,7 +91,7 @@
"test:e2e:workspace-session-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-quit-relaunch-session.spec.ts tests/e2e/golden-terminal-file-link.spec.ts tests/e2e/golden-worktree-create-switch.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"test:e2e:multi-client-navigation": "node config/scripts/run-multi-client-navigation-e2e.mjs",
"test:e2e:floating-mobile-emulator": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/floating-mobile-emulator-tab.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"test:e2e:terminal-rendering-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts tests/e2e/terminal-webgl-atlas-budget.spec.ts tests/e2e/terminal-padding-geometry-golden.spec.ts --grep @terminal-rendering-golden --config tests/playwright.config.ts --project electron-headless --workers=1",
"test:e2e:terminal-rendering-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts tests/e2e/terminal-webgl-atlas-budget.spec.ts --grep @terminal-rendering-golden --config tests/playwright.config.ts --project electron-headless --workers=1",
"test:e2e:source-control-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-file-open-edit-save.spec.ts tests/e2e/golden-source-control-commit.spec.ts tests/e2e/golden-source-control-open-diff.spec.ts --grep @golden --config tests/playwright.config.ts --project electron-headless --workers=1",
"test:e2e:posix-profile-index-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-posix-fresh-startup.spec.ts tests/e2e/golden-posix-profile-index-fsync.spec.ts --grep @posix-profile-index-golden --config tests/playwright.config.ts --project electron-headless --workers=1",
"test:e2e:windows-fresh-startup-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-windows-fresh-startup.spec.ts --grep @windows-fresh-startup-golden --config tests/playwright.config.ts --project electron-headless --workers=1",
@@ -263,6 +263,7 @@
"zustand": "^5.0.14"
},
"optionalDependencies": {
"@vscode/windows-process-tree": "0.8.0",
"sherpa-onnx-darwin-arm64": "1.12.37",
"sherpa-onnx-darwin-x64": "1.12.37",
"sherpa-onnx-linux-arm64": "1.12.37",
@@ -314,7 +315,8 @@
"@xterm/addon-webgl@0.20.0-beta.286": "config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch",
"@xterm/addon-serialize@0.15.0-beta.287": "config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch",
"@xterm/xterm@6.1.0-beta.287": "config/patches/@xterm__xterm@6.1.0-beta.287.patch",
"lint-staged@16.4.0": "config/patches/lint-staged@16.4.0.patch"
"lint-staged@16.4.0": "config/patches/lint-staged@16.4.0.patch",
"@vscode/windows-process-tree@0.8.0": "config/patches/@vscode__windows-process-tree@0.8.0.patch"
}
},
"reactDoctor": {
+24 -3
View File
@@ -8,6 +8,9 @@ overrides:
monaco-editor>dompurify: 3.4.13
patchedDependencies:
'@vscode/windows-process-tree@0.8.0':
hash: 7c08bebce9b36829be6035218db2383f1a889c21ec907ea7e85c36fc54795a05
path: config/patches/@vscode__windows-process-tree@0.8.0.patch
'@xterm/addon-ligatures@0.11.0-beta.287':
hash: 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920
path: config/patches/@xterm__addon-ligatures@0.11.0-beta.287.patch
@@ -24,7 +27,7 @@ patchedDependencies:
hash: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673
path: config/patches/lint-staged@16.4.0.patch
node-pty@1.1.0:
hash: 8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8
hash: 710d5c74d1b738c41316e997e7cb860b61147604f98380ccf4f5bb20b6ed0ac5
path: config/patches/node-pty@1.1.0.patch
importers:
@@ -66,7 +69,7 @@ importers:
version: 3.3.1
node-pty:
specifier: ^1.1.0
version: 1.1.0(patch_hash=8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8)
version: 1.1.0(patch_hash=710d5c74d1b738c41316e997e7cb860b61147604f98380ccf4f5bb20b6ed0ac5)
posthog-node:
specifier: ^5.33.3
version: 5.33.3
@@ -402,6 +405,9 @@ importers:
specifier: ^5.0.14
version: 5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7))
optionalDependencies:
'@vscode/windows-process-tree':
specifier: 0.8.0
version: 0.8.0(patch_hash=7c08bebce9b36829be6035218db2383f1a889c21ec907ea7e85c36fc54795a05)
sherpa-onnx-darwin-arm64:
specifier: 1.12.37
version: 1.12.37
@@ -3378,6 +3384,9 @@ packages:
'@vitest/utils@4.1.5':
resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==}
'@vscode/windows-process-tree@0.8.0':
resolution: {integrity: sha512-TI+h2GRwX+igD/YYJMQQVAFcsCNSg7Te2yYxQpKMzwto5RsJ8d2KKgOeur/p/6sAOQwZvopiTDOClyTHEn9MhQ==}
'@xmldom/xmldom@0.8.13':
resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==}
engines: {node: '>=10.0.0'}
@@ -5426,6 +5435,10 @@ packages:
node-addon-api@4.3.0:
resolution: {integrity: sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==}
node-addon-api@7.1.0:
resolution: {integrity: sha512-mNcltoe1R8o7STTegSOHdnJNN7s5EUvhoS7ShnTHDyOSd+8H+UdWODq6qSv67PjC8Zc5JRT8+oLAMCr0SIXw7g==}
engines: {node: ^16 || ^18 || >= 20}
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
@@ -9592,6 +9605,11 @@ snapshots:
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
'@vscode/windows-process-tree@0.8.0(patch_hash=7c08bebce9b36829be6035218db2383f1a889c21ec907ea7e85c36fc54795a05)':
dependencies:
node-addon-api: 7.1.0
optional: true
'@xmldom/xmldom@0.8.13': {}
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
@@ -12004,6 +12022,9 @@ snapshots:
node-addon-api@4.3.0:
optional: true
node-addon-api@7.1.0:
optional: true
node-addon-api@7.1.1: {}
node-api-version@0.2.1:
@@ -12025,7 +12046,7 @@ snapshots:
node-int64@0.4.0: {}
node-pty@1.1.0(patch_hash=8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8):
node-pty@1.1.0(patch_hash=710d5c74d1b738c41316e997e7cb860b61147604f98380ccf4f5bb20b6ed0ac5):
dependencies:
node-addon-api: 7.1.1
+1 -1
View File
@@ -125,7 +125,7 @@ orca linear list --filter assigned --limit 10 --workspace all --json
orca linear list --filter open --team <key-or-id> --workspace <workspaceId> --json
```
Use `list-issues` when MCP-compatible filters or cursor pagination are needed. A cursor is workspace-specific, so combine `--cursor` with a concrete `--workspace` rather than `all`.
Use `list-issues` when MCP-compatible filters or cursor pagination are needed. Omitting `--limit` returns every match (`result.meta.limit` is `null`), so filter before listing a large workspace; `--limit <n>` caps the read. `--json` sets `result.truncated` (and `result.meta.hasMore`) when a cap held results back; human output prints `truncated: showing N`. Check `truncated` before reporting a count, then page with `--cursor` until `truncated` is false. Issued `--cursor` values bind the workspace; `--workspace all` cannot page; a raw Linear cursor still needs a concrete `--workspace`. Replay `--cursor` against the same Orca runtime that issued it. `--priority` is `0=none`, `1=urgent`, `2=high`, `3=medium`, `4=low`; JSON includes `priorityLabel` on each issue (CLI setter vocabulary). `orca linear search`, `orca linear list`, and `orca linear project list` still cap at their own `--limit` and set `result.truncated` when the cap is hit. Project JSON `priorityLabel` stays Linear's title-case provider string.
Prefer `label add` and `label remove` for incremental edits. `label set` replaces the full label set and should be used only when deliberate cleanup is intended.
+1 -1
View File
@@ -122,7 +122,7 @@ orca linear list --filter assigned --limit 10 --workspace all --json
orca linear list --filter open --team <key-or-id> --workspace <workspaceId> --json
```
Use `list-issues` when MCP-compatible filters or cursor pagination are needed. A cursor is workspace-specific, so combine `--cursor` with a concrete `--workspace` rather than `all`.
Use `list-issues` when MCP-compatible filters or cursor pagination are needed. Omitting `--limit` returns every match (`result.meta.limit` is `null`), so filter before listing a large workspace; `--limit <n>` caps the read. `--json` sets `result.truncated` (and `result.meta.hasMore`) when a cap held results back; human output prints `truncated: showing N`. Check `truncated` before reporting a count, then page with `--cursor` until `truncated` is false. Issued `--cursor` values bind the workspace; `--workspace all` cannot page; a raw Linear cursor still needs a concrete `--workspace`. Replay `--cursor` against the same Orca runtime that issued it. `--priority` is `0=none`, `1=urgent`, `2=high`, `3=medium`, `4=low`; JSON includes `priorityLabel` on each issue (CLI setter vocabulary). `orca linear search`, `orca linear list`, and `orca linear project list` still cap at their own `--limit` and set `result.truncated` when the cap is hit. Project JSON `priorityLabel` stays Linear's title-case provider string.
Prefer `label add` and `label remove` for incremental edits. `label set` replaces the full label set and should be used only when deliberate cleanup is intended.
File diff suppressed because one or more lines are too long
+40
View File
@@ -117,6 +117,46 @@ describe('orca linear CLI handlers', () => {
)
})
it('prints truncation on human stdout and in JSON without using stderr for JSON', async () => {
const listResult = {
issues: [
{
id: 'issue-1',
identifier: 'ENG-1',
title: 'Fix auth',
url: 'https://linear.app/acme/issue/ENG-1',
labels: [],
workspace: { id: 'workspace-1', name: 'Acme' }
}
],
truncated: true,
meta: {
limit: 1,
returned: 1,
hasMore: true,
nextCursor: 'next-page',
orderBy: 'updatedAt',
workspaceId: 'workspace-1',
partial: false,
workspaceErrors: []
}
}
queueFixtures(callMock, okFixture('req_list', listResult))
await main(['linear', 'list-issues', '--limit', '1'], '/tmp/repo')
expect(vi.mocked(console.log).mock.calls[0][0]).toContain('truncated: showing 1')
expect(
vi.mocked(console.error).mock.calls.some((call) => String(call[0]).includes('more results'))
).toBe(true)
vi.mocked(console.log).mockClear()
vi.mocked(console.error).mockClear()
queueFixtures(callMock, okFixture('req_list_json', listResult))
await main(['linear', 'list-issues', '--limit', '1', '--json'], '/tmp/repo')
const jsonOut = String(vi.mocked(console.log).mock.calls[0][0])
expect(jsonOut).toContain('"truncated": true')
expect(vi.mocked(console.error)).not.toHaveBeenCalled()
})
it('keeps global boolean flags before Linear commands from consuming command tokens', async () => {
queueFixtures(callMock, okFixture('req_linear', issueResult()))
+7 -1
View File
@@ -452,7 +452,13 @@ function formatCommandFlagHelp(flag: string, commandPath: string[]): string {
return '--workspace <id|all> Connected Linear workspace id, or all'
}
if (command === 'linear list-issues' && flag === 'cursor') {
return '--cursor <cursor> Opaque cursor returned by a previous list-issues page'
return '--cursor <cursor> Opaque cursor from a previous list-issues page; issued cursors bind the workspace, raw Linear cursors need --workspace'
}
if (command === 'linear list-issues' && flag === 'priority') {
return '--priority <0-4> 0=none, 1=urgent, 2=high, 3=medium, 4=low'
}
if (command === 'linear list-issues' && flag === 'limit') {
return '--limit <n> Max issues to return; omit to return every match'
}
if (command === 'artifacts list' && flag === 'cursor') {
return '--cursor <cursor> Opaque cursor returned by a previous artifacts page'
+5 -1
View File
@@ -403,9 +403,13 @@ describe('orca root help', () => {
const listIssuesHelp = String(logSpy.mock.calls[0][0])
expect(listIssuesHelp).toContain(
'--cursor <cursor> Opaque cursor returned by a previous list-issues page'
'--cursor <cursor> Opaque cursor from a previous list-issues page; issued cursors bind the workspace, raw Linear cursors need --workspace'
)
expect(listIssuesHelp).toContain('--workspace <id|all> Connected Linear workspace id, or all')
expect(listIssuesHelp).toContain('0=none, 1=urgent, 2=high, 3=medium, 4=low')
expect(listIssuesHelp).toContain(
'--limit <n> Max issues to return; omit to return every match'
)
expect(listIssuesHelp).not.toContain('Line cursor from a previous read')
expect(callMock).not.toHaveBeenCalled()
})
+62
View File
@@ -9,6 +9,7 @@ import type {
import {
formatLinearCreate,
formatLinearIssue,
formatLinearMcpIssueList,
formatLinearProjectList,
printLinearMcpIssueListWarnings,
printLinearSearchWarnings
@@ -41,6 +42,7 @@ describe('linear-format', () => {
it('binds a list continuation hint to its Linear workspace', () => {
printLinearMcpIssueListWarnings({
issues: [],
truncated: true,
meta: {
limit: 20,
returned: 20,
@@ -58,6 +60,66 @@ describe('linear-format', () => {
)
})
it('prints a stdout truncation marker when a list-issues page is partial', () => {
const output = formatLinearMcpIssueList({
issues: [
{
id: 'issue-1',
identifier: 'ENG-1',
title: 'Fix auth',
url: 'https://linear.app/acme/issue/ENG-1',
labels: [],
state: { name: 'In Progress' },
assignee: { displayName: 'Ada' },
workspace: { id: 'workspace-1', name: 'Acme' }
}
],
truncated: true,
meta: {
limit: 1,
returned: 1,
hasMore: true,
nextCursor: 'next-page',
orderBy: 'updatedAt',
workspaceId: 'workspace-1',
partial: false,
workspaceErrors: []
}
})
expect(output).toContain('ENG-1')
expect(output).toContain('truncated: showing 1')
expect(output).not.toContain(' of ')
})
it('omits the stdout truncation marker when the page is complete', () => {
const output = formatLinearMcpIssueList({
issues: [
{
id: 'issue-1',
identifier: 'ENG-1',
title: 'Fix auth',
url: 'https://linear.app/acme/issue/ENG-1',
labels: [],
workspace: { id: 'workspace-1', name: 'Acme' }
}
],
truncated: false,
meta: {
limit: 50,
returned: 1,
hasMore: false,
orderBy: 'updatedAt',
workspaceId: 'workspace-1',
partial: false,
workspaceErrors: []
}
})
expect(output).toContain('ENG-1')
expect(output).not.toContain('truncated:')
})
it('includes task fields in issue readback text', () => {
const result = {
issue: {
+18 -18
View File
@@ -17,6 +17,8 @@ import type {
LinearTeamStatesResult,
LinearStatusSetResult
} from '../shared/linear/agent-access'
import { appendLinearListTruncation } from '../shared/linear/list-truncation-format'
import { linearPriorityLabel } from '../shared/linear/priority-label'
import {
formatLinearProjectListRows,
linearProjectListWarningLines
@@ -70,7 +72,11 @@ export function formatLinearSearch(result: LinearSearchResult): string {
if (result.issues.length === 0) {
return 'No Linear issues found.'
}
return result.issues.map(formatSearchRow).join('\n')
return appendLinearListTruncation(
result.issues.map(formatSearchRow).join('\n'),
result.issues.length,
result.truncated ?? result.meta.limitReached
)
}
export function formatLinearTeamList(result: LinearTeamListResult): string {
@@ -114,14 +120,22 @@ export function formatLinearIssueList(result: LinearIssueListResult): string {
if (result.issues.length === 0) {
return 'No Linear issues found.'
}
return result.issues.map(formatSearchRow).join('\n')
return appendLinearListTruncation(
result.issues.map(formatSearchRow).join('\n'),
result.issues.length,
result.truncated ?? result.meta.hasMore
)
}
export function formatLinearMcpIssueList(result: LinearMcpIssueListResult): string {
if (result.issues.length === 0) {
return 'No Linear issues found.'
}
return result.issues.map(formatSearchRow).join('\n')
return appendLinearListTruncation(
result.issues.map(formatSearchRow).join('\n'),
result.issues.length,
result.truncated ?? result.meta.hasMore
)
}
export function printLinearMcpIssueListWarnings(result: LinearMcpIssueListResult): void {
@@ -237,21 +251,7 @@ function formatSearchRow(issue: LinearSearchIssueSummary): string {
}
function formatPriority(priority: number | null | undefined): string {
if (priority == null || priority === 0) {
return 'none'
}
switch (priority) {
case 1:
return 'urgent'
case 2:
return 'high'
case 3:
return 'medium'
case 4:
return 'low'
default:
return 'none'
}
return linearPriorityLabel(priority)
}
function taskOperationLabel(operation: LinearIssueTaskUpdateResult['operation']): string {
+9 -2
View File
@@ -67,8 +67,15 @@ export const LINEAR_MCP_COMMAND_SPECS: CommandSpec[] = [
],
examples: [
'orca linear list-issues --team ENG --state started --assignee me --json',
'orca linear list-issues --query auth --updated-at -P7D --limit 100 --json',
'orca linear list-issues --cursor <cursor> --workspace <id> --json'
'orca linear list-issues --query auth --updated-at -P7D --json',
'orca linear list-issues --assignee me --priority 1 --limit 100 --json',
'orca linear list-issues --cursor <cursor> --json'
],
notes: [
'Omitting --limit returns every match (result.meta.limit is null); --limit <n> caps the read.',
'JSON sets result.truncated when a cap held results back; text prints truncated: showing N.',
'Reuse --cursor from the previous page. Issued cursors bind the workspace; raw Linear cursors still need --workspace.',
'--priority is 0=none, 1=urgent, 2=high, 3=medium, 4=low. JSON includes priorityLabel on each issue.'
]
},
{
@@ -16,6 +16,17 @@ afterAll(() => {
}
})
// Retry once when Electron startup times out before `ready`; keep later failures fatal.
const FIXTURE_LAUNCH_ATTEMPTS = 2
function neverReachedElectronReady(fixtureResult: string): boolean {
try {
return (JSON.parse(fixtureResult) as { step?: string }).step === 'timed out after starting'
} catch {
return false
}
}
type FixtureResult = {
before: Record<string, unknown>
after: Record<string, unknown>
@@ -182,21 +193,27 @@ async function runFixture(): Promise<FixtureResult> {
})
writeFileSync(fixturePath, buildFixtureMain(importPath, resultPath, sourceCookiesPath))
const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env
const electronArgs = [fixturePath, `--user-data-dir=${join(root, 'profile')}`]
const executable = process.platform === 'linux' ? 'xvfb-run' : electronBinary
const args =
process.platform === 'linux'
? ['--auto-servernum', electronBinary, ...electronArgs, '--no-sandbox']
: electronArgs
const run = spawnSync(executable, args, {
encoding: 'utf8',
env,
timeout: 60_000
})
const fixtureResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result'
expect(run.error).toBeUndefined()
expect(run.status, `${fixtureResult}\n${run.stdout}\n${run.stderr}`).toBe(0)
return JSON.parse(fixtureResult) as FixtureResult
for (let attempt = 1; ; attempt += 1) {
rmSync(resultPath, { force: true })
// Why a fresh profile per attempt: a launch that never reached `ready` may have left the
// Chromium profile mid-initialization, and reusing it would bias the retry.
const electronArgs = [fixturePath, `--user-data-dir=${join(root, `profile-${attempt}`)}`]
const run = spawnSync(
executable,
process.platform === 'linux'
? ['--auto-servernum', electronBinary, ...electronArgs, '--no-sandbox']
: electronArgs,
{ encoding: 'utf8', env, timeout: 60_000 }
)
const fixtureResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result'
if (attempt < FIXTURE_LAUNCH_ATTEMPTS && neverReachedElectronReady(fixtureResult)) {
continue
}
expect(run.error).toBeUndefined()
expect(run.status, `${fixtureResult}\n${run.stdout}\n${run.stderr}`).toBe(0)
return JSON.parse(fixtureResult) as FixtureResult
}
}
describe('native Chromium excluded partition cookie under Electron', () => {
@@ -220,5 +237,5 @@ describe('native Chromium excluded partition cookie under Electron', () => {
'partitioned-google',
'stale'
])
}, 90_000)
}, 150_000)
})
+17 -6
View File
@@ -1,6 +1,8 @@
/* eslint-disable max-lines -- Why: cookie import is one pipeline (detect → decrypt → stage → swap) that must stay together to keep encryption/schema/staging in sync. */
import { app, type BrowserWindow, dialog, session } from 'electron'
import { execFileSync } from 'node:child_process'
import { runProcessSync } from '../../shared/child-process/run-process'
import { windowsPowerShellPath } from '../../shared/child-process/windows-system-binary'
import { createDecipheriv, pbkdf2Sync, randomUUID } from 'node:crypto'
import {
appendFileSync,
@@ -1108,13 +1110,22 @@ function getWindowsEncryptionKey(browser: DetectedBrowser): EncryptionKeyResult
'[Convert]::ToBase64String($out)'
].join('')
const result = execFileSync(
'powershell',
['-NoProfile', '-NonInteractive', '-Command', script],
{ encoding: 'utf-8', timeout: 10_000, input: dpapiData }
).trim()
// Why runProcessSync and an absolute path: a bare `powershell` spawn from a
// GUI-subsystem process opens a visible conhost that takes foreground, so
// keystrokes typed into an Orca terminal during a cookie import land in the
// black box (#14543), and PATH under Electron is not the user's (#11771).
const result = runProcessSync({
program: windowsPowerShellPath(),
args: ['-NoProfile', '-NonInteractive', '-Command', script],
timeoutMs: 10_000,
input: dpapiData
})
if (result.code !== 0 || result.timedOut) {
diag(' Windows DPAPI key extraction failed: PowerShell exited non-zero')
return null
}
return { key: Buffer.from(result, 'base64'), mode: 'aes-256-gcm' }
return { key: Buffer.from(result.stdout.trim(), 'base64'), mode: 'aes-256-gcm' }
} catch (err) {
diag(` Windows DPAPI key extraction failed: ${String(err)}`)
return null
@@ -180,6 +180,8 @@ export function forwardGuestShortcutInput(
renderer.send('ui:openWorkspaceBoard')
} else if (action?.type === 'openTasks') {
renderer.send('ui:openTasks')
} else if (action?.type === 'toggleAgentDashboard') {
renderer.send('ui:toggleAgentDashboard')
} else if (action?.type === 'openSettings') {
renderer.send('ui:openSettings')
} else if (action?.type === 'forceReload') {
@@ -1,9 +1,11 @@
import { describe, expect, it } from 'vitest'
import {
buildWslCodexAvailabilityArgs,
buildWslCodexAppServerArgs,
buildWslCodexIdentityProbe,
buildWslCodexLoginArgs
} from './wsl-codex-command'
import { CODEX_READ_ONLY_APP_SERVER_ARGS } from '../codex-cli/codex-read-only-app-server-args'
describe('WSL Codex commands', () => {
it('checks the alias-neutral PATH from the distro login shell', () => {
@@ -25,6 +27,21 @@ describe('WSL Codex commands', () => {
expect(command).toContain('exec "$resolved" login')
})
it('quotes an explicit read-only app-server contract without changing the default', () => {
const readOnlyCommand = buildWslCodexAppServerArgs(
'Ubuntu',
'/home/alice/managed-home',
CODEX_READ_ONLY_APP_SERVER_ARGS
).at(-1)
const defaultCommand = buildWslCodexAppServerArgs('Ubuntu', '/home/alice/managed-home').at(-1)
for (const arg of CODEX_READ_ONLY_APP_SERVER_ARGS) {
expect(readOnlyCommand).toContain(arg)
}
expect(defaultCommand).toContain('app-server')
expect(defaultCommand).not.toContain('approval_policy=never')
})
it('reports the login-shell binary path and version for identity checks', () => {
const command = buildWslCodexIdentityProbe('Ubuntu').args.at(-1)
+6 -2
View File
@@ -46,7 +46,11 @@ export function buildWslCodexIdentityProbe(distro: string): WslCodexIdentityProb
}
}
export function buildWslCodexAppServerArgs(distro: string, linuxHomePath: string): string[] {
export function buildWslCodexAppServerArgs(
distro: string,
linuxHomePath: string,
appServerArgs: readonly string[] = ['app-server']
): string[] {
const command = [
buildCodexPathLookup(),
'if [ -z "$resolved" ]; then',
@@ -54,7 +58,7 @@ export function buildWslCodexAppServerArgs(distro: string, linuxHomePath: string
' exit 127',
'fi',
`export CODEX_HOME=${quotePosixShell(linuxHomePath)}`,
'exec "$resolved" app-server'
`exec "$resolved" ${appServerArgs.map(quotePosixShell).join(' ')}`
].join('\n')
return buildWslCodexShellArgs(distro, command)
}
@@ -0,0 +1,10 @@
// The config override replaces legacy values before Codex validates config.toml.
export const CODEX_READ_ONLY_APP_SERVER_ARGS = [
'-c',
'approval_policy=never',
'-s',
'read-only',
'-a',
'never',
'app-server'
] as const
@@ -4,6 +4,8 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import * as ownerIdentity from '../agent-hooks/managed-hook-owner-identity'
import { CODEX_READ_ONLY_APP_SERVER_ARGS } from '../codex-cli/codex-read-only-app-server-args'
import { getCmdExePath } from '../win32-utils'
import {
_internals,
resolveCodexBackfillSupervisorLockRoot,
@@ -367,6 +369,51 @@ describe('Codex state DB backfill recovery', () => {
expect(terminate).toHaveBeenCalledWith(child)
})
it('uses batch-safe read-only arguments for native Windows recovery', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const child = createFakeChild()
const spawnProcess = vi.fn(() => child)
const codexCommand = 'C:\\Users\\alice\\AppData\\Roaming\\npm\\codex.cmd'
const readStatus = vi
.fn()
.mockReturnValueOnce({ kind: 'incomplete', stateDbPath: 'state.sqlite', status: 'running' })
.mockReturnValue({ kind: 'complete', stateDbPath: 'state.sqlite' })
await runCodexStateDbBackfillRecovery(
'C:\\Users\\alice\\.codex',
new AbortController().signal,
{
spawnProcess: spawnProcess as never,
resolveCommand: () => codexCommand,
readStatus,
terminate: vi.fn(async () => {}),
sleep: vi.fn(async () => {}),
now: vi.fn(() => 1_000)
}
)
const [spawnFile, spawnArgs, spawnOptions] = spawnProcess.mock.calls[0] as unknown as [
string,
string[],
{ cwd?: string; env?: NodeJS.ProcessEnv }
]
expect(spawnFile).toBe(getCmdExePath())
expect(spawnArgs).toEqual([
'/d',
'/c',
codexCommand,
'-c',
'approval_policy=never',
'-s',
'read-only',
'-a',
'never',
'app-server'
])
expect(spawnOptions.cwd).toBe('C:\\Users\\alice\\.codex')
expect(spawnOptions.env?.CODEX_HOME).toBe('C:\\Users\\alice\\.codex')
})
it('retries a live foreign lease until one durable claimant can recover it', async () => {
const first = createFakeChild()
const second = createFakeChild()
@@ -431,6 +478,9 @@ describe('Codex state DB backfill recovery', () => {
const command = spawnCall[1].join(' ')
expect(command).toContain('export CODEX_HOME=')
expect(command).toContain('/home/alice/.codex')
for (const arg of CODEX_READ_ONLY_APP_SERVER_ARGS) {
expect(command).toContain(arg)
}
})
})
@@ -8,6 +8,7 @@ import { withManagedHookInstallLock } from '../agent-hooks/managed-hook-install-
import { readManagedHookHostIdentity } from '../agent-hooks/managed-hook-owner-identity'
import { buildWslCodexAppServerArgs } from '../codex-accounts/wsl-codex-command'
import { resolveCodexCommand } from '../codex-cli/command'
import { CODEX_READ_ONLY_APP_SERVER_ARGS } from '../codex-cli/codex-read-only-app-server-args'
import { terminateCodexProbeChild } from '../rate-limits/codex-probe-termination'
import { getSpawnArgsForWindows } from '../win32-utils'
import { getOrcaUserDataPath } from './codex-home-paths'
@@ -25,7 +26,6 @@ const RECOVERY_MAX_COORDINATOR_FAILURES = 5
const RECOVERY_MAX_SPAWNS = 5
const RECOVERY_MAX_TOTAL_MS = 60 * 60_000
const RECOVERY_OWNER_CHECK_TIMEOUT_MS = 1_000
const RECOVERY_CODEX_ARGS = ['-s', 'read-only', '-a', 'untrusted', 'app-server'] as const
export type CodexStateDbBackfillRecoverySummary = {
outcome: 'completed' | 'already-complete' | 'not-needed' | 'unreadable' | 'stopped' | 'gave-up'
@@ -90,7 +90,11 @@ function spawnRecoveryProcess(
if (wslHome) {
return dependencies.spawnProcess(
'wsl.exe',
buildWslCodexAppServerArgs(wslHome.distro, wslHome.linuxPath),
buildWslCodexAppServerArgs(
wslHome.distro,
wslHome.linuxPath,
CODEX_READ_ONLY_APP_SERVER_ARGS
),
{
stdio: ['pipe', 'ignore', 'ignore'],
windowsHide: true,
@@ -99,7 +103,9 @@ function spawnRecoveryProcess(
)
}
const command = dependencies.resolveCommand()
const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, [...RECOVERY_CODEX_ARGS])
const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, [
...CODEX_READ_ONLY_APP_SERVER_ARGS
])
return dependencies.spawnProcess(spawnCmd, spawnArgs, {
cwd: codexHomePath,
stdio: ['pipe', 'ignore', 'ignore'],
@@ -3,7 +3,7 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types'
describe('foreground-confirmation daemon protocol', () => {
it('rejects daemons from before the fresh-confirmation RPC', () => {
expect(PROTOCOL_VERSION).toBe(35)
expect(PROTOCOL_VERSION).toBe(36)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23)
@@ -18,5 +18,6 @@ describe('foreground-confirmation daemon protocol', () => {
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(32)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(33)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(34)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(35)
})
})
@@ -5,6 +5,7 @@ import {
ASYNC_CWD_VALIDATION_DAEMON_PROTOCOL_VERSION,
CODEX_SHELL_LAUNCH_PREFLIGHT_DAEMON_PROTOCOL_VERSION,
COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION,
CONTENT_ADDRESSED_SHELL_WRAPPER_DAEMON_PROTOCOL_VERSION,
GET_FOREGROUND_PROCESS_PROTOCOL_VERSION,
HISTORY_SEED_TRANSFER_PROTOCOL_VERSION,
MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION,
@@ -18,7 +19,8 @@ import {
describe('daemon protocol version', () => {
it('ships bounded history transfer after the 2031-unsubscribe fact', () => {
expect(PROTOCOL_VERSION).toBe(35)
expect(PROTOCOL_VERSION).toBe(36)
expect(CONTENT_ADDRESSED_SHELL_WRAPPER_DAEMON_PROTOCOL_VERSION).toBe(36)
expect(ASYNC_CWD_VALIDATION_DAEMON_PROTOCOL_VERSION).toBe(35)
expect(CODEX_SHELL_LAUNCH_PREFLIGHT_DAEMON_PROTOCOL_VERSION).toBe(34)
expect(WSL_POSIX_CWD_DAEMON_PROTOCOL_VERSION).toBe(33)
@@ -31,7 +33,7 @@ describe('daemon protocol version', () => {
expect(AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION).toBe(26)
expect(AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION).toBe(26)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toEqual(
Array.from({ length: 34 }, (_, index) => index + 1)
Array.from({ length: 35 }, (_, index) => index + 1)
)
})
+4 -3
View File
@@ -1,6 +1,7 @@
// Why: daemons survive app updates, so wire behavior must be version-gated.
// v35 makes cwd validation async with request-scoped spawn cancellation; older owners stay attachable.
export const PROTOCOL_VERSION = 35
// v36 launches shells from content-addressed wrapper trees; older owners stay attachable.
export const PROTOCOL_VERSION = 36
export const CONTENT_ADDRESSED_SHELL_WRAPPER_DAEMON_PROTOCOL_VERSION = 36
export const ASYNC_CWD_VALIDATION_DAEMON_PROTOCOL_VERSION = 35
export const CODEX_SHELL_LAUNCH_PREFLIGHT_DAEMON_PROTOCOL_VERSION = 34
export const WSL_POSIX_CWD_DAEMON_PROTOCOL_VERSION = 33
@@ -29,7 +30,7 @@ export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24
export const MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION = 29
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27,
28, 29, 30, 31, 32, 33, 34
28, 29, 30, 31, 32, 33, 34, 35
] as const
export function supportsPtyStartupIngress(protocolVersion: number): boolean {
+8
View File
@@ -61,6 +61,7 @@ import { isWindowsGitBashShellPath, resolveWindowsGitBashShellPath } from '../gi
import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell'
import { resolveAgentForegroundProcessWithAvailability } from '../providers/agent-foreground-process'
import { readWindowsConptyProcessIds } from '../providers/windows-conpty-process-membership'
import { assignHostProcessToKillOnCloseJob } from '../windows/windows-pty-job'
import {
isAgentForegroundWrapperProcess,
recognizeAgentProcess,
@@ -588,6 +589,13 @@ function spawnDaemonPtyWithWindowsFallback(args: {
let reportsChildExitStatus = true
const spawnAt = (shellPath: string, shellArgs: string[], cwd: string): pty.IPty => {
const wrapped = wrapShellSpawnForMacosTccAttribution(shellPath, shellArgs, args.env)
// Why here: children inherit job membership, so the host job has to exist
// before the first pty -- but resolving it loads the ConPTY addon, and
// paying that at startup delayed the daemon's endpoint past the boot smoke
// test's readiness check. This path already pays ConPTY cost. Memoised.
if (process.platform === 'win32') {
assignHostProcessToKillOnCloseJob()
}
const proc = pty.spawn(wrapped.file, wrapped.args, {
name: args.env.TERM ?? 'xterm-256color',
cols: args.cols,
+33 -33
View File
@@ -1,4 +1,4 @@
import { execFile } from 'node:child_process'
import { runProcess } from '../../shared/child-process/run-process'
import {
accessSync,
chmodSync,
@@ -196,37 +196,37 @@ export async function execServeSimCommand(
finalArgs.push('-q')
}
return new Promise((resolve, reject) => {
execFile(
executable.command,
[...executable.baseArgs, ...finalArgs],
{ timeout, maxBuffer: 10 * 1024 * 1024, env: getServeSimEnv(executable) },
(error, stdout, stderr) => {
if (error) {
const message =
stdout.toString() || stderr.toString() || error.message || 'serve-sim command failed'
if (/no serve-sim server|not running/i.test(message)) {
reject(
new EmulatorError(
'emulator_no_active',
'No active emulator for this worktree — use orca emulator list/attach or open the pane'
)
)
return
}
reject(new EmulatorError('emulator_error', message))
return
}
if (options?.json) {
try {
resolve(JSON.parse(stdout.toString()))
} catch {
resolve(stdout.toString().trim())
}
return
}
resolve(stdout.toString().trim())
}
let result
try {
result = await runProcess({
program: executable.command,
args: [...executable.baseArgs, ...finalArgs],
env: getServeSimEnv(executable),
maxOutputBytes: 10 * 1024 * 1024,
timeoutMs: timeout
})
} catch (error) {
throw new EmulatorError(
'emulator_error',
error instanceof Error ? error.message : 'serve-sim command failed'
)
})
}
if (result.code !== 0 || result.timedOut) {
const message = result.stdout || result.stderr || 'serve-sim command failed'
if (/no serve-sim server|not running/i.test(message)) {
throw new EmulatorError(
'emulator_no_active',
'No active emulator for this worktree — use orca emulator list/attach or open the pane'
)
}
throw new EmulatorError('emulator_error', message)
}
if (options?.json) {
try {
return JSON.parse(result.stdout)
} catch {
return result.stdout.trim()
}
}
return result.stdout.trim()
}
+2 -2
View File
@@ -90,9 +90,9 @@ describe('mapGhosttyToOrca — window-padding', () => {
expect(result.unsupportedKeys).toEqual([])
})
it('rounds odd-sum dual-value padding to the integer used for fitting', () => {
it('preserves odd-sum dual-value padding as a fractional average', () => {
const result = mapGhosttyToOrca({ 'window-padding-x': '1,2' })
expect(result.diff).toEqual({ terminalPaddingX: 2 })
expect(result.diff).toEqual({ terminalPaddingX: 1.5 })
expect(result.unsupportedKeys).toEqual([])
})
+4 -5
View File
@@ -1,5 +1,3 @@
import { normalizeTerminalPadding } from '../../shared/terminal-padding-settings'
// Why: `Number("1e10")` succeeds and passes `Number.isInteger`, so a Ghostty
// config with `window-padding-x = 1e9` would sail through the mapper and land
// an absurd value in the store. Restrict to plain decimal integers.
@@ -12,8 +10,9 @@ export const parseStrictInt = (v: string): number | null => {
return Number.isFinite(num) ? num : null
}
// Why: Orca stores one integer per axis, so collapse paired paddings to the
// nearest value that xterm's integer-based fit calculation will also use.
// Why: Ghostty accepts "top,bottom" / "left,right" pairs for window paddings,
// but Orca stores a single value per axis — average the pair so the total
// padding along the axis stays the same.
export const parsePaddingValue = (v: string): number | null => {
const parts = v.split(',')
if (parts.length > 2) {
@@ -27,5 +26,5 @@ export const parsePaddingValue = (v: string): number | null => {
}
nums.push(num)
}
return normalizeTerminalPadding(nums.reduce((sum, num) => sum + num, 0) / nums.length)
return nums.reduce((sum, num) => sum + num, 0) / nums.length
}
+9 -10
View File
@@ -25,6 +25,13 @@ const GROK_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'grok-hook.cmd' : '
const WINDOWS_POWERSHELL_LAUNCHER =
/^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand \S+$/
// Why (#14828): Windows registers the bare script path when it is cmd-safe and only falls back
// to the encoded launcher for a profile path that is not (#6078). windows-hook-launcher-chain
// .test.ts pins which branch applies; these cases only care that Orca's hook is present.
function registersManagedGrokScript(command: string): boolean {
return command.includes(GROK_SCRIPT_FILE_NAME) || WINDOWS_POWERSHELL_LAUNCHER.test(command)
}
type WindowsGrokHookRun = {
status: number | null
stderr: string
@@ -253,9 +260,7 @@ describe('GrokHookService', () => {
// Why: build the invalid pattern at runtime so static lint does not flag it.
const bareStar = ['*', ''].join('')
expect(() => new RegExp(bareStar)).toThrow()
expect(config.hooks.PreToolUse[0].hooks[0].command).toMatch(
process.platform === 'win32' ? WINDOWS_POWERSHELL_LAUNCHER : /grok-hook/
)
expect(registersManagedGrokScript(config.hooks.PreToolUse[0].hooks[0].command)).toBe(true)
if (process.platform !== 'win32') {
expect(config.hooks.PreToolUse[0].hooks[0].command).toContain(join(homeDir, '.orca'))
}
@@ -364,12 +369,6 @@ describe('GrokHookService', () => {
definition.hooks.map((hook) => hook.command)
)
expect(commands).toContain('/usr/local/bin/user-hook')
expect(
commands.some((command) =>
process.platform === 'win32'
? WINDOWS_POWERSHELL_LAUNCHER.test(command)
: command.includes(GROK_SCRIPT_FILE_NAME)
)
).toBe(true)
expect(commands.some(registersManagedGrokScript)).toBe(true)
})
})
+16 -2
View File
@@ -9,7 +9,7 @@ import {
readHooksJson,
removeManagedCommands,
wrapPosixHookCommand,
wrapWindowsHookCommand,
wrapWindowsCmdHookCommand,
writeHooksJson,
writeManagedScript,
type HookDefinition
@@ -102,11 +102,25 @@ function getManagedScriptPath(): string {
}
function getManagedCommand(scriptPath: string): string {
// Why (#14828): Grok runs a hook command containing a space as `pwsh -Command <cmd>`, so the
// encoded PowerShell launcher cost two interpreters before reaching the script —
// `grok.exe -> pwsh.exe -> powershell.exe -> cmd.exe`, ~610ms per event, and the agent's hook
// console stays up for all of it. A cmd-safe bare path is spawned directly
// (`grok.exe -> cmd.exe`, ~110ms), the same shape Codex/Devin/Antigravity already register
// (#8430). Paths that are not cmd-safe still fall back to the encoded launcher (#6078).
// Tradeoff, as for those agents: a bare path cannot carry the launcher's missing-script
// guard, so a deleted script surfaces as a per-event `command not found` in the agent's log
// instead of a silent drain. Grok fails open — the tool still runs, including on PreToolUse.
return process.platform === 'win32'
? wrapWindowsHookCommand(scriptPath)
? wrapWindowsCmdHookCommand(scriptPath)
: wrapPosixHookCommand(scriptPath)
}
/** Test seam: the command registered for `scriptPath` on the current platform. */
export function getManagedCommandForTests(scriptPath: string): string {
return getManagedCommand(scriptPath)
}
function getManagedScript(target: 'local' | 'posix' = 'local'): string {
if (target === 'local' && process.platform === 'win32') {
return buildWindowsGrokHookScript()
@@ -0,0 +1,190 @@
// Why (#14828): Grok runs a hook `command` containing a space as `pwsh -Command <cmd>`, so the
// encoded PowerShell launcher put two interpreters between the agent and the script —
// `grok.exe -> pwsh.exe -> powershell.exe -> cmd.exe` — and the console Grok allocates for a
// hook stays up for the whole chain. Measured against Grok 1.0.5's own dispatcher timing, the
// same script costs ~610ms behind the launcher and ~110ms as a bare path. These assertions pin
// the launch shape; the payload/exit-code contract stays covered by hook-service.test.ts.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
const { homedirMock } = vi.hoisted(() => ({
homedirMock: vi.fn<() => string>()
}))
vi.mock('os', async () => {
const actual = (await vi.importActual('os')) as Record<string, unknown>
return {
...actual,
homedir: homedirMock
}
})
import { getManagedCommandForTests, GrokHookService } from './hook-service'
import { wrapWindowsHookCommand } from '../agent-hooks/installer-utils'
const GROK_EVENT_NAMES = [
'SessionStart',
'UserPromptSubmit',
'Stop',
'StopFailure',
'SessionEnd',
'PreToolUse',
'PostToolUse',
'PostToolUseFailure',
'Notification'
] as const
type InstalledConfig = {
hooks: Record<string, { matcher?: string; hooks: { command: string }[] }[]>
}
// Why: generate under a mocked win32 so the POSIX CI legs guard this too (#15117).
function withWin32<T>(run: () => T): T {
const original = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
try {
return run()
} finally {
if (original) {
Object.defineProperty(process, 'platform', original)
}
}
}
function readInstalledConfig(home: string): InstalledConfig {
return JSON.parse(
readFileSync(join(home, '.grok', 'hooks', 'orca-status.json'), 'utf8')
) as InstalledConfig
}
function registeredCommands(config: InstalledConfig): string[] {
return GROK_EVENT_NAMES.flatMap((eventName) =>
(config.hooks[eventName] ?? []).flatMap((definition) =>
definition.hooks.map((hook) => hook.command)
)
)
}
describe('Windows Grok managed hook launch shape', () => {
// Why: only `process.platform` can be mocked, not `path.join`, so an install on a POSIX
// runner produces a `/`-separated path that is cmd-safe by no definition and routes to the
// #6078 fallback by design. These synthetic-path cases carry the invariant on every CI leg;
// the install-based ones below re-check it against a real generated path on Windows.
describe('registered command, from a synthetic Windows path', () => {
const scriptPath = 'C:\\Users\\dev\\.orca\\agent-hooks\\grok-hook.cmd'
it('registers a cmd-safe script path as the command itself (#14828)', () => {
const command = withWin32(() => getManagedCommandForTests(scriptPath))
// Why (#8430): Grok spawns this as argv[0], so it must be exactly one spawnable token.
expect(command).toBe(scriptPath)
// Why: whitespace is the sole trigger for Grok's `pwsh -Command` wrapper, which is the
// interpreter this fix removes. Assert the trigger, not just the absence of the name.
expect(command, 'a space would reintroduce the pwsh wrapper').not.toMatch(/\s/)
expect(command).not.toMatch(/powershell|pwsh/i)
expect(command).not.toMatch(/-EncodedCommand/i)
})
it('still wraps a path cmd.exe would split or expand (#6078)', () => {
const spaced = 'C:\\Users\\Jane Doe\\.orca\\agent-hooks\\grok-hook.cmd'
const command = withWin32(() => getManagedCommandForTests(spaced))
expect(command).toMatch(/-EncodedCommand \S+$/)
// Why: the raw path must never reach a cmd.exe command line unquoted.
expect(command).not.toContain(spaced)
})
})
describe('installed config', () => {
let home = ''
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'orca-grok-launcher-'))
homedirMock.mockReturnValue(home)
})
afterEach(() => {
vi.clearAllMocks()
rmSync(home, { recursive: true, force: true })
home = ''
})
// win32-only: a real cmd-safe managed path needs backslashes; see the note above.
it.skipIf(process.platform !== 'win32')(
'writes the generated script path itself to every managed event',
() => {
const scriptPath = join(home, '.orca', 'agent-hooks', 'grok-hook.cmd')
const commands = withWin32(() => {
expect(new GrokHookService().install().state).toBe('installed')
return registeredCommands(readInstalledConfig(home))
})
expect(commands).toHaveLength(GROK_EVENT_NAMES.length)
for (const command of commands) {
expect(command).toBe(scriptPath)
expect(existsSync(command), 'registered command must name a real file').toBe(true)
}
}
)
it.skipIf(process.platform !== 'win32')(
'replaces a previously installed encoded-PowerShell entry on reinstall',
() => {
const scriptPath = join(home, '.orca', 'agent-hooks', 'grok-hook.cmd')
const staleCommand = wrapWindowsHookCommand(scriptPath)
const configPath = join(home, '.grok', 'hooks', 'orca-status.json')
mkdirSync(dirname(configPath), { recursive: true })
writeFileSync(
configPath,
`${JSON.stringify({
hooks: {
Stop: [{ hooks: [{ type: 'command', command: staleCommand, timeout: 10 }] }],
// Why: a retired event must be swept too, or the old launcher keeps firing there.
SubagentStop: [{ hooks: [{ type: 'command', command: staleCommand, timeout: 10 }] }],
Notification: [{ hooks: [{ type: 'command', command: '/usr/local/bin/user-hook' }] }]
}
})}\n`,
'utf8'
)
const config = withWin32(() => {
expect(new GrokHookService().install().state).toBe('installed')
return readInstalledConfig(home)
})
const all = Object.values(config.hooks).flatMap((definitions) =>
definitions.flatMap((definition) => definition.hooks.map((hook) => hook.command))
)
expect(all).not.toContain(staleCommand)
expect(all.filter((command) => /-EncodedCommand/i.test(command))).toEqual([])
expect(config.hooks.SubagentStop).toBeUndefined()
// Why: sweeping stale Orca entries must not touch hooks the user wrote.
expect(all).toContain('/usr/local/bin/user-hook')
expect(all.filter((command) => command === scriptPath)).toHaveLength(
GROK_EVENT_NAMES.length
)
}
)
it('falls back to the encoded launcher when the profile path is not cmd-safe (#6078)', () => {
const spaceHome = mkdtempSync(join(tmpdir(), 'orca grok spaced '))
homedirMock.mockReturnValue(spaceHome)
try {
const commands = withWin32(() => {
expect(new GrokHookService().install().state).toBe('installed')
return registeredCommands(readInstalledConfig(spaceHome))
})
expect(commands.length).toBeGreaterThan(0)
for (const command of commands) {
expect(command).toMatch(/-EncodedCommand \S+$/)
expect(command).not.toContain(spaceHome)
}
} finally {
rmSync(spaceHome, { recursive: true, force: true })
}
})
})
})
+2
View File
@@ -3397,6 +3397,7 @@ app.on('will-quit', (e) => {
codexUsage?.flush(),
openCodeUsage?.flush()
]).then(() => {})
const skillUploadShutdown = runtime?.disposeSkillUploadSessions() ?? Promise.resolve()
// Why: capture pid/runtimeId synchronously (before any await) so a later teardown path can't null them out mid-chain.
const ownedPid = process.pid
@@ -3431,6 +3432,7 @@ app.on('will-quit', (e) => {
{ name: 'emulator', promise: emulatorShutdown },
{ name: 'ssh', promise: sshShutdown },
{ name: 'plugin-hosts', promise: pluginHostShutdown },
{ name: 'skill-uploads', promise: skillUploadShutdown },
{ name: 'codex-backfill-recovery', promise: codexBackfillRecoveryShutdown },
{ name: 'usage-cache', promise: usageCacheFlush },
{ name: 'stats', promise: statsFlush },
@@ -158,7 +158,8 @@ describe('registerPtyHandlers', () => {
tabId: 'tab-headless',
leafId,
ptyId: expect.any(String),
incarnationId: expect.any(String)
incarnationId: expect.any(String),
hostAdmittedMembership: true
})
})
it('shuts down a split PTY when its expected source binding was retired', async () => {
@@ -511,7 +512,8 @@ describe('registerPtyHandlers', () => {
tabId: 'tab-race',
leafId,
ptyId: 'pty-shared',
startupCwd: '/tmp'
startupCwd: '/tmp',
hostAdmittedMembership: true
})
})
})
@@ -530,7 +530,8 @@ describe('registerPtyHandlers', () => {
worktreeId: 'wt-remote',
tabId: 'tab-remote',
leafId,
ptyId: 'ssh:ssh-1@@relay-pty'
ptyId: 'ssh:ssh-1@@relay-pty',
hostAdmittedMembership: true
},
'ssh:ssh-1'
)
@@ -199,7 +199,8 @@ describe('registerPtyHandlers', () => {
worktreeId: 'wt-remote',
tabId: 'tab-remote',
leafId,
ptyId: 'ssh:ssh-reattach-ok@@relay-pty'
ptyId: 'ssh:ssh-reattach-ok@@relay-pty',
hostAdmittedMembership: true
},
'ssh:ssh-reattach-ok'
)
+1
View File
@@ -5356,6 +5356,7 @@ export function registerPtyHandlers(
tabId: hostSessionBinding.tabId,
leafId: hostSessionBinding.leafId,
ptyId: result.id,
hostAdmittedMembership: true,
...(result.incarnationId ? { incarnationId: result.incarnationId } : {}),
...(cwd ? { startupCwd: cwd } : {}),
...(hostSessionBinding.expectedSourceBinding
+199 -2
View File
@@ -1,21 +1,34 @@
import { win32 } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { getWslHomeMock, parseWslPathMock } = vi.hoisted(() => ({
const { getWslHomeMock, getWslHomeAsyncMock, parseWslPathMock } = vi.hoisted(() => ({
getWslHomeMock: vi.fn(),
getWslHomeAsyncMock: vi.fn(),
parseWslPathMock: vi.fn()
}))
vi.mock('../wsl', () => ({
getWslHome: getWslHomeMock,
getWslHomeAsync: getWslHomeAsyncMock,
parseWslPath: parseWslPathMock
}))
import { computeWorktreePath } from './worktree-logic'
import {
computeWorktreePath,
computeWorktreePathAsync,
getWorktreePathSettings
} from './worktree-logic'
import {
buildKnownOrcaWorkspaceLayouts,
classifyWorktreeOwnership
} from '../../shared/worktree/ownership'
import { relativePathInsideRoot } from '../../shared/cross-platform-path'
import type { Repo } from '../../shared/repo-types'
describe('computeWorktreePath WSL layout', () => {
beforeEach(() => {
getWslHomeMock.mockReset()
getWslHomeAsyncMock.mockReset()
parseWslPathMock.mockReset()
})
@@ -49,6 +62,190 @@ describe('computeWorktreePath WSL layout', () => {
).toBe(win32.join('C:\\workspaces', 'feature'))
})
it('honors an absolute Linux repo worktree base path inside the repo distro (STA-4772)', () => {
parseWslPathMock.mockReturnValue({
distro: 'Ubuntu',
linuxPath: '/home/jin/src/repo'
})
getWslHomeMock.mockReturnValue('\\\\wsl.localhost\\Ubuntu\\home\\jin')
const repo = {
path: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\src\\repo',
worktreeBasePath: '/home/jin/src/.orca-worktrees'
}
const settings = { nestWorkspaces: false, workspaceDir: 'C:\\workspaces' }
expect(computeWorktreePath('feature', repo.path, getWorktreePathSettings(repo, settings))).toBe(
'\\\\wsl.localhost\\Ubuntu\\home\\jin\\src\\.orca-worktrees\\feature'
)
// Why repeat: cached follow-up calls must resolve identically to the first.
expect(computeWorktreePath('feature', repo.path, getWorktreePathSettings(repo, settings))).toBe(
'\\\\wsl.localhost\\Ubuntu\\home\\jin\\src\\.orca-worktrees\\feature'
)
expect(getWslHomeMock).not.toHaveBeenCalled()
})
it('honors a drvfs /mnt repo base path instead of mirroring it away', () => {
parseWslPathMock.mockReturnValue({
distro: 'Ubuntu',
linuxPath: '/home/jin/src/repo'
})
getWslHomeMock.mockReturnValue('\\\\wsl.localhost\\Ubuntu\\home\\jin')
const repo = {
path: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\src\\repo',
worktreeBasePath: '/mnt/d/trees'
}
expect(
computeWorktreePath(
'feature',
repo.path,
getWorktreePathSettings(repo, { nestWorkspaces: false, workspaceDir: 'C:\\workspaces' })
)
).toBe('\\\\wsl.localhost\\Ubuntu\\mnt\\d\\trees\\feature')
})
it('collapses dotted Linux repo base paths end to end', () => {
parseWslPathMock.mockReturnValue({
distro: 'Ubuntu',
linuxPath: '/home/jin/src/repo'
})
getWslHomeMock.mockReturnValue('\\\\wsl.localhost\\Ubuntu\\home\\jin')
const repo = {
path: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\src\\repo',
worktreeBasePath: '/home/jin/src/../trees'
}
expect(
computeWorktreePath(
'feature',
repo.path,
getWorktreePathSettings(repo, { nestWorkspaces: false, workspaceDir: 'C:\\workspaces' })
)
).toBe('\\\\wsl.localhost\\Ubuntu\\home\\jin\\trees\\feature')
})
it('still mirrors drive-letter repo base paths into the distro home', () => {
parseWslPathMock.mockReturnValue({
distro: 'Ubuntu',
linuxPath: '/home/jin/src/repo'
})
getWslHomeMock.mockReturnValue('\\\\wsl.localhost\\Ubuntu\\home\\jin')
const repo = {
path: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\src\\repo',
worktreeBasePath: 'D:\\trees'
}
// Why: desktop drive roots keep WSL worktrees on the WSL filesystem by design.
expect(
computeWorktreePath(
'feature',
repo.path,
getWorktreePathSettings(repo, { nestWorkspaces: false, workspaceDir: 'C:\\workspaces' })
)
).toBe('\\\\wsl.localhost\\Ubuntu\\home\\jin\\orca\\workspaces\\feature')
})
it('classifies whatever creation produces for Linux bases, dotted or not', () => {
parseWslPathMock.mockReturnValue({
distro: 'Ubuntu',
linuxPath: '/home/jin/src/repo'
})
getWslHomeMock.mockReturnValue('\\\\wsl.localhost\\Ubuntu\\home\\jin')
const settings = { nestWorkspaces: true, workspaceDir: 'C:\\workspaces' }
for (const worktreeBasePath of ['/home/jin/trees', '/home/jin/src/../trees', '/mnt/d/trees']) {
const repo = {
id: 'r1',
path: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\src\\repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
kind: 'git',
worktreeBasePath
} as Repo
// Why: the resolver exists so these two layers agree; assert the
// invariant directly instead of two hardcoded strings that happen to match.
const createdPath = computeWorktreePath(
'feature',
repo.path,
getWorktreePathSettings(repo, settings)
)
const layouts = buildKnownOrcaWorkspaceLayouts({ ...settings, workspaceDirHistory: [] }, repo)
// Why containment, not just ownership: a regressed resolver lands in the
// ~/orca/workspaces mirror layout, which also classifies 'external'.
// layouts[0] is the repo-base layout — it is always pushed first.
expect(relativePathInsideRoot(layouts[0].path, createdPath)).not.toBeNull()
expect(
classifyWorktreeOwnership({
repo,
settings: { ...settings, workspaceDirHistory: [] },
worktree: { path: createdPath, isMainWorktree: false },
knownOrcaLayouts: layouts
})
).toBe('external')
}
})
it('resolves the Linux repo base path identically through the async twin', async () => {
parseWslPathMock.mockReturnValue({
distro: 'Ubuntu',
linuxPath: '/home/jin/src/repo'
})
getWslHomeMock.mockReturnValue('\\\\wsl.localhost\\Ubuntu\\home\\jin')
getWslHomeAsyncMock.mockResolvedValue('\\\\wsl.localhost\\Ubuntu\\home\\jin')
const repo = {
path: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\src\\repo',
worktreeBasePath: '/home/jin/src/.orca-worktrees'
}
const pathSettings = getWorktreePathSettings(repo, {
nestWorkspaces: false,
workspaceDir: 'C:\\workspaces'
})
await expect(computeWorktreePathAsync('feature', repo.path, pathSettings)).resolves.toBe(
computeWorktreePath('feature', repo.path, pathSettings)
)
})
it('honors a Linux repo base path even when the distro home lookup fails', () => {
parseWslPathMock.mockReturnValue({
distro: 'Ubuntu',
linuxPath: '/home/jin/src/repo'
})
getWslHomeMock.mockReturnValue(null)
const repo = {
path: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\src\\repo',
worktreeBasePath: '/home/jin/trees'
}
expect(
computeWorktreePath(
'feature',
repo.path,
getWorktreePathSettings(repo, { nestWorkspaces: false, workspaceDir: 'C:\\workspaces' })
)
).toBe('\\\\wsl.localhost\\Ubuntu\\home\\jin\\trees\\feature')
})
it('keeps relative repo base paths anchored to the WSL repo', () => {
parseWslPathMock.mockReturnValue({
distro: 'Ubuntu',
linuxPath: '/home/jin/src/repo'
})
getWslHomeMock.mockReturnValue('\\\\wsl.localhost\\Ubuntu\\home\\jin')
const repo = {
path: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\src\\repo',
worktreeBasePath: '../worktrees'
}
expect(
computeWorktreePath(
'feature',
repo.path,
getWorktreePathSettings(repo, { nestWorkspaces: false, workspaceDir: 'C:\\workspaces' })
)
).toBe('\\\\wsl.localhost\\Ubuntu\\home\\jin\\src\\worktrees\\feature')
})
it('uses an explicit WSL UNC workspace root without remapping it', () => {
parseWslPathMock.mockReturnValue({
distro: 'Ubuntu',
+6 -2
View File
@@ -2,7 +2,7 @@ import { resolve, relative, isAbsolute, posix, sep, win32 } from 'node:path'
import type { GlobalSettings, OrcaWorkspaceLayout } from '../../shared/global-settings-types'
import type { Repo } from '../../shared/repo-types'
import { isWindowsAbsolutePathLike, resolveRuntimePath } from '../../shared/cross-platform-path'
import { isWslUncPath } from '../../shared/wsl-paths'
import { isWslUncPath, resolveWslRepoWorktreeBasePath } from '../../shared/wsl-paths'
import { splitWorktreeId } from '../../shared/worktree/id'
import { replaceKnownEmojiWithShortcodes } from '../../shared/emoji-shortcode-catalog'
import { getWslHome, getWslHomeAsync, parseWslPath } from '../wsl'
@@ -226,7 +226,11 @@ function getEffectiveWorktreeBasePath(
repo: WorktreeBasePathRepo,
settings: WorktreePathSettings
): string {
return getRepoWorktreeBasePath(repo) ?? settings.workspaceDir
const basePath = getRepoWorktreeBasePath(repo)
if (basePath === undefined) {
return settings.workspaceDir
}
return resolveWslRepoWorktreeBasePath(repo.path, basePath)
}
function getRepoWorktreeBasePath(repo: Pick<Repo, 'worktreeBasePath'>): string | undefined {
@@ -1,4 +1,5 @@
import { describe, expect, it, vi, type Mock } from 'vitest'
import { validateGitExecArgs } from '../../relay/git-exec-validator'
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
import type { GitPushTarget } from '../../shared/worktree/types'
import {
@@ -81,6 +82,20 @@ describe('cleanupUnusedWorktreePushTargetRemoteWithExec', () => {
expect(removeCalls(exec)).toEqual([['remote', 'remove', FORK_REMOTE]])
})
it('sends only argv the relay accepts, so SSH cleanup is not silently skipped', async () => {
const exec = makeExec()
await cleanupUnusedWorktreePushTargetRemoteWithExec(
REPO_PATH,
'repo-1::/wt/a',
forkTarget(),
storeOf({ 'repo-1::/wt/a': forkTarget() }),
exec
)
for (const [args] of exec.mock.calls) {
expect(() => validateGitExecArgs(args)).not.toThrow()
}
})
it('keeps a remote Orca did not create (remoteCreated falsy)', async () => {
const exec = makeExec()
await cleanupUnusedWorktreePushTargetRemoteWithExec(
@@ -32,6 +32,10 @@ function makeRepoExec(remotes: Record<string, string>): ExecMock {
remotes[args[2]!] = args[3]!
return { stdout: '', stderr: '' }
}
if (args[0] === 'remote' && args[1] === 'remove') {
delete remotes[args[2]!]
return { stdout: '', stderr: '' }
}
return { stdout: '', stderr: '' }
})
}
@@ -184,3 +188,54 @@ describe('configureCreatedWorktreePushTargetWithExec', () => {
expect(result).toBe(target)
})
})
describe('prepareWorktreePushTargetWithExec rollback', () => {
it('removes the remote it just added when the fetch fails', async () => {
const remotes: Record<string, string> = { origin: 'git@github.com:stablyai/orca.git' }
const exec = vi.fn<GitRemoteExec>(async (args: string[]) => {
if (args[0] === 'fetch') {
throw new Error('network unreachable')
}
return makeRepoExec(remotes)(args, REPO)
})
await expect(
prepareWorktreePushTargetWithExec(
exec,
REPO,
{ remoteName: 'pr-contributor-orca', branchName: 'contributor/fix', remoteUrl: FORK_SSH },
() => false
)
).rejects.toThrow('network unreachable')
expect(callsMatching(exec, ['remote', 'remove'])).toEqual([
['remote', 'remove', 'pr-contributor-orca']
])
expect(remotes).not.toHaveProperty('pr-contributor-orca')
})
it('keeps a reused remote Orca did not add when the fetch fails', async () => {
const remotes: Record<string, string> = {
origin: 'git@github.com:stablyai/orca.git',
existing: FORK_SSH
}
const exec = vi.fn<GitRemoteExec>(async (args: string[]) => {
if (args[0] === 'fetch') {
throw new Error('network unreachable')
}
return makeRepoExec(remotes)(args, REPO)
})
await expect(
prepareWorktreePushTargetWithExec(
exec,
REPO,
{ remoteName: 'pr-contributor-orca', branchName: 'contributor/fix', remoteUrl: FORK_SSH },
() => false
)
).rejects.toThrow('network unreachable')
expect(callsMatching(exec, ['remote', 'remove'])).toEqual([])
expect(remotes.existing).toBe(FORK_SSH)
})
})
+18 -8
View File
@@ -95,14 +95,24 @@ export async function prepareWorktreePushTargetWithExec(
}
}
await execGit(
[
'fetch',
remoteName,
`+refs/heads/${target.branchName}:refs/remotes/${remoteName}/${target.branchName}`
],
repoPath
)
try {
await execGit(
[
'fetch',
remoteName,
`+refs/heads/${target.branchName}:refs/remotes/${remoteName}/${target.branchName}`
],
repoPath
)
} catch (error) {
// Why: the create this remote was added for is failing; leaving it behind
// orphans a pr-* remote nothing will ever clean up (cleanup runs on worktree
// removal, and no worktree exists).
if (remoteCreated) {
await execGit(['remote', 'remove', remoteName], repoPath).catch(() => {})
}
throw error
}
return {
...sanitizedTarget,
remoteName,
+26 -7
View File
@@ -1099,16 +1099,35 @@ async function prepareWorktreePushTargetSsh(
: false
} else {
remoteName = await ensureUniqueRemoteName(execGit, repoPath, target.remoteName)
await provider.exec(['remote', 'add', remoteName, target.remoteUrl], repoPath)
try {
await provider.exec(['remote', 'add', remoteName, target.remoteUrl], repoPath)
} catch (error) {
// Why: relays predating fork-remote support reject this exec by policy; name the fix instead of surfacing their rule.
if (error instanceof Error && error.message.includes('Destructive git remote operations')) {
throw new Error(
'This SSH host is running an older Orca relay that cannot add a fork remote for a PR workspace. Reconnect to deploy the latest relay, then try again.'
)
}
throw error
}
remoteCreated = true
}
}
await provider.fetchRemoteTrackingRef(
repoPath,
remoteName,
target.branchName,
`refs/remotes/${remoteName}/${target.branchName}`
)
try {
await provider.fetchRemoteTrackingRef(
repoPath,
remoteName,
target.branchName,
`refs/remotes/${remoteName}/${target.branchName}`
)
} catch (error) {
// Why: mirrors the local path — a fetch failure aborts the create, so the
// remote we just added on the host would be orphaned with no owner to clean it.
if (remoteCreated) {
await provider.exec(['remote', 'remove', remoteName], repoPath).catch(() => {})
}
throw error
}
return { ...sanitizedTarget, remoteName, ...(remoteCreated ? { remoteCreated: true } : {}) }
}
@@ -0,0 +1,269 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { validateGitExecArgs } from '../../relay/git-exec-validator'
import { getSshGitProviderMock, getActiveMultiplexerMock } from './worktrees-test-module-mocks'
import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness'
vi.mock('electron', async () =>
(await import('./worktrees-test-module-mocks')).electronModuleMock()
)
vi.mock('../git/worktree', async () =>
(await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock()
)
vi.mock('../git/runner', async () =>
(await import('./worktrees-test-module-mocks')).gitRunnerModuleMock()
)
vi.mock('../git/repo', async () =>
(await import('./worktrees-test-module-mocks')).gitRepoModuleMock()
)
vi.mock('../git/git-username', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
resolveLocalGitUsername: (await import('./worktrees-test-module-mocks'))
.resolveLocalGitUsernameMock
}))
vi.mock('../github/client', async () =>
(await import('./worktrees-test-module-mocks')).githubClientModuleMock()
)
vi.mock('../source-control/hosted-review', async () =>
(await import('./worktrees-test-module-mocks')).hostedReviewModuleMock()
)
vi.mock('../providers/ssh-git-dispatch', async () =>
(await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock()
)
vi.mock('../providers/ssh-filesystem-dispatch', async () =>
(await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock()
)
vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock())
vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock())
vi.mock('../setup-runner-script-text', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('../worktree-runner-script', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('../effective-hook-config', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('../setup-hook-env-vars', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('./worktree-logic', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('../terminal-history-deletion', async () =>
(await import('./worktrees-test-module-mocks')).terminalHistoryDeletionModuleMock()
)
vi.mock('../ports/advertised-url-watcher', async () =>
(await import('./worktrees-test-module-mocks')).advertisedUrlWatcherModuleMock()
)
vi.mock('../workspace-cleanup-scan-snapshot', async () =>
(await import('./worktrees-test-module-mocks')).workspaceCleanupScanSnapshotModuleMock()
)
vi.mock('../workspace-space-analysis-snapshot', async () =>
(await import('./worktrees-test-module-mocks')).workspaceSpaceAnalysisSnapshotModuleMock()
)
vi.mock('../workspace-cleanup-removal-snapshot-prune', async () =>
(await import('./worktrees-test-module-mocks')).workspaceCleanupRemovalSnapshotPruneModuleMock()
)
vi.mock('../runtime/worktree-teardown', async () =>
(await import('./worktrees-test-module-mocks')).worktreeTeardownModuleMock()
)
vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock())
describe('registerWorktreeHandlers', () => {
beforeEach(() => {
setupWorktreeHandlers()
})
it('adds the fork remote for an SSH fork-PR worktree through git.exec', async () => {
const repo = {
id: 'repo-ssh',
path: '/remote/repo',
displayName: 'ssh',
badgeColor: '#000',
addedAt: 0,
connectionId: 'conn-1',
worktreeBaseRef: 'origin/main'
}
// Why: run every exec through the relay's own validator so a create shape the
// relay rejects fails here instead of on the user's SSH host.
const exec = vi.fn().mockImplementation(async (args: string[]) => {
validateGitExecArgs(args)
if (args[0] === 'remote' && args[1] === 'get-url') {
return { stdout: 'git@github.com:stablyai/orca.git\n', stderr: '' }
}
if (args[0] === 'remote' && args.length === 1) {
return { stdout: 'origin\n', stderr: '' }
}
return { stdout: '', stderr: '' }
})
const provider = {
exec,
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
addWorktree: vi.fn().mockResolvedValue(undefined),
listWorktrees: vi.fn().mockResolvedValue([
{
path: '/remote/repo-contributor-fix',
head: 'abc123',
branch: 'refs/heads/contributor/fix',
isBare: false,
isMainWorktree: false
}
])
}
const mux = { request: vi.fn().mockResolvedValue(undefined), notify: vi.fn() }
store.getRepos.mockReturnValue([repo])
store.getRepo.mockReturnValue(repo)
getSshGitProviderMock.mockReturnValue(provider)
getActiveMultiplexerMock.mockReturnValue(mux)
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
await handlers['worktrees:create'](null, {
repoId: 'repo-ssh',
name: 'contributor-fix',
branchNameOverride: 'contributor/fix',
pushTarget: {
remoteName: 'pr-contributor-orca',
branchName: 'contributor/fix',
remoteUrl: 'https://github.com/contributor/orca.git'
}
})
expect(exec).toHaveBeenCalledWith(
['remote', 'add', 'pr-contributor-orca', 'https://github.com/contributor/orca.git'],
'/remote/repo'
)
expect(provider.fetchRemoteTrackingRef).toHaveBeenCalledWith(
'/remote/repo',
'pr-contributor-orca',
'contributor/fix',
'refs/remotes/pr-contributor-orca/contributor/fix'
)
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
pushTarget: {
remoteName: 'pr-contributor-orca',
branchName: 'contributor/fix',
remoteUrl: 'https://github.com/contributor/orca.git',
remoteCreated: true
}
})
)
})
it('names the relay upgrade when an older host still rejects the fork remote', async () => {
const repo = {
id: 'repo-ssh',
path: '/remote/repo',
displayName: 'ssh',
badgeColor: '#000',
addedAt: 0,
connectionId: 'conn-1',
worktreeBaseRef: 'origin/main'
}
const provider = {
exec: vi.fn().mockImplementation(async (args: string[]) => {
if (args[0] === 'remote' && args[1] === 'add') {
throw new Error('Destructive git remote operations are not allowed via exec')
}
if (args[0] === 'remote' && args[1] === 'get-url') {
return { stdout: 'git@github.com:stablyai/orca.git\n', stderr: '' }
}
if (args[0] === 'remote' && args.length === 1) {
return { stdout: 'origin\n', stderr: '' }
}
return { stdout: '', stderr: '' }
}),
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
addWorktree: vi.fn().mockResolvedValue(undefined),
listWorktrees: vi.fn().mockResolvedValue([])
}
const mux = { request: vi.fn().mockResolvedValue(undefined), notify: vi.fn() }
store.getRepos.mockReturnValue([repo])
store.getRepo.mockReturnValue(repo)
getSshGitProviderMock.mockReturnValue(provider)
getActiveMultiplexerMock.mockReturnValue(mux)
await expect(
handlers['worktrees:create'](null, {
repoId: 'repo-ssh',
name: 'contributor-fix',
branchNameOverride: 'contributor/fix',
pushTarget: {
remoteName: 'pr-contributor-orca',
branchName: 'contributor/fix',
remoteUrl: 'https://github.com/contributor/orca.git'
}
})
).rejects.toThrow('Reconnect to deploy the latest relay')
expect(provider.addWorktree).not.toHaveBeenCalled()
})
it('drops the fork remote it just added when the SSH head fetch fails', async () => {
const repo = {
id: 'repo-ssh',
path: '/remote/repo',
displayName: 'ssh',
badgeColor: '#000',
addedAt: 0,
connectionId: 'conn-1',
worktreeBaseRef: 'origin/main'
}
const exec = vi.fn().mockImplementation(async (args: string[]) => {
validateGitExecArgs(args)
if (args[0] === 'remote' && args[1] === 'get-url') {
return { stdout: 'git@github.com:stablyai/orca.git\n', stderr: '' }
}
if (args[0] === 'remote' && args.length === 1) {
return { stdout: 'origin\n', stderr: '' }
}
return { stdout: '', stderr: '' }
})
const provider = {
exec,
fetchRemoteTrackingRef: vi
.fn()
.mockImplementation(async (_repoPath: string, remote: string) => {
if (remote === 'pr-contributor-orca') {
throw new Error('network unreachable')
}
}),
addWorktree: vi.fn().mockResolvedValue(undefined),
listWorktrees: vi.fn().mockResolvedValue([])
}
const mux = { request: vi.fn().mockResolvedValue(undefined), notify: vi.fn() }
store.getRepos.mockReturnValue([repo])
store.getRepo.mockReturnValue(repo)
getSshGitProviderMock.mockReturnValue(provider)
getActiveMultiplexerMock.mockReturnValue(mux)
await expect(
handlers['worktrees:create'](null, {
repoId: 'repo-ssh',
name: 'contributor-fix',
branchNameOverride: 'contributor/fix',
pushTarget: {
remoteName: 'pr-contributor-orca',
branchName: 'contributor/fix',
remoteUrl: 'https://github.com/contributor/orca.git'
}
})
).rejects.toThrow('network unreachable')
expect(exec).toHaveBeenCalledWith(['remote', 'remove', 'pr-contributor-orca'], '/remote/repo')
expect(provider.addWorktree).not.toHaveBeenCalled()
})
})
+7 -3
View File
@@ -81,6 +81,7 @@ import {
createWorktreeVisibilitySourceMatcher,
resolveCustomWorktreeVisibilitySources
} from '../../shared/worktree/visibility-sources'
import { resolveConfiguredWorktreeBasePaths } from '../../shared/worktree/configured-worktree-base-path'
import {
assertWorktreeCleanForRemoval,
forceDeleteLocalBranch,
@@ -885,7 +886,8 @@ function buildDetectedGitWorktrees(
)
const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher(
[repo.path, ...liveWorktrees.map((worktree) => worktree.path)],
resolveCustomWorktreeVisibilitySources(repo, settings.worktreeVisibilityDefaults)
resolveCustomWorktreeVisibilitySources(repo, settings.worktreeVisibilityDefaults),
resolveConfiguredWorktreeBasePaths(repo)
)
const detected = liveWorktrees.map((gitWorktree) => {
const worktreeId = `${repo.id}::${gitWorktree.path}`
@@ -1037,7 +1039,8 @@ function buildFolderDetectedWorktrees(store: Store, repo: Repo): DetectedWorktre
const worktrees = listFolderWorkspaces(store, repo)
const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher(
[repo.path, ...worktrees.map((worktree) => worktree.path)],
resolveCustomWorktreeVisibilitySources(repo, settings.worktreeVisibilityDefaults)
resolveCustomWorktreeVisibilitySources(repo, settings.worktreeVisibilityDefaults),
resolveConfiguredWorktreeBasePaths(repo)
)
return worktrees.map((worktree) =>
toDetectedWorktree({
@@ -1122,7 +1125,8 @@ function buildDisconnectedDetectedWorktrees(
const settings = store.getSettings()
const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher(
[repo.path, ...worktrees.map((worktree) => worktree.path)],
resolveCustomWorktreeVisibilitySources(repo, settings.worktreeVisibilityDefaults)
resolveCustomWorktreeVisibilitySources(repo, settings.worktreeVisibilityDefaults),
resolveConfiguredWorktreeBasePaths(repo)
)
const detected = worktrees.map((worktree) => {
const meta = store.getWorktreeMeta(worktree.id)
+3 -1
View File
@@ -69,14 +69,16 @@ export async function searchLinearIssuesForAgents(args: {
.flat()
.sort((left, right) => Date.parse(right.updatedAt ?? '') - Date.parse(left.updatedAt ?? ''))
const limited = merged.slice(0, limit)
const limitReached = merged.length > limit
return {
issues: limited,
truncated: limitReached,
meta: {
query: args.query,
workspaceId,
limit,
returned: limited.length,
limitReached: merged.length > limit,
limitReached,
partial: perWorkspace.failures.length > 0,
workspaceErrors: perWorkspace.failures.map(({ workspace, code, message }) => ({
workspace,
+3
View File
@@ -3,6 +3,7 @@ import type {
LinearIssueSummary,
LinearSearchIssueSummary
} from '../../shared/linear/agent-access'
import { linearPriorityLabel } from '../../shared/linear/priority-label'
export type RawIssueResponse = {
issue?: RawIssue | null
@@ -233,6 +234,7 @@ export function mapIssue(issue: RawIssue): LinearIssueSummary {
assignee: issue.assignee ?? null,
labels: issue.labels?.nodes ?? [],
priority: issue.priority,
priorityLabel: linearPriorityLabel(issue.priority),
estimate: issue.estimate,
dueDate: issue.dueDate,
branchName: issue.branchName,
@@ -254,6 +256,7 @@ export function pickSearchIssue(
project: issue.project,
assignee: issue.assignee,
priority: issue.priority,
priorityLabel: issue.priorityLabel,
estimate: issue.estimate,
dueDate: issue.dueDate,
updatedAt: issue.updatedAt
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { mapIssue, pickSearchIssue } from './issue-context-raw'
describe('Linear search issue summaries', () => {
it('carries priorityLabel from the numeric priority through pickSearchIssue', () => {
const summary = pickSearchIssue(
mapIssue({
id: 'issue-1',
identifier: 'ENG-1',
title: 'Fix auth',
url: 'https://linear.app/acme/issue/ENG-1',
labels: { nodes: [] },
priority: 1
})
)
expect(summary.priority).toBe(1)
expect(summary.priorityLabel).toBe('urgent')
})
})
+87
View File
@@ -0,0 +1,87 @@
import { linearError } from './issue-context-errors'
export const ISSUE_LIST_CURSOR_PREFIX = 'orca.linear.v1.'
export function encodeIssueListCursor(workspaceId: string, providerCursor: string): string {
return (
ISSUE_LIST_CURSOR_PREFIX +
Buffer.from(JSON.stringify({ workspaceId, cursor: providerCursor }), 'utf8').toString(
'base64url'
)
)
}
export function decodeIssueListCursor(
value: string
): { workspaceId: string; cursor: string } | null {
if (!value.startsWith(ISSUE_LIST_CURSOR_PREFIX)) {
return null
}
try {
const parsed: unknown = JSON.parse(
Buffer.from(value.slice(ISSUE_LIST_CURSOR_PREFIX.length), 'base64url').toString('utf8')
)
if (
parsed &&
typeof parsed === 'object' &&
'workspaceId' in parsed &&
'cursor' in parsed &&
typeof parsed.workspaceId === 'string' &&
typeof parsed.cursor === 'string' &&
// A hand-crafted payload must not smuggle back the fan-out selector or an empty
// workspace, both of which would silently widen the read past the bound workspace.
parsed.workspaceId !== 'all' &&
parsed.workspaceId.length > 0 &&
parsed.cursor.length > 0
) {
return { workspaceId: parsed.workspaceId, cursor: parsed.cursor }
}
} catch {
// Prefix matched but payload is not ours; treat as an invalid issued cursor.
}
return null
}
export function resolveIssueListCursor(request: {
cursor?: string
workspaceId?: (string & {}) | 'all'
}): { workspaceId?: (string & {}) | 'all'; linearCursor?: string } {
const cursor = request.cursor
if (!cursor) {
return { workspaceId: request.workspaceId }
}
if (request.workspaceId === 'all') {
throw cursorWorkspaceError(
'Cursor pagination cannot use --workspace all.',
'Pass a concrete --workspace or reuse an issued list-issues nextCursor without --workspace all.'
)
}
const issued = decodeIssueListCursor(cursor)
if (issued) {
if (request.workspaceId && request.workspaceId !== issued.workspaceId) {
throw cursorWorkspaceError(
'Cursor workspace does not match --workspace.',
`Use --workspace ${issued.workspaceId} or omit --workspace to reuse the issued cursor.`
)
}
return { workspaceId: issued.workspaceId, linearCursor: issued.cursor }
}
if (cursor.startsWith(ISSUE_LIST_CURSOR_PREFIX)) {
throw cursorWorkspaceError(
'Cursor was issued by Orca but is malformed or truncated.',
'Re-run list-issues without --cursor, then page with the nextCursor it returns.'
)
}
if (!request.workspaceId) {
throw cursorWorkspaceError(
'Cursor pagination requires a concrete Linear workspace.',
'Pass --workspace with result.meta.workspaceId from the previous list-issues page.',
'Or reuse nextCursor from a current list-issues page, which binds the workspace.'
)
}
return { workspaceId: request.workspaceId, linearCursor: cursor }
}
function cursorWorkspaceError(message: string, ...nextSteps: string[]) {
return linearError('linear_invalid_workspace', message, { nextSteps })
}
+100
View File
@@ -0,0 +1,100 @@
import type { LinearMcpIssueListRequest } from '../../shared/linear/agent-access'
export function buildIssueFilter(request: LinearMcpIssueListRequest): Record<string, unknown> {
const filter: Record<string, unknown> = {}
if (request.team) {
filter.team = namedFilter(request.team, true)
}
if (request.cycle) {
filter.cycle = nullableNamedFilter(request.cycle)
}
if (request.label) {
filter.labels = { some: namedFilter(request.label) }
}
if (request.query) {
filter.searchableContent = { contains: request.query }
}
if (request.state) {
filter.state = workflowStateFilter(request.state)
}
if (request.project) {
filter.project = nullableProjectFilter(request.project)
}
if (request.release) {
filter.releases = { some: namedFilter(request.release, false, true) }
}
if (request.assignee) {
filter.assignee = nullableUserFilter(request.assignee)
}
if (request.delegate) {
filter.delegate = nullableUserFilter(request.delegate)
}
if (request.parentId) {
filter.parent = nullableIdFilter(request.parentId)
}
if (request.priority !== undefined) {
filter.priority = { eq: request.priority }
}
if (request.createdAt) {
filter.createdAt = { gte: request.createdAt }
}
if (request.updatedAt) {
filter.updatedAt = { gte: request.updatedAt }
}
return filter
}
function namedFilter(value: string, includeKey = false, includeVersion = false): object {
return {
or: [
...(isLinearId(value) ? [{ id: { eq: value } }] : []),
{ name: { eqIgnoreCase: value } },
...(includeKey ? [{ key: { eqIgnoreCase: value } }] : []),
...(includeVersion ? [{ version: { eqIgnoreCase: value } }] : [])
]
}
}
function nullableNamedFilter(value: string): object {
return value === 'null' ? { null: true } : namedFilter(value)
}
function workflowStateFilter(value: string): object {
const filter = namedFilter(value) as { or: object[] }
filter.or.push({ type: { eqIgnoreCase: value } })
return filter
}
function nullableProjectFilter(value: string): object {
if (value === 'null') {
return { null: true }
}
const filter = namedFilter(value) as { or: object[] }
filter.or.push({ slugId: { eqIgnoreCase: value } })
return filter
}
function nullableIdFilter(value: string): object {
return value === 'null' ? { null: true } : { id: { eq: value } }
}
function nullableUserFilter(value: string): object {
if (value === 'null') {
return { null: true }
}
if (value.toLocaleLowerCase() === 'me') {
return { isMe: { eq: true } }
}
return {
or: [
...(isLinearId(value) ? [{ id: { eq: value } }] : []),
{ displayName: { eqIgnoreCase: value } },
{ name: { eqIgnoreCase: value } },
{ email: { eqIgnoreCase: value } }
]
}
}
function isLinearId(value: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
}
@@ -0,0 +1,304 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const rawRequest = vi.fn()
const getClients = vi.fn()
const getStatus = vi.fn()
const acquire = vi.fn()
const release = vi.fn()
const clearToken = vi.fn()
const workspace = (id: string, organizationName: string) => ({
id,
organizationId: id,
organizationName,
displayName: 'Ada',
email: null
})
const clientEntry = (id: string, organizationName: string) => ({
workspace: workspace(id, organizationName),
client: { client: { rawRequest } }
})
vi.mock('./linear-request-concurrency', () => ({
acquire,
release
}))
vi.mock('./linear-token-store', () => ({
clearToken
}))
vi.mock('./client', () => ({
getClients,
getStatus,
isAuthError: () => false
}))
function issueNode(id: string, identifier: string, updatedAt: string, priority?: number) {
return {
id,
identifier,
title: `Issue ${identifier}`,
url: `https://linear.app/acme/issue/${identifier}`,
labels: { nodes: [] },
createdAt: updatedAt,
updatedAt,
...(priority === undefined ? {} : { priority })
}
}
function pageResponse(
nodes: ReturnType<typeof issueNode>[],
hasNextPage: boolean,
endCursor?: string
) {
return {
data: {
issues: {
nodes,
pageInfo: { hasNextPage, endCursor: endCursor ?? null }
}
}
}
}
describe('list-issues pagination contract', () => {
beforeEach(() => {
vi.clearAllMocks()
const entry = clientEntry('workspace-1', 'Acme')
getClients.mockReturnValue([entry])
getStatus.mockReturnValue({ workspaces: [entry.workspace] })
})
it('marks a full page with more results as truncated and binds workspace into nextCursor', async () => {
rawRequest.mockResolvedValue(
pageResponse(
[issueNode('issue-1', 'ENG-1', '2026-07-01T00:00:00.000Z', 1)],
true,
'linear-end'
)
)
const { listMcpIssues } = await import('./mcp-issue-list')
const result = await listMcpIssues({ limit: 1, workspaceId: 'workspace-1' })
expect(result.issues).toHaveLength(1)
expect(result.truncated).toBe(true)
expect(result.meta.hasMore).toBe(true)
expect(result.meta.nextCursor).toMatch(/^orca\.linear\.v1\./)
expect(result.issues[0]?.priorityLabel).toBe('urgent')
})
it('does not mark a short complete page as truncated', async () => {
rawRequest.mockResolvedValue(
pageResponse([issueNode('issue-1', 'ENG-1', '2026-07-01T00:00:00.000Z')], false)
)
const { listMcpIssues } = await import('./mcp-issue-list')
const result = await listMcpIssues({ limit: 50 })
expect(result.truncated).toBe(false)
expect(result.meta.hasMore).toBe(false)
expect(result.meta.nextCursor).toBeUndefined()
})
it('does not mark an exact-limit complete page as truncated', async () => {
rawRequest.mockResolvedValue(
pageResponse([issueNode('issue-1', 'ENG-1', '2026-07-01T00:00:00.000Z')], false, 'unused')
)
const { listMcpIssues } = await import('./mcp-issue-list')
const result = await listMcpIssues({ limit: 1 })
expect(result.issues).toHaveLength(1)
expect(result.truncated).toBe(false)
})
it('replays an issued cursor without --workspace', async () => {
rawRequest.mockResolvedValueOnce(
pageResponse([issueNode('issue-1', 'ENG-1', '2026-07-01T00:00:00.000Z')], true, 'linear-end')
)
const { listMcpIssues } = await import('./mcp-issue-list')
const first = await listMcpIssues({ limit: 1 })
rawRequest.mockResolvedValueOnce(
pageResponse([issueNode('issue-2', 'ENG-2', '2026-07-02T00:00:00.000Z')], false)
)
const second = await listMcpIssues({ limit: 1, cursor: first.meta.nextCursor })
expect(second.issues[0]?.identifier).toBe('ENG-2')
expect(rawRequest.mock.calls[1]?.[1]).toMatchObject({ after: 'linear-end' })
expect(getClients.mock.calls[1]?.[0]).toBe('workspace-1')
})
it('rejects a raw cursor without workspace and includes nextSteps', async () => {
const { listMcpIssues } = await import('./mcp-issue-list')
await expect(listMcpIssues({ cursor: 'linear-end' })).rejects.toMatchObject({
code: 'linear_invalid_workspace',
data: {
nextSteps: expect.arrayContaining([
expect.stringContaining('--workspace'),
expect.stringContaining('nextCursor')
])
}
})
expect(rawRequest).not.toHaveBeenCalled()
})
it('rejects a wrapped cursor that disagrees with --workspace', async () => {
rawRequest.mockResolvedValue(
pageResponse([issueNode('issue-1', 'ENG-1', '2026-07-01T00:00:00.000Z')], true, 'linear-end')
)
const { listMcpIssues } = await import('./mcp-issue-list')
const first = await listMcpIssues({ limit: 1, workspaceId: 'workspace-1' })
await expect(
listMcpIssues({ cursor: first.meta.nextCursor, workspaceId: 'workspace-other' })
).rejects.toMatchObject({
code: 'linear_invalid_workspace',
data: { nextSteps: expect.any(Array) }
})
})
it('walks every page when no limit is set and reports a complete read', async () => {
rawRequest
.mockResolvedValueOnce(
pageResponse([issueNode('issue-1', 'ENG-1', '2026-07-03T00:00:00.000Z')], true, 'page-2')
)
.mockResolvedValueOnce(
pageResponse([issueNode('issue-2', 'ENG-2', '2026-07-02T00:00:00.000Z')], true, 'page-3')
)
.mockResolvedValueOnce(
pageResponse([issueNode('issue-3', 'ENG-3', '2026-07-01T00:00:00.000Z')], false)
)
const { listMcpIssues } = await import('./mcp-issue-list')
const result = await listMcpIssues({})
expect(result.issues.map((issue) => issue.identifier)).toEqual(['ENG-1', 'ENG-2', 'ENG-3'])
expect(result.truncated).toBe(false)
expect(result.meta).toMatchObject({ limit: null, returned: 3, hasMore: false })
expect(result.meta.nextCursor).toBeUndefined()
expect(rawRequest.mock.calls.map((call) => call[1]?.after)).toEqual([
undefined,
'page-2',
'page-3'
])
expect(rawRequest.mock.calls[0]?.[1]).toMatchObject({ first: 250 })
})
it('pages past the Linear per-request maximum to satisfy a larger explicit limit', async () => {
const page = (start: number, count: number, hasNextPage: boolean, endCursor?: string) =>
pageResponse(
Array.from({ length: count }, (_unused, index) =>
issueNode(`issue-${start + index}`, `ENG-${start + index}`, '2026-07-01T00:00:00.000Z')
),
hasNextPage,
endCursor
)
rawRequest
.mockResolvedValueOnce(page(1, 250, true, 'page-2'))
.mockResolvedValueOnce(page(251, 50, true, 'page-3'))
const { listMcpIssues } = await import('./mcp-issue-list')
const result = await listMcpIssues({ limit: 300 })
expect(result.issues).toHaveLength(300)
expect(result.truncated).toBe(true)
expect(result.meta).toMatchObject({ limit: 300, returned: 300, hasMore: true })
expect(rawRequest.mock.calls.map((call) => call[1]?.first)).toEqual([250, 50])
})
it('stops and reports truncation when a provider never stops offering pages', async () => {
rawRequest.mockResolvedValue(
pageResponse([issueNode('issue-1', 'ENG-1', '2026-07-01T00:00:00.000Z')], true, 'same-cursor')
)
const { listMcpIssues } = await import('./mcp-issue-list')
const result = await listMcpIssues({})
expect(result.truncated).toBe(true)
expect(result.meta.hasMore).toBe(true)
expect(rawRequest.mock.calls.length).toBeLessThanOrEqual(200)
expect(result.meta.nextCursor).toMatch(/^orca\.linear\.v1\./)
})
it('stops paging when a page claims more results but hands back no cursor', async () => {
rawRequest.mockResolvedValue(
pageResponse([issueNode('issue-1', 'ENG-1', '2026-07-01T00:00:00.000Z')], true)
)
const { listMcpIssues } = await import('./mcp-issue-list')
const result = await listMcpIssues({})
expect(rawRequest).toHaveBeenCalledTimes(1)
expect(result.truncated).toBe(true)
expect(result.meta.nextCursor).toBeUndefined()
})
it('stops on the read budget and hands back a cursor instead of outliving the RPC', async () => {
vi.useFakeTimers()
try {
let issued = 0
rawRequest.mockImplementation(() => {
issued += 1
vi.advanceTimersByTime(11_000)
return Promise.resolve(
pageResponse(
[issueNode(`issue-${issued}`, `ENG-${issued}`, '2026-07-01T00:00:00.000Z')],
true,
`page-${issued}`
)
)
})
const { listMcpIssues } = await import('./mcp-issue-list')
const result = await listMcpIssues({})
expect(issued).toBe(2)
expect(result.truncated).toBe(true)
expect(result.meta.nextCursor).toMatch(/^orca\.linear\.v1\./)
} finally {
vi.useRealTimers()
}
})
it('refuses a hand-crafted cursor that smuggles a fan-out or empty workspace', async () => {
const { encodeIssueListCursor } = await import('./mcp-issue-list-cursor')
const { listMcpIssues } = await import('./mcp-issue-list')
for (const workspaceId of ['all', '']) {
await expect(
listMcpIssues({ cursor: encodeIssueListCursor(workspaceId, 'linear-end') })
).rejects.toMatchObject({ code: 'linear_invalid_workspace' })
}
await expect(
listMcpIssues({ cursor: encodeIssueListCursor('workspace-1', '') })
).rejects.toMatchObject({ code: 'linear_invalid_workspace' })
expect(rawRequest).not.toHaveBeenCalled()
})
it('maps every Linear priority number onto the CLI setter label', async () => {
rawRequest.mockResolvedValue(
pageResponse(
[0, 1, 2, 3, 4].map((priority) =>
issueNode(`issue-${priority}`, `ENG-${priority}`, '2026-07-01T00:00:00.000Z', priority)
),
false
)
)
const { listMcpIssues } = await import('./mcp-issue-list')
const result = await listMcpIssues({ limit: 10 })
expect(result.issues.map((issue) => issue.priorityLabel)).toEqual([
'none',
'urgent',
'high',
'medium',
'low'
])
})
})
+16 -15
View File
@@ -51,17 +51,15 @@ describe('MCP-compatible Linear issue listing', () => {
rawRequest.mockResolvedValue({
data: {
issues: {
nodes: [
{
id: 'issue-1',
identifier: 'ENG-1',
title: 'Fix auth',
url: 'https://linear.app/acme/issue/ENG-1',
labels: { nodes: [] },
createdAt: '2026-07-01T00:00:00.000Z',
updatedAt: '2026-07-20T00:00:00.000Z'
}
],
nodes: Array.from({ length: 100 }, (_unused, index) => ({
id: `issue-${index + 1}`,
identifier: `ENG-${index + 1}`,
title: 'Fix auth',
url: `https://linear.app/acme/issue/ENG-${index + 1}`,
labels: { nodes: [] },
createdAt: '2026-07-01T00:00:00.000Z',
updatedAt: '2026-07-20T00:00:00.000Z'
})),
pageInfo: { hasNextPage: true, endCursor: 'next-page' }
}
}
@@ -108,20 +106,23 @@ describe('MCP-compatible Linear issue listing', () => {
expect(rawRequest.mock.calls[0]?.[1]).not.toMatchObject({
filter: { team: { or: expect.arrayContaining([{ id: { eq: 'ENG' } }]) } }
})
expect(result.truncated).toBe(true)
expect(result.meta).toMatchObject({
limit: 100,
returned: 1,
returned: 100,
hasMore: true,
nextCursor: 'next-page',
orderBy: 'createdAt'
})
// Stops at the requested cap instead of walking on.
expect(rawRequest).toHaveBeenCalledTimes(1)
expect(result.meta.nextCursor).toMatch(/^orca\.linear\.v1\./)
expect(result.issues[0]).toMatchObject({
identifier: 'ENG-1',
workspace: { id: 'workspace-1', name: 'Acme' }
})
})
it('uses null filters and clamps direct callers to the MCP maximum', async () => {
it('uses null filters and pages at the Linear per-request maximum', async () => {
rawRequest.mockResolvedValue({
data: { issues: { nodes: [], pageInfo: { hasNextPage: false } } }
})
@@ -271,7 +272,7 @@ describe('MCP-compatible Linear issue listing', () => {
await expect(listMcpIssues({ cursor: 'next' })).rejects.toMatchObject({
code: 'linear_invalid_workspace'
})
const result = await listMcpIssues({ workspaceId: 'all' })
const result = await listMcpIssues({ workspaceId: 'all', limit: 1 })
expect(result.meta).toMatchObject({ hasMore: true, workspaceId: 'all' })
expect(result.meta.nextCursor).toBeUndefined()
+77 -141
View File
@@ -12,9 +12,16 @@ import {
} from './issue-context-fanout'
import { ISSUE_FIELDS, mapIssue, type RawIssue } from './issue-context-raw'
import { resolveWorkspaceSelector } from './issue-context-workspaces'
import { encodeIssueListCursor, resolveIssueListCursor } from './mcp-issue-list-cursor'
import { buildIssueFilter } from './mcp-issue-list-filter'
const LIST_ISSUES_DEFAULT_LIMIT = 50
const LIST_ISSUES_MAX_LIMIT = 250
// Why: `--limit` is opt-in, so the default read walks every page instead of quietly cutting
// the answer off. Linear caps `first` at 250. The budget and page ceiling are backstops, not
// caps: the CLI abandons an RPC at 60s, so a walk that would outlive it has to stop early and
// say `truncated` with a continuation cursor rather than fail the whole command.
const LIST_ISSUES_PAGE_SIZE = 250
const LIST_ISSUES_MAX_PAGES = 200
const LIST_ISSUES_READ_BUDGET_MS = 20_000
type RawListIssuesResponse = {
issues?: {
@@ -29,6 +36,9 @@ type WorkspaceIssuePage = {
nextCursor?: string
}
// limit null means unbounded; the deadline still applies.
type IssueListReadBudget = { limit: number | null; deadline: number }
const LIST_ISSUES_QUERY = `
query OrcaLinearListIssues(
$first: Int!
@@ -53,15 +63,10 @@ const LIST_ISSUES_QUERY = `
export async function listMcpIssues(
request: LinearMcpIssueListRequest
): Promise<LinearMcpIssueListResult> {
if (request.cursor && (!request.workspaceId || request.workspaceId === 'all')) {
throw linearError(
'linear_invalid_workspace',
'Cursor pagination requires a concrete Linear workspace.'
)
}
const limit = clampLimit(request.limit)
const pagination = resolveIssueListCursor(request)
const limit = resolveLimit(request.limit)
const orderBy = request.orderBy ?? 'updatedAt'
const { entries, failures: entryFailures } = getIssueListEntries(request.workspaceId)
const { entries, failures: entryFailures } = getIssueListEntries(pagination.workspaceId)
if (entries.length === 0) {
if (entryFailures[0]) {
throw entryFailures[0].error
@@ -70,10 +75,17 @@ export async function listMcpIssues(
nextSteps: ['Connect Linear from Orca settings, then retry the issue list.']
})
}
const pagedRequest = {
...request,
cursor: pagination.linearCursor,
workspaceId: pagination.workspaceId
}
// One deadline for the whole call, so fanning out over many workspaces cannot multiply it.
const deadline = Date.now() + LIST_ISSUES_READ_BUDGET_MS
const { pages, failures } = await readIssueListWorkspaces(
entries,
request,
limit,
pagedRequest,
{ limit, deadline },
orderBy,
entryFailures
)
@@ -81,21 +93,23 @@ export async function listMcpIssues(
let hasMore = pages.some((page) => page.hasMore)
issues.sort((left, right) => compareIssues(left, right, orderBy))
if (issues.length > limit) {
if (limit !== null && issues.length > limit) {
hasMore = true
issues.length = limit
}
const workspaceId = pagination.workspaceId === 'all' ? 'all' : entries[0].workspace.id
return {
issues,
truncated: hasMore,
meta: {
limit,
returned: issues.length,
hasMore,
...(hasMore && request.workspaceId !== 'all' && pages.length === 1 && pages[0].nextCursor
? { nextCursor: pages[0].nextCursor }
...(hasMore && workspaceId !== 'all' && pages.length === 1 && pages[0].nextCursor
? { nextCursor: encodeIssueListCursor(workspaceId, pages[0].nextCursor) }
: {}),
orderBy,
workspaceId: request.workspaceId === 'all' ? 'all' : entries[0].workspace.id,
workspaceId,
partial: failures.length > 0,
workspaceErrors: failures.map(({ workspace, code, message }) => ({
workspace,
@@ -122,19 +136,19 @@ function getIssueListEntries(workspaceId?: (string & {}) | 'all'): {
async function readIssueListWorkspaces(
entries: LinearClientForWorkspace[],
request: LinearMcpIssueListRequest,
limit: number,
budget: IssueListReadBudget,
orderBy: 'createdAt' | 'updatedAt',
initialFailures: WorkspaceReadFailure[]
): Promise<{ pages: WorkspaceIssuePage[]; failures: WorkspaceReadFailure[] }> {
if (request.workspaceId !== 'all') {
return {
pages: [await readIssueListWorkspace(entries[0], request, limit, orderBy)],
pages: [await readIssueListWorkspace(entries[0], request, budget, orderBy)],
failures: []
}
}
const settled = await Promise.allSettled(
entries.map((entry) => readIssueListWorkspace(entry, request, limit, orderBy))
entries.map((entry) => readIssueListWorkspace(entry, request, budget, orderBy))
)
const pages: WorkspaceIssuePage[] = []
const failures = [...initialFailures]
@@ -155,136 +169,58 @@ async function readIssueListWorkspaces(
async function readIssueListWorkspace(
entry: LinearClientForWorkspace,
request: LinearMcpIssueListRequest,
limit: number,
{ limit, deadline }: IssueListReadBudget,
orderBy: 'createdAt' | 'updatedAt'
): Promise<WorkspaceIssuePage> {
return await withLinearRead(entry, async () => {
const raw = await entry.client.client.rawRequest<
RawListIssuesResponse,
Record<string, unknown>
>(LIST_ISSUES_QUERY, {
first: limit,
after: request.cursor,
filter: buildIssueFilter(request),
orderBy,
includeArchived: request.includeArchived ?? false
const filter = buildIssueFilter(request)
const issues: WorkspaceIssuePage['issues'] = []
let after = request.cursor
let hasMore = false
let nextCursor: string | undefined
for (let page = 0; page < LIST_ISSUES_MAX_PAGES; page += 1) {
const first =
limit === null
? LIST_ISSUES_PAGE_SIZE
: Math.min(limit - issues.length, LIST_ISSUES_PAGE_SIZE)
// Each page takes its own concurrency slot so a long walk cannot starve other reads.
const connection = await withLinearRead(entry, async () => {
const raw = await entry.client.client.rawRequest<
RawListIssuesResponse,
Record<string, unknown>
>(LIST_ISSUES_QUERY, {
first,
after,
filter,
orderBy,
includeArchived: request.includeArchived ?? false
})
return raw.data?.issues
})
const connection = raw.data?.issues
return {
issues: (connection?.nodes ?? []).map((issue) => ({
for (const issue of connection?.nodes ?? []) {
issues.push({
...mapIssue(issue),
workspace: { id: entry.workspace.id, name: entry.workspace.organizationName }
})),
hasMore: connection?.pageInfo?.hasNextPage === true,
nextCursor: connection?.pageInfo?.endCursor ?? undefined
})
}
})
hasMore = connection?.pageInfo?.hasNextPage === true
nextCursor = connection?.pageInfo?.endCursor ?? undefined
if (!hasMore || !nextCursor) {
break
}
if (limit !== null && issues.length >= limit) {
break
}
if (Date.now() >= deadline) {
break
}
after = nextCursor
}
return { issues, hasMore, nextCursor }
}
function buildIssueFilter(request: LinearMcpIssueListRequest): Record<string, unknown> {
const filter: Record<string, unknown> = {}
if (request.team) {
filter.team = namedFilter(request.team, true)
}
if (request.cycle) {
filter.cycle = nullableNamedFilter(request.cycle)
}
if (request.label) {
filter.labels = { some: namedFilter(request.label) }
}
if (request.query) {
filter.searchableContent = { contains: request.query }
}
if (request.state) {
filter.state = workflowStateFilter(request.state)
}
if (request.project) {
filter.project = nullableProjectFilter(request.project)
}
if (request.release) {
filter.releases = { some: namedFilter(request.release, false, true) }
}
if (request.assignee) {
filter.assignee = nullableUserFilter(request.assignee)
}
if (request.delegate) {
filter.delegate = nullableUserFilter(request.delegate)
}
if (request.parentId) {
filter.parent = nullableIdFilter(request.parentId)
}
if (request.priority !== undefined) {
filter.priority = { eq: request.priority }
}
if (request.createdAt) {
filter.createdAt = { gte: request.createdAt }
}
if (request.updatedAt) {
filter.updatedAt = { gte: request.updatedAt }
}
return filter
}
function namedFilter(value: string, includeKey = false, includeVersion = false): object {
return {
or: [
...(isLinearId(value) ? [{ id: { eq: value } }] : []),
{ name: { eqIgnoreCase: value } },
...(includeKey ? [{ key: { eqIgnoreCase: value } }] : []),
...(includeVersion ? [{ version: { eqIgnoreCase: value } }] : [])
]
}
}
function nullableNamedFilter(value: string): object {
return value === 'null' ? { null: true } : namedFilter(value)
}
function workflowStateFilter(value: string): object {
const filter = namedFilter(value) as { or: object[] }
filter.or.push({ type: { eqIgnoreCase: value } })
return filter
}
function nullableProjectFilter(value: string): object {
if (value === 'null') {
return { null: true }
}
const filter = namedFilter(value) as { or: object[] }
filter.or.push({ slugId: { eqIgnoreCase: value } })
return filter
}
function nullableIdFilter(value: string): object {
return value === 'null' ? { null: true } : { id: { eq: value } }
}
function nullableUserFilter(value: string): object {
if (value === 'null') {
return { null: true }
}
if (value.toLocaleLowerCase() === 'me') {
return { isMe: { eq: true } }
}
return {
or: [
...(isLinearId(value) ? [{ id: { eq: value } }] : []),
{ displayName: { eqIgnoreCase: value } },
{ name: { eqIgnoreCase: value } },
{ email: { eqIgnoreCase: value } }
]
}
}
function isLinearId(value: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
}
function clampLimit(limit: number | undefined): number {
return Math.min(
Math.max(1, Math.floor(limit ?? LIST_ISSUES_DEFAULT_LIMIT)),
LIST_ISSUES_MAX_LIMIT
)
// null means unbounded: read until Linear stops handing out pages.
function resolveLimit(limit: number | undefined): number | null {
return limit === undefined ? null : Math.max(1, Math.floor(limit))
}
function compareIssues(
+25 -21
View File
@@ -1,14 +1,16 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { execFileMock, lstatMock, readFileMock, rmMock } = vi.hoisted(() => ({
execFileMock: vi.fn(),
const { runProcessMock, lstatMock, readFileMock, rmMock } = vi.hoisted(() => ({
runProcessMock: vi.fn(),
lstatMock: vi.fn(),
readFileMock: vi.fn(),
rmMock: vi.fn()
}))
vi.mock('node:child_process', () => ({
execFile: execFileMock
// Why mock the chokepoint: encoding, timeout and the hidden console are its
// contract now, so this suite asserts what the distro is asked to run.
vi.mock('../shared/child-process/run-process', () => ({
runProcess: runProcessMock
}))
vi.mock('node:fs/promises', () => ({
@@ -24,14 +26,16 @@ import {
} from './local-worktree-filesystem'
function completeExecFile(stdout = ''): void {
execFileMock.mockImplementation((_file, _args, _options, callback) => {
callback(null, stdout, '')
})
runProcessMock.mockResolvedValue({ code: 0, signal: null, stdout, stderr: '', timedOut: false })
}
function failExecFile(error: Error & { code?: number | string }): void {
execFileMock.mockImplementation((_file, _args, _options, callback) => {
callback(error, '', '')
function failExecFile(exitCode: number): void {
runProcessMock.mockResolvedValue({
code: exitCode,
signal: null,
stdout: '',
stderr: 'missing',
timedOut: false
})
}
@@ -47,7 +51,7 @@ async function withPlatform<T>(platform: NodeJS.Platform, fn: () => Promise<T>):
describe('local worktree filesystem runtime access', () => {
beforeEach(() => {
execFileMock.mockReset()
runProcessMock.mockReset()
lstatMock.mockReset()
readFileMock.mockReset()
rmMock.mockReset()
@@ -77,7 +81,7 @@ describe('local worktree filesystem runtime access', () => {
force: true
})
)
expect(execFileMock).not.toHaveBeenCalled()
expect(runProcessMock).not.toHaveBeenCalled()
})
it('uses a Win32 long-path namespace for host removal on Windows', async () => {
@@ -157,15 +161,15 @@ describe('local worktree filesystem runtime access', () => {
completeExecFile()
await removeLocalWorktreePath('C:\\Users\\me\\repo feature', { wslDistro: 'Ubuntu' })
expect(execFileMock).toHaveBeenCalledTimes(3)
expect(execFileMock).toHaveBeenNthCalledWith(
expect(runProcessMock).toHaveBeenCalledTimes(3)
expect(runProcessMock).toHaveBeenNthCalledWith(
1,
'wsl.exe',
expect.arrayContaining(['-d', 'Ubuntu']),
expect.objectContaining({ encoding: 'utf8' }),
expect.any(Function)
expect.objectContaining({
program: 'wsl.exe',
args: expect.arrayContaining(['-d', 'Ubuntu'])
})
)
const removeArgs = execFileMock.mock.calls[2]?.[1] as string[]
const removeArgs = runProcessMock.mock.calls[2]?.[0].args as string[]
expect(removeArgs.at(-1)).toContain('rm -rf --')
expect(removeArgs.at(-1)).toContain(String.raw`rm -rf -- '/mnt/c/Users/me/repo feature'`)
expect(rmMock).not.toHaveBeenCalled()
@@ -180,7 +184,7 @@ describe('local worktree filesystem runtime access', () => {
// Why: a login/interactive shell is what puts the distro's rc banner on the
// stdout these callers parse. cat/stat/rm need nothing from the user's PATH,
// so the shell mode is the fix rather than filtering what it prints.
const args = execFileMock.mock.calls[0]?.[1] as string[]
const args = runProcessMock.mock.calls[0]?.[0].args as string[]
expect(args).toContain('-c')
expect(args).not.toContain('-lc')
expect(args).not.toContain('-ilc')
@@ -190,7 +194,7 @@ describe('local worktree filesystem runtime access', () => {
it('reports missing WSL stat targets with an ENOENT-shaped error', async () => {
await withPlatform('win32', async () => {
failExecFile(Object.assign(new Error('missing'), { code: 2 }))
failExecFile(2)
const access = getLocalWorktreePathAccess({ wslDistro: 'Ubuntu' })
await expect(access.statPath('/mnt/c/repo/missing/.git')).rejects.toMatchObject({
+24 -42
View File
@@ -1,4 +1,4 @@
import { execFile } from 'node:child_process'
import { runProcess } from '../shared/child-process/run-process'
import { lstat, readFile } from 'node:fs/promises'
import { buildWslExecArgs, quotePosixShell } from '../shared/wsl-login-shell-command'
import { removeHostTree } from './host-tree-removal'
@@ -16,41 +16,14 @@ type LocalWorktreePathAccess = {
readPath: ReadPath
}
type ExecFileTextResult = {
stdout: string
stderr: string
}
const WSL_FILE_OPERATION_TIMEOUT_MS = 30_000
/** The stat probe's explicit "missing path" branch. */
const WSL_MISSING_PATH_EXIT_CODE = 2
function shouldUseWslFilesystem(options: LocalWorktreeFilesystemOptions): boolean {
return process.platform === 'win32' && !!options.wslDistro?.trim()
}
function execFileText(
file: string,
args: string[],
options: { timeout: number }
): Promise<ExecFileTextResult> {
return new Promise((resolve, reject) => {
execFile(
file,
args,
{ encoding: 'utf8', timeout: options.timeout },
(error, stdout, stderr) => {
if (error) {
reject(error)
return
}
resolve({
stdout: typeof stdout === 'string' ? stdout : String(stdout ?? ''),
stderr: typeof stderr === 'string' ? stderr : String(stderr ?? '')
})
}
)
})
}
/**
* Run a filesystem command inside the distro.
*
@@ -59,20 +32,29 @@ function execFileText(
* output to the stdout these callers parse -- the banner problem -- so the fix is
* to not start one rather than to fence what it prints.
*/
function runWslCommand(distro: string, command: string): Promise<ExecFileTextResult> {
return execFileText('wsl.exe', buildWslExecArgs(distro, ['sh', '-c', command]), {
timeout: WSL_FILE_OPERATION_TIMEOUT_MS
async function runWslCommand(distro: string, command: string): Promise<string> {
const result = await runProcess({
program: 'wsl.exe',
args: buildWslExecArgs(distro, ['sh', '-c', command]),
timeoutMs: WSL_FILE_OPERATION_TIMEOUT_MS
})
if (result.timedOut) {
throw new Error(`WSL filesystem command timed out after ${WSL_FILE_OPERATION_TIMEOUT_MS}ms`)
}
if (result.code !== 0) {
throw Object.assign(new Error(result.stderr.trim() || `wsl.exe exited ${result.code}`), {
exitCode: result.code
})
}
return result.stdout
}
function isWslMissingPathError(error: unknown): boolean {
// Why: the WSL stat probe exits 2 for its explicit "missing path" branch;
// normalize that shell-specific result so callers can handle it like fs.lstat.
const code =
error && typeof error === 'object' && 'code' in error
? String((error as NodeJS.ErrnoException).code)
: ''
return code === '2'
return (
typeof error === 'object' &&
error !== null &&
(error as { exitCode?: unknown }).exitCode === WSL_MISSING_PATH_EXIT_CODE
)
}
export function toLocalWorktreeRuntimePath(
@@ -96,7 +78,7 @@ export function getLocalWorktreePathAccess(
return {
statPath: async (path) => {
const target = quotePosixShell(toLinuxPath(path))
const { stdout } = await runWslCommand(
const stdout = await runWslCommand(
distro,
[
`target=${target}`,
@@ -112,7 +94,7 @@ export function getLocalWorktreePathAccess(
},
readPath: async (path) => {
const target = quotePosixShell(toLinuxPath(path))
const { stdout } = await runWslCommand(distro, `cat -- ${target}`)
const stdout = await runWslCommand(distro, `cat -- ${target}`)
return stdout
}
}
+134 -67
View File
@@ -9,9 +9,9 @@ type AppMetricFixture = {
memory: { workingSetSize: number }
}
const { appMetricsMock, execFileMock, execMock, listRegisteredPtysMock } = vi.hoisted(() => ({
const { appMetricsMock, runProcessMock, execMock, listRegisteredPtysMock } = vi.hoisted(() => ({
appMetricsMock: vi.fn<() => AppMetricFixture[]>(() => []),
execFileMock: vi.fn(),
runProcessMock: vi.fn(),
execMock: vi.fn(),
listRegisteredPtysMock: vi.fn()
}))
@@ -24,13 +24,14 @@ vi.mock('electron', () => ({
vi.mock('child_process', () => ({
exec: (cmd: string, opts: unknown, cb: (err: Error | null, out: { stdout: string }) => void) =>
execMock(cmd, opts, cb),
execFile: (
file: string,
args: string[],
opts: unknown,
cb: (err: Error | null, stdout: string, stderr: string) => void
) => execFileMock(file, args, opts, cb)
execMock(cmd, opts, cb)
}))
// Why mock the chokepoint for the Windows sweep: maxBuffer, timeout and the
// hidden console are its contract now, so the assertions below are about which
// query runs, not how a process is started.
vi.mock('../../shared/child-process/run-process', () => ({
runProcess: (spec: { program: string; args?: string[] }) => runProcessMock(spec)
}))
vi.mock('./pty-registry', () => ({
@@ -242,7 +243,7 @@ describe('collectMemorySnapshot', () => {
vi.restoreAllMocks()
appMetricsMock.mockReset()
appMetricsMock.mockReturnValue([])
execFileMock.mockReset()
runProcessMock.mockReset()
execMock.mockReset()
listRegisteredPtysMock.mockReset()
listRegisteredPtysMock.mockReturnValue([])
@@ -250,13 +251,18 @@ describe('collectMemorySnapshot', () => {
function mockPsResponse(stdout: string) {
execMock.mockImplementation((_cmd, _opts, cb) => cb(null, { stdout, stderr: '' }))
execFileMock.mockImplementation((file, _args, _opts, cb) => {
const output =
file === 'typeperf.exe'
? psFixtureToTypeperfOutput(stdout)
: psFixtureToWindowsProcessOutput(stdout)
cb(null, output, '')
})
runProcessMock.mockImplementation((spec: { program: string }) =>
Promise.resolve({
code: 0,
signal: null,
stdout:
spec.program === 'typeperf.exe'
? psFixtureToTypeperfOutput(stdout)
: psFixtureToWindowsProcessOutput(stdout),
stderr: '',
timedOut: false
})
)
}
function psFixtureToWindowsProcessOutput(stdout: string): string {
@@ -314,7 +320,7 @@ describe('collectMemorySnapshot', () => {
function expectProcessSweepCount(count: number): void {
if (os.platform() === 'win32') {
expect(execFileMock).toHaveBeenCalledTimes(count)
expect(runProcessMock).toHaveBeenCalledTimes(count)
return
}
expect(execMock).toHaveBeenCalledTimes(count)
@@ -328,18 +334,14 @@ describe('collectMemorySnapshot', () => {
await collectMemorySnapshot(emptyStore)
expect(execMock).not.toHaveBeenCalled()
expect(execFileMock).toHaveBeenCalledTimes(1)
const [file, args] = execFileMock.mock.calls[0]
expect(file).toBe('powershell.exe')
expect(args.join(' ')).toContain('Get-CimInstance Win32_Process')
expect(args.join(' ')).toContain('KernelModeTime')
expect(args.join(' ')).toContain('UserModeTime')
expect(args.join(' ')).toContain('CreationDate')
expect(execFileMock.mock.calls[0][2]).toMatchObject({
maxBuffer: 10 * 1024 * 1024,
timeout: 5_000,
windowsHide: true
})
expect(runProcessMock).toHaveBeenCalledTimes(1)
const spec = runProcessMock.mock.calls[0][0]
expect(spec.program).toBe('powershell.exe')
expect(spec.args.join(' ')).toContain('Get-CimInstance Win32_Process')
expect(spec.args.join(' ')).toContain('KernelModeTime')
expect(spec.args.join(' ')).toContain('UserModeTime')
expect(spec.args.join(' ')).toContain('CreationDate')
expect(spec).toMatchObject({ maxOutputBytes: 10 * 1024 * 1024, timeoutMs: 5_000 })
})
it('attributes Windows process CPU from cumulative time deltas between sweeps', async () => {
@@ -349,8 +351,14 @@ describe('collectMemorySnapshot', () => {
'10\t1\t1048576\t10000000\t0\t638830000000000000',
'10\t1\t1048576\t30000000\t0\t638830000000000000'
]
execFileMock.mockImplementation((_file, _args, _opts, cb) =>
cb(null, cpuOutputs.shift() ?? '', '')
runProcessMock.mockImplementation(() =>
Promise.resolve({
code: 0,
signal: null,
stdout: cpuOutputs.shift() ?? '',
stderr: '',
timedOut: false
})
)
listRegisteredPtysMock.mockReturnValue([
{
@@ -368,7 +376,7 @@ describe('collectMemorySnapshot', () => {
expect(first.worktrees[0].sessions[0].cpu).toBe(0)
expect(second.worktrees[0].sessions[0].cpu).toBe(100)
expect(execFileMock.mock.calls.map(([file]) => file)).toEqual([
expect(runProcessMock.mock.calls.map(([spec]) => spec.program)).toEqual([
'powershell.exe',
'powershell.exe'
])
@@ -381,8 +389,14 @@ describe('collectMemorySnapshot', () => {
'10\t1\t1048576\t10000000\t0\t638830000000000000',
'10\t1\t1048576\t30000000\t0\t638830000000000001'
]
execFileMock.mockImplementation((_file, _args, _opts, cb) =>
cb(null, cpuOutputs.shift() ?? '', '')
runProcessMock.mockImplementation(() =>
Promise.resolve({
code: 0,
signal: null,
stdout: cpuOutputs.shift() ?? '',
stderr: '',
timedOut: false
})
)
listRegisteredPtysMock.mockReturnValue([
{
@@ -408,8 +422,14 @@ describe('collectMemorySnapshot', () => {
'10\t1\t1048576\t90071992547409920\t0\t638830000000000000',
'10\t1\t1048576\t90071992567409920\t0\t638830000000000000'
]
execFileMock.mockImplementation((_file, _args, _opts, cb) =>
cb(null, cpuOutputs.shift() ?? '', '')
runProcessMock.mockImplementation(() =>
Promise.resolve({
code: 0,
signal: null,
stdout: cpuOutputs.shift() ?? '',
stderr: '',
timedOut: false
})
)
listRegisteredPtysMock.mockReturnValue([
{
@@ -439,8 +459,14 @@ describe('collectMemorySnapshot', () => {
'10\t1\t1048576\t1000000\t0\t638830000000000000',
'10\t1\t1048576\t20000000\t0\t638830000000000000'
]
execFileMock.mockImplementation((_file, _args, _opts, cb) =>
cb(null, cpuOutputs.shift() ?? '', '')
runProcessMock.mockImplementation(() =>
Promise.resolve({
code: 0,
signal: null,
stdout: cpuOutputs.shift() ?? '',
stderr: '',
timedOut: false
})
)
listRegisteredPtysMock.mockReturnValue([
{
@@ -469,8 +495,14 @@ describe('collectMemorySnapshot', () => {
'10\t1\t1048576\t0\t0\t638830000000000000',
'10\t1\t1048576\t1000000000\t0\t638830000000000000'
]
execFileMock.mockImplementation((_file, _args, _opts, cb) =>
cb(null, cpuOutputs.shift() ?? '', '')
runProcessMock.mockImplementation(() =>
Promise.resolve({
code: 0,
signal: null,
stdout: cpuOutputs.shift() ?? '',
stderr: '',
timedOut: false
})
)
listRegisteredPtysMock.mockReturnValue([
{
@@ -496,8 +528,14 @@ describe('collectMemorySnapshot', () => {
'10\t1\t1048576\t0\t0\t638830000000000000',
'10\t1\t1048576\t100000000\t0\t638830000000000000'
]
execFileMock.mockImplementation((_file, _args, _opts, cb) =>
cb(null, cpuOutputs.shift() ?? '', '')
runProcessMock.mockImplementation(() =>
Promise.resolve({
code: 0,
signal: null,
stdout: cpuOutputs.shift() ?? '',
stderr: '',
timedOut: false
})
)
listRegisteredPtysMock.mockReturnValue([
{
@@ -518,8 +556,14 @@ describe('collectMemorySnapshot', () => {
it('preserves Windows process memory when CPU counters are unavailable', async () => {
vi.spyOn(os, 'platform').mockReturnValue('win32')
execFileMock.mockImplementation((_file, _args, _opts, cb) =>
cb(null, '10\t1\t1048576\t\t\t638830000000000000', '')
runProcessMock.mockImplementation(() =>
Promise.resolve({
code: 0,
signal: null,
stdout: '10\t1\t1048576\t\t\t638830000000000000',
stderr: '',
timedOut: false
})
)
listRegisteredPtysMock.mockReturnValue([
{
@@ -539,13 +583,19 @@ describe('collectMemorySnapshot', () => {
it('uses Typeperf during the CIM retry cooldown', async () => {
vi.spyOn(os, 'platform').mockReturnValue('win32')
execFileMock.mockImplementation((file, _args, _opts, cb) => {
if (file === 'powershell.exe') {
cb(new Error('CIM unavailable'), '', '')
return
}
cb(null, psFixtureToTypeperfOutput('10 1 0 1024'), '')
})
runProcessMock.mockImplementation((spec: { program: string }) =>
Promise.resolve(
spec.program === 'powershell.exe'
? { code: 1, signal: null, stdout: '', stderr: 'CIM unavailable', timedOut: false }
: {
code: 0,
signal: null,
stdout: psFixtureToTypeperfOutput('10 1 0 1024'),
stderr: '',
timedOut: false
}
)
)
listRegisteredPtysMock.mockReturnValue([
{
ptyId: 'cim-pty',
@@ -560,13 +610,13 @@ describe('collectMemorySnapshot', () => {
const first = await collectMemorySnapshot(emptyStore)
const second = await collectMemorySnapshot(emptyStore)
expect(execFileMock).toHaveBeenCalledTimes(3)
expect(execFileMock.mock.calls.map(([file]) => file)).toEqual([
expect(runProcessMock).toHaveBeenCalledTimes(3)
expect(runProcessMock.mock.calls.map(([spec]) => spec.program)).toEqual([
'powershell.exe',
'typeperf.exe',
'typeperf.exe'
])
expect(execFileMock.mock.calls[1][2]).toMatchObject({ windowsHide: true, timeout: 5_000 })
expect(runProcessMock.mock.calls[1][0]).toMatchObject({ timeoutMs: 5_000 })
expect(first.worktrees[0].memory).toBe(1048576)
expect(second.worktrees[0].memory).toBe(1048576)
})
@@ -584,17 +634,28 @@ describe('collectMemorySnapshot', () => {
'10\t1\t1048576\t30000000\t0\t638830000000000000'
]
let cimCalls = 0
execFileMock.mockImplementation((file, _args, _opts, cb) => {
if (file === 'typeperf.exe') {
cb(null, psFixtureToTypeperfOutput('10 1 0 1024'), '')
return
runProcessMock.mockImplementation((spec: { program: string }) => {
if (spec.program === 'typeperf.exe') {
return Promise.resolve({
code: 0,
signal: null,
stdout: psFixtureToTypeperfOutput('10 1 0 1024'),
stderr: '',
timedOut: false
})
}
cimCalls += 1
if (cimCalls === 1) {
cb(new Error('transient CIM failure'), '', '')
return
}
cb(null, cimOutputs.shift() ?? '', '')
return Promise.resolve(
cimCalls === 1
? { code: 1, signal: null, stdout: '', stderr: 'transient CIM failure', timedOut: false }
: {
code: 0,
signal: null,
stdout: cimOutputs.shift() ?? '',
stderr: '',
timedOut: false
}
)
})
listRegisteredPtysMock.mockReturnValue([
{
@@ -612,7 +673,7 @@ describe('collectMemorySnapshot', () => {
const warming = await collectMemorySnapshot(emptyStore)
const recovered = await collectMemorySnapshot(emptyStore)
expect(execFileMock.mock.calls.map(([file]) => file)).toEqual([
expect(runProcessMock.mock.calls.map(([spec]) => spec.program)).toEqual([
'powershell.exe',
'typeperf.exe',
'typeperf.exe',
@@ -775,8 +836,14 @@ describe('collectMemorySnapshot', () => {
execMock.mockImplementation((_cmd, _opts, cb) =>
cb(new Error('process enumeration failed'), { stdout: '' })
)
execFileMock.mockImplementation((_file, _args, _opts, cb) =>
cb(new Error('process enumeration failed'), '', '')
runProcessMock.mockImplementation(() =>
Promise.resolve({
code: 1,
signal: null,
stdout: '',
stderr: 'process enumeration failed',
timedOut: false
})
)
listRegisteredPtysMock.mockReturnValue([])
+22 -20
View File
@@ -1,18 +1,13 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import os from 'node:os'
const { execFileMock, readFileMock } = vi.hoisted(() => ({
execFileMock: vi.fn(),
const { runProcessMock, readFileMock } = vi.hoisted(() => ({
runProcessMock: vi.fn(),
readFileMock: vi.fn()
}))
vi.mock('node:child_process', () => ({
execFile: (
file: string,
args: string[],
options: unknown,
callback: (error: Error | null, stdout: string) => void
) => execFileMock(file, args, options, callback)
vi.mock('../../shared/child-process/run-process', () => ({
runProcess: runProcessMock
}))
vi.mock('node:fs/promises', () => ({
@@ -27,7 +22,7 @@ async function loadHostMemory() {
describe('host memory', () => {
beforeEach(() => {
vi.restoreAllMocks()
execFileMock.mockReset()
runProcessMock.mockReset()
readFileMock.mockReset()
vi.spyOn(os, 'totalmem').mockReturnValue(1_000)
vi.spyOn(os, 'freemem').mockReturnValue(100)
@@ -37,9 +32,13 @@ describe('host memory', () => {
it('uses macOS memory-pressure availability instead of immediate free pages', async () => {
vi.spyOn(os, 'platform').mockReturnValue('darwin')
execFileMock.mockImplementation((_file, _args, _options, callback) =>
callback(null, 'System-wide memory free percentage: 79%')
)
runProcessMock.mockResolvedValue({
code: 0,
signal: null,
stdout: 'System-wide memory free percentage: 79%',
stderr: '',
timedOut: false
})
const { collectHostMemory } = await loadHostMemory()
const host = await collectHostMemory()
@@ -54,15 +53,18 @@ describe('host memory', () => {
cpuCoreCount: 2,
loadAverage1m: 1.5
})
expect(execFileMock.mock.calls[0][0]).toBe('/usr/bin/memory_pressure')
expect(execFileMock.mock.calls[0][1]).toEqual(['-Q'])
expect(runProcessMock).toHaveBeenCalledWith({
program: '/usr/bin/memory_pressure',
args: ['-Q'],
env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
maxOutputBytes: 64 * 1024,
timeoutMs: 1_000
})
})
it('falls back once when macOS availability cannot be read', async () => {
vi.spyOn(os, 'platform').mockReturnValue('darwin')
execFileMock.mockImplementation((_file, _args, _options, callback) =>
callback(new Error('unsupported'), '')
)
runProcessMock.mockRejectedValue(new Error('unsupported'))
const { collectHostMemory } = await loadHostMemory()
const first = await collectHostMemory()
@@ -75,7 +77,7 @@ describe('host memory', () => {
memoryUsagePercent: 90
})
expect(second.availableMemorySource).toBe('free-memory')
expect(execFileMock).toHaveBeenCalledTimes(1)
expect(runProcessMock).toHaveBeenCalledTimes(1)
})
it('uses Linux MemAvailable and keeps the value within physical RAM', async () => {
@@ -103,7 +105,7 @@ describe('host memory', () => {
expect(host.availableMemory).toBe(100)
expect(host.availableMemorySource).toBe('free-memory')
expect(execFileMock).not.toHaveBeenCalled()
expect(runProcessMock).not.toHaveBeenCalled()
expect(readFileMock).not.toHaveBeenCalled()
})
})
+13 -27
View File
@@ -1,4 +1,4 @@
import { execFile } from 'node:child_process'
import { runProcess } from '../../shared/child-process/run-process'
import { readFile } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
@@ -85,10 +85,18 @@ async function readAvailableMemory(
async function readDarwinAvailableMemory(total: number): Promise<number | null> {
try {
const stdout = await execFileText('/usr/bin/memory_pressure', ['-Q'])
const available = parseDarwinAvailableMemory(stdout, total)
if (available !== null) {
return available
const result = await runProcess({
program: '/usr/bin/memory_pressure',
args: ['-Q'],
env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
maxOutputBytes: MEMORY_PRESSURE_MAX_BUFFER,
timeoutMs: MEMORY_PRESSURE_TIMEOUT_MS
})
if (result.code === 0 && !result.timedOut) {
const available = parseDarwinAvailableMemory(result.stdout, total)
if (available !== null) {
return available
}
}
} catch {
// The built-in command is unavailable on older or restricted hosts.
@@ -111,28 +119,6 @@ async function readLinuxAvailableMemory(): Promise<number | null> {
return null
}
function execFileText(file: string, args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
execFile(
file,
args,
{
encoding: 'utf8',
env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
maxBuffer: MEMORY_PRESSURE_MAX_BUFFER,
timeout: MEMORY_PRESSURE_TIMEOUT_MS
},
(error, stdout) => {
if (error) {
reject(error)
return
}
resolve(String(stdout))
}
)
})
}
function nonNegativeNumber(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, value) : 0
}
@@ -1,4 +1,4 @@
import { execFile } from 'node:child_process'
import { runProcess } from '../../shared/child-process/run-process'
import os from 'node:os'
import { performance } from 'node:perf_hooks'
import {
@@ -151,26 +151,17 @@ async function enumerateWindowsWithTypeperf(): Promise<WindowsProcessResourceRow
}
}
function execFileText(file: string, args: string[]): Promise<string> {
return new Promise<string>((resolve, reject) => {
execFile(
file,
args,
{
encoding: 'utf8',
maxBuffer: PROCESS_QUERY_MAX_BUFFER,
timeout: PROCESS_QUERY_TIMEOUT_MS,
windowsHide: true
},
(err, output) => {
if (err) {
reject(err)
return
}
resolve(String(output))
}
)
async function execFileText(file: string, args: string[]): Promise<string> {
const result = await runProcess({
program: file,
args,
timeoutMs: PROCESS_QUERY_TIMEOUT_MS,
maxOutputBytes: PROCESS_QUERY_MAX_BUFFER
})
if (result.timedOut || result.code !== 0) {
throw new Error(`${file} exited ${result.code ?? 'on timeout'}`)
}
return result.stdout
}
function parseWindowsProcessSample(stdout: string): ParsedWindowsProcessSample {
@@ -0,0 +1,138 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getDefaultWorkspaceSession } from '../shared/constants'
import type { WorkspaceSessionState } from '../shared/workspace-session-state-types'
import { retireTerminalSurfaceFromPersistence } from './runtime/mobile-session-terminal-persistence-retirement'
import { TEST_LEAF_1, TEST_LEAF_2 } from './persistence-session-fixtures'
import { createStore, makeTerminalTab, testState } from './persistence-test-harness'
vi.mock('electron', () => ({
app: { getPath: () => testState.dir },
safeStorage: { isEncryptionAvailable: () => false }
}))
const WORKTREE = 'repo1::/worktree'
const OTHER_WORKTREE = 'repo1::/other-worktree'
/** The renderer's own publication: it knows only about the tab it created. */
function rendererSession(): WorkspaceSessionState {
return {
...getDefaultWorkspaceSession(),
activeWorktreeId: WORKTREE,
activeTabId: 'renderer-tab',
tabsByWorktree: {
[WORKTREE]: [
makeTerminalTab({ id: 'renderer-tab', worktreeId: WORKTREE, ptyId: 'renderer-pty' })
]
},
terminalLayoutsByTabId: {
'renderer-tab': {
root: { type: 'leaf', leafId: TEST_LEAF_1 },
activeLeafId: TEST_LEAF_1,
expandedLeafId: null,
ptyIdsByLeafId: { [TEST_LEAF_1]: 'renderer-pty' }
}
}
}
}
function persistedTabIds(session: WorkspaceSessionState, worktreeId: string): string[] {
return (session.tabsByWorktree?.[worktreeId] ?? []).map((tab) => tab.id)
}
describe('host-admitted terminal membership survives a stale renderer replay', () => {
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-host-membership-'))
})
afterEach(() => {
rmSync(testState.dir, { recursive: true, force: true })
})
it('keeps the first host-admitted tab when the renderer replays its pre-create tab list', async () => {
const store = await createStore()
store.setWorkspaceSession(rendererSession())
// `orca terminal create`: the host mints a tab the renderer has never seen.
expect(
store.persistPtyBinding({
worktreeId: WORKTREE,
tabId: 'host-tab',
leafId: TEST_LEAF_2,
ptyId: 'host-pty',
hostAdmittedMembership: true
})
).toBe(true)
expect(persistedTabIds(store.getWorkspaceSession(), WORKTREE)).toContain('host-tab')
// The renderer's debounced writer flushes a snapshot taken before the create.
store.setWorkspaceSession(rendererSession())
expect(persistedTabIds(store.getWorkspaceSession(), WORKTREE)).toContain('host-tab')
})
it('keeps a host-admitted tab in a second worktree of the same repo', async () => {
const store = await createStore()
store.setWorkspaceSession(rendererSession())
store.persistPtyBinding({
worktreeId: OTHER_WORKTREE,
tabId: 'host-tab-other',
leafId: TEST_LEAF_2,
ptyId: 'host-pty-other',
hostAdmittedMembership: true
})
store.setWorkspaceSession(rendererSession())
expect(persistedTabIds(store.getWorkspaceSession(), OTHER_WORKTREE)).toContain('host-tab-other')
})
// Polarity: without the flag the renderer still owns membership, so a renderer
// spawn racing its own writer must not freeze the tab list.
it('leaves renderer-owned membership alone when the binding is not host-admitted', async () => {
const store = await createStore()
store.setWorkspaceSession(rendererSession())
store.persistPtyBinding({
worktreeId: WORKTREE,
tabId: 'renderer-second-tab',
leafId: TEST_LEAF_2,
ptyId: 'renderer-second-pty'
})
store.setWorkspaceSession(rendererSession())
expect(persistedTabIds(store.getWorkspaceSession(), WORKTREE)).toEqual(['renderer-tab'])
})
// Closing must still work afterwards. Closes are host-driven: the retirement is
// computed from the store's own session (see persistTerminalSurfaceRetirements),
// which is what outranks the fence this create just raised.
it('still lets the authoritative retirement path close the host-admitted tab', async () => {
const store = await createStore()
store.setWorkspaceSession(rendererSession())
store.persistPtyBinding({
worktreeId: WORKTREE,
tabId: 'host-tab',
leafId: TEST_LEAF_2,
ptyId: 'host-pty',
incarnationId: 'host-incarnation',
hostAdmittedMembership: true
})
store.setWorkspaceSession(
retireTerminalSurfaceFromPersistence(store.getWorkspaceSession(), {
worktreeId: WORKTREE,
parentTabId: 'host-tab',
leafId: TEST_LEAF_2,
ptyId: 'host-pty',
incarnationId: 'host-incarnation'
})
)
// A stale renderer replay must not resurrect it either.
store.setWorkspaceSession(rendererSession())
expect(persistedTabIds(store.getWorkspaceSession(), WORKTREE)).toEqual(['renderer-tab'])
})
})
+10 -2
View File
@@ -3230,6 +3230,8 @@ export class Store {
startupCwd?: string
expectedBinding?: { ptyId: string; incarnationId?: string }
expectedSourceBinding?: PtyBindingSourceExpectation
/** Set by host-initiated creates, which have no renderer session writer behind them. */
hostAdmittedMembership?: boolean
},
hostId?: string | null
): boolean {
@@ -3281,13 +3283,18 @@ export class Store {
args.expectedBinding !== undefined &&
args.incarnationId !== args.expectedBinding.incarnationId
let terminalMembershipChanged = false
let hostAdmittedTabCreated = false
const advanceTopologyFence = (): void => {
const repoId = getRepoIdFromWorktreeId(bindingWorktreeId)
const currentRevision = session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0
const establishesSplitAuthority = args.expectedSourceBinding !== undefined
// Why: a split, or a host-admitted tab the renderer has never seen, is itself
// the authority — with no fence the renderer's pre-create tab list replays
// over it and the tab is lost even on the repo's first such change.
const establishesMembershipAuthority =
args.expectedSourceBinding !== undefined || hostAdmittedTabCreated
if (
!reconciledIncarnation &&
(!terminalMembershipChanged || (currentRevision <= 0 && !establishesSplitAuthority))
(!terminalMembershipChanged || (currentRevision <= 0 && !establishesMembershipAuthority))
) {
return
}
@@ -3325,6 +3332,7 @@ export class Store {
tab.ptyId = args.ptyId
} else {
terminalMembershipChanged = true
hostAdmittedTabCreated = args.hostAdmittedMembership === true
// Why: pty:spawn can beat the debounced writer; persist a minimal tab so hydration won't prune the binding as orphaned.
const nextTabs = [
...(tabs ?? []),
+10 -17
View File
@@ -9,6 +9,7 @@ import type {
WorkspacePortScanResult
} from '../../shared/workspace-ports'
import { getProcessOutputFields } from '../../shared/process-output-field-scanner'
import { readWindowsProcessTable } from '../windows/windows-process-table'
import { advertisedUrlWatcher, type AdvertisedUrlWatcher } from './advertised-url-watcher'
import { isPortScanWorkerUnavailableError, runPortScanCommand } from './port-scan-command-client'
import { PortScanCommandTimeoutError } from './port-scan-command-protocol'
@@ -491,23 +492,15 @@ async function loadWindowsProcessMetadata(
return result
}
try {
const pidFilter = Array.from(pids)
.filter(Number.isFinite)
.map((pid) => `ProcessId=${pid}`)
.join(' OR ')
const { stdout } = await runPortScanCommand('powershell.exe', [
'-NoProfile',
'-Command',
`Get-CimInstance Win32_Process -Filter "${pidFilter}" | Select-Object ProcessId,Name,CommandLine | ConvertTo-Json -Compress`
])
const parsed = JSON.parse(stdout) as
| { ProcessId: number; Name?: string; CommandLine?: string }
| { ProcessId: number; Name?: string; CommandLine?: string }[]
for (const row of Array.isArray(parsed) ? parsed : [parsed]) {
if (pids.has(row.ProcessId)) {
result.set(row.ProcessId, {
processName: row.Name,
commandLine: row.CommandLine
// Why the native snapshot: attributing ports used to fork a powershell.exe
// per scan just to turn PIDs into names. That is a ~700ms cold start, a
// conhost window, and one more thing a Group Policy can block -- for data
// the panel treats as optional anyway.
for (const row of await readWindowsProcessTable()) {
if (pids.has(row.pid)) {
result.set(row.pid, {
processName: row.name,
commandLine: row.command || undefined
})
}
}
@@ -1,57 +1,57 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() }))
const getAllProcessesMock = vi.fn()
vi.mock('child_process', () => ({ execFile: execFileMock }))
import { resetProcessTableSnapshotForTests } from '../../shared/process-table-snapshot'
import { __setWindowsProcessTreeLoaderForTests } from '../windows/windows-process-table'
import { resolveAgentForegroundProcessWithAvailability } from './agent-foreground-process'
import { resetWindowsProcessRowsSnapshotForTests } from './windows-foreground-process-rows'
// A real snapshot always contains the process doing the querying; the reader
// rejects a table without it, because that is what a blocked
// CreateToolhelp32Snapshot looks like (an empty list, not an error).
const SELF_PROCESS_ROW = { pid: process.pid, ppid: 0, name: 'vitest.exe', commandLine: 'vitest' }
const withSelf = <T>(rows: readonly T[]): (T | typeof SELF_PROCESS_ROW)[] => [
SELF_PROCESS_ROW,
...rows
]
describe('Pi Windows foreground recognition', () => {
let platform: PropertyDescriptor | undefined
beforeEach(() => {
execFileMock.mockReset()
resetProcessTableSnapshotForTests()
resetWindowsProcessRowsSnapshotForTests()
getAllProcessesMock.mockReset()
platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'win32' })
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
__setWindowsProcessTreeLoaderForTests(() => ({
ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 },
getAllProcesses: getAllProcessesMock
}))
})
afterEach(() => {
__setWindowsProcessTreeLoaderForTests()
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
})
it('recognizes the npm entrypoint within the active ConPTY', async () => {
const rows = JSON.stringify([
const rows = [
{
CommandLine: 'C:\\Program Files\\Git\\usr\\bin\\bash.exe',
ExecutablePath: 'C:\\Program Files\\Git\\usr\\bin\\bash.exe',
Name: 'bash.exe',
ParentProcessId: 99,
ProcessId: 100
pid: 100,
ppid: 99,
name: 'bash.exe',
commandLine: '"C:\\Program Files\\Git\\usr\\bin\\bash.exe"'
},
{
CommandLine:
'node.exe C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@earendil-works\\pi-coding-agent\\dist\\cli.js',
ExecutablePath: 'C:\\Program Files\\nodejs\\node.exe',
Name: 'node.exe',
ParentProcessId: 100,
ProcessId: 101
pid: 101,
ppid: 100,
name: 'node.exe',
commandLine:
'node.exe C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@earendil-works\\pi-coding-agent\\dist\\cli.js'
}
])
execFileMock.mockImplementation(
(_cmd: string, _args: string[], _options: unknown, callback: unknown) => {
const done = callback as (error: null, result: { stdout: string; stderr: string }) => void
done(null, {
stdout: rows,
stderr: ''
})
}
)
]
getAllProcessesMock.mockImplementation((cb: (snapshot: unknown) => void) => {
cb(withSelf(rows))
})
const readWindowsConptyProcessIds = vi.fn(async () => new Set([100, 101]))
await expect(
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -64,6 +64,7 @@ import { getAgentForegroundContextPaths } from './agent-foreground-context-paths
import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition'
import { killWithDescendantSweep } from '../pty-descendant-termination'
import { readWindowsConptyProcessIds } from './windows-conpty-process-membership'
import { terminatePtyJob } from '../windows/windows-pty-job'
import { canConfirmAgentFromConsolePresence } from './windows-console-foreground'
import { forceKillPosixPtyProcessGroups } from '../pty/posix-pty-process-groups'
import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery'
@@ -1283,7 +1284,8 @@ export class LocalPtyProvider implements IPtyProvider {
// identity probe returns `own` so agent/MCP orphans cannot hold the worktree cwd
// (#10004). `unknown`/`foreign`/`absent` skip taskkill and rely on root close alone.
await killWithDescendantSweep(proc.pid, signalRoot, {
ownsRoot: () => ptyProcesses.get(id) === proc
ownsRoot: () => ptyProcesses.get(id) === proc,
terminateOwnedTree: () => terminatePtyJob(proc)
})
} else if (process.platform === 'win32' && operation.immediate) {
// Why: a plain shell's ConPTY teardown doesn't reap orphaned children (useConptyDll
@@ -1291,7 +1293,8 @@ export class LocalPtyProvider implements IPtyProvider {
// holds the worktree cwd. Tree kill runs only when the OS identity probe returns `own`;
// otherwise root close alone, and detached children may block physical stop (#10004).
await killWithDescendantSweep(proc.pid, signalRoot, {
ownsRoot: () => ptyProcesses.get(id) === proc
ownsRoot: () => ptyProcesses.get(id) === proc,
terminateOwnedTree: () => terminatePtyJob(proc)
})
} else {
signalRoot()
+148 -209
View File
@@ -1,23 +1,20 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { spawnSync } from 'node:child_process'
import type * as ChildProcess from 'node:child_process'
import type * as LoginSessionPtyProbe from './macos-login-session-pty-probe'
const { existsSyncMock, userInfoMock, execFileMock, stdinEndMock, ptyProbeMock } = vi.hoisted(
() => ({
existsSyncMock: vi.fn(),
userInfoMock: vi.fn(),
execFileMock: vi.fn(),
stdinEndMock: vi.fn(),
ptyProbeMock: vi.fn()
})
)
const { existsSyncMock, userInfoMock, runProcessMock, ptyProbeMock } = vi.hoisted(() => ({
existsSyncMock: vi.fn(),
userInfoMock: vi.fn(),
runProcessMock: vi.fn(),
ptyProbeMock: vi.fn()
}))
vi.mock('node:fs', () => ({ existsSync: existsSyncMock }))
vi.mock('node:os', () => ({ userInfo: userInfoMock }))
vi.mock('node:child_process', async (importOriginal) => ({
...(await importOriginal<typeof ChildProcess>()),
execFile: execFileMock
// Why mock the chokepoint: encoding, kill signal, output cap and closing stdin
// are its contract now, so this suite asserts the PAM probe's argv and verdict.
vi.mock('../../shared/child-process/run-process', () => ({
runProcess: (...args: unknown[]) => Promise.resolve(runProcessMock(...args))
}))
vi.mock('./macos-login-session-pty-probe', async (importOriginal) => ({
...(await importOriginal<typeof LoginSessionPtyProbe>()),
@@ -31,7 +28,21 @@ import {
wrapShellSpawnForMacosTccAttribution
} from './macos-tcc-login-shell'
type ExecFileCallback = (error: Error | null, stdout: string, stderr: string) => void
const ACCEPTED_RESULT = {
code: 0,
signal: null,
stdout: 'ORCA_LOGIN_PREFLIGHT_OK',
stderr: '',
timedOut: false
}
const REJECTED_RESULT = { code: 1, signal: null, stdout: '', stderr: '', timedOut: false }
const TIMED_OUT_RESULT = {
code: null,
signal: 'SIGKILL' as const,
stdout: '',
stderr: '',
timedOut: true
}
const ACCEPTED_OUTCOME = { ok: true, conclusive: true, reason: 'accepted' } as const
const REJECTED_OUTCOME = { ok: false, conclusive: true, reason: 'rejected' } as const
const itOnPosixHost = process.platform === 'win32' ? it.skip : it
@@ -50,12 +61,13 @@ describe('wrapShellSpawnForMacosTccAttribution', () => {
delete process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL
existsSyncMock.mockReturnValue(true)
userInfoMock.mockReturnValue({ username: 'ada', homedir: '/Users/ada' })
execFileMock.mockImplementation(
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
callback(null, 'ORCA_LOGIN_PREFLIGHT_OK', '')
return { stdin: { end: stdinEndMock } }
}
)
runProcessMock.mockResolvedValue({
code: 0,
signal: null,
stdout: 'ORCA_LOGIN_PREFLIGHT_OK',
stderr: '',
timedOut: false
})
ptyProbeMock.mockResolvedValue(REJECTED_OUTCOME)
resetMacosLoginShellPreflightForTests()
})
@@ -93,29 +105,27 @@ describe('wrapShellSpawnForMacosTccAttribution', () => {
'-l'
]
})
expect(execFileMock).toHaveBeenCalledWith(
'/usr/bin/login',
['-flpq', 'ada', '/usr/bin/printf', 'ORCA_LOGIN_PREFLIGHT_OK'],
{
cwd: '/Users/ada',
encoding: 'utf8',
killSignal: 'SIGKILL',
maxBuffer: 1024,
timeout: 500
},
expect.any(Function)
)
expect(stdinEndMock).toHaveBeenCalledOnce()
expect(runProcessMock).toHaveBeenCalledWith({
program: '/usr/bin/login',
args: ['-flpq', 'ada', '/usr/bin/printf', 'ORCA_LOGIN_PREFLIGHT_OK'],
// Why cwd: detached daemons can outlive their launch worktree, and the
// PAM probe must not inherit a deleted one.
cwd: '/Users/ada',
maxOutputBytes: 1024,
signal: undefined,
timeoutMs: 500
})
})
it('spawns the shell directly when login PAM policy rejects the current user', async () => {
setPlatform('darwin')
execFileMock.mockImplementation(
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
callback(null, 'Login incorrect\nlogin: ', '')
return { stdin: { end: stdinEndMock } }
}
)
runProcessMock.mockResolvedValue({
code: 0,
signal: null,
stdout: 'Login incorrect\nlogin: ',
stderr: '',
timedOut: false
})
vi.spyOn(console, 'warn').mockImplementation(() => {})
await prepareMacosTccLoginShell()
@@ -130,32 +140,24 @@ describe('wrapShellSpawnForMacosTccAttribution', () => {
it('caches a conclusive PAM rejection for later terminal spawns', async () => {
setPlatform('darwin')
execFileMock.mockImplementation(
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
// login(1) exiting non-zero on its own is a deterministic PAM verdict.
callback(Object.assign(new Error('login incorrect'), { code: 1 }), '', '')
return { stdin: { end: stdinEndMock } }
}
)
runProcessMock.mockImplementation(() => {
// login(1) exiting non-zero on its own is a deterministic PAM verdict.
return REJECTED_RESULT
})
vi.spyOn(console, 'warn').mockImplementation(() => {})
await prepareMacosTccLoginShell()
await prepareMacosTccLoginShell()
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh')
expect(wrapShellSpawnForMacosTccAttribution('/bin/bash', ['-l']).file).toBe('/bin/bash')
expect(execFileMock).toHaveBeenCalledTimes(1)
expect(runProcessMock).toHaveBeenCalledTimes(1)
expect(ptyProbeMock).toHaveBeenCalledTimes(1)
expect(console.warn).toHaveBeenCalledTimes(1)
})
it('uses the production-shaped PTY verdict when the pipe probe falsely rejects', async () => {
setPlatform('darwin')
execFileMock.mockImplementation(
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
callback(Object.assign(new Error('login incorrect'), { code: 1 }), '', '')
return { stdin: { end: stdinEndMock } }
}
)
runProcessMock.mockReturnValue(REJECTED_RESULT)
ptyProbeMock.mockResolvedValue(ACCEPTED_OUTCOME)
await expect(prepareMacosTccLoginShell()).resolves.toEqual(ACCEPTED_OUTCOME)
@@ -166,12 +168,7 @@ describe('wrapShellSpawnForMacosTccAttribution', () => {
it('dedupes concurrent PTY confirmations of a rejected pipe verdict', async () => {
setPlatform('darwin')
execFileMock.mockImplementation(
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
callback(Object.assign(new Error('login incorrect'), { code: 1 }), '', '')
return { stdin: { end: stdinEndMock } }
}
)
runProcessMock.mockReturnValue(REJECTED_RESULT)
let finishPtyProbe!: (outcome: typeof REJECTED_OUTCOME) => void
ptyProbeMock.mockReturnValue(
new Promise((resolve) => {
@@ -181,9 +178,11 @@ describe('wrapShellSpawnForMacosTccAttribution', () => {
const first = prepareMacosTccLoginShell()
const second = prepareMacosTccLoginShell()
await Promise.resolve()
expect(execFileMock).toHaveBeenCalledTimes(1)
expect(ptyProbeMock).toHaveBeenCalledTimes(1)
// Why waitFor and not a fixed tick count: the probe is async, so counting
// microtasks would couple the test to the wrapper's internals rather than
// to the property under test -- that concurrent callers share one probe.
await vi.waitFor(() => expect(ptyProbeMock).toHaveBeenCalledTimes(1))
expect(runProcessMock).toHaveBeenCalledTimes(1)
finishPtyProbe(REJECTED_OUTCOME)
await expect(Promise.all([first, second])).resolves.toEqual([
@@ -197,18 +196,11 @@ describe('wrapShellSpawnForMacosTccAttribution', () => {
let now = 1_000
vi.spyOn(Date, 'now').mockImplementation(() => now)
let attempt = 0
execFileMock.mockImplementation(
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
attempt += 1
if (attempt === 1) {
// A one-off PAM hiccup that login(1) reports as a deterministic rejection.
callback(Object.assign(new Error('login incorrect'), { code: 1 }), '', '')
} else {
callback(null, 'ORCA_LOGIN_PREFLIGHT_OK', '')
}
return { stdin: { end: stdinEndMock } }
}
)
runProcessMock.mockImplementation(() => {
attempt += 1
// A one-off PAM hiccup that login(1) reports as a deterministic rejection.
return attempt === 1 ? REJECTED_RESULT : ACCEPTED_RESULT
})
vi.spyOn(console, 'warn').mockImplementation(() => {})
await prepareMacosTccLoginShell()
@@ -217,13 +209,13 @@ describe('wrapShellSpawnForMacosTccAttribution', () => {
// Inside the window the rejection stays cached — no probe per spawn.
now += 29 * 60_000
await prepareMacosTccLoginShell()
expect(execFileMock).toHaveBeenCalledTimes(1)
expect(runProcessMock).toHaveBeenCalledTimes(1)
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh')
// Past the window the daemon re-probes instead of staying degraded forever.
now += 60_000
await prepareMacosTccLoginShell()
expect(execFileMock).toHaveBeenCalledTimes(2)
expect(runProcessMock).toHaveBeenCalledTimes(2)
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login')
})
@@ -235,7 +227,7 @@ describe('wrapShellSpawnForMacosTccAttribution', () => {
await prepareMacosTccLoginShell()
now += 24 * 60 * 60_000
await prepareMacosTccLoginShell()
expect(execFileMock).toHaveBeenCalledTimes(1)
expect(runProcessMock).toHaveBeenCalledTimes(1)
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login')
})
@@ -244,12 +236,7 @@ describe('wrapShellSpawnForMacosTccAttribution', () => {
let now = 1_000
vi.spyOn(Date, 'now').mockImplementation(() => now)
// A probe our own SIGKILL cap killed proves nothing about PAM; it must retry.
execFileMock.mockImplementation(
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
callback(Object.assign(new Error('timed out'), { killed: true }), '', '')
return { stdin: { end: stdinEndMock } }
}
)
runProcessMock.mockReturnValue(TIMED_OUT_RESULT)
vi.spyOn(console, 'warn').mockImplementation(() => {})
const first = await prepareMacosTccLoginShell()
@@ -259,19 +246,19 @@ describe('wrapShellSpawnForMacosTccAttribution', () => {
// ...without making every subsequent terminal pay the same 500 ms timeout.
await prepareMacosTccLoginShell()
expect(execFileMock).toHaveBeenCalledOnce()
expect(runProcessMock).toHaveBeenCalledOnce()
now += 5_000
await prepareMacosTccLoginShell()
expect(execFileMock).toHaveBeenCalledTimes(2)
expect(runProcessMock).toHaveBeenCalledTimes(2)
// Repeated failures back off further rather than spawning login(1) every 5 seconds.
now += 5_000
await prepareMacosTccLoginShell()
expect(execFileMock).toHaveBeenCalledTimes(2)
expect(runProcessMock).toHaveBeenCalledTimes(2)
now += 5_000
await prepareMacosTccLoginShell()
expect(execFileMock).toHaveBeenCalledTimes(3)
expect(runProcessMock).toHaveBeenCalledTimes(3)
})
it('self-heals to the login wrapper once a re-probe succeeds (F1)', async () => {
@@ -279,36 +266,30 @@ describe('wrapShellSpawnForMacosTccAttribution', () => {
let now = 1_000
vi.spyOn(Date, 'now').mockImplementation(() => now)
let attempt = 0
execFileMock.mockImplementation(
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
attempt += 1
if (attempt === 1) {
callback(Object.assign(new Error('timed out'), { killed: true }), '', '')
} else {
callback(null, 'ORCA_LOGIN_PREFLIGHT_OK', '')
}
return { stdin: { end: stdinEndMock } }
}
)
runProcessMock.mockImplementation(() => {
attempt += 1
return attempt === 1 ? TIMED_OUT_RESULT : ACCEPTED_RESULT
})
vi.spyOn(console, 'warn').mockImplementation(() => {})
await prepareMacosTccLoginShell()
await prepareMacosTccLoginShell()
expect(execFileMock).toHaveBeenCalledOnce()
expect(runProcessMock).toHaveBeenCalledOnce()
now += 5_000
await prepareMacosTccLoginShell()
expect(execFileMock).toHaveBeenCalledTimes(2)
expect(runProcessMock).toHaveBeenCalledTimes(2)
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login')
})
it('returns a conclusive outcome the daemon can log structurally (F2)', async () => {
setPlatform('darwin')
execFileMock.mockImplementation(
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
callback(null, 'Login incorrect\nlogin: ', '')
return { stdin: { end: stdinEndMock } }
}
)
runProcessMock.mockResolvedValue({
code: 0,
signal: null,
stdout: 'Login incorrect\nlogin: ',
stderr: '',
timedOut: false
})
vi.spyOn(console, 'warn').mockImplementation(() => {})
const outcome = await prepareMacosTccLoginShell()
@@ -319,16 +300,14 @@ describe('wrapShellSpawnForMacosTccAttribution', () => {
it('rejects a marker emitted by a preflight that does not exit cleanly', async () => {
setPlatform('darwin')
execFileMock.mockImplementation(
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
callback(
Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' }),
'ORCA_LOGIN_PREFLIGHT_OK',
''
)
return { stdin: { end: stdinEndMock } }
}
)
// A marker printed by a probe that then hit the timeout cap proves nothing.
runProcessMock.mockReturnValue({
code: null,
signal: 'SIGKILL' as const,
stdout: 'ORCA_LOGIN_PREFLIGHT_OK',
stderr: '',
timedOut: true
})
vi.spyOn(console, 'warn').mockImplementation(() => {})
await prepareMacosTccLoginShell()
@@ -344,7 +323,7 @@ describe('wrapShellSpawnForMacosTccAttribution', () => {
await Promise.all([prepareMacosTccLoginShell(), prepareMacosTccLoginShell()])
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login')
expect(wrapShellSpawnForMacosTccAttribution('/bin/bash', ['-l']).file).toBe('/usr/bin/login')
expect(execFileMock).toHaveBeenCalledTimes(1)
expect(runProcessMock).toHaveBeenCalledTimes(1)
})
it('preserves the non-login Bash shell-ready rcfile launch', async () => {
@@ -525,7 +504,7 @@ describe('wrapShellSpawnForMacosTccAttribution', () => {
file: '/bin/zsh',
args: ['-l']
})
expect(execFileMock).not.toHaveBeenCalled()
expect(runProcessMock).not.toHaveBeenCalled()
})
})
@@ -543,12 +522,13 @@ describe('probeMacosLoginSessionAlive', () => {
delete process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL
existsSyncMock.mockReturnValue(true)
userInfoMock.mockReturnValue({ username: 'ada', homedir: '/Users/ada' })
execFileMock.mockImplementation(
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
callback(null, 'ORCA_LOGIN_PREFLIGHT_OK', '')
return { stdin: { end: stdinEndMock } }
}
)
runProcessMock.mockResolvedValue({
code: 0,
signal: null,
stdout: 'ORCA_LOGIN_PREFLIGHT_OK',
stderr: '',
timedOut: false
})
ptyProbeMock.mockResolvedValue({ ok: true, conclusive: true, reason: 'accepted' })
resetMacosLoginShellPreflightForTests()
})
@@ -569,52 +549,47 @@ describe('probeMacosLoginSessionAlive', () => {
it('re-probes even after a cached acceptance', async () => {
setPlatform('darwin')
await prepareMacosTccLoginShell()
expect(execFileMock).toHaveBeenCalledTimes(1)
expect(runProcessMock).toHaveBeenCalledTimes(1)
const outcome = await probeMacosLoginSessionAlive()
expect(outcome).toEqual({ ok: true, conclusive: true, reason: 'accepted' })
expect(execFileMock).toHaveBeenCalledTimes(2)
expect(runProcessMock).toHaveBeenCalledTimes(2)
})
it('reuses an in-flight startup warmup instead of spawning a duplicate probe', async () => {
setPlatform('darwin')
let finishPreflight!: ExecFileCallback
execFileMock.mockImplementation(
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
finishPreflight = callback
return { stdin: { end: stdinEndMock } }
}
let finishPreflight!: (result: typeof ACCEPTED_RESULT) => void
runProcessMock.mockImplementation(
() =>
new Promise((resolve) => {
finishPreflight = resolve
})
)
const warmup = prepareMacosTccLoginShell()
const freshProbe = probeMacosLoginSessionAlive()
expect(execFileMock).toHaveBeenCalledOnce()
expect(runProcessMock).toHaveBeenCalledOnce()
finishPreflight(null, 'ORCA_LOGIN_PREFLIGHT_OK', '')
finishPreflight(ACCEPTED_RESULT)
await expect(Promise.all([warmup, freshProbe])).resolves.toEqual([
ACCEPTED_OUTCOME,
ACCEPTED_OUTCOME
])
expect(execFileMock).toHaveBeenCalledOnce()
expect(runProcessMock).toHaveBeenCalledOnce()
})
it('does not let a spawn-path probe overwrite a newer death verdict', async () => {
setPlatform('darwin')
ptyProbeMock.mockResolvedValue(REJECTED_OUTCOME)
const callbacks: ExecFileCallback[] = []
execFileMock.mockImplementation(
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
callbacks.push(callback)
return { stdin: { end: stdinEndMock } }
}
)
const finishers: ((result: typeof ACCEPTED_RESULT) => void)[] = []
runProcessMock.mockImplementation(() => new Promise((resolve) => finishers.push(resolve)))
const freshProbe = probeMacosLoginSessionAlive()
const spawnProbe = prepareMacosTccLoginShell()
expect(execFileMock).toHaveBeenCalledTimes(2)
expect(runProcessMock).toHaveBeenCalledTimes(2)
callbacks[0](Object.assign(new Error('login incorrect'), { code: 1 }), '', '')
finishers[0](REJECTED_RESULT)
await expect(freshProbe).resolves.toEqual(REJECTED_OUTCOME)
callbacks[1](null, 'ORCA_LOGIN_PREFLIGHT_OK', '')
finishers[1](ACCEPTED_RESULT)
await expect(spawnProbe).resolves.toEqual(ACCEPTED_OUTCOME)
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh')
@@ -625,12 +600,7 @@ describe('probeMacosLoginSessionAlive', () => {
await prepareMacosTccLoginShell()
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login')
execFileMock.mockImplementation(
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
callback(Object.assign(new Error('login incorrect'), { code: 1 }), '', '')
return { stdin: { end: stdinEndMock } }
}
)
runProcessMock.mockReturnValue(REJECTED_RESULT)
ptyProbeMock.mockResolvedValue(REJECTED_OUTCOME)
const outcome = await probeMacosLoginSessionAlive()
expect(outcome).toEqual({ ok: false, conclusive: true, reason: 'rejected' })
@@ -641,12 +611,7 @@ describe('probeMacosLoginSessionAlive', () => {
it('does not overwrite the cached verdict on an inconclusive probe', async () => {
setPlatform('darwin')
await prepareMacosTccLoginShell()
execFileMock.mockImplementation(
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
callback(Object.assign(new Error('killed'), { killed: true }), '', '')
return { stdin: { end: stdinEndMock } }
}
)
runProcessMock.mockReturnValue(TIMED_OUT_RESULT)
ptyProbeMock.mockResolvedValue({ ok: false, conclusive: false, reason: 'timeout' })
const outcome = await probeMacosLoginSessionAlive()
expect(outcome).toEqual({ ok: false, conclusive: false, reason: 'timeout' })
@@ -656,12 +621,7 @@ describe('probeMacosLoginSessionAlive', () => {
it('does not trust a pipe rejection when its PTY confirmation is inconclusive', async () => {
setPlatform('darwin')
await prepareMacosTccLoginShell()
execFileMock.mockImplementation(
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
callback(Object.assign(new Error('login incorrect'), { code: 1 }), '', '')
return { stdin: { end: stdinEndMock } }
}
)
runProcessMock.mockReturnValue(REJECTED_RESULT)
ptyProbeMock.mockResolvedValue({ ok: false, conclusive: false, reason: 'timeout' })
await expect(probeMacosLoginSessionAlive()).resolves.toEqual({
@@ -674,19 +634,14 @@ describe('probeMacosLoginSessionAlive', () => {
it('does not add PTY probes to periodic checks on a host that never accepted login', async () => {
setPlatform('darwin')
execFileMock.mockImplementation(
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
callback(Object.assign(new Error('login incorrect'), { code: 1 }), '', '')
return { stdin: { end: stdinEndMock } }
}
)
runProcessMock.mockReturnValue(REJECTED_RESULT)
ptyProbeMock.mockResolvedValue(REJECTED_OUTCOME)
await prepareMacosTccLoginShell()
await probeMacosLoginSessionAlive()
await probeMacosLoginSessionAlive()
expect(execFileMock).toHaveBeenCalledTimes(3)
expect(runProcessMock).toHaveBeenCalledTimes(3)
expect(ptyProbeMock).toHaveBeenCalledTimes(1)
})
@@ -695,28 +650,24 @@ describe('probeMacosLoginSessionAlive', () => {
let now = 1_000
vi.spyOn(Date, 'now').mockImplementation(() => now)
await prepareMacosTccLoginShell()
execFileMock.mockImplementation(
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
callback(Object.assign(new Error('login incorrect'), { code: 1 }), '', '')
return { stdin: { end: stdinEndMock } }
}
)
runProcessMock.mockReturnValue(REJECTED_RESULT)
ptyProbeMock.mockResolvedValue(REJECTED_OUTCOME)
await probeMacosLoginSessionAlive()
now += 29 * 60_000
await probeMacosLoginSessionAlive()
execFileMock.mockImplementation(
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
callback(null, 'ORCA_LOGIN_PREFLIGHT_OK', '')
return { stdin: { end: stdinEndMock } }
}
)
runProcessMock.mockResolvedValue({
code: 0,
signal: null,
stdout: 'ORCA_LOGIN_PREFLIGHT_OK',
stderr: '',
timedOut: false
})
now += 60_000
await prepareMacosTccLoginShell()
expect(execFileMock).toHaveBeenCalledTimes(4)
expect(runProcessMock).toHaveBeenCalledTimes(4)
expect(ptyProbeMock).toHaveBeenCalledTimes(2)
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login')
})
@@ -727,20 +678,15 @@ describe('probeMacosLoginSessionAlive', () => {
setPlatform('darwin')
process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL = '1'
expect(await probeMacosLoginSessionAlive()).toBeNull()
expect(execFileMock).not.toHaveBeenCalled()
expect(runProcessMock).not.toHaveBeenCalled()
})
it('escalates an inconclusive pipe probe to a PTY probe and accepts its verdict', async () => {
setPlatform('darwin')
execFileMock.mockImplementation(
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
callback(Object.assign(new Error('killed'), { killed: true }), '', '')
return { stdin: { end: stdinEndMock } }
}
)
runProcessMock.mockReturnValue(TIMED_OUT_RESULT)
const outcome = await probeMacosLoginSessionAlive()
expect(outcome).toEqual({ ok: true, conclusive: true, reason: 'accepted' })
expect(execFileMock).toHaveBeenCalledOnce()
expect(runProcessMock).toHaveBeenCalledOnce()
expect(ptyProbeMock).toHaveBeenCalledWith('ada', '/Users/ada', 4_000, 1_024, undefined)
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login')
})
@@ -748,12 +694,7 @@ describe('probeMacosLoginSessionAlive', () => {
it('treats a PTY-probe rejection as conclusive and flips the wrapper off', async () => {
setPlatform('darwin')
await prepareMacosTccLoginShell()
execFileMock.mockImplementation(
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
callback(Object.assign(new Error('killed'), { killed: true }), '', '')
return { stdin: { end: stdinEndMock } }
}
)
runProcessMock.mockReturnValue(TIMED_OUT_RESULT)
ptyProbeMock.mockResolvedValue({ ok: false, conclusive: true, reason: 'rejected' })
const outcome = await probeMacosLoginSessionAlive()
expect(outcome).toEqual({ ok: false, conclusive: true, reason: 'rejected' })
@@ -763,12 +704,7 @@ describe('probeMacosLoginSessionAlive', () => {
it('stays inconclusive when both pipe and PTY probes time out', async () => {
setPlatform('darwin')
await prepareMacosTccLoginShell()
execFileMock.mockImplementation(
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
callback(Object.assign(new Error('killed'), { killed: true }), '', '')
return { stdin: { end: stdinEndMock } }
}
)
runProcessMock.mockReturnValue(TIMED_OUT_RESULT)
ptyProbeMock.mockResolvedValue({ ok: false, conclusive: false, reason: 'timeout' })
const outcome = await probeMacosLoginSessionAlive()
expect(outcome).toEqual({ ok: false, conclusive: false, reason: 'timeout' })
@@ -780,12 +716,15 @@ describe('probeMacosLoginSessionAlive', () => {
setPlatform('darwin')
const abortController = new AbortController()
abortController.abort()
execFileMock.mockImplementation(
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
callback(Object.assign(new Error('aborted'), { code: 'ABORT_ERR' }), '', '')
return { stdin: { end: stdinEndMock } }
}
)
// An aborted probe reports no exit code, which is inconclusive -- never a
// PAM verdict.
runProcessMock.mockReturnValue({
code: null,
signal: 'SIGTERM' as const,
stdout: '',
stderr: '',
timedOut: false
})
await expect(probeMacosLoginSessionAlive(abortController.signal)).resolves.toEqual({
ok: false,
+29 -40
View File
@@ -1,9 +1,8 @@
import { execFile } from 'node:child_process'
import { runProcess } from '../../shared/child-process/run-process'
import { existsSync } from 'node:fs'
import { userInfo } from 'node:os'
import { basename } from 'node:path'
import {
classifyLoginPreflightError,
runMacosLoginSessionPtyProbe,
type LoginPreflightOutcome
} from './macos-login-session-pty-probe'
@@ -62,50 +61,40 @@ function loginPreflightRetryDelayMs(failureCount: number): number {
// Fidelity limit: the probe runs over pipes while production shells run under a
// real PTY, so a tty-sensitive PAM stack could diverge. It fails safe — a probe
// pass with a prod failure only degrades to today's direct spawn (no wrapper).
function runLoginPreflight(
async function runLoginPreflight(
username: string,
accountHome: string,
timeoutMs = LOGIN_PREFLIGHT_TIMEOUT_MS,
signal?: AbortSignal
): Promise<LoginPreflightOutcome> {
return new Promise((resolve) => {
try {
const child = execFile(
MACOS_LOGIN_PATH,
['-flpq', username, MACOS_PRINTF_PATH, LOGIN_PREFLIGHT_MARKER],
{
// Why: detached daemons can outlive their launch worktree. The PAM
// probe must not inherit a deleted cwd before PTY spawn repairs it.
cwd: accountHome,
encoding: 'utf8',
// Why: PAM policy can wait indefinitely. Bound both child lifetime and
// captured diagnostics without blocking the PTY host's event loop.
killSignal: 'SIGKILL',
maxBuffer: LOGIN_PREFLIGHT_MAX_BUFFER_BYTES,
signal,
timeout: timeoutMs
},
(error, stdout) => {
if (error === null) {
// login(1) can return zero after an EOF-driven failed prompt, so only the
// requested child program's output plus a clean exit proves PAM accepted it.
resolve(
stdout === LOGIN_PREFLIGHT_MARKER
? { ok: true, conclusive: true, reason: 'accepted' }
: { ok: false, conclusive: true, reason: 'rejected' }
)
return
}
resolve(classifyLoginPreflightError(error))
}
)
// Why: login(1) must see immediate EOF, not an interactive pipe, so a PAM
// rejection exits instead of waiting at `login:` until the timeout.
child.stdin?.end()
} catch {
resolve({ ok: false, conclusive: false, reason: 'error' })
try {
const result = await runProcess({
program: MACOS_LOGIN_PATH,
args: ['-flpq', username, MACOS_PRINTF_PATH, LOGIN_PREFLIGHT_MARKER],
// Why: detached daemons can outlive their launch worktree. The PAM
// probe must not inherit a deleted cwd before PTY spawn repairs it.
cwd: accountHome,
maxOutputBytes: LOGIN_PREFLIGHT_MAX_BUFFER_BYTES,
signal,
timeoutMs
})
if (result.timedOut) {
return { ok: false, conclusive: false, reason: 'timeout' }
}
})
if (result.code === null) {
return { ok: false, conclusive: false, reason: 'error' }
}
if (result.code !== 0) {
return { ok: false, conclusive: true, reason: 'rejected' }
}
// login(1) can return zero after an EOF-driven failed prompt, so only the
// requested child program's output plus a clean exit proves PAM accepted it.
return result.stdout === LOGIN_PREFLIGHT_MARKER
? { ok: true, conclusive: true, reason: 'accepted' }
: { ok: false, conclusive: true, reason: 'rejected' }
} catch {
return { ok: false, conclusive: false, reason: 'error' }
}
}
async function verifyRejectedLoginPreflightUnderPty(
@@ -1,26 +1,27 @@
// Regression guard: bound the volume of full-process-table PowerShell/CIM scans
// driven by Windows agent foreground-process inspection — the Windows analogue of
// issue #6288 (POSIX `ps`).
// Regression guard: bound the volume of full-process-table scans driven by
// Windows agent foreground-process inspection — the Windows analogue of issue
// #6288 (POSIX `ps`).
//
// Drives queryWindowsProcessDescendants across several concurrently-inspecting
// agent panes on the agent-completion cadence (ACTIVE_POLL_INTERVAL_MS = 750ms)
// and counts how many powershell.exe process-table scans actually spawn. Pre-fix
// the call site forked one powershell.exe per pane per tick; with the shared
// snapshot cache the scans collapse to ~one per tick regardless of pane count,
// while each pane still resolves the same descendant set.
// and counts how many Toolhelp32 snapshots actually run. Pre-fix the call site
// scanned once per pane per tick; with the shared snapshot cache the scans
// collapse to ~one per tick regardless of pane count, while each pane still
// resolves the same descendant set.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { execFileMock, powershellScanCount } = vi.hoisted(() => ({
execFileMock: vi.fn(),
powershellScanCount: { value: 0 }
}))
const getAllProcessesMock = vi.fn()
vi.mock('child_process', () => ({ execFile: execFileMock }))
import {
queryWindowsProcessDescendants,
resetWindowsProcessRowsSnapshotForTests
} from './windows-foreground-process-rows'
import { __setWindowsProcessTreeLoaderForTests } from '../windows/windows-process-table'
import { queryWindowsProcessDescendants } from './windows-foreground-process-rows'
// A real snapshot always contains the process doing the querying; the reader
// rejects a table without it, because that is what a blocked
// CreateToolhelp32Snapshot looks like (an empty list, not an error).
const SELF_PROCESS_ROW = { pid: process.pid, ppid: 0, name: 'vitest.exe', commandLine: 'vitest' }
const withSelf = <T>(rows: readonly T[]): (T | typeof SELF_PROCESS_ROW)[] => [
SELF_PROCESS_ROW,
...rows
]
const ACTIVE_POLL_INTERVAL_MS = 750
const PANE_COUNT = 6
@@ -29,64 +30,56 @@ const TICKS = Math.floor((WINDOW_SECONDS * 1000) / ACTIVE_POLL_INTERVAL_MS)
const shellPid = (pane: number): number => 100 + pane * 1000
// A real CIM query returns the whole system, so one shared snapshot must contain
// every pane's shell + foreground node/codex child. Each pane resolves its own
// A snapshot returns the whole system, so one shared scan must contain every
// pane's shell + foreground node/codex child. Each pane resolves its own
// descendant from the single scan.
const PROCESS_TABLE_JSON = JSON.stringify(
Array.from({ length: PANE_COUNT }, (_, pane) => {
const shell = shellPid(pane)
return [
{
ProcessId: shell,
ParentProcessId: 99,
Name: 'cmd.exe',
CommandLine: 'cmd.exe',
ExecutablePath: 'C:/Windows/System32/cmd.exe'
},
{
ProcessId: shell + 1,
ParentProcessId: shell,
Name: 'node.exe',
CommandLine: 'node C:/Users/dev/AppData/codex/bin/codex.js',
ExecutablePath: 'C:/Program Files/nodejs/node.exe'
}
]
}).flat()
)
function installCountingPowerShellMock(): void {
execFileMock.mockImplementation((cmd: string, _args: unknown, _opts: unknown, cb: unknown) => {
const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void
if (cmd === 'powershell.exe') {
powershellScanCount.value += 1
const NATIVE_ROWS = Array.from({ length: PANE_COUNT }, (_, pane) => {
const shell = shellPid(pane)
return [
{
pid: shell,
ppid: 99,
name: 'cmd.exe',
commandLine: 'cmd.exe'
},
{
pid: shell + 1,
ppid: shell,
name: 'node.exe',
commandLine: 'node C:/Users/dev/AppData/codex/bin/codex.js'
}
callback(null, { stdout: PROCESS_TABLE_JSON, stderr: '' })
})
}
]
}).flat()
describe('windows agent foreground inspection powershell-scan volume', () => {
describe('windows agent foreground inspection process-table scan volume', () => {
let platform: PropertyDescriptor | undefined
beforeEach(() => {
execFileMock.mockReset()
resetWindowsProcessRowsSnapshotForTests()
powershellScanCount.value = 0
getAllProcessesMock.mockReset()
getAllProcessesMock.mockImplementation((cb: (snapshot: unknown) => void) => {
cb(withSelf(NATIVE_ROWS))
})
platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
__setWindowsProcessTreeLoaderForTests(() => ({
ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 },
getAllProcesses: getAllProcessesMock
}))
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(0)
})
afterEach(() => {
vi.useRealTimers()
__setWindowsProcessTreeLoaderForTests()
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
})
it('bounds powershell scans by poll ticks, not by pane count, while resolving every pane', async () => {
installCountingPowerShellMock()
const scanCount = (): number => getAllProcessesMock.mock.calls.length
it('bounds process-table scans by poll ticks, not by pane count, while resolving every pane', async () => {
for (let tick = 0; tick < TICKS; tick++) {
vi.setSystemTime(tick * ACTIVE_POLL_INTERVAL_MS)
// All panes inspect concurrently within the tick (worst case).
@@ -106,22 +99,20 @@ describe('windows agent foreground inspection powershell-scan volume', () => {
}
const totalInspections = PANE_COUNT * TICKS
// Pre-fix this equals totalInspections (one powershell.exe per inspection).
// With the shared cache, concurrent panes within a tick share one scan and
// the 500ms TTL forces a fresh scan each new 750ms tick -> ~one per tick.
expect(powershellScanCount.value).toBeLessThanOrEqual(TICKS + 1)
expect(powershellScanCount.value).toBeLessThan(totalInspections / 2)
// Pre-fix this equals totalInspections (one scan per inspection). With the
// shared cache, concurrent panes within a tick share one scan and the 500ms
// TTL forces a fresh scan each new 750ms tick -> ~one per tick.
expect(scanCount()).toBeLessThanOrEqual(TICKS + 1)
expect(scanCount()).toBeLessThan(totalInspections / 2)
})
it('collapses a burst of concurrent panes into a single scan', async () => {
installCountingPowerShellMock()
await Promise.all(
Array.from({ length: PANE_COUNT }, (_, pane) =>
queryWindowsProcessDescendants(shellPid(pane))
)
)
expect(powershellScanCount.value).toBe(1)
expect(scanCount()).toBe(1)
})
})
@@ -250,9 +250,7 @@ function candidateMatchesContextPath(
if (normalizedContextPaths.length === 0) {
return false
}
const haystack = normalizePathForCommandMatch(
[candidate.command, candidate.executablePath].filter(Boolean).join('\n')
)
const haystack = normalizePathForCommandMatch(candidate.command)
return normalizedContextPaths.some((contextPath) =>
commandLineContainsPath(haystack, contextPath)
)
@@ -1,142 +1,120 @@
// Regression guard: the Windows agent foreground-process scan re-forks
// powershell.exe (or the wmic fallback) on a ~1s/pane cadence. Electron's main
// process has no console, so a spawn without windowsHide pops a fresh conhost
// window per scan that flashes and steals keyboard focus from the foreground app
// (including Orca's own terminal). Both probes MUST pass windowsHide: true.
/**
* The scan that gates PTY teardown used to fork `powershell.exe` (with a `wmic`
* fallback) on a ~1s/pane cadence. Two of the cases this suite used to carry —
* "the powershell probe passes windowsHide" and "the wmic fallback passes
* windowsHide" — are gone because there is no child process to hide any more.
*
* What survives is the contract that does not depend on the mechanism: a
* teardown-time read must be fresh, and a 32-wide worktree delete must still
* collapse into one scan.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() }))
vi.mock('child_process', () => ({ execFile: execFileMock }))
const getAllProcessesMock = vi.fn()
import { __setWindowsProcessTreeLoaderForTests } from '../windows/windows-process-table'
import {
queryWindowsProcessDescendants,
queryWindowsProcessRowsFresh,
resetWindowsProcessRowsSnapshotForTests
} from './windows-foreground-process-rows'
// A real snapshot always contains the process doing the querying; the reader
// rejects a table without it, because that is what a blocked
// CreateToolhelp32Snapshot looks like (an empty list, not an error).
const SELF_PROCESS_ROW = { pid: process.pid, ppid: 0, name: 'vitest.exe', commandLine: 'vitest' }
const withSelf = <T>(rows: readonly T[]): (T | typeof SELF_PROCESS_ROW)[] => [
SELF_PROCESS_ROW,
...rows
]
type ExecFileCallback = (err: unknown, result: { stdout: string; stderr: string }) => void
type ExecFileCall = [string, string[], Record<string, unknown>, ExecFileCallback]
const POWERSHELL_ROWS_JSON = JSON.stringify([
const NATIVE_ROWS = [
{
ProcessId: 100,
ParentProcessId: 50,
Name: 'powershell.exe',
CommandLine: 'powershell.exe',
ExecutablePath: 'C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe'
pid: 100,
ppid: 50,
name: 'powershell.exe',
commandLine: '"C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe" -NoProfile'
},
{
ProcessId: 200,
ParentProcessId: 100,
Name: 'node.exe',
CommandLine: 'node C:/Users/dev/AppData/codex/bin/codex.js',
ExecutablePath: 'C:/Program Files/nodejs/node.exe'
pid: 200,
ppid: 100,
name: 'node.exe',
commandLine: 'node C:/Users/dev/AppData/codex/bin/codex.js'
}
])
]
const WMIC_ROWS_VALUE =
'CommandLine=powershell.exe\n' +
'ExecutablePath=C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe\n' +
'Name=powershell.exe\n' +
'ParentProcessId=50\n' +
'ProcessId=100\n\n' +
'CommandLine=node C:/Users/dev/AppData/codex/bin/codex.js\n' +
'ExecutablePath=C:/Program Files/nodejs/node.exe\n' +
'Name=node.exe\n' +
'ParentProcessId=100\n' +
'ProcessId=200\n'
/** Returns the options object passed to the mocked execFile for a given command. */
function optionsForCommand(command: string): Record<string, unknown> | undefined {
const call = execFileMock.mock.calls.find((args) => (args as ExecFileCall)[0] === command) as
| ExecFileCall
| undefined
return call?.[2]
}
describe('windows foreground process rows spawn options', () => {
describe('windows process rows', () => {
let platform: PropertyDescriptor | undefined
beforeEach(() => {
execFileMock.mockReset()
resetWindowsProcessRowsSnapshotForTests()
getAllProcessesMock.mockReset()
getAllProcessesMock.mockImplementation((cb: (rows: unknown) => void) => {
cb(withSelf(NATIVE_ROWS))
})
platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
__setWindowsProcessTreeLoaderForTests(() => ({
ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 },
getAllProcesses: getAllProcessesMock
}))
})
afterEach(() => {
__setWindowsProcessTreeLoaderForTests()
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
})
it('hides the console window for the powershell process-table scan', async () => {
execFileMock.mockImplementation((_cmd: string, _args, _opts, cb: ExecFileCallback) => {
cb(null, { stdout: POWERSHELL_ROWS_JSON, stderr: '' })
})
const scanCount = (): number => getAllProcessesMock.mock.calls.length
it('walks descendants from the native snapshot', async () => {
const candidates = await queryWindowsProcessDescendants(100)
expect(candidates?.[0]?.pid).toBe(200)
expect(optionsForCommand('powershell.exe')).toMatchObject({ windowsHide: true })
})
it('hides the console window for the wmic fallback scan', async () => {
execFileMock.mockImplementation((cmd: string, _args, _opts, cb: ExecFileCallback) => {
// Force the powershell probe to miss so the wmic fallback runs.
if (cmd === 'powershell.exe') {
cb(new Error('powershell unavailable'), { stdout: '', stderr: '' })
return
}
cb(null, { stdout: WMIC_ROWS_VALUE, stderr: '' })
it('rejects a snapshot that does not contain the querying process', async () => {
// A blocked CreateToolhelp32Snapshot returns an EMPTY list rather than an
// error, and "nothing is running" is a claim teardown acts on. Our own pid
// is unfalsifiably present in any honest snapshot.
getAllProcessesMock.mockImplementation((cb: (rows: unknown) => void) => {
cb([{ pid: 100, ppid: 50, name: 'other.exe', commandLine: 'other' }])
})
const candidates = await queryWindowsProcessDescendants(100)
expect(candidates?.[0]?.pid).toBe(200)
expect(optionsForCommand('wmic')).toMatchObject({ windowsHide: true })
})
})
// Regression guard: the PID-identity probe that gates `taskkill /T /F` needs rows
// from a scan started after it asked, but worktree delete tears down PTYs 32-wide.
// Reading the table uncached would fork 32 powershell cold-starts per delete.
describe('queryWindowsProcessRowsFresh', () => {
let platform: PropertyDescriptor | undefined
beforeEach(() => {
execFileMock.mockReset()
resetWindowsProcessRowsSnapshotForTests()
platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
execFileMock.mockImplementation((_cmd: string, _args, _opts, cb: ExecFileCallback) => {
cb(null, { stdout: POWERSHELL_ROWS_JSON, stderr: '' })
await expect(queryWindowsProcessRowsFresh()).rejects.toThrow(/unreadable/)
})
it('reports an unreadable table as unavailable, not as an empty machine', async () => {
// An empty table is a claim that nothing is running, and callers act on it
// by declaring a tree dead. Unavailable has to stay distinguishable.
getAllProcessesMock.mockImplementation((cb: (rows: unknown) => void) => {
cb(undefined)
})
resetWindowsProcessRowsSnapshotForTests()
await expect(queryWindowsProcessRowsFresh()).rejects.toThrow()
expect(await queryWindowsProcessDescendants(100)).toBeNull()
})
afterEach(() => {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
it('returns null when the root is absent from the snapshot', async () => {
// Only an observed root can authoritatively have no descendants.
expect(await queryWindowsProcessDescendants(999)).toBeNull()
})
const powershellScanCount = (): number =>
execFileMock.mock.calls.filter((call) => call[0] === 'powershell.exe').length
it('collapses a burst of concurrent identity probes into one scan', async () => {
// A worktree delete tears down PTYs 32-wide.
const rows = await Promise.all(Array.from({ length: 32 }, () => queryWindowsProcessRowsFresh()))
expect(powershellScanCount()).toBe(1)
expect(rows[31]?.map((row) => row.pid)).toEqual([100, 200])
expect(scanCount()).toBe(1)
expect(rows[31]?.map((row) => row.pid)).toEqual([process.pid, 100, 200])
})
it('never answers from the TTL cache, which can predate the recycle it detects', async () => {
await queryWindowsProcessDescendants(100)
expect(powershellScanCount()).toBe(1)
expect(scanCount()).toBe(1)
await queryWindowsProcessRowsFresh()
expect(powershellScanCount()).toBe(2)
expect(scanCount()).toBe(2)
})
})
@@ -1,60 +1,39 @@
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { createProcessTableSnapshotReader } from '../../shared/process-table-snapshot'
const execFileAsync = promisify(execFile)
const WINDOWS_PROCESS_QUERY_TIMEOUT_MS = 3_000
// Why: CommandLine can contain CR/LF text. JSON keeps process fields structured
// so an argument cannot masquerade as another `Name=` / `ProcessId=` row.
const POWERSHELL_PROCESS_QUERY =
'[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; ' +
'Get-CimInstance -ClassName Win32_Process ' +
'-Property CommandLine,ExecutablePath,Name,ParentProcessId,ProcessId | ' +
'Select-Object CommandLine,ExecutablePath,Name,ParentProcessId,ProcessId | ' +
'ConvertTo-Json -Compress'
import {
readWindowsProcessTable,
readWindowsProcessTableFresh,
resetWindowsProcessTableForTests,
type WindowsProcessRow as NativeWindowsProcessRow
} from '../windows/windows-process-table'
export type WindowsProcessRow = {
pid: number
ppid: number
name: string
command: string
executablePath: string
}
export type WindowsProcessCandidate = WindowsProcessRow & { depth: number }
// Why: agent foreground inspection forks a whole-process-table PowerShell/CIM
// scan per pane on the same 750ms/2000ms cadence as the POSIX `ps` path. Without
// dedup, K concurrent agent panes fork K powershell.exe cold-starts, each ~10-40x
// heavier than `ps` — the Windows analogue of the idle-CPU churn #6288/#6667 fixed
// for POSIX. Reuse the same TTL + single-in-flight reader, caching parsed rows so
// a burst of panes collapses to ~2 scans/sec; every caller runs its own descendant
// walk over the shared snapshot.
async function runWindowsProcessRows(): Promise<WindowsProcessRow[]> {
const rows =
(await queryWindowsProcessesWithPowerShell()) ?? (await queryWindowsProcessesWithWmic())
if (!rows) {
// Reject so the reader does not cache the miss; callers fall through to
// node-pty's process name (the prior null-return contract is preserved by
// queryWindowsProcessDescendants catching this).
throw new Error('windows process enumeration unavailable')
function toProcessRow(row: NativeWindowsProcessRow): WindowsProcessRow {
return {
pid: row.pid,
ppid: row.ppid,
name: row.name,
// Why fall back to the image name: a process that denied a query handle has
// no command line, and callers match on `command` first.
command: row.command || row.name
}
return rows
}
const windowsProcessRowsReader = createProcessTableSnapshotReader<WindowsProcessRow[]>({
runPs: runWindowsProcessRows,
now: () => Date.now()
})
/**
* Rows from a scan that starts after this call. PID-identity checks in teardown
* must not reuse a cached row — it can predate the very recycle it detects — but
* they must still dedupe: a worktree delete tears down PTYs 32-wide, so a bypass
* would fork that many powershell cold-starts. Rejects when both probes fail.
* Rows from a scan that starts after this call.
*
* PID-identity checks in teardown must not reuse a cached row — it can predate
* the very recycle it is meant to detect. Rejects when the table is unreadable,
* so "unavailable" stays distinguishable from "nothing is running".
*/
export function queryWindowsProcessRowsFresh(): Promise<WindowsProcessRow[]> {
return windowsProcessRowsReader.getFreshSnapshot()
export async function queryWindowsProcessRowsFresh(): Promise<WindowsProcessRow[]> {
return (await readWindowsProcessTableFresh()).map(toProcessRow)
}
export async function queryWindowsProcessDescendants(
@@ -63,10 +42,11 @@ export async function queryWindowsProcessDescendants(
): Promise<WindowsProcessCandidate[] | null> {
let rows: WindowsProcessRow[]
try {
rows =
const native =
options.fresh === true
? await windowsProcessRowsReader.getFreshSnapshot()
: await windowsProcessRowsReader.getSnapshot()
? await readWindowsProcessTableFresh()
: await readWindowsProcessTable()
rows = native.map(toProcessRow)
} catch {
return null
}
@@ -78,122 +58,9 @@ export async function queryWindowsProcessDescendants(
return collectDescendants(rows, rootPid).sort((a, b) => b.depth - a.depth)
}
/**
* Test-only: clear the shared Windows process-table snapshot so suites that mock
* execFile between cases don't get one case's rows served to the next within TTL.
*/
/** Test-only: clear the shared snapshot so one case's rows never serve the next. */
export function resetWindowsProcessRowsSnapshotForTests(): void {
windowsProcessRowsReader.reset()
}
function parseWindowsProcessValueRows(stdout: string): WindowsProcessRow[] {
const rows: WindowsProcessRow[] = []
let command = ''
let executablePath = ''
let name = ''
let pid = Number.NaN
let ppid = Number.NaN
const flush = (): void => {
if (Number.isFinite(pid) && Number.isFinite(ppid)) {
rows.push({ pid, ppid, name, command: command || name, executablePath })
}
command = ''
executablePath = ''
name = ''
pid = Number.NaN
ppid = Number.NaN
}
for (const raw of stdout.split(/\r?\n/)) {
const line = raw.trim()
if (!line) {
flush()
continue
}
const eq = line.indexOf('=')
if (eq === -1) {
continue
}
const key = line.slice(0, eq)
const value = line.slice(eq + 1)
if (key === 'CommandLine') {
command = value
} else if (key === 'ExecutablePath') {
executablePath = value
} else if (key === 'Name') {
name = value
} else if (key === 'ParentProcessId') {
ppid = Number.parseInt(value, 10)
} else if (key === 'ProcessId') {
pid = Number.parseInt(value, 10)
}
}
flush()
return rows
}
type WindowsProcessJsonRow = {
CommandLine?: unknown
ExecutablePath?: unknown
Name?: unknown
ParentProcessId?: unknown
ProcessId?: unknown
}
function parseWindowsProcessJsonRows(stdout: string): WindowsProcessRow[] | null {
const trimmed = stdout.trim()
if (!trimmed) {
return []
}
try {
const parsed = JSON.parse(trimmed) as unknown
const items = Array.isArray(parsed) ? parsed : [parsed]
return items.flatMap((item) => {
if (!item || typeof item !== 'object') {
return []
}
const row = item as WindowsProcessJsonRow
const pid = numberFromWindowsProcessField(row.ProcessId)
const ppid = numberFromWindowsProcessField(row.ParentProcessId)
if (!Number.isFinite(pid) || !Number.isFinite(ppid)) {
return []
}
const name = stringFromWindowsProcessField(row.Name)
const command = stringFromWindowsProcessField(row.CommandLine) || name
return [
{
pid,
ppid,
name,
command,
executablePath: stringFromWindowsProcessField(row.ExecutablePath)
}
]
})
} catch {
return null
}
}
function stringFromWindowsProcessField(value: unknown): string {
if (typeof value === 'string') {
return value
}
if (value === null || value === undefined) {
return ''
}
return String(value)
}
function numberFromWindowsProcessField(value: unknown): number {
if (typeof value === 'number') {
return value
}
if (typeof value === 'string') {
return Number.parseInt(value, 10)
}
return Number.NaN
resetWindowsProcessTableForTests()
}
function collectDescendants<Row extends { pid: number; ppid: number }>(
@@ -218,56 +85,3 @@ function collectDescendants<Row extends { pid: number; ppid: number }>(
}
return descendants
}
/** Runs the PowerShell/CIM whole-process-table scan; returns null when unavailable. */
async function queryWindowsProcessesWithPowerShell(): Promise<WindowsProcessRow[] | null> {
try {
const { stdout } = await execFileAsync(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-Command', POWERSHELL_PROCESS_QUERY],
{
encoding: 'utf8',
timeout: WINDOWS_PROCESS_QUERY_TIMEOUT_MS,
maxBuffer: 8 * 1024 * 1024,
// Why: this scan re-forks on a ~1s/pane cadence. Electron's main has no
// console, so without windowsHide each fork pops a fresh conhost window
// that flashes and steals keyboard focus from the foreground app
// (including Orca's own terminal).
windowsHide: true
}
)
const rows = parseWindowsProcessJsonRows(stdout)
return rows && rows.length > 0 ? rows : null
} catch {
return null
}
}
/** Fallback whole-process-table scan via wmic when PowerShell is unavailable. */
async function queryWindowsProcessesWithWmic(): Promise<WindowsProcessRow[] | null> {
try {
const { stdout } = await execFileAsync(
'wmic',
[
'process',
'get',
'CommandLine,ExecutablePath,Name,ParentProcessId,ProcessId',
'/format:value'
],
{
encoding: 'utf8',
timeout: WINDOWS_PROCESS_QUERY_TIMEOUT_MS,
maxBuffer: 8 * 1024 * 1024,
// Why: same focus-stealing hazard as the powershell probe — hide the
// wmic fallback's console window too.
windowsHide: true
}
)
const rows = parseWindowsProcessValueRows(stdout)
return rows.length > 0 ? rows : null
} catch {
// Best-effort: Windows process enumeration may be disabled, so callers
// still fall back to node-pty's process name when both probes fail.
return null
}
}
@@ -393,6 +393,50 @@ describe('killWithDescendantSweep', () => {
expect(sendSignal).not.toHaveBeenCalled()
})
it('on Windows terminates the owning job instead of probing and taskkilling', async () => {
// The job names the tree Orca created, so there is nothing to prove: no
// process-table scrape, no parent-pid walk, no pid-recycle guess.
const events: string[] = []
const terminateOwnedTree = vi.fn(() => {
events.push('job-kill')
return 'terminated' as const
})
const killWindowsTree = vi.fn(async () => {})
const verifyTreeKillTarget = vi.fn(async () => 'own' as const)
const killRoot = vi.fn(() => events.push('root-kill'))
await killWithDescendantSweep(4242, killRoot, {
platform: 'win32',
terminateOwnedTree,
killWindowsTree,
verifyTreeKillTarget
})
expect(terminateOwnedTree).toHaveBeenCalledOnce()
expect(verifyTreeKillTarget).not.toHaveBeenCalled()
expect(killWindowsTree).not.toHaveBeenCalled()
// killRoot still runs: the job kills processes, the handle still needs closing.
expect(events).toEqual(['job-kill', 'root-kill'])
})
it('on Windows falls back to the probe when the pty has no job', async () => {
// A pty started before this build, or one the OS refused to assign, has no
// job. `unavailable` must not be read as "already dead".
const terminateOwnedTree = vi.fn(() => 'unavailable' as const)
const killWindowsTree = vi.fn(async () => {})
const killRoot = vi.fn()
await killWithDescendantSweep(4242, killRoot, {
platform: 'win32',
terminateOwnedTree,
killWindowsTree,
verifyTreeKillTarget: async () => 'own'
})
expect(killWindowsTree).toHaveBeenCalledWith(4242)
expect(killRoot).toHaveBeenCalledOnce()
})
it('on Windows taskkills the process tree before killRoot (#10004)', async () => {
const events: string[] = []
const killWindowsTree = vi.fn(async () => {
+17 -3
View File
@@ -1,4 +1,5 @@
import { execFile } from 'node:child_process'
import type { JobTerminationOutcome } from './windows/windows-pty-job'
import { terminateWindowsProcessTree, type WindowsTreeKiller } from './windows-process-tree-kill'
import {
verifyWindowsTreeKillTarget,
@@ -228,6 +229,11 @@ export async function captureDescendantSnapshot(
type KillSweepDeps = SnapshotDeps &
TerminateDeps & {
ownsRoot?: () => boolean
/**
* Terminate the PTY's job object. Returns `unavailable` when this tree has
* no job, which is not permission to assume it is gone.
*/
terminateOwnedTree?: () => JobTerminationOutcome
/** Injectable Windows tree killer (defaults to taskkill /T /F). */
killWindowsTree?: WindowsTreeKiller
/** Injectable Windows root-identity probe (defaults to a live process query). */
@@ -237,9 +243,11 @@ type KillSweepDeps = SnapshotDeps &
/**
* Standard agent-session kill sequencing.
* - POSIX: snapshot the descendant tree, signal members, then killRoot.
* - Windows: taskkill /T /F walks the ConPTY tree only when the identity probe
* returns `own` (and ownsRoot still holds). `unknown`/`foreign`/`absent` skip
* tree kill; killRoot always runs. Detached children may survive probe failure.
* - Windows: terminate the PTY's job object, which is exact and needs no
* identity probe. Only when this build has no job does it fall back to the
* old scheme — a process-table scrape gating `taskkill /T /F` on a
* parent-pid walk, which refuses whenever it cannot prove ownership and so
* leaves the tree running (#9045, #10475).
* Callers must not signal the root before this runs on POSIX — a dead root's
* descendants reparent to pid 1 and become unfindable. Snapshot failure
* degrades to killRoot alone on POSIX.
@@ -253,6 +261,12 @@ export async function killWithDescendantSweep(
if (platform === 'win32') {
try {
if ((deps.ownsRoot?.() ?? true) && Number.isInteger(rootPid) && rootPid > 0) {
// Why first: the job names the tree Orca created, so it is immune to the
// pid recycling the probe below exists to guard against, and it reaches
// descendants that reparented away from the shell.
if (deps.terminateOwnedTree?.() === 'terminated') {
return
}
// Why: ownsRoot() is JS state only, and node-pty's ConPTY exit watcher closes
// the last shell handle before it queues the JS exit callback — Windows may
// already have recycled this PID while the map still looks live. taskkill /T /F
+73 -75
View File
@@ -1,15 +1,23 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { execFileMock, execFileSyncMock } = vi.hoisted(() => ({
execFileMock: vi.fn(),
execFileSyncMock: vi.fn()
const { runProcessMock, runProcessSyncMock } = vi.hoisted(() => ({
runProcessMock: vi.fn(),
runProcessSyncMock: vi.fn()
}))
vi.mock('child_process', () => ({
execFile: execFileMock,
execFileSync: execFileSyncMock
// Why mock the chokepoint: the two probe shapes this module used to reconcile
// by hand -- execFileSync's ETIMEDOUT versus execFile's SIGTERM-with-no-code --
// are now one `timedOut` flag, so the suite states the outcome rather than the
// spawn mechanism that produced it.
vi.mock('../shared/child-process/run-process', () => ({
runProcess: runProcessMock,
runProcessSync: runProcessSyncMock
}))
const ok = { code: 0, signal: null, stdout: '7.4.0', stderr: '', timedOut: false }
const missing = { code: 1, signal: null, stdout: '', stderr: 'not found', timedOut: false }
const timedOut = { code: null, signal: 'SIGTERM' as const, stdout: '', stderr: '', timedOut: true }
function setPlatform(platform: NodeJS.Platform): () => void {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', {
@@ -29,8 +37,8 @@ describe('isPwshAvailable', () => {
beforeEach(() => {
vi.resetModules()
vi.useRealTimers()
execFileMock.mockReset()
execFileSyncMock.mockReset()
runProcessMock.mockReset()
runProcessSyncMock.mockReset()
})
it('returns false on non-Windows platforms', async () => {
@@ -39,7 +47,7 @@ describe('isPwshAvailable', () => {
try {
const { isPwshAvailable } = await import('./pwsh')
expect(isPwshAvailable()).toBe(false)
expect(execFileSyncMock).not.toHaveBeenCalled()
expect(runProcessSyncMock).not.toHaveBeenCalled()
} finally {
restorePlatform()
}
@@ -47,14 +55,15 @@ describe('isPwshAvailable', () => {
it('returns true when pwsh.exe is available on Windows', async () => {
const restorePlatform = setPlatform('win32')
execFileSyncMock.mockReturnValue('PowerShell 7.5.0')
runProcessSyncMock.mockReturnValue(ok)
try {
const { isPwshAvailable } = await import('./pwsh')
expect(isPwshAvailable()).toBe(true)
expect(execFileSyncMock).toHaveBeenCalledWith('pwsh.exe', ['-Version'], {
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 5000
expect(runProcessSyncMock).toHaveBeenCalledWith({
program: 'pwsh.exe',
args: ['-Version'],
timeoutMs: 5000
})
} finally {
restorePlatform()
@@ -63,7 +72,7 @@ describe('isPwshAvailable', () => {
it('returns false when pwsh.exe probe throws on Windows', async () => {
const restorePlatform = setPlatform('win32')
execFileSyncMock.mockImplementation(() => {
runProcessSyncMock.mockImplementation(() => {
throw new Error('missing pwsh')
})
@@ -77,13 +86,13 @@ describe('isPwshAvailable', () => {
it('reuses the cached result across repeated calls', async () => {
const restorePlatform = setPlatform('win32')
execFileSyncMock.mockReturnValue('PowerShell 7.5.0')
runProcessSyncMock.mockReturnValue(ok)
try {
const { isPwshAvailable } = await import('./pwsh')
expect(isPwshAvailable()).toBe(true)
expect(isPwshAvailable()).toBe(true)
expect(execFileSyncMock).toHaveBeenCalledTimes(1)
expect(runProcessSyncMock).toHaveBeenCalledTimes(1)
} finally {
restorePlatform()
}
@@ -91,20 +100,13 @@ describe('isPwshAvailable', () => {
it('repro: does not keep a cold-start timeout cached for the daemon lifetime', async () => {
const restorePlatform = setPlatform('win32')
execFileSyncMock
.mockImplementationOnce(() => {
const error = Object.assign(new Error('spawnSync pwsh.exe ETIMEDOUT'), {
code: 'ETIMEDOUT'
})
throw error
})
.mockReturnValue('PowerShell 7.5.0')
runProcessSyncMock.mockReturnValueOnce(timedOut).mockReturnValue(ok)
try {
const { isPwshAvailable } = await import('./pwsh')
expect(isPwshAvailable()).toBe(false)
expect(isPwshAvailable()).toBe(true)
expect(execFileSyncMock).toHaveBeenCalledTimes(2)
expect(runProcessSyncMock).toHaveBeenCalledTimes(2)
} finally {
restorePlatform()
}
@@ -112,21 +114,18 @@ describe('isPwshAvailable', () => {
it('warms pwsh availability asynchronously with a longer timeout', async () => {
const restorePlatform = setPlatform('win32')
execFileMock.mockImplementation((_file, _args, _options, callback) => {
callback(null, 'PowerShell 7.5.0', '')
})
runProcessMock.mockResolvedValue(ok)
try {
const { isPwshAvailable, warmPwshAvailabilityCache } = await import('./pwsh')
await expect(warmPwshAvailabilityCache()).resolves.toBe(true)
expect(execFileMock).toHaveBeenCalledWith(
'pwsh.exe',
['-Version'],
{ timeout: 30_000, windowsHide: true },
expect.any(Function)
)
expect(runProcessMock).toHaveBeenCalledWith({
program: 'pwsh.exe',
args: ['-Version'],
timeoutMs: 30_000
})
expect(isPwshAvailable()).toBe(true)
expect(execFileSyncMock).not.toHaveBeenCalled()
expect(runProcessSyncMock).not.toHaveBeenCalled()
} finally {
restorePlatform()
}
@@ -136,23 +135,22 @@ describe('isPwshAvailable', () => {
// blocks the Electron main thread for the full timeout when pwsh.exe cold-starts.
it('answers IPC callers without blocking the main thread', async () => {
const restorePlatform = setPlatform('win32')
execFileMock.mockImplementation((_file, _args, _options, callback) => {
setTimeout(() => callback(null, 'PowerShell 7.5.0', ''), 0)
})
runProcessMock.mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve(ok), 0))
)
try {
const { isPwshAvailableAsync } = await import('./pwsh')
const results = await Promise.all([isPwshAvailableAsync(), isPwshAvailableAsync()])
expect(results).toEqual([true, true])
// Concurrent readers share one spawn.
expect(execFileMock).toHaveBeenCalledTimes(1)
expect(execFileMock).toHaveBeenCalledWith(
'pwsh.exe',
['-Version'],
{ timeout: 5000, windowsHide: true },
expect.any(Function)
)
expect(execFileSyncMock).not.toHaveBeenCalled()
expect(runProcessMock).toHaveBeenCalledTimes(1)
expect(runProcessMock).toHaveBeenCalledWith({
program: 'pwsh.exe',
args: ['-Version'],
timeoutMs: 5000
})
expect(runProcessSyncMock).not.toHaveBeenCalled()
} finally {
restorePlatform()
}
@@ -162,15 +160,13 @@ describe('isPwshAvailable', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
const restorePlatform = setPlatform('win32')
execFileMock.mockImplementation((_file, _args, _options, callback) => {
callback(new Error('missing pwsh'), '', '')
})
runProcessMock.mockResolvedValue(missing)
try {
const { isPwshAvailableAsync } = await import('./pwsh')
await expect(isPwshAvailableAsync()).resolves.toBe(false)
await expect(isPwshAvailableAsync()).resolves.toBe(false)
expect(execFileMock).toHaveBeenCalledTimes(1)
expect(runProcessMock).toHaveBeenCalledTimes(1)
} finally {
restorePlatform()
vi.useRealTimers()
@@ -181,14 +177,17 @@ describe('isPwshAvailable', () => {
const restorePlatform = setPlatform('win32')
let finishAsyncProbe!: (error: Error | null) => void
let finishWarmup!: (error: Error | null) => void
execFileMock.mockImplementation((_file, _args, options, callback) => {
const finish = (error: Error | null): void => callback(error, '', '')
if (options.timeout === 30_000) {
finishWarmup = finish
} else {
finishAsyncProbe = finish
}
})
runProcessMock.mockImplementation(
(spec: { timeoutMs: number }) =>
new Promise((resolve) => {
const finish = (error: Error | null): void => resolve(error ? missing : ok)
if (spec.timeoutMs === 30_000) {
finishWarmup = finish
} else {
finishAsyncProbe = finish
}
})
)
try {
const { isPwshAvailable, isPwshAvailableAsync, warmPwshAvailabilityCache } =
@@ -202,7 +201,7 @@ describe('isPwshAvailable', () => {
await expect(staleProbe).resolves.toBe(true)
expect(isPwshAvailable()).toBe(true)
expect(execFileSyncMock).not.toHaveBeenCalled()
expect(runProcessSyncMock).not.toHaveBeenCalled()
} finally {
restorePlatform()
}
@@ -211,16 +210,19 @@ describe('isPwshAvailable', () => {
it('does not duplicate an in-flight async probe for a synchronous caller', async () => {
const restorePlatform = setPlatform('win32')
let finishAsyncProbe!: (error: Error | null) => void
execFileMock.mockImplementation((_file, _args, _options, callback) => {
finishAsyncProbe = (error) => callback(error, '', '')
})
runProcessMock.mockImplementation(
() =>
new Promise((resolve) => {
finishAsyncProbe = (error) => resolve(error ? missing : ok)
})
)
try {
const { isPwshAvailable, isPwshAvailableAsync } = await import('./pwsh')
const probe = isPwshAvailableAsync()
expect(isPwshAvailable()).toBe(true)
expect(execFileSyncMock).not.toHaveBeenCalled()
expect(runProcessSyncMock).not.toHaveBeenCalled()
finishAsyncProbe(null)
await expect(probe).resolves.toBe(true)
@@ -229,14 +231,14 @@ describe('isPwshAvailable', () => {
}
})
// Why: execFile reports a timeout as a SIGTERM kill, not ETIMEDOUT, so caching it as a
// failure would disable the user's PowerShell 7 preference for 30s on every slow cold start.
// Why: caching a slow cold start as a failure would disable the user's
// PowerShell 7 preference for 30s every time .NET is slow to start. The two
// probe paths used to report a timeout differently and had to be reconciled
// by hand; `timedOut` is now the single shape.
it('does not cache a cold-start timeout from the async probe', async () => {
const restorePlatform = setPlatform('win32')
execFileMock.mockImplementation((_file, _args, _options, callback) => {
callback(Object.assign(new Error('pwsh.exe timed out'), { killed: true, signal: 'SIGTERM' }))
})
execFileSyncMock.mockReturnValue('PowerShell 7.5.0')
runProcessMock.mockResolvedValue(timedOut)
runProcessSyncMock.mockReturnValue(ok)
try {
const { isPwshAvailable, isPwshAvailableAsync } = await import('./pwsh')
@@ -251,11 +253,7 @@ describe('isPwshAvailable', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
const restorePlatform = setPlatform('win32')
execFileSyncMock
.mockImplementationOnce(() => {
throw new Error('missing pwsh')
})
.mockReturnValue('PowerShell 7.5.0')
runProcessSyncMock.mockReturnValueOnce(missing).mockReturnValue(ok)
try {
const { isPwshAvailable } = await import('./pwsh')
@@ -263,7 +261,7 @@ describe('isPwshAvailable', () => {
expect(isPwshAvailable()).toBe(false)
vi.setSystemTime(31_001)
expect(isPwshAvailable()).toBe(true)
expect(execFileSyncMock).toHaveBeenCalledTimes(2)
expect(runProcessSyncMock).toHaveBeenCalledTimes(2)
} finally {
restorePlatform()
vi.useRealTimers()
+47 -50
View File
@@ -1,4 +1,4 @@
import { execFile, execFileSync } from 'node:child_process'
import { runProcess, runProcessSync } from '../shared/child-process/run-process'
const PWSH_SYNC_PROBE_TIMEOUT_MS = 5000
const PWSH_WARMUP_PROBE_TIMEOUT_MS = 30_000
@@ -19,29 +19,18 @@ function isCacheFresh(cache: PwshAvailabilityCache): boolean {
)
}
// Why: execFileSync reports a timeout as ETIMEDOUT, but the execFile callback reports it as a
// SIGTERM kill with no code — both shapes must be recognised or the async probe caches a
// cold-start timeout as "pwsh missing".
function isTimeoutError(error: unknown): boolean {
if (typeof error !== 'object' || error === null) {
return false
}
const failure = error as { code?: unknown; killed?: unknown; signal?: unknown }
return failure.code === 'ETIMEDOUT' || (failure.killed === true && failure.signal === 'SIGTERM')
}
function writePwshAvailabilityCache(cache: PwshAvailabilityCache | null): void {
pwshAvailableCache = cache
pwshAvailabilityCacheGeneration += 1
}
function cachePwshProbeFailure(error: unknown, startedAtGeneration: number): boolean {
function cachePwshProbeFailure(timedOut: boolean, startedAtGeneration: number): boolean {
if (startedAtGeneration !== pwshAvailabilityCacheGeneration) {
return pwshAvailableCache?.available ?? false
}
// Why: pwsh.exe cold starts can exceed the sync timeout; do not let one slow
// .NET startup disable the user's PowerShell 7 preference for the daemon.
if (isTimeoutError(error)) {
if (timedOut) {
writePwshAvailabilityCache(null)
return false
}
@@ -71,15 +60,21 @@ export function isPwshAvailable(): boolean {
}
const startedAtGeneration = pwshAvailabilityCacheGeneration
let probe
try {
execFileSync('pwsh.exe', ['-Version'], {
stdio: ['pipe', 'pipe', 'pipe'],
timeout: PWSH_SYNC_PROBE_TIMEOUT_MS
probe = runProcessSync({
program: 'pwsh.exe',
args: ['-Version'],
timeoutMs: PWSH_SYNC_PROBE_TIMEOUT_MS
})
writePwshAvailabilityCache({ available: true })
} catch (error) {
return cachePwshProbeFailure(error, startedAtGeneration)
} catch {
// pwsh.exe is not on PATH at all.
return cachePwshProbeFailure(false, startedAtGeneration)
}
if (probe.timedOut || probe.code !== 0) {
return cachePwshProbeFailure(probe.timedOut, startedAtGeneration)
}
writePwshAvailabilityCache({ available: true })
return pwshAvailableCache?.available ?? false
}
@@ -105,22 +100,23 @@ export function isPwshAvailableAsync(): Promise<boolean> {
}
const startedAtGeneration = pwshAvailabilityCacheGeneration
pwshProbeInFlight = new Promise((resolve) => {
execFile(
'pwsh.exe',
['-Version'],
{ timeout: PWSH_SYNC_PROBE_TIMEOUT_MS, windowsHide: true },
(error) => {
pwshProbeInFlight = null
if (error) {
resolve(cachePwshProbeFailure(error, startedAtGeneration))
} else {
writePwshAvailabilityCache({ available: true })
resolve(true)
}
}
)
pwshProbeInFlight = runProcess({
program: 'pwsh.exe',
args: ['-Version'],
timeoutMs: PWSH_SYNC_PROBE_TIMEOUT_MS
})
.then((probe) => {
pwshProbeInFlight = null
if (probe.timedOut || probe.code !== 0) {
return cachePwshProbeFailure(probe.timedOut, startedAtGeneration)
}
writePwshAvailabilityCache({ available: true })
return true
})
.catch(() => {
pwshProbeInFlight = null
return cachePwshProbeFailure(false, startedAtGeneration)
})
return pwshProbeInFlight
}
@@ -137,21 +133,22 @@ export function warmPwshAvailabilityCache(): Promise<boolean> {
}
const startedAtGeneration = pwshAvailabilityCacheGeneration
pwshWarmupInFlight = new Promise((resolve) => {
execFile(
'pwsh.exe',
['-Version'],
{ timeout: PWSH_WARMUP_PROBE_TIMEOUT_MS, windowsHide: true },
(error) => {
pwshWarmupInFlight = null
if (!error) {
writePwshAvailabilityCache({ available: true })
resolve(true)
return
}
resolve(cachePwshProbeFailure(error, startedAtGeneration))
}
)
pwshWarmupInFlight = runProcess({
program: 'pwsh.exe',
args: ['-Version'],
timeoutMs: PWSH_WARMUP_PROBE_TIMEOUT_MS
})
.then((probe) => {
pwshWarmupInFlight = null
if (probe.timedOut || probe.code !== 0) {
return cachePwshProbeFailure(probe.timedOut, startedAtGeneration)
}
writePwshAvailabilityCache({ available: true })
return true
})
.catch(() => {
pwshWarmupInFlight = null
return cachePwshProbeFailure(false, startedAtGeneration)
})
return pwshWarmupInFlight
}
@@ -0,0 +1,137 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as Win32Utils from '../win32-utils'
const { getSpawnArgsForWindowsMock, ptySpawnMock, resolveCodexCommandMock } = vi.hoisted(() => ({
getSpawnArgsForWindowsMock: vi.fn(),
ptySpawnMock: vi.fn(),
resolveCodexCommandMock: vi.fn()
}))
vi.mock('../win32-utils', async (importOriginal) => ({
...(await importOriginal<typeof Win32Utils>()),
getSpawnArgsForWindows: getSpawnArgsForWindowsMock
}))
vi.mock('../codex-cli/command', () => ({
resolveCodexCommand: resolveCodexCommandMock
}))
vi.mock('node-pty', () => ({
spawn: ptySpawnMock
}))
vi.mock('./codex-auth-presence', () => ({
probeCodexAuthPresence: vi.fn(() => 'present')
}))
vi.mock('../codex/codex-state-db', () => ({
isCodexStateDbBackfillPending: vi.fn(() => false)
}))
vi.mock('../codex/codex-state-db-backfill-recovery', () => ({
startCodexStateDbBackfillRecoveryInBackground: vi.fn(() => Promise.resolve(null))
}))
import { fetchCodexRateLimits } from './codex-fetcher'
const STUB_CODEX_SOURCE = `
const expectedArgs = [
'-c',
'approval_policy=never',
'-s',
'read-only',
'-a',
'never',
'app-server'
]
if (JSON.stringify(process.argv.slice(2)) !== JSON.stringify(expectedArgs)) {
process.stderr.write(
"error: invalid value 'untrusted' for '--ask-for-approval <APPROVAL_POLICY>'\\n" +
' [possible values: on-request, never]\\n'
)
process.exit(2)
}
if (process.env.CODEX_HOME !== process.env.ORCA_EXPECTED_CODEX_HOME) {
process.stderr.write('managed CODEX_HOME was not preserved\\n')
process.exit(3)
}
let buffer = ''
function send(message) {
process.stdout.write(JSON.stringify(message) + '\\n')
}
process.stdin.setEncoding('utf8')
process.stdin.on('data', (chunk) => {
buffer += chunk
let newlineIndex
while ((newlineIndex = buffer.indexOf('\\n')) !== -1) {
const line = buffer.slice(0, newlineIndex).trim()
buffer = buffer.slice(newlineIndex + 1)
if (!line) continue
const message = JSON.parse(line)
if (message.method === 'initialize') {
send({ id: message.id, result: { userAgent: 'codex-contract/0.149.0' } })
continue
}
if (message.method === 'account/rateLimits/read') {
send({
id: message.id,
result: {
rateLimits: {
primary: { usedPercent: 17, windowDurationMins: 300 },
secondary: { usedPercent: 29, windowDurationMins: 10080 }
}
}
})
}
}
})
process.stdin.on('end', () => process.exit(0))
`
let tempRoot: string
let stubPath: string
let previousExpectedHome: string | undefined
describe('Codex rate-limit process contract', () => {
beforeEach(() => {
tempRoot = mkdtempSync(join(tmpdir(), 'orca-codex-rate-limit-contract-'))
stubPath = join(tempRoot, 'codex-contract.cjs')
writeFileSync(stubPath, STUB_CODEX_SOURCE)
previousExpectedHome = process.env.ORCA_EXPECTED_CODEX_HOME
process.env.ORCA_EXPECTED_CODEX_HOME = join(tempRoot, 'managed-codex-home')
resolveCodexCommandMock.mockReturnValue('codex')
getSpawnArgsForWindowsMock.mockImplementation((_command: string, args: string[]) => ({
spawnCmd: process.execPath,
spawnArgs: [stubPath, ...args]
}))
})
afterEach(() => {
if (previousExpectedHome === undefined) {
delete process.env.ORCA_EXPECTED_CODEX_HOME
} else {
process.env.ORCA_EXPECTED_CODEX_HOME = previousExpectedHome
}
rmSync(tempRoot, { recursive: true, force: true })
vi.clearAllMocks()
})
it('starts a read-only non-interactive app-server with the managed home', async () => {
await expect(
fetchCodexRateLimits({
codexHomePath: process.env.ORCA_EXPECTED_CODEX_HOME,
allowPtyFallback: false
})
).resolves.toMatchObject({
provider: 'codex',
session: { usedPercent: 17, windowMinutes: 300 },
weekly: { usedPercent: 29, windowMinutes: 10080 },
status: 'ok',
error: null
})
expect(ptySpawnMock).not.toHaveBeenCalled()
})
})
+7 -3
View File
@@ -51,6 +51,8 @@ vi.mock('./codex-auth-presence', () => ({
import { fetchCodexRateLimits } from './codex-fetcher'
import { probeCodexAuthPresence } from './codex-auth-presence'
import { getActiveHiddenRateLimitPtyCount } from './hidden-pty-cleanup'
import { getCmdExePath } from '../win32-utils'
import { CODEX_READ_ONLY_APP_SERVER_ARGS } from '../codex-cli/codex-read-only-app-server-args'
function makeDisposable() {
return { dispose: vi.fn() }
@@ -722,7 +724,7 @@ describe('fetchCodexRateLimits', () => {
"export CODEX_HOME='\\''/home/alice/.local/share/orca/account/home'\\''"
)
expect(shellCommand).toContain(
"exec codex '\\''-s'\\'' '\\''read-only'\\'' '\\''-a'\\'' '\\''untrusted'\\'' '\\''app-server'\\'' <&3 >&4 3<&- 4>&-"
"exec codex '\\''-c'\\'' '\\''approval_policy=never'\\'' '\\''-s'\\'' '\\''read-only'\\'' '\\''-a'\\'' '\\''never'\\'' '\\''app-server'\\'' <&3 >&4 3<&- 4>&-"
)
expect(shellCommand.match(/<&3 >&4 3<&- 4>&-/g)).toHaveLength(3)
expect(shellCommand.match(/exec codex [^\n]+<&3 >&4 3<&- 4>&-/g)).toHaveLength(3)
@@ -748,6 +750,8 @@ describe('fetchCodexRateLimits', () => {
configurable: true,
value: 'win32'
})
const codexCommand = 'C:\\Users\\alice\\AppData\\Roaming\\npm\\codex.cmd'
resolveCodexCommandMock.mockReturnValue(codexCommand)
const rpcChild = makeRpcChild()
childSpawnMock.mockReturnValue(rpcChild)
rpcChild.stdin.write.mockImplementation((line: string) => {
@@ -783,8 +787,8 @@ describe('fetchCodexRateLimits', () => {
await resultPromise
const [spawnFile, spawnArgs, spawnOptions] = childSpawnMock.mock.calls[0]
expect(spawnFile).toBe('codex')
expect(spawnArgs).toEqual(['-s', 'read-only', '-a', 'untrusted', 'app-server'])
expect(spawnFile).toBe(getCmdExePath())
expect(spawnArgs).toEqual(['/d', '/c', codexCommand, ...CODEX_READ_ONLY_APP_SERVER_ARGS])
expect(spawnOptions).toEqual(
expect.objectContaining({
env: expect.objectContaining({ CODEX_HOME: 'C:\\Users\\alice\\.codex' })
+3 -2
View File
@@ -19,6 +19,7 @@ import {
type CodexRateWindowSnapshot
} from './codex-rate-limit-window-classification'
import { resolveCodexCommand } from '../codex-cli/command'
import { CODEX_READ_ONLY_APP_SERVER_ARGS } from '../codex-cli/codex-read-only-app-server-args'
import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic'
import { getCmdExePath, getSpawnArgsForWindows } from '../win32-utils'
import { cleanupHiddenRateLimitPty, registerHiddenRateLimitPty } from './hidden-pty-cleanup'
@@ -611,7 +612,7 @@ async function withBackendSessionWindow(
}
// ---------------------------------------------------------------------------
// RPC fetch — spawn `codex -s read-only -a untrusted app-server`
// RPC fetch — spawn a read-only, non-interactive `codex app-server`
// ---------------------------------------------------------------------------
async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise<ProviderRateLimits> {
@@ -624,7 +625,7 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise<Provi
let resolved = false
let rpcId = 0
const codexArgs = ['-s', 'read-only', '-a', 'untrusted', 'app-server']
const codexArgs = [...CODEX_READ_ONLY_APP_SERVER_ARGS]
const wslCodex = options?.codexHomePath
? buildWslCodexCommand(options.codexHomePath, codexArgs, { isolateRpcStdio: true })
: null
@@ -0,0 +1,64 @@
/** A host-initiated create has no renderer session writer, so only its own
* durable binding and runtime ownership keep graph sync from pruning the tab
* out from under a live agent — including when a window is attached. */
import { describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeService } from './orca-runtime'
vi.mock('electron', () => ({
BrowserWindow: { fromId: vi.fn(() => ({ isDestroyed: () => false })) },
webContents: { fromId: vi.fn(() => null) },
ipcMain: { on: vi.fn(), removeListener: vi.fn() },
app: { getPath: vi.fn(() => '/tmp') }
}))
function createRuntimeWithAttachedWindow(): {
runtime: OrcaRuntimeService
spawn: ReturnType<typeof vi.fn>
} {
const runtime = new OrcaRuntimeService()
vi.spyOn(
runtime as unknown as {
resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise<unknown>
},
'resolveTerminalWorkspaceLaunchScope'
).mockResolvedValue({
id: 'repo-1::/tmp/wt-cli',
path: '/tmp/wt-cli',
connectionId: null,
repo: null,
folderWorkspace: null
})
const spawn = vi.fn().mockResolvedValue({ id: 'repo-1::/tmp/wt-cli@@a1b2c3d4' })
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
} as never)
runtime.attachWindow(1)
return { runtime, spawn }
}
describe('host-initiated terminal creation under an attached window', () => {
it('persists the host session binding at spawn', async () => {
const { runtime, spawn } = createRuntimeWithAttachedWindow()
await runtime.createTerminal('id:repo-1::/tmp/wt-cli', {})
expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ persistHostSessionBinding: true }))
})
it('marks the spawned PTY runtime-session-owned', async () => {
const { runtime } = createRuntimeWithAttachedWindow()
await runtime.createTerminal('id:repo-1::/tmp/wt-cli', {})
const pty = (
runtime as unknown as {
ptysById: Map<string, { runtimeSessionOwned: boolean; connected: boolean }>
}
).ptysById.get('repo-1::/tmp/wt-cli@@a1b2c3d4')
expect(pty?.connected).toBe(true)
expect(pty?.runtimeSessionOwned).toBe(true)
})
})
@@ -0,0 +1,363 @@
/**
* shouldPreserveHeadlessMobileSessionTab excludes the daemon ptyId form
* <worktreeId>@@<uuid> from its runtime-owned checks, so a host-created terminal
* survives renderer omission only through the binding createTerminal persists;
* a renderer de-persist (a user close) releases it again.
*
* These assert only on a PTY the runtime records as connected or exited —
* never on "unverifiable".
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getDefaultWorkspaceSession } from '../../shared/constants'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import type {
RuntimeMobileSessionTabsRemovedResult,
RuntimeMobileSessionTabsResult
} from '../../shared/runtime-types'
import { OrcaRuntimeService } from './orca-runtime'
vi.mock('electron', () => ({
BrowserWindow: { fromId: vi.fn(() => ({ isDestroyed: () => false })) },
webContents: { fromId: vi.fn(() => null) },
ipcMain: { on: vi.fn(), removeListener: vi.fn() },
app: { getPath: vi.fn(() => '/tmp') }
}))
const REPO_ID = 'repo-1'
const WT_CLI = `${REPO_ID}::/tmp/wt-cli-agent`
const WT_OTHER = `${REPO_ID}::/tmp/wt-other`
// Daemon session id form: <worktreeId>@@<shortUuid>. NOT serve-/ssh-shaped.
const CLI_PTY = `${WT_CLI}@@a1b2c3d4`
const OTHER_PTY = `${WT_OTHER}@@e5f6a7b8`
const PTY_ID_BY_WORKTREE: Record<string, string> = { [WT_CLI]: CLI_PTY, [WT_OTHER]: OTHER_PTY }
type RuntimeInternals = {
ptysById: Map<string, { connected: boolean; runtimeSessionOwned: boolean }>
mobileSessionTabsByWorktree: Map<string, unknown>
}
function makeRepo() {
return {
id: REPO_ID,
path: '/tmp/repo',
displayName: 'repo',
badgeColor: '#000000',
addedAt: 1
}
}
function createHarness() {
// Starts empty: only the create path may put this terminal in the session.
let session: WorkspaceSessionState = { ...getDefaultWorkspaceSession() }
const repo = makeRepo()
const store = {
getRepos: () => [repo],
getRepo: (id: string) => (id === REPO_ID ? repo : undefined),
getAllWorktreeMeta: () => ({}),
getWorktreeMeta: () => undefined,
getSettings: () => ({ workspaceDir: '/tmp/workspaces' }),
getProjects: () => [],
getWorkspaceSession: () => session,
setWorkspaceSession: (next: WorkspaceSessionState) => {
session = next
},
flushOrThrow: () => {}
}
const runtime = new OrcaRuntimeService(store as never)
runtime.setNotifier({
closeTerminal: vi.fn(),
closeTerminalTab: vi.fn(),
revealTerminalSession: vi.fn(async () => null),
createTerminal: vi.fn(),
activateWorktree: vi.fn(),
focusTerminal: vi.fn(),
terminalDriverChanged: vi.fn()
} as never)
vi.spyOn(
runtime as unknown as {
resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise<unknown>
},
'resolveTerminalWorkspaceLaunchScope'
).mockImplementation(async (selector: string) => {
const worktreeId = selector.replace(/^id:/, '')
return {
id: worktreeId,
path: worktreeId.split('::')[1],
connectionId: null,
repo: null,
folderWorkspace: null
}
})
/** Stands in for LoadingStore.persistPtyBinding: the minimal tab + layout the
* store writes when a spawn carries persistHostSessionBinding. */
const persistPtyBinding = (
worktreeId: string,
tabId: string,
leafId: string,
ptyId: string
): void => {
session = {
...session,
tabsByWorktree: {
...session.tabsByWorktree,
[worktreeId]: [
...(session.tabsByWorktree?.[worktreeId] ?? []),
{
id: tabId,
ptyId,
worktreeId,
title: 'codex',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
},
terminalLayoutsByTabId: {
...session.terminalLayoutsByTabId,
[tabId]: {
root: { type: 'leaf', leafId },
activeLeafId: leafId,
expandedLeafId: null,
ptyIdsByLeafId: { [leafId]: ptyId }
}
}
}
}
const spawnedTabIdByWorktree = new Map<string, string>()
const spawn = vi.fn(
async (args: {
worktreeId: string
tabId: string
leafId: string
persistHostSessionBinding?: boolean
}) => {
const ptyId = PTY_ID_BY_WORKTREE[args.worktreeId]!
spawnedTabIdByWorktree.set(args.worktreeId, args.tabId)
// Why: only the host binding contract decides this — the test must not
// hand the runtime an ownership signal the create path did not produce.
if (args.persistHostSessionBinding) {
persistPtyBinding(args.worktreeId, args.tabId, args.leafId, ptyId)
}
return { id: ptyId }
}
)
runtime.setPtyController({
spawn,
write: () => true,
kill: vi.fn(() => true),
stopAndWait: vi.fn(async () => true),
listProcesses: vi.fn(async () => []),
getForegroundProcess: async () => null
} as never)
// The host runs the full desktop app: a window IS attached.
runtime.attachWindow(1)
const events: RuntimeMobileSessionTabsResult[] = []
const removed: string[] = []
// Removals reach clients as an ordinary snapshot frame carrying removed:true
// (notifyMobileSessionTabsRemoved) — there is no separate listener.
runtime.onMobileSessionTabsChanged((snapshot) => {
events.push(snapshot)
if ((snapshot as RuntimeMobileSessionTabsRemovedResult).removed) {
removed.push(snapshot.worktree)
}
})
/** An `orca terminal create` on this host, through the real create path. */
const createCliTerminal = async (worktreeId: string): Promise<string> => {
await runtime.createTerminal(`id:${worktreeId}`, { focus: false })
const tabId = spawnedTabIdByWorktree.get(worktreeId)
expect(tabId).toBeDefined()
const pty = (runtime as unknown as RuntimeInternals).ptysById.get(
PTY_ID_BY_WORKTREE[worktreeId]!
)
expect(pty?.connected).toBe(true)
return tabId!
}
/** A renderer graph sync that mentions ONLY `worktreeIds` — i.e. the panes the
* renderer currently has mounted. Any orca-cli dispatch triggers one.
* `version` must climb, or web clients drop the frame as stale. */
let syncVersion = 0
const syncRendererGraph = (worktreeIds: readonly string[]): void => {
syncVersion += 1
runtime.syncWindowGraph(1, {
tabs: [],
leaves: [],
mobileSessionTabs: worktreeIds.map((worktreeId) => ({
worktree: worktreeId,
publicationEpoch: 'renderer:incident-epoch',
snapshotVersion: syncVersion,
activeGroupId: 'group-1',
activeTabId: null,
activeTabType: null,
tabs: []
}))
} as never)
}
return {
runtime,
events,
removed,
createCliTerminal,
syncRendererGraph,
snapshotTabIds: (worktreeId: string): string[] =>
(
(runtime as unknown as RuntimeInternals).mobileSessionTabsByWorktree.get(worktreeId) as
| { tabs: { parentTabId?: string }[] }
| undefined
)?.tabs.flatMap((tab) => (tab.parentTabId ? [tab.parentTabId] : [])) ?? [],
hasSnapshot: (worktreeId: string): boolean =>
(runtime as unknown as RuntimeInternals).mobileSessionTabsByWorktree.has(worktreeId),
markPtyExited: (ptyId: string): void => {
const record = (runtime as unknown as RuntimeInternals).ptysById.get(ptyId)!
record.connected = false
},
/** The user closed the tab: the renderer de-persists it from the session. */
retirePersistedTabs: (worktreeId: string): void => {
session = {
...session,
tabsByWorktree: { ...session.tabsByWorktree, [worktreeId]: [] }
}
},
persistedTabIds: (worktreeId: string): string[] =>
(session.tabsByWorktree?.[worktreeId] ?? []).map((tab) => tab.id),
isRuntimeSessionOwned: (ptyId: string): boolean =>
(runtime as unknown as RuntimeInternals).ptysById.get(ptyId)?.runtimeSessionOwned === true
}
}
describe('graph sync must not prune a tab whose daemon PTY is live', () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.useRealTimers()
})
it('keeps a live CLI terminal when the renderer syncs a DIFFERENT worktree', async () => {
const h = createHarness()
// The host app is running and the renderer already owns this workspace, so
// its snapshot carries the RENDERER's publication epoch. The CLI terminal
// published next inherits that epoch (publishPtyBackedMobileSessionTerminal).
h.syncRendererGraph([WT_CLI])
await h.createCliTerminal(WT_CLI)
expect(h.hasSnapshot(WT_CLI)).toBe(true)
// An orca-cli dispatch in another worktree drives a renderer graph sync that
// does not mention WT_CLI at all.
h.syncRendererGraph([WT_OTHER])
vi.advanceTimersByTime(300)
// RED on main: the prune loop deletes WT_CLI and emits a removal frame, so
// the paired client clears the strip while the agent keeps running.
expect(h.hasSnapshot(WT_CLI)).toBe(true)
expect(h.removed).not.toContain(WT_CLI)
})
it('keeps a live CLI terminal when the renderer republishes ITS worktree without it', async () => {
const h = createHarness()
h.syncRendererGraph([WT_CLI])
const cliTabId = await h.createCliTerminal(WT_CLI)
// The renderer owns no pane for this terminal, so its publication for the
// same worktree carries zero tabs.
h.syncRendererGraph([WT_CLI])
vi.advanceTimersByTime(300)
expect(h.snapshotTabIds(WT_CLI)).toContain(cliTabId)
})
it('control: still prunes the tab once its PTY has exited', async () => {
const h = createHarness()
h.syncRendererGraph([WT_CLI])
await h.createCliTerminal(WT_CLI)
h.markPtyExited(CLI_PTY)
h.syncRendererGraph([WT_OTHER])
vi.advanceTimersByTime(300)
// A dead terminal must not be preserved — that is the STA-4593 resurrection
// invariant, and the reason id-shape preservation was narrowed originally.
expect(h.hasSnapshot(WT_CLI)).toBe(false)
})
it('control: prunes the CLI terminal once a user close de-persists its tab', async () => {
const h = createHarness()
h.syncRendererGraph([WT_CLI])
const cliTabId = await h.createCliTerminal(WT_CLI)
// A user close is what removes the tab from the persisted session. The PTY
// deliberately stays connected: STA-4593's host is one where PTYs outlive
// their tabs, and that is exactly when resurrection used to happen.
h.retirePersistedTabs(WT_CLI)
h.syncRendererGraph([WT_CLI])
vi.advanceTimersByTime(300)
expect(h.snapshotTabIds(WT_CLI)).not.toContain(cliTabId)
})
// N2: the renderer can republish before its de-persist reaches the host
// session, so a just-closed tab can linger for one frame. Confirming that
// window is TRANSIENT — the next publication prunes it — not a resurrection.
it('a close that races the renderer publication still prunes on the next sync', async () => {
const h = createHarness()
h.syncRendererGraph([WT_CLI])
const cliTabId = await h.createCliTerminal(WT_CLI)
// Publication lands first, while the session still lists the tab.
h.syncRendererGraph([WT_CLI])
vi.advanceTimersByTime(300)
expect(h.snapshotTabIds(WT_CLI)).toContain(cliTabId)
// The de-persist lands afterwards; the very next sync releases ownership.
h.retirePersistedTabs(WT_CLI)
h.syncRendererGraph([WT_CLI])
vi.advanceTimersByTime(300)
expect(h.snapshotTabIds(WT_CLI)).not.toContain(cliTabId)
})
// The safety argument for always persisting rests on a real close actually
// DROPPING that persisted binding. Every other control here hands the
// de-persist to the harness, which would pass even if no close route did it.
// This one drives the real close and lets the production code decide.
it('a real paired close de-persists the CLI terminal and stops preserving it', async () => {
const h = createHarness()
h.syncRendererGraph([WT_CLI])
const cliTabId = await h.createCliTerminal(WT_CLI)
expect(h.persistedTabIds(WT_CLI)).toContain(cliTabId)
expect(h.isRuntimeSessionOwned(CLI_PTY)).toBe(true)
await h.runtime.closeMobileSessionTab(`id:${WT_CLI}`, cliTabId, { reason: 'user' })
// The close must drop the persisted binding, or the tab is preserved forever
// and STA-4593's closed-tab resurrection returns through the CLI path.
expect(h.persistedTabIds(WT_CLI)).not.toContain(cliTabId)
expect(h.isRuntimeSessionOwned(CLI_PTY)).toBe(false)
h.syncRendererGraph([WT_CLI])
vi.advanceTimersByTime(300)
expect(h.snapshotTabIds(WT_CLI)).not.toContain(cliTabId)
})
it('keeps every live CLI terminal when the renderer publishes no worktree at all', async () => {
const h = createHarness()
h.syncRendererGraph([WT_CLI, WT_OTHER])
await h.createCliTerminal(WT_CLI)
await h.createCliTerminal(WT_OTHER)
h.syncRendererGraph([])
vi.advanceTimersByTime(300)
expect(h.hasSnapshot(WT_CLI)).toBe(true)
expect(h.hasSnapshot(WT_OTHER)).toBe(true)
})
})
@@ -0,0 +1,162 @@
/**
* Seam: store.persistPtyBinding must not arm the repo's topology fence. An
* armed fence makes rebaseWorkspaceSessionTerminalMembership treat a later
* renderer close as a stale replay and restore the row, leaving the durable
* de-persist to ride the PTY exit — asynchronous, and able to fail outright.
* Safety is one guard away: advanceTopologyFence's
* `currentRevision <= 0 && !establishesSplitAuthority` early return.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import { createStore, testState } from '../persistence-test-harness'
vi.mock('electron', () => ({
app: { getPath: () => testStateDirRef.dir, getName: () => 'orca', getVersion: () => '0.0.0' },
BrowserWindow: { fromId: () => null, getAllWindows: () => [] },
webContents: { fromId: () => null },
ipcMain: { on: () => {}, handle: () => {}, removeListener: () => {} },
safeStorage: { isEncryptionAvailable: () => false }
}))
const testStateDirRef = vi.hoisted(() => ({ dir: '' }))
const REPO_ID = 'repo-1'
const WT = `${REPO_ID}::/tmp/wt-cli`
const TAB = 'cli-tab-1'
const LEAF = '11111111-1111-4111-8111-111111111111'
const PTY = `${WT}@@a1b2c3d4`
function rendererWriteWithout(
session: WorkspaceSessionState,
tabId: string
): WorkspaceSessionState {
// A renderer session write after closing `tabId`: the row is gone, and the
// renderer never writes the host-private topology fence.
const next: WorkspaceSessionState = {
...session,
tabsByWorktree: {
...session.tabsByWorktree,
[WT]: (session.tabsByWorktree?.[WT] ?? []).filter((tab) => tab.id !== tabId)
}
}
delete (next as { terminalTopologyRevisionByRepoId?: unknown }).terminalTopologyRevisionByRepoId
return next
}
async function makeStore() {
return await createStore()
}
describe('host-created terminal close durability', () => {
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-close-durability-'))
testStateDirRef.dir = testState.dir
})
afterEach(() => {
rmSync(testState.dir, { recursive: true, force: true })
})
it('a host-created terminal does NOT push a fresh repo into host-authoritative membership', async () => {
const store = await makeStore()
store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY })
const session = store.getWorkspaceSession()
expect(session.tabsByWorktree?.[WT]?.map((t) => t.id)).toContain(TAB)
// advanceTopologyFence (store.ts:3284) deliberately declines to arm the
// fence while currentRevision <= 0 and no split authority is established.
// That is what keeps a renderer close authoritative for this repo.
expect(session.terminalTopologyRevisionByRepoId?.[REPO_ID] ?? 0).toBe(0)
})
it('a renderer close write durably removes the row it persisted', async () => {
const store = await makeStore()
store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY })
store.setWorkspaceSession(rendererWriteWithout(store.getWorkspaceSession(), TAB))
expect(store.getWorkspaceSession().tabsByWorktree?.[WT] ?? []).toEqual([])
})
it('stays removed with the PTY still connected and no exit ever delivered', async () => {
const store = await makeStore()
store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY })
store.setWorkspaceSession(rendererWriteWithout(store.getWorkspaceSession(), TAB))
// Kill-failure shape: no retirement, no exit, just more renderer writes.
for (let i = 0; i < 3; i += 1) {
store.setWorkspaceSession({ ...store.getWorkspaceSession() })
}
expect(store.getWorkspaceSession().tabsByWorktree?.[WT] ?? []).toEqual([])
})
it('pre-existing host-authoritative membership rebases the close away (not ours)', async () => {
const store = await makeStore()
// A serve/SSH host arms the fence long before any CLI terminal exists.
store.setWorkspaceSession({
...store.getWorkspaceSession(),
terminalTopologyRevisionByRepoId: { [REPO_ID]: 1 }
})
store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY })
store.setWorkspaceSession(rendererWriteWithout(store.getWorkspaceSession(), TAB))
// Documents PRE-EXISTING behavior: with the fence already armed, a renderer
// write that omits a row is treated as a stale replay and the row survives
// until an exit-driven retirement advances the fence. This is independent of
// host-created terminals — it applies to every tab in such a repo.
expect(store.getWorkspaceSession().tabsByWorktree?.[WT]?.map((t) => t.id)).toContain(TAB)
})
})
/** Pins the fence seam itself rather than spot-checking callers: a new
* fence-advancing path, or arming the fence from the create path, fails here. */
describe('topology fence census', () => {
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-fence-census-'))
testStateDirRef.dir = testState.dir
})
afterEach(() => {
rmSync(testState.dir, { recursive: true, force: true })
})
it('no host-create binding shape arms a fresh repo fence', async () => {
for (const [index, extra] of [
{},
{ incarnationId: '33333333-3333-4333-8333-333333333333' },
{ startupCwd: '/tmp/wt-cli' }
].entries()) {
const store = await makeStore()
store.persistPtyBinding({
worktreeId: WT,
tabId: `${TAB}-${index}`,
leafId: LEAF,
ptyId: `${PTY}-${index}`,
...extra
})
expect(store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.[REPO_ID] ?? 0).toBe(0)
}
})
it('a second pane in the same tab still does not arm the fence', async () => {
const store = await makeStore()
store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY })
store.persistPtyBinding({
worktreeId: WT,
tabId: TAB,
leafId: '22222222-2222-4222-8222-222222222222',
ptyId: `${PTY}-b`
})
expect(store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.[REPO_ID] ?? 0).toBe(0)
})
it('an armed fence keeps climbing, so exit-driven retirement still outranks replays', async () => {
const store = await makeStore()
store.setWorkspaceSession({
...store.getWorkspaceSession(),
terminalTopologyRevisionByRepoId: { [REPO_ID]: 1 }
})
store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY })
expect(
store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.[REPO_ID] ?? 0
).toBeGreaterThan(1)
})
})
+64 -11
View File
@@ -3299,11 +3299,12 @@ describe('OrcaRuntimeService', () => {
leafId: HEADLESS_LEAF_ID,
worktreeId: TEST_WORKTREE_ID
})
// persistHostSessionBinding is no longer a per-call opt-in: createTerminal
// is host-initiated by construction and always persists its binding.
expect(createTerminal).toHaveBeenCalledWith(`id:${TEST_WORKTREE_ID}`, {
tabId,
leafId: HEADLESS_LEAF_ID,
focus: false,
persistHostSessionBinding: true
focus: false
})
})
@@ -13728,8 +13729,7 @@ describe('OrcaRuntimeService', () => {
command: 'codex',
presentation: 'background',
tabId,
leafId,
persistHostSessionBinding: true
leafId
})
).rejects.toThrow('agent_session_exited_during_start')
await expect(runtime.listTerminals(`id:${TEST_WORKTREE_ID}`)).resolves.toMatchObject({
@@ -14954,7 +14954,13 @@ describe('OrcaRuntimeService', () => {
)
})
it('keeps ordinary desktop background terminal persistence opt-in', async () => {
// Why (flipped by the aug20 "windows 2" incident): #8646 scoped the persisted
// binding to windowless promotion, which left a host-initiated terminal on a
// host running the full app with neither a persisted tab nor runtime
// ownership — unclassifiable, so graph sync pruned the tab off a live agent.
// The renderer adopts under the pre-minted tabId, so persisting early cannot
// fork a second tab; it only makes the host's own create durable.
it('persists the host session binding even when a window is attached', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' })
const runtime = new OrcaRuntimeService(store)
const webContents = { send: vi.fn() }
@@ -14976,7 +14982,7 @@ describe('OrcaRuntimeService', () => {
const spawnOptions = spawn.mock.calls[0]?.[0] as
| { persistHostSessionBinding?: boolean }
| undefined
expect(spawnOptions?.persistHostSessionBinding).toBeUndefined()
expect(spawnOptions?.persistHostSessionBinding).toBe(true)
})
it('falls back to background terminal creation for renderer-backed requests without a renderer window', async () => {
@@ -16208,11 +16214,18 @@ describe('OrcaRuntimeService', () => {
vi.useFakeTimers()
try {
const runtime = new OrcaRuntimeService(store)
const serializeProviderBuffer = vi.fn().mockResolvedValue({
data: 'OpenAI Codex\r\nmodel: gpt-5.5\r\ndirectory: /repo\r\n',
cols: 80,
rows: 24,
seq: 1
})
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
getForegroundProcess: async () => null,
serializeProviderBuffer
})
const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`)
@@ -16248,6 +16261,7 @@ describe('OrcaRuntimeService', () => {
await vi.advanceTimersByTimeAsync(2_000)
await timeoutAssertion
expect(serializeProviderBuffer).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
@@ -20516,7 +20530,8 @@ describe('OrcaRuntimeService', () => {
const kill = vi.fn(() => true)
const serializeProviderBuffer = vi.fn().mockResolvedValue({
data: '',
scrollbackAnsi: 'OpenAI Codex\r\nACK\r\n',
scrollbackAnsi:
' >_ OpenAI Codex (v0.131.0)\r\n model: gpt-5.5 high\r\n directory: /repo\r\n',
cols: 80,
rows: 24,
seq: 100,
@@ -20598,11 +20613,36 @@ describe('OrcaRuntimeService', () => {
expect(kill).not.toHaveBeenCalled()
const [terminal] = (await runtime.listTerminals()).terminals
await expect(runtime.readTerminal(terminal.handle)).resolves.toMatchObject({
tail: ['OpenAI Codex', 'ACK']
tail: [' >_ OpenAI Codex (v0.131.0)', ' model: gpt-5.5 high', ' directory: /repo']
})
expect(serializeProviderBuffer).toHaveBeenCalledWith('pty-legacy', {
scrollbackRows: 120
})
await expect(
runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle', timeoutMs: 100 })
).resolves.toMatchObject({ satisfied: true })
serializeProviderBuffer.mockResolvedValueOnce({
data: '',
scrollbackAnsi: 'Do you trust this workspace directory?\r\n1. Yes\r\n2. No\r\n',
cols: 80,
rows: 24,
seq: 101,
source: 'headless' as const,
alternateScreen: false
})
await expect(
runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle', timeoutMs: 100 })
).resolves.toMatchObject({
satisfied: false,
blockedReason: 'codex-trust-workspace'
})
serializeProviderBuffer.mockImplementationOnce(() => new Promise(() => {}))
await expect(
runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle', timeoutMs: 50 })
).rejects.toThrow('timeout')
expect(serializeProviderBuffer).toHaveBeenCalledTimes(4)
await expect(runtime.readTerminal(terminal.handle)).resolves.toMatchObject({ tail: [] })
expect(serializeProviderBuffer).toHaveBeenCalledTimes(4)
expect(
runtime.verifyOrchestrationCompatibilityCaller({
terminalHandle: 'term_legacy',
@@ -22288,6 +22328,12 @@ describe('OrcaRuntimeService', () => {
wslDistro: null
}
])
const serializeProviderBuffer = vi.fn().mockResolvedValue(null)
const serializeBuffer = vi.fn().mockResolvedValue({
data: ' >_ OpenAI Codex (v0.131.0)\r\n model: gpt-5.5 high\r\n directory: /repo\r\n',
cols: 80,
rows: 24
})
const runtime = new OrcaRuntimeService(
{
...runtimeStore,
@@ -22321,7 +22367,10 @@ describe('OrcaRuntimeService', () => {
kill: vi.fn(() => true),
getForegroundProcess: async () => null,
hasPty: (candidate) => candidate === ptyId,
listProcesses
listProcesses,
serializeBuffer,
serializeProviderBuffer,
hasRendererSerializer: () => true
})
const revealTerminalSession = vi.fn().mockImplementation(() =>
publishLegacyWorkerReveal(runtime, {
@@ -22393,6 +22442,11 @@ describe('OrcaRuntimeService', () => {
incarnationId
}
})
await expect(
runtime.waitForTerminal('term_ssh_legacy', { condition: 'tui-idle', timeoutMs: 100 })
).resolves.toMatchObject({ satisfied: true })
expect(serializeProviderBuffer).toHaveBeenCalledOnce()
expect(serializeBuffer).toHaveBeenCalledOnce()
})
it('refuses a cross-distro WSL worker and adopts it after exact host ownership matches', async () => {
@@ -28735,7 +28789,6 @@ describe('OrcaRuntimeService', () => {
const created = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
presentation: 'background',
persistHostSessionBinding: true,
tabId,
leafId,
launchAgent: 'codex'
+131 -38
View File
@@ -215,6 +215,7 @@ import type {
SkillUploadChunkRequest
} from '../../shared/skill-upload-session-contract'
import { SkillUploadSessionService } from '../skills/skill-upload-session-service'
import { SKILL_UPLOAD_STAGING_ROOT_NAME } from '../skills/skill-upload-staging-ownership'
import type { SkillSshWorkspaceAuthority } from '../../shared/skill-ssh-relay-contract'
import {
installSkillBundleOnSshHost,
@@ -612,6 +613,7 @@ import {
resolveCustomWorktreeVisibilitySources,
type WorktreeVisibilitySourceMatcher
} from '../../shared/worktree/visibility-sources'
import { resolveConfiguredWorktreeBasePaths } from '../../shared/worktree/configured-worktree-base-path'
import {
BROWSER_HEADLESS_RUNTIME_CAPABILITY,
BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY,
@@ -905,6 +907,7 @@ import {
sanitizeLinearErrorMessage
} from '../linear/issue-context-errors'
import { listMcpIssues } from '../linear/mcp-issue-list'
import { linearPriorityLabel } from '../../shared/linear/priority-label'
import { writeIssueRelation } from '../linear/issue-relation-write'
import {
createProject as createLinearProject,
@@ -1561,7 +1564,6 @@ type TerminalCreateOptions = {
sessionId?: string
isNewSession?: boolean
preAllocatedHandle?: string
persistHostSessionBinding?: boolean
// Why: only the host-derived structured resume path may attach provider
// identity; opaque terminal.create commands remain ordinary shells.
agentSessionClaim?: AgentSessionExecutionClaim
@@ -3660,6 +3662,7 @@ export class OrcaRuntimeService {
private skillCloudService: SkillCloudService | null = null
private agentSkillShareInProgress = false
private skillUploadSessions: SkillUploadSessionService | null = null
private skillUploadSessionsDisposed = false
private readonly skillTransactionRecovery: Promise<unknown>
private readonly skillInstallOperations = new Map<string, AbortController>()
private readonly skillInstallProgress = new Map<string, SkillBundleInstallProgress>()
@@ -5695,12 +5698,22 @@ export class OrcaRuntimeService {
}
private requireSkillUploadSessions(): SkillUploadSessionService {
if (this.skillUploadSessionsDisposed) {
throw new Error('skill-upload-service-disposed')
}
this.skillUploadSessions ??= new SkillUploadSessionService(
join(app.getPath('userData'), 'skill-installs', 'remote-uploads')
join(app.getPath('userData'), 'skill-installs', SKILL_UPLOAD_STAGING_ROOT_NAME)
)
return this.skillUploadSessions
}
async disposeSkillUploadSessions(): Promise<void> {
this.skillUploadSessionsDisposed = true
const sessions = this.skillUploadSessions
this.skillUploadSessions = null
await sessions?.dispose()
}
getRuntimeId(): string {
return this.runtimeId
}
@@ -13323,7 +13336,8 @@ export class OrcaRuntimeService {
private async withVisibleSnapshotFallback(
ptyId: string,
read: RuntimeTerminalRead,
opts: { cursor?: number; limit?: number } = {}
opts: { cursor?: number; limit?: number } = {},
providerWait: { timeoutMs?: number; retireOnTimeout?: boolean } = {}
): Promise<RuntimeTerminalRead> {
if (typeof opts.cursor === 'number') {
return read
@@ -13338,7 +13352,11 @@ export class OrcaRuntimeService {
!recoveredWorkerFallback &&
this.isKnownUnattachedLocalDaemonPty(ptyId)
if (recoveredWorkerFallback || neverAttachedProviderFallback) {
const providerLines = await this.readProviderTerminalTailLines(ptyId, opts.limit)
const providerLines = await this.readProviderTerminalTailLines(
ptyId,
opts.limit,
providerWait
)
if (providerLines.length > 0) {
return buildVisibleSnapshotReadFallback(read, providerLines, opts.limit)
}
@@ -13376,12 +13394,15 @@ export class OrcaRuntimeService {
private async readProviderTerminalTailLines(
ptyId: string,
limit: number | undefined
limit: number | undefined,
wait: { timeoutMs?: number; retireOnTimeout?: boolean } = {}
): Promise<string[]> {
const lineLimit = terminalReadLimit(limit, DEFAULT_TERMINAL_READ_LIMIT)
const snapshot = await this.serializeProviderTerminalBuffer(ptyId, {
scrollbackRows: lineLimit
})
const snapshot = await this.serializeProviderTerminalBuffer(
ptyId,
{ scrollbackRows: lineLimit },
wait
)
const data = snapshot ? `${snapshot.scrollbackAnsi ?? ''}${snapshot.data}` : ''
if (!snapshot || data.length === 0) {
return []
@@ -18380,9 +18401,7 @@ export class OrcaRuntimeService {
const recovery = this.createTerminal(`id:${expectedWorktreeId}`, {
tabId: parsed.tabId,
leafId: parsed.leafId,
focus: false,
// Why: the HUB renderer may publish its exited layout while recovery is in flight; persist the replacement before that stale graph can orphan it.
persistHostSessionBinding: true
focus: false
}).then((terminal) => ({
handle: terminal.handle,
tabId: parsed.tabId,
@@ -18442,14 +18461,15 @@ export class OrcaRuntimeService {
async readTerminal(
handle: string,
opts: { cursor?: number; limit?: number; screen?: boolean } = {}
opts: { cursor?: number; limit?: number; screen?: boolean } = {},
providerWait: { timeoutMs?: number; retireOnTimeout?: boolean } = {}
): Promise<RuntimeTerminalRead> {
const pty = this.getLivePtyForHandle(handle)
if (pty) {
const read = this.readPtyTerminal(handle, pty.pty, opts)
const visibleRead = opts.screen
? await this.readRenderedScreen(pty.pty.ptyId, read, opts)
: await this.withVisibleSnapshotFallback(pty.pty.ptyId, read, opts)
: await this.withVisibleSnapshotFallback(pty.pty.ptyId, read, opts, providerWait)
this.assertLiveTerminalHandleTargetsPty(handle, pty.pty.ptyId)
return labelTerminalReadSource(visibleRead)
}
@@ -18471,7 +18491,7 @@ export class OrcaRuntimeService {
}
const visibleRead = opts.screen
? await this.readRenderedScreen(leaf.ptyId, read, opts)
: await this.withVisibleSnapshotFallback(leaf.ptyId, read, opts)
: await this.withVisibleSnapshotFallback(leaf.ptyId, read, opts, providerWait)
this.assertLiveTerminalHandleTargetsPty(handle, leaf.ptyId)
return labelTerminalReadSource(visibleRead)
}
@@ -19680,7 +19700,7 @@ export class OrcaRuntimeService {
) {
this.resolveWaiter(waiter, buildPtyTerminalWaitResult(handle, condition, live.pty))
} else {
this.startPtyTuiIdleFallbackPoll(waiter, live.pty)
this.startPtyTuiIdleFallbackPoll(waiter, live.pty, effectiveTimeoutMs)
}
}
})
@@ -19791,7 +19811,7 @@ export class OrcaRuntimeService {
) {
this.resolveWaiter(waiter, buildTerminalWaitResult(handle, condition, live.leaf))
} else {
this.startTuiIdleFallbackPoll(waiter, live.leaf)
this.startTuiIdleFallbackPoll(waiter, live.leaf, effectiveTimeoutMs)
}
}
}
@@ -23316,7 +23336,8 @@ export class OrcaRuntimeService {
const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length
const matcher = createWorktreeVisibilitySourceMatcher(
[repo.path, ...worktrees.map((worktree) => worktree.path)],
resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults)
resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults),
resolveConfiguredWorktreeBasePaths(repo)
)
const detected = worktrees.map((worktree) =>
this.toRuntimeDetectedWorktree(
@@ -23345,7 +23366,8 @@ export class OrcaRuntimeService {
}
const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher(
[repo.path, ...scan.worktrees.map((worktree) => worktree.path)],
resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults)
resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults),
resolveConfiguredWorktreeBasePaths(repo)
)
const expectedHostId = getRepoExecutionHostId(repo)
const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length
@@ -23457,7 +23479,8 @@ export class OrcaRuntimeService {
repo.id,
createWorktreeVisibilitySourceMatcher(
[repo.path, ...(checkoutPathsByRepoId.get(repo.id) ?? [])],
resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults)
resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults),
resolveConfiguredWorktreeBasePaths(repo)
)
])
)
@@ -27760,7 +27783,6 @@ export class OrcaRuntimeService {
presentation: request.presentation ?? 'background',
tabId: request.placement?.tabId,
leafId: request.placement?.leafId,
persistHostSessionBinding: true,
agentSessionClaim: claim,
signal: _caller.signal
})
@@ -27948,7 +27970,6 @@ export class OrcaRuntimeService {
leafId: operationLeafId,
preAllocatedHandle: operationHandle,
viewMode: request.viewMode,
persistHostSessionBinding: true,
agentSessionCreateOperationId: executionOperationId,
signal: caller.signal,
onPtySpawnCommitted: () => {
@@ -28170,10 +28191,6 @@ export class OrcaRuntimeService {
if (launchOpts.signal?.aborted) {
throw new Error('client_disconnected')
}
const persistHostSessionBinding =
launchOpts.persistHostSessionBinding ||
launchOpts.surfaceOwner === false ||
this.getAvailableAuthoritativeWindow() === null
let result: Awaited<ReturnType<NonNullable<RuntimePtyController['spawn']>>>
try {
result = await this.ptyController.spawn({
@@ -28222,12 +28239,10 @@ export class OrcaRuntimeService {
...(adoptedBeforeLaunch ? { adoptedStablePane: adoptedBeforeLaunch } : {}),
...(launchOpts.sessionId ? { sessionId: launchOpts.sessionId } : {}),
...(!adoptedBeforeLaunch && launchOpts.isNewSession ? { isNewSession: true } : {}),
// Why: a headless-created pane has no renderer session writer. Persist
// its tab/leaf binding at spawn so a later promoted window reattaches
// the live daemon or SSH PTY instead of replacing it with a fresh one.
// Re-check freshly: the entry-time snapshot can go stale across the
// awaits above if the authoritative window is destroyed mid-spawn.
...(persistHostSessionBinding ? { persistHostSessionBinding: true } : {})
// Why: a host-initiated create has no renderer session writer, so
// without its own binding graph sync cannot classify the terminal
// and prunes the tab out from under a running agent.
persistHostSessionBinding: true
})
} finally {
releaseStablePaneCreate?.()
@@ -28267,9 +28282,9 @@ export class OrcaRuntimeService {
})
const pty = this.getOrCreatePtyWorktreeRecord(result.id)
if (pty) {
if (persistHostSessionBinding) {
pty.runtimeSessionOwned = true
}
// Released again by releaseRuntimeSessionOwnershipForRendererRetiredTabs
// once the renderer de-persists the tab, i.e. when the user closes it.
pty.runtimeSessionOwned = true
if (!adoptedStablePane) {
if (launchOpts.title) {
const observedAt = this.nextTitleObservationSequence()
@@ -29015,7 +29030,6 @@ export class OrcaRuntimeService {
? { sessionId: stableSessionId }
: {}),
...(isNewSession ? { isNewSession: true } : {}),
persistHostSessionBinding: true,
// Why: this method publishes the authoritative snapshot below; skip the intermediate publish to avoid a wrong-group flash.
deferMobileSessionPublish: true,
signal: opts.signal
@@ -35372,7 +35386,11 @@ export class OrcaRuntimeService {
}
// Why: the primary OSC-title signal can't fire for daemon-hosted terminals (no PTY data through the runtime), so this fallback polls the renderer-synced tab title + foreground-process quiescence; self-cancels when the OSC path fires.
private startTuiIdleFallbackPoll(waiter: TerminalWaiter, leaf: RuntimeLeafRecord): void {
private startTuiIdleFallbackPoll(
waiter: TerminalWaiter,
leaf: RuntimeLeafRecord,
waiterTimeoutMs: number
): void {
let foregroundPollInFlight = false
waiter.pollInterval = setInterval(async () => {
if (!waiter.pollInterval) {
@@ -35455,9 +35473,21 @@ export class OrcaRuntimeService {
}
}
}, TUI_IDLE_POLL_INTERVAL_MS)
const retainedWaitText = buildTerminalWaitText(
leaf.tailBuffer,
leaf.tailPartialLine,
leaf.preview
)
if (leaf.lastAgentStatus === null && retainedWaitText.length === 0) {
this.startTuiIdleVisibleReadProbe(waiter, waiterTimeoutMs)
}
}
private startPtyTuiIdleFallbackPoll(waiter: TerminalWaiter, pty: RuntimePtyWorktreeRecord): void {
private startPtyTuiIdleFallbackPoll(
waiter: TerminalWaiter,
pty: RuntimePtyWorktreeRecord,
waiterTimeoutMs: number
): void {
let foregroundPollInFlight = false
waiter.pollInterval = setInterval(async () => {
if (!waiter.pollInterval) {
@@ -35521,6 +35551,65 @@ export class OrcaRuntimeService {
}
}
}, TUI_IDLE_POLL_INTERVAL_MS)
const retainedWaitText = buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview)
if (pty.lastAgentStatus === null && retainedWaitText.length === 0) {
this.startTuiIdleVisibleReadProbe(waiter, waiterTimeoutMs)
}
}
private startTuiIdleVisibleReadProbe(waiter: TerminalWaiter, waiterTimeoutMs: number): void {
const snapshotTimeoutMs = Math.min(
VISIBLE_TERMINAL_SNAPSHOT_TIMEOUT_MS,
Math.max(0, waiterTimeoutMs - 1)
)
void withTimeout(
this.readTerminal(
waiter.handle,
{},
{
timeoutMs: snapshotTimeoutMs,
retireOnTimeout: true
}
),
snapshotTimeoutMs,
null
)
.then((read) => {
if (
!read ||
read.source !== 'screen' ||
!this.waitersByHandle.get(waiter.handle)?.has(waiter)
) {
return
}
const snapshotText = read.tail.join('\n')
const blockedReason = detectTerminalWaitBlockedReason(snapshotText)
if (!blockedReason && !isKnownReadyPromptPreview(snapshotText)) {
return
}
if (waiter.pollInterval) {
clearInterval(waiter.pollInterval)
waiter.pollInterval = null
}
const pty = this.getLivePtyForHandle(waiter.handle)
if (pty) {
this.resolveWaiter(
waiter,
blockedReason
? buildPtyTerminalWaitBlockedResult(waiter.handle, 'tui-idle', pty.pty, blockedReason)
: buildPtyTerminalWaitResult(waiter.handle, 'tui-idle', pty.pty)
)
return
}
const { leaf } = this.getLiveLeafForHandle(waiter.handle)
this.resolveWaiter(
waiter,
blockedReason
? buildTerminalWaitBlockedResult(waiter.handle, 'tui-idle', leaf, blockedReason)
: buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf)
)
})
.catch(() => {})
}
private getAdoptedPtyExplicitIdleStatus(pty: RuntimePtyWorktreeRecord): AgentStatus | null {
@@ -35747,14 +35836,16 @@ export class OrcaRuntimeService {
code: this.linearWorkspaceErrorCode(error.type),
message: sanitizeLinearErrorMessage(error.message)
}))
const hasMore = result.hasMore === true || result.items.length > limit
return {
projects,
truncated: hasMore,
meta: {
query: params.query,
workspaceId: params.workspaceId,
limit,
returned: projects.length,
hasMore: result.hasMore === true || result.items.length > limit,
hasMore,
partial: workspaceErrors.length > 0,
workspaceErrors
}
@@ -35794,11 +35885,13 @@ export class OrcaRuntimeService {
estimate: issue.estimate,
dueDate: issue.dueDate,
updatedAt: issue.updatedAt,
priorityLabel: linearPriorityLabel(issue.priority),
workspace: {
id: issue.workspaceId ?? workspaceId ?? '',
name: issue.workspaceName ?? issue.workspaceId ?? workspaceId ?? ''
}
})),
truncated: result.hasMore === true,
meta: {
filter,
workspaceId,
+3 -1
View File
@@ -43,7 +43,9 @@ describe('skill install lock', () => {
JSON.stringify({ token: 'dead-owner', pid: 2_147_483_647, createdAt: Date.now() })
)
const release = await acquireSkillInstallLock({ path: lockPath, timeoutMs: 100 })
// Why: reclaiming costs a fsync plus one 50ms retry, so a 100ms budget expires on a
// loaded CI runner and surfaces the legacy file's rename ENOTDIR instead of reclaiming.
const release = await acquireSkillInstallLock({ path: lockPath, timeoutMs: 5_000 })
expect((await readPublishedOwner(lockPath)).token).not.toBe('dead-owner')
await release()
await expect(readdir(lockPath)).rejects.toMatchObject({ code: 'ENOENT' })
@@ -0,0 +1,16 @@
import { createHash } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import { SKILL_PACKAGE_MAX_COMPRESSED_BYTES } from '../../shared/skill-package-manifest'
export async function hashSkillUploadArchive(path: string): Promise<string> {
const size = (await stat(path)).size
if (size < 1 || size > SKILL_PACKAGE_MAX_COMPRESSED_BYTES) {
throw new Error('skill-upload-size-limit')
}
const hash = createHash('sha256')
for await (const chunk of createReadStream(path)) {
hash.update(chunk as Buffer)
}
return hash.digest('hex')
}
@@ -0,0 +1,58 @@
export class SkillUploadOperationLifecycle {
private beginTurn = Promise.resolve()
private operationsSettled = Promise.resolve()
private resolveOperationsSettled: (() => void) | null = null
private inFlight = 0
get hasInFlight(): boolean {
return this.inFlight > 0
}
enter(assertAvailable: () => void): () => void {
assertAvailable()
if (this.inFlight === 0) {
this.operationsSettled = new Promise<void>((resolve) => {
this.resolveOperationsSettled = resolve
})
}
this.inFlight += 1
let left = false
return () => {
if (left) {
return
}
left = true
this.inFlight -= 1
if (this.inFlight === 0) {
const resolve = this.resolveOperationsSettled
this.resolveOperationsSettled = null
resolve?.()
}
}
}
async enterBegin(assertAvailable: () => void): Promise<() => void> {
const leaveOperation = this.enter(assertAvailable)
const previousTurn = this.beginTurn
let releaseTurn!: () => void
this.beginTurn = new Promise<void>((resolve) => {
releaseTurn = resolve
})
await previousTurn
try {
assertAvailable()
} catch (error) {
releaseTurn()
leaveOperation()
throw error
}
return () => {
releaseTurn()
leaveOperation()
}
}
async settle(): Promise<void> {
await this.operationsSettled
}
}
@@ -146,7 +146,7 @@ async function terminateUpload(root: string, boundary: string): Promise<string>
async function completeFreshTransfer(uploadRoot: string): Promise<void> {
const service = new SkillUploadSessionService(uploadRoot)
const begun = await service.begin({ package: identity(), transferId: 'operation-fresh' })
expect(await readdir(uploadRoot)).toEqual([`${begun.uploadId}.tar.gz`])
expect(await stagedArchiveNames(uploadRoot)).toEqual([`${begun.uploadId}.tar.gz`])
await service.append({
uploadId: begun.uploadId,
offset: 0,
@@ -156,9 +156,24 @@ async function completeFreshTransfer(uploadRoot: string): Promise<void> {
const taken = await service.take(begun.uploadId, identity())
expect(await readFile(taken.archivePath)).toEqual(bytes)
await taken.cleanup()
expect(await stagedArchiveNames(uploadRoot)).toEqual([])
await service.dispose()
expect(await readdir(uploadRoot)).toEqual([])
}
async function stagedArchiveNames(uploadRoot: string): Promise<string[]> {
const owners = await readdir(uploadRoot, { withFileTypes: true })
const names = await Promise.all(
owners
.filter((entry) => entry.isDirectory())
.map((entry) => readdir(join(uploadRoot, entry.name)))
)
return names
.flat()
.filter((name) => name.endsWith('.tar.gz'))
.sort()
}
afterEach(async () => {
for (const child of children.splice(0)) {
if (child.exitCode === null && child.signalCode === null) {
@@ -177,7 +192,7 @@ describe.runIf(RUN_REAL_PROCESS)('skill upload process restart recovery', () =>
const uploadRoot = join(root, 'uploads')
const abandonedId = await terminateUpload(root, boundary)
expect(await readdir(uploadRoot)).toEqual([`${abandonedId}.tar.gz`])
expect(await stagedArchiveNames(uploadRoot)).toEqual([`${abandonedId}.tar.gz`])
await completeFreshTransfer(uploadRoot)
},
30_000
@@ -0,0 +1,36 @@
import { rm } from 'node:fs/promises'
export class SkillUploadRetainedPaths {
private readonly transferred = new Set<string>()
private readonly failedCleanup = new Set<string>()
get failedCleanupCount(): number {
return this.failedCleanup.size
}
get isEmpty(): boolean {
return this.transferred.size === 0 && this.failedCleanup.size === 0
}
retainTransferred(path: string): void {
this.transferred.add(path)
}
retainFailedCleanup(path: string): void {
this.failedCleanup.add(path)
}
async removeTransferred(path: string): Promise<void> {
await rm(path, { force: true })
this.transferred.delete(path)
}
async removeFailedCleanup(path: string): Promise<void> {
await rm(path, { force: true })
this.failedCleanup.delete(path)
}
async removeAllFailedCleanup(): Promise<void> {
await Promise.all([...this.failedCleanup].map((path) => this.removeFailedCleanup(path)))
}
}

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