diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 96af9bd0cd1..a107536f302 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -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' diff --git a/config/daemon-host-node-runtime.cjs b/config/daemon-host-node-runtime.cjs new file mode 100644 index 00000000000..944d9a94d5b --- /dev/null +++ b/config/daemon-host-node-runtime.cjs @@ -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// 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 } diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 9e482825a73..9880f0d9de4 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -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) diff --git a/config/patches/node-pty@1.1.0.patch b/config/patches/node-pty@1.1.0.patch index e100def9346..30956b53675 100644 --- a/config/patches/node-pty@1.1.0.patch +++ b/config/patches/node-pty@1.1.0.patch @@ -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%': ' +#include #include - + #include @@ -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 { + return new Promise(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); diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 3f318219eeb..ce03bd6b49a 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -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 diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index c106b166feb..43a898dbca2 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -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) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index f5d3d95b9d0..abc2d902e9a 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 3.5m + + downloads: 3.6m @@ -15,7 +15,7 @@ downloads downloads - 3.5m - 3.5m + 3.6m + 3.6m diff --git a/electron.vite.config.ts b/electron.vite.config.ts index ca69fc8a7cd..c325279e413 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -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'), diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx index bf21df02686..961a62a7eaa 100644 --- a/mobile/src/components/NewWorktreeModal.tsx +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -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(initialRepos ?? []) const [selectedRepo, setSelectedRepo] = useState(null) const [showRepoPicker, setShowRepoPicker] = useState(false) + const [nameAutoFocusEnabled, setNameAutoFocusEnabled] = useState(true) const [selectedAgentState, setSelectedAgent] = useState(AGENT_OPTIONS[0]!) const [runtimeSettings, setRuntimeSettings] = useState(null) const [detectedAgentIdsState, setDetectedAgentIdsState] = useState( @@ -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 ( <> @@ -689,7 +698,13 @@ function NewWorktreeModalContent({ <> Repository - setShowRepoPicker(true)}> + { + prepareSelectionPickerOpen() + setShowRepoPicker(true) + }} + > {selectedRepo ? ( 0} + shouldAutoFocus={nameAutoFocusEnabled && visible && !loading && repos.length > 0} returnKeyType="done" onSubmitEditing={() => { if (canCreate) { @@ -775,7 +790,10 @@ function NewWorktreeModalContent({ setShowAgentPicker(true)} + onPress={() => { + prepareSelectionPickerOpen() + setShowAgentPicker(true) + }} > diff --git a/mobile/src/components/pr-sidebar/pr-auto-merge-availability.ts b/mobile/src/components/pr-sidebar/pr-auto-merge-availability.ts index d6040f87e2b..5453a0209b6 100644 --- a/mobile/src/components/pr-sidebar/pr-auto-merge-availability.ts +++ b/mobile/src/components/pr-sidebar/pr-auto-merge-availability.ts @@ -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) { diff --git a/package.json b/package.json index 083696f8850..ccd6f8751d1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 12052fba2e0..f3a1a3369a9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/resources/minimax-icon.svg b/resources/minimax-icon.svg new file mode 100644 index 00000000000..9698ab27567 --- /dev/null +++ b/resources/minimax-icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/skills/orca-per-workspace-env/SKILL.md b/skills/orca-per-workspace-env/SKILL.md index 32ebb91e586..902e1b15cbe 100644 --- a/skills/orca-per-workspace-env/SKILL.md +++ b/skills/orca-per-workspace-env/SKILL.md @@ -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 — `! `, 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 (`! `, 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:, authSourceSnapshotId: } 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 diff --git a/src/main/ai-vault/remote-session-scanner-codex-index.ts b/src/main/ai-vault/remote-session-scanner-codex-index.ts new file mode 100644 index 00000000000..d6a305ba4cf --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-codex-index.ts @@ -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>> +}): Promise> { + 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> { + const titleBySessionId = new Map() + 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 +} diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts new file mode 100644 index 00000000000..f232b0adf81 --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-sources.ts @@ -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 + +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 { + return parseMessageGraphSessionContent('pi', file, content, platform, options) +} + +function openClawParser( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform, + options: RemoteParserOptions +): Promise { + 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) +} diff --git a/src/main/ai-vault/remote-session-scanner-types.ts b/src/main/ai-vault/remote-session-scanner-types.ts new file mode 100644 index 00000000000..d5a4b2f1acf --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-types.ts @@ -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>> +} + +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 +} + +export type RemoteSessionCandidate = { + source: RemoteSessionSource + file: FileWithMtime +} diff --git a/src/main/ai-vault/remote-session-scanner.test.ts b/src/main/ai-vault/remote-session-scanner.test.ts new file mode 100644 index 00000000000..16173f7b736 --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner.test.ts @@ -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() + + addFile(path: string, content: string, mtimeMs: number): void { + this.files.set(normalize(path), { content, mtimeMs }) + } + + async readDir(dirPath: string): Promise { + const dir = normalize(dirPath) + const prefix = dir.endsWith('/') ? dir : `${dir}/` + const entries = new Map() + 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 { + 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 { + 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 => path + search = unsupported + listFiles = unsupported + watch = unsupported +} + +async function unsupported(): Promise { + 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 }] + } + } + ]) +} diff --git a/src/main/ai-vault/remote-session-scanner.ts b/src/main/ai-vault/remote-session-scanner.ts new file mode 100644 index 00000000000..1b7d66cb7b0 --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner.ts @@ -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 { + 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 { + 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 { + 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 }> { + const sessions: AiVaultSession[] = [] + const parsedFilePaths = new Set() + 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 +}): Promise { + 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 { + 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() + 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 { + 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( + items: readonly T[], + mapper: (item: T) => Promise +): Promise { + 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 +} diff --git a/src/main/ai-vault/session-scanner-accumulator.ts b/src/main/ai-vault/session-scanner-accumulator.ts index a6e8cc293dd..85b33a962d6 100644 --- a/src/main/ai-vault/session-scanner-accumulator.ts +++ b/src/main/ai-vault/session-scanner-accumulator.ts @@ -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, diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts index adfb2aa86db..1114c1b8bb2 100644 --- a/src/main/ai-vault/session-scanner-agent-parser.ts +++ b/src/main/ai-vault/session-scanner-agent-parser.ts @@ -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' diff --git a/src/main/ai-vault/session-scanner-codex-parser.ts b/src/main/ai-vault/session-scanner-codex-parser.ts index a42e0af7962..b18721a8296 100644 --- a/src/main/ai-vault/session-scanner-codex-parser.ts +++ b/src/main/ai-vault/session-scanner-codex-parser.ts @@ -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 { - 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 +}): Promise { + 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 | Iterable + platform: NodeJS.Platform + codexHome: string | null + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null + titleReader?: (sessionId: string) => Promise +}): Promise { + 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( diff --git a/src/main/ai-vault/session-scanner-devin-parser.ts b/src/main/ai-vault/session-scanner-devin-parser.ts index 4750dd788ef..4ad6fa45f78 100644 --- a/src/main/ai-vault/session-scanner-devin-parser.ts +++ b/src/main/ai-vault/session-scanner-devin-parser.ts @@ -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 { - 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 | null { diff --git a/src/main/ai-vault/session-scanner-droid-parser.ts b/src/main/ai-vault/session-scanner-droid-parser.ts new file mode 100644 index 00000000000..d99795bbbd3 --- /dev/null +++ b/src/main/ai-vault/session-scanner-droid-parser.ts @@ -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 { + 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 { + return parseDroidSessionLines({ + file, + lines: content.split(/\r?\n/), + platform, + options + }) +} + +async function parseDroidSessionLines(args: { + file: FileWithMtime + lines: AsyncIterable | Iterable + platform: NodeJS.Platform + options?: ParserSessionOptions +}): Promise { + 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 +): 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 + }) +} diff --git a/src/main/ai-vault/session-scanner-graph-parsers.ts b/src/main/ai-vault/session-scanner-graph-parsers.ts index 3013925a548..a52c83b3213 100644 --- a/src/main/ai-vault/session-scanner-graph-parsers.ts +++ b/src/main/ai-vault/session-scanner-graph-parsers.ts @@ -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 { - 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 { + return parseMessageGraphSessionLines({ + agent, + file, + lines: content.split(/\r?\n/), + platform, + options + }) +} + +async function parseMessageGraphSessionLines(args: { + agent: 'openclaw' | 'pi' + file: FileWithMtime + lines: AsyncIterable | Iterable + platform: NodeJS.Platform + options?: ParserSessionOptions +}): Promise { + 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 { - 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) } diff --git a/src/main/ai-vault/session-scanner-primary-parsers.ts b/src/main/ai-vault/session-scanner-primary-parsers.ts index 363bfe636a8..28a37602a6c 100644 --- a/src/main/ai-vault/session-scanner-primary-parsers.ts +++ b/src/main/ai-vault/session-scanner-primary-parsers.ts @@ -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 { - 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 { + return parseClaudeSessionLines({ + file, + lines: content.split(/\r?\n/), + platform, + options + }) +} + +async function parseClaudeSessionLines(args: { + file: FileWithMtime + lines: AsyncIterable | Iterable + platform: NodeJS.Platform + options?: ParserSessionOptions +}): Promise { + 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 { + 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 { - 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 | Iterable + platform: NodeJS.Platform + options?: ParserSessionOptions +}): Promise { + 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( diff --git a/src/main/ai-vault/session-scanner-secondary-parsers.ts b/src/main/ai-vault/session-scanner-secondary-parsers.ts index edc03cf5815..1edcf6292f0 100644 --- a/src/main/ai-vault/session-scanner-secondary-parsers.ts +++ b/src/main/ai-vault/session-scanner-secondary-parsers.ts @@ -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 { - 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 { + return parseCopilotSessionLines({ + file, + lines: content.split(/\r?\n/), + platform, + options + }) +} + +async function parseCopilotSessionLines(args: { + file: FileWithMtime + lines: AsyncIterable | Iterable + platform: NodeJS.Platform + options?: ParserSessionOptions +}): Promise { + 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 { - 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 { + return parseCursorSessionLines({ + file, + lines: content.split(/\r?\n/), + platform, + options + }) +} + +async function parseCursorSessionLines(args: { + file: FileWithMtime + lines: AsyncIterable | Iterable + platform: NodeJS.Platform + options?: ParserSessionOptions +}): Promise { + 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 { - 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 { + 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) } diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index b6c6ae96412..5d2208b7b60 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -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 = { diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index 181bf1466ea..59f92dafc7a 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -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 platform: NodeJS.Platform + executionHostId: ExecutionHostId issues: AiVaultScanIssue[] }): Promise { 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 { 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 { 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, diff --git a/src/main/automations/run-target-resolution.test.ts b/src/main/automations/run-target-resolution.test.ts new file mode 100644 index 00000000000..6e48fb0aac0 --- /dev/null +++ b/src/main/automations/run-target-resolution.test.ts @@ -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 { + return { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: '#fff', + addedAt: 1, + kind: 'git', + ...overrides + } +} + +function makeSetup(overrides: Partial = {}): 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 { + 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.' + }) + }) +}) diff --git a/src/main/automations/run-target-resolution.ts b/src/main/automations/run-target-resolution.ts index 417737a9960..5ff447d6537 100644 --- a/src/main/automations/run-target-resolution.ts +++ b/src/main/automations/run-target-resolution.ts @@ -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.' diff --git a/src/main/browser/agent-browser-bridge.test.ts b/src/main/browser/agent-browser-bridge.test.ts index ed0ab74dc11..55b650c7377 100644 --- a/src/main/browser/agent-browser-bridge.test.ts +++ b/src/main/browser/agent-browser-bridge.test.ts @@ -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 }) diff --git a/src/main/browser/agent-browser-bridge.ts b/src/main/browser/agent-browser-bridge.ts index b176db8200a..4f460a56aba 100644 --- a/src/main/browser/agent-browser-bridge.ts +++ b/src/main/browser/agent-browser-bridge.ts @@ -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('') diff --git a/src/main/claude-accounts/service.test.ts b/src/main/claude-accounts/service.test.ts index 9b5a9b987fc..31a62ff7880 100644 --- a/src/main/claude-accounts/service.test.ts +++ b/src/main/claude-accounts/service.test.ts @@ -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 } ).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 + } + 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 + } + 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) => { + 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 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 } ).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 } + 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 } 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 } @@ -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') diff --git a/src/main/claude-accounts/service.ts b/src/main/claude-accounts/service.ts index 716e9d1281b..eac29e22c6d 100644 --- a/src/main/claude-accounts/service.ts +++ b/src/main/claude-accounts/service.ts @@ -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 { 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) { diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 51908be3253..0f6d968cc52 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -127,6 +127,8 @@ function createSettings(overrides: Partial = {}): GlobalSettings defaultLinearTeamSelection: null, opencodeSessionCookie: '', opencodeWorkspaceId: '', + minimaxGroupId: '', + minimaxUsageModels: 'general', geminiCliOAuthEnabled: false, agentCmdOverrides: {}, keepComputerAwakeWhileAgentsRun: false, diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 13b39b6b0d7..69fed98ea8d 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -131,6 +131,8 @@ function createSettings(overrides: Partial = {}): GlobalSettings defaultLinearTeamSelection: null, opencodeSessionCookie: '', opencodeWorkspaceId: '', + minimaxGroupId: '', + minimaxUsageModels: 'general', geminiCliOAuthEnabled: false, agentCmdOverrides: {}, keepComputerAwakeWhileAgentsRun: false, diff --git a/src/main/computer/macos-app-catalog.ts b/src/main/computer/macos-app-catalog.ts deleted file mode 100644 index 1b7fcb23397..00000000000 --- a/src/main/computer/macos-app-catalog.ts +++ /dev/null @@ -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 { - 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 - 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() - 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 }) - }) - }) -} diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts index aeffb7aa2a5..e2234830e9f 100644 --- a/src/main/daemon/daemon-entry.ts +++ b/src/main/daemon/daemon-entry.ts @@ -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' diff --git a/src/main/daemon/daemon-host-relocation.test.ts b/src/main/daemon/daemon-host-relocation.test.ts new file mode 100644 index 00000000000..9656164091f --- /dev/null +++ b/src/main/daemon/daemon-host-relocation.test.ts @@ -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() + }) +}) diff --git a/src/main/daemon/daemon-host-relocation.ts b/src/main/daemon/daemon-host-relocation.ts new file mode 100644 index 00000000000..21cdcffe3c7 --- /dev/null +++ b/src/main/daemon/daemon-host-relocation.ts @@ -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.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 +} diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index ff18ecc3397..104adde637f 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -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 diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index f5c2bb34049..fb0a9c190d4 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -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[] = [] diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index b23c83656b1..ad8f14cb068 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -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)}` }) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 09f66a98739..a4fdfce15ee 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -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 diff --git a/src/main/daemon/session.test.ts b/src/main/daemon/session.test.ts index fb5b961cc98..478b4ea99f0 100644 --- a/src/main/daemon/session.test.ts +++ b/src/main/daemon/session.test.ts @@ -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() diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index b4d3433a5f8..d156aeb4802 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -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 { diff --git a/src/main/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts index 5c7bae18c91..909a75ddf1f 100644 --- a/src/main/daemon/shell-ready.ts +++ b/src/main/daemon/shell-ready.ts @@ -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 diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 41e1f96b1bb..2a54ce2c19b 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -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 { diff --git a/src/main/daemon/windows-hidden-console-children.test.ts b/src/main/daemon/windows-hidden-console-children.test.ts new file mode 100644 index 00000000000..7a7e61f7a96 --- /dev/null +++ b/src/main/daemon/windows-hidden-console-children.test.ts @@ -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 }) + }) +}) diff --git a/src/main/daemon/windows-hidden-console-children.ts b/src/main/daemon/windows-hidden-console-children.ts new file mode 100644 index 00000000000..74462444691 --- /dev/null +++ b/src/main/daemon/windows-hidden-console-children.ts @@ -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 { + 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) } + 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)[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() diff --git a/src/main/gitlab/glab-known-hosts.ts b/src/main/gitlab/glab-known-hosts.ts deleted file mode 100644 index 157e579ed66..00000000000 --- a/src/main/gitlab/glab-known-hosts.ts +++ /dev/null @@ -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, - connectionId?: string | null -): Promise { - 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 { - 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() - 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) -} diff --git a/src/main/index.ts b/src/main/index.ts index ff4b249f8dc..7eccdffc7a5 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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({ diff --git a/src/main/ipc/ai-vault.test.ts b/src/main/ipc/ai-vault.test.ts new file mode 100644 index 00000000000..9cc8060eee2 --- /dev/null +++ b/src/main/ipc/ai-vault.test.ts @@ -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}` + } +} diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index f96aa0b6d61..4551379afde 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -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 { + 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 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 { - 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 { + 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 { + 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 { + 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() + 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 { if (process.platform !== 'win32') { return [] diff --git a/src/main/ipc/filesystem-watcher-ignore.test.ts b/src/main/ipc/filesystem-watcher-ignore.test.ts index 6784143e013..6a3ab329fef 100644 --- a/src/main/ipc/filesystem-watcher-ignore.test.ts +++ b/src/main/ipc/filesystem-watcher-ignore.test.ts @@ -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) + } }) }) diff --git a/src/main/ipc/filesystem-watcher-ignore.ts b/src/main/ipc/filesystem-watcher-ignore.ts index 61339afb3e4..9828f4a5911 100644 --- a/src/main/ipc/filesystem-watcher-ignore.ts +++ b/src/main/ipc/filesystem-watcher-ignore.ts @@ -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), diff --git a/src/main/ipc/minimax-credentials.test.ts b/src/main/ipc/minimax-credentials.test.ts new file mode 100644 index 00000000000..77e6f592de0 --- /dev/null +++ b/src/main/ipc/minimax-credentials.test.ts @@ -0,0 +1,162 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const ipcState = vi.hoisted(() => ({ + handleHandlers: new Map 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 + invalidateMiniMaxCredentialState: ReturnType + service: Pick +} { + const refresh = vi.fn(() => Promise.resolve({} as RateLimitState)) + const invalidateMiniMaxCredentialState = vi.fn() + return { + refresh, + invalidateMiniMaxCredentialState, + service: { refresh, invalidateMiniMaxCredentialState } + } +} + +async function invoke(channel: string, ...args: unknown[]): Promise { + 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 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) + ) + }) +}) diff --git a/src/main/ipc/minimax-credentials.ts b/src/main/ipc/minimax-credentials.ts new file mode 100644 index 00000000000..97eefcd7116 --- /dev/null +++ b/src/main/ipc/minimax-credentials.ts @@ -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() + }) +} diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 1f6137296ab..8631fc62b8f 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -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 }) => ({ - id: 'ssh-pty' - })) + const sshSpawn = vi.fn( + async (_opts: { env: Record; 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: { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 8a2c109f744..088f0d1d130 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -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 diff --git a/src/main/ipc/rate-limits.test.ts b/src/main/ipc/rate-limits.test.ts new file mode 100644 index 00000000000..3a299c5bda6 --- /dev/null +++ b/src/main/ipc/rate-limits.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const ipcState = vi.hoisted(() => ({ + handleHandlers: new Map 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 } { + 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) + }) +}) diff --git a/src/main/ipc/rate-limits.ts b/src/main/ipc/rate-limits.ts index 3f657c0c160..a28b7810263 100644 --- a/src/main/ipc/rate-limits.ts +++ b/src/main/ipc/rate-limits.ts @@ -23,4 +23,5 @@ export function registerRateLimitHandlers(rateLimits: RateLimitService): void { ipcMain.handle('rateLimits:fetchInactiveCodexAccounts', () => rateLimits.fetchInactiveCodexAccountsOnOpen() ) + ipcMain.handle('rateLimits:refreshMiniMax', () => rateLimits.refresh()) } diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 76e8b16933b..5ce6342c351 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -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() diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 4baae415b87..50dbd0a8ff4 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -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) diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index 0b29082bf1e..bd2541c7ebd 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -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 } const dispatchExec = (rules: ExecRule[]): ((argv: string[]) => Promise) => { 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: '' }) + } ]) ) diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 15f95306c78..ff676112218 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -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 // scattered Maps/Sets that previously tracked this state independently. const activeSessions = new Map() +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 { await teardownActiveSshSession(targetId, (session) => session.detach()) } diff --git a/src/main/menu/register-app-menu.test.ts b/src/main/menu/register-app-menu.test.ts index 39b266b6846..fa636e8623c 100644 --- a/src/main/menu/register-app-menu.test.ts +++ b/src/main/menu/register-app-menu.test.ts @@ -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 }] ]) }) diff --git a/src/main/menu/register-app-menu.ts b/src/main/menu/register-app-menu.ts index a5390e36afc..c36aca6973a 100644 --- a/src/main/menu/register-app-menu.ts +++ b/src/main/menu/register-app-menu.ts @@ -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 = { diff --git a/src/main/minimax/minimax-cookie-store.test.ts b/src/main/minimax/minimax-cookie-store.test.ts new file mode 100644 index 00000000000..4362dd2df93 --- /dev/null +++ b/src/main/minimax/minimax-cookie-store.test.ts @@ -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 { + 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() + }) +}) diff --git a/src/main/minimax/minimax-cookie-store.ts b/src/main/minimax/minimax-cookie-store.ts new file mode 100644 index 00000000000..3864f892bfb --- /dev/null +++ b/src/main/minimax/minimax-cookie-store.ts @@ -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 }) +} diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index d37faa0ac57..3fc96c897bf 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -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 }) diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 7e8588a6b64..5b0e5ea84da 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -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() * 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 | 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 { return '' } - async clearBuffer(_id: string): Promise { - /* handled client-side in xterm.js */ + async clearBuffer(id: string): Promise { + // 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 */ diff --git a/src/main/providers/local-pty-shell-ready.test.ts b/src/main/providers/local-pty-shell-ready.test.ts index 71983b81abc..e736e236c9a 100644 --- a/src/main/providers/local-pty-shell-ready.test.ts +++ b/src/main/providers/local-pty-shell-ready.test.ts @@ -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', () => { diff --git a/src/main/providers/local-pty-shell-ready.ts b/src/main/providers/local-pty-shell-ready.ts index 0e1b7bdbb3f..978d3e94b2a 100644 --- a/src/main/providers/local-pty-shell-ready.ts +++ b/src/main/providers/local-pty-shell-ready.ts @@ -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, 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 | 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 => { diff --git a/src/main/providers/pty-default-cwd.test.ts b/src/main/providers/pty-default-cwd.test.ts new file mode 100644 index 00000000000..2bb0ef3a79a --- /dev/null +++ b/src/main/providers/pty-default-cwd.test.ts @@ -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() + }) +}) diff --git a/src/main/providers/pty-default-cwd.ts b/src/main/providers/pty-default-cwd.ts new file mode 100644 index 00000000000..e9a7b7a5a3b --- /dev/null +++ b/src/main/providers/pty-default-cwd.ts @@ -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.` + ) +} diff --git a/src/main/providers/pty-path-safety.ts b/src/main/providers/pty-path-safety.ts new file mode 100644 index 00000000000..71360e14ce1 --- /dev/null +++ b/src/main/providers/pty-path-safety.ts @@ -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 +} diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts index 1523f38f02f..27a279cdd55 100644 --- a/src/main/providers/ssh-pty-provider.test.ts +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -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' }) diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index 398cc935142..be84c2a6267 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -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 ?? {} } diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index ec9061c5130..28d5d1d9447 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -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. */ diff --git a/src/main/pty/install-dir-runtime-relocation.test.ts b/src/main/pty/install-dir-runtime-relocation.test.ts new file mode 100644 index 00000000000..0844b5a8322 --- /dev/null +++ b/src/main/pty/install-dir-runtime-relocation.test.ts @@ -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.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() + }) +}) diff --git a/src/main/pty/install-dir-runtime-relocation.ts b/src/main/pty/install-dir-runtime-relocation.ts new file mode 100644 index 00000000000..21ce82fd03e --- /dev/null +++ b/src/main/pty/install-dir-runtime-relocation.ts @@ -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.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 { + const inUse = new Set() + 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 +): 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 + } +} diff --git a/src/main/pty/node-pty-runtime-relocation.test.ts b/src/main/pty/node-pty-runtime-relocation.test.ts new file mode 100644 index 00000000000..e5f9fbd61ac --- /dev/null +++ b/src/main/pty/node-pty-runtime-relocation.test.ts @@ -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 + } + } + }) +}) diff --git a/src/main/pty/node-pty-runtime-relocation.ts b/src/main/pty/node-pty-runtime-relocation.ts new file mode 100644 index 00000000000..7c1631d7816 --- /dev/null +++ b/src/main/pty/node-pty-runtime-relocation.ts @@ -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.pid recording the runtime version they pin. + daemonRuntimeDir: join(userData, 'daemon') + }) + if (destDir) { + process.env[NODE_PTY_NATIVE_DIR_ENV_VAR] = destDir + } +} diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts index e302c90b6d1..d4dbad55fd8 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -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 { +async function fetchViaOAuth(token: string, signal?: AbortSignal): Promise { + 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 { } 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 { 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 { 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 { - 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 { + 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 { + 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 { + 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 { + 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) { diff --git a/src/main/rate-limits/claude-pty.test.ts b/src/main/rate-limits/claude-pty.test.ts index f6f4d22e6a0..f2fd976821b 100644 --- a/src/main/rate-limits/claude-pty.test.ts +++ b/src/main/rate-limits/claude-pty.test.ts @@ -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) diff --git a/src/main/rate-limits/claude-pty.ts b/src/main/rate-limits/claude-pty.ts index f2000489275..88301b9c9dd 100644 --- a/src/main/rate-limits/claude-pty.ts +++ b/src/main/rate-limits/claude-pty.ts @@ -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 { + if (options?.signal?.aborted) { + return abortedClaudeUsageResult() + } const pty = await import('node-pty') + if (options?.signal?.aborted) { + return abortedClaudeUsageResult() + } return new Promise((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 | null = null + let timeout: ReturnType | 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({ diff --git a/src/main/rate-limits/codex-fetcher.test.ts b/src/main/rate-limits/codex-fetcher.test.ts index 4c7221e9e96..daaec0ce7fd 100644 --- a/src/main/rate-limits/codex-fetcher.test.ts +++ b/src/main/rate-limits/codex-fetcher.test.ts @@ -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() }) }) ) diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index 439c5438d2f..8ffbd9c8d95 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -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 { + 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 { + 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 { 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 { + if (options?.signal?.aborted) { + return abortedCodexRateLimitResult() + } return new Promise((resolve) => { let buffer = '' let stderr = '' @@ -396,6 +426,7 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise { settle( { @@ -632,7 +676,13 @@ function parsePtyStatus(output: string): { } async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise { + 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 | null = null + let timeout: ReturnType | 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 { + 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 { diff --git a/src/main/rate-limits/hidden-pty-cleanup.test.ts b/src/main/rate-limits/hidden-pty-cleanup.test.ts index 094931a97b0..43e8453fa0f 100644 --- a/src/main/rate-limits/hidden-pty-cleanup.test.ts +++ b/src/main/rate-limits/hidden-pty-cleanup.test.ts @@ -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) + }) }) diff --git a/src/main/rate-limits/hidden-pty-cleanup.ts b/src/main/rate-limits/hidden-pty-cleanup.ts index 6d85421203c..068b937ee84 100644 --- a/src/main/rate-limits/hidden-pty-cleanup.ts +++ b/src/main/rate-limits/hidden-pty-cleanup.ts @@ -7,6 +7,21 @@ type Disposable = { dispose: () => void } +const activeHiddenRateLimitPtys = new Set() + +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[], diff --git a/src/main/rate-limits/hidden-rate-limit-pty-cwd.ts b/src/main/rate-limits/hidden-rate-limit-pty-cwd.ts new file mode 100644 index 00000000000..bb78f041bd5 --- /dev/null +++ b/src/main/rate-limits/hidden-rate-limit-pty-cwd.ts @@ -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"' + ] +} diff --git a/src/main/rate-limits/minimax-fetcher.test.ts b/src/main/rate-limits/minimax-fetcher.test.ts new file mode 100644 index 00000000000..18221fd750c --- /dev/null +++ b/src/main/rate-limits/minimax-fetcher.test.ts @@ -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]') + }) +}) diff --git a/src/main/rate-limits/minimax-fetcher.ts b/src/main/rate-limits/minimax-fetcher.ts new file mode 100644 index 00000000000..5f1c1445707 --- /dev/null +++ b/src/main/rate-limits/minimax-fetcher.ts @@ -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['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 { + 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 { + 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) + } +} diff --git a/src/main/rate-limits/minimax-request-context.test.ts b/src/main/rate-limits/minimax-request-context.test.ts new file mode 100644 index 00000000000..9b01b4ff013 --- /dev/null +++ b/src/main/rate-limits/minimax-request-context.test.ts @@ -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 + + 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 + }) + ) + }) +}) diff --git a/src/main/rate-limits/minimax-request-context.ts b/src/main/rate-limits/minimax-request-context.ts new file mode 100644 index 00000000000..34dcd47130b --- /dev/null +++ b/src/main/rate-limits/minimax-request-context.ts @@ -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 { + const headers: Record = { + 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 { + await miniMaxSession.clearStorageData({ origin: MINIMAX_ORIGIN, storages: ['cookies'] }) +} + +export async function clearMiniMaxSessionCookieJar(): Promise { + await clearMiniMaxSessionCookieJarForSession(session.fromPartition(MINIMAX_SESSION_PARTITION)) +} + +export async function fetchMiniMaxWithSessionCookieJar(args: { + cookie: string + endpoint: string + groupId: string | null + signal: AbortSignal +}): Promise { + 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 { + 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 + }) +} diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts index 5af9abca04f..082dcf282a9 100644 --- a/src/main/rate-limits/service.test.ts +++ b/src/main/rate-limits/service.test.ts @@ -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 = { promise: Promise resolve: (value: T) => void @@ -42,7 +52,7 @@ function deferred(): Deferred { } 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() + const secondMiniMax = deferred() + 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') + }) }) diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index e3befa61404..d270c6773a8 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -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 | null = null @@ -103,11 +122,14 @@ export class RateLimitService { private fullFetchQueued = false private codexOnlyFetchQueued = false private claudeOnlyFetchQueued = false + private activeFetchAbortControllers = new Set() 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 + ): Promise { + 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 { + private async runFetchAllCycle(signal: AbortSignal): Promise { + 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 { + private async runFetchCodexOnlyCycle(signal: AbortSignal): Promise { + 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 { + private async runFetchClaudeOnlyCycle(signal: AbortSignal): Promise { + 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 && diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 5bc855ee7c4..4be25f39e2b 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -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) => { 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' }) }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 12d6c3e9f37..962a72dccdb 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -796,6 +796,8 @@ type RuntimeStore = { githubProjects?: GlobalSettings['githubProjects'] experimentalNewWorktreeCardStyle?: GlobalSettings['experimentalNewWorktreeCardStyle'] compactWorktreeCards?: GlobalSettings['compactWorktreeCards'] + minimaxGroupId?: GlobalSettings['minimaxGroupId'] + minimaxUsageModels?: GlobalSettings['minimaxUsageModels'] gitlabProjects?: GlobalSettings['gitlabProjects'] experimentalWorktreeSymlinks?: boolean mobileAutoRestoreFitMs?: number | null @@ -2395,6 +2397,8 @@ export class OrcaRuntimeService { | 'githubProjects' | 'experimentalNewWorktreeCardStyle' | 'compactWorktreeCards' + | 'minimaxGroupId' + | 'minimaxUsageModels' > { if (!this.store?.getSettings) { throw new Error('runtime_unavailable') @@ -2414,7 +2418,9 @@ export class OrcaRuntimeService { defaultLinearTeamSelection: settings.defaultLinearTeamSelection ?? null, githubProjects: settings.githubProjects, experimentalNewWorktreeCardStyle: settings.experimentalNewWorktreeCardStyle === true, - compactWorktreeCards: settings.compactWorktreeCards === true + compactWorktreeCards: settings.compactWorktreeCards === true, + minimaxGroupId: settings.minimaxGroupId ?? '', + minimaxUsageModels: settings.minimaxUsageModels ?? 'general' } } @@ -2434,6 +2440,8 @@ export class OrcaRuntimeService { | 'githubProjects' | 'experimentalNewWorktreeCardStyle' | 'compactWorktreeCards' + | 'minimaxGroupId' + | 'minimaxUsageModels' > ): Pick< GlobalSettings, @@ -2451,6 +2459,8 @@ export class OrcaRuntimeService { | 'githubProjects' | 'experimentalNewWorktreeCardStyle' | 'compactWorktreeCards' + | 'minimaxGroupId' + | 'minimaxUsageModels' > { if (!this.store?.getSettings || !this.store.updateSettings) { throw new Error('runtime_unavailable') @@ -5945,7 +5955,9 @@ export class OrcaRuntimeService { }) } - private async clearHeadlessTerminalBuffer(ptyId: string): Promise { + // Public: desktop-initiated clears (ipc/pty.ts) must also drop this mobile + // mirror or a resubscribing mobile client resurrects the cleared scrollback. + async clearHeadlessTerminalBuffer(ptyId: string): Promise { const state = this.headlessTerminals.get(ptyId) if (!state) { return diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index 4baba6aef9a..4c692264cc0 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -22,6 +22,8 @@ describe('client UI RPC methods', () => { defaultRepoSelection: ['repo-1'], defaultLinearTeamSelection: ['team-1'], compactWorktreeCards: true, + minimaxGroupId: 'group-42', + minimaxUsageModels: 'general,abab6.5', githubProjects: { pinned: [], recent: [], @@ -77,6 +79,8 @@ describe('client UI RPC methods', () => { defaultTaskViewPreset: 'my-prs', experimentalNewWorktreeCardStyle: true, compactWorktreeCards: true, + minimaxGroupId: 'group-42', + minimaxUsageModels: 'general,abab6.5', defaultRepoSelection: settings.defaultRepoSelection, defaultLinearTeamSelection: ['team-1', 'team-2'], githubProjects: settings.githubProjects @@ -91,6 +95,8 @@ describe('client UI RPC methods', () => { defaultTaskViewPreset: 'my-prs', experimentalNewWorktreeCardStyle: true, compactWorktreeCards: true, + minimaxGroupId: 'group-42', + minimaxUsageModels: 'general,abab6.5', defaultRepoSelection: settings.defaultRepoSelection, defaultLinearTeamSelection: ['team-1', 'team-2'], githubProjects: settings.githubProjects @@ -176,7 +182,10 @@ describe('client UI RPC methods', () => { ...getDefaultUIState(), worktreeCardProperties: ['status', 'branch', 'automation', 'inline-agents'], _worktreeCardModeDefaulted: true, - statusBarItems: ['codex'], + statusBarItems: ['codex', 'kimi', 'minimax', 'ports'], + _portsStatusBarDefaultAdded: true, + _kimiStatusBarDefaultAdded: true, + _minimaxStatusBarDefaultAdded: true, taskResumeState: { githubMode: 'items', githubItemsQuery: 'is:open', @@ -212,7 +221,10 @@ describe('client UI RPC methods', () => { const payload = { worktreeCardProperties: ['status', 'branch', 'automation', 'inline-agents'], _worktreeCardModeDefaulted: true, - statusBarItems: ['codex'], + statusBarItems: ['codex', 'kimi', 'minimax', 'ports'], + _portsStatusBarDefaultAdded: true, + _kimiStatusBarDefaultAdded: true, + _minimaxStatusBarDefaultAdded: true, taskResumeState: { githubMode: 'items', githubItemsQuery: 'is:open', diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index a18dfb3bd0f..b5991f669e4 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -40,7 +40,17 @@ const WorktreeCardProperties = z .array(LegacyWorktreeCardProperty) .transform((value) => normalizeWorktreeCardProperties(value)) const AgentActivityDisplayMode = z.enum(['compact', 'full']) -const StatusBarItem = z.enum(['claude', 'codex', 'gemini', 'opencode-go', 'ssh', 'resource-usage']) +const StatusBarItem = z.enum([ + 'claude', + 'codex', + 'gemini', + 'opencode-go', + 'kimi', + 'minimax', + 'ssh', + 'resource-usage', + 'ports' +]) const WorkspaceStatusDefinition = z.object({ id: z.string(), label: z.string(), @@ -142,6 +152,8 @@ const SettingsUpdate = z defaultRepoSelection: z.array(z.string()).nullable().optional(), defaultLinearTeamSelection: z.array(z.string()).nullable().optional(), compactWorktreeCards: z.boolean().optional(), + minimaxGroupId: z.string().optional(), + minimaxUsageModels: z.string().optional(), githubProjects: GitHubProjectSettings.optional() }) .strict() @@ -188,6 +200,9 @@ const UiUpdate = z _workspaceStatusesDefaultWorkflowMigrated: z.boolean().optional(), _workspaceStatusesDefaultVisualsMigrated: z.boolean().optional(), statusBarItems: z.array(StatusBarItem).optional(), + _portsStatusBarDefaultAdded: z.boolean().optional(), + _kimiStatusBarDefaultAdded: z.boolean().optional(), + _minimaxStatusBarDefaultAdded: z.boolean().optional(), statusBarVisible: z.boolean().optional(), dismissedUpdateVersion: NullableString.optional(), lastUpdateCheckAt: z.number().finite().nullable().optional(), diff --git a/src/main/runtime/rpc/methods/native-chat.test.ts b/src/main/runtime/rpc/methods/native-chat.test.ts index 4d428c9e5f5..720ce127d2c 100644 --- a/src/main/runtime/rpc/methods/native-chat.test.ts +++ b/src/main/runtime/rpc/methods/native-chat.test.ts @@ -70,6 +70,19 @@ describe('nativeChat.readSession clientKind truncation gating', () => { expect(firstOutput(result)).toBe(OVERSIZED) }) + it('clamps a limit past the max window instead of rejecting (keeps pagination unstuck)', () => { + const method = NATIVE_CHAT_METHODS.find((m) => m.name === 'nativeChat.readSession') + const parsed = method?.params?.parse({ agent: 'claude', sessionId: 's', limit: 5000 }) + // A hard reject here would fail the read and stall "load earlier" at the cap; + // clamping returns the 2000-tail so pagination stops cleanly instead. + expect((parsed as { limit: number }).limit).toBe(2000) + }) + + it('still rejects a non-positive limit', () => { + const method = NATIVE_CHAT_METHODS.find((m) => m.name === 'nativeChat.readSession') + expect(() => method?.params?.parse({ agent: 'claude', sessionId: 's', limit: 0 })).toThrow() + }) + it('windows by count for all client kinds', async () => { const many = Array.from({ length: 60 }, (_unused, n) => { const message = makeMessage('small') diff --git a/src/main/runtime/rpc/methods/native-chat.ts b/src/main/runtime/rpc/methods/native-chat.ts index f434df1d4ed..05558c8b76c 100644 --- a/src/main/runtime/rpc/methods/native-chat.ts +++ b/src/main/runtime/rpc/methods/native-chat.ts @@ -22,7 +22,15 @@ const NativeChatSession = z.object({ .pipe(z.string().min(1, 'Missing session id')), // How many of the most-recent messages to return. Clients start small for a // fast first paint and raise it to page older history in as the user scrolls. - limit: z.number().int().positive().max(2000).optional(), + // Clamp (don't reject) a limit past the max window so a client paging beyond it + // gets the capped tail and pagination stops cleanly — a hard `.max` rejection + // would fail the read and stall "load earlier" at the boundary. + limit: z + .number() + .int() + .positive() + .transform((value) => Math.min(value, MOBILE_NATIVE_CHAT_MAX_WINDOW)) + .optional(), // Optional client-supplied cleanup token. When present, the subscribe handler // keys the fs-watcher cleanup under it so registration and unsubscribe derive // from the SAME token (back-compat: falls back to `agent:sessionId` when absent, diff --git a/src/main/ssh/ssh-relay-session.test.ts b/src/main/ssh/ssh-relay-session.test.ts index d1bef968a5a..aa616dd1518 100644 --- a/src/main/ssh/ssh-relay-session.test.ts +++ b/src/main/ssh/ssh-relay-session.test.ts @@ -43,6 +43,8 @@ vi.mock('../agent-hooks/remote-managed-hook-installers', () => ({ vi.mock('../providers/ssh-pty-provider', () => ({ isSshPtyNotFoundError: (err: unknown) => (err instanceof Error ? err.message : String(err)).includes('not found'), + isSshPtyIdentityMismatchError: (err: unknown) => + (err instanceof Error ? err.message : String(err)).includes('identity mismatch'), SshPtyProvider: class MockSshPtyProvider { onData = vi.fn().mockReturnValue(() => {}) onReplay = vi.fn().mockReturnValue(() => {}) @@ -460,6 +462,90 @@ describe('SshRelaySession', () => { expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith('target-1', 'pty-live', 'attached') }) + it('forwards a lease tab identity to reattach so a reset relay cannot cross-wire it', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() + const { getSshPtyProvider } = await import('../ipc/pty') + const mockAttach = vi.fn().mockResolvedValue(undefined) + vi.mocked(getSshPtyProvider).mockReturnValue({ + attachForReconnect: mockAttach, + dispose: vi.fn() + } as unknown as ReturnType) + vi.mocked(getPtyIdsForConnection).mockReturnValue([]) + vi.mocked(mockStore.getSshRemotePtyLeases).mockReturnValue([ + { targetId: 'target-1', ptyId: 'pty-1', state: 'detached', tabId: 'tab-a' } + ] as ReturnType) + + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + await session.establish(mockConn) + + expect(mockAttach).toHaveBeenCalledWith('pty-1', { tabId: 'tab-a' }) + }) + + it('forwards a lease pane identity when leaf identity is available', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() + const { getSshPtyProvider } = await import('../ipc/pty') + const mockAttach = vi.fn().mockResolvedValue(undefined) + vi.mocked(getSshPtyProvider).mockReturnValue({ + attachForReconnect: mockAttach, + dispose: vi.fn() + } as unknown as ReturnType) + vi.mocked(getPtyIdsForConnection).mockReturnValue([]) + const leafId = '11111111-1111-4111-8111-111111111111' + vi.mocked(mockStore.getSshRemotePtyLeases).mockReturnValue([ + { targetId: 'target-1', ptyId: 'pty-1', state: 'detached', tabId: 'tab-a', leafId } + ] as ReturnType) + + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + await session.establish(mockConn) + + expect(mockAttach).toHaveBeenCalledWith('pty-1', { + paneKey: `tab-a:${leafId}`, + tabId: 'tab-a' + }) + }) + + it('does not expire a live reused relay id when attach rejects identity mismatch', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + await session.establish(mockConn) + vi.clearAllMocks() + mockDeploySuccess() + + const { getSshPtyProvider } = await import('../ipc/pty') + const mockAttach = vi + .fn() + .mockRejectedValueOnce(new Error('PTY "pty-1" not found (identity mismatch)')) + vi.mocked(getSshPtyProvider).mockReturnValue({ + attachForReconnect: mockAttach, + dispose: vi.fn() + } as unknown as ReturnType) + vi.mocked(getPtyIdsForConnection).mockReturnValue([]) + const staleLeafId = '11111111-1111-4111-8111-111111111111' + vi.mocked(mockStore.getSshRemotePtyLeases).mockReturnValue([ + { + targetId: 'target-1', + ptyId: 'pty-1', + state: 'detached', + tabId: 'tab-old', + leafId: staleLeafId + } + ] as ReturnType) + + await session.reconnect(mockConn) + + expect(mockAttach).toHaveBeenCalledWith('pty-1', { + paneKey: `tab-old:${staleLeafId}`, + tabId: 'tab-old' + }) + expect(clearProviderPtyState).not.toHaveBeenCalledWith('ssh:target-1@@pty-1') + expect(deletePtyOwnership).not.toHaveBeenCalledWith('ssh:target-1@@pty-1') + expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith('target-1', 'pty-1', 'expired') + expect(mockWindow.webContents.send).not.toHaveBeenCalledWith('pty:exit', { + id: 'ssh:target-1@@pty-1', + code: -1 + }) + }) + it('rejects establish if detach wins while reattach is in flight', async () => { const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() const { getSshPtyProvider } = await import('../ipc/pty') diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 58bf3fbf798..2462bbd94c3 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -14,7 +14,11 @@ import { execCommand } from './ssh-relay-deploy-helpers' import { isRelayVersionMismatchError } from './ssh-relay-version-mismatch-error' import type { RelayVersionMismatchError } from './ssh-relay-version-mismatch-error' import { SshChannelMultiplexer } from './ssh-channel-multiplexer' -import { SshPtyProvider, isSshPtyNotFoundError } from '../providers/ssh-pty-provider' +import { + SshPtyProvider, + isSshPtyIdentityMismatchError, + isSshPtyNotFoundError +} from '../providers/ssh-pty-provider' import { toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id' import { SshFilesystemProvider } from '../providers/ssh-filesystem-provider' import { SshGitProvider } from '../providers/ssh-git-provider' @@ -62,10 +66,14 @@ import { import type { Store } from '../persistence' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { runRemoteOrcaCli } from './ssh-remote-orca-cli' +import { toSshExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' +import { isTerminalLeafId, makePaneKey } from '../../shared/stable-pane-id' +import { isValidTerminalTabId } from '../../shared/terminal-tab-id' export type RelaySessionState = 'idle' | 'deploying' | 'ready' | 'reconnecting' | 'disposed' type RemoteCliBridgeEnv = { + remoteHome: string binDir: string relayDir: string nodePath: string @@ -74,11 +82,39 @@ type RemoteCliBridgeEnv = { pathDelimiter?: ':' | ';' } +type ExpectedPtyIdentity = { paneKey?: string; tabId?: string } + +function expectedIdentityForLease(lease: { + tabId?: string + leafId?: string +}): ExpectedPtyIdentity | null { + if (typeof lease.tabId !== 'string' || lease.tabId.length === 0) { + return null + } + const paneKey = + isValidTerminalTabId(lease.tabId) && + typeof lease.leafId === 'string' && + isTerminalLeafId(lease.leafId) + ? makePaneKey(lease.tabId, lease.leafId) + : undefined + return { + ...(paneKey ? { paneKey } : {}), + tabId: lease.tabId + } +} + type ForwardedReplayFingerprint = { fingerprint: string deliveredAt: number } +export type SshRelayAiVaultHostInfo = { + targetId: string + executionHostId: ExecutionHostId + remoteHome: string + hostPlatform: RemoteHostPlatform +} + const RECONNECT_REPLAY_DUPLICATE_WINDOW_MS = 1000 const REPLAY_FINGERPRINT_EDGE_CHARS = 128 @@ -189,6 +225,19 @@ export class SshRelaySession { return this.remoteCliBridgeEnv?.hostPlatform ?? this.hostPlatform } + getAiVaultHostInfo(): SshRelayAiVaultHostInfo | null { + const env = this.remoteCliBridgeEnv + if (!env) { + return null + } + return { + targetId: this.targetId, + executionHostId: toSshExecutionHostId(this.targetId), + remoteHome: env.remoteHome, + hostPlatform: env.hostPlatform + } + } + getPortScanner(): PortScanner | null { return this.portScanner } @@ -218,6 +267,7 @@ export class SshRelaySession { this.remoteCliBridgeEnv = remoteHome && remoteRelayDir && nodePath && sockPath && hostPlatform ? { + remoteHome, binDir: joinRemotePath(hostPlatform, remoteHome, '.orca-relay', 'bin'), relayDir: remoteRelayDir, nodePath, @@ -345,6 +395,7 @@ export class SshRelaySession { this.remoteCliBridgeEnv = remoteHome && remoteRelayDir && nodePath && sockPath && hostPlatform ? { + remoteHome, binDir: joinRemotePath(hostPlatform, remoteHome, '.orca-relay', 'bin'), relayDir: remoteRelayDir, nodePath, @@ -1015,10 +1066,21 @@ export class SshRelaySession { } private async reattachKnownPtys(shouldContinue: () => boolean): Promise { - const leasedPtyIds = this.store + const activeLeases = this.store .getSshRemotePtyLeases(this.targetId) .filter((lease) => lease.state !== 'terminated' && lease.state !== 'expired') - .map((lease) => lease.ptyId) + const leasedPtyIds = activeLeases.map((lease) => lease.ptyId) + // Why: carry each lease's pane identity into the attach so the relay can + // reject cross-generation id collisions even between split panes in one tab. + // Older leases may lack leafId, so tabId remains the compatibility fallback. + const expectedIdentityByPtyId = new Map( + activeLeases + .map((lease): [string, ExpectedPtyIdentity] | null => { + const expected = expectedIdentityForLease(lease) + return expected ? [lease.ptyId, expected] : null + }) + .filter((entry): entry is [string, ExpectedPtyIdentity] => entry !== null) + ) // Why: after app restart, ptyOwnership is empty but durable SSH leases // still describe remote PTYs that survived in the relay grace window. const ptyIds = Array.from( @@ -1038,7 +1100,11 @@ export class SshRelaySession { return } try { - const attachResult = (await ptyProvider.attachForReconnect(ptyId)) ?? {} + const expectedIdentity = expectedIdentityByPtyId.get(ptyId) + const attachResult = + (expectedIdentity + ? await ptyProvider.attachForReconnect(ptyId, expectedIdentity) + : await ptyProvider.attachForReconnect(ptyId)) ?? {} if (!shouldContinue()) { return } @@ -1050,12 +1116,20 @@ export class SshRelaySession { if (!isSshPtyNotFoundError(err)) { throw err } + const appPtyId = toAppSshPtyId(this.targetId, ptyId) + if (isSshPtyIdentityMismatchError(err)) { + console.warn( + `[ssh-relay-session] Ignoring stale PTY ${ptyId} for ${this.targetId} after relay identity mismatch: ${ + err instanceof Error ? err.message : String(err) + }` + ) + continue + } console.warn( `[ssh-relay-session] Dropping stale PTY ${ptyId} for ${this.targetId} after relay reattach failed: ${ err instanceof Error ? err.message : String(err) }` ) - const appPtyId = toAppSshPtyId(this.targetId, ptyId) clearProviderPtyState(appPtyId) deletePtyOwnership(appPtyId) this.forwardedReattachReplayByPty.delete(appPtyId) diff --git a/src/main/ssh/ssh-remote-node-install-guidance.ts b/src/main/ssh/ssh-remote-node-install-guidance.ts new file mode 100644 index 00000000000..e4ca3a0d3c3 --- /dev/null +++ b/src/main/ssh/ssh-remote-node-install-guidance.ts @@ -0,0 +1,93 @@ +import type { SshConnection } from './ssh-connection' +import { createSshOperationAbortError } from './ssh-connection-utils' +import { execCommand } from './ssh-relay-deploy-helpers' +import { isSshSessionLimitError } from './ssh-session-limit-error' + +export type RemoteNodeResolutionOptions = { + rethrowSessionLimitErrors?: boolean + signal?: AbortSignal +} + +const NODE_PACKAGE_MANAGER_PROBE_TIMEOUT_MS = 8_000 + +const NODE_PACKAGE_MANAGER_HINTS: readonly { bin: string; label: string; install: string }[] = [ + { bin: 'apt-get', label: 'Debian/Ubuntu', install: 'sudo apt-get install -y nodejs npm' }, + { bin: 'dnf', label: 'Fedora/RHEL', install: 'sudo dnf install -y nodejs npm' }, + { bin: 'yum', label: 'RHEL/CentOS', install: 'sudo yum install -y nodejs npm' }, + { bin: 'pacman', label: 'Arch', install: 'sudo pacman -S --needed nodejs npm' }, + { bin: 'apk', label: 'Alpine', install: 'sudo apk add nodejs npm' }, + { bin: 'zypper', label: 'openSUSE', install: 'sudo zypper install -y nodejs npm' }, + { bin: 'brew', label: 'macOS/Homebrew', install: 'brew install node' } +] + +export async function buildPosixNodeInstallGuidance( + conn: SshConnection, + options?: RemoteNodeResolutionOptions +): Promise { + const detectedBin = await detectPackageManager(conn, options) + return formatNodeInstallHints(detectedBin) +} + +async function detectPackageManager( + conn: SshConnection, + options?: RemoteNodeResolutionOptions +): Promise { + throwIfAborted(options) + const bins = NODE_PACKAGE_MANAGER_HINTS.map((hint) => hint.bin).join(' ') + try { + const output = await execCommand( + conn, + `for p in ${bins}; do if command -v "$p" >/dev/null 2>&1; then echo "$p"; break; fi; done`, + commandOptions(options) + ) + const detected = output.trim().split('\n')[0] + return NODE_PACKAGE_MANAGER_HINTS.some((hint) => hint.bin === detected) ? detected : null + } catch (err) { + if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { + throw err + } + throwIfAborted(options) + return null + } +} + +function formatNodeInstallHints(detectedBin: string | null): string { + const tailored = detectedBin + ? NODE_PACKAGE_MANAGER_HINTS.find((hint) => hint.bin === detectedBin) + : null + const lines = [ + 'Node.js not found on remote host. Orca relay requires Node.js 18+ and npm.', + '', + 'Install Node.js 18+ with npm on the remote host, then reconnect:' + ] + if (tailored) { + lines.push(` ${tailored.label}: ${tailored.install}`) + } else { + for (const hint of NODE_PACKAGE_MANAGER_HINTS) { + lines.push(` ${hint.label}: ${hint.install}`) + } + } + lines.push( + '', + 'Verify the remote runtime before reconnecting:', + ' node --version # must be v18 or newer', + ' npm --version', + '', + 'If your distro package is older than Node 18, install an LTS release from https://nodejs.org/.' + ) + return lines.join('\n') +} + +function commandOptions(options?: RemoteNodeResolutionOptions): { + timeoutMs: number + signal?: AbortSignal +} { + const base = { timeoutMs: NODE_PACKAGE_MANAGER_PROBE_TIMEOUT_MS } + return options?.signal ? { ...base, signal: options.signal } : base +} + +function throwIfAborted(options?: RemoteNodeResolutionOptions): void { + if (options?.signal?.aborted) { + throw createSshOperationAbortError() + } +} diff --git a/src/main/ssh/ssh-remote-node-resolution.test.ts b/src/main/ssh/ssh-remote-node-resolution.test.ts index b3cad3c0ecc..84d9b9b48e7 100644 --- a/src/main/ssh/ssh-remote-node-resolution.test.ts +++ b/src/main/ssh/ssh-remote-node-resolution.test.ts @@ -18,6 +18,14 @@ const { resolveRemoteNodePath } = await import('./ssh-remote-node-resolution') const conn = {} as SshConnection +function decodePowerShellCommand(command: string): string { + const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/) + if (!match) { + throw new Error(`No encoded PowerShell command found in: ${command}`) + } + return Buffer.from(match[1]!, 'base64').toString('utf16le') +} + describe('resolveRemoteNodePath', () => { beforeEach(() => { execCommandMock.mockReset() @@ -88,10 +96,23 @@ describe('resolveRemoteNodePath', () => { const callScript = execCommandMock.mock.calls[0]![1] as string expect(callScript).toContain('"$HOME/.fnm/node-versions"/*/installation/bin/node') + expect(callScript).toContain('"$HOME/.local/share/fnm/node-versions"/*/installation/bin/node') expect(callScript).toContain('"$HOME/.local/share/mise/installs/node"/*/bin/node') expect(callScript).toContain('"$HOME/.asdf/installs/nodejs"/*/bin/node') }) + it('probes fnm XDG data directory installs', async () => { + execCommandMock + .mockResolvedValueOnce( + '/home/u/.local/share/fnm/node-versions/v24.18.0/installation/bin/node\n' + ) + .mockResolvedValueOnce('v24.18.0\n') + + await expect(resolveRemoteNodePath(conn)).resolves.toBe( + '/home/u/.local/share/fnm/node-versions/v24.18.0/installation/bin/node' + ) + }) + it('does not depend on GNU sort when probing version-manager directories', async () => { execCommandMock .mockResolvedValueOnce('/home/u/.nvm/versions/node/v20.11.0/bin/node\n') @@ -258,8 +279,9 @@ describe('resolveRemoteNodePath', () => { .mockResolvedValueOnce('\n') // path probe: empty .mockResolvedValueOnce('/bin/bash') // $SHELL .mockResolvedValueOnce('\n') // command -v node: empty + .mockResolvedValueOnce('apt-get\n') // package manager hint probe - await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Node\.js not found/) + await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/sudo apt-get install -y nodejs npm/) }) it('throws when the path-probe exec fails and the login shell finds nothing', async () => { @@ -267,8 +289,9 @@ describe('resolveRemoteNodePath', () => { .mockRejectedValueOnce(new Error('SSH exec channel failed')) // path probe errors .mockResolvedValueOnce('/bin/zsh') // $SHELL .mockResolvedValueOnce('\n') // command -v node: empty + .mockRejectedValueOnce(new Error('package manager probe failed')) - await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Node\.js not found/) + await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Debian\/Ubuntu:/) }) it('keeps the default path-probe fallback for SSH session-limit-shaped errors', async () => { @@ -318,6 +341,21 @@ describe('resolveRemoteNodePath', () => { ).rejects.toBe(sessionLimitError) }) + it('rethrows SSH session-limit errors from package-manager hint probing in strict mode', async () => { + const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { + reason: 4 + }) + execCommandMock + .mockResolvedValueOnce('\n') + .mockResolvedValueOnce('/bin/bash') + .mockResolvedValueOnce('\n') + .mockRejectedValueOnce(sessionLimitError) + + await expect( + resolveRemoteNodePath(conn, undefined, { rethrowSessionLimitErrors: true }) + ).rejects.toBe(sessionLimitError) + }) + it('rethrows SSH session-limit errors from Windows node resolution in strict mode', async () => { const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { reason: 4 @@ -331,6 +369,21 @@ describe('resolveRemoteNodePath', () => { ).rejects.toBe(sessionLimitError) }) + it('rethrows SSH session-limit errors from Windows version checks in strict mode', async () => { + const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { + reason: 4 + }) + execCommandMock + .mockResolvedValueOnce('C:\\Program Files\\nodejs\\node.exe\n') + .mockRejectedValueOnce(sessionLimitError) + + await expect( + resolveRemoteNodePath(conn, getRemoteHostPlatform('win32-x64'), { + rethrowSessionLimitErrors: true + }) + ).rejects.toBe(sessionLimitError) + }) + it('surfaces an AbortError instead of not-found when the shared signal is aborted', async () => { // Why: in concurrent bootstrap a sibling probe aborts this resolution via // the shared signal. Each strategy catch swallows the injected AbortError, @@ -357,7 +410,32 @@ describe('resolveRemoteNodePath', () => { .mockResolvedValueOnce('/bin/bash') // $SHELL .mockResolvedValueOnce('/old/node2\n') // login shell .mockResolvedValueOnce('v6.17.0\n') // too old + .mockResolvedValueOnce('dnf\n') // package manager hint probe - await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Node\.js not found/) + await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/sudo dnf install -y nodejs npm/) + }) + + it('rejects a Windows node.exe below the minimum version', async () => { + execCommandMock + .mockResolvedValueOnce('C:\\Program Files\\nodejs\\node.exe\n') // Get-Command / common paths + .mockResolvedValueOnce('v16.20.2\n') // version check: too old + + await expect(resolveRemoteNodePath(conn, getRemoteHostPlatform('win32-x64'))).rejects.toThrow( + /winget install OpenJS\.NodeJS\.LTS/ + ) + }) + + it('does not stop Windows node discovery after the first existing candidate', async () => { + execCommandMock + .mockResolvedValueOnce('C:\\old\\node.exe\nC:\\Program Files\\nodejs\\node.exe\n') + .mockResolvedValueOnce('v16.20.2\n') + .mockResolvedValueOnce('v20.11.0\n') + + await expect(resolveRemoteNodePath(conn, getRemoteHostPlatform('win32-x64'))).resolves.toBe( + 'C:/Program Files/nodejs/node.exe' + ) + + const discoveryScript = decodePowerShellCommand(execCommandMock.mock.calls[0]![1] as string) + expect(discoveryScript).not.toMatch(/Write-Output \$path\s+exit 0/) }) }) diff --git a/src/main/ssh/ssh-remote-node-resolution.ts b/src/main/ssh/ssh-remote-node-resolution.ts index e4bb3181b79..52baa610d3f 100644 --- a/src/main/ssh/ssh-remote-node-resolution.ts +++ b/src/main/ssh/ssh-remote-node-resolution.ts @@ -2,7 +2,11 @@ import type { SshConnection } from './ssh-connection' import { createSshOperationAbortError, shellEscape } from './ssh-connection-utils' import type { RemoteHostPlatform } from './ssh-remote-platform' import { isWindowsRemoteHost, normalizeWindowsRemotePath } from './ssh-remote-platform' -import { powerShellCommand } from './ssh-remote-powershell' +import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' +import { + buildPosixNodeInstallGuidance, + type RemoteNodeResolutionOptions +} from './ssh-remote-node-install-guidance' import { execCommand } from './ssh-relay-deploy-helpers' import { isSshSessionLimitError } from './ssh-session-limit-error' @@ -16,11 +20,6 @@ const MIN_NODE_MAJOR = 18 // hang a login shell, so keep this short. const LOGIN_SHELL_PROBE_TIMEOUT_MS = 8_000 -type RemoteNodeResolutionOptions = { - rethrowSessionLimitErrors?: boolean - signal?: AbortSignal -} - export async function resolveRemoteNodePath( conn: SshConnection, host?: RemoteHostPlatform, @@ -47,7 +46,7 @@ export async function resolveRemoteNodePath( return loginShellPath } - throwNodeNotFound(options) + return throwNodeNotFound(conn, options) } // Probe the on-disk install directories of every common Node version manager @@ -91,6 +90,7 @@ for candidate in \\ "$HOME/.local/bin/node" \\ "$HOME/.fnm/aliases/default/bin/node" \\ "$HOME/.fnm/node-versions"/*/installation/bin/node \\ + "$HOME/.local/share/fnm/node-versions"/*/installation/bin/node \\ "$HOME/.local/share/mise/shims/node" \\ "$HOME/.local/share/mise/installs/node"/*/bin/node \\ "$HOME/.asdf/shims/node" \\ @@ -104,7 +104,7 @@ true ` try { - const result = await execCommand(conn, script, { signal: options?.signal }) + const result = await execCommandWithOptionalOptions(conn, script, signalOnlyOptions(options)) const seen = new Set() for (const line of result.split('\n')) { const candidate = line.trim() @@ -138,20 +138,21 @@ async function tryResolveViaLoginShell( // Using it — rather than hardcoding bash — means zsh/fish users whose // custom PATH hooks live in profile files get coverage too. We fall back // to sh if $SHELL is unset (rare, e.g. restricted accounts). - const shellResult = await execCommand(conn, 'echo "${SHELL:-/bin/sh}"', { - timeoutMs: LOGIN_SHELL_PROBE_TIMEOUT_MS, - signal: options?.signal - }) + const shellResult = await execCommand( + conn, + 'echo "${SHELL:-/bin/sh}"', + commandOptions({ timeoutMs: LOGIN_SHELL_PROBE_TIMEOUT_MS }, options) + ) const shell = shellResult.trim().split('\n')[0] if (!shell) { return null } - const nodePath = await execCommand(conn, buildCommandInShell(shell, 'command -v node'), { - wrapCommand: false, - timeoutMs: LOGIN_SHELL_PROBE_TIMEOUT_MS, - signal: options?.signal - }) + const nodePath = await execCommand( + conn, + buildCommandInShell(shell, 'command -v node'), + commandOptions({ wrapCommand: false, timeoutMs: LOGIN_SHELL_PROBE_TIMEOUT_MS }, options) + ) const candidate = nodePath.trim().split('\n')[0] if (!candidate) { return null @@ -187,16 +188,12 @@ async function nodeMeetsVersionRequirement( options?: RemoteNodeResolutionOptions ): Promise { try { - const versionOutput = await execCommand(conn, `${shellEscape(nodePath)} --version`, { - wrapCommand: false, - signal: options?.signal - }) - const match = versionOutput.trim().match(/^v?(\d+)/) - if (!match) { - return false - } - const major = Number.parseInt(match[1]!, 10) - return major >= MIN_NODE_MAJOR + const versionOutput = await execCommand( + conn, + `${shellEscape(nodePath)} --version`, + commandOptions({ wrapCommand: false }, options) + ) + return nodeVersionMeetsRequirement(versionOutput) } catch (err) { if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { throw err @@ -217,26 +214,34 @@ async function resolveRemoteWindowsNodePath( 'if ($env:ProgramFiles) { $paths += (Join-Path $env:ProgramFiles "nodejs/node.exe") }', 'if (${env:ProgramFiles(x86)}) { $paths += (Join-Path ${env:ProgramFiles(x86)} "nodejs/node.exe") }', 'if ($env:LOCALAPPDATA) { $paths += (Join-Path $env:LOCALAPPDATA "Programs/nodejs/node.exe") }', + '$found = $false', 'foreach ($path in $paths) {', ' if ($path -and (Test-Path -LiteralPath $path -PathType Leaf)) {', ' Write-Output $path', - ' exit 0', + ' $found = $true', ' }', '}', + 'if ($found) { exit 0 }', "Write-Error 'Node.js not found'", 'exit 1' ].join('\n') try { - const result = await execCommand(conn, powerShellCommand(script), { - wrapCommand: false, - signal: options?.signal - }) - const nodePath = result.trim().split('\n')[0] - if (nodePath) { + const result = await execCommand( + conn, + powerShellCommand(script), + commandOptions({ wrapCommand: false }, options) + ) + for (const line of result.split('\n')) { + const nodePath = line.trim() + if (!nodePath) { + continue + } const normalized = normalizeWindowsRemotePath(nodePath) - console.log(`[ssh-relay] Found Windows node at: ${normalized}`) - return normalized + if (await windowsNodeMeetsVersionRequirement(conn, normalized, options)) { + console.log(`[ssh-relay] Found Windows node at: ${normalized}`) + return normalized + } } } catch (err) { if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { @@ -245,20 +250,98 @@ async function resolveRemoteWindowsNodePath( // Fall through to the shared error below. } - throwNodeNotFound(options) + throwWindowsNodeNotFound(options) } -function throwNodeNotFound(options?: RemoteNodeResolutionOptions): never { - // Why: when a sibling bootstrap probe aborts this resolution via the shared - // signal, the strategy catch-blocks swallow the injected AbortError and fall - // through to here. Surfacing "Node.js not found" would launder a cancellation - // into a fatal non-session failure, defeating the concurrent-bootstrap - // sequential fallback. Re-raise the abort so callers classify it correctly. +async function windowsNodeMeetsVersionRequirement( + conn: SshConnection, + nodePath: string, + options?: RemoteNodeResolutionOptions +): Promise { + try { + const versionOutput = await execCommand( + conn, + powerShellCommand(`& ${powerShellLiteral(nodePath)} --version`), + commandOptions({ wrapCommand: false }, options) + ) + return nodeVersionMeetsRequirement(versionOutput) + } catch (err) { + if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { + throw err + } + return false + } +} + +function nodeVersionMeetsRequirement(versionOutput: string): boolean { + const match = versionOutput.trim().match(/^v?(\d+)/) + if (!match) { + return false + } + const major = Number.parseInt(match[1]!, 10) + return major >= MIN_NODE_MAJOR +} + +async function throwNodeNotFound( + conn: SshConnection, + options?: RemoteNodeResolutionOptions +): Promise { + throwIfAborted(options) + const guidance = await buildPosixNodeInstallGuidance(conn, options) + throwIfAborted(options) + throw new Error(guidance) +} + +function throwWindowsNodeNotFound(options?: RemoteNodeResolutionOptions): never { + throwIfAborted(options) + throw new Error( + [ + 'Node.js not found on remote host. Orca relay requires Node.js 18+ and npm.', + '', + 'Install Node.js 18+ on the remote host, then reconnect:', + ' winget install OpenJS.NodeJS.LTS', + ' choco install nodejs-lts', + '', + 'Verify the remote runtime before reconnecting:', + ' node --version # must be v18 or newer', + ' npm --version', + '', + 'If those package managers are unavailable, install an LTS release from https://nodejs.org/.' + ].join('\n') + ) +} + +function throwIfAborted(options?: RemoteNodeResolutionOptions): void { + // Why: strategy fallbacks intentionally swallow probe failures, but a shared + // bootstrap abort must stay an AbortError so callers can continue fallback. if (options?.signal?.aborted) { throw createSshOperationAbortError() } - throw new Error( - 'Node.js not found on remote host. Orca relay requires Node.js 18+. ' + - 'Install Node.js on the remote and try again.' - ) +} + +function signalOnlyOptions( + options?: RemoteNodeResolutionOptions +): { signal: AbortSignal } | undefined { + return options?.signal ? { signal: options.signal } : undefined +} + +type RemoteExecOptions = { + wrapCommand?: boolean + timeoutMs?: number + signal?: AbortSignal +} + +function commandOptions( + base: RemoteExecOptions, + options?: RemoteNodeResolutionOptions +): RemoteExecOptions { + return options?.signal ? { ...base, signal: options.signal } : base +} + +async function execCommandWithOptionalOptions( + conn: SshConnection, + command: string, + options?: { signal: AbortSignal } +): Promise { + return options ? execCommand(conn, command, options) : execCommand(conn, command) } diff --git a/src/main/updater-prerelease-feed.test.ts b/src/main/updater-prerelease-feed.test.ts index e9f40a72a09..f30fa276b65 100644 --- a/src/main/updater-prerelease-feed.test.ts +++ b/src/main/updater-prerelease-feed.test.ts @@ -187,6 +187,86 @@ describe('fetchNewerReleaseTag', () => { expect(await fetchNewerReleaseTag('1.3.19-rc.6')).toBe('v1.3.20-rc.1') }) + it('ignores perf-tagged prereleases for regular RC checks', async () => { + respondWithAtom(['v1.4.121-rc.6.perf', 'v1.4.121-rc.6', 'v1.4.121-rc.5']) + + const { fetchNewerReleaseTag } = await import('./updater-prerelease-feed') + + expect(await fetchNewerReleaseTag('1.4.121-rc.5', { includePrerelease: true })).toBe( + 'v1.4.121-rc.6' + ) + }) + + it('reports no RC update when only perf-tagged prereleases are newer', async () => { + respondWithAtom(['v1.4.121-rc.6.perf', 'v1.4.121-rc.5']) + + const { fetchNewerReleaseTagsWithReadiness } = await import('./updater-prerelease-feed') + + await expect( + fetchNewerReleaseTagsWithReadiness('1.4.121-rc.5', 1, { includePrerelease: true }) + ).resolves.toEqual({ + tags: [], + state: 'no-newer' + }) + }) + + it('picks the semver-newest perf-tagged prerelease', async () => { + respondWithAtom([ + 'v1.4.121-rc.6.perf', + 'v1.4.121-rc.7', + 'v1.4.121-rc.5.perf', + 'v1.4.121-rc.6', + 'v1.4.122' + ]) + + const { fetchNewerReleaseTag } = await import('./updater-prerelease-feed') + + expect( + await fetchNewerReleaseTag('1.4.120', { + includePrerelease: true, + releaseFilter: 'perf' + }) + ).toBe('v1.4.121-rc.6.perf') + }) + + it('matches only literal rc.N.perf prerelease tags for perf checks', async () => { + respondWithAtom([ + 'v1.4.121-rc.7.performance', + 'v1.4.121-beta.7.perf', + 'v1.4.121-rc.7.perf.extra', + 'v1.4.121-rc.6.perf' + ]) + + const { fetchNewerReleaseTag, isPerfPrereleaseTag } = await import('./updater-prerelease-feed') + + expect(isPerfPrereleaseTag('v1.4.121-rc.6.perf')).toBe(true) + expect(isPerfPrereleaseTag('v1.4.121-rc.7.performance')).toBe(false) + expect(isPerfPrereleaseTag('v1.4.121-beta.7.perf')).toBe(false) + expect(isPerfPrereleaseTag('v1.4.121-rc.7.perf.extra')).toBe(false) + expect( + await fetchNewerReleaseTag('1.4.120', { + includePrerelease: true, + releaseFilter: 'perf' + }) + ).toBe('v1.4.121-rc.6.perf') + }) + + it('reports no perf update instead of falling back to stable or RC tags', async () => { + respondWithAtom(['v1.4.122', 'v1.4.121-rc.7', 'v1.4.121']) + + const { fetchNewerReleaseTagsWithReadiness } = await import('./updater-prerelease-feed') + + await expect( + fetchNewerReleaseTagsWithReadiness('1.4.120', 1, { + includePrerelease: true, + releaseFilter: 'perf' + }) + ).resolves.toEqual({ + tags: [], + state: 'no-newer' + }) + }) + it('returns a bounded fallback candidate after the newest newer tag', async () => { respondWithAtom(['v1.3.51-rc.7', 'v1.3.51-rc.6', 'v1.3.51-rc.5']) const { fetchNewerReleaseTags } = await import('./updater-prerelease-feed') diff --git a/src/main/updater-prerelease-feed.ts b/src/main/updater-prerelease-feed.ts index ec328db10eb..55f49a77282 100644 --- a/src/main/updater-prerelease-feed.ts +++ b/src/main/updater-prerelease-feed.ts @@ -43,6 +43,18 @@ type ReleaseFeedTag = { version: string } +export function isPerfPrereleaseTag(tag: string): boolean { + const version = normalizeTagToVersion(tag) + const match = version.match(/^\d+\.\d+\.\d+-([0-9A-Za-z-.]+)(?:\+[0-9A-Za-z-.]+)?$/) + const identifiers = match?.[1]?.split('.') ?? [] + return ( + identifiers.length === 3 && + identifiers[0] === 'rc' && + /^\d+$/.test(identifiers[1]) && + identifiers[2] === 'perf' + ) +} + async function fetchReleaseFeedTags(): Promise { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS) @@ -156,6 +168,7 @@ async function hasReadyPlatformManifest(tag: string): Promise { */ type FetchNewerReleaseTagOptions = { includePrerelease?: boolean + releaseFilter?: 'perf' } export type FetchNewerReleaseTagsResult = { @@ -190,9 +203,14 @@ export async function fetchNewerReleaseTagsWithReadiness( return { tags: [], state: 'unavailable' } } - const candidates = includePrerelease - ? tags - : tags.filter(({ version }) => !isPrereleaseVersion(version)) + // Why: perf builds are explicit opt-in; regular prerelease checks should + // stay on the main RC/stable series even though perf tags are semver-newer. + const candidates = + options.releaseFilter === 'perf' + ? tags.filter(({ tag }) => isPerfPrereleaseTag(tag)) + : includePrerelease + ? tags.filter(({ tag }) => !isPerfPrereleaseTag(tag)) + : tags.filter(({ version }) => !isPrereleaseVersion(version)) const newestNewerIndex = candidates.findIndex( ({ version }) => compareVersions(version, currentVersion) > 0 ) diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index 60ff680a012..b4c1a47c54b 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -1012,6 +1012,97 @@ describe('updater', () => { expect(autoUpdaterMock.setFeedURL.mock.calls.length).toBe(setupFeedUrlCalls + 1) }) + it('pins the generic feed to a perf-tagged prerelease when requested', async () => { + appMock.getVersion.mockReturnValue('1.4.120') + fetchNewerReleaseTagsMock.mockResolvedValue(['v1.4.121-rc.6.perf']) + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const mainWindow = { webContents: { send: vi.fn() } } + + const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') + + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() }) + + checkForUpdatesFromMenu({ includePerfPrerelease: true }) + + await vi.waitFor(() => { + expect(fetchNewerReleaseTagsMock).toHaveBeenCalledWith('1.4.120', 2, { + includePrerelease: true, + releaseFilter: 'perf' + }) + expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({ + provider: 'generic', + url: 'https://github.com/stablyai/orca/releases/download/v1.4.121-rc.6.perf' + }) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + }) + expect(autoUpdaterMock.allowPrerelease).toBe(true) + }) + + it('surfaces no-update feedback when no newer perf-tagged prerelease exists', async () => { + appMock.getVersion.mockReturnValue('1.4.120') + fetchNewerReleaseTagsMock.mockResolvedValue({ tags: [], state: 'no-newer' }) + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + + const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') + + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() }) + const setupFeedUrlCalls = autoUpdaterMock.setFeedURL.mock.calls.length + + checkForUpdatesFromMenu({ includePerfPrerelease: true }) + + await vi.waitFor(() => { + expect(sendMock).toHaveBeenCalledWith('updater:status', { + state: 'not-available', + userInitiated: true + }) + }) + expect(fetchNewerReleaseTagsMock).toHaveBeenCalledWith('1.4.120', 2, { + includePrerelease: true, + releaseFilter: 'perf' + }) + expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled() + expect(autoUpdaterMock.setFeedURL.mock.calls.length).toBe(setupFeedUrlCalls) + }) + + it('keeps background retries on the stable channel after a perf publishing-window miss', async () => { + vi.useFakeTimers() + appMock.getVersion.mockReturnValue('1.4.120') + fetchNewerReleaseTagsMock + .mockResolvedValueOnce({ tags: [], state: 'not-ready' }) + .mockResolvedValueOnce(['v1.4.121']) + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + + const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') + + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() }) + + checkForUpdatesFromMenu({ includePerfPrerelease: true }) + + await vi.waitFor(() => { + expect(sendMock).toHaveBeenCalledWith('updater:status', { + state: 'error', + message: "Couldn't reach the update server. Try again in a few minutes.", + userInitiated: true + }) + }) + + await vi.advanceTimersByTimeAsync(60 * 60 * 1000) + + await vi.waitFor(() => { + expect(fetchNewerReleaseTagsMock).toHaveBeenNthCalledWith(2, '1.4.120', 1, { + includePrerelease: false + }) + expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({ + provider: 'generic', + url: 'https://github.com/stablyai/orca/releases/download/v1.4.121' + }) + }) + }) + it('leaves the feed URL alone for a normal user-initiated check', async () => { autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) const mainWindow = { webContents: { send: vi.fn() } } diff --git a/src/main/updater.ts b/src/main/updater.ts index 0d383880088..99553eadfb8 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -2,7 +2,7 @@ import { app, BrowserWindow, powerMonitor } from 'electron' import type { NsisUpdater } from 'electron-updater' import { is } from '@electron-toolkit/utils' -import type { UpdateStatus } from '../shared/types' +import type { UpdateCheckOptions, UpdateStatus } from '../shared/types' import { killAllPty } from './ipc/pty' import { withUpdaterSpan } from './observability/instrumentation' import { loadElectronAutoUpdater, type ElectronAutoUpdater } from './electron-updater-loader' @@ -29,6 +29,8 @@ import { fetchNudge, shouldApplyNudge } from './updater-nudge' type CheckFailureSource = 'event' | 'promise' | 'fallback-promise' type MissingManifestPrereleaseFallbackResult = { userInitiated: boolean } type PrimaryEventSuppression = { failureKey: string; error: unknown } +type UpdateCheckVariant = 'default' | 'prerelease' | 'perf' +type ReleaseFeedPreflightResult = 'ready' | 'not-available' const AUTO_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 const AUTO_UPDATE_RETRY_INTERVAL_MS = 60 * 60 * 1000 @@ -44,9 +46,9 @@ let currentStatus: UpdateStatus = { state: 'idle' } let userInitiatedCheck = false let onBeforeQuitCleanup: (() => void | Promise) | null = null let autoUpdaterInitialized = false -// Why: Shift-clicking "Check for Updates" opts the user into the RC release -// channel for the rest of this process. The generic feed still gets pinned to -// a concrete tag on every check so cancelled RCs without manifests are skipped. +// Why: modifier-clicking "Check for Updates" can target prerelease manifests. +// The generic feed still gets pinned to a concrete tag on every check so +// cancelled prereleases without manifests are skipped. let includePrereleaseActive = false let availableVersion: string | null = null let availableReleaseUrl: string | null = null @@ -69,7 +71,7 @@ let activeUpdateCheckAttemptId: number | null = null let activeUpdateCheckLaunchAttemptId: number | null = null let activeUpdateCheckEventAttemptId: number | null = null let updateAvailableEventPendingAttemptId: number | null = null -let pendingPrereleaseUserInitiatedCheckAfterInFlight = false +let pendingUserInitiatedCheckAfterInFlight: UpdateCheckVariant | null = null let activeUpdateNudgeId: string | null = null let awaitingNudgeCheckOutcome = false let nudgeCheckInFlight = false @@ -156,8 +158,9 @@ function decorateStatusWithActiveNudge(status: UpdateStatus): UpdateStatus { } function sendStatus(status: UpdateStatus): void { - const shouldLaunchPendingPrereleaseCheck = - pendingPrereleaseUserInitiatedCheckAfterInFlight && + const pendingUserInitiatedCheckVariant = pendingUserInitiatedCheckAfterInFlight + const shouldLaunchPendingUserInitiatedCheck = + pendingUserInitiatedCheckVariant !== null && (status.state === 'idle' || status.state === 'not-available' || status.state === 'available' || @@ -225,8 +228,8 @@ function sendStatus(status: UpdateStatus): void { ) { downloadInFlight = false } - if (shouldLaunchPendingPrereleaseCheck) { - launchPendingPrereleaseUserInitiatedCheckAfterInFlight() + if (shouldLaunchPendingUserInitiatedCheck) { + launchPendingUserInitiatedCheckAfterInFlight(pendingUserInitiatedCheckVariant) return } if (statusesEqual(currentStatus, decoratedStatus)) { @@ -236,16 +239,37 @@ function sendStatus(status: UpdateStatus): void { mainWindowRef?.webContents.send('updater:status', decoratedStatus) } -function launchPendingPrereleaseUserInitiatedCheckAfterInFlight(): void { - pendingPrereleaseUserInitiatedCheckAfterInFlight = false +function getOptionsForUpdateCheckVariant(variant: UpdateCheckVariant): UpdateCheckOptions { + switch (variant) { + case 'perf': + return { includePrerelease: true, includePerfPrerelease: true } + case 'prerelease': + return { includePrerelease: true } + case 'default': + return { includePrerelease: false } + } +} + +function getUpdateCheckVariant(options?: UpdateCheckOptions): UpdateCheckVariant { + if (options?.includePerfPrerelease) { + return 'perf' + } + if (options?.includePrerelease) { + return 'prerelease' + } + return 'default' +} + +function launchPendingUserInitiatedCheckAfterInFlight(variant: UpdateCheckVariant): void { + pendingUserInitiatedCheckAfterInFlight = null setTimeout(() => { // Why: electron-updater clears its in-flight promise after emitting the - // terminal event. Deferring one tick lets the queued RC check start fresh - // instead of being deduped into the just-finished stable check. + // terminal event. Deferring one tick lets the queued modifier check start + // fresh instead of being deduped into the just-finished stable check. if (currentStatus.state === 'checking') { currentStatus = { state: 'idle' } } - checkForUpdatesFromMenu({ includePrerelease: true }) + checkForUpdatesFromMenu(getOptionsForUpdateCheckVariant(variant)) }, 0) } @@ -769,7 +793,9 @@ function markMissingManifestPrereleaseFallbackPromiseHandled(message: string): v ) } -async function pinDefaultReleaseFeed(): Promise { +async function pinDefaultReleaseFeed( + variant: UpdateCheckVariant = 'default' +): Promise { const autoUpdater = getAutoUpdater() // Why: the /releases/latest/download/ redirect can move between the update // check and the later manual download click. Pinning to the concrete tag @@ -778,12 +804,15 @@ async function pinDefaultReleaseFeed(): Promise { // Prerelease users still need any-channel resolution so they can move to a // newer RC or the next stable. Stable users should only resolve stable tags. const currentVersion = app.getVersion() - const includePrerelease = includePrereleaseActive || isPrereleaseVersion(currentVersion) + const isPerfCheck = variant === 'perf' + const includePrerelease = + isPerfCheck || includePrereleaseActive || isPrereleaseVersion(currentVersion) const releaseTagsResult = await fetchNewerReleaseTagsWithReadiness( currentVersion, includePrerelease ? 2 : 1, { - includePrerelease + includePrerelease, + ...(isPerfCheck ? { releaseFilter: 'perf' as const } : {}) } ) const newerTag = releaseTagsResult.tags[0] ?? null @@ -814,6 +843,7 @@ async function pinDefaultReleaseFeed(): Promise { `[updater] release feed pinned: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}` ) autoUpdater.setFeedURL({ provider: 'generic', url }) + return 'ready' } else if (releaseTagsResult.state === 'not-ready') { clearPrereleaseFallbackContext() if (releaseTagsResult.lastGoodTag) { @@ -825,13 +855,23 @@ async function pinDefaultReleaseFeed(): Promise { ) publishingWindowLastGoodCheck = { lastGoodTag: releaseTagsResult.lastGoodTag } autoUpdater.setFeedURL({ provider: 'generic', url }) - return + return 'ready' } clearPublishingWindowLastGoodCheck() console.info( `[updater] release feed deferred: current=${currentVersion} includePrerelease=${includePrerelease}; newest release assets are still publishing` ) throw new Error('Latest release assets are still publishing') + } else if (isPerfCheck) { + clearPrereleaseFallbackContext() + clearPublishingWindowLastGoodCheck() + if (releaseTagsResult.state === 'no-newer') { + console.info( + `[updater] perf release not found: current=${currentVersion} includePrerelease=${includePrerelease}` + ) + return 'not-available' + } + throw new Error('Could not resolve perf update feed') } else { clearPrereleaseFallbackContext() clearPublishingWindowLastGoodCheck() @@ -840,6 +880,7 @@ async function pinDefaultReleaseFeed(): Promise { `[updater] release feed fallback: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}` ) autoUpdater.setFeedURL({ provider: 'generic', url }) + return 'ready' } } @@ -972,6 +1013,10 @@ export function checkForUpdates(): void { }) } +function enablePrereleaseManifestChecks(): void { + getAutoUpdater().allowPrerelease = true +} + function enableIncludePrerelease(): void { if (includePrereleaseActive) { return @@ -980,20 +1025,26 @@ function enableIncludePrerelease(): void { // accept a prerelease manifest for users who intentionally Shift-clicked. // We keep using the manifest-probed generic feed instead of the native // GitHub provider because cancelled RC releases can appear without assets. - getAutoUpdater().allowPrerelease = true + enablePrereleaseManifestChecks() includePrereleaseActive = true } /** Menu-triggered check — delegates feedback to renderer toasts via userInitiated flag */ -export function checkForUpdatesFromMenu(options?: { includePrerelease?: boolean }): void { +export function checkForUpdatesFromMenu(options?: UpdateCheckOptions): void { if (!app.isPackaged || is.dev) { sendStatus({ state: 'not-available', userInitiated: true }) return } - if (options?.includePrerelease) { + const checkVariant = getUpdateCheckVariant(options) + if (checkVariant === 'prerelease') { clearPrereleaseFallbackContext() enableIncludePrerelease() + } else if (checkVariant === 'perf') { + clearPrereleaseFallbackContext() + // Why: perf checks need prerelease manifests for this check, but must not + // opt future default/background checks into the RC channel. + enablePrereleaseManifestChecks() } const checkAlreadyInFlight = backgroundCheckLaunchPending || currentStatus.state === 'checking' @@ -1009,10 +1060,10 @@ export function checkForUpdatesFromMenu(options?: { includePrerelease?: boolean if (checkAlreadyInFlight) { backgroundCheckPromotedToUserInitiated = true rearmActiveUpdateCheckStallTimer() - if (options?.includePrerelease) { - // Why: the in-flight check may have already pinned the stable feed. - // Queue a fresh RC check so Shift-click doesn't inherit a stable result. - pendingPrereleaseUserInitiatedCheckAfterInFlight = true + if (checkVariant !== 'default') { + // Why: the in-flight check may have already pinned the stable feed. Queue + // a fresh modifier check so it doesn't inherit a stale-channel result. + pendingUserInitiatedCheckAfterInFlight = checkVariant } return } @@ -1026,9 +1077,26 @@ export function checkForUpdatesFromMenu(options?: { includePrerelease?: boolean markUpdateCheckLaunched(attemptId) return autoUpdater.checkForUpdates() } - const run = pinDefaultReleaseFeed().then(launch) + const run = pinDefaultReleaseFeed(checkVariant).then((preflightResult) => { + if (preflightResult === 'not-available') { + if (!isActiveUpdateCheckAttempt(attemptId)) { + return false + } + userInitiatedCheck = false + finishActiveUpdateCheckAttempt() + recordCompletedUpdateCheck() + sendStatus({ state: 'not-available', userInitiated: true }) + return false + } + return launch() + }) void Promise.resolve(run) - .then(() => handleSettledUpdateCheckPromise(attemptId)) + .then((launchResult) => { + if (launchResult === false) { + return + } + handleSettledUpdateCheckPromise(attemptId) + }) .catch((err) => { if (!isActiveUpdateCheckAttempt(attemptId)) { return diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 674bd39e3f2..2d34a4c9fd0 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -4,7 +4,11 @@ import { randomUUID } from 'node:crypto' import { app, ipcMain } from 'electron' import type { BrowserWindow } from 'electron' import type { Store } from '../persistence' -import type { CreateWorktreeResult, WorktreeStartupLaunch } from '../../shared/types' +import type { + CreateWorktreeResult, + UpdateCheckOptions, + WorktreeStartupLaunch +} from '../../shared/types' import { registerRepoHandlers } from '../ipc/repos' import { registerWorktreeHandlers } from '../ipc/worktrees' import { registerWorkspaceCleanupHandlers } from '../ipc/workspace-cleanup' @@ -448,7 +452,7 @@ export function registerUpdaterHandlers(_store: Store): void { ipcMain.handle('updater:getStatus', () => getUpdateStatus()) ipcMain.handle('updater:getVersion', () => app.getVersion()) - ipcMain.handle('updater:check', (_event, options?: { includePrerelease?: boolean }) => { + ipcMain.handle('updater:check', (_event, options?: UpdateCheckOptions) => { ensureAutoUpdaterConfigured() return checkForUpdatesFromMenu(options) }) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 0af29e96d02..a6cf04e7d75 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -162,6 +162,7 @@ import type { StatsSummary, MemorySnapshot, TuiAgent, + UpdateCheckOptions, UpdateStatus, Worktree, WorktreeBaseStatusEvent, @@ -1150,6 +1151,7 @@ export type PreloadApi = { resize: (id: string, cols: number, rows: number) => void reportGeometry: (id: string, cols: number, rows: number) => void signal: (id: string, signal: string) => void + clearBuffer: (id: string) => void kill: (id: string, opts?: { keepHistory?: boolean }) => Promise ackColdRestore: (id: string) => void ackData: (id: string, charCount: number) => void @@ -2159,7 +2161,7 @@ export type PreloadApi = { updater: { getVersion: () => Promise getStatus: () => Promise - check: (options?: { includePrerelease?: boolean }) => Promise + check: (options?: UpdateCheckOptions) => Promise download: () => Promise quitAndInstall: () => Promise dismissNudge: () => Promise @@ -2796,8 +2798,14 @@ export type PreloadApi = { setPollingInterval: (ms: number) => Promise fetchInactiveClaudeAccounts: () => Promise fetchInactiveCodexAccounts: () => Promise + refreshMiniMax: () => Promise onUpdate: (callback: (state: RateLimitState) => void) => () => void } + minimaxCredentials: { + getStatus: () => Promise<{ configured: boolean }> + saveCookie: (cookie: string) => Promise<{ configured: boolean }> + clearCookie: () => Promise<{ configured: boolean }> + } ssh: { listTargets: () => Promise addTarget: (args: { target: Omit }) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 01a42bd43e7..ba7760a5bf9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -812,6 +812,13 @@ const api = { ipcRenderer.send('pty:signal', { id, signal }) }, + /** Why: Cmd/Ctrl+K clears the renderer xterm, but the PTY host (ConPTY, + * daemon emulator, SSH host buffer) keeps its own screen state and would + * repaint the next prompt at the stale cursor row. */ + clearBuffer: (id: string): void => { + ipcRenderer.send('pty:clearBuffer', { id }) + }, + ackColdRestore: (id: string): void => { ipcRenderer.send('pty:ackColdRestore', { id }) }, @@ -3799,6 +3806,7 @@ const api = { ipcRenderer.invoke('rateLimits:fetchInactiveClaudeAccounts'), fetchInactiveCodexAccounts: (): Promise => ipcRenderer.invoke('rateLimits:fetchInactiveCodexAccounts'), + refreshMiniMax: (): Promise => ipcRenderer.invoke('rateLimits:refreshMiniMax'), onUpdate: (callback: (state: RateLimitState) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, state: RateLimitState) => callback(state) ipcRenderer.on('rateLimits:update', listener) @@ -3806,6 +3814,15 @@ const api = { } }, + minimaxCredentials: { + getStatus: (): Promise<{ configured: boolean }> => + ipcRenderer.invoke('minimaxCredentials:getStatus'), + saveCookie: (cookie: string): Promise<{ configured: boolean }> => + ipcRenderer.invoke('minimaxCredentials:saveCookie', cookie), + clearCookie: (): Promise<{ configured: boolean }> => + ipcRenderer.invoke('minimaxCredentials:clearCookie') + }, + ssh: { listTargets: (): Promise => ipcRenderer.invoke('ssh:listTargets'), diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index a1b4ee08660..0c4a263f13e 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -29,6 +29,10 @@ import { RelayStreamRegistry } from './fs-stream-registry' import { scanWorkspaceSpaceDirectory } from './workspace-space-scan' import { buildRelayCommandEnv } from './relay-command-env' import { assertNoClobberRenameDestinationAvailable } from '../shared/filesystem-rename-collision' +import { + WATCHER_IGNORE_DIRS, + buildParcelWatcherIgnoreOption +} from '../main/ipc/filesystem-watcher-ignore' type WatchState = { rootPath: string @@ -408,7 +412,9 @@ export class FsHandler { })) this.dispatcher.notify('fs.changed', { events: mapped }) }, - { ignore: ['.git', 'node_modules', 'dist', 'build', '.next', '.cache', '__pycache__'] } + // Why: align remote-Linux watchers with the shared nested-glob exclusion + // so nested node_modules/.git don't exhaust inotify on large codebases. + { ignore: buildParcelWatcherIgnoreOption(WATCHER_IGNORE_DIRS) } ) watchState.unwatchFn = () => { void subscription.unsubscribe() diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index ed3a37ff449..aa35e99571a 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -28,7 +28,7 @@ vi.mock('node-pty', () => ({ spawn: mockPtySpawn })) -import { PtyHandler } from './pty-handler' +import { PtyHandler, attachIdentityMismatches } from './pty-handler' import type { RelayDispatcher } from './dispatcher' function createMockDispatcher() { @@ -821,6 +821,51 @@ describe('PtyHandler', () => { expect(dispatcher.notify).not.toHaveBeenCalledWith('pty.data', expect.anything()) }) + it('uses attach identity metadata without exporting it to the shell env', async () => { + const oldPaneKey = process.env.ORCA_PANE_KEY + const oldTabId = process.env.ORCA_TAB_ID + delete process.env.ORCA_PANE_KEY + delete process.env.ORCA_TAB_ID + try { + await dispatcher.callRequest('pty.spawn', { + env: { FOO: 'bar' }, + paneKey: 'tab-a:leaf-a', + tabId: 'tab-a' + }) + } finally { + if (oldPaneKey === undefined) { + delete process.env.ORCA_PANE_KEY + } else { + process.env.ORCA_PANE_KEY = oldPaneKey + } + if (oldTabId === undefined) { + delete process.env.ORCA_TAB_ID + } else { + process.env.ORCA_TAB_ID = oldTabId + } + } + + const spawnOptions = mockPtySpawn.mock.calls[0][2] as { env: Record } + expect(spawnOptions.env.ORCA_PANE_KEY).toBeUndefined() + expect(spawnOptions.env.ORCA_TAB_ID).toBeUndefined() + + await expect( + dispatcher.callRequest('pty.attach', { + id: 'pty-1', + expectedPaneKey: 'tab-b:leaf-b', + expectedTabId: 'tab-b' + }) + ).rejects.toThrow('PTY "pty-1" not found') + + await expect( + dispatcher.callRequest('pty.attach', { + id: 'pty-1', + expectedPaneKey: 'tab-a:leaf-a', + expectedTabId: 'tab-a' + }) + ).resolves.toEqual({}) + }) + it('notifies on PTY exit and removes from map', async () => { let exitCallback: ((info: { exitCode: number }) => void) | undefined mockPtySpawn.mockReturnValue({ @@ -1291,6 +1336,70 @@ describe('PtyHandler', () => { expect(callArgs.env.ORCA_AGENT_HOOK_TOKEN).toBe('abc-uuid') }) + it('revive preserves attach identity metadata without exporting hook identity env', async () => { + const oldPaneKey = process.env.ORCA_PANE_KEY + const oldTabId = process.env.ORCA_TAB_ID + delete process.env.ORCA_PANE_KEY + delete process.env.ORCA_TAB_ID + try { + await dispatcher.callRequest('pty.spawn', { + cols: 90, + rows: 30, + cwd: '/tmp', + env: { FOO: 'bar' }, + paneKey: 'tab-5:leaf-5', + tabId: 'tab-5' + }) + } finally { + if (oldPaneKey === undefined) { + delete process.env.ORCA_PANE_KEY + } else { + process.env.ORCA_PANE_KEY = oldPaneKey + } + if (oldTabId === undefined) { + delete process.env.ORCA_TAB_ID + } else { + process.env.ORCA_TAB_ID = oldTabId + } + } + const state = (await dispatcher.callRequest('pty.serialize', { ids: ['pty-1'] })) as string + + handler.dispose() + mockPtySpawn.mockClear() + dispatcher = createMockDispatcher() + handler = new PtyHandler(dispatcher as unknown as RelayDispatcher) + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) + delete process.env.ORCA_PANE_KEY + delete process.env.ORCA_TAB_ID + try { + await dispatcher.callRequest('pty.revive', { state }) + } finally { + killSpy.mockRestore() + if (oldPaneKey === undefined) { + delete process.env.ORCA_PANE_KEY + } else { + process.env.ORCA_PANE_KEY = oldPaneKey + } + if (oldTabId === undefined) { + delete process.env.ORCA_TAB_ID + } else { + process.env.ORCA_TAB_ID = oldTabId + } + } + + const callArgs = mockPtySpawn.mock.calls[0][2] as { env: Record } + expect(callArgs.env.ORCA_PANE_KEY).toBeUndefined() + expect(callArgs.env.ORCA_TAB_ID).toBeUndefined() + + await expect( + dispatcher.callRequest('pty.attach', { + id: 'pty-1', + expectedPaneKey: 'tab-other:leaf', + expectedTabId: 'tab-other' + }) + ).rejects.toThrow('PTY "pty-1" not found') + }) + it('invokes the exit listener with the spawn-time paneKey', async () => { let onExitCb: ((evt: { exitCode: number }) => void) | undefined mockPtySpawn.mockReturnValue({ @@ -1366,3 +1475,39 @@ describe('PtyHandler', () => { expect(handler.activePtyCount).toBe(0) }) }) + +describe('attachIdentityMismatches', () => { + it('rejects a paneKey collision across relay generations', () => { + // Old lease expects tab-a's pane; the reset relay's pty-1 belongs to tab-b. + expect( + attachIdentityMismatches({ paneKey: 'tab-a:0' }, { paneKey: 'tab-b:0', tabId: 'tab-b' }) + ).toBe(true) + }) + + it('rejects a tabId collision when only tab identity is known', () => { + expect(attachIdentityMismatches({ tabId: 'tab-a' }, { tabId: 'tab-b' })).toBe(true) + }) + + it('accepts a matching identity', () => { + expect( + attachIdentityMismatches( + { paneKey: 'tab-a:0', tabId: 'tab-a' }, + { paneKey: 'tab-a:0', tabId: 'tab-a' } + ) + ).toBe(false) + }) + + it('stays permissive when the caller supplies no identity', () => { + expect(attachIdentityMismatches({}, { paneKey: 'tab-a:0', tabId: 'tab-a' })).toBe(false) + }) + + it('stays permissive when the managed PTY predates identity capture', () => { + expect(attachIdentityMismatches({ paneKey: 'tab-a:0', tabId: 'tab-a' }, {})).toBe(false) + }) + + it('does not reject on tabId when paneKey matches (split panes share a tab)', () => { + expect( + attachIdentityMismatches({ paneKey: 'tab-a:1' }, { paneKey: 'tab-a:1', tabId: 'tab-a' }) + ).toBe(false) + }) +}) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 126f761333b..b539148ce00 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -15,6 +15,7 @@ import { import { getRelayShellLaunchConfig } from './pty-shell-launch' import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types' import { shouldUseShellReadyStartupDelivery } from '../shared/codex-startup-delivery' +import { buildStartupCommandSubmission } from '../shared/startup-command-submission' import { resolveSetupAgentSequenceLaunchCommand } from '../shared/setup-agent-sequencing' import { createShellReadyScanState, @@ -61,6 +62,9 @@ type ManagedPty = { * state when this PTY exits. Symmetric with Orca's local pty.ts. */ paneKey?: string tabId?: string + /** Attach-only identity metadata supplied over RPC. Kept separate from + * paneKey/tabId because those fields also control shell env/revive hooks. */ + attachIdentity?: PtyIdentity worktreeId?: string terminalHandle?: string startupCommand?: ManagedStartupCommand @@ -172,11 +176,28 @@ type SerializedPtyEntry = { cwd: string paneKey?: string tabId?: string + attachIdentity?: PtyIdentity worktreeId?: string terminalHandle?: string } export type PtyExitListener = (event: { id: string; paneKey?: string }) => void + +type PtyIdentity = { paneKey?: string; tabId?: string } + +/** + * True when a reattach's expected pane identity contradicts the target PTY's + * own. Used to reject cross-relay-generation id collisions: a reset relay mints + * `pty-N` from 1 again, so an old lease's `pty-N` can name a different pane's + * fresh PTY. Only compares fields present on *both* sides — absent identity on + * either side is permissive so legacy PTYs and identity-less callers still attach. + */ +export function attachIdentityMismatches(expected: PtyIdentity, managed: PtyIdentity): boolean { + return Boolean( + (expected.paneKey && managed.paneKey && expected.paneKey !== managed.paneKey) || + (expected.tabId && managed.tabId && expected.tabId !== managed.tabId) + ) +} /** Returns env to merge into the PTY's spawn env. Receives spawn context so * augmenters that need a per-PTY identity (e.g. OPENCODE_CONFIG_DIR overlay * paths derived from the renderer's paneKey) can compute it without pulling @@ -323,8 +344,14 @@ export class PtyHandler { } } const submit = process.platform === 'win32' ? '\r' : '\n' - const endsWithSubmit = startup.command.endsWith('\r') || startup.command.endsWith('\n') - const payload = endsWithSubmit ? startup.command : `${startup.command}${submit}` + // Why: a multiline startup prompt is pasted literally via bracketed paste + // only when the Orca shell-ready wrapper is active (waitForShellReady) — + // that is the bash/zsh overlay that arms bracketed-paste mode. Other remote + // shells keep the raw submit path so the ESC[200~ markers are not echoed. + const payload = buildStartupCommandSubmission(startup.command, { + submit, + bracketedPasteSafe: startup.waitForShellReady + }) managed.startupCommand = undefined managed.pty.write(payload) } @@ -593,6 +620,10 @@ export class PtyHandler { // separate ptyId→paneKey map. ORCA_PANE_KEY is shaped `${tabId}:${paneId}` // and is bounded by the renderer; the relay treats it as opaque. const tabId = typeof env?.ORCA_TAB_ID === 'string' ? env.ORCA_TAB_ID : undefined + const attachIdentity = { + paneKey: typeof params.paneKey === 'string' ? params.paneKey : paneKey, + tabId: typeof params.tabId === 'string' ? params.tabId : tabId + } const worktreeId = typeof env?.ORCA_WORKTREE_ID === 'string' ? env.ORCA_WORKTREE_ID : undefined const managed: ManagedPty = { id, @@ -601,6 +632,7 @@ export class PtyHandler { buffered: '', paneKey, tabId, + ...(attachIdentity.paneKey || attachIdentity.tabId ? { attachIdentity } : {}), worktreeId, ...(terminalHandle ? { terminalHandle } : {}), ...(shouldProviderDeliverCommand @@ -660,6 +692,25 @@ export class PtyHandler { throw new Error(`PTY "${id}" not found`) } + // Why: PTY ids are a per-relay-process counter (pty-1, pty-2, …). When the + // relay changes generation — an app update deploys a new content-hashed + // relay dir, or a grace-expired relay restarts — the counter resets, so an + // old lease's `pty-N` can name a freshly spawned `pty-N` that belongs to a + // *different* pane. Attaching by id alone then wires a tab to the wrong + // shell. Reject when the caller's expected identity disagrees with this + // PTY's own so the client falls back to a fresh spawn. Absent identity on + // either side stays permissive for backward compatibility. + const mismatch = attachIdentityMismatches( + { + paneKey: typeof params.expectedPaneKey === 'string' ? params.expectedPaneKey : undefined, + tabId: typeof params.expectedTabId === 'string' ? params.expectedTabId : undefined + }, + managed.attachIdentity ?? { paneKey: managed.paneKey, tabId: managed.tabId } + ) + if (mismatch) { + throw new Error(`PTY "${id}" not found (identity mismatch)`) + } + // Replay buffered output. During pty.spawn({ sessionId }) the renderer has // not registered replay handlers yet, so return the bytes to the caller // instead of notifying them too early. @@ -865,6 +916,7 @@ export class PtyHandler { cwd: managed.initialCwd, paneKey: managed.paneKey, tabId: managed.tabId, + attachIdentity: managed.attachIdentity, worktreeId: managed.worktreeId, ...(managed.terminalHandle ? { terminalHandle: managed.terminalHandle } : {}) }) @@ -937,6 +989,7 @@ export class PtyHandler { buffered: '', paneKey: entry.paneKey, tabId: entry.tabId, + attachIdentity: entry.attachIdentity, worktreeId: entry.worktreeId, ...(entry.terminalHandle ? { terminalHandle: entry.terminalHandle } : {}) }) diff --git a/src/relay/pty-shell-launch.ts b/src/relay/pty-shell-launch.ts index 5197e3a021b..5f58b89702c 100644 --- a/src/relay/pty-shell-launch.ts +++ b/src/relay/pty-shell-launch.ts @@ -141,6 +141,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: remote startup files can re-export user defaults after relay spawn. [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index c26cd144a0f..08f39a8b61c 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -478,7 +478,9 @@ .worktree-sidebar-scrollbar::-webkit-scrollbar-thumb { background-color: transparent; - border-width: 3px 3px 3px 0; + /* Why: no right inset — a 3px transparent right border left a visible strip + beside the sidebar resize seam that read like a gap in the drag handle. */ + border-width: 3px 0 3px 0; } .scrollbar-sleek-parent:hover .worktree-sidebar-scrollbar, @@ -1902,6 +1904,16 @@ text-underline-offset: 2px; } +/* Why: Monaco owns Peek References scrollables and auto-hides faded needed + vertical thumbs while the widget stays open; revalidate these classes on upgrade. */ +.monaco-editor + .reference-zone-widget + .monaco-scrollable-element + > .scrollbar.vertical.invisible.fade { + opacity: 1; + pointer-events: auto; +} + /* ── Git conflict decorations ───────────────────────────────────── */ .monaco-editor .orca-conflict-marker-line { diff --git a/src/renderer/src/assets/rich-markdown-editor.css b/src/renderer/src/assets/rich-markdown-editor.css index a8a03d39ca4..395b18131a7 100644 --- a/src/renderer/src/assets/rich-markdown-editor.css +++ b/src/renderer/src/assets/rich-markdown-editor.css @@ -566,6 +566,13 @@ margin: 0.75em 0; } +/* Why: document markdown files treat source newlines inside a paragraph as + soft breaks, so prose must collapse them instead of inheriting Tiptap's + editable-root `break-spaces` behavior. */ +.rich-markdown-editor-shell .rich-markdown-editor p { + white-space: normal; +} + .rich-markdown-editor .tiptap-mathematics-render { border-radius: 4px; } diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index d5c14dc7668..863928204b0 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -7855,7 +7855,12 @@ export default function GitHubItemDialog({ ) : null return ( -
+ // Why: GitHub item details render inline (not a Radix dialog), so e2e needs a + // stable hook to scope assertions to this detail surface. +
{content}
) diff --git a/src/renderer/src/components/automations/automation-target-availability.test.ts b/src/renderer/src/components/automations/automation-target-availability.test.ts index cbc9b029c92..8c9ab713e88 100644 --- a/src/renderer/src/components/automations/automation-target-availability.test.ts +++ b/src/renderer/src/components/automations/automation-target-availability.test.ts @@ -140,6 +140,72 @@ describe('automation target availability', () => { ).toBe('host-mismatch') }) + it('allows a run context whose derived projectId tier drifted but repo/host/path still match', () => { + expect( + getAutomationTargetAvailability({ + automation: makeAutomation({ + runContext: { + kind: 'workspace-run', + // Snapshotted at the repo: tier before the setup climbed to github:. + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'setup-1', + repoId: 'repo-1', + path: '/repo' + } + }), + repo: makeRepo(), + workspace: makeWorkspace(), + projectHostSetups: [makeProjectHostSetup({ projectId: 'github:o/r' })], + sshConnectionStates: new Map() + }) + ).toEqual({ canRunNow: true, reason: 'available', message: null }) + }) + + it('still blocks a run context whose repoId no longer matches despite projectId drift', () => { + expect( + getAutomationTargetAvailability({ + automation: makeAutomation({ + runContext: { + kind: 'workspace-run', + projectId: 'repo:repo-2', + hostId: 'local', + projectHostSetupId: 'setup-1', + repoId: 'repo-2', + path: '/repo' + } + }), + repo: makeRepo(), + workspace: makeWorkspace(), + projectHostSetups: [makeProjectHostSetup({ projectId: 'github:o/r', repoId: 'repo-2' })], + sshConnectionStates: new Map() + }).reason + ).toBe('host-mismatch') + }) + + it('still blocks when the setup repoId drifts even though the live repo still matches', () => { + // Live repo matches runContext.repoId (repoMatchesContext passes), so this + // isolates the setup-side repoId clause that survives the projectId removal. + expect( + getAutomationTargetAvailability({ + automation: makeAutomation({ + runContext: { + kind: 'workspace-run', + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'setup-1', + repoId: 'repo-1', + path: '/repo' + } + }), + repo: makeRepo(), + workspace: makeWorkspace(), + projectHostSetups: [makeProjectHostSetup({ projectId: 'github:o/r', repoId: 'repo-2' })], + sshConnectionStates: new Map() + }).reason + ).toBe('host-mismatch') + }) + it('allows remote-listed SSH automations whose repo is projected through a runtime server', () => { expect( getAutomationTargetAvailability({ diff --git a/src/renderer/src/components/automations/automation-target-availability.ts b/src/renderer/src/components/automations/automation-target-availability.ts index 008b4c9eb48..ca7c166b5a8 100644 --- a/src/renderer/src/components/automations/automation-target-availability.ts +++ b/src/renderer/src/components/automations/automation-target-availability.ts @@ -96,8 +96,10 @@ export function getAutomationTargetAvailability({ `Project setup on the selected automation host is ${setup.setupState}.` ) } + // 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/path/host instead — the durable, stable target identity. const setupMatchesContext = - setup.projectId === automation.runContext.projectId && setup.repoId === automation.runContext.repoId && setup.path === automation.runContext.path && setupHostMatchesRunContext(setup.hostId, automation.runContext.hostId, automationHostTarget) diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.tsx index 28f275a0437..990d2fdaf38 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditor.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditor.tsx @@ -114,12 +114,12 @@ export default function RichMarkdownEditor({ const editorRef = useRef(null) const cancelAutoFocusRef = useRef<(() => void) | null>(null) const serializeTimerRef = useRef(null) - // Why: normalizeSoftBreaks dispatches a ProseMirror transaction inside onCreate + // Why: empty-list repair dispatches a ProseMirror transaction inside onCreate // which triggers onUpdate. Without this guard the editor immediately marks the // file dirty before the user has typed anything. const isInitializingRef = useRef(true) // Why: internal maintenance paths can dispatch transactions after mount - // (external reloads, soft-break normalization, image-path refresh). Those + // (external reloads, empty-list repair, image-path refresh). Those // are not user edits, so onUpdate must ignore them or split panes can flip a // shared file dirty without any real content change. const isApplyingProgrammaticUpdateRef = useRef(false) diff --git a/src/renderer/src/components/editor/RichMarkdownErrorBoundary.tsx b/src/renderer/src/components/editor/RichMarkdownErrorBoundary.tsx index c99cc1a675b..105be54524f 100644 --- a/src/renderer/src/components/editor/RichMarkdownErrorBoundary.tsx +++ b/src/renderer/src/components/editor/RichMarkdownErrorBoundary.tsx @@ -13,7 +13,7 @@ type State = { } // Why: a thrown exception inside the TipTap/ProseMirror render or in the -// effect that runs `setContent` + `normalizeSoftBreaks` on external-reload +// effect that runs `setContent` + empty-list repair on external-reload // would escape to the React root and — without this boundary — cause React // 18 to unmount the entire renderer subtree, blacking out the whole Orca // window (see issue #826). Scoping the boundary to the rich-markdown editor diff --git a/src/renderer/src/components/editor/markdown-dirty-state.test.ts b/src/renderer/src/components/editor/markdown-dirty-state.test.ts index 108772204a0..46b786ddd75 100644 --- a/src/renderer/src/components/editor/markdown-dirty-state.test.ts +++ b/src/renderer/src/components/editor/markdown-dirty-state.test.ts @@ -8,7 +8,7 @@ import { TableCell } from '@tiptap/extension-table-cell' import { TableHeader } from '@tiptap/extension-table-header' import { TableRow } from '@tiptap/extension-table-row' import { Markdown } from '@tiptap/markdown' -import { normalizeSoftBreaks } from './rich-markdown-normalize' +import { normalizeEmptyListItems } from './rich-markdown-normalize' const testExtensions = [ StarterKit, @@ -49,12 +49,12 @@ function shouldSyncPropIntoEditor( } /** - * Simulates the onCreate flow: normalizeSoftBreaks then getMarkdown(). + * Simulates the onCreate flow: empty-list repair then getMarkdown(). */ function simulateOnCreate(diskContent: string): string { const editor = createEditor(diskContent) try { - normalizeSoftBreaks(editor) + normalizeEmptyListItems(editor) return editor.getMarkdown() } finally { editor.destroy() @@ -65,7 +65,7 @@ function simulateOnCreate(diskContent: string): string { // 1. trimEnd normalization prevents phantom dirty from trailing newlines // // getMarkdown() always appends a trailing \n. For content that round-trips -// cleanly (no soft-break normalization), the ONLY difference is that +// cleanly (no structural repair changes), the ONLY difference is that // trailing newline. trimEnd() must eliminate that false positive. // ----------------------------------------------------------------------- describe('trailing newline does not cause false dirty state', () => { @@ -94,45 +94,37 @@ describe('trailing newline does not cause false dirty state', () => { }) // ----------------------------------------------------------------------- -// 2. normalizeSoftBreaks produces structural differences that getMarkdown() -// serializes differently. These cannot be hidden by trimEnd — they are -// handled at runtime by the isInitializingRef guard in onUpdate. -// -// The tests below document the known divergence so that future changes -// to the serializer or normalizer don't silently shift which category -// a given input falls into. +// 2. Hard-wrapped prose must stay structurally clean. The rich editor renders +// soft breaks through CSS reflow, not by splitting the document model. // ----------------------------------------------------------------------- -describe('normalizeSoftBreaks: known structural changes', () => { - it('splits consecutive lines into separate paragraphs', () => { +describe('document soft-break round-trip', () => { + it('keeps consecutive source lines in one paragraph', () => { const editor = createEditor('Line one\nLine two\nLine three') try { const before = countParagraphs(editor) - normalizeSoftBreaks(editor) + normalizeEmptyListItems(editor) const after = countParagraphs(editor) - expect(after).toBeGreaterThan(before) - expect(after).toBe(3) + expect(before).toBe(1) + expect(after).toBe(1) + expect(editor.state.doc.firstChild?.textContent).toBe('Line one\nLine two\nLine three') } finally { editor.destroy() } }) - it('serialized soft-break content differs from disk content', () => { + it('round-trips a hard-wrapped paragraph without blank-line expansion', () => { const disk = 'Line one\nLine two' const serialized = simulateOnCreate(disk) - // After normalization each line is its own paragraph, serialized with - // blank-line separators. This difference is NOT a bug — the - // isInitializingRef guard prevents it from marking the file dirty. - expect(trimEnd(serialized)).not.toBe(trimEnd(disk)) - expect(trimEnd(serialized)).toBe('Line one\n\nLine two') + expect(trimEnd(serialized)).toBe(trimEnd(disk)) }) it('does not modify content without soft breaks', () => { const editor = createEditor('# Title\n\nBody text') try { const docBefore = editor.state.doc.toJSON() - normalizeSoftBreaks(editor) + normalizeEmptyListItems(editor) const docAfter = editor.state.doc.toJSON() expect(docAfter).toEqual(docBefore) @@ -171,7 +163,7 @@ describe('real edits are detected as dirty', () => { const diskContent = '# README\n\nOriginal text' const editor = createEditor(diskContent) try { - normalizeSoftBreaks(editor) + normalizeEmptyListItems(editor) // Insert text via a ProseMirror transaction (no DOM required) const { tr } = editor.state @@ -189,7 +181,7 @@ describe('real edits are detected as dirty', () => { const diskContent = '# Title\n\nParagraph to keep\n\nParagraph to delete' const editor = createEditor(diskContent) try { - normalizeSoftBreaks(editor) + normalizeEmptyListItems(editor) // Delete the last paragraph node const doc = editor.state.doc diff --git a/src/renderer/src/components/editor/rich-markdown-cut.test.ts b/src/renderer/src/components/editor/rich-markdown-cut.test.ts index 3ca4064c4b8..67eb9f3766f 100644 --- a/src/renderer/src/components/editor/rich-markdown-cut.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-cut.test.ts @@ -1,5 +1,8 @@ -import { describe, expect, it } from 'vitest' +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest' import { Editor } from '@tiptap/core' +import { TextSelection } from '@tiptap/pm/state' +import type { EditorView } from '@tiptap/pm/view' import StarterKit from '@tiptap/starter-kit' import TaskList from '@tiptap/extension-task-list' import TaskItem from '@tiptap/extension-task-item' @@ -8,7 +11,8 @@ import { TableCell } from '@tiptap/extension-table-cell' import { TableHeader } from '@tiptap/extension-table-header' import { TableRow } from '@tiptap/extension-table-row' import { Markdown } from '@tiptap/markdown' -import { normalizeSoftBreaks } from './rich-markdown-normalize' +import { handleRichMarkdownCut } from './rich-markdown-cut-handler' +import { normalizeEmptyListItems, normalizeSoftBreaks } from './rich-markdown-normalize' /** * Minimal extensions matching the rich editor schema without UI dependencies. @@ -33,6 +37,10 @@ function createEditor(markdown: string): Editor { }) } +afterEach(() => { + vi.restoreAllMocks() +}) + /** * Simulates the cut handler's depth walk to determine what node would be cut. * This mirrors the logic in RichMarkdownEditor.tsx handleDOMEvents.cut, @@ -101,6 +109,25 @@ function countParagraphs(editor: Editor): number { return count } +function createClipboardEventMock(): { + data: Map + event: ClipboardEvent + preventDefault: ReturnType +} { + const data = new Map() + const preventDefault = vi.fn() + const event = { + clipboardData: { + setData: vi.fn((type: string, value: string) => { + data.set(type, value) + }) + }, + preventDefault + } as unknown as ClipboardEvent + + return { data, event, preventDefault } +} + describe('rich markdown cut handler behavior', () => { it('heading + paragraph: cuts only the paragraph', () => { const editor = createEditor('# Title\n\nBody text here.\n') @@ -121,20 +148,25 @@ describe('rich markdown cut handler behavior', () => { editor.destroy() }) - it('multi-line markdown (no blank separator): after normalization, each line is a separate paragraph', () => { + it('hard-wrapped document prose stays one paragraph after normalization', () => { const editor = createEditor('Line one\nLine two\nLine three\n') - // Before normalization: single paragraph with \n chars expect(countParagraphs(editor)).toBe(1) expect(editor.state.doc.firstChild!.textContent).toContain('\n') - // Normalize: splits the single paragraph into three + normalizeEmptyListItems(editor) + + expect(countParagraphs(editor)).toBe(1) + expect(editor.state.doc.firstChild!.textContent).toBe('Line one\nLine two\nLine three') + + editor.destroy() + }) + + it('soft-break normalization still creates visible paragraph breaks', () => { + const editor = createEditor('Line one\nLine two\nLine three\n') normalizeSoftBreaks(editor) - // After normalization: three separate paragraph nodes expect(countParagraphs(editor)).toBe(3) - - // Each paragraph is its own line — no \n inside any of them const paragraphs: string[] = [] editor.state.doc.forEach((node) => { if (node.type.name === 'paragraph') { @@ -144,37 +176,71 @@ describe('rich markdown cut handler behavior', () => { }) expect(paragraphs).toEqual(['Line one', 'Line two', 'Line three']) - // Cmd+X on the first paragraph only cuts "Line one", not all three lines - const result = simulateCut(editor, 1) - expect(result.cutNodeType).toBe('paragraph') - expect(result.cutText).toBe('Line one') - editor.destroy() }) - it('multi-line: Cmd+X on second line only cuts that line after normalization', () => { - const editor = createEditor('Line one\nLine two\nLine three\n') - normalizeSoftBreaks(editor) + it('Cmd+X cuts only a visual line inside a hard-wrapped paragraph', () => { + const editor = createEditor('Alpha segment stays\nMiddle segment is cut\nOmega segment stays') + try { + normalizeEmptyListItems(editor) + expect(countParagraphs(editor)).toBe(1) - // Find the second paragraph ("Line two") - const doc = editor.state.doc - let secondParaPos = -1 - let count = 0 - doc.forEach((node, offset) => { - if (node.type.name === 'paragraph') { - count++ - if (count === 2) { - secondParaPos = offset + 1 + const text = editor.state.doc.firstChild!.textContent + const paraStart = 1 + const paraEnd = paraStart + text.length + const lineFrom = paraStart + text.indexOf('Middle') + const nextLineFrom = paraStart + text.indexOf('Omega') + const cursorPos = lineFrom + 'Middle'.length + + let viewState = editor.state.apply( + editor.state.tr.setSelection(TextSelection.create(editor.state.doc, cursorPos)) + ) + + const paragraphElement = document.createElement('p') + vi.spyOn(paragraphElement, 'getBoundingClientRect').mockReturnValue( + DOMRect.fromRect({ x: 20, y: 0, width: 600, height: 60 }) + ) + const view = { + get state() { + return viewState + }, + dispatch: vi.fn((tr) => { + viewState = viewState.apply(tr) + }), + domAtPos: vi.fn(() => ({ node: paragraphElement, offset: 0 })), + coordsAtPos: vi.fn((pos: number) => { + if (pos === paraStart) { + return { top: 0, bottom: 20, left: 20, right: 20 } + } + if (pos === paraEnd) { + return { top: 40, bottom: 60, left: 280, right: 280 } + } + return { top: 20, bottom: 40, left: 120, right: 120 } + }), + posAtCoords: vi.fn((coords: { top: number }) => { + return { pos: coords.top < 40 ? lineFrom : nextLineFrom, inside: -1 } + }) + } as unknown as EditorView + + const clipboard = createClipboardEventMock() + const handled = handleRichMarkdownCut(view, clipboard.event) + + expect(handled).toBe(true) + expect(clipboard.preventDefault).toHaveBeenCalled() + expect(clipboard.data.get('text/plain')).toBe('Middle segment is cut\n') + expect(view.state.doc.firstChild!.textContent).toBe( + 'Alpha segment stays\nOmega segment stays' + ) + let paragraphCount = 0 + view.state.doc.forEach((node) => { + if (node.type.name === 'paragraph') { + paragraphCount++ } - } - }) - - expect(secondParaPos).toBeGreaterThan(0) - const result = simulateCut(editor, secondParaPos) - expect(result.cutNodeType).toBe('paragraph') - expect(result.cutText).toBe('Line two') - - editor.destroy() + }) + expect(paragraphCount).toBe(1) + } finally { + editor.destroy() + } }) it('paragraphs separated by blank lines are separate blocks', () => { @@ -348,11 +414,11 @@ describe('rich markdown cut handler behavior', () => { editor.destroy() }) - it('normalizeSoftBreaks is idempotent on already-clean documents', () => { + it('normalizeEmptyListItems is idempotent on already-clean documents', () => { const editor = createEditor('First.\n\nSecond.\n\nThird.\n') const docBefore = editor.state.doc.toJSON() - normalizeSoftBreaks(editor) + normalizeEmptyListItems(editor) const docAfter = editor.state.doc.toJSON() // Already separated paragraphs should not be modified @@ -361,11 +427,11 @@ describe('rich markdown cut handler behavior', () => { editor.destroy() }) - it('normalizeSoftBreaks does not modify list items or blockquotes', () => { + it('normalizeEmptyListItems does not modify populated list items or blockquotes', () => { const editor = createEditor('- Item 1\n- Item 2\n') const docBefore = editor.state.doc.toJSON() - normalizeSoftBreaks(editor) + normalizeEmptyListItems(editor) const docAfter = editor.state.doc.toJSON() // List structure should be unchanged (no top-level paragraphs to split) diff --git a/src/renderer/src/components/editor/rich-markdown-editor-config.ts b/src/renderer/src/components/editor/rich-markdown-editor-config.ts index de2e520d631..28536b258c5 100644 --- a/src/renderer/src/components/editor/rich-markdown-editor-config.ts +++ b/src/renderer/src/components/editor/rich-markdown-editor-config.ts @@ -2,7 +2,7 @@ import type { Editor, UseEditorOptions } from '@tiptap/react' import { handleRichMarkdownCut } from './rich-markdown-cut-handler' import { handleRichMarkdownPaste } from './rich-markdown-paste-handler' import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html' -import { normalizeSoftBreaks } from './rich-markdown-normalize' +import { normalizeEmptyListItems } from './rich-markdown-normalize' import { autoFocusRichEditor } from './rich-markdown-auto-focus' import { syncSlashMenu, @@ -208,7 +208,10 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE clearAnnotationTarget() }, onCreate: ({ editor: nextEditor }) => { - normalizeSoftBreaks(nextEditor) + // Why: normalizeEmptyListItems (not normalizeSoftBreaks) so hard-wrapped + // source paragraphs stay one paragraph and reflow via CSS instead of being + // split on load. + normalizeEmptyListItems(nextEditor) lastCommittedMarkdownRef.current = content isInitializingRef.current = false cancelAutoFocusRef.current?.() diff --git a/src/renderer/src/components/editor/rich-markdown-empty-paragraph-delete.test.ts b/src/renderer/src/components/editor/rich-markdown-empty-paragraph-delete.test.ts new file mode 100644 index 00000000000..bfdb60af423 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-empty-paragraph-delete.test.ts @@ -0,0 +1,311 @@ +import { describe, expect, it } from 'vitest' +import { Editor } from '@tiptap/core' +import StarterKit from '@tiptap/starter-kit' +import { Markdown } from '@tiptap/markdown' +import { deleteAdjacentEmptyParagraph } from './rich-markdown-empty-paragraph-delete' + +const extensions = [StarterKit, Markdown.configure({ markedOptions: { gfm: true } })] + +const hardWrappedMarkdown = + 'Alpha owns launch-lifetime state keyed by tab id, while native\n' + + 'chat owns conversion, ordering, and transcript reconciliation.\n\n' + + '## Next section' + +const hardWrappedTwoParagraphs = + 'Alpha owns launch-lifetime state keyed by tab id, while native\n' + + 'chat owns conversion, ordering, and transcript reconciliation.\n\n' + + 'Second paragraph starts here.' + +const headingBeforeHardWrappedParagraph = + '## Existing section\n\n' + + 'Alpha owns launch-lifetime state keyed by tab id, while native\n' + + 'chat owns conversion, ordering, and transcript reconciliation.' + +function createEditor(content = hardWrappedMarkdown): Editor { + return new Editor({ + element: null, + extensions, + content, + contentType: 'markdown' + }) +} + +function findNodeTextPosition( + editor: Editor, + nodeName: string, + textNeedle: string +): { from: number; to: number } { + let result: { from: number; to: number } | null = null + editor.state.doc.descendants((node, pos) => { + if (node.type.name === nodeName && node.textContent.includes(textNeedle)) { + result = { from: pos + 1, to: pos + node.nodeSize - 1 } + return false + } + return true + }) + + if (!result) { + throw new Error(`Could not find ${nodeName} containing ${textNeedle}`) + } + + return result +} + +function countHardBreaks(editor: Editor): number { + let count = 0 + editor.state.doc.descendants((node) => { + if (node.type.name === 'hardBreak') { + count += 1 + } + }) + return count +} + +function expectHardWrappedParagraphPreserved(editor: Editor): void { + expect(countHardBreaks(editor)).toBe(0) + expect(editor.state.doc.firstChild?.type.name).toBe('paragraph') + expect(editor.state.doc.firstChild?.textContent).toBe( + 'Alpha owns launch-lifetime state keyed by tab id, while native\n' + + 'chat owns conversion, ordering, and transcript reconciliation.' + ) + expect(editor.getMarkdown().trimEnd()).toBe(hardWrappedMarkdown) +} + +function expectNoHardBreaks(editor: Editor): void { + expect(countHardBreaks(editor)).toBe(0) +} + +describe('rich markdown empty paragraph deletion', () => { + it('Backspace in an inserted empty paragraph preserves soft-wrapped prose', () => { + const editor = createEditor() + try { + const paragraph = findNodeTextPosition(editor, 'paragraph', 'launch-lifetime') + editor.commands.setTextSelection(paragraph.to) + expect(editor.commands.splitBlock()).toBe(true) + + expect(deleteAdjacentEmptyParagraph(editor, 'backward')).toBe(true) + + expectHardWrappedParagraphPreserved(editor) + } finally { + editor.destroy() + } + }) + + it('Backspace at the next block start removes a previous empty paragraph without joining', () => { + const editor = createEditor() + try { + const paragraph = findNodeTextPosition(editor, 'paragraph', 'launch-lifetime') + editor.commands.setTextSelection(paragraph.to) + expect(editor.commands.splitBlock()).toBe(true) + + const heading = findNodeTextPosition(editor, 'heading', 'Next section') + editor.commands.setTextSelection(heading.from) + + expect(deleteAdjacentEmptyParagraph(editor, 'backward')).toBe(true) + + expectHardWrappedParagraphPreserved(editor) + } finally { + editor.destroy() + } + }) + + it('Delete at a soft-wrapped paragraph end removes the following empty paragraph', () => { + const editor = createEditor() + try { + const paragraph = findNodeTextPosition(editor, 'paragraph', 'launch-lifetime') + editor.commands.setTextSelection(paragraph.to) + expect(editor.commands.splitBlock()).toBe(true) + editor.commands.setTextSelection(paragraph.to) + + expect(deleteAdjacentEmptyParagraph(editor, 'forward')).toBe(true) + + expectHardWrappedParagraphPreserved(editor) + } finally { + editor.destroy() + } + }) + + it('Delete in an empty paragraph before soft-wrapped prose preserves soft newlines', () => { + const editor = createEditor(`\n\n${hardWrappedMarkdown}`) + try { + editor.commands.setTextSelection(1) + + expect(deleteAdjacentEmptyParagraph(editor, 'forward')).toBe(true) + + expectNoHardBreaks(editor) + expect(editor.state.doc.firstChild?.textContent).toBe( + 'Alpha owns launch-lifetime state keyed by tab id, while native\n' + + 'chat owns conversion, ordering, and transcript reconciliation.' + ) + } finally { + editor.destroy() + } + }) + + it('Backspace after deleting slash text in an empty command paragraph preserves soft newlines', () => { + const editor = createEditor() + try { + const paragraph = findNodeTextPosition(editor, 'paragraph', 'launch-lifetime') + editor.commands.setTextSelection(paragraph.to) + expect(editor.commands.splitBlock()).toBe(true) + const slashPos = editor.state.selection.from + editor.view.dispatch(editor.state.tr.insertText('/', slashPos)) + editor.view.dispatch(editor.state.tr.delete(slashPos, slashPos + 1)) + + expect(deleteAdjacentEmptyParagraph(editor, 'backward')).toBe(true) + + expectHardWrappedParagraphPreserved(editor) + } finally { + editor.destroy() + } + }) + + it('Backspace joining two non-empty paragraphs preserves source soft newlines', () => { + const editor = createEditor(hardWrappedTwoParagraphs) + try { + const secondParagraph = findNodeTextPosition(editor, 'paragraph', 'Second paragraph') + editor.commands.setTextSelection(secondParagraph.from) + + expect(deleteAdjacentEmptyParagraph(editor, 'backward')).toBe(true) + + expectNoHardBreaks(editor) + expect(editor.state.doc.childCount).toBe(1) + expect(editor.state.doc.firstChild?.textContent).toBe( + 'Alpha owns launch-lifetime state keyed by tab id, while native\n' + + 'chat owns conversion, ordering, and transcript reconciliation.Second paragraph starts here.' + ) + } finally { + editor.destroy() + } + }) + + it('Delete joining two non-empty paragraphs preserves source soft newlines', () => { + const editor = createEditor(hardWrappedTwoParagraphs) + try { + const firstParagraph = findNodeTextPosition(editor, 'paragraph', 'launch-lifetime') + editor.commands.setTextSelection(firstParagraph.to) + + expect(deleteAdjacentEmptyParagraph(editor, 'forward')).toBe(true) + + expectNoHardBreaks(editor) + expect(editor.state.doc.childCount).toBe(1) + expect(editor.state.doc.firstChild?.textContent).toBe( + 'Alpha owns launch-lifetime state keyed by tab id, while native\n' + + 'chat owns conversion, ordering, and transcript reconciliation.Second paragraph starts here.' + ) + } finally { + editor.destroy() + } + }) + + it('Backspace joining a following heading preserves source soft newlines', () => { + const editor = createEditor() + try { + const heading = findNodeTextPosition(editor, 'heading', 'Next section') + editor.commands.setTextSelection(heading.from) + + expect(deleteAdjacentEmptyParagraph(editor, 'backward')).toBe(true) + + expectNoHardBreaks(editor) + expect(editor.state.doc.childCount).toBe(1) + expect(editor.state.doc.firstChild?.type.name).toBe('paragraph') + expect(editor.state.doc.firstChild?.textContent).toBe( + 'Alpha owns launch-lifetime state keyed by tab id, while native\n' + + 'chat owns conversion, ordering, and transcript reconciliation.Next section' + ) + } finally { + editor.destroy() + } + }) + + it('Delete joining a following heading preserves source soft newlines', () => { + const editor = createEditor() + try { + const firstParagraph = findNodeTextPosition(editor, 'paragraph', 'launch-lifetime') + editor.commands.setTextSelection(firstParagraph.to) + + expect(deleteAdjacentEmptyParagraph(editor, 'forward')).toBe(true) + + expectNoHardBreaks(editor) + expect(editor.state.doc.childCount).toBe(1) + expect(editor.state.doc.firstChild?.type.name).toBe('paragraph') + expect(editor.state.doc.firstChild?.textContent).toBe( + 'Alpha owns launch-lifetime state keyed by tab id, while native\n' + + 'chat owns conversion, ordering, and transcript reconciliation.Next section' + ) + } finally { + editor.destroy() + } + }) + + it('Backspace joining from a previous heading preserves source soft newlines', () => { + const editor = createEditor(headingBeforeHardWrappedParagraph) + try { + const paragraph = findNodeTextPosition(editor, 'paragraph', 'launch-lifetime') + editor.commands.setTextSelection(paragraph.from) + + expect(deleteAdjacentEmptyParagraph(editor, 'backward')).toBe(true) + + expectNoHardBreaks(editor) + expect(editor.state.doc.childCount).toBe(2) + expect(editor.state.doc.child(0).type.name).toBe('heading') + expect(editor.state.doc.child(0).textContent).toBe( + 'Existing sectionAlpha owns launch-lifetime state keyed by tab id, while native' + ) + expect(editor.state.doc.child(1).type.name).toBe('paragraph') + expect(editor.state.doc.child(1).textContent).toBe( + 'chat owns conversion, ordering, and transcript reconciliation.' + ) + expect(editor.getMarkdown()).not.toContain(' \n') + } finally { + editor.destroy() + } + }) + + it('Delete joining from a previous heading preserves source soft newlines', () => { + const editor = createEditor(headingBeforeHardWrappedParagraph) + try { + const heading = findNodeTextPosition(editor, 'heading', 'Existing section') + editor.commands.setTextSelection(heading.to) + + expect(deleteAdjacentEmptyParagraph(editor, 'forward')).toBe(true) + + expectNoHardBreaks(editor) + expect(editor.state.doc.childCount).toBe(2) + expect(editor.state.doc.child(0).type.name).toBe('heading') + expect(editor.state.doc.child(0).textContent).toBe( + 'Existing sectionAlpha owns launch-lifetime state keyed by tab id, while native' + ) + expect(editor.state.doc.child(1).type.name).toBe('paragraph') + expect(editor.state.doc.child(1).textContent).toBe( + 'chat owns conversion, ordering, and transcript reconciliation.' + ) + expect(editor.getMarkdown()).not.toContain(' \n') + } finally { + editor.destroy() + } + }) + + it('works inside blockquotes without converting soft newlines to hard breaks', () => { + const editor = createEditor( + '> Alpha owns launch-lifetime state keyed by tab id, while native\n' + + '> chat owns conversion, ordering, and transcript reconciliation.\n' + + '>\n' + + '> Second paragraph starts here.' + ) + try { + const secondParagraph = findNodeTextPosition(editor, 'paragraph', 'Second paragraph') + editor.commands.setTextSelection(secondParagraph.from) + + expect(deleteAdjacentEmptyParagraph(editor, 'backward')).toBe(true) + + expectNoHardBreaks(editor) + expect(editor.state.doc.firstChild?.firstChild?.textContent).toBe( + 'Alpha owns launch-lifetime state keyed by tab id, while native\n' + + 'chat owns conversion, ordering, and transcript reconciliation.Second paragraph starts here.' + ) + } finally { + editor.destroy() + } + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-empty-paragraph-delete.ts b/src/renderer/src/components/editor/rich-markdown-empty-paragraph-delete.ts new file mode 100644 index 00000000000..37081d47813 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-empty-paragraph-delete.ts @@ -0,0 +1,241 @@ +import type { Editor } from '@tiptap/react' +import { Fragment, type Node as PmNode, type Schema } from '@tiptap/pm/model' +import { TextSelection, type Transaction } from '@tiptap/pm/state' + +const BLOCKED_CONTAINER_TYPES = new Set(['listItem', 'taskItem', 'tableCell', 'tableHeader']) +const PARAGRAPH_JOIN_NEIGHBOR_TYPES = new Set(['paragraph', 'heading']) + +function isEmptyParagraph(node: PmNode | null | undefined): boolean { + return node?.type.name === 'paragraph' && node.content.size === 0 +} + +function hasSoftNewline(node: PmNode): boolean { + return node.textContent.includes('\n') +} + +function setSelectionNear(tr: Transaction, pos: number): void { + const clampedPos = Math.max(0, Math.min(pos, tr.doc.content.size)) + tr.setSelection(TextSelection.near(tr.doc.resolve(clampedPos))) +} + +type TextblockBoundary = { + after: PmNode | null + before: PmNode | null + current: PmNode + currentEnd: number + currentStart: number +} + +function getTextblockBoundary(editor: Editor): TextblockBoundary | null { + const { selection } = editor.state + if (!selection.empty || !selection.$from.parent.isTextblock) { + return null + } + + const { $from } = selection + const textblockDepth = $from.depth + for (let depth = 1; depth < textblockDepth; depth += 1) { + if (BLOCKED_CONTAINER_TYPES.has($from.node(depth).type.name)) { + return null + } + } + + const parentIndex = $from.index(textblockDepth - 1) + const container = $from.node(textblockDepth - 1) + return { + after: container.maybeChild(parentIndex + 1) ?? null, + before: container.maybeChild(parentIndex - 1) ?? null, + current: $from.parent, + currentEnd: $from.after(textblockDepth), + currentStart: $from.before(textblockDepth) + } +} + +type SoftNewlineJoin = { + nodes: PmNode[] + selectionOffset: number +} + +function splitParagraphAtFirstSoftNewline( + paragraph: PmNode, + schema: Schema +): { after: Fragment; before: Fragment } | null { + const beforeNodes: PmNode[] = [] + const afterNodes: PmNode[] = [] + let foundSoftNewline = false + + paragraph.content.forEach((child) => { + if (foundSoftNewline) { + afterNodes.push(child) + return + } + + if (!child.isText || !child.text?.includes('\n')) { + beforeNodes.push(child) + return + } + + const newlineIndex = child.text.indexOf('\n') + if (newlineIndex > 0) { + beforeNodes.push(schema.text(child.text.slice(0, newlineIndex), child.marks)) + } + if (newlineIndex + 1 < child.text.length) { + afterNodes.push(schema.text(child.text.slice(newlineIndex + 1), child.marks)) + } + foundSoftNewline = true + }) + + if (!foundSoftNewline) { + return null + } + + return { + after: Fragment.fromArray(afterNodes), + before: Fragment.fromArray(beforeNodes) + } +} + +function createSoftNewlineJoin( + left: PmNode, + right: PmNode, + schema: Schema +): SoftNewlineJoin | null { + if (left.type.name === 'paragraph' && PARAGRAPH_JOIN_NEIGHBOR_TYPES.has(right.type.name)) { + if (!hasSoftNewline(left) && (right.type.name !== 'paragraph' || !hasSoftNewline(right))) { + return null + } + + // Why: ProseMirror Transform.join substitutes paragraph text `\n` with + // hardBreak nodes at textblock boundaries, which makes hard-wrapped prose narrow. + const content = left.content.append(right.content) + if (!left.type.validContent(content)) { + return null + } + + return { + nodes: [left.type.create(left.attrs, content, left.marks)], + selectionOffset: left.content.size + } + } + + if (left.type.name !== 'heading' || right.type.name !== 'paragraph' || !hasSoftNewline(right)) { + return null + } + + const split = splitParagraphAtFirstSoftNewline(right, schema) + if (!split) { + return null + } + + const headingContent = left.content.append(split.before) + if (!left.type.validContent(headingContent)) { + return null + } + + const nodes = [left.type.create(left.attrs, headingContent, left.marks)] + if (split.after.size > 0) { + if (!right.type.validContent(split.after)) { + return null + } + nodes.push(right.type.create(right.attrs, split.after, right.marks)) + } + + return { + nodes, + selectionOffset: left.content.size + } +} + +function dispatchSoftNewlineJoin( + editor: Editor, + from: number, + to: number, + join: SoftNewlineJoin +): void { + const tr = editor.state.tr.replaceWith(from, to, join.nodes) + setSelectionNear(tr, from + 1 + join.selectionOffset) + editor.view.dispatch(tr) +} + +export function deleteAdjacentEmptyParagraph(editor: Editor, direction: 'backward' | 'forward') { + const boundary = getTextblockBoundary(editor) + if (!boundary) { + return false + } + + const { selection } = editor.state + const { $from } = selection + const { after, before, current, currentEnd, currentStart } = boundary + + if (direction === 'backward') { + if ($from.parentOffset !== 0) { + return false + } + + if (isEmptyParagraph(current)) { + if (!before) { + return false + } + // Why: ProseMirror's default Backspace join converts soft `\n` text in + // the previous paragraph into hardBreak nodes. Delete the blank block only. + const tr = editor.state.tr.delete(currentStart, currentEnd) + setSelectionNear(tr, currentStart - 1) + editor.view.dispatch(tr) + return true + } + + if (before && isEmptyParagraph(before)) { + const from = currentStart - before.nodeSize + const tr = editor.state.tr.delete(from, currentStart) + setSelectionNear(tr, tr.mapping.map(selection.from, -1)) + editor.view.dispatch(tr) + return true + } + + if (before && current.isTextblock && before.isTextblock) { + const join = createSoftNewlineJoin(before, current, editor.state.schema) + if (!join) { + return false + } + const from = currentStart - before.nodeSize + dispatchSoftNewlineJoin(editor, from, currentEnd, join) + return true + } + + return false + } + + if ($from.parentOffset !== current.content.size) { + return false + } + + if (isEmptyParagraph(current)) { + if (!after) { + return false + } + const tr = editor.state.tr.delete(currentStart, currentEnd) + setSelectionNear(tr, currentStart) + editor.view.dispatch(tr) + return true + } + + if (after && isEmptyParagraph(after)) { + // Why: Delete at the end of a soft-wrapped paragraph should remove the + // blank line without running ProseMirror's newline-to-hardBreak join path. + const tr = editor.state.tr.delete(currentEnd, currentEnd + after.nodeSize) + setSelectionNear(tr, currentEnd - 1) + editor.view.dispatch(tr) + return true + } + + if (after && current.isTextblock && after.isTextblock) { + const join = createSoftNewlineJoin(current, after, editor.state.schema) + if (!join) { + return false + } + dispatchSoftNewlineJoin(editor, currentStart, currentEnd + after.nodeSize, join) + return true + } + + return false +} diff --git a/src/renderer/src/components/editor/rich-markdown-key-handler.ts b/src/renderer/src/components/editor/rich-markdown-key-handler.ts index 42111c8c368..b5e9ced95a9 100644 --- a/src/renderer/src/components/editor/rich-markdown-key-handler.ts +++ b/src/renderer/src/components/editor/rich-markdown-key-handler.ts @@ -17,6 +17,7 @@ import { convertEmptyNestedOrderedItemToContinuation, exitTrailingEmptyOrderedListItem } from './rich-markdown-list-continuation' +import { deleteAdjacentEmptyParagraph } from './rich-markdown-empty-paragraph-delete' export type KeyHandlerContext = { isMac: boolean @@ -162,7 +163,20 @@ export function createRichMarkdownKeyHandler( ed && !isComposingMarkdownInput(event, ed) && (convertEmptyNestedOrderedItemToContinuation(ed) || - collapseEmptyListContinuationParagraph(ed)) + collapseEmptyListContinuationParagraph(ed) || + deleteAdjacentEmptyParagraph(ed, 'backward')) + ) { + event.preventDefault() + return true + } + } + + if (event.key === 'Delete') { + const ed = ctx.editorRef.current + if ( + ed && + !isComposingMarkdownInput(event, ed) && + deleteAdjacentEmptyParagraph(ed, 'forward') ) { event.preventDefault() return true diff --git a/src/renderer/src/components/editor/rich-markdown-normalize.test.ts b/src/renderer/src/components/editor/rich-markdown-normalize.test.ts index 62599e8df7b..01cb1d96cb2 100644 --- a/src/renderer/src/components/editor/rich-markdown-normalize.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-normalize.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Editor } from '@tiptap/core' import StarterKit from '@tiptap/starter-kit' import { Markdown } from '@tiptap/markdown' -import { normalizeSoftBreaks } from './rich-markdown-normalize' +import { normalizeEmptyListItems, normalizeSoftBreaks } from './rich-markdown-normalize' const extensions = [StarterKit, Markdown.configure({ markedOptions: { gfm: true } })] @@ -24,7 +24,7 @@ describe('rich markdown normalization', () => { const editor = createEditor('1. Item 1\n2. Item 2\n3. \n\n## Next section\n') try { - normalizeSoftBreaks(editor) + normalizeEmptyListItems(editor) const list = editor.state.doc.child(0) const emptyItem = list.child(2) @@ -37,7 +37,21 @@ describe('rich markdown normalization', () => { } }) - it('normalizes newline-heavy soft-break paragraphs without splitting text nodes', () => { + it('leaves hard-wrapped document prose as one paragraph', () => { + const editor = createEditor('Line one\nLine two\nLine three') + + try { + normalizeEmptyListItems(editor) + + expect(editor.state.doc.childCount).toBe(1) + expect(editor.state.doc.child(0).type.name).toBe('paragraph') + expect(editor.state.doc.child(0).textContent).toBe('Line one\nLine two\nLine three') + } finally { + editor.destroy() + } + }) + + it('keeps soft-break splitting without splitting text nodes', () => { const editor = createEditor('Line one\nLine two\nLine three') try { diff --git a/src/renderer/src/components/editor/rich-markdown-normalize.ts b/src/renderer/src/components/editor/rich-markdown-normalize.ts index c1beb2d7773..695d99440ef 100644 --- a/src/renderer/src/components/editor/rich-markdown-normalize.ts +++ b/src/renderer/src/components/editor/rich-markdown-normalize.ts @@ -1,22 +1,19 @@ import type { Editor } from '@tiptap/core' import { Fragment, type Node as PmNode } from '@tiptap/pm/model' -/** - * Why: the `marked` parser (with `breaks: false`, the default) treats consecutive - * lines without a blank separator as a single paragraph with literal `\n` characters - * in the text content (e.g. "Line one\nLine two\nLine three"). These `\n` chars are - * invisible in the rendered HTML (normal `white-space` collapsing), but they cause - * the block-cut handler to remove the entire multi-line paragraph on Cmd+X instead - * of just one logical line. - * - * This function normalises the ProseMirror document by splitting any paragraph whose - * text nodes contain `\n` into separate paragraph nodes — one per line — and by - * giving empty parsed list items a paragraph caret target. Inline marks (bold, - * italic, links, etc.) are preserved on each resulting paragraph. This is - * structurally correct for the editing model: each visual line becomes its own block, - * so the cut handler (and all other block-level operations) work on a per-line basis. - */ +type NormalizeOptions = { + splitSoftBreakParagraphs: boolean +} + +export function normalizeEmptyListItems(editor: Editor): void { + normalizeRichMarkdownDocument(editor, { splitSoftBreakParagraphs: false }) +} + export function normalizeSoftBreaks(editor: Editor): void { + normalizeRichMarkdownDocument(editor, { splitSoftBreakParagraphs: true }) +} + +function normalizeRichMarkdownDocument(editor: Editor, options: NormalizeOptions): void { // Why: we read from editor.view.state (not editor.state) so that the doc // we traverse and the transaction we later create share the same base state. // After setContent(), editor.state can be stale (last React render), while @@ -52,6 +49,10 @@ export function normalizeSoftBreaks(editor: Editor): void { if (node.type !== paragraphType) { return true // continue descending into container nodes } + if (!options.splitSoftBreakParagraphs) { + // Document-editor prose reflows soft breaks through CSS, preserving clean diffs. + return false + } if (!node.textContent.includes('\n')) { return false // no need to descend into inline content } diff --git a/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts b/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts index db34644c66a..2defa250401 100644 --- a/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts +++ b/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts @@ -4,7 +4,7 @@ import type { Editor } from '@tiptap/react' import type { MarkdownDocument } from '../../../../shared/types' import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html' import { syncDocLinkMenu, type DocLinkMenuState } from './rich-markdown-commands' -import { normalizeSoftBreaks } from './rich-markdown-normalize' +import { normalizeEmptyListItems } from './rich-markdown-normalize' import { syncSlashMenu, type SlashMenuState } from './rich-markdown-slash-commands' import { createRichMarkdownImageResolverContext, @@ -133,7 +133,9 @@ function applyExternalRichMarkdownContent( contentType: 'markdown', emitUpdate: false }) - normalizeSoftBreaks(editor) + // Why: normalizeEmptyListItems avoids splitting hard-wrapped paragraphs from + // external content, matching onCreate's single-paragraph reflow behavior. + normalizeEmptyListItems(editor) lastCommittedMarkdownRef.current = content if (hadFocus) { const docSize = editor.state.doc.content.size diff --git a/src/renderer/src/components/feature-wall/ComputerUseAnimatedVisual.tsx b/src/renderer/src/components/feature-wall/ComputerUseAnimatedVisual.tsx deleted file mode 100644 index b1a3c4742e4..00000000000 --- a/src/renderer/src/components/feature-wall/ComputerUseAnimatedVisual.tsx +++ /dev/null @@ -1,377 +0,0 @@ -import { useEffect, useLayoutEffect, useRef, useState, type JSX, type ReactNode } from 'react' -import { GitBranch } from 'lucide-react' -import { cn } from '@/lib/utils' -import { ClaudeIcon } from '../status-bar/icons' -import { FeatureWallClickRing } from './FeatureWallClickRing' -import { CursorIcon } from './feature-tour-preview-glyphs' -import { translate } from '@/i18n/i18n' - -type ComputerUsePhase = 'inspect' | 'target' | 'click' | 'verified' - -const PHASES: readonly ComputerUsePhase[] = ['inspect', 'target', 'click', 'verified', 'verified'] -const PHASE_MS = 1350 - -// Why: the visual must read as "an agent in an Orca worktree drives the -// local app via the `orca computer` CLI" — each command on the left causes -// the visible effect on the right, in lockstep. -const WORKTREE_LABEL = 'checkout fix' - -export function ComputerUseAnimatedVisual(props: { - reducedMotion: boolean - onCycleComplete?: () => void -}): JSX.Element { - const phase = useComputerUsePhase(props.reducedMotion, props.onCycleComplete) - const containerRef = useRef(null) - const buttonRef = useRef(null) - - const [cursorCoords, setCursorCoords] = useState({ x: 0, y: 0, visible: false }) - - const targetVisible = phase === 'target' || phase === 'click' || phase === 'verified' - const clicked = phase === 'click' || phase === 'verified' - const verified = phase === 'verified' - - // Why: cursor coordinates are computed against the app body so they survive - // any reflow when the carousel sizes the slide. - useLayoutEffect(() => { - if (props.reducedMotion) { - setCursorCoords({ x: 0, y: 0, visible: false }) - return - } - - function updateCoords(): void { - const container = containerRef.current - const button = buttonRef.current - if (!container || !button) { - return - } - - const containerRect = container.getBoundingClientRect() - const buttonRect = button.getBoundingClientRect() - - const buttonX = buttonRect.left - containerRect.left + buttonRect.width * 0.78 - const buttonY = buttonRect.top - containerRect.top + buttonRect.height / 2 - - const startX = containerRect.width * 0.35 - const startY = containerRect.height * 0.35 - - if (phase === 'inspect' || phase === 'verified') { - setCursorCoords({ x: startX, y: startY, visible: false }) - } else { - setCursorCoords({ x: buttonX, y: buttonY, visible: true }) - } - } - - updateCoords() - - window.addEventListener('resize', updateCoords) - return () => window.removeEventListener('resize', updateCoords) - }, [phase, props.reducedMotion]) - - return ( -
- - -
-
- - - - - {translate( - 'auto.components.feature.wall.ComputerUseAnimatedVisual.9cddfe96b2', - 'Local app' - )} - -
- -
-
-
-
-
-
-
- - {verified - ? translate( - 'auto.components.feature.wall.ComputerUseAnimatedVisual.c11dda000b', - 'Approved' - ) - : translate( - 'auto.components.feature.wall.ComputerUseAnimatedVisual.bdd5312213', - 'Pending' - )} - -
-
- -
-
- -
-
-
-
-
-
- {clicked - ? translate( - 'auto.components.feature.wall.ComputerUseAnimatedVisual.3cc2df3671', - 'Done' - ) - : translate( - 'auto.components.feature.wall.ComputerUseAnimatedVisual.9634d870d1', - 'Approve' - )} -
-
-
- - -
-
-
-
- ) -} - -function AgentWorktreeTerminal(props: { - phase: ComputerUsePhase - targetVisible: boolean - clicked: boolean - verified: boolean -}): JSX.Element { - return ( -
-
- - - {translate( - 'auto.components.feature.wall.ComputerUseAnimatedVisual.94787f01f8', - 'Claude Code' - )} - - - - {WORKTREE_LABEL} - -
-
- - ● - {translate( - 'auto.components.feature.wall.ComputerUseAnimatedVisual.2adb561b44', - 'Claude Code session started' - )} - - - - {translate('auto.components.feature.wall.ComputerUseAnimatedVisual.99a8624bcb', '>')} - - {translate( - 'auto.components.feature.wall.ComputerUseAnimatedVisual.79445f7512', - 'approve the note in my app' - )} - - - - {translate( - 'auto.components.feature.wall.ComputerUseAnimatedVisual.1719b28a81', - 'found "Approve"' - )} - [#7] - - - - {translate( - 'auto.components.feature.wall.ComputerUseAnimatedVisual.6804cb356f', - 'click sent' - )} - - - - {translate( - 'auto.components.feature.wall.ComputerUseAnimatedVisual.f27676a92c', - 'status:' - )} - - {translate( - 'auto.components.feature.wall.ComputerUseAnimatedVisual.d8401975b1', - 'approved' - )} - - -
-
- ) -} - -function ComputerActionLine(props: { - action: string - target: string - active: boolean - done: boolean -}): JSX.Element { - return ( - - {props.done ? ✓ : } - {props.action} - {props.target} - - ) -} - -function TerminalLine(props: { - children: ReactNode - muted?: boolean - wrap?: boolean - indent?: boolean - visible?: boolean -}): JSX.Element { - const visible = props.visible ?? true - return ( -
- {props.children} -
- ) -} - -function RunSpinner(): JSX.Element { - return ( - - ) -} - -function useComputerUsePhase( - reducedMotion: boolean, - onCycleComplete?: () => void -): ComputerUsePhase { - const [idx, setIdx] = useState(() => (reducedMotion ? PHASES.indexOf('verified') : 0)) - - useEffect(() => { - if (reducedMotion) { - setIdx(PHASES.indexOf('verified')) - return - } - let cancelled = false - const timeouts: number[] = [] - const wait = (ms: number): Promise => - new Promise((resolve) => { - const id = window.setTimeout(() => resolve(), ms) - timeouts.push(id) - }) - - setIdx(0) - async function loop(): Promise { - while (!cancelled) { - for (let nextIdx = 0; nextIdx < PHASES.length; nextIdx += 1) { - setIdx(nextIdx) - await wait(PHASE_MS) - if (cancelled) { - return - } - } - onCycleComplete?.() - } - } - loop() - return () => { - cancelled = true - timeouts.forEach((id) => window.clearTimeout(id)) - } - }, [onCycleComplete, reducedMotion]) - - return PHASES[idx] ?? 'verified' -} - -function AppRow(props: { width: string }): JSX.Element { - return ( -
-
-
-
- ) -} - -function ComputerUseCursor(props: { - visible: boolean - x: number - y: number - isClick: boolean -}): JSX.Element { - return ( -
- - {props.isClick ? : null} -
- ) -} diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.test.tsx new file mode 100644 index 00000000000..a4be125a2f9 --- /dev/null +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.test.tsx @@ -0,0 +1,233 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as ReactModule from 'react' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { FloatingTerminalWindowControls } from './FloatingTerminalWindowControls' + +type ReactElementLike = { + type: unknown + props: Record +} + +const storeBox = vi.hoisted(() => ({ + state: null as unknown +})) + +const mocks = vi.hoisted(() => ({ + activateTab: vi.fn(), + createTab: vi.fn(), + setActiveTabForWorktree: vi.fn(), + setTabBarOrder: vi.fn(), + queueTabStartupCommand: vi.fn(), + focusTerminalTabSurface: vi.fn(), + buildAgentStartupPlan: vi.fn() +})) + +vi.mock('react', async () => { + const actual = await vi.importActual('react') + return { + ...actual, + useCallback: (callback: T) => callback, + useMemo: (factory: () => T) => factory() + } +}) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector(storeBox.state), { + getState: () => storeBox.state + }) +})) + +vi.mock('@/lib/focus-terminal-tab-surface', () => ({ + focusTerminalTabSurface: mocks.focusTerminalTabSurface +})) + +vi.mock('@/lib/tui-agent-startup', () => ({ + buildAgentStartupPlan: mocks.buildAgentStartupPlan +})) + +vi.mock('@/lib/agent-catalog', () => ({ + getAgentCatalog: () => [{ id: 'claude', label: 'Claude' }], + AgentIcon: function AgentIcon() { + return null + } +})) + +vi.mock('@/lib/new-workspace', () => ({ + CLIENT_PLATFORM: 'darwin' +})) + +vi.mock('@/lib/telemetry', () => ({ + tuiAgentToAgentKind: () => 'claude' +})) + +vi.mock('../../../../shared/tui-agent-selection', () => ({ + isTuiAgentEnabled: () => true +})) + +vi.mock('../../../../shared/tui-agent-launch-defaults', () => ({ + resolveTuiAgentLaunchArgs: () => [], + resolveTuiAgentLaunchEnv: () => ({}) +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, vars?: Record) => + vars ? fallback.replace(/\{\{(\w+)\}\}/g, (_match, name: string) => vars[name] ?? '') : fallback +})) + +vi.mock('@/hooks/useShortcutLabel', () => ({ + useOptionalShortcutLabel: () => null +})) + +vi.mock('@/components/ui/button', () => ({ + Button: function Button(props: { children?: unknown }) { + return props.children ?? null + } +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: function Tooltip(props: { children?: unknown }) { + return props.children + }, + TooltipContent: function TooltipContent(props: { children?: unknown }) { + return props.children + }, + TooltipTrigger: function TooltipTrigger(props: { children?: unknown }) { + return props.children + } +})) + +vi.mock('lucide-react', () => ({ + Maximize2: function Maximize2() { + return null + }, + Minimize2: function Minimize2() { + return null + }, + Minus: function Minus() { + return null + } +})) + +vi.mock('sonner', () => ({ + toast: { error: vi.fn() } +})) + +function visit(node: unknown, cb: (node: ReactElementLike) => void): void { + if (node == null || typeof node === 'string' || typeof node === 'number') { + return + } + if (Array.isArray(node)) { + node.forEach((entry) => visit(entry, cb)) + return + } + const element = node as ReactElementLike + if (!element.props) { + return + } + cb(element) + visit(element.props.children, cb) +} + +function findOnClickByAriaLabel(node: unknown, ariaLabel: string): () => void { + let found: (() => void) | null = null + visit(node, (entry) => { + if (entry.props['aria-label'] === ariaLabel && typeof entry.props.onClick === 'function') { + found = entry.props.onClick as () => void + } + }) + if (!found) { + throw new Error(`onClick for aria-label "${ariaLabel}" not found`) + } + return found +} + +const NEW_AGENT_TAB_ID = 'floating-agent-tab' +const EXISTING_TAB_ID = 'floating-existing-tab' + +beforeEach(() => { + for (const mock of Object.values(mocks)) { + mock.mockReset() + } + mocks.createTab.mockImplementation(() => { + const tab = { id: NEW_AGENT_TAB_ID } + const state = storeBox.state as { tabsByWorktree: Record } + const existing = state.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? [] + state.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] = [...existing, tab] + return tab + }) + mocks.buildAgentStartupPlan.mockReturnValue({ + launchCommand: 'claude', + launchConfig: {}, + env: undefined, + startupCommandDelivery: undefined + }) + storeBox.state = { + settings: { + defaultTuiAgent: 'claude', + disabledTuiAgents: [], + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {} + }, + createTab: mocks.createTab, + activateTab: mocks.activateTab, + setActiveTabForWorktree: mocks.setActiveTabForWorktree, + setTabBarOrder: mocks.setTabBarOrder, + queueTabStartupCommand: mocks.queueTabStartupCommand, + tabsByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: [{ id: EXISTING_TAB_ID }] }, + tabBarOrderByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: [EXISTING_TAB_ID] } + } +}) + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('FloatingTerminalWindowControls default-agent launch', () => { + it('activates the new agent tab so the floating panel selects and focuses it', () => { + const element = FloatingTerminalWindowControls({ + maximized: false, + onToggleMaximized: vi.fn(), + onMinimize: vi.fn() + }) + + const launch = findOnClickByAriaLabel(element, 'Open Claude in floating workspace') + launch() + + expect(mocks.createTab).toHaveBeenCalledWith( + FLOATING_TERMINAL_WORKTREE_ID, + undefined, + undefined, + { activate: false } + ) + // Why: TerminalPane consumes any pending startup command on first render, so + // the launch command must be queued before activation can mount the surface - + // otherwise the new tab can come up as a bare shell. + expect(mocks.queueTabStartupCommand).toHaveBeenCalledWith( + NEW_AGENT_TAB_ID, + expect.objectContaining({ + command: 'claude', + launchAgent: 'claude' + }) + ) + expect(mocks.queueTabStartupCommand.mock.invocationCallOrder[0]).toBeLessThan( + mocks.activateTab.mock.invocationCallOrder[0] + ) + // Why: the floating panel renders its visible tab from the unified group's + // activeTabId, which only activateTab writes. setActiveTabForWorktree updates + // the complementary legacy per-worktree map. Without activateTab the new agent + // tab would be appended but never selected/focused. + expect(mocks.setActiveTabForWorktree).toHaveBeenCalledWith( + FLOATING_TERMINAL_WORKTREE_ID, + NEW_AGENT_TAB_ID + ) + expect(mocks.activateTab).toHaveBeenCalledWith(NEW_AGENT_TAB_ID) + expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith(NEW_AGENT_TAB_ID) + // Why: createTab appends the new tab to the worktree; the order reconciliation + // must keep the pre-existing tab and place the new agent tab last. + expect(mocks.setTabBarOrder).toHaveBeenCalledWith(FLOATING_TERMINAL_WORKTREE_ID, [ + EXISTING_TAB_ID, + NEW_AGENT_TAB_ID + ]) + }) +}) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx index 514402a0f36..03213e9820e 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx @@ -43,6 +43,7 @@ export function FloatingTerminalWindowControls({ const defaultTuiAgent = useAppStore((s) => s.settings?.defaultTuiAgent ?? null) const createTab = useAppStore((s) => s.createTab) const setActiveTabForWorktree = useAppStore((s) => s.setActiveTabForWorktree) + const activateTab = useAppStore((s) => s.activateTab) const maximizeShortcutLabel = useOptionalShortcutLabel('floatingWorkspace.maximize') const minimizeShortcutLabel = useOptionalShortcutLabel('floatingWorkspace.minimize') @@ -100,7 +101,12 @@ export function FloatingTerminalWindowControls({ request_kind: 'new' } }) + // Why: the floating panel renders its visible tab from the unified group's + // activeTabId. setActiveTabForWorktree only writes activeTabIdByWorktree, so + // the new agent tab would be appended but never selected/focused. activateTab + // selects it within the group, matching the empty-state tab creators. setActiveTabForWorktree(FLOATING_TERMINAL_WORKTREE_ID, tab.id) + activateTab(tab.id) const fresh = useAppStore.getState() const currentTabs = fresh.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? [] const stored = fresh.tabBarOrderByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? [] @@ -114,7 +120,7 @@ export function FloatingTerminalWindowControls({ order.push(tab.id) fresh.setTabBarOrder(FLOATING_TERMINAL_WORKTREE_ID, order) focusTerminalTabSurface(tab.id) - }, [createTab, defaultAgent, defaultAgentLabel, setActiveTabForWorktree]) + }, [activateTab, createTab, defaultAgent, defaultAgentLabel, setActiveTabForWorktree]) return (
diff --git a/src/renderer/src/components/github-pr-merge-state.test.ts b/src/renderer/src/components/github-pr-merge-state.test.ts index 9284f52729e..a2f52f8fd31 100644 --- a/src/renderer/src/components/github-pr-merge-state.test.ts +++ b/src/renderer/src/components/github-pr-merge-state.test.ts @@ -62,6 +62,18 @@ describe('presentGitHubPRMergeState', () => { }) }) + it('does not offer enable auto-merge for GitHub unstable PRs', () => { + expect( + presentGitHubPRMergeState( + pr({ + mergeable: 'UNKNOWN', + mergeStateStatus: 'UNSTABLE', + checksSummary: { state: 'failure', total: 1, passed: 0, failed: 1, pending: 0 } + }) + ).autoMergeAction + ).toBeNull() + }) + it('does not offer enable auto-merge on conflicting PRs (GitHub would reject it)', () => { expect( presentGitHubPRMergeState(pr({ mergeable: 'CONFLICTING', mergeStateStatus: 'DIRTY' })) diff --git a/src/renderer/src/components/github/CloseReasonDropdown.tsx b/src/renderer/src/components/github/CloseReasonDropdown.tsx deleted file mode 100644 index 6002529cbdf..00000000000 --- a/src/renderer/src/components/github/CloseReasonDropdown.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { Check, ChevronDown } from 'lucide-react' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' -import { Button } from '@/components/ui/button' -import { CLOSE_ISSUE_REASONS } from './github-issue-close-reasons' -import type { GitHubIssueCloseReason } from '../../../../shared/types' -import { translate } from '@/i18n/i18n' - -export function CloseReasonDropdown({ - closeReason, - disabled, - onCloseReasonChange -}: { - closeReason: GitHubIssueCloseReason - disabled: boolean - onCloseReasonChange: (reason: GitHubIssueCloseReason) => void -}): React.JSX.Element { - return ( - - - - - - {CLOSE_ISSUE_REASONS.map((option) => ( - onCloseReasonChange(option.reason)}> -
- {closeReason === option.reason ? ( - - ) : ( - - )} -
-
- {option.icon} - {option.label} -
-

{option.description}

-
-
-
- ))} -
-
- ) -} diff --git a/src/renderer/src/components/github/GitHubIssueCommentComposer.tsx b/src/renderer/src/components/github/GitHubIssueCommentComposer.tsx deleted file mode 100644 index 7b77f4b1ebf..00000000000 --- a/src/renderer/src/components/github/GitHubIssueCommentComposer.tsx +++ /dev/null @@ -1,398 +0,0 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react' -import { LoaderCircle } from 'lucide-react' -import { toast } from 'sonner' -import { Button } from '@/components/ui/button' -import { ButtonGroup } from '@/components/ui/button-group' -import { GitHubMarkdownComposer } from '@/components/github/GitHubMarkdownComposer' -import { CLOSE_ISSUE_REASONS } from '@/components/github/github-issue-close-reasons' -import { CloseReasonDropdown } from '@/components/github/CloseReasonDropdown' -import { - addIssueCommentForRepo, - githubAvatarUrl, - runIssueStateUpdate, - type GitHubIssueCommentProjectOrigin -} from '@/components/github/github-issue-comment-helpers' -import { - getCommentBodySubmitState, - hasBoundedCommentBodyText -} from '@/lib/comment-body-submit-state' -import { useMountedRef } from '@/hooks/useMountedRef' -import { cn } from '@/lib/utils' -import { useAppStore } from '@/store' -import type { - GitHubIssueCloseReason, - GitHubOwnerRepo, - GitHubViewer, - GitHubWorkItem, - GlobalSettings, - PRComment -} from '../../../../shared/types' -import type { TaskSourceContext } from '../../../../shared/task-source-context' -import { translate } from '@/i18n/i18n' - -export function GitHubIssueCommentComposer({ - className, - repoPath, - repoId, - issueNumber, - itemType, - itemState, - itemId, - sourceContext, - sourceSettings, - projectOrigin, - previewGithubRepo, - onCommentAdded, - onStateChange, - onMutated -}: { - className?: string - repoPath: string - repoId?: string | null - issueNumber: number - itemType: 'issue' | 'pr' - itemState?: GitHubWorkItem['state'] - itemId?: string - sourceContext?: TaskSourceContext | null - sourceSettings?: Pick | null | undefined - projectOrigin?: GitHubIssueCommentProjectOrigin - previewGithubRepo?: GitHubOwnerRepo | null - onCommentAdded: (comment: PRComment) => void - onStateChange?: (state: GitHubWorkItem['state']) => void - onMutated?: () => void -}): React.JSX.Element { - const [body, setBody] = useState('') - const [submitting, setSubmitting] = useState(false) - const [statePending, setStatePending] = useState(false) - const [closeReason, setCloseReason] = useState('completed') - const [viewer, setViewer] = useState(null) - const mountedRef = useMountedRef() - const viewerRequestIdRef = useRef(0) - const patchWorkItem = useAppStore((s) => s.patchWorkItem) - const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) - - useEffect(() => { - const requestId = ++viewerRequestIdRef.current - void window.api.gh - .viewer() - .then((nextViewer) => { - if (mountedRef.current && requestId === viewerRequestIdRef.current) { - setViewer(nextViewer) - } - }) - .catch(() => { - if (mountedRef.current && requestId === viewerRequestIdRef.current) { - setViewer(null) - } - }) - return () => { - viewerRequestIdRef.current += 1 - } - }, [mountedRef]) - - const selectedCloseReason = - CLOSE_ISSUE_REASONS.find((option) => option.reason === closeReason) ?? CLOSE_ISSUE_REASONS[0] - - const canMutateIssueState = - itemType === 'issue' && - itemState !== undefined && - itemState !== 'closed' && - Boolean(onStateChange) && - Boolean(repoPath || projectOrigin) - - const canReopenIssue = - itemType === 'issue' && - itemState === 'closed' && - Boolean(onStateChange) && - Boolean(repoPath || projectOrigin) - - const patchProjectRowIfNeeded = useCallback( - (state: GitHubWorkItem['state']) => { - if (!projectOrigin) { - return - } - patchProjectRowContent(projectOrigin.cacheKey, projectOrigin.projectItemId, { state }) - }, - [patchProjectRowContent, projectOrigin] - ) - - const applyStatePatch = useCallback( - (state: GitHubWorkItem['state']) => { - onStateChange?.(state) - if (itemId) { - patchWorkItem(itemId, { state }, repoId ?? undefined) - } - patchProjectRowIfNeeded(state) - }, - [itemId, onStateChange, patchProjectRowIfNeeded, patchWorkItem, repoId] - ) - - const handleSubmit = useCallback(async () => { - const bodyState = getCommentBodySubmitState(body) - if (bodyState.status === 'empty') { - return - } - if (bodyState.status === 'too-large-leading-whitespace') { - toast.error( - translate( - 'auto.components.github.GitHubIssueCommentComposer.commentTooLarge', - 'Comment is too large to submit safely.' - ) - ) - return - } - setSubmitting(true) - try { - const result = await addIssueCommentForRepo({ - repoPath, - repoId: repoId ?? undefined, - sourceContext, - number: issueNumber, - body: bodyState.body, - type: itemType - }) - if (!mountedRef.current) { - return - } - if (result.ok) { - setBody('') - onCommentAdded(result.comment) - } else { - toast.error( - result.error ?? - translate( - 'auto.components.github.GitHubIssueCommentComposer.082515176a', - 'Failed to add comment' - ) - ) - } - } catch (err) { - if (mountedRef.current) { - toast.error( - err instanceof Error - ? err.message - : translate( - 'auto.components.github.GitHubIssueCommentComposer.082515176a', - 'Failed to add comment' - ) - ) - } - } finally { - if (mountedRef.current) { - setSubmitting(false) - } - } - }, [body, issueNumber, itemType, mountedRef, onCommentAdded, repoId, repoPath, sourceContext]) - - const handleCloseIssue = useCallback( - async (reason: GitHubIssueCloseReason = closeReason) => { - if (!canMutateIssueState || statePending) { - return - } - const previousState = itemState ?? 'open' - setStatePending(true) - applyStatePatch('closed') - try { - await runIssueStateUpdate({ - repoPath, - repoId, - sourceContext, - sourceSettings, - projectOrigin, - number: issueNumber, - updates: { state: 'closed', stateReason: reason } - }) - useAppStore.getState().recordFeatureInteraction('github-tasks') - toast.success( - translate('auto.components.github.GitHubIssueCommentComposer.9f88657c4e', 'Issue closed') - ) - onMutated?.() - } catch (err) { - applyStatePatch(previousState) - toast.error( - err instanceof Error - ? err.message - : translate( - 'auto.components.github.GitHubIssueCommentComposer.e9b7cb7d17', - 'Failed to close issue' - ) - ) - } finally { - if (mountedRef.current) { - setStatePending(false) - } - } - }, - [ - applyStatePatch, - canMutateIssueState, - closeReason, - issueNumber, - itemState, - mountedRef, - onMutated, - projectOrigin, - repoId, - repoPath, - sourceContext, - sourceSettings, - statePending - ] - ) - - const handleReopenIssue = useCallback(async () => { - if (!canReopenIssue || statePending) { - return - } - const previousState = itemState ?? 'closed' - setStatePending(true) - applyStatePatch('open') - try { - await runIssueStateUpdate({ - repoPath, - repoId, - sourceContext, - sourceSettings, - projectOrigin, - number: issueNumber, - updates: { state: 'open' } - }) - useAppStore.getState().recordFeatureInteraction('github-tasks') - toast.success( - translate('auto.components.github.GitHubIssueCommentComposer.bd3b4492a0', 'Issue reopened') - ) - onMutated?.() - } catch (err) { - applyStatePatch(previousState) - toast.error( - err instanceof Error - ? err.message - : translate( - 'auto.components.github.GitHubIssueCommentComposer.f2a8c1d903', - 'Failed to reopen issue' - ) - ) - } finally { - if (mountedRef.current) { - setStatePending(false) - } - } - }, [ - applyStatePatch, - canReopenIssue, - issueNumber, - itemState, - mountedRef, - onMutated, - projectOrigin, - repoId, - repoPath, - sourceContext, - sourceSettings, - statePending - ]) - - const avatar = viewer?.login ? ( - {viewer.login} - ) : ( -
- ) - const canSubmitComment = hasBoundedCommentBodyText(body) - - return ( -
-
- {avatar} -
-

- {translate( - 'auto.components.github.GitHubIssueCommentComposer.a1b2c3d4e5', - 'Add a comment' - )} -

- void handleSubmit()} - /> -
- {canMutateIssueState ? ( - - - { - setCloseReason(reason) - void handleCloseIssue(reason) - }} - /> - - ) : null} - {canReopenIssue ? ( - - ) : null} - -
-
-
-
- ) -} diff --git a/src/renderer/src/components/github/GitHubWorkItemAssigneePopoverContent.tsx b/src/renderer/src/components/github/GitHubWorkItemAssigneePopoverContent.tsx deleted file mode 100644 index bfdc5fb7555..00000000000 --- a/src/renderer/src/components/github/GitHubWorkItemAssigneePopoverContent.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import React, { useEffect, useMemo, useState } from 'react' -import { - Command, - CommandEmpty, - CommandInput, - CommandItem, - CommandList -} from '@/components/ui/command' -import { cn } from '@/lib/utils' -import { translate } from '@/i18n/i18n' -import type { GitHubAssignableUser } from '../../../../shared/types' -import { filterGitHubWorkItemAssignees } from './github-work-item-assignee-filter' - -const assigneeCheckIcon = ( - - - -) - -export function GitHubWorkItemAssigneePopoverContent({ - open, - assignees, - selectedLogins, - error, - loading, - onToggleAssignee -}: { - open: boolean - assignees: readonly GitHubAssignableUser[] - selectedLogins: readonly string[] - error: string | null | undefined - loading?: boolean - onToggleAssignee: (login: string) => void -}): React.JSX.Element { - const [query, setQuery] = useState('') - const filteredAssignees = useMemo( - () => filterGitHubWorkItemAssignees(assignees, query), - [assignees, query] - ) - const selectedSet = useMemo(() => new Set(selectedLogins), [selectedLogins]) - - useEffect(() => { - if (!open) { - setQuery('') - } - }, [open]) - - if (error) { - return
{error}
- } - - const emptyText = loading - ? translate( - 'auto.components.github.GitHubWorkItemAssigneePopoverContent.cddd9b04a7', - 'Loading assignees' - ) - : translate( - 'auto.components.github.GitHubWorkItemAssigneePopoverContent.a00830d3f7', - 'No users' - ) - - return ( - - - - - {emptyText} - - {filteredAssignees.map((user) => { - const isSelected = selectedSet.has(user.login) - return ( - onToggleAssignee(user.login)} - className="flex items-center gap-2 rounded-sm px-2 py-1.5 text-[12px]" - > - - {isSelected ? assigneeCheckIcon : null} - - - {user.login} - {user.name ? ( - - {user.name} - - ) : null} - - - ) - })} - - - ) -} diff --git a/src/renderer/src/components/github/GitHubWorkItemLabelPopoverContent.tsx b/src/renderer/src/components/github/GitHubWorkItemLabelPopoverContent.tsx deleted file mode 100644 index ff0612e86bb..00000000000 --- a/src/renderer/src/components/github/GitHubWorkItemLabelPopoverContent.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import React, { useEffect, useMemo, useState } from 'react' -import { ExternalLink, Settings } from 'lucide-react' -import { - Command, - CommandEmpty, - CommandInput, - CommandItem, - CommandList -} from '@/components/ui/command' -import { cn } from '@/lib/utils' -import { translate } from '@/i18n/i18n' -import { filterGitHubWorkItemLabels } from './github-work-item-label-filter' - -const labelCheckIcon = ( - - - -) - -function GitHubLabelsSettingsLink({ - url, - separated, - onOpen -}: { - url: string | null - separated?: boolean - onOpen?: () => void -}): React.JSX.Element | null { - if (!url) { - return null - } - - return ( -
- -
- ) -} - -export function GitHubWorkItemLabelPopoverContent({ - open, - labels, - selectedLabels, - error, - loading, - repositoryLabelsUrl, - onToggleLabel, - onOpenSettingsLink -}: { - open: boolean - labels: readonly string[] - selectedLabels: readonly string[] - error: string | null | undefined - loading?: boolean - repositoryLabelsUrl?: string | null - onToggleLabel: (label: string) => void - onOpenSettingsLink?: () => void -}): React.JSX.Element { - const [query, setQuery] = useState('') - const filteredLabels = useMemo(() => filterGitHubWorkItemLabels(labels, query), [labels, query]) - const selectedSet = useMemo(() => new Set(selectedLabels), [selectedLabels]) - - useEffect(() => { - if (!open) { - setQuery('') - } - }, [open]) - - if (error) { - return
{error}
- } - - const emptyText = loading - ? translate( - 'auto.components.github.GitHubWorkItemLabelPopoverContent.cddd9b04a7', - 'Loading labels' - ) - : translate('auto.components.github.GitHubWorkItemLabelPopoverContent.de26e2eb06', 'No labels') - - return ( - <> - - - - - {emptyText} - - {filteredLabels.map((label) => { - const isSelected = selectedSet.has(label) - return ( - onToggleLabel(label)} - className="flex items-center gap-2 rounded-sm px-2 py-1.5 text-[12px]" - > - - {isSelected ? labelCheckIcon : null} - - {label} - - ) - })} - - - 0} - onOpen={onOpenSettingsLink} - /> - - ) -} diff --git a/src/renderer/src/components/github/github-issue-close-reasons.tsx b/src/renderer/src/components/github/github-issue-close-reasons.tsx deleted file mode 100644 index 921e62d7f8e..00000000000 --- a/src/renderer/src/components/github/github-issue-close-reasons.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { Ban, CheckCircle2, Copy } from 'lucide-react' -import { translate } from '@/i18n/i18n' -import type { GitHubIssueCloseReason } from '../../../../shared/types' - -export type CloseIssueReasonOption = { - reason: GitHubIssueCloseReason - label: string - description: string - icon: React.JSX.Element -} - -export const CLOSE_ISSUE_REASONS: CloseIssueReasonOption[] = [ - { - reason: 'completed', - get label() { - return translate( - 'auto.components.github.githubIssueCloseReasons.completed.label', - 'Close as completed' - ) - }, - get description() { - return translate( - 'auto.components.github.githubIssueCloseReasons.completed.description', - 'Done, closed, fixed, resolved' - ) - }, - icon: - }, - { - reason: 'not_planned', - get label() { - return translate( - 'auto.components.github.githubIssueCloseReasons.notPlanned.label', - 'Close as not planned' - ) - }, - get description() { - return translate( - 'auto.components.github.githubIssueCloseReasons.notPlanned.description', - "Won't fix, can't repro, stale" - ) - }, - icon: - }, - { - reason: 'duplicate', - get label() { - return translate( - 'auto.components.github.githubIssueCloseReasons.duplicate.label', - 'Close as duplicate' - ) - }, - get description() { - return translate( - 'auto.components.github.githubIssueCloseReasons.duplicate.description', - 'Duplicate of another issue' - ) - }, - icon: - } -] diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index b447a4287c2..313845c2030 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -127,7 +127,8 @@ function MessageRow({ expandSignal, onScrollMessageToTop, onLinkClick, - allowFileUriLinks = false + allowFileUriLinks = false, + deliveryFailed = false }: { message: NativeChatMessage expandSignal: boolean @@ -135,6 +136,7 @@ function MessageRow({ onScrollMessageToTop: (el: HTMLElement) => void onLinkClick?: CommentMarkdownLinkClickHandler allowFileUriLinks?: boolean + deliveryFailed?: boolean }): React.JSX.Element | null { const rowRef = useRef(null) const { prose, tools } = useMemo(() => splitNativeChatBlocks(message.blocks), [message.blocks]) @@ -181,6 +183,14 @@ function MessageRow({ )}
+ {deliveryFailed ? ( +
+ {translate( + 'components.native-chat.launchPromptNotDelivered', + 'Not delivered — check the terminal' + )} +
+ ) : null}
) } @@ -227,7 +237,8 @@ export function NativeChatMessageList({ expandSignal, fontScale, onLinkClick, - allowFileUriLinks = false + allowFileUriLinks = false, + failedDeliveryMessageIds }: { session: NativeChatLiveSession isWorking: boolean @@ -237,6 +248,7 @@ export function NativeChatMessageList({ fontScale: number onLinkClick?: CommentMarkdownLinkClickHandler allowFileUriLinks?: boolean + failedDeliveryMessageIds?: ReadonlySet }): React.JSX.Element { const scrollRef = useRef(null) const [stuckToBottom, setStuckToBottom] = useState(true) @@ -370,6 +382,7 @@ export function NativeChatMessageList({ onScrollMessageToTop={scrollMessageToTop} onLinkClick={onLinkClick} allowFileUriLinks={allowFileUriLinks} + deliveryFailed={failedDeliveryMessageIds?.has(message.id) === true} /> ))} {showTypingIndicator ? : null} diff --git a/src/renderer/src/components/native-chat/NativeChatView.tsx b/src/renderer/src/components/native-chat/NativeChatView.tsx index fa190c37879..a9b3bca01ce 100644 --- a/src/renderer/src/components/native-chat/NativeChatView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatView.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '../../store' -import { APP_MENU_PASTE_EVENT } from '@/lib/app-menu-paste' import type { TuiAgent } from '../../../../shared/types' import type { NativeChatSession } from '../../../../shared/native-chat-types' import { useNativeChatLiveSession } from './use-native-chat-live-session' @@ -24,10 +23,12 @@ import { appendPendingSendCache, commandMarkersAsMessages, appendCommandMarkerCache, + launchPromptAsMessage, pendingSendsAsMessages, prunePendingSends, readCommandMarkerCache, readPendingSendCache, + shouldPruneLaunchPrompt, writePendingSendCache, type NativeChatCommandMarker, type NativeChatPendingSend @@ -47,6 +48,8 @@ import { resolveNativeChatFileLink, resolveNativeChatFileLinkContext } from './native-chat-file-link' +import { selectNativeChatRuntimeEnvironmentId } from './native-chat-runtime-owner' +import { useNativeChatPasteBridge } from './use-native-chat-paste-bridge' import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown' import { openDetectedFilePath } from '@/components/terminal-pane/terminal-file-open-routing' @@ -155,7 +158,21 @@ function NativeChatResolvedView({ onSwitchToTerminal?: () => void contextMenuActions?: Omit }): React.JSX.Element { - const session = useNativeChatLiveSession({ paneKey, agent, sessionId, transcriptPath }) + // Primitive owner selection (no useShallow): routes the pane's read/subscribe to + // the remote runtime host for a runtime-owned pane; null keeps the local path. + const runtimeEnvironmentId = useAppStore((s) => + selectNativeChatRuntimeEnvironmentId(s, terminalTabId) + ) + const session = useNativeChatLiveSession({ + paneKey, + agent, + sessionId, + transcriptPath, + runtimeEnvironmentId + }) + const launchPrompt = useAppStore((s) => s.nativeChatLaunchPromptByTabId[terminalTabId] ?? null) + const clearNativeChatLaunchPrompt = useAppStore((s) => s.clearNativeChatLaunchPrompt) + const paneLaunchPrompt = launchPrompt?.agent === agent ? launchPrompt : null // Live hook state for this pane, selected directly so the working indicator // flips the instant the agent reports 'working' — even when switching to chat // mid-turn before the transcript merge has caught up. @@ -173,12 +190,7 @@ function NativeChatResolvedView({ const fileLinkContext = useAppStore( useShallow((s) => resolveNativeChatFileLinkContext(s, terminalTabId)) ) - // Delegate to the composer so a pane-level Cmd/Ctrl+V (or context-menu / - // app-menu paste) attaches a clipboard image when present, falling back to - // text — matching the textarea's own paste behavior and the hosted TUI. - const pasteClipboardIntoComposer = useCallback(() => { - composerRef.current?.pasteFromClipboard() - }, []) + const pasteClipboardIntoComposer = useNativeChatPasteBridge({ rootRef, composerRef }) const contextMenu = useNativeChatContextMenu({ rootRef, onSwitchToTerminal, @@ -188,51 +200,6 @@ function NativeChatResolvedView({ } }) - // Handle Cmd/Ctrl+V at the pane root rather than relying on the composer - // textarea's own onPaste: the React-bound onPaste proved unreliable here (the - // composer can mount more than once, and the live `paste` event does not - // consistently dispatch to the textarea's React handler). A root capture - // listener catches the paste for the focused pane in every case. - useEffect(() => { - const root = rootRef.current - if (!root) { - return - } - const onPaste = (event: ClipboardEvent): void => { - composerRef.current?.handlePasteEvent(event) - } - // Capture phase so the image is intercepted before the textarea's own - // bubble-phase onPaste fires (which would otherwise attach it twice). - root.addEventListener('paste', onPaste, { capture: true }) - return () => { - root.removeEventListener('paste', onPaste, { capture: true }) - } - }, []) - - // Real Cmd/Ctrl+V is claimed by the Edit > Paste menu accelerator, which - // sends this app-menu paste event instead of producing a DOM `paste` event on - // the composer. Route it into the composer whenever focus is anywhere inside - // this chat pane — including the composer textarea itself (the previous - // non-interactive-target guard skipped exactly the focused-textarea case, - // which is why Cmd+V appeared to do nothing). - useEffect(() => { - const onAppMenuPaste = (event: Event): void => { - const root = rootRef.current - const activeElement = document.activeElement - if (!root || !(activeElement instanceof Element) || !root.contains(activeElement)) { - return - } - event.preventDefault() - event.stopPropagation() - pasteClipboardIntoComposer() - } - - window.addEventListener(APP_MENU_PASTE_EVENT, onAppMenuPaste) - return () => { - window.removeEventListener(APP_MENU_PASTE_EVENT, onAppMenuPaste) - } - }, [pasteClipboardIntoComposer]) - // Optimistic "queued" sends (mobile parity): a composer send is echoed // immediately and pruned once its real user turn lands in the transcript, so // the message never vanishes between send and transcript catch-up. @@ -270,6 +237,12 @@ function NativeChatResolvedView({ writePendingSendCache(pendingScope, prunePendingSends(prev, session.messages)) ) }, [session.messages, pendingScope]) + useEffect(() => { + if (!paneLaunchPrompt || !shouldPruneLaunchPrompt(paneLaunchPrompt, session.messages)) { + return + } + clearNativeChatLaunchPrompt(terminalTabId) + }, [clearNativeChatLaunchPrompt, paneLaunchPrompt, session.messages, terminalTabId]) const onOptimisticSend = useCallback( (text: string, imagePaths?: string[]) => { setWorkingInterrupted(false) @@ -291,10 +264,32 @@ function NativeChatResolvedView({ [commandMarkerScope] ) + const launchPromptMessage = useMemo( + () => launchPromptAsMessage(paneLaunchPrompt, session.messages), + [paneLaunchPrompt, session.messages] + ) + const sessionWithLaunchPrompt = useMemo(() => { + if (!launchPromptMessage) { + return session + } + return { ...session, messages: [...session.messages, launchPromptMessage] } + }, [launchPromptMessage, session]) + const sessionAfterCommandBoundaries = useMemo(() => { - const messages = applyCommandMarkerBoundaries(session.messages, commandMarkers) - return messages === session.messages ? session : { ...session, messages } - }, [session, commandMarkers]) + const messages = applyCommandMarkerBoundaries(sessionWithLaunchPrompt.messages, commandMarkers) + return messages === sessionWithLaunchPrompt.messages + ? sessionWithLaunchPrompt + : { ...sessionWithLaunchPrompt, messages } + }, [sessionWithLaunchPrompt, commandMarkers]) + const launchPromptVisible = + launchPromptMessage !== null && + sessionAfterCommandBoundaries.messages.some((message) => message.id === launchPromptMessage.id) + const failedLaunchPromptMessageIds = useMemo(() => { + if (!paneLaunchPrompt?.failed || !launchPromptVisible || !launchPromptMessage) { + return undefined + } + return new Set([launchPromptMessage.id]) + }, [paneLaunchPrompt?.failed, launchPromptMessage, launchPromptVisible]) // The streaming preview bubble (if any) sits after the transcript but before // the optimistic user echoes — same order mobile uses. @@ -422,6 +417,7 @@ function NativeChatResolvedView({ fontScale={fontScale.scale} onLinkClick={nativeChatFileLinkClick} allowFileUriLinks={fileLinkContext !== null} + failedDeliveryMessageIds={failedLaunchPromptMessageIds} /> )}
diff --git a/src/renderer/src/components/native-chat/native-chat-availability.ts b/src/renderer/src/components/native-chat/native-chat-availability.ts index 6c5413eb4fc..4e520af1b9a 100644 --- a/src/renderer/src/components/native-chat/native-chat-availability.ts +++ b/src/renderer/src/components/native-chat/native-chat-availability.ts @@ -1,25 +1,8 @@ import type { Tab, TuiAgent } from '../../../../shared/types' import type { AgentType } from '../../../../shared/agent-status-types' +import { isNativeChatSupportedAgent } from '@/lib/native-chat-supported-agent' -/** Agents whose transcripts the native chat view can actually parse and render. - * Native chat depends on provider-specific transcript/streaming parsing, so the - * toggle must stay limited to the providers we support — currently Claude - * (including the OpenClaude variant) and Codex. Other agents (Grok, Gemini, …) - * run fine in the terminal but have no native-chat rendering, so they must not - * show the toggle. */ -const NATIVE_CHAT_SUPPORTED_AGENTS: ReadonlySet = new Set([ - 'claude', - 'openclaude', - 'codex' -]) - -/** Whether the given agent identity (from any signal: launch hint, live - * detection, or title resolution) is one native chat can render. */ -export function isNativeChatSupportedAgent( - agent: TuiAgent | AgentType | null | undefined -): boolean { - return agent != null && NATIVE_CHAT_SUPPORTED_AGENTS.has(agent) -} +export { isNativeChatSupportedAgent } /** Inputs that decide whether a tab may toggle into the native chat view. * Kept as a plain shape (not the live store) so the decision stays pure and diff --git a/src/renderer/src/components/native-chat/native-chat-file-link.ts b/src/renderer/src/components/native-chat/native-chat-file-link.ts index 511cbe7f924..f6531adaf35 100644 --- a/src/renderer/src/components/native-chat/native-chat-file-link.ts +++ b/src/renderer/src/components/native-chat/native-chat-file-link.ts @@ -31,7 +31,7 @@ type NativeChatFileLinkState = Pick< | 'worktreesByRepo' > -function findTerminalTabWorktreeId( +export function findTerminalTabWorktreeId( tabsByWorktree: NativeChatFileLinkState['tabsByWorktree'], terminalTabId: string ): string | null { diff --git a/src/renderer/src/components/native-chat/native-chat-message-grouping.test.ts b/src/renderer/src/components/native-chat/native-chat-message-grouping.test.ts index 0b510514135..7df292d5ed9 100644 --- a/src/renderer/src/components/native-chat/native-chat-message-grouping.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-message-grouping.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest' import type { NativeChatMessage } from '../../../../shared/native-chat-types' import { buildNativeChatRenderItems, orderNativeChatMessages } from './native-chat-message-grouping' +import { NATIVE_CHAT_STREAMING_ID } from '../../../../shared/native-chat-streaming' function msg( overrides: Partial & Pick @@ -31,6 +32,15 @@ describe('orderNativeChatMessages', () => { ]) expect(ordered.map((m) => m.id)).toEqual(['a', 'z']) }) + + it('sorts the streaming preview after real content but before optimistic echoes', () => { + const ordered = orderNativeChatMessages([ + msg({ id: 'pending:abc', role: 'user', timestamp: 20, source: 'scrape' }), + msg({ id: NATIVE_CHAT_STREAMING_ID, timestamp: null }), + msg({ id: 'real-user', role: 'user', timestamp: 10 }) + ]) + expect(ordered.map((m) => m.id)).toEqual(['real-user', 'streaming', 'pending:abc']) + }) }) describe('buildNativeChatRenderItems', () => { diff --git a/src/renderer/src/components/native-chat/native-chat-pending.test.ts b/src/renderer/src/components/native-chat/native-chat-pending.test.ts index c981fe806c7..5083ab0c518 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.test.ts @@ -8,11 +8,14 @@ import { clearPendingSendCacheForTests, commandMarkersAsMessages, isCommandMarkerId, + isLaunchPromptMessageId, isPendingMessageId, + launchPromptAsMessage, pendingSendsAsMessages, prunePendingSends, readCommandMarkerCache, readPendingSendCache, + shouldPruneLaunchPrompt, writePendingSendCache, type NativeChatPendingSend } from './native-chat-pending' @@ -135,6 +138,86 @@ describe('pendingSendsAsMessages', () => { }) }) +describe('launchPromptAsMessage', () => { + it('maps a launch prompt to a tab-keyed scrape-source user message', () => { + expect( + launchPromptAsMessage({ + tabId: 'tab-1', + agent: 'codex', + text: 'Fix failing checks', + createdAt: 42 + }) + ).toEqual({ + id: 'launch-pending:tab-1', + role: 'user', + blocks: [{ type: 'text', text: 'Fix failing checks' }], + timestamp: 42, + source: 'scrape' + }) + }) + + it('hides the launch prompt while its transcript user turn is visible', () => { + expect( + launchPromptAsMessage( + { + tabId: 'tab-1', + agent: 'codex', + text: 'Fix failing checks', + createdAt: 42 + }, + [userMessage('u1', 'Fix failing checks')] + ) + ).toBeNull() + }) + + it('uses pending-send normalization for large multiline generated prompts', () => { + const prompt = [ + '[Image #1] Resolve the failing checks:', + '', + 'Resolve the failing checks:', + '', + '- lint failed', + ' fix spacing' + ].join('\n') + const transcript = [ + userMessage( + 'u1', + 'Resolve the failing checks: Resolve the failing checks: - lint failed fix spacing' + ), + assistantMessage('a1', 'I will fix it') + ] + + expect( + shouldPruneLaunchPrompt( + { + tabId: 'tab-1', + agent: 'codex', + text: prompt, + createdAt: 42 + }, + transcript + ) + ).toBe(true) + }) + + it('keeps the launch prompt until the transcript advances past the user turn', () => { + const prompt = { + tabId: 'tab-1', + agent: 'claude' as const, + text: 'Fix failing checks', + createdAt: 42 + } + + expect(shouldPruneLaunchPrompt(prompt, [userMessage('u1', 'Fix failing checks')])).toBe(false) + expect( + shouldPruneLaunchPrompt(prompt, [ + userMessage('u1', 'Fix failing checks'), + assistantMessage('a1', 'working') + ]) + ).toBe(true) + }) +}) + describe('pending send cache', () => { it('persists optimistic sends for the same pane and agent', () => { clearPendingSendCacheForTests() @@ -165,6 +248,13 @@ describe('isPendingMessageId', () => { }) }) +describe('isLaunchPromptMessageId', () => { + it('recognizes the launch prompt id prefix', () => { + expect(isLaunchPromptMessageId('launch-pending:tab-1')).toBe(true) + expect(isLaunchPromptMessageId('pending:p1')).toBe(false) + }) +}) + describe('commandMarkersAsMessages', () => { it('renders a slash command as a system "Ran " message', () => { expect(commandMarkersAsMessages([{ id: 'c1', command: '/clear', sentAt: 7 }])).toEqual([ diff --git a/src/renderer/src/components/native-chat/native-chat-pending.ts b/src/renderer/src/components/native-chat/native-chat-pending.ts index 67bfb290aef..3021dc7bba4 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.ts @@ -5,6 +5,7 @@ import { isTextBlock, type NativeChatMessage } from '../../../../shared/native-chat-types' import { stripImagePromptMarker } from './native-chat-image-transcript-markers' +import type { NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt' /** An optimistic, not-yet-confirmed composer send. */ export type NativeChatPendingSend = { @@ -152,6 +153,43 @@ export function isPendingMessageId(id: string): boolean { return id.startsWith('pending:') } +// Why: the seeded prompt has a synthetic id that never matches the real turn's, +// so dedup/prune match on normalized user-message text instead — this hides the +// optimistic bubble once the transcript's own copy of the turn catches up. +export function launchPromptAsMessage( + entry: NativeChatLaunchPrompt | null, + existingMessages: NativeChatMessage[] = [] +): NativeChatMessage | null { + if (!entry) { + return null + } + const represented = matchingUserMessageTexts(existingMessages) + if (represented.has(normalize(entry.text))) { + return null + } + return { + id: `launch-pending:${entry.tabId}`, + role: 'user' as const, + blocks: entry.text.trim().length > 0 ? [{ type: 'text' as const, text: entry.text }] : [], + timestamp: entry.createdAt, + source: 'scrape' as const + } +} + +// Why: prune only once an assistant turn has landed after the matching user +// text — keeping the optimistic bubble through the user-only phase avoids a +// first-turn flash before the transcript's own copy of the turn catches up. +export function shouldPruneLaunchPrompt( + entry: NativeChatLaunchPrompt, + messages: NativeChatMessage[] +): boolean { + return advancedPastUserMessageTexts(messages).has(normalize(entry.text)) +} + +export function isLaunchPromptMessageId(id: string): boolean { + return id.startsWith('launch-pending:') +} + /** A locally-recorded slash command (e.g. `/clear`). Slash commands dispatch to * the agent's TUI and are not chat turns, so we surface a small system line as * feedback that the command ran rather than echoing a user bubble. */ diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-owner.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-owner.test.ts new file mode 100644 index 00000000000..3f3b3cf8e7b --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-runtime-owner.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import type { TerminalTab } from '../../../../shared/types' +import { + selectNativeChatRuntimeEnvironmentId, + type NativeChatRuntimeOwnerState +} from './native-chat-runtime-owner' + +function terminalTab(overrides: Partial = {}): TerminalTab { + return { + id: 'tab-1', + ptyId: null, + worktreeId: 'wt-1', + title: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0, + ...overrides + } +} + +/** A worktree record with a host id but deliberately no `path` — the owner + * selector must not depend on path resolution (KTD-1). */ +function worktreeRecord(hostId: string): NativeChatRuntimeOwnerState['worktreesByRepo'] { + return { repo: [{ id: 'wt-1', repoId: 'repo', hostId } as never] } +} + +function state(overrides: Partial = {}): NativeChatRuntimeOwnerState { + return { + folderWorkspaces: [], + projectGroups: [], + repos: [], + settings: { activeRuntimeEnvironmentId: null }, + tabsByWorktree: { 'wt-1': [terminalTab()] }, + worktreesByRepo: worktreeRecord('local'), + ...overrides + } as NativeChatRuntimeOwnerState +} + +describe('selectNativeChatRuntimeEnvironmentId', () => { + it('returns null for a local-owned worktree', () => { + expect(selectNativeChatRuntimeEnvironmentId(state(), 'tab-1')).toBeNull() + }) + + it('returns the decoded environment id for a runtime-owned worktree', () => { + expect( + selectNativeChatRuntimeEnvironmentId( + state({ worktreesByRepo: worktreeRecord('runtime:env-1') }), + 'tab-1' + ) + ).toBe('env-1') + }) + + it('returns null for an ssh-connection worktree (Model A stays local)', () => { + expect( + selectNativeChatRuntimeEnvironmentId( + state({ worktreesByRepo: worktreeRecord('ssh:conn-1') }), + 'tab-1' + ) + ).toBeNull() + }) + + it('returns null when the terminal tab matches no tab in tabsByWorktree', () => { + expect(selectNativeChatRuntimeEnvironmentId(state({ tabsByWorktree: {} }), 'tab-1')).toBeNull() + }) + + it('returns the owner id even when the worktree record has no resolvable path', () => { + // Guards KTD-1: no `path` on the worktree record and no getKnownWorktreeById — + // the selector must still resolve the runtime owner from the host mapping alone. + expect( + selectNativeChatRuntimeEnvironmentId( + state({ worktreesByRepo: worktreeRecord('runtime:env-1') }), + 'tab-1' + ) + ).toBe('env-1') + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-owner.ts b/src/renderer/src/components/native-chat/native-chat-runtime-owner.ts new file mode 100644 index 00000000000..b01ae9d8fda --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-runtime-owner.ts @@ -0,0 +1,29 @@ +import { + getRuntimeEnvironmentIdForWorktree, + type WorktreeRuntimeOwnerState +} from '@/lib/worktree-runtime-owner' +import type { AppState } from '@/store/types' +import { findTerminalTabWorktreeId } from './native-chat-file-link' + +export type NativeChatRuntimeOwnerState = Pick & WorktreeRuntimeOwnerState + +/** + * The runtime owner id for a Native Chat pane, as a primitive — non-null only for + * `runtime:` hosts (Model B), null for local and `ssh:` (Model A stays local). + * + * KTD-1: intentionally decoupled from `resolveNativeChatFileLinkContext`, which + * returns null whenever the worktree *path* can't resolve (store hydration, folder + * scopes, a remote worktree whose path hasn't landed). In that window the owner is + * still knowable and the transport must route to the runtime — reusing the + * path-coupled context would fall back to local session data, the exact bug this + * kills. Resolve the owner from the tab→worktree mapping alone; do not merge the + * two selections. The shared helper (`findTerminalTabWorktreeId`) is the right + * level of reuse. + */ +export function selectNativeChatRuntimeEnvironmentId( + state: NativeChatRuntimeOwnerState, + terminalTabId: string +): string | null { + const worktreeId = findTerminalTabWorktreeId(state.tabsByWorktree, terminalTabId) + return worktreeId ? getRuntimeEnvironmentIdForWorktree(state, worktreeId) : null +} diff --git a/src/renderer/src/components/native-chat/native-chat-session-assembler.ts b/src/renderer/src/components/native-chat/native-chat-session-assembler.ts index ddb4ac7fd40..53ebbd0084a 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-assembler.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-assembler.ts @@ -6,7 +6,9 @@ import { type NativeChatSession, type NativeChatSessionStatus } from '../../../../shared/native-chat-types' +import { NATIVE_CHAT_STREAMING_ID } from '../../../../shared/native-chat-streaming' import { normalizeImageTranscriptMessages } from './native-chat-image-transcript-markers' +import { isLaunchPromptMessageId, isPendingMessageId } from './native-chat-pending' /** Messages grouped by source. Higher-priority sources (transcript > hook > * scrape) supersede lower ones when they describe the same turn. */ @@ -84,10 +86,29 @@ function supersedes(candidate: NativeChatMessage, existing: NativeChatMessage): return candidateRank > existingRank } +// Why: the tail bubbles form fixed tiers that timestamps alone can't express. +// The streaming preview (null timestamp) must follow real content but sit ahead +// of the optimistic composer echoes, which carry finite `sentAt` timestamps that +// would otherwise sort past it. Rank first, then timestamp within a tier. +function messageSortRank(message: NativeChatMessage): number { + if (message.id === NATIVE_CHAT_STREAMING_ID) { + return 1 + } + if (isPendingMessageId(message.id) || isLaunchPromptMessageId(message.id)) { + return 2 + } + return 0 +} + // Why: null timestamps (sources that can't supply one, e.g. scrape segments) -// sort before any real timestamp so they don't jump to the end. Ties break on -// id for a stable, deterministic order. +// sort before any real timestamp within their tier so they don't jump to the +// end. Ties break on id for a stable, deterministic order. export function compareMessages(a: NativeChatMessage, b: NativeChatMessage): number { + const ar = messageSortRank(a) + const br = messageSortRank(b) + if (ar !== br) { + return ar - br + } const at = a.timestamp ?? Number.NEGATIVE_INFINITY const bt = b.timestamp ?? Number.NEGATIVE_INFINITY if (at !== bt) { diff --git a/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts b/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts new file mode 100644 index 00000000000..0a39178a18f --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts @@ -0,0 +1,238 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { + clearRuntimeCompatibilityCacheForTests, + markRuntimeEnvironmentCompatible, + RuntimeRpcCallError +} from '@/runtime/runtime-rpc-client' +import { RUNTIME_COMPAT_BLOCK_CODE } from '@/runtime/runtime-protocol-compat' +import { + getNativeChatSessionTransport, + RUNTIME_NATIVE_CHAT_RECONNECT_MS, + toRuntimeNativeChatErrorMessage +} from './native-chat-session-transport' + +const nativeChatReadSession = vi.fn() +const nativeChatSubscribe = vi.fn() +const runtimeEnvironmentsCall = vi.fn() +const runtimeEnvironmentsSubscribe = vi.fn() + +const ENV = 'env-1' + +function message(id: string): NativeChatMessage { + return { id, role: 'assistant', blocks: [{ type: 'text', text: id }], timestamp: 1, source: 'transcript' } +} + +function okEnvelope(result: unknown): { ok: true; result: unknown } { + return { ok: true, result } +} + +/** Flush pending microtasks and a macrotask turn — enough for the compat-gate + * await chain inside callRuntimeRpc to reach window.api.runtimeEnvironments.call. */ +function flush(): Promise { + return new Promise((resolve) => setTimeout(resolve)) +} + +beforeEach(() => { + vi.clearAllMocks() + clearRuntimeCompatibilityCacheForTests() + delete (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ + vi.stubGlobal('window', { + location: { pathname: '/index.html' }, + api: { + nativeChat: { readSession: nativeChatReadSession, subscribe: nativeChatSubscribe }, + runtimeEnvironments: { + call: runtimeEnvironmentsCall, + subscribe: runtimeEnvironmentsSubscribe + } + } + }) +}) + +describe('getNativeChatSessionTransport — selection', () => { + it('returns the local adapter for a null owner and forwards readSession args', async () => { + nativeChatReadSession.mockResolvedValue({ messages: [] }) + const transport = getNativeChatSessionTransport(null) + + await transport.readSession('claude', 'sess-1', 40, '/t/path') + + expect(nativeChatReadSession).toHaveBeenCalledWith('claude', 'sess-1', 40, '/t/path') + expect(runtimeEnvironmentsCall).not.toHaveBeenCalled() + }) + + it('returns the runtime adapter for an owner on the desktop client', async () => { + markRuntimeEnvironmentCompatible(ENV) + runtimeEnvironmentsCall.mockResolvedValue(okEnvelope({ messages: [] })) + const transport = getNativeChatSessionTransport(ENV) + + await transport.readSession('claude', 'sess-1', 40, '/t/path') + + expect(runtimeEnvironmentsCall).toHaveBeenCalledWith({ + selector: ENV, + method: 'nativeChat.readSession', + params: { agent: 'claude', sessionId: 'sess-1', limit: 40, transcriptPath: '/t/path' }, + timeoutMs: 15_000 + }) + expect(nativeChatReadSession).not.toHaveBeenCalled() + }) + + it('returns the local adapter on the web client even with an owner (R3 guard)', async () => { + ;(window as unknown as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true + nativeChatReadSession.mockResolvedValue({ messages: [] }) + const transport = getNativeChatSessionTransport(ENV) + + await transport.readSession('claude', 'sess-1', 40, undefined) + + expect(nativeChatReadSession).toHaveBeenCalledOnce() + expect(runtimeEnvironmentsCall).not.toHaveBeenCalled() + }) +}) + +describe('runtime subscribe', () => { + function stubSubscribe(): { + unsubscribe: ReturnType + deliver: (frame: unknown, ok?: boolean) => void + drop: () => void + subscribeCount: () => number + } { + const unsubscribe = vi.fn() + let count = 0 + let onResponse: (r: { ok: boolean; result?: unknown }) => void = () => {} + let onClose: () => void = () => {} + runtimeEnvironmentsSubscribe.mockImplementation((_args, callbacks) => { + count += 1 + onResponse = callbacks.onResponse + onClose = callbacks.onClose ?? (() => {}) + return Promise.resolve({ unsubscribe, sendBinary: vi.fn() }) + }) + return { + unsubscribe, + deliver: (frame, ok = true) => onResponse(ok ? { ok: true, result: frame } : { ok: false }), + drop: () => onClose(), + subscribeCount: () => count + } + } + + it('forwards only appended frames with an array of messages', async () => { + markRuntimeEnvironmentCompatible(ENV) + const { deliver } = stubSubscribe() + const onAppended = vi.fn() + const transport = getNativeChatSessionTransport(ENV) + + transport.subscribe({ subscriptionId: 's-1', agent: 'claude', sessionId: 'sess-1' }, onAppended) + await Promise.resolve() + + deliver({ type: 'appended', messages: [message('m-1')] }) + deliver({ type: 'ready' }) // non-appended, ignored + deliver({ type: 'appended', messages: undefined }) // no array, ignored + deliver({ type: 'appended', messages: [message('m-2')] }, false) // !ok, ignored + + expect(onAppended).toHaveBeenCalledTimes(1) + expect(onAppended).toHaveBeenCalledWith([message('m-1')]) + }) + + it('sync unsubscribe closes the dedicated stream and issues no unsubscribe RPC (KTD-6)', async () => { + markRuntimeEnvironmentCompatible(ENV) + const { unsubscribe } = stubSubscribe() + const transport = getNativeChatSessionTransport(ENV) + + const stop = transport.subscribe( + { subscriptionId: 's-1', agent: 'claude', sessionId: 'sess-1' }, + vi.fn() + ) + await flush() + stop() + await flush() + + expect(unsubscribe).toHaveBeenCalledOnce() + // The runtime reaps the fs-watcher when the dedicated stream socket closes, so + // no nativeChat.unsubscribe RPC is sent — one would ride a different connection + // and never match the watcher's cleanup key. + expect(runtimeEnvironmentsCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'nativeChat.unsubscribe' }) + ) + }) + + it('re-subscribes after a mid-stream drop and stops reconnecting on teardown', async () => { + vi.useFakeTimers() + try { + markRuntimeEnvironmentCompatible(ENV) + const { drop, subscribeCount } = stubSubscribe() + const transport = getNativeChatSessionTransport(ENV) + + const stop = transport.subscribe( + { subscriptionId: 's-1', agent: 'claude', sessionId: 'sess-1' }, + vi.fn() + ) + await vi.advanceTimersByTimeAsync(0) + expect(subscribeCount()).toBe(1) + + drop() // runtime restart / socket dropped mid-stream + await vi.advanceTimersByTimeAsync(RUNTIME_NATIVE_CHAT_RECONNECT_MS) + expect(subscribeCount()).toBe(2) + + stop() + drop() // a late close after teardown must not reconnect + await vi.advanceTimersByTimeAsync(RUNTIME_NATIVE_CHAT_RECONNECT_MS) + expect(subscribeCount()).toBe(2) + } finally { + vi.useRealTimers() + } + }) + + it('closes a stream that resolves after unsubscribe (cancel race)', async () => { + markRuntimeEnvironmentCompatible(ENV) + const unsubscribe = vi.fn() + let resolveHandle: (h: unknown) => void = () => {} + runtimeEnvironmentsSubscribe.mockImplementation( + () => new Promise((resolve) => (resolveHandle = resolve)) + ) + const onAppended = vi.fn() + const transport = getNativeChatSessionTransport(ENV) + + const stop = transport.subscribe( + { subscriptionId: 's-1', agent: 'claude', sessionId: 'sess-1' }, + onAppended + ) + stop() // teardown before the subscribe promise resolves + resolveHandle({ unsubscribe, sendBinary: vi.fn() }) + await Promise.resolve() + await Promise.resolve() + + expect(unsubscribe).toHaveBeenCalledOnce() + expect(onAppended).not.toHaveBeenCalled() + }) +}) + +describe('runtime readSession error mapping', () => { + it('maps a method_not_found rejection to the too-old copy (R4)', async () => { + markRuntimeEnvironmentCompatible(ENV) + runtimeEnvironmentsCall.mockResolvedValue({ + ok: false, + error: { code: 'method_not_found', message: 'no such method' } + }) + const transport = getNativeChatSessionTransport(ENV) + + const result = await transport.readSession('claude', 'sess-1', 40, undefined) + + expect(result).toEqual({ error: expect.stringContaining('too old') }) + }) + + it('maps error classes precisely (KTD-4, not catch-all)', () => { + const tooOld = expect.stringContaining('too old') + const generic = "Couldn't read agent chat from the remote runtime." + + expect( + toRuntimeNativeChatErrorMessage( + new RuntimeRpcCallError({ id: 'r-1', ok: false, error: { code: 'method_not_found', message: 'x' } }) + ) + ).toEqual(tooOld) + + const compatBlock = Object.assign(new Error('runtime too old'), { + code: RUNTIME_COMPAT_BLOCK_CODE + }) + expect(toRuntimeNativeChatErrorMessage(compatBlock)).toEqual(tooOld) + + expect(toRuntimeNativeChatErrorMessage(new Error('request timed out'))).toBe(generic) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-session-transport.ts b/src/renderer/src/components/native-chat/native-chat-session-transport.ts new file mode 100644 index 00000000000..2851a4d84e4 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-transport.ts @@ -0,0 +1,167 @@ +import type { + NativeChatApi, + NativeChatAppendedMessages, + NativeChatReadSessionResult +} from '../../../../preload/api-types' +import { isWebClientLocation } from '@/lib/web-client-location' +import { + callRuntimeRpc, + RuntimeRpcCallError, + type RuntimeClientTarget +} from '@/runtime/runtime-rpc-client' +import { isRuntimeCompatBlockError } from '@/runtime/runtime-protocol-compat' + +/** The read/subscribe surface the live-session hook needs, decoupled from where + * the transcript actually lives. Same shape as `window.api.nativeChat`, so the + * hook and everything downstream (merge, assembler, pagination) are unchanged. */ +export type NativeChatSessionTransport = Pick + +const RUNTIME_TOO_OLD = + 'This remote runtime is too old to show agent chat history. Update the remote runtime to view it.' + +/** Backoff before re-opening a dropped runtime chat stream. Exported for tests. */ +export const RUNTIME_NATIVE_CHAT_RECONNECT_MS = 2_000 + +/** Map a runtime read failure to the message the read-error state renders. A + * version block (old runtime lacking the method, or the protocol-compat gate) + * gets the explicit "update the remote runtime" copy (R4); anything else — a + * timeout or transport error — gets a generic message, so a transient failure is + * never mislabeled as a version problem (KTD-4, not catch-all). */ +export function toRuntimeNativeChatErrorMessage(err: unknown): string { + if (err instanceof RuntimeRpcCallError && err.code === 'method_not_found') { + return RUNTIME_TOO_OLD + } + if (isRuntimeCompatBlockError(err)) { + return RUNTIME_TOO_OLD + } + return "Couldn't read agent chat from the remote runtime." +} + +/** Delegates straight to the local Electron IPC bridge. On the web client + * `window.api.nativeChat` already bridges to the paired runtime, so web keeps + * using this adapter (R3). Preserves whatever `subscribe` returns (sync fn on + * desktop, promise on the web bridge) — the hook's teardown handles both (R6). */ +const localNativeChatTransport: NativeChatSessionTransport = { + readSession: (agent, sessionId, limit, transcriptPath) => + window.api.nativeChat.readSession(agent, sessionId, limit, transcriptPath), + subscribe: (args, onAppended) => window.api.nativeChat.subscribe(args, onAppended) +} + +function createRuntimeNativeChatTransport(environmentId: string): NativeChatSessionTransport { + const target: RuntimeClientTarget = { kind: 'environment', environmentId } + + return { + readSession: async (agent, sessionId, limit, transcriptPath) => { + try { + return await callRuntimeRpc( + target, + 'nativeChat.readSession', + { agent, sessionId, limit, transcriptPath }, + { timeoutMs: 15_000 } + ) + } catch (err) { + return { error: toRuntimeNativeChatErrorMessage(err) } + } + }, + subscribe: (args, onAppended) => { + const { subscriptionId, agent, sessionId, transcriptPath } = args + let cancelled = false + let handleUnsubscribe: (() => void) | null = null + let reconnectTimer: ReturnType | null = null + + // A mid-stream drop (runtime restart, network blip, relay reconnect) closes + // the dedicated stream socket. Re-open it after a short backoff so the live + // tail resumes without a manual chat toggle; the fresh drain re-emits the + // windowed tail, which merges by id so no turn is duplicated. An initial + // connect failure lands in `.catch` instead (and is surfaced through the + // parallel readSession error), so a too-old/absent runtime never spins here. + const scheduleReconnect = (): void => { + handleUnsubscribe = null + if (cancelled || reconnectTimer) { + return + } + reconnectTimer = setTimeout(() => { + reconnectTimer = null + if (!cancelled) { + openStream() + } + }, RUNTIME_NATIVE_CHAT_RECONNECT_MS) + } + + const openStream = (): void => { + void window.api.runtimeEnvironments + .subscribe( + { + selector: environmentId, + method: 'nativeChat.subscribe', + params: { subscriptionId, agent, sessionId, transcriptPath }, + timeoutMs: 15_000 + }, + { + onResponse: (response) => { + if (cancelled) { + return + } + if (response.ok === false) { + return + } + const frame = response.result as { + type?: string + messages?: NativeChatAppendedMessages + } + if (frame?.type === 'appended' && Array.isArray(frame.messages)) { + onAppended(frame.messages) + } + }, + // Established-then-dropped: resume the tail. onClose also fires on our + // own teardown, but `cancelled` short-circuits the reconnect there. + onError: scheduleReconnect, + onClose: scheduleReconnect + } + ) + .then((handle) => { + handleUnsubscribe = handle.unsubscribe + // The stream resolved after teardown already ran — close it now so the + // late-arriving handle doesn't leak (same race as runtime-file-client). + if (cancelled) { + handle.unsubscribe() + handleUnsubscribe = null + } + }) + .catch(() => { + // Initial subscribe failed (e.g. a too-old runtime lacking the method). + // The parallel readSession surfaces the error; don't reconnect-spin. + }) + } + + openStream() + + // Teardown closes the dedicated stream socket; the runtime reaps its + // fs-watcher on that connection's close (registerSubscriptionCleanup → + // cleanupSubscriptionsForConnection), so no explicit nativeChat.unsubscribe + // RPC is needed — and one would ride a different connection and never match + // the watcher's cleanup key anyway. + return () => { + cancelled = true + if (reconnectTimer) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + handleUnsubscribe?.() + handleUnsubscribe = null + } + } + } +} + +/** Select the read/subscribe transport for a pane. Route to the remote runtime + * only for a `runtime:`-owned pane on a non-web client (KTD-2); web and + * local/`ssh:`-owned panes keep the local adapter. */ +export function getNativeChatSessionTransport( + runtimeEnvironmentId: string | null +): NativeChatSessionTransport { + if (runtimeEnvironmentId && !isWebClientLocation()) { + return createRuntimeNativeChatTransport(runtimeEnvironmentId) + } + return localNativeChatTransport +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts index f92937ceea8..31a0c5b3a97 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts @@ -1,20 +1,55 @@ -import { describe, expect, it } from 'vitest' -import type { NativeChatMessage } from '../../../../shared/native-chat-types' -import { mergeNativeChatLiveSession } from './native-chat-live-status' +// @vitest-environment happy-dom -// These exercise the hook's merge core (mergeNativeChatLiveSession), which is -// the pure decision the hook delegates to after doing IPC + store reads. The -// React wiring (subscribe/unsubscribe lifecycle) is covered by the main-side -// transcript-watch + IPC tests; here we pin the live-status precedence rules. +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { useAppStore } from '@/store' +import { mergeNativeChatLiveSession } from './native-chat-live-status' +import { NATIVE_CHAT_INITIAL_LIMIT } from './native-chat-pagination' + +// Mock the session transport so the hook's IO is observable and controllable +// per owner id. Each distinct owner gets its own read/subscribe/unsubscribe mocks. +const { transportFactory, getMockTransport, resetMockTransports } = vi.hoisted(() => { + type MockTransport = { + readSession: ReturnType + subscribe: ReturnType + unsubscribe: ReturnType + } + const transports = new Map() + const getMockTransport = (ownerId: string | null): MockTransport => { + let transport = transports.get(ownerId) + if (!transport) { + const unsubscribe = vi.fn() + transport = { + unsubscribe, + readSession: vi.fn().mockResolvedValue({ messages: [] }), + subscribe: vi.fn(() => unsubscribe) + } + transports.set(ownerId, transport) + } + return transport + } + return { + getMockTransport, + resetMockTransports: () => transports.clear(), + transportFactory: vi.fn((ownerId: string | null) => getMockTransport(ownerId)) + } +}) + +vi.mock('./native-chat-session-transport', () => ({ + getNativeChatSessionTransport: transportFactory +})) + +// Imported after vi.mock is hoisted, so it binds to the mocked transport. +import { + useNativeChatLiveSession, + type NativeChatLiveSession, + type UseNativeChatLiveSessionArgs +} from './use-native-chat-live-session' function assistant(id: string, text: string): NativeChatMessage { - return { - id, - role: 'assistant', - blocks: [{ type: 'text', text }], - timestamp: 2, - source: 'transcript' - } + return { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp: 2, source: 'transcript' } } function user(id: string, text: string): NativeChatMessage { @@ -23,8 +58,6 @@ function user(id: string, text: string): NativeChatMessage { describe('mergeNativeChatLiveSession', () => { it("surfaces live 'working' before the assistant turn lands in the transcript", () => { - // The user prompt is in the transcript but the assistant has not replied - // yet; the hook reports the agent is working. const session = mergeNativeChatLiveSession({ sources: { transcript: [user('u-1', 'do a thing')] }, sessionId: 'sess', @@ -36,8 +69,6 @@ describe('mergeNativeChatLiveSession', () => { }) it("clears 'working' once the assistant message flushes to the transcript", () => { - // Same pane, but now the transcript's last message is the assistant reply — - // the turn is visible, so 'working' is superseded by the derived 'ready'. const session = mergeNativeChatLiveSession({ sources: { transcript: [user('u-1', 'do a thing'), assistant('a-1', 'done')] }, sessionId: 'sess', @@ -89,3 +120,165 @@ describe('mergeNativeChatLiveSession', () => { expect(session.status).toBe('empty') }) }) + +describe('useNativeChatLiveSession — transport routing', () => { + const AGENT = 'claude' as const + const SESSION = 'sess-1' + const PANE = 'pane-1' + const roots: Root[] = [] + let latest: NativeChatLiveSession | null = null + + function Probe(props: UseNativeChatLiveSessionArgs): null { + latest = useNativeChatLiveSession(props) + return null + } + + async function flush(): Promise { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + } + + async function render(props: UseNativeChatLiveSessionArgs): Promise { + const container = document.createElement('div') + const root = createRoot(container) + roots.push(root) + await act(async () => { + root.render(createElement(Probe, props)) + }) + await flush() + return root + } + + async function rerender(root: Root, props: UseNativeChatLiveSessionArgs): Promise { + await act(async () => { + root.render(createElement(Probe, props)) + }) + await flush() + } + + beforeEach(() => { + useAppStore.setState({ agentStatusByPaneKey: {} }) + }) + + afterEach(() => { + for (const root of roots.splice(0)) { + act(() => root.unmount()) + } + latest = null + vi.clearAllMocks() + resetMockTransports() + }) + + it('routes the initial read and subscribe through the runtime transport', async () => { + await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) + + expect(transportFactory).toHaveBeenCalledWith('env-1') + const transport = getMockTransport('env-1') + expect(transport.readSession).toHaveBeenCalledWith(AGENT, SESSION, expect.any(Number), undefined) + expect(transport.subscribe).toHaveBeenCalledOnce() + }) + + it('re-subscribes against the new owner on an owner flip (R5)', async () => { + const root = await render({ + paneKey: PANE, + agent: AGENT, + sessionId: SESSION, + runtimeEnvironmentId: 'env-1' + }) + const first = getMockTransport('env-1') + + await rerender(root, { + paneKey: PANE, + agent: AGENT, + sessionId: SESSION, + runtimeEnvironmentId: 'env-2' + }) + const second = getMockTransport('env-2') + + expect(first.unsubscribe).toHaveBeenCalledOnce() + expect(second.subscribe).toHaveBeenCalledOnce() + }) + + it('tears down the subscription on unmount (no watcher leak)', async () => { + const root = await render({ + paneKey: PANE, + agent: AGENT, + sessionId: SESSION, + runtimeEnvironmentId: 'env-1' + }) + const transport = getMockTransport('env-1') + + await act(async () => { + root.unmount() + }) + + expect(transport.unsubscribe).toHaveBeenCalledOnce() + }) + + it('surfaces a runtime read error in the error phase (R4 end-to-end)', async () => { + getMockTransport('env-1').readSession.mockResolvedValueOnce({ error: 'runtime too old' }) + + await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) + await flush() + + expect(latest?.status).toBe('error') + expect(latest?.error).toBe('runtime too old') + }) + + it('never calls the transport when there is no session id', async () => { + await render({ paneKey: PANE, agent: AGENT, sessionId: null, runtimeEnvironmentId: 'env-1' }) + + const transport = getMockTransport('env-1') + expect(transport.readSession).not.toHaveBeenCalled() + expect(transport.subscribe).not.toHaveBeenCalled() + }) + + it('uses the local transport when the owner is null (unchanged behavior, R6)', async () => { + await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION }) + + expect(transportFactory).toHaveBeenCalledWith(null) + const transport = getMockTransport(null) + expect(transport.readSession).toHaveBeenCalledOnce() + expect(transport.subscribe).toHaveBeenCalledOnce() + }) + + it('discards a load-earlier resolve from the previous owner after a flip', async () => { + // Fill the initial window so hasMore is true and load-earlier can fire. + const many = Array.from({ length: NATIVE_CHAT_INITIAL_LIMIT }, (_unused, n) => + assistant(`m-${n}`, 't') + ) + const first = getMockTransport('env-1') + first.readSession.mockResolvedValueOnce({ messages: many }) + let resolveEarlier: (result: { messages: NativeChatMessage[] }) => void = () => {} + first.readSession.mockImplementationOnce( + () => new Promise((resolve) => (resolveEarlier = resolve)) + ) + + const root = await render({ + paneKey: PANE, + agent: AGENT, + sessionId: SESSION, + runtimeEnvironmentId: 'env-1' + }) + // Kick off load-earlier against env-1, then flip the owner before it resolves. + await act(async () => { + latest?.loadEarlier() + }) + await rerender(root, { + paneKey: PANE, + agent: AGENT, + sessionId: SESSION, + runtimeEnvironmentId: 'env-2' + }) + // The stale env-1 page resolves now; the transport-identity guard must drop it + // so it can't paint the previous host's history into the env-2 pane. + await act(async () => { + resolveEarlier({ messages: [assistant('stale', 'from-env-1')] }) + await Promise.resolve() + }) + + expect(latest?.messages.map((m) => m.id)).not.toContain('stale') + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts index 2eb92dfee10..8832b6e1966 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts @@ -23,6 +23,7 @@ import { NATIVE_CHAT_INITIAL_LIMIT, nextNativeChatLimit } from './native-chat-pagination' +import { getNativeChatSessionTransport } from './native-chat-session-transport' export type UseNativeChatLiveSessionArgs = { /** Composite `${tabId}:${leafId}` key — selects the live hook entry. */ @@ -34,6 +35,9 @@ export type UseNativeChatLiveSessionArgs = { /** Authoritative transcript path from the hook (providerSession), preferred * over reconstructing the path from sessionId. Null when not reported. */ transcriptPath?: string | null + /** Runtime owner of the pane (Model B). Non-null routes read/subscribe to the + * remote runtime host; null/undefined keeps the local IPC path. */ + runtimeEnvironmentId?: string | null } /** A live session plus the older-history pagination controls the view needs. */ @@ -89,17 +93,26 @@ type ReadState = * filled the window. Read results replace the base list (they are an ordered * tail), while live appends accumulate separately so a re-read never drops them. * - * Remote/SSH: `nativeChat.readSession`/`subscribe` are main-process IPC, so the - * transcript is read against the runtime's home dir (local or server-side) on - * main — the renderer is transport-agnostic and needs no remote branch here. + * Transport: IO goes through a per-owner session transport selected by + * getNativeChatSessionTransport. A runtime-owned pane (Model B) reads/tails the + * REMOTE runtime host via the runtime RPCs; local- and ssh-owned panes keep the + * local IPC path. The transport preserves the NativeChatApi read/subscribe shape, + * so everything below (merge, assembler, pagination) is unchanged. * - * Teardown: the subscription is closed on unmount and whenever agent/sessionId - * change, so a toggle back to terminal or a session swap never leaks a watcher. + * Teardown: the subscription is closed on unmount and whenever the owner, agent, + * or sessionId change, so a toggle back to terminal, a session swap, or an + * owner-flip never leaks a watcher (remote or local). */ export function useNativeChatLiveSession( args: UseNativeChatLiveSessionArgs ): NativeChatLiveSession { - const { paneKey, agent, sessionId, transcriptPath } = args + const { paneKey, agent, sessionId, transcriptPath, runtimeEnvironmentId } = args + // Stable per owner id, so a re-render without an owner flip keeps the same + // transport identity and doesn't re-subscribe. + const transport = useMemo( + () => getNativeChatSessionTransport(runtimeEnvironmentId ?? null), + [runtimeEnvironmentId] + ) const [read, setRead] = useState({ phase: 'loading' }) const [hasMore, setHasMore] = useState(false) const [loadingEarlier, setLoadingEarlier] = useState(false) @@ -122,6 +135,10 @@ export function useNativeChatLiveSession( const latestSessionId = useRef(sessionId) latestSessionId.current = sessionId + // Tracks the current owner's transport so a load-earlier resolve from a prior + // host is discarded after an owner flip (the session id can stay the same). + const latestTransport = useRef(transport) + latestTransport.current = transport // Incremental assembler: reset on the base axis (session/agent/read swap), // applyAppends on the hot append axis. `appliedTranscriptRef` is the exact @@ -150,8 +167,8 @@ export function useNativeChatLiveSession( setAppended([]) setHasMore(false) - void window.api?.nativeChat - ?.readSession(agent, sessionId, limitRef.current, transcriptPath ?? undefined) + void transport + .readSession(agent, sessionId, limitRef.current, transcriptPath ?? undefined) .then((result) => { if (cancelled) { return @@ -171,7 +188,7 @@ export function useNativeChatLiveSession( }) const subscriptionId = nextSubscriptionId() - const unsubscribe = window.api?.nativeChat?.subscribe?.( + const unsubscribe = transport.subscribe( { subscriptionId, agent, sessionId, transcriptPath: transcriptPath ?? undefined }, (messages) => { if (!cancelled) { @@ -206,7 +223,9 @@ export function useNativeChatLiveSession( }) } } - }, [agent, sessionId, transcriptPath]) + // `transport` identity changes on an owner flip, re-running this effect to + // tear down the old host's subscription and open one against the new host. + }, [agent, sessionId, transcriptPath, transport]) const loadEarlier = useCallback(() => { if (!sessionId || loadingEarlier || !hasMore || read.phase !== 'ready') { @@ -214,11 +233,12 @@ export function useNativeChatLiveSession( } const nextLimit = nextNativeChatLimit(limitRef.current) setLoadingEarlier(true) - void window.api?.nativeChat - ?.readSession(agent, sessionId, nextLimit, transcriptPath ?? undefined) + void transport + .readSession(agent, sessionId, nextLimit, transcriptPath ?? undefined) .then((result) => { - // Ignore a stale resolve from a session that swapped underneath us. - if (latestSessionId.current !== sessionId) { + // Ignore a stale resolve from a session that swapped OR an owner that + // flipped underneath us — either would paint the wrong host's history. + if (latestSessionId.current !== sessionId || latestTransport.current !== transport) { return } if (!result || 'error' in result) { @@ -230,13 +250,18 @@ export function useNativeChatLiveSession( setRead({ phase: 'ready', messages: result.messages }) setHasMore(hasMoreNativeChatHistory(result.messages.length, nextLimit)) }) + .catch(() => { + // Swallow a rejected earlier-page read (the IPC-backed call can reject): + // it's a "load more" action, so failing should leave the already-loaded + // transcript intact rather than surface an unhandled rejection. + }) .finally(() => { // Always clear the loading flag — even after a session swap — so a stale // resolve can't leave loadingEarlier stuck true on the new session. Only // APPLYING the result above is gated on the session-id match. setLoadingEarlier(false) }) - }, [agent, sessionId, transcriptPath, hasMore, loadingEarlier, read.phase]) + }, [agent, sessionId, transcriptPath, transport, hasMore, loadingEarlier, read.phase]) // Assembled messages reuse the incremental assembler across appends. Computed // outside the status memo: hookState changes only the status override, not the diff --git a/src/renderer/src/components/native-chat/use-native-chat-paste-bridge.ts b/src/renderer/src/components/native-chat/use-native-chat-paste-bridge.ts new file mode 100644 index 00000000000..b41b4fdf964 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-paste-bridge.ts @@ -0,0 +1,55 @@ +import { useCallback, useEffect } from 'react' +import type { RefObject } from 'react' +import { APP_MENU_PASTE_EVENT } from '@/lib/app-menu-paste' +import type { NativeChatComposerHandle } from './NativeChatComposer' + +type NativeChatPasteBridgeRefs = { + rootRef: RefObject + composerRef: RefObject +} + +export function useNativeChatPasteBridge({ + rootRef, + composerRef +}: NativeChatPasteBridgeRefs): () => void { + const pasteClipboardIntoComposer = useCallback(() => { + composerRef.current?.pasteFromClipboard() + }, [composerRef]) + + // Capture at the pane root so repeated composer mounts do not miss image paste. + useEffect(() => { + const root = rootRef.current + if (!root) { + return + } + const onPaste = (event: ClipboardEvent): void => { + composerRef.current?.handlePasteEvent(event) + } + root.addEventListener('paste', onPaste, { capture: true }) + return () => { + root.removeEventListener('paste', onPaste, { capture: true }) + } + }, [composerRef, rootRef]) + + useEffect(() => { + const onAppMenuPaste = (event: Event): void => { + const root = rootRef.current + const activeElement = document.activeElement + // The app-menu paste event is window-scoped; only claim it when focus is + // inside this chat pane so multiple panes don't all react to one Cmd+V. + if (!root || !(activeElement instanceof Element) || !root.contains(activeElement)) { + return + } + event.preventDefault() + event.stopPropagation() + pasteClipboardIntoComposer() + } + + window.addEventListener(APP_MENU_PASTE_EVENT, onAppMenuPaste) + return () => { + window.removeEventListener(APP_MENU_PASTE_EVENT, onAppMenuPaste) + } + }, [pasteClipboardIntoComposer, rootRef]) + + return pasteClipboardIntoComposer +} diff --git a/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx b/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx index 63e88f9e0d1..cb0309d3e6e 100644 --- a/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx +++ b/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx @@ -138,4 +138,34 @@ describe('ProjectCombobox', () => { expect(onValueChange).toHaveBeenCalledWith('project-group:folder-group') }) + + it('renders directory details for duplicate project names', () => { + const duplicateProjects: NewWorkspaceProjectOption[] = [ + { + kind: 'project', + id: 'project:merchant-a', + projectId: 'project:merchant-a', + displayName: 'merchant', + badgeColor: '#111111', + detail: '/workspace/storefront/merchant' + }, + { + kind: 'project', + id: 'project:merchant-b', + projectId: 'project:merchant-b', + displayName: 'merchant', + badgeColor: '#222222', + detail: '/workspace/admin/merchant' + } + ] + + act(() => { + root.render( + + ) + }) + + expect(container.textContent).toContain('/workspace/storefront/merchant') + expect(container.textContent).toContain('/workspace/admin/merchant') + }) }) diff --git a/src/renderer/src/components/new-workspace/smart-workspace-localized-options.test.ts b/src/renderer/src/components/new-workspace/smart-workspace-localized-options.test.ts index a30a0344bbb..601b838f649 100644 --- a/src/renderer/src/components/new-workspace/smart-workspace-localized-options.test.ts +++ b/src/renderer/src/components/new-workspace/smart-workspace-localized-options.test.ts @@ -52,7 +52,7 @@ describe('smart-workspace-localized-options', () => { await i18n.changeLanguage('zh') expect(getMrStateFilters().map((filter) => filter.label)).toEqual([ - '进行中', + '开放', '合并', '已关闭', '全部' diff --git a/src/renderer/src/components/onboarding/OnboardingTourStep.tsx b/src/renderer/src/components/onboarding/OnboardingTourStep.tsx deleted file mode 100644 index a9f2b43065b..00000000000 --- a/src/renderer/src/components/onboarding/OnboardingTourStep.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import type { JSX } from 'react' -import { flushSync } from 'react-dom' -import { ArrowRight } from 'lucide-react' -import { Button } from '@/components/ui/button' -import type { FeatureWallTourDepthSummary } from '../../../../shared/feature-wall-tour-depth' -import { FeatureTourPreview } from '../feature-wall/FeatureTourPreview' -import { FeatureWallTourSurface } from '../feature-wall/FeatureWallTourSurface' -import { usePrefersReducedMotion } from '../feature-wall/feature-wall-modal-helpers' -import { translate } from '@/i18n/i18n' - -type OnboardingTourStepProps = { - tourStarted: boolean - busyLabel: string | null - onStartTour: () => void - onCompleteTour: (markSuccessfulExit?: () => void) => boolean | void | Promise - onExitTour: () => void - onTourDepthSummaryChange: (summary: FeatureWallTourDepthSummary) => void -} - -type ViewTransition = { - finished: Promise -} - -type DocumentWithOptionalViewTransition = Document & { - startViewTransition?: (updateCallback: () => void | Promise) => ViewTransition -} - -export function OnboardingTourStep({ - tourStarted, - busyLabel, - onStartTour, - onCompleteTour, - onExitTour, - onTourDepthSummaryChange -}: OnboardingTourStepProps): JSX.Element { - const prefersReducedMotion = usePrefersReducedMotion() - - const handleStartTour = (): void => { - if (busyLabel) { - return - } - const doc = document as DocumentWithOptionalViewTransition - if (prefersReducedMotion || typeof doc.startViewTransition !== 'function') { - onStartTour() - return - } - - const root = document.documentElement - let didStart = false - root.classList.add('onboarding-tour-start-transition') - try { - // Why: starting the inline feature wall rewrites the page shell; a view - // transition cross-fades the compact intro into the wide tour surface. - const transition = doc.startViewTransition(() => { - didStart = true - flushSync(onStartTour) - }) - void transition.finished.finally(() => { - root.classList.remove('onboarding-tour-start-transition') - }) - } catch { - root.classList.remove('onboarding-tour-start-transition') - if (!didStart) { - onStartTour() - } - } - } - - if (tourStarted) { - return ( - - {translate('auto.components.onboarding.OnboardingTourStep.60c5576353', 'Exit tour')} - - } - /> - ) - } - - return ( -
- - -
- ) -} diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index f67c7d82a3a..8442e73f54e 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -41,6 +41,7 @@ import { translate } from '@/i18n/i18n' import { AiVaultPanelHeader } from './AiVaultPanelHeader' import { AiVaultSessionVirtualList } from './AiVaultSessionVirtualList' import { useAiVaultSessionRefresh } from './ai-vault-session-refresh' +import { useAiVaultExecutionHostScope } from './ai-vault-host-scope' export default function AiVaultPanel(): React.JSX.Element { const activeWorktreeId = useActiveWorktreeId() @@ -77,6 +78,11 @@ export default function AiVaultPanel(): React.JSX.Element { 'runtime', [activeWorktreeId, resumeTargetState] ) + const { executionHostScope, activeSshExecutionHostScope, onExecutionHostScopeChange } = + useAiVaultExecutionHostScope({ + activeWorktreeId: activeWorktreeId ?? null, + resumeTargetState + }) const activeWorktreePath = activeWorktree?.path ?? null // Why: AI Vault ownership is cwd-based, so we must consider live worktrees across all repos. const activeWorktreePaths = useMemo( @@ -106,7 +112,10 @@ export default function AiVaultPanel(): React.JSX.Element { }), [activeProjectKey, activeWorktree, allWorktrees, projectHostSetupProjection] ) - const { error, loading, refresh, scanResult, sessions } = useAiVaultSessionRefresh(scopePaths) + const { error, loading, refresh, scanResult, sessions } = useAiVaultSessionRefresh( + scopePaths, + executionHostScope + ) const sessionProjectById = useMemo( () => buildAiVaultProjectContext({ @@ -121,6 +130,7 @@ export default function AiVaultPanel(): React.JSX.Element { ) const sessionWorktreeById = useAiVaultSessionWorktreeMap({ sessions, + repos, worktrees: allWorktrees, activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null }) @@ -211,6 +221,7 @@ export default function AiVaultPanel(): React.JSX.Element { (session: AiVaultSession) => resolveAiVaultSessionResumeState({ sessionFilePath: session.filePath, + sessionExecutionHostId: session.executionHostId, worktreeInfo: sessionWorktreeById.get(session.id) ?? null, activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null, worktrees: allWorktrees, @@ -231,6 +242,7 @@ export default function AiVaultPanel(): React.JSX.Element { (session: AiVaultSession) => resolveAiVaultSessionResumeActions({ sessionFilePath: session.filePath, + sessionExecutionHostId: session.executionHostId, worktreeInfo: sessionWorktreeById.get(session.id) ?? null, activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null, worktrees: allWorktrees, @@ -293,6 +305,8 @@ export default function AiVaultPanel(): React.JSX.Element { activeWorktreePath={activeWorktreePath} activeProjectKey={activeProjectKey} scope={scope} + executionHostScope={executionHostScope} + activeSshExecutionHostScope={activeSshExecutionHostScope} agents={agents} sort={sort} group={group} @@ -300,6 +314,7 @@ export default function AiVaultPanel(): React.JSX.Element { adjustmentCount={viewAdjustmentCount} onQueryChange={setQuery} onScopeChange={handleScopeChange} + onExecutionHostScopeChange={onExecutionHostScopeChange} onAgentEnabledChange={setAgentEnabled} onSortChange={setSort} onGroupChange={setGroup} @@ -340,6 +355,7 @@ export default function AiVaultPanel(): React.JSX.Element { sessionsCount={sessions.length} filteredSessionsCount={filteredSessions.length} error={error} + vaultScope={scope} buildResumeStartup={buildResumeStartup} getSessionResumeState={getSessionResumeState} getSessionResumeActions={getSessionResumeActions} diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx index 227e96ccca9..2b3cc2802b0 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx @@ -7,7 +7,8 @@ import { FolderOpen, ListFilter, LoaderCircle, - PanelsTopLeft + PanelsTopLeft, + Server } from 'lucide-react' import { Button } from '@/components/ui/button' import { @@ -31,6 +32,12 @@ import { type AiVaultScope, type AiVaultSort } from '../../../../shared/ai-vault-types' +import { + ALL_EXECUTION_HOSTS_SCOPE, + getExecutionHostLabel, + LOCAL_EXECUTION_HOST_ID, + type ExecutionHostScope +} from '../../../../shared/execution-host' import { agentLabel, type AiVaultSessionGroup } from './ai-vault-session-filters' import { translate } from '@/i18n/i18n' @@ -175,6 +182,59 @@ export function VaultScopeSwitch({ ) } +export function VaultHostScopeMenu({ + executionHostScope, + activeSshExecutionHostScope, + onExecutionHostScopeChange +}: { + executionHostScope: ExecutionHostScope + activeSshExecutionHostScope: ExecutionHostScope | null + onExecutionHostScopeChange: (scope: ExecutionHostScope) => void +}): React.JSX.Element { + const hostOptions = [ + LOCAL_EXECUTION_HOST_ID, + ...(activeSshExecutionHostScope ? [activeSshExecutionHostScope] : []), + ALL_EXECUTION_HOSTS_SCOPE + ] as const + const label = getExecutionHostLabel(executionHostScope) + + return ( + + + + + + + {translate('auto.components.right.sidebar.AiVaultPanelControls.host', 'Host')} + + onExecutionHostScopeChange(value as ExecutionHostScope)} + > + {hostOptions.map((scope) => ( + + {getExecutionHostLabel(scope)} + + ))} + + + + ) +} + export function VaultViewMenu({ agents, sort, diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx index 6f40190617e..b463e188a7e 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx @@ -7,7 +7,8 @@ import type { AiVaultScope, AiVaultSort } from '../../../../shared/ai-vault-types' -import { VaultScopeSwitch, VaultViewMenu } from './AiVaultPanelControls' +import type { ExecutionHostScope } from '../../../../shared/execution-host' +import { VaultHostScopeMenu, VaultScopeSwitch, VaultViewMenu } from './AiVaultPanelControls' type AiVaultPanelHeaderProps = { query: string @@ -18,6 +19,8 @@ type AiVaultPanelHeaderProps = { activeWorktreePath: string | null activeProjectKey: string | null scope: AiVaultScope + executionHostScope: ExecutionHostScope + activeSshExecutionHostScope: ExecutionHostScope | null agents: readonly AiVaultAgent[] sort: AiVaultSort group: AiVaultGroup @@ -25,6 +28,7 @@ type AiVaultPanelHeaderProps = { adjustmentCount: number onQueryChange: (query: string) => void onScopeChange: (scope: AiVaultScope) => void + onExecutionHostScopeChange: (scope: ExecutionHostScope) => void onAgentEnabledChange: (agent: AiVaultAgent, enabled: boolean) => void onSortChange: (sort: AiVaultSort) => void onGroupChange: (group: AiVaultGroup) => void @@ -42,6 +46,8 @@ export function AiVaultPanelHeader({ activeWorktreePath, activeProjectKey, scope, + executionHostScope, + activeSshExecutionHostScope, agents, sort, group, @@ -49,6 +55,7 @@ export function AiVaultPanelHeader({ adjustmentCount, onQueryChange, onScopeChange, + onExecutionHostScopeChange, onAgentEnabledChange, onSortChange, onGroupChange, @@ -99,6 +106,11 @@ export function AiVaultPanelHeader({
+ void + onJumpToOriginalPane?: () => void + showJumpToWorktree: boolean + onJumpToWorktree?: () => void + onCopyResume: () => void + onCopyId: () => void + onCopyPath: () => void + onOpenLog?: () => void + onRevealLog?: () => void + onOpenCwd?: () => void +}) { + const Item = menuKind === 'context' ? ContextMenuItem : DropdownMenuItem + const Separator = menuKind === 'context' ? ContextMenuSeparator : DropdownMenuSeparator + const hasLocalPathActions = Boolean(onOpenLog || onRevealLog || onOpenCwd) + + return ( + <> + {onJumpToOriginalPane ? ( + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.jumpToOriginalPane', + 'Jump to Original Pane' + )} + + ) : null} + {showJumpToWorktree ? ( + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.jumpToWorktree', + 'Jump to Worktree' + )} + + ) : null} + + + {resumeLabel} + + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.copyResumeCommand', + 'Copy Resume Command' + )} + + {hasLocalPathActions ? ( + <> + + {onOpenLog ? ( + + + {translate('auto.components.right.sidebar.AiVaultSessionRow.openLog', 'Open Log')} + + ) : null} + {onRevealLog ? ( + + + {translate('auto.components.right.sidebar.AiVaultSessionRow.revealLog', 'Reveal Log')} + + ) : null} + {onOpenCwd ? ( + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.openWorkingDirectory', + 'Open Working Directory' + )} + + ) : null} + + ) : null} + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.copySessionId', + 'Copy Session ID' + )} + + + {translate('auto.components.right.sidebar.AiVaultSessionRow.copyLogPath', 'Copy Log Path')} + + + ) +} diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx index 58923a53479..85f5b2a5759 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx @@ -3,13 +3,14 @@ import { FileJson, FolderGit2, MessageSquare, Play } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' -import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types' import { translate } from '@/i18n/i18n' import { sessionDetailConversationTurns } from './ai-vault-session-display' import { aiVaultWorktreeCompactPath, aiVaultWorktreeStatusLabel, shouldShowAiVaultWorktreeStatusBadge, + shouldShowAiVaultSessionWorktreeLine, type AiVaultSessionWorktreeInfo } from './ai-vault-session-worktree' @@ -17,6 +18,7 @@ export function SessionInlineDetails({ id, session, worktreeInfo, + vaultScope, resumeActions, onResumeInWorktree, onResumeInNewTab, @@ -25,13 +27,14 @@ export function SessionInlineDetails({ id: string session: AiVaultSession worktreeInfo: AiVaultSessionWorktreeInfo | null + vaultScope: AiVaultScope resumeActions: { worktree: { worktreeId: string | null; disabled: boolean } newTab: { worktreeId: string | null; disabled: boolean } } onResumeInWorktree: () => void onResumeInNewTab: () => void - onOpenLog: () => void + onOpenLog?: () => void }): React.JSX.Element { const showResumeInWorktree = Boolean(resumeActions.worktree.worktreeId) const showResumeInNewTab = !showResumeInWorktree || Boolean(resumeActions.newTab.worktreeId) @@ -78,7 +81,7 @@ export function SessionInlineDetails({ )} - {worktreeDisplay ? ( + {shouldShowAiVaultSessionWorktreeLine(worktreeDisplay, { vaultScope }) ? ( } label={translate( @@ -86,7 +89,7 @@ export function SessionInlineDetails({ 'Worktree' )} > - + ) : null}
@@ -132,20 +135,22 @@ export function SessionInlineDetails({ )} ) : null} - + {onOpenLog ? ( + + ) : null}
) @@ -200,9 +205,11 @@ function ConversationTurnCard({ } function WorktreeMetadataLines({ - worktreeInfo + worktreeInfo, + vaultScope }: { worktreeInfo: AiVaultSessionWorktreeInfo + vaultScope: AiVaultScope }): React.JSX.Element { const compactPath = aiVaultWorktreeCompactPath(worktreeInfo.path) const pathLine = @@ -212,7 +219,7 @@ function WorktreeMetadataLines({ return (
- {shouldShowAiVaultWorktreeStatusBadge(worktreeInfo.status) ? ( + {shouldShowAiVaultWorktreeStatusBadge(worktreeInfo.status, { vaultScope }) ? ( <> {aiVaultWorktreeStatusLabel(worktreeInfo.status)} diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx index 5ea6bd9d307..b0d88016327 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx @@ -1,44 +1,36 @@ import { useCallback } from 'react' import type React from 'react' -import { Copy, FileJson, FolderOpen, LocateFixed, PanelTopOpen, Play } from 'lucide-react' -import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu' -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger -} from '@/components/ui/context-menu' -import { Badge } from '@/components/ui/badge' -import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' -import { AgentIcon } from '@/lib/agent-catalog' +import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from '@/components/ui/context-menu' import { cn } from '@/lib/utils' -import { useRepoById } from '@/store/selectors' -import { resolveRepoBadgeColor } from '../../../../shared/repo-badge-color' -import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id' import { AI_VAULT_SESSION_DRAG_END_EVENT, AI_VAULT_SESSION_DRAG_START_EVENT, writeAiVaultSessionDragData } from '@/lib/ai-vault-session-drag' -import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types' import type { AiVaultResumeStartup } from '@/lib/ai-vault-resume-command' -import { agentLabel } from './ai-vault-session-filters' import { translate } from '@/i18n/i18n' -import { SessionInlineDetails, SessionTime } from './AiVaultSessionDetails' +import { SessionInlineDetails } from './AiVaultSessionDetails' import { latestSessionConversationTurn } from './ai-vault-session-display' +import { SessionActionMenuItems } from './AiVaultSessionActionMenuItems' import { SessionRowTrailingActions } from './SessionRowTrailingActions' import type { AiVaultSessionResumeActions } from './ai-vault-session-resume' import { - aiVaultWorktreeStatusLabel, - shouldShowAiVaultWorktreeStatusBadge, + shouldShowAiVaultSessionWorktreeLine, type AiVaultSessionWorktreeInfo } from './ai-vault-session-worktree' +import { + conversationRoleLabel, + getSessionDetailsId, + SessionMetadata, + SessionWorktreeLine +} from './ai-vault-session-row-display' export function VaultSessionRow({ session, resumeStartup, worktreeInfo, + vaultScope, detailsExpanded, resumeDisabled, onToggleDetails, @@ -60,6 +52,7 @@ export function VaultSessionRow({ session: AiVaultSession resumeStartup: AiVaultResumeStartup worktreeInfo: AiVaultSessionWorktreeInfo | null + vaultScope: AiVaultScope detailsExpanded: boolean resumeDisabled: boolean onToggleDetails: () => void @@ -74,8 +67,8 @@ export function VaultSessionRow({ onCopyResume: () => void onCopyId: () => void onCopyPath: () => void - onOpenLog: () => void - onRevealLog: () => void + onOpenLog?: () => void + onRevealLog?: () => void onOpenCwd?: () => void }) { const updatedAt = session.updatedAt ?? session.modifiedAt @@ -102,6 +95,7 @@ export function VaultSessionRow({ title: session.title, command: resumeStartup.command, sessionFilePath: session.filePath, + sessionExecutionHostId: session.executionHostId, ...(resumeStartup.env ? { env: resumeStartup.env } : {}), ...(resumeStartup.launchConfig ? { launchConfig: resumeStartup.launchConfig } : {}) }) @@ -160,9 +154,9 @@ export function VaultSessionRow({ onOpenCwd={onOpenCwd} />
- {detailsExpanded && worktreeInfo ? ( + {detailsExpanded && shouldShowAiVaultSessionWorktreeLine(worktreeInfo, { vaultScope }) ? (
- +
) : null} {!detailsExpanded ? ( @@ -186,6 +180,7 @@ export function VaultSessionRow({ session={session} updatedAt={updatedAt} worktreeInfo={worktreeInfo} + vaultScope={vaultScope} /> ) : null} @@ -194,6 +189,7 @@ export function VaultSessionRow({ id={detailsId} session={session} worktreeInfo={worktreeInfo} + vaultScope={vaultScope} resumeActions={resumeActions} onResumeInWorktree={onResumeInWorktree} onResumeInNewTab={onResumeInNewTab} @@ -222,186 +218,3 @@ export function VaultSessionRow({ ) } - -export function SessionActionMenuItems({ - menuKind = 'dropdown', - resumeDisabled, - resumeLabel, - onResume, - onJumpToOriginalPane, - showJumpToWorktree, - onJumpToWorktree, - onCopyResume, - onCopyId, - onCopyPath, - onOpenLog, - onRevealLog, - onOpenCwd -}: { - menuKind?: 'dropdown' | 'context' - resumeDisabled: boolean - resumeLabel: string - onResume: () => void - onJumpToOriginalPane?: () => void - showJumpToWorktree: boolean - onJumpToWorktree?: () => void - onCopyResume: () => void - onCopyId: () => void - onCopyPath: () => void - onOpenLog: () => void - onRevealLog: () => void - onOpenCwd?: () => void -}) { - const Item = menuKind === 'context' ? ContextMenuItem : DropdownMenuItem - const Separator = menuKind === 'context' ? ContextMenuSeparator : DropdownMenuSeparator - - return ( - <> - {onJumpToOriginalPane ? ( - - - {translate( - 'auto.components.right.sidebar.AiVaultSessionRow.jumpToOriginalPane', - 'Jump to Original Pane' - )} - - ) : null} - {showJumpToWorktree ? ( - - - {translate( - 'auto.components.right.sidebar.AiVaultSessionRow.jumpToWorktree', - 'Jump to Worktree' - )} - - ) : null} - - - {resumeLabel} - - - - {translate( - 'auto.components.right.sidebar.AiVaultSessionRow.copyResumeCommand', - 'Copy Resume Command' - )} - - - - - {translate('auto.components.right.sidebar.AiVaultSessionRow.openLog', 'Open Log')} - - - - {translate('auto.components.right.sidebar.AiVaultSessionRow.revealLog', 'Reveal Log')} - - {onOpenCwd ? ( - - - {translate( - 'auto.components.right.sidebar.AiVaultSessionRow.openWorkingDirectory', - 'Open Working Directory' - )} - - ) : null} - - - {translate( - 'auto.components.right.sidebar.AiVaultSessionRow.copySessionId', - 'Copy Session ID' - )} - - - {translate('auto.components.right.sidebar.AiVaultSessionRow.copyLogPath', 'Copy Log Path')} - - - ) -} - -function getSessionDetailsId(sessionId: string): string { - return `ai-vault-session-details-${sessionId.replace(/[^A-Za-z0-9_-]/g, '-')}` -} - -function SessionMetadata({ - session, - updatedAt, - worktreeInfo -}: { - session: AiVaultSession - updatedAt: string - worktreeInfo: AiVaultSessionWorktreeInfo | null -}) { - return ( -
-
- - - - {agentLabel(session.agent)} - - {translate( - 'auto.components.right.sidebar.AiVaultSessionRow.messageCount', - '{{value0}} msgs', - { value0: session.messageCount } - )} - - · - -
- {worktreeInfo ? : null} -
- ) -} - -function SessionWorktreeLine({ - worktreeInfo -}: { - worktreeInfo: AiVaultSessionWorktreeInfo -}): React.JSX.Element { - const repoId = worktreeInfo.worktreeId - ? (splitWorktreeIdForFilesystem(worktreeInfo.worktreeId)?.repoId ?? null) - : null - const repo = useRepoById(repoId) - - return ( -
- {shouldShowAiVaultWorktreeStatusBadge(worktreeInfo.status) ? ( - - {worktreeStatusLabel(worktreeInfo.status)} - - ) : null} - - - -
- ) -} - -function worktreeStatusLabel(status: AiVaultSessionWorktreeInfo['status']): string { - return aiVaultWorktreeStatusLabel(status) -} - -function conversationRoleLabel(role: AiVaultSession['previewMessages'][number]['role']): string { - if (role === 'user') { - return translate('auto.components.right.sidebar.AiVaultSessionRow.userRole', 'You') - } - if (role === 'assistant') { - return translate('auto.components.right.sidebar.AiVaultSessionRow.agentRole', 'Agent') - } - if (role === 'tool') { - return translate('auto.components.right.sidebar.AiVaultSessionRow.toolRole', 'Tool') - } - if (role === 'system') { - return translate('auto.components.right.sidebar.AiVaultSessionRow.systemRole', 'System') - } - return translate('auto.components.right.sidebar.AiVaultSessionRow.sessionRole', 'Session') -} diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx index e7293f15de7..a8de4d84313 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx @@ -1,6 +1,6 @@ import { useVirtualizer } from '@tanstack/react-virtual' import { useCallback, useMemo, useRef, useState } from 'react' -import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types' import type { AiVaultResumeStartup } from '@/lib/ai-vault-resume-command' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' @@ -19,6 +19,7 @@ import { isAiVaultSessionInCurrentWorktree, type AiVaultSessionWorktreeInfo } from './ai-vault-session-worktree' +import { canUseLocalAiVaultSessionPathActions } from './ai-vault-session-path-actions' import { extractVaultVirtualRowIndexes, getVaultStickyHeaderIndexes, @@ -40,6 +41,7 @@ export function AiVaultSessionVirtualList({ sessionsCount, filteredSessionsCount, error, + vaultScope, buildResumeStartup, getOriginalPaneTarget, getWorktreeInfo, @@ -62,6 +64,7 @@ export function AiVaultSessionVirtualList({ sessionsCount: number filteredSessionsCount: number error: string | null + vaultScope: AiVaultScope buildResumeStartup: (session: AiVaultSession, worktreeId?: string | null) => AiVaultResumeStartup getOriginalPaneTarget: (session: AiVaultSession) => AiVaultOriginalPaneTarget | null getWorktreeInfo: (session: AiVaultSession) => AiVaultSessionWorktreeInfo | null @@ -184,6 +187,7 @@ export function AiVaultSessionVirtualList({ measureElement={virtualizer.measureElement} collapsedGroups={collapsedGroups} expandedSessionIds={expandedSessionIds} + vaultScope={vaultScope} buildResumeStartup={buildResumeStartup} getOriginalPaneTarget={getOriginalPaneTarget} getWorktreeInfo={getWorktreeInfo} @@ -216,6 +220,7 @@ function AiVaultVirtualRow({ measureElement, collapsedGroups, expandedSessionIds, + vaultScope, buildResumeStartup, getOriginalPaneTarget, getWorktreeInfo, @@ -240,6 +245,7 @@ function AiVaultVirtualRow({ measureElement: (node: Element | null) => void collapsedGroups: ReadonlySet expandedSessionIds: ReadonlySet + vaultScope: AiVaultScope buildResumeStartup: (session: AiVaultSession, worktreeId?: string | null) => AiVaultResumeStartup getOriginalPaneTarget: (session: AiVaultSession) => AiVaultOriginalPaneTarget | null getWorktreeInfo: (session: AiVaultSession) => AiVaultSessionWorktreeInfo | null @@ -265,8 +271,7 @@ function AiVaultVirtualRow({ const originalPaneTarget = row.type === 'session' ? getOriginalPaneTarget(row.session) : null const worktreeInfo = row.type === 'session' ? getWorktreeInfo(row.session) : null // Why: omit the jump affordance when the session already lives in the - // worktree on screen — jumping there is a no-op the "Current worktree" badge - // already conveys. + // worktree on screen — jumping there is a no-op. const showJumpToWorktree = !isAiVaultSessionInCurrentWorktree(worktreeInfo) const worktreeJumpId = showJumpToWorktree && canJumpToAiVaultSessionWorktree(worktreeInfo) @@ -275,6 +280,8 @@ function AiVaultVirtualRow({ const resumeState = row.type === 'session' ? getSessionResumeState(row.session) : null const resumeActions = row.type === 'session' ? getSessionResumeActions(row.session) : null const resumeLabel = resumeState ? aiVaultSessionResumeLabel(resumeState) : '' + const canOpenLocalSessionPaths = + row.type === 'session' && canUseLocalAiVaultSessionPathActions(row.session.executionHostId) return (
onCopyResume(row.session, resumeState?.worktreeId)} onCopyId={() => onCopyId(row.session)} onCopyPath={() => onCopyPath(row.session)} - onOpenLog={() => onOpenLog(row.session)} - onRevealLog={() => onRevealLog(row.session)} - onOpenCwd={row.session.cwd ? () => onOpenCwd(row.session) : undefined} + onOpenLog={canOpenLocalSessionPaths ? () => onOpenLog(row.session) : undefined} + onRevealLog={canOpenLocalSessionPaths ? () => onRevealLog(row.session) : undefined} + onOpenCwd={ + canOpenLocalSessionPaths && row.session.cwd ? () => onOpenCwd(row.session) : undefined + } /> )}
diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 5ccc1b9f4ec..0682c1b0790 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -49,6 +49,10 @@ import { PRCommentsList, PRTriageStrip } from './checks-panel-content' +import { + clearPRCommentsListSelection, + type PRCommentsListSelectionClearRequest +} from './pr-comments-list-selection' import { ENTRY_REFRESH_GRACE_MS, shouldEntryRefresh } from './checks-entry-refresh' import type { GitLabDiscussionResolveResult, @@ -435,6 +439,9 @@ export default function ChecksPanel(): React.JSX.Element { const [comments, setComments] = useState([]) const [commentsLoading, setCommentsLoading] = useState(false) const commentsRef = useRef([]) + const [commentsSelectionClearRequest, setCommentsSelectionClearRequest] = + useState(null) + const commentsSelectionClearTokenRef = useRef(0) const [emptyRefreshing, setEmptyRefreshing] = useState(false) const [isRefreshing, setIsRefreshing] = useState(false) const refreshInFlightRef = useRef(false) @@ -2789,6 +2796,15 @@ export default function ChecksPanel(): React.JSX.Element { ] ) + const clearSentCommentSelection = useCallback((reviewContextKey: string): void => { + clearPRCommentsListSelection(reviewContextKey) + commentsSelectionClearTokenRef.current += 1 + setCommentsSelectionClearRequest({ + contextKey: reviewContextKey, + token: commentsSelectionClearTokenRef.current + }) + }, []) + const refreshCommentsAfterBulkResolve = useCallback( async (provider: ChecksPanelReview['provider']): Promise => { if (provider === 'gitlab') { @@ -2802,9 +2818,14 @@ export default function ChecksPanel(): React.JSX.Element { const resolveSelectedThreadsAfterLaunch = useCallback( async (resolution: NonNullable) => { + clearSentCommentSelection(resolution.reviewContextKey) let resolved = 0 - let skipped = 0 + let skipped = Math.max( + 0, + resolution.selectedGroups.length - resolution.selectedThreadIds.length + ) let failed = 0 + let attemptedThreadCount = 0 if (resolution.selectedThreadIds.length === 0) { toast.success( translate( @@ -2816,9 +2837,10 @@ export default function ChecksPanel(): React.JSX.Element { } for (const threadId of resolution.selectedThreadIds) { if (asyncResultKeyRef.current !== resolution.reviewContextKey) { - skipped += resolution.selectedThreadIds.length - resolved - skipped - failed + skipped += resolution.selectedThreadIds.length - attemptedThreadCount break } + attemptedThreadCount += 1 const currentGroup = groupPRComments(commentsRef.current).find( (group) => group.kind === 'thread' && group.threadId === threadId ) @@ -2856,7 +2878,7 @@ export default function ChecksPanel(): React.JSX.Element { ) ) }, - [handleResolve, refreshCommentsAfterBulkResolve] + [clearSentCommentSelection, handleResolve, refreshCommentsAfterBulkResolve] ) const handleFixChecksWithAI = useCallback(async (): Promise => { @@ -3780,6 +3802,7 @@ export default function ChecksPanel(): React.JSX.Element { commentsDisabled={!canTargetPRComments} commentsDisabledReason={commentsDisabledReason} selectionContextKey={stateRequestKey} + selectionClearRequest={commentsSelectionClearRequest} resolveCommentsWithAIDisabled={Boolean(resolveCommentsWithAIDisabledReason)} resolveCommentsWithAIDisabledReason={resolveCommentsWithAIDisabledReason} onAddComment={pr ? handleAddPRComment : undefined} diff --git a/src/renderer/src/components/right-sidebar/CreatePullRequestDialog.tsx b/src/renderer/src/components/right-sidebar/CreatePullRequestDialog.tsx deleted file mode 100644 index 8db272584a5..00000000000 --- a/src/renderer/src/components/right-sidebar/CreatePullRequestDialog.tsx +++ /dev/null @@ -1,339 +0,0 @@ -import React, { useCallback, useRef, useState } from 'react' -import { Loader2 } from 'lucide-react' -import { toast } from 'sonner' -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { useAppStore } from '@/store' -import type { - HostedReviewCreationEligibility, - HostedReviewProvider -} from '../../../../shared/hosted-review' -import { resolveHostedReviewCreationProvider } from '../../../../shared/hosted-review-creation-providers' -import { normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs' -import { stripBaseRef, useCreatePullRequestDialogFields } from './useCreatePullRequestDialogFields' -import { - DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS, - resolveSourceControlAiEnabled, - resolveSourceControlAiForOperation, - resolveSourceControlAiPrCreationDefaults -} from '../../../../shared/source-control-ai' -import { getCommitMessageModelDiscoveryHostKeyForScope } from '../../../../shared/commit-message-host-key' -import { getRuntimeGitScope } from '@/runtime/runtime-git-client' -import { CreatePullRequestGenerateButton } from './CreatePullRequestGenerateButton' -import { CreatePullRequestDialogForm } from './CreatePullRequestDialogForm' -import { formatCreateError, reviewCopy } from './create-pull-request-review-copy' -import { translate } from '@/i18n/i18n' - -type CreatePullRequestDialogProps = { - open: boolean - repoId: string - repoPath: string - worktreeId: string | null - worktreePath: string - branch: string - eligibility: HostedReviewCreationEligibility | null - pushBeforeCreate: boolean - onOpenChange: (open: boolean) => void - onPushBeforeCreate: () => Promise - onBranchChangedByGeneration: () => Promise - onCreated: (result: { - provider: HostedReviewProvider - number: number - url: string - }) => Promise -} - -export function CreatePullRequestDialog({ - open, - repoId, - repoPath, - worktreeId, - worktreePath, - branch, - eligibility, - pushBeforeCreate, - onOpenChange, - onPushBeforeCreate, - onBranchChangedByGeneration, - onCreated -}: CreatePullRequestDialogProps): React.JSX.Element { - const settings = useAppStore((s) => s.settings) - const repo = useAppStore((s) => s.repos.find((candidate) => candidate.id === repoId) ?? null) - const createHostedReview = useAppStore((s) => s.createHostedReview) - const submitInFlightRef = useRef(false) - const [submitting, setSubmitting] = useState(false) - const [error, setError] = useState(null) - const provider = resolveHostedReviewCreationProvider(eligibility?.provider) - const copy = reviewCopy(provider) - const sourceControlAiActionsVisible = React.useMemo( - () => (settings ? resolveSourceControlAiEnabled({ settings, repo }) : false), - [repo, settings] - ) - const prCreationDefaults = React.useMemo(() => { - if (!settings) { - return DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS - } - const hostKey = getCommitMessageModelDiscoveryHostKeyForScope( - getRuntimeGitScope(settings, repo?.connectionId) - ) - const resolved = resolveSourceControlAiForOperation({ - settings, - repo, - operation: 'pullRequest', - discoveryHostKey: hostKey, - prCreationProductDefaults: DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS - }) - return resolved.ok - ? resolved.value.prCreationDefaults - : resolveSourceControlAiPrCreationDefaults({ - settings, - repo, - prCreationProductDefaults: DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS - }) - }, [repo, settings]) - const { - aiGenerationEnabled, - base, - setBase, - title, - setTitle, - body, - setBody, - draft, - setDraft, - baseQuery, - setBaseQuery, - baseResults, - setBaseResults, - baseSearchError, - generating, - generateError, - generateDisabled, - generateDisabledReason, - handleGenerate, - handleCancelGenerate - } = useCreatePullRequestDialogFields({ - open, - repoId, - worktreeId, - worktreePath, - branch, - eligibility, - repo, - settings, - submitting, - prCreationDefaults, - sourceControlAiActionsVisible, - onBranchChangedByGeneration - }) - - const resetSubmissionState = useCallback((): void => { - submitInFlightRef.current = false - setSubmitting(false) - setError(null) - }, []) - - const submitDisabled = - submitting || - generating || - title.trim().length === 0 || - base.trim().length === 0 || - stripBaseRef(base).toLowerCase() === stripBaseRef(branch).toLowerCase() - - const handleSubmit = useCallback(async (): Promise => { - if (submitDisabled || submitInFlightRef.current) { - return - } - submitInFlightRef.current = true - setSubmitting(true) - setError(null) - let pushed = false - try { - if (pushBeforeCreate) { - const ok = await onPushBeforeCreate() - if (!ok) { - setError('Push failed. Resolve the push error, then try again.') - return - } - pushed = true - } - const result = await createHostedReview(repoPath, { - provider, - base: stripBaseRef(base.trim()), - head: normalizeHostedReviewHeadRef(branch), - title: title.trim(), - body, - draft, - worktreePath, - useTemplate: prCreationDefaults.useTemplate - }) - if (result.ok) { - await onCreated({ provider, number: result.number, url: result.url }) - if (prCreationDefaults.openAfterCreate) { - window.api.shell.openUrl(result.url) - } - resetSubmissionState() - onOpenChange(false) - return - } - if (result.existingReview?.url) { - const number = result.existingReview.number - toast.success( - number - ? translate( - 'auto.components.right.sidebar.CreatePullRequestDialog.edc35a7027', - '{{value0}} #{{value1}} is already open', - { value0: copy.titleLabel, value1: number } - ) - : translate( - 'auto.components.right.sidebar.CreatePullRequestDialog.21c7a1daa0', - '{{value0}} is already open', - { value0: copy.titleLabel } - ), - { - action: { - label: translate( - 'auto.components.right.sidebar.CreatePullRequestDialog.7a21f0dae8', - 'Open on {{value0}}', - { value0: copy.providerName } - ), - onClick: () => window.api.shell.openUrl(result.existingReview!.url) - } - } - ) - if (number) { - await onCreated({ provider, number, url: result.existingReview.url }) - resetSubmissionState() - onOpenChange(false) - return - } - } - setError(formatCreateError(result, pushed, copy.shortLabel)) - } finally { - submitInFlightRef.current = false - setSubmitting(false) - } - }, [ - base, - body, - branch, - createHostedReview, - draft, - onCreated, - onOpenChange, - onPushBeforeCreate, - provider, - pushBeforeCreate, - copy.providerName, - copy.shortLabel, - copy.titleLabel, - prCreationDefaults.openAfterCreate, - prCreationDefaults.useTemplate, - repoPath, - resetSubmissionState, - submitDisabled, - title, - worktreePath - ]) - - const handleOpenChange = useCallback( - (nextOpen: boolean): void => { - // Why: closing during submit would reset the in-flight guard while the - // main-process create call can still complete, allowing duplicate clicks. - if (submitting && !nextOpen) { - return - } - if (!nextOpen) { - resetSubmissionState() - } - onOpenChange(nextOpen) - }, - [onOpenChange, resetSubmissionState, submitting] - ) - - return ( - - - -
- - {translate( - 'auto.components.right.sidebar.CreatePullRequestDialog.db9cee18f7', - 'Create {{value0}}', - { value0: copy.titleLabel } - )} - - {aiGenerationEnabled ? ( - void handleGenerate()} - onCancelGenerate={handleCancelGenerate} - /> - ) : null} -
- - {translate( - 'auto.components.right.sidebar.CreatePullRequestDialog.f658ff2455', - 'Confirm the target branch and {{value0}} details before creating the hosted review.', - { value0: copy.shortLabel } - )} - -
- - - - - - - -
-
- ) -} diff --git a/src/renderer/src/components/right-sidebar/CreatePullRequestDialogForm.tsx b/src/renderer/src/components/right-sidebar/CreatePullRequestDialogForm.tsx deleted file mode 100644 index 75f128d1f1b..00000000000 --- a/src/renderer/src/components/right-sidebar/CreatePullRequestDialogForm.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import { Check, ChevronsUpDown } from 'lucide-react' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { cn } from '@/lib/utils' -import { stripBaseRef } from './useCreatePullRequestDialogFields' -import type { CreatePullRequestReviewCopy } from './create-pull-request-review-copy' -import { translate } from '@/i18n/i18n' - -type CreatePullRequestDialogFormProps = { - branch: string - base: string - setBase: (value: string) => void - baseQuery: string - setBaseQuery: (value: string) => void - baseResults: string[] - setBaseResults: (value: string[]) => void - baseSearchError: string | null - title: string - setTitle: (value: string) => void - body: string - setBody: (value: string) => void - draft: boolean - setDraft: (value: boolean) => void - copy: CreatePullRequestReviewCopy - generateError: string | null - error: string | null -} - -export function CreatePullRequestDialogForm({ - branch, - base, - setBase, - baseQuery, - setBaseQuery, - baseResults, - setBaseResults, - baseSearchError, - title, - setTitle, - body, - setBody, - draft, - setDraft, - copy, - generateError, - error -}: CreatePullRequestDialogFormProps): React.JSX.Element { - return ( -
-
- -
- {branch} -
-
- -
-
- -

- {translate( - 'auto.components.right.sidebar.CreatePullRequestDialog.0fad57a14c', - 'Search remote branches or enter a branch name.' - )} -

-
-
- { - setBaseQuery(event.target.value) - setBase(event.target.value) - }} - placeholder={translate( - 'auto.components.right.sidebar.CreatePullRequestDialog.694550a610', - 'main' - )} - aria-invalid={!base.trim()} - className="pr-8" - /> - -
- {baseSearchError ?

{baseSearchError}

: null} - {baseResults.length > 0 ? ( -
- {baseResults.map((ref) => ( - - ))} -
- ) : null} -
- -
- - setTitle(event.target.value)} - placeholder={translate( - 'auto.components.right.sidebar.CreatePullRequestDialog.68314b4369', - 'Title' - )} - aria-invalid={!title.trim()} - /> -
- -
- -