mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 00:03:08 +00:00
Merge remote-tracking branch 'origin/main' into fix-codex-icon-not-showing-up-on-terminal
This commit is contained in:
@@ -878,6 +878,14 @@ jobs:
|
||||
}
|
||||
Get-Item -LiteralPath $file
|
||||
}
|
||||
# The standalone node.exe that hosts the terminal daemon outside the
|
||||
# install-dir kill zone the NSIS updater sweeps. See
|
||||
# src/main/daemon/daemon-host-relocation.ts.
|
||||
$daemonHostNode = 'dist/win-unpacked/resources/daemon-host/node.exe'
|
||||
if (-not (Test-Path -LiteralPath $daemonHostNode -PathType Leaf)) {
|
||||
throw "Missing Windows daemon-host runtime file: $daemonHostNode"
|
||||
}
|
||||
Get-Item -LiteralPath $daemonHostNode
|
||||
|
||||
- name: Install SignPath PowerShell module
|
||||
if: matrix.platform == 'win'
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
const { copyFileSync, existsSync, mkdirSync } = require('node:fs')
|
||||
const { join } = require('node:path')
|
||||
|
||||
// Where the standalone daemon-host node.exe is staged under packaged resources.
|
||||
// Mirrored at runtime by src/main/daemon/daemon-host-relocation.ts, which copies
|
||||
// it into userData/daemon-host/<version>/ and forks the terminal daemon from it.
|
||||
const DAEMON_HOST_DIR = 'daemon-host'
|
||||
const DAEMON_HOST_NODE_EXE = 'node.exe'
|
||||
|
||||
/**
|
||||
* Stages a standalone Windows node.exe into resources/daemon-host so the
|
||||
* detached terminal daemon can be forked from a userData copy that lives
|
||||
* outside the install directory the NSIS updater force-closes mid-update.
|
||||
*
|
||||
* Source is the build host's own node.exe (process.execPath). It is version-
|
||||
* correct by construction: engines.node pins the build to Node 24.x, whose NAPI
|
||||
* 10 runtime loads node-pty's Electron-42-built conpty.node. Being an official
|
||||
* Node binary, it also carries the OpenJS Foundation Authenticode signature, so
|
||||
* it ships validly signed regardless of whether SignPath re-signs nested PEs.
|
||||
*
|
||||
* Windows x64 only — the sole Windows build target. Other targets ship no
|
||||
* node.exe and the runtime falls open to forking the install-dir Electron host.
|
||||
*/
|
||||
function ensurePackagedDaemonHostNode(resourcesDir, electronPlatformName) {
|
||||
if (electronPlatformName !== 'win32') {
|
||||
return
|
||||
}
|
||||
const nodeExePath = process.execPath
|
||||
// The build must run under standalone Node (not Electron) so execPath is a
|
||||
// real node.exe with a NAPI runtime; anything else would strand the daemon.
|
||||
if (!/node\.exe$/i.test(nodeExePath) || !existsSync(nodeExePath)) {
|
||||
throw new Error(
|
||||
`[daemon-host] cannot stage node.exe: build host execPath is not a node.exe (${nodeExePath})`
|
||||
)
|
||||
}
|
||||
const destDir = join(resourcesDir, DAEMON_HOST_DIR)
|
||||
mkdirSync(destDir, { recursive: true })
|
||||
copyFileSync(nodeExePath, join(destDir, DAEMON_HOST_NODE_EXE))
|
||||
}
|
||||
|
||||
module.exports = { ensurePackagedDaemonHostNode, DAEMON_HOST_DIR, DAEMON_HOST_NODE_EXE }
|
||||
@@ -7,6 +7,7 @@ const {
|
||||
prunePackagedRuntimeNodeModules,
|
||||
verifyPackagedMainRuntimeDeps
|
||||
} = require('./packaged-runtime-node-modules.cjs')
|
||||
const { ensurePackagedDaemonHostNode } = require('./daemon-host-node-runtime.cjs')
|
||||
|
||||
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1'
|
||||
const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1'
|
||||
@@ -128,6 +129,10 @@ module.exports = {
|
||||
return
|
||||
}
|
||||
prunePackagedRuntimeNodeModules(resourcesDir, context.electronPlatformName, context.arch)
|
||||
// Why: stage the standalone node.exe that hosts the terminal daemon outside
|
||||
// the install-dir kill zone the NSIS updater sweeps (win32 only; no-ops
|
||||
// elsewhere). See src/main/daemon/daemon-host-relocation.ts.
|
||||
ensurePackagedDaemonHostNode(resourcesDir, context.electronPlatformName)
|
||||
verifyPackagedMainRuntimeDeps(resourcesDir)
|
||||
chmodUnixCliLaunchers(resourcesDir, context.electronPlatformName)
|
||||
chmodMacServeSimHelpers(resourcesDir, context.electronPlatformName)
|
||||
|
||||
@@ -18,7 +18,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")',
|
||||
@@ -54,8 +54,29 @@ index 1ac5758bedd8cf54f32280dea4e4aeb5afdee30d..e619813759c6f14694838bdfbd0ea5f8
|
||||
'msvs_settings': {
|
||||
# Specify this setting here to override a setting from somewhere
|
||||
# else, such as node's common.gypi.
|
||||
diff --git a/lib/conpty_console_list_agent.js b/lib/conpty_console_list_agent.js
|
||||
index 8c4fca9022a6d6f015bca87f61625cde2278f428..201fc1d70a907e31b5a2b33b19a9db20803bc652 100644
|
||||
--- a/lib/conpty_console_list_agent.js
|
||||
+++ b/lib/conpty_console_list_agent.js
|
||||
@@ -10,7 +10,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
||||
var utils_1 = require("./utils");
|
||||
var getConsoleProcessList = utils_1.loadNativeModule('conpty_console_list').module.getConsoleProcessList;
|
||||
var shellPid = parseInt(process.argv[2], 10);
|
||||
-var consoleProcessList = getConsoleProcessList(shellPid);
|
||||
+var consoleProcessList;
|
||||
+try {
|
||||
+ consoleProcessList = getConsoleProcessList(shellPid);
|
||||
+}
|
||||
+catch (_a) {
|
||||
+ // Why: AttachConsole can fail after the shell exits; parent already has this fallback.
|
||||
+ consoleProcessList = [shellPid];
|
||||
+}
|
||||
process.send({ consoleProcessList: consoleProcessList });
|
||||
process.exit(0);
|
||||
//# sourceMappingURL=conpty_console_list_agent.js.map
|
||||
\ No newline at end of file
|
||||
diff --git a/lib/unixTerminal.js b/lib/unixTerminal.js
|
||||
index 1ec12f796a822c78fba9ad7f6448c3987e325c23..cec8b67aef02f8199e5606a0d257088bf1865877 100644
|
||||
index 1ec12f796a822c78fba9ad7f6448c3987e325c23..3c4ae8e7818379a4f6f09ee740959292a2c3515f 100644
|
||||
--- a/lib/unixTerminal.js
|
||||
+++ b/lib/unixTerminal.js
|
||||
@@ -28,8 +28,12 @@ var native = utils_1.loadNativeModule('pty');
|
||||
@@ -73,31 +94,52 @@ index 1ec12f796a822c78fba9ad7f6448c3987e325c23..cec8b67aef02f8199e5606a0d257088b
|
||||
var DEFAULT_FILE = 'sh';
|
||||
var DEFAULT_NAME = 'xterm';
|
||||
var DESTROY_SOCKET_TIMEOUT_MS = 200;
|
||||
diff --git a/lib/conpty_console_list_agent.js b/lib/conpty_console_list_agent.js
|
||||
index ccc111c9e03a4a661ccfd5d8e8f0ee699571b5dd..f92c6bef7d46dc35c941c87ef186aa46d8ed9c44 100644
|
||||
--- a/lib/conpty_console_list_agent.js
|
||||
+++ b/lib/conpty_console_list_agent.js
|
||||
@@ -9,7 +9,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
||||
var utils_1 = require("./utils");
|
||||
var getConsoleProcessList = utils_1.loadNativeModule('conpty_console_list').module.getConsoleProcessList;
|
||||
var shellPid = parseInt(process.argv[2], 10);
|
||||
-var consoleProcessList = getConsoleProcessList(shellPid);
|
||||
+var consoleProcessList;
|
||||
+try {
|
||||
+ consoleProcessList = getConsoleProcessList(shellPid);
|
||||
+}
|
||||
+catch (_a) {
|
||||
+ // Why: AttachConsole can fail after the shell exits; parent already has this fallback.
|
||||
+ consoleProcessList = [shellPid];
|
||||
+}
|
||||
process.send({ consoleProcessList: consoleProcessList });
|
||||
process.exit(0);
|
||||
//# sourceMappingURL=conpty_console_list_agent.js.map
|
||||
diff --git a/lib/utils.js b/lib/utils.js
|
||||
index af7918b6902a105a7c49813bc0bb4d687d87a7d4..fd989104227bb4dff8caebef65f2eba2c3bbd7d2 100644
|
||||
--- a/lib/utils.js
|
||||
+++ b/lib/utils.js
|
||||
@@ -20,6 +20,19 @@ function loadNativeModule(name) {
|
||||
// Check relative to the parent dir for unbundled and then the current dir for bundled
|
||||
var relative = ['..', '.'];
|
||||
var lastError;
|
||||
+ // Why (Orca): the Windows updater force-closes every process whose image
|
||||
+ // lives under the app install dir; this env var relocates the native
|
||||
+ // binaries (conpty.dll spawns OpenConsole.exe from beside itself) so PTY
|
||||
+ // console hosts run outside it and survive updates.
|
||||
+ var overrideDir = process.env.ORCA_NODE_PTY_NATIVE_DIR;
|
||||
+ if (overrideDir) {
|
||||
+ try {
|
||||
+ return { dir: overrideDir, module: require(overrideDir + "/" + name + ".node") };
|
||||
+ }
|
||||
+ catch (e) {
|
||||
+ lastError = e;
|
||||
+ }
|
||||
+ }
|
||||
for (var _i = 0, dirs_1 = dirs; _i < dirs_1.length; _i++) {
|
||||
var d = dirs_1[_i];
|
||||
for (var _a = 0, relative_1 = relative; _a < relative_1.length; _a++) {
|
||||
diff --git a/lib/windowsPtyAgent.js b/lib/windowsPtyAgent.js
|
||||
index a358ffb177357e177661033c1b092f9c9d0e5f5a..087c807dce19553970aaa0f81603e4da2043b91a 100644
|
||||
--- a/lib/windowsPtyAgent.js
|
||||
+++ b/lib/windowsPtyAgent.js
|
||||
@@ -181,7 +181,10 @@ var WindowsPtyAgent = /** @class */ (function () {
|
||||
WindowsPtyAgent.prototype._getConsoleProcessList = function () {
|
||||
var _this = this;
|
||||
return new Promise(function (resolve) {
|
||||
- var agent = child_process_1.fork(path.join(__dirname, 'conpty_console_list_agent'), [_this._innerPid.toString()]);
|
||||
+ // Why (Orca): the terminal daemon can run under a console-subsystem
|
||||
+ // node.exe host; without windowsHide this fork flashes a visible
|
||||
+ // console window on every session kill.
|
||||
+ var agent = child_process_1.fork(path.join(__dirname, 'conpty_console_list_agent'), [_this._innerPid.toString()], { windowsHide: true });
|
||||
agent.on('message', function (message) {
|
||||
clearTimeout(timeout);
|
||||
resolve(message.consoleProcessList);
|
||||
diff --git a/src/conpty_console_list_agent.ts b/src/conpty_console_list_agent.ts
|
||||
index f6a653893e0b9b548c514db29d75599538ee1acb..1d5400489f200ef0161ca687e672e1cc02d29c95 100644
|
||||
index 181ccabbbe9c4948a9725fb1db907a68e9de01fc..24519545c34e0f9a4438a814dd1feb9fd18517d1 100644
|
||||
--- a/src/conpty_console_list_agent.ts
|
||||
+++ b/src/conpty_console_list_agent.ts
|
||||
@@ -11,5 +11,11 @@ import { loadNativeModule } from './utils';
|
||||
@@ -10,6 +10,12 @@ import { loadNativeModule } from './utils';
|
||||
|
||||
const getConsoleProcessList = loadNativeModule('conpty_console_list').module.getConsoleProcessList;
|
||||
const shellPid = parseInt(process.argv[2], 10);
|
||||
-const consoleProcessList = getConsoleProcessList(shellPid);
|
||||
@@ -111,7 +153,7 @@ index f6a653893e0b9b548c514db29d75599538ee1acb..1d5400489f200ef0161ca687e672e1cc
|
||||
process.send!({ consoleProcessList });
|
||||
process.exit(0);
|
||||
diff --git a/src/unix/pty.cc b/src/unix/pty.cc
|
||||
index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca850564e6368d9 100644
|
||||
index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..5c28e6465a6fd506cbc6b548b6d9cb918d3be0da 100644
|
||||
--- a/src/unix/pty.cc
|
||||
+++ b/src/unix/pty.cc
|
||||
@@ -23,7 +23,9 @@
|
||||
@@ -122,11 +164,11 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
|
||||
#include <unistd.h>
|
||||
+#include <string>
|
||||
#include <thread>
|
||||
|
||||
|
||||
#include <sys/types.h>
|
||||
@@ -237,13 +239,23 @@ pty_getproc(int, char *);
|
||||
#endif
|
||||
|
||||
|
||||
#if defined(__APPLE__) || defined(__OpenBSD__)
|
||||
+struct pty_spawn_error {
|
||||
+ const char* step;
|
||||
@@ -147,12 +189,12 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
|
||||
- int* err);
|
||||
+ pty_spawn_error* err);
|
||||
#endif
|
||||
|
||||
|
||||
struct DelBuf {
|
||||
@@ -367,10 +379,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) {
|
||||
@@ -167,7 +209,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
|
||||
throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking.");
|
||||
@@ -684,15 +697,73 @@ pty_getproc(int fd, char *tty) {
|
||||
#endif
|
||||
|
||||
|
||||
#if defined(__APPLE__)
|
||||
+static const char*
|
||||
+pty_errno_name(int errnum) {
|
||||
@@ -238,7 +280,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
|
||||
+ 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 +777,118 @@ pty_posix_spawn(char** argv, char** env,
|
||||
@@ -255,7 +297,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
|
||||
+ pty_set_spawn_error(err, "grantpt", errno);
|
||||
+ goto done;
|
||||
}
|
||||
|
||||
|
||||
- int res = grantpt(*master) || unlockpt(*master);
|
||||
+ res = unlockpt(*master);
|
||||
if (res == -1) {
|
||||
@@ -263,7 +305,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
|
||||
+ 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];
|
||||
@@ -273,14 +315,14 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
|
||||
+ 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) {
|
||||
@@ -289,7 +331,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
|
||||
+ goto done;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
if (winp) {
|
||||
res = ioctl(slave, TIOCSWINSZ, winp);
|
||||
if (res == -1) {
|
||||
@@ -298,7 +340,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
|
||||
+ goto done;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
- posix_spawn_file_actions_t acts;
|
||||
- posix_spawn_file_actions_init(&acts);
|
||||
+ res = posix_spawn_file_actions_init(&acts);
|
||||
@@ -312,7 +354,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
|
||||
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);
|
||||
@@ -328,7 +370,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
|
||||
+ 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);
|
||||
@@ -339,7 +381,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
|
||||
+ 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);
|
||||
@@ -349,7 +391,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
|
||||
+ 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);
|
||||
@@ -374,7 +416,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
|
||||
+ close(*master);
|
||||
+ *master = -1;
|
||||
+ }
|
||||
|
||||
|
||||
- for (; count > 0; count--) {
|
||||
- close(low_fds[count]);
|
||||
+ for (size_t i = 0; i <= count && i < 3; i++) {
|
||||
@@ -384,3 +426,19 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
|
||||
}
|
||||
}
|
||||
#endif
|
||||
diff --git a/src/windowsPtyAgent.ts b/src/windowsPtyAgent.ts
|
||||
index d7054449516f0c9a62af351c2caa17331206d530..d6f0892f614d36b42f32547e81d93cabb268c601 100644
|
||||
--- a/src/windowsPtyAgent.ts
|
||||
+++ b/src/windowsPtyAgent.ts
|
||||
@@ -184,7 +184,10 @@ export class WindowsPtyAgent {
|
||||
|
||||
private _getConsoleProcessList(): Promise<number[]> {
|
||||
return new Promise<number[]>(resolve => {
|
||||
- const agent = fork(path.join(__dirname, 'conpty_console_list_agent'), [ this._innerPid.toString() ]);
|
||||
+ // Why (Orca): the terminal daemon can run under a console-subsystem
|
||||
+ // node.exe host; without windowsHide this fork flashes a visible
|
||||
+ // console window on every session kill.
|
||||
+ const agent = fork(path.join(__dirname, 'conpty_console_list_agent'), [ this._innerPid.toString() ], { windowsHide: true });
|
||||
agent.on('message', message => {
|
||||
clearTimeout(timeout);
|
||||
resolve(message.consoleProcessList);
|
||||
|
||||
@@ -17,6 +17,22 @@ const {
|
||||
prunePackagedZodSources,
|
||||
verifyPackagedMainRuntimeDeps
|
||||
} = require('../packaged-runtime-node-modules.cjs')
|
||||
const { ensurePackagedDaemonHostNode } = require('../daemon-host-node-runtime.cjs')
|
||||
|
||||
// process.execPath is read-only in the types; point it at a fixture node.exe.
|
||||
function withStubbedExecPath(value, fn) {
|
||||
const original = process.execPath
|
||||
Object.defineProperty(process, 'execPath', { value, configurable: true, writable: true })
|
||||
try {
|
||||
return fn()
|
||||
} finally {
|
||||
Object.defineProperty(process, 'execPath', {
|
||||
value: original,
|
||||
configurable: true,
|
||||
writable: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
describe('electron-builder config', () => {
|
||||
it('excludes repo-only source trees from app.asar', () => {
|
||||
@@ -207,6 +223,52 @@ describe('electron-builder config', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('stages the build host node.exe into resources/daemon-host for Windows', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-daemon-host-node-'))
|
||||
try {
|
||||
const fakeHostDir = join(root, 'host')
|
||||
await mkdir(fakeHostDir, { recursive: true })
|
||||
const fakeNodeExe = join(fakeHostDir, 'node.exe')
|
||||
await writeFile(fakeNodeExe, 'fake-node-binary', 'utf8')
|
||||
const resourcesDir = join(root, 'resources')
|
||||
await mkdir(resourcesDir, { recursive: true })
|
||||
|
||||
withStubbedExecPath(fakeNodeExe, () => ensurePackagedDaemonHostNode(resourcesDir, 'win32'))
|
||||
|
||||
await expect(readFile(join(resourcesDir, 'daemon-host', 'node.exe'), 'utf8')).resolves.toBe(
|
||||
'fake-node-binary'
|
||||
)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not stage a daemon-host node.exe on non-Windows platforms', async () => {
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-daemon-host-node-skip-'))
|
||||
try {
|
||||
ensurePackagedDaemonHostNode(resourcesDir, 'linux')
|
||||
await expect(readdir(resourcesDir)).resolves.toEqual([])
|
||||
} finally {
|
||||
await rm(resourcesDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('fails the Windows build when the host binary is not a node.exe', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-daemon-host-node-bad-'))
|
||||
try {
|
||||
const notNode = join(root, 'electron.exe')
|
||||
await writeFile(notNode, 'not-node', 'utf8')
|
||||
const resourcesDir = join(root, 'resources')
|
||||
await mkdir(resourcesDir, { recursive: true })
|
||||
|
||||
expect(() =>
|
||||
withStubbedExecPath(notNode, () => ensurePackagedDaemonHostNode(resourcesDir, 'win32'))
|
||||
).toThrow(/not a node\.exe/)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('includes @parcel/watcher in the packaged runtime closure', () => {
|
||||
// Why: the main process imports '@parcel/watcher' for filesystem change
|
||||
// events; if it is absent from the packaged closure the serve host silently
|
||||
|
||||
@@ -178,6 +178,9 @@ describe('Electron runtime package contract', () => {
|
||||
'dist/win-unpacked/resources/node_modules/node-pty/build/Release'
|
||||
)
|
||||
expect(steps[verifyNodePtyIndex].run).toContain('conpty/conpty.dll')
|
||||
// The daemon-host node.exe must be staged alongside the node-pty runtime so
|
||||
// the terminal daemon can be forked from outside the install-dir kill zone.
|
||||
expect(steps[verifyNodePtyIndex].run).toContain('resources/daemon-host/node.exe')
|
||||
|
||||
const uploadThroughDownloadScript = steps
|
||||
.slice(uploadIndex, downloadIndex + 1)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 3.5m">
|
||||
<title>downloads: 3.5m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 3.6m">
|
||||
<title>downloads: 3.6m</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
|
||||
<text x="37" y="14">downloads</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">3.5m</text>
|
||||
<text x="93.5" y="14">3.5m</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">3.6m</text>
|
||||
<text x="93.5" y="14">3.6m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 943 B After Width: | Height: | Size: 943 B |
@@ -178,6 +178,13 @@ export default defineConfig({
|
||||
input: {
|
||||
index: resolve('src/main/index.ts'),
|
||||
'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'),
|
||||
// Why a separate entry: preloaded into the node.exe-hosted daemon via
|
||||
// `--require` before its module graph loads. Inlining it into
|
||||
// daemon-entry does not work — rollup hoists chunk requires above
|
||||
// inlined code, so other modules capture promisify(execFile) first.
|
||||
'windows-hidden-console-children': resolve(
|
||||
'src/main/daemon/windows-hidden-console-children.ts'
|
||||
),
|
||||
'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'),
|
||||
'stt-worker': resolve('src/main/speech/stt-worker.ts'),
|
||||
'warp-theme-parser-worker': resolve('src/main/warp-themes/warp-theme-parser-worker.ts'),
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
Switch,
|
||||
StyleSheet,
|
||||
Platform,
|
||||
ActivityIndicator
|
||||
ActivityIndicator,
|
||||
Keyboard
|
||||
} from 'react-native'
|
||||
import { ChevronDown, ChevronUp, Check } from 'lucide-react-native'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
@@ -174,6 +175,7 @@ function NewWorktreeModalContent({
|
||||
const [repos, setRepos] = useState<Repo[]>(initialRepos ?? [])
|
||||
const [selectedRepo, setSelectedRepo] = useState<Repo | null>(null)
|
||||
const [showRepoPicker, setShowRepoPicker] = useState(false)
|
||||
const [nameAutoFocusEnabled, setNameAutoFocusEnabled] = useState(true)
|
||||
const [selectedAgentState, setSelectedAgent] = useState<AgentOption>(AGENT_OPTIONS[0]!)
|
||||
const [runtimeSettings, setRuntimeSettings] = useState<RuntimeSettings | null>(null)
|
||||
const [detectedAgentIdsState, setDetectedAgentIdsState] = useState<DetectedAgentIdsState | null>(
|
||||
@@ -667,6 +669,13 @@ function NewWorktreeModalContent({
|
||||
[repos]
|
||||
)
|
||||
|
||||
function prepareSelectionPickerOpen(): void {
|
||||
// Why: picker taps can beat the delayed name-field focus; suppressing it
|
||||
// prevents the keyboard from reopening under the picker drawer.
|
||||
setNameAutoFocusEnabled(false)
|
||||
Keyboard.dismiss()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<BottomDrawer visible={visible} onClose={onClose}>
|
||||
@@ -689,7 +698,13 @@ function NewWorktreeModalContent({
|
||||
<>
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Repository</Text>
|
||||
<Pressable style={styles.fieldButton} onPress={() => setShowRepoPicker(true)}>
|
||||
<Pressable
|
||||
style={styles.fieldButton}
|
||||
onPress={() => {
|
||||
prepareSelectionPickerOpen()
|
||||
setShowRepoPicker(true)
|
||||
}}
|
||||
>
|
||||
{selectedRepo ? (
|
||||
<View
|
||||
style={[styles.repoDot, { backgroundColor: repoBadgeColor(selectedRepo) }]}
|
||||
@@ -760,7 +775,7 @@ function NewWorktreeModalContent({
|
||||
setError('')
|
||||
}}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
shouldAutoFocus={visible && !loading && repos.length > 0}
|
||||
shouldAutoFocus={nameAutoFocusEnabled && visible && !loading && repos.length > 0}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={() => {
|
||||
if (canCreate) {
|
||||
@@ -775,7 +790,10 @@ function NewWorktreeModalContent({
|
||||
<Pressable
|
||||
style={[styles.fieldButton, sshGate.requiresConnection && styles.disabled]}
|
||||
disabled={sshGate.requiresConnection}
|
||||
onPress={() => setShowAgentPicker(true)}
|
||||
onPress={() => {
|
||||
prepareSelectionPickerOpen()
|
||||
setShowAgentPicker(true)
|
||||
}}
|
||||
>
|
||||
<MobileAgentIcon agentId={selectedAgent.id} size={16} />
|
||||
<Text style={styles.fieldButtonText} numberOfLines={1}>
|
||||
|
||||
@@ -15,6 +15,10 @@ function isConflicting(item: MobilePRAutoMergeAvailabilityInput): boolean {
|
||||
return item.mergeable === 'CONFLICTING' || item.mergeStateStatus === 'DIRTY'
|
||||
}
|
||||
|
||||
function isUnstable(item: MobilePRAutoMergeAvailabilityInput): boolean {
|
||||
return item.mergeStateStatus === 'UNSTABLE'
|
||||
}
|
||||
|
||||
function hasReviewRequirement(item: MobilePRAutoMergeAvailabilityInput): boolean {
|
||||
return item.reviewDecision === 'REVIEW_REQUIRED' || item.reviewDecision === 'CHANGES_REQUESTED'
|
||||
}
|
||||
@@ -27,7 +31,9 @@ function canMergeImmediately(item: MobilePRAutoMergeAvailabilityInput): boolean
|
||||
}
|
||||
|
||||
function canRequestWhenReady(item: MobilePRAutoMergeAvailabilityInput): boolean {
|
||||
if (item.state !== 'open' || isConflicting(item)) {
|
||||
// Why: GitHub auto-merge waits on unmet requirements; UNSTABLE is rejected
|
||||
// by the mutation rather than becoming a waitable auto-merge request.
|
||||
if (item.state !== 'open' || isConflicting(item) || isUnstable(item)) {
|
||||
return false
|
||||
}
|
||||
if (item.mergeQueueRequired === true) {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orca",
|
||||
"version": "1.4.123-rc.0",
|
||||
"version": "1.4.124-rc.7",
|
||||
"description": "Next-gen IDE for parallel agentic development",
|
||||
"homepage": "https://github.com/stablyai/orca",
|
||||
"author": "stablyai",
|
||||
|
||||
Generated
+3
-3
@@ -12,7 +12,7 @@ patchedDependencies:
|
||||
hash: 296e716bb67c0aa3d4ff47d493b9fd9ba9518b7e9f6597005fd680ac04274145
|
||||
path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
|
||||
node-pty@1.1.0:
|
||||
hash: 407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282
|
||||
hash: 1b6a77f2c7772dffee8c92116eeb470ae52cd58fe7706755027e78792d684b8a
|
||||
path: config/patches/node-pty@1.1.0.patch
|
||||
|
||||
importers:
|
||||
@@ -54,7 +54,7 @@ importers:
|
||||
version: 3.3.1
|
||||
node-pty:
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0(patch_hash=407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282)
|
||||
version: 1.1.0(patch_hash=1b6a77f2c7772dffee8c92116eeb470ae52cd58fe7706755027e78792d684b8a)
|
||||
posthog-node:
|
||||
specifier: ^5.33.3
|
||||
version: 5.33.3
|
||||
@@ -11747,7 +11747,7 @@ snapshots:
|
||||
undici: 6.27.0
|
||||
which: 6.0.1
|
||||
|
||||
node-pty@1.1.0(patch_hash=407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282):
|
||||
node-pty@1.1.0(patch_hash=1b6a77f2c7772dffee8c92116eeb470ae52cd58fe7706755027e78792d684b8a):
|
||||
dependencies:
|
||||
node-addon-api: 7.1.1
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg width="36" height="32" viewBox="0 0 36.9143 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear" x1="0" y1="15.5" x2="36.9143" y2="15.5" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E21680"/>
|
||||
<stop offset="1" stop-color="#FF633A"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M17.5802 3.08155C17.5802 2.35036 16.987 1.75702 16.2589 1.75702C15.5309 1.75702 14.9377 2.35171 14.9377 3.08155V25.0348C14.9377 26.7337 13.5586 28.1164 11.8639 28.1164C10.1693 28.1164 8.79008 26.7337 8.79008 25.0348V10.9503C8.79008 10.2191 8.19688 9.62575 7.46886 9.62575C6.74085 9.62575 6.14765 10.2204 6.14765 10.9503V16.9201C6.14765 18.619 4.76846 20.0016 3.07381 20.0016C1.37915 20.0016 0 18.619 0 16.9201V14.7563C0 14.2711 0.392318 13.8778 0.876312 13.8778C1.36031 13.8778 1.75262 14.2711 1.75262 14.7563V16.9201C1.75262 17.6513 2.34579 18.2446 3.07381 18.2446C3.80182 18.2446 4.39502 17.6499 4.39502 16.9201V10.9503C4.39502 9.25138 5.77421 7.86874 7.46886 7.86874C9.16352 7.86874 10.5427 9.25138 10.5427 10.9503V25.0348C10.5427 25.766 11.1359 26.3593 11.8639 26.3593C12.5919 26.3593 13.1851 25.7646 13.1851 25.0348V16.4227V3.08155C13.1851 1.38265 14.5643 0 16.2589 0C17.9536 0 19.3328 1.38265 19.3328 3.08155V21.2599C19.3328 21.7451 18.9405 22.1384 18.4565 22.1384C17.9725 22.1384 17.5802 21.7451 17.5802 21.2599V3.08155ZM33.8405 7.86874C32.1458 7.86874 30.7666 9.25138 30.7666 10.9503V22.7155C30.7666 23.4467 30.1734 24.0401 29.4454 24.0401C28.7174 24.0401 28.1242 23.4454 28.1242 22.7155V3.08155C28.1242 1.38265 26.745 0 25.0504 0C23.3557 0 21.9766 1.38265 21.9766 3.08155V28.0042C21.9766 28.7354 21.3834 29.3287 20.6553 29.3287C19.9273 29.3287 19.3341 28.734 19.3341 28.0042V24.9186C19.3341 24.4334 18.9418 24.0401 18.4578 24.0401C17.9738 24.0401 17.5815 24.4334 17.5815 24.9186V28.0042C17.5815 29.7031 18.9607 31.0857 20.6553 31.0857C22.35 31.0857 23.7292 29.7031 23.7292 28.0042V3.08155C23.7292 2.35036 24.3224 1.75702 25.0504 1.75702C25.7784 1.75702 26.3716 2.35171 26.3716 3.08155V22.7155C26.3716 24.4144 27.7508 25.7971 29.4454 25.7971C31.1401 25.7971 32.5193 24.4144 32.5193 22.7155V10.9503C32.5193 10.2191 33.1125 9.62575 33.8405 9.62575C34.5685 9.62575 35.1617 10.2204 35.1617 10.9503V21.2599C35.1617 21.7451 35.554 22.1384 36.038 22.1384C36.522 22.1384 36.9143 21.7451 36.9143 21.2599V10.9503C36.9143 9.25138 35.5351 7.86874 33.8405 7.86874Z" fill="url(#paint0_linear)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -6,7 +6,7 @@ description: >-
|
||||
for each workspace. Covers first-time setup (provider prerequisites, the
|
||||
reusable base snapshot, the coding-agent auth snapshot, credentials, and
|
||||
state), not just the per-workspace lifecycle scripts. Use to stand up
|
||||
per-workspace environments, fix a `environmentRecipes` entry in `orca.yaml`, scaffold
|
||||
per-workspace environments, fix an `environmentRecipes` entry in `orca.yaml`, scaffold
|
||||
provider lifecycle scripts, or resolve an `orca vm recipe doctor` failure.
|
||||
---
|
||||
|
||||
@@ -35,6 +35,11 @@ them in order:
|
||||
|
||||
Then the **per-workspace contract** (create/suspend/resume/destroy) runs fast (§8).
|
||||
|
||||
**The one branch that shapes everything — connection mode:** **Orca-server** (`create` runs `orca serve`
|
||||
in the env and emits a `pairingCode`; §7c/§7f) vs **SSH** (`create` runs no server and emits a
|
||||
`connection.type:"ssh"` block Orca dials into; §7g/§7h). Settle this first — it changes the `create`
|
||||
output shape and half the templates.
|
||||
|
||||
**Quick-start (happy path):** interview the user (connection mode Orca-server vs SSH, provider, agent CLI,
|
||||
git auth — §1.2) + read the provider's CLI docs → scaffold `scripts/orca-vm/` from §7 → run the
|
||||
base-snapshot script, then the auth script (you invoke these by hand; not via `orca.yaml`) → wire
|
||||
@@ -70,7 +75,13 @@ a long time, or need the user at the keyboard. Never create an Orca workspace or
|
||||
4. **Scaffold scripts + state file** from §7 (worked Vercel example: §7f; SSH host: §7g; Docker SSH:
|
||||
§7h; Windows: §7i), filling in the provider's real commands. Make them executable.
|
||||
5. **[CHECKPOINT] Build the base snapshot (§3)** — paid, slow.
|
||||
6. **[CHECKPOINT] Authenticate the agent (§4)** — interactive; the user follows a URL/code.
|
||||
6. **[CHECKPOINT] Authenticate the agent (§4)** — interactive; the user follows a URL/code. **You cannot
|
||||
drive this step** — you run commands non-interactively, so there's no TTY for `docker exec -it` /
|
||||
`ssh -t` to prompt against. The **user** runs the Phase-3 login in their own terminal (or via the
|
||||
Claude Code harness bang-prefix — `! <cmd>`, with the required space after `!`); you scaffold and drive
|
||||
the non-interactive phases around it. After kicking it off, **ask the user to report back once the login
|
||||
finishes** — you can't observe it completing, and you need that confirmation before resuming the
|
||||
non-interactive steps (base/auth commit, doctor, provision).
|
||||
7. **Wire the recipe** so `orca.yaml` points create/suspend/resume/destroy at the scripts (§8). The
|
||||
workspace composer reads `environmentRecipes` from the project's primary checkout of `orca.yaml`, **not** from
|
||||
a feature branch or worktree. So a recipe added only on a branch won't appear as a "Run on" option
|
||||
@@ -127,12 +138,24 @@ The base snapshot has the agent CLI installed but **not logged in**, and per-wor
|
||||
ephemeral — so authenticate once and bake it into a second snapshot layer. Script shape is §7b:
|
||||
|
||||
1. Boot a sandbox from the base `snapshotId` (from state).
|
||||
2. Run the agent's device/OAuth login **interactively** (`--interactive --tty`); the user completes the
|
||||
URL/code in their browser.
|
||||
3. Verify login; **refuse to snapshot an unauthenticated VM.**
|
||||
2. Run the agent's login **interactively** (`--interactive --tty`); the user completes the URL/code in
|
||||
their browser. On a **headless VM this must be the device-auth flow** (e.g. `codex login --device-auth`),
|
||||
**not** plain `codex login`: the default OAuth login starts a loopback callback server on a container
|
||||
port the host browser can't reach, so it hangs. Device-auth instead prints a URL + code the user opens
|
||||
on the **host**.
|
||||
3. Verify login; **refuse to snapshot an unauthenticated VM.** Prefer the status command's **exit code**
|
||||
(most agent CLIs exit non-zero when unauthenticated). If you grep instead, agent status often goes to
|
||||
**stderr** (e.g. `codex login status` prints "Logged in using ChatGPT" there), so **fold stderr first**
|
||||
(`... 2>&1 | grep …`) and match the agent's **exact success line** — never `grep -qi 'logged in'`, which
|
||||
also matches "**not** logged in" and would commit an unauthenticated image.
|
||||
4. Re-snapshot, parse the new id, and overwrite `snapshotId` in state to the authenticated image
|
||||
(recording `authSourceSnapshotId`). Remove the auth sandbox.
|
||||
|
||||
**You can't drive step 2 yourself** (you run commands non-interactively — no TTY). The **user** runs it in
|
||||
their own terminal, or via the Claude Code harness bang-prefix (`! <cmd>`, with the required space after
|
||||
`!`). You scaffold/boot the sandbox and run steps 3–4, but **you cannot observe the interactive login
|
||||
finishing** — so **ask the user to tell you when it's done** before you verify and re-snapshot.
|
||||
|
||||
If the agent's credentials are short-lived, warn that the snapshot may need periodic re-auth (§10).
|
||||
|
||||
For disposable runtimes, do **not** treat a host agent config directory (for example `~/.codex`) as the
|
||||
@@ -148,7 +171,10 @@ inside the disposable runtime and snapshot/commit that runtime layer.
|
||||
- **Git token:** read from env (`GH_TOKEN`/`GITHUB_TOKEN`), falling back to `gh auth token`. Pass to the
|
||||
VM only via the provider's ephemeral `--env`. Inside the VM, use a `GIT_ASKPASS` helper with
|
||||
`x-access-token` (not the token in the clone URL) and `GIT_TERMINAL_PROMPT=0` so a missing token fails
|
||||
fast instead of hanging.
|
||||
fast instead of hanging. When you write the helper from inside `bash -lc` under `set -u`, escape the
|
||||
positional arg and the token (`\$1`, `\$GH_TOKEN`) so they land **literally** and resolve at git-runtime
|
||||
— an unescaped `$1` aborts with "unbound variable", and a literal `$GH_TOKEN` keeps the real token out of
|
||||
the written file. `rm -f` the helper after the clone/fetch.
|
||||
- **Provider auth:** rely on the provider CLI's logged-in session, not checked-in keys.
|
||||
- **Agent auth:** lives in the authenticated snapshot (Phase 3) — never a file you write or commit.
|
||||
- State holds only **non-secret** wiring (snapshot ids, scope, project, port, repo url/ref).
|
||||
@@ -220,8 +246,14 @@ repo URL/ref, and a git token (`GH_TOKEN`); later runs read them back from state
|
||||
set -euo pipefail
|
||||
# read source snapshot from state.snapshotId (fail if absent); auth_name="${base_name}-auth"
|
||||
# 1. boot sandbox from source snapshot; trap: remove on error
|
||||
# 2. INTERACTIVE/TTY remote exec: agent device/oauth login — user completes URL/code
|
||||
# 3. verify login status; refuse to snapshot if not logged in
|
||||
# 2. INTERACTIVE/TTY remote exec: agent login — user completes URL/code. Headless VM: MUST use the
|
||||
# device-auth flow (e.g. `codex login --device-auth`) — plain OAuth login binds a loopback callback
|
||||
# port the host can't reach and hangs. User runs this themselves (you have no interactive TTY); ask
|
||||
# them to report back when it's done before continuing.
|
||||
# 3. verify login, then refuse to snapshot if not logged in. Prefer the status command's EXIT CODE (most
|
||||
# agent CLIs exit non-zero when unauthenticated) over string-matching. If you must grep, fold stderr
|
||||
# first (`status 2>&1 | grep …` — many agents print the success line there) and match the agent's exact
|
||||
# success line; never `grep -qi 'logged in'`, which also matches "not logged in". Codex example: §7f.
|
||||
# 4. snapshot; parse new id
|
||||
# 5. merge { snapshotId:<new>, authSourceSnapshotId:<source> } into state; remove auth sandbox
|
||||
# print only the state JSON to stdout
|
||||
@@ -300,8 +332,10 @@ These ground §7a (base snapshot) and §7b (auth), which are otherwise generic s
|
||||
# provision a fresh build sandbox (retain a couple of snapshots); trap-remove on error
|
||||
vercel sandbox create --name "$base" --runtime node24 --timeout 30m --vcpus 4 --publish-port "$port" \
|
||||
--snapshot-expiration 30d --keep-last-snapshots 2 "${vercel_args[@]}" >&2
|
||||
# remote build (long timeout): install pkgs+gh+pnpm+agent CLI, clone with GIT_ASKPASS, write the
|
||||
# headless main-only build config (drop the renderer), dev setup, build CLI + headless main, smoke-check
|
||||
# remote build (long timeout): install pkgs+gh+pnpm+agent CLI, clone with GIT_ASKPASS (write the helper
|
||||
# with LITERAL \$1/\$GH_TOKEN so they resolve at git-runtime, not write-time — see §5/§7f create — then
|
||||
# `rm -f /tmp/askpass.sh`), write the headless main-only build config (drop the renderer), dev setup,
|
||||
# build CLI + headless main, smoke-check
|
||||
vercel sandbox exec "$base" "${vercel_args[@]}" --timeout 25m --env "GH_TOKEN=$gh_token" … -- bash -lc '…build…' >&2
|
||||
# snapshot the STOPPED sandbox and parse the id from CLI output (fail if unparseable)
|
||||
out="$(vercel sandbox snapshot "$base" --stop --expiration 30d "${vercel_args[@]}" 2>&1)"; printf '%s\n' "$out" >&2
|
||||
@@ -314,10 +348,12 @@ snapshot_id="$(printf '%s\n' "$out" | sed -nE 's/.*(snap_[A-Za-z0-9]+).*/\1/p' |
|
||||
|
||||
```bash
|
||||
vercel sandbox create --name "$auth" --snapshot "$snapshot_id" --timeout 30m --publish-port "$port" "${vercel_args[@]}" >&2
|
||||
# INTERACTIVE: the user completes the device-auth URL/code in their browser
|
||||
# INTERACTIVE — the USER runs this in their own terminal (you have no interactive TTY) and completes the
|
||||
# URL/code on the HOST. --device-auth is MANDATORY on a headless VM: plain `codex login` binds a loopback
|
||||
# callback port the host browser can't reach and hangs. Ask the user to report back when login finishes.
|
||||
vercel sandbox exec --interactive --tty "$auth" "${vercel_args[@]}" -- bash -lc 'codex login --device-auth'
|
||||
# refuse to snapshot an unauthenticated VM
|
||||
vercel sandbox exec "$auth" "${vercel_args[@]}" --timeout 30s -- bash -lc 'codex login status' | grep -qi 'logged in' \
|
||||
# refuse to snapshot an unauthenticated VM — fold stderr, match codex's exact success line (§4)
|
||||
vercel sandbox exec "$auth" "${vercel_args[@]}" --timeout 30s -- bash -lc 'codex login status 2>&1' | grep -Eqi 'Logged in using ChatGPT|Logged in via device' \
|
||||
|| { echo "agent not logged in; not snapshotting" >&2; exit 1; }
|
||||
out="$(vercel sandbox snapshot "$auth" --stop --expiration 30d "${vercel_args[@]}" 2>&1)"; printf '%s\n' "$out" >&2
|
||||
new_id="$(printf '%s\n' "$out" | sed -nE 's/.*(snap_[A-Za-z0-9]+).*/\1/p' | tail -1)"
|
||||
@@ -352,12 +388,15 @@ vercel sandbox exec "$name" "${vercel_args[@]}" --timeout 20m \
|
||||
--env "GH_TOKEN=$gh_token" --env "ORCA_PROJECT_ROOT=$project_root" \
|
||||
--env "ORCA_REPO_URL=$repo_url" --env "ORCA_REPO_REF=$repo_ref" \
|
||||
-- bash -lc 'set -euo pipefail; cd "$ORCA_PROJECT_ROOT"; \
|
||||
# Re-establish git auth for the private-repo fetch (same as §5); without this it hangs on a prompt.
|
||||
# Re-establish git auth for the private-repo fetch (why + full rationale: §5); else it hangs on a prompt.
|
||||
# Load-bearing escaping: \$1 and \$GH_TOKEN must land LITERALLY and resolve at git-runtime. Test after
|
||||
# any edit here — reformatting the nested printf/node quoting silently breaks the fetch or leaks the token.
|
||||
if [ -n "${GH_TOKEN:-}" ]; then \
|
||||
printf "%s\n" "#!/usr/bin/env bash" "case \"\$1\" in *Username*) echo x-access-token;; *Password*) echo \"$GH_TOKEN\";; esac" > /tmp/askpass.sh; \
|
||||
printf "%s\n" "#!/usr/bin/env bash" "case \"\$1\" in *Username*) echo x-access-token;; *Password*) echo \"\$GH_TOKEN\";; esac" > /tmp/askpass.sh; \
|
||||
chmod 700 /tmp/askpass.sh; export GIT_ASKPASS=/tmp/askpass.sh GIT_TERMINAL_PROMPT=0; fi; \
|
||||
git fetch origin "$ORCA_REPO_REF"; \
|
||||
git checkout -B "$ORCA_REPO_REF" FETCH_HEAD; \
|
||||
rm -f /tmp/askpass.sh; \
|
||||
c="$(git rev-parse HEAD)"; [ -f .orca-built ] && [ "$(cat .orca-built)" = "$c" ] || { \
|
||||
pnpm install --prefer-offline && pnpm run build:cli && \
|
||||
node config/scripts/run-electron-vite-build.mjs --config config/electron-vite.vm-serve.config.ts && \
|
||||
@@ -486,8 +525,14 @@ Key points:
|
||||
`connection.type:"ssh"` with `host:"127.0.0.1"`, that port, `username`, `identityFile`, and
|
||||
`identitiesOnly:true`.
|
||||
- Generate a repo-local SSH key if needed, but gitignore the private/public key files.
|
||||
- The auth image is the Docker equivalent of Phase 3: let the user run `codex login`, configure proxy
|
||||
env/config, approve hooks, and verify the agent **inside** the container, then commit it.
|
||||
- **Bake SSH host keys into the base image** (`ssh-keygen -A` at **build** time; at runtime only generate
|
||||
if absent). Ephemeral containers all present the **same** host key, so `known_hosts` on `127.0.0.1`
|
||||
doesn't churn as the published port rotates across workspaces (otherwise every container's freshly
|
||||
generated key collides on `localhost` and trips host-key-changed warnings).
|
||||
- The auth image is the Docker equivalent of Phase 3: the **user** runs the agent login **inside** the
|
||||
container (you can't drive it — you have no interactive TTY), configures proxy env/config, approves
|
||||
hooks, and you commit once they report it's done. On a headless container use the **device-auth** flow
|
||||
(§4). Verify login before committing — exit code, or fold stderr and match the exact success line (§4).
|
||||
- Do not bind-mount or copy the host's full agent home into the image. Let each container have writable
|
||||
agent state; only the committed auth image should carry reusable authenticated state.
|
||||
- If committing from an interactive shell, force the runtime entrypoint back to `sshd`:
|
||||
@@ -507,6 +552,10 @@ ssh -i "$key" -p "$port" -o IdentitiesOnly=yes user@127.0.0.1 'codex --version'
|
||||
If the container exits immediately, inspect logs before the cleanup trap removes it; a committed
|
||||
interactive image with `ENTRYPOINT ["bash"]` is a common cause.
|
||||
|
||||
Also confirm the **host key is stable** across containers: the SSH `ssh -i … 127.0.0.1` dial should not
|
||||
trigger a host-key-changed warning when a second container reuses the port. If it does, the host keys
|
||||
weren't baked into the base image (see the `ssh-keygen -A` point above).
|
||||
|
||||
### 7i. Windows local-side scripts
|
||||
|
||||
The local-side scripts run on the user's desktop. On **Windows**, a bare `.sh` won't execute. Either
|
||||
@@ -640,6 +689,20 @@ startup-only `docker run` before the full clone/install path.
|
||||
- **Build exceeds plan RAM.** Build the **headless main only** (drop the renderer) — the biggest fitter.
|
||||
- **Private-repo clone hangs/fails.** Wrong/missing token. Use `GIT_ASKPASS` + `GIT_TERMINAL_PROMPT=0`
|
||||
so it fails fast instead of prompting.
|
||||
- **`GIT_ASKPASS` helper aborts the clone with "`$1: unbound variable`".** The `printf`/heredoc that writes
|
||||
the helper inside `bash -lc` under `set -u` expanded `$1`/`$GH_TOKEN` at **write** time. Escape them
|
||||
(`\$1`, `\$GH_TOKEN`) so they land literally and resolve at git-runtime; this also keeps the real token
|
||||
out of the file. `rm -f` the helper afterward (§5, §7f).
|
||||
- **Agent verified as "not logged in" despite a good login.** `codex login status` (and similar) print
|
||||
"Logged in …" to **stderr**; an stdout-only `grep` misses it. Prefer the status **exit code**; if you
|
||||
grep, fold stderr first (`status 2>&1 | grep …`) and match the exact success line — not `grep -qi
|
||||
'logged in'`, which also matches "not logged in".
|
||||
- **Headless agent login hangs.** Plain OAuth `login` starts a loopback callback server on a VM/container
|
||||
port the host browser can't reach. Use the **device-auth** flow (`login --device-auth`) — it prints a
|
||||
URL + code the user opens on the host.
|
||||
- **`known_hosts` host-key churn on local Docker.** Each ephemeral container regenerating its SSH host key
|
||||
collides on `127.0.0.1` as the published port rotates. Bake host keys into the base image at build time
|
||||
(`ssh-keygen -A`; runtime generates only if absent) so all containers share one stable key (§7h).
|
||||
- **Snapshot expired/evicted.** If `create` hits an unknown snapshot id, rerun Phases 2–3 and update
|
||||
`snapshotId`.
|
||||
- **Agent auth didn't persist.** Confirm `snapshotId` points at the **authenticated** snapshot; re-run
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { IFilesystemProvider } from '../providers/types'
|
||||
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import { joinRemotePath } from '../ssh/ssh-remote-platform'
|
||||
import { extractString, normalizeTitleText, parseJsonObject } from './session-scanner-values'
|
||||
|
||||
const CODEX_SESSION_INDEX_FILE = 'session_index.jsonl'
|
||||
|
||||
export async function remoteCodexIndexTitles(args: {
|
||||
provider: IFilesystemProvider
|
||||
codexHome: string
|
||||
hostPlatform: RemoteHostPlatform
|
||||
titleCaches: Map<string, Promise<Map<string, string>>>
|
||||
}): Promise<Map<string, string>> {
|
||||
const cached = args.titleCaches.get(args.codexHome)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
const pending = readRemoteCodexIndexTitles(args.provider, args.codexHome, args.hostPlatform)
|
||||
args.titleCaches.set(args.codexHome, pending)
|
||||
return pending
|
||||
}
|
||||
|
||||
async function readRemoteCodexIndexTitles(
|
||||
provider: IFilesystemProvider,
|
||||
codexHome: string,
|
||||
hostPlatform: RemoteHostPlatform
|
||||
): Promise<Map<string, string>> {
|
||||
const titleBySessionId = new Map<string, string>()
|
||||
try {
|
||||
const { content, isBinary } = await provider.readFile(
|
||||
joinRemotePath(hostPlatform, codexHome, CODEX_SESSION_INDEX_FILE)
|
||||
)
|
||||
if (isBinary) {
|
||||
return titleBySessionId
|
||||
}
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
}
|
||||
const sessionId = extractString(record.id)
|
||||
const title = normalizeTitleText(extractString(record.thread_name) ?? '')
|
||||
if (sessionId && title) {
|
||||
titleBySessionId.set(sessionId, title)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Codex indexes are opportunistic; raw transcripts remain sufficient.
|
||||
}
|
||||
return titleBySessionId
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import type { AiVaultAgent, AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import { joinRemotePath } from '../ssh/ssh-remote-platform'
|
||||
import { parseCodexSessionContent } from './session-scanner-codex-parser'
|
||||
import { parseDevinSessionContent } from './session-scanner-devin-parser'
|
||||
import { parseDroidSessionContent } from './session-scanner-droid-parser'
|
||||
import { parseMessageGraphSessionContent } from './session-scanner-graph-parsers'
|
||||
import {
|
||||
parseClaudeSessionContent,
|
||||
parseGeminiSessionContent
|
||||
} from './session-scanner-primary-parsers'
|
||||
import {
|
||||
parseCopilotSessionContent,
|
||||
parseCursorSessionContent,
|
||||
parseHermesSessionContent
|
||||
} from './session-scanner-secondary-parsers'
|
||||
import type { FileWithMtime } from './session-scanner-types'
|
||||
import { normalizePiSessionsDir } from './session-scanner-values'
|
||||
import { remoteCodexIndexTitles } from './remote-session-scanner-codex-index'
|
||||
import type {
|
||||
RemoteParserOptions,
|
||||
RemoteScannerContext,
|
||||
RemoteSessionSource
|
||||
} from './remote-session-scanner-types'
|
||||
|
||||
type RemoteContentParser = (
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform,
|
||||
options: RemoteParserOptions
|
||||
) => Promise<AiVaultSession | null> | AiVaultSession | null
|
||||
|
||||
export function remoteSessionSources(
|
||||
remoteHome: string,
|
||||
hostPlatform: RemoteHostPlatform
|
||||
): RemoteSessionSource[] {
|
||||
return [
|
||||
...remoteCodexSources(remoteHome, hostPlatform),
|
||||
jsonlSource(
|
||||
'claude',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
['.claude', 'projects'],
|
||||
parseClaudeSessionContent
|
||||
),
|
||||
source(
|
||||
'gemini',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
['.gemini', 'tmp'],
|
||||
['.json', '.jsonl'],
|
||||
parseGeminiSessionContent
|
||||
),
|
||||
jsonlSource(
|
||||
'copilot',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
['.copilot', 'session-state'],
|
||||
parseCopilotSessionContent
|
||||
),
|
||||
jsonlSource(
|
||||
'cursor',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
['.cursor', 'projects'],
|
||||
parseCursorSessionContent,
|
||||
(path) => remotePathSegments(path).includes('agent-transcripts')
|
||||
),
|
||||
source(
|
||||
'hermes',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
['.hermes', 'sessions'],
|
||||
['.json'],
|
||||
parseHermesSessionContent
|
||||
),
|
||||
source(
|
||||
'devin',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
['.local', 'share', 'devin', 'cli', 'transcripts'],
|
||||
['.json'],
|
||||
parseDevinSessionContent
|
||||
),
|
||||
jsonlSource('pi', remoteHome, hostPlatform, remotePiSessionsSegments(), piParser),
|
||||
jsonlSource(
|
||||
'droid',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
['.factory', 'sessions'],
|
||||
parseDroidSessionContent
|
||||
),
|
||||
jsonlSource(
|
||||
'droid',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
['.factory', 'projects'],
|
||||
parseDroidSessionContent
|
||||
),
|
||||
...remoteOpenClawSources(remoteHome, hostPlatform)
|
||||
]
|
||||
}
|
||||
|
||||
function source(
|
||||
agent: AiVaultAgent,
|
||||
remoteHome: string,
|
||||
hostPlatform: RemoteHostPlatform,
|
||||
segments: readonly string[],
|
||||
extensions: readonly string[],
|
||||
parseContent: RemoteContentParser,
|
||||
filePredicate?: (path: string) => boolean
|
||||
): RemoteSessionSource {
|
||||
return {
|
||||
agent,
|
||||
rootDir: joinRemotePath(hostPlatform, remoteHome, ...segments),
|
||||
extensions,
|
||||
filePredicate,
|
||||
parse: (file, content, context) =>
|
||||
Promise.resolve(parseContent(file, content, context.hostPlatform.os, parserOptions(context)))
|
||||
}
|
||||
}
|
||||
|
||||
function jsonlSource(
|
||||
agent: AiVaultAgent,
|
||||
remoteHome: string,
|
||||
hostPlatform: RemoteHostPlatform,
|
||||
segments: readonly string[],
|
||||
parseContent: RemoteContentParser,
|
||||
filePredicate?: (path: string) => boolean
|
||||
): RemoteSessionSource {
|
||||
return source(agent, remoteHome, hostPlatform, segments, ['.jsonl'], parseContent, filePredicate)
|
||||
}
|
||||
|
||||
function remoteCodexSources(
|
||||
remoteHome: string,
|
||||
hostPlatform: RemoteHostPlatform
|
||||
): RemoteSessionSource[] {
|
||||
return [
|
||||
joinRemotePath(hostPlatform, remoteHome, '.codex'),
|
||||
joinRemotePath(
|
||||
hostPlatform,
|
||||
remoteHome,
|
||||
'.local',
|
||||
'share',
|
||||
'orca',
|
||||
'codex-runtime-home',
|
||||
'home'
|
||||
)
|
||||
].map((codexHome) => ({
|
||||
agent: 'codex',
|
||||
rootDir: joinRemotePath(hostPlatform, codexHome, 'sessions'),
|
||||
extensions: ['.jsonl'],
|
||||
parse: (file, content, context) =>
|
||||
parseCodexSessionContent({
|
||||
file,
|
||||
content,
|
||||
platform: context.hostPlatform.os,
|
||||
codexHome,
|
||||
executionHostId: context.executionHostId,
|
||||
executionHostPlatform: context.hostPlatform.os,
|
||||
readIndexedTitle: async (sessionId) =>
|
||||
(
|
||||
await remoteCodexIndexTitles({
|
||||
provider: context.provider,
|
||||
codexHome,
|
||||
hostPlatform,
|
||||
titleCaches: context.titleCaches
|
||||
})
|
||||
).get(sessionId) ?? null
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
function remoteOpenClawSources(
|
||||
remoteHome: string,
|
||||
hostPlatform: RemoteHostPlatform
|
||||
): RemoteSessionSource[] {
|
||||
return ['.openclaw', '.clawdbot'].map((rootName) =>
|
||||
jsonlSource(
|
||||
'openclaw',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
[rootName, 'agents'],
|
||||
openClawParser,
|
||||
(path) => remotePathSegments(path).includes('sessions')
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function parserOptions(context: RemoteScannerContext): RemoteParserOptions {
|
||||
return {
|
||||
executionHostId: context.executionHostId,
|
||||
executionHostPlatform: context.hostPlatform.os
|
||||
}
|
||||
}
|
||||
|
||||
function piParser(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform,
|
||||
options: RemoteParserOptions
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseMessageGraphSessionContent('pi', file, content, platform, options)
|
||||
}
|
||||
|
||||
function openClawParser(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform,
|
||||
options: RemoteParserOptions
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseMessageGraphSessionContent('openclaw', file, content, platform, options)
|
||||
}
|
||||
|
||||
function remotePathSegments(path: string): string[] {
|
||||
return path.replace(/\\/g, '/').split('/').filter(Boolean)
|
||||
}
|
||||
|
||||
function remotePiSessionsSegments(): string[] {
|
||||
return normalizePiSessionsDir('/.pi/agent/sessions').split('/').filter(Boolean)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { AiVaultAgent, AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { IFilesystemProvider } from '../providers/types'
|
||||
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import type { FileWithMtime } from './session-scanner-types'
|
||||
|
||||
export type RemoteScannerContext = {
|
||||
provider: IFilesystemProvider
|
||||
executionHostId: ExecutionHostId
|
||||
hostPlatform: RemoteHostPlatform
|
||||
titleCaches: Map<string, Promise<Map<string, string>>>
|
||||
}
|
||||
|
||||
export type RemoteParserOptions = {
|
||||
executionHostId: ExecutionHostId
|
||||
executionHostPlatform: NodeJS.Platform
|
||||
}
|
||||
|
||||
export type RemoteSessionSource = {
|
||||
agent: AiVaultAgent
|
||||
rootDir: string
|
||||
extensions: readonly string[]
|
||||
filePredicate?: (path: string) => boolean
|
||||
parse: (
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
context: RemoteScannerContext
|
||||
) => Promise<AiVaultSession | null>
|
||||
}
|
||||
|
||||
export type RemoteSessionCandidate = {
|
||||
source: RemoteSessionSource
|
||||
file: FileWithMtime
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { DirEntry } from '../../shared/types'
|
||||
import type { FileReadResult, FileStat, IFilesystemProvider } from '../providers/types'
|
||||
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
|
||||
|
||||
class MemoryRemoteProvider implements IFilesystemProvider {
|
||||
private readonly files = new Map<string, { content: string; mtimeMs: number }>()
|
||||
|
||||
addFile(path: string, content: string, mtimeMs: number): void {
|
||||
this.files.set(normalize(path), { content, mtimeMs })
|
||||
}
|
||||
|
||||
async readDir(dirPath: string): Promise<DirEntry[]> {
|
||||
const dir = normalize(dirPath)
|
||||
const prefix = dir.endsWith('/') ? dir : `${dir}/`
|
||||
const entries = new Map<string, DirEntry>()
|
||||
for (const path of this.files.keys()) {
|
||||
if (!path.startsWith(prefix)) {
|
||||
continue
|
||||
}
|
||||
const relative = path.slice(prefix.length)
|
||||
if (!relative) {
|
||||
continue
|
||||
}
|
||||
const [name, ...rest] = relative.split('/')
|
||||
if (!name) {
|
||||
continue
|
||||
}
|
||||
entries.set(name, {
|
||||
name,
|
||||
isDirectory: rest.length > 0,
|
||||
isSymlink: false
|
||||
})
|
||||
}
|
||||
return [...entries.values()].sort((left, right) => left.name.localeCompare(right.name))
|
||||
}
|
||||
|
||||
async readFile(filePath: string): Promise<FileReadResult> {
|
||||
const file = this.files.get(normalize(filePath))
|
||||
if (!file) {
|
||||
throw new Error(`ENOENT: ${filePath}`)
|
||||
}
|
||||
return { content: file.content, isBinary: false }
|
||||
}
|
||||
|
||||
async stat(filePath: string): Promise<FileStat> {
|
||||
const file = this.files.get(normalize(filePath))
|
||||
if (!file) {
|
||||
throw new Error(`ENOENT: ${filePath}`)
|
||||
}
|
||||
return { size: file.content.length, type: 'file', mtime: file.mtimeMs, mtimeMs: file.mtimeMs }
|
||||
}
|
||||
|
||||
writeFile = unsupported
|
||||
writeFileBase64 = unsupported
|
||||
writeFileBase64Chunk = unsupported
|
||||
deletePath = unsupported
|
||||
createFile = unsupported
|
||||
createDir = unsupported
|
||||
createDirNoClobber = unsupported
|
||||
rename = unsupported
|
||||
renameNoClobber = unsupported
|
||||
copy = unsupported
|
||||
realpath = async (path: string): Promise<string> => path
|
||||
search = unsupported
|
||||
listFiles = unsupported
|
||||
watch = unsupported
|
||||
}
|
||||
|
||||
async function unsupported(): Promise<never> {
|
||||
throw new Error('unsupported')
|
||||
}
|
||||
|
||||
function normalize(path: string): string {
|
||||
return path.replace(/\\/g, '/').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function jsonLines(records: unknown[]): string {
|
||||
return records.map((record) => JSON.stringify(record)).join('\n')
|
||||
}
|
||||
|
||||
describe('scanRemoteAiVaultSessions', () => {
|
||||
it('parses remote default and Orca-managed Codex homes with SSH host ids', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
provider.addFile(
|
||||
'/home/ada/.codex/session_index.jsonl',
|
||||
jsonLines([{ id: 'default-session', thread_name: 'Indexed remote title' }]),
|
||||
1
|
||||
)
|
||||
provider.addFile(
|
||||
'/home/ada/.codex/sessions/2026/07/04/default.jsonl',
|
||||
jsonLines([
|
||||
{
|
||||
timestamp: '2026-07-04T01:00:00.000Z',
|
||||
type: 'session_meta',
|
||||
payload: { id: 'default-session', cwd: '/home/ada/repo' }
|
||||
},
|
||||
{
|
||||
timestamp: '2026-07-04T01:00:01.000Z',
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Fallback default title' }]
|
||||
}
|
||||
}
|
||||
]),
|
||||
10
|
||||
)
|
||||
provider.addFile(
|
||||
'/home/ada/.local/share/orca/codex-runtime-home/home/sessions/runtime.jsonl',
|
||||
jsonLines([
|
||||
{
|
||||
timestamp: '2026-07-04T02:00:00.000Z',
|
||||
type: 'session_meta',
|
||||
payload: { id: 'runtime-session', cwd: '/home/ada/runtime-repo' }
|
||||
},
|
||||
{
|
||||
timestamp: '2026-07-04T02:00:01.000Z',
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Managed remote title' }]
|
||||
}
|
||||
}
|
||||
]),
|
||||
20
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions.map((session) => session.title)).toEqual([
|
||||
'Managed remote title',
|
||||
'Indexed remote title'
|
||||
])
|
||||
expect(new Set(result.sessions.map((session) => session.id)).size).toBe(2)
|
||||
expect(result.sessions.every((session) => session.executionHostId === 'ssh:dev-box')).toBe(true)
|
||||
expect(result.sessions.every((session) => session.executionHostPlatform === 'linux')).toBe(true)
|
||||
expect(
|
||||
result.sessions.find((session) => session.sessionId === 'default-session')
|
||||
).toMatchObject({
|
||||
codexHome: '/home/ada/.codex',
|
||||
resumeCommand:
|
||||
"cd '/home/ada/repo' && CODEX_HOME='/home/ada/.codex' codex resume 'default-session'"
|
||||
})
|
||||
expect(
|
||||
result.sessions.find((session) => session.sessionId === 'runtime-session')
|
||||
).toMatchObject({
|
||||
codexHome: '/home/ada/.local/share/orca/codex-runtime-home/home',
|
||||
resumeCommand:
|
||||
"cd '/home/ada/runtime-repo' && CODEX_HOME='/home/ada/.local/share/orca/codex-runtime-home/home' codex resume 'runtime-session'"
|
||||
})
|
||||
})
|
||||
|
||||
it('parses non-Codex transcripts through the same remote scanner', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
provider.addFile(
|
||||
'/home/ada/.claude/projects/repo/claude-session.jsonl',
|
||||
jsonLines([
|
||||
{
|
||||
sessionId: 'claude-session',
|
||||
timestamp: '2026-07-04T04:00:00.000Z',
|
||||
type: 'user',
|
||||
message: { content: [{ type: 'text', text: 'Summarize the remote branch' }] }
|
||||
},
|
||||
{
|
||||
sessionId: 'claude-session',
|
||||
timestamp: '2026-07-04T04:00:01.000Z',
|
||||
type: 'assistant',
|
||||
message: { model: 'claude-opus-4', content: 'Sure.' }
|
||||
}
|
||||
]),
|
||||
40
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
expect(result.sessions[0]).toMatchObject({
|
||||
executionHostId: 'ssh:dev-box',
|
||||
executionHostPlatform: 'linux',
|
||||
agent: 'claude',
|
||||
sessionId: 'claude-session',
|
||||
title: 'Summarize the remote branch',
|
||||
model: 'claude-opus-4',
|
||||
filePath: '/home/ada/.claude/projects/repo/claude-session.jsonl'
|
||||
})
|
||||
})
|
||||
|
||||
it('builds resume commands with the remote host platform', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
provider.addFile(
|
||||
'C:/Users/Ada/.codex/sessions/win.jsonl',
|
||||
jsonLines([
|
||||
{
|
||||
timestamp: '2026-07-04T03:00:00.000Z',
|
||||
type: 'session_meta',
|
||||
payload: { id: 'win-session', cwd: 'C:/repo/app' }
|
||||
},
|
||||
{
|
||||
timestamp: '2026-07-04T03:00:01.000Z',
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Windows remote title' }]
|
||||
}
|
||||
}
|
||||
]),
|
||||
30
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:win-box',
|
||||
remoteHome: 'C:/Users/Ada',
|
||||
hostPlatform: getRemoteHostPlatform('win32-x64')
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions[0]?.executionHostPlatform).toBe('win32')
|
||||
expect(result.sessions[0]?.resumeCommand).toBe(
|
||||
'cmd /d /s /c "cd /d ""C:/repo/app"" && set ""CODEX_HOME=C:/Users/Ada/.codex"" && codex resume ""win-session"""'
|
||||
)
|
||||
})
|
||||
|
||||
it('continues past skipped candidates to fill the remote scan limit', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
provider.addFile(
|
||||
'/home/ada/.codex/sessions/worker.jsonl',
|
||||
codexTranscript({
|
||||
sessionId: 'worker-session',
|
||||
title: 'Internal worker',
|
||||
cwd: '/home/ada/repo',
|
||||
timestamp: '2026-07-04T04:00:00.000Z',
|
||||
threadSource: 'agent'
|
||||
}),
|
||||
40
|
||||
)
|
||||
provider.addFile(
|
||||
'/home/ada/.codex/sessions/user.jsonl',
|
||||
codexTranscript({
|
||||
sessionId: 'user-session',
|
||||
title: 'Visible user session',
|
||||
cwd: '/home/ada/repo',
|
||||
timestamp: '2026-07-04T03:00:00.000Z'
|
||||
}),
|
||||
30
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
limit: 1
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions.map((session) => session.sessionId)).toEqual(['user-session'])
|
||||
})
|
||||
|
||||
it('keeps scoped remote sessions even when they are older than the recency cap', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
provider.addFile(
|
||||
'/home/ada/.codex/sessions/other.jsonl',
|
||||
codexTranscript({
|
||||
sessionId: 'other-session',
|
||||
title: 'Other workspace',
|
||||
cwd: '/home/ada/other',
|
||||
timestamp: '2026-07-04T05:00:00.000Z'
|
||||
}),
|
||||
50
|
||||
)
|
||||
provider.addFile(
|
||||
'/home/ada/.codex/sessions/scoped.jsonl',
|
||||
codexTranscript({
|
||||
sessionId: 'scoped-session',
|
||||
title: 'Scoped workspace',
|
||||
cwd: '/home/ada/repo/app',
|
||||
timestamp: '2026-07-04T01:00:00.000Z'
|
||||
}),
|
||||
10
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
limit: 1,
|
||||
scopePaths: ['/home/ada/repo']
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions.map((session) => session.sessionId)).toEqual([
|
||||
'other-session',
|
||||
'scoped-session'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
function codexTranscript(args: {
|
||||
sessionId: string
|
||||
title: string
|
||||
cwd: string
|
||||
timestamp: string
|
||||
threadSource?: string
|
||||
}): string {
|
||||
return jsonLines([
|
||||
{
|
||||
timestamp: args.timestamp,
|
||||
type: 'session_meta',
|
||||
payload: {
|
||||
id: args.sessionId,
|
||||
cwd: args.cwd,
|
||||
...(args.threadSource ? { thread_source: args.threadSource } : {})
|
||||
}
|
||||
},
|
||||
{
|
||||
timestamp: args.timestamp.replace(':00.000Z', ':01.000Z'),
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: args.title }]
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import { extname } from 'node:path'
|
||||
import type {
|
||||
AiVaultAgent,
|
||||
AiVaultListResult,
|
||||
AiVaultScanIssue,
|
||||
AiVaultSession
|
||||
} from '../../shared/ai-vault-types'
|
||||
import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { FileStat, IFilesystemProvider } from '../providers/types'
|
||||
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import { joinRemotePath } from '../ssh/ssh-remote-platform'
|
||||
import { sessionSortTime } from './session-scanner-accumulator'
|
||||
import type { FileWithMtime } from './session-scanner-types'
|
||||
import { errorMessage } from './session-scanner-values'
|
||||
import { remoteSessionSources } from './remote-session-scanner-sources'
|
||||
import type {
|
||||
RemoteScannerContext,
|
||||
RemoteSessionCandidate,
|
||||
RemoteSessionSource
|
||||
} from './remote-session-scanner-types'
|
||||
|
||||
const DEFAULT_REMOTE_SCAN_LIMIT = 1000
|
||||
const REMOTE_SCAN_CONCURRENCY = 8
|
||||
const REMOTE_SCOPE_PARSE_LIMIT = 2000
|
||||
|
||||
export async function scanRemoteAiVaultSessions(args: {
|
||||
provider: IFilesystemProvider
|
||||
executionHostId: ExecutionHostId
|
||||
remoteHome: string
|
||||
hostPlatform: RemoteHostPlatform
|
||||
limit?: number
|
||||
scopePaths?: readonly string[]
|
||||
}): Promise<AiVaultListResult> {
|
||||
const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DEFAULT_REMOTE_SCAN_LIMIT
|
||||
const issues: AiVaultScanIssue[] = []
|
||||
const context: RemoteScannerContext = {
|
||||
provider: args.provider,
|
||||
executionHostId: args.executionHostId,
|
||||
hostPlatform: args.hostPlatform,
|
||||
titleCaches: new Map()
|
||||
}
|
||||
const candidates = (
|
||||
await mapRemoteScanConcurrently(
|
||||
remoteSessionSources(args.remoteHome, args.hostPlatform),
|
||||
(source) => discoverRemoteSourceCandidates({ source, context, issues })
|
||||
)
|
||||
)
|
||||
.flat()
|
||||
.sort((left, right) => right.file.mtimeMs - left.file.mtimeMs)
|
||||
|
||||
const parsed = await parseRemoteSessionCandidates({ candidates, context, issues, limit })
|
||||
const cappedSessions = parsed.sessions
|
||||
.sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
|
||||
.slice(0, limit)
|
||||
const scopePaths = normalizeRemoteScopePaths(args.scopePaths ?? [])
|
||||
const parsedScopeSessions = parsed.sessions.filter((session) =>
|
||||
isRemoteSessionInScope(session, scopePaths)
|
||||
)
|
||||
const extraScopeSessions = await scanRemoteInScopeSessions({
|
||||
candidates,
|
||||
context,
|
||||
issues,
|
||||
scopePaths,
|
||||
alreadyParsedFilePaths: parsed.parsedFilePaths
|
||||
})
|
||||
|
||||
return {
|
||||
sessions: mergeRemoteSessions(cappedSessions, [...parsedScopeSessions, ...extraScopeSessions]),
|
||||
issues,
|
||||
scannedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverRemoteSourceCandidates(args: {
|
||||
source: RemoteSessionSource
|
||||
context: RemoteScannerContext
|
||||
issues: AiVaultScanIssue[]
|
||||
}): Promise<RemoteSessionCandidate[]> {
|
||||
const paths = await walkRemoteSessionFiles(
|
||||
args.source,
|
||||
args.context.provider,
|
||||
args.context.hostPlatform
|
||||
)
|
||||
const files = await mapRemoteScanConcurrently(paths, (path) =>
|
||||
statRemoteFile(
|
||||
args.context.provider,
|
||||
path,
|
||||
args.source.agent,
|
||||
args.context.executionHostId,
|
||||
args.issues
|
||||
)
|
||||
)
|
||||
return files
|
||||
.filter((file): file is FileWithMtime => Boolean(file))
|
||||
.map((file) => ({ source: args.source, file }))
|
||||
}
|
||||
|
||||
async function walkRemoteSessionFiles(
|
||||
source: RemoteSessionSource,
|
||||
provider: IFilesystemProvider,
|
||||
hostPlatform: RemoteHostPlatform,
|
||||
dirPath = source.rootDir
|
||||
): Promise<string[]> {
|
||||
let entries
|
||||
try {
|
||||
entries = await provider.readDir(dirPath)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
const extensions = new Set(source.extensions)
|
||||
const files: string[] = []
|
||||
for (const entry of entries) {
|
||||
const fullPath = joinRemotePath(hostPlatform, dirPath, entry.name)
|
||||
if (entry.isDirectory && !entry.isSymlink) {
|
||||
files.push(...(await walkRemoteSessionFiles(source, provider, hostPlatform, fullPath)))
|
||||
continue
|
||||
}
|
||||
if (
|
||||
!entry.isSymlink &&
|
||||
extensions.has(extname(entry.name).toLowerCase()) &&
|
||||
(source.filePredicate?.(fullPath) ?? true)
|
||||
) {
|
||||
files.push(fullPath)
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
async function parseRemoteSessionCandidates(args: {
|
||||
candidates: readonly RemoteSessionCandidate[]
|
||||
context: RemoteScannerContext
|
||||
issues: AiVaultScanIssue[]
|
||||
limit: number
|
||||
}): Promise<{ sessions: AiVaultSession[]; parsedFilePaths: Set<string> }> {
|
||||
const sessions: AiVaultSession[] = []
|
||||
const parsedFilePaths = new Set<string>()
|
||||
let index = 0
|
||||
|
||||
while (index < args.candidates.length) {
|
||||
if (canStopParsingRemoteSessions(sessions, args.limit, args.candidates[index]?.file.mtimeMs)) {
|
||||
break
|
||||
}
|
||||
|
||||
const batch = args.candidates.slice(index, index + REMOTE_SCAN_CONCURRENCY)
|
||||
for (const candidate of batch) {
|
||||
parsedFilePaths.add(candidate.file.path)
|
||||
}
|
||||
const results = await Promise.all(
|
||||
batch.map((candidate) => parseRemoteSessionCandidate(candidate, args.context, args.issues))
|
||||
)
|
||||
sessions.push(...results.filter(isAiVaultSession))
|
||||
index += batch.length
|
||||
}
|
||||
|
||||
return { sessions, parsedFilePaths }
|
||||
}
|
||||
|
||||
async function scanRemoteInScopeSessions(args: {
|
||||
candidates: readonly RemoteSessionCandidate[]
|
||||
context: RemoteScannerContext
|
||||
issues: AiVaultScanIssue[]
|
||||
scopePaths: readonly string[]
|
||||
alreadyParsedFilePaths: ReadonlySet<string>
|
||||
}): Promise<AiVaultSession[]> {
|
||||
if (args.scopePaths.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const candidates = args.candidates
|
||||
.filter((candidate) => !args.alreadyParsedFilePaths.has(candidate.file.path))
|
||||
.slice(0, REMOTE_SCOPE_PARSE_LIMIT)
|
||||
const sessions: AiVaultSession[] = []
|
||||
|
||||
for (let index = 0; index < candidates.length; index += REMOTE_SCAN_CONCURRENCY) {
|
||||
const batch = candidates.slice(index, index + REMOTE_SCAN_CONCURRENCY)
|
||||
const results = await Promise.all(
|
||||
batch.map((candidate) => parseRemoteSessionCandidate(candidate, args.context, args.issues))
|
||||
)
|
||||
sessions.push(
|
||||
...results.filter(
|
||||
(session): session is AiVaultSession =>
|
||||
isAiVaultSession(session) && isRemoteSessionInScope(session, args.scopePaths)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return sessions
|
||||
}
|
||||
|
||||
async function parseRemoteSessionCandidate(
|
||||
candidate: RemoteSessionCandidate,
|
||||
context: RemoteScannerContext,
|
||||
issues: AiVaultScanIssue[]
|
||||
): Promise<AiVaultSession | null> {
|
||||
try {
|
||||
const read = await context.provider.readFile(candidate.file.path)
|
||||
if (read.isBinary) {
|
||||
return null
|
||||
}
|
||||
return candidate.source.parse(candidate.file, read.content, context)
|
||||
} catch (err) {
|
||||
issues.push({
|
||||
executionHostId: context.executionHostId,
|
||||
agent: candidate.source.agent,
|
||||
path: candidate.file.path,
|
||||
message: errorMessage(err)
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function mergeRemoteSessions(
|
||||
cappedSessions: AiVaultSession[],
|
||||
scopeSessions: AiVaultSession[]
|
||||
): AiVaultSession[] {
|
||||
if (scopeSessions.length === 0) {
|
||||
return cappedSessions
|
||||
}
|
||||
const byId = new Map<string, AiVaultSession>()
|
||||
for (const session of cappedSessions) {
|
||||
byId.set(session.id, session)
|
||||
}
|
||||
for (const session of scopeSessions) {
|
||||
byId.set(session.id, session)
|
||||
}
|
||||
return [...byId.values()].sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
|
||||
}
|
||||
|
||||
function isRemoteSessionInScope(session: AiVaultSession, scopePaths: readonly string[]): boolean {
|
||||
const cwd = session.cwd
|
||||
return Boolean(cwd && scopePaths.some((scopePath) => isPathInsideOrEqual(scopePath, cwd)))
|
||||
}
|
||||
|
||||
function normalizeRemoteScopePaths(scopePaths: readonly string[]): string[] {
|
||||
return scopePaths.map((scopePath) => scopePath.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
async function statRemoteFile(
|
||||
provider: IFilesystemProvider,
|
||||
path: string,
|
||||
agent: AiVaultAgent,
|
||||
executionHostId: ExecutionHostId,
|
||||
issues: AiVaultScanIssue[]
|
||||
): Promise<FileWithMtime | null> {
|
||||
try {
|
||||
const stat = await provider.stat(path)
|
||||
const mtimeMs = remoteStatMtimeMs(stat)
|
||||
return { path, mtimeMs, modifiedAt: new Date(mtimeMs).toISOString() }
|
||||
} catch (err) {
|
||||
issues.push({ executionHostId, agent, path, message: errorMessage(err) })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function remoteStatMtimeMs(stat: FileStat): number {
|
||||
if (typeof stat.mtimeMs === 'number' && Number.isFinite(stat.mtimeMs)) {
|
||||
return stat.mtimeMs
|
||||
}
|
||||
return stat.mtime > 10_000_000_000 ? stat.mtime : stat.mtime * 1000
|
||||
}
|
||||
|
||||
function canStopParsingRemoteSessions(
|
||||
sessions: AiVaultSession[],
|
||||
limit: number,
|
||||
nextCandidateMtimeMs: number | undefined
|
||||
): boolean {
|
||||
if (sessions.length < limit || typeof nextCandidateMtimeMs !== 'number') {
|
||||
return false
|
||||
}
|
||||
const visibleCutoff = sessions
|
||||
.map(sessionSortTime)
|
||||
.sort((left, right) => right - left)
|
||||
.at(limit - 1)
|
||||
|
||||
// Transcript mtimes bound the remaining candidate order; once the visible
|
||||
// cutoff is newer, older files cannot enter the unscoped top-N result.
|
||||
return typeof visibleCutoff === 'number' && nextCandidateMtimeMs < visibleCutoff
|
||||
}
|
||||
|
||||
function isAiVaultSession(session: AiVaultSession | null): session is AiVaultSession {
|
||||
return Boolean(session)
|
||||
}
|
||||
|
||||
async function mapRemoteScanConcurrently<T, U>(
|
||||
items: readonly T[],
|
||||
mapper: (item: T) => Promise<U>
|
||||
): Promise<U[]> {
|
||||
const results: U[] = []
|
||||
for (let index = 0; index < items.length; index += REMOTE_SCAN_CONCURRENCY) {
|
||||
const batch = items.slice(index, index + REMOTE_SCAN_CONCURRENCY)
|
||||
results.push(...(await Promise.all(batch.map(mapper))))
|
||||
}
|
||||
return results
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type AiVaultSession,
|
||||
type AiVaultSessionPreviewMessage
|
||||
} from '../../shared/ai-vault-types'
|
||||
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
|
||||
import {
|
||||
extractPreviewContentText,
|
||||
@@ -43,7 +44,11 @@ export function createAccumulator(args: {
|
||||
export function finalizeSession(
|
||||
accumulator: SessionAccumulator,
|
||||
platform: NodeJS.Platform,
|
||||
options: { codexHome?: string | null } = {}
|
||||
options: {
|
||||
codexHome?: string | null
|
||||
executionHostId?: ExecutionHostId
|
||||
executionHostPlatform?: NodeJS.Platform | null
|
||||
} = {}
|
||||
): AiVaultSession | null {
|
||||
const sessionId = accumulator.sessionId.trim()
|
||||
if (!sessionId) {
|
||||
@@ -54,8 +59,14 @@ export function finalizeSession(
|
||||
accumulator.fallbackTitle ||
|
||||
`${aiVaultAgentLabel(accumulator.agent)} ${sessionId.slice(0, 8)}`
|
||||
|
||||
const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID
|
||||
|
||||
return {
|
||||
id: `${accumulator.agent}:${sessionId}:${accumulator.filePath}`,
|
||||
id: `${executionHostId}:${accumulator.agent}:${sessionId}:${accumulator.filePath}`,
|
||||
executionHostId,
|
||||
...(options.executionHostPlatform
|
||||
? { executionHostPlatform: options.executionHostPlatform }
|
||||
: {}),
|
||||
agent: accumulator.agent,
|
||||
sessionId,
|
||||
title,
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import { parseDevinSessionFile } from './session-scanner-devin-parser'
|
||||
import { parseDroidSessionFile } from './session-scanner-droid-parser'
|
||||
import { parseGrokSessionFile } from './session-scanner-grok-parser'
|
||||
import {
|
||||
parseDroidSessionFile,
|
||||
parseMessageGraphSessionFile,
|
||||
parseRovoSessionFile
|
||||
} from './session-scanner-graph-parsers'
|
||||
import { parseMessageGraphSessionFile, parseRovoSessionFile } from './session-scanner-graph-parsers'
|
||||
import { parseKimiSessionFile } from './session-scanner-kimi-parser'
|
||||
import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths'
|
||||
import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite'
|
||||
|
||||
@@ -3,6 +3,7 @@ import { stat } from 'node:fs/promises'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import { createInterface } from 'node:readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import {
|
||||
addPreviewContent,
|
||||
createAccumulator,
|
||||
@@ -35,21 +36,61 @@ const codexSessionIndexTitleCache = new Map<string, Promise<CodexSessionIndexTit
|
||||
export async function parseCodexSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
codexHome: string | null = null
|
||||
codexHome: string | null = null,
|
||||
executionHostId?: ExecutionHostId
|
||||
): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'codex',
|
||||
file,
|
||||
sessionId: sessionIdFromFileName(file.path)
|
||||
})
|
||||
let previousTotals: CodexUsageSnapshot | null = null
|
||||
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
|
||||
for await (const line of lines) {
|
||||
return parseCodexSessionLines({
|
||||
file,
|
||||
lines,
|
||||
platform,
|
||||
codexHome,
|
||||
executionHostId,
|
||||
titleReader: (sessionId) => readCodexSessionIndexTitle(file.path, codexHome, sessionId)
|
||||
})
|
||||
}
|
||||
|
||||
export async function parseCodexSessionContent(args: {
|
||||
file: FileWithMtime
|
||||
content: string
|
||||
platform?: NodeJS.Platform
|
||||
codexHome?: string | null
|
||||
executionHostId?: ExecutionHostId
|
||||
executionHostPlatform?: NodeJS.Platform | null
|
||||
readIndexedTitle?: (sessionId: string) => Promise<string | null>
|
||||
}): Promise<AiVaultSession | null> {
|
||||
return parseCodexSessionLines({
|
||||
file: args.file,
|
||||
lines: args.content.split(/\r?\n/),
|
||||
platform: args.platform ?? process.platform,
|
||||
codexHome: args.codexHome ?? null,
|
||||
executionHostId: args.executionHostId,
|
||||
executionHostPlatform: args.executionHostPlatform,
|
||||
titleReader: args.readIndexedTitle
|
||||
})
|
||||
}
|
||||
|
||||
async function parseCodexSessionLines(args: {
|
||||
file: FileWithMtime
|
||||
lines: AsyncIterable<string> | Iterable<string>
|
||||
platform: NodeJS.Platform
|
||||
codexHome: string | null
|
||||
executionHostId?: ExecutionHostId
|
||||
executionHostPlatform?: NodeJS.Platform | null
|
||||
titleReader?: (sessionId: string) => Promise<string | null>
|
||||
}): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'codex',
|
||||
file: args.file,
|
||||
sessionId: sessionIdFromFileName(args.file.path)
|
||||
})
|
||||
let previousTotals: CodexUsageSnapshot | null = null
|
||||
|
||||
for await (const line of args.lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
@@ -70,7 +111,7 @@ export async function parseCodexSessionFile(
|
||||
}
|
||||
const indexedTitle =
|
||||
extractCodexSessionMetadataTitle(payload) ??
|
||||
(await readCodexSessionIndexTitle(file.path, codexHome, accumulator.sessionId))
|
||||
(await args.titleReader?.(accumulator.sessionId))
|
||||
if (indexedTitle) {
|
||||
accumulator.title = indexedTitle
|
||||
}
|
||||
@@ -158,7 +199,11 @@ export async function parseCodexSessionFile(
|
||||
}
|
||||
}
|
||||
|
||||
return finalizeSession(accumulator, platform, { codexHome })
|
||||
return finalizeSession(accumulator, args.platform, {
|
||||
codexHome: args.codexHome,
|
||||
executionHostId: args.executionHostId,
|
||||
executionHostPlatform: args.executionHostPlatform
|
||||
})
|
||||
}
|
||||
|
||||
function addCodexUsage(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { FileWithMtime } from './session-scanner-types'
|
||||
import {
|
||||
addPreviewContent,
|
||||
@@ -17,11 +18,25 @@ import {
|
||||
numberValue
|
||||
} from './session-scanner-values'
|
||||
|
||||
type ParserSessionOptions = {
|
||||
executionHostId?: ExecutionHostId
|
||||
executionHostPlatform?: NodeJS.Platform | null
|
||||
}
|
||||
|
||||
export async function parseDevinSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown)
|
||||
return parseDevinSessionContent(file, await readFile(file.path, 'utf-8'), platform)
|
||||
}
|
||||
|
||||
export function parseDevinSessionContent(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
options: ParserSessionOptions = {}
|
||||
): AiVaultSession | null {
|
||||
const record = asRecord(JSON.parse(content) as unknown)
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
@@ -69,7 +84,7 @@ export async function parseDevinSessionFile(
|
||||
)
|
||||
}
|
||||
}
|
||||
return finalizeSession(accumulator, platform)
|
||||
return finalizeSession(accumulator, platform, options)
|
||||
}
|
||||
|
||||
function extractDevinStepText(step: Record<string, unknown>): string | null {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { createInterface } from 'node:readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
|
||||
import {
|
||||
addPreviewMessage,
|
||||
createAccumulator,
|
||||
finalizeSession,
|
||||
sessionIdFromFileName,
|
||||
updateTimeline
|
||||
} from './session-scanner-accumulator'
|
||||
import {
|
||||
asRecord,
|
||||
extractMessageText,
|
||||
extractPreviewContentText,
|
||||
extractString,
|
||||
normalizeTitleText,
|
||||
parseJsonObject,
|
||||
tokenTotal
|
||||
} from './session-scanner-values'
|
||||
|
||||
type ParserSessionOptions = {
|
||||
executionHostId?: ExecutionHostId
|
||||
executionHostPlatform?: NodeJS.Platform | null
|
||||
}
|
||||
|
||||
export async function parseDroidSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
return parseDroidSessionLines({ file, lines, platform })
|
||||
}
|
||||
|
||||
export async function parseDroidSessionContent(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
options: ParserSessionOptions = {}
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseDroidSessionLines({
|
||||
file,
|
||||
lines: content.split(/\r?\n/),
|
||||
platform,
|
||||
options
|
||||
})
|
||||
}
|
||||
|
||||
async function parseDroidSessionLines(args: {
|
||||
file: FileWithMtime
|
||||
lines: AsyncIterable<string> | Iterable<string>
|
||||
platform: NodeJS.Platform
|
||||
options?: ParserSessionOptions
|
||||
}): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'droid',
|
||||
file: args.file,
|
||||
sessionId: sessionIdFromFileName(args.file.path)
|
||||
})
|
||||
|
||||
for await (const line of args.lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
}
|
||||
updateTimeline(accumulator, record.timestamp)
|
||||
if (record.type === 'session_start') {
|
||||
accumulator.sessionId = extractString(record.id) ?? accumulator.sessionId
|
||||
accumulator.title = normalizeTitleText(extractString(record.title) ?? '')
|
||||
accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd
|
||||
continue
|
||||
}
|
||||
if (record.type === 'system') {
|
||||
accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd
|
||||
accumulator.model = extractString(record.model) ?? accumulator.model
|
||||
}
|
||||
const streamSessionId = extractString(record.session_id) ?? extractString(record.sessionId)
|
||||
if (streamSessionId) {
|
||||
accumulator.sessionId = streamSessionId
|
||||
}
|
||||
if (record.type === 'message') {
|
||||
consumeDroidMessage(accumulator, record)
|
||||
} else if (record.type === 'completion') {
|
||||
accumulator.messageCount++
|
||||
accumulator.totalTokens += tokenTotal(record.usage)
|
||||
addPreviewMessage(accumulator, {
|
||||
role: 'assistant',
|
||||
text: extractString(record.finalText),
|
||||
timestamp: record.timestamp
|
||||
})
|
||||
}
|
||||
}
|
||||
return finalizeSession(accumulator, args.platform, args.options)
|
||||
}
|
||||
|
||||
function consumeDroidMessage(
|
||||
accumulator: SessionAccumulator,
|
||||
record: Record<string, unknown>
|
||||
): void {
|
||||
const role = extractString(record.role) ?? extractString(asRecord(record.message)?.role)
|
||||
if (role !== 'user' && role !== 'assistant') {
|
||||
return
|
||||
}
|
||||
accumulator.messageCount++
|
||||
if (role === 'user') {
|
||||
accumulator.title ??=
|
||||
normalizeTitleText(extractString(record.text) ?? '') ||
|
||||
extractMessageText(asRecord(record.message))
|
||||
}
|
||||
addPreviewMessage(accumulator, {
|
||||
role,
|
||||
text:
|
||||
extractString(record.text) ?? extractPreviewContentText(asRecord(record.message)?.content),
|
||||
timestamp: record.timestamp
|
||||
})
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import { createInterface } from 'node:readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
|
||||
import {
|
||||
addPreviewContent,
|
||||
@@ -17,15 +18,18 @@ import {
|
||||
asRecord,
|
||||
extractContentText,
|
||||
extractMessageText,
|
||||
extractPreviewContentText,
|
||||
extractString,
|
||||
firstString,
|
||||
normalizeTitleText,
|
||||
parseJsonObject,
|
||||
readJsonObjectIfExists,
|
||||
tokenTotal
|
||||
} from './session-scanner-values'
|
||||
|
||||
type ParserSessionOptions = {
|
||||
executionHostId?: ExecutionHostId
|
||||
executionHostPlatform?: NodeJS.Platform | null
|
||||
}
|
||||
|
||||
export async function parseRovoSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
@@ -159,17 +163,43 @@ export async function parseMessageGraphSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent,
|
||||
file,
|
||||
sessionId: sessionIdFromFileName(file.path)
|
||||
})
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
return parseMessageGraphSessionLines({ agent, file, lines, platform })
|
||||
}
|
||||
|
||||
for await (const line of lines) {
|
||||
export async function parseMessageGraphSessionContent(
|
||||
agent: 'openclaw' | 'pi',
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
options: ParserSessionOptions = {}
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseMessageGraphSessionLines({
|
||||
agent,
|
||||
file,
|
||||
lines: content.split(/\r?\n/),
|
||||
platform,
|
||||
options
|
||||
})
|
||||
}
|
||||
|
||||
async function parseMessageGraphSessionLines(args: {
|
||||
agent: 'openclaw' | 'pi'
|
||||
file: FileWithMtime
|
||||
lines: AsyncIterable<string> | Iterable<string>
|
||||
platform: NodeJS.Platform
|
||||
options?: ParserSessionOptions
|
||||
}): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: args.agent,
|
||||
file: args.file,
|
||||
sessionId: sessionIdFromFileName(args.file.path)
|
||||
})
|
||||
|
||||
for await (const line of args.lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
@@ -204,69 +234,5 @@ export async function parseMessageGraphSessionFile(
|
||||
}
|
||||
}
|
||||
|
||||
return finalizeSession(accumulator, platform)
|
||||
}
|
||||
|
||||
export async function parseDroidSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'droid',
|
||||
file,
|
||||
sessionId: sessionIdFromFileName(file.path)
|
||||
})
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
|
||||
for await (const line of lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
}
|
||||
updateTimeline(accumulator, record.timestamp)
|
||||
if (record.type === 'session_start') {
|
||||
accumulator.sessionId = extractString(record.id) ?? accumulator.sessionId
|
||||
accumulator.title = normalizeTitleText(extractString(record.title) ?? '')
|
||||
accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd
|
||||
continue
|
||||
}
|
||||
if (record.type === 'system') {
|
||||
accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd
|
||||
accumulator.model = extractString(record.model) ?? accumulator.model
|
||||
}
|
||||
const streamSessionId = extractString(record.session_id) ?? extractString(record.sessionId)
|
||||
if (streamSessionId) {
|
||||
accumulator.sessionId = streamSessionId
|
||||
}
|
||||
if (record.type === 'message') {
|
||||
const role = extractString(record.role) ?? extractString(asRecord(record.message)?.role)
|
||||
if (role === 'user' || role === 'assistant') {
|
||||
accumulator.messageCount++
|
||||
if (role === 'user') {
|
||||
accumulator.title ??=
|
||||
normalizeTitleText(extractString(record.text) ?? '') ||
|
||||
extractMessageText(asRecord(record.message))
|
||||
}
|
||||
addPreviewMessage(accumulator, {
|
||||
role,
|
||||
text:
|
||||
extractString(record.text) ??
|
||||
extractPreviewContentText(asRecord(record.message)?.content),
|
||||
timestamp: record.timestamp
|
||||
})
|
||||
}
|
||||
} else if (record.type === 'completion') {
|
||||
accumulator.messageCount++
|
||||
accumulator.totalTokens += tokenTotal(record.usage)
|
||||
addPreviewMessage(accumulator, {
|
||||
role: 'assistant',
|
||||
text: extractString(record.finalText),
|
||||
timestamp: record.timestamp
|
||||
})
|
||||
}
|
||||
}
|
||||
return finalizeSession(accumulator, platform)
|
||||
return finalizeSession(accumulator, args.platform, args.options)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createReadStream } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { createInterface } from 'node:readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
|
||||
import {
|
||||
addPreviewContent,
|
||||
@@ -23,24 +24,51 @@ import {
|
||||
tokenTotal
|
||||
} from './session-scanner-values'
|
||||
|
||||
type ParserSessionOptions = {
|
||||
executionHostId?: ExecutionHostId
|
||||
executionHostPlatform?: NodeJS.Platform | null
|
||||
}
|
||||
|
||||
export async function parseClaudeSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'claude',
|
||||
file,
|
||||
sessionId: sessionIdFromFileName(file.path)
|
||||
})
|
||||
let metaTitle: string | null = null
|
||||
let generatedTitle: string | null = null
|
||||
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
return parseClaudeSessionLines({ file, lines, platform })
|
||||
}
|
||||
|
||||
for await (const line of lines) {
|
||||
export async function parseClaudeSessionContent(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
options: ParserSessionOptions = {}
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseClaudeSessionLines({
|
||||
file,
|
||||
lines: content.split(/\r?\n/),
|
||||
platform,
|
||||
options
|
||||
})
|
||||
}
|
||||
|
||||
async function parseClaudeSessionLines(args: {
|
||||
file: FileWithMtime
|
||||
lines: AsyncIterable<string> | Iterable<string>
|
||||
platform: NodeJS.Platform
|
||||
options?: ParserSessionOptions
|
||||
}): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'claude',
|
||||
file: args.file,
|
||||
sessionId: sessionIdFromFileName(args.file.path)
|
||||
})
|
||||
let metaTitle: string | null = null
|
||||
let generatedTitle: string | null = null
|
||||
|
||||
for await (const line of args.lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
@@ -92,7 +120,7 @@ export async function parseClaudeSessionFile(
|
||||
}
|
||||
|
||||
accumulator.fallbackTitle = generatedTitle ?? metaTitle
|
||||
return finalizeSession(accumulator, platform)
|
||||
return finalizeSession(accumulator, args.platform, args.options)
|
||||
}
|
||||
|
||||
export async function parseGeminiSessionFile(
|
||||
@@ -103,7 +131,33 @@ export async function parseGeminiSessionFile(
|
||||
return parseGeminiJsonlSessionFile(file, platform)
|
||||
}
|
||||
|
||||
const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown)
|
||||
return parseGeminiJsonSessionContent(file, await readFile(file.path, 'utf-8'), platform)
|
||||
}
|
||||
|
||||
export async function parseGeminiSessionContent(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
options: ParserSessionOptions = {}
|
||||
): Promise<AiVaultSession | null> {
|
||||
if (file.path.endsWith('.jsonl')) {
|
||||
return parseGeminiJsonlSessionLines({
|
||||
file,
|
||||
lines: content.split(/\r?\n/),
|
||||
platform,
|
||||
options
|
||||
})
|
||||
}
|
||||
return parseGeminiJsonSessionContent(file, content, platform, options)
|
||||
}
|
||||
|
||||
function parseGeminiJsonSessionContent(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform,
|
||||
options: ParserSessionOptions = {}
|
||||
): AiVaultSession | null {
|
||||
const record = asRecord(JSON.parse(content) as unknown)
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
@@ -117,24 +171,33 @@ export async function parseGeminiSessionFile(
|
||||
for (const message of arrayValue(record.messages)) {
|
||||
consumeGeminiMessage(accumulator, asRecord(message))
|
||||
}
|
||||
return finalizeSession(accumulator, platform)
|
||||
return finalizeSession(accumulator, platform, options)
|
||||
}
|
||||
|
||||
export async function parseGeminiJsonlSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'gemini',
|
||||
file,
|
||||
sessionId: sessionIdFromFileName(file.path)
|
||||
})
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
return parseGeminiJsonlSessionLines({ file, lines, platform })
|
||||
}
|
||||
|
||||
for await (const line of lines) {
|
||||
async function parseGeminiJsonlSessionLines(args: {
|
||||
file: FileWithMtime
|
||||
lines: AsyncIterable<string> | Iterable<string>
|
||||
platform: NodeJS.Platform
|
||||
options?: ParserSessionOptions
|
||||
}): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'gemini',
|
||||
file: args.file,
|
||||
sessionId: sessionIdFromFileName(args.file.path)
|
||||
})
|
||||
|
||||
for await (const line of args.lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
@@ -153,7 +216,7 @@ export async function parseGeminiJsonlSessionFile(
|
||||
consumeGeminiMessage(accumulator, record)
|
||||
}
|
||||
|
||||
return finalizeSession(accumulator, platform)
|
||||
return finalizeSession(accumulator, args.platform, args.options)
|
||||
}
|
||||
|
||||
export function consumeGeminiMessage(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { readFile, readdir } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { createInterface } from 'node:readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
|
||||
import {
|
||||
addPreviewContent,
|
||||
@@ -29,21 +30,49 @@ import {
|
||||
tokenTotal
|
||||
} from './session-scanner-values'
|
||||
|
||||
type ParserSessionOptions = {
|
||||
executionHostId?: ExecutionHostId
|
||||
executionHostPlatform?: NodeJS.Platform | null
|
||||
}
|
||||
|
||||
export async function parseCopilotSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'copilot',
|
||||
file,
|
||||
sessionId: sessionIdFromFileName(file.path)
|
||||
})
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
return parseCopilotSessionLines({ file, lines, platform })
|
||||
}
|
||||
|
||||
for await (const line of lines) {
|
||||
export async function parseCopilotSessionContent(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
options: ParserSessionOptions = {}
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseCopilotSessionLines({
|
||||
file,
|
||||
lines: content.split(/\r?\n/),
|
||||
platform,
|
||||
options
|
||||
})
|
||||
}
|
||||
|
||||
async function parseCopilotSessionLines(args: {
|
||||
file: FileWithMtime
|
||||
lines: AsyncIterable<string> | Iterable<string>
|
||||
platform: NodeJS.Platform
|
||||
options?: ParserSessionOptions
|
||||
}): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'copilot',
|
||||
file: args.file,
|
||||
sessionId: sessionIdFromFileName(args.file.path)
|
||||
})
|
||||
|
||||
for await (const line of args.lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
@@ -94,24 +123,47 @@ export async function parseCopilotSessionFile(
|
||||
}
|
||||
}
|
||||
|
||||
return finalizeSession(accumulator, platform)
|
||||
return finalizeSession(accumulator, args.platform, args.options)
|
||||
}
|
||||
|
||||
export async function parseCursorSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'cursor',
|
||||
file,
|
||||
sessionId: sessionIdFromFileName(file.path)
|
||||
})
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
return parseCursorSessionLines({ file, lines, platform })
|
||||
}
|
||||
|
||||
for await (const line of lines) {
|
||||
export async function parseCursorSessionContent(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
options: ParserSessionOptions = {}
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseCursorSessionLines({
|
||||
file,
|
||||
lines: content.split(/\r?\n/),
|
||||
platform,
|
||||
options
|
||||
})
|
||||
}
|
||||
|
||||
async function parseCursorSessionLines(args: {
|
||||
file: FileWithMtime
|
||||
lines: AsyncIterable<string> | Iterable<string>
|
||||
platform: NodeJS.Platform
|
||||
options?: ParserSessionOptions
|
||||
}): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'cursor',
|
||||
file: args.file,
|
||||
sessionId: sessionIdFromFileName(args.file.path)
|
||||
})
|
||||
|
||||
for await (const line of args.lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
@@ -132,7 +184,7 @@ export async function parseCursorSessionFile(
|
||||
)
|
||||
}
|
||||
}
|
||||
return finalizeSession(accumulator, platform)
|
||||
return finalizeSession(accumulator, args.platform, args.options)
|
||||
}
|
||||
|
||||
export async function parseOpenCodeSessionFile(
|
||||
@@ -207,7 +259,16 @@ export async function parseHermesSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown)
|
||||
return parseHermesSessionContent(file, await readFile(file.path, 'utf-8'), platform)
|
||||
}
|
||||
|
||||
export async function parseHermesSessionContent(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
options: ParserSessionOptions = {}
|
||||
): Promise<AiVaultSession | null> {
|
||||
const record = asRecord(JSON.parse(content) as unknown)
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
@@ -234,5 +295,5 @@ export async function parseHermesSessionFile(
|
||||
if (accumulator.messageCount === 0) {
|
||||
accumulator.messageCount = numberValue(record.message_count)
|
||||
}
|
||||
return finalizeSession(accumulator, platform)
|
||||
return finalizeSession(accumulator, platform, options)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
AiVaultSession,
|
||||
AiVaultSessionPreviewMessage
|
||||
} from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
|
||||
export type AiVaultScanOptions = {
|
||||
claudeProjectsDir?: string
|
||||
@@ -33,6 +34,7 @@ export type AiVaultScanOptions = {
|
||||
// the recency cap (see discoverInScopeClaudeFiles).
|
||||
scopePaths?: readonly string[]
|
||||
platform?: NodeJS.Platform
|
||||
executionHostId?: ExecutionHostId
|
||||
}
|
||||
|
||||
export type FileWithMtime = {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
AiVaultScanIssue,
|
||||
AiVaultSession
|
||||
} from '../../shared/ai-vault-types'
|
||||
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host'
|
||||
import { sessionSortTime } from './session-scanner-accumulator'
|
||||
import { parseAgentSessionFile } from './session-scanner-agent-parser'
|
||||
import { codexHomeForSessionsDir } from './session-scanner-codex-paths'
|
||||
@@ -41,6 +42,7 @@ export async function scanAiVaultSessions(
|
||||
const limit = clampPositiveInteger(options.limit, DEFAULT_LIMIT)
|
||||
const limitPerAgent = clampPositiveInteger(options.limitPerAgent, DEFAULT_SCAN_LIMIT_PER_AGENT)
|
||||
const platform = options.platform ?? process.platform
|
||||
const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID
|
||||
const issues: AiVaultScanIssue[] = []
|
||||
const discoveries = await discoverAiVaultSessionSources({ options, limitPerAgent, issues })
|
||||
|
||||
@@ -63,6 +65,7 @@ export async function scanAiVaultSessions(
|
||||
candidates,
|
||||
limit,
|
||||
platform,
|
||||
executionHostId,
|
||||
issues
|
||||
})
|
||||
|
||||
@@ -75,12 +78,13 @@ export async function scanAiVaultSessions(
|
||||
scopePaths: options.scopePaths ?? [],
|
||||
alreadyParsedFilePaths: new Set(cappedSessions.map((session) => session.filePath)),
|
||||
platform,
|
||||
executionHostId,
|
||||
issues
|
||||
})
|
||||
|
||||
return {
|
||||
sessions: mergeSessions(cappedSessions, scopeSessions),
|
||||
issues,
|
||||
issues: issues.map((issue) => ({ executionHostId, ...issue })),
|
||||
scannedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
@@ -110,6 +114,7 @@ async function scanInScopeSessions(args: {
|
||||
scopePaths: readonly string[]
|
||||
alreadyParsedFilePaths: ReadonlySet<string>
|
||||
platform: NodeJS.Platform
|
||||
executionHostId: ExecutionHostId
|
||||
issues: AiVaultScanIssue[]
|
||||
}): Promise<AiVaultSession[]> {
|
||||
if (args.scopePaths.length === 0) {
|
||||
@@ -136,6 +141,7 @@ async function scanInScopeSessions(args: {
|
||||
candidates,
|
||||
limit: candidates.length,
|
||||
platform: args.platform,
|
||||
executionHostId: args.executionHostId,
|
||||
issues: args.issues
|
||||
})
|
||||
}
|
||||
@@ -144,6 +150,7 @@ async function parseSessionCandidates(args: {
|
||||
candidates: SessionFileCandidate[]
|
||||
limit: number
|
||||
platform: NodeJS.Platform
|
||||
executionHostId: ExecutionHostId
|
||||
issues: AiVaultScanIssue[]
|
||||
}): Promise<AiVaultSession[]> {
|
||||
const sessions: AiVaultSession[] = []
|
||||
@@ -159,7 +166,9 @@ async function parseSessionCandidates(args: {
|
||||
const batchSize = Math.min(SESSION_PARSE_CONCURRENCY, needed, remaining)
|
||||
const batch = args.candidates.slice(index, index + batchSize)
|
||||
const results = await Promise.all(
|
||||
batch.map((candidate) => parseSessionCandidate(candidate, args.platform))
|
||||
batch.map((candidate) =>
|
||||
parseSessionCandidate(candidate, args.platform, args.executionHostId)
|
||||
)
|
||||
)
|
||||
|
||||
for (const result of results) {
|
||||
@@ -179,15 +188,20 @@ async function parseSessionCandidates(args: {
|
||||
|
||||
async function parseSessionCandidate(
|
||||
candidate: SessionFileCandidate,
|
||||
platform: NodeJS.Platform
|
||||
platform: NodeJS.Platform,
|
||||
executionHostId: ExecutionHostId
|
||||
): Promise<SessionParseResult> {
|
||||
try {
|
||||
const session = await parseAgentSessionFile(candidate, platform)
|
||||
return { session, issue: null }
|
||||
return {
|
||||
session: session ? withSessionExecutionHost(session, executionHostId) : null,
|
||||
issue: null
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
session: null,
|
||||
issue: {
|
||||
executionHostId,
|
||||
agent: candidate.agent,
|
||||
path: candidate.file.path,
|
||||
message: errorMessage(err)
|
||||
@@ -196,6 +210,20 @@ async function parseSessionCandidate(
|
||||
}
|
||||
}
|
||||
|
||||
function withSessionExecutionHost(
|
||||
session: AiVaultSession,
|
||||
executionHostId: ExecutionHostId
|
||||
): AiVaultSession {
|
||||
if (session.executionHostId === executionHostId) {
|
||||
return session
|
||||
}
|
||||
return {
|
||||
...session,
|
||||
executionHostId,
|
||||
id: `${executionHostId}:${session.agent}:${session.sessionId}:${session.filePath}`
|
||||
}
|
||||
}
|
||||
|
||||
function canStopParsingSessions(
|
||||
sessions: AiVaultSession[],
|
||||
limit: number,
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Store } from '../persistence'
|
||||
import type { Automation } from '../../shared/automations-types'
|
||||
import type { WorkspaceRunContext } from '../../shared/task-source-context'
|
||||
import type { ProjectHostSetup, Repo } from '../../shared/types'
|
||||
import { resolveAutomationRunTarget } from './run-target-resolution'
|
||||
|
||||
function makeRepo(overrides: Partial<Repo> = {}): Repo {
|
||||
return {
|
||||
id: 'repo-1',
|
||||
path: '/repo',
|
||||
displayName: 'Repo',
|
||||
badgeColor: '#fff',
|
||||
addedAt: 1,
|
||||
kind: 'git',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeSetup(overrides: Partial<ProjectHostSetup> = {}): ProjectHostSetup {
|
||||
return {
|
||||
id: 'setup-1',
|
||||
projectId: 'github:o/r',
|
||||
hostId: 'local',
|
||||
repoId: 'repo-1',
|
||||
path: '/repo',
|
||||
displayName: 'Repo',
|
||||
setupState: 'ready',
|
||||
setupMethod: 'legacy-repo',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeRunContext(overrides: Partial<WorkspaceRunContext> = {}): WorkspaceRunContext {
|
||||
return {
|
||||
kind: 'workspace-run',
|
||||
projectId: 'repo:repo-1',
|
||||
hostId: 'local',
|
||||
projectHostSetupId: 'setup-1',
|
||||
repoId: 'repo-1',
|
||||
path: '/repo',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeAutomation(runContext: WorkspaceRunContext): Automation {
|
||||
return {
|
||||
id: 'automation-1',
|
||||
name: 'Nightly',
|
||||
prompt: 'Run checks',
|
||||
precheck: null,
|
||||
agentId: 'codex',
|
||||
projectId: 'repo-1',
|
||||
executionTargetType: 'local',
|
||||
executionTargetId: 'local',
|
||||
schedulerOwner: 'local_host_service',
|
||||
workspaceMode: 'new_per_run',
|
||||
baseBranch: null,
|
||||
reuseSession: false,
|
||||
timezone: 'UTC',
|
||||
rrule: 'FREQ=DAILY',
|
||||
dtstart: 1,
|
||||
enabled: true,
|
||||
nextRunAt: 2,
|
||||
missedRunPolicy: 'run_once_within_grace',
|
||||
missedRunGraceMinutes: 720,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
runContext
|
||||
} as Automation
|
||||
}
|
||||
|
||||
function makeStore(setups: ProjectHostSetup[], repos: Repo[]): Store {
|
||||
return {
|
||||
getProjectHostSetups: () => setups,
|
||||
getRepo: (id: string) => repos.find((repo) => repo.id === id)
|
||||
} as unknown as Store
|
||||
}
|
||||
|
||||
describe('resolveAutomationRunTarget projectId drift', () => {
|
||||
it('resolves when only the derived projectId tier differs (repo: snapshot vs github: setup)', () => {
|
||||
const store = makeStore([makeSetup()], [makeRepo()])
|
||||
const automation = makeAutomation(makeRunContext({ projectId: 'repo:repo-1' }))
|
||||
|
||||
const result = resolveAutomationRunTarget(store, automation)
|
||||
|
||||
expect(result).toMatchObject({ ok: true, cwd: '/repo' })
|
||||
})
|
||||
|
||||
it('resolves when the snapshot is at the git: tier and the setup climbed to github:', () => {
|
||||
const store = makeStore([makeSetup()], [makeRepo()])
|
||||
const automation = makeAutomation(makeRunContext({ projectId: 'git:github.com/o/r' }))
|
||||
|
||||
const result = resolveAutomationRunTarget(store, automation)
|
||||
|
||||
expect(result).toMatchObject({ ok: true, cwd: '/repo' })
|
||||
})
|
||||
|
||||
it('still blocks when the repoId no longer matches the setup', () => {
|
||||
const store = makeStore([makeSetup({ repoId: 'repo-2' })], [makeRepo()])
|
||||
const automation = makeAutomation(makeRunContext({ repoId: 'repo-1' }))
|
||||
|
||||
const result = resolveAutomationRunTarget(store, automation)
|
||||
|
||||
expect(result).toMatchObject({ ok: false })
|
||||
})
|
||||
|
||||
it('still blocks when the hostId no longer matches the setup', () => {
|
||||
// Guard returns at the setup hostId/repoId check before any repo lookup, so
|
||||
// the repo's execution host is irrelevant here — assert the guard's own error.
|
||||
const store = makeStore([makeSetup({ hostId: 'ssh:devbox' })], [makeRepo()])
|
||||
const automation = makeAutomation(makeRunContext({ hostId: 'local' }))
|
||||
|
||||
const result = resolveAutomationRunTarget(store, automation)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: 'Automation run target no longer matches the selected project host setup.'
|
||||
})
|
||||
})
|
||||
|
||||
it("still blocks when the repo's execution host drifted off the automation host", () => {
|
||||
// Setup/context hostId agree, but the repo itself now executes on another host —
|
||||
// pins the repo-execution-host check that lives past the setup-match guard.
|
||||
const store = makeStore([makeSetup()], [makeRepo({ executionHostId: 'ssh:devbox' })])
|
||||
const automation = makeAutomation(makeRunContext())
|
||||
|
||||
const result = resolveAutomationRunTarget(store, automation)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: 'Repository is no longer attached to the selected automation host.'
|
||||
})
|
||||
})
|
||||
|
||||
it('still blocks when the path no longer matches the setup', () => {
|
||||
const store = makeStore([makeSetup({ path: '/repo/new' })], [makeRepo({ path: '/repo/new' })])
|
||||
const automation = makeAutomation(makeRunContext({ path: '/repo/old' }))
|
||||
|
||||
const result = resolveAutomationRunTarget(store, automation)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: 'Project path for the selected automation host has changed.'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -64,11 +64,10 @@ export function resolveAutomationRunTarget(
|
||||
error: `Project setup on the selected automation host is ${setup.setupState}.`
|
||||
}
|
||||
}
|
||||
if (
|
||||
setup.projectId !== context.projectId ||
|
||||
setup.hostId !== context.hostId ||
|
||||
setup.repoId !== context.repoId
|
||||
) {
|
||||
// Why: projectId is a derived identity that upgrades over time (repo:→git:→github:);
|
||||
// matching on it strands automations created before their repo's identity resolved.
|
||||
// Anchor on repoId/hostId/path instead — the durable, stable target identity.
|
||||
if (setup.hostId !== context.hostId || setup.repoId !== context.repoId) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Automation run target no longer matches the selected project host setup.'
|
||||
|
||||
@@ -117,6 +117,103 @@ function failWith(error: string): void {
|
||||
})
|
||||
}
|
||||
|
||||
class TestEvent {
|
||||
type: string
|
||||
bubbles: boolean
|
||||
|
||||
constructor(type: string, init?: { bubbles?: boolean }) {
|
||||
this.type = type
|
||||
this.bubbles = init?.bubbles ?? false
|
||||
}
|
||||
}
|
||||
|
||||
type FillEvalNode = {
|
||||
tagName: string
|
||||
getAttribute: (name: string) => string | null
|
||||
matches: (selector: string) => boolean
|
||||
querySelector?: (selector: string) => FillEvalNode | null
|
||||
dispatchEvent: (event: TestEvent) => boolean
|
||||
value: string
|
||||
}
|
||||
|
||||
function matchesFillEvalSelector(node: FillEvalNode, selector: string): boolean {
|
||||
return selector.split(',').some((candidate) => {
|
||||
const trimmed = candidate.trim()
|
||||
if (trimmed === 'textarea') {
|
||||
return node.tagName === 'TEXTAREA'
|
||||
}
|
||||
if (!trimmed.startsWith('input') || node.tagName !== 'INPUT') {
|
||||
return false
|
||||
}
|
||||
const excludedTypes = [...trimmed.matchAll(/:not\(\[type='([^']+)'\]\)/g)].map((match) =>
|
||||
match[1].toLowerCase()
|
||||
)
|
||||
const inputType = node.getAttribute('type')?.toLowerCase() ?? ''
|
||||
return !excludedTypes.includes(inputType)
|
||||
})
|
||||
}
|
||||
|
||||
function createFillEvalNode(options: {
|
||||
tagName: string
|
||||
role?: string
|
||||
ariaControls?: string
|
||||
descendant?: FillEvalNode | null
|
||||
descendants?: FillEvalNode[]
|
||||
type?: string
|
||||
}) {
|
||||
const events: TestEvent[] = []
|
||||
let value = ''
|
||||
const proto = {
|
||||
get value() {
|
||||
return value
|
||||
},
|
||||
set value(next: string) {
|
||||
value = next
|
||||
}
|
||||
}
|
||||
const node = Object.create(proto) as FillEvalNode
|
||||
|
||||
node.tagName = options.tagName
|
||||
node.getAttribute = (name: string) => {
|
||||
if (name === 'role') {
|
||||
return options.role ?? null
|
||||
}
|
||||
if (name === 'aria-controls') {
|
||||
return options.ariaControls ?? null
|
||||
}
|
||||
if (name === 'type') {
|
||||
return options.type ?? null
|
||||
}
|
||||
return null
|
||||
}
|
||||
node.matches = vi.fn((selector: string) => matchesFillEvalSelector(node, selector))
|
||||
const descendants = options.descendants ?? (options.descendant ? [options.descendant] : [])
|
||||
node.querySelector = vi.fn(
|
||||
(selector: string) => descendants.find((descendant) => descendant.matches(selector)) ?? null
|
||||
)
|
||||
node.dispatchEvent = vi.fn((event: TestEvent) => {
|
||||
events.push(event)
|
||||
return true
|
||||
})
|
||||
|
||||
return {
|
||||
node,
|
||||
events,
|
||||
get value() {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function runFillEvalExpressions(
|
||||
expressions: string[],
|
||||
document: { activeElement: unknown; getElementById: (id: string) => unknown }
|
||||
): void {
|
||||
for (const expression of expressions) {
|
||||
new Function('document', 'Event', `return (${expression})`)(document, TestEvent)
|
||||
}
|
||||
}
|
||||
|
||||
const CDP_DISCOVERY_FAILURE =
|
||||
'Auto-launch failed: All CDP discovery methods failed: connect ECONNREFUSED 127.0.0.1:9222; WebSocket connect failed'
|
||||
|
||||
@@ -1432,6 +1529,155 @@ describe('AgentBrowserBridge', () => {
|
||||
expect(() => new Function(expression)).not.toThrow()
|
||||
})
|
||||
|
||||
it('routes focused spinbutton wrappers to editable descendants before filling', async () => {
|
||||
succeedWith({ ok: true })
|
||||
|
||||
await bridge.fill('@spinbutton', '200')
|
||||
|
||||
const expressions = execFileMock.mock.calls
|
||||
.filter((call: unknown[]) => (call[1] as string[]).includes('eval'))
|
||||
.map((call: unknown[]) => {
|
||||
const args = call[1] as string[]
|
||||
return args[args.indexOf('eval') + 1]
|
||||
})
|
||||
|
||||
const input = createFillEvalNode({ tagName: 'INPUT' })
|
||||
const wrapper = createFillEvalNode({
|
||||
tagName: 'DIV',
|
||||
role: 'spinbutton',
|
||||
descendant: input.node
|
||||
})
|
||||
|
||||
runFillEvalExpressions(expressions, {
|
||||
activeElement: wrapper.node,
|
||||
getElementById: () => null
|
||||
})
|
||||
|
||||
expect(input.value).toBe('200')
|
||||
expect(wrapper.value).toBe('')
|
||||
expect(input.events.map((event) => event.type)).toEqual(['input', 'change'])
|
||||
expect(wrapper.events).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('routes aria-controlled spinbutton wrappers to editable inputs before filling', async () => {
|
||||
succeedWith({ ok: true })
|
||||
|
||||
await bridge.fill('@spinbutton', '200')
|
||||
|
||||
const expressions = execFileMock.mock.calls
|
||||
.filter((call: unknown[]) => (call[1] as string[]).includes('eval'))
|
||||
.map((call: unknown[]) => {
|
||||
const args = call[1] as string[]
|
||||
return args[args.indexOf('eval') + 1]
|
||||
})
|
||||
|
||||
const input = createFillEvalNode({ tagName: 'INPUT' })
|
||||
const wrapper = createFillEvalNode({
|
||||
tagName: 'DIV',
|
||||
role: 'spinbutton',
|
||||
ariaControls: 'target-id'
|
||||
})
|
||||
|
||||
runFillEvalExpressions(expressions, {
|
||||
activeElement: wrapper.node,
|
||||
getElementById: (id: string) => (id === 'target-id' ? input.node : null)
|
||||
})
|
||||
|
||||
expect(input.value).toBe('200')
|
||||
expect(wrapper.value).toBe('')
|
||||
expect(input.events.map((event) => event.type)).toEqual(['input', 'change'])
|
||||
expect(wrapper.events).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('routes aria-controlled spinbutton containers to editable descendants before filling', async () => {
|
||||
succeedWith({ ok: true })
|
||||
|
||||
await bridge.fill('@spinbutton', '200')
|
||||
|
||||
const expressions = execFileMock.mock.calls
|
||||
.filter((call: unknown[]) => (call[1] as string[]).includes('eval'))
|
||||
.map((call: unknown[]) => {
|
||||
const args = call[1] as string[]
|
||||
return args[args.indexOf('eval') + 1]
|
||||
})
|
||||
|
||||
const input = createFillEvalNode({ tagName: 'INPUT' })
|
||||
const controlled = createFillEvalNode({ tagName: 'DIV', descendant: input.node })
|
||||
const wrapper = createFillEvalNode({
|
||||
tagName: 'DIV',
|
||||
role: 'spinbutton',
|
||||
ariaControls: 'target-id'
|
||||
})
|
||||
|
||||
runFillEvalExpressions(expressions, {
|
||||
activeElement: wrapper.node,
|
||||
getElementById: (id: string) => (id === 'target-id' ? controlled.node : null)
|
||||
})
|
||||
|
||||
expect(input.value).toBe('200')
|
||||
expect(controlled.value).toBe('')
|
||||
expect(wrapper.value).toBe('')
|
||||
expect(input.events.map((event) => event.type)).toEqual(['input', 'change'])
|
||||
expect(controlled.events).toHaveLength(0)
|
||||
expect(wrapper.events).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('skips non-text spinbutton descendant inputs before filling', async () => {
|
||||
succeedWith({ ok: true })
|
||||
|
||||
await bridge.fill('@spinbutton', '200')
|
||||
|
||||
const expressions = execFileMock.mock.calls
|
||||
.filter((call: unknown[]) => (call[1] as string[]).includes('eval'))
|
||||
.map((call: unknown[]) => {
|
||||
const args = call[1] as string[]
|
||||
return args[args.indexOf('eval') + 1]
|
||||
})
|
||||
|
||||
const hiddenInput = createFillEvalNode({ tagName: 'INPUT', type: 'hidden' })
|
||||
const numberInput = createFillEvalNode({ tagName: 'INPUT', type: 'number' })
|
||||
const wrapper = createFillEvalNode({
|
||||
tagName: 'DIV',
|
||||
role: 'spinbutton',
|
||||
descendants: [hiddenInput.node, numberInput.node]
|
||||
})
|
||||
|
||||
runFillEvalExpressions(expressions, {
|
||||
activeElement: wrapper.node,
|
||||
getElementById: () => null
|
||||
})
|
||||
|
||||
expect(numberInput.value).toBe('200')
|
||||
expect(hiddenInput.value).toBe('')
|
||||
expect(wrapper.value).toBe('')
|
||||
expect(numberInput.events.map((event) => event.type)).toEqual(['input', 'change'])
|
||||
expect(hiddenInput.events).toHaveLength(0)
|
||||
expect(wrapper.events).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps plain focused inputs as fill targets', async () => {
|
||||
succeedWith({ ok: true })
|
||||
|
||||
await bridge.fill('@input', '200')
|
||||
|
||||
const expressions = execFileMock.mock.calls
|
||||
.filter((call: unknown[]) => (call[1] as string[]).includes('eval'))
|
||||
.map((call: unknown[]) => {
|
||||
const args = call[1] as string[]
|
||||
return args[args.indexOf('eval') + 1]
|
||||
})
|
||||
|
||||
const input = createFillEvalNode({ tagName: 'INPUT' })
|
||||
|
||||
runFillEvalExpressions(expressions, {
|
||||
activeElement: input.node,
|
||||
getElementById: () => null
|
||||
})
|
||||
|
||||
expect(input.value).toBe('200')
|
||||
expect(input.events.map((event) => event.type)).toEqual(['input', 'change'])
|
||||
})
|
||||
|
||||
it('chunks large agent-browser fill values before eval transport', async () => {
|
||||
const text = ['x'.repeat(AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES), 'tail'].join('')
|
||||
succeedWith({ ok: true })
|
||||
|
||||
@@ -92,17 +92,27 @@ function focusedValueSetExpression(
|
||||
options?: { append?: boolean; dispatchEvents?: boolean }
|
||||
): string {
|
||||
const nextValue = options?.append
|
||||
? ["String(el.value ?? '') + ", valueExpression].join('')
|
||||
? ["String(target.value ?? '') + ", valueExpression].join('')
|
||||
: valueExpression
|
||||
const dispatchEvents = options?.dispatchEvents
|
||||
? " el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true }));"
|
||||
? " target.dispatchEvent(new Event('input', { bubbles: true })); target.dispatchEvent(new Event('change', { bubbles: true }));"
|
||||
: ''
|
||||
return [
|
||||
'(() => { const el = document.activeElement; if (el) {' +
|
||||
" const nativeSetter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), 'value')?.set;",
|
||||
'(() => { const el = document.activeElement; if (el) {',
|
||||
// Why: ARIA spinbutton wrappers can hold focus while a contained or controlled input owns the value.
|
||||
" const editableSelector = \"input:not([type='hidden']):not([type='button']):not([type='checkbox']):not([type='radio']):not([type='file']):not([type='image']):not([type='reset']):not([type='submit']), textarea\";",
|
||||
" const isEditable = (node) => !!node && (node.matches?.(editableSelector) ?? (node.tagName === 'TEXTAREA' || (node.tagName === 'INPUT' && !/^(hidden|button|checkbox|radio|file|image|reset|submit)$/i.test(node.getAttribute?.('type') ?? ''))));",
|
||||
' const findEditable = (root) => root?.querySelector?.(editableSelector) ?? null;',
|
||||
' let target = el;',
|
||||
" if (!isEditable(target) && target.getAttribute?.('role') === 'spinbutton') {",
|
||||
" const controls = target.getAttribute('aria-controls');",
|
||||
' if (controls) { for (const id of controls.split(/\\s+/)) { if (!id) continue; const controlled = document.getElementById(id); if (isEditable(controlled)) { target = controlled; break; } const descendant = findEditable(controlled); if (descendant) { target = descendant; break; } } }',
|
||||
' if (target === el) { const descendant = findEditable(target); if (descendant) target = descendant; }',
|
||||
' }',
|
||||
" const nativeSetter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(target), 'value')?.set;",
|
||||
' const nextValue = ',
|
||||
nextValue,
|
||||
'; if (nativeSetter) { nativeSetter.call(el, nextValue); } else { el.value = nextValue; }',
|
||||
'; if (nativeSetter) { nativeSetter.call(target, nextValue); } else { target.value = nextValue; }',
|
||||
dispatchEvents,
|
||||
' } })()'
|
||||
].join('')
|
||||
|
||||
@@ -5,6 +5,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'nod
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import type { ClaudeManagedAccount } from '../../shared/types'
|
||||
import {
|
||||
deleteActiveClaudeKeychainCredentialsStrict,
|
||||
readActiveClaudeKeychainCredentials,
|
||||
@@ -1052,13 +1053,16 @@ describe('ClaudeAccountService credential capture', () => {
|
||||
vi.resetModules()
|
||||
vi.useFakeTimers()
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
stdin: PassThrough
|
||||
stdout: PassThrough
|
||||
stderr: PassThrough
|
||||
kill: () => void
|
||||
}
|
||||
child.stdin = new PassThrough()
|
||||
child.stdout = new PassThrough()
|
||||
child.stderr = new PassThrough()
|
||||
child.kill = vi.fn()
|
||||
const destroyStdin = vi.spyOn(child.stdin, 'destroy')
|
||||
const spawnMock = vi.fn(() => child)
|
||||
vi.doMock('node:child_process', () => ({ spawn: spawnMock }))
|
||||
|
||||
@@ -1074,13 +1078,15 @@ describe('ClaudeAccountService credential capture', () => {
|
||||
runClaudeCommand(
|
||||
args: string[],
|
||||
configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null },
|
||||
timeoutMs: number
|
||||
timeoutMs: number,
|
||||
options?: { keepStdinOpen?: boolean }
|
||||
): Promise<string>
|
||||
}
|
||||
).runClaudeCommand(
|
||||
['login'],
|
||||
{ windowsPath: '/tmp/claude-auth', linuxPath: null, wslDistro: null },
|
||||
1000
|
||||
1000,
|
||||
{ keepStdinOpen: true }
|
||||
)
|
||||
const rejection = expect(commandPromise).rejects.toThrow(
|
||||
'Claude sign-in took too long to finish.'
|
||||
@@ -1090,6 +1096,7 @@ describe('ClaudeAccountService credential capture', () => {
|
||||
|
||||
await rejection
|
||||
expect(child.kill).toHaveBeenCalledTimes(1)
|
||||
expect(destroyStdin).toHaveBeenCalledTimes(1)
|
||||
expect(child.stdout.listenerCount('data')).toBe(0)
|
||||
expect(child.stderr.listenerCount('data')).toBe(0)
|
||||
expect(child.listenerCount('error')).toBe(0)
|
||||
@@ -1100,18 +1107,109 @@ describe('ClaudeAccountService credential capture', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('pipes stdin only for the explicit Claude account login command', async () => {
|
||||
setPlatform('linux')
|
||||
vi.resetModules()
|
||||
vi.mocked(readActiveClaudeKeychainCredentials).mockResolvedValue(null)
|
||||
const loginChild = new EventEmitter() as EventEmitter & {
|
||||
stdin: PassThrough
|
||||
stdout: PassThrough
|
||||
stderr: PassThrough
|
||||
kill: ReturnType<typeof vi.fn>
|
||||
}
|
||||
loginChild.stdin = new PassThrough()
|
||||
loginChild.stdout = new PassThrough()
|
||||
loginChild.stderr = new PassThrough()
|
||||
loginChild.kill = vi.fn()
|
||||
const statusChild = new EventEmitter() as EventEmitter & {
|
||||
stdout: PassThrough
|
||||
stderr: PassThrough
|
||||
kill: ReturnType<typeof vi.fn>
|
||||
}
|
||||
statusChild.stdout = new PassThrough()
|
||||
statusChild.stderr = new PassThrough()
|
||||
statusChild.kill = vi.fn()
|
||||
const spawnMock = vi.fn(
|
||||
(_command: string, args: string[], options: { env: NodeJS.ProcessEnv }) => {
|
||||
if (args[1] === 'login') {
|
||||
writeFileSync(
|
||||
join(options.env.CLAUDE_CONFIG_DIR!, '.credentials.json'),
|
||||
'{"claudeAiOauth":{"email":"user@example.com","accessToken":"token"}}\n',
|
||||
'utf-8'
|
||||
)
|
||||
queueMicrotask(() => loginChild.emit('close', 0))
|
||||
return loginChild
|
||||
}
|
||||
statusChild.stdout.write('{"email":"user@example.com"}\n')
|
||||
queueMicrotask(() => statusChild.emit('close', 0))
|
||||
return statusChild
|
||||
}
|
||||
)
|
||||
vi.doMock('node:child_process', () => ({ spawn: spawnMock }))
|
||||
|
||||
try {
|
||||
const { ClaudeAccountService } = await import('./service')
|
||||
let settings = {
|
||||
claudeManagedAccounts: [] as ClaudeManagedAccount[],
|
||||
activeClaudeManagedAccountId: null,
|
||||
activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} }
|
||||
}
|
||||
const store = {
|
||||
getSettings: vi.fn(() => settings),
|
||||
updateSettings: vi.fn((updates: Partial<typeof settings>) => {
|
||||
settings = { ...settings, ...updates }
|
||||
return settings
|
||||
})
|
||||
}
|
||||
const runtimeAuth = {
|
||||
clearLastWrittenCredentialsJson: vi.fn(),
|
||||
forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {})
|
||||
}
|
||||
const rateLimits = {
|
||||
evictInactiveClaudeCache: vi.fn(),
|
||||
refreshForClaudeAccountChange: vi.fn()
|
||||
}
|
||||
const service = new ClaudeAccountService(
|
||||
store as never,
|
||||
rateLimits as never,
|
||||
runtimeAuth as never
|
||||
)
|
||||
|
||||
await service.addAccount()
|
||||
|
||||
expect(spawnMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'claude',
|
||||
['auth', 'login', '--claudeai'],
|
||||
expect.objectContaining({ stdio: ['pipe', 'pipe', 'pipe'] })
|
||||
)
|
||||
expect(spawnMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'claude',
|
||||
['auth', 'status', '--json'],
|
||||
expect.objectContaining({ stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
)
|
||||
expect(settings.claudeManagedAccounts[0]?.email).toBe('user@example.com')
|
||||
} finally {
|
||||
vi.doUnmock('node:child_process')
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects immediately when Claude sign-in is denied', async () => {
|
||||
vi.resetModules()
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
stdin: PassThrough
|
||||
stdout: PassThrough
|
||||
stderr: PassThrough
|
||||
kill: ReturnType<typeof vi.fn>
|
||||
pid: number
|
||||
}
|
||||
child.stdin = new PassThrough()
|
||||
child.stdout = new PassThrough()
|
||||
child.stderr = new PassThrough()
|
||||
child.kill = vi.fn()
|
||||
child.pid = 4242
|
||||
const destroyStdin = vi.spyOn(child.stdin, 'destroy')
|
||||
const spawnMock = vi.fn(() => child)
|
||||
vi.doMock('node:child_process', () => ({ spawn: spawnMock }))
|
||||
// Denial must tear down the whole detached login/browser tree (process-group kill on POSIX),
|
||||
@@ -1130,13 +1228,15 @@ describe('ClaudeAccountService credential capture', () => {
|
||||
runClaudeCommand(
|
||||
args: string[],
|
||||
configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null },
|
||||
timeoutMs: number
|
||||
timeoutMs: number,
|
||||
options?: { keepStdinOpen?: boolean }
|
||||
): Promise<string>
|
||||
}
|
||||
).runClaudeCommand(
|
||||
['login'],
|
||||
{ windowsPath: '/tmp/claude-auth', linuxPath: null, wslDistro: null },
|
||||
180_000
|
||||
180_000,
|
||||
{ keepStdinOpen: true }
|
||||
)
|
||||
|
||||
child.stderr.write('OAuth authorization failed: access_denied\n')
|
||||
@@ -1144,6 +1244,7 @@ describe('ClaudeAccountService credential capture', () => {
|
||||
await expect(commandPromise).rejects.toThrow('Claude sign-in was denied. Please try again.')
|
||||
expect(killTree).toHaveBeenCalledWith(-child.pid)
|
||||
expect(child.kill).not.toHaveBeenCalled()
|
||||
expect(destroyStdin).toHaveBeenCalledTimes(1)
|
||||
expect(child.stdout.listenerCount('data')).toBe(0)
|
||||
expect(child.stderr.listenerCount('data')).toBe(0)
|
||||
expect(child.listenerCount('error')).toBe(0)
|
||||
@@ -1157,13 +1258,16 @@ describe('ClaudeAccountService credential capture', () => {
|
||||
it('cancels an in-flight Claude account add', async () => {
|
||||
vi.resetModules()
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
stdin: PassThrough
|
||||
stdout: PassThrough
|
||||
stderr: PassThrough
|
||||
kill: ReturnType<typeof vi.fn>
|
||||
}
|
||||
child.stdin = new PassThrough()
|
||||
child.stdout = new PassThrough()
|
||||
child.stderr = new PassThrough()
|
||||
child.kill = vi.fn()
|
||||
const destroyStdin = vi.spyOn(child.stdin, 'destroy')
|
||||
const spawnMock = vi.fn(() => child)
|
||||
vi.doMock('node:child_process', () => ({ spawn: spawnMock }))
|
||||
|
||||
@@ -1203,6 +1307,7 @@ describe('ClaudeAccountService credential capture', () => {
|
||||
expect(service.cancelPendingLogin()).toBe(true)
|
||||
await expect(addPromise).rejects.toThrow('Claude sign-in was cancelled.')
|
||||
expect(child.kill).toHaveBeenCalledTimes(1)
|
||||
expect(destroyStdin).toHaveBeenCalledTimes(1)
|
||||
expect(service.cancelPendingLogin()).toBe(false)
|
||||
expect(settings.claudeManagedAccounts).toEqual([])
|
||||
expect(child.stdout.listenerCount('data')).toBe(0)
|
||||
@@ -1278,14 +1383,17 @@ describe('ClaudeAccountService credential capture', () => {
|
||||
vi.mocked(readActiveClaudeKeychainCredentials).mockResolvedValue(null)
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
pid: number
|
||||
stdin: PassThrough
|
||||
stdout: PassThrough
|
||||
stderr: PassThrough
|
||||
kill: ReturnType<typeof vi.fn>
|
||||
}
|
||||
child.pid = 1234
|
||||
child.stdin = new PassThrough()
|
||||
child.stdout = new PassThrough()
|
||||
child.stderr = new PassThrough()
|
||||
child.kill = vi.fn()
|
||||
const destroyStdin = vi.spyOn(child.stdin, 'destroy')
|
||||
const taskkill = new EventEmitter() as EventEmitter & {
|
||||
unref: ReturnType<typeof vi.fn>
|
||||
}
|
||||
@@ -1339,6 +1447,7 @@ describe('ClaudeAccountService credential capture', () => {
|
||||
expect.objectContaining({ stdio: 'ignore', windowsHide: true })
|
||||
)
|
||||
expect(taskkill.unref).toHaveBeenCalled()
|
||||
expect(destroyStdin).toHaveBeenCalledTimes(1)
|
||||
expect(service.cancelPendingLogin()).toBe(false)
|
||||
} finally {
|
||||
vi.doUnmock('node:child_process')
|
||||
|
||||
@@ -449,7 +449,8 @@ export class ClaudeAccountService {
|
||||
throw new Error('Claude sign-in was cancelled.')
|
||||
}
|
||||
await this.runClaudeCommand(['auth', 'login', '--claudeai'], tempConfig, LOGIN_TIMEOUT_MS, {
|
||||
signal: loginAbortController.signal
|
||||
signal: loginAbortController.signal,
|
||||
keepStdinOpen: true
|
||||
})
|
||||
this.cancelPendingClaudeLogin = null
|
||||
const status = await this.runClaudeCommand(
|
||||
@@ -885,7 +886,7 @@ export class ClaudeAccountService {
|
||||
args: string[],
|
||||
configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null },
|
||||
timeoutMs: number,
|
||||
options?: { allowFailure?: boolean; signal?: AbortSignal }
|
||||
options?: { allowFailure?: boolean; signal?: AbortSignal; keepStdinOpen?: boolean }
|
||||
): Promise<string> {
|
||||
return new Promise((resolvePromise, rejectPromise) => {
|
||||
const spawnConfig =
|
||||
@@ -913,13 +914,26 @@ export class ClaudeAccountService {
|
||||
shell: process.platform === 'win32'
|
||||
}
|
||||
const child = spawn(spawnConfig.command, spawnConfig.args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
// Why: Claude's browser auth can bind its callback lifetime to stdin.
|
||||
// Keeping stdin open prevents hidden managed-login runs from tearing down
|
||||
// the local callback server before the browser returns.
|
||||
stdio: [options?.keepStdinOpen ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
||||
shell: spawnConfig.shell,
|
||||
env: spawnConfig.env,
|
||||
// Why: Claude auth can leave browser/login descendants alive after denial.
|
||||
// A process group lets cancellation terminate the whole POSIX login tree.
|
||||
detached: process.platform !== 'win32'
|
||||
})
|
||||
const stdout = child.stdout
|
||||
const stderr = child.stderr
|
||||
if (!stdout || !stderr) {
|
||||
if (options?.keepStdinOpen) {
|
||||
child.stdin?.destroy()
|
||||
}
|
||||
child.kill()
|
||||
rejectPromise(new Error('Claude command failed to open output streams.'))
|
||||
return
|
||||
}
|
||||
|
||||
let settled = false
|
||||
let output = ''
|
||||
@@ -941,11 +955,14 @@ export class ClaudeAccountService {
|
||||
clearTimeout(timeout)
|
||||
timeout = null
|
||||
}
|
||||
child.stdout.off('data', appendOutput)
|
||||
child.stderr.off('data', appendOutput)
|
||||
stdout.off('data', appendOutput)
|
||||
stderr.off('data', appendOutput)
|
||||
child.off('error', onError)
|
||||
child.off('close', onClose)
|
||||
options?.signal?.removeEventListener('abort', onAbort)
|
||||
if (options?.keepStdinOpen) {
|
||||
child.stdin?.destroy()
|
||||
}
|
||||
}
|
||||
const settle = (callback: () => void): void => {
|
||||
if (settled) {
|
||||
@@ -1006,8 +1023,8 @@ export class ClaudeAccountService {
|
||||
})
|
||||
}
|
||||
|
||||
child.stdout.on('data', appendOutput)
|
||||
child.stderr.on('data', appendOutput)
|
||||
stdout.on('data', appendOutput)
|
||||
stderr.on('data', appendOutput)
|
||||
child.on('error', onError)
|
||||
child.on('close', onClose)
|
||||
if (options?.signal?.aborted) {
|
||||
|
||||
@@ -127,6 +127,8 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
||||
defaultLinearTeamSelection: null,
|
||||
opencodeSessionCookie: '',
|
||||
opencodeWorkspaceId: '',
|
||||
minimaxGroupId: '',
|
||||
minimaxUsageModels: 'general',
|
||||
geminiCliOAuthEnabled: false,
|
||||
agentCmdOverrides: {},
|
||||
keepComputerAwakeWhileAgentsRun: false,
|
||||
|
||||
@@ -131,6 +131,8 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
||||
defaultLinearTeamSelection: null,
|
||||
opencodeSessionCookie: '',
|
||||
opencodeWorkspaceId: '',
|
||||
minimaxGroupId: '',
|
||||
minimaxUsageModels: 'general',
|
||||
geminiCliOAuthEnabled: false,
|
||||
agentCmdOverrides: {},
|
||||
keepComputerAwakeWhileAgentsRun: false,
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import type { ComputerAppInfo } from '../../shared/runtime-types'
|
||||
|
||||
type MacOSRunningApp = ComputerAppInfo & {
|
||||
activationPolicy: number
|
||||
isFrontmost: boolean
|
||||
}
|
||||
|
||||
const JXA_LIST_RUNNING_APPS = `
|
||||
ObjC.import('AppKit');
|
||||
const apps = $.NSWorkspace.sharedWorkspace.runningApplications;
|
||||
const out = [];
|
||||
for (let i = 0; i < apps.count; i += 1) {
|
||||
const app = apps.objectAtIndex(i);
|
||||
if (app.isTerminated) continue;
|
||||
const name = ObjC.unwrap(app.localizedName) || '';
|
||||
const bundleId = ObjC.unwrap(app.bundleIdentifier) || null;
|
||||
const pid = Number(app.processIdentifier);
|
||||
const activationPolicy = Number(app.activationPolicy);
|
||||
if (!name || !Number.isInteger(pid) || pid <= 0) continue;
|
||||
out.push({
|
||||
name,
|
||||
bundleId,
|
||||
pid,
|
||||
activationPolicy,
|
||||
isFrontmost: Boolean(app.active)
|
||||
});
|
||||
}
|
||||
JSON.stringify(out);
|
||||
`
|
||||
|
||||
export async function listMacOSApps(): Promise<ComputerAppInfo[]> {
|
||||
const { stdout } = await execFilePromise(
|
||||
'osascript',
|
||||
['-l', 'JavaScript', '-e', JXA_LIST_RUNNING_APPS],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
maxBuffer: 1024 * 1024
|
||||
}
|
||||
)
|
||||
const apps = parseMacOSApps(stdout).filter(hasLivePid)
|
||||
return dedupeApps(apps.filter(isUserFacingApp)).map(({ name, bundleId, pid }) => ({
|
||||
name,
|
||||
bundleId,
|
||||
pid
|
||||
}))
|
||||
}
|
||||
|
||||
function parseMacOSApps(stdout: string): MacOSRunningApp[] {
|
||||
const parsed: unknown = JSON.parse(stdout)
|
||||
if (!Array.isArray(parsed)) {
|
||||
return []
|
||||
}
|
||||
return parsed.flatMap((value): MacOSRunningApp[] => {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return []
|
||||
}
|
||||
const record = value as Record<string, unknown>
|
||||
const name = typeof record.name === 'string' ? record.name : ''
|
||||
const bundleId = typeof record.bundleId === 'string' ? record.bundleId : null
|
||||
const pid = typeof record.pid === 'number' && Number.isInteger(record.pid) ? record.pid : 0
|
||||
const activationPolicy =
|
||||
typeof record.activationPolicy === 'number' && Number.isInteger(record.activationPolicy)
|
||||
? record.activationPolicy
|
||||
: -1
|
||||
const isFrontmost = record.isFrontmost === true
|
||||
return name && pid > 0 ? [{ name, bundleId, pid, activationPolicy, isFrontmost }] : []
|
||||
})
|
||||
}
|
||||
|
||||
function isUserFacingApp(app: MacOSRunningApp): boolean {
|
||||
return app.activationPolicy === 0
|
||||
}
|
||||
|
||||
function hasLivePid(app: MacOSRunningApp): boolean {
|
||||
try {
|
||||
process.kill(app.pid, 0)
|
||||
return true
|
||||
} catch {
|
||||
// NSWorkspace can briefly retain terminated apps; filter them before provider calls.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function dedupeApps(apps: MacOSRunningApp[]): MacOSRunningApp[] {
|
||||
const seen = new Set<string>()
|
||||
const sorted = [...apps].sort((a, b) => {
|
||||
if (a.isFrontmost !== b.isFrontmost) {
|
||||
return a.isFrontmost ? -1 : 1
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
const result: MacOSRunningApp[] = []
|
||||
for (const app of sorted) {
|
||||
const key = app.bundleId ? `bundle:${app.bundleId.toLowerCase()}` : `pid:${app.pid}`
|
||||
if (seen.has(key)) {
|
||||
continue
|
||||
}
|
||||
seen.add(key)
|
||||
result.push(app)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function execFilePromise(
|
||||
file: string,
|
||||
args: string[],
|
||||
options: { encoding: 'utf8'; timeout: number; maxBuffer: number }
|
||||
): Promise<{ stdout: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(file, args, options, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
const detail = typeof stderr === 'string' && stderr.trim() ? `: ${stderr.trim()}` : ''
|
||||
reject(new Error(`${error.message}${detail}`))
|
||||
return
|
||||
}
|
||||
resolve({ stdout })
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -6,6 +6,12 @@
|
||||
* Signals readiness to parent via IPC: { type: 'ready' }
|
||||
* Shuts down cleanly on SIGTERM.
|
||||
*/
|
||||
// Note: the windowsHide child_process shim is NOT imported here. Rollup's CJS
|
||||
// chunking hoists chunk requires above inlined module code, so an in-graph
|
||||
// "first import" runs AFTER sibling chunks capture promisify(execFile) — the
|
||||
// shim silently did nothing in packaged builds. It ships as its own bundle
|
||||
// entry and the daemon fork preloads it via `--require` (daemon-init.ts),
|
||||
// which Node guarantees runs before any of this graph loads.
|
||||
import { startDaemon, type DaemonHandle } from './daemon-main'
|
||||
import { createPtySubprocess } from './pty-subprocess'
|
||||
import { warmWindowsConptyOnce } from './windows-conpty-warmup'
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Mutable Electron app stub, hoisted so the vi.mock factory can close over it.
|
||||
const { electronApp } = vi.hoisted(() => ({
|
||||
electronApp: {
|
||||
isPackaged: false,
|
||||
userDataPath: '',
|
||||
version: '0.0.0-test',
|
||||
getPath: (): string => electronApp.userDataPath,
|
||||
getVersion: (): string => electronApp.version
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({ app: electronApp }))
|
||||
|
||||
import { resolveDaemonHostSourceDir } from './daemon-host-relocation'
|
||||
|
||||
let tempDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(os.tmpdir(), 'daemon-host-relocation-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
} catch {}
|
||||
})
|
||||
|
||||
describe('resolveDaemonHostSourceDir', () => {
|
||||
it('returns the daemon-host dir when it holds a node.exe', () => {
|
||||
const resources = join(tempDir, 'resources')
|
||||
mkdirSync(join(resources, 'daemon-host'), { recursive: true })
|
||||
writeFileSync(join(resources, 'daemon-host', 'node.exe'), 'host')
|
||||
expect(resolveDaemonHostSourceDir(resources)).toBe(join(resources, 'daemon-host'))
|
||||
})
|
||||
|
||||
it('returns null when the daemon-host dir has no node.exe', () => {
|
||||
const resources = join(tempDir, 'resources')
|
||||
mkdirSync(join(resources, 'daemon-host'), { recursive: true })
|
||||
expect(resolveDaemonHostSourceDir(resources)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when the daemon-host dir is absent', () => {
|
||||
expect(resolveDaemonHostSourceDir(join(tempDir, 'resources'))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// process.resourcesPath is typed read-only; point it at a fixture for the test.
|
||||
function stubResourcesPath(value: string | undefined): void {
|
||||
Object.defineProperty(process, 'resourcesPath', { value, configurable: true, writable: true })
|
||||
}
|
||||
|
||||
// Copies a real node.exe path shape; the win32 guard in the module means this
|
||||
// only exercises the relocation path on Windows.
|
||||
describe.runIf(process.platform === 'win32')('installRelocatedDaemonHost', () => {
|
||||
const originalResourcesPath = process.resourcesPath
|
||||
|
||||
afterEach(() => {
|
||||
stubResourcesPath(originalResourcesPath)
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('relocates a bundled node.exe and exposes its userData path', async () => {
|
||||
const resources = join(tempDir, 'resources')
|
||||
mkdirSync(join(resources, 'daemon-host'), { recursive: true })
|
||||
writeFileSync(join(resources, 'daemon-host', 'node.exe'), 'fake-node-host')
|
||||
stubResourcesPath(resources)
|
||||
electronApp.userDataPath = join(tempDir, 'userData')
|
||||
electronApp.version = '9.9.9'
|
||||
vi.stubEnv('ORCA_FORCE_DAEMON_HOST_RELOCATION', '1')
|
||||
|
||||
// Fresh module so the one-shot install/singleton state is not carried over.
|
||||
vi.resetModules()
|
||||
const mod = await import('./daemon-host-relocation')
|
||||
mod.installRelocatedDaemonHost()
|
||||
|
||||
const execPath = mod.getRelocatedDaemonHostExecPath()
|
||||
expect(execPath).toBe(join(electronApp.userDataPath, 'daemon-host', '9.9.9', 'node.exe'))
|
||||
expect(readFileSync(execPath!, 'utf8')).toBe('fake-node-host')
|
||||
})
|
||||
|
||||
it('fails open to null when no bundled node.exe is present', async () => {
|
||||
const resources = join(tempDir, 'resources')
|
||||
mkdirSync(resources, { recursive: true })
|
||||
stubResourcesPath(resources)
|
||||
electronApp.userDataPath = join(tempDir, 'userData')
|
||||
vi.stubEnv('ORCA_FORCE_DAEMON_HOST_RELOCATION', '1')
|
||||
|
||||
vi.resetModules()
|
||||
const mod = await import('./daemon-host-relocation')
|
||||
mod.installRelocatedDaemonHost()
|
||||
|
||||
expect(mod.getRelocatedDaemonHostExecPath()).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { app } from 'electron'
|
||||
import { ensureRelocatedRuntime } from '../pty/install-dir-runtime-relocation'
|
||||
|
||||
/**
|
||||
* Relocates the Node host that runs the detached terminal daemon out of the
|
||||
* app install directory into userData.
|
||||
*
|
||||
* Why: the daemon is `fork()`ed as plain Node via ELECTRON_RUN_AS_NODE, so its
|
||||
* process image is the install-dir Orca.exe. The NSIS update installer
|
||||
* force-closes every process whose image path is under the install directory
|
||||
* before replacing files, which kills the daemon — and every live terminal it
|
||||
* owns — mid-update. Running the daemon from a version-keyed node.exe staged in
|
||||
* userData takes its image out of the installer's kill zone so it survives.
|
||||
*
|
||||
* Fail-open: if no bundled node.exe is present (dev, or a build that predates
|
||||
* shipping it), relocation no-ops and the caller keeps forking the install-dir
|
||||
* Orca.exe host — the pre-relocation behavior, with zero regression.
|
||||
*/
|
||||
const RELOCATED_DAEMON_HOST_EXE = 'node.exe'
|
||||
|
||||
let relocatedDaemonHostExecPath: string | null = null
|
||||
let installed = false
|
||||
|
||||
/**
|
||||
* The bundled daemon-host dir under app resources, or null when no node.exe is
|
||||
* shipped there. Kept separate from install so the resources path is testable.
|
||||
*/
|
||||
export function resolveDaemonHostSourceDir(resourcesPath: string): string | null {
|
||||
const dir = join(resourcesPath, 'daemon-host')
|
||||
return existsSync(join(dir, RELOCATED_DAEMON_HOST_EXE)) ? dir : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the bundled daemon-host node.exe into a version-keyed userData dir
|
||||
* (once) and records its path for the daemon fork. Safe to call more than once;
|
||||
* only the first call does work.
|
||||
*/
|
||||
export function installRelocatedDaemonHost(): void {
|
||||
if (installed) {
|
||||
return
|
||||
}
|
||||
installed = true
|
||||
if (process.platform !== 'win32') {
|
||||
return
|
||||
}
|
||||
// The install-dir kill zone only exists for packaged installs; a dev override
|
||||
// lets the relocation path be exercised without a full NSIS build.
|
||||
if (!app.isPackaged && process.env.ORCA_FORCE_DAEMON_HOST_RELOCATION !== '1') {
|
||||
return
|
||||
}
|
||||
// Why: fail-open contract — this must never throw. resourcesPath is unset
|
||||
// outside a real Electron process (tests, node-hosted tooling).
|
||||
if (typeof process.resourcesPath !== 'string') {
|
||||
return
|
||||
}
|
||||
const sourceDir = resolveDaemonHostSourceDir(process.resourcesPath)
|
||||
if (!sourceDir) {
|
||||
return
|
||||
}
|
||||
const userData = app.getPath('userData')
|
||||
const destDir = ensureRelocatedRuntime({
|
||||
sourceDir,
|
||||
destRoot: join(userData, 'daemon-host'),
|
||||
version: app.getVersion(),
|
||||
// Same runtimeDir daemon-init writes daemon-v<N>.pid into, so a surviving
|
||||
// daemon's host dir is pinned by its recorded appVersion and never reclaimed
|
||||
// while it runs.
|
||||
daemonRuntimeDir: join(userData, 'daemon')
|
||||
})
|
||||
if (destDir) {
|
||||
relocatedDaemonHostExecPath = join(destDir, RELOCATED_DAEMON_HOST_EXE)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The relocated node.exe to fork the daemon from, or null to fall back to the
|
||||
* install-dir Electron host (ELECTRON_RUN_AS_NODE).
|
||||
*/
|
||||
export function getRelocatedDaemonHostExecPath(): string | null {
|
||||
return relocatedDaemonHostExecPath
|
||||
}
|
||||
@@ -6,7 +6,7 @@ it would scatter the "swap the running provider atomically" invariant across
|
||||
files with no cleaner ownership seam: restart, replaceDaemonProvider, and the
|
||||
module-level spawner/adapter singletons must stay co-located so a future
|
||||
change cannot leave them drifting out of sync. */
|
||||
import { join } from 'node:path'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { app } from 'electron'
|
||||
import { mkdirSync, existsSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { fork } from 'node:child_process'
|
||||
@@ -36,6 +36,11 @@ import {
|
||||
isDaemonStaleForCurrentBundle,
|
||||
killStaleDaemon
|
||||
} from './daemon-health'
|
||||
import {
|
||||
getRelocatedDaemonHostExecPath,
|
||||
installRelocatedDaemonHost
|
||||
} from './daemon-host-relocation'
|
||||
import { startSpan } from '../observability/tracer'
|
||||
import { DegradedDaemonPtyProvider } from './degraded-daemon-pty-provider'
|
||||
import {
|
||||
getLocalPtyProvider,
|
||||
@@ -153,6 +158,16 @@ function createPreservedDaemonHandle(
|
||||
protocolVersion = PROTOCOL_VERSION,
|
||||
mode?: 'degraded-new-pty-fallback'
|
||||
): DaemonProcessHandle {
|
||||
// Why: the trace file is the only artifact available in field
|
||||
// investigations of update-survival incidents; adopt-vs-fresh-fork is the
|
||||
// first question every one of them asks.
|
||||
const adoptSpan = startSpan('daemon.adopt', {
|
||||
attributes: {
|
||||
'daemon.protocol_version': protocolVersion,
|
||||
...(mode ? { 'daemon.mode': mode } : {})
|
||||
}
|
||||
})
|
||||
adoptSpan.end()
|
||||
const handle: DaemonProcessHandle = {
|
||||
shutdown: async () => {
|
||||
await cleanupDaemonForProtocol(runtimeDir, protocolVersion)
|
||||
@@ -260,6 +275,32 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
|
||||
await killStaleDaemon(runtimeDir, socketPath, tokenPath)
|
||||
|
||||
const userDataPath = app.getPath('userData')
|
||||
// Why: staged here instead of app startup so the ~70MB node.exe copy stays
|
||||
// off the first-paint path, and is skipped entirely on launches that adopt
|
||||
// a live daemon (no fork). Latched — repeat calls are free.
|
||||
installRelocatedDaemonHost()
|
||||
// Why: on Windows a relocated node.exe in userData hosts the daemon so its
|
||||
// process image escapes the install-dir the NSIS updater force-closes; when
|
||||
// absent (dev, non-win32, pre-relocation builds) fall back to the install-dir
|
||||
// Electron host run as plain Node via ELECTRON_RUN_AS_NODE.
|
||||
const relocatedHostExecPath = getRelocatedDaemonHostExecPath()
|
||||
// Why --require and not an import inside daemon-entry: Electron's Node
|
||||
// defaults windowsHide=true but standalone node.exe does not, and rollup's
|
||||
// CJS chunking hoists chunk requires above inlined code — an in-graph
|
||||
// import runs AFTER sibling modules capture promisify(execFile), so the
|
||||
// shim silently no-ops (shipped broken in rc.6). Preloading guarantees it
|
||||
// runs before the daemon's module graph loads.
|
||||
const consoleShimPath = join(dirname(entryPath), 'windows-hidden-console-children.js')
|
||||
// Why: without this span a silent fallback to the install-dir host (back
|
||||
// inside the updater kill zone) is indistinguishable from the fixed path
|
||||
// in field trace files.
|
||||
const forkSpan = startSpan('daemon.fork', {
|
||||
attributes: {
|
||||
'daemon.host': relocatedHostExecPath ? 'relocated-node' : 'install-dir-electron',
|
||||
...(relocatedHostExecPath ? { 'daemon.host_exec_path': relocatedHostExecPath } : {}),
|
||||
'app.version': app.getVersion()
|
||||
}
|
||||
})
|
||||
const child = fork(entryPath, ['--socket', socketPath, '--token', tokenPath], {
|
||||
// Why: detached daemons can outlive dev worktrees. Starting from
|
||||
// userData keeps process.cwd() valid after a repo/worktree is deleted.
|
||||
@@ -269,13 +310,20 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
|
||||
// open, which would prevent Electron from exiting cleanly.
|
||||
detached: true,
|
||||
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
|
||||
// Why: ELECTRON_RUN_AS_NODE makes the forked process run as a plain
|
||||
// Node.js process instead of an Electron renderer/main process. Without
|
||||
// it, Electron's GPU/display initialization can interfere with native
|
||||
// module operations like node-pty's posix_spawn of the spawn-helper.
|
||||
// Why: a standalone node.exe is already plain Node; reset execArgv so the
|
||||
// Electron main process's node flags don't leak into it, then preload
|
||||
// the windowsHide shim ahead of the daemon's module graph.
|
||||
...(relocatedHostExecPath
|
||||
? { execPath: relocatedHostExecPath, execArgv: ['--require', consoleShimPath] }
|
||||
: {}),
|
||||
env: {
|
||||
...process.env,
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
// Why: ELECTRON_RUN_AS_NODE makes the forked Electron binary run as a
|
||||
// plain Node.js process instead of an Electron main process. Without it,
|
||||
// Electron's GPU/display initialization can interfere with native module
|
||||
// operations like node-pty's posix_spawn of the spawn-helper. A relocated
|
||||
// node.exe is already plain Node, so the var is omitted there.
|
||||
...(relocatedHostExecPath ? {} : { ELECTRON_RUN_AS_NODE: '1' }),
|
||||
// Why: the detached daemon is plain Node and cannot call Electron's
|
||||
// app.getPath(), but shell-ready rcfiles must live outside swept tmp.
|
||||
ORCA_USER_DATA_PATH: userDataPath
|
||||
@@ -307,6 +355,7 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
|
||||
// Already dead
|
||||
}
|
||||
}
|
||||
forkSpan.fail(error)
|
||||
reject(error)
|
||||
}
|
||||
function onReadyMessage(msg: unknown): void {
|
||||
@@ -318,6 +367,10 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
|
||||
// Why: the daemon process is detached after readiness; leaving
|
||||
// startup listeners attached retains this launch promise closure.
|
||||
cleanupStartupListeners()
|
||||
if (child.pid !== undefined) {
|
||||
forkSpan.setAttribute('daemon.pid', child.pid)
|
||||
}
|
||||
forkSpan.end()
|
||||
if (child.pid) {
|
||||
// Why: JSON pid file carries pid + process start time so later
|
||||
// killStaleDaemon() can verify the pid still belongs to the daemon
|
||||
|
||||
@@ -172,6 +172,23 @@ export class HeadlessEmulator {
|
||||
return this.terminal.buffer.active.type === 'alternate'
|
||||
}
|
||||
|
||||
/** Why: PSReadLine's Ctrl+L repaint is only safe at an empty prompt — with
|
||||
* pending input it re-renders at a cached buffer row that ConPTY's fixed
|
||||
* viewport doesn't track, painting the input well below the prompt. The
|
||||
* cursor line counts as an empty prompt when everything before the cursor
|
||||
* ends with a single '>' and nothing follows it ('>>' is PowerShell's
|
||||
* continuation prompt, i.e. a multiline edit in flight). */
|
||||
isCursorOnEmptyPromptLine(): boolean {
|
||||
const buffer = this.terminal.buffer.active
|
||||
const line = buffer.getLine(buffer.baseY + buffer.cursorY)
|
||||
if (!line) {
|
||||
return false
|
||||
}
|
||||
const upToCursor = line.translateToString(true, 0, buffer.cursorX).trimEnd()
|
||||
const fullLine = line.translateToString(true).trimEnd()
|
||||
return fullLine === upToCursor && upToCursor.endsWith('>') && !upToCursor.endsWith('>>')
|
||||
}
|
||||
|
||||
getVisibleLines(): string[] {
|
||||
const buffer = this.terminal.buffer.active
|
||||
const lines: string[] = []
|
||||
|
||||
@@ -602,6 +602,7 @@ describe('createPtySubprocess', () => {
|
||||
sessionId: 'test',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: 'C:\\repo\\orca',
|
||||
command: 'codex'
|
||||
})
|
||||
|
||||
@@ -1113,6 +1114,97 @@ describe('createPtySubprocess', () => {
|
||||
expect(shellArg.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('allows an explicitly requested plain daemon shell at POSIX root', () => {
|
||||
const proc = mockPtyProcess()
|
||||
spawnMock.mockReturnValue(proc)
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' })
|
||||
|
||||
try {
|
||||
createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24, cwd: '/' })
|
||||
} finally {
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
}
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(Array),
|
||||
expect.objectContaining({ cwd: '/' })
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects daemon automatic agent startup without an explicit cwd', () => {
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' })
|
||||
spawnMock.mockClear()
|
||||
|
||||
try {
|
||||
expect(() =>
|
||||
createPtySubprocess({
|
||||
sessionId: 'test',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
command: 'codex'
|
||||
})
|
||||
).toThrow(/requires a non-root workspace/)
|
||||
} finally {
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
}
|
||||
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects daemon automatic agent startup at POSIX root', () => {
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' })
|
||||
spawnMock.mockClear()
|
||||
|
||||
try {
|
||||
expect(() =>
|
||||
createPtySubprocess({
|
||||
sessionId: 'test',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/',
|
||||
command: 'claude'
|
||||
})
|
||||
).toThrow(/requires a non-root workspace/)
|
||||
} finally {
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
}
|
||||
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a missing explicit POSIX cwd before node-pty spawn', () => {
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' })
|
||||
spawnMock.mockClear()
|
||||
|
||||
try {
|
||||
expect(() =>
|
||||
createPtySubprocess({
|
||||
sessionId: 'test',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/definitely-missing-orca-cwd'
|
||||
})
|
||||
).toThrow(/definitely-missing-orca-cwd/)
|
||||
} finally {
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
}
|
||||
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes custom env to spawned process', () => {
|
||||
const proc = mockPtyProcess()
|
||||
spawnMock.mockReturnValue(proc)
|
||||
@@ -1313,6 +1405,7 @@ describe('createPtySubprocess', () => {
|
||||
sessionId: 'test',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/repo',
|
||||
command: 'codex',
|
||||
env: { SHELL: '/bin/zsh' }
|
||||
})
|
||||
@@ -1339,6 +1432,7 @@ describe('createPtySubprocess', () => {
|
||||
sessionId: 'test',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/repo',
|
||||
command: "codex 'linked issue context'",
|
||||
startupCommandDelivery: 'shell-ready',
|
||||
env: { SHELL: '/bin/zsh' }
|
||||
@@ -1366,6 +1460,7 @@ describe('createPtySubprocess', () => {
|
||||
sessionId: 'test',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/repo',
|
||||
command: "codex --prefill 'linked issue context'",
|
||||
env: { SHELL: '/bin/zsh' }
|
||||
})
|
||||
@@ -1635,6 +1730,7 @@ describe('createPtySubprocess', () => {
|
||||
sessionId: 'test',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: 'C:\\repo\\orca',
|
||||
shellOverride: 'powershell.exe',
|
||||
command: "& 'codex' '--no-alt-screen'"
|
||||
})
|
||||
@@ -1664,6 +1760,7 @@ describe('createPtySubprocess', () => {
|
||||
sessionId: 'test',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: 'C:\\repo\\orca',
|
||||
shellOverride: 'cmd.exe',
|
||||
command: `codex ${'x'.repeat(7000)}`
|
||||
})
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
import { isShellProcess } from '../../shared/shell-process-detection'
|
||||
import { parsePtySessionId } from './pty-session-id'
|
||||
import { getAgentForegroundContextPaths } from '../providers/agent-foreground-context-paths'
|
||||
import { assertSafeAgentStartupCwd, resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd'
|
||||
|
||||
const PANE_IDENTITY_ENV_KEYS = [
|
||||
'ORCA_PANE_KEY',
|
||||
@@ -93,20 +94,7 @@ export type PtySubprocessOptions = {
|
||||
* Returns a stable default working directory for daemon-spawned PTYs.
|
||||
*/
|
||||
function getDefaultCwd(): string {
|
||||
if (process.platform !== 'win32') {
|
||||
return process.env.HOME || '/'
|
||||
}
|
||||
|
||||
// Why: HOMEPATH alone is drive-relative (`\\Users\\name`). Pair it with
|
||||
// HOMEDRIVE when USERPROFILE is unavailable so daemon-spawned Windows PTYs
|
||||
// still start in a valid absolute home directory.
|
||||
if (process.env.USERPROFILE) {
|
||||
return process.env.USERPROFILE
|
||||
}
|
||||
if (process.env.HOMEDRIVE && process.env.HOMEPATH) {
|
||||
return `${process.env.HOMEDRIVE}${process.env.HOMEPATH}`
|
||||
}
|
||||
return 'C:\\'
|
||||
return resolveSafePtyDefaultCwd()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -206,11 +194,13 @@ function isExistingDirectory(path: string | undefined): path is string {
|
||||
* Moves the daemon process to a stable cwd after its original cwd disappears.
|
||||
*/
|
||||
function repairDaemonCwd(): string | null {
|
||||
const candidates = [
|
||||
process.env.ORCA_USER_DATA_PATH,
|
||||
getDefaultCwd(),
|
||||
process.platform === 'win32' ? 'C:\\' : '/'
|
||||
]
|
||||
const candidates = [process.env.ORCA_USER_DATA_PATH]
|
||||
try {
|
||||
candidates.push(getDefaultCwd())
|
||||
} catch {
|
||||
// Keep daemon cwd repair best-effort even when no user terminal cwd is safe.
|
||||
}
|
||||
candidates.push(process.platform === 'win32' ? 'C:\\' : '/')
|
||||
for (const candidate of candidates) {
|
||||
if (isExistingDirectory(candidate)) {
|
||||
try {
|
||||
@@ -314,6 +304,16 @@ function preflightWindowsPtySpawnEnvironment(args: {
|
||||
validateWorkingDirectory(args.validationCwd)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates POSIX spawn cwd before node-pty can fail with an opaque ENOENT.
|
||||
*/
|
||||
function preflightPosixPtySpawnEnvironment(validationCwd: string): void {
|
||||
if (process.platform === 'win32') {
|
||||
return
|
||||
}
|
||||
validateWorkingDirectory(validationCwd)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps native PTY spawn failures with shell and cwd context.
|
||||
*/
|
||||
@@ -589,6 +589,9 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
|
||||
let windowsFallbackAttempts: WindowsShellSpawnAttempt[] = []
|
||||
const startupAgentRecognition = recognizeAgentProcessFromCommandLine(opts.command)
|
||||
const isCodexStartupCommand = startupAgentRecognition?.agent === 'codex'
|
||||
if (opts.command && startupAgentRecognition) {
|
||||
assertSafeAgentStartupCwd(opts.cwd, opts.command)
|
||||
}
|
||||
const requestedCwd = opts.cwd || getDefaultCwd()
|
||||
let spawnCwd = requestedCwd
|
||||
let validationCwd = spawnCwd
|
||||
@@ -772,6 +775,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
|
||||
// runs in a separate forked process with its own code path.
|
||||
ensureNodePtySpawnHelperExecutable()
|
||||
preflightUnixPtySpawnEnvironment()
|
||||
preflightPosixPtySpawnEnvironment(validationCwd)
|
||||
preflightWindowsPtySpawnEnvironment({
|
||||
validationCwd,
|
||||
cwdWasExplicit: opts.cwd !== undefined
|
||||
@@ -1048,6 +1052,16 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
|
||||
dead = true
|
||||
}
|
||||
},
|
||||
clear: () => {
|
||||
if (dead) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
proc.clear()
|
||||
} catch {
|
||||
// Best-effort: a clear on a just-exited PTY must not kill the handle.
|
||||
}
|
||||
},
|
||||
kill: () => {
|
||||
if (dead) {
|
||||
return
|
||||
|
||||
@@ -9,6 +9,7 @@ function createMockSubprocess() {
|
||||
let onData: ((data: string) => void) | null = null
|
||||
let onExit: ((code: number) => void) | null = null
|
||||
let killed = false
|
||||
let clearCalls = 0
|
||||
let pid = 12345
|
||||
|
||||
return {
|
||||
@@ -20,13 +21,20 @@ function createMockSubprocess() {
|
||||
get pid() {
|
||||
return pid
|
||||
},
|
||||
foregroundProcess: null as string | null,
|
||||
getForegroundProcess(): string | null {
|
||||
return null
|
||||
return this.foregroundProcess
|
||||
},
|
||||
write(data: string) {
|
||||
written.push(data)
|
||||
},
|
||||
resize(_cols: number, _rows: number) {},
|
||||
get clearCalls() {
|
||||
return clearCalls
|
||||
},
|
||||
clear() {
|
||||
clearCalls++
|
||||
},
|
||||
kill() {
|
||||
killed = true
|
||||
// Simulate async exit
|
||||
@@ -426,6 +434,98 @@ describe('Session', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearScrollback', () => {
|
||||
function withPlatform(platform: NodeJS.Platform, run: () => void): void {
|
||||
const original = process.platform
|
||||
Object.defineProperty(process, 'platform', { value: platform })
|
||||
try {
|
||||
run()
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { value: original })
|
||||
}
|
||||
}
|
||||
|
||||
it('resyncs the native PTY screen state alongside the emulator clear', () => {
|
||||
createSession()
|
||||
session.clearScrollback()
|
||||
// Why: without the subprocess clear, ConPTY keeps a stale cursor row and
|
||||
// the next prompt repaint lands below a blank gap on Windows.
|
||||
expect(subprocess.clearCalls).toBe(1)
|
||||
const take = session.takePendingOutput(false)
|
||||
expect(take?.records).toContainEqual({ kind: 'clear' })
|
||||
})
|
||||
|
||||
it('nudges a Windows PowerShell prompt to repaint with a form feed', async () => {
|
||||
createSession()
|
||||
subprocess.foregroundProcess = 'powershell.exe'
|
||||
subprocess.simulateData('PS C:\\Users\\me> ')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
withPlatform('win32', () => session.clearScrollback())
|
||||
// Why: the ConPTY clear cannot reach PSReadLine's cached cursor row;
|
||||
// Ctrl+L makes PSReadLine repaint the prompt at the true origin.
|
||||
expect(subprocess.written).toEqual(['\x0c'])
|
||||
})
|
||||
|
||||
it('does not send a form feed while input is pending at the prompt', async () => {
|
||||
createSession()
|
||||
subprocess.foregroundProcess = 'powershell.exe'
|
||||
subprocess.simulateData('PS C:\\Users\\me> fd')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
// Why: PSReadLine repaints pending input at a stale cached row that
|
||||
// ConPTY's fixed viewport doesn't track, so the nudge must be skipped.
|
||||
withPlatform('win32', () => session.clearScrollback())
|
||||
expect(subprocess.written).toEqual([])
|
||||
})
|
||||
|
||||
it('does not send or queue a form feed before shell-ready', async () => {
|
||||
createSession({ shellReadySupported: true })
|
||||
subprocess.foregroundProcess = 'powershell.exe'
|
||||
subprocess.simulateData('PS C:\\Users\\me> ')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
// Why: a queued form feed would flush after the startup command at an
|
||||
// arbitrary later moment, when the prompt gates no longer hold.
|
||||
withPlatform('win32', () => session.clearScrollback())
|
||||
expect(subprocess.written).toEqual([])
|
||||
subprocess.simulateData('\x1b]777;orca-shell-ready\x07\r\nPS C:\\Users\\me> ')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(subprocess.written).toEqual([])
|
||||
})
|
||||
|
||||
it('does not send a form feed at a PowerShell continuation prompt', async () => {
|
||||
createSession()
|
||||
subprocess.foregroundProcess = 'powershell.exe'
|
||||
subprocess.simulateData('PS C:\\Users\\me> {\r\n>> ')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
withPlatform('win32', () => session.clearScrollback())
|
||||
expect(subprocess.written).toEqual([])
|
||||
})
|
||||
|
||||
it('does not send a form feed while a command owns the foreground', async () => {
|
||||
createSession()
|
||||
subprocess.foregroundProcess = 'node'
|
||||
subprocess.simulateData('PS C:\\Users\\me> ')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
withPlatform('win32', () => session.clearScrollback())
|
||||
expect(subprocess.written).toEqual([])
|
||||
})
|
||||
|
||||
it('does not send a form feed on POSIX platforms', async () => {
|
||||
createSession()
|
||||
subprocess.foregroundProcess = 'pwsh'
|
||||
subprocess.simulateData('PS C:\\Users\\me> ')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
withPlatform('linux', () => session.clearScrollback())
|
||||
expect(subprocess.written).toEqual([])
|
||||
})
|
||||
|
||||
it('does not touch the subprocess after dispose', () => {
|
||||
createSession()
|
||||
session.dispose()
|
||||
session.clearScrollback()
|
||||
expect(subprocess.clearCalls).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('snapshot', () => {
|
||||
it('returns a terminal snapshot', async () => {
|
||||
createSession()
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
scanForShellReady,
|
||||
type ShellReadyScanState
|
||||
} from '../shell-ready-marker-scanner'
|
||||
import { isPowerShellProcess } from '../../shared/shell-process-detection'
|
||||
import type {
|
||||
PendingOutputRecord,
|
||||
SessionState,
|
||||
@@ -41,6 +42,10 @@ export type SubprocessHandle = {
|
||||
startupCommandDeliveredInShellArgs?: boolean
|
||||
write(data: string): void
|
||||
resize(cols: number, rows: number): void
|
||||
/** Resync the native PTY's own screen state after a frontend clear.
|
||||
* No-op except on Windows/ConPTY, where a stale ConPTY cursor row makes
|
||||
* the next prompt repaint land below a blank gap. */
|
||||
clear?(): void
|
||||
kill(): void
|
||||
forceKill(): void
|
||||
signal(sig: string): void
|
||||
@@ -285,6 +290,35 @@ export class Session {
|
||||
}
|
||||
this.emulator.clearScrollback()
|
||||
this.recordPendingOutput({ kind: 'clear' })
|
||||
this.subprocess.clear?.()
|
||||
this.#nudgePowerShellPromptRepaint()
|
||||
}
|
||||
|
||||
/** Why: ConPTY's buffer clear cannot reach PSReadLine's cached cursor row,
|
||||
* so PowerShell's first Enter after a clear would still repaint the prompt
|
||||
* at the stale row, leaving a blank gap. A form feed (Ctrl+L) makes
|
||||
* PSReadLine itself repaint at the true origin. Gated to a PowerShell
|
||||
* foreground so a running command or TUI never gets a stray 0x0C, and to
|
||||
* an empty prompt because PSReadLine repaints pending input at a stale
|
||||
* cached row that ConPTY's fixed viewport doesn't track. */
|
||||
#nudgePowerShellPromptRepaint(): void {
|
||||
if (process.platform !== 'win32') {
|
||||
return
|
||||
}
|
||||
// Why: before shell-ready, write() would queue the form feed behind the
|
||||
// buffered startup command and deliver it at an arbitrary later moment,
|
||||
// when the foreground/prompt gates below no longer hold. The nudge is
|
||||
// cosmetic — skip it rather than defer it.
|
||||
if (this._shellState === 'pending' || this.postReadyFlushGate.isPending) {
|
||||
return
|
||||
}
|
||||
if (!isPowerShellProcess(this.subprocess.getForegroundProcess())) {
|
||||
return
|
||||
}
|
||||
if (!this.emulator.isCursorOnEmptyPromptLine()) {
|
||||
return
|
||||
}
|
||||
this.subprocess.write('\x0c')
|
||||
}
|
||||
|
||||
prepareForFinalSnapshot(): string {
|
||||
|
||||
@@ -95,6 +95,11 @@ elif [[ -f "$HOME/.bash_login" ]]; then
|
||||
elif [[ -f "$HOME/.profile" ]]; then
|
||||
source "$HOME/.profile"
|
||||
fi
|
||||
# Why: enable bracketed paste so Orca can deliver a multiline startup prompt as
|
||||
# a single literal paste (ESC[200~…ESC[201~); without it, older readline builds
|
||||
# treat each embedded newline as Enter and mangle the prompt into PS2
|
||||
# continuation. Modern readline defaults this on; force it for the rest.
|
||||
[[ $- == *i* ]] && bind 'set enable-bracketed-paste on' 2>/dev/null
|
||||
__orca_restore_attribution_path() {
|
||||
[[ -n "\${ORCA_ATTRIBUTION_SHIM_DIR:-}" ]] || return 0
|
||||
case "$PATH" in
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Session, type SubprocessHandle } from './session'
|
||||
import { normalizePtySize } from './daemon-pty-size'
|
||||
import { resolveProcessCwd } from '../providers/process-cwd'
|
||||
import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery'
|
||||
import { buildStartupCommandSubmission } from '../../shared/startup-command-submission'
|
||||
import type {
|
||||
SessionInfo,
|
||||
TakePendingOutputResult,
|
||||
@@ -163,8 +164,15 @@ export class TerminalHost {
|
||||
// the user would need to press Enter after Orca launches the agent or
|
||||
// setup script. POSIX shells accept CR as Enter under ICRNL.
|
||||
const submit = process.platform === 'win32' ? '\r' : '\n'
|
||||
const endsWithSubmit = opts.command.endsWith('\r') || opts.command.endsWith('\n')
|
||||
session.write(endsWithSubmit ? opts.command : `${opts.command}${submit}`)
|
||||
// Why: multiline startup prompts are pasted literally via bracketed paste
|
||||
// only for Orca-wrapped bash/zsh, which is exactly when the shell-ready
|
||||
// barrier is supported; other shells keep the raw submit path.
|
||||
session.write(
|
||||
buildStartupCommandSubmission(opts.command, {
|
||||
submit,
|
||||
bracketedPasteSafe: opts.shellReadySupported ?? false
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { promisify } from 'node:util'
|
||||
import { wrapChildProcessApi, withHiddenConsoleDefault } from './windows-hidden-console-children'
|
||||
|
||||
describe('withHiddenConsoleDefault', () => {
|
||||
it('injects windowsHide into an existing options object', () => {
|
||||
const args = withHiddenConsoleDefault(['powershell.exe', ['-NoProfile'], { cwd: 'C:\\x' }])
|
||||
expect(args[2]).toEqual({ windowsHide: true, cwd: 'C:\\x' })
|
||||
})
|
||||
|
||||
it('never overrides an explicit windowsHide: false', () => {
|
||||
const args = withHiddenConsoleDefault(['cmd.exe', { windowsHide: false }])
|
||||
expect(args[1]).toEqual({ windowsHide: false })
|
||||
})
|
||||
|
||||
it('appends options when the call has none (spawn style)', () => {
|
||||
const args = withHiddenConsoleDefault(['cmd.exe', ['/c', 'echo hi']])
|
||||
expect(args).toEqual(['cmd.exe', ['/c', 'echo hi'], { windowsHide: true }])
|
||||
})
|
||||
|
||||
it('appends options for a bare command', () => {
|
||||
expect(withHiddenConsoleDefault(['cmd.exe'])).toEqual(['cmd.exe', { windowsHide: true }])
|
||||
})
|
||||
|
||||
it('inserts options before a trailing callback (exec style)', () => {
|
||||
const cb = (): void => {}
|
||||
const args = withHiddenConsoleDefault(['echo hi', cb])
|
||||
expect(args).toEqual(['echo hi', { windowsHide: true }, cb])
|
||||
})
|
||||
|
||||
it('merges into options that sit between args array and callback (execFile style)', () => {
|
||||
const cb = (): void => {}
|
||||
const args = withHiddenConsoleDefault(['node', ['-v'], { timeout: 5 }, cb])
|
||||
expect(args).toEqual(['node', ['-v'], { windowsHide: true, timeout: 5 }, cb])
|
||||
})
|
||||
|
||||
it('does not mistake the command or args array for options', () => {
|
||||
const args = withHiddenConsoleDefault(['node', ['-v']])
|
||||
expect(args[0]).toBe('node')
|
||||
expect(args[1]).toEqual(['-v'])
|
||||
expect(args[2]).toEqual({ windowsHide: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('wrapChildProcessApi', () => {
|
||||
it('injects windowsHide through the direct call', () => {
|
||||
let seen: unknown[] = []
|
||||
const original = (...args: unknown[]): void => {
|
||||
seen = args
|
||||
}
|
||||
wrapChildProcessApi(original)('cmd.exe', ['/c'])
|
||||
expect(seen[2]).toEqual({ windowsHide: true })
|
||||
})
|
||||
|
||||
it('injects windowsHide through util.promisify (the rc.6 bypass)', async () => {
|
||||
let seen: unknown[] = []
|
||||
const original = (..._args: unknown[]): void => {}
|
||||
Object.defineProperty(original, promisify.custom, {
|
||||
value: (...args: unknown[]) => {
|
||||
seen = args
|
||||
return Promise.resolve('done')
|
||||
}
|
||||
})
|
||||
const wrapped = wrapChildProcessApi(original)
|
||||
await expect(promisify(wrapped)('powershell.exe', ['-NoProfile'])).resolves.toBe('done')
|
||||
expect(seen[2]).toEqual({ windowsHide: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import childProcess from 'node:child_process'
|
||||
|
||||
/**
|
||||
* Defaults `windowsHide: true` for every child_process API in this process.
|
||||
*
|
||||
* Why: Electron's bundled Node patches the `windowsHide` default to true, and
|
||||
* the daemon historically ran under Electron-as-Node, so none of its spawn
|
||||
* call sites (PowerShell CIM probes, node-pty's console-list helper, agent
|
||||
* detection) pass the flag. Hosted by a standalone node.exe the default is
|
||||
* false — with a console-subsystem host every such child allocates a console,
|
||||
* which flashes a visible window (Windows Terminal when it is the default
|
||||
* host) on each periodic probe. Restore the Electron default process-wide.
|
||||
*/
|
||||
|
||||
const PATCHED_APIS = [
|
||||
'spawn',
|
||||
'spawnSync',
|
||||
'exec',
|
||||
'execSync',
|
||||
'execFile',
|
||||
'execFileSync',
|
||||
'fork'
|
||||
] as const
|
||||
|
||||
let installed = false
|
||||
|
||||
function isPlainOptionsObject(value: unknown): value is Record<string, unknown> {
|
||||
return (
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value) && !Buffer.isBuffer(value)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the argument list with `windowsHide: true` merged into its options
|
||||
* object. Handles every child_process signature: options may sit at any
|
||||
* position after the command, may be followed by a callback (exec/execFile),
|
||||
* or may be absent entirely. An explicit caller-provided windowsHide wins.
|
||||
*/
|
||||
export function withHiddenConsoleDefault(args: unknown[]): unknown[] {
|
||||
for (let i = 1; i < args.length; i++) {
|
||||
if (isPlainOptionsObject(args[i])) {
|
||||
const next = [...args]
|
||||
next[i] = { windowsHide: true, ...(args[i] as Record<string, unknown>) }
|
||||
return next
|
||||
}
|
||||
}
|
||||
// No options object: insert one before a trailing callback, else append.
|
||||
const next = [...args]
|
||||
const insertAt = typeof next.at(-1) === 'function' ? next.length - 1 : next.length
|
||||
next.splice(insertAt, 0, { windowsHide: true })
|
||||
return next
|
||||
}
|
||||
|
||||
export function installHiddenConsoleChildDefaults(): void {
|
||||
if (installed || process.platform !== 'win32') {
|
||||
return
|
||||
}
|
||||
installed = true
|
||||
for (const name of PATCHED_APIS) {
|
||||
;(childProcess as Record<string, unknown>)[name] = wrapChildProcessApi(
|
||||
childProcess[name] as (...args: unknown[]) => unknown
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function wrapChildProcessApi(
|
||||
original: (...args: unknown[]) => unknown
|
||||
): (...args: unknown[]) => unknown {
|
||||
const wrapped = (...args: unknown[]): unknown => original(...withHiddenConsoleDefault(args))
|
||||
Object.setPrototypeOf(wrapped, original)
|
||||
// Why: exec/execFile carry a util.promisify.custom implementation that
|
||||
// internally calls the ORIGINAL function — copying the symbol verbatim lets
|
||||
// every `promisify(execFile)` call site bypass the wrapper (rc.6 shipped
|
||||
// this bug: the daemon's CIM probes are promisified and kept flashing).
|
||||
// Wrap each symbol-attached function so the injection applies there too.
|
||||
for (const sym of Object.getOwnPropertySymbols(original)) {
|
||||
const desc = Object.getOwnPropertyDescriptor(original, sym)!
|
||||
if (typeof desc.value === 'function') {
|
||||
const originalCustom = desc.value as (...args: unknown[]) => unknown
|
||||
desc.value = (...args: unknown[]): unknown =>
|
||||
originalCustom(...withHiddenConsoleDefault(args))
|
||||
}
|
||||
Object.defineProperty(wrapped, sym, desc)
|
||||
}
|
||||
return wrapped
|
||||
}
|
||||
|
||||
// Why install on load: this file is built as its own self-contained bundle
|
||||
// entry (electron.vite.config.ts) and preloaded into the daemon via
|
||||
// `node --require` (daemon-init.ts). An in-graph import cannot work: rollup's
|
||||
// CJS output hoists chunk requires above inlined module code, so sibling
|
||||
// chunks capture `promisify(execFile)` before any "first import" executes.
|
||||
// --require runs before the whole graph loads, immune to bundler ordering.
|
||||
// It must therefore never import anything that could be split into a chunk.
|
||||
installHiddenConsoleChildDefaults()
|
||||
@@ -1,79 +0,0 @@
|
||||
import { glabExecFileAsync } from '../git/runner'
|
||||
import { DEFAULT_GITLAB_HOSTS, normalizeGitLabHost, type ProjectRef } from './project-ref-parser'
|
||||
|
||||
let knownHostsCache: readonly string[] | null = null
|
||||
|
||||
export function rememberGlabKnownHost(host: string): void {
|
||||
const normalizedHost = normalizeGitLabHost(host)
|
||||
if (!knownHostsCache || knownHostsCache.map(normalizeGitLabHost).includes(normalizedHost)) {
|
||||
return
|
||||
}
|
||||
knownHostsCache = [...knownHostsCache, normalizedHost]
|
||||
}
|
||||
|
||||
export async function isGlabConfiguredForRemoteHost(
|
||||
repoPath: string,
|
||||
projectRef: Pick<ProjectRef, 'host'>,
|
||||
connectionId?: string | null
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const result = await glabExecFileAsync(
|
||||
['auth', 'status', '--hostname', projectRef.host],
|
||||
connectionId ? {} : { cwd: repoPath }
|
||||
)
|
||||
return result !== undefined
|
||||
} catch (error) {
|
||||
const execLike = error as { stdout?: unknown; stderr?: unknown; message?: unknown }
|
||||
const output =
|
||||
[execLike.stdout, execLike.stderr, execLike.message]
|
||||
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
||||
.join('\n') || String(error)
|
||||
const hosts = parseGlabAuthStatusHosts(output).map(normalizeGitLabHost)
|
||||
return hosts.includes(normalizeGitLabHost(projectRef.host))
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal - exposed for tests only */
|
||||
export function _resetKnownHostsCache(): void {
|
||||
knownHostsCache = null
|
||||
}
|
||||
|
||||
export async function getGlabKnownHosts(): Promise<readonly string[]> {
|
||||
if (knownHostsCache) {
|
||||
return knownHostsCache
|
||||
}
|
||||
try {
|
||||
const { stdout, stderr } = await glabExecFileAsync(['auth', 'status'])
|
||||
// Why: glab writes auth status to stderr in some versions, stdout in
|
||||
// others. Concatenate so the parser sees both.
|
||||
const hosts = parseGlabAuthStatusHosts(`${stdout}\n${stderr}`)
|
||||
// Always include gitlab.com so a fresh-install user with no auth
|
||||
// still recognizes the canonical host.
|
||||
const merged = Array.from(new Set([...DEFAULT_GITLAB_HOSTS, ...hosts]))
|
||||
knownHostsCache = merged
|
||||
return merged
|
||||
} catch {
|
||||
// Auth check failed (glab not installed, no auth, etc.) - fall back
|
||||
// to the canonical default. The caller will hit the auth error on
|
||||
// the first real request anyway.
|
||||
knownHostsCache = [...DEFAULT_GITLAB_HOSTS]
|
||||
return knownHostsCache
|
||||
}
|
||||
}
|
||||
|
||||
// Why: glab auth status output is human-formatted and varies across versions.
|
||||
// Match observed logged-in lines and host headers, dedupe, lowercase.
|
||||
export function parseGlabAuthStatusHosts(output: string): string[] {
|
||||
const hosts = new Set<string>()
|
||||
for (const m of output.matchAll(/logged in to ([a-zA-Z0-9.-]+)/gi)) {
|
||||
hosts.add(m[1].toLowerCase())
|
||||
}
|
||||
for (const line of output.split('\n')) {
|
||||
const bareLine = line.trim()
|
||||
const hostLine = bareLine.endsWith(':') ? bareLine.slice(0, -1) : bareLine
|
||||
if (line === bareLine && /^[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?$/.test(hostLine)) {
|
||||
hosts.add(hostLine.toLowerCase())
|
||||
}
|
||||
}
|
||||
return Array.from(hosts)
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { ClaudeUsageStore, initClaudeUsagePath } from './claude-usage/store'
|
||||
import { CodexUsageStore, initCodexUsagePath } from './codex-usage/store'
|
||||
import { OpenCodeUsageStore, initOpenCodeUsagePath } from './opencode-usage/store'
|
||||
import { killAllPty } from './ipc/pty'
|
||||
import { installRelocatedNodePtyNativeRuntime } from './pty/node-pty-runtime-relocation'
|
||||
import { initDaemonPtyProvider, disconnectDaemon, shutdownDaemon } from './daemon/daemon-init'
|
||||
import { closeAllWatchers } from './ipc/filesystem-watcher'
|
||||
import { disposeWorktreeBaseDirectoryWatchers } from './ipc/worktree-base-directory-watcher'
|
||||
@@ -99,6 +100,7 @@ import {
|
||||
import { ensureWindowsUserDataAclGrant } from './startup/windows-user-data-acl'
|
||||
import { shouldQuitWhenAllWindowsClosed } from './startup/window-all-closed-quit-policy'
|
||||
import { RateLimitService } from './rate-limits/service'
|
||||
import { readMiniMaxSessionCookie } from './minimax/minimax-cookie-store'
|
||||
import { getInitialClaudeRateLimitTarget } from './rate-limits/claude-rate-limit-target'
|
||||
import { getInitialCodexRateLimitTarget } from './rate-limits/codex-rate-limit-target'
|
||||
import {
|
||||
@@ -574,6 +576,12 @@ if (hasSingleInstanceLock) {
|
||||
initClaudeUsagePath()
|
||||
initCodexUsagePath()
|
||||
initOpenCodeUsagePath()
|
||||
// Why: must run before anything loads node-pty (first PTY spawn) so main
|
||||
// and the daemon it forks both pick up ORCA_NODE_PTY_NATIVE_DIR.
|
||||
// (The daemon-host node.exe relocation deliberately does NOT run here: its
|
||||
// ~70MB copy would sit on the first-paint path. daemon-init stages it at
|
||||
// fork time instead.)
|
||||
installRelocatedNodePtyNativeRuntime()
|
||||
crashReports = CrashReportStore.fromUserData()
|
||||
recordCrashBreadcrumb('app_started', {
|
||||
packaged: app.isPackaged,
|
||||
@@ -1685,6 +1693,14 @@ app.whenReady().then(async () => {
|
||||
workspaceIdOverride: settings.opencodeWorkspaceId
|
||||
}
|
||||
})
|
||||
rateLimits.setMiniMaxConfigResolver(() => {
|
||||
const settings = store!.getSettings()
|
||||
return {
|
||||
sessionCookie: readMiniMaxSessionCookie() ?? '',
|
||||
groupId: settings.minimaxGroupId,
|
||||
models: settings.minimaxUsageModels
|
||||
}
|
||||
})
|
||||
rateLimits.setGeminiCliOAuthEnabledResolver(() => store!.getSettings().geminiCliOAuthEnabled)
|
||||
rateLimits.setNetworkProxySettingsResolver(() => store!.getSettings())
|
||||
keybindings = new KeybindingService({
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { IFilesystemProvider } from '../providers/types'
|
||||
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
scanAiVaultSessions: vi.fn(),
|
||||
scanRemoteAiVaultSessions: vi.fn(),
|
||||
getAiVaultWslHomeDirs: vi.fn(),
|
||||
getSshFilesystemProvider: vi.fn(),
|
||||
getActiveSshAiVaultHostInfo: vi.fn(),
|
||||
getActiveSshAiVaultHostInfos: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { on: vi.fn() },
|
||||
ipcMain: { handle: vi.fn() }
|
||||
}))
|
||||
|
||||
vi.mock('../ai-vault/session-scanner', () => ({
|
||||
scanAiVaultSessions: mocks.scanAiVaultSessions
|
||||
}))
|
||||
|
||||
vi.mock('../ai-vault/remote-session-scanner', () => ({
|
||||
scanRemoteAiVaultSessions: mocks.scanRemoteAiVaultSessions
|
||||
}))
|
||||
|
||||
vi.mock('../wsl', () => ({
|
||||
getWslHomeAsync: mocks.getAiVaultWslHomeDirs,
|
||||
listWslDistrosAsync: vi.fn().mockResolvedValue([])
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
|
||||
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE:
|
||||
'Remote connection dropped. Click Reconnect on the SSH target before retrying.',
|
||||
getSshFilesystemProvider: mocks.getSshFilesystemProvider
|
||||
}))
|
||||
|
||||
vi.mock('./ssh', () => ({
|
||||
getActiveSshAiVaultHostInfo: mocks.getActiveSshAiVaultHostInfo,
|
||||
getActiveSshAiVaultHostInfos: mocks.getActiveSshAiVaultHostInfos
|
||||
}))
|
||||
|
||||
const { _internals } = await import('./ai-vault')
|
||||
|
||||
const provider = {} as IFilesystemProvider
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
_internals.resetAiVaultCacheForTests()
|
||||
mocks.scanAiVaultSessions.mockResolvedValue(result([session('local', 'local-session')]))
|
||||
mocks.scanRemoteAiVaultSessions.mockResolvedValue(
|
||||
result([session('ssh:dev-box', 'remote-session')])
|
||||
)
|
||||
mocks.getSshFilesystemProvider.mockReturnValue(provider)
|
||||
mocks.getActiveSshAiVaultHostInfo.mockReturnValue(hostInfo('dev-box'))
|
||||
mocks.getActiveSshAiVaultHostInfos.mockReturnValue([hostInfo('dev-box')])
|
||||
})
|
||||
|
||||
describe('listAiVaultSessions host routing', () => {
|
||||
it('routes local scope to the local scanner', async () => {
|
||||
await _internals.listAiVaultSessions({ executionHostScope: 'local', scopePaths: ['/repo'] })
|
||||
|
||||
expect(mocks.scanAiVaultSessions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
scopePaths: ['/repo'],
|
||||
executionHostId: 'local'
|
||||
})
|
||||
)
|
||||
expect(mocks.scanRemoteAiVaultSessions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes SSH scope to only that SSH target', async () => {
|
||||
await _internals.listAiVaultSessions({
|
||||
executionHostScope: 'ssh:dev-box',
|
||||
scopePaths: ['/home/ada/repo']
|
||||
})
|
||||
|
||||
expect(mocks.scanAiVaultSessions).not.toHaveBeenCalled()
|
||||
expect(mocks.getActiveSshAiVaultHostInfo).toHaveBeenCalledWith('dev-box')
|
||||
expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
scopePaths: ['/home/ada/repo']
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('merges local plus connected SSH targets for all hosts', async () => {
|
||||
const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' })
|
||||
|
||||
expect(mocks.scanAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['ssh:dev-box', 'local'])
|
||||
})
|
||||
|
||||
it('returns a scan issue for a disconnected SSH target', async () => {
|
||||
mocks.getActiveSshAiVaultHostInfo.mockReturnValue(null)
|
||||
mocks.getSshFilesystemProvider.mockReturnValue(undefined)
|
||||
|
||||
const result = await _internals.listAiVaultSessions({
|
||||
executionHostScope: 'ssh:disconnected'
|
||||
})
|
||||
|
||||
expect(result.sessions).toEqual([])
|
||||
expect(result.issues).toMatchObject([
|
||||
{
|
||||
executionHostId: 'ssh:disconnected',
|
||||
agent: 'codex',
|
||||
path: 'disconnected'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps host scope in the cache key', async () => {
|
||||
await _internals.listAiVaultSessions({ executionHostScope: 'local' })
|
||||
await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' })
|
||||
|
||||
expect(mocks.scanAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
function hostInfo(targetId: string) {
|
||||
return {
|
||||
targetId,
|
||||
executionHostId: `ssh:${targetId}` as const,
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
}
|
||||
}
|
||||
|
||||
function result(sessions: AiVaultSession[]): AiVaultListResult {
|
||||
return { sessions, issues: [], scannedAt: new Date().toISOString() }
|
||||
}
|
||||
|
||||
function session(
|
||||
executionHostId: AiVaultSession['executionHostId'],
|
||||
sessionId: string
|
||||
): AiVaultSession {
|
||||
return {
|
||||
id: `${executionHostId}:codex:${sessionId}:/tmp/${sessionId}.jsonl`,
|
||||
executionHostId,
|
||||
agent: 'codex',
|
||||
sessionId,
|
||||
title: sessionId,
|
||||
cwd: '/repo',
|
||||
branch: null,
|
||||
model: null,
|
||||
filePath: `/tmp/${sessionId}.jsonl`,
|
||||
codexHome: null,
|
||||
createdAt: null,
|
||||
updatedAt:
|
||||
sessionId === 'remote-session' ? '2026-07-04T02:00:00.000Z' : '2026-07-04T01:00:00.000Z',
|
||||
modifiedAt: '2026-07-04T00:00:00.000Z',
|
||||
messageCount: 1,
|
||||
totalTokens: 0,
|
||||
previewMessages: [],
|
||||
resumeCommand: `codex resume ${sessionId}`
|
||||
}
|
||||
}
|
||||
+160
-14
@@ -1,8 +1,26 @@
|
||||
import { app, ipcMain } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
import { scanRemoteAiVaultSessions } from '../ai-vault/remote-session-scanner'
|
||||
import { scanAiVaultSessions } from '../ai-vault/session-scanner'
|
||||
import { sessionSortTime } from '../ai-vault/session-scanner-accumulator'
|
||||
import { getWslHomeAsync, listWslDistrosAsync } from '../wsl'
|
||||
import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types'
|
||||
import type {
|
||||
AiVaultListArgs,
|
||||
AiVaultListResult,
|
||||
AiVaultScanIssue
|
||||
} from '../../shared/ai-vault-types'
|
||||
import {
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
normalizeExecutionHostScope,
|
||||
parseExecutionHostId,
|
||||
toSshExecutionHostId,
|
||||
type ExecutionHostScope
|
||||
} from '../../shared/execution-host'
|
||||
import {
|
||||
getSshFilesystemProvider,
|
||||
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE
|
||||
} from '../providers/ssh-filesystem-dispatch'
|
||||
import { getActiveSshAiVaultHostInfo, getActiveSshAiVaultHostInfos } from './ssh'
|
||||
|
||||
const AI_VAULT_CACHE_TTL_MS = 15_000
|
||||
|
||||
@@ -22,10 +40,14 @@ let inflightKey: string | null = null
|
||||
let handlerOptions: AiVaultHandlerOptions = {}
|
||||
|
||||
async function listAiVaultSessions(args?: AiVaultListArgs): Promise<AiVaultListResult> {
|
||||
const executionHostScope = normalizeExecutionHostScope(
|
||||
args?.executionHostScope ?? LOCAL_EXECUTION_HOST_ID
|
||||
)
|
||||
// Scope paths change the result set, so they must be part of the cache key.
|
||||
const key = JSON.stringify({
|
||||
limit: args?.limit ?? 'default',
|
||||
scopePaths: args?.scopePaths ?? []
|
||||
scopePaths: args?.scopePaths ?? [],
|
||||
executionHostScope
|
||||
})
|
||||
const now = Date.now()
|
||||
// Why: opening this panel repeatedly should not re-parse hundreds of JSONL
|
||||
@@ -38,16 +60,7 @@ async function listAiVaultSessions(args?: AiVaultListArgs): Promise<AiVaultListR
|
||||
}
|
||||
|
||||
inflightKey = key
|
||||
const additionalCodexSessionsDirs =
|
||||
handlerOptions.getAdditionalCodexHomePaths?.().map((homePath) => join(homePath, 'sessions')) ??
|
||||
[]
|
||||
inflightList = (async () =>
|
||||
scanAiVaultSessions({
|
||||
limit: args?.limit,
|
||||
scopePaths: args?.scopePaths,
|
||||
additionalCodexSessionsDirs,
|
||||
wslHomeDirs: await getAiVaultWslHomeDirs()
|
||||
}))()
|
||||
inflightList = scanAiVaultSessionsByHostScope(args, executionHostScope)
|
||||
.then((result) => {
|
||||
cachedList = {
|
||||
key,
|
||||
@@ -57,12 +70,133 @@ async function listAiVaultSessions(args?: AiVaultListArgs): Promise<AiVaultListR
|
||||
return result
|
||||
})
|
||||
.finally(() => {
|
||||
inflightKey = null
|
||||
inflightList = null
|
||||
// Only clear tracking if it still refers to this request: a concurrent
|
||||
// different-scope scan may have replaced it and must stay dedupable.
|
||||
if (inflightKey === key) {
|
||||
inflightKey = null
|
||||
inflightList = null
|
||||
}
|
||||
})
|
||||
return inflightList
|
||||
}
|
||||
|
||||
async function scanAiVaultSessionsByHostScope(
|
||||
args: AiVaultListArgs | undefined,
|
||||
executionHostScope: ExecutionHostScope
|
||||
): Promise<AiVaultListResult> {
|
||||
if (executionHostScope === LOCAL_EXECUTION_HOST_ID) {
|
||||
return scanLocalAiVaultSessions(args)
|
||||
}
|
||||
|
||||
if (executionHostScope === 'all') {
|
||||
return mergeAiVaultListResults(
|
||||
await Promise.all([
|
||||
scanLocalAiVaultSessions(args),
|
||||
...getActiveSshAiVaultHostInfos().map((hostInfo) =>
|
||||
scanSshAiVaultSessions(hostInfo.targetId, args)
|
||||
)
|
||||
]),
|
||||
args?.limit
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = parseExecutionHostId(executionHostScope)
|
||||
if (parsed?.kind === 'ssh') {
|
||||
return scanSshAiVaultSessions(parsed.targetId, args)
|
||||
}
|
||||
|
||||
return {
|
||||
sessions: [],
|
||||
issues: [
|
||||
{
|
||||
executionHostId: executionHostScope,
|
||||
agent: 'codex',
|
||||
path: executionHostScope,
|
||||
message: 'Agent Session History is not available for this execution host.'
|
||||
}
|
||||
],
|
||||
scannedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
async function scanLocalAiVaultSessions(args?: AiVaultListArgs): Promise<AiVaultListResult> {
|
||||
const additionalCodexSessionsDirs =
|
||||
handlerOptions.getAdditionalCodexHomePaths?.().map((homePath) => join(homePath, 'sessions')) ??
|
||||
[]
|
||||
return scanAiVaultSessions({
|
||||
limit: args?.limit,
|
||||
scopePaths: args?.scopePaths,
|
||||
additionalCodexSessionsDirs,
|
||||
wslHomeDirs: await getAiVaultWslHomeDirs(),
|
||||
executionHostId: LOCAL_EXECUTION_HOST_ID
|
||||
})
|
||||
}
|
||||
|
||||
async function scanSshAiVaultSessions(
|
||||
targetId: string,
|
||||
args?: AiVaultListArgs
|
||||
): Promise<AiVaultListResult> {
|
||||
const executionHostId = toSshExecutionHostId(targetId)
|
||||
const hostInfo = getActiveSshAiVaultHostInfo(targetId)
|
||||
const provider = getSshFilesystemProvider(targetId)
|
||||
if (!hostInfo || !provider) {
|
||||
return sshScanIssueResult({
|
||||
executionHostId,
|
||||
targetId,
|
||||
message: SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE
|
||||
})
|
||||
}
|
||||
return scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: hostInfo.executionHostId,
|
||||
remoteHome: hostInfo.remoteHome,
|
||||
hostPlatform: hostInfo.hostPlatform,
|
||||
limit: args?.limit,
|
||||
scopePaths: args?.scopePaths
|
||||
})
|
||||
}
|
||||
|
||||
function sshScanIssueResult(args: {
|
||||
executionHostId: `ssh:${string}`
|
||||
targetId: string
|
||||
message: string
|
||||
}): AiVaultListResult {
|
||||
return {
|
||||
sessions: [],
|
||||
issues: [
|
||||
{
|
||||
executionHostId: args.executionHostId,
|
||||
agent: 'codex',
|
||||
path: args.targetId,
|
||||
message: args.message
|
||||
}
|
||||
],
|
||||
scannedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
function mergeAiVaultListResults(
|
||||
results: readonly AiVaultListResult[],
|
||||
rawLimit: number | undefined
|
||||
): AiVaultListResult {
|
||||
const limit = rawLimit && rawLimit > 0 ? Math.floor(rawLimit) : 1000
|
||||
const byId = new Map<string, AiVaultListResult['sessions'][number]>()
|
||||
const issues: AiVaultScanIssue[] = []
|
||||
for (const result of results) {
|
||||
for (const session of result.sessions) {
|
||||
byId.set(session.id, session)
|
||||
}
|
||||
issues.push(...result.issues)
|
||||
}
|
||||
return {
|
||||
sessions: [...byId.values()]
|
||||
.sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
|
||||
.slice(0, limit),
|
||||
issues,
|
||||
scannedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): void {
|
||||
handlerOptions = options
|
||||
ipcMain.handle('aiVault:listSessions', (_event, args?: AiVaultListArgs) =>
|
||||
@@ -77,6 +211,18 @@ export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): vo
|
||||
})
|
||||
}
|
||||
|
||||
function resetAiVaultCacheForTests(): void {
|
||||
cachedList = null
|
||||
inflightList = null
|
||||
inflightKey = null
|
||||
handlerOptions = {}
|
||||
}
|
||||
|
||||
export const _internals = {
|
||||
listAiVaultSessions,
|
||||
resetAiVaultCacheForTests
|
||||
}
|
||||
|
||||
async function getAiVaultWslHomeDirs(): Promise<string[]> {
|
||||
if (process.platform !== 'win32') {
|
||||
return []
|
||||
|
||||
@@ -32,8 +32,18 @@ describe('buildParcelWatcherIgnoreOption', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('passes the plain list through on other platforms', () => {
|
||||
setPlatform('linux')
|
||||
expect(buildParcelWatcherIgnoreOption(WATCHER_IGNORE_DIRS)).toEqual(WATCHER_IGNORE_DIRS)
|
||||
it('expands to nested globs on Linux/Windows so nested node_modules/.git are excluded', () => {
|
||||
for (const platform of ['linux', 'win32'] as const) {
|
||||
setPlatform(platform)
|
||||
const option = buildParcelWatcherIgnoreOption(WATCHER_IGNORE_DIRS)
|
||||
// @parcel/watcher resolves plain names to top-level-only absolute paths on
|
||||
// these platforms, so nested dirs must be matched via depth-agnostic globs.
|
||||
for (const dir of WATCHER_IGNORE_DIRS) {
|
||||
expect(option).toContain(`**/${dir}`)
|
||||
expect(option).toContain(`**/${dir}/**`)
|
||||
}
|
||||
// No plain (glob-free) entries — those would only exclude the top level.
|
||||
expect(option.every((entry) => entry.includes('*'))).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,7 +25,12 @@ export const MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT = 8
|
||||
|
||||
export function buildParcelWatcherIgnoreOption(ignoreDirs: readonly string[]): string[] {
|
||||
if (process.platform !== 'darwin') {
|
||||
return [...ignoreDirs]
|
||||
// Linux/Windows: @parcel/watcher resolves plain names to top-level-only
|
||||
// absolute paths, so nested node_modules/.git/build/etc. (monorepos) get
|
||||
// fully watched — on Linux that means one inotify watch per nested dir,
|
||||
// exhausting fs.inotify.max_user_watches. Nested globs match at any depth
|
||||
// (and **/dir still matches the top-level dir), pruning those subtrees.
|
||||
return ignoreDirs.flatMap((dir) => [`**/${dir}`, `**/${dir}/**`])
|
||||
}
|
||||
return [
|
||||
...ignoreDirs.slice(0, MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT),
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const ipcState = vi.hoisted(() => ({
|
||||
handleHandlers: new Map<string, (event: unknown, ...args: unknown[]) => unknown>()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: (channel: string, handler: (event: unknown, ...args: unknown[]) => unknown) => {
|
||||
ipcState.handleHandlers.set(channel, handler)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const saveMiniMaxSessionCookieMock = vi.hoisted(() => vi.fn())
|
||||
const clearMiniMaxSessionCookieMock = vi.hoisted(() => vi.fn())
|
||||
const hasMiniMaxSessionCookieMock = vi.hoisted(() => vi.fn(() => false))
|
||||
const clearMiniMaxSessionCookieJarMock = vi.hoisted(() => vi.fn(() => Promise.resolve()))
|
||||
|
||||
vi.mock('../minimax/minimax-cookie-store', () => ({
|
||||
saveMiniMaxSessionCookie: saveMiniMaxSessionCookieMock,
|
||||
clearMiniMaxSessionCookie: clearMiniMaxSessionCookieMock,
|
||||
hasMiniMaxSessionCookie: hasMiniMaxSessionCookieMock
|
||||
}))
|
||||
|
||||
vi.mock('../rate-limits/minimax-request-context', () => ({
|
||||
clearMiniMaxSessionCookieJar: clearMiniMaxSessionCookieJarMock
|
||||
}))
|
||||
|
||||
import { registerMiniMaxCredentialsHandlers } from './minimax-credentials'
|
||||
import type { RateLimitService } from '../rate-limits/service'
|
||||
import type { RateLimitState } from '../../shared/rate-limit-types'
|
||||
|
||||
function makeRefreshMock(): {
|
||||
refresh: ReturnType<typeof vi.fn>
|
||||
invalidateMiniMaxCredentialState: ReturnType<typeof vi.fn>
|
||||
service: Pick<RateLimitService, 'refresh' | 'invalidateMiniMaxCredentialState'>
|
||||
} {
|
||||
const refresh = vi.fn(() => Promise.resolve({} as RateLimitState))
|
||||
const invalidateMiniMaxCredentialState = vi.fn()
|
||||
return {
|
||||
refresh,
|
||||
invalidateMiniMaxCredentialState,
|
||||
service: { refresh, invalidateMiniMaxCredentialState }
|
||||
}
|
||||
}
|
||||
|
||||
async function invoke<T>(channel: string, ...args: unknown[]): Promise<T> {
|
||||
const handler = ipcState.handleHandlers.get(channel)
|
||||
if (!handler) {
|
||||
throw new Error(`No handler registered for ${channel}`)
|
||||
}
|
||||
return (await handler({}, ...args)) as T
|
||||
}
|
||||
|
||||
describe('registerMiniMaxCredentialsHandlers', () => {
|
||||
beforeEach(() => {
|
||||
ipcState.handleHandlers.clear()
|
||||
saveMiniMaxSessionCookieMock.mockReset()
|
||||
clearMiniMaxSessionCookieMock.mockReset()
|
||||
clearMiniMaxSessionCookieJarMock.mockReset()
|
||||
clearMiniMaxSessionCookieJarMock.mockResolvedValue(undefined)
|
||||
hasMiniMaxSessionCookieMock.mockReset()
|
||||
hasMiniMaxSessionCookieMock.mockReturnValue(false)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('registers the three MiniMax credential channels', () => {
|
||||
registerMiniMaxCredentialsHandlers(null)
|
||||
expect(ipcState.handleHandlers.has('minimaxCredentials:getStatus')).toBe(true)
|
||||
expect(ipcState.handleHandlers.has('minimaxCredentials:saveCookie')).toBe(true)
|
||||
expect(ipcState.handleHandlers.has('minimaxCredentials:clearCookie')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns the configured state on getStatus from the cookie store', async () => {
|
||||
hasMiniMaxSessionCookieMock.mockReturnValue(true)
|
||||
registerMiniMaxCredentialsHandlers(null)
|
||||
const status = await invoke<{ configured: boolean }>('minimaxCredentials:getStatus')
|
||||
expect(status).toEqual({ configured: true })
|
||||
})
|
||||
|
||||
it('persists the cookie and reports configured after saveCookie', async () => {
|
||||
hasMiniMaxSessionCookieMock.mockReturnValueOnce(true)
|
||||
registerMiniMaxCredentialsHandlers(null)
|
||||
const status = await invoke<{ configured: boolean }>(
|
||||
'minimaxCredentials:saveCookie',
|
||||
'_token=abc; minimax_group_id_v2=42'
|
||||
)
|
||||
expect(saveMiniMaxSessionCookieMock).toHaveBeenCalledWith('_token=abc; minimax_group_id_v2=42')
|
||||
expect(status).toEqual({ configured: true })
|
||||
})
|
||||
|
||||
it('triggers a rate-limit refresh after saveCookie when a service is provided', async () => {
|
||||
const { refresh, invalidateMiniMaxCredentialState, service } = makeRefreshMock()
|
||||
registerMiniMaxCredentialsHandlers(service as RateLimitService)
|
||||
await invoke('minimaxCredentials:saveCookie', '_token=abc')
|
||||
// Why: the save handler is fire-and-forget — wait a microtask cycle so
|
||||
// the queued `void rateLimits?.refresh()` resolves before we assert.
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(invalidateMiniMaxCredentialState).toHaveBeenCalledTimes(1)
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not throw when saveCookie runs without a rate-limit service', async () => {
|
||||
registerMiniMaxCredentialsHandlers(null)
|
||||
await expect(invoke('minimaxCredentials:saveCookie', '_token=abc')).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('clears the cookie and triggers a refresh on clearCookie', async () => {
|
||||
const { refresh, invalidateMiniMaxCredentialState, service } = makeRefreshMock()
|
||||
hasMiniMaxSessionCookieMock.mockReturnValueOnce(false)
|
||||
registerMiniMaxCredentialsHandlers(service as RateLimitService)
|
||||
const status = await invoke<{ configured: boolean }>('minimaxCredentials:clearCookie')
|
||||
expect(clearMiniMaxSessionCookieMock).toHaveBeenCalledTimes(1)
|
||||
expect(invalidateMiniMaxCredentialState).toHaveBeenCalledTimes(1)
|
||||
expect(clearMiniMaxSessionCookieJarMock).toHaveBeenCalledTimes(1)
|
||||
expect(status).toEqual({ configured: false })
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('still refreshes and reports cleared when session jar cleanup rejects', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const { refresh, invalidateMiniMaxCredentialState, service } = makeRefreshMock()
|
||||
clearMiniMaxSessionCookieJarMock.mockRejectedValueOnce(new Error('jar boom'))
|
||||
hasMiniMaxSessionCookieMock.mockReturnValueOnce(false)
|
||||
registerMiniMaxCredentialsHandlers(service as RateLimitService)
|
||||
|
||||
const status = await invoke<{ configured: boolean }>('minimaxCredentials:clearCookie')
|
||||
|
||||
expect(clearMiniMaxSessionCookieMock).toHaveBeenCalledTimes(1)
|
||||
expect(invalidateMiniMaxCredentialState).toHaveBeenCalledTimes(1)
|
||||
expect(clearMiniMaxSessionCookieJarMock).toHaveBeenCalledTimes(1)
|
||||
expect(status).toEqual({ configured: false })
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('failed to clear session cookie jar after credential clear'),
|
||||
expect.any(Error)
|
||||
)
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('logs but does not throw when the post-save rate-limit refresh rejects', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const refresh = vi.fn(() => Promise.reject(new Error('refresh boom')))
|
||||
const invalidateMiniMaxCredentialState = vi.fn()
|
||||
registerMiniMaxCredentialsHandlers({
|
||||
refresh,
|
||||
invalidateMiniMaxCredentialState
|
||||
} as Pick<RateLimitService, 'refresh' | 'invalidateMiniMaxCredentialState'> as RateLimitService)
|
||||
await invoke('minimaxCredentials:saveCookie', '_token=abc')
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(invalidateMiniMaxCredentialState).toHaveBeenCalledTimes(1)
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('failed to trigger rate-limit refresh after save'),
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import {
|
||||
clearMiniMaxSessionCookie,
|
||||
hasMiniMaxSessionCookie,
|
||||
saveMiniMaxSessionCookie
|
||||
} from '../minimax/minimax-cookie-store'
|
||||
import { clearMiniMaxSessionCookieJar } from '../rate-limits/minimax-request-context'
|
||||
import type { RateLimitService } from '../rate-limits/service'
|
||||
|
||||
export type MiniMaxCredentialsStatus = {
|
||||
configured: boolean
|
||||
}
|
||||
|
||||
function getMiniMaxCredentialsStatus(): MiniMaxCredentialsStatus {
|
||||
return { configured: hasMiniMaxSessionCookie() }
|
||||
}
|
||||
|
||||
// Why: fire-and-forget — callers get the persisted cookie status immediately;
|
||||
// the rate-limit refresh runs in the background and only logs on failure.
|
||||
function refreshAfterMiniMaxCredentialChange(
|
||||
rateLimits: RateLimitService | null,
|
||||
action: 'save' | 'clear'
|
||||
): void {
|
||||
rateLimits?.invalidateMiniMaxCredentialState()
|
||||
void rateLimits?.refresh().catch((error: unknown) => {
|
||||
console.error(`[minimax] failed to trigger rate-limit refresh after ${action}:`, error)
|
||||
})
|
||||
}
|
||||
|
||||
export function registerMiniMaxCredentialsHandlers(rateLimits: RateLimitService | null): void {
|
||||
ipcMain.handle('minimaxCredentials:getStatus', () => getMiniMaxCredentialsStatus())
|
||||
ipcMain.handle('minimaxCredentials:saveCookie', (_event, cookie: string) => {
|
||||
// Validate the IPC argument in the main process; the renderer-declared type
|
||||
// is compile-time only and the value arrives as unknown over IPC.
|
||||
if (typeof cookie !== 'string') {
|
||||
throw new Error('MiniMax session cookie must be a string')
|
||||
}
|
||||
saveMiniMaxSessionCookie(cookie)
|
||||
refreshAfterMiniMaxCredentialChange(rateLimits, 'save')
|
||||
return getMiniMaxCredentialsStatus()
|
||||
})
|
||||
ipcMain.handle('minimaxCredentials:clearCookie', async () => {
|
||||
clearMiniMaxSessionCookie()
|
||||
try {
|
||||
await clearMiniMaxSessionCookieJar()
|
||||
} catch (error) {
|
||||
console.error('[minimax] failed to clear session cookie jar after credential clear:', error)
|
||||
}
|
||||
refreshAfterMiniMaxCredentialChange(rateLimits, 'clear')
|
||||
return getMiniMaxCredentialsStatus()
|
||||
})
|
||||
}
|
||||
+193
-6
@@ -196,7 +196,10 @@ import {
|
||||
encodePowerShellCommand,
|
||||
getPowerShellOsc133Bootstrap
|
||||
} from '../powershell-osc133-bootstrap'
|
||||
import { SSH_SESSION_EXPIRED_ERROR } from '../providers/ssh-pty-provider'
|
||||
import {
|
||||
SSH_PTY_IDENTITY_MISMATCH_ERROR,
|
||||
SSH_SESSION_EXPIRED_ERROR
|
||||
} from '../providers/ssh-pty-provider'
|
||||
import { _resetWslCachesForTests, _setWslCachesForTests } from '../wsl'
|
||||
|
||||
const POWERSHELL_OSC133_ARGS = [
|
||||
@@ -2029,9 +2032,11 @@ describe('registerPtyHandlers', () => {
|
||||
})
|
||||
|
||||
it('does NOT inject host-local env on SSH spawns (connectionId set)', async () => {
|
||||
const sshSpawn = vi.fn(async (_opts: { env: Record<string, string> }) => ({
|
||||
id: 'ssh-pty'
|
||||
}))
|
||||
const sshSpawn = vi.fn(
|
||||
async (_opts: { env: Record<string, string>; paneKey?: string; tabId?: string }) => ({
|
||||
id: 'ssh-pty'
|
||||
})
|
||||
)
|
||||
const store = {
|
||||
upsertSshRemotePtyLease: vi.fn(),
|
||||
persistPtyBinding: vi.fn()
|
||||
@@ -2080,7 +2085,8 @@ describe('registerPtyHandlers', () => {
|
||||
tabId: 'tab-1',
|
||||
leafId
|
||||
})
|
||||
const env = sshSpawn.mock.calls.at(-1)![0].env
|
||||
const spawnOptions = sshSpawn.mock.calls.at(-1)![0]
|
||||
const env = spawnOptions.env
|
||||
// Why: every host-local var must be absent over SSH — the hook
|
||||
// server is on the Orca host's 127.0.0.1, dev CLI / attribution /
|
||||
// overlay / plugin-dir paths only exist on the local disk, so
|
||||
@@ -2103,6 +2109,8 @@ describe('registerPtyHandlers', () => {
|
||||
expect(env.HTTPS_PROXY).toBeUndefined()
|
||||
expect(env.NO_PROXY).toBeUndefined()
|
||||
expect(env.FOO).toBe('bar')
|
||||
expect(spawnOptions.paneKey).toBe(makePaneKey('tab-1', leafId))
|
||||
expect(spawnOptions.tabId).toBe('tab-1')
|
||||
expect(openCodeBuildPtyEnvMock).not.toHaveBeenCalled()
|
||||
expect(piBuildPtyEnvMock).not.toHaveBeenCalled()
|
||||
expect(store.upsertSshRemotePtyLease).toHaveBeenCalledWith(
|
||||
@@ -2134,7 +2142,10 @@ describe('registerPtyHandlers', () => {
|
||||
leafId: 'pane:1'
|
||||
})
|
||||
expect(store.upsertSshRemotePtyLease).toHaveBeenCalledTimes(1)
|
||||
expect(sshSpawn.mock.calls.at(-1)?.[0].env.ORCA_PANE_KEY).toBeUndefined()
|
||||
const legacySpawnOptions = sshSpawn.mock.calls.at(-1)?.[0]
|
||||
expect(legacySpawnOptions?.env.ORCA_PANE_KEY).toBeUndefined()
|
||||
expect(legacySpawnOptions?.paneKey).toBeUndefined()
|
||||
expect(legacySpawnOptions?.tabId).toBe('tab-1')
|
||||
expect(store.upsertSshRemotePtyLease.mock.calls[0]?.[0]).not.toHaveProperty('leafId')
|
||||
expect(store.persistPtyBinding).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -2251,6 +2262,78 @@ describe('registerPtyHandlers', () => {
|
||||
expect(piClearPtyMock).toHaveBeenCalledWith(scopedPtyId)
|
||||
})
|
||||
|
||||
it('does not clear a scoped SSH session when remote reattach rejects an identity mismatch', async () => {
|
||||
const scopedPtyId = 'ssh:ssh-1@@remote-pty'
|
||||
const remoteWrite = vi.fn()
|
||||
const sshSpawn = vi.fn(async () => {
|
||||
throw new Error(
|
||||
`${SSH_SESSION_EXPIRED_ERROR}: remote-pty ${SSH_PTY_IDENTITY_MISMATCH_ERROR}`
|
||||
)
|
||||
})
|
||||
const store = {
|
||||
markSshRemotePtyLease: vi.fn()
|
||||
}
|
||||
registerSshPtyProvider('ssh-1', {
|
||||
spawn: sshSpawn,
|
||||
write: remoteWrite,
|
||||
resize: vi.fn(),
|
||||
shutdown: vi.fn(),
|
||||
sendSignal: vi.fn(),
|
||||
getCwd: vi.fn(),
|
||||
getInitialCwd: vi.fn(),
|
||||
clearBuffer: vi.fn(),
|
||||
acknowledgeDataEvent: vi.fn(),
|
||||
hasChildProcesses: vi.fn(),
|
||||
getForegroundProcess: vi.fn(),
|
||||
serialize: vi.fn(),
|
||||
revive: vi.fn(),
|
||||
onData: vi.fn(() => () => {}),
|
||||
onReplay: vi.fn(() => () => {}),
|
||||
onExit: vi.fn(() => () => {}),
|
||||
listProcesses: vi.fn(async () => []),
|
||||
attach: vi.fn(),
|
||||
getDefaultShell: vi.fn(),
|
||||
getProfiles: vi.fn()
|
||||
} as never)
|
||||
setPtyOwnership(scopedPtyId, 'ssh-1')
|
||||
handlers.clear()
|
||||
registerPtyHandlers(
|
||||
mainWindow as never,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
store as never
|
||||
)
|
||||
|
||||
try {
|
||||
await expect(
|
||||
handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
env: {},
|
||||
connectionId: 'ssh-1',
|
||||
sessionId: scopedPtyId
|
||||
})
|
||||
).rejects.toThrow(SSH_SESSION_EXPIRED_ERROR)
|
||||
|
||||
expect(store.markSshRemotePtyLease).not.toHaveBeenCalledWith(
|
||||
'ssh-1',
|
||||
'remote-pty',
|
||||
'expired'
|
||||
)
|
||||
expect(openCodeClearPtyMock).not.toHaveBeenCalledWith(scopedPtyId)
|
||||
expect(piClearPtyMock).not.toHaveBeenCalledWith(scopedPtyId)
|
||||
getPtyWriteListener()(mainWindowIpcEvent, {
|
||||
id: scopedPtyId,
|
||||
data: 'echo still-owned'
|
||||
})
|
||||
expect(remoteWrite).toHaveBeenCalledWith(scopedPtyId, 'echo still-owned')
|
||||
} finally {
|
||||
deletePtyOwnership(scopedPtyId)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not tombstone an SSH lease when explicit kill shutdown fails transiently', async () => {
|
||||
const store = {
|
||||
markSshRemotePtyLease: vi.fn()
|
||||
@@ -5196,6 +5279,110 @@ describe('registerPtyHandlers', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('does not clear runtime-owned SSH reattach state on identity mismatch', async () => {
|
||||
type RuntimeSpawnController = {
|
||||
spawn(args: {
|
||||
cols: number
|
||||
rows: number
|
||||
worktreeId?: string
|
||||
connectionId?: string
|
||||
tabId?: string
|
||||
leafId?: string
|
||||
sessionId?: string
|
||||
persistHostSessionBinding?: boolean
|
||||
}): Promise<{ id: string }>
|
||||
}
|
||||
const connectionId = 'ssh-identity-runtime'
|
||||
const appPtyId = `ssh:${connectionId}@@relay-pty`
|
||||
const remoteWrite = vi.fn()
|
||||
registerSshPtyProvider(connectionId, {
|
||||
spawn: vi.fn(async () => {
|
||||
throw new Error(
|
||||
`${SSH_SESSION_EXPIRED_ERROR}: relay-pty ${SSH_PTY_IDENTITY_MISMATCH_ERROR}`
|
||||
)
|
||||
}),
|
||||
write: remoteWrite,
|
||||
resize: vi.fn(),
|
||||
shutdown: vi.fn(),
|
||||
sendSignal: vi.fn(),
|
||||
getCwd: vi.fn(),
|
||||
getInitialCwd: vi.fn(),
|
||||
clearBuffer: vi.fn(),
|
||||
acknowledgeDataEvent: vi.fn(),
|
||||
onData: vi.fn(() => () => {}),
|
||||
onReplay: vi.fn(() => () => {}),
|
||||
onExit: vi.fn(() => () => {}),
|
||||
listProcesses: vi.fn(),
|
||||
hasChildProcesses: vi.fn(),
|
||||
getForegroundProcess: vi.fn(),
|
||||
serialize: vi.fn(),
|
||||
revive: vi.fn(),
|
||||
getDefaultShell: vi.fn(),
|
||||
getProfiles: vi.fn()
|
||||
} as never)
|
||||
const store = {
|
||||
upsertSshRemotePtyLease: vi.fn(),
|
||||
persistPtyBinding: vi.fn(),
|
||||
removeSshRemotePtyLease: vi.fn(),
|
||||
markSshRemotePtyLease: vi.fn()
|
||||
}
|
||||
let controller: RuntimeSpawnController | null = null
|
||||
const runtime = {
|
||||
setPtyController: vi.fn((value) => {
|
||||
controller = value
|
||||
}),
|
||||
createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'),
|
||||
registerPreAllocatedHandleForPty: vi.fn(),
|
||||
registerPty: vi.fn(),
|
||||
getDriver: vi.fn(() => ({ kind: 'host' })),
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
onPtyData: vi.fn()
|
||||
}
|
||||
|
||||
try {
|
||||
setPtyOwnership(appPtyId, connectionId)
|
||||
registerPtyHandlers(
|
||||
mainWindow as never,
|
||||
runtime as never,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
store as never
|
||||
)
|
||||
const spawnController = controller as unknown as RuntimeSpawnController
|
||||
const leafId = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
await expect(
|
||||
spawnController.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
connectionId,
|
||||
worktreeId: 'wt-remote',
|
||||
tabId: 'tab-remote',
|
||||
leafId,
|
||||
sessionId: appPtyId,
|
||||
persistHostSessionBinding: true
|
||||
})
|
||||
).rejects.toThrow(SSH_SESSION_EXPIRED_ERROR)
|
||||
|
||||
expect(store.markSshRemotePtyLease).not.toHaveBeenCalledWith(
|
||||
connectionId,
|
||||
'relay-pty',
|
||||
'expired'
|
||||
)
|
||||
expect(store.upsertSshRemotePtyLease).not.toHaveBeenCalled()
|
||||
expect(store.persistPtyBinding).not.toHaveBeenCalled()
|
||||
expect(openCodeClearPtyMock).not.toHaveBeenCalledWith(appPtyId)
|
||||
expect(piClearPtyMock).not.toHaveBeenCalledWith(appPtyId)
|
||||
getPtyWriteListener()(mainWindowIpcEvent, { id: appPtyId, data: 'echo still-owned' })
|
||||
expect(remoteWrite).toHaveBeenCalledWith(appPtyId, 'echo still-owned')
|
||||
} finally {
|
||||
deletePtyOwnership(appPtyId)
|
||||
unregisterSshPtyProvider(connectionId)
|
||||
}
|
||||
})
|
||||
|
||||
it('cleans up fresh runtime-owned SSH spawns when binding persistence fails', async () => {
|
||||
type RuntimeSpawnController = {
|
||||
spawn(args: {
|
||||
|
||||
+74
-11
@@ -37,7 +37,11 @@ import { isPwshAvailable } from '../pwsh'
|
||||
import { LocalPtyProvider } from '../providers/local-pty-provider'
|
||||
import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types'
|
||||
import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery'
|
||||
import { SSH_SESSION_EXPIRED_ERROR, isSshPtyNotFoundError } from '../providers/ssh-pty-provider'
|
||||
import {
|
||||
SSH_SESSION_EXPIRED_ERROR,
|
||||
isSshPtyIdentityMismatchError,
|
||||
isSshPtyNotFoundError
|
||||
} from '../providers/ssh-pty-provider'
|
||||
import { parseAppSshPtyId, toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id'
|
||||
import { createPtySpawnTiming } from './pty-spawn-timing'
|
||||
import { mintPtySessionId, isSafePtySessionId } from '../daemon/pty-session-id'
|
||||
@@ -77,6 +81,7 @@ import {
|
||||
parseLegacyNumericPaneKey,
|
||||
parsePaneKey
|
||||
} from '../../shared/stable-pane-id'
|
||||
import { isValidTerminalTabId } from '../../shared/terminal-tab-id'
|
||||
import { resolveTerminalStartupCwdForWorkspace } from '../../shared/terminal-startup-cwd'
|
||||
import { localTerminalCwdCanonicalizer } from '../pty/terminal-cwd-realpath'
|
||||
import {
|
||||
@@ -2057,6 +2062,7 @@ export function registerPtyHandlers(
|
||||
!store ||
|
||||
typeof args.worktreeId !== 'string' ||
|
||||
typeof args.tabId !== 'string' ||
|
||||
!isValidTerminalTabId(args.tabId) ||
|
||||
typeof args.leafId !== 'string' ||
|
||||
!isTerminalLeafId(args.leafId)
|
||||
) {
|
||||
@@ -2142,10 +2148,33 @@ export function registerPtyHandlers(
|
||||
if (args.worktreeId !== undefined) {
|
||||
spawnOptions.worktreeId = args.worktreeId
|
||||
}
|
||||
const hadSessionSizeBeforeAttach =
|
||||
effectiveSessionAppId !== undefined ? ptySizes.has(effectiveSessionAppId) : false
|
||||
const sessionSizeBeforeAttach =
|
||||
effectiveSessionAppId !== undefined ? ptySizes.get(effectiveSessionAppId) : undefined
|
||||
if (sessionId !== undefined) {
|
||||
spawnOptions.sessionId = sessionId
|
||||
ptySizes.set(effectiveSessionAppId ?? sessionId, { cols: args.cols, rows: args.rows })
|
||||
}
|
||||
const materializedPaneKey = hostSessionBinding
|
||||
? makePaneKey(hostSessionBinding.tabId, hostSessionBinding.leafId)
|
||||
: null
|
||||
const metadataLeafId =
|
||||
typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null
|
||||
const metadataPaneKey =
|
||||
typeof args.tabId === 'string' &&
|
||||
isValidTerminalTabId(args.tabId) &&
|
||||
args.tabId.length <= 512 &&
|
||||
metadataLeafId
|
||||
? makePaneKey(args.tabId, metadataLeafId)
|
||||
: null
|
||||
const spawnIdentityPaneKey = materializedPaneKey ?? metadataPaneKey
|
||||
if (spawnIdentityPaneKey) {
|
||||
spawnOptions.paneKey = spawnIdentityPaneKey
|
||||
}
|
||||
if (typeof args.tabId === 'string' && args.tabId.length > 0 && args.tabId.length <= 512) {
|
||||
spawnOptions.tabId = args.tabId
|
||||
}
|
||||
if (process.platform === 'win32' && !args.connectionId) {
|
||||
spawnOptions.shellOverride = terminalRuntimeOptions.shellOverride
|
||||
spawnOptions.terminalWindowsWslDistro =
|
||||
@@ -2155,9 +2184,6 @@ export function registerPtyHandlers(
|
||||
: undefined
|
||||
}
|
||||
|
||||
const materializedPaneKey = hostSessionBinding
|
||||
? makePaneKey(hostSessionBinding.tabId, hostSessionBinding.leafId)
|
||||
: null
|
||||
const existingPaneSpawn = materializedPaneKey
|
||||
? paneSpawnReservationsByPaneKey.get(materializedPaneKey)
|
||||
: undefined
|
||||
@@ -2177,8 +2203,14 @@ export function registerPtyHandlers(
|
||||
} catch (err) {
|
||||
const rawMessage = err instanceof Error ? err.message : String(err)
|
||||
const spawnError = normalizeNodePtySpawnError(err)
|
||||
const isIdentityMismatch =
|
||||
isSshPtyIdentityMismatchError(spawnError) || isSshPtyIdentityMismatchError(rawMessage)
|
||||
if (effectiveSessionAppId !== undefined) {
|
||||
ptySizes.delete(effectiveSessionAppId)
|
||||
if (isIdentityMismatch && hadSessionSizeBeforeAttach && sessionSizeBeforeAttach) {
|
||||
ptySizes.set(effectiveSessionAppId, sessionSizeBeforeAttach)
|
||||
} else {
|
||||
ptySizes.delete(effectiveSessionAppId)
|
||||
}
|
||||
}
|
||||
if (
|
||||
args.connectionId &&
|
||||
@@ -2186,11 +2218,13 @@ export function registerPtyHandlers(
|
||||
(spawnError.message.includes(SSH_SESSION_EXPIRED_ERROR) ||
|
||||
rawMessage.includes(SSH_SESSION_EXPIRED_ERROR))
|
||||
) {
|
||||
if (effectiveSessionAppId !== undefined) {
|
||||
if (effectiveSessionAppId !== undefined && !isIdentityMismatch) {
|
||||
clearProviderPtyState(effectiveSessionAppId)
|
||||
deletePtyOwnership(effectiveSessionAppId)
|
||||
}
|
||||
store?.markSshRemotePtyLease(args.connectionId, effectiveSessionRelayId, 'expired')
|
||||
if (!isIdentityMismatch) {
|
||||
store?.markSshRemotePtyLease(args.connectionId, effectiveSessionRelayId, 'expired')
|
||||
}
|
||||
}
|
||||
if (isMintedSessionId && sessionId !== undefined) {
|
||||
clearProviderPtyState(sessionId)
|
||||
@@ -2682,7 +2716,7 @@ export function registerPtyHandlers(
|
||||
typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null
|
||||
const metadataPaneKey =
|
||||
typeof args.tabId === 'string' &&
|
||||
args.tabId.length > 0 &&
|
||||
isValidTerminalTabId(args.tabId) &&
|
||||
args.tabId.length <= 512 &&
|
||||
metadataLeafId
|
||||
? makePaneKey(args.tabId, metadataLeafId)
|
||||
@@ -2870,6 +2904,12 @@ export function registerPtyHandlers(
|
||||
if (args.worktreeId !== undefined) {
|
||||
spawnOptions.worktreeId = args.worktreeId
|
||||
}
|
||||
if (reservationPaneKey) {
|
||||
spawnOptions.paneKey = reservationPaneKey
|
||||
}
|
||||
if (typeof args.tabId === 'string' && args.tabId.length > 0 && args.tabId.length <= 512) {
|
||||
spawnOptions.tabId = args.tabId
|
||||
}
|
||||
if (effectiveSessionId !== undefined) {
|
||||
spawnOptions.sessionId = effectiveSessionId
|
||||
}
|
||||
@@ -2883,6 +2923,10 @@ export function registerPtyHandlers(
|
||||
if (effectiveShellOverride !== undefined) {
|
||||
spawnOptions.shellOverride = effectiveShellOverride
|
||||
}
|
||||
const hadSessionSizeBeforeAttach =
|
||||
effectiveSessionAppId !== undefined ? ptySizes.has(effectiveSessionAppId) : false
|
||||
const sessionSizeBeforeAttach =
|
||||
effectiveSessionAppId !== undefined ? ptySizes.get(effectiveSessionAppId) : undefined
|
||||
if (effectiveSessionId !== undefined) {
|
||||
// Why: daemon PTYs can emit prompt/startup bytes before spawn()
|
||||
// resolves. Runtime headless snapshots need the real pane geometry
|
||||
@@ -2930,11 +2974,17 @@ export function registerPtyHandlers(
|
||||
} catch (err) {
|
||||
const rawMessage = err instanceof Error ? err.message : String(err)
|
||||
const spawnError = normalizeNodePtySpawnError(err)
|
||||
const isIdentityMismatch =
|
||||
isSshPtyIdentityMismatchError(spawnError) || isSshPtyIdentityMismatchError(rawMessage)
|
||||
if (preSpawnStartupTerminalColorReplyPtyId) {
|
||||
clearStartupTerminalColorQueryReplies(preSpawnStartupTerminalColorReplyPtyId)
|
||||
}
|
||||
if (effectiveSessionAppId !== undefined) {
|
||||
ptySizes.delete(effectiveSessionAppId)
|
||||
if (isIdentityMismatch && hadSessionSizeBeforeAttach && sessionSizeBeforeAttach) {
|
||||
ptySizes.set(effectiveSessionAppId, sessionSizeBeforeAttach)
|
||||
} else {
|
||||
ptySizes.delete(effectiveSessionAppId)
|
||||
}
|
||||
}
|
||||
if (
|
||||
args.connectionId &&
|
||||
@@ -2945,11 +2995,13 @@ export function registerPtyHandlers(
|
||||
// Why: expired remote reattach means the relay has already dropped
|
||||
// the backing PTY. Clear the durable lease so later session writes
|
||||
// cannot restore the stale pane binding.
|
||||
if (effectiveSessionAppId !== undefined) {
|
||||
if (effectiveSessionAppId !== undefined && !isIdentityMismatch) {
|
||||
clearProviderPtyState(effectiveSessionAppId)
|
||||
deletePtyOwnership(effectiveSessionAppId)
|
||||
}
|
||||
store?.markSshRemotePtyLease(args.connectionId, effectiveSessionRelayId, 'expired')
|
||||
if (!isIdentityMismatch) {
|
||||
store?.markSshRemotePtyLease(args.connectionId, effectiveSessionRelayId, 'expired')
|
||||
}
|
||||
}
|
||||
// Why: if buildPtyHostEnv materialized provider state for this minted
|
||||
// id but provider.spawn failed, that state would otherwise leak.
|
||||
@@ -3509,6 +3561,17 @@ export function registerPtyHandlers(
|
||||
.catch(() => {})
|
||||
})
|
||||
|
||||
ipcMain.removeAllListeners('pty:clearBuffer')
|
||||
ipcMain.on('pty:clearBuffer', (_event, args: { id: string }) => {
|
||||
// Why: the renderer already cleared its own xterm buffer. This clears the
|
||||
// PTY-side state (ConPTY screen buffer, daemon emulator, SSH host buffer)
|
||||
// so the next prompt repaint doesn't land at a stale cursor row.
|
||||
tryGetProviderForPty(args.id)
|
||||
?.clearBuffer(args.id)
|
||||
.catch(() => {})
|
||||
runtime?.clearHeadlessTerminalBuffer(args.id).catch(() => {})
|
||||
})
|
||||
|
||||
ipcMain.handle('pty:kill', async (_event, args: { id: string; keepHistory?: boolean }) => {
|
||||
const ownedConnectionId = ptyOwnership.get(args.id)
|
||||
const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(args.id) : null
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const ipcState = vi.hoisted(() => ({
|
||||
handleHandlers: new Map<string, (event: unknown, ...args: unknown[]) => unknown>()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: (channel: string, handler: (event: unknown, ...args: unknown[]) => unknown) => {
|
||||
ipcState.handleHandlers.set(channel, handler)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
import { registerRateLimitHandlers } from './rate-limits'
|
||||
import type { RateLimitService } from '../rate-limits/service'
|
||||
import type { RateLimitState } from '../../shared/rate-limit-types'
|
||||
|
||||
function makeService(): { service: RateLimitService; refresh: ReturnType<typeof vi.fn> } {
|
||||
const refresh = vi.fn(() => Promise.resolve({} as RateLimitState))
|
||||
const service = {
|
||||
getState: vi.fn(() => ({}) as RateLimitState),
|
||||
refresh,
|
||||
refreshCodexForTarget: vi.fn(() => Promise.resolve({} as RateLimitState)),
|
||||
refreshClaudeForTarget: vi.fn(() => Promise.resolve({} as RateLimitState)),
|
||||
consumeCodexRateLimitResetCredit: vi.fn(() =>
|
||||
Promise.resolve({ outcome: 'noCredit', state: {} as RateLimitState })
|
||||
),
|
||||
setPollingInterval: vi.fn(() => Promise.resolve()),
|
||||
fetchInactiveClaudeAccountsOnOpen: vi.fn(() => Promise.resolve()),
|
||||
fetchInactiveCodexAccountsOnOpen: vi.fn(() => Promise.resolve())
|
||||
}
|
||||
return { service: service as unknown as RateLimitService, refresh }
|
||||
}
|
||||
|
||||
describe('registerRateLimitHandlers', () => {
|
||||
beforeEach(() => {
|
||||
ipcState.handleHandlers.clear()
|
||||
})
|
||||
|
||||
it('registers a refreshMiniMax channel that delegates to refresh()', async () => {
|
||||
const { service, refresh } = makeService()
|
||||
registerRateLimitHandlers(service)
|
||||
const handler = ipcState.handleHandlers.get('rateLimits:refreshMiniMax')
|
||||
expect(handler).toBeDefined()
|
||||
await handler!({})
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps the existing rate-limit channels registered', () => {
|
||||
const { service } = makeService()
|
||||
registerRateLimitHandlers(service)
|
||||
expect(ipcState.handleHandlers.has('rateLimits:get')).toBe(true)
|
||||
expect(ipcState.handleHandlers.has('rateLimits:refresh')).toBe(true)
|
||||
expect(ipcState.handleHandlers.has('rateLimits:refreshMiniMax')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -23,4 +23,5 @@ export function registerRateLimitHandlers(rateLimits: RateLimitService): void {
|
||||
ipcMain.handle('rateLimits:fetchInactiveCodexAccounts', () =>
|
||||
rateLimits.fetchInactiveCodexAccountsOnOpen()
|
||||
)
|
||||
ipcMain.handle('rateLimits:refreshMiniMax', () => rateLimits.refresh())
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ const {
|
||||
registerAgentHookHandlersMock,
|
||||
registerAgentTrustHandlersMock,
|
||||
registerClaudeAccountHandlersMock,
|
||||
registerMiniMaxCredentialsHandlersMock,
|
||||
registerClipboardHandlersMock,
|
||||
setTrustedClipboardRendererWebContentsIdMock,
|
||||
registerUpdaterHandlersMock,
|
||||
@@ -89,6 +90,7 @@ const {
|
||||
registerAgentHookHandlersMock: vi.fn(),
|
||||
registerAgentTrustHandlersMock: vi.fn(),
|
||||
registerClaudeAccountHandlersMock: vi.fn(),
|
||||
registerMiniMaxCredentialsHandlersMock: vi.fn(),
|
||||
registerClipboardHandlersMock: vi.fn(),
|
||||
setTrustedClipboardRendererWebContentsIdMock: vi.fn(),
|
||||
registerUpdaterHandlersMock: vi.fn(),
|
||||
@@ -279,6 +281,10 @@ vi.mock('./claude-accounts', () => ({
|
||||
registerClaudeAccountHandlers: registerClaudeAccountHandlersMock
|
||||
}))
|
||||
|
||||
vi.mock('./minimax-credentials', () => ({
|
||||
registerMiniMaxCredentialsHandlers: registerMiniMaxCredentialsHandlersMock
|
||||
}))
|
||||
|
||||
vi.mock('../window/attach-main-window-services', () => ({
|
||||
registerUpdaterHandlers: registerUpdaterHandlersMock
|
||||
}))
|
||||
@@ -353,6 +359,7 @@ describe('registerCoreHandlers', () => {
|
||||
registerAgentHookHandlersMock.mockReset()
|
||||
registerAgentTrustHandlersMock.mockReset()
|
||||
registerClaudeAccountHandlersMock.mockReset()
|
||||
registerMiniMaxCredentialsHandlersMock.mockReset()
|
||||
registerClipboardHandlersMock.mockReset()
|
||||
setTrustedClipboardRendererWebContentsIdMock.mockReset()
|
||||
registerUpdaterHandlersMock.mockReset()
|
||||
@@ -418,6 +425,7 @@ describe('registerCoreHandlers', () => {
|
||||
expect(registerAgentHookHandlersMock).toHaveBeenCalledWith(runtime)
|
||||
expect(registerPetHandlersMock).toHaveBeenCalled()
|
||||
expect(registerClaudeAccountHandlersMock).toHaveBeenCalledWith(claudeAccounts)
|
||||
expect(registerMiniMaxCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits)
|
||||
expect(registerRateLimitHandlersMock).toHaveBeenCalledWith(rateLimits)
|
||||
expect(registerGitHubHandlersMock).toHaveBeenCalledWith(store, stats)
|
||||
expect(registerLinearHandlersMock).toHaveBeenCalled()
|
||||
|
||||
@@ -53,6 +53,7 @@ import { registerCodexAccountHandlers } from './codex-accounts'
|
||||
import { registerAgentHookHandlers } from './agent-hooks'
|
||||
import { registerAgentTrustHandlers } from './agent-trust'
|
||||
import { registerClaudeAccountHandlers } from './claude-accounts'
|
||||
import { registerMiniMaxCredentialsHandlers } from './minimax-credentials'
|
||||
import { registerUpdaterHandlers } from '../window/attach-main-window-services'
|
||||
import {
|
||||
registerClipboardHandlers,
|
||||
@@ -117,6 +118,7 @@ export function registerCoreHandlers(
|
||||
registerAgentHookHandlers(runtime)
|
||||
registerAgentTrustHandlers()
|
||||
registerClaudeAccountHandlers(claudeAccounts)
|
||||
registerMiniMaxCredentialsHandlers(rateLimits)
|
||||
registerRateLimitHandlers(rateLimits)
|
||||
registerGitHubHandlers(store, stats)
|
||||
registerGitLabHandlers(store)
|
||||
|
||||
@@ -2413,13 +2413,13 @@ describe('repos:getBaseRefDefault envelope', () => {
|
||||
// independent of which Promise in the Promise.all resolves first.
|
||||
type ExecResponse = { stdout: string; stderr: string }
|
||||
type ExecRule = {
|
||||
match: (argv: string[]) => boolean
|
||||
matches: (argv: string[]) => boolean
|
||||
respond: () => Promise<ExecResponse>
|
||||
}
|
||||
const dispatchExec = (rules: ExecRule[]): ((argv: string[]) => Promise<ExecResponse>) => {
|
||||
return (argv: string[]) => {
|
||||
for (const rule of rules) {
|
||||
if (rule.match(argv)) {
|
||||
if (rule.matches(argv)) {
|
||||
return rule.respond()
|
||||
}
|
||||
}
|
||||
@@ -2438,11 +2438,17 @@ describe('repos:getBaseRefDefault envelope', () => {
|
||||
mockGitProvider.exec = vi.fn().mockImplementation(
|
||||
dispatchExec([
|
||||
{
|
||||
match: isSymbolicRef,
|
||||
matches: isSymbolicRef,
|
||||
respond: () => Promise.resolve({ stdout: 'refs/remotes/origin/main\n', stderr: '' })
|
||||
},
|
||||
// The origin/HEAD target is verified before it is trusted, so the
|
||||
// symbolic-ref result must also resolve via rev-parse.
|
||||
{
|
||||
match: isRemoteList,
|
||||
matches: isRevParseFor('refs/remotes/origin/main'),
|
||||
respond: () => Promise.resolve({ stdout: '', stderr: '' })
|
||||
},
|
||||
{
|
||||
matches: isRemoteList,
|
||||
respond: () => Promise.resolve({ stdout: 'origin\nupstream\n', stderr: '' })
|
||||
}
|
||||
])
|
||||
@@ -2468,11 +2474,17 @@ describe('repos:getBaseRefDefault envelope', () => {
|
||||
mockGitProvider.exec = vi.fn().mockImplementation(
|
||||
dispatchExec([
|
||||
{
|
||||
match: isSymbolicRef,
|
||||
matches: isSymbolicRef,
|
||||
respond: () => Promise.resolve({ stdout: 'refs/remotes/origin/main\n', stderr: '' })
|
||||
},
|
||||
// The origin/HEAD target is verified before it is trusted, so the
|
||||
// symbolic-ref result must also resolve via rev-parse.
|
||||
{
|
||||
match: isRemoteList,
|
||||
matches: isRevParseFor('refs/remotes/origin/main'),
|
||||
respond: () => Promise.resolve({ stdout: '', stderr: '' })
|
||||
},
|
||||
{
|
||||
matches: isRemoteList,
|
||||
respond: () => Promise.reject(new Error('relay exec failed'))
|
||||
}
|
||||
])
|
||||
@@ -2500,18 +2512,21 @@ describe('repos:getBaseRefDefault envelope', () => {
|
||||
mockGitProvider.exec = vi.fn().mockImplementation(
|
||||
dispatchExec([
|
||||
// symbolic-ref rejects (no origin/HEAD on the remote)
|
||||
{ match: isSymbolicRef, respond: () => Promise.reject(new Error('no symbolic-ref')) },
|
||||
{ matches: isSymbolicRef, respond: () => Promise.reject(new Error('no symbolic-ref')) },
|
||||
// probe 1: refs/remotes/origin/main — rejects
|
||||
{
|
||||
match: isRevParseFor('refs/remotes/origin/main'),
|
||||
matches: isRevParseFor('refs/remotes/origin/main'),
|
||||
respond: () => Promise.reject(new Error('missing'))
|
||||
},
|
||||
// probe 2: refs/remotes/origin/master — succeeds
|
||||
{
|
||||
match: isRevParseFor('refs/remotes/origin/master'),
|
||||
matches: isRevParseFor('refs/remotes/origin/master'),
|
||||
respond: () => Promise.resolve({ stdout: 'abc123\n', stderr: '' })
|
||||
},
|
||||
{ match: isRemoteList, respond: () => Promise.resolve({ stdout: 'origin\n', stderr: '' }) }
|
||||
{
|
||||
matches: isRemoteList,
|
||||
respond: () => Promise.resolve({ stdout: 'origin\n', stderr: '' })
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
|
||||
+18
-1
@@ -5,7 +5,7 @@ import type { Store } from '../persistence'
|
||||
import { SshConnectionStore } from '../ssh/ssh-connection-store'
|
||||
import { SshConnectionManager, type SshConnectionCallbacks } from '../ssh/ssh-connection'
|
||||
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
|
||||
import { SshRelaySession } from '../ssh/ssh-relay-session'
|
||||
import { SshRelaySession, type SshRelayAiVaultHostInfo } from '../ssh/ssh-relay-session'
|
||||
import { SshPortForwardManager } from '../ssh/ssh-port-forward'
|
||||
import type {
|
||||
DetectedPort,
|
||||
@@ -123,6 +123,23 @@ export async function removeRegisteredSshTarget(targetId: string): Promise<void>
|
||||
// scattered Maps/Sets that previously tracked this state independently.
|
||||
const activeSessions = new Map<string, SshRelaySession>()
|
||||
|
||||
export function getActiveSshAiVaultHostInfo(targetId: string): SshRelayAiVaultHostInfo | null {
|
||||
if (isRuntimeOwnedSshTargetId(targetId)) {
|
||||
return null
|
||||
}
|
||||
return activeSessions.get(targetId)?.getAiVaultHostInfo() ?? null
|
||||
}
|
||||
|
||||
export function getActiveSshAiVaultHostInfos(): SshRelayAiVaultHostInfo[] {
|
||||
return [...activeSessions.values()].flatMap((session) => {
|
||||
if (isRuntimeOwnedSshTargetId(session.targetId)) {
|
||||
return []
|
||||
}
|
||||
const info = session.getAiVaultHostInfo()
|
||||
return info ? [info] : []
|
||||
})
|
||||
}
|
||||
|
||||
async function detachActiveSshSession(targetId: string): Promise<void> {
|
||||
await teardownActiveSshSession(targetId, (session) => session.detach())
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ describe('registerAppMenu', () => {
|
||||
expect(options.onBeforeReload).toHaveBeenCalledWith({ ignoreCache: true, webContentsId: 102 })
|
||||
})
|
||||
|
||||
it('includes prereleases when Check for Updates is clicked with shift held', () => {
|
||||
it('routes Check for Updates modifier clicks to prerelease and perf checks', () => {
|
||||
const options = buildMenuOptions()
|
||||
registerAppMenu(options)
|
||||
|
||||
@@ -152,19 +152,42 @@ describe('registerAppMenu', () => {
|
||||
)
|
||||
|
||||
item?.click?.({} as never, undefined as never, { shiftKey: true } as Electron.KeyboardEvent)
|
||||
item?.click?.({} as never, undefined as never, {} as Electron.KeyboardEvent)
|
||||
item?.click?.(
|
||||
{} as never,
|
||||
undefined as never,
|
||||
{ metaKey: true, shiftKey: true } as Electron.KeyboardEvent
|
||||
{
|
||||
shiftKey: true,
|
||||
...(isMac ? { metaKey: true } : { ctrlKey: true })
|
||||
} as Electron.KeyboardEvent
|
||||
)
|
||||
item?.click?.(
|
||||
{} as never,
|
||||
undefined as never,
|
||||
(isMac ? { metaKey: true } : { ctrlKey: true }) as Electron.KeyboardEvent
|
||||
)
|
||||
item?.click?.(
|
||||
{} as never,
|
||||
undefined as never,
|
||||
(isMac ? { ctrlKey: true } : { metaKey: true }) as Electron.KeyboardEvent
|
||||
)
|
||||
item?.click?.(
|
||||
{} as never,
|
||||
undefined as never,
|
||||
{
|
||||
triggeredByAccelerator: true,
|
||||
shiftKey: true,
|
||||
...(isMac ? { metaKey: true } : { ctrlKey: true })
|
||||
} as Electron.KeyboardEvent
|
||||
)
|
||||
item?.click?.({} as never, undefined as never, {} as Electron.KeyboardEvent)
|
||||
item?.click?.({} as never, undefined as never, { metaKey: true } as Electron.KeyboardEvent)
|
||||
|
||||
expect(options.onCheckForUpdates.mock.calls).toEqual([
|
||||
[{ includePrerelease: true }],
|
||||
[{ includePrerelease: true }],
|
||||
[{ includePrerelease: false }],
|
||||
[{ includePrerelease: false }]
|
||||
[{ includePrerelease: true, includePerfPrerelease: false }],
|
||||
[{ includePrerelease: false, includePerfPrerelease: false }],
|
||||
[{ includePrerelease: true, includePerfPrerelease: true }],
|
||||
[{ includePrerelease: false, includePerfPrerelease: true }],
|
||||
[{ includePrerelease: false, includePerfPrerelease: false }],
|
||||
[{ includePrerelease: false, includePerfPrerelease: false }]
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type KeybindingActionId,
|
||||
type KeybindingOverrides
|
||||
} from '../../shared/keybindings'
|
||||
import type { UpdateCheckOptions } from '../../shared/types'
|
||||
import { translateMain } from '../i18n/main-i18n'
|
||||
|
||||
export type AppearanceMenuState = {
|
||||
@@ -26,7 +27,7 @@ type RegisterAppMenuOptions = {
|
||||
onOpenSetupGuide: (window?: Electron.BaseWindow | null) => void
|
||||
onOpenFeatureTour: (window?: Electron.BaseWindow | null) => void
|
||||
onOpenCrashReport: (window?: Electron.BaseWindow | null) => void
|
||||
onCheckForUpdates: (options: { includePrerelease: boolean }) => void
|
||||
onCheckForUpdates: (options: UpdateCheckOptions) => void
|
||||
onBeforeReload?: (options: { ignoreCache: boolean; webContentsId: number }) => void
|
||||
onZoomIn: () => void
|
||||
onZoomOut: () => void
|
||||
@@ -83,16 +84,19 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void {
|
||||
webContents.reload()
|
||||
}
|
||||
|
||||
// Why: holding Shift while clicking Check for Updates opts this check into
|
||||
// the release-candidate channel. Extracted so both the macOS app-menu entry
|
||||
// and the Windows/Linux Help-menu entry share the exact same behavior.
|
||||
// Why: modifier-click update checks are hidden power-user affordances.
|
||||
// Extracted so the macOS app-menu entry and Windows/Linux Help entry share
|
||||
// identical RC/perf channel routing.
|
||||
const checkForUpdatesClick: Electron.MenuItemConstructorOptions['click'] = (
|
||||
_menuItem,
|
||||
_window,
|
||||
event
|
||||
) => {
|
||||
const includePrerelease = !event.triggeredByAccelerator && event.shiftKey === true
|
||||
onCheckForUpdates({ includePrerelease })
|
||||
const modifierClick = !event.triggeredByAccelerator
|
||||
const includePerfPrerelease =
|
||||
modifierClick && (isMac ? event.metaKey === true : event.ctrlKey === true)
|
||||
const includePrerelease = modifierClick && event.shiftKey === true
|
||||
onCheckForUpdates({ includePrerelease, includePerfPrerelease })
|
||||
}
|
||||
|
||||
const checkForUpdatesItem: Electron.MenuItemConstructorOptions = {
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as MiniMaxCookieStore from './minimax-cookie-store'
|
||||
|
||||
const safeStorageMock = vi.hoisted(() => ({
|
||||
isEncryptionAvailable: vi.fn(() => true),
|
||||
encryptString: vi.fn((value: string) => Buffer.from(value)),
|
||||
decryptString: vi.fn((value: Buffer) => value.toString('utf8'))
|
||||
}))
|
||||
|
||||
const electronMock = vi.hoisted(() => ({
|
||||
safeStorage: safeStorageMock
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => electronMock)
|
||||
|
||||
const existsSyncMock = vi.fn()
|
||||
const readFileSyncMock = vi.fn()
|
||||
const rmSyncMock = vi.fn()
|
||||
const hardenExistingSecureFileMock = vi.fn()
|
||||
const writeSecureFileMock = vi.fn()
|
||||
const homedirMock = vi.fn(() => '/home/test')
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
existsSync: existsSyncMock,
|
||||
readFileSync: readFileSyncMock,
|
||||
rmSync: rmSyncMock
|
||||
}))
|
||||
|
||||
vi.mock('node:os', () => ({
|
||||
homedir: homedirMock
|
||||
}))
|
||||
|
||||
vi.mock('node:path', () => ({
|
||||
join: (...parts: string[]) => parts.join('/')
|
||||
}))
|
||||
|
||||
vi.mock('../../shared/secure-file', () => ({
|
||||
hardenExistingSecureFile: hardenExistingSecureFileMock,
|
||||
writeSecureFile: writeSecureFileMock
|
||||
}))
|
||||
|
||||
const storePath = '/home/test/.orca/minimax-session-cookie.enc'
|
||||
const envelope = (kind: 'encrypted' | 'plaintext', value: string): string =>
|
||||
`orca-minimax-cookie:v1:${kind}:${Buffer.from(value, 'utf8').toString('base64')}`
|
||||
|
||||
async function loadStore(): Promise<typeof MiniMaxCookieStore> {
|
||||
return await import('./minimax-cookie-store')
|
||||
}
|
||||
|
||||
describe('minimax-cookie-store', () => {
|
||||
beforeEach(() => {
|
||||
existsSyncMock.mockReset()
|
||||
readFileSyncMock.mockReset()
|
||||
rmSyncMock.mockReset()
|
||||
hardenExistingSecureFileMock.mockReset()
|
||||
writeSecureFileMock.mockReset()
|
||||
safeStorageMock.isEncryptionAvailable.mockReset()
|
||||
safeStorageMock.encryptString.mockReset()
|
||||
safeStorageMock.decryptString.mockReset()
|
||||
safeStorageMock.isEncryptionAvailable.mockReturnValue(true)
|
||||
safeStorageMock.encryptString.mockImplementation((value: string) => Buffer.from(value))
|
||||
safeStorageMock.decryptString.mockImplementation((value: Buffer) => value.toString('utf8'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('returns false when no file exists yet', async () => {
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
const store = await loadStore()
|
||||
expect(store.hasMiniMaxSessionCookie()).toBe(false)
|
||||
expect(hardenExistingSecureFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hardens the cookie file when checking status for an existing cookie', async () => {
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
const store = await loadStore()
|
||||
expect(store.hasMiniMaxSessionCookie()).toBe(true)
|
||||
expect(hardenExistingSecureFileMock).toHaveBeenCalledWith(storePath)
|
||||
})
|
||||
|
||||
it('still reports an existing cookie when status-path hardening fails', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
hardenExistingSecureFileMock.mockImplementation(() => {
|
||||
throw new Error('permission denied')
|
||||
})
|
||||
const store = await loadStore()
|
||||
expect(store.hasMiniMaxSessionCookie()).toBe(true)
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to harden MiniMax cookie file'),
|
||||
expect.any(Error)
|
||||
)
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('writes the cookie using safeStorage when encryption is available', async () => {
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
const store = await loadStore()
|
||||
store.saveMiniMaxSessionCookie('_token=abc; minimax_group_id_v2=42')
|
||||
expect(safeStorageMock.encryptString).toHaveBeenCalledWith('_token=abc; minimax_group_id_v2=42')
|
||||
expect(writeSecureFileMock).toHaveBeenCalledWith(
|
||||
storePath,
|
||||
envelope('encrypted', '_token=abc; minimax_group_id_v2=42')
|
||||
)
|
||||
})
|
||||
|
||||
it('warns and writes plaintext when safeStorage is unavailable', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
const store = await loadStore()
|
||||
store.saveMiniMaxSessionCookie('_token=abc')
|
||||
expect(writeSecureFileMock).toHaveBeenCalledWith(storePath, envelope('plaintext', '_token=abc'))
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('safeStorage encryption unavailable'))
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('refuses empty cookies', async () => {
|
||||
const store = await loadStore()
|
||||
expect(() => store.saveMiniMaxSessionCookie(' ')).toThrow(/required/)
|
||||
})
|
||||
|
||||
it('reads decrypted cookie from disk and caches it', async () => {
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
readFileSyncMock.mockReturnValue(Buffer.from(envelope('encrypted', 'encrypted-payload')))
|
||||
safeStorageMock.decryptString.mockReturnValue('_token=cached; minimax_group_id_v2=9')
|
||||
const store = await loadStore()
|
||||
const first = store.readMiniMaxSessionCookie()
|
||||
const second = store.readMiniMaxSessionCookie()
|
||||
expect(first).toBe('_token=cached; minimax_group_id_v2=9')
|
||||
expect(second).toBe(first)
|
||||
expect(hardenExistingSecureFileMock).toHaveBeenCalledTimes(1)
|
||||
expect(hardenExistingSecureFileMock).toHaveBeenCalledWith(storePath)
|
||||
expect(safeStorageMock.decryptString).toHaveBeenCalledTimes(1)
|
||||
expect(safeStorageMock.decryptString).toHaveBeenCalledWith(Buffer.from('encrypted-payload'))
|
||||
})
|
||||
|
||||
it('returns null when no file exists', async () => {
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
const store = await loadStore()
|
||||
expect(store.readMiniMaxSessionCookie()).toBeNull()
|
||||
})
|
||||
|
||||
it('returns enveloped plaintext when safeStorage is unavailable and reads succeed', async () => {
|
||||
safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
readFileSyncMock.mockReturnValue(Buffer.from(envelope('plaintext', '_token=plaintext')))
|
||||
const store = await loadStore()
|
||||
expect(store.readMiniMaxSessionCookie()).toBe('_token=plaintext')
|
||||
})
|
||||
|
||||
it('reads legacy plaintext cookies when decrypting is unavailable', async () => {
|
||||
safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
readFileSyncMock.mockReturnValue(Buffer.from('_token=legacy'))
|
||||
const store = await loadStore()
|
||||
expect(store.readMiniMaxSessionCookie()).toBe('_token=legacy')
|
||||
})
|
||||
|
||||
it('reads legacy plaintext cookies when decrypting fails', async () => {
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
readFileSyncMock.mockReturnValue(Buffer.from('_token=legacy'))
|
||||
safeStorageMock.decryptString.mockImplementation(() => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
const store = await loadStore()
|
||||
expect(store.readMiniMaxSessionCookie()).toBe('_token=legacy')
|
||||
})
|
||||
|
||||
it('does not treat encrypted legacy bytes as plaintext when safeStorage is unavailable', async () => {
|
||||
safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
readFileSyncMock.mockReturnValue(Buffer.from('encrypted-payload'))
|
||||
const store = await loadStore()
|
||||
expect(() => store.readMiniMaxSessionCookie()).toThrow(/could not be decrypted/)
|
||||
})
|
||||
|
||||
it('throws for encrypted envelopes when safeStorage is unavailable', async () => {
|
||||
safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
readFileSyncMock.mockReturnValue(Buffer.from(envelope('encrypted', 'encrypted-payload')))
|
||||
const store = await loadStore()
|
||||
expect(() => store.readMiniMaxSessionCookie()).toThrow(/could not be decrypted/)
|
||||
})
|
||||
|
||||
it('throws when decryption fails', async () => {
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
readFileSyncMock.mockReturnValue(Buffer.from(envelope('encrypted', 'encrypted-payload')))
|
||||
safeStorageMock.decryptString.mockImplementation(() => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
const store = await loadStore()
|
||||
expect(() => store.readMiniMaxSessionCookie()).toThrow(/could not be decrypted/)
|
||||
})
|
||||
|
||||
it('clears the cached cookie and removes the file', async () => {
|
||||
existsSyncMock.mockReturnValueOnce(true)
|
||||
readFileSyncMock.mockReturnValueOnce(Buffer.from(envelope('encrypted', 'encrypted-payload')))
|
||||
safeStorageMock.decryptString.mockReturnValueOnce('_token=preclear')
|
||||
const store = await loadStore()
|
||||
expect(store.readMiniMaxSessionCookie()).toBe('_token=preclear')
|
||||
store.clearMiniMaxSessionCookie()
|
||||
expect(rmSyncMock).toHaveBeenCalledWith(storePath, { force: true })
|
||||
expect(store.readMiniMaxSessionCookie()).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
import { safeStorage } from 'electron'
|
||||
import { existsSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { hardenExistingSecureFile, writeSecureFile } from '../../shared/secure-file'
|
||||
|
||||
const MINIMAX_COOKIE_FILE = 'minimax-session-cookie.enc'
|
||||
const COOKIE_ENVELOPE_PREFIX = 'orca-minimax-cookie:v1:'
|
||||
let cachedMiniMaxCookie: string | null = null
|
||||
let warnedMiniMaxCookieStatusHardenFailure = false
|
||||
|
||||
type MiniMaxCookieEnvelope = {
|
||||
kind: 'encrypted' | 'plaintext'
|
||||
payload: Buffer
|
||||
}
|
||||
|
||||
function getOrcaDir(): string {
|
||||
return join(homedir(), '.orca')
|
||||
}
|
||||
|
||||
function getMiniMaxCookiePath(): string {
|
||||
return join(getOrcaDir(), MINIMAX_COOKIE_FILE)
|
||||
}
|
||||
|
||||
function encodeCookieEnvelope(kind: MiniMaxCookieEnvelope['kind'], payload: Buffer): string {
|
||||
return `${COOKIE_ENVELOPE_PREFIX}${kind}:${payload.toString('base64')}`
|
||||
}
|
||||
|
||||
function decodeCookieEnvelope(raw: Buffer): MiniMaxCookieEnvelope | null {
|
||||
const text = raw.toString('utf8')
|
||||
if (!text.startsWith(COOKIE_ENVELOPE_PREFIX)) {
|
||||
return null
|
||||
}
|
||||
const rest = text.slice(COOKIE_ENVELOPE_PREFIX.length)
|
||||
const separator = rest.indexOf(':')
|
||||
if (separator < 0) {
|
||||
throw new Error('MiniMax session cookie could not be decrypted')
|
||||
}
|
||||
const kind = rest.slice(0, separator)
|
||||
if (kind !== 'encrypted' && kind !== 'plaintext') {
|
||||
throw new Error('MiniMax session cookie could not be decrypted')
|
||||
}
|
||||
return {
|
||||
kind,
|
||||
payload: Buffer.from(rest.slice(separator + 1), 'base64')
|
||||
}
|
||||
}
|
||||
|
||||
// Why: migrates cookies saved before the envelope format existed. Older files
|
||||
// hold raw bytes (safeStorage-encrypted or plaintext), so we sniff the content
|
||||
// to tell the two apart rather than removing this as seemingly dead code.
|
||||
function looksLikeCookieHeader(value: string): boolean {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
return false
|
||||
}
|
||||
for (let index = 0; index < trimmed.length; index += 1) {
|
||||
const code = trimmed.charCodeAt(index)
|
||||
if (code < 32 || code === 127) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return (
|
||||
/^Cookie:\s*\S+/i.test(trimmed) ||
|
||||
/(?:^|;\s*)[A-Za-z0-9_.-]+\s*=/.test(trimmed) ||
|
||||
/(?:^|[;\s])[A-Za-z0-9_.-]+\s*:\s*["'][^"']+["']/.test(trimmed)
|
||||
)
|
||||
}
|
||||
|
||||
function readEnvelope(envelope: MiniMaxCookieEnvelope): string {
|
||||
if (envelope.kind === 'plaintext') {
|
||||
return envelope.payload.toString('utf8')
|
||||
}
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
throw new Error('MiniMax session cookie could not be decrypted')
|
||||
}
|
||||
return safeStorage.decryptString(envelope.payload)
|
||||
}
|
||||
|
||||
function readLegacyCookie(raw: Buffer): string {
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
try {
|
||||
return safeStorage.decryptString(raw)
|
||||
} catch {
|
||||
const plaintext = raw.toString('utf8')
|
||||
if (looksLikeCookieHeader(plaintext)) {
|
||||
return plaintext
|
||||
}
|
||||
throw new Error('MiniMax session cookie could not be decrypted')
|
||||
}
|
||||
}
|
||||
const plaintext = raw.toString('utf8')
|
||||
if (looksLikeCookieHeader(plaintext)) {
|
||||
return plaintext
|
||||
}
|
||||
throw new Error('MiniMax session cookie could not be decrypted')
|
||||
}
|
||||
|
||||
export function hasMiniMaxSessionCookie(): boolean {
|
||||
const keyPath = getMiniMaxCookiePath()
|
||||
if (!existsSync(keyPath)) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
hardenExistingSecureFile(keyPath)
|
||||
} catch (error) {
|
||||
if (!warnedMiniMaxCookieStatusHardenFailure) {
|
||||
warnedMiniMaxCookieStatusHardenFailure = true
|
||||
console.warn('[minimax] Failed to harden MiniMax cookie file while checking status', error)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function saveMiniMaxSessionCookie(cookie: string): void {
|
||||
const trimmed = cookie.trim()
|
||||
if (!trimmed) {
|
||||
throw new Error('MiniMax session cookie is required')
|
||||
}
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
writeSecureFile(
|
||||
getMiniMaxCookiePath(),
|
||||
encodeCookieEnvelope('encrypted', safeStorage.encryptString(trimmed))
|
||||
)
|
||||
cachedMiniMaxCookie = trimmed
|
||||
return
|
||||
}
|
||||
console.warn('[minimax] safeStorage encryption unavailable — storing MiniMax cookie in plaintext')
|
||||
writeSecureFile(
|
||||
getMiniMaxCookiePath(),
|
||||
encodeCookieEnvelope('plaintext', Buffer.from(trimmed, 'utf8'))
|
||||
)
|
||||
cachedMiniMaxCookie = trimmed
|
||||
}
|
||||
|
||||
export function readMiniMaxSessionCookie(): string | null {
|
||||
if (cachedMiniMaxCookie !== null) {
|
||||
return cachedMiniMaxCookie
|
||||
}
|
||||
const keyPath = getMiniMaxCookiePath()
|
||||
if (!existsSync(keyPath)) {
|
||||
return null
|
||||
}
|
||||
// Why: keep hardening out of the decode/decrypt try below so a chmod/ACL
|
||||
// failure isn't misreported as a decrypt failure (matches hasMiniMaxSessionCookie).
|
||||
try {
|
||||
hardenExistingSecureFile(keyPath)
|
||||
} catch (error) {
|
||||
console.warn('[minimax] Failed to harden MiniMax cookie file while reading', error)
|
||||
}
|
||||
try {
|
||||
const raw = readFileSync(keyPath)
|
||||
const envelope = decodeCookieEnvelope(raw)
|
||||
cachedMiniMaxCookie = envelope ? readEnvelope(envelope) : readLegacyCookie(raw)
|
||||
return cachedMiniMaxCookie
|
||||
} catch (error) {
|
||||
console.error('[minimax] failed to decode/decrypt session cookie', error)
|
||||
throw new Error('MiniMax session cookie could not be decrypted')
|
||||
}
|
||||
}
|
||||
|
||||
export function clearMiniMaxSessionCookie(): void {
|
||||
cachedMiniMaxCookie = null
|
||||
rmSync(getMiniMaxCookiePath(), { force: true })
|
||||
}
|
||||
@@ -82,6 +82,7 @@ vi.mock('../wsl', () => ({
|
||||
}))
|
||||
|
||||
import { LocalPtyProvider } from './local-pty-provider'
|
||||
import { isRootLikePath } from './pty-path-safety'
|
||||
import { POWERLEVEL10K_WIZARD_DISABLE_ENV } from '../pty/powerlevel10k-wizard-env'
|
||||
|
||||
describe('LocalPtyProvider', () => {
|
||||
@@ -218,6 +219,52 @@ describe('LocalPtyProvider', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('allows an explicitly requested plain shell at POSIX root', async () => {
|
||||
await provider.spawn({ cols: 80, rows: 24, cwd: '/' })
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(Array),
|
||||
expect.objectContaining({ cwd: '/' })
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the safe default cwd for automatic agent startup without an explicit cwd', async () => {
|
||||
spawnMock.mockClear()
|
||||
const origHome = process.env.HOME
|
||||
// Pin HOME so we assert the exact resolved candidate, not just non-root-ness —
|
||||
// catches regressions where resolveSafePtyDefaultCwd picks an unintended home.
|
||||
process.env.HOME = '/home/testuser'
|
||||
|
||||
try {
|
||||
// Why: an omitted cwd resolves to a guaranteed-safe default home (the
|
||||
// guard only rejects root-like paths), so the agent must still launch.
|
||||
await expect(
|
||||
provider.spawn({ cols: 80, rows: 24, command: 'codex' })
|
||||
).resolves.toBeDefined()
|
||||
|
||||
const spawnCall = spawnMock.mock.calls.at(-1)!
|
||||
expect(spawnCall[2].cwd).toBe('/home/testuser')
|
||||
expect(isRootLikePath(spawnCall[2].cwd)).toBe(false)
|
||||
} finally {
|
||||
if (origHome === undefined) {
|
||||
delete process.env.HOME
|
||||
} else {
|
||||
process.env.HOME = origHome
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects automatic agent startup at POSIX root', async () => {
|
||||
spawnMock.mockClear()
|
||||
|
||||
await expect(
|
||||
provider.spawn({ cols: 80, rows: 24, cwd: '/', command: 'claude' })
|
||||
).rejects.toThrow(/requires a non-root workspace/)
|
||||
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('invokes onSpawned callback', async () => {
|
||||
const onSpawned = vi.fn()
|
||||
provider.configure({ onSpawned })
|
||||
|
||||
@@ -55,6 +55,7 @@ import { resolveAgentForegroundProcess } from './agent-foreground-process'
|
||||
import { getAgentForegroundContextPaths } from './agent-foreground-context-paths'
|
||||
import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition'
|
||||
import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery'
|
||||
import { assertSafeAgentStartupCwd, resolveSafePtyDefaultCwd } from './pty-default-cwd'
|
||||
|
||||
const PANE_IDENTITY_ENV_KEYS = [
|
||||
'ORCA_PANE_KEY',
|
||||
@@ -88,20 +89,7 @@ const exitListeners = new Set<ExitCallback>()
|
||||
* Returns a stable default cwd for locally spawned PTYs.
|
||||
*/
|
||||
function getDefaultCwd(): string {
|
||||
if (process.platform !== 'win32') {
|
||||
return process.env.HOME || '/'
|
||||
}
|
||||
|
||||
// Why: USERPROFILE is not guaranteed in all Windows launch contexts.
|
||||
// Falling back to bare HOMEPATH yields a drive-relative path, so combine
|
||||
// HOMEDRIVE + HOMEPATH to keep spawned PTYs anchored to the intended home.
|
||||
if (process.env.USERPROFILE) {
|
||||
return process.env.USERPROFILE
|
||||
}
|
||||
if (process.env.HOMEDRIVE && process.env.HOMEPATH) {
|
||||
return `${process.env.HOMEDRIVE}${process.env.HOMEPATH}`
|
||||
}
|
||||
return 'C:\\'
|
||||
return resolveSafePtyDefaultCwd()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -352,8 +340,18 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
}
|
||||
const id = allocatePtyId(reattachId ?? undefined)
|
||||
|
||||
const startupAgentRecognition = args.command
|
||||
? recognizeAgentProcessFromCommandLine(args.command)
|
||||
: null
|
||||
|
||||
const defaultCwd = getDefaultCwd()
|
||||
const cwd = args.cwd || defaultCwd
|
||||
// Why: gate on the effective cwd (post default-cwd fallback), not the raw
|
||||
// args.cwd — an omitted cwd resolves to a safe default and must not be
|
||||
// rejected as if it were a root-like path.
|
||||
if (args.command && startupAgentRecognition) {
|
||||
assertSafeAgentStartupCwd(cwd, args.command)
|
||||
}
|
||||
const wslInfo = process.platform === 'win32' ? parseWslPath(cwd) : null
|
||||
const worktreeWslContext =
|
||||
process.platform === 'win32' ? getWslContextFromWorktreeId(args.worktreeId) : undefined
|
||||
@@ -594,8 +592,7 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
finalEnv.ORCA_OMP_STATUS_EXTENSION ||
|
||||
finalEnv.ORCA_CODEX_HOME ||
|
||||
finalEnv.ORCA_AGENT_TEAMS_SHIM_DIR
|
||||
const isCodexStartupCommand =
|
||||
recognizeAgentProcessFromCommandLine(args.command)?.agent === 'codex'
|
||||
const isCodexStartupCommand = startupAgentRecognition?.agent === 'codex'
|
||||
let shellLaunch: ReturnType<typeof getShellReadyLaunchConfig> | null = null
|
||||
if (args.command && isCodexStartupCommand) {
|
||||
const shouldWaitForShellReady = shouldUseShellReadyStartupDelivery({
|
||||
@@ -808,9 +805,21 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
ptyDisposables.set(id, disposables)
|
||||
|
||||
if (args.command && !startupCommandDeliveredInShellArgs) {
|
||||
writeStartupCommandWhenShellReady(shellReadyPromise, proc, args.command, (cleanup) => {
|
||||
startupCommandCleanup = cleanup
|
||||
})
|
||||
// Why: only Orca-wrapped POSIX bash/zsh have bracketed-paste mode armed
|
||||
// (bash via `bind`, zsh on by default), so multiline startup prompts can
|
||||
// be pasted literally there; other shells keep the raw submit path.
|
||||
const spawnedShellName = getSpawnedShellName(shellPath).toLowerCase()
|
||||
const bracketedPasteSafe =
|
||||
process.platform !== 'win32' && (spawnedShellName === 'bash' || spawnedShellName === 'zsh')
|
||||
writeStartupCommandWhenShellReady(
|
||||
shellReadyPromise,
|
||||
proc,
|
||||
args.command,
|
||||
(cleanup) => {
|
||||
startupCommandCleanup = cleanup
|
||||
},
|
||||
{ bracketedPasteSafe }
|
||||
)
|
||||
}
|
||||
|
||||
// Why: publish the OS pid so ipc/pty can register the PTY with the memory
|
||||
@@ -901,8 +910,19 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
async getInitialCwd(_id: string): Promise<string> {
|
||||
return ''
|
||||
}
|
||||
async clearBuffer(_id: string): Promise<void> {
|
||||
/* handled client-side in xterm.js */
|
||||
async clearBuffer(id: string): Promise<void> {
|
||||
// Why: xterm.js clear() only resets the renderer. ConPTY keeps its own
|
||||
// screen buffer, so without this its stale cursor row makes the next
|
||||
// prompt repaint land below a blank gap. No-op on POSIX.
|
||||
//
|
||||
// Unlike the daemon session, no PSReadLine form-feed nudge here: it is
|
||||
// only safe at an empty prompt, and without a headless emulator this
|
||||
// provider cannot tell whether input is pending.
|
||||
try {
|
||||
ptyProcesses.get(id)?.clear()
|
||||
} catch {
|
||||
/* PTY may have just exited */
|
||||
}
|
||||
}
|
||||
acknowledgeDataEvent(_id: string, _charCount: number): void {
|
||||
/* no flow control for local */
|
||||
|
||||
@@ -165,6 +165,61 @@ describe('writeStartupCommandWhenShellReady', () => {
|
||||
await Promise.resolve()
|
||||
expect(proc._writes).toEqual(['codex\n'])
|
||||
})
|
||||
|
||||
// Why: regression for the multiline agent-prompt bug. A startup command with
|
||||
// embedded newlines must be wrapped in bracketed paste (ESC[200~ … ESC[201~)
|
||||
// followed by a single submit byte, so bash readline / zsh zle insert the
|
||||
// whole prompt literally instead of reading each LF as Enter and mangling it
|
||||
// into PS2 continuation.
|
||||
it('wraps a multiline startup command in bracketed paste when the shell supports it', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' })
|
||||
const proc = createMockProc()
|
||||
const ready = Promise.resolve()
|
||||
const command = "claude '--dangerously-skip-permissions' 'line one\nline two'"
|
||||
writeStartupCommandWhenShellReady(ready, proc, command, () => {}, {
|
||||
bracketedPasteSafe: true
|
||||
})
|
||||
|
||||
await ready
|
||||
proc._emitData('\r\nuser@host % ')
|
||||
vi.advanceTimersByTime(30)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(proc._writes).toEqual([`\x1b[200~${command}\x1b[201~\n`])
|
||||
})
|
||||
|
||||
it('leaves a single-line command on the raw submit path even when bracketed paste is safe', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' })
|
||||
const proc = createMockProc()
|
||||
const ready = Promise.resolve()
|
||||
writeStartupCommandWhenShellReady(ready, proc, 'claude', () => {}, {
|
||||
bracketedPasteSafe: true
|
||||
})
|
||||
|
||||
await ready
|
||||
proc._emitData('\r\nuser@host % ')
|
||||
vi.advanceTimersByTime(30)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(proc._writes).toEqual(['claude\n'])
|
||||
})
|
||||
|
||||
it('does not bracket-wrap a multiline command when the shell lacks bracketed paste', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' })
|
||||
const proc = createMockProc()
|
||||
const ready = Promise.resolve()
|
||||
const command = 'echo one\necho two'
|
||||
// Default options: bracketedPasteSafe is false, so the raw path is kept to
|
||||
// avoid echoing the ESC[200~ markers on shells without bracketed paste.
|
||||
writeStartupCommandWhenShellReady(ready, proc, command, () => {})
|
||||
|
||||
await ready
|
||||
proc._emitData('\r\nuser@host % ')
|
||||
vi.advanceTimersByTime(30)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(proc._writes).toEqual([`${command}\n`])
|
||||
})
|
||||
})
|
||||
|
||||
describe('scanForShellReady', () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
isPowerShellExecutableName
|
||||
} from '../powershell-osc133-bootstrap'
|
||||
import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper'
|
||||
import { buildStartupCommandSubmission } from '../../shared/startup-command-submission'
|
||||
import {
|
||||
getZshEnvTemplate,
|
||||
getZshFinalZdotdirRestoreBlock,
|
||||
@@ -118,6 +119,11 @@ elif [[ -f "$HOME/.bash_login" ]]; then
|
||||
elif [[ -f "$HOME/.profile" ]]; then
|
||||
source "$HOME/.profile"
|
||||
fi
|
||||
# Why: enable bracketed paste so Orca can deliver a multiline startup prompt as
|
||||
# a single literal paste (ESC[200~…ESC[201~). Without it, older readline builds
|
||||
# treat each embedded newline as Enter and mangle the prompt into PS2
|
||||
# continuation. Modern readline defaults this on; force it for the rest.
|
||||
[[ $- == *i* ]] && bind 'set enable-bracketed-paste on' 2>/dev/null
|
||||
# Why: preserve bash's normal login-shell contract. Many users already source
|
||||
# ~/.bashrc from ~/.bash_profile; forcing ~/.bashrc again here would duplicate
|
||||
# PATH edits, hooks, and prompt init in Orca startup-command shells.
|
||||
@@ -432,7 +438,11 @@ export function writeStartupCommandWhenShellReady(
|
||||
readyPromise: Promise<void | ShellReadySignal>,
|
||||
proc: pty.IPty,
|
||||
startupCommand: string,
|
||||
onExit: (cleanup: () => void) => void
|
||||
onExit: (cleanup: () => void) => void,
|
||||
// Why: only Orca-wrapped bash/zsh have bracketed-paste mode active, so
|
||||
// multiline startup commands are wrapped in ESC[200~/ESC[201~ only there;
|
||||
// other shells keep the raw submit path to avoid echoing the markers.
|
||||
options: { bracketedPasteSafe?: boolean } = {}
|
||||
): void {
|
||||
let sent = false
|
||||
let postReadyTimer: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -470,12 +480,17 @@ export function writeStartupCommandWhenShellReady(
|
||||
// Enter under ICRNL, so CR works there too, but this code path is reached
|
||||
// on Windows as well as POSIX via writeStartupCommandWhenShellReady.
|
||||
const submit = process.platform === 'win32' ? '\r' : '\n'
|
||||
const endsWithSubmit = startupCommand.endsWith('\r') || startupCommand.endsWith('\n')
|
||||
const payload = endsWithSubmit ? startupCommand : `${startupCommand}${submit}`
|
||||
// Why: startup commands are usually long, quoted agent launches. Writing
|
||||
// them in one PTY call after the shell-ready barrier avoids the incremental
|
||||
// paste behavior that still dropped characters in practice.
|
||||
proc.write(payload)
|
||||
// paste behavior that still dropped characters in practice. Multiline
|
||||
// prompts are additionally wrapped in bracketed paste (see the helper) so
|
||||
// embedded newlines are inserted literally instead of submitting early.
|
||||
proc.write(
|
||||
buildStartupCommandSubmission(startupCommand, {
|
||||
submit,
|
||||
bracketedPasteSafe: options.bracketedPasteSafe === true
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const schedulePostReadyFlush = (): void => {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { homedirMock } = vi.hoisted(() => ({
|
||||
homedirMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:os', () => ({
|
||||
homedir: homedirMock
|
||||
}))
|
||||
|
||||
import {
|
||||
assertSafeAgentStartupCwd,
|
||||
isSafeImplicitPtyCwd,
|
||||
resolveSafePtyDefaultCwd
|
||||
} from './pty-default-cwd'
|
||||
|
||||
function setPlatform(platform: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: platform
|
||||
})
|
||||
}
|
||||
|
||||
describe('pty default cwd safety', () => {
|
||||
const originalPlatform = process.platform
|
||||
|
||||
beforeEach(() => {
|
||||
setPlatform(originalPlatform)
|
||||
homedirMock.mockReset()
|
||||
homedirMock.mockReturnValue('/home/orca')
|
||||
})
|
||||
|
||||
it('rejects root-like implicit cwd values', () => {
|
||||
expect(isSafeImplicitPtyCwd(undefined)).toBe(false)
|
||||
expect(isSafeImplicitPtyCwd('')).toBe(false)
|
||||
expect(isSafeImplicitPtyCwd('/')).toBe(false)
|
||||
expect(isSafeImplicitPtyCwd('C:\\')).toBe(false)
|
||||
expect(isSafeImplicitPtyCwd('\\\\server\\share')).toBe(false)
|
||||
})
|
||||
|
||||
it('uses a non-root POSIX home and skips root env values', () => {
|
||||
setPlatform('linux')
|
||||
|
||||
expect(resolveSafePtyDefaultCwd({ HOME: '/home/alice' })).toBe('/home/alice')
|
||||
expect(resolveSafePtyDefaultCwd({ HOME: '/' })).toBe('/home/orca')
|
||||
})
|
||||
|
||||
it('throws instead of falling back to POSIX root', () => {
|
||||
setPlatform('darwin')
|
||||
homedirMock.mockReturnValue('/')
|
||||
|
||||
expect(() => resolveSafePtyDefaultCwd({ HOME: '/' })).toThrow(
|
||||
/No safe default working directory/
|
||||
)
|
||||
})
|
||||
|
||||
it('uses Windows home candidates without accepting drive roots', () => {
|
||||
setPlatform('win32')
|
||||
homedirMock.mockReturnValue('C:\\Users\\os-home')
|
||||
|
||||
expect(
|
||||
resolveSafePtyDefaultCwd({
|
||||
USERPROFILE: 'C:\\',
|
||||
HOMEDRIVE: 'D:',
|
||||
HOMEPATH: '\\Users\\alice'
|
||||
})
|
||||
).toBe('D:\\Users\\alice')
|
||||
})
|
||||
|
||||
it('requires automatic agent startup commands to provide a non-root cwd', () => {
|
||||
expect(() => assertSafeAgentStartupCwd(undefined, 'codex')).toThrow(
|
||||
/requires a non-root workspace/
|
||||
)
|
||||
expect(() => assertSafeAgentStartupCwd('/', 'claude')).toThrow(/requires a non-root workspace/)
|
||||
expect(() => assertSafeAgentStartupCwd('/repo', 'codex')).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { homedir } from 'node:os'
|
||||
|
||||
import { isRootLikePath } from './pty-path-safety'
|
||||
|
||||
function homeDrivePath(env: NodeJS.ProcessEnv): string | null {
|
||||
if (!env.HOMEDRIVE || !env.HOMEPATH) {
|
||||
return null
|
||||
}
|
||||
return `${env.HOMEDRIVE}${env.HOMEPATH}`
|
||||
}
|
||||
|
||||
export function isSafeImplicitPtyCwd(path: string | null | undefined): path is string {
|
||||
return !isRootLikePath(path)
|
||||
}
|
||||
|
||||
export function resolveSafePtyDefaultCwd(env: NodeJS.ProcessEnv = process.env): string {
|
||||
const candidates =
|
||||
process.platform === 'win32'
|
||||
? [env.USERPROFILE, homeDrivePath(env), homedir()]
|
||||
: [env.HOME, homedir()]
|
||||
const selected = candidates.find(isSafeImplicitPtyCwd)
|
||||
// Why: silently falling back to "/" or a drive root is the exact runaway-CPU
|
||||
// bug this module prevents (see runaway-cpu-hidden-usage-pty-design.md) — fail loud.
|
||||
if (!selected) {
|
||||
// Log the rejected candidates so the rare "no safe home at all" environment
|
||||
// (e.g. HOME=/ or unset in a minimal container) is diagnosable, not opaque.
|
||||
console.error(
|
||||
'[pty-default-cwd] No safe default working directory for terminal launch; every home candidate resolved to a root-like path.',
|
||||
{ platform: process.platform, candidates }
|
||||
)
|
||||
throw new Error('No safe default working directory is available for terminal launch.')
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
export function assertSafeAgentStartupCwd(cwd: string | undefined, command: string): void {
|
||||
if (isSafeImplicitPtyCwd(cwd)) {
|
||||
return
|
||||
}
|
||||
throw new Error(
|
||||
`Automatic agent startup command "${command}" requires a non-root workspace working directory.`
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { posix, win32 } from 'node:path'
|
||||
|
||||
// Why: both PTY spawn paths (default terminal cwd + hidden usage probes) gate a
|
||||
// security boundary here — an empty, "/", drive-root, or network-share-root cwd
|
||||
// triggers the unbounded file discovery this guard exists to prevent. Keep the
|
||||
// detection in one place so a fix to one enforcement path can't skip the other.
|
||||
export function isRootLikePath(path: string | null | undefined): boolean {
|
||||
const trimmed = path?.trim() ?? ''
|
||||
if (!trimmed) {
|
||||
return true
|
||||
}
|
||||
// A filesystem root is its own parent. Check both POSIX and Windows semantics
|
||||
// so a Windows drive/UNC root is still rejected when running on POSIX (and vice
|
||||
// versa) — path.dirname alone only understands the host platform's separators.
|
||||
const normalized = posix.normalize(trimmed)
|
||||
return posix.dirname(normalized) === normalized || win32.dirname(trimmed) === trimmed
|
||||
}
|
||||
@@ -67,6 +67,26 @@ describe('SshPtyProvider', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards pane identity as relay metadata on fresh spawn', async () => {
|
||||
mux.request.mockResolvedValue({ id: 'pty-2' })
|
||||
|
||||
await provider.spawn({
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
paneKey: 'tab-a:leaf-a',
|
||||
tabId: 'tab-a'
|
||||
})
|
||||
|
||||
expect(mux.request).toHaveBeenCalledWith('pty.spawn', {
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: undefined,
|
||||
env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true' },
|
||||
paneKey: 'tab-a:leaf-a',
|
||||
tabId: 'tab-a'
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards explicit shellOverride and terminalWindowsWslDistro to the relay mux', async () => {
|
||||
mux.request.mockResolvedValue({ id: 'pty-2' })
|
||||
|
||||
@@ -276,6 +296,27 @@ describe('SshPtyProvider', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('reattaches with explicit pane identity when hook env was stripped', async () => {
|
||||
mux.request.mockResolvedValue({ replay: 'buffered-output' })
|
||||
|
||||
await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
sessionId: 'pty-old',
|
||||
paneKey: 'tab-a:leaf-a',
|
||||
tabId: 'tab-a'
|
||||
})
|
||||
|
||||
expect(mux.request).toHaveBeenCalledWith('pty.attach', {
|
||||
id: 'pty-old',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
suppressReplayNotification: true,
|
||||
expectedPaneKey: 'tab-a:leaf-a',
|
||||
expectedTabId: 'tab-a'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not fresh-spawn over an expired reattach session', async () => {
|
||||
mux.request.mockRejectedValueOnce(new Error('PTY "pty-old" not found'))
|
||||
|
||||
@@ -320,6 +361,20 @@ describe('SshPtyProvider', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('attachForReconnect forwards expected identity when provided', async () => {
|
||||
await provider.attachForReconnect(scopedPty1, {
|
||||
paneKey: 'tab-a:leaf-a',
|
||||
tabId: 'tab-a'
|
||||
})
|
||||
|
||||
expect(mux.request).toHaveBeenCalledWith('pty.attach', {
|
||||
id: 'pty-1',
|
||||
suppressReplayNotification: true,
|
||||
expectedPaneKey: 'tab-a:leaf-a',
|
||||
expectedTabId: 'tab-a'
|
||||
})
|
||||
})
|
||||
|
||||
it('write sends pty.data notification', () => {
|
||||
provider.write(scopedPty1, 'hello')
|
||||
expect(mux.notify).toHaveBeenCalledWith('pty.data', { id: 'pty-1', data: 'hello' })
|
||||
|
||||
@@ -15,12 +15,18 @@ type RemoteCliBridgeEnv = {
|
||||
}
|
||||
|
||||
export const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED'
|
||||
export const SSH_PTY_IDENTITY_MISMATCH_ERROR = 'SSH_PTY_IDENTITY_MISMATCH'
|
||||
|
||||
export function isSshPtyNotFoundError(err: unknown): boolean {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return /PTY ".+" not found/i.test(message)
|
||||
}
|
||||
|
||||
export function isSshPtyIdentityMismatchError(err: unknown): boolean {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return message.includes(SSH_PTY_IDENTITY_MISMATCH_ERROR) || /identity mismatch/i.test(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remote PTY provider that proxies all operations through the relay
|
||||
* via the JSON-RPC multiplexer. Implements the same IPtyProvider interface
|
||||
@@ -101,11 +107,19 @@ export class SshPtyProvider implements IPtyProvider {
|
||||
`[ssh-pty] spawn() called with sessionId=${opts.sessionId}, attempting pty.attach`
|
||||
)
|
||||
try {
|
||||
// Why: pass the pane's expected identity so the relay can reject a
|
||||
// cross-generation id collision (see pty-handler attach) instead of
|
||||
// replaying the wrong shell into this pane. ORCA_PANE_KEY is the
|
||||
// renderer's per-pane identity; ORCA_TAB_ID is the coarser fallback.
|
||||
const expectedPaneKey = opts.paneKey ?? opts.env?.ORCA_PANE_KEY
|
||||
const expectedTabId = opts.tabId ?? opts.env?.ORCA_TAB_ID
|
||||
const attachResult = (await this.mux.request('pty.attach', {
|
||||
id: relaySessionId,
|
||||
cols: opts.cols,
|
||||
rows: opts.rows,
|
||||
suppressReplayNotification: true
|
||||
suppressReplayNotification: true,
|
||||
...(expectedPaneKey ? { expectedPaneKey } : {}),
|
||||
...(expectedTabId ? { expectedTabId } : {})
|
||||
})) as { replay?: string }
|
||||
console.warn(
|
||||
`[ssh-pty] pty.attach succeeded for ${opts.sessionId}, replay=${!!attachResult.replay}`
|
||||
@@ -121,7 +135,10 @@ export class SshPtyProvider implements IPtyProvider {
|
||||
// binding before replacing the dead relay PTY in the same pane.
|
||||
console.warn(`[ssh-pty] pty.attach FAILED for ${opts.sessionId}:`, err)
|
||||
if (isSshPtyNotFoundError(err)) {
|
||||
throw new Error(`${SSH_SESSION_EXPIRED_ERROR}: ${relaySessionId}`)
|
||||
const mismatchMarker = isSshPtyIdentityMismatchError(err)
|
||||
? ` ${SSH_PTY_IDENTITY_MISMATCH_ERROR}`
|
||||
: ''
|
||||
throw new Error(`${SSH_SESSION_EXPIRED_ERROR}: ${relaySessionId}${mismatchMarker}`)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
@@ -143,7 +160,12 @@ export class SshPtyProvider implements IPtyProvider {
|
||||
...(opts.commandDelivery ? { commandDelivery: opts.commandDelivery } : {}),
|
||||
...(opts.startupCommandDelivery
|
||||
? { startupCommandDelivery: opts.startupCommandDelivery }
|
||||
: {})
|
||||
: {}),
|
||||
// Why: main may strip ORCA_PANE_KEY/ORCA_TAB_ID from the shell env when
|
||||
// remote hooks are disabled, but the relay still needs attach identity
|
||||
// metadata to reject cross-generation PTY id collisions.
|
||||
...(opts.paneKey ? { paneKey: opts.paneKey } : {}),
|
||||
...(opts.tabId ? { tabId: opts.tabId } : {})
|
||||
})
|
||||
return {
|
||||
...(result as PtySpawnResult),
|
||||
@@ -185,12 +207,19 @@ export class SshPtyProvider implements IPtyProvider {
|
||||
await this.mux.request('pty.attach', { id: this.toRelayPtyId(id) })
|
||||
}
|
||||
|
||||
async attachForReconnect(id: string): Promise<{ replay?: string }> {
|
||||
async attachForReconnect(
|
||||
id: string,
|
||||
expected?: { paneKey?: string; tabId?: string }
|
||||
): Promise<{ replay?: string }> {
|
||||
// Why: reconnect owns replay delivery so stale/duplicate attach results can
|
||||
// be filtered before they reach the renderer.
|
||||
// be filtered before they reach the renderer. The expected identity lets the
|
||||
// relay reject a cross-generation id collision instead of reattaching this
|
||||
// lease to a different pane's freshly spawned PTY.
|
||||
const result = (await this.mux.request('pty.attach', {
|
||||
id: this.toRelayPtyId(id),
|
||||
suppressReplayNotification: true
|
||||
suppressReplayNotification: true,
|
||||
...(expected?.paneKey ? { expectedPaneKey: expected.paneKey } : {}),
|
||||
...(expected?.tabId ? { expectedTabId: expected.tabId } : {})
|
||||
})) as { replay?: string } | undefined
|
||||
return result ?? {}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,12 @@ export type PtySpawnOptions = {
|
||||
/** Orca worktree identity. When present, the local provider scopes shell
|
||||
* history to this worktree so ArrowUp only surfaces local commands. */
|
||||
worktreeId?: string
|
||||
/** Stable terminal pane identity. Remote providers use this as PTY metadata
|
||||
* even when it must not be exported into the spawned shell environment. */
|
||||
paneKey?: string
|
||||
/** Stable terminal tab identity used as a coarser attach guard when a pane
|
||||
* identity is unavailable. */
|
||||
tabId?: string
|
||||
/** Daemon session ID. A caller-provided ID is treated as an attach request;
|
||||
* daemon hosts also pass minted IDs for fresh sessions that need stable
|
||||
* per-PTY state before provider.spawn returns. */
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { getDaemonPidPath, serializeDaemonPidFile } from '../daemon/daemon-spawner'
|
||||
import type { DaemonPidFile } from '../daemon/daemon-spawner'
|
||||
import {
|
||||
collectInUseRuntimeVersions,
|
||||
ensureRelocatedRuntime
|
||||
} from './install-dir-runtime-relocation'
|
||||
|
||||
let tempDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(os.tmpdir(), 'install-dir-relocation-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
} catch {}
|
||||
})
|
||||
|
||||
// A generic runtime tree with a nested dir and a .pdb symbol file, to exercise
|
||||
// recursive copy and the symbol-exclusion rule independent of any one runtime.
|
||||
function seedSourceDir(dir: string): void {
|
||||
mkdirSync(join(dir, 'nested'), { recursive: true })
|
||||
writeFileSync(join(dir, 'runtime.exe'), 'host')
|
||||
writeFileSync(join(dir, 'runtime.dll'), 'lib')
|
||||
writeFileSync(join(dir, 'runtime.pdb'), 'symbols')
|
||||
writeFileSync(join(dir, 'nested', 'inner.bin'), 'inner')
|
||||
}
|
||||
|
||||
// Writes a daemon pid file exactly as the daemon does (userData/daemon/daemon-v<N>.pid).
|
||||
function writeDaemonPidFile(
|
||||
daemonRuntimeDir: string,
|
||||
protocolVersion: number,
|
||||
pidFile: DaemonPidFile
|
||||
): void {
|
||||
mkdirSync(daemonRuntimeDir, { recursive: true })
|
||||
writeFileSync(
|
||||
getDaemonPidPath(daemonRuntimeDir, protocolVersion),
|
||||
serializeDaemonPidFile(pidFile)
|
||||
)
|
||||
}
|
||||
|
||||
// Deterministic liveness that treats the given pids as running, so tests never
|
||||
// depend on the host's real process table.
|
||||
function aliveFor(...alivePids: number[]): (pid: number) => boolean {
|
||||
const set = new Set(alivePids)
|
||||
return (pid) => set.has(pid)
|
||||
}
|
||||
|
||||
describe('collectInUseRuntimeVersions', () => {
|
||||
it('returns an empty set when the daemon dir does not exist', () => {
|
||||
expect(collectInUseRuntimeVersions(join(tempDir, 'no-daemon-dir')).size).toBe(0)
|
||||
})
|
||||
|
||||
it('collects the app version a live daemon pins', () => {
|
||||
const daemonDir = join(tempDir, 'daemon')
|
||||
writeDaemonPidFile(daemonDir, 18, { pid: 4321, startedAtMs: null, appVersion: '1.4.124-rc.1' })
|
||||
|
||||
const inUse = collectInUseRuntimeVersions(daemonDir, aliveFor(4321))
|
||||
|
||||
expect([...inUse]).toEqual(['1.4.124-rc.1'])
|
||||
})
|
||||
|
||||
it('omits the version of a daemon whose pid is dead', () => {
|
||||
const daemonDir = join(tempDir, 'daemon')
|
||||
writeDaemonPidFile(daemonDir, 18, { pid: 4321, startedAtMs: null, appVersion: '1.4.124-rc.1' })
|
||||
|
||||
const inUse = collectInUseRuntimeVersions(daemonDir, aliveFor(/* nobody */))
|
||||
|
||||
expect(inUse.size).toBe(0)
|
||||
})
|
||||
|
||||
it('collects one version per live daemon across protocol versions', () => {
|
||||
const daemonDir = join(tempDir, 'daemon')
|
||||
writeDaemonPidFile(daemonDir, 18, { pid: 100, startedAtMs: null, appVersion: '1.4.124-rc.1' })
|
||||
writeDaemonPidFile(daemonDir, 19, { pid: 200, startedAtMs: null, appVersion: '1.4.124-rc.2' })
|
||||
|
||||
const inUse = collectInUseRuntimeVersions(daemonDir, aliveFor(100, 200))
|
||||
|
||||
expect([...inUse].sort()).toEqual(['1.4.124-rc.1', '1.4.124-rc.2'])
|
||||
})
|
||||
|
||||
it('ignores a daemon whose pid file records no app version', () => {
|
||||
const daemonDir = join(tempDir, 'daemon')
|
||||
// Pre-relocation daemon: JSON pid file without appVersion.
|
||||
writeDaemonPidFile(daemonDir, 18, { pid: 4321, startedAtMs: null })
|
||||
// Legacy daemon: bare-integer pid file (appVersion parses to null).
|
||||
mkdirSync(daemonDir, { recursive: true })
|
||||
writeFileSync(getDaemonPidPath(daemonDir, 17), '9999')
|
||||
|
||||
const inUse = collectInUseRuntimeVersions(daemonDir, aliveFor(4321, 9999))
|
||||
|
||||
expect(inUse.size).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores non-pid files in the daemon dir', () => {
|
||||
const daemonDir = join(tempDir, 'daemon')
|
||||
mkdirSync(daemonDir, { recursive: true })
|
||||
writeFileSync(join(daemonDir, 'daemon-v18.token'), 'secret')
|
||||
writeFileSync(join(daemonDir, 'daemon-v18.sock'), 'x')
|
||||
writeFileSync(join(daemonDir, 'notes.txt'), 'x')
|
||||
|
||||
expect(collectInUseRuntimeVersions(daemonDir, aliveFor(1, 2, 3)).size).toBe(0)
|
||||
})
|
||||
|
||||
it('skips a malformed pid file without throwing', () => {
|
||||
const daemonDir = join(tempDir, 'daemon')
|
||||
mkdirSync(daemonDir, { recursive: true })
|
||||
writeFileSync(getDaemonPidPath(daemonDir, 18), 'not-a-pid-at-all')
|
||||
writeDaemonPidFile(daemonDir, 19, { pid: 200, startedAtMs: null, appVersion: '2.0.0' })
|
||||
|
||||
const inUse = collectInUseRuntimeVersions(daemonDir, aliveFor(200))
|
||||
|
||||
expect([...inUse]).toEqual(['2.0.0'])
|
||||
})
|
||||
|
||||
it('treats the current process as alive under the default liveness probe', () => {
|
||||
const daemonDir = join(tempDir, 'daemon')
|
||||
// startedAtMs null so startTimeMatches short-circuits true on every platform.
|
||||
writeDaemonPidFile(daemonDir, 18, {
|
||||
pid: process.pid,
|
||||
startedAtMs: null,
|
||||
appVersion: '3.0.0'
|
||||
})
|
||||
|
||||
expect([...collectInUseRuntimeVersions(daemonDir)]).toEqual(['3.0.0'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ensureRelocatedRuntime', () => {
|
||||
it('copies the runtime tree (without symbols) and returns the version dir', () => {
|
||||
const sourceDir = join(tempDir, 'source')
|
||||
seedSourceDir(sourceDir)
|
||||
const destRoot = join(tempDir, 'dest')
|
||||
|
||||
const destDir = ensureRelocatedRuntime({
|
||||
sourceDir,
|
||||
destRoot,
|
||||
version: '1.2.3',
|
||||
daemonRuntimeDir: join(tempDir, 'daemon')
|
||||
})
|
||||
|
||||
expect(destDir).toBe(join(destRoot, '1.2.3'))
|
||||
expect(readFileSync(join(destDir!, 'runtime.exe'), 'utf8')).toBe('host')
|
||||
expect(readFileSync(join(destDir!, 'runtime.dll'), 'utf8')).toBe('lib')
|
||||
expect(readFileSync(join(destDir!, 'nested', 'inner.bin'), 'utf8')).toBe('inner')
|
||||
expect(existsSync(join(destDir!, 'runtime.pdb'))).toBe(false)
|
||||
})
|
||||
|
||||
it('skips recopying once the completion marker exists', () => {
|
||||
const sourceDir = join(tempDir, 'source')
|
||||
seedSourceDir(sourceDir)
|
||||
const destRoot = join(tempDir, 'dest')
|
||||
ensureRelocatedRuntime({
|
||||
sourceDir,
|
||||
destRoot,
|
||||
version: '1.2.3',
|
||||
daemonRuntimeDir: join(tempDir, 'daemon')
|
||||
})
|
||||
|
||||
writeFileSync(join(sourceDir, 'runtime.exe'), 'changed-after-first-copy')
|
||||
const destDir = ensureRelocatedRuntime({
|
||||
sourceDir,
|
||||
destRoot,
|
||||
version: '1.2.3',
|
||||
daemonRuntimeDir: join(tempDir, 'daemon')
|
||||
})
|
||||
|
||||
expect(readFileSync(join(destDir!, 'runtime.exe'), 'utf8')).toBe('host')
|
||||
})
|
||||
|
||||
it('redoes an interrupted copy that has no completion marker', () => {
|
||||
const sourceDir = join(tempDir, 'source')
|
||||
seedSourceDir(sourceDir)
|
||||
const destRoot = join(tempDir, 'dest')
|
||||
mkdirSync(join(destRoot, '1.2.3'), { recursive: true })
|
||||
writeFileSync(join(destRoot, '1.2.3', 'runtime.exe'), 'torn partial copy')
|
||||
|
||||
const destDir = ensureRelocatedRuntime({
|
||||
sourceDir,
|
||||
destRoot,
|
||||
version: '1.2.3',
|
||||
daemonRuntimeDir: join(tempDir, 'daemon')
|
||||
})
|
||||
|
||||
expect(readFileSync(join(destDir!, 'runtime.exe'), 'utf8')).toBe('host')
|
||||
})
|
||||
|
||||
it('reclaims a stale version dir once no live daemon pins it', () => {
|
||||
const sourceDir = join(tempDir, 'source')
|
||||
seedSourceDir(sourceDir)
|
||||
const destRoot = join(tempDir, 'dest')
|
||||
const daemonRuntimeDir = join(tempDir, 'daemon')
|
||||
ensureRelocatedRuntime({ sourceDir, destRoot, version: '1.0.0', daemonRuntimeDir })
|
||||
|
||||
// The 1.0.0 daemon exited, so its pid is dead — nothing pins 1.0.0.
|
||||
writeDaemonPidFile(daemonRuntimeDir, 18, { pid: 4321, startedAtMs: null, appVersion: '1.0.0' })
|
||||
|
||||
const destDir = ensureRelocatedRuntime({
|
||||
sourceDir,
|
||||
destRoot,
|
||||
version: '2.0.0',
|
||||
daemonRuntimeDir,
|
||||
isDaemonPidAlive: aliveFor(/* 4321 is dead */)
|
||||
})
|
||||
|
||||
expect(destDir).toBe(join(destRoot, '2.0.0'))
|
||||
expect(existsSync(join(destRoot, '1.0.0'))).toBe(false)
|
||||
expect(existsSync(join(destRoot, '2.0.0', 'runtime.exe'))).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves a stale version dir a surviving daemon still pins', () => {
|
||||
const sourceDir = join(tempDir, 'source')
|
||||
seedSourceDir(sourceDir)
|
||||
const destRoot = join(tempDir, 'dest')
|
||||
const daemonRuntimeDir = join(tempDir, 'daemon')
|
||||
ensureRelocatedRuntime({ sourceDir, destRoot, version: '1.0.0', daemonRuntimeDir })
|
||||
|
||||
// The 1.0.0 daemon survived the update and still loads its 1.0.0 runtime dir
|
||||
// on demand — deleting it would strand the running daemon.
|
||||
writeDaemonPidFile(daemonRuntimeDir, 18, { pid: 4321, startedAtMs: null, appVersion: '1.0.0' })
|
||||
|
||||
const destDir = ensureRelocatedRuntime({
|
||||
sourceDir,
|
||||
destRoot,
|
||||
version: '2.0.0',
|
||||
daemonRuntimeDir,
|
||||
isDaemonPidAlive: aliveFor(4321)
|
||||
})
|
||||
|
||||
expect(destDir).toBe(join(destRoot, '2.0.0'))
|
||||
expect(existsSync(join(destRoot, '1.0.0', 'runtime.exe'))).toBe(true)
|
||||
expect(existsSync(join(destRoot, '2.0.0', 'runtime.exe'))).toBe(true)
|
||||
})
|
||||
|
||||
it('fails open when the source dir is missing', () => {
|
||||
expect(
|
||||
ensureRelocatedRuntime({
|
||||
sourceDir: join(tempDir, 'does-not-exist'),
|
||||
destRoot: join(tempDir, 'dest'),
|
||||
version: '1.2.3',
|
||||
daemonRuntimeDir: join(tempDir, 'daemon')
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { parseDaemonPidFile, startTimeMatches } from '../daemon/daemon-health'
|
||||
|
||||
/**
|
||||
* Relocates a per-version runtime directory out of the app install directory
|
||||
* into userData, and reclaims old version dirs no live daemon still pins.
|
||||
*
|
||||
* Why: the Windows NSIS update installer force-closes every process (and, for
|
||||
* the terminal runtime, every native binary loaded from a process) whose image
|
||||
* lives under the install directory before replacing files. Copying the runtime
|
||||
* to a version-keyed userData dir takes it out of that kill zone so the detached
|
||||
* daemon and its PTYs survive updates. Both node-pty's native runtime and the
|
||||
* daemon's own Node host share this machinery.
|
||||
*/
|
||||
export const RELOCATION_COMPLETE_MARKER = '.relocation-complete'
|
||||
|
||||
function copyRuntimeTree(sourceDir: string, destDir: string): void {
|
||||
mkdirSync(destDir, { recursive: true })
|
||||
for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
|
||||
const sourcePath = join(sourceDir, entry.name)
|
||||
const destPath = join(destDir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
copyRuntimeTree(sourcePath, destPath)
|
||||
} else if (entry.isFile() && !/\.pdb$/i.test(entry.name)) {
|
||||
copyFileSync(sourcePath, destPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type IsDaemonPidAlive = (pid: number, startedAtMs: number | null) => boolean
|
||||
|
||||
function isDaemonPidAliveDefault(pid: number, startedAtMs: number | null): boolean {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return startTimeMatches(pid, startedAtMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime version dirs still claimed by a live daemon, read from the daemon
|
||||
* pid files under `daemonRuntimeDir` (`daemon-v<N>.pid`).
|
||||
*
|
||||
* Why: a daemon deliberately survives app updates, and it loads its version's
|
||||
* relocated runtime (node-pty binaries, and — once relocated — its own Node
|
||||
* host) on demand. Each pid file records that `appVersion`, so a live daemon's
|
||||
* version dir must never be reclaimed while the process is still running.
|
||||
*/
|
||||
export function collectInUseRuntimeVersions(
|
||||
daemonRuntimeDir: string,
|
||||
isPidAlive: IsDaemonPidAlive = isDaemonPidAliveDefault
|
||||
): Set<string> {
|
||||
const inUse = new Set<string>()
|
||||
let entries
|
||||
try {
|
||||
entries = readdirSync(daemonRuntimeDir, { withFileTypes: true })
|
||||
} catch {
|
||||
return inUse
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !/^daemon-v\d+\.pid$/.test(entry.name)) {
|
||||
continue
|
||||
}
|
||||
let parsed
|
||||
try {
|
||||
parsed = parseDaemonPidFile(readFileSync(join(daemonRuntimeDir, entry.name), 'utf8'))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
// appVersion null => a pre-relocation daemon that loads from the install
|
||||
// dir and thus pins no version dir here.
|
||||
if (parsed && parsed.appVersion !== null && isPidAlive(parsed.pid, parsed.startedAtMs)) {
|
||||
inUse.add(parsed.appVersion)
|
||||
}
|
||||
}
|
||||
return inUse
|
||||
}
|
||||
|
||||
function removeStaleRuntimeVersions(
|
||||
destRoot: string,
|
||||
keepVersion: string,
|
||||
inUseVersions: ReadonlySet<string>
|
||||
): void {
|
||||
let entries
|
||||
try {
|
||||
entries = readdirSync(destRoot, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name === keepVersion || inUseVersions.has(entry.name)) {
|
||||
continue
|
||||
}
|
||||
// Why: Windows maps the daemon's binaries with FILE_SHARE_DELETE, so a
|
||||
// rename/delete succeeds even while a surviving daemon still needs them —
|
||||
// a rename heuristic would delete in-use dirs and strand the daemon. Only
|
||||
// dirs no live daemon claims via its pid file are safe to remove.
|
||||
try {
|
||||
rmSync(join(destRoot, entry.name), { recursive: true, force: true })
|
||||
} catch {
|
||||
// Still locked or already gone — retry on a future launch.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures `sourceDir` is copied to `destRoot/version` (once, guarded by a
|
||||
* completion marker) and reclaims sibling version dirs no live daemon pins.
|
||||
* Returns the version dir, or null on any failure (callers fail open to
|
||||
* loading from the install dir — the pre-relocation behavior).
|
||||
*/
|
||||
export function ensureRelocatedRuntime(options: {
|
||||
sourceDir: string
|
||||
destRoot: string
|
||||
version: string
|
||||
daemonRuntimeDir: string
|
||||
isDaemonPidAlive?: IsDaemonPidAlive
|
||||
}): string | null {
|
||||
const { sourceDir, destRoot, version, daemonRuntimeDir, isDaemonPidAlive } = options
|
||||
const destDir = join(destRoot, version)
|
||||
try {
|
||||
// Why: the marker is written only after a full copy, so a crash mid-copy
|
||||
// leaves no marker and the next launch redoes the copy from scratch.
|
||||
if (!existsSync(join(destDir, RELOCATION_COMPLETE_MARKER))) {
|
||||
rmSync(destDir, { recursive: true, force: true })
|
||||
copyRuntimeTree(sourceDir, destDir)
|
||||
writeFileSync(join(destDir, RELOCATION_COMPLETE_MARKER), '')
|
||||
}
|
||||
const inUseVersions = collectInUseRuntimeVersions(daemonRuntimeDir, isDaemonPidAlive)
|
||||
removeStaleRuntimeVersions(destRoot, version, inUseVersions)
|
||||
return destDir
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import os from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
isPackaged: false,
|
||||
getPath: vi.fn(() => '/unused'),
|
||||
getVersion: vi.fn(() => '0.0.0-test')
|
||||
}
|
||||
}))
|
||||
|
||||
import { ensureRelocatedRuntime } from './install-dir-runtime-relocation'
|
||||
import { resolveNodePtyNativeSourceDir } from './node-pty-runtime-relocation'
|
||||
|
||||
let tempDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(os.tmpdir(), 'node-pty-relocation-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Why: the loader-override test dlopens conpty.node from tempDir, and a
|
||||
// loaded native module stays image-locked until the process exits.
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
} catch {}
|
||||
})
|
||||
|
||||
describe('resolveNodePtyNativeSourceDir', () => {
|
||||
it('prefers the rebuilt build/Release binding over prebuilds', () => {
|
||||
const pkg = join(tempDir, 'node-pty')
|
||||
mkdirSync(join(pkg, 'build', 'Release'), { recursive: true })
|
||||
mkdirSync(join(pkg, 'prebuilds', `win32-${process.arch}`), { recursive: true })
|
||||
writeFileSync(join(pkg, 'build', 'Release', 'conpty.node'), 'x')
|
||||
writeFileSync(join(pkg, 'prebuilds', `win32-${process.arch}`, 'conpty.node'), 'x')
|
||||
expect(resolveNodePtyNativeSourceDir(pkg)).toBe(join(pkg, 'build', 'Release'))
|
||||
})
|
||||
|
||||
it('falls back to the platform prebuild dir', () => {
|
||||
const pkg = join(tempDir, 'node-pty')
|
||||
mkdirSync(join(pkg, 'prebuilds', `win32-${process.arch}`), { recursive: true })
|
||||
writeFileSync(join(pkg, 'prebuilds', `win32-${process.arch}`, 'conpty.node'), 'x')
|
||||
expect(resolveNodePtyNativeSourceDir(pkg)).toBe(join(pkg, 'prebuilds', `win32-${process.arch}`))
|
||||
})
|
||||
|
||||
it('returns null when no conpty binding exists', () => {
|
||||
const pkg = join(tempDir, 'node-pty')
|
||||
mkdirSync(join(pkg, 'build', 'Release'), { recursive: true })
|
||||
expect(resolveNodePtyNativeSourceDir(pkg)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// Loads the real win32 conpty binding, so it cannot run on other platforms.
|
||||
describe.runIf(process.platform === 'win32')('patched node-pty loader override', () => {
|
||||
it('loads the conpty binding from ORCA_NODE_PTY_NATIVE_DIR', () => {
|
||||
const requireFromHere = createRequire(import.meta.url)
|
||||
const nodePtyPackageDir = dirname(requireFromHere.resolve('node-pty/package.json'))
|
||||
const sourceDir = resolveNodePtyNativeSourceDir(nodePtyPackageDir)
|
||||
expect(sourceDir).not.toBeNull()
|
||||
|
||||
const destDir = ensureRelocatedRuntime({
|
||||
sourceDir: sourceDir!,
|
||||
destRoot: join(tempDir, 'relocated-runtime'),
|
||||
version: 'loader-test',
|
||||
daemonRuntimeDir: join(tempDir, 'daemon')
|
||||
})
|
||||
expect(destDir).not.toBeNull()
|
||||
|
||||
const previousNativeDir = process.env.ORCA_NODE_PTY_NATIVE_DIR
|
||||
process.env.ORCA_NODE_PTY_NATIVE_DIR = destDir!
|
||||
try {
|
||||
const utils = requireFromHere('node-pty/lib/utils.js')
|
||||
const loaded = utils.loadNativeModule('conpty')
|
||||
expect(loaded.dir).toBe(destDir)
|
||||
expect(typeof loaded.module.startProcess).toBe('function')
|
||||
} finally {
|
||||
if (previousNativeDir === undefined) {
|
||||
delete process.env.ORCA_NODE_PTY_NATIVE_DIR
|
||||
} else {
|
||||
process.env.ORCA_NODE_PTY_NATIVE_DIR = previousNativeDir
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { app } from 'electron'
|
||||
import { ensureRelocatedRuntime } from './install-dir-runtime-relocation'
|
||||
|
||||
/**
|
||||
* Relocates node-pty's Windows native runtime (conpty.node, conpty.dll,
|
||||
* OpenConsole.exe, winpty binaries) from the install directory to userData.
|
||||
*
|
||||
* Why: the NSIS update installer force-closes every process whose image path
|
||||
* is under the install directory before replacing files. conpty.dll spawns
|
||||
* OpenConsole.exe from beside itself, so while these binaries live in the
|
||||
* install dir every live terminal's console host is killed mid-update.
|
||||
* Loading them from userData (via the ORCA_NODE_PTY_NATIVE_DIR override
|
||||
* patched into node-pty's loader) takes them out of the installer's kill
|
||||
* zone, and the detached daemon inherits the env var so its PTYs are covered.
|
||||
*/
|
||||
export const NODE_PTY_NATIVE_DIR_ENV_VAR = 'ORCA_NODE_PTY_NATIVE_DIR'
|
||||
|
||||
export function resolveNodePtyNativeSourceDir(nodePtyPackageDir: string): string | null {
|
||||
// Packaged builds carry the rebuilt binding in build/Release; dev installs
|
||||
// (and the forced-relocation test path) load from prebuilds.
|
||||
const candidates = [
|
||||
join(nodePtyPackageDir, 'build', 'Release'),
|
||||
join(nodePtyPackageDir, 'prebuilds', `win32-${process.arch}`)
|
||||
]
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(join(candidate, 'conpty.node'))) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function installRelocatedNodePtyNativeRuntime(): void {
|
||||
if (process.platform !== 'win32') {
|
||||
return
|
||||
}
|
||||
if (!app.isPackaged && process.env.ORCA_FORCE_CONPTY_RELOCATION !== '1') {
|
||||
return
|
||||
}
|
||||
// Note: a pre-set env var is deliberately NOT honored here. The update
|
||||
// relaunch chain (old app -> installer -> new app) inherits the previous
|
||||
// version's value, which would pin the new process to stale binaries and
|
||||
// skip copying the current version.
|
||||
let nodePtyPackageDir: string
|
||||
try {
|
||||
const requireFromHere = createRequire(import.meta.url)
|
||||
nodePtyPackageDir = dirname(requireFromHere.resolve('node-pty/package.json'))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const sourceDir = resolveNodePtyNativeSourceDir(nodePtyPackageDir)
|
||||
if (!sourceDir) {
|
||||
return
|
||||
}
|
||||
const userData = app.getPath('userData')
|
||||
const destDir = ensureRelocatedRuntime({
|
||||
sourceDir,
|
||||
destRoot: join(userData, 'node-pty-runtime'),
|
||||
version: app.getVersion(),
|
||||
// Keyed to daemon-init's runtimeDir (userData/daemon), where surviving
|
||||
// daemons write daemon-v<N>.pid recording the runtime version they pin.
|
||||
daemonRuntimeDir: join(userData, 'daemon')
|
||||
})
|
||||
if (destDir) {
|
||||
process.env[NODE_PTY_NATIVE_DIR_ENV_VAR] = destDir
|
||||
}
|
||||
}
|
||||
@@ -312,6 +312,17 @@ type ClaudeUsageAttemptState = {
|
||||
attemptedSources: UsageRateLimitSource[]
|
||||
}
|
||||
|
||||
function abortedClaudeRateLimitResult(): ProviderRateLimits {
|
||||
return {
|
||||
provider: 'claude',
|
||||
session: null,
|
||||
weekly: null,
|
||||
updatedAt: Date.now(),
|
||||
error: 'Rate-limit fetch aborted',
|
||||
status: 'error'
|
||||
}
|
||||
}
|
||||
|
||||
function parseResetTimestamp(value: string | number | undefined): number | null {
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value)) {
|
||||
@@ -389,11 +400,21 @@ function mapFableWeeklyWindow(data: OAuthUsageResponse): RateLimitWindow | null
|
||||
)
|
||||
}
|
||||
|
||||
async function fetchViaOAuth(token: string): Promise<ProviderRateLimits> {
|
||||
async function fetchViaOAuth(token: string, signal?: AbortSignal): Promise<ProviderRateLimits> {
|
||||
if (signal?.aborted) {
|
||||
return abortedClaudeRateLimitResult()
|
||||
}
|
||||
await ensureProxyFromEnv()
|
||||
if (signal?.aborted) {
|
||||
return abortedClaudeRateLimitResult()
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), API_TIMEOUT_MS)
|
||||
const abortFromExternalSignal = (): void => {
|
||||
controller.abort()
|
||||
}
|
||||
signal?.addEventListener('abort', abortFromExternalSignal, { once: true })
|
||||
|
||||
try {
|
||||
// Why: net.fetch uses Chromium's networking stack which respects OS proxy
|
||||
@@ -414,6 +435,9 @@ async function fetchViaOAuth(token: string): Promise<ProviderRateLimits> {
|
||||
}
|
||||
|
||||
const data = (await res.json()) as OAuthUsageResponse
|
||||
if (signal?.aborted) {
|
||||
return abortedClaudeRateLimitResult()
|
||||
}
|
||||
|
||||
return {
|
||||
provider: 'claude',
|
||||
@@ -424,8 +448,14 @@ async function fetchViaOAuth(token: string): Promise<ProviderRateLimits> {
|
||||
error: null,
|
||||
status: 'ok'
|
||||
}
|
||||
} catch (err) {
|
||||
if (signal?.aborted) {
|
||||
return abortedClaudeRateLimitResult()
|
||||
}
|
||||
throw err
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
signal?.removeEventListener('abort', abortFromExternalSignal)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,11 +537,13 @@ async function fetchClaudeUsageViaCli(input: {
|
||||
oauthCredentials: OAuthCredentialReadResult
|
||||
attempts: ClaudeUsageAttemptState
|
||||
networkProxySettings?: NetworkProxySettings
|
||||
signal?: AbortSignal
|
||||
}): Promise<ProviderRateLimits> {
|
||||
recordAttempt(input.attempts, 'cli')
|
||||
const limits = await fetchViaPty({
|
||||
authPreparation: input.authPreparation,
|
||||
networkProxySettings: input.networkProxySettings
|
||||
networkProxySettings: input.networkProxySettings,
|
||||
signal: input.signal
|
||||
})
|
||||
return withClaudeUsageMetadata(
|
||||
limits,
|
||||
@@ -567,8 +599,9 @@ async function supplementOAuthUsageFromCli(input: {
|
||||
attempts: ClaudeUsageAttemptState
|
||||
allowUsagePanelSupplement: boolean
|
||||
networkProxySettings?: NetworkProxySettings
|
||||
signal?: AbortSignal
|
||||
}): Promise<ProviderRateLimits> {
|
||||
if (!canSupplementOAuthUsageFromCli(input)) {
|
||||
if (input.signal?.aborted || !canSupplementOAuthUsageFromCli(input)) {
|
||||
return input.oauthLimits
|
||||
}
|
||||
try {
|
||||
@@ -576,7 +609,8 @@ async function supplementOAuthUsageFromCli(input: {
|
||||
authPreparation: input.authPreparation,
|
||||
oauthCredentials: input.oauthCredentials,
|
||||
attempts: input.attempts,
|
||||
networkProxySettings: input.networkProxySettings
|
||||
networkProxySettings: input.networkProxySettings,
|
||||
signal: input.signal
|
||||
})
|
||||
return mergeClaudeUsageWindows(input.oauthLimits, cliLimits)
|
||||
} catch (err) {
|
||||
@@ -637,25 +671,40 @@ async function attemptCliRepairThenRetryOAuth(input: {
|
||||
attempts: ClaudeUsageAttemptState
|
||||
oauthCredentials: OAuthCredentialReadResult
|
||||
}): Promise<ProviderRateLimits | null> {
|
||||
if (input.options?.signal?.aborted) {
|
||||
return abortedClaudeRateLimitResult()
|
||||
}
|
||||
let cliResult: ProviderRateLimits | null = null
|
||||
try {
|
||||
cliResult = await fetchClaudeUsageViaCli({
|
||||
authPreparation: input.options?.authPreparation,
|
||||
oauthCredentials: input.oauthCredentials,
|
||||
attempts: input.attempts,
|
||||
networkProxySettings: input.options?.networkProxySettings
|
||||
networkProxySettings: input.options?.networkProxySettings,
|
||||
signal: input.options?.signal
|
||||
})
|
||||
} catch (err) {
|
||||
warnClaudeUsageFetchFailure(input.options?.authPreparation, input.oauthCredentials, err)
|
||||
}
|
||||
|
||||
// Why: bail before credential I/O if the fetch cycle was stopped mid-CLI-repair.
|
||||
if (input.options?.signal?.aborted) {
|
||||
return abortedClaudeRateLimitResult()
|
||||
}
|
||||
|
||||
const refreshedCredentials = await readOAuthCredentials(
|
||||
resolveOAuthCredentialReadOptions(input.options?.authPreparation)
|
||||
)
|
||||
if (input.options?.signal?.aborted) {
|
||||
return abortedClaudeRateLimitResult()
|
||||
}
|
||||
if (refreshedCredentials.token) {
|
||||
recordAttempt(input.attempts, 'oauth')
|
||||
try {
|
||||
const oauthRetry = await fetchViaOAuth(refreshedCredentials.token)
|
||||
const oauthRetry = await fetchViaOAuth(refreshedCredentials.token, input.options?.signal)
|
||||
if (input.options?.signal?.aborted) {
|
||||
return abortedClaudeRateLimitResult()
|
||||
}
|
||||
const supplemented = mergeClaudeUsageWindows(oauthRetry, cliResult)
|
||||
return withClaudeUsageMetadata(
|
||||
supplemented,
|
||||
@@ -683,16 +732,21 @@ export type FetchClaudeRateLimitsOptions = {
|
||||
allowPtyFallback?: boolean
|
||||
allowUsagePanelSupplement?: boolean
|
||||
networkProxySettings?: NetworkProxySettings
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export type FetchManagedAccountUsageOptions = {
|
||||
allowUsagePanelSupplement?: boolean
|
||||
networkProxySettings?: NetworkProxySettings
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export async function fetchClaudeRateLimits(
|
||||
options?: FetchClaudeRateLimitsOptions
|
||||
): Promise<ProviderRateLimits> {
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedClaudeRateLimitResult()
|
||||
}
|
||||
const attempts: ClaudeUsageAttemptState = { attemptedSources: [] }
|
||||
const allowCliFallback = options?.allowPtyFallback !== false
|
||||
const plan = resolveClaudeUsageRefreshPlan({
|
||||
@@ -715,11 +769,17 @@ export async function fetchClaudeRateLimits(
|
||||
const oauthCredentials = await readOAuthCredentials(
|
||||
resolveOAuthCredentialReadOptions(options?.authPreparation)
|
||||
)
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedClaudeRateLimitResult()
|
||||
}
|
||||
|
||||
if (plan.steps.some((step) => step.source === 'oauth') && oauthCredentials.token) {
|
||||
recordAttempt(attempts, 'oauth')
|
||||
try {
|
||||
const oauthLimits = await fetchViaOAuth(oauthCredentials.token)
|
||||
const oauthLimits = await fetchViaOAuth(oauthCredentials.token, options?.signal)
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedClaudeRateLimitResult()
|
||||
}
|
||||
const limits = await supplementOAuthUsageFromCli({
|
||||
oauthLimits,
|
||||
authPreparation: options?.authPreparation,
|
||||
@@ -727,8 +787,12 @@ export async function fetchClaudeRateLimits(
|
||||
attempts,
|
||||
networkProxySettings: options?.networkProxySettings,
|
||||
allowUsagePanelSupplement:
|
||||
options?.allowUsagePanelSupplement ?? isManagedClaudeAuth(options?.authPreparation)
|
||||
options?.allowUsagePanelSupplement ?? isManagedClaudeAuth(options?.authPreparation),
|
||||
signal: options?.signal
|
||||
})
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedClaudeRateLimitResult()
|
||||
}
|
||||
return withClaudeUsageMetadata(
|
||||
limits,
|
||||
metadataForAttempt({
|
||||
@@ -767,7 +831,8 @@ export async function fetchClaudeRateLimits(
|
||||
authPreparation: options?.authPreparation,
|
||||
oauthCredentials,
|
||||
attempts,
|
||||
networkProxySettings: options?.networkProxySettings
|
||||
networkProxySettings: options?.networkProxySettings,
|
||||
signal: options?.signal
|
||||
})
|
||||
} catch (ptyError) {
|
||||
warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, ptyError)
|
||||
@@ -825,7 +890,8 @@ export async function fetchClaudeRateLimits(
|
||||
authPreparation: options?.authPreparation,
|
||||
oauthCredentials,
|
||||
attempts,
|
||||
networkProxySettings: options?.networkProxySettings
|
||||
networkProxySettings: options?.networkProxySettings,
|
||||
signal: options?.signal
|
||||
})
|
||||
} catch (err) {
|
||||
warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, err)
|
||||
@@ -871,7 +937,8 @@ export async function fetchClaudeRateLimits(
|
||||
authPreparation: options?.authPreparation,
|
||||
oauthCredentials,
|
||||
attempts,
|
||||
networkProxySettings: options?.networkProxySettings
|
||||
networkProxySettings: options?.networkProxySettings,
|
||||
signal: options?.signal
|
||||
})
|
||||
} catch (err) {
|
||||
warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, err)
|
||||
@@ -1079,7 +1146,11 @@ async function fetchManagedUsagePanelSupplement(input: {
|
||||
credentialsJson: string
|
||||
oauthLimits: ProviderRateLimits
|
||||
networkProxySettings?: NetworkProxySettings
|
||||
signal?: AbortSignal
|
||||
}): Promise<ProviderRateLimits | null> {
|
||||
if (input.signal?.aborted) {
|
||||
return null
|
||||
}
|
||||
const authPreparation = getManagedUsagePanelAuthPreparation(input.account, input.location)
|
||||
if (!authPreparation) {
|
||||
return null
|
||||
@@ -1087,8 +1158,12 @@ async function fetchManagedUsagePanelSupplement(input: {
|
||||
return withManagedPreviewKeychainCredentials(input.location, input.credentialsJson, async () => {
|
||||
const cliLimits = await fetchViaPty({
|
||||
authPreparation,
|
||||
networkProxySettings: input.networkProxySettings
|
||||
networkProxySettings: input.networkProxySettings,
|
||||
signal: input.signal
|
||||
})
|
||||
if (input.signal?.aborted) {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
!canTrustManagedUsagePanelSupplement(input.oauthLimits, cliLimits, {
|
||||
requireMatchingOAuthWindow: input.location.kind === 'keychain'
|
||||
@@ -1108,8 +1183,14 @@ export async function fetchManagedAccountUsage(
|
||||
account: InactiveClaudeAccountInfo,
|
||||
options: FetchManagedAccountUsageOptions = {}
|
||||
): Promise<ProviderRateLimits> {
|
||||
if (options.signal?.aborted) {
|
||||
return abortedClaudeRateLimitResult()
|
||||
}
|
||||
const location = resolveManagedCredentialsLocation(account)
|
||||
let credentialsJson = location ? await readManagedCredentialsJson(location) : null
|
||||
if (options.signal?.aborted) {
|
||||
return abortedClaudeRateLimitResult()
|
||||
}
|
||||
if (!location || !credentialsJson) {
|
||||
return {
|
||||
provider: 'claude',
|
||||
@@ -1129,6 +1210,9 @@ export async function fetchManagedAccountUsage(
|
||||
let token = parseOAuthCredentialsJson(credentialsJson, 'credentials-file').token
|
||||
if (isOauthTokenExpiring(credentialsJson)) {
|
||||
const refreshed = await refreshClaudeOauthCredentials(credentialsJson)
|
||||
if (options.signal?.aborted) {
|
||||
return abortedClaudeRateLimitResult()
|
||||
}
|
||||
if (refreshed) {
|
||||
try {
|
||||
await writeManagedCredentialsJson(location, refreshed)
|
||||
@@ -1155,7 +1239,10 @@ export async function fetchManagedAccountUsage(
|
||||
// Why: PTY fallback is intentionally omitted for inactive accounts. The PTY
|
||||
// path is used only as a supplement after OAuth succeeds, and it points
|
||||
// directly at the managed account's isolated config so selection is unchanged.
|
||||
const oauthLimits = await fetchViaOAuth(token)
|
||||
const oauthLimits = await fetchViaOAuth(token, options.signal)
|
||||
if (options.signal?.aborted) {
|
||||
return abortedClaudeRateLimitResult()
|
||||
}
|
||||
if (
|
||||
!canSupplementOAuthUsageFromCli({
|
||||
oauthLimits,
|
||||
@@ -1171,7 +1258,8 @@ export async function fetchManagedAccountUsage(
|
||||
location,
|
||||
credentialsJson,
|
||||
oauthLimits,
|
||||
networkProxySettings: options.networkProxySettings
|
||||
networkProxySettings: options.networkProxySettings,
|
||||
signal: options.signal
|
||||
})
|
||||
return mergeClaudeUsageWindows(oauthLimits, cliLimits)
|
||||
} catch (err) {
|
||||
|
||||
@@ -14,6 +14,7 @@ vi.mock('node-pty', () => ({
|
||||
}))
|
||||
|
||||
import { fetchViaPty } from './claude-pty'
|
||||
import { getActiveHiddenRateLimitPtyCount } from './hidden-pty-cleanup'
|
||||
|
||||
function makeDisposable() {
|
||||
return { dispose: vi.fn() }
|
||||
@@ -97,6 +98,22 @@ describe('fetchViaPty', () => {
|
||||
await resultPromise
|
||||
})
|
||||
|
||||
it('spawns the hidden usage PTY in a bounded non-root cwd', async () => {
|
||||
const term = makeMockTerm()
|
||||
spawnMock.mockReturnValue(term)
|
||||
|
||||
const resultPromise = fetchViaPty()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
const spawnCwd = spawnMock.mock.calls[0]?.[2]?.cwd as string
|
||||
expect(spawnCwd).toContain('rate-limit-pty-cwd')
|
||||
expect(spawnCwd).not.toBe('/')
|
||||
expect(spawnCwd).not.toMatch(/^[A-Za-z]:(?:[\\/])?$/)
|
||||
|
||||
term.emitExit()
|
||||
await resultPromise
|
||||
})
|
||||
|
||||
it('leaves the spawn env proxy untouched when no proxy is configured', async () => {
|
||||
const term = makeMockTerm()
|
||||
spawnMock.mockReturnValue(term)
|
||||
@@ -135,6 +152,8 @@ describe('fetchViaPty', () => {
|
||||
const [spawnFile, spawnArgs] = spawnMock.mock.calls[0] as [string, string[]]
|
||||
expect(spawnFile).toBe('wsl.exe')
|
||||
const bashCommand = spawnArgs.at(-1) as string
|
||||
expect(bashCommand).toContain('mkdir -p "$orca_rate_limit_cwd"')
|
||||
expect(bashCommand).toContain('cd "$orca_rate_limit_cwd"')
|
||||
expect(bashCommand).toContain("export HTTPS_PROXY='http://127.0.0.1:7890'")
|
||||
expect(bashCommand).toContain('exec claude')
|
||||
|
||||
@@ -142,6 +161,28 @@ describe('fetchViaPty', () => {
|
||||
await resultPromise
|
||||
})
|
||||
|
||||
it('kills and unregisters the hidden PTY when the fetch signal aborts', async () => {
|
||||
const term = makeMockTerm()
|
||||
spawnMock.mockReturnValue(term)
|
||||
const controller = new AbortController()
|
||||
const killMock = term.kill
|
||||
|
||||
const resultPromise = fetchViaPty({ signal: controller.signal })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(getActiveHiddenRateLimitPtyCount()).toBe(1)
|
||||
|
||||
controller.abort()
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
provider: 'claude',
|
||||
status: 'error',
|
||||
error: 'Rate-limit fetch aborted'
|
||||
})
|
||||
expect(killMock).toHaveBeenCalledTimes(1)
|
||||
expect(getActiveHiddenRateLimitPtyCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('clears the startup delay timer when the hidden PTY exits early', async () => {
|
||||
const term = makeMockTerm()
|
||||
spawnMock.mockReturnValue(term)
|
||||
|
||||
@@ -7,8 +7,12 @@ import { resolveClaudeCommand } from '../codex-cli/command'
|
||||
import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service'
|
||||
import { applyClaudeEnvPatch } from '../claude-accounts/environment'
|
||||
import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic'
|
||||
import { cleanupHiddenRateLimitPty } from './hidden-pty-cleanup'
|
||||
import { cleanupHiddenRateLimitPty, registerHiddenRateLimitPty } from './hidden-pty-cleanup'
|
||||
import { extractClaudePtyResetMetadata } from './claude-pty-reset-parser'
|
||||
import {
|
||||
getHiddenRateLimitWslCwdSetupCommands,
|
||||
resolveHiddenRateLimitPtyCwd
|
||||
} from './hidden-rate-limit-pty-cwd'
|
||||
|
||||
const PTY_TIMEOUT_MS = 25_000
|
||||
const MAX_OUTPUT_LENGTH = 100_000 // 100KB buffer limit
|
||||
@@ -197,11 +201,29 @@ function describeClaudeUsageFailure(output: string): string {
|
||||
return 'Claude usage is unavailable right now.'
|
||||
}
|
||||
|
||||
function abortedClaudeUsageResult(): ProviderRateLimits {
|
||||
return {
|
||||
provider: 'claude',
|
||||
session: null,
|
||||
weekly: null,
|
||||
updatedAt: Date.now(),
|
||||
error: 'Rate-limit fetch aborted',
|
||||
status: 'error'
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchViaPty(options?: {
|
||||
authPreparation?: ClaudeRuntimeAuthPreparation
|
||||
networkProxySettings?: NetworkProxySettings
|
||||
signal?: AbortSignal
|
||||
}): Promise<ProviderRateLimits> {
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedClaudeUsageResult()
|
||||
}
|
||||
const pty = await import('node-pty')
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedClaudeUsageResult()
|
||||
}
|
||||
|
||||
return new Promise<ProviderRateLimits>((resolve) => {
|
||||
let output = ''
|
||||
@@ -251,10 +273,13 @@ export async function fetchViaPty(options?: {
|
||||
// Why: Windows-side env does not cross into the distro without WSLENV,
|
||||
// so export the configured proxy inside the command for the inner claude.
|
||||
[
|
||||
// Why: hidden usage probes must not inherit a root-like WSL cwd;
|
||||
// keep Claude discovery bounded to a tiny temp directory.
|
||||
...getHiddenRateLimitWslCwdSetupCommands(),
|
||||
`export CLAUDE_CONFIG_DIR=${shellQuote(wslConfig.linuxConfigDir)}`,
|
||||
...Object.entries(proxyEnv).map(([key, value]) => `export ${key}=${shellQuote(value)}`),
|
||||
'exec claude'
|
||||
].join('; ')
|
||||
].join(' && ')
|
||||
]
|
||||
: isWin32
|
||||
? ['/c', `"${claudeCommand}"`]
|
||||
@@ -264,10 +289,14 @@ export async function fetchViaPty(options?: {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
// Why: hidden usage PTYs must not inherit the process cwd (e.g. / or a
|
||||
// drive root), which can trigger unbounded file discovery.
|
||||
cwd: resolveHiddenRateLimitPtyCwd(),
|
||||
env: spawnEnv
|
||||
})
|
||||
const termDisposables: { dispose: () => void }[] = []
|
||||
const termDisposables: { dispose: () => void }[] = [registerHiddenRateLimitPty(term)]
|
||||
let enterInterval: ReturnType<typeof setInterval> | null = null
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function clearFollowupTimers(): void {
|
||||
if (startupDelayTimer) {
|
||||
@@ -288,7 +317,32 @@ export async function fetchViaPty(options?: {
|
||||
}
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
function settleAborted(): void {
|
||||
if (resolved) {
|
||||
return
|
||||
}
|
||||
resolved = true
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
timeout = null
|
||||
}
|
||||
clearFollowupTimers()
|
||||
cleanupHiddenRateLimitPty(term, termDisposables, { kill: true })
|
||||
resolve(abortedClaudeUsageResult())
|
||||
}
|
||||
|
||||
if (options?.signal) {
|
||||
if (options.signal.aborted) {
|
||||
settleAborted()
|
||||
return
|
||||
}
|
||||
options.signal.addEventListener('abort', settleAborted, { once: true })
|
||||
termDisposables.push({
|
||||
dispose: () => options.signal?.removeEventListener('abort', settleAborted)
|
||||
})
|
||||
}
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true
|
||||
clearFollowupTimers()
|
||||
@@ -342,7 +396,10 @@ export async function fetchViaPty(options?: {
|
||||
return
|
||||
}
|
||||
resolved = true
|
||||
clearTimeout(timeout)
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
timeout = null
|
||||
}
|
||||
clearFollowupTimers()
|
||||
cleanupHiddenRateLimitPty(term, termDisposables, { kill: true })
|
||||
|
||||
@@ -443,7 +500,10 @@ export async function fetchViaPty(options?: {
|
||||
clearFollowupTimers()
|
||||
if (!resolved) {
|
||||
resolved = true
|
||||
clearTimeout(timeout)
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
timeout = null
|
||||
}
|
||||
const clean = stripTerminalControlSequences(output)
|
||||
const { session, weekly, fableWeekly } = parsePtyUsage(clean)
|
||||
resolve({
|
||||
|
||||
@@ -33,6 +33,7 @@ vi.mock('./codex-auth-presence', () => ({
|
||||
|
||||
import { fetchCodexRateLimits } from './codex-fetcher'
|
||||
import { codexAuthExists } from './codex-auth-presence'
|
||||
import { getActiveHiddenRateLimitPtyCount } from './hidden-pty-cleanup'
|
||||
|
||||
function makeDisposable() {
|
||||
return { dispose: vi.fn() }
|
||||
@@ -52,6 +53,25 @@ function makeRpcChild() {
|
||||
return child
|
||||
}
|
||||
|
||||
function makePtyTerm() {
|
||||
let dataHandler: ((data: string) => void) | null = null
|
||||
let exitHandler: (() => void) | null = null
|
||||
return {
|
||||
onData: vi.fn((callback: (data: string) => void) => {
|
||||
dataHandler = callback
|
||||
return makeDisposable()
|
||||
}),
|
||||
onExit: vi.fn((callback: () => void) => {
|
||||
exitHandler = callback
|
||||
return makeDisposable()
|
||||
}),
|
||||
write: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
emitData: (data: string) => dataHandler?.(data),
|
||||
emitExit: () => exitHandler?.()
|
||||
}
|
||||
}
|
||||
|
||||
describe('fetchCodexRateLimits', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
@@ -108,6 +128,83 @@ describe('fetchCodexRateLimits', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('spawns the RPC rate-limit reader in a bounded non-root cwd', async () => {
|
||||
const rpcChild = makeRpcChild()
|
||||
childSpawnMock.mockReturnValue(rpcChild)
|
||||
|
||||
const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false })
|
||||
|
||||
const spawnCwd = childSpawnMock.mock.calls[0]?.[2]?.cwd as string
|
||||
expect(spawnCwd).toContain('rate-limit-pty-cwd')
|
||||
expect(spawnCwd).not.toBe('/')
|
||||
expect(spawnCwd).not.toMatch(/^[A-Za-z]:\\?$/)
|
||||
|
||||
rpcChild.emit('close')
|
||||
await resultPromise
|
||||
})
|
||||
|
||||
it('spawns the PTY fallback in a bounded non-root cwd', async () => {
|
||||
const term = makePtyTerm()
|
||||
childSpawnMock.mockImplementation(() => {
|
||||
throw new Error('rpc unavailable')
|
||||
})
|
||||
ptySpawnMock.mockReturnValue(term)
|
||||
|
||||
const resultPromise = fetchCodexRateLimits()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
const spawnCwd = ptySpawnMock.mock.calls[0]?.[2]?.cwd as string
|
||||
expect(spawnCwd).toContain('rate-limit-pty-cwd')
|
||||
expect(spawnCwd).not.toBe('/')
|
||||
expect(spawnCwd).not.toMatch(/^[A-Za-z]:\\?$/)
|
||||
|
||||
term.emitExit()
|
||||
await resultPromise
|
||||
})
|
||||
|
||||
it('kills the RPC child and skips PTY fallback when the fetch signal aborts', async () => {
|
||||
const rpcChild = makeRpcChild()
|
||||
childSpawnMock.mockReturnValue(rpcChild)
|
||||
const controller = new AbortController()
|
||||
|
||||
const resultPromise = fetchCodexRateLimits({ signal: controller.signal })
|
||||
|
||||
controller.abort()
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
provider: 'codex',
|
||||
status: 'error',
|
||||
error: 'Rate-limit fetch aborted'
|
||||
})
|
||||
expect(rpcChild.kill).toHaveBeenCalledTimes(1)
|
||||
expect(ptySpawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('kills and unregisters the PTY fallback when the fetch signal aborts', async () => {
|
||||
const term = makePtyTerm()
|
||||
childSpawnMock.mockImplementation(() => {
|
||||
throw new Error('rpc unavailable')
|
||||
})
|
||||
ptySpawnMock.mockReturnValue(term)
|
||||
const controller = new AbortController()
|
||||
const killMock = term.kill
|
||||
|
||||
const resultPromise = fetchCodexRateLimits({ signal: controller.signal })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(getActiveHiddenRateLimitPtyCount()).toBe(1)
|
||||
|
||||
controller.abort()
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
provider: 'codex',
|
||||
status: 'error',
|
||||
error: 'Rate-limit fetch aborted'
|
||||
})
|
||||
expect(killMock).toHaveBeenCalledTimes(1)
|
||||
expect(getActiveHiddenRateLimitPtyCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('falls back to the PTY status reader when RPC exits before returning usage', async () => {
|
||||
const rpcChild = makeRpcChild()
|
||||
const ptyHandlers: { onData?: (data: string) => void } = {}
|
||||
@@ -437,17 +534,18 @@ describe('fetchCodexRateLimits', () => {
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await resultPromise
|
||||
|
||||
expect(childSpawnMock).toHaveBeenCalledWith(
|
||||
'wsl.exe',
|
||||
[
|
||||
'-d',
|
||||
'Ubuntu',
|
||||
'--',
|
||||
'bash',
|
||||
'-lc',
|
||||
"export CODEX_HOME='/home/alice/.local/share/orca/account/home'; exec codex '-s' 'read-only' '-a' 'untrusted' 'app-server'"
|
||||
],
|
||||
const [spawnFile, spawnArgs, spawnOptions] = childSpawnMock.mock.calls[0]
|
||||
expect(spawnFile).toBe('wsl.exe')
|
||||
const bashCommand = spawnArgs.at(-1) as string
|
||||
expect(bashCommand).toContain('mkdir -p "$orca_rate_limit_cwd"')
|
||||
expect(bashCommand).toContain('cd "$orca_rate_limit_cwd"')
|
||||
expect(bashCommand).toContain(
|
||||
"export CODEX_HOME='/home/alice/.local/share/orca/account/home'"
|
||||
)
|
||||
expect(bashCommand).toContain("exec codex '-s' 'read-only' '-a' 'untrusted' 'app-server'")
|
||||
expect(spawnOptions).toEqual(
|
||||
expect.objectContaining({
|
||||
cwd: expect.stringContaining('rate-limit-pty-cwd'),
|
||||
env: expect.not.objectContaining({ CODEX_HOME: expect.anything() })
|
||||
})
|
||||
)
|
||||
@@ -488,17 +586,18 @@ describe('fetchCodexRateLimits', () => {
|
||||
rpcChild.emit('close')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(ptySpawnMock).toHaveBeenCalledWith(
|
||||
'wsl.exe',
|
||||
[
|
||||
'-d',
|
||||
'Ubuntu',
|
||||
'--',
|
||||
'bash',
|
||||
'-lc',
|
||||
"export CODEX_HOME='/home/alice/.local/share/orca/account/home'; exec codex "
|
||||
],
|
||||
const [spawnFile, spawnArgs, spawnOptions] = ptySpawnMock.mock.calls[0]
|
||||
expect(spawnFile).toBe('wsl.exe')
|
||||
const bashCommand = spawnArgs.at(-1) as string
|
||||
expect(bashCommand).toContain('mkdir -p "$orca_rate_limit_cwd"')
|
||||
expect(bashCommand).toContain('cd "$orca_rate_limit_cwd"')
|
||||
expect(bashCommand).toContain(
|
||||
"export CODEX_HOME='/home/alice/.local/share/orca/account/home'"
|
||||
)
|
||||
expect(bashCommand).toContain('exec codex ')
|
||||
expect(spawnOptions).toEqual(
|
||||
expect.objectContaining({
|
||||
cwd: expect.stringContaining('rate-limit-pty-cwd'),
|
||||
env: expect.not.objectContaining({ CODEX_HOME: expect.anything() })
|
||||
})
|
||||
)
|
||||
|
||||
@@ -14,9 +14,13 @@ import { codexAuthExists } from './codex-auth-presence'
|
||||
import { resolveCodexCommand } from '../codex-cli/command'
|
||||
import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic'
|
||||
import { getCmdExePath, getSpawnArgsForWindows } from '../win32-utils'
|
||||
import { cleanupHiddenRateLimitPty } from './hidden-pty-cleanup'
|
||||
import { cleanupHiddenRateLimitPty, registerHiddenRateLimitPty } from './hidden-pty-cleanup'
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
import { extractCodexAuthError, isCodexAuthError } from '../../shared/codex-auth-errors'
|
||||
import {
|
||||
getHiddenRateLimitWslCwdSetupCommands,
|
||||
resolveHiddenRateLimitPtyCwd
|
||||
} from './hidden-rate-limit-pty-cwd'
|
||||
|
||||
const RPC_TIMEOUT_MS = 10_000
|
||||
const WSL_RPC_TIMEOUT_MS = 25_000
|
||||
@@ -26,6 +30,7 @@ const MAX_DIAGNOSTIC_OUTPUT_LENGTH = 100_000
|
||||
export type FetchCodexRateLimitsOptions = {
|
||||
codexHomePath?: string | null
|
||||
allowPtyFallback?: boolean
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -117,9 +122,10 @@ function buildWslCodexCommand(
|
||||
return null
|
||||
}
|
||||
const script = [
|
||||
...getHiddenRateLimitWslCwdSetupCommands(),
|
||||
`export CODEX_HOME=${shellQuote(wslInfo.linuxPath)}`,
|
||||
`exec codex ${args.map(shellQuote).join(' ')}`
|
||||
].join('; ')
|
||||
].join(' && ')
|
||||
return {
|
||||
command: 'wsl.exe',
|
||||
args: ['-d', wslInfo.distro, '--', 'bash', '-lc', script]
|
||||
@@ -161,6 +167,17 @@ function normalizeCreditStatus(status: string | undefined): string {
|
||||
return status?.toLowerCase() ?? 'unknown'
|
||||
}
|
||||
|
||||
function abortedCodexRateLimitResult(): ProviderRateLimits {
|
||||
return {
|
||||
provider: 'codex',
|
||||
session: null,
|
||||
weekly: null,
|
||||
updatedAt: Date.now(),
|
||||
error: 'Rate-limit fetch aborted',
|
||||
status: 'error'
|
||||
}
|
||||
}
|
||||
|
||||
function getNextAvailableCreditExpiry(
|
||||
credits: RateLimitResetCredits['credits'] | undefined
|
||||
): number | null {
|
||||
@@ -228,6 +245,9 @@ function mapBackendRateLimitResetCredits(
|
||||
async function getCodexBackendAuthHeaders(
|
||||
options?: FetchCodexRateLimitsOptions
|
||||
): Promise<CodexBackendAuthHeaders | null> {
|
||||
if (options?.signal?.aborted) {
|
||||
return null
|
||||
}
|
||||
const authPath = join(getCodexHomePath(options?.codexHomePath), 'auth.json')
|
||||
const auth = JSON.parse(await readFile(authPath, 'utf8')) as CodexAuthFile
|
||||
const accessToken = auth.tokens?.access_token
|
||||
@@ -250,16 +270,22 @@ async function getCodexBackendAuthHeaders(
|
||||
async function fetchBackendRateLimitResetCredits(
|
||||
options?: FetchCodexRateLimitsOptions
|
||||
): Promise<RateLimitResetCredits | null> {
|
||||
if (options?.signal?.aborted) {
|
||||
return null
|
||||
}
|
||||
const auth = await getCodexBackendAuthHeaders(options)
|
||||
if (!auth) {
|
||||
return null
|
||||
}
|
||||
if (options?.signal?.aborted) {
|
||||
return null
|
||||
}
|
||||
// Why: published Codex 0.140 can read windows through app-server but strips
|
||||
// reset-credit metadata that the backend already returns.
|
||||
const response = await fetch(
|
||||
'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits',
|
||||
auth
|
||||
)
|
||||
const response = await fetch('https://chatgpt.com/backend-api/wham/rate-limit-reset-credits', {
|
||||
...auth,
|
||||
signal: options?.signal
|
||||
})
|
||||
if (!response.ok) {
|
||||
return null
|
||||
}
|
||||
@@ -272,6 +298,7 @@ async function withBackendRateLimitResetCredits(
|
||||
options?: FetchCodexRateLimitsOptions
|
||||
): Promise<ProviderRateLimits> {
|
||||
if (
|
||||
options?.signal?.aborted ||
|
||||
limits.provider !== 'codex' ||
|
||||
(limits.rateLimitResetCredits?.nextExpiresAt !== undefined &&
|
||||
limits.rateLimitResetCredits.nextExpiresAt !== null)
|
||||
@@ -373,6 +400,9 @@ function mapRpcWindow(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise<ProviderRateLimits> {
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedCodexRateLimitResult()
|
||||
}
|
||||
return new Promise<ProviderRateLimits>((resolve) => {
|
||||
let buffer = ''
|
||||
let stderr = ''
|
||||
@@ -396,6 +426,7 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise<Provi
|
||||
: getSpawnArgsForWindows(codexCommand, codexArgs)
|
||||
const child = spawn(spawnCmd, spawnArgs, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
cwd: resolveHiddenRateLimitPtyCwd(),
|
||||
// Why: the selected Codex rate-limit account must only affect this fetch
|
||||
// subprocess. Never mutate process.env globally or other Codex features
|
||||
// would inherit the managed account unintentionally.
|
||||
@@ -416,6 +447,7 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise<Provi
|
||||
clearTimeout(timeout)
|
||||
timeout = null
|
||||
}
|
||||
options?.signal?.removeEventListener('abort', onAbort)
|
||||
child.stdout.off('data', onStdoutData)
|
||||
child.stderr.off('data', onStderrData)
|
||||
child.off('error', onError)
|
||||
@@ -434,6 +466,18 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise<Provi
|
||||
resolve(result)
|
||||
}
|
||||
|
||||
function onAbort(): void {
|
||||
settle(abortedCodexRateLimitResult(), { kill: true })
|
||||
}
|
||||
|
||||
if (options?.signal) {
|
||||
if (options.signal.aborted) {
|
||||
onAbort()
|
||||
return
|
||||
}
|
||||
options.signal.addEventListener('abort', onAbort, { once: true })
|
||||
}
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
settle(
|
||||
{
|
||||
@@ -632,7 +676,13 @@ function parsePtyStatus(output: string): {
|
||||
}
|
||||
|
||||
async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise<ProviderRateLimits> {
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedCodexRateLimitResult()
|
||||
}
|
||||
const pty = await import('node-pty')
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedCodexRateLimitResult()
|
||||
}
|
||||
const wslCodex = options?.codexHomePath ? buildWslCodexCommand(options.codexHomePath, []) : null
|
||||
const codexCommand = wslCodex ? 'codex' : resolveCodexCommand()
|
||||
|
||||
@@ -652,20 +702,50 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise<Provi
|
||||
let resolved = false
|
||||
let sentStatus = false
|
||||
let settleTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const term = pty.spawn(spawnFile, spawnArgs, {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: resolveHiddenRateLimitPtyCwd(),
|
||||
env: {
|
||||
...(wslCodex ? cloneProcessEnvWithoutCodexHome() : process.env),
|
||||
TERM: 'xterm-256color',
|
||||
...(options?.codexHomePath && !wslCodex ? { CODEX_HOME: options.codexHomePath } : {})
|
||||
}
|
||||
})
|
||||
const termDisposables: { dispose: () => void }[] = []
|
||||
const termDisposables: { dispose: () => void }[] = [registerHiddenRateLimitPty(term)]
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
function settleAborted(): void {
|
||||
if (resolved) {
|
||||
return
|
||||
}
|
||||
resolved = true
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
timeout = null
|
||||
}
|
||||
if (settleTimer) {
|
||||
clearTimeout(settleTimer)
|
||||
settleTimer = null
|
||||
}
|
||||
cleanupHiddenRateLimitPty(term, termDisposables, { kill: true })
|
||||
resolve(abortedCodexRateLimitResult())
|
||||
}
|
||||
|
||||
if (options?.signal) {
|
||||
if (options.signal.aborted) {
|
||||
settleAborted()
|
||||
return
|
||||
}
|
||||
options.signal.addEventListener('abort', settleAborted, { once: true })
|
||||
termDisposables.push({
|
||||
dispose: () => options.signal?.removeEventListener('abort', settleAborted)
|
||||
})
|
||||
}
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true
|
||||
if (settleTimer) {
|
||||
@@ -709,7 +789,10 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise<Provi
|
||||
return
|
||||
}
|
||||
resolved = true
|
||||
clearTimeout(timeout)
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
timeout = null
|
||||
}
|
||||
cleanupHiddenRateLimitPty(term, termDisposables, { kill: true })
|
||||
|
||||
// eslint-disable-next-line no-control-regex
|
||||
@@ -742,7 +825,10 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise<Provi
|
||||
}
|
||||
if (!resolved) {
|
||||
resolved = true
|
||||
clearTimeout(timeout)
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
timeout = null
|
||||
}
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const clean = output.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
|
||||
const { session, weekly } = parsePtyStatus(clean)
|
||||
@@ -773,6 +859,9 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise<Provi
|
||||
export async function fetchCodexRateLimits(
|
||||
options?: FetchCodexRateLimitsOptions
|
||||
): Promise<ProviderRateLimits> {
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedCodexRateLimitResult()
|
||||
}
|
||||
// Why: never spawn the `codex` binary unless the user has signed in. Without
|
||||
// auth the RPC/PTY paths can only error, and spawning them shows up as an
|
||||
// unexpected background Codex process for users who don't use Codex.
|
||||
@@ -790,8 +879,12 @@ export async function fetchCodexRateLimits(
|
||||
// Path A: try RPC first
|
||||
try {
|
||||
const rpcResult = await fetchViaRpc(options)
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedCodexRateLimitResult()
|
||||
}
|
||||
if (rpcResult.status === 'ok' || rpcResult.status === 'unavailable') {
|
||||
return await withBackendRateLimitResetCredits(rpcResult, options)
|
||||
const withResetCredits = await withBackendRateLimitResetCredits(rpcResult, options)
|
||||
return options?.signal?.aborted ? abortedCodexRateLimitResult() : withResetCredits
|
||||
}
|
||||
if (isCodexAuthError(rpcResult.error)) {
|
||||
return rpcResult
|
||||
@@ -802,6 +895,9 @@ export async function fetchCodexRateLimits(
|
||||
// Why: app-server can fail independently of the interactive CLI. Keep the
|
||||
// status-bar useful by trying the older /status PTY reader on RPC errors.
|
||||
} catch {
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedCodexRateLimitResult()
|
||||
}
|
||||
if (options?.allowPtyFallback === false) {
|
||||
return {
|
||||
provider: 'codex',
|
||||
@@ -817,8 +913,19 @@ export async function fetchCodexRateLimits(
|
||||
|
||||
// Path B: PTY fallback
|
||||
try {
|
||||
return await withBackendRateLimitResetCredits(await fetchViaPty(options), options)
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedCodexRateLimitResult()
|
||||
}
|
||||
const ptyResult = await fetchViaPty(options)
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedCodexRateLimitResult()
|
||||
}
|
||||
const withResetCredits = await withBackendRateLimitResetCredits(ptyResult, options)
|
||||
return options?.signal?.aborted ? abortedCodexRateLimitResult() : withResetCredits
|
||||
} catch (err) {
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedCodexRateLimitResult()
|
||||
}
|
||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||
const isNotInstalled = message.includes('ENOENT')
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanupHiddenRateLimitPty } from './hidden-pty-cleanup'
|
||||
import {
|
||||
cleanupHiddenRateLimitPty,
|
||||
getActiveHiddenRateLimitPtyCount,
|
||||
registerHiddenRateLimitPty
|
||||
} from './hidden-pty-cleanup'
|
||||
|
||||
function setPlatform(platform: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
@@ -98,4 +102,34 @@ describe('cleanupHiddenRateLimitPty', () => {
|
||||
expect(term.kill).toHaveBeenCalledTimes(1)
|
||||
expect(term.destroy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('removes registered hidden PTYs when cleanup kills them', () => {
|
||||
setPlatform('darwin')
|
||||
const term = {
|
||||
kill: vi.fn(),
|
||||
destroy: vi.fn()
|
||||
}
|
||||
const registration = registerHiddenRateLimitPty(term)
|
||||
|
||||
expect(getActiveHiddenRateLimitPtyCount()).toBe(1)
|
||||
|
||||
cleanupHiddenRateLimitPty(term, [registration], { kill: true })
|
||||
|
||||
expect(getActiveHiddenRateLimitPtyCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('removes registered hidden PTYs after natural exit cleanup', () => {
|
||||
setPlatform('darwin')
|
||||
const term = {
|
||||
kill: vi.fn(),
|
||||
destroy: vi.fn()
|
||||
}
|
||||
const registration = registerHiddenRateLimitPty(term)
|
||||
|
||||
expect(getActiveHiddenRateLimitPtyCount()).toBe(1)
|
||||
|
||||
cleanupHiddenRateLimitPty(term, [registration], { kill: false })
|
||||
|
||||
expect(getActiveHiddenRateLimitPtyCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,21 @@ type Disposable = {
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
const activeHiddenRateLimitPtys = new Set<HiddenPty>()
|
||||
|
||||
export function registerHiddenRateLimitPty(term: HiddenPty): Disposable {
|
||||
activeHiddenRateLimitPtys.add(term)
|
||||
return {
|
||||
dispose: () => {
|
||||
activeHiddenRateLimitPtys.delete(term)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getActiveHiddenRateLimitPtyCount(): number {
|
||||
return activeHiddenRateLimitPtys.size
|
||||
}
|
||||
|
||||
export function cleanupHiddenRateLimitPty(
|
||||
term: HiddenPty,
|
||||
disposables: Disposable[],
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { mkdirSync, realpathSync, statSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { isRootLikePath } from '../providers/pty-path-safety'
|
||||
|
||||
const HIDDEN_RATE_LIMIT_PTY_CWD_DIR = 'rate-limit-pty-cwd'
|
||||
const WSL_RATE_LIMIT_PTY_CWD_DIR = 'orca-rate-limit-pty-cwd'
|
||||
|
||||
// Why: the hidden usage PTY must run in a bounded, never-root directory so
|
||||
// Claude's discovery cannot walk a whole filesystem — reject a root-like user
|
||||
// data path and scope to tmpdir instead (see runaway-cpu-hidden-usage-pty-design.md).
|
||||
function resolveUserDataRoot(userDataPath?: string | null): string {
|
||||
const root = userDataPath?.trim() || process.env.ORCA_USER_DATA_PATH?.trim()
|
||||
if (root && !isRootLikePath(root)) {
|
||||
return root
|
||||
}
|
||||
return join(tmpdir(), 'orca-rate-limit-pty')
|
||||
}
|
||||
|
||||
export function resolveHiddenRateLimitPtyCwd(options?: { userDataPath?: string | null }): string {
|
||||
const cwd = join(resolveUserDataRoot(options?.userDataPath), HIDDEN_RATE_LIMIT_PTY_CWD_DIR)
|
||||
mkdirSync(cwd, { recursive: true })
|
||||
const realCwd = realpathSync(cwd)
|
||||
if (isRootLikePath(realCwd) || !statSync(realCwd).isDirectory()) {
|
||||
throw new Error(`Hidden rate-limit PTY cwd is not a safe directory: ${realCwd}`)
|
||||
}
|
||||
return realCwd
|
||||
}
|
||||
|
||||
export function getHiddenRateLimitWslCwdSetupCommands(): string[] {
|
||||
return [
|
||||
`orca_rate_limit_cwd="\${TMPDIR:-/tmp}/${WSL_RATE_LIMIT_PTY_CWD_DIR}"`,
|
||||
'mkdir -p "$orca_rate_limit_cwd"',
|
||||
'cd "$orca_rate_limit_cwd"'
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { clearStorageDataMock, cookiesSetMock, netFetchMock, sessionFromPartitionMock } = vi.hoisted(
|
||||
() => {
|
||||
const netFetchMock = vi.fn()
|
||||
const cookiesSetMock = vi.fn(() => Promise.resolve())
|
||||
const clearStorageDataMock = vi.fn(() => Promise.resolve())
|
||||
const sessionFromPartitionMock = vi.fn(() => ({
|
||||
clearStorageData: clearStorageDataMock,
|
||||
cookies: { set: cookiesSetMock },
|
||||
fetch: netFetchMock
|
||||
}))
|
||||
return { clearStorageDataMock, cookiesSetMock, netFetchMock, sessionFromPartitionMock }
|
||||
}
|
||||
)
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
net: { fetch: netFetchMock },
|
||||
session: { fromPartition: sessionFromPartitionMock }
|
||||
}))
|
||||
|
||||
import {
|
||||
extractMiniMaxCookieValue,
|
||||
fetchMiniMaxRateLimits,
|
||||
normalizeMiniMaxCookieHeader,
|
||||
redactMiniMaxSecret
|
||||
} from './minimax-fetcher'
|
||||
|
||||
const MINIMAX_URL = 'https://platform.minimax.io/v1/api/openplatform/coding_plan/remains'
|
||||
|
||||
function makeResponse(body: unknown, status = 200): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body
|
||||
} as Response
|
||||
}
|
||||
|
||||
function makeOkPayload(remainingPercent: number): unknown {
|
||||
const now = Date.now()
|
||||
return {
|
||||
base_resp: { status_code: 0, status_msg: 'ok' },
|
||||
model_remains: [
|
||||
{
|
||||
model_name: 'general',
|
||||
current_interval_remaining_percent: remainingPercent,
|
||||
start_time: now - 60_000,
|
||||
end_time: now + 5 * 60 * 60 * 1000,
|
||||
remains_time: 5 * 60 * 60 * 1000
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const FULL_COOKIE =
|
||||
'_token=eyJh.eyJ.payload; _twpid=tw.123; minimax_group_id_v2=12345; platform_cookie_consent=3'
|
||||
|
||||
function getCookieJarSetNames(): string[] {
|
||||
return cookiesSetMock.mock.calls.map((call) => {
|
||||
const [details] = call as unknown as [{ name: string }]
|
||||
return details.name
|
||||
})
|
||||
}
|
||||
|
||||
describe('fetchMiniMaxRateLimits', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-07-04T12:00:00.000Z'))
|
||||
clearStorageDataMock.mockClear()
|
||||
cookiesSetMock.mockClear()
|
||||
netFetchMock.mockReset()
|
||||
sessionFromPartitionMock.mockClear()
|
||||
sessionFromPartitionMock.mockImplementation(() => ({
|
||||
clearStorageData: clearStorageDataMock,
|
||||
cookies: { set: cookiesSetMock },
|
||||
fetch: netFetchMock
|
||||
}))
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns unavailable when cookie is empty', async () => {
|
||||
const result = await fetchMiniMaxRateLimits({ cookie: '' })
|
||||
expect(result.status).toBe('unavailable')
|
||||
expect(result.provider).toBe('minimax')
|
||||
expect(result.session).toBeNull()
|
||||
expect(result.weekly).toBeNull()
|
||||
expect(result.error).toMatch(/not configured/i)
|
||||
expect(netFetchMock).not.toHaveBeenCalled()
|
||||
expect(cookiesSetMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns unavailable when cookie is only whitespace', async () => {
|
||||
const result = await fetchMiniMaxRateLimits({ cookie: ' ' })
|
||||
expect(result.status).toBe('unavailable')
|
||||
expect(netFetchMock).not.toHaveBeenCalled()
|
||||
expect(cookiesSetMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns error when cookie has no _token', async () => {
|
||||
const result = await fetchMiniMaxRateLimits({
|
||||
cookie: '_twpid=tw.123; minimax_group_id_v2=12345'
|
||||
})
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.error).toMatch(/MiniMax auth cookie not found/)
|
||||
expect(netFetchMock).not.toHaveBeenCalled()
|
||||
expect(cookiesSetMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('classifies 401 as stale-token', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(makeResponse({}, 401))
|
||||
const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.usageMetadata?.failureKind).toBe('stale-token')
|
||||
expect(result.error).toMatch(/session expired/i)
|
||||
})
|
||||
|
||||
it('classifies 403 as stale-token', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(makeResponse({}, 403))
|
||||
const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.usageMetadata?.failureKind).toBe('stale-token')
|
||||
})
|
||||
|
||||
it('classifies 500 as server', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(makeResponse({}, 500))
|
||||
const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.usageMetadata?.failureKind).toBe('server')
|
||||
expect(result.error).toMatch(/500/)
|
||||
})
|
||||
|
||||
it('aborts a hung fetch after the timeout and classifies it as network', async () => {
|
||||
netFetchMock.mockImplementation((_url: string, init: { signal: AbortSignal }) => {
|
||||
return new Promise((_resolve, reject) => {
|
||||
const abort = (): void => {
|
||||
const error = new Error('The operation was aborted')
|
||||
error.name = 'AbortError'
|
||||
reject(error)
|
||||
}
|
||||
// The manual-cookie-header fallback receives the already-aborted signal.
|
||||
if (init.signal.aborted) {
|
||||
abort()
|
||||
return
|
||||
}
|
||||
init.signal.addEventListener('abort', abort)
|
||||
})
|
||||
})
|
||||
const promise = fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
|
||||
await vi.advanceTimersByTimeAsync(15_000)
|
||||
const result = await promise
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.usageMetadata?.failureKind).toBe('network')
|
||||
})
|
||||
|
||||
it('falls back to a manual Cookie header when the session cookie jar fetch throws', async () => {
|
||||
netFetchMock
|
||||
.mockRejectedValueOnce(new Error('cookie jar transport boom'))
|
||||
.mockResolvedValueOnce(makeResponse(makeOkPayload(70)))
|
||||
const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
|
||||
expect(result.status).toBe('ok')
|
||||
expect(result.session?.usedPercent).toBe(30)
|
||||
expect(netFetchMock).toHaveBeenCalledTimes(2)
|
||||
// First (session-cookie-jar) transport sends no Cookie header; the fallback does.
|
||||
expect(netFetchMock.mock.calls[0][1].headers.Cookie).toBeUndefined()
|
||||
expect(netFetchMock.mock.calls[1][1].headers.Cookie).toContain('_token=')
|
||||
})
|
||||
|
||||
it('returns ok with session window mapping remaining to usedPercent', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(35)))
|
||||
const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
|
||||
expect(result.status).toBe('ok')
|
||||
expect(result.provider).toBe('minimax')
|
||||
expect(result.session?.usedPercent).toBe(65)
|
||||
expect(result.session?.windowMinutes).toBe(300)
|
||||
expect(result.weekly).toBeNull()
|
||||
})
|
||||
|
||||
it('reports a fixed 5-hour session window regardless of API interval drift', async () => {
|
||||
const startTime = 1_700_000_000_000
|
||||
const endTime = startTime + 295 * 60_000
|
||||
netFetchMock.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
base_resp: { status_code: 0, status_msg: 'ok' },
|
||||
model_remains: [
|
||||
{
|
||||
model_name: 'general',
|
||||
current_interval_remaining_percent: 60,
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
remains_time: endTime - startTime
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
|
||||
expect(result.status).toBe('ok')
|
||||
expect(result.session?.windowMinutes).toBe(300)
|
||||
expect(result.session?.resetsAt).toBe(endTime)
|
||||
})
|
||||
|
||||
it('does not derive the windowMinutes label from the raw start/end interval', async () => {
|
||||
const startTime = 1_700_000_000_000
|
||||
const endTime = startTime + 4 * 60 * 60_000
|
||||
netFetchMock.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
base_resp: { status_code: 0, status_msg: 'ok' },
|
||||
model_remains: [
|
||||
{
|
||||
model_name: 'general',
|
||||
current_interval_remaining_percent: 63,
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
remains_time: endTime - startTime
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
|
||||
expect(result.status).toBe('ok')
|
||||
expect(result.session?.windowMinutes).toBe(300)
|
||||
expect(result.session?.usedPercent).toBe(37)
|
||||
expect(result.session?.resetsAt).toBe(endTime)
|
||||
})
|
||||
|
||||
it('sets the cookie jar and sends browser-like request headers', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(80)))
|
||||
await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
|
||||
expect(sessionFromPartitionMock).toHaveBeenCalledWith('orca-minimax-rate-limit-fetch')
|
||||
expect(clearStorageDataMock).toHaveBeenCalledWith({
|
||||
origin: 'https://platform.minimax.io',
|
||||
storages: ['cookies']
|
||||
})
|
||||
expect(getCookieJarSetNames()).toEqual([
|
||||
'_token',
|
||||
'_twpid',
|
||||
'minimax_group_id_v2',
|
||||
'platform_cookie_consent'
|
||||
])
|
||||
expect(netFetchMock).toHaveBeenCalledTimes(1)
|
||||
const [url, init] = netFetchMock.mock.calls[0]
|
||||
expect(url).toBe(MINIMAX_URL)
|
||||
expect(init.method).toBe('GET')
|
||||
expect(init.headers.Cookie).toBeUndefined()
|
||||
expect(init.headers['X-Group-Id']).toBe('12345')
|
||||
expect(init.headers.Referer).toBe('https://platform.minimax.io/console/usage')
|
||||
expect(init.headers.Accept).toMatch(/application\/json/)
|
||||
expect(init.headers['Accept-Language']).toBe('en-US,en;q=0.9')
|
||||
expect(init.headers['Sec-Fetch-Dest']).toBeUndefined()
|
||||
expect(init.headers['Sec-Fetch-Mode']).toBeUndefined()
|
||||
expect(init.headers['Sec-Fetch-Site']).toBeUndefined()
|
||||
expect(init.headers['User-Agent']).toMatch(/^Mozilla\/5\.0/)
|
||||
expect(init.headers['User-Agent']).not.toContain('orca-minimax-usage')
|
||||
})
|
||||
|
||||
it('accepts quoted MiniMax cookie storage syntax', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(80)))
|
||||
await fetchMiniMaxRateLimits({
|
||||
cookie: '_token:"jwt-token" minimax_group_id_v2:"42"'
|
||||
})
|
||||
const [, init] = netFetchMock.mock.calls[0]
|
||||
expect(getCookieJarSetNames()).toEqual(['_token', 'minimax_group_id_v2'])
|
||||
expect(init.headers.Cookie).toBeUndefined()
|
||||
expect(init.headers['X-Group-Id']).toBe('42')
|
||||
})
|
||||
|
||||
it('preserves the complete browser Cookie header', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(80)))
|
||||
const fullBrowserCookie = [
|
||||
'platform_cookie_consent=3',
|
||||
'_ga=analytics',
|
||||
'_token=tok',
|
||||
'_twpid=tw.1',
|
||||
'ak_bmsc=ak',
|
||||
'bm_sv=sv',
|
||||
'bm_sz=sz',
|
||||
'_abck=ab',
|
||||
'minimax_group_id_v2=42',
|
||||
'sensorsdata2015jssdkcross=analytics'
|
||||
].join('; ')
|
||||
await fetchMiniMaxRateLimits({
|
||||
cookie: fullBrowserCookie
|
||||
})
|
||||
const [, init] = netFetchMock.mock.calls[0]
|
||||
expect(getCookieJarSetNames()).toEqual([
|
||||
'platform_cookie_consent',
|
||||
'_ga',
|
||||
'_token',
|
||||
'_twpid',
|
||||
'ak_bmsc',
|
||||
'bm_sv',
|
||||
'bm_sz',
|
||||
'_abck',
|
||||
'minimax_group_id_v2',
|
||||
'sensorsdata2015jssdkcross'
|
||||
])
|
||||
expect(init.headers.Cookie).toBeUndefined()
|
||||
})
|
||||
|
||||
it('prefers explicit groupId over cookie value', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(80)))
|
||||
await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE, groupId: 'override-id' })
|
||||
expect(netFetchMock.mock.calls[0][1].headers['X-Group-Id']).toBe('override-id')
|
||||
})
|
||||
|
||||
it('falls back to cookie minimax_group_id_v2 when groupId is empty', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(80)))
|
||||
await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE, groupId: '' })
|
||||
expect(netFetchMock.mock.calls[0][1].headers['X-Group-Id']).toBe('12345')
|
||||
})
|
||||
|
||||
it('surfaces base_resp status_code != 0 as usage-unavailable', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(
|
||||
makeResponse({ base_resp: { status_code: 401, status_msg: 'unauth' } })
|
||||
)
|
||||
const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.usageMetadata?.failureKind).toBe('usage-unavailable')
|
||||
expect(result.error).toContain('unauth')
|
||||
})
|
||||
|
||||
it('classifies malformed MiniMax JSON responses as parse failures', async () => {
|
||||
netFetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => {
|
||||
throw new SyntaxError('Unexpected token <')
|
||||
}
|
||||
} as unknown as Response)
|
||||
const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.usageMetadata?.failureKind).toBe('parse')
|
||||
expect(result.error).toContain('Unexpected token')
|
||||
})
|
||||
|
||||
it('returns error when model_remains is empty', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(
|
||||
makeResponse({ base_resp: { status_code: 0 }, model_remains: [] })
|
||||
)
|
||||
const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.usageMetadata?.failureKind).toBe('usage-unavailable')
|
||||
expect(result.error).toMatch(/configured model was not found/i)
|
||||
})
|
||||
|
||||
it('returns error when configured model is not in response', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(
|
||||
makeResponse({ base_resp: { status_code: 0 }, model_remains: [] })
|
||||
)
|
||||
const result = await fetchMiniMaxRateLimits({
|
||||
cookie: FULL_COOKIE,
|
||||
models: 'unrelated-model'
|
||||
})
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.error).toMatch(/configured model was not found/i)
|
||||
})
|
||||
|
||||
it('falls back to the lone snapshot when no configured model matches', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(40)))
|
||||
const result = await fetchMiniMaxRateLimits({
|
||||
cookie: FULL_COOKIE,
|
||||
models: 'unrelated-model'
|
||||
})
|
||||
expect(result.status).toBe('ok')
|
||||
expect(result.session?.usedPercent).toBe(60)
|
||||
})
|
||||
|
||||
it('selects the first configured model when multiple are listed', async () => {
|
||||
const payload = makeOkPayload(40)
|
||||
;(payload as { model_remains: unknown[] }).model_remains = [
|
||||
{
|
||||
model_name: 'unrelated',
|
||||
current_interval_remaining_percent: 10,
|
||||
start_time: Date.now() - 60_000,
|
||||
end_time: Date.now() + 5 * 60 * 60 * 1000,
|
||||
remains_time: 5 * 60 * 60 * 1000
|
||||
},
|
||||
{
|
||||
model_name: 'general',
|
||||
current_interval_remaining_percent: 40,
|
||||
start_time: Date.now() - 60_000,
|
||||
end_time: Date.now() + 5 * 60 * 60 * 1000,
|
||||
remains_time: 5 * 60 * 60 * 1000
|
||||
}
|
||||
]
|
||||
netFetchMock.mockResolvedValueOnce(makeResponse(payload))
|
||||
const result = await fetchMiniMaxRateLimits({
|
||||
cookie: FULL_COOKIE,
|
||||
models: 'general'
|
||||
})
|
||||
expect(result.status).toBe('ok')
|
||||
expect(result.session?.usedPercent).toBe(60)
|
||||
})
|
||||
|
||||
it('treats a blank model list as the default general model', async () => {
|
||||
const payload = makeOkPayload(25)
|
||||
;(payload as { model_remains: unknown[] }).model_remains = [
|
||||
{
|
||||
model_name: 'unrelated',
|
||||
current_interval_remaining_percent: 10,
|
||||
start_time: Date.now() - 60_000,
|
||||
end_time: Date.now() + 5 * 60 * 60 * 1000,
|
||||
remains_time: 5 * 60 * 60 * 1000
|
||||
},
|
||||
{
|
||||
model_name: 'general',
|
||||
current_interval_remaining_percent: 25,
|
||||
start_time: Date.now() - 60_000,
|
||||
end_time: Date.now() + 5 * 60 * 60 * 1000,
|
||||
remains_time: 5 * 60 * 60 * 1000
|
||||
}
|
||||
]
|
||||
netFetchMock.mockResolvedValueOnce(makeResponse(payload))
|
||||
const result = await fetchMiniMaxRateLimits({
|
||||
cookie: FULL_COOKIE,
|
||||
models: ' '
|
||||
})
|
||||
expect(result.status).toBe('ok')
|
||||
expect(result.session?.usedPercent).toBe(75)
|
||||
})
|
||||
|
||||
it('redacts _token in any error path that includes payload text', async () => {
|
||||
const payload = { base_resp: { status_code: 7, status_msg: FULL_COOKIE } }
|
||||
netFetchMock.mockResolvedValueOnce(makeResponse(payload))
|
||||
const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
|
||||
expect(result.error).not.toContain('eyJh')
|
||||
expect(result.error).not.toContain('minimax_group_id_v2=12345')
|
||||
expect(result.error).toContain('[REDACTED]')
|
||||
})
|
||||
|
||||
it('logs MiniMax failures with cookie names but without cookie values', async () => {
|
||||
const warn = vi.mocked(console.warn)
|
||||
netFetchMock.mockResolvedValueOnce(
|
||||
makeResponse({ base_resp: { status_code: 7, status_msg: FULL_COOKIE } })
|
||||
)
|
||||
await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[minimax] usage fetch failed',
|
||||
expect.objectContaining({
|
||||
baseRespStatusMsg: expect.not.stringContaining('eyJh'),
|
||||
cookieNames: ['_token', '_twpid', 'minimax_group_id_v2', 'platform_cookie_consent']
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeMiniMaxCookieHeader', () => {
|
||||
it('preserves all cookie pairs from a browser Cookie header', () => {
|
||||
const normalized = normalizeMiniMaxCookieHeader(
|
||||
'_token=tok; session=other; ak_bmsc=ak; minimax_group_id_v2=42; random=xyz'
|
||||
)
|
||||
expect(normalized).toBe(
|
||||
'_token=tok; session=other; ak_bmsc=ak; minimax_group_id_v2=42; random=xyz'
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes quoted MiniMax cookie storage syntax', () => {
|
||||
expect(normalizeMiniMaxCookieHeader('_token:"tok" minimax_group_id_v2:"42"')).toBe(
|
||||
'_token=tok; minimax_group_id_v2=42'
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts a copied Cookie header line', () => {
|
||||
expect(normalizeMiniMaxCookieHeader('Cookie: session=abc; other=xyz')).toBe(
|
||||
'session=abc; other=xyz'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractMiniMaxCookieValue', () => {
|
||||
it('returns the value for a given cookie name', () => {
|
||||
expect(extractMiniMaxCookieValue(FULL_COOKIE, 'minimax_group_id_v2')).toBe('12345')
|
||||
})
|
||||
it('returns null when the name is absent', () => {
|
||||
expect(extractMiniMaxCookieValue('_token=tok', 'minimax_group_id_v2')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('redactMiniMaxSecret', () => {
|
||||
it('redacts _token values', () => {
|
||||
expect(redactMiniMaxSecret('cookie _token=eyJhABCDEF')).toContain('_token=[REDACTED]')
|
||||
expect(redactMiniMaxSecret('cookie _token=eyJhABCDEF')).not.toContain('eyJhABCDEF')
|
||||
})
|
||||
it('redacts minimax_group_id_v2 values', () => {
|
||||
expect(redactMiniMaxSecret('minimax_group_id_v2=99999 trailing')).not.toContain('99999')
|
||||
})
|
||||
it('redacts MiniMax anti-bot cookie values', () => {
|
||||
const redacted = redactMiniMaxSecret('ak_bmsc=secret bm_sv:"secret2"')
|
||||
expect(redacted).not.toContain('secret')
|
||||
expect(redacted).toContain('ak_bmsc=[REDACTED]')
|
||||
expect(redacted).toContain('bm_sv:[REDACTED]')
|
||||
})
|
||||
it('redacts Cookie: header lines', () => {
|
||||
expect(redactMiniMaxSecret('X-Cookie: _token=secret')).toContain('[REDACTED]')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,283 @@
|
||||
import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types'
|
||||
import {
|
||||
extractMiniMaxCookieValue,
|
||||
fetchMiniMaxWithManualCookieHeader,
|
||||
fetchMiniMaxWithSessionCookieJar,
|
||||
getUniqueMiniMaxCookieNames,
|
||||
logMiniMaxFetchFailure,
|
||||
makeMiniMaxRequestHeaders,
|
||||
MINIMAX_USAGE_ENDPOINT,
|
||||
normalizeMiniMaxCookieHeader,
|
||||
redactMiniMaxSecret,
|
||||
type MiniMaxFetchResponse
|
||||
} from './minimax-request-context'
|
||||
|
||||
export {
|
||||
extractMiniMaxCookieValue,
|
||||
normalizeMiniMaxCookieHeader,
|
||||
redactMiniMaxSecret
|
||||
} from './minimax-request-context'
|
||||
|
||||
const API_TIMEOUT_MS = 15_000
|
||||
|
||||
type MiniMaxUsageItem = {
|
||||
model_name?: unknown
|
||||
current_interval_remaining_percent?: unknown
|
||||
start_time?: unknown
|
||||
end_time?: unknown
|
||||
remains_time?: unknown
|
||||
}
|
||||
|
||||
type MiniMaxUsageResponse = {
|
||||
base_resp?: {
|
||||
status_code?: unknown
|
||||
status_msg?: unknown
|
||||
}
|
||||
model_remains?: MiniMaxUsageItem[]
|
||||
}
|
||||
|
||||
type MiniMaxUsageSnapshot = {
|
||||
modelName: string
|
||||
window: RateLimitWindow
|
||||
}
|
||||
|
||||
export type FetchMiniMaxRateLimitsOptions = {
|
||||
cookie: string
|
||||
groupId?: string | null
|
||||
models?: string | readonly string[] | null
|
||||
endpoint?: string
|
||||
}
|
||||
|
||||
function clampPercent(value: number): number {
|
||||
return Math.max(0, Math.min(100, Math.round(value)))
|
||||
}
|
||||
|
||||
function makeUnavailable(error: string): ProviderRateLimits {
|
||||
return {
|
||||
provider: 'minimax',
|
||||
session: null,
|
||||
weekly: null,
|
||||
updatedAt: Date.now(),
|
||||
error,
|
||||
status: 'unavailable',
|
||||
usageMetadata: { failureKind: 'missing-credentials', source: 'web' }
|
||||
}
|
||||
}
|
||||
|
||||
function makeError(
|
||||
error: string,
|
||||
failureKind: NonNullable<ProviderRateLimits['usageMetadata']>['failureKind']
|
||||
): ProviderRateLimits {
|
||||
return {
|
||||
provider: 'minimax',
|
||||
session: null,
|
||||
weekly: null,
|
||||
updatedAt: Date.now(),
|
||||
error,
|
||||
status: 'error',
|
||||
usageMetadata: { failureKind, source: 'web' }
|
||||
}
|
||||
}
|
||||
|
||||
function parseModels(models: FetchMiniMaxRateLimitsOptions['models']): string[] {
|
||||
if (Array.isArray(models)) {
|
||||
const parsed = models.map((model) => model.trim()).filter(Boolean)
|
||||
return parsed.length > 0 ? parsed : ['general']
|
||||
}
|
||||
if (typeof models === 'string') {
|
||||
const parsed = models
|
||||
.split(',')
|
||||
.map((model) => model.trim())
|
||||
.filter(Boolean)
|
||||
return parsed.length > 0 ? parsed : ['general']
|
||||
}
|
||||
return ['general']
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Why: MiniMax's API returns `end_time - start_time` that can drift below the
|
||||
// 5-hour bucket (e.g. 4h or 295 min). The UI labels must reflect the contracted
|
||||
// session — a fixed 5-hour window — so the status bar reads "5h" regardless of
|
||||
// what the API reports. Mirrors how Codex always reports 300/10080 minutes.
|
||||
const MINIMAX_SESSION_WINDOW_MINUTES = 300
|
||||
|
||||
function parseUsageItem(item: MiniMaxUsageItem): MiniMaxUsageSnapshot | null {
|
||||
const modelName = typeof item.model_name === 'string' ? item.model_name : null
|
||||
const remainingPercent = asNumber(item.current_interval_remaining_percent)
|
||||
const startTime = asNumber(item.start_time)
|
||||
const endTime = asNumber(item.end_time)
|
||||
if (!modelName || remainingPercent === null || startTime === null || endTime === null) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
modelName,
|
||||
window: {
|
||||
usedPercent: clampPercent(100 - remainingPercent),
|
||||
windowMinutes: MINIMAX_SESSION_WINDOW_MINUTES,
|
||||
resetsAt: endTime,
|
||||
resetDescription: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function selectSnapshot(
|
||||
snapshots: MiniMaxUsageSnapshot[],
|
||||
preferredModels: string[]
|
||||
): MiniMaxUsageSnapshot | null {
|
||||
for (const model of preferredModels) {
|
||||
const match = snapshots.find((snapshot) => snapshot.modelName === model)
|
||||
if (match) {
|
||||
return match
|
||||
}
|
||||
}
|
||||
return snapshots.length === 1 ? snapshots[0] : null
|
||||
}
|
||||
|
||||
async function fetchMiniMaxResponse(args: {
|
||||
cookie: string
|
||||
endpoint: string
|
||||
groupId: string | null
|
||||
signal: AbortSignal
|
||||
}): Promise<MiniMaxFetchResponse> {
|
||||
try {
|
||||
return await fetchMiniMaxWithSessionCookieJar(args)
|
||||
} catch (sessionFetchError) {
|
||||
const message =
|
||||
sessionFetchError instanceof Error ? sessionFetchError.message : String(sessionFetchError)
|
||||
console.warn(
|
||||
'[minimax] session cookie jar fetch failed; falling back to manual Cookie header',
|
||||
{
|
||||
error: redactMiniMaxSecret(message),
|
||||
cookieNames: getUniqueMiniMaxCookieNames(args.cookie),
|
||||
requestHeaderNames: Object.keys(makeMiniMaxRequestHeaders(args.groupId))
|
||||
}
|
||||
)
|
||||
return await fetchMiniMaxWithManualCookieHeader(args)
|
||||
}
|
||||
}
|
||||
|
||||
function handleMiniMaxHttpError(fetchResult: MiniMaxFetchResponse): ProviderRateLimits | null {
|
||||
const { response } = fetchResult
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
logMiniMaxFetchFailure({
|
||||
transport: fetchResult.transport,
|
||||
responseStatus: response.status,
|
||||
cookieNames: fetchResult.cookieNames,
|
||||
requestHeaderNames: fetchResult.requestHeaderNames
|
||||
})
|
||||
return makeError(
|
||||
'MiniMax session expired. Replace the MiniMax cookie in Settings.',
|
||||
'stale-token'
|
||||
)
|
||||
}
|
||||
if (!response.ok) {
|
||||
logMiniMaxFetchFailure({
|
||||
transport: fetchResult.transport,
|
||||
responseStatus: response.status,
|
||||
cookieNames: fetchResult.cookieNames,
|
||||
requestHeaderNames: fetchResult.requestHeaderNames
|
||||
})
|
||||
return makeError(`MiniMax usage fetch failed (${response.status})`, 'server')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function handleMiniMaxPayloadError(
|
||||
fetchResult: MiniMaxFetchResponse,
|
||||
payload: MiniMaxUsageResponse
|
||||
): ProviderRateLimits | null {
|
||||
const statusCode = payload.base_resp?.status_code
|
||||
if (statusCode === undefined || statusCode === 0) {
|
||||
return null
|
||||
}
|
||||
logMiniMaxFetchFailure({
|
||||
transport: fetchResult.transport,
|
||||
responseStatus: fetchResult.response.status,
|
||||
statusCode,
|
||||
statusMsg: payload.base_resp?.status_msg,
|
||||
cookieNames: fetchResult.cookieNames,
|
||||
requestHeaderNames: fetchResult.requestHeaderNames
|
||||
})
|
||||
const message =
|
||||
typeof payload.base_resp?.status_msg === 'string'
|
||||
? payload.base_resp.status_msg
|
||||
: 'MiniMax returned an error'
|
||||
return makeError(redactMiniMaxSecret(message), 'usage-unavailable')
|
||||
}
|
||||
|
||||
export async function fetchMiniMaxRateLimits(
|
||||
options: FetchMiniMaxRateLimitsOptions
|
||||
): Promise<ProviderRateLimits> {
|
||||
const rawCookie = options.cookie.trim()
|
||||
if (!rawCookie) {
|
||||
return makeUnavailable('MiniMax session cookie not configured')
|
||||
}
|
||||
const cookie = normalizeMiniMaxCookieHeader(rawCookie)
|
||||
if (!extractMiniMaxCookieValue(cookie, '_token')) {
|
||||
return makeError(
|
||||
'MiniMax auth cookie not found — paste a Cookie header with _token',
|
||||
'missing-credentials'
|
||||
)
|
||||
}
|
||||
const groupId =
|
||||
options.groupId?.trim() || extractMiniMaxCookieValue(cookie, 'minimax_group_id_v2')
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), API_TIMEOUT_MS)
|
||||
try {
|
||||
const fetchResult = await fetchMiniMaxResponse({
|
||||
cookie,
|
||||
endpoint: options.endpoint ?? MINIMAX_USAGE_ENDPOINT,
|
||||
groupId,
|
||||
signal: controller.signal
|
||||
})
|
||||
const httpError = handleMiniMaxHttpError(fetchResult)
|
||||
if (httpError) {
|
||||
return httpError
|
||||
}
|
||||
let payload: MiniMaxUsageResponse
|
||||
try {
|
||||
payload = (await fetchResult.response.json()) as MiniMaxUsageResponse
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Invalid MiniMax usage response'
|
||||
return makeError(redactMiniMaxSecret(message), 'parse')
|
||||
}
|
||||
const payloadError = handleMiniMaxPayloadError(fetchResult, payload)
|
||||
if (payloadError) {
|
||||
return payloadError
|
||||
}
|
||||
const snapshots = (payload.model_remains ?? [])
|
||||
.map(parseUsageItem)
|
||||
.filter((snapshot): snapshot is MiniMaxUsageSnapshot => snapshot !== null)
|
||||
const selected = selectSnapshot(snapshots, parseModels(options.models))
|
||||
if (!selected) {
|
||||
return makeError(
|
||||
'MiniMax usage data for the configured model was not found',
|
||||
'usage-unavailable'
|
||||
)
|
||||
}
|
||||
return {
|
||||
provider: 'minimax',
|
||||
session: selected.window,
|
||||
weekly: null,
|
||||
updatedAt: Date.now(),
|
||||
error: null,
|
||||
status: 'ok',
|
||||
usageMetadata: { source: 'web' }
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown MiniMax usage error'
|
||||
return makeError(redactMiniMaxSecret(message), 'network')
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { clearStorageDataMock, cookiesSetMock, netFetchMock, sessionFromPartitionMock } = vi.hoisted(
|
||||
() => {
|
||||
const netFetchMock = vi.fn()
|
||||
const cookiesSetMock = vi.fn(() => Promise.resolve())
|
||||
const clearStorageDataMock = vi.fn(() => Promise.resolve())
|
||||
const sessionFromPartitionMock = vi.fn(() => ({
|
||||
clearStorageData: clearStorageDataMock,
|
||||
cookies: { set: cookiesSetMock },
|
||||
fetch: netFetchMock
|
||||
}))
|
||||
return { clearStorageDataMock, cookiesSetMock, netFetchMock, sessionFromPartitionMock }
|
||||
}
|
||||
)
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
net: { fetch: netFetchMock },
|
||||
session: { fromPartition: sessionFromPartitionMock }
|
||||
}))
|
||||
|
||||
import {
|
||||
clearMiniMaxSessionCookieJar,
|
||||
extractMiniMaxCookieValue,
|
||||
fetchMiniMaxWithManualCookieHeader,
|
||||
fetchMiniMaxWithSessionCookieJar,
|
||||
getUniqueMiniMaxCookieNames,
|
||||
logMiniMaxFetchFailure,
|
||||
makeMiniMaxRequestHeaders,
|
||||
MINIMAX_USAGE_ENDPOINT,
|
||||
normalizeMiniMaxCookieHeader,
|
||||
redactMiniMaxSecret
|
||||
} from './minimax-request-context'
|
||||
|
||||
const FULL_COOKIE =
|
||||
'_token=eyJh.eyJ.payload; _twpid=tw.123; minimax_group_id_v2=12345; platform_cookie_consent=3'
|
||||
|
||||
describe('normalizeMiniMaxCookieHeader', () => {
|
||||
it('preserves all cookie pairs from a browser Cookie header', () => {
|
||||
const normalized = normalizeMiniMaxCookieHeader(
|
||||
'_token=tok; session=other; ak_bmsc=ak; minimax_group_id_v2=42; random=xyz'
|
||||
)
|
||||
expect(normalized).toBe(
|
||||
'_token=tok; session=other; ak_bmsc=ak; minimax_group_id_v2=42; random=xyz'
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes quoted MiniMax cookie storage syntax', () => {
|
||||
expect(normalizeMiniMaxCookieHeader('_token:"tok" minimax_group_id_v2:"42"')).toBe(
|
||||
'_token=tok; minimax_group_id_v2=42'
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts a copied Cookie header line with prefix', () => {
|
||||
expect(normalizeMiniMaxCookieHeader('Cookie: session=abc; other=xyz')).toBe(
|
||||
'session=abc; other=xyz'
|
||||
)
|
||||
})
|
||||
|
||||
it('merges header and quoted forms without dropping any pair', () => {
|
||||
const mixed = '_token=header-quoted _twpid:"q-1"; minimax_group_id_v2=42'
|
||||
const normalized = normalizeMiniMaxCookieHeader(mixed)
|
||||
expect(normalized).toContain('_token=header-quoted')
|
||||
expect(normalized).toContain('_twpid=q-1')
|
||||
expect(normalized).toContain('minimax_group_id_v2=42')
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractMiniMaxCookieValue', () => {
|
||||
it('returns the value for a known name', () => {
|
||||
expect(extractMiniMaxCookieValue(FULL_COOKIE, '_token')).toBe('eyJh.eyJ.payload')
|
||||
})
|
||||
|
||||
it('returns null when the name is absent', () => {
|
||||
expect(extractMiniMaxCookieValue('_token=tok', 'minimax_group_id_v2')).toBeNull()
|
||||
})
|
||||
|
||||
it('handles Chromium quoted syntax', () => {
|
||||
expect(
|
||||
extractMiniMaxCookieValue('_token:"jwt" minimax_group_id_v2:"42"', 'minimax_group_id_v2')
|
||||
).toBe('42')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getUniqueMiniMaxCookieNames', () => {
|
||||
it('deduplicates repeated names', () => {
|
||||
const names = getUniqueMiniMaxCookieNames(
|
||||
'_token=a; _token=b; minimax_group_id_v2=42; minimax_group_id_v2=99'
|
||||
)
|
||||
expect(names).toEqual(['_token', 'minimax_group_id_v2'])
|
||||
})
|
||||
|
||||
it('returns names from a quoted-only Cookie storage export', () => {
|
||||
const names = getUniqueMiniMaxCookieNames(
|
||||
'_token:"jwt" minimax_group_id_v2:"42" platform_cookie_consent:"3"'
|
||||
)
|
||||
expect(names).toEqual(
|
||||
expect.arrayContaining(['_token', 'minimax_group_id_v2', 'platform_cookie_consent'])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('redactMiniMaxSecret', () => {
|
||||
it('redacts _token values in header and quoted syntax', () => {
|
||||
const redacted = redactMiniMaxSecret('cookie _token=eyJhABCDEF and _token:"x.y"')
|
||||
expect(redacted).toContain('_token=[REDACTED]')
|
||||
expect(redacted).not.toContain('eyJhABCDEF')
|
||||
expect(redacted).not.toContain('x.y')
|
||||
})
|
||||
|
||||
it('redacts quoted values with whitespace around the colon', () => {
|
||||
const redacted = redactMiniMaxSecret('_token : "spaced.secret"')
|
||||
expect(redacted).not.toContain('spaced.secret')
|
||||
expect(redacted).toContain('_token:[REDACTED]')
|
||||
})
|
||||
|
||||
it('redacts minimax_group_id_v2', () => {
|
||||
expect(redactMiniMaxSecret('minimax_group_id_v2=99999 trailing')).not.toContain('99999')
|
||||
})
|
||||
|
||||
it('redacts MiniMax anti-bot cookie values', () => {
|
||||
const redacted = redactMiniMaxSecret('ak_bmsc=zzzsecret bm_sv:"yyyvalue" _abck="xxxdata"')
|
||||
expect(redacted).not.toContain('zzzsecret')
|
||||
expect(redacted).not.toContain('yyyvalue')
|
||||
expect(redacted).not.toContain('xxxdata')
|
||||
expect(redacted).toContain('ak_bmsc=[REDACTED]')
|
||||
expect(redacted).toContain('bm_sv:[REDACTED]')
|
||||
expect(redacted).toContain('_abck=[REDACTED]')
|
||||
})
|
||||
|
||||
it('redacts full Cookie: header lines', () => {
|
||||
const redacted = redactMiniMaxSecret('Header line\nCookie: _token=secret\nFooter')
|
||||
expect(redacted).toContain('Cookie: [REDACTED]')
|
||||
expect(redacted).not.toContain('Cookie: _token=secret')
|
||||
})
|
||||
})
|
||||
|
||||
describe('makeMiniMaxRequestHeaders', () => {
|
||||
it('always includes browser-like Accept, Accept-Language, Referer, and User-Agent', () => {
|
||||
const headers = makeMiniMaxRequestHeaders(null)
|
||||
expect(headers.Accept).toMatch(/application\/json/)
|
||||
expect(headers['Accept-Language']).toBe('en-US,en;q=0.9')
|
||||
expect(headers.Referer).toBe('https://platform.minimax.io/console/usage')
|
||||
expect(headers['User-Agent']).toMatch(/^Mozilla\/5\.0/)
|
||||
expect(headers['User-Agent']).not.toContain('orca-minimax-usage')
|
||||
})
|
||||
|
||||
it('omits X-Group-Id when groupId is null', () => {
|
||||
const headers = makeMiniMaxRequestHeaders(null)
|
||||
expect(headers['X-Group-Id']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('omits X-Group-Id when groupId is empty string', () => {
|
||||
const headers = makeMiniMaxRequestHeaders('')
|
||||
expect(headers['X-Group-Id']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('includes X-Group-Id when groupId is provided', () => {
|
||||
const headers = makeMiniMaxRequestHeaders('2034972027806299092')
|
||||
expect(headers['X-Group-Id']).toBe('2034972027806299092')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchMiniMaxWithSessionCookieJar', () => {
|
||||
beforeEach(() => {
|
||||
clearStorageDataMock.mockClear()
|
||||
cookiesSetMock.mockClear()
|
||||
netFetchMock.mockReset()
|
||||
sessionFromPartitionMock.mockClear()
|
||||
sessionFromPartitionMock.mockImplementation(() => ({
|
||||
clearStorageData: clearStorageDataMock,
|
||||
cookies: { set: cookiesSetMock },
|
||||
fetch: netFetchMock
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('uses a dedicated MiniMax partition and clears cookies before and after fetching', async () => {
|
||||
netFetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ base_resp: { status_code: 0 }, model_remains: [] })
|
||||
})
|
||||
const controller = new AbortController()
|
||||
await fetchMiniMaxWithSessionCookieJar({
|
||||
cookie: FULL_COOKIE,
|
||||
endpoint: MINIMAX_USAGE_ENDPOINT,
|
||||
groupId: '12345',
|
||||
signal: controller.signal
|
||||
})
|
||||
expect(sessionFromPartitionMock).toHaveBeenCalledWith('orca-minimax-rate-limit-fetch')
|
||||
expect(clearStorageDataMock).toHaveBeenCalledTimes(2)
|
||||
expect(clearStorageDataMock).toHaveBeenNthCalledWith(1, {
|
||||
origin: 'https://platform.minimax.io',
|
||||
storages: ['cookies']
|
||||
})
|
||||
expect(clearStorageDataMock).toHaveBeenNthCalledWith(2, {
|
||||
origin: 'https://platform.minimax.io',
|
||||
storages: ['cookies']
|
||||
})
|
||||
})
|
||||
|
||||
it('attempts the final session cleanup when the pre-fetch clear rejects', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
clearStorageDataMock
|
||||
.mockRejectedValueOnce(new Error('pre-clear boom'))
|
||||
.mockResolvedValueOnce(undefined)
|
||||
const controller = new AbortController()
|
||||
|
||||
await expect(
|
||||
fetchMiniMaxWithSessionCookieJar({
|
||||
cookie: FULL_COOKIE,
|
||||
endpoint: MINIMAX_USAGE_ENDPOINT,
|
||||
groupId: '12345',
|
||||
signal: controller.signal
|
||||
})
|
||||
).rejects.toThrow('pre-clear boom')
|
||||
|
||||
expect(clearStorageDataMock).toHaveBeenCalledTimes(2)
|
||||
expect(clearStorageDataMock).toHaveBeenNthCalledWith(1, {
|
||||
origin: 'https://platform.minimax.io',
|
||||
storages: ['cookies']
|
||||
})
|
||||
expect(clearStorageDataMock).toHaveBeenNthCalledWith(2, {
|
||||
origin: 'https://platform.minimax.io',
|
||||
storages: ['cookies']
|
||||
})
|
||||
expect(cookiesSetMock).not.toHaveBeenCalled()
|
||||
expect(netFetchMock).not.toHaveBeenCalled()
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears the dedicated MiniMax partition on demand', async () => {
|
||||
await clearMiniMaxSessionCookieJar()
|
||||
expect(sessionFromPartitionMock).toHaveBeenCalledWith('orca-minimax-rate-limit-fetch')
|
||||
expect(clearStorageDataMock).toHaveBeenCalledWith({
|
||||
origin: 'https://platform.minimax.io',
|
||||
storages: ['cookies']
|
||||
})
|
||||
})
|
||||
|
||||
it('sets every cookie pair onto the session jar with secure + path /', async () => {
|
||||
netFetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ base_resp: { status_code: 0 }, model_remains: [] })
|
||||
})
|
||||
const controller = new AbortController()
|
||||
await fetchMiniMaxWithSessionCookieJar({
|
||||
cookie: '_token=tok; ak_bmsc=ak; minimax_group_id_v2=42',
|
||||
endpoint: MINIMAX_USAGE_ENDPOINT,
|
||||
groupId: null,
|
||||
signal: controller.signal
|
||||
})
|
||||
expect(cookiesSetMock).toHaveBeenCalledTimes(3)
|
||||
const setDetails = cookiesSetMock.mock.calls.map((call) => {
|
||||
const [details] = call as unknown as [
|
||||
{ name: string; value: string; secure: boolean; path: string }
|
||||
]
|
||||
return details
|
||||
})
|
||||
expect(setDetails).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: '_token', value: 'tok', secure: true, path: '/' }),
|
||||
expect.objectContaining({ name: 'ak_bmsc', value: 'ak', secure: true, path: '/' }),
|
||||
expect.objectContaining({
|
||||
name: 'minimax_group_id_v2',
|
||||
value: '42',
|
||||
secure: true,
|
||||
path: '/'
|
||||
})
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('reports the transport name as session-cookie-jar on success', async () => {
|
||||
netFetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ base_resp: { status_code: 0 }, model_remains: [] })
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const result = await fetchMiniMaxWithSessionCookieJar({
|
||||
cookie: FULL_COOKIE,
|
||||
endpoint: MINIMAX_USAGE_ENDPOINT,
|
||||
groupId: '12345',
|
||||
signal: controller.signal
|
||||
})
|
||||
expect(result.transport).toBe('session-cookie-jar')
|
||||
expect(result.cookieNames).toEqual([
|
||||
'_token',
|
||||
'_twpid',
|
||||
'minimax_group_id_v2',
|
||||
'platform_cookie_consent'
|
||||
])
|
||||
expect(result.requestHeaderNames).toContain('X-Group-Id')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchMiniMaxWithManualCookieHeader', () => {
|
||||
beforeEach(() => {
|
||||
clearStorageDataMock.mockClear()
|
||||
cookiesSetMock.mockClear()
|
||||
netFetchMock.mockReset()
|
||||
sessionFromPartitionMock.mockClear()
|
||||
sessionFromPartitionMock.mockImplementation(() => ({
|
||||
clearStorageData: clearStorageDataMock,
|
||||
cookies: { set: cookiesSetMock },
|
||||
fetch: netFetchMock
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('attaches the full Cookie header and X-Group-Id via the dedicated partition fetch', async () => {
|
||||
netFetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ base_resp: { status_code: 0 }, model_remains: [] })
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const result = await fetchMiniMaxWithManualCookieHeader({
|
||||
cookie: FULL_COOKIE,
|
||||
endpoint: MINIMAX_USAGE_ENDPOINT,
|
||||
groupId: '12345',
|
||||
signal: controller.signal
|
||||
})
|
||||
expect(result.transport).toBe('manual-cookie-header')
|
||||
expect(sessionFromPartitionMock).toHaveBeenCalledWith('orca-minimax-rate-limit-fetch')
|
||||
expect(clearStorageDataMock).toHaveBeenCalledTimes(2)
|
||||
expect(cookiesSetMock).not.toHaveBeenCalled()
|
||||
expect(netFetchMock).toHaveBeenCalledTimes(1)
|
||||
const [url, init] = netFetchMock.mock.calls[0]
|
||||
expect(url).toBe(MINIMAX_USAGE_ENDPOINT)
|
||||
expect(init.method).toBe('GET')
|
||||
expect(init.headers.Cookie).toBe(FULL_COOKIE)
|
||||
expect(init.headers['X-Group-Id']).toBe('12345')
|
||||
})
|
||||
|
||||
it('omits X-Group-Id when groupId is null', async () => {
|
||||
netFetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ base_resp: { status_code: 0 }, model_remains: [] })
|
||||
})
|
||||
const controller = new AbortController()
|
||||
await fetchMiniMaxWithManualCookieHeader({
|
||||
cookie: FULL_COOKIE,
|
||||
endpoint: MINIMAX_USAGE_ENDPOINT,
|
||||
groupId: null,
|
||||
signal: controller.signal
|
||||
})
|
||||
const [, init] = netFetchMock.mock.calls[0]
|
||||
expect(init.headers['X-Group-Id']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('normalizes copied Cookie prefix and quoted syntax before sending manual header', async () => {
|
||||
netFetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ base_resp: { status_code: 0 }, model_remains: [] })
|
||||
})
|
||||
const controller = new AbortController()
|
||||
await fetchMiniMaxWithManualCookieHeader({
|
||||
cookie: 'Cookie: _token=tok; minimax_group_id_v2=42; _twpid:"tw"',
|
||||
endpoint: MINIMAX_USAGE_ENDPOINT,
|
||||
groupId: null,
|
||||
signal: controller.signal
|
||||
})
|
||||
const [, init] = netFetchMock.mock.calls[0]
|
||||
expect(init.headers.Cookie).toBe('_token=tok; minimax_group_id_v2=42; _twpid=tw')
|
||||
})
|
||||
|
||||
it('attempts the final cleanup when the manual fallback pre-fetch clear rejects', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
clearStorageDataMock
|
||||
.mockRejectedValueOnce(new Error('manual pre-clear boom'))
|
||||
.mockResolvedValueOnce(undefined)
|
||||
const controller = new AbortController()
|
||||
|
||||
await expect(
|
||||
fetchMiniMaxWithManualCookieHeader({
|
||||
cookie: FULL_COOKIE,
|
||||
endpoint: MINIMAX_USAGE_ENDPOINT,
|
||||
groupId: '12345',
|
||||
signal: controller.signal
|
||||
})
|
||||
).rejects.toThrow('manual pre-clear boom')
|
||||
|
||||
expect(clearStorageDataMock).toHaveBeenCalledTimes(2)
|
||||
expect(netFetchMock).not.toHaveBeenCalled()
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('logMiniMaxFetchFailure', () => {
|
||||
let warn: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('logs structured fields without leaking cookie values', () => {
|
||||
logMiniMaxFetchFailure({
|
||||
transport: 'session-cookie-jar',
|
||||
responseStatus: 200,
|
||||
statusCode: 7,
|
||||
statusMsg: '_token=eyJhABCDEF ak_bmsc=secretvalue',
|
||||
cookieNames: ['_token', 'ak_bmsc'],
|
||||
requestHeaderNames: ['Accept', 'X-Group-Id']
|
||||
})
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[minimax] usage fetch failed',
|
||||
expect.objectContaining({
|
||||
transport: 'session-cookie-jar',
|
||||
responseStatus: 200,
|
||||
baseRespStatusCode: 7,
|
||||
baseRespStatusMsg: expect.not.stringContaining('eyJhABCDEF'),
|
||||
cookieNames: ['_token', 'ak_bmsc'],
|
||||
requestHeaderNames: ['Accept', 'X-Group-Id']
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('logs without redacting when statusMsg is not a string', () => {
|
||||
logMiniMaxFetchFailure({
|
||||
transport: 'manual-cookie-header',
|
||||
responseStatus: 401,
|
||||
statusCode: undefined,
|
||||
statusMsg: undefined,
|
||||
cookieNames: ['_token'],
|
||||
requestHeaderNames: ['Accept']
|
||||
})
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[minimax] usage fetch failed',
|
||||
expect.objectContaining({
|
||||
transport: 'manual-cookie-header',
|
||||
responseStatus: 401,
|
||||
baseRespStatusCode: undefined,
|
||||
baseRespStatusMsg: undefined
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,203 @@
|
||||
import { session, type Session } from 'electron'
|
||||
|
||||
export const MINIMAX_USAGE_ENDPOINT =
|
||||
'https://platform.minimax.io/v1/api/openplatform/coding_plan/remains'
|
||||
|
||||
const MINIMAX_ORIGIN = 'https://platform.minimax.io'
|
||||
const MINIMAX_REFERER = 'https://platform.minimax.io/console/usage'
|
||||
const MINIMAX_SESSION_PARTITION = 'orca-minimax-rate-limit-fetch'
|
||||
const SENSITIVE_COOKIE_NAMES = new Set([
|
||||
'_token',
|
||||
'_twpid',
|
||||
'_abck',
|
||||
'ak_bmsc',
|
||||
'bm_mi',
|
||||
'bm_sv',
|
||||
'bm_sz',
|
||||
'minimax_group_id_v2'
|
||||
])
|
||||
|
||||
export type MiniMaxFetchTransport = 'session-cookie-jar' | 'manual-cookie-header'
|
||||
|
||||
export type MiniMaxFetchResponse = {
|
||||
response: Response
|
||||
requestHeaderNames: string[]
|
||||
cookieNames: string[]
|
||||
transport: MiniMaxFetchTransport
|
||||
}
|
||||
|
||||
// Why: MiniMax's usage endpoint rejects non-browser clients, so we send a real
|
||||
// per-platform Firefox UA instead of a custom agent string. Don't "clean up".
|
||||
function getMiniMaxBrowserUserAgent(): string {
|
||||
if (process.platform === 'win32') {
|
||||
return 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0'
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
return 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:152.0) Gecko/20100101 Firefox/152.0'
|
||||
}
|
||||
return 'Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0'
|
||||
}
|
||||
|
||||
function parseCookiePairs(cookie: string): { name: string; value: string }[] {
|
||||
const headerPairs = cookie
|
||||
.split(';')
|
||||
.map((part) => part.trim())
|
||||
.map((part) => {
|
||||
const normalizedPart = part.replace(/^Cookie:\s*/i, '')
|
||||
const eq = normalizedPart.indexOf('=')
|
||||
if (eq < 0) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
name: normalizedPart.slice(0, eq).trim(),
|
||||
value: normalizedPart.slice(eq + 1).trim()
|
||||
}
|
||||
})
|
||||
.filter((pair): pair is { name: string; value: string } => Boolean(pair?.name && pair.value))
|
||||
// Why: Chromium cookie storage exports are often copied as `name:"value"`,
|
||||
// not as an HTTP `Cookie` header. Accept both formats to avoid credential UX traps.
|
||||
const quotedCookiePairPattern = /(?:^|[;\s])([A-Za-z0-9_.-]+)\s*:\s*["']([^"']+)["']/g
|
||||
const quotedPairs = [...cookie.matchAll(quotedCookiePairPattern)]
|
||||
.map((match) => {
|
||||
const [, name = '', value = ''] = match
|
||||
return { name: name.trim(), value: value.trim() }
|
||||
})
|
||||
.filter((pair) => pair.name && pair.value)
|
||||
return [...headerPairs, ...quotedPairs]
|
||||
}
|
||||
|
||||
export function extractMiniMaxCookieValue(cookie: string, name: string): string | null {
|
||||
return parseCookiePairs(cookie).find((pair) => pair.name === name)?.value ?? null
|
||||
}
|
||||
|
||||
export function normalizeMiniMaxCookieHeader(cookie: string): string {
|
||||
return parseCookiePairs(cookie)
|
||||
.map((pair) => `${pair.name}=${pair.value}`)
|
||||
.join('; ')
|
||||
}
|
||||
|
||||
export function getUniqueMiniMaxCookieNames(cookie: string): string[] {
|
||||
return [...new Set(parseCookiePairs(cookie).map((pair) => pair.name))]
|
||||
}
|
||||
|
||||
export function redactMiniMaxSecret(value: string): string {
|
||||
let redacted = value.replace(/Cookie:\s*[^\n\r]+/gi, 'Cookie: [REDACTED]')
|
||||
for (const name of SENSITIVE_COOKIE_NAMES) {
|
||||
redacted = redacted
|
||||
.replace(new RegExp(`${name}=([^;\\s]+)`, 'g'), `${name}=[REDACTED]`)
|
||||
// Match parseCookiePairs' `\s*:\s*` tolerance so `name : "secret"` is redacted too.
|
||||
.replace(new RegExp(`${name}\\s*:\\s*["'][^"']+["']`, 'g'), `${name}:[REDACTED]`)
|
||||
}
|
||||
return redacted
|
||||
}
|
||||
|
||||
export function makeMiniMaxRequestHeaders(groupId: string | null): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
Referer: MINIMAX_REFERER,
|
||||
'User-Agent': getMiniMaxBrowserUserAgent()
|
||||
}
|
||||
if (groupId) {
|
||||
headers['X-Group-Id'] = groupId
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
async function clearMiniMaxSessionCookieJarForSession(miniMaxSession: Session): Promise<void> {
|
||||
await miniMaxSession.clearStorageData({ origin: MINIMAX_ORIGIN, storages: ['cookies'] })
|
||||
}
|
||||
|
||||
export async function clearMiniMaxSessionCookieJar(): Promise<void> {
|
||||
await clearMiniMaxSessionCookieJarForSession(session.fromPartition(MINIMAX_SESSION_PARTITION))
|
||||
}
|
||||
|
||||
export async function fetchMiniMaxWithSessionCookieJar(args: {
|
||||
cookie: string
|
||||
endpoint: string
|
||||
groupId: string | null
|
||||
signal: AbortSignal
|
||||
}): Promise<MiniMaxFetchResponse> {
|
||||
const miniMaxSession = session.fromPartition(MINIMAX_SESSION_PARTITION)
|
||||
const cookiePairs = parseCookiePairs(args.cookie)
|
||||
try {
|
||||
await clearMiniMaxSessionCookieJarForSession(miniMaxSession)
|
||||
await Promise.all(
|
||||
cookiePairs.map((pair) =>
|
||||
miniMaxSession.cookies.set({
|
||||
url: MINIMAX_ORIGIN,
|
||||
name: pair.name,
|
||||
value: pair.value,
|
||||
secure: true,
|
||||
path: '/'
|
||||
})
|
||||
)
|
||||
)
|
||||
const headers = makeMiniMaxRequestHeaders(args.groupId)
|
||||
return {
|
||||
response: await miniMaxSession.fetch(args.endpoint, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
signal: args.signal
|
||||
}),
|
||||
requestHeaderNames: Object.keys(headers),
|
||||
cookieNames: getUniqueMiniMaxCookieNames(args.cookie),
|
||||
transport: 'session-cookie-jar'
|
||||
}
|
||||
} finally {
|
||||
await clearMiniMaxSessionCookieJarForSession(miniMaxSession).catch((error: unknown) => {
|
||||
console.warn('[minimax] failed to clear session cookie jar after fetch', error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMiniMaxWithManualCookieHeader(args: {
|
||||
cookie: string
|
||||
endpoint: string
|
||||
groupId: string | null
|
||||
signal: AbortSignal
|
||||
}): Promise<MiniMaxFetchResponse> {
|
||||
const miniMaxSession = session.fromPartition(MINIMAX_SESSION_PARTITION)
|
||||
try {
|
||||
await clearMiniMaxSessionCookieJarForSession(miniMaxSession)
|
||||
const headers = {
|
||||
...makeMiniMaxRequestHeaders(args.groupId),
|
||||
Cookie: normalizeMiniMaxCookieHeader(args.cookie)
|
||||
}
|
||||
return {
|
||||
response: await miniMaxSession.fetch(args.endpoint, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
signal: args.signal
|
||||
}),
|
||||
requestHeaderNames: Object.keys(headers),
|
||||
cookieNames: getUniqueMiniMaxCookieNames(args.cookie),
|
||||
transport: 'manual-cookie-header'
|
||||
}
|
||||
} finally {
|
||||
await clearMiniMaxSessionCookieJarForSession(miniMaxSession).catch((error: unknown) => {
|
||||
console.warn('[minimax] failed to clear session cookie jar after fetch', error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function logMiniMaxFetchFailure(details: {
|
||||
transport: MiniMaxFetchTransport
|
||||
responseStatus?: number
|
||||
statusCode?: unknown
|
||||
statusMsg?: unknown
|
||||
cookieNames: string[]
|
||||
requestHeaderNames: string[]
|
||||
}): void {
|
||||
console.warn('[minimax] usage fetch failed', {
|
||||
transport: details.transport,
|
||||
responseStatus: details.responseStatus,
|
||||
baseRespStatusCode: details.statusCode,
|
||||
baseRespStatusMsg:
|
||||
typeof details.statusMsg === 'string'
|
||||
? redactMiniMaxSecret(details.statusMsg)
|
||||
: details.statusMsg,
|
||||
cookieNames: details.cookieNames,
|
||||
requestHeaderNames: details.requestHeaderNames
|
||||
})
|
||||
}
|
||||
@@ -9,7 +9,9 @@ import { RateLimitService } from './service'
|
||||
import { fetchClaudeRateLimits, fetchManagedAccountUsage } from './claude-fetcher'
|
||||
import { fetchCodexRateLimits } from './codex-fetcher'
|
||||
import { fetchGeminiRateLimits } from './gemini-usage-fetcher'
|
||||
import { fetchMiniMaxRateLimits } from './minimax-fetcher'
|
||||
import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher'
|
||||
import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store'
|
||||
|
||||
vi.mock('./claude-fetcher', () => ({
|
||||
fetchClaudeRateLimits: vi.fn(),
|
||||
@@ -28,6 +30,14 @@ vi.mock('./opencode-go-usage-fetcher', () => ({
|
||||
fetchOpenCodeGoRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./minimax-fetcher', () => ({
|
||||
fetchMiniMaxRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../minimax/minimax-cookie-store', () => ({
|
||||
hasMiniMaxSessionCookie: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
type Deferred<T> = {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
@@ -42,7 +52,7 @@ function deferred<T>(): Deferred<T> {
|
||||
}
|
||||
|
||||
function okProvider(
|
||||
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go',
|
||||
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'minimax',
|
||||
usedPercent: number,
|
||||
updatedAt = Date.now()
|
||||
): ProviderRateLimits {
|
||||
@@ -62,7 +72,7 @@ function okProvider(
|
||||
}
|
||||
|
||||
function errorProvider(
|
||||
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go',
|
||||
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'minimax',
|
||||
message: string
|
||||
): ProviderRateLimits {
|
||||
return {
|
||||
@@ -116,6 +126,8 @@ describe('RateLimitService', () => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 0, Date.now()))
|
||||
vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue(okProvider('opencode-go', 0, Date.now()))
|
||||
vi.mocked(fetchMiniMaxRateLimits).mockResolvedValue(okProvider('minimax', 0, Date.now()))
|
||||
vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('does not refetch Claude when a Codex account switch is queued during fetchAll', async () => {
|
||||
@@ -333,6 +345,110 @@ describe('RateLimitService', () => {
|
||||
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('aborts the active fetch cycle and clears queued refreshes on stop', async () => {
|
||||
const service = new RateLimitService()
|
||||
const capturedSignals: { claude?: AbortSignal; codex?: AbortSignal } = {}
|
||||
|
||||
vi.mocked(fetchClaudeRateLimits).mockImplementation(
|
||||
(options) =>
|
||||
new Promise((resolve) => {
|
||||
capturedSignals.claude = options?.signal
|
||||
options?.signal?.addEventListener(
|
||||
'abort',
|
||||
() => resolve(errorProvider('claude', 'aborted')),
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
)
|
||||
vi.mocked(fetchCodexRateLimits).mockImplementation(
|
||||
(options) =>
|
||||
new Promise((resolve) => {
|
||||
capturedSignals.codex = options?.signal
|
||||
options?.signal?.addEventListener(
|
||||
'abort',
|
||||
() => resolve(errorProvider('codex', 'aborted')),
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
const activeFetch = serviceInternals(service).fetchAll()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
const queuedRefresh = service.refresh()
|
||||
await Promise.resolve()
|
||||
|
||||
service.stop()
|
||||
|
||||
expect(capturedSignals.claude?.aborted).toBe(true)
|
||||
expect(capturedSignals.codex?.aborted).toBe(true)
|
||||
|
||||
await queuedRefresh
|
||||
await activeFetch
|
||||
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1)
|
||||
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('aborts inactive Claude preview fetches on stop', async () => {
|
||||
const service = new RateLimitService()
|
||||
const account = { id: 'account-1', managedAuthPath: '/tmp/account-1/auth' }
|
||||
const capturedSignals: { claude?: AbortSignal } = {}
|
||||
service.setInactiveClaudeAccountsResolver(() => [account])
|
||||
vi.mocked(fetchManagedAccountUsage).mockImplementation(
|
||||
(_account, options) =>
|
||||
new Promise((resolve) => {
|
||||
capturedSignals.claude = options?.signal
|
||||
options?.signal?.addEventListener(
|
||||
'abort',
|
||||
() => resolve(errorProvider('claude', 'aborted')),
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
const previewFetch = service.fetchInactiveClaudeAccountsOnOpen()
|
||||
await Promise.resolve()
|
||||
|
||||
service.stop()
|
||||
|
||||
expect(capturedSignals.claude?.aborted).toBe(true)
|
||||
|
||||
await previewFetch
|
||||
|
||||
expect(service.getState().inactiveClaudeAccounts).toEqual([])
|
||||
})
|
||||
|
||||
it('aborts inactive Codex preview fetches on stop', async () => {
|
||||
const service = new RateLimitService()
|
||||
const account = { id: 'account-1', managedHomePath: '/tmp/account-1/home' }
|
||||
const capturedSignals: { codex?: AbortSignal } = {}
|
||||
service.setInactiveCodexAccountsResolver(() => [account])
|
||||
vi.mocked(fetchCodexRateLimits).mockImplementation(
|
||||
(options) =>
|
||||
new Promise((resolve) => {
|
||||
capturedSignals.codex = options?.signal
|
||||
options?.signal?.addEventListener(
|
||||
'abort',
|
||||
() => resolve(errorProvider('codex', 'aborted')),
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
const previewFetch = service.fetchInactiveCodexAccountsOnOpen()
|
||||
await Promise.resolve()
|
||||
|
||||
service.stop()
|
||||
|
||||
expect(capturedSignals.codex?.aborted).toBe(true)
|
||||
|
||||
await previewFetch
|
||||
|
||||
expect(service.getState().inactiveCodexAccounts).toEqual([])
|
||||
})
|
||||
|
||||
it('fetches Gemini and OpenCode Go alongside Claude and Codex', async () => {
|
||||
const service = new RateLimitService()
|
||||
service.setOpenCodeGoConfigResolver(() => ({
|
||||
@@ -351,11 +467,14 @@ describe('RateLimitService', () => {
|
||||
await service.refresh()
|
||||
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1)
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledWith({
|
||||
authPreparation: undefined,
|
||||
allowPtyFallback: false,
|
||||
allowUsagePanelSupplement: true
|
||||
})
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
authPreparation: undefined,
|
||||
allowPtyFallback: false,
|
||||
allowUsagePanelSupplement: true,
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
)
|
||||
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1)
|
||||
expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1)
|
||||
expect(fetchGeminiRateLimits).toHaveBeenCalledWith(true)
|
||||
@@ -453,16 +572,19 @@ describe('RateLimitService', () => {
|
||||
await service.refresh()
|
||||
|
||||
expect(resolver).toHaveBeenCalledWith({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledWith({
|
||||
authPreparation: expect.objectContaining({
|
||||
runtime: 'wsl',
|
||||
wslDistro: 'Ubuntu',
|
||||
wslLinuxConfigDir: '/home/jin/.claude',
|
||||
stripAuthEnv: true
|
||||
}),
|
||||
allowPtyFallback: true,
|
||||
allowUsagePanelSupplement: true
|
||||
})
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
authPreparation: expect.objectContaining({
|
||||
runtime: 'wsl',
|
||||
wslDistro: 'Ubuntu',
|
||||
wslLinuxConfigDir: '/home/jin/.claude',
|
||||
stripAuthEnv: true
|
||||
}),
|
||||
allowPtyFallback: true,
|
||||
allowUsagePanelSupplement: true,
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
)
|
||||
expect(service.getState().claudeTarget).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
||||
})
|
||||
|
||||
@@ -483,11 +605,14 @@ describe('RateLimitService', () => {
|
||||
|
||||
await service.refresh()
|
||||
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledWith({
|
||||
authPreparation: expect.objectContaining({ provenance: 'system' }),
|
||||
allowPtyFallback: false,
|
||||
allowUsagePanelSupplement: true
|
||||
})
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
authPreparation: expect.objectContaining({ provenance: 'system' }),
|
||||
allowPtyFallback: false,
|
||||
allowUsagePanelSupplement: true,
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not use Claude PTY fallback when Claude auth preparation is unavailable', async () => {
|
||||
@@ -498,11 +623,14 @@ describe('RateLimitService', () => {
|
||||
|
||||
await service.refresh()
|
||||
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledWith({
|
||||
authPreparation: undefined,
|
||||
allowPtyFallback: false,
|
||||
allowUsagePanelSupplement: true
|
||||
})
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
authPreparation: undefined,
|
||||
allowPtyFallback: false,
|
||||
allowUsagePanelSupplement: true,
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not use Claude PTY fallback for WSL system-default usage refreshes', async () => {
|
||||
@@ -523,11 +651,14 @@ describe('RateLimitService', () => {
|
||||
|
||||
await service.refresh()
|
||||
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledWith({
|
||||
authPreparation: expect.objectContaining({ provenance: 'wsl:Ubuntu:system' }),
|
||||
allowPtyFallback: false,
|
||||
allowUsagePanelSupplement: true
|
||||
})
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
authPreparation: expect.objectContaining({ provenance: 'wsl:Ubuntu:system' }),
|
||||
allowPtyFallback: false,
|
||||
allowUsagePanelSupplement: true,
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not cache host Codex usage under an outgoing WSL account', async () => {
|
||||
@@ -604,10 +735,13 @@ describe('RateLimitService', () => {
|
||||
|
||||
await service.fetchInactiveCodexAccountsOnOpen()
|
||||
|
||||
expect(fetchCodexRateLimits).toHaveBeenCalledWith({
|
||||
codexHomePath: wslCodexHome,
|
||||
allowPtyFallback: false
|
||||
})
|
||||
expect(fetchCodexRateLimits).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
codexHomePath: wslCodexHome,
|
||||
allowPtyFallback: false,
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
)
|
||||
expect(service.getState().inactiveCodexAccounts).toEqual([
|
||||
{
|
||||
accountId: 'account-1',
|
||||
@@ -629,9 +763,13 @@ describe('RateLimitService', () => {
|
||||
|
||||
await service.fetchInactiveClaudeAccountsOnOpen()
|
||||
|
||||
expect(fetchManagedAccountUsage).toHaveBeenCalledWith(account, {
|
||||
allowUsagePanelSupplement: true
|
||||
})
|
||||
expect(fetchManagedAccountUsage).toHaveBeenCalledWith(
|
||||
account,
|
||||
expect.objectContaining({
|
||||
allowUsagePanelSupplement: true,
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not start overlapping inactive Codex preview fetches', async () => {
|
||||
@@ -930,4 +1068,129 @@ describe('RateLimitService', () => {
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('fetches MiniMax alongside other providers when a config resolver is set', async () => {
|
||||
const service = new RateLimitService()
|
||||
service.setMiniMaxConfigResolver(() => ({
|
||||
sessionCookie: '_token=abc; minimax_group_id_v2=42',
|
||||
groupId: '',
|
||||
models: 'general'
|
||||
}))
|
||||
vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(true)
|
||||
vi.mocked(fetchMiniMaxRateLimits).mockResolvedValueOnce(okProvider('minimax', 50, Date.now()))
|
||||
|
||||
await service.refresh()
|
||||
|
||||
expect(fetchMiniMaxRateLimits).toHaveBeenCalledTimes(1)
|
||||
expect(fetchMiniMaxRateLimits).toHaveBeenCalledWith({
|
||||
cookie: '_token=abc; minimax_group_id_v2=42',
|
||||
groupId: '',
|
||||
models: 'general'
|
||||
})
|
||||
|
||||
const state = service.getState()
|
||||
expect(state.minimax?.status).toBe('ok')
|
||||
expect(state.minimax?.session?.usedPercent).toBe(50)
|
||||
expect(state.minimaxCookieConfigured).toBe(true)
|
||||
})
|
||||
|
||||
it('reports minimaxCookieConfigured from the cookie store even without a resolver', () => {
|
||||
const service = new RateLimitService()
|
||||
vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(true)
|
||||
expect(service.getState().minimaxCookieConfigured).toBe(true)
|
||||
})
|
||||
|
||||
it('discards the previous MiniMax snapshot when its config hash changes', async () => {
|
||||
const service = new RateLimitService()
|
||||
let models = 'general'
|
||||
service.setMiniMaxConfigResolver(() => ({
|
||||
sessionCookie: '_token=abc',
|
||||
groupId: '',
|
||||
models
|
||||
}))
|
||||
vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(true)
|
||||
vi.mocked(fetchMiniMaxRateLimits)
|
||||
.mockResolvedValueOnce(okProvider('minimax', 40, Date.now()))
|
||||
.mockResolvedValueOnce(okProvider('minimax', 10, Date.now()))
|
||||
|
||||
await service.refresh()
|
||||
expect(service.getState().minimax?.session?.usedPercent).toBe(40)
|
||||
|
||||
models = 'premium'
|
||||
await service.refresh()
|
||||
|
||||
const state = service.getState()
|
||||
expect(fetchMiniMaxRateLimits).toHaveBeenCalledTimes(2)
|
||||
expect(state.minimax?.session?.usedPercent).toBe(10)
|
||||
})
|
||||
|
||||
it('does not apply an in-flight MiniMax result after credential invalidation', async () => {
|
||||
const service = new RateLimitService()
|
||||
const firstMiniMax = deferred<ProviderRateLimits>()
|
||||
const secondMiniMax = deferred<ProviderRateLimits>()
|
||||
service.setMiniMaxConfigResolver(() => ({
|
||||
sessionCookie: '_token=abc',
|
||||
groupId: '',
|
||||
models: 'general'
|
||||
}))
|
||||
vi.mocked(fetchMiniMaxRateLimits)
|
||||
.mockImplementationOnce(() => firstMiniMax.promise)
|
||||
.mockImplementationOnce(() => secondMiniMax.promise)
|
||||
|
||||
const firstRefresh = service.refresh()
|
||||
await Promise.resolve()
|
||||
|
||||
service.invalidateMiniMaxCredentialState()
|
||||
const queuedRefresh = service.refresh()
|
||||
await Promise.resolve()
|
||||
|
||||
firstMiniMax.resolve(okProvider('minimax', 50, Date.now()))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(service.getState().minimax?.status).toBe('fetching')
|
||||
expect(service.getState().minimax?.session).toBeNull()
|
||||
|
||||
secondMiniMax.resolve(okProvider('minimax', 10, Date.now()))
|
||||
await firstRefresh
|
||||
await queuedRefresh
|
||||
|
||||
const state = service.getState()
|
||||
expect(fetchMiniMaxRateLimits).toHaveBeenCalledTimes(2)
|
||||
expect(state.minimax?.session?.usedPercent).toBe(10)
|
||||
})
|
||||
|
||||
it('isolates MiniMax failures from other providers', async () => {
|
||||
const service = new RateLimitService()
|
||||
service.setMiniMaxConfigResolver(() => ({
|
||||
sessionCookie: '_token=abc',
|
||||
groupId: '',
|
||||
models: 'general'
|
||||
}))
|
||||
vi.mocked(fetchMiniMaxRateLimits).mockRejectedValueOnce(new Error('minimax down'))
|
||||
vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now()))
|
||||
|
||||
await service.refresh()
|
||||
|
||||
const state = service.getState()
|
||||
expect(state.minimax?.status).toBe('error')
|
||||
expect(state.minimax?.error).toBe('minimax down')
|
||||
expect(state.claude?.status).toBe('ok')
|
||||
})
|
||||
|
||||
it('isolates MiniMax config resolver failures from other providers', async () => {
|
||||
const service = new RateLimitService()
|
||||
service.setMiniMaxConfigResolver(() => {
|
||||
throw new Error('MiniMax session cookie could not be decrypted')
|
||||
})
|
||||
vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now()))
|
||||
|
||||
await service.refresh()
|
||||
|
||||
const state = service.getState()
|
||||
expect(fetchMiniMaxRateLimits).not.toHaveBeenCalled()
|
||||
expect(state.minimax?.status).toBe('error')
|
||||
expect(state.minimax?.error).toBe('MiniMax session cookie could not be decrypted')
|
||||
expect(state.claude?.status).toBe('ok')
|
||||
})
|
||||
})
|
||||
|
||||
+361
-91
@@ -21,6 +21,8 @@ import {
|
||||
} from '../claude-accounts/runtime-selection'
|
||||
import { fetchGeminiRateLimits } from './gemini-usage-fetcher'
|
||||
import { fetchKimiRateLimits } from './kimi-fetcher'
|
||||
import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store'
|
||||
import { fetchMiniMaxRateLimits } from './minimax-fetcher'
|
||||
import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher'
|
||||
import {
|
||||
normalizeCodexAccountSelectionTarget,
|
||||
@@ -43,6 +45,17 @@ type OpenCodeGoRateLimitConfig = {
|
||||
workspaceIdOverride: string
|
||||
}
|
||||
|
||||
type MiniMaxRateLimitConfig = {
|
||||
sessionCookie: string
|
||||
groupId: string
|
||||
models: string
|
||||
}
|
||||
|
||||
type MiniMaxResolvedConfig = {
|
||||
config: MiniMaxRateLimitConfig
|
||||
error: string | null
|
||||
}
|
||||
|
||||
type GeminiCliOAuthEnabledResolver = () => boolean
|
||||
|
||||
// Why: Claude's subscription usage endpoint has a tight request budget. Quota
|
||||
@@ -64,6 +77,7 @@ type InternalRateLimitState = {
|
||||
gemini: ProviderRateLimits | null
|
||||
opencodeGo: ProviderRateLimits | null
|
||||
kimi: ProviderRateLimits | null
|
||||
minimax: ProviderRateLimits | null
|
||||
}
|
||||
|
||||
function normalizePollingInterval(ms: number): number {
|
||||
@@ -85,13 +99,18 @@ function isSystemDefaultClaudeAuth(
|
||||
return provenance === 'system' || Boolean(provenance?.endsWith(':system'))
|
||||
}
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
export class RateLimitService {
|
||||
private state: InternalRateLimitState = {
|
||||
claude: null,
|
||||
codex: null,
|
||||
gemini: null,
|
||||
opencodeGo: null,
|
||||
kimi: null
|
||||
kimi: null,
|
||||
minimax: null
|
||||
}
|
||||
private pollInterval: number = DEFAULT_POLL_MS
|
||||
private timer: ReturnType<typeof setInterval> | null = null
|
||||
@@ -103,11 +122,14 @@ export class RateLimitService {
|
||||
private fullFetchQueued = false
|
||||
private codexOnlyFetchQueued = false
|
||||
private claudeOnlyFetchQueued = false
|
||||
private activeFetchAbortControllers = new Set<AbortController>()
|
||||
private fetchIdleResolvers: (() => void)[] = []
|
||||
private codexFetchGeneration = 0
|
||||
private claudeFetchGeneration = 0
|
||||
private opencodeFetchGeneration = 0
|
||||
private minimaxFetchGeneration = 0
|
||||
private lastOpencodeConfigHash = ''
|
||||
private lastMiniMaxConfigHash = ''
|
||||
private codexHomePathResolver: CodexHomePathResolver | null = null
|
||||
private codexFetchTarget: NormalizedCodexAccountSelectionTarget = {
|
||||
runtime: 'host',
|
||||
@@ -119,6 +141,7 @@ export class RateLimitService {
|
||||
wslDistro: null
|
||||
}
|
||||
private openCodeGoConfigResolver: (() => OpenCodeGoRateLimitConfig) | null = null
|
||||
private miniMaxConfigResolver: (() => MiniMaxRateLimitConfig) | null = null
|
||||
private geminiCliOAuthEnabledResolver: GeminiCliOAuthEnabledResolver | null = null
|
||||
private inactiveClaudeAccountsResolver: (() => InactiveClaudeAccountInfo[]) | null = null
|
||||
private inactiveCodexAccountsResolver: (() => InactiveCodexAccountInfo[]) | null = null
|
||||
@@ -162,6 +185,10 @@ export class RateLimitService {
|
||||
this.openCodeGoConfigResolver = resolver
|
||||
}
|
||||
|
||||
setMiniMaxConfigResolver(resolver: () => MiniMaxRateLimitConfig): void {
|
||||
this.miniMaxConfigResolver = resolver
|
||||
}
|
||||
|
||||
setGeminiCliOAuthEnabledResolver(resolver: GeminiCliOAuthEnabledResolver): void {
|
||||
this.geminiCliOAuthEnabledResolver = resolver
|
||||
}
|
||||
@@ -221,6 +248,11 @@ export class RateLimitService {
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.abortActiveFetchCycle()
|
||||
this.clearQueuedFetches()
|
||||
this.inactiveClaudeFetching.clear()
|
||||
this.inactiveCodexFetching.clear()
|
||||
this.resolveAndClearFetchIdleWaiters()
|
||||
this.stopTimer()
|
||||
this.clearDeferredStartupRefresh()
|
||||
this.detachWindowListeners?.()
|
||||
@@ -233,6 +265,10 @@ export class RateLimitService {
|
||||
this.pruneInactiveCodexState()
|
||||
return {
|
||||
...this.state,
|
||||
// Why: the cookie lives in the file system, not GlobalSettings. Surface
|
||||
// its presence on the pushed state so the renderer keeps the MiniMax
|
||||
// bar visible across reloads and between snapshot refreshes.
|
||||
minimaxCookieConfigured: hasMiniMaxSessionCookie(),
|
||||
claudeTarget: this.claudeFetchTarget,
|
||||
codexTarget: this.codexFetchTarget,
|
||||
inactiveClaudeAccounts: this.buildInactiveArray(
|
||||
@@ -255,6 +291,16 @@ export class RateLimitService {
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
invalidateMiniMaxCredentialState(): void {
|
||||
this.minimaxFetchGeneration += 1
|
||||
// Why: saving or forgetting the browser cookie can race an in-flight usage
|
||||
// fetch; clear the visible snapshot before any old-cookie result returns.
|
||||
this.updateState({
|
||||
...this.state,
|
||||
minimax: this.withFetchingStatus(null, 'minimax')
|
||||
})
|
||||
}
|
||||
|
||||
async refreshForCodexAccountChange(
|
||||
outgoingAccountId?: string | null,
|
||||
target?: CodexAccountSelectionTarget
|
||||
@@ -369,30 +415,18 @@ export class RateLimitService {
|
||||
return
|
||||
}
|
||||
const fetchGeneration = this.inactiveClaudeAccountsGeneration
|
||||
const controller = this.beginFetchCycle()
|
||||
const signal = controller.signal
|
||||
|
||||
for (const account of accounts) {
|
||||
this.inactiveClaudeFetching.add(account.id)
|
||||
}
|
||||
this.pushToRenderer()
|
||||
|
||||
for (const account of accounts) {
|
||||
if (
|
||||
fetchGeneration !== this.inactiveClaudeAccountsGeneration ||
|
||||
!this.isCurrentInactiveClaudeAccount(account.id)
|
||||
) {
|
||||
this.inactiveClaudeFetching.delete(account.id)
|
||||
if (!this.isCurrentInactiveClaudeAccount(account.id)) {
|
||||
this.inactiveClaudeCache.delete(account.id)
|
||||
}
|
||||
this.pushToRenderer()
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const fresh = await fetchManagedAccountUsage(account, {
|
||||
allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(),
|
||||
networkProxySettings: this.networkProxySettingsResolver?.()
|
||||
})
|
||||
try {
|
||||
for (const account of accounts) {
|
||||
if (
|
||||
signal.aborted ||
|
||||
fetchGeneration !== this.inactiveClaudeAccountsGeneration ||
|
||||
!this.isCurrentInactiveClaudeAccount(account.id)
|
||||
) {
|
||||
@@ -403,24 +437,46 @@ export class RateLimitService {
|
||||
this.pushToRenderer()
|
||||
continue
|
||||
}
|
||||
const cached = this.inactiveClaudeCache.get(account.id) ?? null
|
||||
this.inactiveClaudeCache.set(account.id, this.applyStalePolicy(fresh, cached))
|
||||
} catch {
|
||||
// Why: per-account try/catch prevents one Keychain rejection or
|
||||
// network error from aborting the remaining accounts in the batch.
|
||||
if (
|
||||
fetchGeneration !== this.inactiveClaudeAccountsGeneration ||
|
||||
!this.isCurrentInactiveClaudeAccount(account.id)
|
||||
) {
|
||||
this.inactiveClaudeCache.delete(account.id)
|
||||
try {
|
||||
const fresh = await fetchManagedAccountUsage(account, {
|
||||
allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(),
|
||||
networkProxySettings: this.networkProxySettingsResolver?.(),
|
||||
signal
|
||||
})
|
||||
if (
|
||||
signal.aborted ||
|
||||
fetchGeneration !== this.inactiveClaudeAccountsGeneration ||
|
||||
!this.isCurrentInactiveClaudeAccount(account.id)
|
||||
) {
|
||||
this.inactiveClaudeFetching.delete(account.id)
|
||||
if (!this.isCurrentInactiveClaudeAccount(account.id)) {
|
||||
this.inactiveClaudeCache.delete(account.id)
|
||||
}
|
||||
this.pushToRenderer()
|
||||
continue
|
||||
}
|
||||
const cached = this.inactiveClaudeCache.get(account.id) ?? null
|
||||
this.inactiveClaudeCache.set(account.id, this.applyStalePolicy(fresh, cached))
|
||||
} catch {
|
||||
// Why: per-account try/catch prevents one Keychain rejection or
|
||||
// network error from aborting the remaining accounts in the batch.
|
||||
if (
|
||||
signal.aborted ||
|
||||
fetchGeneration !== this.inactiveClaudeAccountsGeneration ||
|
||||
!this.isCurrentInactiveClaudeAccount(account.id)
|
||||
) {
|
||||
this.inactiveClaudeCache.delete(account.id)
|
||||
}
|
||||
}
|
||||
this.inactiveClaudeFetching.delete(account.id)
|
||||
this.pushToRenderer()
|
||||
}
|
||||
this.inactiveClaudeFetching.delete(account.id)
|
||||
this.pushToRenderer()
|
||||
}
|
||||
|
||||
if (fetchGeneration === this.inactiveClaudeAccountsGeneration) {
|
||||
this.lastInactiveClaudeFetchAt = Date.now()
|
||||
if (!signal.aborted && fetchGeneration === this.inactiveClaudeAccountsGeneration) {
|
||||
this.lastInactiveClaudeFetchAt = Date.now()
|
||||
}
|
||||
} finally {
|
||||
this.finishFetchCycle(controller)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,36 +495,18 @@ export class RateLimitService {
|
||||
// Why: account switching can make a previewed account active while its
|
||||
// RPC-only usage fetch is still in flight; stale results must be ignored.
|
||||
const fetchGeneration = this.inactiveCodexAccountsGeneration
|
||||
const controller = this.beginFetchCycle()
|
||||
const signal = controller.signal
|
||||
|
||||
for (const account of accounts) {
|
||||
this.inactiveCodexFetching.add(account.id)
|
||||
}
|
||||
this.pushToRenderer()
|
||||
|
||||
for (const account of accounts) {
|
||||
if (
|
||||
fetchGeneration !== this.inactiveCodexAccountsGeneration ||
|
||||
!this.isCurrentInactiveCodexAccount(account.id)
|
||||
) {
|
||||
this.inactiveCodexFetching.delete(account.id)
|
||||
if (!this.isCurrentInactiveCodexAccount(account.id)) {
|
||||
this.inactiveCodexCache.delete(account.id)
|
||||
}
|
||||
this.pushToRenderer()
|
||||
continue
|
||||
}
|
||||
try {
|
||||
// Why: fetchCodexRateLimits already accepts codexHomePath, so we can
|
||||
// point it at the managed account's home directory directly without
|
||||
// materializing credentials into the shared runtime location.
|
||||
// Why: opening the account switcher should never start hidden PTYs for
|
||||
// every inactive account. On Windows that fallback can crash inside
|
||||
// ConPTY; RPC-only is enough for this non-critical preview surface.
|
||||
const fresh = await fetchCodexRateLimits({
|
||||
codexHomePath: account.managedHomePath,
|
||||
allowPtyFallback: false
|
||||
})
|
||||
try {
|
||||
for (const account of accounts) {
|
||||
if (
|
||||
signal.aborted ||
|
||||
fetchGeneration !== this.inactiveCodexAccountsGeneration ||
|
||||
!this.isCurrentInactiveCodexAccount(account.id)
|
||||
) {
|
||||
@@ -479,23 +517,51 @@ export class RateLimitService {
|
||||
this.pushToRenderer()
|
||||
continue
|
||||
}
|
||||
const cached = this.inactiveCodexCache.get(account.id) ?? null
|
||||
this.inactiveCodexCache.set(account.id, this.applyStalePolicy(fresh, cached))
|
||||
} catch {
|
||||
// Why: per-account try/catch prevents one failure from aborting the batch.
|
||||
if (
|
||||
fetchGeneration !== this.inactiveCodexAccountsGeneration ||
|
||||
!this.isCurrentInactiveCodexAccount(account.id)
|
||||
) {
|
||||
this.inactiveCodexCache.delete(account.id)
|
||||
try {
|
||||
// Why: fetchCodexRateLimits already accepts codexHomePath, so we can
|
||||
// point it at the managed account's home directory directly without
|
||||
// materializing credentials into the shared runtime location.
|
||||
// Why: opening the account switcher should never start hidden PTYs for
|
||||
// every inactive account. On Windows that fallback can crash inside
|
||||
// ConPTY; RPC-only is enough for this non-critical preview surface.
|
||||
const fresh = await fetchCodexRateLimits({
|
||||
codexHomePath: account.managedHomePath,
|
||||
allowPtyFallback: false,
|
||||
signal
|
||||
})
|
||||
if (
|
||||
signal.aborted ||
|
||||
fetchGeneration !== this.inactiveCodexAccountsGeneration ||
|
||||
!this.isCurrentInactiveCodexAccount(account.id)
|
||||
) {
|
||||
this.inactiveCodexFetching.delete(account.id)
|
||||
if (!this.isCurrentInactiveCodexAccount(account.id)) {
|
||||
this.inactiveCodexCache.delete(account.id)
|
||||
}
|
||||
this.pushToRenderer()
|
||||
continue
|
||||
}
|
||||
const cached = this.inactiveCodexCache.get(account.id) ?? null
|
||||
this.inactiveCodexCache.set(account.id, this.applyStalePolicy(fresh, cached))
|
||||
} catch {
|
||||
// Why: per-account try/catch prevents one failure from aborting the batch.
|
||||
if (
|
||||
signal.aborted ||
|
||||
fetchGeneration !== this.inactiveCodexAccountsGeneration ||
|
||||
!this.isCurrentInactiveCodexAccount(account.id)
|
||||
) {
|
||||
this.inactiveCodexCache.delete(account.id)
|
||||
}
|
||||
}
|
||||
this.inactiveCodexFetching.delete(account.id)
|
||||
this.pushToRenderer()
|
||||
}
|
||||
this.inactiveCodexFetching.delete(account.id)
|
||||
this.pushToRenderer()
|
||||
}
|
||||
|
||||
if (fetchGeneration === this.inactiveCodexAccountsGeneration) {
|
||||
this.lastInactiveCodexFetchAt = Date.now()
|
||||
if (!signal.aborted && fetchGeneration === this.inactiveCodexAccountsGeneration) {
|
||||
this.lastInactiveCodexFetchAt = Date.now()
|
||||
}
|
||||
} finally {
|
||||
this.finishFetchCycle(controller)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -644,8 +710,13 @@ export class RateLimitService {
|
||||
try {
|
||||
let shouldContinue = true
|
||||
while (shouldContinue) {
|
||||
await this.runFetchAllCycle()
|
||||
const signal = await this.runWithFetchAbortSignal((fetchSignal) =>
|
||||
this.runFetchAllCycle(fetchSignal)
|
||||
)
|
||||
shouldContinue = false
|
||||
if (signal.aborted) {
|
||||
break
|
||||
}
|
||||
if (this.fullFetchQueued) {
|
||||
this.fullFetchQueued = false
|
||||
shouldContinue = true
|
||||
@@ -653,11 +724,21 @@ export class RateLimitService {
|
||||
}
|
||||
if (this.codexOnlyFetchQueued) {
|
||||
this.codexOnlyFetchQueued = false
|
||||
await this.runFetchCodexOnlyCycle()
|
||||
const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
|
||||
this.runFetchCodexOnlyCycle(fetchSignal)
|
||||
)
|
||||
if (codexSignal.aborted) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if (this.claudeOnlyFetchQueued) {
|
||||
this.claudeOnlyFetchQueued = false
|
||||
await this.runFetchClaudeOnlyCycle()
|
||||
const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
|
||||
this.runFetchClaudeOnlyCycle(fetchSignal)
|
||||
)
|
||||
if (claudeSignal.aborted) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -679,11 +760,21 @@ export class RateLimitService {
|
||||
try {
|
||||
let shouldContinue = true
|
||||
while (shouldContinue) {
|
||||
await this.runFetchCodexOnlyCycle()
|
||||
const signal = await this.runWithFetchAbortSignal((fetchSignal) =>
|
||||
this.runFetchCodexOnlyCycle(fetchSignal)
|
||||
)
|
||||
shouldContinue = false
|
||||
if (signal.aborted) {
|
||||
break
|
||||
}
|
||||
if (this.fullFetchQueued) {
|
||||
this.fullFetchQueued = false
|
||||
await this.runFetchAllCycle()
|
||||
const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
|
||||
this.runFetchAllCycle(fetchSignal)
|
||||
)
|
||||
if (fullSignal.aborted) {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (this.codexOnlyFetchQueued) {
|
||||
@@ -692,7 +783,12 @@ export class RateLimitService {
|
||||
}
|
||||
if (this.claudeOnlyFetchQueued) {
|
||||
this.claudeOnlyFetchQueued = false
|
||||
await this.runFetchClaudeOnlyCycle()
|
||||
const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
|
||||
this.runFetchClaudeOnlyCycle(fetchSignal)
|
||||
)
|
||||
if (claudeSignal.aborted) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -714,11 +810,21 @@ export class RateLimitService {
|
||||
try {
|
||||
let shouldContinue = true
|
||||
while (shouldContinue) {
|
||||
await this.runFetchClaudeOnlyCycle()
|
||||
const signal = await this.runWithFetchAbortSignal((fetchSignal) =>
|
||||
this.runFetchClaudeOnlyCycle(fetchSignal)
|
||||
)
|
||||
shouldContinue = false
|
||||
if (signal.aborted) {
|
||||
break
|
||||
}
|
||||
if (this.fullFetchQueued) {
|
||||
this.fullFetchQueued = false
|
||||
await this.runFetchAllCycle()
|
||||
const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
|
||||
this.runFetchAllCycle(fetchSignal)
|
||||
)
|
||||
if (fullSignal.aborted) {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (this.claudeOnlyFetchQueued) {
|
||||
@@ -727,7 +833,12 @@ export class RateLimitService {
|
||||
}
|
||||
if (this.codexOnlyFetchQueued) {
|
||||
this.codexOnlyFetchQueued = false
|
||||
await this.runFetchCodexOnlyCycle()
|
||||
const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
|
||||
this.runFetchCodexOnlyCycle(fetchSignal)
|
||||
)
|
||||
if (codexSignal.aborted) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -769,6 +880,49 @@ export class RateLimitService {
|
||||
}
|
||||
}
|
||||
|
||||
private beginFetchCycle(): AbortController {
|
||||
const controller = new AbortController()
|
||||
this.activeFetchAbortControllers.add(controller)
|
||||
return controller
|
||||
}
|
||||
|
||||
private finishFetchCycle(controller: AbortController): void {
|
||||
this.activeFetchAbortControllers.delete(controller)
|
||||
}
|
||||
|
||||
private async runWithFetchAbortSignal(
|
||||
fn: (signal: AbortSignal) => Promise<void>
|
||||
): Promise<AbortSignal> {
|
||||
const controller = this.beginFetchCycle()
|
||||
try {
|
||||
await fn(controller.signal)
|
||||
return controller.signal
|
||||
} finally {
|
||||
this.finishFetchCycle(controller)
|
||||
}
|
||||
}
|
||||
|
||||
private abortActiveFetchCycle(): void {
|
||||
for (const controller of this.activeFetchAbortControllers) {
|
||||
controller.abort()
|
||||
}
|
||||
this.activeFetchAbortControllers.clear()
|
||||
}
|
||||
|
||||
private clearQueuedFetches(): void {
|
||||
this.fullFetchQueued = false
|
||||
this.codexOnlyFetchQueued = false
|
||||
this.claudeOnlyFetchQueued = false
|
||||
}
|
||||
|
||||
private resolveAndClearFetchIdleWaiters(): void {
|
||||
const resolvers = this.fetchIdleResolvers
|
||||
this.fetchIdleResolvers = []
|
||||
for (const resolve of resolvers) {
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
|
||||
private isSameCodexTarget(
|
||||
left: NormalizedCodexAccountSelectionTarget,
|
||||
right: NormalizedCodexAccountSelectionTarget
|
||||
@@ -833,9 +987,45 @@ export class RateLimitService {
|
||||
return process.platform !== 'win32'
|
||||
}
|
||||
|
||||
private resolveMiniMaxConfig(): MiniMaxResolvedConfig {
|
||||
try {
|
||||
return {
|
||||
config: this.miniMaxConfigResolver?.() ?? {
|
||||
sessionCookie: '',
|
||||
groupId: '',
|
||||
models: 'general'
|
||||
},
|
||||
error: null
|
||||
}
|
||||
} catch (error) {
|
||||
// Why: one unreadable browser cookie must not abort every provider's
|
||||
// quota refresh; surface it as MiniMax-only state instead.
|
||||
return {
|
||||
config: {
|
||||
sessionCookie: '',
|
||||
groupId: '',
|
||||
models: 'general'
|
||||
},
|
||||
error: toErrorMessage(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getMiniMaxCredentialError(message: string): ProviderRateLimits {
|
||||
return {
|
||||
provider: 'minimax',
|
||||
session: null,
|
||||
weekly: null,
|
||||
updatedAt: Date.now(),
|
||||
error: message,
|
||||
status: 'error',
|
||||
usageMetadata: { failureKind: 'keychain-unavailable', source: 'web' }
|
||||
}
|
||||
}
|
||||
|
||||
private withFetchingStatus(
|
||||
current: ProviderRateLimits | null,
|
||||
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi'
|
||||
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax'
|
||||
): ProviderRateLimits {
|
||||
if (!current) {
|
||||
return {
|
||||
@@ -850,9 +1040,15 @@ export class RateLimitService {
|
||||
return { ...current, status: 'fetching' }
|
||||
}
|
||||
|
||||
private async runFetchAllCycle(): Promise<void> {
|
||||
private async runFetchAllCycle(signal: AbortSignal): Promise<void> {
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
const claudeTarget = this.claudeFetchTarget
|
||||
const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget)
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system'
|
||||
const claudeGeneration = this.claudeFetchGeneration
|
||||
const codexTarget = this.codexFetchTarget
|
||||
@@ -863,6 +1059,10 @@ export class RateLimitService {
|
||||
const openCodeGoConfig = this.openCodeGoConfigResolver?.()
|
||||
const cookie = openCodeGoConfig?.sessionCookie ?? ''
|
||||
const workspaceIdOverride = openCodeGoConfig?.workspaceIdOverride ?? ''
|
||||
const miniMaxConfigResult = this.resolveMiniMaxConfig()
|
||||
const miniMaxCookie = miniMaxConfigResult.config.sessionCookie
|
||||
const miniMaxGroupId = miniMaxConfigResult.config.groupId
|
||||
const miniMaxModels = miniMaxConfigResult.config.models
|
||||
const geminiCliOAuthEnabled = this.geminiCliOAuthEnabledResolver?.() ?? false
|
||||
|
||||
// Detect if configuration changed — if it did, we must discard any stale
|
||||
@@ -875,6 +1075,14 @@ export class RateLimitService {
|
||||
}
|
||||
const opencodeGeneration = this.opencodeFetchGeneration
|
||||
|
||||
const currentMiniMaxConfigHash = `${miniMaxCookie}|${miniMaxGroupId}|${miniMaxModels}|${miniMaxConfigResult.error ?? ''}`
|
||||
const miniMaxConfigChanged = currentMiniMaxConfigHash !== this.lastMiniMaxConfigHash
|
||||
if (miniMaxConfigChanged) {
|
||||
this.lastMiniMaxConfigHash = currentMiniMaxConfigHash
|
||||
this.minimaxFetchGeneration += 1
|
||||
}
|
||||
const miniMaxGeneration = this.minimaxFetchGeneration
|
||||
|
||||
// Mark all providers as fetching while keeping previous data visible.
|
||||
// Codex account changes clear Codex separately before this method is
|
||||
// called, so ordinary refreshes still preserve the current values.
|
||||
@@ -886,30 +1094,46 @@ export class RateLimitService {
|
||||
opencodeGo: opencodeConfigChanged
|
||||
? this.withFetchingStatus(null, 'opencode-go')
|
||||
: this.withFetchingStatus(previousState.opencodeGo, 'opencode-go'),
|
||||
kimi: this.withFetchingStatus(previousState.kimi, 'kimi')
|
||||
kimi: this.withFetchingStatus(previousState.kimi, 'kimi'),
|
||||
minimax: miniMaxConfigChanged
|
||||
? this.withFetchingStatus(null, 'minimax')
|
||||
: this.withFetchingStatus(previousState.minimax, 'minimax')
|
||||
})
|
||||
|
||||
const missingWslCodexHome = codexHomePath
|
||||
? null
|
||||
: this.getMissingWslCodexHomeResult(codexTarget)
|
||||
const [claudeResult, codexResult, geminiResult, opencodeGoResult, kimiResult] =
|
||||
const [claudeResult, codexResult, geminiResult, opencodeGoResult, kimiResult, miniMaxResult] =
|
||||
await Promise.allSettled([
|
||||
fetchClaudeRateLimits({
|
||||
authPreparation: claudeAuthPreparation,
|
||||
allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation),
|
||||
allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(),
|
||||
networkProxySettings: this.networkProxySettingsResolver?.()
|
||||
networkProxySettings: this.networkProxySettingsResolver?.(),
|
||||
signal
|
||||
}),
|
||||
missingWslCodexHome ??
|
||||
fetchCodexRateLimits({
|
||||
codexHomePath,
|
||||
allowPtyFallback: this.shouldAllowCodexPtyFallback()
|
||||
allowPtyFallback: this.shouldAllowCodexPtyFallback(),
|
||||
signal
|
||||
}),
|
||||
fetchGeminiRateLimits(geminiCliOAuthEnabled),
|
||||
fetchOpenCodeGoRateLimits(cookie, workspaceIdOverride || undefined),
|
||||
fetchKimiRateLimits()
|
||||
fetchKimiRateLimits(),
|
||||
miniMaxConfigResult.error
|
||||
? Promise.resolve(this.getMiniMaxCredentialError(miniMaxConfigResult.error))
|
||||
: fetchMiniMaxRateLimits({
|
||||
cookie: miniMaxCookie,
|
||||
groupId: miniMaxGroupId,
|
||||
models: miniMaxModels
|
||||
})
|
||||
])
|
||||
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
|
||||
const claude =
|
||||
claudeResult.status === 'fulfilled'
|
||||
? claudeResult.value
|
||||
@@ -977,8 +1201,26 @@ export class RateLimitService {
|
||||
status: 'error'
|
||||
} satisfies ProviderRateLimits)
|
||||
|
||||
const miniMax =
|
||||
miniMaxResult.status === 'fulfilled'
|
||||
? miniMaxResult.value
|
||||
: ({
|
||||
provider: 'minimax',
|
||||
session: null,
|
||||
weekly: null,
|
||||
updatedAt: Date.now(),
|
||||
error:
|
||||
miniMaxResult.reason instanceof Error
|
||||
? miniMaxResult.reason.message
|
||||
: 'Unknown error',
|
||||
status: 'error'
|
||||
} satisfies ProviderRateLimits)
|
||||
|
||||
const latestCodexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null
|
||||
const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget)
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system'
|
||||
const latestCodexProvenance = this.getCodexProvenance(codexTarget, latestCodexHomePath)
|
||||
const shouldApplyCodex =
|
||||
@@ -988,6 +1230,7 @@ export class RateLimitService {
|
||||
claudeProvenance === latestClaudeProvenance &&
|
||||
this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget)
|
||||
const shouldApplyOpencode = opencodeGeneration === this.opencodeFetchGeneration
|
||||
const shouldApplyMiniMax = miniMaxGeneration === this.minimaxFetchGeneration
|
||||
|
||||
// Why: account switches can race in-flight Codex fetches. Only apply a
|
||||
// Codex result if both the selected-account provenance and the request
|
||||
@@ -1007,13 +1250,21 @@ export class RateLimitService {
|
||||
? opencodeGo
|
||||
: this.applyStalePolicy(opencodeGo, previousState.opencodeGo)
|
||||
: this.state.opencodeGo,
|
||||
kimi: this.applyStalePolicy(kimi, previousState.kimi)
|
||||
kimi: this.applyStalePolicy(kimi, previousState.kimi),
|
||||
minimax: shouldApplyMiniMax
|
||||
? miniMaxConfigChanged
|
||||
? miniMax
|
||||
: this.applyStalePolicy(miniMax, previousState.minimax)
|
||||
: this.state.minimax
|
||||
})
|
||||
|
||||
this.lastFetchAt = Date.now()
|
||||
}
|
||||
|
||||
private async runFetchCodexOnlyCycle(): Promise<void> {
|
||||
private async runFetchCodexOnlyCycle(signal: AbortSignal): Promise<void> {
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
const codexTarget = this.codexFetchTarget
|
||||
const codexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null
|
||||
const codexProvenance = this.getCodexProvenance(codexTarget, codexHomePath)
|
||||
@@ -1033,7 +1284,8 @@ export class RateLimitService {
|
||||
? Promise.resolve(missingWslCodexHome)
|
||||
: fetchCodexRateLimits({
|
||||
codexHomePath,
|
||||
allowPtyFallback: this.shouldAllowCodexPtyFallback()
|
||||
allowPtyFallback: this.shouldAllowCodexPtyFallback(),
|
||||
signal
|
||||
})
|
||||
).catch(
|
||||
(err): ProviderRateLimits => ({
|
||||
@@ -1046,6 +1298,10 @@ export class RateLimitService {
|
||||
})
|
||||
)
|
||||
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
|
||||
const latestCodexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null
|
||||
const latestCodexProvenance = this.getCodexProvenance(codexTarget, latestCodexHomePath)
|
||||
const shouldApplyCodex =
|
||||
@@ -1059,9 +1315,15 @@ export class RateLimitService {
|
||||
this.lastFetchAt = Date.now()
|
||||
}
|
||||
|
||||
private async runFetchClaudeOnlyCycle(): Promise<void> {
|
||||
private async runFetchClaudeOnlyCycle(signal: AbortSignal): Promise<void> {
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
const claudeTarget = this.claudeFetchTarget
|
||||
const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget)
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system'
|
||||
const claudeGeneration = this.claudeFetchGeneration
|
||||
const previousState = this.state
|
||||
@@ -1075,7 +1337,8 @@ export class RateLimitService {
|
||||
authPreparation: claudeAuthPreparation,
|
||||
allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation),
|
||||
allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(),
|
||||
networkProxySettings: this.networkProxySettingsResolver?.()
|
||||
networkProxySettings: this.networkProxySettingsResolver?.(),
|
||||
signal
|
||||
}).catch(
|
||||
(err): ProviderRateLimits => ({
|
||||
provider: 'claude',
|
||||
@@ -1087,7 +1350,14 @@ export class RateLimitService {
|
||||
})
|
||||
)
|
||||
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
|
||||
const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget)
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system'
|
||||
const shouldApplyClaude =
|
||||
claudeGeneration === this.claudeFetchGeneration &&
|
||||
|
||||
@@ -1228,27 +1228,33 @@ computeWorktreePathMock.mockImplementation(
|
||||
ensurePathWithinWorkspaceMock.mockImplementation((targetPath: string) => targetPath)
|
||||
|
||||
describe('OrcaRuntimeService', () => {
|
||||
it('projects worktree card display settings to paired clients', () => {
|
||||
it('projects runtime-backed settings to paired clients', () => {
|
||||
const runtime = new OrcaRuntimeService({
|
||||
...store,
|
||||
getSettings: () => ({
|
||||
...store.getSettings(),
|
||||
experimentalNewWorktreeCardStyle: true,
|
||||
compactWorktreeCards: true
|
||||
compactWorktreeCards: true,
|
||||
minimaxGroupId: 'group-42',
|
||||
minimaxUsageModels: 'general,abab6.5'
|
||||
})
|
||||
} as never)
|
||||
|
||||
expect(runtime.getClientSettings()).toMatchObject({
|
||||
experimentalNewWorktreeCardStyle: true,
|
||||
compactWorktreeCards: true
|
||||
compactWorktreeCards: true,
|
||||
minimaxGroupId: 'group-42',
|
||||
minimaxUsageModels: 'general,abab6.5'
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts worktree card display setting updates from paired clients', () => {
|
||||
it('accepts runtime-backed setting updates from paired clients', () => {
|
||||
let settings = {
|
||||
...store.getSettings(),
|
||||
experimentalNewWorktreeCardStyle: false,
|
||||
compactWorktreeCards: false
|
||||
compactWorktreeCards: false,
|
||||
minimaxGroupId: '',
|
||||
minimaxUsageModels: 'general'
|
||||
}
|
||||
const updateSettings = vi.fn((updates: Partial<typeof settings>) => {
|
||||
settings = { ...settings, ...updates }
|
||||
@@ -1263,19 +1269,30 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(
|
||||
runtime.updateClientSettings({
|
||||
experimentalNewWorktreeCardStyle: true,
|
||||
compactWorktreeCards: true
|
||||
compactWorktreeCards: true,
|
||||
minimaxGroupId: 'group-42',
|
||||
minimaxUsageModels: 'general,abab6.5'
|
||||
})
|
||||
).toMatchObject({
|
||||
experimentalNewWorktreeCardStyle: true,
|
||||
compactWorktreeCards: true
|
||||
compactWorktreeCards: true,
|
||||
minimaxGroupId: 'group-42',
|
||||
minimaxUsageModels: 'general,abab6.5'
|
||||
})
|
||||
expect(updateSettings).toHaveBeenCalledWith(
|
||||
{ experimentalNewWorktreeCardStyle: true, compactWorktreeCards: true },
|
||||
{
|
||||
experimentalNewWorktreeCardStyle: true,
|
||||
compactWorktreeCards: true,
|
||||
minimaxGroupId: 'group-42',
|
||||
minimaxUsageModels: 'general,abab6.5'
|
||||
},
|
||||
{ notifyListeners: true }
|
||||
)
|
||||
expect(runtime.getClientSettings()).toMatchObject({
|
||||
experimentalNewWorktreeCardStyle: true,
|
||||
compactWorktreeCards: true
|
||||
compactWorktreeCards: true,
|
||||
minimaxGroupId: 'group-42',
|
||||
minimaxUsageModels: 'general,abab6.5'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user