mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/sta-4304-codex-config-preserve
This commit is contained in:
@@ -73,7 +73,8 @@ runs:
|
||||
shell: bash
|
||||
run: |
|
||||
pnpm install --frozen-lockfile --ignore-scripts
|
||||
git diff --exit-code package.json pnpm-lock.yaml
|
||||
# Job containers can run composite steps from a source mirror without .git.
|
||||
git -C "$GITHUB_WORKSPACE" diff --exit-code -- package.json pnpm-lock.yaml
|
||||
|
||||
# Why cached: `--ignore-scripts` leaves node-pty without build/Release, so
|
||||
# ensure-native-runtime node-gyp-compiles it in every job that asks for a runtime.
|
||||
|
||||
@@ -116,6 +116,15 @@ jobs:
|
||||
- name: Enforce max-lines ratchet
|
||||
run: pnpm run check:max-lines-ratchet
|
||||
|
||||
- name: Enforce runtime Electron-import ratchet
|
||||
run: pnpm run check:runtime-electron-ratchet
|
||||
|
||||
# Why both: the ratchet proves nothing reachable from the runtime imports electron,
|
||||
# which is a property of the import graph. This proves the Node artifact it enables
|
||||
# actually boots, pairs, creates a worktree and round-trips a real PTY.
|
||||
- name: Boot orcad and round-trip a terminal
|
||||
run: pnpm run smoke:orcad-terminal
|
||||
|
||||
- name: Verify bundled skill guides
|
||||
run: pnpm run verify:bundled-skill-guides
|
||||
|
||||
@@ -406,6 +415,8 @@ jobs:
|
||||
src/main/daemon/node-pty-fd-leak.test.ts \
|
||||
src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \
|
||||
src/main/providers/__tests__/shell-ready-framework-example.test.ts \
|
||||
src/main/pty/codex-shell-launch-preflight.test.ts \
|
||||
src/main/pty/omp-shell-wrapper-alias-safety.test.ts \
|
||||
src/main/pty/omp-shell-wrapper.node-pty.test.ts \
|
||||
src/main/shell-startup-feature-channel.test.ts \
|
||||
src/main/terminal-history-fish-session.node-pty.test.ts \
|
||||
@@ -618,11 +629,30 @@ 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/wsl/wsl-runner.test.ts
|
||||
src/main/wsl/wsl-guest-environment.test.ts
|
||||
src/main/wsl/wsl-invocation-boundary.test.ts
|
||||
src/main/wsl/wsl-executable-path.win32.test.ts
|
||||
src/main/wsl/wsl-w1-w3-contract.test.ts
|
||||
src/shared/source-scan/source-tree-scan.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
|
||||
|
||||
@@ -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 != '' }}
|
||||
|
||||
@@ -103,6 +103,8 @@ docs/**
|
||||
!docs/reference/headless-linux-server.md
|
||||
!docs/reference/linux-glibc-compatibility.md
|
||||
!docs/reference/relay-grace-time-reconfiguration.md
|
||||
!docs/reference/windows-process-enumeration.md
|
||||
!docs/reference/wsl-runner-verification.md
|
||||
!docs/reference/remote-wire-compatibility.md
|
||||
!docs/reference/renderer-agent-status-performance.md
|
||||
!docs/reference/ssh-execution-boundary.md
|
||||
|
||||
@@ -34,6 +34,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.
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
|
||||
|
||||
[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) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
|
||||
Pair with your desktop app to monitor and steer your agents from your phone.
|
||||
|
||||
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android:** [Download APK 0.0.43](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk) · [Install guide](https://www.onorca.dev/docs/android-apk)
|
||||
- **Android:** [Download APK 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [Install guide](https://www.onorca.dev/docs/android-apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -128,7 +128,6 @@ inline src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx
|
||||
inline src/renderer/src/components/terminal-pane/TerminalPane.tsx
|
||||
inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts
|
||||
inline src/renderer/src/components/terminal-pane/keyboard-handlers.ts
|
||||
inline src/renderer/src/components/terminal-pane/pty-connection.ts
|
||||
inline src/renderer/src/components/terminal-pane/pty-transport.ts
|
||||
inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts
|
||||
inline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"updatedAt": "2026-08-18",
|
||||
"updatedAt": "2026-08-20",
|
||||
"policy": {
|
||||
"maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"],
|
||||
"blockingPromotion": {
|
||||
@@ -10,6 +10,306 @@
|
||||
}
|
||||
},
|
||||
"gates": [
|
||||
{
|
||||
"id": "cmd-j-tabs.host-qualified-candidate-ownership",
|
||||
"title": "Cmd-J tab candidates retain execution-host ownership",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "renderer-workspace-tabs",
|
||||
"layer": "renderer-state-and-navigation",
|
||||
"surfaces": [
|
||||
"Cmd-J browser tabs",
|
||||
"Cmd-J simulator tabs",
|
||||
"Cmd-J workspace tabs",
|
||||
"paired-runtime tab mirror"
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local", "ssh", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local", "ssh", "remote-runtime"],
|
||||
"coverageNotes": "Deterministic candidate-builder contracts inject local and paired-runtime worktrees with the same bare id into the real aggregated tab buckets, including folder workspaces and a paired runtime transporting an SSH-owned worktree. A mirror contract preserves distinct same-id runtime tabs. Manual local-emulator contracts retain local ownership and dedupe explicitly owned tabs before worktree hydration. A paired Electron journey verifies both visible browser rows and both visible simulator rows, then requires each selection to render its owning active tab and update backing host-qualified state.",
|
||||
"motivatingLinks": [
|
||||
"https://linear.app/stably/issue/STA-4965",
|
||||
"https://github.com/stablyai/orca/pull/15371"
|
||||
],
|
||||
"invariant": "Candidate identity is the tuple of execution-host identity and worktree identity: same-id sibling hosts retain separate correct browser, simulator, and workspace tabs, each tab appears exactly once, and selection activates its owning host.",
|
||||
"oracle": "Aggregate two hosts' tabs under one intentionally colliding worktree id, stamp each unified tab with its execution host, and require exactly one candidate per owner. Repeat with an SSH physical owner transported by a paired-runtime alias. Independently require each browser and simulator Cmd-J selection to update activeWorkspaceExecutionHostId plus its owning workspace/group and render the selected tab active. Ambiguous legacy tabs without ownership fail closed.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/WorktreeJumpPalette.test.tsx",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/palette-repo-resolution.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts src/renderer/src/lib/palette-repo-resolution.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/browser-palette-page-entries.test.ts src/renderer/src/lib/simulator-palette-search.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/cmd-j-host-qualified-candidate-ownership.test.ts src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/browser-palette-page-entries.test.ts src/renderer/src/lib/simulator-palette-search.test.ts src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts src/renderer/src/store/slices/tabs-pane-layout-operations.test.ts src/shared/workspace-session-schema.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/cmd-j-host-qualified-candidate-ownership.test.ts src/renderer/src/lib/palette-repo-resolution.test.ts src/renderer/src/lib/browser-palette-page-entries.test.ts src/renderer/src/lib/browser-palette-search.test.ts src/renderer/src/lib/simulator-palette-search.test.ts src/renderer/src/lib/workspace-tab-palette-search.test.ts src/renderer/src/lib/browser-page-palette-activation.test.ts src/renderer/src/lib/simulator-tab-palette-activation.test.ts src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts src/renderer/src/store/slices/tabs-pane-layout-operations.test.ts src/shared/workspace-session-schema.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts src/renderer/src/components/WorktreeJumpPalette.test.tsx src/renderer/src/components/tab-bar/use-open-tab-search.test.ts src/renderer/src/lib/cmd-j-host-qualified-candidate-ownership.test.ts src/renderer/src/lib/palette-repo-resolution.test.ts src/renderer/src/lib/browser-palette-page-entries.test.ts src/renderer/src/lib/browser-palette-search.test.ts src/renderer/src/lib/simulator-palette-search.test.ts src/renderer/src/lib/workspace-tab-palette-search.test.ts src/renderer/src/lib/browser-page-palette-activation.test.ts src/renderer/src/lib/simulator-tab-palette-activation.test.ts src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts src/renderer/src/store/slices/tabs-pane-layout-operations.test.ts src/shared/workspace-session-schema.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts src/renderer/src/components/WorktreeJumpPalette.test.tsx src/renderer/src/components/tab-bar/use-open-tab-search.test.ts src/renderer/src/lib/cmd-j-host-qualified-candidate-ownership.test.ts src/renderer/src/lib/palette-repo-resolution.test.ts src/renderer/src/lib/browser-palette-page-entries.test.ts src/renderer/src/lib/browser-palette-search.test.ts src/renderer/src/lib/simulator-palette-search.test.ts src/renderer/src/lib/workspace-tab-palette-search.test.ts src/renderer/src/lib/browser-page-palette-activation.test.ts src/renderer/src/lib/simulator-tab-palette-activation.test.ts src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts src/renderer/src/lib/open-mobile-emulator-tab.test.ts src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts src/renderer/src/store/slices/tabs-pane-layout-operations.test.ts src/shared/workspace-session-schema.test.ts",
|
||||
"pnpm exec playwright test tests/e2e/paired-cmd-j-host-qualified-tabs.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-cmd-j-host-qualified-tabs.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts",
|
||||
"src/renderer/src/components/WorktreeJumpPalette.test.tsx",
|
||||
"src/renderer/src/components/tab-bar/use-open-tab-search.test.ts",
|
||||
"src/renderer/src/lib/cmd-j-host-qualified-candidate-ownership.test.ts",
|
||||
"src/renderer/src/lib/palette-repo-resolution.test.ts",
|
||||
"src/renderer/src/lib/browser-palette-page-entries.test.ts",
|
||||
"src/renderer/src/lib/browser-palette-search.test.ts",
|
||||
"src/renderer/src/lib/simulator-palette-search.test.ts",
|
||||
"src/renderer/src/lib/workspace-tab-palette-search.test.ts",
|
||||
"src/renderer/src/lib/browser-page-palette-activation.test.ts",
|
||||
"src/renderer/src/lib/simulator-tab-palette-activation.test.ts",
|
||||
"src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts",
|
||||
"src/renderer/src/lib/open-mobile-emulator-tab.test.ts",
|
||||
"src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts",
|
||||
"src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts",
|
||||
"src/renderer/src/store/slices/tabs-pane-layout-operations.test.ts",
|
||||
"src/shared/workspace-session-schema.test.ts",
|
||||
"tests/e2e/paired-cmd-j-host-qualified-tabs.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts",
|
||||
"assertions": [
|
||||
"a runtime-owned SSH worktree never exposes review metadata cached for its physical host"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/WorktreeJumpPalette.test.tsx",
|
||||
"assertions": [
|
||||
"a runtime-owned worktree row never renders repository metadata borrowed from its physical SSH host"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/cmd-j-host-qualified-candidate-ownership.test.ts",
|
||||
"assertions": [
|
||||
"browser, simulator, and workspace candidates recognize a paired-runtime alias for an SSH-owned worktree",
|
||||
"paired-runtime aliases retain current-row state and unverifiable explicit legacy owners fail closed",
|
||||
"host filtering and scoped open-tab search cannot hide a same-id sibling from the legacy ownership ambiguity set"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts",
|
||||
"assertions": [
|
||||
"active local tab creation stamps local ownership instead of leaving a same-id row ambiguous"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/palette-repo-resolution.test.ts",
|
||||
"assertions": [
|
||||
"paired SSH rows resolve display metadata through their runtime owner alias",
|
||||
"paired SSH worktrees resolve their runtime-owned repository metadata",
|
||||
"an unavailable explicit runtime owner never borrows physical-host or bare repository metadata",
|
||||
"an explicit unknown owner never borrows another host's metadata"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts",
|
||||
"assertions": [
|
||||
"same-id folder simulator reuse selects the active owning host",
|
||||
"inactive local simulator creation remains host-stamped amid a collision",
|
||||
"an explicitly owned simulator is reused before its matching worktree row hydrates"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/open-mobile-emulator-tab.test.ts",
|
||||
"assertions": ["manual emulator launch lookup and creation stay on the local provider"]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/browser-palette-page-entries.test.ts",
|
||||
"assertions": [
|
||||
"same-id local and remote browser tabs appear once under their owning hosts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/simulator-palette-search.test.ts",
|
||||
"assertions": [
|
||||
"same-id local and remote simulator tabs appear once under their owning hosts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/paired-cmd-j-host-qualified-tabs.spec.ts",
|
||||
"assertions": [
|
||||
"both browser selections activate and render their owning host and workspace",
|
||||
"both simulator selections activate and render their owning host and group"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-20",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/cmd-j-host-qualified-candidate-ownership.test.ts src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts",
|
||||
"result": "failed",
|
||||
"durationSeconds": 1.8,
|
||||
"summary": "The first review-round oracle failed 4/40: browser and simulator dropped the SSH worktree's paired-runtime-owned tab, generic workspace rows attributed the remote tab to local, and active local tab creation left ownership unstamped."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-20",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/browser-palette-page-entries.test.ts src/renderer/src/lib/simulator-palette-search.test.ts",
|
||||
"result": "failed",
|
||||
"durationSeconds": 0.5,
|
||||
"summary": "The byte-identical oracle on affected baseline 012e9f410c failed 4 tests and passed 24: each same-id browser and simulator tab was routed through both hosts, and ambiguous unstamped rows were duplicated instead of failing closed."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-20",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/browser-palette-page-entries.test.ts src/renderer/src/lib/simulator-palette-search.test.ts src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts src/renderer/src/store/slices/tabs-pane-layout-operations.test.ts src/shared/workspace-session-schema.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 2.3,
|
||||
"summary": "6 test files passed, 104 tests passed on the STA-4965 candidate."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-20",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-cmd-j-host-qualified-tabs.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 12.7,
|
||||
"summary": "The paired Electron journey passed real host browser publication, same-id collision injection, separate browser/simulator Cmd-J rows, and selected-host backing-state assertions."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-20",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec playwright test tests/e2e/paired-cmd-j-host-qualified-tabs.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 24.1,
|
||||
"summary": "A fresh electron-vite --mode e2e build passed the paired journey: the visible Cmd-J list retained one owning browser/simulator row and each selection changed the execution host plus its owning workspace or group."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-20",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/browser-palette-page-entries.test.ts src/renderer/src/lib/simulator-palette-search.test.ts",
|
||||
"result": "failed",
|
||||
"durationSeconds": 0.5,
|
||||
"summary": "The same oracle on fe45b73c20 with only browser-palette-page-entries.ts and simulator-palette-search.ts restored from origin/main failed the same 4 tests and passed 24; restored candidate files passed all 28."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-20",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/cmd-j-host-qualified-candidate-ownership.test.ts src/renderer/src/lib/palette-repo-resolution.test.ts src/renderer/src/lib/browser-palette-page-entries.test.ts src/renderer/src/lib/browser-palette-search.test.ts src/renderer/src/lib/simulator-palette-search.test.ts src/renderer/src/lib/workspace-tab-palette-search.test.ts src/renderer/src/lib/browser-page-palette-activation.test.ts src/renderer/src/lib/simulator-tab-palette-activation.test.ts src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts src/renderer/src/store/slices/tabs-pane-layout-operations.test.ts src/shared/workspace-session-schema.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 10.5,
|
||||
"summary": "The review-hardened candidate passed 189/189 focused contracts, including direct browser-result host fallback, SSH plus paired-runtime current-row aliases, explicit-owner fail-closed behavior, generic workspace rows, simulator reuse, local creation stamping, activation, and same-id distinct-runtime mirror preservation."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-20",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts src/renderer/src/lib/palette-repo-resolution.test.ts",
|
||||
"result": "failed",
|
||||
"durationSeconds": 0.8,
|
||||
"summary": "The final-review oracle failed 4/16 before the follow-up: same-id folder simulator reuse selected no owning tab, inactive local simulator creation was unstamped, the paired SSH/runtime alias resolved local metadata, and an explicit unknown owner fell back locally."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-21",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts src/renderer/src/components/WorktreeJumpPalette.test.tsx src/renderer/src/components/tab-bar/use-open-tab-search.test.ts src/renderer/src/lib/cmd-j-host-qualified-candidate-ownership.test.ts src/renderer/src/lib/palette-repo-resolution.test.ts src/renderer/src/lib/browser-palette-page-entries.test.ts src/renderer/src/lib/browser-palette-search.test.ts src/renderer/src/lib/simulator-palette-search.test.ts src/renderer/src/lib/workspace-tab-palette-search.test.ts src/renderer/src/lib/browser-page-palette-activation.test.ts src/renderer/src/lib/simulator-tab-palette-activation.test.ts src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts src/renderer/src/store/slices/tabs-pane-layout-operations.test.ts src/shared/workspace-session-schema.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 24.3,
|
||||
"summary": "The OpenCode-review-hardened candidate passed 225/225 focused contracts, including unfiltered ownership ambiguity under host filters and scoped open-tab search, review-cache ownership, rendered palette repo metadata, folder workspaces, inactive local simulator creation, paired SSH/runtime display metadata, and explicit-owner fail-closed resolution."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-20",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/palette-repo-resolution.test.ts",
|
||||
"result": "failed",
|
||||
"durationSeconds": 0.1,
|
||||
"summary": "The final reviewer repo-label oracle failed 1/6 before the follow-up: a paired SSH worktree resolved the local bare-id repository instead of its runtime-owned repository."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-20",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/palette-repo-resolution.test.ts",
|
||||
"result": "failed",
|
||||
"durationSeconds": 0.1,
|
||||
"summary": "The final clean-round fail-closed oracle failed 1/7 before the follow-up: an unavailable explicit runtime owner borrowed physical SSH repository metadata."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-20",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/WorktreeJumpPalette.test.tsx",
|
||||
"result": "failed",
|
||||
"durationSeconds": 5.1,
|
||||
"summary": "With the component's physical-host prelookup restored, the rendered palette oracle failed 1/12: the runtime-owned row displayed the physical SSH repository badge."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-20",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts",
|
||||
"result": "failed",
|
||||
"durationSeconds": 0.2,
|
||||
"summary": "The final review-cache oracle failed 1/6: a runtime-owned SSH worktree exposed the physical host's cached pull request."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-20",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts",
|
||||
"result": "failed",
|
||||
"durationSeconds": 0.4,
|
||||
"summary": "The exact-head clean-round oracle failed 1/6: after the host-qualified index rejected a physical SSH review, the palette document still surfaced its unqualified legacy cache entry for the runtime-owned worktree."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-20",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec playwright test tests/e2e/paired-cmd-j-host-qualified-tabs.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 36.7,
|
||||
"summary": "A fresh e2e-mode Electron build passed the paired journey: each remote/local browser and simulator candidate appeared exactly once, selected the owning host plus workspace or group tab, and rendered that tab active. The group active-tab oracle remains stable while asynchronous paired session mirroring updates the legacy bare-worktree activeTabType field."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-21",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-cmd-j-host-qualified-tabs.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 20.6,
|
||||
"summary": "After a fresh build exposed an unrelated mirrored terminal entering the broad backing-state diagnostic, the exact built candidate passed with that oracle scoped to the four seeded browser/simulator tabs; rendered active-tab and backing execution-host assertions remained green."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 180,
|
||||
"scope": "focused deterministic contracts plus one paired Electron journey"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "not-started",
|
||||
"evidence": "The candidate contract has no timers. Initial Electron attempts exposed seeded-worktree discovery and programmatic keyboard-selection harness races before the ownership assertions; soak history is not yet available."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "Byte-identical browser and simulator candidate tests failed 4/28 on affected baseline 012e9f410c, passed 28/28 on candidate fe45b73c20, failed the same 4/28 when only the two candidate filters were disabled, and returned to 28/28 after restoration. Review-round topology contracts failed 4/40 before the alias/generic/local-stamping follow-up; final-review contracts then failed 4/16 before folder/background/worktree-metadata hardening, 1/6 before paired repository-metadata hardening, 1/7 before unavailable-runtime fail-closed hardening, 1/12 with the component's physical-host bypass restored, 1/6 before review-cache ownership used the same resolver, 1/6 before the downstream document rejected a remote owner's unqualified legacy cache entry, and 1/6 when a host filter or scoped open-tab search hid the same-id sibling from legacy ambiguity detection. Manual local-provider ownership failed 1/12 before explicit host routing, and pre-hydration dedupe failed 1/13 before exact stamped-owner reuse; the combined contracts then passed 25/25 and the six-file review suite passed 73/73. The prior completed focused suite passed 225/225."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Candidate construction adds bounded in-memory ownership filtering over the already-read worktree tab bucket and introduces no polling or network work."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Collect paired Electron CI soak history without unexplained flakes.",
|
||||
"Add a live paired-runtime-over-SSH journey when the E2E harness can provision that combined topology."
|
||||
],
|
||||
"knownGaps": [
|
||||
"Live Linux, Windows, and paired-runtime-over-SSH journeys are not collected; the SSH/runtime dual-owner topology is deterministic renderer coverage.",
|
||||
"The paired Electron simulator path seeds renderer state because remote web clients cannot launch a native emulator.",
|
||||
"The paired Electron journey proves a real host mirror stamp before injecting the same-id sibling state; it does not create the collision through the wire.",
|
||||
"Mixed-version behavior is covered by optional-field parsing and fail-closed legacy ambiguity, not a live old-client/new-host matrix."
|
||||
],
|
||||
"demotionRule": "Cannot promote if a same-id sibling tab duplicates, disappears, activates another host, or if ambiguous unstamped ownership is guessed."
|
||||
},
|
||||
{
|
||||
"id": "ephemeral-vm-runtime.rollback-readable-sidecar",
|
||||
"title": "VM lifecycle stays rollback-readable and cleanup-retryable",
|
||||
@@ -3380,7 +3680,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 +3695,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 +3724,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 +3772,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 +3930,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 +13951,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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Modules reachable from the Orca runtime that import `electron`.
|
||||
# Generated by config/scripts/check-runtime-electron-ratchet.mjs.
|
||||
# This list is EMPTY and must stay that way: the runtime boots on plain Node
|
||||
# (see `pnpm run build:orcad`). Any entry means the runtime got less portable;
|
||||
# migrate the module behind a host port instead (src/main/host/).
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Bundle `orcad` — the Orca runtime served from plain Node, no Electron.
|
||||
*
|
||||
* Variant B (see docs/design/node-only-runtime-backend.html): the browser-pane and
|
||||
* speech clusters are excluded. That is not a size optimisation — those modules are
|
||||
* the only ones that statically import `node:sqlite`, so dropping them is what keeps
|
||||
* the host Node floor at 18 instead of 22.5+.
|
||||
*/
|
||||
import { build } from 'esbuild'
|
||||
import { mkdirSync, rmSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import process from 'node:process'
|
||||
|
||||
const ROOT = join(import.meta.dirname, '..', '..')
|
||||
const OUT_DIR = join(ROOT, 'out', 'orcad')
|
||||
const ENTRY = join(ROOT, 'src/main/orcad/main.ts')
|
||||
|
||||
// Native addons must exist on the host; they cannot be bundled.
|
||||
// `electron` is external so a residual import fails loudly at require() time rather
|
||||
// than silently bundling the npm package's installer shim, which is what happened the
|
||||
// first time and made the bundle look clean while it was not.
|
||||
// Why only these: measured, not guessed. `node-pty` is a hard `require.resolve` — orcad
|
||||
// exits at startup without it. `@parcel/watcher` is a guarded dynamic import, so the
|
||||
// server boots without it but every watch install fails. `fsevents` is macOS-only and
|
||||
// optional upstream. better-sqlite3 / keytar / cpu-features were externalized here
|
||||
// defensively and appear nowhere in the graph; listing them implied a shipping burden
|
||||
// that does not exist.
|
||||
const EXTERNAL = ['electron', 'node-pty', '@parcel/watcher', 'fsevents']
|
||||
|
||||
/** Why: the UMD build's relative dynamic requires do not bundle. Same fix build-relay.mjs uses. */
|
||||
const jsoncParserEsm = {
|
||||
name: 'jsonc-parser-esm',
|
||||
setup(pluginBuild) {
|
||||
pluginBuild.onResolve({ filter: /^jsonc-parser$/ }, () => ({
|
||||
path: join(ROOT, 'node_modules', 'jsonc-parser', 'lib', 'esm', 'main.js')
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/** Why: optional native deps reference prebuilt .node files that may not exist here. */
|
||||
const externalNativeAddons = {
|
||||
name: 'external-native-addons',
|
||||
setup(pluginBuild) {
|
||||
pluginBuild.onResolve({ filter: /\.node$/ }, (args) => ({ path: args.path, external: true }))
|
||||
}
|
||||
}
|
||||
|
||||
rmSync(OUT_DIR, { recursive: true, force: true })
|
||||
mkdirSync(OUT_DIR, { recursive: true })
|
||||
|
||||
const result = await build({
|
||||
entryPoints: [ENTRY],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
format: 'cjs',
|
||||
outfile: join(OUT_DIR, 'orcad.js'),
|
||||
external: EXTERNAL,
|
||||
plugins: [jsoncParserEsm, externalNativeAddons],
|
||||
metafile: true,
|
||||
minify: true,
|
||||
sourcemap: false,
|
||||
define: { 'process.env.NODE_ENV': '"production"' },
|
||||
logLevel: 'error'
|
||||
})
|
||||
|
||||
const output = Object.values(result.metafile.outputs).find((o) => o.entryPoint)
|
||||
// Why check `original` and not just `path`: when electron is bundleable, esbuild
|
||||
// rewrites `path` to the resolved file under node_modules and the naive check passes
|
||||
// while the package is very much in the bundle.
|
||||
const electronImporters = new Set()
|
||||
for (const [file, info] of Object.entries(result.metafile.inputs)) {
|
||||
for (const imported of info.imports ?? []) {
|
||||
const specifier = imported.original ?? imported.path
|
||||
if (specifier === 'electron' || specifier.startsWith('electron/')) {
|
||||
electronImporters.add(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (electronImporters.size > 0) {
|
||||
console.error(
|
||||
`[build-orcad] ${electronImporters.size} module(s) in the bundle import electron:
|
||||
${[...electronImporters].map((f) => ` - ${f}`).join('\n')}`
|
||||
)
|
||||
// Why this can exceed the ratchet baseline: the ratchet measures the graph reachable
|
||||
// from orca-runtime + runtime-rpc, but this entry also imports ipc/pty directly to
|
||||
// install the PTY controller. Once orcad ships, it should become a ratchet entry
|
||||
// point so the two numbers cannot drift.
|
||||
process.exitCode = 1
|
||||
} else {
|
||||
console.log(
|
||||
`[build-orcad] ok — ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron imports.`
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Ratchet gate for Electron imports reachable from the Orca runtime.
|
||||
*
|
||||
* The runtime is meant to become host-agnostic so it can also run on plain Node
|
||||
* (see docs/design/node-only-runtime-backend.html). Nothing enforces that today:
|
||||
* `orca-runtime.ts` reaches ~50 modules that import `electron`, and the number
|
||||
* silently grows whenever someone adds an import several hops away, because no
|
||||
* single reviewer sees the transitive edge.
|
||||
*
|
||||
* This bundles the runtime with esbuild, reads the metafile for every module that
|
||||
* imports `electron`, and compares that set to a checked-in baseline. A NEW module
|
||||
* fails the build; a removed one must be dropped from the baseline. The baseline
|
||||
* may only shrink, so the migration is measurable and cannot regress.
|
||||
*
|
||||
* This is a reachability check, not a lint rule: the point is precisely the edges
|
||||
* that no per-file rule can see.
|
||||
*
|
||||
* Usage: node config/scripts/check-runtime-electron-ratchet.mjs [--write]
|
||||
*/
|
||||
import { build } from 'esbuild'
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import process from 'node:process'
|
||||
|
||||
// Why absolute, not cwd-relative: `pnpm lint` runs from the repo root but CI steps and
|
||||
// editors do not always, and a cwd-relative miss surfaced as an unhandled ENOENT stack
|
||||
// instead of a usable message.
|
||||
const ROOT = path.join(import.meta.dirname, '..', '..')
|
||||
const BASELINE_PATH = path.join(ROOT, 'config', 'runtime-electron-baseline.txt')
|
||||
|
||||
// The two module graphs a Node backend would have to boot: the runtime service
|
||||
// itself and the RPC server that fronts it.
|
||||
const ENTRY_POINTS = [
|
||||
path.join(ROOT, 'src', 'main', 'runtime', 'orca-runtime.ts'),
|
||||
path.join(ROOT, 'src', 'main', 'runtime', 'runtime-rpc.ts')
|
||||
]
|
||||
|
||||
// Native addons and electron cannot be bundled; externalising them is what the
|
||||
// relay build already does (config/scripts/build-relay.mjs).
|
||||
const EXTERNAL = [
|
||||
'electron',
|
||||
'node-pty',
|
||||
'@parcel/watcher',
|
||||
'better-sqlite3',
|
||||
'keytar',
|
||||
'fsevents',
|
||||
'cpu-features'
|
||||
]
|
||||
|
||||
/**
|
||||
* Why: some optional native deps (ssh2's cpu-features) reference a prebuilt `.node`
|
||||
* that only exists where a build toolchain has run. Resolving them made this gate
|
||||
* pass on a developer machine and hard-fail on CI. Nothing here needs the addon —
|
||||
* only the import graph — so mark every `.node` external instead.
|
||||
*/
|
||||
const externalNativeAddons = {
|
||||
name: 'external-native-addons',
|
||||
setup(pluginBuild) {
|
||||
pluginBuild.onResolve({ filter: /\.node$/ }, (args) => ({ path: args.path, external: true }))
|
||||
}
|
||||
}
|
||||
|
||||
export async function collectElectronImporters(entryPoints = ENTRY_POINTS) {
|
||||
const result = await build({
|
||||
entryPoints,
|
||||
bundle: true,
|
||||
write: false,
|
||||
// Why outdir with write:false: esbuild refuses multiple entry points without one,
|
||||
// even though nothing is emitted — the metafile is all this reads.
|
||||
outdir: path.join(ROOT, 'runtime-electron-ratchet-metafile-only'),
|
||||
platform: 'node',
|
||||
target: 'node20',
|
||||
format: 'cjs',
|
||||
external: EXTERNAL,
|
||||
metafile: true,
|
||||
absWorkingDir: ROOT,
|
||||
logLevel: 'silent',
|
||||
plugins: [externalNativeAddons]
|
||||
})
|
||||
const importers = new Set()
|
||||
for (const [file, info] of Object.entries(result.metafile.inputs)) {
|
||||
for (const imported of info.imports ?? []) {
|
||||
// Subpaths (electron/main) are as unavailable under plain Node as the bare module.
|
||||
if (imported.path === 'electron' || imported.path.startsWith('electron/')) {
|
||||
importers.add(path.relative(ROOT, path.resolve(ROOT, file)).split(path.sep).join('/'))
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...importers].sort()
|
||||
}
|
||||
|
||||
export function readBaseline(text) {
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0 && !line.startsWith('#'))
|
||||
.sort()
|
||||
}
|
||||
|
||||
export function diffAgainstBaseline(current, baseline) {
|
||||
const baselineSet = new Set(baseline)
|
||||
const currentSet = new Set(current)
|
||||
return {
|
||||
added: current.filter((file) => !baselineSet.has(file)),
|
||||
removed: baseline.filter((file) => !currentSet.has(file))
|
||||
}
|
||||
}
|
||||
|
||||
function renderBaseline(files) {
|
||||
return [
|
||||
'# Modules reachable from the Orca runtime that import `electron`.',
|
||||
'# Generated by config/scripts/check-runtime-electron-ratchet.mjs.',
|
||||
'# This list is EMPTY and must stay that way: the runtime boots on plain Node',
|
||||
'# (see `pnpm run build:orcad`). Any entry means the runtime got less portable;',
|
||||
'# migrate the module behind a host port instead (src/main/host/).',
|
||||
'',
|
||||
...files
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const write = process.argv.includes('--write')
|
||||
const current = await collectElectronImporters()
|
||||
|
||||
if (write) {
|
||||
writeFileSync(BASELINE_PATH, `${renderBaseline(current)}\n`)
|
||||
console.log(`[runtime-electron-ratchet] wrote ${current.length} entries to ${BASELINE_PATH}`)
|
||||
return
|
||||
}
|
||||
|
||||
const baseline = readBaseline(readFileSync(BASELINE_PATH, 'utf8'))
|
||||
const { added, removed } = diffAgainstBaseline(current, baseline)
|
||||
|
||||
if (added.length > 0) {
|
||||
console.error(
|
||||
`[runtime-electron-ratchet] ${added.length} new module(s) reachable from the Orca runtime now import electron:
|
||||
${added.map((file) => ` + ${file}`).join('\n')}
|
||||
|
||||
The runtime must stay bootable on plain Node. Put the Electron facility behind a port in
|
||||
src/main/host/ and depend on the port, or move the code out of the runtime's import graph.
|
||||
See docs/design/node-only-runtime-backend.html.`
|
||||
)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
if (removed.length > 0) {
|
||||
console.error(
|
||||
`[runtime-electron-ratchet] ${removed.length} module(s) no longer import electron — nice.
|
||||
Refresh the baseline so the gate keeps its new, tighter floor:
|
||||
${removed.map((file) => ` - ${file}`).join('\n')}
|
||||
|
||||
node config/scripts/check-runtime-electron-ratchet.mjs --write`
|
||||
)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`[runtime-electron-ratchet] ok — ${current.length} entries, unchanged.`)
|
||||
}
|
||||
|
||||
// Why pathToFileURL and not a `file://` template: on Windows process.argv[1] is a
|
||||
// native path (C:\repo\...) while import.meta.url is file:///C:/repo/..., so the
|
||||
// template never matches and the gate would exit 0 without checking anything — a
|
||||
// lint gate that fails open. Same idiom as check-max-lines-ratchet.mjs:225.
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
await main()
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import {
|
||||
collectElectronImporters,
|
||||
diffAgainstBaseline,
|
||||
readBaseline
|
||||
} from './check-runtime-electron-ratchet.mjs'
|
||||
|
||||
describe('readBaseline', () => {
|
||||
it('drops comments and blank lines and sorts, so baseline formatting cannot cause a false diff', () => {
|
||||
expect(readBaseline('# header\n\n b/second.ts \na/first.ts\n')).toEqual([
|
||||
'a/first.ts',
|
||||
'b/second.ts'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('diffAgainstBaseline', () => {
|
||||
it('reports a module that started importing electron', () => {
|
||||
expect(diffAgainstBaseline(['a.ts', 'b.ts'], ['a.ts'])).toEqual({
|
||||
added: ['b.ts'],
|
||||
removed: []
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a module that stopped, so the baseline is forced to tighten rather than drift', () => {
|
||||
expect(diffAgainstBaseline(['a.ts'], ['a.ts', 'b.ts'])).toEqual({
|
||||
added: [],
|
||||
removed: ['b.ts']
|
||||
})
|
||||
})
|
||||
|
||||
it('is quiet when the set is unchanged', () => {
|
||||
expect(diffAgainstBaseline(['a.ts'], ['a.ts'])).toEqual({ added: [], removed: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('the checked-in baseline', () => {
|
||||
// Why real: the value of this gate is the transitive edges, which a fixture cannot model.
|
||||
// If this is slow enough to hurt, it is still cheaper than shipping a runtime that
|
||||
// cannot boot on Node.
|
||||
it('matches what the runtime actually reaches today', async () => {
|
||||
const current = await collectElectronImporters()
|
||||
const baseline = readBaseline(readFileSync('config/runtime-electron-baseline.txt', 'utf8'))
|
||||
expect(diffAgainstBaseline(current, baseline)).toEqual({ added: [], removed: [] })
|
||||
}, 120_000)
|
||||
|
||||
// Why an exact-empty assertion now: the reachable set reached zero, so "may only
|
||||
// shrink" has no room left and any entry at all is a regression. This is strictly
|
||||
// stronger than the old under-src/ check, which only stopped a node_modules path from
|
||||
// padding a non-empty count.
|
||||
it('stays empty, so nothing reachable from the runtime imports electron', () => {
|
||||
const baseline = readBaseline(readFileSync('config/runtime-electron-baseline.txt', 'utf8'))
|
||||
expect(baseline).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
|
||||
@@ -169,6 +169,12 @@ async function assertStubSourcesMatchTopics(repoRoot) {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: path.relative yields `\` on Windows, but these paths are asserted in tests and pasted into commands.
|
||||
// split(sep) rather than replaceAll('\\', '/') so a POSIX filename containing a backslash survives intact.
|
||||
function toPosixRelativePath(repoRoot, filePath, pathModule = path) {
|
||||
return pathModule.relative(repoRoot, filePath).split(pathModule.sep).join('/')
|
||||
}
|
||||
|
||||
async function buildArtifacts(repoRoot = REPO_ROOT) {
|
||||
const guideRoot = path.join(repoRoot, 'skill-guides')
|
||||
const sourceFiles = (await readdir(guideRoot, { withFileTypes: true }))
|
||||
@@ -193,7 +199,7 @@ async function buildArtifacts(repoRoot = REPO_ROOT) {
|
||||
// Why: Git may render text with native EOLs despite repository policy; the
|
||||
// embedded guide and generated projection must have one platform-neutral identity.
|
||||
const markdown = normalizeMarkdown(await readFile(sourcePath, 'utf8'))
|
||||
const frontmatter = parseFrontmatter(markdown, path.relative(repoRoot, sourcePath))
|
||||
const frontmatter = parseFrontmatter(markdown, toPosixRelativePath(repoRoot, sourcePath))
|
||||
if (frontmatter.name !== name) {
|
||||
throw new Error(`Guide source ${name}.md declares mismatched name ${frontmatter.name}`)
|
||||
}
|
||||
@@ -243,7 +249,7 @@ async function verifyArtifacts(artifacts, repoRoot = REPO_ROOT) {
|
||||
if (stale.length > 0) {
|
||||
throw new Error(
|
||||
`Generated bundled skill guides are stale:\n${stale
|
||||
.map((filePath) => path.relative(repoRoot, filePath))
|
||||
.map((filePath) => toPosixRelativePath(repoRoot, filePath))
|
||||
.join('\n')}\nRun node config/scripts/generate-bundled-skill-guides.mjs --write.`
|
||||
)
|
||||
}
|
||||
@@ -272,6 +278,7 @@ export {
|
||||
normalizeMarkdown,
|
||||
parseFrontmatter,
|
||||
serializeEmbeddedModule,
|
||||
toPosixRelativePath,
|
||||
verifyArtifacts,
|
||||
writeArtifacts
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
frontmatterBlock,
|
||||
normalizeMarkdown,
|
||||
parseFrontmatter,
|
||||
toPosixRelativePath,
|
||||
verifyArtifacts,
|
||||
writeArtifacts
|
||||
} from './generate-bundled-skill-guides.mjs'
|
||||
@@ -232,6 +233,20 @@ describe('bundled skill guide generator', () => {
|
||||
await expect(verifyArtifacts(artifacts, root)).rejects.toThrow('skills/computer-use/SKILL.md')
|
||||
})
|
||||
|
||||
// Why: the stale-artifact assertions above only hit the Windows separator when the host is
|
||||
// Windows; injecting path.win32 makes the Linux/macOS shards catch the regression too.
|
||||
it('formats contributor-facing paths with forward slashes on every platform', () => {
|
||||
expect(
|
||||
toPosixRelativePath('C:\\repo', 'C:\\repo\\src\\cli\\bundled-skill-guides.ts', path.win32)
|
||||
).toBe('src/cli/bundled-skill-guides.ts')
|
||||
expect(
|
||||
toPosixRelativePath('C:\\repo', 'C:\\repo\\skills\\computer-use\\SKILL.md', path.win32)
|
||||
).toBe('skills/computer-use/SKILL.md')
|
||||
expect(toPosixRelativePath('/repo', '/repo/skills/computer-use/SKILL.md', path.posix)).toBe(
|
||||
'skills/computer-use/SKILL.md'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects mismatched source names and ambiguous aliases', async () => {
|
||||
const root = await createFixture()
|
||||
await writeFile(
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { delimiter, join } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { parse } from 'yaml'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const action = parse(readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8'))
|
||||
const installScript = action.runs.steps.find((step) => step.name === 'Install dependencies').run
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return spawnSync(command, args, { encoding: 'utf8', ...options })
|
||||
}
|
||||
|
||||
function createFixture() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-install-node-action-'))
|
||||
const workspace = join(root, 'checkout')
|
||||
const detachedCwd = join(root, 'action cwd')
|
||||
const bin = join(root, 'bin')
|
||||
mkdirSync(workspace)
|
||||
mkdirSync(detachedCwd)
|
||||
mkdirSync(bin)
|
||||
|
||||
for (const directory of [workspace, detachedCwd]) {
|
||||
writeFileSync(join(directory, 'package.json'), '{"name":"fixture"}\n')
|
||||
writeFileSync(join(directory, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n')
|
||||
}
|
||||
|
||||
expect(run('git', ['init', '-q'], { cwd: workspace }).status).toBe(0)
|
||||
expect(run('git', ['add', 'package.json', 'pnpm-lock.yaml'], { cwd: workspace }).status).toBe(0)
|
||||
|
||||
const pnpm = join(bin, 'pnpm')
|
||||
writeFileSync(pnpm, '#!/bin/sh\nexit 0\n')
|
||||
chmodSync(pnpm, 0o755)
|
||||
return { bin, detachedCwd, root, workspace }
|
||||
}
|
||||
|
||||
function executeInstallScript(fixture) {
|
||||
return run('bash', ['-c', installScript], {
|
||||
cwd: fixture.detachedCwd,
|
||||
env: {
|
||||
...process.env,
|
||||
GITHUB_WORKSPACE: fixture.workspace,
|
||||
PATH: `${fixture.bin}${delimiter}${process.env.PATH}`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('install-node-dependencies action', () => {
|
||||
it.each([
|
||||
['package.json', '{"name":"changed"}\n'],
|
||||
['pnpm-lock.yaml', 'lockfileVersion: 9\nchanged: true\n']
|
||||
])('rejects a changed %s when the composite step cwd is detached', (file, contents) => {
|
||||
const fixture = createFixture()
|
||||
try {
|
||||
const clean = executeInstallScript(fixture)
|
||||
expect(clean.status, clean.stderr || clean.stdout).toBe(0)
|
||||
|
||||
writeFileSync(join(fixture.workspace, file), contents)
|
||||
const dirty = executeInstallScript(fixture)
|
||||
const output = `${dirty.stdout}\n${dirty.stderr}`
|
||||
expect(dirty.status).toBe(1)
|
||||
expect(output).toContain(`diff --git a/${file} b/${file}`)
|
||||
expect(output).not.toContain('diff --git a/package.json b/pnpm-lock.yaml')
|
||||
} finally {
|
||||
rmSync(fixture.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import fs from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { repairTranslatedValue } from './locale-translation-policy.mjs'
|
||||
|
||||
// The frozen-terminal recovery entry is searchable only if both halves of the pair use the
|
||||
// UI's "stopped" sense; MT rendered `unfreeze` as 녹이다 (thawing ice), splitting them.
|
||||
const PAIR = [
|
||||
{ key: 'auto.components.settings.terminal.search.88561b3499', enValue: 'frozen', ko: '정지됨' },
|
||||
{
|
||||
key: 'auto.components.settings.terminal.search.d4daf4f612',
|
||||
enValue: 'unfreeze',
|
||||
ko: '정지 해제'
|
||||
}
|
||||
]
|
||||
|
||||
function readCatalog(locale) {
|
||||
return JSON.parse(
|
||||
fs.readFileSync(
|
||||
new URL(`../../src/renderer/src/i18n/locales/${locale}.json`, import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function getValue(catalog, key) {
|
||||
return key.split('.').reduce((value, part) => value[part], catalog)
|
||||
}
|
||||
|
||||
describe('Korean frozen-terminal settings search keywords', () => {
|
||||
it('repairs the machine-translated unfreeze keyword', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.terminal.search.d4daf4f612',
|
||||
enValue: 'unfreeze',
|
||||
localeValue: '녹이다',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('정지 해제')
|
||||
})
|
||||
|
||||
it('ships the pair in the Korean catalog', () => {
|
||||
const catalog = readCatalog('ko')
|
||||
for (const { key, ko } of PAIR) {
|
||||
expect(getValue(catalog, key), key).toBe(ko)
|
||||
}
|
||||
})
|
||||
|
||||
it('survives the canonical catalog repair policy', () => {
|
||||
const english = readCatalog('en')
|
||||
const catalog = readCatalog('ko')
|
||||
for (const { key, enValue } of PAIR) {
|
||||
expect(getValue(english, key), key).toBe(enValue)
|
||||
const localeValue = getValue(catalog, key)
|
||||
expect(repairTranslatedValue({ key, enValue, localeValue, locale: 'ko' }), key).toBe(
|
||||
localeValue
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -848,6 +848,9 @@
|
||||
"auto.components.editor.CheckRunDetailsPanel.fd46a70f1a": {
|
||||
"ko": "시작됨"
|
||||
},
|
||||
"auto.components.editor.CheckRunJobs.1c0a4d7e02": {
|
||||
"ko": "성공"
|
||||
},
|
||||
"auto.components.editor.CombinedDiffFileTree.d5ac717d65": {
|
||||
"ko": "commit 안 됨"
|
||||
},
|
||||
@@ -2210,6 +2213,9 @@
|
||||
"auto.components.settings.DeveloperPermissionsPane.e119f0d66b": {
|
||||
"ko": "자동화"
|
||||
},
|
||||
"auto.components.settings.DeveloperPermissionsPane.f903bf20b5": {
|
||||
"ko": "터미널과 개발 도구가 로컬 네트워크의 서비스에 연결할 수 있도록 허용합니다. macOS는 이 권한의 현재 상태를 Orca에 보고하지 않습니다."
|
||||
},
|
||||
"auto.components.settings.ExperimentalPane.agentHibernation.idleMinutesDescription": {
|
||||
"ko": "완료된 백그라운드 agent를 Orca가 최대 절전 모드로 전환하기 전에 기다릴 유휴 시간(분)입니다."
|
||||
},
|
||||
@@ -4088,6 +4094,9 @@
|
||||
"auto.components.shared.useDaemonActions.fe2ab66d45": {
|
||||
"ko": "세션 {{value1}}개 중 {{value0}}개를 종료했습니다. {{value2}}개가 종료를 거부했습니다."
|
||||
},
|
||||
"auto.components.sidebar.AddRemoteHostDialog.sshImportSynced": {
|
||||
"ko": "{{value0}} 호스트{{value1}}을(를) Orca에 추가했습니다."
|
||||
},
|
||||
"auto.components.sidebar.AddRepoCreateStep.0ae45b8238": {
|
||||
"ko": "my-project"
|
||||
},
|
||||
@@ -5003,6 +5012,12 @@
|
||||
"auto.web.WebConnect.4a4c017be1": {
|
||||
"ko": "엔드포인트:"
|
||||
},
|
||||
"components.native-chat.approval.allow": {
|
||||
"ko": "허용"
|
||||
},
|
||||
"components.native-chat.approval.deny": {
|
||||
"ko": "거부"
|
||||
},
|
||||
"settings.appearance.statusBar.resourceUsageToggleDescription": {
|
||||
"ko": "리소스 관리자를 표시합니다. 클릭하면 CPU, 메모리, 세션, 데몬 제어, 워크스페이스 디스크 스캔을 볼 수 있습니다."
|
||||
},
|
||||
|
||||
@@ -37,6 +37,10 @@ export const SEARCH_KEYWORD_OVERRIDES = {
|
||||
prompt: '프롬프트',
|
||||
rename: '이름 변경',
|
||||
session: '세션',
|
||||
// Why: MT read 'unfreeze' as thawing ice (녹이다). Pin it to the 정지됨 pair so
|
||||
// frozen-terminal recovery stays reachable from the term the UI actually shows.
|
||||
frozen: '정지됨',
|
||||
unfreeze: '정지 해제',
|
||||
location: '위치',
|
||||
detect: '감지',
|
||||
path: '경로',
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -256,7 +256,9 @@ describe('PR workflow parallelism', () => {
|
||||
// resolution can never legitimately change anything.
|
||||
expect(dependencyInstall.run).toContain('--frozen-lockfile')
|
||||
expect(dependencyInstall.run).not.toContain('--no-frozen-lockfile')
|
||||
expect(dependencyInstall.run).toContain('git diff --exit-code package.json pnpm-lock.yaml')
|
||||
expect(dependencyInstall.run).toContain(
|
||||
'git -C "$GITHUB_WORKSPACE" diff --exit-code -- package.json pnpm-lock.yaml'
|
||||
)
|
||||
expect(dependencyInstall.run).toContain('--ignore-scripts')
|
||||
expect(dependencyInstall.run).not.toContain('--os=')
|
||||
expect(dependencyInstall.run).not.toContain('--cpu=')
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* Boots the BUILT headless runtime server (`out/main/index.js --serve`), pairs a real
|
||||
* client to it over the advertised endpoint, creates a terminal, runs a command in it,
|
||||
* and asserts the output comes back — then shuts down.
|
||||
*
|
||||
* Why this exists: "the server started" proves almost nothing. The runtime dispatches
|
||||
* terminal creation into OrcaRuntimeService, and without an installed headless PTY
|
||||
* controller that path falls through to a renderer reply that never arrives and times
|
||||
* out after ten seconds. A boot probe, a port bind, and a `host.platform` call all pass
|
||||
* against a server whose terminals are dead. Only a PTY round trip catches it.
|
||||
*
|
||||
* This is also the acceptance gate for a future Node-only backend
|
||||
* (docs/design/node-only-runtime-backend.html): the same script should pass against
|
||||
* `orcad` unchanged, because it drives nothing but the public pairing + RPC surface.
|
||||
*
|
||||
* Hard assertions (fail the job):
|
||||
* - the server emits its ready payload with a pairing offer,
|
||||
* - a paired client can list worktrees and create a terminal,
|
||||
* - a command run in that terminal produces its output,
|
||||
* - the server exits when asked.
|
||||
*/
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import process from 'node:process'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const serveEntry = join(projectDir, 'out', 'main', 'index.js')
|
||||
const ORCAD_ENTRY = join(projectDir, 'out', 'orcad', 'orcad.js')
|
||||
const READY_TIMEOUT_MS = 120_000
|
||||
const OUTPUT_TIMEOUT_MS = 30_000
|
||||
const SHUTDOWN_TIMEOUT_MS = 15_000
|
||||
// Why a random high port: a fixed one collides with a developer's own `orca serve`.
|
||||
const PORT = 6800 + Math.floor(Number(process.env.ORCA_SMOKE_PORT_OFFSET ?? '0'))
|
||||
|
||||
function log(message) {
|
||||
process.stdout.write(`[serve-terminal-smoke] ${message}\n`)
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
process.stderr.write(`[serve-terminal-smoke] FAIL: ${message}\n`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer the CLI built from this checkout over whatever `orca` is on PATH: it is the
|
||||
* version under test, and a CI runner has no installed Orca app to fall back on.
|
||||
*/
|
||||
function resolveCli() {
|
||||
const built = join(projectDir, 'out', 'cli', 'index.js')
|
||||
return existsSync(built)
|
||||
? { command: process.execPath, prefix: [built] }
|
||||
: { command: 'orca', prefix: [] }
|
||||
}
|
||||
|
||||
/** The `orca` CLI, driven with an explicit pairing code so it targets this server only. */
|
||||
function orca(pairingCode, args) {
|
||||
const cli = resolveCli()
|
||||
const result = spawnSync(
|
||||
cli.command,
|
||||
[...cli.prefix, ...args, '--pairing-code', pairingCode, '--json'],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
// Why not shell:true — argument encoding is handled by spawnSync; a shell would
|
||||
// re-split the pairing code, which is base64url and can contain '='.
|
||||
shell: false
|
||||
}
|
||||
)
|
||||
if (result.error) {
|
||||
throw new Error(`orca ${args[0]} failed to spawn: ${result.error.message}`)
|
||||
}
|
||||
const line = (result.stdout ?? '').trim()
|
||||
if (!line.startsWith('{')) {
|
||||
throw new Error(`orca ${args.join(' ')} produced no JSON:\n${result.stdout}\n${result.stderr}`)
|
||||
}
|
||||
const parsed = JSON.parse(line)
|
||||
if (parsed.ok === false) {
|
||||
throw new Error(
|
||||
`orca ${args.join(' ')} returned ${parsed.error?.code}: ${parsed.error?.message}`
|
||||
)
|
||||
}
|
||||
return parsed.result
|
||||
}
|
||||
|
||||
function waitForReady(child) {
|
||||
return new Promise((resolvePromise, rejectPromise) => {
|
||||
let buffered = ''
|
||||
let serverErr = ''
|
||||
const timer = setTimeout(
|
||||
() => rejectPromise(new Error(`no ready payload within ${READY_TIMEOUT_MS}ms`)),
|
||||
READY_TIMEOUT_MS
|
||||
)
|
||||
// Why read stderr at all: an unread pipe can fill and block the child, and without it
|
||||
// a boot failure surfaces only as "exited with 1", which says nothing actionable.
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stderr.on('data', (chunk) => {
|
||||
serverErr += chunk
|
||||
})
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stdout.on('data', (chunk) => {
|
||||
buffered += chunk
|
||||
for (const line of buffered.split('\n')) {
|
||||
if (!line.startsWith('{')) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const payload = JSON.parse(line)
|
||||
if (payload.type === 'orca_server_ready') {
|
||||
clearTimeout(timer)
|
||||
resolvePromise(payload)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Partial line; wait for the rest.
|
||||
}
|
||||
}
|
||||
})
|
||||
child.on('exit', (code) => {
|
||||
clearTimeout(timer)
|
||||
rejectPromise(
|
||||
new Error(
|
||||
`server exited with ${code} before signalling ready${
|
||||
serverErr.trim() ? `:\n${serverErr.trim()}` : ' (no stderr)'
|
||||
}`
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function pairingCodeFrom(payload) {
|
||||
const url = payload?.pairing?.url
|
||||
if (!url) {
|
||||
throw new Error('ready payload carried no pairing offer')
|
||||
}
|
||||
const code = new URL(url).searchParams.get('code')
|
||||
if (!code) {
|
||||
throw new Error(`pairing url had no code: ${url}`)
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
async function waitForNonce(pairingCode, terminalHandle, nonce) {
|
||||
const deadline = Date.now() + OUTPUT_TIMEOUT_MS
|
||||
while (Date.now() < deadline) {
|
||||
const read = orca(pairingCode, ['terminal', 'read', '--terminal', terminalHandle])
|
||||
const tail = (read?.terminal?.tail ?? []).map((entry) => String(entry)).join('\n')
|
||||
if (tail.includes(nonce)) {
|
||||
return true
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 1_000))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* The two hosts this acceptance drives.
|
||||
*
|
||||
* Everything after boot — pairing, worktree list, terminal create, the nonce round trip —
|
||||
* is identical for both. That is the point: the Node artifact has to satisfy the same
|
||||
* contract as the Electron server, proven by the same code rather than a parallel test
|
||||
* that could drift into asserting less.
|
||||
*/
|
||||
function resolveLaunch(userDataDir) {
|
||||
// Why a flag and not just an env var: package scripts have to set this on Windows too,
|
||||
// and `FOO=bar cmd` is not portable there.
|
||||
const flagIndex = process.argv.indexOf('--target')
|
||||
const target =
|
||||
flagIndex !== -1 ? process.argv[flagIndex + 1] : (process.env.ORCA_SMOKE_TARGET ?? 'electron')
|
||||
if (target === 'orcad') {
|
||||
return {
|
||||
label: `orcad (${ORCAD_ENTRY})`,
|
||||
command: process.execPath,
|
||||
args: [ORCAD_ENTRY, '--port', String(PORT), '--json'],
|
||||
env: { ORCA_USER_DATA: userDataDir }
|
||||
}
|
||||
}
|
||||
if (target !== 'electron') {
|
||||
throw new Error(
|
||||
`--target (or ORCA_SMOKE_TARGET) must be 'electron' or 'orcad', got '${target}'`
|
||||
)
|
||||
}
|
||||
const serveArgs = [
|
||||
serveEntry,
|
||||
'--serve',
|
||||
'--serve-port',
|
||||
String(PORT),
|
||||
'--serve-json',
|
||||
`--user-data-dir=${userDataDir}`
|
||||
]
|
||||
const override = process.env.ORCA_SMOKE_ELECTRON
|
||||
return {
|
||||
label: `electron (${serveEntry})`,
|
||||
command: override ?? 'npx',
|
||||
args: override ? serveArgs : ['electron', ...serveArgs],
|
||||
env: {}
|
||||
}
|
||||
}
|
||||
|
||||
/** A throwaway git repo with one commit, so `repo add` has something real to register. */
|
||||
function seedGitRepo() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-smoke-repo-'))
|
||||
writeFileSync(join(dir, 'README.md'), '# orca smoke\n')
|
||||
const git = (...args) => {
|
||||
const result = spawnSync('git', args, { cwd: dir, encoding: 'utf8' })
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`git ${args.join(' ')} failed: ${result.stderr || result.stdout}`)
|
||||
}
|
||||
}
|
||||
git('init', '-b', 'main')
|
||||
git('config', 'user.email', 'smoke@orca.test')
|
||||
git('config', 'user.name', 'Orca Smoke')
|
||||
git('add', '-A')
|
||||
git('commit', '-m', 'seed')
|
||||
return dir
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const userDataDir = mkdtempSync(join(tmpdir(), 'orca-serve-smoke-'))
|
||||
const launch = resolveLaunch(userDataDir)
|
||||
log(`booting ${launch.label} on port ${PORT} with userData ${userDataDir}`)
|
||||
|
||||
// Why tracked out here: the worktree lands in the real workspaces root, not the temp
|
||||
// profile, so the finally block has to remove it explicitly or every run leaks one.
|
||||
let seeded = null
|
||||
let pairing = null
|
||||
|
||||
const child = spawn(launch.command, launch.args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env, ...launch.env }
|
||||
})
|
||||
|
||||
try {
|
||||
const ready = await waitForReady(child)
|
||||
log(`ready: ${ready.advertisedEndpoint}`)
|
||||
const pairingCode = pairingCodeFrom(ready)
|
||||
pairing = pairingCode
|
||||
|
||||
// Why seed instead of using whatever the profile already holds: a hermetic repo makes
|
||||
// this runnable on a clean CI box, keeps the assertion deterministic, and exercises
|
||||
// repo.add + worktree.create rather than assuming someone else registered a worktree.
|
||||
const repoPath = seedGitRepo()
|
||||
seeded = { repoPath }
|
||||
log(`seeded repo at ${repoPath}`)
|
||||
const repo = orca(pairingCode, ['repo', 'add', '--path', repoPath])?.repo
|
||||
if (!repo?.id) {
|
||||
throw new Error('repo.add returned no repo id')
|
||||
}
|
||||
|
||||
const worktreeName = `smoke-${randomBytes(4).toString('hex')}`
|
||||
const created = orca(pairingCode, [
|
||||
'worktree',
|
||||
'create',
|
||||
'--repo',
|
||||
`id:${repo.id}`,
|
||||
'--name',
|
||||
worktreeName,
|
||||
'--setup',
|
||||
'skip'
|
||||
])?.worktree
|
||||
if (!created?.id) {
|
||||
throw new Error('worktree.create returned no worktree id')
|
||||
}
|
||||
seeded.worktreeId = created.id
|
||||
log(`created worktree ${created.id}`)
|
||||
|
||||
// Why `show` and not membership in `list`: list is capped, and the Electron target
|
||||
// reads a shared dev profile that can already hold more worktrees than the cap. The
|
||||
// point is that the server persisted and can resolve THIS worktree.
|
||||
const shown = orca(pairingCode, ['worktree', 'show', '--worktree', created.id])?.worktree
|
||||
if (shown?.id !== created.id) {
|
||||
throw new Error('worktree.create succeeded but worktree.show cannot resolve it')
|
||||
}
|
||||
log(`server resolves ${shown.id}`)
|
||||
|
||||
const terminal = orca(pairingCode, ['terminal', 'create', '--worktree', created.id])?.terminal
|
||||
if (!terminal?.handle) {
|
||||
throw new Error('terminal.create returned no handle')
|
||||
}
|
||||
log(`created ${terminal.handle}`)
|
||||
|
||||
// Why invoke node rather than `echo`: the shell differs per platform, node does not.
|
||||
const nonce = `ORCA_SMOKE_${randomBytes(8).toString('hex')}`
|
||||
orca(pairingCode, [
|
||||
'terminal',
|
||||
'send',
|
||||
'--terminal',
|
||||
terminal.handle,
|
||||
'--text',
|
||||
`"${process.execPath}" -e "console.log('${nonce}')"`,
|
||||
'--enter'
|
||||
])
|
||||
|
||||
if (!(await waitForNonce(pairingCode, terminal.handle, nonce))) {
|
||||
throw new Error(
|
||||
`terminal produced no output containing ${nonce} within ${OUTPUT_TIMEOUT_MS}ms — ` +
|
||||
`the server started and answered RPC, but its PTY path is dead`
|
||||
)
|
||||
}
|
||||
log('terminal round trip OK')
|
||||
} catch (error) {
|
||||
fail(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
// Why before SIGTERM: worktree removal is a server operation, so it needs the server.
|
||||
if (seeded?.worktreeId && pairing) {
|
||||
const cleanupCli = resolveCli()
|
||||
const removed = spawnSync(
|
||||
cleanupCli.command,
|
||||
[
|
||||
...cleanupCli.prefix,
|
||||
'worktree',
|
||||
'rm',
|
||||
'--worktree',
|
||||
seeded.worktreeId,
|
||||
'--pairing-code',
|
||||
pairing,
|
||||
'--force',
|
||||
'--json'
|
||||
],
|
||||
{ encoding: 'utf8' }
|
||||
)
|
||||
// Why the parent too: `worktree rm` removes the worktree directory, leaving the
|
||||
// empty `<workspaces>/<repo-name>/` container behind. Every run would leak one.
|
||||
const worktreePath = seeded.worktreeId.split('::')[1]
|
||||
if (removed.status === 0 && worktreePath) {
|
||||
rmSync(dirname(worktreePath), { recursive: true, force: true })
|
||||
}
|
||||
if (removed.status !== 0) {
|
||||
log(
|
||||
`WARN: could not remove seeded worktree ${seeded.worktreeId}: ` +
|
||||
`${(removed.stderr?.trim() || removed.stdout?.trim() || '').replace(/\s+/g, ' ')} (exit ${removed.status})`
|
||||
)
|
||||
}
|
||||
}
|
||||
// Why the exitCode guard: a server that died during boot has already exited, and
|
||||
// waiting for a second 'exit' that will never fire reported a bogus shutdown failure
|
||||
// stacked on top of the real error.
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill('SIGTERM')
|
||||
const exited = await Promise.race([
|
||||
new Promise((r) => child.on('exit', () => r(true))),
|
||||
new Promise((r) => setTimeout(() => r(false), SHUTDOWN_TIMEOUT_MS))
|
||||
])
|
||||
if (!exited) {
|
||||
child.kill('SIGKILL')
|
||||
fail(`server did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of SIGTERM`)
|
||||
}
|
||||
}
|
||||
rmSync(userDataDir, { recursive: true, force: true })
|
||||
if (seeded?.repoPath) {
|
||||
rmSync(seeded.repoPath, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
if (!process.exitCode) {
|
||||
log('PASS')
|
||||
}
|
||||
}
|
||||
|
||||
await main()
|
||||
@@ -0,0 +1,67 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, beforeEach } from 'vitest'
|
||||
import { setAppEnvironment, type AppEnvironment } from '../../src/shared/app-environment'
|
||||
import { setSecretStore } from '../../src/shared/secret-store'
|
||||
|
||||
/**
|
||||
* Why: both host ports throw until an entrypoint installs them, which is the right
|
||||
* production behaviour but would fail ~70 suites that only ever needed *some* host.
|
||||
* Install benign defaults before every test so those suites stay unchanged; a suite
|
||||
* that asserts on host behaviour installs its own and wins, because this runs first.
|
||||
*/
|
||||
|
||||
// One directory per test environment, not per test: suites that write a file in one
|
||||
// step and read it back in the next would otherwise lose it between them. Removed on
|
||||
// teardown — vitest builds one environment per test file, so without this a full run
|
||||
// leaves thousands of directories behind.
|
||||
const userData = mkdtempSync(join(tmpdir(), 'orca-vitest-userdata-'))
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(userData, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
// Deliberately not a plaintext passthrough: encryptString must return something a
|
||||
// test can tell apart from its input, or a test that forgot to seal would pass.
|
||||
const SEAL_PREFIX = 'vitest-sealed:'
|
||||
|
||||
/**
|
||||
* Build a fake environment from just the members a suite cares about. Exported so
|
||||
* suites that need a specific path or metrics fixture state only that, instead of
|
||||
* restating all seven members — which is boilerplate, and pushed one suite past
|
||||
* the max-lines budget.
|
||||
*/
|
||||
export function fakeAppEnvironment(overrides: Partial<AppEnvironment> = {}): AppEnvironment {
|
||||
return {
|
||||
getPath: (name) => (name === 'home' ? homedir() : name === 'temp' ? tmpdir() : userData),
|
||||
getAppPath: () => process.cwd(),
|
||||
getVersion: () => '0.0.0-test',
|
||||
isPackaged: () => false,
|
||||
onWillQuit: () => {},
|
||||
exit: () => {},
|
||||
getAppMetrics: () => [],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
/** Install a fake in one call — the common shape in suites that need one specific member. */
|
||||
export function installFakeAppEnvironment(overrides: Partial<AppEnvironment> = {}): void {
|
||||
setAppEnvironment(fakeAppEnvironment(overrides))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setAppEnvironment(fakeAppEnvironment())
|
||||
setSecretStore({
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (plainText) => Buffer.from(`${SEAL_PREFIX}${plainText}`),
|
||||
decryptString: (cipher) => {
|
||||
const text = cipher.toString()
|
||||
if (!text.startsWith(SEAL_PREFIX)) {
|
||||
throw new Error('vitest secret store: ciphertext was not produced by this store')
|
||||
}
|
||||
return text.slice(SEAL_PREFIX.length)
|
||||
},
|
||||
describeProtectionGap: () => null
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@
|
||||
"include": [
|
||||
"../electron.vite.config.*",
|
||||
"./build-plugins/**/*",
|
||||
"./scripts/vitest-host-ports-setup.ts",
|
||||
"../src/main/**/*",
|
||||
"../src/renderer/src/lib/skill-freshness-display-status.ts",
|
||||
"../src/preload/**/*",
|
||||
|
||||
@@ -19,7 +19,10 @@ export default defineConfig({
|
||||
// Why --expose-gc: retention tests need a deterministic collection point to measure what a queue really holds.
|
||||
execArgv: ['--no-experimental-webstorage', '--expose-gc'],
|
||||
// Why: happy-dom drops MutationObserver callbacks on GC; keep them alive like a browser does.
|
||||
setupFiles: [resolve('config/scripts/happy-dom-mutation-observer-retention.ts')],
|
||||
setupFiles: [
|
||||
resolve('config/scripts/happy-dom-mutation-observer-retention.ts'),
|
||||
resolve('config/scripts/vitest-host-ports-setup.ts')
|
||||
],
|
||||
include: [
|
||||
'src/**/*.test.ts',
|
||||
'src/**/*.test.tsx',
|
||||
|
||||
+1
-1
@@ -102,7 +102,7 @@ Orca uses shadows sparingly. Three levels in practice:
|
||||
|
||||
1. **Inset hairline** — `border` + `border` token. The default. Almost everything sits at this level.
|
||||
2. **Subtle lift** — `shadow-xs` + a single-token border. Outline buttons, embedded cards.
|
||||
3. **Floating** — `0 10px 24px rgba(0, 0, 0, 0.18)`. Popovers, popups that escape the editor surface. Reserved.
|
||||
3. **Floating** — `shadow-floating` (`0 10px 24px rgba(0, 0, 0, 0.18)`). Popovers, popups that escape the editor surface. Reserved.
|
||||
|
||||
Don't add a fourth level. If something needs more emphasis than "floating," you're probably reaching for the focus `ring` instead.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 24m">
|
||||
<title>downloads: 24m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 27m">
|
||||
<title>downloads: 27m</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">24m</text>
|
||||
<text x="90" y="14">24m</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">27m</text>
|
||||
<text x="90" y="14">27m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 935 B After Width: | Height: | Size: 935 B |
@@ -36,7 +36,7 @@
|
||||
|
||||
Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar.
|
||||
|
||||
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono.
|
||||
|
||||
- **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk)
|
||||
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
|
||||
Surveillez et pilotez vos agents depuis votre téléphone — soyez notifié quand un agent termine, et envoyez des instructions de suivi où que vous soyez.
|
||||
|
||||
[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.43](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -235,7 +235,7 @@ yay -S stably-orca-bin
|
||||
Associez-la à l'app de bureau pour surveiller et piloter vos agents depuis votre téléphone.
|
||||
|
||||
- **iOS :** [Télécharger sur l'App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [rejoindre TestFlight](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android :** [Télécharger l'APK 0.0.43](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk)
|
||||
- **Android :** [Télécharger l'APK 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。
|
||||
|
||||
- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk)
|
||||
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
|
||||
|
||||
[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) · [문서 →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -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) · [설치 가이드](https://www.onorca.dev/docs/android-apk)
|
||||
- **Android:** [APK 0.0.44 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [설치 가이드](https://www.onorca.dev/docs/android-apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
Monitore e conduza seus agentes pelo celular — receba uma notificação quando um agente terminar e envie instruções de acompanhamento de qualquer lugar.
|
||||
|
||||
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.43](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
|
||||
Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular.
|
||||
|
||||
- **iOS:** [Baixar na App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [entrar no TestFlight](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android:** [Baixar APK 0.0.43](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk)
|
||||
- **Android:** [Baixar APK 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
与桌面应用配对,用手机监控并指挥你的智能体。
|
||||
|
||||
- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk)
|
||||
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.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.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Verifying the W1–W3 Windows/WSL work
|
||||
|
||||
Three layers of coverage, because each catches what the others structurally cannot.
|
||||
|
||||
## 1. Unit — runs everywhere, every PR
|
||||
|
||||
| Suite | Pins |
|
||||
|---|---|
|
||||
| `src/main/wsl/wsl-runner.test.ts` | Separator, lane selection, fencing, WSLENV, guest cwd, script interpreter, budget split, refusal on unresolved PATH |
|
||||
| `src/main/wsl/wsl-guest-environment.test.ts` | Burst collapse, per-distro isolation, malformed-payload rejection, transient vs permanent, retry windows, joiner budget |
|
||||
| `src/main/wsl/wsl-w1-w3-contract.test.ts` | The W1→W3 chain end to end: absolute `wsl.exe`, argv array, bounded call, no `--`, script byte-identical, WSLENV, no shell on probe, login PATH still applied |
|
||||
| `src/shared/source-scan/source-tree-scan.test.ts` | The guard helpers. A guard that under-reports is worse than none |
|
||||
|
||||
## 2. Ratchets — the goalposts, enforced continuously
|
||||
|
||||
| Guard | Measures |
|
||||
|---|---|
|
||||
| `wsl-invocation-boundary.test.ts` | Files spawning `wsl.exe` outside the runner, plus bash-only payloads that fail to declare `shell: 'bash'` |
|
||||
| `windows-console-visibility.test.ts` | Direct child-process calls missing `windowsHide` |
|
||||
| `child-process-import-boundary.test.ts` | Files importing `child_process` outside the chokepoint |
|
||||
| `wsl-exec-mode-separator.test.ts` | The banned `--` separator |
|
||||
| `pty-descendant-termination-job-coverage.test.ts` | Every sweep passes `terminateOwnedTree` |
|
||||
|
||||
Each fails on a **new** offender *and* on a **stale** entry, so the count can only fall. Verify a guard by planting a violation and watching it get named — that step has found a bug in the guard itself three times.
|
||||
|
||||
## 3. Real-binary — the assertions nothing else can make
|
||||
|
||||
**Windows CI** (`package (windows)` job in `pr.yml`) rebuilds node-pty from patched source and runs the `win32` suites against a real ConPTY: a real detached grandchild, a real job kill, and the inverse — a clean `exit` must leave backgrounded work alone.
|
||||
|
||||
**Real WSL distro** — not in CI; WSL isn't available on hosted runners.
|
||||
|
||||
```
|
||||
ORCA_REAL_WSL_RUNNER_TEST=1 ORCA_WSL_TEST_DISTRO=Ubuntu-24.04 \
|
||||
pnpm vitest run src/main/wsl/wsl-runner.wsl.test.ts
|
||||
```
|
||||
|
||||
It appends `sleep 60` to the distro's `~/.profile` and asserts the probe lane still answers inside its budget — **#14288 reproduced, not simulated** — then restores the profile. Also covers banner stripping, a script carrying quotes and `$` arriving byte-identical, WSLENV crossing, and guest cwd.
|
||||
|
||||
Run this before shipping a change to `src/main/wsl/`. It is the only evidence that the probe lane does what the workstream claims, and it has already gone stale once against a runner change while passing in CI, because CI skips it.
|
||||
|
||||
## Known gaps in the windowsHide guard
|
||||
|
||||
Recorded rather than implied, because a guard that looks complete is worse than one with a documented edge.
|
||||
|
||||
- **`fork` is not scanned.** Node forwards `windowsHide` to spawn at runtime, but `ForkOptions` does not declare it, so the two live sites — `main/daemon/daemon-init.ts` and `main/plugins/plugin-host-process.ts` — cannot be fixed without a cast. Both are console-subsystem children on Windows.
|
||||
- **The allowlist is file-granular, so an allowlisted file is blind.** ~18 of its entries are false positives (`RegExp.prototype.exec`, `provider.exec`, and files the lexer desynced on), and each carries a standing pre-approval for a real regression in that file. Those entries also cannot be retired by fixing code, so the list cannot reach zero as written. Making it call-granular is the fix.
|
||||
- **`stripComments` has no desync report.** The fail-closed check runs on already-stripped text, so a regex literal containing a slash-star can still swallow code silently. No occurrence in `src/` today.
|
||||
|
||||
### Verifying a guard change
|
||||
|
||||
Plant a violation and watch it fail. Every guard fix in this workstream that was verified only by reading was wrong — three consecutive attempts at an exact lexer each shipped a desync that *reduced* the offender count, which read as progress. Plant at least: a plain call, one in a template-literal-heavy file, one in a regex-heavy file, `windowsHide: false`, a ternary first argument, and a renamed import.
|
||||
@@ -21,6 +21,7 @@ import { PickerListDrawer } from './PickerListDrawer'
|
||||
import { MobileAgentIcon } from './MobileAgentIcon'
|
||||
import { getSuggestedCreatureName } from './worktree-name-suggestion'
|
||||
import { useRetiredWorktreeNames } from '../worktree/use-retired-worktree-names'
|
||||
import { repoColor } from '../worktree/repo-color'
|
||||
import { deriveWorkspaceSshGate, workspaceSshStatusLabel } from '../tasks/workspace-ssh-gate'
|
||||
import {
|
||||
isSetupHookTrusted,
|
||||
@@ -120,15 +121,6 @@ type CreateOptions = {
|
||||
approvedSetupContentHash?: string
|
||||
}
|
||||
|
||||
function repoColor(name: string): string {
|
||||
const palette = ['#f97316', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16', '#f59e0b', '#6366f1']
|
||||
let hash = 0
|
||||
for (let i = 0; i < name.length; i += 1) {
|
||||
hash = (hash * 31 + name.charCodeAt(i)) | 0
|
||||
}
|
||||
return palette[Math.abs(hash) % palette.length]!
|
||||
}
|
||||
|
||||
function repoBadgeColor(repo: Repo | null): string {
|
||||
return repo?.badgeColor || repoColor(repo?.displayName ?? 'repository')
|
||||
}
|
||||
|
||||
@@ -121,6 +121,25 @@ describe('buildMobileAiVaultResumeCommand', () => {
|
||||
})
|
||||
|
||||
describe('buildMobileAiVaultResumeLaunch', () => {
|
||||
it('routes Kimi through the resumable-agent startup plan', () => {
|
||||
// Why: kimi joining RESUMABLE_TUI_AGENTS moves it off the plain command fallback, so the
|
||||
// cd prefix Kimi needs (sessions are work-dir-scoped) must survive the new branch.
|
||||
const launch = buildMobileAiVaultResumeLaunch({
|
||||
session: session({
|
||||
agent: 'kimi',
|
||||
sessionId: 'session_431324d7-2165-42f0-9ecd-9f93437b3201'
|
||||
}),
|
||||
hostPlatform: 'darwin'
|
||||
})
|
||||
|
||||
expect(launch).toMatchObject({
|
||||
command:
|
||||
"cd '/Users/ada/repo' && kimi '--yolo' '--session' 'session_431324d7-2165-42f0-9ecd-9f93437b3201'",
|
||||
launchConfig: { agentCommand: "kimi '--yolo'" },
|
||||
launchAgent: 'kimi'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves an arbitrary OMP transcript locator for later cold resume', () => {
|
||||
const launch = buildMobileAiVaultResumeLaunch({
|
||||
session: session({
|
||||
|
||||
@@ -16,7 +16,7 @@ type BulkCloseSheetDeps = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the long-press bulk-close entries (Close Others / Left / Right) shared
|
||||
* Builds the long-press bulk-close entries (Close Others / Left) shared
|
||||
* by every session tab sheet. Lives outside the session route to keep the
|
||||
* orchestration out of its max-lines budget; anchors are passed by tab id so
|
||||
* sheets never need the full tab object.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { selectBulkCloseTabs } from './mobile-tab-close-selection'
|
||||
import { BULK_TAB_CLOSE_ACTIONS, selectBulkCloseTabs } from './mobile-tab-close-selection'
|
||||
|
||||
const tab = (id: string, isDirty?: boolean, isPinned?: boolean) => ({
|
||||
id,
|
||||
@@ -10,6 +10,13 @@ const tab = (id: string, isDirty?: boolean, isPinned?: boolean) => ({
|
||||
describe('selectBulkCloseTabs', () => {
|
||||
const tabs = [tab('a'), tab('b'), tab('c'), tab('d')]
|
||||
|
||||
it('offers only close-others and close-left actions', () => {
|
||||
expect(BULK_TAB_CLOSE_ACTIONS).toEqual([
|
||||
{ mode: 'others', label: 'Close Other Tabs' },
|
||||
{ mode: 'left', label: 'Close Tabs to the Left' }
|
||||
])
|
||||
})
|
||||
|
||||
it('selects every tab except the anchor for mode "others"', () => {
|
||||
expect(selectBulkCloseTabs(tabs, 'b', 'others').map((t) => t.id)).toEqual(['a', 'c', 'd'])
|
||||
})
|
||||
@@ -18,13 +25,8 @@ describe('selectBulkCloseTabs', () => {
|
||||
expect(selectBulkCloseTabs(tabs, 'c', 'left').map((t) => t.id)).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('selects tabs after the anchor for mode "right"', () => {
|
||||
expect(selectBulkCloseTabs(tabs, 'b', 'right').map((t) => t.id)).toEqual(['c', 'd'])
|
||||
})
|
||||
|
||||
it('returns empty when the anchor is at the edge', () => {
|
||||
it('returns empty when the anchor is at the left edge', () => {
|
||||
expect(selectBulkCloseTabs(tabs, 'a', 'left')).toEqual([])
|
||||
expect(selectBulkCloseTabs(tabs, 'd', 'right')).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty when the anchor is not in the list', () => {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
export type BulkTabCloseMode = 'others' | 'left' | 'right'
|
||||
export type BulkTabCloseMode = 'others' | 'left'
|
||||
|
||||
/** Long-press sheet entries, in display order. */
|
||||
export const BULK_TAB_CLOSE_ACTIONS: { mode: BulkTabCloseMode; label: string }[] = [
|
||||
{ mode: 'others', label: 'Close Other Tabs' },
|
||||
{ mode: 'left', label: 'Close Tabs to the Left' },
|
||||
{ mode: 'right', label: 'Close Tabs to the Right' }
|
||||
{ mode: 'left', label: 'Close Tabs to the Left' }
|
||||
]
|
||||
|
||||
type BulkClosableTab = {
|
||||
@@ -15,7 +14,7 @@ type BulkClosableTab = {
|
||||
|
||||
/**
|
||||
* Pick the tabs a long-press bulk close ("Close Other Tabs" / "Close Tabs to
|
||||
* the Left/Right") should target, in strip order relative to the pressed tab.
|
||||
* the Left") should target, in strip order relative to the pressed tab.
|
||||
* Dirty documents are skipped — mobile has no save prompt on close, so bulk
|
||||
* closing must never silently discard unsaved edits.
|
||||
*/
|
||||
@@ -31,8 +30,6 @@ export function selectBulkCloseTabs<T extends BulkClosableTab>(
|
||||
const candidates =
|
||||
mode === 'others'
|
||||
? tabs.filter((_, index) => index !== anchorIndex)
|
||||
: mode === 'left'
|
||||
? tabs.slice(0, anchorIndex)
|
||||
: tabs.slice(anchorIndex + 1)
|
||||
: tabs.slice(0, anchorIndex)
|
||||
return candidates.filter((tab) => tab.isDirty !== true && tab.isPinned !== true)
|
||||
}
|
||||
|
||||
+8
-2
@@ -11,7 +11,7 @@
|
||||
"main": "./out/main/index.js",
|
||||
"scripts": {
|
||||
"format": "oxfmt --write .",
|
||||
"lint": "oxlint && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage",
|
||||
"lint": "oxlint && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage",
|
||||
"audit:code-quality": "pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run audit:react-doctor",
|
||||
"audit:code-quality:native": "oxlint --config config/oxlint-code-quality-native-plugins.json src config tests mobile --deny-warnings",
|
||||
"audit:code-quality:type-aware": "oxlint --type-aware --config config/oxlint-code-quality-type-aware.json src config tests --deny-warnings",
|
||||
@@ -29,6 +29,10 @@
|
||||
"test:repro:remote-agent-session": "pnpm run build:cli && pnpm run build:electron-vite && node config/scripts/remote-agent-session-authority-repro.mjs",
|
||||
"check:reliability-gates": "node config/scripts/check-reliability-gates.mjs",
|
||||
"check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs",
|
||||
"check:runtime-electron-ratchet": "node config/scripts/check-runtime-electron-ratchet.mjs",
|
||||
"build:orcad": "node config/scripts/build-orcad.mjs",
|
||||
"smoke:orcad-terminal": "node config/scripts/ensure-native-runtime.mjs --runtime=node && pnpm run build:cli && pnpm run build:orcad && node config/scripts/runtime-serve-terminal-smoke.mjs --target orcad",
|
||||
"smoke:serve-terminal": "node config/scripts/runtime-serve-terminal-smoke.mjs",
|
||||
"check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs",
|
||||
"generate:bundled-skill-guides": "node config/scripts/generate-bundled-skill-guides.mjs --write",
|
||||
"verify:bundled-skill-guides": "node config/scripts/generate-bundled-skill-guides.mjs --check",
|
||||
@@ -263,6 +267,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 +319,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": {
|
||||
|
||||
Generated
+24
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const callMock = vi.fn()
|
||||
|
||||
vi.mock('./runtime-client', () => {
|
||||
class RuntimeClient {
|
||||
call = callMock
|
||||
getCliStatus = vi.fn()
|
||||
openOrca = vi.fn()
|
||||
}
|
||||
|
||||
class RuntimeClientError extends Error {
|
||||
readonly code: string
|
||||
|
||||
constructor(code: string, message: string) {
|
||||
super(message)
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
class RuntimeRpcFailureError extends RuntimeClientError {
|
||||
readonly response: unknown
|
||||
|
||||
constructor(response: unknown) {
|
||||
super('runtime_error', 'runtime_error')
|
||||
this.response = response
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
RuntimeClient,
|
||||
RuntimeClientError,
|
||||
RuntimeRpcFailureError
|
||||
}
|
||||
})
|
||||
|
||||
import { main } from './index'
|
||||
import { okFixture, queueFixtures } from './test-fixtures'
|
||||
|
||||
// Why: StorageKeyValue requires a non-empty key but accepts any string value, empty included.
|
||||
describe('orca cli storage set preserves an empty value', () => {
|
||||
beforeEach(() => {
|
||||
callMock.mockReset()
|
||||
process.exitCode = undefined
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('passes an empty --value through to browser.storage.local.set', async () => {
|
||||
queueFixtures(callMock, okFixture('req_storage_local_set', { success: true }))
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(
|
||||
['storage', 'local', 'set', '--key', 'flag', '--value', '', '--worktree', 'all', '--json'],
|
||||
'/tmp/not-an-orca-worktree'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledTimes(1)
|
||||
expect(callMock).toHaveBeenCalledWith('browser.storage.local.set', {
|
||||
key: 'flag',
|
||||
value: '',
|
||||
worktree: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('passes an empty --value through to browser.storage.session.set', async () => {
|
||||
queueFixtures(callMock, okFixture('req_storage_session_set', { success: true }))
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(
|
||||
['storage', 'session', 'set', '--key', 'flag', '--value', '', '--worktree', 'all', '--json'],
|
||||
'/tmp/not-an-orca-worktree'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledTimes(1)
|
||||
expect(callMock).toHaveBeenCalledWith('browser.storage.session.set', {
|
||||
key: 'flag',
|
||||
value: '',
|
||||
worktree: undefined
|
||||
})
|
||||
})
|
||||
|
||||
// Why: PowerShell 5.1 drops empty native-exe args, so `--value=` is the Windows escape hatch.
|
||||
it('passes an empty --value= (equals form) through to browser.storage.local.set', async () => {
|
||||
queueFixtures(callMock, okFixture('req_storage_local_set', { success: true }))
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(
|
||||
['storage', 'local', 'set', '--key=flag', '--value=', '--worktree', 'all', '--json'],
|
||||
'/tmp/not-an-orca-worktree'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledTimes(1)
|
||||
expect(callMock).toHaveBeenCalledWith('browser.storage.local.set', {
|
||||
key: 'flag',
|
||||
value: '',
|
||||
worktree: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('still rejects an empty --key before RPC dispatch', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const priorExitCode = process.exitCode
|
||||
|
||||
await main(
|
||||
['storage', 'local', 'set', '--key', '', '--value', 'x', '--worktree', 'all'],
|
||||
'/tmp/not-an-orca-worktree'
|
||||
)
|
||||
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
expect(errorSpy.mock.calls.flat().join('\n')).toContain('Missing required --key')
|
||||
expect(process.exitCode).toBe(1)
|
||||
|
||||
process.exitCode = priorExitCode
|
||||
})
|
||||
|
||||
it('still rejects a missing --value before RPC dispatch', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const priorExitCode = process.exitCode
|
||||
|
||||
await main(
|
||||
['storage', 'local', 'set', '--key', 'flag', '--worktree', 'all'],
|
||||
'/tmp/not-an-orca-worktree'
|
||||
)
|
||||
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
expect(errorSpy.mock.calls.flat().join('\n')).toContain('Missing required --value')
|
||||
expect(process.exitCode).toBe(1)
|
||||
|
||||
process.exitCode = priorExitCode
|
||||
})
|
||||
})
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import { printResult } from '../format'
|
||||
import { getRequiredStringFlag } from '../flags'
|
||||
import { getRequiredStringFlag, getRequiredStringFlagAllowingEmpty } from '../flags'
|
||||
import { getBrowserCommandTarget } from '../selectors'
|
||||
|
||||
export const BROWSER_STORAGE_HANDLERS: Record<string, CommandHandler> = {
|
||||
@@ -12,7 +12,8 @@ export const BROWSER_STORAGE_HANDLERS: Record<string, CommandHandler> = {
|
||||
},
|
||||
'storage local set': async ({ flags, client, cwd, json }) => {
|
||||
const key = getRequiredStringFlag(flags, 'key')
|
||||
const value = getRequiredStringFlag(flags, 'value')
|
||||
// Why: the server accepts any string value; setItem(key, '') differs from an unset key.
|
||||
const value = getRequiredStringFlagAllowingEmpty(flags, 'value')
|
||||
const target = await getBrowserCommandTarget(flags, cwd, client)
|
||||
const result = await client.call<unknown>('browser.storage.local.set', {
|
||||
key,
|
||||
@@ -34,7 +35,7 @@ export const BROWSER_STORAGE_HANDLERS: Record<string, CommandHandler> = {
|
||||
},
|
||||
'storage session set': async ({ flags, client, cwd, json }) => {
|
||||
const key = getRequiredStringFlag(flags, 'key')
|
||||
const value = getRequiredStringFlag(flags, 'value')
|
||||
const value = getRequiredStringFlagAllowingEmpty(flags, 'value')
|
||||
const target = await getBrowserCommandTarget(flags, cwd, client)
|
||||
const result = await client.call<unknown>('browser.storage.session.set', {
|
||||
key,
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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.'
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -59,15 +59,23 @@ __orca_omp() {
|
||||
fi
|
||||
}
|
||||
if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then
|
||||
omp() { __orca_omp "$@"; }
|
||||
# Why the function reserved word: it suppresses alias expansion of the name, which
|
||||
# an `alias omp` otherwise rewrites at parse time, aborting the rest of the file.
|
||||
function omp { __orca_omp "$@"; }
|
||||
fi
|
||||
|
||||
# Why: Codex must keep using Orca's runtime CODEX_HOME after profile scripts.
|
||||
[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"
|
||||
# Why: a typed alias expands inside the shell, after pane launch prep.
|
||||
__orca_codex_binary="$(command -v codex 2>/dev/null || :)"
|
||||
# Why unalias inside the substitution: an alias named codex makes command -v
|
||||
# report the alias text, and the subshell leaves the user's own alias intact.
|
||||
# Why || : twice — zsh alone aborts inside the substitution, but every shell's
|
||||
# assignment adopts its exit status, so an absent codex trips set -e in bash too.
|
||||
__orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)"
|
||||
if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then
|
||||
codex() {
|
||||
# Why the function reserved word: it suppresses alias expansion of the name,
|
||||
# which otherwise rewrites this header at parse time and aborts the whole file.
|
||||
function codex {
|
||||
"${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex >/dev/null 2>&1 || :
|
||||
command codex "$@"
|
||||
}
|
||||
|
||||
@@ -97,15 +97,23 @@ __orca_deferred_init() {
|
||||
fi
|
||||
}
|
||||
if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then
|
||||
omp() { __orca_omp "$@"; }
|
||||
# Why the function reserved word: it suppresses alias expansion of the name, which
|
||||
# an `alias omp` otherwise rewrites at parse time, aborting the rest of the file.
|
||||
function omp { __orca_omp "$@"; }
|
||||
fi
|
||||
|
||||
# Why: Codex must keep using Orca's runtime CODEX_HOME after rc files.
|
||||
[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"
|
||||
# Why: a typed alias expands inside the shell, after pane launch prep.
|
||||
__orca_codex_binary="$(command -v codex 2>/dev/null || :)"
|
||||
# Why unalias inside the substitution: an alias named codex makes command -v
|
||||
# report the alias text, and the subshell leaves the user's own alias intact.
|
||||
# Why || : twice — zsh alone aborts inside the substitution, but every shell's
|
||||
# assignment adopts its exit status, so an absent codex trips set -e in bash too.
|
||||
__orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)"
|
||||
if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then
|
||||
codex() {
|
||||
# Why the function reserved word: it suppresses alias expansion of the name,
|
||||
# which otherwise rewrites this header at parse time and aborts the whole file.
|
||||
function codex {
|
||||
"${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex >/dev/null 2>&1 || :
|
||||
command codex "$@"
|
||||
}
|
||||
|
||||
@@ -62,15 +62,23 @@ __orca_omp() {
|
||||
fi
|
||||
}
|
||||
if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then
|
||||
omp() { __orca_omp "$@"; }
|
||||
# Why the function reserved word: it suppresses alias expansion of the name, which
|
||||
# an `alias omp` otherwise rewrites at parse time, aborting the rest of the file.
|
||||
function omp { __orca_omp "$@"; }
|
||||
fi
|
||||
|
||||
# Why: Codex must keep using Orca's runtime CODEX_HOME after profile scripts.
|
||||
[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"
|
||||
# Why: a typed alias expands inside the shell, after pane launch prep.
|
||||
__orca_codex_binary="$(command -v codex 2>/dev/null || :)"
|
||||
# Why unalias inside the substitution: an alias named codex makes command -v
|
||||
# report the alias text, and the subshell leaves the user's own alias intact.
|
||||
# Why || : twice — zsh alone aborts inside the substitution, but every shell's
|
||||
# assignment adopts its exit status, so an absent codex trips set -e in bash too.
|
||||
__orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)"
|
||||
if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then
|
||||
codex() {
|
||||
# Why the function reserved word: it suppresses alias expansion of the name,
|
||||
# which otherwise rewrites this header at parse time and aborts the whole file.
|
||||
function codex {
|
||||
"${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex >/dev/null 2>&1 || :
|
||||
command codex "$@"
|
||||
}
|
||||
|
||||
@@ -97,15 +97,23 @@ __orca_deferred_init() {
|
||||
fi
|
||||
}
|
||||
if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then
|
||||
omp() { __orca_omp "$@"; }
|
||||
# Why the function reserved word: it suppresses alias expansion of the name, which
|
||||
# an `alias omp` otherwise rewrites at parse time, aborting the rest of the file.
|
||||
function omp { __orca_omp "$@"; }
|
||||
fi
|
||||
|
||||
# Why: Codex must keep using Orca's runtime CODEX_HOME after rc files.
|
||||
[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"
|
||||
# Why: a typed alias expands inside the shell, after pane launch prep.
|
||||
__orca_codex_binary="$(command -v codex 2>/dev/null || :)"
|
||||
# Why unalias inside the substitution: an alias named codex makes command -v
|
||||
# report the alias text, and the subshell leaves the user's own alias intact.
|
||||
# Why || : twice — zsh alone aborts inside the substitution, but every shell's
|
||||
# assignment adopts its exit status, so an absent codex trips set -e in bash too.
|
||||
__orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)"
|
||||
if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then
|
||||
codex() {
|
||||
# Why the function reserved word: it suppresses alias expansion of the name,
|
||||
# which otherwise rewrites this header at parse time and aborts the whole file.
|
||||
function codex {
|
||||
"${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex >/dev/null 2>&1 || :
|
||||
command codex "$@"
|
||||
}
|
||||
|
||||
@@ -51,7 +51,9 @@ __orca_omp() {
|
||||
fi
|
||||
}
|
||||
if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then
|
||||
omp() { __orca_omp "$@"; }
|
||||
# Why the function reserved word: it suppresses alias expansion of the name, which
|
||||
# an `alias omp` otherwise rewrites at parse time, aborting the rest of the file.
|
||||
function omp { __orca_omp "$@"; }
|
||||
fi
|
||||
|
||||
if [[ -n "${ORCA_HISTFILE:-}" ]]; then
|
||||
|
||||
@@ -71,7 +71,9 @@ __orca_deferred_init() {
|
||||
fi
|
||||
}
|
||||
if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then
|
||||
omp() { __orca_omp "$@"; }
|
||||
# Why the function reserved word: it suppresses alias expansion of the name, which
|
||||
# an `alias omp` otherwise rewrites at parse time, aborting the rest of the file.
|
||||
function omp { __orca_omp "$@"; }
|
||||
fi
|
||||
fi
|
||||
if [[ -n "${_orca_histfile:-}" ]]; then
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence'
|
||||
import type { HooksConfig } from './installer-utils'
|
||||
|
||||
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
@@ -26,16 +27,17 @@ export function parseHooksJsonText(raw: string): HooksConfig | null {
|
||||
// bytes it was derived from; the raw snapshot and the parse must come from one
|
||||
// read or a concurrent save can slip between them unnoticed.
|
||||
export function readHooksJsonWithRaw(configPath: string): HooksJsonSnapshot {
|
||||
if (!existsSync(configPath)) {
|
||||
return { raw: null, config: {} }
|
||||
}
|
||||
let raw: string
|
||||
// Why: the read arm below already separates "no hooks configured" from "could
|
||||
// not read", but the `existsSync` arm in front of it did not — it returned a
|
||||
// VALID EMPTY config for a file that merely could not be opened, and the
|
||||
// installer then wrote generated hooks over it. One read classifies both, and
|
||||
// closes the TOCTOU window between the two calls.
|
||||
try {
|
||||
raw = readFileSync(configPath, 'utf-8')
|
||||
} catch {
|
||||
return { raw: null, config: null }
|
||||
const raw = readFileSync(configPath, 'utf-8')
|
||||
return { raw, config: parseHooksJsonText(raw) }
|
||||
} catch (error) {
|
||||
return isDefinitiveAbsence(error) ? { raw: null, config: {} } : { raw: null, config: null }
|
||||
}
|
||||
return { raw, config: parseHooksJsonText(raw) }
|
||||
}
|
||||
|
||||
export function readHooksJson(configPath: string): HooksConfig | null {
|
||||
|
||||
@@ -515,6 +515,12 @@ describe('AgentHookServer listener replay', () => {
|
||||
// Why: closedAgentStatusTabIds is LRU-bounded, so the tab fence is not permanent.
|
||||
// If restoring a sibling key lifted the closed tab's PANE fence too, eviction of the
|
||||
// tab id would leave nothing at all holding that pane shut.
|
||||
//
|
||||
// Why a non-boundary event: a genuine new turn deliberately lifts the pane fence
|
||||
// (STA-3386), so it cannot be used to prove the fence exists. Measured on the pre-fix
|
||||
// tree, `claude` + `UserPromptSubmit` already revived this pane; this case only ever
|
||||
// passed with `before_agent_start` because the gate matched two raw literals and did not
|
||||
// recognize pi's boundary. The control below keeps it from passing for that reason again.
|
||||
it('leaves a closed tab pane fenced once its tab id is evicted', async () => {
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production' })
|
||||
@@ -545,10 +551,18 @@ describe('AgentHookServer listener replay', () => {
|
||||
}
|
||||
|
||||
await postHook(
|
||||
{ hook_event_name: 'before_agent_start', prompt: 'after eviction' },
|
||||
{ hook_event_name: 'agent_end', prompt: 'after eviction' },
|
||||
{ paneKey: detachedPane, tabId: 'tab-2' }
|
||||
)
|
||||
expect(server.getStatusSnapshot()).toEqual([])
|
||||
|
||||
// Why: proves the empty snapshot above came from the fence and not from an inert
|
||||
// event — the same post on an unfenced pane must produce a row.
|
||||
await postHook(
|
||||
{ hook_event_name: 'agent_end', prompt: 'unfenced control' },
|
||||
{ paneKey: makePaneKey('tab-9', LEAF_3), tabId: 'tab-9' }
|
||||
)
|
||||
expect(server.getStatusSnapshot()).toHaveLength(1)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
|
||||
@@ -51,6 +51,33 @@ describe('Copilot hook normalization', () => {
|
||||
expect(result?.payload.prompt).toBe('camel event')
|
||||
})
|
||||
|
||||
it('captures the Copilot provider session from SessionStart and Stop', () => {
|
||||
const sessionId = '940237d9-c712-48e8-bca1-fd75fc4a8d4b'
|
||||
const started = _internals.normalizeHookPayload(
|
||||
'copilot',
|
||||
buildBody({ hook_event_name: 'SessionStart', session_id: sessionId }),
|
||||
'production'
|
||||
)
|
||||
expect(started?.providerSession).toEqual({ key: 'session_id', id: sessionId })
|
||||
|
||||
const stopped = _internals.normalizeHookPayload(
|
||||
'copilot',
|
||||
buildBody({ hook_event_name: 'Stop', session_id: sessionId }),
|
||||
'production'
|
||||
)
|
||||
expect(stopped?.payload.state).toBe('done')
|
||||
expect(stopped?.providerSession).toEqual({ key: 'session_id', id: sessionId })
|
||||
})
|
||||
|
||||
it('captures the Copilot provider session from a camelCase sessionId', () => {
|
||||
const result = _internals.normalizeHookPayload(
|
||||
'copilot',
|
||||
buildBody({ hookEventName: 'agentStop', sessionId: 'copilot-camel' }),
|
||||
'production'
|
||||
)
|
||||
expect(result?.providerSession).toEqual({ key: 'session_id', id: 'copilot-camel' })
|
||||
})
|
||||
|
||||
it('infers Copilot user prompt payloads that omit hook_event_name', () => {
|
||||
const result = _internals.normalizeHookPayload(
|
||||
'copilot',
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import { AgentHookServer } from './server'
|
||||
|
||||
const PANE = makePaneKey('tab-opencode', '11111111-1111-4111-8111-111111111111')
|
||||
const TARGET_PANE = makePaneKey('tab-opencode', '22222222-2222-4222-8222-222222222222')
|
||||
|
||||
describe('AgentHookServer OpenCode lifecycle', () => {
|
||||
const servers: AgentHookServer[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const server of servers) {
|
||||
server.stop()
|
||||
}
|
||||
servers.length = 0
|
||||
})
|
||||
|
||||
async function setup(): Promise<{
|
||||
server: AgentHookServer
|
||||
post: (
|
||||
payload: Record<string, unknown>,
|
||||
launchToken: string,
|
||||
paneKey?: string,
|
||||
source?: 'opencode' | 'mimo-code'
|
||||
) => Promise<Response>
|
||||
}> {
|
||||
const server = new AgentHookServer()
|
||||
servers.push(server)
|
||||
await server.start({ env: 'production' })
|
||||
const env = server.buildPtyEnv()
|
||||
return {
|
||||
server,
|
||||
post: (payload, launchToken, paneKey = PANE, source = 'opencode') =>
|
||||
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/${source}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
|
||||
},
|
||||
body: JSON.stringify({
|
||||
paneKey,
|
||||
launchToken,
|
||||
tabId: 'tab-opencode',
|
||||
worktreeId: 'wt-opencode',
|
||||
env: 'production',
|
||||
payload
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
it('accepts Busy after a retired pane receives a root SessionStart', async () => {
|
||||
const { server, post } = await setup()
|
||||
await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token')
|
||||
server.retirePaneAuthority(PANE)
|
||||
|
||||
await post({ hook_event_name: 'SessionStart', sessionID: 'fresh' }, 'fresh-token')
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
paneKey: PANE,
|
||||
state: 'done',
|
||||
sessionBoundary: true,
|
||||
providerSession: { key: 'session_id', id: 'fresh' }
|
||||
})
|
||||
])
|
||||
|
||||
await post({ hook_event_name: 'SessionBusy', sessionID: 'fresh' }, 'fresh-token')
|
||||
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({ paneKey: PANE, state: 'working', agentType: 'opencode' })
|
||||
])
|
||||
})
|
||||
|
||||
it('accepts a resumed fresh user MessagePart but not arbitrary Busy', async () => {
|
||||
const { server, post } = await setup()
|
||||
await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token')
|
||||
server.retirePaneAuthority(PANE)
|
||||
|
||||
await post({ hook_event_name: 'SessionBusy', sessionID: 'resumed' }, 'resume-token')
|
||||
expect(server.getStatusSnapshot()).toEqual([])
|
||||
|
||||
await post(
|
||||
{
|
||||
hook_event_name: 'MessagePart',
|
||||
role: 'user',
|
||||
text: 'continue the task',
|
||||
messageID: 'message-resumed',
|
||||
sessionID: 'resumed'
|
||||
},
|
||||
'resume-token'
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({ state: 'working', prompt: 'continue the task' })
|
||||
])
|
||||
})
|
||||
|
||||
it('maps question.asked attention to Waiting after restart', async () => {
|
||||
const { server, post } = await setup()
|
||||
await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token')
|
||||
server.retirePaneAuthority(PANE)
|
||||
await post({ hook_event_name: 'SessionStart', sessionID: 'fresh' }, 'fresh-token')
|
||||
|
||||
await post(
|
||||
{ hook_event_name: 'AskUserQuestion', id: 'question-1', sessionID: 'fresh' },
|
||||
'fresh-token'
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({ state: 'waiting', agentType: 'opencode' })
|
||||
])
|
||||
})
|
||||
|
||||
it('suppresses stale old-token Busy after a fresh restart', async () => {
|
||||
const { server, post } = await setup()
|
||||
await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token')
|
||||
server.retirePaneAuthority(PANE)
|
||||
await post({ hook_event_name: 'SessionStart', sessionID: 'fresh' }, 'fresh-token')
|
||||
await post({ hook_event_name: 'SessionBusy', sessionID: 'fresh' }, 'fresh-token')
|
||||
|
||||
await post(
|
||||
{ hook_event_name: 'SessionBusy', sessionID: 'old', prompt: 'stale prompt' },
|
||||
'old-token'
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({ state: 'working', prompt: '' })
|
||||
])
|
||||
})
|
||||
|
||||
it('clears the destination token fence when pane authority transfers', async () => {
|
||||
const { server, post } = await setup()
|
||||
await post(
|
||||
{ hook_event_name: 'SessionBusy', sessionID: 'target-old' },
|
||||
'target-old-token',
|
||||
TARGET_PANE
|
||||
)
|
||||
server.retirePaneAuthority(TARGET_PANE)
|
||||
await post(
|
||||
{ hook_event_name: 'SessionStart', sessionID: 'target-fresh' },
|
||||
'target-fresh-token',
|
||||
TARGET_PANE
|
||||
)
|
||||
await post({ hook_event_name: 'SessionBusy', sessionID: 'source' }, 'source-token')
|
||||
|
||||
// The destination fence rejects the source token until the transfer removes it.
|
||||
await post(
|
||||
{ hook_event_name: 'SessionBusy', sessionID: 'source-before-transfer' },
|
||||
'source-token',
|
||||
TARGET_PANE
|
||||
)
|
||||
expect(
|
||||
server.getStatusSnapshot().find((entry) => entry.paneKey === TARGET_PANE)?.providerSession
|
||||
).toEqual({ key: 'session_id', id: 'target-fresh' })
|
||||
|
||||
server.transferPaneAuthority(PANE, TARGET_PANE, 'pty-opencode')
|
||||
await post(
|
||||
{ hook_event_name: 'SessionBusy', sessionID: 'source-after-transfer' },
|
||||
'source-token',
|
||||
TARGET_PANE
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
paneKey: TARGET_PANE,
|
||||
providerSession: { key: 'session_id', id: 'source-after-transfer' }
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('re-fences on a new SessionStart while the pane stays authorized', async () => {
|
||||
const { server, post } = await setup()
|
||||
await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token')
|
||||
server.retirePaneAuthority(PANE)
|
||||
await post({ hook_event_name: 'SessionStart', sessionID: 'restart' }, 'restart-token')
|
||||
await post({ hook_event_name: 'SessionBusy', sessionID: 'restart' }, 'restart-token')
|
||||
// The stale old-token follow-up stays fenced out.
|
||||
await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token')
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({ providerSession: { key: 'session_id', id: 'restart' } })
|
||||
])
|
||||
|
||||
// Why: the runtime defers retirement while an agent stays in the foreground, so a
|
||||
// genuinely new process can start in a still-authorized pane. Its SessionStart must
|
||||
// replace the stale fence — not be swallowed as a stale event (rowless reuse).
|
||||
await post({ hook_event_name: 'SessionStart', sessionID: 'fresh' }, 'fresh-token')
|
||||
await post({ hook_event_name: 'SessionBusy', sessionID: 'fresh' }, 'fresh-token')
|
||||
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({ providerSession: { key: 'session_id', id: 'fresh' } })
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the fence when a live pane sees a tokenless SessionStart', async () => {
|
||||
const { server, post } = await setup()
|
||||
await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token')
|
||||
server.retirePaneAuthority(PANE)
|
||||
await post({ hook_event_name: 'SessionStart', sessionID: 'restart' }, 'restart-token')
|
||||
await post({ hook_event_name: 'SessionBusy', sessionID: 'restart' }, 'restart-token')
|
||||
|
||||
// Why: an untokened boundary cannot prove which process it belongs to, so dropping
|
||||
// the fence for it would reopen the pane to every stale token.
|
||||
await post({ hook_event_name: 'SessionStart', sessionID: 'tokenless' }, '')
|
||||
await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token')
|
||||
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({ providerSession: { key: 'session_id', id: 'restart' } })
|
||||
])
|
||||
})
|
||||
|
||||
it('does not let a stale explicit prompt re-fence a live pane', async () => {
|
||||
const { server, post } = await setup()
|
||||
await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token')
|
||||
server.retirePaneAuthority(PANE)
|
||||
await post({ hook_event_name: 'SessionStart', sessionID: 'restart' }, 'restart-token')
|
||||
await post({ hook_event_name: 'SessionBusy', sessionID: 'restart' }, 'restart-token')
|
||||
|
||||
// Why: prompts recur mid-session, so honoring one as a process boundary would hand
|
||||
// the pane back to any still-live stale process.
|
||||
await post(
|
||||
{
|
||||
hook_event_name: 'MessagePart',
|
||||
role: 'user',
|
||||
text: 'stale prompt',
|
||||
messageID: 'message-stale',
|
||||
sessionID: 'old'
|
||||
},
|
||||
'old-token'
|
||||
)
|
||||
await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token')
|
||||
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({ providerSession: { key: 'session_id', id: 'restart' }, prompt: '' })
|
||||
])
|
||||
})
|
||||
|
||||
it('restarts a retired mimo-code pane on an explicit user prompt', async () => {
|
||||
const { server, post } = await setup()
|
||||
await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token', PANE, 'mimo-code')
|
||||
server.retirePaneAuthority(PANE)
|
||||
|
||||
// Why: mimo-code emits no SessionStart, so the explicit prompt is its only restart
|
||||
// boundary — excluding it would strand every retired mimo-code pane.
|
||||
await post(
|
||||
{
|
||||
hook_event_name: 'MessagePart',
|
||||
role: 'user',
|
||||
text: 'continue the task',
|
||||
messageID: 'message-resumed',
|
||||
sessionID: 'resumed'
|
||||
},
|
||||
'resume-token',
|
||||
PANE,
|
||||
'mimo-code'
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({ state: 'working', prompt: 'continue the task' })
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentHookSource } from '../../shared/agent-hook-relay'
|
||||
import { AgentHookServer, _internals } from './server'
|
||||
import { PANE } from './server.test-fixtures'
|
||||
|
||||
const { trackMock, getCohortAtEmitMock } = vi.hoisted(() => ({
|
||||
trackMock: vi.fn(),
|
||||
getCohortAtEmitMock: vi.fn()
|
||||
}))
|
||||
vi.mock('../telemetry/client', () => ({ track: trackMock }))
|
||||
vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtEmitMock }))
|
||||
|
||||
beforeEach(() => {
|
||||
_internals.resetCachesForTests()
|
||||
trackMock.mockReset()
|
||||
getCohortAtEmitMock.mockReset()
|
||||
getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 })
|
||||
})
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
/** Each source's own new-turn boundary, as `isNewTurnEvent` classifies it. `null` means the
|
||||
* classifier names no boundary for that source. That is not the same as "can never revive":
|
||||
* mimo-code's boundary is an explicit-prompt MessagePart, which the gate handles separately. */
|
||||
const NEW_TURN_EVENT: Record<AgentHookSource, string | null> = {
|
||||
claude: 'SessionStart',
|
||||
kimi: 'UserPromptSubmit',
|
||||
codex: 'SessionStart',
|
||||
gemini: 'BeforeAgent',
|
||||
antigravity: 'PreInvocation',
|
||||
amp: 'agent.start',
|
||||
cursor: 'beforeSubmitPrompt',
|
||||
pi: 'before_agent_start',
|
||||
omp: 'before_agent_start',
|
||||
'prime-agent': 'before_agent_start',
|
||||
droid: 'UserPromptSubmit',
|
||||
grok: 'user_prompt_submit',
|
||||
copilot: 'sessionStart',
|
||||
hermes: 'pre_llm_call',
|
||||
devin: 'UserPromptSubmit',
|
||||
opencode: 'SessionStart',
|
||||
'mimo-code': null,
|
||||
'command-code': null
|
||||
}
|
||||
|
||||
function reviveRetiredPane(source: unknown, hookEventName: string): boolean {
|
||||
const server = new AgentHookServer()
|
||||
// Why: retirement is what command completion leaves behind on a reusable shell pane.
|
||||
server.retirePaneAuthority(PANE)
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
...(source === undefined ? {} : { source }),
|
||||
hookEventName,
|
||||
payload: {
|
||||
state: 'working',
|
||||
prompt: 'after reuse',
|
||||
agentType: typeof source === 'string' ? source : 'claude'
|
||||
}
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
return server.getStatusSnapshot().some((entry) => entry.paneKey === PANE)
|
||||
}
|
||||
|
||||
describe("retired pane un-retires on each provider's own new-turn event", () => {
|
||||
// Why: the gate matched two raw literals, so only the 5 sources that happen to name their
|
||||
// boundary UserPromptSubmit/SessionStart could ever revive — the rest stayed rowless forever.
|
||||
// Why: keys of a Record<AgentHookSource, …> — a new source fails typecheck here rather than
|
||||
// silently skipping coverage, which is the same guarantee the runtime list would give.
|
||||
const revivable = (Object.keys(NEW_TURN_EVENT) as AgentHookSource[]).filter(
|
||||
(source) => NEW_TURN_EVENT[source] !== null
|
||||
)
|
||||
|
||||
it.each(revivable)('%s', (source) => {
|
||||
const hookEventName = NEW_TURN_EVENT[source]
|
||||
expect(hookEventName).not.toBeNull()
|
||||
expect(reviveRetiredPane(source, hookEventName as string)).toBe(true)
|
||||
})
|
||||
|
||||
// Why these two: every case above passes `source`, so the source-less compatibility path —
|
||||
// the branch added for older relays — would otherwise ship with no coverage at all.
|
||||
it('revives on a literal boundary when an older relay omits source', () => {
|
||||
expect(reviveRetiredPane(undefined, 'UserPromptSubmit')).toBe(true)
|
||||
})
|
||||
|
||||
it('cannot revive a non-literal provider when an older relay omits source', () => {
|
||||
// Why pinned: this is the accepted cost of the legacy shim, not an oversight. Widening the
|
||||
// literal list to "fix" it would re-create the two-literal gate this change removes.
|
||||
expect(reviveRetiredPane(undefined, 'before_agent_start')).toBe(false)
|
||||
})
|
||||
|
||||
it('revives on any event from a provider this build does not recognize', () => {
|
||||
// Why fail open: a newer host can relay a 19th provider whose boundary name is unknown here.
|
||||
// `isAgentHookSource` rejects it, so it must not fall through to the legacy literals and
|
||||
// strand the pane permanently.
|
||||
expect(reviveRetiredPane('future-provider-19' as AgentHookSource, 'SomethingNewEntirely')).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it.each([null, '', 19, { provider: 'future-provider-19' }])(
|
||||
'keeps malformed source value %j behind the retired-pane fence',
|
||||
(source) => {
|
||||
expect(
|
||||
reviveRetiredPane(source, source === null ? 'SessionStart' : 'SomethingNewEntirely')
|
||||
).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it('leaves the pane retired for a source with no turn boundary', () => {
|
||||
// Why mimo-code and command-code: neither names a boundary through `isNewTurnEvent`, so
|
||||
// SessionStart must not open the gate for them. Mimo-code still revives on its own
|
||||
// explicit-prompt MessagePart — that path is covered in server-opencode-lifecycle.test.ts.
|
||||
expect(reviveRetiredPane('mimo-code', 'SessionStart')).toBe(false)
|
||||
expect(reviveRetiredPane('command-code', 'SessionStart')).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves the pane retired for a non-boundary event on a revivable source', () => {
|
||||
// Why: guards the inverse — the gate must not open on any event that merely mentions a session.
|
||||
expect(reviveRetiredPane('gemini', 'AfterAgent')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -741,6 +741,7 @@ export class AgentHookServer {
|
||||
private promptSentHashSalt = randomBytes(16).toString('hex')
|
||||
private closedAgentStatusTabIds = new Set<string>()
|
||||
private closedAgentStatusPaneKeys = new Set<string>()
|
||||
private restartedStatusLaunchTokenHashByPaneKey = new Map<string, string>()
|
||||
private connectionTimestampWatermarkById = new Map<string, number>()
|
||||
// Why: skip disk writes when the JSON exactly matches the last write; guards against re-firing trailing timers when nothing changed.
|
||||
private lastWrittenJson: string | null = null
|
||||
@@ -1123,7 +1124,15 @@ export class AgentHookServer {
|
||||
|
||||
private getAgentStatusDisposition(
|
||||
paneKey: string,
|
||||
event?: { hookEventName?: string; isReplay?: boolean }
|
||||
event?: {
|
||||
source?: AgentHookSource
|
||||
/** Raw wire value, so the gate can tell "field absent" from "field present but unknown". */
|
||||
rawSource?: unknown
|
||||
hookEventName?: string
|
||||
isReplay?: boolean
|
||||
hasExplicitPrompt?: boolean
|
||||
launchToken?: string
|
||||
}
|
||||
): 'accept' | 'restart' | 'suppress' {
|
||||
const ownerPaneKey = this.resolvePaneKeyAlias(paneKey)
|
||||
const paneRetired =
|
||||
@@ -1134,17 +1143,75 @@ export class AgentHookServer {
|
||||
return 'suppress'
|
||||
}
|
||||
if (!paneRetired) {
|
||||
const tokenFence = this.restartedStatusLaunchTokenHashByPaneKey.get(ownerPaneKey)
|
||||
// Why: deferred retirement lets a new process start in a still-authorized pane, so
|
||||
// its tokened SessionStart re-fences; prompts recur, so a stale process would win.
|
||||
if (
|
||||
event?.hookEventName === 'SessionStart' &&
|
||||
event.isReplay !== true &&
|
||||
tokenFence !== undefined
|
||||
) {
|
||||
const startedLaunchToken = event.launchToken?.trim()
|
||||
if (startedLaunchToken) {
|
||||
this.restartedStatusLaunchTokenHashByPaneKey.set(
|
||||
ownerPaneKey,
|
||||
createHash('sha256').update(startedLaunchToken).digest('hex')
|
||||
)
|
||||
return 'accept'
|
||||
}
|
||||
}
|
||||
if (event && tokenFence) {
|
||||
const launchToken = event.launchToken?.trim()
|
||||
if (!launchToken || createHash('sha256').update(launchToken).digest('hex') !== tokenFence) {
|
||||
return 'suppress'
|
||||
}
|
||||
}
|
||||
return 'accept'
|
||||
}
|
||||
// Why: command completion retires launch authority but leaves its shell pane reusable.
|
||||
// A live SessionStart proves a new agent process owns the retired pane just like a
|
||||
// A live new-turn event proves a new agent process owns the retired pane just like a
|
||||
// fresh prompt does — without it, a session resumed in a reused pane stays rowless (STA-3386).
|
||||
if (
|
||||
(event?.hookEventName === 'UserPromptSubmit' || event?.hookEventName === 'SessionStart') &&
|
||||
event.isReplay !== true
|
||||
) {
|
||||
// Why the classifier, not literals: only 5 of 18 sources name their boundary
|
||||
// `UserPromptSubmit`/`SessionStart`; the rest stayed retired forever.
|
||||
// Why four branches: `source` collapses to undefined when an older relay omits the field,
|
||||
// when a newer host sends an unknown string, and when the wire value is malformed. Only an
|
||||
// unknown string is valid future-provider evidence. Unreachable from the local path, which
|
||||
// 404s an unresolvable source.
|
||||
const isNewTurn =
|
||||
event?.source !== undefined
|
||||
? isNewTurnEvent(event.source, event.hookEventName)
|
||||
: typeof event?.rawSource === 'string' && event.rawSource.trim().length > 0
|
||||
? // Why fail OPEN for an unknown provider: its boundary event is unknowable here, and
|
||||
// the costs are asymmetric — a stranded pane is invisible and permanent with no user
|
||||
// recovery, while a spurious revive decays after AGENT_STATUS_STALE_AFTER_MS.
|
||||
true
|
||||
: event?.rawSource === undefined
|
||||
? // Why literals here: an older relay omits `source` entirely. Legacy shim only — it
|
||||
// cannot revive a provider whose boundary event is named anything else.
|
||||
event?.hookEventName === 'UserPromptSubmit' || event?.hookEventName === 'SessionStart'
|
||||
: false
|
||||
// Why in addition to the classifier: the OpenCode family carries its mid-session boundary in
|
||||
// an explicit-prompt MessagePart, which isNewTurnEvent cannot name — and mimo-code has no
|
||||
// SessionStart at all, so without this its retired panes never come back.
|
||||
const freshOpenCodeFamilyPrompt =
|
||||
(event?.source === 'opencode' || event?.source === 'mimo-code') &&
|
||||
event.hookEventName === 'MessagePart' &&
|
||||
event.hasExplicitPrompt === true
|
||||
// Why the token is minted here: a revive proves a live lifecycle, and fencing follow-up
|
||||
// status on that launch token stops a stale process reclaiming the pane's row without
|
||||
// restoring retired orchestration authority.
|
||||
if ((isNewTurn || freshOpenCodeFamilyPrompt) && event?.isReplay !== true) {
|
||||
this.closedAgentStatusPaneKeys.delete(paneKey)
|
||||
this.closedAgentStatusPaneKeys.delete(ownerPaneKey)
|
||||
const launchToken = event?.launchToken?.trim()
|
||||
if (launchToken) {
|
||||
this.restartedStatusLaunchTokenHashByPaneKey.set(
|
||||
ownerPaneKey,
|
||||
createHash('sha256').update(launchToken).digest('hex')
|
||||
)
|
||||
} else {
|
||||
this.restartedStatusLaunchTokenHashByPaneKey.delete(ownerPaneKey)
|
||||
}
|
||||
return 'restart'
|
||||
}
|
||||
return 'suppress'
|
||||
@@ -1802,6 +1869,13 @@ export class AgentHookServer {
|
||||
if (this.runtimeObservedStatusPaneKeys.delete(previousOwnerPaneKey)) {
|
||||
this.runtimeObservedStatusPaneKeys.add(toPaneKey)
|
||||
}
|
||||
const restartedTokenHash =
|
||||
this.restartedStatusLaunchTokenHashByPaneKey.get(previousOwnerPaneKey)
|
||||
this.restartedStatusLaunchTokenHashByPaneKey.delete(previousOwnerPaneKey)
|
||||
this.restartedStatusLaunchTokenHashByPaneKey.delete(toPaneKey)
|
||||
if (restartedTokenHash) {
|
||||
this.restartedStatusLaunchTokenHashByPaneKey.set(toPaneKey, restartedTokenHash)
|
||||
}
|
||||
const activeTurnCompletedAt = this.activeHookTurnCompletedAtByPaneKey.get(previousOwnerPaneKey)
|
||||
if (activeTurnCompletedAt !== undefined) {
|
||||
this.activeHookTurnCompletedAtByPaneKey.delete(previousOwnerPaneKey)
|
||||
@@ -1862,6 +1936,7 @@ export class AgentHookServer {
|
||||
const hadStatus = [...paneKeys].some((key) => this.state.lastStatusByPaneKey.has(key))
|
||||
for (const key of paneKeys) {
|
||||
this.markPaneClosedForAgentStatus(key)
|
||||
this.restartedStatusLaunchTokenHashByPaneKey.delete(key)
|
||||
this.clearAssistantMessageRetry(key)
|
||||
this.clearCodexSubagentPoll(key)
|
||||
clearPaneCacheState(this.state, key)
|
||||
@@ -2249,8 +2324,12 @@ export class AgentHookServer {
|
||||
? envelope.compactTrigger
|
||||
: undefined
|
||||
const statusDisposition = this.getAgentStatusDisposition(paneKey, {
|
||||
source,
|
||||
rawSource: envelope.source,
|
||||
hookEventName,
|
||||
isReplay: envelope.isReplay === true
|
||||
isReplay: envelope.isReplay === true,
|
||||
hasExplicitPrompt: envelope.hasExplicitPrompt === true,
|
||||
launchToken: envelope.launchToken
|
||||
})
|
||||
if (statusDisposition === 'suppress') {
|
||||
return
|
||||
@@ -2467,8 +2546,11 @@ export class AgentHookServer {
|
||||
const normalized = this.normalizeLocalHookPayload(source, aliasedBody)
|
||||
const statusDisposition = normalized.event
|
||||
? this.getAgentStatusDisposition(normalized.event.paneKey, {
|
||||
source,
|
||||
hookEventName: normalized.event.hookEventName,
|
||||
isReplay: normalized.event.isReplay
|
||||
isReplay: normalized.event.isReplay,
|
||||
hasExplicitPrompt: normalized.event.hasExplicitPrompt,
|
||||
launchToken: normalized.event.launchToken
|
||||
})
|
||||
: 'suppress'
|
||||
if (normalized.event && statusDisposition !== 'suppress') {
|
||||
@@ -2562,6 +2644,7 @@ export class AgentHookServer {
|
||||
this.promptSentDedupeByPaneKey.clear()
|
||||
this.closedAgentStatusTabIds.clear()
|
||||
this.closedAgentStatusPaneKeys.clear()
|
||||
this.restartedStatusLaunchTokenHashByPaneKey.clear()
|
||||
this.retiredPaneFencesByKey.clear()
|
||||
this.connectionTimestampWatermarkById.clear()
|
||||
this.legacyPaneKeyAliases.clear()
|
||||
@@ -2739,6 +2822,7 @@ export class AgentHookServer {
|
||||
this.runtimeObservedStatusPaneKeys.delete(paneKey)
|
||||
this.currentAuthorityObservations.delete(paneKey)
|
||||
this.promptSentDedupeByPaneKey.delete(paneKey)
|
||||
this.restartedStatusLaunchTokenHashByPaneKey.delete(paneKey)
|
||||
}
|
||||
if (aliasChanged) {
|
||||
this.notifyPaneKeyAliasPersistenceListener()
|
||||
@@ -2760,6 +2844,7 @@ export class AgentHookServer {
|
||||
this.activeHookTurnCompletedAtByPaneKey.delete(resolvedPaneKey)
|
||||
this.currentAuthorityObservations.delete(resolvedPaneKey)
|
||||
this.promptSentDedupeByPaneKey.delete(resolvedPaneKey)
|
||||
this.restartedStatusLaunchTokenHashByPaneKey.delete(resolvedPaneKey)
|
||||
let clearedAlias = false
|
||||
for (const [legacyPaneKey, stablePaneKey] of this.legacyPaneKeyAliases) {
|
||||
if (stablePaneKey.stablePaneKey === resolvedPaneKey) {
|
||||
@@ -2770,6 +2855,7 @@ export class AgentHookServer {
|
||||
this.activeHookTurnCompletedAtByPaneKey.delete(legacyPaneKey)
|
||||
this.currentAuthorityObservations.delete(legacyPaneKey)
|
||||
this.promptSentDedupeByPaneKey.delete(legacyPaneKey)
|
||||
this.restartedStatusLaunchTokenHashByPaneKey.delete(legacyPaneKey)
|
||||
clearedAlias = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { execFile, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { app } from 'electron'
|
||||
import { getAppEnvironment } from '../../shared/app-environment'
|
||||
|
||||
import type { MultiplexerTransport } from '../ssh/ssh-channel-multiplexer'
|
||||
import {
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type WslRelayStartupFailure
|
||||
} from './wsl-hook-relay-sentinel'
|
||||
import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env'
|
||||
import { runWslProcess } from '../wsl/wsl-runner'
|
||||
import {
|
||||
WSL_HOOK_RELAY_BUNDLE_NAME,
|
||||
WSL_HOOK_RELAY_DIR,
|
||||
@@ -42,7 +43,7 @@ export function resolveWslHookRelayBundle(): WslHookRelayBundle | null {
|
||||
candidates.push(join(process.resourcesPath, 'app.asar.unpacked', 'out', 'relay', 'wsl'))
|
||||
}
|
||||
try {
|
||||
const appPath = app.getAppPath()
|
||||
const appPath = getAppEnvironment().getAppPath()
|
||||
candidates.push(join(appPath, 'resources', 'relay', 'wsl'))
|
||||
candidates.push(join(appPath, 'out', 'relay', 'wsl'))
|
||||
} catch {
|
||||
@@ -171,56 +172,29 @@ export function isWslDistroRunning(distro: string): Promise<boolean> {
|
||||
})
|
||||
}
|
||||
|
||||
export function runWslInstallProcess(
|
||||
export async function runWslInstallProcess(
|
||||
distro: string,
|
||||
script: string,
|
||||
env: NodeJS.ProcessEnv
|
||||
// Unused: the install script embeds its own version/paths and reads
|
||||
// nothing from the crossed guest environment.
|
||||
_env: NodeJS.ProcessEnv
|
||||
): Promise<{ code: number | null; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Why: --exec skips the default login shell; the script rides stdin so
|
||||
// no quoting crosses the wsl.exe boundary at all.
|
||||
const child = spawn('wsl.exe', ['-d', distro, '--exec', 'sh', '-s'], {
|
||||
env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
})
|
||||
let stderr = ''
|
||||
let settled = false
|
||||
// Why: a wedged wsl.exe here would otherwise pin the manager's state at
|
||||
// 'starting' forever — the one unbounded await on the ensure path.
|
||||
const timeout = setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
child.kill()
|
||||
resolve({
|
||||
code: null,
|
||||
stderr: `${stderr}\ninstall timed out after ${INSTALL_TIMEOUT_MS}ms`
|
||||
})
|
||||
}
|
||||
}, INSTALL_TIMEOUT_MS)
|
||||
child.stderr.on('data', (d: Buffer) => {
|
||||
stderr = (stderr + decodeWslText(d.toString('utf8'))).slice(-MAX_STARTUP_BUFFER_BYTES)
|
||||
})
|
||||
child.on('error', (err) => {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
clearTimeout(timeout)
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
child.on('close', (code) => {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
clearTimeout(timeout)
|
||||
resolve({ code, stderr })
|
||||
}
|
||||
})
|
||||
child.stdin.on('error', () => {
|
||||
// Guest exited before consuming stdin — surfaced via close/code.
|
||||
})
|
||||
child.stdin.write(script)
|
||||
child.stdin.end()
|
||||
const result = await runWslProcess({
|
||||
distro,
|
||||
loginPath: 'none',
|
||||
script,
|
||||
// Declared because the payload is opaque here: it is POSIX plus a heredoc.
|
||||
shell: 'sh',
|
||||
timeoutMs: INSTALL_TIMEOUT_MS
|
||||
// No maxOutputBytes: the default cap holds the whole stream so the slice
|
||||
// below can take the end of it.
|
||||
})
|
||||
// Tail, not head: the operative error ("mv: Read-only file system") lands
|
||||
// after whatever apt and base64 already printed.
|
||||
const stderr = result.stderr.slice(-MAX_STARTUP_BUFFER_BYTES)
|
||||
return result.timedOut
|
||||
? { code: null, stderr: `${stderr}\ninstall timed out after ${INSTALL_TIMEOUT_MS}ms` }
|
||||
: { code: result.code, stderr }
|
||||
}
|
||||
|
||||
const TRANSIENT_RETRY_LIMIT = 2
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment'
|
||||
import { join } from 'node:path'
|
||||
|
||||
type ElectronAppPath = { getAppPath(): string; isPackaged: boolean }
|
||||
type ElectronAppPath = { getAppPath(): string; isPackaged(): boolean }
|
||||
|
||||
// Why the port and not require('electron'): this module is reachable from plain-Node
|
||||
// fork entries, where the literal text require("electron") fails the build guard even
|
||||
// inside a try/catch. hasAppEnvironment() gives the same "no app root here" answer.
|
||||
function loadElectronApp(): ElectronAppPath | null {
|
||||
try {
|
||||
return require('electron').app ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return hasAppEnvironment() ? getAppEnvironment() : null
|
||||
}
|
||||
|
||||
export function resolveAiVaultServiceEntryPath(
|
||||
@@ -47,6 +47,6 @@ export function resolveAiVaultServiceEntryPathWithoutApp(
|
||||
export function getAiVaultServiceEntryPath(): string {
|
||||
const app = loadElectronApp()
|
||||
return app
|
||||
? resolveAiVaultServiceEntryPath(app.getAppPath(), app.isPackaged)
|
||||
? resolveAiVaultServiceEntryPath(app.getAppPath(), app.isPackaged())
|
||||
: resolveAiVaultServiceEntryPathWithoutApp(process.cwd(), process.resourcesPath)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
updateExternalAutomation
|
||||
} from './external-manager'
|
||||
import { mapHermesJobs, mapOpenClawJobs } from './external-job-mappers'
|
||||
import { getActiveMultiplexer } from '../ipc/ssh'
|
||||
import { getActiveMultiplexer } from '../ssh/ssh-target-registry'
|
||||
import type { Store } from '../persistence'
|
||||
import type * as Fs from 'node:fs'
|
||||
|
||||
@@ -41,7 +41,7 @@ vi.mock('fs', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../ipc/ssh', () => ({
|
||||
vi.mock('../ssh/ssh-target-registry', () => ({
|
||||
getActiveMultiplexer: vi.fn()
|
||||
}))
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
} from '../../shared/automations-types'
|
||||
import type { SshTarget } from '../../shared/ssh-types'
|
||||
import type { Store } from '../persistence'
|
||||
import { getActiveMultiplexer } from '../ipc/ssh'
|
||||
import { getActiveMultiplexer } from '../ssh/ssh-target-registry'
|
||||
import { isRuntimeOwnedSshTarget } from '../ssh/ssh-connection-store'
|
||||
import { mapHermesJobs, mapOpenClawJobs } from './external-job-mappers'
|
||||
import {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import { toRuntimeExecutionHostId } from '../../shared/execution-host'
|
||||
import { AutomationService } from './service'
|
||||
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
|
||||
|
||||
const testState = { dir: '' }
|
||||
|
||||
@@ -21,6 +22,8 @@ vi.mock('electron', () => ({
|
||||
|
||||
async function createStore() {
|
||||
vi.resetModules()
|
||||
// Why: userData resolves through AppEnvironment; point it at this file's temp dir.
|
||||
installFakeAppEnvironment({ getPath: () => testState.dir })
|
||||
const { Store, initDataPath } = await import('../persistence')
|
||||
initDataPath()
|
||||
return new Store()
|
||||
|
||||
@@ -10,13 +10,13 @@ let tempHome = ''
|
||||
|
||||
async function loadModule() {
|
||||
vi.resetModules()
|
||||
vi.doMock('electron', () => ({
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (value: string) => Buffer.from(value),
|
||||
decryptString: (value: Buffer) => value.toString('utf-8')
|
||||
}
|
||||
}))
|
||||
const { setSecretStore } = await import('../../shared/secret-store')
|
||||
setSecretStore({
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (value) => Buffer.from(value),
|
||||
decryptString: (value) => value.toString('utf-8'),
|
||||
describeProtectionGap: () => null
|
||||
})
|
||||
vi.doMock('node:os', async () => {
|
||||
const actual = await vi.importActual<typeof Os>('node:os')
|
||||
return { ...actual, homedir: () => tempHome }
|
||||
|
||||
@@ -19,13 +19,13 @@ async function loadStore(
|
||||
// Why: doMock registrations outlive resetModules, so an injected failure from
|
||||
// one case would leak into every later one in this file.
|
||||
vi.doUnmock('node:fs')
|
||||
vi.doMock('electron', () => ({
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (value: string) => Buffer.from(value),
|
||||
decryptString: decryptStringMock
|
||||
}
|
||||
}))
|
||||
const { setSecretStore } = await import('../../shared/secret-store')
|
||||
setSecretStore({
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (value) => Buffer.from(value),
|
||||
decryptString: decryptStringMock,
|
||||
describeProtectionGap: () => null
|
||||
})
|
||||
vi.doMock('node:os', async () => {
|
||||
const actual = await vi.importActual<typeof Os>('node:os')
|
||||
return { ...actual, homedir: () => tempHome }
|
||||
|
||||
@@ -19,13 +19,13 @@ vi.mock('../git/runner', () => ({ gitExecFileAsync: vi.fn() }))
|
||||
|
||||
async function loadModules() {
|
||||
vi.resetModules()
|
||||
vi.doMock('electron', () => ({
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (value: string) => Buffer.from(value),
|
||||
decryptString: decryptSpy
|
||||
}
|
||||
}))
|
||||
const { setSecretStore } = await import('../../shared/secret-store')
|
||||
setSecretStore({
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (value) => Buffer.from(value),
|
||||
decryptString: decryptSpy,
|
||||
describeProtectionGap: () => null
|
||||
})
|
||||
vi.doMock('node:os', async () => {
|
||||
const actual = await vi.importActual<typeof Os>('node:os')
|
||||
return { ...actual, homedir: () => tempHome }
|
||||
|
||||
@@ -2604,7 +2604,11 @@ export class AgentBrowserBridge {
|
||||
child = execFile(
|
||||
this.agentBrowserBin,
|
||||
['--session', sessionName, 'close'],
|
||||
{ timeout: STALE_SESSION_CLOSE_TIMEOUT_MS },
|
||||
// Why windowsHide: agent-browser is console-subsystem and Orca's main
|
||||
// process owns no console, so each spawn gets a fresh visible conhost
|
||||
// that takes foreground -- keystrokes typed into a terminal at that
|
||||
// moment land in the black box (#14543).
|
||||
{ timeout: STALE_SESSION_CLOSE_TIMEOUT_MS, windowsHide: true },
|
||||
(error) =>
|
||||
finish(
|
||||
error
|
||||
@@ -2667,6 +2671,9 @@ export class AgentBrowserBridge {
|
||||
{
|
||||
timeout: execOptions?.timeoutMs ?? EXEC_TIMEOUT_MS,
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
// Why windowsHide: see the stale-session close above -- every
|
||||
// agent-browser invocation would otherwise flash a console (#14543).
|
||||
windowsHide: true,
|
||||
env: execOptions?.envOverrides
|
||||
? { ...process.env, ...execOptions.envOverrides }
|
||||
: process.env
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* The error every browser command rejects with.
|
||||
*
|
||||
* Why its own module: this is seven lines with no dependencies, but it lived in
|
||||
* `cdp-bridge.ts`, which imports `webContents` and drags the whole Chromium cluster
|
||||
* along. The runtime catches this type on paths that have nothing to do with CDP, so
|
||||
* that one import kept a Node host from loading the runtime at all.
|
||||
*/
|
||||
export class BrowserError extends Error {
|
||||
constructor(
|
||||
readonly code: string,
|
||||
message: string
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
@@ -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,4 +1,6 @@
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { RuntimeBrowserCommands } from '../runtime/orca-runtime-browser'
|
||||
import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createConnection } from 'node:net'
|
||||
@@ -280,6 +282,10 @@ describe('Browser automation pipeline (integration)', () => {
|
||||
const RENDERER_WC_ID = 1
|
||||
|
||||
beforeEach(async () => {
|
||||
// Why: constructing the browser commands is what pulls the Chromium cluster in, so
|
||||
// production installs this at the Electron entry. Suites that exercise browser
|
||||
// automation install it too; a Node host installs none and the RPCs reject.
|
||||
setRuntimeBrowserCommandsFactory((host) => new RuntimeBrowserCommands(host))
|
||||
activeGuestHarness = createMockGuest(GUEST_WC_ID, 'https://example.com', 'Example Domain')
|
||||
const { guest } = activeGuestHarness
|
||||
activeGuest = guest
|
||||
|
||||
@@ -48,17 +48,13 @@ import {
|
||||
import { insertTextThroughCdp } from './browser-text-insertion'
|
||||
import type { BrowserManager } from './browser-manager'
|
||||
import { ANTI_DETECTION_SCRIPT } from './anti-detection'
|
||||
import { BrowserError } from './browser-error'
|
||||
|
||||
const CAPTURE_LOG_LIMIT = 1000
|
||||
|
||||
export class BrowserError extends Error {
|
||||
constructor(
|
||||
readonly code: string,
|
||||
message: string
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
// Why re-exported: moved to ./browser-error so the runtime can catch it without
|
||||
// pulling Chromium in. Existing importers of this path keep working.
|
||||
export { BrowserError } from './browser-error'
|
||||
|
||||
type TabState = {
|
||||
navigationId: string | null
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
/* eslint-disable max-lines -- Why: keeps file/Keychain/snapshot/env-patch auth semantics together so PTY launch and quota-fetch paths can't drift. */
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { app } from 'electron'
|
||||
@@ -15,7 +14,9 @@ import {
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
import { resolveLocalAccountRuntimeTarget } from '../../shared/local-account-runtime'
|
||||
import { getDefaultWslDistro, getWslHome, toWindowsWslPath } from '../wsl'
|
||||
import { buildEncodedWslBashCommand } from '../wsl-bash-command'
|
||||
const OWNERSHIP_PROBE_TIMEOUT = 'orca-wsl-ownership-probe-timeout'
|
||||
|
||||
import { runWslProcess } from '../wsl/wsl-runner'
|
||||
import { hasLiveClaudePtys } from './live-pty-gate'
|
||||
import { isOauthTokenExpiring, refreshClaudeOauthCredentials } from './oauth-refresh'
|
||||
import { ClaudeRuntimePathResolver } from './runtime-paths'
|
||||
@@ -142,7 +143,7 @@ export class ClaudeRuntimeAuthService {
|
||||
)
|
||||
await this.restoreSystemDefaultSnapshot(
|
||||
previousAccount ? await this.readManagedCredentials(previousAccount) : null,
|
||||
previousAccount ? this.readManagedOauthAccount(previousAccount) : undefined
|
||||
previousAccount ? await this.readManagedOauthAccount(previousAccount) : undefined
|
||||
)
|
||||
this.lastSyncedAccountId = null
|
||||
return
|
||||
@@ -189,7 +190,7 @@ export class ClaudeRuntimeAuthService {
|
||||
? await this.readManagedCredentials(previousAccount)
|
||||
: null
|
||||
const previousManagedOauthAccount = previousAccount
|
||||
? this.readManagedOauthAccount(previousAccount)
|
||||
? await this.readManagedOauthAccount(previousAccount)
|
||||
: null
|
||||
if (previousAccount && previousAccount.id !== activeAccount?.id) {
|
||||
if (previousManagedCredentialsJson) {
|
||||
@@ -256,7 +257,7 @@ export class ClaudeRuntimeAuthService {
|
||||
}
|
||||
|
||||
if (activeAccount.managedAuthRuntime === 'wsl') {
|
||||
if (!this.getOwnedManagedAuthPath(activeAccount)) {
|
||||
if (!(await this.getOwnedManagedAuthPath(activeAccount))) {
|
||||
console.warn(
|
||||
'[claude-runtime-auth] Active WSL managed account is not owned by Orca, restoring system default'
|
||||
)
|
||||
@@ -294,7 +295,7 @@ export class ClaudeRuntimeAuthService {
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.getOwnedManagedAuthPath(activeAccount)) {
|
||||
if (!(await this.getOwnedManagedAuthPath(activeAccount))) {
|
||||
console.warn(
|
||||
'[claude-runtime-auth] Active managed account is not owned by Orca, restoring system default'
|
||||
)
|
||||
@@ -303,7 +304,7 @@ export class ClaudeRuntimeAuthService {
|
||||
previousAccount &&
|
||||
(previousAccount.id !== activeAccount.id ||
|
||||
this.hasMaterializedRuntimeAuth ||
|
||||
this.runtimeOauthAccountMatches(this.readManagedOauthAccount(previousAccount)))
|
||||
this.runtimeOauthAccountMatches(await this.readManagedOauthAccount(previousAccount)))
|
||||
) {
|
||||
await this.restoreSystemDefaultSnapshotForMissingManagedCredentials(
|
||||
previousAccount,
|
||||
@@ -380,7 +381,7 @@ export class ClaudeRuntimeAuthService {
|
||||
readBackResult.runtimeCredentialsJson,
|
||||
activeAccount,
|
||||
credentialsJson,
|
||||
this.readManagedOauthAccount(activeAccount)
|
||||
await this.readManagedOauthAccount(activeAccount)
|
||||
)
|
||||
) {
|
||||
// Why: this Claude launched under the active managed account, but persistence still needs positive account proof.
|
||||
@@ -427,12 +428,12 @@ export class ClaudeRuntimeAuthService {
|
||||
} catch (error) {
|
||||
await this.restoreSystemDefaultSnapshot(
|
||||
credentialsJson,
|
||||
this.readManagedOauthAccount(activeAccount)
|
||||
await this.readManagedOauthAccount(activeAccount)
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
const managedOauthAccount = this.readManagedOauthAccount(activeAccount)
|
||||
const managedOauthAccount = await this.readManagedOauthAccount(activeAccount)
|
||||
if (this.writeRuntimeOauthAccount(managedOauthAccount)) {
|
||||
this.lastWrittenOauthAccount = managedOauthAccount
|
||||
this.hasLastWrittenOauthAccount = true
|
||||
@@ -718,7 +719,7 @@ export class ClaudeRuntimeAuthService {
|
||||
runtimeOauthAccount,
|
||||
account,
|
||||
managedCredentialsJson,
|
||||
this.readManagedOauthAccount(account)
|
||||
await this.readManagedOauthAccount(account)
|
||||
)
|
||||
if (match === 'match') {
|
||||
matches.push({ account, managedCredentialsJson })
|
||||
@@ -1008,7 +1009,7 @@ export class ClaudeRuntimeAuthService {
|
||||
}
|
||||
|
||||
private async readManagedCredentials(account: ClaudeManagedAccount): Promise<string | null> {
|
||||
const managedAuthPath = this.getOwnedManagedAuthPath(account)
|
||||
const managedAuthPath = await this.getOwnedManagedAuthPath(account)
|
||||
if (!managedAuthPath) {
|
||||
return null
|
||||
}
|
||||
@@ -1022,7 +1023,7 @@ export class ClaudeRuntimeAuthService {
|
||||
account: ClaudeManagedAccount,
|
||||
credentialsJson: string
|
||||
): Promise<void> {
|
||||
const managedAuthPath = this.getOwnedManagedAuthPath(account)
|
||||
const managedAuthPath = await this.getOwnedManagedAuthPath(account)
|
||||
if (!managedAuthPath) {
|
||||
throw new Error('Managed Claude auth storage is not owned by Orca.')
|
||||
}
|
||||
@@ -1061,8 +1062,8 @@ export class ClaudeRuntimeAuthService {
|
||||
return refreshed
|
||||
}
|
||||
|
||||
private readManagedOauthAccount(account: ClaudeManagedAccount): unknown {
|
||||
const managedAuthPath = this.getOwnedManagedAuthPath(account)
|
||||
private async readManagedOauthAccount(account: ClaudeManagedAccount): Promise<unknown> {
|
||||
const managedAuthPath = await this.getOwnedManagedAuthPath(account)
|
||||
if (!managedAuthPath) {
|
||||
return null
|
||||
}
|
||||
@@ -1074,7 +1075,7 @@ export class ClaudeRuntimeAuthService {
|
||||
}
|
||||
}
|
||||
|
||||
private getOwnedManagedAuthPath(account: ClaudeManagedAccount): string | null {
|
||||
private async getOwnedManagedAuthPath(account: ClaudeManagedAccount): Promise<string | null> {
|
||||
const wslInfo = parseWslUncPath(account.managedAuthPath)
|
||||
if (wslInfo) {
|
||||
if (
|
||||
@@ -1085,31 +1086,38 @@ export class ClaudeRuntimeAuthService {
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
const canonicalLinuxPath = execFileSync(
|
||||
'wsl.exe',
|
||||
[
|
||||
'-d',
|
||||
wslInfo.distro,
|
||||
'--exec',
|
||||
'bash',
|
||||
'-lc',
|
||||
buildEncodedWslBashCommand(
|
||||
[
|
||||
'set -euo pipefail',
|
||||
`candidate=${shellQuote(wslInfo.linuxPath)}`,
|
||||
'managed_root="${HOME%/}/.local/share/orca/claude-accounts"',
|
||||
'candidate_real=$(readlink -f -- "$candidate")',
|
||||
'managed_root_real=$(readlink -f -- "$managed_root")',
|
||||
'test -f "$candidate_real/.orca-managed-claude-auth"',
|
||||
`test "$(cat "$candidate_real/.orca-managed-claude-auth")" = ${shellQuote(account.id)}`,
|
||||
'case "$candidate_real" in "$managed_root_real"/*/auth) printf "%s\\n" "$candidate_real" ;; *) exit 35 ;; esac'
|
||||
].join('\n')
|
||||
)
|
||||
],
|
||||
{ encoding: 'utf-8', timeout: 5000 }
|
||||
).trim()
|
||||
const owned = await runWslProcess({
|
||||
distro: wslInfo.distro,
|
||||
loginPath: 'none',
|
||||
shell: 'bash',
|
||||
script: [
|
||||
'set -euo pipefail',
|
||||
`candidate=${shellQuote(wslInfo.linuxPath)}`,
|
||||
'managed_root="${HOME%/}/.local/share/orca/claude-accounts"',
|
||||
'candidate_real=$(readlink -f -- "$candidate")',
|
||||
'managed_root_real=$(readlink -f -- "$managed_root")',
|
||||
'test -f "$candidate_real/.orca-managed-claude-auth"',
|
||||
`test "$(cat "$candidate_real/.orca-managed-claude-auth")" = ${shellQuote(account.id)}`,
|
||||
'case "$candidate_real" in "$managed_root_real"/*/auth) printf "%s\\n" "$candidate_real" ;; *) exit 35 ;; esac'
|
||||
].join('\n'),
|
||||
timeoutMs: 5000
|
||||
})
|
||||
if (owned.timedOut) {
|
||||
throw new Error(OWNERSHIP_PROBE_TIMEOUT)
|
||||
}
|
||||
if (owned.code !== 0) {
|
||||
return null
|
||||
}
|
||||
const canonicalLinuxPath = owned.stdout.trim()
|
||||
return canonicalLinuxPath ? toWindowsWslPath(canonicalLinuxPath, wslInfo.distro) : null
|
||||
} catch {
|
||||
} catch (error) {
|
||||
// Why rethrow a timeout: null means "not owned by Orca", and the
|
||||
// caller persists that -- clearing the user's account selection. A
|
||||
// slow distro must not decide ownership. Swallowing it here is what
|
||||
// made the previous guard dead code.
|
||||
if (error instanceof Error && error.message === OWNERSHIP_PROBE_TIMEOUT) {
|
||||
throw error
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable max-lines -- Why: Claude managed accounts need one audited owner
|
||||
for login, credential capture, Keychain storage, selection, and rate-limit refresh. */
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { execFileSync, spawn } from 'node:child_process'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, relative, resolve, sep } from 'node:path'
|
||||
@@ -33,7 +33,7 @@ import { beginClaudeAuthSwitch, endClaudeAuthSwitch } from './live-pty-gate'
|
||||
import { findDuplicateClaudeAccount } from './claude-duplicate-account'
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
import { toWindowsWslPath } from '../wsl'
|
||||
import { buildEncodedWslBashCommand } from '../wsl-bash-command'
|
||||
import { runWslProcess } from '../wsl/wsl-runner'
|
||||
import { buildWindowsCommandInvocation } from './windows-command-invocation'
|
||||
import {
|
||||
getClaudeSelectionTargetForAccount,
|
||||
@@ -162,7 +162,7 @@ export class ClaudeAccountService {
|
||||
target?: ClaudeAccountAddTarget
|
||||
): Promise<ClaudeRateLimitAccountsState> {
|
||||
const accountId = randomUUID()
|
||||
const managedAuth = this.createManagedAuthDir(accountId, target)
|
||||
const managedAuth = await this.createManagedAuthDir(accountId, target)
|
||||
const previousSettings = this.store.getSettings()
|
||||
try {
|
||||
const captured = await this.runClaudeLoginAndCapture(managedAuth)
|
||||
@@ -183,7 +183,7 @@ export class ClaudeAccountService {
|
||||
options?: ClaudeAccountImportOptions
|
||||
): Promise<ClaudeRateLimitAccountsState> {
|
||||
const accountId = randomUUID()
|
||||
const managedAuth = this.createManagedAuthDir(accountId, options)
|
||||
const managedAuth = await this.createManagedAuthDir(accountId, options)
|
||||
const previousSettings = this.store.getSettings()
|
||||
try {
|
||||
const captured = await this.captureFromExistingConfigDir(
|
||||
@@ -332,7 +332,7 @@ export class ClaudeAccountService {
|
||||
|
||||
private async doReauthenticateAccount(accountId: string): Promise<ClaudeRateLimitAccountsState> {
|
||||
const account = this.requireAccount(accountId)
|
||||
const managedAuthPath = this.assertManagedAuthPath(account.managedAuthPath, accountId)
|
||||
const managedAuthPath = await this.assertManagedAuthPath(account.managedAuthPath, accountId)
|
||||
const previousSettings = this.store.getSettings()
|
||||
const previousManagedAuth = await this.readManagedAuthSnapshot(accountId, managedAuthPath)
|
||||
const captured = await this.runClaudeLoginAndCapture({
|
||||
@@ -390,7 +390,7 @@ export class ClaudeAccountService {
|
||||
}
|
||||
if (restoredManagedCredentials || !wroteManagedCredentials) {
|
||||
try {
|
||||
this.restoreManagedOauthSnapshot(accountId, managedAuthPath, previousManagedAuth)
|
||||
await this.restoreManagedOauthSnapshot(accountId, managedAuthPath, previousManagedAuth)
|
||||
} catch (rollbackError) {
|
||||
console.warn(
|
||||
'[claude-accounts] Failed to restore managed oauth metadata during rollback:',
|
||||
@@ -581,7 +581,7 @@ export class ClaudeAccountService {
|
||||
wslLinuxAuthPath: null
|
||||
}
|
||||
): Promise<CapturedClaudeAuth> {
|
||||
const tempConfig = this.createTemporaryClaudeConfigDir(location)
|
||||
const tempConfig = await this.createTemporaryClaudeConfigDir(location)
|
||||
const loginAbortController = new AbortController()
|
||||
this.cancelPendingClaudeLogin = () => {
|
||||
if (loginAbortController.signal.aborted) {
|
||||
@@ -635,7 +635,7 @@ export class ClaudeAccountService {
|
||||
cleanupError = error
|
||||
}
|
||||
}
|
||||
this.removeTemporaryClaudeConfigDir(tempConfig)
|
||||
await this.removeTemporaryClaudeConfigDir(tempConfig)
|
||||
this.cancelPendingClaudeLogin = null
|
||||
}
|
||||
if (captureError) {
|
||||
@@ -647,11 +647,11 @@ export class ClaudeAccountService {
|
||||
return captured!
|
||||
}
|
||||
|
||||
private createTemporaryClaudeConfigDir(location: ManagedClaudeAuthLocation): {
|
||||
private async createTemporaryClaudeConfigDir(location: ManagedClaudeAuthLocation): Promise<{
|
||||
windowsPath: string
|
||||
linuxPath: string | null
|
||||
wslDistro: string | null
|
||||
} {
|
||||
}> {
|
||||
if (location.managedAuthRuntime !== 'wsl') {
|
||||
return {
|
||||
windowsPath: mkdtempSync(join(tmpdir(), 'orca-claude-login-')),
|
||||
@@ -662,21 +662,15 @@ export class ClaudeAccountService {
|
||||
if (!location.wslDistro) {
|
||||
throw new Error('Could not resolve the active WSL distribution for Claude login.')
|
||||
}
|
||||
const linuxPath = execFileSync(
|
||||
'wsl.exe',
|
||||
[
|
||||
'-d',
|
||||
location.wslDistro,
|
||||
'--exec',
|
||||
'bash',
|
||||
'-lc',
|
||||
'mktemp -d "${TMPDIR:-/tmp}/orca-claude-login.XXXXXX"'
|
||||
],
|
||||
{ encoding: 'utf-8', timeout: 5000 }
|
||||
)
|
||||
.replaceAll(String.fromCharCode(0), '')
|
||||
.trim()
|
||||
if (!linuxPath.startsWith('/')) {
|
||||
const created = await runWslProcess({
|
||||
distro: location.wslDistro,
|
||||
loginPath: 'none',
|
||||
shell: 'bash',
|
||||
script: 'mktemp -d "${TMPDIR:-/tmp}/orca-claude-login.XXXXXX"',
|
||||
timeoutMs: 5000
|
||||
})
|
||||
const linuxPath = created.stdout.replaceAll(String.fromCharCode(0), '').trim()
|
||||
if (created.code !== 0 || created.timedOut || !linuxPath.startsWith('/')) {
|
||||
throw new Error('Could not create a temporary WSL Claude login directory.')
|
||||
}
|
||||
return {
|
||||
@@ -686,25 +680,20 @@ export class ClaudeAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
private removeTemporaryClaudeConfigDir(tempConfig: {
|
||||
private async removeTemporaryClaudeConfigDir(tempConfig: {
|
||||
windowsPath: string
|
||||
linuxPath: string | null
|
||||
wslDistro: string | null
|
||||
}): void {
|
||||
}): Promise<void> {
|
||||
if (tempConfig.linuxPath && tempConfig.wslDistro) {
|
||||
try {
|
||||
execFileSync(
|
||||
'wsl.exe',
|
||||
[
|
||||
'-d',
|
||||
tempConfig.wslDistro,
|
||||
'--exec',
|
||||
'bash',
|
||||
'-lc',
|
||||
`rm -rf -- ${shellQuote(tempConfig.linuxPath)}`
|
||||
],
|
||||
{ encoding: 'utf-8', timeout: 5000 }
|
||||
)
|
||||
await runWslProcess({
|
||||
distro: tempConfig.wslDistro,
|
||||
loginPath: 'none',
|
||||
program: 'rm',
|
||||
args: ['-rf', '--', tempConfig.linuxPath],
|
||||
timeoutMs: 5000
|
||||
})
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
@@ -817,7 +806,7 @@ export class ClaudeAccountService {
|
||||
managedAuthPath: string,
|
||||
credentialsJson: string
|
||||
): Promise<void> {
|
||||
const trustedPath = this.assertManagedAuthPath(managedAuthPath, accountId)
|
||||
const trustedPath = await this.assertManagedAuthPath(managedAuthPath, accountId)
|
||||
if (process.platform === 'darwin') {
|
||||
await writeManagedClaudeKeychainCredentials(accountId, credentialsJson)
|
||||
} else {
|
||||
@@ -830,7 +819,7 @@ export class ClaudeAccountService {
|
||||
managedAuthPath: string,
|
||||
oauthAccount: unknown
|
||||
): Promise<void> {
|
||||
const trustedPath = this.assertManagedAuthPath(managedAuthPath, accountId)
|
||||
const trustedPath = await this.assertManagedAuthPath(managedAuthPath, accountId)
|
||||
writeClaudeManagedAuthFile(
|
||||
trustedPath,
|
||||
'oauth-account.json',
|
||||
@@ -842,7 +831,7 @@ export class ClaudeAccountService {
|
||||
accountId: string,
|
||||
managedAuthPath: string
|
||||
): Promise<ManagedClaudeAuthSnapshot> {
|
||||
const trustedPath = this.assertManagedAuthPath(managedAuthPath, accountId)
|
||||
const trustedPath = await this.assertManagedAuthPath(managedAuthPath, accountId)
|
||||
return {
|
||||
credentialsJson:
|
||||
process.platform === 'darwin'
|
||||
@@ -857,7 +846,7 @@ export class ClaudeAccountService {
|
||||
managedAuthPath: string,
|
||||
snapshot: ManagedClaudeAuthSnapshot
|
||||
): Promise<void> {
|
||||
const trustedPath = this.assertManagedAuthPath(managedAuthPath, accountId)
|
||||
const trustedPath = await this.assertManagedAuthPath(managedAuthPath, accountId)
|
||||
const credentialsPath = join(trustedPath, '.credentials.json')
|
||||
if (process.platform === 'darwin') {
|
||||
await (snapshot.credentialsJson !== null
|
||||
@@ -870,12 +859,12 @@ export class ClaudeAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
private restoreManagedOauthSnapshot(
|
||||
private async restoreManagedOauthSnapshot(
|
||||
accountId: string,
|
||||
managedAuthPath: string,
|
||||
snapshot: ManagedClaudeAuthSnapshot
|
||||
): void {
|
||||
const trustedPath = this.assertManagedAuthPath(managedAuthPath, accountId)
|
||||
): Promise<void> {
|
||||
const trustedPath = await this.assertManagedAuthPath(managedAuthPath, accountId)
|
||||
const oauthPath = join(trustedPath, 'oauth-account.json')
|
||||
if (snapshot.oauthAccountJson !== null) {
|
||||
writeClaudeManagedAuthFile(trustedPath, 'oauth-account.json', snapshot.oauthAccountJson)
|
||||
@@ -884,11 +873,11 @@ export class ClaudeAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
private createManagedAuthDir(
|
||||
private async createManagedAuthDir(
|
||||
accountId: string,
|
||||
target?: ClaudeAccountAddTarget
|
||||
): ManagedClaudeAuthLocation {
|
||||
const wslAuth = this.tryCreateWslManagedAuthDir(accountId, target)
|
||||
): Promise<ManagedClaudeAuthLocation> {
|
||||
const wslAuth = await this.tryCreateWslManagedAuthDir(accountId, target)
|
||||
if (wslAuth) {
|
||||
return wslAuth
|
||||
}
|
||||
@@ -897,55 +886,58 @@ export class ClaudeAccountService {
|
||||
mkdirSync(managedAuthPath, { recursive: true })
|
||||
writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), `${accountId}\n`, 'utf-8')
|
||||
return {
|
||||
managedAuthPath: this.assertManagedAuthPath(managedAuthPath, accountId),
|
||||
managedAuthPath: await this.assertManagedAuthPath(managedAuthPath, accountId),
|
||||
managedAuthRuntime: 'host',
|
||||
wslDistro: null,
|
||||
wslLinuxAuthPath: null
|
||||
}
|
||||
}
|
||||
|
||||
private tryCreateWslManagedAuthDir(
|
||||
private async tryCreateWslManagedAuthDir(
|
||||
accountId: string,
|
||||
target?: ClaudeAccountAddTarget
|
||||
): ManagedClaudeAuthLocation | null {
|
||||
): Promise<ManagedClaudeAuthLocation | null> {
|
||||
if (process.platform !== 'win32' || target?.runtime !== 'wsl') {
|
||||
return null
|
||||
}
|
||||
|
||||
const distroArgs = target.wslDistro?.trim() ? ['-d', target.wslDistro.trim()] : []
|
||||
const infoOutput = execFileSync(
|
||||
'wsl.exe',
|
||||
[...distroArgs, '--exec', 'bash', '-lc', 'printf "%s\\n%s\\n" "$WSL_DISTRO_NAME" "$HOME"'],
|
||||
{ encoding: 'utf-8', timeout: 5000 }
|
||||
)
|
||||
const [rawDistro, rawHome] = infoOutput
|
||||
.replaceAll(String.fromCharCode(0), '')
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
const distro = target.wslDistro?.trim() || rawDistro
|
||||
const requestedDistro = target.wslDistro?.trim() || undefined
|
||||
const info = await runWslProcess({
|
||||
distro: requestedDistro,
|
||||
loginPath: 'none',
|
||||
shell: 'bash',
|
||||
script: 'printf "%s\\n%s\\n" "$WSL_DISTRO_NAME" "$HOME"',
|
||||
timeoutMs: 5000
|
||||
})
|
||||
const [rawDistro, rawHome] =
|
||||
info.code === 0 && !info.timedOut
|
||||
? info.stdout
|
||||
.replaceAll(String.fromCharCode(0), '')
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
: []
|
||||
const distro = requestedDistro || rawDistro
|
||||
const home = rawHome
|
||||
if (!distro || !home?.startsWith('/')) {
|
||||
throw new Error('Could not resolve the active WSL home directory for Claude login.')
|
||||
}
|
||||
|
||||
const wslLinuxAuthPath = `${home.replace(/\/$/, '')}/.local/share/orca/claude-accounts/${accountId}/auth`
|
||||
const markerPath = `${wslLinuxAuthPath}/.orca-managed-claude-auth`
|
||||
execFileSync(
|
||||
'wsl.exe',
|
||||
[
|
||||
'-d',
|
||||
distro,
|
||||
'--exec',
|
||||
'bash',
|
||||
'-lc',
|
||||
`mkdir -p ${shellQuote(wslLinuxAuthPath)} && printf '%s\\n' ${shellQuote(accountId)} > ${shellQuote(markerPath)}`
|
||||
],
|
||||
{ encoding: 'utf-8', timeout: 5000 }
|
||||
)
|
||||
const created = await runWslProcess({
|
||||
distro,
|
||||
loginPath: 'none',
|
||||
shell: 'bash',
|
||||
script: 'mkdir -p "$1" && printf \'%s\\n\' "$2" > "$1/.orca-managed-claude-auth"',
|
||||
args: [wslLinuxAuthPath, accountId],
|
||||
timeoutMs: 5000
|
||||
})
|
||||
if (created.code !== 0 || created.timedOut) {
|
||||
throw new Error('Could not create the managed WSL Claude auth directory.')
|
||||
}
|
||||
|
||||
const managedAuthPath = toWindowsWslPath(wslLinuxAuthPath, distro)
|
||||
return {
|
||||
managedAuthPath: this.assertManagedAuthPath(managedAuthPath, accountId),
|
||||
managedAuthPath: await this.assertManagedAuthPath(managedAuthPath, accountId),
|
||||
managedAuthRuntime: 'wsl',
|
||||
wslDistro: distro,
|
||||
wslLinuxAuthPath
|
||||
@@ -958,7 +950,10 @@ export class ClaudeAccountService {
|
||||
return root
|
||||
}
|
||||
|
||||
private assertManagedAuthPath(candidatePath: string, expectedAccountId?: string): string {
|
||||
private async assertManagedAuthPath(
|
||||
candidatePath: string,
|
||||
expectedAccountId?: string
|
||||
): Promise<string> {
|
||||
const wslInfo = parseWslUncPath(candidatePath)
|
||||
if (wslInfo) {
|
||||
if (
|
||||
@@ -969,31 +964,28 @@ export class ClaudeAccountService {
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
const canonicalLinuxPath = execFileSync(
|
||||
'wsl.exe',
|
||||
[
|
||||
'-d',
|
||||
wslInfo.distro,
|
||||
'--exec',
|
||||
'bash',
|
||||
'-lc',
|
||||
buildEncodedWslBashCommand(
|
||||
[
|
||||
'set -euo pipefail',
|
||||
`candidate=${shellQuote(wslInfo.linuxPath)}`,
|
||||
'managed_root="${HOME%/}/.local/share/orca/claude-accounts"',
|
||||
'candidate_real=$(readlink -f -- "$candidate")',
|
||||
'managed_root_real=$(readlink -f -- "$managed_root")',
|
||||
'test -f "$candidate_real/.orca-managed-claude-auth"',
|
||||
expectedAccountId
|
||||
? `test "$(cat "$candidate_real/.orca-managed-claude-auth")" = ${shellQuote(expectedAccountId)}`
|
||||
: 'test -n "$(cat "$candidate_real/.orca-managed-claude-auth")"',
|
||||
'case "$candidate_real" in "$managed_root_real"/*/auth) printf "%s\\n" "$candidate_real" ;; *) exit 35 ;; esac'
|
||||
].join('\n')
|
||||
)
|
||||
],
|
||||
{ encoding: 'utf-8', timeout: 5000 }
|
||||
).trim()
|
||||
const owned = await runWslProcess({
|
||||
distro: wslInfo.distro,
|
||||
loginPath: 'none',
|
||||
shell: 'bash',
|
||||
script: [
|
||||
'set -euo pipefail',
|
||||
`candidate=${shellQuote(wslInfo.linuxPath)}`,
|
||||
'managed_root="${HOME%/}/.local/share/orca/claude-accounts"',
|
||||
'candidate_real=$(readlink -f -- "$candidate")',
|
||||
'managed_root_real=$(readlink -f -- "$managed_root")',
|
||||
'test -f "$candidate_real/.orca-managed-claude-auth"',
|
||||
expectedAccountId
|
||||
? `test "$(cat "$candidate_real/.orca-managed-claude-auth")" = ${shellQuote(expectedAccountId)}`
|
||||
: 'test -n "$(cat "$candidate_real/.orca-managed-claude-auth")"',
|
||||
'case "$candidate_real" in "$managed_root_real"/*/auth) printf "%s\\n" "$candidate_real" ;; *) exit 35 ;; esac'
|
||||
].join('\n'),
|
||||
timeoutMs: 5000
|
||||
})
|
||||
if (owned.code !== 0 || owned.timedOut) {
|
||||
throw new Error('Managed Claude auth directory does not exist on disk.')
|
||||
}
|
||||
const canonicalLinuxPath = owned.stdout.trim()
|
||||
if (!canonicalLinuxPath) {
|
||||
throw new Error('Managed Claude auth directory does not exist on disk.')
|
||||
}
|
||||
@@ -1036,7 +1028,7 @@ export class ClaudeAccountService {
|
||||
|
||||
private async safeRemoveManagedAuth(accountId: string, candidatePath: string): Promise<void> {
|
||||
try {
|
||||
const managedAuthPath = this.assertManagedAuthPath(candidatePath, accountId)
|
||||
const managedAuthPath = await this.assertManagedAuthPath(candidatePath, accountId)
|
||||
rmSync(resolve(managedAuthPath, '..'), { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
console.warn('[claude-accounts] Refusing to remove untrusted managed auth:', error)
|
||||
@@ -1092,6 +1084,9 @@ export class ClaudeAccountService {
|
||||
// the local callback server before the browser returns.
|
||||
stdio: [options?.keepStdinOpen ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
||||
shell: spawnConfig.shell,
|
||||
// On Windows this is wsl.exe or a cmd invocation; without the flag it
|
||||
// opens a console and steals foreground on every managed login (#10488).
|
||||
windowsHide: true,
|
||||
windowsVerbatimArguments: spawnConfig.windowsVerbatimArguments,
|
||||
env: spawnConfig.env,
|
||||
// Why: Claude auth can leave browser/login descendants alive after denial.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable max-lines -- Why: this file centralizes cross-platform CLI install state, launcher resolution, and PATH registration so the public shell command stays consistent across packaged and development builds. */
|
||||
import { app } from 'electron'
|
||||
import { getAppEnvironment } from '../../shared/app-environment'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { constants, existsSync } from 'node:fs'
|
||||
import {
|
||||
@@ -93,11 +93,11 @@ export class CliInstaller {
|
||||
|
||||
constructor(options: CliInstallerOptions = {}) {
|
||||
this.platform = options.platform ?? process.platform
|
||||
this.isPackaged = options.isPackaged ?? app.isPackaged
|
||||
this.userDataPath = options.userDataPath ?? app.getPath('userData')
|
||||
this.isPackaged = options.isPackaged ?? getAppEnvironment().isPackaged()
|
||||
this.userDataPath = options.userDataPath ?? getAppEnvironment().getPath('userData')
|
||||
this.resourcesPath = options.resourcesPath ?? process.resourcesPath
|
||||
this.execPathValue = options.execPath ?? process.execPath
|
||||
this.appPathValue = options.appPath ?? app.getAppPath()
|
||||
this.appPathValue = options.appPath ?? getAppEnvironment().getAppPath()
|
||||
this.homePath = options.homePath ?? homedir()
|
||||
this.localAppDataPath =
|
||||
options.localAppDataPath ??
|
||||
|
||||
@@ -5,11 +5,10 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const execFileMock = vi.hoisted(() => vi.fn())
|
||||
const runWslProcessMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('node:child_process', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
execFile: execFileMock
|
||||
vi.mock('../wsl/wsl-runner', () => ({
|
||||
runWslProcess: runWslProcessMock
|
||||
}))
|
||||
|
||||
import { WslCliInstaller, _internals } from './wsl-cli-installer'
|
||||
@@ -158,7 +157,7 @@ function createWslRunner(
|
||||
|
||||
describe('WslCliInstaller', () => {
|
||||
beforeEach(() => {
|
||||
execFileMock.mockReset()
|
||||
runWslProcessMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -355,25 +354,6 @@ describe('WslCliInstaller', () => {
|
||||
expect(bridge).toContain('exit $exitCode')
|
||||
})
|
||||
|
||||
it('wraps WSL bash scripts as a single encoded command line', () => {
|
||||
const command = [
|
||||
'set -euo pipefail',
|
||||
`cat > "$command_tmp" <<'ORCA_WSL_CLI'`,
|
||||
'#!/usr/bin/env bash',
|
||||
'exec powershell.exe "$@"',
|
||||
'ORCA_WSL_CLI'
|
||||
].join('\n')
|
||||
const wrapped = _internals.buildEncodedWslBashCommand(command)
|
||||
const encoded = wrapped.match(
|
||||
/^set -o pipefail; printf %s '([^']+)' \| base64 -d \| bash$/
|
||||
)?.[1]
|
||||
|
||||
expect(wrapped).not.toContain('\n')
|
||||
expect(wrapped).toContain('set -o pipefail;')
|
||||
expect(encoded).toBeTruthy()
|
||||
expect(Buffer.from(encoded as string, 'base64').toString('utf8')).toBe(command)
|
||||
})
|
||||
|
||||
it('treats absolute Windows PowerShell as interop-ready when powershell.exe is missing from PATH', async () => {
|
||||
const wsl = createWslRunner()
|
||||
const installer = new WslCliInstaller({
|
||||
@@ -793,29 +773,37 @@ describe('WslCliInstaller', () => {
|
||||
})
|
||||
|
||||
it('settles when wsl.exe never reports completion', async () => {
|
||||
vi.useFakeTimers()
|
||||
const killMock = vi.fn()
|
||||
execFileMock.mockImplementation(() => ({ kill: killMock }))
|
||||
// Why no fake timers: the timeout is now runProcess's own, internal to the
|
||||
// mocked runWslProcess -- there is nothing left here to advance.
|
||||
runWslProcessMock.mockResolvedValue({ code: null, stdout: '', stderr: '', timedOut: true })
|
||||
const installer = new WslCliInstaller({
|
||||
platform: 'win32',
|
||||
distro: 'Ubuntu',
|
||||
hostInstaller: { getStatus: async () => makeHostStatus() }
|
||||
})
|
||||
|
||||
const promise = installer.getStatus()
|
||||
let settled = false
|
||||
void promise
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
settled = true
|
||||
})
|
||||
await expect(installer.getStatus()).rejects.toThrow('WSL command timed out')
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
await Promise.resolve()
|
||||
it('reports the distro unreachable rather than "not on PATH" without the login PATH', async () => {
|
||||
// The `case ":$PATH:"` probe answers from the distro default PATH when the
|
||||
// login PATH never resolved, and ~/.local/bin is never on that. Settings
|
||||
// would then state as fact that the CLI is not on PATH while the user's
|
||||
// own terminal finds it -- #14288 turning into a confident wrong verdict.
|
||||
runWslProcessMock.mockResolvedValue({
|
||||
environmentResolved: false,
|
||||
code: 0,
|
||||
stdout: 'no',
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
})
|
||||
const installer = new WslCliInstaller({
|
||||
platform: 'win32',
|
||||
distro: 'Ubuntu',
|
||||
hostInstaller: { getStatus: async () => makeHostStatus() }
|
||||
})
|
||||
|
||||
expect(settled).toBe(true)
|
||||
await expect(promise).rejects.toThrow('WSL command timed out')
|
||||
expect(killMock).toHaveBeenCalled()
|
||||
await expect(installer.getStatus()).rejects.toThrow('Could not reach the WSL distro')
|
||||
})
|
||||
|
||||
it('refuses to remove an old managed launcher when the bridge path is user-owned', async () => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/* eslint-disable max-lines -- Why: WSL CLI status/install/remove share one state machine;
|
||||
splitting the installer would separate conflict checks from the operations they guard. */
|
||||
import { execFile } from 'node:child_process'
|
||||
import type { CliInstallStatus } from '../../shared/cli-install-types'
|
||||
import { getDefaultWslDistro } from '../wsl'
|
||||
import { runWslProcess } from '../wsl/wsl-runner'
|
||||
import { CliInstaller } from './cli-installer'
|
||||
import {
|
||||
buildManagedLegacyRemoveCommand,
|
||||
@@ -214,7 +214,9 @@ export class WslCliInstaller {
|
||||
await this.run(
|
||||
this.distro as string,
|
||||
[
|
||||
'set -euo pipefail',
|
||||
// Why -eu not -euo pipefail: transported via runWslProcess's `sh -s`,
|
||||
// and no pipe here needs pipefail -- dash on Ubuntu 20.04 lacks the option.
|
||||
'set -eu',
|
||||
`mkdir -p ${quoteShell(status.pathDirectory as string)}`,
|
||||
`mkdir -p ${quoteShell(getPosixDirname(getBridgePathFromCommandPath(status.commandPath)))}`,
|
||||
buildRegistrationLockPrelude(status.commandPath),
|
||||
@@ -282,9 +284,7 @@ export class WslCliInstaller {
|
||||
// startup reconciliation as opt-in proof, silently undoing this removal.
|
||||
await this.run(
|
||||
this.distro as string,
|
||||
['set -euo pipefail', buildManagedLegacyRemoveCommand(quoteShell(legacyCommandPath))].join(
|
||||
'\n'
|
||||
)
|
||||
['set -eu', buildManagedLegacyRemoveCommand(quoteShell(legacyCommandPath))].join('\n')
|
||||
)
|
||||
return status
|
||||
}
|
||||
@@ -457,57 +457,36 @@ export class WslCliInstaller {
|
||||
}
|
||||
|
||||
async function runWslCommand(distro: string, command: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let child: ReturnType<typeof execFile> | null = null
|
||||
let settled = false
|
||||
|
||||
const finish = (error: Error | null, stdout = ''): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timeout)
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
resolve(stdout)
|
||||
}
|
||||
|
||||
// Why: WSL CLI status/install/remove backs Settings UI; a wedged wsl.exe
|
||||
// process must not leave the command registration flow pending forever.
|
||||
const timeout = setTimeout(() => {
|
||||
child?.kill()
|
||||
finish(new Error(`WSL command timed out after ${WSL_COMMAND_TIMEOUT_MS}ms.`))
|
||||
}, WSL_COMMAND_TIMEOUT_MS)
|
||||
|
||||
try {
|
||||
child = execFile(
|
||||
'wsl.exe',
|
||||
['-d', distro, '--exec', 'bash', '-lc', buildEncodedWslBashCommand(command)],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: WSL_COMMAND_TIMEOUT_MS
|
||||
},
|
||||
(error, stdout) => {
|
||||
finish(error ?? null, stdout)
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
finish(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
// Why the probe lane fixes #14288: the prior login shell (`bash -lc`) sourced
|
||||
// ~/.profile, so one blocking line there ate the whole 10s timeout.
|
||||
const result = await runWslProcess({
|
||||
distro,
|
||||
loginPath: 'preferred',
|
||||
script: command,
|
||||
// Declared, not assumed: the payload is opaque here, so the guard cannot
|
||||
// check it for bashisms. These are POSIX (`-eu`, `case`), hence sh.
|
||||
shell: 'sh',
|
||||
timeoutMs: WSL_COMMAND_TIMEOUT_MS
|
||||
})
|
||||
}
|
||||
|
||||
function buildEncodedWslBashCommand(command: string): string {
|
||||
// Why: raw multiline heredocs can be flattened while crossing wsl.exe's
|
||||
// Windows command-line boundary. Send one shell-safe line and decode inside WSL.
|
||||
const encoded = Buffer.from(command, 'utf8').toString('base64')
|
||||
return `set -o pipefail; printf %s ${quoteShell(encoded)} | base64 -d | bash`
|
||||
// Timeout first: it is the more specific diagnosis, and a timed-out run also
|
||||
// leaves the environment unresolved, so the order decides which one shows.
|
||||
if (result.timedOut) {
|
||||
throw new Error(`WSL command timed out after ${WSL_COMMAND_TIMEOUT_MS}ms.`)
|
||||
}
|
||||
// Every command here reads the login PATH -- the `case ":$PATH:"` probe most
|
||||
// of all. Without it that probe answers from the distro default PATH, which
|
||||
// never has ~/.local/bin, and Settings states as fact that the CLI is not on
|
||||
// PATH while the user's own terminal finds it. Unverifiable, not negative.
|
||||
if (!result.environmentResolved) {
|
||||
throw new Error('Could not reach the WSL distro. Try again.')
|
||||
}
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr.trim() || `WSL command failed with exit code ${result.code}.`)
|
||||
}
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
export const _internals = {
|
||||
buildEncodedWslBashCommand,
|
||||
buildWslBridgeScript,
|
||||
buildWslLauncher,
|
||||
getBridgePathFromCommandPath,
|
||||
|
||||
@@ -152,7 +152,9 @@ export function buildManagedLegacyRemoveCommand(quotedLegacyCommandPath: string)
|
||||
export function buildSafeRemoveCommand(commandPath: string, legacyCommandPath?: string): string {
|
||||
const bridgePath = getBridgePathFromCommandPath(commandPath)
|
||||
return [
|
||||
'set -euo pipefail',
|
||||
// Why -eu not -euo pipefail: this script runs via runWslProcess's `sh -s`,
|
||||
// and no pipe here needs pipefail -- dash on Ubuntu 20.04 lacks the option.
|
||||
'set -eu',
|
||||
buildRegistrationLockPrelude(commandPath),
|
||||
buildSafeReplaceGuard(commandPath, MANAGED_MARKER),
|
||||
buildSafeReplaceGuard(bridgePath, BRIDGE_MANAGED_MARKER),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* eslint-disable max-lines -- Why: keeps Codex's whole runtime-home contract in one place so account-switch semantics don't drift across launch/login/quota paths. */
|
||||
import { quotePosixShell } from '../../shared/wsl-login-shell-command'
|
||||
import {
|
||||
appendFileSync,
|
||||
copyFileSync,
|
||||
@@ -1224,17 +1225,19 @@ export class CodexRuntimeHomeService {
|
||||
'-lc',
|
||||
[
|
||||
'set -e',
|
||||
`if [ ! -e ${this.quoteBashString(activeLinuxPath)} ] && [ ! -L ${this.quoteBashString(activeLinuxPath)} ]; then :`,
|
||||
`elif [ -e ${this.quoteBashString(activeLinuxPath)} ] && [ ! -L ${this.quoteBashString(activeLinuxPath)} ]; then :`,
|
||||
`if [ ! -e ${quotePosixShell(activeLinuxPath)} ] && [ ! -L ${quotePosixShell(activeLinuxPath)} ]; then :`,
|
||||
`elif [ -e ${quotePosixShell(activeLinuxPath)} ] && [ ! -L ${quotePosixShell(activeLinuxPath)} ]; then :`,
|
||||
'else',
|
||||
`mkdir -p ${this.quoteBashString(activeLinuxParentPath)}`,
|
||||
`rm -rf -- ${this.quoteBashString(nextLinuxPath)}`,
|
||||
`ln -s -- ${this.quoteBashString(runtimeWsl.linuxPath)} ${this.quoteBashString(nextLinuxPath)}`,
|
||||
`mv -Tf -- ${this.quoteBashString(nextLinuxPath)} ${this.quoteBashString(activeLinuxPath)}`,
|
||||
`mkdir -p ${quotePosixShell(activeLinuxParentPath)}`,
|
||||
`rm -rf -- ${quotePosixShell(nextLinuxPath)}`,
|
||||
`ln -s -- ${quotePosixShell(runtimeWsl.linuxPath)} ${quotePosixShell(nextLinuxPath)}`,
|
||||
`mv -Tf -- ${quotePosixShell(nextLinuxPath)} ${quotePosixShell(activeLinuxPath)}`,
|
||||
'fi'
|
||||
].join('\n')
|
||||
],
|
||||
{ stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000 }
|
||||
// wsl.exe is console-subsystem: without this a GUI-launched Orca flashes
|
||||
// a conhost and steals foreground for up to the timeout (#10488).
|
||||
{ stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000, windowsHide: true }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1243,10 +1246,6 @@ export class CodexRuntimeHomeService {
|
||||
return index > 0 ? value.slice(0, index) : '/'
|
||||
}
|
||||
|
||||
private quoteBashString(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`
|
||||
}
|
||||
|
||||
private joinWslPath(basePath: string, ...segments: string[]): string {
|
||||
return parseWslUncPath(basePath)
|
||||
? pathWin32.join(basePath, ...segments)
|
||||
|
||||
@@ -3,7 +3,8 @@ import { EventEmitter } from 'node:events'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { buildWslCodexAvailabilityArgs, buildWslCodexLoginArgs } from './wsl-codex-command'
|
||||
import { buildWslCodexAvailabilityScript, buildWslCodexLoginArgs } from './wsl-codex-command'
|
||||
import type { WslResult, WslSpec } from '../wsl/wsl-runner'
|
||||
import {
|
||||
createCodexAuthJson,
|
||||
createRateLimits,
|
||||
@@ -33,6 +34,14 @@ function decodeEncodedWslBashCommand(command: string): string {
|
||||
return encoded ? Buffer.from(encoded, 'base64').toString('utf8') : command
|
||||
}
|
||||
|
||||
function wslOk(stdout = ''): WslResult {
|
||||
return { environmentResolved: true, code: 0, stdout, stderr: '', timedOut: false }
|
||||
}
|
||||
|
||||
function wslFailed(code: number, stderr = ''): WslResult {
|
||||
return { environmentResolved: true, code, stdout: '', stderr, timedOut: false }
|
||||
}
|
||||
|
||||
describe('CodexAccountService config sync', () => {
|
||||
registerCodexAccountsTestHomes()
|
||||
|
||||
@@ -56,22 +65,30 @@ describe('CodexAccountService config sync', () => {
|
||||
const execFileSyncMock = vi.fn((_command: string, args: string[]) => {
|
||||
const script = decodeEncodedWslBashCommand(String(args.at(-1)))
|
||||
expect(args.slice(0, 2)).toEqual(['-d', 'Debian'])
|
||||
expect(script).toContain('readlink -f')
|
||||
return `${wslLinuxHomePath}\n`
|
||||
})
|
||||
const runWslProcessMock = vi.fn(async (spec: WslSpec) => {
|
||||
const script = String(spec.script)
|
||||
expect(spec.distro).toBe('Debian')
|
||||
if (script.includes('WSL_DISTRO_NAME')) {
|
||||
return 'Debian\n/home/alice\n'
|
||||
// 'none': reads $HOME and $WSL_DISTRO_NAME, which wsl.exe supplies from
|
||||
// /etc/passwd without a login shell.
|
||||
expect(spec.loginPath).toBe('none')
|
||||
return wslOk('Debian\n/home/alice\n')
|
||||
}
|
||||
if (script.includes('readlink -f')) {
|
||||
return `${wslLinuxHomePath}\n`
|
||||
}
|
||||
if (script.includes('command -v codex')) {
|
||||
throw new Error('bash -ic does not inherit the distro login-shell PATH')
|
||||
}
|
||||
if (args.slice(2, 5).join(' ') === '--exec sh -c') {
|
||||
expect(args).toEqual(buildWslCodexAvailabilityArgs('Debian'))
|
||||
return ''
|
||||
if (script.includes('_orca_lookup_command=')) {
|
||||
// 'preferred': a PATH lookup. Under 'none' an nvm-installed codex is
|
||||
// invisible and a working install is reported absent (#9725).
|
||||
expect(spec.loginPath).toBe('preferred')
|
||||
expect(script).toBe(buildWslCodexAvailabilityScript())
|
||||
return wslOk()
|
||||
}
|
||||
expect(spec.loginPath).toBe('none')
|
||||
expect(script).toContain('mkdir -p ')
|
||||
mkdirSync(wslManagedHomePath, { recursive: true })
|
||||
writeFileSync(join(wslManagedHomePath, '.orca-managed-home'), 'account-id-for-test\n')
|
||||
return ''
|
||||
return wslOk()
|
||||
})
|
||||
const spawnMock = vi.fn((command: string, args: string[]) => {
|
||||
expect(command).toBe('wsl.exe')
|
||||
@@ -110,6 +127,7 @@ describe('CodexAccountService config sync', () => {
|
||||
execFileSync: execFileSyncMock,
|
||||
spawn: spawnMock
|
||||
}))
|
||||
vi.doMock('../wsl/wsl-runner', () => ({ runWslProcess: runWslProcessMock }))
|
||||
vi.doMock('../../shared/wsl-paths', () => ({
|
||||
parseWslUncPath: (path: string) =>
|
||||
path === wslManagedHomePath ? { distro: 'Debian', linuxPath: wslLinuxHomePath } : null
|
||||
@@ -172,19 +190,22 @@ describe('CodexAccountService config sync', () => {
|
||||
const execFileSyncMock = vi.fn((_command: string, args: string[]) => {
|
||||
const script = decodeEncodedWslBashCommand(String(args.at(-1)))
|
||||
expect(args.slice(0, 2)).toEqual(['-d', 'Debian'])
|
||||
expect(script).toContain('readlink -f')
|
||||
return `${wslLinuxHomePath}\n`
|
||||
})
|
||||
const runWslProcessMock = vi.fn(async (spec: WslSpec) => {
|
||||
const script = String(spec.script)
|
||||
expect(spec.distro).toBe('Debian')
|
||||
if (script.includes('WSL_DISTRO_NAME')) {
|
||||
return 'Debian\n/home/alice\n'
|
||||
return wslOk('Debian\n/home/alice\n')
|
||||
}
|
||||
if (script.includes('readlink -f')) {
|
||||
return `${wslLinuxHomePath}\n`
|
||||
}
|
||||
if (args.slice(2, 5).join(' ') === '--exec sh -c') {
|
||||
expect(args).toEqual(buildWslCodexAvailabilityArgs('Debian'))
|
||||
throw new Error('codex missing')
|
||||
if (script.includes('_orca_lookup_command=')) {
|
||||
expect(script).toBe(buildWslCodexAvailabilityScript())
|
||||
return wslFailed(1, 'codex missing')
|
||||
}
|
||||
mkdirSync(wslManagedHomePath, { recursive: true })
|
||||
writeFileSync(join(wslManagedHomePath, '.orca-managed-home'), 'account-id-for-test\n')
|
||||
return ''
|
||||
return wslOk()
|
||||
})
|
||||
const spawnMock = vi.fn()
|
||||
|
||||
@@ -195,6 +216,7 @@ describe('CodexAccountService config sync', () => {
|
||||
execFileSync: execFileSyncMock,
|
||||
spawn: spawnMock
|
||||
}))
|
||||
vi.doMock('../wsl/wsl-runner', () => ({ runWslProcess: runWslProcessMock }))
|
||||
vi.doMock('../../shared/wsl-paths', () => ({
|
||||
parseWslUncPath: (path: string) =>
|
||||
path === wslManagedHomePath ? { distro: 'Debian', linuxPath: wslLinuxHomePath } : null
|
||||
@@ -229,6 +251,84 @@ describe('CodexAccountService config sync', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('says it could not check, not "not installed", when the login PATH is unavailable', async () => {
|
||||
// #9725: an nvm-installed codex lives only on the login PATH. When the
|
||||
// probe cannot supply it, a non-zero lookup is "we could not check" --
|
||||
// reporting a working install as absent is the bug this pins.
|
||||
vi.resetModules()
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'win32'
|
||||
})
|
||||
|
||||
const wslManagedHomePath = join(testState.userDataDir, 'wsl-managed-home')
|
||||
const wslLinuxHomePath = '/home/alice/.local/share/orca/codex-accounts/account-id-for-test/home'
|
||||
|
||||
const execFileSyncMock = vi.fn((_command: string, args: string[]) => {
|
||||
const script = decodeEncodedWslBashCommand(String(args.at(-1)))
|
||||
expect(args.slice(0, 2)).toEqual(['-d', 'Debian'])
|
||||
expect(script).toContain('readlink -f')
|
||||
return `${wslLinuxHomePath}\n`
|
||||
})
|
||||
const runWslProcessMock = vi.fn(async (spec: WslSpec) => {
|
||||
const script = String(spec.script)
|
||||
expect(spec.distro).toBe('Debian')
|
||||
if (script.includes('WSL_DISTRO_NAME')) {
|
||||
return wslOk('Debian\n/home/alice\n')
|
||||
}
|
||||
if (script.includes('_orca_lookup_command=')) {
|
||||
expect(script).toBe(buildWslCodexAvailabilityScript())
|
||||
return { ...wslFailed(1, 'codex missing'), environmentResolved: false }
|
||||
}
|
||||
mkdirSync(wslManagedHomePath, { recursive: true })
|
||||
writeFileSync(join(wslManagedHomePath, '.orca-managed-home'), 'account-id-for-test\n')
|
||||
return wslOk()
|
||||
})
|
||||
const spawnMock = vi.fn()
|
||||
|
||||
vi.doMock('node:crypto', () => ({
|
||||
randomUUID: () => 'account-id-for-test'
|
||||
}))
|
||||
vi.doMock('node:child_process', () => ({
|
||||
execFileSync: execFileSyncMock,
|
||||
spawn: spawnMock
|
||||
}))
|
||||
vi.doMock('../wsl/wsl-runner', () => ({ runWslProcess: runWslProcessMock }))
|
||||
vi.doMock('../../shared/wsl-paths', () => ({
|
||||
parseWslUncPath: (path: string) =>
|
||||
path === wslManagedHomePath ? { distro: 'Debian', linuxPath: wslLinuxHomePath } : null
|
||||
}))
|
||||
vi.doMock('../wsl', () => ({
|
||||
toWindowsWslPath: () => wslManagedHomePath
|
||||
}))
|
||||
|
||||
const settings = createSettings()
|
||||
const store = createStore(settings)
|
||||
const rateLimits = createRateLimits()
|
||||
const runtimeHome = createRuntimeHome()
|
||||
|
||||
try {
|
||||
const { CodexAccountService } = await import('./service')
|
||||
const service = new CodexAccountService(
|
||||
store as never,
|
||||
rateLimits as never,
|
||||
runtimeHome as never
|
||||
)
|
||||
|
||||
await expect(service.addAccount({ runtime: 'wsl', wslDistro: 'Debian' })).rejects.toThrow(
|
||||
'Could not check the Codex CLI in WSL. Try again.'
|
||||
)
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
expect(existsSync(wslManagedHomePath)).toBe(false)
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: originalPlatform
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('reauthenticates a WSL managed Codex account inside its distro', async () => {
|
||||
vi.resetModules()
|
||||
const originalPlatform = process.platform
|
||||
@@ -260,6 +360,7 @@ describe('CodexAccountService config sync', () => {
|
||||
}
|
||||
return ''
|
||||
})
|
||||
const runWslProcessMock = vi.fn(async () => wslOk())
|
||||
let clearSelectionDuringLogin = (): void => {}
|
||||
const spawnMock = vi.fn((command: string, args: string[]) => {
|
||||
expect(command).toBe('wsl.exe')
|
||||
@@ -292,6 +393,7 @@ describe('CodexAccountService config sync', () => {
|
||||
execFileSync: execFileSyncMock,
|
||||
spawn: spawnMock
|
||||
}))
|
||||
vi.doMock('../wsl/wsl-runner', () => ({ runWslProcess: runWslProcessMock }))
|
||||
vi.doMock('../../shared/wsl-paths', () => ({
|
||||
parseWslUncPath: (path: string) =>
|
||||
path === wslManagedHomePath ? { distro: 'Ubuntu', linuxPath: wslLinuxHomePath } : null
|
||||
@@ -386,16 +488,20 @@ describe('CodexAccountService config sync', () => {
|
||||
|
||||
const execFileSyncMock = vi.fn((_command: string, args: string[]) => {
|
||||
const script = decodeEncodedWslBashCommand(String(args.at(-1)))
|
||||
if (script.includes('mkdir -p -- "$candidate"')) {
|
||||
mkdirSync(wslManagedHomePath, { recursive: true })
|
||||
writeFileSync(join(wslManagedHomePath, '.orca-managed-home'), 'account-1\n', 'utf-8')
|
||||
return ''
|
||||
}
|
||||
if (script.includes('readlink -f')) {
|
||||
return `${wslLinuxHomePath}\n`
|
||||
}
|
||||
return ''
|
||||
})
|
||||
const runWslProcessMock = vi.fn(async (spec: WslSpec) => {
|
||||
const script = String(spec.script)
|
||||
if (script.includes('mkdir -p -- "$candidate"')) {
|
||||
expect(spec.shell).toBe('bash')
|
||||
mkdirSync(wslManagedHomePath, { recursive: true })
|
||||
writeFileSync(join(wslManagedHomePath, '.orca-managed-home'), 'account-1\n', 'utf-8')
|
||||
}
|
||||
return wslOk()
|
||||
})
|
||||
const spawnMock = vi.fn((command: string, args: string[]) => {
|
||||
expect(command).toBe('wsl.exe')
|
||||
expect(args).toEqual(buildWslCodexLoginArgs('Ubuntu', wslLinuxHomePath))
|
||||
@@ -423,6 +529,7 @@ describe('CodexAccountService config sync', () => {
|
||||
execFileSync: execFileSyncMock,
|
||||
spawn: spawnMock
|
||||
}))
|
||||
vi.doMock('../wsl/wsl-runner', () => ({ runWslProcess: runWslProcessMock }))
|
||||
vi.doMock('../../shared/wsl-paths', () => ({
|
||||
parseWslUncPath: (path: string) =>
|
||||
path === wslManagedHomePath ? { distro: 'Ubuntu', linuxPath: wslLinuxHomePath } : null
|
||||
@@ -512,6 +619,7 @@ describe('CodexAccountService config sync', () => {
|
||||
}),
|
||||
spawn: vi.fn()
|
||||
}))
|
||||
vi.doMock('../wsl/wsl-runner', () => ({ runWslProcess: vi.fn(async () => wslOk()) }))
|
||||
vi.doMock('../../shared/wsl-paths', () => ({
|
||||
parseWslUncPath: (path: string) =>
|
||||
path === wslManagedHomePath ? { distro: 'Ubuntu', linuxPath: wslLinuxHomePath } : null
|
||||
|
||||
@@ -41,8 +41,9 @@ import type { RateLimitService } from '../rate-limits/service'
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
import { toWindowsWslPath } from '../wsl'
|
||||
import { buildEncodedWslBashCommand } from '../wsl-bash-command'
|
||||
import { runWslProcess } from '../wsl/wsl-runner'
|
||||
import {
|
||||
buildWslCodexAvailabilityArgs,
|
||||
buildWslCodexAvailabilityScript,
|
||||
buildWslCodexLoginArgs,
|
||||
WSL_CODEX_AVAILABILITY_TIMEOUT_MS
|
||||
} from './wsl-codex-command'
|
||||
@@ -71,6 +72,7 @@ const WINDOWS_RM_RETRY_DELAY_MS = 150
|
||||
const WINDOWS_LOGIN_AUTH_POLL_INTERVAL_MS = 500
|
||||
const WINDOWS_LOGIN_POST_AUTH_EXIT_GRACE_MS = 5_000
|
||||
const WINDOWS_LOGIN_TREE_KILL_TIMEOUT_MS = 5_000
|
||||
const WSL_MANAGED_HOME_TIMEOUT_MS = 5_000
|
||||
|
||||
type CodexOAuthCredentials = {
|
||||
idToken: string | null
|
||||
@@ -754,7 +756,7 @@ export class CodexAccountService {
|
||||
|
||||
private async doAddAccount(target?: CodexAccountAddTarget): Promise<CodexRateLimitAccountsState> {
|
||||
const accountId = randomUUID()
|
||||
const managedHome = this.createManagedHome(accountId, target)
|
||||
const managedHome = await this.createManagedHome(accountId, target)
|
||||
const { managedHomePath } = managedHome
|
||||
try {
|
||||
const canonicalConfig = this.readCanonicalConfigForManagedHome(managedHomePath)
|
||||
@@ -773,7 +775,7 @@ export class CodexAccountService {
|
||||
target?: CodexAccountAddTarget
|
||||
): Promise<CodexRateLimitAccountsState> {
|
||||
const accountId = randomUUID()
|
||||
const managedHome = this.createManagedHome(accountId, target)
|
||||
const managedHome = await this.createManagedHome(accountId, target)
|
||||
const { managedHomePath } = managedHome
|
||||
try {
|
||||
const canonicalConfig = this.readCanonicalConfigForManagedHome(managedHomePath)
|
||||
@@ -897,7 +899,7 @@ export class CodexAccountService {
|
||||
options?: CodexAccountReauthenticateOptions
|
||||
): Promise<CodexRateLimitAccountsState> {
|
||||
const account = this.requireAccount(accountId)
|
||||
const managedHomePath = this.ensureManagedHomeForReauthentication(account)
|
||||
const managedHomePath = await this.ensureManagedHomeForReauthentication(account)
|
||||
const accountTarget = getCodexSelectionTargetForAccount(account)
|
||||
const selectedAccountId = getSelectedCodexAccountIdForTarget(
|
||||
this.store.getSettings(),
|
||||
@@ -1181,11 +1183,11 @@ export class CodexAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
private createManagedHome(
|
||||
private async createManagedHome(
|
||||
accountId: string,
|
||||
target?: CodexAccountAddTarget
|
||||
): ManagedHomeLocation {
|
||||
const wslHome = this.tryCreateWslManagedHome(accountId, target)
|
||||
): Promise<ManagedHomeLocation> {
|
||||
const wslHome = await this.tryCreateWslManagedHome(accountId, target)
|
||||
if (wslHome) {
|
||||
return wslHome
|
||||
}
|
||||
@@ -1202,25 +1204,30 @@ export class CodexAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
private tryCreateWslManagedHome(
|
||||
private async tryCreateWslManagedHome(
|
||||
accountId: string,
|
||||
target?: CodexAccountAddTarget
|
||||
): ManagedHomeLocation | null {
|
||||
): Promise<ManagedHomeLocation | null> {
|
||||
if (process.platform !== 'win32' || target?.runtime !== 'wsl') {
|
||||
return null
|
||||
}
|
||||
|
||||
const distroArgs = target.wslDistro?.trim() ? ['-d', target.wslDistro.trim()] : []
|
||||
const infoOutput = execFileSync(
|
||||
'wsl.exe',
|
||||
[...distroArgs, '--exec', 'bash', '-lc', 'printf "%s\\n%s\\n" "$WSL_DISTRO_NAME" "$HOME"'],
|
||||
{ encoding: 'utf-8', timeout: 5000 }
|
||||
)
|
||||
const [rawDistro, rawHome] = infoOutput
|
||||
const requestedDistro = target.wslDistro?.trim() || undefined
|
||||
const info = await runWslProcess({
|
||||
distro: requestedDistro,
|
||||
loginPath: 'none',
|
||||
script: 'printf "%s\\n%s\\n" "$WSL_DISTRO_NAME" "$HOME"',
|
||||
shell: 'bash',
|
||||
timeoutMs: WSL_MANAGED_HOME_TIMEOUT_MS
|
||||
})
|
||||
if (info.code !== 0 || info.timedOut) {
|
||||
throw new Error('Could not resolve the active WSL home directory for Codex login.')
|
||||
}
|
||||
const [rawDistro, rawHome] = info.stdout
|
||||
.replaceAll(String.fromCharCode(0), '')
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
const distro = target.wslDistro?.trim() || rawDistro
|
||||
const distro = requestedDistro || rawDistro
|
||||
const home = rawHome
|
||||
if (!distro || !home?.startsWith('/')) {
|
||||
throw new Error('Could not resolve the active WSL home directory for Codex login.')
|
||||
@@ -1228,25 +1235,23 @@ export class CodexAccountService {
|
||||
|
||||
const wslLinuxHomePath = `${home.replace(/\/$/, '')}/.local/share/orca/codex-accounts/${accountId}/home`
|
||||
const markerPath = `${wslLinuxHomePath}/.orca-managed-home`
|
||||
execFileSync(
|
||||
'wsl.exe',
|
||||
[
|
||||
'-d',
|
||||
distro,
|
||||
'--exec',
|
||||
'bash',
|
||||
'-lc',
|
||||
`mkdir -p ${shellQuote(wslLinuxHomePath)} && printf '%s\\n' ${shellQuote(accountId)} > ${shellQuote(markerPath)}`
|
||||
],
|
||||
{ encoding: 'utf-8', timeout: 5000 }
|
||||
)
|
||||
const created = await runWslProcess({
|
||||
distro,
|
||||
loginPath: 'none',
|
||||
script: `mkdir -p ${shellQuote(wslLinuxHomePath)} && printf '%s\\n' ${shellQuote(accountId)} > ${shellQuote(markerPath)}`,
|
||||
shell: 'bash',
|
||||
timeoutMs: WSL_MANAGED_HOME_TIMEOUT_MS
|
||||
})
|
||||
if (created.code !== 0 || created.timedOut) {
|
||||
throw new Error('Could not create the managed Codex home inside WSL.')
|
||||
}
|
||||
|
||||
const managedHomePath = toWindowsWslPath(wslLinuxHomePath, distro)
|
||||
let trustedManagedHomePath: string
|
||||
try {
|
||||
trustedManagedHomePath = this.assertManagedHomePath(managedHomePath, accountId)
|
||||
} catch (error) {
|
||||
this.safeRemoveWslManagedHomeCandidate(distro, wslLinuxHomePath, accountId)
|
||||
await this.safeRemoveWslManagedHomeCandidate(distro, wslLinuxHomePath, accountId)
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -1420,10 +1425,12 @@ export class CodexAccountService {
|
||||
return root
|
||||
}
|
||||
|
||||
private ensureManagedHomeForReauthentication(account: CodexManagedAccount): string {
|
||||
private async ensureManagedHomeForReauthentication(
|
||||
account: CodexManagedAccount
|
||||
): Promise<string> {
|
||||
const wslInfo = parseWslUncPath(account.managedHomePath)
|
||||
if (wslInfo && process.platform === 'win32') {
|
||||
this.ensureExpectedWslManagedHomeForReauthentication(account, wslInfo)
|
||||
await this.ensureExpectedWslManagedHomeForReauthentication(account, wslInfo)
|
||||
return this.assertManagedHomePath(account.managedHomePath, account.id)
|
||||
}
|
||||
|
||||
@@ -1452,10 +1459,10 @@ export class CodexAccountService {
|
||||
return this.assertManagedHomePath(expectedManagedHomePath, account.id)
|
||||
}
|
||||
|
||||
private ensureExpectedWslManagedHomeForReauthentication(
|
||||
private async ensureExpectedWslManagedHomeForReauthentication(
|
||||
account: CodexManagedAccount,
|
||||
wslInfo: { distro: string; linuxPath: string }
|
||||
): void {
|
||||
): Promise<void> {
|
||||
if (
|
||||
account.managedHomeRuntime !== 'wsl' ||
|
||||
account.wslDistro !== wslInfo.distro ||
|
||||
@@ -1465,29 +1472,29 @@ export class CodexAccountService {
|
||||
return
|
||||
}
|
||||
|
||||
execFileSync(
|
||||
'wsl.exe',
|
||||
[
|
||||
'-d',
|
||||
wslInfo.distro,
|
||||
'--exec',
|
||||
'bash',
|
||||
'-lc',
|
||||
buildEncodedWslBashCommand(
|
||||
[
|
||||
'set -euo pipefail',
|
||||
`candidate=${shellQuote(wslInfo.linuxPath)}`,
|
||||
`expected_marker=${shellQuote(account.id)}`,
|
||||
'marker="$candidate/.orca-managed-home"',
|
||||
'if [ -e "$candidate" ] && [ ! -f "$marker" ]; then exit 41; fi',
|
||||
'if [ -f "$marker" ] && [ "$(cat "$marker")" != "$expected_marker" ]; then exit 42; fi',
|
||||
'mkdir -p -- "$candidate"',
|
||||
'printf "%s\\n" "$expected_marker" > "$marker"'
|
||||
].join('\n')
|
||||
)
|
||||
],
|
||||
{ encoding: 'utf-8', timeout: 5000 }
|
||||
)
|
||||
const result = await runWslProcess({
|
||||
distro: wslInfo.distro,
|
||||
loginPath: 'none',
|
||||
script: [
|
||||
'set -euo pipefail',
|
||||
`candidate=${shellQuote(wslInfo.linuxPath)}`,
|
||||
`expected_marker=${shellQuote(account.id)}`,
|
||||
'marker="$candidate/.orca-managed-home"',
|
||||
'if [ -e "$candidate" ] && [ ! -f "$marker" ]; then exit 41; fi',
|
||||
'if [ -f "$marker" ] && [ "$(cat "$marker")" != "$expected_marker" ]; then exit 42; fi',
|
||||
'mkdir -p -- "$candidate"',
|
||||
'printf "%s\\n" "$expected_marker" > "$marker"'
|
||||
].join('\n'),
|
||||
shell: 'bash',
|
||||
timeoutMs: WSL_MANAGED_HOME_TIMEOUT_MS
|
||||
})
|
||||
// Why: 41/42 mean the path is not this account's home; re-auth must refuse
|
||||
// rather than write credentials into someone else's directory.
|
||||
if (result.code !== 0 || result.timedOut) {
|
||||
throw new Error(
|
||||
`Could not prepare the managed Codex home in WSL ${wslInfo.distro} for re-authentication.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private isMissingManagedHomeError(error: unknown): boolean {
|
||||
@@ -1553,7 +1560,7 @@ export class CodexAccountService {
|
||||
].join('\n')
|
||||
)
|
||||
],
|
||||
{ encoding: 'utf-8', timeout: 5000 }
|
||||
{ windowsHide: true, encoding: 'utf-8', timeout: 5000 }
|
||||
).trim()
|
||||
if (!canonicalLinuxPath) {
|
||||
throw new Error('Managed Codex home directory does not exist on disk.')
|
||||
@@ -1593,42 +1600,38 @@ export class CodexAccountService {
|
||||
})
|
||||
}
|
||||
|
||||
private safeRemoveWslManagedHomeCandidate(
|
||||
private async safeRemoveWslManagedHomeCandidate(
|
||||
distro: string,
|
||||
linuxHomePath: string,
|
||||
expectedAccountId: string
|
||||
): void {
|
||||
): Promise<void> {
|
||||
// Why: creation can fail after mkdir/marker but before trust, so cleanup must verify the marker/account ID inside WSL.
|
||||
try {
|
||||
execFileSync(
|
||||
'wsl.exe',
|
||||
[
|
||||
'-d',
|
||||
distro,
|
||||
'--exec',
|
||||
'bash',
|
||||
'-lc',
|
||||
buildEncodedWslBashCommand(
|
||||
[
|
||||
'set -euo pipefail',
|
||||
`candidate=${shellQuote(linuxHomePath)}`,
|
||||
`expected_marker=${shellQuote(expectedAccountId)}`,
|
||||
'managed_root="${HOME%/}/.local/share/orca/codex-accounts"',
|
||||
'candidate_real=$(readlink -f -- "$candidate" 2>/dev/null || true)',
|
||||
'managed_root_real=$(readlink -f -- "$managed_root" 2>/dev/null || true)',
|
||||
'test -n "$candidate_real"',
|
||||
'test -n "$managed_root_real"',
|
||||
'case "$candidate_real" in "$managed_root_real"/*/home) ;; *) exit 0 ;; esac',
|
||||
'test -f "$candidate_real/.orca-managed-home"',
|
||||
'test "$(cat "$candidate_real/.orca-managed-home")" = "$expected_marker"',
|
||||
'rm -rf -- "$candidate_real"',
|
||||
'parent_dir=$(dirname -- "$candidate_real")',
|
||||
'case "$parent_dir" in "$managed_root_real"/*) rmdir -- "$parent_dir" 2>/dev/null || true ;; esac'
|
||||
].join('\n')
|
||||
)
|
||||
],
|
||||
{ encoding: 'utf-8', timeout: 5000 }
|
||||
)
|
||||
const result = await runWslProcess({
|
||||
distro,
|
||||
loginPath: 'none',
|
||||
script: [
|
||||
'set -euo pipefail',
|
||||
`candidate=${shellQuote(linuxHomePath)}`,
|
||||
`expected_marker=${shellQuote(expectedAccountId)}`,
|
||||
'managed_root="${HOME%/}/.local/share/orca/codex-accounts"',
|
||||
'candidate_real=$(readlink -f -- "$candidate" 2>/dev/null || true)',
|
||||
'managed_root_real=$(readlink -f -- "$managed_root" 2>/dev/null || true)',
|
||||
'test -n "$candidate_real"',
|
||||
'test -n "$managed_root_real"',
|
||||
'case "$candidate_real" in "$managed_root_real"/*/home) ;; *) exit 0 ;; esac',
|
||||
'test -f "$candidate_real/.orca-managed-home"',
|
||||
'test "$(cat "$candidate_real/.orca-managed-home")" = "$expected_marker"',
|
||||
'rm -rf -- "$candidate_real"',
|
||||
'parent_dir=$(dirname -- "$candidate_real")',
|
||||
'case "$parent_dir" in "$managed_root_real"/*) rmdir -- "$parent_dir" 2>/dev/null || true ;; esac'
|
||||
].join('\n'),
|
||||
shell: 'bash',
|
||||
timeoutMs: WSL_MANAGED_HOME_TIMEOUT_MS
|
||||
})
|
||||
if (result.code !== 0 || result.timedOut) {
|
||||
throw new Error(`WSL cleanup exited with ${result.timedOut ? 'a timeout' : result.code}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[codex-accounts] Failed to clean up WSL managed home candidate:', error)
|
||||
}
|
||||
@@ -1693,7 +1696,7 @@ export class CodexAccountService {
|
||||
private async runCodexLogin(managedHomePath: string): Promise<void> {
|
||||
const wslInfo = parseWslUncPath(managedHomePath)
|
||||
if (wslInfo) {
|
||||
this.assertWslCodexCliAvailable(wslInfo)
|
||||
await this.assertWslCodexCliAvailable(wslInfo)
|
||||
}
|
||||
// Why: reauthentication starts with an existing auth.json. Only new auth
|
||||
// bytes prove this login completed; existence alone would kill the
|
||||
@@ -1849,16 +1852,29 @@ export class CodexAccountService {
|
||||
})
|
||||
}
|
||||
|
||||
private assertWslCodexCliAvailable(wslInfo: { distro: string; linuxPath: string }): void {
|
||||
try {
|
||||
execFileSync('wsl.exe', buildWslCodexAvailabilityArgs(wslInfo.distro), {
|
||||
encoding: 'utf-8',
|
||||
timeout: WSL_CODEX_AVAILABILITY_TIMEOUT_MS
|
||||
})
|
||||
} catch (error) {
|
||||
private async assertWslCodexCliAvailable(wslInfo: {
|
||||
distro: string
|
||||
linuxPath: string
|
||||
}): Promise<void> {
|
||||
// This is a PATH lookup, so it needs the login PATH: an nvm-installed codex
|
||||
// lives nowhere else. Marking it 'none' reports a working install as absent.
|
||||
const result = await runWslProcess({
|
||||
distro: wslInfo.distro,
|
||||
loginPath: 'preferred',
|
||||
script: buildWslCodexAvailabilityScript(),
|
||||
// POSIX command lookup; declared because the payload is opaque here.
|
||||
shell: 'sh',
|
||||
timeoutMs: WSL_CODEX_AVAILABILITY_TIMEOUT_MS
|
||||
})
|
||||
if (result.code !== 0 && !result.environmentResolved) {
|
||||
// A miss without the login PATH is "we could not check", not "not
|
||||
// installed" -- claiming absence here is #9725.
|
||||
throw new Error('Could not check the Codex CLI in WSL. Try again.')
|
||||
}
|
||||
if (result.code !== 0 || result.timedOut) {
|
||||
throw new Error(
|
||||
`Codex CLI is not available in WSL ${wslInfo.distro}. Install Codex in that distro or switch Account location to Windows.`,
|
||||
{ cause: error }
|
||||
{ cause: new Error(result.stderr.trim() || `codex lookup exited with ${result.code}`) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildWslCodexAvailabilityArgs,
|
||||
buildWslCodexAvailabilityScript,
|
||||
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', () => {
|
||||
const args = buildWslCodexAvailabilityArgs('Ubuntu24-Dev')
|
||||
// The distro argv and the login-shell PATH are the runner's now (its probe
|
||||
// lane caches the same getent-resolved login environment); only the
|
||||
// alias-neutral lookup is still this module's to get right.
|
||||
it('checks the alias-neutral PATH the runner supplies', () => {
|
||||
const script = buildWslCodexAvailabilityScript()
|
||||
|
||||
expect(args.slice(0, 5)).toEqual(['-d', 'Ubuntu24-Dev', '--exec', 'sh', '-c'])
|
||||
expect(args.at(-1)).toContain('getent passwd')
|
||||
expect(args.at(-1)).toContain('_orca_lookup_command=')
|
||||
expect(args.at(-1)).toContain('codex')
|
||||
expect(args.at(-1)).not.toContain('bash -ic')
|
||||
expect(script).toContain('_orca_lookup_command=')
|
||||
expect(script).toContain("'codex'")
|
||||
expect(script).toContain('[ -n "$resolved" ]')
|
||||
expect(script).not.toContain('bash -ic')
|
||||
})
|
||||
|
||||
it('launches the resolved Codex executable with its quoted managed home', () => {
|
||||
@@ -25,6 +29,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)
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
export const WSL_CODEX_AVAILABILITY_TIMEOUT_MS = 5_000
|
||||
export const WSL_CODEX_NOT_FOUND_MESSAGE = 'Codex CLI not found in the WSL login-shell PATH.'
|
||||
|
||||
export function buildWslCodexAvailabilityArgs(distro: string): string[] {
|
||||
const command = [buildCodexPathLookup(), '[ -n "$resolved" ]'].join('\n')
|
||||
return buildWslCodexShellArgs(distro, command)
|
||||
/** Exits 0 only when `codex` resolves on the login PATH the runner supplies. */
|
||||
export function buildWslCodexAvailabilityScript(): string {
|
||||
return [buildCodexPathLookup(), '[ -n "$resolved" ]'].join('\n')
|
||||
}
|
||||
|
||||
export type WslCodexIdentityProbe = {
|
||||
@@ -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)
|
||||
}
|
||||
@@ -73,7 +77,13 @@ export function buildWslCodexLoginArgs(distro: string, linuxHomePath: string): s
|
||||
}
|
||||
|
||||
function buildCodexPathLookup(): string {
|
||||
return buildPosixCommandPathLookupScript({ kind: 'literal', value: 'codex' })
|
||||
// Same skip as agent detection: otherwise detection can report the guest
|
||||
// codex while this resolves the Windows one ahead of it on PATH, and Orca
|
||||
// launches a different binary than the one it said was installed.
|
||||
return buildPosixCommandPathLookupScript(
|
||||
{ kind: 'literal', value: 'codex' },
|
||||
{ skipWindowsMountDirs: true }
|
||||
)
|
||||
}
|
||||
|
||||
function buildWslCodexShellArgs(distro: string, command: string): string[] {
|
||||
|
||||
@@ -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
|
||||
@@ -160,10 +160,10 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe(
|
||||
): CodexConfigMirrorResult {
|
||||
const systemConfigPath = join(systemHomePath, 'config.toml')
|
||||
const runtimeConfigPath = join(runtimeHomePath, 'config.toml')
|
||||
// Why: `existsSync` answered `false` for a locked file exactly as for an absent
|
||||
// one, so a held handle on the RUNTIME config read as "no runtime config yet"
|
||||
// and the fresh-mirror branch below overwrote it wholesale. Neither side may
|
||||
// be acted on unless it was actually observed.
|
||||
// Why: `existsSync` collapses an indeterminate probe into the same `false` as
|
||||
// absence, so a transiently unavailable RUNTIME config could reach the fresh
|
||||
// mirror after the path recovered. Neither side may be acted on unless it was
|
||||
// actually observed.
|
||||
const systemConfigObservation = observeAgentStateFile(systemConfigPath)
|
||||
if (systemConfigObservation.kind === 'indeterminate') {
|
||||
return { status: 'refused-indeterminate', error: systemConfigObservation.error }
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CodexPaneAccountRegistryMutations } from './codex-pane-account-registry-mutations'
|
||||
import type { CodexPaneAccountRegistryFile } from './codex-pane-account-registry-types'
|
||||
|
||||
const EXISTING = {
|
||||
selectionKey: 'host',
|
||||
accountId: 'account-1',
|
||||
homeRoute: 'account-home'
|
||||
} as const
|
||||
|
||||
const SPAWNED = {
|
||||
selectionKey: 'host',
|
||||
accountId: 'account-2',
|
||||
homeRoute: 'account-home'
|
||||
} as const
|
||||
|
||||
function parseRegistry(serialized: string): CodexPaneAccountRegistryFile {
|
||||
return JSON.parse(serialized) as CodexPaneAccountRegistryFile
|
||||
}
|
||||
|
||||
describe('CodexPaneAccountRegistryMutations', () => {
|
||||
beforeEach(() => vi.useFakeTimers())
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('retries a one-shot spawn record and merges it with the recovered file', async () => {
|
||||
let readable = false
|
||||
let persisted = JSON.stringify({ version: 2, panes: { existing: EXISTING } })
|
||||
const write = vi.fn((registry: CodexPaneAccountRegistryFile) => {
|
||||
persisted = JSON.stringify(registry)
|
||||
return true
|
||||
})
|
||||
const mutations = new CodexPaneAccountRegistryMutations({
|
||||
read: () => (readable ? parseRegistry(persisted) : null),
|
||||
write
|
||||
})
|
||||
|
||||
mutations.record('spawned', SPAWNED)
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
|
||||
readable = true
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
expect(parseRegistry(persisted).panes).toEqual({ existing: EXISTING, spawned: SPAWNED })
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('bounds automatic retries but keeps the pending mutation for a later read', async () => {
|
||||
let readable = false
|
||||
let persisted = JSON.stringify({ version: 2, panes: { existing: EXISTING } })
|
||||
const read = vi.fn(() => (readable ? parseRegistry(persisted) : null))
|
||||
const write = vi.fn((registry: CodexPaneAccountRegistryFile) => {
|
||||
persisted = JSON.stringify(registry)
|
||||
return true
|
||||
})
|
||||
const mutations = new CodexPaneAccountRegistryMutations({ read, write })
|
||||
|
||||
mutations.record('spawned', SPAWNED)
|
||||
await vi.advanceTimersByTimeAsync(120_000)
|
||||
|
||||
expect(read).toHaveBeenCalledTimes(5)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
readable = true
|
||||
mutations.flush()
|
||||
expect(parseRegistry(persisted).panes.spawned).toEqual(SPAWNED)
|
||||
})
|
||||
|
||||
it('retries durability after a write failure even when the cached value now matches', async () => {
|
||||
const registry = parseRegistry(JSON.stringify({ version: 2, panes: { existing: EXISTING } }))
|
||||
const write = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true)
|
||||
const mutations = new CodexPaneAccountRegistryMutations({ read: () => registry, write })
|
||||
|
||||
mutations.record('spawned', SPAWNED)
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
expect(write).toHaveBeenCalledTimes(2)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('retries a failed reconciliation write without a pending pane mutation', async () => {
|
||||
const registry = parseRegistry(JSON.stringify({ version: 2, panes: { existing: EXISTING } }))
|
||||
const write = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true)
|
||||
const mutations = new CodexPaneAccountRegistryMutations({ read: () => registry, write })
|
||||
|
||||
mutations.persistReconciliation(registry, true)
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
expect(write).toHaveBeenCalledTimes(2)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,157 @@
|
||||
import type {
|
||||
CodexPaneAccountRecord,
|
||||
CodexPaneAccountRegistryFile
|
||||
} from './codex-pane-account-registry-types'
|
||||
|
||||
// Why: bounds both crash-leaked disk records and mutations retained while the file is unavailable.
|
||||
const MAX_TRACKED_PANES = 2000
|
||||
const RETRY_DELAYS_MS = [100, 1_000, 5_000, 30_000] as const
|
||||
|
||||
type MutationStoreDependencies = {
|
||||
read: () => CodexPaneAccountRegistryFile | null
|
||||
write: (registry: CodexPaneAccountRegistryFile) => boolean
|
||||
}
|
||||
|
||||
export class CodexPaneAccountRegistryMutations {
|
||||
private readonly pending = new Map<string, CodexPaneAccountRecord | null>()
|
||||
private retryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private retryAttempt = 0
|
||||
private needsPersistence = false
|
||||
|
||||
constructor(private readonly dependencies: MutationStoreDependencies) {}
|
||||
|
||||
record(ptyId: string, record: CodexPaneAccountRecord | null): void {
|
||||
this.pending.delete(ptyId)
|
||||
this.pending.set(ptyId, record)
|
||||
if (this.pending.size > MAX_TRACKED_PANES) {
|
||||
const oldestPtyId = this.pending.keys().next().value
|
||||
if (typeof oldestPtyId === 'string') {
|
||||
this.pending.delete(oldestPtyId)
|
||||
console.warn('[codex-pane-accounts] Dropped oldest pending registry mutation at capacity')
|
||||
}
|
||||
}
|
||||
this.flush()
|
||||
}
|
||||
|
||||
flush(): void {
|
||||
if (this.pending.size === 0 && !this.needsPersistence) {
|
||||
this.clearRetryState()
|
||||
return
|
||||
}
|
||||
const registry = this.dependencies.read()
|
||||
if (!registry) {
|
||||
this.scheduleRetry()
|
||||
return
|
||||
}
|
||||
let changed = false
|
||||
for (const [ptyId, record] of this.pending) {
|
||||
changed = applyMutation(registry, ptyId, record) || changed
|
||||
}
|
||||
trimRegistry(registry)
|
||||
if ((changed || this.needsPersistence) && !this.dependencies.write(registry)) {
|
||||
this.needsPersistence = true
|
||||
this.scheduleRetry()
|
||||
return
|
||||
}
|
||||
this.needsPersistence = false
|
||||
this.pending.clear()
|
||||
this.clearRetryState()
|
||||
}
|
||||
|
||||
getPendingRegistry(): CodexPaneAccountRegistryFile | null {
|
||||
if (this.pending.size === 0) {
|
||||
return null
|
||||
}
|
||||
const registry: CodexPaneAccountRegistryFile = { version: 2, panes: {} }
|
||||
for (const [ptyId, record] of this.pending) {
|
||||
applyMutation(registry, ptyId, record)
|
||||
}
|
||||
return registry
|
||||
}
|
||||
|
||||
persistReconciliation(registry: CodexPaneAccountRegistryFile, changed: boolean): void {
|
||||
if (!changed) {
|
||||
return
|
||||
}
|
||||
if (!this.dependencies.write(registry)) {
|
||||
this.needsPersistence = true
|
||||
this.scheduleRetry()
|
||||
return
|
||||
}
|
||||
this.needsPersistence = false
|
||||
this.pending.clear()
|
||||
this.clearRetryState()
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.needsPersistence = false
|
||||
this.pending.clear()
|
||||
this.clearRetryState()
|
||||
}
|
||||
|
||||
private scheduleRetry(): void {
|
||||
if (this.retryTimer || this.retryAttempt >= RETRY_DELAYS_MS.length) {
|
||||
return
|
||||
}
|
||||
this.retryTimer = setTimeout(() => {
|
||||
this.retryTimer = null
|
||||
this.flush()
|
||||
}, RETRY_DELAYS_MS[this.retryAttempt])
|
||||
this.retryAttempt += 1
|
||||
this.retryTimer.unref?.()
|
||||
}
|
||||
|
||||
private clearRetryState(): void {
|
||||
if (this.retryTimer) {
|
||||
clearTimeout(this.retryTimer)
|
||||
this.retryTimer = null
|
||||
}
|
||||
this.retryAttempt = 0
|
||||
}
|
||||
}
|
||||
|
||||
function applyMutation(
|
||||
registry: CodexPaneAccountRegistryFile,
|
||||
ptyId: string,
|
||||
record: CodexPaneAccountRecord | null
|
||||
): boolean {
|
||||
if (!record) {
|
||||
if (!(ptyId in registry.panes)) {
|
||||
return false
|
||||
}
|
||||
delete registry.panes[ptyId]
|
||||
return true
|
||||
}
|
||||
const existing = registry.panes[ptyId]
|
||||
if (recordsEqual(existing, record)) {
|
||||
return false
|
||||
}
|
||||
registry.panes[ptyId] = record
|
||||
return true
|
||||
}
|
||||
|
||||
function recordsEqual(
|
||||
left: CodexPaneAccountRecord | undefined,
|
||||
right: CodexPaneAccountRecord
|
||||
): boolean {
|
||||
return (
|
||||
left?.selectionKey === right.selectionKey &&
|
||||
left.accountId === right.accountId &&
|
||||
left.homeRoute === right.homeRoute &&
|
||||
left.shellStartupHomeOverride?.home === right.shellStartupHomeOverride?.home &&
|
||||
left.shellStartupHomeOverride?.shell === right.shellStartupHomeOverride?.shell &&
|
||||
left.shellStartupHomeOverride?.configHome === right.shellStartupHomeOverride?.configHome &&
|
||||
left.shellStartupHomeOverride?.codexHome === right.shellStartupHomeOverride?.codexHome &&
|
||||
left.environmentHomeOverride?.codexHome === right.environmentHomeOverride?.codexHome
|
||||
)
|
||||
}
|
||||
|
||||
function trimRegistry(registry: CodexPaneAccountRegistryFile): void {
|
||||
const trackedPtyIds = Object.keys(registry.panes)
|
||||
if (trackedPtyIds.length <= MAX_TRACKED_PANES) {
|
||||
return
|
||||
}
|
||||
for (const staleId of trackedPtyIds.slice(0, trackedPtyIds.length - MAX_TRACKED_PANES)) {
|
||||
delete registry.panes[staleId]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type {
|
||||
CodexEnvironmentHomeOverride,
|
||||
CodexShellStartupHomeOverride
|
||||
} from './codex-real-home-path'
|
||||
|
||||
export type CodexPaneHomeRoute =
|
||||
| 'real-home'
|
||||
| 'shared-home'
|
||||
| 'account-home'
|
||||
| 'custom-home'
|
||||
| 'wsl-home'
|
||||
|
||||
export type CodexPaneAccountRecord = {
|
||||
/** 'host' or 'wsl:<distro>' — the selection lane this pane launched from. */
|
||||
selectionKey: string
|
||||
/** Managed account id, or null for the system-default account. */
|
||||
accountId: string | null
|
||||
/** Absent only on records written before route provenance was introduced. */
|
||||
homeRoute?: CodexPaneHomeRoute
|
||||
/** Rechecked when CODEX_HOME came from process-global shell startup. */
|
||||
shellStartupHomeOverride?: CodexShellStartupHomeOverride
|
||||
/** Rechecked after restart when CODEX_HOME came from the process environment. */
|
||||
environmentHomeOverride?: CodexEnvironmentHomeOverride
|
||||
}
|
||||
|
||||
export type CodexPaneAccountRegistryFile = {
|
||||
version: 2
|
||||
panes: Record<string, CodexPaneAccountRecord>
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user