diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 770886f8c66..f8b369865d6 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -88,7 +88,7 @@ The workflow resolves the next version from GitHub Releases, bumps `package.json **How the next version is chosen:** -All stable kinds (`patch`, `minor`, `major`) are computed off the latest *stable* release, ignoring any RCs in between. +All stable kinds (`patch`, `minor`, `major`) are computed off the latest _stable_ release, ignoring any RCs in between. - `kind=rc` + last tag was stable (e.g. `v1.3.14`) → `v1.3.15-rc.0`. - `kind=rc` + active RC series (e.g. `v1.3.15-rc.2`) → `v1.3.15-rc.3`. @@ -99,8 +99,9 @@ All stable kinds (`patch`, `minor`, `major`) are computed off the latest *stable **Safety guarantees:** - Stable releases are refused if the new version isn't strictly greater than the latest published stable. This is the only rule `electron-updater` actually needs — it compares semver within the `latest` channel, so a regressing stable is the one thing that breaks auto-update for fresh installs. -- Complete RC draft releases created by the release workflow are published before cutting a new tag, so a GitHub queue failure in the final publish job can be resumed safely. -- If the latest RC tag exists but is still draft-only or missing its GitHub Release, the workflow resumes that tag before cutting the next RC. This keeps retries from leaving multiple unpublished draft releases behind. +- Complete RC draft releases created by the release workflow are published before cutting a new tag only when the draft tag was built from the current release ref. Stale drafts are skipped so fixes cut a fresh RC instead of exposing old artifacts. +- If the latest RC tag exists but is still draft-only or missing its GitHub Release, the workflow resumes that tag only when it was built from the current release ref. Otherwise the next RC number is cut. +- RC numbering also considers release commits on `main`, so deleting a stale tag does not let a later cut reuse the same RC number. - Off-main releases (when `ref` is not the tip of `main`) only push the tag. `main` is never mutated from a non-main ref, so you can safely release an older commit without polluting history. - When `ref` is the tip of `main`, the version-bump commit is fast-forwarded onto `main` so local `package.json` stays in sync with what's shipped. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index a1e685dbc5a..2f5e334ab85 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,6 +1,6 @@ name: Bug report description: Report a problem with Orca -title: "[Bug]: " +title: '[Bug]: ' labels: - bug body: diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 5becf91d000..d4e0a800402 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,6 +1,6 @@ name: Feature request description: Suggest an improvement for Orca -title: "[Feature]: " +title: '[Feature]: ' labels: - enhancement body: diff --git a/.github/ISSUE_TEMPLATE/other.yml b/.github/ISSUE_TEMPLATE/other.yml index b59ddd5f83c..3da84207303 100644 --- a/.github/ISSUE_TEMPLATE/other.yml +++ b/.github/ISSUE_TEMPLATE/other.yml @@ -1,6 +1,6 @@ name: Other description: Report another Orca issue type -title: "[Other]: " +title: '[Other]: ' body: - type: textarea id: details diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 1887a494b63..483e7b27f83 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -94,5 +94,11 @@ jobs: - name: Test run: pnpm test - - name: Build - run: pnpm build + - name: Build unpacked app + run: pnpm build:unpack + + # Why: the packaged CLI runs outside Electron's asar integration, so + # bare runtime imports must be present in the package itself. Run from a + # temp copy so Node cannot mask missing package deps with repo node_modules. + - name: Smoke packaged CLI + run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/linux-unpacked diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index ad9cd01a597..35672c01570 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -252,11 +252,24 @@ jobs: } highest_rc_for_base() { - local base="$1" - git tag --list "v${base}-rc.*" \ - | sed -E "s/^v${base//./\\.}-rc\.//" \ - | sort -n \ - | tail -n 1 || true + node config/scripts/release-rc-history.mjs "$1" + } + + tag_matches_current_ref() { + local tag="$1" + local tag_commit + local head_commit + if ! tag_commit="$(git rev-parse "${tag}^{}" 2>/dev/null)"; then + return 1 + fi + head_commit="$(git rev-parse HEAD)" + if [[ "$tag_commit" == "$head_commit" ]]; then + return 0 + fi + + local tag_parent + tag_parent="$(git rev-parse "${tag_commit}^" 2>/dev/null)" || return 1 + [[ "$tag_parent" == "$head_commit" ]] } release_draft_state() { @@ -280,6 +293,10 @@ jobs: release_state="$(release_draft_state "$tag")" case "$release_state" in missing|true) + if ! tag_matches_current_ref "$tag"; then + echo "::warning::Tag $tag already exists but was cut from a different release ref ($reason) - cutting the next version instead of reusing stale artifacts." + return 1 + fi echo "::warning::Tag $tag already exists but has no published release ($reason) - recovering by re-dispatching the release build against the existing tag." echo "recovered_tag=$tag" >>"$GITHUB_OUTPUT" echo "recovered=true" >>"$GITHUB_OUTPUT" @@ -317,10 +334,11 @@ jobs: else existing_rc_tag="v${base}-rc.${highest_rc}" # Why: a failed or GitHub-stuck run can leave the highest RC - # tag attached to a draft/missing release. Resume that tag so - # retries finish the intended release instead of piling up - # vX.Y.Z-rc.N+1 drafts. - recover_unpublished_tag "$existing_rc_tag" "latest RC in series" || true + # tag attached to a draft/missing release. Resume only when it + # was cut from this ref; stale attempts advance to rc.N+1. + if git rev-parse "$existing_rc_tag" >/dev/null 2>&1; then + recover_unpublished_tag "$existing_rc_tag" "latest RC in series" || true + fi new="${base}-rc.$((highest_rc + 1))" fi ;; @@ -363,7 +381,7 @@ jobs: # over a shipped version) and needs human attention. if git rev-parse "v$new" >/dev/null 2>&1; then recover_unpublished_tag "v$new" "tag collision" || { - echo "::error::Tag v$new already exists and has a published release. Refusing to re-cut over a shipped version." >&2 + echo "::error::Tag v$new already exists and cannot be recovered for this ref. Refusing to re-cut over an existing version." >&2 exit 1 } fi diff --git a/.oxlintrc.json b/.oxlintrc.json index 355b00c82f1..f414c9066eb 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,5 +1,5 @@ { - "$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/oxc-project.github.io/refs/heads/json-schemas/oxlint/v0.17.0/config.json", + "$schema": "./node_modules/oxlint/configuration_schema.json", "plugins": ["typescript", "react", "react-hooks", "react-perf", "unicorn"], "categories": { "correctness": "error" @@ -39,7 +39,10 @@ "typescript/prefer-function-type": "error", "typescript/prefer-includes": "error", "typescript/prefer-optional-chain": "error", - "typescript/switch-exhaustiveness-check": "error", + "typescript/switch-exhaustiveness-check": [ + "error", + { "allowDefaultCaseForExhaustiveSwitch": false } + ], "curly": "error", "no-unneeded-ternary": "error", "no-useless-return": "error", diff --git a/AGENTS.md b/AGENTS.md index fba60208e6b..570e92f9cfe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ Keep comments short — one or two lines. Capture only the non-obvious reason (s ## File and Module Naming -Never use vague names like `helpers`, `utils`, `common`, `misc`, or `shared-stuff` for files, folders, or modules. They carry zero information and tend to become dumping grounds. Name files after what they *actually* contain — prefer the concrete domain concept (e.g. `tab-group-state.ts`, `terminal-orphan-cleanup.ts`) over the generic role (`tabs-helpers.ts`, `terminal-utils.ts`). If you find yourself reaching for `helpers`, the file probably has more than one responsibility and should be split, or there's a better name hiding in the code that describes what the functions operate on. +Never use vague names like `helpers`, `utils`, `common`, `misc`, or `shared-stuff` for files, folders, or modules. They carry zero information and tend to become dumping grounds. Name files after what they _actually_ contain — prefer the concrete domain concept (e.g. `tab-group-state.ts`, `terminal-orphan-cleanup.ts`) over the generic role (`tabs-helpers.ts`, `terminal-utils.ts`). If you find yourself reaching for `helpers`, the file probably has more than one responsibility and should be split, or there's a better name hiding in the code that describes what the functions operate on. ## Worktree Safety diff --git a/README.md b/README.md index 3bedf42646d..f7ab283d8cc 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@

The AI Orchestrator for 100x builders.
- Run Claude Code, Codex, Grok, Antigravity, or OpenCode side-by-side across repos — each in its own worktree, tracked in one place.
+ Run Claude Code, OpenClaude, Codex, Grok, Antigravity, or OpenCode side-by-side across repos — each in its own worktree, tracked in one place.
Available for macOS, Windows, and Linux.

@@ -26,10 +26,11 @@ ## Supported Agents -Orca supports any CLI agent (*not just this list*). +Orca supports any CLI agent (_not just this list_).

Claude Code   + OpenClaude   Codex   Grok   Gemini   @@ -62,7 +63,7 @@ Orca supports any CLI agent (*not just this list*). ## Features -- **No login required** — Bring your own Claude Code, Codex, Grok, or Antigravity subscription. +- **No login required** — Bring your own Claude Code, OpenClaude, Codex, Grok, or Antigravity subscription. - **Worktree-native** — Every feature gets its own worktree. No stashing, no branch juggling. Spin up and switch instantly. - **Multi-agent terminals** — Run multiple AI agents side-by-side in tabs and panes. See which ones are active at a glance. - **Built-in source control** — Review AI-generated diffs, make quick edits, and commit without leaving Orca. @@ -79,7 +80,7 @@ Orca supports any CLI agent (*not just this list*). - **[Download from onOrca.dev](https://onOrca.dev)** - Or via **[GitHub Releases page](https://github.com/stablyai/orca/releases/latest)** -*Alternatively, install from a package manager:* +_Alternatively, install from a package manager:_ ### macOS (Homebrew) @@ -108,7 +109,7 @@ Control your agents from your phone.

- **iOS:** [Download from App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [Download from GH release](https://github.com/stablyai/orca/releases/tag/mobile-v0.0.10) +- **Android:** [Download APK from GitHub Releases](https://github.com/stablyai/orca/releases/download/mobile-v0.0.10/app-release.apk) --- diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index b4017a7f9f9..9c01fc35580 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -1,6 +1,12 @@ const { chmodSync, existsSync, readdirSync } = require('node:fs') const { execFileSync } = require('node:child_process') const { join, resolve } = require('node:path') +const electronBuilderNativeRebuild = require('./scripts/electron-builder-native-rebuild.cjs') +const { + createPackagedRuntimeNodeModuleResources, + prunePackagedRuntimeNodeModules, + verifyPackagedMainRuntimeDeps +} = require('./packaged-runtime-node-modules.cjs') const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' const featureWallResources = { @@ -14,6 +20,25 @@ const relayExtraResource = { from: 'out/relay', to: 'relay' } +// Why: the main bundle, packaged CLI, SSH paths, and speech worker all execute +// from package directories where pnpm's symlink farm is absent. Copy the exact +// runtime dependency closure to Resources/node_modules so bare require() calls +// do not fall through to a developer checkout's node_modules. +const packagedRuntimeNodeModuleResources = createPackagedRuntimeNodeModuleResources() + +const commonExtraResources = [relayExtraResource, ...packagedRuntimeNodeModuleResources] +const macSpeechNativeResource = { + from: 'node_modules/sherpa-onnx-darwin-${arch}', + to: 'node_modules/sherpa-onnx-darwin-${arch}' +} +const linuxSpeechNativeResource = { + from: 'node_modules/sherpa-onnx-linux-${arch}', + to: 'node_modules/sherpa-onnx-linux-${arch}' +} +const winSpeechNativeResource = { + from: 'node_modules/sherpa-onnx-win-x64', + to: 'node_modules/sherpa-onnx-win-x64' +} /** @type {import('electron-builder').Configuration} */ module.exports = { @@ -24,12 +49,22 @@ module.exports = { }, files: [ '!**/.vscode/*', - '!src/*', + // Why: these repo-only inputs are either bundled into out/ or copied via + // extraResources. Shipping them in app.asar bloats the desktop bundle. + '!src{,/**/*}', + '!config{,/**/*}', + '!docs{,/**/*}', + '!mobile{,/**/*}', + '!native{,/**/*}', + '!skills{,/**/*}', + '!tests{,/**/*}', + '!Casks{,/**/*}', + '!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}', + '!out/**/*.test.js', '!electron.vite.config.{js,ts,mjs,cjs}', '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,CHANGELOG.md,README.md}', '!{.env,.env.*,.npmrc,pnpm-lock.yaml}', '!tsconfig.json', - '!config/*', // Why: feature-wall media is copied via extraResources so runtime can read // it from process.resourcesPath; exclude the source copy from app.asar. '!resources/onboarding/feature-wall/**' @@ -88,6 +123,9 @@ module.exports = { if (!existsSync(resourcesDir)) { return } + prunePackagedRuntimeNodeModules(resourcesDir, context.electronPlatformName) + verifyPackagedMainRuntimeDeps(resourcesDir) + chmodUnixCliLaunchers(resourcesDir, context.electronPlatformName) for (const filename of readdirSync(resourcesDir)) { if (!filename.startsWith('agent-browser-')) { continue @@ -104,7 +142,8 @@ module.exports = { win: { executableName: 'Orca', extraResources: [ - relayExtraResource, + ...commonExtraResources, + winSpeechNativeResource, { from: 'resources/win32/bin/orca.cmd', to: 'bin/orca.cmd' @@ -158,7 +197,8 @@ module.exports = { hardenedRuntime: isMacRelease, notarize: isMacRelease, extraResources: [ - relayExtraResource, + ...commonExtraResources, + macSpeechNativeResource, { from: 'resources/darwin/bin/orca', to: 'bin/orca' @@ -198,7 +238,8 @@ module.exports = { // sizes; a single 1024px PNG is ignored by some Linux docks/launchers. icon: 'resources/build/icon.icns', extraResources: [ - relayExtraResource, + ...commonExtraResources, + linuxSpeechNativeResource, { from: 'resources/linux/bin/orca-ide', to: 'bin/orca-ide' @@ -230,11 +271,13 @@ module.exports = { artifactName: 'orca-ide-${version}.${arch}.${ext}', depends: ['python3', 'python3-gobject', 'at-spi2-core', 'xdotool', 'xclip'] }, + beforeBuild: electronBuilderNativeRebuild, // Why: must be true so that electron-builder rebuilds native modules // (node-pty) for each target architecture when producing dual-arch macOS // builds (x64 + arm64). With npmRebuild disabled, CI on an arm64 runner // packages arm64 binaries into the x64 DMG, causing "posix_spawnp failed" - // on Intel Macs. + // on Intel Macs. The beforeBuild hook performs Orca's targeted rebuild and + // returns false so electron-builder does not rebuild optional cpu-features. npmRebuild: true, publish: { provider: 'github', @@ -244,6 +287,21 @@ module.exports = { } } +function chmodUnixCliLaunchers(resourcesDir, electronPlatformName) { + if (electronPlatformName === 'win32') { + return + } + for (const launcherName of ['orca', 'orca-ide']) { + const launcherPath = join(resourcesDir, 'bin', launcherName) + if (!existsSync(launcherPath)) { + continue + } + // Why: packaged Unix installs expose these extraResources as public shell + // commands, and source/packager mode drift must not ship a non-executable CLI. + chmodSync(launcherPath, 0o755) + } +} + async function signMacComputerUseHelper(helperAppPath, packager) { if (!existsSync(helperAppPath)) { if (isMacRelease) { diff --git a/config/oxlint-switch-exhaustiveness.json b/config/oxlint-switch-exhaustiveness.json new file mode 100644 index 00000000000..c967a7b9b5d --- /dev/null +++ b/config/oxlint-switch-exhaustiveness.json @@ -0,0 +1,19 @@ +{ + "$schema": "../node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript"], + "categories": { + "correctness": "off", + "suspicious": "off", + "pedantic": "off", + "perf": "off", + "style": "off", + "restriction": "off", + "nursery": "off" + }, + "rules": { + "typescript/switch-exhaustiveness-check": [ + "error", + { "allowDefaultCaseForExhaustiveSwitch": false } + ] + } +} diff --git a/config/packaged-runtime-node-modules.cjs b/config/packaged-runtime-node-modules.cjs new file mode 100644 index 00000000000..f5510075133 --- /dev/null +++ b/config/packaged-runtime-node-modules.cjs @@ -0,0 +1,256 @@ +const { existsSync, readFileSync, readdirSync, realpathSync, rmSync } = require('node:fs') +const { dirname, join, resolve } = require('node:path') +const { builtinModules, createRequire } = require('node:module') + +const projectDir = resolve(__dirname, '..') +const requireFromProject = createRequire(join(projectDir, 'package.json')) + +const PACKAGED_RUNTIME_PACKAGE_ROOTS = [ + '@electron-toolkit/utils', + '@linear/sdk', + 'electron-updater', + 'node-pty', + 'posthog-node', + 'qrcode', + 'ssh2', + 'tweetnacl', + 'ws', + 'yaml', + 'zod' +] + +const NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM = { + darwin: 'darwin-', + linux: 'linux-', + win32: 'win32-' +} +const TYPE_DECLARATION_ARTIFACT_RE = /\.d\.(?:c|m)?ts(?:\.map)?$/ +const VERSIONED_ONNXRUNTIME_DYLIB_RE = /^libonnxruntime\.\d[\d.]*\.dylib$/ + +const NODE_BUILTINS = new Set([ + ...builtinModules, + ...builtinModules.map((moduleName) => `node:${moduleName}`) +]) + +function packageNameFromSpecifier(specifier) { + if (specifier.startsWith('@')) { + const [scope, name] = specifier.split('/') + return scope && name ? `${scope}/${name}` : specifier + } + return specifier.split('/')[0] +} + +function isPackagedExternalSpecifier(specifier) { + return ( + !specifier.startsWith('.') && + !specifier.startsWith('/') && + specifier !== 'electron' && + !NODE_BUILTINS.has(specifier) + ) +} + +function resolvePackageJsonPath(packageName, fromDir = projectDir) { + try { + return requireFromProject.resolve(`${packageName}/package.json`, { paths: [fromDir] }) + } catch { + const entryPath = requireFromProject.resolve(packageName, { paths: [fromDir] }) + let dir = dirname(entryPath) + while (dir !== dirname(dir)) { + const packageJsonPath = join(dir, 'package.json') + if (existsSync(packageJsonPath)) { + return packageJsonPath + } + dir = dirname(dir) + } + throw new Error(`Could not find package.json for ${packageName}`) + } +} + +function readPackage(packageName, fromDir = projectDir) { + const packageJsonPath = resolvePackageJsonPath(packageName, fromDir) + const packageDir = realpathSync(dirname(packageJsonPath)) + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) + return { + name: packageJson.name ?? packageName, + packageDir, + dependencies: Object.keys(packageJson.dependencies ?? {}) + } +} + +function collectPackagedRuntimePackages() { + const packages = new Map() + const visit = (packageName, fromDir = projectDir) => { + if (packageName === 'electron' || packages.has(packageName)) { + return + } + + const packageInfo = readPackage(packageName, fromDir) + if (packages.has(packageInfo.name)) { + return + } + packages.set(packageInfo.name, packageInfo.packageDir) + + for (const dependencyName of packageInfo.dependencies) { + visit(dependencyName, packageInfo.packageDir) + } + } + + for (const packageName of PACKAGED_RUNTIME_PACKAGE_ROOTS) { + visit(packageName) + } + + return [...packages.entries()].sort(([left], [right]) => left.localeCompare(right)) +} + +function createPackagedRuntimeNodeModuleResources() { + return collectPackagedRuntimePackages().map(([packageName, packageDir]) => ({ + from: packageDir, + to: join('node_modules', ...packageName.split('/')) + })) +} + +function normalizeAsarEntryPath(entry) { + return entry.replace(/\\/g, '/').replace(/^\/+/, '') +} + +function findAsarEntry(entries, expectedPath) { + return entries.find((entry) => normalizeAsarEntryPath(entry) === expectedPath) +} + +function verifyPackagedMainRuntimeDeps(resourcesDir, asar = require('@electron/asar')) { + const asarPath = join(resourcesDir, 'app.asar') + if (!existsSync(asarPath)) { + return + } + + const mainFiles = ['out/main/index.js', 'out/main/agent-hooks/managed-agent-hook-controls.js'] + const entries = asar.listPackage(asarPath) + const missing = new Set() + + for (const file of mainFiles) { + const entry = findAsarEntry(entries, file) + if (!entry) { + throw new Error(`Packaged main file ${file} was not found in ${asarPath}`) + } + + // Why: @electron/asar lists entries with host separators; Windows returns + // backslashes, and extractFile expects that same host-style path. + const internalPath = entry.replace(/^[\\/]+/, '') + const source = asar.extractFile(asarPath, internalPath).toString('utf8') + for (const match of source.matchAll(/require\(["']([^"']+)["']\)/g)) { + const specifier = match[1] + if (!isPackagedExternalSpecifier(specifier)) { + continue + } + const packageName = packageNameFromSpecifier(specifier) + if (!existsSync(join(resourcesDir, 'node_modules', ...packageName.split('/')))) { + missing.add(packageName) + } + } + } + + if (missing.size > 0) { + throw new Error( + `Packaged main bundle has bare runtime imports without copied node_modules: ${[ + ...missing + ].join(', ')}` + ) + } +} + +function prunePackagedNodePty(resourcesDir, electronPlatformName) { + const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty') + if (!existsSync(nodePtyDir)) { + return + } + + const allowedPrebuildPrefix = NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM[electronPlatformName] + if (allowedPrebuildPrefix) { + const prebuildsDir = join(nodePtyDir, 'prebuilds') + if (existsSync(prebuildsDir)) { + for (const entry of readdirSync(prebuildsDir, { withFileTypes: true })) { + if (entry.isDirectory() && !entry.name.startsWith(allowedPrebuildPrefix)) { + rmSync(join(prebuildsDir, entry.name), { recursive: true, force: true }) + } + } + } + } + + if (electronPlatformName !== 'win32') { + // Why: conpty is Windows-only and node-pty resolves runtime binaries from + // build/Release or prebuilds/-, not third_party/conpty. + rmSync(join(nodePtyDir, 'third_party', 'conpty'), { recursive: true, force: true }) + rmSync(join(nodePtyDir, 'deps', 'winpty'), { recursive: true, force: true }) + } +} + +function prunePackagedRuntimeTypeDeclarations(resourcesDir) { + const nodeModulesDir = join(resourcesDir, 'node_modules') + if (!existsSync(nodeModulesDir)) { + return + } + pruneMatchingFiles(nodeModulesDir, (filename) => TYPE_DECLARATION_ARTIFACT_RE.test(filename)) +} + +function prunePackagedSherpaOnnx(resourcesDir, electronPlatformName) { + if (electronPlatformName !== 'darwin') { + return + } + const nodeModulesDir = join(resourcesDir, 'node_modules') + if (!existsSync(nodeModulesDir)) { + return + } + for (const entry of readdirSync(nodeModulesDir, { withFileTypes: true })) { + if (!entry.isDirectory() || !entry.name.startsWith('sherpa-onnx-darwin-')) { + continue + } + const packageDir = join(nodeModulesDir, entry.name) + const packageEntries = readdirSync(packageDir) + const hasVersionedOnnxRuntime = packageEntries.some((filename) => + VERSIONED_ONNXRUNTIME_DYLIB_RE.test(filename) + ) + if (hasVersionedOnnxRuntime) { + // Why: darwin sherpa-onnx binaries link to the versioned ONNX Runtime + // install name; the unversioned dylib is a duplicate fallback copy. + rmSync(join(packageDir, 'libonnxruntime.dylib'), { force: true }) + } + } +} + +function prunePackagedZodSources(resourcesDir) { + // Why: Zod's src tree is TypeScript source only selected by the @zod/source + // condition; packaged runtime import/require paths resolve to built JS. + rmSync(join(resourcesDir, 'node_modules', 'zod', 'src'), { recursive: true, force: true }) +} + +function prunePackagedRuntimeNodeModules(resourcesDir, electronPlatformName) { + prunePackagedNodePty(resourcesDir, electronPlatformName) + prunePackagedRuntimeTypeDeclarations(resourcesDir) + prunePackagedSherpaOnnx(resourcesDir, electronPlatformName) + prunePackagedZodSources(resourcesDir) +} + +function pruneMatchingFiles(directory, shouldPrune) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const entryPath = join(directory, entry.name) + if (entry.isDirectory()) { + pruneMatchingFiles(entryPath, shouldPrune) + } else if (entry.isFile() && shouldPrune(entry.name)) { + rmSync(entryPath, { force: true }) + } + } +} + +module.exports = { + PACKAGED_RUNTIME_PACKAGE_ROOTS, + createPackagedRuntimeNodeModuleResources, + findAsarEntry, + isPackagedExternalSpecifier, + packageNameFromSpecifier, + prunePackagedNodePty, + prunePackagedRuntimeNodeModules, + prunePackagedSherpaOnnx, + prunePackagedRuntimeTypeDeclarations, + prunePackagedZodSources, + verifyPackagedMainRuntimeDeps +} diff --git a/config/scripts/build-native-for-platform.mjs b/config/scripts/build-native-for-platform.mjs new file mode 100755 index 00000000000..a16cb34322a --- /dev/null +++ b/config/scripts/build-native-for-platform.mjs @@ -0,0 +1,26 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process' + +if (process.platform !== 'darwin') { + console.log(`[native-build] no macOS native computer build required on ${process.platform}`) + process.exit(0) +} + +runPnpmScript('build:computer-macos') + +function runPnpmScript(scriptName) { + const npmExecPath = process.env.npm_execpath + const command = npmExecPath + ? process.execPath + : process.platform === 'win32' + ? 'pnpm.cmd' + : 'pnpm' + const args = npmExecPath ? [npmExecPath, 'run', scriptName] : ['run', scriptName] + const result = spawnSync(command, args, { stdio: 'inherit' }) + + if (result.signal) { + process.kill(process.pid, result.signal) + } + process.exit(result.status ?? (result.error ? 1 : 0)) +} diff --git a/config/scripts/build-relay.mjs b/config/scripts/build-relay.mjs index 430d70801bb..968901f50d6 100644 --- a/config/scripts/build-relay.mjs +++ b/config/scripts/build-relay.mjs @@ -36,7 +36,7 @@ for (const platform of PLATFORMS) { outfile: join(outDir, 'relay.js'), // Native addons cannot be bundled — they must exist on the remote host. // The relay gracefully degrades when they are absent. - external: ['node-pty', '@parcel/watcher', 'better-sqlite3'], + external: ['node-pty', '@parcel/watcher'], sourcemap: false, minify: true, define: { diff --git a/config/scripts/check-styled-scrollbars.test.mjs b/config/scripts/check-styled-scrollbars.test.mjs index 6483fd540ea..5ab2f46b1f9 100644 --- a/config/scripts/check-styled-scrollbars.test.mjs +++ b/config/scripts/check-styled-scrollbars.test.mjs @@ -129,6 +129,22 @@ describe('check-styled-scrollbars', () => { expect(reports).toHaveLength(0) }) + it('reports inline vertical overflow when the scrollbar class is conditional or short-circuited', () => { + for (const classNameExpression of [ + "enabled && 'scrollbar-sleek'", + "enabled ? 'scrollbar-sleek' : undefined", + "enabled || 'scrollbar-sleek'", + "enabled ?? 'scrollbar-sleek'" + ]) { + const reports = reportUnstyledScrollbars( + 'Example.tsx', + `export function Example({ enabled }) { return
}` + ) + + expect(reports, classNameExpression).toHaveLength(1) + } + }) + it('reports logical inline style spreads without an Orca scrollbar class', () => { const reports = reportUnstyledScrollbars( 'Example.tsx', @@ -156,6 +172,15 @@ describe('check-styled-scrollbars', () => { expect(reports).toHaveLength(0) }) + it('uses later spread className props over earlier explicit className props', () => { + const reports = reportUnstyledScrollbars( + 'Example.tsx', + "export function Example() { return
}" + ) + + expect(reports).toHaveLength(1) + }) + it('supports variant helper className config', () => { const reports = reportUnstyledScrollbars( 'Example.tsx', diff --git a/config/scripts/computer-use-smoke.mjs b/config/scripts/computer-use-smoke.mjs index 1d5290a7ec0..5350deef1b8 100644 --- a/config/scripts/computer-use-smoke.mjs +++ b/config/scripts/computer-use-smoke.mjs @@ -11,7 +11,11 @@ const args = new Set(process.argv.slice(2)) const requestedApps = valueFlag('--apps') const includeScreenshot = args.has('--screenshot') const session = valueFlag('--session') ?? `computer-smoke-${process.pid}` -const preferredApps = (requestedApps ?? process.env.ORCA_COMPUTER_SMOKE_APPS ?? 'Finder,TextEdit,Spotify,Slack,Microsoft Edge') +const preferredApps = ( + requestedApps ?? + process.env.ORCA_COMPUTER_SMOKE_APPS ?? + 'Finder,TextEdit,Spotify,Slack,Microsoft Edge' +) .split(',') .map((app) => app.trim()) .filter(Boolean) @@ -23,8 +27,12 @@ if (!existsSync(cliPath)) { const list = unwrapResult(runCli(['computer', 'list-apps', '--json'])) const apps = Array.isArray(list.apps) ? list.apps : [] const availableNames = new Set(apps.map((app) => String(app.name ?? '').toLowerCase())) -const availableBundles = new Set(apps.map((app) => String(app.bundleId ?? '').toLowerCase()).filter(Boolean)) -const targets = preferredApps.filter((app) => availableNames.has(app.toLowerCase()) || availableBundles.has(app.toLowerCase())) +const availableBundles = new Set( + apps.map((app) => String(app.bundleId ?? '').toLowerCase()).filter(Boolean) +) +const targets = preferredApps.filter( + (app) => availableNames.has(app.toLowerCase()) || availableBundles.has(app.toLowerCase()) +) console.log(`computer-use smoke: ${apps.length} apps listed`) if (targets.length === 0) { @@ -34,16 +42,19 @@ if (targets.length === 0) { let failures = 0 for (const app of targets) { - const result = runCli([ - 'computer', - 'get-app-state', - '--session', - session, - '--app', - app, - ...(includeScreenshot ? [] : ['--no-screenshot']), - '--json' - ], { allowFailure: true }) + const result = runCli( + [ + 'computer', + 'get-app-state', + '--session', + session, + '--app', + app, + ...(includeScreenshot ? [] : ['--no-screenshot']), + '--json' + ], + { allowFailure: true } + ) if (!result.ok) { failures += 1 @@ -57,15 +68,19 @@ for (const app of targets) { const lineCount = treeText.split('\n').filter(Boolean).length const secondaryActions = (treeText.match(/Secondary Actions:/g) ?? []).length const settable = (treeText.match(/\bsettable\b/g) ?? []).length - const screenshotState = state.screenshot ? `${state.screenshot.width}x${state.screenshot.height}` : 'missing' - console.log([ - `computer-use smoke: ${snapshot.app.name}`, - `${snapshot.elementCount} elements`, - `${lineCount} lines`, - `${secondaryActions} secondary-action lines`, - `${settable} settable elements`, - `screenshot=${screenshotState}` - ].join(' | ')) + const screenshotState = state.screenshot + ? `${state.screenshot.width}x${state.screenshot.height}` + : 'missing' + console.log( + [ + `computer-use smoke: ${snapshot.app.name}`, + `${snapshot.elementCount} elements`, + `${lineCount} lines`, + `${secondaryActions} secondary-action lines`, + `${settable} settable elements`, + `screenshot=${screenshotState}` + ].join(' | ') + ) } if (failures > 0) { @@ -86,7 +101,8 @@ function runCli(cliArgs, options = {}) { encoding: 'utf8', env: { ...process.env, - ORCA_USER_DATA_PATH: process.env.ORCA_COMPUTER_SMOKE_USER_DATA_PATH ?? defaultDevUserDataPath() + ORCA_USER_DATA_PATH: + process.env.ORCA_COMPUTER_SMOKE_USER_DATA_PATH ?? defaultDevUserDataPath() } }) if (child.status !== 0) { diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 4e4938a3585..a1d75c30f25 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -1,10 +1,66 @@ +import { mkdir, mkdtemp, readdir, rm, stat, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { describe, expect, it } from 'vitest' const require = createRequire(import.meta.url) const electronBuilderConfig = require('../electron-builder.config.cjs') +const electronBuilderNativeRebuild = require('./electron-builder-native-rebuild.cjs') +const { + findAsarEntry, + prunePackagedNodePty, + prunePackagedSherpaOnnx, + prunePackagedRuntimeTypeDeclarations, + prunePackagedZodSources, + verifyPackagedMainRuntimeDeps +} = require('../packaged-runtime-node-modules.cjs') describe('electron-builder config', () => { + it('excludes repo-only source trees from app.asar', () => { + expect(electronBuilderConfig.files).toEqual( + expect.arrayContaining([ + '!src{,/**/*}', + '!config{,/**/*}', + '!docs{,/**/*}', + '!mobile{,/**/*}', + '!native{,/**/*}', + '!skills{,/**/*}', + '!tests{,/**/*}', + '!Casks{,/**/*}', + '!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}', + '!out/**/*.test.js' + ]) + ) + }) + + it('keeps runtime resources available through extraResources', () => { + expect(electronBuilderConfig.mac.extraResources).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + from: 'native/computer-use-macos/.build/release/Orca Computer Use.app', + to: 'Orca Computer Use.app' + }) + ]) + ) + expect(electronBuilderConfig.linux.extraResources).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + from: 'native/computer-use-linux/runtime.py', + to: 'computer-use-linux/runtime.py' + }) + ]) + ) + expect(electronBuilderConfig.win.extraResources).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + from: 'native/computer-use-windows/runtime.ps1', + to: 'computer-use-windows/runtime.ps1' + }) + ]) + ) + }) + it('uses the multi-size icon source for Linux packages', () => { expect(electronBuilderConfig.linux.icon).toBe('resources/build/icon.icns') }) @@ -18,4 +74,147 @@ describe('electron-builder config', () => { artifactName: 'orca-ide-${version}.${arch}.${ext}' }) }) + + it('uses Orca native rebuild hook instead of electron-builder default rebuild', () => { + expect(electronBuilderConfig.beforeBuild).toBe(electronBuilderNativeRebuild) + expect(electronBuilderConfig.npmRebuild).toBe(true) + }) + + it('verifies packaged main runtime deps from Windows-style asar entries', async () => { + const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-deps-')) + try { + await writeFile(join(resourcesDir, 'app.asar'), '', 'utf8') + await mkdir(join(resourcesDir, 'node_modules', 'yaml'), { recursive: true }) + await mkdir(join(resourcesDir, 'node_modules', 'zod'), { recursive: true }) + + const sources = new Map([ + ['out\\main\\index.js', 'const z = require("zod")'], + ['out\\main\\agent-hooks\\managed-agent-hook-controls.js', 'const YAML = require("yaml")'] + ]) + const asar = { + listPackage: () => [...sources.keys()].map((entry) => `\\${entry}`), + extractFile: (_asarPath, internalPath) => Buffer.from(sources.get(internalPath), 'utf8') + } + + expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).not.toThrow() + } finally { + await rm(resourcesDir, { recursive: true, force: true }) + } + }) + + it('normalizes host-specific asar entry separators', () => { + expect(findAsarEntry(['\\out\\main\\index.js'], 'out/main/index.js')).toBe( + '\\out\\main\\index.js' + ) + expect(findAsarEntry(['/out/main/index.js'], 'out/main/index.js')).toBe('/out/main/index.js') + }) + + it('prunes non-target node-pty prebuilds from packaged runtime resources', async () => { + const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-node-pty-prune-')) + try { + const prebuildsDir = join(resourcesDir, 'node_modules', 'node-pty', 'prebuilds') + await mkdir(join(prebuildsDir, 'darwin-arm64'), { recursive: true }) + await mkdir(join(prebuildsDir, 'darwin-x64'), { recursive: true }) + await mkdir(join(prebuildsDir, 'linux-x64'), { recursive: true }) + await mkdir(join(prebuildsDir, 'win32-x64'), { recursive: true }) + await mkdir(join(resourcesDir, 'node_modules', 'node-pty', 'third_party', 'conpty'), { + recursive: true + }) + await mkdir(join(resourcesDir, 'node_modules', 'node-pty', 'deps', 'winpty'), { + recursive: true + }) + + prunePackagedNodePty(resourcesDir, 'darwin') + + await expect(readdir(prebuildsDir).then((entries) => entries.sort())).resolves.toEqual([ + 'darwin-arm64', + 'darwin-x64' + ]) + await expect( + readdir(join(resourcesDir, 'node_modules', 'node-pty', 'third_party')) + ).resolves.toEqual([]) + await expect( + readdir(join(resourcesDir, 'node_modules', 'node-pty', 'deps')) + ).resolves.toEqual([]) + } finally { + await rm(resourcesDir, { recursive: true, force: true }) + } + }) + + it('prunes type declaration artifacts from packaged runtime node_modules', async () => { + const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-type-prune-')) + try { + const packageDir = join(resourcesDir, 'node_modules', 'example-package') + await mkdir(join(packageDir, 'dist'), { recursive: true }) + await writeFile(join(packageDir, 'dist', 'index.cjs'), 'module.exports = {}', 'utf8') + await writeFile(join(packageDir, 'dist', 'index.d.ts'), 'export type Value = string', 'utf8') + await writeFile(join(packageDir, 'dist', 'index.d.cts'), 'export type Value = string', 'utf8') + await writeFile(join(packageDir, 'dist', 'index.d.mts.map'), '{}', 'utf8') + + prunePackagedRuntimeTypeDeclarations(resourcesDir) + + await expect(readdir(join(packageDir, 'dist'))).resolves.toEqual(['index.cjs']) + } finally { + await rm(resourcesDir, { recursive: true, force: true }) + } + }) + + it('prunes duplicate darwin sherpa-onnx runtime dylib aliases', async () => { + const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-sherpa-prune-')) + try { + const packageDir = join(resourcesDir, 'node_modules', 'sherpa-onnx-darwin-arm64') + await mkdir(packageDir, { recursive: true }) + await writeFile(join(packageDir, 'sherpa-onnx.node'), '', 'utf8') + await writeFile(join(packageDir, 'libonnxruntime.1.23.2.dylib'), '', 'utf8') + await writeFile(join(packageDir, 'libonnxruntime.dylib'), '', 'utf8') + + prunePackagedSherpaOnnx(resourcesDir, 'darwin') + + await expect(readdir(packageDir).then((entries) => entries.sort())).resolves.toEqual([ + 'libonnxruntime.1.23.2.dylib', + 'sherpa-onnx.node' + ]) + } finally { + await rm(resourcesDir, { recursive: true, force: true }) + } + }) + + it('prunes zod TypeScript sources from packaged runtime resources', async () => { + const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-zod-prune-')) + try { + const packageDir = join(resourcesDir, 'node_modules', 'zod') + await mkdir(join(packageDir, 'src'), { recursive: true }) + await writeFile(join(packageDir, 'index.cjs'), 'module.exports = {}', 'utf8') + await writeFile(join(packageDir, 'src', 'index.ts'), 'export const value = true', 'utf8') + + prunePackagedZodSources(resourcesDir) + + await expect(readdir(packageDir)).resolves.toEqual(['index.cjs']) + } finally { + await rm(resourcesDir, { recursive: true, force: true }) + } + }) + + it.skipIf(process.platform === 'win32')( + 'marks packaged Unix CLI launchers executable', + async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-electron-builder-config-')) + try { + const resourcesDir = join(root, 'linux-unpacked', 'resources') + const launcherPath = join(resourcesDir, 'bin', 'orca-ide') + await mkdir(join(resourcesDir, 'bin'), { recursive: true }) + await mkdir(join(resourcesDir, 'node_modules', 'zod', 'src'), { recursive: true }) + await writeFile(launcherPath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o644 }) + + await electronBuilderConfig.afterPack({ + appOutDir: join(root, 'linux-unpacked'), + electronPlatformName: 'linux' + }) + + expect((await stat(launcherPath)).mode & 0o111).not.toBe(0) + } finally { + await rm(root, { recursive: true, force: true }) + } + } + ) }) diff --git a/config/scripts/electron-builder-native-rebuild.cjs b/config/scripts/electron-builder-native-rebuild.cjs new file mode 100644 index 00000000000..2aa847db2eb --- /dev/null +++ b/config/scripts/electron-builder-native-rebuild.cjs @@ -0,0 +1,52 @@ +const { execFileSync } = require('node:child_process') +const { resolve } = require('node:path') + +const projectDir = resolve(__dirname, '../..') + +function electronBuilderNativeRebuild(context) { + return runElectronBuilderNativeRebuild(context) +} + +function runElectronBuilderNativeRebuild(context, runner = execFileSync) { + const args = buildNativeRebuildArgs(context) + runner(process.execPath, args, { + cwd: projectDir, + stdio: 'inherit' + }) + + // Why: returning false tells electron-builder that native deps were handled + // externally, avoiding its all-module rebuild of optional cpu-features. + return false +} + +function buildNativeRebuildArgs(context) { + const platform = readPlatformName(context?.platform) + const arch = readArchName(context?.arch) + + return [ + 'config/scripts/rebuild-native-deps.mjs', + `--platform=${platform}`, + `--arch=${arch}`, + '--force' + ] +} + +function readPlatformName(platform) { + const name = typeof platform === 'string' ? platform : platform?.nodeName + if (!name) { + throw new Error('electron-builder native rebuild context is missing platform.nodeName') + } + return name +} + +function readArchName(arch) { + if (!arch || typeof arch !== 'string') { + throw new Error('electron-builder native rebuild context is missing arch') + } + return arch +} + +module.exports = electronBuilderNativeRebuild +module.exports.default = electronBuilderNativeRebuild +module.exports.buildNativeRebuildArgs = buildNativeRebuildArgs +module.exports.runElectronBuilderNativeRebuild = runElectronBuilderNativeRebuild diff --git a/config/scripts/electron-builder-native-rebuild.test.mjs b/config/scripts/electron-builder-native-rebuild.test.mjs new file mode 100644 index 00000000000..e92c62b7a3e --- /dev/null +++ b/config/scripts/electron-builder-native-rebuild.test.mjs @@ -0,0 +1,49 @@ +import { createRequire } from 'node:module' +import { describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const { + buildNativeRebuildArgs, + runElectronBuilderNativeRebuild +} = require('./electron-builder-native-rebuild.cjs') + +describe('electron-builder native rebuild hook', () => { + it('passes the target platform and arch to Orca native rebuild script', () => { + expect( + buildNativeRebuildArgs({ + platform: { nodeName: 'darwin' }, + arch: 'x64' + }) + ).toEqual([ + 'config/scripts/rebuild-native-deps.mjs', + '--platform=darwin', + '--arch=x64', + '--force' + ]) + }) + + it('returns false so electron-builder skips its optional module rebuild pass', () => { + const calls = [] + const result = runElectronBuilderNativeRebuild( + { + platform: { nodeName: 'linux' }, + arch: 'arm64' + }, + (...args) => calls.push(args) + ) + + expect(result).toBe(false) + expect(calls).toEqual([ + [ + process.execPath, + ['config/scripts/rebuild-native-deps.mjs', '--platform=linux', '--arch=arm64', '--force'], + expect.objectContaining({ stdio: 'inherit' }) + ] + ]) + }) + + it('rejects incomplete electron-builder contexts', () => { + expect(() => buildNativeRebuildArgs({ arch: 'x64' })).toThrow(/platform/) + expect(() => buildNativeRebuildArgs({ platform: { nodeName: 'linux' } })).toThrow(/arch/) + }) +}) diff --git a/config/scripts/ensure-native-runtime.mjs b/config/scripts/ensure-native-runtime.mjs index 6530ba75576..33870b3faa5 100644 --- a/config/scripts/ensure-native-runtime.mjs +++ b/config/scripts/ensure-native-runtime.mjs @@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process' import { createRequire } from 'node:module' +import { existsSync, readFileSync } from 'node:fs' import { release } from 'node:os' import { basename, dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' @@ -11,7 +12,7 @@ const scriptPath = fileURLToPath(import.meta.url) const projectDir = resolve(dirname(scriptPath), '../..') const runtime = readRuntimeArg() -const NATIVE_MODULES = ['better-sqlite3', 'node-pty'] +const NATIVE_MODULES = ['node-pty'] const CHILD_CHECK_FLAG = '--check-only' if (process.argv.includes(CHILD_CHECK_FLAG)) { @@ -106,14 +107,12 @@ function runNodeCheck() { } function runElectronCheck() { - let electronExecutable - try { - electronExecutable = require('electron') - } catch (error) { - return { ok: false, error } + const electronExecutable = resolveInstalledElectronExecutable() + if (!electronExecutable.ok) { + return { ok: false, error: electronExecutable.error } } - const result = spawnSync(electronExecutable, [scriptPath, CHILD_CHECK_FLAG], { + const result = spawnSync(electronExecutable.path, [scriptPath, CHILD_CHECK_FLAG], { cwd: projectDir, env: { ...process.env, @@ -126,6 +125,63 @@ function runElectronCheck() { return parseChildCheckResult(result) } +function resolveInstalledElectronExecutable() { + const electronPackageDir = resolve(projectDir, 'node_modules/electron') + try { + const electronVersion = JSON.parse( + readFileSync(resolve(electronPackageDir, 'package.json'), 'utf8') + ).version + const platformPath = getElectronPlatformPath() + const installedVersion = readFileSync(resolve(electronPackageDir, 'dist', 'version'), 'utf8') + .trim() + .replace(/^v/, '') + if (installedVersion !== electronVersion) { + return { + ok: false, + error: new Error( + `Electron package binary version ${installedVersion} does not match ${electronVersion}.` + ) + } + } + const installedPlatformPath = readFileSync(resolve(electronPackageDir, 'path.txt'), 'utf8') + if (installedPlatformPath !== platformPath) { + return { + ok: false, + error: new Error( + `Electron package path.txt points at ${installedPlatformPath}, expected ${platformPath}.` + ) + } + } + const electronPath = process.env.ELECTRON_OVERRIDE_DIST_PATH + ? resolve(process.env.ELECTRON_OVERRIDE_DIST_PATH, platformPath) + : resolve(electronPackageDir, 'dist', platformPath) + if (!existsSync(electronPath)) { + return { ok: false, error: new Error(`Electron executable is missing at ${electronPath}.`) } + } + return { ok: true, path: electronPath } + } catch (error) { + return { ok: false, error } + } +} + +function getElectronPlatformPath() { + const targetPlatform = + process.env.ELECTRON_INSTALL_PLATFORM || process.env.npm_config_platform || process.platform + switch (targetPlatform) { + case 'mas': + case 'darwin': + return 'Electron.app/Contents/MacOS/Electron' + case 'freebsd': + case 'openbsd': + case 'linux': + return 'electron' + case 'win32': + return 'electron.exe' + default: + throw new Error(`Electron builds are not available on platform: ${targetPlatform}`) + } +} + function parseChildCheckResult(result) { const failures = parseCheckFailures(result.stderr) @@ -163,15 +219,6 @@ function collectNativeModuleFailures() { } function loadNativeModule(moduleName) { - if (moduleName === 'better-sqlite3') { - const Database = require(moduleName) - // Why: better-sqlite3 defers loading its .node binding until Database is - // constructed, so a plain require() misses Node ABI mismatches. - const db = new Database(':memory:') - db.close() - return - } - if (moduleName === 'node-pty') { loadNodePtyNativeModule() return diff --git a/config/scripts/ensure-native-runtime.test.mjs b/config/scripts/ensure-native-runtime.test.mjs index 65ba4f0cd60..99e1a3760f9 100644 --- a/config/scripts/ensure-native-runtime.test.mjs +++ b/config/scripts/ensure-native-runtime.test.mjs @@ -39,10 +39,10 @@ describe('ensure-native-runtime', () => { expect(result.status, result.stderr).toBe(0) const log = readFileSync(logPath, 'utf8') - expect(log).toContain('pnpm rebuild better-sqlite3\n') - expect(log.split('\n').filter((line) => line.startsWith('better-sqlite3 '))).toEqual([ - 'better-sqlite3 child marker=false', - 'better-sqlite3 child marker=true' + expect(log).toContain('pnpm rebuild node-pty\n') + expect(log.split('\n').filter((line) => line.startsWith('node-pty child '))).toEqual([ + expect.stringMatching(/^node-pty child (?:conpty|pty) marker=false$/), + expect.stringMatching(/^node-pty child (?:conpty|pty) marker=true$/) ]) } finally { rmSync(projectDir, { recursive: true, force: true }) @@ -69,44 +69,24 @@ function envWithPrependedPath(binDir, extraEnv) { } function writeFakeNativeModules(projectDir) { - const sqliteDir = join(projectDir, 'node_modules', 'better-sqlite3') const nodePtyDir = join(projectDir, 'node_modules', 'node-pty') - mkdirSync(sqliteDir, { recursive: true }) mkdirSync(join(nodePtyDir, 'lib'), { recursive: true }) - writeFileSync( - join(sqliteDir, 'index.js'), - ` -const { appendFileSync, existsSync } = require('node:fs') - -module.exports = class Database { - constructor() { - const markerExists = existsSync(process.env.ORCA_NATIVE_TEST_MARKER) - appendFileSync( - process.env.ORCA_NATIVE_TEST_LOG, - \`better-sqlite3 \${process.argv.includes('--check-only') ? 'child' : 'parent'} marker=\${markerExists}\\n\` - ) - if (!markerExists) { - throw new Error('ABI mismatch sentinel') - } - } - - close() {} -} -` - ) - writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n') writeFileSync( join(nodePtyDir, 'lib', 'utils.js'), ` -const { appendFileSync } = require('node:fs') +const { appendFileSync, existsSync } = require('node:fs') exports.loadNativeModule = function loadNativeModule(nativeName) { + const markerExists = existsSync(process.env.ORCA_NATIVE_TEST_MARKER) appendFileSync( process.env.ORCA_NATIVE_TEST_LOG, - \`node-pty \${process.argv.includes('--check-only') ? 'child' : 'parent'} \${nativeName}\\n\` + \`node-pty \${process.argv.includes('--check-only') ? 'child' : 'parent'} \${nativeName} marker=\${markerExists}\\n\` ) + if (!markerExists) { + throw new Error('ABI mismatch sentinel') + } } ` ) diff --git a/config/scripts/install-dev-cli.mjs b/config/scripts/install-dev-cli.mjs index ddd5226820f..53ea42a1b19 100644 --- a/config/scripts/install-dev-cli.mjs +++ b/config/scripts/install-dev-cli.mjs @@ -7,12 +7,10 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' const scriptDir = path.dirname(fileURLToPath(import.meta.url)) -const source = path.join(scriptDir, 'orca-dev') +const source = path.join(scriptDir, 'orca-dev.mjs') const commandPath = - process.platform === 'darwin' || process.platform === 'linux' - ? '/usr/local/bin/orca-dev' - : null + process.platform === 'darwin' || process.platform === 'linux' ? '/usr/local/bin/orca-dev' : null if (!commandPath) { console.log('[orca-dev] Skipping global symlink (unsupported platform).') @@ -21,7 +19,9 @@ if (!commandPath) { function isOwnedByUs(target) { try { - if (!lstatSync(target).isSymbolicLink()) { return false } + if (!lstatSync(target).isSymbolicLink()) { + return false + } return readlinkSync(target) === source } catch { return false diff --git a/config/scripts/install-electron-package-binary.mjs b/config/scripts/install-electron-package-binary.mjs index 25662d10755..2966e275385 100644 --- a/config/scripts/install-electron-package-binary.mjs +++ b/config/scripts/install-electron-package-binary.mjs @@ -17,13 +17,14 @@ import { platform as osPlatform, tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -const require = createRequire(import.meta.url) const projectDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..') const electronPackageDir = resolve(projectDir, 'node_modules/electron') const electronRequire = createRequire(resolve(electronPackageDir, 'package.json')) const { version: electronVersion } = electronRequire('./package.json') const { downloadArtifact } = electronRequire('@electron/get') -const platformPath = getElectronPlatformPath() +const targetPlatform = getElectronTargetPlatform() +const targetArch = getElectronTargetArch() +const platformPath = getElectronPlatformPath(targetPlatform) try { // Why: Electron's own install.js can exit 0 while an async extract promise is @@ -59,17 +60,24 @@ async function main() { function electronPackageIsUsable() { try { - const electronPath = resolveElectronPath() - return existsSync(electronPath) + const installedVersion = readFileSync(resolve(electronPackageDir, 'dist', 'version'), 'utf8') + .trim() + .replace(/^v/, '') + const installedPlatformPath = readFileSync(resolve(electronPackageDir, 'path.txt'), 'utf8') + return ( + installedVersion === electronVersion && + installedPlatformPath === platformPath && + existsSync(getElectronExecutablePath()) + ) } catch { return false } } -function resolveElectronPath() { - const electronModulePath = require.resolve('electron') - delete require.cache[electronModulePath] - return require('electron') +function getElectronExecutablePath() { + return process.env.ELECTRON_OVERRIDE_DIST_PATH + ? resolve(process.env.ELECTRON_OVERRIDE_DIST_PATH, platformPath) + : resolve(electronPackageDir, 'dist', platformPath) } function resetPartialElectronInstall() { @@ -107,8 +115,8 @@ async function installElectronPackageBinary() { const zipPath = await downloadArtifact({ version: electronVersion, artifactName: 'electron', - platform: process.env.npm_config_platform || osPlatform(), - arch: process.env.npm_config_arch || process.arch, + platform: targetPlatform, + arch: targetArch, cacheRoot, force: true, tempDirectory: tempDir, @@ -250,8 +258,15 @@ function safeReaddir(targetPath) { } } -function getElectronPlatformPath() { - const targetPlatform = process.env.npm_config_platform || osPlatform() +function getElectronTargetPlatform() { + return process.env.ELECTRON_INSTALL_PLATFORM || process.env.npm_config_platform || osPlatform() +} + +function getElectronTargetArch() { + return process.env.ELECTRON_INSTALL_ARCH || process.env.npm_config_arch || process.arch +} + +function getElectronPlatformPath(targetPlatform) { switch (targetPlatform) { case 'mas': case 'darwin': diff --git a/config/scripts/install-electron-package-binary.test.mjs b/config/scripts/install-electron-package-binary.test.mjs index 95c1057eb91..aa58dae18a6 100644 --- a/config/scripts/install-electron-package-binary.test.mjs +++ b/config/scripts/install-electron-package-binary.test.mjs @@ -49,6 +49,50 @@ describe('install-electron-package-binary', () => { } }) + it('uses Electron 42 install env vars before npm config platform flags', () => { + const projectDir = mkTempProject() + + try { + writeFakeElectronPackage(projectDir) + writeFakeElectronGet(projectDir) + writeFakeExtractor(projectDir, { createExecutable: true }) + + const result = runInstallScript(projectDir, { + ELECTRON_INSTALL_PLATFORM: 'win32', + ELECTRON_INSTALL_ARCH: 'arm64', + npm_config_platform: 'linux', + npm_config_arch: 'x64' + }) + + expect(result.status, result.stderr).toBe(0) + expect(readFileSync(join(projectDir, 'electron-get.log'), 'utf8')).toContain( + 'platform=win32 arch=arm64' + ) + expect(readFileSync(join(projectDir, 'node_modules', 'electron', 'path.txt'), 'utf8')).toBe( + 'electron.exe' + ) + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + }) + + it('does not trigger Electron 42 lazy require downloads while checking install state', () => { + const projectDir = mkTempProject() + + try { + writeFakeElectronPackage(projectDir, { lazyRequireMarker: 'lazy-require.marker' }) + writeFakeElectronGet(projectDir) + writeFakeExtractor(projectDir, { createExecutable: true }) + + const result = runInstallScript(projectDir) + + expect(result.status, result.stderr).toBe(0) + expect(existsSync(join(projectDir, 'lazy-require.marker'))).toBe(false) + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + }) + it('fails instead of silently accepting a partial Electron extract', () => { const projectDir = mkTempProject() @@ -100,7 +144,7 @@ function mkTempProject() { return projectDir } -function runInstallScript(projectDir) { +function runInstallScript(projectDir, extraEnv = {}) { return spawnSync(process.execPath, ['config/scripts/install-electron-package-binary.mjs'], { cwd: projectDir, encoding: 'utf8', @@ -108,12 +152,13 @@ function runInstallScript(projectDir) { ...process.env, npm_config_platform: 'linux', npm_config_arch: 'x64', - ORCA_ELECTRON_PACKAGE_EXTRACTOR: join(projectDir, 'fake-extractor.cjs') + ORCA_ELECTRON_PACKAGE_EXTRACTOR: join(projectDir, 'fake-extractor.cjs'), + ...extraEnv } }) } -function writeFakeElectronPackage(projectDir) { +function writeFakeElectronPackage(projectDir, { lazyRequireMarker = null } = {}) { const electronDir = join(projectDir, 'node_modules', 'electron') mkdirSync(electronDir, { recursive: true }) writeFileSync( @@ -126,6 +171,7 @@ function writeFakeElectronPackage(projectDir) { ` const fs = require('node:fs') const path = require('node:path') +${lazyRequireMarker ? `fs.writeFileSync(${JSON.stringify(lazyRequireMarker)}, 'required')` : ''} const pathFile = path.join(__dirname, 'path.txt') if (!fs.existsSync(pathFile)) { throw new Error('Electron failed to install correctly, please delete node_modules/electron and try installing again') @@ -144,7 +190,10 @@ function writeFakeElectronGet(projectDir, { downloadNeverSettles = false } = {}) const { mkdirSync, writeFileSync, appendFileSync } = require('node:fs') const { join } = require('node:path') exports.downloadArtifact = async function downloadArtifact(details) { - appendFileSync('electron-get.log', 'cacheRoot=' + details.cacheRoot + '\\n') + appendFileSync( + 'electron-get.log', + 'cacheRoot=' + details.cacheRoot + ' platform=' + details.platform + ' arch=' + details.arch + '\\n' + ) if (${JSON.stringify(downloadNeverSettles)}) { return new Promise(() => {}) } @@ -167,6 +216,7 @@ const extractDir = process.argv[3] mkdirSync(join(extractDir, 'locales'), { recursive: true }) if (${JSON.stringify(createExecutable)}) { writeFileSync(join(extractDir, 'electron'), '') + writeFileSync(join(extractDir, 'electron.exe'), '') writeFileSync(join(extractDir, 'version'), 'v41.5.0') if (process.platform !== 'win32') { symlinkSync('version', join(extractDir, 'version-link')) diff --git a/config/scripts/orca-dev-bin.test.mjs b/config/scripts/orca-dev-bin.test.mjs new file mode 100644 index 00000000000..46ffc82ff4c --- /dev/null +++ b/config/scripts/orca-dev-bin.test.mjs @@ -0,0 +1,53 @@ +import { execFileSync } from 'node:child_process' +import { chmodSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { describe, expect, it } from 'vitest' + +const projectDir = path.resolve(import.meta.dirname, '../..') +const packageJson = JSON.parse(readFileSync(path.join(projectDir, 'package.json'), 'utf8')) +const wrapperPath = path.join(projectDir, 'config', 'scripts', 'orca-dev.mjs') + +describe('orca-dev package bin', () => { + it('uses a Node entrypoint for cross-platform package installs', () => { + expect(packageJson.bin['orca-dev']).toBe('./config/scripts/orca-dev.mjs') + expect(readFileSync(wrapperPath, 'utf8').startsWith('#!/usr/bin/env node\n')).toBe(true) + }) + + it('runs the dev CLI through Node without requiring Bash', () => { + const root = mkdtempSync(path.join(tmpdir(), 'orca-dev-bin-')) + const cliEntry = path.join(root, 'cli-entry.cjs') + const outputPath = path.join(root, 'output.json') + writeFileSync( + cliEntry, + [ + 'const fs = require("node:fs");', + `fs.writeFileSync(${JSON.stringify(outputPath)}, JSON.stringify({`, + ' argv: process.argv.slice(2),', + ' userDataPath: process.env.ORCA_USER_DATA_PATH,', + ' appExecutable: process.env.ORCA_APP_EXECUTABLE', + '}));' + ].join('\n'), + 'utf8' + ) + if (process.platform !== 'win32') { + chmodSync(cliEntry, 0o755) + } + + execFileSync(process.execPath, [wrapperPath, '--help'], { + env: { + ...process.env, + ORCA_DEV_CLI_ENTRY_PATH: cliEntry, + ORCA_DEV_USER_DATA_PATH: path.join(root, 'user-data'), + ORCA_APP_EXECUTABLE: path.join(root, 'Electron') + }, + stdio: 'ignore' + }) + + expect(JSON.parse(readFileSync(outputPath, 'utf8'))).toEqual({ + argv: ['--help'], + userDataPath: path.join(root, 'user-data'), + appExecutable: path.join(root, 'Electron') + }) + }) +}) diff --git a/config/scripts/orca-dev.mjs b/config/scripts/orca-dev.mjs new file mode 100755 index 00000000000..39cbe459ba8 --- /dev/null +++ b/config/scripts/orca-dev.mjs @@ -0,0 +1,74 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process' +import { accessSync, constants, existsSync, realpathSync, statSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const scriptPath = realpathSync(fileURLToPath(import.meta.url)) +const scriptDir = path.dirname(scriptPath) +const repoRoot = path.resolve(scriptDir, '..', '..') +const cliEntry = + process.env.ORCA_DEV_CLI_ENTRY_PATH ?? path.join(repoRoot, 'out', 'cli', 'index.js') + +if (!existsSync(cliEntry)) { + console.error("orca-dev: CLI not built yet. Run 'pnpm run build:cli' first.") + process.exit(1) +} + +process.env.ORCA_USER_DATA_PATH = process.env.ORCA_DEV_USER_DATA_PATH ?? getDefaultDevUserDataPath() + +const electronExecutable = getElectronExecutable() +if (!process.env.ORCA_APP_EXECUTABLE && isRunnableFile(electronExecutable)) { + process.env.ORCA_APP_EXECUTABLE = electronExecutable + process.env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT = '1' +} + +const result = spawnSync(process.execPath, [cliEntry, ...process.argv.slice(2)], { + stdio: 'inherit', + env: process.env +}) + +if (result.signal) { + process.kill(process.pid, result.signal) +} +process.exit(result.status ?? (result.error ? 1 : 0)) + +function getDefaultDevUserDataPath() { + if (process.platform === 'darwin') { + return path.join(process.env.HOME ?? '', 'Library', 'Application Support', 'orca-dev') + } + if (process.platform === 'win32') { + return path.join( + process.env.APPDATA ?? path.join(process.env.USERPROFILE ?? '', 'AppData', 'Roaming'), + 'orca-dev' + ) + } + return path.join( + process.env.XDG_CONFIG_HOME ?? path.join(process.env.HOME ?? '', '.config'), + 'orca-dev' + ) +} + +function getElectronExecutable() { + if (process.platform === 'win32') { + return path.join(repoRoot, 'node_modules', 'electron', 'dist', 'electron.exe') + } + return path.join(repoRoot, 'node_modules', '.bin', 'electron') +} + +function isRunnableFile(candidate) { + try { + const stats = statSync(candidate) + if (!stats.isFile()) { + return false + } + if (process.platform === 'win32') { + return true + } + accessSync(candidate, constants.X_OK) + return true + } catch { + return false + } +} diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index 98f822f06c6..870b61f5171 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -33,6 +33,21 @@ describe('Electron runtime package contract', () => { } }) + it('keeps Windows and Linux package builds off the macOS native helper build', () => { + const scripts = packageJson.scripts + + expect(scripts['build:desktop']).not.toContain('build:computer-macos') + expect(scripts['build:win']).toContain('pnpm run build:desktop') + expect(scripts['build:win']).not.toContain('pnpm run build ') + expect(scripts['build:win']).not.toContain('build:computer-macos') + expect(scripts['build:linux']).toContain('pnpm run build:desktop') + expect(scripts['build:linux']).not.toContain('pnpm run build ') + expect(scripts['build:linux']).not.toContain('build:computer-macos') + expect(scripts['build:mac']).toContain('pnpm run build:computer-macos') + expect(scripts['build:release']).toContain('pnpm run build:native') + expect(scripts['build:release']).not.toContain('build:computer-macos') + }) + it('guards release publishing before electron-builder runs', () => { const releaseWorkflow = readFileSync( join(projectDir, '.github/workflows/release-cut.yml'), @@ -75,6 +90,22 @@ describe('Electron runtime package contract', () => { expect(bumpStep.run).toContain('git commit --allow-empty -m "$commit_message"') }) + it('keeps release-cut RC retries monotonic across stale attempts', () => { + const releaseWorkflow = readFileSync( + join(projectDir, '.github/workflows/release-cut.yml'), + 'utf8' + ) + const parsedWorkflow = parse(releaseWorkflow) + const versionStep = parsedWorkflow.jobs.cut.steps.find( + (step) => step.name === 'Compute next version' + ) + + expect(versionStep.run).toContain('node config/scripts/release-rc-history.mjs "$1"') + expect(versionStep.run).toContain('tag_matches_current_ref') + expect(versionStep.run).toContain('cutting the next version instead of reusing stale artifacts') + expect(versionStep.run).toContain('git rev-parse "$existing_rc_tag"') + }) + it('bumps separate Homebrew casks for stable and RC desktop tags', () => { const releaseWorkflow = parse( readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8') @@ -86,7 +117,7 @@ describe('Electron runtime package contract', () => { expect(releaseWorkflow.jobs['homebrew-bump'].if).toContain( "startsWith(needs.cut.outputs.tag, 'v')" ) - expect(releaseWorkflow.jobs['homebrew-bump'].if).not.toContain("-rc.") + expect(releaseWorkflow.jobs['homebrew-bump'].if).not.toContain('-rc.') expect(releaseWorkflow.jobs['homebrew-bump-published-rc-draft'].with.tag).toBe( '${{ needs.cut.outputs.latest_published_rc_tag }}' ) @@ -117,4 +148,16 @@ describe('Electron runtime package contract', () => { expect(installStep.run).toBe('node config/scripts/install-electron-package-binary.mjs') }) + + it('smokes the packaged CLI from outside the checkout in PR checks', () => { + const prWorkflow = readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8') + const parsedWorkflow = parse(prWorkflow) + const smokeStep = parsedWorkflow.jobs.verify.steps.find( + (step) => step.name === 'Smoke packaged CLI' + ) + + expect(smokeStep.run).toBe( + 'node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/linux-unpacked' + ) + }) }) diff --git a/config/scripts/pty-batch-flush-benchmark.mjs b/config/scripts/pty-batch-flush-benchmark.mjs index ba1ff39e41a..f8ad0d63fa0 100644 --- a/config/scripts/pty-batch-flush-benchmark.mjs +++ b/config/scripts/pty-batch-flush-benchmark.mjs @@ -7,10 +7,7 @@ const PAYLOAD_CHARS = Number.parseInt(process.env.ORCA_PTY_BENCH_PAYLOAD_CHARS ? const RUNS = Number.parseInt(process.env.ORCA_PTY_BENCH_RUNS ?? '30', 10) const MEASURE_TIMER_DELAYS = process.env.ORCA_PTY_BENCH_MEASURE_TIMER_DELAYS !== '0' const INGRESS_CHUNKS = Number.parseInt(process.env.ORCA_PTY_BENCH_INGRESS_CHUNKS ?? '96', 10) -const INGRESS_CHUNK_CHARS = Number.parseInt( - process.env.ORCA_PTY_BENCH_INGRESS_CHARS ?? '65536', - 10 -) +const INGRESS_CHUNK_CHARS = Number.parseInt(process.env.ORCA_PTY_BENCH_INGRESS_CHARS ?? '65536', 10) const CHUNK_CHARS = 16 * 1024 const MAX_WRITES_PER_SLICE = 2 const RECENT_PTY_OUTPUT_LIMIT = 4096 @@ -30,14 +27,10 @@ if (!Number.isInteger(RUNS) || RUNS <= 0) { throw new Error(`ORCA_PTY_BENCH_RUNS must be positive, received ${RUNS}`) } if (!Number.isInteger(INGRESS_CHUNKS) || INGRESS_CHUNKS <= 0) { - throw new Error( - `ORCA_PTY_BENCH_INGRESS_CHUNKS must be positive, received ${INGRESS_CHUNKS}` - ) + throw new Error(`ORCA_PTY_BENCH_INGRESS_CHUNKS must be positive, received ${INGRESS_CHUNKS}`) } if (!Number.isInteger(INGRESS_CHUNK_CHARS) || INGRESS_CHUNK_CHARS <= 0) { - throw new Error( - `ORCA_PTY_BENCH_INGRESS_CHARS must be positive, received ${INGRESS_CHUNK_CHARS}` - ) + throw new Error(`ORCA_PTY_BENCH_INGRESS_CHARS must be positive, received ${INGRESS_CHUNK_CHARS}`) } function makePendingData() { diff --git a/config/scripts/publish-complete-draft-releases.mjs b/config/scripts/publish-complete-draft-releases.mjs index fade0b5e357..a02431afe88 100644 --- a/config/scripts/publish-complete-draft-releases.mjs +++ b/config/scripts/publish-complete-draft-releases.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node +import { execFileSync } from 'node:child_process' import { appendFileSync } from 'node:fs' import { pathToFileURL } from 'node:url' import { verifyRequiredReleaseAssets } from './verify-release-required-assets.mjs' @@ -21,6 +22,28 @@ function isRcTag(tag) { return tag.includes('-rc.') } +function gitOutput(args, cwd) { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'] + }).trim() +} + +export function isTagBuiltFromCurrentRef(tag, { cwd = process.cwd() } = {}) { + try { + const tagCommit = gitOutput(['rev-parse', `${tag}^{}`], cwd) + const currentCommit = gitOutput(['rev-parse', 'HEAD'], cwd) + if (tagCommit === currentCommit) { + return true + } + + return gitOutput(['rev-parse', `${tagCommit}^`], cwd) === currentCommit + } catch { + return false + } +} + async function githubJson(fetchImpl, url, token, options = {}) { const res = await fetchImpl(url, { ...options, @@ -55,6 +78,7 @@ export async function publishCompleteDraftReleases({ token, fetchImpl = fetch, verifyReleaseAssets = verifyRequiredReleaseAssets, + isDraftBuiltFromCurrentRef = ({ tag }) => isTagBuiltFromCurrentRef(tag), log = console.log }) { if (!repo) { @@ -74,6 +98,13 @@ export async function publishCompleteDraftReleases({ for (const release of candidates) { const tag = release.tag_name + if (!(await isDraftBuiltFromCurrentRef({ tag, release }))) { + const reason = 'tag is not built from the current release ref' + skipped.push({ tag, reason }) + log(`Skipping stale RC draft release ${tag}: ${reason}`) + continue + } + try { await verifyReleaseAssets({ repo, tag, token }) } catch (error) { @@ -85,13 +116,18 @@ export async function publishCompleteDraftReleases({ // Why: only release-cut-authored RC drafts with a complete asset set are // resumed here; incomplete drafts stay private for the normal rebuild path. - await githubJson(fetchImpl, `https://api.github.com/repos/${repo}/releases/${release.id}`, token, { - method: 'PATCH', - body: JSON.stringify({ - draft: false, - prerelease: isRcTag(tag) - }) - }) + await githubJson( + fetchImpl, + `https://api.github.com/repos/${repo}/releases/${release.id}`, + token, + { + method: 'PATCH', + body: JSON.stringify({ + draft: false, + prerelease: isRcTag(tag) + }) + } + ) published.push(tag) log(`Published complete RC draft release ${tag}`) } diff --git a/config/scripts/publish-complete-draft-releases.test.mjs b/config/scripts/publish-complete-draft-releases.test.mjs index 098d637389c..bc61c58c7e3 100644 --- a/config/scripts/publish-complete-draft-releases.test.mjs +++ b/config/scripts/publish-complete-draft-releases.test.mjs @@ -1,13 +1,39 @@ +import { execFileSync } from 'node:child_process' import { mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { + isTagBuiltFromCurrentRef, isReleaseCutDraft, publishCompleteDraftReleases, writeGithubOutputs } from './publish-complete-draft-releases.mjs' +function git(cwd, args) { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }).trim() +} + +function withGitRepo(run) { + const dir = mkdtempSync(join(tmpdir(), 'orca-draft-release-')) + try { + git(dir, ['init', '--initial-branch=main']) + git(dir, ['config', 'user.name', 'Test Bot']) + git(dir, ['config', 'user.email', 'test@example.com']) + run(dir) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +} + +function commit(cwd, message) { + git(cwd, ['commit', '--allow-empty', '-m', message]) +} + function jsonResponse(body, init = {}) { return { ok: init.ok ?? true, @@ -51,6 +77,41 @@ describe('isReleaseCutDraft', () => { }) }) +describe('isTagBuiltFromCurrentRef', () => { + it('accepts a tag on the current release commit', () => { + withGitRepo((repo) => { + commit(repo, 'initial') + commit(repo, 'release: v1.4.36-rc.6') + git(repo, ['tag', 'v1.4.36-rc.6']) + + expect(isTagBuiltFromCurrentRef('v1.4.36-rc.6', { cwd: repo })).toBe(true) + }) + }) + + it('accepts a tag whose release commit is built from the current ref', () => { + withGitRepo((repo) => { + commit(repo, 'initial') + const source = git(repo, ['rev-parse', 'HEAD']) + commit(repo, 'release: v1.4.36-rc.6') + git(repo, ['tag', 'v1.4.36-rc.6']) + git(repo, ['checkout', source]) + + expect(isTagBuiltFromCurrentRef('v1.4.36-rc.6', { cwd: repo })).toBe(true) + }) + }) + + it('rejects a stale tag when the current ref has moved on', () => { + withGitRepo((repo) => { + commit(repo, 'initial') + commit(repo, 'release: v1.4.36-rc.6') + git(repo, ['tag', 'v1.4.36-rc.6']) + commit(repo, 'fix: release packaging') + + expect(isTagBuiltFromCurrentRef('v1.4.36-rc.6', { cwd: repo })).toBe(false) + }) + }) +}) + describe('publishCompleteDraftReleases', () => { it('publishes complete release-cut drafts and skips incomplete ones', async () => { const fetchImpl = vi @@ -86,6 +147,7 @@ describe('publishCompleteDraftReleases', () => { token: 'token', fetchImpl, verifyReleaseAssets, + isDraftBuiltFromCurrentRef: vi.fn(async () => true), log }) @@ -106,6 +168,38 @@ describe('publishCompleteDraftReleases', () => { }) ) }) + + it('skips stale complete drafts before publishing', async () => { + const fetchImpl = vi.fn().mockResolvedValueOnce( + jsonResponse([ + { + id: 7, + draft: true, + tag_name: 'v1.4.2-rc.7', + created_at: '2026-05-15T07:31:19Z', + author: { login: 'github-actions[bot]' } + } + ]) + ) + const verifyReleaseAssets = vi.fn() + const log = vi.fn() + + const result = await publishCompleteDraftReleases({ + repo: 'stablyai/orca', + token: 'token', + fetchImpl, + verifyReleaseAssets, + isDraftBuiltFromCurrentRef: vi.fn(async () => false), + log + }) + + expect(result).toEqual({ + published: [], + skipped: [{ tag: 'v1.4.2-rc.7', reason: 'tag is not built from the current release ref' }] + }) + expect(verifyReleaseAssets).not.toHaveBeenCalled() + expect(fetchImpl).toHaveBeenCalledTimes(1) + }) }) describe('writeGithubOutputs', () => { diff --git a/config/scripts/rebuild-native-deps.mjs b/config/scripts/rebuild-native-deps.mjs index 49ebc103da3..715d65f7549 100644 --- a/config/scripts/rebuild-native-deps.mjs +++ b/config/scripts/rebuild-native-deps.mjs @@ -4,47 +4,58 @@ * * The standard `electron-builder install-app-deps` uses @electron/rebuild * internally but does not expose the `ignoreModules` option (as of - * electron-builder 26.x). On Windows dev machines that lack the full - * Visual C++ / Python build toolchain, `cpu-features@0.0.10` (an optional - * performance dependency of `ssh2`) fails to build with node-gyp because - * `buildcheck.gypi` is missing from the tarball. This causes the entire - * postinstall step to fail and prevents `pnpm install` from completing. + * electron-builder 26.x). `cpu-features@0.0.10` is an optional performance + * dependency of `ssh2`; it fails to build in common environments (missing + * buildcheck.gypi on Windows, and Electron 42's V8 external-pointer API on + * Linux). This can make the entire postinstall step fail and prevent + * `pnpm install` from completing. * * This script replaces `electron-builder install-app-deps` in the postinstall - * lifecycle. It calls @electron/rebuild's JS API directly so that we can pass - * `ignoreModules: ['cpu-features']` on Windows. Skipping cpu-features is - * safe: ssh2 detects the missing native module and falls back to pure-JS CPU - * feature detection automatically. - * - * On macOS and Linux the full rebuild (including cpu-features) runs as usual. + * lifecycle and the electron-builder beforeBuild hook. It calls + * @electron/rebuild's JS API directly so that we can skip `cpu-features` when + * rebuilding modules against Electron. Skipping + * cpu-features is safe: ssh2 detects the missing native module and falls back + * to pure-JS CPU feature detection automatically. */ import { rebuild } from '@electron/rebuild' import { execFileSync, spawnSync } from 'node:child_process' -import { createRequire } from 'node:module' import { existsSync, globSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { platform as osPlatform } from 'node:os' import { resolve } from 'node:path' -const require = createRequire(import.meta.url) const projectDir = process.cwd() +let cliOptions +try { + cliOptions = readCliOptions(process.argv.slice(2)) +} catch (error) { + console.error(`[rebuild] ${formatError(error)}`) + process.exit(2) +} +const rebuildPlatform = cliOptions.platform ?? osPlatform() +const rebuildArch = cliOptions.arch ?? process.arch +const electronPackageDir = resolve(projectDir, 'node_modules/electron') const electronVersion = JSON.parse( - readFileSync(resolve(projectDir, 'node_modules/electron/package.json'), 'utf8') + readFileSync(resolve(electronPackageDir, 'package.json'), 'utf8') ).version -const ignoreModules = process.platform === 'win32' ? ['cpu-features'] : [] +const ignoreModules = ['cpu-features'] if (ignoreModules.length > 0) { - console.log(`[rebuild] Skipping modules on Windows: ${ignoreModules.join(', ')}`) + console.log(`[rebuild] Skipping optional Electron rebuild modules: ${ignoreModules.join(', ')}`) } // Why: @electron/rebuild's default module walker doesn't reliably find native // modules inside pnpm's .pnpm/ store. Passing an explicit list of modules to // rebuild via `onlyModules` ensures they're recompiled against Electron's Node // ABI regardless of the package manager's store layout. -const NATIVE_MODULES = ['better-sqlite3', 'node-pty', 'cpu-features'] +const NATIVE_MODULES = ['node-pty', 'cpu-features'] const onlyModules = NATIVE_MODULES.filter((m) => !ignoreModules.includes(m)) -const forceRebuild = process.env.ORCA_FORCE_NATIVE_REBUILD === '1' +const forceRebuild = + process.env.ORCA_FORCE_NATIVE_REBUILD === '1' || + cliOptions.force || + rebuildPlatform !== osPlatform() || + rebuildArch !== process.arch ensureElectronPackageInstalled() @@ -61,7 +72,7 @@ if (!forceRebuild) { console.log(probe.stderr.trim()) } } else { - console.log('[rebuild] ORCA_FORCE_NATIVE_REBUILD=1 set; forcing native rebuild.') + console.log(`[rebuild] Forcing native rebuild for ${rebuildPlatform}-${rebuildArch}.`) } // Why: cpu-features ships without `buildcheck.gypi`; its own `install` script @@ -98,6 +109,8 @@ try { await rebuild({ buildPath: projectDir, electronVersion, + platform: rebuildPlatform, + arch: rebuildArch, ignoreModules, onlyModules, // Why: without force, @electron/rebuild skips modules it considers @@ -127,18 +140,13 @@ try { } function ensureElectronPackageInstalled() { - try { - require('electron') + if (electronPackageIsUsable()) { return - } catch (/** @type {any} */ err) { - if (!isElectronPackageInstallError(err)) { - throw err - } } // Why: CI has observed Electron's postinstall exiting cleanly without - // writing path.txt; use our strict installer instead of Electron's install.js - // so partial extracts and poisoned caches are rejected before native rebuild. + // writing path.txt. Electron 42's lazy require() would run install.js here, + // so inspect dist/ directly and keep using our strict partial-extract checks. console.log('[rebuild] Electron package binary is missing; installing Electron package binary.') resetPartialElectronInstall() try { @@ -152,32 +160,35 @@ function ensureElectronPackageInstalled() { process.exit(1) } - try { - require('electron') - } catch (/** @type {any} */ err) { - if (!repairElectronPathFile()) { + if (!electronPackageIsUsable()) { + const repaired = repairElectronPathFile() + if (!repaired || !electronPackageIsUsable()) { logElectronInstallDiagnostics() if (continuePostinstallWithoutElectron()) { process.exit(0) } - console.error( - '[rebuild] Electron package is still unavailable after retry:', - err?.message ?? err - ) - process.exit(1) - } - try { - require('electron') - } catch (/** @type {any} */ retryErr) { - console.error( - '[rebuild] Electron package is still unavailable after repairing path.txt:', - retryErr?.message ?? retryErr - ) + console.error('[rebuild] Electron package is still unavailable after retry.') process.exit(1) } } } +function electronPackageIsUsable() { + try { + const installedVersion = readFileSync(resolve(electronPackageDir, 'dist', 'version'), 'utf8') + .trim() + .replace(/^v/, '') + const installedPlatformPath = readFileSync(resolve(electronPackageDir, 'path.txt'), 'utf8') + return ( + installedVersion === electronVersion && + installedPlatformPath === getElectronPlatformPath() && + existsSync(getElectronExecutablePath()) + ) + } catch { + return false + } +} + function runElectronPackageBinaryInstall() { const env = { ...process.env } delete env.ELECTRON_SKIP_BINARY_DOWNLOAD @@ -204,7 +215,6 @@ function runElectronPackageBinaryInstall() { } function resetPartialElectronInstall() { - const electronPackageDir = resolve(projectDir, 'node_modules/electron') // Why: Electron's installer can leave a partial dist/ tree behind after // skipped or interrupted postinstall runs; retry from a clean target. rmSync(resolve(electronPackageDir, 'dist'), { recursive: true, force: true }) @@ -224,12 +234,8 @@ function continuePostinstallWithoutElectron() { } function repairElectronPathFile() { - const electronPackageDir = resolve(projectDir, 'node_modules/electron') const platformPath = getElectronPlatformPath() - const electronPath = process.env.ELECTRON_OVERRIDE_DIST_PATH - ? resolve(process.env.ELECTRON_OVERRIDE_DIST_PATH, platformPath) - : resolve(electronPackageDir, 'dist', platformPath) - if (!existsSync(electronPath)) { + if (!existsSync(getElectronExecutablePath())) { return false } @@ -242,7 +248,6 @@ function repairElectronPathFile() { } function logElectronInstallDiagnostics() { - const electronPackageDir = resolve(projectDir, 'node_modules/electron') const electronDistDir = resolve(electronPackageDir, 'dist') const pathFile = resolve(electronPackageDir, 'path.txt') console.error('[rebuild] Electron install diagnostics:') @@ -263,7 +268,8 @@ function safeReaddir(targetPath) { } function getElectronPlatformPath() { - const targetPlatform = process.env.npm_config_platform || osPlatform() + const targetPlatform = + process.env.ELECTRON_INSTALL_PLATFORM || process.env.npm_config_platform || rebuildPlatform switch (targetPlatform) { case 'mas': case 'darwin': @@ -279,13 +285,63 @@ function getElectronPlatformPath() { } } -function probeElectronNativeModules(moduleNames) { - let electronExecutable - try { - electronExecutable = require('electron') - } catch (error) { - return { ok: false, status: null, stderr: formatError(error) } +function readCliOptions(args) { + const options = { force: false } + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] + if (arg === '--force') { + options.force = true + continue + } + if (arg === '--platform') { + options.platform = readRequiredArgValue(args, (index += 1), '--platform') + continue + } + if (arg.startsWith('--platform=')) { + options.platform = readInlineArgValue(arg, '--platform') + continue + } + if (arg === '--arch') { + options.arch = readRequiredArgValue(args, (index += 1), '--arch') + continue + } + if (arg.startsWith('--arch=')) { + options.arch = readInlineArgValue(arg, '--arch') + continue + } + throw new Error(`Unknown argument: ${arg}`) } + return options +} + +function readRequiredArgValue(args, index, flag) { + const value = args[index] + if (!value || value.startsWith('--')) { + throw new Error(`Missing value for ${flag}`) + } + return value +} + +function readInlineArgValue(arg, flag) { + const value = arg.slice(`${flag}=`.length) + if (!value) { + throw new Error(`Missing value for ${flag}`) + } + return value +} + +function getElectronExecutablePath() { + const platformPath = getElectronPlatformPath() + return process.env.ELECTRON_OVERRIDE_DIST_PATH + ? resolve(process.env.ELECTRON_OVERRIDE_DIST_PATH, platformPath) + : resolve(electronPackageDir, 'dist', platformPath) +} + +function probeElectronNativeModules(moduleNames) { + if (!electronPackageIsUsable()) { + return { ok: false, status: null, stderr: 'Electron package binary is unavailable.' } + } + const electronExecutable = getElectronExecutablePath() const probeSource = ` const { createRequire } = require('node:module') @@ -309,12 +365,6 @@ if (failures.length > 0) { } function loadNativeModule(moduleName) { - if (moduleName === 'better-sqlite3') { - const Database = projectRequire(moduleName) - const db = new Database(':memory:') - db.close() - return - } if (moduleName === 'node-pty') { projectRequire('node-pty') const { loadNativeModule } = projectRequire('node-pty/lib/utils') @@ -373,10 +423,6 @@ function isPostinstall() { return process.env.npm_lifecycle_event === 'postinstall' } -function isElectronPackageInstallError(error) { - return /Electron failed to install correctly/i.test(formatError(error)) -} - function formatError(error) { return error instanceof Error ? error.message : String(error) } diff --git a/config/scripts/release-rc-history.mjs b/config/scripts/release-rc-history.mjs new file mode 100644 index 00000000000..e3cdea2d870 --- /dev/null +++ b/config/scripts/release-rc-history.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process' +import { pathToFileURL } from 'node:url' + +function gitLines(args, cwd) { + try { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'] + }) + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + } catch { + return [] + } +} + +export function rcNumberFromTag(base, tag) { + const prefix = `v${base}-rc.` + if (!tag.startsWith(prefix)) { + return null + } + + const suffix = tag.slice(prefix.length) + return /^\d+$/.test(suffix) ? Number(suffix) : null +} + +export function rcNumberFromReleaseSubject(base, subject) { + const prefix = `release: v${base}-rc.` + if (!subject.startsWith(prefix)) { + return null + } + + const match = /^(\d+)(?:\s|$)/.exec(subject.slice(prefix.length)) + return match ? Number(match[1]) : null +} + +export function highestRcForBase(base, { cwd = process.cwd() } = {}) { + const numbers = [] + + for (const tag of gitLines(['tag', '--list', `v${base}-rc.*`], cwd)) { + const rcNumber = rcNumberFromTag(base, tag) + if (rcNumber !== null) { + numbers.push(rcNumber) + } + } + + const logRefs = ['HEAD', 'origin/main'].filter( + (ref) => gitLines(['rev-parse', '--verify', '--quiet', ref], cwd).length > 0 + ) + if (logRefs.length > 0) { + for (const subject of gitLines(['log', '--format=%s', ...logRefs], cwd)) { + const rcNumber = rcNumberFromReleaseSubject(base, subject) + if (rcNumber !== null) { + numbers.push(rcNumber) + } + } + } + + return numbers.length === 0 ? null : Math.max(...numbers) +} + +function main() { + const base = process.argv[2] + if (!base) { + throw new Error('Usage: node config/scripts/release-rc-history.mjs ') + } + + const highest = highestRcForBase(base) + if (highest !== null) { + process.stdout.write(String(highest)) + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + main() + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + } +} diff --git a/config/scripts/release-rc-history.test.mjs b/config/scripts/release-rc-history.test.mjs new file mode 100644 index 00000000000..7ceb28f3410 --- /dev/null +++ b/config/scripts/release-rc-history.test.mjs @@ -0,0 +1,78 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + highestRcForBase, + rcNumberFromReleaseSubject, + rcNumberFromTag +} from './release-rc-history.mjs' + +function git(cwd, args) { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }).trim() +} + +function withGitRepo(run) { + const dir = mkdtempSync(join(tmpdir(), 'orca-rc-history-')) + try { + git(dir, ['init', '--initial-branch=main']) + git(dir, ['config', 'user.name', 'Test Bot']) + git(dir, ['config', 'user.email', 'test@example.com']) + run(dir) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +} + +function commit(cwd, message, { allowEmpty = true } = {}) { + const args = ['commit', '-m', message] + if (allowEmpty) { + args.splice(1, 0, '--allow-empty') + } + git(cwd, args) +} + +describe('release RC history', () => { + it('parses only exact desktop RC tag suffixes', () => { + expect(rcNumberFromTag('1.4.36', 'v1.4.36-rc.7')).toBe(7) + expect(rcNumberFromTag('1.4.36', 'v1.4.36-rc.7-extra')).toBeNull() + expect(rcNumberFromTag('1.4.36', 'v1.4.35-rc.7')).toBeNull() + }) + + it('parses release commit subjects with optional slot markers', () => { + expect(rcNumberFromReleaseSubject('1.4.36', 'release: v1.4.36-rc.6')).toBe(6) + expect( + rcNumberFromReleaseSubject('1.4.36', 'release: v1.4.36-rc.6 [rc-slot:2026-05-30-03]') + ).toBe(6) + expect(rcNumberFromReleaseSubject('1.4.36', 'release: v1.4.36-rc.6-extra')).toBeNull() + expect(rcNumberFromReleaseSubject('1.4.36', 'fix: v1.4.36-rc.6')).toBeNull() + }) + + it('keeps RC numbers monotonic after a stale tag is deleted', () => { + withGitRepo((repo) => { + commit(repo, 'initial') + commit(repo, 'release: v1.4.36-rc.5') + git(repo, ['tag', 'v1.4.36-rc.5']) + commit(repo, 'release: v1.4.36-rc.6') + + expect(highestRcForBase('1.4.36', { cwd: repo })).toBe(6) + }) + }) + + it('considers origin/main when releasing from an older ref', () => { + withGitRepo((repo) => { + commit(repo, 'initial') + git(repo, ['update-ref', 'refs/remotes/origin/main', 'HEAD']) + commit(repo, 'release: v1.4.36-rc.6') + git(repo, ['update-ref', 'refs/remotes/origin/main', 'HEAD']) + git(repo, ['checkout', 'HEAD~1']) + + expect(highestRcForBase('1.4.36', { cwd: repo })).toBe(6) + }) + }) +}) diff --git a/config/scripts/run-electron-vite-dev.mjs b/config/scripts/run-electron-vite-dev.mjs index 8dde69e7733..a818f3e64bf 100644 --- a/config/scripts/run-electron-vite-dev.mjs +++ b/config/scripts/run-electron-vite-dev.mjs @@ -241,12 +241,7 @@ function ensureRelativeSymlink(linkPath, target) { } function restoreElectronFrameworkSymlinks(appPath) { - const frameworkPath = path.join( - appPath, - 'Contents', - 'Frameworks', - 'Electron Framework.framework' - ) + const frameworkPath = path.join(appPath, 'Contents', 'Frameworks', 'Electron Framework.framework') const versionsPath = path.join(frameworkPath, 'Versions') if (!existsSync(path.join(versionsPath, 'A'))) { return diff --git a/config/scripts/smoke-packaged-cli.mjs b/config/scripts/smoke-packaged-cli.mjs new file mode 100644 index 00000000000..6283e54b2b8 --- /dev/null +++ b/config/scripts/smoke-packaged-cli.mjs @@ -0,0 +1,46 @@ +import { cp, mkdtemp, rm } from 'node:fs/promises' +import { execFile } from 'node:child_process' +import { tmpdir } from 'node:os' +import { basename, join, resolve } from 'node:path' +import { promisify } from 'node:util' + +const execFileAsync = promisify(execFile) + +function readAppDirArg(argv) { + const explicit = argv.find((arg) => arg.startsWith('--app-dir=')) + if (explicit) { + return explicit.slice('--app-dir='.length) + } + if (process.platform === 'darwin') { + return 'dist/mac-arm64/Orca.app' + } + if (process.platform === 'win32') { + return 'dist/win-unpacked' + } + return 'dist/linux-unpacked' +} + +function getPackagedCliPath(appDir) { + if (process.platform === 'darwin' || appDir.endsWith('.app')) { + return join(appDir, 'Contents', 'Resources', 'bin', 'orca') + } + if (process.platform === 'win32') { + return join(appDir, 'resources', 'bin', 'orca.cmd') + } + return join(appDir, 'resources', 'bin', 'orca-ide') +} + +const appDir = resolve(readAppDirArg(process.argv.slice(2))) +const tempRoot = await mkdtemp(join(tmpdir(), 'orca-packaged-cli-smoke-')) +const copiedAppDir = join(tempRoot, basename(appDir)) + +try { + await cp(appDir, copiedAppDir, { recursive: true, verbatimSymlinks: true }) + const cliPath = getPackagedCliPath(copiedAppDir) + await execFileAsync(cliPath, ['--help'], { + env: { ...process.env, NODE_PATH: '' } + }) + console.log(`[packaged-cli-smoke] ${cliPath} --help succeeded outside the repo`) +} finally { + await rm(tempRoot, { recursive: true, force: true }) +} diff --git a/config/scripts/styled-scrollbars/styled-scrollbar-jsx-check.mjs b/config/scripts/styled-scrollbars/styled-scrollbar-jsx-check.mjs index 0ca8a25aabc..4dab6f3b687 100644 --- a/config/scripts/styled-scrollbars/styled-scrollbar-jsx-check.mjs +++ b/config/scripts/styled-scrollbars/styled-scrollbar-jsx-check.mjs @@ -138,6 +138,20 @@ function expressionHasStyledScrollbarLiteral(node) { hasStyledScrollbar = true return } + // Why: a scrollbar literal that only renders on some branches must not be + // treated as covering an unconditional inline overflow. Skip conditional + // and short-circuit expressions when proving unconditional coverage. + if (ts.isConditionalExpression(current)) { + return + } + if ( + ts.isBinaryExpression(current) && + (current.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken || + current.operatorToken.kind === ts.SyntaxKind.BarBarToken || + current.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken) + ) { + return + } ts.forEachChild(current, visit) } @@ -246,7 +260,12 @@ function jsxElementReports(node, filePath, sourceFile) { for (const attribute of node.attributes.properties) { if (ts.isJsxSpreadAttribute(attribute)) { - classExpression ??= spreadPropExpressions(attribute.expression, 'className').at(-1) + // Why: at runtime React applies attributes in source order, so a later + // spread that supplies className overrides an earlier explicit className. + const spreadClassExpression = spreadPropExpressions(attribute.expression, 'className').at(-1) + if (spreadClassExpression) { + classExpression = spreadClassExpression + } styleExpressions.push(...spreadPropExpressions(attribute.expression, 'style')) } else if (jsxAttributeName(attribute) === 'className') { classExpression = jsxAttributeExpression(attribute) diff --git a/config/scripts/terminal-e2e-helpers.mjs b/config/scripts/terminal-e2e-helpers.mjs index 09266f1fe72..d410523c430 100644 --- a/config/scripts/terminal-e2e-helpers.mjs +++ b/config/scripts/terminal-e2e-helpers.mjs @@ -16,7 +16,7 @@ * await term.connect() * const ptyId = await term.discoverActivePtyId() * await term.send(ptyId, 'echo hello\r') - * const screenshot = await term.screenshot('/tmp/test.png') + * const screenshot = await term.screenshot() * await term.waitForOutput(ptyId, 'hello') * * Or run directly: @@ -24,8 +24,19 @@ */ import { execFileSync } from 'child_process' +import { tmpdir } from 'node:os' +import { join } from 'node:path' const AGENT_BROWSER = 'agent-browser' +const sleepBuffer = new Int32Array(new SharedArrayBuffer(4)) + +function sleep(ms) { + Atomics.wait(sleepBuffer, 0, 0, ms) +} + +function tempScreenshotPath(filename) { + return join(tmpdir(), filename) +} /** Thin wrapper around `agent-browser --cdp `. */ function ab(port, args) { @@ -77,7 +88,7 @@ export class OrcaTerminal { evalInRenderer(this.port, js) // Wait for output to render - execFileSync('sleep', ['1.5']) + sleep(1_500) // Read the visible xterm buffer to find which marker appeared const bufferJs = ` @@ -129,14 +140,11 @@ export class OrcaTerminal { * @param {number} maxId — highest PTY ID to probe * @param {string} screenshotPath — where to save the screenshot */ - probePtyIdWithScreenshot(maxId = 10, screenshotPath = '/tmp/orca-pty-probe.png') { + probePtyIdWithScreenshot(maxId = 10, screenshotPath = tempScreenshotPath('orca-pty-probe.png')) { for (let i = 1; i <= maxId; i++) { - evalInRenderer( - this.port, - `window.api.pty.write('${i}', '\\x03\\x15echo PTY_ID_${i}\\r')` - ) + evalInRenderer(this.port, `window.api.pty.write('${i}', '\\x03\\x15echo PTY_ID_${i}\\r')`) } - execFileSync('sleep', ['2']) + sleep(2_000) this.screenshot(screenshotPath) return screenshotPath } @@ -179,7 +187,7 @@ export class OrcaTerminal { * @param {string} path — output file path * @returns {string} the screenshot path */ - screenshot(path = '/tmp/orca-terminal.png') { + screenshot(path = tempScreenshotPath('orca-terminal.png')) { ab(this.port, ['screenshot', path]) return path } @@ -190,7 +198,7 @@ export class OrcaTerminal { */ newTerminal() { ab(this.port, ['click', '@e7']) // "New terminal (Cmd+T)" button - execFileSync('sleep', ['2']) + sleep(2_000) } /** @@ -201,9 +209,9 @@ export class OrcaTerminal { */ readLang(ptyId) { this.exec(ptyId, 'echo __LANG__=$LANG') - execFileSync('sleep', ['1']) + sleep(1_000) // Screenshot and return for inspection - return this.screenshot('/tmp/orca-lang-check.png') + return this.screenshot(tempScreenshotPath('orca-lang-check.png')) } } diff --git a/config/scripts/verify-cli-bin.mjs b/config/scripts/verify-cli-bin.mjs new file mode 100755 index 00000000000..8f16597e3c0 --- /dev/null +++ b/config/scripts/verify-cli-bin.mjs @@ -0,0 +1,69 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process' +import { chmodSync, readFileSync, statSync } from 'node:fs' +import path from 'node:path' +import { pathToFileURL } from 'node:url' + +export function verifyPackageCliBin({ + projectDir = path.resolve(import.meta.dirname, '..', '..'), + fixExecutable = false, + runHelp = false +} = {}) { + const packageJsonPath = path.join(projectDir, 'package.json') + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) + const binTarget = packageJson.bin?.orca + if (typeof binTarget !== 'string' || binTarget.length === 0) { + throw new Error('package.json must declare bin.orca') + } + + const binPath = path.resolve(projectDir, binTarget) + const stats = statSync(binPath) + if (!stats.isFile()) { + throw new Error(`bin.orca target is not a file: ${binTarget}`) + } + if (stats.size === 0) { + throw new Error(`bin.orca target is empty: ${binTarget}`) + } + + const content = readFileSync(binPath, 'utf8') + if (!content.startsWith('#!/usr/bin/env node\n')) { + throw new Error(`bin.orca target must start with a Node shebang: ${binTarget}`) + } + + if (process.platform !== 'win32' && (stats.mode & 0o111) === 0) { + if (!fixExecutable) { + throw new Error(`bin.orca target is not executable: ${binTarget}`) + } + chmodSync(binPath, stats.mode | 0o755) + } + + if (runHelp) { + execFileSync(process.execPath, [binPath, '--help'], { + cwd: projectDir, + stdio: 'ignore' + }) + } + + return { binPath, size: statSync(binPath).size } +} + +function main() { + const args = new Set(process.argv.slice(2)) + const result = verifyPackageCliBin({ + fixExecutable: args.has('--fix-executable'), + runHelp: args.has('--run-help') + }) + console.log( + `[cli-bin] verified ${path.relative(process.cwd(), result.binPath)} (${result.size} bytes)` + ) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + main() + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + } +} diff --git a/config/scripts/verify-cli-bin.test.mjs b/config/scripts/verify-cli-bin.test.mjs new file mode 100644 index 00000000000..ebbac6e3326 --- /dev/null +++ b/config/scripts/verify-cli-bin.test.mjs @@ -0,0 +1,57 @@ +import { chmodSync, mkdirSync, readFileSync, statSync, writeFileSync, mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { verifyPackageCliBin } from './verify-cli-bin.mjs' + +function makeProjectWithCli(content, mode = 0o755) { + const projectDir = mkdtempSync(path.join(tmpdir(), 'orca-cli-bin-')) + const cliPath = path.join(projectDir, 'out', 'cli', 'index.js') + mkdirSync(path.dirname(cliPath), { recursive: true }) + writeFileSync( + path.join(projectDir, 'package.json'), + JSON.stringify({ bin: { orca: './out/cli/index.js' } }), + 'utf8' + ) + writeFileSync(cliPath, content, 'utf8') + if (process.platform !== 'win32') { + chmodSync(cliPath, mode) + } + return { projectDir, cliPath } +} + +describe('verifyPackageCliBin', () => { + it('accepts a non-empty Node entrypoint and can run help through Node', () => { + const { projectDir, cliPath } = makeProjectWithCli( + '#!/usr/bin/env node\nif (process.argv.includes("--help")) process.exit(0)\n' + ) + + expect(verifyPackageCliBin({ projectDir, runHelp: true })).toMatchObject({ + binPath: cliPath + }) + }) + + it('rejects an empty package bin target', () => { + const { projectDir } = makeProjectWithCli('') + + expect(() => verifyPackageCliBin({ projectDir })).toThrow('bin.orca target is empty') + }) + + it('rejects package bin targets without a Node shebang', () => { + const { projectDir } = makeProjectWithCli('console.log("orca")\n') + + expect(() => verifyPackageCliBin({ projectDir })).toThrow('Node shebang') + }) + + it.skipIf(process.platform === 'win32')('can repair the POSIX executable bit', () => { + const { projectDir, cliPath } = makeProjectWithCli( + '#!/usr/bin/env node\nconsole.log("orca")\n', + 0o644 + ) + + expect(() => verifyPackageCliBin({ projectDir })).toThrow('not executable') + verifyPackageCliBin({ projectDir, fixExecutable: true }) + expect(statSync(cliPath).mode & 0o111).not.toBe(0) + expect(readFileSync(cliPath, 'utf8')).toContain('#!/usr/bin/env node') + }) +}) diff --git a/config/scripts/verify-telemetry-constants.mjs b/config/scripts/verify-telemetry-constants.mjs index 24f36563532..036778f00ad 100644 --- a/config/scripts/verify-telemetry-constants.mjs +++ b/config/scripts/verify-telemetry-constants.mjs @@ -170,9 +170,7 @@ function verifyAsar(asarPath) { const writeKeyMatch = WRITE_KEY_RE.exec(indexJs) if (!buildIdentityMatch) { - console.error( - `::error::BUILD_IDENTITY constant missing or unexpected value in ${asarPath}` - ) + console.error(`::error::BUILD_IDENTITY constant missing or unexpected value in ${asarPath}`) const sample = indexJs.match(/.{0,80}BUILD_IDENTITY.{0,80}/g)?.slice(0, 5) ?? [] for (const line of sample) { console.error(` ${line.slice(0, 200)}`) @@ -213,9 +211,7 @@ for (const asarPath of asarMatches) { const distinctIdentities = new Set(results.map((r) => r.buildIdentity)) if (distinctIdentities.size > 1) { - console.error( - `::error::asars disagree on BUILD_IDENTITY: ${[...distinctIdentities].join(', ')}` - ) + console.error(`::error::asars disagree on BUILD_IDENTITY: ${[...distinctIdentities].join(', ')}`) for (const r of results) { console.error(` - ${r.asarPath}: ${r.buildIdentity}`) } diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 6347e646ca8..e606895e682 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -22,6 +22,7 @@ "../src/main/gemini/hook-service.ts", "../src/main/grok/hook-service.ts", "../src/main/hermes/hook-service.ts", + "../src/main/openclaude/hook-service.ts", "../src/main/runtime/runtime-metadata.ts", "../src/main/win32-utils.ts" ], diff --git a/config/tsconfig.web.json b/config/tsconfig.web.json index 844bf185d3c..92a6b9f2831 100644 --- a/config/tsconfig.web.json +++ b/config/tsconfig.web.json @@ -10,10 +10,9 @@ "compilerOptions": { "composite": true, "jsx": "react-jsx", - "baseUrl": "..", "paths": { - "@renderer/*": ["src/renderer/src/*"], - "@/*": ["src/renderer/src/*"] + "@renderer/*": ["../src/renderer/src/*"], + "@/*": ["../src/renderer/src/*"] } } } diff --git a/docs/STYLEGUIDE.md b/docs/STYLEGUIDE.md index d948ab90781..2fe82789657 100644 --- a/docs/STYLEGUIDE.md +++ b/docs/STYLEGUIDE.md @@ -1,6 +1,6 @@ # Orca UI Style Guide -This is the **UI/visual design** doc for Orca — color tokens, typography, component selection, and UX rules. It is *not* an architecture doc; for system-level design see code and inline comments. Token values live in `src/renderer/src/assets/main.css` (canonical); this file documents the *roles and rules* for using them. +This is the **UI/visual design** doc for Orca — color tokens, typography, component selection, and UX rules. It is _not_ an architecture doc; for system-level design see code and inline comments. Token values live in `src/renderer/src/assets/main.css` (canonical); this file documents the _roles and rules_ for using them. ## Overview @@ -14,12 +14,12 @@ When in doubt: ## Source of truth -| Concern | Canonical location | -| -------------------- | ------------------------------------------------ | -| Color tokens | `src/renderer/src/assets/main.css` (`:root`, `.dark`) | -| Tailwind theme bindings | Same file, `@theme inline { … }` block | -| Component primitives | `src/renderer/src/components/ui/` (shadcn-style) | -| App typography / scrollbars / titlebar chrome | Same `main.css` | +| Concern | Canonical location | +| --------------------------------------------- | ----------------------------------------------------- | +| Color tokens | `src/renderer/src/assets/main.css` (`:root`, `.dark`) | +| Tailwind theme bindings | Same file, `@theme inline { … }` block | +| Component primitives | `src/renderer/src/components/ui/` (shadcn-style) | +| App typography / scrollbars / titlebar chrome | Same `main.css` | Never hardcode a hex value in component code if a variable already covers it. If a new token is needed, add it to `main.css` (both `:root` and `.dark`), expose it in the `@theme inline` block, then use it. @@ -27,21 +27,21 @@ Never hardcode a hex value in component code if a variable already covers it. If Tokens come in pairs: a **surface** and a **foreground** that meets contrast on it. Always use them together. -| Role | Use it for | Don't use it for | -| ---------------------------------- | ------------------------------------------------------------- | --------------------------------------------- | -| `background` / `foreground` | App canvas, default text | Cards, popovers, sidebar (have their own) | -| `card` / `card-foreground` | Panels lifted off the canvas | The canvas itself | -| `popover` / `popover-foreground` | Floating menus, dropdowns, hovercards | Inline UI | -| `primary` / `primary-foreground` | The single affirmative action in a flow (Save, Confirm) | Decorative accents; hover states; secondary actions | -| `secondary` / `secondary-foreground` | Lower-emphasis actions next to a primary | The affirmative action | -| `muted` / `muted-foreground` | De-emphasized text, captions, placeholders, disabled chrome | Body copy; primary actions | -| `accent` / `accent-foreground` | Hover/active backgrounds for ghost buttons and list rows | Solid filled buttons (use `secondary` instead) | -| `destructive` / `destructive-foreground` | Delete, discard, irreversible-action buttons; error states | Cancel buttons (Cancel is not destructive) | -| `border` | All hairlines: dividers, input outlines, card edges | Heavy emphasis; that's `ring` | -| `input` | Form field background only | Anywhere outside form fields | -| `ring` | Focus-visible outlines, active selection halos | Persistent decoration | -| `sidebar` (+ variants) | The worktree sidebar and its children | Other panels | -| `editor-surface` | Background of Monaco / markdown editor panes | App chrome | +| Role | Use it for | Don't use it for | +| ---------------------------------------- | ----------------------------------------------------------- | --------------------------------------------------- | +| `background` / `foreground` | App canvas, default text | Cards, popovers, sidebar (have their own) | +| `card` / `card-foreground` | Panels lifted off the canvas | The canvas itself | +| `popover` / `popover-foreground` | Floating menus, dropdowns, hovercards | Inline UI | +| `primary` / `primary-foreground` | The single affirmative action in a flow (Save, Confirm) | Decorative accents; hover states; secondary actions | +| `secondary` / `secondary-foreground` | Lower-emphasis actions next to a primary | The affirmative action | +| `muted` / `muted-foreground` | De-emphasized text, captions, placeholders, disabled chrome | Body copy; primary actions | +| `accent` / `accent-foreground` | Hover/active backgrounds for ghost buttons and list rows | Solid filled buttons (use `secondary` instead) | +| `destructive` / `destructive-foreground` | Delete, discard, irreversible-action buttons; error states | Cancel buttons (Cancel is not destructive) | +| `border` | All hairlines: dividers, input outlines, card edges | Heavy emphasis; that's `ring` | +| `input` | Form field background only | Anywhere outside form fields | +| `ring` | Focus-visible outlines, active selection halos | Persistent decoration | +| `sidebar` (+ variants) | The worktree sidebar and its children | Other panels | +| `editor-surface` | Background of Monaco / markdown editor panes | App chrome | The `sidebar` family expands into `--sidebar`, `--sidebar-foreground`, `--sidebar-primary`, `--sidebar-primary-foreground`, `--sidebar-accent`, `--sidebar-accent-foreground`, `--sidebar-border`, and `--sidebar-ring` — use them inside the worktree sidebar so its hover/selected/focus states stay consistent and don't bleed into other panels. `editor-surface` is its own token (not just `background`) because Monaco and the markdown editor have a slightly darker surface in dark mode to match VS Code conventions; reach for it whenever you're rendering an editor pane. @@ -49,17 +49,17 @@ The `sidebar` family expands into `--sidebar`, `--sidebar-foreground`, `--sideba For diff status, file-tree decorations, and the changes view, use the git decoration tokens (mirroring VS Code's palette so users transferring from VS Code aren't surprised): -| Token | State | -| ------------------------------ | ------------------------------ | -| `--git-decoration-added` | Added / new | -| `--git-decoration-modified` | Modified | -| `--git-decoration-deleted` | Deleted | -| `--git-decoration-renamed` | Renamed | -| `--git-decoration-untracked` | Untracked | -| `--git-decoration-copied` | Copied | -| `--git-decoration-ignored` | Ignored by git | +| Token | State | +| ---------------------------- | -------------- | +| `--git-decoration-added` | Added / new | +| `--git-decoration-modified` | Modified | +| `--git-decoration-deleted` | Deleted | +| `--git-decoration-renamed` | Renamed | +| `--git-decoration-untracked` | Untracked | +| `--git-decoration-copied` | Copied | +| `--git-decoration-ignored` | Ignored by git | -Use these *only* for git status. Don't reuse them for unrelated state colors — that breaks the convention. +Use these _only_ for git status. Don't reuse them for unrelated state colors — that breaks the convention. ### List rows: hover, selected, current @@ -68,7 +68,7 @@ A common point of drift. Use these conventions for any list-style row (worktrees - **Idle:** transparent background. - **Hover:** `bg-accent` (in the worktree sidebar, `bg-sidebar-accent`). - **Keyboard-selected (cmdk highlight):** `data-[selected=true]:bg-accent` plus a `border-border` outline so the active row stays visible while the user types. The `data-selected` attribute is set by `cmdk` automatically. -- **Persistent "current" / "active" row** (e.g. the worktree the user is viewing): also `bg-accent`, *plus* a `data-current="true"` attribute so CSS or future styling can distinguish it from the cmdk highlight. +- **Persistent "current" / "active" row** (e.g. the worktree the user is viewing): also `bg-accent`, _plus_ a `data-current="true"` attribute so CSS or future styling can distinguish it from the cmdk highlight. - **Don't:** hardcode `bg-[#ededed]` / `bg-[#333333]` or invent a "selected" color. The accent token already adapts to light/dark and matches the rest of the app. ### Color mixing @@ -118,14 +118,14 @@ Use the shadcn primitives in `src/renderer/src/components/ui/` before writing an Variants in priority order: -| Variant | Use case | -| ------------- | ---------------------------------------------------------------- | -| `default` | The single affirmative action in a flow. | -| `secondary` | Lower-emphasis sibling next to a `default`. | -| `outline` | Toolbar / standalone actions where a filled button feels heavy. | +| Variant | Use case | +| ------------- | ------------------------------------------------------------------ | +| `default` | The single affirmative action in a flow. | +| `secondary` | Lower-emphasis sibling next to a `default`. | +| `outline` | Toolbar / standalone actions where a filled button feels heavy. | | `ghost` | Icon buttons, list-row triggers, anywhere chrome should disappear. | -| `link` | Inline text actions inside paragraphs. | -| `destructive` | Delete, discard, irreversible. Never for Cancel. | +| `link` | Inline text actions inside paragraphs. | +| `destructive` | Delete, discard, irreversible. Never for Cancel. | Sizes: `default` (36px), `sm` (32px), `xs` (24px), `lg` (40px), plus `icon`, `icon-xs`, `icon-sm`, `icon-lg`. Match the size to the surrounding row height — don't drop a `default` button into a 28px toolbar. @@ -137,26 +137,26 @@ Browse `src/renderer/src/components/ui/` for the full list. Most wrap a Radix UI When a control has multiple plausible primitives, use this fork: -| You want… | Reach for | Don't use | -| -------------------------------------------------------- | -------------------------- | ---------------------------------------- | -| Hover-only label on an icon-only button | `Tooltip` | `HoverCard` (too heavy), title attr | -| Hover preview of richer content (avatar + summary) | `HoverCard` | `Tooltip` (no rich content) | -| Click-revealed menu with actions | `DropdownMenu` | `Popover` with hand-rolled list | -| Right-click contextual actions | `ContextMenu` | `DropdownMenu` (different invocation) | -| Click-revealed surface with arbitrary content (form, picker) | `Popover` | `Dialog` (it traps focus and dims) | -| Modal that demands a decision before you continue | `Dialog` | `Popover`, inline overlay | -| Drawer / panel sliding in from an edge | `Sheet` | `Dialog` centered | -| Single choice from a known list | `Select` | Custom listbox | -| Single choice with search / fuzzy filtering | `Command` inside `Popover` | `Select` (no search) | -| Multi-select with search | `repo-multi-combobox` / `team-multi-combobox` (mirror their pattern) | Roll a new one | -| Transient confirmation ("Saved", "Copied") | `sonner` toast | `Dialog`, inline banner | -| Persistent inline status ("3 errors") | inline text + `Badge` | toast (toasts disappear) | +| You want… | Reach for | Don't use | +| ------------------------------------------------------------ | -------------------------------------------------------------------- | ------------------------------------- | +| Hover-only label on an icon-only button | `Tooltip` | `HoverCard` (too heavy), title attr | +| Hover preview of richer content (avatar + summary) | `HoverCard` | `Tooltip` (no rich content) | +| Click-revealed menu with actions | `DropdownMenu` | `Popover` with hand-rolled list | +| Right-click contextual actions | `ContextMenu` | `DropdownMenu` (different invocation) | +| Click-revealed surface with arbitrary content (form, picker) | `Popover` | `Dialog` (it traps focus and dims) | +| Modal that demands a decision before you continue | `Dialog` | `Popover`, inline overlay | +| Drawer / panel sliding in from an edge | `Sheet` | `Dialog` centered | +| Single choice from a known list | `Select` | Custom listbox | +| Single choice with search / fuzzy filtering | `Command` inside `Popover` | `Select` (no search) | +| Multi-select with search | `repo-multi-combobox` / `team-multi-combobox` (mirror their pattern) | Roll a new one | +| Transient confirmation ("Saved", "Copied") | `sonner` toast | `Dialog`, inline banner | +| Persistent inline status ("3 errors") | inline text + `Badge` | toast (toasts disappear) | If you find yourself styling around a primitive (`` to act like a ``, or vice versa), stop and reconsider — the focus-management semantics differ and a future contributor will be misled by the mismatch. ### Tooltips -Tooltips exist to *name* a control whose meaning isn't obvious from its appearance. They are not the place to teach, persuade, or warn — anything users need to read while acting belongs in the visible UI. +Tooltips exist to _name_ a control whose meaning isn't obvious from its appearance. They are not the place to teach, persuade, or warn — anything users need to read while acting belongs in the visible UI. - **Use a tooltip when:** an icon-only button or compact chip needs a label. Toolbar icons, badges with abbreviations, truncated paths. - **Don't use a tooltip when:** the control already has a visible label, the content is interactive (links, buttons), or the message is critical (errors, blocking warnings — those go inline). @@ -172,7 +172,9 @@ Tooltips exist to *name* a control whose meaning isn't obvious from its appearan - Settings + + Settings + ``` @@ -185,11 +187,11 @@ Icons come from **`lucide-react`**. Don't import a second icon library. - **`size-7`+:** for featured/empty-state hero icons only. - **Stroke width:** lucide's default 2px. Don't override per-icon. - **Color:** inherit from surrounding text — `text-muted-foreground` for secondary, `text-destructive` for destructive, etc. Don't apply a token to the SVG directly when the parent already carries the right color. -- **Spinner:** the canonical loading icon is ``. For 3s+ multi-step work, prefer a label that names the stage ("Cloning…" → "Installing…") over an unlabeled spinner. See *UX rule 1*. +- **Spinner:** the canonical loading icon is ``. For 3s+ multi-step work, prefer a label that names the stage ("Cloning…" → "Installing…") over an unlabeled spinner. See _UX rule 1_. ### Keyboard shortcut chips -Use **``** from `src/renderer/src/components/ShortcutKeyCombo.tsx`. It renders a consistent key-cap style and inserts a `+` separator on Windows/Linux (Mac shows adjacent glyphs, no separator). It does **not** transform key strings — the *caller* picks the platform-appropriate labels and passes them in: +Use **``** from `src/renderer/src/components/ShortcutKeyCombo.tsx`. It renders a consistent key-cap style and inserts a `+` separator on Windows/Linux (Mac shows adjacent glyphs, no separator). It does **not** transform key strings — the _caller_ picks the platform-appropriate labels and passes them in: ```tsx const isMac = navigator.userAgent.includes('Mac') @@ -204,7 +206,7 @@ See `Landing.tsx` for the canonical pattern. Don't roll a one-off `` — kb - **Tooltips on icon buttons** — append the chip after the label, trailing. - **Dropdown / context-menu items** — use `` (or its context-menu equivalent) for the right-aligned chip; don't position one yourself. -- **Never on Cancel, Dismiss, or `link`-variant inline actions** — see *UX rule 3*. +- **Never on Cancel, Dismiss, or `link`-variant inline actions** — see _UX rule 3_. **The label MUST match the actual binding for the platform.** If the keyboard handler reads `metaKey` on Mac and `ctrlKey` elsewhere, the chip must show `⌘` on Mac and `Ctrl` elsewhere. Mismatched chips are worse than no chip. @@ -235,25 +237,25 @@ These are the rules a contributor will most often get wrong if they're working i ### 1. Match in-flight feedback to perceived duration -The right question isn't *"should this control change while it's working?"* — it's *"how long does the action take, and what does the user need to know during that time?"* +The right question isn't _"should this control change while it's working?"_ — it's _"how long does the action take, and what does the user need to know during that time?"_ -| Duration | Feedback | -| ------------------- | ------------------------------------------------------- | -| 0–100 ms | None. Anything visible reads as a glitch. | -| 100 ms–1 s | Disabled state only. | -| 1 s–3 s | Disabled + spinner or label swap. | -| 3 s+ or multi-step | Stage labels, progress, optional reassurance. | +| Duration | Feedback | +| ------------------ | --------------------------------------------- | +| 0–100 ms | None. Anything visible reads as a glitch. | +| 100 ms–1 s | Disabled state only. | +| 1 s–3 s | Disabled + spinner or label swap. | +| 3 s+ or multi-step | Stage labels, progress, optional reassurance. | Two corollaries: - **Pre-reserve any space you'll later occupy.** If a control may swap to a longer label or grow an icon, fix its footprint up front (use `width`, not `min-width`). A control that resizes mid-action looks broken even when the action succeeded. -- **Don't pick worst-case feedback for everyone.** If the action is fast locally and slow remotely (SSH), defer the visible loading state by ~200ms. Local users see nothing; remote users get appropriate feedback. Bind the *disabled* state immediately (so double-clicks don't double-submit) and the *visible* state on a timer. +- **Don't pick worst-case feedback for everyone.** If the action is fast locally and slow remotely (SSH), defer the visible loading state by ~200ms. Local users see nothing; remote users get appropriate feedback. Bind the _disabled_ state immediately (so double-clicks don't double-submit) and the _visible_ state on a timer. ### 2. Look for sibling components before designing in isolation If your component has a sibling — same domain, overlapping behavior, often visible at adjacent moments in the same flow — the two should read as one design. Same icons, same shortcut conventions, same submit semantics. A user moving between them shouldn't perceive a seam. -This is *not* "match every existing pattern." Some repo patterns are debt and copying them spreads the debt. The narrower claim is about *adjacent* components. Diverging from a sibling needs a reason: either the sibling is wrong (fix both) or the new component has a real difference in role (commit to it). +This is _not_ "match every existing pattern." Some repo patterns are debt and copying them spreads the debt. The narrower claim is about _adjacent_ components. Diverging from a sibling needs a reason: either the sibling is wrong (fix both) or the new component has a real difference in role (commit to it). When there's no sibling, match the surrounding chrome — button sizes, icon weights, copy tone — and don't manufacture a sibling from a screen the user will never correlate with this one. @@ -268,7 +270,7 @@ Orca runs on macOS, Linux, and Windows. Every UI change must hold up on all thre - **Modifier keys:** Never hardcode `e.metaKey`. Use `navigator.userAgent.includes('Mac')` to choose `metaKey` on Mac and `ctrlKey` on Linux/Windows. Electron menu accelerators should use `CmdOrCtrl`. - **Shortcut labels:** Display `⌘` / `⇧` on Mac; display `Ctrl+` / `Shift+` on other platforms. The label must reflect the actual binding for that platform. - **Window chrome:** macOS shows traffic lights; the titlebar reserves an 80px gutter (`titlebar-traffic-light-pad`) so they don't overlap content. Don't put hit targets in that band on Mac. -- **SSH:** Many users run Orca on a remote machine. Loading states, focus management, and animations must hold up under 50–200 ms of extra latency. Test under simulated latency (or actual SSH) — local-only verification isn't enough. See *UX rules → 1*. +- **SSH:** Many users run Orca on a remote machine. Loading states, focus management, and animations must hold up under 50–200 ms of extra latency. Test under simulated latency (or actual SSH) — local-only verification isn't enough. See _UX rules → 1_. ## When this guide is silent diff --git a/docs/floating-terminal-panel-position-persistence.md b/docs/floating-terminal-panel-position-persistence.md new file mode 100644 index 00000000000..82ef799eaf5 --- /dev/null +++ b/docs/floating-terminal-panel-position-persistence.md @@ -0,0 +1,73 @@ +# Floating Terminal Panel Position Persistence + +## Problem + +The floating workspace panel appears in a different default location after app restart instead of returning to the user's last dragged or resized placement. + +- `src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx:132` initializes panel `bounds` from `getDefaultFloatingTerminalBounds()` on every renderer mount. +- `src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx:349` only normalizes that in-memory initial state when the panel opens; it does not read or write durable position state. +- `src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx:1039` updates bounds while dragging, and `src/renderer/src/components/floating-terminal/FloatingTerminalResizeHandles.tsx:98` updates bounds while resizing, but both changes remain React state only. +- The toggle button already persists its own location through localStorage in `src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.tsx:33`, so the inconsistency is isolated to the larger panel. + +## Root Cause + +Panel geometry is transient renderer state. Restarting Orca remounts `FloatingTerminalPanel`, so the panel recomputes from the current viewport instead of restoring the last user placement. The component also has no source tracking, so its legacy right-gap normalization cannot distinguish a default position from an intentional user drag. + +## Non-Goals + +- Do not change terminal, browser, or markdown tab persistence. +- Do not persist remote or SSH-specific state; panel geometry is local renderer chrome. +- Do not persist maximized panel bounds as the normal restored size. +- Do not add new settings UI or change design tokens. + +## Design + +1. Add floating panel bounds persistence helpers beside the existing panel bounds math: + - use a panel-specific versioned key, e.g. `orca-floating-terminal-panel-bounds-v1`; + - parse only finite `left`, `top`, `width`, and `height` numbers; + - distinguish `default` versus `user` bounds sources; + - expose a panel viewport-usability guard so saved user bounds are not clamped against Electron's transient zero-sized startup viewport; + - expand clamping to normalize both position and size. The current `clampFloatingTerminalBounds` only clamps `left` and `top`; persisted restores also need the resize-handle width/height caps. +2. Read and write storage defensively: + - `window` absence or `localStorage` get/set failures fall back to in-memory behavior for the session; + - malformed, partial, non-finite, or non-object JSON falls back to default bounds. +3. Initialize `FloatingTerminalPanel` once from persisted bounds when present, otherwise from the current default. Store the initial source in a ref, mirroring the toggle button pattern. +4. Reconcile bounds in `useLayoutEffect` before first paint and on viewport resize: + - default-sourced bounds re-anchor to the current bottom-right default; + - user-sourced bounds clamp into the visible viewport and persist the clamped result only after the viewport is usable; + - remove `normalizedInitialBoundsRef` and the `rightGap > 160` reset. That heuristic is superseded by source-aware reconciliation and would otherwise wipe valid saved left-side placements. +5. Replace direct `setBounds` calls for user-driven placement changes with a panel-local geometry updater: + - drag and resize pointer moves should clamp and update React state for smooth feedback, but stage the latest bounds in a ref; + - pointer up and pointer cancel commit the staged bounds to storage only after movement produced staged geometry. `localStorage.setItem` is synchronous, so do not write on every pointer move or after a titlebar click with no movement; + - the panel-level `onMouseUp` size capture must not convert a default-sourced panel into a user-sourced panel after an ordinary click. Commit measured dimensions only after a real geometry interaction, or when the panel was already user-sourced; + - resize handles should receive explicit preview/commit callbacks instead of the raw React setter, so resize, drag, and restore use the same persistence rules. +6. When entering maximized mode, store the pre-maximized bounds and source in memory only. Do not persist the maximized rectangle. While maximized, viewport resize should recompute maximized bounds without touching the stored normal bounds or source. On restore, return to the stored normal bounds, reconcile them through the same source-aware rules, and persist only if the restored source is `user`. + +## Consistency Model + +- Persistence is a restart seed, not live cross-window synchronization. +- Multiple renderer windows share the same localStorage key and therefore use last-writer-wins across restarts/reloads. Do not subscribe to `storage` events for live updates; another window or DevTools edit should not move an open panel mid-drag. +- External storage mutations are picked up on the next renderer mount or reload. +- The state is local renderer chrome. It must not go through worktree settings, terminal state, or SSH-backed runtime APIs. + +## Edge Cases + +- Malformed or partial localStorage JSON falls back to default bounds. +- Unavailable localStorage should not break the floating workspace; persistence simply becomes best-effort. +- Startup can briefly report a zero-sized renderer; saved user bounds must not be clamped to that unusable viewport. +- A saved position from a larger monitor should be clamped back on-screen on the current monitor. +- A saved size larger than the current viewport should shrink to the largest size that still leaves a small visible margin while respecting minimum panel dimensions. +- A viewport smaller than the minimum panel size should keep the panel at the minimum size and keep its top-left corner reachable. +- Maximized mode should not overwrite the normal saved size and position. +- Plain clicks inside a default-positioned panel should not make that default position sticky forever. +- Pointer cancellation should commit the last valid drag or resize bounds, matching pointer-up behavior. +- SSH-backed terminals keep using the same floating workspace UI; geometry persistence stays local and does not assume local command execution. + +## Rollout + +1. Extend `floating-terminal-panel-bounds.ts` with parse, source, viewport usability, resolve, and size-aware clamp helpers plus focused unit coverage. +2. Wire `FloatingTerminalPanel` to read, reconcile, and persist panel bounds through those helpers; remove the legacy right-gap normalization. +3. Update `FloatingTerminalResizeHandles` to use preview/commit callbacks so drag, resize, measured-size capture, and maximize restore share one persistence path. +4. Add component coverage for persisted startup, bad storage, zero-sized startup deferral, default re-anchoring, user clamping, and the "plain click does not persist default" case. +5. Run focused Vitest coverage, then `pnpm typecheck` and `pnpm lint`. +6. Verify in Electron by moving/resizing the panel, reloading or restarting the dev app, checking maximized restore, and confirming evidence screenshots are not committed. diff --git a/docs/linear-scope-selector.md b/docs/linear-scope-selector.md new file mode 100644 index 00000000000..88e96a3432f --- /dev/null +++ b/docs/linear-scope-selector.md @@ -0,0 +1,126 @@ +# Linear Scope Selector + +## Problem + +- `src/renderer/src/components/TaskPage.tsx:4368` renders workspace selection, "open in Linear", and team selection as separate controls even though they define one Linear issue-list scope. +- `src/renderer/src/components/ui/team-multi-combobox.tsx:127` offers only "All teams" plus team rows. When a team is absent, there is no affordance that explains whether the key is team-limited, the user lacks private-team access, the team is archived/retired, or the backend failed to fetch every page. +- Settings and onboarding both say a workspace is connected, but the connect copy only points to personal API-key settings. It does not explain full-access keys, team-limited keys, member API-key restrictions, or private-team access. +- The backend already supports the core scope model: `workspaceId === 'all'` fans out across stored Linear clients, and team list calls are routed through the same runtime APIs used for local and SSH sessions. The gaps are renderer UX plus stale/incomplete data handling. + +## Goal + +Make Linear task scoping feel like one selector: + +1. Replace the separate workspace dropdown and team dropdown in the Tasks toolbar with one Linear scope popover. +2. Preserve the existing state model: `selectedWorkspaceId` is one workspace id or `all`; `defaultLinearTeamSelection` remains a nullable global list where `null` means sticky-all and an array means an explicit subset of team IDs. +3. Add an "Add team access" path from the selector. It opens a reusable API-key dialog. Pasting a key for an already connected workspace replaces that workspace token because the workspace id is stable. +4. Make the copy accurate: one full-access personal API key for a Linear workspace can cover every team the key owner can access in that workspace; restricted/team-limited keys only expose permitted teams; private teams require the key owner to be a member or otherwise have access. +5. Link to the most specific Linear settings URL Orca can build from `organizationUrlKey`, with global fallbacks when the workspace is unknown or `all`. + +## Non-goals + +- Do not change Linear authentication storage, encryption, IPC/RPC method names, or the `LinearWorkspaceSelection` type. +- Do not switch Linear auth to OAuth. +- Do not create, join, unarchive, or grant access to Linear teams from Orca. "Add team access" means paste a key whose owner/scope can already see the team. +- Do not make team selection per-workspace in this change. +- Do not touch provider-generic review code. + +## Required Fixes Before UI + +1. Fix team-list completeness. + - `src/main/linear/teams.ts` currently calls `entry.client.teams()` once per workspace. The SDK query is paginated, so this can omit teams in larger workspaces. + - Fetch all pages before the selector relies on team absence as a meaningful signal. Use `teams({ first })` plus `fetchNext()` until `pageInfo.hasNextPage` is false, or an equivalent raw GraphQL query. Keep the existing `MAX_CONCURRENT` limiter and do not set `includeArchived`; retired teams should stay absent. + +2. Invalidate caches when a Linear key is connected or replaced. + - Replacing a team-limited key with a full-access key must not leave the old `linearTeamCache` visible for up to `TEAM_CACHE_TTL`. + - On successful `connectLinear`, synchronously clear Linear issue/search/team caches, in-flight issue/search/list/team requests, and metadata. Do this before publishing success, then await a forced status refresh before resolving so callers see the selected workspace and workspace list that came from the new key. The renderer `linearConnect` result is typed as `{ viewer }` only; do not rely on the main-process-only `workspace` field. + - Guard `fetchLinearIssue()` and `listLinearTeams()` cache writes with a request generation or entry identity check. Clearing their in-flight maps is not enough today: those promises write unconditionally after resolution and can repopulate old issue/team data after key replacement. `searchLinearIssues()` and `listLinearIssues()` already use in-flight entry identity checks for forced refreshes; keep them covered by the same generation if connect/disconnect clears their maps. + - Force a team refetch for the selected/connected workspace if the Linear Tasks view is open. + - Issue list caches also need invalidation because changing key scope can add or remove visible issues. + +3. Handle auth-driven token removal explicitly. + - Backend calls can clear a workspace token on auth errors. In `all` workspace mode, `listTeams()`, `listIssues()`, and `searchIssues()` may return partial results instead of throwing. + - After any auth-driven clear, the renderer must get a forced Linear status refresh or a typed signal so disconnected workspaces disappear without waiting for a later manual check. The current array-returning issue/search/team APIs have no signal for `all`-mode partial auth clears, so either add an auth-cleared result envelope end-to-end or force a metadata-only status refresh after `all`-workspace issue/search/team reads. Do not set `linearStatus` to globally disconnected from a single request error; refresh status because multi-workspace failures may only remove one workspace. + - Strengthen `checkLinearConnection()` status equality. It currently compares workspace count, effective selected id, connected state, and viewer email. It misses active workspace id and workspace metadata changes when the count is unchanged. Compare a stable workspace signature including id, organization id/name/url key, display name, and email. + - When a forced status refresh observes a changed selected workspace or workspace signature, clear issue/search/team caches, in-flight issue/search/list/team requests, and metadata. `checkLinearConnection()` currently only updates `linearStatus`, so removed workspaces can leave stale rows in `all` caches. + +4. Be honest about team filtering. + - Current Linear issue reads do not accept team IDs; team selection filters the already-limited issue/search result in the renderer after the `LINEAR_ITEM_LIMIT` page arrives. In `all` workspace mode, each workspace may fetch up to the limit, but the backend aggregates and trims back to `LINEAR_ITEM_LIMIT` before the renderer filters by team. + - This UI change should not imply exhaustive server-side team scope. Empty task states must say no fetched/current issues match the selected teams. True server-side team scoping needs a separate change to add team IDs to cache keys, runtime RPC params, and Linear GraphQL filters. + +## Design + +1. Add URL helpers in `src/shared/linear-links.ts`. + - `buildLinearPersonalApiKeySettingsUrl(organizationUrlKey?: string | null)` returns `https://linear.app//settings/account/security` when a slug exists, otherwise `https://linear.app/settings/account/security`. + - `buildLinearWorkspaceApiSettingsUrl(organizationUrlKey?: string | null)` returns `https://linear.app//settings/api` when a slug exists, otherwise `https://linear.app/settings/api`. + - Encode path segments consistently with `buildLinearTeamUrl`. + +2. Add `LinearApiKeyDialog` in `src/renderer/src/components/linear-api-key-dialog.tsx`. + - Props: `open`, `onOpenChange`, optional `workspace`, optional `title`, optional `description`, optional `connectLabel`, optional `onConnected`, optional `overlayClassName`/`contentClassName`. + - Own input, connecting/error state, Enter-to-submit, and `connectLinear`. Fire `onConnected` only after `connectLinear` resolves with refreshed store status. + - Use org-specific URLs only when a single workspace context is known. For `all`, use the global fallback and copy that tells the user to choose the intended Linear workspace. + - Guidance: + - Create a Personal API key from Account > Security & Access. + - Prefer full access when Orca should show every team the account can access in that workspace. + - If member API keys are blocked, ask a workspace admin to allow them from workspace API settings. Do not imply the workspace API page creates the personal key. + - A key never grants teams the owner cannot access. + - Make storage copy runtime-aware. Local runtime keys use the local OS keychain when Electron encryption is available; SSH/remote-runtime keys are stored by that runtime, not necessarily on this machine. + +3. Replace the Tasks toolbar Linear controls with `LinearScopeSelector`. + - Suggested file: `src/renderer/src/components/linear-scope-selector.tsx`. + - Keep state updates in `TaskPage`: workspaces, selected workspace id, teams, selected team ids, callbacks for workspace select, team select, select-all teams, open selected team URL, and open API-key dialog. + - Trigger label examples: + - One workspace + sticky-all: `All teams` + - One workspace + subset: `ENG, STA +1` + - Multiple workspaces + `all` + sticky-all: `All workspaces` + - Multiple workspaces + one workspace + sticky-all: ` / All teams` + - Multiple workspaces + subset: ` / ENG, STA +1` or `All workspaces / ENG +1` + - If the active scope is `all` and selected team keys are duplicated or span multiple workspaces, prefer `All workspaces / 2 teams` over an ambiguous key list. + - Popover content: + - Search filters team rows by team name, team key, and workspace name. + - Workspace section appears only when more than one workspace is connected. It includes "All workspaces" and each workspace. + - Selecting a workspace must call the existing `selectLinearWorkspace` flow, clear selected issue/list/error/loading state the same way `TaskPage` does now, and must not mutate `defaultLinearTeamSelection`. + - Team section includes "All teams" and selectable team rows. "All teams" persists `defaultLinearTeamSelection: null`; explicit subsets persist an array. Normalize a selection equal to all available teams back to `null`, even if the user selected teams one by one. Never persist an empty array; keep the last team selected or save `null`. + - Team rows show name, key, and workspace name when active workspace is `all` or when multiple workspaces exist. + - Footer has "Add team access" with a key/link icon. Close the popover before opening the API-key dialog. + - Keep the external-link button as an icon-only sibling. It remains an action, and should stay enabled only when exactly one selected team has a URL. + +4. Keep team selection reconciliation, but document its limits. + - `reconcileLinearTeamSelection()` preserves sticky-all and drops stale team IDs. + - Because the saved team subset is global, switching workspaces may temporarily select all teams in the new workspace while preserving the old saved subset for later. Do not introduce per-workspace persistence here. + +5. Update Settings and onboarding to use `LinearApiKeyDialog`. + - Rename "Add workspace" to "Add Linear access" when disconnected and "Update access" or "Add workspace access" when connected. + - Replace "Each workspace uses its own locally stored API key" with copy that says each connected Linear workspace has one key stored by the active runtime; a full-access key can cover all visible teams in that workspace, and a restricted key can be replaced any time. + - Keep existing per-workspace Test and Disconnect controls. + +## Edge Cases + +- No Linear workspaces connected: no Tasks selector; Settings/onboarding still show Connect. +- One workspace connected: hide the workspace section, but keep "Add team access" visible. +- Multiple workspaces + `all`: team keys/names can repeat across workspaces; always show workspace names in rows. +- Empty team result: show an empty state plus footer action. Do not claim the key is the only possible cause; absence can also come from private-team membership, archived teams, permissions, or a fetch failure. +- Empty issue result after team selection: say no fetched/current issues match the selected teams. Do not imply Linear was queried exhaustively for those teams. +- Legacy or missing `organizationUrlKey`: use global Linear settings URLs and keep the paste flow usable. +- Replacement key for an existing workspace: select that workspace, invalidate caches, refresh status, and refetch teams immediately. +- Auth errors: a backend call may clear a token. The selector must tolerate stale rows during the refresh, then drop disconnected workspaces when status updates. +- Multi-window/external mutations: there is no real-time renderer broadcast. Force `checkLinearConnection(true)` after connect/disconnect/test flows and when opening the selector if stale status would be visible. Forced refresh must still update state and invalidate caches when workspace metadata changed but the workspace count did not. A same-workspace key replacement with identical viewer/workspace metadata is invisible to status today; do not claim that case is handled unless this change adds a non-secret credential revision or a renderer broadcast. +- Remote runtime/SSH: continue routing through `connectLinear`, `selectLinearWorkspace`, `listLinearTeams`, and runtime RPC. Do not call local Electron APIs directly except for opening Linear URLs. +- Accessibility: trigger is a combobox-style button, rows are keyboard-selectable, icon buttons have labels/tooltips, and the dialog traps focus. + +## Tests + +- `src/shared/linear-links.test.ts`: URL helpers, fallback behavior, and path encoding. +- `src/main/linear/teams.test.ts`: paginated team fetching and `all` workspace aggregation. +- `src/renderer/src/store/slices/linear.test.ts`: successful connect invalidates issue/search/team caches, drops stale in-flight writes, awaits refreshed status, and does not mark all Linear disconnected for one workspace auth failure. +- `src/renderer/src/store/slices/linear.test.ts`: forced status refresh detects changed workspace ids/metadata with the same workspace count and invalidates Linear caches when the scope signature changes. +- Component-independent label and selection-persistence helper tests for `LinearScopeSelector`, including selecting every visible team persisting sticky-all (`null`) rather than a frozen full-team array. +- Avoid brittle popover DOM tests unless matching an existing selector test pattern. + +## Rollout + +1. Fix backend/store correctness: team pagination, connect invalidation, and auth-clear status refresh. +2. Add Linear settings URL helpers and tests. +3. Add `LinearApiKeyDialog`; migrate Settings, onboarding, and the existing Tasks connect dialog. +4. Add `LinearScopeSelector` and replace only the Linear toolbar selector block in `TaskPage`. +5. Run focused Linear tests, then `pnpm typecheck` and `pnpm lint`. diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md index 108a8eacba2..06a2d45b803 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -14,7 +14,7 @@

El orquestador de IA para desarrolladores 100x.
- Ejecuta Claude Code, Codex, Grok, Antigravity u OpenCode en paralelo entre repositorios — cada uno en su propio worktree, todo administrado desde un solo lugar.
+ Ejecuta Claude Code, OpenClaude, Codex, Grok, Antigravity u OpenCode en paralelo entre repositorios — cada uno en su propio worktree, todo administrado desde un solo lugar.
Disponible para macOS, Windows y Linux.

@@ -32,6 +32,7 @@ Orca es compatible con cualquier agente CLI (_no solo los de esta lista_).

Claude Code   + OpenClaude   Codex   Grok   Gemini   @@ -64,7 +65,7 @@ Orca es compatible con cualquier agente CLI (_no solo los de esta lista_). ## Características -- **Sin login** — Usa tu propia suscripción de Claude Code, Codex, Grok o Antigravity. +- **Sin login** — Usa tu propia suscripción de Claude Code, OpenClaude, Codex, Grok o Antigravity. - **Nativo con worktrees** — Cada feature vive en su propio worktree. Nada de stash ni malabares entre ramas. Crea y cambia al instante. - **Terminales multi-agente** — Ejecuta varios agentes de IA en paralelo en pestañas y paneles. Mira de un vistazo cuáles están activos. - **Control de versiones integrado** — Revisa los diffs generados por IA, haz ediciones rápidas y haz commit sin salir de Orca. diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index 2bfe4360fef..389facb6757 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -14,7 +14,7 @@

100x ビルダーのための AI オーケストレーター。
- Claude Code、Codex、Grok、Antigravity、OpenCode をリポジトリをまたいで並行実行 — それぞれを専用のワークツリーで動かし、1 か所で追跡できます。
+ Claude Code、OpenClaude、Codex、Grok、Antigravity、OpenCode をリポジトリをまたいで並行実行 — それぞれを専用のワークツリーで動かし、1 か所で追跡できます。
macOS、Windows、Linux で利用できます。

@@ -32,6 +32,7 @@ Orca は任意の CLI エージェントに対応しています(_このリス

Claude Code   + OpenClaude   Codex   Grok   Gemini   @@ -64,7 +65,7 @@ Orca は任意の CLI エージェントに対応しています(_このリス ## 機能 -- **ログイン不要** — お持ちの Claude Code、Codex、Grok、Antigravity サブスクリプションをそのまま利用できます。 +- **ログイン不要** — お持ちの Claude Code、OpenClaude、Codex、Grok、Antigravity サブスクリプションをそのまま利用できます。 - **ワークツリーネイティブ** — 各機能は専用のワークツリーで開発できます。スタッシュやブランチ切り替えに悩まず、すぐに作成して切り替えられます。 - **マルチエージェントターミナル** — 複数の AI エージェントをタブやペインで並行実行できます。どれがアクティブかを一目で確認できます。 - **組み込みソース管理** — AI が生成した Diff を確認し、すばやく編集して、Orca から離れずにコミットできます。 diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index 7e25bb80829..1c20d6ea5c5 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -14,7 +14,7 @@

100x 빌더를 위한 AI 오케스트레이터.
- Claude Code, Codex, Grok, Antigravity, OpenCode를 여러 리포지토리에서 나란히 실행하세요. 각 에이전트는 자체 worktree에서 실행되고 한곳에서 추적됩니다.
+ Claude Code, OpenClaude, Codex, Grok, Antigravity, OpenCode를 여러 리포지토리에서 나란히 실행하세요. 각 에이전트는 자체 worktree에서 실행되고 한곳에서 추적됩니다.
macOS, Windows, Linux에서 사용할 수 있습니다.

@@ -32,6 +32,7 @@ Orca는 모든 CLI 에이전트를 지원합니다(_아래 목록에만 한정

Claude Code   + OpenClaude   Codex   Grok   Gemini   @@ -64,7 +65,7 @@ Orca는 모든 CLI 에이전트를 지원합니다(_아래 목록에만 한정 ## 기능 -- **로그인 불필요** — 보유한 Claude Code, Codex, Grok 또는 Antigravity 구독을 그대로 사용하세요. +- **로그인 불필요** — 보유한 Claude Code, OpenClaude, Codex, Grok 또는 Antigravity 구독을 그대로 사용하세요. - **Worktree 네이티브** — 모든 기능은 자체 worktree를 가집니다. stash나 브랜치 전환에 얽매이지 않고 즉시 만들고 전환할 수 있습니다. - **멀티 에이전트 터미널** — 여러 AI 에이전트를 탭과 패널에서 나란히 실행하세요. 어떤 에이전트가 활성 상태인지 한눈에 볼 수 있습니다. - **내장 소스 관리** — AI가 생성한 diff를 검토하고, 빠르게 수정하고, Orca를 떠나지 않고 커밋할 수 있습니다. diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index bf31b3bc0cd..386c3f47ddd 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -14,7 +14,7 @@

面向 100x 构建者的 AI 编排器。
- 跨仓库并排运行 Claude Code、Codex、Grok、Antigravity 或 OpenCode — 每个都在自己的 worktree 中运行,并在一个地方统一跟踪。
+ 跨仓库并排运行 Claude Code、OpenClaude、Codex、Grok、Antigravity 或 OpenCode — 每个都在自己的 worktree 中运行,并在一个地方统一跟踪。
支持 macOS、Windows 和 Linux。

@@ -32,6 +32,7 @@ Orca 支持任何 CLI 智能体(_不仅限于以下列表_)。

Claude Code   + OpenClaude   Codex   Grok   Gemini   @@ -64,7 +65,7 @@ Orca 支持任何 CLI 智能体(_不仅限于以下列表_)。 ## 特性 -- **无需登录** — 直接使用你自己的 Claude Code、Codex、Grok 或 Antigravity 订阅。 +- **无需登录** — 直接使用你自己的 Claude Code、OpenClaude、Codex、Grok 或 Antigravity 订阅。 - **原生 worktree 工作流** — 每个功能都有自己的 worktree。无需 stash,也不用来回切分支。立即创建,快速切换。 - **多智能体终端** — 在标签页和面板中并排运行多个 AI 智能体。一眼就能看到哪些正在活跃。 - **内置源码管理** — 查看 AI 生成的 diff,快速编辑,并且无需离开 Orca 就能提交。 diff --git a/docs/reference/direct-url-or-file-entry.md b/docs/reference/direct-url-or-file-entry.md index b5ea3c0c7e8..8664965147f 100644 --- a/docs/reference/direct-url-or-file-entry.md +++ b/docs/reference/direct-url-or-file-entry.md @@ -43,17 +43,14 @@ The behavior must work from both the titlebar tab strip and split-group tab stri `TabBar` owns input text, pending state, focus, and dropdown close behavior. It does not create browser tabs or files. `groupId` should be `groupId ?? worktreeId`, matching the existing `resolvedGroupId` fallback. 3. Add a small hook/helper pair instead of duplicating Quick Open logic: - - `useTabEntryFileList` loads the file list when the menu opens, using the same inputs as `QuickOpen`: active worktree path, `getConnectionId(worktreeId)`, nested worktree exclusions, and active SSH target status. It cancels stale requests on close or key changes. - A pure classifier/opening helper accepts the query, file-list snapshot, load/error state, worktree metadata, runtime context, and target group. 4. Wire the callback in both owners: - - `Terminal.tsx` titlebar fallback resolves the current active worktree and target group the same way `handleNewTab` / `handleNewBrowserTab` do. - `useTabGroupWorkspaceModel` passes its explicit `worktreeId` and `groupId`. Do not rely on ambient `activeGroupIdByWorktree`; split-group `+` can be invoked from an unfocused group. 5. Classify submissions in this order: - - Empty after trim: reject inline. - Explicit URL: accept only `http://` and `https://` URLs with a parseable host. - Existing file: once the file-list snapshot is ready, normalize query separators for matching, prefer exact relative-path match, then exact basename match, then `rankQuickOpenFiles`. Before opening, `statRuntimePath` the matched absolute path and reject directories/stale missing matches instead of blindly opening stale list entries. @@ -61,14 +58,12 @@ The behavior must work from both the titlebar tab strip and split-group tab stri - New file: only after file listing has completed successfully with no existing-file match and no host-like URL match. Treat the query as a relative worktree path. If listing fails, allow explicit `http://` / `https://` URLs only; keep bare host-like inputs blocked because they cannot be disambiguated from files. 6. Validate new file paths before joining: - - Reject POSIX absolute paths, Windows drive paths, UNC paths, `~`, empty path, trailing slash, control characters, `.` / `..` segments, and empty raw segments such as `a//b`. - Normalize `\` to `/` only after absolute-path checks, then run segment validation on the normalized path too so traversal like `a\..\b` is still rejected. - Build the absolute path with `joinPath(worktreePath, relativePath)`, then create with `createRuntimePath(context, absolutePath, 'file')`. - On `EEXIST` / "exists", immediately `statRuntimePath`; if it is now a file, open it. If it is a directory, show an error. This handles another window/process winning the create race. 7. Open actions: - - URL: in paired web clients, call `createWebRuntimeSessionBrowserTab({ worktreeId, url, targetGroupId: groupId })`; otherwise call `createBrowserTab(worktreeId, url, { activate: true, targetGroupId: groupId, title: url })`. Do not use `openNewBrowserTabInActiveWorkspace`; it only opens the default URL. - Existing/new file: call `openFile(fileInfo, { preview: false, targetGroupId: groupId })` with `language: detectLanguage(relativePath)`. Include the active runtime environment owner as `runtimeEnvironmentId` through the normal `openFile` fallback; do not suppress runtime ownership. diff --git a/docs/reference/feature-discovery-interaction-tracking.md b/docs/reference/feature-discovery-interaction-tracking.md index 539730b1b04..cdc2dafcc5b 100644 --- a/docs/reference/feature-discovery-interaction-tracking.md +++ b/docs/reference/feature-discovery-interaction-tracking.md @@ -21,37 +21,37 @@ Do not upload this state as broad analytics. Product analytics should continue t ## Feature Catalog -| Feature | Interaction ID | Record when | Education use | -| --- | --- | --- | --- | -| Review notes to agent | `review-notes` | A diff or markdown review note is added, or review notes are marked sent to an agent. | Suppress or target a future review-notes tour/tip about adding line notes and sending focused feedback back to an agent. | -| AI commit generation | `ai-commit-generation` | AI commit-message generation is enabled or an AI commit message is generated. | Suppress future education about AI commit generation. No contextual tour is planned for this branch. | -| AI PR generation | `ai-pr-generation` | AI pull-request title/body/draft fields are generated. | Suppress future education about AI PR generation. No contextual tour is planned for this branch. | -| Floating Workspace | `floating-workspace` | The floating workspace opens, is enabled, or is configured. | Suppress future tips about the global terminal/browser/markdown workspace. No contextual tour is planned for this branch. | -| Quick Commands | `quick-commands` | A terminal quick command is created or edited. | Suppress future tips about saved terminal commands. No contextual tour is planned for this branch. | -| Computer Use setup | `computer-use-setup` | Computer Use is selected in onboarding, a permission setup is opened, or the skill setup terminal opens. | Suppress setup-focused tips once the user has started setup. | -| Computer Use | `computer-use` | A successful `computer.*` runtime method other than capability probing is handled. | Suppress future usage tips once an agent has actually invoked Computer Use. | -| Mobile pairing | `mobile-pairing` | Mobile is enabled or a mobile pairing QR/code is generated. | Suppress future mobile-pairing tips. No contextual tour is planned for this branch. | -| Browser element grab | `browser-grab` | Browser grab mode is started, an element is copied, or an element screenshot is copied. | Suppress future tips about grabbing page context once the user has used the element picker. | -| Browser annotations | `browser-annotations` | Browser annotation mode is started, an annotation is added, browser annotations are copied, or browser annotations are cleared. | Suppress future tips about annotating local pages and sending concrete UI feedback to an agent. | -| Cookie import | `cookie-import` | Browser cookies are imported from another browser or file, imported through runtime browser profile methods, or default imported cookies are cleared. | Suppress future cookie-import education without conflating cookie setup with Agent Browser Use itself. | -| Workspace board actions | `workspace-board-actions` | A card/status action, lane configuration, density change, pin drop, or board drag action is used. | Keep the existing workspace-board tour, and avoid repeating deeper board-action education after real use. | -| Automation creation | `automation-created` | A local automation or external Hermes cron is created. | Keep the existing Automations tour, and suppress creation-focused education after the user creates one. | -| Automation run | `automation-run` | A local or external automation run is manually queued. | Keep the existing Automations tour, and suppress run/inspection education after the user queues a run. | -| Resource Manager | `resource-manager` | The Resource Manager status-bar popover is opened, or its status-bar visibility is toggled in Appearance/status-bar controls. | Suppress future tips about CPU, memory, session, daemon, and workspace disk-scan controls after the user has found the manager. | -| Workspace cleanup / disk space | `workspace-cleanup` | The Space page opens, a workspace disk scan starts/cancels, or cleanup removes scanned workspace rows. | Suppress future tips about scanning workspace disk usage and reclaiming old workspace storage. | -| Ports | `ports` | The Ports popover opens, the Ports status-bar item is configured, external ports are expanded, or a port is opened/copied/stopped. | Suppress future tips about discovering and acting on workspace ports. | -| SSH | `ssh` | SSH status opens, SSH status-bar visibility changes, a target is added/imported/tested, or an SSH target is connected/disconnected. | Suppress future SSH setup/status tips after the user has interacted with remote target controls. | -| Provider usage tracking | `usage-tracking` | Stats & Usage is opened, Claude/Codex/OpenCode usage analytics are enabled, provider usage details are opened from the status bar, Gemini usage/OAuth is configured, or provider usage status-bar toggles are changed. | Suppress future tips about where to find token/rate-limit/usage tracking for Claude, Codex, Gemini, OpenCode, and related providers. | -| Claude account switching | `claude-account-switching` | A Claude managed account is added, selected, reauthenticated, removed, or selected from the status bar. | Suppress future tips about using multiple Claude accounts once the user has started account management. | -| Codex account switching | `codex-account-switching` | A Codex managed account is added, selected, reauthenticated, removed, or selected from the status bar. | Suppress future tips about Codex account switching and restart follow-up flows. | -| Workspace tabs | `terminal-tabs` | A workspace tab is created, reordered, renamed, recolored, pinned/unpinned, moved, or closed. | Suppress future tips about tab-level workspace organization. These are real workspace tabs, not workspace board cards. | -| Split panes | `terminal-panes` | A split group is created from the pane shortcut/menu, a split is resized, or panes are merged. | Suppress future tips about pane-level split workflows. | -| Tab splits | `tab-splits` | A workspace tab is moved into another pane or split into a new pane from a tab split/drop action. | Suppress future tips about tab-level split workflows separately from pane creation/resizing. | -| Agent Browser Use setup | `agent-browser-setup` | Browser Use is selected in onboarding, enabled in settings, or its setup terminal opens. | Suppress future setup tips once the user has started Browser Use setup. | -| Agent Browser Use | `agent-browser-use` | A successful non-profile `browser.*` runtime method is handled. | Suppress future usage tips once an agent/runtime has actually driven Orca's browser. | -| Agent Orchestration setup | `agent-orchestration-setup` | Orchestration is selected in onboarding, enabled in settings, or its setup terminal opens. | Suppress future setup tips once the user has started Orchestration setup. | -| Agent Orchestration | `agent-orchestration` | A successful `orchestration.*` runtime method is handled. | Suppress future usage tips once an agent/runtime has actually used orchestration. | -| Notifications | `notifications` | Notifications are enabled in onboarding/settings or a test notification is sent. | Suppress future notification setup tips. | +| Feature | Interaction ID | Record when | Education use | +| ------------------------------ | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| Review notes to agent | `review-notes` | A diff or markdown review note is added, or review notes are marked sent to an agent. | Suppress or target a future review-notes tour/tip about adding line notes and sending focused feedback back to an agent. | +| AI commit generation | `ai-commit-generation` | AI commit-message generation is enabled or an AI commit message is generated. | Suppress future education about AI commit generation. No contextual tour is planned for this branch. | +| AI PR generation | `ai-pr-generation` | AI pull-request title/body/draft fields are generated. | Suppress future education about AI PR generation. No contextual tour is planned for this branch. | +| Floating Workspace | `floating-workspace` | The floating workspace opens, is enabled, or is configured. | Suppress future tips about the global terminal/browser/markdown workspace. No contextual tour is planned for this branch. | +| Quick Commands | `quick-commands` | A terminal quick command is created or edited. | Suppress future tips about saved terminal commands. No contextual tour is planned for this branch. | +| Computer Use setup | `computer-use-setup` | Computer Use is selected in onboarding, a permission setup is opened, or the skill setup terminal opens. | Suppress setup-focused tips once the user has started setup. | +| Computer Use | `computer-use` | A successful `computer.*` runtime method other than capability probing is handled. | Suppress future usage tips once an agent has actually invoked Computer Use. | +| Mobile pairing | `mobile-pairing` | Mobile is enabled or a mobile pairing QR/code is generated. | Suppress future mobile-pairing tips. No contextual tour is planned for this branch. | +| Browser element grab | `browser-grab` | Browser grab mode is started, an element is copied, or an element screenshot is copied. | Suppress future tips about grabbing page context once the user has used the element picker. | +| Browser annotations | `browser-annotations` | Browser annotation mode is started, an annotation is added, browser annotations are copied, or browser annotations are cleared. | Suppress future tips about annotating local pages and sending concrete UI feedback to an agent. | +| Cookie import | `cookie-import` | Browser cookies are imported from another browser or file, imported through runtime browser profile methods, or default imported cookies are cleared. | Suppress future cookie-import education without conflating cookie setup with Agent Browser Use itself. | +| Workspace board actions | `workspace-board-actions` | A card/status action, lane configuration, density change, pin drop, or board drag action is used. | Keep the existing workspace-board tour, and avoid repeating deeper board-action education after real use. | +| Automation creation | `automation-created` | A local automation or external Hermes cron is created. | Keep the existing Automations tour, and suppress creation-focused education after the user creates one. | +| Automation run | `automation-run` | A local or external automation run is manually queued. | Keep the existing Automations tour, and suppress run/inspection education after the user queues a run. | +| Resource Manager | `resource-manager` | The Resource Manager status-bar popover is opened, or its status-bar visibility is toggled in Appearance/status-bar controls. | Suppress future tips about CPU, memory, session, daemon, and workspace disk-scan controls after the user has found the manager. | +| Workspace cleanup / disk space | `workspace-cleanup` | The Space page opens, a workspace disk scan starts/cancels, or cleanup removes scanned workspace rows. | Suppress future tips about scanning workspace disk usage and reclaiming old workspace storage. | +| Ports | `ports` | The Ports popover opens, the Ports status-bar item is configured, external ports are expanded, or a port is opened/copied/stopped. | Suppress future tips about discovering and acting on workspace ports. | +| SSH | `ssh` | SSH status opens, SSH status-bar visibility changes, a target is added/imported/tested, or an SSH target is connected/disconnected. | Suppress future SSH setup/status tips after the user has interacted with remote target controls. | +| Provider usage tracking | `usage-tracking` | Stats & Usage is opened, Claude/Codex/OpenCode usage analytics are enabled, provider usage details are opened from the status bar, Gemini usage/OAuth is configured, or provider usage status-bar toggles are changed. | Suppress future tips about where to find token/rate-limit/usage tracking for Claude, Codex, Gemini, OpenCode, and related providers. | +| Claude account switching | `claude-account-switching` | A Claude managed account is added, selected, reauthenticated, removed, or selected from the status bar. | Suppress future tips about using multiple Claude accounts once the user has started account management. | +| Codex account switching | `codex-account-switching` | A Codex managed account is added, selected, reauthenticated, removed, or selected from the status bar. | Suppress future tips about Codex account switching and restart follow-up flows. | +| Workspace tabs | `terminal-tabs` | A workspace tab is created, reordered, renamed, recolored, pinned/unpinned, moved, or closed. | Suppress future tips about tab-level workspace organization. These are real workspace tabs, not workspace board cards. | +| Split panes | `terminal-panes` | A split group is created from the pane shortcut/menu, a split is resized, or panes are merged. | Suppress future tips about pane-level split workflows. | +| Tab splits | `tab-splits` | A workspace tab is moved into another pane or split into a new pane from a tab split/drop action. | Suppress future tips about tab-level split workflows separately from pane creation/resizing. | +| Agent Browser Use setup | `agent-browser-setup` | Browser Use is selected in onboarding, enabled in settings, or its setup terminal opens. | Suppress future setup tips once the user has started Browser Use setup. | +| Agent Browser Use | `agent-browser-use` | A successful non-profile `browser.*` runtime method is handled. | Suppress future usage tips once an agent/runtime has actually driven Orca's browser. | +| Agent Orchestration setup | `agent-orchestration-setup` | Orchestration is selected in onboarding, enabled in settings, or its setup terminal opens. | Suppress future setup tips once the user has started Orchestration setup. | +| Agent Orchestration | `agent-orchestration` | A successful `orchestration.*` runtime method is handled. | Suppress future usage tips once an agent/runtime has actually used orchestration. | +| Notifications | `notifications` | Notifications are enabled in onboarding/settings or a test notification is sent. | Suppress future notification setup tips. | ## Surface-Level Features diff --git a/docs/reference/memory-leak-audit-pass-2.md b/docs/reference/memory-leak-audit-pass-2.md deleted file mode 100644 index 99e05d78c20..00000000000 --- a/docs/reference/memory-leak-audit-pass-2.md +++ /dev/null @@ -1,51 +0,0 @@ -# Memory Leak Audit Pass 2 - -Started: 2026-05-29 PDT - -Branch: `nwparker/mem-leak-audit-pass-2`, based on latest `origin/main` after the first leak-fix PRs were merged. The held mobile notification PR #2887 is tracked separately and is not duplicated here. - -Objective: re-scan the entire Orca codebase for missed listener, timer, observer, worker, socket, watcher, subscription, and disposable leaks; open scoped PRs for confirmed new misses. - -## Codebase Inventory - -Tracked code files, from `git ls-files`: - -| Bucket | Files | Status | -| ----------------- | ----: | ------- | -| `src/renderer` | 1575 | Checked | -| `src/main` | 856 | Checked | -| `src/shared` | 245 | Checked | -| `mobile/src` | 100 | Checked | -| `tests/e2e` | 72 | Checked | -| `src/relay` | 65 | Checked | -| `src/cli` | 65 | Checked | -| `config/scripts` | 33 | Checked | -| `mobile/app` | 16 | Checked | -| `mobile/packages` | 12 | Checked | -| `other-code` | 10 | Checked | -| `native` | 6 | Checked | -| `src/preload` | 6 | Checked | -| Total | 3061 | Checked | - -## Scan Log - -- 2026-05-29: Created fresh audit branch from latest `origin/main`; confirmed held mobile PR #2887 remains open and is not duplicated. -- 2026-05-29: Counted 3532 broad risk-pattern hits across `src`, `mobile`, `tests`, `config`, and `native`. -- 2026-05-29: Re-ran broad listener/timer/observer/worker heuristics after fixes. Remaining raw candidates are expected lifetime-bound listeners: React Native subscriptions with `.remove()`, WebView document-lifetime scripts, preload document-level file-drop handlers, pane DOM listeners whose pane elements are disposed, and singleton window focus cache invalidation. -- 2026-05-29: Checked React `useEffect`/`useLayoutEffect` candidates with risky APIs. Remaining hits are either direct cleanup returns, one-frame focus `requestAnimationFrame` calls, or effects whose teardown is handled by adjacent owner lifecycle. -- 2026-05-29: Checked main/runtime/browser/SSH/daemon/rate-limit/relay/shared/CLI/test/native buckets for unpaired timers, sockets, startup listeners, file watches, and abort listeners. -- 2026-05-29: Opened and merged low-risk follow-up PRs #3043, #3045, #3046, #3047, and #3049 for the confirmed misses. - -## Findings - -- `src/main/speech/model-manager.ts`: cancelling a stalled speech-model download only flipped an `aborted` flag; if HTTPS never delivered another chunk, the request and promise could stay alive indefinitely. Fixed by wiring `AbortController` through the download request and redirect chain. Risk: low. PR: #3043, merged. -- `src/main/runtime/rpc/unix-socket-transport.ts`: a long-poll request keepalive interval was only cleared on reply; if the Unix socket closed first and the handler never replied after abort, the interval retained the socket/request closure. Fixed by making connection close run per-dispatch cleanup. Risk: low. PR: #3045, merged. -- `src/relay/dispatcher.ts`: relay disposal stopped keepalives but did not abort in-flight request contexts, so stale SSH-side scans/watchers could continue after the dispatcher could no longer send responses. Fixed by aborting all tracked request controllers on dispose. Risk: low. PR: #3046, merged. -- `src/preload/index.ts`: notification sound playback registered `ended`/`error` listeners for each play, but a failed `audio.play()` or replaced cached audio could leave those listeners attached. Fixed by tracking and running explicit playback listener cleanup. Risk: low. PR: #3047, merged. -- `src/main/browser/cdp-ws-proxy.ts`: CDP proxy startup errors after debugger attach rejected without releasing the debugger/server objects. Fixed the startup failure path to close partial servers and detach the debugger. Risk: low. PR: #3049, merged. - -## Validation - -- `pnpm vitest run --config config/vitest.config.ts src/main/browser/cdp-ws-proxy.test.ts src/main/speech/model-manager.test.ts src/main/runtime/rpc/unix-socket-transport.test.ts src/relay/dispatcher.test.ts` -- `pnpm run typecheck:node` -- `pnpm exec oxlint src/main/browser/cdp-ws-proxy.ts src/main/runtime/rpc/unix-socket-transport.ts src/main/runtime/rpc/unix-socket-transport.test.ts src/relay/client-request-aborts.ts src/relay/dispatcher.ts src/relay/dispatcher.test.ts src/preload/index.ts src/main/speech/model-manager.ts src/main/speech/model-manager.test.ts` diff --git a/docs/reference/memory-leak-audit.md b/docs/reference/memory-leak-audit.md deleted file mode 100644 index 6c3e6442eb2..00000000000 --- a/docs/reference/memory-leak-audit.md +++ /dev/null @@ -1,74 +0,0 @@ -# Memory Leak Audit - -Started: 2026-05-26 18:36 PDT - -Objective: scan the entire Orca codebase for memory leaks, fix confirmed leaks, and keep durable coverage notes until the repository-wide scan is complete. - -## Codebase Inventory - -Tracked code files, from `git ls-files`: - -| Bucket | Files | Status | -| ----------------- | ----: | ------------------------------------------- | -| `src/renderer` | 1534 | Checked | -| `src/main` | 838 | Checked | -| `src/shared` | 220 | Checked | -| `mobile/src` | 94 | Checked | -| `tests/e2e` | 71 | Checked | -| `src/relay` | 65 | Checked | -| `src/cli` | 64 | Checked | -| `config/scripts` | 27 | Checked | -| `mobile/app` | 16 | Checked | -| `other-code` | 13 | Checked | -| `mobile/packages` | 12 | Checked | -| `native` | 6 | Checked | -| `src/preload` | 6 | Checked by IPC subscription heuristic | -| Total | 2966 | Repository-wide risk-pattern scan completed | - -## Scan Log - -- 2026-05-26: Loaded `$memory-leak-audit` checklist and built an initial inventory from `git ls-files`. -- 2026-05-26: Ran a broad listener/timer/observer search across `src`, `mobile`, `tests`, and `config`. -- 2026-05-26: Ran a React `useEffect` cleanup heuristic over 1845 TS/TSX files. Manual follow-up identified one confirmed issue in `src/renderer/src/components/editor/PdfViewer.tsx`: `EventBus.on('scalechanging', ...)` had no matching `off`. -- 2026-05-26: Ran a preload IPC heuristic. `src/preload/index.ts` and `src/preload/runtime-environment-subscriptions.ts` had apparent cleanup for their `ipcRenderer.on` subscriptions. -- 2026-05-26: Ran an `ipcMain.on` cleanup heuristic over `src/main`. The only unpaired hit was `src/main/ipc/session.ts`, which is a process-lifetime registration via `registerSessionHandlers`. -- 2026-05-26: Manually reviewed main-process interval/service candidates. Confirmed `src/main/speech/stt-service.ts` retained a timed-out, terminated speech worker in service state until idle teardown. -- 2026-05-26: Ran a Promise/EventEmitter heuristic over main, relay, and CLI code. Confirmed `src/main/ssh/ssh-connection.ts` left ssh2 startup listeners attached after a successful long-lived connection. -- 2026-05-26: Confirmed `src/main/browser/cdp-ws-proxy.ts` retained its startup HTTP server `error` listener after successful bind. -- 2026-05-26: Continued Promise/EventEmitter follow-up in daemon startup paths. Confirmed startup listeners were retained after successful bind/readiness in `src/main/daemon/daemon-server.ts`, `src/main/daemon/daemon-init.ts`, and `src/main/daemon/production-launcher.ts`. -- 2026-05-26: Reviewed BrowserWindow/session listeners. Confirmed `src/main/window/attach-main-window-services.ts` stacked a persistent browser-session `will-download` listener on every main-window services attach. -- 2026-05-26: Reviewed browser-session profile lifecycle. Confirmed deleted isolated/imported profiles left partition policy callbacks/listeners and configured-partition bookkeeping behind in `src/main/browser/browser-session-registry.ts`. -- 2026-05-26: Reviewed mobile RPC subscriptions. Confirmed `mobile/src/notifications/mobile-notifications.ts` retained a local streaming callback when notification subscription cleanup ran before the desktop returned `ready`. -- 2026-05-26: Reviewed speech model extraction timers. Confirmed `src/main/speech/model-manager.ts` left the abort polling interval alive when extraction abort/timeout rejected before the child emitted `close` or `error`. -- 2026-05-26: Manually followed up renderer high-risk surfaces from the broad scan: pane manager, browser webviews, terminal pane lifecycle, editor integrations, sidebar/global document listeners, observers, and shared clocks. Remaining hits had cleanup, were process-lifetime globals, or were element-owned listeners that are collected with their DOM nodes. -- 2026-05-26: Manually followed up main-process long-lived services from the broad scan: runtime RPC, SSH services, speech worker/model manager, rate-limit polling, filesystem watchers, BrowserWindow-scoped IPC/listeners, browser-session handlers, daemon startup, and relay-backed flows. -- 2026-05-26: Checked mobile React Native subscriptions, WebView injected listeners, RPC subscription teardown, repeated polling intervals, and app/session screens. Confirmed one notifications subscription cleanup issue and no additional unpaired repeated-mount listener leaks. -- 2026-05-26: Checked `src/shared`, `src/relay`, `src/cli`, `config/scripts`, `tests/e2e`, `mobile/packages`, and native computer-use runtimes for listener/timer/socket/watch patterns. Follow-up found existing cleanup or process/test-lifetime ownership. - -## Confirmed Fixes - -- `src/renderer/src/components/editor/PdfViewer.tsx`: unregister the pdf.js `scalechanging` listener on cleanup to avoid retaining stale component state after repeated PDF opens. -- `src/main/speech/stt-service.ts`: when dictation stop times out, remove worker listeners, terminate the worker, and clear the warm-worker slot so later dictation cannot retain or reuse a dead worker. -- `src/main/ssh/ssh-connection.ts`: remove ssh2 startup `ready`/`error` listeners once the connection resolves or rejects; steady-state disconnect listeners remain scoped to the live connection. -- `src/main/browser/cdp-ws-proxy.ts`: remove the startup server `error` listener after the proxy binds so the resolved start promise closure is not retained for the proxy lifetime. -- `src/main/daemon/daemon-server.ts`: remove the startup server `error` listener after the daemon socket binds. -- `src/main/daemon/daemon-init.ts`: remove detached daemon child-process startup listeners after IPC readiness or startup failure. -- `src/main/daemon/production-launcher.ts`: remove detached daemon child-process startup listeners after IPC readiness or startup failure. -- `src/main/window/attach-main-window-services.ts`: replace the named persistent browser-session `will-download` handler before registering it so window re-attach cannot stack handlers. -- `src/main/browser/browser-session-registry.ts`: clear deleted profile partition permission/display-media callbacks, remove the download listener, and drop configured-partition bookkeeping. -- `mobile/src/notifications/mobile-notifications.ts`: always drop the local notification stream on cleanup, even if the desktop has not returned a subscription id yet. -- `src/main/speech/model-manager.ts`: clear extraction timeout/abort pollers and remove child listeners directly on abort/timeout rejection, instead of depending on a later child `close`. - -## Validation - -- Passed: `pnpm vitest run --config config/vitest.config.ts src/main/speech/stt-service.test.ts src/main/speech/model-manager.test.ts src/main/ssh/ssh-connection.test.ts src/main/browser/cdp-ws-proxy.test.ts src/main/daemon/daemon-server.test.ts src/main/daemon/production-launcher.test.ts src/main/daemon/daemon-init.test.ts src/main/window/attach-main-window-services.test.ts src/main/browser/browser-session-registry.test.ts src/main/browser/browser-session-registry.persistence.test.ts` (10 files, 128 tests). -- Passed: `pnpm run typecheck:node`. -- Passed: `pnpm run typecheck:web`. -- Passed: focused `pnpm exec oxlint` over changed TypeScript files. -- Passed after final import cleanup: `pnpm vitest run --config config/vitest.config.ts src/main/daemon/daemon-server.test.ts src/main/daemon/production-launcher.test.ts`. -- Passed: `git diff --check`. -- Attempted: `./node_modules/.bin/vitest run mobile/src/notifications/mobile-notifications.test.ts`; blocked in this workspace because mobile dependencies are not installed (`expo/tsconfig.base` cannot be resolved). The mobile test file is included for the mobile package test environment. - -## Residual Risk - -- This was a repository-wide static/manual memory-leak audit of listener, timer, observer, worker, socket, watcher, subscription, and resource-pool patterns. It does not replace heap-snapshot profiling under a long interactive session, but no further confirmed leaks were found in the scanned patterns. diff --git a/docs/reference/telemetry-availability.md b/docs/reference/telemetry-availability.md index 4626c972eab..b84958731ef 100644 --- a/docs/reference/telemetry-availability.md +++ b/docs/reference/telemetry-availability.md @@ -15,11 +15,11 @@ This file records operational availability. Event contracts still live in `src/s ## Boundary Fields -| Field | Meaning | -| --- | --- | -| `code_merged_at_utc` | When the implementation reached `main`. Useful for source history only. | -| `first_released_at_utc` | First release commit/build that contains the code. This is the earliest possible user exposure. | -| `first_seen_at_utc` | First observed PostHog row for the event/property. This proves at least one event flowed. | +| Field | Meaning | +| ------------------------ | -------------------------------------------------------------------------------------------------------------------------- | +| `code_merged_at_utc` | When the implementation reached `main`. Useful for source history only. | +| `first_released_at_utc` | First release commit/build that contains the code. This is the earliest possible user exposure. | +| `first_seen_at_utc` | First observed PostHog row for the event/property. This proves at least one event flowed. | | `dashboard_ready_at_utc` | Earliest lower bound for saved dashboards after all required signals have first-valid rows and pass any validation window. | First-valid means the released app version has been observed, the required event/property fields are populated, joins used by the dashboard are available, and any validation window listed in the rollout entry has passed. @@ -48,32 +48,32 @@ D1+/D3+/D7+ retention means the user fired `app_opened` at least once after 24/7 Scope: `nth_repo_added` on repo/activation/retention events. Current schemas declare it on `app_opened`, `repo_added`, `add_repo_setup_step_action`, `add_repo_existing_workspaces_detected`, `workspace_created`, `workspace_create_failed`, `setup_script_prompt_shown`, `setup_script_prompt_action`, `agent_started`, `agent_prompt_sent`, and `agent_error`. The original rollout covered `app_opened`, `repo_added`, `add_repo_setup_step_action`, `workspace_created`, `workspace_create_failed`, `agent_started`, and `agent_error`; later events have their own first-seen timestamps below. -| Field | Value | -| --- | --- | -| PR | `#1591` | -| Merge commit | `002e2acc38cad262d694049cc86c0962ffd381ce` | -| `code_merged_at_utc` | `2026-05-08T17:50:44Z` | -| First release | `v1.3.42-rc.1` | -| First release commit | `b6dab85aaa0e9b600ec93846824ba815542a1e1e` | -| `first_released_at_utc` | `2026-05-08T17:56:35Z` | -| Earliest `first_seen_at_utc` | `2026-05-08T18:40:00.354Z` on `app_opened` | -| `dashboard_ready_at_utc` | Event-dependent. Use `2026-05-08T18:40:00.354Z` for general `app_opened` repo-count cuts; use `2026-05-08T20:01:06.364Z` or later for first-repo activation cuts requiring `repo_added`. | +| Field | Value | +| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| PR | `#1591` | +| Merge commit | `002e2acc38cad262d694049cc86c0962ffd381ce` | +| `code_merged_at_utc` | `2026-05-08T17:50:44Z` | +| First release | `v1.3.42-rc.1` | +| First release commit | `b6dab85aaa0e9b600ec93846824ba815542a1e1e` | +| `first_released_at_utc` | `2026-05-08T17:56:35Z` | +| Earliest `first_seen_at_utc` | `2026-05-08T18:40:00.354Z` on `app_opened` | +| `dashboard_ready_at_utc` | Event-dependent. Use `2026-05-08T18:40:00.354Z` for general `app_opened` repo-count cuts; use `2026-05-08T20:01:06.364Z` or later for first-repo activation cuts requiring `repo_added`. | PostHog evidence checked at `2026-05-23T23:34:32Z`: -| Event | First seen with `nth_repo_added` | -| --- | --- | -| `app_opened` | `2026-05-08T18:40:00.354Z` (`1.3.42-rc.1`) | -| `workspace_created` | `2026-05-08T19:15:06.913Z` | -| `agent_started` | `2026-05-08T19:15:07.130Z` | -| `agent_prompt_sent` | `TBD` | -| `repo_added` | `2026-05-08T20:01:06.364Z` (`1.3.42`) | -| `add_repo_setup_step_action` | `2026-05-08T20:01:20.897Z` | -| `workspace_create_failed` | `2026-05-08T23:04:32.315Z` | -| `agent_error` | `2026-05-13T14:13:16.096Z` | -| `add_repo_existing_workspaces_detected` | `2026-05-20T05:33:20.160Z` | -| `setup_script_prompt_shown` | `2026-05-21T04:09:04.231Z` | -| `setup_script_prompt_action` | `2026-05-21T04:10:31.616Z` | +| Event | First seen with `nth_repo_added` | +| --------------------------------------- | ------------------------------------------ | +| `app_opened` | `2026-05-08T18:40:00.354Z` (`1.3.42-rc.1`) | +| `workspace_created` | `2026-05-08T19:15:06.913Z` | +| `agent_started` | `2026-05-08T19:15:07.130Z` | +| `agent_prompt_sent` | `TBD` | +| `repo_added` | `2026-05-08T20:01:06.364Z` (`1.3.42`) | +| `add_repo_setup_step_action` | `2026-05-08T20:01:20.897Z` | +| `workspace_create_failed` | `2026-05-08T23:04:32.315Z` | +| `agent_error` | `2026-05-13T14:13:16.096Z` | +| `add_repo_existing_workspaces_detected` | `2026-05-20T05:33:20.160Z` | +| `setup_script_prompt_shown` | `2026-05-21T04:09:04.231Z` | +| `setup_script_prompt_action` | `2026-05-21T04:10:31.616Z` | Dashboard caveats: @@ -87,26 +87,26 @@ Scope: `cohort` on onboarding events. Current schemas declare it on `onboarding_ The original `#1608` rollout covered `onboarding_started`, `onboarding_step_viewed`, `onboarding_step_completed`, `onboarding_step_skipped`, `onboarding_step4_path_clicked`, `onboarding_step4_path_failed`, `onboarding_completed`, `onboarding_dismissed`, `onboarding_agent_picked`, and onboarding import events. Later onboarding events joined the roster by declaring `cohort` in their schemas. -| Field | Value | -| --- | --- | -| PR | `#1608` | -| Merge commit | `318e2b4c2c501b50280dc9e5b11ed34bd899bdd9` | -| `code_merged_at_utc` | `2026-05-09T02:57:18Z` | -| First release | `v1.3.44` | -| First release commit | `e602decf1d35dc54c84eea516fc2b9afdc065cce` | -| `first_released_at_utc` | `2026-05-09T08:05:39Z` | -| Earliest `first_seen_at_utc` | `2026-05-09T09:54:03.038Z` on `onboarding_started { cohort: 'fresh_install' }` (`1.3.45`) | -| `dashboard_ready_at_utc` | `2026-05-09T09:54:03.038Z` for fresh-install onboarding-start cohorts; use later event-specific first-seen timestamps where a tile requires another event. | +| Field | Value | +| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| PR | `#1608` | +| Merge commit | `318e2b4c2c501b50280dc9e5b11ed34bd899bdd9` | +| `code_merged_at_utc` | `2026-05-09T02:57:18Z` | +| First release | `v1.3.44` | +| First release commit | `e602decf1d35dc54c84eea516fc2b9afdc065cce` | +| `first_released_at_utc` | `2026-05-09T08:05:39Z` | +| Earliest `first_seen_at_utc` | `2026-05-09T09:54:03.038Z` on `onboarding_started { cohort: 'fresh_install' }` (`1.3.45`) | +| `dashboard_ready_at_utc` | `2026-05-09T09:54:03.038Z` for fresh-install onboarding-start cohorts; use later event-specific first-seen timestamps where a tile requires another event. | Later onboarding cohort events: -| Event | PR | First release | `first_seen_at_utc` | -| --- | --- | --- | --- | -| `onboarding_feature_setup_run` | `#1853` (`2e5ac1c8ebdc93f6281622d9e4ef60ab14c71096`) | `v1.4.2-rc.1` at `2026-05-14T22:23:46Z` | `2026-05-15T05:55:37.248Z` (`1.4.2-rc.4`) | -| `onboarding_feature_setup_terminal_opened` | `#1853` (`2e5ac1c8ebdc93f6281622d9e4ef60ab14c71096`) | `v1.4.2-rc.1` at `2026-05-14T22:23:46Z` | `2026-05-15T05:55:37.261Z` (`1.4.2-rc.4`) | +| Event | PR | First release | `first_seen_at_utc` | +| ---------------------------------------------- | ---------------------------------------------------- | --------------------------------------- | ----------------------------------------- | +| `onboarding_feature_setup_run` | `#1853` (`2e5ac1c8ebdc93f6281622d9e4ef60ab14c71096`) | `v1.4.2-rc.1` at `2026-05-14T22:23:46Z` | `2026-05-15T05:55:37.248Z` (`1.4.2-rc.4`) | +| `onboarding_feature_setup_terminal_opened` | `#1853` (`2e5ac1c8ebdc93f6281622d9e4ef60ab14c71096`) | `v1.4.2-rc.1` at `2026-05-14T22:23:46Z` | `2026-05-15T05:55:37.261Z` (`1.4.2-rc.4`) | | `onboarding_feature_setup_terminal_interacted` | `#1853` (`2e5ac1c8ebdc93f6281622d9e4ef60ab14c71096`) | `v1.4.2-rc.1` at `2026-05-14T22:23:46Z` | `2026-05-15T11:47:28.022Z` (`1.4.2-rc.6`) | -| `onboarding_feature_setup_toggled` | `#1853` (`2e5ac1c8ebdc93f6281622d9e4ef60ab14c71096`) | `v1.4.2-rc.1` at `2026-05-14T22:23:46Z` | `2026-05-17T03:03:54.535Z` (`1.4.3`) | -| `onboarding_task_sources_snapshot` | `#2275` (`ececd27fd8203a7acb3e430fa016fb4653aaa93c`) | `v1.4.7-rc.1` at `2026-05-19T00:47:11Z` | `2026-05-19T04:18:23.872Z` (`1.4.7`) | +| `onboarding_feature_setup_toggled` | `#1853` (`2e5ac1c8ebdc93f6281622d9e4ef60ab14c71096`) | `v1.4.2-rc.1` at `2026-05-14T22:23:46Z` | `2026-05-17T03:03:54.535Z` (`1.4.3`) | +| `onboarding_task_sources_snapshot` | `#2275` (`ececd27fd8203a7acb3e430fa016fb4653aaa93c`) | `v1.4.7-rc.1` at `2026-05-19T00:47:11Z` | `2026-05-19T04:18:23.872Z` (`1.4.7`) | Dashboard caveats: @@ -121,26 +121,26 @@ Dashboard caveats: Scope: first feature-wall tour event family before the inline onboarding tour. This added base open/close and tile activity telemetry. Historical `feature_wall_closed` rows from this rollout have `dwell_ms`; the source/depth fields needed for tour cohort retention only arrive in the later tour telemetry rollout. -| Field | Value | -| --- | --- | -| PR | `#1772` | -| Merge commit | `fdf7d9e97a46f27b3667d9bae6c8aca5d11345d8` | -| `code_merged_at_utc` | `2026-05-14T22:20:50Z` | -| First release | `v1.4.2-rc.1` | -| First release commit | `c66e9801fe5f159cac23564ec7c028957973692a` | -| `first_released_at_utc` | `2026-05-14T22:23:46Z` | -| `first_seen_at_utc` | `2026-05-15T06:15:30.263Z` on `feature_wall_opened { source: 'popup' }` (`1.4.2-rc.4`) | +| Field | Value | +| ------------------------ | ---------------------------------------------------------------------------------------------------------------- | +| PR | `#1772` | +| Merge commit | `fdf7d9e97a46f27b3667d9bae6c8aca5d11345d8` | +| `code_merged_at_utc` | `2026-05-14T22:20:50Z` | +| First release | `v1.4.2-rc.1` | +| First release commit | `c66e9801fe5f159cac23564ec7c028957973692a` | +| `first_released_at_utc` | `2026-05-14T22:23:46Z` | +| `first_seen_at_utc` | `2026-05-15T06:15:30.263Z` on `feature_wall_opened { source: 'popup' }` (`1.4.2-rc.4`) | | `dashboard_ready_at_utc` | Event-dependent. Useful for base feature-wall usage after first-seen rows, not for inline-tour cohort retention. | PostHog evidence checked at `2026-05-23T23:34:32Z`: -| Event/property | `first_seen_at_utc` | -| --- | --- | -| `feature_wall_opened { source: 'popup' }` | `2026-05-15T06:15:30.263Z` (`1.4.2-rc.4`) | -| `feature_wall_tile_focused` | `2026-05-15T06:15:31.073Z` (`1.4.2-rc.4`) | -| `feature_wall_closed` with legacy `dwell_ms` only | `2026-05-15T06:18:22.677Z` | -| `feature_wall_tile_clicked` | `2026-05-17T04:35:04.142Z` (`1.4.3`) | -| `feature_wall_opened { source: 'help_menu' }` | `2026-05-17T07:02:23.278Z` (`1.4.3`) | +| Event/property | `first_seen_at_utc` | +| ------------------------------------------------- | ----------------------------------------- | +| `feature_wall_opened { source: 'popup' }` | `2026-05-15T06:15:30.263Z` (`1.4.2-rc.4`) | +| `feature_wall_tile_focused` | `2026-05-15T06:15:31.073Z` (`1.4.2-rc.4`) | +| `feature_wall_closed` with legacy `dwell_ms` only | `2026-05-15T06:18:22.677Z` | +| `feature_wall_tile_clicked` | `2026-05-17T04:35:04.142Z` (`1.4.3`) | +| `feature_wall_opened { source: 'help_menu' }` | `2026-05-17T07:02:23.278Z` (`1.4.3`) | Dashboard caveats: @@ -151,16 +151,16 @@ Dashboard caveats: Scope: optional "Explore Orca" tour during onboarding and the Help menu entry point. This shipped the product surface, not the full retention cohort instrumentation. It also added `source = 'onboarding'` to the feature-wall open source enum and added feature-wall group/feature/docs click events with source. -| Field | Value | -| --- | --- | -| PR | `#2652` | -| Merge commit | `669ade23133b9905fff1b6ed22755ee2911ea517` | -| `code_merged_at_utc` | `2026-05-23T19:21:14Z` | -| First release | `v1.4.23-rc.0` | -| First release commit | `1f1171e0dee5d0fbf969de56845882bf8deb9037` | -| `first_released_at_utc` | `2026-05-23T20:14:38Z` | -| First app version observed | `1.4.23-rc.0` at `2026-05-23T20:54:12.343Z` | -| `dashboard_ready_at_utc` | `TBD`; do not use this surface-only rollout for tour cohort retention tiles. | +| Field | Value | +| -------------------------- | ---------------------------------------------------------------------------- | +| PR | `#2652` | +| Merge commit | `669ade23133b9905fff1b6ed22755ee2911ea517` | +| `code_merged_at_utc` | `2026-05-23T19:21:14Z` | +| First release | `v1.4.23-rc.0` | +| First release commit | `1f1171e0dee5d0fbf969de56845882bf8deb9037` | +| `first_released_at_utc` | `2026-05-23T20:14:38Z` | +| First app version observed | `1.4.23-rc.0` at `2026-05-23T20:54:12.343Z` | +| `dashboard_ready_at_utc` | `TBD`; do not use this surface-only rollout for tour cohort retention tiles. | Dashboard caveats: @@ -179,32 +179,32 @@ Added/changed signals: - `feature_wall_closed` optional `source`, `exit_action`, `furthest_step`, `last_group_id`, and bounded visited/completed depth counts. - `feature_wall_opened { source: 'onboarding' }` through the inline tour surface. -| Field | Value | -| --- | --- | -| PR | `#2713` | -| Branch commit | `9115143927dce38547545a07e58b85d17796fd7a` | -| Merge commit | `b9c55bb07127f799528761ec31d7e2b7f8598c07` | -| `code_merged_at_utc` | `2026-05-23T21:15:11Z` | -| First release | `v1.4.23-rc.1` | -| First release commit | `c74765922e770c6540496735a5a2be838457256a` | -| `first_released_at_utc` | `2026-05-23T21:16:38Z` | -| `first_seen_at_utc` | `TBD` | -| `dashboard_ready_at_utc` | `TBD` | +| Field | Value | +| ------------------------ | ------------------------------------------ | +| PR | `#2713` | +| Branch commit | `9115143927dce38547545a07e58b85d17796fd7a` | +| Merge commit | `b9c55bb07127f799528761ec31d7e2b7f8598c07` | +| `code_merged_at_utc` | `2026-05-23T21:15:11Z` | +| First release | `v1.4.23-rc.1` | +| First release commit | `c74765922e770c6540496735a5a2be838457256a` | +| `first_released_at_utc` | `2026-05-23T21:16:38Z` | +| `first_seen_at_utc` | `TBD` | +| `dashboard_ready_at_utc` | `TBD` | Required readiness signals: -| Signal | `first_seen_at_utc` | -| --- | --- | -| `onboarding_tour_outcome` with valid `outcome` and joinable fresh-install start | `TBD` | -| `onboarding_step_viewed { value_kind: 'tour' }` | `TBD` | -| `feature_wall_closed` with non-null `source`, `exit_action`, and expected depth fields | `TBD` | +| Signal | `first_seen_at_utc` | +| -------------------------------------------------------------------------------------- | ------------------- | +| `onboarding_tour_outcome` with valid `outcome` and joinable fresh-install start | `TBD` | +| `onboarding_step_viewed { value_kind: 'tour' }` | `TBD` | +| `feature_wall_closed` with non-null `source`, `exit_action`, and expected depth fields | `TBD` | QA/opportunistic signals: -| Signal | `first_seen_at_utc` | -| --- | --- | +| Signal | `first_seen_at_utc` | +| ------------------------------------------------ | --------------------------------------------------------------- | | `onboarding_step_skipped { value_kind: 'tour' }` | `TBD`; do not gate dashboard readiness on natural skip traffic. | -| `feature_wall_opened { source: 'onboarding' }` | `TBD`; surface availability only. | +| `feature_wall_opened { source: 'onboarding' }` | `TBD`; surface availability only. | PostHog evidence checked at `2026-05-23T23:34:32Z`: diff --git a/docs/ssh-config-target-compatibility.md b/docs/ssh-config-target-compatibility.md new file mode 100644 index 00000000000..07817dc41f7 --- /dev/null +++ b/docs/ssh-config-target-compatibility.md @@ -0,0 +1,61 @@ +# SSH Config Target Compatibility + +## Problem + +- `src/main/ssh/ssh-connection-utils.ts:108` resolves OpenSSH config with `ssh -G`, but then prefers the persisted `target.host` over `resolved.hostname`. +- `src/main/ssh/ssh-connection-utils.ts:109` treats persisted port `22` as an explicit override, so a config-host target can ignore a resolved non-default `Port`. +- `src/main/ssh/ssh-config-parser.ts:210` imports config aliases with `host` set to the alias when the concrete `Host` block lacks an inline `HostName`; later `ssh -G` may know the real host, but the connection path ignores it. +- `src/renderer/src/components/settings/SshPane.tsx:63` requires both host and username, even though OpenSSH config aliases can resolve the user and users commonly paste `user@host:port` targets. +- `src/main/ssh/ssh-config-parser.ts:330` parses `ForwardAgent`, but `src/main/ssh/ssh-connection-utils.ts:112` did not pass it into ssh2, so remote git commands that rely on the local agent could fail. + +## Goal + +Make Orca behave like a mature SSH client for common config-host flows: aliases imported from `~/.ssh/config`, aliases with inherited `HostName`/`Port`, and pasted SSH targets should connect without users re-entering information that OpenSSH can resolve. + +## Non-goals + +- Do not add persistent secret storage for SSH passwords or key passphrases. +- Do not redesign the whole SSH settings page. +- Do not change relay deployment or remote PTY lease semantics. +- Do not add a known-host trust UI in this patch. + +## Design + +1. Preserve explicit target overrides while letting config aliases use resolved values. + - In `buildConnectConfig`, prefer `resolved.hostname` only when the persisted host is blank, the same as `configHost`, or the same as the label. + - Prefer `resolved.port` when the target is a config-host target still on the default `22`; keep non-default target ports as explicit overrides. + - Continue using target username first, then resolved user. + +2. Honor resolved agent forwarding where ssh2 can support it. + - Set `agentForward` only when resolved config requested forwarding and an agent is actually configured. + - Leave system-SSH transport unchanged because it already delegates to OpenSSH config. + +3. Normalize settings form drafts before save. + - Accept `ssh://user@host:port`, `user@host:port`, and plain aliases in the Host field. + - Auto-fill username and port from pasted inputs only when the dedicated fields are still empty/default. + - Allow username to be omitted; `ssh -G` can provide it during connect. + +4. Keep UI changes small. + - Rename copy only where needed to avoid implying username is mandatory. + - Render username-less targets without a leading `@`. + - Do not introduce new colors, typography, or layout patterns. + +5. Cover behavior with focused tests. + - Add connection-config tests for config-host resolved hostname/port precedence. + - Add connection-config tests for `ForwardAgent yes`. + - Add renderer utility tests for pasted SSH target normalization. + +## Edge Cases + +- Explicit non-default port in Orca still wins over `ssh -G`. +- Empty or unparsable host input remains invalid. +- IPv6 bracket syntax is accepted for `ssh://` URLs and preserved conservatively for scp-like inputs. +- Plain config aliases remain valid even without a username. + +## Rollout + +1. Add the renderer draft-normalization helper and tests. +2. Wire the SSH settings form save path and labels to the helper. +3. Update `buildConnectConfig` precedence/agent-forwarding and tests. +4. Clean up username-less target display. +5. Run focused tests and typecheck/lint where feasible. diff --git a/mobile/.oxlintrc.json b/mobile/.oxlintrc.json index 90d8894e282..8263b919379 100644 --- a/mobile/.oxlintrc.json +++ b/mobile/.oxlintrc.json @@ -1,3 +1,4 @@ { + "$schema": "./node_modules/oxlint/configuration_schema.json", "rules": {} } diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index fc4765448c6..30069d6be2c 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -9,7 +9,7 @@ import { TextInput } from 'react-native' import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' -import { useLocalSearchParams, useRouter } from 'expo-router' +import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router' import { Search, X, @@ -62,6 +62,11 @@ import { loadPreferences, savePreferences } from '../../../src/storage/preferences' +import { + createInitialHostRouteActionState, + resolveHostRouteActionState, + setHostRouteNewWorktreeVisible +} from '../../../src/host-route-action-state' // Why: locally-typed subset of the desktop's RuntimeStatus we read from // `status.get`. Only the version fields matter to mobile today; everything @@ -340,7 +345,9 @@ export default function HostScreen() { const [actionTarget, setActionTarget] = useState(null) const [confirmDelete, setConfirmDelete] = useState(null) const [confirmRemoveHost, setConfirmRemoveHost] = useState(false) - const [showNewWorktree, setShowNewWorktree] = useState(false) + const [routeActionState, setRouteActionState] = useState(() => + createInitialHostRouteActionState(action) + ) const [sleptIds, setSleptIds] = useState>(new Set()) // Persisted pin state @@ -348,9 +355,16 @@ export default function HostScreen() { const [_prefsLoaded, setPrefsLoaded] = useState(false) const [collapsedGroups, setCollapsedGroups] = useState>(new Set()) - useEffect(() => { - if (action === 'newWorktree') setShowNewWorktree(true) - }, [action]) + const resolvedRouteActionState = resolveHostRouteActionState(routeActionState, action) + // Why: `action=newWorktree` is a route-derived open edge. Resolve it before + // commit, but don't reopen after the user closes while the same URL remains. + if (resolvedRouteActionState !== routeActionState) { + setRouteActionState(resolvedRouteActionState) + } + const showNewWorktree = resolvedRouteActionState.showNewWorktree + const setShowNewWorktreeVisible = useCallback((visible: boolean) => { + setRouteActionState((current) => setHostRouteNewWorktreeVisible(current, visible)) + }, []) // Load persisted pins and preferences useEffect(() => { @@ -481,12 +495,6 @@ export default function HostScreen() { } }, [client, connState, hostId]) - useEffect(() => { - if (connState === 'connected') { - void fetchWorktrees() - } - }, [connState, fetchWorktrees]) - // Why: read desktop's protocol version from status.get on every connect // and re-evaluate compatibility. If the desktop declares this mobile // build too old (or vice versa via the local minimum), the host detail @@ -528,13 +536,18 @@ export default function HostScreen() { } }, [connState, client]) - useEffect(() => { - if (connState !== 'connected') return - const interval = setInterval(() => { + useFocusEffect( + useCallback(() => { + if (connState !== 'connected') return void fetchWorktrees() - }, 3000) - return () => clearInterval(interval) - }, [connState, fetchWorktrees]) + // Why: React Navigation keeps previous stack screens mounted; only + // poll the host list while this route is visible. + const interval = setInterval(() => { + void fetchWorktrees() + }, 3000) + return () => clearInterval(interval) + }, [connState, fetchWorktrees]) + ) const updateLocalPins = useCallback( (worktreeId: string, pinned: boolean) => { @@ -871,7 +884,7 @@ export default function HostScreen() { setShowNewWorktree(true)} + onPress={() => setShowNewWorktreeVisible(true)} disabled={connState !== 'connected'} > setShowNewWorktree(false)} + onClose={() => setShowNewWorktreeVisible(false)} /> ) diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 26d42bab2cc..f30870d9a30 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -27,6 +27,7 @@ import { ChevronLeft, ChevronRight, ChevronsRight, + Copy, Eraser, Folder, File, @@ -34,10 +35,12 @@ import { GitBranch, Globe, Keyboard as KeyboardIcon, + MessageSquare, Mic, Monitor, Plus, RefreshCw, + Send, Smartphone, SquareTerminal, X @@ -67,8 +70,10 @@ import { loadTerminalAccessoryLayout } from '../../../../src/terminal/terminal-accessory-layout' import { + clearTerminalLiveInputFocusTimer, getTerminalLiveSpecialKeyBytes, - isTerminalLiveInputWithinByteLimit + isTerminalLiveInputWithinByteLimit, + scheduleTerminalLiveInputFocus } from '../../../../src/terminal/terminal-live-input' import { countTerminalGestureInputSequences } from '../../../../src/terminal/terminal-gesture-input' import { MobileBrowserPane, type MobileBrowserTab } from '../../../../src/browser/MobileBrowserPane' @@ -88,6 +93,13 @@ import { buildMobileDiffLines, type MobileDiffLine } from '../../../../src/session/mobile-diff-lines' +import { + addMobileDiffComment, + formatDiffComments, + normalizeMobileDiffComments, + removeDeliveredMobileDiffComments, + removeMobileDiffComments +} from '../../../../src/session/mobile-diff-comments' import { buildPlainMobileDiffSyntaxLines, highlightMobileCode, @@ -109,7 +121,13 @@ import { type MobileNewTabAgentOption, type MobileNewTabAgentSettings } from '../../../../src/session/mobile-new-tab-agent-options' +import { + createMobileSessionCreateWarningState, + dismissMobileSessionCreateWarningState, + reconcileMobileSessionCreateWarningState +} from '../../../../src/session/mobile-session-create-warning-state' import { colors, spacing, radii, typography } from '../../../../src/theme/mobile-theme' +import type { DiffComment } from '../../../../../src/shared/types' type Terminal = TerminalRecord @@ -188,6 +206,20 @@ type FileDocState = type RenderableDiffLine = MobileHighlightedDiffLine +type DiffCommentActions = { + comments: DiffComment[] + busy: boolean + onAdd: (filePath: string, lineNumber: number, body: string) => Promise + onDelete: (commentId: string) => Promise + onCopyAll: () => Promise + onSendAll: () => void +} + +type DiffNotesDelivery = { + prompt: string + comments: DiffComment[] +} + type ReadyFileDocState = Extract type FileSyntaxState = { @@ -543,37 +575,136 @@ function SyntaxSegments({ segments }: { segments: MobileSyntaxSegment[] }) { function DiffLineRow({ line, title, - index + index, + comments, + activeCommentLine, + commentDraft, + commentsBusy, + onStartComment, + onCancelComment, + onDraftChange, + onSubmitComment, + onDeleteComment }: { line: RenderableDiffLine title: string index: number + comments: DiffComment[] + activeCommentLine: number | null + commentDraft: string + commentsBusy: boolean + onStartComment: (lineNumber: number) => void + onCancelComment: () => void + onDraftChange: (value: string) => void + onSubmitComment: (lineNumber: number) => void + onDeleteComment: (commentId: string) => void }) { + const commentLine = line.newLineNumber + const isCommenting = commentLine !== undefined && activeCommentLine === commentLine + const canComment = commentLine !== undefined + // Why: review notes are anchored to the modified side, so the single mobile + // gutter should show the same line number the note will reference. + const gutterLineNumber = line.newLineNumber ?? line.oldLineNumber ?? '' return ( - - {line.oldLineNumber ?? line.newLineNumber ?? ''} - + + {gutterLineNumber} - {line.kind === 'add' ? '+ ' : line.kind === 'delete' ? '- ' : ' '} + + {line.kind === 'add' ? '+ ' : line.kind === 'delete' ? '- ' : ' '} + + - - + {canComment ? ( + [ + styles.diffCommentAddButton, + pressed && styles.diffCommentAddButtonPressed, + commentsBusy && styles.diffCommentButtonDisabled + ]} + disabled={commentsBusy} + onPress={() => { + if (commentLine !== undefined) onStartComment(commentLine) + }} + accessibilityLabel={`Add note on line ${commentLine}`} + > + + + ) : null} + + {comments.length > 0 ? ( + + {comments.map((comment) => ( + + + + Line {comment.lineNumber} + onDeleteComment(comment.id)} + accessibilityLabel={`Delete note on line ${comment.lineNumber}`} + > + + + + {comment.body} + + ))} + + ) : null} + {isCommenting ? ( + + + + + Cancel + + { + if (commentLine !== undefined) onSubmitComment(commentLine) + }} + > + Save note + + + + ) : null} ) } @@ -582,12 +713,14 @@ function FileReader({ doc, title, relativePath, - language + language, + diffCommentActions }: { doc: FileDocState | undefined title: string relativePath: string language?: string + diffCommentActions?: DiffCommentActions }) { const syntaxLanguage = useMemo( () => resolveMobileSyntaxLanguage(relativePath || title, language), @@ -595,6 +728,8 @@ function FileReader({ ) const [fileSyntax, setFileSyntax] = useState(null) const [diffSyntax, setDiffSyntax] = useState(null) + const [activeCommentLine, setActiveCommentLine] = useState(null) + const [commentDraft, setCommentDraft] = useState('') const plainDiffLines = useMemo( () => doc?.status === 'ready' && doc.kind === 'diff' @@ -602,9 +737,80 @@ function FileReader({ : [], [doc] ) + const diffCommentsForFile = useMemo( + () => + diffCommentActions?.comments.filter( + (comment) => comment.filePath === relativePath && comment.source !== 'markdown' + ) ?? [], + [diffCommentActions?.comments, relativePath] + ) + const diffCommentsByLine = useMemo(() => { + const map = new Map() + for (const comment of diffCommentsForFile) { + const list = map.get(comment.lineNumber) ?? [] + list.push(comment) + map.set(comment.lineNumber, list) + } + for (const list of map.values()) { + list.sort((a, b) => a.createdAt - b.createdAt) + } + return map + }, [diffCommentsForFile]) + + const startComment = useCallback((lineNumber: number) => { + setActiveCommentLine(lineNumber) + setCommentDraft('') + }, []) + + const cancelComment = useCallback(() => { + setActiveCommentLine(null) + setCommentDraft('') + }, []) + + const submitComment = useCallback( + (lineNumber: number) => { + if (!diffCommentActions) return + void diffCommentActions.onAdd(relativePath, lineNumber, commentDraft).then((added) => { + if (added) { + setActiveCommentLine(null) + setCommentDraft('') + } + }) + }, + [commentDraft, diffCommentActions, relativePath] + ) + const renderDiffLine: ListRenderItem = useCallback( - ({ item, index }) => , - [title] + ({ item, index }) => ( + { + if (diffCommentActions) void diffCommentActions.onDelete(commentId) + }} + /> + ), + [ + activeCommentLine, + cancelComment, + commentDraft, + diffCommentActions, + diffCommentsByLine, + startComment, + submitComment, + title + ] ) useEffect(() => { @@ -651,8 +857,52 @@ function FileReader({ if (doc.kind === 'diff') { const activeDiffSyntax = diffSyntax?.doc === doc && diffSyntax.language === syntaxLanguage ? diffSyntax.lines : null + const commentCount = diffCommentActions?.comments.length ?? 0 + const unsentCommentCount = + diffCommentActions?.comments.filter((comment) => !comment.sentAt).length ?? 0 + const commentsBusy = diffCommentActions?.busy === true + const canCopyNotes = commentCount > 0 && !commentsBusy + const canSendNotes = unsentCommentCount > 0 && !commentsBusy return ( + {diffCommentActions ? ( + + + + + {commentCount === 0 + ? 'No review notes' + : `${commentCount} review ${commentCount === 1 ? 'note' : 'notes'}`} + + + + void diffCommentActions.onCopyAll()} + accessibilityLabel="Copy review notes" + > + + Copy + + + + Send + + + + ) : null} ) @@ -726,11 +977,18 @@ export default function SessionScreen() { const [markdownDocs, setMarkdownDocs] = useState>(new Map()) const markdownDocsRef = useRef>(new Map()) const [fileDocs, setFileDocs] = useState>(new Map()) + const [diffComments, setDiffComments] = useState([]) + const diffCommentsRef = useRef([]) + const [diffCommentBusy, setDiffCommentBusy] = useState(false) + const [pendingDiffNotesDelivery, setPendingDiffNotesDelivery] = + useState(null) const [creating, setCreating] = useState(false) const [creatingBrowser, setCreatingBrowser] = useState(false) const [creatingMarkdown, setCreatingMarkdown] = useState(false) const [createError, setCreateError] = useState('') - const [createWarning, setCreateWarning] = useState(initialCreateWarning) + const [createWarningState, setCreateWarningState] = useState(() => + createMobileSessionCreateWarningState(initialCreateWarning) + ) const [showCreateTabDrawer, setShowCreateTabDrawer] = useState(false) const [createTabAgentLoadState, setCreateTabAgentLoadState] = useState('idle') @@ -781,6 +1039,8 @@ export default function SessionScreen() { const [canPaste, setCanPaste] = useState(false) const [toastMessage, setToastMessage] = useState(null) const toastOpacityRef = useRef(new Animated.Value(0)) + const toastHideTimerRef = useRef | null>(null) + const toastSeqRef = useRef(0) // Why: WebView pushes terminal modes (bracketed-paste, alt-screen) on every // change so paste reads a synchronous snapshot — no round-trip required. const ptyModesRef = useRef>(new Map()) @@ -797,6 +1057,7 @@ export default function SessionScreen() { const viewportMeasuredRef = useRef(false) const terminalRefs = useRef>(new Map()) const liveInputRef = useRef(null) + const liveInputFocusTimerRef = useRef | null>(null) const terminalUnsubsRef = useRef void>>(new Map()) const subscribingHandlesRef = useRef>(new Set()) const initializedHandlesRef = useRef>(new Set()) @@ -813,6 +1074,9 @@ export default function SessionScreen() { const markdownSaveSeqRef = useRef>(new Map()) const markdownSaveInFlightRef = useRef>(new Set()) const subscribeSeqRef = useRef>(new Map()) + // Why: post-RPC refresh timers capture this screen and must not survive + // route reuse or unmount. + const delayedActionTimersRef = useRef>>(new Set()) // Why: server-side layout state machine emits a monotonic seq on every // applyLayout. Track the highest seq we've observed per handle and drop // any scrollback/resized event with a strictly older seq — these are @@ -835,28 +1099,75 @@ export default function SessionScreen() { activeSessionTab?.type !== 'browser' const liveInputEnabled = activeHandle ? liveInputTerminalHandles.has(activeHandle) : false const [browserScreencastSupported, setBrowserScreencastSupported] = useState(null) + // Why: terminal gesture/input callbacks are intentionally stable and + // imperative; keep their refs current before commit instead of one effect later. + clientRef.current = client + connStateRef.current = connState + activeSessionTabTypeRef.current = activeSessionTab?.type ?? null + sessionTabsRef.current = sessionTabs + activeSessionTabIdRef.current = activeSessionTabId + markdownDocsRef.current = markdownDocs + const reconciledCreateWarningState = reconcileMobileSessionCreateWarningState( + createWarningState, + initialCreateWarning + ) + // Why: Expo can reuse this screen for a new route. Reconcile before paint + // so a dismissed old creation warning never flashes for the next session. + if (reconciledCreateWarningState !== createWarningState) { + setCreateWarningState(reconciledCreateWarningState) + } + const createWarning = reconciledCreateWarningState.visible - useEffect(() => { - setCreateWarning(initialCreateWarning) - }, [initialCreateWarning]) - - const showToast = useCallback((message: string, durationMs = 1200) => { - setToastMessage(message) - Animated.timing(toastOpacityRef.current, { - toValue: 1, - duration: 150, - useNativeDriver: true - }).start(() => { - setTimeout(() => { - Animated.timing(toastOpacityRef.current, { - toValue: 0, - duration: 200, - useNativeDriver: true - }).start(() => setToastMessage(null)) - }, durationMs) - }) + const clearDelayedActionTimers = useCallback(() => { + for (const timer of delayedActionTimersRef.current) { + clearTimeout(timer) + } + delayedActionTimersRef.current.clear() }, []) + const scheduleDelayedAction = useCallback((fn: () => void, ms: number) => { + const timer = setTimeout(() => { + delayedActionTimersRef.current.delete(timer) + fn() + }, ms) + delayedActionTimersRef.current.add(timer) + }, []) + + const clearToastHideTimer = useCallback(() => { + if (!toastHideTimerRef.current) return + clearTimeout(toastHideTimerRef.current) + toastHideTimerRef.current = null + }, []) + + const showToast = useCallback( + (message: string, durationMs = 1200) => { + const seq = toastSeqRef.current + 1 + toastSeqRef.current = seq + clearToastHideTimer() + setToastMessage(message) + Animated.timing(toastOpacityRef.current, { + toValue: 1, + duration: 150, + useNativeDriver: true + }).start(({ finished }) => { + if (!finished || toastSeqRef.current !== seq) return + toastHideTimerRef.current = setTimeout(() => { + toastHideTimerRef.current = null + Animated.timing(toastOpacityRef.current, { + toValue: 0, + duration: 200, + useNativeDriver: true + }).start((result) => { + if (result.finished && toastSeqRef.current === seq) { + setToastMessage(null) + } + }) + }, durationMs) + }) + }, + [clearToastHideTimer] + ) + const dictation = useMobileDictation({ client, enabled: canSend, @@ -876,20 +1187,8 @@ export default function SessionScreen() { }) useEffect(() => { - activeSessionTabTypeRef.current = activeSessionTab?.type ?? null - }, [activeSessionTab]) - - useEffect(() => { - sessionTabsRef.current = sessionTabs - }, [sessionTabs]) - - useEffect(() => { - activeSessionTabIdRef.current = activeSessionTabId - }, [activeSessionTabId]) - - useEffect(() => { - markdownDocsRef.current = markdownDocs - }, [markdownDocs]) + diffCommentsRef.current = diffComments + }, [diffComments]) const getTerminalRef = useCallback((handle: string | null) => { return handle ? terminalRefs.current.get(handle) : undefined @@ -1039,7 +1338,7 @@ export default function SessionScreen() { // xterm's scrollWidth can still be transient when it commits. // Re-fire after a short delay so it runs against a settled DOM. // Mirrors the 'resized' handler below. - setTimeout(() => getTerminalRef(handle)?.resetZoom(), 200) + scheduleDelayedAction(() => getTerminalRef(handle)?.resetZoom(), 200) // Why: viewport measurement needs xterm to be initialized (cell // dimensions come from the renderer). On the first subscribe the // WebView hasn't loaded yet, so viewportRef is null and the server @@ -1132,7 +1431,7 @@ export default function SessionScreen() { new Map(prev).set(handle, data.displayMode as MobileDisplayMode) ) } - setTimeout(() => getTerminalRef(handle)?.resetZoom(), 200) + scheduleDelayedAction(() => getTerminalRef(handle)?.resetZoom(), 200) } } ) @@ -1144,7 +1443,7 @@ export default function SessionScreen() { } subscribingHandlesRef.current.delete(handle) }, - [client, getTerminalRef] + [client, getTerminalRef, scheduleDelayedAction] ) // Why: toggles between phone and desktop mode via server RPC. The server @@ -1509,6 +1808,138 @@ export default function SessionScreen() { [client, worktreeId] ) + const loadDiffComments = useCallback(async (): Promise => { + if (!client || connState !== 'connected' || !worktreeId) { + setDiffComments([]) + return + } + const response = await client.sendRequest('worktree.show', { + worktree: `id:${worktreeId}` + }) + if (!response.ok) { + return + } + const result = (response as RpcSuccess).result as { + worktree?: { diffComments?: unknown } + } + setDiffComments(normalizeMobileDiffComments(result.worktree?.diffComments, worktreeId)) + }, [client, connState, worktreeId]) + + const persistDiffComments = useCallback( + async (comments: readonly DiffComment[]): Promise => { + if (!client || connState !== 'connected') { + throw new Error('Waiting for desktop...') + } + const response = await client.sendRequest('worktree.set', { + worktree: `id:${worktreeId}`, + diffComments: comments + }) + if (!response.ok) { + throw new Error((response as RpcFailure).error.message || 'Failed to save review notes') + } + }, + [client, connState, worktreeId] + ) + + useEffect(() => { + void loadDiffComments() + }, [loadDiffComments]) + + const addDiffCommentForFile = useCallback( + async (filePath: string, lineNumber: number, body: string): Promise => { + if (diffCommentBusy) return false + const nextId = `mobile-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` + const result = addMobileDiffComment(diffCommentsRef.current, { + id: nextId, + worktreeId, + filePath, + lineNumber, + body, + createdAt: Date.now() + }) + if (!result.comment) return false + const previous = diffCommentsRef.current + setDiffCommentBusy(true) + setDiffComments(result.comments) + try { + await persistDiffComments(result.comments) + triggerSuccess() + showToast('Note added') + return true + } catch (err) { + setDiffComments(previous) + triggerError() + showToast(err instanceof Error ? err.message : 'Failed to save note', 1600) + return false + } finally { + setDiffCommentBusy(false) + } + }, + [diffCommentBusy, persistDiffComments, showToast, worktreeId] + ) + + const deleteDiffCommentForFile = useCallback( + async (commentId: string): Promise => { + if (diffCommentBusy) return + const previous = diffCommentsRef.current + const next = removeMobileDiffComments(previous, new Set([commentId])) + if (next.length === previous.length) return + setDiffCommentBusy(true) + setDiffComments(next) + try { + await persistDiffComments(next) + triggerSelection() + } catch (err) { + setDiffComments(previous) + triggerError() + showToast(err instanceof Error ? err.message : 'Failed to delete note', 1600) + } finally { + setDiffCommentBusy(false) + } + }, + [diffCommentBusy, persistDiffComments, showToast] + ) + + const copyDiffCommentsToClipboard = useCallback(async (): Promise => { + const comments = diffCommentsRef.current + if (comments.length === 0) return + try { + await Clipboard.setStringAsync(formatDiffComments(comments)) + triggerSuccess() + showToast('Notes copied') + } catch { + triggerError() + showToast("Couldn't copy notes", 1600) + } + }, [showToast]) + + const sendDiffCommentsToAgent = useCallback((): void => { + const comments = diffCommentsRef.current.filter((comment) => !comment.sentAt) + if (comments.length === 0) return + setPendingDiffNotesDelivery({ + comments: [...comments], + prompt: formatDiffComments(comments) + }) + }, []) + + const clearDeliveredDiffComments = useCallback( + async (delivered: readonly DiffComment[]): Promise => { + const previous = diffCommentsRef.current + const next = removeDeliveredMobileDiffComments(previous, delivered) + if (next.length === previous.length) return + setDiffCommentBusy(true) + setDiffComments(next) + try { + await persistDiffComments(next) + } catch { + setDiffComments(previous) + } finally { + setDiffCommentBusy(false) + } + }, + [persistDiffComments] + ) + const updateMarkdownLocalContent = useCallback((tabId: string, content: string) => { setMarkdownDocs((prev) => { const current = prev.get(tabId) @@ -1683,15 +2114,7 @@ export default function SessionScreen() { } }, [applySessionTabs, client, worktreeId]) - // Why: keep clientRef in sync with the shared client from - // useHostClient() so the existing imperative call sites - // (clientRef.current.sendRequest...) keep working without churn. useEffect(() => { - clientRef.current = client - }, [client]) - - useEffect(() => { - connStateRef.current = connState if (connState === 'connected') return for (const queued of terminalGestureInputQueuesRef.current.values()) { if (queued.timer) clearTimeout(queued.timer) @@ -1723,17 +2146,6 @@ export default function SessionScreen() { } }, [client, connState]) - // Why: only clear terminal cache on actual unmount. Running it whenever - // `client` changes — including the initial null → real-client transition - // from useHostClient's async open path — would unsubscribe terminals and - // wipe xterm state mid-subscribe on a normal session-screen mount. - useEffect(() => { - return () => { - clearTerminalCache() - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - // Why: deviceToken is read from host record so feature code can pass // `client.id` on subscribe/send for driver-state-machine identity. // The shared client itself stays alive across screens; we just need @@ -1908,7 +2320,11 @@ export default function SessionScreen() { setLiveInputTerminalHandles(new Set()) setMarkdownDocs(new Map()) setFileDocs(new Map()) - }, [clearTerminalCache, worktreeId]) + clearDelayedActionTimers() + return () => { + clearDelayedActionTimers() + } + }, [clearDelayedActionTimers, clearTerminalCache, worktreeId]) useEffect(() => { if (connState !== 'connected') return @@ -1998,14 +2414,20 @@ export default function SessionScreen() { return () => unsubscribe() }, [applySessionTabs, client, connState, worktreeId]) - useEffect(() => { - if (connState !== 'connected') return - const interval = setInterval(() => { + useFocusEffect( + useCallback(() => { + if (connState !== 'connected') return void fetchSessionTabs() void fetchTerminals() - }, 2000) - return () => clearInterval(interval) - }, [connState, fetchSessionTabs, fetchTerminals]) + // Why: the live tab subscription stays mounted for stream ownership, + // but the fallback list poll should stop while this route is hidden. + const interval = setInterval(() => { + void fetchSessionTabs() + void fetchTerminals() + }, 2000) + return () => clearInterval(interval) + }, [connState, fetchSessionTabs, fetchTerminals]) + ) // Why: unsubscribe the old terminal so the server restores its desktop dims // (clearing the phone-fit banner), then subscribe the new terminal with the @@ -2294,8 +2716,9 @@ export default function SessionScreen() { }) setLiveInputCapture('') if (nextEnabled) { - setTimeout(() => liveInputRef.current?.focus(), 50) + scheduleTerminalLiveInputFocus(liveInputFocusTimerRef, () => liveInputRef.current?.focus()) } else { + clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef) liveInputRef.current?.blur() } }, [activeHandle, liveInputTerminalHandles]) @@ -2532,9 +2955,23 @@ export default function SessionScreen() { }, [stopAccessoryRepeat] ) - useEffect(() => { - return () => stopAccessoryRepeat() - }, [stopAccessoryRepeat]) + const setMobileSessionRootRef = useCallback( + (node: View | null): void => { + if (node !== null) { + return + } + // Why: terminal subscriptions and route-level timers must clear only on + // real route detach; client churn during mount can otherwise wipe xterm + // state mid-subscribe. + toastSeqRef.current += 1 + clearTerminalCache() + clearToastHideTimer() + clearDelayedActionTimers() + clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef) + stopAccessoryRepeat() + }, + [clearDelayedActionTimers, clearTerminalCache, clearToastHideTimer, stopAccessoryRepeat] + ) const handleSelectionMode = useCallback((handle: string, active: boolean) => { if (handle !== activeHandleRef.current) return @@ -2683,7 +3120,8 @@ export default function SessionScreen() { }, [selectModeActive]) useEffect(() => { - if (!showCreateTabDrawer) { + const shouldLoadAgentOptions = showCreateTabDrawer || pendingDiffNotesDelivery !== null + if (!shouldLoadAgentOptions) { setCreateTabAgentLoadState('idle') setCreateTabAgentOptions([]) return @@ -2704,7 +3142,7 @@ export default function SessionScreen() { client.sendRequest('repo.list') ]) if (!settingsResponse.ok) { - throw new Error(settingsResponse.error.message) + throw new Error((settingsResponse as RpcFailure).error.message) } const settings = ( (settingsResponse as RpcSuccess).result as { @@ -2712,7 +3150,7 @@ export default function SessionScreen() { } ).settings if (!repoResponse.ok) { - throw new Error(repoResponse.error.message) + throw new Error((repoResponse as RpcFailure).error.message) } const repoId = getRepoIdFromMobileWorktreeId(worktreeId) if (!repoId) { @@ -2729,7 +3167,7 @@ export default function SessionScreen() { ? await client.sendRequest('preflight.detectRemoteAgents', { connectionId }) : await client.sendRequest('preflight.detectAgents') if (!detectedResponse.ok) { - throw new Error(detectedResponse.error.message) + throw new Error((detectedResponse as RpcFailure).error.message) } if (stale) { return @@ -2747,9 +3185,12 @@ export default function SessionScreen() { return () => { stale = true } - }, [client, connState, showCreateTabDrawer, worktreeId]) + }, [client, connState, pendingDiffNotesDelivery, showCreateTabDrawer, worktreeId]) - async function handleCreateTerminal(agent?: MobileNewTabAgentOption['agent']) { + async function handleCreateTerminal( + agent?: MobileNewTabAgentOption['agent'], + options?: { initialPrompt?: string; onPromptSent?: () => void } + ) { if (!client || creating) return setCreating(true) @@ -2805,11 +3246,42 @@ export default function SessionScreen() { return next }) subscribeToTerminal(createdHandle) + if (options?.initialPrompt?.trim()) { + void client + .sendRequest('terminal.send', { + terminal: createdHandle, + text: options.initialPrompt, + enter: true, + ...(deviceTokenRef.current + ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } + : {}) + }) + .then((sendResponse) => { + if (!sendResponse.ok) { + throw new Error( + (sendResponse as RpcFailure).error.message || 'Failed to send notes' + ) + } + const result = (sendResponse as RpcSuccess).result as { + send?: { accepted?: boolean } + } + if (result.send?.accepted === false) { + throw new Error('Terminal input is locked by another client.') + } + triggerSuccess() + showToast('Notes sent') + options.onPromptSent?.() + }) + .catch((err) => { + triggerError() + showToast(err instanceof Error ? err.message : "Couldn't send notes", 1800) + }) + } } else { activeHandleRef.current = null setActiveHandle(null) } - setTimeout(() => void fetchSessionTabs(), 500) + scheduleDelayedAction(() => void fetchSessionTabs(), 500) } else { setCreateError('Failed to create terminal') } @@ -2851,7 +3323,7 @@ export default function SessionScreen() { if (!openResponse.ok) { throw new Error((openResponse as RpcFailure).error.message) } - setTimeout(() => void fetchSessionTabs(), 300) + scheduleDelayedAction(() => void fetchSessionTabs(), 300) return } throw new Error('Unable to create untitled markdown note') @@ -2892,7 +3364,7 @@ export default function SessionScreen() { if (!response.ok) { throw new Error((response as RpcFailure).error.message) } - setTimeout(() => void fetchSessionTabs(), 300) + scheduleDelayedAction(() => void fetchSessionTabs(), 300) return true } catch (err) { const message = err instanceof Error ? err.message : 'Failed to create browser' @@ -2924,7 +3396,7 @@ export default function SessionScreen() { if (!response.ok) { throw new Error((response as RpcFailure).error.message) } - setTimeout(() => void fetchSessionTabs(), 250) + scheduleDelayedAction(() => void fetchSessionTabs(), 250) } catch (err) { const message = err instanceof Error ? err.message : 'Browser command failed' showToast(message, 1600) @@ -2952,7 +3424,7 @@ export default function SessionScreen() { terminalsRef.current = next return next }) - setTimeout(() => void fetchTerminals(), 300) + scheduleDelayedAction(() => void fetchTerminals(), 300) } } catch { // Rename failed — refresh will restore the server title. @@ -2982,7 +3454,7 @@ export default function SessionScreen() { subscribeToTerminal(replacement.handle) } } - setTimeout(() => void fetchTerminals(), 300) + scheduleDelayedAction(() => void fetchTerminals(), 300) } } catch { // Close failed — keep the local tab list unchanged. @@ -3009,7 +3481,7 @@ export default function SessionScreen() { activeHandleRef.current = null setActiveHandle(null) } - setTimeout(() => void fetchSessionTabs(), 300) + scheduleDelayedAction(() => void fetchSessionTabs(), 300) } } catch { // Close failed — keep the authoritative session snapshot visible. @@ -3132,9 +3604,57 @@ export default function SessionScreen() { } ] : [] + const sendDiffNotesAgentActions = + pendingDiffNotesDelivery === null + ? [] + : createTabAgentLoadState === 'loading' + ? [ + { + label: 'Detecting Agents', + icon: Bot, + disabled: true, + loading: true, + onPress: () => {} + } + ] + : createTabAgentOptions.length > 0 + ? createTabAgentOptions.map((option) => ({ + label: option.label, + hint: 'New agent session', + icon: Bot, + onPress: () => { + const delivery = pendingDiffNotesDelivery + setPendingDiffNotesDelivery(null) + if (!delivery) return + void handleCreateTerminal(option.agent, { + initialPrompt: delivery.prompt, + onPromptSent: () => void clearDeliveredDiffComments(delivery.comments) + }) + } + })) + : createTabAgentLoadState === 'loaded' + ? [ + { + label: 'No Enabled Agents', + icon: Bot, + disabled: true, + onPress: () => {} + } + ] + : createTabAgentLoadState === 'error' + ? [ + { + label: 'Agent Presets Unavailable', + hint: 'Copy notes instead', + icon: Bot, + disabled: true, + onPress: () => {} + } + ] + : [] return ( - + @@ -3274,7 +3794,7 @@ export default function SessionScreen() { {createWarning} setCreateWarning('')} + onPress={() => setCreateWarningState(dismissMobileSessionCreateWarningState)} accessibilityLabel="Dismiss workspace creation warning" hitSlop={8} > @@ -3336,6 +3856,18 @@ export default function SessionScreen() { title={activeFileTab.title || 'File'} relativePath={activeFileTab.relativePath} language={activeFileTab.language} + diffCommentActions={ + activeFileTab.diffSource === 'staged' || activeFileTab.diffSource === 'unstaged' + ? { + comments: diffComments, + busy: diffCommentBusy, + onAdd: addDiffCommentForFile, + onDelete: deleteDiffCommentForFile, + onCopyAll: copyDiffCommentsToClipboard, + onSendAll: sendDiffCommentsToAgent + } + : undefined + } /> {toastMessage && ( @@ -3697,6 +4229,34 @@ export default function SessionScreen() { onClose={() => setShowCreateTabDrawer(false)} /> + { + const delivery = pendingDiffNotesDelivery + setPendingDiffNotesDelivery(null) + if (!delivery) return + void Clipboard.setStringAsync(delivery.prompt) + .then(() => { + triggerSuccess() + showToast('Notes copied') + }) + .catch(() => { + triggerError() + showToast("Couldn't copy notes", 1500) + }) + } + } + ]} + onClose={() => setPendingDiffNotesDelivery(null)} + /> + { return new Promise((resolve) => setTimeout(resolve, ms)) } +function getRepoIdFromMobileWorktreeId(id: string): string { + // Why: mobile cannot import desktop shared modules in its standalone tsc run, + // but the runtime worktree id wire format is still `${repoId}::${path}`. + const separatorIdx = id.indexOf('::') + return separatorIdx === -1 ? id : id.slice(0, separatorIdx) +} + +async function resolveMobileBranchCompareBaseRef( + client: RpcClient, + worktreeId: string +): Promise { + const repoId = getRepoIdFromMobileWorktreeId(worktreeId) + if (!repoId) { + return null + } + + let repoBaseRef: string | null = null + const repoResponse = await client.sendRequest('repo.list') + if (repoResponse.ok) { + const repos = ((repoResponse as RpcSuccess).result as { repos?: RuntimeRepoSummary[] }).repos + const repo = repos?.find((candidate) => candidate.id === repoId) + repoBaseRef = repo?.worktreeBaseRef?.trim() || null + } + + if (repoBaseRef) { + return repoBaseRef + } + + const defaultResponse = await client.sendRequest('repo.baseRefDefault', { repo: `id:${repoId}` }) + if (!defaultResponse.ok) { + if (isMobileGitUnavailable(defaultResponse.error?.code, defaultResponse.error?.message)) { + return null + } + throw new Error(defaultResponse.error?.message || 'Unable to resolve branch base') + } + const result = (defaultResponse as RpcSuccess).result as RepoBaseRefDefaultResult + return result.defaultBaseRef?.trim() || null +} + function getWorktreeLabel(name: string | undefined, worktreeId: string): string { if (name?.trim()) { return name.trim() @@ -133,6 +224,27 @@ function statusColor(status: MobileGitFileStatus): string { } } +function formatBranchEntryMeta(entry: MobileGitBranchChangeEntry): string | null { + const stats = + entry.added !== undefined || entry.removed !== undefined + ? `+${entry.added ?? 0} -${entry.removed ?? 0}` + : null + if (entry.oldPath) { + return stats ? `from ${entry.oldPath}; ${stats}` : `from ${entry.oldPath}` + } + return stats +} + +function diffLinePrefix(kind: MobileDiffLine['kind']): string { + if (kind === 'add') return '+' + if (kind === 'delete') return '-' + return ' ' +} + +function diffLineNumber(line: MobileDiffLine): string { + return String(line.newLineNumber ?? line.oldLineNumber ?? '') +} + export default function MobileSourceControlScreen() { const params = useLocalSearchParams<{ hostId?: string | string[] @@ -148,6 +260,12 @@ export default function MobileSourceControlScreen() { const insets = useSafeAreaInsets() const { client, state: connState } = useHostClient(hostId) const [screenState, setScreenState] = useState({ kind: 'loading' }) + const [branchCompareState, setBranchCompareState] = useState({ + kind: 'idle' + }) + const [branchDiffPreview, setBranchDiffPreview] = useState( + null + ) const [busyAction, setBusyAction] = useState(null) const [commitMessage, setCommitMessage] = useState('') const [discardTarget, setDiscardTarget] = useState(null) @@ -155,21 +273,31 @@ export default function MobileSourceControlScreen() { const [actionError, setActionError] = useState(null) const [keyboardLift, setKeyboardLift] = useState(0) const [openingPath, setOpeningPath] = useState(null) + const [openingBranchPath, setOpeningBranchPath] = useState(null) const busyActionRef = useRef(null) const currentStatusIdentityRef = useRef('') + const currentBranchCompareIdentityRef = useRef('') const loadGenerationRef = useRef(0) + const branchCompareGenerationRef = useRef(0) const mountedRef = useRef(true) const openingPathRef = useRef(null) + const openingBranchPathRef = useRef(null) const statusLoadInFlightRef = useRef(null) const worktreeLabel = getWorktreeLabel(name, worktreeId) const statusIdentityKey = `${hostId}\0${worktreeId}` currentStatusIdentityRef.current = statusIdentityKey + currentBranchCompareIdentityRef.current = statusIdentityKey - useEffect(() => { - return () => { - mountedRef.current = false - loadGenerationRef.current += 1 + const setMobileSourceControlRootRef = useCallback((node: View | null): void => { + if (node !== null) { + mountedRef.current = true + return } + // Why: source-control RPC loads can outlive the route; invalidate pending + // writes when the screen detaches without a passive cleanup-only Effect. + mountedRef.current = false + loadGenerationRef.current += 1 + branchCompareGenerationRef.current += 1 }, []) useEffect(() => { @@ -188,6 +316,63 @@ export default function MobileSourceControlScreen() { } }, [insets.bottom]) + const loadBranchCompare = useCallback( + async (options?: { preserveReadyOnFailure?: boolean }) => { + const loadKey = statusIdentityKey + const generation = branchCompareGenerationRef.current + 1 + branchCompareGenerationRef.current = generation + const isCurrentLoad = () => + mountedRef.current && + branchCompareGenerationRef.current === generation && + currentBranchCompareIdentityRef.current === loadKey + + if (!worktreeId || !client || connState !== 'connected') { + if (isCurrentLoad()) { + setBranchCompareState({ kind: 'idle' }) + } + return false + } + + setBranchCompareState((prev) => (prev.kind === 'ready' ? prev : { kind: 'loading' })) + try { + const baseRef = await resolveMobileBranchCompareBaseRef(client, worktreeId) + if (!isCurrentLoad()) return false + if (!baseRef) { + setBranchCompareState({ kind: 'idle' }) + return true + } + const response = await client.sendRequest('git.branchCompare', { + worktree: `id:${worktreeId}`, + baseRef + }) + if (!isCurrentLoad()) return false + if (!response.ok) { + if (isMobileGitUnavailable(response.error?.code, response.error?.message)) { + setBranchCompareState({ kind: 'idle' }) + return false + } + throw new Error(response.error?.message || 'Unable to load committed changes') + } + setBranchCompareState({ + kind: 'ready', + result: (response as RpcSuccess).result as MobileGitBranchCompareResult + }) + return true + } catch (err) { + if (!isCurrentLoad()) return false + const message = err instanceof Error ? err.message : 'Unable to load committed changes' + setBranchCompareState((prev) => { + if (options?.preserveReadyOnFailure && prev.kind === 'ready') { + return prev + } + return { kind: 'error', message } + }) + return false + } + }, + [client, connState, statusIdentityKey, worktreeId] + ) + const loadStatus = useCallback( async (options?: LoadStatusOptions) => { const loadKey = statusIdentityKey @@ -230,6 +415,7 @@ export default function MobileSourceControlScreen() { if (response.ok) { const result = (response as RpcSuccess).result as MobileGitStatusResult setScreenState({ kind: 'ready', status: result }) + void loadBranchCompare({ preserveReadyOnFailure: true }) if (options?.clearActionErrorOnSuccess !== false) { setActionError(null) } @@ -278,7 +464,7 @@ export default function MobileSourceControlScreen() { } } }, - [client, connState, statusIdentityKey, worktreeId] + [client, connState, loadBranchCompare, statusIdentityKey, worktreeId] ) useEffect(() => { @@ -301,6 +487,31 @@ export default function MobileSourceControlScreen() { [entries] ) const sections = useMemo(() => buildMobileSourceControlSections(derivedEntries), [derivedEntries]) + const branchCompareResult = branchCompareState.kind === 'ready' ? branchCompareState.result : null + const branchCompareSection = useMemo( + () => buildMobileBranchCompareSection(branchCompareResult?.entries ?? []), + [branchCompareResult] + ) + const branchCompareSummaryText = branchCompareResult + ? formatMobileBranchCompareSummary(branchCompareResult.summary) + : null + const branchCompareCanOpen = branchCompareResult + ? canOpenMobileBranchCompareDiff(branchCompareResult.summary) + : false + const branchEntries = useMemo( + () => + (branchCompareSection?.data ?? []).map((entry) => ({ + ...entry, + canOpen: branchCompareCanOpen + })), + [branchCompareCanOpen, branchCompareSection] + ) + const shouldShowBranchCompareSection = + branchEntries.length > 0 || + branchCompareState.kind === 'loading' || + branchCompareState.kind === 'error' || + (branchCompareResult !== null && branchCompareResult.summary.status !== 'ready') + const hasVisibleChanges = sections.length > 0 || shouldShowBranchCompareSection const stageablePaths = useMemo(() => getStageablePaths(entries), [entries]) const unstageablePaths = useMemo(() => getUnstageablePaths(entries), [entries]) const stagedCount = useMemo(() => countStagedEntries(entries), [entries]) @@ -622,13 +833,81 @@ export default function MobileSourceControlScreen() { [client, connState, hostId, name, origin, router, worktreeId] ) + const openBranchDiff = useCallback( + async (entry: MobileGitBranchChangeEntry) => { + if (openingBranchPathRef.current || openingPathRef.current || busyActionRef.current) return + if (!client || connState !== 'connected') { + if (!mountedRef.current) return + setActionError('Waiting for desktop...') + return + } + if (branchCompareState.kind !== 'ready') { + return + } + const summary = branchCompareState.result.summary + if (!canOpenMobileBranchCompareDiff(summary) || !summary.headOid || !summary.mergeBase) { + return + } + + openingBranchPathRef.current = entry.path + setOpeningBranchPath(entry.path) + setBranchDiffPreview({ kind: 'loading', entry }) + try { + const response = await client.sendRequest('git.branchDiff', { + worktree: `id:${worktreeId}`, + filePath: entry.path, + ...(entry.oldPath ? { oldPath: entry.oldPath } : {}), + compare: { + baseRef: summary.baseRef, + ...(summary.baseOid ? { baseOid: summary.baseOid } : {}), + headOid: summary.headOid, + mergeBase: summary.mergeBase + } + }) + if (!response.ok) { + throw new Error(response.error?.message || 'Unable to load committed diff') + } + const result = (response as RpcSuccess).result as GitDiffTextResult | { kind: 'binary' } + if (result.kind !== 'text') { + throw new Error('Binary branch diff preview unavailable on mobile') + } + const diff = buildMobileDiffLines(result.originalContent, result.modifiedContent) + if (!mountedRef.current) return + setBranchDiffPreview({ + kind: 'ready', + entry, + summary, + lines: diff.lines, + truncated: diff.truncated + }) + triggerSelection() + } catch (err) { + if (!mountedRef.current) return + triggerError() + setBranchDiffPreview({ + kind: 'error', + entry, + message: err instanceof Error ? err.message : 'Unable to load committed diff' + }) + } finally { + if (openingBranchPathRef.current === entry.path) { + openingBranchPathRef.current = null + if (mountedRef.current) { + setOpeningBranchPath(null) + } + } + } + }, + [branchCompareState, client, connState, worktreeId] + ) + const actionSheetActions = useMemo(() => { const hasMessage = commitMessage.trim().length > 0 const hasStaged = stagedCount > 0 const hasUpstream = upstream?.hasUpstream === true const ahead = upstream?.ahead ?? 0 const behind = upstream?.behind ?? 0 - const busy = busyAction !== null + const busy = busyAction !== null || openingPath !== null || openingBranchPath !== null const commitHint = !hasStaged ? 'Stage at least one file' : !hasMessage @@ -749,6 +1028,8 @@ export default function MobileSourceControlScreen() { }, [ busyAction, commitMessage, + openingBranchPath, + openingPath, runActionSheetCommit, runActionSheetCommitSequence, runActionSheetCommitSync, @@ -771,7 +1052,8 @@ export default function MobileSourceControlScreen() { busyAction === item.unstageActionId || busyAction === item.discardActionId || openingPath === item.path - const rowDisabled = !item.canOpen || busyAction !== null || openingPath !== null + const rowDisabled = + !item.canOpen || busyAction !== null || openingPath !== null || openingBranchPath !== null return ( [ @@ -817,10 +1099,11 @@ export default function MobileSourceControlScreen() { [ styles.iconButton, - (busyAction !== null || openingPath !== null) && styles.iconButtonDisabled, + (busyAction !== null || openingPath !== null || openingBranchPath !== null) && + styles.iconButtonDisabled, pressed && styles.iconButtonPressed ]} - disabled={busyAction !== null || openingPath !== null} + disabled={busyAction !== null || openingPath !== null || openingBranchPath !== null} onPress={() => void runGitAction(item.unstageActionId, 'git.unstage', { filePath: item.path }) } @@ -835,10 +1118,13 @@ export default function MobileSourceControlScreen() { [ styles.iconButton, - (busyAction !== null || openingPath !== null) && styles.iconButtonDisabled, + (busyAction !== null || openingPath !== null || openingBranchPath !== null) && + styles.iconButtonDisabled, pressed && styles.iconButtonPressed ]} - disabled={busyAction !== null || openingPath !== null} + disabled={ + busyAction !== null || openingPath !== null || openingBranchPath !== null + } onPress={() => void runGitAction(item.stageActionId, 'git.stage', { filePath: item.path }) } @@ -852,10 +1138,13 @@ export default function MobileSourceControlScreen() { [ styles.iconButton, - (busyAction !== null || openingPath !== null) && styles.iconButtonDisabled, + (busyAction !== null || openingPath !== null || openingBranchPath !== null) && + styles.iconButtonDisabled, pressed && styles.iconButtonPressed ]} - disabled={busyAction !== null || openingPath !== null} + disabled={ + busyAction !== null || openingPath !== null || openingBranchPath !== null + } onPress={() => setDiscardTarget(item)} hitSlop={8} accessibilityLabel={`Discard ${item.path}`} @@ -868,7 +1157,7 @@ export default function MobileSourceControlScreen() { ) }, - [busyAction, openFile, openingPath, runGitAction] + [busyAction, openFile, openingBranchPath, openingPath, runGitAction] ) const keyExtractor = useCallback( @@ -886,8 +1175,172 @@ export default function MobileSourceControlScreen() { [] ) + const renderBranchCompareFooter = useCallback(() => { + if (!shouldShowBranchCompareSection) { + return null + } + + return ( + + + + Committed on Branch + {branchCompareSummaryText ? ( + + {branchCompareSummaryText} + + ) : null} + + {branchEntries.length} + + {branchCompareState.kind === 'loading' ? ( + + + Loading committed changes... + + ) : branchCompareState.kind === 'error' ? ( + + {branchCompareState.message} + + ) : branchCompareResult && branchCompareResult.summary.status !== 'ready' ? ( + + + {branchCompareResult.summary.errorMessage ?? 'Committed changes unavailable.'} + + + ) : ( + branchEntries.map((entry) => { + const rowBusy = openingBranchPath === entry.path + const rowDisabled = + !entry.canOpen || + busyAction !== null || + openingPath !== null || + openingBranchPath !== null + const meta = formatBranchEntryMeta(entry) + return ( + [ + styles.fileRow, + pressed && entry.canOpen && styles.fileRowPressed, + rowDisabled && styles.fileRowDisabled, + !entry.canOpen && styles.fileRowUnavailable + ]} + onPress={() => void openBranchDiff(entry)} + disabled={rowDisabled} + accessibilityLabel={`Open committed change ${entry.path}`} + > + + + {MOBILE_GIT_STATUS_LABELS[entry.status]} + + + + + + {entry.path} + + {meta ? ( + + {meta} + + ) : null} + + {rowBusy ? : null} + + ) + }) + )} + + ) + }, [ + branchCompareResult, + branchCompareState, + branchCompareSummaryText, + branchEntries, + busyAction, + openBranchDiff, + openingBranchPath, + openingPath, + shouldShowBranchCompareSection + ]) + + const renderBranchDiffPreview = useCallback(() => { + if (!branchDiffPreview) { + return null + } + const entry = branchDiffPreview.entry + return ( + setBranchDiffPreview(null)} + dragContentToDismiss={false} + zIndex={1100} + > + + + + {entry.path} + + + {branchDiffPreview.kind === 'ready' + ? `${branchDiffPreview.summary.baseRef}..HEAD` + : 'Committed on branch'} + + + [styles.diffCloseButton, pressed && styles.iconButtonPressed]} + onPress={() => setBranchDiffPreview(null)} + hitSlop={8} + accessibilityLabel="Close committed diff preview" + > + + + + + {branchDiffPreview.kind === 'loading' ? ( + + + + ) : branchDiffPreview.kind === 'error' ? ( + + Unable to Load Diff + {branchDiffPreview.message} + + ) : ( + + {branchDiffPreview.truncated ? ( + Diff truncated for mobile preview. + ) : null} + {branchDiffPreview.lines.map((line, index) => ( + + {diffLineNumber(line)} + {diffLinePrefix(line.kind)} + {line.text || ' '} + + ))} + + )} + + ) + }, [branchDiffPreview]) + return ( - + [ styles.refreshButton, - (busyAction !== null || openingPath !== null) && styles.refreshButtonDisabled, + (busyAction !== null || openingPath !== null || openingBranchPath !== null) && + styles.refreshButtonDisabled, pressed && styles.refreshButtonPressed ]} onPress={() => void loadStatus()} - disabled={busyAction !== null || openingPath !== null} + disabled={busyAction !== null || openingPath !== null || openingBranchPath !== null} hitSlop={8} accessibilityLabel="Refresh source control" > @@ -953,6 +1407,9 @@ export default function MobileSourceControlScreen() { {unstagedCount} changed {stagedCount} staged + {branchEntries.length > 0 ? ( + {branchEntries.length} on branch + ) : null} {status && status.conflictOperation !== 'unknown' ? ( {status.conflictOperation} ) : null} @@ -968,13 +1425,19 @@ export default function MobileSourceControlScreen() { [ styles.bulkButton, - (stageablePaths.length === 0 || busyAction !== null || openingPath !== null) && + (stageablePaths.length === 0 || + busyAction !== null || + openingPath !== null || + openingBranchPath !== null) && styles.bulkButtonDisabled, pressed && styles.bulkButtonPressed ]} onPress={() => void stageAll()} disabled={ - busyAction !== null || openingPath !== null || stageablePaths.length === 0 + busyAction !== null || + openingPath !== null || + openingBranchPath !== null || + stageablePaths.length === 0 } > {busyAction === 'stage-all' ? ( @@ -987,13 +1450,19 @@ export default function MobileSourceControlScreen() { [ styles.bulkButton, - (unstageablePaths.length === 0 || busyAction !== null || openingPath !== null) && + (unstageablePaths.length === 0 || + busyAction !== null || + openingPath !== null || + openingBranchPath !== null) && styles.bulkButtonDisabled, pressed && styles.bulkButtonPressed ]} onPress={() => void unstageAll()} disabled={ - busyAction !== null || openingPath !== null || unstageablePaths.length === 0 + busyAction !== null || + openingPath !== null || + openingBranchPath !== null || + unstageablePaths.length === 0 } > {busyAction === 'unstage-all' ? ( @@ -1007,10 +1476,11 @@ export default function MobileSourceControlScreen() { style={({ pressed }) => [ styles.bulkMenuButton, pressed && styles.bulkButtonPressed, - (busyAction !== null || openingPath !== null) && styles.bulkButtonDisabled + (busyAction !== null || openingPath !== null || openingBranchPath !== null) && + styles.bulkButtonDisabled ]} onPress={() => setShowActionSheet(true)} - disabled={busyAction !== null || openingPath !== null} + disabled={busyAction !== null || openingPath !== null || openingBranchPath !== null} hitSlop={8} accessibilityLabel="Open source control actions" > @@ -1019,7 +1489,7 @@ export default function MobileSourceControlScreen() { - {entries.length === 0 ? ( + {!hasVisibleChanges ? ( No Changes Working tree is clean. @@ -1030,6 +1500,7 @@ export default function MobileSourceControlScreen() { renderItem={renderItem} keyExtractor={keyExtractor} renderSectionHeader={renderSectionHeader} + ListFooterComponent={renderBranchCompareFooter} stickySectionHeadersEnabled={false} contentContainerStyle={styles.listContent} /> @@ -1062,7 +1533,9 @@ export default function MobileSourceControlScreen() { onChangeText={setCommitMessage} placeholder="Commit message" placeholderTextColor={colors.textMuted} - editable={busyAction === null && openingPath === null} + editable={ + busyAction === null && openingPath === null && openingBranchPath === null + } returnKeyType="done" onSubmitEditing={() => void commit()} /> @@ -1073,7 +1546,8 @@ export default function MobileSourceControlScreen() { (!commitMessage.trim() || stagedCount === 0 || busyAction !== null || - openingPath !== null) && + openingPath !== null || + openingBranchPath !== null) && styles.commitButtonDisabled, pressed && styles.commitButtonPressed ]} @@ -1082,7 +1556,8 @@ export default function MobileSourceControlScreen() { !commitMessage.trim() || stagedCount === 0 || busyAction !== null || - openingPath !== null + openingPath !== null || + openingBranchPath !== null } > {busyAction === 'commit' ? ( @@ -1096,6 +1571,8 @@ export default function MobileSourceControlScreen() { )} + {renderBranchDiffPreview()} + { +): LinearIssueSection[] { const sorted = [...issues].sort((a, b) => compareLinearIssues(a, b, orderBy)) if (groupBy === 'none') { return [{ key: 'all', label: 'Issues', color: colors.accentBlue, issues: sorted }] @@ -1864,14 +1873,19 @@ function GitHubPrFileDiff({ onCommentDraftChange: (key: string, value: string) => void onSubmitComment: (line: number) => void }): ReactNode { - const diffLines = useMemo( - () => buildGitHubPrFileDiffLines(contents.original, contents.modified), + const diffPreview = useMemo( + () => + buildGitHubPrFileDiffPreview( + contents.original, + contents.modified, + MAX_RENDERED_PR_DIFF_LINES + ), [contents.modified, contents.original] ) - const visibleDiffLines = diffLines.slice(0, MAX_RENDERED_PR_DIFF_LINES) - const hiddenDiffLineCount = Math.max(0, diffLines.length - visibleDiffLines.length) + const visibleDiffLines = diffPreview.lines + const hiddenDiffLineCount = Math.max(0, diffPreview.totalLineCount - visibleDiffLines.length) - if (diffLines.length === 0) { + if (diffPreview.totalLineCount === 0) { return No text changes found. } @@ -1879,7 +1893,7 @@ function GitHubPrFileDiff({ {hiddenDiffLineCount > 0 ? ( - Showing first {MAX_RENDERED_PR_DIFF_LINES} of {diffLines.length} diff lines. + Showing first {MAX_RENDERED_PR_DIFF_LINES} of {diffPreview.totalLineCount} diff lines. ) : null} {visibleDiffLines.map((line) => { @@ -2145,6 +2159,7 @@ export default function MobileTasksScreen() { const [prFileLoadingPath, setPrFileLoadingPath] = useState(null) const [prFileCommentDrafts, setPrFileCommentDrafts] = useState>({}) const [copiedLinkKey, setCopiedLinkKey] = useState(null) + const copiedLinkResetTimerRef = useRef | null>(null) const [expandedResolvedCommentGroups, setExpandedResolvedCommentGroups] = useState>( () => new Set() ) @@ -2266,6 +2281,13 @@ export default function MobileTasksScreen() { const [projectMutating, setProjectMutating] = useState(false) const [projectRepoNotInOrca, setProjectRepoNotInOrca] = useState(null) + // Why: project detail text inputs rerender this screen while comments stay + // unchanged; keep grouping out of the typing path. + const projectDetailCommentGroups = useMemo( + () => + groupDetailComments(projectRowDetail?.provider === 'github' ? projectRowDetail.comments : []), + [projectRowDetail] + ) const requestedTaskSource = useMemo( () => (isTaskProvider(taskSource) ? taskSource : undefined), [taskSource] @@ -3737,6 +3759,15 @@ export default function MobileTasksScreen() { return () => clearTimeout(timer) }, [githubKind, provider, query, taskStateHydrated]) + const setTaskCopyFeedbackRootRef = useCallback((node: View | null): void => { + if (node !== null) { + return + } + // Why: copied-link feedback is screen-local; clear the pending reset when + // the Tasks screen detaches without a passive cleanup-only Effect. + clearMobileTaskCopyFeedbackTimer(copiedLinkResetTimerRef) + }, []) + useEffect(() => { if (!taskUiReady || provider !== 'github' || githubMode !== 'items') return const trimmed = appliedQuery.trim() @@ -4998,42 +5029,22 @@ export default function MobileTasksScreen() { workspaceCreateTargetRepo ]) - useEffect(() => { - if (!tasksSupported || !workspaceCreateDraft || workspaceAgentOverridden) return - setWorkspaceAgent(pickWorkspaceAgent(runtimeTaskSettings, workspaceDetectedAgentIds)) - }, [ - runtimeTaskSettings, - tasksSupported, - workspaceAgentOverridden, - workspaceCreateDraft, - workspaceDetectedAgentIds - ]) - - useEffect(() => { - if ( - !workspaceCreateDraft || - !tasksSupported || - workspaceDetectedAgentIds === null || - !workspaceAgent || - workspaceAgent === 'blank' || - (workspaceDetectedAgentIds.has(workspaceAgent) && - isWorkspaceAgentEnabled(workspaceAgent, runtimeTaskSettings.disabledTuiAgents)) - ) { - return - } - // Why: the drawer can open before SSH/local detection settles. If the user - // picked an agent that is not actually available on that host, fall back to - // the same detected-agent rule desktop uses instead of launching a bad CLI. - setWorkspaceAgent(pickWorkspaceAgent(runtimeTaskSettings, workspaceDetectedAgentIds)) - setWorkspaceAgentOverridden(false) - }, [ - runtimeTaskSettings, - tasksSupported, - workspaceAgent, - workspaceCreateDraft, - workspaceDetectedAgentIds, - runtimeTaskSettings.disabledTuiAgents - ]) + const workspaceAgentSelection = resolveWorkspaceAgentSelection({ + selectionActive: tasksSupported && workspaceCreateDraft !== null, + settings: runtimeTaskSettings, + detectedAgentIds: workspaceDetectedAgentIds, + agent: workspaceAgent, + overridden: workspaceAgentOverridden + }) + if ( + workspaceAgentSelection.agent !== workspaceAgent || + workspaceAgentSelection.overridden !== workspaceAgentOverridden + ) { + // Why: the drawer can open before SSH/local detection settles. Resolve the + // visible agent before commit so users do not see an unavailable override. + setWorkspaceAgent(workspaceAgentSelection.agent) + setWorkspaceAgentOverridden(workspaceAgentSelection.overridden) + } const resolvedWorkspaceAgent = useMemo( () => workspaceAgent ?? pickWorkspaceAgent(runtimeTaskSettings, workspaceDetectedAgentIds), @@ -6828,9 +6839,7 @@ export default function MobileTasksScreen() { try { await Clipboard.setStringAsync(url) setCopiedLinkKey(key) - setTimeout(() => { - setCopiedLinkKey((current) => (current === key ? null : current)) - }, 1500) + scheduleMobileTaskCopyFeedbackReset(copiedLinkResetTimerRef, key, setCopiedLinkKey) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to copy link') } @@ -6840,9 +6849,7 @@ export default function MobileTasksScreen() { try { await Clipboard.setStringAsync(value) setCopiedLinkKey(key) - setTimeout(() => { - setCopiedLinkKey((current) => (current === key ? null : current)) - }, 1500) + scheduleMobileTaskCopyFeedbackReset(copiedLinkResetTimerRef, key, setCopiedLinkKey) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to copy text') } @@ -7866,26 +7873,29 @@ export default function MobileTasksScreen() { ) } - const createTargetOptions: PickerOption[] = - provider === 'github' || provider === 'gitlab' - ? hostedRepos.map((repo) => ({ - value: repo.id, - label: repo.displayName, - subtitle: repo.path, - renderIcon: () => ( - - ) - })) - : linearTeams.map((team) => ({ - value: team.id, - label: team.name, - subtitle: team.workspaceName - })) + const createTargetOptions = useMemo[]>( + () => + provider === 'github' || provider === 'gitlab' + ? hostedRepos.map((repo) => ({ + value: repo.id, + label: repo.displayName, + subtitle: repo.path, + renderIcon: () => ( + + ) + })) + : linearTeams.map((team) => ({ + value: team.id, + label: team.name, + subtitle: team.workspaceName + })), + [hostedRepos, linearTeams, provider] + ) const selectedCreateTarget = provider === 'github' || provider === 'gitlab' ? (hostedRepos.find((repo) => repo.id === createRepoId) ?? hostedRepos[0] ?? null) @@ -7940,19 +7950,23 @@ export default function MobileTasksScreen() { : `${selectedHostedRepos.length} repos` const repoPickerSelectedRepo = selectedRepoIds.size > 0 && selectedHostedRepos.length === 1 ? selectedHostedRepos[0]! : null - const workspaceRepoOptions: PickerOption[] = workspaceRepos.map((repo) => ({ - value: repo.id, - label: repo.displayName, - subtitle: repo.path, - renderIcon: () => ( - - ) - })) + const workspaceRepoOptions = useMemo[]>( + () => + workspaceRepos.map((repo) => ({ + value: repo.id, + label: repo.displayName, + subtitle: repo.path, + renderIcon: () => ( + + ) + })), + [workspaceRepos] + ) const sortedItems = useMemo(() => { const next = [...items] if (taskSort === 'repository') { @@ -8022,6 +8036,17 @@ export default function MobileTasksScreen() { ], [githubProjectFields, githubProjectSortOverride, githubProjectViewSort] ) + const githubProjectViewOptions = useMemo[]>( + () => + githubProjectViews.map((view) => ({ + value: view.id, + label: view.name, + subtitle: + view.layout === 'TABLE_LAYOUT' ? `View #${view.number}` : 'Unsupported layout on mobile', + disabled: view.layout !== 'TABLE_LAYOUT' + })), + [githubProjectViews] + ) const githubPresetOptions = githubKind === 'prs' ? PR_PRESETS : ISSUE_PRESETS const githubPresetPickerOptions = useMemo( () => @@ -8052,6 +8077,16 @@ export default function MobileTasksScreen() { linearWorkspaces.find((workspace) => workspace.id === selectedLinearWorkspaceId) ?.displayName ?? 'Workspace') + const linearWorkspaceOptions = useMemo[]>( + () => [ + { value: 'all', label: 'All workspaces' }, + ...linearWorkspaces.map((workspace) => ({ + value: workspace.id, + label: workspace.organizationName ?? workspace.displayName ?? workspace.id + })) + ], + [linearWorkspaces] + ) const linearTeamLabel = selectedLinearTeamIds.size === 0 || selectedLinearTeamIds.size === linearTeams.length ? 'All teams' @@ -8090,6 +8125,20 @@ export default function MobileTasksScreen() { () => groupLinearIssues(linearIssuesForView, linearGroupBy, linearOrderBy), [linearGroupBy, linearIssuesForView, linearOrderBy] ) + // Why: FlatList treats data identity as meaningful; unrelated renders should + // not rebuild the section/item wrapper array. + const linearListEntries = useMemo( + () => + linearIssueSections.flatMap((section) => + linearGroupBy === 'none' + ? section.issues.map((issue) => ({ type: 'issue' as const, issue })) + : [ + { type: 'section' as const, section }, + ...section.issues.map((issue) => ({ type: 'issue' as const, issue })) + ] + ), + [linearGroupBy, linearIssueSections] + ) const linearBoardSections = useMemo( () => groupLinearIssues( @@ -8200,7 +8249,7 @@ export default function MobileTasksScreen() { return ( - + router.back()}> @@ -8949,14 +8998,7 @@ export default function MobileTasksScreen() { ) : ( - linearGroupBy === 'none' - ? section.issues.map((issue) => ({ type: 'issue' as const, issue })) - : [ - { type: 'section' as const, section }, - ...section.issues.map((issue) => ({ type: 'issue' as const, issue })) - ] - )} + data={linearListEntries} keyExtractor={(entry) => entry.type === 'section' ? `linear-section:${entry.section.key}` : entry.issue.id } @@ -9759,15 +9801,7 @@ export default function MobileTasksScreen() { ({ - value: view.id, - label: view.name, - subtitle: - view.layout === 'TABLE_LAYOUT' - ? `View #${view.number}` - : 'Unsupported layout on mobile', - disabled: view.layout !== 'TABLE_LAYOUT' - }))} + options={githubProjectViewOptions} selected={pendingGitHubProjectViewSelection ? '' : (activeGitHubProjectViewId ?? '')} onSelect={(viewId) => { const view = githubProjectViews.find((candidate) => candidate.id === viewId) @@ -9898,13 +9932,7 @@ export default function MobileTasksScreen() { ({ - value: workspace.id, - label: workspace.organizationName ?? workspace.displayName ?? workspace.id - })) - ]} + options={linearWorkspaceOptions} selected={selectedLinearWorkspaceId ?? ''} onSelect={(workspaceId) => { setSelectedLinearWorkspaceId(workspaceId) @@ -11824,7 +11852,7 @@ export default function MobileTasksScreen() { {projectRowDetail.comments.length === 0 ? ( No comments. ) : ( - groupDetailComments(projectRowDetail.comments).map((group) => { + projectDetailCommentGroups.map((group) => { const groupId = detailCommentGroupId(group) const root = detailCommentGroupRoot(group) const count = detailCommentGroupCount(group) diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index 3486fd441bc..489a20c6ae0 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -305,12 +305,12 @@ export default function HomeScreen() { if (hosts.length > 0) primeHosts(hosts) }, [hosts, primeHosts]) const allClientsRef = useRef>([]) - useEffect(() => { - allClientsRef.current = allClients.map((entry) => ({ - hostId: entry.hostId, - client: entry.client - })) - }, [allClients]) + // Why: the focus callback stays stable to avoid refetching on every + // client-store render, but it still needs the latest host clients. + allClientsRef.current = allClients.map((entry) => ({ + hostId: entry.hostId, + client: entry.client + })) // Why: hydrate the home page from a persisted snapshot on cold-start so // Resume + Account-usage cards paint immediately with last-known data diff --git a/mobile/app/pair-confirm.tsx b/mobile/app/pair-confirm.tsx index 32decd09344..8e95ea438d1 100644 --- a/mobile/app/pair-confirm.tsx +++ b/mobile/app/pair-confirm.tsx @@ -1,12 +1,16 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import { View, Text, StyleSheet, Pressable, ActivityIndicator, BackHandler } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router' import { ChevronLeft } from 'lucide-react-native' -import { parsePairingCode } from '../src/transport/pairing' +import { resolvePairConfirmRouteState } from '../src/transport/pair-confirm-state' +import { + startPairingConnectionAttempt, + type PairingConnectionAttempt +} from '../src/transport/pairing-connection-attempt' import { connect } from '../src/transport/rpc-client' import { saveHost, getNextHostName } from '../src/transport/host-store' -import type { ConnectionLogEntry, PairingOffer, RpcResponse } from '../src/transport/types' +import type { ConnectionLogEntry, RpcResponse } from '../src/transport/types' import { colors, spacing, radii, typography } from '../src/theme/mobile-theme' import { ConnectionLog } from '../src/components/ConnectionLog' @@ -23,7 +27,6 @@ export default function PairConfirmScreen() { const router = useRouter() const insets = useSafeAreaInsets() const params = useLocalSearchParams<{ code?: string }>() - const [offer, setOffer] = useState(null) const [status, setStatus] = useState('awaiting-confirm') const [errorMessage, setErrorMessage] = useState('') const [logs, setLogs] = useState([]) @@ -31,6 +34,17 @@ export default function PairConfirmScreen() { // over the initial state setter) always sees the freshest list and we // batch fewer setState calls when entries arrive in bursts. const logsRef = useRef([]) + const mountedRef = useRef(true) + const activePairingAttemptRef = useRef(null) + + const routeState = resolvePairConfirmRouteState(params.code) + const offer = routeState.offer + const resolvedStatus = + status === 'awaiting-confirm' && routeState.kind === 'error' ? 'error' : status + const resolvedErrorMessage = + status === 'awaiting-confirm' && routeState.kind === 'error' + ? routeState.errorMessage + : errorMessage const cancel = useCallback(() => { router.replace('/') @@ -46,20 +60,17 @@ export default function PairConfirmScreen() { }, [cancel]) ) - useEffect(() => { - if (!params.code) { - setStatus('error') - setErrorMessage('Missing pairing code') + const setPairConfirmRootRef = useCallback((node: View | null): void => { + if (node !== null) { + mountedRef.current = true return } - const parsed = parsePairingCode(params.code) - if (!parsed) { - setStatus('error') - setErrorMessage('Not a valid pairing code') - return - } - setOffer(parsed) - }, [params.code]) + // Why: pairing attempts can outlive the visible route; dispose them when + // the confirm screen detaches without a passive cleanup-only Effect. + mountedRef.current = false + activePairingAttemptRef.current?.dispose() + activePairingAttemptRef.current = null + }, []) async function confirm() { if (!offer) return @@ -67,29 +78,40 @@ export default function PairConfirmScreen() { logsRef.current = [] setLogs([]) let client: ReturnType | null = null + activePairingAttemptRef.current?.dispose() // Why: split the try/catch around the network call vs the local save // so a Keychain or AsyncStorage failure doesn't masquerade as a // "Cannot connect" error. let response: RpcResponse - let timedOut = false - const overallTimer = setTimeout(() => { - timedOut = true - client?.close() - }, PAIRING_OVERALL_TIMEOUT_MS) + const attempt = startPairingConnectionAttempt({ + timeoutMs: PAIRING_OVERALL_TIMEOUT_MS, + closeClient: () => client?.close() + }) + activePairingAttemptRef.current = attempt try { client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64, { onLog: (entry) => { + if (!mountedRef.current || activePairingAttemptRef.current !== attempt) return logsRef.current = [...logsRef.current, entry] setLogs(logsRef.current) } }) response = await client.sendRequest('status.get') - clearTimeout(overallTimer) - client.close() - client = null + const attemptIsCurrent = activePairingAttemptRef.current === attempt + attempt.dispose() + if (activePairingAttemptRef.current === attempt) { + activePairingAttemptRef.current = null + } + if (!mountedRef.current || !attemptIsCurrent) return } catch (err) { - clearTimeout(overallTimer) + const timedOut = attempt.timedOut + const attemptIsCurrent = activePairingAttemptRef.current === attempt + attempt.dispose() + if (activePairingAttemptRef.current === attempt) { + activePairingAttemptRef.current = null + } + if (!mountedRef.current || !attemptIsCurrent) return console.warn('[pair-confirm] connect failed', err) setStatus('error') setErrorMessage( @@ -97,11 +119,11 @@ export default function PairConfirmScreen() { ? `Couldn't connect within ${PAIRING_OVERALL_TIMEOUT_MS / 1000}s — see log below for where it stalled` : 'Cannot connect — check that your computer is on the same network' ) - client?.close() return } if (!response.ok) { + if (!mountedRef.current) return setStatus('error') setErrorMessage( response.error.code === 'unauthorized' @@ -122,8 +144,10 @@ export default function PairConfirmScreen() { publicKeyB64: offer.publicKeyB64, lastConnected: Date.now() }) + if (!mountedRef.current) return router.replace(`/h/${hostId}`) } catch (err) { + if (!mountedRef.current) return console.warn('[pair-confirm] save failed', err) setStatus('error') setErrorMessage( @@ -135,13 +159,13 @@ export default function PairConfirmScreen() { const containerPadding = { paddingTop: insets.top + spacing.sm } return ( - + - {offer && status === 'awaiting-confirm' && ( + {offer && resolvedStatus === 'awaiting-confirm' && ( <> Pair with this desktop? @@ -156,7 +180,7 @@ export default function PairConfirmScreen() { )} - {status === 'connecting' && ( + {resolvedStatus === 'connecting' && ( <> Connecting… @@ -166,9 +190,9 @@ export default function PairConfirmScreen() { )} - {status === 'error' && ( + {resolvedStatus === 'error' && ( <> - {errorMessage} + {resolvedErrorMessage} {logs.length > 0 && ( diff --git a/mobile/app/pair-scan.tsx b/mobile/app/pair-scan.tsx index b64ea284a87..79314437b51 100644 --- a/mobile/app/pair-scan.tsx +++ b/mobile/app/pair-scan.tsx @@ -5,6 +5,10 @@ import { CameraView, useCameraPermissions } from 'expo-camera' import { useRouter } from 'expo-router' import { ChevronLeft, Clipboard as ClipboardIcon, QrCode } from 'lucide-react-native' import { decodePairingUrl, parsePairingCode } from '../src/transport/pairing' +import { + startPairingConnectionAttempt, + type PairingConnectionAttempt +} from '../src/transport/pairing-connection-attempt' import { connect } from '../src/transport/rpc-client' import { saveHost, getNextHostName } from '../src/transport/host-store' import type { ConnectionLogEntry, PairingOffer, RpcResponse } from '../src/transport/types' @@ -38,6 +42,20 @@ export default function PairScanScreen() { const [logs, setLogs] = useState([]) const logsRef = useRef([]) const processingRef = useRef(false) + const mountedRef = useRef(true) + const activePairingAttemptRef = useRef(null) + + const setPairScanRootRef = useCallback((node: View | null): void => { + if (node !== null) { + mountedRef.current = true + return + } + // Why: pairing attempts can outlive the visible route; dispose them when + // the scan screen detaches without a passive cleanup-only Effect. + mountedRef.current = false + activePairingAttemptRef.current?.dispose() + activePairingAttemptRef.current = null + }, []) const handleBarCodeScanned = useCallback( ({ data }: { data: string }) => { @@ -78,30 +96,41 @@ export default function PairScanScreen() { logsRef.current = [] setLogs([]) let client: ReturnType | null = null + activePairingAttemptRef.current?.dispose() // Why: split the try/catch around the network call vs the local save // so a Keychain or AsyncStorage failure doesn't masquerade as a // "Cannot connect — same network?" error. Pairing reached the // desktop fine; the failure is local persistence. let response: RpcResponse - let timedOut = false - const overallTimer = setTimeout(() => { - timedOut = true - client?.close() - }, PAIRING_OVERALL_TIMEOUT_MS) + const attempt = startPairingConnectionAttempt({ + timeoutMs: PAIRING_OVERALL_TIMEOUT_MS, + closeClient: () => client?.close() + }) + activePairingAttemptRef.current = attempt try { client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64, { onLog: (entry) => { + if (!mountedRef.current || activePairingAttemptRef.current !== attempt) return logsRef.current = [...logsRef.current, entry] setLogs(logsRef.current) } }) response = await client.sendRequest('status.get') - clearTimeout(overallTimer) - client.close() - client = null + const attemptIsCurrent = activePairingAttemptRef.current === attempt + attempt.dispose() + if (activePairingAttemptRef.current === attempt) { + activePairingAttemptRef.current = null + } + if (!mountedRef.current || !attemptIsCurrent) return } catch (err) { - clearTimeout(overallTimer) + const timedOut = attempt.timedOut + const attemptIsCurrent = activePairingAttemptRef.current === attempt + attempt.dispose() + if (activePairingAttemptRef.current === attempt) { + activePairingAttemptRef.current = null + } + if (!mountedRef.current || !attemptIsCurrent) return console.warn('[pair] connect failed', err) setStatus('error') setErrorMessage( @@ -110,11 +139,11 @@ export default function PairScanScreen() { : 'Cannot connect — check that your computer is on the same network' ) processingRef.current = false - client?.close() return } if (!response.ok) { + if (!mountedRef.current) return if (response.error.code === 'unauthorized') { setStatus('error') setErrorMessage('Authentication failed — token may be expired') @@ -138,8 +167,10 @@ export default function PairScanScreen() { publicKeyB64: offer.publicKeyB64, lastConnected: Date.now() }) + if (!mountedRef.current) return router.replace(`/h/${hostId}`) } catch (err) { + if (!mountedRef.current) return console.warn('[pair] save failed', err) setStatus('error') setErrorMessage( @@ -167,7 +198,7 @@ export default function PairScanScreen() { if (!permission) { return ( - + ) @@ -176,7 +207,7 @@ export default function PairScanScreen() { if (!permission.granted) { const canAskAgain = permission.canAskAgain !== false return ( - + router.back()}> @@ -219,7 +250,7 @@ export default function PairScanScreen() { } return ( - + router.back()}> diff --git a/mobile/app/terminal-settings.tsx b/mobile/app/terminal-settings.tsx index 107ed53f925..19f9f9de9c2 100644 --- a/mobile/app/terminal-settings.tsx +++ b/mobile/app/terminal-settings.tsx @@ -35,6 +35,7 @@ import { saveTerminalAccessoryLayout, setTerminalAccessoryBuiltInVisible } from '../src/terminal/terminal-accessory-layout' +import { setTerminalAutoRestoreFitMsForHost } from '../src/terminal/terminal-auto-restore-fit-state' type RestoreValue = 'indefinite' | '60s' | '5m' | '30m' @@ -136,6 +137,10 @@ export default function TerminalSettingsScreen() { }, []) const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts]) const hostClients = useAllHostClients(hostIds) + const hostClientsById = useMemo( + () => new Map(hostClients.map((entry) => [entry.hostId, entry.client])), + [hostClients] + ) const [customKeys, setCustomKeys] = useState([]) const [showCustomKeyModal, setShowCustomKeyModal] = useState(false) @@ -224,47 +229,45 @@ export default function TerminalSettingsScreen() { useEffect(() => { let cancelled = false for (const host of hosts) { - const entry = hostClients.find((e) => e.hostId === host.id) - const client = entry?.client ?? null + const client = hostClientsById.get(host.id) ?? null if (!client) continue void client .sendRequest('terminal.getAutoRestoreFit') .then((resp) => { if (cancelled) return const value = (resp as { ms?: number | null } | null)?.ms - setHostMs((prev) => ({ ...prev, [host.id]: value === undefined ? null : value })) + // Why: reconnect/status ticks can replay the same value; preserving + // object identity avoids rerendering every settings row again. + setHostMs((prev) => setTerminalAutoRestoreFitMsForHost(prev, host.id, value)) }) .catch(() => { - if (!cancelled) setHostMs((prev) => ({ ...prev, [host.id]: null })) + if (!cancelled) { + setHostMs((prev) => setTerminalAutoRestoreFitMsForHost(prev, host.id, null)) + } }) } return () => { cancelled = true } - }, [hosts, hostClients]) + }, [hosts, hostClientsById]) async function selectValue(hostId: string, value: RestoreValue) { - const entry = hostClients.find((e) => e.hostId === hostId) - const client = entry?.client ?? null + const client = hostClientsById.get(hostId) ?? null if (!client) return const opt = AUTO_RESTORE_FIT_OPTIONS.find((o) => o.value === value) if (!opt) return - setHostMs((prev) => ({ ...prev, [hostId]: opt.ms })) + setHostMs((prev) => setTerminalAutoRestoreFitMsForHost(prev, hostId, opt.ms)) try { const resp = (await client.sendRequest('terminal.setAutoRestoreFit', { ms: opt.ms })) as { ms?: number | null } | null - const finalMs = resp?.ms === undefined ? null : resp.ms - setHostMs((prev) => ({ ...prev, [hostId]: finalMs })) + setHostMs((prev) => setTerminalAutoRestoreFitMsForHost(prev, hostId, resp?.ms)) } catch { try { const resp = (await client.sendRequest('terminal.getAutoRestoreFit')) as { ms?: number | null } | null - setHostMs((prev) => ({ - ...prev, - [hostId]: resp?.ms === undefined ? null : resp.ms - })) + setHostMs((prev) => setTerminalAutoRestoreFitMsForHost(prev, hostId, resp?.ms)) } catch { // give up silently — the next mount retries } @@ -301,12 +304,12 @@ export default function TerminalSettingsScreen() { ) : ( {hosts.map((host, idx) => { - const entry = hostClients.find((e) => e.hostId === host.id) + const client = hostClientsById.get(host.id) ?? null return ( {idx > 0 && } setPickerHostId(host.id)} diff --git a/mobile/app/troubleshoot.tsx b/mobile/app/troubleshoot.tsx index be800b74c31..42bf651e59e 100644 --- a/mobile/app/troubleshoot.tsx +++ b/mobile/app/troubleshoot.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect, useRef } from 'react' +import { useState, useCallback, useRef } from 'react' import { View, Text, @@ -26,6 +26,11 @@ import { } from 'lucide-react-native' import { colors, spacing, typography } from '../src/theme/mobile-theme' import { loadHosts } from '../src/transport/host-store' +import { + startDiagnosticFetchTimeout, + type DiagnosticFetchTimeout +} from '../src/diagnostics/diagnostic-fetch-timeout' +import { formatEndpoint, testHostReachability } from '../src/diagnostics/host-reachability' type DiagnosticStatus = 'idle' | 'running' | 'done' @@ -113,11 +118,19 @@ export default function TroubleshootScreen() { const [diagnosticStatus, setDiagnosticStatus] = useState('idle') const [checks, setChecks] = useState([]) const abortRef = useRef(false) + const diagnosticRunRef = useRef(0) + const activeInternetCheckRef = useRef(null) - useEffect(() => { - return () => { - abortRef.current = true + const setTroubleshootRootRef = useCallback((node: View | null): void => { + if (node !== null) { + return } + // Why: diagnostics can outlive the screen; cancel the active run when the + // route detaches without a passive cleanup-only Effect. + abortRef.current = true + diagnosticRunRef.current += 1 + activeInternetCheckRef.current?.dispose() + activeInternetCheckRef.current = null }, []) const toggleSection = useCallback((id: string) => { @@ -125,11 +138,16 @@ export default function TroubleshootScreen() { }, []) const runDiagnostics = useCallback(async () => { + const runId = diagnosticRunRef.current + 1 + diagnosticRunRef.current = runId abortRef.current = false + activeInternetCheckRef.current?.dispose() + activeInternetCheckRef.current = null setDiagnosticStatus('running') setChecks([]) const results: CheckResult[] = [] + const isCurrentRun = () => !abortRef.current && diagnosticRunRef.current === runId try { const hosts = await loadHosts() @@ -142,33 +160,40 @@ export default function TroubleshootScreen() { results.push({ label: 'Paired hosts', status: 'warn', detail: 'Could not read host data' }) } - if (abortRef.current) return + if (!isCurrentRun()) return setChecks([...results]) + const internetCheck = startDiagnosticFetchTimeout(5000) + activeInternetCheckRef.current = internetCheck try { - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), 5000) const resp = await fetch('https://dns.google/resolve?name=example.com&type=A', { - signal: controller.signal + signal: internetCheck.signal }) - clearTimeout(timeout) + if (!isCurrentRun()) return results.push( resp.ok ? { label: 'Internet', status: 'pass', detail: 'Connected' } : { label: 'Internet', status: 'warn', detail: 'Unexpected response' } ) } catch { + if (!isCurrentRun()) return results.push({ label: 'Internet', status: 'fail', detail: 'No connection' }) + } finally { + internetCheck.dispose() + if (activeInternetCheckRef.current === internetCheck) { + activeInternetCheckRef.current = null + } } - if (abortRef.current) return + if (!isCurrentRun()) return setChecks([...results]) try { const hosts = await loadHosts() for (const host of hosts) { - if (abortRef.current) return + if (!isCurrentRun()) return const reachable = await testHostReachability(host.endpoint) + if (!isCurrentRun()) return results.push({ label: host.name, status: reachable ? 'pass' : 'fail', @@ -182,7 +207,7 @@ export default function TroubleshootScreen() { results.push({ label: 'Hosts', status: 'warn', detail: 'Could not test' }) } - if (abortRef.current) return + if (!isCurrentRun()) return results.push({ label: 'Platform', @@ -195,7 +220,10 @@ export default function TroubleshootScreen() { }, []) return ( - + router.back()}> @@ -288,40 +316,6 @@ export default function TroubleshootScreen() { ) } -// Why: WebSocket reachability is tested by opening a connection and waiting for -// the server to respond with any frame, or timing out after 4 seconds. -// We don't complete the E2EE handshake — just verify the endpoint is listening. -async function testHostReachability(endpoint: string): Promise { - return new Promise((resolve) => { - const timeout = setTimeout(() => { - ws.close() - resolve(false) - }, 4000) - - const ws = new WebSocket(endpoint) - - ws.onopen = () => { - clearTimeout(timeout) - ws.close() - resolve(true) - } - - ws.onerror = () => { - clearTimeout(timeout) - resolve(false) - } - }) -} - -function formatEndpoint(endpoint: string): string { - try { - const url = new URL(endpoint) - return url.host - } catch { - return endpoint - } -} - const styles = StyleSheet.create({ container: { flex: 1, diff --git a/mobile/package.json b/mobile/package.json index bae2b78012e..9ef537ce020 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -52,8 +52,8 @@ "@types/react-native": "^0.73.0", "@types/ws": "^8.18.1", "expo-module-scripts": "^55.0.2", - "oxfmt": "^0.47.0", - "oxlint": "^1.63.0", + "oxfmt": "^0.52.0", + "oxlint": "^1.67.0", "tsx": "^4.21.0", "typescript": "^5.9.3", "vitest": "^4.1.5" diff --git a/mobile/packages/expo-two-way-audio/android/src/main/java/expo/modules/twowayaudio/AudioEngine.kt b/mobile/packages/expo-two-way-audio/android/src/main/java/expo/modules/twowayaudio/AudioEngine.kt index 811b29426af..b2d3a1ebc72 100644 --- a/mobile/packages/expo-two-way-audio/android/src/main/java/expo/modules/twowayaudio/AudioEngine.kt +++ b/mobile/packages/expo-two-way-audio/android/src/main/java/expo/modules/twowayaudio/AudioEngine.kt @@ -328,11 +328,6 @@ class AudioEngine (context: Context) { audioSampleQueue.add(data) playbackEvents += 1 playbackQueuedBytes += data.size.toLong() - Log.d( - "AudioEngine", - "playPCMData queued bytes=${data.size} queueSize=${audioSampleQueue.size} " + - "head=${data.take(12).joinToString(" ") { byte -> "%02x".format(byte.toInt() and 0xff) }}" - ) flushBridgeStats("queue") if (playbackRunning.compareAndSet(false, true)) { playAudioFromSampleQueue() @@ -367,11 +362,6 @@ class AudioEngine (context: Context) { val written = audioTrack.write(data, 0, data.size) playbackWrites += 1 playbackWriteBytes += written.coerceAtLeast(0).toLong() - Log.d( - "AudioEngine", - "playSample wrote=$written requested=${data.size} playState=${audioTrack.playState} " + - "queueSize=${audioSampleQueue.size}" - ) flushBridgeStats("write") } @@ -455,18 +445,18 @@ class AudioEngine (context: Context) { private fun calculateRMSLevel(buffer: ByteArray): Float { val epsilon = 1e-5f // To avoid log(0) - - // Convert ByteArray to FloatArray by treating each pair of bytes as a single 16-bit PCM sample - val floatBuffer = FloatArray(buffer.size / 2) - for (i in floatBuffer.indices) { - // Combine two bytes into a 16-bit signed integer + val sampleCount = buffer.size / 2 + var sumSquares = 0.0f + for (i in 0 until sampleCount) { + // Why: preserve Android's existing signed-byte decode while avoiding + // a temporary sample array on every mic/playback audio buffer. val sample = (buffer[i * 2].toInt() or (buffer[i * 2 + 1].toInt() shl 8)).toShort() - // Normalize sample to -1.0 to 1.0 range for FloatArray - floatBuffer[i] = sample / 32768.0f + val normalizedSample = sample / 32768.0f + sumSquares += normalizedSample * normalizedSample } // Calculate RMS value - val rmsValue = kotlin.math.sqrt(floatBuffer.fold(0f) { acc, sample -> acc + sample * sample } / floatBuffer.size) + val rmsValue = kotlin.math.sqrt(sumSquares / sampleCount) // Convert to decibels val dbValue = 20 * kotlin.math.log10(maxOf(rmsValue, epsilon)) diff --git a/mobile/packages/expo-two-way-audio/src/hooks.ts b/mobile/packages/expo-two-way-audio/src/hooks.ts index 8f8bf8c8da2..03c2468aba7 100644 --- a/mobile/packages/expo-two-way-audio/src/hooks.ts +++ b/mobile/packages/expo-two-way-audio/src/hooks.ts @@ -12,15 +12,21 @@ export const useMicrophonePermissions = createPermissionHook({ requestMethod: requestMicrophonePermissionsAsync }) -export function useIsRecording() { - const subscribe = (cb: () => void) => { - const sub = addExpoTwoWayAudioEventListener('onRecordingChange', cb) - return () => sub.remove() - } - const getSnapshot = () => isRecording() - const getServerSnapshot = () => false +// Why: useSyncExternalStore resubscribes when these identities change; keep +// the native recording listener stable across component re-renders. +const subscribeToRecordingChanges = (cb: () => void) => { + const sub = addExpoTwoWayAudioEventListener('onRecordingChange', cb) + return () => sub.remove() +} +const getRecordingSnapshot = () => isRecording() +const getServerRecordingSnapshot = () => false - return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) +export function useIsRecording() { + return useSyncExternalStore( + subscribeToRecordingChanges, + getRecordingSnapshot, + getServerRecordingSnapshot + ) } export function useExpoTwoWayAudioEventListener( diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 2fa0bd18806..58a3cb14a33 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -121,11 +121,11 @@ importers: specifier: ^55.0.2 version: 55.0.2(@babel/core@7.29.0)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(esbuild@0.27.7)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.8.0))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6) oxfmt: - specifier: ^0.47.0 - version: 0.47.0 + specifier: ^0.52.0 + version: 0.52.0 oxlint: - specifier: ^1.63.0 - version: 1.63.0 + specifier: ^1.67.0 + version: 1.67.0 tsx: specifier: ^4.21.0 version: 4.21.0 @@ -1374,230 +1374,246 @@ packages: '@oxc-project/types@0.129.0': resolution: {integrity: sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==} - '@oxfmt/binding-android-arm-eabi@0.47.0': - resolution: {integrity: sha512-KrMQRdMi/upr81qT4ijK6X6BNp6jqpMY7FwILQnwIy9QLc3qpnhUx5rsCLGzn4ewsCQ0CNAspN2ogmP1GXLyLw==} + '@oxfmt/binding-android-arm-eabi@0.52.0': + resolution: {integrity: sha512-17EMSJnQ9g+upVHrAUYDMfH5lvRKQ9Nvg8WtEoH72oDr1VpWz+7/o3tD97U1EToen2YAQ/68JmtDYkQUi20dfQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxfmt/binding-android-arm64@0.47.0': - resolution: {integrity: sha512-r4ixS/PeUpAFKgrpDoZ5pSkthjZzVzKd95525Aazj+aOv9H4ulK5zYHGb7wFY5n5kZxHK8TbOJUZgoEb1ohddQ==} + '@oxfmt/binding-android-arm64@0.52.0': + resolution: {integrity: sha512-A2G1IdwGEW2lLJkIxcvuirRH1CzSl/e0NX11zTlW1gvxJThfwbI/BEoaKrTNpm7M2FchvIf6guvIQU7d5iz+OQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxfmt/binding-darwin-arm64@0.47.0': - resolution: {integrity: sha512-CLWxiKpMl+195cm09CuaWEhJK0CirRkoMa07aR9+9AFPat2LfIKtwx1JqxZM0MTvcMe6+adlJNdVL6jdInvq3g==} + '@oxfmt/binding-darwin-arm64@0.52.0': + resolution: {integrity: sha512-f9+bLvOYxy7NttCLFTvQ7afmqDOWY4wIP9xdvfj5trQ1qj6f2UFAGwZESlfsMjvJNTyRpXfIlOanCI9FOvoeQA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxfmt/binding-darwin-x64@0.47.0': - resolution: {integrity: sha512-Xq5fjTYDC50faUeLSm0rZdBqoTgleXEdD7NpJdARtQIczkCJn3xNjMUSQQkUmh4CtxkKTNL68lytcOK3e/osgg==} + '@oxfmt/binding-darwin-x64@0.52.0': + resolution: {integrity: sha512-YSTB9sJ5nnQd/Q0ddHkgof0ZCHPAnWZT1IW2SJ8omz7CP7KluJhO1fNHrpqdxCtpztJwSs4hY1uAee35wKxxaw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxfmt/binding-freebsd-x64@0.47.0': - resolution: {integrity: sha512-QOU9ZIJ52p5askcEC0QJvvr8trHAWoonul8bgISo6gYUL3s50zkqafBYcNAr9LJZQbsZtPfIWHk9+5+nUp1qJQ==} + '@oxfmt/binding-freebsd-x64@0.52.0': + resolution: {integrity: sha512-NIrRNTTPCs4UbmVs0bxLSCDlLCtIRMJIXklNKaXa5Oj2/K1UIMBvgE8+uPVo01Io3N9HF0+GAX+aAHjUgZS7vA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxfmt/binding-linux-arm-gnueabihf@0.47.0': - resolution: {integrity: sha512-oJxDM1aBhPvz9gmElBv8UpxyiqhwfjcbrSxT5F0xtuUzY6dQI27/AQPIt3eu3Z5Yvn0kQl5R7MA3Z+MbnRvCBw==} + '@oxfmt/binding-linux-arm-gnueabihf@0.52.0': + resolution: {integrity: sha512-JXUCde8mn3GpgQouz2PXUokgy/uT1QrRJBL2s983VWcSQp62wTFYiNXgTKdeo1Jgbr0IgUnKKvzIk/YBlj/nVQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm-musleabihf@0.47.0': - resolution: {integrity: sha512-g8Lh50VS4ibGz2q6v7r9UZY4D0dM16SdrFYOMzhqIoCwGcai8VMIRUAcqn1/jlCsOOzUXJ741+kCeJt0cofakQ==} + '@oxfmt/binding-linux-arm-musleabihf@0.52.0': + resolution: {integrity: sha512-psbUXaRZ+V8DaXz10Qf7LSHtdtdKAmC8fxXgeU608jjzrmWK4quamZMOpl6sf+dikoFHA85uE93Q0BqxrCdQrQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm64-gnu@0.47.0': - resolution: {integrity: sha512-YrNT1vQ0asaXoRbrvYENPqmBfOQ9Xr8enPNOULeYfg44VjCcrUowFy5QZr+WawE0zyP8cH9e9Gxxg0fDEFzhcg==} + '@oxfmt/binding-linux-arm64-gnu@0.52.0': + resolution: {integrity: sha512-Jw7MgWUU9lcLCcy82updISP3EthTlfvAwR6gWNxPzqly7+fLvOi2gHQE9xXQjpqaVLm/8P+gOzlv9ODuoVlaaw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] - '@oxfmt/binding-linux-arm64-musl@0.47.0': - resolution: {integrity: sha512-IxtQC/sbBi4ubbY+MdwdanRWrG9InQJVZqyMsBa5IUaQcnSg86gQme574HxXMC1p4bo4YhV99zQ+wNnGCvEgzw==} + '@oxfmt/binding-linux-arm64-musl@0.52.0': + resolution: {integrity: sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] - '@oxfmt/binding-linux-ppc64-gnu@0.47.0': - resolution: {integrity: sha512-EWXEhOMbWO0q6eJSbu0QLkU8cKi0ljlYLngeDs2Ocu/pm1rrLwyQiYzlFbdnMRURI4w9ndr1sI9rSbhlJ5o23Q==} + '@oxfmt/binding-linux-ppc64-gnu@0.52.0': + resolution: {integrity: sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] - '@oxfmt/binding-linux-riscv64-gnu@0.47.0': - resolution: {integrity: sha512-tZrjS11TUiDuEpRaqdk8K9F9xETRyKXfuZKmdeW+Gj7coBnm7+8sBEfyt033EAFEQSlkniAXvBLh+Qja2ioGBQ==} + '@oxfmt/binding-linux-riscv64-gnu@0.52.0': + resolution: {integrity: sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] - '@oxfmt/binding-linux-riscv64-musl@0.47.0': - resolution: {integrity: sha512-KBFy+2CFKUCZzYwX2ZOPQKck1vjQbz+hextuc19G4r0WRJwadfAeuQMQRQvB+Ivc8brlbOVg7et8K7E467440g==} + '@oxfmt/binding-linux-riscv64-musl@0.52.0': + resolution: {integrity: sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] - '@oxfmt/binding-linux-s390x-gnu@0.47.0': - resolution: {integrity: sha512-REUPFKVGSiK99B+9eaPhluEVglzaoj/SMykNC5SUiV2RSsBfV5lWN7Y0iCIc251Wz3GaeAGZsJ/zj3gjarxdFg==} + '@oxfmt/binding-linux-s390x-gnu@0.52.0': + resolution: {integrity: sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] - '@oxfmt/binding-linux-x64-gnu@0.47.0': - resolution: {integrity: sha512-KVftVSVEDeIfRW3TIeLe3aNI/iY4m1fu5mDwHcisKMZSCMKLkrhFsjowC7o9RoqNPxbbglm2+/6KAKBIts2t0Q==} + '@oxfmt/binding-linux-x64-gnu@0.52.0': + resolution: {integrity: sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] - '@oxfmt/binding-linux-x64-musl@0.47.0': - resolution: {integrity: sha512-DTsmGEaA2860Aq5VUyDO8/MT9NFxwVL93RnRYmpMwK6DsSkThmvEpqoUDDljziEpAedMRG19SCogrNbINSbLUQ==} + '@oxfmt/binding-linux-x64-musl@0.52.0': + resolution: {integrity: sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] - '@oxfmt/binding-openharmony-arm64@0.47.0': - resolution: {integrity: sha512-8r5BDro7fLOBoq1JXHLVSs55OlrxQhEso4HVo0TcY7OXJUPYfjPoOaYL5us+yIwqyP9rQwN+rxuiNFSmaxSuOQ==} + '@oxfmt/binding-openharmony-arm64@0.52.0': + resolution: {integrity: sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxfmt/binding-win32-arm64-msvc@0.47.0': - resolution: {integrity: sha512-qtz/gzm8IjSPUlseZ0ofW8zyHLoZsuP5HTfcGGkWkUblB89JT8GNYH3ICqjbDsqsGqXum0/ZndXTFplSdXFIcg==} + '@oxfmt/binding-win32-arm64-msvc@0.52.0': + resolution: {integrity: sha512-k2sz6gWQdMfh5HPpIS+Bw/0UEV/kaK2xuqJRrWL233sEHx9WLlsmvlPFM4HUNThkYbSN0U0vPW7LVKZWDS8hPQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxfmt/binding-win32-ia32-msvc@0.47.0': - resolution: {integrity: sha512-5vIcdcIDE7nCx+MXN6sm8kbC4zajDB31E86rez4i45iHNH/2NjdKlJ720xcHTr3eeiMcttCGPHPhE1TjtBDGZw==} + '@oxfmt/binding-win32-ia32-msvc@0.52.0': + resolution: {integrity: sha512-rhke69GTcArodLHpjMTfNnvjTEBryDeZcUCKK/VjXDMtfTULl6QRh0ymX5/hbCUv2WjYm9h/QbW++q2vE15gWQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxfmt/binding-win32-x64-msvc@0.47.0': - resolution: {integrity: sha512-Sr59Y5ms54ONBjxFeWhVlGyQcHXxcl9DxC23f6yXlRkcos7LXBLoO+KDfxexjHIOZh7cWqrWduzvUjJ+pHp8cQ==} + '@oxfmt/binding-win32-x64-msvc@0.52.0': + resolution: {integrity: sha512-q5xL7oeXkZdEtNZWBdvehJcmt+GRu9l2bK40yJs1jJXlqq+r0Hygb1rTjq+FM2o/2xyt4cufH6KRplHp3Jjsvw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@oxlint/binding-android-arm-eabi@1.63.0': - resolution: {integrity: sha512-A9xLtQt7i0OA1PoB/meog6kikXI9CdwEp7ZwQqmgnpKn3G3b1orvTDy8CQ6T7w1HvDrgWGB78PkFKcWgibcTCg==} + '@oxlint/binding-android-arm-eabi@1.67.0': + resolution: {integrity: sha512-VrSi571rDv1N8HaEDM+DEX8nmT0y9jJo8tzzW13vsOWTx59xQczCIJx68n2zWOXRT5YKZsOZXp4qkHN/10x4mw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxlint/binding-android-arm64@1.63.0': - resolution: {integrity: sha512-SQo+ZMvdR9l3CxZp5W5gFNxSiDxclY6lOzzNpKYLF8asESpm3Pwumx0gER5T7aHLF1/2BAAtLD3DiDkdgy4V1A==} + '@oxlint/binding-android-arm64@1.67.0': + resolution: {integrity: sha512-l6+NdYxMoRohix5r5bbigW16LPicceCwGcQ6LKKuE1kUdjgFfQolJjrJsQYPFetIs78Gxj/G/f5TEGoTCwj9nQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxlint/binding-darwin-arm64@1.63.0': - resolution: {integrity: sha512-6W82XjJDTmMnjg30427l0dufpnyLoq7wEukKdM6/g2VIybRVuQiBVh43EA4b+UxZ3+tLcKm+Or/pXGNgLCEU8g==} + '@oxlint/binding-darwin-arm64@1.67.0': + resolution: {integrity: sha512-jOzXxS1AxFxhImLIRbtGIMrEwaXcgMw3gR57WB1cRk8ai+vpr6726kxXqVvlNsrXtJ/FrmOm8RxlC0m8SW24Qg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxlint/binding-darwin-x64@1.63.0': - resolution: {integrity: sha512-CnWd/YCuVG5W1BYkjJEVbJG11o526O9qAwBEQM+nh8K19CRFUkFdROXCyYkGmroHEYQe4vgQ6+lh3550Lp35Xw==} + '@oxlint/binding-darwin-x64@1.67.0': + resolution: {integrity: sha512-3DFAVY94OqjIZHXIPz37yGRSWwOFTAqChQ64/M69GYLawzP0KiwdhDNfqdKKYT0bTR/DNxmMnQsj3ns+8+X/Lg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxlint/binding-freebsd-x64@1.63.0': - resolution: {integrity: sha512-a4eZAqrmtajqcxfdAzC+l7g3PaE3V8hpAYqqeD3fTxLXOMFdK3eNTZrU80n4dDEVm0JXy1aL5PqvqWldBl6zYA==} + '@oxlint/binding-freebsd-x64@1.67.0': + resolution: {integrity: sha512-e4dDKZuLu8TR9DEBssWSDahlPgZBwojTTHZUvnjBRJfJJbpxYCjfjKfi0Z1+CSLMiJBwI2yCDtRM1XJQaARjmg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxlint/binding-linux-arm-gnueabihf@1.63.0': - resolution: {integrity: sha512-tYUtU9TdbU3uXF5D62g5zXJ13iniFGhXQx5vp9cyEjGdbSAY3VdFBSaldYvyoDmgMZ0ZYuwQP1Y4t2Fhejwa0w==} + '@oxlint/binding-linux-arm-gnueabihf@1.67.0': + resolution: {integrity: sha512-BKytFdcQzbITV3xlnzDUDTEDtbUMCCiC4EaNTDZ4FyT8gdNvBC4gfiLucXp/sQl0XU3p7syTlorUWVVVBZab2g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-musleabihf@1.63.0': - resolution: {integrity: sha512-I5r3twFf776UZg9dmRo2xbrKt00tTkORXEVe0ctg4vdTkQvJAjiCHxnbAU2HL1AiJ9cqADA76MAliuilsAWnvg==} + '@oxlint/binding-linux-arm-musleabihf@1.67.0': + resolution: {integrity: sha512-XYAv0esBDX7BpTzRDjVX2Vdj+zndd8ll2dFQiaeQ6zTZr7A8GRDTN7fH3FP3jU+O0vCDx85oH/EtG7BzPgAXuw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm64-gnu@1.63.0': - resolution: {integrity: sha512-t7ltUkg6FFh4b564QyGir8xIj/QZbXu8FlcRkcyW9+ztr/mfRHlvUOFd95pJCXi9s/L5DrUeWWgpXRS+V+6igQ==} + '@oxlint/binding-linux-arm64-gnu@1.67.0': + resolution: {integrity: sha512-zizRMjA0i6u/2B0evgda04iycu+MoNuf1pBy6Eh+1CjC5wMEG7qN5zdDKTCvFc0KSYSDM9QTG3gjZHirgtQuKg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] - '@oxlint/binding-linux-arm64-musl@1.63.0': - resolution: {integrity: sha512-Q5mmZy/XWjuYFUuQyYjOvZ5U/JkKEwnpir6hGxhh6HcdP0V/BKxLo8dqkfF/t7r7AguB17dfS/8+go5AQDRR6g==} + '@oxlint/binding-linux-arm64-musl@1.67.0': + resolution: {integrity: sha512-zB/Tf6sUjmmvvbva9Gj3JTJ8rJ9t4I8/U0o6vSRtd0DRIsIuyegBwJAzhSUFQHdMijIRJkW0exs/yBhpw2S20w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] - '@oxlint/binding-linux-ppc64-gnu@1.63.0': - resolution: {integrity: sha512-uBGtuZ0TzLB4x5wVa82HGNvYqY8buwDhyCnCP0R0gkk9szqVsP0MeTtD5HX7EsEuFIt+aYmYxuxeVxs3nTSwtQ==} + '@oxlint/binding-linux-ppc64-gnu@1.67.0': + resolution: {integrity: sha512-kgU40Gt74CK0TCsF51KZymkIwN9U0BajKsMijB52zPqOeZU9NAHkA/NSQkZDHEaCakx42DxhXkODiAqf2b4Gug==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] - '@oxlint/binding-linux-riscv64-gnu@1.63.0': - resolution: {integrity: sha512-h4s6FwxE+9MeA181o0dnDwHP32Y/bG8EiB/vrD6Ib+AMt6haigDc/0bUtI/sLmQDBMJnUfaCmtSSrEAqjtEVrA==} + '@oxlint/binding-linux-riscv64-gnu@1.67.0': + resolution: {integrity: sha512-tOYhkk/iaG9aD3FvGpBFd1Lrw0x0RaVoJBxjUkfNzS50rC5NS5BteNCwgr8A2zCdADrIIoze6D7u6U5Ic++/iQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] - '@oxlint/binding-linux-riscv64-musl@1.63.0': - resolution: {integrity: sha512-2EaNcCBR8Mcjl5ARtuN3BdEpVkX7KpjSjMGZ/mJMIeaXgTtdz5ytg2VwygMSStA/k0ixfvZFoZOfjDEcouV5vQ==} + '@oxlint/binding-linux-riscv64-musl@1.67.0': + resolution: {integrity: sha512-sEtywrPb+0b+tHYl1SDCrw903fiC4eyKoNqzP3v+f2JT3Xcv4NEYG+P8rj+eEnX7IWhqV/xj8/JmcmVj21CXaA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] - '@oxlint/binding-linux-s390x-gnu@1.63.0': - resolution: {integrity: sha512-p4hlf/fd7TrYYl3QrWWD0GocqJefwMu3cHQhmi2FvEB/YOvFb5DZN3SMBaPi7B1TM5DeypkEtrVib674q1KKPg==} + '@oxlint/binding-linux-s390x-gnu@1.67.0': + resolution: {integrity: sha512-BvR8Moa0zCLxroOx4vZaZN9nUfwAUpSTwjZdxZyKy4bv3PrzrXrxKR/ZQ0L9wNSvlPhnMJeZfa3q5w6ZCTuN6Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] - '@oxlint/binding-linux-x64-gnu@1.63.0': - resolution: {integrity: sha512-Vgq9rkRVcPcjbcH+ihYTfpeR7vCXfqpd+z5ItTGc0yYUV59L5ceHYN1iV4H9bKGV7Rn5hkVc7x3mSvHegduENA==} + '@oxlint/binding-linux-x64-gnu@1.67.0': + resolution: {integrity: sha512-mm2cxM6fksOpq6l0uFws8BUGKAR4dNa/cZCn37Npq7PFbhD5HDJqWfnoIvTaeRKMy5XdS2tO0MA0qbHDrnXAAA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] - '@oxlint/binding-linux-x64-musl@1.63.0': - resolution: {integrity: sha512-3/Lkq/ncooA61rorrC+ZQed1Bc4VpGj+WnGsp58zmxKgvZ2vhreu+dcVyr3mX8NUpq7mfZ4gDDTou/yrF1Pd7A==} + '@oxlint/binding-linux-x64-musl@1.67.0': + resolution: {integrity: sha512-WmbMuLapKyDlobMkXAaAL0Y+Uczh4LETfIfQsUpbId4Ip8Ai82/jqeYTOoUCkuuhBFapgqP253+d83tLKOksJg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] - '@oxlint/binding-openharmony-arm64@1.63.0': - resolution: {integrity: sha512-0/EdD/6hDkx5Mfd769PTjvEM8mZ/6Dfukp1dBCL/2PjlIVGEtYdNZyok6ChqYPsT9JcFnlQnUeQzO0/1L/oC9w==} + '@oxlint/binding-openharmony-arm64@1.67.0': + resolution: {integrity: sha512-9g/PqxYJelzzTAOR5Y+RiRqdeydhEuXv2KxNeFcAKQ7UsvnWSY1OP4MsuPMbTO2Pf70tz7mFhl1j13H3fyh+8g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxlint/binding-win32-arm64-msvc@1.63.0': - resolution: {integrity: sha512-wb0CUkN8ngwPiRQBjD1Cj0LsHeNvm+Xt6YBHDMtj2DVQVD6Oj8Ri7g6BD+KICf6LaBqZlmzOvy6nF9E/8yyGOg==} + '@oxlint/binding-win32-arm64-msvc@1.67.0': + resolution: {integrity: sha512-2VhwE6Gatb0vJGnN0TBuQMbKCOiZlSQ/zJvVWYLK4a9d4iDiJOen/yVQkGpmsJ90MuH66fzi0kEKI0jRQMDxGA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxlint/binding-win32-ia32-msvc@1.63.0': - resolution: {integrity: sha512-BX5iq+ovdNlVYhSn5qPMUIT0uwAwt2lmEnCnzK+Gkhw4DovIvhGb96OFhV8yzQNUnQxn/xGkOR+X+BLrLDNm8w==} + '@oxlint/binding-win32-ia32-msvc@1.67.0': + resolution: {integrity: sha512-EQ3VExXfeM1InbE5+JjufhZZTWy+kHUwgt3yZR7gQ47Je/mE0WspQPan0OJznh493L5anM210YNJtH1PXjTSFg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxlint/binding-win32-x64-msvc@1.63.0': - resolution: {integrity: sha512-QeN/WELOfsXMeYwxvfgQrl6CbVftYUCZsGXHjXQd5Trccm8+i4gmtxaOui4xbJQaiDlviF8F3yLSBloQUeFsfA==} + '@oxlint/binding-win32-x64-msvc@1.67.0': + resolution: {integrity: sha512-bw24y+/1MHS4QDkons3YyHkPT9uCMoLHHgQhb+mb8NOjTYwub1CZ+K9Ngr8aO5DMrDrkqHwTzlTwFP2vS8Y/ZQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -4721,20 +4737,31 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} - oxfmt@0.47.0: - resolution: {integrity: sha512-OFbkbzxKCpooQEnRmpTDnuwTX8KHXzZTQ4Df/hz85fpS67Pl+lxPEFvUtin56HIIS0B1k4X8oIzTXRZPufA2CA==} + oxfmt@0.52.0: + resolution: {integrity: sha512-nJlYM35F64zTDMecCNhoHNkf+D/eHv7xcjj9XDSj+bFAVtN93m7v8DQMdHd6nDG6Akf/kEYYHmDUBs2Dz27Sug==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + peerDependencies: + svelte: ^5.0.0 + vite-plus: '*' + peerDependenciesMeta: + svelte: + optional: true + vite-plus: + optional: true - oxlint@1.63.0: - resolution: {integrity: sha512-9TGXetdjgIHOJ9OiReomP7nnrMkV9HxC1xM2ramJSLQpzxjsAJtQwa4wqkJN2f/uCrqZuJseFuSlWDdvcruveg==} + oxlint@1.67.0: + resolution: {integrity: sha512-blwwaHPdoH8piQ5/z0KHeoHFR7FZgl12WluKJfu4qFLPkZl6mK04PkLE45Fw1NxfBRSlh40Gu7MkxHUw++ociQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: oxlint-tsgolint: '>=0.22.1' + vite-plus: '*' peerDependenciesMeta: oxlint-tsgolint: optional: true + vite-plus: + optional: true p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} @@ -7638,118 +7665,118 @@ snapshots: '@oxc-project/types@0.129.0': {} - '@oxfmt/binding-android-arm-eabi@0.47.0': + '@oxfmt/binding-android-arm-eabi@0.52.0': optional: true - '@oxfmt/binding-android-arm64@0.47.0': + '@oxfmt/binding-android-arm64@0.52.0': optional: true - '@oxfmt/binding-darwin-arm64@0.47.0': + '@oxfmt/binding-darwin-arm64@0.52.0': optional: true - '@oxfmt/binding-darwin-x64@0.47.0': + '@oxfmt/binding-darwin-x64@0.52.0': optional: true - '@oxfmt/binding-freebsd-x64@0.47.0': + '@oxfmt/binding-freebsd-x64@0.52.0': optional: true - '@oxfmt/binding-linux-arm-gnueabihf@0.47.0': + '@oxfmt/binding-linux-arm-gnueabihf@0.52.0': optional: true - '@oxfmt/binding-linux-arm-musleabihf@0.47.0': + '@oxfmt/binding-linux-arm-musleabihf@0.52.0': optional: true - '@oxfmt/binding-linux-arm64-gnu@0.47.0': + '@oxfmt/binding-linux-arm64-gnu@0.52.0': optional: true - '@oxfmt/binding-linux-arm64-musl@0.47.0': + '@oxfmt/binding-linux-arm64-musl@0.52.0': optional: true - '@oxfmt/binding-linux-ppc64-gnu@0.47.0': + '@oxfmt/binding-linux-ppc64-gnu@0.52.0': optional: true - '@oxfmt/binding-linux-riscv64-gnu@0.47.0': + '@oxfmt/binding-linux-riscv64-gnu@0.52.0': optional: true - '@oxfmt/binding-linux-riscv64-musl@0.47.0': + '@oxfmt/binding-linux-riscv64-musl@0.52.0': optional: true - '@oxfmt/binding-linux-s390x-gnu@0.47.0': + '@oxfmt/binding-linux-s390x-gnu@0.52.0': optional: true - '@oxfmt/binding-linux-x64-gnu@0.47.0': + '@oxfmt/binding-linux-x64-gnu@0.52.0': optional: true - '@oxfmt/binding-linux-x64-musl@0.47.0': + '@oxfmt/binding-linux-x64-musl@0.52.0': optional: true - '@oxfmt/binding-openharmony-arm64@0.47.0': + '@oxfmt/binding-openharmony-arm64@0.52.0': optional: true - '@oxfmt/binding-win32-arm64-msvc@0.47.0': + '@oxfmt/binding-win32-arm64-msvc@0.52.0': optional: true - '@oxfmt/binding-win32-ia32-msvc@0.47.0': + '@oxfmt/binding-win32-ia32-msvc@0.52.0': optional: true - '@oxfmt/binding-win32-x64-msvc@0.47.0': + '@oxfmt/binding-win32-x64-msvc@0.52.0': optional: true - '@oxlint/binding-android-arm-eabi@1.63.0': + '@oxlint/binding-android-arm-eabi@1.67.0': optional: true - '@oxlint/binding-android-arm64@1.63.0': + '@oxlint/binding-android-arm64@1.67.0': optional: true - '@oxlint/binding-darwin-arm64@1.63.0': + '@oxlint/binding-darwin-arm64@1.67.0': optional: true - '@oxlint/binding-darwin-x64@1.63.0': + '@oxlint/binding-darwin-x64@1.67.0': optional: true - '@oxlint/binding-freebsd-x64@1.63.0': + '@oxlint/binding-freebsd-x64@1.67.0': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.63.0': + '@oxlint/binding-linux-arm-gnueabihf@1.67.0': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.63.0': + '@oxlint/binding-linux-arm-musleabihf@1.67.0': optional: true - '@oxlint/binding-linux-arm64-gnu@1.63.0': + '@oxlint/binding-linux-arm64-gnu@1.67.0': optional: true - '@oxlint/binding-linux-arm64-musl@1.63.0': + '@oxlint/binding-linux-arm64-musl@1.67.0': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.63.0': + '@oxlint/binding-linux-ppc64-gnu@1.67.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.63.0': + '@oxlint/binding-linux-riscv64-gnu@1.67.0': optional: true - '@oxlint/binding-linux-riscv64-musl@1.63.0': + '@oxlint/binding-linux-riscv64-musl@1.67.0': optional: true - '@oxlint/binding-linux-s390x-gnu@1.63.0': + '@oxlint/binding-linux-s390x-gnu@1.67.0': optional: true - '@oxlint/binding-linux-x64-gnu@1.63.0': + '@oxlint/binding-linux-x64-gnu@1.67.0': optional: true - '@oxlint/binding-linux-x64-musl@1.63.0': + '@oxlint/binding-linux-x64-musl@1.67.0': optional: true - '@oxlint/binding-openharmony-arm64@1.63.0': + '@oxlint/binding-openharmony-arm64@1.67.0': optional: true - '@oxlint/binding-win32-arm64-msvc@1.63.0': + '@oxlint/binding-win32-arm64-msvc@1.67.0': optional: true - '@oxlint/binding-win32-ia32-msvc@1.63.0': + '@oxlint/binding-win32-ia32-msvc@1.67.0': optional: true - '@oxlint/binding-win32-x64-msvc@1.63.0': + '@oxlint/binding-win32-x64-msvc@1.67.0': optional: true '@pkgr/core@0.2.9': {} @@ -11759,51 +11786,51 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 - oxfmt@0.47.0: + oxfmt@0.52.0: dependencies: tinypool: 2.1.0 optionalDependencies: - '@oxfmt/binding-android-arm-eabi': 0.47.0 - '@oxfmt/binding-android-arm64': 0.47.0 - '@oxfmt/binding-darwin-arm64': 0.47.0 - '@oxfmt/binding-darwin-x64': 0.47.0 - '@oxfmt/binding-freebsd-x64': 0.47.0 - '@oxfmt/binding-linux-arm-gnueabihf': 0.47.0 - '@oxfmt/binding-linux-arm-musleabihf': 0.47.0 - '@oxfmt/binding-linux-arm64-gnu': 0.47.0 - '@oxfmt/binding-linux-arm64-musl': 0.47.0 - '@oxfmt/binding-linux-ppc64-gnu': 0.47.0 - '@oxfmt/binding-linux-riscv64-gnu': 0.47.0 - '@oxfmt/binding-linux-riscv64-musl': 0.47.0 - '@oxfmt/binding-linux-s390x-gnu': 0.47.0 - '@oxfmt/binding-linux-x64-gnu': 0.47.0 - '@oxfmt/binding-linux-x64-musl': 0.47.0 - '@oxfmt/binding-openharmony-arm64': 0.47.0 - '@oxfmt/binding-win32-arm64-msvc': 0.47.0 - '@oxfmt/binding-win32-ia32-msvc': 0.47.0 - '@oxfmt/binding-win32-x64-msvc': 0.47.0 + '@oxfmt/binding-android-arm-eabi': 0.52.0 + '@oxfmt/binding-android-arm64': 0.52.0 + '@oxfmt/binding-darwin-arm64': 0.52.0 + '@oxfmt/binding-darwin-x64': 0.52.0 + '@oxfmt/binding-freebsd-x64': 0.52.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.52.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.52.0 + '@oxfmt/binding-linux-arm64-gnu': 0.52.0 + '@oxfmt/binding-linux-arm64-musl': 0.52.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.52.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.52.0 + '@oxfmt/binding-linux-riscv64-musl': 0.52.0 + '@oxfmt/binding-linux-s390x-gnu': 0.52.0 + '@oxfmt/binding-linux-x64-gnu': 0.52.0 + '@oxfmt/binding-linux-x64-musl': 0.52.0 + '@oxfmt/binding-openharmony-arm64': 0.52.0 + '@oxfmt/binding-win32-arm64-msvc': 0.52.0 + '@oxfmt/binding-win32-ia32-msvc': 0.52.0 + '@oxfmt/binding-win32-x64-msvc': 0.52.0 - oxlint@1.63.0: + oxlint@1.67.0: optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.63.0 - '@oxlint/binding-android-arm64': 1.63.0 - '@oxlint/binding-darwin-arm64': 1.63.0 - '@oxlint/binding-darwin-x64': 1.63.0 - '@oxlint/binding-freebsd-x64': 1.63.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.63.0 - '@oxlint/binding-linux-arm-musleabihf': 1.63.0 - '@oxlint/binding-linux-arm64-gnu': 1.63.0 - '@oxlint/binding-linux-arm64-musl': 1.63.0 - '@oxlint/binding-linux-ppc64-gnu': 1.63.0 - '@oxlint/binding-linux-riscv64-gnu': 1.63.0 - '@oxlint/binding-linux-riscv64-musl': 1.63.0 - '@oxlint/binding-linux-s390x-gnu': 1.63.0 - '@oxlint/binding-linux-x64-gnu': 1.63.0 - '@oxlint/binding-linux-x64-musl': 1.63.0 - '@oxlint/binding-openharmony-arm64': 1.63.0 - '@oxlint/binding-win32-arm64-msvc': 1.63.0 - '@oxlint/binding-win32-ia32-msvc': 1.63.0 - '@oxlint/binding-win32-x64-msvc': 1.63.0 + '@oxlint/binding-android-arm-eabi': 1.67.0 + '@oxlint/binding-android-arm64': 1.67.0 + '@oxlint/binding-darwin-arm64': 1.67.0 + '@oxlint/binding-darwin-x64': 1.67.0 + '@oxlint/binding-freebsd-x64': 1.67.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.67.0 + '@oxlint/binding-linux-arm-musleabihf': 1.67.0 + '@oxlint/binding-linux-arm64-gnu': 1.67.0 + '@oxlint/binding-linux-arm64-musl': 1.67.0 + '@oxlint/binding-linux-ppc64-gnu': 1.67.0 + '@oxlint/binding-linux-riscv64-gnu': 1.67.0 + '@oxlint/binding-linux-riscv64-musl': 1.67.0 + '@oxlint/binding-linux-s390x-gnu': 1.67.0 + '@oxlint/binding-linux-x64-gnu': 1.67.0 + '@oxlint/binding-linux-x64-musl': 1.67.0 + '@oxlint/binding-openharmony-arm64': 1.67.0 + '@oxlint/binding-win32-arm64-msvc': 1.67.0 + '@oxlint/binding-win32-ia32-msvc': 1.67.0 + '@oxlint/binding-win32-x64-msvc': 1.67.0 p-limit@2.3.0: dependencies: diff --git a/mobile/src/browser/MobileBrowserPane.tsx b/mobile/src/browser/MobileBrowserPane.tsx index b53ef24d612..5efeee30893 100644 --- a/mobile/src/browser/MobileBrowserPane.tsx +++ b/mobile/src/browser/MobileBrowserPane.tsx @@ -47,6 +47,7 @@ import { type BrowserZoomState } from './browser-touch-geometry' import { displayBrowserUrl, normalizeBrowserUrl } from './browser-url' +import { resolveMobileBrowserAddressSync } from './mobile-browser-address-sync' export type MobileBrowserTab = { type: 'browser' @@ -135,6 +136,10 @@ export function MobileBrowserPane({ const cachedInitialFrame = peekCachedBrowserFrame(cacheKey) const [addressValue, setAddressValue] = useState(displayBrowserUrl(tab.url)) const [addressFocused, setAddressFocused] = useState(false) + const [addressSyncState, setAddressSyncState] = useState({ + focused: false, + url: tab.url + }) const [keyboardValue, setKeyboardValue] = useState('') const [frameUri, setFrameUri] = useState(cachedInitialFrame?.uri ?? null) const [frameMetadata, setFrameMetadata] = useState( @@ -188,6 +193,17 @@ export function MobileBrowserPane({ } }, []) + const setRootViewRef = useCallback( + (node: View | null) => { + // Why: long-press right-click timers belong to this responder surface; + // clearing from ref cleanup preserves the same unmount boundary. + if (node === null) { + clearLongPressTimer() + } + }, + [clearLongPressTimer] + ) + const resetBrowserZoomState = useCallback(() => { clearLongPressTimer() pinchRef.current = null @@ -211,11 +227,18 @@ export function MobileBrowserPane({ } }, [worktreeId]) - useEffect(() => { - if (!addressFocused) { + const addressSync = resolveMobileBrowserAddressSync(addressSyncState, { + focused: addressFocused, + url: tab.url + }) + if (addressSync.nextState !== addressSyncState) { + setAddressSyncState(addressSync.nextState) + if (addressSync.shouldSyncValue) { + // Why: keep browser stream/goto address updates intact, but avoid a + // stale post-blur paint when the tab URL is the source of truth. setAddressValue(displayBrowserUrl(tab.url)) } - }, [addressFocused, tab.url]) + } useLayoutEffect(() => { // Why: gesture and stream handlers need committed values before passive @@ -694,8 +717,6 @@ export function MobileBrowserPane({ [client, flushPendingWheelCommand, pageParams] ) - useEffect(() => () => clearLongPressTimer(), [clearLongPressTimer]) - const mapTouchPoint = useCallback((locationX: number, locationY: number): BrowserPoint | null => { return mapScreenToBrowserPoint( locationX, @@ -1017,7 +1038,7 @@ export function MobileBrowserPane({ ) return ( - + { + it('syncs the tab URL when the input is not focused', () => { + const result = resolveMobileBrowserAddressSync( + { focused: false, url: 'https://old.example/' }, + { focused: false, url: 'https://new.example/' } + ) + + expect(result).toEqual({ + nextState: { focused: false, url: 'https://new.example/' }, + shouldSyncValue: true + }) + }) + + it('defers tab URL sync while the user is editing', () => { + const result = resolveMobileBrowserAddressSync( + { focused: false, url: 'https://old.example/' }, + { focused: true, url: 'https://new.example/' } + ) + + expect(result).toEqual({ + nextState: { focused: true, url: 'https://new.example/' }, + shouldSyncValue: false + }) + }) + + it('syncs the latest tab URL when editing ends', () => { + const result = resolveMobileBrowserAddressSync( + { focused: true, url: 'https://new.example/' }, + { focused: false, url: 'https://new.example/' } + ) + + expect(result).toEqual({ + nextState: { focused: false, url: 'https://new.example/' }, + shouldSyncValue: true + }) + }) + + it('preserves externally updated address text when focus and tab URL are unchanged', () => { + const previous = { focused: false, url: 'https://new.example/' } + const result = resolveMobileBrowserAddressSync(previous, { + focused: false, + url: 'https://new.example/' + }) + + expect(result).toEqual({ + nextState: previous, + shouldSyncValue: false + }) + }) +}) diff --git a/mobile/src/browser/mobile-browser-address-sync.ts b/mobile/src/browser/mobile-browser-address-sync.ts new file mode 100644 index 00000000000..702739590ed --- /dev/null +++ b/mobile/src/browser/mobile-browser-address-sync.ts @@ -0,0 +1,20 @@ +export type MobileBrowserAddressSyncState = { + focused: boolean + url: string +} + +export type MobileBrowserAddressSyncResult = { + nextState: MobileBrowserAddressSyncState + shouldSyncValue: boolean +} + +export function resolveMobileBrowserAddressSync( + previous: MobileBrowserAddressSyncState, + next: MobileBrowserAddressSyncState +): MobileBrowserAddressSyncResult { + const changed = previous.focused !== next.focused || previous.url !== next.url + return { + nextState: changed ? next : previous, + shouldSyncValue: changed && !next.focused + } +} diff --git a/mobile/src/components/BottomDrawer.tsx b/mobile/src/components/BottomDrawer.tsx index e96f035c2a7..a0d154c4296 100644 --- a/mobile/src/components/BottomDrawer.tsx +++ b/mobile/src/components/BottomDrawer.tsx @@ -22,6 +22,7 @@ import Animated, { Extrapolation } from 'react-native-reanimated' import { colors, spacing } from '../theme/mobile-theme' +import { resolveBottomDrawerMounted } from './bottom-drawer-mount-state' import { useResponsiveLayout } from '../layout/responsive-layout' const DISMISS_THRESHOLD = 80 @@ -50,16 +51,17 @@ export function BottomDrawer({ zIndex }: Props) { const [mounted, setMounted] = useState(visible) + const resolvedMounted = resolveBottomDrawerMounted(visible, mounted) - useEffect(() => { - if (visible) { - setMounted(true) - } - }, [visible]) + // Why: opening drawers should mount before commit; waiting for a passive + // Effect adds a null render before every drawer can animate in. + if (resolvedMounted !== mounted) { + setMounted(resolvedMounted) + } // Why: hidden drawers are rendered by parent screens even while closed; keep // their Reanimated/Gesture setup out of hot paths like commit-message typing. - if (!mounted) return null + if (!resolvedMounted) return null return ( (null) - // Why: keep the latest entry visible while logs grow during a slow - // connect. Skip on empty so the title row doesn't jump. - useEffect(() => { - if (entries.length === 0) return - const id = setTimeout(() => scrollRef.current?.scrollToEnd({ animated: true }), 16) - return () => clearTimeout(id) - }, [entries.length]) - if (entries.length === 0) return null const baseTs = entries[0]!.ts @@ -55,6 +47,7 @@ export function ConnectionLog({ entries, title }: Props) { style={styles.scroll} contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false} + onContentSizeChange={() => scrollRef.current?.scrollToEnd({ animated: true })} > {entries.map((entry) => ( diff --git a/mobile/src/components/CustomKeyModal.tsx b/mobile/src/components/CustomKeyModal.tsx index 7931d4f9485..5299563b012 100644 --- a/mobile/src/components/CustomKeyModal.tsx +++ b/mobile/src/components/CustomKeyModal.tsx @@ -12,7 +12,7 @@ import { type TerminalShortcutSpecialKey } from '../terminal/terminal-accessory-keys' -export const CUSTOM_ACCESSORY_KEYS_STORAGE_KEY = 'orca:custom-accessory-keys' +const CUSTOM_ACCESSORY_KEYS_STORAGE_KEY = 'orca:custom-accessory-keys' export type CustomKey = { id: string diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx index 365de5316d3..05856e3bb00 100644 --- a/mobile/src/components/NewWorktreeModal.tsx +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -17,7 +17,6 @@ import { BottomDrawer } from './BottomDrawer' import { MobileAgentIcon } from './MobileAgentIcon' import { getSuggestedCreatureName } from './worktree-name-suggestion' import { deriveWorkspaceSshGate, workspaceSshStatusLabel } from '../tasks/workspace-ssh-gate' -import { MOBILE_AGENT_CATALOG } from '../tasks/mobile-agent-catalog' import { WORKTREE_CREATE_TIMEOUT_MS } from '../tasks/workspace-create-timeout' import { isSetupHookTrusted, @@ -27,13 +26,19 @@ import { type SetupHookTrust } from '../tasks/setup-hook-trust' import { - filterEnabledMobileTuiAgents, isMobileTuiAgent, isMobileTuiAgentEnabled, MOBILE_TUI_AGENT_LAUNCH_COMMANDS } from '../tasks/mobile-tui-agents' import type { PersistedTrustedOrcaHooks, TuiAgent } from '../../../src/shared/types' import type { SshConnectionState } from '../../../src/shared/ssh-types' +import { + NEW_WORKTREE_AGENT_OPTIONS as AGENT_OPTIONS, + NEW_WORKTREE_BLANK_AGENT as BLANK_TERMINAL, + pickPreferredNewWorktreeAgent, + resolveNewWorktreeAgentSelection, + type NewWorktreeAgentOption as AgentOption +} from './new-worktree-agent-selection' type Repo = { id: string @@ -71,51 +76,6 @@ type SetupTrustPrompt = { previouslyApproved: boolean } -type AgentOption = { - id: TuiAgent | '__blank__' - label: string - faviconDomain?: string -} - -const AGENT_OPTIONS: AgentOption[] = MOBILE_AGENT_CATALOG - -const BLANK_TERMINAL: AgentOption = { id: '__blank__', label: 'Blank Terminal' } - -function agentOptionFor(id: string | null | undefined): AgentOption | null { - if (!id) return null - if (id === 'blank' || id === '__blank__') return BLANK_TERMINAL - return AGENT_OPTIONS.find((agent) => agent.id === id) ?? null -} - -function pickPreferredAgent( - settings: RuntimeSettings | null, - detectedAgentIds: Set | null -): AgentOption { - const preferred = agentOptionFor(settings?.defaultTuiAgent) - if (preferred?.id === '__blank__') { - return preferred - } - if ( - preferred && - isMobileTuiAgent(preferred.id) && - isMobileTuiAgentEnabled(preferred.id, settings?.disabledTuiAgents) && - (detectedAgentIds === null || detectedAgentIds.has(preferred.id)) - ) { - return preferred - } - const enabledAgents = filterEnabledMobileTuiAgents( - MOBILE_AGENT_CATALOG.map((agent) => agent.id), - settings?.disabledTuiAgents - ) - const detectedOption = AGENT_OPTIONS.find( - (agent) => - agent.id !== '__blank__' && - enabledAgents.includes(agent.id) && - (detectedAgentIds === null || detectedAgentIds.has(agent.id)) - ) - return detectedOption ?? BLANK_TERMINAL -} - function repoColor(name: string): string { const palette = ['#f97316', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16', '#f59e0b', '#6366f1'] let hash = 0 @@ -209,10 +169,10 @@ export function NewWorktreeModal({ const [repos, setRepos] = useState([]) const [selectedRepo, setSelectedRepo] = useState(null) const [showRepoPicker, setShowRepoPicker] = useState(false) - const [selectedAgent, setSelectedAgent] = useState(AGENT_OPTIONS[0]!) + const [selectedAgentState, setSelectedAgent] = useState(AGENT_OPTIONS[0]!) const [runtimeSettings, setRuntimeSettings] = useState(null) const [detectedAgentIds, setDetectedAgentIds] = useState | null>(null) - const [agentOverridden, setAgentOverridden] = useState(false) + const [agentOverriddenState, setAgentOverridden] = useState(false) const [showAgentPicker, setShowAgentPicker] = useState(false) const [sshState, setSshState] = useState(null) const [sshConnecting, setSshConnecting] = useState(false) @@ -246,6 +206,23 @@ export function NewWorktreeModal({ state: sshState, connecting: sshConnecting }) + const selectedAgentResolution = resolveNewWorktreeAgentSelection({ + visible, + selectedAgent: selectedAgentState, + agentOverridden: agentOverriddenState, + runtimeSettings, + detectedAgentIds + }) + // Why: agent preference repair is pure render dataflow; doing it here + // avoids a stale selected-agent commit while preserving user overrides. + if ( + selectedAgentState.id !== selectedAgentResolution.selectedAgent.id || + agentOverriddenState !== selectedAgentResolution.agentOverridden + ) { + setSelectedAgent(selectedAgentResolution.selectedAgent) + setAgentOverridden(selectedAgentResolution.agentOverridden) + } + const selectedAgent = selectedAgentResolution.selectedAgent useEffect(() => { if (!visible) { @@ -383,23 +360,6 @@ export function NewWorktreeModal({ } }, [client, selectedRepoConnectionId, sshGate.status, visible]) - useEffect(() => { - if (!visible || agentOverridden) return - setSelectedAgent(pickPreferredAgent(runtimeSettings, detectedAgentIds)) - }, [agentOverridden, detectedAgentIds, runtimeSettings, visible]) - - useEffect(() => { - if (!visible || detectedAgentIds === null || selectedAgent.id === '__blank__') return - if ( - detectedAgentIds.has(selectedAgent.id) && - isMobileTuiAgentEnabled(selectedAgent.id, runtimeSettings?.disabledTuiAgents) - ) { - return - } - setSelectedAgent(pickPreferredAgent(runtimeSettings, detectedAgentIds)) - setAgentOverridden(false) - }, [detectedAgentIds, runtimeSettings, selectedAgent.id, visible]) - useEffect(() => { if (!client || !selectedRepo) { setSetupCommand(null) @@ -526,7 +486,7 @@ export function NewWorktreeModal({ selectedAgent.id !== '__blank__' && !isMobileTuiAgentEnabled(selectedAgent.id, latestRuntimeSettings?.disabledTuiAgents) ) { - setSelectedAgent(pickPreferredAgent(latestRuntimeSettings, detectedAgentIds)) + setSelectedAgent(pickPreferredNewWorktreeAgent(latestRuntimeSettings, detectedAgentIds)) setAgentOverridden(false) setError('Selected agent is disabled. Choose an enabled agent before creating.') return diff --git a/mobile/src/components/PickerModal.tsx b/mobile/src/components/PickerModal.tsx index f786f99004c..99935379cb5 100644 --- a/mobile/src/components/PickerModal.tsx +++ b/mobile/src/components/PickerModal.tsx @@ -23,6 +23,11 @@ type Props = { zIndex?: number } +type PickerModalContentProps = Pick< + Props, + 'options' | 'selected' | 'onSelect' | 'onLongSelect' | 'onClose' +> + export function PickerModal({ visible, title, @@ -39,53 +44,73 @@ export function PickerModal({ {title} - - {options.map((opt, i) => { - const isSelected = opt.value === selected - return ( - - {i > 0 && } - [ - styles.row, - pressed && !opt.disabled && styles.rowPressed, - opt.disabled && styles.rowDisabled - ]} - onPress={() => { - if (opt.disabled) return - onSelect(opt.value) - onClose() - }} - onLongPress={ - onLongSelect - ? () => { - if (opt.disabled) return - onLongSelect(opt.value) - onClose() - } - : undefined - } - > - {opt.renderIcon ? ( - {opt.renderIcon(isSelected)} - ) : null} - - - {opt.label} - - {opt.subtitle ? {opt.subtitle} : null} - - {isSelected && } - - - ) - })} - + ) } +function PickerModalContent({ + options, + selected, + onSelect, + onLongSelect, + onClose +}: PickerModalContentProps) { + // Why: closed BottomDrawer instances return null, so keeping option rows in + // this child avoids rebuilding hidden picker contents on every parent render. + return ( + + {options.map((opt, i) => { + const isSelected = opt.value === selected + return ( + + {i > 0 && } + [ + styles.row, + pressed && !opt.disabled && styles.rowPressed, + opt.disabled && styles.rowDisabled + ]} + onPress={() => { + if (opt.disabled) return + onSelect(opt.value) + onClose() + }} + onLongPress={ + onLongSelect + ? () => { + if (opt.disabled) return + onLongSelect(opt.value) + onClose() + } + : undefined + } + > + {opt.renderIcon ? ( + {opt.renderIcon(isSelected)} + ) : null} + + + {opt.label} + + {opt.subtitle ? {opt.subtitle} : null} + + {isSelected && } + + + ) + })} + + ) +} + const styles = StyleSheet.create({ header: { paddingHorizontal: spacing.xs, diff --git a/mobile/src/components/bottom-drawer-mount-state.test.ts b/mobile/src/components/bottom-drawer-mount-state.test.ts new file mode 100644 index 00000000000..638780b7238 --- /dev/null +++ b/mobile/src/components/bottom-drawer-mount-state.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest' +import { resolveBottomDrawerMounted } from './bottom-drawer-mount-state' + +describe('resolveBottomDrawerMounted', () => { + it('mounts before opening and stays mounted while closing', () => { + expect(resolveBottomDrawerMounted(true, false)).toBe(true) + expect(resolveBottomDrawerMounted(true, true)).toBe(true) + expect(resolveBottomDrawerMounted(false, true)).toBe(true) + expect(resolveBottomDrawerMounted(false, false)).toBe(false) + }) +}) diff --git a/mobile/src/components/bottom-drawer-mount-state.ts b/mobile/src/components/bottom-drawer-mount-state.ts new file mode 100644 index 00000000000..3e9a7f11068 --- /dev/null +++ b/mobile/src/components/bottom-drawer-mount-state.ts @@ -0,0 +1,3 @@ +export function resolveBottomDrawerMounted(visible: boolean, mounted: boolean): boolean { + return visible || mounted +} diff --git a/mobile/src/components/new-worktree-agent-selection.test.ts b/mobile/src/components/new-worktree-agent-selection.test.ts new file mode 100644 index 00000000000..dde450d66b9 --- /dev/null +++ b/mobile/src/components/new-worktree-agent-selection.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest' + +import { + NEW_WORKTREE_BLANK_AGENT, + newWorktreeAgentOptionFor, + pickPreferredNewWorktreeAgent, + resolveNewWorktreeAgentSelection +} from './new-worktree-agent-selection' + +describe('new worktree agent selection', () => { + it('picks the preferred detected agent when there is no user override', () => { + const selected = newWorktreeAgentOptionFor('claude') + const resolved = resolveNewWorktreeAgentSelection({ + visible: true, + selectedAgent: selected, + agentOverridden: false, + runtimeSettings: { defaultTuiAgent: 'codex' }, + detectedAgentIds: new Set(['claude', 'codex']) + }) + + expect(resolved).toEqual({ + selectedAgent: newWorktreeAgentOptionFor('codex'), + agentOverridden: false + }) + }) + + it('keeps an available user override', () => { + const selected = newWorktreeAgentOptionFor('codex') + const resolved = resolveNewWorktreeAgentSelection({ + visible: true, + selectedAgent: selected, + agentOverridden: true, + runtimeSettings: { defaultTuiAgent: 'claude' }, + detectedAgentIds: new Set(['claude', 'codex']) + }) + + expect(resolved).toEqual({ selectedAgent: selected, agentOverridden: true }) + }) + + it('clears an unavailable user override after detection completes', () => { + const resolved = resolveNewWorktreeAgentSelection({ + visible: true, + selectedAgent: newWorktreeAgentOptionFor('codex'), + agentOverridden: true, + runtimeSettings: { defaultTuiAgent: 'claude' }, + detectedAgentIds: new Set(['claude']) + }) + + expect(resolved).toEqual({ + selectedAgent: newWorktreeAgentOptionFor('claude'), + agentOverridden: false + }) + }) + + it('clears a disabled user override after detection completes', () => { + const resolved = resolveNewWorktreeAgentSelection({ + visible: true, + selectedAgent: newWorktreeAgentOptionFor('codex'), + agentOverridden: true, + runtimeSettings: { defaultTuiAgent: 'claude', disabledTuiAgents: ['codex'] }, + detectedAgentIds: new Set(['claude', 'codex']) + }) + + expect(resolved).toEqual({ + selectedAgent: newWorktreeAgentOptionFor('claude'), + agentOverridden: false + }) + }) + + it('keeps blank terminal as an explicit override', () => { + const resolved = resolveNewWorktreeAgentSelection({ + visible: true, + selectedAgent: NEW_WORKTREE_BLANK_AGENT, + agentOverridden: true, + runtimeSettings: { defaultTuiAgent: 'claude' }, + detectedAgentIds: new Set(['claude']) + }) + + expect(resolved).toEqual({ + selectedAgent: NEW_WORKTREE_BLANK_AGENT, + agentOverridden: true + }) + }) + + it('leaves closed modal state untouched', () => { + const selected = newWorktreeAgentOptionFor('codex') + const resolved = resolveNewWorktreeAgentSelection({ + visible: false, + selectedAgent: selected, + agentOverridden: true, + runtimeSettings: { defaultTuiAgent: 'claude' }, + detectedAgentIds: new Set(['claude']) + }) + + expect(resolved).toEqual({ selectedAgent: selected, agentOverridden: true }) + }) + + it('uses blank when no detected agent is known', () => { + expect(pickPreferredNewWorktreeAgent({ defaultTuiAgent: null }, new Set()).id).toBe('__blank__') + }) +}) diff --git a/mobile/src/components/new-worktree-agent-selection.ts b/mobile/src/components/new-worktree-agent-selection.ts new file mode 100644 index 00000000000..22101c00a30 --- /dev/null +++ b/mobile/src/components/new-worktree-agent-selection.ts @@ -0,0 +1,90 @@ +import type { TuiAgent } from '../../../src/shared/types' +import { MOBILE_AGENT_CATALOG } from '../tasks/mobile-agent-catalog' +import { isMobileTuiAgentEnabled } from '../tasks/mobile-tui-agents' +import { pickWorkspaceAgent } from '../tasks/workspace-agent-selection' + +export type NewWorktreeRuntimeSettings = { + defaultTuiAgent?: TuiAgent | 'blank' | null + disabledTuiAgents?: TuiAgent[] +} + +export type NewWorktreeAgentOption = { + id: TuiAgent | '__blank__' + label: string + faviconDomain?: string +} + +export const NEW_WORKTREE_AGENT_OPTIONS: NewWorktreeAgentOption[] = MOBILE_AGENT_CATALOG + +export const NEW_WORKTREE_BLANK_AGENT: NewWorktreeAgentOption = { + id: '__blank__', + label: 'Blank Terminal' +} + +export function newWorktreeAgentOptionFor(id: string | null | undefined): NewWorktreeAgentOption { + if (id === 'blank' || id === '__blank__') { + return NEW_WORKTREE_BLANK_AGENT + } + return NEW_WORKTREE_AGENT_OPTIONS.find((agent) => agent.id === id) ?? NEW_WORKTREE_BLANK_AGENT +} + +export function pickPreferredNewWorktreeAgent( + settings: NewWorktreeRuntimeSettings | null, + detectedAgentIds: Set | null +): NewWorktreeAgentOption { + return newWorktreeAgentOptionFor( + pickWorkspaceAgent( + { + defaultTuiAgent: settings?.defaultTuiAgent, + disabledTuiAgents: settings?.disabledTuiAgents + }, + detectedAgentIds + ) + ) +} + +function isSelectableAgent( + agent: NewWorktreeAgentOption, + settings: NewWorktreeRuntimeSettings | null, + detectedAgentIds: Set | null +): boolean { + if (agent.id === '__blank__') { + return true + } + if (!isMobileTuiAgentEnabled(agent.id, settings?.disabledTuiAgents)) { + return false + } + return detectedAgentIds === null || detectedAgentIds.has(agent.id) +} + +export function resolveNewWorktreeAgentSelection({ + visible, + selectedAgent, + agentOverridden, + runtimeSettings, + detectedAgentIds +}: { + visible: boolean + selectedAgent: NewWorktreeAgentOption + agentOverridden: boolean + runtimeSettings: NewWorktreeRuntimeSettings | null + detectedAgentIds: Set | null +}): { selectedAgent: NewWorktreeAgentOption; agentOverridden: boolean } { + if (!visible) { + return { selectedAgent, agentOverridden } + } + + const preferred = pickPreferredNewWorktreeAgent(runtimeSettings, detectedAgentIds) + if (!agentOverridden) { + return { selectedAgent: preferred, agentOverridden: false } + } + + if ( + detectedAgentIds !== null && + !isSelectableAgent(selectedAgent, runtimeSettings, detectedAgentIds) + ) { + return { selectedAgent: preferred, agentOverridden: false } + } + + return { selectedAgent, agentOverridden: true } +} diff --git a/mobile/src/components/worktree-name-suggestion.ts b/mobile/src/components/worktree-name-suggestion.ts index 55665b8c59a..e1f14d5464c 100644 --- a/mobile/src/components/worktree-name-suggestion.ts +++ b/mobile/src/components/worktree-name-suggestion.ts @@ -13,7 +13,7 @@ function stripTrailingSeparators(p: string): string { // Why: cross-platform path basename — handles both POSIX ("/") and Windows // ("\\") separators, mirroring src/renderer/src/lib/path.ts so the mobile // suggestion logic agrees with the desktop's collision check. -export function pathBasename(p: string): string { +function pathBasename(p: string): string { const normalized = stripTrailingSeparators(p) const idx = Math.max(normalized.lastIndexOf('/'), normalized.lastIndexOf('\\')) return idx === -1 ? normalized : normalized.slice(idx + 1) diff --git a/mobile/src/diagnostics/diagnostic-fetch-timeout.test.ts b/mobile/src/diagnostics/diagnostic-fetch-timeout.test.ts new file mode 100644 index 00000000000..6843f48ae01 --- /dev/null +++ b/mobile/src/diagnostics/diagnostic-fetch-timeout.test.ts @@ -0,0 +1,33 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { startDiagnosticFetchTimeout } from './diagnostic-fetch-timeout' + +describe('diagnostic fetch timeout', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('aborts when the timeout fires', () => { + vi.useFakeTimers() + const attempt = startDiagnosticFetchTimeout(5000) + + expect(attempt.signal.aborted).toBe(false) + expect(attempt.timedOut).toBe(false) + + vi.advanceTimersByTime(5000) + + expect(attempt.signal.aborted).toBe(true) + expect(attempt.timedOut).toBe(true) + }) + + it('clears the timeout when disposed early', () => { + vi.useFakeTimers() + const attempt = startDiagnosticFetchTimeout(5000) + + attempt.dispose() + expect(attempt.signal.aborted).toBe(true) + expect(attempt.timedOut).toBe(false) + + vi.advanceTimersByTime(5000) + expect(attempt.timedOut).toBe(false) + }) +}) diff --git a/mobile/src/diagnostics/diagnostic-fetch-timeout.ts b/mobile/src/diagnostics/diagnostic-fetch-timeout.ts new file mode 100644 index 00000000000..8cc4fe2cad4 --- /dev/null +++ b/mobile/src/diagnostics/diagnostic-fetch-timeout.ts @@ -0,0 +1,36 @@ +export type DiagnosticFetchTimeout = { + readonly signal: AbortSignal + readonly timedOut: boolean + dispose: () => void +} + +export function startDiagnosticFetchTimeout(timeoutMs: number): DiagnosticFetchTimeout { + const controller = new AbortController() + let disposed = false + let timedOut = false + let timer: ReturnType | null = setTimeout(() => { + timer = null + timedOut = true + controller.abort() + }, timeoutMs) + + function dispose() { + if (disposed) return + disposed = true + if (timer) { + clearTimeout(timer) + timer = null + } + if (!controller.signal.aborted) { + controller.abort() + } + } + + return { + signal: controller.signal, + get timedOut() { + return timedOut + }, + dispose + } +} diff --git a/mobile/src/diagnostics/host-reachability.test.ts b/mobile/src/diagnostics/host-reachability.test.ts new file mode 100644 index 00000000000..9b2933a4e0f --- /dev/null +++ b/mobile/src/diagnostics/host-reachability.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from 'vitest' +import { testHostReachability } from './host-reachability' + +describe('testHostReachability', () => { + it('returns false without leaving timers when WebSocket rejects a malformed endpoint', async () => { + vi.useFakeTimers() + vi.stubGlobal( + 'WebSocket', + class { + constructor() { + throw new TypeError('Invalid URL') + } + } + ) + + try { + await expect(testHostReachability('not-a-url')).resolves.toBe(false) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.unstubAllGlobals() + vi.useRealTimers() + } + }) +}) diff --git a/mobile/src/diagnostics/host-reachability.ts b/mobile/src/diagnostics/host-reachability.ts new file mode 100644 index 00000000000..7ae292126e8 --- /dev/null +++ b/mobile/src/diagnostics/host-reachability.ts @@ -0,0 +1,58 @@ +const HOST_REACHABILITY_TIMEOUT_MS = 4000 + +// Why: troubleshooting needs a cheap endpoint probe without completing the +// encrypted mobile runtime handshake. +export async function testHostReachability(endpoint: string): Promise { + return new Promise((resolve) => { + let ws: WebSocket + try { + ws = new WebSocket(endpoint) + } catch { + resolve(false) + return + } + + let settled = false + let timeout: ReturnType | null = null + + const finish = (reachable: boolean, closeSocket: boolean): void => { + if (settled) { + return + } + settled = true + if (timeout) { + clearTimeout(timeout) + timeout = null + } + if (closeSocket) { + try { + ws.close() + } catch { + // The probe is already complete; close failures should not change the diagnostic result. + } + } + resolve(reachable) + } + + timeout = setTimeout(() => { + finish(false, true) + }, HOST_REACHABILITY_TIMEOUT_MS) + + ws.onopen = () => { + finish(true, true) + } + + ws.onerror = () => { + finish(false, true) + } + }) +} + +export function formatEndpoint(endpoint: string): string { + try { + const url = new URL(endpoint) + return url.host + } catch { + return endpoint + } +} diff --git a/mobile/src/hooks/mobile-dictation-pending-audio-budget.test.ts b/mobile/src/hooks/mobile-dictation-pending-audio-budget.test.ts new file mode 100644 index 00000000000..249f923c33f --- /dev/null +++ b/mobile/src/hooks/mobile-dictation-pending-audio-budget.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { + MOBILE_DICTATION_MAX_PENDING_AUDIO_BYTES, + MobileDictationPendingAudioBudget +} from './mobile-dictation-pending-audio-budget' + +describe('MobileDictationPendingAudioBudget', () => { + it('caps pending raw PCM audio at five seconds', () => { + expect(MOBILE_DICTATION_MAX_PENDING_AUDIO_BYTES).toBe(160_000) + + const budget = new MobileDictationPendingAudioBudget() + expect(budget.tryReserve(159_999)).toBe(true) + expect(budget.tryReserve(1)).toBe(true) + expect(budget.pendingAudioBytes).toBe(160_000) + expect(budget.tryReserve(1)).toBe(false) + expect(budget.pendingAudioBytes).toBe(160_000) + }) + + it('releases completed chunks and clamps stale releases after reset', () => { + const budget = new MobileDictationPendingAudioBudget(10) + + expect(budget.tryReserve(6)).toBe(true) + budget.release(4) + expect(budget.pendingAudioBytes).toBe(2) + expect(budget.tryReserve(8)).toBe(true) + + budget.reset() + budget.release(6) + expect(budget.pendingAudioBytes).toBe(0) + expect(budget.tryReserve(10)).toBe(true) + }) +}) diff --git a/mobile/src/hooks/mobile-dictation-pending-audio-budget.ts b/mobile/src/hooks/mobile-dictation-pending-audio-budget.ts new file mode 100644 index 00000000000..7f814e493bc --- /dev/null +++ b/mobile/src/hooks/mobile-dictation-pending-audio-budget.ts @@ -0,0 +1,46 @@ +export const MOBILE_DICTATION_PCM_SAMPLE_RATE = 16000 + +const PCM_BYTES_PER_SAMPLE = 2 +const MAX_PENDING_AUDIO_SECONDS = 5 + +// Why: dictation chunk RPCs can wait through reconnect before timing out, so +// cap retained raw microphone audio before it is expanded into base64 payloads. +export const MOBILE_DICTATION_MAX_PENDING_AUDIO_BYTES = + MOBILE_DICTATION_PCM_SAMPLE_RATE * PCM_BYTES_PER_SAMPLE * MAX_PENDING_AUDIO_SECONDS + +export const MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE = + 'Connection is too slow for voice dictation. Try again when the connection improves.' + +export class MobileDictationPendingAudioBudget { + private pendingBytes = 0 + + constructor(private readonly maxPendingBytes = MOBILE_DICTATION_MAX_PENDING_AUDIO_BYTES) {} + + get pendingAudioBytes(): number { + return this.pendingBytes + } + + tryReserve(byteLength: number): boolean { + const normalizedByteLength = normalizeByteLength(byteLength) + if (this.pendingBytes + normalizedByteLength > this.maxPendingBytes) { + return false + } + this.pendingBytes += normalizedByteLength + return true + } + + release(byteLength: number): void { + this.pendingBytes = Math.max(0, this.pendingBytes - normalizeByteLength(byteLength)) + } + + reset(): void { + this.pendingBytes = 0 + } +} + +function normalizeByteLength(byteLength: number): number { + if (!Number.isFinite(byteLength) || byteLength <= 0) { + return 0 + } + return Math.floor(byteLength) +} diff --git a/mobile/src/hooks/use-mobile-dictation-source.test.ts b/mobile/src/hooks/use-mobile-dictation-source.test.ts new file mode 100644 index 00000000000..6319e2d9b5a --- /dev/null +++ b/mobile/src/hooks/use-mobile-dictation-source.test.ts @@ -0,0 +1,51 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(new URL('./use-mobile-dictation.ts', import.meta.url), 'utf8') + +function sliceBetween(startPattern: string, endPattern: string): string { + const start = source.indexOf(startPattern) + expect(start).toBeGreaterThanOrEqual(0) + const end = source.indexOf(endPattern, start) + expect(end).toBeGreaterThan(start) + return source.slice(start, end) +} + +describe('useMobileDictation source invariants', () => { + it('publishes live option refs from committed renders before passive Effects flush', () => { + const refDeclarations = sliceBetween( + 'const clientRef = useRef(client)', + 'useLayoutEffect(() => {' + ) + expect(refDeclarations).not.toContain('.current =') + + const mirrorEffect = sliceBetween('useLayoutEffect(() => {', 'const reportError =') + expect(mirrorEffect).toContain('clientRef.current = client') + expect(mirrorEffect).toContain('enabledRef.current = enabled') + expect(mirrorEffect).toContain('onTranscriptRef.current = onTranscript') + expect(mirrorEffect).toContain('onErrorRef.current = onError') + expect(mirrorEffect).toContain('}, [client, enabled, onTranscript, onError])') + }) + + it('reserves pending audio bytes before encoding microphone chunks', () => { + const microphoneEffect = sliceBetween( + "addExpoTwoWayAudioEventListener('onMicrophoneData'", + 'return () => sub.remove()' + ) + + const reserveIndex = microphoneEffect.indexOf('tryReserve(byteLength)') + const encodeIndex = microphoneEffect.indexOf('bytesToBase64(bytes)') + expect(reserveIndex).toBeGreaterThanOrEqual(0) + expect(encodeIndex).toBeGreaterThanOrEqual(0) + expect(reserveIndex).toBeLessThan(encodeIndex) + expect(microphoneEffect).toContain('MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE') + expect(microphoneEffect).toContain('pendingAudioBudgetRef.current.release(byteLength)') + }) + + it('resets pending audio bytes whenever pending chunk tracking is cleared', () => { + const pendingChunkClears = source.match(/pendingChunksRef\.current\.clear\(\)/g) ?? [] + const pendingAudioResets = source.match(/pendingAudioBudgetRef\.current\.reset\(\)/g) ?? [] + + expect(pendingAudioResets).toHaveLength(pendingChunkClears.length) + }) +}) diff --git a/mobile/src/hooks/use-mobile-dictation.ts b/mobile/src/hooks/use-mobile-dictation.ts index 29963ecec05..d46a6147ac3 100644 --- a/mobile/src/hooks/use-mobile-dictation.ts +++ b/mobile/src/hooks/use-mobile-dictation.ts @@ -1,7 +1,7 @@ /* oxlint-disable max-lines -- Why: mobile dictation keeps permission, recording, * chunk upload, completion, and cancellation in one hook so native audio state * cannot drift from the runtime RPC lifecycle. */ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { Buffer } from 'buffer' import { addExpoTwoWayAudioEventListener, @@ -11,6 +11,11 @@ import { toggleRecording } from '@orca/expo-two-way-audio' import type { RpcClient } from '../transport/rpc-client' +import { + MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE, + MOBILE_DICTATION_PCM_SAMPLE_RATE, + MobileDictationPendingAudioBudget +} from './mobile-dictation-pending-audio-budget' type DictationStatus = 'idle' | 'starting' | 'recording' | 'processing' | 'error' @@ -32,7 +37,6 @@ export type UseMobileDictationResult = { cancel: () => Promise } -const MOBILE_PCM_SAMPLE_RATE = 16000 const DICTATION_FINISH_TIMEOUT_MS = 75_000 function bytesToBase64(bytes: Uint8Array): string { @@ -53,11 +57,14 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil const onTranscriptRef = useRef(onTranscript) const onErrorRef = useRef(onError) const pendingChunksRef = useRef>>(new Set()) + const pendingAudioBudgetRef = useRef(new MobileDictationPendingAudioBudget()) const acceptingChunksRef = useRef(false) const generationRef = useRef(0) const finishingIdRef = useRef(null) - useEffect(() => { + useLayoutEffect(() => { + // Native audio events can arrive before passive Effects flush, but refs + // should only expose options from a committed render. clientRef.current = client enabledRef.current = enabled onTranscriptRef.current = onTranscript @@ -80,6 +87,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil activeIdRef.current = null acceptingChunksRef.current = false pendingChunksRef.current.clear() + pendingAudioBudgetRef.current.reset() toggleRecording(false) if (client && dictationId) { void client.sendRequest('speech.dictation.cancel', { dictationId }).catch(() => undefined) @@ -98,11 +106,16 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil } const raw = event.data const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw) + const byteLength = bytes.byteLength + if (!pendingAudioBudgetRef.current.tryReserve(byteLength)) { + failActiveDictation(dictationId, new Error(MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE)) + return + } const sendChunk = client .sendRequest('speech.dictation.chunk', { dictationId, audioBase64: bytesToBase64(bytes), - sampleRate: MOBILE_PCM_SAMPLE_RATE + sampleRate: MOBILE_DICTATION_PCM_SAMPLE_RATE }) .then((response) => { if (!response.ok) { @@ -111,6 +124,9 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil }) .catch((err) => failActiveDictation(dictationId, err)) .finally(() => { + if (activeIdRef.current === dictationId || finishingIdRef.current === dictationId) { + pendingAudioBudgetRef.current.release(byteLength) + } pendingChunksRef.current.delete(sendChunk) }) pendingChunksRef.current.add(sendChunk) @@ -183,6 +199,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil acceptingChunksRef.current = true pendingChunksRef.current.clear() + pendingAudioBudgetRef.current.reset() toggleRecording(true) setStatus('recording') }, []) @@ -231,6 +248,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil activeIdRef.current = null finishingIdRef.current = null pendingChunksRef.current.clear() + pendingAudioBudgetRef.current.reset() setStatus('idle') if (text) { onTranscriptRef.current(text) @@ -254,6 +272,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil finishingIdRef.current = null acceptingChunksRef.current = false pendingChunksRef.current.clear() + pendingAudioBudgetRef.current.reset() toggleRecording(false) if (client && dictationId) { await client.sendRequest('speech.dictation.cancel', { dictationId }).catch(() => undefined) @@ -285,6 +304,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil finishingIdRef.current = null acceptingChunksRef.current = false pendingChunksRef.current.clear() + pendingAudioBudgetRef.current.reset() toggleRecording(false) void tearDown() if (clientRef.current && dictationId) { diff --git a/mobile/src/host-route-action-state.test.ts b/mobile/src/host-route-action-state.test.ts new file mode 100644 index 00000000000..c662d2d3418 --- /dev/null +++ b/mobile/src/host-route-action-state.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' + +import { + createInitialHostRouteActionState, + resolveHostRouteActionState, + setHostRouteNewWorktreeVisible +} from './host-route-action-state' + +describe('host route action state', () => { + it('opens new worktree modal on an initial newWorktree action', () => { + expect(createInitialHostRouteActionState('newWorktree')).toEqual({ + routeAction: 'newWorktree', + showNewWorktree: true + }) + }) + + it('keeps initial non-action routes closed', () => { + expect(createInitialHostRouteActionState(undefined)).toEqual({ + routeAction: undefined, + showNewWorktree: false + }) + }) + + it('opens once when route action changes to newWorktree', () => { + expect( + resolveHostRouteActionState({ routeAction: undefined, showNewWorktree: false }, 'newWorktree') + ).toEqual({ + routeAction: 'newWorktree', + showNewWorktree: true + }) + }) + + it('does not reopen after user closes while route action is unchanged', () => { + const closed = setHostRouteNewWorktreeVisible( + { routeAction: 'newWorktree', showNewWorktree: true }, + false + ) + + expect(resolveHostRouteActionState(closed, 'newWorktree')).toBe(closed) + }) + + it('preserves an already-open modal when route action changes away', () => { + expect( + resolveHostRouteActionState({ routeAction: 'newWorktree', showNewWorktree: true }, undefined) + ).toEqual({ + routeAction: undefined, + showNewWorktree: true + }) + }) +}) diff --git a/mobile/src/host-route-action-state.ts b/mobile/src/host-route-action-state.ts new file mode 100644 index 00000000000..bdcb7a5249c --- /dev/null +++ b/mobile/src/host-route-action-state.ts @@ -0,0 +1,36 @@ +export type HostRouteActionState = { + routeAction: string | undefined + showNewWorktree: boolean +} + +export function createInitialHostRouteActionState( + routeAction: string | undefined +): HostRouteActionState { + return { + routeAction, + showNewWorktree: routeAction === 'newWorktree' + } +} + +export function resolveHostRouteActionState( + current: HostRouteActionState, + routeAction: string | undefined +): HostRouteActionState { + if (current.routeAction === routeAction) { + return current + } + return { + routeAction, + showNewWorktree: current.showNewWorktree || routeAction === 'newWorktree' + } +} + +export function setHostRouteNewWorktreeVisible( + current: HostRouteActionState, + showNewWorktree: boolean +): HostRouteActionState { + if (current.showNewWorktree === showNewWorktree) { + return current + } + return { ...current, showNewWorktree } +} diff --git a/mobile/src/notifications/mobile-notifications.test.ts b/mobile/src/notifications/mobile-notifications.test.ts new file mode 100644 index 00000000000..32893cc30d6 --- /dev/null +++ b/mobile/src/notifications/mobile-notifications.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from 'vitest' +import { subscribeToDesktopNotifications } from './mobile-notifications' +import type { RpcClient } from '../transport/rpc-client' + +vi.mock('expo-notifications', () => ({ + AndroidImportance: { HIGH: 'high' }, + setNotificationChannelAsync: vi.fn(), + getPermissionsAsync: vi.fn(), + requestPermissionsAsync: vi.fn(), + scheduleNotificationAsync: vi.fn() +})) + +vi.mock('react-native', () => ({ + Platform: { OS: 'ios' } +})) + +vi.mock('../storage/preferences', () => ({ + loadPushNotificationsEnabled: vi.fn() +})) + +describe('subscribeToDesktopNotifications', () => { + it('drops the local stream when disposed before the desktop returns ready', () => { + const unsubscribeStream = vi.fn() + const client = { + subscribe: vi.fn(() => unsubscribeStream), + getState: vi.fn(() => 'connected'), + sendRequest: vi.fn() + } as unknown as RpcClient + + const unsubscribe = subscribeToDesktopNotifications(client, 'host-1') + unsubscribe() + + expect(unsubscribeStream).toHaveBeenCalledTimes(1) + expect(client.sendRequest).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/notifications/mobile-notifications.ts b/mobile/src/notifications/mobile-notifications.ts index f22b459a667..a26aa1dc545 100644 --- a/mobile/src/notifications/mobile-notifications.ts +++ b/mobile/src/notifications/mobile-notifications.ts @@ -114,8 +114,10 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin // unmount races with disconnect). sendRequest rejects immediately on a // closed client — swallow it since server-side cleanup happens via // connection-close anyway. + // Always drop the local stream first; readiness can race unmount and we + // must not retain the callback while waiting for a subscription id. + unsubscribeStream() if (subscriptionId) { - unsubscribeStream() unsubscribeServer(subscriptionId) } } diff --git a/mobile/src/session/mobile-diff-comments.test.ts b/mobile/src/session/mobile-diff-comments.test.ts new file mode 100644 index 00000000000..0fa0c5be624 --- /dev/null +++ b/mobile/src/session/mobile-diff-comments.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest' +import type { DiffComment } from '../../../src/shared/types' +import { + addMobileDiffComment, + formatDiffComments, + normalizeMobileDiffComments, + removeDeliveredMobileDiffComments, + removeMobileDiffComments +} from './mobile-diff-comments' + +function comment(overrides: Partial & Pick): DiffComment { + return { + id: overrides.id, + worktreeId: 'wt-1', + filePath: 'src/app.ts', + source: 'diff', + lineNumber: 4, + body: 'check this', + createdAt: 100, + side: 'modified', + ...overrides + } +} + +describe('mobile diff comments', () => { + it('normalizes persisted worktree metadata for mobile rendering', () => { + expect( + normalizeMobileDiffComments( + [ + comment({ id: 'a' }), + { id: 'missing-body', filePath: 'src/app.ts', lineNumber: 1, body: ' ' }, + null + ], + 'wt-1' + ) + ).toEqual([comment({ id: 'a' })]) + }) + + it('creates trimmed modified-side comments', () => { + const result = addMobileDiffComment([], { + id: 'mobile-1', + worktreeId: 'wt-1', + filePath: 'src/app.ts', + lineNumber: 8, + body: ' Needs tests ', + createdAt: 200 + }) + + expect(result.comment).toMatchObject({ + id: 'mobile-1', + worktreeId: 'wt-1', + filePath: 'src/app.ts', + source: 'diff', + lineNumber: 8, + body: 'Needs tests', + side: 'modified' + }) + expect(result.comments).toHaveLength(1) + }) + + it('rejects blank comment bodies', () => { + const existing = [comment({ id: 'a' })] + const result = addMobileDiffComment(existing, { + id: 'mobile-1', + worktreeId: 'wt-1', + filePath: 'src/app.ts', + lineNumber: 8, + body: ' ', + createdAt: 200 + }) + + expect(result.comment).toBeNull() + expect(result.comments).toEqual(existing) + }) + + it('removes delivered comments by snapshot id without touching new notes', () => { + expect( + removeMobileDiffComments( + [comment({ id: 'a' }), comment({ id: 'b' })], + new Set(['a', 'missing']) + ) + ).toEqual([comment({ id: 'b' })]) + }) + + it('keeps a changed note when clearing an older delivered snapshot', () => { + const delivered = comment({ id: 'a', body: 'old note' }) + + expect( + removeDeliveredMobileDiffComments( + [comment({ id: 'a', body: 'new note' }), comment({ id: 'b' })], + [delivered] + ) + ).toEqual([comment({ id: 'a', body: 'new note' }), comment({ id: 'b' })]) + }) + + it('uses the desktop-compatible prompt format', () => { + expect(formatDiffComments([comment({ id: 'a', body: 'quote "this"' })])).toBe( + ['File: src/app.ts', 'Line: 4', 'User comment: "quote \\"this\\""'].join('\n') + ) + }) +}) diff --git a/mobile/src/session/mobile-diff-comments.ts b/mobile/src/session/mobile-diff-comments.ts new file mode 100644 index 00000000000..ec14dc51304 --- /dev/null +++ b/mobile/src/session/mobile-diff-comments.ts @@ -0,0 +1,132 @@ +import type { DiffComment } from '../../../src/shared/types' + +export type CreateMobileDiffCommentInput = { + worktreeId: string + filePath: string + lineNumber: number + body: string + id: string + createdAt: number +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function isMarkdownComment(comment: Pick): boolean { + return comment.source === 'markdown' +} + +// Why: mobile Vitest/Metro run from the mobile package and cannot transform +// runtime imports from root src/shared. Keep this byte-for-byte compatible with +// the desktop shared formatter contract. +export function formatDiffComment(c: DiffComment): string { + const escaped = c.body + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\r/g, '\\r') + .replace(/\n/g, '\\n') + const lineLabel = + c.startLine !== undefined && c.startLine !== c.lineNumber + ? `Lines: ${c.startLine}-${c.lineNumber}` + : `Line: ${c.lineNumber}` + if (!isMarkdownComment(c)) { + return [`File: ${c.filePath}`, lineLabel, `User comment: "${escaped}"`].join('\n') + } + return [`File: ${c.filePath}`, 'Source: markdown', lineLabel, `User comment: "${escaped}"`].join( + '\n' + ) +} + +export function formatDiffComments(comments: readonly DiffComment[]): string { + return comments.map(formatDiffComment).join('\n\n') +} + +export function normalizeMobileDiffComments(value: unknown, worktreeId: string): DiffComment[] { + if (!Array.isArray(value)) return [] + return value.flatMap((candidate): DiffComment[] => { + if (!isRecord(candidate)) return [] + const id = typeof candidate.id === 'string' ? candidate.id : '' + const filePath = typeof candidate.filePath === 'string' ? candidate.filePath : '' + const lineNumber = typeof candidate.lineNumber === 'number' ? candidate.lineNumber : NaN + const body = typeof candidate.body === 'string' ? candidate.body.trim() : '' + const createdAt = typeof candidate.createdAt === 'number' ? candidate.createdAt : Date.now() + if (!id || !filePath || !Number.isFinite(lineNumber) || !body) return [] + return [ + { + id, + worktreeId: typeof candidate.worktreeId === 'string' ? candidate.worktreeId : worktreeId, + filePath, + source: candidate.source === 'markdown' ? 'markdown' : 'diff', + selectedText: + typeof candidate.selectedText === 'string' ? candidate.selectedText : undefined, + startLine: typeof candidate.startLine === 'number' ? candidate.startLine : undefined, + lineNumber, + body, + createdAt, + sentAt: typeof candidate.sentAt === 'number' ? candidate.sentAt : undefined, + side: 'modified' + } + ] + }) +} + +export function createMobileDiffComment(input: CreateMobileDiffCommentInput): DiffComment | null { + const body = input.body.trim() + if (!body || !Number.isFinite(input.lineNumber) || input.lineNumber <= 0) { + return null + } + return { + id: input.id, + worktreeId: input.worktreeId, + filePath: input.filePath, + source: 'diff', + lineNumber: input.lineNumber, + body, + createdAt: input.createdAt, + side: 'modified' + } +} + +export function addMobileDiffComment( + comments: readonly DiffComment[], + input: CreateMobileDiffCommentInput +): { comments: DiffComment[]; comment: DiffComment | null } { + const comment = createMobileDiffComment(input) + if (!comment) { + return { comments: [...comments], comment: null } + } + return { comments: [...comments, comment], comment } +} + +export function removeMobileDiffComments( + comments: readonly DiffComment[], + ids: ReadonlySet +): DiffComment[] { + if (ids.size === 0) return [...comments] + return comments.filter((comment) => !ids.has(comment.id)) +} + +function deliveredCommentMatches(comment: DiffComment, delivered: DiffComment): boolean { + return ( + comment.id === delivered.id && + comment.body === delivered.body && + comment.filePath === delivered.filePath && + comment.lineNumber === delivered.lineNumber && + comment.selectedText === delivered.selectedText && + comment.source === delivered.source && + comment.startLine === delivered.startLine + ) +} + +export function removeDeliveredMobileDiffComments( + comments: readonly DiffComment[], + delivered: readonly DiffComment[] +): DiffComment[] { + if (delivered.length === 0) return [...comments] + const deliveredById = new Map(delivered.map((comment) => [comment.id, comment])) + return comments.filter((comment) => { + const snapshot = deliveredById.get(comment.id) + return !snapshot || !deliveredCommentMatches(comment, snapshot) + }) +} diff --git a/mobile/src/session/mobile-diff-lines.test.ts b/mobile/src/session/mobile-diff-lines.test.ts index 1b66d9b7645..2f8779eb66e 100644 --- a/mobile/src/session/mobile-diff-lines.test.ts +++ b/mobile/src/session/mobile-diff-lines.test.ts @@ -14,4 +14,39 @@ describe('buildMobileDiffLines', () => { { kind: 'add', text: 'four', newLineNumber: 4 } ]) }) + + it('caps add-only preview rows before returning the mobile truncation marker', () => { + const modified = Array.from({ length: 3_000 }, (_, index) => `line-${index + 1}`).join('\n') + + const result = buildMobileDiffLines('', `${modified}\n`) + + expect(result.truncated).toBe(true) + expect(result.lines).toHaveLength(2_501) + expect(result.lines[0]).toEqual({ kind: 'add', text: 'line-1', newLineNumber: 1 }) + expect(result.lines[2_499]).toEqual({ kind: 'add', text: 'line-2500', newLineNumber: 2500 }) + expect(result.lines[2_500]).toEqual({ + kind: 'context', + text: '... diff truncated for mobile preview ...' + }) + }) + + it('caps prefix-suffix fallback rows for very large changed files', () => { + const original = Array.from({ length: 3_000 }, (_, index) => `old-${index + 1}`).join('\n') + const modified = Array.from({ length: 3_000 }, (_, index) => `new-${index + 1}`).join('\n') + + const result = buildMobileDiffLines(`${original}\n`, `${modified}\n`) + + expect(result.truncated).toBe(true) + expect(result.lines).toHaveLength(2_501) + expect(result.lines[0]).toEqual({ kind: 'delete', text: 'old-1', oldLineNumber: 1 }) + expect(result.lines[2_499]).toEqual({ + kind: 'delete', + text: 'old-2500', + oldLineNumber: 2500 + }) + expect(result.lines[2_500]).toEqual({ + kind: 'context', + text: '... diff truncated for mobile preview ...' + }) + }) }) diff --git a/mobile/src/session/mobile-diff-lines.ts b/mobile/src/session/mobile-diff-lines.ts index e887daa7b63..12f9fdcb66c 100644 --- a/mobile/src/session/mobile-diff-lines.ts +++ b/mobile/src/session/mobile-diff-lines.ts @@ -9,6 +9,7 @@ export type MobileDiffLine = { const MAX_DIFF_CELLS = 200_000 const MAX_MOBILE_DIFF_LINES = 2_500 +const MAX_BUILT_DIFF_LINES = MAX_MOBILE_DIFF_LINES + 1 const TRUNCATED_LINE: MobileDiffLine = { kind: 'context', text: '... diff truncated for mobile preview ...' @@ -27,10 +28,7 @@ export function buildMobileDiffLines( ? buildLcsDiffLines(originalLines, modifiedLines) : buildPrefixSuffixDiffLines(originalLines, modifiedLines) - if (lines.length <= MAX_MOBILE_DIFF_LINES) { - return { lines, truncated: false } - } - return { lines: [...lines.slice(0, MAX_MOBILE_DIFF_LINES), TRUNCATED_LINE], truncated: true } + return finalizeMobileDiffLines(lines) } function splitContentLines(content: string): string[] { @@ -61,48 +59,68 @@ function buildLcsDiffLines(originalLines: string[], modifiedLines: string[]): Mo let modifiedIndex = 0 while (originalIndex < originalLines.length && modifiedIndex < modifiedLines.length) { if (originalLines[originalIndex] === modifiedLines[modifiedIndex]) { - lines.push({ - kind: 'context', - text: originalLines[originalIndex] ?? '', - oldLineNumber: originalIndex + 1, - newLineNumber: modifiedIndex + 1 - }) + if ( + !appendDiffLine(lines, { + kind: 'context', + text: originalLines[originalIndex] ?? '', + oldLineNumber: originalIndex + 1, + newLineNumber: modifiedIndex + 1 + }) + ) { + return lines + } originalIndex += 1 modifiedIndex += 1 } else if ( dp[(originalIndex + 1) * rowWidth + modifiedIndex] >= dp[originalIndex * rowWidth + modifiedIndex + 1] ) { - lines.push({ - kind: 'delete', - text: originalLines[originalIndex] ?? '', - oldLineNumber: originalIndex + 1 - }) + if ( + !appendDiffLine(lines, { + kind: 'delete', + text: originalLines[originalIndex] ?? '', + oldLineNumber: originalIndex + 1 + }) + ) { + return lines + } originalIndex += 1 } else { - lines.push({ - kind: 'add', - text: modifiedLines[modifiedIndex] ?? '', - newLineNumber: modifiedIndex + 1 - }) + if ( + !appendDiffLine(lines, { + kind: 'add', + text: modifiedLines[modifiedIndex] ?? '', + newLineNumber: modifiedIndex + 1 + }) + ) { + return lines + } modifiedIndex += 1 } } while (originalIndex < originalLines.length) { - lines.push({ - kind: 'delete', - text: originalLines[originalIndex] ?? '', - oldLineNumber: originalIndex + 1 - }) + if ( + !appendDiffLine(lines, { + kind: 'delete', + text: originalLines[originalIndex] ?? '', + oldLineNumber: originalIndex + 1 + }) + ) { + return lines + } originalIndex += 1 } while (modifiedIndex < modifiedLines.length) { - lines.push({ - kind: 'add', - text: modifiedLines[modifiedIndex] ?? '', - newLineNumber: modifiedIndex + 1 - }) + if ( + !appendDiffLine(lines, { + kind: 'add', + text: modifiedLines[modifiedIndex] ?? '', + newLineNumber: modifiedIndex + 1 + }) + ) { + return lines + } modifiedIndex += 1 } @@ -134,28 +152,69 @@ function buildPrefixSuffixDiffLines( const lines: MobileDiffLine[] = [] for (let i = 0; i < prefixLength; i += 1) { - lines.push({ - kind: 'context', - text: originalLines[i] ?? '', - oldLineNumber: i + 1, - newLineNumber: i + 1 - }) + if ( + !appendDiffLine(lines, { + kind: 'context', + text: originalLines[i] ?? '', + oldLineNumber: i + 1, + newLineNumber: i + 1 + }) + ) { + return lines + } } for (let i = prefixLength; i < originalLines.length - suffixLength; i += 1) { - lines.push({ kind: 'delete', text: originalLines[i] ?? '', oldLineNumber: i + 1 }) + if ( + !appendDiffLine(lines, { + kind: 'delete', + text: originalLines[i] ?? '', + oldLineNumber: i + 1 + }) + ) { + return lines + } } for (let i = prefixLength; i < modifiedLines.length - suffixLength; i += 1) { - lines.push({ kind: 'add', text: modifiedLines[i] ?? '', newLineNumber: i + 1 }) + if ( + !appendDiffLine(lines, { + kind: 'add', + text: modifiedLines[i] ?? '', + newLineNumber: i + 1 + }) + ) { + return lines + } } for (let i = originalLines.length - suffixLength; i < originalLines.length; i += 1) { const modifiedIndex = modifiedLines.length - suffixLength + (i - (originalLines.length - suffixLength)) - lines.push({ - kind: 'context', - text: originalLines[i] ?? '', - oldLineNumber: i + 1, - newLineNumber: modifiedIndex + 1 - }) + if ( + !appendDiffLine(lines, { + kind: 'context', + text: originalLines[i] ?? '', + oldLineNumber: i + 1, + newLineNumber: modifiedIndex + 1 + }) + ) { + return lines + } } return lines } + +function appendDiffLine(lines: MobileDiffLine[], line: MobileDiffLine): boolean { + // Why: the mobile preview only renders the first capped rows; keep one extra + // row solely to preserve the existing "truncated" marker decision. + lines.push(line) + return lines.length < MAX_BUILT_DIFF_LINES +} + +function finalizeMobileDiffLines(lines: MobileDiffLine[]): { + lines: MobileDiffLine[] + truncated: boolean +} { + if (lines.length <= MAX_MOBILE_DIFF_LINES) { + return { lines, truncated: false } + } + return { lines: [...lines.slice(0, MAX_MOBILE_DIFF_LINES), TRUNCATED_LINE], truncated: true } +} diff --git a/mobile/src/session/mobile-session-create-warning-state.test.ts b/mobile/src/session/mobile-session-create-warning-state.test.ts new file mode 100644 index 00000000000..8cc9c2766a2 --- /dev/null +++ b/mobile/src/session/mobile-session-create-warning-state.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { + createMobileSessionCreateWarningState, + dismissMobileSessionCreateWarningState, + reconcileMobileSessionCreateWarningState +} from './mobile-session-create-warning-state' + +describe('mobile session create warning state', () => { + it('preserves a dismissed warning while the route warning is unchanged', () => { + const dismissed = dismissMobileSessionCreateWarningState( + createMobileSessionCreateWarningState('Created, but setup failed') + ) + + expect(reconcileMobileSessionCreateWarningState(dismissed, 'Created, but setup failed')).toBe( + dismissed + ) + }) + + it('shows a new warning when the route warning changes', () => { + const dismissed = dismissMobileSessionCreateWarningState( + createMobileSessionCreateWarningState('Old warning') + ) + + expect(reconcileMobileSessionCreateWarningState(dismissed, 'New warning')).toEqual({ + source: 'New warning', + visible: 'New warning' + }) + }) +}) diff --git a/mobile/src/session/mobile-session-create-warning-state.ts b/mobile/src/session/mobile-session-create-warning-state.ts new file mode 100644 index 00000000000..be827af6bfb --- /dev/null +++ b/mobile/src/session/mobile-session-create-warning-state.ts @@ -0,0 +1,29 @@ +export type MobileSessionCreateWarningState = { + source: string + visible: string +} + +export function createMobileSessionCreateWarningState( + source: string +): MobileSessionCreateWarningState { + return { source, visible: source } +} + +export function reconcileMobileSessionCreateWarningState( + current: MobileSessionCreateWarningState, + source: string +): MobileSessionCreateWarningState { + if (current.source === source) { + return current + } + return createMobileSessionCreateWarningState(source) +} + +export function dismissMobileSessionCreateWarningState( + current: MobileSessionCreateWarningState +): MobileSessionCreateWarningState { + if (!current.visible) { + return current + } + return { ...current, visible: '' } +} diff --git a/mobile/src/source-control/mobile-branch-compare.test.ts b/mobile/src/source-control/mobile-branch-compare.test.ts new file mode 100644 index 00000000000..b96090faece --- /dev/null +++ b/mobile/src/source-control/mobile-branch-compare.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, expectTypeOf, it } from 'vitest' +import type { GitBranchCompareResult } from '../../../src/shared/types' +import { + buildMobileBranchCompareSection, + canOpenMobileBranchCompareDiff, + formatMobileBranchCompareSummary, + type MobileGitBranchCompareResult +} from './mobile-branch-compare' + +describe('mobile branch compare helpers', () => { + it('keeps the mobile branch compare type in lockstep with the runtime contract', () => { + expectTypeOf().toEqualTypeOf() + }) + + it('sorts committed branch entries by path', () => { + const section = buildMobileBranchCompareSection([ + { path: 'zeta.ts', status: 'modified' }, + { path: 'alpha.ts', status: 'added' } + ]) + + expect(section?.title).toBe('Committed on Branch') + expect(section?.data.map((entry) => entry.path)).toEqual(['alpha.ts', 'zeta.ts']) + }) + + it('summarizes ready branch compares', () => { + expect( + formatMobileBranchCompareSummary({ + baseRef: 'origin/main', + baseOid: 'a'.repeat(40), + compareRef: 'HEAD', + headOid: 'b'.repeat(40), + mergeBase: 'c'.repeat(40), + changedFiles: 2, + commitsAhead: 1, + status: 'ready' + }) + ).toBe('2 files - 1 commit - vs origin/main') + }) + + it('only opens branch diffs when compare object ids are available', () => { + expect( + canOpenMobileBranchCompareDiff({ + baseRef: 'origin/main', + baseOid: 'a'.repeat(40), + compareRef: 'HEAD', + headOid: 'b'.repeat(40), + mergeBase: 'c'.repeat(40), + changedFiles: 1, + status: 'ready' + }) + ).toBe(true) + + expect( + canOpenMobileBranchCompareDiff({ + baseRef: 'origin/main', + baseOid: null, + compareRef: 'HEAD', + headOid: null, + mergeBase: null, + changedFiles: 0, + status: 'unborn-head' + }) + ).toBe(false) + }) +}) diff --git a/mobile/src/source-control/mobile-branch-compare.ts b/mobile/src/source-control/mobile-branch-compare.ts new file mode 100644 index 00000000000..060ee768ec4 --- /dev/null +++ b/mobile/src/source-control/mobile-branch-compare.ts @@ -0,0 +1,53 @@ +import type { + GitBranchChangeEntry, + GitBranchCompareResult, + GitBranchCompareSummary +} from '../../../src/shared/types' + +export type MobileGitBranchChangeEntry = GitBranchChangeEntry +export type MobileGitBranchCompareSummary = GitBranchCompareSummary +export type MobileGitBranchCompareResult = GitBranchCompareResult + +export type MobileBranchCompareSection< + TEntry extends MobileGitBranchChangeEntry = MobileGitBranchChangeEntry +> = { + title: 'Committed on Branch' + data: TEntry[] +} + +function compareBranchEntries( + a: MobileGitBranchChangeEntry, + b: MobileGitBranchChangeEntry +): number { + return a.path.localeCompare(b.path, undefined, { numeric: true }) +} + +export function buildMobileBranchCompareSection( + entries: readonly TEntry[] +): MobileBranchCompareSection | null { + if (entries.length === 0) { + return null + } + return { + title: 'Committed on Branch', + data: [...entries].sort(compareBranchEntries) + } +} + +export function formatMobileBranchCompareSummary( + summary: MobileGitBranchCompareSummary +): string | null { + if (summary.status !== 'ready') { + return summary.errorMessage ?? null + } + const parts = [`${summary.changedFiles} ${summary.changedFiles === 1 ? 'file' : 'files'}`] + if (summary.commitsAhead !== undefined) { + parts.push(`${summary.commitsAhead} ${summary.commitsAhead === 1 ? 'commit' : 'commits'}`) + } + parts.push(`vs ${summary.baseRef}`) + return parts.join(' - ') +} + +export function canOpenMobileBranchCompareDiff(summary: MobileGitBranchCompareSummary): boolean { + return summary.status === 'ready' && Boolean(summary.headOid && summary.mergeBase) +} diff --git a/mobile/src/tasks/github-pr-file-diff.test.ts b/mobile/src/tasks/github-pr-file-diff.test.ts index 13a2008a395..c1200be1ec1 100644 --- a/mobile/src/tasks/github-pr-file-diff.test.ts +++ b/mobile/src/tasks/github-pr-file-diff.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { buildGitHubPrFileDiffLines } from './github-pr-file-diff' +import { buildGitHubPrFileDiffLines, buildGitHubPrFileDiffPreview } from './github-pr-file-diff' describe('buildGitHubPrFileDiffLines', () => { it('preserves context and marks added and removed lines', () => { @@ -50,4 +50,28 @@ describe('buildGitHubPrFileDiffLines', () => { expect(lines[0]).toMatchObject({ kind: 'removed', oldLineNumber: 1, text: 'old-0' }) expect(lines.at(-1)).toMatchObject({ kind: 'added', newLineNumber: 500, text: 'new-499' }) }) + + it('builds capped previews while preserving the exact total line count', () => { + const original = Array.from({ length: 500 }, (_, index) => `old-${index}`).join('\n') + const modified = Array.from({ length: 500 }, (_, index) => `new-${index}`).join('\n') + + const preview = buildGitHubPrFileDiffPreview(original, modified, 400) + + expect(preview.totalLineCount).toBe(1000) + expect(preview.lines).toHaveLength(400) + expect(preview.lines[0]).toMatchObject({ kind: 'removed', oldLineNumber: 1, text: 'old-0' }) + expect(preview.lines.at(-1)).toMatchObject({ + kind: 'removed', + oldLineNumber: 400, + text: 'old-399' + }) + }) + + it('can compute the total without retaining preview rows', () => { + const modified = Array.from({ length: 20 }, (_, index) => `new-${index}`).join('\n') + + const preview = buildGitHubPrFileDiffPreview('', modified, 0) + + expect(preview).toEqual({ lines: [], totalLineCount: 20 }) + }) }) diff --git a/mobile/src/tasks/github-pr-file-diff.ts b/mobile/src/tasks/github-pr-file-diff.ts index 274f0631f12..1be684197d4 100644 --- a/mobile/src/tasks/github-pr-file-diff.ts +++ b/mobile/src/tasks/github-pr-file-diff.ts @@ -6,6 +6,11 @@ export type GitHubPrFileDiffLine = { text: string } +export type GitHubPrFileDiffPreview = { + lines: GitHubPrFileDiffLine[] + totalLineCount: number +} + type DiffOperation = | { kind: 'context'; oldLine: string; newLine: string } | { kind: 'removed'; oldLine: string } @@ -21,7 +26,11 @@ function splitContentLines(value: string): string[] { return lines.at(-1) === '' ? lines.slice(0, -1) : lines } -function exactLineDiff(original: string[], modified: string[]): DiffOperation[] { +function appendExactLineDiff( + original: string[], + modified: string[], + appendOperation: (operation: DiffOperation) => void +): void { const rowWidth = modified.length + 1 const table = new Uint16Array((original.length + 1) * rowWidth) @@ -39,14 +48,13 @@ function exactLineDiff(original: string[], modified: string[]): DiffOperation[] } } - const operations: DiffOperation[] = [] let oldIndex = 0 let newIndex = 0 while (oldIndex < original.length && newIndex < modified.length) { const oldLine = original[oldIndex] const newLine = modified[newIndex] if (oldLine === newLine) { - operations.push({ kind: 'context', oldLine, newLine }) + appendOperation({ kind: 'context', oldLine, newLine }) oldIndex += 1 newIndex += 1 continue @@ -54,45 +62,66 @@ function exactLineDiff(original: string[], modified: string[]): DiffOperation[] const removeScore = table[(oldIndex + 1) * rowWidth + newIndex] const addScore = table[oldIndex * rowWidth + newIndex + 1] if (removeScore >= addScore) { - operations.push({ kind: 'removed', oldLine }) + appendOperation({ kind: 'removed', oldLine }) oldIndex += 1 } else { - operations.push({ kind: 'added', newLine }) + appendOperation({ kind: 'added', newLine }) newIndex += 1 } } while (oldIndex < original.length) { - operations.push({ kind: 'removed', oldLine: original[oldIndex] }) + appendOperation({ kind: 'removed', oldLine: original[oldIndex] }) oldIndex += 1 } while (newIndex < modified.length) { - operations.push({ kind: 'added', newLine: modified[newIndex] }) + appendOperation({ kind: 'added', newLine: modified[newIndex] }) newIndex += 1 } - return operations } -function buildMiddleDiff(original: string[], modified: string[]): DiffOperation[] { +function appendMiddleDiff( + original: string[], + modified: string[], + appendOperation: (operation: DiffOperation) => void +): void { if (original.length === 0) { - return modified.map((newLine) => ({ kind: 'added', newLine })) + for (const newLine of modified) { + appendOperation({ kind: 'added', newLine }) + } + return } if (modified.length === 0) { - return original.map((oldLine) => ({ kind: 'removed', oldLine })) + for (const oldLine of original) { + appendOperation({ kind: 'removed', oldLine }) + } + return } if (original.length * modified.length <= EXACT_DIFF_CELL_LIMIT) { - return exactLineDiff(original, modified) + appendExactLineDiff(original, modified, appendOperation) + return + } + // Why: the Tasks diff UI renders a capped preview. Stream fallback rows so a + // generated PR file does not allocate thousands of discarded row objects. + for (const oldLine of original) { + appendOperation({ kind: 'removed', oldLine }) + } + for (const newLine of modified) { + appendOperation({ kind: 'added', newLine }) } - // Why: very large files must still show all content without an O(n*m) mobile stall. - return [ - ...original.map((oldLine) => ({ kind: 'removed' as const, oldLine })), - ...modified.map((newLine) => ({ kind: 'added' as const, newLine })) - ] } export function buildGitHubPrFileDiffLines( originalContent: string, modifiedContent: string ): GitHubPrFileDiffLine[] { + return buildGitHubPrFileDiffPreview(originalContent, modifiedContent).lines +} + +export function buildGitHubPrFileDiffPreview( + originalContent: string, + modifiedContent: string, + maxLines = Number.POSITIVE_INFINITY +): GitHubPrFileDiffPreview { const originalLines = splitContentLines(originalContent) const modifiedLines = splitContentLines(modifiedContent) let prefixLength = 0 @@ -114,7 +143,6 @@ export function buildGitHubPrFileDiffLines( suffixLength += 1 } - const prefix = originalLines.slice(0, prefixLength) const originalMiddle = originalLines.slice( prefixLength, suffixLength === 0 ? originalLines.length : originalLines.length - suffixLength @@ -123,46 +151,63 @@ export function buildGitHubPrFileDiffLines( prefixLength, suffixLength === 0 ? modifiedLines.length : modifiedLines.length - suffixLength ) - const suffix = originalLines.slice(originalLines.length - suffixLength) - const operations: DiffOperation[] = [ - ...prefix.map((line) => ({ kind: 'context' as const, oldLine: line, newLine: line })), - ...buildMiddleDiff(originalMiddle, modifiedMiddle), - ...suffix.map((line) => ({ kind: 'context' as const, oldLine: line, newLine: line })) - ] const result: GitHubPrFileDiffLine[] = [] let oldLineNumber = 1 let newLineNumber = 1 - operations.forEach((operation, index) => { + let operationIndex = 0 + let totalLineCount = 0 + const normalizedMaxLines = Math.max(0, Math.floor(maxLines)) + function appendOperation(operation: DiffOperation): void { + const index = operationIndex + operationIndex += 1 + totalLineCount += 1 if (operation.kind === 'context') { - result.push({ - key: `${index}:context:${oldLineNumber}:${newLineNumber}`, - kind: 'context', - oldLineNumber, - newLineNumber, - text: operation.newLine - }) + if (result.length < normalizedMaxLines) { + result.push({ + key: `${index}:context:${oldLineNumber}:${newLineNumber}`, + kind: 'context', + oldLineNumber, + newLineNumber, + text: operation.newLine + }) + } oldLineNumber += 1 newLineNumber += 1 return } if (operation.kind === 'removed') { - result.push({ - key: `${index}:removed:${oldLineNumber}`, - kind: 'removed', - oldLineNumber, - text: operation.oldLine - }) + if (result.length < normalizedMaxLines) { + result.push({ + key: `${index}:removed:${oldLineNumber}`, + kind: 'removed', + oldLineNumber, + text: operation.oldLine + }) + } oldLineNumber += 1 return } - result.push({ - key: `${index}:added:${newLineNumber}`, - kind: 'added', - newLineNumber, - text: operation.newLine - }) + if (result.length < normalizedMaxLines) { + result.push({ + key: `${index}:added:${newLineNumber}`, + kind: 'added', + newLineNumber, + text: operation.newLine + }) + } newLineNumber += 1 - }) - return result + } + + for (let i = 0; i < prefixLength; i += 1) { + const line = originalLines[i] ?? '' + appendOperation({ kind: 'context', oldLine: line, newLine: line }) + } + appendMiddleDiff(originalMiddle, modifiedMiddle, appendOperation) + for (let i = originalLines.length - suffixLength; i < originalLines.length; i += 1) { + const line = originalLines[i] ?? '' + appendOperation({ kind: 'context', oldLine: line, newLine: line }) + } + + return { lines: result, totalLineCount } } diff --git a/mobile/src/tasks/mobile-task-copy-feedback-timer.test.ts b/mobile/src/tasks/mobile-task-copy-feedback-timer.test.ts new file mode 100644 index 00000000000..687bb76f3ed --- /dev/null +++ b/mobile/src/tasks/mobile-task-copy-feedback-timer.test.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + clearMobileTaskCopyFeedbackTimer, + scheduleMobileTaskCopyFeedbackReset, + type MobileTaskCopyFeedbackTimerRef +} from './mobile-task-copy-feedback-timer' + +function createTimerRef(): MobileTaskCopyFeedbackTimerRef { + return { current: null } +} + +function createCopiedKeyState(initial: string | null) { + let value = initial + return { + get value() { + return value + }, + setCopiedKey(updater: (current: string | null) => string | null) { + value = updater(value) + } + } +} + +describe('mobile task copy feedback timer', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('clears the copied key only when the scheduled key is still current', () => { + vi.useFakeTimers() + const timerRef = createTimerRef() + const copied = createCopiedKeyState('task:one') + + scheduleMobileTaskCopyFeedbackReset(timerRef, 'task:one', copied.setCopiedKey) + copied.setCopiedKey(() => 'task:two') + vi.runOnlyPendingTimers() + + expect(copied.value).toBe('task:two') + expect(timerRef.current).toBeNull() + }) + + it('replaces stale copy feedback timers', () => { + vi.useFakeTimers() + const timerRef = createTimerRef() + const copied = createCopiedKeyState('task:one') + + scheduleMobileTaskCopyFeedbackReset(timerRef, 'task:one', copied.setCopiedKey) + copied.setCopiedKey(() => 'task:two') + scheduleMobileTaskCopyFeedbackReset(timerRef, 'task:two', copied.setCopiedKey) + vi.runOnlyPendingTimers() + + expect(copied.value).toBeNull() + expect(timerRef.current).toBeNull() + }) + + it('cancels pending copy feedback work', () => { + vi.useFakeTimers() + const timerRef = createTimerRef() + const copied = createCopiedKeyState('task:one') + + scheduleMobileTaskCopyFeedbackReset(timerRef, 'task:one', copied.setCopiedKey) + clearMobileTaskCopyFeedbackTimer(timerRef) + vi.runOnlyPendingTimers() + + expect(copied.value).toBe('task:one') + expect(timerRef.current).toBeNull() + }) +}) diff --git a/mobile/src/tasks/mobile-task-copy-feedback-timer.ts b/mobile/src/tasks/mobile-task-copy-feedback-timer.ts new file mode 100644 index 00000000000..107f9260077 --- /dev/null +++ b/mobile/src/tasks/mobile-task-copy-feedback-timer.ts @@ -0,0 +1,28 @@ +export type MobileTaskCopyFeedbackTimerRef = { + current: ReturnType | null +} + +type SetCopiedTaskKey = (updater: (current: string | null) => string | null) => void + +export function clearMobileTaskCopyFeedbackTimer(timerRef: MobileTaskCopyFeedbackTimerRef): void { + if (timerRef.current === null) { + return + } + clearTimeout(timerRef.current) + timerRef.current = null +} + +export function scheduleMobileTaskCopyFeedbackReset( + timerRef: MobileTaskCopyFeedbackTimerRef, + key: string, + setCopiedKey: SetCopiedTaskKey, + delayMs = 1500 +): void { + // Why: task copy actions share one copied key; replacing the timer prevents + // an older copy from clearing newer feedback or surviving unmount. + clearMobileTaskCopyFeedbackTimer(timerRef) + timerRef.current = setTimeout(() => { + timerRef.current = null + setCopiedKey((current) => (current === key ? null : current)) + }, delayMs) +} diff --git a/mobile/src/tasks/mobile-task-providers.ts b/mobile/src/tasks/mobile-task-providers.ts index a5a28c385e8..eb6d0416608 100644 --- a/mobile/src/tasks/mobile-task-providers.ts +++ b/mobile/src/tasks/mobile-task-providers.ts @@ -1,6 +1,6 @@ export type TaskProvider = 'github' | 'gitlab' | 'linear' -export const MOBILE_TASK_PROVIDERS: readonly TaskProvider[] = ['github', 'gitlab', 'linear'] +const MOBILE_TASK_PROVIDERS: readonly TaskProvider[] = ['github', 'gitlab', 'linear'] const TASK_PROVIDER_SET = new Set(MOBILE_TASK_PROVIDERS) diff --git a/mobile/src/tasks/mobile-tui-agents.ts b/mobile/src/tasks/mobile-tui-agents.ts index d16342bece6..36608042e7d 100644 --- a/mobile/src/tasks/mobile-tui-agents.ts +++ b/mobile/src/tasks/mobile-tui-agents.ts @@ -5,6 +5,7 @@ import type { TuiAgent } from '../../../src/shared/types' // mirrored with src/shared/tui-agent-selection.ts and assert parity in tests. export const MOBILE_TUI_AGENT_AUTO_PICK_ORDER = [ 'claude', + 'openclaude', 'codex', 'grok', 'copilot', @@ -37,6 +38,7 @@ export const MOBILE_TUI_AGENT_AUTO_PICK_ORDER = [ export const MOBILE_TUI_AGENT_LABELS: Record = { claude: 'Claude', + openclaude: 'OpenClaude', codex: 'Codex', grok: 'Grok', copilot: 'GitHub Copilot', @@ -68,6 +70,7 @@ export const MOBILE_TUI_AGENT_LABELS: Record = { } export const MOBILE_TUI_AGENT_FAVICON_DOMAINS: Partial> = { + openclaude: 'openclaude.gitlawb.com', grok: 'x.ai', copilot: 'github.com', opencode: 'opencode.ai', @@ -97,6 +100,7 @@ export const MOBILE_TUI_AGENT_FAVICON_DOMAINS: Partial> export const MOBILE_TUI_AGENT_LAUNCH_COMMANDS: Record = { claude: 'claude', + openclaude: 'openclaude', codex: 'codex', grok: 'grok', copilot: 'copilot', @@ -131,7 +135,7 @@ export function isMobileTuiAgent(value: unknown): value is TuiAgent { return MOBILE_TUI_AGENT_AUTO_PICK_ORDER.includes(value as TuiAgent) } -export function normalizeDisabledMobileTuiAgents(value: unknown): TuiAgent[] { +function normalizeDisabledMobileTuiAgents(value: unknown): TuiAgent[] { if (!Array.isArray(value)) { return [] } diff --git a/mobile/src/tasks/mobile-work-items.ts b/mobile/src/tasks/mobile-work-items.ts index 02d8fbe989f..9108cd26025 100644 --- a/mobile/src/tasks/mobile-work-items.ts +++ b/mobile/src/tasks/mobile-work-items.ts @@ -3,7 +3,7 @@ export const PER_REPO_FETCH_LIMIT = 36 export const CROSS_REPO_DISPLAY_LIMIT = 100 -export const GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE = +const GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE = 'GitHub work items require a GitHub remote for SSH repositories' export function isGitHubWorkItemsSshRemoteRequiredError(error: unknown): boolean { diff --git a/mobile/src/tasks/mobile-workspace-name.ts b/mobile/src/tasks/mobile-workspace-name.ts index ae4b31dcdf7..0cf116155f6 100644 --- a/mobile/src/tasks/mobile-workspace-name.ts +++ b/mobile/src/tasks/mobile-workspace-name.ts @@ -5,7 +5,7 @@ export function resolveMobileWorkspaceCreateName(args: { return args.draft?.trim() || args.fallback } -export function slugifyForWorkspaceName(input: string): string { +function slugifyForWorkspaceName(input: string): string { return input .trim() .toLowerCase() diff --git a/mobile/src/tasks/workspace-agent-selection.test.ts b/mobile/src/tasks/workspace-agent-selection.test.ts index 816988faa4c..18202d5dc71 100644 --- a/mobile/src/tasks/workspace-agent-selection.test.ts +++ b/mobile/src/tasks/workspace-agent-selection.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest' import { normalizeWorkspaceAgent, pickWorkspaceAgent, + resolveWorkspaceAgentSelection, workspaceAgentLabel } from './workspace-agent-selection' @@ -54,4 +55,52 @@ describe('workspace agent selection', () => { expect(normalizeWorkspaceAgent('__blank__')).toBe('blank') expect(workspaceAgentLabel('codex')).toBe('Codex') }) + + it('keeps automatic selection current while create selection is active', () => { + expect( + resolveWorkspaceAgentSelection({ + selectionActive: true, + settings: { defaultTuiAgent: 'codex' }, + detectedAgentIds: new Set(['claude', 'codex']), + agent: null, + overridden: false + }) + ).toEqual({ agent: 'codex', overridden: false }) + }) + + it('preserves a valid user override', () => { + expect( + resolveWorkspaceAgentSelection({ + selectionActive: true, + settings: { defaultTuiAgent: 'claude' }, + detectedAgentIds: new Set(['claude', 'codex']), + agent: 'codex', + overridden: true + }) + ).toEqual({ agent: 'codex', overridden: true }) + }) + + it('falls back when a user override is unavailable after detection settles', () => { + expect( + resolveWorkspaceAgentSelection({ + selectionActive: true, + settings: { defaultTuiAgent: 'codex' }, + detectedAgentIds: new Set(['claude']), + agent: 'codex', + overridden: true + }) + ).toEqual({ agent: 'claude', overridden: false }) + }) + + it('does not repair inactive selection state', () => { + expect( + resolveWorkspaceAgentSelection({ + selectionActive: false, + settings: { defaultTuiAgent: 'codex' }, + detectedAgentIds: new Set(['codex']), + agent: null, + overridden: false + }) + ).toEqual({ agent: null, overridden: false }) + }) }) diff --git a/mobile/src/tasks/workspace-agent-selection.ts b/mobile/src/tasks/workspace-agent-selection.ts index 6d2bd384619..da406452882 100644 --- a/mobile/src/tasks/workspace-agent-selection.ts +++ b/mobile/src/tasks/workspace-agent-selection.ts @@ -15,6 +15,17 @@ type WorkspaceAgentSettings = { disabledTuiAgents?: unknown } +export type WorkspaceAgentSelectionState = { + agent: WorkspaceAgentChoice | null + overridden: boolean +} + +type ResolveWorkspaceAgentSelectionArgs = WorkspaceAgentSelectionState & { + selectionActive: boolean + settings: WorkspaceAgentSettings + detectedAgentIds: Set | null +} + export function workspaceAgentLabel(agent: WorkspaceAgentChoice): string { return agent === 'blank' ? 'Blank Terminal' : MOBILE_TUI_AGENT_LABELS[agent] } @@ -55,3 +66,32 @@ export function filterWorkspaceAgents(agents: readonly TuiAgent[], disabled?: un export function isWorkspaceAgentEnabled(agent: TuiAgent, disabled?: unknown): boolean { return isMobileTuiAgentEnabled(agent, disabled) } + +export function resolveWorkspaceAgentSelection({ + selectionActive, + settings, + detectedAgentIds, + agent, + overridden +}: ResolveWorkspaceAgentSelectionArgs): WorkspaceAgentSelectionState { + const current = { agent, overridden } + if (!selectionActive) { + return current + } + + const pickedAgent = pickWorkspaceAgent(settings, detectedAgentIds) + if (!overridden) { + return agent === pickedAgent ? current : { agent: pickedAgent, overridden: false } + } + + if ( + detectedAgentIds === null || + !agent || + agent === 'blank' || + (detectedAgentIds.has(agent) && isWorkspaceAgentEnabled(agent, settings.disabledTuiAgents)) + ) { + return current + } + + return { agent: pickedAgent, overridden: false } +} diff --git a/mobile/src/tasks/workspace-ssh-gate.ts b/mobile/src/tasks/workspace-ssh-gate.ts index 567355cad2f..43c7e88d277 100644 --- a/mobile/src/tasks/workspace-ssh-gate.ts +++ b/mobile/src/tasks/workspace-ssh-gate.ts @@ -7,7 +7,7 @@ export type WorkspaceSshGate = { error: string | null } -export function isWorkspaceSshConnectInProgress(status: SshConnectionStatus | null): boolean { +function isWorkspaceSshConnectInProgress(status: SshConnectionStatus | null): boolean { return status === 'connecting' || status === 'deploying-relay' || status === 'reconnecting' } diff --git a/mobile/src/terminal/TerminalWebView.tsx b/mobile/src/terminal/TerminalWebView.tsx index 99771f3e453..98bf72d53d8 100644 --- a/mobile/src/terminal/TerminalWebView.tsx +++ b/mobile/src/terminal/TerminalWebView.tsx @@ -74,6 +74,9 @@ type TerminalMessage = | { type: 'do-select-all'; id?: number } | { type: 'set-theme'; id?: number; terminalTheme?: MobileTerminalTheme } +const MAX_PENDING_WEB_WRITE_BYTES = 1_000_000 +const MAX_PENDING_WEB_WRITE_MESSAGES = 4096 + const DEFAULT_TERMINAL_THEME: MobileTerminalTheme['theme'] = { background: colors.terminalBg, foreground: '#c0caf5', @@ -270,8 +273,10 @@ const XTERM_HTML = ` var scrollThumb = document.getElementById('scroll-thumb'); var scrollIndicatorHideTimer = null; var writeQueue = []; + var writeQueueHead = 0; var writesDraining = false; var afterDrainCallbacks = []; + var termObserverDisposables = []; var ready = false; var currentScale = 1; var userScale = 1; @@ -589,6 +594,39 @@ const XTERM_HTML = ` } } + function resetWriteQueue() { + writeQueue = []; + writeQueueHead = 0; + } + + function enqueueWrite(data) { + writeQueue.push(data); + } + + function nextQueuedWrite() { + if (writeQueueHead >= writeQueue.length) { + resetWriteQueue(); + return undefined; + } + var next = writeQueue[writeQueueHead]; + writeQueueHead++; + // Why: high-throughput terminals can enqueue faster than xterm parses; + // compact consumed slots so drain work stays O(1) without retaining old chunks. + if (writeQueueHead > 128 && writeQueueHead * 2 > writeQueue.length) { + writeQueue = writeQueue.slice(writeQueueHead); + writeQueueHead = 0; + } + return next; + } + + function disposeTermObservers() { + var disposables = termObserverDisposables; + termObserverDisposables = []; + for (var i = 0; i < disposables.length; i++) { + try { disposables[i] && disposables[i].dispose && disposables[i].dispose(); } catch (e) {} + } + } + function extractMouseModeScanTail(input) { var start = Math.max(input.lastIndexOf(ESC), input.lastIndexOf(C1_CSI)); if (start === -1) return ''; @@ -608,7 +646,7 @@ const XTERM_HTML = ` function pumpWrites(gen) { if (!ready || !term || writesDraining || gen !== terminalGeneration) return; - var next = writeQueue.shift(); + var next = nextQueuedWrite(); if (typeof next !== 'string') { var callbacks = afterDrainCallbacks; afterDrainCallbacks = []; @@ -634,7 +672,7 @@ const XTERM_HTML = ` terminalGeneration++; var gen = terminalGeneration; ready = false; - writeQueue = []; + resetWriteQueue(); writesDraining = false; afterDrainCallbacks = []; initRows = rows || 24; @@ -659,6 +697,7 @@ const XTERM_HTML = ` var oldTerm = term; var oldSurface = surface; var nextSurface = null; + disposeTermObservers(); if (oldTerm) { nextSurface = document.createElement('div'); nextSurface.id = 'terminal-surface'; @@ -689,7 +728,7 @@ const XTERM_HTML = ` }); term.open(surface); if (typeof replayData === 'string' && replayData.length > 0) { - writeQueue.push(replayData); + enqueueWrite(replayData); } // Why: reset eviction tracking + attach observers for the new term. @@ -718,7 +757,7 @@ const XTERM_HTML = ` function write(data) { updateMouseModeFromData(data); - writeQueue.push(data); + enqueueWrite(data); pumpWrites(terminalGeneration); // Why: first live data chunk after init may widen the buffer past // what the post-replay applyFitScale measured. Re-fit once after this @@ -816,7 +855,7 @@ const XTERM_HTML = ` write(msg.data); } else if (msg.type === 'clear') { terminalGeneration++; - writeQueue = []; + resetWriteQueue(); afterDrainCallbacks = []; writesDraining = false; mouseModeScanTail = ''; @@ -968,15 +1007,20 @@ const XTERM_HTML = ` function attachTermObservers() { if (!term) return; - try { term.onLineFeed(logFeedAndEvict); } catch (e) {} - try { term.onScroll(function() { updateScrollIndicator(false); }); } catch (e) {} + disposeTermObservers(); + try { termObserverDisposables.push(term.onLineFeed(logFeedAndEvict)); } catch (e) {} + try { + termObserverDisposables.push(term.onScroll(function() { updateScrollIndicator(false); })); + } catch (e) {} // Why: emit modes on every parsed write so RN's mirror stays current // without round-trip; covers \\x1b[?2004h/l and alt-screen toggles. try { - term.onWriteParsed && term.onWriteParsed(function() { - emitModesIfChanged(); - emitKeyboardAvoidanceMetrics(); - }); + if (term.onWriteParsed) { + termObserverDisposables.push(term.onWriteParsed(function() { + emitModesIfChanged(); + emitKeyboardAvoidanceMetrics(); + })); + } } catch (e) {} // Initial emit once buffer settles. afterWritesDrained(function() { @@ -1880,6 +1924,8 @@ export const TerminalWebView = forwardRef(function const webViewRef = useRef(null) const isWebReadyRef = useRef(false) const pendingMessagesRef = useRef([]) + const pendingWriteBytesRef = useRef(0) + const pendingWriteCountRef = useRef(0) const messageIdRef = useRef(0) const terminalThemeKey = useMemo(() => JSON.stringify(terminalTheme ?? null), [terminalTheme]) const measureResolveRef = useRef< @@ -1900,20 +1946,58 @@ export const TerminalWebView = forwardRef(function const flushPendingMessages = useCallback(() => { const pending = pendingMessagesRef.current pendingMessagesRef.current = [] + pendingWriteBytesRef.current = 0 + pendingWriteCountRef.current = 0 for (const msg of pending) { sendToWebView(msg) } }, [sendToWebView]) + const clearPendingMessages = useCallback(() => { + pendingMessagesRef.current = [] + pendingWriteBytesRef.current = 0 + pendingWriteCountRef.current = 0 + }, []) + + const queuePendingMessage = useCallback((msg: TerminalMessage) => { + const pending = pendingMessagesRef.current + pending.push(msg) + if (msg.type !== 'write') { + return + } + + pendingWriteBytesRef.current += msg.data.length + pendingWriteCountRef.current += 1 + while ( + pendingWriteBytesRef.current > MAX_PENDING_WEB_WRITE_BYTES || + pendingWriteCountRef.current > MAX_PENDING_WEB_WRITE_MESSAGES + ) { + const dropIndex = pending.findIndex((candidate) => candidate.type === 'write') + if (dropIndex === -1) { + pendingWriteBytesRef.current = 0 + pendingWriteCountRef.current = 0 + return + } + const [dropped] = pending.splice(dropIndex, 1) + if (dropped?.type === 'write') { + pendingWriteBytesRef.current = Math.max( + 0, + pendingWriteBytesRef.current - dropped.data.length + ) + pendingWriteCountRef.current = Math.max(0, pendingWriteCountRef.current - 1) + } + } + }, []) + const postMessage = useCallback( (msg: TerminalMessage) => { if (!isWebReadyRef.current) { - pendingMessagesRef.current.push(msg) + queuePendingMessage(msg) return } sendToWebView(msg) }, - [sendToWebView] + [queuePendingMessage, sendToWebView] ) const handleMessage = useCallback( @@ -2017,7 +2101,10 @@ export const TerminalWebView = forwardRef(function const handleLoadStart = useCallback(() => { isWebReadyRef.current = false - }, []) + // Why: messages queued for a previous WebView generation are stale after a reload; + // dropping them avoids replaying terminal chunks before the next init snapshot. + clearPendingMessages() + }, [clearPendingMessages]) useEffect(() => { postMessage({ type: 'set-theme', terminalTheme }) @@ -2060,15 +2147,25 @@ export const TerminalWebView = forwardRef(function if (!isWebReadyRef.current) return Promise.resolve(null) return new Promise((resolve) => { measureResolveRef.current?.(null) - measureResolveRef.current = resolve + let timeout: ReturnType | null = null + const finish = (result: { cols: number; rows: number } | null) => { + if (timeout) { + clearTimeout(timeout) + timeout = null + } + if (measureResolveRef.current === finish) { + measureResolveRef.current = null + } + resolve(result) + } + measureResolveRef.current = finish sendToWebView({ type: 'measure', containerHeight }) // Why: if the WebView doesn't respond within 2s (e.g., xterm // failed to load), resolve null so the caller can disable // Fit to Phone rather than hanging indefinitely. - setTimeout(() => { - if (measureResolveRef.current === resolve) { - measureResolveRef.current = null - resolve(null) + timeout = setTimeout(() => { + if (measureResolveRef.current === finish) { + finish(null) } }, 2000) }) @@ -2088,7 +2185,20 @@ export const TerminalWebView = forwardRef(function // WebView doesn't hang the caller. const p = readyPromiseRef.current if (!p) return - await Promise.race([p, new Promise((resolve) => setTimeout(resolve, 3000))]) + await new Promise((resolve) => { + let settled = false + const timeout = setTimeout(() => { + settled = true + resolve() + }, 3000) + void p.finally(() => { + if (!settled) { + clearTimeout(timeout) + settled = true + resolve() + } + }) + }) } }), [postMessage, sendToWebView, terminalTheme] diff --git a/mobile/src/terminal/terminal-auto-restore-fit-state.test.ts b/mobile/src/terminal/terminal-auto-restore-fit-state.test.ts new file mode 100644 index 00000000000..9d314e5be2c --- /dev/null +++ b/mobile/src/terminal/terminal-auto-restore-fit-state.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { + normalizeTerminalAutoRestoreFitMs, + setTerminalAutoRestoreFitMsForHost +} from './terminal-auto-restore-fit-state' + +describe('terminal auto restore fit state', () => { + it('normalizes missing server values to the default indefinite value', () => { + expect(normalizeTerminalAutoRestoreFitMs(undefined)).toBeNull() + expect(normalizeTerminalAutoRestoreFitMs(null)).toBeNull() + expect(normalizeTerminalAutoRestoreFitMs(60_000)).toBe(60_000) + }) + + it('returns the existing state object when a host value is unchanged', () => { + const current = { hostA: 60_000, hostB: null } + + expect(setTerminalAutoRestoreFitMsForHost(current, 'hostA', 60_000)).toBe(current) + expect(setTerminalAutoRestoreFitMsForHost(current, 'hostB', undefined)).toBe(current) + }) + + it('updates one host while preserving other host values', () => { + const current = { hostA: 60_000, hostB: null } + const next = setTerminalAutoRestoreFitMsForHost(current, 'hostA', 300_000) + + expect(next).not.toBe(current) + expect(next).toEqual({ hostA: 300_000, hostB: null }) + }) +}) diff --git a/mobile/src/terminal/terminal-auto-restore-fit-state.ts b/mobile/src/terminal/terminal-auto-restore-fit-state.ts new file mode 100644 index 00000000000..5fff18165df --- /dev/null +++ b/mobile/src/terminal/terminal-auto-restore-fit-state.ts @@ -0,0 +1,17 @@ +export type TerminalAutoRestoreFitByHost = Record + +export function normalizeTerminalAutoRestoreFitMs(value: number | null | undefined): number | null { + return value === undefined ? null : value +} + +export function setTerminalAutoRestoreFitMsForHost( + current: TerminalAutoRestoreFitByHost, + hostId: string, + value: number | null | undefined +): TerminalAutoRestoreFitByHost { + const nextValue = normalizeTerminalAutoRestoreFitMs(value) + if (current[hostId] === nextValue) { + return current + } + return { ...current, [hostId]: nextValue } +} diff --git a/mobile/src/terminal/terminal-live-input.test.ts b/mobile/src/terminal/terminal-live-input.test.ts index 0bad02da6d0..3b60155b86f 100644 --- a/mobile/src/terminal/terminal-live-input.test.ts +++ b/mobile/src/terminal/terminal-live-input.test.ts @@ -1,11 +1,22 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { TERMINAL_LIVE_INPUT_MAX_BYTES, + clearTerminalLiveInputFocusTimer, getTerminalLiveSpecialKeyBytes, - isTerminalLiveInputWithinByteLimit + isTerminalLiveInputWithinByteLimit, + scheduleTerminalLiveInputFocus, + type TerminalLiveInputFocusTimerRef } from './terminal-live-input' +function createTimerRef(): TerminalLiveInputFocusTimerRef { + return { current: null } +} + describe('terminal live input', () => { + afterEach(() => { + vi.useRealTimers() + }) + it('maps phone keyboard special keys to PTY bytes', () => { expect(getTerminalLiveSpecialKeyBytes('Backspace')).toBe('\x7f') expect(getTerminalLiveSpecialKeyBytes('Enter')).toBeNull() @@ -22,4 +33,32 @@ describe('terminal live input', () => { isTerminalLiveInputWithinByteLimit('é'.repeat(TERMINAL_LIVE_INPUT_MAX_BYTES / 2 + 1)) ).toBe(false) }) + + it('replaces pending deferred focus work', () => { + vi.useFakeTimers() + const timerRef = createTimerRef() + const staleFocus = vi.fn() + const nextFocus = vi.fn() + + scheduleTerminalLiveInputFocus(timerRef, staleFocus) + scheduleTerminalLiveInputFocus(timerRef, nextFocus) + vi.runOnlyPendingTimers() + + expect(staleFocus).not.toHaveBeenCalled() + expect(nextFocus).toHaveBeenCalledTimes(1) + expect(timerRef.current).toBeNull() + }) + + it('clears pending deferred focus work', () => { + vi.useFakeTimers() + const timerRef = createTimerRef() + const focus = vi.fn() + + scheduleTerminalLiveInputFocus(timerRef, focus) + clearTerminalLiveInputFocusTimer(timerRef) + vi.runOnlyPendingTimers() + + expect(focus).not.toHaveBeenCalled() + expect(timerRef.current).toBeNull() + }) }) diff --git a/mobile/src/terminal/terminal-live-input.ts b/mobile/src/terminal/terminal-live-input.ts index 87b6544fc74..f3e15fea20c 100644 --- a/mobile/src/terminal/terminal-live-input.ts +++ b/mobile/src/terminal/terminal-live-input.ts @@ -2,6 +2,10 @@ const TERMINAL_LIVE_INPUT_MAX_BYTES = 256 * 1024 const encoder = new TextEncoder() +export type TerminalLiveInputFocusTimerRef = { + current: ReturnType | null +} + export function getTerminalLiveSpecialKeyBytes(key: string): string | null { if (key === 'Backspace') { return '\x7f' @@ -16,4 +20,26 @@ export function isTerminalLiveInputWithinByteLimit( return encoder.encode(text).byteLength <= maxBytes } +export function clearTerminalLiveInputFocusTimer(timerRef: TerminalLiveInputFocusTimerRef): void { + if (timerRef.current === null) { + return + } + clearTimeout(timerRef.current) + timerRef.current = null +} + +export function scheduleTerminalLiveInputFocus( + timerRef: TerminalLiveInputFocusTimerRef, + focus: () => void, + delayMs = 50 +): void { + // Why: live input can be toggled during route changes; replacing the pending + // focus timer prevents stale native TextInput focus after unmount/disable. + clearTerminalLiveInputFocusTimer(timerRef) + timerRef.current = setTimeout(() => { + timerRef.current = null + focus() + }, delayMs) +} + export { TERMINAL_LIVE_INPUT_MAX_BYTES } diff --git a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts index 9aabc65a6d0..d1c4398d1ed 100644 --- a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts +++ b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts @@ -78,6 +78,35 @@ describe('TerminalWebView scroll routing', () => { expect(resetBlock).toContain('cancelAnimationFrame(normalScrollFrameId);') }) + it('drains terminal writes without shifting the queued array', () => { + expect(source).toContain('var writeQueueHead = 0;') + expect(source).toContain('function nextQueuedWrite()') + expect(source).toContain('writeQueueHead++;') + expect(source).toContain('writeQueue = writeQueue.slice(writeQueueHead);') + expect(source).not.toContain('writeQueue.shift()') + }) + + it('bounds native-side pending WebView writes while preserving control messages', () => { + expect(source).toContain('const MAX_PENDING_WEB_WRITE_BYTES = 1_000_000') + expect(source).toContain('const MAX_PENDING_WEB_WRITE_MESSAGES = 4096') + expect(source).toContain('const pendingWriteBytesRef = useRef(0)') + expect(source).toContain('const pendingWriteCountRef = useRef(0)') + expect(source).toContain('const queuePendingMessage = useCallback') + expect(source).toContain('pendingWriteCountRef.current > MAX_PENDING_WEB_WRITE_MESSAGES') + expect(source).toContain("candidate.type === 'write'") + expect(source).toContain('clearPendingMessages()') + }) + + it('clears WebView await timers when the real response wins', () => { + const measureBlock = sliceBetween('measureFitDimensions(', 'resetZoom()') + expect(measureBlock).toContain('clearTimeout(timeout)') + expect(measureBlock).toContain('measureResolveRef.current === finish') + + const readyBlock = sliceBetween('async awaitReady()', '})') + expect(readyBlock).toContain('clearTimeout(timeout)') + expect(readyBlock).toContain('void p.finally') + }) + it('hides xterm scrollbars and drives the mobile scroll indicator from committed rows', () => { expect(source).toContain('

') expect(source).toContain('.xterm .xterm-viewport::-webkit-scrollbar') diff --git a/mobile/src/transport/connection-health.ts b/mobile/src/transport/connection-health.ts index 43bdc57b622..6bc5ba673a1 100644 --- a/mobile/src/transport/connection-health.ts +++ b/mobile/src/transport/connection-health.ts @@ -16,9 +16,9 @@ import type { ConnectionState } from './types' // session but haven't been for ≥ 1 minute despite the retry loop // spinning, treat the same as never-connected. Catches the case // where the desktop's IP changed mid-session. -export const WARNING_ATTEMPTS = 3 -export const UNREACHABLE_ATTEMPTS = 12 -export const STALE_SINCE_LAST_CONNECT_MS = 60_000 +const WARNING_ATTEMPTS = 3 +const UNREACHABLE_ATTEMPTS = 12 +const STALE_SINCE_LAST_CONNECT_MS = 60_000 export type ConnectionVerdict = | { kind: 'normal'; label: string } @@ -76,13 +76,3 @@ export function classifyConnection(args: { return { kind: 'normal', label: 'Reconnecting…' } } - -// Why: the message under the banner explains what likely happened so the -// user understands why we're suggesting Re-pair. Tuned to be specific -// about IP/port without being technical (we don't want to leak -// "ws://192.168.x.y:port" unless someone is debugging). -export function unreachableHint(reason: 'never-connected' | 'stale'): string { - return reason === 'never-connected' - ? "Can't reach this Orca desktop. Its network address may have changed since pairing — try re-pairing from the desktop's Settings → Mobile screen." - : 'Lost contact with the Orca desktop. If your network changed (different Wi-Fi, IP renewed, or desktop restarted), try re-pairing.' -} diff --git a/mobile/src/transport/e2ee.ts b/mobile/src/transport/e2ee.ts index a6dbf6f346f..2732b3d615f 100644 --- a/mobile/src/transport/e2ee.ts +++ b/mobile/src/transport/e2ee.ts @@ -73,7 +73,7 @@ export function decrypt(encrypted: string, sharedKey: Uint8Array): string | null return plaintext ? new TextDecoder().decode(plaintext) : null } -export function encryptBytes(plaintext: Uint8Array, sharedKey: Uint8Array): Uint8Array { +function encryptBytes(plaintext: Uint8Array, sharedKey: Uint8Array): Uint8Array { const nonce = u8(nacl.randomBytes(nacl.box.nonceLength)) const ciphertext = nacl.box.after(u8(plaintext), nonce, u8(sharedKey)) diff --git a/mobile/src/transport/host-names.test.ts b/mobile/src/transport/host-names.test.ts new file mode 100644 index 00000000000..e4e06417ac3 --- /dev/null +++ b/mobile/src/transport/host-names.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { getNextHostNameFromHosts } from './host-names' + +describe('getNextHostNameFromHosts', () => { + it('increments the largest numbered default host name', () => { + expect( + getNextHostNameFromHosts([ + { name: 'Host 2' }, + { name: 'Custom workstation' }, + { name: 'Host 7' }, + { name: 'Host 3' } + ]) + ).toBe('Host 8') + }) + + it('handles very large host lists without spreading number arrays', () => { + const hosts = Array.from({ length: 130_000 }, (_, index) => ({ + name: `Host ${index + 1}` + })) + + expect(getNextHostNameFromHosts(hosts)).toBe('Host 130001') + }) +}) diff --git a/mobile/src/transport/host-names.ts b/mobile/src/transport/host-names.ts new file mode 100644 index 00000000000..cdb4418cbe4 --- /dev/null +++ b/mobile/src/transport/host-names.ts @@ -0,0 +1,21 @@ +type HostNameSource = { + readonly name: string +} + +const HOST_NUMBER_PATTERN = /^Host (\d+)$/ + +export function getNextHostNameFromHosts(hosts: readonly HostNameSource[]): string { + let largestHostNumber = 0 + + for (const host of hosts) { + const match = HOST_NUMBER_PATTERN.exec(host.name) + if (!match) continue + + const hostNumber = Number.parseInt(match[1]!, 10) + if (hostNumber > largestHostNumber) { + largestHostNumber = hostNumber + } + } + + return `Host ${largestHostNumber + 1}` +} diff --git a/mobile/src/transport/host-store.ts b/mobile/src/transport/host-store.ts index af21723a66f..9a022869af9 100644 --- a/mobile/src/transport/host-store.ts +++ b/mobile/src/transport/host-store.ts @@ -6,6 +6,7 @@ import { type HostProfile, type StoredHostProfile } from './types' +import { getNextHostNameFromHosts } from './host-names' const STORAGE_KEY = 'orca:hosts' // Why: SecureStore keys must match [A-Za-z0-9._-]; colons are rejected. @@ -161,14 +162,7 @@ export async function renameHost(hostId: string, newName: string): Promise export async function getNextHostName(): Promise { const hosts = await loadStoredHosts() - const existingNumbers = hosts - .map((h) => { - const match = h.name.match(/^Host (\d+)$/) - return match ? parseInt(match[1]!, 10) : 0 - }) - .filter((n) => n > 0) - const next = existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1 - return `Host ${next}` + return getNextHostNameFromHosts(hosts) } export async function updateLastConnected(hostId: string): Promise { diff --git a/mobile/src/transport/index.ts b/mobile/src/transport/index.ts deleted file mode 100644 index 8aacca59eff..00000000000 --- a/mobile/src/transport/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -export { connect, type RpcClient } from './rpc-client' -export { - loadHosts, - saveHost, - removeHost, - renameHost, - getNextHostName, - updateLastConnected -} from './host-store' -export type { - RpcRequest, - RpcResponse, - RpcSuccess, - RpcFailure, - ConnectionState, - HostProfile, - PairingOffer -} from './types' -export { PairingOfferSchema, PAIRING_OFFER_VERSION } from './types' diff --git a/mobile/src/transport/pair-confirm-state.test.ts b/mobile/src/transport/pair-confirm-state.test.ts new file mode 100644 index 00000000000..1a318dd7e07 --- /dev/null +++ b/mobile/src/transport/pair-confirm-state.test.ts @@ -0,0 +1,54 @@ +import { Buffer } from 'node:buffer' +import { describe, expect, it } from 'vitest' + +import { resolvePairConfirmRouteState } from './pair-confirm-state' +import type { PairingOffer } from './types' + +function encodeOffer(offer: PairingOffer): string { + return Buffer.from(JSON.stringify(offer)) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, '') +} + +describe('resolvePairConfirmRouteState', () => { + const offer: PairingOffer = { + v: 2, + endpoint: 'ws://192.168.1.10:6768', + deviceToken: 'token-abc', + publicKeyB64: 'pubkey-xyz' + } + + it('accepts a valid pairing code', () => { + expect(resolvePairConfirmRouteState(encodeOffer(offer))).toEqual({ + kind: 'ready', + offer, + errorMessage: '' + }) + }) + + it('accepts a full pairing URL', () => { + expect(resolvePairConfirmRouteState(`orca://pair#${encodeOffer(offer)}`)).toEqual({ + kind: 'ready', + offer, + errorMessage: '' + }) + }) + + it('reports a missing pairing code', () => { + expect(resolvePairConfirmRouteState(undefined)).toEqual({ + kind: 'error', + offer: null, + errorMessage: 'Missing pairing code' + }) + }) + + it('reports an invalid pairing code', () => { + expect(resolvePairConfirmRouteState('not a pairing code')).toEqual({ + kind: 'error', + offer: null, + errorMessage: 'Not a valid pairing code' + }) + }) +}) diff --git a/mobile/src/transport/pair-confirm-state.ts b/mobile/src/transport/pair-confirm-state.ts new file mode 100644 index 00000000000..242e67d7ffe --- /dev/null +++ b/mobile/src/transport/pair-confirm-state.ts @@ -0,0 +1,19 @@ +import { parsePairingCode } from './pairing' +import type { PairingOffer } from './types' + +export type PairConfirmRouteState = + | { kind: 'ready'; offer: PairingOffer; errorMessage: '' } + | { kind: 'error'; offer: null; errorMessage: string } + +export function resolvePairConfirmRouteState(code: string | undefined): PairConfirmRouteState { + if (!code) { + return { kind: 'error', offer: null, errorMessage: 'Missing pairing code' } + } + + const offer = parsePairingCode(code) + if (!offer) { + return { kind: 'error', offer: null, errorMessage: 'Not a valid pairing code' } + } + + return { kind: 'ready', offer, errorMessage: '' } +} diff --git a/mobile/src/transport/pairing-connection-attempt.test.ts b/mobile/src/transport/pairing-connection-attempt.test.ts new file mode 100644 index 00000000000..b05867eb7c0 --- /dev/null +++ b/mobile/src/transport/pairing-connection-attempt.test.ts @@ -0,0 +1,41 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { startPairingConnectionAttempt } from './pairing-connection-attempt' + +describe('pairing connection attempt cleanup', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('closes the temporary client when the overall pairing timeout fires', () => { + vi.useFakeTimers() + const closeClient = vi.fn() + + const attempt = startPairingConnectionAttempt({ timeoutMs: 25_000, closeClient }) + + expect(attempt.timedOut).toBe(false) + vi.advanceTimersByTime(24_999) + expect(closeClient).not.toHaveBeenCalled() + + vi.advanceTimersByTime(1) + expect(attempt.timedOut).toBe(true) + expect(closeClient).toHaveBeenCalledTimes(1) + + attempt.dispose() + expect(closeClient).toHaveBeenCalledTimes(1) + }) + + it('clears the timeout and closes the temporary client when disposed early', () => { + vi.useFakeTimers() + const closeClient = vi.fn() + + const attempt = startPairingConnectionAttempt({ timeoutMs: 25_000, closeClient }) + attempt.dispose() + + expect(attempt.timedOut).toBe(false) + expect(closeClient).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(25_000) + expect(attempt.timedOut).toBe(false) + expect(closeClient).toHaveBeenCalledTimes(1) + }) +}) diff --git a/mobile/src/transport/pairing-connection-attempt.ts b/mobile/src/transport/pairing-connection-attempt.ts new file mode 100644 index 00000000000..c2c1372ff97 --- /dev/null +++ b/mobile/src/transport/pairing-connection-attempt.ts @@ -0,0 +1,46 @@ +export type PairingConnectionAttempt = { + readonly timedOut: boolean + dispose: () => void +} + +export function startPairingConnectionAttempt({ + timeoutMs, + closeClient +}: { + timeoutMs: number + closeClient: () => void +}): PairingConnectionAttempt { + let disposed = false + let clientClosed = false + let timedOut = false + let timer: ReturnType | null = null + + function closeClientOnce() { + if (clientClosed) return + clientClosed = true + closeClient() + } + + function dispose() { + if (disposed) return + disposed = true + if (timer) { + clearTimeout(timer) + timer = null + } + closeClientOnce() + } + + timer = setTimeout(() => { + timer = null + timedOut = true + dispose() + }, timeoutMs) + + return { + get timedOut() { + return timedOut + }, + dispose + } +} diff --git a/mobile/src/transport/rpc-client.test.ts b/mobile/src/transport/rpc-client.test.ts index 11b6419cb6e..600ce49788d 100644 --- a/mobile/src/transport/rpc-client.test.ts +++ b/mobile/src/transport/rpc-client.test.ts @@ -151,6 +151,29 @@ describe('mobile rpc-client connection timeout', () => { client.close() }) + it('ignores stale socket opens after reconnect swaps in a new socket', () => { + const client = connect('ws://desktop.invalid', 'token', 'server-key') + const firstSocket = mockSockets[0]! + firstSocket.emitCloseOnClose = false + + vi.advanceTimersByTime(12_000) + vi.advanceTimersByTime(500) + + const secondSocket = mockSockets[1]! + expect(client.getState()).toBe('connecting') + + firstSocket.open() + + expect(client.getState()).toBe('connecting') + expect(secondSocket.sent).toEqual([]) + + vi.advanceTimersByTime(12_000) + + expect(secondSocket.close).toHaveBeenCalledTimes(1) + + client.close() + }) + it('clears the open timeout once the socket opens and authenticates', () => { const client = connect('ws://desktop.invalid', 'token', 'server-key') const socket = mockSockets[0]! @@ -479,4 +502,69 @@ describe('mobile rpc-client connection timeout', () => { client.close() }) + + it('applies per-request timeout overrides while waiting for reconnect', async () => { + const client = connect('ws://desktop.invalid', 'token', 'server-key') + const socket = mockSockets[0]! + + socket.open() + socket.receive(JSON.stringify({ type: 'e2ee_ready' })) + socket.receive('encrypted:{"type":"e2ee_authenticated"}') + socket.close() + + const request = client.sendRequest( + 'speech.dictation.finish', + { dictationId: 'd1' }, + { + timeoutMs: 123 + } + ) + let requestOutcome = 'pending' + request.then( + () => { + requestOutcome = 'resolved' + }, + (error: Error) => { + requestOutcome = error.message + } + ) + + try { + await vi.advanceTimersByTimeAsync(122) + await Promise.resolve() + expect(requestOutcome).toBe('pending') + + await vi.advanceTimersByTimeAsync(1) + await Promise.resolve() + expect(requestOutcome).toBe('Timed out while connecting to the remote Orca runtime.') + } finally { + client.close() + await request.catch(() => undefined) + } + }) + + it('rejects requests waiting for reconnect after the retry cap', async () => { + const client = connect('ws://desktop.invalid', 'token', 'server-key') + const socket = mockSockets[0]! + + socket.open() + socket.receive(JSON.stringify({ type: 'e2ee_ready' })) + socket.receive('encrypted:{"type":"e2ee_authenticated"}') + socket.close() + + const waitingRequestError = client.sendRequest('status.get').then( + () => null, + (error: Error) => error + ) + await vi.runAllTimersAsync() + + expect(client.getState()).toBe('reconnecting') + expect(client.getReconnectAttempt()).toBe(12) + await expect(waitingRequestError).resolves.toMatchObject({ + message: 'Connection retry limit reached' + }) + await expect(client.sendRequest('status.get')).rejects.toThrow('Connection retry limit reached') + + client.close() + }) }) diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 995169f9bfb..896a9037061 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -30,6 +30,12 @@ type PendingRequest = { reject: (error: Error) => void } +type ConnectWaiter = { + resolve: () => void + reject: (error: Error) => void + timeout: ReturnType | null +} + type SendRequestOptions = { timeoutMs?: number } @@ -188,7 +194,7 @@ export function connect( let activeBrowserScreencastRequestId: string | null = null let pendingBrowserScreencastRequestId: string | null = null const stateListeners = new Set<(state: ConnectionState) => void>() - const connectWaiters: Array<{ resolve: () => void; reject: (e: Error) => void }> = [] + const connectWaiters: ConnectWaiter[] = [] if (onStateChange) { stateListeners.add(onStateChange) @@ -199,6 +205,16 @@ export function connect( // in the logs. let stateEnteredAt = Date.now() + function rejectConnectWaiters(reason: string) { + const error = new Error(reason) + for (const waiter of connectWaiters.splice(0)) { + if (waiter.timeout) { + clearTimeout(waiter.timeout) + } + waiter.reject(error) + } + } + function setState(next: ConnectionState) { if (state === next) return const prev = state @@ -214,11 +230,16 @@ export function connect( }) if (next === 'connected') { lastConnectedAt = Date.now() - for (const w of connectWaiters.splice(0)) w.resolve() + for (const waiter of connectWaiters.splice(0)) { + if (waiter.timeout) { + clearTimeout(waiter.timeout) + } + waiter.resolve() + } } else if (next === 'disconnected' || next === 'auth-failed') { const reason = next === 'auth-failed' ? 'Unauthorized — pairing may be revoked' : 'Connection closed' - for (const w of connectWaiters.splice(0)) w.reject(new Error(reason)) + rejectConnectWaiters(reason) } for (const listener of stateListeners) { listener(next) @@ -236,11 +257,31 @@ export function connect( } } - function waitForConnected(): Promise { + function waitForConnected(timeoutMs?: number): Promise { if (state === 'connected') return Promise.resolve() if (intentionallyClosed) return Promise.reject(new Error('Client closed')) + if (state === 'reconnecting' && reconnectAttempt >= GIVE_UP_AFTER_ATTEMPTS && !reconnectTimer) { + // Why: after the retry cap there is no future state transition to + // release callers waiting before their per-request timeout starts. + return Promise.reject(new Error('Connection retry limit reached')) + } return new Promise((resolve, reject) => { - connectWaiters.push({ resolve, reject }) + const waiter: ConnectWaiter = { resolve, reject, timeout: null } + if (timeoutMs !== undefined) { + // Why: explicit per-request timeouts must include offline/reconnect + // waiting, not only the RPC after the socket becomes connected. + waiter.timeout = setTimeout( + () => { + const index = connectWaiters.indexOf(waiter) + if (index !== -1) { + connectWaiters.splice(index, 1) + } + reject(new Error('Timed out while connecting to the remote Orca runtime.')) + }, + Math.max(0, timeoutMs) + ) + } + connectWaiters.push(waiter) }) } @@ -278,6 +319,19 @@ export function connect( ws = new WebSocket(endpoint) const openingWs = ws + const ignoreStaleSocketEvent = (eventName: string): boolean => { + if (ws === openingWs) { + return false + } + // Why: React Native can deliver callbacks from a timed-out socket after + // reconnect has swapped in a replacement; stale events must not mutate it. + console.log('[net] stale ws event ignored', { + eventName, + state, + attempt: reconnectAttempt + }) + return true + } // Why: React Native can leave TCP/WebSocket opens pending indefinitely on // flaky network handoffs. Force the existing onclose reconnect path if @@ -302,6 +356,9 @@ export function connect( }, CONNECT_TIMEOUT_MS) ws.onopen = () => { + if (ignoreStaleSocketEvent('open')) { + return + } console.log('[net] ws.onopen', { attempt: reconnectAttempt }) clearConnectTimer() reconnectAttempt = 0 @@ -316,13 +373,16 @@ export function connect( type: 'e2ee_hello', publicKeyB64: publicKeyToBase64(ephemeral.publicKey) }) - ws?.send(hello) + openingWs.send(hello) emitLog('info', 'Sent e2ee_hello', 'Awaiting server e2ee_ready') sharedKey = deriveSharedKey(ephemeral.secretKey, serverPublicKey) handshakeTimer = setTimeout(() => { handshakeTimer = null + if (ws !== openingWs || state !== 'handshaking') { + return + } console.log('[net] handshake-timeout fired (e2ee_authenticated never arrived)', { timeoutMs: HANDSHAKE_TIMEOUT_MS }) @@ -331,11 +391,14 @@ export function connect( 'Handshake timeout', `No e2ee_ready/e2ee_authenticated within ${HANDSHAKE_TIMEOUT_MS / 1000}s` ) - ws?.close() + openingWs.close() }, HANDSHAKE_TIMEOUT_MS) } ws.onmessage = (event) => { + if (ignoreStaleSocketEvent('message')) { + return + } void handleSocketMessage(event.data) } @@ -431,6 +494,9 @@ export function connect( if (raw === null) { const bytes = await websocketPayloadToUint8(rawData) + if (ws !== openingWs) { + return + } if (!bytes) { return } @@ -609,6 +675,9 @@ export function connect( } ws.onerror = (event) => { + if (ignoreStaleSocketEvent('error')) { + return + } // Why: RN surfaces network errors here (DNS failure, TCP RST, etc). // onclose fires right after, but logging the error message gives us // the original cause that the close code alone can hide. @@ -699,6 +768,7 @@ export function connect( reason: 'give-up-cap', endpoint: redactedEndpoint(endpoint) }) + rejectConnectWaiters('Connection retry limit reached') return } const delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)]! @@ -969,7 +1039,7 @@ export function connect( ): Promise { const waitStart = Date.now() const wasConnected = state === 'connected' - await waitForConnected() + await waitForConnected(options?.timeoutMs) if (!wasConnected) { console.log('[net] sendRequest waited for connect', { method, diff --git a/mobile/src/transport/types.ts b/mobile/src/transport/types.ts index 6750dc69eca..1140c692ecb 100644 --- a/mobile/src/transport/types.ts +++ b/mobile/src/transport/types.ts @@ -24,7 +24,7 @@ export type RpcFailure = { export type RpcResponse = RpcSuccess | RpcFailure -export const PAIRING_OFFER_VERSION = 2 +const PAIRING_OFFER_VERSION = 2 export const PairingOfferSchema = z.object({ v: z.literal(PAIRING_OFFER_VERSION), diff --git a/native/computer-use-linux/runtime.py b/native/computer-use-linux/runtime.py index ac6495ac50e..8fcb2622ee6 100644 --- a/native/computer-use-linux/runtime.py +++ b/native/computer-use-linux/runtime.py @@ -23,14 +23,20 @@ import gi gi.require_version("Atspi", "2.0") try: gi.require_version("Gdk", "3.0") - from gi.repository import Gdk + gi.require_version("GdkPixbuf", "2.0") + from gi.repository import Gdk, GdkPixbuf except (ImportError, ValueError): Gdk = None + GdkPixbuf = None from gi.repository import Atspi MAX_NODES = 1200 MAX_DEPTH = 64 TEXT_LIMIT = 500 +MAX_SCREENSHOT_PNG_BYTES = 900_000 +MAX_SCREENSHOT_EDGE = 1280 +MIN_SCREENSHOT_SCALE = 0.25 +SCREENSHOT_SCALE_STEP = 0.85 BLOCKED_APP_FRAGMENTS = ( "1password", "bitwarden", @@ -400,7 +406,7 @@ def render_accessibility_tree(root, window_rect, root_path): def capture_png(rect): - if Gdk is None or rect is None or os.environ.get("XDG_SESSION_TYPE", "").lower() == "wayland": + if Gdk is None or GdkPixbuf is None or rect is None or os.environ.get("XDG_SESSION_TYPE", "").lower() == "wayland": return None screen = Gdk.Screen.get_default() root = screen.get_root_window() if screen else None @@ -409,8 +415,59 @@ def capture_png(rect): pixbuf = Gdk.pixbuf_get_from_window(root, round(rect.x), round(rect.y), max(1, round(rect.width)), max(1, round(rect.height))) if pixbuf is None: return None + return bounded_png_payload(pixbuf) + + +def png_bytes(pixbuf): ok, data = pixbuf.save_to_bufferv("png", [], []) - return base64.b64encode(bytes(data)).decode("ascii") if ok else None + return bytes(data) if ok else None + + +def screenshot_payload(data, width, height, original_width): + return { + "base64": base64.b64encode(data).decode("ascii"), + "width": width, + "height": height, + "scale": width / max(1, original_width), + } + + +def bounded_png_payload(pixbuf): + original_width = max(1, pixbuf.get_width()) + original_height = max(1, pixbuf.get_height()) + data = png_bytes(pixbuf) + if data is None: + return None + if len(data) <= MAX_SCREENSHOT_PNG_BYTES: + return screenshot_payload(data, original_width, original_height, original_width) + + # Why: screenshots are sent through JSON/stdout; matching macOS bounds keeps + # large or high-DPI windows from multiplying native, base64, and Node memory. + best_data = data + best_width = original_width + best_height = original_height + scale = min(1.0, MAX_SCREENSHOT_EDGE / max(original_width, original_height)) + while scale >= MIN_SCREENSHOT_SCALE: + width = max(1, round(original_width * scale)) + height = max(1, round(original_height * scale)) + if width == best_width and height == best_height: + scale *= SCREENSHOT_SCALE_STEP + continue + scaled = pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.BILINEAR) + if scaled is None: + scale *= SCREENSHOT_SCALE_STEP + continue + candidate = png_bytes(scaled) + if candidate is not None: + if len(candidate) <= MAX_SCREENSHOT_PNG_BYTES: + return screenshot_payload(candidate, width, height, original_width) + if len(candidate) < len(best_data): + best_data = candidate + best_width = width + best_height = height + scale *= SCREENSHOT_SCALE_STEP + + return screenshot_payload(best_data, best_width, best_height, original_width) def first_descendant(root, predicate): @@ -470,13 +527,17 @@ def make_snapshot(query, include_screenshot, window_id=None, window_index=None, window_index, window = choose_window(app, window_id, window_index) bounds = screen_rect(window) records, lines, truncation = render_accessibility_tree(window, bounds, [window_index]) + screenshot = capture_png(bounds) if include_screenshot else None return { "snapshotId": str(uuid.uuid4()), "app": app_json(app), "windowTitle": name_of(window), "windowId": window_index, "windowBounds": bounds.to_json() if bounds else None, - "screenshotPngBase64": capture_png(bounds) if include_screenshot else None, + "screenshotPngBase64": screenshot["base64"] if screenshot else None, + "screenshotWidth": screenshot["width"] if screenshot else None, + "screenshotHeight": screenshot["height"] if screenshot else None, + "screenshotScale": screenshot["scale"] if screenshot else None, "coordinateSpace": "window", "truncation": truncation, "treeLines": lines, @@ -503,7 +564,7 @@ def handshake_response(): is_wayland = os.environ.get("XDG_SESSION_TYPE", "").lower() == "wayland" has_hotkey = shutil.which("xdotool") is not None and not is_wayland has_clipboard = any(shutil.which(command) for command in ("wl-copy", "xclip", "xsel")) - has_screenshot = Gdk is not None and not is_wayland + has_screenshot = Gdk is not None and GdkPixbuf is not None and not is_wayland return { "platform": "linux", "provider": "orca-computer-use-linux", diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift index 8c7d6a4a4e4..eaadb85530d 100644 --- a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift @@ -135,6 +135,26 @@ struct Snapshot { let elements: [Int: ElementRecord] let truncated: Bool let maxDepthReached: Bool + + func withoutScreenshotPayload() -> Snapshot { + Snapshot( + id: id, + app: app, + windowTitle: windowTitle, + windowBounds: windowBounds, + windowId: windowId, + windowLayer: windowLayer, + treeText: treeText, + focusedElementId: focusedElementId, + screenshot: nil, + screenshotStatus: .skipped, + screenshotScale: CGSize(width: 1, height: 1), + screenshotEngine: nil, + elements: elements, + truncated: truncated, + maxDepthReached: maxDepthReached + ) + } } struct ScreenshotPayload { @@ -157,7 +177,6 @@ enum ScreenshotStatus { final class Provider { private var snapshots: [String: Snapshot] = [:] - private var hasPromptedForAccessibility = false func handle(method: String, params: [String: JSONValue]) throws -> Any { switch method { @@ -224,18 +243,21 @@ final class Provider { ) let keys = [query, app.name, app.bundleId ?? ""].filter { !$0.isEmpty }.map { $0.lowercased() } let namespace = snapshotNamespace(params) + // Why: cached snapshots only validate element identity for follow-up + // actions; retaining MB-scale screenshot base64 in the long-lived agent grows memory. + let cachedSnapshot = snapshot.withoutScreenshotPayload() for key in keys { if !isExplicitSnapshotNamespace(namespace) { - snapshots[key] = snapshot - snapshots[snapshotWindowKey(key, snapshot.windowId)] = snapshot + snapshots[key] = cachedSnapshot + snapshots[snapshotWindowKey(key, snapshot.windowId)] = cachedSnapshot if let windowIndex { - snapshots[snapshotWindowIndexKey(key, windowIndex)] = snapshot + snapshots[snapshotWindowIndexKey(key, windowIndex)] = cachedSnapshot } } - snapshots[namespacedSnapshotKey(namespace, key)] = snapshot - snapshots[namespacedSnapshotKey(namespace, snapshotWindowKey(key, snapshot.windowId))] = snapshot + snapshots[namespacedSnapshotKey(namespace, key)] = cachedSnapshot + snapshots[namespacedSnapshotKey(namespace, snapshotWindowKey(key, snapshot.windowId))] = cachedSnapshot if let windowIndex { - snapshots[namespacedSnapshotKey(namespace, snapshotWindowIndexKey(key, windowIndex))] = snapshot + snapshots[namespacedSnapshotKey(namespace, snapshotWindowIndexKey(key, windowIndex))] = cachedSnapshot } } return snapshot @@ -301,14 +323,6 @@ final class Provider { } } - private func promptForAccessibilityOnce() { - guard !hasPromptedForAccessibility else { - return - } - hasPromptedForAccessibility = true - _ = promptForAccessibility() - } - private func listApps() -> [AppDescriptor] { var seen = Set() return NSWorkspace.shared.runningApplications @@ -467,8 +481,12 @@ final class Provider { restoreWindow: Bool ) throws -> Snapshot { guard accessibilityTrusted() else { - promptForAccessibilityOnce() - throw ProviderError.coded("permission_denied", "Accessibility permission is required for Orca Computer Use.") + // Why: agents retry failed observations. Only the explicit setup flow + // should open macOS privacy prompts/settings; runtime calls stay quiet. + throw ProviderError.coded( + "permission_denied", + "Accessibility permission is required for Orca Computer Use. Run `orca computer permissions` or open Settings > Computer Use, grant Accessibility to Orca Computer Use, then retry." + ) } let appElement = AXUIElementCreateApplication(app.pid) enableManualAccessibilityIfNeeded(appElement, app: app) @@ -480,7 +498,14 @@ final class Provider { allowRecovery: restoreWindow ) let focusedTitle = stringAttribute(focused, kAXTitleAttribute as String) ?? app.name - guard let capture = WindowCapture.resolve(candidates: windowCandidates, titleHint: focusedTitle, windowId: windowId, windowIndex: windowIndex) else { + let canCaptureScreenshot = includeScreenshot && screenCaptureTrusted() + guard let capture = WindowCapture.resolve( + candidates: windowCandidates, + titleHint: focusedTitle, + windowId: windowId, + windowIndex: windowIndex, + captureImage: canCaptureScreenshot + ) else { throw ProviderError.coded("window_not_found", "app '\(app.name)' has no on-screen window") } guard let window = matchingWindow(appElement: appElement, capture: capture, focused: focused, explicitTarget: windowId != nil || windowIndex != nil) else { @@ -492,7 +517,7 @@ final class Provider { let screenshot = includeScreenshot ? capture.screenshotPayload() : nil let screenshotStatus: ScreenshotStatus = if screenshot != nil { .captured - } else if includeScreenshot && !screenCaptureTrusted() { + } else if includeScreenshot && !canCaptureScreenshot { .failed("Screen Recording permission is required for Orca Computer Use; grant permission or pass --no-screenshot to inspect accessibility state only.") } else if includeScreenshot { .failed("window screenshot capture returned no image; retry with --no-screenshot if accessibility state is sufficient.") @@ -817,11 +842,6 @@ private func accessibilityTrusted() -> Bool { AXIsProcessTrusted() } -private func promptForAccessibility() -> Bool { - let options = ["AXTrustedCheckOptionPrompt": true] as CFDictionary - return AXIsProcessTrustedWithOptions(options) -} - private func screenCaptureTrusted() -> Bool { CGPreflightScreenCaptureAccess() } @@ -1628,19 +1648,37 @@ private struct WindowCapture { let title: String? let image: CapturedImage? - static func resolve(pid: pid_t, titleHint: String?, windowId: CGWindowID?, windowIndex: Int?) -> WindowCapture? { - resolve(candidates: candidates(pid: pid), titleHint: titleHint, windowId: windowId, windowIndex: windowIndex) + static func resolve( + pid: pid_t, + titleHint: String?, + windowId: CGWindowID?, + windowIndex: Int?, + captureImage: Bool + ) -> WindowCapture? { + resolve( + candidates: candidates(pid: pid), + titleHint: titleHint, + windowId: windowId, + windowIndex: windowIndex, + captureImage: captureImage + ) } - static func resolve(candidates: [WindowCandidate], titleHint: String?, windowId: CGWindowID?, windowIndex: Int?) -> WindowCapture? { + static func resolve( + candidates: [WindowCandidate], + titleHint: String?, + windowId: CGWindowID?, + windowIndex: Int?, + captureImage: Bool + ) -> WindowCapture? { if let windowId { guard let candidate = candidates.first(where: { $0.windowId == windowId }) else { return nil } - return WindowCapture(candidate: candidate) + return WindowCapture(candidate: candidate, captureImage: captureImage) } if let windowIndex { let visibleWindows = candidates.filter { $0.layer == 0 } guard visibleWindows.indices.contains(windowIndex) else { return nil } - return WindowCapture(candidate: visibleWindows[windowIndex]) + return WindowCapture(candidate: visibleWindows[windowIndex], captureImage: captureImage) } guard let best = candidates.sorted(by: { lhs, rhs in if let titleHint, lhs.title == titleHint, rhs.title != titleHint { return true } @@ -1649,15 +1687,21 @@ private struct WindowCapture { }).first else { return nil } - return WindowCapture(candidate: best) + return WindowCapture(candidate: best, captureImage: captureImage) } - private init(candidate: WindowCandidate) { + private init(candidate: WindowCandidate, captureImage: Bool) { self.windowId = candidate.windowId self.layer = candidate.layer self.bounds = candidate.bounds self.title = candidate.title - self.image = Self.captureImage(windowId: candidate.windowId, bounds: candidate.bounds) + // Why: probing image APIs before TCC preflight can raise Screen + // Recording prompts, even for --no-screenshot calls. + if captureImage { + self.image = Self.captureImage(windowId: candidate.windowId, bounds: candidate.bounds) + } else { + self.image = nil + } } static func candidates(pid: pid_t) -> [WindowCandidate] { diff --git a/native/computer-use-windows/runtime.ps1 b/native/computer-use-windows/runtime.ps1 index f4b74e8183f..77e03d22941 100644 --- a/native/computer-use-windows/runtime.ps1 +++ b/native/computer-use-windows/runtime.ps1 @@ -62,6 +62,10 @@ public static class OrcaDesktopWin32 { $MaxNodes = 1200 $MaxDepth = 64 $TextLimit = 500 +$MaxScreenshotPngBytes = 900000 +$MaxScreenshotEdge = 1280 +$MinScreenshotScale = 0.25 +$ScreenshotScaleStep = 0.85 $BlockedAppFragments = @( "1password", "bitwarden", @@ -452,23 +456,103 @@ function Render-OrcaTree($RootElement, $WindowFrame) { [pscustomobject]@{ elements = @($records.ToArray()); lines = @($lines.ToArray()); truncation = $truncation } } +function ConvertTo-OrcaPngBytes([System.Drawing.Image]$Image) { + $stream = $null + try { + $stream = New-Object System.IO.MemoryStream + $Image.Save($stream, [System.Drawing.Imaging.ImageFormat]::Png) + return ,$stream.ToArray() + } finally { + if ($null -ne $stream) { $stream.Dispose() } + } +} + +function New-OrcaScreenshotPayload([byte[]]$Bytes, [int]$Width, [int]$Height, [double]$Scale) { + [pscustomobject]@{ + base64 = [Convert]::ToBase64String($Bytes) + width = $Width + height = $Height + scale = $Scale + } +} + +function Resize-OrcaBitmap([System.Drawing.Bitmap]$Source, [int]$Width, [int]$Height) { + $resized = $null + $graphics = $null + try { + $resized = New-Object System.Drawing.Bitmap $Width, $Height + $graphics = [System.Drawing.Graphics]::FromImage($resized) + $graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::Bilinear + $graphics.DrawImage($Source, 0, 0, $Width, $Height) + $result = $resized + $resized = $null + return $result + } finally { + if ($null -ne $graphics) { $graphics.Dispose() } + if ($null -ne $resized) { $resized.Dispose() } + } +} + +function Get-OrcaBoundedScreenshotPayload([System.Drawing.Bitmap]$Bitmap) { + $originalWidth = [int][Math]::Max(1, $Bitmap.Width) + $originalHeight = [int][Math]::Max(1, $Bitmap.Height) + $pngBytes = ConvertTo-OrcaPngBytes $Bitmap + if ($pngBytes.Length -le $MaxScreenshotPngBytes) { + return New-OrcaScreenshotPayload $pngBytes $originalWidth $originalHeight 1.0 + } + + # Why: screenshots cross process boundaries as PNG base64 in JSON; cap noisy + # large-window payloads to match the macOS provider's memory bounds. + $bestBytes = $pngBytes + $bestWidth = $originalWidth + $bestHeight = $originalHeight + $scale = [Math]::Min(1.0, $MaxScreenshotEdge / [double][Math]::Max($originalWidth, $originalHeight)) + while ($scale -ge $MinScreenshotScale) { + $width = [int][Math]::Max(1, [Math]::Round($originalWidth * $scale)) + $height = [int][Math]::Max(1, [Math]::Round($originalHeight * $scale)) + if ($width -eq $bestWidth -and $height -eq $bestHeight) { + $scale *= $ScreenshotScaleStep + continue + } + + $resized = $null + try { + $resized = Resize-OrcaBitmap $Bitmap $width $height + $candidateBytes = ConvertTo-OrcaPngBytes $resized + if ($candidateBytes.Length -le $MaxScreenshotPngBytes) { + return New-OrcaScreenshotPayload $candidateBytes $width $height ($width / [double]$originalWidth) + } + if ($candidateBytes.Length -lt $bestBytes.Length) { + $bestBytes = $candidateBytes + $bestWidth = $width + $bestHeight = $height + } + } finally { + if ($null -ne $resized) { $resized.Dispose() } + } + + $scale *= $ScreenshotScaleStep + } + + New-OrcaScreenshotPayload $bestBytes $bestWidth $bestHeight ($bestWidth / [double]$originalWidth) +} + function Get-OrcaScreenshot([bool]$IncludeScreenshot, $WindowFrame) { if (-not $IncludeScreenshot -or $null -eq $WindowFrame) { return $null } + $bitmap = $null + $graphics = $null try { $width = [int][Math]::Max(1, [Math]::Round($WindowFrame.width)) $height = [int][Math]::Max(1, [Math]::Round($WindowFrame.height)) $bitmap = New-Object System.Drawing.Bitmap $width, $height $graphics = [System.Drawing.Graphics]::FromImage($bitmap) $graphics.CopyFromScreen([int][Math]::Round($WindowFrame.x), [int][Math]::Round($WindowFrame.y), 0, 0, $bitmap.Size) - $stream = New-Object System.IO.MemoryStream - $bitmap.Save($stream, [System.Drawing.Imaging.ImageFormat]::Png) - $bytes = $stream.ToArray() - $graphics.Dispose() - $bitmap.Dispose() - $stream.Dispose() - [Convert]::ToBase64String($bytes) + Get-OrcaBoundedScreenshotPayload $bitmap } catch { $null + } finally { + if ($null -ne $graphics) { $graphics.Dispose() } + if ($null -ne $bitmap) { $bitmap.Dispose() } } } @@ -479,6 +563,7 @@ function New-OrcaSnapshot([string]$Query, [bool]$IncludeScreenshot, $WindowId = $root = Get-OrcaRootElement $process $windowFrame = Get-OrcaWindowFrame $process $root $tree = Render-OrcaTree $root $windowFrame + $screenshot = Get-OrcaScreenshot $IncludeScreenshot $windowFrame [pscustomobject]@{ snapshotId = [guid]::NewGuid().ToString() @@ -486,7 +571,10 @@ function New-OrcaSnapshot([string]$Query, [bool]$IncludeScreenshot, $WindowId = windowTitle = $process.MainWindowTitle windowId = Get-OrcaWindowId $process windowBounds = $windowFrame - screenshotPngBase64 = Get-OrcaScreenshot $IncludeScreenshot $windowFrame + screenshotPngBase64 = if ($null -ne $screenshot) { $screenshot.base64 } else { $null } + screenshotWidth = if ($null -ne $screenshot) { $screenshot.width } else { $null } + screenshotHeight = if ($null -ne $screenshot) { $screenshot.height } else { $null } + screenshotScale = if ($null -ne $screenshot) { $screenshot.scale } else { $null } coordinateSpace = "window" truncation = $tree.truncation treeLines = @($tree.lines) diff --git a/package.json b/package.json index 08f85453ded..459eb252513 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,18 @@ { "name": "orca", - "version": "1.4.36-rc.4", + "version": "1.4.36-rc.11", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", "bin": { "orca": "./out/cli/index.js", - "orca-dev": "./config/scripts/orca-dev" + "orca-dev": "./config/scripts/orca-dev.mjs" }, "main": "./out/main/index.js", "scripts": { "format": "oxfmt --write .", - "lint": "oxlint && node config/scripts/check-styled-scrollbars.mjs", + "lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs", + "lint:switch-exhaustiveness": "oxlint --type-aware --config config/oxlint-switch-exhaustiveness.json src/main src/preload src/shared src/relay src/cli src/renderer/src config tests --quiet", "prepare": "husky", "test": "node config/scripts/ensure-native-runtime.mjs --runtime=node && vitest run --config config/vitest.config.ts", "check:styled-scrollbars": "node config/scripts/check-styled-scrollbars.mjs", @@ -37,35 +38,57 @@ "dev:web": "vite --config vite.web.config.ts --host 127.0.0.1", "build:relay": "node config/scripts/build-relay.mjs", "build:computer-macos": "node config/scripts/build-computer-macos.mjs", + "build:native": "node config/scripts/build-native-for-platform.mjs", "smoke:computer": "node config/scripts/computer-use-smoke.mjs", "verify:computer-native": "node config/scripts/verify-computer-native.mjs", - "build:cli": "tsc -p config/tsconfig.cli.json --outDir out --composite false --incremental false && node config/scripts/install-dev-cli.mjs", + "verify:cli-bin": "node config/scripts/verify-cli-bin.mjs", + "build:cli": "tsc -p config/tsconfig.cli.json --outDir out --composite false --incremental false && node config/scripts/verify-cli-bin.mjs --fix-executable && node config/scripts/install-dev-cli.mjs", "build:electron-vite": "node config/scripts/run-electron-vite-build.mjs", "build:web": "vite build --config vite.web.config.ts && node config/scripts/verify-web-build.mjs", - "build": "pnpm run typecheck && pnpm run build:relay && pnpm run build:computer-macos && pnpm run build:cli && pnpm run build:electron-vite && pnpm run build:web", - "build:release": "pnpm run build:relay && pnpm run build:computer-macos && pnpm run verify:computer-native && pnpm run build:cli && pnpm run build:electron-vite && pnpm run build:web", + "build:desktop": "pnpm run typecheck && pnpm run build:relay && pnpm run build:cli && pnpm run build:electron-vite && pnpm run build:web", + "build": "pnpm run build:desktop && pnpm run build:native", + "build:release": "pnpm run build:relay && pnpm run build:native && pnpm run verify:computer-native && pnpm run build:cli && pnpm run build:electron-vite && pnpm run build:web", "postinstall": "node config/scripts/rebuild-native-deps.mjs", "rebuild:electron": "node config/scripts/rebuild-native-deps.mjs", - "rebuild:node": "pnpm rebuild better-sqlite3 node-pty", + "rebuild:node": "pnpm rebuild node-pty", "build:unpack": "pnpm run build && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --dir", - "build:win": "pnpm run build && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --win", + "build:win": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --win", "build:icons": "bash resources/icon-source/generate.sh", - "build:mac": "pnpm run build && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --mac", - "build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build && pnpm run ensure:electron-runtime && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac", - "build:linux": "pnpm run build && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --linux", + "build:mac": "pnpm run build:desktop && pnpm run build:computer-macos && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --mac", + "build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build:desktop && ORCA_MAC_RELEASE=1 pnpm run build:computer-macos && pnpm run ensure:electron-runtime && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac", + "build:linux": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --linux", "test:e2e": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headless", "test:e2e:headful": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headful", "test:e2e:computer": "vitest run --config tests/e2e/vitest.config.ts" }, "dependencies": { - "@dnd-kit/core": "^6.3.1", - "@dnd-kit/sortable": "^10.0.0", - "@dnd-kit/utilities": "^3.2.2", "@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/utils": "^4.0.0", "@linear/sdk": "^82.1.0", - "@monaco-editor/react": "^4.7.0", "@parcel/watcher": "^2.5.6", + "@xterm/addon-serialize": "0.15.0-beta.220", + "@xterm/headless": "6.1.0-beta.220", + "agent-browser": "~0.27.0", + "electron-updater": "^6.8.3", + "node-pty": "^1.1.0", + "posthog-node": "^5.33.3", + "qrcode": "^1.5.4", + "sherpa-onnx": "1.12.37", + "ssh2": "^1.17.0", + "tweetnacl": "^1.0.3", + "ws": "^8.20.0", + "yaml": "^2.8.4", + "zod": "~4.4.3" + }, + "devDependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@electron-toolkit/tsconfig": "^2.0.0", + "@electron/rebuild": "^4.0.4", + "@monaco-editor/react": "^4.7.0", + "@playwright/test": "^1.59.1", + "@stablyai/playwright-test": "^2.1.14", + "@tailwindcss/vite": "^4.2.4", "@tanstack/react-virtual": "^3.13.24", "@tiptap/extension-code-block-lowlight": "^3.22.5", "@tiptap/extension-details": "^3.22.5", @@ -83,35 +106,46 @@ "@tiptap/pm": "^3.22.5", "@tiptap/react": "^3.22.5", "@tiptap/starter-kit": "^3.22.5", + "@types/node": "^25.6.0", + "@types/qrcode": "^1.5.6", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@types/ssh2": "^1.15.5", + "@types/ws": "^8.18.1", + "@typescript/native-preview": "7.0.0-dev.20260421.2", + "@vitejs/plugin-react": "^5.2.0", "@xterm/addon-fit": "0.12.0-beta.220", "@xterm/addon-ligatures": "0.11.0-beta.220", "@xterm/addon-search": "0.17.0-beta.220", - "@xterm/addon-serialize": "0.15.0-beta.220", "@xterm/addon-unicode11": "0.10.0-beta.220", "@xterm/addon-web-links": "0.13.0-beta.220", "@xterm/addon-webgl": "0.20.0-beta.219", - "@xterm/headless": "6.1.0-beta.220", "@xterm/xterm": "6.1.0-beta.220", - "agent-browser": "~0.24.1", - "better-sqlite3": "^12.9.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "dompurify": "^3.4.2", - "electron-updater": "^6.8.3", + "electron": "^42.3.0", + "electron-builder": "^26.8.1", + "electron-vite": "^5.0.0", "emoji-picker-react": "^4.19.1", "html-to-image": "^1.11.13", + "husky": "^9.1.7", "katex": "^0.16.45", + "lint-staged": "^16.4.0", "lowlight": "^3.3.0", "lucide-react": "^0.577.0", "mermaid": "^11.15.0", "monaco-editor": "^0.55.1", - "node-pty": "^1.1.0", + "oxfmt": "^0.52.0", + "oxlint": "^1.67.0", + "oxlint-tsgolint": "0.23.0", "pdfjs-dist": "^5.7.284", - "posthog-node": "^5.33.3", - "qrcode": "^1.5.4", "radix-ui": "^1.4.3", + "react": "^19.2.5", "react-colorful": "^5.7.0", + "react-dom": "^19.2.5", + "react-grab": "^0.1.33", "react-markdown": "^10.1.0", "rehype-highlight": "^7.0.2", "rehype-katex": "^7.0.1", @@ -124,48 +158,15 @@ "remark-math": "^6.0.0", "remark-parse": "^11.0.0", "shadcn": "^4.7.0", - "sherpa-onnx": "1.12.37", - "simple-git": "^3.36.0", "sonner": "^2.0.7", - "ssh2": "^1.17.0", "tailwind-merge": "^3.5.0", - "tw-animate-css": "^1.4.0", - "tweetnacl": "^1.0.3", - "unified": "^11.0.5", - "ws": "^8.20.0", - "yaml": "^2.8.4", - "zod": "~4.4.3", - "zustand": "^5.0.13" - }, - "devDependencies": { - "@electron-toolkit/tsconfig": "^2.0.0", - "@electron/rebuild": "^4.0.4", - "@playwright/test": "^1.59.1", - "@stablyai/playwright-test": "^2.1.14", - "@tailwindcss/vite": "^4.2.4", - "@types/better-sqlite3": "^7.6.13", - "@types/node": "^25.6.0", - "@types/qrcode": "^1.5.6", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", - "@types/ssh2": "^1.15.5", - "@types/ws": "^8.18.1", - "@typescript/native-preview": "7.0.0-dev.20260421.2", - "@vitejs/plugin-react": "^5.2.0", - "electron": "^41.5.0", - "electron-builder": "^26.8.1", - "electron-vite": "^5.0.0", - "husky": "^9.1.7", - "lint-staged": "^16.4.0", - "oxfmt": "^0.41.0", - "oxlint": "^1.63.0", - "react": "^19.2.5", - "react-dom": "^19.2.5", - "react-grab": "^0.1.33", "tailwindcss": "^4.2.4", + "tw-animate-css": "^1.4.0", "typescript": "^5.9.3", + "unified": "^11.0.5", "vite": "^7.3.2", - "vitest": "^4.1.5" + "vitest": "^4.1.5", + "zustand": "^5.0.13" }, "optionalDependencies": { "sherpa-onnx-darwin-arm64": "1.12.37", @@ -203,7 +204,6 @@ }, "onlyBuiltDependencies": [ "@parcel/watcher", - "better-sqlite3", "cpu-features", "esbuild", "node-pty", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9be400fdf3e..3de3643bdf4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,30 +19,82 @@ importers: .: dependencies: + '@electron-toolkit/preload': + specifier: ^3.0.2 + version: 3.0.2(electron@42.3.0) + '@electron-toolkit/utils': + specifier: ^4.0.0 + version: 4.0.0(electron@42.3.0) + '@linear/sdk': + specifier: ^82.1.0 + version: 82.1.0(graphql@16.13.2) + '@parcel/watcher': + specifier: ^2.5.6 + version: 2.5.6 + '@xterm/addon-serialize': + specifier: 0.15.0-beta.220 + version: 0.15.0-beta.220(@xterm/xterm@6.1.0-beta.220) + '@xterm/headless': + specifier: 6.1.0-beta.220 + version: 6.1.0-beta.220 + agent-browser: + specifier: ~0.27.0 + version: 0.27.0 + electron-updater: + specifier: ^6.8.3 + version: 6.8.3 + node-pty: + specifier: ^1.1.0 + version: 1.1.0(patch_hash=407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282) + posthog-node: + specifier: ^5.33.3 + version: 5.33.3 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 + sherpa-onnx: + specifier: 1.12.37 + version: 1.12.37 + ssh2: + specifier: ^1.17.0 + version: 1.17.0 + tweetnacl: + specifier: ^1.0.3 + version: 1.0.3 + ws: + specifier: ^8.20.0 + version: 8.20.0 + yaml: + specifier: ^2.8.4 + version: 2.8.4 + zod: + specifier: ~4.4.3 + version: 4.4.3 + devDependencies: '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@dnd-kit/sortable': specifier: ^10.0.0 version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) - '@dnd-kit/utilities': - specifier: ^3.2.2 - version: 3.2.2(react@19.2.5) - '@electron-toolkit/preload': - specifier: ^3.0.2 - version: 3.0.2(electron@41.5.0) - '@electron-toolkit/utils': - specifier: ^4.0.0 - version: 4.0.0(electron@41.5.0) - '@linear/sdk': - specifier: ^82.1.0 - version: 82.1.0(graphql@16.13.2) + '@electron-toolkit/tsconfig': + specifier: ^2.0.0 + version: 2.0.0(@types/node@25.6.0) + '@electron/rebuild': + specifier: ^4.0.4 + version: 4.0.4 '@monaco-editor/react': specifier: ^4.7.0 version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@parcel/watcher': - specifier: ^2.5.6 - version: 2.5.6 + '@playwright/test': + specifier: ^1.59.1 + version: 1.59.1 + '@stablyai/playwright-test': + specifier: ^2.1.14 + version: 2.1.14(@playwright/test@1.59.1)(zod@4.4.3) + '@tailwindcss/vite': + specifier: ^4.2.4 + version: 4.2.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) '@tanstack/react-virtual': specifier: ^3.13.24 version: 3.13.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -94,6 +146,30 @@ importers: '@tiptap/starter-kit': specifier: ^3.22.5 version: 3.22.5 + '@types/node': + specifier: ^25.6.0 + version: 25.6.0 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + '@types/ssh2': + specifier: ^1.15.5 + version: 1.15.5 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 + '@typescript/native-preview': + specifier: 7.0.0-dev.20260421.2 + version: 7.0.0-dev.20260421.2 + '@vitejs/plugin-react': + specifier: ^5.2.0 + version: 5.2.0(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) '@xterm/addon-fit': specifier: 0.12.0-beta.220 version: 0.12.0-beta.220(@xterm/xterm@6.1.0-beta.220) @@ -103,9 +179,6 @@ importers: '@xterm/addon-search': specifier: 0.17.0-beta.220 version: 0.17.0-beta.220(@xterm/xterm@6.1.0-beta.220) - '@xterm/addon-serialize': - specifier: 0.15.0-beta.220 - version: 0.15.0-beta.220(@xterm/xterm@6.1.0-beta.220) '@xterm/addon-unicode11': specifier: 0.10.0-beta.220 version: 0.10.0-beta.220(@xterm/xterm@6.1.0-beta.220) @@ -115,18 +188,9 @@ importers: '@xterm/addon-webgl': specifier: 0.20.0-beta.219 version: 0.20.0-beta.219(patch_hash=b7465b48cca8d596e3bf0e441f939100c95adaddef13e85df089ed0ad6f6b1ea)(@xterm/xterm@6.1.0-beta.220) - '@xterm/headless': - specifier: 6.1.0-beta.220 - version: 6.1.0-beta.220 '@xterm/xterm': specifier: 6.1.0-beta.220 version: 6.1.0-beta.220 - agent-browser: - specifier: ~0.24.1 - version: 0.24.1 - better-sqlite3: - specifier: ^12.9.0 - version: 12.9.0 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -139,18 +203,30 @@ importers: dompurify: specifier: ^3.4.2 version: 3.4.5 - electron-updater: - specifier: ^6.8.3 - version: 6.8.3 + electron: + specifier: ^42.3.0 + version: 42.3.0 + electron-builder: + specifier: ^26.8.1 + version: 26.8.1(electron-builder-squirrel-windows@26.8.1) + electron-vite: + specifier: ^5.0.0 + version: 5.0.0(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) emoji-picker-react: specifier: ^4.19.1 version: 4.19.1(react@19.2.5) html-to-image: specifier: ^1.11.13 version: 1.11.13 + husky: + specifier: ^9.1.7 + version: 9.1.7 katex: specifier: ^0.16.45 version: 0.16.45 + lint-staged: + specifier: ^16.4.0 + version: 16.4.0 lowlight: specifier: ^3.3.0 version: 3.3.0 @@ -163,24 +239,33 @@ importers: monaco-editor: specifier: ^0.55.1 version: 0.55.1 - node-pty: - specifier: ^1.1.0 - version: 1.1.0(patch_hash=407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282) + oxfmt: + specifier: ^0.52.0 + version: 0.52.0 + oxlint: + specifier: ^1.67.0 + version: 1.67.0(oxlint-tsgolint@0.23.0) + oxlint-tsgolint: + specifier: 0.23.0 + version: 0.23.0 pdfjs-dist: specifier: ^5.7.284 version: 5.7.284 - posthog-node: - specifier: ^5.33.3 - version: 5.33.3 - qrcode: - specifier: ^1.5.4 - version: 1.5.4 radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: + specifier: ^19.2.5 + version: 19.2.5 react-colorful: specifier: ^5.7.0 version: 5.7.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react-dom: + specifier: ^19.2.5 + version: 19.2.5(react@19.2.5) + react-grab: + specifier: ^0.1.33 + version: 0.1.34(react@19.2.5) react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.14)(react@19.2.5) @@ -217,127 +302,33 @@ importers: shadcn: specifier: ^4.7.0 version: 4.7.0(@types/node@25.6.0)(typescript@5.9.3) - sherpa-onnx: - specifier: 1.12.37 - version: 1.12.37 - simple-git: - specifier: ^3.36.0 - version: 3.36.0 sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - ssh2: - specifier: ^1.17.0 - version: 1.17.0 tailwind-merge: specifier: ^3.5.0 version: 3.5.0 - tw-animate-css: - specifier: ^1.4.0 - version: 1.4.0 - tweetnacl: - specifier: ^1.0.3 - version: 1.0.3 - unified: - specifier: ^11.0.5 - version: 11.0.5 - ws: - specifier: ^8.20.0 - version: 8.20.0 - yaml: - specifier: ^2.8.4 - version: 2.8.4 - zod: - specifier: ~4.4.3 - version: 4.4.3 - zustand: - specifier: ^5.0.13 - version: 5.0.13(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5)) - devDependencies: - '@electron-toolkit/tsconfig': - specifier: ^2.0.0 - version: 2.0.0(@types/node@25.6.0) - '@electron/rebuild': - specifier: ^4.0.4 - version: 4.0.4 - '@playwright/test': - specifier: ^1.59.1 - version: 1.59.1 - '@stablyai/playwright-test': - specifier: ^2.1.14 - version: 2.1.14(@playwright/test@1.59.1)(zod@4.4.3) - '@tailwindcss/vite': - specifier: ^4.2.4 - version: 4.2.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) - '@types/better-sqlite3': - specifier: ^7.6.13 - version: 7.6.13 - '@types/node': - specifier: ^25.6.0 - version: 25.6.0 - '@types/qrcode': - specifier: ^1.5.6 - version: 1.5.6 - '@types/react': - specifier: ^19.2.14 - version: 19.2.14 - '@types/react-dom': - specifier: ^19.2.3 - version: 19.2.3(@types/react@19.2.14) - '@types/ssh2': - specifier: ^1.15.5 - version: 1.15.5 - '@types/ws': - specifier: ^8.18.1 - version: 8.18.1 - '@typescript/native-preview': - specifier: 7.0.0-dev.20260421.2 - version: 7.0.0-dev.20260421.2 - '@vitejs/plugin-react': - specifier: ^5.2.0 - version: 5.2.0(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) - electron: - specifier: ^41.5.0 - version: 41.5.0 - electron-builder: - specifier: ^26.8.1 - version: 26.8.1(electron-builder-squirrel-windows@26.8.1) - electron-vite: - specifier: ^5.0.0 - version: 5.0.0(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) - husky: - specifier: ^9.1.7 - version: 9.1.7 - lint-staged: - specifier: ^16.4.0 - version: 16.4.0 - oxfmt: - specifier: ^0.41.0 - version: 0.41.0 - oxlint: - specifier: ^1.63.0 - version: 1.63.0 - react: - specifier: ^19.2.5 - version: 19.2.5 - react-dom: - specifier: ^19.2.5 - version: 19.2.5(react@19.2.5) - react-grab: - specifier: ^0.1.33 - version: 0.1.34(react@19.2.5) tailwindcss: specifier: ^4.2.4 version: 4.2.4 + tw-animate-css: + specifier: ^1.4.0 + version: 1.4.0 typescript: specifier: ^5.9.3 version: 5.9.3 + unified: + specifier: ^11.0.5 + version: 11.0.5 vite: specifier: ^7.3.2 version: 7.3.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) vitest: specifier: ^4.1.5 version: 4.1.5(@types/node@25.6.0)(msw@2.14.3(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) + zustand: + specifier: ^5.0.13 + version: 5.0.13(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5)) optionalDependencies: sherpa-onnx-darwin-arm64: specifier: 1.12.37 @@ -576,14 +567,14 @@ packages: resolution: {integrity: sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==} hasBin: true - '@electron/get@2.0.3': - resolution: {integrity: sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==} - engines: {node: '>=12'} - '@electron/get@3.1.0': resolution: {integrity: sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==} engines: {node: '>=14'} + '@electron/get@5.0.0': + resolution: {integrity: sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA==} + engines: {node: '>=22.12.0'} + '@electron/notarize@2.5.0': resolution: {integrity: sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==} engines: {node: '>= 10.0.0'} @@ -1006,12 +997,6 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@kwsites/file-exists@1.1.1': - resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} - - '@kwsites/promise-deferred@1.1.1': - resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==} - '@linear/sdk@82.1.0': resolution: {integrity: sha512-Ok7o+LqXaenx6Um58NQqjQoQanDsCgAIe9yNgpVbqRSh5APz3Ds1kZUz2vWmSNTNATFZm1zDQtEktTMga2X7UQ==} engines: {node: '>=18.x'} @@ -1162,246 +1147,276 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} - '@oxfmt/binding-android-arm-eabi@0.41.0': - resolution: {integrity: sha512-REfrqeMKGkfMP+m/ScX4f5jJBSmVNYcpoDF8vP8f8eYPDuPGZmzp56NIUsYmx3h7f6NzC6cE3gqh8GDWrJHCKw==} + '@oxfmt/binding-android-arm-eabi@0.52.0': + resolution: {integrity: sha512-17EMSJnQ9g+upVHrAUYDMfH5lvRKQ9Nvg8WtEoH72oDr1VpWz+7/o3tD97U1EToen2YAQ/68JmtDYkQUi20dfQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxfmt/binding-android-arm64@0.41.0': - resolution: {integrity: sha512-s0b1dxNgb2KomspFV2LfogC2XtSJB42POXF4bMCLJyvQmAGos4ZtjGPfQreToQEaY0FQFjz3030ggI36rF1q5g==} + '@oxfmt/binding-android-arm64@0.52.0': + resolution: {integrity: sha512-A2G1IdwGEW2lLJkIxcvuirRH1CzSl/e0NX11zTlW1gvxJThfwbI/BEoaKrTNpm7M2FchvIf6guvIQU7d5iz+OQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxfmt/binding-darwin-arm64@0.41.0': - resolution: {integrity: sha512-EGXGualADbv/ZmamE7/2DbsrYmjoPlAmHEpTL4vapLF4EfVD6fr8/uQDFnPJkUBjiSWFJZtFNsGeN1B6V3owmA==} + '@oxfmt/binding-darwin-arm64@0.52.0': + resolution: {integrity: sha512-f9+bLvOYxy7NttCLFTvQ7afmqDOWY4wIP9xdvfj5trQ1qj6f2UFAGwZESlfsMjvJNTyRpXfIlOanCI9FOvoeQA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxfmt/binding-darwin-x64@0.41.0': - resolution: {integrity: sha512-WxySJEvdQQYMmyvISH3qDpTvoS0ebnIP63IMxLLWowJyPp/AAH0hdWtlo+iGNK5y3eVfa5jZguwNaQkDKWpGSw==} + '@oxfmt/binding-darwin-x64@0.52.0': + resolution: {integrity: sha512-YSTB9sJ5nnQd/Q0ddHkgof0ZCHPAnWZT1IW2SJ8omz7CP7KluJhO1fNHrpqdxCtpztJwSs4hY1uAee35wKxxaw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxfmt/binding-freebsd-x64@0.41.0': - resolution: {integrity: sha512-Y2kzMkv3U3oyuYaR4wTfGjOTYTXiFC/hXmG0yVASKkbh02BJkvD98Ij8bIevr45hNZ0DmZEgqiXF+9buD4yMYQ==} + '@oxfmt/binding-freebsd-x64@0.52.0': + resolution: {integrity: sha512-NIrRNTTPCs4UbmVs0bxLSCDlLCtIRMJIXklNKaXa5Oj2/K1UIMBvgE8+uPVo01Io3N9HF0+GAX+aAHjUgZS7vA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxfmt/binding-linux-arm-gnueabihf@0.41.0': - resolution: {integrity: sha512-ptazDjdUyhket01IjPTT6ULS1KFuBfTUU97osTP96X5y/0oso+AgAaJzuH81oP0+XXyrWIHbRzozSAuQm4p48g==} + '@oxfmt/binding-linux-arm-gnueabihf@0.52.0': + resolution: {integrity: sha512-JXUCde8mn3GpgQouz2PXUokgy/uT1QrRJBL2s983VWcSQp62wTFYiNXgTKdeo1Jgbr0IgUnKKvzIk/YBlj/nVQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm-musleabihf@0.41.0': - resolution: {integrity: sha512-UkoL2OKxFD+56bPEBcdGn+4juTW4HRv/T6w1dIDLnvKKWr6DbarB/mtHXlADKlFiJubJz8pRkttOR7qjYR6lTA==} + '@oxfmt/binding-linux-arm-musleabihf@0.52.0': + resolution: {integrity: sha512-psbUXaRZ+V8DaXz10Qf7LSHtdtdKAmC8fxXgeU608jjzrmWK4quamZMOpl6sf+dikoFHA85uE93Q0BqxrCdQrQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm64-gnu@0.41.0': - resolution: {integrity: sha512-gofu0PuumSOHYczD8p62CPY4UF6ee+rSLZJdUXkpwxg6pILiwSDBIouPskjF/5nF3A7QZTz2O9KFNkNxxFN9tA==} + '@oxfmt/binding-linux-arm64-gnu@0.52.0': + resolution: {integrity: sha512-Jw7MgWUU9lcLCcy82updISP3EthTlfvAwR6gWNxPzqly7+fLvOi2gHQE9xXQjpqaVLm/8P+gOzlv9ODuoVlaaw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-arm64-musl@0.41.0': - resolution: {integrity: sha512-VfVZxL0+6RU86T8F8vKiDBa+iHsr8PAjQmKGBzSCAX70b6x+UOMFl+2dNihmKmUwqkCazCPfYjt6SuAPOeQJ3g==} + '@oxfmt/binding-linux-arm64-musl@0.52.0': + resolution: {integrity: sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-ppc64-gnu@0.41.0': - resolution: {integrity: sha512-bwzokz2eGvdfJbc0i+zXMJ4BBjQPqg13jyWpEEZDOrBCQ91r8KeY2Mi2kUeuMTZNFXju+jcAbAbpyJxRGla0eg==} + '@oxfmt/binding-linux-ppc64-gnu@0.52.0': + resolution: {integrity: sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-gnu@0.41.0': - resolution: {integrity: sha512-POLM//PCH9uqDeNDwWL3b3DkMmI3oI2cU6hwc2lnztD1o7dzrQs3R9nq555BZ6wI7t2lyhT9CS+CRaz5X0XqLA==} + '@oxfmt/binding-linux-riscv64-gnu@0.52.0': + resolution: {integrity: sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-musl@0.41.0': - resolution: {integrity: sha512-NNK7PzhFqLUwx/G12Xtm6scGv7UITvyGdAR5Y+TlqsG+essnuRWR4jRNODWRjzLZod0T3SayRbnkSIWMBov33w==} + '@oxfmt/binding-linux-riscv64-musl@0.52.0': + resolution: {integrity: sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-s390x-gnu@0.41.0': - resolution: {integrity: sha512-qVf/zDC5cN9eKe4qI/O/m445er1IRl6swsSl7jHkqmOSVfknwCe5JXitYjZca+V/cNJSU/xPlC5EFMabMMFDpw==} + '@oxfmt/binding-linux-s390x-gnu@0.52.0': + resolution: {integrity: sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-gnu@0.41.0': - resolution: {integrity: sha512-ojxYWu7vUb6ysYqVCPHuAPVZHAI40gfZ0PDtZAMwVmh2f0V8ExpPIKoAKr7/8sNbAXJBBpZhs2coypIo2jJX4w==} + '@oxfmt/binding-linux-x64-gnu@0.52.0': + resolution: {integrity: sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-musl@0.41.0': - resolution: {integrity: sha512-O2exZLBxoCMIv2vlvcbkdedazJPTdG0VSup+0QUCfYQtx751zCZNboX2ZUOiQ/gDTdhtXvSiot0h6GEGkOyalA==} + '@oxfmt/binding-linux-x64-musl@0.52.0': + resolution: {integrity: sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxfmt/binding-openharmony-arm64@0.41.0': - resolution: {integrity: sha512-N+31/VoL+z+NNBt8viy3I4NaIdPbiYeOnB884LKqvXldaE2dRztdPv3q5ipfZYv0RwFp7JfqS4I27K/DSHCakg==} + '@oxfmt/binding-openharmony-arm64@0.52.0': + resolution: {integrity: sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxfmt/binding-win32-arm64-msvc@0.41.0': - resolution: {integrity: sha512-Z7NAtu/RN8kjCQ1y5oDD0nTAeRswh3GJ93qwcW51srmidP7XPBmZbLlwERu1W5veCevQJtPS9xmkpcDTYsGIwQ==} + '@oxfmt/binding-win32-arm64-msvc@0.52.0': + resolution: {integrity: sha512-k2sz6gWQdMfh5HPpIS+Bw/0UEV/kaK2xuqJRrWL233sEHx9WLlsmvlPFM4HUNThkYbSN0U0vPW7LVKZWDS8hPQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxfmt/binding-win32-ia32-msvc@0.41.0': - resolution: {integrity: sha512-uNxxP3l4bJ6VyzIeRqCmBU2Q0SkCFgIhvx9/9dJ9V8t/v+jP1IBsuaLwCXGR8JPHtkj4tFp+RHtUmU2ZYAUpMA==} + '@oxfmt/binding-win32-ia32-msvc@0.52.0': + resolution: {integrity: sha512-rhke69GTcArodLHpjMTfNnvjTEBryDeZcUCKK/VjXDMtfTULl6QRh0ymX5/hbCUv2WjYm9h/QbW++q2vE15gWQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxfmt/binding-win32-x64-msvc@0.41.0': - resolution: {integrity: sha512-49ZSpbZ1noozyPapE8SUOSm3IN0Ze4b5nkO+4+7fq6oEYQQJFhE0saj5k/Gg4oewVPdjn0L3ZFeWk2Vehjcw7A==} + '@oxfmt/binding-win32-x64-msvc@0.52.0': + resolution: {integrity: sha512-q5xL7oeXkZdEtNZWBdvehJcmt+GRu9l2bK40yJs1jJXlqq+r0Hygb1rTjq+FM2o/2xyt4cufH6KRplHp3Jjsvw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@oxlint/binding-android-arm-eabi@1.63.0': - resolution: {integrity: sha512-A9xLtQt7i0OA1PoB/meog6kikXI9CdwEp7ZwQqmgnpKn3G3b1orvTDy8CQ6T7w1HvDrgWGB78PkFKcWgibcTCg==} + '@oxlint-tsgolint/darwin-arm64@0.23.0': + resolution: {integrity: sha512-gOs9PVr2wEg4ox9z0aJo+RKhhImW86YL5N6yav8BK/rgPsIrwN/igSZ+pbRr723NFvUNKde9fgMhRA6JrXAOZw==} + cpu: [arm64] + os: [darwin] + + '@oxlint-tsgolint/darwin-x64@0.23.0': + resolution: {integrity: sha512-kjJ8B+7n4tB9VJdxS5A9GdJt6/bYpzbu4lXp2uO1S3sRmCB5gDEABlGoiePNApRWaW+xqL4b4xgiE727jSLhuA==} + cpu: [x64] + os: [darwin] + + '@oxlint-tsgolint/linux-arm64@0.23.0': + resolution: {integrity: sha512-6dCZuKNu135seMXilkRk9SpCx6i1XgmiipYGalLij5WVRX6ZYS8c4xI7preN/zv9fCXhsQclTIMDu2Y/cytTjw==} + cpu: [arm64] + os: [linux] + + '@oxlint-tsgolint/linux-x64@0.23.0': + resolution: {integrity: sha512-3bdilnyA7kmSTjK27rvjIjSxL5SIg3wt7vwNiRkouWB83ytssyKnuGvxSYJxgMEmFpSutzaBzcCUM2jDtPGcgA==} + cpu: [x64] + os: [linux] + + '@oxlint-tsgolint/win32-arm64@0.23.0': + resolution: {integrity: sha512-j+OEp44SVYiQ+ZD+uttsX7u6L9SvmbbQ77SO1pSFCcJlsVMeCk8qZsjhKfGKuT/jIA+ipOJMVs/+pqUfObBWNw==} + cpu: [arm64] + os: [win32] + + '@oxlint-tsgolint/win32-x64@0.23.0': + resolution: {integrity: sha512-5MyjFuqf+g8OUPJBSGWHJtmoWnzFJYyOg4To9WMQshZYEWig/vtu7JtJ03VWnzHv9LJkAUeApY0gVCOywFR/iQ==} + cpu: [x64] + os: [win32] + + '@oxlint/binding-android-arm-eabi@1.67.0': + resolution: {integrity: sha512-VrSi571rDv1N8HaEDM+DEX8nmT0y9jJo8tzzW13vsOWTx59xQczCIJx68n2zWOXRT5YKZsOZXp4qkHN/10x4mw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxlint/binding-android-arm64@1.63.0': - resolution: {integrity: sha512-SQo+ZMvdR9l3CxZp5W5gFNxSiDxclY6lOzzNpKYLF8asESpm3Pwumx0gER5T7aHLF1/2BAAtLD3DiDkdgy4V1A==} + '@oxlint/binding-android-arm64@1.67.0': + resolution: {integrity: sha512-l6+NdYxMoRohix5r5bbigW16LPicceCwGcQ6LKKuE1kUdjgFfQolJjrJsQYPFetIs78Gxj/G/f5TEGoTCwj9nQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxlint/binding-darwin-arm64@1.63.0': - resolution: {integrity: sha512-6W82XjJDTmMnjg30427l0dufpnyLoq7wEukKdM6/g2VIybRVuQiBVh43EA4b+UxZ3+tLcKm+Or/pXGNgLCEU8g==} + '@oxlint/binding-darwin-arm64@1.67.0': + resolution: {integrity: sha512-jOzXxS1AxFxhImLIRbtGIMrEwaXcgMw3gR57WB1cRk8ai+vpr6726kxXqVvlNsrXtJ/FrmOm8RxlC0m8SW24Qg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxlint/binding-darwin-x64@1.63.0': - resolution: {integrity: sha512-CnWd/YCuVG5W1BYkjJEVbJG11o526O9qAwBEQM+nh8K19CRFUkFdROXCyYkGmroHEYQe4vgQ6+lh3550Lp35Xw==} + '@oxlint/binding-darwin-x64@1.67.0': + resolution: {integrity: sha512-3DFAVY94OqjIZHXIPz37yGRSWwOFTAqChQ64/M69GYLawzP0KiwdhDNfqdKKYT0bTR/DNxmMnQsj3ns+8+X/Lg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxlint/binding-freebsd-x64@1.63.0': - resolution: {integrity: sha512-a4eZAqrmtajqcxfdAzC+l7g3PaE3V8hpAYqqeD3fTxLXOMFdK3eNTZrU80n4dDEVm0JXy1aL5PqvqWldBl6zYA==} + '@oxlint/binding-freebsd-x64@1.67.0': + resolution: {integrity: sha512-e4dDKZuLu8TR9DEBssWSDahlPgZBwojTTHZUvnjBRJfJJbpxYCjfjKfi0Z1+CSLMiJBwI2yCDtRM1XJQaARjmg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxlint/binding-linux-arm-gnueabihf@1.63.0': - resolution: {integrity: sha512-tYUtU9TdbU3uXF5D62g5zXJ13iniFGhXQx5vp9cyEjGdbSAY3VdFBSaldYvyoDmgMZ0ZYuwQP1Y4t2Fhejwa0w==} + '@oxlint/binding-linux-arm-gnueabihf@1.67.0': + resolution: {integrity: sha512-BKytFdcQzbITV3xlnzDUDTEDtbUMCCiC4EaNTDZ4FyT8gdNvBC4gfiLucXp/sQl0XU3p7syTlorUWVVVBZab2g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-musleabihf@1.63.0': - resolution: {integrity: sha512-I5r3twFf776UZg9dmRo2xbrKt00tTkORXEVe0ctg4vdTkQvJAjiCHxnbAU2HL1AiJ9cqADA76MAliuilsAWnvg==} + '@oxlint/binding-linux-arm-musleabihf@1.67.0': + resolution: {integrity: sha512-XYAv0esBDX7BpTzRDjVX2Vdj+zndd8ll2dFQiaeQ6zTZr7A8GRDTN7fH3FP3jU+O0vCDx85oH/EtG7BzPgAXuw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm64-gnu@1.63.0': - resolution: {integrity: sha512-t7ltUkg6FFh4b564QyGir8xIj/QZbXu8FlcRkcyW9+ztr/mfRHlvUOFd95pJCXi9s/L5DrUeWWgpXRS+V+6igQ==} + '@oxlint/binding-linux-arm64-gnu@1.67.0': + resolution: {integrity: sha512-zizRMjA0i6u/2B0evgda04iycu+MoNuf1pBy6Eh+1CjC5wMEG7qN5zdDKTCvFc0KSYSDM9QTG3gjZHirgtQuKg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-arm64-musl@1.63.0': - resolution: {integrity: sha512-Q5mmZy/XWjuYFUuQyYjOvZ5U/JkKEwnpir6hGxhh6HcdP0V/BKxLo8dqkfF/t7r7AguB17dfS/8+go5AQDRR6g==} + '@oxlint/binding-linux-arm64-musl@1.67.0': + resolution: {integrity: sha512-zB/Tf6sUjmmvvbva9Gj3JTJ8rJ9t4I8/U0o6vSRtd0DRIsIuyegBwJAzhSUFQHdMijIRJkW0exs/yBhpw2S20w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxlint/binding-linux-ppc64-gnu@1.63.0': - resolution: {integrity: sha512-uBGtuZ0TzLB4x5wVa82HGNvYqY8buwDhyCnCP0R0gkk9szqVsP0MeTtD5HX7EsEuFIt+aYmYxuxeVxs3nTSwtQ==} + '@oxlint/binding-linux-ppc64-gnu@1.67.0': + resolution: {integrity: sha512-kgU40Gt74CK0TCsF51KZymkIwN9U0BajKsMijB52zPqOeZU9NAHkA/NSQkZDHEaCakx42DxhXkODiAqf2b4Gug==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-gnu@1.63.0': - resolution: {integrity: sha512-h4s6FwxE+9MeA181o0dnDwHP32Y/bG8EiB/vrD6Ib+AMt6haigDc/0bUtI/sLmQDBMJnUfaCmtSSrEAqjtEVrA==} + '@oxlint/binding-linux-riscv64-gnu@1.67.0': + resolution: {integrity: sha512-tOYhkk/iaG9aD3FvGpBFd1Lrw0x0RaVoJBxjUkfNzS50rC5NS5BteNCwgr8A2zCdADrIIoze6D7u6U5Ic++/iQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-musl@1.63.0': - resolution: {integrity: sha512-2EaNcCBR8Mcjl5ARtuN3BdEpVkX7KpjSjMGZ/mJMIeaXgTtdz5ytg2VwygMSStA/k0ixfvZFoZOfjDEcouV5vQ==} + '@oxlint/binding-linux-riscv64-musl@1.67.0': + resolution: {integrity: sha512-sEtywrPb+0b+tHYl1SDCrw903fiC4eyKoNqzP3v+f2JT3Xcv4NEYG+P8rj+eEnX7IWhqV/xj8/JmcmVj21CXaA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxlint/binding-linux-s390x-gnu@1.63.0': - resolution: {integrity: sha512-p4hlf/fd7TrYYl3QrWWD0GocqJefwMu3cHQhmi2FvEB/YOvFb5DZN3SMBaPi7B1TM5DeypkEtrVib674q1KKPg==} + '@oxlint/binding-linux-s390x-gnu@1.67.0': + resolution: {integrity: sha512-BvR8Moa0zCLxroOx4vZaZN9nUfwAUpSTwjZdxZyKy4bv3PrzrXrxKR/ZQ0L9wNSvlPhnMJeZfa3q5w6ZCTuN6Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-gnu@1.63.0': - resolution: {integrity: sha512-Vgq9rkRVcPcjbcH+ihYTfpeR7vCXfqpd+z5ItTGc0yYUV59L5ceHYN1iV4H9bKGV7Rn5hkVc7x3mSvHegduENA==} + '@oxlint/binding-linux-x64-gnu@1.67.0': + resolution: {integrity: sha512-mm2cxM6fksOpq6l0uFws8BUGKAR4dNa/cZCn37Npq7PFbhD5HDJqWfnoIvTaeRKMy5XdS2tO0MA0qbHDrnXAAA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-musl@1.63.0': - resolution: {integrity: sha512-3/Lkq/ncooA61rorrC+ZQed1Bc4VpGj+WnGsp58zmxKgvZ2vhreu+dcVyr3mX8NUpq7mfZ4gDDTou/yrF1Pd7A==} + '@oxlint/binding-linux-x64-musl@1.67.0': + resolution: {integrity: sha512-WmbMuLapKyDlobMkXAaAL0Y+Uczh4LETfIfQsUpbId4Ip8Ai82/jqeYTOoUCkuuhBFapgqP253+d83tLKOksJg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxlint/binding-openharmony-arm64@1.63.0': - resolution: {integrity: sha512-0/EdD/6hDkx5Mfd769PTjvEM8mZ/6Dfukp1dBCL/2PjlIVGEtYdNZyok6ChqYPsT9JcFnlQnUeQzO0/1L/oC9w==} + '@oxlint/binding-openharmony-arm64@1.67.0': + resolution: {integrity: sha512-9g/PqxYJelzzTAOR5Y+RiRqdeydhEuXv2KxNeFcAKQ7UsvnWSY1OP4MsuPMbTO2Pf70tz7mFhl1j13H3fyh+8g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxlint/binding-win32-arm64-msvc@1.63.0': - resolution: {integrity: sha512-wb0CUkN8ngwPiRQBjD1Cj0LsHeNvm+Xt6YBHDMtj2DVQVD6Oj8Ri7g6BD+KICf6LaBqZlmzOvy6nF9E/8yyGOg==} + '@oxlint/binding-win32-arm64-msvc@1.67.0': + resolution: {integrity: sha512-2VhwE6Gatb0vJGnN0TBuQMbKCOiZlSQ/zJvVWYLK4a9d4iDiJOen/yVQkGpmsJ90MuH66fzi0kEKI0jRQMDxGA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxlint/binding-win32-ia32-msvc@1.63.0': - resolution: {integrity: sha512-BX5iq+ovdNlVYhSn5qPMUIT0uwAwt2lmEnCnzK+Gkhw4DovIvhGb96OFhV8yzQNUnQxn/xGkOR+X+BLrLDNm8w==} + '@oxlint/binding-win32-ia32-msvc@1.67.0': + resolution: {integrity: sha512-EQ3VExXfeM1InbE5+JjufhZZTWy+kHUwgt3yZR7gQ47Je/mE0WspQPan0OJznh493L5anM210YNJtH1PXjTSFg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxlint/binding-win32-x64-msvc@1.63.0': - resolution: {integrity: sha512-QeN/WELOfsXMeYwxvfgQrl6CbVftYUCZsGXHjXQd5Trccm8+i4gmtxaOui4xbJQaiDlviF8F3yLSBloQUeFsfA==} + '@oxlint/binding-win32-x64-msvc@1.67.0': + resolution: {integrity: sha512-bw24y+/1MHS4QDkons3YyHkPT9uCMoLHHgQhb+mb8NOjTYwub1CZ+K9Ngr8aO5DMrDrkqHwTzlTwFP2vS8Y/ZQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -2365,12 +2380,6 @@ packages: '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} - '@simple-git/args-pathspec@1.0.3': - resolution: {integrity: sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA==} - - '@simple-git/argv-parser@1.1.1': - resolution: {integrity: sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw==} - '@sindresorhus/is@4.6.0': resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} @@ -2748,9 +2757,6 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} - '@types/better-sqlite3@7.6.13': - resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} - '@types/cacheable-request@6.0.3': resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} @@ -3095,8 +3101,8 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} - agent-browser@0.24.1: - resolution: {integrity: sha512-csWJtYEQow52b+p93zVZfNrcNBwbxGCZDXDMNWl2ij2i0MFKubIzN+icUeX2/NrkZe5iIau8px+HQlxata2oPw==} + agent-browser@0.27.0: + resolution: {integrity: sha512-mmHzVsYFVA6nshNNGJzg83aVMgKpf4h98ytY3pvtJB1Cot0ZyA2bfnkbSngGD56Azkj+GlhVH6qx9DfKOVE0yg==} hasBin: true ajv-formats@3.0.1: @@ -3209,21 +3215,11 @@ packages: bcrypt-pbkdf@1.0.2: resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} - better-sqlite3@12.9.0: - resolution: {integrity: sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==} - engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x} - - bindings@1.5.0: - resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} - bippy@0.5.40: resolution: {integrity: sha512-3QPSDG5tgd7FCIkcKsUqmbGdlHxBxP7II5drXPNp1I0rpA9+4+/VjQOigDTbzeqTi6Xkaf1Dq7x4E8vbOuwOkA==} peerDependencies: react: '>=17.0.1' - bl@4.1.0: - resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -3337,9 +3333,6 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} - chownr@1.1.4: - resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} - chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -3718,10 +3711,6 @@ packages: babel-plugin-macros: optional: true - deep-extend@0.6.0: - resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} - engines: {node: '>=4.0.0'} - deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} @@ -3863,9 +3852,9 @@ packages: resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} engines: {node: '>=8.0.0'} - electron@41.5.0: - resolution: {integrity: sha512-x9j9//PubUA4EjDtQbZhtk3prolandqCKgit0uCIqc1jb8FTskPbnJtxcDFB1aejczJcuERgjPixBUaMwoWyJg==} - engines: {node: '>= 12.20.55'} + electron@42.3.0: + resolution: {integrity: sha512-9ZiLdRXk+WDxW1OgIUz8J2rIQ5TYU9o629gCOjU48Q3dQiOmym7osWsH5Ubs/Jh4uuFLn6m6SBD2rmRXLAPz9g==} + engines: {node: '>= 22.12.0'} hasBin: true emoji-picker-react@4.19.1: @@ -3899,6 +3888,10 @@ packages: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} + env-paths@3.0.0: + resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} @@ -3993,10 +3986,6 @@ packages: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} - expand-template@2.0.3: - resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} - engines: {node: '>=6'} - expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -4078,9 +4067,6 @@ packages: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} - file-uri-to-path@1.0.0: - resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} - filelist@1.0.6: resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} @@ -4119,9 +4105,6 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} - fs-constants@1.0.0: - resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} - fs-extra@10.1.0: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} @@ -4205,9 +4188,6 @@ packages: resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} engines: {node: '>=18'} - github-from-package@0.0.0: - resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} - github-slugger@2.0.0: resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} @@ -4400,9 +4380,6 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - ini@1.3.8: - resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -5015,9 +4992,6 @@ packages: resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} engines: {node: '>= 18'} - mkdirp-classic@0.5.3: - resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} - mkdirp@0.5.6: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} hasBin: true @@ -5053,17 +5027,10 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - napi-build-utils@2.0.0: - resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} - negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} - node-abi@3.91.0: - resolution: {integrity: sha512-B+S7X/GS3Un6wMICtnsNjQD7oSpVBQrZftHE6GZ1Fe9/k3XOOoqbM5DZZ0GO4x3YiSCQfrM28yj1ppplwgIsfg==} - engines: {node: '>=10'} - node-abi@4.30.0: resolution: {integrity: sha512-D7v3slBz2jgsBLCY9FnFsD0nlXvuKd49Pl9L+Vj+7ZlivHsLK2mkekQo7vZwYco+kyb4OJlaWfYdr3+27vfYtA==} engines: {node: '>=22.12.0'} @@ -5170,20 +5137,35 @@ packages: outvariant@1.4.3: resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} - oxfmt@0.41.0: - resolution: {integrity: sha512-sKLdJZdQ3bw6x9qKiT7+eID4MNEXlDHf5ZacfIircrq6Qwjk0L6t2/JQlZZrVHTXJawK3KaMuBoJnEJPcqCEdg==} + oxfmt@0.52.0: + resolution: {integrity: sha512-nJlYM35F64zTDMecCNhoHNkf+D/eHv7xcjj9XDSj+bFAVtN93m7v8DQMdHd6nDG6Akf/kEYYHmDUBs2Dz27Sug==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + peerDependencies: + svelte: ^5.0.0 + vite-plus: '*' + peerDependenciesMeta: + svelte: + optional: true + vite-plus: + optional: true - oxlint@1.63.0: - resolution: {integrity: sha512-9TGXetdjgIHOJ9OiReomP7nnrMkV9HxC1xM2ramJSLQpzxjsAJtQwa4wqkJN2f/uCrqZuJseFuSlWDdvcruveg==} + oxlint-tsgolint@0.23.0: + resolution: {integrity: sha512-3mBv3CoPbh8dFbzfDGIWa2ytZjn2v+3EX4aKRXjIhsoGFzG8GCjfRirz3rwZf1wYbZzsNLTSgpw8VjQuWdp/jA==} + hasBin: true + + oxlint@1.67.0: + resolution: {integrity: sha512-blwwaHPdoH8piQ5/z0KHeoHFR7FZgl12WluKJfu4qFLPkZl6mK04PkLE45Fw1NxfBRSlh40Gu7MkxHUw++ociQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: oxlint-tsgolint: '>=0.22.1' + vite-plus: '*' peerDependenciesMeta: oxlint-tsgolint: optional: true + vite-plus: + optional: true p-cancelable@2.1.1: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} @@ -5352,12 +5334,6 @@ packages: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} - prebuild-install@7.1.3: - resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} - engines: {node: '>=10'} - deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. - hasBin: true - pretty-ms@9.3.0: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} @@ -5468,10 +5444,6 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} - rc@1.2.8: - resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} - hasBin: true - react-colorful@5.7.0: resolution: {integrity: sha512-fuesYIemttah97XmsIHmz4OORDHiSFzyc9HMAIrCHJou2jaRQmL8cFJ76K4zQhhj8jzwOBlOi4BaGTjjOZCfTg==} peerDependencies: @@ -5540,10 +5512,6 @@ packages: resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==} hasBin: true - readable-stream@3.6.2: - resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} - engines: {node: '>= 6'} - recast@0.23.11: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} @@ -5668,9 +5636,6 @@ packages: rw@1.3.3: resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -5787,15 +5752,6 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} - simple-concat@1.0.1: - resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} - - simple-get@4.0.1: - resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} - - simple-git@3.36.0: - resolution: {integrity: sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q==} - simple-update-notifier@2.0.0: resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} engines: {node: '>=10'} @@ -5894,9 +5850,6 @@ packages: resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==} engines: {node: '>=20'} - string_decoder@1.3.0: - resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -5924,10 +5877,6 @@ packages: resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} engines: {node: '>=18'} - strip-json-comments@2.0.1: - resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} - engines: {node: '>=0.10.0'} - style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -5959,13 +5908,6 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} - tar-fs@2.1.4: - resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} - - tar-stream@2.2.0: - resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} - engines: {node: '>=6'} - tar@7.5.14: resolution: {integrity: sha512-/7sHKgQO3JLP9ESlwTYUUftHUadOURUqq23xs1vjcnp8Vss6k0wCfzulyEtk5g91pjvnuriimGlyG7k6msrzRw==} engines: {node: '>=18'} @@ -6057,9 +5999,6 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tunnel-agent@0.6.0: - resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} - tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} @@ -6102,6 +6041,10 @@ packages: resolution: {integrity: sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==} engines: {node: '>=18.17'} + undici@7.26.0: + resolution: {integrity: sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==} + engines: {node: '>=20.18.1'} + unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} @@ -6710,17 +6653,17 @@ snapshots: dependencies: '@noble/ciphers': 1.3.0 - '@electron-toolkit/preload@3.0.2(electron@41.5.0)': + '@electron-toolkit/preload@3.0.2(electron@42.3.0)': dependencies: - electron: 41.5.0 + electron: 42.3.0 '@electron-toolkit/tsconfig@2.0.0(@types/node@25.6.0)': dependencies: '@types/node': 25.6.0 - '@electron-toolkit/utils@4.0.0(electron@41.5.0)': + '@electron-toolkit/utils@4.0.0(electron@42.3.0)': dependencies: - electron: 41.5.0 + electron: 42.3.0 '@electron/asar@3.4.1': dependencies: @@ -6734,7 +6677,7 @@ snapshots: fs-extra: 9.1.0 minimist: 1.2.8 - '@electron/get@2.0.3': + '@electron/get@3.1.0': dependencies: debug: 4.4.3 env-paths: 2.2.1 @@ -6748,17 +6691,16 @@ snapshots: transitivePeerDependencies: - supports-color - '@electron/get@3.1.0': + '@electron/get@5.0.0': dependencies: debug: 4.4.3 - env-paths: 2.2.1 - fs-extra: 8.1.0 - got: 11.8.6 + env-paths: 3.0.0 + graceful-fs: 4.2.11 progress: 2.0.3 - semver: 6.3.1 + semver: 7.7.4 sumchecker: 3.0.1 optionalDependencies: - global-agent: 3.0.0 + undici: 7.26.0 transitivePeerDependencies: - supports-color @@ -7054,14 +6996,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@kwsites/file-exists@1.1.1': - dependencies: - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - '@kwsites/promise-deferred@1.1.1': {} - '@linear/sdk@82.1.0(graphql@16.13.2)': dependencies: '@graphql-typed-document-node/core': 3.2.0(graphql@16.13.2) @@ -7206,118 +7140,136 @@ snapshots: '@open-draft/until@2.1.0': {} - '@oxfmt/binding-android-arm-eabi@0.41.0': + '@oxfmt/binding-android-arm-eabi@0.52.0': optional: true - '@oxfmt/binding-android-arm64@0.41.0': + '@oxfmt/binding-android-arm64@0.52.0': optional: true - '@oxfmt/binding-darwin-arm64@0.41.0': + '@oxfmt/binding-darwin-arm64@0.52.0': optional: true - '@oxfmt/binding-darwin-x64@0.41.0': + '@oxfmt/binding-darwin-x64@0.52.0': optional: true - '@oxfmt/binding-freebsd-x64@0.41.0': + '@oxfmt/binding-freebsd-x64@0.52.0': optional: true - '@oxfmt/binding-linux-arm-gnueabihf@0.41.0': + '@oxfmt/binding-linux-arm-gnueabihf@0.52.0': optional: true - '@oxfmt/binding-linux-arm-musleabihf@0.41.0': + '@oxfmt/binding-linux-arm-musleabihf@0.52.0': optional: true - '@oxfmt/binding-linux-arm64-gnu@0.41.0': + '@oxfmt/binding-linux-arm64-gnu@0.52.0': optional: true - '@oxfmt/binding-linux-arm64-musl@0.41.0': + '@oxfmt/binding-linux-arm64-musl@0.52.0': optional: true - '@oxfmt/binding-linux-ppc64-gnu@0.41.0': + '@oxfmt/binding-linux-ppc64-gnu@0.52.0': optional: true - '@oxfmt/binding-linux-riscv64-gnu@0.41.0': + '@oxfmt/binding-linux-riscv64-gnu@0.52.0': optional: true - '@oxfmt/binding-linux-riscv64-musl@0.41.0': + '@oxfmt/binding-linux-riscv64-musl@0.52.0': optional: true - '@oxfmt/binding-linux-s390x-gnu@0.41.0': + '@oxfmt/binding-linux-s390x-gnu@0.52.0': optional: true - '@oxfmt/binding-linux-x64-gnu@0.41.0': + '@oxfmt/binding-linux-x64-gnu@0.52.0': optional: true - '@oxfmt/binding-linux-x64-musl@0.41.0': + '@oxfmt/binding-linux-x64-musl@0.52.0': optional: true - '@oxfmt/binding-openharmony-arm64@0.41.0': + '@oxfmt/binding-openharmony-arm64@0.52.0': optional: true - '@oxfmt/binding-win32-arm64-msvc@0.41.0': + '@oxfmt/binding-win32-arm64-msvc@0.52.0': optional: true - '@oxfmt/binding-win32-ia32-msvc@0.41.0': + '@oxfmt/binding-win32-ia32-msvc@0.52.0': optional: true - '@oxfmt/binding-win32-x64-msvc@0.41.0': + '@oxfmt/binding-win32-x64-msvc@0.52.0': optional: true - '@oxlint/binding-android-arm-eabi@1.63.0': + '@oxlint-tsgolint/darwin-arm64@0.23.0': optional: true - '@oxlint/binding-android-arm64@1.63.0': + '@oxlint-tsgolint/darwin-x64@0.23.0': optional: true - '@oxlint/binding-darwin-arm64@1.63.0': + '@oxlint-tsgolint/linux-arm64@0.23.0': optional: true - '@oxlint/binding-darwin-x64@1.63.0': + '@oxlint-tsgolint/linux-x64@0.23.0': optional: true - '@oxlint/binding-freebsd-x64@1.63.0': + '@oxlint-tsgolint/win32-arm64@0.23.0': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.63.0': + '@oxlint-tsgolint/win32-x64@0.23.0': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.63.0': + '@oxlint/binding-android-arm-eabi@1.67.0': optional: true - '@oxlint/binding-linux-arm64-gnu@1.63.0': + '@oxlint/binding-android-arm64@1.67.0': optional: true - '@oxlint/binding-linux-arm64-musl@1.63.0': + '@oxlint/binding-darwin-arm64@1.67.0': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.63.0': + '@oxlint/binding-darwin-x64@1.67.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.63.0': + '@oxlint/binding-freebsd-x64@1.67.0': optional: true - '@oxlint/binding-linux-riscv64-musl@1.63.0': + '@oxlint/binding-linux-arm-gnueabihf@1.67.0': optional: true - '@oxlint/binding-linux-s390x-gnu@1.63.0': + '@oxlint/binding-linux-arm-musleabihf@1.67.0': optional: true - '@oxlint/binding-linux-x64-gnu@1.63.0': + '@oxlint/binding-linux-arm64-gnu@1.67.0': optional: true - '@oxlint/binding-linux-x64-musl@1.63.0': + '@oxlint/binding-linux-arm64-musl@1.67.0': optional: true - '@oxlint/binding-openharmony-arm64@1.63.0': + '@oxlint/binding-linux-ppc64-gnu@1.67.0': optional: true - '@oxlint/binding-win32-arm64-msvc@1.63.0': + '@oxlint/binding-linux-riscv64-gnu@1.67.0': optional: true - '@oxlint/binding-win32-ia32-msvc@1.63.0': + '@oxlint/binding-linux-riscv64-musl@1.67.0': optional: true - '@oxlint/binding-win32-x64-msvc@1.63.0': + '@oxlint/binding-linux-s390x-gnu@1.67.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.67.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.67.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.67.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.67.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.67.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.67.0': optional: true '@parcel/watcher-android-arm64@2.5.6': @@ -8244,12 +8196,6 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} - '@simple-git/args-pathspec@1.0.3': {} - - '@simple-git/argv-parser@1.1.1': - dependencies: - '@simple-git/args-pathspec': 1.0.3 - '@sindresorhus/is@4.6.0': {} '@sindresorhus/merge-streams@4.0.0': {} @@ -8621,10 +8567,6 @@ snapshots: dependencies: '@babel/types': 7.29.0 - '@types/better-sqlite3@7.6.13': - dependencies: - '@types/node': 25.6.0 - '@types/cacheable-request@6.0.3': dependencies: '@types/http-cache-semantics': 4.2.0 @@ -9001,7 +8943,7 @@ snapshots: agent-base@7.1.4: {} - agent-browser@0.24.1: {} + agent-browser@0.27.0: {} ajv-formats@3.0.1(ajv@8.20.0): optionalDependencies: @@ -9128,25 +9070,10 @@ snapshots: dependencies: tweetnacl: 0.14.5 - better-sqlite3@12.9.0: - dependencies: - bindings: 1.5.0 - prebuild-install: 7.1.3 - - bindings@1.5.0: - dependencies: - file-uri-to-path: 1.0.0 - bippy@0.5.40(react@19.2.5): dependencies: react: 19.2.5 - bl@4.1.0: - dependencies: - buffer: 5.7.1 - inherits: 2.0.4 - readable-stream: 3.6.2 - body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -9197,6 +9124,7 @@ snapshots: dependencies: base64-js: 1.5.1 ieee754: 1.2.1 + optional: true buildcheck@0.0.7: optional: true @@ -9284,8 +9212,6 @@ snapshots: character-reference-invalid@2.0.1: {} - chownr@1.1.4: {} - chownr@3.0.0: {} chromium-pickle-js@0.2.0: {} @@ -9651,8 +9577,6 @@ snapshots: dedent@1.7.2: {} - deep-extend@0.6.0: {} - deepmerge@4.3.1: {} default-browser-id@5.0.1: {} @@ -9847,9 +9771,9 @@ snapshots: transitivePeerDependencies: - supports-color - electron@41.5.0: + electron@42.3.0: dependencies: - '@electron/get': 2.0.3 + '@electron/get': 5.0.0 '@types/node': 24.12.2 extract-zip: 2.0.1 transitivePeerDependencies: @@ -9879,6 +9803,8 @@ snapshots: env-paths@2.2.1: {} + env-paths@3.0.0: {} + environment@1.1.0: {} err-code@2.0.3: {} @@ -10021,8 +9947,6 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 - expand-template@2.0.3: {} - expect-type@1.3.0: {} exponential-backoff@3.1.3: {} @@ -10131,8 +10055,6 @@ snapshots: dependencies: is-unicode-supported: 2.1.0 - file-uri-to-path@1.0.0: {} - filelist@1.0.6: dependencies: minimatch: 5.1.9 @@ -10177,8 +10099,6 @@ snapshots: fresh@2.0.0: {} - fs-constants@1.0.0: {} - fs-extra@10.1.0: dependencies: graceful-fs: 4.2.11 @@ -10267,8 +10187,6 @@ snapshots: '@sec-ant/readable-stream': 0.4.1 is-stream: 4.0.1 - github-from-package@0.0.0: {} - github-slugger@2.0.0: {} glob-parent@5.1.2: @@ -10527,7 +10445,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 - ieee754@1.2.1: {} + ieee754@1.2.1: + optional: true ignore@5.3.2: {} @@ -10545,8 +10464,6 @@ snapshots: inherits@2.0.4: {} - ini@1.3.8: {} - inline-style-parser@0.2.7: {} internmap@1.0.1: {} @@ -11307,8 +11224,6 @@ snapshots: dependencies: minipass: 7.1.3 - mkdirp-classic@0.5.3: {} - mkdirp@0.5.6: dependencies: minimist: 1.2.8 @@ -11359,14 +11274,8 @@ snapshots: nanoid@3.3.12: {} - napi-build-utils@2.0.0: {} - negotiator@1.0.0: {} - node-abi@3.91.0: - dependencies: - semver: 7.7.4 - node-abi@4.30.0: dependencies: semver: 7.7.4 @@ -11489,51 +11398,61 @@ snapshots: outvariant@1.4.3: {} - oxfmt@0.41.0: + oxfmt@0.52.0: dependencies: tinypool: 2.1.0 optionalDependencies: - '@oxfmt/binding-android-arm-eabi': 0.41.0 - '@oxfmt/binding-android-arm64': 0.41.0 - '@oxfmt/binding-darwin-arm64': 0.41.0 - '@oxfmt/binding-darwin-x64': 0.41.0 - '@oxfmt/binding-freebsd-x64': 0.41.0 - '@oxfmt/binding-linux-arm-gnueabihf': 0.41.0 - '@oxfmt/binding-linux-arm-musleabihf': 0.41.0 - '@oxfmt/binding-linux-arm64-gnu': 0.41.0 - '@oxfmt/binding-linux-arm64-musl': 0.41.0 - '@oxfmt/binding-linux-ppc64-gnu': 0.41.0 - '@oxfmt/binding-linux-riscv64-gnu': 0.41.0 - '@oxfmt/binding-linux-riscv64-musl': 0.41.0 - '@oxfmt/binding-linux-s390x-gnu': 0.41.0 - '@oxfmt/binding-linux-x64-gnu': 0.41.0 - '@oxfmt/binding-linux-x64-musl': 0.41.0 - '@oxfmt/binding-openharmony-arm64': 0.41.0 - '@oxfmt/binding-win32-arm64-msvc': 0.41.0 - '@oxfmt/binding-win32-ia32-msvc': 0.41.0 - '@oxfmt/binding-win32-x64-msvc': 0.41.0 + '@oxfmt/binding-android-arm-eabi': 0.52.0 + '@oxfmt/binding-android-arm64': 0.52.0 + '@oxfmt/binding-darwin-arm64': 0.52.0 + '@oxfmt/binding-darwin-x64': 0.52.0 + '@oxfmt/binding-freebsd-x64': 0.52.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.52.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.52.0 + '@oxfmt/binding-linux-arm64-gnu': 0.52.0 + '@oxfmt/binding-linux-arm64-musl': 0.52.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.52.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.52.0 + '@oxfmt/binding-linux-riscv64-musl': 0.52.0 + '@oxfmt/binding-linux-s390x-gnu': 0.52.0 + '@oxfmt/binding-linux-x64-gnu': 0.52.0 + '@oxfmt/binding-linux-x64-musl': 0.52.0 + '@oxfmt/binding-openharmony-arm64': 0.52.0 + '@oxfmt/binding-win32-arm64-msvc': 0.52.0 + '@oxfmt/binding-win32-ia32-msvc': 0.52.0 + '@oxfmt/binding-win32-x64-msvc': 0.52.0 - oxlint@1.63.0: + oxlint-tsgolint@0.23.0: optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.63.0 - '@oxlint/binding-android-arm64': 1.63.0 - '@oxlint/binding-darwin-arm64': 1.63.0 - '@oxlint/binding-darwin-x64': 1.63.0 - '@oxlint/binding-freebsd-x64': 1.63.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.63.0 - '@oxlint/binding-linux-arm-musleabihf': 1.63.0 - '@oxlint/binding-linux-arm64-gnu': 1.63.0 - '@oxlint/binding-linux-arm64-musl': 1.63.0 - '@oxlint/binding-linux-ppc64-gnu': 1.63.0 - '@oxlint/binding-linux-riscv64-gnu': 1.63.0 - '@oxlint/binding-linux-riscv64-musl': 1.63.0 - '@oxlint/binding-linux-s390x-gnu': 1.63.0 - '@oxlint/binding-linux-x64-gnu': 1.63.0 - '@oxlint/binding-linux-x64-musl': 1.63.0 - '@oxlint/binding-openharmony-arm64': 1.63.0 - '@oxlint/binding-win32-arm64-msvc': 1.63.0 - '@oxlint/binding-win32-ia32-msvc': 1.63.0 - '@oxlint/binding-win32-x64-msvc': 1.63.0 + '@oxlint-tsgolint/darwin-arm64': 0.23.0 + '@oxlint-tsgolint/darwin-x64': 0.23.0 + '@oxlint-tsgolint/linux-arm64': 0.23.0 + '@oxlint-tsgolint/linux-x64': 0.23.0 + '@oxlint-tsgolint/win32-arm64': 0.23.0 + '@oxlint-tsgolint/win32-x64': 0.23.0 + + oxlint@1.67.0(oxlint-tsgolint@0.23.0): + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.67.0 + '@oxlint/binding-android-arm64': 1.67.0 + '@oxlint/binding-darwin-arm64': 1.67.0 + '@oxlint/binding-darwin-x64': 1.67.0 + '@oxlint/binding-freebsd-x64': 1.67.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.67.0 + '@oxlint/binding-linux-arm-musleabihf': 1.67.0 + '@oxlint/binding-linux-arm64-gnu': 1.67.0 + '@oxlint/binding-linux-arm64-musl': 1.67.0 + '@oxlint/binding-linux-ppc64-gnu': 1.67.0 + '@oxlint/binding-linux-riscv64-gnu': 1.67.0 + '@oxlint/binding-linux-riscv64-musl': 1.67.0 + '@oxlint/binding-linux-s390x-gnu': 1.67.0 + '@oxlint/binding-linux-x64-gnu': 1.67.0 + '@oxlint/binding-linux-x64-musl': 1.67.0 + '@oxlint/binding-openharmony-arm64': 1.67.0 + '@oxlint/binding-win32-arm64-msvc': 1.67.0 + '@oxlint/binding-win32-ia32-msvc': 1.67.0 + '@oxlint/binding-win32-x64-msvc': 1.67.0 + oxlint-tsgolint: 0.23.0 p-cancelable@2.1.1: {} @@ -11681,21 +11600,6 @@ snapshots: powershell-utils@0.1.0: {} - prebuild-install@7.1.3: - dependencies: - detect-libc: 2.1.2 - expand-template: 2.0.3 - github-from-package: 0.0.0 - minimist: 1.2.8 - mkdirp-classic: 0.5.3 - napi-build-utils: 2.0.0 - node-abi: 3.91.0 - pump: 3.0.4 - rc: 1.2.8 - simple-get: 4.0.1 - tar-fs: 2.1.4 - tunnel-agent: 0.6.0 - pretty-ms@9.3.0: dependencies: parse-ms: 4.0.0 @@ -11889,13 +11793,6 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 - rc@1.2.8: - dependencies: - deep-extend: 0.6.0 - ini: 1.3.8 - minimist: 1.2.8 - strip-json-comments: 2.0.1 - react-colorful@5.7.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: react: 19.2.5 @@ -11968,12 +11865,6 @@ snapshots: transitivePeerDependencies: - supports-color - readable-stream@3.6.2: - dependencies: - inherits: 2.0.4 - string_decoder: 1.3.0 - util-deprecate: 1.0.2 - recast@0.23.11: dependencies: ast-types: 0.16.1 @@ -12184,8 +12075,6 @@ snapshots: rw@1.3.3: {} - safe-buffer@5.2.1: {} - safer-buffer@2.1.2: {} sanitize-filename@1.6.4: @@ -12341,24 +12230,6 @@ snapshots: signal-exit@4.1.0: {} - simple-concat@1.0.1: {} - - simple-get@4.0.1: - dependencies: - decompress-response: 6.0.0 - once: 1.4.0 - simple-concat: 1.0.1 - - simple-git@3.36.0: - dependencies: - '@kwsites/file-exists': 1.1.1 - '@kwsites/promise-deferred': 1.1.1 - '@simple-git/args-pathspec': 1.0.3 - '@simple-git/argv-parser': 1.1.1 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - simple-update-notifier@2.0.0: dependencies: semver: 7.7.4 @@ -12449,10 +12320,6 @@ snapshots: get-east-asian-width: 1.5.0 strip-ansi: 7.2.0 - string_decoder@1.3.0: - dependencies: - safe-buffer: 5.2.1 - stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 @@ -12478,8 +12345,6 @@ snapshots: strip-final-newline@4.0.0: {} - strip-json-comments@2.0.1: {} - style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 @@ -12508,21 +12373,6 @@ snapshots: tapable@2.3.3: {} - tar-fs@2.1.4: - dependencies: - chownr: 1.1.4 - mkdirp-classic: 0.5.3 - pump: 3.0.4 - tar-stream: 2.2.0 - - tar-stream@2.2.0: - dependencies: - bl: 4.1.0 - end-of-stream: 1.4.5 - fs-constants: 1.0.0 - inherits: 2.0.4 - readable-stream: 3.6.2 - tar@7.5.14: dependencies: '@isaacs/fs-minipass': 4.0.1 @@ -12609,10 +12459,6 @@ snapshots: tslib@2.8.1: {} - tunnel-agent@0.6.0: - dependencies: - safe-buffer: 5.2.1 - tw-animate-css@1.4.0: {} tweetnacl@0.14.5: {} @@ -12644,6 +12490,9 @@ snapshots: undici@6.25.0: {} + undici@7.26.0: + optional: true + unicorn-magic@0.3.0: {} unified@11.0.5: diff --git a/resources/build/icon.icns b/resources/build/icon.icns index 097c8cef8c1..dc390835b5b 100644 Binary files a/resources/build/icon.icns and b/resources/build/icon.icns differ diff --git a/resources/icon-source/generate.sh b/resources/icon-source/generate.sh index 5255ca1a301..d8377345a51 100755 --- a/resources/icon-source/generate.sh +++ b/resources/icon-source/generate.sh @@ -12,6 +12,13 @@ TMP_DIR=$(mktemp -d) trap 'rm -rf "$TMP_DIR"' EXIT +MAGICK_BIN=$(command -v magick || true) +if [ -z "$MAGICK_BIN" ]; then + echo "Error: ImageMagick is required to trim the small macOS icon slots." >&2 + echo "Install with: brew install imagemagick" >&2 + exit 1 +fi + echo "Compiling icon from $ICON_SOURCE..." # Generate .icns using actool (requires Xcode) @@ -29,22 +36,42 @@ if [ ! -f "$TMP_DIR/icon.icns" ]; then fi cp "$TMP_DIR/icon.icns" "$BUILD_DIR/icon.icns" + +# macOS list views use the small .icns slots directly. Icon Composer keeps the +# safe-area inset there, so trim only those slots while preserving larger icons. +ICONSET_DIR="$TMP_DIR/icon.iconset" +iconutil -c iconset "$BUILD_DIR/icon.icns" -o "$ICONSET_DIR" +for icon_file in \ + icon_16x16.png \ + icon_16x16@2x.png \ + icon_32x32.png \ + icon_32x32@2x.png; do + case "$icon_file" in + icon_16x16.png) icon_size=16 ;; + icon_16x16@2x.png | icon_32x32.png) icon_size=32 ;; + icon_32x32@2x.png) icon_size=64 ;; + esac + "$MAGICK_BIN" "$ICONSET_DIR/$icon_file" \ + -trim +repage \ + -resize "${icon_size}x${icon_size}" \ + -background none \ + -gravity center \ + -extent "${icon_size}x${icon_size}" \ + "$ICONSET_DIR/$icon_file" +done +iconutil -c icns "$ICONSET_DIR" -o "$BUILD_DIR/icon.icns" echo " -> resources/build/icon.icns" -# Extract 1024x1024 PNG from icns for electron-builder fallback & resources -sips -s format png --resampleWidth 1024 "$BUILD_DIR/icon.icns" --out "$BUILD_DIR/icon.png" >/dev/null 2>&1 +# Extract PNG fallbacks from the unmodified compiled icon; small-slot trimming is +# only for the macOS .icns list representations. +sips -s format png --resampleWidth 1024 "$TMP_DIR/icon.icns" --out "$BUILD_DIR/icon.png" >/dev/null 2>&1 echo " -> resources/build/icon.png (1024x1024)" -sips -s format png --resampleWidth 256 "$BUILD_DIR/icon.icns" --out "$RESOURCES_DIR/icon.png" >/dev/null 2>&1 +sips -s format png --resampleWidth 256 "$TMP_DIR/icon.icns" --out "$RESOURCES_DIR/icon.png" >/dev/null 2>&1 echo " -> resources/icon.png (256x256)" # Generate .ico for Windows (proper ICO format with multiple sizes) -if command -v magick &>/dev/null; then - magick "$BUILD_DIR/icon.png" -define icon:auto-resize=256,128,64,48,32,16 "$BUILD_DIR/icon.ico" - echo " -> resources/build/icon.ico (multi-size ICO via ImageMagick)" -else - echo "Warning: ImageMagick not found, skipping icon.ico generation" >&2 - echo "Install with: brew install imagemagick" >&2 -fi +"$MAGICK_BIN" "$BUILD_DIR/icon.png" -define icon:auto-resize=256,128,64,48,32,16 "$BUILD_DIR/icon.ico" +echo " -> resources/build/icon.ico (multi-size ICO via ImageMagick)" echo "Done! Icons generated in resources/build/ and resources/" diff --git a/resources/openclaude-logo.png b/resources/openclaude-logo.png new file mode 100644 index 00000000000..270e21b9b0a Binary files /dev/null and b/resources/openclaude-logo.png differ diff --git a/skills/orca-cli/SKILL.md b/skills/orca-cli/SKILL.md index 7e5f3a2123d..5814e850b28 100644 --- a/skills/orca-cli/SKILL.md +++ b/skills/orca-cli/SKILL.md @@ -34,7 +34,7 @@ Use `orca` (or `orca-ide` on Linux) for: - stopping or waiting on Orca-managed terminals - creating and managing scheduled Orca automations - accessing repos known to Orca -Do not use `orca` / `orca-ide` when plain shell tools are simpler and Orca state does not matter. + Do not use `orca` / `orca-ide` when plain shell tools are simpler and Orca state does not matter. Examples: @@ -539,12 +539,12 @@ orca exec --command "keyboard inserttext \"text\"" --json # bypasses key event ### Browser Error Codes -| Error Code | Meaning | Recovery | -|-----------|---------|----------| -| `browser_no_tab` | No browser tab is open in this worktree | Open a tab, or use `--worktree all` to check other worktrees | -| `browser_stale_ref` | Ref is invalid (page changed since snapshot) | Run `orca snapshot` to get fresh refs | -| `browser_tab_not_found` | Tab index does not exist | Run `orca tab list` to see available tabs | -| `browser_error` | Error from the browser automation engine | Read the message for details; common causes: element not found, navigation timeout, JS error | +| Error Code | Meaning | Recovery | +| ----------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------- | +| `browser_no_tab` | No browser tab is open in this worktree | Open a tab, or use `--worktree all` to check other worktrees | +| `browser_stale_ref` | Ref is invalid (page changed since snapshot) | Run `orca snapshot` to get fresh refs | +| `browser_tab_not_found` | Tab index does not exist | Run `orca tab list` to see available tabs | +| `browser_error` | Error from the browser automation engine | Read the message for details; common causes: element not found, navigation timeout, JS error | ### Browser Worked Example diff --git a/skills/orchestration/SKILL.md b/skills/orchestration/SKILL.md index 9a22023be36..8cbdcd00f56 100644 --- a/skills/orchestration/SKILL.md +++ b/skills/orchestration/SKILL.md @@ -32,6 +32,19 @@ Use `orca-cli` instead for ordinary terminal control, shell commands, browser au - The orchestration experimental feature must be enabled in Settings > Experimental. - All `orca orchestration` commands are RPC calls to the running Orca runtime — they require an active Orca session. +## Ownership And Handoff Boundaries + +Orchestration messages and tasks are runtime-global. The authority for a worker completion is the active dispatch context (`taskId` + `dispatchId` + assignee handle), not the filesystem worktree by itself. Cross-worktree coordination is valid when a live coordinator intentionally owns the task graph. + +Do not treat a copied or injected preamble as automatic parentage for new work. First classify the situation: + +- **Coordinated subtask**: a live coordinator owns the DAG and is waiting on this dispatch. Use the exact `worker_done`, heartbeat, `ask`, and escalation flow from the preamble, even if the coordinator terminal is in another worktree. +- **Full handoff**: the original actor intentionally delegated ownership and does not want to monitor the work. Finish the current assignment in the current session. Create a new coordinator only when the user asks for orchestration or you deliberately decompose fresh subtasks in the current worktree; if you spawn workers, pass your current-worktree coordinator handle and use a current-worktree selector such as `--worktree active`. + +When the handoff type is unclear, inspect `orca orchestration task-list --json`, `orca orchestration dispatch-show`, and `orca terminal list --json` for the task, dispatch, and handle ownership before sending lifecycle messages. If you still cannot tell whether the remote handle owns an active dispatch, ask the current owner instead of silently completing a task into an unrelated workstream. + +Why: a stale or copied cross-worktree `worker_done` can make an unrelated feature coordinator responsible for work it intentionally delegated away. Conversely, refusing every cross-worktree completion would break legitimate orchestrated DAGs, so the decision must follow dispatch ownership, not location alone. + ## Command Surface ### Messaging @@ -57,15 +70,15 @@ Why: `--wait` blocks until a matching message arrives or the timeout expires (de **Group addresses** resolve to terminal handles: -| Group | Resolves To | -|-------|------------| -| `@all` | All terminal handles except sender | -| `@idle` | Handles where the agent is currently idle | -| `@claude` | Handles running Claude Code | -| `@codex` | Handles running Codex | -| `@opencode` | Handles running OpenCode | -| `@gemini` | Handles running Gemini | -| `@worktree:` | All handles in a specific worktree | +| Group | Resolves To | +| ---------------- | ----------------------------------------- | +| `@all` | All terminal handles except sender | +| `@idle` | Handles where the agent is currently idle | +| `@claude` | Handles running Claude Code | +| `@codex` | Handles running Codex | +| `@opencode` | Handles running OpenCode | +| `@gemini` | Handles running Gemini | +| `@worktree:` | All handles in a specific worktree | Group messages fan out: one message per recipient, shared `thread_id`, independent read tracking. @@ -165,8 +178,9 @@ Why: the 120-line terminal output buffer (`terminal read`) is for status monitor ## Agent Guidance -- When dispatched with a preamble, **always run the `worker_done` command when done**. This is the primary feedback mechanism — it keeps the coordinator's context window clean. -- If blocked or unable to complete a task, send an `escalation` message to the coordinator instead of silently stalling. +- When dispatched with a valid live preamble, **send `worker_done` exactly once to the owning coordinator**. The `dispatchId` in the payload is the completion authority. +- Treat a preamble inherited through terminal history or a full handoff as stale unless the current prompt explicitly keeps that coordinator in the loop. +- If blocked or unable to complete a task, send an `escalation` message to the owning coordinator only when ownership is valid; otherwise report the blocker in the current session. - Use `orca orchestration check` to read incoming messages from the coordinator or other agents. Messages are delivered automatically when you go idle, but you can also poll explicitly. - Treat `orca orchestration` commands the same way you treat `git` or `npm` — they are CLI tools available in your shell. - The coordinator uses `orca orchestration task-list --ready` as its external memory. Prefer querying orchestration state over tracking it in your context window. diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index a134a8a2a9e..5e661b3e386 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { parseArgs } from './args' +import { parseArgs, validateCommandAndFlags } from './args' describe('parseArgs', () => { it('keeps an empty string as a flag value', () => { @@ -38,3 +38,35 @@ describe('parseArgs', () => { expect(parsed.flags.get('url')).toBe('https://example.com') }) }) + +describe('validateCommandAndFlags', () => { + const specs = [ + { + path: ['demo'], + summary: 'Demo command', + usage: 'orca demo', + allowedFlags: [] + } + ] + + it('allows global runtime selector flags even when the command spec omits them', () => { + const parsed = parseArgs([ + 'demo', + '--pairing-code', + 'remote-runtime', + '--environment', + 'server', + '--json' + ]) + + expect(() => validateCommandAndFlags(specs, parsed)).not.toThrow() + }) + + it('still rejects unknown command-specific flags', () => { + const parsed = parseArgs(['demo', '--bogus']) + + expect(() => validateCommandAndFlags(specs, parsed)).toThrow( + 'Unknown flag --bogus for command: demo' + ) + }) +}) diff --git a/src/cli/args.ts b/src/cli/args.ts index 02992e3b30f..5e2a703a0e2 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -174,7 +174,9 @@ export function validateCommandAndFlags(specs: CommandSpec[], parsed: ParsedArgs } for (const flag of parsed.flags.keys()) { + const isGlobalFlag = GLOBAL_FLAGS.includes(flag) if ( + !isGlobalFlag && !spec.allowedFlags.includes(flag) && !(flag === 'page' && supportsBrowserPageFlag(spec.path)) ) { diff --git a/src/cli/browser.test.ts b/src/cli/browser.test.ts index 86bbee3039a..7ca2c509a57 100644 --- a/src/cli/browser.test.ts +++ b/src/cli/browser.test.ts @@ -517,6 +517,81 @@ describe('orca cli browser tab profiles', () => { }) }) +describe('orca cli browser cookies', () => { + beforeEach(() => { + callMock.mockReset() + process.exitCode = undefined + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('passes a finite non-negative cookie expiry through as a number', async () => { + queueFixtures(callMock, okFixture('req_cookie', { success: true })) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + [ + 'cookie', + 'set', + '--name', + 'sid', + '--value', + 'x', + '--expires', + '0', + '--worktree', + 'all', + '--json' + ], + '/tmp/not-an-orca-worktree' + ) + + expect(callMock).toHaveBeenCalledWith('browser.cookie.set', { + name: 'sid', + value: 'x', + expires: 0, + worktree: undefined + }) + }) + + it.each(['not-a-number', 'Infinity', '-1'])( + 'rejects invalid cookie expiry value %s before RPC dispatch', + async (expires) => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main( + ['cookie', 'set', '--name', 'sid', '--value', 'x', '--expires', expires], + '/tmp/not-an-orca-worktree' + ) + + expect(callMock).not.toHaveBeenCalled() + expect(errorSpy.mock.calls.flat().join('\n')).toContain(`Invalid --expires value: ${expires}`) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + } + ) + + it('rejects --expires without a value before RPC dispatch', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main( + ['cookie', 'set', '--name', 'sid', '--value', 'x', '--expires'], + '/tmp/not-an-orca-worktree' + ) + + expect(callMock).not.toHaveBeenCalled() + expect(errorSpy.mock.calls.flat().join('\n')).toContain('Missing value for --expires.') + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) +}) + describe('orca cli browser waits and viewport flags', () => { beforeEach(() => { callMock.mockReset() diff --git a/src/cli/format.test.ts b/src/cli/format.test.ts index 33a2ea5afe9..15fe22e307c 100644 --- a/src/cli/format.test.ts +++ b/src/cli/format.test.ts @@ -1,7 +1,27 @@ -import { describe, expect, it } from 'vitest' +import { existsSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, describe, expect, it, vi } from 'vitest' import { RuntimeRpcFailureError } from './runtime-client' -import { formatCliError, formatTerminalRead, formatWorktreeList } from './format' -import type { RuntimeWorktreeRecord } from '../shared/runtime-types' +import { + formatCliError, + formatComputerAction, + formatTerminalRead, + formatWorktreeList, + printResult +} from './format' +import type { ComputerActionResult, RuntimeWorktreeRecord } from '../shared/runtime-types' + +let testScreenshotDir: string | null = null + +afterEach(() => { + vi.restoreAllMocks() + delete process.env.ORCA_COMPUTER_SCREENSHOT_TMPDIR + if (testScreenshotDir) { + rmSync(testScreenshotDir, { recursive: true, force: true }) + testScreenshotDir = null + } +}) function worktree(overrides: Partial = {}): RuntimeWorktreeRecord { const base: RuntimeWorktreeRecord = { @@ -174,3 +194,131 @@ describe('formatTerminalRead', () => { expect(output).not.toContain('undefined') }) }) + +describe('formatComputerAction', () => { + it('includes routed worktree and explicit window target in the suggested follow-up command', () => { + const result: ComputerActionResult = { + snapshot: { + id: 'snap-1', + app: { name: 'Text Editor', bundleId: null, pid: 100 }, + window: { title: 'Document', id: 42, width: 800, height: 600 }, + coordinateSpace: 'window', + treeText: 'tree', + elementCount: 5, + focusedElementId: null + }, + screenshot: null, + screenshotStatus: { state: 'skipped', reason: 'no_screenshot_flag' }, + action: { + path: 'accessibility', + targetWindowId: 41 + } + } + + const output = formatComputerAction('click', result, { + worktree: 'id:repo::/tmp/repo', + windowId: 99 + }) + + expect(output).toContain( + "Use `orca computer get-app-state --app 'Text Editor' --worktree id:repo::/tmp/repo --window-id 99`" + ) + }) + + it('preserves explicit window-index targeting in the suggested follow-up command', () => { + const result: ComputerActionResult = { + snapshot: { + id: 'snap-1', + app: { name: 'Finder', bundleId: 'com.apple.finder', pid: 100 }, + window: { title: 'Document', id: 42, width: 800, height: 600 }, + coordinateSpace: 'window', + treeText: 'tree', + elementCount: 5, + focusedElementId: null + }, + screenshot: null, + screenshotStatus: { state: 'skipped', reason: 'no_screenshot_flag' } + } + + const output = formatComputerAction('click', result, { + session: 'manual', + windowIndex: 1 + }) + + expect(output).toContain( + 'Use `orca computer get-app-state --app com.apple.finder --session manual --window-index 1`' + ) + }) +}) + +describe('printResult computer screenshots', () => { + it('removes expired screenshot temp files when cleanup is due', () => { + testScreenshotDir = mkdtempSync(join(tmpdir(), 'orca-format-test-')) + process.env.ORCA_COMPUTER_SCREENSHOT_TMPDIR = testScreenshotDir + const expiredPath = join(testScreenshotDir, 'old-screenshot.png') + writeFileSync(expiredPath, 'old') + const expired = new Date(Date.now() - 48 * 60 * 60 * 1000) + utimesSync(expiredPath, expired, expired) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined) + + printResult( + { + id: 'req-cleanup', + ok: true, + result: { + screenshot: { + data: Buffer.from('png-data').toString('base64'), + format: 'png', + width: 1, + height: 1, + scale: 1 + } + }, + _meta: { runtimeId: 'runtime-1' } + }, + true, + () => 'unused' + ) + + expect(existsSync(expiredPath)).toBe(false) + expect(existsSync(join(testScreenshotDir, '.last-cleanup'))).toBe(true) + expect(logSpy).toHaveBeenCalled() + }) + + it('skips screenshot temp cleanup when the cleanup marker is fresh', () => { + testScreenshotDir = mkdtempSync(join(tmpdir(), 'orca-format-test-')) + const expiredPath = join(testScreenshotDir, 'old-screenshot.png') + writeFileSync(expiredPath, 'old') + const expired = new Date(Date.now() - 48 * 60 * 60 * 1000) + utimesSync(expiredPath, expired, expired) + writeFileSync(join(testScreenshotDir, '.last-cleanup'), 'recent\n') + process.env.ORCA_COMPUTER_SCREENSHOT_TMPDIR = testScreenshotDir + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined) + + printResult( + { + id: 'req/1', + ok: true, + result: { + screenshot: { + data: Buffer.from('png-data').toString('base64'), + format: 'png', + width: 1, + height: 1, + scale: 1 + } + }, + _meta: { runtimeId: 'runtime-1' } + }, + true, + () => 'unused' + ) + + expect(existsSync(expiredPath)).toBe(true) + const output = JSON.parse(logSpy.mock.calls[0][0]) as { + result: { screenshot: { dataOmitted: boolean; path: string } } + } + expect(output.result.screenshot.dataOmitted).toBe(true) + expect(output.result.screenshot.path).toContain('req_1-screenshot.png') + }) +}) diff --git a/src/cli/format.ts b/src/cli/format.ts index 5119dc8a5fd..d05b6eef54a 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -34,6 +34,7 @@ import type { RuntimeWorktreeRecord } from '../shared/runtime-types' import type { Automation, AutomationRun } from '../shared/automations-types' +import { formatAutomationPrecheckTimeout } from '../shared/automation-precheck' import { formatAutomationSchedule } from '../shared/automation-schedules' import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments' import type { RuntimeRpcFailure, RuntimeRpcSuccess } from './runtime-client' @@ -340,6 +341,13 @@ export function formatAutomationShow(result: { automation: Automation }): string `enabled: ${automation.enabled}`, `schedule: ${formatAutomationSchedule(automation.rrule)}`, `rrule: ${automation.rrule}`, + `precheck: ${ + automation.precheck + ? `${automation.precheck.command} (timeout ${formatAutomationPrecheckTimeout( + automation.precheck.timeoutSeconds + )})` + : 'none' + }`, `nextRunAt: ${new Date(automation.nextRunAt).toISOString()}`, `projectId: ${automation.projectId}`, `workspaceMode: ${automation.workspaceMode}`, @@ -366,10 +374,25 @@ export function formatAutomationRun(result: { run: AutomationRun }): string { `trigger: ${result.run.trigger}`, `scheduledFor: ${new Date(result.run.scheduledFor).toISOString()}`, `workspaceId: ${result.run.workspaceId ?? 'null'}`, + `precheck: ${formatAutomationRunPrecheck(result.run)}`, `error: ${result.run.error ?? 'null'}` ].join('\n') } +function formatAutomationRunPrecheck(run: AutomationRun): string { + const result = run.precheckResult + if (!result) { + return 'none' + } + const outcome = result.timedOut + ? 'timed out' + : result.error + ? 'error' + : `exit ${result.exitCode ?? 'unknown'}` + const output = result.stderr.trim() || result.stdout.trim() + return output ? `${outcome}; ${output}` : outcome +} + export function formatAutomationRuns(result: { runs: AutomationRun[] }): string { if (result.runs.length === 0) { return 'No automation runs found.' @@ -377,7 +400,7 @@ export function formatAutomationRuns(result: { runs: AutomationRun[] }): string return result.runs .map( (run) => - `${run.id} ${run.automationId} ${run.status} ${run.trigger} ${new Date(run.scheduledFor).toISOString()}\n${run.title}${run.error ? `\nerror: ${run.error}` : ''}` + `${run.id} ${run.automationId} ${run.status} ${run.trigger} ${new Date(run.scheduledFor).toISOString()}\n${run.title}${run.precheckResult ? `\nprecheck: ${formatAutomationRunPrecheck(run)}` : ''}${run.error ? `\nerror: ${run.error}` : ''}` ) .join('\n\n') } @@ -511,9 +534,12 @@ function prepareCliJsonResult( } const COMPUTER_SCREENSHOT_TTL_MS = 24 * 60 * 60 * 1000 +const COMPUTER_SCREENSHOT_CLEANUP_INTERVAL_MS = 60 * 60 * 1000 +const COMPUTER_SCREENSHOT_CLEANUP_MARKER = '.last-cleanup' function computerScreenshotTempDir(): string { - const outputDir = join(tmpdir(), 'orca-computer-use') + const outputDir = + process.env.ORCA_COMPUTER_SCREENSHOT_TMPDIR || join(tmpdir(), 'orca-computer-use') mkdirSync(outputDir, { recursive: true, mode: 0o700 }) const stat = lstatSync(outputDir) if (!stat.isDirectory() || stat.isSymbolicLink()) { @@ -527,7 +553,19 @@ function computerScreenshotTempDir(): string { } function cleanupComputerScreenshots(outputDir: string): void { - const cutoff = Date.now() - COMPUTER_SCREENSHOT_TTL_MS + const now = Date.now() + const markerPath = join(outputDir, COMPUTER_SCREENSHOT_CLEANUP_MARKER) + try { + // Why: agents can call computer-use CLI commands in loops; a marker keeps + // temp cleanup from becoming a synchronous directory scan per screenshot. + if (statSync(markerPath).mtimeMs > now - COMPUTER_SCREENSHOT_CLEANUP_INTERVAL_MS) { + return + } + } catch { + // Missing or unreadable marker means this process should attempt cleanup. + } + + const cutoff = now - COMPUTER_SCREENSHOT_TTL_MS for (const entry of readdirSync(outputDir)) { if (!entry.endsWith('-screenshot.png') && !entry.endsWith('-screenshot.img')) { continue @@ -541,6 +579,11 @@ function cleanupComputerScreenshots(outputDir: string): void { // Best-effort cleanup only; formatting should not fail because a temp file raced. } } + try { + writeFileSync(markerPath, `${now}\n`, { mode: 0o600 }) + } catch { + // Best-effort marker only; stale cleanup state should not hide a screenshot. + } } function safeCliFileStem(value: string): string { @@ -584,15 +627,55 @@ export function formatListWindows(result: ComputerListWindowsResult): string { .join('\n') } -export function formatComputerAction(verb: string, result: ComputerActionResult): string { +export type ComputerActionFollowUpTarget = { + session?: string + worktree?: string + windowId?: number + windowIndex?: number + restoreWindow?: boolean +} + +export function formatComputerAction( + verb: string, + result: ComputerActionResult, + target: ComputerActionFollowUpTarget = {} +): string { const path = result.action?.path ? ` via ${result.action.path}` : '' const verification = formatActionVerification(result.action?.verification) - const app = shellQuote(result.snapshot.app.bundleId ?? result.snapshot.app.name) - const windowId = - result.snapshot.window.id === null || result.snapshot.window.id === undefined - ? '' - : ` --window-id ${result.snapshot.window.id}` - return `${formatActionVerb(verb)} completed${path}${verification}; ${result.snapshot.elementCount} elements in current window. Use \`orca computer get-app-state --app ${app}${windowId}\` to inspect.` + const followUpCommand = formatComputerFollowUpCommand(result, target) + return `${formatActionVerb(verb)} completed${path}${verification}; ${result.snapshot.elementCount} elements in current window. Use \`${followUpCommand}\` to inspect.` +} + +function formatComputerFollowUpCommand( + result: ComputerActionResult, + target: ComputerActionFollowUpTarget +): string { + const args = [ + 'orca', + 'computer', + 'get-app-state', + '--app', + shellQuote(result.snapshot.app.bundleId ?? result.snapshot.app.name) + ] + if (target.session) { + args.push('--session', shellQuote(target.session)) + } else if (target.worktree) { + args.push('--worktree', shellQuote(target.worktree)) + } + if (target.windowId !== undefined) { + args.push('--window-id', String(target.windowId)) + } else if (target.windowIndex !== undefined) { + args.push('--window-index', String(target.windowIndex)) + } else { + const windowId = result.action?.targetWindowId ?? result.snapshot.window.id + if (windowId !== null && windowId !== undefined) { + args.push('--window-id', String(windowId)) + } + } + if (target.restoreWindow) { + args.push('--restore-window') + } + return args.join(' ') } function formatActionVerification(verification: ComputerActionVerification | undefined): string { diff --git a/src/cli/handlers/automations.ts b/src/cli/handlers/automations.ts index 8c7ec60d386..be09b4060e3 100644 --- a/src/cli/handlers/automations.ts +++ b/src/cli/handlers/automations.ts @@ -2,11 +2,16 @@ import type { Automation, AutomationCreateInput, + AutomationPrecheck, AutomationRun, AutomationSchedulePreset, AutomationUpdateInput } from '../../shared/automations-types' import type { TuiAgent } from '../../shared/types' +import { + DEFAULT_AUTOMATION_PRECHECK_TIMEOUT_SECONDS, + MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS +} from '../../shared/automation-precheck' import { buildAutomationRrule, isValidAutomationSchedule } from '../../shared/automation-schedules' import { isTuiAgent } from '../../shared/tui-agent-config' import type { CommandHandler } from '../dispatch' @@ -234,6 +239,37 @@ function getReuseSessionFlag(flags: Map): boolean | un return undefined } +function getPrecheckFlag( + flags: Map +): AutomationPrecheck | null | undefined { + const hasPrecheck = flags.has('precheck') + const timeoutSeconds = getOptionalPositiveIntegerFlag(flags, 'precheck-timeout') + if (!hasPrecheck) { + if (timeoutSeconds !== undefined) { + throw new RuntimeClientError('invalid_argument', '--precheck-timeout requires --precheck') + } + return undefined + } + const value = flags.get('precheck') + if (typeof value !== 'string') { + throw new RuntimeClientError('invalid_argument', '--precheck requires a command') + } + const command = value.trim() + if (!command) { + return null + } + if (timeoutSeconds !== undefined && timeoutSeconds > MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS) { + throw new RuntimeClientError( + 'invalid_argument', + `--precheck-timeout must be at most ${MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS} seconds` + ) + } + return { + command, + timeoutSeconds: timeoutSeconds ?? DEFAULT_AUTOMATION_PRECHECK_TIMEOUT_SECONDS + } +} + function getWorkspaceModeFlag( flags: Map ): 'existing' | 'new_per_run' | undefined { @@ -311,6 +347,7 @@ export const AUTOMATION_HANDLERS: Record = { const result = await client.call<{ automation: Automation }>('automation.create', { name: getRequiredStringFlag(flags, 'name'), prompt: getRequiredStringFlag(flags, 'prompt'), + precheck: getPrecheckFlag(flags), agentId: getProviderFlag(flags), repo: target.repo, workspace: target.workspace, @@ -332,6 +369,7 @@ export const AUTOMATION_HANDLERS: Record = { updates: { name: getOptionalStringFlag(flags, 'name'), prompt: getOptionalStringFlag(flags, 'prompt'), + precheck: getPrecheckFlag(flags), agentId: getOptionalProviderFlag(flags), repo: target.repo, workspace: target.workspace, diff --git a/src/cli/handlers/browser-cookie.ts b/src/cli/handlers/browser-cookie.ts index ba46ae721c6..ac4b20ac8c1 100644 --- a/src/cli/handlers/browser-cookie.ts +++ b/src/cli/handlers/browser-cookie.ts @@ -6,8 +6,24 @@ import type { import type { CommandHandler } from '../dispatch' import { printResult } from '../format' import { getOptionalStringFlag, getRequiredStringFlag } from '../flags' +import { RuntimeClientError } from '../runtime-client' import { getBrowserCommandTarget } from '../selectors' +function getOptionalCookieExpiry(flags: Map): number | undefined { + if (!flags.has('expires')) { + return undefined + } + const rawExpires = flags.get('expires') + if (typeof rawExpires !== 'string' || rawExpires.length === 0) { + throw new RuntimeClientError('invalid_argument', 'Missing value for --expires.') + } + const expires = Number(rawExpires) + if (!Number.isFinite(expires) || expires < 0) { + throw new RuntimeClientError('invalid_argument', `Invalid --expires value: ${rawExpires}`) + } + return expires +} + export const BROWSER_COOKIE_HANDLERS: Record = { 'cookie get': async ({ flags, client, cwd, json }) => { const url = getOptionalStringFlag(flags, 'url') @@ -30,7 +46,7 @@ export const BROWSER_COOKIE_HANDLERS: Record = { const domain = getOptionalStringFlag(flags, 'domain') const path = getOptionalStringFlag(flags, 'path') const sameSite = getOptionalStringFlag(flags, 'sameSite') - const expires = getOptionalStringFlag(flags, 'expires') + const expires = getOptionalCookieExpiry(flags) if (domain) { params.domain = domain } @@ -46,8 +62,8 @@ export const BROWSER_COOKIE_HANDLERS: Record = { if (sameSite) { params.sameSite = sameSite } - if (expires) { - params.expires = Number(expires) + if (expires !== undefined) { + params.expires = expires } Object.assign(params, await getBrowserCommandTarget(flags, cwd, client)) const result = await client.call('browser.cookie.set', params) diff --git a/src/cli/handlers/computer.test.ts b/src/cli/handlers/computer.test.ts index 62f86363a48..1f4bb4b837e 100644 --- a/src/cli/handlers/computer.test.ts +++ b/src/cli/handlers/computer.test.ts @@ -212,6 +212,32 @@ describe('orca computer CLI handlers', () => { }) }) + it('prints session and window context in action follow-up commands', async () => { + queueFixtures(callMock, okFixture('req_click', sampleSnapshot())) + + await main( + [ + 'computer', + 'click', + '--session', + 'manual', + '--app', + 'Finder', + '--element-index', + '3', + '--window-index', + '1', + '--restore-window' + ], + '/tmp/repo/src' + ) + + const output = vi.mocked(console.log).mock.calls[0][0] + expect(output).toContain( + 'Use `orca computer get-app-state --app com.apple.finder --session manual --window-index 1 --restore-window`' + ) + }) + it('maps action command flags to RPC payloads', async () => { queueFixtures( callMock, diff --git a/src/cli/handlers/computer.ts b/src/cli/handlers/computer.ts index 173509381a8..65aea3bf2a6 100644 --- a/src/cli/handlers/computer.ts +++ b/src/cli/handlers/computer.ts @@ -85,6 +85,7 @@ export const COMPUTER_HANDLERS: Record = { }, 'computer click': async ({ flags, client, cwd, json }) => { const target = await getComputerCommandTarget(flags, cwd, client) + const observeFlags = getComputerActionObserveFlags(flags) const result = await client.call('computer.click', { ...target, elementIndex: getOptionalNonNegativeIntegerFlag(flags, 'element-index'), @@ -92,22 +93,28 @@ export const COMPUTER_HANDLERS: Record = { y: getOptionalNumberFlag(flags, 'y'), clickCount: getOptionalPositiveIntegerFlag(flags, 'click-count'), mouseButton: getOptionalStringFlag(flags, 'mouse-button'), - ...getComputerActionObserveFlags(flags) + ...observeFlags }) - printResult(result, json, (value) => formatComputerAction('click', value)) + printResult(result, json, (value) => + formatComputerAction('click', value, { ...target, ...observeFlags }) + ) }, 'computer perform-secondary-action': async ({ flags, client, cwd, json }) => { const target = await getComputerCommandTarget(flags, cwd, client) + const observeFlags = getComputerActionObserveFlags(flags) const result = await client.call('computer.performSecondaryAction', { ...target, elementIndex: getRequiredNonNegativeIntegerFlag(flags, 'element-index'), action: getRequiredStringFlag(flags, 'action'), - ...getComputerActionObserveFlags(flags) + ...observeFlags }) - printResult(result, json, (value) => formatComputerAction('perform-secondary-action', value)) + printResult(result, json, (value) => + formatComputerAction('perform-secondary-action', value, { ...target, ...observeFlags }) + ) }, 'computer scroll': async ({ flags, client, cwd, json }) => { const target = await getComputerCommandTarget(flags, cwd, client) + const observeFlags = getComputerActionObserveFlags(flags) const result = await client.call('computer.scroll', { ...target, elementIndex: getOptionalNonNegativeIntegerFlag(flags, 'element-index'), @@ -115,12 +122,15 @@ export const COMPUTER_HANDLERS: Record = { y: getOptionalNumberFlag(flags, 'y'), direction: getRequiredStringFlag(flags, 'direction'), pages: getOptionalPositiveNumberFlag(flags, 'pages'), - ...getComputerActionObserveFlags(flags) + ...observeFlags }) - printResult(result, json, (value) => formatComputerAction('scroll', value)) + printResult(result, json, (value) => + formatComputerAction('scroll', value, { ...target, ...observeFlags }) + ) }, 'computer drag': async ({ flags, client, cwd, json }) => { const target = await getComputerCommandTarget(flags, cwd, client) + const observeFlags = getComputerActionObserveFlags(flags) const result = await client.call('computer.drag', { ...target, fromElementIndex: getOptionalNonNegativeIntegerFlag(flags, 'from-element-index'), @@ -129,55 +139,72 @@ export const COMPUTER_HANDLERS: Record = { fromY: getOptionalNumberFlag(flags, 'from-y'), toX: getOptionalNumberFlag(flags, 'to-x'), toY: getOptionalNumberFlag(flags, 'to-y'), - ...getComputerActionObserveFlags(flags) + ...observeFlags }) - printResult(result, json, (value) => formatComputerAction('drag', value)) + printResult(result, json, (value) => + formatComputerAction('drag', value, { ...target, ...observeFlags }) + ) }, 'computer type-text': async ({ flags, client, cwd, json }) => { const target = await getComputerCommandTarget(flags, cwd, client) + const observeFlags = getComputerActionObserveFlags(flags) const result = await client.call('computer.typeText', { ...target, text: await getTextPayload(flags, 'text'), - ...getComputerActionObserveFlags(flags) + ...observeFlags }) - printResult(result, json, (value) => formatComputerAction('type-text', value)) + printResult(result, json, (value) => + formatComputerAction('type-text', value, { ...target, ...observeFlags }) + ) }, 'computer press-key': async ({ flags, client, cwd, json }) => { const target = await getComputerCommandTarget(flags, cwd, client) + const observeFlags = getComputerActionObserveFlags(flags) const result = await client.call('computer.pressKey', { ...target, key: getRequiredStringFlag(flags, 'key'), - ...getComputerActionObserveFlags(flags) + ...observeFlags }) - printResult(result, json, (value) => formatComputerAction('press-key', value)) + printResult(result, json, (value) => + formatComputerAction('press-key', value, { ...target, ...observeFlags }) + ) }, 'computer hotkey': async ({ flags, client, cwd, json }) => { const target = await getComputerCommandTarget(flags, cwd, client) + const observeFlags = getComputerActionObserveFlags(flags) const result = await client.call('computer.hotkey', { ...target, key: getRequiredStringFlag(flags, 'key'), - ...getComputerActionObserveFlags(flags) + ...observeFlags }) - printResult(result, json, (value) => formatComputerAction('hotkey', value)) + printResult(result, json, (value) => + formatComputerAction('hotkey', value, { ...target, ...observeFlags }) + ) }, 'computer paste-text': async ({ flags, client, cwd, json }) => { const target = await getComputerCommandTarget(flags, cwd, client) + const observeFlags = getComputerActionObserveFlags(flags) const result = await client.call('computer.pasteText', { ...target, text: await getTextPayload(flags, 'text'), - ...getComputerActionObserveFlags(flags) + ...observeFlags }) - printResult(result, json, (value) => formatComputerAction('paste-text', value)) + printResult(result, json, (value) => + formatComputerAction('paste-text', value, { ...target, ...observeFlags }) + ) }, 'computer set-value': async ({ flags, client, cwd, json }) => { const target = await getComputerCommandTarget(flags, cwd, client) + const observeFlags = getComputerActionObserveFlags(flags) const result = await client.call('computer.setValue', { ...target, elementIndex: getRequiredNonNegativeIntegerFlag(flags, 'element-index'), value: await getTextPayload(flags, 'value'), - ...getComputerActionObserveFlags(flags) + ...observeFlags }) - printResult(result, json, (value) => formatComputerAction('set-value', value)) + printResult(result, json, (value) => + formatComputerAction('set-value', value, { ...target, ...observeFlags }) + ) } } @@ -255,24 +282,12 @@ function getComputerActionObserveFlags(flags: Map): { windowId?: number windowIndex?: number } { - const observeFlags: { - noScreenshot?: boolean - restoreWindow?: boolean - windowId?: number - windowIndex?: number - } = { - noScreenshot: flags.has('no-screenshot') ? true : undefined - } - if (flags.has('restore-window')) { - observeFlags.restoreWindow = true - } const windowId = getOptionalNumberFlag(flags, 'window-id') - if (windowId !== undefined) { - observeFlags.windowId = windowId - } const windowIndex = getOptionalNonNegativeIntegerFlag(flags, 'window-index') - if (windowIndex !== undefined) { - observeFlags.windowIndex = windowIndex + return { + noScreenshot: flags.has('no-screenshot') ? true : undefined, + ...(flags.has('restore-window') ? { restoreWindow: true } : {}), + ...(windowId !== undefined ? { windowId } : {}), + ...(windowIndex !== undefined ? { windowIndex } : {}) } - return observeFlags } diff --git a/src/cli/handlers/core.ts b/src/cli/handlers/core.ts index 895aa7abca8..4ad992ac12a 100644 --- a/src/cli/handlers/core.ts +++ b/src/cli/handlers/core.ts @@ -2,6 +2,21 @@ import type { CommandHandler } from '../dispatch' import { formatCliStatus, formatStatus, printResult } from '../format' import { RuntimeClientError, serveOrcaApp } from '../runtime-client' +function getOptionalServePort(flags: Map): string | null { + if (!flags.has('port')) { + return null + } + const rawPort = flags.get('port') + if (typeof rawPort !== 'string' || rawPort.length === 0) { + throw new RuntimeClientError('invalid_argument', 'Missing value for --port.') + } + const port = Number(rawPort) + if (!Number.isInteger(port) || port < 0 || port > 65535) { + throw new RuntimeClientError('invalid_argument', `Invalid --port value: ${rawPort}`) + } + return rawPort +} + export const CORE_HANDLERS: Record = { open: async ({ client, json }) => { const result = await client.openOrca() @@ -14,16 +29,10 @@ export const CORE_HANDLERS: Record = { 'Use either --mobile-pairing or --no-pairing, not both.' ) } - const rawPort = flags.get('port') - if (typeof rawPort === 'string') { - const port = Number(rawPort) - if (!Number.isInteger(port) || port < 0 || port > 65535) { - throw new RuntimeClientError('invalid_argument', `Invalid --port value: ${rawPort}`) - } - } + const port = getOptionalServePort(flags) const exitCode = await serveOrcaApp({ json, - port: typeof rawPort === 'string' ? rawPort : null, + port, pairingAddress: typeof flags.get('pairing-address') === 'string' ? (flags.get('pairing-address') as string) diff --git a/src/cli/handlers/file.test.ts b/src/cli/handlers/file.test.ts index b4e11d020db..fa03e286ce2 100644 --- a/src/cli/handlers/file.test.ts +++ b/src/cli/handlers/file.test.ts @@ -112,6 +112,29 @@ describe('orca file CLI handlers', () => { }) }) + it('reports unopened direct diffs instead of formatting them as opened', async () => { + queueFixtures( + callMock, + okFixture('req_diff', { + worktree: 'wt-1', + relativePath: 'assets/logo.png', + kind: 'binary', + opened: false + }) + ) + + await main(['file', 'diff', '--path', 'assets/logo.png', '--worktree', 'id:wt-1'], '/tmp/repo') + + expect(callMock).toHaveBeenCalledWith('files.openDiff', { + worktree: 'id:wt-1', + relativePath: 'assets/logo.png', + staged: false + }) + expect(vi.mocked(console.log).mock.calls[0][0]).toBe( + 'Did not open diff for assets/logo.png: binary file.' + ) + }) + it('rejects --worktree without a value before cwd inference or RPC calls', async () => { const priorExitCode = process.exitCode @@ -179,6 +202,38 @@ describe('orca file CLI handlers', () => { expect(vi.mocked(console.log).mock.calls[0][0]).toBe('Opened 3 changed file targets.') }) + it('places unopened changed-file diffs in skipped instead of opened', async () => { + queueFixtures( + callMock, + okFixture('req_status', { + entries: [{ path: 'assets/logo.png', status: 'modified', area: 'unstaged' }], + conflictOperation: 'unknown' + }), + okFixture('req_diff', { + worktree: 'wt-1', + relativePath: 'assets/logo.png', + kind: 'binary', + opened: false + }) + ) + + await main(['file', 'open-changed', '--worktree', 'id:wt-1', '--json'], '/tmp/elsewhere') + + const output = JSON.parse(vi.mocked(console.log).mock.calls[0][0]) + expect(output.result.opened).toEqual([]) + expect(output.result.skipped).toEqual([ + { + path: 'assets/logo.png', + mode: 'diff', + staged: false, + opened: false, + kind: 'binary', + skipped: true, + reason: 'binary file' + } + ]) + }) + it('skips unresolved conflict entries in diff mode without opening a normal diff', async () => { queueFixtures( callMock, diff --git a/src/cli/handlers/file.ts b/src/cli/handlers/file.ts index 8431e860899..a9a7aee99b4 100644 --- a/src/cli/handlers/file.ts +++ b/src/cli/handlers/file.ts @@ -104,7 +104,8 @@ async function openFileDiff( mode: 'diff', staged, opened: result.result.opened, - kind: result.result.kind + kind: result.result.kind, + ...(result.result.opened ? {} : { skipped: true, reason: `${result.result.kind} file` }) } } @@ -129,7 +130,9 @@ function formatFileOpen(result: RuntimeFileOpenResult): string { } function formatFileDiff(result: RuntimeFileOpenResult): string { - return `Opened diff for ${result.relativePath}.` + return result.opened + ? `Opened diff for ${result.relativePath}.` + : `Did not open diff for ${result.relativePath}: ${result.kind} file.` } export const FILE_HANDLERS: Record = { @@ -192,7 +195,9 @@ export const FILE_HANDLERS: Record = { reason: 'unresolved conflict may not have a single diff target' }) } else { - opened.push(await openFileDiff(ctx, worktree, entry.path, staged)) + const record = await openFileDiff(ctx, worktree, entry.path, staged) + const records = record.opened ? opened : skipped + records.push(record) } } } diff --git a/src/cli/handlers/orchestration.test.ts b/src/cli/handlers/orchestration.test.ts index 210e2d343a6..0a67ae8527a 100644 --- a/src/cli/handlers/orchestration.test.ts +++ b/src/cli/handlers/orchestration.test.ts @@ -1,12 +1,21 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const callMock = vi.fn() +const originalTerminalHandle = process.env.ORCA_TERMINAL_HANDLE // Why: isolate the handler's flag-to-param mapping; printResult only writes output. vi.mock('../format', () => ({ printResult: vi.fn() })) import { ORCHESTRATION_HANDLERS } from './orchestration' +afterEach(() => { + if (originalTerminalHandle === undefined) { + delete process.env.ORCA_TERMINAL_HANDLE + } else { + process.env.ORCA_TERMINAL_HANDLE = originalTerminalHandle + } +}) + describe('orchestration reset CLI handler', () => { beforeEach(() => { callMock.mockReset().mockResolvedValue({ result: { reset: 'all' } }) @@ -46,3 +55,110 @@ describe('orchestration reset CLI handler', () => { }) }) }) + +describe('orchestration timeout flag validation', () => { + const invalidTimeoutValues: [string, string | boolean][] = [ + ['missing', true], + ['empty', ''], + ['non-numeric', 'not-a-number'], + ['zero', '0'], + ['negative', '-1'] + ] + + beforeEach(() => { + callMock.mockReset() + delete process.env.ORCA_TERMINAL_HANDLE + }) + + const invokeCheck = (flags: Map) => + ORCHESTRATION_HANDLERS['orchestration check']({ + flags, + client: { call: callMock }, + cwd: '/tmp/repo', + json: true + } as never) + + const invokeAsk = (flags: Map) => + ORCHESTRATION_HANDLERS['orchestration ask']({ + flags, + client: { call: callMock }, + cwd: '/tmp/repo', + json: true + } as never) + + it.each(invalidTimeoutValues)('rejects invalid check --timeout-ms: %s', async (_label, value) => { + const flags = new Map([ + ['wait', true], + ['timeout-ms', value] + ]) + + await expect(invokeCheck(flags)).rejects.toThrow(/--timeout-ms/) + expect(callMock).not.toHaveBeenCalled() + }) + + it('passes a parsed check timeout into the RPC payload', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_worker' + callMock.mockResolvedValue({ result: { messages: [], count: 0 } }) + + await invokeCheck( + new Map([ + ['wait', true], + ['timeout-ms', '250'] + ]) + ) + + expect(callMock).toHaveBeenCalledWith('orchestration.check', { + terminal: 'term_worker', + unread: undefined, + all: undefined, + types: undefined, + inject: undefined, + wait: true, + timeoutMs: 250 + }) + }) + + it.each(invalidTimeoutValues)('rejects invalid ask --timeout-ms: %s', async (_label, value) => { + const flags = new Map([ + ['to', 'term_coord'], + ['question', 'Proceed?'], + ['timeout-ms', value] + ]) + + await expect(invokeAsk(flags)).rejects.toThrow(/--timeout-ms/) + expect(callMock).not.toHaveBeenCalled() + }) + + it('uses the parsed ask timeout for both runtime wait and client timeout', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_worker' + callMock.mockResolvedValue({ + result: { + answer: 'yes', + messageId: 'msg_1', + threadId: 'thread_1', + timedOut: false + } + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await invokeAsk( + new Map([ + ['to', 'term_coord'], + ['question', 'Proceed?'], + ['timeout-ms', '123'] + ]) + ) + + expect(callMock).toHaveBeenCalledWith( + 'orchestration.ask', + { + to: 'term_coord', + question: 'Proceed?', + options: undefined, + timeoutMs: 123, + from: 'term_worker' + }, + { timeoutMs: 5_123 } + ) + }) +}) diff --git a/src/cli/handlers/orchestration.ts b/src/cli/handlers/orchestration.ts index baa72bba969..f57a86e8fe6 100644 --- a/src/cli/handlers/orchestration.ts +++ b/src/cli/handlers/orchestration.ts @@ -93,6 +93,27 @@ function isDevCliInvocation(): boolean { return process.env.ORCA_USER_DATA_PATH?.includes('orca-dev') ?? false } +function getOptionalPositiveIntegerValueFlag( + flags: Map, + name: string +): number | undefined { + if (!flags.has(name)) { + return undefined + } + const raw = flags.get(name) + if (typeof raw !== 'string' || raw.length === 0) { + throw new RuntimeClientError('invalid_argument', `Missing value for --${name}.`) + } + const value = Number(raw) + if (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0) { + throw new RuntimeClientError( + 'invalid_argument', + `Invalid positive integer for --${name}: ${raw}` + ) + } + return value +} + export const ORCHESTRATION_HANDLERS: Record = { 'orchestration send': async ({ flags, client, cwd, json }) => { const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from') @@ -118,9 +139,9 @@ export const ORCHESTRATION_HANDLERS: Record = { }, 'orchestration check': async ({ flags, client, cwd, json }) => { - const terminal = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'terminal') const wait = flags.has('wait') - const timeoutMs = flags.has('timeout-ms') ? Number(flags.get('timeout-ms')) : undefined + const timeoutMs = getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms') + const terminal = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'terminal') // Why: Claude Code's Bash tool auto-backgrounds subprocesses that produce // no output for ~2 min (shorter on the non-interactive path). Emit a @@ -303,8 +324,9 @@ export const ORCHESTRATION_HANDLERS: Record = { }, 'orchestration ask': async ({ flags, client, cwd, json }) => { + const parsedTimeoutMs = getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms') const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from') - const timeoutMs = flags.has('timeout-ms') ? Number(flags.get('timeout-ms')) : 600_000 + const timeoutMs = parsedTimeoutMs ?? 600_000 const result = await client.call<{ answer: string | null messageId: string | null @@ -316,7 +338,7 @@ export const ORCHESTRATION_HANDLERS: Record = { to: getRequiredStringFlag(flags, 'to'), question: getRequiredStringFlag(flags, 'question'), options: getOptionalStringFlag(flags, 'options'), - timeoutMs: flags.has('timeout-ms') ? Number(flags.get('timeout-ms')) : undefined, + timeoutMs: parsedTimeoutMs, from }, // Why: the runtime's `waitForMessage` can block up to `timeoutMs`, but diff --git a/src/cli/handlers/worktree.ts b/src/cli/handlers/worktree.ts index b4065387b66..eda99c4b3b3 100644 --- a/src/cli/handlers/worktree.ts +++ b/src/cli/handlers/worktree.ts @@ -2,7 +2,8 @@ import type { RuntimeWorktreeListResult, RuntimeWorktreePsResult, RuntimeWorktreeRecord, - RuntimeWorktreeCreateResult + RuntimeWorktreeCreateResult, + RuntimeWorktreeRemoveResult } from '../../shared/runtime-types' import type { CommandHandler } from '../dispatch' import { formatWorktreeList, formatWorktreePs, formatWorktreeShow, printResult } from '../format' @@ -24,12 +25,26 @@ type HookWarningResult = { warning?: string } +type PreservedBranchResult = { + preservedBranch?: { + branchName: string + } +} + function printHookWarning(result: HookWarningResult, json: boolean): void { if (!json && result.warning) { console.error(`warning: ${result.warning}`) } } +function printPreservedBranchWarning(result: PreservedBranchResult, json: boolean): void { + if (!json && result.preservedBranch) { + console.error( + `warning: local branch "${result.preservedBranch.branchName}" was kept because Git could not safely delete it` + ) + } +} + function printLineageSummary(result: RuntimeWorktreeCreateResult, json: boolean): void { if (json) { return @@ -146,18 +161,20 @@ export const WORKTREE_HANDLERS: Record = { displayName: getOptionalStringFlag(flags, 'display-name'), linkedIssue: getOptionalNullableNumberFlag(flags, 'issue'), comment: getOptionalStringFlag(flags, 'comment'), + workspaceStatus: getOptionalStringFlag(flags, 'workspace-status'), parentWorktree: await getOptionalWorktreeSelector(flags, 'parent-worktree', cwd, client), noParent: flags.get('no-parent') === true }) printResult(result, json, formatWorktreeShow) }, 'worktree rm': async ({ flags, client, cwd, json }) => { - const result = await client.call<{ removed: boolean; warning?: string }>('worktree.rm', { + const result = await client.call('worktree.rm', { worktree: await getRequiredWorktreeSelector(flags, 'worktree', cwd, client), force: flags.get('force') === true, runHooks: flags.get('run-hooks') === true }) printHookWarning(result.result, json) + printPreservedBranchWarning(result.result, json) printResult(result, json, (value) => `removed: ${value.removed}`) } } diff --git a/src/cli/help.ts b/src/cli/help.ts index 50de2d06059..b5990b63eac 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -169,7 +169,7 @@ Common Commands: orca worktree create --repo --name [--base-branch ] [--issue ] [--comment ] [--parent-worktree ] [--no-parent] [--run-hooks] [--activate] [--json] orca worktree show --worktree [--json] orca worktree current [--json] - orca worktree set --worktree [--display-name ] [--issue ] [--comment ] [--parent-worktree |--no-parent] [--json] + orca worktree set --worktree [--display-name ] [--issue ] [--comment ] [--workspace-status ] [--parent-worktree |--no-parent] [--json] orca worktree rm --worktree [--force] [--run-hooks] [--json] orca worktree ps [--limit ] [--json] orca file open [--worktree ] [--json] @@ -388,6 +388,8 @@ export function formatFlagHelp(flag: string): string { worktree: '--worktree Worktree selector such as id:, branch:, issue:, path:, or active/current', workspace: '--workspace Existing worktree selector for automation runs', + 'workspace-status': + '--workspace-status Board status id (defaults: todo, in-progress, in-review, completed)', prompt: '--prompt Automation prompt to pass to the agent', staged: '--staged Open staged source-control changes', provider: '--provider Agent id such as codex, claude, or gemini', diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index b1efaa23cb8..8da612f0708 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -85,6 +85,7 @@ import { main, normalizeWorktreeSelector } from './index' +import { GLOBAL_FLAGS } from './args' import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from './test-fixtures' describe('COMMAND_SPECS collision check', () => { @@ -96,6 +97,20 @@ describe('COMMAND_SPECS collision check', () => { seen.add(key) } }) + + it('allows every flag documented in command usage strings', () => { + const flagPattern = /--([a-zA-Z0-9-]+)/g + for (const spec of COMMAND_SPECS) { + const allowed = new Set([...GLOBAL_FLAGS, ...spec.allowedFlags]) + for (const match of spec.usage.matchAll(flagPattern)) { + const flag = match[1] + expect( + allowed.has(flag), + `Documented flag --${flag} is not allowed for command: ${spec.path.join(' ')}` + ).toBe(true) + } + } + }) }) describe('orca cli worktree awareness', () => { @@ -183,6 +198,7 @@ describe('orca cli worktree awareness', () => { queueFixtures( callMock, worktreeListFixture([ + buildWorktree('/tmp/repo', 'main'), buildWorktree('/tmp/repo/feature', 'feature/foo'), buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'duplicate-repo') ]), @@ -411,11 +427,48 @@ describe('orca cli worktree awareness', () => { displayName: undefined, linkedIssue: undefined, comment: undefined, + workspaceStatus: undefined, parentWorktree: undefined, noParent: true }) }) + it('passes workspace status through worktree.set', async () => { + queueFixtures( + callMock, + okFixture('req_set_status', { + worktree: { + ...buildWorktree('/tmp/repo/child', 'feature/child'), + workspaceStatus: 'in-review' + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + [ + 'worktree', + 'set', + '--worktree', + 'id:repo::/tmp/repo/child', + '--workspace-status', + 'in-review', + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).toHaveBeenCalledWith('worktree.set', { + worktree: 'id:repo::/tmp/repo/child', + displayName: undefined, + linkedIssue: undefined, + comment: undefined, + workspaceStatus: 'in-review', + parentWorktree: undefined, + noParent: false + }) + }) + it('passes explicit activation through worktree.create', async () => { queueFixtures( callMock, @@ -742,6 +795,22 @@ describe('orca cli worktree awareness', () => { process.exitCode = priorExitCode }) + it('rejects value-less serve ports before launching the app', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main(['serve', '--port', '--json'], '/tmp/repo') + + expect(serveOrcaAppMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + 'Missing value for --port.' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + it('lists saved environments even when ORCA_ENVIRONMENT is set', async () => { process.env.ORCA_ENVIRONMENT = 'stale-env' listEnvironmentsMock.mockReturnValue([addEnvironmentFromPairingCodeMock()]) diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts index aa930341eee..f9211173be2 100644 --- a/src/cli/runtime/client.ts +++ b/src/cli/runtime/client.ts @@ -111,8 +111,10 @@ export class RuntimeClient { id: response.id, ok: true, result: { + // Why: remote status proves the paired runtime is reachable, not + // that this client machine has a local Orca desktop process. app: { - running: true, + running: false, pid: null }, runtime: { diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts index 3ad50666be8..4f5a17ca579 100644 --- a/src/cli/runtime/websocket-transport.test.ts +++ b/src/cli/runtime/websocket-transport.test.ts @@ -74,6 +74,7 @@ describe('CLI remote WebSocket transport', () => { const client = new RuntimeClient('/tmp/unused', 5_000, barePayload) const status = await client.getCliStatus() + expect(status.result.app).toEqual({ running: false, pid: null }) expect(status.result.runtime.reachable).toBe(true) expect(status.result.runtime.runtimeId).toBe('runtime-ws-2') }) @@ -95,6 +96,7 @@ describe('CLI remote WebSocket transport', () => { const client = new RuntimeClient(userDataPath, 5_000, null, 'remote-dev') const status = await client.getCliStatus() + expect(status.result.app).toEqual({ running: false, pid: null }) expect(status.result.runtime.reachable).toBe(true) expect(status.result.runtime.runtimeId).toBe('runtime-env-1') }) diff --git a/src/cli/selectors.ts b/src/cli/selectors.ts index a94878ca87f..94a668b460a 100644 --- a/src/cli/selectors.ts +++ b/src/cli/selectors.ts @@ -1,5 +1,9 @@ import { isAbsolute, relative, resolve as resolvePath } from 'path' -import type { ComputerAppQuery, RuntimeWorktreeListResult } from '../shared/runtime-types' +import type { + ComputerAppQuery, + RuntimeWorktreeListResult, + RuntimeWorktreeRecord +} from '../shared/runtime-types' import { isPathInsideOrEqual } from '../shared/cross-platform-path' import type { RuntimeClient } from './runtime-client' import { RuntimeClientError } from './runtime-client' @@ -57,9 +61,16 @@ export async function resolveCurrentWorktreeSelector( const worktrees = await client.call('worktree.list', { limit: 10_000 }) - const enclosingWorktree = worktrees.result.worktrees - .filter((worktree) => isWithinPath(resolvePath(worktree.path), currentPath)) - .sort((left, right) => right.path.length - left.path.length)[0] + let enclosingWorktree: RuntimeWorktreeRecord | undefined + let enclosingPathLength = -1 + for (const worktree of worktrees.result.worktrees) { + const worktreePath = resolvePath(worktree.path) + if (!isWithinPath(worktreePath, currentPath) || worktreePath.length <= enclosingPathLength) { + continue + } + enclosingWorktree = worktree + enclosingPathLength = worktreePath.length + } if (!enclosingWorktree) { throw new RuntimeClientError( diff --git a/src/cli/specs/automations.ts b/src/cli/specs/automations.ts index 25032942779..ff3071a9372 100644 --- a/src/cli/specs/automations.ts +++ b/src/cli/specs/automations.ts @@ -3,6 +3,7 @@ import { GLOBAL_FLAGS } from '../args' const AUTOMATION_TARGET_FLAGS = ['repo', 'workspace', 'workspace-mode', 'base-branch'] const AUTOMATION_SCHEDULE_FLAGS = ['trigger', 'schedule', 'time', 'day', 'timezone'] +const AUTOMATION_PRECHECK_FLAGS = ['precheck', 'precheck-timeout'] const AUTOMATION_STATE_FLAGS = [ 'enabled', 'disabled', @@ -31,12 +32,13 @@ export const AUTOMATION_COMMAND_SPECS: CommandSpec[] = [ path: ['automations', 'create'], summary: 'Create a scheduled Orca automation', usage: - 'orca automations create --name --trigger --prompt --provider [--repo |--workspace ] [--json]', + 'orca automations create --name --trigger --prompt --provider [--precheck ] [--repo |--workspace ] [--json]', allowedFlags: [ ...GLOBAL_FLAGS, 'name', 'prompt', 'provider', + ...AUTOMATION_PRECHECK_FLAGS, ...AUTOMATION_TARGET_FLAGS, ...AUTOMATION_SCHEDULE_FLAGS, ...AUTOMATION_STATE_FLAGS @@ -45,11 +47,13 @@ export const AUTOMATION_COMMAND_SPECS: CommandSpec[] = [ 'Trigger accepts hourly, daily, weekdays, weekly, a 5-field cron expression, or an RRULE string.', 'When --repo is omitted, the CLI uses the enclosing Orca worktree when one can be resolved from cwd.', 'Use --workspace to run in an existing worktree; otherwise the automation creates a new worktree per run.', + 'Use --precheck to run a bounded command before scheduled runs; exit code 0 continues, anything else records a skipped run.', 'Use --reuse-session only with existing-workspace automations to submit later runs to the previous live automation session when it is still available. Use --fresh-session to disable reuse.' ], examples: [ 'orca automations create --name "Daily review" --trigger daily --prompt "Review open changes" --provider codex', - 'orca automations create --name "Weekday triage" --trigger "0 9 * * 1-5" --prompt "Triage issues" --provider claude --repo my-repo' + 'orca automations create --name "Weekday triage" --trigger "0 9 * * 1-5" --prompt "Triage issues" --provider claude --repo my-repo', + 'orca automations create --name "PR review" --trigger hourly --precheck "gh pr list --json number -q .[0].number" --prompt "Review requested PRs" --provider codex' ] }, { @@ -62,6 +66,7 @@ export const AUTOMATION_COMMAND_SPECS: CommandSpec[] = [ 'name', 'prompt', 'provider', + ...AUTOMATION_PRECHECK_FLAGS, ...AUTOMATION_TARGET_FLAGS, ...AUTOMATION_SCHEDULE_FLAGS, ...AUTOMATION_STATE_FLAGS diff --git a/src/cli/specs/browser-basic.ts b/src/cli/specs/browser-basic.ts index 26c81da288f..a4bf31eea42 100644 --- a/src/cli/specs/browser-basic.ts +++ b/src/cli/specs/browser-basic.ts @@ -161,7 +161,7 @@ export const BROWSER_BASIC_COMMAND_SPECS: CommandSpec[] = [ path: ['tab', 'show'], summary: 'Show one browser tab by page id', usage: 'orca tab show --page [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'worktree'] + allowedFlags: [...GLOBAL_FLAGS, 'page', 'worktree'] }, { path: ['tab', 'current'], @@ -173,7 +173,7 @@ export const BROWSER_BASIC_COMMAND_SPECS: CommandSpec[] = [ path: ['tab', 'switch'], summary: 'Switch the active browser tab', usage: 'orca tab switch (--index | --page ) [--worktree ] [--focus] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'index', 'worktree', 'focus'] + allowedFlags: [...GLOBAL_FLAGS, 'index', 'page', 'worktree', 'focus'] }, { path: ['tab', 'create'], @@ -203,25 +203,25 @@ export const BROWSER_BASIC_COMMAND_SPECS: CommandSpec[] = [ path: ['tab', 'profile', 'set'], summary: 'Switch a browser tab to a different browser profile', usage: 'orca tab profile set (--page | --worktree ) --profile [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'profile', 'worktree'] + allowedFlags: [...GLOBAL_FLAGS, 'profile', 'page', 'worktree'] }, { path: ['tab', 'profile', 'show'], summary: 'Show the browser profile bound to a tab', usage: 'orca tab profile show --page [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'worktree'] + allowedFlags: [...GLOBAL_FLAGS, 'page', 'worktree'] }, { path: ['tab', 'profile', 'use-default'], summary: 'Switch a browser tab back to the default browser profile', usage: 'orca tab profile use-default --page [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'worktree'] + allowedFlags: [...GLOBAL_FLAGS, 'page', 'worktree'] }, { path: ['tab', 'profile', 'clone'], summary: 'Clone a browser tab into a different browser profile', usage: 'orca tab profile clone --profile [--page ] [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'profile', 'worktree'] + allowedFlags: [...GLOBAL_FLAGS, 'profile', 'page', 'worktree'] }, { path: ['tab', 'close'], diff --git a/src/cli/specs/core.ts b/src/cli/specs/core.ts index 6eea616b412..37d7881e666 100644 --- a/src/cli/specs/core.ts +++ b/src/cli/specs/core.ts @@ -123,15 +123,19 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ path: ['worktree', 'set'], summary: 'Update Orca metadata for a worktree', usage: - 'orca worktree set --worktree [--display-name ] [--issue ] [--comment ] [--parent-worktree |--no-parent] [--json]', + 'orca worktree set --worktree [--display-name ] [--issue ] [--comment ] [--workspace-status ] [--parent-worktree |--no-parent] [--json]', allowedFlags: [ ...GLOBAL_FLAGS, 'worktree', 'display-name', 'issue', 'comment', + 'workspace-status', 'parent-worktree', 'no-parent' + ], + notes: [ + 'Workspace status ids match the board columns (defaults: todo, in-progress, in-review, completed); custom statuses use their configured id.' ] }, { diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts index 80d5c493833..a9255541af4 100644 --- a/src/main/agent-hooks/first-work-branch-rename.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -38,6 +38,7 @@ vi.mock('../text-generation/commit-message-agent-environment', () => ({ vi.mock('../ipc/worktree-logic', () => ({ computeBranchName: computeBranchNameMock })) import { + FIRST_WORK_BRANCH_RENAME_SETTLED_CACHE_LIMIT, maybeAutoRenameBranchOnFirstWork, resetFirstWorkBranchRenameState, type FirstWorkBranchRenameDeps, @@ -203,6 +204,41 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { expect(generateBranchNameMock).not.toHaveBeenCalled() }) + it('bounds settled worktree dedupe while keeping recent entries', async () => { + gitExecFileAsyncMock.mockImplementation( + gitResponder({ currentBranch: 'you/my-feature', hasUpstream: false }) + ) + const { deps } = makeDeps() + const firstWorktreeId = `${REPO_ID}${WORKTREE_ID_SEPARATOR}/repo/wt-0` + const lastWorktreeId = `${REPO_ID}${WORKTREE_ID_SEPARATOR}/repo/wt-${FIRST_WORK_BRANCH_RENAME_SETTLED_CACHE_LIMIT}` + + for (let index = 0; index <= FIRST_WORK_BRANCH_RENAME_SETTLED_CACHE_LIMIT; index += 1) { + await maybeAutoRenameBranchOnFirstWork( + workingEvent({ + tabId: undefined, + paneKey: '', + worktreeId: `${REPO_ID}${WORKTREE_ID_SEPARATOR}/repo/wt-${index}` + }), + deps + ) + } + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes( + FIRST_WORK_BRANCH_RENAME_SETTLED_CACHE_LIMIT + 1 + ) + + gitExecFileAsyncMock.mockClear() + await maybeAutoRenameBranchOnFirstWork( + workingEvent({ tabId: undefined, paneKey: '', worktreeId: firstWorktreeId }), + deps + ) + await maybeAutoRenameBranchOnFirstWork( + workingEvent({ tabId: undefined, paneKey: '', worktreeId: lastWorktreeId }), + deps + ) + + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + it('retries on a later event after a transient failure (does not poison the worktree)', async () => { generateBranchNameMock.mockResolvedValueOnce({ success: false, error: 'agent not ready' }) const { deps, onRenamed } = makeDeps() diff --git a/src/main/agent-hooks/first-work-branch-rename.ts b/src/main/agent-hooks/first-work-branch-rename.ts index 20a73d753ec..59bd8f04403 100644 --- a/src/main/agent-hooks/first-work-branch-rename.ts +++ b/src/main/agent-hooks/first-work-branch-rename.ts @@ -68,6 +68,7 @@ export type FirstWorkBranchRenameDeps = { // settled, so the real first prompt can still succeed on a later event. const inFlightWorktreeIds = new Set() const settledWorktreeIds = new Set() +export const FIRST_WORK_BRANCH_RENAME_SETTLED_CACHE_LIMIT = 500 /** Test seam: clear the per-process dedupe sets. */ export function resetFirstWorkBranchRenameState(): void { @@ -75,6 +76,20 @@ export function resetFirstWorkBranchRenameState(): void { settledWorktreeIds.clear() } +function rememberSettledWorktreeId(worktreeId: string): void { + // Why: the app can see unbounded worktree ids over a long session; evicting + // oldest entries trades a rare re-probe for bounded process memory. + settledWorktreeIds.delete(worktreeId) + settledWorktreeIds.add(worktreeId) + while (settledWorktreeIds.size > FIRST_WORK_BRANCH_RENAME_SETTLED_CACHE_LIMIT) { + const oldest = settledWorktreeIds.values().next().value + if (typeof oldest !== 'string') { + return + } + settledWorktreeIds.delete(oldest) + } +} + export async function maybeAutoRenameBranchOnFirstWork( event: FirstWorkBranchRenameEvent, deps: FirstWorkBranchRenameDeps @@ -107,7 +122,7 @@ export async function maybeAutoRenameBranchOnFirstWork( // ineligible); false means a transient bail that should retry later. const settled = await runAutoRename(worktreeId, prompt, event.assistantMessage, deps) if (settled) { - settledWorktreeIds.add(worktreeId) + rememberSettledWorktreeId(worktreeId) } } catch (error) { // Why: best-effort, opt-in convenience. A failure must never disrupt the diff --git a/src/main/agent-hooks/installer-utils-remote.test.ts b/src/main/agent-hooks/installer-utils-remote.test.ts index 304ef4484ea..75f9c9682fe 100644 --- a/src/main/agent-hooks/installer-utils-remote.test.ts +++ b/src/main/agent-hooks/installer-utils-remote.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import type { SFTPWrapper } from 'ssh2' import { @@ -164,6 +164,27 @@ describe('installer-utils-remote', () => { }) }) + it('times out remote reads that never call back', async () => { + vi.useFakeTimers() + try { + const sftp = { + readFile: vi.fn() + } as unknown as SFTPWrapper + const pending = readHooksJsonRemote(sftp, '/home/u/.claude/settings.json') + let rejection: unknown = null + pending.catch((error) => { + rejection = error + }) + + await vi.advanceTimersByTimeAsync(30_000) + + expect(rejection).toBeInstanceOf(Error) + await expect(pending).rejects.toThrow('Timed out waiting for SFTP readFile') + } finally { + vi.useRealTimers() + } + }) + it('atomically writes settings.json via tmp + rename', async () => { const { sftp, fs } = createFakeSftp() await writeHooksJsonRemote(sftp, '/home/u/.claude/settings.json', { diff --git a/src/main/agent-hooks/installer-utils-remote.ts b/src/main/agent-hooks/installer-utils-remote.ts index 63e10b45d5f..63fc21c4bd8 100644 --- a/src/main/agent-hooks/installer-utils-remote.ts +++ b/src/main/agent-hooks/installer-utils-remote.ts @@ -17,6 +17,7 @@ import type { SFTPWrapper, FileEntryWithStats } from 'ssh2' import { isPlainObject, type HooksConfig } from './installer-utils' const DEFAULT_REMOTE_CONFIG_MODE = 0o600 +const REMOTE_SFTP_OPERATION_TIMEOUT_MS = 10_000 /** Read+JSON-parse a remote file. Returns `null` on parse failure (caller * surfaces "could not parse" status to the UI), `{}` on missing file @@ -170,47 +171,71 @@ export async function writeTextFileRemoteAtomic( // ─── Private SFTP primitives ──────────────────────────────────────── -async function readFile(sftp: SFTPWrapper, remotePath: string): Promise { - return new Promise((resolve, reject) => { - sftp.readFile(remotePath, 'utf8', (err, data) => { +function sftpOperation( + label: string, + run: (callback: (err: unknown, value?: T) => void) => void +): Promise { + return new Promise((resolve, reject) => { + let settled = false + const timer = setTimeout(() => { + if (settled) { + return + } + settled = true + // Why: remote hook installation must fail open; a wedged SFTP callback + // should degrade hook status, not block SSH workspace startup forever. + reject(new Error(`Timed out waiting for SFTP ${label}`)) + }, REMOTE_SFTP_OPERATION_TIMEOUT_MS) + if (typeof timer === 'object' && 'unref' in timer) { + timer.unref() + } + + const finish = (err: unknown, value?: T): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) if (err) { reject(err) return } - resolve(typeof data === 'string' ? data : data.toString('utf8')) - }) + resolve(value as T) + } + + try { + run(finish) + } catch (error) { + finish(error) + } }) } +async function readFile(sftp: SFTPWrapper, remotePath: string): Promise { + const data = await sftpOperation(`readFile ${remotePath}`, (callback) => { + sftp.readFile(remotePath, 'utf8', callback) + }) + return typeof data === 'string' ? data : data.toString('utf8') +} + async function writeFile( sftp: SFTPWrapper, remotePath: string, content: string, mode?: number ): Promise { - return new Promise((resolve, reject) => { - const options = - mode === undefined ? { encoding: 'utf8' as const } : { encoding: 'utf8' as const, mode } - sftp.writeFile(remotePath, content, options, (err) => { - if (err) { - reject(err) - return - } - resolve() - }) + const options = + mode === undefined ? { encoding: 'utf8' as const } : { encoding: 'utf8' as const, mode } + await sftpOperation(`writeFile ${remotePath}`, (callback) => { + sftp.writeFile(remotePath, content, options, callback) }) } async function statMode(sftp: SFTPWrapper, remotePath: string): Promise { - return new Promise((resolve, reject) => { - sftp.stat(remotePath, (err, stats) => { - if (err) { - reject(err) - return - } - resolve(stats.mode & 0o7777) - }) + const stats = await sftpOperation<{ mode: number }>(`stat ${remotePath}`, (callback) => { + sftp.stat(remotePath, callback) }) + return stats.mode & 0o7777 } async function getRemoteFileModeOrDefault( @@ -248,76 +273,38 @@ async function rename(sftp: SFTPWrapper, src: string, dst: string): Promise { - return new Promise((resolve, reject) => { - sftp.rename(src, dst, (err) => { - if (err) { - reject(err) - return - } - resolve() - }) + await sftpOperation(`rename ${src}`, (callback) => { + sftp.rename(src, dst, callback) }) } async function renameOpenSsh(sftp: SFTPWrapper, src: string, dst: string): Promise { - return new Promise((resolve, reject) => { - sftp.ext_openssh_rename(src, dst, (err) => { - if (err) { - reject(err) - return - } - resolve() - }) + await sftpOperation(`openssh_rename ${src}`, (callback) => { + sftp.ext_openssh_rename(src, dst, callback) }) } async function unlink(sftp: SFTPWrapper, remotePath: string): Promise { - return new Promise((resolve, reject) => { - sftp.unlink(remotePath, (err) => { - if (err) { - reject(err) - return - } - resolve() - }) + await sftpOperation(`unlink ${remotePath}`, (callback) => { + sftp.unlink(remotePath, callback) }) } async function chmod(sftp: SFTPWrapper, remotePath: string, mode: number): Promise { - return new Promise((resolve, reject) => { - sftp.chmod(remotePath, mode, (err) => { - if (err) { - reject(err) - return - } - resolve() - }) + await sftpOperation(`chmod ${remotePath}`, (callback) => { + sftp.chmod(remotePath, mode, callback) }) } async function readdir(sftp: SFTPWrapper, remotePath: string): Promise { - return new Promise((resolve, reject) => { - sftp.readdir(remotePath, (err, list) => { - if (err) { - reject(err) - return - } - resolve(list) - }) + return await sftpOperation(`readdir ${remotePath}`, (callback) => { + sftp.readdir(remotePath, callback) }) } async function mkdir(sftp: SFTPWrapper, remotePath: string): Promise { - return new Promise((resolve, reject) => { - sftp.mkdir(remotePath, (err) => { - if (err) { - // SSH_FX_FAILURE (4) often means "already exists" on OpenSSH; we - // probe with stat afterwards rather than parse the error code. - reject(err) - return - } - resolve() - }) + await sftpOperation(`mkdir ${remotePath}`, (callback) => { + sftp.mkdir(remotePath, callback) }) } diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts index 0b69077d3b1..dcdc596a5de 100644 --- a/src/main/agent-hooks/installer-utils.ts +++ b/src/main/agent-hooks/installer-utils.ts @@ -101,8 +101,9 @@ export function wrapPosixHookCommand(scriptPath: string, env: Record void] type ManagedHookRemover = readonly [HookInstallAgent, () => AgentHookInstallStatus] @@ -19,6 +20,7 @@ type ManagedHookStatusReader = readonly [HookInstallAgent, () => AgentHookInstal export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[] = [ ['claude', () => claudeHookService.install()], + ['openclaude', () => openClaudeHookService.install()], ['codex', () => codexHookService.install()], ['gemini', () => geminiHookService.install()], ['antigravity', () => antigravityHookService.install()], @@ -33,6 +35,7 @@ export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[] const LOCAL_MANAGED_HOOK_REMOVERS: readonly ManagedHookRemover[] = [ ['claude', () => claudeHookService.remove()], + ['openclaude', () => openClaudeHookService.remove()], ['codex', () => codexHookService.remove()], ['gemini', () => geminiHookService.remove()], ['antigravity', () => antigravityHookService.remove()], @@ -47,6 +50,7 @@ const LOCAL_MANAGED_HOOK_REMOVERS: readonly ManagedHookRemover[] = [ const LOCAL_MANAGED_HOOK_STATUS_READERS: readonly ManagedHookStatusReader[] = [ ['claude', () => claudeHookService.getStatus()], + ['openclaude', () => openClaudeHookService.getStatus()], ['codex', () => codexHookService.getStatus()], ['gemini', () => geminiHookService.getStatus()], ['antigravity', () => antigravityHookService.getStatus()], diff --git a/src/main/agent-hooks/migration-unsupported-pty-state.test.ts b/src/main/agent-hooks/migration-unsupported-pty-state.test.ts new file mode 100644 index 00000000000..25b801f30c6 --- /dev/null +++ b/src/main/agent-hooks/migration-unsupported-pty-state.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { MigrationUnsupportedPtyEntry } from '../../shared/agent-status-types' +import { + clearMigrationUnsupportedPty, + clearMigrationUnsupportedPtysForPaneKey, + getMigrationUnsupportedPtySnapshot, + setMigrationUnsupportedPty, + setMigrationUnsupportedPtyListener, + setMigrationUnsupportedPtyPersistenceListener +} from './migration-unsupported-pty-state' + +function makeEntry(ptyId: string, paneKey: string): MigrationUnsupportedPtyEntry { + return { + ptyId, + paneKey, + tabId: paneKey.split(':')[0] ?? paneKey, + worktreeId: 'wt-1', + reason: 'legacy-numeric-pane-key', + source: 'local', + updatedAt: 1_000 + } +} + +describe('migration unsupported PTY state', () => { + afterEach(() => { + setMigrationUnsupportedPtyListener(null) + setMigrationUnsupportedPtyPersistenceListener(null) + for (const entry of getMigrationUnsupportedPtySnapshot()) { + clearMigrationUnsupportedPty(entry.ptyId) + } + }) + + it('persists once when clearing multiple entries for one pane', () => { + const listener = vi.fn() + const persist = vi.fn() + setMigrationUnsupportedPtyListener(listener) + setMigrationUnsupportedPtyPersistenceListener(persist) + const first = makeEntry('pty-1', 'tab-1:leaf-a') + const second = makeEntry('pty-2', 'tab-1:leaf-a') + const otherPane = makeEntry('pty-3', 'tab-2:leaf-b') + + setMigrationUnsupportedPty(first) + setMigrationUnsupportedPty(second) + setMigrationUnsupportedPty(otherPane) + listener.mockClear() + persist.mockClear() + + clearMigrationUnsupportedPtysForPaneKey('tab-1:leaf-a') + + expect(listener).toHaveBeenCalledTimes(2) + expect(listener).toHaveBeenNthCalledWith(1, { type: 'clear', ptyId: 'pty-1' }) + expect(listener).toHaveBeenNthCalledWith(2, { type: 'clear', ptyId: 'pty-2' }) + expect(persist).toHaveBeenCalledTimes(1) + expect(persist).toHaveBeenCalledWith([otherPane]) + }) +}) diff --git a/src/main/agent-hooks/migration-unsupported-pty-state.ts b/src/main/agent-hooks/migration-unsupported-pty-state.ts index 4e214ee13b0..17fa0d09e8f 100644 --- a/src/main/agent-hooks/migration-unsupported-pty-state.ts +++ b/src/main/agent-hooks/migration-unsupported-pty-state.ts @@ -39,9 +39,21 @@ export function clearMigrationUnsupportedPty(ptyId: string): void { } export function clearMigrationUnsupportedPtysForPaneKey(paneKey: string): void { + const ptyIdsToClear: string[] = [] for (const [ptyId, entry] of entriesByPtyId) { if (entry.paneKey === paneKey) { - clearMigrationUnsupportedPty(ptyId) + ptyIdsToClear.push(ptyId) } } + if (ptyIdsToClear.length === 0) { + return + } + // Why: pane teardown can clear several legacy PTYs for one stable pane. + // Persist once after the batch instead of rebuilding the full snapshot for + // every entry while still emitting individual renderer clear events. + for (const ptyId of ptyIdsToClear) { + entriesByPtyId.delete(ptyId) + listener?.({ type: 'clear', ptyId }) + } + persistenceListener?.(getMigrationUnsupportedPtySnapshot()) } diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index 47322d0c364..b9c917e9cd8 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -18,6 +18,7 @@ import { ClaudeHookService } from '../claude/hook-service' import { GrokHookService } from '../grok/hook-service' import { CopilotHookService } from '../copilot/hook-service' import { HermesHookService } from '../hermes/hook-service' +import { openClaudeHookService } from '../openclaude/hook-service' type FakeFs = { files: Map @@ -123,6 +124,10 @@ describe('remote hook service installers', () => { path: '/home/dev/.orca/agent-hooks/claude-hook.sh', install: (sftp: SFTPWrapper) => new ClaudeHookService().installRemote(sftp, '/home/dev') }, + { + path: '/home/dev/.orca/agent-hooks/openclaude-hook.sh', + install: (sftp: SFTPWrapper) => openClaudeHookService.installRemote(sftp, '/home/dev') + }, { path: '/home/dev/.orca/agent-hooks/codex-hook.sh', install: (sftp: SFTPWrapper) => new CodexHookService().installRemote(sftp, '/home/dev') diff --git a/src/main/agent-hooks/remote-managed-hook-installers.ts b/src/main/agent-hooks/remote-managed-hook-installers.ts index 944e522a0a6..a199b1596b4 100644 --- a/src/main/agent-hooks/remote-managed-hook-installers.ts +++ b/src/main/agent-hooks/remote-managed-hook-installers.ts @@ -9,6 +9,7 @@ import { cursorHookService } from '../cursor/hook-service' import { commandCodeHookService } from '../command-code/hook-service' import { grokHookService } from '../grok/hook-service' import { hermesHookService } from '../hermes/hook-service' +import { openClaudeHookService } from '../openclaude/hook-service' type RemoteManagedHookInstaller = readonly [ AgentHookInstallStatus['agent'], @@ -17,6 +18,7 @@ type RemoteManagedHookInstaller = readonly [ const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [ ['claude', (sftp, remoteHome) => claudeHookService.installRemote(sftp, remoteHome)], + ['openclaude', (sftp, remoteHome) => openClaudeHookService.installRemote(sftp, remoteHome)], ['codex', (sftp, remoteHome) => codexHookService.installRemote(sftp, remoteHome)], ['gemini', (sftp, remoteHome) => geminiHookService.installRemote(sftp, remoteHome)], ['antigravity', (sftp, remoteHome) => antigravityHookService.installRemote(sftp, remoteHome)], diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 5901c084fd7..b5a397973ca 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -16,6 +16,7 @@ import { join } from 'path' import { AgentHookServer, agentHookServer, _internals } from './server' import { AGENT_STATUS_MAX_FIELD_LENGTH, + AGENT_STATUS_STALE_AFTER_MS, parseAgentStatusPayload } from '../../shared/agent-status-types' import { makePaneKey } from '../../shared/stable-pane-id' @@ -1789,6 +1790,65 @@ describe('AgentHookServer listener replay', () => { } }) + it('ignores local nested Claude Stop while a parent Codex hook status is active', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const listener = vi.fn() + server.setListener(listener) + const postHook = async ( + source: 'codex' | 'claude', + payload: Record + ): Promise => { + const response = await fetch( + `http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/${source}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload)) + } + ) + expect(response.status).toBe(204) + } + + await postHook('codex', { + hook_event_name: 'UserPromptSubmit', + prompt: 'parent codex' + }) + await postHook('claude', { + hook_event_name: 'Stop', + last_assistant_message: 'child finished' + }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + state: 'working', + prompt: 'parent codex', + agentType: 'codex' + }) + ]) + const snapshot = server.getStatusSnapshot()[0] + expect(snapshot.lastAssistantMessage).toBeUndefined() + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenLastCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + state: 'working', + prompt: 'parent codex', + agentType: 'codex' + }) + }) + ) + } finally { + server.stop() + } + }) + it('maps registered legacy numeric HTTP pane keys to stable pane keys', async () => { const server = new AgentHookServer() await server.start({ env: 'production' }) @@ -3308,6 +3368,32 @@ describe('Claude hook normalization', () => { expect(result?.payload.lastAssistantMessage).toBe('what is up my dude') }) + it('StopFailure maps to done without copying provider error text', () => { + _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'UserPromptSubmit', + prompt: 'say hi' + }), + 'production' + ) + + const result = _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'StopFailure', + error: 'invalid_request', + error_details: 'model is not supported', + last_assistant_message: 'API Error: model is not supported' + }), + 'production' + ) + + expect(result?.payload.state).toBe('done') + expect(result?.payload.prompt).toBe('say hi') + expect(result?.payload.lastAssistantMessage).toBeUndefined() + }) + describe('Stop transcript scan', () => { let tmpDir: string let transcriptPath: string @@ -4027,6 +4113,19 @@ describe('Droid hook normalization', () => { expect(result).toBeNull() }) + it('SubagentStop does not mark Droid done for mission progress', () => { + const result = _internals.normalizeHookPayload( + 'droid', + buildBody({ + hook_event_name: 'SubagentStop', + last_assistant_message: '# Completed Wrote the requested validation assertions' + }), + 'production' + ) + + expect(result).toBeNull() + }) + it('PreToolUse maps to working and surfaces the tool name and input preview', () => { const result = _internals.normalizeHookPayload( 'droid', @@ -5599,6 +5698,211 @@ describe('AgentHookServer ingestRemote', () => { ) }) + it('preserves active pane identity when a nested remote hook reports another agent', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + const listener = vi.fn() + server.setListener(listener) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + payload: { state: 'working', prompt: 'parent codex', agentType: 'codex' } + }, + 'conn-1' + ) + + vi.setSystemTime(1_100) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + payload: { + state: 'working', + prompt: 'nested claude', + agentType: 'claude', + toolName: 'Read', + toolInput: '00-review-context.md' + } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + state: 'working', + prompt: 'nested claude', + agentType: 'codex', + toolName: 'Read', + toolInput: '00-review-context.md', + receivedAt: 1_100 + }) + ]) + expect(listener).toHaveBeenLastCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + prompt: 'nested claude', + agentType: 'codex' + }) + }) + ) + expect(trackMock).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('ignores nested remote done while the parent pane agent is still active', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + const listener = vi.fn() + server.setListener(listener) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + payload: { state: 'working', prompt: 'parent codex', agentType: 'codex' } + }, + 'conn-1' + ) + + vi.setSystemTime(1_100) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + payload: { + state: 'done', + prompt: 'nested claude', + agentType: 'claude', + toolName: 'Read', + toolInput: '00-review-context.md', + lastAssistantMessage: 'child finished' + } + }, + 'conn-1' + ) + + const snapshot = server.getStatusSnapshot() + expect(snapshot).toHaveLength(1) + expect(snapshot[0]).toMatchObject({ + paneKey: PANE, + state: 'working', + prompt: 'parent codex', + agentType: 'codex', + receivedAt: 1_000, + stateStartedAt: 1_000 + }) + expect(snapshot[0].toolName).toBeUndefined() + expect(snapshot[0].toolInput).toBeUndefined() + expect(snapshot[0].lastAssistantMessage).toBeUndefined() + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenLastCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + state: 'working', + prompt: 'parent codex', + agentType: 'codex' + }) + }) + ) + expect(trackMock).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('allows remote pane identity to change after the prior turn is done', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'done', prompt: 'parent codex', agentType: 'codex' } + }, + 'conn-1' + ) + + vi.setSystemTime(1_100) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'real claude turn', agentType: 'claude' } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'working', + prompt: 'real claude turn', + agentType: 'claude', + receivedAt: 1_100 + }) + ]) + } finally { + vi.useRealTimers() + } + }) + + it('allows stale active remote pane identity to change', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'old codex turn', agentType: 'codex' } + }, + 'conn-1' + ) + + vi.setSystemTime(1_000 + AGENT_STATUS_STALE_AFTER_MS + 1) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'new claude turn', agentType: 'claude' } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'working', + prompt: 'new claude turn', + agentType: 'claude', + receivedAt: 1_000 + AGENT_STATUS_STALE_AFTER_MS + 1 + }) + ]) + } finally { + vi.useRealTimers() + } + }) + it('lets remote Claude permission clear when matching approved tool execution starts', () => { const server = new AgentHookServer() const waiting = parseAgentStatusPayload( diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 636b1e4affb..54115644c98 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -44,6 +44,10 @@ import { type AgentStatusState, normalizeAgentStatusPayload } from '../../shared/agent-status-types' +import { + resolveAgentStatusIdentity, + shouldSuppressInheritedTerminalStatus +} from '../../shared/agent-status-identity' import { isAgentInterruptInputIntent, type AgentInterruptInferenceRequest @@ -539,8 +543,10 @@ export class AgentHookServer { } } - private attachStatusTiming(payload: AgentHookEventPayload): EnrichedAgentHookEventPayload { - const now = Date.now() + private attachStatusTiming( + payload: AgentHookEventPayload, + now = Date.now() + ): EnrichedAgentHookEventPayload { const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as | EnrichedAgentHookEventPayload | undefined @@ -627,7 +633,38 @@ export class AgentHookServer { const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as | EnrichedAgentHookEventPayload | undefined - const effectivePayload = attachClaudePermissionToolUseId(previous, payload) + const now = Date.now() + const identity = resolveAgentStatusIdentity({ + existing: previous + ? { + agentType: previous.payload.agentType, + state: previous.payload.state, + updatedAt: previous.receivedAt + } + : undefined, + incoming: payload.payload.agentType, + now + }) + if ( + previous && + shouldSuppressInheritedTerminalStatus({ + inheritedFromActivePane: identity.inheritedFromActivePane, + incomingState: payload.payload.state + }) + ) { + return previous + } + const identityResolvedPayload = + identity.agentType === payload.payload.agentType + ? payload + : { + ...payload, + payload: { + ...payload.payload, + agentType: identity.agentType + } + } + const effectivePayload = attachClaudePermissionToolUseId(previous, identityResolvedPayload) if (previous && shouldKeepClaudePermissionVisible(previous, effectivePayload)) { return previous } @@ -661,8 +698,10 @@ export class AgentHookServer { ) { this.clearAssistantMessageRetry(effectivePayload.paneKey) } - this.maybeTrackAgentPromptSent(effectivePayload, previous) - const enriched = this.attachStatusTiming(effectivePayload) + if (!identity.inheritedFromActivePane) { + this.maybeTrackAgentPromptSent(effectivePayload, previous) + } + const enriched = this.attachStatusTiming(effectivePayload, now) this.runtimeObservedStatusPaneKeys.add(enriched.paneKey) this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched) this.scheduleStatusPersist() diff --git a/src/main/agent-trust-presets.ts b/src/main/agent-trust-presets.ts index 56220d41f4a..163c2183208 100644 --- a/src/main/agent-trust-presets.ts +++ b/src/main/agent-trust-presets.ts @@ -5,6 +5,8 @@ import { writeFileAtomically } from './codex-accounts/fs-utils' import { getOrcaManagedCodexHomePath } from './codex/codex-home-paths' import { upsertProjectTrustLevel } from './codex/config-toml-trust' +export type AgentTrustPreset = 'cursor' | 'copilot' | 'codex' + /** * Pre-mark a workspace as trusted for cursor-agent, GitHub Copilot CLI, or * Codex so the agent's "Do you trust this folder?" menu does not fire on diff --git a/src/main/amp/hook-service.test.ts b/src/main/amp/hook-service.test.ts index 9c83074d9a1..25c6939a10f 100644 --- a/src/main/amp/hook-service.test.ts +++ b/src/main/amp/hook-service.test.ts @@ -50,10 +50,13 @@ describe('AmpHookService', () => { expect(source).toContain("amp.on('tool.result'") expect(source).toContain("amp.on('agent.end'") expect(source).toContain('return { action: "allow" }') - expect(source).toContain('let postQueue = Promise.resolve()') + expect(source).toContain('const MAX_PENDING_POSTS = 50') + expect(source).toContain('let postQueue: QueuedPost[] = []') expect(source).toContain('function enqueuePost') + expect(source).toContain('postQueue.shift()') expect(source).toContain('enqueuePost("tool.call"') expect(source).not.toContain('await post("tool.call"') + expect(source).not.toContain('postQueue = postQueue.then') expect(source).toContain('process.env.ORCA_PANE_KEY') expect(source).toContain('process.env.ORCA_AGENT_HOOK_ENDPOINT') }) diff --git a/src/main/amp/hook-service.ts b/src/main/amp/hook-service.ts index 93b0e066fc0..ad56b46413d 100644 --- a/src/main/amp/hook-service.ts +++ b/src/main/amp/hook-service.ts @@ -247,11 +247,35 @@ function getAmpPluginSource(): string { ' }', '}', '', - 'let postQueue = Promise.resolve()', + 'const MAX_PENDING_POSTS = 50', + 'type QueuedPost = { hookEventName: string; payload: Record }', + 'let postQueue: QueuedPost[] = []', + 'let postDraining = false', + '', + 'async function drainPostQueue(): Promise {', + ' if (postDraining) return', + ' postDraining = true', + ' try {', + ' while (postQueue.length > 0) {', + ' const next = postQueue.shift()', + ' if (!next) continue', + ' await post(next.hookEventName, next.payload)', + ' }', + ' } finally {', + ' postDraining = false', + ' if (postQueue.length > 0) {', + ' void drainPostQueue()', + ' }', + ' }', + '}', 'function enqueuePost(hookEventName: string, payload: Record): void {', - ' // Why: keep hook callbacks non-blocking while preserving Amp event order.', - ' postQueue = postQueue.then(() => post(hookEventName, payload), () => post(hookEventName, payload))', - ' void postQueue.catch(() => {})', + ' // Why: keep hook callbacks non-blocking without retaining unbounded', + ' // payload closures when Orca is down and each POST waits for timeout.', + ' if (postQueue.length >= MAX_PENDING_POSTS) {', + ' postQueue.shift()', + ' }', + ' postQueue.push({ hookEventName, payload })', + ' void drainPostQueue()', '}', '', 'export default function (amp: PluginAPI) {', diff --git a/src/main/antigravity/hook-service.ts b/src/main/antigravity/hook-service.ts index 8a60eca877e..8a019acd4f4 100644 --- a/src/main/antigravity/hook-service.ts +++ b/src/main/antigravity/hook-service.ts @@ -124,7 +124,9 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { // the event name so Orca shows a status row instead of silently dropping it. " payload='{}'", 'fi', + // Timeout caps best-effort hook posts if the local listener stalls. 'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/antigravity" \\', + ' --connect-timeout 0.5 --max-time 1.5 \\', ' -H "Content-Type: application/x-www-form-urlencoded" \\', ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', @@ -160,7 +162,9 @@ function getWindowsWrapperScript(eventName: string): string { } function buildWindowsAntigravityHookPostCommand(): string { - return `powershell -NoProfile -ExecutionPolicy Bypass -Command "$utf8=[System.Text.UTF8Encoding]::new($false); [Console]::InputEncoding=$utf8; [Console]::OutputEncoding=$utf8; $inputData=[Console]::In.ReadToEnd(); try { $payload=if ([string]::IsNullOrWhiteSpace($inputData)) { @{} } else { $inputData | ConvertFrom-Json }; $body=@{ paneKey=$env:ORCA_PANE_KEY; tabId=$env:ORCA_TAB_ID; worktreeId=$env:ORCA_WORKTREE_ID; env=$env:ORCA_AGENT_HOOK_ENV; version=$env:ORCA_AGENT_HOOK_VERSION; hook_event_name=$env:ORCA_ANTIGRAVITY_EVENT; payload=$payload } | ConvertTo-Json -Depth 100 -Compress; $bodyBytes=$utf8.GetBytes($body); Invoke-WebRequest -UseBasicParsing -Method Post -Uri ('http://127.0.0.1:' + $env:ORCA_AGENT_HOOK_PORT + '/hook/antigravity') -ContentType 'application/json; charset=utf-8' -Headers @{ 'X-Orca-Agent-Hook-Token'=$env:ORCA_AGENT_HOOK_TOKEN } -Body $bodyBytes | Out-Null } catch {}"` + // Why: Antigravity hooks are best-effort status updates; do not let a stalled + // local listener hold the agent process open. + return `powershell -NoProfile -ExecutionPolicy Bypass -Command "$utf8=[System.Text.UTF8Encoding]::new($false); [Console]::InputEncoding=$utf8; [Console]::OutputEncoding=$utf8; $inputData=[Console]::In.ReadToEnd(); try { $payload=if ([string]::IsNullOrWhiteSpace($inputData)) { @{} } else { $inputData | ConvertFrom-Json }; $body=@{ paneKey=$env:ORCA_PANE_KEY; tabId=$env:ORCA_TAB_ID; worktreeId=$env:ORCA_WORKTREE_ID; env=$env:ORCA_AGENT_HOOK_ENV; version=$env:ORCA_AGENT_HOOK_VERSION; hook_event_name=$env:ORCA_ANTIGRAVITY_EVENT; payload=$payload } | ConvertTo-Json -Depth 100 -Compress; $bodyBytes=$utf8.GetBytes($body); Invoke-WebRequest -UseBasicParsing -Method Post -Uri ('http://127.0.0.1:' + $env:ORCA_AGENT_HOOK_PORT + '/hook/antigravity') -ContentType 'application/json; charset=utf-8' -Headers @{ 'X-Orca-Agent-Hook-Token'=$env:ORCA_AGENT_HOOK_TOKEN } -Body $bodyBytes -TimeoutSec 2 | Out-Null } catch {}"` } function isRecord(value: unknown): value is Record { diff --git a/src/main/attribution/terminal-attribution.test.ts b/src/main/attribution/terminal-attribution.test.ts index ef99f3f2ba2..1686cbe0ed0 100644 --- a/src/main/attribution/terminal-attribution.test.ts +++ b/src/main/attribution/terminal-attribution.test.ts @@ -14,7 +14,7 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { applyTerminalAttributionEnv } from './terminal-attribution' +import { applyTerminalAttributionEnv, resolveAttributionShellFamily } from './terminal-attribution' describe('applyTerminalAttributionEnv', () => { let tmpRoot: string | null = null @@ -61,6 +61,26 @@ describe('applyTerminalAttributionEnv', () => { }) } + it('classifies Windows native and POSIX shell families for attribution shims', () => { + expect(resolveAttributionShellFamily({ platform: 'win32', shellPath: 'powershell.exe' })).toBe( + 'native-windows' + ) + expect(resolveAttributionShellFamily({ platform: 'win32', shellPath: 'cmd.exe' })).toBe( + 'native-windows' + ) + expect( + resolveAttributionShellFamily({ + platform: 'win32', + shellPath: 'C:\\Program Files\\Git\\bin\\bash.exe' + }) + ).toBe('posix') + expect(resolveAttributionShellFamily({ platform: 'win32', shellPath: 'wsl.exe' })).toBe('posix') + expect(resolveAttributionShellFamily({ platform: 'win32', isWsl: true })).toBe('posix') + expect(resolveAttributionShellFamily({ platform: 'darwin', shellPath: '/bin/zsh' })).toBe( + undefined + ) + }) + it('does not amend HEAD when git commit --dry-run exits successfully', () => { const root = makeTmpRoot() const repo = join(root, 'repo') @@ -730,6 +750,50 @@ exit 1 expect(new Set(shimEntries).size).toBe(shimEntries.length) }) + it('puts only Windows shims on PATH for native Windows shells', () => { + const root = makeTmpRoot() + const userDataPath = join(root, 'user-data') + const baseEnv: Record = { PATH: 'C:\\Git\\cmd;C:\\Windows\\System32' } + + applyTerminalAttributionEnv(baseEnv, { + enabled: true, + platform: 'win32', + shellFamily: 'native-windows', + userDataPath + }) + + const posixDir = join(userDataPath, 'orca-terminal-attribution', 'posix') + const win32Dir = join(userDataPath, 'orca-terminal-attribution', 'win32') + const pathEntries = baseEnv.PATH.split(';') + + expect(pathEntries[0]).toBe(win32Dir) + expect(pathEntries).not.toContain(posixDir) + expect(baseEnv.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined() + expect(existsSync(join(win32Dir, 'git.cmd'))).toBe(true) + }) + + it('keeps POSIX shims first for Windows Git Bash and WSL shells', () => { + const root = makeTmpRoot() + const userDataPath = join(root, 'user-data') + const baseEnv: Record = { PATH: 'C:\\Program Files\\Git\\cmd;C:\\Windows' } + + applyTerminalAttributionEnv(baseEnv, { + enabled: true, + platform: 'win32', + shellFamily: 'posix', + userDataPath + }) + + const posixDir = join(userDataPath, 'orca-terminal-attribution', 'posix') + const win32Dir = join(userDataPath, 'orca-terminal-attribution', 'win32') + const pathEntries = baseEnv.PATH.split(';') + + expect(pathEntries[0]).toBe(posixDir) + expect(pathEntries).not.toContain(win32Dir) + expect(baseEnv.ORCA_ATTRIBUTION_SHIM_DIR).toBe(posixDir) + expect(existsSync(join(posixDir, 'git'))).toBe(true) + }) + it('writes PowerShell wrappers without raw-template backslash escapes', () => { const root = makeTmpRoot() applyTerminalAttributionEnv( diff --git a/src/main/attribution/terminal-attribution.ts b/src/main/attribution/terminal-attribution.ts index 4162e15e33c..252ac589b3d 100644 --- a/src/main/attribution/terminal-attribution.ts +++ b/src/main/attribution/terminal-attribution.ts @@ -3,7 +3,7 @@ scripts for both POSIX shells and Windows shells. Keeping the scripts adjacent to the env injection code makes the attribution behavior auditable as one unit instead of scattering generated shell fragments across files. */ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' -import { join } from 'path' +import { join, win32 as pathWin32 } from 'path' import { ORCA_GIT_COMMIT_TRAILER } from '../../shared/orca-attribution' const ATTRIBUTION_ROOT_DIR = 'orca-terminal-attribution' @@ -29,12 +29,39 @@ type AttributionShimPaths = { win32Dir: string } +export type AttributionShellFamily = 'native-windows' | 'posix' + +export function resolveAttributionShellFamily(options: { + platform?: NodeJS.Platform + shellPath?: string + isWsl?: boolean +}): AttributionShellFamily | undefined { + const platform = options.platform ?? process.platform + if (platform !== 'win32') { + return undefined + } + const shellName = options.shellPath?.replaceAll('\\', '/').split('/').pop()?.toLowerCase() + if (options.isWsl || shellName === 'wsl.exe' || shellName === 'wsl') { + return 'posix' + } + if (shellName === 'bash.exe' || shellName === 'sh.exe' || shellName === 'zsh.exe') { + return 'posix' + } + return 'native-windows' +} + export function applyTerminalAttributionEnv( baseEnv: Record, - options: { enabled: boolean; userDataPath: string } + options: { + enabled: boolean + userDataPath: string + platform?: NodeJS.Platform + shellFamily?: AttributionShellFamily + } ): void { + const platform = options.platform ?? process.platform if (!options.enabled) { - clearTerminalAttributionEnv(baseEnv) + clearTerminalAttributionEnv(baseEnv, platform) return } @@ -45,19 +72,20 @@ export function applyTerminalAttributionEnv( return } - const pathDelimiter = process.platform === 'win32' ? ';' : ':' + const pathDelimiter = platform === 'win32' ? ';' : ':' const basePath = baseEnv.PATH ?? process.env.PATH ?? '' // Why: resolve real Windows commands before prepending shims so cmd wrappers // cannot recursively point ORCA_REAL_* at themselves. - const resolvedGit = - process.platform === 'win32' ? resolveWindowsExecutable('git', basePath) : null - const resolvedGh = process.platform === 'win32' ? resolveWindowsExecutable('gh', basePath) : null + const resolvedGit = platform === 'win32' ? resolveWindowsExecutable('git', basePath) : null + const resolvedGh = platform === 'win32' ? resolveWindowsExecutable('gh', basePath) : null const { posixDir, win32Dir } = shimPaths - // Why: Windows terminals may be cmd/PowerShell or Git Bash. Include both shim - // families; native shells ignore extensionless POSIX files, Git Bash can use them. - const prependDirs = process.platform === 'win32' ? [posixDir, win32Dir] : [posixDir] + const shellFamily = options.shellFamily ?? (platform === 'win32' ? 'native-windows' : 'posix') + // Why: Windows native shells can try to open extensionless POSIX shims before + // PATHEXT reaches git.cmd, which surfaces an "Open With" dialog. + const prependDirs = + platform === 'win32' && shellFamily === 'native-windows' ? [win32Dir] : [posixDir] const prependDirKeys = new Set( - prependDirs.map((dir) => (process.platform === 'win32' ? dir.toLowerCase() : dir)) + prependDirs.map((dir) => (platform === 'win32' ? dir.toLowerCase() : dir)) ) const cleanedBasePath = stripAttributionPathEntries(basePath, pathDelimiter) .split(pathDelimiter) @@ -65,7 +93,7 @@ export function applyTerminalAttributionEnv( if (!entry) { return false } - const key = process.platform === 'win32' ? entry.toLowerCase() : entry + const key = platform === 'win32' ? entry.toLowerCase() : entry return !prependDirKeys.has(key) }) .join(pathDelimiter) @@ -79,9 +107,13 @@ export function applyTerminalAttributionEnv( baseEnv.ORCA_GIT_COMMIT_TRAILER = ORCA_GIT_COMMIT_TRAILER baseEnv.ORCA_GH_PR_FOOTER = ORCA_GH_FOOTER baseEnv.ORCA_GH_ISSUE_FOOTER = ORCA_GH_FOOTER - baseEnv.ORCA_ATTRIBUTION_SHIM_DIR = posixDir + if (shellFamily === 'posix') { + baseEnv.ORCA_ATTRIBUTION_SHIM_DIR = posixDir + } else { + delete baseEnv.ORCA_ATTRIBUTION_SHIM_DIR + } - if (process.platform === 'win32') { + if (platform === 'win32') { if (resolvedGit) { baseEnv.ORCA_REAL_GIT = resolvedGit } @@ -91,11 +123,14 @@ export function applyTerminalAttributionEnv( } } -function clearTerminalAttributionEnv(baseEnv: Record): void { +function clearTerminalAttributionEnv( + baseEnv: Record, + platform: NodeJS.Platform +): void { for (const key of ATTRIBUTION_ENV_KEYS) { delete baseEnv[key] } - const pathDelimiter = process.platform === 'win32' ? ';' : ':' + const pathDelimiter = platform === 'win32' ? ';' : ':' const cleanedPath = stripAttributionPathEntries(baseEnv.PATH ?? '', pathDelimiter) if (cleanedPath) { baseEnv.PATH = cleanedPath @@ -167,12 +202,12 @@ function resolveWindowsExecutable(command: string, pathValue: string): string | for (const dir of searchDirs) { for (const ext of pathExt) { - const candidate = join(dir, `${command}${ext}`) + const candidate = pathWin32.join(dir, `${command}${ext}`) if (existsSync(candidate)) { return candidate } } - const bareCandidate = join(dir, command) + const bareCandidate = pathWin32.join(dir, command) if (existsSync(bareCandidate)) { return bareCandidate } diff --git a/src/main/automations/external-manager.test.ts b/src/main/automations/external-manager.test.ts index 05b54874eb1..71b46d67f3e 100644 --- a/src/main/automations/external-manager.test.ts +++ b/src/main/automations/external-manager.test.ts @@ -1,13 +1,16 @@ /* eslint-disable max-lines -- Why: external automation mapping and lifecycle IPC share fixtures. */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createExternalAutomation, + listExternalAutomationManagers, listExternalAutomationRuns, runExternalAutomationAction, updateExternalAutomation } from './external-manager' import { mapHermesJobs, mapOpenClawJobs } from './external-job-mappers' import { getActiveMultiplexer } from '../ipc/ssh' +import type { Store } from '../persistence' +import type * as Fs from 'fs' const execFileMock = vi.hoisted(() => vi.fn((...args: unknown[]) => { @@ -16,20 +19,64 @@ const execFileMock = vi.hoisted(() => const execCallback = callback as (error: Error | null, stdout: string, stderr: string) => void execCallback(null, '', '') } + return { kill: vi.fn() } }) ) +const existsSyncMock = vi.hoisted(() => vi.fn(() => false)) + +function resolveExecFileMock(...args: unknown[]) { + const callback = args.at(-1) + if (typeof callback === 'function') { + const execCallback = callback as (error: Error | null, stdout: string, stderr: string) => void + execCallback(null, '', '') + } + return { kill: vi.fn() } +} vi.mock('child_process', () => ({ execFile: execFileMock })) +vi.mock('fs', async () => { + const actual = await vi.importActual('fs') + return { + ...actual, + existsSync: existsSyncMock + } +}) vi.mock('../ipc/ssh', () => ({ getActiveMultiplexer: vi.fn() })) beforeEach(() => { - execFileMock.mockClear() + execFileMock.mockReset() + execFileMock.mockImplementation(resolveExecFileMock) + existsSyncMock.mockReturnValue(false) vi.mocked(getActiveMultiplexer).mockReset() }) +afterEach(() => { + vi.useRealTimers() +}) + +describe('listExternalAutomationManagers', () => { + it('settles when a local command lookup hangs', async () => { + vi.useFakeTimers() + execFileMock.mockImplementation(() => ({ kill: vi.fn() })) + const promise = listExternalAutomationManagers({ + getSshTargets: () => [] + } as unknown as Store) + let settled = false + void promise.finally(() => { + settled = true + }) + + await vi.advanceTimersByTimeAsync(5_000) + await Promise.resolve() + + expect(settled).toBe(true) + await expect(promise).resolves.toEqual([]) + }) +}) + describe('mapHermesJobs', () => { it('normalizes Hermes cron jobs into external automation rows', () => { const jobs = mapHermesJobs('hermes:local', [ @@ -177,11 +224,40 @@ describe('createExternalAutomation', () => { '--workdir', '/repo' ], - { encoding: 'utf-8' }, + { encoding: 'utf-8', timeout: 30_000 }, expect.any(Function) ) }) + it('settles when local Hermes cron creation hangs', async () => { + vi.useFakeTimers() + const killMock = vi.fn() + execFileMock.mockImplementation(() => ({ kill: killMock })) + + const promise = createExternalAutomation({ + managerId: 'hermes:local', + provider: 'hermes', + target: { type: 'local' }, + name: 'Nightly audit', + prompt: 'Audit the repo', + schedule: '0 9 * * 1-5', + workdir: null + }) + let settled = false + void promise + .catch(() => undefined) + .finally(() => { + settled = true + }) + + await vi.advanceTimersByTimeAsync(30_000) + await Promise.resolve() + + expect(settled).toBe(true) + await expect(promise).rejects.toThrow('Local automation command timed out') + expect(killMock).toHaveBeenCalled() + }) + it('updates local Hermes cron jobs through the CLI', async () => { await updateExternalAutomation({ managerId: 'hermes:local', @@ -209,7 +285,7 @@ describe('createExternalAutomation', () => { '--workdir', '/repo' ], - { encoding: 'utf-8' }, + { encoding: 'utf-8', timeout: 30_000 }, expect.any(Function) ) }) @@ -228,7 +304,7 @@ describe('runExternalAutomationAction', () => { expect(execFileMock).toHaveBeenCalledWith( 'hermes', ['cron', 'run', 'job-1'], - { encoding: 'utf-8' }, + { encoding: 'utf-8', timeout: 30_000 }, expect.any(Function) ) }) @@ -259,7 +335,7 @@ describe('runExternalAutomationAction', () => { expect(execFileMock).toHaveBeenCalledWith( 'openclaw', ['cron', 'disable', 'job-1'], - { encoding: 'utf-8' }, + { encoding: 'utf-8', timeout: 30_000 }, expect.any(Function) ) }) diff --git a/src/main/automations/external-manager.ts b/src/main/automations/external-manager.ts index 08c3e208cf6..a08198331bc 100644 --- a/src/main/automations/external-manager.ts +++ b/src/main/automations/external-manager.ts @@ -5,7 +5,6 @@ import { existsSync } from 'fs' import { readFile } from 'fs/promises' import { homedir } from 'os' import { join } from 'path' -import { promisify } from 'util' import type { ExternalAutomationAction, ExternalAutomationActionInput, @@ -25,12 +24,13 @@ import { readHermesCronOutputRunsPage } from './hermes-cron-output' -const execFileAsync = promisify(execFile) const HERMES_HOME = process.env.HERMES_HOME?.trim() || join(homedir(), '.hermes') const HERMES_CRON_DIR = join(HERMES_HOME, 'cron') const HERMES_JOBS_FILE = join(HERMES_CRON_DIR, 'jobs.json') const OPENCLAW_JOBS_FILE = join(homedir(), '.openclaw', 'cron', 'jobs.json') const EXTERNAL_JOB_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/ +const LOCAL_COMMAND_LOOKUP_TIMEOUT_MS = 5_000 +const LOCAL_AUTOMATION_COMMAND_TIMEOUT_MS = 30_000 function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) @@ -39,13 +39,92 @@ function isRecord(value: unknown): value is Record { async function isCommandOnPath(command: string): Promise { const finder = process.platform === 'win32' ? 'where' : 'which' try { - await execFileAsync(finder, [command], { encoding: 'utf-8' }) + await runLocalCommandLookup(finder, [command]) return true } catch { return false } } +function runLocalCommandLookup(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + let child: ReturnType | null = null + let settled = false + + const finish = (error: Error | null): void => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + if (error) { + reject(error) + return + } + resolve() + } + + // Why: these probes run while loading Automations; a wedged PATH shim must + // not keep the list IPC pending forever. + const timeout = setTimeout(() => { + child?.kill() + finish(new Error(`Command lookup timed out after ${LOCAL_COMMAND_LOOKUP_TIMEOUT_MS}ms.`)) + }, LOCAL_COMMAND_LOOKUP_TIMEOUT_MS) + + try { + child = execFile(command, args, { encoding: 'utf-8' }, (error) => { + finish(error ?? null) + }) + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))) + } + }) +} + +function runLocalAutomationCommand(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + let child: ReturnType | null = null + let settled = false + + const finish = (error: Error | null): void => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + if (error) { + reject(error) + return + } + resolve() + } + + // Why: local automation mutations back UI actions; a wedged CLI must not + // keep create/edit/run/delete pending after Node signals a timeout. + const timeout = setTimeout(() => { + child?.kill() + finish( + new Error( + `Local automation command timed out after ${LOCAL_AUTOMATION_COMMAND_TIMEOUT_MS}ms.` + ) + ) + }, LOCAL_AUTOMATION_COMMAND_TIMEOUT_MS) + + try { + child = execFile( + command, + args, + { encoding: 'utf-8', timeout: LOCAL_AUTOMATION_COMMAND_TIMEOUT_MS }, + (error) => { + finish(error ?? null) + } + ) + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))) + } + }) +} + async function readLocalHermesJobs(): Promise { if (!existsSync(HERMES_JOBS_FILE)) { return [] @@ -400,7 +479,7 @@ export async function createExternalAutomation( ): Promise { const normalized = normalizeHermesCronMutationInput(input) if (input.target.type === 'local') { - await execFileAsync('hermes', hermesCronCreateArgs(normalized), { encoding: 'utf-8' }) + await runLocalAutomationCommand('hermes', hermesCronCreateArgs(normalized)) clearHermesCronOutputRunCountCache() return } @@ -422,9 +501,7 @@ export async function updateExternalAutomation( } const normalized = normalizeHermesCronMutationInput(input) if (input.target.type === 'local') { - await execFileAsync('hermes', hermesCronEditArgs(input.jobId, normalized), { - encoding: 'utf-8' - }) + await runLocalAutomationCommand('hermes', hermesCronEditArgs(input.jobId, normalized)) clearHermesCronOutputRunCountCache(input.jobId) return } @@ -450,7 +527,7 @@ export async function runExternalAutomationAction( ? hermesCommandForAction(input.action) : openClawCommandForAction(input.action) if (input.target.type === 'local') { - await execFileAsync(input.provider, ['cron', command, input.jobId], { encoding: 'utf-8' }) + await runLocalAutomationCommand(input.provider, ['cron', command, input.jobId]) if (input.provider === 'hermes') { clearHermesCronOutputRunCountCache(input.jobId) } diff --git a/src/main/automations/hermes-cron-output.test.ts b/src/main/automations/hermes-cron-output.test.ts index b0eb79867d6..4adeab33da3 100644 --- a/src/main/automations/hermes-cron-output.test.ts +++ b/src/main/automations/hermes-cron-output.test.ts @@ -34,7 +34,7 @@ const fakeDatabase = vi.hoisted(() => }) ) -vi.mock('better-sqlite3', () => ({ +vi.mock('../sqlite/sync-database', () => ({ default: fakeDatabase })) @@ -174,6 +174,36 @@ Run summary: monitor automation completed successfully. ) }) + it('hydrates referenced logs in valid dot-dot-prefixed Hermes subdirectories', async () => { + const home = await createHermesHome() + const outputDir = join(home, 'cron', 'output', 'job-1') + const scriptLogPath = join(home, '..logs', 'x-monitor.log') + await mkdir(outputDir, { recursive: true }) + await mkdir(dirname(scriptLogPath), { recursive: true }) + await writeFile(scriptLogPath, 'dot-dot-prefixed log line\n', 'utf-8') + await writeFile( + join(outputDir, '2026-05-15_09-02-00.md'), + `# Cron Job: Monitor automation + +## Response + +Latest log path: ${scriptLogPath} +Run summary: monitor automation completed successfully. +`, + 'utf-8' + ) + + const { readHermesCronOutputRunsPage } = await loadReader() + const page = await readHermesCronOutputRunsPage('job-1', { page: 1, pageSize: 25 }) + + expect((page.runs[0] as { output_content?: string }).output_content).toContain( + '## Latest log file' + ) + expect((page.runs[0] as { output_content?: string }).output_content).toContain( + 'dot-dot-prefixed log line' + ) + }) + it('uses a count-only path when page size is zero', async () => { const home = await createHermesHome() const outputDir = join(home, 'cron', 'output', 'job-1') @@ -229,4 +259,27 @@ Run summary: monitor automation completed successfully. runs: [] }) }) + + it('evicts oldest count cache entries when many job ids are observed', async () => { + const home = await createHermesHome() + const outputDir = join(home, 'cron', 'output', 'job-0') + await mkdir(outputDir, { recursive: true }) + await writeFile(join(outputDir, '2026-05-15_09-02-00.md'), 'first run', 'utf-8') + + const { readHermesCronOutputRunsPage } = await loadReader() + await expect(readHermesCronOutputRunsPage('job-0', { page: 1, pageSize: 0 })).resolves.toEqual({ + total: 1, + runs: [] + }) + + for (let i = 1; i <= 200; i += 1) { + await readHermesCronOutputRunsPage(`job-${i}`, { page: 1, pageSize: 0 }) + } + await writeFile(join(outputDir, '2026-05-15_09-03-00.md'), 'second run', 'utf-8') + + await expect(readHermesCronOutputRunsPage('job-0', { page: 1, pageSize: 0 })).resolves.toEqual({ + total: 2, + runs: [] + }) + }) }) diff --git a/src/main/automations/hermes-cron-output.ts b/src/main/automations/hermes-cron-output.ts index a54400dc7fc..1b36eb3011e 100644 --- a/src/main/automations/hermes-cron-output.ts +++ b/src/main/automations/hermes-cron-output.ts @@ -3,8 +3,8 @@ import { existsSync } from 'fs' import { open, readdir, readFile, realpath, stat } from 'fs/promises' import { homedir } from 'os' -import { isAbsolute, join, relative, resolve } from 'path' -import Database from 'better-sqlite3' +import { isAbsolute, join, relative, resolve, sep } from 'path' +import Database from '../sqlite/sync-database' const HERMES_HOME = process.env.HERMES_HOME?.trim() || join(homedir(), '.hermes') const HERMES_OUTPUT_DIR = join(HERMES_HOME, 'cron', 'output') @@ -162,7 +162,11 @@ async function readReferencedLogFile(content: string): Promise<{ const relativeToHermesHome = relative(resolve(homeRealPath), resolve(logRealPath)) // Why: the output body can contain agent-authored text, so only hydrate // referenced files that resolve inside Hermes' own data directory. - if (relativeToHermesHome.startsWith('..') || isAbsolute(relativeToHermesHome)) { + if ( + relativeToHermesHome === '..' || + relativeToHermesHome.startsWith(`..${sep}`) || + isAbsolute(relativeToHermesHome) + ) { return null } const logStat = await stat(logPath) @@ -406,6 +410,7 @@ async function readHermesCronOutputRunRefs(jobId: string): Promise expiresAt: number @@ -420,12 +425,33 @@ export function clearHermesCronOutputRunCountCache(jobId?: string): void { hermesRunCountCache.clear() } +function pruneHermesRunCountCache(now: number): void { + for (const [jobId, entry] of hermesRunCountCache) { + if (entry.expiresAt <= now) { + hermesRunCountCache.delete(jobId) + } + } + while (hermesRunCountCache.size >= HERMES_RUN_COUNT_CACHE_MAX_ENTRIES) { + const oldestJobId = hermesRunCountCache.keys().next().value + if (oldestJobId === undefined) { + return + } + hermesRunCountCache.delete(oldestJobId) + } +} + async function readHermesCronOutputRunCount(jobId: string): Promise { const now = Date.now() const cached = hermesRunCountCache.get(jobId) if (cached && cached.expiresAt > now) { return cached.promise } + if (cached) { + hermesRunCountCache.delete(jobId) + } + // Why: external Hermes jobs can be created/removed outside Orca; without a + // size bound and expired sweep, a long session can pin stale job ids forever. + pruneHermesRunCountCache(now) const entry: HermesRunCountCacheEntry = { promise: readHermesCronOutputRunRefs(jobId).then((refs) => refs.length), expiresAt: Number.POSITIVE_INFINITY diff --git a/src/main/automations/precheck-runner.test.ts b/src/main/automations/precheck-runner.test.ts new file mode 100644 index 00000000000..bdcda1ba221 --- /dev/null +++ b/src/main/automations/precheck-runner.test.ts @@ -0,0 +1,102 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { runAutomationPrecheck } from './precheck-runner' + +const sshManagerState = vi.hoisted(() => ({ + manager: null as null | { + getConnection: ReturnType + } +})) + +vi.mock('../ipc/ssh', () => ({ + getSshConnectionManager: () => sshManagerState.manager +})) + +const node = JSON.stringify(process.execPath) + +function nodeCommand(script: string): string { + return `${node} -e ${JSON.stringify(script)}` +} + +describe('runAutomationPrecheck', () => { + let cwd = '' + + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), 'orca-precheck-test-')) + sshManagerState.manager = null + }) + + afterEach(() => { + rmSync(cwd, { recursive: true, force: true }) + }) + + it('captures exit code and output for a non-zero local precheck', async () => { + const result = await runAutomationPrecheck({ + precheck: { + command: nodeCommand( + "console.log('stdout text'); console.error('stderr text'); process.exit(7)" + ), + timeoutSeconds: 5 + }, + target: { type: 'local', cwd } + }) + + expect(result.exitCode).toBe(7) + expect(result.timedOut).toBe(false) + expect(result.stdout).toContain('stdout text') + expect(result.stderr).toContain('stderr text') + expect(result.error).toBeNull() + }) + + it('marks a local precheck as timed out', async () => { + const result = await runAutomationPrecheck({ + precheck: { + command: nodeCommand('setTimeout(() => {}, 5000)'), + timeoutSeconds: 1 + }, + target: { type: 'local', cwd } + }) + + expect(result.exitCode).toBeNull() + expect(result.timedOut).toBe(true) + expect(result.error).toBe('Precheck timed out after 1s.') + }) + + it('uses the SSH channel exit event as the precheck exit code', async () => { + const channel = Object.assign(new EventEmitter(), { + stderr: new PassThrough(), + close: vi.fn() + }) + sshManagerState.manager = { + getConnection: vi.fn(() => ({ + getState: () => ({ status: 'connected' }), + exec: vi.fn(async () => channel) + })) + } + + const resultPromise = runAutomationPrecheck({ + precheck: { + command: "printf 'ready'", + timeoutSeconds: 5 + }, + target: { + type: 'ssh', + cwd: '/repo/path', + connectionId: 'ssh-1' + } + }) + await Promise.resolve() + channel.emit('data', Buffer.from('ready\n')) + channel.emit('exit', 0) + channel.emit('close') + + const result = await resultPromise + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('ready') + expect(result.error).toBeNull() + }) +}) diff --git a/src/main/automations/precheck-runner.ts b/src/main/automations/precheck-runner.ts new file mode 100644 index 00000000000..fa9a452086f --- /dev/null +++ b/src/main/automations/precheck-runner.ts @@ -0,0 +1,269 @@ +import { spawn, type ChildProcess } from 'node:child_process' +import type { ClientChannel } from 'ssh2' +import type { AutomationPrecheck, AutomationPrecheckResult } from '../../shared/automations-types' +import { MAX_AUTOMATION_PRECHECK_OUTPUT_CHARS } from '../../shared/automation-precheck' +import { getSshConnectionManager } from '../ipc/ssh' +import { shellEscape } from '../ssh/ssh-connection-utils' + +type AutomationPrecheckExecutionTarget = + | { + type: 'local' + cwd: string + } + | { + type: 'ssh' + cwd: string + connectionId: string + } + +type TailBuffer = { + content: string + truncated: boolean +} + +function appendTail(buffer: TailBuffer, chunk: string): TailBuffer { + const content = `${buffer.content}${chunk}` + if (content.length <= MAX_AUTOMATION_PRECHECK_OUTPUT_CHARS) { + return { ...buffer, content } + } + return { + content: content.slice(-MAX_AUTOMATION_PRECHECK_OUTPUT_CHARS), + truncated: true + } +} + +function createPrecheckResult(args: { + precheck: AutomationPrecheck + startedAt: number + stdout: TailBuffer + stderr: TailBuffer + exitCode: number | null + timedOut: boolean + error: string | null +}): AutomationPrecheckResult { + const completedAt = Date.now() + return { + command: args.precheck.command, + exitCode: args.exitCode, + timedOut: args.timedOut, + durationMs: Math.max(0, completedAt - args.startedAt), + stdout: args.stdout.content, + stderr: args.stderr.content, + stdoutTruncated: args.stdout.truncated, + stderrTruncated: args.stderr.truncated, + error: args.error, + startedAt: args.startedAt, + completedAt + } +} + +function failedPrecheckResult( + precheck: AutomationPrecheck, + startedAt: number, + error: string +): AutomationPrecheckResult { + return createPrecheckResult({ + precheck, + startedAt, + stdout: { content: '', truncated: false }, + stderr: { content: '', truncated: false }, + exitCode: null, + timedOut: false, + error + }) +} + +function killLocalPrecheckProcessTree(child: ChildProcess): ReturnType | null { + const pid = child.pid + if (!pid) { + child.kill() + return null + } + + if (process.platform === 'win32') { + try { + // Why: shell prechecks can launch child processes; taskkill walks the + // Windows process tree so timeout means the command is actually stopped. + const killer = spawn('taskkill', ['/pid', String(pid), '/t', '/f'], { + stdio: 'ignore', + windowsHide: true + }) + killer.on('error', () => child.kill()) + killer.unref() + } catch { + child.kill() + } + return null + } + + try { + process.kill(-pid, 'SIGTERM') + } catch { + child.kill() + } + + const forceKillTimer = setTimeout(() => { + try { + process.kill(-pid, 'SIGKILL') + } catch { + /* process group already exited */ + } + }, 2000) + forceKillTimer.unref?.() + return forceKillTimer +} + +function runLocalPrecheck( + precheck: AutomationPrecheck, + target: Extract +): Promise { + const startedAt = Date.now() + const timeoutMs = precheck.timeoutSeconds * 1000 + return new Promise((resolve) => { + let stdout: TailBuffer = { content: '', truncated: false } + let stderr: TailBuffer = { content: '', truncated: false } + let timedOut = false + let settled = false + let timeout: ReturnType | null = null + let forceKillTimer: ReturnType | null = null + + const child = spawn(precheck.command, { + cwd: target.cwd, + detached: process.platform !== 'win32', + env: process.env, + shell: true, + windowsHide: true + }) + + const settle = (exitCode: number | null, error: string | null): void => { + if (settled) { + return + } + settled = true + if (timeout) { + clearTimeout(timeout) + timeout = null + } + if (forceKillTimer) { + clearTimeout(forceKillTimer) + forceKillTimer = null + } + resolve( + createPrecheckResult({ precheck, startedAt, stdout, stderr, exitCode, timedOut, error }) + ) + } + + timeout = setTimeout(() => { + timedOut = true + forceKillTimer = killLocalPrecheckProcessTree(child) + }, timeoutMs) + + child.stdout?.setEncoding('utf8') + child.stderr?.setEncoding('utf8') + child.stdout?.on('data', (chunk: string) => { + stdout = appendTail(stdout, chunk) + }) + child.stderr?.on('data', (chunk: string) => { + stderr = appendTail(stderr, chunk) + }) + child.on('error', (error) => { + settle(null, error.message) + }) + child.on('close', (code) => { + settle( + typeof code === 'number' ? code : null, + timedOut ? `Precheck timed out after ${precheck.timeoutSeconds}s.` : null + ) + }) + }) +} + +function runSshChannelPrecheck(args: { + precheck: AutomationPrecheck + channel: ClientChannel + startedAt: number +}): Promise { + const { precheck, channel, startedAt } = args + const timeoutMs = precheck.timeoutSeconds * 1000 + return new Promise((resolve) => { + let stdout: TailBuffer = { content: '', truncated: false } + let stderr: TailBuffer = { content: '', truncated: false } + let exitCode: number | null = null + let timedOut = false + let settled = false + let timeout: ReturnType | null = null + + const settle = (exitCode: number | null, error: string | null): void => { + if (settled) { + return + } + settled = true + if (timeout) { + clearTimeout(timeout) + timeout = null + } + resolve( + createPrecheckResult({ precheck, startedAt, stdout, stderr, exitCode, timedOut, error }) + ) + } + + timeout = setTimeout(() => { + timedOut = true + channel.close() + }, timeoutMs) + + const fail = (error: Error): void => { + settle(null, error.message) + } + channel.on('error', fail) + channel.stderr.on('error', fail) + channel.on('data', (data: Buffer | string) => { + stdout = appendTail(stdout, data.toString()) + }) + channel.stderr.on('data', (data: Buffer | string) => { + stderr = appendTail(stderr, data.toString()) + }) + channel.on('exit', (code: number | null) => { + exitCode = typeof code === 'number' ? code : null + }) + channel.on('close', (code?: number | null) => { + if (typeof code === 'number') { + exitCode = code + } + settle(exitCode, timedOut ? `Precheck timed out after ${precheck.timeoutSeconds}s.` : null) + }) + }) +} + +async function runSshPrecheck( + precheck: AutomationPrecheck, + target: Extract +): Promise { + const startedAt = Date.now() + const manager = getSshConnectionManager() + const connection = manager?.getConnection(target.connectionId) + if (!connection || connection.getState().status !== 'connected') { + return failedPrecheckResult(precheck, startedAt, 'SSH target is not connected.') + } + try { + const remoteCommand = `cd ${shellEscape(target.cwd)} && ${precheck.command}` + const channel = await connection.exec(remoteCommand) + return await runSshChannelPrecheck({ precheck, channel, startedAt }) + } catch (error) { + return failedPrecheckResult( + precheck, + startedAt, + error instanceof Error ? error.message : String(error) + ) + } +} + +export async function runAutomationPrecheck(args: { + precheck: AutomationPrecheck + target: AutomationPrecheckExecutionTarget +}): Promise { + if (args.target.type === 'ssh') { + return await runSshPrecheck(args.precheck, args.target) + } + return await runLocalPrecheck(args.precheck, args.target) +} diff --git a/src/main/automations/service-precheck.test.ts b/src/main/automations/service-precheck.test.ts new file mode 100644 index 00000000000..553674a9c34 --- /dev/null +++ b/src/main/automations/service-precheck.test.ts @@ -0,0 +1,132 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import type { Repo } from '../../shared/types' +import { AutomationService } from './service' + +const runAutomationPrecheckMock = vi.hoisted(() => vi.fn()) +const testState = { dir: '' } + +vi.mock('electron', () => ({ + app: { + getPath: () => testState.dir + }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('encrypted:'.length) + } +})) + +vi.mock('../git/repo', () => ({ + getGitUsername: vi.fn().mockReturnValue('testuser') +})) + +vi.mock('./precheck-runner', () => ({ + runAutomationPrecheck: runAutomationPrecheckMock +})) + +async function createStore() { + vi.resetModules() + const { Store, initDataPath } = await import('../persistence') + initDataPath() + return new Store() +} + +const makeRepo = (overrides: Partial = {}): Repo => ({ + id: 'r1', + path: '/repo', + displayName: 'test', + badgeColor: '#fff', + addedAt: 1, + ...overrides +}) + +describe('AutomationService prechecks', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-automations-test-')) + runAutomationPrecheckMock.mockReset() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('runs scheduled prechecks in the target repo before dispatch', async () => { + vi.setSystemTime(new Date('2026-05-13T08:00:00Z')) + const store = await createStore() + store.addRepo(makeRepo({ path: '/repo/path' })) + const automation = store.createAutomation({ + name: 'Conditional check', + prompt: 'Check the repo', + precheck: { + command: 'test -f ready', + timeoutSeconds: 30 + }, + agentId: 'claude', + projectId: 'r1', + workspaceMode: 'new_per_run', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-14T00:00:00Z').getTime() + }) + const run = store.createAutomationRun(automation, Date.now(), 'scheduled') + const precheckResult = { + command: 'test -f ready', + exitCode: 0, + timedOut: false, + durationMs: 5, + stdout: '', + stderr: '', + stdoutTruncated: false, + stderrTruncated: false, + error: null, + startedAt: Date.now(), + completedAt: Date.now() + } + runAutomationPrecheckMock.mockResolvedValue(precheckResult) + const service = new AutomationService(store, { tickMs: 60_000 }) + + const result = await service.runPrecheck(automation.id, run.id) + + expect(result).toEqual(precheckResult) + expect(runAutomationPrecheckMock).toHaveBeenCalledWith({ + precheck: { + command: 'test -f ready', + timeoutSeconds: 30 + }, + target: { + type: 'local', + cwd: '/repo/path' + } + }) + }) + + it('does not run prechecks for manual dispatches', async () => { + vi.setSystemTime(new Date('2026-05-13T08:00:00Z')) + const store = await createStore() + store.addRepo(makeRepo()) + const automation = store.createAutomation({ + name: 'Manual check', + prompt: 'Check the repo', + precheck: { + command: 'test -f ready', + timeoutSeconds: 30 + }, + agentId: 'claude', + projectId: 'r1', + workspaceMode: 'new_per_run', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-14T00:00:00Z').getTime() + }) + const run = store.createAutomationRun(automation, Date.now(), 'manual') + const service = new AutomationService(store, { tickMs: 60_000 }) + + await expect(service.runPrecheck(automation.id, run.id)).resolves.toBeNull() + expect(runAutomationPrecheckMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/automations/service.ts b/src/main/automations/service.ts index 655dc8d5d85..d2386b0de99 100644 --- a/src/main/automations/service.ts +++ b/src/main/automations/service.ts @@ -4,12 +4,15 @@ import type { Automation, AutomationDispatchRequest, AutomationDispatchResult, + AutomationPrecheckResult, AutomationRun, AutomationRunStatus, AutomationRunUsage } from '../../shared/automations-types' import type { ClaudeUsageStore } from '../claude-usage/store' import type { CodexUsageStore } from '../codex-usage/store' +import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id' +import { runAutomationPrecheck } from './precheck-runner' const DEFAULT_TICK_MS = 60 * 1000 @@ -72,6 +75,43 @@ export class AutomationService { return await this.requestDispatch(automation, run) } + async runPrecheck(automationId: string, runId: string): Promise { + const automation = this.store.listAutomations().find((entry) => entry.id === automationId) + if (!automation) { + throw new Error('Automation not found.') + } + const run = this.store.listAutomationRuns(automationId).find((entry) => entry.id === runId) + if (!run) { + throw new Error('Automation run not found.') + } + if (run.trigger !== 'scheduled' || !automation.precheck) { + return null + } + const cwd = this.getPrecheckCwd(automation) + if (!cwd) { + return { + command: automation.precheck.command, + exitCode: null, + timedOut: false, + durationMs: 0, + stdout: '', + stderr: '', + stdoutTruncated: false, + stderrTruncated: false, + error: 'Automation precheck target is no longer available.', + startedAt: Date.now(), + completedAt: Date.now() + } + } + return await runAutomationPrecheck({ + precheck: automation.precheck, + target: + automation.executionTargetType === 'ssh' + ? { type: 'ssh', cwd, connectionId: automation.executionTargetId } + : { type: 'local', cwd } + }) + } + async markDispatchResult(result: AutomationDispatchResult): Promise { const run = this.store.updateAutomationRun(result) if (!isFinalRunStatus(run.status)) { @@ -190,6 +230,16 @@ export class AutomationService { } } + private getPrecheckCwd(automation: Automation): string | null { + if (automation.workspaceMode === 'existing') { + const parsed = automation.workspaceId + ? splitWorktreeIdForFilesystem(automation.workspaceId) + : null + return parsed?.worktreePath ?? null + } + return this.store.getRepo(automation.projectId)?.path ?? null + } + private async evaluateAutomation(automation: Automation, now: number): Promise { const scheduledFor = this.store.getLatestAutomationOccurrence(automation, now) if (scheduledFor === null) { @@ -242,6 +292,7 @@ function isFinalRunStatus(status: AutomationRunStatus): boolean { return ( status === 'completed' || status === 'dispatch_failed' || + status === 'skipped_precheck' || status === 'skipped_missed' || status === 'skipped_unavailable' || status === 'skipped_needs_interactive_auth' diff --git a/src/main/azure-devops/client.ts b/src/main/azure-devops/client.ts index ca692563e3f..2914772db9a 100644 --- a/src/main/azure-devops/client.ts +++ b/src/main/azure-devops/client.ts @@ -199,17 +199,19 @@ function sortPullRequestsForBranch( left: RawAzureDevOpsPullRequest, right: RawAzureDevOpsPullRequest ): number { + const leftStatus = left.status?.trim().toLowerCase() + const rightStatus = right.status?.trim().toLowerCase() + const abandonedOrder = Number(leftStatus === 'abandoned') - Number(rightStatus === 'abandoned') + // Why: abandoned PRs can have newer close dates, but should not hide a usable branch PR. + if (abandonedOrder !== 0) { + return abandonedOrder + } const leftTime = Date.parse(left.closedDate ?? left.creationDate ?? '') || 0 const rightTime = Date.parse(right.closedDate ?? right.creationDate ?? '') || 0 if (leftTime !== rightTime) { return rightTime - leftTime } - const leftActive = left.status?.trim().toLowerCase() === 'active' - const rightActive = right.status?.trim().toLowerCase() === 'active' - if (leftActive !== rightActive) { - return leftActive ? -1 : 1 - } - return 0 + return Number(rightStatus === 'active') - Number(leftStatus === 'active') } export async function getAzureDevOpsAuthStatus(): Promise { @@ -254,14 +256,12 @@ export async function getAzureDevOpsAuthStatus(): Promise export async function getAzureDevOpsPullRequest( repoPath: string, - prNumber: number + prNumber: number, + connectionId?: string | null ): Promise { - const repo = await getAzureDevOpsRepoRef(repoPath) - if (!repo) { - return null - } - const repository = await getRepository(repo) - if (!repository) { + const repo = await getAzureDevOpsRepoRef(repoPath, connectionId) + const repository = repo ? await getRepository(repo) : null + if (!repo || !repository) { return null } const raw = await requestJson( @@ -276,19 +276,17 @@ export async function getAzureDevOpsPullRequest( export async function getAzureDevOpsPullRequestForBranch( repoPath: string, branch: string, - linkedPRNumber?: number | null + linkedPRNumber?: number | null, + connectionId?: string | null ): Promise { const branchName = branch.replace(/^refs\/heads\//, '') if (!branchName && linkedPRNumber == null) { return null } - const repo = await getAzureDevOpsRepoRef(repoPath) - if (!repo) { - return null - } - const repository = await getRepository(repo) - if (!repository) { + const repo = await getAzureDevOpsRepoRef(repoPath, connectionId) + const repository = repo ? await getRepository(repo) : null + if (!repo || !repository) { return null } @@ -322,6 +320,9 @@ export async function getAzureDevOpsPullRequestForBranch( return raw ? normalizePullRequest(repo, repository.idOrName, repository.webBaseUrl, raw) : null } -export async function getAzureDevOpsRepoSlug(repoPath: string): Promise { - return getAzureDevOpsRepoRef(repoPath) +export async function getAzureDevOpsRepoSlug( + repoPath: string, + connectionId?: string | null +): Promise { + return getAzureDevOpsRepoRef(repoPath, connectionId) } diff --git a/src/main/azure-devops/pull-request-mappers.ts b/src/main/azure-devops/pull-request-mappers.ts index d804029e53f..062a99fa0b4 100644 --- a/src/main/azure-devops/pull-request-mappers.ts +++ b/src/main/azure-devops/pull-request-mappers.ts @@ -58,6 +58,7 @@ export function mapAzureDevOpsMergeable(mergeStatus: string | null | undefined): return 'MERGEABLE' case 'conflicts': return 'CONFLICTING' + case undefined: default: return 'UNKNOWN' } @@ -80,6 +81,7 @@ function classifyAzureDevOpsStatus(state: string | null | undefined): CheckStatu case 'queued': case 'running': return 'pending' + case undefined: default: return 'neutral' } diff --git a/src/main/azure-devops/repository-ref.test.ts b/src/main/azure-devops/repository-ref.test.ts index 4d07f58c8df..92e738afbe0 100644 --- a/src/main/azure-devops/repository-ref.test.ts +++ b/src/main/azure-devops/repository-ref.test.ts @@ -1,7 +1,29 @@ -import { describe, expect, it } from 'vitest' -import { parseAzureDevOpsRepoRef } from './repository-ref' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { sshExecMock } = vi.hoisted(() => ({ + sshExecMock: vi.fn() +})) + +import { + _getAzureDevOpsRepoRefCacheSize, + _resetAzureDevOpsRepoRefCache, + getAzureDevOpsRepoRefForRemote, + parseAzureDevOpsRepoRef +} from './repository-ref' +import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' describe('parseAzureDevOpsRepoRef', () => { + beforeEach(() => { + sshExecMock.mockReset() + unregisterSshGitProvider('conn-1') + _resetAzureDevOpsRepoRefCache() + }) + + afterEach(() => { + unregisterSshGitProvider('conn-1') + _resetAzureDevOpsRepoRefCache() + }) + it('parses dev.azure.com HTTPS remotes', () => { expect( parseAzureDevOpsRepoRef('https://dev.azure.com/acme/Project%20One/_git/repo-name') @@ -15,6 +37,25 @@ describe('parseAzureDevOpsRepoRef', () => { }) }) + it('strips trailing slashes after .git suffixes', () => { + expect(parseAzureDevOpsRepoRef('https://dev.azure.com/acme/Project/_git/repo.git/')).toEqual({ + host: 'dev.azure.com', + organization: 'acme', + project: 'Project', + repository: 'repo', + apiBaseUrl: 'https://dev.azure.com/acme/Project', + webBaseUrl: 'https://dev.azure.com/acme/Project/_git/repo' + }) + expect(parseAzureDevOpsRepoRef('git@ssh.dev.azure.com:v3/acme/Project/repo.git/')).toEqual({ + host: 'dev.azure.com', + organization: 'acme', + project: 'Project', + repository: 'repo', + apiBaseUrl: 'https://dev.azure.com/acme/Project', + webBaseUrl: 'https://dev.azure.com/acme/Project/_git/repo' + }) + }) + it('parses legacy visualstudio.com HTTPS remotes', () => { expect(parseAzureDevOpsRepoRef('https://acme.visualstudio.com/Project/_git/repo.git')).toEqual({ host: 'acme.visualstudio.com', @@ -53,4 +94,57 @@ describe('parseAzureDevOpsRepoRef', () => { it('ignores non-Azure remotes', () => { expect(parseAzureDevOpsRepoRef('git@github.com:stablyai/orca.git')).toBeNull() }) + + it('resolves repository refs through the SSH git provider for connected repos', async () => { + sshExecMock.mockResolvedValueOnce({ + stdout: 'git@ssh.dev.azure.com:v3/acme/Project/repo\n', + stderr: '' + }) + registerSshGitProvider('conn-1', { exec: sshExecMock } as never) + + await expect(getAzureDevOpsRepoRefForRemote('/repo', 'origin', 'conn-1')).resolves.toEqual({ + host: 'dev.azure.com', + organization: 'acme', + project: 'Project', + repository: 'repo', + apiBaseUrl: 'https://dev.azure.com/acme/Project', + webBaseUrl: 'https://dev.azure.com/acme/Project/_git/repo' + }) + + expect(sshExecMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/repo') + }) + + it('bounds cached repository refs for distinct repo paths', async () => { + sshExecMock.mockResolvedValue({ + stdout: 'git@ssh.dev.azure.com:v3/acme/Project/repo\n', + stderr: '' + }) + registerSshGitProvider('conn-1', { exec: sshExecMock } as never) + + for (let i = 0; i < 513; i += 1) { + await getAzureDevOpsRepoRefForRemote(`/repo-${i}`, 'origin', 'conn-1') + } + + expect(_getAzureDevOpsRepoRefCacheSize()).toBe(512) + }) + + it('does not cache transient SSH provider failures as unsupported repos', async () => { + sshExecMock.mockRejectedValueOnce(new Error('connection closed')).mockResolvedValueOnce({ + stdout: 'git@ssh.dev.azure.com:v3/acme/Project/repo\n', + stderr: '' + }) + registerSshGitProvider('conn-1', { exec: sshExecMock } as never) + + await expect(getAzureDevOpsRepoRefForRemote('/repo', 'origin', 'conn-1')).resolves.toBeNull() + await expect(getAzureDevOpsRepoRefForRemote('/repo', 'origin', 'conn-1')).resolves.toEqual({ + host: 'dev.azure.com', + organization: 'acme', + project: 'Project', + repository: 'repo', + apiBaseUrl: 'https://dev.azure.com/acme/Project', + webBaseUrl: 'https://dev.azure.com/acme/Project/_git/repo' + }) + + expect(sshExecMock).toHaveBeenCalledTimes(2) + }) }) diff --git a/src/main/azure-devops/repository-ref.ts b/src/main/azure-devops/repository-ref.ts index 1da1d60fd71..56baaa4e87a 100644 --- a/src/main/azure-devops/repository-ref.ts +++ b/src/main/azure-devops/repository-ref.ts @@ -1,4 +1,5 @@ import { gitExecFileAsync } from '../git/runner' +import { getSshGitProvider } from '../providers/ssh-git-dispatch' export type AzureDevOpsRepoRef = { host: string @@ -9,6 +10,7 @@ export type AzureDevOpsRepoRef = { organization?: string | null } +const REPO_REF_CACHE_MAX_ENTRIES = 512 const repoRefCache = new Map() /** @internal - exposed for tests only */ @@ -16,6 +18,22 @@ export function _resetAzureDevOpsRepoRefCache(): void { repoRefCache.clear() } +/** @internal - exposed for tests only */ +export function _getAzureDevOpsRepoRefCacheSize(): number { + return repoRefCache.size +} + +function rememberRepoRefCacheEntry(cacheKey: string, value: AzureDevOpsRepoRef | null): void { + repoRefCache.set(cacheKey, value) + while (repoRefCache.size > REPO_REF_CACHE_MAX_ENTRIES) { + const oldestKey = repoRefCache.keys().next().value + if (oldestKey === undefined) { + return + } + repoRefCache.delete(oldestKey) + } +} + function decodeSegment(value: string): string { try { return decodeURIComponent(value) @@ -30,6 +48,7 @@ function encodeSegment(value: string): string { function splitPath(path: string): string[] { return path + .replace(/\/+$/, '') .replace(/\.git$/i, '') .split('/') .map((part) => part.trim()) @@ -184,25 +203,40 @@ export function parseAzureDevOpsRepoRef(remoteUrl: string): AzureDevOpsRepoRef | export async function getAzureDevOpsRepoRefForRemote( repoPath: string, - remoteName: string + remoteName: string, + connectionId?: string | null ): Promise { - const cacheKey = `${repoPath}\0${remoteName}` + const cacheKey = `${connectionId ?? 'local'}\0${repoPath}\0${remoteName}` if (repoRefCache.has(cacheKey)) { return repoRefCache.get(cacheKey)! } try { - const { stdout } = await gitExecFileAsync(['remote', 'get-url', remoteName], { - cwd: repoPath - }) + const sshGitProvider = connectionId ? getSshGitProvider(connectionId) : null + if (connectionId && !sshGitProvider) { + return null + } + const { stdout } = sshGitProvider + ? await sshGitProvider.exec(['remote', 'get-url', remoteName], repoPath) + : await gitExecFileAsync(['remote', 'get-url', remoteName], { + cwd: repoPath + }) const result = parseAzureDevOpsRepoRef(stdout) - repoRefCache.set(cacheKey, result) + rememberRepoRefCacheEntry(cacheKey, result) return result } catch { - repoRefCache.set(cacheKey, null) + if (connectionId) { + // Why: SSH provider failures are often transient reconnect/tunnel states; + // caching them as "not Azure DevOps" would poison the repo for the session. + return null + } + rememberRepoRefCacheEntry(cacheKey, null) return null } } -export async function getAzureDevOpsRepoRef(repoPath: string): Promise { - return getAzureDevOpsRepoRefForRemote(repoPath, 'origin') +export async function getAzureDevOpsRepoRef( + repoPath: string, + connectionId?: string | null +): Promise { + return getAzureDevOpsRepoRefForRemote(repoPath, 'origin', connectionId) } diff --git a/src/main/bitbucket/client.ts b/src/main/bitbucket/client.ts index d5c2c1637ee..d07c43b62ed 100644 --- a/src/main/bitbucket/client.ts +++ b/src/main/bitbucket/client.ts @@ -159,9 +159,10 @@ export async function getBitbucketAuthStatus(): Promise { export async function getBitbucketPullRequest( repoPath: string, - prNumber: number + prNumber: number, + connectionId?: string | null ): Promise { - const repo = await getBitbucketRepoRef(repoPath) + const repo = await getBitbucketRepoRef(repoPath, connectionId) if (!repo) { return null } @@ -174,14 +175,15 @@ export async function getBitbucketPullRequest( export async function getBitbucketPullRequestForBranch( repoPath: string, branch: string, - linkedPRNumber?: number | null + linkedPRNumber?: number | null, + connectionId?: string | null ): Promise { const branchName = branch.replace(/^refs\/heads\//, '') if (!branchName && linkedPRNumber == null) { return null } - const repo = await getBitbucketRepoRef(repoPath) + const repo = await getBitbucketRepoRef(repoPath, connectionId) if (!repo) { return null } @@ -217,6 +219,9 @@ export async function getBitbucketPullRequestForBranch( return raw ? normalizePullRequest(repo, raw) : null } -export async function getBitbucketRepoSlug(repoPath: string): Promise { - return getBitbucketRepoRef(repoPath) +export async function getBitbucketRepoSlug( + repoPath: string, + connectionId?: string | null +): Promise { + return getBitbucketRepoRef(repoPath, connectionId) } diff --git a/src/main/bitbucket/pull-request-mappers.ts b/src/main/bitbucket/pull-request-mappers.ts index 92c99d7f0a4..4f68c320235 100644 --- a/src/main/bitbucket/pull-request-mappers.ts +++ b/src/main/bitbucket/pull-request-mappers.ts @@ -50,6 +50,7 @@ export function mapBitbucketPullRequestState( case 'SUPERSEDED': return 'closed' case 'OPEN': + case undefined: default: return 'open' } diff --git a/src/main/bitbucket/repository-ref.test.ts b/src/main/bitbucket/repository-ref.test.ts index 6ea77cf415a..bc277b8326e 100644 --- a/src/main/bitbucket/repository-ref.test.ts +++ b/src/main/bitbucket/repository-ref.test.ts @@ -1,7 +1,8 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { gitExecFileAsyncMock } = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn() +const { gitExecFileAsyncMock, sshExecMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn(), + sshExecMock: vi.fn() })) vi.mock('../git/runner', () => ({ @@ -9,17 +10,26 @@ vi.mock('../git/runner', () => ({ })) import { + _getBitbucketRepoRefCacheSize, _resetBitbucketRepoRefCache, + getBitbucketRepoRefForRemote, getBitbucketRepoRef, parseBitbucketRepoRef } from './repository-ref' +import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' describe('Bitbucket repository refs', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() + sshExecMock.mockReset() + unregisterSshGitProvider('conn-1') _resetBitbucketRepoRefCache() }) + afterEach(() => { + unregisterSshGitProvider('conn-1') + }) + it('parses HTTPS, SSH, and ssh:// Bitbucket remotes', () => { expect(parseBitbucketRepoRef('https://bitbucket.org/team/project.git')).toEqual({ workspace: 'team', @@ -36,6 +46,34 @@ describe('Bitbucket repository refs', () => { expect(parseBitbucketRepoRef('https://github.com/team/project.git')).toBeNull() }) + it('strips trailing slashes after .git suffixes', () => { + expect(parseBitbucketRepoRef('https://bitbucket.org/team/project.git/')).toEqual({ + workspace: 'team', + repoSlug: 'project' + }) + expect(parseBitbucketRepoRef('git@bitbucket.org:team/project.git/')).toEqual({ + workspace: 'team', + repoSlug: 'project' + }) + }) + + it('keeps malformed percent sequences as literal repo path text', async () => { + expect(parseBitbucketRepoRef('git@bitbucket.org:team/project%zz.git')).toEqual({ + workspace: 'team', + repoSlug: 'project%zz' + }) + + gitExecFileAsyncMock.mockResolvedValue({ + stdout: 'git@bitbucket.org:team/project%zz.git\n', + stderr: '' + }) + + await expect(getBitbucketRepoRef('/repo')).resolves.toEqual({ + workspace: 'team', + repoSlug: 'project%zz' + }) + }) + it('resolves origin through the WSL-aware git runner and caches the result', async () => { gitExecFileAsyncMock.mockResolvedValue({ stdout: 'git@bitbucket.org:team/project.git\n', @@ -55,4 +93,50 @@ describe('Bitbucket repository refs', () => { cwd: '/repo' }) }) + + it('bounds cached repository refs for distinct repo paths', async () => { + gitExecFileAsyncMock.mockResolvedValue({ + stdout: 'git@bitbucket.org:team/project.git\n', + stderr: '' + }) + + for (let i = 0; i < 513; i += 1) { + await getBitbucketRepoRef(`/repo-${i}`) + } + + expect(_getBitbucketRepoRefCacheSize()).toBe(512) + }) + + it('resolves project refs through the SSH git provider for connected repos', async () => { + sshExecMock.mockResolvedValueOnce({ + stdout: 'git@bitbucket.org:remote/project.git\n', + stderr: '' + }) + registerSshGitProvider('conn-1', { exec: sshExecMock } as never) + + await expect(getBitbucketRepoRefForRemote('/repo', 'origin', 'conn-1')).resolves.toEqual({ + workspace: 'remote', + repoSlug: 'project' + }) + + expect(sshExecMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/repo') + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('does not cache transient SSH provider failures as unsupported repos', async () => { + sshExecMock.mockRejectedValueOnce(new Error('connection closed')).mockResolvedValueOnce({ + stdout: 'git@bitbucket.org:remote/project.git\n', + stderr: '' + }) + registerSshGitProvider('conn-1', { exec: sshExecMock } as never) + + await expect(getBitbucketRepoRefForRemote('/repo', 'origin', 'conn-1')).resolves.toBeNull() + await expect(getBitbucketRepoRefForRemote('/repo', 'origin', 'conn-1')).resolves.toEqual({ + workspace: 'remote', + repoSlug: 'project' + }) + + expect(sshExecMock).toHaveBeenCalledTimes(2) + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + }) }) diff --git a/src/main/bitbucket/repository-ref.ts b/src/main/bitbucket/repository-ref.ts index b51b7c1371c..e6995675963 100644 --- a/src/main/bitbucket/repository-ref.ts +++ b/src/main/bitbucket/repository-ref.ts @@ -1,10 +1,12 @@ import { gitExecFileAsync } from '../git/runner' +import { getSshGitProvider } from '../providers/ssh-git-dispatch' export type BitbucketRepoRef = { workspace: string repoSlug: string } +const REPO_REF_CACHE_MAX_ENTRIES = 512 const repoRefCache = new Map() /** @internal - exposed for tests only */ @@ -12,8 +14,32 @@ export function _resetBitbucketRepoRefCache(): void { repoRefCache.clear() } +/** @internal - exposed for tests only */ +export function _getBitbucketRepoRefCacheSize(): number { + return repoRefCache.size +} + +function rememberRepoRefCacheEntry(cacheKey: string, value: BitbucketRepoRef | null): void { + repoRefCache.set(cacheKey, value) + while (repoRefCache.size > REPO_REF_CACHE_MAX_ENTRIES) { + const oldestKey = repoRefCache.keys().next().value + if (oldestKey === undefined) { + return + } + repoRefCache.delete(oldestKey) + } +} + +function decodeSegment(value: string): string { + try { + return decodeURIComponent(value) + } catch { + return value + } +} + function parseBitbucketPath(pathname: string): BitbucketRepoRef | null { - const withoutSuffix = pathname.replace(/\.git$/i, '') + const withoutSuffix = pathname.replace(/\/+$/, '').replace(/\.git$/i, '') const parts = withoutSuffix .split('/') .map((part) => part.trim()) @@ -27,8 +53,8 @@ function parseBitbucketPath(pathname: string): BitbucketRepoRef | null { return null } return { - workspace: decodeURIComponent(workspace), - repoSlug: decodeURIComponent(repoSlug) + workspace: decodeSegment(workspace), + repoSlug: decodeSegment(repoSlug) } } @@ -52,25 +78,40 @@ export function parseBitbucketRepoRef(remoteUrl: string): BitbucketRepoRef | nul export async function getBitbucketRepoRefForRemote( repoPath: string, - remoteName: string + remoteName: string, + connectionId?: string | null ): Promise { - const cacheKey = `${repoPath}\0${remoteName}` + const cacheKey = `${connectionId ?? 'local'}\0${repoPath}\0${remoteName}` if (repoRefCache.has(cacheKey)) { return repoRefCache.get(cacheKey)! } try { - const { stdout } = await gitExecFileAsync(['remote', 'get-url', remoteName], { - cwd: repoPath - }) + const sshGitProvider = connectionId ? getSshGitProvider(connectionId) : null + if (connectionId && !sshGitProvider) { + return null + } + const { stdout } = sshGitProvider + ? await sshGitProvider.exec(['remote', 'get-url', remoteName], repoPath) + : await gitExecFileAsync(['remote', 'get-url', remoteName], { + cwd: repoPath + }) const result = parseBitbucketRepoRef(stdout) - repoRefCache.set(cacheKey, result) + rememberRepoRefCacheEntry(cacheKey, result) return result } catch { - repoRefCache.set(cacheKey, null) + if (connectionId) { + // Why: SSH provider failures are often transient reconnect/tunnel states; + // caching them as "not Bitbucket" would poison the repo for the session. + return null + } + rememberRepoRefCacheEntry(cacheKey, null) return null } } -export async function getBitbucketRepoRef(repoPath: string): Promise { - return getBitbucketRepoRefForRemote(repoPath, 'origin') +export async function getBitbucketRepoRef( + repoPath: string, + connectionId?: string | null +): Promise { + return getBitbucketRepoRefForRemote(repoPath, 'origin', connectionId) } diff --git a/src/main/browser/agent-browser-bridge.test.ts b/src/main/browser/agent-browser-bridge.test.ts index 46635c57a46..a6680c06822 100644 --- a/src/main/browser/agent-browser-bridge.test.ts +++ b/src/main/browser/agent-browser-bridge.test.ts @@ -69,6 +69,7 @@ function mockBrowserManager( getWebContentsIdByTabId: () => tabs, getWorktreeIdForTab: (tabId: string) => worktrees.get(tabId), getGuestWebContentsId: vi.fn(() => null), + unregisterGuest: vi.fn(), ensureWebviewVisible: vi.fn(async () => () => {}), acquireAutomationVisibility: vi.fn(async () => () => {}), ...overrides @@ -157,6 +158,40 @@ describe('AgentBrowserBridge', () => { expect(clickCall![1]).not.toContain('--cdp') }) + it('continues when stale agent-browser session close hangs during session creation', async () => { + vi.useFakeTimers() + try { + const closeKill = vi.fn() + execFileMock.mockImplementation( + (_bin: string, args: string[], _opts: unknown, cb: Function) => { + if (args.includes('close')) { + return { kill: closeKill } + } + if (args.includes('snapshot')) { + cb(null, JSON.stringify({ success: true, data: { snapshot: 'ready' } }), '') + return { kill: vi.fn() } + } + throw new Error(`unexpected agent-browser args ${args.join(' ')}`) + } + ) + + const promise = bridge.snapshot() + let settled = false + void promise.finally(() => { + settled = true + }) + + await vi.advanceTimersByTimeAsync(3_000) + await Promise.resolve() + + expect(settled).toBe(true) + await expect(promise).resolves.toEqual({ browserPageId: 'tab-1', snapshot: 'ready' }) + expect(closeKill).toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + // ── --json always appended ── it('always appends --json to commands', async () => { @@ -273,7 +308,9 @@ describe('AgentBrowserBridge', () => { it('strips --cdp and --session from exec commands', async () => { succeedWith({ output: 'ok' }) - await bridge.exec('dblclick @e3 --cdp ws://evil --session hijack') + await bridge.exec( + 'dblclick @e3 --cdp ws://evil --session hijack --cdp=ws://evil-equals --session=hijack-equals' + ) // Why: find the actual exec call (contains 'dblclick'), not the stale-session close const execCall = execFileMock.mock.calls.find((c: unknown[]) => @@ -281,9 +318,11 @@ describe('AgentBrowserBridge', () => { ) const args = execCall![1] as string[] // The bridge's own --session and --cdp (for session init) are expected. - // Verify the user-injected ones were stripped: no 'ws://evil' or 'hijack' + // Verify the user-injected ones were stripped, including --flag=value forms. expect(args.join(' ')).not.toContain('ws://evil') + expect(args.join(' ')).not.toContain('ws://evil-equals') expect(args.join(' ')).not.toContain('hijack') + expect(args.join(' ')).not.toContain('hijack-equals') expect(args).toContain('dblclick') expect(args).toContain('@e3') }) @@ -339,6 +378,21 @@ describe('AgentBrowserBridge', () => { ]) expect(b.getActiveWebContentsId()).toBeNull() }) + + it('unregisters stale tab-list entries when their WebContents is gone', () => { + const tabs = new Map([ + ['tab-a', 1], + ['tab-b', 2] + ]) + const wc2 = mockWebContents(2, 'https://b.com', 'B') + webContentsFromIdMock.mockImplementation((id: number) => (id === 2 ? wc2 : null)) + const unregisterGuest = vi.fn() + + const b = new AgentBrowserBridge(mockBrowserManager(tabs, new Map(), { unregisterGuest })) + + expect(b.tabList().tabs).toMatchObject([{ browserPageId: 'tab-b', active: true }]) + expect(unregisterGuest).toHaveBeenCalledWith('tab-a') + }) }) // ── Tab switch ── @@ -400,6 +454,19 @@ describe('AgentBrowserBridge', () => { expect(mouseCalls[1]?.[1]).toMatchObject({ type: 'mouseReleased', x: 10, y: 20 }) }) + it('drops empty command queues after direct CDP commands finish', async () => { + const wc = mockWebContents(100) + wc.debugger.sendCommand.mockResolvedValue({}) + webContentsFromIdMock.mockReturnValue(wc) + + await bridge.mouseClick(10, 20, 'right', undefined, 'tab-1') + + expect( + (bridge as unknown as { commandQueues: Map }).commandQueues.size + ).toBe(0) + expect((bridge as unknown as { processingQueues: Set }).processingQueues.size).toBe(0) + }) + // ── Command queue serialization ── it('serializes concurrent commands per session', async () => { @@ -732,6 +799,53 @@ describe('AgentBrowserBridge', () => { expect(commandCalls.filter((args) => args.includes('close'))).toHaveLength(2) }) + it('tears down a session that finishes creating after destruction starts', async () => { + const commandCalls: string[][] = [] + let releaseStaleClose: (() => void) | null = null + execFileMock.mockImplementation( + (_bin: string, args: string[], _opts: unknown, cb: Function) => { + commandCalls.push(args) + if (args.includes('close') && !releaseStaleClose) { + releaseStaleClose = () => { + cb(null, JSON.stringify({ success: true, data: null }), '') + } + return { kill: vi.fn() } + } + cb(null, JSON.stringify({ success: true, data: null }), '') + return { kill: vi.fn() } + } + ) + + const ensurePromise = ( + bridge as unknown as { + ensureSession: ( + sessionName: string, + browserPageId: string, + webContentsId: number + ) => Promise + } + ).ensureSession('orca-tab-tab-1', 'tab-1', 100) + + await vi.waitFor(() => { + expect(releaseStaleClose).not.toBeNull() + }) + expect(CdpWsProxyMock.instances).toHaveLength(0) + + const destroyPromise = ( + bridge as unknown as { destroySession: (name: string) => Promise } + ).destroySession('orca-tab-tab-1') + + releaseStaleClose!() + await ensurePromise + await destroyPromise + + const sessions = (bridge as unknown as { sessions: Map }).sessions + const proxy = CdpWsProxyMock.instances[0] as { stop: ReturnType } + expect(commandCalls.filter((args) => args.includes('close'))).toHaveLength(2) + expect(sessions.size).toBe(0) + expect(proxy.stop).toHaveBeenCalledTimes(1) + }) + it('cancels the command already running when a session is destroyed', async () => { succeedWith({ snapshot: 'initial' }) await bridge.snapshot() @@ -884,6 +998,33 @@ describe('AgentBrowserBridge', () => { expect(routeCalls[0]).toContain('--cdp') }) + it('clears pending intercept restore state when a swapped tab closes before reuse', async () => { + const tabs = new Map([['tab-1', 100]]) + const mgr = mockBrowserManager(tabs) + const b = new AgentBrowserBridge(mgr) + b.setActiveTab(100) + + succeedWith({ ok: true }) + await b.interceptEnable(['https://old.example/**']) + + tabs.set('tab-1', 200) + webContentsFromIdMock.mockReturnValue(mockWebContents(200)) + succeedWith(null) + await b.onProcessSwap('tab-1', 200) + + expect( + (b as unknown as { pendingInterceptRestore: Map }).pendingInterceptRestore + .size + ).toBe(1) + + await b.onTabClosed(200) + + expect( + (b as unknown as { pendingInterceptRestore: Map }).pendingInterceptRestore + .size + ).toBe(0) + }) + // ── Tab close clears active ── it('clears activeWebContentsId on tab close', async () => { @@ -1069,6 +1210,20 @@ describe('AgentBrowserBridge', () => { expect(args).toContain('https://example.com') }) + it('builds valid fill eval JavaScript for multiline values', async () => { + succeedWith({ ok: true }) + + await bridge.fill('@textarea', "line one\nline two with 'quote' and \\ slash") + + const evalCall = execFileMock.mock.calls.find((call: unknown[]) => + (call[1] as string[]).includes('eval') + ) + expect(evalCall).toBeDefined() + const args = evalCall![1] as string[] + const expression = args[args.indexOf('eval') + 1] + expect(() => new Function(expression)).not.toThrow() + }) + // ── Cookie command arg building ── it('builds cookie set args with all options', async () => { diff --git a/src/main/browser/agent-browser-bridge.ts b/src/main/browser/agent-browser-bridge.ts index 41d84cb389c..cff0ffcdce9 100644 --- a/src/main/browser/agent-browser-bridge.ts +++ b/src/main/browser/agent-browser-bridge.ts @@ -54,6 +54,7 @@ import type { const EXEC_TIMEOUT_MS = 90_000 const CONSECUTIVE_TIMEOUT_LIMIT = 3 const WAIT_PROCESS_TIMEOUT_GRACE_MS = 1_000 +const STALE_SESSION_CLOSE_TIMEOUT_MS = 3_000 type SessionState = { proxy: CdpWsProxy @@ -170,6 +171,22 @@ function parseShellArgs(input: string): string[] { return args } +function stripAgentBrowserTargetArgs(args: string[]): string[] { + const stripped: string[] = [] + for (let index = 0; index < args.length; index++) { + const arg = args[index] + if (arg === '--cdp' || arg === '--session') { + index++ + continue + } + if (arg.startsWith('--cdp=') || arg.startsWith('--session=')) { + continue + } + stripped.push(arg) + } + return stripped +} + // Why: agent-browser returns generic error messages for stale/unknown refs. // Map them to a specific code so agents can reliably detect and re-snapshot. function classifyErrorCode(message: string): string { @@ -497,7 +514,9 @@ export class AgentBrowserBridge { this.activeWebContentsId = nextWorktreeActiveWebContentsId } if (browserPageId) { - await this.destroySession(`orca-tab-${browserPageId}`) + const sessionName = `orca-tab-${browserPageId}` + await this.destroySession(sessionName) + this.pendingInterceptRestore.delete(sessionName) } this.options.onTabsChanged?.(owningWorktreeId) } @@ -565,6 +584,7 @@ export class AgentBrowserBridge { for (const [tabId, wcId] of tabs) { const wc = this.getWebContents(wcId) if (!wc) { + this.browserManager.unregisterGuest(tabId) continue } if (firstLiveWcId === null) { @@ -689,10 +709,10 @@ export class AgentBrowserBridge { // directly via JS and dispatch input/change events for React/framework compat. return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { await this.execAgentBrowser(sessionName, ['focus', element]) - const escaped = value.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + const serializedValue = JSON.stringify(value) await this.execAgentBrowser(sessionName, [ 'eval', - `(() => { const el = document.activeElement; if (el) { const nativeSetter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), 'value')?.set; if (nativeSetter) { nativeSetter.call(el, '${escaped}'); } else { el.value = '${escaped}'; } el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); } })()` + `(() => { const el = document.activeElement; if (el) { const nativeSetter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), 'value')?.set; if (nativeSetter) { nativeSetter.call(el, ${serializedValue}); } else { el.value = ${serializedValue}; } el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); } })()` ]) return { filled: element } as BrowserFillResult }) @@ -1694,12 +1714,9 @@ export class AgentBrowserBridge { async exec(command: string, worktreeId?: string, browserPageId?: string): Promise { return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - // Why: strip --cdp and --session from raw command to prevent session/target injection - const sanitized = command - .replace(/--cdp\s+\S+/g, '') - .replace(/--session\s+\S+/g, '') - .trim() - const args = parseShellArgs(sanitized) + // Why: strip target/session flags from raw passthrough commands so a + // caller cannot override Orca's selected browser page or CDP proxy. + const args = stripAgentBrowserTargetArgs(parseShellArgs(command.trim())) return await this.execAgentBrowser(sessionName, args) }) } @@ -1712,6 +1729,7 @@ export class AgentBrowserBridge { promises.push(this.destroySession(sessionName)) } await Promise.allSettled(promises) + this.pendingInterceptRestore.clear() } // ── Internal ── @@ -1799,6 +1817,9 @@ export class AgentBrowserBridge { } } + if (queue && queue.length === 0 && this.commandQueues.get(sessionName) === queue) { + this.commandQueues.delete(sessionName) + } this.processingQueues.delete(sessionName) } @@ -1829,6 +1850,7 @@ export class AgentBrowserBridge { } if (!this.getWebContents(webContentsId)) { + this.browserManager.unregisterGuest(browserPageId) throw new BrowserError( 'browser_tab_not_found', `Browser page ${browserPageId} is no longer available` @@ -1855,6 +1877,15 @@ export class AgentBrowserBridge { if (wcId === preferredWcId && this.getWebContents(wcId)) { return { browserPageId: tabId, webContentsId: wcId } } + if (wcId === preferredWcId) { + this.browserManager.unregisterGuest(tabId) + if (this.activeWebContentsId === wcId) { + this.activeWebContentsId = null + } + if (worktreeId && this.activeWebContentsPerWorktree.get(worktreeId) === wcId) { + this.activeWebContentsPerWorktree.delete(worktreeId) + } + } } } @@ -1870,6 +1901,7 @@ export class AgentBrowserBridge { } return { browserPageId: tabId, webContentsId: wcId } } + this.browserManager.unregisterGuest(tabId) } throw new BrowserError( @@ -1917,11 +1949,7 @@ export class AgentBrowserBridge { // across Orca restarts. A stale session ignores --cdp (already initialized) and // connects to the dead port. Must await close so the daemon forgets the session // before we pass --cdp with the new port. - await new Promise((resolve) => { - execFile(this.agentBrowserBin, ['--session', sessionName, 'close'], { timeout: 3000 }, () => - resolve() - ) - }) + await this.closeStaleAgentBrowserSession(sessionName) const proxy = new CdpWsProxy(wc) const cdpEndpoint = await proxy.start() @@ -1954,8 +1982,21 @@ export class AgentBrowserBridge { return } + const pendingCreation = this.pendingSessionCreation.get(sessionName) + if (pendingCreation) { + // Why: tab close can race with stale-session cleanup before sessions.set(). + // Wait for creation to settle so a late proxy cannot survive the close. + try { + await pendingCreation + } catch { + // Creation failures are handled by the original caller; teardown still + // needs to reject queued work and clear any partial state below. + } + } + const session = this.sessions.get(sessionName) if (!session) { + this.rejectQueuedCommandsForClosedSession(sessionName) return } @@ -1964,19 +2005,7 @@ export class AgentBrowserBridge { // Why: queued commands would hang forever if we just delete the queue — // their promises would never resolve or reject. Drain and reject them. - const queue = this.commandQueues.get(sessionName) - this.commandQueues.delete(sessionName) - this.processingQueues.delete(sessionName) - if (queue) { - const err = new BrowserError( - 'browser_tab_closed', - 'Tab was closed while commands were queued' - ) - for (const cmd of queue) { - cmd.reject(err) - } - queue.length = 0 - } + this.rejectQueuedCommandsForClosedSession(sessionName) if (session.activeProcess) { // Why: queued command rejection is not enough when a daemon command is @@ -2011,6 +2040,22 @@ export class AgentBrowserBridge { } } + private rejectQueuedCommandsForClosedSession(sessionName: string): void { + const queue = this.commandQueues.get(sessionName) + this.commandQueues.delete(sessionName) + this.processingQueues.delete(sessionName) + if (queue) { + const err = new BrowserError( + 'browser_tab_closed', + 'Tab was closed while commands were queued' + ) + for (const cmd of queue) { + cmd.reject(err) + } + queue.length = 0 + } + } + private async execAgentBrowser( sessionName: string, commandArgs: string[], @@ -2028,6 +2073,7 @@ export class AgentBrowserBridge { // could be destroyed. Check here to give a clear error instead of letting the // proxy fail with cryptic Electron debugger errors. if (!this.getWebContents(session.webContentsId)) { + await this.destroySession(sessionName) throw this.createPageUnavailableError(sessionName) } @@ -2045,7 +2091,12 @@ export class AgentBrowserBridge { args.push('--cdp', String(port)) } - args.push(...commandArgs, '--json') + // Why: exec passthrough can produce a large argv array; spreading it into + // push risks V8 argument limits before execFile receives the command. + for (const commandArg of commandArgs) { + args.push(commandArg) + } + args.push('--json') const stdout = await this.runAgentBrowserRaw(sessionName, args, execOptions) const translated = translateResult(stdout) @@ -2097,6 +2148,40 @@ export class AgentBrowserBridge { return new BrowserError('browser_tab_not_found', pageUnavailableMessageForSession(sessionName)) } + private closeStaleAgentBrowserSession(sessionName: string): Promise { + return new Promise((resolve) => { + let child: ReturnType | null = null + let settled = false + + const finish = (): void => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + resolve() + } + + // Why: this is best-effort daemon cleanup before creating a fresh session; + // a wedged close command must not block the real browser action. + const timeout = setTimeout(() => { + child?.kill() + finish() + }, STALE_SESSION_CLOSE_TIMEOUT_MS) + + try { + child = execFile( + this.agentBrowserBin, + ['--session', sessionName, 'close'], + { timeout: STALE_SESSION_CLOSE_TIMEOUT_MS }, + finish + ) + } catch { + finish() + } + }) + } + private createCommandError( sessionName: string, message: string, diff --git a/src/main/browser/browser-cookie-import.comet.test.ts b/src/main/browser/browser-cookie-import.comet.test.ts index c528a6de356..fc43315a31a 100644 --- a/src/main/browser/browser-cookie-import.comet.test.ts +++ b/src/main/browser/browser-cookie-import.comet.test.ts @@ -122,6 +122,71 @@ describe('detectInstalledBrowsers — Comet', () => { expect(names).toEqual(['Personal', 'Research', 'Work']) }) + it('ignores Comet profile directories that escape the browser root', async () => { + vi.doMock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + return { + ...actual, + existsSync: (p: string) => { + if (p.includes('Comet/Local State')) { + return true + } + if (p.includes('Application Support/Outside/Network/Cookies')) { + return true + } + return false + }, + readFileSync: (p: string, enc?: string) => { + if (typeof p === 'string' && p.includes('Comet/Local State')) { + return JSON.stringify({ + profile: { + info_cache: { + '../Outside': { name: 'Outside' } + } + } + }) + } + return actual.readFileSync(p as never, enc as never) + } + } + }) + + const { detectInstalledBrowsers } = await import('./browser-cookie-import') + const detected = detectInstalledBrowsers() + expect(detected.find((b) => b.family === 'comet')).toBeUndefined() + }) + + it('rejects explicit Comet profile selections that escape the browser root', async () => { + vi.doMock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + return { + ...actual, + existsSync: (p: string) => { + if (p.includes('Application Support/Outside/Network/Cookies')) { + return true + } + return false + } + } + }) + + const { selectBrowserProfile } = await import('./browser-cookie-import') + const selected = selectBrowserProfile( + { + family: 'comet', + label: 'Comet', + cookiesPath: '/Users/test/Library/Application Support/Comet/Default/Network/Cookies', + keychainService: 'Comet Safe Storage', + keychainAccount: 'Comet', + profiles: [{ name: 'Outside', directory: '../Outside' }], + selectedProfile: 'Default' + }, + '../Outside' + ) + + expect(selected).toBeNull() + }) + it('skips Comet when the data directory exists but no Cookies DB is present', async () => { vi.doMock('node:fs', async () => { const actual = await vi.importActual('node:fs') diff --git a/src/main/browser/browser-cookie-import.test.ts b/src/main/browser/browser-cookie-import.test.ts index 66bd5ad3d73..05791a982d9 100644 --- a/src/main/browser/browser-cookie-import.test.ts +++ b/src/main/browser/browser-cookie-import.test.ts @@ -1,3 +1,5 @@ +/* eslint-disable max-lines -- Why: cookie import tests share import-time Electron mocks plus + browser-specific cookie fixtures; splitting would duplicate brittle setup. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { sessionFromPartitionMock, dialogShowOpenDialogMock } = vi.hoisted(() => ({ @@ -14,13 +16,77 @@ vi.mock('electron', () => ({ import { buildChromiumCookieInsertParams, importCookiesFromFile, + importCookiesFromBrowser, detectInstalledBrowsers, - type ChromiumCookieColumnInfo + type ChromiumCookieColumnInfo, + type DetectedBrowser } from './browser-cookie-import' import { writeFileSync, mkdtempSync, rmSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' +const LARGE_SAFARI_COOKIE_COUNT = 150_000 + +function buildSafariBinaryCookies(cookieCount: number): Buffer { + const cookies: Buffer[] = [] + const offsets: number[] = [] + let pageSize = 8 + cookieCount * 4 + + for (let index = 0; index < cookieCount; index += 1) { + offsets.push(pageSize) + const cookie = buildExpiredSafariCookie(index) + cookies.push(cookie) + pageSize += cookie.length + } + + const page = Buffer.alloc(pageSize) + page.writeUInt32BE(0x00000100, 0) + page.writeUInt32LE(cookieCount, 4) + for (let index = 0; index < offsets.length; index += 1) { + page.writeUInt32LE(offsets[index], 8 + index * 4) + } + + let cookieOffset = 8 + cookieCount * 4 + for (const cookie of cookies) { + cookie.copy(page, cookieOffset) + cookieOffset += cookie.length + } + + const file = Buffer.alloc(12 + page.length) + file.write('cook', 0, 'utf8') + file.writeUInt32BE(1, 4) + file.writeUInt32BE(page.length, 8) + page.copy(file, 12) + return file +} + +function buildExpiredSafariCookie(index: number): Buffer { + const domain = `.expired-${index}.example.com` + const name = `sid-${index}` + const path = '/' + const value = 'expired' + const strings = [domain, name, path, value] + const headerSize = 48 + let cursor = headerSize + const offsets = strings.map((text) => { + const offset = cursor + cursor += Buffer.byteLength(text) + 1 + return offset + }) + + const cookie = Buffer.alloc(cursor) + cookie.writeUInt32LE(cookie.length, 0) + cookie.writeUInt32LE(offsets[0], 16) + cookie.writeUInt32LE(offsets[1], 20) + cookie.writeUInt32LE(offsets[2], 24) + cookie.writeUInt32LE(offsets[3], 28) + cookie.writeDoubleLE(1, 40) + for (let index = 0; index < strings.length; index += 1) { + cookie.write(strings[index], offsets[index], 'utf8') + } + return cookie +} + describe('importCookiesFromFile', () => { let tmpDir: string let cookiesSetMock: ReturnType @@ -214,6 +280,41 @@ describe('importCookiesFromFile', () => { }) }) +describe('importCookiesFromBrowser Safari', () => { + let tmpDir: string + let cookiesSetMock: ReturnType + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'orca-safari-cookie-test-')) + cookiesSetMock = vi.fn().mockResolvedValue(undefined) + sessionFromPartitionMock.mockReset() + sessionFromPartitionMock.mockReturnValue({ + cookies: { set: cookiesSetMock } + }) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('reports expired cookies from large Safari binary cookie pages', async () => { + const cookiesPath = join(tmpDir, 'Cookies.binarycookies') + writeFileSync(cookiesPath, buildSafariBinaryCookies(LARGE_SAFARI_COOKIE_COUNT)) + const browser: DetectedBrowser = { + family: 'safari', + label: 'Safari', + cookiesPath, + profiles: [], + selectedProfile: 'Default' + } + + const result = await importCookiesFromBrowser(browser, 'persist:test') + + expect(result).toEqual({ ok: false, reason: 'All Safari cookies are expired.' }) + expect(cookiesSetMock).not.toHaveBeenCalled() + }) +}) + describe('detectInstalledBrowsers', () => { it('returns an array of detected browsers', () => { const browsers = detectInstalledBrowsers() diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts index 6ab200c423e..7079141f4e6 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -173,6 +173,17 @@ function resolveCookiesPath(profileDir: string): string | null { return null } +function isSafeBrowserProfileDirectory(directory: string): boolean { + return ( + directory.length > 0 && + directory !== '.' && + !directory.includes('\0') && + !directory.includes('/') && + !directory.includes('\\') && + !directory.includes('..') + ) +} + // Why: Chrome's Local State JSON contains profile.info_cache which maps profile // directory names (e.g. "Default", "Profile 1") to metadata including the // user-visible display name. This lets us show human-readable names in the picker. @@ -190,6 +201,10 @@ function discoverProfiles(browserRoot: string): BrowserProfile[] { } const profiles: BrowserProfile[] = [] for (const [dir, info] of Object.entries(infoCache)) { + // Why: Local State is external metadata, but profile dirs become path segments. + if (!isSafeBrowserProfileDirectory(dir)) { + continue + } const profileName = (info as { name?: string })?.name ?? dir profiles.push({ name: profileName, directory: dir }) } @@ -362,6 +377,9 @@ export function selectBrowserProfile( browser: DetectedBrowser, profileDirectory: string ): DetectedBrowser | null { + if (!isSafeBrowserProfileDirectory(profileDirectory)) { + return null + } if (browser.family === 'firefox') { const profilesRoot = firefoxProfilesRoot() if (!profilesRoot) { @@ -726,7 +744,9 @@ export function getUserAgentForBrowser( const v = readBrowserVersion('/Applications/Comet.app') return v ? `Mozilla/5.0 (${platform}) ${chromeBase} Chrome/${v} Safari/537.36` : null } - default: + case 'firefox': + case 'safari': + case 'manual': return null } } @@ -1119,11 +1139,19 @@ function decodeSafariBinaryCookies(buffer: Buffer): ValidatedCookie[] { for (const pageSize of pageSizes) { const page = buffer.subarray(cursor, cursor + pageSize) cursor += pageSize - cookies.push(...decodeSafariPage(page)) + appendSafariCookies(cookies, decodeSafariPage(page)) } return cookies } +function appendSafariCookies(target: ValidatedCookie[], cookies: readonly ValidatedCookie[]): void { + // Why: Safari binary cookie pages can contain generated-size cookie lists; + // spreading a decoded page into push can exceed JavaScript's argument limit. + for (const cookie of cookies) { + target.push(cookie) + } +} + function decodeSafariPage(page: Buffer): ValidatedCookie[] { if (page.length < 16) { return [] diff --git a/src/main/browser/browser-grab-payload.test.ts b/src/main/browser/browser-grab-payload.test.ts index 390e35056fa..575d67375a9 100644 --- a/src/main/browser/browser-grab-payload.test.ts +++ b/src/main/browser/browser-grab-payload.test.ts @@ -122,4 +122,30 @@ describe('clampGrabPayload', () => { expect(payload?.target.reactComponents?.length).toBeLessThanOrEqual(512) expect(payload?.target.sourceFile).toBe('src/Button.tsx:12:4') }) + + it('drops executable and embedded URL schemes from page and attribute URLs', () => { + const payload = clampGrabPayload( + makeRawPayload({ + page: { + ...(makeRawPayload().page as Record), + sanitizedUrl: 'javascript:alert(1)' + }, + target: { + ...(makeRawPayload().target as Record), + attributes: { + href: 'javascript:alert(1)', + src: 'data:text/html,', + action: 'vbscript:msgbox(1)', + title: 'Safe label' + } + } + }) + ) + + expect(payload?.page.sanitizedUrl).toBe('') + expect(payload?.target.attributes.href).toBe('') + expect(payload?.target.attributes.src).toBe('') + expect(payload?.target.attributes.action).toBe('') + expect(payload?.target.attributes.title).toBe('Safe label') + }) }) diff --git a/src/main/browser/browser-grab-payload.ts b/src/main/browser/browser-grab-payload.ts index 46815ed8008..8c92670efde 100644 --- a/src/main/browser/browser-grab-payload.ts +++ b/src/main/browser/browser-grab-payload.ts @@ -5,6 +5,8 @@ import { GRAB_SECRET_PATTERNS } from '../../shared/browser-grab-types' +const SAFE_GRAB_URL_PROTOCOLS = new Set(['http:', 'https:', 'file:']) + /** * Re-validate and clamp all string, array, and budget fields in a grab payload * before forwarding to the renderer. This is the main-side safety net: even if @@ -66,6 +68,12 @@ export function clampGrabPayload(raw: unknown): BrowserGrabPayload | null { } try { const url = new URL(str) + if (url.protocol === 'about:') { + return url.toString() === 'about:blank' ? 'about:blank' : '' + } + if (!SAFE_GRAB_URL_PROTOCOLS.has(url.protocol)) { + return '' + } url.search = '' url.hash = '' return url.toString() diff --git a/src/main/browser/browser-grab-session-controller.ts b/src/main/browser/browser-grab-session-controller.ts index fafef11c25c..f810a3a7405 100644 --- a/src/main/browser/browser-grab-session-controller.ts +++ b/src/main/browser/browser-grab-session-controller.ts @@ -188,6 +188,11 @@ export class BrowserGrabSessionController { const timeoutId = setTimeout(() => { settleOnce({ opId, kind: 'cancelled', reason: 'timeout' }) }, GRAB_OP_TIMEOUT_MS) + // Why: the timeout prevents stale grab state, but an armed grab should + // not keep Electron main alive after its owning tab/window is gone. + if (typeof timeoutId.unref === 'function') { + timeoutId.unref() + } guest.on('did-start-navigation', handleNavigation) guest.on('destroyed', handleDestroyed) diff --git a/src/main/browser/browser-guest-ui.ts b/src/main/browser/browser-guest-ui.ts index b710a4ac4d7..5fa7a8c9d07 100644 --- a/src/main/browser/browser-guest-ui.ts +++ b/src/main/browser/browser-guest-ui.ts @@ -333,6 +333,8 @@ export function setupGuestShortcutForwarding(args: { } if (keybindingMatchesAction('tab.newBrowser', input, process.platform, keybindings)) { renderer.send('ui:newBrowserTab') + } else if (keybindingMatchesAction('tab.newMarkdown', input, process.platform, keybindings)) { + renderer.send('ui:newMarkdownTab') } else if (keybindingMatchesAction('tab.newTerminal', input, process.platform, keybindings)) { // Why: Cmd/Ctrl+T opens a terminal in the user's active terminal surface // even when focus is inside a browser guest. Cmd/Ctrl+Shift+B is the @@ -389,6 +391,8 @@ export function setupGuestShortcutForwarding(args: { renderer.reloadIgnoringCache() } else if (action?.type === 'jumpToWorktreeIndex') { renderer.send('ui:jumpToWorktreeIndex', action.index) + } else if (action?.type === 'jumpToTabIndex') { + renderer.send('ui:jumpToTabIndex', action.index) } else if (action?.type === 'dictationKeyDown') { if (!shouldForwardDictationShortcut?.()) { return diff --git a/src/main/browser/browser-manager-grab.test.ts b/src/main/browser/browser-manager-grab.test.ts index b3c16e08165..739dda6db52 100644 --- a/src/main/browser/browser-manager-grab.test.ts +++ b/src/main/browser/browser-manager-grab.test.ts @@ -817,9 +817,9 @@ describe('browserManager grab operations', () => { const promise = browserManager.awaitGrabSelection('tab-1', 'op-1', guest) // Find the destroyed handler and trigger it - const destroyedHandler = guestOnMock.mock.calls.find( - ([event]) => event === 'destroyed' - )?.[1] as (() => void) | undefined + const destroyedHandler = guestOnMock.mock.calls + .filter(([event]) => event === 'destroyed') + .at(-1)?.[1] as (() => void) | undefined expect(destroyedHandler).toBeTypeOf('function') destroyedHandler?.() diff --git a/src/main/browser/browser-manager.test.ts b/src/main/browser/browser-manager.test.ts index bbdefb15c76..6776131e98c 100644 --- a/src/main/browser/browser-manager.test.ts +++ b/src/main/browser/browser-manager.test.ts @@ -819,6 +819,91 @@ describe('browserManager', () => { expect(guestOnMock.mock.calls.filter(([event]) => event === 'will-redirect')).toHaveLength(1) }) + it('cleans attached guest policy state when a guest is destroyed before registration', () => { + const guest = { + id: 304, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock + } + webContentsFromIdMock.mockReturnValue(guest) + + browserManager.attachGuestPolicies(guest as never) + + const destroyedHandler = guestOnMock.mock.calls.find(([event]) => event === 'destroyed')?.[1] as + | (() => void) + | undefined + expect(destroyedHandler).toBeTypeOf('function') + + destroyedHandler?.() + browserManager.registerGuest({ + browserPageId: 'browser-destroyed-before-register', + webContentsId: guest.id, + rendererWebContentsId + }) + + expect(browserManager.getGuestWebContentsId('browser-destroyed-before-register')).toBeNull() + }) + + it('fully unregisters stale guests discovered during authorization', () => { + const guest = { + id: 305, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock + } + webContentsFromIdMock.mockReturnValue(guest) + + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'browser-stale', + workspaceId: 'workspace-stale', + worktreeId: 'worktree-stale', + sessionProfileId: 'profile-stale', + webContentsId: guest.id, + rendererWebContentsId + }) + + const internals = browserManager as unknown as { + rendererWebContentsIdByTabId: Map + workspaceIdByPageId: Map + sessionProfileIdByPageId: Map + worktreeIdByTabId: Map + contextMenuCleanupByTabId: Map void> + grabShortcutCleanupByTabId: Map void> + shortcutForwardingCleanupByTabId: Map void> + } + expect(internals.rendererWebContentsIdByTabId.has('browser-stale')).toBe(true) + expect(internals.workspaceIdByPageId.has('browser-stale')).toBe(true) + expect(internals.sessionProfileIdByPageId.has('browser-stale')).toBe(true) + expect(internals.worktreeIdByTabId.has('browser-stale')).toBe(true) + expect(internals.contextMenuCleanupByTabId.has('browser-stale')).toBe(true) + expect(internals.grabShortcutCleanupByTabId.has('browser-stale')).toBe(true) + expect(internals.shortcutForwardingCleanupByTabId.has('browser-stale')).toBe(true) + + webContentsFromIdMock.mockReturnValue(null) + + expect(browserManager.getAuthorizedGuest('browser-stale', rendererWebContentsId)).toBeNull() + + expect(browserManager.getGuestWebContentsId('browser-stale')).toBeNull() + expect(internals.rendererWebContentsIdByTabId.has('browser-stale')).toBe(false) + expect(internals.workspaceIdByPageId.has('browser-stale')).toBe(false) + expect(internals.sessionProfileIdByPageId.has('browser-stale')).toBe(false) + expect(internals.worktreeIdByTabId.has('browser-stale')).toBe(false) + expect(internals.contextMenuCleanupByTabId.has('browser-stale')).toBe(false) + expect(internals.grabShortcutCleanupByTabId.has('browser-stale')).toBe(false) + expect(internals.shortcutForwardingCleanupByTabId.has('browser-stale')).toBe(false) + expect(guestOffMock).toHaveBeenCalled() + }) + it('replays a queued main-frame load failure after the guest registers', () => { const rendererSendMock = vi.fn() const guest = { diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index c7d7f958fbe..222542b90ae 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -640,12 +640,23 @@ export class BrowserManager { guest.on('will-navigate', navigationGuard) guest.on('will-redirect', navigationGuard) guest.on('did-fail-load', didFailLoadHandler) + const handleDestroyed = (): void => { + // Why: guests can be destroyed before renderer registration. Without + // this, attach-time policy closures remain retained until app shutdown. + this.cleanupGuestPolicyAttachment(guest.id) + } + guest.on('destroyed', handleDestroyed) // Why: store cleanup so unregisterGuest can remove these listeners when the // guest surface is torn down, preventing the callbacks from preventing GC of // the underlying WebContents wrapper. this.policyCleanupByGuestId.set(guest.id, () => { disposeAntiDetection() + try { + guest.off('destroyed', handleDestroyed) + } catch { + // guest may already be destroyed + } if (!guest.isDestroyed()) { guest.off('will-navigate', navigationGuard) guest.off('will-redirect', navigationGuard) @@ -660,17 +671,20 @@ export class BrowserManager { // resolving to the live page, or stale download/popup/permission callbacks // can be delivered to the wrong session after the swap. this.tabIdByWebContentsId.delete(previousWebContentsId) + this.cleanupGuestPolicyAttachment(previousWebContentsId) + } - const policyCleanup = this.policyCleanupByGuestId.get(previousWebContentsId) + private cleanupGuestPolicyAttachment(guestWebContentsId: number): void { + const policyCleanup = this.policyCleanupByGuestId.get(guestWebContentsId) if (policyCleanup) { policyCleanup() - this.policyCleanupByGuestId.delete(previousWebContentsId) + this.policyCleanupByGuestId.delete(guestWebContentsId) } - this.policyAttachedGuestIds.delete(previousWebContentsId) - this.pendingLoadFailuresByGuestId.delete(previousWebContentsId) - this.pendingPermissionEventsByGuestId.delete(previousWebContentsId) - this.pendingPopupEventsByGuestId.delete(previousWebContentsId) - this.pendingDownloadIdsByGuestId.delete(previousWebContentsId) + this.policyAttachedGuestIds.delete(guestWebContentsId) + this.pendingLoadFailuresByGuestId.delete(guestWebContentsId) + this.pendingPermissionEventsByGuestId.delete(guestWebContentsId) + this.pendingPopupEventsByGuestId.delete(guestWebContentsId) + this.pendingDownloadIdsByGuestId.delete(guestWebContentsId) } registerGuest({ @@ -754,12 +768,7 @@ export class BrowserManager { // the underlying Chromium surface after the guest is destroyed. const guestWebContentsId = this.webContentsIdByTabId.get(browserTabId) if (guestWebContentsId !== undefined) { - const policyCleanup = this.policyCleanupByGuestId.get(guestWebContentsId) - if (policyCleanup) { - policyCleanup() - this.policyCleanupByGuestId.delete(guestWebContentsId) - } - this.policyAttachedGuestIds.delete(guestWebContentsId) + this.cleanupGuestPolicyAttachment(guestWebContentsId) } const cleanup = this.contextMenuCleanupByTabId.get(browserTabId) @@ -929,6 +938,11 @@ export class BrowserManager { download.pendingCancelTimer = setTimeout(() => { this.cancelDownloadInternal(downloadId, 'Timed out waiting for user approval.') }, 60_000) + // Why: approval timeout is a fail-closed safety net, not a reason to keep + // Electron main alive after the browser/runtime is otherwise shutting down. + if (typeof download.pendingCancelTimer.unref === 'function') { + download.pendingCancelTimer.unref() + } } getDownloadPrompt(downloadId: string, senderWebContentsId: number): { filename: string } | null { @@ -1035,8 +1049,9 @@ export class BrowserManager { } const guest = webContents.fromId(webContentsId) if (!guest || guest.isDestroyed()) { - this.webContentsIdByTabId.delete(browserTabId) - this.tabIdByWebContentsId.delete(webContentsId) + // Why: stale guest discovery must clear every per-tab registry entry, + // not just the forward/reverse WebContents maps. + this.unregisterGuest(browserTabId) return false } guest.openDevTools({ mode: 'detach' }) @@ -1102,8 +1117,9 @@ export class BrowserManager { } const guest = webContents.fromId(webContentsId) if (!guest || guest.isDestroyed()) { - this.webContentsIdByTabId.delete(browserTabId) - this.tabIdByWebContentsId.delete(webContentsId) + // Why: stale guest discovery must clear every per-tab registry entry, + // not just the forward/reverse WebContents maps. + this.unregisterGuest(browserTabId) return false } @@ -1131,8 +1147,9 @@ export class BrowserManager { } const guest = webContents.fromId(webContentsId) if (!guest || guest.isDestroyed()) { - this.webContentsIdByTabId.delete(browserTabId) - this.tabIdByWebContentsId.delete(webContentsId) + // Why: stale guest discovery must clear every per-tab registry entry, + // not just the forward/reverse WebContents maps. + this.unregisterGuest(browserTabId) return false } @@ -1238,8 +1255,9 @@ export class BrowserManager { } const guest = webContents.fromId(guestId) if (!guest || guest.isDestroyed()) { - this.webContentsIdByTabId.delete(browserTabId) - this.tabIdByWebContentsId.delete(guestId) + // Why: stale guest discovery must clear every per-tab registry entry, + // not just the forward/reverse WebContents maps. + this.unregisterGuest(browserTabId) return null } return guest diff --git a/src/main/browser/browser-session-registry.persistence.test.ts b/src/main/browser/browser-session-registry.persistence.test.ts index 697e9448d46..346d08132fd 100644 --- a/src/main/browser/browser-session-registry.persistence.test.ts +++ b/src/main/browser/browser-session-registry.persistence.test.ts @@ -243,4 +243,37 @@ describe('BrowserSessionRegistry persistence', () => { expect(written.pendingCookieImports).toEqual({ [importedPartition]: '/staged/imported' }) expect(written.pendingCookieDbPath).toBeNull() }) + + it('ignores pending cookie imports for invalid persisted profile partitions', async () => { + const invalidPartition = 'persist:../../outside' + const fsState = createFsState() + seedMeta(fsState, { + defaultSource: null, + userAgent: null, + userAgentByPartition: {}, + pendingCookieDbPath: null, + pendingCookieImports: { + [invalidPartition]: '/staged/evil' + }, + profiles: [ + { + id: 'profile-1', + scope: 'imported', + partition: invalidPartition, + label: 'Invalid', + source: null + } + ] + }) + fsState.present.add('/staged/evil') + + installModuleMocks(fsState) + const { browserSessionRegistry } = await import('./browser-session-registry') + + browserSessionRegistry.applyPendingCookieImport() + + const written = JSON.parse(fsState.files.get(META_PATH) ?? '{}') + expect(written.pendingCookieImports).toEqual({}) + expect(fsState.present.has('/outside/Cookies')).toBe(false) + }) }) diff --git a/src/main/browser/browser-session-registry.ts b/src/main/browser/browser-session-registry.ts index 43bc0fb675f..857c5dc3c9a 100644 --- a/src/main/browser/browser-session-registry.ts +++ b/src/main/browser/browser-session-registry.ts @@ -192,10 +192,14 @@ class BrowserSessionRegistry { if (pendingEntries.length === 0) { return } - const knownPartitions = new Set([ - ORCA_BROWSER_PARTITION, - ...meta.profiles.map((p) => p.partition) - ]) + // Why: replay writes to partition-derived file paths, so corrupted + // metadata must pass the same validation as the webview allowlist. + const knownPartitions = new Set([ORCA_BROWSER_PARTITION]) + for (const profile of meta.profiles) { + if (BrowserSessionRegistry.isValidPersistedProfile(profile)) { + knownPartitions.add(profile.partition) + } + } const remainingEntries = { ...meta.pendingCookieImports } for (const [partition, stagedPath] of pendingEntries) { @@ -426,17 +430,24 @@ class BrowserSessionRegistry { // registering anything. private static readonly PARTITION_RE = /^persist:orca-browser-session-[\da-f-]{36}$/ + private static isValidPersistedProfile(profile: unknown): profile is BrowserSessionProfile { + if (!profile || typeof profile !== 'object') { + return false + } + const candidate = profile as Partial + return ( + candidate.id !== 'default' && + candidate.scope !== 'default' && + typeof candidate.id === 'string' && + typeof candidate.partition === 'string' && + typeof candidate.label === 'string' && + BrowserSessionRegistry.PARTITION_RE.test(candidate.partition) + ) + } + hydrateFromPersisted(profiles: BrowserSessionProfile[]): void { for (const profile of profiles) { - if (profile.id === 'default' || profile.scope === 'default') { - continue - } - if ( - typeof profile.id !== 'string' || - typeof profile.partition !== 'string' || - typeof profile.label !== 'string' || - !BrowserSessionRegistry.PARTITION_RE.test(profile.partition) - ) { + if (!BrowserSessionRegistry.isValidPersistedProfile(profile)) { continue } this.profiles.set(profile.id, profile) diff --git a/src/main/browser/cdp-bridge-integration.test.ts b/src/main/browser/cdp-bridge-integration.test.ts index 8475a0cbca8..eab3e74b6c2 100644 --- a/src/main/browser/cdp-bridge-integration.test.ts +++ b/src/main/browser/cdp-bridge-integration.test.ts @@ -72,11 +72,17 @@ const SEARCH_PAGE_TREE: AXNode[] = [ // ── Mock WebContents factory ── -function createMockGuest(id: number, url: string, title: string) { +function createMockGuest( + id: number, + url: string, + title: string, + options?: { readyState?: string | (() => string) } +) { let currentUrl = url let currentTitle = title let currentTree = EXAMPLE_COM_TREE let navHistoryId = 1 + let debuggerAttached = false const sendCommandMock = vi.fn(async (method: string, params?: Record) => { switch (method) { @@ -110,7 +116,14 @@ function createMockGuest(id: number, url: string, title: string) { case 'Runtime.evaluate': { const expr = (params as { expression: string }).expression if (expr === 'document.readyState') { - return { result: { value: 'complete' } } + return { + result: { + value: + typeof options?.readyState === 'function' + ? options.readyState() + : (options?.readyState ?? 'complete') + } + } } if (expr === 'location.origin') { return { result: { value: new URL(currentUrl).origin } } @@ -181,7 +194,13 @@ function createMockGuest(id: number, url: string, title: string) { on: vi.fn(), off: vi.fn(), debugger: { - attach: vi.fn(), + isAttached: vi.fn(() => debuggerAttached), + attach: vi.fn(() => { + if (debuggerAttached) { + throw new Error('Another debugger is already attached') + } + debuggerAttached = true + }), detach: vi.fn(), sendCommand: sendCommandMock, on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { @@ -203,7 +222,20 @@ function createMockGuest(id: number, url: string, title: string) { } } - return { guest, sendCommandMock } + return { + guest, + sendCommandMock, + emitDebugger(event: string, ...args: unknown[]) { + for (const handler of debuggerListeners.get(event) ?? []) { + handler(...args) + } + }, + emitDebuggerMessage(method: string, params?: Record) { + for (const handler of debuggerListeners.get('message') ?? []) { + handler({}, method, params) + } + } + } } // ── RPC helper ── @@ -239,12 +271,17 @@ describe('Browser automation pipeline (integration)', () => { let server: OrcaRuntimeRpcServer let endpoint: string let authToken: string + let activeGuest: ReturnType['guest'] + let activeGuestHarness: ReturnType + let cdpBridge: CdpBridge const GUEST_WC_ID = 5001 const RENDERER_WC_ID = 1 beforeEach(async () => { - const { guest } = createMockGuest(GUEST_WC_ID, 'https://example.com', 'Example Domain') + activeGuestHarness = createMockGuest(GUEST_WC_ID, 'https://example.com', 'Example Domain') + const { guest } = activeGuestHarness + activeGuest = guest webContentsFromIdMock.mockImplementation((id: number) => { if (id === GUEST_WC_ID) { return guest @@ -261,7 +298,7 @@ describe('Browser automation pipeline (integration)', () => { rendererWebContentsId: RENDERER_WC_ID }) - const cdpBridge = new CdpBridge(browserManager) + cdpBridge = new CdpBridge(browserManager) cdpBridge.setActiveTab(GUEST_WC_ID) const userDataPath = mkdtempSync(join(tmpdir(), 'browser-e2e-')) @@ -313,6 +350,24 @@ describe('Browser automation pipeline (integration)', () => { role: 'link', name: 'More information...' }) + expect(activeGuest.debugger.attach).toHaveBeenCalledTimes(1) + }) + + it('preserves debugger listeners owned by other browser streams', async () => { + const externalDetach = vi.fn() + const externalMessage = vi.fn() + activeGuest.debugger.on('detach', externalDetach) + activeGuest.debugger.on('message', externalMessage) + + const res = await rpc('browser.snapshot') + expect(res.ok).toBe(true) + + activeGuestHarness.emitDebugger('message', {}, 'Runtime.consoleAPICalled', {}) + activeGuestHarness.emitDebugger('detach') + + expect(externalMessage).toHaveBeenCalledWith({}, 'Runtime.consoleAPICalled', {}) + expect(externalDetach).toHaveBeenCalledTimes(1) + expect(activeGuest.debugger.removeAllListeners).not.toHaveBeenCalled() }) // ── Click ── @@ -370,6 +425,43 @@ describe('Browser automation pipeline (integration)', () => { expect((res.error as { code: string }).code).toBe('browser_navigation_failed') }) + it('clears readyState polling timers when navigation times out', async () => { + vi.useFakeTimers() + try { + const slowGuestHarness = createMockGuest(6001, 'https://slow.example.com', 'Slow Page', { + readyState: 'loading' + }) + webContentsFromIdMock.mockImplementation((id: number) => { + if (id === 6001) { + return slowGuestHarness.guest + } + return null + }) + + const browserManager = new BrowserManager() + browserManager.attachGuestPolicies(slowGuestHarness.guest as never) + browserManager.registerGuest({ + browserPageId: 'slow-page', + webContentsId: 6001, + rendererWebContentsId: RENDERER_WC_ID + }) + const bridge = new CdpBridge(browserManager) + bridge.setActiveTab(6001) + + const gotoResult = bridge.goto('https://slow.example.com/still-loading').then( + () => null, + (error: unknown) => error + ) + + await vi.advanceTimersByTimeAsync(25_000) + + await expect(gotoResult).resolves.toMatchObject({ code: 'browser_timeout' }) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + // ── Fill ── it('fills an input by ref', async () => { @@ -431,6 +523,47 @@ describe('Browser automation pipeline (integration)', () => { expect(result.data.length).toBeGreaterThan(0) }) + it('bounds capture request bookkeeping when network entries are evicted or fail', async () => { + const startRes = await rpc('browser.capture.start') + expect(startRes.ok).toBe(true) + + for (let i = 0; i <= 1000; i++) { + activeGuestHarness.emitDebuggerMessage('Network.responseReceived', { + requestId: `req-${i}`, + response: { + url: `https://example.com/${i}`, + status: 200, + mimeType: 'text/plain' + }, + timestamp: i + }) + } + + const state = ( + cdpBridge as unknown as { + tabState: Map< + string, + { + networkLog: unknown[] + networkRequestMap: Map + } + > + } + ).tabState.get('page-1') + + expect(state?.networkLog).toHaveLength(1000) + expect(state?.networkRequestMap.has('req-0')).toBe(false) + expect(state?.networkRequestMap.size).toBe(1000) + + activeGuestHarness.emitDebuggerMessage('Network.loadingFailed', { requestId: 'req-1' }) + expect(state?.networkRequestMap.has('req-1')).toBe(false) + expect(state?.networkRequestMap.size).toBe(999) + + const stopRes = await rpc('browser.capture.stop') + expect(stopRes.ok).toBe(true) + expect(state?.networkRequestMap.size).toBe(0) + }) + // ── Eval ── it('evaluates JavaScript in the page context', async () => { diff --git a/src/main/browser/cdp-bridge.ts b/src/main/browser/cdp-bridge.ts index 8bfa6e62de9..5390455a2d9 100644 --- a/src/main/browser/cdp-bridge.ts +++ b/src/main/browser/cdp-bridge.ts @@ -48,6 +48,8 @@ import { import type { BrowserManager } from './browser-manager' import { ANTI_DETECTION_SCRIPT } from './anti-detection' +const CAPTURE_LOG_LIMIT = 1000 + export class BrowserError extends Error { constructor( readonly code: string, @@ -61,6 +63,8 @@ type TabState = { navigationId: string | null snapshotResult: SnapshotResult | null debuggerAttached: boolean + debuggerDetachListener: (() => void) | null + debuggerMessageListener: ((_event: unknown, method: string, params: unknown) => void) | null iframeSessions: Map // Why: capture state is per-tab so console/network events from one tab // don't pollute another's capture buffer. @@ -866,6 +870,7 @@ export class CdpBridge { state.capturing = true state.consoleLog = [] state.networkLog = [] + state.networkRequestMap.clear() return { capturing: true } }) @@ -878,6 +883,7 @@ export class CdpBridge { const state = this.getOrCreateTabState(tabId) state.capturing = false + state.networkRequestMap.clear() return { stopped: true } }) @@ -1032,6 +1038,11 @@ export class CdpBridge { } const tabId = this.resolveTabIdSafe(webContentsId) if (tabId) { + const state = this.tabState.get(tabId) + const guest = webContents.fromId(webContentsId) + if (state && guest) { + this.removeDebuggerListeners(guest, state) + } this.tabState.delete(tabId) this.commandQueues.delete(tabId) } @@ -1118,6 +1129,8 @@ export class CdpBridge { navigationId: null, snapshotResult: null, debuggerAttached: false, + debuggerDetachListener: null, + debuggerMessageListener: null, iframeSessions: new Map(), capturing: false, consoleLog: [], @@ -1132,15 +1145,42 @@ export class CdpBridge { return state } + private removeDebuggerListeners(guest: Electron.WebContents, state: TabState): void { + const detachListener = state.debuggerDetachListener + const messageListener = state.debuggerMessageListener + state.debuggerDetachListener = null + state.debuggerMessageListener = null + + if (detachListener) { + try { + guest.debugger.removeListener('detach', detachListener as never) + } catch { + // guest may already be destroyed + } + } + if (messageListener) { + try { + guest.debugger.removeListener('message', messageListener as never) + } catch { + // guest may already be destroyed + } + } + } + private async ensureDebuggerAttached(guest: Electron.WebContents): Promise { const tabId = this.resolveTabId(guest.id) const state = this.getOrCreateTabState(tabId) - if (state.debuggerAttached) { + if (state.debuggerAttached && guest.debugger.isAttached()) { return } try { - guest.debugger.attach('1.3') + // Why: browser tabs are already debugger-attached by BrowserManager's + // anti-detection injection. Reusing that attachment lets CDP commands + // run instead of failing with "another debugger is already attached." + if (!guest.debugger.isAttached()) { + guest.debugger.attach('1.3') + } } catch { throw new BrowserError( 'browser_cdp_error', @@ -1169,20 +1209,18 @@ export class CdpBridge { source: ANTI_DETECTION_SCRIPT }) - // Why: remove any stale listeners from a previous attach cycle to prevent - // listener accumulation. After a detach+reattach, the old handlers would - // still fire alongside the new ones, causing duplicate log entries, - // duplicate dialog dismissals, and corrupted iframe session maps. - guest.debugger.removeAllListeners('detach') - guest.debugger.removeAllListeners('message') + // Why: only remove CDP bridge listeners from the previous attach cycle. + // Screencast/proxy sessions share this debugger and own their teardown hooks. + this.removeDebuggerListeners(guest, state) - guest.debugger.on('detach', () => { + const detachListener = (): void => { state.debuggerAttached = false state.snapshotResult = null state.iframeSessions.clear() - }) + this.removeDebuggerListeners(guest, state) + } - guest.debugger.on('message', (_event: unknown, method: string, params: unknown) => { + const messageListener = (_event: unknown, method: string, params: unknown): void => { if (method === 'Page.frameNavigated') { state.snapshotResult = null state.navigationId = null @@ -1246,7 +1284,7 @@ export class CdpBridge { url: p.stackTrace?.callFrames?.[0]?.url, line: p.stackTrace?.callFrames?.[0]?.lineNumber }) - if (state.consoleLog.length > 1000) { + if (state.consoleLog.length > CAPTURE_LOG_LIMIT) { state.consoleLog.shift() } } @@ -1280,19 +1318,27 @@ export class CdpBridge { if (p.requestId) { state.networkRequestMap.set(p.requestId, entry) } - if (state.networkLog.length > 1000) { - state.networkLog.shift() + if (state.networkLog.length > CAPTURE_LOG_LIMIT) { + const evicted = state.networkLog.shift() + if (evicted) { + for (const [requestId, requestEntry] of state.networkRequestMap) { + if (requestEntry === evicted) { + state.networkRequestMap.delete(requestId) + break + } + } + } } } } - if (method === 'Network.loadingFinished') { + if (method === 'Network.loadingFinished' || method === 'Network.loadingFailed') { const p = params as { requestId?: string; encodedDataLength?: number } | undefined - if (p?.requestId && p.encodedDataLength) { + if (p?.requestId) { const entry = state.networkRequestMap.get(p.requestId) - if (entry) { + if (entry && method === 'Network.loadingFinished' && p.encodedDataLength) { entry.size = p.encodedDataLength - state.networkRequestMap.delete(p.requestId) } + state.networkRequestMap.delete(p.requestId) } } } @@ -1316,7 +1362,12 @@ export class CdpBridge { }) } } - }) + } + + state.debuggerDetachListener = detachListener + state.debuggerMessageListener = messageListener + guest.debugger.on('detach', detachListener) + guest.debugger.on('message', messageListener) state.debuggerAttached = true } @@ -1583,28 +1634,51 @@ export class CdpBridge { // Phase 1: wait for readyState=complete await new Promise((resolve, reject) => { + let settled = false + let pollTimer: ReturnType | null = null + + const finish = (callback: () => void): void => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + if (pollTimer) { + clearTimeout(pollTimer) + pollTimer = null + } + callback() + } + const timeout = setTimeout(() => { - reject(new BrowserError('browser_timeout', 'Page load timed out.')) + finish(() => reject(new BrowserError('browser_timeout', 'Page load timed out.'))) }, TIMEOUT_MS) const check = async (): Promise => { + if (settled) { + return + } try { const { result } = (await sender('Runtime.evaluate', { expression: 'document.readyState', returnByValue: true })) as { result: { value: string } } + if (settled) { + return + } if (result.value === 'complete') { - clearTimeout(timeout) - resolve() + finish(resolve) } else { - setTimeout(check, 100) + pollTimer = setTimeout(() => { + pollTimer = null + void check() + }, 100) } } catch { - clearTimeout(timeout) - resolve() + finish(resolve) } } - check() + void check() }) // Phase 2: wait for network idle diff --git a/src/main/browser/cdp-screenshot.test.ts b/src/main/browser/cdp-screenshot.test.ts index 8ae6418e0af..97859f7297f 100644 --- a/src/main/browser/cdp-screenshot.test.ts +++ b/src/main/browser/cdp-screenshot.test.ts @@ -189,6 +189,51 @@ describe('captureScreenshot', () => { 'Screenshot timed out — the browser tab may not be visible or the window may not have focus.' ) }) + + it('reports the original timeout when fallback encoding fails', async () => { + vi.useFakeTimers() + + const webContents = createMockWebContents() + webContents.debugger.sendCommand.mockImplementation(() => new Promise(() => {})) + webContents.capturePage.mockResolvedValueOnce({ + isEmpty: () => { + throw new Error('native image unavailable') + } + }) + const onResult = vi.fn() + const onError = vi.fn() + + captureScreenshot(webContents as never, { format: 'png' }, onResult, onError) + await vi.advanceTimersByTimeAsync(8000) + + expect(onResult).not.toHaveBeenCalled() + expect(onError).toHaveBeenCalledWith( + 'Screenshot timed out — the browser tab may not be visible or the window may not have focus.' + ) + }) + + it('reports the timeout when both CDP and fallback capture stall', async () => { + vi.useFakeTimers() + + const webContents = createMockWebContents() + webContents.debugger.sendCommand.mockImplementation(() => new Promise(() => {})) + webContents.capturePage.mockImplementation(() => new Promise(() => {})) + const onResult = vi.fn() + const onError = vi.fn() + + captureScreenshot(webContents as never, { format: 'png' }, onResult, onError) + await vi.advanceTimersByTimeAsync(8000) + + expect(webContents.capturePage).toHaveBeenCalledTimes(1) + expect(onResult).not.toHaveBeenCalled() + expect(onError).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1000) + + expect(onError).toHaveBeenCalledWith( + 'Screenshot timed out — the browser tab may not be visible or the window may not have focus.' + ) + }) }) describe('captureFullPageScreenshot', () => { diff --git a/src/main/browser/cdp-screenshot.ts b/src/main/browser/cdp-screenshot.ts index 91baacac6c6..639db887289 100644 --- a/src/main/browser/cdp-screenshot.ts +++ b/src/main/browser/cdp-screenshot.ts @@ -1,6 +1,7 @@ import type { WebContents } from 'electron' const SCREENSHOT_TIMEOUT_MS = 8000 +const FALLBACK_CAPTURE_TIMEOUT_MS = 1000 const SCREENSHOT_TIMEOUT_MESSAGE = 'Screenshot timed out — the browser tab may not be visible or the window may not have focus.' @@ -210,6 +211,34 @@ export function captureScreenshot( } let settled = false + let timeoutTimer: ReturnType | null = null + let fallbackTimer: ReturnType | null = null + const clearTimers = (): void => { + if (timeoutTimer) { + clearTimeout(timeoutTimer) + timeoutTimer = null + } + if (fallbackTimer) { + clearTimeout(fallbackTimer) + fallbackTimer = null + } + } + const settleResult = (result: unknown): void => { + if (settled) { + return + } + settled = true + clearTimers() + onResult(result) + } + const settleError = (message: string): void => { + if (settled) { + return + } + settled = true + clearTimers() + onError(message) + } // Why: a compositor invalidate is cheap and can recover guest instances that // are visible but have not produced a fresh frame since being reclaimed into // the active browser tab. @@ -218,47 +247,52 @@ export function captureScreenshot( } catch { // Some guest teardown paths reject repaint requests. Fall through to CDP. } - const timer = setTimeout(async () => { - if (!settled) { - try { - const image = await webContents.capturePage() - if (settled) { - return - } - const fallback = encodeNativeImageScreenshot(image, params) - if (fallback) { + timeoutTimer = setTimeout(() => { + if (settled) { + return + } + // Why: capturePage is only a best-effort fallback. If it also stalls, the + // CDP proxy must still settle instead of inheriting the compositor hang. + fallbackTimer = setTimeout( + () => settleError(SCREENSHOT_TIMEOUT_MESSAGE), + FALLBACK_CAPTURE_TIMEOUT_MS + ) + void Promise.resolve() + .then(() => webContents.capturePage()) + .then( + (image) => { if (settled) { return } - settled = true - onResult(fallback) - return + if (fallbackTimer) { + clearTimeout(fallbackTimer) + fallbackTimer = null + } + let fallback: { data: string } | null = null + try { + fallback = encodeNativeImageScreenshot(image, params) + } catch { + settleError(SCREENSHOT_TIMEOUT_MESSAGE) + return + } + if (fallback) { + settleResult(fallback) + return + } + settleError(SCREENSHOT_TIMEOUT_MESSAGE) + }, + () => { + if (fallbackTimer) { + clearTimeout(fallbackTimer) + fallbackTimer = null + } + settleError(SCREENSHOT_TIMEOUT_MESSAGE) } - } catch { - // Fall through to the original timeout error below. - } - - if (!settled) { - settled = true - onError(SCREENSHOT_TIMEOUT_MESSAGE) - } - } + ) }, SCREENSHOT_TIMEOUT_MS) dbg .sendCommand('Page.captureScreenshot', screenshotParams) - .then((result) => { - if (!settled) { - settled = true - clearTimeout(timer) - onResult(result) - } - }) - .catch((err) => { - if (!settled) { - settled = true - clearTimeout(timer) - onError((err as Error).message) - } - }) + .then((result) => settleResult(result)) + .catch((err) => settleError((err as Error).message)) } diff --git a/src/main/browser/cdp-ws-proxy.test.ts b/src/main/browser/cdp-ws-proxy.test.ts index ea373013062..9f345a2e6ee 100644 --- a/src/main/browser/cdp-ws-proxy.test.ts +++ b/src/main/browser/cdp-ws-proxy.test.ts @@ -330,6 +330,28 @@ describe('CdpWsProxy', () => { }) }) + it('detaches client websocket listeners after client close', async () => { + const client = await connect() + const serverClient = (proxy as unknown as { client: WebSocket | null }).client + expect(serverClient).toBeTruthy() + const offSpy = vi.spyOn(serverClient!, 'off') + + client.close() + + const start = Date.now() + while ( + (proxy as unknown as { client: WebSocket | null }).client && + Date.now() - start < 2_000 + ) { + await new Promise((resolve) => setTimeout(resolve, 20)) + } + + expect((proxy as unknown as { client: WebSocket | null }).client).toBeNull() + const removedEvents = offSpy.mock.calls.map(([event]) => event) + expect(removedEvents).toEqual(expect.arrayContaining(['message', 'close'])) + offSpy.mockRestore() + }) + it('rejects inflight requests on stop', async () => { let resolveCommand: (v: unknown) => void mock.webContents.debugger.sendCommand.mockImplementation( diff --git a/src/main/browser/cdp-ws-proxy.ts b/src/main/browser/cdp-ws-proxy.ts index 6ec1d2bb55d..f65e5b5a343 100644 --- a/src/main/browser/cdp-ws-proxy.ts +++ b/src/main/browser/cdp-ws-proxy.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Why: this proxy owns HTTP discovery, websocket client lifecycle, and CDP debugger forwarding together. */ import { WebSocketServer, WebSocket } from 'ws' import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'http' import type { WebContents } from 'electron' @@ -9,6 +10,7 @@ export class CdpWsProxy { private httpServer: Server | null = null private wss: WebSocketServer | null = null private client: WebSocket | null = null + private detachClientListeners: (() => void) | null = null private port = 0 private debuggerMessageHandler: ((...args: unknown[]) => void) | null = null private debuggerDetachHandler: ((...args: unknown[]) => void) | null = null @@ -39,16 +41,27 @@ export class CdpWsProxy { failStart(error) } this.wss.on('connection', (ws) => { - if (this.client) { - this.client.close() - } + this.closeClient() this.client = ws - ws.on('message', (data) => this.handleClientMessage(ws, data.toString())) - ws.on('close', () => { + const onMessage = (data: WebSocket.RawData): void => { + this.handleClientMessage(ws, data.toString()) + } + const onClose = (): void => { + detach() if (this.client === ws) { this.client = null } - }) + } + const detach = (): void => { + ws.off('message', onMessage) + ws.off('close', onClose) + if (this.detachClientListeners === detach) { + this.detachClientListeners = null + } + } + this.detachClientListeners = detach + ws.on('message', onMessage) + ws.on('close', onClose) }) this.httpServer.listen(0, '127.0.0.1', () => { this.httpServer?.removeListener('error', onListenError) @@ -66,10 +79,7 @@ export class CdpWsProxy { async stop(): Promise { this.detachDebugger() - if (this.client) { - this.client.close() - this.client = null - } + this.closeClient() if (this.wss) { this.wss.close() this.wss = null @@ -84,6 +94,14 @@ export class CdpWsProxy { return this.port } + private closeClient(): void { + const client = this.client + this.detachClientListeners?.() + this.detachClientListeners = null + this.client = null + client?.close() + } + private send(payload: unknown, client = this.client): void { if (client?.readyState === WebSocket.OPEN) { client.send(JSON.stringify(payload)) diff --git a/src/main/browser/grab-guest-script.test.ts b/src/main/browser/grab-guest-script.test.ts index e8705c0edd8..4455b1d0420 100644 --- a/src/main/browser/grab-guest-script.test.ts +++ b/src/main/browser/grab-guest-script.test.ts @@ -121,6 +121,13 @@ describe('buildGuestOverlayScript', () => { expect(script).toContain("return '';") }) + it('arm script rejects executable and embedded URL schemes', () => { + const script = buildGuestOverlayScript('arm') + + expect(script).toContain('SAFE_URL_PROTOCOLS') + expect(script).toContain('!SAFE_URL_PROTOCOLS.has(u.protocol)') + }) + it('arm script slices text nodes before normalizing bounded text', () => { const script = buildGuestOverlayScript('arm') diff --git a/src/main/browser/grab-guest-script.ts b/src/main/browser/grab-guest-script.ts index 9817bc187ac..a07f4b70892 100644 --- a/src/main/browser/grab-guest-script.ts +++ b/src/main/browser/grab-guest-script.ts @@ -93,6 +93,8 @@ const ARM_SCRIPT = `(function() { 'secret', 'password', 'passwd' ]; + var SAFE_URL_PROTOCOLS = new Set(['http:', 'https:', 'file:']); + var STYLE_PROPS = [ 'display', 'position', 'width', 'height', 'margin', 'padding', 'color', 'backgroundColor', 'border', 'borderRadius', 'fontFamily', @@ -118,6 +120,12 @@ const ARM_SCRIPT = `(function() { function sanitizeUrl(url) { try { var u = new URL(url); + if (u.protocol === 'about:') { + return u.toString() === 'about:blank' ? 'about:blank' : ''; + } + if (!SAFE_URL_PROTOCOLS.has(u.protocol)) { + return ''; + } u.search = ''; u.hash = ''; return u.toString(); diff --git a/src/main/claude-accounts/keychain.test.ts b/src/main/claude-accounts/keychain.test.ts index b03c790ba19..f0ec1dd40c5 100644 --- a/src/main/claude-accounts/keychain.test.ts +++ b/src/main/claude-accounts/keychain.test.ts @@ -45,6 +45,7 @@ describe('Claude Keychain credentials', () => { }) afterEach(() => { + vi.useRealTimers() if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) } @@ -175,6 +176,36 @@ describe('Claude Keychain credentials', () => { ]) }) + it('rejects when a keychain read never reports completion', async () => { + vi.useFakeTimers() + const configDir = '/tmp/orca-claude-login-test' + const killMock = vi.fn() + execFileMock.mockImplementationOnce(() => ({ kill: killMock }) as never) + + let settled = false + let rejected: unknown + const readPromise = readActiveClaudeKeychainCredentialsStrict(configDir).then( + (credentials) => { + settled = true + return credentials + }, + (error: unknown) => { + settled = true + rejected = error + return null + } + ) + + await vi.advanceTimersByTimeAsync(3000) + + expect(settled).toBe(true) + await readPromise + expect(rejected).toEqual( + expect.objectContaining({ message: 'security timed out after 3000ms' }) + ) + expect(killMock).toHaveBeenCalled() + }) + it('deletes both scoped and legacy active credentials for config-dir cleanup', async () => { const configDir = '/tmp/orca-claude-login-test' const scopedService = serviceForConfigDir(configDir) diff --git a/src/main/claude-accounts/keychain.ts b/src/main/claude-accounts/keychain.ts index ff9afdc66a2..910e1786a7b 100644 --- a/src/main/claude-accounts/keychain.ts +++ b/src/main/claude-accounts/keychain.ts @@ -3,6 +3,12 @@ import { createHash } from 'node:crypto' const ACTIVE_CLAUDE_SERVICE = 'Claude Code-credentials' const ORCA_CLAUDE_SERVICE = 'Orca Claude Code Managed Credentials' +const KEYCHAIN_COMMAND_TIMEOUT_MS = 3_000 + +type SecurityCommandResult = { + stdout: string + stderr: string +} export async function readActiveClaudeKeychainCredentials( configDir?: string @@ -97,30 +103,25 @@ async function readKeychainPassword(service: string, account: string): Promise { - execFile( - 'security', - ['find-generic-password', '-s', service, '-a', account, '-w'], - { timeout: 3_000 }, - (error, stdout, stderr) => { - if (!error && stdout.trim()) { - resolve(stdout.trim()) - return - } - const message = `${stderr} ${error?.message ?? ''}`.toLowerCase() - const code = (error as { code?: unknown } | null)?.code - if ( - code === 44 || - message.includes('could not be found') || - message.includes('not be found') - ) { - resolve(null) - return - } - reject(error ?? new Error(`Could not read macOS Keychain item ${service}/${account}.`)) - } - ) - }) + try { + const { stdout } = await execSecurityCommand([ + 'find-generic-password', + '-s', + service, + '-a', + account, + '-w' + ]) + if (stdout.trim()) { + return stdout.trim() + } + throw new Error(`Could not read macOS Keychain item ${service}/${account}.`) + } catch (error) { + if (isKeychainNotFoundError(error)) { + return null + } + throw error + } } async function writeKeychainPassword( @@ -152,26 +153,81 @@ function execSecurity( args: string[], options?: { ignoreFailure?: boolean; ignoreNotFound?: boolean } ): Promise { + return execSecurityCommand(args).then(undefined, (error: unknown) => { + if (options?.ignoreNotFound && isKeychainNotFoundError(error)) { + return + } + if (!options?.ignoreFailure) { + throw error + } + }) +} + +function isKeychainNotFoundError(error: unknown): boolean { + const code = + error && typeof error === 'object' && 'code' in error + ? (error as { code?: unknown }).code + : undefined + const message = + error && typeof error === 'object' + ? `${(error as { stderr?: unknown }).stderr ?? ''} ${ + (error as { message?: unknown }).message ?? '' + }`.toLowerCase() + : String(error).toLowerCase() + return code === 44 || message.includes('could not be found') || message.includes('not be found') +} + +function execSecurityCommand(args: string[]): Promise { return new Promise((resolve, reject) => { - execFile('security', args, { timeout: 3_000 }, (error, _stdout, stderr) => { - if (!error) { - resolve() + let settled = false + let child: ReturnType | undefined + const timer = setTimeout(() => { + if (settled) { return } - const code = (error as { code?: unknown }).code - const message = `${stderr} ${error.message}`.toLowerCase() - if ( - options?.ignoreNotFound && - (code === 44 || message.includes('could not be found') || message.includes('not be found')) - ) { - resolve() + settled = true + child?.kill() + reject( + Object.assign(new Error(`security timed out after ${KEYCHAIN_COMMAND_TIMEOUT_MS}ms`), { + code: 'ETIMEDOUT', + stderr: '' + }) + ) + }, KEYCHAIN_COMMAND_TIMEOUT_MS) + + const settle = (callback: () => void): void => { + if (settled) { return } - if (!options?.ignoreFailure) { - reject(error) - return - } - resolve() - }) + settled = true + clearTimeout(timer) + callback() + } + + // Why: Node's execFile timeout only signals the `security` process; a + // stuck callback would otherwise leave auth/keychain operations pending. + try { + child = execFile( + 'security', + args, + { timeout: KEYCHAIN_COMMAND_TIMEOUT_MS }, + (error, stdout, stderr) => { + if (error) { + settle(() => + reject( + Object.assign(error, { + stdout: String(stdout), + stderr: String(stderr) + }) + ) + ) + return + } + settle(() => resolve({ stdout: String(stdout), stderr: String(stderr) })) + } + ) + } catch (error) { + settle(() => reject(error)) + } }) } diff --git a/src/main/claude-accounts/service.test.ts b/src/main/claude-accounts/service.test.ts index 423b6aa9992..379a98ae758 100644 --- a/src/main/claude-accounts/service.test.ts +++ b/src/main/claude-accounts/service.test.ts @@ -1,8 +1,10 @@ /* eslint-disable max-lines -- test suite covers Claude capture and rollback edge cases */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { PassThrough } from 'node:stream' import { deleteActiveClaudeKeychainCredentialsStrict, readActiveClaudeKeychainCredentials, @@ -803,4 +805,56 @@ describe('ClaudeAccountService credential capture', () => { wslDistro: 'Ubuntu' }) }) + + it('removes command listeners when Claude sign-in times out', async () => { + vi.resetModules() + vi.useFakeTimers() + const child = new EventEmitter() as EventEmitter & { + stdout: PassThrough + stderr: PassThrough + kill: () => void + } + child.stdout = new PassThrough() + child.stderr = new PassThrough() + child.kill = vi.fn() + const spawnMock = vi.fn(() => child) + vi.doMock('node:child_process', () => ({ spawn: spawnMock })) + + try { + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + createService() as never, + createService() as never, + createService() as never + ) + const commandPromise = ( + service as unknown as { + runClaudeCommand( + args: string[], + configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null }, + timeoutMs: number + ): Promise + } + ).runClaudeCommand( + ['login'], + { windowsPath: '/tmp/claude-auth', linuxPath: null, wslDistro: null }, + 1000 + ) + const rejection = expect(commandPromise).rejects.toThrow( + 'Claude sign-in took too long to finish.' + ) + + await vi.advanceTimersByTimeAsync(1000) + + await rejection + expect(child.kill).toHaveBeenCalledTimes(1) + expect(child.stdout.listenerCount('data')).toBe(0) + expect(child.stderr.listenerCount('data')).toBe(0) + expect(child.listenerCount('error')).toBe(0) + expect(child.listenerCount('close')).toBe(0) + } finally { + vi.useRealTimers() + vi.doUnmock('node:child_process') + } + }) }) diff --git a/src/main/claude-accounts/service.ts b/src/main/claude-accounts/service.ts index c09183c94fb..49c6f0f1a5c 100644 --- a/src/main/claude-accounts/service.ts +++ b/src/main/claude-accounts/service.ts @@ -914,25 +914,35 @@ export class ClaudeAccountService { output = output.slice(-MAX_COMMAND_OUTPUT_CHARS) } } + let timeout: ReturnType | null = null + const cleanupListeners = (): void => { + if (timeout) { + clearTimeout(timeout) + timeout = null + } + child.stdout.off('data', appendOutput) + child.stderr.off('data', appendOutput) + child.off('error', onError) + child.off('close', onClose) + } const settle = (callback: () => void): void => { if (settled) { return } settled = true - clearTimeout(timeout) + cleanupListeners() callback() } - const timeout = setTimeout(() => { + const timeoutError = new Error('Claude sign-in took too long to finish.') + timeout = setTimeout(() => { child.kill() - settle(() => rejectPromise(new Error('Claude sign-in took too long to finish.'))) + settle(() => rejectPromise(timeoutError)) }, timeoutMs) - child.stdout.on('data', appendOutput) - child.stderr.on('data', appendOutput) - child.on('error', (error) => { + const onError = (error: Error): void => { settle(() => rejectPromise(error)) - }) - child.on('close', (code) => { + } + const onClose = (code: number | null): void => { settle(() => { if (code === 0 || options?.allowFailure) { resolvePromise(output) @@ -947,7 +957,12 @@ export class ClaudeAccountService { ) ) }) - }) + } + + child.stdout.on('data', appendOutput) + child.stderr.on('data', appendOutput) + child.on('error', onError) + child.on('close', onClose) }) } diff --git a/src/main/claude-usage/scanner-large-directory.test.ts b/src/main/claude-usage/scanner-large-directory.test.ts new file mode 100644 index 00000000000..5f189943a37 --- /dev/null +++ b/src/main/claude-usage/scanner-large-directory.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from 'vitest' +import type { Dirent } from 'node:fs' +import type * as FsPromises from 'fs/promises' +import type * as NodeOs from 'os' +import { join } from 'path' + +const { homedirMock, readdirMock } = vi.hoisted(() => ({ + homedirMock: vi.fn<() => string>(), + readdirMock: vi.fn<(dirPath: string) => Promise>() +})) + +vi.mock('os', async () => { + const actual = await vi.importActual('os') + return { + ...actual, + homedir: homedirMock + } +}) + +vi.mock('fs/promises', async () => { + const actual = await vi.importActual('fs/promises') + return { + ...actual, + readdir: readdirMock + } +}) + +const FILE_COUNT = 125_000 +const FAKE_HOME = join('/', 'tmp', 'orca-large-claude-home') +const PROJECTS_ROOT = join(FAKE_HOME, '.claude', 'projects') +const TRANSCRIPTS_ROOT = join(FAKE_HOME, '.claude', 'transcripts') +const PROJECT_DIR = join(PROJECTS_ROOT, 'large-project') + +function dirent(name: string, kind: 'directory' | 'file'): Dirent { + return { + name, + isDirectory: () => kind === 'directory', + isFile: () => kind === 'file' + } as Dirent +} + +const largeTranscriptEntries = Array.from({ length: FILE_COUNT }, (_, index) => + dirent(`session-${index}.jsonl`, 'file') +) + +describe('listClaudeTranscriptFiles large directories', () => { + it('keeps nested transcript scans past the JavaScript spread-argument limit', async () => { + homedirMock.mockReturnValue(FAKE_HOME) + readdirMock.mockImplementation(async (dirPath) => { + if (dirPath === PROJECTS_ROOT) { + return [dirent('large-project', 'directory')] + } + if (dirPath === PROJECT_DIR) { + return largeTranscriptEntries + } + if (dirPath === TRANSCRIPTS_ROOT) { + return [] + } + throw new Error(`Unexpected readdir path: ${dirPath}`) + }) + + const { listClaudeTranscriptFiles } = await import('./scanner') + + await expect(listClaudeTranscriptFiles()).resolves.toHaveLength(FILE_COUNT) + }) +}) diff --git a/src/main/claude-usage/scanner.ts b/src/main/claude-usage/scanner.ts index 2b250327912..9bfe007a49f 100644 --- a/src/main/claude-usage/scanner.ts +++ b/src/main/claude-usage/scanner.ts @@ -133,7 +133,7 @@ async function walkJsonlFiles(dirPath: string): Promise { for (const entry of entries) { const fullPath = join(dirPath, entry.name) if (entry.isDirectory()) { - files.push(...(await walkJsonlFiles(fullPath))) + appendDiscoveredFiles(files, await walkJsonlFiles(fullPath)) continue } if (entry.isFile() && entry.name.endsWith('.jsonl')) { @@ -144,6 +144,14 @@ async function walkJsonlFiles(dirPath: string): Promise { return files } +function appendDiscoveredFiles(target: string[], source: readonly string[]): void { + // Why: long-lived transcript directories can exceed V8's argument limit if + // child file arrays are spread into push(). + for (const filePath of source) { + target.push(filePath) + } +} + export async function listClaudeTranscriptFiles(): Promise { const roots = [CLAUDE_PROJECTS_DIR, CLAUDE_TRANSCRIPTS_DIR] const files = await Promise.all( @@ -657,14 +665,6 @@ export async function scanClaudeUsageFiles( } } -export function getClaudeProjectsDirectory(): string { - return CLAUDE_PROJECTS_DIR -} - -export function getClaudeTranscriptsDirectory(): string { - return CLAUDE_TRANSCRIPTS_DIR -} - export function createWorktreeRefs( repos: Repo[], worktreesByRepo: Map diff --git a/src/main/claude-usage/store.test.ts b/src/main/claude-usage/store.test.ts index 2baabbd6e98..d29acbb4303 100644 --- a/src/main/claude-usage/store.test.ts +++ b/src/main/claude-usage/store.test.ts @@ -1,3 +1,5 @@ +/* eslint-disable max-lines -- Why: scan gating, pricing, and automation +usage attribution share one stateful Claude usage store fixture. */ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ClaudeUsagePersistedState } from './types' @@ -260,6 +262,7 @@ describe('ClaudeUsageStore', () => { it('returns automation usage for a single matching worktree session', async () => { const worktreeId = 'repo-1::/workspace/repo-a' + const completedAt = Date.parse('2026-04-09T15:06:00.000Z') const store = createStoreWithState({ scanState: { enabled: true, @@ -298,7 +301,7 @@ describe('ClaudeUsageStore', () => { } ] }) - ;(store as unknown as { refresh: typeof store.refresh }).refresh = vi.fn().mockResolvedValue({ + const refreshMock = vi.fn().mockResolvedValue({ enabled: true, isScanning: false, lastScanStartedAt: 1, @@ -306,17 +309,28 @@ describe('ClaudeUsageStore', () => { lastScanError: null, hasAnyClaudeData: true }) - - const usage = await store.getAutomationRunUsage({ + ;(store as unknown as { refresh: typeof store.refresh }).refresh = refreshMock + const request = { worktreeId, terminalSessionId: 'tab-1', - startedAt: new Date('2026-04-09T14:59:00.000Z').getTime(), - completedAt: new Date('2026-04-09T15:06:00.000Z').getTime() - }) + startedAt: completedAt - 7 * 60_000, + completedAt + } + + const usage = await store.getAutomationRunUsage(request) expect(usage.status).toBe('known') expect(usage.providerSessionId).toBe('session-1') expect(usage.totalTokens).toBe(1800) expect(usage.estimatedCostUsd).toBeCloseTo(0.010935) + expect(refreshMock).toHaveBeenCalledWith(true) + + ;( + store as unknown as { state: ClaudeUsagePersistedState } + ).state.scanState.lastScanCompletedAt = completedAt + 1000 + refreshMock.mockClear() + await store.getAutomationRunUsage(request) + + expect(refreshMock).toHaveBeenCalledWith(false) }) }) diff --git a/src/main/claude-usage/store.ts b/src/main/claude-usage/store.ts index 9d3b212989c..d9a410e8033 100644 --- a/src/main/claude-usage/store.ts +++ b/src/main/claude-usage/store.ts @@ -671,7 +671,7 @@ export class ClaudeUsageStore { return unavailable('no_matching_session', 'Run session metadata is incomplete.') } - const scanState = await this.refresh(true) + const scanState = await this.refresh(this.shouldForceAutomationUsageScan(input.completedAt)) if (scanState.lastScanError) { return unavailable('scan_failed', scanState.lastScanError) } @@ -790,6 +790,15 @@ export class ClaudeUsageStore { }) } + private shouldForceAutomationUsageScan(completedAt: number): boolean { + const { lastScanCompletedAt, lastScanError } = this.state.scanState + // Why: attribution needs a scan after the run finishes, but repeated + // lookups after that point should not rescan all Claude transcript history. + return ( + Boolean(lastScanError) || lastScanCompletedAt === null || lastScanCompletedAt < completedAt + ) + } + private async getCurrentWorktreeFingerprint(): Promise { const repos = this.store.getRepos() const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts index 3514e666267..90909cd6468 100644 --- a/src/main/claude/hook-service.test.ts +++ b/src/main/claude/hook-service.test.ts @@ -3,7 +3,7 @@ // or the script body that lands on the remote box. Local install behavior // is exercised through `installer-utils.test.ts` and the per-CLI status // audit; this file covers ONLY the SFTP-backed path added in commit #8. -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' import { vi, describe, expect, it } from 'vitest' @@ -16,6 +16,7 @@ vi.mock('electron', () => ({ import type { SFTPWrapper } from 'ssh2' import { ClaudeHookService } from './hook-service' +import { OPENCLAUDE_HOOK_SETTINGS } from './hook-settings' type FakeFs = { files: Map @@ -162,6 +163,7 @@ describe('ClaudeHookService.install', () => { command.includes('/Users/old/.orca/agent-hooks/claude-hook.sh') ) ).toBe(false) + expect(legacy.hooks.StopFailure[0].hooks[0].command).toContain('claude-hook.sh') } finally { vi.unstubAllEnvs() rmSync(tmpHome, { recursive: true, force: true }) @@ -187,6 +189,7 @@ describe('ClaudeHookService.installRemote', () => { for (const event of [ 'UserPromptSubmit', 'Stop', + 'StopFailure', 'PreToolUse', 'PostToolUse', 'PostToolUseFailure', @@ -246,3 +249,58 @@ describe('ClaudeHookService.installRemote', () => { expect(userCmds.filter((c) => c.includes('claude-hook.sh'))).toHaveLength(1) }) }) + +describe('OpenClaudeHookService-compatible install', () => { + const makeOpenClaudeService = (): ClaudeHookService => + new ClaudeHookService({ + agent: 'openclaude', + displayName: 'OpenClaude', + settings: OPENCLAUDE_HOOK_SETTINGS + }) + + it('installs managed hooks into OpenClaude settings without touching Claude settings', () => { + const tmpHome = mkdtempSync(join(tmpdir(), 'orca-openclaude-hooks-')) + vi.stubEnv('HOME', tmpHome) + try { + const openClaudeSettings = join(tmpHome, '.openclaude', 'settings.json') + mkdirSync(join(tmpHome, '.openclaude'), { recursive: true }) + writeFileSync(openClaudeSettings, JSON.stringify({ hooks: {} })) + + const status = makeOpenClaudeService().install() + + expect(status).toMatchObject({ + agent: 'openclaude', + state: 'installed', + configPath: openClaudeSettings + }) + const parsed = JSON.parse(readFileSync(openClaudeSettings, 'utf-8')) + for (const event of ['UserPromptSubmit', 'Stop', 'StopFailure']) { + const command = parsed.hooks[event][0].hooks[0].command as string + expect(command).toContain('openclaude-hook.sh') + } + expect( + readFileSync(join(tmpHome, '.orca', 'agent-hooks', 'openclaude-hook.sh'), 'utf-8') + ).toContain('/hook/claude') + expect(existsSync(join(tmpHome, '.claude', 'settings.json'))).toBe(false) + } finally { + vi.unstubAllEnvs() + rmSync(tmpHome, { recursive: true, force: true }) + } + }) + + it('writes remote OpenClaude settings under .openclaude', async () => { + const { sftp, fs } = createFakeSftp() + + const status = await makeOpenClaudeService().installRemote(sftp, '/home/dev') + + expect(status).toMatchObject({ + agent: 'openclaude', + state: 'installed', + configPath: '/home/dev/.openclaude/settings.json' + }) + const parsed = JSON.parse(fs.files.get('/home/dev/.openclaude/settings.json')!) + const command = parsed.hooks.StopFailure[0].hooks[0].command as string + expect(command).toContain('/home/dev/.orca/agent-hooks/openclaude-hook.sh') + expect(fs.files.get('/home/dev/.orca/agent-hooks/openclaude-hook.sh')).toContain('/hook/claude') + }) +}) diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index 4fa638b6968..7eb208a9951 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -14,14 +14,30 @@ import { import { applyManagedHooks, CLAUDE_EVENTS, + CLAUDE_HOOK_SETTINGS, + getManagedScriptFileName, getConfigPath, getManagedCommand, getManagedScriptPath, + getPosixManagedScriptFileName, getRemoteConfigPath, getRemoteManagedCommand, - removeManagedHooks + removeManagedHooks, + type ClaudeCompatibleHookSettings } from './hook-settings' +type ClaudeHookServiceOptions = { + agent: AgentHookInstallStatus['agent'] + displayName: string + settings: ClaudeCompatibleHookSettings +} + +const DEFAULT_CLAUDE_HOOK_SERVICE_OPTIONS: ClaudeHookServiceOptions = { + agent: 'claude', + displayName: 'Claude', + settings: CLAUDE_HOOK_SETTINGS +} + function getManagedScript(target: 'local' | 'posix' = 'local'): string { if (target === 'local' && process.platform === 'win32') { return [ @@ -72,7 +88,9 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { // Why: worktreeId embeds a filesystem path, so hand-building JSON in POSIX // shell is not safe once a path contains quotes or newlines. Post the raw // hook payload plus metadata as form fields and let the receiver parse it. + // Timeout caps best-effort hook posts if the local listener stalls. 'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/claude" \\', + ' --connect-timeout 0.5 --max-time 1.5 \\', ' -H "Content-Type: application/x-www-form-urlencoded" \\', ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', @@ -87,17 +105,23 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { } export class ClaudeHookService { + private readonly options: ClaudeHookServiceOptions + + constructor(options: ClaudeHookServiceOptions = DEFAULT_CLAUDE_HOOK_SERVICE_OPTIONS) { + this.options = options + } + getStatus(): AgentHookInstallStatus { - const configPath = getConfigPath() - const scriptPath = getManagedScriptPath() + const configPath = getConfigPath(this.options.settings) + const scriptPath = getManagedScriptPath(this.options.settings) const config = readHooksJson(configPath) if (!config) { return { - agent: 'claude', + agent: this.options.agent, state: 'error', configPath, managedHooksPresent: false, - detail: 'Could not parse Claude settings.json' + detail: `Could not parse ${this.options.displayName} settings.json` } } @@ -134,25 +158,29 @@ export class ClaudeHookService { state = 'partial' detail = `Managed hook missing for events: ${missing.join(', ')}` } - return { agent: 'claude', state, configPath, managedHooksPresent, detail } + return { agent: this.options.agent, state, configPath, managedHooksPresent, detail } } install(): AgentHookInstallStatus { - const configPath = getConfigPath() - const scriptPath = getManagedScriptPath() + const configPath = getConfigPath(this.options.settings) + const scriptPath = getManagedScriptPath(this.options.settings) const config = readHooksJson(configPath) if (!config) { return { - agent: 'claude', + agent: this.options.agent, state: 'error', configPath, managedHooksPresent: false, - detail: 'Could not parse Claude settings.json' + detail: `Could not parse ${this.options.displayName} settings.json` } } const command = getManagedCommand(scriptPath) - const nextConfig = applyManagedHooks(config, command) + const nextConfig = applyManagedHooks( + config, + command, + getManagedScriptFileName(this.options.settings) + ) writeManagedScript(scriptPath, getManagedScript()) writeHooksJson(configPath, nextConfig) return this.getStatus() @@ -166,8 +194,9 @@ export class ClaudeHookService { // and a `.sh` managed script body. The remote platform is gated by the // relay's capability RPC at a higher layer; we cannot detect it from // `process.platform` here (that's the local box). - const remoteConfigPath = getRemoteConfigPath(remoteHome) - const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/claude-hook.sh` + const remoteConfigPath = getRemoteConfigPath(remoteHome, this.options.settings) + const remoteScriptFileName = getPosixManagedScriptFileName(this.options.settings) + const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/${remoteScriptFileName}` // Why: SFTP reads/writes fail far more often than local fs (network drops, // EACCES on remote dirs, disk full, channel closed). Wrap the entire // install flow in try/catch so a transient I/O failure surfaces as a @@ -179,18 +208,18 @@ export class ClaudeHookService { const config = await readHooksJsonRemote(sftp, remoteConfigPath) if (!config) { return { - agent: 'claude', + agent: this.options.agent, state: 'error', configPath: remoteConfigPath, managedHooksPresent: false, - detail: 'Could not parse remote Claude settings.json' + detail: `Could not parse remote ${this.options.displayName} settings.json` } } // Why: the POSIX wrapper is identical regardless of where the script // lands; only the path differs. Reuse the same wrapper helper. const command = getRemoteManagedCommand(remoteScriptPath) - const nextConfig = applyManagedHooks(config, command, 'claude-hook.sh') + const nextConfig = applyManagedHooks(config, command, remoteScriptFileName) // Why: write the script first, then the settings — settings.json // referencing a missing script body would fire `command not found` on @@ -204,7 +233,7 @@ export class ClaudeHookService { await writeHooksJsonRemote(sftp, remoteConfigPath, nextConfig) return { - agent: 'claude', + agent: this.options.agent, state: 'installed', configPath: remoteConfigPath, managedHooksPresent: true, @@ -212,7 +241,7 @@ export class ClaudeHookService { } } catch (err) { return { - agent: 'claude', + agent: this.options.agent, state: 'error', configPath: remoteConfigPath, managedHooksPresent: false, @@ -222,18 +251,21 @@ export class ClaudeHookService { } remove(): AgentHookInstallStatus { - const configPath = getConfigPath() + const configPath = getConfigPath(this.options.settings) const config = readHooksJson(configPath) if (!config) { return { - agent: 'claude', + agent: this.options.agent, state: 'error', configPath, managedHooksPresent: false, - detail: 'Could not parse Claude settings.json' + detail: `Could not parse ${this.options.displayName} settings.json` } } - const { config: nextConfig, changed } = removeManagedHooks(config) + const { config: nextConfig, changed } = removeManagedHooks( + config, + getManagedScriptFileName(this.options.settings) + ) if (changed) { writeHooksJson(configPath, nextConfig) } diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts index e4e3bc07662..f6a7a2a0f73 100644 --- a/src/main/claude/hook-settings.ts +++ b/src/main/claude/hook-settings.ts @@ -9,9 +9,27 @@ import { type HooksConfig } from '../agent-hooks/installer-utils' +export type ClaudeCompatibleHookSettings = { + configDirName: '.claude' | '.openclaude' + scriptBaseName: 'claude-hook' | 'openclaude-hook' +} + +export const CLAUDE_HOOK_SETTINGS: ClaudeCompatibleHookSettings = { + configDirName: '.claude', + scriptBaseName: 'claude-hook' +} + +export const OPENCLAUDE_HOOK_SETTINGS: ClaudeCompatibleHookSettings = { + configDirName: '.openclaude', + scriptBaseName: 'openclaude-hook' +} + export const CLAUDE_EVENTS = [ { eventName: 'UserPromptSubmit', definition: { hooks: [{ type: 'command', command: '' }] } }, { eventName: 'Stop', definition: { hooks: [{ type: 'command', command: '' }] } }, + // Why: OpenClaude skips normal Stop hooks after API/model errors and emits + // StopFailure instead; without this hook Orca leaves the turn spinning. + { eventName: 'StopFailure', definition: { hooks: [{ type: 'command', command: '' }] } }, // Why: PreToolUse gives the dashboard a live readout of the in-flight tool // (name + input preview) before it completes. { @@ -32,20 +50,26 @@ export const CLAUDE_EVENTS = [ } ] as const -export function getConfigPath(): string { - return join(homedir(), '.claude', 'settings.json') +export function getConfigPath(settings = CLAUDE_HOOK_SETTINGS): string { + return join(homedir(), settings.configDirName, 'settings.json') } -export function getManagedScriptFileName(): string { - return process.platform === 'win32' ? 'claude-hook.cmd' : 'claude-hook.sh' +export function getManagedScriptFileName(settings = CLAUDE_HOOK_SETTINGS): string { + return process.platform === 'win32' + ? `${settings.scriptBaseName}.cmd` + : getPosixManagedScriptFileName(settings) } -export function getManagedScriptPath(): string { - return getSharedManagedScriptPath(getManagedScriptFileName()) +export function getPosixManagedScriptFileName(settings = CLAUDE_HOOK_SETTINGS): string { + return `${settings.scriptBaseName}.sh` } -export function getRemoteConfigPath(remoteHome: string): string { - return `${remoteHome.replace(/\/$/, '')}/.claude/settings.json` +export function getManagedScriptPath(settings = CLAUDE_HOOK_SETTINGS): string { + return getSharedManagedScriptPath(getManagedScriptFileName(settings)) +} + +export function getRemoteConfigPath(remoteHome: string, settings = CLAUDE_HOOK_SETTINGS): string { + return `${remoteHome.replace(/\/$/, '')}/${settings.configDirName}/settings.json` } export function getManagedCommand(scriptPath: string): string { diff --git a/src/main/cli/appimage-cli-wrapper.ts b/src/main/cli/appimage-cli-wrapper.ts new file mode 100644 index 00000000000..b8012581147 --- /dev/null +++ b/src/main/cli/appimage-cli-wrapper.ts @@ -0,0 +1,37 @@ +const APPIMAGE_CLI_SCRIPT = [ + '(async()=>{', + 'try{', + 'const path=require("path");', + 'const appDir=process.env.APPDIR;', + 'if(!appDir){console.error("Orca AppImage runtime did not set APPDIR.");process.exit(1);}', + 'const cli=path.join(appDir,"resources","app.asar.unpacked","out","cli","index.js");', + 'await Promise.resolve(require(cli).main(process.argv.slice(1)));', + '}catch(error){', + 'console.error(error&&error.stack?error.stack:String(error));process.exit(1);', + '}', + '})();' +].join('') + +export function buildAppImageCliWrapper(appImagePath: string): string { + // Why: AppImage mounts resources under a fresh FUSE path per launch, so the + // installed command must call the stable outer AppImage and resolve APPDIR. + return `#!/usr/bin/env bash +set -euo pipefail +APPIMAGE=${quoteShell(appImagePath)} +if [ ! -f "$APPIMAGE" ]; then + echo "Orca AppImage not found at $APPIMAGE" >&2 + echo "If you moved the AppImage, re-run CLI registration from Orca Settings." >&2 + exit 1 +fi +export ORCA_NODE_OPTIONS="\${NODE_OPTIONS-}" +export ORCA_NODE_REPL_EXTERNAL_MODULE="\${NODE_REPL_EXTERNAL_MODULE-}" +unset NODE_OPTIONS +unset NODE_REPL_EXTERNAL_MODULE +# Why: AppImage mount paths change on each launch; $APPDIR is the runtime mount. +ELECTRON_RUN_AS_NODE=1 exec "$APPIMAGE" -e ${quoteShell(APPIMAGE_CLI_SCRIPT)} -- "$@" +` +} + +function quoteShell(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'` +} diff --git a/src/main/cli/cli-installer.test.ts b/src/main/cli/cli-installer.test.ts index 43f525b3f94..9f642eeda45 100644 --- a/src/main/cli/cli-installer.test.ts +++ b/src/main/cli/cli-installer.test.ts @@ -1,7 +1,19 @@ -import { lstat, mkdtemp, mkdir, readFile, symlink, writeFile } from 'node:fs/promises' +/* eslint-disable max-lines -- Why: cli-installer covers darwin/linux/win32 install, remove, fallback, and privileged-runner paths; each platform combination requires its own fixture and assertions to catch regressions. */ +import { + chmod, + lstat, + mkdtemp, + mkdir, + readFile, + readlink, + symlink, + writeFile +} from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const execFileMock = vi.hoisted(() => vi.fn()) vi.mock('electron', () => ({ app: { @@ -11,7 +23,12 @@ vi.mock('electron', () => ({ } })) +vi.mock('node:child_process', () => ({ + execFile: execFileMock +})) + import { CliInstaller } from './cli-installer' +import { buildAppImageCliWrapper } from './appimage-cli-wrapper' async function makeFixture(): Promise<{ root: string @@ -27,8 +44,23 @@ async function makeFixture(): Promise<{ return { root, userDataPath, appPath } } +async function createPackagedMacLauncher(root: string): Promise { + const resourcesPath = join(root, 'resources') + await mkdir(join(resourcesPath, 'bin'), { recursive: true }) + await writeFile(join(resourcesPath, 'bin', 'orca'), '#!/usr/bin/env bash\necho orca\n', { + encoding: 'utf8', + mode: 0o755 + }) + return resourcesPath +} + describe('CliInstaller', () => { + beforeEach(() => { + execFileMock.mockReset() + }) + afterEach(() => { + vi.useRealTimers() vi.restoreAllMocks() }) @@ -58,6 +90,8 @@ describe('CliInstaller', () => { const launcherContent = await readFile(installed.launcherPath as string, 'utf8') expect(launcherContent).toContain('ELECTRON_RUN_AS_NODE=1') + expect(launcherContent).toContain(`export ORCA_USER_DATA_PATH='${fixture.userDataPath}'`) + expect(launcherContent).toContain('export ORCA_APP_EXECUTABLE="$ELECTRON"') expect(launcherContent).toContain(join(fixture.appPath, 'out', 'cli', 'index.js')) const removed = await installer.remove() @@ -89,12 +123,131 @@ describe('CliInstaller', () => { const launcherContent = await readFile(installed.launcherPath as string, 'utf8') expect(launcherContent).toContain('ELECTRON_RUN_AS_NODE=1') + expect(launcherContent).toContain(`export ORCA_USER_DATA_PATH='${fixture.userDataPath}'`) const removed = await installer.remove() expect(removed.state).toBe('not_installed') } ) + // Why: dev installs are useful for validation, but they must not replace the + // packaged `orca` / `orca-ide` commands developers rely on day to day. + it.skipIf(process.platform === 'win32')( + 'uses a separate orca-dev command for default development installs', + async () => { + const fixture = await makeFixture() + const homePath = join(fixture.root, 'home') + const commandDir = join(homePath, '.local', 'bin') + const installer = new CliInstaller({ + platform: 'linux', + isPackaged: false, + userDataPath: fixture.userDataPath, + execPath: '/opt/Orca/orca-ide', + appPath: fixture.appPath, + homePath, + processPathEnv: commandDir + }) + + const installed = await installer.install() + expect(installed.state).toBe('installed') + expect(installed.commandName).toBe('orca-dev') + expect(installed.commandPath).toBe(join(commandDir, 'orca-dev')) + expect(installed.launcherPath).toBe(join(fixture.userDataPath, 'cli', 'bin', 'orca-dev')) + await expect(readlink(installed.commandPath as string)).resolves.toBe(installed.launcherPath) + } + ) + + // Why: AppImage resources live under a per-launch FUSE mount, so the + // installed shell command must be a stable wrapper rather than a symlink. + it.skipIf(process.platform === 'win32')( + 'creates an AppImage wrapper under the linux command path', + async () => { + const fixture = await makeFixture() + const commandDir = join(fixture.root, '.local', 'bin') + const installPath = join(commandDir, 'orca-ide') + const appImagePath = join(fixture.root, 'Orca.AppImage') + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { + encoding: 'utf8', + mode: 0o755 + }) + + const installer = new CliInstaller({ + platform: 'linux', + isPackaged: true, + appImagePath, + commandPathOverride: installPath, + processPathEnv: commandDir + }) + + const initial = await installer.getStatus() + expect(initial).toMatchObject({ + state: 'not_installed', + installMethod: 'wrapper', + launcherPath: appImagePath + }) + + const installed = await installer.install() + expect(installed).toMatchObject({ + state: 'installed', + commandName: 'orca-ide', + installMethod: 'wrapper', + launcherPath: appImagePath, + currentTarget: appImagePath, + pathConfigured: true + }) + + const commandStats = await lstat(installPath) + expect(commandStats.isFile()).toBe(true) + expect(commandStats.mode & 0o111).not.toBe(0) + await expect(readlink(installPath)).rejects.toMatchObject({ code: 'EINVAL' }) + await expect(readFile(installPath, 'utf8')).resolves.toBe( + buildAppImageCliWrapper(appImagePath) + ) + + const removed = await installer.remove() + expect(removed.state).toBe('not_installed') + } + ) + + it.skipIf(process.platform === 'win32')( + 'reports a stale AppImage wrapper when the AppImage path changes', + async () => { + const fixture = await makeFixture() + const commandDir = join(fixture.root, '.local', 'bin') + const installPath = join(commandDir, 'orca-ide') + const oldAppImagePath = join(fixture.root, 'Old-Orca.AppImage') + const newAppImagePath = join(fixture.root, 'Orca.AppImage') + await mkdir(commandDir, { recursive: true }) + await writeFile(installPath, buildAppImageCliWrapper(oldAppImagePath), { + encoding: 'utf8', + mode: 0o755 + }) + await writeFile(newAppImagePath, '#!/usr/bin/env bash\n', { + encoding: 'utf8', + mode: 0o755 + }) + + const installer = new CliInstaller({ + platform: 'linux', + isPackaged: true, + appImagePath: newAppImagePath, + commandPathOverride: installPath, + processPathEnv: commandDir + }) + + await expect(installer.getStatus()).resolves.toMatchObject({ + state: 'stale', + installMethod: 'wrapper', + currentTarget: newAppImagePath + }) + + await expect(installer.install()).resolves.toMatchObject({ state: 'installed' }) + await expect(readFile(installPath, 'utf8')).resolves.toBe( + buildAppImageCliWrapper(newAppImagePath) + ) + } + ) + // Why: Linux renamed the public command to avoid shadowing GNOME Orca, so // upgrading must clean up only the old symlink owned by prior Orca installs. it.skipIf(process.platform === 'win32')( @@ -103,19 +256,49 @@ describe('CliInstaller', () => { const fixture = await makeFixture() const homePath = join(fixture.root, 'home') const commandDir = join(homePath, '.local', 'bin') - const oldLauncherPath = join(fixture.userDataPath, 'cli', 'bin', 'orca') + const resourcesPath = join(fixture.root, 'resources') + const launcherPath = join(resourcesPath, 'bin', 'orca-ide') + const oldLauncherPath = join(resourcesPath, 'bin', 'orca') const legacyCommandPath = join(commandDir, 'orca') await mkdir(commandDir, { recursive: true }) - await mkdir(join(fixture.userDataPath, 'cli', 'bin'), { recursive: true }) + await mkdir(join(resourcesPath, 'bin'), { recursive: true }) + await writeFile(launcherPath, '#!/usr/bin/env bash\n', 'utf8') await writeFile(oldLauncherPath, '#!/usr/bin/env bash\n', 'utf8') await symlink(oldLauncherPath, legacyCommandPath) const installer = new CliInstaller({ platform: 'linux', - isPackaged: false, - userDataPath: fixture.userDataPath, - execPath: '/opt/Orca/orca-ide', - appPath: fixture.appPath, + isPackaged: true, + resourcesPath, + homePath, + processPathEnv: commandDir + }) + + const installed = await installer.install() + expect(installed.commandPath).toBe(join(commandDir, 'orca-ide')) + await expect(lstat(legacyCommandPath)).rejects.toMatchObject({ code: 'ENOENT' }) + } + ) + + it.skipIf(process.platform === 'win32')( + 'removes a legacy linux orca symlink when installing an AppImage wrapper', + async () => { + const fixture = await makeFixture() + const homePath = join(fixture.root, 'home') + const commandDir = join(homePath, '.local', 'bin') + const legacyCommandPath = join(commandDir, 'orca') + const appImagePath = join(fixture.root, 'Orca.AppImage') + await mkdir(commandDir, { recursive: true }) + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { + encoding: 'utf8', + mode: 0o755 + }) + await symlink(join('/tmp', '.mount_Orca1234', 'resources', 'bin', 'orca'), legacyCommandPath) + + const installer = new CliInstaller({ + platform: 'linux', + isPackaged: true, + appImagePath, homePath, processPathEnv: commandDir }) @@ -151,20 +334,56 @@ describe('CliInstaller', () => { const wrapperContent = await readFile(installPath, 'utf8') expect(wrapperContent).toContain('ORCA_LAUNCHER=') expect(wrapperContent).toContain('orca.cmd') + const launcherContent = await readFile(installed.launcherPath as string, 'utf8') + expect(launcherContent).toContain(`set "ORCA_USER_DATA_PATH=${fixture.userDataPath}"`) + expect(launcherContent).toContain('set "ORCA_APP_EXECUTABLE=%ELECTRON%"') const removed = await installer.remove() expect(removed.state).toBe('not_installed') expect(userPath).not.toContain(join(fixture.root, 'Programs', 'Orca', 'bin')) }) + it('settles when the Windows PATH query hangs', async () => { + vi.useFakeTimers() + const fixture = await makeFixture() + const installPath = join(fixture.root, 'Programs', 'Orca', 'bin', 'orca.cmd') + const killMock = vi.fn() + execFileMock.mockImplementation(() => ({ kill: killMock })) + const installer = new CliInstaller({ + platform: 'win32', + isPackaged: false, + userDataPath: fixture.userDataPath, + execPath: 'C:\\Users\\me\\AppData\\Local\\Orca\\Orca.exe', + appPath: fixture.appPath, + commandPathOverride: installPath + }) + + const promise = installer.getStatus() + let settled = false + void promise + .catch(() => undefined) + .finally(() => { + settled = true + }) + + await vi.waitFor(() => expect(execFileMock).toHaveBeenCalled()) + await vi.advanceTimersByTimeAsync(5_000) + await Promise.resolve() + + expect(settled).toBe(true) + await expect(promise).rejects.toThrow('Windows PATH command timed out') + expect(killMock).toHaveBeenCalled() + }) + // Why: this test creates a Unix symlink to /tmp/not-orca, which only applies on macOS/Linux. it.skipIf(process.platform === 'win32')( - 'reports stale when a different symlink already exists', + 'refuses to replace an unknown symlink at the command path', async () => { const fixture = await makeFixture() const installPath = join(fixture.root, 'bin', 'orca') + const existingTarget = '/tmp/not-orca' await mkdir(join(fixture.root, 'bin'), { recursive: true }) - await symlink('/tmp/not-orca', installPath) + await symlink(existingTarget, installPath) const installer = new CliInstaller({ platform: 'darwin', @@ -176,9 +395,349 @@ describe('CliInstaller', () => { }) await expect(installer.getStatus()).resolves.toMatchObject({ - state: 'stale', + state: 'conflict', supported: true }) + await expect(installer.install()).rejects.toThrow('Refusing to replace non-Orca command') + await expect(readlink(installPath)).resolves.toBe(existingTarget) + } + ) + + // Why: packaged app moves can leave a symlink to an older Orca-owned launcher; + // those are safe to refresh, unlike arbitrary user symlinks. + it.skipIf(process.platform === 'win32')( + 'replaces stale packaged Orca launcher symlinks', + async () => { + const fixture = await makeFixture() + const commandDir = join(fixture.root, 'bin') + const installPath = join(commandDir, 'orca') + const resourcesPath = join(fixture.root, 'Current.app', 'Contents', 'Resources') + const launcherPath = join(resourcesPath, 'bin', 'orca') + const oldLauncherPath = join(fixture.root, 'Old.app', 'Contents', 'Resources', 'bin', 'orca') + await mkdir(commandDir, { recursive: true }) + await mkdir(join(resourcesPath, 'bin'), { recursive: true }) + await writeFile(launcherPath, '#!/usr/bin/env bash\n', 'utf8') + await symlink(oldLauncherPath, installPath) + + const installer = new CliInstaller({ + platform: 'darwin', + isPackaged: true, + resourcesPath, + commandPathOverride: installPath, + processPathEnv: commandDir + }) + + await expect(installer.getStatus()).resolves.toMatchObject({ + state: 'stale', + currentTarget: oldLauncherPath + }) + await expect(installer.install()).resolves.toMatchObject({ state: 'installed' }) + await expect(readlink(installPath)).resolves.toBe(launcherPath) + } + ) + + // Why: a dev build can temporarily own the public command on developer + // machines; packaged Orca should treat that as stale, not a hard conflict. + it.skipIf(process.platform === 'win32')( + 'replaces stale sibling dev launcher symlinks from packaged installs', + async () => { + const fixture = await makeFixture() + for (const devLauncherName of ['orca', 'orca-dev']) { + const caseRoot = join(fixture.root, devLauncherName) + const commandDir = join(caseRoot, 'bin') + const installPath = join(commandDir, 'orca') + const userDataPath = join(caseRoot, 'orca') + const resourcesPath = join(caseRoot, 'Current.app', 'Contents', 'Resources') + const launcherPath = join(resourcesPath, 'bin', 'orca') + const devLauncherPath = join(`${userDataPath}-dev`, 'cli', 'bin', devLauncherName) + await mkdir(commandDir, { recursive: true }) + await mkdir(join(resourcesPath, 'bin'), { recursive: true }) + await mkdir(join(`${userDataPath}-dev`, 'cli', 'bin'), { recursive: true }) + await writeFile(launcherPath, '#!/usr/bin/env bash\n', 'utf8') + await writeFile(devLauncherPath, '#!/usr/bin/env bash\n', 'utf8') + await symlink(devLauncherPath, installPath) + + const installer = new CliInstaller({ + platform: 'darwin', + isPackaged: true, + userDataPath, + resourcesPath, + commandPathOverride: installPath, + processPathEnv: commandDir + }) + + await expect(installer.getStatus()).resolves.toMatchObject({ + state: 'stale', + currentTarget: devLauncherPath + }) + await expect(installer.install()).resolves.toMatchObject({ state: 'installed' }) + await expect(readlink(installPath)).resolves.toBe(launcherPath) + } + } + ) + + // Why: on Apple Silicon, /usr/local/bin does not exist by default. The installer + // must fall back to ~/.local/bin (user-writable, no sudo) rather than failing + // silently when the parent directory is absent. + it.skipIf(process.platform === 'win32')( + 'falls back to ~/.local/bin/orca on macOS when /usr/local/bin does not exist', + async () => { + const fixture = await makeFixture() + const homePath = join(fixture.root, 'home') + const resourcesPath = await createPackagedMacLauncher(fixture.root) + // Simulate arm64: point defaultMacCommandPath at a dir that does not exist + // in the fixture so existsSync(dirname(...)) returns false. + const absentUsrLocalBin = join(fixture.root, 'usr', 'local', 'bin', 'orca') + const installer = new CliInstaller({ + platform: 'darwin', + isPackaged: true, + resourcesPath, + userDataPath: fixture.userDataPath, + execPath: '/Applications/Orca.app/Contents/MacOS/Orca', + appPath: fixture.appPath, + homePath, + defaultMacCommandPath: absentUsrLocalBin, + processPathEnv: join(homePath, '.local', 'bin') + }) + + const status = await installer.getStatus() + expect(status.commandPath).toBe(join(homePath, '.local', 'bin', 'orca')) + expect(status.state).toBe('not_installed') + expect(status.supported).toBe(true) + + const installed = await installer.install() + expect(installed.state).toBe('installed') + expect(installed.commandPath).toBe(join(homePath, '.local', 'bin', 'orca')) + expect(installed.pathConfigured).toBe(true) + } + ) + + // Why: on Intel Macs /usr/local/bin exists, so the installer must keep using + // it as the canonical path and not regress to ~/.local/bin. + it.skipIf(process.platform === 'win32')( + 'uses /usr/local/bin/orca on macOS when /usr/local/bin exists', + async () => { + const fixture = await makeFixture() + const resourcesPath = await createPackagedMacLauncher(fixture.root) + const usrLocalBin = join(fixture.root, 'usr', 'local', 'bin') + await mkdir(usrLocalBin, { recursive: true }) + + const installPath = join(usrLocalBin, 'orca') + const installer = new CliInstaller({ + platform: 'darwin', + isPackaged: true, + resourcesPath, + userDataPath: fixture.userDataPath, + execPath: '/Applications/Orca.app/Contents/MacOS/Orca', + appPath: fixture.appPath, + defaultMacCommandPath: installPath, + processPathEnv: usrLocalBin + }) + + const installed = await installer.install() + expect(installed.state).toBe('installed') + expect(installed.commandPath).toBe(installPath) + expect(installed.pathConfigured).toBe(true) + } + ) + + // Why: when macCommandPath falls back to ~/.local/bin/orca on arm64, commandName + // must still be 'orca' (not 'orca-ide' which is Linux-only). + it.skipIf(process.platform === 'win32')( + 'reports commandName as orca (not orca-ide) when falling back to ~/.local/bin on macOS', + async () => { + const fixture = await makeFixture() + const homePath = join(fixture.root, 'home') + const resourcesPath = await createPackagedMacLauncher(fixture.root) + const absentUsrLocalBin = join(fixture.root, 'usr', 'local', 'bin', 'orca') + const installer = new CliInstaller({ + platform: 'darwin', + isPackaged: true, + resourcesPath, + userDataPath: fixture.userDataPath, + execPath: '/Applications/Orca.app/Contents/MacOS/Orca', + appPath: fixture.appPath, + homePath, + defaultMacCommandPath: absentUsrLocalBin, + processPathEnv: join(homePath, '.local', 'bin') + }) + + const status = await installer.getStatus() + expect(status.commandName).toBe('orca') + } + ) + + // Why: the privilegedRunner is injectable so the EACCES→osascript path can be + // exercised in integration without spawning osascript in unit tests. + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'invokes the injected privilegedRunner when install falls back to elevated permissions', + async () => { + const fixture = await makeFixture() + const protectedDir = join(fixture.root, 'protected') + await mkdir(protectedDir) + await chmod(protectedDir, 0o500) + + const installPath = join(protectedDir, 'bin', 'orca') + const privilegedCommands: string[] = [] + const installer = new CliInstaller({ + platform: 'darwin', + isPackaged: false, + userDataPath: fixture.userDataPath, + execPath: '/Applications/Orca.app/Contents/MacOS/Orca', + appPath: fixture.appPath, + commandPathOverride: installPath, + privilegedRunner: async (command: string) => { + privilegedCommands.push(command) + await chmod(protectedDir, 0o700) + const launcherPath = (await installer.getStatus()).launcherPath as string + await mkdir(dirname(installPath), { recursive: true }) + await symlink(launcherPath, installPath) + }, + processPathEnv: dirname(installPath) + }) + + try { + const installed = await installer.install() + + expect(installed.state).toBe('installed') + expect(installed.pathConfigured).toBe(true) + expect(privilegedCommands).toHaveLength(1) + expect(privilegedCommands[0]).toContain('mkdir -p') + expect(privilegedCommands[0]).toContain('ln -sfn') + await expect(readlink(installPath)).resolves.toBe(installed.launcherPath) + } finally { + await chmod(protectedDir, 0o700).catch(() => undefined) + } + } + ) + + // Why: macCommandPath is resolved at construction — getStatus() must return the + // same commandPath on repeated calls without re-running existsSync. + it.skipIf(process.platform === 'win32')( + 'resolves macCommandPath once at construction — commandPath stable across repeated getStatus()', + async () => { + const fixture = await makeFixture() + const homePath = join(fixture.root, 'home') + const resourcesPath = await createPackagedMacLauncher(fixture.root) + const absentUsrLocalBin = join(fixture.root, 'usr', 'local', 'bin', 'orca') + const installer = new CliInstaller({ + platform: 'darwin', + isPackaged: true, + resourcesPath, + userDataPath: fixture.userDataPath, + execPath: '/Applications/Orca.app/Contents/MacOS/Orca', + appPath: fixture.appPath, + homePath, + defaultMacCommandPath: absentUsrLocalBin, + processPathEnv: join(homePath, '.local', 'bin') + }) + + const s1 = await installer.getStatus() + await mkdir(dirname(absentUsrLocalBin), { recursive: true }) + const s2 = await installer.getStatus() + const s3 = await installer.getStatus() + + expect(s1.commandPath).toBe(s2.commandPath) + expect(s2.commandPath).toBe(s3.commandPath) + expect(s1.commandPath).toBe(join(homePath, '.local', 'bin', 'orca')) + } + ) + + it('resolves packaged Windows command path to resources/bin/orca.cmd', async () => { + const fixture = await makeFixture() + const localAppDataPath = fixture.root + const resourcesPath = join(fixture.root, 'resources') + await mkdir(join(resourcesPath, 'bin'), { recursive: true }) + await writeFile(join(resourcesPath, 'bin', 'orca.cmd'), '@echo off\n', 'utf8') + + const installer = new CliInstaller({ + platform: 'win32', + isPackaged: true, + resourcesPath, + localAppDataPath, + userDataPath: fixture.userDataPath, + execPath: join(localAppDataPath, 'Programs', 'Orca', 'Orca.exe'), + appPath: fixture.appPath, + userPathReader: async () => null, + userPathWriter: async () => {} + }) + + const status = await installer.getStatus() + expect(status.commandPath).toBe( + join(localAppDataPath, 'Programs', 'Orca', 'resources', 'bin', 'orca.cmd') + ) + }) + + it('does not overwrite the packaged Windows launcher while registering PATH', async () => { + const fixture = await makeFixture() + const localAppDataPath = fixture.root + const resourcesPath = join(localAppDataPath, 'Programs', 'Orca', 'resources') + const bundledLauncher = join(resourcesPath, 'bin', 'orca.cmd') + const bundledContent = '@echo off\r\necho bundled-orca %*\r\n' + await mkdir(dirname(bundledLauncher), { recursive: true }) + await writeFile(bundledLauncher, bundledContent, 'utf8') + + let userPath: string | null = null + const installer = new CliInstaller({ + platform: 'win32', + isPackaged: true, + resourcesPath, + localAppDataPath, + userDataPath: fixture.userDataPath, + execPath: join(localAppDataPath, 'Programs', 'Orca', 'Orca.exe'), + appPath: fixture.appPath, + userPathReader: async () => userPath, + userPathWriter: async (value) => { + userPath = value + } + }) + + const installed = await installer.install() + + expect(installed.state).toBe('installed') + expect(installed.pathConfigured).toBe(true) + expect(installed.commandPath).toBe(bundledLauncher) + expect(userPath).toBe(dirname(bundledLauncher)) + await expect(readFile(bundledLauncher, 'utf8')).resolves.toBe(bundledContent) + + const removed = await installer.remove() + + expect(removed.state).toBe('not_installed') + expect(removed.pathConfigured).toBe(false) + expect(userPath).toBe('') + await expect(readFile(bundledLauncher, 'utf8')).resolves.toBe(bundledContent) + }) + + // Why: the arm64 fallback must apply for packaged builds, not just dev launchers. + it.skipIf(process.platform === 'win32')( + 'resolves to ~/.local/bin/orca on arm64 even when isPackaged is true', + async () => { + const fixture = await makeFixture() + const homePath = join(fixture.root, 'home') + const absentUsrLocalBin = join(fixture.root, 'usr', 'local', 'bin', 'orca') + const resourcesPath = join(fixture.root, 'resources') + const bundledLauncher = join(resourcesPath, 'bin', 'orca') + await mkdir(join(resourcesPath, 'bin'), { recursive: true }) + await writeFile(bundledLauncher, '#!/usr/bin/env bash\necho orca\n', { + encoding: 'utf8', + mode: 0o755 + }) + + const installer = new CliInstaller({ + platform: 'darwin', + isPackaged: true, + resourcesPath, + userDataPath: fixture.userDataPath, + execPath: '/Applications/Orca.app/Contents/MacOS/Orca', + appPath: fixture.appPath, + homePath, + defaultMacCommandPath: absentUsrLocalBin, + processPathEnv: join(homePath, '.local', 'bin') + }) + + const status = await installer.getStatus() + expect(status.commandPath).toBe(join(homePath, '.local', 'bin', 'orca')) + expect(status.supported).toBe(true) } ) }) diff --git a/src/main/cli/cli-installer.ts b/src/main/cli/cli-installer.ts index 054517b3742..043566db1f8 100644 --- a/src/main/cli/cli-installer.ts +++ b/src/main/cli/cli-installer.ts @@ -4,15 +4,18 @@ import { execFile } from 'node:child_process' import { existsSync } from 'node:fs' import { lstat, mkdir, readFile, readlink, symlink, unlink, writeFile } from 'node:fs/promises' import { homedir } from 'node:os' -import { dirname, isAbsolute, join, resolve } from 'node:path' +import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path' import { promisify } from 'node:util' import type { CliInstallMethod, CliInstallStatus } from '../../shared/cli-install-types' +import { buildAppImageCliWrapper } from './appimage-cli-wrapper' const execFileAsync = promisify(execFile) const DEFAULT_MAC_COMMAND_PATH = '/usr/local/bin/orca' +const DEV_COMMAND_NAME = 'orca-dev' const LINUX_COMMAND_NAME = 'orca-ide' const LEGACY_LINUX_COMMAND_NAME = 'orca' const DEV_LAUNCHER_DIR = ['cli', 'bin'] +const WINDOWS_PATH_COMMAND_TIMEOUT_MS = 5_000 type CliInstallerOptions = { platform?: NodeJS.Platform @@ -25,9 +28,13 @@ type CliInstallerOptions = { localAppDataPath?: string processPathEnv?: string | null commandPathOverride?: string | null + /** Feeds into the /usr/local/bin existence check at construction time; used in tests to simulate absent /usr/local/bin on arm64 without relying on real filesystem state. */ + defaultMacCommandPath?: string privilegedRunner?: (command: string) => Promise userPathReader?: () => Promise userPathWriter?: (value: string) => Promise + /** Why: AppImage reports a stable outer file path via $APPIMAGE while bundled resources live in an ephemeral FUSE mount. */ + appImagePath?: string | null } type InstallSpec = { @@ -46,12 +53,18 @@ export class CliInstaller { private readonly localAppDataPath: string private readonly processPathEnv: string | null private readonly commandPathOverride: string | null + private readonly macCommandPath: string private readonly privilegedRunner: (command: string) => Promise private readonly userPathReader: () => Promise private readonly userPathWriter: (value: string) => Promise + private readonly appImagePath: string | null - // Why: Linux uses `orca-ide` to avoid shadowing GNOME Orca's /usr/bin/orca. private get commandName(): string { + if (!this.isPackaged && !this.commandPathOverride) { + // Why: development builds must not claim the production shell command. + return DEV_COMMAND_NAME + } + // Why: packaged Linux uses `orca-ide` to avoid shadowing GNOME Orca's /usr/bin/orca. return this.platform === 'linux' ? LINUX_COMMAND_NAME : 'orca' } @@ -70,9 +83,24 @@ export class CliInstaller { this.processPathEnv = options.processPathEnv ?? process.env.PATH ?? process.env.Path ?? null this.commandPathOverride = options.commandPathOverride ?? process.env.ORCA_CLI_INSTALL_PATH ?? null + // Why: resolved once at construction — existsSync must not run on every + // getStatus() call (hot path). /usr/local/bin is absent by default on Apple + // Silicon Macs (Homebrew moved to /opt/homebrew); fall back to ~/.local/bin + // which is user-writable, requires no elevated permissions, and is the + // XDG-standard user bin dir already on PATH via shell init on arm64. + // defaultMacCommandPath is a test seam: it feeds into the existence check + // so tests can simulate arm64 without relying on the real /usr/local/bin. + const candidateMacPath = options.defaultMacCommandPath ?? DEFAULT_MAC_COMMAND_PATH + this.macCommandPath = existsSync(dirname(candidateMacPath)) + ? candidateMacPath + : join(this.homePath, '.local', 'bin', 'orca') this.privilegedRunner = options.privilegedRunner ?? runMacPrivilegedCommand this.userPathReader = options.userPathReader ?? (() => readWindowsUserPath()) this.userPathWriter = options.userPathWriter ?? ((value) => writeWindowsUserPath(value)) + this.appImagePath = + this.platform === 'linux' && this.isPackaged + ? (options.appImagePath ?? process.env.APPIMAGE ?? null) + : null } async getStatus(): Promise { @@ -96,6 +124,12 @@ export class CliInstaller { const launcherPath = await this.resolveLauncherPath() if (!launcherPath) { + const detail = + this.isLinuxAppImage() && this.appImagePath + ? `The AppImage file at ${this.appImagePath} is missing. Move it back or re-run CLI registration from the current AppImage location.` + : this.isPackaged + ? 'The bundled CLI launcher is missing from this Orca build.' + : 'Development mode uses a generated launcher for validation only.' return { platform: this.platform, commandName: this.commandName, @@ -108,16 +142,16 @@ export class CliInstaller { state: 'unsupported', currentTarget: null, unsupportedReason: this.isPackaged ? 'launcher_missing' : 'launch_mode_unavailable', - detail: this.isPackaged - ? 'The bundled CLI launcher is missing from this Orca build.' - : 'Development mode uses a generated launcher for validation only.' + detail } } const baseStatus = spec.installMethod === 'symlink' ? await this.inspectSymlink(spec.commandPath, launcherPath) - : await this.inspectWindowsWrapper(spec.commandPath, launcherPath) + : this.isLinuxAppImage() + ? await this.inspectAppImageWrapper(spec.commandPath, launcherPath) + : await this.inspectWindowsWrapper(spec.commandPath, launcherPath) const pathDirectory = dirname(spec.commandPath) const pathConfigured = await this.isPathConfigured(pathDirectory) return this.withPathInfo(baseStatus, pathDirectory, pathConfigured) @@ -132,13 +166,22 @@ export class CliInstaller { throw new Error(`Refusing to replace non-Orca command at ${status.commandPath}.`) } - await mkdir(dirname(status.commandPath), { recursive: true }) - // eslint-disable-next-line unicorn/prefer-ternary -- Why: the install path performs async side effects and is easier to audit as an explicit branch than as an awaited ternary. if (status.installMethod === 'symlink') { await this.installSymlink(status) await this.removeLegacyLinuxCommandIfManaged(status.launcherPath) + } else if (this.isLinuxAppImage()) { + await this.installAppImageWrapper(status.commandPath, status.launcherPath) + await this.removeLegacyLinuxCommandIfManaged(status.launcherPath) + } else if (this.isWindowsPackagedBundledCommand(status.commandPath, status.launcherPath)) { + // Why: packaged Windows already ships resources/bin/orca.cmd. Registration + // only owns the user PATH entry; rewriting the asset makes it recurse. } else { + // Why: mkdir stays here for the Windows wrapper path — the target dir is + // user-writable (%LOCALAPPDATA%) so EACCES cannot occur. The symlink path + // handles its own mkdir inside installSymlink so EACCES triggers the + // privileged-runner instead of propagating as an unhandled rejection. + await mkdir(dirname(status.commandPath), { recursive: true }) await this.installWindowsWrapper(status.commandPath, status.launcherPath) } @@ -175,6 +218,8 @@ export class CliInstaller { if (status.installMethod === 'symlink') { await this.removeSymlink(status.commandPath) await this.removeLegacyLinuxCommandIfManaged(status.launcherPath) + } else if (this.isWindowsPackagedBundledCommand(status.commandPath, status.launcherPath)) { + await this.removeWindowsPathEntry(dirname(status.commandPath)) } else { await unlink(status.commandPath) await this.removeWindowsPathEntry(dirname(status.commandPath)) @@ -192,7 +237,7 @@ export class CliInstaller { if (this.platform === 'darwin' || this.platform === 'linux') { return { commandPath, - installMethod: 'symlink' + installMethod: this.isLinuxAppImage() ? 'wrapper' : 'symlink' } } @@ -211,8 +256,22 @@ export class CliInstaller { return this.commandPathOverride } + if (!this.isPackaged) { + // Why: default dev registration is a separate command, while tests and + // diagnostics can still exercise production paths via commandPathOverride. + if (this.platform === 'darwin') { + return `/usr/local/bin/${DEV_COMMAND_NAME}` + } + if (this.platform === 'linux') { + return join(this.homePath, '.local', 'bin', DEV_COMMAND_NAME) + } + if (this.platform === 'win32') { + return join(this.localAppDataPath, 'Programs', 'Orca Dev', 'bin', `${DEV_COMMAND_NAME}.cmd`) + } + } + if (this.platform === 'darwin') { - return DEFAULT_MAC_COMMAND_PATH + return this.macCommandPath } if (this.platform === 'linux') { @@ -226,7 +285,7 @@ export class CliInstaller { } if (this.platform === 'win32') { - return join(this.localAppDataPath, 'Programs', 'Orca', 'bin', 'orca.cmd') + return join(this.localAppDataPath, 'Programs', 'Orca', 'resources', 'bin', 'orca.cmd') } return null @@ -237,6 +296,10 @@ export class CliInstaller { return null } + if (this.isLinuxAppImage()) { + return this.appImagePath && existsSync(this.appImagePath) ? this.appImagePath : null + } + if (this.isPackaged) { const bundledPath = getBundledLauncherPath(this.platform, this.resourcesPath) return bundledPath && existsSync(bundledPath) ? bundledPath : null @@ -246,7 +309,8 @@ export class CliInstaller { platform: this.platform, userDataPath: this.userDataPath, execPath: this.execPathValue, - cliEntryPath: join(this.appPathValue, 'out', 'cli', 'index.js') + cliEntryPath: join(this.appPathValue, 'out', 'cli', 'index.js'), + commandName: this.commandName }) } @@ -258,6 +322,11 @@ export class CliInstaller { if (status.state === 'stale') { await unlink(status.commandPath as string) } + // Why: mkdir is placed here (not in install()) so that an EACCES/EPERM + // failure — e.g. /usr/local/bin absent on Intel Mac — falls into the + // privileged-runner catch below instead of surfacing as an unhandled + // rejection that leaves Settings silently showing "not installed". + await mkdir(dirname(status.commandPath as string), { recursive: true }) await symlink(status.launcherPath as string, status.commandPath as string) } catch (error) { if (this.platform !== 'darwin' || !isPermissionError(error)) { @@ -302,8 +371,7 @@ export class CliInstaller { const currentTarget = await readlink(legacyCommandPath) const resolvedCurrentTarget = resolve(dirname(legacyCommandPath), currentTarget) - const legacyLauncherPath = resolve(dirname(launcherPath), LEGACY_LINUX_COMMAND_NAME) - if (resolvedCurrentTarget !== legacyLauncherPath) { + if (!this.isManagedLegacyLinuxTarget(resolvedCurrentTarget, launcherPath)) { return } @@ -318,10 +386,89 @@ export class CliInstaller { } } + private isManagedLegacyLinuxTarget(resolvedTarget: string, launcherPath: string): boolean { + const legacyLauncherPath = resolve(dirname(launcherPath), LEGACY_LINUX_COMMAND_NAME) + if (resolvedTarget === legacyLauncherPath) { + return true + } + + if (basename(resolvedTarget) !== LEGACY_LINUX_COMMAND_NAME) { + return false + } + + const devLauncherDir = resolve(this.userDataPath, ...DEV_LAUNCHER_DIR) + const devRelative = relative(devLauncherDir, resolvedTarget) + if (devRelative && !devRelative.startsWith('..') && !isAbsolute(devRelative)) { + return true + } + + // Why: AppImage upgrades can leave a legacy symlink into a now-gone FUSE + // mount; the stable AppImage path is not a sibling of that old target. + return /(?:^|[/\\])resources[/\\]bin[/\\]orca$/.test(resolvedTarget) + } + private async installWindowsWrapper(commandPath: string, launcherPath: string): Promise { await writeFile(commandPath, buildWindowsForwarder(launcherPath), 'utf8') } + private async installAppImageWrapper(commandPath: string, appImagePath: string): Promise { + // Why: unlike macOS symlink install, AppImage uses the user-writable Linux + // command dir and must create it before writing the wrapper file. + await mkdir(dirname(commandPath), { recursive: true }) + await writeFile(commandPath, buildAppImageCliWrapper(appImagePath), { + encoding: 'utf8', + mode: 0o755 + }) + } + + private async inspectAppImageWrapper( + commandPath: string, + appImagePath: string + ): Promise { + try { + const stats = await lstat(commandPath) + if (!stats.isFile()) { + return this.buildStatus({ + commandPath, + launcherPath: appImagePath, + installMethod: 'wrapper', + supported: true, + state: 'conflict', + currentTarget: null, + detail: `${commandPath} exists but is not an Orca launcher script.` + }) + } + + const currentContent = await readFile(commandPath, 'utf8') + const expectedContent = buildAppImageCliWrapper(appImagePath) + return this.buildStatus({ + commandPath, + launcherPath: appImagePath, + installMethod: 'wrapper', + supported: true, + state: currentContent === expectedContent ? 'installed' : 'stale', + currentTarget: appImagePath, + detail: + currentContent === expectedContent + ? `Registered at ${commandPath}.` + : `${commandPath} points to a different launcher.` + }) + } catch (error) { + if (isMissingError(error)) { + return this.buildStatus({ + commandPath, + launcherPath: appImagePath, + installMethod: 'wrapper', + supported: true, + state: 'not_installed', + currentTarget: null, + detail: `Register ${commandPath} to use Orca from the terminal.` + }) + } + throw error + } + } + private async inspectSymlink( commandPath: string, launcherPath: string @@ -343,17 +490,21 @@ export class CliInstaller { const currentTarget = await readlink(commandPath) const resolvedCurrentTarget = resolve(dirname(commandPath), currentTarget) const resolvedLauncher = resolve(launcherPath) + const isInstalled = resolvedCurrentTarget === resolvedLauncher + const isManagedStaleTarget = + !isInstalled && this.isManagedSymlinkTarget(resolvedCurrentTarget, launcherPath) return this.buildStatus({ commandPath, launcherPath, installMethod: 'symlink', supported: true, - state: resolvedCurrentTarget === resolvedLauncher ? 'installed' : 'stale', + state: isInstalled ? 'installed' : isManagedStaleTarget ? 'stale' : 'conflict', currentTarget: resolvedCurrentTarget, - detail: - resolvedCurrentTarget === resolvedLauncher - ? `Registered at ${commandPath}.` - : `${commandPath} points to a different launcher.` + detail: isInstalled + ? `Registered at ${commandPath}.` + : isManagedStaleTarget + ? `${commandPath} points to an older Orca launcher.` + : `${commandPath} points to a non-Orca launcher.` }) } catch (error) { if (isMissingError(error)) { @@ -371,6 +522,73 @@ export class CliInstaller { } } + private isManagedSymlinkTarget(resolvedTarget: string, launcherPath: string): boolean { + const expectedName = basename(launcherPath) + if (this.isPackaged && this.isSiblingDevLauncherTarget(resolvedTarget, expectedName)) { + return true + } + + if (basename(resolvedTarget) !== expectedName) { + return false + } + + const devLauncherDir = resolve(this.userDataPath, ...DEV_LAUNCHER_DIR) + if (isPathInsideOrEqual(devLauncherDir, resolvedTarget)) { + return true + } + + if (this.platform === 'darwin') { + // Why: prior packaged installs can leave a symlink to an older Orca.app + // resources launcher, but arbitrary user-owned symlinks must not be replaced. + return /(?:^|[/\\])[^/\\]+\.app[/\\]Contents[/\\]Resources[/\\]bin[/\\][^/\\]+$/.test( + resolvedTarget + ) + } + + if (this.platform === 'linux') { + return /(?:^|[/\\])resources[/\\]bin[/\\][^/\\]+$/.test(resolvedTarget) + } + + return false + } + + private isSiblingDevLauncherTarget( + resolvedTarget: string, + packagedLauncherName: string + ): boolean { + if (![packagedLauncherName, DEV_COMMAND_NAME].includes(basename(resolvedTarget))) { + return false + } + + const packagedUserDataPath = resolve(this.userDataPath) + const siblingDevUserDataPath = `${packagedUserDataPath}-dev` + const siblingDevLauncherDir = resolve(siblingDevUserDataPath, ...DEV_LAUNCHER_DIR) + + // Why: development builds generate launchers under the sibling `*-dev` + // profile; packaged Orca must be able to reclaim that public command. + return ( + basename(siblingDevUserDataPath) === `${basename(packagedUserDataPath)}-dev` && + isPathInsideOrEqual(siblingDevLauncherDir, resolvedTarget) + ) + } + + private isLinuxAppImage(): boolean { + return this.platform === 'linux' && Boolean(this.appImagePath) + } + + private isWindowsPackagedBundledCommand( + commandPath: string | null, + launcherPath: string | null + ): commandPath is string { + return ( + this.platform === 'win32' && + this.isPackaged && + commandPath !== null && + launcherPath !== null && + samePathEntry('win32', commandPath, launcherPath) + ) + } + private async inspectWindowsWrapper( commandPath: string, launcherPath: string @@ -389,6 +607,18 @@ export class CliInstaller { }) } + if (this.isWindowsPackagedBundledCommand(commandPath, launcherPath)) { + return this.buildStatus({ + commandPath, + launcherPath, + installMethod: 'wrapper', + supported: true, + state: 'installed', + currentTarget: launcherPath, + detail: `Registered at ${commandPath}.` + }) + } + const currentContent = await readFile(commandPath, 'utf8') const expectedContent = buildWindowsForwarder(launcherPath) return this.buildStatus({ @@ -457,6 +687,21 @@ export class CliInstaller { pathDirectory: string, pathConfigured: boolean ): CliInstallStatus { + if ( + this.isWindowsPackagedBundledCommand(status.commandPath, status.launcherPath) && + status.state === 'installed' && + !pathConfigured + ) { + return { + ...status, + pathDirectory, + pathConfigured, + state: 'not_installed', + currentTarget: null, + detail: `Register ${status.commandPath} to use Orca from Command Prompt or PowerShell.` + } + } + if (status.state !== 'installed') { return { ...status, @@ -511,6 +756,7 @@ async function ensureDevLauncher(args: { userDataPath: string execPath: string cliEntryPath: string + commandName: string }): Promise { if ( !isAbsoluteForPlatform(args.platform, args.execPath) || @@ -523,7 +769,7 @@ async function ensureDevLauncher(args: { const launcherPath = join( args.userDataPath, ...DEV_LAUNCHER_DIR, - args.platform === 'win32' ? 'orca.cmd' : args.platform === 'linux' ? LINUX_COMMAND_NAME : 'orca' + args.platform === 'win32' ? `${args.commandName}.cmd` : args.commandName ) await mkdir(dirname(launcherPath), { recursive: true }) @@ -533,8 +779,8 @@ async function ensureDevLauncher(args: { // changing the packaged registration contract. const content = args.platform === 'win32' - ? buildWindowsDevLauncher(args.execPath, args.cliEntryPath) - : buildUnixDevLauncher(args.execPath, args.cliEntryPath) + ? buildWindowsDevLauncher(args.execPath, args.cliEntryPath, args.userDataPath) + : buildUnixDevLauncher(args.execPath, args.cliEntryPath, args.userDataPath) await writeFile(launcherPath, content, { encoding: 'utf8', mode: args.platform === 'win32' ? undefined : 0o755 @@ -542,11 +788,20 @@ async function ensureDevLauncher(args: { return launcherPath } -function buildUnixDevLauncher(execPathValue: string, cliEntryPath: string): string { +function buildUnixDevLauncher( + execPathValue: string, + cliEntryPath: string, + userDataPath: string +): string { return `#!/usr/bin/env bash set -euo pipefail ELECTRON=${quoteShell(execPathValue)} CLI=${quoteShell(cliEntryPath)} +export ORCA_USER_DATA_PATH=${quoteShell(userDataPath)} +if [ -z "\${ORCA_APP_EXECUTABLE:-}" ]; then + export ORCA_APP_EXECUTABLE="$ELECTRON" + export ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1 +fi export ORCA_NODE_OPTIONS="\${NODE_OPTIONS-}" export ORCA_NODE_REPL_EXTERNAL_MODULE="\${NODE_REPL_EXTERNAL_MODULE-}" unset NODE_OPTIONS @@ -555,11 +810,20 @@ ELECTRON_RUN_AS_NODE=1 "$ELECTRON" "$CLI" "$@" ` } -function buildWindowsDevLauncher(execPathValue: string, cliEntryPath: string): string { +function buildWindowsDevLauncher( + execPathValue: string, + cliEntryPath: string, + userDataPath: string +): string { return `@echo off setlocal set "ELECTRON=${escapeWindowsBatchValue(execPathValue)}" set "CLI=${escapeWindowsBatchValue(cliEntryPath)}" +set "ORCA_USER_DATA_PATH=${escapeWindowsBatchValue(userDataPath)}" +if not defined ORCA_APP_EXECUTABLE ( + set "ORCA_APP_EXECUTABLE=%ELECTRON%" + set "ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1" +) set "ORCA_NODE_OPTIONS=%NODE_OPTIONS%" set "ORCA_NODE_REPL_EXTERNAL_MODULE=%NODE_REPL_EXTERNAL_MODULE%" set NODE_OPTIONS= @@ -593,6 +857,11 @@ function samePathEntry(platform: NodeJS.Platform, left: string, right: string): : left === right } +function isPathInsideOrEqual(parentPath: string, childPath: string): boolean { + const childRelative = relative(parentPath, childPath) + return childRelative === '' || (!childRelative.startsWith('..') && !isAbsolute(childRelative)) +} + function normalizeWindowsPath(value: string): string { return value.replaceAll('/', '\\').replace(/\\+$/, '').toLowerCase() } @@ -639,7 +908,7 @@ function isAbsoluteForPlatform(platform: NodeJS.Platform, value: string): boolea } async function readWindowsUserPath(): Promise { - const { stdout } = await execFileAsync('powershell', [ + const stdout = await runWindowsPathCommand([ '-NoProfile', '-Command', "[Environment]::GetEnvironmentVariable('Path','User')" @@ -648,7 +917,7 @@ async function readWindowsUserPath(): Promise { } async function writeWindowsUserPath(value: string): Promise { - await execFileAsync('powershell', [ + await runWindowsPathCommand([ '-NoProfile', '-Command', // Why: PATH registration must stay user-scoped on Windows so the Orca @@ -658,6 +927,48 @@ async function writeWindowsUserPath(value: string): Promise { ]) } +function runWindowsPathCommand(args: string[]): Promise { + return new Promise((resolve, reject) => { + let child: ReturnType | null = null + let settled = false + + const finish = (error: Error | null, stdout = ''): void => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + if (error) { + reject(error) + return + } + resolve(stdout) + } + + // Why: Windows PATH reads/writes back CLI Settings; wedged PowerShell must + // not keep command registration status or install/remove pending forever. + const timeout = setTimeout(() => { + child?.kill() + finish( + new Error(`Windows PATH command timed out after ${WINDOWS_PATH_COMMAND_TIMEOUT_MS}ms.`) + ) + }, WINDOWS_PATH_COMMAND_TIMEOUT_MS) + + try { + child = execFile( + 'powershell', + args, + { encoding: 'utf8', timeout: WINDOWS_PATH_COMMAND_TIMEOUT_MS }, + (error, stdout) => { + finish(error ?? null, stdout) + } + ) + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))) + } + }) +} + function quotePowerShell(value: string): string { return `'${value.replaceAll("'", "''")}'` } diff --git a/src/main/cli/packaged-cli-assets.test.ts b/src/main/cli/packaged-cli-assets.test.ts index b13d794f9b2..4aa8b034d93 100644 --- a/src/main/cli/packaged-cli-assets.test.ts +++ b/src/main/cli/packaged-cli-assets.test.ts @@ -1,27 +1,42 @@ import { execFile } from 'node:child_process' -import { copyFile, chmod, mkdir, mkdtemp, rm, stat, symlink, writeFile } from 'node:fs/promises' +import { copyFile, mkdir, mkdtemp, rm, stat, symlink, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' import { promisify } from 'node:util' import { describe, expect, it } from 'vitest' +import { buildAppImageCliWrapper } from './appimage-cli-wrapper' const require = createRequire(import.meta.url) const execFileAsync = promisify(execFile) const itRunsUnixShell = process.platform === 'win32' ? it.skip : it const builderConfig = require('../../../config/electron-builder.config.cjs') as { - asarUnpack?: string[] + mac?: { extraResources?: { from?: string; to?: string }[] } + linux?: { extraResources?: { from?: string; to?: string }[] } + win?: { extraResources?: { from?: string; to?: string }[] } } const linuxLauncherAsset = new URL('../../../resources/linux/bin/orca-ide', import.meta.url) describe('packaged CLI assets', () => { - it('unpacks runtime dependencies used before Electron asar integration is available', () => { - expect(builderConfig.asarUnpack).toEqual( + it('copies runtime dependencies used before Electron asar integration is available', () => { + const runtimeResourceTargets = new Set( + [ + ...(builderConfig.mac?.extraResources ?? []), + ...(builderConfig.linux?.extraResources ?? []), + ...(builderConfig.win?.extraResources ?? []) + ].map((resource) => resource.to) + ) + + expect([...runtimeResourceTargets]).toEqual( expect.arrayContaining([ - 'node_modules/ws/**', - 'node_modules/tweetnacl/**', - 'node_modules/zod/**', - 'node_modules/yaml/**' + join('node_modules', 'ws'), + join('node_modules', 'tweetnacl'), + join('node_modules', 'zod'), + join('node_modules', 'yaml'), + join('node_modules', 'node-pty'), + join('node_modules', 'sherpa-onnx-darwin-${arch}'), + join('node_modules', 'sherpa-onnx-linux-${arch}'), + join('node_modules', 'sherpa-onnx-win-x64') ]) ) }) @@ -47,7 +62,7 @@ describe('packaged CLI assets', () => { await mkdir(launcherDir, { recursive: true }) await mkdir(cliDir, { recursive: true }) await copyFile(linuxLauncherAsset, launcherPath) - await chmod(launcherPath, 0o755) + expect((await stat(launcherPath)).mode & 0o111).not.toBe(0) await writeFile(cliPath, '', 'utf8') await writeFile( electronPath, @@ -84,4 +99,72 @@ printf 'arg=%s\\n' "$@" } } ) + + itRunsUnixShell('runs the AppImage CLI wrapper through APPDIR at runtime', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-appimage-cli-')) + try { + const appDir = join(root, 'Orca.AppDir') + const cliDir = join(appDir, 'resources', 'app.asar.unpacked', 'out', 'cli') + const cliPath = join(cliDir, 'index.js') + const appImagePath = join(root, "Orca's AppImage.AppImage") + const commandPath = join(root, 'orca-ide') + await mkdir(cliDir, { recursive: true }) + await writeFile( + cliPath, + `exports.main = (argv) => { + console.log(JSON.stringify({ + argv, + appDir: process.env.APPDIR, + runAsNode: process.env.ELECTRON_RUN_AS_NODE, + nodeOptions: process.env.NODE_OPTIONS ?? null, + orcaNodeOptions: process.env.ORCA_NODE_OPTIONS ?? null, + nodeReplExternalModule: process.env.NODE_REPL_EXTERNAL_MODULE ?? null, + orcaNodeReplExternalModule: process.env.ORCA_NODE_REPL_EXTERNAL_MODULE ?? null + })) +} +`, + 'utf8' + ) + await writeFile( + appImagePath, + `#!/usr/bin/env bash +export APPDIR="$FAKE_APPDIR" +exec node "$@" +`, + { encoding: 'utf8', mode: 0o755 } + ) + await writeFile(commandPath, buildAppImageCliWrapper(appImagePath), { + encoding: 'utf8', + mode: 0o755 + }) + + const result = await execFileAsync(commandPath, ['--help', 'two words'], { + env: { + ...process.env, + FAKE_APPDIR: appDir, + NODE_OPTIONS: '--trace-warnings', + NODE_REPL_EXTERNAL_MODULE: 'external-loader' + } + }) + const payload = JSON.parse(result.stdout) as { + argv: string[] + appDir: string + runAsNode: string + nodeOptions: string | null + orcaNodeOptions: string | null + nodeReplExternalModule: string | null + orcaNodeReplExternalModule: string | null + } + + expect(payload.argv).toEqual(['--help', 'two words']) + expect(payload.appDir).toBe(appDir) + expect(payload.runAsNode).toBe('1') + expect(payload.nodeOptions).toBeNull() + expect(payload.orcaNodeOptions).toBe('--trace-warnings') + expect(payload.nodeReplExternalModule).toBeNull() + expect(payload.orcaNodeReplExternalModule).toBe('external-loader') + } finally { + await rm(root, { recursive: true, force: true }) + } + }) }) diff --git a/src/main/cli/wsl-cli-installer.test.ts b/src/main/cli/wsl-cli-installer.test.ts index 3804251c1ba..b35c5658735 100644 --- a/src/main/cli/wsl-cli-installer.test.ts +++ b/src/main/cli/wsl-cli-installer.test.ts @@ -1,8 +1,17 @@ import type { CliInstallStatus } from '../../shared/cli-install-types' -import { WslCliInstaller, _internals } from './wsl-cli-installer' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -function makeHostStatus(launcherPath = 'C:\\Users\\me\\AppData\\Local\\Orca\\bin\\orca.cmd') { +const execFileMock = vi.hoisted(() => vi.fn()) + +vi.mock('node:child_process', () => ({ + execFile: execFileMock +})) + +import { WslCliInstaller, _internals } from './wsl-cli-installer' + +function makeHostStatus( + launcherPath = 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.cmd' +) { return { platform: 'win32', commandName: 'orca', @@ -33,9 +42,6 @@ function createWslRunner(initialFile: string | null = null, pathIncludesLocalBin if (command.includes('printf %s "$HOME"')) { return '/home/alice' } - if (command.includes('command -v powershell.exe')) { - return 'yes' - } if (command.includes('case ":$PATH:"')) { return pathIncludesLocalBin ? 'yes' : 'no' } @@ -50,6 +56,9 @@ function createWslRunner(initialFile: string | null = null, pathIncludesLocalBin files.set(bridgePath, bridge) return '' } + if (command.includes('command -v powershell.exe')) { + return 'yes' + } if (command.includes('rm -f')) { if ( files.has(bridgePath) && @@ -80,6 +89,14 @@ function createWslRunner(initialFile: string | null = null, pathIncludesLocalBin } describe('WslCliInstaller', () => { + beforeEach(() => { + execFileMock.mockReset() + }) + + afterEach(() => { + vi.useRealTimers() + }) + it('installs a WSL launcher that forwards to the Windows Orca launcher', async () => { const wsl = createWslRunner() const installer = new WslCliInstaller({ @@ -99,11 +116,11 @@ describe('WslCliInstaller', () => { expect(installed).toMatchObject({ state: 'installed', pathConfigured: true, - launcherPath: 'C:\\Users\\me\\AppData\\Local\\Orca\\bin\\orca.cmd' + launcherPath: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.cmd' }) expect(wsl.getFile()).toBe( _internals.buildWslLauncher( - 'C:\\Users\\me\\AppData\\Local\\Orca\\bin\\orca.cmd', + 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.cmd', '/home/alice/.local/share/orca/orca-wsl-bridge.ps1' ) ) @@ -180,7 +197,12 @@ describe('WslCliInstaller', () => { ) const bridge = _internals.buildWslBridgeScript() - expect(launcher).toContain('powershell.exe -NoProfile -ExecutionPolicy Bypass -File') + expect(launcher).toContain('command -v powershell.exe') + expect(launcher).toContain('/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe') + expect(launcher).toContain( + 'Orca WSL CLI requires Windows interop and could not find powershell.exe.' + ) + expect(launcher).toContain('"$ORCA_POWERSHELL" -NoProfile -ExecutionPolicy Bypass -File') expect(launcher).toContain('"$ORCA_WIN_LAUNCHER" "$@"') expect(launcher).not.toContain('-Command') expect(bridge).toContain('[Parameter(ValueFromRemainingArguments=$true)]') @@ -208,6 +230,78 @@ describe('WslCliInstaller', () => { expect(Buffer.from(encoded as string, 'base64').toString('utf8')).toBe(command) }) + it('treats absolute Windows PowerShell as interop-ready when powershell.exe is missing from PATH', async () => { + const wsl = createWslRunner() + const installer = new WslCliInstaller({ + platform: 'win32', + distro: 'Ubuntu', + hostInstaller: { getStatus: async () => makeHostStatus() }, + wslRunner: async (distro, command) => { + if (command.includes('command -v powershell.exe') && !command.includes('cat >')) { + expect(command).toContain('/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe') + return 'yes' + } + return wsl.runner(distro, command) + } + }) + + await expect(installer.getStatus()).resolves.toMatchObject({ + state: 'not_installed', + commandPath: '/home/alice/.local/bin/orca-ide' + }) + }) + + it('marks stale managed launchers that point at the old app bin instead of packaged resources', async () => { + const oldLauncher = _internals.buildWslLauncher( + 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\bin\\orca.cmd', + '/home/alice/.local/share/orca/orca-wsl-bridge.ps1' + ) + const wsl = createWslRunner(oldLauncher) + const installer = new WslCliInstaller({ + platform: 'win32', + distro: 'Ubuntu', + hostInstaller: { getStatus: async () => makeHostStatus() }, + wslRunner: wsl.runner + }) + + await expect(installer.getStatus()).resolves.toMatchObject({ + state: 'stale', + currentTarget: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\bin\\orca.cmd', + launcherPath: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.cmd' + }) + + await expect(installer.install()).resolves.toMatchObject({ + state: 'installed', + currentTarget: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.cmd' + }) + }) + + it('settles when wsl.exe never reports completion', async () => { + vi.useFakeTimers() + const killMock = vi.fn() + execFileMock.mockImplementation(() => ({ kill: killMock })) + const installer = new WslCliInstaller({ + platform: 'win32', + distro: 'Ubuntu', + hostInstaller: { getStatus: async () => makeHostStatus() } + }) + + const promise = installer.getStatus() + let settled = false + void promise + .catch(() => undefined) + .finally(() => { + settled = true + }) + + await vi.advanceTimersByTimeAsync(10_000) + await Promise.resolve() + + expect(settled).toBe(true) + await expect(promise).rejects.toThrow('WSL command timed out') + expect(killMock).toHaveBeenCalled() + }) + it('refuses to remove an old managed launcher when the bridge path is user-owned', async () => { const oldLauncher = _internals.buildWslLauncher( 'C:\\Old\\orca.cmd', diff --git a/src/main/cli/wsl-cli-installer.ts b/src/main/cli/wsl-cli-installer.ts index 33eedeb3e5f..9e5d8877237 100644 --- a/src/main/cli/wsl-cli-installer.ts +++ b/src/main/cli/wsl-cli-installer.ts @@ -1,7 +1,6 @@ /* eslint-disable max-lines -- Why: WSL CLI status/install/remove share one state machine; splitting the installer would separate conflict checks from the operations they guard. */ import { execFile } from 'node:child_process' -import { promisify } from 'node:util' import type { CliInstallStatus } from '../../shared/cli-install-types' import { getDefaultWslDistro } from '../wsl' import { CliInstaller } from './cli-installer' @@ -18,11 +17,11 @@ import { quoteShell } from './wsl-cli-scripts' -const execFileAsync = promisify(execFile) const MANAGED_MARKER = getWslLauncherMarker() const BRIDGE_MANAGED_MARKER = getWslBridgeMarker() const WSL_COMMAND_NAME = 'orca-ide' const LEGACY_WSL_COMMAND_NAME = 'orca' +const WSL_COMMAND_TIMEOUT_MS = 10_000 type WslCliInstallerOptions = { platform?: NodeJS.Platform @@ -235,7 +234,7 @@ export class WslCliInstaller { ( await this.run( this.distro, - 'command -v powershell.exe >/dev/null 2>&1 && command -v wslpath >/dev/null 2>&1 && printf yes || printf no' + '{ command -v powershell.exe >/dev/null 2>&1 || [ -x /mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe ]; } && command -v wslpath >/dev/null 2>&1 && printf yes || printf no' ) ).trim() === 'yes' if (!interopReady) { @@ -348,15 +347,46 @@ export class WslCliInstaller { } async function runWslCommand(distro: string, command: string): Promise { - const { stdout } = (await execFileAsync( - 'wsl.exe', - ['-d', distro, '--', 'bash', '-lc', buildEncodedWslBashCommand(command)], - { - encoding: 'utf8', - timeout: 10000 + return new Promise((resolve, reject) => { + let child: ReturnType | null = null + let settled = false + + const finish = (error: Error | null, stdout = ''): void => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + if (error) { + reject(error) + return + } + resolve(stdout) } - )) as { stdout: string } - return stdout + + // Why: WSL CLI status/install/remove backs Settings UI; a wedged wsl.exe + // process must not leave the command registration flow pending forever. + const timeout = setTimeout(() => { + child?.kill() + finish(new Error(`WSL command timed out after ${WSL_COMMAND_TIMEOUT_MS}ms.`)) + }, WSL_COMMAND_TIMEOUT_MS) + + try { + child = execFile( + 'wsl.exe', + ['-d', distro, '--', 'bash', '-lc', buildEncodedWslBashCommand(command)], + { + encoding: 'utf8', + timeout: WSL_COMMAND_TIMEOUT_MS + }, + (error, stdout) => { + finish(error ?? null, stdout) + } + ) + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))) + } + }) } function buildEncodedWslBashCommand(command: string): string { diff --git a/src/main/cli/wsl-cli-scripts.ts b/src/main/cli/wsl-cli-scripts.ts index 23f8f27ce65..70e1b16fb25 100644 --- a/src/main/cli/wsl-cli-scripts.ts +++ b/src/main/cli/wsl-cli-scripts.ts @@ -12,8 +12,16 @@ ${MANAGED_MARKER} # ORCA_WIN_LAUNCHER_B64=${encodedTarget} ORCA_WIN_LAUNCHER=${quoteShell(windowsLauncherPath)} ORCA_BRIDGE_PS1=${quoteShell(bridgePath)} +if command -v powershell.exe >/dev/null 2>&1; then + ORCA_POWERSHELL=powershell.exe +elif [ -x /mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe ]; then + ORCA_POWERSHELL=/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe +else + echo "Orca WSL CLI requires Windows interop and could not find powershell.exe." >&2 + exit 1 +fi ORCA_BRIDGE_PS1_WIN=$(wslpath -w "$ORCA_BRIDGE_PS1") -exec powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$ORCA_BRIDGE_PS1_WIN" "$ORCA_WIN_LAUNCHER" "$@" +exec "$ORCA_POWERSHELL" -NoProfile -ExecutionPolicy Bypass -File "$ORCA_BRIDGE_PS1_WIN" "$ORCA_WIN_LAUNCHER" "$@" ` } diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 116c59c922b..2d7bb9e76cf 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -39,6 +39,7 @@ vi.mock('node:os', async () => { function createSettings(overrides: Partial = {}): GlobalSettings { const appFontFamily = overrides.appFontFamily ?? 'Geist' const agentStatusHooksEnabled = overrides.agentStatusHooksEnabled ?? true + const tabAutoGenerateTitle = overrides.tabAutoGenerateTitle ?? false return { workspaceDir: testState.fakeHomeDir, nestWorkspaces: false, @@ -133,7 +134,8 @@ function createSettings(overrides: Partial = {}): GlobalSettings enableGitHubAttribution: true, ...overrides, appFontFamily, - agentStatusHooksEnabled + agentStatusHooksEnabled, + tabAutoGenerateTitle } } @@ -2770,6 +2772,8 @@ describe('CodexRuntimeHomeService', () => { const runtimeSessionsDir = join(getRuntimeCodexHomePath(), 'sessions') mkdirSync(runtimeSessionsDir, { recursive: true }) writeFileSync(join(runtimeSessionsDir, 'session.json'), '{"turns":[1]}', 'utf-8') + mkdirSync(join(runtimeSessionsDir, 'nested'), { recursive: true }) + writeFileSync(join(runtimeSessionsDir, 'nested', 'session.json'), '{"turns":[2]}', 'utf-8') const managedHomePath = createManagedAuth( testState.userDataDir, 'account-1', @@ -2778,6 +2782,8 @@ describe('CodexRuntimeHomeService', () => { const legacySessionsDir = join(managedHomePath, 'sessions') mkdirSync(legacySessionsDir, { recursive: true }) writeFileSync(join(legacySessionsDir, 'session.json'), '{"turns":[1,2]}', 'utf-8') + mkdirSync(join(legacySessionsDir, 'nested'), { recursive: true }) + writeFileSync(join(legacySessionsDir, 'nested', 'session.json'), '{"turns":[2,3]}', 'utf-8') const store = createStore(createSettings()) const { CodexRuntimeHomeService } = await import('./runtime-home-service') @@ -2789,9 +2795,17 @@ describe('CodexRuntimeHomeService', () => { ).toBe('{"turns":[1,2]}') expect( readFileSync( - join(testState.userDataDir, 'codex-runtime-home', 'migration-diagnostics.jsonl'), + join(runtimeSessionsDir, 'nested', 'session.orca-legacy-account-1.json'), 'utf-8' ) - ).toContain('"type":"session-conflict"') + ).toBe('{"turns":[2,3]}') + const diagnostics = readFileSync( + join(testState.userDataDir, 'codex-runtime-home', 'migration-diagnostics.jsonl'), + 'utf-8' + ) + .trim() + .split('\n') + expect(diagnostics).toHaveLength(2) + expect(diagnostics[0]).toContain('"type":"session-conflict"') }) }) diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 0be2ebef6e6..b722252294e 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -3,6 +3,7 @@ contract for Codex inside Orca. Keeping path resolution, system-default snapshots, auth materialization, and recovery together prevents account-switch semantics from drifting across PTY launch, login, and quota fetch paths. */ import { + appendFileSync, copyFileSync, existsSync, chmodSync, @@ -969,7 +970,7 @@ export class CodexRuntimeHomeService { for (const entry of readdirSync(rootPath, { withFileTypes: true })) { const childPath = join(rootPath, entry.name) if (entry.isDirectory()) { - files.push(...this.listFilesRecursively(childPath)) + this.appendListedFiles(files, this.listFilesRecursively(childPath)) continue } if (entry.isFile()) { @@ -979,6 +980,14 @@ export class CodexRuntimeHomeService { return files.sort() } + private appendListedFiles(target: string[], source: readonly string[]): void { + // Why: migrating legacy session trees must tolerate directories larger than + // V8's argument limit for spread calls. + for (const filePath of source) { + target.push(filePath) + } + } + private getPreservedLegacySessionPath(runtimeFilePath: string, accountId: string): string { const extension = extname(runtimeFilePath) const basename = runtimeFilePath.slice(0, runtimeFilePath.length - extension.length) @@ -987,10 +996,13 @@ export class CodexRuntimeHomeService { private appendMigrationDiagnostic(record: Record): void { const diagnosticsPath = this.getMigrationDiagnosticsPath() - const existingContents = existsSync(diagnosticsPath) - ? readFileSync(diagnosticsPath, 'utf-8') - : '' - writeFileAtomically(diagnosticsPath, `${existingContents}${JSON.stringify(record)}\n`) + try { + appendFileSync(diagnosticsPath, `${JSON.stringify(record)}\n`, { encoding: 'utf-8' }) + } catch (error) { + // Why: conflict diagnostics are useful, but must not make the one-shot + // migration fail after the session file has already been preserved. + console.warn('[codex-runtime-home] Failed to append migration diagnostic:', error) + } } private captureSystemDefaultSnapshot(options: { force: boolean }): void { diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index aa20396c5ee..7cfbea6a924 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -1,7 +1,16 @@ /* eslint-disable max-lines -- test suite covers config sync, login seeding, and fallback scenarios */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { EventEmitter } from 'node:events' -import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + statSync, + utimesSync, + writeFileSync +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { PassThrough } from 'node:stream' @@ -31,6 +40,7 @@ function decodeEncodedWslBashCommand(command: string): string { function createSettings(overrides: Partial = {}): GlobalSettings { const appFontFamily = overrides.appFontFamily ?? 'Geist' const agentStatusHooksEnabled = overrides.agentStatusHooksEnabled ?? true + const tabAutoGenerateTitle = overrides.tabAutoGenerateTitle ?? false return { workspaceDir: testState.fakeHomeDir, nestWorkspaces: false, @@ -125,7 +135,8 @@ function createSettings(overrides: Partial = {}): GlobalSettings enableGitHubAttribution: true, ...overrides, appFontFamily, - agentStatusHooksEnabled + agentStatusHooksEnabled, + tabAutoGenerateTitle } } @@ -226,6 +237,45 @@ describe('CodexAccountService config sync', () => { ) }) + it('does not rewrite managed configs that already match canonical config', async () => { + const canonicalConfigPath = join(testState.fakeHomeDir, '.codex', 'config.toml') + const canonicalConfig = 'approval_policy = "never"\nsandbox_mode = "danger-full-access"\n' + writeFileSync(canonicalConfigPath, canonicalConfig, 'utf-8') + const managedHomePath = createManagedHome( + testState.userDataDir, + 'account-1', + canonicalConfig, + '{"account":"managed"}\n' + ) + const managedConfigPath = join(managedHomePath, 'config.toml') + const oldDate = new Date('2024-01-01T00:00:00.000Z') + utimesSync(managedConfigPath, oldDate, oldDate) + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + const rateLimits = createRateLimits() + const runtimeHome = createRuntimeHome() + + const { CodexAccountService } = await import('./service') + new CodexAccountService(store as never, rateLimits as never, runtimeHome as never) + + expect(statSync(managedConfigPath).mtimeMs).toBeLessThan(Date.now() - 60_000) + }) + it('does not sync configs when ~/.codex/config.toml is missing', async () => { const firstManagedHomePath = createManagedHome( testState.userDataDir, @@ -994,4 +1044,59 @@ describe('CodexAccountService config sync', () => { expect(rateLimits.refreshForCodexAccountChange).toHaveBeenCalledTimes(2) }) + + it('removes command listeners when Codex login times out', async () => { + vi.resetModules() + vi.useFakeTimers() + const child = new EventEmitter() as EventEmitter & { + stdout: PassThrough + stderr: PassThrough + kill: () => void + } + child.stdout = new PassThrough() + child.stderr = new PassThrough() + child.kill = vi.fn() + const spawnMock = vi.fn(() => child) + vi.doMock('node:child_process', () => ({ + execFileSync: vi.fn(), + spawn: spawnMock + })) + vi.doMock('../codex-cli/command', () => ({ + resolveCodexCommand: () => 'codex' + })) + + try { + const settings = createSettings() + const store = createStore(settings) + const rateLimits = createRateLimits() + const runtimeHome = createRuntimeHome() + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + rateLimits as never, + runtimeHome as never + ) + const loginPromise = ( + service as unknown as { + runCodexLogin(managedHomePath: string): Promise + } + ).runCodexLogin(testState.fakeHomeDir) + const rejection = expect(loginPromise).rejects.toThrow( + 'Codex sign-in took too long to finish.' + ) + + await vi.advanceTimersByTimeAsync(120_000) + + await rejection + expect(child.kill).toHaveBeenCalledTimes(1) + expect(child.stdout.listenerCount('data')).toBe(0) + expect(child.stderr.listenerCount('data')).toBe(0) + expect(child.listenerCount('error')).toBe(0) + expect(child.listenerCount('close')).toBe(0) + } finally { + vi.useRealTimers() + vi.doUnmock('node:child_process') + vi.doUnmock('../codex-cli/command') + } + }) }) diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index ce6414ddf0e..0aef471c77a 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -495,7 +495,16 @@ export class CodexAccountService { } private writeManagedConfig(managedHomePath: string, contents: string): void { - writeFileAtomically(join(managedHomePath, 'config.toml'), contents) + const configPath = join(managedHomePath, 'config.toml') + try { + if (existsSync(configPath) && readFileSync(configPath, 'utf-8') === contents) { + return + } + } catch { + // Why: read errors should not make a stale config look current; the + // atomic write path owns Windows ACL repair and persistent error surfacing. + } + writeFileAtomically(configPath, contents) } private getManagedAccountsRoot(): string { @@ -734,26 +743,36 @@ export class CodexAccountService { } } + let timeout: ReturnType | null = null + const cleanupListeners = (): void => { + if (timeout) { + clearTimeout(timeout) + timeout = null + } + child.stdout.off('data', appendOutput) + child.stderr.off('data', appendOutput) + child.off('error', onError) + child.off('close', onClose) + } + const settle = (callback: () => void): void => { if (settled) { return } settled = true - clearTimeout(timeout) + cleanupListeners() callback() } - const timeout = setTimeout(() => { + const timeoutError = new Error('Codex sign-in took too long to finish. Please try again.') + timeout = setTimeout(() => { child.kill() settle(() => { - rejectPromise(new Error('Codex sign-in took too long to finish. Please try again.')) + rejectPromise(timeoutError) }) }, LOGIN_TIMEOUT_MS) - child.stdout.on('data', appendOutput) - child.stderr.on('data', appendOutput) - - child.on('error', (error) => { + const onError = (error: Error): void => { settle(() => { const isEnoent = (error as NodeJS.ErrnoException).code === 'ENOENT' // Why: ENOENT can mean either the codex binary doesn't exist OR the @@ -767,9 +786,9 @@ export class CodexAccountService { : error.message rejectPromise(new Error(message)) }) - }) + } - child.on('close', (code) => { + const onClose = (code: number | null): void => { settle(() => { if (code === 0) { resolvePromise() @@ -784,7 +803,12 @@ export class CodexAccountService { ) ) }) - }) + } + + child.stdout.on('data', appendOutput) + child.stderr.on('data', appendOutput) + child.on('error', onError) + child.on('close', onClose) }) } diff --git a/src/main/codex-cli/command.test.ts b/src/main/codex-cli/command.test.ts index e88d432d790..70928625ddd 100644 --- a/src/main/codex-cli/command.test.ts +++ b/src/main/codex-cli/command.test.ts @@ -1,12 +1,23 @@ -import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' +import { chmodSync, mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' +import { delimiter, dirname, join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { getVersionManagerBinPaths, resolveClaudeCommand, resolveCodexCommand } from './command' function makeExecutable(path: string): void { mkdirSync(dirname(path), { recursive: true }) writeFileSync(path, '') + if (process.platform !== 'win32') { + chmodSync(path, 0o755) + } +} + +function makeNonExecutableFile(path: string): void { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, '') + if (process.platform !== 'win32') { + chmodSync(path, 0o644) + } } describe('resolveCodexCommand', () => { @@ -26,6 +37,44 @@ describe('resolveCodexCommand', () => { ) }) + it.skipIf(process.platform === 'win32')( + 'skips non-runnable PATH entries and keeps scanning', + () => { + const root = mkdtempSync(join(tmpdir(), 'orca-codex-command-')) + const badDir = join(root, 'bad-bin') + const goodDir = join(root, 'good-bin') + const badCommandPath = join(badDir, 'codex') + const goodCommandPath = join(goodDir, 'codex') + makeNonExecutableFile(badCommandPath) + makeExecutable(goodCommandPath) + + expect( + resolveCodexCommand({ + platform: 'linux', + pathEnv: [badDir, goodDir].join(delimiter), + homePath: root + }) + ).toBe(goodCommandPath) + } + ) + + it('skips PATH directories named like the command', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-codex-command-')) + const badDir = join(root, 'bad-bin') + const goodDir = join(root, 'good-bin') + mkdirSync(join(badDir, 'codex'), { recursive: true }) + const goodCommandPath = join(goodDir, 'codex') + makeExecutable(goodCommandPath) + + expect( + resolveCodexCommand({ + platform: 'linux', + pathEnv: [badDir, goodDir].join(delimiter), + homePath: root + }) + ).toBe(goodCommandPath) + }) + it('falls back to the newest nvm-installed Codex when PATH misses it', () => { const root = mkdtempSync(join(tmpdir(), 'orca-codex-command-')) const v22Path = join(root, '.nvm', 'versions', 'node', 'v22.14.0', 'bin', 'codex') diff --git a/src/main/codex-cli/command.ts b/src/main/codex-cli/command.ts index c11cf07ff78..666cf010639 100644 --- a/src/main/codex-cli/command.ts +++ b/src/main/codex-cli/command.ts @@ -1,4 +1,4 @@ -import { existsSync, readdirSync } from 'node:fs' +import { accessSync, constants, existsSync, readdirSync, statSync } from 'node:fs' import { homedir } from 'node:os' import { delimiter, dirname, join } from 'node:path' @@ -50,11 +50,15 @@ function compareVersionDesc(left: string, right: string): number { return right.localeCompare(left) } -function findFirstExecutable(directories: string[], executableNames: string[]): string | null { +function findFirstExecutable( + platform: NodeJS.Platform, + directories: string[], + executableNames: string[] +): string | null { for (const directory of directories) { for (const executableName of executableNames) { const candidate = join(directory, executableName) - if (existsSync(candidate)) { + if (isRunnableCommand(platform, candidate)) { return candidate } } @@ -63,6 +67,24 @@ function findFirstExecutable(directories: string[], executableNames: string[]): return null } +function isRunnableCommand(platform: NodeJS.Platform, candidate: string): boolean { + try { + const stats = statSync(candidate) + if (!stats.isFile()) { + return false + } + if (platform === 'win32') { + return true + } + // Why: GUI fallback probing should skip placeholders/directories so spawn + // can continue to a runnable CLI instead of failing later with EACCES/EISDIR. + accessSync(candidate, constants.X_OK) + return true + } catch { + return false + } +} + function getVersionManagerDirectories( platform: NodeJS.Platform, homePath: string, @@ -91,7 +113,7 @@ function getVersionManagerDirectories( .sort(compareVersionDesc) .map((entry) => join(nvmVersionsDir, entry, 'bin')) - const firstNvmMatch = findFirstExecutable(nvmVersionDirectories, executableNames) + const firstNvmMatch = findFirstExecutable(platform, nvmVersionDirectories, executableNames) if (firstNvmMatch) { directories.unshift(dirname(firstNvmMatch)) } @@ -131,13 +153,14 @@ export function resolveCliCommand( const platform = options.platform ?? process.platform const executableNames = getExecutableNames(platform, commandName) const pathEnv = options.pathEnv ?? process.env.PATH ?? process.env.Path ?? null - const pathCandidate = findFirstExecutable(splitPath(pathEnv), executableNames) + const pathCandidate = findFirstExecutable(platform, splitPath(pathEnv), executableNames) if (pathCandidate) { return pathCandidate } const homePath = options.homePath ?? homedir() const versionManagerCandidate = findFirstExecutable( + platform, getVersionManagerDirectories(platform, homePath, executableNames), executableNames ) diff --git a/src/main/codex-usage/scanner-large-directory.test.ts b/src/main/codex-usage/scanner-large-directory.test.ts new file mode 100644 index 00000000000..58c9dc4ed2c --- /dev/null +++ b/src/main/codex-usage/scanner-large-directory.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from 'vitest' +import type { Dirent, Stats } from 'node:fs' +import type * as FsPromises from 'fs/promises' +import { join } from 'path' + +const { readdirMock, statMock } = vi.hoisted(() => ({ + readdirMock: vi.fn<(dirPath: string) => Promise>(), + statMock: vi.fn<(filePath: string) => Promise>() +})) + +vi.mock('fs/promises', async () => { + const actual = await vi.importActual('fs/promises') + return { + ...actual, + readdir: readdirMock, + stat: statMock + } +}) + +const FILE_COUNT = 125_000 +const FAKE_ROOT = join('/', 'tmp', 'orca-large-codex-home') +const RUNTIME_SESSIONS_ROOT = join(FAKE_ROOT, 'runtime', 'sessions') +const SYSTEM_SESSIONS_ROOT = join(FAKE_ROOT, 'system', 'sessions') +const RUNTIME_BULK_DIR = join(RUNTIME_SESSIONS_ROOT, 'bulk') + +vi.mock('../codex/codex-home-paths', () => ({ + getOrcaManagedCodexHomePath: () => join(FAKE_ROOT, 'runtime'), + getSystemCodexHomePath: () => join(FAKE_ROOT, 'system') +})) + +vi.mock('../codex/codex-session-bridge', () => ({ + getLegacyCopiedCodexSessionBridgeScanPreference: () => null +})) + +function dirent(name: string, kind: 'directory' | 'file'): Dirent { + return { + name, + isDirectory: () => kind === 'directory', + isFile: () => kind === 'file' + } as Dirent +} + +const largeSessionEntries = Array.from({ length: FILE_COUNT }, (_, index) => + dirent(`session-${index}.jsonl`, 'file') +) + +describe('listCodexSessionFiles large directories', () => { + it('keeps nested session scans past the JavaScript spread-argument limit', async () => { + readdirMock.mockImplementation(async (dirPath) => { + if (dirPath === RUNTIME_SESSIONS_ROOT) { + return [dirent('bulk', 'directory')] + } + if (dirPath === RUNTIME_BULK_DIR) { + return largeSessionEntries + } + if (dirPath === SYSTEM_SESSIONS_ROOT) { + return [] + } + throw new Error(`Unexpected readdir path: ${dirPath}`) + }) + statMock.mockImplementation(async (filePath) => { + const match = /session-(\d+)\.jsonl$/.exec(filePath.replaceAll('\\', '/')) + return { + dev: 1, + ino: match ? Number(match[1]) + 1 : 0 + } as Stats + }) + + const { listCodexSessionFiles } = await import('./scanner') + + await expect(listCodexSessionFiles()).resolves.toHaveLength(FILE_COUNT) + }) +}) diff --git a/src/main/codex-usage/scanner.test.ts b/src/main/codex-usage/scanner.test.ts index d060d6c5b2e..62777257b06 100644 --- a/src/main/codex-usage/scanner.test.ts +++ b/src/main/codex-usage/scanner.test.ts @@ -228,6 +228,65 @@ describe('attributeCodexUsageEvent', () => { expect(attributed?.worktreeId).toBe('repo-2::/workspace/repo/app2') }) + it('attributes cwd paths under dotdot-prefixed child directories to the worktree', async () => { + const attributed = await attributeCodexUsageEvent( + { + sessionId: 'session-1', + timestamp: '2026-04-09T10:00:00.000Z', + cwd: '/workspace/repo/..fixtures/session', + model: 'gpt-5.2-codex', + hasInferredPricing: false, + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 25, + reasoningOutputTokens: 10, + totalTokens: 125 + }, + [ + { + repoId: 'repo-1', + worktreeId: 'repo-1::/workspace/repo', + path: '/workspace/repo', + displayName: 'Repo', + canonicalPath: '/workspace/repo' + } + ] + ) + + expect(attributed?.projectKey).toBe('worktree:repo-1::/workspace/repo') + expect(attributed?.projectLabel).toBe('Repo') + expect(attributed?.worktreeId).toBe('repo-1::/workspace/repo') + }) + + it('does not attribute true parent-directory escapes to the worktree', async () => { + const attributed = await attributeCodexUsageEvent( + { + sessionId: 'session-1', + timestamp: '2026-04-09T10:00:00.000Z', + cwd: '/workspace/repo/../other/session', + model: 'gpt-5.2-codex', + hasInferredPricing: false, + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 25, + reasoningOutputTokens: 10, + totalTokens: 125 + }, + [ + { + repoId: 'repo-1', + worktreeId: 'repo-1::/workspace/repo', + path: '/workspace/repo', + displayName: 'Repo', + canonicalPath: '/workspace/repo' + } + ] + ) + + expect(attributed?.projectKey).toBe('cwd:/workspace/repo/../other/session') + expect(attributed?.worktreeId).toBeNull() + }) + it('does not treat different Windows drives as containing paths', async () => { const attributed = await attributeCodexUsageEvent( { diff --git a/src/main/codex-usage/scanner.ts b/src/main/codex-usage/scanner.ts index aeccbe791c4..bd77a020a4e 100644 --- a/src/main/codex-usage/scanner.ts +++ b/src/main/codex-usage/scanner.ts @@ -7,6 +7,7 @@ import type { Repo } from '../../shared/types' import { areWorktreePathsEqual } from '../ipc/worktree-logic' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from '../codex/codex-home-paths' import { getLegacyCopiedCodexSessionBridgeScanPreference } from '../codex/codex-session-bridge' +import { canonicalizeUsageWorktreePaths } from '../usage-worktree-canonicalizer' import type { CodexUsageAttributedEvent, CodexUsageDailyAggregate, @@ -97,7 +98,7 @@ async function walkJsonlFiles(dirPath: string): Promise { for (const entry of entries) { const fullPath = join(dirPath, entry.name) if (entry.isDirectory()) { - files.push(...(await walkJsonlFiles(fullPath))) + appendDiscoveredFiles(files, await walkJsonlFiles(fullPath)) continue } if (entry.isFile() && entry.name.endsWith('.jsonl')) { @@ -108,6 +109,14 @@ async function walkJsonlFiles(dirPath: string): Promise { return files } +function appendDiscoveredFiles(target: string[], source: readonly string[]): void { + // Why: large session directories can exceed V8's argument limit if child + // file arrays are spread into push(). + for (const filePath of source) { + target.push(filePath) + } +} + export function getCodexSessionsDirectory(): string { // Why: Orca-launched Codex processes receive an Orca-owned CODEX_HOME, so // callers that need the primary runtime path should not consult ambient @@ -127,7 +136,7 @@ export async function listCodexSessionFiles(): Promise { const files: string[] = [] for (const dirPath of getCodexSessionDirectories()) { try { - files.push(...(await walkJsonlFiles(dirPath))) + appendDiscoveredFiles(files, await walkJsonlFiles(dirPath)) } catch { // Missing or unreadable history in one home should not hide the other. } @@ -421,14 +430,7 @@ function localDayFromTimestamp(timestamp: string): string | null { async function buildWorktreesWithCanonicalPaths( worktrees: CodexUsageWorktreeRef[] ): Promise<(CodexUsageWorktreeRef & { canonicalPath: string })[]> { - const canonicalized = await Promise.all( - worktrees.map(async (worktree) => ({ - ...worktree, - canonicalPath: await canonicalizePath(worktree.path) - })) - ) - - return canonicalized.sort((left, right) => right.canonicalPath.length - left.canonicalPath.length) + return canonicalizeUsageWorktreePaths(worktrees, canonicalizePath) } function isContainingPath(candidatePath: string, targetPath: string): boolean { @@ -446,7 +448,14 @@ function isContainingPath(candidatePath: string, targetPath: string): boolean { const isAbsoluteRelative = useWin32 ? win32.isAbsolute(relativePath) : posix.isAbsolute(relativePath) - return !isAbsoluteRelative && !relativePath.startsWith('..') && relativePath !== '.' + const parentPrefix = useWin32 ? `..${win32.sep}` : `..${posix.sep}` + // Why: `..name` is a valid child path; only `..` and `../...` escape. + return ( + !isAbsoluteRelative && + relativePath !== '..' && + !relativePath.startsWith(parentPrefix) && + relativePath !== '.' + ) } function findContainingWorktree( diff --git a/src/main/codex-usage/store.test.ts b/src/main/codex-usage/store.test.ts index 8f407b130bf..934ea9a8235 100644 --- a/src/main/codex-usage/store.test.ts +++ b/src/main/codex-usage/store.test.ts @@ -736,7 +736,7 @@ describe('CodexUsageStore', () => { } ] }) - ;(store as unknown as { refresh: typeof store.refresh }).refresh = vi.fn().mockResolvedValue({ + const refreshMock = vi.fn().mockResolvedValue({ enabled: true, isScanning: false, lastScanStartedAt: 1, @@ -744,18 +744,29 @@ describe('CodexUsageStore', () => { lastScanError: null, hasAnyCodexData: true }) - - const usage = await store.getAutomationRunUsage({ + ;(store as unknown as { refresh: typeof store.refresh }).refresh = refreshMock + const completedAt = new Date('2026-04-10T15:06:00.000Z').getTime() + const request = { worktreeId, terminalSessionId: 'tab-1', startedAt: new Date('2026-04-10T14:59:00.000Z').getTime(), - completedAt: new Date('2026-04-10T15:06:00.000Z').getTime() - }) + completedAt + } + + const usage = await store.getAutomationRunUsage(request) expect(usage.status).toBe('known') expect(usage.providerSessionId).toBe('session-1') expect(usage.cacheReadTokens).toBe(400) expect(usage.reasoningOutputTokens).toBe(100) expect(usage.estimatedCostUsd).toBeCloseTo(0.0033) + expect(refreshMock).toHaveBeenCalledWith(true) + + ;(store as unknown as { state: CodexUsagePersistedState }).state.scanState.lastScanCompletedAt = + completedAt + 1000 + refreshMock.mockClear() + await store.getAutomationRunUsage(request) + + expect(refreshMock).toHaveBeenCalledWith(false) }) }) diff --git a/src/main/codex-usage/store.ts b/src/main/codex-usage/store.ts index 7c24f9b6b32..3001bd37e64 100644 --- a/src/main/codex-usage/store.ts +++ b/src/main/codex-usage/store.ts @@ -684,7 +684,7 @@ export class CodexUsageStore { return unavailable('no_matching_session', 'Run session metadata is incomplete.') } - const scanState = await this.refresh(true) + const scanState = await this.refresh(this.shouldForceAutomationUsageScan(input.completedAt)) if (scanState.lastScanError) { return unavailable('scan_failed', scanState.lastScanError) } @@ -881,6 +881,15 @@ export class CodexUsageStore { return 'Mixed models' } + private shouldForceAutomationUsageScan(completedAt: number): boolean { + const { lastScanCompletedAt, lastScanError } = this.state.scanState + // Why: attribution needs a scan after the run finishes, but repeated + // lookups after that point should not rescan all Codex session history. + return ( + Boolean(lastScanError) || lastScanCompletedAt === null || lastScanCompletedAt < completedAt + ) + } + private async getCurrentWorktreeFingerprint(): Promise { const repos = this.store.getRepos() const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) diff --git a/src/main/codex/codex-session-bridge.ts b/src/main/codex/codex-session-bridge.ts index c0a45b3a244..7a2815f029d 100644 --- a/src/main/codex/codex-session-bridge.ts +++ b/src/main/codex/codex-session-bridge.ts @@ -61,7 +61,7 @@ function listCodexSessionJsonlFiles(rootPath: string): string[] { for (const entry of readdirSync(rootPath, { withFileTypes: true })) { const childPath = join(rootPath, entry.name) if (entry.isDirectory()) { - files.push(...listCodexSessionJsonlFiles(childPath)) + appendSessionFilePaths(files, listCodexSessionJsonlFiles(childPath)) continue } if (entry.isFile() && entry.name.endsWith('.jsonl')) { @@ -74,6 +74,14 @@ function listCodexSessionJsonlFiles(rootPath: string): string[] { return files.sort() } +function appendSessionFilePaths(target: string[], source: readonly string[]): void { + // Why: existing Codex homes can accumulate enough nested sessions to exceed + // V8's argument limit if child arrays are spread into push(). + for (const filePath of source) { + target.push(filePath) + } +} + function linkSystemCodexSessionFile( sourcePath: string, targetPath: string, diff --git a/src/main/codex/config-toml-trust.test.ts b/src/main/codex/config-toml-trust.test.ts index 763dcca5870..6b79f0ff680 100644 --- a/src/main/codex/config-toml-trust.test.ts +++ b/src/main/codex/config-toml-trust.test.ts @@ -11,9 +11,11 @@ import { } from 'fs' import { tmpdir } from 'os' import { join } from 'path' +import { escapeRegex } from '../../shared/string-utils' import { computeTrustKey, computeTrustedHash, + escapeTomlString, parseTrustKey, readHookTrustEntries, removeHookTrustEntries, @@ -341,6 +343,49 @@ describe('upsertHookTrustEntries', () => { expect(occurrences).toHaveLength(1) }) + it('collapses duplicate blocks for the same hook key while preserving unrelated hook state', () => { + const key = + 'C:\\Users\\me\\AppData\\Roaming\\orca\\codex-runtime-home\\home\\hooks.json:session_start:0:0' + const unrelatedKey = + 'C:\\Users\\me\\AppData\\Roaming\\orca\\codex-runtime-home\\home\\hooks.json:stop:0:0' + const original = [ + `[hooks.state."${escapeTomlString(key)}"]`, + 'enabled = true', + 'trusted_hash = "sha256:STALE1"', + '', + `[hooks.state."${escapeTomlString(unrelatedKey)}"]`, + 'enabled = true', + 'trusted_hash = "sha256:KEEP"', + '', + `[hooks.state."${escapeTomlString(key)}"]`, + 'enabled = false', + 'trusted_hash = "sha256:STALE2"', + '' + ].join('\r\n') + writeFileSync(configPath, original, 'utf-8') + + const entry: CodexTrustEntry = { + sourcePath: 'C:\\Users\\me\\AppData\\Roaming\\orca\\codex-runtime-home\\home\\hooks.json', + eventLabel: 'session_start', + groupIndex: 0, + handlerIndex: 0, + command: 'echo session' + } + upsertHookTrustEntries(configPath, [entry]) + + const written = readFileSync(configPath, 'utf-8') + const duplicateKeyOccurrences = written.match( + new RegExp(`\\[hooks\\.state\\."${escapeRegex(escapeTomlString(key))}"\\]`, 'g') + ) + expect(duplicateKeyOccurrences).toHaveLength(1) + expect(written).toContain(`[hooks.state."${escapeTomlString(unrelatedKey)}"]`) + expect(written).toContain('trusted_hash = "sha256:KEEP"') + expect(written).toContain('enabled = false') + expect(written).not.toContain('STALE1') + expect(written).not.toContain('STALE2') + expect(written).toContain(`trusted_hash = "${computeTrustedHash(entry)}"`) + }) + it('writes a .bak file before overwriting an existing config', () => { writeFileSync(configPath, 'model = "old"\n', 'utf-8') upsertHookTrustEntries(configPath, [ @@ -517,6 +562,96 @@ describe('upsertHookTrustEntries', () => { ) }) + it('does not treat the target hook header inside a multi-line basic string as a duplicate', () => { + const key = '/x/hooks.json:pre_tool_use:0:0' + const original = [ + `[hooks.state."${key}"]`, + 'enabled = true', + 'trusted_hash = "sha256:STALE"', + '', + '[notes]', + 'body = """', + `[hooks.state."${key}"]`, + 'is only documentation here.', + '"""', + '' + ].join('\n') + writeFileSync(configPath, original, 'utf-8') + + upsertHookTrustEntries(configPath, [ + { + sourcePath: '/x/hooks.json', + eventLabel: 'pre_tool_use', + groupIndex: 0, + handlerIndex: 0, + command: 'echo' + } + ]) + + const written = readFileSync(configPath, 'utf-8') + expect(written).toContain( + ['body = """', `[hooks.state."${key}"]`, 'is only documentation here.', '"""'].join('\n') + ) + expect(written).toContain('[notes]') + expect(written).not.toContain('sha256:STALE') + }) + + it('does not let triple quotes in comments hide an existing trust block', () => { + const key = '/x/hooks.json:pre_tool_use:0:0' + const original = [ + '# user note mentions triple quote: """', + `[hooks.state."${key}"]`, + 'enabled = true', + 'trusted_hash = "sha256:STALE"', + '' + ].join('\n') + writeFileSync(configPath, original, 'utf-8') + + upsertHookTrustEntries(configPath, [ + { + sourcePath: '/x/hooks.json', + eventLabel: 'pre_tool_use', + groupIndex: 0, + handlerIndex: 0, + command: 'echo' + } + ]) + + const written = readFileSync(configPath, 'utf-8') + expect(written).toContain('# user note mentions triple quote: """') + expect(written.match(/\[hooks\.state\."/g)).toHaveLength(1) + expect(written).not.toContain('sha256:STALE') + }) + + it('does not let triple quotes in single-line strings hide an existing trust block', () => { + const key = '/x/hooks.json:pre_tool_use:0:0' + const original = [ + 'note = "\\"\\"\\""', + 'literal_note = \'"""\'', + `[hooks.state."${key}"]`, + 'enabled = true', + 'trusted_hash = "sha256:STALE"', + '' + ].join('\n') + writeFileSync(configPath, original, 'utf-8') + + upsertHookTrustEntries(configPath, [ + { + sourcePath: '/x/hooks.json', + eventLabel: 'pre_tool_use', + groupIndex: 0, + handlerIndex: 0, + command: 'echo' + } + ]) + + const written = readFileSync(configPath, 'utf-8') + expect(written).toContain('note = "\\"\\"\\""') + expect(written).toContain('literal_note = \'"""\'') + expect(written.match(/\[hooks\.state\."/g)).toHaveLength(1) + expect(written).not.toContain('sha256:STALE') + }) + it('treats `\\"""` inside a multi-line basic string as an escaped quote, not a close', () => { // Why: a basic multi-line string with `\"` escapes must not be misread as // closing early — content and any following real header must survive intact. @@ -783,6 +918,80 @@ describe('removeHookTrustEntries', () => { expect(written).toContain('[unrelated]\nvalue = 42') }) + it('removes duplicate blocks for the requested key', () => { + const key = '/x/hooks.json:pre_tool_use:0:0' + const otherKey = '/x/hooks.json:post_tool_use:0:0' + const original = [ + `[hooks.state."${key}"]`, + 'enabled = false', + 'trusted_hash = "sha256:A"', + '', + `[hooks.state."${otherKey}"]`, + 'enabled = true', + 'trusted_hash = "sha256:OTHER"', + '', + `[hooks.state."${key}"]`, + 'enabled = true', + 'trusted_hash = "sha256:B"', + '' + ].join('\n') + writeFileSync(configPath, original, 'utf-8') + + removeHookTrustEntries(configPath, [key]) + + const written = readFileSync(configPath, 'utf-8') + expect(written).not.toContain(`[hooks.state."${key}"]`) + expect(written).not.toContain('sha256:A') + expect(written).not.toContain('sha256:B') + expect(written).toContain(`[hooks.state."${otherKey}"]`) + expect(written).toContain('sha256:OTHER') + }) + + it('does not remove the target hook header text inside a multi-line string', () => { + const key = '/x/hooks.json:pre_tool_use:0:0' + const original = [ + `[hooks.state."${key}"]`, + 'enabled = true', + 'trusted_hash = "sha256:K"', + '', + '[notes]', + 'body = """', + `[hooks.state."${key}"]`, + 'is only documentation here.', + '"""', + '' + ].join('\n') + writeFileSync(configPath, original, 'utf-8') + + removeHookTrustEntries(configPath, [key]) + + const written = readFileSync(configPath, 'utf-8') + expect(written).not.toContain('sha256:K') + expect(written).toContain('[notes]') + expect(written).toContain( + ['body = """', `[hooks.state."${key}"]`, 'is only documentation here.', '"""'].join('\n') + ) + }) + + it('does not let triple quotes in comments hide a block being removed', () => { + const key = '/x/hooks.json:pre_tool_use:0:0' + const original = [ + '# user note mentions triple quote: """', + `[hooks.state."${key}"]`, + 'enabled = true', + 'trusted_hash = "sha256:K"', + '' + ].join('\n') + writeFileSync(configPath, original, 'utf-8') + + removeHookTrustEntries(configPath, [key]) + + const written = readFileSync(configPath, 'utf-8') + expect(written).toContain('# user note mentions triple quote: """') + expect(written).not.toContain(`[hooks.state."${key}"]`) + expect(written).not.toContain('sha256:K') + }) + it('preserves the line separator when no blank line precedes the removed block', () => { // Why: regression — removeTrustBlock used to cut from match.index (the // captured leading newline) and fused the previous content into the next @@ -873,6 +1082,39 @@ describe('readHookTrustEntries', () => { expect(result.get(keyB)?.enabled).toBe(true) }) + it('does not let triple quotes in comments hide later trust entries', () => { + const key = '/x/hooks.json:pre_tool_use:0:0' + const original = [ + '# user note mentions triple quote: """', + `[hooks.state."${key}"]`, + 'enabled = true', + 'trusted_hash = "sha256:AAA"', + '' + ].join('\n') + writeFileSync(configPath, original, 'utf-8') + + const result = readHookTrustEntries(configPath) + + expect(result.get(key)).toEqual({ trustedHash: 'sha256:AAA', enabled: true }) + }) + + it('does not let triple quotes in single-line strings hide later trust entries', () => { + const key = '/x/hooks.json:pre_tool_use:0:0' + const original = [ + 'note = "\\"\\"\\""', + 'literal_note = \'"""\'', + `[hooks.state."${key}"]`, + 'enabled = true', + 'trusted_hash = "sha256:AAA"', + '' + ].join('\n') + writeFileSync(configPath, original, 'utf-8') + + const result = readHookTrustEntries(configPath) + + expect(result.get(key)).toEqual({ trustedHash: 'sha256:AAA', enabled: true }) + }) + it('unescapes `\\\\` in the block key', () => { // Why: a real Windows path on disk like C:\foo gets written escaped as // `C:\\foo` inside the TOML key — the returned Map should expose the diff --git a/src/main/codex/config-toml-trust.ts b/src/main/codex/config-toml-trust.ts index d4f2026e155..fc96ff899b2 100644 --- a/src/main/codex/config-toml-trust.ts +++ b/src/main/codex/config-toml-trust.ts @@ -307,9 +307,8 @@ export function escapeTomlString(value: string): string { } function upsertTrustBlock(content: string, key: string, hash: string): string { - const headerPattern = buildHeaderPattern(key) - const match = headerPattern.exec(content) - if (!match) { + const ranges = findTrustBlockRanges(content, key) + if (ranges.length === 0) { const block = buildTrustBlock(key, hash, true) if (content.length === 0) { return `${block}\n` @@ -320,44 +319,70 @@ function upsertTrustBlock(content: string, key: string, hash: string): string { const separator = content.endsWith('\n\n') ? '' : content.endsWith('\n') ? '\n' : '\n\n' return `${content}${separator}${block}\n` } - const headerStart = match.index + (match[1] ? match[1].length : 0) - const headerLineEnd = match.index + match[0].length - // Why: find the next top-level table header [...] so we replace ONLY this - // block. Comments and blank lines between us and the next header are part - // of our block and get rewritten — Codex itself only writes the two known - // fields, so this is safe. - const after = content.slice(headerLineEnd) - const nextHeaderRel = findNextTableHeader(after) - const blockEnd = nextHeaderRel === -1 ? content.length : headerLineEnd + nextHeaderRel + // Why: preserve a user-set `enabled = false` so a hand-disabled hook is not // silently re-enabled by the next auto-install on app start. - const existingBlock = content.slice(headerLineEnd, blockEnd) - const enabledMatch = /^[ \t]*enabled[ \t]*=[ \t]*(true|false)[ \t\r]*(?:#.*)?$/m.exec( - existingBlock - ) - const enabled = enabledMatch ? enabledMatch[1] === 'true' : true + // If duplicate blocks already exist, treat any disabled copy as authoritative + // while collapsing the malformed TOML back to one table. + const enabled = !ranges.some((range) => { + const existingBlock = content.slice(range.headerLineEnd, range.end) + const enabledMatch = /^[ \t]*enabled[ \t]*=[ \t]*(true|false)[ \t\r]*(?:#.*)?$/m.exec( + existingBlock + ) + return enabledMatch?.[1] === 'false' + }) const block = buildTrustBlock(key, hash, enabled) - return `${content.slice(0, headerStart)}${block}\n${content.slice(blockEnd)}` + let cursor = 0 + let deduped = '' + ranges.forEach((range, index) => { + deduped += content.slice(cursor, range.start) + if (index === 0) { + deduped += `${block}\n` + } + cursor = range.end + }) + return deduped + content.slice(cursor) } // Why: Codex emits the canonical form with the key double-quoted; we never // share this slot with another tool, so we don't bother accepting bare -// dotted-key variants. -// Why: accept both LF and CRLF — Windows editors (and some user-edited files) -// terminate the header line with \r\n. -// Why: TOML allows leading whitespace before headers, so accept indented -// headers — the reader does too, and a column-0-only writer would otherwise -// append a duplicate block on hand-indented configs. -// Why: trailing newline is a lookahead so a header at EOS without a final -// newline still matches (otherwise we append a duplicate block). -// Why: TOML allows `# inline comment` after `]`, so accept it before the -// line-end lookahead — otherwise a user-annotated header would force the -// no-match branch and append a duplicate block. -function buildHeaderPattern(key: string): RegExp { +// dotted-key variants. The caller applies this only to complete physical lines +// outside TOML multi-line strings. +function buildHeaderLinePattern(key: string): RegExp { const escapedKey = escapeRegex(escapeTomlString(key)) - return new RegExp( - `(^|\\r?\\n)[ \\t]*\\[hooks\\.state\\."${escapedKey}"\\][ \\t]*(?:#[^\\r\\n]*)?(?=\\r?\\n|$)` - ) + return new RegExp(`^[ \\t]*\\[hooks\\.state\\."${escapedKey}"\\][ \\t]*(?:#[^\\r\\n]*)?$`) +} + +type TrustBlockRange = { + start: number + headerLineEnd: number + end: number +} + +function findTrustBlockRanges(content: string, key: string): TrustBlockRange[] { + const headerPattern = buildHeaderLinePattern(key) + const ranges: TrustBlockRange[] = [] + let cursor = 0 + let multilineState: TomlMultilineState = { basic: false, literal: false } + while (cursor < content.length) { + const newlineIdx = content.indexOf('\n', cursor) + const lineEnd = newlineIdx === -1 ? content.length : newlineIdx + const rawLine = content.slice(cursor, lineEnd) + const line = rawLine.replace(/\r$/, '') + const nextCursor = newlineIdx === -1 ? content.length : newlineIdx + 1 + if (!isInsideTomlMultilineString(multilineState) && headerPattern.test(line)) { + const headerLineEnd = rawLine.endsWith('\r') ? lineEnd - 1 : lineEnd + const after = content.slice(headerLineEnd) + const nextHeaderRel = findNextTableHeader(after) + const blockEnd = nextHeaderRel === -1 ? content.length : headerLineEnd + nextHeaderRel + ranges.push({ start: cursor, headerLineEnd, end: blockEnd }) + cursor = Math.max(blockEnd, nextCursor) + continue + } + multilineState = updateTomlMultilineState(multilineState, line) + cursor = nextCursor + } + return ranges } function buildProjectHeaderPattern(projectPath: string): RegExp { @@ -371,22 +396,13 @@ function buildProjectHeaderPattern(projectPath: string): RegExp { // a flat regex misclassifies both cases. function findNextTableHeader(text: string): number { let cursor = 0 - let inMultilineBasic = false - let inMultilineLiteral = false + let multilineState: TomlMultilineState = { basic: false, literal: false } while (cursor < text.length) { const newlineIdx = text.indexOf('\n', cursor) const lineEnd = newlineIdx === -1 ? text.length : newlineIdx const rawLine = text.slice(cursor, lineEnd) const line = rawLine.replace(/\r$/, '') - if (inMultilineBasic) { - if (countUnescapedTripleQuote(line, '"""') % 2 === 1) { - inMultilineBasic = false - } - } else if (inMultilineLiteral) { - if (countTripleQuote(line, "'''") % 2 === 1) { - inMultilineLiteral = false - } - } else { + if (!isInsideTomlMultilineString(multilineState)) { const trimmed = line.trimStart() // Why: stop at both `[table]` and `[[array.of.tables]]` — both end our // block. Skipping `[[ ]]` here would let our slice consume past array @@ -394,14 +410,8 @@ function findNextTableHeader(text: string): number { if (trimmed.startsWith('[') && isCompleteTableHeader(trimmed)) { return cursor } - // Odd count means this line opens a multi-line string without closing it. - if (countUnescapedTripleQuote(line, '"""') % 2 === 1) { - inMultilineBasic = true - } - if (countTripleQuote(line, "'''") % 2 === 1) { - inMultilineLiteral = true - } } + multilineState = updateTomlMultilineState(multilineState, line) if (newlineIdx === -1) { return -1 } @@ -410,38 +420,6 @@ function findNextTableHeader(text: string): number { return -1 } -// Why: literal strings (`'''`) don't honor escapes, so a plain indexOf scan -// suffices. -function countTripleQuote(line: string, quote: string): number { - let count = 0 - let i = 0 - while ((i = line.indexOf(quote, i)) !== -1) { - count++ - i += 3 - } - return count -} - -// Why: basic multi-line strings honor `\"` (and `\\`) escapes. Skip past -// `\` so an escaped quote doesn't count toward triple-quote scans. -function countUnescapedTripleQuote(line: string, quote: '"""'): number { - let count = 0 - let i = 0 - while (i < line.length) { - if (line[i] === '\\' && i + 1 < line.length) { - i += 2 - continue - } - if (line.startsWith(quote, i)) { - count++ - i += 3 - continue - } - i++ - } - return count -} - // Why: walk the header byte-by-byte so `]` inside a quoted key segment // doesn't terminate us early. Basic strings honor `\` escapes; literal // strings (single quotes) don't allow escapes per TOML spec. @@ -500,6 +478,92 @@ function isCompleteTableHeader(line: string): boolean { return false } +type TomlMultilineState = { + basic: boolean + literal: boolean +} + +type TomlMultilineMode = 'basic' | 'literal' | null + +function isInsideTomlMultilineString(state: TomlMultilineState): boolean { + return state.basic || state.literal +} + +function updateTomlMultilineState(state: TomlMultilineState, line: string): TomlMultilineState { + let mode: TomlMultilineMode = state.basic ? 'basic' : state.literal ? 'literal' : null + let index = 0 + while (index < line.length) { + if (mode === 'basic') { + if (line[index] === '\\') { + index += 2 + continue + } + if (line.startsWith('"""', index)) { + mode = null + index += 3 + continue + } + index++ + continue + } + if (mode === 'literal') { + if (line.startsWith("'''", index)) { + mode = null + index += 3 + continue + } + index++ + continue + } + + const char = line[index] + if (char === '#') { + break + } + if (line.startsWith('"""', index)) { + mode = 'basic' + index += 3 + continue + } + if (line.startsWith("'''", index)) { + mode = 'literal' + index += 3 + continue + } + if (char === '"') { + index = skipTomlBasicString(line, index + 1) + continue + } + if (char === "'") { + index = skipTomlLiteralString(line, index + 1) + continue + } + index++ + } + return { basic: mode === 'basic', literal: mode === 'literal' } +} + +function skipTomlBasicString(line: string, startIndex: number): number { + let index = startIndex + while (index < line.length) { + const char = line[index] + if (char === '\\') { + index += 2 + continue + } + if (char === '"') { + return index + 1 + } + index++ + } + return index +} + +function skipTomlLiteralString(line: string, startIndex: number): number { + const endIndex = line.indexOf("'", startIndex) + return endIndex === -1 ? line.length : endIndex + 1 +} + // Why: same atomic-rename + .bak rotation pattern as writeHooksJson — a // half-written config.toml can brick a user's Codex install, so write to // tmp and rename. Random-suffix tmp name avoids cross-process races on @@ -543,19 +607,18 @@ export function removeHookTrustEntries(configPath: string, keys: readonly string } function removeTrustBlock(content: string, key: string): string { - const headerPattern = buildHeaderPattern(key) - const match = headerPattern.exec(content) - if (!match) { + const ranges = findTrustBlockRanges(content, key) + if (ranges.length === 0) { return content } - // Why: skip past the captured leading newline so we don't fuse the previous - // line into the next header (e.g. `a = 1[other]` — invalid TOML). - const cutStart = match.index + (match[1] ? match[1].length : 0) - const headerLineEnd = match.index + match[0].length - const after = content.slice(headerLineEnd) - const nextHeaderRel = findNextTableHeader(after) - const cutEnd = nextHeaderRel === -1 ? content.length : headerLineEnd + nextHeaderRel - return content.slice(0, cutStart) + content.slice(cutEnd) + + let cursor = 0 + let updated = '' + for (const range of ranges) { + updated += content.slice(cursor, range.start) + cursor = range.end + } + return updated + content.slice(cursor) } export function readHookTrustEntries(configPath: string): Map { @@ -570,29 +633,16 @@ export function readHookTrustEntries(configPath: string): Map { const prodHooks = JSON.parse(readFileSync(prodHooksPath, 'utf-8')) as { hooks: Record } - expect(devHooks.hooks.Stop?.[0]?.hooks?.[0]?.command).toBe('user-hook') - expect(prodHooks.hooks.Stop?.[0]?.hooks?.[0]?.command).toBe('user-hook') + expect( + devHooks.hooks.Stop?.some((definition) => + definition.hooks?.some((hook) => hook.command === 'user-hook') + ) + ).toBe(true) + expect( + prodHooks.hooks.Stop?.some((definition) => + definition.hooks?.some((hook) => hook.command === 'user-hook') + ) + ).toBe(true) expect( devHooks.hooks.Stop?.some((definition) => definition.hooks?.[0]?.command?.includes('codex-hook') @@ -243,15 +251,62 @@ describe('CodexHookService', () => { { matcher?: string; hooks?: { command?: string; statusMessage?: string }[] }[] > } - expect(runtimeHooks.hooks.Stop?.[0]?.matcher).toBe('*') - expect(runtimeHooks.hooks.Stop?.[0]?.hooks?.[0]?.command).toBe('user-hook') - expect(runtimeHooks.hooks.Stop?.[0]?.hooks?.[0]?.statusMessage).toBe('Running user hook') + expect(runtimeHooks.hooks.Stop?.[1]?.matcher).toBe('*') + expect(runtimeHooks.hooks.Stop?.[1]?.hooks?.[0]?.command).toBe('user-hook') + expect(runtimeHooks.hooks.Stop?.[1]?.hooks?.[0]?.statusMessage).toBe('Running user hook') const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:0:0`)) + expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:1:0`)) expect(runtimeToml).not.toContain(hookTrustHeader(`${systemHooksPath}:stop:0:0`)) }) + it('runs managed PostToolUse status before mirrored user hooks', () => { + const systemCodexHome = join(tmpHome, '.codex') + const systemHooksPath = join(systemCodexHome, 'hooks.json') + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync( + systemHooksPath, + `${JSON.stringify({ + hooks: { + PostToolUse: [{ hooks: [{ type: 'command', command: 'slow-user-post-tool-hook' }] }] + } + })}\n`, + 'utf-8' + ) + writeFileSync( + join(systemCodexHome, 'config.toml'), + upsertHookTrustEntriesInContent('model = "system-model"\n', [ + { + sourcePath: systemHooksPath, + eventLabel: 'post_tool_use', + groupIndex: 0, + handlerIndex: 0, + command: 'slow-user-post-tool-hook' + } + ]), + 'utf-8' + ) + + expect(new CodexHookService().install().state).toBe('installed') + + const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') + const managedHooksPath = join(managedCodexHome, 'hooks.json') + const runtimeHooks = JSON.parse(readFileSync(managedHooksPath, 'utf-8')) as { + hooks: Record + } + + expect(runtimeHooks.hooks.PostToolUse?.[0]?.hooks?.[0]?.command).toContain('codex-hook') + expect(runtimeHooks.hooks.PostToolUse?.[1]?.hooks?.[0]?.command).toBe( + 'slow-user-post-tool-hook' + ) + + const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') + expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:post_tool_use:0:0`)) + expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:post_tool_use:1:0`)) + expect(runtimeToml).not.toContain(hookTrustHeader(`${systemHooksPath}:post_tool_use:0:0`)) + }) + it('mirrors system user hook approvals when the system trust indices are stale', () => { const systemCodexHome = join(tmpHome, '.codex') const systemHooksPath = join(systemCodexHome, 'hooks.json') @@ -300,6 +355,7 @@ describe('CodexHookService', () => { const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:0:0`)) expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:1:0`)) + expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:2:0`)) expect(runtimeToml).not.toContain(hookTrustHeader(`${systemHooksPath}:stop:0:0`)) expect(runtimeToml).not.toContain(hookTrustHeader(`${systemHooksPath}:stop:1:0`)) }) @@ -382,6 +438,7 @@ describe('CodexHookService', () => { const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:0:0`)) + expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:1:0`)) for (const command of pluginCommands) { expect(runtimeToml).not.toContain(command) } @@ -481,7 +538,7 @@ describe('CodexHookService', () => { const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') const managedHooksPath = join(managedCodexHome, 'hooks.json') - const runtimeUserTrustHeader = hookTrustHeader(`${managedHooksPath}:stop:0:0`) + const runtimeUserTrustHeader = hookTrustHeader(`${managedHooksPath}:stop:1:0`) expect(readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8')).toContain( runtimeUserTrustHeader ) @@ -491,7 +548,7 @@ describe('CodexHookService', () => { const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') expect(runtimeToml).not.toContain(runtimeUserTrustHeader) - expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:1:0`)) + expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:0:0`)) }) it('refreshes mirrored system user hooks when the system hooks file changes', () => { @@ -558,13 +615,31 @@ describe('CodexHookService', () => { const service = new CodexHookService() expect(service.install().state).toBe('installed') + const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') + const managedHooksPath = join(managedCodexHome, 'hooks.json') + const runtimeTomlPath = join(managedCodexHome, 'config.toml') + const permissionRequestHeader = hookTrustHeader(`${managedHooksPath}:permission_request:0:0`) + const installedToml = readFileSync(runtimeTomlPath, 'utf-8') + const permissionRequestIndex = installedToml.indexOf(permissionRequestHeader) + expect(permissionRequestIndex).not.toBe(-1) + const nextHeaderIndex = installedToml.indexOf( + '\n[', + permissionRequestIndex + permissionRequestHeader.length + ) + const permissionRequestBlock = installedToml.slice( + permissionRequestIndex, + nextHeaderIndex === -1 ? installedToml.length : nextHeaderIndex + ) + writeFileSync( + runtimeTomlPath, + `${installedToml.trimEnd()}\n\n${permissionRequestBlock.trimEnd()}\n`, + 'utf-8' + ) const status = service.refreshRuntimeUserHooks() expect(status.state).toBe('not_installed') expect(status.managedHooksPresent).toBe(false) - const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') - const managedHooksPath = join(managedCodexHome, 'hooks.json') const runtimeHooks = JSON.parse(readFileSync(managedHooksPath, 'utf-8')) as { hooks: Record } @@ -574,7 +649,7 @@ describe('CodexHookService', () => { expect(runtimeCommands).toEqual(['user-stop-hook']) expect(runtimeCommands.some((command) => command.includes('codex-hook'))).toBe(false) - const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') + const runtimeToml = readFileSync(runtimeTomlPath, 'utf-8') expect(runtimeToml).toContain( `${hookTrustHeader(`${managedHooksPath}:stop:0:0`)}\nenabled = false` ) @@ -644,6 +719,47 @@ describe('CodexHookService', () => { expect(systemToml).not.toContain(':session_start:0:0') }) + it('removes very large legacy Orca-managed hook lists from system ~/.codex', () => { + const systemCodexHome = join(tmpHome, '.codex') + const systemHooksPath = join(systemCodexHome, 'hooks.json') + const legacyScriptPath = join( + tmpHome, + '.orca', + 'agent-hooks', + process.platform === 'win32' ? 'codex-hook.cmd' : 'codex-hook.sh' + ) + const legacyCommand = + process.platform === 'win32' ? legacyScriptPath : wrapPosixHookCommand(legacyScriptPath) + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync( + systemHooksPath, + `${JSON.stringify({ + hooks: { + Stop: Array.from({ length: 130_000 }, () => ({ + hooks: [{ type: 'command', command: legacyCommand }] + })) + } + })}\n`, + 'utf-8' + ) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + try { + expect(new CodexHookService().install().state).toBe('installed') + + expect(warnSpy).not.toHaveBeenCalledWith( + '[codex-hook-service] failed to clean legacy Codex hooks', + expect.anything() + ) + } finally { + warnSpy.mockRestore() + } + const systemHooks = JSON.parse(readFileSync(systemHooksPath, 'utf-8')) as { + hooks: Record + } + expect(systemHooks.hooks.Stop).toBeUndefined() + }) + it('removes the legacy Orca Codex profile file when it only contains managed hooks', () => { const systemCodexHome = join(tmpHome, '.codex') const profilePath = join(systemCodexHome, 'orca-agent-status.config.toml') @@ -911,6 +1027,59 @@ describe('CodexHookService', () => { expect(trustConfig).not.toContain('model = "runtime-model"') }) + it('repairs duplicate managed SessionStart trust tables on restart install', () => { + const systemCodexHome = join(tmpHome, '.codex') + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "system-model"\n', 'utf-8') + + const service = new CodexHookService() + expect(service.install().state).toBe('installed') + + const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') + const managedHooksPath = join(managedCodexHome, 'hooks.json') + const runtimeTomlPath = join(managedCodexHome, 'config.toml') + const sessionStartHeader = hookTrustHeader(`${managedHooksPath}:session_start:0:0`) + const installedToml = readFileSync(runtimeTomlPath, 'utf-8') + const sessionStartIndex = installedToml.indexOf(sessionStartHeader) + expect(sessionStartIndex).not.toBe(-1) + const nextHeaderIndex = installedToml.indexOf( + '\n[', + sessionStartIndex + sessionStartHeader.length + ) + const sessionStartBlock = installedToml + .slice(sessionStartIndex, nextHeaderIndex === -1 ? installedToml.length : nextHeaderIndex) + .trimEnd() + const staleDisabledBlock = sessionStartBlock + .replace('enabled = true', 'enabled = false') + .replace(/trusted_hash = "[^"]+"/, 'trusted_hash = "sha256:STALE_DISABLED"') + const staleEnabledBlock = sessionStartBlock.replace( + /trusted_hash = "[^"]+"/, + 'trusted_hash = "sha256:STALE_ENABLED"' + ) + writeFileSync( + runtimeTomlPath, + `${installedToml.slice( + 0, + sessionStartIndex + )}${staleDisabledBlock}\n\n${staleEnabledBlock}${installedToml.slice( + nextHeaderIndex === -1 ? installedToml.length : nextHeaderIndex + )}`, + 'utf-8' + ) + expect(readFileSync(runtimeTomlPath, 'utf-8').split(sessionStartHeader)).toHaveLength(3) + + // Why: preserving `enabled = false` is the repair contract; status can be + // partial because the user-disabled managed hook remains disabled. + expect(['installed', 'partial']).toContain(service.install().state) + + const repairedToml = readFileSync(runtimeTomlPath, 'utf-8') + expect(repairedToml.split(sessionStartHeader)).toHaveLength(2) + expect(repairedToml).toContain('enabled = false') + expect(repairedToml).not.toContain('STALE_DISABLED') + expect(repairedToml).not.toContain('STALE_ENABLED') + expect(repairedToml).toContain('model = "system-model"') + }) + it('preserves runtime-only project trust while honoring system project untrust', () => { const systemCodexHome = join(tmpHome, '.codex') mkdirSync(systemCodexHome, { recursive: true }) diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index bda41ad1b22..aa9b6f7b060 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -76,6 +76,10 @@ const CODEX_EVENT_LABEL: Record<(typeof CODEX_EVENTS)[number], CodexEventLabel> Stop: 'stop' } +const CODEX_MANAGED_EVENT_LABELS = new Set( + CODEX_EVENTS.map((eventName) => CODEX_EVENT_LABEL[eventName]) +) + const CODEX_HOOK_EVENT_LABEL: Record = { ...CODEX_EVENT_LABEL, PreCompact: 'pre_compact', @@ -427,6 +431,20 @@ function collectMirroredRuntimeUserHookTrustEntries( return entries } +function moveMirroredRuntimeUserTrustAfterManagedStatusHook( + entries: readonly MirroredRuntimeUserHookTrustEntry[] +): MirroredRuntimeUserHookTrustEntry[] { + return entries.map(({ entry, enabled }) => { + if (!CODEX_MANAGED_EVENT_LABELS.has(entry.eventLabel)) { + return { entry, enabled } + } + return { + entry: { ...entry, groupIndex: entry.groupIndex + 1 }, + enabled + } + }) +} + function escapeRegex(value: string): string { return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') } @@ -491,7 +509,10 @@ function cleanupLegacySystemManagedHooks(): void { definitions, isManagedCommand ) - trustEntries.push(...eventTrustEntries) + // Why: user hook configs can be large; avoid the argument limit from push(...entries). + for (const entry of eventTrustEntries) { + trustEntries.push(entry) + } const cleaned = removeManagedCommands(definitions, isManagedCommand) removedManagedHook ||= definitions.some((definition) => hookDefinitionHasManagedCommand(definition, isManagedCommand) @@ -643,7 +664,9 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { // Why: worktreeId embeds a filesystem path, so hand-building JSON in POSIX // shell is not safe once a path contains quotes or newlines. Post the raw // hook payload plus metadata as form fields and let the receiver parse it. + // Timeout caps best-effort hook posts if the local listener stalls. 'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex" \\', + ' --connect-timeout 0.5 --max-time 1.5 \\', ' -H "Content-Type: application/x-www-form-urlencoded" \\', ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', @@ -696,9 +719,9 @@ export class CodexHookService { let presentCount = 0 for (const eventName of CODEX_EVENTS) { const definitions = Array.isArray(config.hooks?.[eventName]) ? config.hooks![eventName]! : [] - // Why: install() appends our managed definition at the end, so its - // group index is the LAST match. Picking the first match would - // misreport stale duplicates as trust-missing. + // Why: older installs appended this command, while current installs + // prepend it. Picking the last match keeps status repair conservative + // if duplicate managed definitions survive from a stale hooks.json. let foundGroupIndex = -1 let foundHandlerIndex = -1 definitions.forEach((definition, idx) => { @@ -823,7 +846,9 @@ export class CodexHookService { // hook sits in the "review required" pile. We compute the trust hash for // each managed entry as we install it and persist it alongside hooks.json // so the user does not have to /hooks-approve after every install. - const mirroredUserTrustEntries = hookPlan.trustEntries + const mirroredUserTrustEntries = moveMirroredRuntimeUserTrustAfterManagedStatusHook( + hookPlan.trustEntries + ) const trustEntries: CodexTrustEntry[] = mirroredUserTrustEntries.map(({ entry }) => entry) for (const eventName of CODEX_EVENTS) { const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] @@ -831,15 +856,14 @@ export class CodexHookService { const definition: HookDefinition = { hooks: [{ type: 'command', command }] } - nextHooks[eventName] = [...cleaned, definition] - // Why: our managed definition is appended after `cleaned`, so its - // group index in the resulting hooks.json is `cleaned.length`. The - // handler is always the first (and only) entry in the group, so - // handler index is 0. Codex's hook_key uses these positional indices. + nextHooks[eventName] = [definition, ...cleaned] + // Why: the status hook must run before user hooks so a slow + // PostToolUse/Stop hook cannot leave the sidebar stuck on the previous + // state while Codex visibly reports that hooks are still running. trustEntries.push({ sourcePath: configPath, eventLabel: CODEX_EVENT_LABEL[eventName], - groupIndex: cleaned.length, + groupIndex: 0, handlerIndex: 0, command }) @@ -857,8 +881,10 @@ export class CodexHookService { // Why: system user hook approvals are mirrored into runtime CODEX_HOME. // If the user later revokes approval in ~/.codex/config.toml, preserving // all old runtime [hooks.state.*] blocks would keep Orca Codex trusted. - removeStaleRuntimeHookTrustEntries(tomlPath, configPath, trustEntries) + // Upsert first so duplicate repair can preserve a disabled managed copy + // before stale cleanup removes old managed hook keys. upsertHookTrustEntries(tomlPath, trustEntries) + removeStaleRuntimeHookTrustEntries(tomlPath, configPath, trustEntries) applyMirroredRuntimeUserHookTrustStates(tomlPath, mirroredUserTrustEntries) } catch (error) { return { @@ -998,8 +1024,10 @@ export class CodexHookService { syncSystemConfigIntoManagedCodexHome() // Why: this path is used when Orca status hooks are disabled. The // runtime CODEX_HOME should keep user hooks, but not Orca-managed trust. - removeStaleRuntimeHookTrustEntries(tomlPath, configPath, trustEntries) + // Write current mirrored user trust first so stale cleanup compares + // against current hashes while deleting old managed hook keys. upsertHookTrustEntries(tomlPath, trustEntries) + removeStaleRuntimeHookTrustEntries(tomlPath, configPath, trustEntries) applyMirroredRuntimeUserHookTrustStates(tomlPath, hookPlan.trustEntries) } catch (error) { return { diff --git a/src/main/command-code/hook-service.ts b/src/main/command-code/hook-service.ts index 516d4c0bad7..5d6161e6c69 100644 --- a/src/main/command-code/hook-service.ts +++ b/src/main/command-code/hook-service.ts @@ -163,7 +163,9 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { 'if [ -z "$payload" ]; then', ' exit 0', 'fi', + // Timeout caps best-effort hook posts if the local listener stalls. 'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/command-code" \\', + ' --connect-timeout 0.5 --max-time 1.5 \\', ' -H "Content-Type: application/x-www-form-urlencoded" \\', ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', diff --git a/src/main/computer/desktop-script-provider-client.test.ts b/src/main/computer/desktop-script-provider-client.test.ts index cdc5436dc7e..db9a2e5e509 100644 --- a/src/main/computer/desktop-script-provider-client.test.ts +++ b/src/main/computer/desktop-script-provider-client.test.ts @@ -1,14 +1,40 @@ /* eslint-disable max-lines -- Why: desktop provider contract coverage shares one mocked bridge harness. */ import { execFile } from 'child_process' -import { readFileSync } from 'fs' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { DesktopScriptProviderClient } from './desktop-script-provider-client' +const { operationFiles, mkdtempMock, rmMock, writeFileMock } = vi.hoisted(() => { + const files = new Map() + return { + operationFiles: files, + mkdtempMock: vi.fn(async (prefix: string) => `${prefix}${files.size}`), + rmMock: vi.fn(async () => undefined), + writeFileMock: vi.fn(async (filePath: string, data: string | Buffer) => { + files.set(filePath, Buffer.isBuffer(data) ? data.toString('utf8') : data) + }) + } +}) + vi.mock('child_process', () => ({ execFile: vi.fn() })) +vi.mock('fs/promises', () => ({ + mkdtemp: mkdtempMock, + rm: rmMock, + writeFile: writeFileMock +})) + describe('DesktopScriptProviderClient', () => { + afterEach(() => { + vi.useRealTimers() + vi.mocked(execFile).mockReset() + operationFiles.clear() + mkdtempMock.mockClear() + rmMock.mockClear() + writeFileMock.mockClear() + }) + it('normalizes list-apps responses', async () => { mockBridgeResponse({ ok: true, @@ -81,6 +107,34 @@ describe('DesktopScriptProviderClient', () => { expect(typeof operationPath).toBe('string') }) + it('uses bridge screenshot dimensions when the native payload is downscaled', async () => { + mockBridgeResponse({ + ok: true, + snapshot: { + ...sampleBridgeSnapshot('Text Editor', 'initial'), + screenshotWidth: 150, + screenshotHeight: 100, + screenshotScale: 0.5 + } + }) + + const client = new DesktopScriptProviderClient('linux', '/tmp/runtime.py') + + const result = await client.snapshot({ app: 'Text Editor' }) + + expect(result.screenshot).toEqual({ + data: 'iVBORw0KGgo=', + format: 'png', + width: 150, + height: 100, + scale: 0.5 + }) + expect(result.snapshot.window).toMatchObject({ + width: 300, + height: 200 + }) + }) + it('targets cached elements by session and explicit window id', async () => { mockBridgeResponse({ ok: true, @@ -181,6 +235,48 @@ describe('DesktopScriptProviderClient', () => { ).rejects.toMatchObject({ code: 'element_not_found' }) }) + it('bounds cached desktop snapshots while keeping recent aliases usable', async () => { + const snapshotCount = 40 + for (let index = 0; index < snapshotCount; index++) { + mockBridgeResponse({ + ok: true, + snapshot: { + ...sampleBridgeSnapshot(`App ${index}`, `value ${index}`), + snapshotId: `snap-${index}`, + app: { name: `App ${index}`, bundleIdentifier: `bundle.${index}`, pid: 100 + index }, + windowId: 1_000 + index, + windowTitle: `Window ${index}` + } + }) + } + mockBridgeResponse( + { + ok: true, + snapshot: sampleBridgeSnapshot('App 39', 'clicked') + }, + (operation) => { + expect(operation).toMatchObject({ + tool: 'click', + app: 'App 39', + element: expect.objectContaining({ index: 0 }) + }) + } + ) + + const client = new DesktopScriptProviderClient('linux', '/tmp/runtime.py') + + for (let index = 0; index < snapshotCount; index++) { + await client.snapshot({ app: `App ${index}` }) + } + + await expect(client.action('click', { app: 'App 0', elementIndex: 0 })).rejects.toMatchObject({ + code: 'element_not_found' + }) + await expect( + client.action('click', { app: 'App 39', elementIndex: 0, noScreenshot: true }) + ).resolves.toMatchObject({ snapshot: { app: { name: 'App 39' } } }) + }) + it('explains screenshot capture failures while keeping accessibility state usable', async () => { mockBridgeResponse({ ok: true, @@ -291,6 +387,27 @@ describe('DesktopScriptProviderClient', () => { }) }) + it('rejects when the desktop provider subprocess ignores the exec timeout', async () => { + vi.useFakeTimers() + const kill = vi.fn() + vi.mocked(execFile).mockImplementationOnce(() => ({ kill }) as never) + + const client = new DesktopScriptProviderClient('linux', '/tmp/runtime.py') + const promise = client.listApps() + let settled = false + void promise.catch(() => { + settled = true + }) + + await vi.waitFor(() => expect(execFile).toHaveBeenCalled(), { timeout: 1_000 }) + await vi.advanceTimersByTimeAsync(30_001) + await vi.runOnlyPendingTimersAsync() + + expect(settled).toBe(true) + expect(kill).toHaveBeenCalled() + await expect(promise).rejects.toMatchObject({ code: 'action_timeout' }) + }) + it('maps action-specific bridge errors to actionable codes', async () => { mockBridgeResponse({ ok: false, error: 'element value is not settable' }) mockBridgeResponse({ ok: false, error: 'Raise is not a valid secondary action' }) @@ -375,7 +492,11 @@ function mockBridgeResponse( vi.mocked(execFile).mockImplementationOnce((_command, _args, _options, callback) => { const operationPath = _args?.at(-1) if (inspectOperation && typeof operationPath === 'string') { - inspectOperation(JSON.parse(readFileSync(operationPath, 'utf8')) as Record) + const operation = operationFiles.get(operationPath) + if (!operation) { + throw new Error(`Missing mocked operation file: ${operationPath}`) + } + inspectOperation(JSON.parse(operation) as Record) } const done = callback as (error: Error | null, stdout: string, stderr: string) => void done(null, JSON.stringify(response), '') diff --git a/src/main/computer/desktop-script-provider-client.ts b/src/main/computer/desktop-script-provider-client.ts index 7d0c7a4a8a0..f08b113aa58 100644 --- a/src/main/computer/desktop-script-provider-client.ts +++ b/src/main/computer/desktop-script-provider-client.ts @@ -71,6 +71,9 @@ type BridgeSnapshot = { windowId?: number | null windowBounds?: BridgeFrame | null screenshotPngBase64?: string | null + screenshotWidth?: number | null + screenshotHeight?: number | null + screenshotScale?: number | null coordinateSpace?: 'window' truncation?: { truncated?: boolean @@ -154,6 +157,12 @@ type BridgeRequest = { } const REQUEST_TIMEOUT_MS = 30_000 +const MAX_CACHED_DESKTOP_SNAPSHOTS = 32 + +type CachedSnapshotEntry = { + snapshot: BridgeSnapshot + keys: string[] +} export function shouldUseDesktopScriptProvider(): boolean { return desktopScriptPlatform() !== null && resolveDesktopScriptProviderPath() !== null @@ -161,6 +170,7 @@ export function shouldUseDesktopScriptProvider(): boolean { export class DesktopScriptProviderClient { private readonly snapshots = new Map() + private readonly snapshotEntries: CachedSnapshotEntry[] = [] private providerCapabilities: ComputerProviderCapabilities | null = null constructor( @@ -347,31 +357,31 @@ export class DesktopScriptProviderClient { snapshot: BridgeSnapshot, params: Record ): void { - const namespace = snapshotNamespace(params) - for (const key of [ - query, - snapshot.app.name, - snapshot.app.bundleId, - snapshot.app.bundleIdentifier, - String(snapshot.app.pid), - ...snapshotKeysForWindow(query, snapshot), - ...snapshotKeysForWindow(snapshot.app.name, snapshot), - ...(snapshot.app.bundleId ? snapshotKeysForWindow(snapshot.app.bundleId, snapshot) : []), - ...(snapshot.app.bundleIdentifier - ? snapshotKeysForWindow(snapshot.app.bundleIdentifier, snapshot) - : []), - ...snapshotKeysForWindowIndex(query, params), - ...snapshotKeysForWindowIndex(snapshot.app.name, params), - ...(snapshot.app.bundleId ? snapshotKeysForWindowIndex(snapshot.app.bundleId, params) : []), - ...(snapshot.app.bundleIdentifier - ? snapshotKeysForWindowIndex(snapshot.app.bundleIdentifier, params) - : []) - ]) { - if (key) { - if (!isExplicitSnapshotNamespace(namespace)) { - this.snapshots.set(key.toLowerCase(), snapshot) + const keys = snapshotCacheKeys(query, snapshot, params) + if (keys.length === 0) { + return + } + + const entry = { snapshot, keys } + this.snapshotEntries.push(entry) + for (const key of keys) { + this.snapshots.set(key, snapshot) + } + this.pruneSnapshotCache() + } + + private pruneSnapshotCache(): void { + while (this.snapshotEntries.length > MAX_CACHED_DESKTOP_SNAPSHOTS) { + const expired = this.snapshotEntries.shift() + if (!expired) { + return + } + for (const key of expired.keys) { + // Why: newer snapshots can reuse the same alias; only remove aliases + // that still point at the large snapshot payload being evicted. + if (this.snapshots.get(key) === expired.snapshot) { + this.snapshots.delete(key) } - this.snapshots.set(namespacedSnapshotKey(namespace, key), snapshot) } } } @@ -428,41 +438,80 @@ function execBridge( ] : [scriptPath, operationPath] return new Promise((resolve, reject) => { - execFile( - command, - args, - { - env: process.env, - maxBuffer: 20 * 1024 * 1024, - timeout: REQUEST_TIMEOUT_MS, - windowsHide: true - }, - (error, stdout, stderr) => { - if (error) { - const message = stderr.trim() || stdout.trim() || error.message - reject( - error.killed - ? new RuntimeClientError('action_timeout', message) - : mapBridgeError(message) - ) - return - } - resolve({ stdout, stderr }) + let child: ReturnType | null = null + let settled = false + + const finish = (error: Error | null, result?: { stdout: string; stderr: string }): void => { + if (settled) { + return } - ) + settled = true + clearTimeout(timeout) + if (error) { + reject(error) + return + } + resolve(result ?? { stdout: '', stderr: '' }) + } + + // Why: Node's execFile timeout only sends a signal; a provider process that + // ignores it can still leave the computer-use action promise pending. + const timeout = setTimeout(() => { + child?.kill() + finish( + new RuntimeClientError( + 'action_timeout', + `desktop provider timed out after ${REQUEST_TIMEOUT_MS}ms` + ) + ) + }, REQUEST_TIMEOUT_MS) + + try { + child = execFile( + command, + args, + { + env: process.env, + maxBuffer: 20 * 1024 * 1024, + timeout: REQUEST_TIMEOUT_MS, + windowsHide: true + }, + (error, stdout, stderr) => { + if (error) { + const message = stderr.trim() || stdout.trim() || error.message + finish( + error.killed + ? new RuntimeClientError('action_timeout', message) + : mapBridgeError(message) + ) + return + } + finish(null, { stdout, stderr }) + } + ) + } catch (error) { + finish(mapBridgeError(error instanceof Error ? error.message : String(error))) + } }) } function renderSnapshot(snapshot: BridgeSnapshot, noScreenshot: boolean): ComputerSnapshotResult { const bounds = snapshot.windowBounds const treeText = renderTreeText(snapshot) + // Why: Linux/Windows providers may downscale screenshots to cap IPC payloads, + // while window bounds remain the unscaled coordinate space for actions. + const screenshotWidth = + positiveRoundedNumber(snapshot.screenshotWidth) ?? Math.max(1, Math.round(bounds?.width ?? 1)) + const screenshotHeight = + positiveRoundedNumber(snapshot.screenshotHeight) ?? Math.max(1, Math.round(bounds?.height ?? 1)) + const screenshotScale = positiveNumber(snapshot.screenshotScale) ?? 1 const screenshot = snapshot.screenshotPngBase64 ? { data: snapshot.screenshotPngBase64, format: 'png' as const, - width: Math.max(1, Math.round(bounds?.width ?? 1)), - height: Math.max(1, Math.round(bounds?.height ?? 1)), - scale: 1 + width: screenshotWidth, + height: screenshotHeight, + scale: screenshotScale } : null return { @@ -514,6 +563,15 @@ function renderSnapshot(snapshot: BridgeSnapshot, noScreenshot: boolean): Comput } } +function positiveNumber(value: number | null | undefined): number | null { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null +} + +function positiveRoundedNumber(value: number | null | undefined): number | null { + const numberValue = positiveNumber(value) + return numberValue === null ? null : Math.max(1, Math.round(numberValue)) +} + function fallbackSnapshotId(snapshot: BridgeSnapshot): string { const appRef = snapshot.app.bundleId ?? snapshot.app.bundleIdentifier ?? snapshot.app.name return `${appRef}:${snapshot.app.pid}:${snapshot.windowId ?? 'window'}` @@ -626,6 +684,43 @@ function elementParam( return element } +function snapshotCacheKeys( + query: string, + snapshot: BridgeSnapshot, + params: Record +): string[] { + const namespace = snapshotNamespace(params) + const keys = new Set() + for (const key of [ + query, + snapshot.app.name, + snapshot.app.bundleId, + snapshot.app.bundleIdentifier, + String(snapshot.app.pid), + ...snapshotKeysForWindow(query, snapshot), + ...snapshotKeysForWindow(snapshot.app.name, snapshot), + ...(snapshot.app.bundleId ? snapshotKeysForWindow(snapshot.app.bundleId, snapshot) : []), + ...(snapshot.app.bundleIdentifier + ? snapshotKeysForWindow(snapshot.app.bundleIdentifier, snapshot) + : []), + ...snapshotKeysForWindowIndex(query, params), + ...snapshotKeysForWindowIndex(snapshot.app.name, params), + ...(snapshot.app.bundleId ? snapshotKeysForWindowIndex(snapshot.app.bundleId, params) : []), + ...(snapshot.app.bundleIdentifier + ? snapshotKeysForWindowIndex(snapshot.app.bundleIdentifier, params) + : []) + ]) { + if (!key) { + continue + } + if (!isExplicitSnapshotNamespace(namespace)) { + keys.add(key.toLowerCase()) + } + keys.add(namespacedSnapshotKey(namespace, key)) + } + return [...keys] +} + function snapshotKeysForWindow(query: string, snapshot: BridgeSnapshot): string[] { return snapshot.windowId === null || snapshot.windowId === undefined ? [] diff --git a/src/main/computer/key-mapping.test.ts b/src/main/computer/key-mapping.test.ts index 2604df8ab93..6b599cd483c 100644 --- a/src/main/computer/key-mapping.test.ts +++ b/src/main/computer/key-mapping.test.ts @@ -6,6 +6,7 @@ describe('parseKey', () => { ['a', { key: 'a', modifiers: [] }], ['7', { key: '7', modifiers: [] }], [';', { key: ';', modifiers: [] }], + ['+', { key: '+', modifiers: [] }], ['Return', { key: 'Enter', modifiers: [] }], ['Escape', { key: 'Escape', modifiers: [] }], ['BackSpace', { key: 'Backspace', modifiers: [] }], @@ -42,6 +43,8 @@ describe('parseKey', () => { ['cmd+a', { key: 'a', modifiers: ['Meta'] }], ['command+a', { key: 'a', modifiers: ['Meta'] }], ['CmdOrCtrl+a', { key: 'a', modifiers: [process.platform === 'darwin' ? 'Meta' : 'Ctrl'] }], + ['ctrl++', { key: '+', modifiers: ['Ctrl'] }], + ['ctrl+shift++', { key: '+', modifiers: ['Ctrl', 'Shift'] }], ['super+Left', { key: 'ArrowLeft', modifiers: ['Meta'] }], ['win+Right', { key: 'ArrowRight', modifiers: ['Meta'] }] ])('maps chord %s', (input, expected) => { diff --git a/src/main/computer/key-mapping.ts b/src/main/computer/key-mapping.ts index cc6cb414ac1..4634f241268 100644 --- a/src/main/computer/key-mapping.ts +++ b/src/main/computer/key-mapping.ts @@ -50,10 +50,7 @@ const KEY_NAMES: Record = { } export function parseKey(input: string): KeyChord { - const parts = input - .split('+') - .map((part) => part.trim()) - .filter(Boolean) + const parts = splitKeyChord(input) if (parts.length === 0) { throw new RuntimeClientError('invalid_argument', 'key must not be empty') } @@ -66,6 +63,28 @@ export function parseKey(input: string): KeyChord { } } +function splitKeyChord(input: string): string[] { + const trimmed = input.trim() + // Why: `+` is both the chord separator and a printable key users can press. + if (trimmed === '+') { + return ['+'] + } + if (trimmed.endsWith('+')) { + return [ + ...trimmed + .slice(0, -1) + .split('+') + .map((part) => part.trim()) + .filter(Boolean), + '+' + ] + } + return input + .split('+') + .map((part) => part.trim()) + .filter(Boolean) +} + function parseModifier(input: string): string { const platformModifier = PLATFORM_MODIFIER_NAMES[input.toLowerCase()] if (platformModifier) { diff --git a/src/main/computer/macos-computer-use-permissions.test.ts b/src/main/computer/macos-computer-use-permissions.test.ts index d2c38856025..12e11788a3f 100644 --- a/src/main/computer/macos-computer-use-permissions.test.ts +++ b/src/main/computer/macos-computer-use-permissions.test.ts @@ -1,3 +1,5 @@ +/* oxlint-disable max-lines -- Why: these macOS permission flows share one +mocked child_process/fs platform harness. */ import { execFileSync, spawn, spawnSync } from 'child_process' import { mkdtemp, readFile, rm, stat } from 'fs/promises' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -13,14 +15,15 @@ vi.mock('child_process', () => ({ execFileSync: vi.fn(), spawn: vi.fn(() => { const child = { - stdout: { on: vi.fn(), setEncoding: vi.fn() }, - stderr: { on: vi.fn(), setEncoding: vi.fn() }, + stdout: { off: vi.fn(), on: vi.fn(), setEncoding: vi.fn() }, + stderr: { off: vi.fn(), on: vi.fn(), setEncoding: vi.fn() }, on: vi.fn((event: string, callback: (status: number) => void) => { if (event === 'close') { queueMicrotask(() => callback(0)) } return child }), + off: vi.fn(() => child), unref: vi.fn() } return child @@ -63,6 +66,7 @@ describe('openComputerUsePermissions', () => { }) afterEach(() => { + vi.useRealTimers() setPlatform(originalPlatform) }) @@ -206,14 +210,15 @@ describe('openComputerUsePermissions', () => { const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions') resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app') const child = { - stdout: { on: vi.fn(), setEncoding: vi.fn() }, - stderr: { on: vi.fn(), setEncoding: vi.fn() }, + stdout: { off: vi.fn(), on: vi.fn(), setEncoding: vi.fn() }, + stderr: { off: vi.fn(), on: vi.fn(), setEncoding: vi.fn() }, on: vi.fn((event: string, callback: (error: Error) => void) => { if (event === 'error') { queueMicrotask(() => callback(new Error('spawn ENOENT /private/path'))) } return child }), + off: vi.fn(() => child), unref: vi.fn() } vi.mocked(spawn).mockImplementationOnce(() => child as unknown as ReturnType) @@ -229,6 +234,75 @@ describe('openComputerUsePermissions', () => { }) }) + it('removes permission status helper listeners after close', async () => { + const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions') + resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app') + const child = { + stdout: { off: vi.fn(), on: vi.fn(), setEncoding: vi.fn() }, + stderr: { off: vi.fn(), on: vi.fn(), setEncoding: vi.fn() }, + on: vi.fn((event: string, callback: (status: number) => void) => { + if (event === 'close') { + queueMicrotask(() => callback(0)) + } + return child + }), + off: vi.fn(() => child), + unref: vi.fn() + } + vi.mocked(spawn).mockImplementationOnce(() => child as unknown as ReturnType) + + await expect(getComputerUsePermissionStatus()).resolves.toMatchObject({ + helperUnavailableReason: null + }) + + expect(child.stdout.off).toHaveBeenCalledWith('data', expect.any(Function)) + expect(child.stderr.off).toHaveBeenCalledWith('data', expect.any(Function)) + expect(child.off).toHaveBeenCalledWith('error', expect.any(Function)) + expect(child.off).toHaveBeenCalledWith('close', expect.any(Function)) + }) + + it('times out when the permission status helper launch never closes', async () => { + vi.useFakeTimers() + const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions') + resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app') + const child = { + stdout: { off: vi.fn(), on: vi.fn(), setEncoding: vi.fn() }, + stderr: { off: vi.fn(), on: vi.fn(), setEncoding: vi.fn() }, + on: vi.fn(() => child), + off: vi.fn(() => child), + kill: vi.fn(), + unref: vi.fn() + } + vi.mocked(spawn).mockImplementationOnce(() => child as unknown as ReturnType) + + let settled = false + const statusPromise = getComputerUsePermissionStatus().then( + (status) => { + settled = true + return status + }, + (error: unknown) => { + settled = true + throw error + } + ) + const rejection = expect(statusPromise).rejects.toMatchObject({ + name: 'RuntimeClientError', + code: 'accessibility_error', + message: 'Timed out launching permission helper' + }) + + await vi.advanceTimersByTimeAsync(5000) + + expect(settled).toBe(true) + await rejection + expect(child.kill).toHaveBeenCalled() + expect(rm).toHaveBeenCalledWith('/tmp/orca-computer-use-permissions-test', { + recursive: true, + force: true + }) + }) + it('reads permission status through the helper app identity', async () => { const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions') resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app') diff --git a/src/main/computer/macos-computer-use-permissions.ts b/src/main/computer/macos-computer-use-permissions.ts index c41fec98350..0b025fc63bd 100644 --- a/src/main/computer/macos-computer-use-permissions.ts +++ b/src/main/computer/macos-computer-use-permissions.ts @@ -1,3 +1,5 @@ +/* oxlint-disable max-lines -- Why: permission setup, status probes, and TCC +reset share the helper-app identity contract and platform guards. */ import { execFileSync, spawn, spawnSync } from 'child_process' import { mkdtemp, readFile, rm, stat } from 'fs/promises' import { tmpdir } from 'os' @@ -17,6 +19,7 @@ import type { } from '../../shared/computer-use-permissions-types' const DEFAULT_COMPUTER_USE_BUNDLE_ID = 'com.stablyai.orca.computer-use' +const PERMISSION_STATUS_HELPER_LAUNCH_TIMEOUT_MS = 5_000 export function openComputerUsePermissions( permissionId?: ComputerUsePermissionId @@ -233,30 +236,74 @@ function launchPermissionStatusHelper(helperAppPath: string, statusPath: string) launch.stdout?.setEncoding('utf8') launch.stderr?.setEncoding('utf8') - launch.stdout?.on('data', (chunk) => { + const onStdoutData = (chunk: string): void => { stdout += chunk - }) - launch.stderr?.on('data', (chunk) => { + } + const onStderrData = (chunk: string): void => { stderr += chunk - }) - launch.on('error', () => { - reject( + } + let settled = false + let launchTimeout: ReturnType | null = null + const removeListeners = (): void => { + launch.stdout?.off('data', onStdoutData) + launch.stderr?.off('data', onStderrData) + launch.off('error', onError) + launch.off('close', onClose) + if (launchTimeout) { + clearTimeout(launchTimeout) + launchTimeout = null + } + } + const settleResolve = (): void => { + if (settled) { + return + } + settled = true + removeListeners() + resolve() + } + const settleReject = (error: Error): void => { + if (settled) { + return + } + settled = true + removeListeners() + reject(error) + } + const onError = (): void => { + settleReject( new RuntimeClientError( 'accessibility_error', 'Could not check permissions: failed to launch helper' ) ) - }) - launch.on('close', (status) => { + } + const onClose = (status: number | null): void => { if (status === 0) { - resolve() + settleResolve() return } const detail = stderr.trim() || stdout.trim() || `exit ${status ?? 'unknown'}` - reject( + settleReject( new RuntimeClientError('accessibility_error', `Could not check permissions: ${detail}`) ) - }) + } + const onTimeout = (): void => { + launch.kill() + settleReject( + new RuntimeClientError('accessibility_error', 'Timed out launching permission helper') + ) + } + // Why: the status-file polling timeout only starts after `open` exits; if + // `open` wedges first, permission checks would otherwise stay pending. + launchTimeout = setTimeout(onTimeout, PERMISSION_STATUS_HELPER_LAUNCH_TIMEOUT_MS) + if (typeof launchTimeout.unref === 'function') { + launchTimeout.unref() + } + launch.stdout?.on('data', onStdoutData) + launch.stderr?.on('data', onStderrData) + launch.on('error', onError) + launch.on('close', onClose) }) } diff --git a/src/main/computer/macos-native-provider-client.test.ts b/src/main/computer/macos-native-provider-client.test.ts new file mode 100644 index 00000000000..c097c289a81 --- /dev/null +++ b/src/main/computer/macos-native-provider-client.test.ts @@ -0,0 +1,194 @@ +import { EventEmitter } from 'events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + chmodSyncMock, + connectMacOSProviderSocketMock, + mkdtempSyncMock, + resolveMacOSComputerUseExecutablePathMock, + rmSyncMock, + spawnMock, + writeFileSyncMock +} = vi.hoisted(() => ({ + chmodSyncMock: vi.fn(), + connectMacOSProviderSocketMock: vi.fn(), + mkdtempSyncMock: vi.fn(), + resolveMacOSComputerUseExecutablePathMock: vi.fn(), + rmSyncMock: vi.fn(), + spawnMock: vi.fn(), + writeFileSyncMock: vi.fn() +})) + +vi.mock('child_process', () => ({ + spawn: spawnMock +})) + +vi.mock('fs', () => ({ + chmodSync: chmodSyncMock, + mkdtempSync: mkdtempSyncMock, + rmSync: rmSyncMock, + writeFileSync: writeFileSyncMock +})) + +vi.mock('./macos-native-provider-paths', () => ({ + resolveMacOSComputerUseExecutablePath: resolveMacOSComputerUseExecutablePathMock +})) + +vi.mock('./macos-native-provider-socket', () => ({ + connectMacOSProviderSocket: connectMacOSProviderSocketMock +})) + +class FakeSocket extends EventEmitter { + destroyed = false + writes: string[] = [] + + setEncoding(): void {} + + write(line: string, callback?: (error?: Error | null) => void): boolean { + this.writes.push(line) + callback?.(null) + return true + } + + end(): void { + this.destroyed = true + } + + destroy(): this { + this.destroyed = true + return this + } +} + +async function loadClientModule() { + vi.resetModules() + return await import('./macos-native-provider-client') +} + +describe('MacOSNativeProviderClient', () => { + const sockets: FakeSocket[] = [] + + beforeEach(() => { + vi.useFakeTimers() + sockets.length = 0 + mkdtempSyncMock.mockImplementation((prefix: string) => `${prefix}${sockets.length}`) + resolveMacOSComputerUseExecutablePathMock.mockReturnValue( + '/Applications/Orca Computer Use.app/Contents/MacOS/orca-computer-use-macos' + ) + spawnMock.mockReturnValue({ unref: vi.fn() }) + connectMacOSProviderSocketMock.mockImplementation(async () => { + const socket = new FakeSocket() + sockets.push(socket) + return socket + }) + }) + + afterEach(() => { + chmodSyncMock.mockReset() + connectMacOSProviderSocketMock.mockReset() + mkdtempSyncMock.mockReset() + resolveMacOSComputerUseExecutablePathMock.mockReset() + rmSyncMock.mockReset() + spawnMock.mockReset() + writeFileSyncMock.mockReset() + vi.useRealTimers() + }) + + it('ignores stale socket data, close, and error after a replacement socket starts', async () => { + const { MacOSNativeProviderClient } = await loadClientModule() + const client = new MacOSNativeProviderClient() + + const firstCall = client.capabilities() + const firstRejection = expect(firstCall).rejects.toThrow( + 'native macOS provider handshake timed out' + ) + await vi.waitFor(() => expect(sockets).toHaveLength(1)) + const firstSocket = sockets[0]! + + await vi.advanceTimersByTimeAsync(60_000) + await firstRejection + expect(firstSocket.destroyed).toBe(true) + expect(firstSocket.listenerCount('data')).toBe(0) + expect(firstSocket.listenerCount('close')).toBe(0) + expect(firstSocket.listenerCount('error')).toBe(1) + + const secondCall = client.capabilities() + await vi.waitFor(() => expect(sockets).toHaveLength(2)) + const secondSocket = sockets[1]! + await vi.waitFor(() => expect(secondSocket.writes).toHaveLength(1)) + const secondRequest = JSON.parse(secondSocket.writes[0]!) as { id: number } + + // Why: a timed-out helper socket can flush events after restart. Those + // stale events must not clear/reject the active replacement request. + firstSocket.emit('data', '{"id":999,"ok":false,"error":{"code":"old","message":"old"}}\n') + expect(() => firstSocket.emit('error', new Error('old helper failed late'))).not.toThrow() + firstSocket.emit('close') + + const capabilities = { + protocolVersion: 1, + supports: {} + } + secondSocket.emit( + 'data', + `${JSON.stringify({ id: secondRequest.id, ok: true, result: capabilities })}\n` + ) + + await expect(secondCall).resolves.toEqual(capabilities) + }) + + it('starts a replacement socket after the active helper connection errors', async () => { + const { MacOSNativeProviderClient } = await loadClientModule() + const client = new MacOSNativeProviderClient() + + const firstCall = client.capabilities() + const firstRejection = expect(firstCall).rejects.toThrow('active helper failed') + await vi.waitFor(() => expect(sockets).toHaveLength(1)) + const firstSocket = sockets[0]! + const firstSocketDirectory = mkdtempSyncMock.mock.results[0]?.value as string + await vi.waitFor(() => expect(firstSocket.writes).toHaveLength(1)) + + firstSocket.emit('error', new Error('active helper failed')) + await firstRejection + expect(firstSocket.destroyed).toBe(true) + expect(firstSocket.listenerCount('data')).toBe(0) + expect(firstSocket.listenerCount('close')).toBe(0) + expect(firstSocket.listenerCount('error')).toBe(1) + expect(rmSyncMock).toHaveBeenCalledWith(firstSocketDirectory, { + recursive: true, + force: true + }) + + const secondCall = client.capabilities() + await vi.waitFor(() => expect(sockets).toHaveLength(2)) + const secondSocket = sockets[1]! + await vi.waitFor(() => expect(secondSocket.writes).toHaveLength(1)) + const secondRequest = JSON.parse(secondSocket.writes[0]!) as { id: number } + + const capabilities = { + protocolVersion: 1, + supports: {} + } + secondSocket.emit( + 'data', + `${JSON.stringify({ id: secondRequest.id, ok: true, result: capabilities })}\n` + ) + + await expect(secondCall).resolves.toEqual(capabilities) + }) + + it('terminates the helper process when socket startup fails', async () => { + const providerKill = vi.fn() + spawnMock.mockReturnValueOnce({ unref: vi.fn(), kill: providerKill }) + connectMacOSProviderSocketMock.mockRejectedValueOnce(new Error('socket did not open')) + const { MacOSNativeProviderClient } = await loadClientModule() + const client = new MacOSNativeProviderClient() + + await expect(client.capabilities()).rejects.toThrow('socket did not open') + + expect(providerKill).toHaveBeenCalledWith('SIGTERM') + expect(rmSyncMock).toHaveBeenCalledWith(expect.stringContaining('orca-computer-use-'), { + recursive: true, + force: true + }) + }) +}) diff --git a/src/main/computer/macos-native-provider-client.ts b/src/main/computer/macos-native-provider-client.ts index ec6b0c4a855..929ab78bc28 100644 --- a/src/main/computer/macos-native-provider-client.ts +++ b/src/main/computer/macos-native-provider-client.ts @@ -28,6 +28,10 @@ import { RuntimeClientError } from './runtime-client-error' const REQUEST_TIMEOUT_MS = 60_000 const HELPER_CONNECT_TIMEOUT_MS = 10_000 +// Why: Node treats unhandled socket 'error' events as process exceptions, so +// stale helper sockets keep a no-op listener that does not retain the client. +function ignoreStaleSocketError(): void {} + export function shouldUseMacOSNativeProvider(): boolean { return ( process.platform === 'darwin' && @@ -47,6 +51,7 @@ export class MacOSNativeProviderClient { private pending = new Map() private socketBuffer = '' private providerCapabilities: ComputerProviderCapabilities | null = null + private socketListenerCleanup: (() => void) | null = null async listApps(): Promise { return (await this.call('listApps', {})) as ComputerListAppsResult } @@ -70,6 +75,8 @@ export class MacOSNativeProviderClient { this.socket = null this.socketStartPromise = null this.providerCapabilities = null + this.socketBuffer = '' + this.cleanupActiveSocketListeners() if (socket && !socket.destroyed) { const id = this.nextId++ socket.write(`${JSON.stringify({ id, method: 'terminate', params: {}, token })}\n`) @@ -164,6 +171,8 @@ export class MacOSNativeProviderClient { if (this.socket && !this.socket.destroyed) { return this.socket } + this.cleanupActiveSocketListeners() + this.socket = null if (this.socketStartPromise) { return await this.socketStartPromise } @@ -192,12 +201,26 @@ export class MacOSNativeProviderClient { try { const socket = await connectMacOSProviderSocket(this.socketPath, HELPER_CONNECT_TIMEOUT_MS) socket.setEncoding('utf8') - socket.on('data', (chunk: string) => this.handleSocketData(chunk)) - socket.on('close', () => this.handleSocketClose()) - socket.on('error', (error) => this.handleTransportError(error)) + this.socketBuffer = '' + const onData = (chunk: string) => this.handleSocketData(socket, chunk) + const onClose = () => this.handleSocketClose(socket) + const onError = (error: Error) => this.handleTransportError(socket, error) + socket.on('data', onData) + socket.on('close', onClose) + socket.on('error', onError) + this.socketListenerCleanup = () => { + socket.off('data', onData) + socket.off('close', onClose) + socket.off('error', onError) + socket.off('error', ignoreStaleSocketError) + socket.on('error', ignoreStaleSocketError) + } this.socket = socket return socket } catch (error) { + // Why: connect failures happen after spawn; terminate the detached + // helper so repeated startup attempts do not leave orphan providers. + provider.kill('SIGTERM') this.cleanupSocketDirectory() this.socketPath = null this.socketTokenPath = null @@ -205,7 +228,12 @@ export class MacOSNativeProviderClient { throw error } } - private handleSocketData(chunk: string): void { + private handleSocketData(socket: net.Socket, chunk: string): void { + // Why: a timed-out helper socket can emit after a replacement starts. + // Stale data must not corrupt the replacement socket's line buffer. + if (this.socket !== socket) { + return + } this.socketBuffer += chunk this.socketBuffer = this.consumeLines(this.socketBuffer) } @@ -242,14 +270,34 @@ export class MacOSNativeProviderClient { } pending.reject(new RuntimeClientError(response.error.code, response.error.message)) } - private handleSocketClose(): void { + private handleSocketClose(socket: net.Socket): void { + // Why: late close from a prior helper socket must not tear down the active + // replacement socket or reject its in-flight requests. + if (this.socket !== socket) { + return + } + this.cleanupActiveSocketListeners() this.socket = null + this.socketBuffer = '' this.cleanupSocketDirectory() this.rejectPending( new RuntimeClientError('accessibility_error', 'native macOS helper app connection closed') ) } - private handleTransportError(error: Error): void { + private handleTransportError(socket: net.Socket, error: Error): void { + // Why: stale socket errors can arrive after shutdown/restart. + if (this.socket !== socket) { + return + } + this.cleanupActiveSocketListeners() + // Why: an active transport error makes the helper socket unreliable; the + // next request must reconnect instead of reusing a broken socket. + this.socket = null + this.socketBuffer = '' + if (!socket.destroyed) { + socket.destroy() + } + this.cleanupSocketDirectory() this.rejectPending(new RuntimeClientError('accessibility_error', error.message)) } private cleanupSocketDirectory(): void { @@ -268,6 +316,11 @@ export class MacOSNativeProviderClient { this.pending.delete(id) } } + private cleanupActiveSocketListeners(): void { + const cleanup = this.socketListenerCleanup + this.socketListenerCleanup = null + cleanup?.() + } } function isMacOS14OrNewer(): boolean { diff --git a/src/main/computer/macos-native-provider-socket.test.ts b/src/main/computer/macos-native-provider-socket.test.ts new file mode 100644 index 00000000000..ef9f9ba471e --- /dev/null +++ b/src/main/computer/macos-native-provider-socket.test.ts @@ -0,0 +1,47 @@ +import { EventEmitter } from 'events' +import { describe, expect, it, vi } from 'vitest' + +const { createConnectionMock } = vi.hoisted(() => ({ + createConnectionMock: vi.fn() +})) + +vi.mock('net', () => ({ + default: { + createConnection: createConnectionMock + } +})) + +import { connectMacOSProviderSocket } from './macos-native-provider-socket' + +class FakeSocket extends EventEmitter { + destroy = vi.fn() +} + +describe('connectMacOSProviderSocket', () => { + it('removes socket listeners after a connection error', async () => { + vi.useFakeTimers() + try { + const socket = new FakeSocket() + createConnectionMock.mockReturnValueOnce(socket) + + const promise = connectMacOSProviderSocket('/tmp/orca-computer.sock', 50) + await Promise.resolve() + + expect(socket.listenerCount('error')).toBe(1) + expect(socket.listenerCount('connect')).toBe(1) + + const rejection = expect(promise).rejects.toThrow( + 'native macOS helper app did not open its socket' + ) + socket.emit('error', new Error('ECONNREFUSED')) + await vi.advanceTimersByTimeAsync(100) + + await rejection + expect(socket.destroy).toHaveBeenCalledTimes(1) + expect(socket.listenerCount('error')).toBe(0) + expect(socket.listenerCount('connect')).toBe(0) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/computer/macos-native-provider-socket.ts b/src/main/computer/macos-native-provider-socket.ts index 021ab111794..13732d17809 100644 --- a/src/main/computer/macos-native-provider-socket.ts +++ b/src/main/computer/macos-native-provider-socket.ts @@ -24,14 +24,20 @@ export async function connectMacOSProviderSocket( function connectSocket(socketPath: string): Promise { return new Promise((resolve, reject) => { const socket = net.createConnection(socketPath) - const onError = (error: Error) => { + const cleanup = (): void => { + socket.off('error', onError) + socket.off('connect', onConnect) + } + const onError = (error: Error): void => { + cleanup() socket.destroy() reject(error) } - socket.once('error', onError) - socket.once('connect', () => { - socket.off('error', onError) + const onConnect = (): void => { + cleanup() resolve(socket) - }) + } + socket.once('error', onError) + socket.once('connect', onConnect) }) } diff --git a/src/main/computer/permissions.test.ts b/src/main/computer/permissions.test.ts index 38c3ed6ed2f..fd94f389b34 100644 --- a/src/main/computer/permissions.test.ts +++ b/src/main/computer/permissions.test.ts @@ -65,12 +65,12 @@ describe('notifyPermissionRequired', () => { Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) }) - function getNotificationEventHandler(eventName: string): () => void { + function getNotificationEventHandler(eventName: string): (...args: unknown[]) => void { const call = notificationOnMock.mock.calls.find((c: unknown[]) => c[0] === eventName) if (!call) { throw new Error(`Notification ${eventName} handler not registered`) } - return call[1] as () => void + return call[1] as (...args: unknown[]) => void } it('clears the retained notification fallback timer when the notification closes', () => { @@ -98,4 +98,23 @@ describe('notifyPermissionRequired', () => { ) expect(vi.getTimerCount()).toBe(0) }) + + it('clears retained notification state when native delivery fails', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + notifyPermissionRequired('Enable accessibility') + expect(vi.getTimerCount()).toBe(1) + + const failedHandler = getNotificationEventHandler('failed') + failedHandler({}, 'Application is not code signed') + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Accessibility permission notification failed') + ) + expect(vi.getTimerCount()).toBe(0) + expect(notificationRemoveListenerMock).toHaveBeenCalledWith('failed', failedHandler) + } finally { + warn.mockRestore() + } + }) }) diff --git a/src/main/computer/permissions.ts b/src/main/computer/permissions.ts index d3ff714486b..a98f01c3dbc 100644 --- a/src/main/computer/permissions.ts +++ b/src/main/computer/permissions.ts @@ -1,29 +1,10 @@ -import { Notification, shell, systemPreferences } from 'electron' +import { Notification, shell } from 'electron' const ACCESSIBILITY_SETTINGS_URL = 'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility' -const DEFAULT_ACCESSIBILITY_INSTRUCTIONS = - 'System Settings -> Privacy & Security -> Accessibility -> enable Orca' const activePermissionNotifications = new Set() -/** Probe accessibility permissions; lazy -- invoked only on first failure path. */ -export async function checkAccessibilityPermission(): Promise<{ - ok: boolean - instructions?: string -}> { - if (process.platform !== 'darwin') { - return { ok: true } - } - - try { - const ok = systemPreferences.isTrustedAccessibilityClient(false) - return ok ? { ok: true } : { ok: false, instructions: DEFAULT_ACCESSIBILITY_INSTRUCTIONS } - } catch { - return { ok: false, instructions: DEFAULT_ACCESSIBILITY_INSTRUCTIONS } - } -} - /** Surface a notification through Orca's existing notification system (do not duplicate UI). */ export function notifyPermissionRequired(instructions: string): void { if (!Notification.isSupported()) { @@ -46,6 +27,7 @@ export function notifyPermissionRequired(instructions: string): void { activePermissionNotifications.delete(notification) notification.removeListener('close', release) notification.removeListener('click', onClick) + notification.removeListener('failed', onFailed) if (releaseTimer) { clearTimeout(releaseTimer) releaseTimer = null @@ -57,8 +39,17 @@ export function notifyPermissionRequired(instructions: string): void { void shell.openExternal(ACCESSIBILITY_SETTINGS_URL) } } + function onFailed(_event: unknown, error?: string): void { + // Why: Electron 42 reports macOS UNNotification delivery failures here, + // most commonly when a local validation build is not code-signed. + console.warn( + `[computer] Accessibility permission notification failed${error ? `: ${error}` : '.'}` + ) + release() + } notification.on('close', release) notification.on('click', onClick) + notification.on('failed', onFailed) releaseTimer = setTimeout(release, 5 * 60 * 1000) if (typeof releaseTimer.unref === 'function') { releaseTimer.unref() diff --git a/src/main/computer/sidecar-client.test.ts b/src/main/computer/sidecar-client.test.ts new file mode 100644 index 00000000000..b552d0b31ae --- /dev/null +++ b/src/main/computer/sidecar-client.test.ts @@ -0,0 +1,117 @@ +import { EventEmitter } from 'events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { callComputerSidecarCapabilities, resetComputerSidecarForTest } from './sidecar-client' + +const { forkMock } = vi.hoisted(() => ({ + forkMock: vi.fn() +})) + +vi.mock('child_process', () => ({ + fork: forkMock +})) + +type SentRequest = { + id: number + method: string + params: unknown +} + +class FakeChildProcess extends EventEmitter { + killed = false + sent: SentRequest[] = [] + + send(message: SentRequest, callback?: (error: Error | null) => void): boolean { + this.sent.push(message) + callback?.(null) + return true + } + + kill(): boolean { + this.killed = true + return true + } +} + +describe('computer sidecar client', () => { + const children: FakeChildProcess[] = [] + + beforeEach(() => { + vi.useFakeTimers() + children.length = 0 + forkMock.mockImplementation(() => { + const child = new FakeChildProcess() + children.push(child) + return child + }) + }) + + afterEach(() => { + resetComputerSidecarForTest() + forkMock.mockReset() + vi.useRealTimers() + }) + + it('ignores stale child exit and error after a replacement sidecar starts', async () => { + const firstCall = callComputerSidecarCapabilities() + const firstRejection = expect(firstCall).rejects.toThrow( + 'computer sidecar capabilities timed out' + ) + const firstChild = children[0]! + + await vi.advanceTimersByTimeAsync(60_000) + await firstRejection + expect(firstChild.killed).toBe(true) + expect(firstChild.listenerCount('message')).toBe(0) + expect(firstChild.listenerCount('exit')).toBe(0) + expect(firstChild.listenerCount('error')).toBe(1) + + const secondCall = callComputerSidecarCapabilities() + const secondChild = children[1]! + const secondRequest = secondChild.sent[0]! + + // Why: OS process events from a timed-out child can arrive after restart. + // They must not clear/reject the replacement child's active request. + firstChild.emit('message', { + id: secondRequest.id, + ok: false, + error: { code: 'old', message: 'old sidecar replied late' } + }) + expect(() => firstChild.emit('error', new Error('old sidecar failed late'))).not.toThrow() + firstChild.emit('exit', 1, null) + + secondChild.emit('message', { + id: secondRequest.id, + ok: true, + result: { supports: { screenshots: true } } + }) + + await expect(secondCall).resolves.toEqual({ supports: { screenshots: true } }) + }) + + it('starts a replacement sidecar after the active child errors', async () => { + const firstCall = callComputerSidecarCapabilities() + const firstRejection = expect(firstCall).rejects.toThrow('active sidecar failed') + const firstChild = children[0]! + + firstChild.emit('error', new Error('active sidecar failed')) + await firstRejection + expect(firstChild.killed).toBe(true) + expect(firstChild.listenerCount('message')).toBe(0) + expect(firstChild.listenerCount('exit')).toBe(0) + expect(firstChild.listenerCount('error')).toBe(1) + + const secondCall = callComputerSidecarCapabilities() + void secondCall.catch(() => undefined) + expect(children).toHaveLength(2) + const secondChild = children[1]! + const secondRequest = secondChild.sent[0]! + + secondChild.emit('message', { + id: secondRequest.id, + ok: true, + result: { supports: { screenshots: true } } + }) + + await expect(secondCall).resolves.toEqual({ supports: { screenshots: true } }) + }) +}) diff --git a/src/main/computer/sidecar-client.ts b/src/main/computer/sidecar-client.ts index 8981a0c6568..c2a42954221 100644 --- a/src/main/computer/sidecar-client.ts +++ b/src/main/computer/sidecar-client.ts @@ -43,6 +43,10 @@ type PendingRequest = { const REQUEST_TIMEOUT_MS = 60_000 let sidecar: ComputerSidecarProcess | null = null +// Why: Node treats unhandled child 'error' events as process exceptions, so +// stale children keep a no-op listener that does not retain the sidecar owner. +function ignoreStaleChildError(): void {} + export function shouldUseComputerSidecar(): boolean { return ( (process.platform === 'darwin' || @@ -115,6 +119,7 @@ function loadElectronApp(): { getAppPath(): string; isPackaged: boolean } | null class ComputerSidecarProcess { private child: ChildProcess | null = null + private childListenerCleanup: (() => void) | null = null private nextId = 1 private pending = new Map() @@ -147,6 +152,7 @@ class ComputerSidecarProcess { shutdown(): void { const child = this.child this.child = null + this.cleanupActiveChildListeners() for (const [id, pending] of this.pending) { clearTimeout(pending.timer) pending.reject(new RuntimeClientError('accessibility_error', 'computer sidecar shut down')) @@ -159,6 +165,8 @@ class ComputerSidecarProcess { if (this.child && !this.child.killed) { return this.child } + this.cleanupActiveChildListeners() + this.child = null const child = fork(this.entryPath, [], { stdio: ['ignore', 'pipe', 'pipe', 'ipc'], @@ -170,9 +178,25 @@ class ComputerSidecarProcess { ...(process.platform === 'win32' ? { windowsHide: true } : {}) }) - child.on('message', (message) => this.handleMessage(message)) - child.on('exit', (code, signal) => this.handleExit(code, signal)) - child.on('error', (error) => this.handleError(error)) + const onMessage = (message: unknown) => { + if (this.child === child) { + this.handleMessage(message) + } + } + const onExit = (code: number | null, signal: NodeJS.Signals | null) => + this.handleExit(child, code, signal) + const onError = (error: Error) => this.handleError(child, error) + + child.on('message', onMessage) + child.on('exit', onExit) + child.on('error', onError) + this.childListenerCleanup = () => { + child.off('message', onMessage) + child.off('exit', onExit) + child.off('error', onError) + child.off('error', ignoreStaleChildError) + child.on('error', ignoreStaleChildError) + } this.child = child return child } @@ -194,7 +218,17 @@ class ComputerSidecarProcess { pending.reject(new RuntimeClientError(message.error.code, message.error.message)) } - private handleExit(code: number | null, signal: NodeJS.Signals | null): void { + private handleExit( + child: ChildProcess, + code: number | null, + signal: NodeJS.Signals | null + ): void { + // Why: a timed-out child can exit after a replacement has started; stale + // exits must not clear the live child or reject its in-flight requests. + if (this.child !== child) { + return + } + this.cleanupActiveChildListeners() this.child = null const detail = signal ? `signal ${signal}` : `code ${code ?? 'unknown'}` const error = new RuntimeClientError( @@ -208,7 +242,16 @@ class ComputerSidecarProcess { } } - private handleError(error: Error): void { + private handleError(child: ChildProcess, error: Error): void { + // Why: late errors from a prior child should not poison the current sidecar. + if (this.child !== child) { + return + } + this.cleanupActiveChildListeners() + // Why: an active process error makes the IPC sidecar unreliable; restart + // on the next call instead of reusing a broken helper. + this.child = null + child.kill('SIGTERM') const wrapped = new RuntimeClientError('accessibility_error', error.message) for (const [id, pending] of this.pending) { clearTimeout(pending.timer) @@ -216,6 +259,12 @@ class ComputerSidecarProcess { this.pending.delete(id) } } + + private cleanupActiveChildListeners(): void { + const cleanup = this.childListenerCleanup + this.childListenerCleanup = null + cleanup?.() + } } function isSidecarResponse(message: unknown): message is ComputerSidecarResponse { diff --git a/src/main/copilot/hook-service.test.ts b/src/main/copilot/hook-service.test.ts index 6cc687646d9..972df565790 100644 --- a/src/main/copilot/hook-service.test.ts +++ b/src/main/copilot/hook-service.test.ts @@ -46,6 +46,16 @@ function readConfig(): Record { return JSON.parse(readFileSync(join(copilotHome, 'hooks', 'orca.json'), 'utf-8')) } +function makeStaleManagedHookDefinition(): Record { + if (process.platform === 'win32') { + return { + type: 'command', + powershell: 'powershell.exe -File C:/old/agent-hooks/copilot-hook.ps1' + } + } + return { type: 'command', bash: '/bin/sh "/old/agent-hooks/copilot-hook.sh"' } +} + describe('CopilotHookService', () => { it('installs a user-level Copilot hook file under COPILOT_HOME', () => { const service = new CopilotHookService() @@ -118,9 +128,9 @@ describe('CopilotHookService', () => { hooks: { UserPromptSubmit: [ { type: 'command', bash: 'echo user prompt' }, - { type: 'command', bash: '/bin/sh "/old/agent-hooks/copilot-hook.sh"' } + makeStaleManagedHookDefinition() ], - OldEvent: [{ type: 'command', bash: '/bin/sh "/old/agent-hooks/copilot-hook.sh"' }] + OldEvent: [makeStaleManagedHookDefinition()] } }, null, @@ -139,6 +149,46 @@ describe('CopilotHookService', () => { expect(hooks.UserPromptSubmit).toHaveLength(2) }) + it('reports partial when stale managed hooks only exist under retired events', () => { + const configPath = join(copilotHome, 'hooks', 'orca.json') + mkdirSync(join(copilotHome, 'hooks'), { recursive: true }) + writeFileSync( + configPath, + JSON.stringify( + { + version: 1, + hooks: { + OldEvent: [makeStaleManagedHookDefinition()] + } + }, + null, + 2 + ) + ) + + const status = new CopilotHookService().getStatus() + + expect(status.state).toBe('partial') + expect(status.managedHooksPresent).toBe(true) + expect(status.detail).toBe('Managed Copilot hook file contains stale entries') + }) + + it('reports partial when stale managed hooks remain alongside current hooks', () => { + const service = new CopilotHookService() + service.install() + const configPath = join(copilotHome, 'hooks', 'orca.json') + const config = readConfig() + const hooks = config.hooks as Record + hooks.UserPromptSubmit.push(makeStaleManagedHookDefinition()) + writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`) + + const status = service.getStatus() + + expect(status.state).toBe('partial') + expect(status.managedHooksPresent).toBe(true) + expect(status.detail).toBe('Managed Copilot hook file contains stale entries') + }) + it('forces version 1 in the dedicated Copilot hook file', () => { const configPath = join(copilotHome, 'hooks', 'orca.json') mkdirSync(join(copilotHome, 'hooks'), { recursive: true }) diff --git a/src/main/copilot/hook-service.ts b/src/main/copilot/hook-service.ts index ad0bb3005fc..c5b8ae1ec2b 100644 --- a/src/main/copilot/hook-service.ts +++ b/src/main/copilot/hook-service.ts @@ -9,7 +9,6 @@ import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared import { createManagedCommandMatcher, getSharedManagedScriptPath, - hookDefinitionHasManagedCommand, readHooksJson, removeManagedCommands, wrapPosixHookCommand, @@ -91,6 +90,23 @@ function definitionHasCurrentCommand(definition: HookDefinition, command: string ) } +function definitionHasStaleManagedCommand( + definition: HookDefinition, + currentCommand: string | null, + isManagedCommand: (command: string | undefined) => boolean +): boolean { + const commands = [definition.command, definition.bash, definition.powershell] + if (commands.some((command) => isManagedCommand(command) && command !== currentCommand)) { + return true + } + return ( + Array.isArray(definition.hooks) && + definition.hooks.some( + (hook) => isManagedCommand(hook.command) && hook.command !== currentCommand + ) + ) +} + function definitionsChanged(before: HookDefinition[], after: HookDefinition[]): boolean { return ( before.length !== after.length || @@ -184,6 +200,7 @@ export class CopilotHookService { const missing: string[] = [] let presentCount = 0 let staleManagedPresent = false + const managedEvents = new Set(COPILOT_EVENTS) for (const eventName of COPILOT_EVENTS) { const command = getManagedCommand(scriptPath, eventName) const definitions = Array.isArray(config.hooks?.[eventName]) ? config.hooks![eventName]! : [] @@ -194,13 +211,21 @@ export class CopilotHookService { presentCount += 1 } else { missing.push(eventName) - staleManagedPresent = - staleManagedPresent || - definitions.some((definition) => - hookDefinitionHasManagedCommand(definition, isManagedCommand) - ) } } + for (const [eventName, definitions] of Object.entries(config.hooks ?? {})) { + if (!Array.isArray(definitions)) { + continue + } + const currentCommand = managedEvents.has(eventName) + ? getManagedCommand(scriptPath, eventName) + : null + staleManagedPresent = + staleManagedPresent || + definitions.some((definition) => + definitionHasStaleManagedCommand(definition, currentCommand, isManagedCommand) + ) + } const managedHooksPresent = presentCount > 0 || staleManagedPresent let state: AgentHookInstallState @@ -208,6 +233,9 @@ export class CopilotHookService { if (config.disableAllHooks === true && managedHooksPresent) { state = 'partial' detail = 'Managed Copilot hook file is disabled' + } else if (staleManagedPresent) { + state = 'partial' + detail = 'Managed Copilot hook file contains stale entries' } else if (missing.length === 0) { state = 'installed' detail = null diff --git a/src/main/crash-reporting/crash-breadcrumb-store.test.ts b/src/main/crash-reporting/crash-breadcrumb-store.test.ts index c674b8b4c7e..2965007c9dd 100644 --- a/src/main/crash-reporting/crash-breadcrumb-store.test.ts +++ b/src/main/crash-reporting/crash-breadcrumb-store.test.ts @@ -1,11 +1,13 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { clearCrashBreadcrumbsForTest, getCrashBreadcrumbSnapshot, + recordCoalescedCrashBreadcrumb, recordCrashBreadcrumb } from './crash-breadcrumb-store' afterEach(() => { + vi.useRealTimers() clearCrashBreadcrumbsForTest() }) @@ -48,4 +50,37 @@ describe('crash breadcrumb store', () => { expect(getCrashBreadcrumbSnapshot()).toHaveLength(1) expect(getCrashBreadcrumbSnapshot()[0].data).toEqual({ packaged: false }) }) + + it('coalesces repeated breadcrumbs inside the interval', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-05-20T12:00:00.000Z')) + + recordCoalescedCrashBreadcrumb({ + name: 'agent_state_changed', + data: { agentType: 'claude', state: 'working' }, + coalesceKey: 'agent:claude:working', + minIntervalMs: 30_000 + }) + vi.advanceTimersByTime(1_000) + recordCoalescedCrashBreadcrumb({ + name: 'agent_state_changed', + data: { agentType: 'claude', state: 'working' }, + coalesceKey: 'agent:claude:working', + minIntervalMs: 30_000 + }) + vi.advanceTimersByTime(30_000) + recordCoalescedCrashBreadcrumb({ + name: 'agent_state_changed', + data: { agentType: 'claude', state: 'working' }, + coalesceKey: 'agent:claude:working', + minIntervalMs: 30_000 + }) + + expect(getCrashBreadcrumbSnapshot().map((entry) => entry.data)).toEqual([ + { agentType: 'claude', state: 'working' }, + { agentType: 'claude', state: 'working', suppressedSinceLast: 1 } + ]) + + vi.useRealTimers() + }) }) diff --git a/src/main/crash-reporting/crash-breadcrumb-store.ts b/src/main/crash-reporting/crash-breadcrumb-store.ts index 61f630f5f1d..55210366ced 100644 --- a/src/main/crash-reporting/crash-breadcrumb-store.ts +++ b/src/main/crash-reporting/crash-breadcrumb-store.ts @@ -1,16 +1,15 @@ import { sanitizeCrashReportBreadcrumbs, + type CrashReportBreadcrumbData, type CrashReportBreadcrumb } from '../../shared/crash-reporting' const MAX_BREADCRUMBS = 30 let breadcrumbs: CrashReportBreadcrumb[] = [] +let coalescedBreadcrumbs = new Map() -export function recordCrashBreadcrumb( - name: string, - data?: Record -): void { +export function recordCrashBreadcrumb(name: string, data?: CrashReportBreadcrumbData): void { const sanitized = sanitizeCrashReportBreadcrumbs([ { createdAt: new Date().toISOString(), @@ -28,6 +27,31 @@ export function recordCrashBreadcrumb( } } +export function recordCoalescedCrashBreadcrumb({ + name, + data, + coalesceKey, + minIntervalMs +}: { + name: string + data?: CrashReportBreadcrumbData + coalesceKey: string + minIntervalMs: number +}): void { + const now = Date.now() + const previous = coalescedBreadcrumbs.get(coalesceKey) + if (previous && now - previous.recordedAt < minIntervalMs) { + previous.suppressed += 1 + return + } + + coalescedBreadcrumbs.set(coalesceKey, { recordedAt: now, suppressed: 0 }) + recordCrashBreadcrumb( + name, + previous?.suppressed ? { ...data, suppressedSinceLast: previous.suppressed } : data + ) +} + export function getCrashBreadcrumbSnapshot(): CrashReportBreadcrumb[] { return breadcrumbs.map((breadcrumb) => ({ ...breadcrumb, @@ -37,4 +61,5 @@ export function getCrashBreadcrumbSnapshot(): CrashReportBreadcrumb[] { export function clearCrashBreadcrumbsForTest(): void { breadcrumbs = [] + coalescedBreadcrumbs = new Map() } diff --git a/src/main/crash-reporting/process-gone-classification.test.ts b/src/main/crash-reporting/process-gone-classification.test.ts index 669dc5dbda7..4621d140b35 100644 --- a/src/main/crash-reporting/process-gone-classification.test.ts +++ b/src/main/crash-reporting/process-gone-classification.test.ts @@ -143,6 +143,24 @@ describe('shouldRecordProcessGoneCrash', () => { expectedTeardown: 'none' }) ).toBe(false) + expect( + shouldRecordProcessGoneCrash({ + source: 'child', + processType: 'GPU', + reason: 'abnormal-exit', + exitCode: 512, + expectedTeardown: 'none' + }) + ).toBe(false) + expect( + shouldRecordProcessGoneCrash({ + source: 'child', + processType: 'GPU', + reason: 'abnormal-exit', + exitCode: 8704, + expectedTeardown: 'none' + }) + ).toBe(false) expect( shouldRecordProcessGoneCrash({ source: 'child', @@ -163,6 +181,26 @@ describe('shouldRecordProcessGoneCrash', () => { expectedTeardown: 'none' }) ).toBe(false) + expect( + shouldRecordProcessGoneCrash({ + source: 'child', + processType: 'Utility', + serviceName: 'audio.mojom.AudioService', + reason: 'killed', + exitCode: 1, + expectedTeardown: 'none' + }) + ).toBe(false) + expect( + shouldRecordProcessGoneCrash({ + source: 'child', + processType: 'Utility', + serviceName: 'audio.mojom.AudioService', + reason: 'crashed', + exitCode: -1, + expectedTeardown: 'none' + }) + ).toBe(false) }) it('still records unknown child process crashes', () => { diff --git a/src/main/crash-reporting/process-gone-classification.ts b/src/main/crash-reporting/process-gone-classification.ts index 0d0f3aad98d..1049eaca214 100644 --- a/src/main/crash-reporting/process-gone-classification.ts +++ b/src/main/crash-reporting/process-gone-classification.ts @@ -3,8 +3,11 @@ export type ExpectedTeardownScope = 'none' | 'renderer-reload' | 'app-shutdown' const WINDOWS_CONTROL_TERMINATION_EXIT_CODES = new Set([0xc000013a, 0x40010004]) const RECOVERABLE_CHILD_PROCESS_TYPES = new Set(['gpu']) -const RECOVERABLE_UTILITY_SERVICE_NAMES = new Set(['network.mojom.NetworkService']) -const RECOVERABLE_CHILD_PROCESS_REASONS = new Set(['crashed', 'killed']) +const RECOVERABLE_UTILITY_SERVICE_NAMES = new Set([ + 'audio.mojom.AudioService', + 'network.mojom.NetworkService' +]) +const RECOVERABLE_CHILD_PROCESS_REASONS = new Set(['abnormal-exit', 'crashed', 'killed']) function isWindowsControlTerminationExitCode(exitCode: number | null): boolean { if (exitCode === null) { @@ -56,8 +59,8 @@ export function shouldRecordProcessGoneCrash({ exitCode: number | null expectedTeardown: ExpectedTeardownScope }): boolean { - // Why: GPU and Network Service exits are recoverable Chromium child-process - // churn; treating them as app crashes creates noisy user prompts. + // Why: GPU, Network Service, and Audio Service exits are recoverable Chromium + // child-process churn; treating them as app crashes creates noisy user prompts. if (isRecoverableChromiumChildProcess({ source, processType, serviceName, reason })) { return false } diff --git a/src/main/crash-reporting/process-gone-dedupe.test.ts b/src/main/crash-reporting/process-gone-dedupe.test.ts new file mode 100644 index 00000000000..521e543d0fa --- /dev/null +++ b/src/main/crash-reporting/process-gone-dedupe.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { getProcessGoneDedupeKey, ProcessGoneDedupe } from './process-gone-dedupe' + +describe('ProcessGoneDedupe', () => { + it('suppresses duplicate keys inside the dedupe window', () => { + const dedupe = new ProcessGoneDedupe({ windowMs: 2_000 }) + const key = getProcessGoneDedupeKey('GPU', 'crashed', 5) + + expect(dedupe.shouldRecord(key, 1_000)).toBe(true) + expect(dedupe.shouldRecord(key, 2_999)).toBe(false) + expect(dedupe.shouldRecord(key, 3_000)).toBe(true) + }) + + it('prunes stale keys outside the dedupe window', () => { + const dedupe = new ProcessGoneDedupe({ windowMs: 2_000 }) + + expect(dedupe.shouldRecord('a', 1_000)).toBe(true) + expect(dedupe.shouldRecord('b', 1_500)).toBe(true) + expect(dedupe.shouldRecord('c', 3_000)).toBe(true) + + expect(dedupe.size).toBe(2) + }) + + it('bounds unique keys during crash storms', () => { + const dedupe = new ProcessGoneDedupe({ windowMs: 60_000, maxKeys: 3 }) + + expect(dedupe.shouldRecord('a', 1_000)).toBe(true) + expect(dedupe.shouldRecord('b', 1_001)).toBe(true) + expect(dedupe.shouldRecord('c', 1_002)).toBe(true) + expect(dedupe.shouldRecord('d', 1_003)).toBe(true) + + expect(dedupe.size).toBe(3) + expect(dedupe.shouldRecord('a', 1_004)).toBe(true) + }) +}) diff --git a/src/main/crash-reporting/process-gone-dedupe.ts b/src/main/crash-reporting/process-gone-dedupe.ts new file mode 100644 index 00000000000..f2caf0f3d1f --- /dev/null +++ b/src/main/crash-reporting/process-gone-dedupe.ts @@ -0,0 +1,64 @@ +const DEFAULT_PROCESS_GONE_DEDUPE_WINDOW_MS = 2_000 +const DEFAULT_PROCESS_GONE_DEDUPE_MAX_KEYS = 128 + +type ProcessGoneDedupeOptions = { + windowMs?: number + maxKeys?: number +} + +export class ProcessGoneDedupe { + private readonly windowMs: number + private readonly maxKeys: number + private readonly recentKeys = new Map() + + constructor(options: ProcessGoneDedupeOptions = {}) { + this.windowMs = options.windowMs ?? DEFAULT_PROCESS_GONE_DEDUPE_WINDOW_MS + this.maxKeys = options.maxKeys ?? DEFAULT_PROCESS_GONE_DEDUPE_MAX_KEYS + } + + shouldRecord(key: string, now = Date.now()): boolean { + this.prune(now) + + const previous = this.recentKeys.get(key) + if (previous !== undefined && now - previous < this.windowMs) { + return false + } + + // Why: process-gone tuples come from Electron and can vary by exit code; + // keep the short dedupe window without retaining stale tuples forever. + this.recentKeys.delete(key) + this.recentKeys.set(key, now) + this.prune(now) + return true + } + + get size(): number { + return this.recentKeys.size + } + + private prune(now: number): void { + for (const [key, recordedAt] of this.recentKeys) { + if (now - recordedAt >= this.windowMs) { + this.recentKeys.delete(key) + } + } + + while (this.recentKeys.size > this.maxKeys) { + const oldest = this.recentKeys.keys().next() + if (oldest.done) { + break + } + this.recentKeys.delete(oldest.value) + } + } +} + +export function getProcessGoneDedupeKey( + processType: string, + reason: string, + exitCode: number | null +): string { + return `${processType}:${reason}:${exitCode ?? 'null'}` +} + +export const processGoneDedupe = new ProcessGoneDedupe() diff --git a/src/main/cursor/hook-service.ts b/src/main/cursor/hook-service.ts index 6d7da1b1754..c2fcbba475a 100644 --- a/src/main/cursor/hook-service.ts +++ b/src/main/cursor/hook-service.ts @@ -99,7 +99,9 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { // Why: worktreeId embeds a filesystem path, so hand-building JSON in POSIX // shell is not safe once a path contains quotes or newlines. Post the raw // hook payload plus metadata as form fields and let the receiver parse it. + // Timeout caps best-effort hook posts if the local listener stalls. 'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/cursor" \\', + ' --connect-timeout 0.5 --max-time 1.5 \\', ' -H "Content-Type: application/x-www-form-urlencoded" \\', ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', diff --git a/src/main/daemon/client.test.ts b/src/main/daemon/client.test.ts index ea3ff88bfee..728a0d75833 100644 --- a/src/main/daemon/client.test.ts +++ b/src/main/daemon/client.test.ts @@ -1,3 +1,5 @@ +/* eslint-disable max-lines -- Why: daemon connection, RPC, event, and disconnect behavior share one socket test harness. */ +import { EventEmitter } from 'events' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createServer, type Server, type Socket } from 'net' import { tmpdir } from 'os' @@ -36,6 +38,7 @@ describe('DaemonClient', () => { }) afterEach(async () => { + vi.useRealTimers() client?.disconnect() await new Promise((resolve) => { if (server?.listening) { @@ -48,12 +51,21 @@ describe('DaemonClient', () => { }) function startMockDaemon(opts?: { + closeOnConnect?: boolean + closeOnHello?: boolean onControlMessage?: (msg: unknown) => string | null + onHello?: (msg: HelloMessage) => void onStreamHello?: (msg: HelloMessage) => void rejectVersion?: boolean + suppressHelloResponse?: boolean }): Promise { return new Promise((resolve) => { server = createServer((socket) => { + if (opts?.closeOnConnect) { + socket.destroy() + return + } + let buffer = '' socket.on('data', (chunk) => { buffer += chunk.toString() @@ -69,6 +81,14 @@ describe('DaemonClient', () => { if (msg.type === 'hello') { const hello = msg as HelloMessage + opts?.onHello?.(hello) + if (opts?.closeOnHello) { + socket.destroy() + return + } + if (opts?.suppressHelloResponse) { + return + } if (opts?.rejectVersion) { socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Version mismatch' })) return @@ -106,12 +126,101 @@ describe('DaemonClient', () => { await waitFor(() => hellos.length > 0) }) + it('removes socket startup listeners after connecting', async () => { + await startMockDaemon() + + client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + + const connectedClient = client as unknown as { + controlSocket: Socket | null + streamSocket: Socket | null + } + for (const socket of [connectedClient.controlSocket, connectedClient.streamSocket]) { + expect(socket?.listenerCount('connect')).toBe(0) + // One live error listener remains: the disconnect handler installed + // after the daemon hello handshake succeeds. + expect(socket?.listenerCount('error')).toBe(1) + } + }) + it('rejects on version mismatch', async () => { await startMockDaemon({ rejectVersion: true }) client = new DaemonClient({ socketPath, tokenPath }) await expect(client.ensureConnected()).rejects.toThrow() }) + + it('times out when the daemon never answers hello', async () => { + let resolveHello: () => void = () => {} + const helloReceived = new Promise((resolve) => { + resolveHello = resolve + }) + await startMockDaemon({ + suppressHelloResponse: true, + onHello: resolveHello + }) + + vi.useFakeTimers() + try { + client = new DaemonClient({ socketPath, tokenPath }) + const outcomePromise = client + .ensureConnected() + .then(() => 'connected') + .catch((error: Error) => error.message) + + await helloReceived + await vi.advanceTimersByTimeAsync(5000) + const outcome = await Promise.race([outcomePromise, Promise.resolve('pending')]) + + expect(outcome).toBe('Hello response timed out') + } finally { + vi.useRealTimers() + } + }) + + it('removes hello startup listeners after timeout', async () => { + vi.useFakeTimers() + + client = new DaemonClient({ socketPath, tokenPath }) + const write = vi.fn(() => true) + const destroy = vi.fn() + const socket = new EventEmitter() as Socket + socket.write = write as unknown as Socket['write'] + socket.destroy = destroy as unknown as Socket['destroy'] + const sendHello = ( + client as unknown as { + sendHello(socket: Socket, token: string, role: 'control' | 'stream'): Promise + } + ).sendHello.bind(client) + + const promise = sendHello(socket, 'test-token-123', 'control') + const rejection = expect(promise).rejects.toThrow('Hello response timed out') + await vi.advanceTimersByTimeAsync(5000) + + await rejection + expect(write).toHaveBeenCalledOnce() + expect(destroy).toHaveBeenCalledOnce() + expect(socket.listenerCount('data')).toBe(0) + expect(socket.listenerCount('error')).toBe(0) + expect(socket.listenerCount('close')).toBe(0) + }) + + it('rejects when the daemon closes before hello completes', async () => { + await startMockDaemon({ closeOnHello: true }) + + client = new DaemonClient({ socketPath, tokenPath }) + await expect(client.ensureConnected()).rejects.toThrow( + 'Connection closed before hello response' + ) + }) + + it('rejects when the daemon closes immediately after connect', async () => { + await startMockDaemon({ closeOnConnect: true }) + + client = new DaemonClient({ socketPath, tokenPath }) + await expect(client.ensureConnected()).rejects.toThrow() + }) }) describe('RPC', () => { @@ -226,6 +335,32 @@ describe('DaemonClient', () => { }) describe('disconnect', () => { + it('removes socket listeners when disconnecting', async () => { + await startMockDaemon() + + client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + + const connectedClient = client as unknown as { + controlSocket: Socket | null + streamSocket: Socket | null + } + const sockets = [connectedClient.controlSocket, connectedClient.streamSocket] + for (const socket of sockets) { + expect(socket?.listenerCount('data')).toBe(1) + expect(socket?.listenerCount('close')).toBe(1) + expect(socket?.listenerCount('error')).toBe(1) + } + + client.disconnect() + + for (const socket of sockets) { + expect(socket?.listenerCount('data')).toBe(0) + expect(socket?.listenerCount('close')).toBe(0) + expect(socket?.listenerCount('error')).toBe(0) + } + }) + it('emits disconnected when server destroys sockets', async () => { const serverSockets: Socket[] = [] await startMockDaemon() diff --git a/src/main/daemon/client.ts b/src/main/daemon/client.ts index 007afd5ee68..757acd182c3 100644 --- a/src/main/daemon/client.ts +++ b/src/main/daemon/client.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Why: daemon handshake, RPC, stream events, and reconnect cleanup share one socket lifecycle. */ import { connect, type Socket } from 'net' import { readFileSync } from 'fs' import { randomUUID } from 'crypto' @@ -45,6 +46,7 @@ export class DaemonClient { private eventListeners: ((event: unknown) => void)[] = [] private disconnectedListeners: (() => void)[] = [] private requestCounter = 0 + private cleanupSocketListeners: (() => void) | null = null constructor(opts: DaemonClientOptions) { this.socketPath = opts.socketPath @@ -74,16 +76,22 @@ export class DaemonClient { private async doConnect(): Promise { const token = readFileSync(this.tokenPath, 'utf-8').trim() + const pendingListenerCleanups: (() => void)[] = [] + const cleanupPendingListeners = (): void => { + for (const cleanup of pendingListenerCleanups.splice(0)) { + cleanup() + } + } try { // Sequential: control first, then stream this.controlSocket = await this.connectSocket() await this.sendHello(this.controlSocket, token, 'control') - this.setupControlParser() + pendingListenerCleanups.push(this.setupControlParser(this.controlSocket)) this.streamSocket = await this.connectSocket() await this.sendHello(this.streamSocket, token, 'stream') - this.setupStreamParser() + pendingListenerCleanups.push(this.setupStreamParser(this.streamSocket)) this.connected = true this.disconnectArmed = true @@ -91,11 +99,21 @@ export class DaemonClient { const gen = this.connectionGeneration const handleClose = () => this.handleDisconnect(gen) - this.controlSocket.on('close', handleClose) - this.controlSocket.on('error', handleClose) - this.streamSocket.on('close', handleClose) - this.streamSocket.on('error', handleClose) + const controlSocket = this.controlSocket + const streamSocket = this.streamSocket + controlSocket.on('close', handleClose) + controlSocket.on('error', handleClose) + streamSocket.on('close', handleClose) + streamSocket.on('error', handleClose) + pendingListenerCleanups.push(() => { + controlSocket.off('close', handleClose) + controlSocket.off('error', handleClose) + streamSocket.off('close', handleClose) + streamSocket.off('error', handleClose) + }) + this.cleanupSocketListeners = cleanupPendingListeners } catch (error) { + cleanupPendingListeners() this.controlSocket?.destroy() this.streamSocket?.destroy() this.controlSocket = null @@ -163,6 +181,7 @@ export class DaemonClient { disconnect(): void { this.connected = false this.disconnectArmed = false + this.cleanupActiveSocketListeners() for (const [id, pending] of this.pendingRequests) { clearTimeout(pending.timer) @@ -179,20 +198,27 @@ export class DaemonClient { private connectSocket(): Promise { return new Promise((resolve, reject) => { const socket = connect(this.socketPath) + const cleanup = (): void => { + clearTimeout(timer) + socket.removeListener('connect', onConnect) + socket.removeListener('error', onError) + } + const onConnect = (): void => { + cleanup() + resolve(socket) + } + const onError = (err: Error): void => { + cleanup() + reject(err) + } const timer = setTimeout(() => { + cleanup() socket.destroy() reject(new DaemonProtocolError('Connection timed out')) }, CONNECT_TIMEOUT_MS) - socket.on('connect', () => { - clearTimeout(timer) - resolve(socket) - }) - - socket.on('error', (err) => { - clearTimeout(timer) - reject(err) - }) + socket.on('connect', onConnect) + socket.on('error', onError) }) } @@ -207,6 +233,29 @@ export class DaemonClient { } let buffer = '' + let settled = false + let timer: ReturnType | null = null + const cleanup = (): void => { + if (timer) { + clearTimeout(timer) + timer = null + } + socket.removeListener('data', onData) + socket.removeListener('error', onError) + socket.removeListener('close', onClose) + } + const finish = (error?: Error): void => { + if (settled) { + return + } + settled = true + cleanup() + if (error) { + reject(error) + return + } + resolve() + } const onData = (chunk: Buffer): void => { buffer += chunk.toString() const newlineIdx = buffer.indexOf('\n') @@ -214,32 +263,38 @@ export class DaemonClient { return } - socket.removeListener('data', onData) const line = buffer.slice(0, newlineIdx) try { const response = JSON.parse(line) as HelloResponse if (response.ok) { - resolve() + finish() } else { - reject( + finish( new DaemonProtocolError(addNodePtyRecoveryHint(response.error ?? 'Hello rejected')) ) } } catch { - reject(new DaemonProtocolError('Invalid hello response')) + finish(new DaemonProtocolError('Invalid hello response')) } } + const onError = (error: Error): void => finish(error) + const onClose = (): void => + finish(new DaemonProtocolError('Connection closed before hello response')) + timer = setTimeout(() => { + // Why: a stale daemon can accept the socket but never answer hello; + // without a handshake timeout, startup waits forever on ensureConnected(). + finish(new DaemonProtocolError('Hello response timed out')) + socket.destroy() + }, CONNECT_TIMEOUT_MS) socket.on('data', onData) + socket.on('error', onError) + socket.on('close', onClose) socket.write(encodeNdjson(hello)) }) } - private setupControlParser(): void { - if (!this.controlSocket) { - return - } - + private setupControlParser(socket: Socket): () => void { const parser = createNdjsonParser( (msg) => { const response = msg as RpcResponse @@ -259,14 +314,12 @@ export class DaemonClient { () => {} // Ignore parse errors on control socket ) - this.controlSocket.on('data', (chunk) => parser.feed(chunk.toString())) + const onData = (chunk: Buffer) => parser.feed(chunk.toString()) + socket.on('data', onData) + return () => socket.off('data', onData) } - private setupStreamParser(): void { - if (!this.streamSocket) { - return - } - + private setupStreamParser(socket: Socket): () => void { const parser = createNdjsonParser( (msg) => { const event = msg as DaemonEvent @@ -279,7 +332,9 @@ export class DaemonClient { () => {} // Ignore parse errors on stream socket ) - this.streamSocket.on('data', (chunk) => parser.feed(chunk.toString())) + const onData = (chunk: Buffer) => parser.feed(chunk.toString()) + socket.on('data', onData) + return () => socket.off('data', onData) } private handleDisconnect(generation: number): void { @@ -288,6 +343,7 @@ export class DaemonClient { } this.disconnectArmed = false this.connected = false + this.cleanupActiveSocketListeners() for (const [id, pending] of this.pendingRequests) { clearTimeout(pending.timer) @@ -304,4 +360,10 @@ export class DaemonClient { listener() } } + + private cleanupActiveSocketListeners(): void { + const cleanup = this.cleanupSocketListeners + this.cleanupSocketListeners = null + cleanup?.() + } } diff --git a/src/main/daemon/daemon-health-socket-cleanup.test.ts b/src/main/daemon/daemon-health-socket-cleanup.test.ts new file mode 100644 index 00000000000..a6404ed0220 --- /dev/null +++ b/src/main/daemon/daemon-health-socket-cleanup.test.ts @@ -0,0 +1,82 @@ +import { EventEmitter } from 'events' +import { mkdtempSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { netConnectMock } = vi.hoisted(() => ({ + netConnectMock: vi.fn() +})) + +vi.mock('net', () => ({ connect: netConnectMock })) + +import { healthCheckDaemon, killStaleDaemon } from './daemon-health' + +class FakeSocket extends EventEmitter { + destroy = vi.fn() + write = vi.fn() +} + +describe('daemon health socket listener cleanup', () => { + let dir: string + let socketPath: string + let tokenPath: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'daemon-health-socket-cleanup-')) + socketPath = join(dir, 'daemon.sock') + tokenPath = join(dir, 'daemon.token') + writeFileSync(socketPath, '') + writeFileSync(tokenPath, 'token') + netConnectMock.mockReset() + }) + + afterEach(() => { + vi.useRealTimers() + rmSync(dir, { recursive: true, force: true }) + }) + + it('removes health-check socket listeners after a daemon response', async () => { + const socket = new FakeSocket() + netConnectMock.mockReturnValueOnce(socket) + + const result = healthCheckDaemon(socketPath, tokenPath) + socket.emit('connect') + socket.emit('data', Buffer.from('{"type":"hello","ok":true}\n{"id":"health-1","ok":true}\n')) + + await expect(result).resolves.toBe(true) + expect(socket.listenerCount('connect')).toBe(0) + expect(socket.listenerCount('error')).toBe(0) + expect(socket.listenerCount('data')).toBe(0) + expect(socket.destroy).toHaveBeenCalledTimes(1) + }) + + it('removes health-check socket listeners after a timeout', async () => { + vi.useFakeTimers() + const socket = new FakeSocket() + netConnectMock.mockReturnValueOnce(socket) + + const result = healthCheckDaemon(socketPath, tokenPath) + await vi.advanceTimersByTimeAsync(3_000) + + await expect(result).resolves.toBe(false) + expect(socket.listenerCount('connect')).toBe(0) + expect(socket.listenerCount('error')).toBe(0) + expect(socket.listenerCount('data')).toBe(0) + expect(socket.destroy).toHaveBeenCalledTimes(1) + }) + + it('removes stale-socket probe listeners after a timeout', async () => { + vi.useFakeTimers() + const socket = new FakeSocket() + netConnectMock.mockReturnValueOnce(socket) + + const result = killStaleDaemon(dir, socketPath, tokenPath) + await vi.advanceTimersByTimeAsync(500) + + await expect(result).resolves.toBe(false) + expect(socket.listenerCount('connect')).toBe(0) + expect(socket.listenerCount('error')).toBe(0) + expect(socket.destroy).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/daemon/daemon-health.test.ts b/src/main/daemon/daemon-health.test.ts index 3574eaa7a2f..5ace2af8d60 100644 --- a/src/main/daemon/daemon-health.test.ts +++ b/src/main/daemon/daemon-health.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, rmSync, writeFileSync } from 'fs' +import { spawn } from 'child_process' +import { mkdtempSync, rmSync, utimesSync, writeFileSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' import { createServer, connect, type Server } from 'net' @@ -8,6 +9,7 @@ import { getDaemonPidPath, serializeDaemonPidFile } from './daemon-spawner' import { getProcessStartedAtMs, healthCheckDaemon, + isDaemonOlderThanPathMtime, killStaleDaemon, parseDaemonPidFile, startTimeMatches @@ -264,3 +266,60 @@ describe('killStaleDaemon pid identity guards', () => { } }) }) + +describe('isDaemonOlderThanPathMtime', () => { + let dir: string + let socketPath: string + let tokenPath: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'daemon-health-mtime-test-')) + socketPath = join(dir, 'daemon.sock') + tokenPath = join(dir, 'daemon.token') + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('detects a daemon that started before the current bundle entry was written', async () => { + if (process.platform === 'win32') { + return + } + + const child = spawn( + process.execPath, + [ + '-e', + 'setTimeout(() => {}, 30000)', + 'daemon-entry', + '--socket', + socketPath, + '--token', + tokenPath + ], + { stdio: 'ignore' } + ) + try { + await new Promise((resolve) => setTimeout(resolve, 100)) + const startedAtMs = getProcessStartedAtMs(child.pid!) + if (startedAtMs === null) { + return + } + + const entryPath = join(dir, 'daemon-entry.js') + writeFileSync(entryPath, '', 'utf8') + const future = new Date(startedAtMs + 10_000) + utimesSync(entryPath, future, future) + writeFileSync( + getDaemonPidPath(dir), + serializeDaemonPidFile({ pid: child.pid!, startedAtMs, entryPath }), + { mode: 0o600 } + ) + + expect(isDaemonOlderThanPathMtime(dir, socketPath, tokenPath, entryPath)).toBe(true) + } finally { + child.kill('SIGKILL') + } + }) +}) diff --git a/src/main/daemon/daemon-health.ts b/src/main/daemon/daemon-health.ts index d83272bfb7b..b1a06382357 100644 --- a/src/main/daemon/daemon-health.ts +++ b/src/main/daemon/daemon-health.ts @@ -1,7 +1,7 @@ /* oxlint-disable max-lines -- Why: pid validation shares process-identity helpers with kill escalation so the SIGKILL safety checks stay co-located. */ import { execFileSync } from 'child_process' -import { existsSync, readFileSync, unlinkSync } from 'fs' +import { existsSync, readFileSync, statSync, unlinkSync } from 'fs' import { connect, type Socket } from 'net' import { encodeNdjson } from './ndjson' import { getDaemonPidPath } from './daemon-spawner' @@ -32,19 +32,33 @@ function canConnectSocket(socketPath: string): Promise { return } const sock = connect({ path: socketPath }) + let settled = false + const cleanup = (): void => { + clearTimeout(timer) + sock.off('connect', onConnect) + sock.off('error', onError) + } + const settle = (result: boolean): void => { + if (settled) { + return + } + settled = true + cleanup() + resolve(result) + } + const onConnect = (): void => { + settle(true) + sock.destroy() + } + const onError = (): void => { + settle(false) + } const timer = setTimeout(() => { + settle(false) sock.destroy() - resolve(false) }, 500) - sock.on('connect', () => { - clearTimeout(timer) - sock.destroy() - resolve(true) - }) - sock.on('error', () => { - clearTimeout(timer) - resolve(false) - }) + sock.on('connect', onConnect) + sock.on('error', onError) }) } @@ -71,14 +85,17 @@ export function healthCheckDaemon(socketPath: string, tokenPath: string): Promis } settled = true clearTimeout(timer) + removeSocketListeners() sock?.destroy() resolve(result) } - const timer = setTimeout(() => settle(false), HEALTH_CHECK_TIMEOUT_MS) - - sock = connect({ path: socketPath }) - sock.on('error', () => settle(false)) - sock.on('connect', () => { + const removeSocketListeners = (): void => { + sock?.off('error', onError) + sock?.off('connect', onConnect) + sock?.off('data', onData) + } + const onError = (): void => settle(false) + const onConnect = (): void => { const hello: HelloMessage = { type: 'hello', version: PROTOCOL_VERSION, @@ -87,10 +104,8 @@ export function healthCheckDaemon(socketPath: string, tokenPath: string): Promis role: 'control' } sock?.write(encodeNdjson(hello)) - }) - - let buffer = '' - sock.on('data', (chunk: Buffer) => { + } + const onData = (chunk: Buffer): void => { if (settled) { return } @@ -128,7 +143,15 @@ export function healthCheckDaemon(socketPath: string, tokenPath: string): Promis return } } - }) + } + const timer = setTimeout(() => settle(false), HEALTH_CHECK_TIMEOUT_MS) + + sock = connect({ path: socketPath }) + sock.on('error', onError) + sock.on('connect', onConnect) + + let buffer = '' + sock.on('data', onData) }) } @@ -167,14 +190,17 @@ export function getMacDaemonSystemResolverHealth( } settled = true clearTimeout(timer) + removeSocketListeners() sock?.destroy() resolve(result) } - const timer = setTimeout(() => settle('unknown'), RESOLVER_HEALTH_CHECK_TIMEOUT_MS) - - sock = connect({ path: socketPath }) - sock.on('error', () => settle('unknown')) - sock.on('connect', () => { + const removeSocketListeners = (): void => { + sock?.off('error', onError) + sock?.off('connect', onConnect) + sock?.off('data', onData) + } + const onError = (): void => settle('unknown') + const onConnect = (): void => { const hello: HelloMessage = { type: 'hello', version: protocolVersion, @@ -183,10 +209,8 @@ export function getMacDaemonSystemResolverHealth( role: 'control' } sock?.write(encodeNdjson(hello)) - }) - - let buffer = '' - sock.on('data', (chunk: Buffer) => { + } + const onData = (chunk: Buffer): void => { if (settled) { return } @@ -231,7 +255,15 @@ export function getMacDaemonSystemResolverHealth( return } } - }) + } + const timer = setTimeout(() => settle('unknown'), RESOLVER_HEALTH_CHECK_TIMEOUT_MS) + + sock = connect({ path: socketPath }) + sock.on('error', onError) + sock.on('connect', onConnect) + + let buffer = '' + sock.on('data', onData) }) } @@ -466,6 +498,38 @@ export function getDaemonLaunchIdentity( return commandLine.includes(expectedEntryPath) ? 'match' : 'mismatch' } +export function isDaemonOlderThanPathMtime( + runtimeDir: string, + socketPath: string, + tokenPath: string, + path: string, + protocolVersion = PROTOCOL_VERSION +): boolean { + let parsedPid: ParsedDaemonPid | null + try { + parsedPid = parseDaemonPidFile( + readFileSync(getDaemonPidPath(runtimeDir, protocolVersion), 'utf8') + ) + } catch { + return false + } + + if (!parsedPid || !isDaemonProcess(parsedPid.pid, socketPath, tokenPath, parsedPid.startedAtMs)) { + return false + } + + const startedAtMs = parsedPid.startedAtMs ?? getProcessStartedAtMs(parsedPid.pid) + if (startedAtMs === null) { + return false + } + + try { + return startedAtMs + START_TIME_TOLERANCE_MS < statSync(path).mtimeMs + } catch { + return false + } +} + export async function killStaleDaemon( runtimeDir: string, socketPath: string, diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts index ec91c7592e3..3aa47ac5507 100644 --- a/src/main/daemon/daemon-init.test.ts +++ b/src/main/daemon/daemon-init.test.ts @@ -22,6 +22,7 @@ const { healthCheckDaemonMock, getMacDaemonSystemResolverHealthMock, getDaemonLaunchIdentityMock, + isDaemonOlderThanPathMtimeMock, killStaleDaemonMock, getProcessStartedAtMsMock, daemonClientMock, @@ -54,6 +55,10 @@ const { } return this }, + removeListener(event: string, cb: () => void) { + handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] + return this + }, destroy() {} } }) @@ -61,6 +66,7 @@ const { const healthCheckDaemonMock = vi.fn(async () => true) const getMacDaemonSystemResolverHealthMock = vi.fn(() => 'healthy') const getDaemonLaunchIdentityMock = vi.fn(() => 'match') + const isDaemonOlderThanPathMtimeMock = vi.fn(() => false) const killStaleDaemonMock = vi.fn(async () => true) const getProcessStartedAtMsMock = vi.fn(() => 1_000_000) @@ -93,6 +99,7 @@ const { healthCheckDaemonMock, getMacDaemonSystemResolverHealthMock, getDaemonLaunchIdentityMock, + isDaemonOlderThanPathMtimeMock, killStaleDaemonMock, getProcessStartedAtMsMock, daemonClientMock, @@ -161,6 +168,7 @@ vi.mock('./daemon-health', () => ({ getDaemonLaunchIdentity: getDaemonLaunchIdentityMock, getMacDaemonSystemResolverHealth: getMacDaemonSystemResolverHealthMock, healthCheckDaemon: healthCheckDaemonMock, + isDaemonOlderThanPathMtime: isDaemonOlderThanPathMtimeMock, killStaleDaemon: killStaleDaemonMock, getProcessStartedAtMs: getProcessStartedAtMsMock })) @@ -252,6 +260,8 @@ async function importFresh() { getMacDaemonSystemResolverHealthMock.mockReset() getMacDaemonSystemResolverHealthMock.mockReturnValue('healthy') getDaemonLaunchIdentityMock.mockClear() + isDaemonOlderThanPathMtimeMock.mockReset() + isDaemonOlderThanPathMtimeMock.mockReturnValue(false) killStaleDaemonMock.mockClear() getAppPathMock.mockReset() getAppPathMock.mockReturnValue('/fake/app') @@ -450,6 +460,10 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { } return this }, + removeListener(event: string, cb: () => void) { + handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] + return this + }, destroy() {} } }) @@ -577,6 +591,10 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { } return this }, + removeListener(event: string, cb: () => void) { + handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] + return this + }, destroy() {} } }) @@ -590,6 +608,48 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(killStaleDaemonMock).not.toHaveBeenCalled() }) + it('cleans up daemon socket probe listeners when the probe times out', async () => { + vi.useFakeTimers() + try { + const handlers: Record void>> = { + connect: new Set(), + error: new Set() + } + const socket = { + on(event: string, cb: () => void) { + handlers[event]?.add(cb) + return this + }, + removeListener(event: string, cb: () => void) { + handlers[event]?.delete(cb) + return this + }, + destroy: vi.fn(), + listenerCount(event: string) { + return handlers[event]?.size ?? 0 + } + } + probeSocketExistsMock.mockReturnValue(true) + netConnectMock.mockReturnValueOnce(socket) + const mod = await importFresh() + + const cleanup = mod.cleanupDaemonForProtocol('/fake/daemon', PROTOCOL_VERSION) + await Promise.resolve() + + expect(socket.listenerCount('connect')).toBe(1) + expect(socket.listenerCount('error')).toBe(1) + + await vi.advanceTimersByTimeAsync(1000) + + await expect(cleanup).resolves.toEqual({ cleaned: false, killedCount: 0 }) + expect(socket.destroy).toHaveBeenCalledTimes(1) + expect(socket.listenerCount('connect')).toBe(0) + expect(socket.listenerCount('error')).toBe(0) + } finally { + vi.useRealTimers() + } + }) + it('coalesces concurrent restartDaemon() calls so the 7-step sequence runs exactly once', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() @@ -969,7 +1029,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(child.unref).not.toHaveBeenCalled() }) - it('keeps packaged healthy-daemon reuse independent of dev app-path identity', async () => { + it('preserves a packaged healthy daemon when its app bundle is current', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() @@ -981,12 +1041,76 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { killStaleDaemonMock.mockClear() forkMock.mockClear() isPackagedMock.mockReturnValue(true) - getDaemonLaunchIdentityMock.mockReturnValueOnce('mismatch') await launcher('/fake/socket', '/fake/token') - expect(getDaemonLaunchIdentityMock).not.toHaveBeenCalled() + expect(getDaemonLaunchIdentityMock).toHaveBeenCalledWith( + '/fake/userData/daemon', + '/fake/socket', + '/fake/token', + '/fake/app/out/main/daemon-entry.js' + ) + expect(isDaemonOlderThanPathMtimeMock).toHaveBeenCalledWith( + '/fake/userData/daemon', + '/fake/socket', + '/fake/token', + '/fake/app/out/main/daemon-entry.js' + ) expect(killStaleDaemonMock).not.toHaveBeenCalled() expect(forkMock).not.toHaveBeenCalled() }) + + it('respawns a packaged daemon that predates the current app bundle', async () => { + const mod = await importFresh() + await mod.initDaemonPtyProvider() + + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + isPackagedMock.mockReturnValue(true) + isDaemonOlderThanPathMtimeMock.mockReturnValueOnce(true) + forkMock.mockImplementationOnce(() => { + const handlers: Record void)[]> = { + message: [], + error: [], + exit: [] + } + return { + pid: 12345, + on(event: string, cb: (arg?: unknown) => void) { + handlers[event]?.push(cb) + if (event === 'message') { + queueMicrotask(() => cb({ type: 'ready' })) + } + return this + }, + off(event: string, cb: (arg?: unknown) => void) { + handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] + return this + }, + disconnect: vi.fn(), + unref: vi.fn() + } + }) + + await launcher('/fake/socket', '/fake/token') + + expect(isDaemonOlderThanPathMtimeMock).toHaveBeenCalledWith( + '/fake/userData/daemon', + '/fake/socket', + '/fake/token', + '/fake/app/out/main/daemon-entry.js' + ) + expect(killStaleDaemonMock).toHaveBeenCalledWith( + '/fake/userData/daemon', + '/fake/socket', + '/fake/token' + ) + expect(forkMock).toHaveBeenCalledWith( + '/fake/app/out/main/daemon-entry.js', + ['--socket', '/fake/socket', '--token', '/fake/token'], + expect.objectContaining({ detached: true }) + ) + }) }) diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index fe741fe57e9..eaa14b32971 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -32,6 +32,7 @@ import { getDaemonLaunchIdentity, getProcessStartedAtMs, healthCheckDaemon, + isDaemonOlderThanPathMtime, killStaleDaemon } from './daemon-health' import { @@ -83,19 +84,35 @@ function probeSocket(socketPath: string): Promise { return } const sock = connect({ path: socketPath }) - const timer = setTimeout(() => { - sock.destroy() - resolve(false) + let settled = false + let timer: ReturnType + function finish(alive: boolean, options?: { destroy?: boolean }): void { + if (settled) { + return + } + settled = true + clearTimeout(timer) + sock.removeListener('connect', onConnect) + sock.removeListener('error', onError) + if (options?.destroy) { + sock.destroy() + } + resolve(alive) + } + + function onConnect(): void { + finish(true, { destroy: true }) + } + + function onError(): void { + finish(false) + } + + timer = setTimeout(() => { + finish(false, { destroy: true }) }, 1000) - sock.on('connect', () => { - clearTimeout(timer) - sock.destroy() - resolve(true) - }) - sock.on('error', () => { - clearTimeout(timer) - resolve(false) - }) + sock.on('connect', onConnect) + sock.on('error', onError) }) } @@ -146,15 +163,19 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { console.warn('[daemon] Replacing daemon with unavailable macOS system resolver') await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION) } else { - // Why: dev worktrees share the same orca-dev userData, so a daemon from - // a deleted sibling checkout can pass protocol health checks while still - // pointing at missing native modules. Packaged app paths are stable and - // should preserve existing warm daemon reuse semantics. - const identity = app.isPackaged - ? 'match' - : getDaemonLaunchIdentity(runtimeDir, socketPath, tokenPath, entryPath) - if (identity === 'mismatch') { - console.warn('[daemon] Replacing daemon launched from a different app path') + // Why: a protocol-healthy daemon can outlive the app bundle that + // launched it. In dev this happens after deleting/rebuilding a + // worktree; in packaged apps it happens when the stable + // /Applications/Orca.app path is replaced during update. + const identity = getDaemonLaunchIdentity(runtimeDir, socketPath, tokenPath, entryPath) + const stalePackagedBundle = + app.isPackaged && isDaemonOlderThanPathMtime(runtimeDir, socketPath, tokenPath, entryPath) + if (identity === 'mismatch' || stalePackagedBundle) { + console.warn( + stalePackagedBundle + ? '[daemon] Replacing daemon launched before the current app bundle was installed' + : '[daemon] Replacing daemon launched from a different app path' + ) await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION) } else { // Why: daemon is already running from a previous app session and diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index 4bcd7d610bf..c80e1f7e0d5 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -7,6 +7,7 @@ import { DaemonPtyAdapter } from './daemon-pty-adapter' import { DaemonServer } from './daemon-server' import { getHistorySessionDirName } from './history-paths' import type { SubprocessHandle } from './session' +import type { GetSnapshotResult, TerminalSnapshot } from './types' import type * as DaemonHealthModule from './daemon-health' const { getMacDaemonSystemResolverHealthMock } = vi.hoisted(() => ({ @@ -55,6 +56,27 @@ function createMockSubprocess(): SubprocessHandle & { } } +function createTestSnapshot(label: string): TerminalSnapshot { + return { + snapshotAnsi: label, + scrollbackAnsi: '', + rehydrateSequences: '', + cwd: null, + modes: { + bracketedPaste: false, + mouseTracking: false, + mouseTrackingMode: 'none', + sgrMouseMode: false, + sgrMousePixelsMode: false, + applicationCursor: false, + alternateScreen: false + }, + cols: 80, + rows: 24, + scrollbackLines: 0 + } +} + async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise { const start = Date.now() while (!predicate()) { @@ -539,6 +561,57 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { } }) + it('limits concurrent checkpoint snapshot and disk work', async () => { + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const releaseSnapshotRequests: (() => void)[] = [] + const requestedSessionIds: string[] = [] + let inFlight = 0 + let maxInFlight = 0 + const request = vi.fn( + async (_type: string, payload: { sessionId: string }): Promise => { + requestedSessionIds.push(payload.sessionId) + inFlight++ + maxInFlight = Math.max(maxInFlight, inFlight) + await new Promise((resolve) => { + releaseSnapshotRequests.push(() => { + inFlight-- + resolve() + }) + }) + return { snapshot: createTestSnapshot(payload.sessionId) } + } + ) + const checkpoint = vi.fn(async () => {}) + const dispose = vi.fn(async () => {}) + const disconnect = vi.fn() + const internals = historyAdapter as unknown as { + client: { request: typeof request; disconnect: typeof disconnect } + historyManager: { checkpoint: typeof checkpoint; dispose: typeof dispose } + checkpointSessions(sessionIds: Iterable): Promise> + } + internals.client = { request, disconnect } + internals.historyManager = { checkpoint, dispose } + + const checkpointing = internals.checkpointSessions(['a', 'b', 'c', 'd', 'e', 'f']) + await waitFor(() => requestedSessionIds.length === 4) + + expect(maxInFlight).toBe(4) + expect(requestedSessionIds).toEqual(['a', 'b', 'c', 'd']) + + for (const release of releaseSnapshotRequests.splice(0)) { + release() + } + await waitFor(() => requestedSessionIds.length === 6) + + expect(maxInFlight).toBe(4) + + for (const release of releaseSnapshotRequests.splice(0)) { + release() + } + await expect(checkpointing).resolves.toEqual(new Set(['a', 'b', 'c', 'd', 'e', 'f'])) + expect(checkpoint).toHaveBeenCalledTimes(6) + }) + it('does not schedule a checkpoint timer until a session is dirty', async () => { const adapterClass = DaemonPtyAdapter as unknown as { CHECKPOINT_INTERVAL_MS: number } const previousInterval = adapterClass.CHECKPOINT_INTERVAL_MS @@ -700,6 +773,65 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(third.coldRestore).toBeUndefined() }) + it('drops sticky cold restore data on explicit shutdown', async () => { + const sessionId = 'sticky-cache-shutdown-test' + const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + join(sessionDir, 'meta.json'), + JSON.stringify({ + cwd: '/tmp', + cols: 80, + rows: 24, + startedAt: '2026-04-15T10:00:00Z', + endedAt: null, + exitCode: null + }) + ) + writeFileSync(join(sessionDir, 'scrollback.bin'), 'cached output') + + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const internals = historyAdapter as unknown as { + coldRestoreCache: Map + } + + await historyAdapter.spawn({ cols: 80, rows: 24, sessionId }) + expect(internals.coldRestoreCache.has(sessionId)).toBe(true) + + await historyAdapter.shutdown(sessionId, { immediate: true }) + + expect(internals.coldRestoreCache.has(sessionId)).toBe(false) + }) + + it('drops sticky cold restore data on natural exit', async () => { + const sessionId = 'sticky-cache-exit-test' + const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + join(sessionDir, 'meta.json'), + JSON.stringify({ + cwd: '/tmp', + cols: 80, + rows: 24, + startedAt: '2026-04-15T10:00:00Z', + endedAt: null, + exitCode: null + }) + ) + writeFileSync(join(sessionDir, 'scrollback.bin'), 'cached output') + + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const internals = historyAdapter as unknown as { + coldRestoreCache: Map + } + + await historyAdapter.spawn({ cols: 80, rows: 24, sessionId }) + expect(internals.coldRestoreCache.has(sessionId)).toBe(true) + + lastSubprocess._simulateExit(0) + await waitFor(() => !internals.coldRestoreCache.has(sessionId)) + }) + it('opens session for checkpointing after cold restore', async () => { const sessionId = 'post-restore-data' const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 0d62bd33d55..89e1ea7bd8a 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -32,6 +32,7 @@ export type DaemonPtyAdapterOptions = { } const MAX_TOMBSTONES = 1000 +const MAX_CONCURRENT_CHECKPOINTS = 4 export class TerminalKilledError extends Error { constructor(sessionId: string) { @@ -257,6 +258,7 @@ export class DaemonPtyAdapter implements IPtyProvider { await this.client.request('kill', { sessionId: id }) this.activeSessionIds.delete(id) this.dirtySessionVersions.delete(id) + this.coldRestoreCache.delete(id) this.stopCheckpointTimerIfIdle() this.initialCwds.delete(id) // Why: history removal is for the "user explicitly closed this terminal" @@ -429,6 +431,7 @@ export class DaemonPtyAdapter implements IPtyProvider { this.dirtySessionVersions.clear() this.stopCheckpointTimer() for (const id of ids) { + this.coldRestoreCache.delete(id) // Why: listener throws are intentionally *not* caught — matches the // natural onExit fanout in setupEventRouting, so synthetic exits don't // diverge in error semantics from real ones. A throwing listener is a @@ -485,6 +488,7 @@ export class DaemonPtyAdapter implements IPtyProvider { dispose(): void { this.stopCheckpointTimer() this.dirtySessionVersions.clear() + this.coldRestoreCache.clear() this.removeEventListener?.() this.removeEventListener = null // Why: final checkpoints are written daemon-side in TerminalHost.dispose() @@ -519,6 +523,7 @@ export class DaemonPtyAdapter implements IPtyProvider { // and the pending getSnapshot RPCs would be rejected. await this.checkpointAllSessions() this.dirtySessionVersions.clear() + this.coldRestoreCache.clear() this.removeEventListener?.() this.removeEventListener = null this.client.disconnect() @@ -621,10 +626,18 @@ export class DaemonPtyAdapter implements IPtyProvider { if (!this.historyManager) { return completed } - const promises: Promise[] = [] - for (const sessionId of sessionIds) { - promises.push( - this.client + const ids = Array.from(sessionIds) + let nextIndex = 0 + + const checkpointNext = async (): Promise => { + for (;;) { + const index = nextIndex + nextIndex++ + if (index >= ids.length) { + return + } + const sessionId = ids[index] + await this.client .request('getSnapshot', { sessionId }) .then((result) => { if (result.snapshot && this.historyManager) { @@ -636,9 +649,15 @@ export class DaemonPtyAdapter implements IPtyProvider { return undefined }) .catch((err) => console.warn('[history] checkpoint failed:', sessionId, err)) - ) + } } - await Promise.all(promises) + // Why: snapshot serialization and checkpoint writes are CPU/disk heavy. + // Dirty-session filtering keeps idle terminals out; this cap prevents one + // tick from snapshotting every active dirty terminal at once. + const workers = Array.from({ length: Math.min(MAX_CONCURRENT_CHECKPOINTS, ids.length) }, () => + checkpointNext() + ) + await Promise.all(workers) return completed } @@ -741,6 +760,7 @@ export class DaemonPtyAdapter implements IPtyProvider { } else if (event.event === 'exit') { this.activeSessionIds.delete(event.sessionId) this.dirtySessionVersions.delete(event.sessionId) + this.coldRestoreCache.delete(event.sessionId) this.stopCheckpointTimerIfIdle() if (this.historyManager) { void this.historyManager diff --git a/src/main/daemon/daemon-pty-router.test.ts b/src/main/daemon/daemon-pty-router.test.ts index 725a14c38d5..fd99feb78e3 100644 --- a/src/main/daemon/daemon-pty-router.test.ts +++ b/src/main/daemon/daemon-pty-router.test.ts @@ -8,6 +8,16 @@ type AdapterMock = DaemonPtyAdapter & { emitExit: (id: string, code: number) => void } +const LARGE_RECONCILE_SESSION_COUNT = 150_000 + +function buildSessionIds(prefix: string, count: number): string[] { + const ids: string[] = [] + for (let index = 0; index < count; index += 1) { + ids.push(`${prefix}-${index}`) + } + return ids +} + function createAdapter( label: string, sessions: string[] = [], @@ -158,6 +168,22 @@ describe('DaemonPtyRouter', () => { expect(current.write).toHaveBeenCalledWith('current-alive', 'new\n') }) + it('merges large startup reconciliation results', async () => { + const alive = buildSessionIds('alive', LARGE_RECONCILE_SESSION_COUNT) + const killed = buildSessionIds('killed', LARGE_RECONCILE_SESSION_COUNT) + const current = createAdapter('current', [], { alive, killed }) + const router = new DaemonPtyRouter({ current, legacy: [] }) + + const result = await router.reconcileOnStartup(new Set(['wt'])) + + expect(result.alive).toHaveLength(LARGE_RECONCILE_SESSION_COUNT) + expect(result.killed).toHaveLength(LARGE_RECONCILE_SESSION_COUNT) + expect(result.alive.at(-1)).toBe(`alive-${LARGE_RECONCILE_SESSION_COUNT - 1}`) + expect(result.killed.at(-1)).toBe(`killed-${LARGE_RECONCILE_SESSION_COUNT - 1}`) + router.write('alive-0', 'restored\n') + expect(current.write).toHaveBeenCalledWith('alive-0', 'restored\n') + }) + it('disposes current and legacy adapters', () => { const current = createAdapter('current') const legacy = createAdapter('legacy') diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index e1668457b29..49274862f04 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -175,8 +175,14 @@ export class DaemonPtyRouter implements IPtyProvider { const killed: string[] = [] for (const adapter of this.allAdapters()) { const result = await adapter.reconcileOnStartup(validWorktreeIds) - alive.push(...result.alive) - killed.push(...result.killed) + // Why: daemon startup can reconcile many restored sessions; spreading + // those arrays into push can exceed JavaScript's argument limit. + for (const id of result.alive) { + alive.push(id) + } + for (const id of result.killed) { + killed.push(id) + } for (const id of result.alive) { this.sessionAdapters.set(id, adapter) } diff --git a/src/main/daemon/daemon-server.test.ts b/src/main/daemon/daemon-server.test.ts index 794d5f44737..feb9fe9259e 100644 --- a/src/main/daemon/daemon-server.test.ts +++ b/src/main/daemon/daemon-server.test.ts @@ -91,6 +91,50 @@ describe('DaemonServer', () => { return client } + async function connectRawHello(role: 'control' | 'stream', clientId: string): Promise { + const socket = connect(socketPath) + await new Promise((resolve) => socket.once('connect', resolve)) + socket.write( + encodeNdjson({ + type: 'hello', + version: PROTOCOL_VERSION, + token: readFileSync(tokenPath, 'utf-8').trim(), + clientId, + role + }) + ) + await new Promise((resolve, reject) => { + const cleanup = (): void => { + socket.off('data', onData) + socket.off('error', onError) + } + const onData = (data: Buffer): void => { + cleanup() + const parsed = JSON.parse(data.toString().trim()) as { ok?: boolean; error?: string } + if (parsed.ok) { + resolve() + return + } + reject(new Error(parsed.error ?? 'hello rejected')) + } + const onError = (error: Error): void => { + cleanup() + reject(error) + } + socket.on('data', onData) + socket.on('error', onError) + }) + return socket + } + + async function waitFor(predicate: () => boolean, timeoutMs = 2_000): Promise { + const startedAt = Date.now() + while (!predicate() && Date.now() - startedAt < timeoutMs) { + await new Promise((resolve) => setTimeout(resolve, 20)) + } + expect(predicate()).toBe(true) + } + describe('startup', () => { it('creates token file and starts listening', async () => { await startServer() @@ -380,6 +424,75 @@ describe('DaemonServer', () => { }) }) + describe('stream socket lifecycle', () => { + it('clears the tracked stream socket when it closes', async () => { + await startServer() + const daemon = server as unknown as DaemonServerPrivate + const control = await connectRawHello('control', 'raw-client') + const stream = await connectRawHello('stream', 'raw-client') + + expect(daemon.clients.get('raw-client')?.streamSocket).toBeTruthy() + + stream.destroy() + + await waitFor(() => daemon.clients.get('raw-client')?.streamSocket === null) + control.destroy() + }) + + it('destroys a replaced stream socket for the same client', async () => { + await startServer() + const daemon = server as unknown as DaemonServerPrivate + const control = await connectRawHello('control', 'raw-client') + const firstStream = await connectRawHello('stream', 'raw-client') + let firstClosed = false + firstStream.once('close', () => { + firstClosed = true + }) + + const secondStream = await connectRawHello('stream', 'raw-client') + + await waitFor(() => firstClosed) + expect(daemon.clients.get('raw-client')?.streamSocket).toBeTruthy() + secondStream.destroy() + control.destroy() + }) + + it('destroys previous sockets when a control client id reconnects', async () => { + await startServer() + const daemon = server as unknown as DaemonServerPrivate + const firstControl = await connectRawHello('control', 'raw-client') + const firstStream = await connectRawHello('stream', 'raw-client') + let firstControlClosed = false + let firstStreamClosed = false + firstControl.once('close', () => { + firstControlClosed = true + }) + firstStream.once('close', () => { + firstStreamClosed = true + }) + + const secondControl = await connectRawHello('control', 'raw-client') + + await waitFor(() => firstControlClosed && firstStreamClosed) + expect(daemon.clients.get('raw-client')?.controlSocket).toBeTruthy() + expect(daemon.clients.get('raw-client')?.streamSocket).toBeNull() + secondControl.destroy() + }) + + it('destroys orphan stream sockets without a control client', async () => { + await startServer() + const daemon = server as unknown as DaemonServerPrivate + const stream = await connectRawHello('stream', 'missing-client') + let closed = false + stream.once('close', () => { + closed = true + }) + + await waitFor(() => closed || stream.destroyed) + expect(daemon.clients.has('missing-client')).toBe(false) + }) + }) + describe('shutdown', () => { it('stops accepting connections after shutdown', async () => { await startServer() diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 02e8cc61fd2..381400e25da 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -151,6 +151,7 @@ export class DaemonServer { socket.write(encodeNdjson({ type: 'hello', ok: true })) if (hello.role === 'control') { + const previous = this.clients.get(hello.clientId) const client: ConnectedClient = { clientId: hello.clientId, controlSocket: socket, @@ -158,12 +159,22 @@ export class DaemonServer { } this.clients.set(hello.clientId, client) this.setupControlSocket(socket, hello.clientId) + if (previous) { + // Why: a reconnect can reuse a clientId before the old sockets notice + // their close. Tear them down after installing the new owner so stale + // close events cannot delete the replacement client entry. + previous.streamSocket?.destroy() + previous.controlSocket.destroy() + } } else if (hello.role === 'stream') { const client = this.clients.get(hello.clientId) - if (client) { - client.streamSocket = socket + if (!client) { + // Why: stream sockets are only meaningful beside a control socket; an + // orphan stream would otherwise stay open with no tracked owner. + socket.destroy() + return } - // Stream socket is receive-only from daemon's perspective (for events) + this.setupStreamSocket(socket, client) } } @@ -178,11 +189,41 @@ export class DaemonServer { socket.on('data', (chunk) => parser.feed(chunk.toString())) socket.on('close', () => { + const client = this.clients.get(clientId) + if (client?.controlSocket !== socket) { + return + } this.streamDataBatcher.clear(clientId) + client.streamSocket?.destroy() this.clients.delete(clientId) }) } + private setupStreamSocket(socket: Socket, client: ConnectedClient): void { + const previous = client.streamSocket + socket.removeAllListeners('data') + client.streamSocket = socket + + const cleanup = (): void => { + socket.removeListener('close', cleanup) + socket.removeListener('error', cleanup) + if (this.clients.get(client.clientId) !== client || client.streamSocket !== socket) { + return + } + this.streamDataBatcher.clear(client.clientId) + client.streamSocket = null + } + + socket.on('close', cleanup) + socket.on('error', cleanup) + + if (previous && previous !== socket) { + // Why: replacing a stream socket must not leave the old receive-only + // channel alive and untracked. + previous.destroy() + } + } + private async handleRequest( socket: Socket, clientId: string, @@ -264,6 +305,9 @@ export class DaemonServer { } } + case 'cancelCreateOrAttach': + return {} + case 'write': try { this.lastInputAtBySessionId.set(request.payload.sessionId, performance.now()) @@ -322,7 +366,7 @@ export class DaemonServer { return { pong: true } case 'systemResolverHealth': - return { health: readCurrentProcessMacSystemResolverHealth() } + return { health: await readCurrentProcessMacSystemResolverHealth() } case 'shutdown': if (request.payload.killSessions) { @@ -330,10 +374,8 @@ export class DaemonServer { } process.nextTick(() => this.shutdown()) return {} - - default: - throw new Error(`Unknown request type: ${(request as { type: string }).type}`) } + throw new Error(`Unknown request type: ${(request as { type: string }).type}`) } private sendExitEvent( diff --git a/src/main/daemon/daemon-stream-data-batcher.test.ts b/src/main/daemon/daemon-stream-data-batcher.test.ts index 08b4624eb78..da2286d3ed1 100644 --- a/src/main/daemon/daemon-stream-data-batcher.test.ts +++ b/src/main/daemon/daemon-stream-data-batcher.test.ts @@ -1,13 +1,14 @@ import { describe, expect, it, vi } from 'vitest' import type { Socket } from 'net' import { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' +import { createNdjsonParser } from './ndjson' -function createBatcher() { +function createBatcher(options?: ConstructorParameters[1]) { const streamSocket = { destroyed: false, write: vi.fn() } as unknown as Socket & { write: ReturnType } - const batcher = new DaemonStreamDataBatcher(() => ({ streamSocket })) + const batcher = new DaemonStreamDataBatcher(() => ({ streamSocket }), options) return { batcher, streamSocket } } @@ -100,4 +101,32 @@ describe('DaemonStreamDataBatcher', () => { vi.useRealTimers() } }) + + it('writes large stream data as parser-sized NDJSON events', () => { + vi.useFakeTimers() + try { + const maxLineBytes = 256 + const { batcher, streamSocket } = createBatcher({ maxLineBytes }) + const data = 'x'.repeat(maxLineBytes * 3) + const onMessage = vi.fn() + const onError = vi.fn() + const parser = createNdjsonParser(onMessage, onError, { maxLineBytes }) + + batcher.enqueue('client-1', 'session-1', data) + vi.advanceTimersByTime(8) + for (const [line] of streamSocket.write.mock.calls) { + parser.feed(String(line)) + } + + expect(onError).not.toHaveBeenCalled() + expect(onMessage).toHaveBeenCalled() + expect( + onMessage.mock.calls + .map(([message]) => (message as { payload?: { data?: string } }).payload?.data ?? '') + .join('') + ).toBe(data) + } finally { + vi.useRealTimers() + } + }) }) diff --git a/src/main/daemon/daemon-stream-data-batcher.ts b/src/main/daemon/daemon-stream-data-batcher.ts index b9b1f6d92c9..b83f2135097 100644 --- a/src/main/daemon/daemon-stream-data-batcher.ts +++ b/src/main/daemon/daemon-stream-data-batcher.ts @@ -1,5 +1,5 @@ import type { Socket } from 'net' -import { encodeNdjson } from './ndjson' +import { encodeNdjson, NDJSON_MAX_LINE_BYTES } from './ndjson' type StreamDataClient = { streamSocket: Socket | null @@ -20,12 +20,99 @@ type EnqueueOptions = { flushMaxChars?: number } +type DaemonStreamDataBatcherOptions = { + maxLineBytes?: number +} + +function encodeStreamDataEvent(sessionId: string, data: string): string { + return encodeNdjson({ + type: 'event', + event: 'data', + sessionId, + payload: { data } + }) +} + +function streamDataEventLineBytes(sessionId: string, data: string): number { + return Buffer.byteLength(encodeStreamDataEvent(sessionId, data), 'utf8') +} + +function isHighSurrogate(value: number): boolean { + return value >= 0xd800 && value <= 0xdbff +} + +function isLowSurrogate(value: number): boolean { + return value >= 0xdc00 && value <= 0xdfff +} + +function clampToSafeSplitIndex(value: string, start: number, end: number): number { + if (end <= start || end >= value.length) { + return end + } + const prev = value.charCodeAt(end - 1) + const next = value.charCodeAt(end) + return isHighSurrogate(prev) && isLowSurrogate(next) ? end - 1 : end +} + +function nextSafeSplitIndex(value: string, start: number): number { + const next = Math.min(value.length, start + 1) + if ( + next < value.length && + isHighSurrogate(value.charCodeAt(start)) && + isLowSurrogate(value.charCodeAt(next)) + ) { + return next + 1 + } + return next +} + +function splitStreamDataForNdjson(sessionId: string, data: string, maxLineBytes: number): string[] { + if (streamDataEventLineBytes(sessionId, data) <= maxLineBytes) { + return [data] + } + + const chunks: string[] = [] + let start = 0 + while (start < data.length) { + let low = start + 1 + let high = data.length + let best = start + + while (low <= high) { + const rawMid = Math.floor((low + high) / 2) + const mid = clampToSafeSplitIndex(data, start, rawMid) + if (mid <= start) { + low = rawMid + 1 + continue + } + + if (streamDataEventLineBytes(sessionId, data.slice(start, mid)) <= maxLineBytes) { + best = mid + low = rawMid + 1 + } else { + high = rawMid - 1 + } + } + + const end = best > start ? best : nextSafeSplitIndex(data, start) + chunks.push(data.slice(start, end)) + start = end + } + + return chunks +} + export class DaemonStreamDataBatcher { private pendingByClient = new Map() private getClient: (clientId: string) => StreamDataClient | undefined + private maxLineBytes: number - constructor(getClient: (clientId: string) => StreamDataClient | undefined) { + constructor( + getClient: (clientId: string) => StreamDataClient | undefined, + options: DaemonStreamDataBatcherOptions = {} + ) { this.getClient = getClient + this.maxLineBytes = Math.max(1, options.maxLineBytes ?? NDJSON_MAX_LINE_BYTES) } enqueue(clientId: string, sessionId: string, data: string, options: EnqueueOptions = {}): void { @@ -79,14 +166,7 @@ export class DaemonStreamDataBatcher { } for (const entry of batch.queue) { - client.streamSocket.write( - encodeNdjson({ - type: 'event', - event: 'data', - sessionId: entry.sessionId, - payload: { data: entry.data } - }) - ) + this.writeStreamDataEvent(client.streamSocket, entry.sessionId, entry.data) } } @@ -137,14 +217,7 @@ export class DaemonStreamDataBatcher { } for (const entry of flushed) { - client.streamSocket.write( - encodeNdjson({ - type: 'event', - event: 'data', - sessionId: entry.sessionId, - payload: { data: entry.data } - }) - ) + this.writeStreamDataEvent(client.streamSocket, entry.sessionId, entry.data) } } @@ -161,4 +234,13 @@ export class DaemonStreamDataBatcher { this.pendingByClient.delete(id) } } + + private writeStreamDataEvent(streamSocket: Socket, sessionId: string, data: string): void { + // Why: createNdjsonParser rejects oversized lines. Terminal output can + // burst faster than the batch interval, so writer-side chunking prevents + // the daemon from dropping its own stream events at the receiver. + for (const chunk of splitStreamDataForNdjson(sessionId, data, this.maxLineBytes)) { + streamSocket.write(encodeStreamDataEvent(sessionId, chunk)) + } + } } diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index 90155072a94..2611ea025c4 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -285,6 +285,8 @@ export class HeadlessEmulator { case 'any': seqs.push('\x1b[?1003h') break + case 'none': + break } // Why: xterm tracks the mouse protocol and SGR encoding as independent // modes, so snapshots must preserve the encoding even when reporting is off. diff --git a/src/main/daemon/history-reader.test.ts b/src/main/daemon/history-reader.test.ts index 65e594f121d..40fbc9812f9 100644 --- a/src/main/daemon/history-reader.test.ts +++ b/src/main/daemon/history-reader.test.ts @@ -271,5 +271,12 @@ describe('HistoryReader', () => { expect(reader.listRestorable()).toEqual([sessionId]) }) + + it('skips malformed encoded session directories', () => { + mkdirSync(join(dir, '%E0%A4%A'), { recursive: true }) + writeSessionWithScrollback(dir, 'alive', makeMeta(), 'data') + + expect(reader.listRestorable()).toEqual(['alive']) + }) }) }) diff --git a/src/main/daemon/history-reader.ts b/src/main/daemon/history-reader.ts index e646b004797..d2fe9604e9d 100644 --- a/src/main/daemon/history-reader.ts +++ b/src/main/daemon/history-reader.ts @@ -90,7 +90,12 @@ export class HistoryReader { if (!entry.isDirectory()) { continue } - const sessionId = decodeURIComponent(entry.name) + let sessionId: string + try { + sessionId = decodeURIComponent(entry.name) + } catch { + continue + } const meta = this.readMeta(sessionId) if (meta && meta.endedAt === null) { restorable.push(sessionId) diff --git a/src/main/daemon/ndjson.test.ts b/src/main/daemon/ndjson.test.ts index 698a43f0a34..3d1d2c1512b 100644 --- a/src/main/daemon/ndjson.test.ts +++ b/src/main/daemon/ndjson.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { encodeNdjson, createNdjsonParser } from './ndjson' +import { encodeNdjson, createNdjsonParser, NDJSON_MAX_LINE_BYTES } from './ndjson' describe('encodeNdjson', () => { it('encodes an object as a JSON line ending with newline', () => { @@ -16,6 +16,10 @@ describe('encodeNdjson', () => { }) describe('createNdjsonParser', () => { + it('exports a bounded default line size', () => { + expect(NDJSON_MAX_LINE_BYTES).toBe(16 * 1024 * 1024) + }) + it('parses a single complete message', () => { const onMessage = vi.fn() const onError = vi.fn() @@ -98,6 +102,33 @@ describe('createNdjsonParser', () => { expect(onMessage).toHaveBeenCalledWith({ good: true }) }) + it('drops oversized complete lines and parses following messages', () => { + const onMessage = vi.fn() + const onError = vi.fn() + const parser = createNdjsonParser(onMessage, onError, { maxLineBytes: 24 }) + + parser.feed(`${'x'.repeat(25)}\n{"good":true}\n`) + + expect(onError).toHaveBeenCalledOnce() + expect(onError.mock.calls[0][0].message).toContain('NDJSON line exceeds max 24 bytes') + expect(onMessage).toHaveBeenCalledOnce() + expect(onMessage).toHaveBeenCalledWith({ good: true }) + }) + + it('discards oversized partial lines until the next delimiter', () => { + const onMessage = vi.fn() + const onError = vi.fn() + const parser = createNdjsonParser(onMessage, onError, { maxLineBytes: 24 }) + + parser.feed('{"too":') + parser.feed(`"${'x'.repeat(24)}"}`) + parser.feed('\n{"fresh":true}\n') + + expect(onError).toHaveBeenCalledOnce() + expect(onMessage).toHaveBeenCalledOnce() + expect(onMessage).toHaveBeenCalledWith({ fresh: true }) + }) + it('handles messages with embedded newlines in strings', () => { const onMessage = vi.fn() const parser = createNdjsonParser(onMessage) diff --git a/src/main/daemon/ndjson.ts b/src/main/daemon/ndjson.ts index 7d10b2330a9..17b37cb8d11 100644 --- a/src/main/daemon/ndjson.ts +++ b/src/main/daemon/ndjson.ts @@ -2,25 +2,79 @@ export function encodeNdjson(msg: unknown): string { return `${JSON.stringify(msg)}\n` } +export const NDJSON_MAX_LINE_BYTES = 16 * 1024 * 1024 + export type NdjsonParser = { feed(chunk: string): void reset(): void } +export type NdjsonParserOptions = { + maxLineBytes?: number +} + export function createNdjsonParser( onMessage: (msg: unknown) => void, - onError?: (err: Error) => void + onError?: (err: Error) => void, + options: NdjsonParserOptions = {} ): NdjsonParser { let buffer = '' + let bufferBytes = 0 + let discardingOversizedLine = false + const maxLineBytes = Math.max(1, options.maxLineBytes ?? NDJSON_MAX_LINE_BYTES) + + const clearBuffer = (): void => { + buffer = '' + bufferBytes = 0 + } + + const reportOversizedLine = (observedBytes: number): void => { + onError?.( + new Error(`NDJSON line exceeds max ${maxLineBytes} bytes (${observedBytes} bytes received)`) + ) + } return { feed(chunk: string): void { - buffer += chunk + let remaining = chunk - let newlineIndex: number - while ((newlineIndex = buffer.indexOf('\n')) !== -1) { - const line = buffer.slice(0, newlineIndex) - buffer = buffer.slice(newlineIndex + 1) + while (remaining.length > 0) { + const newlineIndex = remaining.indexOf('\n') + const hasNewline = newlineIndex !== -1 + const segment = hasNewline ? remaining.slice(0, newlineIndex) : remaining + remaining = hasNewline ? remaining.slice(newlineIndex + 1) : '' + + if (discardingOversizedLine) { + if (hasNewline) { + discardingOversizedLine = false + clearBuffer() + continue + } + return + } + + const segmentBytes = Buffer.byteLength(segment, 'utf8') + const nextLineBytes = bufferBytes + segmentBytes + // Why: daemon sockets are local but persistent; a peer that never sends + // a newline must not grow the parser buffer without bound. + if (nextLineBytes > maxLineBytes) { + reportOversizedLine(nextLineBytes) + clearBuffer() + if (!hasNewline) { + discardingOversizedLine = true + return + } + continue + } + + buffer += segment + bufferBytes = nextLineBytes + if (!hasNewline) { + return + } + + const line = buffer + clearBuffer() if (line.length === 0) { continue @@ -35,7 +89,8 @@ export function createNdjsonParser( }, reset(): void { - buffer = '' + clearBuffer() + discardingOversizedLine = false } } } diff --git a/src/main/daemon/production-launcher.test.ts b/src/main/daemon/production-launcher.test.ts index bfeda791e8d..cec7d0b6ca4 100644 --- a/src/main/daemon/production-launcher.test.ts +++ b/src/main/daemon/production-launcher.test.ts @@ -125,6 +125,57 @@ describe('createProductionLauncher', () => { expect(child.disconnect).toHaveBeenCalled() expect(child.unref).toHaveBeenCalled() }) + + it('removes shutdown exit listener when force-kill timeout settles first', async () => { + vi.useFakeTimers() + try { + const handlers: Record void)[]> = { + message: [], + error: [], + exit: [] + } + const child = { + pid: 12345, + killed: false, + on: vi.fn((event: string, cb: (arg?: unknown) => void) => { + handlers[event]?.push(cb) + return child + }), + once: vi.fn((event: string, cb: (arg?: unknown) => void) => { + handlers[event]?.push(cb) + return child + }), + off: vi.fn((event: string, cb: (arg?: unknown) => void) => { + handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] + return child + }), + kill: vi.fn(), + disconnect: vi.fn(), + unref: vi.fn() + } + forkMock.mockReturnValueOnce(child) + + const launcher = createProductionLauncher({ + getDaemonEntryPath: () => join(dir, 'daemon-entry.js') + }) + + const launch = launcher(socketPathFor(dir), tokenPathFor(dir)) + handlers.message[0]?.({ type: 'ready' }) + const handle = await launch + + const shutdown = handle.shutdown() + expect(handlers.exit).toHaveLength(1) + + await vi.advanceTimersByTimeAsync(5000) + await shutdown + + expect(child.kill).toHaveBeenNthCalledWith(1, 'SIGTERM') + expect(child.kill).toHaveBeenNthCalledWith(2, 'SIGKILL') + expect(handlers.exit).toHaveLength(0) + } finally { + vi.useRealTimers() + } + }) }) function socketPathFor(dir: string): string { diff --git a/src/main/daemon/production-launcher.ts b/src/main/daemon/production-launcher.ts index 10693ef7991..bf4ff597397 100644 --- a/src/main/daemon/production-launcher.ts +++ b/src/main/daemon/production-launcher.ts @@ -89,16 +89,28 @@ function shutdownChild(child: ChildProcess): Promise { return } - const timeout = setTimeout(() => { - child.kill('SIGKILL') + let settled = false + let timeout: ReturnType + function finish(): void { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + child.off('exit', onExit) resolve() + } + + function onExit(): void { + finish() + } + + timeout = setTimeout(() => { + child.kill('SIGKILL') + finish() }, 5000) - child.once('exit', () => { - clearTimeout(timeout) - resolve() - }) - + child.once('exit', onExit) child.kill('SIGTERM') }) } diff --git a/src/main/daemon/pty-session-id.test.ts b/src/main/daemon/pty-session-id.test.ts index da0bfd5ffba..6ba42f0876a 100644 --- a/src/main/daemon/pty-session-id.test.ts +++ b/src/main/daemon/pty-session-id.test.ts @@ -75,6 +75,10 @@ describe('isSafePtySessionId', () => { // validator must allow that as long as the result stays inside userData. expect(isSafePtySessionId('sub/path/ok@@12345678', USER_DATA)).toBe(true) }) + + it('accepts ids with path segments that merely start with dot-dot', () => { + expect(isSafePtySessionId('..sessions/ok@@12345678', USER_DATA)).toBe(true) + }) }) describe('parsePtySessionId', () => { diff --git a/src/main/daemon/pty-session-id.ts b/src/main/daemon/pty-session-id.ts index fec681d1c19..447c5f3616b 100644 --- a/src/main/daemon/pty-session-id.ts +++ b/src/main/daemon/pty-session-id.ts @@ -61,7 +61,7 @@ export function isSafePtySessionId(id: string, userDataPath: string): boolean { // from C:\userdata to D:\evil yields "D:\evil", which does NOT start with // ".."). Reject any absolute result — a legitimate subpath under // userData always produces a relative result on every platform. - if (rel === '' || isAbsolute(rel) || rel.startsWith('..') || rel.includes(`..${sep}`)) { + if (rel === '' || isAbsolute(rel) || rel === '..' || rel.startsWith(`..${sep}`)) { return false } return true diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 02ef5c8bb00..0001f3330b0 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -792,6 +792,37 @@ describe('createPtySubprocess', () => { ) }) + it('launches Git Bash with login args and CHERE_INVOKING on Windows', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + + Object.defineProperty(process, 'platform', { value: 'win32' }) + + try { + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + cwd: 'C:\\Users\\jin\\repo', + shellOverride: 'C:\\PortableGit\\bin\\bash.exe' + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + expect(spawnMock).toHaveBeenCalledWith( + 'C:\\PortableGit\\bin\\bash.exe', + ['--login', '-i'], + expect.objectContaining({ + cwd: 'C:\\Users\\jin\\repo', + env: expect.objectContaining({ CHERE_INVOKING: '1' }) + }) + ) + }) + it('rejects a missing explicit native Windows cwd before node-pty spawn', () => { const platform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { value: 'win32' }) @@ -894,7 +925,12 @@ describe('createPtySubprocess', () => { expect(spawnMock).toHaveBeenCalledWith( 'wsl.exe', - ['--', 'bash', '-c', `cd '${expectedLinuxCwd}' && exec bash -l`], + [ + '--', + 'bash', + '-c', + `cd '${expectedLinuxCwd}' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l` + ], expect.objectContaining({ cwd: expect.any(String) }) ) }) @@ -931,7 +967,14 @@ describe('createPtySubprocess', () => { expect(spawnMock).toHaveBeenCalledWith( 'wsl.exe', - ['-d', 'Debian', '--', 'bash', '-c', `cd '${expectedLinuxCwd}' && exec bash -l`], + [ + '-d', + 'Debian', + '--', + 'bash', + '-c', + `cd '${expectedLinuxCwd}' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l` + ], expect.objectContaining({ cwd: expect.any(String) }) ) }) @@ -959,7 +1002,14 @@ describe('createPtySubprocess', () => { expect(spawnMock).toHaveBeenCalledWith( 'wsl.exe', - ['-d', 'Ubuntu', '--', 'bash', '-c', "cd '/home/jin/repo' && exec bash -l"], + [ + '-d', + 'Ubuntu', + '--', + 'bash', + '-c', + 'cd \'/home/jin/repo\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l' + ], expect.objectContaining({ cwd: expect.any(String) }) ) }) @@ -987,7 +1037,14 @@ describe('createPtySubprocess', () => { expect(spawnMock).toHaveBeenCalledWith( 'wsl.exe', - ['-d', 'Ubuntu', '--', 'bash', '-c', "cd '/home/jin/repo' && exec bash -l"], + [ + '-d', + 'Ubuntu', + '--', + 'bash', + '-c', + 'cd \'/home/jin/repo\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l' + ], expect.objectContaining({ env: expect.not.objectContaining({ CODEX_HOME: expect.anything(), @@ -1072,7 +1129,14 @@ describe('createPtySubprocess', () => { expect(spawnMock).toHaveBeenCalledWith( 'wsl.exe', - ['-d', 'Ubuntu', '--', 'bash', '-c', `cd '${expectedLinuxCwd}' && exec bash -l`], + [ + '-d', + 'Ubuntu', + '--', + 'bash', + '-c', + `cd '${expectedLinuxCwd}' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l` + ], expect.objectContaining({ env: expect.objectContaining({ CODEX_HOME: '/home/jin/.local/share/orca/codex-accounts/a/home', @@ -1106,13 +1170,56 @@ describe('createPtySubprocess', () => { expect(spawnMock).toHaveBeenCalledWith( 'wsl.exe', - ['-d', 'Ubuntu', '--', 'bash', '-c', "cd '/home/jin/repo' && exec bash -l"], + [ + '-d', + 'Ubuntu', + '--', + 'bash', + '-c', + 'cd \'/home/jin/repo\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l' + ], expect.objectContaining({ env: expect.objectContaining({ CODEX_HOME: '/home/jin/.codex-alt' }) }) ) }) + it('marks Orca terminal handles for WSL env import in daemon WSL terminals', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + + Object.defineProperty(process, 'platform', { value: 'win32' }) + + try { + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo', + env: { + ORCA_TERMINAL_HANDLE: 'term_wsl', + WSLENV: 'FOO/u' + } + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + expect(spawnMock).toHaveBeenCalledWith( + 'wsl.exe', + expect.any(Array), + expect.objectContaining({ + env: expect.objectContaining({ + ORCA_TERMINAL_HANDLE: 'term_wsl', + WSLENV: 'FOO/u:ORCA_TERMINAL_HANDLE/u' + }) + }) + ) + }) + it('keeps daemon WSL split panes in their distro when cwd is already POSIX', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) @@ -1139,7 +1246,14 @@ describe('createPtySubprocess', () => { ) expect(spawnMock).toHaveBeenCalledWith( 'wsl.exe', - ['-d', 'Ubuntu', '--', 'bash', '-c', "cd '/home/jin/repo/subdir' && exec bash -l"], + [ + '-d', + 'Ubuntu', + '--', + 'bash', + '-c', + 'cd \'/home/jin/repo/subdir\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l' + ], expect.objectContaining({ cwd: expect.any(String) }) ) }) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 182612cb1a0..4b157bfcb75 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -24,6 +24,9 @@ import { removeInheritedNoColor } from '../pty/terminal-color-env' import { parseWslPath } from '../wsl' import { addWslEnvKeys } from '../wsl-env' import { getWslContextFromSessionId } from './wsl-session-context' +import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env' +import { isWindowsGitBashShellPath, resolveWindowsGitBashShellPath } from '../git-bash' +import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell' const PANE_IDENTITY_ENV_KEYS = ['ORCA_PANE_KEY', 'ORCA_TAB_ID', 'ORCA_WORKTREE_ID'] as const @@ -238,6 +241,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl if (process.platform === 'win32') { const normalizedShellFamily = pathWin32.basename(shellPath).toLowerCase() + const resolvedGitBashPath = resolveWindowsGitBashShellPath(shellPath) // Why: daemon spawn requests can carry either a canonical shell family // (`powershell.exe`) or a concrete PowerShell executable path from a // one-off override. Normalize both forms back to the PowerShell family so @@ -246,18 +250,24 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl const shouldResolvePowerShellFamily = opts.terminalWindowsPowerShellImplementation !== undefined || pathWin32.basename(shellPath) === shellPath - shellPath = shouldResolvePowerShellFamily - ? (resolveEffectiveWindowsPowerShell({ - shellFamily: - normalizedShellFamily === 'powershell.exe' || normalizedShellFamily === 'pwsh.exe' - ? 'powershell.exe' - : normalizedShellFamily === 'cmd.exe' || normalizedShellFamily === 'wsl.exe' - ? normalizedShellFamily - : undefined, - implementation: opts.terminalWindowsPowerShellImplementation, - pwshAvailable: isPwshAvailable() - }) ?? shellPath) - : shellPath + if (resolvedGitBashPath) { + shellPath = resolvedGitBashPath + } else if (shellPath === WINDOWS_GIT_BASH_SHELL) { + shellPath = 'powershell.exe' + } else { + shellPath = shouldResolvePowerShellFamily + ? (resolveEffectiveWindowsPowerShell({ + shellFamily: + normalizedShellFamily === 'powershell.exe' || normalizedShellFamily === 'pwsh.exe' + ? 'powershell.exe' + : normalizedShellFamily === 'cmd.exe' || normalizedShellFamily === 'wsl.exe' + ? normalizedShellFamily + : undefined, + implementation: opts.terminalWindowsPowerShellImplementation, + pwshAvailable: isPwshAvailable() + }) ?? shellPath) + : shellPath + } // Why: matches LocalPtyProvider — CMD needs chcp 65001, PowerShell needs // $PROFILE dot-sourcing, WSL needs a --bash entry with a translated cwd. // Reuse the same shared launch-args helper after resolving the effective @@ -272,6 +282,11 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl shellArgs = resolved.shellArgs spawnCwd = resolved.effectiveCwd validationCwd = resolved.validationCwd + if (isWindowsGitBashShellPath(shellPath)) { + // Why: Git for Windows login startup files otherwise cd to $HOME, + // ignoring node-pty's cwd for repo-scoped terminals. + env.CHERE_INVOKING ??= '1' + } const codexHomeWslInfo = env.CODEX_HOME ? parseWslPath(env.CODEX_HOME) : null if (pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe') { if (codexHomeWslInfo) { @@ -319,6 +334,9 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl delete env.CODEX_HOME delete env.ORCA_CODEX_HOME } + if (pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe') { + addOrcaWslInteropEnv(env) + } } else { // Why: any Orca-injected overlay env that user rc files can clobber // needs the wrapper so the post-rc restore line runs. diff --git a/src/main/droid/hook-service.test.ts b/src/main/droid/hook-service.test.ts index 2ee333d3610..5608cc10dd8 100644 --- a/src/main/droid/hook-service.test.ts +++ b/src/main/droid/hook-service.test.ts @@ -63,6 +63,7 @@ describe('DroidHookService', () => { 'PreToolUse', 'SessionStart', 'Stop', + 'SubagentStop', 'UserPromptSubmit' ].sort() ) diff --git a/src/main/droid/hook-service.ts b/src/main/droid/hook-service.ts index 7bcaba1bbd3..0f29c6dd82b 100644 --- a/src/main/droid/hook-service.ts +++ b/src/main/droid/hook-service.ts @@ -21,6 +21,9 @@ const DROID_EVENTS = [ { eventName: 'SessionStart', definition: { hooks: [{ type: 'command', command: '' }] } }, { eventName: 'UserPromptSubmit', definition: { hooks: [{ type: 'command', command: '' }] } }, { eventName: 'Stop', definition: { hooks: [{ type: 'command', command: '' }] } }, + // Why: sub-droid completion is mission progress, not parent Droid completion. + // The listener intentionally ignores SubagentStop so it cannot notify. + { eventName: 'SubagentStop', definition: { hooks: [{ type: 'command', command: '' }] } }, { eventName: 'PreToolUse', definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } @@ -83,7 +86,9 @@ function getManagedScript(): string { 'if [ -z "$payload" ]; then', ' exit 0', 'fi', + // Timeout caps best-effort hook posts if the local listener stalls. 'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/droid" \\', + ' --connect-timeout 0.5 --max-time 1.5 \\', ' -H "Content-Type: application/x-www-form-urlencoded" \\', ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', diff --git a/src/main/gemini/hook-service.ts b/src/main/gemini/hook-service.ts index 4ff71a10eec..486c4752ebf 100644 --- a/src/main/gemini/hook-service.ts +++ b/src/main/gemini/hook-service.ts @@ -91,7 +91,9 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { // Why: worktreeId embeds a filesystem path, so hand-building JSON in POSIX // shell is not safe once a path contains quotes or newlines. Post the raw // hook payload plus metadata as form fields and let the receiver parse it. + // Timeout caps best-effort hook posts if the local listener stalls. 'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/gemini" \\', + ' --connect-timeout 0.5 --max-time 1.5 \\', ' -H "Content-Type: application/x-www-form-urlencoded" \\', ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', diff --git a/src/main/ghostty/discovery.test.ts b/src/main/ghostty/discovery.test.ts index 53df7b28e15..30582fc32e9 100644 --- a/src/main/ghostty/discovery.test.ts +++ b/src/main/ghostty/discovery.test.ts @@ -16,7 +16,7 @@ vi.mock('fs/promises', () => ({ stat: statMock })) -import { findGhosttyConfigPath, getGhosttyConfigPaths } from './discovery' +import { findGhosttyConfigPath, findGhosttyConfigPaths, getGhosttyConfigPaths } from './discovery' function enoent(): Error { return Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) @@ -48,10 +48,10 @@ describe('getGhosttyConfigPaths', () => { const paths = getGhosttyConfigPaths() expect(paths).toEqual([ - '/Users/alice/.config/ghostty/config', '/Users/alice/.config/ghostty/config.ghostty', - '/Users/alice/Library/Application Support/com.mitchellh.ghostty/config', - '/Users/alice/Library/Application Support/com.mitchellh.ghostty/config.ghostty' + '/Users/alice/.config/ghostty/config', + '/Users/alice/Library/Application Support/com.mitchellh.ghostty/config.ghostty', + '/Users/alice/Library/Application Support/com.mitchellh.ghostty/config' ]) }) @@ -62,10 +62,10 @@ describe('getGhosttyConfigPaths', () => { const paths = getGhosttyConfigPaths() expect(paths).toEqual([ - '/custom/xdg/ghostty/config', '/custom/xdg/ghostty/config.ghostty', - '/Users/alice/Library/Application Support/com.mitchellh.ghostty/config', - '/Users/alice/Library/Application Support/com.mitchellh.ghostty/config.ghostty' + '/custom/xdg/ghostty/config', + '/Users/alice/Library/Application Support/com.mitchellh.ghostty/config.ghostty', + '/Users/alice/Library/Application Support/com.mitchellh.ghostty/config' ]) }) @@ -76,8 +76,8 @@ describe('getGhosttyConfigPaths', () => { const paths = getGhosttyConfigPaths() expect(paths).toEqual([ - '/home/bob/.config/ghostty/config', - '/home/bob/.config/ghostty/config.ghostty' + '/home/bob/.config/ghostty/config.ghostty', + '/home/bob/.config/ghostty/config' ]) }) @@ -88,8 +88,8 @@ describe('getGhosttyConfigPaths', () => { const paths = getGhosttyConfigPaths() expect(paths).toEqual([ - '/custom/config/ghostty/config', - '/custom/config/ghostty/config.ghostty' + '/custom/config/ghostty/config.ghostty', + '/custom/config/ghostty/config' ]) }) @@ -100,8 +100,8 @@ describe('getGhosttyConfigPaths', () => { const paths = getGhosttyConfigPaths() expect(paths).toEqual([ - path.win32.join('C:\\Users\\Charlie\\AppData\\Roaming', 'ghostty', 'config'), - path.win32.join('C:\\Users\\Charlie\\AppData\\Roaming', 'ghostty', 'config.ghostty') + path.win32.join('C:\\Users\\Charlie\\AppData\\Roaming', 'ghostty', 'config.ghostty'), + path.win32.join('C:\\Users\\Charlie\\AppData\\Roaming', 'ghostty', 'config') ]) }) @@ -179,7 +179,7 @@ describe('findGhosttyConfigPath', () => { expect(result).toBe('/Users/alice/.config/ghostty/config.ghostty') }) - it('prefers config over config.ghostty when both exist in the same directory', async () => { + it('returns all existing config files in Ghostty load order', async () => { homedirMock.mockReturnValue('/Users/alice') platformMock.mockReturnValue('darwin') delete process.env.XDG_CONFIG_HOME @@ -194,8 +194,11 @@ describe('findGhosttyConfigPath', () => { throw enoent() }) - const result = await findGhosttyConfigPath() - expect(result).toBe('/Users/alice/.config/ghostty/config') + const result = await findGhosttyConfigPaths() + expect(result).toEqual([ + '/Users/alice/.config/ghostty/config.ghostty', + '/Users/alice/.config/ghostty/config' + ]) }) it('returns null when no config exists on macOS', async () => { diff --git a/src/main/ghostty/discovery.ts b/src/main/ghostty/discovery.ts index 8dbfaf85afd..27d7b08e83d 100644 --- a/src/main/ghostty/discovery.ts +++ b/src/main/ghostty/discovery.ts @@ -10,9 +10,10 @@ function xdgConfigDirs(home: string): string[] { return [path.join(home, '.config', 'ghostty')] } -// Why: Check legacy `config` first, then modern `config.ghostty` per Ghostty docs. +// Why: Ghostty loads the modern filename before the legacy `config` fallback, +// and later files in this order override earlier files. function withFilenames(dirs: string[]): string[] { - return dirs.flatMap((dir) => [path.join(dir, 'config'), path.join(dir, 'config.ghostty')]) + return dirs.flatMap((dir) => [path.join(dir, 'config.ghostty'), path.join(dir, 'config')]) } export function getGhosttyConfigPaths(): string[] { @@ -33,23 +34,36 @@ export function getGhosttyConfigPaths(): string[] { const appData = process.env.APPDATA || home const base = path.win32.join(appData, 'ghostty') // Why: path.win32.join preserves backslashes even when tests run on macOS/Linux. - return [path.win32.join(base, 'config'), path.win32.join(base, 'config.ghostty')] + return [path.win32.join(base, 'config.ghostty'), path.win32.join(base, 'config')] } - default: + case 'aix': + case 'android': + case 'cygwin': + case 'freebsd': + case 'haiku': + case 'netbsd': + case 'openbsd': + case 'sunos': return [] } } -export async function findGhosttyConfigPath(): Promise { +export async function findGhosttyConfigPaths(): Promise { + const found: string[] = [] for (const p of getGhosttyConfigPaths()) { try { const s = await stat(p) if (s.isFile()) { - return p + found.push(p) } } catch { // ENOENT or permission error — continue probing other paths. } } - return null + return found +} + +export async function findGhosttyConfigPath(): Promise { + const paths = await findGhosttyConfigPaths() + return paths[0] ?? null } diff --git a/src/main/ghostty/index.test.ts b/src/main/ghostty/index.test.ts index ce4e70df05a..37d059c91fd 100644 --- a/src/main/ghostty/index.test.ts +++ b/src/main/ghostty/index.test.ts @@ -1,6 +1,6 @@ import type { Store } from '../persistence' import type { GlobalSettings } from '../../shared/types' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' const { statMock, readFileMock } = vi.hoisted(() => ({ statMock: vi.fn(), @@ -19,6 +19,17 @@ vi.mock('os', () => ({ import { previewGhosttyImport } from './index' +const originalXdgConfigHome = process.env.XDG_CONFIG_HOME + +afterEach(() => { + vi.clearAllMocks() + if (originalXdgConfigHome !== undefined) { + process.env.XDG_CONFIG_HOME = originalXdgConfigHome + } else { + delete process.env.XDG_CONFIG_HOME + } +}) + function createStore(settings: Record = {}): Store { return { getSettings: () => settings as GlobalSettings @@ -66,6 +77,43 @@ background = #1a1a1a expect(result.unsupportedKeys).toEqual([]) }) + it('imports every discovered config file in Ghostty load order', async () => { + delete process.env.XDG_CONFIG_HOME + statMock.mockImplementation(async (p: string) => { + if ( + p === '/Users/alice/.config/ghostty/config.ghostty' || + p === '/Users/alice/.config/ghostty/config' + ) { + return { isFile: () => true } + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + readFileMock.mockImplementation(async (p: string) => { + if (p === '/Users/alice/.config/ghostty/config.ghostty') { + return 'font-size = 22\nbackground = #1a1a1a\n' + } + if (p === '/Users/alice/.config/ghostty/config') { + return 'font-family = JetBrains Mono\nfont-size = 18\n' + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + + const result = await previewGhosttyImport(createStore()) + + expect(result.found).toBe(true) + expect(result.configPath).toBe('/Users/alice/.config/ghostty/config.ghostty') + expect(result.configPaths).toEqual([ + '/Users/alice/.config/ghostty/config.ghostty', + '/Users/alice/.config/ghostty/config' + ]) + expect(result.diff).toEqual({ + terminalFontFamily: 'JetBrains Mono', + terminalFontSize: 18, + terminalColorOverrides: { background: '#1a1a1a' } + }) + expect(result.unsupportedKeys).toEqual([]) + }) + it('omits values that match current settings', async () => { statMock.mockImplementation(async (p: string) => { if (p === '/Users/alice/Library/Application Support/com.mitchellh.ghostty/config') { diff --git a/src/main/ghostty/index.ts b/src/main/ghostty/index.ts index 63e8d5d20d7..c7aa8f81835 100644 --- a/src/main/ghostty/index.ts +++ b/src/main/ghostty/index.ts @@ -2,7 +2,7 @@ import { readFile, stat } from 'fs/promises' import { platform } from 'os' import type { GlobalSettings, GhosttyImportPreview } from '../../shared/types' import type { Store } from '../persistence' -import { findGhosttyConfigPath } from './discovery' +import { findGhosttyConfigPaths } from './discovery' import { parseGhosttyConfig } from './parser' import { mapGhosttyToOrca } from './mapper' @@ -41,35 +41,58 @@ function valuesEqual(a: unknown, b: unknown): boolean { return false } +function asArray(value: string | string[] | undefined): string[] { + if (value === undefined) { + return [] + } + return Array.isArray(value) ? value : [value] +} + +function mergeParsedConfig( + target: Record, + parsed: Record +): void { + for (const [key, value] of Object.entries(parsed)) { + if (key === 'palette') { + target[key] = [...asArray(target[key]), ...asArray(value)] + continue + } + target[key] = value + } +} + export async function previewGhosttyImport(store: Store): Promise { - const configPath = await findGhosttyConfigPath() - if (!configPath) { + const configPaths = await findGhosttyConfigPaths() + if (configPaths.length === 0) { return { found: false, diff: {}, unsupportedKeys: [] } } - let content: string - try { - const info = await stat(configPath) - if (info.size > MAX_CONFIG_BYTES) { + const parsed: Record = {} + for (const configPath of configPaths) { + let content: string + try { + const info = await stat(configPath) + if (info.size > MAX_CONFIG_BYTES) { + return { + found: false, + diff: {}, + unsupportedKeys: [], + error: `Config file is too large to import (${info.size} bytes, limit ${MAX_CONFIG_BYTES}).` + } + } + content = await readFile(configPath, 'utf-8') + } catch (err) { + const message = err instanceof Error ? err.message : 'Could not read config file' return { found: false, diff: {}, unsupportedKeys: [], - error: `Config file is too large to import (${info.size} bytes, limit ${MAX_CONFIG_BYTES}).` + error: `Could not read config: ${message}` } } - content = await readFile(configPath, 'utf-8') - } catch (err) { - const message = err instanceof Error ? err.message : 'Could not read config file' - return { - found: false, - diff: {}, - unsupportedKeys: [], - error: `Could not read config: ${message}` - } + mergeParsedConfig(parsed, parseGhosttyConfig(content)) } - const parsed = parseGhosttyConfig(content) const { diff: rawDiff, unsupportedKeys } = mapGhosttyToOrca(parsed, platform() === 'darwin') const currentSettings = store.getSettings() @@ -85,7 +108,8 @@ export async function previewGhosttyImport(store: Store): Promise { expect(result.unsupportedKeys).toEqual([]) }) + it('uses the latest value when a scalar key is repeated', () => { + const result = mapGhosttyToOrca({ 'font-size': ['12', '14'] }) + expect(result.diff).toEqual({ terminalFontSize: 14 }) + expect(result.unsupportedKeys).toEqual([]) + }) + it('rejects out-of-range font-weight', () => { const result = mapGhosttyToOrca({ 'font-weight': '50' }) expect(result.diff).toEqual({}) diff --git a/src/main/ghostty/mapper.ts b/src/main/ghostty/mapper.ts index 496de3cadbf..69abcbff4d7 100644 --- a/src/main/ghostty/mapper.ts +++ b/src/main/ghostty/mapper.ts @@ -272,7 +272,7 @@ export function mapGhosttyToOrca( } for (const [key, rawValue] of Object.entries(parsed)) { - const value = Array.isArray(rawValue) ? rawValue[0] : rawValue + const value = Array.isArray(rawValue) ? (rawValue.at(-1) ?? '') : rawValue // Why: Ghostty's selection-word-chars defines characters that ARE part of a // word, while xterm.js wordSeparator defines characters that BREAK words. diff --git a/src/main/git-bash.test.ts b/src/main/git-bash.test.ts new file mode 100644 index 00000000000..5cfdc33b533 --- /dev/null +++ b/src/main/git-bash.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from 'vitest' +import { + getGitBashCandidatePaths, + isWindowsGitBashShellPath, + resolveGitBashPath, + resolveWindowsGitBashShellPath +} from './git-bash' + +describe('Git Bash path discovery', () => { + it('includes common Git for Windows install locations and PATH-derived fallbacks', () => { + const candidates = getGitBashCandidatePaths({ + ProgramFiles: 'C:\\Program Files', + LOCALAPPDATA: 'C:\\Users\\alice\\AppData\\Local', + Path: '"C:\\Program Files\\Git\\cmd";C:\\tools;C:\\PortableGit\\bin' + }) + + expect(candidates).toContain('C:\\Program Files\\Git\\bin\\bash.exe') + expect(candidates).toContain('C:\\Users\\alice\\AppData\\Local\\Programs\\Git\\bin\\bash.exe') + expect(candidates).toContain('C:\\Program Files\\Git\\usr\\bin\\bash.exe') + expect(candidates).toContain('C:\\PortableGit\\bin\\bash.exe') + expect(candidates).not.toContain('C:\\tools\\bash.exe') + }) + + it('resolves the first existing common bash.exe path on Windows', () => { + const exists = vi.fn((path: string) => path === 'C:\\Program Files\\Git\\bin\\bash.exe') + + expect( + resolveGitBashPath({ + platform: 'win32', + env: { ProgramFiles: 'C:\\Program Files' }, + exists + }) + ).toBe('C:\\Program Files\\Git\\bin\\bash.exe') + }) + + it('does not expose Git Bash discovery on non-Windows hosts', () => { + expect( + resolveGitBashPath({ + platform: 'darwin', + env: { ProgramFiles: 'C:\\Program Files' }, + exists: () => true + }) + ).toBeNull() + }) + + it('maps the persisted Git Bash sentinel to a discovered bash.exe path', () => { + expect( + resolveWindowsGitBashShellPath('git-bash', { + platform: 'win32', + env: { LOCALAPPDATA: 'C:\\Users\\alice\\AppData\\Local' }, + exists: (path) => path === 'C:\\Users\\alice\\AppData\\Local\\Programs\\Git\\bin\\bash.exe' + }) + ).toBe('C:\\Users\\alice\\AppData\\Local\\Programs\\Git\\bin\\bash.exe') + }) + + it('honors an explicit bash.exe path for future user-configurable launch paths', () => { + expect(resolveWindowsGitBashShellPath('D:\\PortableGit\\bin\\bash.exe')).toBe( + 'D:\\PortableGit\\bin\\bash.exe' + ) + }) + + it('recognizes Git Bash executable paths case-insensitively', () => { + expect(isWindowsGitBashShellPath('D:\\PortableGit\\BIN\\BASH.EXE')).toBe(true) + }) + + it('does not classify arbitrary bash.exe paths as Git Bash', () => { + expect(resolveWindowsGitBashShellPath('C:\\msys64\\usr\\bin\\bash.exe')).toBeNull() + expect(isWindowsGitBashShellPath('C:\\cygwin64\\bin\\bash.exe')).toBe(false) + }) + + it('ignores non-Git bash.exe candidates discovered through PATH', () => { + expect( + resolveGitBashPath({ + platform: 'win32', + env: { Path: 'C:\\msys64\\usr\\bin' }, + exists: (path) => path === 'C:\\msys64\\usr\\bin\\bash.exe' + }) + ).toBeNull() + }) +}) diff --git a/src/main/git-bash.ts b/src/main/git-bash.ts new file mode 100644 index 00000000000..40acd0fb945 --- /dev/null +++ b/src/main/git-bash.ts @@ -0,0 +1,143 @@ +import { existsSync } from 'fs' +import { win32 as pathWin32 } from 'path' +import { WINDOWS_GIT_BASH_SHELL } from '../shared/windows-terminal-shell' + +type GitBashPathOptions = { + env?: NodeJS.ProcessEnv + exists?: (path: string) => boolean + platform?: NodeJS.Platform +} + +function readEnv(env: NodeJS.ProcessEnv, names: string[]): string | undefined { + for (const name of names) { + const value = env[name] + if (value) { + return value + } + } + return undefined +} + +function normalizePathSegment(segment: string): string { + const trimmed = segment.trim() + return trimmed.startsWith('"') && trimmed.endsWith('"') ? trimmed.slice(1, -1) : trimmed +} + +function pushCandidate( + candidates: string[], + seen: Set, + candidate: string | undefined +): void { + if (!candidate) { + return + } + const normalized = pathWin32.normalize(candidate) + const key = normalized.toLowerCase() + if (!seen.has(key)) { + seen.add(key) + candidates.push(normalized) + } +} + +export function getGitBashCandidatePaths(env: NodeJS.ProcessEnv = process.env): string[] { + const candidates: string[] = [] + const seen = new Set() + const roots = [ + readEnv(env, ['ProgramFiles', 'PROGRAMFILES']), + readEnv(env, ['ProgramW6432', 'PROGRAMW6432']), + readEnv(env, ['ProgramFiles(x86)', 'PROGRAMFILES(X86)']), + readEnv(env, ['LOCALAPPDATA', 'LocalAppData']) + ] + + for (const root of roots) { + if (!root) { + continue + } + pushCandidate(candidates, seen, pathWin32.join(root, 'Git', 'bin', 'bash.exe')) + pushCandidate(candidates, seen, pathWin32.join(root, 'Git', 'usr', 'bin', 'bash.exe')) + pushCandidate(candidates, seen, pathWin32.join(root, 'Programs', 'Git', 'bin', 'bash.exe')) + pushCandidate( + candidates, + seen, + pathWin32.join(root, 'Programs', 'Git', 'usr', 'bin', 'bash.exe') + ) + } + + const pathValue = readEnv(env, ['Path', 'PATH']) + if (pathValue) { + for (const rawSegment of pathValue.split(pathWin32.delimiter)) { + const segment = normalizePathSegment(rawSegment) + if (!segment) { + continue + } + const directBashCandidate = pathWin32.join(segment, 'bash.exe') + if (isGitForWindowsBashPath(directBashCandidate)) { + pushCandidate(candidates, seen, directBashCandidate) + } + + const basename = pathWin32.basename(segment).toLowerCase() + const parent = pathWin32.dirname(segment) + const parentBasename = pathWin32.basename(parent).toLowerCase() + if (basename === 'cmd' && (parentBasename === 'git' || parentBasename === 'portablegit')) { + pushCandidate(candidates, seen, pathWin32.join(parent, 'bin', 'bash.exe')) + pushCandidate(candidates, seen, pathWin32.join(parent, 'usr', 'bin', 'bash.exe')) + } else if (basename === 'git' || basename === 'portablegit') { + pushCandidate(candidates, seen, pathWin32.join(segment, 'bin', 'bash.exe')) + pushCandidate(candidates, seen, pathWin32.join(segment, 'usr', 'bin', 'bash.exe')) + } + } + } + + return candidates +} + +export function resolveGitBashPath(options: GitBashPathOptions = {}): string | null { + const platform = options.platform ?? process.platform + if (platform !== 'win32') { + return null + } + const exists = options.exists ?? existsSync + for (const candidate of getGitBashCandidatePaths(options.env ?? process.env)) { + if (isGitForWindowsBashPath(candidate) && exists(candidate)) { + return candidate + } + } + return null +} + +export function isGitBashAvailable(): boolean { + return resolveGitBashPath() !== null +} + +export function isGitForWindowsBashPath(shellPath: string): boolean { + const normalized = pathWin32.normalize(shellPath).toLowerCase() + return /(?:^|\\)(?:git|portablegit)(?:\\usr)?\\bin\\bash\.exe$/.test(normalized) +} + +export function resolveWindowsGitBashShellPath( + shell: string, + options: GitBashPathOptions = {} +): string | null { + const trimmed = shell.trim() + if (!trimmed) { + return null + } + if (trimmed === WINDOWS_GIT_BASH_SHELL) { + return resolveGitBashPath(options) + } + + const shellBasename = pathWin32.basename(trimmed).toLowerCase() + if (shellBasename !== 'bash.exe') { + return null + } + + if (pathWin32.isAbsolute(trimmed) || trimmed.includes('\\') || trimmed.includes('/')) { + return isGitForWindowsBashPath(trimmed) ? trimmed : null + } + + return resolveGitBashPath(options) +} + +export function isWindowsGitBashShellPath(shellPath: string): boolean { + return isGitForWindowsBashPath(shellPath) +} diff --git a/src/main/git/hosted-remote-url.test.ts b/src/main/git/hosted-remote-url.test.ts index 8baff672454..60a30e0dc07 100644 --- a/src/main/git/hosted-remote-url.test.ts +++ b/src/main/git/hosted-remote-url.test.ts @@ -50,11 +50,11 @@ describe('hosted remote URLs', () => { expect( buildHostedRemoteFileUrl('git@gitlab.com:group/sub/repo.git', 'src/a.ts', 'feature/x', 9) - ).toBe('https://gitlab.com/group/sub/repo/tree/feature%2Fx/src/a.ts#L9') + ).toBe('https://gitlab.com/group/sub/repo/-/blob/feature%2Fx/src/a.ts#L9') expect( buildHostedRemoteFileUrl('git@bitbucket.org:team/repo.git', 'src/a.ts', 'feature/x', 7) - ).toBe('https://bitbucket.org/team/repo/src/feature%2Fx/src/a.ts#L7') + ).toBe('https://bitbucket.org/team/repo/src/feature%2Fx/src/a.ts#a.ts-7') expect( buildHostedRemoteFileUrl( @@ -66,6 +66,12 @@ describe('hosted remote URLs', () => { ).toBe('https://github.com/Org/Repo/blob/feature%2Fx/src/a.ts#L5') }) + it('builds Bitbucket line fragments from the target file name', () => { + expect( + buildHostedRemoteFileUrl('https://bitbucket.org/team/repo.git', 'src/a file.ts', 'main', 29) + ).toBe('https://bitbucket.org/team/repo/src/main/src/a%20file.ts#a%20file.ts-29') + }) + it('rejects unsupported hosts and incomplete repo paths', () => { expect(parseHostedRemote('git@example.com:team/repo.git')).toBeNull() expect(parseHostedRemote('git@github.com:repo.git')).toBeNull() diff --git a/src/main/git/hosted-remote-url.ts b/src/main/git/hosted-remote-url.ts index 5fd487346e6..0af711b1f89 100644 --- a/src/main/git/hosted-remote-url.ts +++ b/src/main/git/hosted-remote-url.ts @@ -96,6 +96,11 @@ function encodeRelativePath(path: string): string { return path.replaceAll('\\', '/').split('/').filter(Boolean).map(encodeURIComponent).join('/') } +function encodeBitbucketFileLineFragment(path: string, line: number): string { + const fileName = path.replaceAll('\\', '/').split('/').filter(Boolean).at(-1) + return fileName ? `#${encodeURIComponent(`${fileName}-${line}`)}` : '' +} + export function buildHostedRemoteFileUrl( remoteUrl: string, relativePath: string, @@ -117,7 +122,7 @@ export function buildHostedRemoteFileUrl( return `${baseUrl}/blob/${encodedBranch}${filePathSuffix}#L${line}` } if (remote.provider === 'gitlab') { - return `${baseUrl}/tree/${encodedBranch}${filePathSuffix}#L${line}` + return `${baseUrl}/-/blob/${encodedBranch}${filePathSuffix}#L${line}` } - return `${baseUrl}/src/${encodedBranch}${filePathSuffix}#L${line}` + return `${baseUrl}/src/${encodedBranch}${filePathSuffix}${encodeBitbucketFileLineFragment(relativePath, line)}` } diff --git a/src/main/git/remote.test.ts b/src/main/git/remote.test.ts index b86b3eee4ca..e6ebc146533 100644 --- a/src/main/git/remote.test.ts +++ b/src/main/git/remote.test.ts @@ -335,6 +335,22 @@ describe('git remote operations', () => { ]) }) + it('fetches explicit publish target remotes whose names contain slashes', async () => { + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + + await gitFetch('/repo', { + remoteName: 'foo/bar', + branchName: 'feature/fix' + }) + + expect(gitExecFileAsyncMock.mock.calls).toEqual([ + [['check-ref-format', '--branch', 'feature/fix'], { cwd: '/repo' }], + [['fetch', '--prune', 'foo/bar'], { cwd: '/repo' }] + ]) + }) + it('normalizes fetch authentication errors to a friendly message', async () => { gitExecFileAsyncMock.mockRejectedValueOnce(new Error('Authentication failed')) diff --git a/src/main/git/remove-worktree.test.ts b/src/main/git/remove-worktree.test.ts index 9790e014f51..4293b812753 100644 --- a/src/main/git/remove-worktree.test.ts +++ b/src/main/git/remove-worktree.test.ts @@ -35,6 +35,7 @@ vi.mock('fs/promises', async () => { import { addSparseWorktree, assertWorktreeCleanForRemoval, + forceDeleteLocalBranch, listWorktrees, removeWorktree } from './worktree' @@ -51,7 +52,15 @@ function mockGitCommands(results: Record): void { const key = `git ${args.join(' ')}` const callCount = (callCounts.get(key) ?? 0) + 1 callCounts.set(key, callCount) - const result = results[`${key}#${callCount}`] ?? results[key] ?? {} + const lineListKey = + key === 'git worktree list --porcelain -z' ? 'git worktree list --porcelain' : '' + const result = + results[`${key}#${callCount}`] ?? + results[key] ?? + (lineListKey + ? (results[`${lineListKey}#${callCount}`] ?? results[lineListKey]) + : undefined) ?? + {} if (result.error) { throw Object.assign(result.error, { @@ -117,11 +126,11 @@ branch refs/heads/main expect.arrayContaining([ 'git worktree remove /repo-feature', 'git worktree prune', - 'git branch -d feature/test' + 'git branch -d -- feature/test' ]) ) expectGitCallOrder(calls, 'git worktree remove /repo-feature', 'git worktree prune') - expectGitCallOrder(calls, 'git worktree prune', 'git branch -d feature/test') + expectGitCallOrder(calls, 'git worktree prune', 'git branch -d -- feature/test') }) it('preserves the branch when requested for a pre-existing local branch checkout', async () => { @@ -144,8 +153,8 @@ branch refs/heads/feature/test expect(calls).toEqual( expect.arrayContaining(['git worktree remove /repo-feature', 'git worktree prune']) ) - expect(calls).not.toContain('git branch -d feature/test') - expect(calls).not.toContain('git branch -D feature/test') + expect(calls).not.toContain('git branch -d -- feature/test') + expect(calls).not.toContain('git branch -D -- feature/test') }) it('skips branch deletion when another worktree still points at the branch', async () => { @@ -183,11 +192,11 @@ branch refs/heads/feature/test expect.arrayContaining([ 'git worktree remove /repo-feature', 'git worktree prune', - 'git worktree list --porcelain' + 'git worktree list --porcelain -z' ]) ) - expect(calls).not.toContain('git branch -d feature/test') - expect(calls).not.toContain('git branch -D feature/test') + expect(calls).not.toContain('git branch -d -- feature/test') + expect(calls).not.toContain('git branch -D -- feature/test') expectGitCallOrder(calls, 'git worktree remove /repo-feature', 'git worktree prune') }) @@ -223,10 +232,10 @@ branch refs/heads/main expect.arrayContaining([ 'git worktree remove /repo-feature', 'git worktree prune', - 'git branch -d feature/test' + 'git branch -d -- feature/test' ]) ) - expectGitCallOrder(calls, 'git worktree prune', 'git branch -d feature/test') + expectGitCallOrder(calls, 'git worktree prune', 'git branch -d -- feature/test') }) it('passes --force before the worktree path when forced removal is requested', async () => { @@ -281,7 +290,7 @@ branch refs/heads/main expect.arrayContaining([ 'git worktree remove c:\\workspaces\\delete-branch-ui-test', 'git worktree prune', - 'git branch -d feature/test' + 'git branch -d -- feature/test' ]) ) }) @@ -305,13 +314,15 @@ HEAD abc123 branch refs/heads/main ` }, - 'git branch -d feature/test': { + 'git branch -d -- feature/test': { error: new Error('branch delete failed'), stderr: 'branch delete failed' } }) - await expect(removeWorktree('/repo', '/repo-feature')).resolves.toBeUndefined() + await expect(removeWorktree('/repo', '/repo-feature')).resolves.toEqual({ + preservedBranch: { branchName: 'feature/test', head: 'def456' } + }) expect(warnSpy).toHaveBeenCalledWith( '[git] Preserved local branch "feature/test" after removing worktree (not fully merged)', @@ -320,6 +331,73 @@ branch refs/heads/main warnSpy.mockRestore() }) + + it('force-deletes a preserved branch only at its saved head', async () => { + mockGitCommands({}) + + await forceDeleteLocalBranch('/repo', 'feature/test', 'def456') + + const calls = getGitCalls() + expect(calls).toContain('git worktree list --porcelain') + expect(calls).toContain('git update-ref -d refs/heads/feature/test def456') + expect(calls).toContain('git config --remove-section branch.feature/test') + }) + + it('refuses to force-delete a preserved branch that is checked out again', async () => { + mockGitCommands({ + 'git worktree list --porcelain': { + stdout: `worktree /repo-feature +HEAD def456 +branch refs/heads/feature/test +` + } + }) + + await expect(forceDeleteLocalBranch('/repo', 'feature/test', 'def456')).rejects.toThrow( + 'checked out in another worktree' + ) + expect(getGitCalls()).not.toContain('git update-ref -d refs/heads/feature/test def456') + }) + + it('restores a preserved branch when a concurrent checkout wins after deletion', async () => { + mockGitCommands({ + 'git worktree list --porcelain': { + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main +` + }, + 'git worktree list --porcelain#2': { + stdout: `worktree /repo-feature +HEAD 0000000000000000000000000000000000000000 +branch refs/heads/feature/test +` + } + }) + + await expect(forceDeleteLocalBranch('/repo', 'feature/test', 'def456')).rejects.toThrow( + 'checked out in another worktree' + ) + expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toContainEqual([ + 'update-ref', + 'refs/heads/feature/test', + 'def456', + '' + ]) + }) + + it('refuses to force-delete a preserved branch after its head changes', async () => { + mockGitCommands({ + 'git update-ref -d refs/heads/feature/test def456': { + error: new Error('cannot lock ref') + } + }) + + await expect(forceDeleteLocalBranch('/repo', 'feature/test', 'def456')).rejects.toThrow( + 'changed after the workspace was deleted' + ) + expect(getGitCalls()).not.toContain('git config --remove-section branch.feature/test') + }) }) describe('assertWorktreeCleanForRemoval', () => { @@ -406,7 +484,7 @@ describe('listWorktrees', () => { // Why: the non-sparse main worktree gets an fs probe of its sparse config // file; the linked worktree short-circuits on the parsed `sparse` token and // does not. Only one git subprocess runs regardless of worktree count. - expect(getGitCalls()).toEqual(['git worktree list --porcelain']) + expect(getGitCalls()).toEqual(['git worktree list --porcelain -z']) expect(statMock).toHaveBeenCalledTimes(1) expect(translateWslOutputPathsMock).toHaveBeenCalledTimes(2) }) @@ -422,7 +500,7 @@ describe('listWorktrees', () => { await expect(listWorktrees('/workspace/deleted-repo')).resolves.toEqual([]) - expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'list', '--porcelain'], { + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'list', '--porcelain', '-z'], { cwd: '/workspace/deleted-repo' }) expect(statMock).toHaveBeenCalledWith('/workspace/deleted-repo') @@ -444,7 +522,7 @@ describe('listWorktrees', () => { await expect(listWorktrees('/private/tmp/orca-issue-1582-test/my-repo')).resolves.toEqual([]) - expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'list', '--porcelain'], { + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'list', '--porcelain', '-z'], { cwd: '/private/tmp/orca-issue-1582-test/my-repo' }) expect(warnSpy).not.toHaveBeenCalled() @@ -453,7 +531,7 @@ describe('listWorktrees', () => { it('detects sparse checkout after translating paths when porcelain omits sparse token', async () => { gitExecFileAsyncMock.mockImplementation((args: string[]) => { - if (args.join(' ') === 'worktree list --porcelain') { + if (args.join(' ') === 'worktree list --porcelain -z') { return { stdout: 'worktree /home/me/repo\nHEAD abc123\nbranch refs/heads/main\n\n' + @@ -502,7 +580,101 @@ describe('listWorktrees', () => { // Why: the detection path must not spawn a git subprocess per worktree — // the perf regression in #1131 came from `git sparse-checkout list` firing // on every poll. - expect(getGitCalls()).toEqual(['git worktree list --porcelain']) + expect(getGitCalls()).toEqual(['git worktree list --porcelain -z']) + }) + + it('bounds concurrent sparse-checkout filesystem probes', async () => { + const worktreeCount = 20 + const sparseWorktreePath = '/repo-worktree-17' + gitExecFileAsyncMock.mockResolvedValueOnce({ + stdout: Array.from({ length: worktreeCount }, (_, index) => + [ + `worktree ${index === 0 ? '/repo' : `/repo-worktree-${index}`}`, + `HEAD ${String(index).padStart(6, '0')}`, + `branch refs/heads/${index === 0 ? 'main' : `feature/${index}`}`, + '' + ].join('\n') + ).join('\n'), + stderr: '' + }) + + const pendingProbeResolves: (() => void)[] = [] + let activeProbes = 0 + let maxActiveProbes = 0 + statMock.mockImplementation(async (filePath: string) => { + activeProbes += 1 + maxActiveProbes = Math.max(maxActiveProbes, activeProbes) + await new Promise((resolve) => pendingProbeResolves.push(resolve)) + activeProbes -= 1 + + if (filePath.includes(sparseWorktreePath)) { + return { isFile: () => true, size: 32 } + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + + let completed = false + const listPromise = listWorktrees('/repo').finally(() => { + completed = true + }) + + for (let attempt = 0; pendingProbeResolves.length < 8 && attempt < 20; attempt += 1) { + await Promise.resolve() + } + expect(pendingProbeResolves).toHaveLength(8) + + for (let attempt = 0; !completed && attempt < 20; attempt += 1) { + pendingProbeResolves.splice(0).forEach((resolve) => resolve()) + await Promise.resolve() + await Promise.resolve() + } + expect(completed).toBe(true) + + const worktrees = await listPromise + + expect(maxActiveProbes).toBeLessThanOrEqual(8) + expect(statMock).toHaveBeenCalledTimes(worktreeCount) + expect(worktrees).toHaveLength(worktreeCount) + expect(worktrees[17]).toMatchObject({ + path: sparseWorktreePath, + isSparse: true + }) + }) + + it('falls back to line-block porcelain output when Git rejects -z', async () => { + mockGitCommands({ + 'git worktree list --porcelain -z': { + error: Object.assign(new Error("unknown switch `z'"), { + stderr: "error: unknown switch `z'" + }) + }, + 'git worktree list --porcelain': { + stdout: + 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n\n' + + 'worktree /repo-feature\nHEAD def456\nbranch refs/heads/feature/test\n' + } + }) + + await expect(listWorktrees('/repo')).resolves.toEqual([ + { + path: '/repo', + head: 'abc123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '/repo-feature', + head: 'def456', + branch: 'refs/heads/feature/test', + isBare: false, + isMainWorktree: false + } + ]) + expect(getGitCalls()).toEqual([ + 'git worktree list --porcelain -z', + 'git worktree list --porcelain' + ]) }) }) @@ -576,7 +748,7 @@ branch refs/heads/main 'git sparse-checkout set -- packages/web', 'git worktree remove --force /repo-feature', 'git worktree prune', - 'git branch -D feature/test' + 'git branch -D -- feature/test' ]) ) expectGitCallOrder( @@ -584,6 +756,6 @@ branch refs/heads/main 'git sparse-checkout set -- packages/web', 'git worktree remove --force /repo-feature' ) - expectGitCallOrder(calls, 'git worktree prune', 'git branch -D feature/test') + expectGitCallOrder(calls, 'git worktree prune', 'git branch -D -- feature/test') }) }) diff --git a/src/main/git/repo-detection.test.ts b/src/main/git/repo-detection.test.ts new file mode 100644 index 00000000000..67597384782 --- /dev/null +++ b/src/main/git/repo-detection.test.ts @@ -0,0 +1,37 @@ +import { execFileSync } from 'child_process' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import * as path from 'path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { isGitRepo } from './repo' + +function git(cwd: string, args: string[]): string { + return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }) +} + +describe('isGitRepo', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'orca-repo-detect-')) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('rejects directories with an invalid .git file', () => { + const fakeRepo = path.join(tmpDir, 'fake') + mkdirSync(fakeRepo) + writeFileSync(path.join(fakeRepo, '.git'), 'not a gitdir file') + + expect(isGitRepo(fakeRepo)).toBe(false) + }) + + it('accepts bare git repositories', () => { + const bareRepo = path.join(tmpDir, 'bare.git') + git(tmpDir, ['init', '--bare', '--quiet', bareRepo]) + + expect(isGitRepo(bareRepo)).toBe(true) + }) +}) diff --git a/src/main/git/repo.test.ts b/src/main/git/repo.test.ts index 8521d34698e..f89140c53f5 100644 --- a/src/main/git/repo.test.ts +++ b/src/main/git/repo.test.ts @@ -47,6 +47,33 @@ function getHeadSha(dir: string): string { return git(dir, ['rev-parse', 'HEAD']).trim() } +describe('buildSearchBaseRefsArgv', () => { + it('caps broad local ref searches before parsing results', () => { + const argv = buildSearchBaseRefsArgv('feature', 25) + + expect(argv).toContain('--exclude=refs/remotes/**/HEAD') + expect(argv).toContain('--count=100') + expect(argv).toContain('refs/heads/**/*feature*') + expect(argv).toContain('refs/remotes/**/*feature*/**') + }) + + it('keeps segmented display-format searches bounded', () => { + const argv = buildSearchBaseRefsArgv('upstream/main', 10) + + expect(argv).toContain('--exclude=refs/remotes/**/HEAD') + expect(argv).toContain('--count=40') + expect(argv).toContain('refs/remotes/*upstream*/*main*') + expect(argv).toContain('refs/heads/*upstream*/*main*') + }) + + it('adds fallback headroom when remote HEAD cannot be excluded by git', () => { + const argv = buildSearchBaseRefsArgv('feature', 25, { excludeRemoteHead: false }) + + expect(argv).not.toContain('--exclude=refs/remotes/**/HEAD') + expect(argv).toContain('--count=200') + }) +}) + describe('searchBaseRefs (widened glob)', () => { let tmpDir: string @@ -189,6 +216,16 @@ describe('searchBaseRefs (widened glob)', () => { createRemoteRef(tmpDir, 'origin/feature/something', sha) createRemoteRef(tmpDir, 'upstream/feature/something', sha) + expect( + await getBranchConflictKind(tmpDir, 'feature/something', 'origin/feature/something') + ).toBe('remote') + }) + + it('reports remote conflicts when the remote name contains a slash', async () => { + const sha = getHeadSha(tmpDir) + git(tmpDir, ['remote', 'add', 'foo/bar', 'https://example.invalid/repo.git']) + createRemoteRef(tmpDir, 'foo/bar/feature/something', sha) + const result = await getBranchConflictKind( tmpDir, 'feature/something', diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index fff8ad5a558..469f3e0d1a4 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -1,7 +1,7 @@ /* oxlint-disable max-lines */ import { execSync } from 'child_process' import { existsSync, statSync } from 'fs' -import { join, basename } from 'path' +import { basename } from 'path' import { gitExecFileSync, gitExecFileAsync } from './runner' import type { BaseRefSearchResult } from '../../shared/types' import { buildHostedRemoteFileUrl, parseHostedRemote } from './hosted-remote-url' @@ -53,25 +53,23 @@ export function isGitRepo(path: string): boolean { if (!existsSync(path) || !statSync(path).isDirectory()) { return false } - // .git dir or file (for worktrees) or bare repo - if (existsSync(join(path, '.git'))) { - return true - } - // Might be a bare repo — ask git - const result = gitExecFileSync(['rev-parse', '--is-inside-work-tree'], { + const insideWorkTree = gitExecFileSync(['rev-parse', '--is-inside-work-tree'], { cwd: path }).trim() - return result === 'true' - } catch { - // Also check if it's a bare repo - try { - const result = gitExecFileSync(['rev-parse', '--is-bare-repository'], { - cwd: path - }).trim() - return result === 'true' - } catch { - return false + if (insideWorkTree === 'true') { + return true } + } catch { + // Fall through to the bare-repo probe below. + } + + try { + const bareRepo = gitExecFileSync(['rev-parse', '--is-bare-repository'], { + cwd: path + }).trim() + return bareRepo === 'true' + } catch { + return false } } @@ -772,20 +770,21 @@ export async function getBranchConflictKind( } try { + const remoteNames = (await listRemoteNames(path)).sort((a, b) => b.length - a.length) const { stdout } = await gitExecFileAsync( ['for-each-ref', '--format=%(refname)', 'refs/remotes'], { cwd: path } ) - // Why: refs have the form refs/remotes//. We strip the - // first three segments so that e.g. "feature/dashboard" only matches - // "refs/remotes/origin/feature/dashboard", not "refs/remotes/origin/other/feature/dashboard". const hasRemoteConflict = stdout.split('\n').some((ref) => { const trimmed = ref.trim() if (isAllowedRemoteBaseRef(trimmed, allowedBaseRef)) { return false } - const parts = trimmed.split('/') - return parts.slice(3).join('/') === branchName + const shortRef = trimmed.replace(/^refs\/remotes\//, '') + // Why: git allows slashes in remote names. Use the configured remote + // list so foo/bar/feature resolves as branch "feature" for remote + // "foo/bar", matching searchBaseRefDetails. + return resolveLocalBranchName(trimmed, shortRef, remoteNames) === branchName }) return hasRemoteConflict ? 'remote' : null diff --git a/src/main/git/runner-command-exec.test.ts b/src/main/git/runner-command-exec.test.ts index 83a326b019e..30278404e90 100644 --- a/src/main/git/runner-command-exec.test.ts +++ b/src/main/git/runner-command-exec.test.ts @@ -13,7 +13,7 @@ vi.mock('node:child_process', () => ({ spawn: spawnMock })) -import { commandExecFileAsync } from './runner' +import { commandExecFileAsync, gitExecFileAsync } from './runner' type MockChildProcess = EventEmitter & { stdout: EventEmitter @@ -112,6 +112,10 @@ describe('commandExecFileAsync Windows command shims', () => { expect.objectContaining({ stdio: 'ignore', windowsHide: true }) ) expect(command.kill).not.toHaveBeenCalled() + expect(command.stdout.listenerCount('data')).toBe(0) + expect(command.stderr.listenerCount('data')).toBe(0) + expect(command.listenerCount('error')).toBe(0) + expect(command.listenerCount('close')).toBe(0) }) }) @@ -137,6 +141,73 @@ describe('commandExecFileAsync Windows command shims', () => { expect.objectContaining({ stdio: 'ignore', windowsHide: true }) ) expect(command.kill).not.toHaveBeenCalled() + expect(command.stdout.listenerCount('data')).toBe(0) + expect(command.stderr.listenerCount('data')).toBe(0) + expect(command.listenerCount('error')).toBe(0) + expect(command.listenerCount('close')).toBe(0) + }) + }) + + it('removes listeners after successful Windows .cmd shim executions', async () => { + await withPlatform('win32', async () => { + const command = createMockChildProcess(1234) + spawnMock.mockReturnValue(command) + + const promise = commandExecFileAsync('C:\\tools\\pnpm.cmd', ['--version'], { + cwd: 'C:\\repo' + }) + command.stdout.emit('data', Buffer.from('9.1.0\n')) + command.stderr.emit('data', Buffer.from('notice\n')) + command.emit('close', 0) + + await expect(promise).resolves.toEqual({ stdout: '9.1.0\n', stderr: 'notice\n' }) + expect(command.stdout.listenerCount('data')).toBe(0) + expect(command.stderr.listenerCount('data')).toBe(0) + expect(command.listenerCount('error')).toBe(0) + expect(command.listenerCount('close')).toBe(0) }) }) }) + +describe('runner execFile timeout handling', () => { + beforeEach(() => { + execFileMock.mockReset() + execFileSyncMock.mockReset() + spawnMock.mockReset() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('rejects command executions when execFile never calls back after timeout', async () => { + const child = createMockChildProcess(1234) + execFileMock.mockReturnValue(child) + + const promise = commandExecFileAsync('git', ['status'], { + cwd: '/repo', + timeout: 1000 + }) + const rejection = expect(promise).rejects.toThrow('git timed out.') + await vi.advanceTimersByTimeAsync(1000) + + await rejection + expect(child.kill).toHaveBeenCalled() + }) + + it('rejects git executions when execFile never calls back after timeout', async () => { + const child = createMockChildProcess(1234) + execFileMock.mockReturnValue(child) + + const promise = gitExecFileAsync(['status'], { + cwd: '/repo', + timeout: 1000 + }) + const rejection = expect(promise).rejects.toThrow('git timed out.') + await vi.advanceTimersByTimeAsync(1000) + + await rejection + expect(child.kill).toHaveBeenCalled() + }) +}) diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index e7dbc07e76e..91f92c8ac94 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -9,14 +9,18 @@ consistent across every repo-scoped subprocess call. */ * This module detects WSL paths and routes command execution through `wsl.exe -d ` * with translated Linux paths, so every call site gets WSL support for free. */ -import { execFile, execFileSync, spawn, type ChildProcess, type SpawnOptions } from 'child_process' -import { promisify } from 'util' +import { + execFile, + execFileSync, + spawn, + type ChildProcess, + type ExecFileOptions, + type SpawnOptions +} from 'child_process' import { withGitSpan } from '../observability/instrumentation' import { getDefaultWslDistro, parseWslPath, toWindowsWslPath, type WslPathInfo } from '../wsl' import { getSpawnArgsForWindows, isWindowsBatchScript, resolveWindowsCommand } from '../win32-utils' -const execFileAsync = promisify(execFile) - // ─── Core resolution ──────────────────────────────────────────────── type ResolvedCommand = { @@ -269,6 +273,112 @@ function killSpawnedCommandTree(child: ChildProcess): void { } } +type ExecFileCaptureOptions = Omit & { + timeout?: number +} + +function emptyExecFileOutput(options: ExecFileCaptureOptions): string | Buffer { + return options.encoding === 'buffer' ? Buffer.alloc(0) : '' +} + +function isExecFileResultObject( + value: unknown +): value is { stdout: string | Buffer; stderr: string | Buffer } { + return ( + value !== null && + typeof value === 'object' && + !Buffer.isBuffer(value) && + 'stdout' in value && + 'stderr' in value + ) +} + +function execFileCapture( + command: string, + args: string[], + options: ExecFileCaptureOptions +): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> { + return new Promise((resolve, reject) => { + if (options.signal?.aborted) { + reject(createAbortError()) + return + } + + let settled = false + let child: ChildProcess | null = null + let timer: NodeJS.Timeout | null = null + const cleanup = (): void => { + if (timer) { + clearTimeout(timer) + timer = null + } + options.signal?.removeEventListener('abort', onAbort) + } + const finish = ( + error: Error | null, + stdout: string | Buffer = emptyExecFileOutput(options), + stderr: string | Buffer = emptyExecFileOutput(options) + ): void => { + if (settled) { + return + } + settled = true + cleanup() + if (error) { + const enriched = error as Error & { stdout?: string | Buffer; stderr?: string | Buffer } + enriched.stdout ??= stdout + enriched.stderr ??= stderr + reject(enriched) + return + } + resolve({ stdout, stderr }) + } + const onAbort = (): void => { + if (child) { + killSpawnedCommandTree(child) + } + finish(createAbortError()) + } + + try { + child = execFile( + command, + args, + { + cwd: options.cwd, + encoding: options.encoding, + maxBuffer: options.maxBuffer, + env: options.env, + signal: options.signal + }, + (error, stdout, stderr) => { + if (!error && stderr === undefined && isExecFileResultObject(stdout)) { + finish(null, stdout.stdout, stdout.stderr) + return + } + finish(error, stdout, stderr) + } + ) + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))) + return + } + + // Why: Node's native execFile timeout waits for the child to exit after + // signaling it. Some CLIs ignore that signal, so reject the UI operation + // on our own timer and kill the child only as best effort. + if (options.timeout && options.timeout > 0) { + timer = setTimeout(() => { + if (child) { + killSpawnedCommandTree(child) + } + finish(new Error(`${command} timed out.`)) + }, options.timeout) + } + options.signal?.addEventListener('abort', onAbort, { once: true }) + }) +} + async function spawnCommandCapture( command: string, args: string[], @@ -296,15 +406,23 @@ async function spawnCommandCapture( killSpawnedCommandTree(child) finish(createAbortError()) } + const cleanupListeners = (): void => { + if (timer) { + clearTimeout(timer) + timer = null + } + options.signal?.removeEventListener('abort', onAbort) + child.stdout?.off('data', onStdoutData) + child.stderr?.off('data', onStderrData) + child.off('error', onError) + child.off('close', onClose) + } const finish = (error: Error | null): void => { if (settled) { return } settled = true - if (timer) { - clearTimeout(timer) - } - options.signal?.removeEventListener('abort', onAbort) + cleanupListeners() if (error) { reject(Object.assign(error, { stdout, stderr })) return @@ -318,7 +436,7 @@ async function spawnCommandCapture( }, options.timeout) : null options.signal?.addEventListener('abort', onAbort, { once: true }) - child.stdout?.on('data', (chunk: Buffer) => { + function onStdoutData(chunk: Buffer): void { stdoutBytes += chunk.byteLength if (options.maxBuffer && stdoutBytes > options.maxBuffer) { killSpawnedCommandTree(child) @@ -326,8 +444,8 @@ async function spawnCommandCapture( return } stdout += chunk.toString(options.encoding ?? 'utf-8') - }) - child.stderr?.on('data', (chunk: Buffer) => { + } + function onStderrData(chunk: Buffer): void { stderrBytes += chunk.byteLength if (options.maxBuffer && stderrBytes > options.maxBuffer) { killSpawnedCommandTree(child) @@ -335,15 +453,21 @@ async function spawnCommandCapture( return } stderr += chunk.toString(options.encoding ?? 'utf-8') - }) - child.on('error', finish) - child.on('close', (code) => { + } + function onError(error: Error): void { + finish(error) + } + function onClose(code: number | null): void { if (code === 0) { finish(null) return } finish(new Error(`${command} exited with ${code}.`)) - }) + } + child.stdout?.on('data', onStdoutData) + child.stderr?.on('data', onStderrData) + child.on('error', onError) + child.on('close', onClose) }) } @@ -372,7 +496,7 @@ export async function gitExecFileAsync( { args, ...(options.cwd !== undefined ? { cwd: options.cwd } : {}) }, async () => { const resolved = resolveCommand('git', args, options.cwd) - const { stdout, stderr } = await execFileAsync(resolved.binary, resolved.args, { + const { stdout, stderr } = await execFileCapture(resolved.binary, resolved.args, { cwd: resolved.cwd, encoding: (options.encoding ?? 'utf-8') as BufferEncoding, maxBuffer: options.maxBuffer, @@ -403,7 +527,7 @@ export async function commandExecFileAsync( }) } try { - const { stdout, stderr } = await execFileAsync(binary, resolved.args, { + const { stdout, stderr } = await execFileCapture(binary, resolved.args, { cwd: resolved.cwd, encoding: options.encoding ?? 'utf-8', maxBuffer: options.maxBuffer, @@ -436,7 +560,7 @@ export async function gitExecFileAsyncBuffer( options: { cwd: string; maxBuffer?: number } ): Promise<{ stdout: Buffer }> { const resolved = resolveCommand('git', args, options.cwd) - const { stdout } = (await execFileAsync(resolved.binary, resolved.args, { + const { stdout } = (await execFileCapture(resolved.binary, resolved.args, { cwd: resolved.cwd, encoding: 'buffer', maxBuffer: options.maxBuffer @@ -721,7 +845,7 @@ export async function ghExecFileAsync( let attemptedDefaultWslFallback = false for (let attempt = 0; attempt <= GH_RETRY_DELAYS_MS.length; attempt++) { try { - const { stdout, stderr } = await execFileAsync(resolved.binary, resolved.args, { + const { stdout, stderr } = await execFileCapture(resolved.binary, resolved.args, { cwd: resolved.cwd, encoding: (options.encoding ?? 'utf-8') as BufferEncoding, maxBuffer: options.maxBuffer, @@ -818,7 +942,7 @@ export async function glabExecFileAsync( let attemptedDefaultWslFallback = false for (let attempt = 0; attempt <= GH_RETRY_DELAYS_MS.length; attempt++) { try { - const { stdout, stderr } = await execFileAsync(resolved.binary, resolved.args, { + const { stdout, stderr } = await execFileCapture(resolved.binary, resolved.args, { cwd: resolved.cwd, encoding: (options.encoding ?? 'utf-8') as BufferEncoding, maxBuffer: options.maxBuffer, diff --git a/src/main/git/status-cquoted-paths.test.ts b/src/main/git/status-cquoted-paths.test.ts new file mode 100644 index 00000000000..0008b21bc7a --- /dev/null +++ b/src/main/git/status-cquoted-paths.test.ts @@ -0,0 +1,44 @@ +import { execFileSync } from 'child_process' +import { mkdtemp, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import * as path from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import { getStatus, stageFile } from './status' + +const tempRoots: string[] = [] + +async function createRepo(): Promise { + const repo = await mkdtemp(path.join(tmpdir(), 'orca-status-cquoted-')) + tempRoots.push(repo) + execFileSync('git', ['init', '-q'], { cwd: repo }) + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repo }) + execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: repo }) + return repo +} + +function gitNames(repo: string, args: string[]): string[] { + const stdout = execFileSync('git', args, { cwd: repo, encoding: 'utf8' }) + return stdout.split('\0').filter(Boolean) +} + +afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('git status C-quoted paths', () => { + it('returns the real path for untracked files whose names contain tabs', async () => { + const repo = await createRepo() + const filePath = 'tab\tfile.txt' + await writeFile(path.join(repo, filePath), 'new file\n') + + const status = await getStatus(repo) + + expect(status.entries).toEqual([ + { path: filePath, status: 'untracked', area: 'untracked', added: 1 } + ]) + + await stageFile(repo, status.entries[0].path) + + expect(gitNames(repo, ['diff', '--cached', '--name-only', '-z'])).toEqual([filePath]) + }) +}) diff --git a/src/main/git/status-pathspec-literals.test.ts b/src/main/git/status-pathspec-literals.test.ts new file mode 100644 index 00000000000..31d66bcdba9 --- /dev/null +++ b/src/main/git/status-pathspec-literals.test.ts @@ -0,0 +1,72 @@ +import { execFileSync } from 'child_process' +import { mkdtemp, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import * as path from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import { bulkStageFiles, bulkUnstageFiles, stageFile, unstageFile } from './status' + +const tempRoots: string[] = [] + +async function createRepoWithGlobNamedFiles(): Promise { + const repo = await mkdtemp(path.join(tmpdir(), 'orca-status-pathspec-')) + tempRoots.push(repo) + execFileSync('git', ['init', '-q'], { cwd: repo }) + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repo }) + execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: repo }) + await writeFile(path.join(repo, '*.log'), 'selected') + await writeFile(path.join(repo, 'keep.log'), 'keep') + execFileSync('git', ['add', '*.log', 'keep.log'], { cwd: repo }) + execFileSync('git', ['commit', '-q', '-m', 'initial'], { cwd: repo }) + await writeFile(path.join(repo, '*.log'), 'selected modified') + await writeFile(path.join(repo, 'keep.log'), 'keep modified') + return repo +} + +function gitNames(repo: string, args: string[]): string[] { + const stdout = execFileSync('git', args, { cwd: repo, encoding: 'utf8' }) + return stdout.split(/\r?\n/).filter(Boolean) +} + +afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('git status pathspec literals', () => { + it('stages a tracked path with Git glob characters as one literal path', async () => { + const repo = await createRepoWithGlobNamedFiles() + + await stageFile(repo, '*.log') + + expect(gitNames(repo, ['diff', '--cached', '--name-only'])).toEqual(['*.log']) + expect(gitNames(repo, ['diff', '--name-only'])).toEqual(['keep.log']) + }) + + it('bulk stages tracked paths with Git glob characters as literal paths', async () => { + const repo = await createRepoWithGlobNamedFiles() + + await bulkStageFiles(repo, ['*.log']) + + expect(gitNames(repo, ['diff', '--cached', '--name-only'])).toEqual(['*.log']) + expect(gitNames(repo, ['diff', '--name-only'])).toEqual(['keep.log']) + }) + + it('unstages a tracked path with Git glob characters as one literal path', async () => { + const repo = await createRepoWithGlobNamedFiles() + execFileSync('git', ['add', '*.log', 'keep.log'], { cwd: repo }) + + await unstageFile(repo, '*.log') + + expect(gitNames(repo, ['diff', '--cached', '--name-only'])).toEqual(['keep.log']) + expect(gitNames(repo, ['diff', '--name-only'])).toEqual(['*.log']) + }) + + it('bulk unstages tracked paths with Git glob characters as literal paths', async () => { + const repo = await createRepoWithGlobNamedFiles() + execFileSync('git', ['add', '*.log', 'keep.log'], { cwd: repo }) + + await bulkUnstageFiles(repo, ['*.log']) + + expect(gitNames(repo, ['diff', '--cached', '--name-only'])).toEqual(['keep.log']) + expect(gitNames(repo, ['diff', '--name-only'])).toEqual(['*.log']) + }) +}) diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index 252b23ff608..285bfd78ea2 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -8,6 +8,7 @@ const { lstatMock, realpathMock, readFileMock, + statMock, rmMock, existsSyncMock } = vi.hoisted(() => ({ @@ -16,6 +17,7 @@ const { lstatMock: vi.fn(), realpathMock: vi.fn(), readFileMock: vi.fn(), + statMock: vi.fn(), rmMock: vi.fn(), existsSyncMock: vi.fn() })) @@ -33,6 +35,7 @@ vi.mock('fs/promises', () => ({ lstat: lstatMock, realpath: realpathMock, readFile: readFileMock, + stat: statMock, rm: rmMock })) @@ -49,6 +52,7 @@ import { detectConflictOperation, discardChanges, getBranchCompare, + getCommitCompare, getDiff, getStagedCommitContext, getStatus, @@ -139,14 +143,14 @@ describe('bulk git helpers', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(3) expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( 1, - ['add', '--', ...filePaths.slice(0, 100)], + ['add', '--', ...filePaths.slice(0, 100).map((filePath) => `:(literal)${filePath}`)], { cwd: '/repo' } ) expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( 3, - ['add', '--', ...filePaths.slice(200)], + ['add', '--', ...filePaths.slice(200).map((filePath) => `:(literal)${filePath}`)], { cwd: '/repo' } @@ -162,7 +166,12 @@ describe('bulk git helpers', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( 2, - ['restore', '--staged', '--', ...filePaths.slice(100)], + [ + 'restore', + '--staged', + '--', + ...filePaths.slice(100).map((filePath) => `:(literal)${filePath}`) + ], { cwd: '/repo' } @@ -210,6 +219,26 @@ describe('bulk git helpers', () => { expect(rmMock).not.toHaveBeenCalled() }) + it('handles large tracked path lists during bulk discard classification', async () => { + const trackedStdout = Array.from({ length: 150_000 }, (_, index) => `docs/file-${index}.ts`) + .join('\0') + .concat('\0') + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: trackedStdout }).mockResolvedValueOnce({ + stdout: '' + }) + + await bulkDiscardChanges('/repo', ['docs']) + + expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, + ['restore', '--worktree', '--source=HEAD', '--', ':(literal)docs'], + { + cwd: '/repo' + } + ) + expect(rmMock).not.toHaveBeenCalled() + }) + it('rejects bulk discard paths that traverse outside the worktree', async () => { await expect(bulkDiscardChanges('/repo', ['src/file.ts', '../outside.txt'])).rejects.toThrow( 'resolves outside the worktree' @@ -226,7 +255,12 @@ describe('getDiff', () => { gitExecFileAsyncBufferMock.mockReset() lstatMock.mockReset() readFileMock.mockReset() + statMock.mockReset() existsSyncMock.mockReset() + statMock.mockResolvedValue({ + isFile: () => true, + size: 12 + }) }) it('uses the index as the left side for unstaged diffs when present', async () => { @@ -292,6 +326,21 @@ describe('getDiff', () => { expect(result.modifiedIsBinary).toBe(false) }) + it('does not read oversized working-tree files into memory', async () => { + gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: Buffer.from('index-content\n') }) + statMock.mockResolvedValueOnce({ + isFile: () => true, + size: 10 * 1024 * 1024 + 1 + }) + + const result = await getDiff('/repo', 'dist/large.log', false) + + expect(readFileMock).not.toHaveBeenCalled() + expect(result.kind).toBe('binary') + expect(result.modifiedIsBinary).toBe(true) + expect(result.modifiedContent).toBe('') + }) + it('includes preview metadata for pdf diffs', async () => { const pdfBuffer = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x00]) gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: pdfBuffer }) @@ -584,6 +633,32 @@ describe('getStatus', () => { ]) }) + it('attaches numstat counts for literal paths containing rename markers', async () => { + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + existsSyncMock.mockReturnValue(false) + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args.includes('status')) { + return Promise.resolve({ + stdout: '1 .M N... 100644 100644 100644 aaaa aaaa docs/a => b.txt\n' + }) + } + if (args.includes('--numstat')) { + return Promise.resolve({ stdout: '1\t0\tdocs/a => b.txt\0' }) + } + return Promise.resolve({ stdout: '' }) + }) + + const result = await getStatus('/repo') + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['-c', 'core.quotePath=false', 'diff', '-z', '--numstat', '-M'], + { cwd: '/repo', env: expect.objectContaining({ GIT_OPTIONAL_LOCKS: '0' }) } + ) + expect(result.entries).toEqual([ + { path: 'docs/a => b.txt', status: 'modified', area: 'unstaged', added: 1, removed: 0 } + ]) + }) + it('attaches staged rename counts to the new path', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') existsSyncMock.mockReturnValue(false) @@ -896,4 +971,100 @@ describe('getBranchCompare', () => { { path: 'docs/日本語/sample.md', status: 'modified', added: 2, removed: 1 } ]) }) + + it('attaches counts for branch compare paths containing rename markers', async () => { + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args[0] === 'branch') { + return Promise.resolve({ stdout: 'main\n' }) + } + if (args[0] === 'rev-parse' && args.includes('HEAD')) { + return Promise.resolve({ stdout: 'head-oid\n' }) + } + if (args[0] === 'rev-parse') { + return Promise.resolve({ stdout: 'base-oid\n' }) + } + if (args[0] === 'merge-base') { + return Promise.resolve({ stdout: 'merge-base-oid\n' }) + } + if (args.includes('--name-status')) { + return Promise.resolve({ stdout: 'M\tdocs/a => b.txt\n' }) + } + if (args.includes('--numstat')) { + return Promise.resolve({ + stdout: args.includes('-z') ? '1\t0\tdocs/a => b.txt\0' : '1\t0\tdocs/a => b.txt\n' + }) + } + if (args[0] === 'rev-list') { + return Promise.resolve({ stdout: '1\n' }) + } + throw new Error(`unexpected git args: ${args.join(' ')}`) + }) + + const result = await getBranchCompare('/repo', 'origin/main') + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + [ + '-c', + 'core.quotePath=false', + 'diff', + '-z', + '--numstat', + '-M', + '-C', + 'merge-base-oid', + 'head-oid' + ], + expect.objectContaining({ cwd: '/repo' }) + ) + expect(result.entries).toEqual([ + { path: 'docs/a => b.txt', status: 'modified', added: 1, removed: 0 } + ]) + }) +}) + +describe('getCommitCompare', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + gitExecFileAsyncBufferMock.mockReset() + }) + + it('attaches counts for commit compare paths containing rename markers', async () => { + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args[0] === 'rev-parse') { + return Promise.resolve({ stdout: 'commit-oid\n' }) + } + if (args[0] === 'rev-list') { + return Promise.resolve({ stdout: 'commit-oid parent-oid\n' }) + } + if (args.includes('--name-status')) { + return Promise.resolve({ stdout: 'M\tdocs/a => b.txt\n' }) + } + if (args.includes('--numstat')) { + return Promise.resolve({ + stdout: args.includes('-z') ? '1\t0\tdocs/a => b.txt\0' : '1\t0\tdocs/a => b.txt\n' + }) + } + throw new Error(`unexpected git args: ${args.join(' ')}`) + }) + + const result = await getCommitCompare('/repo', 'commit-oid') + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + [ + '-c', + 'core.quotePath=false', + 'diff', + '-z', + '--numstat', + '-M', + '-C', + 'parent-oid', + 'commit-oid' + ], + expect.objectContaining({ cwd: '/repo' }) + ) + expect(result.entries).toEqual([ + { path: 'docs/a => b.txt', status: 'modified', added: 1, removed: 0 } + ]) + }) }) diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 5ab2c757d83..3b74681ba88 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -1,6 +1,6 @@ /* eslint-disable max-lines */ import { existsSync } from 'fs' -import { readFile } from 'fs/promises' +import { readFile, stat } from 'fs/promises' import * as path from 'path' import type { GitBranchChangeEntry, @@ -28,6 +28,7 @@ import { parseNumstat, type GitLineStats } from '../../shared/git-uncommitted-line-stats' +import { decodeGitCQuotedPath } from '../../shared/git-cquoted-path' import { gitExecFileAsync, gitExecFileAsyncBuffer, gitOptionalLocksDisabledEnv } from './runner' import { removeSafeUntrackedDiscardTarget, @@ -130,8 +131,8 @@ export async function getStatus( // space-delimited fields and the old path after the tab. Preserving // spaces here keeps row actions and numstat counts keyed correctly. const tabParts = line.split('\t') - const path = tabParts[0].split(' ').slice(9).join(' ') - const oldPath = tabParts.slice(1).join('\t') + const path = decodeGitCQuotedPath(tabParts[0].split(' ').slice(9).join(' ')) + const oldPath = decodeGitCQuotedPath(tabParts.slice(1).join('\t')) if (indexStatus !== '.') { entries.push({ path, status: parseStatusChar(indexStatus), area: 'staged', oldPath }) } @@ -145,7 +146,7 @@ export async function getStatus( } } else { // Regular change entry - const path = parts.slice(8).join(' ') + const path = decodeGitCQuotedPath(parts.slice(8).join(' ')) if (indexStatus !== '.') { entries.push({ path, status: parseStatusChar(indexStatus), area: 'staged' }) } @@ -155,10 +156,10 @@ export async function getStatus( } } else if (line.startsWith('? ')) { // Untracked file - const path = line.slice(2) + const path = decodeGitCQuotedPath(line.slice(2)) entries.push({ path, status: 'untracked', area: 'untracked' }) } else if (line.startsWith('! ')) { - ignoredPaths.push(line.slice(2)) + ignoredPaths.push(decodeGitCQuotedPath(line.slice(2))) } else if (line.startsWith('u ')) { const unmergedEntry = await parseUnmergedEntry(worktreePath, line) if (unmergedEntry) { @@ -219,7 +220,15 @@ async function runNumstat( ): Promise> { try { const { stdout } = await gitExecFileAsync( - ['-c', 'core.quotePath=false', 'diff', ...(cached ? ['--cached'] : []), '--numstat', '-M'], + [ + '-c', + 'core.quotePath=false', + 'diff', + '-z', + ...(cached ? ['--cached'] : []), + '--numstat', + '-M' + ], { cwd: worktreePath, env: gitOptionalLocksDisabledEnv() } ) return parseNumstat(stdout) @@ -337,7 +346,7 @@ async function parseUnmergedEntry( const modeStage1 = parts[3] const modeStage2 = parts[4] const modeStage3 = parts[5] - const filePath = parts.slice(10).join(' ') + const filePath = decodeGitCQuotedPath(parts.slice(10).join(' ')) if (!filePath) { return null } @@ -746,7 +755,7 @@ async function loadBranchChanges( gitOptions ), gitExecFileAsync( - ['-c', 'core.quotePath=false', 'diff', '--numstat', '-M', '-C', mergeBase, headOid], + ['-c', 'core.quotePath=false', 'diff', '-z', '--numstat', '-M', '-C', mergeBase, headOid], gitOptions ) ]) @@ -789,11 +798,12 @@ async function loadCommitChanges( commitOid ] const numstatArgs = parentOid - ? ['-c', 'core.quotePath=false', 'diff', '--numstat', '-M', '-C', parentOid, commitOid] + ? ['-c', 'core.quotePath=false', 'diff', '-z', '--numstat', '-M', '-C', parentOid, commitOid] : [ '-c', 'core.quotePath=false', 'diff-tree', + '-z', '--root', '--no-commit-id', '--numstat', @@ -830,15 +840,15 @@ function parseBranchChangeLine(line: string): GitBranchChangeEntry | null { const status = parseBranchStatusChar(rawStatus[0] ?? 'M') if (rawStatus.startsWith('R') || rawStatus.startsWith('C')) { - const oldPath = parts[1] - const path = parts[2] + const oldPath = decodeGitCQuotedPath(parts[1] ?? '') + const path = decodeGitCQuotedPath(parts[2] ?? '') if (!path) { return null } return { path, oldPath, status } } - const path = parts[1] + const path = decodeGitCQuotedPath(parts[1] ?? '') if (!path) { return null } @@ -941,6 +951,15 @@ async function readGitBlobAtOidPath( async function readWorkingTreeFile(filePath: string): Promise { try { + const fileStat = await stat(filePath) + if (!fileStat.isFile()) { + return { content: '', isBinary: false, exists: false } + } + if (fileStat.size > MAX_GIT_SHOW_BYTES) { + // Why: git blob reads are capped through maxBuffer; mirror that bound for + // unstaged working-tree content before readFile can pull in huge assets. + return { content: '', isBinary: true, exists: true } + } const buffer = await readFile(filePath) return bufferToBlob(buffer, filePath) } catch { @@ -1020,14 +1039,16 @@ const PREVIEWABLE_BINARY_MIME_TYPES: Record = { * Stage a file. */ export async function stageFile(worktreePath: string, filePath: string): Promise { - await gitExecFileAsync(['add', '--', filePath], { cwd: worktreePath }) + await gitExecFileAsync(['add', '--', literalPathspec(filePath)], { cwd: worktreePath }) } /** * Unstage a file. */ export async function unstageFile(worktreePath: string, filePath: string): Promise { - await gitExecFileAsync(['restore', '--staged', '--', filePath], { cwd: worktreePath }) + await gitExecFileAsync(['restore', '--staged', '--', literalPathspec(filePath)], { + cwd: worktreePath + }) } export async function getStagedCommitContext( @@ -1158,7 +1179,13 @@ async function listTrackedPathSpecs( cwd: worktreePath } ) - trackedPaths.push(...stdout.split('\0').filter(Boolean)) + // Why: a tracked directory can contain enough paths for push(...split) + // to exceed the JavaScript argument limit before discard decisions run. + for (const trackedPath of stdout.split('\0')) { + if (trackedPath) { + trackedPaths.push(trackedPath) + } + } } return trackedPaths } @@ -1240,7 +1267,7 @@ export async function bulkStageFiles(worktreePath: string, filePaths: string[]): } for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) - await gitExecFileAsync(['add', '--', ...chunk], { cwd: worktreePath }) + await gitExecFileAsync(['add', '--', ...chunk.map(literalPathspec)], { cwd: worktreePath }) } } @@ -1253,6 +1280,8 @@ export async function bulkUnstageFiles(worktreePath: string, filePaths: string[] } for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) - await gitExecFileAsync(['restore', '--staged', '--', ...chunk], { cwd: worktreePath }) + await gitExecFileAsync(['restore', '--staged', '--', ...chunk.map(literalPathspec)], { + cwd: worktreePath + }) } } diff --git a/src/main/git/upstream.test.ts b/src/main/git/upstream.test.ts index a6026ed7cba..906c7e3abc1 100644 --- a/src/main/git/upstream.test.ts +++ b/src/main/git/upstream.test.ts @@ -141,6 +141,39 @@ describe('getUpstreamStatus', () => { }) }) + it('keeps a configured upstream whose remote name contains a slash', async () => { + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args[0] === 'symbolic-ref') { + return Promise.resolve({ stdout: 'feature\n' }) + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + return Promise.resolve({ stdout: 'origin/team/feature\n' }) + } + if (args[0] === 'remote') { + return Promise.resolve({ stdout: 'origin\norigin/team\n' }) + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) { + return Promise.resolve({ stdout: 'origin-feature-oid\n' }) + } + if (args[0] === 'rev-list' && args.includes('HEAD...origin/team/feature')) { + return Promise.resolve({ stdout: '2\t0\n' }) + } + if (args[0] === 'rev-list' && args.includes('HEAD...origin/feature')) { + return Promise.resolve({ stdout: '9\t9\n' }) + } + throw new Error(`unexpected git args: ${args.join(' ')}`) + }) + + const result = await getUpstreamStatus('/repo') + + expect(result).toEqual({ + hasUpstream: true, + upstreamName: 'origin/team/feature', + ahead: 2, + behind: 0 + }) + }) + it('uses an explicit publish target instead of the configured upstream', async () => { gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: '', stderr: '' }) diff --git a/src/main/git/worktree-list-paths.test.ts b/src/main/git/worktree-list-paths.test.ts new file mode 100644 index 00000000000..9b37ee75604 --- /dev/null +++ b/src/main/git/worktree-list-paths.test.ts @@ -0,0 +1,71 @@ +import { execFileSync } from 'child_process' +import { mkdtemp, realpath, rm } from 'fs/promises' +import { tmpdir } from 'os' +import * as path from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import { listWorktrees, removeWorktree } from './worktree' + +const tempRoots: string[] = [] + +function git(cwd: string, args: string[]): string { + return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }) +} + +async function createRepoWithNewlineWorktree(): Promise<{ + repoPath: string + worktreePath: string +}> { + const root = await mkdtemp(path.join(tmpdir(), 'orca-worktree-paths-')) + tempRoots.push(root) + const repoPath = path.join(root, 'repo') + const requestedWorktreePath = path.join(root, 'linked\nworktree') + + execFileSync('git', ['init', '--quiet', repoPath]) + git(repoPath, ['symbolic-ref', 'HEAD', 'refs/heads/main']) + git(repoPath, ['config', 'user.email', 'test@example.com']) + git(repoPath, ['config', 'user.name', 'Test User']) + git(repoPath, ['commit', '--allow-empty', '--quiet', '-m', 'initial']) + git(repoPath, ['worktree', 'add', '--quiet', '-b', 'feature/newline', requestedWorktreePath]) + + return { + repoPath: await realpath(repoPath), + worktreePath: await realpath(requestedWorktreePath) + } +} + +function branchExists(repoPath: string, branchName: string): boolean { + try { + git(repoPath, ['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`]) + return true + } catch { + return false + } +} + +afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('git worktree paths', () => { + it.skipIf(process.platform === 'win32')( + 'lists worktrees whose paths contain newlines', + async () => { + const { repoPath, worktreePath } = await createRepoWithNewlineWorktree() + + const worktrees = await listWorktrees(repoPath) + + expect(worktrees.map((worktree) => worktree.path)).toContain(worktreePath) + } + ) + + it.skipIf(process.platform === 'win32')( + 'deletes the matching local branch after removing a newline-path worktree', + async () => { + const { repoPath, worktreePath } = await createRepoWithNewlineWorktree() + + await removeWorktree(repoPath, worktreePath) + + expect(branchExists(repoPath, 'feature/newline')).toBe(false) + } + ) +}) diff --git a/src/main/git/worktree.test.ts b/src/main/git/worktree.test.ts index d62932628c1..f9f8e882b17 100644 --- a/src/main/git/worktree.test.ts +++ b/src/main/git/worktree.test.ts @@ -160,6 +160,36 @@ branch refs/heads/main ]) }) + it('parses NUL-delimited porcelain output with newline paths', () => { + const output = [ + 'worktree /repo', + 'HEAD abc123', + 'branch refs/heads/main', + '', + 'worktree /repo/linked\nworktree', + 'HEAD def456', + 'branch refs/heads/feature/newline', + '' + ].join('\0') + + expect(parseWorktreeList(output, { nulDelimited: true })).toEqual([ + { + path: '/repo', + head: 'abc123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '/repo/linked\nworktree', + head: 'def456', + branch: 'refs/heads/feature/newline', + isBare: false, + isMainWorktree: false + } + ]) + }) + it('parses multiple bare entries mixed with regular entries', () => { const output = `worktree /bare-one HEAD 0000000 @@ -846,6 +876,7 @@ describe('addWorktree', () => { expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toContainEqual([ 'branch', '-D', + '--', 'feature/test' ]) }) @@ -871,11 +902,13 @@ describe('removeWorktree', () => { gitExecFileAsyncMock.mockRejectedValueOnce(new Error('not fully merged')) // branch -d // Should not throw — the unmerged branch is preserved, not force-deleted. - await expect(removeWorktree('/repo', '/repo-feature', false)).resolves.toBeUndefined() + await expect(removeWorktree('/repo', '/repo-feature', false)).resolves.toEqual({ + preservedBranch: { branchName: 'feature/test', head: 'def456' } + }) const calls = gitExecFileAsyncMock.mock.calls.map((call) => call[0]) - expect(calls).toContainEqual(['branch', '-d', 'feature/test']) - expect(calls).not.toContainEqual(['branch', '-D', 'feature/test']) + expect(calls).toContainEqual(['branch', '-d', '--', 'feature/test']) + expect(calls).not.toContainEqual(['branch', '-D', '--', 'feature/test']) }) it('deletes the branch when `branch -d` succeeds (fully merged)', async () => { @@ -890,6 +923,7 @@ describe('removeWorktree', () => { expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toContainEqual([ 'branch', '-d', + '--', 'feature/test' ]) }) diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index 55b621d19d7..cf933763006 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -2,7 +2,11 @@ import { stat } from 'fs/promises' import { join, posix, win32 } from 'path' import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref' -import type { GitWorktreeInfo, LocalBaseRefRefreshResult } from '../../shared/types' +import type { + GitWorktreeInfo, + LocalBaseRefRefreshResult, + RemoveWorktreeResult +} from '../../shared/types' import { gitExecFileAsync, translateWslOutputPaths } from './runner' import { resolveGitDir } from './status' import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe' @@ -15,6 +19,8 @@ type SparseWorktreeCreateError = Error & { cleanupFailed?: boolean } +const SPARSE_CHECKOUT_DETECTION_CONCURRENCY = 8 + function getErrorCode(error: unknown): string | undefined { return typeof error === 'object' && error !== null && 'code' in error ? String((error as { code?: unknown }).code) @@ -39,6 +45,12 @@ function isNotGitRepositoryError(error: unknown): boolean { return /not a git repository/i.test(getErrorText(error)) } +function isUnsupportedWorktreeListZError(error: unknown): boolean { + return /(?:unknown|invalid) (?:switch|option).*`?-z'?|(?:unknown|invalid) (?:switch|option).*`?z'?/i.test( + getErrorText(error) + ) +} + function normalizeLocalBranchRef(branch: string): string { return branch.replace(/^refs\/heads\//, '') } @@ -102,12 +114,12 @@ function looksLikeWindowsPath(pathValue: string): boolean { /** * Parse the porcelain output of `git worktree list --porcelain`. */ -export function parseWorktreeList(output: string): GitWorktreeInfo[] { +export function parseWorktreeList( + output: string, + options: { nulDelimited?: boolean } = {} +): GitWorktreeInfo[] { const worktrees: GitWorktreeInfo[] = [] - const blocks = output - .trim() - .split(/\r?\n\r?\n/) - .map((block) => block.trim().split(/\r?\n/)) + const blocks = options.nulDelimited ? splitNulWorktreeList(output) : splitLineWorktreeList(output) for (const lines of blocks) { if (lines.length === 0) { @@ -150,30 +162,69 @@ export function parseWorktreeList(output: string): GitWorktreeInfo[] { return worktrees } +function splitLineWorktreeList(output: string): string[][] { + return output + .trim() + .split(/\r?\n\r?\n/) + .map((block) => block.trim().split(/\r?\n/)) +} + +function splitNulWorktreeList(output: string): string[][] { + if (!output.includes('\0')) { + return splitLineWorktreeList(output) + } + + const blocks: string[][] = [] + let currentBlock: string[] = [] + + for (const field of output.split('\0')) { + if (field) { + currentBlock.push(field) + continue + } + if (currentBlock.length > 0) { + blocks.push(currentBlock) + currentBlock = [] + } + } + + if (currentBlock.length > 0) { + blocks.push(currentBlock) + } + + return blocks +} + +async function readWorktreeList(repoPath: string): Promise { + try { + const { stdout } = await gitExecFileAsync(['worktree', 'list', '--porcelain', '-z'], { + cwd: repoPath + }) + return parseWorktreeList(stdout, { nulDelimited: true }) + } catch (error) { + if (!isUnsupportedWorktreeListZError(error)) { + throw error + } + } + + // Why: `-z` is required to preserve worktree paths containing newlines, but + // Git <2.36 rejects it. Keep the old parser as a compatibility fallback. + const { stdout } = await gitExecFileAsync(['worktree', 'list', '--porcelain'], { + cwd: repoPath + }) + return parseWorktreeList(stdout) +} + /** * List all worktrees for a git repo at the given path. */ export async function listWorktrees(repoPath: string): Promise { try { - // Why: do not pass `-z` here. `-z` requires Git ≥ 2.36; older Git rejects - // it, listWorktrees returns [], and every create flow throws "Worktree - // created but not found in listing" (issue #1453). - const { stdout } = await gitExecFileAsync(['worktree', 'list', '--porcelain'], { - cwd: repoPath - }) - const worktrees = parseWorktreeList(stdout).map((worktree) => { + const worktrees = (await readWorktreeList(repoPath)).map((worktree) => { const translatedPath = translateWorktreePath(worktree.path, repoPath) return translatedPath === worktree.path ? worktree : { ...worktree, path: translatedPath } }) - return Promise.all( - worktrees.map(async (worktree) => { - if (worktree.isBare || worktree.isSparse) { - return worktree - } - const isSparse = await detectSparseCheckout(worktree.path) - return isSparse ? { ...worktree, isSparse } : worktree - }) - ) + return annotateSparseCheckoutStatus(worktrees) } catch (err) { if (getErrorCode(err) === 'ENOENT') { try { @@ -188,14 +239,42 @@ export async function listWorktrees(repoPath: string): Promise { + const annotated = [...worktrees] + let nextIndex = 0 + + async function detectNext(): Promise { + while (nextIndex < worktrees.length) { + const index = nextIndex + nextIndex += 1 + const worktree = worktrees[index] + if (!worktree || worktree.isBare || worktree.isSparse) { + continue + } + const isSparse = await detectSparseCheckout(worktree.path) + if (isSparse) { + annotated[index] = { ...worktree, isSparse } + } + } + } + + // Why: worktree refreshes run on git-status polling paths. Many worktrees can + // otherwise fan out `.git`/sparse-checkout filesystem probes all at once. + const workerCount = Math.min(SPARSE_CHECKOUT_DETECTION_CONCURRENCY, worktrees.length) + await Promise.all(Array.from({ length: workerCount }, () => detectNext())) + return annotated +} + async function refreshLocalBaseRefForWorktreeCreate( repoPath: string, baseBranch: string, @@ -460,12 +539,13 @@ export async function removeWorktree( // and must be removed outright. User-initiated deletes leave it false so unmerged // commits are preserved. options: { deleteBranch?: boolean; forceBranchDelete?: boolean } = {} -): Promise { +): Promise { const worktreesBeforeRemoval = await listWorktrees(repoPath) const removedWorktree = worktreesBeforeRemoval.find((worktree) => areWorktreePathsEqual(worktree.path, worktreePath) ) const branchName = normalizeLocalBranchRef(removedWorktree?.branch ?? '') + const branchHead = removedWorktree?.head ?? '' const args = ['worktree', 'remove'] if (force) { @@ -476,10 +556,10 @@ export async function removeWorktree( await gitExecFileAsync(['worktree', 'prune'], { cwd: repoPath }) if (!branchName) { - return + return {} } if (options.deleteBranch === false) { - return + return {} } // Why: `git worktree list` can still include stale sibling records until @@ -490,7 +570,7 @@ export async function removeWorktree( (worktree) => normalizeLocalBranchRef(worktree.branch) === branchName ) if (branchStillInUse) { - return + return {} } try { @@ -502,7 +582,8 @@ export async function removeWorktree( // force-deleted. forceBranchDelete opts into `-D` for failed-creation rollback, // where the fresh branch has no user work to protect. const deleteFlag = options.forceBranchDelete ? '-D' : '-d' - await gitExecFileAsync(['branch', deleteFlag, branchName], { cwd: repoPath }) + await gitExecFileAsync(['branch', deleteFlag, '--', branchName], { cwd: repoPath }) + return {} } catch (error) { // Expected when the branch still has unmerged/unpublished commits: keep it. // Deleting a worktree must never silently discard commits. @@ -510,9 +591,69 @@ export async function removeWorktree( `[git] Preserved local branch "${branchName}" after removing worktree (not fully merged)`, error ) + return { preservedBranch: { branchName, ...(branchHead ? { head: branchHead } : {}) } } } } +export async function forceDeleteLocalBranch( + repoPath: string, + branchName: string, + expectedHead: string, + runGit: (args: string[], cwd: string) => Promise<{ stdout: string; stderr: string }> = ( + args, + cwd + ) => gitExecFileAsync(args, { cwd }) +): Promise { + if (!branchName || branchName.includes('\0')) { + throw new Error('Invalid branch name') + } + if (!expectedHead) { + throw new Error( + `Cannot force-delete local branch "${branchName}" without the commit Git preserved.` + ) + } + if (await isLocalBranchCheckedOut(repoPath, branchName, runGit)) { + throw new Error(`Local branch "${branchName}" is checked out in another worktree.`) + } + // Why: stale toast actions must not delete a branch that moved after Git + // preserved it. `update-ref` deletes only if the ref still has expectedHead. + try { + await runGit(['update-ref', '-d', `refs/heads/${branchName}`, expectedHead], repoPath) + } catch { + throw new Error( + `Local branch "${branchName}" changed after the workspace was deleted. Review it before deleting it.` + ) + } + if (await isLocalBranchCheckedOut(repoPath, branchName, runGit)) { + try { + await runGit(['update-ref', `refs/heads/${branchName}`, expectedHead, ''], repoPath) + } catch (restoreError) { + console.warn( + `[git] Failed to restore local branch "${branchName}" after concurrent checkout`, + restoreError + ) + } + throw new Error(`Local branch "${branchName}" is checked out in another worktree.`) + } + try { + await runGit(['config', '--remove-section', `branch.${branchName}`], repoPath) + } catch { + // Best-effort parity with `git branch -D`; stale config is harmless and + // should not make the already-deleted ref look like a failed delete. + } +} + +async function isLocalBranchCheckedOut( + repoPath: string, + branchName: string, + runGit: (args: string[], cwd: string) => Promise<{ stdout: string; stderr: string }> +): Promise { + const { stdout } = await runGit(['worktree', 'list', '--porcelain'], repoPath) + return parseWorktreeList(stdout).some( + (worktree) => normalizeLocalBranchRef(worktree.branch) === branchName + ) +} + /** * Assert a worktree is clean enough for non-force removal. */ diff --git a/src/main/gitea/client.ts b/src/main/gitea/client.ts index c86dd89a9a4..5aadd803bf8 100644 --- a/src/main/gitea/client.ts +++ b/src/main/gitea/client.ts @@ -186,9 +186,10 @@ export async function getGiteaAuthStatus(): Promise { export async function getGiteaPullRequest( repoPath: string, - prNumber: number + prNumber: number, + connectionId?: string | null ): Promise { - const repo = await getGiteaRepoRef(repoPath) + const repo = await getGiteaRepoRef(repoPath, connectionId) if (!repo) { return null } @@ -202,14 +203,15 @@ export async function getGiteaPullRequest( export async function getGiteaPullRequestForBranch( repoPath: string, branch: string, - linkedPRNumber?: number | null + linkedPRNumber?: number | null, + connectionId?: string | null ): Promise { const branchName = branch.replace(/^refs\/heads\//, '') if (!branchName && linkedPRNumber == null) { return null } - const repo = await getGiteaRepoRef(repoPath) + const repo = await getGiteaRepoRef(repoPath, connectionId) if (!repo) { return null } @@ -248,6 +250,9 @@ export async function getGiteaPullRequestForBranch( return raw ? normalizePullRequest(repo, raw) : null } -export async function getGiteaRepoSlug(repoPath: string): Promise { - return getGiteaRepoRef(repoPath) +export async function getGiteaRepoSlug( + repoPath: string, + connectionId?: string | null +): Promise { + return getGiteaRepoRef(repoPath, connectionId) } diff --git a/src/main/gitea/pull-request-mappers.test.ts b/src/main/gitea/pull-request-mappers.test.ts index 3b71a93ffdd..d497096f166 100644 --- a/src/main/gitea/pull-request-mappers.test.ts +++ b/src/main/gitea/pull-request-mappers.test.ts @@ -11,6 +11,7 @@ describe('Gitea pull request mappers', () => { expect(mapGiteaPullRequestState({ state: 'open' })).toBe('open') expect(mapGiteaPullRequestState({ state: 'closed' })).toBe('closed') expect(mapGiteaPullRequestState({ state: 'open', draft: true })).toBe('draft') + expect(mapGiteaPullRequestState({ state: 'closed', draft: true })).toBe('closed') expect(mapGiteaPullRequestState({ state: 'closed', merged: true })).toBe('merged') }) diff --git a/src/main/gitea/pull-request-mappers.ts b/src/main/gitea/pull-request-mappers.ts index 090e38bd39d..eee0bd5d946 100644 --- a/src/main/gitea/pull-request-mappers.ts +++ b/src/main/gitea/pull-request-mappers.ts @@ -48,6 +48,7 @@ function classifyGiteaStatus(status: string | null | undefined): CheckStatus { case 'pending': return 'pending' case 'skipped': + case undefined: default: return 'neutral' } @@ -92,10 +93,15 @@ export function mapGiteaPullRequestState( if (raw.merged) { return 'merged' } + // Closed Gitea PRs can still carry the draft flag; terminal state should + // win so review summaries do not show closed PRs as active drafts. + if (raw.state?.trim().toLowerCase() === 'closed') { + return 'closed' + } if (raw.draft) { return 'draft' } - return raw.state?.trim().toLowerCase() === 'closed' ? 'closed' : 'open' + return 'open' } export function mapGiteaMergeable(value: boolean | null | undefined): PRMergeableState { diff --git a/src/main/gitea/repository-ref.test.ts b/src/main/gitea/repository-ref.test.ts index 0095fb9c98a..a104208d1b6 100644 --- a/src/main/gitea/repository-ref.test.ts +++ b/src/main/gitea/repository-ref.test.ts @@ -1,21 +1,35 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { gitExecFileAsyncMock } = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn() +const { gitExecFileAsyncMock, sshExecMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn(), + sshExecMock: vi.fn() })) vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) -import { _resetGiteaRepoRefCache, getGiteaRepoRef, parseGiteaRepoRef } from './repository-ref' +import { + _getGiteaRepoRefCacheSize, + _resetGiteaRepoRefCache, + getGiteaRepoRef, + getGiteaRepoRefForRemote, + parseGiteaRepoRef +} from './repository-ref' +import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' describe('Gitea repository ref parsing', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() + sshExecMock.mockReset() + unregisterSshGitProvider('conn-1') _resetGiteaRepoRefCache() }) + afterEach(() => { + unregisterSshGitProvider('conn-1') + }) + it('parses HTTPS remotes and derives the API base URL', () => { expect(parseGiteaRepoRef('https://git.example.com/team/project.git')).toEqual({ host: 'git.example.com', @@ -26,6 +40,21 @@ describe('Gitea repository ref parsing', () => { }) }) + it('strips trailing slashes after .git suffixes', () => { + expect(parseGiteaRepoRef('https://git.example.com/team/project.git/')).toEqual({ + host: 'git.example.com', + owner: 'team', + repo: 'project', + apiBaseUrl: 'https://git.example.com/api/v1', + webBaseUrl: 'https://git.example.com' + }) + expect(parseGiteaRepoRef('git@gitea.example.test:team/project.git/')).toMatchObject({ + host: 'gitea.example.test', + owner: 'team', + repo: 'project' + }) + }) + it('preserves an HTTP subpath when deriving the API base URL', () => { expect(parseGiteaRepoRef('https://git.example.com/code/team/project.git')).toEqual({ host: 'git.example.com', @@ -95,4 +124,52 @@ describe('Gitea repository ref parsing', () => { cwd: '/repo' }) }) + + it('bounds cached repository refs for distinct repo paths', async () => { + gitExecFileAsyncMock.mockResolvedValue({ + stdout: 'https://git.example.com/team/project.git\n', + stderr: '' + }) + + for (let i = 0; i < 513; i += 1) { + await getGiteaRepoRef(`/repo-${i}`) + } + + expect(_getGiteaRepoRefCacheSize()).toBe(512) + }) + + it('resolves repository refs through the SSH git provider for connected repos', async () => { + sshExecMock.mockResolvedValueOnce({ + stdout: 'git@gitea.example.test:remote/project.git\n', + stderr: '' + }) + registerSshGitProvider('conn-1', { exec: sshExecMock } as never) + + await expect(getGiteaRepoRefForRemote('/repo', 'origin', 'conn-1')).resolves.toMatchObject({ + host: 'gitea.example.test', + owner: 'remote', + repo: 'project' + }) + + expect(sshExecMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/repo') + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('does not cache transient SSH provider failures as unsupported repos', async () => { + sshExecMock.mockRejectedValueOnce(new Error('connection closed')).mockResolvedValueOnce({ + stdout: 'git@gitea.example.test:remote/project.git\n', + stderr: '' + }) + registerSshGitProvider('conn-1', { exec: sshExecMock } as never) + + await expect(getGiteaRepoRefForRemote('/repo', 'origin', 'conn-1')).resolves.toBeNull() + await expect(getGiteaRepoRefForRemote('/repo', 'origin', 'conn-1')).resolves.toMatchObject({ + host: 'gitea.example.test', + owner: 'remote', + repo: 'project' + }) + + expect(sshExecMock).toHaveBeenCalledTimes(2) + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + }) }) diff --git a/src/main/gitea/repository-ref.ts b/src/main/gitea/repository-ref.ts index 6687525a48c..08dfd7108b6 100644 --- a/src/main/gitea/repository-ref.ts +++ b/src/main/gitea/repository-ref.ts @@ -1,4 +1,5 @@ import { gitExecFileAsync } from '../git/runner' +import { getSshGitProvider } from '../providers/ssh-git-dispatch' export type GiteaRepoRef = { host: string @@ -15,6 +16,7 @@ const KNOWN_NON_GITEA_HOSTS = new Set([ 'dev.azure.com', 'ssh.dev.azure.com' ]) +const REPO_REF_CACHE_MAX_ENTRIES = 512 const repoRefCache = new Map() /** @internal - exposed for tests only */ @@ -22,6 +24,22 @@ export function _resetGiteaRepoRefCache(): void { repoRefCache.clear() } +/** @internal - exposed for tests only */ +export function _getGiteaRepoRefCacheSize(): number { + return repoRefCache.size +} + +function rememberRepoRefCacheEntry(cacheKey: string, value: GiteaRepoRef | null): void { + repoRefCache.set(cacheKey, value) + while (repoRefCache.size > REPO_REF_CACHE_MAX_ENTRIES) { + const oldestKey = repoRefCache.keys().next().value + if (oldestKey === undefined) { + return + } + repoRefCache.delete(oldestKey) + } +} + function decodeSegment(value: string): string { try { return decodeURIComponent(value) @@ -31,7 +49,7 @@ function decodeSegment(value: string): string { } function parsePath(pathname: string): { owner: string; repo: string; basePath: string } | null { - const withoutSuffix = pathname.replace(/\.git$/i, '') + const withoutSuffix = pathname.replace(/\/+$/, '').replace(/\.git$/i, '') const parts = withoutSuffix .split('/') .map((part) => part.trim()) @@ -121,25 +139,40 @@ export function parseGiteaRepoRef(remoteUrl: string): GiteaRepoRef | null { export async function getGiteaRepoRefForRemote( repoPath: string, - remoteName: string + remoteName: string, + connectionId?: string | null ): Promise { - const cacheKey = `${repoPath}\0${remoteName}` + const cacheKey = `${connectionId ?? 'local'}\0${repoPath}\0${remoteName}` if (repoRefCache.has(cacheKey)) { return repoRefCache.get(cacheKey)! } try { - const { stdout } = await gitExecFileAsync(['remote', 'get-url', remoteName], { - cwd: repoPath - }) + const sshGitProvider = connectionId ? getSshGitProvider(connectionId) : null + if (connectionId && !sshGitProvider) { + return null + } + const { stdout } = sshGitProvider + ? await sshGitProvider.exec(['remote', 'get-url', remoteName], repoPath) + : await gitExecFileAsync(['remote', 'get-url', remoteName], { + cwd: repoPath + }) const result = parseGiteaRepoRef(stdout) - repoRefCache.set(cacheKey, result) + rememberRepoRefCacheEntry(cacheKey, result) return result } catch { - repoRefCache.set(cacheKey, null) + if (connectionId) { + // Why: SSH provider failures are often transient reconnect/tunnel states; + // caching them as "not Gitea" would poison the repo for the session. + return null + } + rememberRepoRefCacheEntry(cacheKey, null) return null } } -export async function getGiteaRepoRef(repoPath: string): Promise { - return getGiteaRepoRefForRemote(repoPath, 'origin') +export async function getGiteaRepoRef( + repoPath: string, + connectionId?: string | null +): Promise { + return getGiteaRepoRefForRemote(repoPath, 'origin', connectionId) } diff --git a/src/main/github/client-work-items.test.ts b/src/main/github/client-work-items.test.ts index e8e3dcba802..86138028a5f 100644 --- a/src/main/github/client-work-items.test.ts +++ b/src/main/github/client-work-items.test.ts @@ -58,7 +58,12 @@ vi.mock('./rate-limit', () => ({ noteRateLimitSpend: noteRateLimitSpendMock })) -import { countWorkItems, listWorkItems, _resetOwnerRepoCache } from './client' +import { + countWorkItems, + listWorkItems, + _resetMergeQueueCacheForTests, + _resetOwnerRepoCache +} from './client' describe('listWorkItems', () => { beforeEach(() => { @@ -84,6 +89,7 @@ describe('listWorkItems', () => { })) getOwnerRepoForRemoteMock.mockResolvedValue(null) _resetOwnerRepoCache() + _resetMergeQueueCacheForTests() }) it('runs both issue and PR GitHub searches for a mixed query and merges the results by recency', async () => { @@ -221,6 +227,58 @@ describe('listWorkItems', () => { ]) }) + it('hydrates PR list rows with repository merge method settings', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify([ + { + number: 42, + title: 'Add feature', + state: 'OPEN', + url: 'https://github.com/acme/widgets/pull/42', + labels: [], + updatedAt: '2026-03-28T00:00:00Z', + author: { login: 'octocat' }, + isDraft: false, + headRefName: 'feature/add-feature', + headRefOid: 'head-42', + baseRefName: 'main' + } + ]) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + data: { + repository: { + viewerDefaultMergeMethod: 'REBASE', + mergeCommitAllowed: false, + rebaseMergeAllowed: true, + squashMergeAllowed: true + } + } + }) + }) + + const { items } = await listWorkItems('/repo-root', 10, 'is:pr') + + expect(items).toHaveLength(1) + expect(items[0]?.mergeMethodSettings).toEqual({ + defaultMethod: 'rebase', + allowedMethods: { + squash: true, + merge: false, + rebase: true + } + }) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, + expect.arrayContaining(['api', 'graphql', '-f', 'owner=acme', '-f', 'repo=widgets']), + { cwd: '/repo-root' } + ) + }) + it('routes draft queries to PR search only', async () => { getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) @@ -242,7 +300,7 @@ describe('listWorkItems', () => { ]) }) const { items } = await listWorkItems('/repo-root', 10, 'is:pr is:draft') - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( [ 'pr', @@ -301,7 +359,7 @@ describe('listWorkItems', () => { const { items } = await listWorkItems('/repo-root', 10, 'is:merged') - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( [ 'pr', diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index a2e720a362b..e56627b4509 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -19,6 +19,7 @@ const { noteRateLimitSpendMock, ghRepoExecOptionsMock, githubRepoContextMock, + getSshGitProviderMock, acquireMock, releaseMock } = vi.hoisted(() => ({ @@ -40,6 +41,7 @@ const { repoPath, connectionId: connectionId ?? null })), + getSshGitProviderMock: vi.fn(), acquireMock: vi.fn(), releaseMock: vi.fn() })) @@ -78,6 +80,10 @@ vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: getSshGitProviderMock +})) + vi.mock('./rate-limit', () => ({ getRateLimit: getRateLimitMock, rateLimitGuard: rateLimitGuardMock, @@ -92,7 +98,9 @@ import { mergePR, resolveReviewThread, setPRAutoMerge, + updatePRState, updatePRTitle, + _getMergeQueueCacheSizeForTests, _resetOwnerRepoCache, _resetMergeQueueCacheForTests } from './client' @@ -118,6 +126,7 @@ describe('getPRForBranch', () => { noteRateLimitSpendMock.mockReset() ghRepoExecOptionsMock.mockClear() githubRepoContextMock.mockClear() + getSshGitProviderMock.mockReset() acquireMock.mockReset() releaseMock.mockReset() acquireMock.mockResolvedValue(undefined) @@ -297,6 +306,69 @@ describe('getPRForBranch', () => { }) }) + it('hydrates repository merge method settings for exact PR lookups', async () => { + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 99, + title: 'Linked PR', + state: 'OPEN', + url: 'https://github.com/acme/widgets/pull/99', + statusCheckRollup: [], + updatedAt: '2026-03-28T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + reviewDecision: 'APPROVED', + mergeStateStatus: 'CLEAN', + autoMergeRequest: null, + baseRefName: 'main', + headRefName: 'someone/fix', + baseRefOid: 'base-oid', + headRefOid: 'linked-head-oid' + }) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + data: { + repository: { + viewerDefaultMergeMethod: 'REBASE', + mergeCommitAllowed: false, + rebaseMergeAllowed: true, + squashMergeAllowed: true, + mergeQueue: null + } + } + }) + }) + + const pr = await getPRForBranch('/repo-root', 'feature/local-worktree', 99) + + expect(pr?.mergeMethodSettings).toEqual({ + defaultMethod: 'rebase', + allowedMethods: { + squash: true, + merge: false, + rebase: true + } + }) + expect(pr?.mergeQueueRequired).toBe(false) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, + expect.arrayContaining([ + 'api', + 'graphql', + '-f', + 'owner=acme', + '-f', + 'repo=widgets', + '-f', + 'branch=main' + ]), + { cwd: '/repo-root' } + ) + }) + it('treats linked PR metadata as authoritative even when the branch head differs', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'current-worktree-head\n', stderr: '' }) @@ -604,6 +676,197 @@ describe('getPRForBranch', () => { expect(pr).toMatchObject({ number: 42, title: 'Fallback PR lookup' }) }) + it('falls back to the tracked upstream branch when the local branch name differs', async () => { + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) + .mockResolvedValueOnce({ + stdout: JSON.stringify([ + { + number: 78, + title: 'Upstream branch PR', + state: 'open', + html_url: 'https://github.com/acme/widgets/pull/78', + updated_at: '2026-03-28T00:00:00Z', + draft: false, + mergeable: true, + base: { ref: 'main', sha: 'base-oid' }, + head: { ref: 'contributor/original', sha: 'upstream-head-oid' } + } + ]) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 78, + title: 'Hydrated upstream branch PR', + state: 'OPEN', + url: 'https://github.com/acme/widgets/pull/78', + statusCheckRollup: [], + updatedAt: '2026-03-28T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + baseRefName: 'main', + headRefName: 'contributor/original', + baseRefOid: 'base-oid', + headRefOid: 'upstream-head-oid' + }) + }) + gitExecFileAsyncMock.mockResolvedValueOnce({ + stdout: 'origin/contributor/original\n', + stderr: '' + }) + + const pr = await getPRForBranch('/repo-root', 'local-created-from-pr') + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['rev-parse', '--abbrev-ref', '--symbolic-full-name', 'local-created-from-pr@{upstream}'], + { cwd: '/repo-root' } + ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 1, + ['api', 'repos/acme/widgets/pulls?head=acme%3Alocal-created-from-pr&state=all&per_page=1'], + { cwd: '/repo-root' } + ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, + ['api', 'repos/acme/widgets/pulls?head=acme%3Acontributor%2Foriginal&state=all&per_page=1'], + { cwd: '/repo-root' } + ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 3, + [ + 'pr', + 'view', + '78', + '--repo', + 'acme/widgets', + '--json', + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' + ], + { cwd: '/repo-root' } + ) + expect(pr).toMatchObject({ + number: 78, + title: 'Hydrated upstream branch PR', + headSha: 'upstream-head-oid' + }) + }) + + it('uses the tracked upstream remote owner for fork branch lookup', async () => { + resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ + candidates: [ + { owner: 'stablyai', repo: 'orca' }, + { owner: 'origin-owner', repo: 'orca' } + ], + headRepo: { owner: 'origin-owner', repo: 'orca' } + }) + getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'fork-owner', repo: 'orca' }) + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) + .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) + .mockResolvedValueOnce({ + stdout: JSON.stringify([ + { + number: 78, + title: 'Fork upstream branch PR', + state: 'open', + html_url: 'https://github.com/stablyai/orca/pull/78', + updated_at: '2026-03-28T00:00:00Z', + draft: false, + mergeable: true, + base: { ref: 'main', sha: 'base-oid' }, + head: { ref: 'contributor/original', sha: 'upstream-head-oid' } + } + ]) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 78, + title: 'Hydrated fork upstream branch PR', + state: 'OPEN', + url: 'https://github.com/stablyai/orca/pull/78', + statusCheckRollup: [], + updatedAt: '2026-03-28T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + baseRefName: 'main', + headRefName: 'contributor/original', + baseRefOid: 'base-oid', + headRefOid: 'upstream-head-oid' + }) + }) + gitExecFileAsyncMock.mockResolvedValueOnce({ + stdout: 'fork/contributor/original\n', + stderr: '' + }) + + const pr = await getPRForBranch('/repo-root', 'local-created-from-pr') + + expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith('/repo-root', 'fork', undefined) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 3, + [ + 'api', + 'repos/stablyai/orca/pulls?head=fork-owner%3Acontributor%2Foriginal&state=all&per_page=1' + ], + { cwd: '/repo-root' } + ) + expect(pr).toMatchObject({ + number: 78, + title: 'Hydrated fork upstream branch PR', + prRepo: { owner: 'stablyai', repo: 'orca' }, + headRepo: { owner: 'fork-owner', repo: 'orca' } + }) + }) + + it('checks the tracked upstream branch through the SSH git provider', async () => { + const sshGitProvider = { + exec: vi.fn().mockResolvedValue({ + stdout: 'origin/contributor/original\n', + stderr: '' + }) + } + getSshGitProviderMock.mockReturnValue(sshGitProvider) + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) + .mockResolvedValueOnce({ + stdout: JSON.stringify([ + { + number: 78, + title: 'SSH upstream branch PR', + state: 'open', + html_url: 'https://github.com/acme/widgets/pull/78', + updated_at: '2026-03-28T00:00:00Z', + draft: false, + mergeable: true, + base: { ref: 'main', sha: 'base-oid' }, + head: { ref: 'contributor/original', sha: 'upstream-head-oid' } + } + ]) + }) + + const pr = await getPRForBranch( + '/remote/repo-root', + 'local-created-from-pr', + undefined, + 'ssh-1' + ) + + expect(sshGitProvider.exec).toHaveBeenCalledWith( + ['rev-parse', '--abbrev-ref', '--symbolic-full-name', 'local-created-from-pr@{upstream}'], + '/remote/repo-root' + ) + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, + ['api', 'repos/acme/widgets/pulls?head=acme%3Acontributor%2Foriginal&state=all&per_page=1'], + {} + ) + expect(pr).toMatchObject({ number: 78, title: 'SSH upstream branch PR' }) + }) + it('uses linked PR number as the source of truth when provided', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock.mockResolvedValueOnce({ @@ -1080,6 +1343,65 @@ describe('getPRForBranch', () => { }) }) +describe('updatePRState', () => { + beforeEach(() => { + ghExecFileAsyncMock.mockReset() + getOwnerRepoMock.mockReset() + ghRepoExecOptionsMock.mockClear() + githubRepoContextMock.mockClear() + acquireMock.mockReset() + releaseMock.mockReset() + acquireMock.mockResolvedValue(undefined) + _resetOwnerRepoCache() + }) + + it('reopens pull requests through the gh PR command', async () => { + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) + + await expect(updatePRState('/repo-root', 3977, { state: 'open' })).resolves.toEqual({ + ok: true + }) + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + ['pr', 'reopen', '3977', '--repo', 'stablyai/orca'], + { cwd: '/repo-root' } + ) + expect(acquireMock).toHaveBeenCalledTimes(1) + expect(releaseMock).toHaveBeenCalledTimes(1) + }) + + it('closes pull requests through the gh PR command', async () => { + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) + + await expect(updatePRState('/repo-root', 3977, { state: 'closed' })).resolves.toEqual({ + ok: true + }) + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + ['pr', 'close', '3977', '--repo', 'stablyai/orca'], + { cwd: '/repo-root' } + ) + }) + + it('reopens SSH-backed pull requests without local cwd options', async () => { + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) + + await expect( + updatePRState('/remote/repo-root', 3977, { state: 'open' }, 'ssh-1') + ).resolves.toEqual({ + ok: true + }) + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + ['pr', 'reopen', '3977', '--repo', 'stablyai/orca'], + {} + ) + }) +}) + describe('GitHub GraphQL rate-limit guard', () => { beforeEach(() => { execFileAsyncMock.mockReset() @@ -1365,6 +1687,41 @@ describe('GitHub GraphQL rate-limit guard', () => { ).toHaveLength(1) }) + it('bounds merge metadata cache entries across many base branches', async () => { + getOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) + let prViewCount = 0 + ghExecFileAsyncMock.mockImplementation(async (args) => { + if (args.includes('graphql')) { + return { stdout: JSON.stringify({ data: { repository: { mergeQueue: null } } }) } + } + prViewCount += 1 + return { + stdout: JSON.stringify({ + number: prViewCount, + title: 'PR', + state: 'OPEN', + url: `https://github.com/stablyai/orca/pull/${prViewCount}`, + statusCheckRollup: [], + updatedAt: '2026-04-01T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + reviewDecision: 'APPROVED', + mergeStateStatus: 'CLEAN', + autoMergeRequest: null, + baseRefName: `base-${prViewCount}`, + baseRefOid: 'base-oid', + headRefOid: 'head-oid' + }) + } + }) + + for (let i = 0; i < 260; i++) { + await getPRForBranch('/repo-root', `feature/${i}`, i + 1) + } + + expect(_getMergeQueueCacheSizeForTests()).toBe(256) + }) + it('returns conflicting file details instead of running gh merge when PR is dirty', async () => { ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify({ diff --git a/src/main/github/client.ts b/src/main/github/client.ts index fb5c5be0a3b..488aaa59a67 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -18,13 +18,15 @@ import type { GitHubViewer, GitHubWorkItem, GitHubPullRequestStateUpdate, - GitHubRerunPRChecksResult + GitHubRerunPRChecksResult, + GitHubPRMergeMethodSettings } from '../../shared/types' import type { CreateHostedReviewInput, CreateHostedReviewResult } from '../../shared/hosted-review' import { normalizeHostedReviewBaseRef, normalizeHostedReviewHeadRef } from '../../shared/hosted-review-refs' +import { normalizeGitHubPRMergeMethodSettings } from '../../shared/github-pr-merge-methods' import { parseTaskQuery, type ParsedTaskQuery } from '../../shared/task-query' import { GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE, @@ -36,6 +38,7 @@ import { tmpdir } from 'os' import { getPRConflictSummary } from './conflict-summary' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths' +import { splitRemoteBranchName } from '../../shared/git-effective-upstream' import { execFileAsync, ghExecFileAsync, @@ -55,6 +58,7 @@ import { getRemoteUrlForRepo, type OwnerRepo } from './gh-utils' +import { getSshGitProvider } from '../providers/ssh-git-dispatch' export { _resetOwnerRepoCache } from './gh-utils' export { getIssue, @@ -86,10 +90,37 @@ type GhExecOptions = ReturnType const ORCA_REPO = 'stablyai/orca' const MERGE_QUEUE_CACHE_TTL_MS = 10 * 60 * 1000 const MERGE_QUEUE_UNKNOWN_CACHE_TTL_MS = 60 * 1000 -const mergeQueueRequiredCache = new Map() +const MERGE_QUEUE_CACHE_MAX_ENTRIES = 256 +type GitHubRepositoryMergeMetadata = { + mergeQueueRequired: boolean | null + mergeMethodSettings?: GitHubPRMergeMethodSettings +} +const repositoryMergeMetadataCache = new Map< + string, + { value: GitHubRepositoryMergeMetadata; expiresAt: number } +>() export function _resetMergeQueueCacheForTests(): void { - mergeQueueRequiredCache.clear() + repositoryMergeMetadataCache.clear() +} + +export function _getMergeQueueCacheSizeForTests(): number { + return repositoryMergeMetadataCache.size +} + +function pruneRepositoryMergeMetadataCache(now = Date.now()): void { + for (const [cacheKey, cached] of repositoryMergeMetadataCache) { + if (cached.expiresAt <= now) { + repositoryMergeMetadataCache.delete(cacheKey) + } + } + while (repositoryMergeMetadataCache.size > MERGE_QUEUE_CACHE_MAX_ENTRIES) { + const oldestKey = repositoryMergeMetadataCache.keys().next().value + if (oldestKey === undefined) { + break + } + repositoryMergeMetadataCache.delete(oldestKey) + } } async function assertRateLimitBudget(bucket: RateLimitBucketKind): Promise { @@ -350,9 +381,9 @@ const WORK_ITEM_PR_LIST_JSON_FIELDS = 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests' // Why: these fields are intentionally excluded from `gh pr list` because -// statusCheckRollup/review decision/merge metadata fan out into expensive -// GraphQL work across every row. Requested reviewers are kept in the list -// payload because the Tasks table renders that column on first paint. +// statusCheckRollup/review decision/PR-specific merge metadata fan out into +// expensive GraphQL work across every row. Requested reviewers are kept in the +// list payload because the Tasks table renders that column on first paint. const WORK_ITEM_PR_DETAIL_JSON_FIELDS = 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,additions,deletions,changedFiles,reviewDecision,reviewRequests,latestReviews,assignees,statusCheckRollup,mergeable,mergeStateStatus,autoMergeRequest,maintainerCanModify' @@ -683,6 +714,26 @@ function mapPullRequestWorkItem( } } +async function hydrateWorkItemMergeMethodSettings( + items: MainWorkItem[], + ownerRepo: OwnerRepo | null, + ghOptions: GhExecOptions +): Promise { + const hasPullRequest = items.some((item) => item.type === 'pr') + if (!ownerRepo || !hasPullRequest) { + return items + } + // Why: merge method settings are repository-level, so one cached metadata + // probe can keep Tasks rows accurate without per-PR GraphQL fan-out. + const mergeMetadata = await detectRepositoryMergeMetadata(ownerRepo, undefined, ghOptions) + if (!mergeMetadata.mergeMethodSettings) { + return items + } + return items.map((item) => + item.type === 'pr' ? { ...item, mergeMethodSettings: mergeMetadata.mergeMethodSettings } : item + ) +} + async function fetchIssueWorkItem( repoPath: string, ownerRepo: OwnerRepo | null, @@ -733,8 +784,14 @@ async function fetchPullRequestWorkItem( const item = JSON.parse(stdout) as Record const mapped = mapPullRequestWorkItem(item, ownerRepo) const baseRefName = typeof item.baseRefName === 'string' ? item.baseRefName : undefined - const mergeQueueRequired = await detectMergeQueueRequired(ownerRepo, baseRefName, ghOptions) - return { ...mapped, mergeQueueRequired } + const mergeMetadata = await detectRepositoryMergeMetadata(ownerRepo, baseRefName, ghOptions) + return { + ...mapped, + mergeQueueRequired: mergeMetadata.mergeQueueRequired, + ...(mergeMetadata.mergeMethodSettings + ? { mergeMethodSettings: mergeMetadata.mergeMethodSettings } + : {}) + } } catch { const { stdout } = await ghExecFileAsync( ['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${number}`], @@ -930,6 +987,7 @@ async function listRecentWorkItems( prs = (JSON.parse(prsSettled.value.stdout) as Record[]).map((item) => mapPullRequestWorkItem(item, prOwnerRepo) ) + prs = await hydrateWorkItemMergeMethodSettings(prs, prOwnerRepo, ghOptions) } else { // Why: PR-side failures must preserve the pre-diff behavior of // Promise.all by re-throwing so the rejection propagates up through @@ -1071,10 +1129,11 @@ async function listQueriedWorkItems( const mapped = (JSON.parse(stdout) as Record[]).map((item) => mapPullRequestWorkItem(item, prOwnerRepo) ) + const hydrated = await hydrateWorkItemMergeMethodSettings(mapped, prOwnerRepo, ghOptions) if (query.state === 'closed') { - return mapped.filter((item) => item.state !== 'merged') + return hydrated.filter((item) => item.state !== 'merged') } - return mapped + return hydrated } catch (err) { console.warn('listQueriedWorkItems PRs partial failure:', err) return [] @@ -1692,6 +1751,7 @@ type PullRequestLookupData = { autoMergeRequest?: unknown autoMergeEnabled?: boolean mergeQueueRequired?: boolean | null + mergeMethodSettings?: GitHubPRMergeMethodSettings mergeStateStatus?: string | null baseRefName?: string headRefName?: string @@ -1758,63 +1818,106 @@ function normalizePullRequestLookupData(data: PullRequestLookupData): PullReques } } -function cacheMergeQueueRequired(cacheKey: string, value: boolean | null, ttlMs: number): void { - mergeQueueRequiredCache.set(cacheKey, { +function cacheRepositoryMergeMetadata( + cacheKey: string, + value: GitHubRepositoryMergeMetadata, + ttlMs: number +): void { + const now = Date.now() + pruneRepositoryMergeMetadataCache(now) + // Why: merge metadata is keyed by user-controlled branch names. Keep the + // per-session cache bounded even if many short-lived branches are inspected. + repositoryMergeMetadataCache.delete(cacheKey) + repositoryMergeMetadataCache.set(cacheKey, { value, - expiresAt: Date.now() + ttlMs + expiresAt: now + ttlMs }) + pruneRepositoryMergeMetadataCache(now) } -async function detectMergeQueueRequired( +async function detectRepositoryMergeMetadata( ownerRepo: OwnerRepo, branchName: string | undefined, ghOptions: GhExecOptions -): Promise { - if (!branchName) { - return null - } - const cacheKey = `${ownerRepo.owner.toLowerCase()}/${ownerRepo.repo.toLowerCase()}:${branchName}` - const cached = mergeQueueRequiredCache.get(cacheKey) - if (cached && cached.expiresAt > Date.now()) { +): Promise { + const cacheKey = `${ownerRepo.owner.toLowerCase()}/${ownerRepo.repo.toLowerCase()}:${ + branchName ?? '__repo__' + }` + pruneRepositoryMergeMetadataCache() + const cached = repositoryMergeMetadataCache.get(cacheKey) + if (cached) { return cached.value } const guard = rateLimitGuard('graphql') if (guard.blocked) { - return null + return { mergeQueueRequired: null } } - const query = `query($owner: String!, $repo: String!, $branch: String!) { + const query = branchName + ? `query($owner: String!, $repo: String!, $branch: String!) { repository(owner: $owner, name: $repo) { + viewerDefaultMergeMethod + mergeCommitAllowed + rebaseMergeAllowed + squashMergeAllowed mergeQueue(branch: $branch) { id } } + }` + : `query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + viewerDefaultMergeMethod + mergeCommitAllowed + rebaseMergeAllowed + squashMergeAllowed + } }` try { noteRateLimitSpend('graphql') - const { stdout } = await ghExecFileAsync( - [ - 'api', - 'graphql', - '-f', - `query=${query}`, - '-f', - `owner=${ownerRepo.owner}`, - '-f', - `repo=${ownerRepo.repo}`, - '-f', - `branch=${branchName}` - ], - ghOptions - ) - const parsed = JSON.parse(stdout) as { - data?: { repository?: { mergeQueue?: { id?: unknown } | null } | null } + const args = [ + 'api', + 'graphql', + '-f', + `query=${query}`, + '-f', + `owner=${ownerRepo.owner}`, + '-f', + `repo=${ownerRepo.repo}` + ] + if (branchName) { + args.push('-f', `branch=${branchName}`) } - const value = Boolean(parsed.data?.repository?.mergeQueue) - cacheMergeQueueRequired(cacheKey, value, MERGE_QUEUE_CACHE_TTL_MS) + const { stdout } = await ghExecFileAsync(args, ghOptions) + const parsed = JSON.parse(stdout) as { + data?: { + repository?: { + viewerDefaultMergeMethod?: unknown + mergeCommitAllowed?: unknown + rebaseMergeAllowed?: unknown + squashMergeAllowed?: unknown + mergeQueue?: { id?: unknown } | null + } | null + } + } + const repository = parsed.data?.repository + const mergeMethodSettings = repository + ? normalizeGitHubPRMergeMethodSettings({ + defaultMethod: repository.viewerDefaultMergeMethod, + mergeCommitAllowed: repository.mergeCommitAllowed, + rebaseMergeAllowed: repository.rebaseMergeAllowed, + squashMergeAllowed: repository.squashMergeAllowed + }) + : undefined + const value: GitHubRepositoryMergeMetadata = { + mergeQueueRequired: branchName ? Boolean(repository?.mergeQueue) : null, + ...(mergeMethodSettings ? { mergeMethodSettings } : {}) + } + cacheRepositoryMergeMetadata(cacheKey, value, MERGE_QUEUE_CACHE_TTL_MS) return value } catch { // Why: failed merge-queue probes should stay conservative without // retrying GraphQL on every status poll while GitHub/network is unhappy. - cacheMergeQueueRequired(cacheKey, null, MERGE_QUEUE_UNKNOWN_CACHE_TTL_MS) - return null + const value: GitHubRepositoryMergeMetadata = { mergeQueueRequired: null } + cacheRepositoryMergeMetadata(cacheKey, value, MERGE_QUEUE_UNKNOWN_CACHE_TTL_MS) + return value } } @@ -1826,11 +1929,15 @@ async function hydratePullRequestLookupData( const normalized = normalizePullRequestLookupData(data) const hasRichMergeFields = 'reviewDecision' in data || 'mergeStateStatus' in data || 'autoMergeRequest' in data + const mergeMetadata = hasRichMergeFields + ? await detectRepositoryMergeMetadata(ownerRepo, normalized.baseRefName, ghOptions) + : undefined return { ...normalized, - mergeQueueRequired: hasRichMergeFields - ? await detectMergeQueueRequired(ownerRepo, normalized.baseRefName, ghOptions) - : undefined + ...(mergeMetadata ? { mergeQueueRequired: mergeMetadata.mergeQueueRequired } : {}), + ...(mergeMetadata?.mergeMethodSettings + ? { mergeMethodSettings: mergeMetadata.mergeMethodSettings } + : {}) } } @@ -1891,6 +1998,98 @@ async function getFallbackPRListForBranch( return list[0] ?? null } +type TrackedUpstreamBranch = { + remoteName: string + branchName: string +} + +function parseTrackedUpstreamBranch( + upstreamRef: string, + branchName: string +): TrackedUpstreamBranch | null { + const parsed = splitRemoteBranchName(upstreamRef.trim()) + if (!parsed || parsed.branchName === branchName) { + return null + } + return parsed +} + +async function getTrackedUpstreamBranch( + repoPath: string, + branchName: string, + connectionId?: string | null +): Promise { + const args = ['rev-parse', '--abbrev-ref', '--symbolic-full-name', `${branchName}@{upstream}`] + try { + const provider = connectionId ? getSshGitProvider(connectionId) : null + const result = provider + ? await provider.exec(args, repoPath) + : await gitExecFileAsync(args, { cwd: repoPath }) + return parseTrackedUpstreamBranch(result.stdout, branchName) + } catch { + return null + } +} + +async function lookupPRByBranchName(args: { + candidates: OwnerRepo[] + headRepo: OwnerRepo | null + branchName: string + ghOptions: GhExecOptions +}): Promise<{ data: PullRequestLookupData | null; dataRepo: OwnerRepo | null }> { + if (args.candidates.length > 0) { + for (const candidate of args.candidates) { + try { + const branchData = args.headRepo + ? await getRestPRForBranch( + candidate, + args.headRepo.owner, + args.branchName, + args.ghOptions + ) + : await getFallbackPRListForBranch(candidate, args.branchName, args.ghOptions) + // Why: REST/list branch lookup identifies the PR cheaply; exact + // `gh pr view` carries review, merge queue, and auto-merge state. + const data = await hydrateBranchLookupWithExactPR(candidate, branchData, args.ghOptions) + if (data) { + return { data, dataRepo: candidate } + } + } catch (err) { + if (args.headRepo) { + throw err + } + const branchData = await getRestPRForBranch( + candidate, + candidate.owner, + args.branchName, + args.ghOptions + ) + const data = await hydrateBranchLookupWithExactPR(candidate, branchData, args.ghOptions) + if (data) { + return { data, dataRepo: candidate } + } + } + } + return { data: null, dataRepo: null } + } + + try { + const { stdout } = await ghExecFileAsync( + ['pr', 'view', args.branchName, '--json', PR_LOOKUP_JSON_FIELDS], + args.ghOptions + ) + return { + data: normalizePullRequestLookupData(JSON.parse(stdout) as PullRequestLookupData), + dataRepo: null + } + } catch (err) { + if (isNoPullRequestError(err)) { + return { data: null, dataRepo: null } + } + throw err + } +} + async function getRestPRByNumber( ownerRepo: OwnerRepo, number: number, @@ -2051,6 +2250,7 @@ export async function getPRForBranchOutcome( const { candidates, headRepo } = await resolvePRRepositoryCandidates(repoPath, connectionId) let data: PullRequestLookupData | null = null let dataRepo: OwnerRepo | null = null + let dataHeadRepo: OwnerRepo | null = headRepo if (typeof linkedPRNumber === 'number') { const exactLookup = await lookupPRByNumber({ @@ -2063,49 +2263,32 @@ export async function getPRForBranchOutcome( } else if (branchName) { // During a rebase the worktree is in detached HEAD and branch is empty. // An empty --head filter causes gh to return an arbitrary PR. - if (candidates.length > 0) { - for (const candidate of candidates) { - try { - const branchData = headRepo - ? await getRestPRForBranch(candidate, headRepo.owner, branchName, ghOptions) - : await getFallbackPRListForBranch(candidate, branchName, ghOptions) - // Why: REST/list branch lookup identifies the PR cheaply; exact - // `gh pr view` carries review, merge queue, and auto-merge state. - data = await hydrateBranchLookupWithExactPR(candidate, branchData, ghOptions) - if (data) { - dataRepo = candidate - break - } - } catch (err) { - if (headRepo) { - throw err - } else { - const branchData = await getRestPRForBranch( - candidate, - candidate.owner, - branchName, - ghOptions - ) - data = await hydrateBranchLookupWithExactPR(candidate, branchData, ghOptions) - if (data) { - dataRepo = candidate - break - } - } - } - } - } else { - try { - const { stdout } = await ghExecFileAsync( - ['pr', 'view', branchName, '--json', PR_LOOKUP_JSON_FIELDS], + const branchLookup = await lookupPRByBranchName({ + candidates, + headRepo, + branchName, + ghOptions + }) + data = branchLookup.data + dataRepo = branchLookup.dataRepo + if (!data) { + // Why: worktrees can have a short local branch tracking a differently + // named remote PR head; after the local miss, try that configured head. + const upstreamBranch = await getTrackedUpstreamBranch(repoPath, branchName, connectionId) + if (upstreamBranch) { + const upstreamHeadRepo = + (await getOwnerRepoForRemote(repoPath, upstreamBranch.remoteName, connectionId)) ?? + headRepo + const upstreamLookup = await lookupPRByBranchName({ + candidates, + headRepo: upstreamHeadRepo, + branchName: upstreamBranch.branchName, ghOptions - ) - data = normalizePullRequestLookupData(JSON.parse(stdout)) - } catch (err) { - if (isNoPullRequestError(err)) { - data = null - } else { - throw err + }) + data = upstreamLookup.data + dataRepo = upstreamLookup.dataRepo + if (data) { + dataHeadRepo = upstreamHeadRepo } } } @@ -2148,10 +2331,13 @@ export async function getPRForBranchOutcome( ...(data.mergeQueueRequired !== undefined ? { mergeQueueRequired: data.mergeQueueRequired } : {}), + ...(data.mergeMethodSettings !== undefined + ? { mergeMethodSettings: data.mergeMethodSettings } + : {}), ...(data.mergeStateStatus !== undefined ? { mergeStateStatus: data.mergeStateStatus } : {}), headSha: data.headRefOid, prRepo: dataRepo ?? undefined, - headRepo: headRepo ?? undefined, + headRepo: dataHeadRepo ?? undefined, conflictSummary } } @@ -2930,10 +3116,11 @@ export async function addPRReviewCommentReply( threadId?: string, path?: string, line?: number, - connectionId?: string | null + connectionId?: string | null, + prRepo?: OwnerRepo | null ): Promise { const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId)) - const ownerRepo = await getOwnerRepo(repoPath, connectionId) + const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId)) if (!ownerRepo) { return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } } @@ -2950,9 +3137,13 @@ export async function addPRReviewCommentReply( ], ghOptions ) + const data = JSON.parse(stdout) as Parameters[0] + if (typeof data.id !== 'number' || !Number.isSafeInteger(data.id) || data.id < 1) { + return { ok: false, error: 'Unexpected response from GitHub' } + } return { ok: true, - comment: mapReviewCommentResponse(JSON.parse(stdout), body, path, line, undefined, threadId) + comment: mapReviewCommentResponse(data, body, path, line, undefined, threadId) } } catch (err) { const stderr = err instanceof Error ? err.message : String(err) @@ -3171,16 +3362,14 @@ export async function updatePRState( await acquire() try { + const cmd = updates.state === 'closed' ? 'close' : 'reopen' + // Why: GitHub can reject REST pull state PATCHes for reopen paths with a + // generic 422; gh's PR commands use GitHub's supported reopen flow. await ghExecFileAsync( - [ - 'api', - '-X', - 'PATCH', - `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${prNumber}`, - '--raw-field', - `state=${updates.state}` - ], - ghOptions + ['pr', cmd, String(prNumber), '--repo', `${ownerRepo.owner}/${ownerRepo.repo}`], + { + ...ghOptions + } ) return { ok: true } } catch (err) { diff --git a/src/main/github/gh-utils.test.ts b/src/main/github/gh-utils.test.ts index 54646ffbeaa..8a2a313f60e 100644 --- a/src/main/github/gh-utils.test.ts +++ b/src/main/github/gh-utils.test.ts @@ -15,6 +15,7 @@ vi.mock('../providers/ssh-git-dispatch', () => ({ })) import { + _getOwnerRepoCacheSize, _resetOwnerRepoCache, classifyGhError, classifyListIssuesError, @@ -39,6 +40,14 @@ describe('github owner/repo resolution', () => { owner: 'acme', repo: 'widgets' }) + expect(parseGitHubOwnerRepo('https://alice@github.com/acme/widgets.git')).toEqual({ + owner: 'acme', + repo: 'widgets' + }) + expect(parseGitHubOwnerRepo('https://github.com:443/acme/widgets.git')).toEqual({ + owner: 'acme', + repo: 'widgets' + }) expect(parseGitHubOwnerRepo('git@github.com:stablyai/orca.git')).toEqual({ owner: 'stablyai', repo: 'orca' @@ -47,6 +56,14 @@ describe('github owner/repo resolution', () => { owner: 'TheBoredTeam', repo: 'boring.notch' }) + expect(parseGitHubOwnerRepo('ssh://git@github.com/stablyai/orca.git')).toEqual({ + owner: 'stablyai', + repo: 'orca' + }) + expect(parseGitHubOwnerRepo('ssh://git@ssh.github.com:443/stablyai/orca.git')).toEqual({ + owner: 'stablyai', + repo: 'orca' + }) expect(parseGitHubOwnerRepo('git@example.com:stablyai/orca.git')).toBeNull() }) @@ -75,6 +92,17 @@ describe('github owner/repo resolution', () => { }) }) + it('resolves GitHub HTTPS origin remotes with user info and a default port', async () => { + gitExecFileAsyncMock.mockResolvedValueOnce({ + stdout: 'https://alice@github.com:443/acme/widgets.git\n' + }) + + await expect(getOwnerRepo('/repo')).resolves.toEqual({ owner: 'acme', repo: 'widgets' }) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], { + cwd: '/repo' + }) + }) + it('prefers upstream for issue owner/repo resolution', async () => { gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'git@github.com:stablyai/orca.git\n' @@ -139,6 +167,29 @@ describe('github owner/repo resolution', () => { await expect(getOwnerRepo('/repo', 'ssh-1')).resolves.toEqual({ owner: 'remote', repo: 'orca' }) }) + it('prunes expired distinct owner/repo cache entries on later lookups', async () => { + const nowSpy = vi.spyOn(Date, 'now') + try { + nowSpy.mockReturnValue(1_000) + gitExecFileAsyncMock.mockResolvedValueOnce({ + stdout: 'git@github.com:stablyai/orca.git\n' + }) + await expect(getOwnerRepo('/repo-a')).resolves.toEqual({ owner: 'stablyai', repo: 'orca' }) + expect(_getOwnerRepoCacheSize()).toBe(1) + + nowSpy.mockReturnValue(32_000) + gitExecFileAsyncMock.mockResolvedValueOnce({ + stdout: 'git@github.com:acme/widgets.git\n' + }) + await expect(getOwnerRepo('/repo-b')).resolves.toEqual({ owner: 'acme', repo: 'widgets' }) + + expect(_getOwnerRepoCacheSize()).toBe(1) + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) + } finally { + nowSpy.mockRestore() + } + }) + it('resolves PR candidates as upstream then origin and de-dupes matching slugs', async () => { gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: 'git@github.com:Acme/Orca.git\n' }) diff --git a/src/main/github/gh-utils.ts b/src/main/github/gh-utils.ts index 1105f900636..5bb3572a410 100644 --- a/src/main/github/gh-utils.ts +++ b/src/main/github/gh-utils.ts @@ -138,6 +138,7 @@ export function ghRepoExecOptions(context: GitHubRepoContext): { } const OWNER_REPO_CACHE_TTL_MS = 30_000 +const OWNER_REPO_CACHE_MAX_ENTRIES = 512 type OwnerRepoCacheEntry = { value: OwnerRepo | null @@ -151,6 +152,26 @@ export function _resetOwnerRepoCache(): void { ownerRepoCache.clear() } +/** @internal — exposed for tests only */ +export function _getOwnerRepoCacheSize(): number { + return ownerRepoCache.size +} + +function pruneOwnerRepoCache(now: number): void { + for (const [key, entry] of ownerRepoCache) { + if (entry.expiresAt <= now) { + ownerRepoCache.delete(key) + } + } + while (ownerRepoCache.size > OWNER_REPO_CACHE_MAX_ENTRIES) { + const oldestKey = ownerRepoCache.keys().next().value + if (oldestKey === undefined) { + return + } + ownerRepoCache.delete(oldestKey) + } +} + export function parseGitHubOwnerRepo(remoteUrl: string): OwnerRepo | null { const identity = parseGitHubRemoteIdentity(remoteUrl) if (!identity || identity.host.toLowerCase() !== 'github.com') { @@ -159,17 +180,42 @@ export function parseGitHubOwnerRepo(remoteUrl: string): OwnerRepo | null { return { owner: identity.owner, repo: identity.repo } } +function normalizeGitHubRemoteHost(host: string): string { + const normalizedHost = host.toLowerCase() + // Why: GitHub documents ssh.github.com:443 as SSH-over-HTTPS for github.com repos. + return normalizedHost === 'ssh.github.com' ? 'github.com' : normalizedHost +} + +function parseGitHubRemotePath(path: string): Pick | null { + const parts = path.replace(/^\/+/, '').replace(/\/+$/, '').split('/') + if (parts.length !== 2) { + return null + } + const [owner, repoWithSuffix] = parts + const repo = repoWithSuffix.replace(/\.git$/i, '') + if (!owner || !repo) { + return null + } + return { owner, repo } +} + export function parseGitHubRemoteIdentity(remoteUrl: string): GitHubRemoteIdentity | null { const trimmed = remoteUrl.trim() - const httpsMatch = trimmed.match(/^https?:\/\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/i) - if (httpsMatch) { - return { host: httpsMatch[1], owner: httpsMatch[2], repo: httpsMatch[3] } - } const sshMatch = trimmed.match(/^git@([^:]+):([^/]+)\/([^/]+?)(?:\.git)?$/i) if (sshMatch) { - return { host: sshMatch[1], owner: sshMatch[2], repo: sshMatch[3] } + return { host: normalizeGitHubRemoteHost(sshMatch[1]), owner: sshMatch[2], repo: sshMatch[3] } + } + + try { + const url = new URL(trimmed) + if (!['git:', 'git+ssh:', 'http:', 'https:', 'ssh:'].includes(url.protocol.toLowerCase())) { + return null + } + const path = parseGitHubRemotePath(url.pathname) + return path ? { host: normalizeGitHubRemoteHost(url.hostname), ...path } : null + } catch { + return null } - return null } export async function getRemoteUrlForRepo( @@ -197,27 +243,28 @@ export async function getOwnerRepoForRemote( ): Promise { const context = githubRepoContext(repoPath, connectionId) const cacheKey = `${context.connectionId ?? 'local'}\0${context.repoPath}\0${remoteName}` + const now = Date.now() + pruneOwnerRepoCache(now) const cached = ownerRepoCache.get(cacheKey) - if (cached && cached.expiresAt > Date.now()) { + if (cached && cached.expiresAt > now) { return cached.value } - if (cached) { - ownerRepoCache.delete(cacheKey) - } try { const remoteUrl = await getRemoteUrlForRepo(context, remoteName) const result = remoteUrl ? parseGitHubOwnerRepo(remoteUrl) : null if (result) { ownerRepoCache.set(cacheKey, { value: result, - expiresAt: Date.now() + OWNER_REPO_CACHE_TTL_MS + expiresAt: now + OWNER_REPO_CACHE_TTL_MS }) + pruneOwnerRepoCache(now) return result } } catch { // ignore — non-GitHub remote or no remote } - ownerRepoCache.set(cacheKey, { value: null, expiresAt: Date.now() + OWNER_REPO_CACHE_TTL_MS }) + ownerRepoCache.set(cacheKey, { value: null, expiresAt: now + OWNER_REPO_CACHE_TTL_MS }) + pruneOwnerRepoCache(now) return null } diff --git a/src/main/github/issues.test.ts b/src/main/github/issues.test.ts index 3c2c0a7109e..1f857ca02fc 100644 --- a/src/main/github/issues.test.ts +++ b/src/main/github/issues.test.ts @@ -126,6 +126,46 @@ describe('issue source operations', () => { ) }) + it('creates issues with labels and assignees', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 925, + html_url: 'https://github.com/stablyai/orca/issues/925' + }) + }) + + await expect( + createIssue('/repo-root', 'New issue', 'Body', undefined, undefined, { + labels: ['bug', 'frontend'], + assignees: ['octo'] + }) + ).resolves.toEqual({ + ok: true, + number: 925, + url: 'https://github.com/stablyai/orca/issues/925' + }) + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + [ + 'api', + '-X', + 'POST', + 'repos/stablyai/orca/issues', + '--raw-field', + 'title=New issue', + '--raw-field', + 'body=Body', + '--raw-field', + 'labels[]=bug', + '--raw-field', + 'labels[]=frontend', + '--raw-field', + 'assignees[]=octo' + ], + { cwd: '/repo-root' } + ) + }) + it('updates issue body through the REST issue endpoint', async () => { getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) diff --git a/src/main/github/issues.ts b/src/main/github/issues.ts index 272d61ea14d..6322e60dd2d 100644 --- a/src/main/github/issues.ts +++ b/src/main/github/issues.ts @@ -5,6 +5,7 @@ surface is broad. */ import type { ClassifiedError, GitHubAssignableUser, + GitHubCreateIssueFields, GitHubCommentResult, GitHubIssueUpdate, IssueInfo, @@ -12,6 +13,7 @@ import type { PRComment } from '../../shared/types' import { mapIssueInfo } from './mappers' +import type { OwnerRepo } from './gh-utils' // prettier-ignore import { ghExecFileAsync, acquire, release, getIssueOwnerRepo, resolveIssueSource, classifyGhError, classifyListIssuesError, ghRepoExecOptions, githubRepoContext } from './gh-utils' @@ -150,7 +152,8 @@ export async function createIssue( title: string, body: string, preference?: IssueSourcePreference, - connectionId?: string | null + connectionId?: string | null, + fields?: GitHubCreateIssueFields ): Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }> { const trimmedTitle = title.trim() if (!trimmedTitle) { @@ -164,19 +167,24 @@ export async function createIssue( } await acquire() try { - const { stdout } = await ghExecFileAsync( - [ - 'api', - '-X', - 'POST', - `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues`, - '--raw-field', - `title=${trimmedTitle}`, - '--raw-field', - `body=${body}` - ], - ghOptions - ) + const args = [ + 'api', + '-X', + 'POST', + `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues`, + '--raw-field', + `title=${trimmedTitle}`, + '--raw-field', + `body=${body}` + ] + for (const label of fields?.labels ?? []) { + args.push('--raw-field', `labels[]=${label}`) + } + for (const assignee of fields?.assignees ?? []) { + args.push('--raw-field', `assignees[]=${assignee}`) + } + + const { stdout } = await ghExecFileAsync(args, ghOptions) const data = JSON.parse(stdout) as { number?: number; html_url?: string; url?: string } if (typeof data.number !== 'number') { return { ok: false, error: 'Unexpected response from GitHub' } @@ -323,11 +331,12 @@ export async function addIssueComment( repoPath: string, issueNumber: number, body: string, - connectionId?: string | null + connectionId?: string | null, + ownerRepoOverride?: OwnerRepo | null ): Promise { const context = githubRepoContext(repoPath, connectionId) const ghOptions = ghRepoExecOptions(context) - const ownerRepo = await getIssueOwnerRepo(repoPath, connectionId) + const ownerRepo = ownerRepoOverride ?? (await getIssueOwnerRepo(repoPath, connectionId)) if (!ownerRepo) { return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } } @@ -351,8 +360,11 @@ export async function addIssueComment( created_at?: string html_url?: string } + if (typeof data.id !== 'number' || !Number.isSafeInteger(data.id) || data.id < 1) { + return { ok: false, error: 'Unexpected response from GitHub' } + } const comment: PRComment = { - id: data.id ?? Date.now(), + id: data.id, author: data.user?.login ?? 'You', authorAvatarUrl: data.user?.avatar_url ?? '', body: data.body ?? body, diff --git a/src/main/github/pr-refresh-coordinator.test.ts b/src/main/github/pr-refresh-coordinator.test.ts index 00d5692cb2e..ce924f29e35 100644 --- a/src/main/github/pr-refresh-coordinator.test.ts +++ b/src/main/github/pr-refresh-coordinator.test.ts @@ -51,7 +51,7 @@ function makePR(overrides: Partial = {}): PRInfo { url: 'https://github.com/acme/repo/pull/12', checksStatus: 'pending', updatedAt: '2026-05-12T00:00:00Z', - mergeable: 'UNKNOWN', + mergeable: 'MERGEABLE', headSha: 'head-sha', ...overrides } @@ -98,7 +98,7 @@ describe('pr-refresh-coordinator', () => { const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator') getPRForBranchOutcomeMock.mockResolvedValueOnce({ kind: 'found', - pr: makePR({ checksStatus: 'pending' }), + pr: makePR({ checksStatus: 'pending', mergeable: 'MERGEABLE' }), fetchedAt: Date.now() }) @@ -119,7 +119,7 @@ describe('pr-refresh-coordinator', () => { getPRForBranchOutcomeMock .mockResolvedValueOnce({ kind: 'found', - pr: makePR({ checksStatus: 'pending' }), + pr: makePR({ checksStatus: 'pending', mergeable: 'MERGEABLE' }), fetchedAt: Date.now() }) .mockResolvedValueOnce({ @@ -205,7 +205,7 @@ describe('pr-refresh-coordinator', () => { enqueuePRRefresh({ ...candidate, cachedFetchedAt: Date.now() }, 'active', 80, 1) visibleOutcome.resolve({ kind: 'found', - pr: makePR({ checksStatus: 'pending' }), + pr: makePR({ checksStatus: 'pending', mergeable: 'MERGEABLE' }), fetchedAt: Date.now() }) await vi.advanceTimersByTimeAsync(0) @@ -360,7 +360,7 @@ describe('pr-refresh-coordinator', () => { getPRForBranchOutcomeMock .mockResolvedValueOnce({ kind: 'found', - pr: makePR({ checksStatus: 'pending' }), + pr: makePR({ checksStatus: 'pending', mergeable: 'MERGEABLE' }), fetchedAt: Date.now() }) .mockResolvedValueOnce({ @@ -401,4 +401,84 @@ describe('pr-refresh-coordinator', () => { ]) expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) }) + + it('clears visible follow-ups when the owning window is destroyed', async () => { + const { + _getVisiblePRRefreshWindowCountForTests, + clearVisiblePRRefreshWindow, + reportVisiblePRRefreshCandidates + } = await import('./pr-refresh-coordinator') + getPRForBranchOutcomeMock.mockResolvedValue({ + kind: 'found', + pr: makePR({ checksStatus: 'pending', mergeable: 'MERGEABLE' }), + fetchedAt: Date.now() + }) + + reportVisiblePRRefreshCandidates([makeCandidate()], 1, 1) + await vi.runOnlyPendingTimersAsync() + + expect(_getVisiblePRRefreshWindowCountForTests()).toBe(1) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) + + clearVisiblePRRefreshWindow(1) + await vi.advanceTimersByTimeAsync(90_000) + + expect(_getVisiblePRRefreshWindowCountForTests()).toBe(0) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) + }) + + it('clears visible retry backoff when a non-visible manual refresh steals the retry', async () => { + const { + _getPRRefreshErrorBackoffCountForTests, + refreshPRNow, + reportVisiblePRRefreshCandidates + } = await import('./pr-refresh-coordinator') + getPRForBranchOutcomeMock + .mockResolvedValueOnce({ + kind: 'upstream-error', + errorType: 'network', + message: 'network down', + fetchedAt: Date.now() + }) + .mockResolvedValueOnce({ + kind: 'found', + pr: makePR({ checksStatus: 'success' }), + fetchedAt: Date.now() + }) + + const candidate = makeCandidate() + reportVisiblePRRefreshCandidates([candidate], 1, 1) + await vi.advanceTimersByTimeAsync(0) + expect(_getPRRefreshErrorBackoffCountForTests()).toBe(1) + + getAllWebContentsMock.mockReturnValue([]) + await refreshPRNow(candidate) + + expect(_getPRRefreshErrorBackoffCountForTests()).toBe(0) + }) + + it('retries visible PRs with unknown mergeability before the success-check interval', async () => { + const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator') + getPRForBranchOutcomeMock + .mockResolvedValueOnce({ + kind: 'found', + pr: makePR({ checksStatus: 'success', mergeable: 'UNKNOWN' }), + fetchedAt: Date.now() + }) + .mockResolvedValueOnce({ + kind: 'found', + pr: makePR({ checksStatus: 'success', mergeable: 'MERGEABLE' }), + fetchedAt: Date.now() + }) + + reportVisiblePRRefreshCandidates([makeCandidate()], 1, 1) + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(9_999) + + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(1) + + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) + }) }) diff --git a/src/main/github/pr-refresh-coordinator.ts b/src/main/github/pr-refresh-coordinator.ts index 42f533a742e..c35baa9b240 100644 --- a/src/main/github/pr-refresh-coordinator.ts +++ b/src/main/github/pr-refresh-coordinator.ts @@ -29,6 +29,7 @@ type PRRefreshOutcomeObserver = ( ) => void const MIN_BACKGROUND_REFRESH_AGE_MS = 60_000 +const MERGEABILITY_PENDING_REFRESH_MS = 10_000 const BACKGROUND_BUDGET_WINDOW_MS = 5 * 60_000 const MIN_BACKGROUND_SPACING_MS = 10_000 const BACKGROUND_BUDGET_MAX = 20 @@ -50,6 +51,30 @@ export function setPRRefreshOutcomeObserver(observer: PRRefreshOutcomeObserver | outcomeObserver = observer } +function removeInvisibleVisibleRefreshes(): void { + for (const [key, entry] of queue) { + if (entry.reason === 'visible' && !isVisibleKey(key)) { + queue.delete(key) + errorBackoff.delete(key) + broadcast({ + aliases: Array.from(entry.aliases.values()), + reason: 'visible', + status: 'skipped', + skippedReason: 'fresh' + }) + } + } +} + +export function clearVisiblePRRefreshWindow(windowId: number): void { + if (!visibleByWindow.delete(windowId)) { + return + } + // Why: visible follow-ups are owned by the renderer that reported them. + // If that WebContents is destroyed, no later visibility report may arrive. + removeInvisibleVisibleRefreshes() +} + function nextSequence(): number { sequence += 1 return sequence @@ -183,7 +208,9 @@ function visibleCandidateAfterOutcome( cachedFetchedAt: outcome.fetchedAt, cachedHasPR: outcome.kind === 'found', cachedPRState: outcome.kind === 'found' ? outcome.pr.state : null, - cachedChecksStatus: outcome.kind === 'found' ? outcome.pr.checksStatus : null + cachedChecksStatus: outcome.kind === 'found' ? outcome.pr.checksStatus : null, + cachedMergeable: outcome.kind === 'found' ? outcome.pr.mergeable : null, + cachedMergeStateStatus: outcome.kind === 'found' ? (outcome.pr.mergeStateStatus ?? null) : null } } @@ -249,6 +276,9 @@ function scheduleVisibleFollowUp( windowId?: number ): void { if (!isVisibleKey(key)) { + // Why: manual/active refreshes can remove the queued visible retry after + // its owner window is gone, leaving the retry backoff without an owner. + errorBackoff.delete(key) return } if (outcome.kind === 'upstream-error') { @@ -295,6 +325,16 @@ function refreshIntervalForCandidate(candidate: GitHubPRRefreshCandidate): numbe if (candidate.cachedHasPR === false) { return 15 * 60_000 } + if ( + candidate.cachedHasPR === true && + candidate.cachedPRState === 'open' && + candidate.cachedMergeable === 'UNKNOWN' && + !hasResolvedMergeStateStatus(candidate.cachedMergeStateStatus) + ) { + // Why: GitHub can return transient UNKNOWN mergeability while it computes + // the PR test merge; visible merge buttons need a prompt follow-up. + return MERGEABILITY_PENDING_REFRESH_MS + } if (candidate.cachedChecksStatus === 'success') { return 10 * 60_000 } @@ -307,6 +347,10 @@ function refreshIntervalForCandidate(candidate: GitHubPRRefreshCandidate): numbe return MIN_BACKGROUND_REFRESH_AGE_MS } +function hasResolvedMergeStateStatus(status: string | null | undefined): boolean { + return status === 'CLEAN' || status === 'BEHIND' || status === 'BLOCKED' +} + function backgroundRefreshBuckets(): ('core' | 'graphql')[] { // Why: branch refreshes prefer REST but can still fall back to `gh pr list` // when local head-owner metadata is unavailable. Guard both buckets until the @@ -531,23 +575,20 @@ export function reportVisiblePRRefreshCandidates( return } visibleByWindow.set(windowId, { generation, keys: new Set(candidates.map(refreshKey)) }) - for (const [key, entry] of queue) { - if (entry.reason === 'visible' && !isVisibleKey(key)) { - queue.delete(key) - errorBackoff.delete(key) - broadcast({ - aliases: Array.from(entry.aliases.values()), - reason: 'visible', - status: 'skipped', - skippedReason: 'fresh' - }) - } - } + removeInvisibleVisibleRefreshes() for (const candidate of candidates) { enqueuePRRefresh(candidate, 'visible', 40, windowId) } } +export function _getVisiblePRRefreshWindowCountForTests(): number { + return visibleByWindow.size +} + +export function _getPRRefreshErrorBackoffCountForTests(): number { + return errorBackoff.size +} + export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise { const alias = aliasFromCandidate(candidate) const key = refreshKey(candidate) diff --git a/src/main/github/pr-start-point.test.ts b/src/main/github/pr-start-point.test.ts index d4c8dfe9808..f0f81b152f3 100644 --- a/src/main/github/pr-start-point.test.ts +++ b/src/main/github/pr-start-point.test.ts @@ -141,8 +141,13 @@ describe('resolveGitHubPrStartPoint', () => { }) }) - it('returns a tracking ref and push target when same-repo branch fetch succeeds', async () => { - const gitExec = vi.fn(async () => ({ stdout: '', stderr: '' })) + it('returns the verified head SHA, branch override, and push target when same-repo branch fetch succeeds', async () => { + const gitExec = vi.fn(async (args: string[]) => { + if (args[0] === 'rev-parse') { + return { stdout: 'abc123\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) const result = await resolveGitHubPrStartPoint({ repoPath: '/repo-root', @@ -159,7 +164,9 @@ describe('resolveGitHubPrStartPoint', () => { ]) expect(gitExec).toHaveBeenCalledWith(['rev-parse', '--verify', 'origin/feature/add-feature']) expect(result).toEqual({ - baseBranch: 'origin/feature/add-feature', + baseBranch: 'abc123', + headSha: 'abc123', + branchNameOverride: 'feature/add-feature', pushTarget: { remoteName: 'origin', branchName: 'feature/add-feature' } }) }) diff --git a/src/main/github/pr-start-point.ts b/src/main/github/pr-start-point.ts index 16ba1a3e7f6..c53807bc0f8 100644 --- a/src/main/github/pr-start-point.ts +++ b/src/main/github/pr-start-point.ts @@ -1,4 +1,4 @@ -import type { GitPushTarget } from '../../shared/types' +import type { GitHubPrStartPoint, GitPushTarget } from '../../shared/types' import { isMissingRemoteRefGitError } from '../git/fetch-error-classification' import { getPullRequestPushTarget, getWorkItem } from './client' @@ -14,9 +14,7 @@ type ResolveGitHubPrStartPointArgs = { resolveRemote: () => Promise } -type ResolveGitHubPrStartPointResult = - | { baseBranch: string; pushTarget?: GitPushTarget } - | { error: string } +type ResolveGitHubPrStartPointResult = GitHubPrStartPoint | { error: string } export async function resolveGitHubPrStartPoint( args: ResolveGitHubPrStartPointArgs @@ -122,14 +120,21 @@ export async function resolveGitHubPrStartPoint( } const remoteRef = `${remote}/${headRefName}` + let headSha: string try { - await args.gitExec(['rev-parse', '--verify', remoteRef]) + const { stdout } = await args.gitExec(['rev-parse', '--verify', remoteRef]) + headSha = stdout.trim() } catch { return { error: `Remote ref ${remoteRef} does not exist after fetch.` } } + if (!headSha) { + return { error: `Empty SHA resolving PR #${args.prNumber} head.` } + } return { - baseBranch: remoteRef, + baseBranch: headSha, + headSha, + branchNameOverride: headRefName, pushTarget: { remoteName: remote, branchName: headRefName } } } diff --git a/src/main/github/project-view.test.ts b/src/main/github/project-view.test.ts index 383fcb1d412..ac75c7b6d28 100644 --- a/src/main/github/project-view.test.ts +++ b/src/main/github/project-view.test.ts @@ -5,9 +5,19 @@ // (GitHub allows them, e.g. `_internal`), // (c) owner slug validation must reject `.`/`_` (GitHub disallows them in // usernames/orgs), -// (d) parseProjectPaste shorthand owner-only alphabet matches the renderer. -import { describe, expect, it } from 'vitest' +// (d) parseProjectPaste shorthand owner-only alphabet matches the renderer, +// (e) project owner/capability caches stay bounded in long sessions. +import { beforeEach, describe, expect, it } from 'vitest' import { + PROJECT_VIEW_OWNER_CACHE_MAX_ENTRIES, + _getProjectViewCacheSizesForTests, + _getProjectViewOwnerTypeForTests, + _hasProjectViewParentFieldRetriedForTests, + _hasProjectViewParentFieldWarningLoggedForTests, + _markProjectViewParentFieldRetriedForTests, + _markProjectViewParentFieldWarningLoggedForTests, + _rememberProjectViewOwnerTypeForTests, + _resetProjectViewCachesForTests, classifyProjectError, isValidOwnerSlug, isValidRepoSlug, @@ -142,3 +152,38 @@ describe('parseProjectPaste', () => { expect(parseProjectPaste(' ')).toBeNull() }) }) + +describe('project view owner caches', () => { + beforeEach(() => { + _resetProjectViewCachesForTests() + }) + + it('LRU-evicts old owner type probes', () => { + for (let i = 0; i <= PROJECT_VIEW_OWNER_CACHE_MAX_ENTRIES; i++) { + _rememberProjectViewOwnerTypeForTests(`owner-${i}`, i % 2 === 0 ? 'organization' : 'user') + } + + expect(_getProjectViewCacheSizesForTests().ownerTypes).toBe( + PROJECT_VIEW_OWNER_CACHE_MAX_ENTRIES + ) + expect(_getProjectViewOwnerTypeForTests('owner-0')).toBeUndefined() + expect(_getProjectViewOwnerTypeForTests('owner-1')).toBe('user') + }) + + it('LRU-evicts old parent-field retry and warning probes', () => { + for (let i = 0; i <= PROJECT_VIEW_OWNER_CACHE_MAX_ENTRIES; i++) { + const scopeKey = `owner-${i}\u0000organization` + _markProjectViewParentFieldRetriedForTests(scopeKey) + _markProjectViewParentFieldWarningLoggedForTests(scopeKey) + } + + expect(_getProjectViewCacheSizesForTests()).toMatchObject({ + parentFieldRetries: PROJECT_VIEW_OWNER_CACHE_MAX_ENTRIES, + parentFieldWarnings: PROJECT_VIEW_OWNER_CACHE_MAX_ENTRIES + }) + expect(_hasProjectViewParentFieldRetriedForTests('owner-0\u0000organization')).toBe(false) + expect(_hasProjectViewParentFieldWarningLoggedForTests('owner-0\u0000organization')).toBe(false) + expect(_hasProjectViewParentFieldRetriedForTests('owner-1\u0000organization')).toBe(true) + expect(_hasProjectViewParentFieldWarningLoggedForTests('owner-1\u0000organization')).toBe(true) + }) +}) diff --git a/src/main/github/project-view.ts b/src/main/github/project-view.ts index 6fa6ced6444..67a60fc9989 100644 --- a/src/main/github/project-view.ts +++ b/src/main/github/project-view.ts @@ -90,9 +90,41 @@ const DISCOVERY_MAX_ORGS = 20 const DISCOVERY_ORG_PAGE_SIZE = 20 const DISCOVERY_PROJECTS_PER_ORG = 20 const FIELD_VALUES_PAGE_SIZE = 100 +export const PROJECT_VIEW_OWNER_CACHE_MAX_ENTRIES = 512 // ─── Module-scope caches (reset on HMR — intentional) ────────────────── +// Why: pasted and discovered GitHub owners are user-controlled across a long +// main-process session; keep capability probes bounded to avoid unbounded +// retention while preserving the hot-owner fast path. +function rememberProjectViewCacheEntry( + cache: Map, + key: K, + value: V, + maxEntries = PROJECT_VIEW_OWNER_CACHE_MAX_ENTRIES +): void { + if (cache.has(key)) { + cache.delete(key) + } + cache.set(key, value) + while (cache.size > maxEntries) { + const oldest = cache.keys().next() + if (oldest.done) { + break + } + cache.delete(oldest.value) + } +} + +function getProjectViewCacheEntry(cache: Map, key: K): V | undefined { + if (!cache.has(key)) { + return undefined + } + const value = cache.get(key) as V + rememberProjectViewCacheEntry(cache, key, value) + return value +} + // Why: HMR reloading should re-probe capability. Both caches live as plain // module locals so a dev-time code swap naturally re-runs capability probes // instead of carrying a stale "unsupported" flag into fresh code. @@ -101,8 +133,8 @@ const ownerTypeCache = new Map() // a single owner's capability gap (e.g. token without scope on org A) doesn't // poison every subsequent fetch for unrelated owners that DO support // Issue.parent. See bug-scan finding 2. -const parentFieldRetriedByOwner = new Set() -const parentFieldWarningLoggedByOwner = new Set() +const parentFieldRetriedByOwner = new Map() +const parentFieldWarningLoggedByOwner = new Map() // Why: concurrent fetchAllItems calls for the same owner all observe the // same "not-yet-retried" state, each issuing a duplicate first-page probe // and racing to set the flag. Use an in-flight promise per owner so only @@ -113,14 +145,86 @@ function ownerScopeKey(owner: string, ownerType: GitHubProjectOwnerType): string return `${owner}\u0000${ownerType}` } -/** @internal — test-only */ -export function _resetProjectViewModuleState(): void { +function rememberOwnerType(owner: string, ownerType: GitHubProjectOwnerType | null): void { + rememberProjectViewCacheEntry(ownerTypeCache, owner, ownerType) +} + +function getCachedOwnerType(owner: string): GitHubProjectOwnerType | null | undefined { + return getProjectViewCacheEntry(ownerTypeCache, owner) +} + +function markParentFieldRetried(scopeKey: string): void { + rememberProjectViewCacheEntry(parentFieldRetriedByOwner, scopeKey, true) +} + +function hasParentFieldRetried(scopeKey: string): boolean { + return getProjectViewCacheEntry(parentFieldRetriedByOwner, scopeKey) === true +} + +function markParentFieldWarningLogged(scopeKey: string): void { + rememberProjectViewCacheEntry(parentFieldWarningLoggedByOwner, scopeKey, true) +} + +function hasParentFieldWarningLogged(scopeKey: string): boolean { + return getProjectViewCacheEntry(parentFieldWarningLoggedByOwner, scopeKey) === true +} + +export function _resetProjectViewCachesForTests(): void { ownerTypeCache.clear() parentFieldRetriedByOwner.clear() parentFieldWarningLoggedByOwner.clear() parentFieldProbeInFlight.clear() } +export function _getProjectViewCacheSizesForTests(): { + ownerTypes: number + parentFieldRetries: number + parentFieldWarnings: number + parentFieldProbes: number +} { + return { + ownerTypes: ownerTypeCache.size, + parentFieldRetries: parentFieldRetriedByOwner.size, + parentFieldWarnings: parentFieldWarningLoggedByOwner.size, + parentFieldProbes: parentFieldProbeInFlight.size + } +} + +/** @internal - exposed for cache-bound tests only. */ +export function _rememberProjectViewOwnerTypeForTests( + owner: string, + ownerType: GitHubProjectOwnerType | null +): void { + rememberOwnerType(owner, ownerType) +} + +/** @internal - exposed for cache-bound tests only. */ +export function _getProjectViewOwnerTypeForTests( + owner: string +): GitHubProjectOwnerType | null | undefined { + return getCachedOwnerType(owner) +} + +/** @internal - exposed for cache-bound tests only. */ +export function _markProjectViewParentFieldRetriedForTests(scopeKey: string): void { + markParentFieldRetried(scopeKey) +} + +/** @internal - exposed for cache-bound tests only. */ +export function _hasProjectViewParentFieldRetriedForTests(scopeKey: string): boolean { + return hasParentFieldRetried(scopeKey) +} + +/** @internal - exposed for cache-bound tests only. */ +export function _markProjectViewParentFieldWarningLoggedForTests(scopeKey: string): void { + markParentFieldWarningLogged(scopeKey) +} + +/** @internal - exposed for cache-bound tests only. */ +export function _hasProjectViewParentFieldWarningLoggedForTests(scopeKey: string): boolean { + return hasParentFieldWarningLogged(scopeKey) +} + // ─── Normalizers ─────────────────────────────────────────────────────── type RawProjectV2Field = { @@ -282,6 +386,7 @@ export function normalizeFieldValue( .filter((u): u is GitHubProjectUser => u !== null) return { kind: 'users', fieldId, users } } + case undefined: default: // Unknown __typename → forward-compat: drop silently, do not throw, // do not classify as drift (see design §Error Handling). @@ -890,7 +995,7 @@ async function fetchAllItems(args: { if (inFlight) { await inFlight.catch(() => {}) } - let includeParent = !parentFieldRetriedByOwner.has(scopeKey) + let includeParent = !hasParentFieldRetried(scopeKey) let parentFieldDropped = !includeParent // First page — single-flight the with-parent attempt PER OWNER so // concurrent callers for the same owner observe one probe result instead @@ -924,7 +1029,7 @@ async function fetchAllItems(args: { // parentFieldProbeInFlight=null and parentFieldRetriedByOwner without // the scopeKey, then issue duplicate with-parent probes. if (!result.ok && errorsIndicateParentField(result.rawErrors, result.stderr)) { - parentFieldRetriedByOwner.add(scopeKey) + markParentFieldRetried(scopeKey) } return result } finally { @@ -948,14 +1053,14 @@ async function fetchAllItems(args: { // Retry the whole table without parent. Mark this owner as retried so // subsequent fetches against the same owner skip the probe — but other // owners are unaffected. - parentFieldRetriedByOwner.add(scopeKey) + markParentFieldRetried(scopeKey) includeParent = false parentFieldDropped = true - if (!parentFieldWarningLoggedByOwner.has(scopeKey)) { + if (!hasParentFieldWarningLogged(scopeKey)) { console.warn( `[project-view] Issue.parent is not available for ${args.owner} on this token — retrying without the parent selection.` ) - parentFieldWarningLoggedByOwner.add(scopeKey) + markParentFieldWarningLogged(scopeKey) } first = await fetchItemsPageWithRaw({ owner: args.owner, @@ -1410,7 +1515,7 @@ export async function listAccessibleProjects(): Promise { } }) -import { getMergeRequest, getMergeRequestForBranch, listMergeRequests, updateMR } from './client' +import { + _getGitLabRateLimitCacheSize, + _resetGitLabRateLimitCache, + getMergeRequest, + getMergeRequestForBranch, + getJobTrace, + addMRInlineComment, + diagnoseAuth, + getRateLimit, + listMergeRequests, + resolveMRDiscussion, + retryJob, + updateMR, + updateMRReviewers +} from './client' describe('gitlab client — MR operations', () => { beforeEach(() => { @@ -47,6 +61,7 @@ describe('gitlab client — MR operations', () => { acquireMock.mockReset() releaseMock.mockReset() acquireMock.mockResolvedValue(undefined) + _resetGitLabRateLimitCache() getGlabKnownHostsMock.mockResolvedValue(['gitlab.com']) resolveIssueSourceMock.mockResolvedValue({ source: { host: 'gitlab.com', path: 'g/p' }, @@ -117,6 +132,73 @@ describe('gitlab client — MR operations', () => { }) }) + describe('diagnoseAuth', () => { + it('reports glab hosts from auth status', async () => { + glabExecFileAsyncMock.mockResolvedValueOnce({ + stdout: '✓ Logged in to gitlab.com as alice\n', + stderr: '' + }) + + await expect(diagnoseAuth()).resolves.toMatchObject({ + glabAvailable: true, + authenticated: true, + hosts: ['gitlab.com'], + activeHost: 'gitlab.com' + }) + }) + }) + + describe('getRateLimit', () => { + it('parses GitLab REST budget headers', async () => { + glabApiWithHeadersMock.mockResolvedValueOnce({ + body: '{}', + headers: { + 'ratelimit-limit': '2000', + 'ratelimit-remaining': '1997', + 'ratelimit-reset': '1780000000' + } + }) + + await expect( + getRateLimit({ host: 'gitlab.example.com', force: true }) + ).resolves.toMatchObject({ + ok: true, + snapshot: { + host: 'gitlab.example.com', + rest: { + limit: 2000, + remaining: 1997, + resetAt: 1780000000 + } + } + }) + expect(glabApiWithHeadersMock).toHaveBeenCalledWith([ + '--hostname', + 'gitlab.example.com', + 'user' + ]) + }) + + it('reports a null bucket when the host omits rate-limit headers', async () => { + glabApiWithHeadersMock.mockResolvedValueOnce({ body: '{}', headers: {} }) + + await expect(getRateLimit({ force: true })).resolves.toMatchObject({ + ok: true, + snapshot: { host: null, rest: null } + }) + }) + + it('bounds cached rate-limit snapshots across many hosts', async () => { + glabApiWithHeadersMock.mockResolvedValue({ body: '{}', headers: {} }) + + for (let i = 0; i < 70; i++) { + await getRateLimit({ host: `gitlab-${i}.example.com`, force: true }) + } + + expect(_getGitLabRateLimitCacheSize()).toBe(64) + }) + }) + describe('getMergeRequestForBranch', () => { it('finds the most recently updated MR for a branch across states', async () => { getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) @@ -145,6 +227,25 @@ describe('gitlab client — MR operations', () => { ) }) + it('uses legacy pipeline payloads when branch MR lists omit head_pipeline', async () => { + getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) + glabExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify([ + { + iid: 8, + title: 'Legacy pipeline branch', + state: 'opened', + sha: 'def', + pipeline: { status: 'failed' } + } + ]) + }) + + const mr = await getMergeRequestForBranch('/repo', 'feature/legacy-pipeline') + expect(mr?.number).toBe(8) + expect(mr?.pipelineStatus).toBe('failure') + }) + it('strips refs/heads/ prefix from the branch arg', async () => { getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) @@ -411,4 +512,230 @@ describe('gitlab client — MR operations', () => { ) }) }) + + describe('resolveMRDiscussion', () => { + beforeEach(() => { + resolveIssueSourceMock.mockImplementation(async () => ({ + source: { host: 'git.internal', path: 'g/p' }, + fellBack: false + })) + }) + + it('updates the discussion resolved state through the selected SSH GitLab host', async () => { + glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '{}' }) + + await expect( + resolveMRDiscussion('/repo', 12, 'discussion-1', true, 'upstream', 'conn-1') + ).resolves.toEqual({ ok: true }) + + expect(glabExecFileAsyncMock).toHaveBeenCalledWith( + [ + 'api', + '--hostname', + 'git.internal', + '-X', + 'PUT', + 'projects/g%2Fp/merge_requests/12/discussions/discussion-1', + '-f', + 'resolved=true' + ], + {} + ) + }) + + it('rejects an empty discussion id without calling glab', async () => { + await expect(resolveMRDiscussion('/repo', 12, ' ', true)).resolves.toEqual({ + ok: false, + error: 'Discussion id is required' + }) + + expect(glabExecFileAsyncMock).not.toHaveBeenCalled() + }) + }) + + describe('addMRInlineComment', () => { + beforeEach(() => { + resolveIssueSourceMock.mockImplementation(async () => ({ + source: { host: 'git.internal', path: 'g/p' }, + fellBack: false + })) + }) + + it('posts an inline discussion with GitLab position fields', async () => { + glabExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + id: 'discussion-1', + notes: [ + { + id: 500, + author: { username: 'alice', avatar_url: 'https://example.com/a.png' }, + body: 'please fix', + created_at: '2026-05-05T10:00:00Z', + position: { new_path: 'src/app.ts', new_line: 12 } + } + ] + }) + }) + + await expect( + addMRInlineComment( + '/repo', + 12, + { + body: 'please fix', + path: 'src/app.ts', + line: 12, + baseSha: 'base', + startSha: 'start', + headSha: 'head' + }, + 'upstream', + 'conn-1' + ) + ).resolves.toMatchObject({ + ok: true, + comment: { + id: 500, + threadId: 'discussion-1', + path: 'src/app.ts', + line: 12 + } + }) + + expect(glabExecFileAsyncMock).toHaveBeenCalledWith( + [ + 'api', + '--hostname', + 'git.internal', + '-X', + 'POST', + 'projects/g%2Fp/merge_requests/12/discussions', + '-f', + 'body=please fix', + '-f', + 'position[position_type]=text', + '-f', + 'position[base_sha]=base', + '-f', + 'position[start_sha]=start', + '-f', + 'position[head_sha]=head', + '-f', + 'position[old_path]=src/app.ts', + '-f', + 'position[new_path]=src/app.ts', + '-f', + 'position[new_line]=12' + ], + {} + ) + }) + }) + + describe('job CI operations', () => { + beforeEach(() => { + resolveIssueSourceMock.mockImplementation(async () => ({ + source: { host: 'git.internal', path: 'g/p' }, + fellBack: false + })) + }) + + it('fetches a job trace through the selected SSH GitLab host', async () => { + glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'trace output' }) + + await expect(getJobTrace('/repo', 99, 'upstream', 'conn-1')).resolves.toEqual({ + ok: true, + trace: 'trace output' + }) + + expect(glabExecFileAsyncMock).toHaveBeenCalledWith( + ['api', '--hostname', 'git.internal', 'projects/g%2Fp/jobs/99/trace'], + {} + ) + }) + + it('retries a job through the selected SSH GitLab host', async () => { + glabExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + id: 100, + pipeline: { id: 50 }, + name: 'test', + stage: 'test', + status: 'pending', + web_url: 'https://git.internal/g/p/-/jobs/100', + duration: null + }) + }) + + await expect(retryJob('/repo', 99, 'upstream', 'conn-1')).resolves.toEqual({ + ok: true, + job: { + id: 100, + pipelineId: 50, + name: 'test', + stage: 'test', + status: 'pending', + webUrl: 'https://git.internal/g/p/-/jobs/100', + duration: null + } + }) + + expect(glabExecFileAsyncMock).toHaveBeenCalledWith( + ['api', '--hostname', 'git.internal', '-X', 'POST', 'projects/g%2Fp/jobs/99/retry'], + {} + ) + }) + }) + + describe('updateMRReviewers', () => { + beforeEach(() => { + resolveIssueSourceMock.mockImplementation(async () => ({ + source: { host: 'git.internal', path: 'g/p' }, + fellBack: false + })) + }) + + it('sets reviewers through reviewer_ids on the selected SSH GitLab host', async () => { + glabExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + reviewers: [ + { + id: 1, + username: 'alice', + name: 'Alice', + avatar_url: 'https://example.com/a.png', + state: 'active' + } + ] + }) + }) + + await expect(updateMRReviewers('/repo', 12, [1], 'upstream', 'conn-1')).resolves.toEqual({ + ok: true, + reviewers: [ + { + id: 1, + username: 'alice', + name: 'Alice', + avatarUrl: 'https://example.com/a.png', + state: 'active' + } + ] + }) + + expect(glabExecFileAsyncMock).toHaveBeenCalledWith( + [ + 'api', + '--hostname', + 'git.internal', + '-X', + 'PUT', + 'projects/g%2Fp/merge_requests/12', + '-f', + 'reviewer_ids[]=1' + ], + {} + ) + }) + }) }) diff --git a/src/main/gitlab/client-work-items.test.ts b/src/main/gitlab/client-work-items.test.ts index 8d87fa74f7d..5de8522d4e7 100644 --- a/src/main/gitlab/client-work-items.test.ts +++ b/src/main/gitlab/client-work-items.test.ts @@ -119,6 +119,20 @@ describe('gitlab client — combined listWorkItems', () => { expect(issuesCallPath.at(-1)).toContain('search=ambiguous%20selector') }) + it('passes the requested page through to merge request and issue fetches', async () => { + glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} }) + glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) + + await listWorkItems('/repo', 'opened', 2, 20) + + const mergeRequestCallPath = glabApiWithHeadersMock.mock.calls[0][0] as string[] + const issuesCallPath = glabExecFileAsyncMock.mock.calls[0][0] as string[] + const mergeRequestParams = new URLSearchParams(mergeRequestCallPath[0].split('?')[1]) + const issueParams = new URLSearchParams(issuesCallPath.at(-1)?.split('?')[1]) + expect(mergeRequestParams.get('page')).toBe('2') + expect(issueParams.get('page')).toBe('2') + }) + it("omits the state param when 'all'", async () => { glabExecFileAsyncMock.mockImplementation(async () => { return { stdout: '[]' } @@ -143,7 +157,7 @@ describe('gitlab client — combined listWorkItems', () => { 'api', '--hostname', 'git.internal', - 'projects/g%2Fp/issues?per_page=20&order_by=updated_at&sort=desc&state=opened' + 'projects/g%2Fp/issues?page=1&per_page=20&order_by=updated_at&sort=desc&state=opened' ]) }) diff --git a/src/main/gitlab/client.test.ts b/src/main/gitlab/client.test.ts index 7cf02eb5218..ce04b746a00 100644 --- a/src/main/gitlab/client.test.ts +++ b/src/main/gitlab/client.test.ts @@ -165,7 +165,7 @@ describe('gitlab client — viewer & paste-URL lookup', () => { } ]) expect(glabExecFileAsyncMock).toHaveBeenCalledWith( - ['api', '--paginate', 'todos?state=pending&per_page=50'], + ['api', 'todos?state=pending&per_page=50'], { cwd: '/repo' } ) }) diff --git a/src/main/gitlab/client.ts b/src/main/gitlab/client.ts index 00298754ca4..01fbed1dba5 100644 --- a/src/main/gitlab/client.ts +++ b/src/main/gitlab/client.ts @@ -3,10 +3,20 @@ co-locating GitLab MR/issue/work-item operations keeps the concurrency acquire/release pattern obvious across operations. */ import type { ClassifiedError, + GitLabAssignableUser, + GitLabAuthDiagnostic, + GitLabDiscussionResolveResult, + GitLabJobTraceResult, GitLabPagedResult, + GitLabPipelineJob, + GitLabRateLimitSnapshot, + GitLabMRInlineCommentInput, + GitLabMRReviewersUpdateResult, + GitLabRetryJobResult, GitLabTodo, GitLabViewer, GitLabWorkItem, + GetGitLabRateLimitResult, IssueSourcePreference, ListMergeRequestsResult, MRComment, @@ -25,6 +35,7 @@ import { glabRepoExecOptions, glabApiWithHeaders, glabExecFileAsync, + parseGlabAuthStatusHosts, release, resolveIssueSource, type ProjectRef @@ -37,6 +48,10 @@ function encodedProject(projectPath: string): string { return encodeURIComponent(projectPath) } +const GITLAB_RATE_LIMIT_CACHE_TTL_MS = 30_000 +const GITLAB_RATE_LIMIT_CACHE_MAX_ENTRIES = 64 +const gitLabRateLimitCache = new Map() + /** * Get the authenticated GitLab viewer. Mirrors getAuthenticatedViewer * from the GitHub client — returns null when glab is unavailable, the @@ -61,6 +76,152 @@ export async function getAuthenticatedViewer(): Promise { } } +export async function diagnoseAuth(): Promise { + const envTokenInProcess = process.env.GITLAB_TOKEN + ? 'GITLAB_TOKEN' + : process.env.GLAB_TOKEN + ? 'GLAB_TOKEN' + : null + try { + const { stdout, stderr } = await glabExecFileAsync(['auth', 'status']) + const output = `${stdout}\n${stderr}` + const hosts = parseGlabAuthStatusHosts(output) + return { + glabAvailable: true, + authenticated: + /logged in|authenticated|token/i.test(output) && !/not logged in/i.test(output), + hosts, + activeHost: hosts[0] ?? null, + envTokenInProcess, + error: null + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { + glabAvailable: !/ENOENT|not found|spawn/i.test(message), + authenticated: false, + hosts: [], + activeHost: null, + envTokenInProcess, + error: message + } + } +} + +function parseRateLimitHeader( + headers: Record, + keys: readonly string[] +): number | null { + for (const key of keys) { + const parsed = Number.parseInt(headers[key], 10) + if (Number.isFinite(parsed)) { + return parsed + } + } + return null +} + +function parseRateLimitResetAt(headers: Record): number | null { + const numeric = parseRateLimitHeader(headers, ['ratelimit-reset', 'x-ratelimit-reset']) + if (numeric !== null) { + return numeric + } + const resetTime = headers['ratelimit-resettime'] ?? headers['x-ratelimit-resettime'] + if (!resetTime) { + return null + } + const millis = Date.parse(resetTime) + return Number.isFinite(millis) ? Math.floor(millis / 1000) : null +} + +function parseGitLabRateLimitSnapshot( + headers: Record, + host: string | null +): GitLabRateLimitSnapshot { + const limit = parseRateLimitHeader(headers, ['ratelimit-limit', 'x-ratelimit-limit']) + const remaining = parseRateLimitHeader(headers, ['ratelimit-remaining', 'x-ratelimit-remaining']) + const resetAt = parseRateLimitResetAt(headers) + return { + host, + fetchedAt: Date.now(), + rest: + limit === null && remaining === null && resetAt === null + ? null + : { + limit: limit ?? 0, + remaining: remaining ?? 0, + resetAt + } + } +} + +/** @internal — test-only */ +export function _resetGitLabRateLimitCache(): void { + gitLabRateLimitCache.clear() +} + +/** @internal — test-only */ +export function _getGitLabRateLimitCacheSize(): number { + return gitLabRateLimitCache.size +} + +function pruneGitLabRateLimitCache(now = Date.now()): void { + for (const [cacheKey, snapshot] of gitLabRateLimitCache) { + if (now - snapshot.fetchedAt >= GITLAB_RATE_LIMIT_CACHE_TTL_MS) { + gitLabRateLimitCache.delete(cacheKey) + } + } + while (gitLabRateLimitCache.size > GITLAB_RATE_LIMIT_CACHE_MAX_ENTRIES) { + const oldestKey = gitLabRateLimitCache.keys().next().value + if (oldestKey === undefined) { + break + } + gitLabRateLimitCache.delete(oldestKey) + } +} + +function rememberGitLabRateLimitSnapshot( + cacheKey: string, + snapshot: GitLabRateLimitSnapshot +): void { + pruneGitLabRateLimitCache() + // Why: self-managed GitLab hostnames come from repo config; keep this + // process cache bounded even across many transient hosts. + gitLabRateLimitCache.delete(cacheKey) + gitLabRateLimitCache.set(cacheKey, snapshot) + pruneGitLabRateLimitCache() +} + +export async function getRateLimit(options?: { + force?: boolean + host?: string | null +}): Promise { + const host = options?.host?.trim() || null + const cacheKey = host ?? 'default' + pruneGitLabRateLimitCache() + const cached = gitLabRateLimitCache.get(cacheKey) + if (!options?.force && cached && Date.now() - cached.fetchedAt < GITLAB_RATE_LIMIT_CACHE_TTL_MS) { + return { ok: true, snapshot: cached } + } + + await acquire() + try { + // Why: GitLab.com and self-managed GitLab instances expose REST budget + // headers inconsistently. Query a cheap authenticated endpoint and report + // the headers when present; a null bucket means this host omitted them. + const args = host ? ['--hostname', host, 'user'] : ['user'] + const { headers } = await glabApiWithHeaders(args) + const snapshot = parseGitLabRateLimitSnapshot(headers, host) + rememberGitLabRateLimitSnapshot(cacheKey, snapshot) + return { ok: true, snapshot } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { ok: false, error: message } + } finally { + release() + } +} + /** * Resolve a project's full GitLab project ref (host + path). Mirrors * github/getRepoSlug. Returns null for non-GitLab remotes. @@ -146,10 +307,13 @@ export async function getMergeRequestForBranch( ) const data = JSON.parse(stdout) as (Parameters[0] & { head_pipeline?: { status?: string } | null + pipeline?: { status?: string } | null })[] if (Array.isArray(data) && data.length > 0) { const raw = data[0] - const pipelineStatus = derivePipelineStatus(raw.head_pipeline ?? null) + // Why: older GitLab list payloads expose `pipeline` instead of + // `head_pipeline`, matching the detail endpoint compatibility path. + const pipelineStatus = derivePipelineStatus(raw.head_pipeline ?? raw.pipeline ?? null) return mapMRInfo(raw, pipelineStatus) } } @@ -368,14 +532,6 @@ export async function getWorkItemByProjectRef( } } -// Why: combined MR + issue list for the Tasks-screen and picker -// surfaces. Centralizes the merge logic that TaskPage previously did -// inline so the IPC layer has a single function to call. Pagination is -// approximate — the v1 contract is "page 1 of perPage MRs + perPage -// issues, mixed by updatedAt desc" which is good enough for a typical -// project's <100 active items. -export type ListWorkItemsState = MRListState - function mrStateToIssueState(state: MRListState): IssueListState | null { // Why: GitLab issues don't have a 'merged' state. When the user is // filtering MRs to merged, return null so listWorkItems can skip the @@ -439,7 +595,7 @@ export async function listWorkItems( items: [] as GitLabWorkItem[], error: undefined as ClassifiedError | undefined }) - : fetchIssuesAsWorkItems(repoPath, projectRef, issueState, perPage, query, connectionId) + : fetchIssuesAsWorkItems(repoPath, projectRef, issueState, page, perPage, query, connectionId) ]) const merged = [...mrs.items, ...issues.items].sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '') @@ -468,6 +624,7 @@ export async function fetchIssuesAsWorkItems( repoPath: string, projectRef: ProjectRef, state: IssueListState, + page: number, perPage: number, query?: string, connectionId?: string | null @@ -480,7 +637,7 @@ export async function fetchIssuesAsWorkItems( [ 'api', ...glabHostnameArgs(projectRef, connectionId), - `projects/${encodedProject(projectRef.path)}/issues?per_page=${perPage}&order_by=updated_at&sort=desc${stateParam}${searchParam}` + `projects/${encodedProject(projectRef.path)}/issues?page=${page}&per_page=${perPage}&order_by=updated_at&sort=desc${stateParam}${searchParam}` ], glabRepoExecOptions(repoPath, connectionId) ) @@ -520,14 +677,13 @@ export async function listTodos( } await acquire() try { - // Why: per_page=50 keeps the first-page round-trip small. Pagination - // is left for a follow-up — most users have <50 pending todos in - // practice and the UI shows the highest-priority ones first. + // Why: per_page=50 keeps this user-scoped cross-project view cheap. The UI + // shows the highest-priority todos first, so avoid walking every pending + // todo page from large GitLab accounts. const { stdout } = await glabExecFileAsync( [ 'api', ...(projectRef ? glabHostnameArgs(projectRef, connectionId) : []), - '--paginate', 'todos?state=pending&per_page=50' ], glabRepoExecOptions(repoPath, connectionId) @@ -547,9 +703,6 @@ export async function listTodos( updated_at?: string state?: string } - // Why: --paginate concatenates JSON arrays (one per page) into a - // single stream. glab's behavior is to emit them as one JSON array - // when the endpoint returns arrays — we trust that contract here. const data = JSON.parse(stdout) as RESTTodo[] return data.map((t) => ({ id: t.id ?? 0, @@ -782,6 +935,305 @@ export async function addMRComment( ) } +export async function addMRInlineComment( + repoPath: string, + iid: number, + input: GitLabMRInlineCommentInput, + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRef?: ProjectRef | null +): Promise<{ ok: true; comment: MRComment } | { ok: false; error: string }> { + return withProjectRef<{ ok: true; comment: MRComment } | { ok: false; error: string }>( + repoPath, + preference, + connectionId, + projectRef, + async (projectRef) => { + const body = input.body.trim() + if (!body) { + return { ok: false, error: 'Comment body is required' } + } + await acquire() + try { + const oldPath = input.oldPath ?? input.path + const { stdout } = await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + '-X', + 'POST', + `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/discussions`, + '-f', + `body=${body}`, + '-f', + 'position[position_type]=text', + '-f', + `position[base_sha]=${input.baseSha}`, + '-f', + `position[start_sha]=${input.startSha}`, + '-f', + `position[head_sha]=${input.headSha}`, + '-f', + `position[old_path]=${oldPath}`, + '-f', + `position[new_path]=${input.path}`, + '-f', + `position[new_line]=${input.line}` + ], + glabRepoExecOptions(repoPath, connectionId) + ) + const data = JSON.parse(stdout) as { + id?: string + notes?: { + id?: number + author?: { username?: string; avatar_url?: string; state?: string } | null + body?: string + created_at?: string + position?: { new_path?: string; new_line?: number } | null + }[] + } + const note = data.notes?.[0] + return { + ok: true, + comment: { + id: note?.id ?? Date.now(), + author: note?.author?.username ?? 'You', + authorAvatarUrl: note?.author?.avatar_url ?? '', + body: note?.body ?? body, + createdAt: note?.created_at ?? new Date().toISOString(), + url: '', + threadId: data.id, + isResolved: false, + isBot: note?.author?.state === 'bot', + path: note?.position?.new_path ?? input.path, + line: note?.position?.new_line ?? input.line + } + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return { ok: false, error: classifyGlabError(msg).message } + } finally { + release() + } + }, + { ok: false, error: 'Could not resolve GitLab project for this repository' } + ) +} + +export async function resolveMRDiscussion( + repoPath: string, + iid: number, + discussionId: string, + resolved: boolean, + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRef?: ProjectRef | null +): Promise { + return withProjectRef( + repoPath, + preference, + connectionId, + projectRef, + async (projectRef) => { + const trimmedDiscussionId = discussionId.trim() + if (!trimmedDiscussionId) { + return { ok: false, error: 'Discussion id is required' } + } + await acquire() + try { + // Why: GitLab resolves/reopens the whole discussion thread, not a single + // note; this mirrors GitHub's thread-level resolve mutation. + await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + '-X', + 'PUT', + `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/discussions/${encodeURIComponent(trimmedDiscussionId)}`, + '-f', + `resolved=${resolved ? 'true' : 'false'}` + ], + glabRepoExecOptions(repoPath, connectionId) + ) + return { ok: true } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return { ok: false, error: classifyGlabError(msg).message } + } finally { + release() + } + }, + { ok: false, error: 'Could not resolve GitLab project for this repository' } + ) +} + +function mapRetriedPipelineJob( + data: { + id?: number + pipeline?: { id?: number | null } | null + name?: string + stage?: string + status?: string + web_url?: string + duration?: number | null + }, + fallbackJobId: number +): GitLabPipelineJob { + return { + id: data.id ?? fallbackJobId, + ...(typeof data.pipeline?.id === 'number' ? { pipelineId: data.pipeline.id } : {}), + name: data.name ?? '', + stage: data.stage ?? '', + status: data.status ?? '', + webUrl: data.web_url ?? '', + duration: typeof data.duration === 'number' ? data.duration : null + } +} + +function mapGitLabReviewer(raw: { + id?: number + username?: string | null + name?: string | null + avatar_url?: string | null + state?: string | null +}): GitLabAssignableUser | null { + if (!raw.username) { + return null + } + return { + ...(typeof raw.id === 'number' ? { id: raw.id } : {}), + username: raw.username, + name: raw.name ?? null, + avatarUrl: raw.avatar_url ?? '', + ...(raw.state !== undefined ? { state: raw.state } : {}) + } +} + +export async function updateMRReviewers( + repoPath: string, + iid: number, + reviewerIds: number[], + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRef?: ProjectRef | null +): Promise { + return withProjectRef( + repoPath, + preference, + connectionId, + projectRef, + async (projectRef) => { + await acquire() + try { + const fields = + reviewerIds.length > 0 + ? reviewerIds.map((id) => ['-f', `reviewer_ids[]=${id}`]).flat() + : ['-f', 'reviewer_ids='] + const { stdout } = await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + '-X', + 'PUT', + `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}`, + ...fields + ], + glabRepoExecOptions(repoPath, connectionId) + ) + const data = JSON.parse(stdout) as { reviewers?: Parameters[0][] } + return { + ok: true, + reviewers: (data.reviewers ?? []) + .map(mapGitLabReviewer) + .filter((u): u is GitLabAssignableUser => !!u) + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return { ok: false, error: classifyGlabError(msg).message } + } finally { + release() + } + }, + { ok: false, error: 'Could not resolve GitLab project for this repository' } + ) +} + +export async function getJobTrace( + repoPath: string, + jobId: number, + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRef?: ProjectRef | null +): Promise { + return withProjectRef( + repoPath, + preference, + connectionId, + projectRef, + async (projectRef) => { + await acquire() + try { + const { stdout } = await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + `projects/${encodedProject(projectRef.path)}/jobs/${jobId}/trace` + ], + glabRepoExecOptions(repoPath, connectionId) + ) + return { ok: true, trace: stdout } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return { ok: false, error: classifyGlabError(msg).message } + } finally { + release() + } + }, + { ok: false, error: 'Could not resolve GitLab project for this repository' } + ) +} + +export async function retryJob( + repoPath: string, + jobId: number, + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRef?: ProjectRef | null +): Promise { + return withProjectRef( + repoPath, + preference, + connectionId, + projectRef, + async (projectRef) => { + await acquire() + try { + const { stdout } = await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + '-X', + 'POST', + `projects/${encodedProject(projectRef.path)}/jobs/${jobId}/retry` + ], + glabRepoExecOptions(repoPath, connectionId) + ) + const trimmed = stdout.trim() + return { + ok: true, + ...(trimmed ? { job: mapRetriedPipelineJob(JSON.parse(trimmed), jobId) } : {}) + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return { ok: false, error: classifyGlabError(msg).message } + } finally { + release() + } + }, + { ok: false, error: 'Could not resolve GitLab project for this repository' } + ) +} + export async function updateMR( repoPath: string, iid: number, diff --git a/src/main/gitlab/gl-utils.test.ts b/src/main/gitlab/gl-utils.test.ts index faf3f93068e..b2c253c19f6 100644 --- a/src/main/gitlab/gl-utils.test.ts +++ b/src/main/gitlab/gl-utils.test.ts @@ -13,6 +13,7 @@ vi.mock('../git/runner', () => ({ })) import { + _getProjectRefCacheSize, _resetKnownHostsCache, _resetProjectRefCache, classifyGlabError, @@ -92,6 +93,17 @@ describe('gitlab project ref parsing', () => { }) }) + it('strips trailing slashes after .git suffixes', () => { + expect(parseGitLabProjectRef('https://gitlab.com/acme/widgets.git/')).toEqual({ + host: 'gitlab.com', + path: 'acme/widgets' + }) + expect(parseGitLabProjectRef('ssh://git@gitlab.com/acme/widgets.git/')).toEqual({ + host: 'gitlab.com', + path: 'acme/widgets' + }) + }) + it('preserves git protocol remote support', () => { expect(parseGitLabProjectRef('git://gitlab.com/acme/widgets.git')).toEqual({ host: 'gitlab.com', @@ -179,6 +191,19 @@ describe('gitlab project ref resolution', () => { expect(gitExecFileAsyncMock).not.toHaveBeenCalled() }) + it('bounds cached project refs for distinct repo paths', async () => { + gitExecFileAsyncMock.mockResolvedValue({ + stdout: 'git@gitlab.com:stablyai/orca.git\n', + stderr: '' + }) + + for (let i = 0; i < 513; i += 1) { + await getProjectRef(`/repo-${i}`) + } + + expect(_getProjectRefCacheSize()).toBe(512) + }) + it('does not cache a missing SSH provider as a permanent null project ref', async () => { await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toBeNull() diff --git a/src/main/gitlab/gl-utils.ts b/src/main/gitlab/gl-utils.ts index 86c355e0015..ad109985bcd 100644 --- a/src/main/gitlab/gl-utils.ts +++ b/src/main/gitlab/gl-utils.ts @@ -103,6 +103,7 @@ export function classifyListIssuesError(stderr: string): ClassifiedError { // the short local name `ProjectRef`. export type ProjectRef = GitLabProjectRef +const PROJECT_REF_CACHE_MAX_ENTRIES = 512 const projectRefCache = new Map() /** @internal — exposed for tests only */ @@ -110,6 +111,22 @@ export function _resetProjectRefCache(): void { projectRefCache.clear() } +/** @internal — exposed for tests only */ +export function _getProjectRefCacheSize(): number { + return projectRefCache.size +} + +function rememberProjectRefCacheEntry(cacheKey: string, value: ProjectRef | null): void { + projectRefCache.set(cacheKey, value) + while (projectRefCache.size > PROJECT_REF_CACHE_MAX_ENTRIES) { + const oldestKey = projectRefCache.keys().next().value + if (oldestKey === undefined) { + return + } + projectRefCache.delete(oldestKey) + } +} + /** * Hosts always treated as GitLab. Self-hosted instances are added at * runtime via `getGlabKnownHosts()`, which inspects `glab auth status`. @@ -121,7 +138,7 @@ function normalizeHost(value: string): string { } function stripGitSuffix(path: string): string { - return path.replace(/\.git$/i, '') + return path.replace(/\/+$/, '').replace(/\.git$/i, '') } function makeProjectRef( @@ -187,7 +204,7 @@ export async function getProjectRefForRemote( : await gitExecFileAsync(['remote', 'get-url', remoteName], { cwd: repoPath }) const result = parseGitLabProjectRef(stdout, knownHosts) if (result) { - projectRefCache.set(cacheKey, result) + rememberProjectRefCacheEntry(cacheKey, result) return result } } catch { @@ -198,7 +215,7 @@ export async function getProjectRefForRemote( } // ignore — non-GitLab remote or no remote configured } - projectRefCache.set(cacheKey, null) + rememberProjectRefCacheEntry(cacheKey, null) return null } diff --git a/src/main/gitlab/issues.test.ts b/src/main/gitlab/issues.test.ts index 14712d8f6bc..9fc2303eccc 100644 --- a/src/main/gitlab/issues.test.ts +++ b/src/main/gitlab/issues.test.ts @@ -256,7 +256,8 @@ describe('gitlab issue operations', () => { glabExecFileAsyncMock .mockResolvedValueOnce({ stdout: 'bug\nfeature\n' }) .mockResolvedValueOnce({ - stdout: '{"username":"alice","name":"Alice","avatar_url":"https://example.com/a.png"}\n' + stdout: + '{"id":12,"username":"alice","name":"Alice","avatar_url":"https://example.com/a.png","state":"active"}\n' }) await expect(listLabels('/repo-root', 'upstream', 'conn-1')).resolves.toEqual([ @@ -265,9 +266,11 @@ describe('gitlab issue operations', () => { ]) await expect(listAssignableUsers('/repo-root', 'upstream', 'conn-1')).resolves.toEqual([ { + id: 12, username: 'alice', name: 'Alice', - avatarUrl: 'https://example.com/a.png' + avatarUrl: 'https://example.com/a.png', + state: 'active' } ]) @@ -287,7 +290,7 @@ describe('gitlab issue operations', () => { '--paginate', 'projects/stablyai%2Forca/members/all?per_page=100', '--jq', - '.[] | {username, name, avatar_url}' + '.[] | {id, username, name, avatar_url, state}' ]) }) diff --git a/src/main/gitlab/issues.ts b/src/main/gitlab/issues.ts index 8f9278bd760..b2ced1e3522 100644 --- a/src/main/gitlab/issues.ts +++ b/src/main/gitlab/issues.ts @@ -474,11 +474,17 @@ export async function listAssignableUsers( '--paginate', `projects/${encodedProject(projectRef.path)}/members/all?per_page=100`, '--jq', - '.[] | {username, name, avatar_url}' + '.[] | {id, username, name, avatar_url, state}' ], glabRepoExecOptions(repoPath, connectionId) ) - type RESTMember = { username?: string; name?: string | null; avatar_url?: string | null } + type RESTMember = { + id?: number + username?: string + name?: string | null + avatar_url?: string | null + state?: string | null + } const users: GitLabAssignableUser[] = [] for (const line of stdout.split('\n')) { const trimmed = line.trim() @@ -489,9 +495,11 @@ export async function listAssignableUsers( const user = JSON.parse(trimmed) as RESTMember if (user.username) { users.push({ + ...(typeof user.id === 'number' ? { id: user.id } : {}), username: user.username, name: user.name ?? null, - avatarUrl: user.avatar_url ?? '' + avatarUrl: user.avatar_url ?? '', + ...(user.state !== undefined ? { state: user.state } : {}) }) } } catch { diff --git a/src/main/gitlab/mappers.ts b/src/main/gitlab/mappers.ts index 37a97e3a409..61ba93d27ac 100644 --- a/src/main/gitlab/mappers.ts +++ b/src/main/gitlab/mappers.ts @@ -2,10 +2,13 @@ import type { CheckStatus, GitLabIssueInfo, GitLabWorkItem, - MRCheckDetail, MRInfo, MRState } from '../../shared/types' +import { + mapGitLabPipelineJobStatusToCheckStatus, + mapGitLabPipelineJobStatusToConclusion +} from '../../shared/gitlab-pipeline-checks' // ── Pipeline job mapping (GitLab REST `/pipelines/:id/jobs`) ──────── // Why: GitLab pipeline jobs roughly map to GitHub check-runs, but use a @@ -13,49 +16,8 @@ import type { // into PRCheckDetail's status + conclusion shape so the renderer can // share a row with the GitHub side. -export function mapPipelineJobStatusToCheckStatus(status: string): MRCheckDetail['status'] { - const s = status?.toLowerCase() - if (s === 'created' || s === 'pending' || s === 'waiting_for_resource' || s === 'preparing') { - return 'queued' - } - if (s === 'running') { - return 'in_progress' - } - return 'completed' -} - -export function mapPipelineJobStatusToConclusion(status: string): MRCheckDetail['conclusion'] { - const s = status?.toLowerCase() - if (s === 'success') { - return 'success' - } - if (s === 'failed') { - return 'failure' - } - if (s === 'canceled' || s === 'canceling') { - return 'cancelled' - } - if (s === 'skipped') { - return 'skipped' - } - // Why: 'manual' jobs require user trigger and never auto-complete; we - // surface them as neutral rather than pending so they don't stall the - // top-level rollup at "pending" forever. - if (s === 'manual') { - return 'neutral' - } - if ( - s === 'created' || - s === 'pending' || - s === 'running' || - s === 'waiting_for_resource' || - s === 'preparing' || - s === 'scheduled' - ) { - return 'pending' - } - return null -} +export const mapPipelineJobStatusToCheckStatus = mapGitLabPipelineJobStatusToCheckStatus +export const mapPipelineJobStatusToConclusion = mapGitLabPipelineJobStatusToConclusion // ── MR state mapping ──────────────────────────────────────────────── // Why: glab returns the API state directly. Apply the draft flag (or a diff --git a/src/main/gitlab/merge-request-creation.test.ts b/src/main/gitlab/merge-request-creation.test.ts new file mode 100644 index 00000000000..742413c080a --- /dev/null +++ b/src/main/gitlab/merge-request-creation.test.ts @@ -0,0 +1,231 @@ +/* eslint-disable max-lines -- Why: MR creation tests share glab and SSH filesystem mocks across CLI, template, and duplicate-detection paths. */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + getProjectSlugMock, + glabExecFileAsyncMock, + glabHostnameArgsMock, + glabRepoExecOptionsMock, + acquireMock, + releaseMock, + getSshFilesystemProviderMock +} = vi.hoisted(() => ({ + getProjectSlugMock: vi.fn(), + glabExecFileAsyncMock: vi.fn(), + glabHostnameArgsMock: vi.fn((projectRef: { host: string }) => ['--hostname', projectRef.host]), + glabRepoExecOptionsMock: vi.fn((repoPath: string, connectionId?: string | null) => + connectionId ? {} : { cwd: repoPath } + ), + acquireMock: vi.fn(), + releaseMock: vi.fn(), + getSshFilesystemProviderMock: vi.fn() +})) + +vi.mock('./client', () => ({ + getProjectSlug: getProjectSlugMock +})) + +vi.mock('./gl-utils', () => ({ + acquire: acquireMock, + release: releaseMock, + glabExecFileAsync: glabExecFileAsyncMock, + glabHostnameArgs: glabHostnameArgsMock, + glabRepoExecOptions: glabRepoExecOptionsMock +})) + +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: getSshFilesystemProviderMock +})) + +import { createGitLabMergeRequest } from './merge-request-creation' + +describe('createGitLabMergeRequest', () => { + beforeEach(() => { + getProjectSlugMock.mockReset() + glabExecFileAsyncMock.mockReset() + glabHostnameArgsMock.mockClear() + glabRepoExecOptionsMock.mockClear() + acquireMock.mockReset() + releaseMock.mockReset() + getSshFilesystemProviderMock.mockReset() + acquireMock.mockResolvedValue(undefined) + getProjectSlugMock.mockResolvedValue({ host: 'gitlab.com', path: 'acme/widgets' }) + }) + + it('creates a GitLab merge request with normalized refs', async () => { + glabExecFileAsyncMock.mockResolvedValueOnce({ + stdout: 'https://gitlab.com/acme/widgets/-/merge_requests/42\n', + stderr: '' + }) + + await expect( + createGitLabMergeRequest('/repo-root', { + provider: 'gitlab', + base: 'origin/main', + head: 'refs/heads/feature/create-mr', + title: ' Create MR UI ', + body: 'Body text', + draft: true + }) + ).resolves.toEqual({ + ok: true, + number: 42, + url: 'https://gitlab.com/acme/widgets/-/merge_requests/42' + }) + + const [args, options] = glabExecFileAsyncMock.mock.calls[0] + expect(args).toEqual( + expect.arrayContaining([ + 'mr', + 'create', + '-R', + 'acme/widgets', + '--target-branch', + 'main', + '--source-branch', + 'feature/create-mr', + '--title', + 'Create MR UI', + '--description', + 'Body text', + '--draft' + ]) + ) + expect(args).toEqual(expect.arrayContaining(['--hostname', 'gitlab.com'])) + expect(options).toMatchObject({ + cwd: '/repo-root', + timeout: 60_000, + idempotent: false + }) + expect(acquireMock).toHaveBeenCalledOnce() + expect(releaseMock).toHaveBeenCalledOnce() + }) + + it('creates SSH-backed merge requests without using the remote path as a local cwd', async () => { + glabExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + iid: 45, + web_url: 'https://gitlab.com/acme/widgets/-/merge_requests/45' + }), + stderr: '' + }) + + await expect( + createGitLabMergeRequest( + '/remote/repo-root', + { + provider: 'gitlab', + base: 'main', + head: 'feature/ssh-create-mr', + title: 'SSH Create MR' + }, + 'ssh-1' + ) + ).resolves.toEqual({ + ok: true, + number: 45, + url: 'https://gitlab.com/acme/widgets/-/merge_requests/45' + }) + + expect(getProjectSlugMock).toHaveBeenCalledWith('/remote/repo-root', 'ssh-1') + const [args, options] = glabExecFileAsyncMock.mock.calls[0] + expect(args).toEqual( + expect.arrayContaining([ + 'mr', + 'create', + '-R', + 'acme/widgets', + '--target-branch', + 'main', + '--source-branch', + 'feature/ssh-create-mr' + ]) + ) + expect(options).toMatchObject({ + timeout: 60_000, + idempotent: false + }) + expect(options).not.toHaveProperty('cwd') + }) + + it('reads merge request templates from the SSH filesystem provider', async () => { + const readRemoteFile = vi.fn(async (path: string) => { + if (path === '/remote/repo-root/.gitlab/merge_request_templates/Default.md') { + return { content: 'Remote MR template body', isBinary: false } + } + throw new Error('missing template') + }) + getSshFilesystemProviderMock.mockReturnValue({ readFile: readRemoteFile }) + glabExecFileAsyncMock.mockResolvedValueOnce({ + stdout: 'https://gitlab.com/acme/widgets/-/merge_requests/46\n', + stderr: '' + }) + + await expect( + createGitLabMergeRequest( + '/remote/repo-root', + { + provider: 'gitlab', + base: 'main', + head: 'feature/ssh-template', + title: 'SSH Template MR', + body: '', + useTemplate: true + }, + 'ssh-1' + ) + ).resolves.toEqual({ + ok: true, + number: 46, + url: 'https://gitlab.com/acme/widgets/-/merge_requests/46' + }) + + const [args] = glabExecFileAsyncMock.mock.calls[0] + expect(readRemoteFile).toHaveBeenCalledWith( + '/remote/repo-root/.gitlab/merge_request_templates/Default.md' + ) + expect(args[args.indexOf('--description') + 1]).toBe('Remote MR template body') + }) + + it('returns an existing merge request when GitLab reports a duplicate', async () => { + glabExecFileAsyncMock + .mockRejectedValueOnce(new Error('merge request already exists')) + .mockResolvedValueOnce({ + stdout: JSON.stringify([ + { + iid: 77, + web_url: 'https://gitlab.com/acme/widgets/-/merge_requests/77' + } + ]), + stderr: '' + }) + + await expect( + createGitLabMergeRequest('/repo-root', { + provider: 'gitlab', + base: 'main', + head: 'feature/existing', + title: 'Existing MR' + }) + ).resolves.toEqual({ + ok: false, + code: 'already_exists', + error: 'A merge request already exists for this branch.', + existingReview: { + number: 77, + url: 'https://gitlab.com/acme/widgets/-/merge_requests/77' + } + }) + + expect(glabExecFileAsyncMock.mock.calls[1][0]).toEqual( + expect.arrayContaining([ + 'mr', + 'list', + '--source-branch', + 'feature/existing', + '--target-branch', + 'main' + ]) + ) + }) +}) diff --git a/src/main/gitlab/merge-request-creation.ts b/src/main/gitlab/merge-request-creation.ts new file mode 100644 index 00000000000..9dee1b66e0d --- /dev/null +++ b/src/main/gitlab/merge-request-creation.ts @@ -0,0 +1,303 @@ +import { readFile } from 'fs/promises' +import { join } from 'path' +import type { CreateHostedReviewInput, CreateHostedReviewResult } from '../../shared/hosted-review' +import { + normalizeHostedReviewBaseRef, + normalizeHostedReviewHeadRef +} from '../../shared/hosted-review-refs' +import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths' +import { getProjectSlug } from './client' +import { + acquire, + glabExecFileAsync, + glabHostnameArgs, + glabRepoExecOptions, + release, + type ProjectRef +} from './gl-utils' + +function execErrorMessage(error: unknown): string { + if (error instanceof Error) { + const maybeExec = error as Error & { stderr?: unknown; stdout?: unknown } + return [maybeExec.stderr, maybeExec.stdout, error.message] + .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) + .join('\n') + .trim() + } + return String(error) +} + +function classifyCreateMRError(error: unknown): CreateHostedReviewResult { + const message = execErrorMessage(error) + if (message) { + console.warn('createGitLabMergeRequest failed:', message) + } + const lower = message.toLowerCase() + if ( + lower.includes('not logged') || + lower.includes('not authenticated') || + lower.includes('authentication') || + lower.includes('glab auth login') || + lower.includes('http 401') + ) { + return { + ok: false, + code: 'auth_required', + error: + 'Create MR failed: GitLab is not authenticated. Next step: run glab auth login in this environment.' + } + } + if (lower.includes('already exists') || lower.includes('merge request already exists')) { + return { + ok: false, + code: 'already_exists', + error: 'A merge request already exists for this branch.' + } + } + if (lower.includes('timed out') || lower.includes('timeout')) { + return { + ok: false, + code: 'unknown_completion', + error: 'MR creation may have completed. Refreshing branch review state...' + } + } + if (lower.includes('validation failed') || lower.includes('http 422')) { + return { + ok: false, + code: 'validation', + error: + 'Create MR failed: GitLab rejected the merge request. Check the base branch and branch state, then try again.' + } + } + return { + ok: false, + code: 'unknown', + error: 'Create MR failed: GitLab could not create the merge request. Try again in a moment.' + } +} + +function parseMergeRequestPayload(stdout: string): { number: number; url: string } | null { + const trimmed = stdout.trim() + if (!trimmed) { + return null + } + try { + const parsed = JSON.parse(trimmed) as { + iid?: unknown + number?: unknown + web_url?: unknown + webUrl?: unknown + url?: unknown + } + const number = Number(parsed.iid ?? parsed.number) + const url = + typeof parsed.web_url === 'string' + ? parsed.web_url.trim() + : typeof parsed.webUrl === 'string' + ? parsed.webUrl.trim() + : typeof parsed.url === 'string' + ? parsed.url.trim() + : '' + if (Number.isInteger(number) && number > 0 && url) { + return { number, url } + } + } catch { + // Fall through to URL parsing for glab's normal text output. + } + const urlMatch = trimmed.match(/https?:\/\/[^\s]+\/-\/merge_requests\/(\d+)/) + if (!urlMatch) { + return null + } + return { number: Number(urlMatch[1]), url: urlMatch[0] } +} + +async function findOpenMRByHeadBase(args: { + repoPath: string + projectRef: ProjectRef + head: string + base: string + connectionId?: string | null +}): Promise<{ number: number; url: string } | null> { + const { stdout } = await glabExecFileAsync( + [ + 'mr', + 'list', + '-R', + args.projectRef.path, + '--source-branch', + args.head, + '--target-branch', + args.base, + '--per-page', + '2', + '--output', + 'json', + ...glabHostnameArgs(args.projectRef, args.connectionId) + ], + glabRepoExecOptions(args.repoPath, args.connectionId) + ) + const list = JSON.parse(stdout) as { + iid?: number + number?: number + web_url?: string + webUrl?: string + url?: string + }[] + if (list.length !== 1) { + return null + } + return parseMergeRequestPayload(JSON.stringify(list[0])) +} + +async function readMergeRequestTemplate( + repoPath: string, + connectionId?: string | null +): Promise { + const relativeCandidates = [ + '.gitlab/merge_request_templates/Default.md', + '.gitlab/merge_request_templates/default.md', + '.gitlab/merge_request_template.md', + '.gitlab/MERGE_REQUEST_TEMPLATE.md' + ] + const remoteProvider = connectionId ? getSshFilesystemProvider(connectionId) : undefined + if (connectionId && !remoteProvider) { + return '' + } + for (const relativeCandidate of relativeCandidates) { + try { + if (remoteProvider) { + const result = await remoteProvider.readFile( + joinWorktreeRelativePath(repoPath, relativeCandidate) + ) + if (result.isBinary) { + continue + } + return result.content + } + return await readFile(join(repoPath, relativeCandidate), 'utf8') + } catch { + // Try the next conventional GitLab merge-request template path. + } + } + return '' +} + +export async function createGitLabMergeRequest( + repoPath: string, + input: CreateHostedReviewInput, + connectionId?: string | null +): Promise { + if (input.provider !== 'gitlab') { + return { + ok: false, + code: 'unsupported_provider', + error: 'Creating reviews for this provider is not supported yet.' + } + } + + const projectRef = await getProjectSlug(repoPath, connectionId) + if (!projectRef) { + return { + ok: false, + code: 'unsupported_provider', + error: 'Creating merge requests requires a GitLab remote.' + } + } + + const base = normalizeHostedReviewBaseRef(input.base) + const head = input.head ? normalizeHostedReviewHeadRef(input.head) || undefined : undefined + const title = input.title.trim() + if (!base || !title) { + return { + ok: false, + code: 'validation', + error: 'Create MR failed: base branch and title are required.' + } + } + if (head && head.toLowerCase() === base.toLowerCase()) { + return { + ok: false, + code: 'validation', + error: 'Create MR failed: choose a different base branch before creating a merge request.' + } + } + + await acquire() + try { + const body = + input.useTemplate && !input.body?.trim() + ? await readMergeRequestTemplate(repoPath, connectionId) + : (input.body ?? '') + const createArgs = [ + 'mr', + 'create', + '-R', + projectRef.path, + '--target-branch', + base, + '--title', + title, + '--description', + body, + '--yes', + ...glabHostnameArgs(projectRef, connectionId) + ] + if (head) { + createArgs.push('--source-branch', head) + } + if (input.draft) { + createArgs.push('--draft') + } + try { + const { stdout } = await glabExecFileAsync(createArgs, { + ...glabRepoExecOptions(repoPath, connectionId), + timeout: 60_000, + idempotent: false + }) + const created = parseMergeRequestPayload(stdout) + if (created) { + return { ok: true, ...created } + } + const found = head + ? await findOpenMRByHeadBase({ repoPath, projectRef, head, base, connectionId }).catch( + () => null + ) + : null + if (found) { + return { ok: true, ...found } + } + return { + ok: false, + code: 'unknown_completion', + error: 'MR creation may have completed. Refreshing branch review state...' + } + } catch (error) { + const classified = classifyCreateMRError(error) + if ( + !classified.ok && + (classified.code === 'already_exists' || classified.code === 'unknown_completion') && + head + ) { + const existing = await findOpenMRByHeadBase({ + repoPath, + projectRef, + head, + base, + connectionId + }).catch(() => null) + if (existing) { + return { + ok: false, + code: 'already_exists', + error: 'A merge request already exists for this branch.', + existingReview: existing + } + } + } + return classified + } + } finally { + release() + } +} diff --git a/src/main/gitlab/work-item-details.test.ts b/src/main/gitlab/work-item-details.test.ts new file mode 100644 index 00000000000..5c5f40a5d5f --- /dev/null +++ b/src/main/gitlab/work-item-details.test.ts @@ -0,0 +1,137 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + glabExecFileAsyncMock, + getGlabKnownHostsMock, + resolveIssueSourceMock, + acquireMock, + releaseMock +} = vi.hoisted(() => ({ + glabExecFileAsyncMock: vi.fn(), + getGlabKnownHostsMock: vi.fn(), + resolveIssueSourceMock: vi.fn(), + acquireMock: vi.fn(), + releaseMock: vi.fn() +})) + +vi.mock('./gl-utils', () => ({ + acquire: acquireMock, + release: releaseMock, + getGlabKnownHosts: getGlabKnownHostsMock, + resolveIssueSource: resolveIssueSourceMock, + glabExecFileAsync: glabExecFileAsyncMock, + glabHostnameArgs: vi.fn(() => []), + glabRepoExecOptions: vi.fn((repoPath: string, connectionId?: string | null) => + connectionId ? {} : { cwd: repoPath } + ) +})) + +import { getWorkItemDetails } from './work-item-details' + +describe('getWorkItemDetails', () => { + beforeEach(() => { + glabExecFileAsyncMock.mockReset() + getGlabKnownHostsMock.mockReset() + resolveIssueSourceMock.mockReset() + acquireMock.mockReset() + releaseMock.mockReset() + acquireMock.mockResolvedValue(undefined) + getGlabKnownHostsMock.mockResolvedValue(['gitlab.com']) + resolveIssueSourceMock.mockResolvedValue({ + source: { host: 'gitlab.com', path: 'g/p' }, + fellBack: false + }) + }) + + it('caps MR detail discussions, jobs, and file diffs to one API page', async () => { + glabExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const endpoint = args.at(-1) + if (endpoint === 'projects/g%2Fp/merge_requests/12') { + return { + stdout: JSON.stringify({ + id: 120, + iid: 12, + title: 'Bound detail payloads', + state: 'opened', + web_url: 'https://gitlab.com/g/p/-/merge_requests/12', + updated_at: '2026-05-31T12:00:00Z', + source_branch: 'feature/bounds', + target_branch: 'main', + description: 'MR body', + sha: 'head-sha', + diff_refs: { base_sha: 'base-sha', start_sha: 'start-sha' }, + head_pipeline: { id: 99 } + }) + } + } + if (endpoint === 'projects/g%2Fp/merge_requests/12/discussions?per_page=100') { + return { + stdout: JSON.stringify([ + { + id: 'discussion-1', + notes: [ + { + id: 1, + body: 'Review note', + created_at: '2026-05-31T12:01:00Z', + author: { username: 'alice', avatar_url: 'https://example.com/a.png' } + } + ] + } + ]) + } + } + if (endpoint === 'projects/g%2Fp/pipelines/99/jobs?per_page=100') { + return { + stdout: JSON.stringify([ + { + id: 10, + name: 'verify', + stage: 'test', + status: 'success', + web_url: 'https://gitlab.com/g/p/-/jobs/10', + duration: 12 + } + ]) + } + } + if (endpoint === 'projects/g%2Fp/merge_requests/12/reviewers') { + return { stdout: '[]' } + } + if (endpoint === 'projects/g%2Fp/merge_requests/12/approvals') { + return { stdout: JSON.stringify({ approvals_required: 0, approvals_left: 0 }) } + } + if (endpoint === 'projects/g%2Fp/merge_requests/12/approval_state') { + return { stdout: JSON.stringify({ rules: [] }) } + } + if (endpoint === 'projects/g%2Fp/merge_requests/12/diffs?per_page=100') { + return { + stdout: JSON.stringify([ + { + new_path: 'src/app.ts', + old_path: 'src/app.ts', + diff: '@@ -1 +1 @@\n-old\n+new' + } + ]) + } + } + throw new Error(`unexpected glab call: ${args.join(' ')}`) + }) + + const details = await getWorkItemDetails('/repo', 12, 'mr') + + expect(details?.comments).toHaveLength(1) + expect(details?.pipelineJobs).toHaveLength(1) + expect(details?.files).toHaveLength(1) + expect(details?.files?.[0]).toMatchObject({ + path: 'src/app.ts', + additions: 1, + deletions: 1 + }) + expect(glabExecFileAsyncMock.mock.calls.map(([args]) => args)).toContainEqual([ + 'api', + 'projects/g%2Fp/merge_requests/12/diffs?per_page=100' + ]) + expect(glabExecFileAsyncMock.mock.calls.flatMap(([args]) => args)).not.toContain('--paginate') + }) +}) diff --git a/src/main/gitlab/work-item-details.ts b/src/main/gitlab/work-item-details.ts index ddec56a3d74..33cbff6eebc 100644 --- a/src/main/gitlab/work-item-details.ts +++ b/src/main/gitlab/work-item-details.ts @@ -1,8 +1,12 @@ +/* eslint-disable max-lines -- Why: aggregated detail-fetch for GitLabItemDialog spans issues, MRs, comments, pipelines, reviewers, approvals, and changed files; splitting would obscure the shared fetch context. */ // Why: aggregated detail-fetch for GitLabItemDialog. Parallel of // src/main/github/work-item-details.ts but scoped to v1 surface — -// description body, flattened discussion notes, MR pipeline jobs. -// Files / inline review-comment positioning / approvals are deferred. +// description body, flattened discussion notes, MR pipeline jobs/reviewers. +// Files / inline review-comment positioning are deferred. import type { + GitLabAssignableUser, + GitLabMRApprovalState, + GitLabMRFile, GitLabPipelineJob, GitLabWorkItem, GitLabWorkItemDetails, @@ -89,7 +93,8 @@ async function fetchDiscussions( [ 'api', ...glabHostnameArgs(projectRef, connectionId), - '--paginate', + // Why: detail drawers need a bounded recent conversation snapshot. + // Walking every historic discussion can retain and render huge note sets. `projects/${encodedProject(projectRef.path)}/${resource}/${iid}/discussions?per_page=100` ], glabRepoExecOptions(repoPath, connectionId) @@ -108,9 +113,31 @@ type GitLabRawJob = { duration?: number | null } -function mapPipelineJob(raw: GitLabRawJob): GitLabPipelineJob { +type GitLabRawUser = { + id?: number + username?: string | null + name?: string | null + avatar_url?: string | null + state?: string | null +} + +function mapGitLabUser(raw: GitLabRawUser | null | undefined): GitLabAssignableUser | null { + if (!raw?.username) { + return null + } + return { + ...(typeof raw.id === 'number' ? { id: raw.id } : {}), + username: raw.username, + name: raw.name ?? null, + avatarUrl: raw.avatar_url ?? '', + ...(raw.state !== undefined ? { state: raw.state } : {}) + } +} + +function mapPipelineJob(raw: GitLabRawJob, pipelineId: number): GitLabPipelineJob { return { id: raw.id ?? 0, + pipelineId, name: raw.name ?? '', stage: raw.stage ?? '', status: raw.status ?? '', @@ -129,13 +156,80 @@ async function fetchPipelineJobs( [ 'api', ...glabHostnameArgs(projectRef, connectionId), - '--paginate', + // Why: one MR details load should not fetch every job page from very + // large pipelines; the first 100 jobs match the visible summary budget. `projects/${encodedProject(projectRef.path)}/pipelines/${pipelineId}/jobs?per_page=100` ], glabRepoExecOptions(repoPath, connectionId) ) const data = JSON.parse(stdout) as GitLabRawJob[] - return data.map(mapPipelineJob) + return data.map((job) => mapPipelineJob(job, pipelineId)) +} + +function countDiffLines(diff: string): { additions: number; deletions: number } { + let additions = 0 + let deletions = 0 + for (const line of diff.split('\n')) { + if (line.startsWith('+++') || line.startsWith('---')) { + continue + } + if (line.startsWith('+')) { + additions += 1 + } else if (line.startsWith('-')) { + deletions += 1 + } + } + return { additions, deletions } +} + +function mapMRFile(raw: { + new_path?: string + old_path?: string + diff?: string + new_file?: boolean + deleted_file?: boolean + renamed_file?: boolean + binary?: boolean + too_large?: boolean +}): GitLabMRFile { + const diff = raw.diff ?? '' + const counts = countDiffLines(diff) + const status = raw.new_file + ? 'added' + : raw.deleted_file + ? 'removed' + : raw.renamed_file + ? 'renamed' + : 'modified' + return { + path: raw.new_path ?? raw.old_path ?? '', + ...(raw.old_path && raw.old_path !== raw.new_path ? { oldPath: raw.old_path } : {}), + status, + additions: counts.additions, + deletions: counts.deletions, + isBinary: Boolean(raw.binary || raw.too_large || !diff), + ...(diff ? { diff } : {}) + } +} + +async function fetchMRFiles( + repoPath: string, + projectRef: ProjectRef, + iid: number, + connectionId?: string | null +): Promise { + const { stdout } = await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + // Why: GitLab deprecated the all-in-one `changes` endpoint in favor of + // the paginated diffs endpoint; cap the file snapshot at one visible page. + `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/diffs?per_page=100` + ], + glabRepoExecOptions(repoPath, connectionId) + ) + const data = JSON.parse(stdout) as Parameters[0][] + return data.map(mapMRFile).filter((file) => file.path) } // ── Top-level aggregator ─────────────────────────────────────────── @@ -150,6 +244,89 @@ type GitLabRawMR = Parameters[0] & { sha?: string diff_refs?: { base_sha?: string; head_sha?: string; start_sha?: string } | null head_pipeline?: { id?: number } | null + reviewers?: GitLabRawUser[] | null +} + +async function fetchMRReviewers( + repoPath: string, + projectRef: ProjectRef, + iid: number, + connectionId?: string | null +): Promise { + const { stdout } = await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/reviewers` + ], + glabRepoExecOptions(repoPath, connectionId) + ) + const data = JSON.parse(stdout) as { user?: GitLabRawUser | null }[] + return data + .map((entry) => mapGitLabUser(entry.user)) + .filter((u): u is GitLabAssignableUser => !!u) +} + +async function fetchMRApprovalState( + repoPath: string, + projectRef: ProjectRef, + iid: number, + connectionId?: string | null +): Promise { + const [approvalsRes, stateRes] = await Promise.allSettled([ + glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/approvals` + ], + glabRepoExecOptions(repoPath, connectionId) + ), + glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/approval_state` + ], + glabRepoExecOptions(repoPath, connectionId) + ) + ]) + if (approvalsRes.status === 'rejected' && stateRes.status === 'rejected') { + return undefined + } + const approvals = + approvalsRes.status === 'fulfilled' + ? (JSON.parse(approvalsRes.value.stdout) as { + approvals_required?: number | null + approvals_left?: number | null + approved_by?: { user?: GitLabRawUser | null }[] + }) + : null + const state = + stateRes.status === 'fulfilled' + ? (JSON.parse(stateRes.value.stdout) as { + rules?: { + id?: number + name?: string + approvals_required?: number + approved?: boolean + }[] + }) + : null + return { + approvalsRequired: + typeof approvals?.approvals_required === 'number' ? approvals.approvals_required : null, + approvalsLeft: typeof approvals?.approvals_left === 'number' ? approvals.approvals_left : null, + approvedBy: (approvals?.approved_by ?? []) + .map((entry) => mapGitLabUser(entry.user)) + .filter((u): u is GitLabAssignableUser => !!u), + rules: (state?.rules ?? []).map((rule) => ({ + id: rule.id ?? 0, + name: rule.name ?? 'Approval rule', + approvalsRequired: rule.approvals_required ?? 0, + approved: Boolean(rule.approved) + })) + } } /** @@ -259,12 +436,23 @@ async function fetchMRDetails( typeof pipelineId === 'number' ? await fetchPipelineJobs(repoPath, projectRef, pipelineId, connectionId).catch(() => []) : undefined + const [reviewers, approvalState, files] = await Promise.all([ + fetchMRReviewers(repoPath, projectRef, iid, connectionId).catch(() => + (mrRaw.reviewers ?? []).map(mapGitLabUser).filter((u): u is GitLabAssignableUser => !!u) + ), + fetchMRApprovalState(repoPath, projectRef, iid, connectionId).catch(() => undefined), + fetchMRFiles(repoPath, projectRef, iid, connectionId).catch(() => []) + ]) return { item, body: mrRaw.description ?? '', comments: flattenDiscussions(discussions), headSha: mrRaw.sha, baseSha: mrRaw.diff_refs?.base_sha, - ...(pipelineJobs !== undefined ? { pipelineJobs } : {}) + startSha: mrRaw.diff_refs?.start_sha, + files, + ...(pipelineJobs !== undefined ? { pipelineJobs } : {}), + reviewers, + ...(approvalState ? { approvalState } : {}) } } diff --git a/src/main/grok/hook-service.ts b/src/main/grok/hook-service.ts index bb8ed8de48c..d26bf8bb80e 100644 --- a/src/main/grok/hook-service.ts +++ b/src/main/grok/hook-service.ts @@ -85,7 +85,9 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { 'if [ -z "$payload" ]; then', ' exit 0', 'fi', + // Timeout caps best-effort hook posts if the local listener stalls. 'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/grok" \\', + ' --connect-timeout 0.5 --max-time 1.5 \\', ' -H "Content-Type: application/x-www-form-urlencoded" \\', ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', diff --git a/src/main/hermes/hook-service.test.ts b/src/main/hermes/hook-service.test.ts index 33218042b46..df1d95a077f 100644 --- a/src/main/hermes/hook-service.test.ts +++ b/src/main/hermes/hook-service.test.ts @@ -224,4 +224,46 @@ describe('HermesHookService', () => { } }) }) + + it('bounds generated plugin payload normalization before JSON encoding', () => { + const pythonAvailable = spawnSync('python3', ['--version'], { encoding: 'utf-8' }).status === 0 + if (!pythonAvailable) { + return + } + new HermesHookService().install() + + const initPath = join(homeDir, 'plugins', _internals.HERMES_PLUGIN_NAME, '__init__.py') + const script = [ + 'import importlib.util, json', + `spec = importlib.util.spec_from_file_location("orca_status", ${JSON.stringify(initPath)})`, + 'mod = importlib.util.module_from_spec(spec)', + 'spec.loader.exec_module(mod)', + 'payload = mod._payload_for_event("post_tool_call", {', + ' "session_id": "sess-1",', + ' "tool_name": "BigTool",', + ' "args": {"long": "x" * 9000, "items": list(range(200))},', + ' "result": {"text": "y" * 9000, "nested": [{"value": i} for i in range(200)]},', + ' "duration_ms": 12,', + '})', + 'print(json.dumps(payload))' + ].join('\n') + + const output = execFileSync('python3', ['-c', script], { + encoding: 'utf-8', + timeout: 15_000 + }) + const payload = JSON.parse(output) as { + args: { long: string; items: unknown[] } + result: { text: string; nested: unknown[] } + tool_input: { items: unknown[] } + } + + expect(payload.args.long).toHaveLength(8192 + '...[truncated]'.length) + expect(payload.args.long.endsWith('...[truncated]')).toBe(true) + expect(payload.args.items).toHaveLength(51) + expect(payload.args.items.at(-1)).toBe('...[truncated]') + expect(payload.result.text.endsWith('...[truncated]')).toBe(true) + expect(payload.result.nested).toHaveLength(51) + expect(payload.tool_input.items).toHaveLength(51) + }) }) diff --git a/src/main/hermes/hook-service.ts b/src/main/hermes/hook-service.ts index 0f3e9bbd890..2c9ea3f498c 100644 --- a/src/main/hermes/hook-service.ts +++ b/src/main/hermes/hook-service.ts @@ -289,9 +289,16 @@ import json import os import urllib.error import urllib.request -from typing import Any, Callable +from typing import Any, Callable, Optional EVENTS = ${JSON.stringify(HERMES_EVENTS)} +# Why: hook args/results can include full tool payloads. Bound traversal before +# JSON encoding so status hooks stay best-effort and cheap. +MAX_JSONABLE_DEPTH = 5 +MAX_JSONABLE_ITEMS = 50 +MAX_JSONABLE_NODES = 500 +MAX_JSONABLE_STRING = 8192 +TRUNCATED = "...[truncated]" SELECTED_KEYS = { "on_session_start": ("session_id", "model", "platform"), "pre_llm_call": ("session_id", "user_message", "is_first_turn", "model", "platform", "sender_id"), @@ -306,16 +313,41 @@ SELECTED_KEYS = { } -def _jsonable(value: Any, depth: int = 0) -> Any: - if depth > 5: - return repr(value) - if value is None or isinstance(value, (str, int, float, bool)): +def _truncate_string(value: str) -> str: + if len(value) <= MAX_JSONABLE_STRING: return value + return value[:MAX_JSONABLE_STRING] + TRUNCATED + + +def _jsonable(value: Any, depth: int = 0, budget: Optional[list[int]] = None) -> Any: + if budget is None: + budget = [MAX_JSONABLE_NODES] + if budget[0] <= 0: + return TRUNCATED + budget[0] -= 1 + if depth > MAX_JSONABLE_DEPTH: + return _truncate_string(repr(value)) + if value is None or isinstance(value, (int, float, bool)): + return value + if isinstance(value, str): + return _truncate_string(value) if isinstance(value, dict): - return {str(k): _jsonable(v, depth + 1) for k, v in value.items()} + out: dict[str, Any] = {} + for index, (k, v) in enumerate(value.items()): + if index >= MAX_JSONABLE_ITEMS: + out[TRUNCATED] = True + break + out[_truncate_string(str(k))] = _jsonable(v, depth + 1, budget) + return out if isinstance(value, (list, tuple, set)): - return [_jsonable(v, depth + 1) for v in value] - return repr(value) + out = [] + for index, item in enumerate(value): + if index >= MAX_JSONABLE_ITEMS: + out.append(TRUNCATED) + break + out.append(_jsonable(item, depth + 1, budget)) + return out + return _truncate_string(repr(value)) def _endpoint_env() -> dict[str, str]: diff --git a/src/main/hooks.test.ts b/src/main/hooks.test.ts index b57e4ea3e34..5e9269075a8 100644 --- a/src/main/hooks.test.ts +++ b/src/main/hooks.test.ts @@ -2,7 +2,7 @@ import type { Repo } from '../shared/types' import { describe, expect, it, vi } from 'vitest' -import { parseOrcaYaml } from './hooks' +import { getDefaultTabsLaunch, parseOrcaYaml } from './hooks' // Mock fs and path used by loadHooks vi.mock('fs', () => ({ @@ -142,6 +142,44 @@ describe('parseOrcaYaml', () => { issueCommand: 'claude -p "Read issue #{{issue}}"' }) }) + + it('parses default terminal tabs from orca.yaml', () => { + const yaml = [ + 'defaultTabs:', + ' - title: Claude', + ' color: "#f97316"', + ' command: claude', + ' - title: LocalHost', + ' color: "#9ca3af"', + ' command: pnpm dev', + ' - title: Notes' + ].join('\n') + + expect(parseOrcaYaml(yaml)).toEqual({ + scripts: {}, + defaultTabs: [ + { title: 'Claude', color: '#f97316', command: 'claude' }, + { title: 'LocalHost', color: '#9ca3af', command: 'pnpm dev' }, + { title: 'Notes' } + ] + }) + }) + + it('drops invalid default tab entries and unsafe color values', () => { + const yaml = [ + 'defaultTabs:', + ' - title: Server', + ' color: "red"', + ' command: pnpm dev', + ' - 42', + ' - title: ""' + ].join('\n') + + expect(parseOrcaYaml(yaml)).toEqual({ + scripts: {}, + defaultTabs: [{ title: 'Server', command: 'pnpm dev' }] + }) + }) }) describe('hasUnrecognizedOrcaYamlKeys', () => { @@ -174,7 +212,15 @@ describe('hasUnrecognizedOrcaYamlKeys', () => { it('returns false when the file contains only recognised keys', async () => { const fs = await import('fs') vi.mocked(fs.readFileSync).mockReturnValue( - 'scripts:\n setup: |\n pnpm install\nissueCommand: |\n claude -p "test"\n' + [ + 'scripts:', + ' setup: |', + ' pnpm install', + 'issueCommand: |', + ' claude -p "test"', + 'defaultTabs:', + ' - title: Claude' + ].join('\n') ) const { hasUnrecognizedOrcaYamlKeys } = await import('./hooks') @@ -749,6 +795,52 @@ describe('runHook', () => { }) } }) + + it('settles WSL hooks when wsl.exe never reports completion', async () => { + vi.useFakeTimers() + execMock.mockReset() + execFileMock.mockReset() + const killMock = vi.fn() + execFileMock.mockImplementation(() => ({ kill: killMock }) as never) + + const fs = await import('fs') + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.readFileSync).mockReturnValue('scripts:\n setup: |\n echo hello\n') + + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + + try { + const { runHook } = await import('./hooks') + const promise = runHook('setup', '\\\\wsl.localhost\\Ubuntu\\home\\jin\\feature', { + ...makeRepo(), + path: 'C:\\Users\\jinwo\\git\\orca' + }) + let settled = false + void promise.finally(() => { + settled = true + }) + + await vi.advanceTimersByTimeAsync(120_000) + await Promise.resolve() + + expect(settled).toBe(true) + await expect(promise).resolves.toMatchObject({ + success: false, + output: expect.stringContaining('Hook timed out') + }) + expect(killMock).toHaveBeenCalled() + } finally { + vi.useRealTimers() + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) }) describe('shouldRunSetupForCreate', () => { @@ -788,3 +880,63 @@ describe('shouldRunSetupForCreate', () => { expect(shouldRunSetupForCreate(makeRepo('run-by-default'), 'skip')).toBe(false) }) }) + +describe('getDefaultTabsLaunch', () => { + const makeRepo = ( + setupRunPolicy?: 'ask' | 'run-by-default' | 'skip-by-default', + commandSourcePolicy?: 'local-only' | 'run-both' | 'shared-only' + ) => + ({ + id: 'test-id', + path: '/test/repo', + displayName: 'Test Repo', + badgeColor: '#000', + addedAt: Date.now(), + hookSettings: { + mode: 'auto', + setupRunPolicy, + commandSourcePolicy, + scripts: { setup: '', archive: '' } + } + }) as unknown as Repo + + it('opts into default tab command execution through the setup decision', () => { + const hooks = { + scripts: {}, + defaultTabs: [{ title: 'Server', command: 'pnpm dev' }] + } + + expect(getDefaultTabsLaunch(hooks, makeRepo('skip-by-default'), 'run')).toEqual({ + tabs: hooks.defaultTabs, + runCommands: true + }) + expect(getDefaultTabsLaunch(hooks, makeRepo('run-by-default'), 'skip')).toEqual({ + tabs: hooks.defaultTabs, + runCommands: false + }) + }) + + it('creates commandless default tabs without requiring setup approval', () => { + const hooks = { + scripts: {}, + defaultTabs: [{ title: 'Notes' }] + } + + expect(getDefaultTabsLaunch(hooks, makeRepo('ask'))).toEqual({ + tabs: hooks.defaultTabs, + runCommands: false + }) + }) + + it('does not run shared default tab commands when command source is local-only', () => { + const hooks = { + scripts: {}, + defaultTabs: [{ title: 'Server', command: 'pnpm dev' }] + } + + expect(getDefaultTabsLaunch(hooks, makeRepo('run-by-default', 'local-only'))).toEqual({ + tabs: hooks.defaultTabs, + runCommands: false + }) + }) +}) diff --git a/src/main/hooks.ts b/src/main/hooks.ts index 0b33a81bcad..b63c3fc934b 100644 --- a/src/main/hooks.ts +++ b/src/main/hooks.ts @@ -2,6 +2,7 @@ import { readFileSync, existsSync, mkdirSync, writeFileSync, chmodSync, rmSync } from 'fs' import { dirname, join } from 'path' import { exec, execFile } from 'child_process' +import { parse } from 'yaml' import { getDefaultRepoHookSettings } from '../shared/constants' import { getRuntimePathBasename } from '../shared/cross-platform-path' import { resolveHookCommandSourcePolicy } from '../shared/hook-command-source-policy' @@ -9,10 +10,12 @@ import { gitExecFileSync } from './git/runner' import { isWslPath, parseWslPath, toWindowsWslPath, toLinuxPath } from './wsl' import type { HookCommandSourcePolicy, + OrcaDefaultTabTemplate, OrcaHooks, Repo, SetupDecision, SetupRunPolicy, + WorktreeDefaultTabsLaunch, WorktreeSetupLaunch } from '../shared/types' @@ -26,88 +29,79 @@ function getHookShell(): string | undefined { return '/bin/bash' } +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null +} + +function asTrimmedString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined +} + +const DEFAULT_TAB_COLOR_RE = /^#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?$/ + +function normalizeDefaultTabs(value: unknown): OrcaDefaultTabTemplate[] { + if (!Array.isArray(value)) { + return [] + } + + return value + .map((entry) => { + const record = asRecord(entry) + if (!record) { + return null + } + const title = asTrimmedString(record.title) + const command = asTrimmedString(record.command) + const color = asTrimmedString(record.color) + const normalizedColor = color && DEFAULT_TAB_COLOR_RE.test(color) ? color : undefined + if (!title && !command && !normalizedColor) { + return null + } + return { + ...(title ? { title } : {}), + ...(normalizedColor ? { color: normalizedColor } : {}), + ...(command ? { command } : {}) + } + }) + .filter((entry): entry is OrcaDefaultTabTemplate => entry !== null) +} + /** - * Parse a simple orca.yaml file. Handles only the supported `scripts:` and - * `issueCommand:` keys with multiline string values (YAML block scalar `|`). + * Parse the supported project defaults from `orca.yaml`. */ export function parseOrcaYaml(content: string): OrcaHooks | null { - const hooks: OrcaHooks = { scripts: {} } - const lines = content.split(/\r?\n/) - - let currentSection: 'scripts' | 'issueCommand' | null = null - let currentKey: 'setup' | 'archive' | null = null - let issueCommandValue = '' - - for (const line of lines) { - const topLevelKeyMatch = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(\|)?\s*(.*)$/) - if (topLevelKeyMatch) { - if (currentSection === 'scripts' && currentKey) { - hooks.scripts[currentKey] = issueCommandValue.trimEnd() - } else if (currentSection === 'issueCommand') { - hooks.issueCommand = issueCommandValue.trimEnd() || undefined - } - - const [, key, blockScalar, rest] = topLevelKeyMatch - currentKey = null - issueCommandValue = '' - - if (key === 'scripts') { - currentSection = 'scripts' - continue - } - - if (key === 'issueCommand') { - currentSection = 'issueCommand' - if (blockScalar) { - continue - } - hooks.issueCommand = rest.trim() || undefined - currentSection = null - continue - } - - currentSection = null - continue - } - - if (currentSection === 'scripts') { - // Indented key like " setup: |" or " archive: |" or " setup: echo hello" - const keyMatch = line.match(/^ (setup|archive):\s*(\|)?\s*(.*)$/) - if (keyMatch) { - // Save previous key - if (currentKey) { - hooks.scripts[currentKey] = issueCommandValue.trimEnd() - } - currentKey = keyMatch[1] as 'setup' | 'archive' - issueCommandValue = keyMatch[3] ? `${keyMatch[3]}\n` : '' - continue - } - - // Content line (indented by 4+ spaces under a key) - if (currentKey && line.startsWith(' ')) { - issueCommandValue += `${line.slice(4)}\n` - } - continue - } - - if (currentSection === 'issueCommand' && line.startsWith(' ')) { - // Why: `issueCommand` is a top-level scalar in `orca.yaml`, so its block - // content must stay separate from the `scripts:` parser rather than being - // shoehorned into that section's indentation rules. - issueCommandValue += `${line.slice(2)}\n` - } - } - - if (currentSection === 'scripts' && currentKey) { - hooks.scripts[currentKey] = issueCommandValue.trimEnd() - } else if (currentSection === 'issueCommand') { - hooks.issueCommand = issueCommandValue.trimEnd() || undefined - } - - if (!hooks.scripts.setup && !hooks.scripts.archive && !hooks.issueCommand) { + let root: unknown + try { + root = parse(content) + } catch { return null } - return hooks + + const record = asRecord(root) + if (!record) { + return null + } + + const scriptsRecord = asRecord(record.scripts) + const setup = scriptsRecord ? asTrimmedString(scriptsRecord.setup) : undefined + const archive = scriptsRecord ? asTrimmedString(scriptsRecord.archive) : undefined + const issueCommand = asTrimmedString(record.issueCommand) + const defaultTabs = normalizeDefaultTabs(record.defaultTabs) + + if (!setup && !archive && !issueCommand && defaultTabs.length === 0) { + return null + } + + return { + scripts: { + ...(setup ? { setup } : {}), + ...(archive ? { archive } : {}) + }, + ...(issueCommand ? { issueCommand } : {}), + ...(defaultTabs.length > 0 ? { defaultTabs } : {}) + } } /** @@ -139,7 +133,7 @@ export function hasHooksFile(repoPath: string): boolean { // return `null` from `parseOrcaYaml` and show a confusing "could not be parsed" // error. Detecting well-formed but unrecognised keys lets the UI suggest an // update instead of implying the file is broken. -const RECOGNIZED_ORCA_YAML_KEYS = new Set(['scripts', 'issueCommand']) +const RECOGNIZED_ORCA_YAML_KEYS = new Set(['scripts', 'issueCommand', 'defaultTabs']) /** * Return true when `orca.yaml` contains at least one top-level key that this @@ -339,6 +333,44 @@ export function shouldRunSetupForCreate(repo: Repo, decision: SetupDecision = 'i return policy === 'run-by-default' } +export function getDefaultTabCommandTrustContent(hooks: OrcaHooks | null): string { + const commands = (hooks?.defaultTabs ?? []) + .map((tab, index) => { + const command = tab.command?.trim() + if (!command) { + return null + } + const label = tab.title ? ` ${tab.title}` : '' + return `# defaultTabs[${index + 1}]${label}\n${command}` + }) + .filter((entry): entry is string => entry !== null) + return [hooks?.scripts.setup?.trim(), ...commands].filter(Boolean).join('\n\n') +} + +export function getDefaultTabsLaunch( + hooks: OrcaHooks | null, + repo: Repo, + decision: SetupDecision = 'inherit' +): WorktreeDefaultTabsLaunch | undefined { + const tabs = hooks?.defaultTabs ?? [] + if (tabs.length === 0) { + return undefined + } + const hasCommands = tabs.some((tab) => Boolean(tab.command?.trim())) + const sharedCommandPolicy = resolveHookCommandSourcePolicy( + repo.hookSettings?.commandSourcePolicy, + { + hasLocalScript: Boolean(repo.hookSettings?.scripts.setup?.trim()) + } + ) + // Why: default tab commands come from committed `orca.yaml`; a repo set to + // local-only may still use shared titles/colors, but must not execute them. + const canRunSharedCommands = sharedCommandPolicy !== 'local-only' + const runCommands = + hasCommands && canRunSharedCommands ? shouldRunSetupForCreate(repo, decision) : false + return { tabs, runCommands } +} + export function getSetupCommandSource( repo: Repo, worktreePath?: string @@ -525,30 +557,53 @@ export function runHook( } return new Promise((resolve) => { - execFile( - 'wsl.exe', - ['-d', wslInfo.distro, '--', 'bash', '-c', bashCmd], - { - timeout: HOOK_TIMEOUT, - encoding: 'utf-8', - env: { ...process.env, ...wslEnv } - }, - (error, stdout, stderr) => { - if (error) { - console.error(`[hooks] ${hookName} hook failed in ${cwd}:`, error.message) - resolve({ - success: false, - output: `${stdout}\n${stderr}\n${error.message}`.trim() - }) - } else { - console.log(`[hooks] ${hookName} hook completed in ${cwd}`) - resolve({ - success: true, - output: `${stdout}\n${stderr}`.trim() - }) - } + let child: ReturnType | null = null + let settled = false + + const finish = (error: Error | null, stdout = '', stderr = ''): void => { + if (settled) { + return } - ) + settled = true + clearTimeout(timeout) + if (error) { + console.error(`[hooks] ${hookName} hook failed in ${cwd}:`, error.message) + resolve({ + success: false, + output: `${stdout}\n${stderr}\n${error.message}`.trim() + }) + } else { + console.log(`[hooks] ${hookName} hook completed in ${cwd}`) + resolve({ + success: true, + output: `${stdout}\n${stderr}`.trim() + }) + } + } + + // Why: Node's execFile timeout only signals wsl.exe; if no callback + // arrives, hook setup/archive must still unblock after HOOK_TIMEOUT. + const timeout = setTimeout(() => { + child?.kill() + finish(new Error(`Hook timed out after ${HOOK_TIMEOUT}ms.`)) + }, HOOK_TIMEOUT) + + try { + child = execFile( + 'wsl.exe', + ['-d', wslInfo.distro, '--', 'bash', '-c', bashCmd], + { + timeout: HOOK_TIMEOUT, + encoding: 'utf-8', + env: { ...process.env, ...wslEnv } + }, + (error, stdout, stderr) => { + finish(error ?? null, stdout, stderr) + } + ) + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))) + } }) } diff --git a/src/main/index.ts b/src/main/index.ts index d1d9c5ff7ad..ef10cfbbe6b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -52,13 +52,17 @@ import { patchPackagedProcessPath, shouldInstallManagedHooks } from './startup/configure-process' +import { maybeRedirectAppImageCliLaunch } from './startup/appimage-cli-redirect' import { startFirstWindowStartupServices } from './startup/first-window-startup-services' import { getDevInstanceIdentity } from './startup/dev-instance-identity' import { hydrateShellPath, mergePathSegments } from './startup/hydrate-shell-path' import { acquireSingleInstanceLock, - logSingleInstanceLockFailure + logSingleInstanceLockBypass, + logSingleInstanceLockFailure, + shouldBypassSingleInstanceLock } from './startup/single-instance-lock' +import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from './startup/startup-diagnostics' import { RateLimitService } from './rate-limits/service' import { getInitialClaudeRateLimitTarget } from './rate-limits/claude-rate-limit-target' import { getInitialCodexRateLimitTarget } from './rate-limits/codex-rate-limit-target' @@ -82,7 +86,8 @@ import { getPtyIdForPaneKey, registerPaneKeyTeardownListener, getLocalPtyProvider, - registerHeadlessPtyRuntime + registerHeadlessPtyRuntime, + isRendererPtyOutputPaused } from './ipc/pty' import { AgentBrowserBridge } from './browser/agent-browser-bridge' import { browserManager } from './browser/browser-manager' @@ -91,6 +96,7 @@ import { AutomationService } from './automations/service' import { AgentAwakeService } from './agent-awake-service' import { getCrashBreadcrumbSnapshot, + recordCoalescedCrashBreadcrumb, recordCrashBreadcrumb } from './crash-reporting/crash-breadcrumb-store' import { CrashReportStore } from './crash-reporting/crash-report-store' @@ -99,6 +105,7 @@ import { shouldRecoverRendererAfterProcessGone, type ExpectedTeardownScope } from './crash-reporting/process-gone-classification' +import { getProcessGoneDedupeKey, processGoneDedupe } from './crash-reporting/process-gone-dedupe' import { advanceSyntheticTitleSpinnerEntries, type SyntheticTitleSpinnerEntry @@ -106,6 +113,7 @@ import { import { shouldSendSyntheticTitleFrame } from './synthetic-title-visibility' import { isCrashReportReason } from '../shared/crash-reporting' import { KeybindingService } from './keybindings/keybinding-service' +import { applyElectronProxySettings } from './network/proxy-settings' let mainWindow: BrowserWindow | null = null /** Whether a manual app.quit() (Cmd+Q, etc.) is in progress. Shared with the @@ -133,7 +141,16 @@ let watcherShutdownDone = false let automations: AutomationService | null = null let keybindings: KeybindingService | null = null let expectedRendererReload: { webContentsId: number; until: number } | null = null +const AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS = 30_000 const isServeMode = process.argv.includes('--serve') +const appImageCliRedirect = maybeRedirectAppImageCliLaunch({ + isPackaged: app.isPackaged, + resourcesPath: process.resourcesPath, + execPath: process.execPath +}) +if (appImageCliRedirect.redirected) { + app.exit(appImageCliRedirect.status) +} // Why: the store/runtime singletons live here in index.ts; injecting them keeps // the rename orchestrator free of module-level state and unit-testable. @@ -210,6 +227,17 @@ configureDevUserDataPath(is.dev) // Why: CLI-shared Codex helpers cannot import Electron. Seed the resolved // app userData path once Electron has applied dev/e2e overrides. process.env.ORCA_USER_DATA_PATH ??= app.getPath('userData') +const startupDiagnosticsEnabled = isStartupDiagnosticsEnabled() +if (startupDiagnosticsEnabled) { + logStartupDiagnostic('before-single-instance-lock', { + version: app.getVersion(), + packaged: app.isPackaged, + platform: process.platform, + osRelease: os.release(), + userData: app.getPath('userData'), + e2eUserData: Boolean(process.env.ORCA_E2E_USER_DATA_DIR) + }) +} function focusExistingWindow(): void { // Why: the second-instance event fires on the *primary* Electron process @@ -261,6 +289,18 @@ function getExpectedTeardownScope(webContentsId?: number): ExpectedTeardownScope : 'none' } +function recordAgentStateCrashBreadcrumb(agentType: string, state: string): void { + // Why: hook pings can arrive many times per second while an agent works. + // Coalescing preserves crash-report room for renderer errors and memory + // samples instead of filling all 30 breadcrumbs with identical state pings. + recordCoalescedCrashBreadcrumb({ + name: 'agent_state_changed', + data: { agentType, state }, + coalesceKey: `agent:${agentType}:${state}`, + minIntervalMs: AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS + }) +} + // Why: the lock must be acquired AFTER configureDevUserDataPath — Electron // derives the lock identity from the `userData` path, so this placement lets // dev (`orca-dev`) and packaged (`orca`) runs lock in separate namespaces @@ -273,8 +313,28 @@ function getExpectedTeardownScope(webContentsId?: number): ExpectedTeardownScope // hook endpoint files are namespaced per dev instance when the hook server // starts below. Packaged Orca keeps the lock to protect against the corruption // documented in PR #1326 / issue #1312. +const bypassSingleInstanceLock = shouldBypassSingleInstanceLock({ + isDev: is.dev, + isServeMode +}) +if (bypassSingleInstanceLock) { + // Why: this is an explicit diagnostic escape hatch for macOS builds where + // Electron reports a false lock loss before any normal app logs exist. + logSingleInstanceLockBypass() +} const hasSingleInstanceLock = - is.dev && !isServeMode ? true : acquireSingleInstanceLock(app, focusExistingWindow) + is.dev && !isServeMode + ? true + : bypassSingleInstanceLock + ? true + : acquireSingleInstanceLock(app, focusExistingWindow) +if (startupDiagnosticsEnabled) { + logStartupDiagnostic('single-instance-lock-result', { + acquired: hasSingleInstanceLock, + bypassed: bypassSingleInstanceLock, + skippedForDev: is.dev && !isServeMode + }) +} if (!hasSingleInstanceLock) { // Why: if Electron returns a false negative here, packaged macOS launches // otherwise look like silent crashes. `open --stderr` can capture this line. @@ -554,10 +614,7 @@ function openMainWindow(): BrowserWindow { stateStartedAt, ...(orchestration ? { orchestration } : {}) }) - recordCrashBreadcrumb('agent_state_changed', { - agentType: payload.agentType ?? 'unknown', - state: payload.state - }) + recordAgentStateCrashBreadcrumb(payload.agentType ?? 'unknown', payload.state) // Why: cursor-agent's OSC title stays "Cursor Agent" for the whole turn, // and opencode's stays bare "OpenCode" — neither carries a working/idle // signal the title heuristic can read. Synthesize an OSC title update @@ -600,8 +657,6 @@ function sendOpenCrashReport(targetWindow?: BrowserWindow | null): void { webContents?.send('ui:openCrashReport') } -const recentCrashKeys = new Map() - function recordProcessGoneCrash( source: 'renderer' | 'child', processType: string, @@ -631,12 +686,10 @@ function recordProcessGoneCrash( }) return } - const key = `${processType}:${reason}:${exitCode ?? 'null'}` - const now = Date.now() - if (now - (recentCrashKeys.get(key) ?? 0) < 2_000) { + const key = getProcessGoneDedupeKey(processType, reason, exitCode) + if (!processGoneDedupe.shouldRecord(key)) { return } - recentCrashKeys.set(key, now) const span = startSpan('electron.process_gone', { attributes: { 'crash.source': source, @@ -870,6 +923,9 @@ function sendSyntheticTitle(ptyId: string, data: string, options: { force?: bool if (!mainWindow || mainWindow.isDestroyed()) { return } + if (options.force !== true && isRendererPtyOutputPaused(ptyId)) { + return + } // Why: repeated working-spinner frames are decorative and can arrive every // 80ms per agent. Final/permission frames are forced because they drive BEL. if ( @@ -993,14 +1049,16 @@ function driveSyntheticTitleFromHook( // decorated " ready" label rather than the bare native title — which // for cursor the detector deliberately treats as a no-op so cursor's own // per-turn re-emissions cannot clobber our synthesized state. The - // done/permission frames also carry a trailing BEL (0x07 outside of any OSC - // sequence) so the tab-level unread badge + notification dispatch in - // pty-connection lights up — those key off BEL, not the working→idle title - // transition. + // Permission frames also carry a trailing BEL (0x07 outside of any OSC + // sequence) so user-input-required states light up immediately. Done frames + // intentionally avoid the extra BEL: hook/status completion notifications + // own final-task attention and can cancel milestone noise during loops. stopSyntheticTitleSpinner(paneKey) - const label = - state === 'blocked' || state === 'waiting' ? profile.permissionLabel : profile.idleLabel - sendSyntheticTitle(ptyId, `\x1b]0;${label}\x07\x07`, { force: true }) + const needsUserInput = state === 'blocked' || state === 'waiting' + const label = needsUserInput ? profile.permissionLabel : profile.idleLabel + sendSyntheticTitle(ptyId, `\x1b]0;${label}\x07${needsUserInput ? '\x07' : ''}`, { + force: true + }) } app.whenReady().then(async () => { @@ -1013,6 +1071,13 @@ app.whenReady().then(async () => { } store = new Store() + try { + // Why: Dock/Launchpad launches do not inherit shell proxy env vars, so the + // persisted proxy must be applied before any app-owned network fetchers run. + await applyElectronProxySettings(store.getSettings()) + } catch { + console.warn('[proxy] Failed to apply network proxy settings') + } agentAwakeService = new AgentAwakeService() agentAwakeService.setEnabled(store.getSettings().keepComputerAwakeWhileAgentsRun) // Why: disk-hydrated status rows are UI continuity only. The service starts diff --git a/src/main/ipc/agent-hooks.test.ts b/src/main/ipc/agent-hooks.test.ts index c15d5425b9a..711cf3982ff 100644 --- a/src/main/ipc/agent-hooks.test.ts +++ b/src/main/ipc/agent-hooks.test.ts @@ -46,6 +46,9 @@ vi.mock('../agent-hooks/server', async () => { vi.mock('../claude/hook-service', () => ({ claudeHookService: { getStatus: vi.fn(() => ({ agent: 'claude', state: 'absent' })) } })) +vi.mock('../openclaude/hook-service', () => ({ + openClaudeHookService: { getStatus: vi.fn(() => ({ agent: 'openclaude', state: 'absent' })) } +})) vi.mock('../codex/hook-service', () => ({ codexHookService: { getStatus: vi.fn(() => ({ agent: 'codex', state: 'absent' })) } })) @@ -135,6 +138,17 @@ describe('agentHooks:ampStatus IPC', () => { }) }) +describe('agentHooks:openClaudeStatus IPC', () => { + it('returns OpenClaude hook installation status', async () => { + const { registerAgentHookHandlers } = await import('./agent-hooks') + registerAgentHookHandlers() + + const handler = handleHandlers.get('agentHooks:openClaudeStatus') + expect(handler).toBeDefined() + expect(handler!({})).toEqual({ agent: 'openclaude', state: 'absent' }) + }) +}) + describe('agentHooks:commandCodeStatus IPC', () => { it('returns Command Code hook installation status', async () => { const { registerAgentHookHandlers } = await import('./agent-hooks') diff --git a/src/main/ipc/agent-hooks.ts b/src/main/ipc/agent-hooks.ts index 2287db0ea4e..7637fd0d25d 100644 --- a/src/main/ipc/agent-hooks.ts +++ b/src/main/ipc/agent-hooks.ts @@ -21,6 +21,7 @@ import { commandCodeHookService } from '../command-code/hook-service' import { grokHookService } from '../grok/hook-service' import { copilotHookService } from '../copilot/hook-service' import { hermesHookService } from '../hermes/hook-service' +import { openClaudeHookService } from '../openclaude/hook-service' // Why: install/remove are intentionally not exposed to the renderer. Orca // auto-installs managed hooks at app startup (see src/main/index.ts), so a @@ -34,6 +35,7 @@ export function registerAgentHookHandlers(): void { // register-core-handlers.ts prevents re-entry, but decoupling from that guard // future-proofs this file. ipcMain.removeHandler('agentHooks:claudeStatus') + ipcMain.removeHandler('agentHooks:openClaudeStatus') ipcMain.removeHandler('agentHooks:codexStatus') ipcMain.removeHandler('agentHooks:geminiStatus') ipcMain.removeHandler('agentHooks:antigravityStatus') @@ -101,6 +103,19 @@ export function registerAgentHookHandlers(): void { } } }) + ipcMain.handle('agentHooks:openClaudeStatus', (): AgentHookInstallStatus => { + try { + return openClaudeHookService.getStatus() + } catch (err) { + return { + agent: 'openclaude', + state: 'error', + configPath: '', + managedHooksPresent: false, + detail: err instanceof Error ? err.message : String(err) + } + } + }) ipcMain.handle('agentHooks:codexStatus', (): AgentHookInstallStatus => { try { return codexHookService.getStatus() diff --git a/src/main/ipc/agent-trust.ts b/src/main/ipc/agent-trust.ts index b31913178d1..9e18d1c59cc 100644 --- a/src/main/ipc/agent-trust.ts +++ b/src/main/ipc/agent-trust.ts @@ -1,11 +1,11 @@ import { ipcMain } from 'electron' import { + type AgentTrustPreset, markCodexProjectTrusted, markCopilotFolderTrusted, markCursorWorkspaceTrusted } from '../agent-trust-presets' - -export type AgentTrustPreset = 'cursor' | 'copilot' | 'codex' +import { markRemoteAgentWorkspaceTrusted } from '../remote-agent-trust-presets' /** * Why: cursor-agent, GitHub Copilot CLI, and Codex gate first-launch in an @@ -20,12 +20,24 @@ export function registerAgentTrustHandlers(): void { ipcMain.removeHandler('agentTrust:markTrusted') ipcMain.handle( 'agentTrust:markTrusted', - async (_event, args: { preset: AgentTrustPreset; workspacePath: string }): Promise => { + async ( + _event, + args: { preset: AgentTrustPreset; workspacePath: string; connectionId?: string } + ): Promise => { if (!args || typeof args.workspacePath !== 'string' || !args.workspacePath) { return } try { - if (args.preset === 'cursor') { + const connectionId = typeof args.connectionId === 'string' ? args.connectionId.trim() : '' + if (connectionId) { + // Why: SSH-launched agents read trust artifacts from the remote + // user's home, not from this desktop process. + await markRemoteAgentWorkspaceTrusted({ + preset: args.preset, + connectionId, + workspacePath: args.workspacePath + }) + } else if (args.preset === 'cursor') { markCursorWorkspaceTrusted(args.workspacePath) } else if (args.preset === 'copilot') { markCopilotFolderTrusted(args.workspacePath) diff --git a/src/main/ipc/app.test.ts b/src/main/ipc/app.test.ts index 1abd90b560e..125658acaf3 100644 --- a/src/main/ipc/app.test.ts +++ b/src/main/ipc/app.test.ts @@ -1,10 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { handlers, appExitMock, appQuitMock, appRelaunchMock } = vi.hoisted(() => ({ +const { handlers, appExitMock, appQuitMock, appRelaunchMock, execFileMock } = vi.hoisted(() => ({ handlers: new Map unknown>(), appExitMock: vi.fn(), appQuitMock: vi.fn(), - appRelaunchMock: vi.fn() + appRelaunchMock: vi.fn(), + execFileMock: vi.fn() +})) + +vi.mock('node:child_process', () => ({ + execFile: execFileMock })) vi.mock('electron', () => ({ @@ -35,16 +40,21 @@ vi.mock('@electron-toolkit/utils', () => ({ import { registerAppHandlers } from './app' describe('registerAppHandlers', () => { + const originalPlatform = process.platform + beforeEach(() => { vi.useFakeTimers() handlers.clear() appExitMock.mockReset() appQuitMock.mockReset() appRelaunchMock.mockReset() + execFileMock.mockReset() + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) }) afterEach(() => { vi.useRealTimers() + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) }) it('marks relaunch as expected shutdown before exiting', () => { @@ -80,4 +90,25 @@ describe('registerAppHandlers', () => { expect(appQuitMock).toHaveBeenCalledTimes(1) expect(appExitMock).not.toHaveBeenCalled() }) + + it('falls back when the macOS keyboard layout probe never reports completion', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + const killMock = vi.fn() + execFileMock.mockImplementation(() => ({ kill: killMock })) + registerAppHandlers({} as never) + + const handler = handlers.get('app:getKeyboardInputSourceId') + expect(handler).toBeDefined() + let settled = false + const resultPromise = Promise.resolve(handler?.(null)).then((result) => { + settled = true + return result + }) + + await vi.advanceTimersByTimeAsync(500) + + expect(settled).toBe(true) + await expect(resultPromise).resolves.toBeNull() + expect(killMock).toHaveBeenCalled() + }) }) diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index d24e737d6fc..c34e4b5c0a2 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -2,7 +2,6 @@ import { execFile } from 'node:child_process' import { existsSync } from 'node:fs' import path from 'node:path' import { pathToFileURL } from 'node:url' -import { promisify } from 'node:util' import { app, BrowserWindow, dialog, ipcMain, type IpcMainInvokeEvent } from 'electron' import { is } from '@electron-toolkit/utils' import type { AppIdentity } from '../../shared/app-identity' @@ -11,6 +10,7 @@ import type { Store } from '../persistence' import { getDevInstanceIdentity } from '../startup/dev-instance-identity' import { isPwshAvailable } from '../pwsh' import { isWslAvailable, listWslDistros } from '../wsl' +import { isGitBashAvailable } from '../git-bash' import { setUnreadDockBadgeCount } from '../dock/unread-badge' import { authorizeExternalPath } from './filesystem-auth' import { @@ -20,7 +20,7 @@ import { } from './floating-workspace-directory' import { isMarkdownDocumentName, markdownDocumentFromFilePath } from './markdown-documents' -const execFileAsync = promisify(execFile) +const KEYBOARD_INPUT_SOURCE_TIMEOUT_MS = 500 type RegisterAppHandlersOptions = { onBeforeRelaunch?: () => void @@ -100,6 +100,52 @@ function resolveDevFeatureWallAssetDir(): string { return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0] } +function readKeyboardInputSourceId(): Promise { + return new Promise((resolve, reject) => { + let settled = false + let child: ReturnType | undefined + const timer = setTimeout(() => { + if (settled) { + return + } + settled = true + child?.kill() + reject(new Error('Keyboard input source probe timed out')) + }, KEYBOARD_INPUT_SOURCE_TIMEOUT_MS) + + const settle = (callback: () => void): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + callback() + } + + // Why: execFile's timeout only signals `defaults`; if the callback + // never arrives, window-focus keyboard probes would remain pending. + try { + child = execFile( + '/usr/bin/defaults', + ['read', 'com.apple.HIToolbox', 'AppleCurrentKeyboardLayoutInputSourceID'], + // Why: short timeout so a wedged defaults binary (corporate-managed + // config, sandbox policy, ...) never holds the handle indefinitely. + // Fall through to the fingerprint on timeout. + { encoding: 'utf8', timeout: KEYBOARD_INPUT_SOURCE_TIMEOUT_MS }, + (error, stdout) => { + if (error) { + settle(() => reject(error)) + return + } + settle(() => resolve(String(stdout))) + } + ) + } catch (error) { + settle(() => reject(error)) + } + }) +} + export function registerAppHandlers(store: Store, options: RegisterAppHandlersOptions = {}): void { ipcMain.handle('app:getFeatureWallAssetBaseUrl', (): string => getFeatureWallAssetBaseUrl()) @@ -119,6 +165,7 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp ipcMain.handle('wsl:isAvailable', (): boolean => isWslAvailable()) ipcMain.handle('wsl:listDistros', (): string[] => listWslDistros()) ipcMain.handle('pwsh:isAvailable', (): boolean => isPwshAvailable()) + ipcMain.handle('gitBash:isAvailable', (): boolean => isGitBashAvailable()) // Why: ABC, Polish Pro, US Extended, ABC Extended, and every CJK Roman // IME all report a US-QWERTY base layer to navigator.keyboard.getLayoutMap() @@ -132,7 +179,7 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp // have no equivalent and return null so the fingerprint stays the only // signal. // - // Why `defaults read` (via execFileSync) and not systemPreferences + // Why `defaults read` and not systemPreferences // .getUserDefault: getUserDefault only reads from NSGlobalDomain and the // current app's own domain. The keyboard layout ID lives in the // `com.apple.HIToolbox` domain, which getUserDefault cannot reach — @@ -148,14 +195,7 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp // The probe re-runs on every window focus-in (see option-as-alt-probe.ts), // and a blocking execFileSync would briefly stall unrelated IPC each // time the user Alt-Tabbed back into the app. - const { stdout } = await execFileAsync( - '/usr/bin/defaults', - ['read', 'com.apple.HIToolbox', 'AppleCurrentKeyboardLayoutInputSourceID'], - // Why: short timeout so a wedged defaults binary (corporate-managed - // config, sandbox policy, …) never holds the handle indefinitely. - // Fall through to the fingerprint on timeout. - { encoding: 'utf8', timeout: 500 } - ) + const stdout = await readKeyboardInputSourceId() const trimmed = stdout.trim() return trimmed.length > 0 ? trimmed : null } catch { diff --git a/src/main/ipc/automations.ts b/src/main/ipc/automations.ts index ec3ddbcf712..7a523addec8 100644 --- a/src/main/ipc/automations.ts +++ b/src/main/ipc/automations.ts @@ -5,6 +5,7 @@ import type { Automation, AutomationCreateInput, AutomationDispatchResult, + AutomationPrecheckResult, ExternalAutomationCreateInput, ExternalAutomationActionInput, ExternalAutomationManager, @@ -66,6 +67,14 @@ export function registerAutomationHandlers(store: Store, service: AutomationServ 'automations:runNow', (_event, args: { id: string }): Promise => service.runNow(args.id) ) + ipcMain.handle( + 'automations:runPrecheck', + ( + _event, + args: { automationId: string; runId: string } + ): Promise => + service.runPrecheck(args.automationId, args.runId) + ) ipcMain.handle( 'automations:markDispatchResult', (_event, result: AutomationDispatchResult): Promise => diff --git a/src/main/ipc/browser.test.ts b/src/main/ipc/browser.test.ts index 7b89cc1da35..7ca96e305d6 100644 --- a/src/main/ipc/browser.test.ts +++ b/src/main/ipc/browser.test.ts @@ -57,7 +57,11 @@ vi.mock('../browser/browser-manager', () => ({ } })) -import { registerBrowserHandlers, setAgentBrowserBridgeRef } from './browser' +import { + registerBrowserHandlers, + setAgentBrowserBridgeRef, + waitForTabRegistration +} from './browser' describe('registerBrowserHandlers', () => { beforeEach(() => { @@ -165,6 +169,56 @@ describe('registerBrowserHandlers', () => { expect(onTabChangedMock).toHaveBeenCalledWith(4242, 'wt-browser') }) + it('resolves concurrent tab registration waiters for the same page', async () => { + vi.useFakeTimers() + try { + getGuestWebContentsIdMock.mockReturnValue(null) + const first = waitForTabRegistration('page-1', 1000) + const second = waitForTabRegistration('page-1', 1000) + const settled = Promise.allSettled([first, second]) + + registerBrowserHandlers() + + const registerHandler = handleMock.mock.calls.find( + ([channel]) => channel === 'browser:registerGuest' + )?.[1] as ( + event: { sender: Electron.WebContents }, + args: { + browserPageId: string + workspaceId: string + worktreeId: string + webContentsId: number + } + ) => boolean + + const result = registerHandler( + { + sender: { + id: 91, + isDestroyed: () => false, + getType: () => 'window', + getURL: () => 'file:///renderer/index.html' + } as Electron.WebContents + }, + { + browserPageId: 'page-1', + workspaceId: 'workspace-1', + worktreeId: 'worktree-1', + webContentsId: 123 + } + ) + + expect(result).toBe(true) + await vi.advanceTimersByTimeAsync(1001) + expect(await settled).toEqual([ + { status: 'fulfilled', value: undefined }, + { status: 'fulfilled', value: undefined } + ]) + } finally { + vi.useRealTimers() + } + }) + it('validates annotation viewport bridge requests before syncing to the guest', async () => { registerBrowserHandlers() diff --git a/src/main/ipc/browser.ts b/src/main/ipc/browser.ts index c640365d7ec..826a2eca84e 100644 --- a/src/main/ipc/browser.ts +++ b/src/main/ipc/browser.ts @@ -39,22 +39,32 @@ let agentBrowserBridgeRef: AgentBrowserBridge | null = null // Why: CLI-driven tab creation must wait until the renderer mounts the webview // and calls registerGuest, so the tab has a webContentsId and is operable by -// subsequent commands. This map holds one-shot resolvers keyed by browserPageId. -const pendingTabRegistrations = new Map void>() +// subsequent commands. Multiple commands can wait for the same page during +// startup, so keep all one-shot resolvers keyed by browserPageId. +const pendingTabRegistrations = new Map void>>() export function waitForTabRegistration(browserPageId: string, timeoutMs = 8_000): Promise { if (browserManager.getGuestWebContentsId(browserPageId) !== null) { return Promise.resolve() } return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - pendingTabRegistrations.delete(browserPageId) - reject(new Error('Tab registration timed out')) - }, timeoutMs) - pendingTabRegistrations.set(browserPageId, () => { + let registrationResolvers = pendingTabRegistrations.get(browserPageId) + if (!registrationResolvers) { + registrationResolvers = new Set() + pendingTabRegistrations.set(browserPageId, registrationResolvers) + } + const resolveRegistration = (): void => { clearTimeout(timer) resolve() - }) + } + const timer = setTimeout(() => { + registrationResolvers.delete(resolveRegistration) + if (registrationResolvers.size === 0) { + pendingTabRegistrations.delete(browserPageId) + } + reject(new Error('Tab registration timed out')) + }, timeoutMs) + registrationResolvers.add(resolveRegistration) }) } @@ -128,10 +138,12 @@ export function registerBrowserHandlers(): void { if (agentBrowserBridgeRef && previousWcId !== null && previousWcId !== args.webContentsId) { agentBrowserBridgeRef.onProcessSwap(args.browserPageId, args.webContentsId, previousWcId) } - const pendingResolve = pendingTabRegistrations.get(args.browserPageId) - if (pendingResolve) { + const pendingResolves = pendingTabRegistrations.get(args.browserPageId) + if (pendingResolves) { pendingTabRegistrations.delete(args.browserPageId) - pendingResolve() + for (const pendingResolve of pendingResolves) { + pendingResolve() + } } return true } diff --git a/src/main/ipc/crash-reporting.test.ts b/src/main/ipc/crash-reporting.test.ts index 6ea6c300a62..bea144731e2 100644 --- a/src/main/ipc/crash-reporting.test.ts +++ b/src/main/ipc/crash-reporting.test.ts @@ -1,18 +1,32 @@ +/* oxlint-disable max-lines -- Why: crash-reporting IPC handlers share one mocked ipcMain registry and store contract. */ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { CrashReportRecord } from '../../shared/crash-reporting' -const { handlers, clipboardWriteTextMock, submitFeedbackMock } = vi.hoisted(() => ({ +const { + handlers, + listeners, + clipboardWriteTextMock, + submitFeedbackMock, + recordCrashBreadcrumbMock +} = vi.hoisted(() => ({ handlers: new Map unknown>(), + listeners: new Map void>(), clipboardWriteTextMock: vi.fn(), - submitFeedbackMock: vi.fn() + submitFeedbackMock: vi.fn(), + recordCrashBreadcrumbMock: vi.fn() })) vi.mock('electron', () => ({ + app: { getVersion: () => '1.0.0-test' }, clipboard: { writeText: clipboardWriteTextMock }, ipcMain: { removeHandler: vi.fn((channel: string) => handlers.delete(channel)), handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => { handlers.set(channel, handler) + }), + removeAllListeners: vi.fn((channel: string) => listeners.delete(channel)), + on: vi.fn((channel: string, listener: (_event: unknown, args?: unknown) => void) => { + listeners.set(channel, listener) }) } })) @@ -21,7 +35,16 @@ vi.mock('./feedback', () => ({ submitFeedback: submitFeedbackMock })) -import { registerCrashReportingHandlers } from './crash-reporting' +vi.mock('../crash-reporting/crash-breadcrumb-store', () => ({ + getCrashBreadcrumbSnapshot: vi.fn(() => []), + recordCrashBreadcrumb: (...args: unknown[]) => recordCrashBreadcrumbMock(...args) +})) + +import { + _getCrashReportingStateSizesForTests, + _resetRendererErrorReportDedupeForTests, + registerCrashReportingHandlers +} from './crash-reporting' function report( status: CrashReportRecord['status'] = 'pending', @@ -48,9 +71,12 @@ function report( describe('registerCrashReportingHandlers', () => { beforeEach(() => { handlers.clear() + listeners.clear() clipboardWriteTextMock.mockReset() submitFeedbackMock.mockReset() + recordCrashBreadcrumbMock.mockReset() submitFeedbackMock.mockResolvedValue({ ok: true }) + _resetRendererErrorReportDedupeForTests() }) it('copies the latest pending diagnostic text to the clipboard', async () => { @@ -214,4 +240,212 @@ describe('registerCrashReportingHandlers', () => { }) expect(markSent).not.toHaveBeenCalled() }) + + it('bounds submitted report ids by evicting the oldest successful sends', async () => { + registerCrashReportingHandlers({ + getById: vi.fn(async (reportId: string) => report('pending', reportId)), + dismiss: vi.fn(), + markSent: vi.fn(async (reportId: string) => report('sent', reportId)), + markDismissedSent: vi.fn(), + listRecent: vi.fn(async () => []), + record: vi.fn(), + formatDiagnosticText: vi.fn() + } as never) + + for (let i = 0; i < 260; i += 1) { + await handlers.get('crashReports:submit')?.(null, { + reportId: `crash-${i}`, + submitAnonymously: true, + githubLogin: null, + githubEmail: null + }) + } + + expect(_getCrashReportingStateSizesForTests().submittedReportIds).toBe(256) + }) + + it('records a deduped renderer error boundary report through the crash store', async () => { + const recorded = report('pending', 'react-render') + const recordMock = vi.fn(async () => recorded) + registerCrashReportingHandlers({ + getById: vi.fn(), + dismiss: vi.fn(), + markSent: vi.fn(), + markDismissedSent: vi.fn(), + listRecent: vi.fn(async () => []), + record: recordMock, + formatDiagnosticText: vi.fn() + } as never) + + const args = { + boundaryId: 'terminal.workbench', + surface: 'terminal-workbench', + errorName: 'TypeError', + errorMessage: 'Cannot read /Users/alice/project/token=abc123', + errorStack: 'TypeError: nope\n at /Users/alice/project/App.tsx:12:1', + componentStack: 'at Terminal\nat App', + activeView: 'terminal', + activeModal: 'none', + activeTabType: 'terminal', + activeRightSidebarTab: 'source-control', + hasActiveWorktree: true + } + + await expect(handlers.get('crashReports:recordRendererError')?.(null, args)).resolves.toEqual({ + ok: true, + report: recorded, + deduped: false + }) + await expect(handlers.get('crashReports:recordRendererError')?.(null, args)).resolves.toEqual({ + ok: true, + report: null, + deduped: true + }) + + expect(recordMock).toHaveBeenCalledTimes(1) + expect(recordMock).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'renderer', + processType: 'react-render', + reason: 'react-error-boundary', + exitCode: null, + appVersion: '1.0.0-test', + details: expect.objectContaining({ + boundary_id: 'terminal.workbench', + surface: 'terminal-workbench', + error_name: 'TypeError', + error_message: 'Cannot read /Users/alice/project/token=abc123', + active_view: 'terminal', + active_modal: 'none', + active_tab_type: 'terminal', + right_sidebar_tab: 'source-control', + has_active_worktree: true + }) + }) + ) + }) + + it('rejects invalid renderer error boundary surfaces', async () => { + const recordMock = vi.fn() + registerCrashReportingHandlers({ + getById: vi.fn(), + dismiss: vi.fn(), + markSent: vi.fn(), + markDismissedSent: vi.fn(), + listRecent: vi.fn(async () => []), + record: recordMock, + formatDiagnosticText: vi.fn() + } as never) + + await expect( + handlers.get('crashReports:recordRendererError')?.(null, { + boundaryId: 'terminal.workbench', + surface: 'unknown', + errorName: 'TypeError', + errorMessage: 'nope' + }) + ).resolves.toEqual({ ok: false, error: 'Invalid renderer error report.' }) + expect(recordMock).not.toHaveBeenCalled() + }) + + it('bounds renderer error dedupe keys by evicting the oldest unique reports', async () => { + let recordCount = 0 + const recordMock = vi.fn(async () => report('pending', `react-render-${recordCount++}`)) + registerCrashReportingHandlers({ + getById: vi.fn(), + dismiss: vi.fn(), + markSent: vi.fn(), + markDismissedSent: vi.fn(), + listRecent: vi.fn(async () => []), + record: recordMock, + formatDiagnosticText: vi.fn() + } as never) + + const baseArgs = { + boundaryId: 'terminal.workbench', + surface: 'terminal-workbench', + errorName: 'TypeError', + componentStack: 'at Terminal' + } + + for (let i = 0; i < 260; i += 1) { + await handlers.get('crashReports:recordRendererError')?.(null, { + ...baseArgs, + errorMessage: `unique-render-error-${i}` + }) + } + + await expect( + handlers.get('crashReports:recordRendererError')?.(null, { + ...baseArgs, + errorMessage: 'unique-render-error-0' + }) + ).resolves.toEqual({ + ok: true, + report: expect.objectContaining({ id: 'react-render-260' }), + deduped: false + }) + await expect( + handlers.get('crashReports:recordRendererError')?.(null, { + ...baseArgs, + errorMessage: 'unique-render-error-259' + }) + ).resolves.toEqual({ + ok: true, + report: null, + deduped: true + }) + + expect(recordMock).toHaveBeenCalledTimes(261) + }) + + it('records sanitized renderer breadcrumbs', () => { + registerCrashReportingHandlers({ + getLatestPending: vi.fn(), + getById: vi.fn(), + dismiss: vi.fn(), + markSent: vi.fn(), + listRecent: vi.fn(), + record: vi.fn(), + formatDiagnosticText: vi.fn() + } as never) + + listeners.get('crashReports:recordBreadcrumb')?.(null, { + name: 'renderer_error', + data: { + message: 'boom', + count: 2, + ok: true, + empty: null, + badNumber: Number.POSITIVE_INFINITY, + object: { ignored: true } + } + }) + + expect(recordCrashBreadcrumbMock).toHaveBeenCalledWith('renderer_error', { + message: 'boom', + count: 2, + ok: true, + empty: null + }) + }) + + it('ignores renderer breadcrumbs without a string name', () => { + registerCrashReportingHandlers({ + getLatestPending: vi.fn(), + getById: vi.fn(), + dismiss: vi.fn(), + markSent: vi.fn(), + listRecent: vi.fn(), + record: vi.fn(), + formatDiagnosticText: vi.fn() + } as never) + + listeners.get('crashReports:recordBreadcrumb')?.(null, { + name: 123, + data: { message: 'boom' } + }) + + expect(recordCrashBreadcrumbMock).not.toHaveBeenCalled() + }) }) diff --git a/src/main/ipc/crash-reporting.ts b/src/main/ipc/crash-reporting.ts index 9de812035e8..92cea265301 100644 --- a/src/main/ipc/crash-reporting.ts +++ b/src/main/ipc/crash-reporting.ts @@ -1,14 +1,221 @@ -import { clipboard, ipcMain } from 'electron' +/* oxlint-disable max-lines -- Why: crash-reporting IPC handlers share one + dedupe/submission state machine and one crash-store contract. */ +import os from 'node:os' +import { app, clipboard, ipcMain } from 'electron' import { + type CrashReportBreadcrumbData, formatCrashReportText, + type ReactErrorBoundaryReportArgs, + type ReactErrorBoundaryReportResult, type CrashReportSubmitArgs, type CrashReportSubmitResult } from '../../shared/crash-reporting' import { submitFeedback } from './feedback' import type { CrashReportStore } from '../crash-reporting/crash-report-store' +import { + getCrashBreadcrumbSnapshot, + recordCrashBreadcrumb +} from '../crash-reporting/crash-breadcrumb-store' const inFlightSubmissions = new Set() const submittedReportIds = new Set() +const recentRendererErrorReportKeys = new Map() + +const RENDERER_ERROR_DEDUPE_MS = 10 * 60 * 1000 +const MAX_RENDERER_ERROR_KEY_AGE_MS = RENDERER_ERROR_DEDUPE_MS * 2 +const MAX_RECENT_RENDERER_ERROR_REPORT_KEYS = 256 +const MAX_SUBMITTED_REPORT_IDS = 256 + +const REACT_ERROR_BOUNDARY_SURFACES = new Set([ + 'app-root', + 'web-root', + 'workspace-shell', + 'sidebar', + 'terminal-workbench', + 'right-sidebar', + 'page', + 'modal', + 'overlay', + 'rich-markdown-editor' +]) + +function stringField(value: unknown, maxLength: number): string | undefined { + if (typeof value !== 'string') { + return undefined + } + const trimmed = value.trim() + if (!trimmed) { + return undefined + } + return trimmed.length > maxLength ? trimmed.slice(0, maxLength) : trimmed +} + +function nullableStringField(value: unknown, maxLength: number): string | null | undefined { + if (value === null) { + return null + } + return stringField(value, maxLength) +} + +function normalizeRendererErrorReportArgs(args: unknown): ReactErrorBoundaryReportArgs | null { + if (!args || typeof args !== 'object') { + return null + } + const record = args as Record + const boundaryId = stringField(record.boundaryId, 120) + const surface = stringField(record.surface, 80) + const errorName = stringField(record.errorName, 120) ?? 'Error' + const errorMessage = stringField(record.errorMessage, 1_000) ?? 'Unknown render error' + if ( + !boundaryId || + !surface || + !REACT_ERROR_BOUNDARY_SURFACES.has(surface as ReactErrorBoundaryReportArgs['surface']) + ) { + return null + } + + return { + boundaryId, + surface: surface as ReactErrorBoundaryReportArgs['surface'], + errorName, + errorMessage, + ...(stringField(record.errorStack, 8_000) + ? { errorStack: stringField(record.errorStack, 8_000) } + : {}), + ...(stringField(record.componentStack, 8_000) + ? { componentStack: stringField(record.componentStack, 8_000) } + : {}), + ...(stringField(record.activeView, 80) + ? { activeView: stringField(record.activeView, 80) } + : {}), + ...(nullableStringField(record.activeModal, 80) !== undefined + ? { activeModal: nullableStringField(record.activeModal, 80) ?? null } + : {}), + ...(stringField(record.activeTabType, 80) + ? { activeTabType: stringField(record.activeTabType, 80) } + : {}), + ...(stringField(record.activeRightSidebarTab, 80) + ? { activeRightSidebarTab: stringField(record.activeRightSidebarTab, 80) } + : {}), + ...(typeof record.hasActiveWorktree === 'boolean' + ? { hasActiveWorktree: record.hasActiveWorktree } + : {}) + } +} + +function pruneRendererErrorReportKeys(now: number): void { + for (const [key, seenAt] of recentRendererErrorReportKeys) { + if (now - seenAt > MAX_RENDERER_ERROR_KEY_AGE_MS) { + recentRendererErrorReportKeys.delete(key) + } + } + while (recentRendererErrorReportKeys.size > MAX_RECENT_RENDERER_ERROR_REPORT_KEYS) { + const oldestKey = recentRendererErrorReportKeys.keys().next().value + if (oldestKey === undefined) { + break + } + recentRendererErrorReportKeys.delete(oldestKey) + } +} + +function getRendererErrorReportKey(args: ReactErrorBoundaryReportArgs): string { + return JSON.stringify({ + boundaryId: args.boundaryId, + surface: args.surface, + errorName: args.errorName, + errorMessage: args.errorMessage, + componentStack: args.componentStack + }).slice(0, 12_000) +} + +function rememberSubmittedReportId(reportId: string): void { + // Why: report ids are IPC input. Keep duplicate-send suppression useful for + // recent reports without retaining every id a broken renderer can vary. + submittedReportIds.delete(reportId) + submittedReportIds.add(reportId) + while (submittedReportIds.size > MAX_SUBMITTED_REPORT_IDS) { + const oldestId = submittedReportIds.keys().next().value + if (oldestId === undefined) { + break + } + submittedReportIds.delete(oldestId) + } +} + +async function recordRendererErrorReport( + store: CrashReportStore, + args: unknown +): Promise { + const normalized = normalizeRendererErrorReportArgs(args) + if (!normalized) { + return { ok: false, error: 'Invalid renderer error report.' } + } + + const now = Date.now() + pruneRendererErrorReportKeys(now) + const key = getRendererErrorReportKey(normalized) + if (now - (recentRendererErrorReportKeys.get(key) ?? 0) < RENDERER_ERROR_DEDUPE_MS) { + return { ok: true, report: null, deduped: true } + } + recentRendererErrorReportKeys.set(key, now) + // Why: renderer error reports are IPC input. A broken renderer can vary the + // component stack/message inside the age window, so bound the main-side + // dedupe map by count as well as time. + pruneRendererErrorReportKeys(now) + + const report = await store.record({ + source: 'renderer', + processType: 'react-render', + reason: 'react-error-boundary', + exitCode: null, + appVersion: app.getVersion(), + platform: process.platform, + osRelease: os.release(), + arch: process.arch, + electronVersion: process.versions.electron ?? 'unknown', + chromeVersion: process.versions.chrome ?? 'unknown', + details: { + boundary_id: normalized.boundaryId, + surface: normalized.surface, + error_name: normalized.errorName, + error_message: normalized.errorMessage, + ...(normalized.errorStack ? { error_stack: normalized.errorStack } : {}), + ...(normalized.componentStack ? { component_stack: normalized.componentStack } : {}), + ...(normalized.activeView ? { active_view: normalized.activeView } : {}), + ...(normalized.activeModal !== undefined ? { active_modal: normalized.activeModal } : {}), + ...(normalized.activeTabType ? { active_tab_type: normalized.activeTabType } : {}), + ...(normalized.activeRightSidebarTab + ? { right_sidebar_tab: normalized.activeRightSidebarTab } + : {}), + ...(normalized.hasActiveWorktree !== undefined + ? { has_active_worktree: normalized.hasActiveWorktree } + : {}) + }, + // Why: React render failures are recoverable only because a boundary + // caught them; persist the same recent app breadcrumbs as native crashes. + breadcrumbs: getCrashBreadcrumbSnapshot() + }) + + return { ok: true, report, deduped: false } +} + +export function _resetRendererErrorReportDedupeForTests(): void { + recentRendererErrorReportKeys.clear() + submittedReportIds.clear() + inFlightSubmissions.clear() +} + +export function _getCrashReportingStateSizesForTests(): { + submittedReportIds: number + inFlightSubmissions: number + recentRendererErrorReportKeys: number +} { + return { + submittedReportIds: submittedReportIds.size, + inFlightSubmissions: inFlightSubmissions.size, + recentRendererErrorReportKeys: recentRendererErrorReportKeys.size + } +} async function getLatestPendingReport( store: CrashReportStore @@ -33,6 +240,21 @@ async function getLatestSendableReport( ) } +function sanitizeRendererBreadcrumbData(value: unknown): CrashReportBreadcrumbData | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined + } + const sanitized: CrashReportBreadcrumbData = {} + for (const [key, entry] of Object.entries(value)) { + if (typeof entry === 'string' || typeof entry === 'boolean' || entry === null) { + sanitized[key] = entry + } else if (typeof entry === 'number' && Number.isFinite(entry)) { + sanitized[key] = entry + } + } + return Object.keys(sanitized).length > 0 ? sanitized : undefined +} + export function registerCrashReportingHandlers(store: CrashReportStore): void { ipcMain.removeHandler('crashReports:getLatestPending') ipcMain.handle('crashReports:getLatestPending', () => getLatestPendingReport(store)) @@ -52,6 +274,17 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void { return store.dismiss(args.reportId) }) + ipcMain.removeAllListeners('crashReports:recordBreadcrumb') + ipcMain.on( + 'crashReports:recordBreadcrumb', + (_event, args?: { name?: unknown; data?: unknown }) => { + if (!args || typeof args.name !== 'string') { + return + } + recordCrashBreadcrumb(args.name, sanitizeRendererBreadcrumbData(args.data)) + } + ) + ipcMain.removeHandler('crashReports:copyLatestDiagnostics') ipcMain.handle( 'crashReports:copyLatestDiagnostics', @@ -67,6 +300,16 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void { } ) + ipcMain.removeHandler('crashReports:recordRendererError') + ipcMain.handle('crashReports:recordRendererError', async (_event, args: unknown) => { + try { + return await recordRendererErrorReport(store, args) + } catch (error) { + console.error('[crash-reporting] Failed to record renderer error report:', error) + return { ok: false, error: 'Failed to record renderer error report.' } + } + }) + ipcMain.removeHandler('crashReports:submit') ipcMain.handle( 'crashReports:submit', @@ -108,7 +351,7 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void { if (!result.ok) { return { ...result, report } } - submittedReportIds.add(report.id) + rememberSubmittedReportId(report.id) if (report.status === 'dismissed') { try { // Why: startup prompts are dismissed before the user can send from diff --git a/src/main/ipc/developer-permissions.test.ts b/src/main/ipc/developer-permissions.test.ts index d62c54efda2..ccb119d0e5c 100644 --- a/src/main/ipc/developer-permissions.test.ts +++ b/src/main/ipc/developer-permissions.test.ts @@ -6,6 +6,7 @@ const { askForMediaAccessMock, getMediaAccessStatusMock, isTrustedAccessibilityClientMock, + execFileMock, createSocketMock, socketMock, socketState @@ -27,6 +28,7 @@ const { askForMediaAccessMock: vi.fn(), getMediaAccessStatusMock: vi.fn(), isTrustedAccessibilityClientMock: vi.fn(), + execFileMock: vi.fn(), createSocketMock: vi.fn(() => socketMock), socketMock, socketState @@ -53,6 +55,10 @@ vi.mock('node:dgram', () => ({ } })) +vi.mock('node:child_process', () => ({ + execFile: execFileMock +})) + import { registerDeveloperPermissionHandlers } from './developer-permissions' describe('registerDeveloperPermissionHandlers', () => { @@ -66,6 +72,15 @@ describe('registerDeveloperPermissionHandlers', () => { askForMediaAccessMock.mockReset() getMediaAccessStatusMock.mockReset() isTrustedAccessibilityClientMock.mockReset() + execFileMock.mockReset() + execFileMock.mockImplementation((...args: unknown[]) => { + const callback = args.at(-1) + if (typeof callback === 'function') { + const execCallback = callback as () => void + execCallback() + } + return { kill: vi.fn() } + }) createSocketMock.mockClear() socketState.sendCallback = null socketMock.on.mockClear() @@ -122,4 +137,27 @@ describe('registerDeveloperPermissionHandlers', () => { expect(socketMock.removeListener).toHaveBeenCalledWith('error', expect.any(Function)) expect(socketMock.close).toHaveBeenCalledTimes(1) }) + + it('settles the automation prompt when osascript hangs', async () => { + const killMock = vi.fn() + execFileMock.mockImplementation(() => ({ kill: killMock })) + registerDeveloperPermissionHandlers() + + const result = getRequestHandler()({}, { id: 'automation' }) + let settled = false + void result.finally(() => { + settled = true + }) + + await vi.advanceTimersByTimeAsync(3_000) + await Promise.resolve() + + expect(settled).toBe(true) + await expect(result).resolves.toEqual({ + id: 'automation', + status: 'unknown', + openedSystemSettings: false + }) + expect(killMock).toHaveBeenCalled() + }) }) diff --git a/src/main/ipc/developer-permissions.ts b/src/main/ipc/developer-permissions.ts index 954ba61687c..d0d079bd093 100644 --- a/src/main/ipc/developer-permissions.ts +++ b/src/main/ipc/developer-permissions.ts @@ -32,6 +32,7 @@ const DEVELOPER_PERMISSION_IDS: DeveloperPermissionId[] = [ 'usb', 'bluetooth' ] +const APPLE_EVENTS_PROMPT_TIMEOUT_MS = 3_000 function unsupportedOffMac(): DeveloperPermissionStatus | null { return process.platform === 'darwin' ? null : 'unsupported' @@ -86,12 +87,38 @@ async function openPrivacyPane(id: DeveloperPermissionId): Promise { function triggerAppleEventsPrompt(): Promise { return new Promise((resolve) => { - execFile( - 'osascript', - ['-e', 'tell application "System Events" to return 1'], - { timeout: 3000 }, - () => resolve() - ) + let child: ReturnType | null = null + let settled = false + + const finish = (): void => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + resolve() + } + + // Why: this request only nudges macOS to show the Automation prompt; a + // stuck osascript process should not keep the permission IPC pending. + const timeout = setTimeout(() => { + child?.kill() + finish() + }, APPLE_EVENTS_PROMPT_TIMEOUT_MS) + if (typeof timeout.unref === 'function') { + timeout.unref() + } + + try { + child = execFile( + 'osascript', + ['-e', 'tell application "System Events" to return 1'], + { timeout: APPLE_EVENTS_PROMPT_TIMEOUT_MS }, + finish + ) + } catch { + finish() + } }) } diff --git a/src/main/ipc/diagnostics.test.ts b/src/main/ipc/diagnostics.test.ts index 3d05ffa751f..3531af823f4 100644 --- a/src/main/ipc/diagnostics.test.ts +++ b/src/main/ipc/diagnostics.test.ts @@ -268,6 +268,23 @@ describe('diagnostics IPC handlers', () => { await expect(upload({}, bundle.bundleSubmissionId)).rejects.toThrow(/expired/) }) + it('expires retained bundle previews without another diagnostics call', async () => { + vi.useFakeTimers() + try { + const bundle = makeBundle({ bundleSubmissionId: 'bundleabcdefghijklmnop' }) + collectDiagnosticBundleMock.mockReturnValue(bundle) + const collect = handlers.get('diagnostics:collectBundle')! + const upload = handlers.get('diagnostics:uploadBundle')! + + await collect({}, 30) + await vi.advanceTimersByTimeAsync(15 * 60 * 1000 + 1) + + await expect(upload({}, bundle.bundleSubmissionId)).rejects.toThrow(/expired/) + } finally { + vi.useRealTimers() + } + }) + it('clears retained previews when local traces are cleared', async () => { const bundle = makeBundle({ bundleSubmissionId: 'bundleabcdefghijklmnop' }) collectDiagnosticBundleMock.mockReturnValue(bundle) diff --git a/src/main/ipc/diagnostics.ts b/src/main/ipc/diagnostics.ts index 2b8c6de829b..c083714e8f7 100644 --- a/src/main/ipc/diagnostics.ts +++ b/src/main/ipc/diagnostics.ts @@ -46,6 +46,7 @@ type PendingBundle = { bundle: CollectedBundle readonly createdAtMs: number readonly previewFilePath: string + ttlTimer: ReturnType previewOpened: boolean } @@ -104,8 +105,7 @@ function resolveOrcaChannel(): 'stable' | 'rc' | 'dev' { function prunePendingBundles(now = Date.now()): void { for (const [id, pending] of pendingBundles) { if (now - pending.createdAtMs > PENDING_BUNDLE_TTL_MS) { - deletePreviewFile(pending.previewFilePath) - pendingBundles.delete(id) + deletePendingBundle(id) } } while (pendingBundles.size > MAX_PENDING_BUNDLES) { @@ -113,25 +113,35 @@ function prunePendingBundles(now = Date.now()): void { if (!oldest) { break } - const pending = pendingBundles.get(oldest) - if (pending) { - deletePreviewFile(pending.previewFilePath) - pendingBundles.delete(oldest) - } + deletePendingBundle(oldest) } } function rememberBundle(bundle: CollectedBundle): void { + deletePendingBundle(bundle.bundleSubmissionId) const previewFilePath = writeBundlePreviewFile(bundle) pendingBundles.set(bundle.bundleSubmissionId, { bundle, createdAtMs: Date.now(), previewFilePath, + // Why: diagnostics previews retain redacted payload bytes in main; the + // documented TTL must expire even if the renderer never makes another call. + ttlTimer: schedulePendingBundleExpiry(bundle.bundleSubmissionId), previewOpened: false }) prunePendingBundles() } +function schedulePendingBundleExpiry(bundleSubmissionId: string): ReturnType { + const timer = setTimeout(() => { + deletePendingBundle(bundleSubmissionId) + }, PENDING_BUNDLE_TTL_MS) + if (typeof timer === 'object' && 'unref' in timer) { + timer.unref() + } + return timer +} + function toBundlePreview(bundle: CollectedBundle): DiagnosticsBundlePreview { return { bundleSubmissionId: bundle.bundleSubmissionId, @@ -185,8 +195,13 @@ function discardPendingBundle(bundleSubmissionId: unknown): void { ) { throw new Error('bundleSubmissionId has invalid format') } + deletePendingBundle(bundleSubmissionId) +} + +function deletePendingBundle(bundleSubmissionId: string): void { const pending = pendingBundles.get(bundleSubmissionId) if (pending) { + clearTimeout(pending.ttlTimer) deletePreviewFile(pending.previewFilePath) pendingBundles.delete(bundleSubmissionId) } @@ -221,10 +236,9 @@ function deletePreviewFile(filePath: string): void { } function discardAllPendingBundles(): void { - for (const pending of pendingBundles.values()) { - deletePreviewFile(pending.previewFilePath) + for (const bundleSubmissionId of Array.from(pendingBundles.keys())) { + deletePendingBundle(bundleSubmissionId) } - pendingBundles.clear() } function isTicketId(value: unknown): value is string { @@ -331,9 +345,8 @@ export function registerDiagnosticsHandlers(): void { }) const uploadedPending = pendingBundles.get(bundle.bundleSubmissionId) if (uploadedPending) { - deletePreviewFile(uploadedPending.previewFilePath) + deletePendingBundle(bundle.bundleSubmissionId) } - pendingBundles.delete(bundle.bundleSubmissionId) return result } ) diff --git a/src/main/ipc/dropped-path-resolution.test.ts b/src/main/ipc/dropped-path-resolution.test.ts new file mode 100644 index 00000000000..2d5c16c37f1 --- /dev/null +++ b/src/main/ipc/dropped-path-resolution.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { resolveLocalDroppedPathsForAgent } from './dropped-path-resolution' + +describe('resolveLocalDroppedPathsForAgent', () => { + it('translates dropped Windows paths for local WSL worktrees', () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + expect( + resolveLocalDroppedPathsForAgent( + [ + 'C:\\Users\\alice\\Desktop\\notes.txt', + '\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo\\README.md' + ], + '\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo' + ) + ).toEqual(['/mnt/c/Users/alice/Desktop/notes.txt', '/home/alice/repo/README.md']) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('leaves dropped paths unchanged for non-WSL worktrees', () => { + const paths = ['C:\\Users\\alice\\Desktop\\notes.txt'] + + expect(resolveLocalDroppedPathsForAgent(paths, 'C:\\Users\\alice\\repo')).toBe(paths) + }) +}) diff --git a/src/main/ipc/dropped-path-resolution.ts b/src/main/ipc/dropped-path-resolution.ts new file mode 100644 index 00000000000..5ef94ca13de --- /dev/null +++ b/src/main/ipc/dropped-path-resolution.ts @@ -0,0 +1,7 @@ +import { parseWslPath, toLinuxPath } from '../wsl' + +export function resolveLocalDroppedPathsForAgent(paths: string[], worktreePath: string): string[] { + // Why: a local WSL PTY runs inside Linux, so Windows drop paths must be + // rewritten to paths the shell and agent can read. + return parseWslPath(worktreePath) ? paths.map((droppedPath) => toLinuxPath(droppedPath)) : paths +} diff --git a/src/main/ipc/feedback.test.ts b/src/main/ipc/feedback.test.ts index ebc86399fdd..47543e0dbe0 100644 --- a/src/main/ipc/feedback.test.ts +++ b/src/main/ipc/feedback.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { fetchMock, handlers } = vi.hoisted(() => ({ fetchMock: vi.fn(), @@ -29,11 +29,16 @@ function postedBody(): Record { describe('submitFeedback', () => { beforeEach(() => { + vi.useRealTimers() handlers.clear() fetchMock.mockReset() fetchMock.mockResolvedValue(okResponse()) }) + afterEach(() => { + vi.useRealTimers() + }) + it('strips GitHub identity and anonymous contact fields when submitted anonymously', async () => { const anonymousArgs = { feedback: 'private bug report', @@ -94,6 +99,56 @@ describe('submitFeedback', () => { }) }) + it('falls back when the primary feedback request stalls', async () => { + vi.useFakeTimers() + fetchMock.mockImplementation((url: string, init?: RequestInit) => { + if (url.includes('api.onorca.dev')) { + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(new Error('request aborted'))) + }) + } + return Promise.resolve(okResponse()) + }) + + const result = submitFeedback({ + feedback: 'stalled primary', + submitAnonymously: false, + githubLogin: 'trusted-user', + githubEmail: 'trusted@example.com' + }) + await vi.advanceTimersByTimeAsync(10_000) + + await expect(Promise.race([result, Promise.resolve('pending')])).resolves.toEqual({ ok: true }) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('does not retry the fallback when the fallback fails after a primary server error', async () => { + vi.useFakeTimers() + fetchMock.mockImplementation((url: string, init?: RequestInit) => { + if (url.includes('api.onorca.dev')) { + return Promise.resolve({ ok: false, status: 500 } as Response) + } + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(new Error('fallback aborted'))) + }) + }) + + const result = submitFeedback({ + feedback: 'primary 500 and fallback stalled', + submitAnonymously: false, + githubLogin: 'trusted-user', + githubEmail: 'trusted@example.com' + }) + await vi.advanceTimersByTimeAsync(10_000) + + await expect(Promise.race([result, Promise.resolve('pending')])).resolves.toEqual({ + ok: false, + status: null, + error: 'fallback aborted' + }) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + it('forces renderer IPC submissions onto the feedback lane', async () => { registerFeedbackHandlers() await handlers.get('feedback:submit')?.(null, { diff --git a/src/main/ipc/feedback.ts b/src/main/ipc/feedback.ts index f062b5b650f..a207dd3029f 100644 --- a/src/main/ipc/feedback.ts +++ b/src/main/ipc/feedback.ts @@ -8,6 +8,7 @@ import { app, ipcMain, net } from 'electron' // same pattern used by updater-changelog.ts and updater-nudge.ts. const FEEDBACK_API_URL = 'https://api.onorca.dev/v1/feedback' const FEEDBACK_API_FALLBACK_URL = 'https://www.onorca.dev/v1/feedback' +const FEEDBACK_REQUEST_TIMEOUT_MS = 10_000 export type FeedbackSubmissionType = 'feedback' | 'crash' @@ -61,11 +62,47 @@ function buildSubmitBody(args: InternalFeedbackSubmitArgs): FeedbackSubmitBody { } async function postFeedback(url: string, body: FeedbackSubmitBody): Promise { - return net.fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body) - }) + const controller = new AbortController() + // Why: a silent feedback endpoint should not leave IPC or crash-report + // submission flows pending forever. + const timeout = setTimeout(() => controller.abort(), FEEDBACK_REQUEST_TIMEOUT_MS) + try { + return await net.fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: controller.signal + }) + } finally { + clearTimeout(timeout) + } +} + +function messageFromError(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +async function submitFallbackFeedback( + body: FeedbackSubmitBody, + primaryError?: unknown +): Promise { + try { + const fallback = await postFeedback(FEEDBACK_API_FALLBACK_URL, body) + if (fallback.ok) { + return { ok: true } + } + return { ok: false, status: fallback.status, error: `status ${fallback.status}` } + } catch (fallbackError) { + const message = messageFromError(fallbackError) + if (primaryError === undefined) { + return { ok: false, status: null, error: message } + } + return { + ok: false, + status: null, + error: `${messageFromError(primaryError)}; fallback: ${message}` + } + } } export async function submitFeedback( @@ -81,28 +118,14 @@ export async function submitFeedback( // 404/5xx-style results and network errors — don't mask real 4xx responses // from a healthy host. if (res.status === 404 || res.status >= 500) { - const fallback = await postFeedback(FEEDBACK_API_FALLBACK_URL, body) - if (fallback.ok) { - return { ok: true } - } - return { ok: false, status: fallback.status, error: `status ${fallback.status}` } + return submitFallbackFeedback(body) } return { ok: false, status: res.status, error: `status ${res.status}` } } catch (error) { // Why: falling back on any network-level failure preserves the prior // behavior where DNS/connect failures on the primary host transparently // try the website-hosted versioned endpoint. - try { - const fallback = await postFeedback(FEEDBACK_API_FALLBACK_URL, body) - if (fallback.ok) { - return { ok: true } - } - return { ok: false, status: fallback.status, error: `status ${fallback.status}` } - } catch (fallbackError) { - const message = fallbackError instanceof Error ? fallbackError.message : String(fallbackError) - const primaryMessage = error instanceof Error ? error.message : String(error) - return { ok: false, status: null, error: `${primaryMessage}; fallback: ${message}` } - } + return submitFallbackFeedback(body, error) } } diff --git a/src/main/ipc/filesystem-auth.test.ts b/src/main/ipc/filesystem-auth.test.ts new file mode 100644 index 00000000000..1b7f7627273 --- /dev/null +++ b/src/main/ipc/filesystem-auth.test.ts @@ -0,0 +1,188 @@ +import type * as NodePath from 'node:path' +import { mkdir, mkdtemp, realpath, rm, symlink } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Store } from '../persistence' +import type * as RepoWorktrees from '../repo-worktrees' +import { listRepoWorktrees } from '../repo-worktrees' +import type { GitWorktreeInfo, Repo } from '../../shared/types' +import { + invalidateAuthorizedRootsCache, + isDescendantOrEqual, + rebuildAuthorizedRootsCache, + resolveAuthorizedPath, + resolveRegisteredWorktreePath, + validateGitRelativeFilePath +} from './filesystem-auth' + +vi.mock('../repo-worktrees', async () => { + const actual = await vi.importActual('../repo-worktrees') + return { + ...actual, + listRepoWorktrees: vi.fn() + } +}) + +const LARGE_WORKTREE_ROOT_COUNT = 150_000 + +const repo: Repo = { + id: 'repo-1', + path: '/repos/app', + displayName: 'app', + badgeColor: '#000000', + addedAt: 1, + kind: 'git' +} + +function makeStore(repos: Repo[] = [repo]): Store { + return { + getRepos: () => repos, + getSettings: () => ({}) + } as unknown as Store +} + +describe('filesystem auth worktree roots', () => { + beforeEach(() => { + invalidateAuthorizedRootsCache() + vi.mocked(listRepoWorktrees).mockReset() + }) + + it('rebuilds the authorized roots cache for large worktree lists', async () => { + const worktrees: GitWorktreeInfo[] = Array.from( + { length: LARGE_WORKTREE_ROOT_COUNT }, + (_, index) => ({ + path: `/linked/worktree-${index}`, + head: '', + branch: `refs/heads/generated-${index}`, + isBare: false, + isMainWorktree: false + }) + ) + vi.mocked(listRepoWorktrees).mockResolvedValue(worktrees) + const store = makeStore() + + await rebuildAuthorizedRootsCache(store) + + const lastWorktreePath = `/linked/worktree-${LARGE_WORKTREE_ROOT_COUNT - 1}` + await expect(resolveRegisteredWorktreePath(lastWorktreePath, store)).resolves.toBe( + resolve(lastWorktreePath) + ) + expect(listRepoWorktrees).toHaveBeenCalledTimes(1) + }) + + it('bounds concurrent repo probes while rebuilding authorized roots', async () => { + const repos = Array.from({ length: 20 }, (_, index) => ({ + ...repo, + id: `repo-${index}`, + path: `/repos/app-${index}` + })) + let active = 0 + let maxActive = 0 + vi.mocked(listRepoWorktrees).mockImplementation(async () => { + active += 1 + maxActive = Math.max(maxActive, active) + await new Promise((resolve) => setTimeout(resolve, 1)) + active -= 1 + return [] + }) + + await rebuildAuthorizedRootsCache(makeStore(repos)) + + expect(listRepoWorktrees).toHaveBeenCalledTimes(repos.length) + expect(maxActive).toBeLessThanOrEqual(8) + }) +}) + +describe('filesystem-auth path containment', () => { + it('authorizes missing nested descendants under an allowed repo', async () => { + const tempRoot = await mkdtemp(join(tmpdir(), 'orca-auth-missing-')) + try { + const repoPath = join(tempRoot, 'repo') + await mkdir(repoPath) + const store = makeStore([{ ...repo, id: 'repo-temp', path: repoPath }]) + const targetPath = join(repoPath, 'new', 'nested', 'file.ts') + + await expect(resolveAuthorizedPath(targetPath, store)).resolves.toBe( + join(await realpath(repoPath), 'new', 'nested', 'file.ts') + ) + } finally { + await rm(tempRoot, { recursive: true, force: true }) + } + }) + + it.skipIf(process.platform === 'win32')( + 'rejects missing descendants under a symlinked ancestor outside the repo', + async () => { + const tempRoot = await mkdtemp(join(tmpdir(), 'orca-auth-symlink-')) + try { + const repoPath = join(tempRoot, 'repo') + const outsidePath = join(tempRoot, 'outside') + await mkdir(repoPath) + await mkdir(outsidePath) + await symlink(outsidePath, join(repoPath, 'linked-outside'), 'dir') + const store = makeStore([{ ...repo, id: 'repo-temp', path: repoPath }]) + + await expect( + resolveAuthorizedPath(join(repoPath, 'linked-outside', 'new', 'file.ts'), store) + ).rejects.toThrow('Access denied') + } finally { + await rm(tempRoot, { recursive: true, force: true }) + } + } + ) + + it('allows descendants whose path segment starts with dotdot characters', () => { + const root = resolve('/workspace/repo') + const child = resolve('/workspace/repo/..fixtures/file.ts') + + expect(isDescendantOrEqual(child, root)).toBe(true) + }) + + it('allows git-relative files under dotdot-prefixed child directories', () => { + expect(validateGitRelativeFilePath(resolve('/workspace/repo'), '..fixtures/file.ts')).toBe( + '..fixtures/file.ts' + ) + }) + + it('still rejects parent-directory escapes', () => { + const root = resolve('/workspace/repo') + const outside = resolve('/workspace/repo/../other/file.ts') + + expect(isDescendantOrEqual(outside, root)).toBe(false) + expect(() => validateGitRelativeFilePath(root, '../other/file.ts')).toThrow( + 'Access denied: git file path escapes the selected worktree' + ) + }) + + it('accepts Windows descendants when drive and root casing differ', async () => { + vi.resetModules() + vi.doMock('../repo-worktrees', () => ({ + isRepoRoot: vi.fn(), + listRepoWorktrees: vi.fn() + })) + vi.doMock('path', async () => { + const path = await vi.importActual('node:path') + return { + ...path.win32, + default: path.win32 + } + }) + + try { + const { isDescendantOrEqual: isDescendantOrEqualWithWinPath } = + await import('./filesystem-auth') + + expect( + isDescendantOrEqualWithWinPath(String.raw`c:\repo\src\app.ts`, String.raw`C:\Repo`) + ).toBe(true) + expect( + isDescendantOrEqualWithWinPath(String.raw`D:\repo\src\app.ts`, String.raw`C:\Repo`) + ).toBe(false) + } finally { + vi.doUnmock('path') + vi.doUnmock('../repo-worktrees') + vi.resetModules() + } + }) +}) diff --git a/src/main/ipc/filesystem-auth.ts b/src/main/ipc/filesystem-auth.ts index 92e1f6d8943..e92966c183e 100644 --- a/src/main/ipc/filesystem-auth.ts +++ b/src/main/ipc/filesystem-auth.ts @@ -1,8 +1,12 @@ -import { resolve, relative, dirname, basename, isAbsolute } from 'path' +/* eslint-disable max-lines -- Why: filesystem authorization keeps root +discovery, canonicalization, and registered-worktree cache checks together so +the security boundary is auditable end to end. */ +import { resolve, relative, dirname, basename, isAbsolute, sep } from 'path' import { realpathSync } from 'fs' import { realpath } from 'fs/promises' import type { Store } from '../persistence' import { isRepoRoot, listRepoWorktrees } from '../repo-worktrees' +import { computeWorkspaceRoot, getWorktreePathSettings } from './worktree-logic' export const PATH_ACCESS_DENIED_MESSAGE = 'Access denied: path resolves outside allowed directories. If this blocks a legitimate workflow, please file a GitHub issue.' @@ -12,6 +16,7 @@ const registeredWorktreeRootsByRepo = new Map>() const registeredWorktreeRootRepoIds = new Set() let registeredWorktreeRootsDirty = true let registeredWorktreeRootsRefresh: Promise | null = null +const AUTHORIZED_ROOTS_REBUILD_CONCURRENCY = 8 export function authorizeExternalPath(targetPath: string): void { const resolvedTarget = resolve(targetPath) @@ -47,22 +52,28 @@ export function isDescendantOrEqual(resolvedTarget: string, resolvedBase: string return true } const rel = relative(resolvedBase, resolvedTarget) - // rel must not start with ".." and must not be an absolute path (e.g. different drive on Windows) + // rel must not be ".."/"../..." or an absolute path (e.g. different drive on Windows) // [Security Fix]: Added !isAbsolute(rel) to prevent drive traversal bypasses on Windows // where relative('D:\\repo', 'C:\\etc\\passwd') returns absolute path 'C:\\etc\\passwd' - return ( - rel !== '' && - !rel.startsWith('..') && - !isAbsolute(rel) && - resolve(resolvedBase, rel) === resolvedTarget - ) + // Why: Windows path.relative() already treats drive/root casing as equivalent; + // rejoining and comparing strings would deny valid `c:\repo` descendants of `C:\Repo`. + return rel !== '' && !(rel === '..' || rel.startsWith(`..${sep}`)) && !isAbsolute(rel) } export function getAllowedRoots(store: Store): string[] { - const roots = getLocalRepos(store).map((repo) => resolve(repo.path)) - const workspaceDir = store.getSettings().workspaceDir - if (workspaceDir) { - roots.push(resolve(workspaceDir)) + const localRepos = getLocalRepos(store) + const settings = store.getSettings() + const roots = localRepos.map((repo) => resolve(repo.path)) + if (settings.workspaceDir) { + if (localRepos.length === 0) { + roots.push(resolve(settings.workspaceDir)) + } else { + for (const repo of localRepos) { + roots.push( + resolve(computeWorkspaceRoot(repo.path, getWorktreePathSettings(repo, settings))) + ) + } + } } return roots } @@ -81,11 +92,8 @@ export function isPathAllowed(targetPath: string, store: Store): boolean { } export async function rebuildAuthorizedRootsCache(store: Store): Promise { - // Why: repos are processed in parallel so the cache rebuild completes in - // wall-clock time proportional to the slowest single repo, not the sum of - // all repos. The previous sequential loop was the main bottleneck on - // Windows where each `git worktree list` + realpath chain takes 500 ms+ - // due to slower process creation and antivirus I/O scanning. + // Why: repos are processed with bounded parallelism so the cache rebuild + // keeps the Windows speedup without spawning one git process per repo. // // Why no realpath() here: this rebuild runs on repo/worktree invalidation, // so canonicalizing every repo root would repeatedly touch TCC-protected @@ -94,15 +102,17 @@ export async function rebuildAuthorizedRootsCache(store: Store): Promise { // destructive or read/write operation, so the security boundary remains // enforced where it matters. const repos = getLocalRepos(store) - const perProjectResults = await Promise.all( - repos.map(async (repo) => { + const perProjectResults = await mapWithConcurrency( + repos, + AUTHORIZED_ROOTS_REBUILD_CONCURRENCY, + async (repo) => { const roots: string[] = [] try { roots.push(resolve(repo.path)) - const worktrees = await listRepoWorktrees(repo) - const worktreeRoots = worktrees.map((wt) => resolve(wt.path)) - roots.push(...worktreeRoots) + for (const worktree of await listRepoWorktrees(repo)) { + roots.push(resolve(worktree.path)) + } } catch (error) { // Why: a single inaccessible repo (EACCES, EIO, etc.) must not break // the entire cache rebuild — that would disable File Explorer and @@ -111,7 +121,7 @@ export async function rebuildAuthorizedRootsCache(store: Store): Promise { console.warn(`[filesystem-auth] skipping repo ${repo.path} during cache rebuild:`, error) } return { repoId: repo.id, roots } - }) + } ) registeredWorktreeRoots.clear() @@ -129,6 +139,26 @@ export async function rebuildAuthorizedRootsCache(store: Store): Promise { registeredWorktreeRootsDirty = false } +async function mapWithConcurrency( + items: readonly T[], + maxConcurrent: number, + mapper: (item: T) => Promise +): Promise { + const results: R[] = [] + let nextIndex = 0 + const workerCount = Math.min(maxConcurrent, items.length) + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (nextIndex < items.length) { + const index = nextIndex + nextIndex += 1 + results[index] = await mapper(items[index]) + } + }) + ) + return results +} + export function registerWorktreeRootsForRepo( store: Store, repoId: string, @@ -200,7 +230,15 @@ export async function resolveAuthorizedPath( // Canonicalize the parent so symlinks in ancestors cannot redirect us // outside allowed roots, but keep the final segment untouched so callers // (delete/rename) act on the link itself. - const realParent = await realpath(dirname(resolvedTarget)) + let realParent: string + try { + realParent = await realpath(dirname(resolvedTarget)) + } catch (error) { + if (isENOENT(error)) { + return resolveAuthorizedMissingPath(resolvedTarget, store) + } + throw error + } const candidateTarget = resolve(realParent, basename(resolvedTarget)) if ( !(await isPathAllowedIncludingRegisteredWorktrees(candidateTarget, store, { @@ -226,17 +264,40 @@ export async function resolveAuthorizedPath( if (!isENOENT(error)) { throw error } + return resolveAuthorizedMissingPath(resolvedTarget, store) + } +} - const realParent = await realpath(dirname(resolvedTarget)) - const candidateTarget = resolve(realParent, basename(resolvedTarget)) - if ( - !(await isPathAllowedIncludingRegisteredWorktrees(candidateTarget, store, { - canonicalSourcePath: resolvedTarget - })) - ) { - throw new Error(PATH_ACCESS_DENIED_MESSAGE) +async function resolveAuthorizedMissingPath(resolvedTarget: string, store: Store): Promise { + let existingAncestor = resolvedTarget + const missingSegments: string[] = [] + + while (true) { + try { + const realAncestor = await realpath(existingAncestor) + const candidateTarget = resolve(realAncestor, ...missingSegments) + if ( + !(await isPathAllowedIncludingRegisteredWorktrees(candidateTarget, store, { + canonicalSourcePath: resolvedTarget + })) + ) { + throw new Error(PATH_ACCESS_DENIED_MESSAGE) + } + return candidateTarget + } catch (error) { + if (!isENOENT(error)) { + throw error + } + const parent = dirname(existingAncestor) + if (parent === existingAncestor) { + throw error + } + // Why: create/copy callers intentionally create missing parents after + // auth. Canonicalize the nearest existing ancestor so symlink escapes are + // still caught without rejecting legitimate nested paths. + missingSegments.unshift(basename(existingAncestor)) + existingAncestor = parent } - return candidateTarget } } @@ -253,6 +314,10 @@ async function isPathAllowedIncludingRegisteredWorktrees( return true } + if (await isPathAllowedByCanonicalAllowedRoot(targetPath, options.canonicalSourcePath, store)) { + return true + } + if (await isPathAllowedByCanonicalRegisteredRoot(targetPath, options.canonicalSourcePath)) { return true } @@ -326,6 +391,29 @@ function allLocalRepoRootsRegistered(localRepoIds: Set): boolean { return true } +async function isPathAllowedByCanonicalAllowedRoot( + targetPath: string, + sourcePath: string | undefined, + store: Store +): Promise { + if (!sourcePath) { + return false + } + for (const root of getAllowedRoots(store)) { + const resolvedRoot = resolve(root) + if (!isDescendantOrEqual(sourcePath, resolvedRoot)) { + continue + } + // Why: active file operations may resolve `/var` to `/private/var` on + // macOS. Canonicalize only the matched root instead of the whole repo set. + const canonicalRoot = await normalizeExistingPath(resolvedRoot) + if (isDescendantOrEqual(targetPath, canonicalRoot)) { + return true + } + } + return false +} + function isRegisteredWorktreePath(targetPath: string): boolean { for (const root of registeredWorktreeRoots) { if (isDescendantOrEqual(targetPath, root)) { diff --git a/src/main/ipc/filesystem-import-ssh-ops.test.ts b/src/main/ipc/filesystem-import-ssh-ops.test.ts index 376e867da43..c1b1504805e 100644 --- a/src/main/ipc/filesystem-import-ssh-ops.test.ts +++ b/src/main/ipc/filesystem-import-ssh-ops.test.ts @@ -12,6 +12,7 @@ const { sftpExistsMock, uploadFileMock, uploadDirMock, + removeDirectorySftpMock, mkdirSftpMock, getConnMgrMock } = vi.hoisted(() => ({ @@ -24,6 +25,7 @@ const { sftpExistsMock: vi.fn(), uploadFileMock: vi.fn(), uploadDirMock: vi.fn(), + removeDirectorySftpMock: vi.fn(), mkdirSftpMock: vi.fn(), getConnMgrMock: vi.fn() })) @@ -42,6 +44,7 @@ vi.mock('../ssh/sftp-upload', () => ({ sftpPathExists: sftpExistsMock, uploadFile: uploadFileMock, uploadDirectory: uploadDirMock, + removeDirectorySftp: removeDirectorySftpMock, mkdirSftp: mkdirSftpMock })) vi.mock('./ssh', () => ({ getSshConnectionManager: getConnMgrMock })) @@ -96,6 +99,7 @@ describe('fs:importExternalPaths — SSH operations', () => { sftpExistsMock, uploadFileMock, uploadDirMock, + removeDirectorySftpMock, mkdirSftpMock, getConnMgrMock ].forEach((m) => m.mockReset()) @@ -108,6 +112,7 @@ describe('fs:importExternalPaths — SSH operations', () => { sftpExistsMock.mockResolvedValue(false) uploadFileMock.mockResolvedValue(undefined) uploadDirMock.mockResolvedValue(undefined) + removeDirectorySftpMock.mockResolvedValue(undefined) mkdirSftpMock.mockResolvedValue(undefined) getConnMgrMock.mockReturnValue({ getConnection: () => makeConn() }) registerFilesystemMutationHandlers(store as never) @@ -220,6 +225,24 @@ describe('fs:importExternalPaths — SSH operations', () => { expect(results[0]).toMatchObject({ status: 'failed', reason: 'permission denied' }) }) + it('removes a created SSH directory import root when uploadDirectory fails', async () => { + mockDir('/tmp/dropped/assets') + readdirMock.mockResolvedValue([]) + uploadDirMock.mockRejectedValue(new Error('disk full')) + + const { results } = await invoke({ + sourcePaths: ['/tmp/dropped/assets'], + destDir, + connectionId: connId + }) + + expect(results[0]).toMatchObject({ status: 'failed', reason: 'disk full' }) + expect(mkdirSftpMock).toHaveBeenCalledWith(mockSftp, `${destDir}/assets`, { + allowExisting: false + }) + expect(removeDirectorySftpMock).toHaveBeenCalledWith(mockSftp, `${destDir}/assets`) + }) + it('deconflicts directory names via SFTP lstat', async () => { mockDir('/tmp/dropped/assets') readdirMock.mockResolvedValue([]) diff --git a/src/main/ipc/filesystem-import-ssh.test.ts b/src/main/ipc/filesystem-import-ssh.test.ts index e348f2f2979..8096c834e8d 100644 --- a/src/main/ipc/filesystem-import-ssh.test.ts +++ b/src/main/ipc/filesystem-import-ssh.test.ts @@ -1,5 +1,6 @@ import path from 'path' import { constants } from 'fs' +import { EventEmitter } from 'events' import { Readable, Writable } from 'stream' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -16,6 +17,7 @@ const { sftpExistsMock, uploadFileMock, uploadDirMock, + removeDirectorySftpMock, mkdirSftpMock, getConnMgrMock } = vi.hoisted(() => ({ @@ -30,6 +32,7 @@ const { sftpExistsMock: vi.fn(), uploadFileMock: vi.fn(), uploadDirMock: vi.fn(), + removeDirectorySftpMock: vi.fn(), mkdirSftpMock: vi.fn(), getConnMgrMock: vi.fn() })) @@ -51,6 +54,7 @@ vi.mock('../ssh/sftp-upload', () => ({ sftpPathExists: sftpExistsMock, uploadFile: uploadFileMock, uploadDirectory: uploadDirMock, + removeDirectorySftp: removeDirectorySftpMock, mkdirSftp: mkdirSftpMock })) vi.mock('./ssh', () => ({ getSshConnectionManager: getConnMgrMock })) @@ -71,10 +75,30 @@ const store = { } const enoent = (): Error => Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) +type MockSftpWriteStream = EventEmitter & { + destroy: ReturnType + end: ReturnType +} + +function createMockSftpWriteStream(): MockSftpWriteStream { + const stream = new EventEmitter() as MockSftpWriteStream + stream.destroy = vi.fn() + stream.end = vi.fn(() => stream.emit('close')) + return stream +} + describe('fs:importExternalPaths — SSH routing & connection', () => { const destDir = '/home/user/project/src' const connId = 'ssh-conn-1' - const mockSftp = { end: vi.fn() } + const sftpWriteStreams: MockSftpWriteStream[] = [] + const mockSftp = { + end: vi.fn(), + createWriteStream: vi.fn(() => { + const stream = createMockSftpWriteStream() + sftpWriteStreams.push(stream) + return stream + }) + } const makeConn = (status = 'connected') => ({ getState: () => ({ status }), sftp: vi.fn().mockResolvedValue(mockSftp) @@ -107,10 +131,13 @@ describe('fs:importExternalPaths — SSH routing & connection', () => { sftpExistsMock, uploadFileMock, uploadDirMock, + removeDirectorySftpMock, mkdirSftpMock, getConnMgrMock ].forEach((m) => m.mockReset()) mockSftp.end.mockReset() + mockSftp.createWriteStream.mockClear() + sftpWriteStreams.length = 0 handleMock.mockImplementation((ch: string, h: never) => { handlers.set(ch, h) }) @@ -143,6 +170,7 @@ describe('fs:importExternalPaths — SSH routing & connection', () => { sftpExistsMock.mockResolvedValue(false) uploadFileMock.mockResolvedValue(undefined) uploadDirMock.mockResolvedValue(undefined) + removeDirectorySftpMock.mockResolvedValue(undefined) mkdirSftpMock.mockResolvedValue(undefined) registerFilesystemMutationHandlers(store as never) }) @@ -235,4 +263,21 @@ describe('fs:importExternalPaths — SSH routing & connection', () => { expect(results[0]).toMatchObject({ status: 'failed', reason: 'disk full' }) expect(mockSftp.end).toHaveBeenCalledOnce() }) + + it('removes staging marker write listeners after remote stream close', async () => { + getConnMgrMock.mockReturnValue({ getConnection: () => makeConn() }) + mockFile('/tmp/dropped/file.txt') + + await invoke({ + sourcePaths: ['/tmp/dropped/file.txt'], + destDir: '/home/user/project/.orca/drops', + connectionId: connId, + ensureDir: true + }) + + expect(mockSftp.createWriteStream).toHaveBeenCalledWith('/home/user/project/.orca/.gitignore') + expect(sftpWriteStreams[0]!.listenerCount('close')).toBe(0) + expect(sftpWriteStreams[0]!.listenerCount('error')).toBe(0) + expect(sftpWriteStreams[0]!.destroy).toHaveBeenCalledOnce() + }) }) diff --git a/src/main/ipc/filesystem-import-ssh.ts b/src/main/ipc/filesystem-import-ssh.ts index c29dbd6ee04..089198510bf 100644 --- a/src/main/ipc/filesystem-import-ssh.ts +++ b/src/main/ipc/filesystem-import-ssh.ts @@ -3,7 +3,13 @@ import { basename, join, posix, resolve } from 'path' import type { SFTPWrapper } from 'ssh2' import { authorizeExternalPath, isENOENT } from './filesystem-auth' import { getSshConnectionManager } from './ssh' -import { uploadFile, uploadDirectory, mkdirSftp, sftpPathExists } from '../ssh/sftp-upload' +import { + uploadFile, + uploadDirectory, + mkdirSftp, + sftpPathExists, + removeDirectorySftp +} from '../ssh/sftp-upload' import type { ImportItemResult } from './filesystem-mutations' // Why: the SSH import path bypasses SshFilesystemProvider and uses @@ -118,6 +124,7 @@ async function importOneSourceSsh( const originalName = basename(resolvedSource) + let createdDestDir: string | null = null try { const finalName = await deconflictNameSftp(sftp, destDir, originalName, reservedNames) const destPath = `${destDir}/${finalName}` @@ -125,6 +132,7 @@ async function importOneSourceSsh( if (isDir) { await mkdirSftp(sftp, destPath, { allowExisting: false }) + createdDestDir = destPath await uploadDirectory(sftp, resolvedSource, destPath, await realpath(resolvedSource), { exclusive: true }) @@ -140,6 +148,11 @@ async function importOneSourceSsh( renamed } } catch (error) { + if (createdDestDir) { + // Why: local directory imports roll back partial output; SSH imports + // should not leave the no-clobber root after a nested upload failure. + await removeDirectorySftp(sftp, createdDestDir).catch(() => {}) + } return { sourcePath, status: 'failed', @@ -203,16 +216,24 @@ function writeSftpFile(sftp: SFTPWrapper, remotePath: string, contents: string): return new Promise((resolve, reject) => { let settled = false const writeStream = sftp.createWriteStream(remotePath) + const cleanup = (): void => { + writeStream.off('close', onClose) + writeStream.off('error', onError) + } const settle = (fn: typeof resolve | typeof reject, val?: unknown): void => { if (settled) { return } settled = true + cleanup() writeStream.destroy() fn(val as never) } - writeStream.on('close', () => settle(resolve)) - writeStream.on('error', (err) => settle(reject, err)) + const onClose = (): void => settle(resolve) + const onError = (err: Error): void => settle(reject, err) + + writeStream.on('close', onClose) + writeStream.on('error', onError) writeStream.end(contents) }) } diff --git a/src/main/ipc/filesystem-import.test.ts b/src/main/ipc/filesystem-import.test.ts index 318e2d120fc..c618a2a584b 100644 --- a/src/main/ipc/filesystem-import.test.ts +++ b/src/main/ipc/filesystem-import.test.ts @@ -524,6 +524,194 @@ describe('fs:importExternalPaths', () => { expect(closeMock).toHaveBeenCalled() }) + it('stages runtime upload directories whose child names start with dotdot characters', async () => { + const sourcePath = '/tmp/dropped/project' + const resolvedPath = path.resolve(sourcePath) + const childDir = path.join(resolvedPath, '..assets') + const childFile = path.join(childDir, 'icon.txt') + lstatMock.mockImplementation(async (p: string) => { + if (p === resolvedPath || p === childDir) { + return { + size: 0, + ino: 1, + dev: 1, + isFile: () => false, + isDirectory: () => true, + isSymbolicLink: () => false + } + } + if (p === childFile) { + return { + size: 4, + ino: 2, + dev: 1, + isFile: () => true, + isDirectory: () => false, + isSymbolicLink: () => false + } + } + throw enoent() + }) + readdirMock.mockImplementation(async (p: string) => { + if (p === resolvedPath) { + return [ + { + name: '..assets', + isDirectory: () => true, + isSymbolicLink: () => false, + isFile: () => false + } + ] + } + if (p === childDir) { + return [ + { + name: 'icon.txt', + isDirectory: () => false, + isSymbolicLink: () => false, + isFile: () => true + } + ] + } + return [] + }) + openMock.mockResolvedValue({ + stat: vi.fn().mockResolvedValue({ + size: 4, + ino: 2, + dev: 1, + isFile: () => true + }), + readFile: vi.fn().mockResolvedValue(Buffer.from('icon')), + close: vi.fn().mockResolvedValue(undefined) + }) + + const result = (await handlers.get('fs:stageExternalPathsForRuntimeUpload')!(null, { + sourcePaths: [sourcePath] + })) as { sources: unknown[] } + + expect(result.sources).toEqual([ + { + sourcePath, + status: 'staged', + name: 'project', + kind: 'directory', + entries: [ + { relativePath: '', kind: 'directory' }, + { relativePath: '..assets', kind: 'directory' }, + { relativePath: '..assets/icon.txt', kind: 'file', contentBase64: 'aWNvbg==' } + ] + } + ]) + }) + + it('skips runtime upload directories with nested symlinks during the staging traversal', async () => { + const sourcePath = '/tmp/dropped/project' + const resolvedPath = path.resolve(sourcePath) + lstatMock.mockImplementation(async (p: string) => { + if (p === resolvedPath) { + return { + size: 0, + ino: 1, + dev: 1, + isFile: () => false, + isDirectory: () => true, + isSymbolicLink: () => false + } + } + throw enoent() + }) + readdirMock.mockResolvedValue([ + { + name: 'link.txt', + isDirectory: () => false, + isSymbolicLink: () => true, + isFile: () => false + } + ]) + + const result = (await handlers.get('fs:stageExternalPathsForRuntimeUpload')!(null, { + sourcePaths: [sourcePath] + })) as { sources: { status: string; reason?: string }[] } + + expect(result.sources[0]).toMatchObject({ status: 'skipped', reason: 'symlink' }) + expect(readdirMock).toHaveBeenCalledOnce() + expect(openMock).not.toHaveBeenCalled() + }) + + it('checks runtime upload directory byte budget before reading a file that exceeds the total cap', async () => { + const sourcePath = '/tmp/dropped/project' + const resolvedPath = path.resolve(sourcePath) + const filePaths = ['one.bin', 'two.bin', 'three.bin', 'four.bin', 'overflow.bin'].map((name) => + path.join(resolvedPath, name) + ) + const mib = 1024 * 1024 + const regularSize = 25 * mib + const overflowSize = 1 * mib + const readFileMock = vi.fn().mockResolvedValue(Buffer.from('chunk')) + + lstatMock.mockImplementation(async (p: string) => { + if (p === resolvedPath) { + return { + size: 0, + ino: 1, + dev: 1, + isFile: () => false, + isDirectory: () => true, + isSymbolicLink: () => false + } + } + const fileIndex = filePaths.indexOf(p) + if (fileIndex !== -1) { + const size = fileIndex === filePaths.length - 1 ? overflowSize : regularSize + return { + size, + ino: fileIndex + 2, + dev: 1, + isFile: () => true, + isDirectory: () => false, + isSymbolicLink: () => false + } + } + throw enoent() + }) + readdirMock.mockResolvedValue( + filePaths.map((filePath) => ({ + name: path.basename(filePath), + isDirectory: () => false, + isSymbolicLink: () => false, + isFile: () => true + })) + ) + openMock.mockImplementation(async (p: string) => { + const fileIndex = filePaths.indexOf(p) + if (fileIndex >= 0 && fileIndex < filePaths.length - 1) { + return { + stat: vi.fn().mockResolvedValue({ + size: regularSize, + ino: fileIndex + 2, + dev: 1, + isFile: () => true + }), + readFile: readFileMock, + close: vi.fn().mockResolvedValue(undefined) + } + } + throw new Error(`unexpected open: ${p}`) + }) + + const result = (await handlers.get('fs:stageExternalPathsForRuntimeUpload')!(null, { + sourcePaths: [sourcePath] + })) as { sources: { status: string; reason?: string }[] } + + expect(result.sources[0]).toMatchObject({ + status: 'failed', + reason: 'Remote import is too large' + }) + expect(readFileMock).toHaveBeenCalledTimes(4) + expect(openMock).not.toHaveBeenCalledWith(filePaths.at(-1), expect.anything()) + }) + it('fails runtime upload staging when a file changes between lstat and open', async () => { const sourcePath = '/tmp/dropped/logo.png' const resolvedPath = path.resolve(sourcePath) diff --git a/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts b/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts new file mode 100644 index 00000000000..327614faf0a --- /dev/null +++ b/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts @@ -0,0 +1,61 @@ +import { execFile as execFileCallback } from 'node:child_process' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Store } from '../persistence' + +const { checkRgAvailableMock } = vi.hoisted(() => ({ + checkRgAvailableMock: vi.fn() +})) + +vi.mock('./rg-availability', () => ({ + checkRgAvailable: checkRgAvailableMock +})) + +import { listQuickOpenFiles } from './filesystem-list-files' + +const execFile = promisify(execFileCallback) + +function makeStore(repoPath: string): Store { + return { + getRepos: () => [ + { + id: 'repo-1', + path: repoPath, + displayName: 'repo', + badgeColor: '#000000', + addedAt: 0, + kind: 'git' + } + ], + getSettings: () => ({}) + } as unknown as Store +} + +describe('filesystem-list-files real git fallback', () => { + let tempDir: string | null = null + + afterEach(async () => { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }) + tempDir = null + } + vi.clearAllMocks() + }) + + it('returns real paths for filenames Git would C-quote in newline output', async () => { + checkRgAvailableMock.mockResolvedValue(false) + tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-git-fallback-')) + const repoPath = join(tempDir, 'repo') + await execFile('git', ['init', '-q', repoPath]) + const tabbedPath = join(repoPath, 'tab\tfile.txt') + await writeFile(tabbedPath, 'content') + await execFile('git', ['add', '.'], { cwd: repoPath }) + + await expect(listQuickOpenFiles(repoPath, makeStore(repoPath))).resolves.toEqual([ + 'tab\tfile.txt' + ]) + }) +}) diff --git a/src/main/ipc/filesystem-list-files.test.ts b/src/main/ipc/filesystem-list-files.test.ts index d315cc53e6a..a09739b143d 100644 --- a/src/main/ipc/filesystem-list-files.test.ts +++ b/src/main/ipc/filesystem-list-files.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Why: one Quick Open file-list suite covers both rg and git fallback process lifecycles. */ import { describe, expect, it, vi, beforeEach } from 'vitest' const { spawnMock, resolveAuthorizedPathMock, checkRgAvailableMock } = vi.hoisted(() => ({ @@ -165,6 +166,44 @@ describe('filesystem-list-files', () => { await expect(promise).resolves.toEqual(['src/index.ts']) }) + it('settles and detaches rg scans that ignore timeout kills', async () => { + vi.useFakeTimers() + + try { + const p1 = createMockProcess() + const p2 = createMockProcess() + + spawnMock.mockImplementation((_cmd, args: string[]) => { + if (isIgnoredRgPass(args)) { + return p2 + } + return p1 + }) + + const storeMock = {} as unknown as Store + const promise = listQuickOpenFiles('/mock/root', storeMock) + + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + + ;(p1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\npartial') + const rejection = expect(promise).rejects.toThrow('rg list timed out') + + await vi.advanceTimersByTimeAsync(10000) + + await rejection + expect(p1.kill).toHaveBeenCalled() + expect(p2.kill).toHaveBeenCalled() + expect((p1.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) + expect((p1.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0) + expect(p1.listenerCount('error')).toBe(0) + expect(p1.listenerCount('close')).toBe(0) + } finally { + vi.useRealTimers() + } + }) + it('filters out .next, .cache, .stably, .vscode, .idea', async () => { const p1 = createMockProcess() const p2 = createMockProcess() @@ -217,13 +256,13 @@ describe('filesystem-list-files', () => { const promise = listQuickOpenFiles('/mock/root', storeMock) setTimeout(() => { - ;(gitP1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n') - ;(gitP1.stdout as unknown as EventEmitter).emit('data', 'package.json\n') - ;(gitP1.stdout as unknown as EventEmitter).emit('data', 'node_modules/dep/index.js\n') + ;(gitP1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\0') + ;(gitP1.stdout as unknown as EventEmitter).emit('data', 'package.json\0') + ;(gitP1.stdout as unknown as EventEmitter).emit('data', 'node_modules/dep/index.js\0') gitP1.emit('close', 0, null) - ;(gitP2.stdout as unknown as EventEmitter).emit('data', '.env.local\n') - ;(gitP2.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\n') + ;(gitP2.stdout as unknown as EventEmitter).emit('data', '.env.local\0') + ;(gitP2.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\0') gitP2.emit('close', 0, null) }, 10) @@ -265,10 +304,10 @@ describe('filesystem-list-files', () => { const promise = listQuickOpenFiles('/mock/root', storeMock) setTimeout(() => { - ;(gitP1.stdout as unknown as EventEmitter).emit('data', '.next/cache/1.js\n') - ;(gitP1.stdout as unknown as EventEmitter).emit('data', '.vscode/settings.json\n') - ;(gitP1.stdout as unknown as EventEmitter).emit('data', '.github/workflows/ci.yml\n') - ;(gitP1.stdout as unknown as EventEmitter).emit('data', 'valid.ts\n') + ;(gitP1.stdout as unknown as EventEmitter).emit('data', '.next/cache/1.js\0') + ;(gitP1.stdout as unknown as EventEmitter).emit('data', '.vscode/settings.json\0') + ;(gitP1.stdout as unknown as EventEmitter).emit('data', '.github/workflows/ci.yml\0') + ;(gitP1.stdout as unknown as EventEmitter).emit('data', 'valid.ts\0') gitP1.emit('close', 0, null) gitP2.emit('close', 0, null) @@ -279,6 +318,46 @@ describe('filesystem-list-files', () => { expect(result).toEqual(['.github/workflows/ci.yml', 'valid.ts']) }) + it('settles and detaches git fallback scans that ignore timeout kills', async () => { + checkRgAvailableMock.mockResolvedValue(false) + vi.useFakeTimers() + + try { + const gitP1 = createMockProcess() + const gitP2 = createMockProcess() + let callIndex = 0 + + spawnMock.mockImplementation((cmd: string) => { + if (cmd === 'git') { + callIndex++ + return callIndex === 1 ? gitP1 : gitP2 + } + return createMockProcess() + }) + + const storeMock = {} as unknown as Store + const promise = listQuickOpenFiles('/mock/root', storeMock) + + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + + ;(gitP1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\0partial') + + await vi.advanceTimersByTimeAsync(10000) + + await expect(promise).resolves.toEqual(['src/index.ts']) + expect(gitP1.kill).toHaveBeenCalled() + expect(gitP2.kill).toHaveBeenCalled() + expect((gitP1.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) + expect((gitP1.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0) + expect(gitP1.listenerCount('error')).toBe(0) + expect(gitP1.listenerCount('close')).toBe(0) + } finally { + vi.useRealTimers() + } + }) + it('does not fall back to git when rg is available', async () => { checkRgAvailableMock.mockResolvedValue(true) diff --git a/src/main/ipc/filesystem-list-files.ts b/src/main/ipc/filesystem-list-files.ts index 07f55ec7663..90ff30895dc 100644 --- a/src/main/ipc/filesystem-list-files.ts +++ b/src/main/ipc/filesystem-list-files.ts @@ -59,18 +59,6 @@ export async function listQuickOpenFiles( let buf = '' let done = false let parseablePathCount = 0 - const finish = (err?: Error): void => { - if (done) { - return - } - done = true - clearTimeout(timer) - if (err) { - reject(err) - } else { - resolve() - } - } const processLine = (rawLine: string): void => { const translated = @@ -94,8 +82,8 @@ export async function listQuickOpenFiles( stdio: ['ignore', 'pipe', 'pipe'] }) children.push(child) - child.stdout!.setEncoding('utf-8') - child.stdout!.on('data', (chunk: string) => { + let timer: ReturnType + const handleStdoutData = (chunk: string): void => { buf += chunk let start = 0 let newlineIdx = buf.indexOf('\n', start) @@ -105,17 +93,17 @@ export async function listQuickOpenFiles( newlineIdx = buf.indexOf('\n', start) } buf = start < buf.length ? buf.substring(start) : '' - }) - child.stderr!.on('data', () => { + } + const handleStderrData = (): void => { /* drain */ - }) - child.once('error', () => { + } + const handleError = (): void => { // Why: treat spawn errors like an abnormal exit — discard residual // buffer so a truncated final byte sequence cannot leak as a path. buf = '' finish(new Error('rg failed to start')) - }) - child.once('close', (code, signal) => { + } + const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => { if (signal) { // Why: a signal exit means timeout/OOM/external kill. Returning the // already-streamed prefix would recreate the false-empty bug this @@ -136,8 +124,32 @@ export async function listQuickOpenFiles( } else { finish(new Error(`rg exited with code ${code}`)) } - }) - const timer = setTimeout(() => { + } + const finish = (err?: Error): void => { + if (done) { + return + } + done = true + clearTimeout(timer) + // Why: child.kill() is advisory. If rg ignores it, detach our + // closures so repeated Quick Open attempts do not retain old scans. + child.stdout!.off('data', handleStdoutData) + child.stderr!.off('data', handleStderrData) + child.off('error', handleError) + child.off('close', handleClose) + if (err) { + reject(err) + } else { + resolve() + } + } + + child.stdout!.setEncoding('utf-8') + child.stdout!.on('data', handleStdoutData) + child.stderr!.on('data', handleStderrData) + child.once('error', handleError) + child.once('close', handleClose) + timer = setTimeout(() => { // Why: on timeout, the buffer is likely truncated mid-path. Discard // it so Quick Open never displays a malformed entry. buf = '' @@ -185,30 +197,19 @@ function listFilesWithGit( return new Promise((resolve) => { let buf = '' let done = false - const finish = (): void => { - if (done) { - return - } - done = true - clearTimeout(timer) - resolve() - } - const processLine = (line: string): void => { - if (line.charCodeAt(line.length - 1) === 13 /* \r */) { - line = line.substring(0, line.length - 1) - } - if (!line) { + const processPath = (path: string): void => { + if (!path) { return } // Why: git exclude pathspecs prune most hits, but post-filter is // still required because pathspec semantics differ subtly from the // rg globs and exist as a correctness backstop. - if (shouldExcludeQuickOpenRelPath(line, excludePathPrefixes)) { + if (shouldExcludeQuickOpenRelPath(path, excludePathPrefixes)) { return } - if (shouldIncludeQuickOpenPath(line)) { - files.add(line) + if (shouldIncludeQuickOpenPath(path)) { + files.add(path) } } @@ -218,34 +219,55 @@ function listFilesWithGit( cwd: rootPath, stdio: ['ignore', 'pipe', 'pipe'] }) - child.stdout!.setEncoding('utf-8') - child.stdout!.on('data', (chunk: string) => { + let timer: ReturnType + const handleStdoutData = (chunk: string): void => { buf += chunk let start = 0 - let newlineIdx = buf.indexOf('\n', start) - while (newlineIdx !== -1) { - processLine(buf.substring(start, newlineIdx)) - start = newlineIdx + 1 - newlineIdx = buf.indexOf('\n', start) + let nulIdx = buf.indexOf('\0', start) + while (nulIdx !== -1) { + processPath(buf.substring(start, nulIdx)) + start = nulIdx + 1 + nulIdx = buf.indexOf('\0', start) } buf = start < buf.length ? buf.substring(start) : '' - }) - child.stderr!.on('data', () => { + } + const handleStderrData = (): void => { /* drain */ - }) - child.once('error', () => { + } + const handleError = (): void => { buf = '' finish() - }) - child.once('close', () => { + } + const handleClose = (): void => { if (buf) { - processLine(buf) + processPath(buf) } finish() - }) - const timer = setTimeout(() => { + } + const finish = (): void => { + if (done) { + return + } + done = true + clearTimeout(timer) + // Why: child.kill() is advisory. If git ignores it, detach our + // closures so repeated Quick Open attempts do not retain old scans. + child.stdout!.off('data', handleStdoutData) + child.stderr!.off('data', handleStderrData) + child.off('error', handleError) + child.off('close', handleClose) + resolve() + } + + child.stdout!.setEncoding('utf-8') + child.stdout!.on('data', handleStdoutData) + child.stderr!.on('data', handleStderrData) + child.once('error', handleError) + child.once('close', handleClose) + timer = setTimeout(() => { buf = '' child.kill() + finish() }, 10000) }) } diff --git a/src/main/ipc/filesystem-mutations.ts b/src/main/ipc/filesystem-mutations.ts index 38c9d798105..478b946aff1 100644 --- a/src/main/ipc/filesystem-mutations.ts +++ b/src/main/ipc/filesystem-mutations.ts @@ -14,11 +14,12 @@ import { unlink, writeFile } from 'fs/promises' -import { basename, dirname, isAbsolute, join, relative, resolve } from 'path' +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'path' import { pipeline } from 'stream/promises' import type { Store } from '../persistence' import { authorizeExternalPath, resolveAuthorizedPath, isENOENT } from './filesystem-auth' import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { resolveLocalDroppedPathsForAgent } from './dropped-path-resolution' import { importExternalPathsSsh } from './filesystem-import-ssh' import { assertNoClobberRenameDestinationAvailable } from '../../shared/filesystem-rename-collision' @@ -209,7 +210,11 @@ export function registerFilesystemMutationHandlers(store: Store): void { // Why: `== null` (not `!args.connectionId`) so an empty string is // treated as a renderer error, not silently routed to the local branch. if (args.connectionId == null) { - return { resolvedPaths: args.paths, skipped: [], failed: [] } + return { + resolvedPaths: resolveLocalDroppedPathsForAgent(args.paths, args.worktreePath), + skipped: [], + failed: [] + } } const worktreePath = args.worktreePath.replace(/\/+$/, '') const destDir = `${worktreePath}/.orca/drops` @@ -289,6 +294,8 @@ export type StagedExternalImportEntry = const REMOTE_IMPORT_MAX_FILE_BYTES = 25 * 1024 * 1024 const REMOTE_IMPORT_MAX_TOTAL_BYTES = 100 * 1024 * 1024 +class RuntimeUploadSymlinkError extends Error {} + // ─── External Import Implementation ───────────────────────────────── /** @@ -420,14 +427,10 @@ async function stageOneSourceForRuntimeUpload( if (!sourceStat.isFile() && !sourceStat.isDirectory()) { return { sourcePath, status: 'skipped', reason: 'unsupported' } } - if (sourceStat.isDirectory() && (await preScanForSymlinks(resolvedSource))) { - return { sourcePath, status: 'skipped', reason: 'symlink' } - } - try { const entries = sourceStat.isDirectory() ? await stageDirectoryEntries(resolvedSource) - : [await stageFileEntry(resolvedSource, '')] + : [(await stageFileEntry(resolvedSource, '')).entry] return { sourcePath, status: 'staged', @@ -436,6 +439,9 @@ async function stageOneSourceForRuntimeUpload( entries } } catch (error) { + if (error instanceof RuntimeUploadSymlinkError) { + return { sourcePath, status: 'skipped', reason: 'symlink' } + } return { sourcePath, status: 'failed', @@ -452,7 +458,7 @@ async function stageDirectoryEntries(rootPath: string): Promise { const dirStat = await lstat(dirPath) if (dirStat.isSymbolicLink()) { - throw new Error( + throw new RuntimeUploadSymlinkError( `Symlink not allowed in '${normalizeRelativeUploadPath(relative(rootPath, dirPath))}'` ) } @@ -470,14 +476,10 @@ async function stageDirectoryEntries(rootPath: string): Promise { + options?: { rootRealPath?: string; totalBytesBefore?: number } +): Promise<{ entry: StagedExternalImportEntry; byteLength: number }> { const statResult = await lstat(filePath) const displayPath = normalizeRelativeUploadPath(relativePath) if (statResult.isSymbolicLink()) { - throw new Error(`Symlink not allowed in '${displayPath}'`) + throw new RuntimeUploadSymlinkError(`Symlink not allowed in '${displayPath}'`) } if (!statResult.isFile()) { throw new Error(`Unsupported file type in '${displayPath}'`) } - if (rootRealPath) { - await assertRealPathInsideRoot(rootRealPath, filePath, displayPath) + if (options?.rootRealPath) { + await assertRealPathInsideRoot(options.rootRealPath, filePath, displayPath) } - assertRemoteUploadBudget(relativePath, statResult.size, statResult.size) + const initialTotalBytes = + options?.totalBytesBefore === undefined + ? statResult.size + : options.totalBytesBefore + statResult.size + assertRemoteUploadBudget(relativePath, statResult.size, initialTotalBytes) const fileHandle = await open(filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)) try { const openedStat = await fileHandle.stat() @@ -526,16 +534,23 @@ async function stageFileEntry( ) { throw new Error(`File changed during upload staging: '${displayPath}'`) } - assertRemoteUploadBudget(relativePath, openedStat.size, openedStat.size) + const totalBytes = + options?.totalBytesBefore === undefined + ? openedStat.size + : options.totalBytesBefore + openedStat.size + assertRemoteUploadBudget(relativePath, openedStat.size, totalBytes) const buffer = await fileHandle.readFile() const afterReadStat = await fileHandle.stat() if (afterReadStat.size !== openedStat.size) { throw new Error(`File changed during upload staging: '${displayPath}'`) } return { - relativePath: displayPath, - kind: 'file', - contentBase64: buffer.toString('base64') + entry: { + relativePath: displayPath, + kind: 'file', + contentBase64: buffer.toString('base64') + }, + byteLength: openedStat.size } } finally { await fileHandle.close() @@ -549,7 +564,11 @@ async function assertRealPathInsideRoot( ): Promise { const candidateRealPath = await realpath(candidatePath) const relativeToRoot = relative(rootRealPath, candidateRealPath) - if (relativeToRoot !== '' && (relativeToRoot.startsWith('..') || isAbsolute(relativeToRoot))) { + // Why: `..name` is a valid child path; only `..` and `../...` escape. + if ( + relativeToRoot !== '' && + (relativeToRoot === '..' || relativeToRoot.startsWith(`..${sep}`) || isAbsolute(relativeToRoot)) + ) { throw new Error(`Path escaped upload root during staging: '${displayPath}'`) } } diff --git a/src/main/ipc/filesystem-search-git.test.ts b/src/main/ipc/filesystem-search-git.test.ts index 7cd3c767a99..3f29e3c10dc 100644 --- a/src/main/ipc/filesystem-search-git.test.ts +++ b/src/main/ipc/filesystem-search-git.test.ts @@ -168,6 +168,32 @@ describe('filesystem-search-git', () => { expect(result.totalMatches).toBe(0) }) + it('settles and detaches when git grep ignores the timeout kill', async () => { + vi.useFakeTimers() + + try { + const proc = createMockProcess() + spawnMock.mockReturnValue(proc) + + const promise = searchWithGitGrep('/mock/root', { query: 'ok', rootPath: '/mock/root' }, 100) + + ;(proc.stdout as unknown as EventEmitter).emit('data', 'valid.ts\x001:ok\npartial') + + await vi.runOnlyPendingTimersAsync() + + const result = await promise + expect(result.truncated).toBe(true) + expect(result.files).toHaveLength(1) + expect(proc.kill).toHaveBeenCalled() + expect((proc.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) + expect((proc.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0) + expect(proc.listenerCount('error')).toBe(0) + expect(proc.listenerCount('close')).toBe(0) + } finally { + vi.useRealTimers() + } + }) + it('skips lines without null separator', async () => { const proc = createMockProcess() spawnMock.mockReturnValue(proc) diff --git a/src/main/ipc/filesystem-search-git.ts b/src/main/ipc/filesystem-search-git.ts index 8ea8752d39e..41853c0f4d5 100644 --- a/src/main/ipc/filesystem-search-git.ts +++ b/src/main/ipc/filesystem-search-git.ts @@ -28,51 +28,68 @@ export function searchWithGitGrep( let stdoutBuffer = '' let done = false - const resolveOnce = (): void => { + const child = gitSpawn(gitArgs, { + cwd: rootPath, + stdio: ['ignore', 'pipe', 'pipe'] + }) + let killTimeout: ReturnType + + function resolveOnce(): void { if (done) { return } done = true clearTimeout(killTimeout) + // Why: child.kill() is advisory. If git ignores it, detach our + // closures so repeated fallback searches do not retain old scans. + child.stdout!.off('data', handleStdoutData) + child.stderr!.off('data', handleStderrData) + child.off('error', handleError) + child.off('close', handleClose) resolve(finalize(acc)) } - const processLine = (line: string): void => { + function processLine(line: string): void { const verdict = ingestGitGrepLine(line, rootPath, matchRegex, acc, maxResults) if (verdict === 'stop') { child.kill() } } - const child = gitSpawn(gitArgs, { - cwd: rootPath, - stdio: ['ignore', 'pipe', 'pipe'] - }) - child.stdout!.setEncoding('utf-8') - child.stdout!.on('data', (chunk: string) => { + function handleStdoutData(chunk: string): void { stdoutBuffer += chunk const lines = stdoutBuffer.split('\n') stdoutBuffer = lines.pop() ?? '' for (const l of lines) { processLine(l) } - }) - child.stderr!.on('data', () => { + } + + function handleStderrData(): void { /* drain */ - }) - child.once('error', () => { + } + + function handleError(): void { resolveOnce() - }) - child.once('close', () => { + } + + function handleClose(): void { if (stdoutBuffer) { processLine(stdoutBuffer) } resolveOnce() - }) + } - const killTimeout = setTimeout(() => { + child.stdout!.setEncoding('utf-8') + child.stdout!.on('data', handleStdoutData) + child.stderr!.on('data', handleStderrData) + child.once('error', handleError) + child.once('close', handleClose) + + killTimeout = setTimeout(() => { acc.truncated = true child.kill() + resolveOnce() }, SEARCH_TIMEOUT_MS) }) } diff --git a/src/main/ipc/filesystem-search-rg-timeout.test.ts b/src/main/ipc/filesystem-search-rg-timeout.test.ts new file mode 100644 index 00000000000..2619426824a --- /dev/null +++ b/src/main/ipc/filesystem-search-rg-timeout.test.ts @@ -0,0 +1,119 @@ +import { EventEmitter } from 'events' +import type { ChildProcess } from 'child_process' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { handleMock, resolveAuthorizedPathMock, checkRgAvailableMock, wslAwareSpawnMock } = + vi.hoisted(() => ({ + handleMock: vi.fn(), + resolveAuthorizedPathMock: vi.fn(), + checkRgAvailableMock: vi.fn(), + wslAwareSpawnMock: vi.fn() + })) + +const handlers = new Map Promise | unknown>() + +vi.mock('electron', () => ({ + ipcMain: { + handle: handleMock + }, + shell: { + trashItem: vi.fn() + } +})) + +vi.mock('../git/runner', () => ({ + gitExecFileAsync: vi.fn(), + wslAwareSpawn: wslAwareSpawnMock +})) + +vi.mock('../wsl', () => ({ + parseWslPath: vi.fn(() => null), + toWindowsWslPath: vi.fn((value: string) => value) +})) + +vi.mock('./filesystem-auth', () => ({ + authorizeExternalPath: vi.fn(async (value: string) => value), + isENOENT: vi.fn(() => false), + resolveAuthorizedPath: resolveAuthorizedPathMock, + resolveRegisteredWorktreePath: vi.fn(async (value: string) => value), + validateGitRelativeFilePath: vi.fn((value: string) => value) +})) + +vi.mock('./filesystem-list-files', () => ({ + listQuickOpenFiles: vi.fn() +})) + +vi.mock('./filesystem-mutations', () => ({ + registerFilesystemMutationHandlers: vi.fn() +})) + +vi.mock('./filesystem-search-git', () => ({ + searchWithGitGrep: vi.fn() +})) + +vi.mock('./markdown-documents', () => ({ + listMarkdownDocuments: vi.fn(), + markdownDocumentsFromRelativePaths: vi.fn() +})) + +vi.mock('./rg-availability', () => ({ + checkRgAvailable: checkRgAvailableMock +})) + +import { registerFilesystemHandlers } from './filesystem' + +function createMockProcess(): ChildProcess { + const p = new EventEmitter() as unknown as ChildProcess + ;(p as unknown as Record).stdout = new EventEmitter() + ;( + (p as unknown as Record).stdout as EventEmitter & { + setEncoding: () => void + } + ).setEncoding = vi.fn() + ;(p as unknown as Record).stderr = new EventEmitter() + ;(p as unknown as Record).kill = vi.fn() + return p +} + +describe('filesystem rg search timeout', () => { + beforeEach(() => { + handlers.clear() + vi.clearAllMocks() + handleMock.mockImplementation((channel, handler) => { + handlers.set(channel, handler) + }) + resolveAuthorizedPathMock.mockImplementation(async (value: string) => value) + checkRgAvailableMock.mockResolvedValue(true) + }) + + it('settles and detaches when rg ignores the timeout kill', async () => { + vi.useFakeTimers() + + try { + const child = createMockProcess() + wslAwareSpawnMock.mockReturnValue(child) + registerFilesystemHandlers({} as never) + + const promise = handlers.get('fs:search')!( + { sender: { id: 7 } }, + { rootPath: '/repo', query: 'ok' } + ) as Promise<{ truncated: boolean }> + + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + + await vi.runOnlyPendingTimersAsync() + + const result = await promise + expect(result.truncated).toBe(true) + expect(child.kill).toHaveBeenCalled() + expect((child.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) + expect((child.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0) + expect(child.listenerCount('error')).toBe(0) + expect(child.listenerCount('close')).toBe(0) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts b/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts index 427b8ee39f1..87582aa03c0 100644 --- a/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts +++ b/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { handleMock } = vi.hoisted(() => ({ handleMock: vi.fn() @@ -49,6 +49,10 @@ describe('local filesystem watcher unsubscribe cleanup', () => { await closeAllWatchers() }) + afterEach(async () => { + await closeAllWatchers() + }) + it('awaits an unsubscribe already started by sender cleanup during shutdown', async () => { vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) let resolveUnsubscribe: () => void = () => {} @@ -120,4 +124,131 @@ describe('local filesystem watcher unsubscribe cleanup', () => { await shutdownPromise expect(shutdownResolved).toBe(true) }) + + it('unsubscribes if the sender is destroyed while the local watcher is opening', async () => { + vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) + const destroyedCallbacks: (() => void)[] = [] + let resolveSubscribe: (subscription: { unsubscribe: () => void }) => void = () => {} + const unsubscribeMock = vi.fn() + vi.mocked(subscribeParcelWatcher).mockImplementation( + () => + new Promise((resolve) => { + resolveSubscribe = resolve as typeof resolveSubscribe + }) + ) + const sender = { + isDestroyed: () => false, + send: vi.fn(), + once: vi.fn((event: string, callback: () => void) => { + if (event === 'destroyed') { + destroyedCallbacks.push(callback) + } + }), + id: 1 + } + + const watchPromise = handlers['fs:watchWorktree']( + { sender }, + { worktreePath: '/tmp/repo' } + ) as Promise + await vi.waitFor(() => { + expect(subscribeParcelWatcher).toHaveBeenCalled() + }) + expect(destroyedCallbacks).toHaveLength(1) + destroyedCallbacks[0]() + resolveSubscribe({ unsubscribe: unsubscribeMock }) + await watchPromise + + expect(unsubscribeMock).toHaveBeenCalledTimes(1) + expect(sender.once).toHaveBeenCalledWith('destroyed', expect.any(Function)) + }) + + it('dedupes concurrent local watcher opens for the same root', async () => { + const statResolvers: (() => void)[] = [] + vi.mocked(stat).mockImplementation( + () => + new Promise((resolve) => { + statResolvers.push(() => resolve({ isDirectory: () => true } as never)) + }) + ) + const subscribeResolvers: ((subscription: { unsubscribe: () => void }) => void)[] = [] + const unsubscribeMock = vi.fn() + vi.mocked(subscribeParcelWatcher).mockImplementation( + () => + new Promise((resolve) => { + subscribeResolvers.push(resolve as (subscription: { unsubscribe: () => void }) => void) + }) + ) + const senderOne = { + isDestroyed: () => false, + send: vi.fn(), + once: vi.fn(), + id: 1 + } + const senderTwo = { + isDestroyed: () => false, + send: vi.fn(), + once: vi.fn(), + id: 2 + } + + const watchOne = handlers['fs:watchWorktree']( + { sender: senderOne }, + { worktreePath: '/tmp/repo' } + ) as Promise + await vi.waitFor(() => { + expect(statResolvers).toHaveLength(1) + }) + const watchTwo = handlers['fs:watchWorktree']( + { sender: senderTwo }, + { worktreePath: '/tmp/repo' } + ) as Promise + + try { + await Promise.resolve() + for (const resolveStat of statResolvers) { + resolveStat() + } + await vi.waitFor(() => { + expect(subscribeParcelWatcher).toHaveBeenCalled() + }) + await Promise.resolve() + + expect(subscribeParcelWatcher).toHaveBeenCalledTimes(1) + } finally { + for (const resolveSubscribe of subscribeResolvers) { + resolveSubscribe({ unsubscribe: unsubscribeMock }) + } + await Promise.allSettled([watchOne, watchTwo]) + } + + expect(senderOne.once).toHaveBeenCalledWith('destroyed', expect.any(Function)) + expect(senderTwo.once).toHaveBeenCalledWith('destroyed', expect.any(Function)) + }) + + it('keeps a single grace teardown timer for duplicate local unwatch calls', async () => { + vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) + const unsubscribeMock = vi.fn() + vi.mocked(subscribeParcelWatcher).mockResolvedValue({ unsubscribe: unsubscribeMock } as never) + const sender = { + isDestroyed: () => false, + send: vi.fn(), + once: vi.fn(), + id: 1 + } + + await handlers['fs:watchWorktree']({ sender }, { worktreePath: '/tmp/repo' }) + + vi.useFakeTimers() + try { + handlers['fs:unwatchWorktree']({ sender: { id: 1 } }, { worktreePath: '/tmp/repo' }) + handlers['fs:unwatchWorktree']({ sender: { id: 1 } }, { worktreePath: '/tmp/repo' }) + + expect(vi.getTimerCount()).toBe(1) + await vi.advanceTimersByTimeAsync(30_000) + expect(unsubscribeMock).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) }) diff --git a/src/main/ipc/filesystem-watcher-unwatchable-roots.test.ts b/src/main/ipc/filesystem-watcher-unwatchable-roots.test.ts new file mode 100644 index 00000000000..5e625de192b --- /dev/null +++ b/src/main/ipc/filesystem-watcher-unwatchable-roots.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { handleMock } = vi.hoisted(() => ({ + handleMock: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: handleMock + } +})) + +vi.mock('fs/promises', () => ({ + stat: vi.fn() +})) + +vi.mock('@parcel/watcher', () => ({ + subscribe: vi.fn() +})) + +vi.mock('./filesystem-watcher-wsl', () => ({ + createWslWatcher: vi.fn() +})) + +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: vi.fn() +})) + +import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher' +import { stat } from 'fs/promises' + +type HandlerMap = Record Promise | unknown> + +describe('filesystem watcher unwatchable root cache', () => { + const handlers: HandlerMap = {} + + beforeEach(async () => { + handleMock.mockReset() + vi.mocked(stat).mockReset() + for (const key of Object.keys(handlers)) { + delete handlers[key] + } + handleMock.mockImplementation((channel, handler) => { + handlers[channel] = handler + }) + registerFilesystemWatcherHandlers() + await closeAllWatchers() + }) + + it('evicts oldest failed local roots while suppressing recent retries', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + vi.mocked(stat).mockRejectedValue(new Error('missing')) + + for (let i = 0; i < 257; i += 1) { + await handlers['fs:watchWorktree']({ sender }, { worktreePath: `/tmp/missing-${i}` }) + } + expect(stat).toHaveBeenCalledTimes(257) + + await handlers['fs:watchWorktree']({ sender }, { worktreePath: '/tmp/missing-0' }) + expect(stat).toHaveBeenCalledTimes(258) + + await handlers['fs:watchWorktree']({ sender }, { worktreePath: '/tmp/missing-256' }) + expect(stat).toHaveBeenCalledTimes(258) + + warnSpy.mockRestore() + await closeAllWatchers() + }) +}) diff --git a/src/main/ipc/filesystem-watcher-wsl.test.ts b/src/main/ipc/filesystem-watcher-wsl.test.ts index c6536338bf6..8b310436608 100644 --- a/src/main/ipc/filesystem-watcher-wsl.test.ts +++ b/src/main/ipc/filesystem-watcher-wsl.test.ts @@ -143,6 +143,40 @@ describe('createWslWatcher', () => { await root.subscription.unsubscribe() }) + it('limits concurrent child directory reads during WSL snapshots', async () => { + const scheduleBatchFlush = vi.fn() + const childDirs = Array.from({ length: 40 }, (_, index) => dirent(`dir-${index}`, 'dir')) + let activeChildReads = 0 + let maxActiveChildReads = 0 + + readdirMock.mockImplementation((dirPath: string) => { + if (dirPath === rootPath) { + return Promise.resolve(childDirs) + } + activeChildReads += 1 + maxActiveChildReads = Math.max(maxActiveChildReads, activeChildReads) + return new Promise[]>((resolve) => { + setTimeout(() => { + activeChildReads -= 1 + resolve([]) + }, 1) + }) + }) + + const rootPromise = createWslWatcher(rootKey, rootPath, deps(scheduleBatchFlush)) + await Promise.resolve() + await Promise.resolve() + + expect(maxActiveChildReads).toBeLessThanOrEqual(8) + for (let i = 0; i < childDirs.length; i += 1) { + await vi.advanceTimersByTimeAsync(1) + } + + const root = await rootPromise + expect(maxActiveChildReads).toBeLessThanOrEqual(8) + await root.subscription.unsubscribe() + }) + it('marks a large WSL poll event batch for overflow without retaining every event', async () => { const scheduleBatchFlush = vi.fn() const initialEntries = Array.from({ length: 200_000 }, (_, index) => dirent(`file-${index}.ts`)) diff --git a/src/main/ipc/filesystem-watcher-wsl.ts b/src/main/ipc/filesystem-watcher-wsl.ts index 0955207d090..e7d045445d8 100644 --- a/src/main/ipc/filesystem-watcher-wsl.ts +++ b/src/main/ipc/filesystem-watcher-wsl.ts @@ -14,7 +14,7 @@ import { readdir } from 'fs/promises' import * as path from 'path' import type { WebContents } from 'electron' import type { Event as WatcherEvent } from '@parcel/watcher' -import { queueWatcherEvents } from './filesystem-watcher-event-batch' +import { MAX_BATCHED_WATCHER_EVENTS, queueWatcherEvents } from './filesystem-watcher-event-batch' export type WatcherSubscription = { unsubscribe(): Promise @@ -40,6 +40,8 @@ export type WslWatcherDeps = { } const POLL_INTERVAL_MS = 2000 +const SNAPSHOT_CHILD_READ_CONCURRENCY = 8 +const DIFF_EVENT_OVERFLOW_LIMIT = MAX_BATCHED_WATCHER_EVENTS + 1 type DirSnapshot = Map> @@ -56,6 +58,23 @@ function shouldIgnore(name: string, ignoreDirs: string[]): boolean { return ignoreDirs.includes(name) } +async function forEachWithConcurrency( + items: readonly T[], + limit: number, + worker: (item: T) => Promise +): Promise { + let nextIndex = 0 + const workerCount = Math.min(limit, items.length) + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (nextIndex < items.length) { + const item = items[nextIndex++] + await worker(item) + } + }) + ) +} + /** * Take a snapshot of the root directory and one level of subdirectories. * Returns a map of dirPath → set of entry names. @@ -70,22 +89,24 @@ async function takeSnapshot(rootPath: string, ignoreDirs: string[]): Promise entry.isDirectory() || entry.isSymbolicLink()) - .map(async (entry) => { - const childPath = path.join(rootPath, entry.name) - const childEntries = await readDirEntriesSafe(childPath) - const childFiltered = childEntries - .filter((childEntry) => !shouldIgnore(childEntry.name, ignoreDirs)) - .map((childEntry) => childEntry.name) - snapshot.set(childPath, new Set(childFiltered)) - }) - ) + const childDirs = filtered.filter((entry) => entry.isDirectory() || entry.isSymbolicLink()) + await forEachWithConcurrency(childDirs, SNAPSHOT_CHILD_READ_CONCURRENCY, async (entry) => { + const childPath = path.join(rootPath, entry.name) + const childEntries = await readDirEntriesSafe(childPath) + const childFiltered = childEntries + .filter((childEntry) => !shouldIgnore(childEntry.name, ignoreDirs)) + .map((childEntry) => childEntry.name) + snapshot.set(childPath, new Set(childFiltered)) + }) return snapshot } +function appendDiffEvent(events: WatcherEvent[], event: WatcherEvent): boolean { + events.push(event) + return events.length >= DIFF_EVENT_OVERFLOW_LIMIT +} + /** * Diff two snapshots and return synthetic watcher events. */ @@ -97,7 +118,14 @@ function diffSnapshots(prev: DirSnapshot, next: DirSnapshot): WatcherEvent[] { if (!prevEntries) { // New directory appeared — emit create for all entries for (const name of nextEntries) { - events.push({ type: 'create', path: path.join(dirPath, name) } as WatcherEvent) + if ( + appendDiffEvent(events, { + type: 'create', + path: path.join(dirPath, name) + } as WatcherEvent) + ) { + return events + } } continue } @@ -105,14 +133,28 @@ function diffSnapshots(prev: DirSnapshot, next: DirSnapshot): WatcherEvent[] { // Check for new entries (create) for (const name of nextEntries) { if (!prevEntries.has(name)) { - events.push({ type: 'create', path: path.join(dirPath, name) } as WatcherEvent) + if ( + appendDiffEvent(events, { + type: 'create', + path: path.join(dirPath, name) + } as WatcherEvent) + ) { + return events + } } } // Check for removed entries (delete) for (const name of prevEntries) { if (!nextEntries.has(name)) { - events.push({ type: 'delete', path: path.join(dirPath, name) } as WatcherEvent) + if ( + appendDiffEvent(events, { + type: 'delete', + path: path.join(dirPath, name) + } as WatcherEvent) + ) { + return events + } } } } @@ -120,7 +162,9 @@ function diffSnapshots(prev: DirSnapshot, next: DirSnapshot): WatcherEvent[] { // Check for directories that disappeared entirely for (const [dirPath] of prev) { if (!next.has(dirPath)) { - events.push({ type: 'delete', path: dirPath } as WatcherEvent) + if (appendDiffEvent(events, { type: 'delete', path: dirPath } as WatcherEvent)) { + return events + } } } diff --git a/src/main/ipc/filesystem-watcher.test.ts b/src/main/ipc/filesystem-watcher.test.ts index d7496128a3b..04045eb1049 100644 --- a/src/main/ipc/filesystem-watcher.test.ts +++ b/src/main/ipc/filesystem-watcher.test.ts @@ -1,3 +1,6 @@ +/* eslint-disable max-lines -- Why: filesystem watcher tests share module-level +state across local, WSL, and SSH lifecycle paths; keeping them together makes +closeAllWatchers cleanup regressions visible in one suite. */ import { beforeEach, describe, expect, it, vi } from 'vitest' const { handleMock, getSshFilesystemProviderMock } = vi.hoisted(() => ({ @@ -37,7 +40,7 @@ describe('registerFilesystemWatcherHandlers', () => { const handlers: HandlerMap = {} const originalPlatform = process.platform - beforeEach(() => { + beforeEach(async () => { vi.useRealTimers() handleMock.mockReset() getSshFilesystemProviderMock.mockReset() @@ -54,6 +57,7 @@ describe('registerFilesystemWatcherHandlers', () => { handlers[channel] = handler }) registerFilesystemWatcherHandlers() + await closeAllWatchers() }) it('pins Parcel to the Windows backend for local Windows watches', async () => { @@ -138,6 +142,38 @@ describe('registerFilesystemWatcherHandlers', () => { vi.useRealTimers() }) + it('retries SSH worktree watches when provider setup rejects', async () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const sendMock = vi.fn() + const sender = { isDestroyed: () => false, send: sendMock, once: vi.fn(), id: 1 } + const unwatchMock = vi.fn() + const retryWatchMock = vi.fn().mockResolvedValue(unwatchMock) + getSshFilesystemProviderMock + .mockReturnValueOnce({ + watch: vi.fn().mockRejectedValue(new Error('provider disposed')) + }) + .mockReturnValue({ watch: retryWatchMock }) + + await expect( + handlers['fs:watchWorktree']( + { sender }, + { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + ) + ).resolves.toBeUndefined() + + await vi.advanceTimersByTimeAsync(1_000) + + expect(retryWatchMock).toHaveBeenCalledWith('/home/me/repo', expect.any(Function)) + handlers['fs:unwatchWorktree']( + { sender: { id: 1 } }, + { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + ) + expect(unwatchMock).toHaveBeenCalledTimes(1) + warnSpy.mockRestore() + vi.useRealTimers() + }) + it('cancels pending SSH watch retries during watcher shutdown', async () => { vi.useFakeTimers() const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) @@ -281,6 +317,45 @@ describe('registerFilesystemWatcherHandlers', () => { expect(unwatchMock).toHaveBeenCalledTimes(1) }) + it('unsubscribes if the sender is destroyed while an SSH watcher is opening', async () => { + const destroyedCallbacks: (() => void)[] = [] + const sender = { + isDestroyed: () => false, + send: vi.fn(), + once: vi.fn((event: string, callback: () => void) => { + if (event === 'destroyed') { + destroyedCallbacks.push(callback) + } + }), + id: 1 + } + const unwatchMock = vi.fn() + let resolveWatch: (unwatch: () => void) => void = () => {} + const watchPromise = new Promise<() => void>((resolve) => { + resolveWatch = resolve + }) + const watchMock = vi.fn().mockReturnValue(watchPromise) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + + const watch = handlers['fs:watchWorktree']( + { sender }, + { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + ) as Promise + + await Promise.resolve() + expect(watchMock).toHaveBeenCalledTimes(1) + expect(destroyedCallbacks).toHaveLength(1) + + destroyedCallbacks[0]() + resolveWatch(unwatchMock) + await watch + + expect(unwatchMock).toHaveBeenCalledTimes(1) + const onEvents = watchMock.mock.calls[0][1] + onEvents([{ path: '/home/me/repo/file.txt', type: 'update' }]) + expect(sender.send).not.toHaveBeenCalled() + }) + it('revives a pending SSH watcher install when a new sender joins after cancellation', async () => { const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' } const senderOne = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } diff --git a/src/main/ipc/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts index 2a3b27d97bf..ca4129d04f9 100644 --- a/src/main/ipc/filesystem-watcher.ts +++ b/src/main/ipc/filesystem-watcher.ts @@ -49,8 +49,23 @@ const watchedRoots = new Map() // @parcel/watcher's ReadDirectoryChangesW doesn't work) are cached so // we don't retry on every worktree switch and spam the console with // repeated "Failed to read changes" / "watchman not found" errors. +const UNWATCHABLE_ROOT_CACHE_MAX = 256 const unwatchableRoots = new Set() +function rememberUnwatchableRoot(rootKey: string): void { + // Why: missing/deleted worktrees can churn through unique paths during a long + // session; keep retry suppression useful without retaining every failed path. + unwatchableRoots.delete(rootKey) + unwatchableRoots.add(rootKey) + while (unwatchableRoots.size > UNWATCHABLE_ROOT_CACHE_MAX) { + const oldest = unwatchableRoots.keys().next().value + if (oldest === undefined) { + break + } + unwatchableRoots.delete(oldest) + } +} + // Why: watcher cleanup is keyed to the renderer WebContents, not to a specific // watched root. One listener per sender avoids MaxListeners warnings when a // workspace has many local and SSH-backed worktrees open. @@ -66,6 +81,36 @@ const pendingTeardowns = new Map>() // Why: @parcel/watcher unsubscribe completes native async work. Sender-destroy // cleanup can start it before app shutdown, so will-quit must still await it. const pendingLocalUnsubscribes = new Set>() +type LocalWatcherInstallToken = { + cancelled: boolean + listeners: Map +} +type LocalWatcherInstallResult = 'installed' | 'unavailable' | 'cancelled' +// Why: native watcher creation is async. Concurrent local watch requests for +// the same root must share one install or later resolves can orphan listeners. +const inFlightLocalInstalls = new Map() +const pendingLocalInstallPromises = new Map>() + +function addInFlightLocalInstallListener( + token: LocalWatcherInstallToken, + sender: WebContents +): void { + if (sender.isDestroyed()) { + return + } + token.listeners.set(sender.id, sender) + token.cancelled = false + registerSenderCleanup(sender) +} + +function cleanupInFlightLocalInstallsForSender(senderId: number): void { + for (const token of inFlightLocalInstalls.values()) { + token.listeners.delete(senderId) + if (token.listeners.size === 0) { + token.cancelled = true + } + } +} // ── Path normalization ─────────────────────────────────────────────── @@ -326,6 +371,7 @@ async function createWatcher(rootKey: string, rootPath: string): Promise { const rootKey = normalizeRootPath(worktreePath) + if (sender.isDestroyed()) { + return + } // Don't retry roots that already failed — avoids repeated error spam. if (unwatchableRoots.has(rootKey)) { + rememberUnwatchableRoot(rootKey) return } @@ -388,47 +447,112 @@ async function subscribe(worktreePath: string, sender: WebContents): Promise { + let root: WatchedRoot + try { + const s = await stat(rootKey) + if (!s.isDirectory()) { + console.warn(`[filesystem-watcher] not a directory: ${rootKey}`) + rememberUnwatchableRoot(rootKey) + return 'unavailable' + } + } catch { + console.warn(`[filesystem-watcher] cannot stat root: ${rootKey}`) + rememberUnwatchableRoot(rootKey) + return 'unavailable' + } + + try { + // Why: WSL paths use inotifywait inside the Linux distro where + // inotify works natively; native Windows paths use @parcel/watcher. + root = isWslPath(worktreePath) + ? await createWslWatcher(rootKey, worktreePath, { + ignoreDirs: WATCHER_IGNORE_DIRS, + scheduleBatchFlush, + watchedRoots + }) + : await createWatcher(rootKey, rootKey) + } catch { + // Why: createWatcher / createWslWatcher already logged the error. Swallow + // it here so the renderer's watchWorktree call resolves without crashing. + rememberUnwatchableRoot(rootKey) + return 'unavailable' + } finally { + if (inFlightLocalInstalls.get(rootKey) === cancelToken) { + inFlightLocalInstalls.delete(rootKey) + } + } + + const liveListeners = new Map( + Array.from(cancelToken.listeners.entries()).filter(([, listener]) => !listener.isDestroyed()) + ) + if (cancelToken.cancelled || liveListeners.size === 0) { + if (root.batch.timer) { + clearTimeout(root.batch.timer) + } + void trackLocalUnsubscribe(rootKey, root) + return 'cancelled' + } + + root.listeners = liveListeners + watchedRoots.set(rootKey, root) + for (const listener of liveListeners.values()) { + registerSenderCleanup(listener) + } + return 'installed' } function unsubscribe(worktreePath: string, senderId: number): void { const rootKey = normalizeRootPath(worktreePath) + const inFlight = inFlightLocalInstalls.get(rootKey) + if (inFlight) { + inFlight.listeners.delete(senderId) + inFlight.cancelled = inFlight.listeners.size === 0 + } + const root = watchedRoots.get(rootKey) if (!root) { return @@ -443,6 +567,12 @@ function unsubscribe(worktreePath: string, senderId: number): void { clearTimeout(root.batch.timer) } + // Why: duplicate renderer cleanup can call unwatch more than once for a + // root; keep one tracked grace timer instead of leaking overwritten timers. + if (pendingTeardowns.has(rootKey)) { + return + } + const teardownTimer = setTimeout(() => { pendingTeardowns.delete(rootKey) // Re-check: a new listener may have arrived during the grace period. @@ -486,6 +616,27 @@ const pendingRemoteInstallPromises = new Map { senderCleanupRegistered.clear() + unwatchableRoots.clear() // Cancel any pending grace-period teardowns — we're tearing down everything. for (const timer of pendingTeardowns.values()) { @@ -748,6 +901,10 @@ export async function closeAllWatchers(): Promise { for (const token of inFlightRemoteInstalls.values()) { token.cancelled = true } + for (const token of inFlightLocalInstalls.values()) { + token.listeners.clear() + token.cancelled = true + } for (const [rootKey, root] of watchedRoots) { if (root.batch.timer) { diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 5fb43bd5c1f..8ff6769fbb5 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -523,6 +523,7 @@ export function registerFilesystemHandlers( let stdoutBuffer = '' let resolved = false let child: ChildProcess | null = null + let killTimeout: ReturnType // Why: when rg runs inside WSL, output paths are Linux-native // (e.g. /home/user/repo/src/file.ts). Translate them back to @@ -541,6 +542,12 @@ export function registerFilesystemHandlers( activeTextSearches.delete(searchKey) } clearTimeout(killTimeout) + // Why: child.kill() is advisory. If rg ignores it, detach our + // closures so repeated local searches do not retain old scans. + child?.stdout?.off('data', handleStdoutData) + child?.stderr?.off('data', handleStderrData) + child?.off('error', handleError) + child?.off('close', handleClose) resolvePromise(finalize(acc)) } @@ -558,35 +565,39 @@ export function registerFilesystemHandlers( child = nextChild activeTextSearches.set(searchKey, nextChild) - nextChild.stdout!.setEncoding('utf-8') - nextChild.stdout!.on('data', (chunk: string) => { + const handleStdoutData = (chunk: string): void => { stdoutBuffer += chunk const lines = stdoutBuffer.split('\n') stdoutBuffer = lines.pop() ?? '' for (const line of lines) { processLine(line) } - }) - nextChild.stderr!.on('data', () => { + } + const handleStderrData = (): void => { // Drain stderr so rg cannot block on a full pipe. - }) - - nextChild.once('error', () => { + } + const handleError = (): void => { resolveOnce() - }) - - nextChild.once('close', () => { + } + const handleClose = (): void => { if (stdoutBuffer) { processLine(stdoutBuffer) } resolveOnce() - }) + } + + nextChild.stdout!.setEncoding('utf-8') + nextChild.stdout!.on('data', handleStdoutData) + nextChild.stderr!.on('data', handleStderrData) + nextChild.once('error', handleError) + nextChild.once('close', handleClose) // Why: if the timeout fires, the child is killed and results are partial. // We must mark them as truncated so the UI can indicate incomplete results. - const killTimeout = setTimeout(() => { + killTimeout = setTimeout(() => { acc.truncated = true child?.kill() + resolveOnce() }, SEARCH_TIMEOUT_MS) }) } diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index 4a7fe5cb36c..bce2e4544bb 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -6,6 +6,7 @@ import { ipcMain, webContents } from 'electron' import { resolve } from 'path' import type { Repo, + GitHubCreateIssueFields, GitHubIssueUpdate, GitHubOwnerRepo, GitHubPullRequestStateUpdate, @@ -48,6 +49,7 @@ import { starOrca } from '../github/client' import { + clearVisiblePRRefreshWindow, enqueuePRRefresh, refreshPRNow, reportVisiblePRRefreshCandidates, @@ -97,6 +99,8 @@ import { appStarSourceSchema } from '../../shared/gh-star-source' import { track } from '../telemetry/client' import { getCohortAtEmit } from '../telemetry/cohort-classifier' +const prRefreshVisibilityCleanupRegistered = new Set() + // Why: notify every renderer (each window has its own SWR cache instance) // that a work item was mutated locally so they can drop their cached entry // and refetch on the next open. Only emitted after a successful mutation. @@ -247,6 +251,14 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi ipcMain.handle( 'gh:reportVisiblePRRefreshCandidates', (event, args: { candidates: GitHubPRRefreshCandidate[]; generation: number }) => { + const senderId = event.sender.id + if (!prRefreshVisibilityCleanupRegistered.has(senderId)) { + prRefreshVisibilityCleanupRegistered.add(senderId) + event.sender.once('destroyed', () => { + prRefreshVisibilityCleanupRegistered.delete(senderId) + clearVisiblePRRefreshWindow(senderId) + }) + } const candidates = args.candidates.map((candidate) => { const repo = assertRegisteredRepo(candidate.repoPath, store) return { @@ -257,7 +269,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi connectionState: repo.connectionId ? 'connected' : candidate.connectionState } }) - reportVisiblePRRefreshCandidates(candidates, args.generation, event.sender.id) + reportVisiblePRRefreshCandidates(candidates, args.generation, senderId) return true } ) @@ -282,14 +294,19 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi ipcMain.handle( 'gh:createIssue', - (_event, args: { repoPath: string; title: string; body: string }) => { + (_event, args: { repoPath: string; title: string; body: string } & GitHubCreateIssueFields) => { const repo = assertRegisteredRepo(args, store) + const fields = + args.labels !== undefined || args.assignees !== undefined + ? { labels: args.labels, assignees: args.assignees } + : undefined return createIssue( repo.path, args.title, args.body, repo.issueSourcePreference, - repoConnectionId(repo) + repoConnectionId(repo), + fields ) } ) @@ -522,12 +539,14 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi event, args: { repoPath: string + repoId?: string prNumber: number commentId: number body: string threadId?: string path?: string line?: number + prRepo?: GitHubOwnerRepo | null } ) => { const repo = assertRegisteredRepo(args, store) @@ -556,7 +575,8 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi args.threadId, args.path, args.line, - repoConnectionId(repo) + repoConnectionId(repo), + args.prRepo ?? null ) if (result.ok) { broadcastWorkItemMutated( @@ -834,7 +854,14 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi 'gh:addIssueComment', async ( event, - args: { repoPath: string; number: number; body: string; type?: 'issue' | 'pr' } + args: { + repoPath: string + repoId?: string + number: number + body: string + type?: 'issue' | 'pr' + prRepo?: GitHubOwnerRepo | null + } ) => { const repo = assertRegisteredRepo(args, store) if (typeof args.number !== 'number' || !Number.isInteger(args.number) || args.number < 1) { @@ -847,7 +874,8 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi repo.path, args.number, args.body.trim(), - repoConnectionId(repo) + repoConnectionId(repo), + args.prRepo ?? null ) if (result.ok) { // Why: PR conversation comments hit `/issues/N/comments` too, but the diff --git a/src/main/ipc/gitlab.ts b/src/main/ipc/gitlab.ts index fba51798aa2..c753c1de624 100644 --- a/src/main/ipc/gitlab.ts +++ b/src/main/ipc/gitlab.ts @@ -3,7 +3,13 @@ GitLab IPC handlers co-located keeps the repo-path validation pattern reviewable as one surface. */ import { ipcMain } from 'electron' import { resolve } from 'path' -import type { GitLabIssueUpdate, GitLabWorkItem, Repo } from '../../shared/types' +import type { + GitLabIssueUpdate, + GitLabMRInlineCommentInput, + GitLabMRUpdate, + GitLabWorkItem, + Repo +} from '../../shared/types' import type { Store } from '../persistence' import { normalizeGitLabIssueAssignee, @@ -14,14 +20,18 @@ import { import { recordGitLabProjectRecent } from '../gitlab/gitlab-project-recents' import { addIssueComment, + addMRInlineComment, addMRComment, closeMR, createIssue, + diagnoseAuth, getAuthenticatedViewer, + getJobTrace, getIssue, getMergeRequest, getMergeRequestForBranch, getProjectSlug, + getRateLimit, getWorkItemByProjectRef, listAssignableUsers, listIssues, @@ -31,7 +41,11 @@ import { listWorkItems, mergeMR, reopenMR, - updateIssue + resolveMRDiscussion, + retryJob, + updateIssue, + updateMR, + updateMRReviewers } from '../gitlab/client' import { getWorkItemDetails } from '../gitlab/work-item-details' import type { ProjectRef } from '../gitlab/gl-utils' @@ -57,6 +71,14 @@ export function registerGitLabHandlers(store: Store): void { return getAuthenticatedViewer() }) + ipcMain.handle('gitlab:diagnoseAuth', async () => diagnoseAuth()) + + ipcMain.handle( + 'gitlab:rateLimit', + async (_event, args?: { force?: boolean; host?: string | null }) => + getRateLimit({ force: Boolean(args?.force), host: args?.host ?? null }) + ) + ipcMain.handle('gitlab:projectSlug', async (_event, args: { repoPath: string }) => { const repo = assertRegisteredRepo(args.repoPath, store) return getProjectSlug(repo.path, repoConnectionId(repo)) @@ -276,6 +298,43 @@ export function registerGitLabHandlers(store: Store): void { } ) + ipcMain.handle( + 'gitlab:updateMR', + async (_event, args: { repoPath: string; iid: number; updates: GitLabMRUpdate }) => { + const repo = assertRegisteredRepo(args.repoPath, store) + return updateMR( + repo.path, + args.iid, + args.updates, + repo.issueSourcePreference, + repoConnectionId(repo) + ) + } + ) + + ipcMain.handle( + 'gitlab:updateMRReviewers', + async ( + _event, + args: { + repoPath: string + iid: number + reviewerIds: number[] + projectRef?: ProjectRef | null + } + ) => { + const repo = assertRegisteredRepo(args.repoPath, store) + return updateMRReviewers( + repo.path, + args.iid, + args.reviewerIds, + repo.issueSourcePreference, + repoConnectionId(repo), + args.projectRef + ) + } + ) + ipcMain.handle( 'gitlab:addMRComment', async (_event, args: { repoPath: string; iid: number; body: string }) => { @@ -290,6 +349,75 @@ export function registerGitLabHandlers(store: Store): void { } ) + ipcMain.handle( + 'gitlab:addMRInlineComment', + async ( + _event, + args: { + repoPath: string + iid: number + input: GitLabMRInlineCommentInput + projectRef?: ProjectRef | null + } + ) => { + const repo = assertRegisteredRepo(args.repoPath, store) + return addMRInlineComment( + repo.path, + args.iid, + args.input, + repo.issueSourcePreference, + repoConnectionId(repo), + args.projectRef + ) + } + ) + + ipcMain.handle( + 'gitlab:resolveMRDiscussion', + async ( + _event, + args: { repoPath: string; iid: number; discussionId: string; resolved: boolean } + ) => { + const repo = assertRegisteredRepo(args.repoPath, store) + return resolveMRDiscussion( + repo.path, + args.iid, + args.discussionId, + args.resolved, + repo.issueSourcePreference, + repoConnectionId(repo) + ) + } + ) + + ipcMain.handle( + 'gitlab:jobTrace', + async (_event, args: { repoPath: string; jobId: number; projectRef?: ProjectRef | null }) => { + const repo = assertRegisteredRepo(args.repoPath, store) + return getJobTrace( + repo.path, + args.jobId, + repo.issueSourcePreference, + repoConnectionId(repo), + args.projectRef + ) + } + ) + + ipcMain.handle( + 'gitlab:retryJob', + async (_event, args: { repoPath: string; jobId: number; projectRef?: ProjectRef | null }) => { + const repo = assertRegisteredRepo(args.repoPath, store) + return retryJob( + repo.path, + args.jobId, + repo.issueSourcePreference, + repoConnectionId(repo), + args.projectRef + ) + } + ) + // Why: My Todos surface — cross-project, user-scoped. The repoPath is // only used for the registered-repo guard; `glab api todos` doesn't // care about cwd because the endpoint is user-scoped. diff --git a/src/main/ipc/linear.ts b/src/main/ipc/linear.ts index c0b4b02c2c3..775e5b1c81c 100644 --- a/src/main/ipc/linear.ts +++ b/src/main/ipc/linear.ts @@ -1,3 +1,5 @@ +/* eslint-disable max-lines -- Why: Linear IPC validates one namespace in one + registration boundary so local and SSH runtime schemas can stay mirrored. */ import { ipcMain } from 'electron' import { connect, disconnect, getStatus, selectWorkspace, testConnection } from '../linear/client' import { _resetPreflightCache } from './preflight' @@ -10,10 +12,22 @@ import { addIssueComment, getIssueComments } from '../linear/issues' -import { listProjects } from '../linear/projects' +import { + getCustomView, + getProject, + listCustomViewIssues, + listCustomViewProjects, + listCustomViews, + listProjectIssues, + listProjects +} from '../linear/projects' import { listTeams, getTeamStates, getTeamLabels, getTeamMembers } from '../linear/teams' import type { LinearListFilter } from '../linear/issues' -import type { LinearIssueUpdate, LinearWorkspaceSelection } from '../../shared/types' +import type { + LinearCustomViewModel, + LinearIssueUpdate, + LinearWorkspaceSelection +} from '../../shared/types' const VALID_FILTERS = new Set(['assigned', 'created', 'all', 'completed']) @@ -26,6 +40,21 @@ function normalizeWorkspaceSelection(value: unknown): LinearWorkspaceSelection | return workspaceId as LinearWorkspaceSelection | undefined } +function normalizeConcreteWorkspaceId(value: unknown): string { + const workspaceId = normalizeWorkspaceId(value) + if (!workspaceId || workspaceId === 'all') { + throw new Error('Concrete Linear workspace ID is required') + } + return workspaceId +} + +function normalizeCustomViewModel(value: unknown): LinearCustomViewModel { + if (value !== 'issue' && value !== 'project') { + throw new Error('Custom view model is required') + } + return value +} + export function registerLinearHandlers(): void { ipcMain.handle('linear:connect', async (_event, args: { apiKey: string }) => { if (typeof args?.apiKey !== 'string' || !args.apiKey.trim()) { @@ -244,6 +273,97 @@ export function registerLinearHandlers(): void { } ) + ipcMain.handle( + 'linear:getProject', + async (_event, args: { id: string; workspaceId?: string }) => { + if (typeof args?.id !== 'string' || !args.id.trim()) { + throw new Error('Project ID is required') + } + return getProject(args.id.trim(), normalizeConcreteWorkspaceId(args.workspaceId)) + } + ) + + ipcMain.handle( + 'linear:listProjectIssues', + async (_event, args: { projectId: string; limit?: number; workspaceId?: string }) => { + if (typeof args?.projectId !== 'string' || !args.projectId.trim()) { + throw new Error('Project ID is required') + } + const limit = Math.min(Math.max(1, args?.limit ?? 20), 50) + return listProjectIssues( + args.projectId.trim(), + limit, + normalizeConcreteWorkspaceId(args.workspaceId) + ) + } + ) + + ipcMain.handle( + 'linear:listCustomViews', + async ( + _event, + args?: { + model?: LinearCustomViewModel + limit?: number + workspaceId?: LinearWorkspaceSelection + } + ) => { + const limit = Math.min(Math.max(1, args?.limit ?? 20), 50) + return listCustomViews( + normalizeCustomViewModel(args?.model), + limit, + normalizeWorkspaceSelection(args?.workspaceId) + ) + } + ) + + ipcMain.handle( + 'linear:getCustomView', + async ( + _event, + args: { viewId: string; model?: LinearCustomViewModel; workspaceId?: string } + ) => { + if (typeof args?.viewId !== 'string' || !args.viewId.trim()) { + throw new Error('Custom view ID is required') + } + return getCustomView( + args.viewId.trim(), + normalizeCustomViewModel(args.model), + normalizeConcreteWorkspaceId(args.workspaceId) + ) + } + ) + + ipcMain.handle( + 'linear:listCustomViewIssues', + async (_event, args: { viewId: string; limit?: number; workspaceId?: string }) => { + if (typeof args?.viewId !== 'string' || !args.viewId.trim()) { + throw new Error('Custom view ID is required') + } + const limit = Math.min(Math.max(1, args?.limit ?? 20), 50) + return listCustomViewIssues( + args.viewId.trim(), + limit, + normalizeConcreteWorkspaceId(args.workspaceId) + ) + } + ) + + ipcMain.handle( + 'linear:listCustomViewProjects', + async (_event, args: { viewId: string; limit?: number; workspaceId?: string }) => { + if (typeof args?.viewId !== 'string' || !args.viewId.trim()) { + throw new Error('Custom view ID is required') + } + const limit = Math.min(Math.max(1, args?.limit ?? 20), 50) + return listCustomViewProjects( + args.viewId.trim(), + limit, + normalizeConcreteWorkspaceId(args.workspaceId) + ) + } + ) + ipcMain.handle( 'linear:teamStates', async (_event, args: { teamId: string; workspaceId?: string }) => { diff --git a/src/main/ipc/notebook.test.ts b/src/main/ipc/notebook.test.ts index b041e42ac80..3e4304239a6 100644 --- a/src/main/ipc/notebook.test.ts +++ b/src/main/ipc/notebook.test.ts @@ -71,6 +71,10 @@ describe('notebook IPC', () => { exitCode: null, error: 'Python cell timed out.' }) + expect(proc.stdout.listenerCount('data')).toBe(0) + expect(proc.stderr.listenerCount('data')).toBe(0) + expect(proc.listenerCount('error')).toBe(0) + expect(proc.listenerCount('close')).toBe(0) if (process.platform !== 'win32') { expect(spawnMock).toHaveBeenCalledWith( diff --git a/src/main/ipc/notebook.ts b/src/main/ipc/notebook.ts index 36b2bc97a5b..adcec1e6112 100644 --- a/src/main/ipc/notebook.ts +++ b/src/main/ipc/notebook.ts @@ -121,6 +121,7 @@ async function runPythonCandidate( const stderr: BoundedCapture = { text: '', bytes: 0, truncated: false } let settled = false let forceKillTimer: ReturnType | null = null + let timeout: ReturnType | null = null const child = spawn( candidate.command, [...candidate.argsPrefix, '-c', buildPythonExecutionCode(code, preamble)], @@ -131,48 +132,66 @@ async function runPythonCandidate( env: process.env } ) - const timeout = setTimeout(() => { + const cleanup = (options: { clearForceKillTimer: boolean }): void => { + if (timeout) { + clearTimeout(timeout) + timeout = null + } + if (forceKillTimer && options.clearForceKillTimer) { + clearTimeout(forceKillTimer) + forceKillTimer = null + } + child.stdout.off('data', onStdoutData) + child.stderr.off('data', onStderrData) + child.off('error', onError) + child.off('close', onClose) + } + const finish = ( + result: NotebookRunResult, + options: { clearForceKillTimer: boolean } = { clearForceKillTimer: true } + ): void => { if (settled) { return } settled = true + cleanup(options) + resolve(result) + } + + timeout = setTimeout(() => { forceKillTimer = terminateNotebookProcessTree(child) - resolve({ - stdout: stdout.text, - stderr: stderr.text, - exitCode: null, - error: 'Python cell timed out.' - }) + finish( + { + stdout: stdout.text, + stderr: stderr.text, + exitCode: null, + error: 'Python cell timed out.' + }, + { clearForceKillTimer: false } + ) }, PYTHON_RUN_TIMEOUT_MS) - child.stdout.on('data', (chunk: Buffer) => { + const onStdoutData = (chunk: Buffer): void => { appendBounded(stdout, chunk) - }) - child.stderr.on('data', (chunk: Buffer) => { + } + const onStderrData = (chunk: Buffer): void => { appendBounded(stderr, chunk) - }) - child.on('error', (error) => { - if (settled) { - return - } - settled = true - clearTimeout(timeout) - if (forceKillTimer) { - clearTimeout(forceKillTimer) - } - resolve({ stdout: stdout.text, stderr: stderr.text, exitCode: null, error: error.message }) - }) - child.on('close', (exitCode) => { - if (forceKillTimer) { - clearTimeout(forceKillTimer) - } - if (settled) { - return - } - settled = true - clearTimeout(timeout) - resolve({ stdout: stdout.text, stderr: stderr.text, exitCode }) - }) + } + const onError = (error: Error): void => { + finish({ stdout: stdout.text, stderr: stderr.text, exitCode: null, error: error.message }) + } + const onClose = (exitCode: number | null): void => { + finish({ + stdout: stdout.text, + stderr: stderr.text, + exitCode + }) + } + + child.stdout.on('data', onStdoutData) + child.stderr.on('data', onStderrData) + child.on('error', onError) + child.on('close', onClose) }) } diff --git a/src/main/ipc/notification-options.ts b/src/main/ipc/notification-options.ts index d92179a8222..1213e8f1d92 100644 --- a/src/main/ipc/notification-options.ts +++ b/src/main/ipc/notification-options.ts @@ -6,6 +6,7 @@ const NOTIFICATION_BODY_PREVIEW_MAX_LENGTH = 180 const AGENT_TYPE_LABELS: Readonly> = { claude: 'Claude', + openclaude: 'OpenClaude', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', diff --git a/src/main/ipc/notifications.test.ts b/src/main/ipc/notifications.test.ts index 9800cd141d9..e2785ce73f5 100644 --- a/src/main/ipc/notifications.test.ts +++ b/src/main/ipc/notifications.test.ts @@ -143,12 +143,12 @@ describe('registerNotificationHandlers', () => { return call[1] as (event: unknown) => unknown } - function getNotificationEventHandler(eventName: string): () => void { + function getNotificationEventHandler(eventName: string): (...args: unknown[]) => void { const call = notificationOnMock.mock.calls.find((c: unknown[]) => c[0] === eventName) if (!call) { throw new Error(`Notification ${eventName} handler not registered`) } - return call[1] as () => void + return call[1] as (...args: unknown[]) => void } function getNotificationOnceEventHandler(eventName: string): () => void { @@ -435,6 +435,37 @@ describe('registerNotificationHandlers', () => { expect(notificationRemoveListenerMock).toHaveBeenCalledWith('close', closeHandler) }) + it('releases retained notifications when native delivery fails', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + registerNotificationHandlers({ + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: true + } + }) + } as never) + + const handler = getDispatchHandler() + expect(handler({}, { source: 'agent-task-complete' })).toEqual({ delivered: true }) + expect(vi.getTimerCount()).toBe(1) + + const failedHandler = getNotificationEventHandler('failed') + failedHandler({}, 'Application is not code signed') + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('agent-task-complete notification failed to show') + ) + expect(vi.getTimerCount()).toBe(0) + expect(notificationRemoveListenerMock).toHaveBeenCalledWith('failed', failedHandler) + } finally { + warn.mockRestore() + } + }) + it('formats agent-task-complete with the agent response when a status snapshot is present', () => { registerNotificationHandlers({ getSettings: () => ({ @@ -934,6 +965,38 @@ describe('registerNotificationHandlers', () => { expect(notificationShowMock).toHaveBeenCalledTimes(2) }) + it('bounds notification cooldown keys during unique worktree bursts', () => { + notificationIsSupportedMock.mockReturnValue(false) + registerNotificationHandlers({ + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: false + } + }) + } as never) + + const handler = getDispatchHandler() + for (let i = 0; i < 75; i++) { + expect(handler({}, { source: 'terminal-bell', worktreeId: `repo::wt-${i}` })).toEqual({ + delivered: false, + reason: 'not-supported' + }) + } + + expect(handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt-0' })).toEqual({ + delivered: false, + reason: 'not-supported' + }) + expect(handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt-74' })).toEqual({ + delivered: false, + reason: 'cooldown' + }) + expect(notificationCtorMock).not.toHaveBeenCalled() + }) + it('deduplicates agent-task-complete and terminal-bell for the same worktree', () => { registerNotificationHandlers({ getSettings: () => ({ @@ -992,10 +1055,14 @@ describe('registerNotificationHandlers', () => { const handler = getDispatchHandler() const result = handler({}, { source: 'test', requireDisplayConfirmation: true }) - getNotificationOnceEventHandler('show')() + const showHandler = getNotificationOnceEventHandler('show') + const failedHandler = getNotificationOnceEventHandler('failed') + showHandler() await expect(result).resolves.toEqual({ delivered: true }) expect(notificationShowMock).toHaveBeenCalledTimes(1) + expect(notificationRemoveListenerMock).toHaveBeenCalledWith('show', showHandler) + expect(notificationRemoveListenerMock).toHaveBeenCalledWith('failed', failedHandler) }) it('reports not-displayed when explicit test notifications never show', async () => { @@ -1013,10 +1080,14 @@ describe('registerNotificationHandlers', () => { const handler = getDispatchHandler() const result = handler({}, { source: 'test', requireDisplayConfirmation: true }) + const showHandler = getNotificationOnceEventHandler('show') + const failedHandler = getNotificationOnceEventHandler('failed') await vi.advanceTimersByTimeAsync(2501) await expect(result).resolves.toEqual({ delivered: false, reason: 'not-displayed' }) expect(notificationShowMock).toHaveBeenCalledTimes(1) + expect(notificationRemoveListenerMock).toHaveBeenCalledWith('show', showHandler) + expect(notificationRemoveListenerMock).toHaveBeenCalledWith('failed', failedHandler) }) it('loads allowed custom sound files for preload playback', async () => { @@ -1104,12 +1175,12 @@ describe('registerNotificationHandlers', () => { describe('triggerStartupNotificationRegistration', () => { const originalPlatform = process.platform - function getStartupNotificationEventHandler(eventName: string): () => void { + function getStartupNotificationEventHandler(eventName: string): (...args: unknown[]) => void { const call = notificationOnMock.mock.calls.find((c: unknown[]) => c[0] === eventName) if (!call) { throw new Error(`Startup notification ${eventName} handler not registered`) } - return call[1] as () => void + return call[1] as (...args: unknown[]) => void } beforeEach(() => { @@ -1185,4 +1256,29 @@ describe('triggerStartupNotificationRegistration', () => { expect(notificationRemoveListenerMock).toHaveBeenCalledWith('click', expect.any(Function)) expect(notificationRemoveListenerMock).toHaveBeenCalledWith('show', expect.any(Function)) }) + + it('cleans up startup notification registration when native delivery fails', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const store = { + getUI: () => ({ notificationPermissionRequested: undefined }), + updateUI: vi.fn() + } + + triggerStartupNotificationRegistration(store as never) + expect(vi.getTimerCount()).toBe(1) + + const failedHandler = getStartupNotificationEventHandler('failed') + failedHandler({}, 'Application is not code signed') + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('startup registration notification failed to show') + ) + expect(notificationCloseMock).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) + expect(notificationRemoveListenerMock).toHaveBeenCalledWith('failed', failedHandler) + } finally { + warn.mockRestore() + } + }) }) diff --git a/src/main/ipc/notifications.ts b/src/main/ipc/notifications.ts index 08b9ec57631..f35be06f73f 100644 --- a/src/main/ipc/notifications.ts +++ b/src/main/ipc/notifications.ts @@ -25,6 +25,7 @@ import { buildNotificationOptions } from './notification-options' import { parsePaneKey } from '../../shared/stable-pane-id' const NOTIFICATION_COOLDOWN_MS = 5000 +const MAX_RECENT_NOTIFICATION_KEYS = 50 const NOTIFICATION_DISPLAY_CONFIRMATION_TIMEOUT_MS = 2500 const NOTIFICATION_RELEASE_FALLBACK_MS = 5 * 60 * 1000 const MAX_NOTIFICATION_SOUND_BYTES = 10 * 1024 * 1024 @@ -136,23 +137,65 @@ function waitForNotificationDisplay(notification: Notification): Promise { let settled = false let timer: ReturnType | null = null - const settle = (displayed: boolean): void => { + + function cleanup(): void { + notification.removeListener('show', onShow) + notification.removeListener('failed', onFailed) + if (timer) { + clearTimeout(timer) + timer = null + } + } + + function settle(displayed: boolean): void { if (settled) { return } settled = true - if (timer) { - clearTimeout(timer) - } + cleanup() resolve(displayed) } - notification.once('show', () => settle(true)) - notification.once('failed', () => settle(false)) + function onShow(): void { + settle(true) + } + + function onFailed(): void { + settle(false) + } + + notification.once('show', onShow) + notification.once('failed', onFailed) timer = setTimeout(() => settle(false), NOTIFICATION_DISPLAY_CONFIRMATION_TIMEOUT_MS) }) } +function logNativeNotificationFailure(context: string, error?: string): void { + console.warn( + `[notifications] ${context} notification failed to show${error ? `: ${error}` : '.'}` + ) +} + +function pruneRecentNotifications(recentNotifications: Map, now: number): void { + if (recentNotifications.size <= MAX_RECENT_NOTIFICATION_KEYS) { + return + } + + for (const [key, ts] of recentNotifications) { + if (now - ts >= NOTIFICATION_COOLDOWN_MS) { + recentNotifications.delete(key) + } + } + + while (recentNotifications.size > MAX_RECENT_NOTIFICATION_KEYS) { + const oldest = recentNotifications.keys().next() + if (oldest.done) { + break + } + recentNotifications.delete(oldest.value) + } +} + export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntimeService): void { const recentNotifications = new Map() @@ -223,16 +266,12 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime if (now - lastSentAt < NOTIFICATION_COOLDOWN_MS) { return { delivered: false, reason: 'cooldown' } } + recentNotifications.delete(dedupeKey) recentNotifications.set(dedupeKey, now) - // Evict stale entries so the map doesn't grow unbounded. - if (recentNotifications.size > 50) { - for (const [key, ts] of recentNotifications) { - if (now - ts >= NOTIFICATION_COOLDOWN_MS) { - recentNotifications.delete(key) - } - } - } + // Why: a storm across many worktrees should not make every + // notification dispatch scan an ever-growing cooldown table. + pruneRecentNotifications(recentNotifications, now) } const notificationOptions = buildNotificationOptions(args) @@ -265,13 +304,27 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime // Why: prevent GC from collecting the notification (and its click // handler) while it's still visible in macOS Notification Center. let clickHandler: (() => void) | null = null + let failedHandler: ((_event: unknown, error?: string) => void) | null = null const release = retainNotificationUntilRelease(notification, () => { if (clickHandler) { notification.removeListener('click', clickHandler) clickHandler = null } + if (failedHandler) { + notification.removeListener('failed', failedHandler) + failedHandler = null + } }) + failedHandler = (_event, error) => { + // Why: Electron 42's macOS UNNotification backend reports unsigned + // apps and native delivery errors here; release immediately instead + // of retaining a dead notification until the fallback timer. + logNativeNotificationFailure(args.source, error) + release() + } + notification.on('failed', failedHandler) + // Why: clicking a notification should bring Orca to the foreground and // switch to the worktree/pane that triggered it. Worktree activation owns // repo/sidebar state; the optional focusTerminal follow-up uses the stable @@ -440,6 +493,7 @@ export function triggerStartupNotificationRegistration(store: Store): void { activeNotifications.delete(notification) notification.removeListener('click', onClick) notification.removeListener('show', onShow) + notification.removeListener('failed', onFailed) notification.close() } @@ -462,8 +516,16 @@ export function triggerStartupNotificationRegistration(store: Store): void { } } + function onFailed(_event: unknown, error?: string): void { + // Why: Electron 42 requires code-signed macOS apps for UNNotification + // delivery. Unsigned builds fail here instead of producing the permission UI. + logNativeNotificationFailure('startup registration', error) + cleanup() + } + notification.on('click', onClick) notification.on('show', onShow) + notification.on('failed', onFailed) // Fallback in case macOS doesn't fire the 'show' event (e.g. user denies). fallbackTimer = setTimeout(cleanup, 10_000) diff --git a/src/main/ipc/pet.test.ts b/src/main/ipc/pet.test.ts new file mode 100644 index 00000000000..59311df5613 --- /dev/null +++ b/src/main/ipc/pet.test.ts @@ -0,0 +1,112 @@ +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + appGetPathMock, + browserWindowFromWebContentsMock, + browserWindowGetFocusedWindowMock, + handleMock, + nativeImageCreateFromBufferMock, + showOpenDialogMock +} = vi.hoisted(() => ({ + appGetPathMock: vi.fn(), + browserWindowFromWebContentsMock: vi.fn(), + browserWindowGetFocusedWindowMock: vi.fn(), + handleMock: vi.fn(), + nativeImageCreateFromBufferMock: vi.fn(), + showOpenDialogMock: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { + getPath: appGetPathMock + }, + BrowserWindow: { + fromWebContents: browserWindowFromWebContentsMock, + getFocusedWindow: browserWindowGetFocusedWindowMock + }, + dialog: { + showOpenDialog: showOpenDialogMock + }, + ipcMain: { + handle: handleMock + }, + nativeImage: { + createFromBuffer: nativeImageCreateFromBufferMock + } +})) + +import { registerPetHandlers } from './pet' +import type { CustomPet } from '../../shared/types' + +describe('registerPetHandlers', () => { + let tempDir: string + let userDataDir: string + const handlers = new Map Promise>() + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'orca-pet-test-')) + userDataDir = join(tempDir, 'user-data') + handlers.clear() + appGetPathMock.mockReset() + browserWindowFromWebContentsMock.mockReset() + browserWindowGetFocusedWindowMock.mockReset() + handleMock.mockReset() + nativeImageCreateFromBufferMock.mockReset() + showOpenDialogMock.mockReset() + + appGetPathMock.mockReturnValue(userDataDir) + browserWindowFromWebContentsMock.mockReturnValue(null) + browserWindowGetFocusedWindowMock.mockReturnValue(null) + handleMock.mockImplementation((channel, handler) => { + handlers.set(channel, handler) + }) + nativeImageCreateFromBufferMock.mockReturnValue({ + isEmpty: () => true, + getSize: () => ({ width: 0, height: 0 }) + }) + }) + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }) + }) + + function getHandler(channel: string): (event: unknown, ...args: unknown[]) => Promise { + registerPetHandlers() + const handler = handlers.get(channel) + if (!handler) { + throw new Error(`${channel} handler not registered`) + } + return handler + } + + it('imports a pet bundle whose manifest uses Windows separators', async () => { + const bundleDir = join(tempDir, 'windows-export.codex-pet') + const sheetBytes = Buffer.from('not decoded without frame metadata') + await mkdir(join(bundleDir, 'assets'), { recursive: true }) + await writeFile( + join(bundleDir, 'pet.json'), + JSON.stringify({ + id: 'windows-export', + displayName: 'Windows export', + spritesheetPath: String.raw`assets\spritesheet.png` + }) + ) + await writeFile(join(bundleDir, 'assets', 'spritesheet.png'), sheetBytes) + showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: [bundleDir] }) + + const result = (await getHandler('pet:importPetBundle')({ sender: {} })) as CustomPet + + expect(result).toMatchObject({ + label: 'Windows export', + fileName: 'spritesheet.png', + mimeType: 'image/png', + kind: 'bundle' + }) + await expect( + readFile(join(userDataDir, 'sidekicks', 'custom', result.id, 'spritesheet.png')) + ).resolves.toEqual(sheetBytes) + }) +}) diff --git a/src/main/ipc/pet.ts b/src/main/ipc/pet.ts index de3d11b59e3..2a6c0e13c73 100644 --- a/src/main/ipc/pet.ts +++ b/src/main/ipc/pet.ts @@ -315,10 +315,17 @@ export function registerPetHandlers(): void { // that shape before path validation. Reject absolute paths and any resolved // path that escapes the bundle directory. Also reject symlinks so a // malicious bundle can't reach outside via a sibling link. - if (isAbsolute(manifest.spritesheetPath)) { + const normalizedSpritePath = manifest.spritesheetPath.replace(/[\\/]+/g, sep) + if ( + isAbsolute(manifest.spritesheetPath) || + isAbsolute(normalizedSpritePath) || + /^[a-zA-Z]:/.test(manifest.spritesheetPath) + ) { throw new Error('spritesheetPath must be relative to the bundle.') } - const sheetSrc = resolve(bundleDir, manifest.spritesheetPath) + // Why: pet bundles may be exported on Windows and imported on macOS/Linux; + // normalize manifest separators before Node resolves the bundle-relative path. + const sheetSrc = resolve(bundleDir, normalizedSpritePath) const bundleResolved = resolve(bundleDir) if (sheetSrc === bundleResolved) { throw new Error('spritesheetPath must point to a file, not the bundle root.') diff --git a/src/main/ipc/preflight.test.ts b/src/main/ipc/preflight.test.ts index 05b6c92bd8f..3a64777f983 100644 --- a/src/main/ipc/preflight.test.ts +++ b/src/main/ipc/preflight.test.ts @@ -8,6 +8,7 @@ const { execFileAsyncMock, hydrateShellPathMock, mergePathSegmentsMock, + getActiveMultiplexerMock, getBitbucketAuthStatusMock, getAzureDevOpsAuthStatusMock, getGiteaAuthStatusMock @@ -17,6 +18,7 @@ const { execFileAsyncMock: vi.fn(), hydrateShellPathMock: vi.fn(), mergePathSegmentsMock: vi.fn(), + getActiveMultiplexerMock: vi.fn(), getBitbucketAuthStatusMock: vi.fn(), getAzureDevOpsAuthStatusMock: vi.fn(), getGiteaAuthStatusMock: vi.fn() @@ -43,6 +45,10 @@ vi.mock('../startup/hydrate-shell-path', () => ({ mergePathSegments: mergePathSegmentsMock })) +vi.mock('./ssh', () => ({ + getActiveMultiplexer: getActiveMultiplexerMock +})) + vi.mock('../bitbucket/client', () => ({ getBitbucketAuthStatus: getBitbucketAuthStatusMock })) @@ -62,13 +68,7 @@ import { runPreflightCheck } from './preflight' -type HandlerMap = Record< - string, - ( - _event?: unknown, - args?: { force?: boolean; wslDistro?: string | null; wslDefault?: boolean } - ) => Promise -> +type HandlerMap = Record Promise> describe('preflight', () => { const originalPlatform = process.platform @@ -94,6 +94,7 @@ describe('preflight', () => { execFileAsyncMock.mockReset() hydrateShellPathMock.mockReset() mergePathSegmentsMock.mockReset() + getActiveMultiplexerMock.mockReset() getBitbucketAuthStatusMock.mockReset() getAzureDevOpsAuthStatusMock.mockReset() getGiteaAuthStatusMock.mockReset() @@ -144,10 +145,12 @@ describe('preflight', () => { gitea: defaultGiteaStatus }) expect(execFileAsyncMock).toHaveBeenNthCalledWith(4, 'gh', ['auth', 'status'], { - encoding: 'utf-8' + encoding: 'utf-8', + timeout: 5000 }) expect(execFileAsyncMock).toHaveBeenNthCalledWith(5, 'glab', ['auth', 'status'], { - encoding: 'utf-8' + encoding: 'utf-8', + timeout: 5000 }) }) @@ -205,6 +208,47 @@ describe('preflight', () => { expect(status.glab).toEqual({ installed: true, authenticated: false }) }) + it('times out hung local preflight probes', async () => { + vi.useFakeTimers() + try { + execFileAsyncMock.mockImplementation((command, args) => { + if (command === 'git') { + return Promise.resolve({ stdout: 'git version 2.0.0\n' }) + } + if (command === 'gh' && Array.isArray(args) && args[0] === '--version') { + return new Promise(() => {}) + } + if (command === 'glab') { + return Promise.reject(new Error('command not found: glab')) + } + throw new Error(`unexpected command ${String(command)}`) + }) + + const statusPromise = runPreflightCheck() + let settled = false + void statusPromise.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + + await vi.advanceTimersByTimeAsync(5000) + await Promise.resolve() + + expect(settled).toBe(true) + await expect(statusPromise).resolves.toMatchObject({ + git: { installed: true }, + gh: { installed: false }, + glab: { installed: false } + }) + } finally { + vi.useRealTimers() + } + }) + it('prefers the selected WSL distro when checking gh for a WSL workspace', async () => { Object.defineProperty(process, 'platform', { configurable: true, @@ -248,6 +292,48 @@ describe('preflight', () => { ) }) + it('times out hung WSL preflight probes', async () => { + vi.useFakeTimers() + try { + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + execFileAsyncMock.mockImplementation((command, args) => { + if (command === 'git') { + return Promise.resolve({ stdout: 'git version 2.0.0\n' }) + } + if (command === 'gh' || command === 'glab') { + return Promise.reject(Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' })) + } + if (command === 'wsl.exe' && Array.isArray(args) && args.at(-1) === "'gh' --version") { + return new Promise(() => {}) + } + if (command === 'wsl.exe' && Array.isArray(args) && args.at(-1) === "'glab' --version") { + return Promise.reject(Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' })) + } + throw new Error(`unexpected command ${String(command)}`) + }) + + const statusPromise = runPreflightCheck(false, { wslDistro: 'Ubuntu' }) + let settled = false + void statusPromise.finally(() => { + settled = true + }) + + await vi.advanceTimersByTimeAsync(5000) + await Promise.resolve() + + expect(settled).toBe(true) + await expect(statusPromise).resolves.toMatchObject({ + gh: { installed: false }, + glab: { installed: false } + }) + } finally { + vi.useRealTimers() + } + }) + it('re-runs the probe when forced so updated gh auth state is visible without relaunch', async () => { execFileAsyncMock .mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' }) @@ -354,6 +440,9 @@ describe('preflight', () => { if (command !== 'which') { throw new Error(`unexpected command ${String(command)}`) } + if (String(args[0]) === 'openclaude') { + return { stdout: '/Users/test/.local/bin/openclaude\n' } + } if (String(args[0]) === 'cursor-agent') { return { stdout: '/Users/test/.local/bin/cursor-agent\n' } } @@ -362,7 +451,24 @@ describe('preflight', () => { registerPreflightHandlers() - await expect(handlers['preflight:detectAgents']()).resolves.toEqual(['cursor']) + await expect(handlers['preflight:detectAgents']()).resolves.toEqual(['openclaude', 'cursor']) + }) + + it('sends OpenClaude detection commands through the SSH remote preflight path', async () => { + const request = vi.fn().mockResolvedValue({ agents: ['openclaude'] }) + getActiveMultiplexerMock.mockReturnValue({ + isDisposed: () => false, + request + }) + + registerPreflightHandlers() + + await expect( + handlers['preflight:detectRemoteAgents'](undefined, { connectionId: 'ssh-1' }) + ).resolves.toEqual(['openclaude']) + expect(request).toHaveBeenCalledWith('preflight.detectAgents', { + commands: expect.arrayContaining([{ id: 'openclaude', cmd: 'openclaude' }]) + }) }) it('detects agents from the selected WSL distro for a WSL workspace', async () => { diff --git a/src/main/ipc/preflight.ts b/src/main/ipc/preflight.ts index 8821a65ff48..cedb554167d 100644 --- a/src/main/ipc/preflight.ts +++ b/src/main/ipc/preflight.ts @@ -11,6 +11,7 @@ import { getGiteaAuthStatus } from '../gitea/client' import { _resetKnownHostsCache } from '../gitlab/gl-utils' import { getActiveMultiplexer } from './ssh' const execFileAsync = promisify(execFile) +const PREFLIGHT_COMMAND_TIMEOUT_MS = 5000 type PreflightRuntimeContext = { wslDistro?: string | null @@ -59,15 +60,56 @@ function shellQuote(value: string): string { return `'${value.replace(/'/g, "'\\''")}'` } +type PreflightCommandResult = { stdout: string; stderr: string } + +// Why: a broken PATH shim or auth helper should not keep startup/settings +// preflight IPC pending forever; WSL probes already use the same deadline. +async function withPreflightTimeout(command: string, commandPromise: Promise): Promise { + let timeout: ReturnType | null = null + try { + return await Promise.race([ + commandPromise, + new Promise((_resolve, reject) => { + timeout = setTimeout(() => { + const error = Object.assign(new Error(`Timed out running ${command}`), { + code: 'ETIMEDOUT' + }) + reject(error) + }, PREFLIGHT_COMMAND_TIMEOUT_MS) + if (typeof timeout.unref === 'function') { + timeout.unref() + } + }) + ]) + } finally { + if (timeout) { + clearTimeout(timeout) + } + } +} + +async function execLocalPreflightCommand( + command: string, + args: string[] +): Promise { + const commandPromise = execFileAsync(command, args, { + encoding: 'utf-8', + timeout: PREFLIGHT_COMMAND_TIMEOUT_MS + }) as Promise + + return withPreflightTimeout(command, commandPromise) +} + async function execCommandInWsl( target: WslPreflightTarget, command: string ): Promise<{ stdout: string; stderr: string }> { const distroArgs = target.distro ? ['-d', target.distro] : [] - return execFileAsync('wsl.exe', [...distroArgs, '--', 'bash', '-lc', command], { + const commandPromise = execFileAsync('wsl.exe', [...distroArgs, '--', 'bash', '-lc', command], { encoding: 'utf-8', - timeout: 5000 + timeout: PREFLIGHT_COMMAND_TIMEOUT_MS }) as Promise<{ stdout: string; stderr: string }> + return withPreflightTimeout('wsl.exe', commandPromise) } async function isCommandAvailable( @@ -77,7 +119,7 @@ async function isCommandAvailable( try { await (wslTarget ? execCommandInWsl(wslTarget, `${shellQuote(command)} --version`) - : execFileAsync(command, ['--version'])) + : execLocalPreflightCommand(command, ['--version'])) return true } catch { return false @@ -92,7 +134,7 @@ async function isCommandOnPath(command: string, wslTarget?: WslPreflightTarget): try { const { stdout } = wslTarget ? await execCommandInWsl(wslTarget, `command -v ${shellQuote(command)}`) - : await execFileAsync(finder, [command], { encoding: 'utf-8' }) + : await execLocalPreflightCommand(finder, [command]) return stdout .split(/\r?\n/) .map((line) => line.trim()) @@ -198,9 +240,7 @@ async function isGhAuthenticated(wslTarget?: WslPreflightTarget): Promise ({ isPackaged: true, getPath: getPathMock }, + nativeTheme: { + shouldUseDarkColors: true + }, ipcMain: { handle: handleMock, on: onMock, @@ -149,11 +152,12 @@ import { clearProviderPtyState, deletePtyOwnership, getPtyIdForPaneKey, + hasPendingRendererSerializerForPaneKey, setPtyOwnership, setLocalPtyProvider, unregisterSshPtyProvider } from './pty' -import { hasLiveClaudePtys } from '../claude-accounts/live-pty-gate' +import { hasLiveClaudePtys, markClaudePtySpawned } from '../claude-accounts/live-pty-gate' import { encodePowerShellCommand, getPowerShellOsc133Bootstrap @@ -377,7 +381,12 @@ describe('registerPtyHandlers', () => { argsEnv?: Record, processEnvOverrides?: Record, getSelectedCodexHomePath?: () => string | null, - getSettings?: () => { enableGitHubAttribution?: boolean; agentStatusHooksEnabled?: boolean }, + getSettings?: () => { + enableGitHubAttribution?: boolean + agentStatusHooksEnabled?: boolean + httpProxyUrl?: string + httpProxyBypassRules?: string + }, // Why: PR #2662 finding 2 — the threading from IPC `args.command` through // buildPtyHostEnv to piTitlebarExtensionService.buildPtyEnv was untested // for the OMP case because this helper never forwarded a command. Accept @@ -468,6 +477,15 @@ describe('registerPtyHandlers', () => { expect(hasLiveClaudePtys()).toBe(false) }) + it('clears Claude live-PTY tracking from shared provider teardown', () => { + markClaudePtySpawned('ssh-claude-pty') + expect(hasLiveClaudePtys()).toBe(true) + + clearProviderPtyState('ssh-claude-pty') + + expect(hasLiveClaudePtys()).toBe(false) + }) + it('defaults LANG to en_US.UTF-8 when not inherited from process.env', async () => { const env = await spawnAndGetEnv(undefined, { LANG: undefined }) expect(env.LANG).toBe('en_US.UTF-8') @@ -854,6 +872,18 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_CODEX_HOME).toBe('/tmp/orca-codex-home') }) + it('injects explicit proxy settings into local PTY env', async () => { + const env = await spawnAndGetEnv(undefined, undefined, undefined, () => ({ + httpProxyUrl: 'http://proxy.example:8080', + httpProxyBypassRules: 'localhost,*.internal' + })) + + expect(env.HTTP_PROXY).toBe('http://proxy.example:8080') + expect(env.HTTPS_PROXY).toBe('http://proxy.example:8080') + expect(env.ALL_PROXY).toBe('http://proxy.example:8080') + expect(env.NO_PROXY).toBe('localhost,*.internal') + }) + describe('daemon-active provider (parity with LocalPtyProvider)', () => { // Why: these tests guard the regression the daemon-parity refactor was // written to fix — under the daemon, LocalPtyProvider.buildSpawnEnv is @@ -896,7 +926,11 @@ describe('registerPtyHandlers', () => { async function daemonSpawnAndGetOptions( argsEnv?: Record, getSelectedCodexHomePath?: () => string | null, - getSettings?: () => { enableGitHubAttribution: boolean }, + getSettings?: () => { + enableGitHubAttribution?: boolean + httpProxyUrl?: string + httpProxyBypassRules?: string + }, processEnvOverrides?: Record, // Why: daemon spawn tests need to exercise both WSL launch metadata // from main and PR #2662 command threading for OMP overlay selection. @@ -943,7 +977,11 @@ describe('registerPtyHandlers', () => { async function daemonSpawnAndGetEnv( argsEnv?: Record, getSelectedCodexHomePath?: () => string | null, - getSettings?: () => { enableGitHubAttribution: boolean }, + getSettings?: () => { + enableGitHubAttribution?: boolean + httpProxyUrl?: string + httpProxyBypassRules?: string + }, processEnvOverrides?: Record, spawnArgs?: { cwd?: string; shellOverride?: string; command?: string } ): Promise> { @@ -1042,6 +1080,17 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_CODEX_HOME).toBe('/tmp/orca-codex-home') }) + it('injects explicit proxy settings on the daemon path', async () => { + const env = await daemonSpawnAndGetEnv({}, undefined, () => ({ + httpProxyUrl: 'http://proxy.example:8080', + httpProxyBypassRules: 'localhost;*.internal' + })) + + expect(env.HTTP_PROXY).toBe('http://proxy.example:8080') + expect(env.HTTPS_PROXY).toBe('http://proxy.example:8080') + expect(env.NO_PROXY).toBe('localhost,*.internal') + }) + it('skips host Codex home when a daemon-backed Windows spawn targets a WSL cwd', async () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { @@ -1428,7 +1477,10 @@ describe('registerPtyHandlers', () => { mainWindow as never, undefined, undefined, - undefined, + (() => ({ + httpProxyUrl: 'http://proxy.example:8080', + httpProxyBypassRules: 'localhost' + })) as never, undefined, store as never ) @@ -1458,6 +1510,9 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined() expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined() expect(env.CODEX_HOME).toBeUndefined() + expect(env.HTTP_PROXY).toBeUndefined() + expect(env.HTTPS_PROXY).toBeUndefined() + expect(env.NO_PROXY).toBeUndefined() expect(env.FOO).toBe('bar') expect(openCodeBuildPtyEnvMock).not.toHaveBeenCalled() expect(piBuildPtyEnvMock).not.toHaveBeenCalled() @@ -2291,6 +2346,27 @@ describe('registerPtyHandlers', () => { expect(spawnController.hasRendererSerializer?.(result.id)).toBe(true) }) + it('clears pending pane serializer declarations when their renderer is destroyed', async () => { + registerPtyHandlers(mainWindow as never) + const paneKey = makePaneKey('tab-crash', '22222222-2222-4222-8222-222222222222') + const destroyedListeners: (() => void)[] = [] + const sender = { + id: 42, + once: vi.fn((event: string, listener: () => void) => { + if (event === 'destroyed') { + destroyedListeners.push(listener) + } + }) + } + + await handlers.get('pty:declarePendingPaneSerializer')!({ sender }, { paneKey }) + + expect(hasPendingRendererSerializerForPaneKey(paneKey)).toBe(true) + expect(destroyedListeners).toHaveLength(1) + destroyedListeners[0]() + expect(hasPendingRendererSerializerForPaneKey(paneKey)).toBe(false) + }) + it('ignores renderer-provided ORCA_TERMINAL_HANDLE for local PTY spawns', async () => { const runtime = { setPtyController: vi.fn(), @@ -2313,6 +2389,40 @@ describe('registerPtyHandlers', () => { expect(runtime.preAllocateHandleForPty).toHaveBeenCalledWith(expect.any(String)) }) + it('forwards the trusted Orca terminal handle into managed WSL terminals', async () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + const runtime = { + setPtyController: vi.fn(), + preAllocateHandleForPty: vi.fn(() => 'term_wsl'), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + + try { + registerPtyHandlers(mainWindow as never, runtime as never) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + shellOverride: 'wsl.exe' + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + const spawnCall = spawnMock.mock.calls.at(-1)! + const env = spawnCall[2].env as Record + expect(spawnCall[0]).toBe('wsl.exe') + expect(env.ORCA_TERMINAL_HANDLE).toBe('term_wsl') + expect(env.WSLENV).toBe('ORCA_TERMINAL_HANDLE/u') + }) + describe('Windows UTF-8 code page', () => { let originalPlatform: string let originalComspec: string | undefined @@ -2402,7 +2512,7 @@ describe('registerPtyHandlers', () => { expect(env.PYTHONUTF8).toBe('0') }) - it('passes no encoding args for unrecognized shells', () => { + it('launches Git Bash from COMSPEC as an interactive login shell', () => { process.env.COMSPEC = 'C:\\Program Files\\Git\\bin\\bash.exe' registerPtyHandlers(mainWindow as never) @@ -2410,8 +2520,10 @@ describe('registerPtyHandlers', () => { expect(spawnMock).toHaveBeenCalledWith( 'C:\\Program Files\\Git\\bin\\bash.exe', - [], - expect.any(Object) + ['--login', '-i'], + expect.objectContaining({ + env: expect.objectContaining({ CHERE_INVOKING: '1' }) + }) ) }) @@ -2860,6 +2972,162 @@ describe('registerPtyHandlers', () => { } }) + it('does not send renderer PTY data while renderer output is paused', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const pauseCall = onMock.mock.calls.find((call: unknown[]) => call[0] === 'pty:pauseOutput') + if (!pauseCall) { + throw new Error('missing pty:pauseOutput listener') + } + const pauseRendererOutput = pauseCall[1] as ( + event: unknown, + args: { id: string; paused: boolean } + ) => void + mainWindow.webContents.send.mockClear() + + pauseRendererOutput(null, { id: spawnResult.id, paused: true }) + mockProc.emitData('hidden output') + vi.advanceTimersByTime(50) + + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith('pty:data', { + id: spawnResult.id, + data: 'hidden output' + }) + } finally { + vi.useRealTimers() + } + }) + + it('answers mode 2031 subscribes while renderer PTY output is paused', async () => { + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const pauseCall = onMock.mock.calls.find((call: unknown[]) => call[0] === 'pty:pauseOutput') + if (!pauseCall) { + throw new Error('missing pty:pauseOutput listener') + } + const pauseRendererOutput = pauseCall[1] as ( + event: unknown, + args: { id: string; paused: boolean } + ) => void + mainWindow.webContents.send.mockClear() + + pauseRendererOutput(null, { id: spawnResult.id, paused: true }) + mockProc.emitData('\x1b[?2031h') + + expect(mockProc.proc.write).toHaveBeenCalledWith('\x1b[?997;1n') + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith( + 'pty:data', + expect.objectContaining({ id: spawnResult.id }) + ) + }) + + it('answers split paused mode 2031 subscribes using current app color scheme', async () => { + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + registerPtyHandlers(mainWindow as never, undefined, undefined, (() => ({ + theme: 'light' + })) as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const pauseCall = onMock.mock.calls.find((call: unknown[]) => call[0] === 'pty:pauseOutput') + if (!pauseCall) { + throw new Error('missing pty:pauseOutput listener') + } + const pauseRendererOutput = pauseCall[1] as ( + event: unknown, + args: { id: string; paused: boolean } + ) => void + + pauseRendererOutput(null, { id: spawnResult.id, paused: true }) + mockProc.emitData('\x1b[?20') + expect(mockProc.proc.write).not.toHaveBeenCalled() + + mockProc.emitData('31h') + expect(mockProc.proc.write).toHaveBeenCalledWith('\x1b[?997;2n') + }) + + it('keeps 250 paused background PTYs off the renderer input path', async () => { + vi.useFakeTimers() + const backgroundCount = 250 + const procs = Array.from({ length: backgroundCount + 1 }, () => createMockProc()) + let spawnIndex = 0 + spawnMock.mockImplementation(() => procs[spawnIndex++].proc) + + try { + registerPtyHandlers(mainWindow as never) + const activeSpawn = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const backgroundSpawns: { id: string }[] = [] + for (let index = 0; index < backgroundCount; index++) { + backgroundSpawns.push( + (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + ) + } + const pauseCall = onMock.mock.calls.find((call: unknown[]) => call[0] === 'pty:pauseOutput') + if (!pauseCall) { + throw new Error('missing pty:pauseOutput listener') + } + const pauseRendererOutput = pauseCall[1] as ( + event: unknown, + args: { id: string; paused: boolean } + ) => void + for (const spawn of backgroundSpawns) { + pauseRendererOutput(null, { id: spawn.id, paused: true }) + } + mainWindow.webContents.send.mockClear() + + for (let index = 0; index < backgroundCount; index++) { + procs[index + 1].emitData(`hidden ${index}\n`) + } + vi.advanceTimersByTime(100) + + for (let index = 0; index < backgroundCount; index++) { + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith('pty:data', { + id: backgroundSpawns[index].id, + data: `hidden ${index}\n` + }) + } + + const writeListener = getPtyWriteListener() + writeListener(null, { id: activeSpawn.id, data: 'a' }) + procs[0].emitData('redraw') + + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: activeSpawn.id, + data: 'redraw' + }) + } finally { + vi.useRealTimers() + } + }) + it('sends small PTY redraws immediately after terminal input', async () => { vi.useFakeTimers() const mockProc = createMockProc() @@ -2894,6 +3162,158 @@ describe('registerPtyHandlers', () => { } }) + it('holds background PTY output briefly after foreground input', async () => { + vi.useFakeTimers() + const activeProc = createMockProc() + const backgroundProc = createMockProc() + spawnMock.mockReturnValueOnce(activeProc.proc).mockReturnValueOnce(backgroundProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const activeSpawn = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const backgroundSpawn = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const writeListener = getPtyWriteListener() + mainWindow.webContents.send.mockClear() + + backgroundProc.emitData('background output') + writeListener(null, { + id: activeSpawn.id, + data: 'a' + }) + + vi.advanceTimersByTime(8) + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith('pty:data', { + id: backgroundSpawn.id, + data: 'background output' + }) + + activeProc.emitData('\x1b[20;2Hredraw') + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: activeSpawn.id, + data: '\x1b[20;2Hredraw' + }) + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith('pty:data', { + id: backgroundSpawn.id, + data: 'background output' + }) + + vi.advanceTimersByTime(41) + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith('pty:data', { + id: backgroundSpawn.id, + data: 'background output' + }) + + vi.advanceTimersByTime(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: backgroundSpawn.id, + data: 'background output' + }) + } finally { + vi.useRealTimers() + } + }) + + it('does not hold foreground PTY batch output behind background output', async () => { + vi.useFakeTimers() + const activeProc = createMockProc() + const backgroundProc = createMockProc() + spawnMock.mockReturnValueOnce(activeProc.proc).mockReturnValueOnce(backgroundProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const activeSpawn = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const backgroundSpawn = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const writeListener = getPtyWriteListener() + mainWindow.webContents.send.mockClear() + + backgroundProc.emitData('background output') + writeListener(null, { + id: activeSpawn.id, + data: 'a' + }) + const foregroundOutput = 'x'.repeat(1025) + activeProc.emitData(foregroundOutput) + + vi.advanceTimersByTime(8) + + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: activeSpawn.id, + data: foregroundOutput + }) + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith('pty:data', { + id: backgroundSpawn.id, + data: 'background output' + }) + } finally { + vi.useRealTimers() + } + }) + + it('does not starve background PTY output during continuous input', async () => { + vi.useFakeTimers() + const activeProc = createMockProc() + const backgroundProc = createMockProc() + spawnMock.mockReturnValueOnce(activeProc.proc).mockReturnValueOnce(backgroundProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const activeSpawn = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const backgroundSpawn = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const writeListener = getPtyWriteListener() + mainWindow.webContents.send.mockClear() + + backgroundProc.emitData('background output') + for (let index = 0; index < 7; index++) { + writeListener(null, { + id: activeSpawn.id, + data: 'a' + }) + vi.advanceTimersByTime(40) + } + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith('pty:data', { + id: backgroundSpawn.id, + data: 'background output' + }) + + writeListener(null, { + id: activeSpawn.id, + data: 'a' + }) + vi.advanceTimersByTime(10) + + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: backgroundSpawn.id, + data: 'background output' + }) + } finally { + vi.useRealTimers() + } + }) + it('ignores PTY input for unknown sessions', async () => { vi.useFakeTimers() const mockProc = createMockProc() @@ -2953,6 +3373,77 @@ describe('registerPtyHandlers', () => { } }) + it('batches repeated small PTY chunks after the interactive output budget is exhausted', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const writeListener = getPtyWriteListener() + + writeListener(null, { + id: spawnResult.id, + data: 'a' + }) + mainWindow.webContents.send.mockClear() + + const smallChunk = 'x'.repeat(512) + for (let index = 0; index < 65; index++) { + mockProc.emitData(smallChunk) + } + + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(64) + vi.advanceTimersByTime(8) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(65) + expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(65, 'pty:data', { + id: spawnResult.id, + data: smallChunk + }) + } finally { + vi.useRealTimers() + } + }) + + it('sends larger ANSI redraws immediately after terminal input', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const writeListener = getPtyWriteListener() + + writeListener(null, { + id: spawnResult.id, + data: 'a' + }) + mainWindow.webContents.send.mockClear() + + const redraw = `\x1b[2J\x1b[H${'codex composer redraw '.repeat(80)}` + mockProc.emitData(redraw) + + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: spawnResult.id, + data: redraw + }) + vi.advanceTimersByTime(8) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + it('batches combined pending output that exceeds the interactive size limit', async () => { vi.useFakeTimers() const mockProc = createMockProc() @@ -3486,6 +3977,52 @@ describe('registerPtyHandlers', () => { ) }) + it('removes the previous orphan-cleanup listener from its original webContents', () => { + const firstWindow = { + isDestroyed: () => false, + webContents: { + on: vi.fn(), + send: vi.fn(), + removeListener: vi.fn() + } + } + const secondWindow = { + isDestroyed: () => false, + webContents: { + on: vi.fn(), + send: vi.fn(), + removeListener: vi.fn() + } + } + + registerPtyHandlers(firstWindow as never) + const didFinishLoad = firstWindow.webContents.on.mock.calls.find( + ([eventName]) => eventName === 'did-finish-load' + )?.[1] as (() => void) | undefined + expect(didFinishLoad).toBeTypeOf('function') + + setLocalPtyProvider({ + spawn: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + onData: vi.fn(() => vi.fn()), + onExit: vi.fn(() => vi.fn()), + listProcesses: vi.fn(async () => []), + getForegroundProcess: vi.fn(async () => null) + } as never) + registerPtyHandlers(secondWindow as never) + + expect(firstWindow.webContents.removeListener).toHaveBeenCalledWith( + 'did-finish-load', + didFinishLoad + ) + expect( + secondWindow.webContents.on.mock.calls.some(([eventName]) => eventName === 'did-finish-load') + ).toBe(false) + }) + it('clears PTY state even when kill reports the process is already gone', async () => { const proc = { onData: vi.fn(() => makeDisposable()), diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 4ffb18facf9..33148b0b51c 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -5,7 +5,7 @@ boundary. Splitting it by line count would scatter tightly coupled terminal process behavior across files without a cleaner ownership seam. */ import { join, delimiter } from 'path' import { randomUUID } from 'crypto' -import { type BrowserWindow, ipcMain, app } from 'electron' +import { type BrowserWindow, type WebContents, ipcMain, app, nativeTheme } from 'electron' export { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-ready' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import type { Store } from '../persistence' @@ -31,7 +31,10 @@ import { markClaudePtyExited, markClaudePtySpawned } from '../claude-accounts/live-pty-gate' -import { applyTerminalAttributionEnv } from '../attribution/terminal-attribution' +import { + applyTerminalAttributionEnv, + resolveAttributionShellFamily +} from '../attribution/terminal-attribution' import { registerPty, unregisterPty } from '../memory/pty-registry' import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' import { track } from '../telemetry/client' @@ -44,6 +47,11 @@ import { } from '../../shared/telemetry-events' import { isRemoteAgentHooksEnabled } from '../../shared/agent-hook-relay' import { createTerminalSessionStateSaveFailureMessage } from '../../shared/terminal-session-state-save-failure' +import { + mode2031SequenceFor, + resolveTerminalColorSchemeMode, + scanMode2031Sequences +} from '../../shared/terminal-color-scheme-protocol' import { readShellStartupEnvVar } from '../pty/shell-startup-env' import { isTerminalLeafId, @@ -57,8 +65,10 @@ import { } from '../agent-hooks/migration-unsupported-pty-state' import { parseWslPath } from '../wsl' import { mergePersistedWindowsPath } from '../pty/windows-environment-path' +import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env' import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' +import { buildConfiguredProxyEnv, type NetworkProxySettings } from '../../shared/network-proxy' // ─── Provider Registry ────────────────────────────────────────────── // Routes PTY operations by connectionId. null = local provider. @@ -78,6 +88,11 @@ const ptySizes = new Map() // is PTY-scoped and must be cleared by every teardown path, including SSH and // daemon shutdowns that do not flow through the local provider exit listener. const lastInputAtByPty = new Map() +const interactiveOutputCharsByPty = new Map() +// Why: hidden renderer panes restore from main-owned snapshots, so ordinary +// PTY bytes do not need to wake the renderer while a pane is hidden. +const rendererPausedOutputPtys = new Set() +const rendererPausedMode2031ScanTailByPty = new Map() // Why: the agent-hooks server caches per-paneKey state (last prompt, last // tool) that otherwise grows unbounded as panes come and go. Track the // spawn-time paneKey so clearProviderPtyState can clear that cache on PTY @@ -105,6 +120,10 @@ export function getPtyIdForPaneKey(paneKey: string): string | undefined { return paneKeyPtyId.get(paneKey) } +export function isRendererPtyOutputPaused(ptyId: string): boolean { + return rendererPausedOutputPtys.has(ptyId) +} + // Why: consumers (currently the cursor-agent synthesized-spinner loop in // main/index.ts) need to tear down paneKey-scoped state when a PTY exits so // intervals / timers cannot leak for the process lifetime. A callback @@ -127,7 +146,8 @@ export function registerPaneKeyTeardownListener(listener: PaneKeyTeardownListene // still matches, so a remount that pre-signals before the old PTY's teardown // runs is preserved. See docs/mobile-prefer-renderer-scrollback.md. let pendingSerializerGenSeq = 0 -const pendingByPaneKey = new Map() +const pendingByPaneKey = new Map() +const pendingPaneSerializerCleanupRegistered = new Set() // Why: at PTY spawn time we capture the gen that was pending for the spawn's // paneKey, so teardown can settle ONLY that gen. Without this, a paneKey // remount that replaces the pending entry with a new gen would still get @@ -159,14 +179,32 @@ function rememberPaneKeyForPty(ptyId: string, paneKey: unknown): string | null { return normalizedPaneKey } -function declarePendingPaneSerializer(paneKey: string): number { +function cleanupPendingPaneSerializersForSender(ownerWebContentsId: number): void { + pendingPaneSerializerCleanupRegistered.delete(ownerWebContentsId) + for (const [paneKey, pending] of pendingByPaneKey) { + if (pending.ownerWebContentsId === ownerWebContentsId) { + pendingByPaneKey.delete(paneKey) + } + } +} + +function registerPendingPaneSerializerCleanup(sender: WebContents | undefined): void { + if (!sender || pendingPaneSerializerCleanupRegistered.has(sender.id)) { + return + } + pendingPaneSerializerCleanupRegistered.add(sender.id) + sender.once('destroyed', () => cleanupPendingPaneSerializersForSender(sender.id)) +} + +function declarePendingPaneSerializer(paneKey: string, sender: WebContents | undefined): number { const gen = ++pendingSerializerGenSeq - pendingByPaneKey.set(paneKey, gen) + registerPendingPaneSerializerCleanup(sender) + pendingByPaneKey.set(paneKey, { gen, ownerWebContentsId: sender?.id ?? null }) return gen } function settlePendingPaneSerializer(paneKey: string, gen: number): void { - if (pendingByPaneKey.get(paneKey) === gen) { + if (pendingByPaneKey.get(paneKey)?.gen === gen) { pendingByPaneKey.delete(paneKey) } } @@ -269,7 +307,10 @@ export type BuildPtyHostEnvOptions = { * resolve to Pi for back-compat. NEVER infer from disk presence; that's * the bug this option fixes (cross-agent shadowing when both dirs exist). */ launchCommand?: string + shellPath?: string + isWsl?: boolean agentStatusHooksEnabled: boolean + networkProxySettings?: NetworkProxySettings } function readInheritedPath(baseEnv: Record): string { @@ -469,6 +510,7 @@ export function buildPtyHostEnv( opts: BuildPtyHostEnvOptions ): Record { mergePersistedWindowsPath(baseEnv) + Object.assign(baseEnv, buildConfiguredProxyEnv(opts.networkProxySettings)) // Why: the Local path passes a baseEnv that already includes process.env // (LocalPtyProvider.spawn merges it before calling buildSpawnEnv). The @@ -636,7 +678,11 @@ export function buildPtyHostEnv( } applyTerminalAttributionEnv(baseEnv, { enabled: opts.githubAttributionEnabled, - userDataPath: opts.userDataPath + userDataPath: opts.userDataPath, + shellFamily: resolveAttributionShellFamily({ + shellPath: opts.shellPath, + isWsl: opts.isWsl + }) }) return baseEnv @@ -722,8 +768,14 @@ export function clearProviderPtyState(id: string): void { // new teardown path forgets to remove one provider's overlay/hook state. openCodeHookService.clearPty(id) piTitlebarExtensionService.clearPty(id) + // Why: SSH exit and connection-teardown paths bypass pty.ts's local onExit + // callback but still need to release Claude account-switch guards. + markClaudePtyExited(id) ptySizes.delete(id) lastInputAtByPty.delete(id) + interactiveOutputCharsByPty.delete(id) + rendererPausedOutputPtys.delete(id) + rendererPausedMode2031ScanTailByPty.delete(id) const paneKey = ptyPaneKey.get(id) const stillOwnsPaneKey = paneKey ? paneKeyPtyId.get(paneKey) === id : false // Why: drop the memory-collector registration so a dead PTY does not keep @@ -800,6 +852,7 @@ export function setPtyOwnership(id: string, connectionId: string | null): void { let localDataUnsub: (() => void) | null = null let localExitUnsub: (() => void) | null = null let didFinishLoadHandler: (() => void) | null = null +let didFinishLoadWebContents: WebContents | null = null // Why: the "Restart daemon" path needs to re-bind provider→renderer listeners // against the freshly-created adapter after replaceDaemonProvider swaps the @@ -813,6 +866,14 @@ export function rebindLocalProviderListeners(): void { rebindProviderListeners?.() } +function clearDidFinishLoadHandler(): void { + if (didFinishLoadHandler && didFinishLoadWebContents) { + didFinishLoadWebContents.removeListener('did-finish-load', didFinishLoadHandler) + } + didFinishLoadHandler = null + didFinishLoadWebContents = null +} + // Why: the "Restart daemon" flow needs to detach listeners from the current // adapter *after* synthetic pty:exit events fan out (so the renderer receives // them) but *before* replaceDaemonProvider swaps in the new adapter (so the @@ -849,6 +910,7 @@ export function registerPtyHandlers( ipcMain.removeHandler('pty:getMainBufferSnapshot') ipcMain.removeHandler('pty:writeAccepted') ipcMain.removeAllListeners('pty:write') + ipcMain.removeAllListeners('pty:pauseOutput') ipcMain.removeAllListeners('pty:ackColdRestore') ipcMain.removeAllListeners('pty:serializeBuffer:response') @@ -882,7 +944,10 @@ export function registerPtyHandlers( skipCodexHomeEnv: ctx?.isWsl === true && !selectedCodexHomePath, githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false, launchCommand: ctx?.command, - agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()) + shellPath: ctx?.shellPath, + isWsl: ctx?.isWsl, + agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()), + networkProxySettings: getSettings?.() }) // Why: agents need their own terminal handle at process start so they // can self-identify in orchestration messages without an extra RPC. @@ -897,6 +962,9 @@ export function registerPtyHandlers( if (preAllocatedHandle) { env.ORCA_TERMINAL_HANDLE = preAllocatedHandle } + if (ctx?.isWsl === true) { + addOrcaWslInteropEnv(env) + } return env }, onSpawned: (id) => runtime?.onPtySpawned(id), @@ -930,6 +998,42 @@ export function registerPtyHandlers( // large output and non-interactive output must still use the batcher. const INTERACTIVE_OUTPUT_WINDOW_MS = 100 const INTERACTIVE_OUTPUT_MAX_CHARS = 1024 + const INTERACTIVE_REDRAW_MAX_CHARS = PTY_BATCH_FLUSH_CHUNK_CHARS + const INTERACTIVE_OUTPUT_BUDGET_CHARS = 32 * 1024 + const BACKGROUND_OUTPUT_INPUT_QUIET_MS = 50 + const BACKGROUND_OUTPUT_MAX_INPUT_HOLD_MS = 250 + let lastRendererInputAt = Number.NEGATIVE_INFINITY + let lastRendererInputPtyId: string | null = null + let backgroundFlushHeldSince: number | null = null + + function isLikelyInteractiveRedraw(data: string): boolean { + if (data.length <= INTERACTIVE_OUTPUT_MAX_CHARS) { + return true + } + // Why: Codex-style TUIs can repaint more than 1 KB per keypress. ANSI + // control redraws are still latency-sensitive, while plain command output + // should stay on the throughput batch path. + return data.length <= INTERACTIVE_REDRAW_MAX_CHARS && data.includes('\x1b[') + } + + function shouldSendInteractiveOutputNow(id: string, data: string, now: number): boolean { + const lastInputAt = lastInputAtByPty.get(id) + if (lastInputAt === undefined || now - lastInputAt > INTERACTIVE_OUTPUT_WINDOW_MS) { + interactiveOutputCharsByPty.delete(id) + return false + } + if (!isLikelyInteractiveRedraw(data)) { + interactiveOutputCharsByPty.set(id, INTERACTIVE_OUTPUT_BUDGET_CHARS) + return false + } + const usedChars = interactiveOutputCharsByPty.get(id) ?? 0 + if (usedChars + data.length > INTERACTIVE_OUTPUT_BUDGET_CHARS) { + interactiveOutputCharsByPty.set(id, INTERACTIVE_OUTPUT_BUDGET_CHARS) + return false + } + interactiveOutputCharsByPty.set(id, usedChars + data.length) + return true + } function getChunkStartSeq(endSeq: number | undefined, data: string): number | undefined { return typeof endSeq === 'number' ? Math.max(0, endSeq - data.length) : undefined @@ -972,15 +1076,32 @@ export function registerPtyHandlers( flushTimer = setTimeout(flushPendingData, delayMs) } - function flushPendingData(): void { - flushTimer = null - if (mainWindow.isDestroyed()) { - pendingData.clear() - return + function hasRecentRendererInput(now: number): boolean { + return now - lastRendererInputAt < BACKGROUND_OUTPUT_INPUT_QUIET_MS + } + + function hasPendingDataOutsideRecentInputPty(): boolean { + for (const id of pendingData.keys()) { + if (id !== lastRendererInputPtyId) { + return true + } } + return false + } + + function drainPendingDataEntries( + maxWrites: number, + shouldDrain: (id: string) => boolean = () => true + ): number { let writes = 0 - while (pendingData.size > 0 && writes < PTY_BATCH_FLUSH_MAX_WRITES) { - const next = pendingData.entries().next().value + while (writes < maxWrites) { + let next: [string, PendingPtyData] | undefined + for (const entry of pendingData.entries()) { + if (shouldDrain(entry[0])) { + next = entry + break + } + } if (!next) { break } @@ -999,6 +1120,51 @@ export function registerPtyHandlers( mainWindow.webContents.send('pty:data', makePtyDataPayload(id, chunk, pending.startSeq)) writes++ } + return writes + } + + function shouldHoldBackgroundFlushForInput(now: number): boolean { + if ( + pendingData.size === 0 || + !hasRecentRendererInput(now) || + !hasPendingDataOutsideRecentInputPty() + ) { + backgroundFlushHeldSince = null + return false + } + backgroundFlushHeldSince ??= now + if (now - backgroundFlushHeldSince >= BACKGROUND_OUTPUT_MAX_INPUT_HOLD_MS) { + backgroundFlushHeldSince = null + return false + } + return true + } + + function flushPendingData(): void { + flushTimer = null + if (mainWindow.isDestroyed()) { + pendingData.clear() + return + } + const now = performance.now() + let writes = 0 + if (hasRecentRendererInput(now) && lastRendererInputPtyId !== null) { + writes += drainPendingDataEntries( + PTY_BATCH_FLUSH_MAX_WRITES, + (id) => id === lastRendererInputPtyId + ) + } + if (shouldHoldBackgroundFlushForInput(now)) { + // Why: hidden PTYs can keep producing output while the user types in a + // foreground TUI. Holding background IPC briefly lets those bytes + // coalesce instead of flooding the renderer ahead of key echo frames. + const quietDelay = Math.max(1, BACKGROUND_OUTPUT_INPUT_QUIET_MS - (now - lastRendererInputAt)) + schedulePendingDataFlush(quietDelay) + return + } + if (writes < PTY_BATCH_FLUSH_MAX_WRITES) { + writes += drainPendingDataEntries(PTY_BATCH_FLUSH_MAX_WRITES - writes) + } if (pendingData.size > 0) { // Why: a background terminal can dump megabytes at once. Yield between // small IPC slices so keystroke writes are not stuck behind one flush. @@ -1014,6 +1180,42 @@ export function registerPtyHandlers( flushTimer = null } + function setRendererPtyOutputPaused(id: string, paused: boolean): void { + if (paused) { + rendererPausedOutputPtys.add(id) + pendingData.delete(id) + clearFlushTimerIfIdle() + return + } + rendererPausedOutputPtys.delete(id) + rendererPausedMode2031ScanTailByPty.delete(id) + } + + function respondToPausedRendererMode2031Subscribe(id: string, data: string): void { + const scan = scanMode2031Sequences(rendererPausedMode2031ScanTailByPty.get(id) ?? '', data) + if (scan.tail) { + rendererPausedMode2031ScanTailByPty.set(id, scan.tail) + } else { + rendererPausedMode2031ScanTailByPty.delete(id) + } + if (!scan.subscribe) { + return + } + const provider = tryGetProviderForPty(id) + if (!provider) { + return + } + const mode = resolveTerminalColorSchemeMode(getSettings?.(), nativeTheme.shouldUseDarkColors) + try { + // Why: paused renderer panes still need protocol replies that affect how + // TUIs draw into the main-owned snapshot. Without this, Codex starts in a + // fallback prompt style and the restored composer loses its background. + provider.write(id, mode2031SequenceFor(mode)) + } catch { + // Best effort; renderer restore will continue from whatever the TUI emits. + } + } + // Why: extracted so the "Restart daemon" flow can rebind against the fresh // adapter after replaceDaemonProvider runs. Both the startup registration // and the post-restart rebind go through the same code path — no risk of @@ -1046,14 +1248,20 @@ export function registerPtyHandlers( pendingData.clear() return } + if (rendererPausedOutputPtys.has(payload.id)) { + respondToPausedRendererMode2031Subscribe(payload.id, payload.data) + pendingData.delete(payload.id) + clearFlushTimerIfIdle() + return + } const existing = pendingData.get(payload.id) const pending = appendPendingPtyData(existing, payload.data, startSeq) const nextData = pending.data - const lastInputAt = lastInputAtByPty.get(payload.id) - const isInteractiveOutput = - nextData.length <= INTERACTIVE_OUTPUT_MAX_CHARS && - lastInputAt !== undefined && - performance.now() - lastInputAt <= INTERACTIVE_OUTPUT_WINDOW_MS + const isInteractiveOutput = shouldSendInteractiveOutputNow( + payload.id, + nextData, + performance.now() + ) if (isInteractiveOutput) { pendingData.delete(payload.id) clearFlushTimerIfIdle() @@ -1093,6 +1301,11 @@ export function registerPtyHandlers( pendingData.delete(payload.id) } lastInputAtByPty.delete(payload.id) + interactiveOutputCharsByPty.delete(payload.id) + if (lastRendererInputPtyId === payload.id) { + lastRendererInputPtyId = null + } + rendererPausedOutputPtys.delete(payload.id) mainWindow.webContents.send('pty:exit', payload) } }) @@ -1191,11 +1404,9 @@ export function registerPtyHandlers( // Why: only applies to LocalPtyProvider where PTYs live in the Electron main // process and can become orphaned on page reload. Daemon-backed sessions // survive renderer restarts by design — orphan cleanup would kill them. + clearDidFinishLoadHandler() if (localProvider instanceof LocalPtyProvider) { const lp = localProvider - if (didFinishLoadHandler) { - mainWindow.webContents.removeListener('did-finish-load', didFinishLoadHandler) - } didFinishLoadHandler = () => { const killed = lp.killOrphanedPtys(lp.advanceGeneration() - 1) for (const { id } of killed) { @@ -1205,6 +1416,7 @@ export function registerPtyHandlers( runtime?.onPtyExit(id, -1) } } + didFinishLoadWebContents = mainWindow.webContents mainWindow.webContents.on('did-finish-load', didFinishLoadHandler) } @@ -1267,7 +1479,10 @@ export function registerPtyHandlers( skipCodexHomeEnv, githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false, launchCommand: args.command, - agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()) + shellPath: daemonShellOverride ?? process.env.COMSPEC, + isWsl: shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, args.cwd), + agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()), + networkProxySettings: getSettings?.() }) } @@ -1711,7 +1926,10 @@ export function registerPtyHandlers( skipCodexHomeEnv, githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false, launchCommand: args.command, - agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()) + shellPath: effectiveShellOverride ?? process.env.COMSPEC, + isWsl: shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, args.cwd), + agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()), + networkProxySettings: getSettings?.() }) } catch (err) { // Why: buildPtyHostEnv has filesystem side-effects (Pi overlay @@ -1921,9 +2139,9 @@ export function registerPtyHandlers( // only settles its own generation. A remount that replaces the entry // with a new gen must not be stomped by the old PTY's teardown. if (validatedPaneKey && rendererPreSignaled) { - const gen = pendingByPaneKey.get(validatedPaneKey) - if (gen !== undefined) { - ptyPendingGenByPtyId.set(result.id, gen) + const pending = pendingByPaneKey.get(validatedPaneKey) + if (pending) { + ptyPendingGenByPtyId.set(result.id, pending.gen) } } @@ -2065,7 +2283,11 @@ export function registerPtyHandlers( return false } try { - lastInputAtByPty.set(args.id, performance.now()) + const now = performance.now() + lastRendererInputAt = now + lastRendererInputPtyId = args.id + lastInputAtByPty.set(args.id, now) + interactiveOutputCharsByPty.set(args.id, 0) provider.write(args.id, args.data) return true } catch { @@ -2089,7 +2311,11 @@ export function registerPtyHandlers( return false } try { - lastInputAtByPty.set(args.id, performance.now()) + const now = performance.now() + lastRendererInputAt = now + lastRendererInputPtyId = args.id + lastInputAtByPty.set(args.id, now) + interactiveOutputCharsByPty.set(args.id, 0) provider.write(args.id, args.data) return true } catch { @@ -2100,6 +2326,14 @@ export function registerPtyHandlers( ipcMain.on('pty:write', (_event, args: { id: string; data: string }) => { writePtyInput(args) }) + + ipcMain.on('pty:pauseOutput', (_event, args: { id?: string; paused?: boolean }) => { + if (typeof args?.id !== 'string') { + return + } + setRendererPtyOutputPaused(args.id, args.paused === true) + }) + ipcMain.handle('pty:writeAccepted', (_event, args: { id: string; data: string }): boolean => { return writePtyInputAccepted(args) }) @@ -2258,11 +2492,11 @@ export function registerPtyHandlers( // or pane unmount before settle, renderer calls clear with the same gen. ipcMain.handle( 'pty:declarePendingPaneSerializer', - async (_event, args: { paneKey?: unknown }): Promise => { + async (event, args: { paneKey?: unknown }): Promise => { if (!isValidPaneKey(args.paneKey)) { throw new Error('Invalid paneKey') } - return declarePendingPaneSerializer(args.paneKey) + return declarePendingPaneSerializer(args.paneKey, event?.sender) } ) diff --git a/src/main/ipc/remote-workspace-cache.test.ts b/src/main/ipc/remote-workspace-cache.test.ts new file mode 100644 index 00000000000..3965ba9ebec --- /dev/null +++ b/src/main/ipc/remote-workspace-cache.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import type { + RemoteWorkspaceSession, + RemoteWorkspaceSnapshot +} from '../../shared/remote-workspace-types' +import { + REMOTE_WORKSPACE_SNAPSHOT_CACHE_MAX_ENTRIES, + _getRemoteWorkspaceCacheSizesForTests, + _getRemoteWorkspaceSnapshotForTests, + _rememberRemoteWorkspaceSnapshotForTests, + _resetRemoteWorkspaceCachesForTests +} from './remote-workspace' + +function emptyRemoteWorkspaceSession(): RemoteWorkspaceSession { + return { + activeWorktreePath: null, + activeTabId: null, + tabsByWorktreePath: {}, + terminalLayoutsByTabId: {} + } +} + +function snapshot(session: RemoteWorkspaceSession, revision = 7): RemoteWorkspaceSnapshot { + return { + namespace: 'target', + revision, + updatedAt: 123, + schemaVersion: 1, + session + } +} + +describe('remote workspace snapshot cache', () => { + beforeEach(() => { + _resetRemoteWorkspaceCachesForTests() + }) + + it('LRU-evicts old target snapshots', () => { + for (let i = 0; i <= REMOTE_WORKSPACE_SNAPSHOT_CACHE_MAX_ENTRIES; i++) { + _rememberRemoteWorkspaceSnapshotForTests( + `target-${i}`, + snapshot({ + activeWorktreePath: `/repo-${i}`, + activeTabId: null, + tabsByWorktreePath: {}, + terminalLayoutsByTabId: {} + }) + ) + } + + expect(_getRemoteWorkspaceCacheSizesForTests().snapshots).toBe( + REMOTE_WORKSPACE_SNAPSHOT_CACHE_MAX_ENTRIES + ) + expect(_getRemoteWorkspaceSnapshotForTests('target-0')).toBeUndefined() + expect(_getRemoteWorkspaceSnapshotForTests('target-1')?.session.activeWorktreePath).toBe( + '/repo-1' + ) + }) + + it('refreshes snapshot recency on cache reads', () => { + for (let i = 0; i < REMOTE_WORKSPACE_SNAPSHOT_CACHE_MAX_ENTRIES; i++) { + _rememberRemoteWorkspaceSnapshotForTests( + `target-${i}`, + snapshot(emptyRemoteWorkspaceSession()) + ) + } + + expect(_getRemoteWorkspaceSnapshotForTests('target-0')).toBeDefined() + _rememberRemoteWorkspaceSnapshotForTests('target-new', snapshot(emptyRemoteWorkspaceSession())) + + expect(_getRemoteWorkspaceSnapshotForTests('target-0')).toBeDefined() + expect(_getRemoteWorkspaceSnapshotForTests('target-1')).toBeUndefined() + }) +}) diff --git a/src/main/ipc/remote-workspace-patch-queue.test.ts b/src/main/ipc/remote-workspace-patch-queue.test.ts index b37725500ee..6de465626e9 100644 --- a/src/main/ipc/remote-workspace-patch-queue.test.ts +++ b/src/main/ipc/remote-workspace-patch-queue.test.ts @@ -35,7 +35,10 @@ vi.mock('./remote-workspace-events', () => ({ registerRemoteWorkspaceNotificationHandler: registerRemoteWorkspaceNotificationHandlerMock })) -import { registerRemoteWorkspaceHandlers } from './remote-workspace' +import { + _resetRemoteWorkspaceCachesForTests, + registerRemoteWorkspaceHandlers +} from './remote-workspace' function snapshot(session: RemoteWorkspaceSession, revision = 7): RemoteWorkspaceSnapshot { return { @@ -80,6 +83,7 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { } beforeEach(() => { + _resetRemoteWorkspaceCachesForTests() handlers.clear() muxByTargetId.clear() vi.mocked(ipcMain.handle).mockReset() @@ -179,6 +183,115 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { expect(patchBaseRevisions).toEqual([7, 8]) }) + it('patches independent hydrated targets concurrently', async () => { + const secondTarget: SshTarget = { + id: 'target-2', + label: 'Target 2', + host: 'two.example.com', + port: 22, + username: 'alice' + } + getSshConnectionStoreMock.mockReturnValue({ + listTargets: () => [target, secondTarget] + }) + getRepoMock.mockImplementation((repoId: string) => { + if (repoId === 'repo-target-1') { + return { + id: 'repo-target-1', + path: '/remote/repo-a', + displayName: 'Repo A', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'target-1' + } as never + } + if (repoId === 'repo-target-2') { + return { + id: 'repo-target-2', + path: '/remote/repo-b', + displayName: 'Repo B', + badgeColor: 'green', + addedAt: 1, + connectionId: 'target-2' + } as never + } + return undefined + }) + + let releaseFirstPatch!: () => void + const firstPatchCanFinish = new Promise((resolve) => { + releaseFirstPatch = resolve + }) + const previousSnapshot = snapshot( + { + activeWorktreePath: '/previous', + activeTabId: null, + tabsByWorktreePath: {}, + terminalLayoutsByTabId: {} + }, + 7 + ) + const slowRequest = vi.fn(async (method: string, params: Record) => { + if (method === 'workspace.get') { + return previousSnapshot + } + if (method === 'workspace.patch') { + await firstPatchCanFinish + return { ok: true, snapshot: snapshot(patchSession(params), 8) } + } + throw new Error(`Unexpected method ${method}`) + }) + const fastRequest = vi.fn(async (method: string, params: Record) => { + if (method === 'workspace.get') { + return previousSnapshot + } + if (method === 'workspace.patch') { + return { ok: true, snapshot: snapshot(patchSession(params), 8) } + } + throw new Error(`Unexpected method ${method}`) + }) + muxByTargetId.set('target-1', { request: slowRequest }) + muxByTargetId.set('target-2', { request: fastRequest }) + + const resultPromise = callSetForConnectedTargets({ + session: { + ...sessionWithTab('repo-target-1::/remote/workspace-a', 'tab-a'), + tabsByWorktree: { + 'repo-target-1::/remote/workspace-a': [ + { + id: 'tab-a', + type: 'terminal', + title: 'Shell A', + worktreeId: 'repo-target-1::/remote/workspace-a' + } as never + ], + 'repo-target-2::/remote/workspace-b': [ + { + id: 'tab-b', + type: 'terminal', + title: 'Shell B', + worktreeId: 'repo-target-2::/remote/workspace-b' + } as never + ] + } + }, + hydratedTargetIds: ['target-1', 'target-2'] + }) + + await vi.waitFor(() => + expect(slowRequest.mock.calls.some(([method]) => method === 'workspace.patch')).toBe(true) + ) + await vi.waitFor(() => + expect(fastRequest.mock.calls.some(([method]) => method === 'workspace.patch')).toBe(true) + ) + + releaseFirstPatch() + await expect(resultPromise).resolves.toMatchObject([ + { targetId: 'target-1', result: { ok: true } }, + { targetId: 'target-2', result: { ok: true } } + ]) + }) + it('retries once when a reset relay reports a lower revision than the cached base', async () => { const resetTarget: SshTarget = { id: 'target-reset', diff --git a/src/main/ipc/remote-workspace.test.ts b/src/main/ipc/remote-workspace.test.ts index 46d249b35c6..6da587ac86c 100644 --- a/src/main/ipc/remote-workspace.test.ts +++ b/src/main/ipc/remote-workspace.test.ts @@ -35,6 +35,7 @@ vi.mock('./remote-workspace-events', () => ({ })) import { + _resetRemoteWorkspaceCachesForTests, registerRemoteWorkspaceHandlers, remoteWorkspaceSessionMatchesSnapshot } from './remote-workspace' @@ -150,11 +151,14 @@ describe('remoteWorkspace:setForConnectedTargets', () => { const requestByTargetId = new Map>() const muxByTargetId = new Map }>() const getRepoMock = vi.fn() + const getWorkspaceSessionMock = vi.fn() const store = { - getRepo: getRepoMock + getRepo: getRepoMock, + getWorkspaceSession: getWorkspaceSessionMock } as unknown as Store beforeEach(() => { + _resetRemoteWorkspaceCachesForTests() handlers.clear() requestByTargetId.clear() muxByTargetId.clear() @@ -168,6 +172,8 @@ describe('remoteWorkspace:setForConnectedTargets', () => { listTargets: () => targets }) getRepoMock.mockReset() + getWorkspaceSessionMock.mockReset() + getWorkspaceSessionMock.mockReturnValue(baseSession) getRepoMock.mockImplementation((repoId: string) => repoId === 'repo-target-1' ? ({ @@ -217,7 +223,7 @@ describe('remoteWorkspace:setForConnectedTargets', () => { }) async function callSetForConnectedTargets(args: { - session: WorkspaceSessionState + session?: WorkspaceSessionState hydratedTargetIds?: unknown }): Promise { const handler = handlers.get('remoteWorkspace:setForConnectedTargets') @@ -257,4 +263,37 @@ describe('remoteWorkspace:setForConnectedTargets', () => { ) expect(requestByTargetId.get('target-2')).toBeUndefined() }) + + it('can export from the persisted store session when no session argument is provided', async () => { + getWorkspaceSessionMock.mockReturnValue({ + activeRepoId: 'repo-target-1', + activeWorktreeId: 'repo-target-1::/repo', + activeTabId: 'tab-store', + tabsByWorktree: { + 'repo-target-1::/repo': [ + { + id: 'tab-store', + title: 'Store shell', + ptyId: 'pty-store', + worktreeId: 'repo-target-1::/repo' + } as never + ] + }, + terminalLayoutsByTabId: {} + }) + + await callSetForConnectedTargets({ hydratedTargetIds: ['target-1'] }) + + expect(requestByTargetId.get('target-1')).toHaveBeenCalledWith( + 'workspace.patch', + expect.objectContaining({ + patch: expect.objectContaining({ + session: expect.objectContaining({ + activeWorktreePath: '/repo', + activeTabId: 'tab-store' + }) + }) + }) + ) + }) }) diff --git a/src/main/ipc/remote-workspace.ts b/src/main/ipc/remote-workspace.ts index 77173c249fd..5ccc3c12cbe 100644 --- a/src/main/ipc/remote-workspace.ts +++ b/src/main/ipc/remote-workspace.ts @@ -25,12 +25,71 @@ import { registerRemoteWorkspaceNotificationHandler } from './remote-workspace-e const CLIENT_ID = randomUUID() const CLIENT_NAME = hostname() || 'This device' const SNAPSHOT_SCHEMA_VERSION = 1 +export const REMOTE_WORKSPACE_SNAPSHOT_CACHE_MAX_ENTRIES = 64 let mainWindowGetter: (() => BrowserWindow | null) | null = null const latestSnapshotByTargetId = new Map() const remoteWorkspacePatchTailByTargetId = new Map>() let unregisterRemoteWorkspaceNotifications: (() => void) | null = null +function rememberRemoteWorkspaceSnapshot( + targetId: string, + snapshot: RemoteWorkspaceSnapshot +): void { + if (latestSnapshotByTargetId.has(targetId)) { + latestSnapshotByTargetId.delete(targetId) + } + latestSnapshotByTargetId.set(targetId, snapshot) + while (latestSnapshotByTargetId.size > REMOTE_WORKSPACE_SNAPSHOT_CACHE_MAX_ENTRIES) { + const oldest = latestSnapshotByTargetId.keys().next() + if (oldest.done) { + break + } + latestSnapshotByTargetId.delete(oldest.value) + } +} + +function getCachedRemoteWorkspaceSnapshot(targetId: string): RemoteWorkspaceSnapshot | undefined { + const snapshot = latestSnapshotByTargetId.get(targetId) + if (!snapshot) { + return undefined + } + // Why: remote workspace snapshots can contain the whole tab/layout session + // for a target. Touch cache hits so deleted or rarely used targets age out. + rememberRemoteWorkspaceSnapshot(targetId, snapshot) + return snapshot +} + +export function _resetRemoteWorkspaceCachesForTests(): void { + latestSnapshotByTargetId.clear() + remoteWorkspacePatchTailByTargetId.clear() +} + +export function _getRemoteWorkspaceCacheSizesForTests(): { + snapshots: number + patchTails: number +} { + return { + snapshots: latestSnapshotByTargetId.size, + patchTails: remoteWorkspacePatchTailByTargetId.size + } +} + +/** @internal - exposed for cache-bound tests only. */ +export function _rememberRemoteWorkspaceSnapshotForTests( + targetId: string, + snapshot: RemoteWorkspaceSnapshot +): void { + rememberRemoteWorkspaceSnapshot(targetId, snapshot) +} + +/** @internal - exposed for cache-bound tests only. */ +export function _getRemoteWorkspaceSnapshotForTests( + targetId: string +): RemoteWorkspaceSnapshot | undefined { + return getCachedRemoteWorkspaceSnapshot(targetId) +} + function emptyRemoteSession(): RemoteWorkspaceSession { return { activeWorktreePath: null, @@ -211,7 +270,7 @@ async function getRemoteSnapshot(target: SshTarget): Promise { + async (_event, args: { session?: WorkspaceSessionState; hydratedTargetIds?: unknown }) => { const hydratedTargetIds = getExplicitHydratedTargetIds(args.hydratedTargetIds) if (!hydratedTargetIds) { // Why: an omitted hydration set used to broadcast one session to every @@ -386,17 +445,21 @@ export function registerRemoteWorkspaceHandlers( (target) => hydratedTargetIds.has(target.id) && getActiveMultiplexer(target.id) ) ?? [] - const results: { targetId: string; result: RemoteWorkspacePatchResult }[] = [] - for (const target of targets) { - const session = exportSessionForTarget(store, target.id, args.session) - const result = await queueRemoteWorkspacePatch(target.id, () => - patchRemoteWorkspaceSession(target, session) - ) - if (result) { - results.push({ targetId: target.id, result }) - } - } - return results + const workspaceSession = args.session ?? store.getWorkspaceSession() + const results = await Promise.all( + targets.map(async (target) => { + // Why: each target has its own revision stream. Keep same-target + // writes queued, but do not let one slow relay block others. + const session = exportSessionForTarget(store, target.id, workspaceSession) + const result = await queueRemoteWorkspacePatch(target.id, () => + patchRemoteWorkspaceSession(target, session) + ) + return result ? { targetId: target.id, result } : null + }) + ) + return results.filter( + (entry): entry is { targetId: string; result: RemoteWorkspacePatchResult } => entry !== null + ) } ) diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index 8af78912ad3..eb359c3f8a9 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -549,6 +549,42 @@ describe('repos:addRemote', () => { expect(result).toHaveProperty('repo.path', '/home/user/project') }) + it('uses the resolved git root basename for the default remote display name', async () => { + mockGitProvider.isGitRepoAsync.mockResolvedValueOnce({ + isRepo: true, + rootPath: '/home/user/project' + }) + + const result = await handlers.get('repos:addRemote')!(null, { + connectionId: 'conn-1', + remotePath: '/home/user/project/src' + }) + + expect(mockStore.addRepo).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/home/user/project', + displayName: 'project' + }) + ) + expect(result).toHaveProperty('repo.displayName', 'project') + }) + + it('derives default remote display names from Windows path separators', async () => { + const result = await handlers.get('repos:addRemote')!(null, { + connectionId: 'conn-1', + remotePath: 'C:\\Users\\alice\\project', + kind: 'folder' + }) + + expect(mockStore.addRepo).toHaveBeenCalledWith( + expect.objectContaining({ + path: 'C:\\Users\\alice\\project', + displayName: 'project' + }) + ) + expect(result).toHaveProperty('repo.displayName', 'project') + }) + it('notifies renderer when remote repo is added', async () => { await handlers.get('repos:addRemote')!(null, { connectionId: 'conn-1', diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 148c00e4b84..e8046d594e8 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -84,6 +84,14 @@ function emitRepoAdded(method: RepoMethod, alreadyExisted: boolean): void { track('repo_added', { method, ...getCohortAtEmit() }) } +function getRemoteRepoFolderName(remotePath: string): string { + const trimmed = remotePath.replace(/[\\/]+$/, '') + if (!trimmed) { + return remotePath + } + return trimmed.split(/[\\/]/).at(-1) || remotePath +} + type ActiveCloneMetadata = { path: string pathKey: string @@ -601,9 +609,6 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v return { repo: existing } } - const pathSegments = resolvedPath.replace(/\/+$/, '').split('/') - let folderName = pathSegments.at(-1) || resolvedPath - if (args.kind !== 'folder') { // Why: when kind is not explicitly 'folder', verify the remote path is // a git repo. Return an error on failure so the renderer can show the "Open as @@ -627,6 +632,8 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v } } + const folderName = getRemoteRepoFolderName(resolvedPath) + // When folderName is the home directory basename (e.g. 'ubuntu'), // use SSH target label for a more descriptive name let displayName = args.displayName || folderName @@ -909,6 +916,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v | 'repoIcon' | 'hookSettings' | 'worktreeBaseRef' + | 'worktreeBasePath' | 'kind' | 'symlinkPaths' | 'issueSourcePreference' @@ -949,6 +957,14 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v delete updates.symlinkPaths } } + if ('worktreeBasePath' in updates && updates.worktreeBasePath !== undefined) { + const v = updates.worktreeBasePath as unknown + if (typeof v !== 'string') { + delete updates.worktreeBasePath + } else { + updates.worktreeBasePath = v.trim() || undefined + } + } if ('repoIcon' in updates) { const repoIcon = sanitizeRepoIcon(updates.repoIcon) if (repoIcon === undefined) { @@ -993,6 +1009,9 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v } const updated = store.updateRepo(args.repoId, updates) if (updated) { + if ('worktreeBasePath' in updates) { + invalidateAuthorizedRootsCache() + } notifyReposChanged(mainWindow) } return updated diff --git a/src/main/ipc/rg-availability.test.ts b/src/main/ipc/rg-availability.test.ts new file mode 100644 index 00000000000..fce390c3723 --- /dev/null +++ b/src/main/ipc/rg-availability.test.ts @@ -0,0 +1,60 @@ +import { EventEmitter } from 'events' +import type { ChildProcess } from 'child_process' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { wslAwareSpawnMock } = vi.hoisted(() => ({ + wslAwareSpawnMock: vi.fn() +})) + +vi.mock('../git/runner', () => ({ + wslAwareSpawn: wslAwareSpawnMock +})) + +import { checkRgAvailable } from './rg-availability' + +function createMockProcess(): ChildProcess { + const child = new EventEmitter() as unknown as ChildProcess + ;(child as unknown as { kill: () => boolean }).kill = vi.fn(() => true) + return child +} + +describe('checkRgAvailable', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns true when rg exits successfully', async () => { + const child = createMockProcess() + wslAwareSpawnMock.mockReturnValue(child) + + const promise = checkRgAvailable('/repo') + child.emit('close', 0) + + await expect(promise).resolves.toBe(true) + expect(wslAwareSpawnMock).toHaveBeenCalledWith('rg', ['--version'], { + cwd: '/repo', + stdio: 'ignore' + }) + }) + + it('settles and detaches when rg availability check ignores timeout kills', async () => { + vi.useFakeTimers() + + try { + const child = createMockProcess() + wslAwareSpawnMock.mockReturnValue(child) + + const promise = checkRgAvailable('/repo') + await Promise.resolve() + + await vi.advanceTimersByTimeAsync(5000) + + await expect(Promise.race([promise, Promise.resolve('pending')])).resolves.toBe(false) + expect(child.kill).toHaveBeenCalled() + expect(child.listenerCount('error')).toBe(0) + expect(child.listenerCount('close')).toBe(0) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/ipc/rg-availability.ts b/src/main/ipc/rg-availability.ts index f6abf2d9afa..74c6e1e77a4 100644 --- a/src/main/ipc/rg-availability.ts +++ b/src/main/ipc/rg-availability.ts @@ -1,5 +1,7 @@ import { wslAwareSpawn } from '../git/runner' +const RG_AVAILABILITY_TIMEOUT_MS = 5000 + // Why the `settled` flag: when rg is not installed, spawn emits both 'error' // and 'close' with non-deterministic ordering across Node versions/platforms. // Without guarding, a late 'error' after 'close' would double-resolve (or a @@ -22,19 +24,34 @@ export function checkRgAvailable(searchPath?: string): Promise { ...(searchPath ? { cwd: searchPath } : {}), stdio: 'ignore' }) - child.once('error', () => { + let timeout: ReturnType + + const cleanup = (): void => { + clearTimeout(timeout) + child.off('error', onError) + child.off('close', onClose) + } + + const settle = (available: boolean, options?: { kill?: boolean }): void => { if (settled) { return } settled = true - resolve(false) - }) - child.once('close', (code) => { - if (settled) { - return + cleanup() + if (options?.kill) { + child.kill() } - settled = true - resolve(code === 0) - }) + resolve(available) + } + + const onError = (): void => settle(false) + const onClose = (code: number | null): void => settle(code === 0) + + child.once('error', onError) + child.once('close', onClose) + timeout = setTimeout(() => settle(false, { kill: true }), RG_AVAILABILITY_TIMEOUT_MS) + if (typeof timeout.unref === 'function') { + timeout.unref() + } }) } diff --git a/src/main/ipc/runtime-environments.test.ts b/src/main/ipc/runtime-environments.test.ts index 14d51953361..993ed146d61 100644 --- a/src/main/ipc/runtime-environments.test.ts +++ b/src/main/ipc/runtime-environments.test.ts @@ -10,6 +10,8 @@ import * as environmentStore from '../../shared/runtime-environment-store' const { handleMock, onMock, + removeHandlerMock, + removeAllListenersMock, getPathMock, sendRemoteRuntimeRequestMock, subscribeRemoteRuntimeRequestMock, @@ -18,6 +20,8 @@ const { } = vi.hoisted(() => ({ handleMock: vi.fn(), onMock: vi.fn(), + removeHandlerMock: vi.fn(), + removeAllListenersMock: vi.fn(), getPathMock: vi.fn(), sendRemoteRuntimeRequestMock: vi.fn(), subscribeRemoteRuntimeRequestMock: vi.fn(), @@ -27,7 +31,12 @@ const { vi.mock('electron', () => ({ app: { getPath: getPathMock }, - ipcMain: { handle: handleMock, on: onMock } + ipcMain: { + handle: handleMock, + on: onMock, + removeHandler: removeHandlerMock, + removeAllListeners: removeAllListenersMock + } })) vi.mock('../../shared/remote-runtime-client', () => ({ @@ -68,6 +77,8 @@ describe('registerRuntimeEnvironmentHandlers', () => { getPathMock.mockReturnValue(userDataPath) handleMock.mockReset() onMock.mockReset() + removeHandlerMock.mockReset() + removeAllListenersMock.mockReset() sendRemoteRuntimeRequestMock.mockReset() subscribeRemoteRuntimeRequestMock.mockReset() sendRemoteRuntimeConnectionRequestMock.mockReset() @@ -96,6 +107,22 @@ describe('registerRuntimeEnvironmentHandlers', () => { ]) }) + it('clears stale IPC registrations before registering runtime environment handlers', () => { + registerRuntimeEnvironmentHandlers() + + expect(removeHandlerMock.mock.calls.map((call) => call[0])).toEqual([ + 'runtimeEnvironments:list', + 'runtimeEnvironments:addFromPairingCode', + 'runtimeEnvironments:resolve', + 'runtimeEnvironments:remove', + 'runtimeEnvironments:getStatus', + 'runtimeEnvironments:call', + 'runtimeEnvironments:subscribe', + 'runtimeEnvironments:unsubscribe' + ]) + expect(removeAllListenersMock).toHaveBeenCalledWith('runtimeEnvironments:subscriptionBinary') + }) + it('stores, resolves, lists, and removes environments under Electron userData', async () => { registerRuntimeEnvironmentHandlers() @@ -421,6 +448,70 @@ describe('registerRuntimeEnvironmentHandlers', () => { markUsedSpy.mockRestore() }) + it('closes streaming subscriptions when their saved runtime is removed', async () => { + registerRuntimeEnvironmentHandlers() + const close = vi.fn() + const sendBinary = vi.fn() + subscribeRemoteRuntimeRequestMock.mockResolvedValue({ + requestId: 'stream-remove', + close, + sendBinary + }) + + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + const added = await add(null, { name: 'desk', pairingCode: pairingCode() }) + + const destroyedListenerRemoved = vi.fn() + const subscribe = handler< + { + selector: string + method: string + params?: unknown + subscriptionId?: string + }, + { subscriptionId: string; requestId: string } + >('runtimeEnvironments:subscribe') + const result = await subscribe( + { + sender: { + id: 1, + isDestroyed: () => false, + send: vi.fn(), + once: vi.fn(), + removeListener: destroyedListenerRemoved + } + }, + { + selector: 'desk', + method: 'terminal.subscribe', + params: { terminal: 't1' }, + subscriptionId: 'removed-env-sub' + } + ) + + const remove = handler<{ selector: string }, { removed: { id: string; name: string } }>( + 'runtimeEnvironments:remove' + ) + expect(remove(null, { selector: added.environment.id })).toMatchObject({ + removed: { id: added.environment.id, name: 'desk' } + }) + + expect(close).toHaveBeenCalledTimes(1) + expect(destroyedListenerRemoved).toHaveBeenCalledWith('destroyed', expect.any(Function)) + + const unsubscribe = handler<{ subscriptionId: string }, { unsubscribed: boolean }>( + 'runtimeEnvironments:unsubscribe' + ) + expect( + await unsubscribe({ sender: { id: 1 } }, { subscriptionId: result.subscriptionId }) + ).toEqual({ + unsubscribed: false + }) + }) + it('rejects cross-window streaming subscription control', async () => { registerRuntimeEnvironmentHandlers() const close = vi.fn() diff --git a/src/main/ipc/runtime-environments.ts b/src/main/ipc/runtime-environments.ts index 9b278ba63ba..3d206a149fb 100644 --- a/src/main/ipc/runtime-environments.ts +++ b/src/main/ipc/runtime-environments.ts @@ -28,7 +28,19 @@ import { } from './runtime-environment-request-connections' const DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS = 15_000 +const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [ + 'runtimeEnvironments:list', + 'runtimeEnvironments:addFromPairingCode', + 'runtimeEnvironments:resolve', + 'runtimeEnvironments:remove', + 'runtimeEnvironments:getStatus', + 'runtimeEnvironments:call', + 'runtimeEnvironments:subscribe', + 'runtimeEnvironments:unsubscribe' +] as const + type RetainedRemoteRuntimeSubscription = RemoteRuntimeSubscription & { + environmentId: string ownerWebContentsId: number removeDestroyedListener: () => void } @@ -42,7 +54,26 @@ function shouldUseCachedRequestConnection(method: string): boolean { return method === 'terminal.send' || method === 'terminal.updateViewport' } +function closeSubscriptionsForEnvironment(environmentId: string): void { + // Why: removing a saved runtime invalidates its streaming WebSockets too; + // otherwise terminal/browser subscriptions stay alive until renderer teardown. + for (const [subscriptionId, subscription] of remoteRuntimeSubscriptions) { + if (subscription.environmentId !== environmentId) { + continue + } + remoteRuntimeSubscriptions.delete(subscriptionId) + subscription.close() + } +} + export function registerRuntimeEnvironmentHandlers(): void { + // Why: keep direct re-registration safe even though register-core-handlers + // normally guards this path; otherwise the binary send listener can stack. + for (const channel of RUNTIME_ENVIRONMENT_HANDLER_CHANNELS) { + ipcMain.removeHandler(channel) + } + ipcMain.removeAllListeners('runtimeEnvironments:subscriptionBinary') + ipcMain.handle('runtimeEnvironments:list', (): PublicKnownRuntimeEnvironment[] => listEnvironments(getUserDataPath()).map(redactRuntimeEnvironment) ) @@ -68,6 +99,7 @@ export function registerRuntimeEnvironmentHandlers(): void { if (args.selector !== removed.id) { closeRemoteRuntimeRequestConnection(args.selector) } + closeSubscriptionsForEnvironment(removed.id) return { removed: redactRuntimeEnvironment(removed) } } ) @@ -118,6 +150,7 @@ export function registerRuntimeEnvironmentHandlers(): void { if (remoteRuntimeSubscriptions.has(subscriptionId)) { throw new Error('Runtime environment subscription id already exists') } + const environment = resolveEnvironment(getUserDataPath(), args.selector) const sender = event.sender const ownerWebContentsId = sender.id let senderDestroyed = sender.isDestroyed() @@ -145,7 +178,7 @@ export function registerRuntimeEnvironmentHandlers(): void { destroyedListenerAttached = true try { subscription = await subscribeRuntimeEnvironment( - args.selector, + environment.id, args.method, args.params, args.timeoutMs, @@ -176,6 +209,7 @@ export function registerRuntimeEnvironmentHandlers(): void { } remoteRuntimeSubscriptions.set(subscriptionId, { requestId: subscription.requestId, + environmentId: environment.id, ownerWebContentsId, removeDestroyedListener, sendBinary: (bytes) => subscription?.sendBinary(bytes) ?? false, diff --git a/src/main/ipc/session.ts b/src/main/ipc/session.ts index dc962596fac..1a5fd170b4d 100644 --- a/src/main/ipc/session.ts +++ b/src/main/ipc/session.ts @@ -1,6 +1,6 @@ import { ipcMain } from 'electron' import type { Store } from '../persistence' -import type { WorkspaceSessionState } from '../../shared/types' +import type { WorkspaceSessionPatch, WorkspaceSessionState } from '../../shared/types' export function registerSessionHandlers(store: Store): void { ipcMain.handle('session:get', () => { @@ -11,6 +11,10 @@ export function registerSessionHandlers(store: Store): void { store.setWorkspaceSession(args) }) + ipcMain.handle('session:patch', (_event, args: WorkspaceSessionPatch) => { + store.patchWorkspaceSession(args) + }) + // Synchronous variant for the renderer's beforeunload handler. // sendSync blocks the renderer until this returns, guaranteeing the // data (including terminal scrollback buffers) is persisted to disk diff --git a/src/main/ipc/settings.test.ts b/src/main/ipc/settings.test.ts index 657283b4ca1..d1b19f58794 100644 --- a/src/main/ipc/settings.test.ts +++ b/src/main/ipc/settings.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' -const { browserWindowGetAllWindowsMock, handleMock, previewGhosttyImportMock } = vi.hoisted(() => ({ +const { + applyElectronProxySettingsMock, + browserWindowGetAllWindowsMock, + handleMock, + previewGhosttyImportMock +} = vi.hoisted(() => ({ + applyElectronProxySettingsMock: vi.fn(), browserWindowGetAllWindowsMock: vi.fn(), handleMock: vi.fn(), previewGhosttyImportMock: vi.fn() @@ -16,6 +22,10 @@ vi.mock('../ghostty/index', () => ({ previewGhosttyImport: previewGhosttyImportMock })) +vi.mock('../network/proxy-settings', () => ({ + applyElectronProxySettings: applyElectronProxySettingsMock +})) + import { registerSettingsHandlers } from './settings' const settingsInvokeEvent = { sender: { id: 1 } } @@ -36,6 +46,8 @@ const store = { describe('registerSettingsHandlers', () => { beforeEach(() => { handleMock.mockClear() + applyElectronProxySettingsMock.mockClear() + applyElectronProxySettingsMock.mockResolvedValue({ source: 'settings' }) previewGhosttyImportMock.mockClear() browserWindowGetAllWindowsMock.mockReset() store.getSettings.mockReset() @@ -154,4 +166,54 @@ describe('registerSettingsHandlers', () => { { notifyListeners: true, originWebContentsId: 1 } ) }) + + it('sanitizes and applies proxy settings from renderer settings IPC', async () => { + store.getSettings.mockReturnValue({ httpProxyUrl: '' }) + store.updateSettings.mockReturnValue({ + httpProxyUrl: 'http://proxy.example:8080', + httpProxyBypassRules: 'localhost;*.internal' + }) + registerSettingsHandlers(store as never) + + const handler = handleMock.mock.calls.find((call) => call[0] === 'settings:set')?.[1] as ( + _event: unknown, + args: unknown + ) => Promise + + await handler(settingsInvokeEvent, { + httpProxyUrl: ' http://proxy.example:8080/path#frag ', + httpProxyBypassRules: 'localhost, *.internal' + }) + + expect(store.updateSettings).toHaveBeenCalledWith( + { + httpProxyUrl: 'http://proxy.example:8080', + httpProxyBypassRules: 'localhost;*.internal' + }, + { notifyListeners: true, originWebContentsId: 1 } + ) + expect(applyElectronProxySettingsMock).toHaveBeenCalledWith({ + httpProxyUrl: 'http://proxy.example:8080', + httpProxyBypassRules: 'localhost;*.internal' + }) + }) + + it('drops invalid proxy URLs at the settings boundary', async () => { + store.getSettings.mockReturnValue({ httpProxyUrl: 'http://proxy.example:8080' }) + store.updateSettings.mockReturnValue({ httpProxyUrl: '' }) + registerSettingsHandlers(store as never) + + const handler = handleMock.mock.calls.find((call) => call[0] === 'settings:set')?.[1] as ( + _event: unknown, + args: unknown + ) => Promise + + await handler(settingsInvokeEvent, { httpProxyUrl: 'ftp://proxy.example:2121' }) + + expect(store.updateSettings).toHaveBeenCalledWith( + { httpProxyUrl: '' }, + { notifyListeners: true, originWebContentsId: 1 } + ) + expect(applyElectronProxySettingsMock).toHaveBeenCalledWith({ httpProxyUrl: '' }) + }) }) diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index bbedaec5471..60a457e97fc 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -9,6 +9,8 @@ import { SETTINGS_CHANGED_WHITELIST, type SettingsChangedKey } from '../../share import type { AgentAwakeService } from '../agent-awake-service' import { sanitizeFloatingWorkspaceDirectorySetting } from './floating-workspace-directory' import { applyAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' +import { applyElectronProxySettings } from '../network/proxy-settings' +import { normalizeProxyBypassRules, normalizeProxyUrl } from '../../shared/network-proxy' // Why: the whitelist is the source-of-truth for which keys we emit on. Casting // to a Set once at module load lets the IPC handler's per-key membership @@ -54,6 +56,13 @@ export function registerSettingsHandlers( args.floatingTerminalCwd ) } + if ('httpProxyUrl' in args) { + const proxyUrl = normalizeProxyUrl(args.httpProxyUrl) + sanitizedArgs.httpProxyUrl = proxyUrl.ok ? proxyUrl.value : '' + } + if ('httpProxyBypassRules' in args) { + sanitizedArgs.httpProxyBypassRules = normalizeProxyBypassRules(args.httpProxyBypassRules) + } if (args.theme) { nativeTheme.themeSource = args.theme } @@ -82,6 +91,13 @@ export function registerSettingsHandlers( if (APPEARANCE_MENU_KEYS.some((key) => key in sanitizedArgs)) { rebuildAppMenu() } + if ('httpProxyUrl' in sanitizedArgs || 'httpProxyBypassRules' in sanitizedArgs) { + try { + await applyElectronProxySettings(result) + } catch { + console.warn('[settings] failed to apply network proxy settings') + } + } // Why: telemetry-plan.md§Settings — fire `settings_changed` only for // whitelisted keys, with `value_kind` distinguishing booleans from diff --git a/src/main/ipc/shell.test.ts b/src/main/ipc/shell.test.ts index 29a301cba65..ca53ee48ef7 100644 --- a/src/main/ipc/shell.test.ts +++ b/src/main/ipc/shell.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Why: shell IPC path validation, OS opener fallbacks, and launcher lifecycle tests share one mocked Electron/child_process boundary. */ import { beforeEach, describe, expect, it, vi } from 'vitest' import { normalize, resolve } from 'node:path' import { pathToFileURL } from 'node:url' @@ -58,6 +59,7 @@ import { EXTERNAL_EDITOR_CLI_COMMAND, registerShellHandlers } from './shell' function createSpawnedProcess(result: 'spawn' | 'error' = 'spawn'): { once: ReturnType + off: ReturnType unref: ReturnType } { const child = { @@ -69,6 +71,7 @@ function createSpawnedProcess(result: 'spawn' | 'error' = 'spawn'): { } return child }), + off: vi.fn(() => child), unref: vi.fn() } return child @@ -203,7 +206,8 @@ describe('registerShellHandlers', () => { }) it('maps launcher failures to launch-failed', async () => { - spawnMock.mockReturnValueOnce(createSpawnedProcess('error')) + const child = createSpawnedProcess('error') + spawnMock.mockReturnValueOnce(child) const workspacePath = resolve('workspace') const handler = getHandler('shell:openInExternalEditor') @@ -220,10 +224,14 @@ describe('registerShellHandlers', () => { stdio: 'ignore', windowsHide: true }) + expect(child.off).toHaveBeenCalledWith('error', expect.any(Function)) + expect(child.off).toHaveBeenCalledWith('spawn', expect.any(Function)) expect(openPathMock).not.toHaveBeenCalled() }) it('opens existing absolute paths with the editor launcher', async () => { + const child = createSpawnedProcess() + spawnMock.mockReturnValueOnce(child) const workspacePath = resolve('workspace') const handler = getHandler('shell:openInExternalEditor') @@ -237,6 +245,8 @@ describe('registerShellHandlers', () => { stdio: 'ignore', windowsHide: true }) + expect(child.off).toHaveBeenCalledWith('error', expect.any(Function)) + expect(child.off).toHaveBeenCalledWith('spawn', expect.any(Function)) expect(openPathMock).not.toHaveBeenCalled() }) @@ -302,7 +312,7 @@ describe('registerShellHandlers', () => { it('does not open relative file paths', async () => { const handler = getHandler('shell:openFilePath') - await expect(handler({}, 'relative/file.md')).resolves.toBeUndefined() + await expect(handler({}, 'relative/file.md')).resolves.toBe(false) expect(openPathMock).not.toHaveBeenCalled() }) @@ -310,16 +320,33 @@ describe('registerShellHandlers', () => { statMock.mockRejectedValueOnce(new Error('missing')) const handler = getHandler('shell:openFilePath') - await expect(handler({}, resolve('missing.md'))).resolves.toBeUndefined() + await expect(handler({}, resolve('missing.md'))).resolves.toBe(false) expect(openPathMock).not.toHaveBeenCalled() }) - it('swallows host launcher failures for file paths', async () => { + it('returns true when the host launcher accepts file paths', async () => { + const filePath = resolve('note.md') + const handler = getHandler('shell:openFilePath') + + await expect(handler({}, filePath)).resolves.toBe(true) + expect(openPathMock).toHaveBeenCalledWith(normalize(filePath)) + }) + + it('returns false for host launcher failures for file paths', async () => { openPathMock.mockRejectedValueOnce(new Error('launcher unavailable')) const filePath = resolve('note.md') const handler = getHandler('shell:openFilePath') - await expect(handler({}, filePath)).resolves.toBeUndefined() + await expect(handler({}, filePath)).resolves.toBe(false) + expect(openPathMock).toHaveBeenCalledWith(normalize(filePath)) + }) + + it('returns false when the host launcher reports file path errors', async () => { + openPathMock.mockResolvedValueOnce('no default app') + const filePath = resolve('note.md') + const handler = getHandler('shell:openFilePath') + + await expect(handler({}, filePath)).resolves.toBe(false) expect(openPathMock).toHaveBeenCalledWith(normalize(filePath)) }) diff --git a/src/main/ipc/shell.ts b/src/main/ipc/shell.ts index 37048c650c3..b261426a880 100644 --- a/src/main/ipc/shell.ts +++ b/src/main/ipc/shell.ts @@ -84,21 +84,31 @@ async function launchExternalEditor(pathValue: string, command?: string): Promis windowsHide: true }) let settled = false - const settle = (callback: () => void): void => { + + function cleanup(): void { + child.off('error', onError) + child.off('spawn', onSpawn) + } + + function settle(callback: () => void): void { if (settled) { return } settled = true + cleanup() callback() } - child.once('error', (error) => { + function onError(error: Error): void { settle(() => rejectPromise(error)) - }) - child.once('spawn', () => { + } + + function onSpawn(): void { child.unref() settle(resolvePromise) - }) + } + child.once('error', onError) + child.once('spawn', onSpawn) }) } @@ -118,6 +128,19 @@ async function openInExternalEditor( } } +async function openWithSystemDefault(pathValue: string): Promise { + const target = await validateLocalPathTarget(pathValue) + if (!target.ok) { + return false + } + try { + const errorMessage = await shell.openPath(target.path) + return errorMessage.length === 0 + } catch { + return false + } +} + export function registerShellHandlers(): void { ipcMain.handle('shell:openPath', (_event, path: string) => { shell.showItemInFolder(path) @@ -149,16 +172,8 @@ export function registerShellHandlers(): void { return shell.openExternal(parsed.toString()) }) - ipcMain.handle('shell:openFilePath', async (_event, filePath: string) => { - const target = await validateLocalPathTarget(filePath) - if (!target.ok) { - return - } - try { - await shell.openPath(target.path) - } catch { - // Why: legacy file-open IPC is best-effort; callers already treat failure as a no-op. - } + ipcMain.handle('shell:openFilePath', async (_event, filePath: string): Promise => { + return openWithSystemDefault(filePath) }) ipcMain.handle('shell:openFileUri', async (_event, rawUri: string) => { @@ -190,11 +205,7 @@ export function registerShellHandlers(): void { return } - try { - await shell.openPath(target.path) - } catch { - // Why: legacy file-open IPC is best-effort; callers already treat failure as a no-op. - } + await openWithSystemDefault(target.path) }) ipcMain.handle('shell:pathExists', async (_event, filePath: string): Promise => { diff --git a/src/main/ipc/speech.test.ts b/src/main/ipc/speech.test.ts new file mode 100644 index 00000000000..310103fb2a1 --- /dev/null +++ b/src/main/ipc/speech.test.ts @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { handleMock, fromWebContentsMock, getSpeechModelManagerMock, getSpeechSttServiceMock } = + vi.hoisted(() => ({ + handleMock: vi.fn(), + fromWebContentsMock: vi.fn(), + getSpeechModelManagerMock: vi.fn(), + getSpeechSttServiceMock: vi.fn() + })) + +vi.mock('electron', () => ({ + app: { getPath: vi.fn(() => '/tmp/orca-speech-test') }, + BrowserWindow: { fromWebContents: fromWebContentsMock }, + ipcMain: { handle: handleMock }, + systemPreferences: { + getMediaAccessStatus: vi.fn(() => 'granted'), + askForMediaAccess: vi.fn(() => Promise.resolve(true)) + } +})) + +vi.mock('../speech/model-catalog', () => ({ + SPEECH_MODEL_CATALOG: [], + getCatalogModel: vi.fn(() => ({ id: 'model-1' })) +})) + +vi.mock('../speech/speech-runtime-service', () => ({ + getSpeechModelManager: getSpeechModelManagerMock, + getSpeechSttService: getSpeechSttServiceMock +})) + +import { registerSpeechHandlers } from './speech' + +type SpeechDownloadHandler = (event: { sender: { id: number } }, modelId: string) => Promise + +function getHandler(channel: string): SpeechDownloadHandler { + const call = handleMock.mock.calls.find((entry) => entry[0] === channel) + if (!call) { + throw new Error(`${channel} handler not registered`) + } + return call[1] as SpeechDownloadHandler +} + +describe('registerSpeechHandlers', () => { + beforeEach(() => { + handleMock.mockReset() + fromWebContentsMock.mockReset() + getSpeechModelManagerMock.mockReset() + getSpeechSttServiceMock.mockReset() + }) + + it('clears the model download progress callback after completion', async () => { + const clearProgressCallback = vi.fn() + const progressCallbacks: ((modelId: string, progress: number) => void)[] = [] + let resolveDownload: () => void = () => {} + const manager = { + setProgressCallback: vi.fn((callback: (modelId: string, progress: number) => void) => { + progressCallbacks.push(callback) + return clearProgressCallback + }), + downloadModel: vi.fn( + () => + new Promise((resolve) => { + resolveDownload = resolve + }) + ) + } + const send = vi.fn() + const window = { + isDestroyed: vi.fn(() => false), + webContents: { send }, + once: vi.fn(), + off: vi.fn() + } + getSpeechModelManagerMock.mockReturnValue(manager) + fromWebContentsMock.mockReturnValue(window) + registerSpeechHandlers({} as never) + + const pending = getHandler('speech:downloadModel')({ sender: { id: 7 } }, 'model-1') + progressCallbacks[0]?.('model-1', 0.5) + resolveDownload() + await pending + + expect(send).toHaveBeenCalledWith('speech:downloadProgress', { + modelId: 'model-1', + progress: 0.5 + }) + expect(clearProgressCallback).toHaveBeenCalledTimes(1) + expect(window.off).toHaveBeenCalledWith('closed', expect.any(Function)) + }) + + it('clears the model download progress callback when the window closes', async () => { + const clearProgressCallback = vi.fn() + let resolveDownload: () => void = () => {} + const closeHandlers: (() => void)[] = [] + const manager = { + setProgressCallback: vi.fn(() => clearProgressCallback), + downloadModel: vi.fn( + () => + new Promise((resolve) => { + resolveDownload = resolve + }) + ) + } + const window = { + isDestroyed: vi.fn(() => false), + webContents: { send: vi.fn() }, + once: vi.fn((_event: string, handler: () => void) => { + closeHandlers.push(handler) + }), + off: vi.fn() + } + getSpeechModelManagerMock.mockReturnValue(manager) + fromWebContentsMock.mockReturnValue(window) + registerSpeechHandlers({} as never) + + const pending = getHandler('speech:downloadModel')({ sender: { id: 7 } }, 'model-1') + closeHandlers[0]?.() + resolveDownload() + await pending + + expect(clearProgressCallback).toHaveBeenCalledTimes(1) + expect(window.off).toHaveBeenCalledWith('closed', expect.any(Function)) + }) +}) diff --git a/src/main/ipc/speech.ts b/src/main/ipc/speech.ts index 957ae1b02af..36a69b64097 100644 --- a/src/main/ipc/speech.ts +++ b/src/main/ipc/speech.ts @@ -21,13 +21,28 @@ export function registerSpeechHandlers(store: Store): void { if (!window) { return } - manager.setProgressCallback((id, progress) => { + const clearProgressCallback = manager.setProgressCallback((id, progress) => { if (!window.isDestroyed()) { window.webContents.send('speech:downloadProgress', { modelId: id, progress }) } }) - - await manager.downloadModel(modelId) + // Why: ModelManager is process-wide; scope this BrowserWindow closure to + // the download/window lifetime so stale windows are not retained. + let progressCallbackCleared = false + const cleanupProgressCallback = (): void => { + if (progressCallbackCleared) { + return + } + progressCallbackCleared = true + window.off('closed', cleanupProgressCallback) + clearProgressCallback() + } + window.once('closed', cleanupProgressCallback) + try { + await manager.downloadModel(modelId) + } finally { + cleanupProgressCallback() + } }) ipcMain.handle('speech:cancelDownload', async (_event, modelId: string) => { diff --git a/src/main/ipc/ssh-browse.test.ts b/src/main/ipc/ssh-browse.test.ts index 4d288be2394..f50775a492c 100644 --- a/src/main/ipc/ssh-browse.test.ts +++ b/src/main/ipc/ssh-browse.test.ts @@ -59,6 +59,12 @@ describe('registerSshBrowseHandler', () => { ] }) expect(exec).toHaveBeenCalledWith('cd "$HOME" && pwd && command ls -1Ap') + expect(channel.listenerCount('data')).toBe(0) + expect(channel.listenerCount('exit')).toBe(0) + expect(channel.listenerCount('close')).toBe(0) + expect(channel.listenerCount('error')).toBe(0) + expect(channel.stderr.listenerCount('data')).toBe(0) + expect(channel.stderr.listenerCount('error')).toBe(0) }) it('escapes remote browse paths before invoking command ls', async () => { @@ -81,4 +87,62 @@ describe('registerSshBrowseHandler', () => { }) expect(exec).toHaveBeenCalledWith("cd '/tmp/it'\\''s here' && pwd && command ls -1Ap") }) + + it('rejects and detaches listeners when the browse channel errors', async () => { + const channel = createMockChannel() + const exec = vi.fn().mockResolvedValue(channel) + const getConnectionManager = () => ({ + getConnection: () => ({ exec }) + }) + registerSshBrowseHandler(getConnectionManager as never) + + const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: '/tmp' }) + await Promise.resolve() + channel.emit('error', new Error('remote disconnected')) + + await expect(resultPromise).rejects.toThrow('remote disconnected') + expect(channel.listenerCount('data')).toBe(0) + expect(channel.listenerCount('exit')).toBe(0) + expect(channel.listenerCount('close')).toBe(0) + expect(channel.listenerCount('error')).toBe(0) + expect(channel.stderr.listenerCount('data')).toBe(0) + expect(channel.stderr.listenerCount('error')).toBe(0) + }) + + it('times out browse channels that never close', async () => { + vi.useFakeTimers() + try { + const channel = createMockChannel() + const exec = vi.fn().mockResolvedValue(channel) + const getConnectionManager = () => ({ + getConnection: () => ({ exec }) + }) + registerSshBrowseHandler(getConnectionManager as never) + + const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: '/mnt/stalled' }) + let settled = false + void resultPromise.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + + await Promise.resolve() + await vi.advanceTimersByTimeAsync(15_000) + + expect(settled).toBe(true) + await expect(resultPromise).rejects.toThrow('Remote directory listing timed out') + expect(channel.listenerCount('data')).toBe(0) + expect(channel.listenerCount('exit')).toBe(0) + expect(channel.listenerCount('close')).toBe(0) + expect(channel.listenerCount('error')).toBe(0) + expect(channel.stderr.listenerCount('data')).toBe(0) + expect(channel.stderr.listenerCount('error')).toBe(0) + } finally { + vi.useRealTimers() + } + }) }) diff --git a/src/main/ipc/ssh-browse.ts b/src/main/ipc/ssh-browse.ts index 78a7eb1ea94..83afbd9de01 100644 --- a/src/main/ipc/ssh-browse.ts +++ b/src/main/ipc/ssh-browse.ts @@ -6,6 +6,8 @@ export type RemoteDirEntry = { isDirectory: boolean } +const SSH_BROWSE_TIMEOUT_MS = 15_000 + // Why: the relay's fs.readDir enforces workspace root ACLs, which aren't // registered until a repo is added. This handler uses a raw SSH exec channel // to list directories, allowing the user to browse the remote filesystem @@ -44,19 +46,72 @@ export function registerSshBrowseHandler( let stdout = '' let stderr = '' let exitCode: number | null = null + let settled = false + let timeout: ReturnType | null = null - channel.on('data', (data: Buffer) => { + const cleanup = (): void => { + if (timeout) { + clearTimeout(timeout) + timeout = null + } + channel.off('data', onStdoutData) + channel.stderr.off('data', onStderrData) + channel.off('exit', onExit) + channel.off('close', onClose) + channel.off('error', onError) + channel.stderr.off('error', onError) + } + const rejectOnce = (error: Error): void => { + if (settled) { + return + } + settled = true + cleanup() + reject(error) + } + const closeChannel = (): void => { + const closable = channel as { close?: () => void; destroy?: () => void } + try { + if (typeof closable.close === 'function') { + closable.close() + } else if (typeof closable.destroy === 'function') { + closable.destroy() + } + } catch { + /* best effort */ + } + } + const onTimeout = (): void => { + // Why: remote browsing runs before a relay workspace root exists, so + // it cannot rely on relay request deadlines. Bound this raw exec + // channel directly to keep Add Remote Project from hanging forever. + rejectOnce(new Error('Remote directory listing timed out')) + closeChannel() + } + const resolveOnce = (result: { entries: RemoteDirEntry[]; resolvedPath: string }): void => { + if (settled) { + return + } + settled = true + cleanup() + resolve(result) + } + + const onStdoutData = (data: Buffer): void => { stdout += data.toString() - }) - channel.stderr.on('data', (data: Buffer) => { + } + const onStderrData = (data: Buffer): void => { stderr += data.toString() - }) + } // `exit` fires before `close`; capture the code so we can distinguish // a failed `ls` that still produced `pwd` output from an empty listing. - channel.on('exit', (code: number | null) => { + const onExit = (code: number | null): void => { exitCode = code - }) - channel.on('close', () => { + } + const onError = (error: Error): void => { + rejectOnce(error) + } + const onClose = (): void => { // A null exitCode means the server closed the channel without // sending an exit-status message (or signalled termination). We // can't assume success — falling back to "empty stdout = empty @@ -70,17 +125,17 @@ export function registerSshBrowseHandler( (exitCode === null ? 'Remote listing failed (channel closed without exit status)' : `Remote listing failed (exit ${exitCode})`) - reject(new Error(msg)) + rejectOnce(new Error(msg)) return } if (stderr.trim() && !stdout.trim()) { - reject(new Error(stderr.trim())) + rejectOnce(new Error(stderr.trim())) return } const lines = stdout.trim().split('\n') if (lines.length === 0) { - reject(new Error('Empty response from remote')) + rejectOnce(new Error('Empty response from remote')) return } @@ -107,8 +162,21 @@ export function registerSshBrowseHandler( return a.name.localeCompare(b.name) }) - resolve({ entries, resolvedPath }) - }) + resolveOnce({ entries, resolvedPath }) + } + + channel.on('data', onStdoutData) + channel.stderr.on('data', onStderrData) + channel.on('exit', onExit) + channel.on('close', onClose) + // Why: SSH exec streams emit `error` on transport loss; without a + // scoped listener, a disappearing remote can become process-fatal. + channel.on('error', onError) + channel.stderr.on('error', onError) + timeout = setTimeout(onTimeout, SSH_BROWSE_TIMEOUT_MS) + if (typeof timeout.unref === 'function') { + timeout.unref() + } }) } ) diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index b3ac052224f..df0ff73f016 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -47,6 +47,7 @@ const { dispose: vi.fn(), isDisposed: vi.fn().mockReturnValue(false), onNotification: vi.fn(), + onRequest: vi.fn().mockReturnValue(() => {}), onDispose: vi.fn().mockReturnValue(() => {}), request: vi.fn().mockResolvedValue({}), notify: vi.fn() @@ -151,7 +152,8 @@ vi.mock('./pty', () => ({ deletePtyOwnership: vi.fn(), setPtyOwnership: vi.fn(), getSshPtyProvider: vi.fn(), - getPtyIdsForConnection: vi.fn().mockReturnValue([]) + getPtyIdsForConnection: vi.fn().mockReturnValue([]), + isRendererPtyOutputPaused: vi.fn().mockReturnValue(false) })) vi.mock('../providers/ssh-filesystem-dispatch', () => ({ diff --git a/src/main/ipc/workspace-cleanup.test.ts b/src/main/ipc/workspace-cleanup.test.ts index 2a1e5072183..0694ac5e9a5 100644 --- a/src/main/ipc/workspace-cleanup.test.ts +++ b/src/main/ipc/workspace-cleanup.test.ts @@ -3,7 +3,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ipcMain } from 'electron' import type { Store } from '../persistence' -import type { DiffComment, GitStatusResult, Repo } from '../../shared/types' +import type { + DiffComment, + GitStatusResult, + GitWorktreeInfo, + Repo, + WorktreeMeta +} from '../../shared/types' const { listRepoWorktreesMock, @@ -63,6 +69,29 @@ const REPO: Repo = { badgeColor: '#000', addedAt: NOW } +const LARGE_WORKTREE_COUNT = 150_000 + +function buildGitWorktrees(count: number): GitWorktreeInfo[] { + const worktrees: GitWorktreeInfo[] = [] + for (let index = 0; index < count; index += 1) { + worktrees.push({ + path: `/repo-feature-${index}`, + head: `abc${index}`, + branch: `refs/heads/feature-${index}`, + isBare: false, + isMainWorktree: false + }) + } + return worktrees +} + +function buildWorktreeIds(repoId: string, count: number): string[] { + const worktreeIds: string[] = [] + for (let index = 0; index < count; index += 1) { + worktreeIds.push(`${repoId}::/repo-feature-${index}`) + } + return worktreeIds +} function makeStore( options: { @@ -167,6 +196,48 @@ describe('workspace cleanup scan', () => { expect(result.candidates).toEqual([]) }) + it('uses direct metadata lookup for focused disconnected remote preflight', async () => { + const targetWorktreeId = 'repo-1::/remote/repo-feature' + const targetMeta: WorktreeMeta = { + displayName: 'Remote Feature', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: NOW - 2 * 24 * 60 * 60 * 1000 + } + const getWorktreeMeta = vi.fn((worktreeId: string) => + worktreeId === targetWorktreeId ? targetMeta : undefined + ) + const getAllWorktreeMeta = vi.fn(() => { + throw new Error('focused disconnected SSH preflight should not enumerate all metadata') + }) + const store = { + getRepos: () => [{ ...REPO, connectionId: 'ssh-1' }], + getWorktreeMeta, + getAllWorktreeMeta + } as unknown as Store + + const result = await scanWorkspaceCleanup(store, { worktreeId: targetWorktreeId }) + + expect(getWorktreeMeta).toHaveBeenCalledWith(targetWorktreeId) + expect(getAllWorktreeMeta).not.toHaveBeenCalled() + expect(result.errors).toEqual([]) + expect(result.candidates[0]).toMatchObject({ + worktreeId: targetWorktreeId, + path: '/remote/repo-feature', + blockers: ['ssh-disconnected'], + git: { + clean: null, + checkedAt: null + } + }) + }) + it('scans connected remote workspaces through the SSH git provider', async () => { const provider = { listWorktrees: vi.fn().mockResolvedValue([ @@ -344,6 +415,60 @@ describe('workspace cleanup scan', () => { }) }) + it('summarizes large diff-note lists without hitting argument limits', async () => { + const diffComments = Array.from( + { length: 150_000 }, + (_, index): DiffComment => ({ + id: `comment-${index}`, + worktreeId: 'repo-1::/repo-feature', + filePath: 'src/file.ts', + lineNumber: 12, + body: 'Follow up before deleting', + createdAt: NOW - index, + side: 'modified' + }) + ) + + const result = await scanWorkspaceCleanup( + makeStore({ + baseRef: undefined, + diffComments + }) + ) + + expect(result.candidates[0]?.localContext).toMatchObject({ + diffCommentCount: 150_000, + newestDiffCommentAt: NOW + }) + }) + + it('aggregates large cleanup candidate batches without hitting argument limits', async () => { + listRepoWorktreesMock.mockResolvedValue(buildGitWorktrees(LARGE_WORKTREE_COUNT)) + + const result = await scanWorkspaceCleanup(makeStore(), { + skipGitWorktreeIds: buildWorktreeIds(REPO.id, LARGE_WORKTREE_COUNT) + }) + + expect(getStatusMock).not.toHaveBeenCalled() + expect(result.errors).toEqual([]) + expect(result.candidates).toHaveLength(LARGE_WORKTREE_COUNT) + expect(result.candidates[0]).toMatchObject({ + worktreeId: 'repo-1::/repo-feature-0', + path: '/repo-feature-0', + branch: 'feature-0', + tier: 'review', + git: { + clean: null, + checkedAt: null + } + }) + expect(result.candidates[LARGE_WORKTREE_COUNT - 1]).toMatchObject({ + worktreeId: `repo-1::/repo-feature-${LARGE_WORKTREE_COUNT - 1}`, + path: `/repo-feature-${LARGE_WORKTREE_COUNT - 1}`, + branch: `feature-${LARGE_WORKTREE_COUNT - 1}` + }) + }) + it('does not expose PR cache state in inactivity cleanup results', async () => { const result = await scanWorkspaceCleanup( makeStore({ diff --git a/src/main/ipc/workspace-cleanup.ts b/src/main/ipc/workspace-cleanup.ts index 21c7e26819a..7a6bdbf2b66 100644 --- a/src/main/ipc/workspace-cleanup.ts +++ b/src/main/ipc/workspace-cleanup.ts @@ -11,7 +11,13 @@ import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { listRegisteredPtys } from '../memory/pty-registry' import { getSshPtyProvider } from './pty' import { isFolderRepo } from '../../shared/repo-kind' -import type { GitStatusResult, GitWorktreeInfo, Repo, Worktree } from '../../shared/types' +import type { + GitStatusResult, + GitWorktreeInfo, + Repo, + Worktree, + WorktreeMeta +} from '../../shared/types' import { mergeWorktree } from './worktree-logic' import { splitWorktreeId } from '../../shared/worktree-id' import { @@ -199,8 +205,8 @@ export async function scanWorkspaceCleanup( targetWorktreeId: args.worktreeId, skipGitWorktreeIds: new Set(args.skipGitWorktreeIds ?? []) }) - candidates.push(...result.candidates) - errors.push(...result.errors) + appendListItems(candidates, result.candidates) + appendListItems(errors, result.errors) } return { scannedAt, candidates, errors } @@ -327,7 +333,7 @@ async function buildCandidate(args: { const gitEvidence = !shouldReadGit ? createEmptyGitEvidence() : await readGitEvidence(worktree, repo, provider) - blockers.push(...gitEvidence.blockers) + appendListItems(blockers, gitEvidence.blockers) const candidateWithoutFingerprint: WorkspaceCleanupCandidate = { worktreeId: worktree.id, @@ -392,6 +398,14 @@ function shouldReadGitEvidence(args: { return true } +function appendListItems(target: T[], entries: readonly T[]): void { + // Why: cleanup can aggregate generated-size worktree batches; spreading + // those batches into push can exceed JavaScript's argument limit. + for (const entry of entries) { + target.push(entry) + } +} + async function readGitEvidence( worktree: Worktree, repo: Repo, @@ -515,56 +529,76 @@ function synthesizeDisconnectedSshCandidates( scannedAt: number, targetWorktreeId?: string ): WorkspaceCleanupCandidate[] { - return Object.entries(store.getAllWorktreeMeta()) - .filter(([worktreeId, meta]) => { - if (!worktreeId.startsWith(`${repo.id}::`)) { - return false - } - if (targetWorktreeId) { - return targetWorktreeId === worktreeId - } - return isWorkspaceInactiveForCleanup(meta, scannedAt) - }) - .map(([worktreeId, meta]) => { - const parsed = splitWorktreeId(worktreeId) - const path = parsed?.worktreePath ?? worktreeId - const reasons = getInactivityReasons(meta, scannedAt) - return applyWorkspaceCleanupPolicy({ - worktreeId, - repoId: repo.id, - repoName: repo.displayName, - connectionId: repo.connectionId ?? null, - displayName: meta.displayName || basename(path), - branch: basename(path), - path, - tier: 'protected', - selectedByDefault: false, - reasons, - blockers: ['ssh-disconnected'], - lastActivityAt: meta.lastActivityAt, - ...(meta.createdAt !== undefined ? { createdAt: meta.createdAt } : {}), - localContext: { - terminalTabCount: 0, - cleanEditorTabCount: 0, - browserTabCount: 0, - diffCommentCount: meta.diffComments?.length ?? 0, - newestDiffCommentAt: getNewestDiffCommentAt(meta.diffComments), - retainedDoneAgentCount: 0 - }, - git: { - clean: null, - upstreamAhead: null, - upstreamBehind: null, - checkedAt: null - }, - fingerprint: createWorkspaceCleanupFingerprint({ - branch: basename(path), - head: '', - gitClean: null, - lastActivityAt: meta.lastActivityAt - }) - }) + const repoWorktreePrefix = `${repo.id}::` + if (targetWorktreeId) { + if (!targetWorktreeId.startsWith(repoWorktreePrefix)) { + return [] + } + // Why: focused delete preflight names one workspace already; walking all + // persisted metadata is unnecessary for disconnected SSH repos. + const meta = store.getWorktreeMeta(targetWorktreeId) + return meta ? [createDisconnectedSshCandidate(repo, scannedAt, targetWorktreeId, meta)] : [] + } + + const candidates: WorkspaceCleanupCandidate[] = [] + const allMeta = store.getAllWorktreeMeta() + for (const worktreeId in allMeta) { + if (!Object.hasOwn(allMeta, worktreeId) || !worktreeId.startsWith(repoWorktreePrefix)) { + continue + } + const meta = allMeta[worktreeId] + if (!meta || !isWorkspaceInactiveForCleanup(meta, scannedAt)) { + continue + } + candidates.push(createDisconnectedSshCandidate(repo, scannedAt, worktreeId, meta)) + } + return candidates +} + +function createDisconnectedSshCandidate( + repo: Repo, + scannedAt: number, + worktreeId: string, + meta: WorktreeMeta +): WorkspaceCleanupCandidate { + const parsed = splitWorktreeId(worktreeId) + const path = parsed?.worktreePath ?? worktreeId + const reasons = getInactivityReasons(meta, scannedAt) + return applyWorkspaceCleanupPolicy({ + worktreeId, + repoId: repo.id, + repoName: repo.displayName, + connectionId: repo.connectionId ?? null, + displayName: meta.displayName || basename(path), + branch: basename(path), + path, + tier: 'protected', + selectedByDefault: false, + reasons, + blockers: ['ssh-disconnected'], + lastActivityAt: meta.lastActivityAt, + ...(meta.createdAt !== undefined ? { createdAt: meta.createdAt } : {}), + localContext: { + terminalTabCount: 0, + cleanEditorTabCount: 0, + browserTabCount: 0, + diffCommentCount: meta.diffComments?.length ?? 0, + newestDiffCommentAt: getNewestDiffCommentAt(meta.diffComments), + retainedDoneAgentCount: 0 + }, + git: { + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: null + }, + fingerprint: createWorkspaceCleanupFingerprint({ + branch: basename(path), + head: '', + gitClean: null, + lastActivityAt: meta.lastActivityAt }) + }) } function buildLocalContext(worktree: Worktree): WorkspaceCleanupCandidate['localContext'] { @@ -582,7 +616,16 @@ function getNewestDiffCommentAt(diffComments: Worktree['diffComments'] | undefin if (!diffComments || diffComments.length === 0) { return null } - return Math.max(...diffComments.map((comment) => comment.createdAt)) + // Why: persisted diff notes can grow large enough for spread-based Math.max + // to exceed the JavaScript argument limit during cleanup scans. + let newest = diffComments[0]?.createdAt ?? null + for (let index = 1; index < diffComments.length; index += 1) { + const createdAt = diffComments[index]?.createdAt + if (createdAt !== undefined && (newest === null || createdAt > newest)) { + newest = createdAt + } + } + return newest } function createEmptyGitEvidence(): GitEvidence { diff --git a/src/main/ipc/workspace-ports.test.ts b/src/main/ipc/workspace-ports.test.ts index ce66b79b8b6..841c046092f 100644 --- a/src/main/ipc/workspace-ports.test.ts +++ b/src/main/ipc/workspace-ports.test.ts @@ -2,18 +2,22 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { WorkspacePort, WorkspacePortScanResult } from '../../shared/workspace-ports' const handlers = new Map Promise | unknown>() -const { handleMock, scanWorkspacePortsMock, processKillMock } = vi.hoisted(() => ({ - handleMock: vi.fn(), - scanWorkspacePortsMock: vi.fn(), - processKillMock: vi.fn() -})) +const { handleMock, removeHandlerMock, scanWorkspacePortsMock, processKillMock } = vi.hoisted( + () => ({ + handleMock: vi.fn(), + removeHandlerMock: vi.fn(), + scanWorkspacePortsMock: vi.fn(), + processKillMock: vi.fn() + }) +) vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: vi.fn(() => []) }, ipcMain: { - handle: handleMock + handle: handleMock, + removeHandler: removeHandlerMock } })) @@ -68,6 +72,7 @@ describe('registerWorkspacePortHandlers', () => { beforeEach(() => { handlers.clear() handleMock.mockReset() + removeHandlerMock.mockReset() scanWorkspacePortsMock.mockReset() handleMock.mockImplementation((channel, handler) => { handlers.set(channel, handler) @@ -77,6 +82,14 @@ describe('registerWorkspacePortHandlers', () => { vi.spyOn(process, 'kill').mockImplementation(processKillMock) }) + it('removes existing IPC handlers before registering new ones', () => { + const store = makeStore() + registerWorkspacePortHandlers(store as never) + + expect(removeHandlerMock).toHaveBeenCalledWith('workspacePorts:scan') + expect(removeHandlerMock).toHaveBeenCalledWith('workspacePorts:kill') + }) + it('derives local worktree probes from the main store instead of renderer input', async () => { const store = makeStore() registerWorkspacePortHandlers(store as never) diff --git a/src/main/ipc/workspace-ports.ts b/src/main/ipc/workspace-ports.ts index 27d1df254d7..38af074ee9d 100644 --- a/src/main/ipc/workspace-ports.ts +++ b/src/main/ipc/workspace-ports.ts @@ -38,6 +38,8 @@ export function registerWorkspacePortHandlers( broadcastWorkspacePortAdvertisedUrlChanged(getWindows, event) }) + ipcMain.removeHandler('workspacePorts:scan') + ipcMain.removeHandler('workspacePorts:kill') ipcMain.handle( 'workspacePorts:scan', (_event, rawArgs?: unknown): Promise => { diff --git a/src/main/ipc/worktree-hooks.ts b/src/main/ipc/worktree-hooks.ts deleted file mode 100644 index 91eeb45e5fb..00000000000 --- a/src/main/ipc/worktree-hooks.ts +++ /dev/null @@ -1,131 +0,0 @@ -// Why: extracted from worktrees.ts to keep the main IPC module under the -// max-lines threshold. Hooks IPC handlers (check, readIssueCommand, -// writeIssueCommand) are self-contained and don't interact with worktree -// creation or removal state. - -import { ipcMain } from 'electron' -import { join } from 'path' -import type { Store } from '../persistence' -import { isFolderRepo } from '../../shared/repo-kind' -import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' -import { - hasHooksFile, - hasUnrecognizedOrcaYamlKeys, - loadHooks, - readIssueCommand, - writeIssueCommand -} from '../hooks' - -export function registerHooksHandlers(store: Store): void { - ipcMain.removeHandler('hooks:check') - ipcMain.removeHandler('hooks:readIssueCommand') - ipcMain.removeHandler('hooks:writeIssueCommand') - - ipcMain.handle('hooks:check', async (_event, args: { repoId: string }) => { - const repo = store.getRepo(args.repoId) - if (!repo || isFolderRepo(repo)) { - return { hasHooks: false, hooks: null, mayNeedUpdate: false } - } - - // Why: remote repos read orca.yaml via the SSH filesystem provider. - // Parsing happens in the main process since it's CPU-cheap and avoids - // adding YAML parsing to the relay. - if (repo.connectionId) { - const fsProvider = getSshFilesystemProvider(repo.connectionId) - if (!fsProvider) { - return { hasHooks: false, hooks: null, mayNeedUpdate: false } - } - try { - const result = await fsProvider.readFile(join(repo.path, '.orca.yaml')) - if (result.isBinary) { - return { hasHooks: false, hooks: null, mayNeedUpdate: false } - } - const { parse } = await import('yaml') - const parsed = parse(result.content) - return { hasHooks: true, hooks: parsed, mayNeedUpdate: false } - } catch { - return { hasHooks: false, hooks: null, mayNeedUpdate: false } - } - } - - const has = hasHooksFile(repo.path) - const hooks = has ? loadHooks(repo.path) : null - // Why: when a newer Orca version adds a top-level key to `orca.yaml`, older - // versions that don't recognise it return null and show "could not be parsed". - // Detecting well-formed but unrecognised keys lets the UI suggest updating - // instead of implying the file is broken. - const mayNeedUpdate = has && !hooks && hasUnrecognizedOrcaYamlKeys(repo.path) - return { - hasHooks: has, - hooks, - mayNeedUpdate - } - }) - - ipcMain.handle('hooks:readIssueCommand', async (_event, args: { repoId: string }) => { - const repo = store.getRepo(args.repoId) - if (!repo || isFolderRepo(repo)) { - return { - localContent: null, - sharedContent: null, - effectiveContent: null, - localFilePath: '', - source: 'none' as const - } - } - - if (repo.connectionId) { - const fsProvider = getSshFilesystemProvider(repo.connectionId) - if (!fsProvider) { - return { - localContent: null, - sharedContent: null, - effectiveContent: null, - localFilePath: '', - source: 'none' as const - } - } - try { - const result = await fsProvider.readFile(join(repo.path, '.orca', 'issue-command')) - return { - localContent: result.isBinary ? null : result.content, - sharedContent: null, - effectiveContent: result.isBinary ? null : result.content, - localFilePath: join(repo.path, '.orca', 'issue-command'), - source: 'local' as const - } - } catch { - return { - localContent: null, - sharedContent: null, - effectiveContent: null, - localFilePath: '', - source: 'none' as const - } - } - } - - return readIssueCommand(repo.path) - }) - - ipcMain.handle( - 'hooks:writeIssueCommand', - async (_event, args: { repoId: string; content: string }) => { - const repo = store.getRepo(args.repoId) - if (!repo || isFolderRepo(repo)) { - return - } - - if (repo.connectionId) { - const fsProvider = getSshFilesystemProvider(repo.connectionId) - if (!fsProvider) { - return - } - await fsProvider.writeFile(join(repo.path, '.orca', 'issue-command'), args.content) - return - } - - writeIssueCommand(repo.path, args.content) - } - ) -} diff --git a/src/main/ipc/worktree-logic-wsl.test.ts b/src/main/ipc/worktree-logic-wsl.test.ts index 12c2f51212a..d3c9eae4de4 100644 --- a/src/main/ipc/worktree-logic-wsl.test.ts +++ b/src/main/ipc/worktree-logic-wsl.test.ts @@ -48,4 +48,19 @@ describe('computeWorktreePath WSL layout', () => { }) ).toBe(win32.join('C:\\workspaces', 'feature')) }) + + it('uses an explicit WSL UNC workspace root without remapping it', () => { + parseWslPathMock.mockReturnValue({ + distro: 'Ubuntu', + linuxPath: '/home/jin/src/repo' + }) + getWslHomeMock.mockReturnValue('\\\\wsl.localhost\\Ubuntu\\home\\jin') + + expect( + computeWorktreePath('feature', '\\\\wsl.localhost\\Ubuntu\\home\\jin\\src\\repo', { + nestWorkspaces: false, + workspaceDir: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\custom-worktrees' + }) + ).toBe('\\\\wsl.localhost\\Ubuntu\\home\\jin\\custom-worktrees\\feature') + }) }) diff --git a/src/main/ipc/worktree-logic.test.ts b/src/main/ipc/worktree-logic.test.ts index 73231eab8fa..14c33599f02 100644 --- a/src/main/ipc/worktree-logic.test.ts +++ b/src/main/ipc/worktree-logic.test.ts @@ -9,6 +9,10 @@ import { ensurePathWithinWorkspace, computeBranchName, computeWorktreePath, + computeRemoteWorktreePath, + computeWorkspaceRoot, + getWorktreeCreationLayout, + getWorktreePathSettings, shouldSetDisplayName, mergeWorktree, parseWorktreeId, @@ -111,6 +115,12 @@ describe('ensurePathWithinWorkspace', () => { 'Invalid worktree path' ) }) + + it('allows workspace children whose names start with dot-dot text', () => { + const result = ensurePathWithinWorkspace('/workspace/..repo/feature', '/workspace') + + expect(result).toBe(resolve('/workspace/..repo/feature')) + }) }) describe('computeBranchName', () => { @@ -168,6 +178,78 @@ describe('computeWorktreePath', () => { }) ).toBe(join('/workspaces', 'my-project', 'feature')) }) + + it('resolves relative workspace directories from the repo path', () => { + expect(computeWorkspaceRoot('/projects/app/repo', { workspaceDir: '../worktrees' })).toBe( + resolve('/projects/app/worktrees') + ) + expect( + computeWorktreePath('feature', '/projects/app/repo', { + nestWorkspaces: false, + workspaceDir: '../worktrees' + }) + ).toBe(resolve('/projects/app/worktrees/feature')) + }) + + it('scopes the same relative repo override to each repo root', () => { + const settings = { nestWorkspaces: false, workspaceDir: '/global/workspaces' } + const repoA = { path: '/projects/a/repo', worktreeBasePath: '../worktrees' } + const repoB = { path: '/projects/b/repo', worktreeBasePath: '../worktrees' } + + expect( + computeWorktreePath('feature', repoA.path, getWorktreePathSettings(repoA, settings)) + ).toBe(resolve('/projects/a/worktrees/feature')) + expect( + computeWorktreePath('feature', repoB.path, getWorktreePathSettings(repoB, settings)) + ).toBe(resolve('/projects/b/worktrees/feature')) + expect(getWorktreeCreationLayout(repoA, settings)).toEqual({ + path: '../worktrees', + nestWorkspaces: false + }) + }) + + it('resolves Windows-style relative workspace directories with Windows separators', () => { + expect( + computeWorktreePath('feature', 'C:\\Projects\\app\\repo', { + nestWorkspaces: false, + workspaceDir: '..\\worktrees' + }) + ).toBe('C:\\Projects\\app\\worktrees\\feature') + }) + + it('keeps legacy SSH sibling paths for global absolute workspace directories', () => { + expect( + computeRemoteWorktreePath('feature', '/remote/repo', { + nestWorkspaces: false, + workspaceDir: '/local/workspaces' + }) + ).toBe('/remote/feature') + }) + + it('applies repo-specific SSH workspace directories on the remote path', () => { + expect( + computeRemoteWorktreePath( + 'feature', + '/remote/project/repo', + { + nestWorkspaces: false, + workspaceDir: '../worktrees' + }, + { useConfiguredAbsolutePath: true } + ) + ).toBe('/remote/project/worktrees/feature') + expect( + computeRemoteWorktreePath( + 'feature', + 'C:\\Remote\\repo', + { + nestWorkspaces: false, + workspaceDir: '..\\worktrees' + }, + { useConfiguredAbsolutePath: true } + ) + ).toBe('C:\\Remote\\worktrees\\feature') + }) }) describe('areWorktreePathsEqual', () => { @@ -184,6 +266,16 @@ describe('areWorktreePathsEqual', () => { it('keeps POSIX path comparison case-sensitive', () => { expect(areWorktreePathsEqual('/tmp/Worktree', '/tmp/worktree', 'linux')).toBe(false) }) + + it('treats macOS /private/tmp git paths as matching /tmp workspace paths', () => { + expect( + areWorktreePathsEqual( + '/private/tmp/orca-proof/worktrees/repo/feature', + '/tmp/orca-proof/worktrees/repo/feature', + 'darwin' + ) + ).toBe(true) + }) }) describe('shouldSetDisplayName', () => { diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index e94dfd6a495..13e6038280a 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -1,9 +1,21 @@ -import { basename, join, resolve, relative, isAbsolute, posix, win32 } from 'path' -import type { GitWorktreeInfo, Worktree, WorktreeMeta } from '../../shared/types' +import { basename, resolve, relative, isAbsolute, posix, sep, win32 } from 'path' +import type { + GitWorktreeInfo, + GlobalSettings, + OrcaWorkspaceLayout, + Repo, + Worktree, + WorktreeMeta +} from '../../shared/types' +import { resolveRuntimePath } from '../../shared/cross-platform-path' +import { isWslUncPath } from '../../shared/wsl-paths' import { splitWorktreeId } from '../../shared/worktree-id' import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' import { getWslHome, parseWslPath } from '../wsl' +type WorktreePathSettings = Pick +type WorktreeBasePathRepo = Pick + /** * Sanitize a worktree name for use in branch names and directory paths. * Strips unsafe characters and collapses runs of special chars to a single hyphen. @@ -57,7 +69,7 @@ export function ensurePathWithinWorkspace(targetPath: string, workspaceDir: stri const resolvedTargetPath = resolve(targetPath) const rel = relative(resolvedWorkspaceDir, resolvedTargetPath) - if (isAbsolute(rel) || rel.startsWith('..')) { + if (isAbsolute(rel) || rel === '..' || rel.startsWith(`..${sep}`)) { throw new Error('Invalid worktree path') } @@ -95,37 +107,73 @@ export function computeBranchName( export function computeWorktreePath( sanitizedName: string, repoPath: string, - settings: { nestWorkspaces: boolean; workspaceDir: string } + settings: WorktreePathSettings ): string { - const pathOps = - looksLikeWindowsPath(repoPath) || looksLikeWindowsPath(settings.workspaceDir) - ? win32 - : { basename, join } - - const wsl = parseWslPath(repoPath) - if (wsl) { - const wslHome = getWslHome(wsl.distro) - if (wslHome) { - // Why: WSL UNC paths are still Windows paths from Node's perspective. - // On Linux CI, the default path helpers use POSIX semantics and would - // treat `\\wsl.localhost\...` as a plain string, producing mixed-separator - // paths like `\\wsl.localhost\Ubuntu\home\jin/orca/...`. Use win32 path - // operations whenever a Windows/UNC path is involved so behavior matches - // the Windows production runtime. - const wslWorkspaceDir = win32.join(wslHome, 'orca', 'workspaces') - if (settings.nestWorkspaces) { - const repoName = win32.basename(repoPath).replace(/\.git$/, '') - return win32.join(wslWorkspaceDir, repoName, sanitizedName) - } - return win32.join(wslWorkspaceDir, sanitizedName) - } - } + const workspaceRoot = computeWorkspaceRoot(repoPath, settings) + const pathOps = getRuntimePathOps(repoPath, workspaceRoot) if (settings.nestWorkspaces) { const repoName = pathOps.basename(repoPath).replace(/\.git$/, '') - return pathOps.join(settings.workspaceDir, repoName, sanitizedName) + return pathOps.join(workspaceRoot, repoName, sanitizedName) } - return pathOps.join(settings.workspaceDir, sanitizedName) + return pathOps.join(workspaceRoot, sanitizedName) +} + +export function computeWorkspaceRoot(repoPath: string, settings: { workspaceDir: string }): string { + const wsl = parseWslPath(repoPath) + if (wsl && shouldMirrorWorkspaceDirInsideWsl(repoPath, settings.workspaceDir)) { + const wslHome = getWslHome(wsl.distro) + if (wslHome) { + // Why: WSL UNC paths are still Windows paths from Node's perspective. + // Mirror absolute local desktop workspace roots inside the distro so + // terminals stay on the WSL filesystem; repo-relative roots can resolve + // directly against the WSL repo path. + return win32.join(wslHome, 'orca', 'workspaces') + } + } + return resolveWorkspaceDirForRepo(repoPath, settings.workspaceDir) +} + +export function computeRemoteWorktreePath( + sanitizedName: string, + repoPath: string, + settings: WorktreePathSettings, + options: { useConfiguredAbsolutePath?: boolean } = {} +): string { + if ( + options.useConfiguredAbsolutePath || + isWorkspaceDirRelativeToRepo(repoPath, settings.workspaceDir) + ) { + return computeWorktreePath(sanitizedName, repoPath, settings) + } + // Why: absolute global workspaceDir values belong to the desktop machine. + // SSH worktrees keep the legacy repo-sibling root unless a repo-specific + // path opts into a remote-host location. + return getRuntimePathOps(repoPath, repoPath).join(repoPath, '..', sanitizedName) +} + +export function getWorktreePathSettings( + repo: WorktreeBasePathRepo, + settings: WorktreePathSettings +): WorktreePathSettings { + return { + nestWorkspaces: settings.nestWorkspaces, + workspaceDir: getEffectiveWorktreeBasePath(repo, settings) + } +} + +export function getWorktreeCreationLayout( + repo: WorktreeBasePathRepo, + settings: WorktreePathSettings +): OrcaWorkspaceLayout { + return { + path: getEffectiveWorktreeBasePath(repo, settings), + nestWorkspaces: settings.nestWorkspaces + } +} + +export function hasRepoWorktreeBasePath(repo: Pick): boolean { + return getRepoWorktreeBasePath(repo) !== undefined } export function areWorktreePathsEqual( @@ -142,13 +190,66 @@ export function areWorktreePathsEqual( // create spuriously fails until the next full reload repopulates state. return left.toLowerCase() === right.toLowerCase() } - const left = posix.normalize(posix.resolve(leftPath)) - const right = posix.normalize(posix.resolve(rightPath)) + const left = normalizePosixWorktreePathForComparison(leftPath, platform) + const right = normalizePosixWorktreePathForComparison(rightPath, platform) return left === right } function looksLikeWindowsPath(pathValue: string): boolean { - return /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\') + return ( + /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\') || pathValue.startsWith('//') + ) +} + +function normalizePosixWorktreePathForComparison( + pathValue: string, + platform: NodeJS.Platform +): string { + const normalized = posix.normalize(posix.resolve(pathValue)) + if (platform !== 'darwin') { + return normalized + } + if (normalized === '/private/tmp') { + return '/tmp' + } + return normalized.startsWith('/private/tmp/') ? normalized.slice('/private'.length) : normalized +} + +function getRuntimePathOps( + repoPath: string, + workspaceDir: string +): Pick { + return looksLikeWindowsPath(repoPath) || looksLikeWindowsPath(workspaceDir) ? win32 : posix +} + +function resolveWorkspaceDirForRepo(repoPath: string, workspaceDir: string): string { + const pathOps = getRuntimePathOps(repoPath, workspaceDir) + return pathOps.isAbsolute(workspaceDir) + ? pathOps.normalize(workspaceDir) + : resolveRuntimePath(repoPath, workspaceDir) +} + +function isWorkspaceDirRelativeToRepo(repoPath: string, workspaceDir: string): boolean { + return !getRuntimePathOps(repoPath, workspaceDir).isAbsolute(workspaceDir) +} + +function getEffectiveWorktreeBasePath( + repo: WorktreeBasePathRepo, + settings: WorktreePathSettings +): string { + return getRepoWorktreeBasePath(repo) ?? settings.workspaceDir +} + +function getRepoWorktreeBasePath(repo: Pick): string | undefined { + const trimmed = repo.worktreeBasePath?.trim() + return trimmed || undefined +} + +function shouldMirrorWorkspaceDirInsideWsl(repoPath: string, workspaceDir: string): boolean { + if (isWorkspaceDirRelativeToRepo(repoPath, workspaceDir)) { + return false + } + return !isWslUncPath(workspaceDir) } /** diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index fd63dd4d92e..cfb11b5a301 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -8,7 +8,7 @@ // cohesive flow would split awkwardly. import type { BrowserWindow } from 'electron' -import { join, posix, win32 } from 'path' +import { posix, win32 } from 'path' import { existsSync } from 'fs' import { randomUUID } from 'crypto' import type { Store } from '../persistence' @@ -30,14 +30,15 @@ import { gitExecFileAsync } from '../git/runner' import { parseGitHubOwnerRepo } from '../github/gh-utils' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import type { RemoteFetchResult, RemoteTrackingBase } from '../runtime/orca-runtime' -import { isWslPath, parseWslPath, getWslHome } from '../wsl' import { buildPosixRunnerScript, buildWindowsRunnerScript, createSetupRunnerScript, + getDefaultTabsLaunch, getEffectiveHooks, getEffectiveHooksFromConfig, getSetupRunnerEnvVars, + loadHooks, parseOrcaYaml, shouldRunSetupForCreate } from '../hooks' @@ -53,13 +54,18 @@ import { sanitizeWorktreeDisplayName, computeBranchName, computeWorktreePath, + computeRemoteWorktreePath, + computeWorkspaceRoot, ensurePathWithinWorkspace, + getWorktreeCreationLayout, + getWorktreePathSettings, + hasRepoWorktreeBasePath, shouldSetDisplayName, mergeWorktree, areWorktreePathsEqual } from './worktree-logic' import { getRepoIdFromWorktreeId } from '../../shared/worktree-id' -import { invalidateAuthorizedRootsCache } from './filesystem-auth' +import { invalidateAuthorizedRootsCache, isENOENT } from './filesystem-auth' import { createWorktreeSymlinks } from './worktree-symlinks' import { normalizeSparseDirectories } from './sparse-checkout-directories' import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths' @@ -160,19 +166,34 @@ async function canCheckoutExistingLocalBranch( branchName: string, baseBranch: string ): Promise { - if (normalizeLocalBranchName(baseBranch) !== branchName) { - return false - } + let localHead = '' try { - await gitExecFileAsync( + const { stdout } = await gitExecFileAsync( ['rev-parse', '--verify', '--quiet', `refs/heads/${branchName}^{commit}`], { cwd: repoPath } ) + localHead = stdout.trim() } catch { return false } + if (normalizeLocalBranchName(baseBranch) !== branchName) { + if (!localHead) { + return false + } + try { + const { stdout } = await gitExecFileAsync( + ['rev-parse', '--verify', '--quiet', `${baseBranch}^{commit}`], + { cwd: repoPath } + ) + if (stdout.trim() !== localHead) { + return false + } + } catch { + return false + } + } const worktrees = await listWorktrees(repoPath) return !worktrees.some((worktree) => normalizeLocalBranchName(worktree.branch) === branchName) } @@ -183,21 +204,155 @@ async function canCheckoutExistingLocalBranchSsh( branchName: string, baseBranch: string ): Promise { - if (normalizeLocalBranchName(baseBranch) !== branchName) { - return false - } + let localHead = '' try { - await provider.exec( + const { stdout } = await provider.exec( ['rev-parse', '--verify', '--quiet', `refs/heads/${branchName}^{commit}`], repoPath ) + localHead = stdout.trim() } catch { return false } + if (normalizeLocalBranchName(baseBranch) !== branchName) { + if (!localHead) { + return false + } + try { + const { stdout } = await provider.exec( + ['rev-parse', '--verify', '--quiet', `${baseBranch}^{commit}`], + repoPath + ) + if (stdout.trim() !== localHead) { + return false + } + } catch { + return false + } + } const worktrees = await provider.listWorktrees(repoPath) return !worktrees.some((worktree) => normalizeLocalBranchName(worktree.branch) === branchName) } +async function listSshRemoteNames(provider: SshGitProvider, repoPath: string): Promise { + try { + const { stdout } = await provider.exec(['remote'], repoPath) + return stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .sort((a, b) => b.length - a.length) + } catch { + return [] + } +} + +function isAllowedSshRemoteBaseRef(refName: string, allowedBaseRef: string): boolean { + if (!allowedBaseRef) { + return false + } + const normalizedAllowedRef = allowedBaseRef.startsWith('refs/remotes/') + ? allowedBaseRef + : `refs/remotes/${allowedBaseRef}` + return refName === normalizedAllowedRef +} + +function resolveSshRemoteBranchName(refName: string, remoteNames: string[]): string { + const remotePrefix = 'refs/remotes/' + if (!refName.startsWith(remotePrefix)) { + return refName + } + const remoteAndBranch = refName.slice(remotePrefix.length) + const remote = remoteNames.find((candidate) => remoteAndBranch.startsWith(`${candidate}/`)) + if (remote) { + return remoteAndBranch.slice(remote.length + 1) + } + return remoteAndBranch.split('/').slice(1).join('/') || remoteAndBranch +} + +async function hasSshRemoteBranchConflict( + provider: SshGitProvider, + repoPath: string, + branchName: string, + allowedBaseRef: string +): Promise { + const remoteNames = await listSshRemoteNames(provider, repoPath) + try { + const { stdout } = await provider.exec( + ['for-each-ref', '--format=%(refname)', 'refs/remotes'], + repoPath + ) + return stdout.split(/\r?\n/).some((line) => { + const refName = line.trim() + if (!refName || /^refs\/remotes\/.+\/HEAD$/.test(refName)) { + return false + } + if (isAllowedSshRemoteBaseRef(refName, allowedBaseRef)) { + return false + } + // Why: `git branch --all --list feature/x` does not match + // `remotes/origin/feature/x`; parse remote refs directly instead. + return resolveSshRemoteBranchName(refName, remoteNames) === branchName + }) + } catch { + return false + } +} + +type SelectedPrBranchInput = Pick< + CreateWorktreeArgs, + 'branchNameOverride' | 'linkedPR' | 'pushTarget' +> + +function isSelectedGitHubPrBranchOverride( + args: SelectedPrBranchInput, + branchName: string +): boolean { + return typeof args.linkedPR === 'number' && args.branchNameOverride === branchName +} + +function isMatchingSelectedGitHubPr( + existingPR: Awaited>, + args: SelectedPrBranchInput, + branchName: string +): boolean { + return Boolean( + existingPR && + isSelectedGitHubPrBranchOverride(args, branchName) && + existingPR.number === args.linkedPR + ) +} + +function isAllowedPushTargetRemoteConflict( + conflictKind: 'local' | 'remote' | null, + branchName: string, + args: SelectedPrBranchInput +): boolean { + return ( + conflictKind === 'remote' && + isSelectedGitHubPrBranchOverride(args, branchName) && + args.pushTarget?.branchName === branchName + ) +} + +async function remotePathExists( + fsProvider: IFilesystemProvider | null | undefined, + pathValue: string +): Promise { + if (!fsProvider) { + return false + } + try { + await fsProvider.stat(pathValue) + return true + } catch (error) { + if (isENOENT(error)) { + return false + } + throw error + } +} + async function ensureUniqueRemoteName(repoPath: string, preferred: string): Promise { const { stdout } = await gitExecFileAsync(['remote'], { cwd: repoPath }) const existing = new Set( @@ -566,12 +721,18 @@ async function readRemoteEffectiveHooks( fsProvider: IFilesystemProvider, hooksRootPath: string ): Promise> { + return getEffectiveHooksFromConfig(repo, await readRemoteOrcaYaml(fsProvider, hooksRootPath)) +} + +async function readRemoteOrcaYaml( + fsProvider: IFilesystemProvider, + hooksRootPath: string +): Promise> { try { const result = await fsProvider.readFile(joinWorktreeRelativePath(hooksRootPath, 'orca.yaml')) - const yamlHooks = result.isBinary ? null : parseOrcaYaml(result.content) - return getEffectiveHooksFromConfig(repo, yamlHooks) + return result.isBinary ? null : parseOrcaYaml(result.content) } catch { - return getEffectiveHooksFromConfig(repo, null) + return null } } @@ -714,10 +875,13 @@ export async function createRemoteWorktree( mainWindow: BrowserWindow ): Promise { const provider = requireSshGitProvider(repo.connectionId!) + const fsProvider = getSshFilesystemProvider(repo.connectionId!) const settings = store.getSettings() - const requestedName = args.name + const worktreePathSettings = getWorktreePathSettings(repo, settings) + let effectiveRequestedName = args.name const sanitizedName = sanitizeWorktreeName(args.name) + let effectiveSanitizedName = sanitizedName const requestedDisplayName = args.displayName ? sanitizeWorktreeDisplayName(args.displayName) : undefined @@ -735,8 +899,9 @@ export async function createRemoteWorktree( username ) - // Compute worktree path relative to the repo's parent on the remote - const remotePath = `${repo.path}/../${sanitizedName}` + let remotePath = computeRemoteWorktreePath(sanitizedName, repo.path, worktreePathSettings, { + useConfiguredAbsolutePath: hasRepoWorktreeBasePath(repo) + }) // Determine base branch // Why: previously fell back to a hardcoded 'origin/main' when @@ -769,19 +934,41 @@ export async function createRemoteWorktree( baseBranch ) if (!checkoutExistingBranch) { - // Check branch conflict on remote - try { - const { stdout } = await provider.exec(['branch', '--list', '--all', branchName], repo.path) - if (stdout.trim()) { - throw new Error(`Branch "${branchName}" already exists. Pick a different worktree name.`) - } - } catch (e) { - if (e instanceof Error && e.message.includes('already exists')) { - throw e - } + if (await hasSshRemoteBranchConflict(provider, repo.path, branchName, baseBranch)) { + throw new Error( + `Branch "${branchName}" already exists on a remote. Pick a different worktree name.` + ) } } + let remotePathResolved = !args.branchNameOverride + for (let suffix = 1; args.branchNameOverride && suffix < 100; suffix += 1) { + effectiveSanitizedName = suffix === 1 ? sanitizedName : `${sanitizedName}-${suffix}` + effectiveRequestedName = + suffix === 1 + ? args.name + : args.name.trim() + ? `${args.name}-${suffix}` + : effectiveSanitizedName + remotePath = computeRemoteWorktreePath( + effectiveSanitizedName, + repo.path, + worktreePathSettings, + { + useConfiguredAbsolutePath: hasRepoWorktreeBasePath(repo) + } + ) + if (!(await remotePathExists(fsProvider, remotePath))) { + remotePathResolved = true + break + } + } + if (!remotePathResolved) { + throw new Error( + `Could not find an available remote worktree path for "${sanitizedName}". Pick a different worktree name.` + ) + } + const sparseDirectories = args.sparseCheckout ? normalizeSparseDirectories(args.sparseCheckout.directories) : [] @@ -837,7 +1024,6 @@ export async function createRemoteWorktree( ? await refreshLocalBaseRefForRemoteWorktreeCreate(provider, repo.path, remoteTrackingBase) : undefined - const fsProvider = getSshFilesystemProvider(repo.connectionId!) if (fsProvider) { const primaryHooks = await readRemoteEffectiveHooks(repo, fsProvider, repo.path) if (primaryHooks?.scripts.setup) { @@ -937,7 +1123,7 @@ export async function createRemoteWorktree( // Re-list to get the created worktree info const gitWorktrees = await provider.listWorktrees(repo.path) const created = gitWorktrees.find( - (gw) => gw.branch?.endsWith(branchName) || gw.path.endsWith(sanitizedName) + (gw) => gw.branch?.endsWith(branchName) || gw.path.endsWith(effectiveSanitizedName) ) if (!created) { throw new Error('Worktree created but not found in listing') @@ -969,17 +1155,14 @@ export async function createRemoteWorktree( createdAt: now, orcaCreatedAt: now, orcaCreationSource: 'ssh', - orcaCreationWorkspaceLayout: { - path: settings.workspaceDir, - nestWorkspaces: settings.nestWorkspaces - }, + orcaCreationWorkspaceLayout: getWorktreeCreationLayout(repo, settings), baseRef: baseBranch, ...(checkoutExistingBranch ? { preserveBranchOnDelete: true } : {}), ...(configuredPushTarget ? { pushTarget: configuredPushTarget } : {}), ...(requestedDisplayName ? { displayName: requestedDisplayName } - : shouldSetDisplayName(requestedName, branchName, sanitizedName) - ? { displayName: requestedName } + : shouldSetDisplayName(effectiveRequestedName, branchName, effectiveSanitizedName) + ? { displayName: effectiveRequestedName } : {}), ...(isTuiAgent(args.createdWithAgent) ? { createdWithAgent: args.createdWithAgent } : {}), ...(sparseDirectories.length > 0 @@ -1007,8 +1190,20 @@ export async function createRemoteWorktree( // `symlinkPaths` configured have them silently ignored here. let setup: CreateWorktreeResult['setup'] + let defaultTabs: CreateWorktreeResult['defaultTabs'] if (fsProvider) { - const hooks = await readRemoteEffectiveHooks(repo, fsProvider, created.path) + const yamlHooks = await readRemoteOrcaYaml(fsProvider, created.path) + const hooks = getEffectiveHooksFromConfig(repo, yamlHooks) + try { + defaultTabs = getDefaultTabsLaunch(yamlHooks, repo, args.setupDecision) + } catch (error) { + // Why: default tab commands share setup's run policy. If the target branch + // adds commands without a renderer decision, create the tabs but don't run them. + console.warn(`[hooks] default tab commands skipped for ${created.path}:`, error) + defaultTabs = yamlHooks?.defaultTabs + ? { tabs: yamlHooks.defaultTabs, runCommands: false } + : undefined + } const setupScript = hooks?.scripts.setup let shouldLaunchSetup = false if (setupScript) { @@ -1040,6 +1235,7 @@ export async function createRemoteWorktree( return { worktree, ...(setup ? { setup } : {}), + ...(defaultTabs ? { defaultTabs } : {}), ...(localBaseRefRefresh ? { localBaseRefRefresh } : {}) } } @@ -1052,6 +1248,7 @@ export async function createLocalWorktree( runtime?: OrcaRuntimeService ): Promise { const settings = store.getSettings() + const worktreePathSettings = getWorktreePathSettings(repo, settings) const username = getGitUsername(repo.path) const requestedName = args.name @@ -1113,13 +1310,7 @@ export async function createLocalWorktree( .catch(() => undefined) emitCreateWorktreeProgress(mainWindow, 'fetching') } - // Why: WSL worktrees live under ~/orca/workspaces inside the WSL - // filesystem. Validate against that root, not the Windows workspace dir. - // If WSL home lookup fails, keep using the configured workspace root so - // the path traversal guard still runs on the fallback path. - const wslInfo = isWslPath(repo.path) ? parseWslPath(repo.path) : null - const wslHome = wslInfo ? getWslHome(wslInfo.distro) : null - const workspaceRoot = wslHome ? join(wslHome, 'orca', 'workspaces') : settings.workspaceDir + const workspaceRoot = computeWorkspaceRoot(repo.path, worktreePathSettings) // Why: this validation does not depend on remote refs, so it can overlap a // required remote-tracking base refresh. @@ -1184,11 +1375,9 @@ export async function createLocalWorktree( repo.path, selectedExistingLocalBranchName ? selectedExistingLocalBranchName - : suffix === 1 && args.branchNameOverride + : args.branchNameOverride ? args.branchNameOverride - : args.branchNameOverride - ? `${args.branchNameOverride}-${suffix}` - : undefined, + : undefined, effectiveSanitizedName, settings, username @@ -1202,7 +1391,32 @@ export async function createLocalWorktree( lastBranchConflictKind = checkoutExistingBranch ? null : await getBranchConflictKind(repo.path, branchName, baseBranch) + const allowedPushTargetRemoteConflict = + lastBranchConflictKind && + isAllowedPushTargetRemoteConflict(lastBranchConflictKind, branchName, args) if (lastBranchConflictKind) { + if (allowedPushTargetRemoteConflict) { + lastExistingPR = null + let lookupFailed = false + try { + lastExistingPR = await getPRForBranch(repo.path, branchName) + } catch { + lookupFailed = true + } + if (!lookupFailed && isMatchingSelectedGitHubPr(lastExistingPR, args, branchName)) { + lastBranchConflictKind = null + } else if (lastExistingPR) { + break + } + } + } + if (lastBranchConflictKind) { + // Why: PR resolver-provided branch names are exact branch identity. + // Retrying with a suffixed branch would silently detach the worktree + // from the PR being opened. + if (args.branchNameOverride) { + break + } continue } @@ -1219,13 +1433,16 @@ export async function createLocalWorktree( } catch { // GitHub API may be unreachable, rate-limited, or token missing } - if (lastExistingPR) { + if (lastExistingPR && !isMatchingSelectedGitHubPr(lastExistingPR, args, branchName)) { + if (args.branchNameOverride) { + break + } continue } } worktreePath = ensurePathWithinWorkspace( - computeWorktreePath(effectiveSanitizedName, repo.path, settings), + computeWorktreePath(effectiveSanitizedName, repo.path, worktreePathSettings), workspaceRoot ) if (existsSync(worktreePath)) { @@ -1358,10 +1575,7 @@ export async function createLocalWorktree( createdAt: now, orcaCreatedAt: now, orcaCreationSource: 'desktop', - orcaCreationWorkspaceLayout: { - path: settings.workspaceDir, - nestWorkspaces: settings.nestWorkspaces - }, + orcaCreationWorkspaceLayout: getWorktreeCreationLayout(repo, settings), baseRef: baseBranch, ...(checkoutExistingBranch ? { preserveBranchOnDelete: true } : {}), ...(configuredPushTarget ? { pushTarget: configuredPushTarget } : {}), @@ -1414,7 +1628,20 @@ export async function createLocalWorktree( // disabling setup with no UI signal. See #1280 for the original gate and // the regression this replaced. let setup: CreateWorktreeResult['setup'] - const setupScript = getEffectiveHooks(repo, worktreePath)?.scripts.setup + let defaultTabs: CreateWorktreeResult['defaultTabs'] + const createdYamlHooks = loadHooks(worktreePath) + const createdEffectiveHooks = getEffectiveHooksFromConfig(repo, createdYamlHooks) + try { + defaultTabs = getDefaultTabsLaunch(createdYamlHooks, repo, args.setupDecision) + } catch (error) { + // Why: default tab commands share setup's run policy. If the target branch + // adds commands without a renderer decision, create the tabs but don't run them. + console.warn(`[hooks] default tab commands skipped for ${worktreePath}:`, error) + defaultTabs = createdYamlHooks?.defaultTabs + ? { tabs: createdYamlHooks.defaultTabs, runCommands: false } + : undefined + } + const setupScript = createdEffectiveHooks?.scripts.setup let shouldLaunchSetup = false if (setupScript) { try { @@ -1448,6 +1675,7 @@ export async function createLocalWorktree( return { worktree, ...(setup ? { setup } : {}), + ...(defaultTabs ? { defaultTabs } : {}), ...(addResult.localBaseRefRefresh ? { localBaseRefRefresh: addResult.localBaseRefRefresh } : {}) } } diff --git a/src/main/ipc/worktrees-windows.test.ts b/src/main/ipc/worktrees-windows.test.ts index 2cbeeb85acc..4dfa7f8d761 100644 --- a/src/main/ipc/worktrees-windows.test.ts +++ b/src/main/ipc/worktrees-windows.test.ts @@ -11,6 +11,8 @@ const { getBranchConflictKindMock, getPRForBranchMock, getEffectiveHooksMock, + getEffectiveHooksFromConfigMock, + getDefaultTabsLaunchMock, createIssueCommandRunnerScriptMock, createSetupRunnerScriptMock, shouldRunSetupForCreateMock, @@ -30,6 +32,8 @@ const { getBranchConflictKindMock: vi.fn(), getPRForBranchMock: vi.fn(), getEffectiveHooksMock: vi.fn(), + getEffectiveHooksFromConfigMock: vi.fn(), + getDefaultTabsLaunchMock: vi.fn(), createIssueCommandRunnerScriptMock: vi.fn(), createSetupRunnerScriptMock: vi.fn(), shouldRunSetupForCreateMock: vi.fn(), @@ -75,6 +79,8 @@ vi.mock('../hooks', () => ({ createIssueCommandRunnerScript: createIssueCommandRunnerScriptMock, createSetupRunnerScript: createSetupRunnerScriptMock, getEffectiveHooks: getEffectiveHooksMock, + getEffectiveHooksFromConfig: getEffectiveHooksFromConfigMock, + getDefaultTabsLaunch: getDefaultTabsLaunchMock, loadHooks: loadHooksMock, runHook: runHookMock, hasHooksFile: hasHooksFileMock, @@ -122,6 +128,8 @@ describe('registerWorktreeHandlers – Windows path handling', () => { getBranchConflictKindMock.mockReset() getPRForBranchMock.mockReset() getEffectiveHooksMock.mockReset() + getEffectiveHooksFromConfigMock.mockReset() + getDefaultTabsLaunchMock.mockReset() createIssueCommandRunnerScriptMock.mockReset() createSetupRunnerScriptMock.mockReset() shouldRunSetupForCreateMock.mockReset() @@ -176,6 +184,8 @@ describe('registerWorktreeHandlers – Windows path handling', () => { getBranchConflictKindMock.mockResolvedValue(null) getPRForBranchMock.mockResolvedValue(null) getEffectiveHooksMock.mockReturnValue(null) + getEffectiveHooksFromConfigMock.mockReturnValue(null) + getDefaultTabsLaunchMock.mockReturnValue(undefined) shouldRunSetupForCreateMock.mockReturnValue(false) computeWorktreePathMock.mockReturnValue('C:\\workspaces\\improve-dashboard') ensurePathWithinWorkspaceMock.mockReturnValue('C:\\workspaces\\improve-dashboard') diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 12111d31909..b37ca34dfcc 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -13,6 +13,7 @@ const { addWorktreeMock, addSparseWorktreeMock, removeWorktreeMock, + forceDeleteLocalBranchMock, getGitUsernameMock, getDefaultBaseRefMock, getDefaultRemoteMock, @@ -24,6 +25,7 @@ const { createIssueCommandRunnerScriptMock, createSetupRunnerScriptMock, getEffectiveHooksFromConfigMock, + getDefaultTabsLaunchMock, parseOrcaYamlMock, shouldRunSetupForCreateMock, buildPosixRunnerScriptMock, @@ -58,6 +60,7 @@ const { addWorktreeMock: vi.fn(), addSparseWorktreeMock: vi.fn(), removeWorktreeMock: vi.fn(), + forceDeleteLocalBranchMock: vi.fn(), getGitUsernameMock: vi.fn(), getDefaultBaseRefMock: vi.fn(), getDefaultRemoteMock: vi.fn(), @@ -69,6 +72,7 @@ const { createIssueCommandRunnerScriptMock: vi.fn(), createSetupRunnerScriptMock: vi.fn(), getEffectiveHooksFromConfigMock: vi.fn(), + getDefaultTabsLaunchMock: vi.fn(), parseOrcaYamlMock: vi.fn(), shouldRunSetupForCreateMock: vi.fn(), buildPosixRunnerScriptMock: vi.fn(), @@ -98,7 +102,8 @@ vi.mock('../git/worktree', () => ({ assertWorktreeCleanForRemoval: assertWorktreeCleanForRemovalMock, addWorktree: addWorktreeMock, addSparseWorktree: addSparseWorktreeMock, - removeWorktree: removeWorktreeMock + removeWorktree: removeWorktreeMock, + forceDeleteLocalBranch: forceDeleteLocalBranchMock })) vi.mock('../git/runner', () => ({ @@ -149,6 +154,7 @@ vi.mock('../hooks', () => ({ createSetupRunnerScript: createSetupRunnerScriptMock, getEffectiveHooks: getEffectiveHooksMock, getEffectiveHooksFromConfig: getEffectiveHooksFromConfigMock, + getDefaultTabsLaunch: getDefaultTabsLaunchMock, getSetupRunnerEnvVars: getSetupRunnerEnvVarsMock, loadHooks: loadHooksMock, parseOrcaYaml: parseOrcaYamlMock, @@ -174,6 +180,16 @@ vi.mock('../terminal-history', () => ({ deleteWorktreeHistoryDir: deleteWorktreeHistoryDirMock })) +const { advertisedUrlWatcherForgetWorktreeMock } = vi.hoisted(() => ({ + advertisedUrlWatcherForgetWorktreeMock: vi.fn() +})) + +vi.mock('../ports/advertised-url-watcher', () => ({ + advertisedUrlWatcher: { + forgetWorktree: advertisedUrlWatcherForgetWorktreeMock + } +})) + const { killAllProcessesForWorktreeMock, getLocalPtyProviderMock } = vi.hoisted(() => ({ killAllProcessesForWorktreeMock: vi.fn(), getLocalPtyProviderMock: vi.fn() @@ -233,6 +249,7 @@ describe('registerWorktreeHandlers', () => { addWorktreeMock, addSparseWorktreeMock, removeWorktreeMock, + forceDeleteLocalBranchMock, getGitUsernameMock, getDefaultBaseRefMock, getDefaultRemoteMock, @@ -242,6 +259,7 @@ describe('registerWorktreeHandlers', () => { getPullRequestPushTargetMock, getEffectiveHooksMock, getEffectiveHooksFromConfigMock, + getDefaultTabsLaunchMock, parseOrcaYamlMock, createIssueCommandRunnerScriptMock, createSetupRunnerScriptMock, @@ -271,7 +289,8 @@ describe('registerWorktreeHandlers', () => { store.removeWorktreeLineage, killAllProcessesForWorktreeMock, getLocalPtyProviderMock, - deleteWorktreeHistoryDirMock + deleteWorktreeHistoryDirMock, + advertisedUrlWatcherForgetWorktreeMock ]) { m.mockReset() } @@ -323,7 +342,8 @@ describe('registerWorktreeHandlers', () => { // don't trip on undefined. gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) getEffectiveHooksMock.mockReturnValue(null) - getEffectiveHooksFromConfigMock.mockReturnValue(null) + getEffectiveHooksFromConfigMock.mockImplementation(() => getEffectiveHooksMock()) + getDefaultTabsLaunchMock.mockReturnValue(undefined) parseOrcaYamlMock.mockReturnValue(null) shouldRunSetupForCreateMock.mockReturnValue(false) buildPosixRunnerScriptMock.mockImplementation( @@ -372,6 +392,7 @@ describe('registerWorktreeHandlers', () => { ) ensurePathWithinWorkspaceMock.mockImplementation((targetPath: string) => targetPath) listWorktreesMock.mockResolvedValue([]) + forceDeleteLocalBranchMock.mockResolvedValue(undefined) // Why: createLocalWorktree routes `git fetch` through // `runtime.fetchRemoteWithCache` (§3.3 Lifecycle). A minimal stub @@ -492,6 +513,50 @@ describe('registerWorktreeHandlers', () => { }) }) + it('uses a repo-specific worktree base path when creating local worktrees', async () => { + store.getRepo.mockReturnValue({ + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + worktreeBaseRef: null, + worktreeBasePath: '../worktrees' + }) + listWorktreesMock.mockResolvedValue([ + { + path: '../worktrees/feature', + head: 'abc123', + branch: 'feature', + isBare: false, + isMainWorktree: false + } + ]) + + await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'feature' + }) + + expect(computeWorktreePathMock).toHaveBeenCalledWith('feature', '/workspace/repo', { + nestWorkspaces: false, + workspaceDir: '../worktrees' + }) + expect(addWorktreeMock).toHaveBeenCalledWith( + '/workspace/repo', + '../worktrees/feature', + 'feature', + 'origin/main', + false + ) + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + 'repo-1::../worktrees/feature', + expect.objectContaining({ + orcaCreationWorkspaceLayout: { path: '../worktrees', nestWorkspaces: false } + }) + ) + }) + it('uses branchNameOverride for the git branch while keeping the sanitized worktree path', async () => { listWorktreesMock.mockResolvedValue([ { @@ -682,43 +747,248 @@ describe('registerWorktreeHandlers', () => { }) }) - it('suffixes branchNameOverride without flattening slashes when the first branch collides', async () => { + it('does not suffix branchNameOverride when the requested branch collides', async () => { getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) => branch === 'feature/something' ? 'remote' : null ) + + await expect( + handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'feature/something', + branchNameOverride: 'feature/something' + }) + ).rejects.toThrow( + 'Branch "feature/something" already exists on a remote. Pick a different worktree name.' + ) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['check-ref-format', '--branch', 'feature/something'], + { cwd: '/workspace/repo' } + ) + expect(addWorktreeMock).not.toHaveBeenCalled() + }) + + it('allows a resolver-provided PR branch override to match its remote push target', async () => { + getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) => + branch === 'feature/fix' ? 'remote' : null + ) listWorktreesMock.mockResolvedValue([ { - path: '/workspace/feature-something-2', + path: '/workspace/fix-title', head: 'abc123', - branch: 'feature/something-2', + branch: 'refs/heads/feature/fix', isBare: false, isMainWorktree: false } ]) + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + getPRForBranchMock.mockResolvedValueOnce({ + number: 42, + title: 'Selected PR', + state: 'open', + url: 'https://example.com/pr/42', + checksStatus: 'success', + updatedAt: '2026-05-21T00:00:00Z', + mergeable: 'UNKNOWN' + }) - const result = await handlers['worktrees:create'](null, { + await handlers['worktrees:create'](null, { repoId: 'repo-1', - name: 'feature/something', - branchNameOverride: 'feature/something' + name: 'fix-title', + baseBranch: 'abc123', + branchNameOverride: 'feature/fix', + linkedPR: 42, + pushTarget: { remoteName: 'origin', branchName: 'feature/fix' } }) - expect(gitExecFileAsyncMock).toHaveBeenCalledWith( - ['check-ref-format', '--branch', 'feature/something-2'], - { cwd: '/workspace/repo' } - ) expect(addWorktreeMock).toHaveBeenCalledWith( '/workspace/repo', - '/workspace/feature-something-2', - 'feature/something-2', - 'origin/main', + '/workspace/fix-title', + 'feature/fix', + 'abc123', false ) - expect(result).toEqual({ - worktree: expect.objectContaining({ - path: '/workspace/feature-something-2', - branch: 'feature/something-2' + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['branch', '--set-upstream-to', 'origin/feature/fix', 'feature/fix'], + { cwd: '/workspace/fix-title' } + ) + expect(getPRForBranchMock).toHaveBeenCalledWith('/workspace/repo', 'feature/fix') + }) + + it('rejects a matching push target branch without selected PR metadata', async () => { + getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) => + branch === 'feature/fix' ? 'remote' : null + ) + + await expect( + handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'fix-title', + baseBranch: 'abc123', + branchNameOverride: 'feature/fix', + pushTarget: { remoteName: 'origin', branchName: 'feature/fix' } }) + ).rejects.toThrow( + 'Branch "feature/fix" already exists on a remote. Pick a different worktree name.' + ) + + expect(addWorktreeMock).not.toHaveBeenCalled() + }) + + it('rejects a matching push target branch when selected PR metadata has no PR number', async () => { + getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) => + branch === 'feature/fix' ? 'remote' : null + ) + + await expect( + handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'fix-title', + baseBranch: 'abc123', + branchNameOverride: 'feature/fix', + linkedPR: null, + pushTarget: { remoteName: 'origin', branchName: 'feature/fix' } + }) + ).rejects.toThrow( + 'Branch "feature/fix" already exists on a remote. Pick a different worktree name.' + ) + + expect(addWorktreeMock).not.toHaveBeenCalled() + }) + + it('rejects a matching push target branch when the existing PR is different', async () => { + getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) => + branch === 'feature/fix' ? 'remote' : null + ) + getPRForBranchMock.mockResolvedValueOnce({ + number: 43, + title: 'Different PR', + state: 'open', + url: 'https://example.com/pr/43', + checksStatus: 'success', + updatedAt: '2026-05-21T00:00:00Z', + mergeable: 'UNKNOWN' }) + + await expect( + handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'fix-title', + baseBranch: 'abc123', + branchNameOverride: 'feature/fix', + linkedPR: 42, + pushTarget: { remoteName: 'origin', branchName: 'feature/fix' } + }) + ).rejects.toThrow('Branch "feature/fix" already has PR #43. Pick a different worktree name.') + + expect(getPRForBranchMock).toHaveBeenCalledWith('/workspace/repo', 'feature/fix') + expect(addWorktreeMock).not.toHaveBeenCalled() + }) + + it('rejects a selected PR remote conflict when the PR lookup fails', async () => { + getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) => + branch === 'feature/fix' ? 'remote' : null + ) + getPRForBranchMock.mockRejectedValueOnce(new Error('gh unavailable')) + + await expect( + handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'fix-title', + baseBranch: 'abc123', + branchNameOverride: 'feature/fix', + linkedPR: 42, + pushTarget: { remoteName: 'origin', branchName: 'feature/fix' } + }) + ).rejects.toThrow( + 'Branch "feature/fix" already exists on a remote. Pick a different worktree name.' + ) + + expect(getPRForBranchMock).toHaveBeenCalledWith('/workspace/repo', 'feature/fix') + expect(addWorktreeMock).not.toHaveBeenCalled() + }) + + it('checks out an unused existing PR branch only when it is at the resolved head SHA', async () => { + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/fix^{commit}')) { + return { stdout: 'abc123\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('abc123^{commit}')) { + return { stdout: 'abc123\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + listWorktreesMock + .mockResolvedValueOnce([ + { + path: '/workspace/repo', + head: 'main', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + .mockResolvedValueOnce([ + { + path: '/workspace/repo', + head: 'main', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '/workspace/fix-title', + head: 'abc123', + branch: 'refs/heads/feature/fix', + isBare: false, + isMainWorktree: false + } + ]) + + await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'fix-title', + baseBranch: 'abc123', + branchNameOverride: 'feature/fix' + }) + + expect(getBranchConflictKindMock).not.toHaveBeenCalled() + expect(addWorktreeMock).toHaveBeenCalledWith( + '/workspace/repo', + '/workspace/fix-title', + 'feature/fix', + 'abc123', + false, + false, + { checkoutExistingBranch: true } + ) + }) + + it('rejects an existing PR branch when its tip differs from the resolved head SHA', async () => { + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/fix^{commit}')) { + return { stdout: 'old123\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('abc123^{commit}')) { + return { stdout: 'abc123\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + getBranchConflictKindMock.mockResolvedValueOnce('local') + + await expect( + handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'fix-title', + baseBranch: 'abc123', + branchNameOverride: 'feature/fix' + }) + ).rejects.toThrow( + 'Branch "feature/fix" already exists locally. Pick a different worktree name.' + ) + + expect(addWorktreeMock).not.toHaveBeenCalled() }) it('persists a sanitized artifact title as the worktree display name', async () => { @@ -969,6 +1239,41 @@ describe('registerWorktreeHandlers', () => { }) }) + it('returns the same-repo PR head SHA and exact branch override when resolving a PR base', async () => { + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'rev-parse') { + return { stdout: 'def456\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + + const result = await handlers['worktrees:resolvePrBase'](null, { + repoId: 'repo-1', + prNumber: 42, + headRefName: 'feature/add-feature', + isCrossRepository: false + }) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + [ + 'fetch', + 'origin', + '+refs/heads/feature/add-feature:refs/remotes/origin/feature/add-feature' + ], + { cwd: '/workspace/repo' } + ) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['rev-parse', '--verify', 'origin/feature/add-feature'], + { cwd: '/workspace/repo' } + ) + expect(result).toEqual({ + baseBranch: 'def456', + headSha: 'def456', + branchNameOverride: 'feature/add-feature', + pushTarget: { remoteName: 'origin', branchName: 'feature/add-feature' } + }) + }) + it('resolves a fork PR base even when push-target discovery fails', async () => { getPullRequestPushTargetMock.mockRejectedValueOnce(new Error('lookup failed')) gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { @@ -1387,20 +1692,20 @@ describe('registerWorktreeHandlers', () => { expect(provider.addWorktree).toHaveBeenCalledWith( '/remote/repo', 'sparse-dashboard', - '/remote/repo/../sparse-dashboard', + '/remote/sparse-dashboard', { base: 'origin/main', noCheckout: true } ) expect(provider.exec).toHaveBeenCalledWith( ['sparse-checkout', 'init', '--cone'], - '/remote/repo/../sparse-dashboard' + '/remote/sparse-dashboard' ) expect(provider.exec).toHaveBeenCalledWith( ['sparse-checkout', 'set', '--', 'apps/mobile', 'packages/shared'], - '/remote/repo/../sparse-dashboard' + '/remote/sparse-dashboard' ) expect(provider.exec).toHaveBeenCalledWith( ['checkout', 'sparse-dashboard'], - '/remote/repo/../sparse-dashboard' + '/remote/sparse-dashboard' ) expect(store.setWorktreeMeta).toHaveBeenCalledWith( 'repo-ssh::/remote/sparse-dashboard', @@ -1420,6 +1725,199 @@ describe('registerWorktreeHandlers', () => { }) }) + it('suffixes only the SSH worktree path when an exact PR branch checkout path exists', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'abc123' + } + const provider = { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/fix^{commit}')) { + return { stdout: 'abc123\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('abc123^{commit}')) { + return { stdout: 'abc123\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + removeWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi + .fn() + .mockResolvedValueOnce([ + { + path: '/remote/repo', + head: 'main', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + .mockResolvedValueOnce([ + { + path: '/remote/fix-title-2', + head: 'abc123', + branch: 'refs/heads/feature/fix', + isBare: false, + isMainWorktree: false + } + ]) + } + const fsProvider = { + stat: vi.fn().mockImplementation(async (pathValue: string) => { + if (pathValue === '/remote/fix-title') { + return { size: 0, type: 'directory', mtime: 0 } + } + const error = new Error('missing') as Error & { code: string } + error.code = 'ENOENT' + throw error + }), + readFile: vi.fn().mockRejectedValue(new Error('missing')) + } + const mux = { + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + getSshFilesystemProviderMock.mockReturnValue(fsProvider) + getActiveMultiplexerMock.mockReturnValue(mux) + + await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'fix-title', + baseBranch: 'abc123', + branchNameOverride: 'feature/fix' + }) + + expect(provider.addWorktree).toHaveBeenCalledWith( + '/remote/repo', + 'feature/fix', + '/remote/fix-title-2', + { checkoutExistingBranch: true } + ) + expect(mux.request).toHaveBeenCalledWith('session.registerRoot', { + rootPath: '/remote/fix-title-2' + }) + }) + + it('blocks SSH worktree creation when the requested branch already exists on a remote', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'origin/main' + } + const provider = { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'branch' && args.includes('feature/something')) { + return { stdout: '', stderr: '' } + } + if (args[0] === 'for-each-ref') { + return { stdout: 'refs/remotes/origin/feature/something\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/something^{commit}')) { + throw new Error('missing local branch') + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + removeWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/feature-something', + head: 'abc123', + branch: 'refs/heads/feature/something', + isBare: false, + isMainWorktree: false + } + ]) + } + const mux = { + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + getActiveMultiplexerMock.mockReturnValue(mux) + + await expect( + handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'feature-something', + branchNameOverride: 'feature/something' + }) + ).rejects.toThrow('Branch "feature/something" already exists on a remote.') + + expect(provider.addWorktree).not.toHaveBeenCalled() + }) + + it('blocks SSH worktree creation when a slashed remote owns the requested branch', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'origin/main' + } + const provider = { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'remote') { + return { stdout: 'origin\nfoo/bar\n', stderr: '' } + } + if (args[0] === 'for-each-ref') { + return { stdout: 'refs/remotes/foo/bar/feature/something\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/something^{commit}')) { + throw new Error('missing local branch') + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + removeWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([]) + } + const mux = { + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + getActiveMultiplexerMock.mockReturnValue(mux) + + await expect( + handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'feature-something', + branchNameOverride: 'feature/something' + }) + ).rejects.toThrow('Branch "feature/something" already exists on a remote.') + + expect(provider.addWorktree).not.toHaveBeenCalled() + }) + it('unsets SSH branch base config before removing a sparse worktree after setup failure', async () => { const repo = { id: 'repo-ssh', @@ -1468,9 +1966,9 @@ describe('registerWorktreeHandlers', () => { expect(provider.exec).toHaveBeenCalledWith( ['config', '--local', '--unset-all', 'branch.sparse-dashboard.base'], - '/remote/repo/../sparse-dashboard' + '/remote/sparse-dashboard' ) - expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/repo/../sparse-dashboard', true, { + expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/sparse-dashboard', true, { deleteBranch: true, forceBranchDelete: true }) @@ -2269,6 +2767,83 @@ describe('registerWorktreeHandlers', () => { }) }) + it('limits concurrent repo scans in worktrees:listAll while preserving order', async () => { + const repos = Array.from({ length: 10 }, (_, index) => ({ + id: `repo-${index}`, + path: `/workspace/repo-${index}`, + displayName: `repo-${index}`, + badgeColor: '#000', + addedAt: 0 + })) + store.getRepos.mockReturnValue(repos) + let activeScans = 0 + let maxActiveScans = 0 + let notifyScanStarted: (() => void) | undefined + const waitForScanCount = async (count: number): Promise => { + while (listWorktreesMock.mock.calls.length < count) { + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error(`Timed out waiting for ${count} scans`)), + 1000 + ) + notifyScanStarted = () => { + clearTimeout(timeout) + resolve() + } + }) + } + } + const pendingScans: (() => void)[] = [] + listWorktreesMock.mockImplementation( + async ( + repoPath: string + ): Promise< + { path: string; head: string; branch: string; isBare: false; isMainWorktree: true }[] + > => { + activeScans += 1 + maxActiveScans = Math.max(maxActiveScans, activeScans) + await new Promise((resolve) => { + pendingScans.push(resolve) + notifyScanStarted?.() + notifyScanStarted = undefined + }) + activeScans -= 1 + return [ + { + path: repoPath, + head: 'abc123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ] + } + ) + + const listPromise = handlers['worktrees:listAll'](null, undefined) as Promise< + { path: string }[] + > + await Promise.resolve() + + expect(listWorktreesMock).toHaveBeenCalledTimes(8) + expect(maxActiveScans).toBe(8) + + for (const resolve of pendingScans.splice(0)) { + resolve() + } + await waitForScanCount(10) + + expect(listWorktreesMock).toHaveBeenCalledTimes(10) + + for (const resolve of pendingScans.splice(0)) { + resolve() + } + const listed = await listPromise + + expect(maxActiveScans).toBe(8) + expect(listed.map((worktree) => worktree.path)).toEqual(repos.map((repo) => repo.path)) + }) + it('skips past a suffix that already belongs to a PR after an initial branch conflict', async () => { // Why: `gh pr list` is network-bound and previously fired on every single // create, adding 1–3s to the happy path. We now only probe PR conflicts @@ -2410,6 +2985,11 @@ describe('registerWorktreeHandlers', () => { setup: worktreePath ? 'pnpm worktree:setup # worktree' : 'pnpm worktree:setup' } })) + getEffectiveHooksFromConfigMock.mockReturnValue({ + scripts: { + setup: 'pnpm worktree:setup # worktree' + } + }) shouldRunSetupForCreateMock.mockReturnValue(true) const result = await handlers['worktrees:create'](null, { @@ -2696,6 +3276,7 @@ describe('registerWorktreeHandlers', () => { store.removeWorktreeMeta.mock.invocationCallOrder[0] ) expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId) + expect(advertisedUrlWatcherForgetWorktreeMock).toHaveBeenCalledWith(worktreeId) expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(worktreeId) expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { repoId: 'repo-folder' @@ -3200,6 +3781,49 @@ describe('registerWorktreeHandlers', () => { ) }) + it('force-deletes a branch that was preserved by safe worktree removal', async () => { + mockKnownFeatureWorktree() + removeWorktreeMock.mockResolvedValue({ + preservedBranch: { branchName: 'feature/test', head: 'def456' } + }) + + await handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/workspace/feature-wt' + }) + const result = await handlers['worktrees:forceDeletePreservedBranch'](null, { + worktreeId: 'repo-1::/workspace/feature-wt', + branchName: 'feature/test', + expectedHead: 'def456' + }) + + expect(result).toEqual({ deleted: true }) + expect(forceDeleteLocalBranchMock).toHaveBeenCalledWith( + '/workspace/repo', + 'feature/test', + 'def456' + ) + }) + + it('rejects stale preserved-branch cleanup actions with an old head', async () => { + mockKnownFeatureWorktree() + removeWorktreeMock.mockResolvedValue({ + preservedBranch: { branchName: 'feature/test', head: 'new456' } + }) + + await handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/workspace/feature-wt' + }) + + await expect( + handlers['worktrees:forceDeletePreservedBranch'](null, { + worktreeId: 'repo-1::/workspace/feature-wt', + branchName: 'feature/test', + expectedHead: 'old123' + }) + ).rejects.toThrow('No preserved branch cleanup is pending') + expect(forceDeleteLocalBranchMock).not.toHaveBeenCalled() + }) + it('removes an unused Orca-created fork remote after deleting its worktree', async () => { mockKnownFeatureWorktree() removeWorktreeMock.mockResolvedValue(undefined) @@ -3533,18 +4157,18 @@ describe('registerWorktreeHandlers', () => { const first = handlers['worktrees:remove'](null, { worktreeId: 'repo-1::/workspace/feature-wt', force: true - }) as Promise + }) as Promise const second = handlers['worktrees:remove'](null, { worktreeId: 'repo-1::/workspace/feature-wt', force: true - }) as Promise + }) as Promise await started await Promise.resolve() expect(removeWorktreeMock).toHaveBeenCalledTimes(1) finishRemoval() - await expect(Promise.all([first, second])).resolves.toEqual([undefined, undefined]) + await expect(Promise.all([first, second])).resolves.toEqual([{}, {}]) expect(store.removeWorktreeMeta).toHaveBeenCalledTimes(1) expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledTimes(1) expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) @@ -3567,7 +4191,7 @@ describe('registerWorktreeHandlers', () => { const first = handlers['worktrees:remove'](null, { worktreeId: 'repo-1::/workspace/feature-wt' - }) as Promise + }) as Promise await started await expect( @@ -3579,7 +4203,7 @@ describe('registerWorktreeHandlers', () => { expect(removeWorktreeMock).toHaveBeenCalledTimes(1) finishRemoval() - await expect(first).resolves.toBeUndefined() + await expect(first).resolves.toEqual({}) }) it('still rejects forced unregistered delete paths that exist on disk', async () => { @@ -3781,6 +4405,123 @@ describe('registerWorktreeHandlers', () => { expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled() }) + it('keeps SSH issue-command local overrides usable when shared read fails', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: null + } + const fsProvider = { + readFile: vi.fn(async (filePath: string) => { + if (filePath.endsWith('/.orca/issue-command')) { + return { content: 'local command\n', isBinary: false } + } + throw new Error('shared read failed') + }) + } + store.getRepo.mockReturnValue(repo) + getSshFilesystemProviderMock.mockReturnValue(fsProvider) + + await expect( + handlers['hooks:readIssueCommand'](null, { + repoId: 'repo-ssh' + }) + ).resolves.toMatchObject({ + status: 'ok', + localContent: 'local command', + sharedContent: null, + effectiveContent: 'local command', + source: 'local' + }) + }) + + it('writes SSH issue-command overrides without clobbering .gitignore on read failure', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: null + } + const fsProvider = { + createDir: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockRejectedValue(new Error('ssh read failed')), + writeFile: vi.fn().mockResolvedValue(undefined), + deletePath: vi.fn().mockResolvedValue(undefined) + } + store.getRepo.mockReturnValue(repo) + getSshFilesystemProviderMock.mockReturnValue(fsProvider) + + await expect( + handlers['hooks:writeIssueCommand'](null, { + repoId: 'repo-ssh', + content: 'orca issue command' + }) + ).rejects.toThrow('ssh read failed') + + expect(fsProvider.writeFile).not.toHaveBeenCalled() + }) + + it('creates remote .gitignore only when it is missing while writing SSH issue commands', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: null + } + const enoent = Object.assign(new Error('missing'), { code: 'ENOENT' }) + const fsProvider = { + createDir: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockRejectedValue(enoent), + writeFile: vi.fn().mockResolvedValue(undefined), + deletePath: vi.fn().mockResolvedValue(undefined) + } + store.getRepo.mockReturnValue(repo) + getSshFilesystemProviderMock.mockReturnValue(fsProvider) + + await handlers['hooks:writeIssueCommand'](null, { + repoId: 'repo-ssh', + content: 'orca issue command' + }) + + expect(fsProvider.writeFile).toHaveBeenNthCalledWith(1, '/remote/repo/.gitignore', '.orca\n') + expect(fsProvider.writeFile).toHaveBeenNthCalledWith( + 2, + '/remote/repo/.orca/issue-command', + 'orca issue command\n' + ) + }) + + it('rejects SSH issue-command writes when the remote filesystem provider is unavailable', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: null + } + store.getRepo.mockReturnValue(repo) + getSshFilesystemProviderMock.mockReturnValue(null) + + await expect( + handlers['hooks:writeIssueCommand'](null, { + repoId: 'repo-ssh', + content: 'orca issue command' + }) + ).rejects.toThrow('Remote filesystem unavailable') + }) + it('rejects ask-policy creates before mutating git state when setup decision is missing', async () => { getEffectiveHooksMock.mockReturnValue({ scripts: { diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index f8f9abdcc36..d82d935da1b 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -1,7 +1,7 @@ /* oxlint-disable max-lines */ import type { BrowserWindow } from 'electron' import { ipcMain } from 'electron' -import { readFile, rm } from 'fs/promises' +import { readFile, rm, stat } from 'fs/promises' import { randomUUID } from 'crypto' import type { Store } from '../persistence' import { isFolderRepo } from '../../shared/repo-kind' @@ -12,10 +12,13 @@ import type { CreateWorktreeResult, DetectedWorktree, DetectedWorktreeListResult, + ForceDeleteWorktreeBranchResult, + GitHubPrStartPoint, GitPushTarget, GitWorktreeInfo, OrcaHooks, Repo, + RemoveWorktreeResult, Worktree, WorktreeMeta } from '../../shared/types' @@ -26,6 +29,7 @@ import { } from '../../shared/worktree-ownership' import { assertWorktreeCleanForRemoval, + forceDeleteLocalBranch, listWorktrees as listGitWorktrees, removeWorktree } from '../git/worktree' @@ -78,6 +82,7 @@ import { track } from '../telemetry/client' import { getCohortAtEmit } from '../telemetry/cohort-classifier' import { workspaceSourceSchema, type WorkspaceSource } from '../../shared/telemetry-events' import { classifyWorkspaceCreateError } from './workspace-create-error-classifier' +import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' import { assertWorktreeDoesNotContainRegisteredWorktree, canCleanupUnregisteredOrcaWorktreeDirectory, @@ -92,6 +97,35 @@ import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree-id' const WORKTREE_ARCHIVE_HOOK_TIMEOUT_MS = 120_000 +const WORKTREE_LIST_ALL_CONCURRENCY = 8 + +async function mapWithConcurrency( + items: readonly T[], + limit: number, + fn: (item: T) => Promise +): Promise { + const results: R[] = [] + let nextIndex = 0 + const workerCount = Math.min(limit, items.length) + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (nextIndex < items.length) { + const index = nextIndex + nextIndex += 1 + results[index] = await fn(items[index]) + } + }) + ) + return results +} + +function removeWorktreeMetadataAndTransientState(store: Store, worktreeId: string): void { + // Why: worktree IDs are path-derived and can be recreated, so removal must + // drop process-local caches before the same ID can point at a new workspace. + store.removeWorktreeMeta(worktreeId) + advertisedUrlWatcher.forgetWorktree(worktreeId) + deleteWorktreeHistoryDir(worktreeId) +} // Why: worktrees discovered on disk (not created via Orca's UI) have no // persisted WorktreeMeta, so mergeWorktree falls back to `lastActivityAt: 0`. @@ -199,7 +233,66 @@ async function runRemoteArchiveHook( type WorktreeRemovalInFlight = { optionsKey: string - promise: Promise + promise: Promise +} + +type PreservedBranchCleanupTarget = { + branchName: string + head: string + pushTarget?: GitPushTarget +} + +const preservedBranchCleanupByWorktreeId = new Map() + +function rememberPreservedBranchCleanupTarget( + worktreeId: string, + result: RemoveWorktreeResult | undefined, + fallbackHead: string | undefined, + pushTarget: GitPushTarget | undefined +): void { + if (result?.preservedBranch) { + const head = result.preservedBranch.head ?? fallbackHead + if (!head) { + throw new Error( + `Cannot safely offer force-delete for preserved branch "${result.preservedBranch.branchName}" without its saved commit.` + ) + } + preservedBranchCleanupByWorktreeId.set(worktreeId, { + branchName: result.preservedBranch.branchName, + head, + ...(pushTarget ? { pushTarget } : {}) + }) + return + } + preservedBranchCleanupByWorktreeId.delete(worktreeId) +} + +function preserveBranchHeadFallback( + result: RemoveWorktreeResult | undefined, + fallbackHead: string | undefined +): RemoveWorktreeResult { + if (!result?.preservedBranch || result.preservedBranch.head || !fallbackHead) { + return result ?? {} + } + return { + ...result, + preservedBranch: { + ...result.preservedBranch, + head: fallbackHead + } + } +} + +function getPreservedBranchCleanupTarget( + worktreeId: string, + branchName: string, + expectedHead: string +): PreservedBranchCleanupTarget { + const target = preservedBranchCleanupByWorktreeId.get(worktreeId) + if (!target || target.branchName !== branchName || target.head !== expectedHead) { + throw new Error(`No preserved branch cleanup is pending for "${branchName}".`) + } + return target } const loggedUnavailableSshGitProviders = new Set() @@ -337,7 +430,7 @@ function buildDetectedGitWorktrees( gitWorktrees: GitWorktreeInfo[] ): DetectedWorktree[] { const settings = store.getSettings() - const knownOrcaLayouts = repo.connectionId ? [] : buildKnownOrcaWorkspaceLayouts(settings, repo) + const knownOrcaLayouts = buildKnownOrcaWorkspaceLayouts(settings, repo) const isLegacyRepoForVisibility = isLegacyRepoForExternalWorktreeVisibility(repo) return gitWorktrees.map((gitWorktree) => { const worktreeId = `${repo.id}::${gitWorktree.path}` @@ -547,6 +640,7 @@ export function registerWorktreeHandlers( ipcMain.removeHandler('worktrees:resolvePrBase') ipcMain.removeHandler('worktrees:resolveMrBase') ipcMain.removeHandler('worktrees:remove') + ipcMain.removeHandler('worktrees:forceDeletePreservedBranch') ipcMain.removeHandler('worktrees:updateMeta') ipcMain.removeHandler('worktrees:listLineage') ipcMain.removeHandler('worktrees:updateLineage') @@ -563,63 +657,61 @@ export function registerWorktreeHandlers( ? createSshWorktreeMetaIndex(Object.entries(store.getAllWorktreeMeta())) : new Map() - // Why: repos are listed in parallel so total time = slowest repo, not - // the sum of all repos. Each listRepoWorktrees spawns `git worktree list`. - const results = await Promise.all( - repos.map(async (repo) => { - try { - let gitWorktrees - if (isFolderRepo(repo)) { - return listVisibleFolderWorkspaces(store, repo) - } else if (repo.connectionId) { - const provider = getSshGitProvider(repo.connectionId) - if (!provider) { - warnOnce( - loggedUnavailableSshGitProviders, - `${repo.connectionId}:${repo.id}`, - `[worktrees] SSH git provider unavailable; skipping worktree list for repo "${repo.displayName}" (${repo.id}) at ${repo.path} on connection ${repo.connectionId}` - ) - return listDisconnectedSshWorktrees(repo, sshWorktreeMetaIndex) - } - loggedUnavailableSshGitProviders.delete(`${repo.connectionId}:${repo.id}`) - try { - gitWorktrees = await provider.listWorktrees(repo.path) - } catch (err) { - warnOnce( - loggedWorktreeListFailures, - `${repo.id}:${repo.path}`, - `[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, - err - ) - return listDisconnectedSshWorktrees(repo, sshWorktreeMetaIndex) - } - } else { - gitWorktrees = await listRepoWorktrees(repo) + // Why: each local repo listing can spawn `git worktree list`; cap fan-out + // so large repo fleets don't start unbounded subprocesses at once. + const results = await mapWithConcurrency(repos, WORKTREE_LIST_ALL_CONCURRENCY, async (repo) => { + try { + let gitWorktrees + if (isFolderRepo(repo)) { + return listVisibleFolderWorkspaces(store, repo) + } else if (repo.connectionId) { + const provider = getSshGitProvider(repo.connectionId) + if (!provider) { + warnOnce( + loggedUnavailableSshGitProviders, + `${repo.connectionId}:${repo.id}`, + `[worktrees] SSH git provider unavailable; skipping worktree list for repo "${repo.displayName}" (${repo.id}) at ${repo.path} on connection ${repo.connectionId}` + ) + return listDisconnectedSshWorktrees(repo, sshWorktreeMetaIndex) } - rememberLocalWorktreeRoots(store, repo, gitWorktrees) - pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) - loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) - return buildDetectedGitWorktrees(store, repo, gitWorktrees) - .filter((worktree) => worktree.visible) - .map((worktree) => stampAndMergeVisibleDetectedWorktree(store, repo, worktree)) - } catch (err) { - warnOnce( - loggedWorktreeListFailures, - `${repo.id}:${repo.path}`, - `[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, - err - ) - // Why: do NOT seed an empty success here. registerWorktreeRootsForRepo - // would mark this repo as registered and flip - // registeredWorktreeRootsDirty to false, which causes - // resolveRegisteredWorktreePath to permanently deny access to - // legitimate linked worktrees of this repo until something invalidates - // the cache. Leaving it unregistered keeps the cache dirty so the - // next access path can rebuild. - return [] + loggedUnavailableSshGitProviders.delete(`${repo.connectionId}:${repo.id}`) + try { + gitWorktrees = await provider.listWorktrees(repo.path) + } catch (err) { + warnOnce( + loggedWorktreeListFailures, + `${repo.id}:${repo.path}`, + `[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, + err + ) + return listDisconnectedSshWorktrees(repo, sshWorktreeMetaIndex) + } + } else { + gitWorktrees = await listRepoWorktrees(repo) } - }) - ) + rememberLocalWorktreeRoots(store, repo, gitWorktrees) + pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) + loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) + return buildDetectedGitWorktrees(store, repo, gitWorktrees) + .filter((worktree) => worktree.visible) + .map((worktree) => stampAndMergeVisibleDetectedWorktree(store, repo, worktree)) + } catch (err) { + warnOnce( + loggedWorktreeListFailures, + `${repo.id}:${repo.path}`, + `[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, + err + ) + // Why: do NOT seed an empty success here. registerWorktreeRootsForRepo + // would mark this repo as registered and flip + // registeredWorktreeRootsDirty to false, which causes + // resolveRegisteredWorktreePath to permanently deny access to + // legitimate linked worktrees of this repo until something invalidates + // the cache. Leaving it unregistered keeps the cache dirty so the + // next access path can rebuild. + return [] + } + }) return results.flat() }) @@ -828,7 +920,7 @@ export function registerWorktreeHandlers( headRefName?: string isCrossRepository?: boolean } - ): Promise<{ baseBranch: string; pushTarget?: GitPushTarget } | { error: string }> => { + ): Promise => { const repo = store.getRepo(args.repoId) if (!repo) { return { error: 'Repo not found' } @@ -911,7 +1003,7 @@ export function registerWorktreeHandlers( // Why: stale toast actions, double-clicks, and Space/sidebar races can // target the same worktree concurrently. Share the destructive backend // operation so only one path touches Git and the filesystem. - const removal = (async (): Promise => { + const removal = (async (): Promise => { const { repoId, worktreePath } = parseWorktreeId(args.worktreeId) const repo = store.getRepo(repoId) if (!repo) { @@ -931,10 +1023,10 @@ export function registerWorktreeHandlers( }).catch((err) => { console.warn(`[worktree-teardown] failed for ${args.worktreeId}:`, err) }) - store.removeWorktreeMeta(args.worktreeId) - deleteWorktreeHistoryDir(args.worktreeId) + removeWorktreeMetadataAndTransientState(store, args.worktreeId) + preservedBranchCleanupByWorktreeId.delete(args.worktreeId) notifyWorktreesChanged(mainWindow, repoId) - return + return {} } // Why: the renderer-supplied worktreeId contains a filesystem path. @@ -953,9 +1045,7 @@ export function registerWorktreeHandlers( if (!registeredWorktree) { const fsProvider = repo.connectionId ? getSshFilesystemProvider(repo.connectionId) : null let canCleanOrphanedDirectory = false - const knownOrcaLayouts = repo.connectionId - ? [] - : buildKnownOrcaWorkspaceLayouts(store.getSettings(), repo) + const knownOrcaLayouts = buildKnownOrcaWorkspaceLayouts(store.getSettings(), repo) if ( canCleanupUnregisteredOrcaWorktreeDirectory({ meta: removedMeta, @@ -1009,10 +1099,10 @@ export function registerWorktreeHandlers( invalidateAuthorizedRootsCache() } runtime.clearOptimisticReconcileToken(args.worktreeId) - store.removeWorktreeMeta(args.worktreeId) - deleteWorktreeHistoryDir(args.worktreeId) + removeWorktreeMetadataAndTransientState(store, args.worktreeId) + preservedBranchCleanupByWorktreeId.delete(args.worktreeId) notifyWorktreesChanged(mainWindow, repoId) - return + return {} } if (args.force && (await isAlreadyRemovedWorktreePath(repo, worktreePath))) { // Why: Force-delete can be retried from stale UI after a prior delete @@ -1036,10 +1126,10 @@ export function registerWorktreeHandlers( invalidateAuthorizedRootsCache() } runtime.clearOptimisticReconcileToken(args.worktreeId) - store.removeWorktreeMeta(args.worktreeId) - deleteWorktreeHistoryDir(args.worktreeId) + removeWorktreeMetadataAndTransientState(store, args.worktreeId) + preservedBranchCleanupByWorktreeId.delete(args.worktreeId) notifyWorktreesChanged(mainWindow, repoId) - return + return {} } throw new Error(`Refusing to delete unregistered worktree path: ${worktreePath}`) } @@ -1074,9 +1164,13 @@ export function registerWorktreeHandlers( } } - await (deleteBranch + const rawRemovalResult = await (deleteBranch ? provider!.removeWorktree(canonicalWorktreePath, args.force) : provider!.removeWorktree(canonicalWorktreePath, args.force, { deleteBranch })) + const removalResult = preserveBranchHeadFallback( + rawRemovalResult, + registeredWorktree.head + ) await cleanupUnusedWorktreePushTargetRemoteSsh( provider!, repo.path, @@ -1084,11 +1178,16 @@ export function registerWorktreeHandlers( removedPushTarget, store ) + rememberPreservedBranchCleanupTarget( + args.worktreeId, + removalResult, + registeredWorktree.head, + removedPushTarget + ) runtime.clearOptimisticReconcileToken(args.worktreeId) - store.removeWorktreeMeta(args.worktreeId) - deleteWorktreeHistoryDir(args.worktreeId) + removeWorktreeMetadataAndTransientState(store, args.worktreeId) notifyWorktreesChanged(mainWindow, repoId) - return + return removalResult ?? {} } // Why: `git worktree remove` (non-force) refuses to delete a worktree @@ -1133,12 +1232,16 @@ export function registerWorktreeHandlers( }) } + let removalResult: RemoveWorktreeResult | undefined try { - await (deleteBranch - ? removeWorktree(repo.path, canonicalWorktreePath, args.force ?? false) - : removeWorktree(repo.path, canonicalWorktreePath, args.force ?? false, { - deleteBranch - })) + removalResult = preserveBranchHeadFallback( + await (deleteBranch + ? removeWorktree(repo.path, canonicalWorktreePath, args.force ?? false) + : removeWorktree(repo.path, canonicalWorktreePath, args.force ?? false, { + deleteBranch + })), + registeredWorktree.head + ) } catch (error) { // If git no longer tracks this worktree, clean up the directory and metadata if (isOrphanedWorktreeError(error)) { @@ -1164,11 +1267,11 @@ export function registerWorktreeHandlers( store ) runtime.clearOptimisticReconcileToken(args.worktreeId) - store.removeWorktreeMeta(args.worktreeId) - deleteWorktreeHistoryDir(args.worktreeId) + removeWorktreeMetadataAndTransientState(store, args.worktreeId) + preservedBranchCleanupByWorktreeId.delete(args.worktreeId) invalidateAuthorizedRootsCache() notifyWorktreesChanged(mainWindow, repoId) - return + return {} } throw new Error( formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false) @@ -1180,16 +1283,22 @@ export function registerWorktreeHandlers( removedPushTarget, store ) + rememberPreservedBranchCleanupTarget( + args.worktreeId, + removalResult, + registeredWorktree.head, + removedPushTarget + ) runtime.clearOptimisticReconcileToken(args.worktreeId) - store.removeWorktreeMeta(args.worktreeId) - deleteWorktreeHistoryDir(args.worktreeId) + removeWorktreeMetadataAndTransientState(store, args.worktreeId) invalidateAuthorizedRootsCache() notifyWorktreesChanged(mainWindow, repoId) + return removalResult ?? {} })() worktreeRemovalsInFlight.set(args.worktreeId, { optionsKey, promise: removal }) try { - await removal + return await removal } finally { if (worktreeRemovalsInFlight.get(args.worktreeId)?.promise === removal) { worktreeRemovalsInFlight.delete(args.worktreeId) @@ -1198,6 +1307,56 @@ export function registerWorktreeHandlers( } ) + ipcMain.handle( + 'worktrees:forceDeletePreservedBranch', + async ( + _event, + args: { worktreeId: string; branchName: string; expectedHead: string } + ): Promise => { + const { repoId } = parseWorktreeId(args.worktreeId) + const cleanupTarget = getPreservedBranchCleanupTarget( + args.worktreeId, + args.branchName, + args.expectedHead + ) + const repo = store.getRepo(repoId) + if (!repo) { + throw new Error(`Repo not found: ${repoId}`) + } + if (isFolderRepo(repo)) { + throw new Error('Folder workspaces do not have local Git branches.') + } + + if (repo.connectionId) { + const provider = requireSshGitProvider(repo.connectionId) + await forceDeleteLocalBranch( + repo.path, + cleanupTarget.branchName, + cleanupTarget.head, + (argv, cwd) => provider.exec(argv, cwd) + ) + await cleanupUnusedWorktreePushTargetRemoteSsh( + provider, + repo.path, + args.worktreeId, + cleanupTarget.pushTarget, + store + ) + } else { + await forceDeleteLocalBranch(repo.path, cleanupTarget.branchName, cleanupTarget.head) + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + args.worktreeId, + cleanupTarget.pushTarget, + store + ) + } + + preservedBranchCleanupByWorktreeId.delete(args.worktreeId) + return { deleted: true } + } + ) + ipcMain.handle( 'worktrees:updateMeta', (_event, args: { worktreeId: string; updates: Partial }) => { @@ -1258,23 +1417,29 @@ export function registerWorktreeHandlers( ipcMain.handle('hooks:check', async (_event, args: { repoId: string }) => { const repo = store.getRepo(args.repoId) if (!repo || isFolderRepo(repo)) { - return { hasHooks: false, hooks: null, mayNeedUpdate: false } + return { status: 'ok', hasHooks: false, hooks: null, mayNeedUpdate: false } } if (repo.connectionId) { const fsProvider = getSshFilesystemProvider(repo.connectionId) if (!fsProvider) { - return { hasHooks: false, hooks: null, mayNeedUpdate: false } + return { status: 'error', hasHooks: false, hooks: null, mayNeedUpdate: false } } try { const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) return { + status: 'ok', hasHooks: !result.isBinary, hooks: result.isBinary ? null : parseOrcaYaml(result.content), mayNeedUpdate: false } - } catch { - return { hasHooks: false, hooks: null, mayNeedUpdate: false } + } catch (error) { + return { + status: isENOENT(error) ? 'ok' : 'error', + hasHooks: false, + hooks: null, + mayNeedUpdate: false + } } } @@ -1286,6 +1451,7 @@ export function registerWorktreeHandlers( // instead of implying the file is broken. const mayNeedUpdate = has && !hooks && hasUnrecognizedOrcaYamlKeys(repo.path) return { + status: 'ok', hasHooks: has, hooks, mayNeedUpdate @@ -1310,36 +1476,66 @@ export function registerWorktreeHandlers( return [] } - return inspectSetupScriptImportCandidates(async (relativePath) => { - const filePath = joinWorktreeRelativePath(repo.path, relativePath) - if (repo.connectionId) { - const fsProvider = getSshFilesystemProvider(repo.connectionId) - if (!fsProvider) { - return null + return inspectSetupScriptImportCandidates( + async (relativePath) => { + const filePath = joinWorktreeRelativePath(repo.path, relativePath) + if (repo.connectionId) { + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + return null + } + try { + const result = await fsProvider.readFile(filePath) + return result.isBinary ? null : result.content + } catch { + return null + } } - try { - const result = await fsProvider.readFile(filePath) - return result.isBinary ? null : result.content - } catch { - return null - } - } - try { - return await readFile(filePath, 'utf-8') - } catch (error) { - if (!isENOENT(error)) { - console.warn('[hooks] Failed to inspect setup script import candidate:', error) + try { + return await readFile(filePath, 'utf-8') + } catch (error) { + if (!isENOENT(error)) { + console.warn('[hooks] Failed to inspect setup script import candidate:', error) + } + return null + } + }, + { + fileExists: async (relativePath) => { + const filePath = joinWorktreeRelativePath(repo.path, relativePath) + if (repo.connectionId) { + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + return false + } + try { + const fileStat = await fsProvider.stat(filePath) + return fileStat.type !== 'directory' + } catch { + return false + } + } + + try { + const fileStat = await stat(filePath) + return !fileStat.isDirectory() + } catch (error) { + if (!isENOENT(error)) { + console.warn('[hooks] Failed to stat setup script import candidate:', error) + } + return false + } } - return null } - }) + ) }) - ipcMain.handle('hooks:readIssueCommand', (_event, args: { repoId: string }) => { + ipcMain.handle('hooks:readIssueCommand', async (_event, args: { repoId: string }) => { const repo = store.getRepo(args.repoId) if (!repo || isFolderRepo(repo)) { return { + status: 'ok', localContent: null, sharedContent: null, effectiveContent: null, @@ -1347,14 +1543,100 @@ export function registerWorktreeHandlers( source: 'none' as const } } + if (repo.connectionId) { + const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command') + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + return { + status: 'error', + localContent: null, + sharedContent: null, + effectiveContent: null, + localFilePath: issueCommandPath, + source: 'none' as const + } + } + + let status: 'ok' | 'error' = 'ok' + let localContent: string | null = null + let sharedContent: string | null = null + try { + const result = await fsProvider.readFile(issueCommandPath) + localContent = result.isBinary ? null : result.content.trim() || null + } catch (error) { + if (!isENOENT(error)) { + status = 'error' + } + } + try { + const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) + sharedContent = result.isBinary + ? null + : parseOrcaYaml(result.content)?.issueCommand?.trim() || null + } catch (error) { + if (!isENOENT(error)) { + status = 'error' + } + } + const effectiveContent = localContent ?? sharedContent + return { + status: localContent ? 'ok' : status, + localContent, + sharedContent, + effectiveContent, + localFilePath: issueCommandPath, + source: localContent + ? ('local' as const) + : sharedContent + ? ('shared' as const) + : ('none' as const) + } + } return readIssueCommand(repo.path) }) - ipcMain.handle('hooks:writeIssueCommand', (_event, args: { repoId: string; content: string }) => { - const repo = store.getRepo(args.repoId) - if (!repo || isFolderRepo(repo)) { - return + ipcMain.handle( + 'hooks:writeIssueCommand', + async (_event, args: { repoId: string; content: string }) => { + const repo = store.getRepo(args.repoId) + if (!repo || isFolderRepo(repo)) { + return + } + if (repo.connectionId) { + const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command') + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + throw new Error( + 'Remote filesystem unavailable. Reconnect the SSH target before retrying.' + ) + } + const trimmed = args.content.trim() + if (!trimmed) { + await fsProvider.deletePath(issueCommandPath, false).catch((error: unknown) => { + if (!isENOENT(error)) { + throw error + } + }) + return + } + await fsProvider.createDir(joinWorktreeRelativePath(repo.path, '.orca')) + const gitignorePath = joinWorktreeRelativePath(repo.path, '.gitignore') + try { + const result = await fsProvider.readFile(gitignorePath) + if (!result.isBinary && !/^\.orca\/?$/m.test(result.content)) { + const separator = result.content.endsWith('\n') ? '' : '\n' + await fsProvider.writeFile(gitignorePath, `${result.content}${separator}.orca\n`) + } + } catch (error) { + if (!isENOENT(error)) { + throw error + } + await fsProvider.writeFile(gitignorePath, '.orca\n') + } + await fsProvider.writeFile(issueCommandPath, `${trimmed}\n`) + return + } + writeIssueCommand(repo.path, args.content) } - writeIssueCommand(repo.path, args.content) - }) + ) } diff --git a/src/main/linear/client.ts b/src/main/linear/client.ts index e5924176e17..f32d96a4a0f 100644 --- a/src/main/linear/client.ts +++ b/src/main/linear/client.ts @@ -160,7 +160,11 @@ function normalizeWorkspace(input: unknown): LinearWorkspace | null { organizationUrlKey: typeof record.organizationUrlKey === 'string' ? record.organizationUrlKey : undefined, displayName: record.displayName, - email: typeof record.email === 'string' ? record.email : null + email: typeof record.email === 'string' ? record.email : null, + credentialRevision: + typeof record.credentialRevision === 'number' && Number.isFinite(record.credentialRevision) + ? record.credentialRevision + : undefined } } @@ -431,8 +435,11 @@ function workspaceFromLinearData( function upsertWorkspace(workspace: LinearWorkspace, options: { select?: boolean } = {}): void { const file = getWorkspaceFile() + const current = file.workspaces.find((entry) => entry.id === workspace.id) + const credentialRevision = (current?.credentialRevision ?? 0) + 1 + const workspaceWithRevision = { ...workspace, credentialRevision } const withoutCurrent = file.workspaces.filter((entry) => entry.id !== workspace.id) - const workspaces = [...withoutCurrent, workspace].sort((a, b) => + const workspaces = [...withoutCurrent, workspaceWithRevision].sort((a, b) => a.organizationName.localeCompare(b.organizationName) ) const selectedWorkspaceId = options.select diff --git a/src/main/linear/projects.ts b/src/main/linear/projects.ts index f5b9f7d80a5..847425262a7 100644 --- a/src/main/linear/projects.ts +++ b/src/main/linear/projects.ts @@ -1,53 +1,951 @@ -import type { Project, ProjectSearchResult } from '@linear/sdk' -import type { LinearProjectSummary, LinearWorkspaceSelection } from '../../shared/types' -import { acquire, clearToken, getClients, isAuthError, release } from './client' +/* eslint-disable max-lines -- Why: Linear project and custom-view reads share + raw GraphQL selection sets, workspace fan-out, and partial-failure mapping. */ +import type { + LinearCollectionResult, + LinearConcreteWorkspaceId, + LinearCustomViewModel, + LinearCustomViewSummary, + LinearIssue, + LinearProjectDetail, + LinearProjectMemberSummary, + LinearProjectSummary, + LinearWorkspaceError, + LinearWorkspaceSelection +} from '../../shared/types' +import { + acquire, + clearToken, + getClients, + isAuthError, + release, + type LinearClientForWorkspace +} from './client' -function mapLinearProject(project: Project | ProjectSearchResult): LinearProjectSummary { +type LinearRawVariables = Record + +type PageInfoNode = { + hasNextPage?: boolean | null +} + +type LinearConnection = { + nodes?: T[] | null + pageInfo?: PageInfoNode | null +} + +type LinearUserNode = { + id: string + displayName?: string | null + avatarUrl?: string | null +} + +type LinearProjectNode = { + id: string + name: string + description?: string | null + content?: string | null + url?: string | null + color?: string | null + icon?: string | null + health?: string | null + priority?: number | null + priorityLabel?: string | null + progress?: number | null + scope?: number | null + issueCountHistory?: number[] | null + completedIssueCountHistory?: number[] | null + startDate?: string | null + targetDate?: string | null + createdAt?: string | null + updatedAt?: string | null + completedAt?: string | null + canceledAt?: string | null + startedAt?: string | null + status?: { + id: string + name?: string | null + type?: string | null + color?: string | null + } | null + lead?: LinearUserNode | null + members?: LinearConnection | null + teams?: LinearConnection<{ id: string; name?: string | null; key?: string | null }> | null + labels?: LinearConnection<{ id: string; name?: string | null; color?: string | null }> | null + projectMilestones?: LinearConnection<{ + id: string + name?: string | null + status?: string | null + targetDate?: string | null + progress?: number | null + }> | null + externalLinks?: LinearConnection<{ + id: string + label?: string | null + url?: string | null + }> | null + lastUpdate?: { + id: string + body?: string | null + health?: string | null + url?: string | null + createdAt?: string | null + updatedAt?: string | null + user?: LinearUserNode | null + } | null +} + +type LinearIssueNode = { + id: string + identifier: string + title: string + description?: string | null + url: string + estimate?: number | null + priority: number + updatedAt: string + labelIds?: string[] | null + state?: { + name?: string | null + type?: string | null + color?: string | null + } | null + team?: { + id?: string | null + name?: string | null + key?: string | null + } | null + assignee?: LinearUserNode | null + labels?: LinearConnection<{ id: string; name: string }> | null +} + +type LinearCustomViewNode = { + id: string + name: string + description?: string | null + modelName?: string | null + color?: string | null + icon?: string | null + shared?: boolean | null + slugId?: string | null + createdAt?: string | null + updatedAt?: string | null + team?: { id: string; name?: string | null; key?: string | null } | null + owner?: LinearUserNode | null + creator?: LinearUserNode | null +} + +type ProjectConnectionResponse = { + projects?: LinearConnection | null + searchProjects?: LinearConnection | null + project?: LinearProjectNode | null +} + +type ProjectIssueConnectionResponse = { + project?: { + issues?: LinearConnection | null + } | null +} + +type CustomViewConnectionResponse = { + customViews?: LinearConnection | null + customView?: + | (LinearCustomViewNode & { + issues?: LinearConnection | null + projects?: LinearConnection | null + }) + | null +} + +const ORCA_PROJECT_FIELDS = ` + id + name + description + content + url + color + icon + health + priority + priorityLabel + progress + scope + issueCountHistory + completedIssueCountHistory + startDate + targetDate + createdAt + updatedAt + completedAt + canceledAt + startedAt + status { + id + name + type + color + } + lead { + id + displayName + avatarUrl + } + members(first: 10) { + nodes { + id + displayName + avatarUrl + } + } + teams(first: 10) { + nodes { + id + name + key + } + } + labels(first: 20) { + nodes { + id + name + color + } + } +` + +const ORCA_PROJECT_DETAIL_FIELDS = ` + ${ORCA_PROJECT_FIELDS} + projectMilestones(first: 20) { + nodes { + id + name + status + targetDate + progress + } + } + externalLinks(first: 20) { + nodes { + id + label + url + } + } + lastUpdate { + id + body + health + url + createdAt + updatedAt + user { + id + displayName + avatarUrl + } + } +` + +const ORCA_ISSUE_FIELDS = ` + id + identifier + title + description + url + priority + estimate + updatedAt + labelIds + state { + name + type + color + } + team { + id + name + key + } + assignee { + id + displayName + avatarUrl + } + labels(first: 50) { + nodes { + id + name + } + } +` + +const PROJECTS_QUERY = ` + query OrcaLinearProjects($first: Int, $filter: ProjectFilter, $orderBy: PaginationOrderBy) { + projects(first: $first, filter: $filter, orderBy: $orderBy) { + nodes { + ${ORCA_PROJECT_FIELDS} + } + pageInfo { + hasNextPage + } + } + } +` + +const SEARCH_PROJECTS_QUERY = ` + query OrcaLinearProjectSearch($term: String!, $first: Int) { + searchProjects(term: $term, first: $first) { + nodes { + ${ORCA_PROJECT_FIELDS} + } + pageInfo { + hasNextPage + } + } + } +` + +const PROJECT_QUERY = ` + query OrcaLinearProject($id: String!) { + project(id: $id) { + ${ORCA_PROJECT_DETAIL_FIELDS} + } + } +` + +const PROJECT_ISSUES_QUERY = ` + query OrcaLinearProjectIssues($id: String!, $first: Int, $orderBy: PaginationOrderBy) { + project(id: $id) { + issues(first: $first, orderBy: $orderBy) { + nodes { + ${ORCA_ISSUE_FIELDS} + } + pageInfo { + hasNextPage + } + } + } + } +` + +const CUSTOM_VIEWS_QUERY = ` + query OrcaLinearCustomViews( + $first: Int, + $filter: CustomViewFilter, + $orderBy: PaginationOrderBy + ) { + customViews(first: $first, filter: $filter, orderBy: $orderBy) { + nodes { + id + name + description + modelName + color + icon + shared + slugId + createdAt + updatedAt + team { + id + name + key + } + owner { + id + displayName + avatarUrl + } + creator { + id + displayName + avatarUrl + } + } + pageInfo { + hasNextPage + } + } + } +` + +const CUSTOM_VIEW_QUERY = ` + query OrcaLinearCustomView($id: String!) { + customView(id: $id) { + id + name + description + modelName + color + icon + shared + slugId + createdAt + updatedAt + team { + id + name + key + } + owner { + id + displayName + avatarUrl + } + creator { + id + displayName + avatarUrl + } + } + } +` + +const CUSTOM_VIEW_ISSUES_QUERY = ` + query OrcaLinearCustomViewIssues($id: String!, $first: Int, $orderBy: PaginationOrderBy) { + customView(id: $id) { + id + modelName + issues(first: $first, orderBy: $orderBy) { + nodes { + ${ORCA_ISSUE_FIELDS} + } + pageInfo { + hasNextPage + } + } + } + } +` + +const CUSTOM_VIEW_PROJECTS_QUERY = ` + query OrcaLinearCustomViewProjects($id: String!, $first: Int, $orderBy: PaginationOrderBy) { + customView(id: $id) { + id + modelName + projects(first: $first, orderBy: $orderBy) { + nodes { + ${ORCA_PROJECT_FIELDS} + } + pageInfo { + hasNextPage + } + } + } + } +` + +const inFlight = new Map>() + +function clampLimit(limit = 20): number { + return Math.min(Math.max(1, Math.floor(limit)), 50) +} + +function coalesce(key: string, load: () => Promise): Promise { + const existing = inFlight.get(key) as Promise | undefined + if (existing) { + return existing + } + const promise = load().finally(() => inFlight.delete(key)) + inFlight.set(key, promise) + return promise +} + +function normalizeConcreteWorkspaceId(workspaceId: unknown): LinearConcreteWorkspaceId { + if (typeof workspaceId !== 'string' || !workspaceId.trim() || workspaceId === 'all') { + throw new Error('Concrete Linear workspace ID is required') + } + return workspaceId.trim() +} + +function workspaceError(entry: LinearClientForWorkspace, error: unknown): LinearWorkspaceError { + if (isAuthError(error)) { + return { + workspaceId: entry.workspace.id, + workspaceName: entry.workspace.organizationName, + type: 'auth', + message: 'Linear authentication expired for this workspace.' + } + } + + const record = error as { name?: string; message?: string; status?: number; response?: unknown } + const message = record.message || 'Linear request failed.' + const status = + typeof record.status === 'number' + ? record.status + : typeof (record.response as { status?: unknown } | undefined)?.status === 'number' + ? ((record.response as { status: number }).status as number) + : undefined + const name = record.name ?? '' + + if (status === 429 || /rate/i.test(name)) { + return { + workspaceId: entry.workspace.id, + workspaceName: entry.workspace.organizationName, + type: 'rate_limited', + message + } + } + if ((typeof status === 'number' && status >= 500) || /network/i.test(name)) { + return { + workspaceId: entry.workspace.id, + workspaceName: entry.workspace.organizationName, + type: 'network', + message + } + } + + return { + workspaceId: entry.workspace.id, + workspaceName: entry.workspace.organizationName, + type: 'unknown', + message + } +} + +function shouldFailWholeRequest(selection: LinearWorkspaceSelection | null | undefined): boolean { + return selection !== 'all' +} + +function lastNumericValue(values?: number[] | null): number | undefined { + const last = values?.at(-1) + return typeof last === 'number' ? last : undefined +} + +function mapUser(user?: LinearUserNode | null): LinearProjectMemberSummary | undefined { + if (!user?.id) { + return undefined + } + return { + id: user.id, + displayName: user.displayName ?? '', + avatarUrl: user.avatarUrl ?? undefined + } +} + +function mapProjectForWorkspace( + entry: LinearClientForWorkspace, + project: LinearProjectNode +): LinearProjectSummary { return { id: project.id, + workspaceId: entry.workspace.id, + workspaceName: entry.workspace.organizationName, name: project.name, url: project.url ?? undefined, - color: project.color ?? undefined + color: project.color ?? undefined, + icon: project.icon ?? undefined, + description: project.description ?? undefined, + content: project.content ?? undefined, + status: project.status + ? { + id: project.status.id, + name: project.status.name ?? '', + type: project.status.type ?? undefined, + color: project.status.color ?? undefined + } + : undefined, + health: project.health ?? null, + priority: project.priority ?? null, + priorityLabel: project.priorityLabel ?? null, + lead: mapUser(project.lead), + members: project.members?.nodes + ?.map(mapUser) + .filter((user): user is LinearProjectMemberSummary => !!user), + teams: project.teams?.nodes?.map((team) => ({ + id: team.id, + name: team.name ?? '', + key: team.key ?? undefined + })), + labels: project.labels?.nodes?.map((label) => ({ + id: label.id, + name: label.name ?? '', + color: label.color ?? undefined + })), + startDate: project.startDate ?? null, + targetDate: project.targetDate ?? null, + createdAt: project.createdAt ?? undefined, + updatedAt: project.updatedAt ?? undefined, + completedAt: project.completedAt ?? null, + canceledAt: project.canceledAt ?? null, + startedAt: project.startedAt ?? null, + progress: project.progress ?? null, + scope: project.scope ?? null, + issueCount: lastNumericValue(project.issueCountHistory), + completedIssueCount: lastNumericValue(project.completedIssueCountHistory) } } +function mapProjectDetailForWorkspace( + entry: LinearClientForWorkspace, + project: LinearProjectNode +): LinearProjectDetail { + return { + ...mapProjectForWorkspace(entry, project), + milestones: project.projectMilestones?.nodes?.map((milestone) => ({ + id: milestone.id, + name: milestone.name ?? '', + status: milestone.status ?? undefined, + targetDate: milestone.targetDate ?? null, + progress: milestone.progress ?? null + })), + resources: project.externalLinks?.nodes + ?.filter((link) => link.url) + .map((link) => ({ + id: link.id, + title: link.label || link.url || 'Link', + url: link.url!, + type: 'link' + })), + latestUpdate: project.lastUpdate + ? { + id: project.lastUpdate.id, + body: project.lastUpdate.body ?? undefined, + health: project.lastUpdate.health ?? null, + url: project.lastUpdate.url ?? undefined, + createdAt: project.lastUpdate.createdAt ?? undefined, + updatedAt: project.lastUpdate.updatedAt ?? undefined, + user: mapUser(project.lastUpdate.user) + } + : undefined + } +} + +function mapIssueForWorkspace( + entry: LinearClientForWorkspace, + issue: LinearIssueNode +): LinearIssue { + const labelNodes = issue.labels?.nodes ?? [] + return { + id: issue.id, + identifier: issue.identifier, + title: issue.title, + description: issue.description ?? undefined, + url: issue.url, + state: { + name: issue.state?.name ?? '', + type: issue.state?.type ?? '', + color: issue.state?.color ?? '' + }, + team: { + id: issue.team?.id ?? '', + name: issue.team?.name ?? '', + key: issue.team?.key ?? '' + }, + labels: labelNodes.map((label) => label.name), + labelIds: issue.labelIds ?? labelNodes.map((label) => label.id), + assignee: mapUser(issue.assignee), + estimate: issue.estimate ?? null, + priority: issue.priority, + updatedAt: issue.updatedAt, + workspaceId: entry.workspace.id, + workspaceName: entry.workspace.organizationName + } +} + +function mapCustomViewModel(modelName?: string | null): LinearCustomViewModel | null { + const normalized = modelName?.toLowerCase() + if (normalized === 'issue') { + return 'issue' + } + if (normalized === 'project') { + return 'project' + } + return null +} + +function mapCustomViewForWorkspace( + entry: LinearClientForWorkspace, + view: LinearCustomViewNode +): LinearCustomViewSummary | null { + const model = mapCustomViewModel(view.modelName) + if (!model) { + return null + } + return { + id: view.id, + workspaceId: entry.workspace.id, + workspaceName: entry.workspace.organizationName, + name: view.name, + description: view.description ?? undefined, + model, + url: view.slugId + ? `https://linear.app/${entry.workspace.organizationUrlKey}/view/${view.slugId}` + : undefined, + color: view.color ?? undefined, + icon: view.icon ?? undefined, + shared: view.shared ?? undefined, + team: view.team + ? { + id: view.team.id, + name: view.team.name ?? undefined, + key: view.team.key ?? undefined + } + : undefined, + owner: mapUser(view.owner), + creator: mapUser(view.creator), + createdAt: view.createdAt ?? undefined, + updatedAt: view.updatedAt ?? undefined + } +} + +async function readCollection( + key: string, + workspaceId: LinearWorkspaceSelection | null | undefined, + load: (entry: LinearClientForWorkspace) => Promise> +): Promise> { + return coalesce(key, async () => { + const entries = getClients(workspaceId) + if (entries.length === 0) { + return { items: [] } + } + + const results = await Promise.all( + entries.map(async (entry) => { + await acquire() + try { + return await load(entry) + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.workspace.id) + } else { + console.warn('[linear] project/view read failed:', error) + } + if (shouldFailWholeRequest(workspaceId)) { + throw error + } + return { items: [], errors: [workspaceError(entry, error)] } + } finally { + release() + } + }) + ) + + return { + items: results.flatMap((result) => result.items), + errors: results.flatMap((result) => result.errors ?? []).length + ? results.flatMap((result) => result.errors ?? []) + : undefined, + hasMore: results.some((result) => result.hasMore) + } + }) +} + +async function readConcreteCollection( + key: string, + workspaceId: LinearConcreteWorkspaceId, + load: (entry: LinearClientForWorkspace) => Promise> +): Promise> { + const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) + return readCollection(key, concreteWorkspaceId, load) +} + export async function listProjects( query: string | undefined, limit = 20, workspaceId?: LinearWorkspaceSelection | null -): Promise { +): Promise> { + const first = clampLimit(limit) const trimmed = query?.trim() + const key = `listProjects:${workspaceId ?? 'default'}:${trimmed ?? ''}:${first}` + return readCollection(key, workspaceId, async (entry) => { + const variables = trimmed ? { term: trimmed, first } : { first, orderBy: 'updatedAt' } + const result = await entry.client.client.rawRequest< + ProjectConnectionResponse, + LinearRawVariables + >(trimmed ? SEARCH_PROJECTS_QUERY : PROJECTS_QUERY, variables) + const connection = trimmed ? result.data?.searchProjects : result.data?.projects + return { + items: (connection?.nodes ?? []).map((project) => mapProjectForWorkspace(entry, project)), + hasMore: !!connection?.pageInfo?.hasNextPage + } + }) +} - const entries = getClients(workspaceId) - if (entries.length === 0) { - return [] +export async function getProject( + id: string, + workspaceId: LinearConcreteWorkspaceId +): Promise { + const projectId = id.trim() + const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) + if (!projectId) { + throw new Error('Project ID is required') } + const key = `getProject:${concreteWorkspaceId}:${projectId}` + return coalesce(key, async () => { + const entries = getClients(concreteWorkspaceId) + const entry = entries[0] + if (!entry) { + return null + } + await acquire() + try { + const result = await entry.client.client.rawRequest< + ProjectConnectionResponse, + LinearRawVariables + >(PROJECT_QUERY, { id: projectId }) + return result.data?.project ? mapProjectDetailForWorkspace(entry, result.data.project) : null + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.workspace.id) + } + throw error + } finally { + release() + } + }) +} - const results = await Promise.all( - entries.map(async (entry) => { - await acquire() - try { - if (!trimmed) { - const connection = await entry.client.projects({ first: limit }) - return connection.nodes.map(mapLinearProject) - } - const connection = await entry.client.searchProjects(trimmed, { first: limit }) - return connection.nodes.map(mapLinearProject) - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.workspace.id) - if (workspaceId !== 'all') { - throw error - } - } else { - console.warn('[linear] listProjects failed:', error) - } - return [] - } finally { - release() - } - }) +export async function listProjectIssues( + projectId: string, + limit = 20, + workspaceId: LinearConcreteWorkspaceId +): Promise> { + const id = projectId.trim() + if (!id) { + throw new Error('Project ID is required') + } + const first = clampLimit(limit) + const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) + return readConcreteCollection( + `listProjectIssues:${concreteWorkspaceId}:${id}:${first}`, + concreteWorkspaceId, + async (entry) => { + const result = await entry.client.client.rawRequest< + ProjectIssueConnectionResponse, + LinearRawVariables + >(PROJECT_ISSUES_QUERY, { id, first, orderBy: 'updatedAt' }) + const project = result.data?.project + if (!project) { + throw new Error('Project was not found') + } + const connection = project.issues + return { + items: (connection?.nodes ?? []).map((issue) => mapIssueForWorkspace(entry, issue)), + hasMore: !!connection?.pageInfo?.hasNextPage + } + } ) +} + +export async function listCustomViews( + model: LinearCustomViewModel, + limit = 20, + workspaceId?: LinearWorkspaceSelection | null +): Promise> { + const first = clampLimit(limit) + const key = `listCustomViews:${workspaceId ?? 'default'}:${model}:${first}` + const filter = { modelName: { eq: model === 'project' ? 'Project' : 'Issue' } } + return readCollection(key, workspaceId, async (entry) => { + const result = await entry.client.client.rawRequest< + CustomViewConnectionResponse, + LinearRawVariables + >(CUSTOM_VIEWS_QUERY, { first, filter, orderBy: 'updatedAt' }) + const connection = result.data?.customViews + return { + items: (connection?.nodes ?? []) + .map((view) => mapCustomViewForWorkspace(entry, view)) + .filter((view): view is LinearCustomViewSummary => !!view && view.model === model), + hasMore: !!connection?.pageInfo?.hasNextPage + } + }) +} - return results.flat().slice(0, limit) +export async function getCustomView( + viewId: string, + model: LinearCustomViewModel, + workspaceId: LinearConcreteWorkspaceId +): Promise { + const id = viewId.trim() + const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) + if (!id) { + throw new Error('Custom view ID is required') + } + const key = `getCustomView:${concreteWorkspaceId}:${model}:${id}` + return coalesce(key, async () => { + const entries = getClients(concreteWorkspaceId) + const entry = entries[0] + if (!entry) { + return null + } + await acquire() + try { + const result = await entry.client.client.rawRequest< + CustomViewConnectionResponse, + LinearRawVariables + >(CUSTOM_VIEW_QUERY, { id }) + const view = result.data?.customView + const mapped = view ? mapCustomViewForWorkspace(entry, view) : null + return mapped?.model === model ? mapped : null + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.workspace.id) + } + throw error + } finally { + release() + } + }) +} + +export async function listCustomViewIssues( + viewId: string, + limit = 20, + workspaceId: LinearConcreteWorkspaceId +): Promise> { + const id = viewId.trim() + if (!id) { + throw new Error('Custom view ID is required') + } + const first = clampLimit(limit) + const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) + return readConcreteCollection( + `listCustomViewIssues:${concreteWorkspaceId}:${id}:${first}`, + concreteWorkspaceId, + async (entry) => { + const result = await entry.client.client.rawRequest< + CustomViewConnectionResponse, + LinearRawVariables + >(CUSTOM_VIEW_ISSUES_QUERY, { id, first, orderBy: 'updatedAt' }) + const view = result.data?.customView + if (mapCustomViewModel(view?.modelName) !== 'issue') { + throw new Error('Custom view does not contain issues') + } + const connection = view?.issues + return { + items: (connection?.nodes ?? []).map((issue) => mapIssueForWorkspace(entry, issue)), + hasMore: !!connection?.pageInfo?.hasNextPage + } + } + ) +} + +export async function listCustomViewProjects( + viewId: string, + limit = 20, + workspaceId: LinearConcreteWorkspaceId +): Promise> { + const id = viewId.trim() + if (!id) { + throw new Error('Custom view ID is required') + } + const first = clampLimit(limit) + const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) + return readConcreteCollection( + `listCustomViewProjects:${concreteWorkspaceId}:${id}:${first}`, + concreteWorkspaceId, + async (entry) => { + const result = await entry.client.client.rawRequest< + CustomViewConnectionResponse, + LinearRawVariables + >(CUSTOM_VIEW_PROJECTS_QUERY, { id, first, orderBy: 'updatedAt' }) + const view = result.data?.customView + if (mapCustomViewModel(view?.modelName) !== 'project') { + throw new Error('Custom view does not contain projects') + } + const connection = view?.projects + return { + items: (connection?.nodes ?? []).map((project) => mapProjectForWorkspace(entry, project)), + hasMore: !!connection?.pageInfo?.hasNextPage + } + } + ) } diff --git a/src/main/linear/teams.test.ts b/src/main/linear/teams.test.ts new file mode 100644 index 00000000000..0ce71536bfd --- /dev/null +++ b/src/main/linear/teams.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { LinearClientForWorkspace } from './client' + +const getClients = vi.fn() +const clearToken = vi.fn() + +vi.mock('./client', () => ({ + acquire: vi.fn().mockResolvedValue(undefined), + release: vi.fn(), + getClients: (...args: unknown[]) => getClients(...args), + isAuthError: vi.fn().mockReturnValue(false), + clearToken: (...args: unknown[]) => clearToken(...args) +})) + +type TeamNode = { + id: string + name: string + key: string +} + +function team(id: string, name = id, key = id.toUpperCase()): TeamNode { + return { id, name, key } +} + +function makeTeamConnection(pages: TeamNode[][]) { + const nodes = [...(pages[0] ?? [])] + let pageIndex = 0 + return { + nodes, + pageInfo: { hasNextPage: pages.length > 1 }, + fetchNext: vi + .fn() + .mockImplementation( + async function fetchNext(this: { nodes: TeamNode[]; pageInfo: { hasNextPage: boolean } }) { + pageIndex += 1 + this.nodes.push(...(pages[pageIndex] ?? [])) + this.pageInfo.hasNextPage = pageIndex < pages.length - 1 + return this + } + ) + } +} + +function makeEntry( + workspaceId: string, + organizationName: string, + organizationUrlKey: string, + pages: TeamNode[][] +): LinearClientForWorkspace { + return { + workspace: { + id: workspaceId, + organizationId: workspaceId, + organizationName, + organizationUrlKey, + displayName: 'Ada', + email: 'ada@example.com' + }, + client: { + teams: vi.fn().mockResolvedValue(makeTeamConnection(pages)) + } + } as unknown as LinearClientForWorkspace +} + +describe('Linear teams', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('fetches every page of teams for a workspace', async () => { + const entry = makeEntry('workspace-1', 'Workspace', 'acme', [ + [team('team-1', 'Backend', 'BE')], + [team('team-2', 'Frontend', 'FE')], + [team('team-3', 'Support', 'SUP')] + ]) + getClients.mockReturnValue([entry]) + const { listTeams } = await import('./teams') + + await expect(listTeams('workspace-1')).resolves.toMatchObject([ + { id: 'team-1', url: 'https://linear.app/acme/team/BE/all' }, + { id: 'team-2', url: 'https://linear.app/acme/team/FE/all' }, + { id: 'team-3', url: 'https://linear.app/acme/team/SUP/all' } + ]) + + expect(entry.client.teams).toHaveBeenCalledWith({ first: 100 }) + }) + + it('aggregates teams across all selected workspaces', async () => { + getClients.mockReturnValue([ + makeEntry('workspace-1', 'Alpha', 'alpha', [[team('team-a', 'Alpha Team', 'ALP')]]), + makeEntry('workspace-2', 'Beta', 'beta', [[team('team-b', 'Beta Team', 'BET')]]) + ]) + const { listTeams } = await import('./teams') + + await expect(listTeams('all')).resolves.toMatchObject([ + { id: 'team-a', workspaceId: 'workspace-1', workspaceName: 'Alpha' }, + { id: 'team-b', workspaceId: 'workspace-2', workspaceName: 'Beta' } + ]) + }) +}) diff --git a/src/main/linear/teams.ts b/src/main/linear/teams.ts index 73913dec734..4448e1b161f 100644 --- a/src/main/linear/teams.ts +++ b/src/main/linear/teams.ts @@ -6,7 +6,35 @@ import type { LinearWorkspaceSelection } from '../../shared/types' import { buildLinearTeamUrl } from '../../shared/linear-links' -import { acquire, release, getClients, isAuthError, clearToken } from './client' +import { + acquire, + release, + getClients, + isAuthError, + clearToken, + type LinearClientForWorkspace +} from './client' + +const TEAM_PAGE_SIZE = 100 + +async function fetchAllTeamsForWorkspace(entry: LinearClientForWorkspace): Promise { + let page = await entry.client.teams({ first: TEAM_PAGE_SIZE }) + while (page.pageInfo.hasNextPage) { + await page.fetchNext() + } + return page.nodes.map((t) => ({ + id: t.id, + workspaceId: entry.workspace.id, + workspaceName: entry.workspace.organizationName, + name: t.name, + key: t.key, + url: + buildLinearTeamUrl({ + organizationUrlKey: entry.workspace.organizationUrlKey, + teamKey: t.key + }) ?? undefined + })) +} export async function listTeams( workspaceId?: LinearWorkspaceSelection | null @@ -20,19 +48,7 @@ export async function listTeams( entries.map(async (entry) => { await acquire() try { - const teams = await entry.client.teams() - return teams.nodes.map((t) => ({ - id: t.id, - workspaceId: entry.workspace.id, - workspaceName: entry.workspace.organizationName, - name: t.name, - key: t.key, - url: - buildLinearTeamUrl({ - organizationUrlKey: entry.workspace.organizationUrlKey, - teamKey: t.key - }) ?? undefined - })) + return fetchAllTeamsForWorkspace(entry) } catch (error) { if (isAuthError(error)) { clearToken(entry.workspace.id) diff --git a/src/main/linux-lid-sleep-assertion.test.ts b/src/main/linux-lid-sleep-assertion.test.ts index b55aa69aa18..4b2c3d29805 100644 --- a/src/main/linux-lid-sleep-assertion.test.ts +++ b/src/main/linux-lid-sleep-assertion.test.ts @@ -90,6 +90,24 @@ describe('LinuxLidSleepAssertion', () => { expect(child.kill).toHaveBeenCalledTimes(1) }) + it('removes child listeners when stopped intentionally', () => { + const child = new FakeSystemdInhibitProcess() + const assertion = new LinuxLidSleepAssertion({ + logger: createLogger(), + platform: 'linux', + spawn: vi.fn(() => child) + }) + + assertion.start('status-change') + expect(child.listenerCount('error')).toBe(1) + expect(child.listenerCount('exit')).toBe(1) + + assertion.stop('settings-change') + + expect(child.listenerCount('error')).toBe(0) + expect(child.listenerCount('exit')).toBe(0) + }) + it('does not report an intentional stop as a failed inhibitor', () => { const logger = createLogger() const child = new FakeSystemdInhibitProcess() @@ -153,6 +171,8 @@ describe('LinuxLidSleepAssertion', () => { expect(onUnexpectedFailure).toHaveBeenCalledWith('linux-lid-assertion-failure') expect(spawn).toHaveBeenCalledTimes(2) expect(logger.warn).toHaveBeenCalledTimes(1) + expect(firstChild.listenerCount('error')).toBe(0) + expect(firstChild.listenerCount('exit')).toBe(0) }) it('suppresses retry attempts until the shared retry gate expires', () => { diff --git a/src/main/linux-lid-sleep-assertion.ts b/src/main/linux-lid-sleep-assertion.ts index aef1d68a164..f59be5aba5d 100644 --- a/src/main/linux-lid-sleep-assertion.ts +++ b/src/main/linux-lid-sleep-assertion.ts @@ -4,12 +4,15 @@ export const LINUX_LID_SLEEP_ASSERTION_RETRY_MS = 30_000 type Logger = Pick +type SystemdInhibitErrorListener = (error: Error & { code?: string }) => void +type SystemdInhibitExitListener = (code: number | null, signal: NodeJS.Signals | null) => void + type SystemdInhibitProcess = { kill: () => boolean - on: { - (event: 'error', listener: (error: Error & { code?: string }) => void): void - (event: 'exit', listener: (code: number | null, signal: NodeJS.Signals | null) => void): void - } + on(event: 'error', listener: SystemdInhibitErrorListener): void + on(event: 'exit', listener: SystemdInhibitExitListener): void + off(event: 'error', listener: SystemdInhibitErrorListener): void + off(event: 'exit', listener: SystemdInhibitExitListener): void pid?: number } @@ -41,6 +44,7 @@ export class LinuxLidSleepAssertion { private warnedForLastFailure = false private readonly intentionalStops = new WeakSet() private readonly reportedFailures = new WeakSet() + private readonly childCleanups = new WeakMap void>() constructor(options: LinuxLidSleepAssertionOptions = {}) { this.logger = options.logger ?? console @@ -84,7 +88,7 @@ export class LinuxLidSleepAssertion { } this.child = child - child.on('error', (error) => { + const onError: SystemdInhibitErrorListener = (error) => { this.handleChildFailure( child, `error:${String(error.code ?? error.message)}`, @@ -92,13 +96,19 @@ export class LinuxLidSleepAssertion { reason, error ) - }) - child.on('exit', (code, signal) => { + } + const onExit: SystemdInhibitExitListener = (code, signal) => { this.handleChildFailure(child, `exit:${String(code)}:${String(signal)}`, 'exit', reason, { code, signal }) + } + this.childCleanups.set(child, () => { + child.off('error', onError) + child.off('exit', onExit) }) + child.on('error', onError) + child.on('exit', onExit) this.resetRetrySuppression() this.resetFailureStreak() } @@ -112,6 +122,7 @@ export class LinuxLidSleepAssertion { const child = this.child this.child = null this.intentionalStops.add(child) + this.detachChildListeners(child) try { child.kill() } catch (error) { @@ -132,6 +143,7 @@ export class LinuxLidSleepAssertion { startReason: string, details: unknown ): void { + this.detachChildListeners(child) if (this.intentionalStops.has(child)) { this.intentionalStops.delete(child) return @@ -146,6 +158,15 @@ export class LinuxLidSleepAssertion { this.handleFailure(failureKey, startReason, details, failureType) } + private detachChildListeners(child: SystemdInhibitProcess): void { + const cleanup = this.childCleanups.get(child) + if (!cleanup) { + return + } + cleanup() + this.childCleanups.delete(child) + } + private handleFailure( failureKey: string, reason: string, diff --git a/src/main/macos-system-sleep-assertion.test.ts b/src/main/macos-system-sleep-assertion.test.ts index 9baca8c564a..8d17485a245 100644 --- a/src/main/macos-system-sleep-assertion.test.ts +++ b/src/main/macos-system-sleep-assertion.test.ts @@ -79,6 +79,24 @@ describe('MacosSystemSleepAssertion', () => { expect(child.kill).toHaveBeenCalledTimes(1) }) + it('removes child listeners when stopped intentionally', () => { + const child = new FakeCaffeinateProcess() + const assertion = new MacosSystemSleepAssertion({ + logger: createLogger(), + platform: 'darwin', + spawn: vi.fn(() => child) + }) + + assertion.start('status-change') + expect(child.listenerCount('error')).toBe(1) + expect(child.listenerCount('exit')).toBe(1) + + assertion.stop('settings-change') + + expect(child.listenerCount('error')).toBe(0) + expect(child.listenerCount('exit')).toBe(0) + }) + it('clears the child and notifies the owner on unexpected exit', () => { const firstChild = new FakeCaffeinateProcess() const secondChild = new FakeCaffeinateProcess() @@ -101,6 +119,8 @@ describe('MacosSystemSleepAssertion', () => { expect(onUnexpectedFailure).toHaveBeenCalledWith('macos-assertion-failure') expect(spawn).toHaveBeenCalledTimes(2) + expect(firstChild.listenerCount('error')).toBe(0) + expect(firstChild.listenerCount('exit')).toBe(0) }) it('does not report an intentional stop as unexpected', () => { diff --git a/src/main/macos-system-sleep-assertion.ts b/src/main/macos-system-sleep-assertion.ts index dae26880b00..67a7567b138 100644 --- a/src/main/macos-system-sleep-assertion.ts +++ b/src/main/macos-system-sleep-assertion.ts @@ -4,12 +4,15 @@ export const MACOS_SYSTEM_SLEEP_ASSERTION_RETRY_MS = 30_000 type Logger = Pick +type CaffeinateErrorListener = (error: Error) => void +type CaffeinateExitListener = (code: number | null, signal: NodeJS.Signals | null) => void + type CaffeinateProcess = { kill: () => boolean - on: { - (event: 'error', listener: (error: Error) => void): void - (event: 'exit', listener: (code: number | null, signal: NodeJS.Signals | null) => void): void - } + on(event: 'error', listener: CaffeinateErrorListener): void + on(event: 'exit', listener: CaffeinateExitListener): void + off(event: 'error', listener: CaffeinateErrorListener): void + off(event: 'exit', listener: CaffeinateExitListener): void pid?: number } @@ -40,6 +43,7 @@ export class MacosSystemSleepAssertion { private warnedForLastFailure = false private readonly intentionalStops = new WeakSet() private readonly reportedFailures = new WeakSet() + private readonly childCleanups = new WeakMap void>() constructor(options: MacosSystemSleepAssertionOptions = {}) { this.logger = options.logger ?? console @@ -70,15 +74,21 @@ export class MacosSystemSleepAssertion { } this.child = child - child.on('error', (error) => { + const onError: CaffeinateErrorListener = (error) => { this.handleChildFailure(child, `error:${String(error.message)}`, 'error', reason, error) - }) - child.on('exit', (code, signal) => { + } + const onExit: CaffeinateExitListener = (code, signal) => { this.handleChildFailure(child, `exit:${String(code)}:${String(signal)}`, 'exit', reason, { code, signal }) + } + this.childCleanups.set(child, () => { + child.off('error', onError) + child.off('exit', onExit) }) + child.on('error', onError) + child.on('exit', onExit) this.resetRetrySuppression() this.resetFailureStreak() } @@ -92,6 +102,7 @@ export class MacosSystemSleepAssertion { const child = this.child this.child = null this.intentionalStops.add(child) + this.detachChildListeners(child) try { child.kill() } catch (error) { @@ -114,6 +125,7 @@ export class MacosSystemSleepAssertion { startReason: string, details: unknown ): void { + this.detachChildListeners(child) if (this.intentionalStops.has(child)) { this.intentionalStops.delete(child) return @@ -128,6 +140,15 @@ export class MacosSystemSleepAssertion { this.handleFailure(failureKey, startReason, details, failureType) } + private detachChildListeners(child: CaffeinateProcess): void { + const cleanup = this.childCleanups.get(child) + if (!cleanup) { + return + } + cleanup() + this.childCleanups.delete(child) + } + private handleFailure( failureKey: string, reason: string, diff --git a/src/main/memory/hydrate-local-pty-registry.test.ts b/src/main/memory/hydrate-local-pty-registry.test.ts index 3cb4f888e86..0854fa2493b 100644 --- a/src/main/memory/hydrate-local-pty-registry.test.ts +++ b/src/main/memory/hydrate-local-pty-registry.test.ts @@ -22,6 +22,8 @@ import type { unregisterPty as UnregisterFn } from './pty-registry' +const LARGE_SESSION_COUNT = 150_000 + // Why: the hydrator pulls the daemon provider through this module-level // getter. Stubbing it lets us drive the offline / throwing / live paths // without spinning up real sockets. @@ -64,6 +66,19 @@ function makeProvider(sessions: SessionInfo[]): Pick { expect(entry!.paneKey).toBe('tab-1:1') }) - it('skips SSH sessions (repo with non-null connectionId)', async () => { + it('skips SSH repos before enumerating worktrees', async () => { const { hydrate, listRegisteredPtys } = await loadFresh() const ptyId = 'repo-ssh::/remote/Stingray@@feedface' @@ -185,6 +200,7 @@ describe('hydrateLocalPtyRegistryAtBoot', () => { // visible to the local process sampler. Mirrors the spawn-time gate // around `registerPty` in `pty.ts`'s `pty:spawn` handler. expect(listRegisteredPtys()).toHaveLength(0) + expect(listRepoWorktreesMock).not.toHaveBeenCalled() }) it('registers a local session whose worktree is in the store with the daemon-published pid', async () => { @@ -206,4 +222,24 @@ describe('hydrateLocalPtyRegistryAtBoot', () => { expect(entry!.pid).toBe(4242) expect(entry!.worktreeId).toBe('repo-a::/local/Triton') }) + + it('hydrates large daemon session lists', async () => { + const { hydrate, listRegisteredPtys } = await loadFresh() + + const sessions = makeLocalSessions('repo-a', '/local/Triton', LARGE_SESSION_COUNT) + const provider = makeProvider(sessions) + getDaemonProviderMock.mockReturnValue(provider) + listRepoWorktreesMock.mockResolvedValue([ + { path: '/local/Triton', head: '', branch: '', isBare: false, isMainWorktree: true } + ]) + + await hydrate(makeStore([{ id: 'repo-a', connectionId: null }])) + + const registered = listRegisteredPtys() + expect(registered).toHaveLength(LARGE_SESSION_COUNT) + expect(registered[0]?.ptyId).toBe('repo-a::/local/Triton@@00000000') + expect(registered.at(-1)?.ptyId).toBe( + `repo-a::/local/Triton@@${(LARGE_SESSION_COUNT - 1).toString(16).padStart(8, '0')}` + ) + }) }) diff --git a/src/main/memory/hydrate-local-pty-registry.ts b/src/main/memory/hydrate-local-pty-registry.ts index bfa2d2d5052..d416c51392b 100644 --- a/src/main/memory/hydrate-local-pty-registry.ts +++ b/src/main/memory/hydrate-local-pty-registry.ts @@ -76,8 +76,13 @@ export async function hydrateLocalPtyRegistryAtBoot(store: Pick() for (const repo of repos) { - const worktrees = await listRepoWorktrees(repo) const connectionId = repo.connectionId ?? null + if (connectionId) { + // Why: SSH PTYs are never registered for local process sampling, so + // avoid startup SSH/git enumeration for repos we will skip anyway. + continue + } + const worktrees = await listRepoWorktrees(repo) for (const wt of worktrees) { const worktreeId = `${repo.id}::${wt.path}` repoConnectionIdByWorktreeId.set(worktreeId, connectionId) @@ -146,7 +151,11 @@ async function collectSessionInfos( for (const adapter of adapters) { try { const sessions = await adapter.listSessions() - out.push(...sessions) + // Why: warm reattach can discover many daemon sessions at once; spreading + // listSessions() into push can exceed JavaScript's argument limit. + for (const session of sessions) { + out.push(session) + } } catch (err) { // Why: a single adapter failing should not abort hydration of the // others — the current adapter and any legacy daemons each have diff --git a/src/main/network/macos-system-resolver-health.test.ts b/src/main/network/macos-system-resolver-health.test.ts index 508f28c4c2e..383270e68d5 100644 --- a/src/main/network/macos-system-resolver-health.test.ts +++ b/src/main/network/macos-system-resolver-health.test.ts @@ -1,5 +1,37 @@ -import { describe, expect, it } from 'vitest' -import { classifyMacSystemResolverHealth } from './macos-system-resolver-health' +import { EventEmitter } from 'events' +import { PassThrough } from 'stream' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { spawn } from 'child_process' +import { + classifyMacSystemResolverHealth, + readCurrentProcessMacSystemResolverHealth +} from './macos-system-resolver-health' +import type { ChildProcessWithoutNullStreams } from 'child_process' + +vi.mock('child_process', () => ({ + spawn: vi.fn() +})) + +function mockPlatform(platform: NodeJS.Platform): void { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) +} + +function createMockScutilProcess(): ChildProcessWithoutNullStreams { + const child = new EventEmitter() as ChildProcessWithoutNullStreams + child.stdout = new PassThrough() + child.stderr = new PassThrough() + child.kill = vi.fn(() => true) as ChildProcessWithoutNullStreams['kill'] + return child +} + +beforeEach(() => { + vi.useRealTimers() + vi.mocked(spawn).mockReset() +}) + +afterEach(() => { + vi.restoreAllMocks() +}) describe('classifyMacSystemResolverHealth', () => { it('treats the macOS no-resolver output as unhealthy', () => { @@ -22,3 +54,106 @@ resolver #1 expect(classifyMacSystemResolverHealth('')).toBe('unknown') }) }) + +describe('readCurrentProcessMacSystemResolverHealth', () => { + it('fails open without spawning scutil outside macOS', async () => { + mockPlatform('linux') + + await expect(readCurrentProcessMacSystemResolverHealth()).resolves.toBe('unknown') + + expect(spawn).not.toHaveBeenCalled() + }) + + it('reads scutil asynchronously so the daemon event loop can keep serving PTYs', async () => { + mockPlatform('darwin') + const child = createMockScutilProcess() + vi.mocked(spawn).mockReturnValue(child) + let settled = false + + const healthPromise = readCurrentProcessMacSystemResolverHealth().then((health) => { + settled = true + return health + }) + const timerFired = new Promise((resolve) => setTimeout(() => resolve(true), 0)) + + await expect(timerFired).resolves.toBe(true) + expect(settled).toBe(false) + expect(spawn).toHaveBeenCalledWith('/usr/sbin/scutil', ['--dns'], { + stdio: ['ignore', 'pipe', 'pipe'] + }) + + child.stdout.emit( + 'data', + ` +DNS configuration + +resolver #1 + nameserver[0] : 1.1.1.1 +` + ) + child.emit('close', 0) + + await expect(healthPromise).resolves.toBe('healthy') + }) + + it('settles at the timeout even if scutil has not closed yet', async () => { + vi.useFakeTimers() + mockPlatform('darwin') + const child = createMockScutilProcess() + vi.mocked(spawn).mockReturnValue(child) + + const healthPromise = readCurrentProcessMacSystemResolverHealth() + + await vi.advanceTimersByTimeAsync(1_500) + + await expect(healthPromise).resolves.toBe('unknown') + expect(child.kill).toHaveBeenCalledTimes(1) + }) + + it('removes scutil listeners when the timeout settles before child close', async () => { + vi.useFakeTimers() + mockPlatform('darwin') + const child = createMockScutilProcess() + vi.mocked(spawn).mockReturnValue(child) + + const healthPromise = readCurrentProcessMacSystemResolverHealth() + + expect(child.stdout.listenerCount('data')).toBe(1) + expect(child.stderr.listenerCount('data')).toBe(1) + expect(child.listenerCount('error')).toBe(1) + expect(child.listenerCount('close')).toBe(1) + + await vi.advanceTimersByTimeAsync(1_500) + await expect(healthPromise).resolves.toBe('unknown') + + expect(child.stdout.listenerCount('data')).toBe(0) + expect(child.stderr.listenerCount('data')).toBe(0) + expect(child.listenerCount('error')).toBe(0) + expect(child.listenerCount('close')).toBe(0) + }) + + it('removes scutil listeners when the child closes normally', async () => { + mockPlatform('darwin') + const child = createMockScutilProcess() + vi.mocked(spawn).mockReturnValue(child) + + const healthPromise = readCurrentProcessMacSystemResolverHealth() + + child.stdout.emit( + 'data', + ` +DNS configuration + +resolver #1 + nameserver[0] : 1.1.1.1 +` + ) + child.emit('close', 0) + + await expect(healthPromise).resolves.toBe('healthy') + expect(child.stdout.listenerCount('data')).toBe(0) + expect(child.stderr.listenerCount('data')).toBe(0) + expect(child.listenerCount('error')).toBe(0) + expect(child.listenerCount('close')).toBe(0) + }) +}) diff --git a/src/main/network/macos-system-resolver-health.ts b/src/main/network/macos-system-resolver-health.ts index 846eb1f3e0c..b14e7b9d419 100644 --- a/src/main/network/macos-system-resolver-health.ts +++ b/src/main/network/macos-system-resolver-health.ts @@ -1,4 +1,4 @@ -import { spawnSync } from 'child_process' +import { spawn } from 'child_process' import type { SystemResolverHealth } from '../daemon/types' const MAC_RESOLVER_CHECK_TIMEOUT_MS = 1_500 @@ -16,18 +16,51 @@ export function classifyMacSystemResolverHealth(scutilOutput: string): SystemRes return 'unknown' } -export function readCurrentProcessMacSystemResolverHealth(): SystemResolverHealth { +export async function readCurrentProcessMacSystemResolverHealth(): Promise { if (process.platform !== 'darwin') { return 'unknown' } - const result = spawnSync('/usr/sbin/scutil', ['--dns'], { - encoding: 'utf8', - timeout: MAC_RESOLVER_CHECK_TIMEOUT_MS, - stdio: ['ignore', 'pipe', 'pipe'] + return new Promise((resolve) => { + let stdout = '' + let stderr = '' + let settled = false + let timer: ReturnType | null = null + const child = spawn('/usr/sbin/scutil', ['--dns'], { + stdio: ['ignore', 'pipe', 'pipe'] + }) + const onStdoutData = (chunk: string): void => { + stdout += chunk + } + const onStderrData = (chunk: string): void => { + stderr += chunk + } + const finish = (): void => { + if (settled) { + return + } + settled = true + if (timer !== null) { + clearTimeout(timer) + timer = null + } + child.stdout.off('data', onStdoutData) + child.stderr.off('data', onStderrData) + child.off('error', finish) + child.off('close', finish) + resolve(classifyMacSystemResolverHealth(`${stdout}\n${stderr}`)) + } + timer = setTimeout(() => { + child.kill() + // Why: this runs inside the daemon request path, so the timeout must + // cap the RPC even if scutil is slow to exit after SIGTERM. + finish() + }, MAC_RESOLVER_CHECK_TIMEOUT_MS) + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.stdout.on('data', onStdoutData) + child.stderr.on('data', onStderrData) + child.on('error', finish) + child.on('close', finish) }) - const output = `${typeof result.stdout === 'string' ? result.stdout : ''}\n${ - typeof result.stderr === 'string' ? result.stderr : '' - }` - return classifyMacSystemResolverHealth(output) } diff --git a/src/main/network/macos-tailscale-dns-diagnostic.test.ts b/src/main/network/macos-tailscale-dns-diagnostic.test.ts index 5ea93e259e1..96753a281de 100644 --- a/src/main/network/macos-tailscale-dns-diagnostic.test.ts +++ b/src/main/network/macos-tailscale-dns-diagnostic.test.ts @@ -1,9 +1,16 @@ -import { describe, expect, it } from 'vitest' +import { execFileSync } from 'node:child_process' +import { afterEach, describe, expect, it, vi } from 'vitest' import { + __resetMacTailscaleDnsDiagnosticCacheForTests, parseMacTailscaleDnsDiagnostic, + withMacTailscaleDnsHint, withMacTailscaleDnsHintForDiagnostic } from './macos-tailscale-dns-diagnostic' +vi.mock('node:child_process', () => ({ + execFileSync: vi.fn() +})) + const MAGIC_DNS_ONLY_SCUTIL = ` DNS configuration @@ -81,3 +88,31 @@ describe('withMacTailscaleDnsHintForDiagnostic', () => { ) }) }) + +describe('withMacTailscaleDnsHint', () => { + const originalPlatform = process.platform + + afterEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + vi.mocked(execFileSync).mockReset() + __resetMacTailscaleDnsDiagnosticCacheForTests() + }) + + it('reads macOS DNS state through the system scutil path', () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + vi.mocked(execFileSync).mockReturnValue(MAGIC_DNS_ONLY_SCUTIL) + + const result = withMacTailscaleDnsHint('Codex failed.', 'dns lookup failed') + + expect(result).toContain('Tailscale MagicDNS (100.100.100.100)') + expect(execFileSync).toHaveBeenCalledWith( + '/usr/sbin/scutil', + ['--dns'], + expect.objectContaining({ + encoding: 'utf8', + timeout: 1500, + stdio: ['ignore', 'pipe', 'ignore'] + }) + ) + }) +}) diff --git a/src/main/network/macos-tailscale-dns-diagnostic.ts b/src/main/network/macos-tailscale-dns-diagnostic.ts index 6ea30576756..5ec5501525e 100644 --- a/src/main/network/macos-tailscale-dns-diagnostic.ts +++ b/src/main/network/macos-tailscale-dns-diagnostic.ts @@ -52,7 +52,7 @@ function readMacTailscaleDnsDiagnostic(now = Date.now()): DnsDiagnostic | null { let diagnostic: DnsDiagnostic | null = null try { - const output = execFileSync('scutil', ['--dns'], { + const output = execFileSync('/usr/sbin/scutil', ['--dns'], { encoding: 'utf8', timeout: 1500, stdio: ['ignore', 'pipe', 'ignore'] diff --git a/src/main/network/proxy-settings.test.ts b/src/main/network/proxy-settings.test.ts new file mode 100644 index 00000000000..b307cac245c --- /dev/null +++ b/src/main/network/proxy-settings.test.ts @@ -0,0 +1,135 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { defaultSessionMock } = vi.hoisted(() => ({ + defaultSessionMock: { + resolveProxy: vi.fn(async () => 'DIRECT'), + setProxy: vi.fn(async () => {}) + } +})) + +vi.mock('electron', () => ({ + session: { + defaultSession: defaultSessionMock + } +})) + +import { + applyElectronProxySettings, + ensureElectronProxyFromEnvironment, + resetProxyApplicationForTests +} from './proxy-settings' + +function createProxySession(resolveProxy = 'DIRECT') { + return { + resolveProxy: vi.fn(async () => resolveProxy), + setProxy: vi.fn(async () => {}), + closeAllConnections: vi.fn(async () => {}) + } +} + +describe('Electron proxy settings', () => { + beforeEach(() => { + resetProxyApplicationForTests() + }) + + it('applies explicit settings before env fallback', async () => { + const proxySession = createProxySession() + + await expect( + applyElectronProxySettings( + { + httpProxyUrl: ' http://user:pass@proxy.example:8080/path#token ', + httpProxyBypassRules: 'localhost, *.internal' + }, + { + proxySession, + env: { HTTPS_PROXY: 'http://env.example:8080' } + } + ) + ).resolves.toEqual({ + source: 'settings', + proxyRules: 'http://user:pass@proxy.example:8080', + proxyBypassRules: 'localhost;*.internal' + }) + + expect(proxySession.setProxy).toHaveBeenCalledWith({ + mode: 'fixed_servers', + proxyRules: 'http://user:pass@proxy.example:8080', + proxyBypassRules: 'localhost;*.internal' + }) + expect(proxySession.resolveProxy).not.toHaveBeenCalled() + expect(proxySession.closeAllConnections).toHaveBeenCalledTimes(1) + }) + + it('preserves system proxy settings when no explicit or env proxy is configured', async () => { + const proxySession = createProxySession('PROXY system.example:8080') + + await expect(applyElectronProxySettings({}, { proxySession, env: {} })).resolves.toEqual({ + source: 'system' + }) + + expect(proxySession.setProxy).not.toHaveBeenCalled() + }) + + it('bridges env proxy vars only when Chromium would otherwise go direct', async () => { + const proxySession = createProxySession('DIRECT') + + await expect( + applyElectronProxySettings( + {}, + { + proxySession, + env: { + HTTPS_PROXY: 'https://env.example:8443', + HTTP_PROXY: 'http://lower-priority.example:8080', + NO_PROXY: 'localhost,*.internal' + } + } + ) + ).resolves.toEqual({ + source: 'env', + proxyRules: 'https://env.example:8443', + proxyBypassRules: 'localhost;*.internal' + }) + + expect(proxySession.setProxy).toHaveBeenCalledWith({ + mode: 'fixed_servers', + proxyRules: 'https://env.example:8443', + proxyBypassRules: 'localhost;*.internal' + }) + }) + + it('clears a previous app proxy before returning to system/env behavior', async () => { + const proxySession = createProxySession('DIRECT') + + await applyElectronProxySettings( + { httpProxyUrl: 'http://proxy.example:8080' }, + { proxySession } + ) + await applyElectronProxySettings( + { httpProxyUrl: '' }, + { proxySession, env: { HTTP_PROXY: 'http://env.example:8080' } } + ) + + expect(proxySession.setProxy).toHaveBeenNthCalledWith(2, { mode: 'system' }) + expect(proxySession.setProxy).toHaveBeenNthCalledWith(3, { + mode: 'fixed_servers', + proxyRules: 'http://env.example:8080' + }) + }) + + it('does not let env fallback override an already-applied explicit setting', async () => { + const proxySession = createProxySession('DIRECT') + + await applyElectronProxySettings( + { httpProxyUrl: 'http://proxy.example:8080' }, + { proxySession } + ) + await ensureElectronProxyFromEnvironment({ + proxySession, + env: { HTTP_PROXY: 'http://env.example:8080' } + }) + + expect(proxySession.setProxy).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/network/proxy-settings.ts b/src/main/network/proxy-settings.ts new file mode 100644 index 00000000000..c1f63e0ac99 --- /dev/null +++ b/src/main/network/proxy-settings.ts @@ -0,0 +1,125 @@ +import { session } from 'electron' +import { + getProxyBypassRulesFromEnvironment, + getProxyUrlFromEnvironment, + normalizeProxyBypassRules, + normalizeProxyUrl, + type NetworkProxySettings +} from '../../shared/network-proxy' + +type ProxySession = { + resolveProxy(url: string): Promise + setProxy(config: { + mode?: 'system' | 'fixed_servers' + proxyRules?: string + proxyBypassRules?: string + }): Promise + closeAllConnections?: () => Promise +} + +export type ProxyApplyResult = + | { source: 'settings'; proxyRules: string; proxyBypassRules?: string } + | { source: 'env'; proxyRules: string; proxyBypassRules?: string } + | { source: 'system' | 'none' | 'invalid-settings' | 'invalid-env' } + +const PROXY_PROBE_URL = 'https://api.anthropic.com/' + +let lastAppliedProxyConfig: Extract | null = null + +async function setSessionProxy( + proxySession: ProxySession, + config: Parameters[0] +): Promise { + await proxySession.setProxy(config) + await proxySession.closeAllConnections?.() +} + +export function resetProxyApplicationForTests(): void { + lastAppliedProxyConfig = null +} + +export async function ensureElectronProxyFromEnvironment( + options: { + proxySession?: ProxySession + env?: Record + force?: boolean + probeUrl?: string + } = {} +): Promise { + if (!options.force && lastAppliedProxyConfig !== null) { + return lastAppliedProxyConfig + } + + const proxySession = options.proxySession ?? session.defaultSession + const resolved = await proxySession.resolveProxy(options.probeUrl ?? PROXY_PROBE_URL) + if (resolved !== 'DIRECT') { + return { source: 'system' } + } + + const proxy = getProxyUrlFromEnvironment(options.env ?? process.env) + if (!proxy.ok) { + return { source: 'invalid-env' } + } + if (!proxy.value) { + return { source: 'none' } + } + + const bypassRules = getProxyBypassRulesFromEnvironment(options.env ?? process.env) + await setSessionProxy(proxySession, { + mode: 'fixed_servers', + proxyRules: proxy.value, + ...(bypassRules ? { proxyBypassRules: bypassRules } : {}) + }) + lastAppliedProxyConfig = { + source: 'env', + proxyRules: proxy.value, + ...(bypassRules ? { proxyBypassRules: bypassRules } : {}) + } + return lastAppliedProxyConfig +} + +export async function applyElectronProxySettings( + settings: NetworkProxySettings, + options: { + proxySession?: ProxySession + env?: Record + probeUrl?: string + } = {} +): Promise { + const proxySession = options.proxySession ?? session.defaultSession + const proxy = normalizeProxyUrl(settings.httpProxyUrl) + if (!proxy.ok) { + return ensureElectronProxyFromEnvironment({ + proxySession, + env: options.env, + force: lastAppliedProxyConfig !== null, + probeUrl: options.probeUrl + }).then((result) => (result.source === 'none' ? { source: 'invalid-settings' } : result)) + } + + if (proxy.value) { + const bypassRules = normalizeProxyBypassRules(settings.httpProxyBypassRules) + await setSessionProxy(proxySession, { + mode: 'fixed_servers', + proxyRules: proxy.value, + ...(bypassRules ? { proxyBypassRules: bypassRules } : {}) + }) + lastAppliedProxyConfig = { + source: 'settings', + proxyRules: proxy.value, + ...(bypassRules ? { proxyBypassRules: bypassRules } : {}) + } + return lastAppliedProxyConfig + } + + if (lastAppliedProxyConfig !== null) { + await setSessionProxy(proxySession, { mode: 'system' }) + lastAppliedProxyConfig = null + } + return ensureElectronProxyFromEnvironment({ + proxySession, + env: options.env, + force: true, + probeUrl: options.probeUrl + }) +} diff --git a/src/main/observability/diagnostic-upload-http.test.ts b/src/main/observability/diagnostic-upload-http.test.ts new file mode 100644 index 00000000000..6ad6f2dcf9d --- /dev/null +++ b/src/main/observability/diagnostic-upload-http.test.ts @@ -0,0 +1,66 @@ +import { EventEmitter } from 'node:events' +import { describe, expect, it, vi } from 'vitest' + +const { httpRequestMock, httpsRequestMock } = vi.hoisted(() => ({ + httpRequestMock: vi.fn(), + httpsRequestMock: vi.fn() +})) + +vi.mock('node:http', () => ({ request: httpRequestMock })) +vi.mock('node:https', () => ({ request: httpsRequestMock })) + +import { MAX_RESPONSE_BYTES, postJsonForJson } from './diagnostic-upload-http' + +class FakeRequest extends EventEmitter { + destroy = vi.fn() + end = vi.fn() + write = vi.fn() +} + +class FakeResponse extends EventEmitter { + statusCode = 200 + destroy = vi.fn() +} + +describe('diagnostic upload HTTP', () => { + it('removes request and response listeners after a successful response', async () => { + const request = new FakeRequest() + const response = new FakeResponse() + httpRequestMock.mockImplementationOnce((_options, callback) => { + callback(response) + return request + }) + + const result = postJsonForJson('http://diagnostics.example/upload', { ok: true }, 1000) + response.emit('data', Buffer.from('{"accepted":true}')) + response.emit('end') + + await expect(result).resolves.toEqual({ accepted: true }) + expect(request.listenerCount('error')).toBe(0) + expect(request.listenerCount('timeout')).toBe(0) + expect(response.listenerCount('data')).toBe(0) + expect(response.listenerCount('end')).toBe(0) + expect(response.listenerCount('error')).toBe(0) + }) + + it('removes listeners after rejecting an oversized response', async () => { + const request = new FakeRequest() + const response = new FakeResponse() + httpRequestMock.mockImplementationOnce((_options, callback) => { + callback(response) + return request + }) + + const result = postJsonForJson('http://diagnostics.example/upload', { ok: true }, 1000) + response.emit('data', Buffer.alloc(MAX_RESPONSE_BYTES + 1)) + + await expect(result).rejects.toThrow('diagnostic response exceeded size limit') + expect(request.destroy).toHaveBeenCalledTimes(1) + expect(response.destroy).toHaveBeenCalledTimes(1) + expect(request.listenerCount('error')).toBe(0) + expect(request.listenerCount('timeout')).toBe(0) + expect(response.listenerCount('data')).toBe(0) + expect(response.listenerCount('end')).toBe(0) + expect(response.listenerCount('error')).toBe(0) + }) +}) diff --git a/src/main/observability/diagnostic-upload-http.ts b/src/main/observability/diagnostic-upload-http.ts index 95528ab7cbd..17e6b186085 100644 --- a/src/main/observability/diagnostic-upload-http.ts +++ b/src/main/observability/diagnostic-upload-http.ts @@ -1,4 +1,4 @@ -import { request as httpRequest } from 'node:http' +import { request as httpRequest, type ClientRequest, type IncomingMessage } from 'node:http' import { request as httpsRequest } from 'node:https' import { URL } from 'node:url' @@ -38,20 +38,81 @@ function postRaw( ): Promise { return new Promise((resolve, reject) => { let settled = false + let req: ClientRequest | null = null + let res: IncomingMessage | null = null + const chunks: Buffer[] = [] + let responseBytes = 0 + function cleanupListeners(): void { + req?.off('error', onRequestError) + req?.off('timeout', onRequestTimeout) + res?.off('data', onResponseData) + res?.off('end', onResponseEnd) + res?.off('error', onResponseError) + } function resolveOnce(value: unknown): void { if (settled) { return } settled = true + cleanupListeners() resolve(value) } - function rejectOnce(error: Error): void { + function rejectOnce( + error: Error, + options: { destroyRequest?: boolean; destroyResponse?: boolean } = {} + ): void { if (settled) { return } settled = true + if (options.destroyRequest) { + req?.destroy() + } + if (options.destroyResponse) { + res?.destroy() + } + cleanupListeners() reject(error) } + function onResponseData(chunk: Buffer): void { + responseBytes += chunk.length + if (responseBytes > MAX_RESPONSE_BYTES) { + // Why: diagnostics endpoints should return tiny JSON envelopes. + // Cap response buffering so a bad endpoint cannot grow main memory. + rejectOnce(new Error('diagnostic response exceeded size limit'), { + destroyRequest: true, + destroyResponse: true + }) + return + } + chunks.push(chunk) + } + function onResponseEnd(): void { + const status = res?.statusCode ?? 0 + const text = Buffer.concat(chunks).toString('utf8') + if (status >= 200 && status < 300) { + try { + resolveOnce(text.length > 0 ? JSON.parse(text) : {}) + } catch { + rejectOnce(new Error(`malformed JSON response (HTTP ${status})`)) + } + } else { + // Why: this error can cross IPC into renderer toasts. Never + // include backend response bodies; they may contain infra detail. + rejectOnce(new Error(`HTTP ${status}`)) + } + } + function onResponseError(): void { + rejectOnce(new Error('diagnostic network request failed')) + } + function onRequestError(): void { + // Why: request errors can include endpoint hostnames. The diagnostics + // endpoint contract keeps infrastructure details out of renderer IPC. + rejectOnce(new Error('diagnostic network request failed')) + } + function onRequestTimeout(): void { + rejectOnce(new Error('diagnostic network request timed out'), { destroyRequest: true }) + } let parsed: URL try { parsed = new URL(url) @@ -64,7 +125,7 @@ function postRaw( return } const protocol = parsed.protocol === 'https:' ? httpsRequest : httpRequest - const req = protocol( + req = protocol( { protocol: parsed.protocol, hostname: parsed.hostname, @@ -77,50 +138,15 @@ function postRaw( ...headers } }, - (res) => { - const chunks: Buffer[] = [] - let responseBytes = 0 - res.on('data', (chunk: Buffer) => { - responseBytes += chunk.length - if (responseBytes > MAX_RESPONSE_BYTES) { - // Why: diagnostics endpoints should return tiny JSON envelopes. - // Cap response buffering so a bad endpoint cannot grow main memory. - rejectOnce(new Error('diagnostic response exceeded size limit')) - req.destroy() - res.destroy() - return - } - chunks.push(chunk) - }) - res.on('end', () => { - const status = res.statusCode ?? 0 - const text = Buffer.concat(chunks).toString('utf8') - if (status >= 200 && status < 300) { - try { - resolveOnce(text.length > 0 ? JSON.parse(text) : {}) - } catch { - rejectOnce(new Error(`malformed JSON response (HTTP ${status})`)) - } - } else { - // Why: this error can cross IPC into renderer toasts. Never - // include backend response bodies; they may contain infra detail. - rejectOnce(new Error(`HTTP ${status}`)) - } - }) - res.on('error', () => { - rejectOnce(new Error('diagnostic network request failed')) - }) + (response) => { + res = response + res.on('data', onResponseData) + res.on('end', onResponseEnd) + res.on('error', onResponseError) } ) - req.on('error', () => { - // Why: request errors can include endpoint hostnames. The diagnostics - // endpoint contract keeps infrastructure details out of renderer IPC. - rejectOnce(new Error('diagnostic network request failed')) - }) - req.on('timeout', () => { - rejectOnce(new Error('diagnostic network request timed out')) - req.destroy() - }) + req.on('error', onRequestError) + req.on('timeout', onRequestTimeout) req.write(body) req.end() }) diff --git a/src/main/observability/otlp-exporter-http-cleanup.test.ts b/src/main/observability/otlp-exporter-http-cleanup.test.ts new file mode 100644 index 00000000000..754846671d4 --- /dev/null +++ b/src/main/observability/otlp-exporter-http-cleanup.test.ts @@ -0,0 +1,101 @@ +import { EventEmitter } from 'node:events' +import { describe, expect, it, vi } from 'vitest' +import type { RedactableSpan } from './redactor' + +const { httpRequestMock, httpsRequestMock } = vi.hoisted(() => ({ + httpRequestMock: vi.fn(), + httpsRequestMock: vi.fn() +})) + +vi.mock('node:http', () => ({ request: httpRequestMock })) +vi.mock('node:https', () => ({ request: httpsRequestMock })) + +import { createOtlpExporter } from './otlp-exporter' + +class FakeRequest extends EventEmitter { + destroy = vi.fn() + end = vi.fn() + write = vi.fn() +} + +class FakeResponse extends EventEmitter { + statusCode = 204 + resume = vi.fn() +} + +function span(): RedactableSpan { + return { + name: 'unit', + traceId: 'a'.repeat(32), + spanId: 'b'.repeat(16), + kind: 'internal', + startTimeUnixNano: '1000', + endTimeUnixNano: '2000', + durationMs: 1, + attributes: {}, + events: [], + exit: { _tag: 'Success' } + } +} + +describe('otlp exporter HTTP cleanup', () => { + it('removes request listeners after a successful export response', async () => { + const request = new FakeRequest() + const response = new FakeResponse() + let responseCallback: ((response: FakeResponse) => void) | null = null + httpRequestMock.mockImplementationOnce( + (_options: unknown, callback: (response: FakeResponse) => void) => { + responseCallback = callback + return request + } + ) + + const exporter = createOtlpExporter({ + tracesUrl: 'http://collector.example/v1/traces', + serviceName: 'orca-test', + timeoutMs: 1000 + }) + exporter.exportSpan(span()) + const flush = exporter.flush() + + expect(request.listenerCount('error')).toBe(1) + expect(request.listenerCount('timeout')).toBe(1) + const respond = responseCallback as ((response: FakeResponse) => void) | null + if (!respond) { + throw new Error('HTTP response callback was not registered') + } + respond(response) + + await flush + exporter.close() + expect(response.resume).toHaveBeenCalledTimes(1) + expect(request.listenerCount('error')).toBe(0) + expect(request.listenerCount('timeout')).toBe(0) + }) + + it('removes request listeners after an export timeout', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const request = new FakeRequest() + httpRequestMock.mockReturnValueOnce(request) + + const exporter = createOtlpExporter({ + tracesUrl: 'http://collector.example/v1/traces', + serviceName: 'orca-test', + timeoutMs: 1000 + }) + exporter.exportSpan(span()) + const flush = exporter.flush() + + request.emit('timeout') + + await flush + exporter.close() + expect(request.destroy).toHaveBeenCalledTimes(1) + expect(request.listenerCount('error')).toBe(0) + expect(request.listenerCount('timeout')).toBe(0) + } finally { + warnSpy.mockRestore() + } + }) +}) diff --git a/src/main/observability/otlp-exporter.ts b/src/main/observability/otlp-exporter.ts index 2f00ff80c10..7bdfe8c77dd 100644 --- a/src/main/observability/otlp-exporter.ts +++ b/src/main/observability/otlp-exporter.ts @@ -21,7 +21,7 @@ // (~80 KB of transitive deps) for a feature gated entirely on an env var // the typical user will never set. -import { request as httpRequest } from 'node:http' +import { request as httpRequest, type ClientRequest } from 'node:http' import { request as httpsRequest } from 'node:https' import { URL } from 'node:url' import { redactSpan, type RedactableSpan, type SpanEvent } from './redactor' @@ -298,6 +298,33 @@ function encodeOtlpPayload(serviceName: string, spans: RedactableSpan[]): OtlpPa function postJson(url: string, body: unknown, timeoutMs: number): Promise { return new Promise((resolve, reject) => { + let settled = false + let req: ClientRequest | null = null + const cleanupListeners = (): void => { + req?.off('error', onError) + req?.off('timeout', onTimeout) + } + const resolveOnce = (): void => { + if (settled) { + return + } + settled = true + cleanupListeners() + resolve() + } + const rejectOnce = (error: Error, options?: { destroy?: boolean }): void => { + if (settled) { + return + } + settled = true + if (options?.destroy) { + req?.destroy() + } + cleanupListeners() + reject(error) + } + const onError = (error: Error): void => rejectOnce(error) + const onTimeout = (): void => rejectOnce(new Error('OTLP timeout'), { destroy: true }) let parsed: URL try { parsed = new URL(url) @@ -307,7 +334,7 @@ function postJson(url: string, body: unknown, timeoutMs: number): Promise } const data = JSON.stringify(body) const protocol = parsed.protocol === 'https:' ? httpsRequest : httpRequest - const req = protocol( + req = protocol( { protocol: parsed.protocol, hostname: parsed.hostname, @@ -326,16 +353,14 @@ function postJson(url: string, body: unknown, timeoutMs: number): Promise res.resume() const status = res.statusCode ?? 0 if (status >= 200 && status < 300) { - resolve() + resolveOnce() } else { - reject(new Error(`OTLP HTTP ${status}`)) + rejectOnce(new Error(`OTLP HTTP ${status}`)) } } ) - req.on('error', reject) - req.on('timeout', () => { - req.destroy(new Error('OTLP timeout')) - }) + req.on('error', onError) + req.on('timeout', onTimeout) req.write(data) req.end() }) diff --git a/src/main/observability/tracer.ts b/src/main/observability/tracer.ts index f11b0fbbf59..62cfcf2932b 100644 --- a/src/main/observability/tracer.ts +++ b/src/main/observability/tracer.ts @@ -114,12 +114,6 @@ export function setActiveSink(sink: TracerSink | null): void { activeSink = sink } -/** Read the currently-active sink. Used by the bundle path to flush before - * collecting traces, and by tests for assertion. */ -export function getActiveSink(): TracerSink | null { - return activeSink -} - /** Get the current parent context, or `undefined` if we are at the top of * the trace tree. Renderer-IPC entry points capture this, embed it in * span-event attributes (so cross-process spans can be visually linked diff --git a/src/main/openclaude/hook-service.ts b/src/main/openclaude/hook-service.ts new file mode 100644 index 00000000000..5f93d47d813 --- /dev/null +++ b/src/main/openclaude/hook-service.ts @@ -0,0 +1,8 @@ +import { ClaudeHookService } from '../claude/hook-service' +import { OPENCLAUDE_HOOK_SETTINGS } from '../claude/hook-settings' + +export const openClaudeHookService = new ClaudeHookService({ + agent: 'openclaude', + displayName: 'OpenClaude', + settings: OPENCLAUDE_HOOK_SETTINGS +}) diff --git a/src/main/opencode-usage/scanner.test.ts b/src/main/opencode-usage/scanner.test.ts index 625effd4ce6..ff039d41acd 100644 --- a/src/main/opencode-usage/scanner.test.ts +++ b/src/main/opencode-usage/scanner.test.ts @@ -1,9 +1,14 @@ -import Database from 'better-sqlite3' +/* eslint-disable max-lines -- Why: OpenCode scanner tests cover multiple DB schema generations and attribution boundaries together so parser regressions stay auditable. */ import { mkdtempSync, rmSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' import { afterEach, describe, expect, it } from 'vitest' -import { parseOpenCodeUsageDatabase, parseOpenCodeUsageRow } from './scanner' +import Database from '../sqlite/sync-database' +import { + attributeOpenCodeUsageEvent, + parseOpenCodeUsageDatabase, + parseOpenCodeUsageRow +} from './scanner' const WORKTREE = '/workspace/repo' @@ -28,6 +33,21 @@ function worktrees() { ] } +function usageEvent(cwd: string) { + return { + sessionId: 'session-1', + timestamp: '2026-04-09T10:00:00.000Z', + cwd, + model: 'anthropic/claude-sonnet-4-5', + estimatedCostUsd: 0.012, + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 25, + reasoningOutputTokens: 10, + totalTokens: 125 + } +} + describe('parseOpenCodeUsageRow', () => { it('reads assistant message tokens, cost, model, cwd, and timestamp', () => { const parsed = parseOpenCodeUsageRow({ @@ -72,6 +92,44 @@ describe('parseOpenCodeUsageRow', () => { }) }) +describe('attributeOpenCodeUsageEvent', () => { + it('attributes cwd paths under dotdot-prefixed child directories to the worktree', async () => { + const attributed = await attributeOpenCodeUsageEvent( + usageEvent(`${WORKTREE}/..fixtures/session`), + worktrees() + ) + + expect(attributed?.projectKey).toBe('worktree:repo-1::/workspace/repo') + expect(attributed?.projectLabel).toBe('Repo') + expect(attributed?.worktreeId).toBe('repo-1::/workspace/repo') + }) + + it('does not attribute true parent-directory escapes to the worktree', async () => { + const attributed = await attributeOpenCodeUsageEvent( + usageEvent(`${WORKTREE}/../other/session`), + worktrees() + ) + + expect(attributed?.projectKey).toBe('cwd:/workspace/repo/../other/session') + expect(attributed?.worktreeId).toBeNull() + }) + + it('does not treat different Windows drives as containing paths', async () => { + const attributed = await attributeOpenCodeUsageEvent(usageEvent('D:\\other\\repo'), [ + { + repoId: 'repo-1', + worktreeId: 'repo-1::C:\\repo', + path: 'C:\\repo', + displayName: 'Repo', + canonicalPath: 'C:\\repo' + } + ]) + + expect(attributed?.projectKey).toBe('cwd:d:/other/repo') + expect(attributed?.worktreeId).toBeNull() + }) +}) + describe('parseOpenCodeUsageDatabase', () => { afterEach(() => { for (const dir of tempDirs) { diff --git a/src/main/opencode-usage/scanner.ts b/src/main/opencode-usage/scanner.ts index 3066d707e7b..83cc7f742d6 100644 --- a/src/main/opencode-usage/scanner.ts +++ b/src/main/opencode-usage/scanner.ts @@ -1,11 +1,12 @@ /* eslint-disable max-lines -- Why: OpenCode usage analytics need to normalize multiple local DB schema generations, attribute worktrees, and build persisted projections in one auditable pipeline. */ -import Database from 'better-sqlite3' import { existsSync } from 'fs' import { readdir, realpath, stat } from 'fs/promises' import { homedir } from 'os' import { isAbsolute, join, posix, win32 } from 'path' import type { Repo } from '../../shared/types' import { areWorktreePathsEqual } from '../ipc/worktree-logic' +import Database from '../sqlite/sync-database' +import { canonicalizeUsageWorktreePaths } from '../usage-worktree-canonicalizer' import type { OpenCodeUsageAttributedEvent, OpenCodeUsageDailyAggregate, @@ -428,19 +429,20 @@ function isContainingPath(candidatePath: string, targetPath: string): boolean { const isAbsoluteRelative = useWin32 ? win32.isAbsolute(relativePath) : posix.isAbsolute(relativePath) - return !isAbsoluteRelative && !relativePath.startsWith('..') && relativePath !== '.' + const parentPrefix = useWin32 ? `..${win32.sep}` : `..${posix.sep}` + // Why: `..name` is a valid child path; only `..` and `../...` escape. + return ( + !isAbsoluteRelative && + relativePath !== '..' && + !relativePath.startsWith(parentPrefix) && + relativePath !== '.' + ) } async function buildWorktreesWithCanonicalPaths( worktrees: OpenCodeUsageWorktreeRef[] ): Promise<(OpenCodeUsageWorktreeRef & { canonicalPath: string })[]> { - const canonicalized = await Promise.all( - worktrees.map(async (worktree) => ({ - ...worktree, - canonicalPath: await canonicalizePath(worktree.path) - })) - ) - return canonicalized.sort((left, right) => right.canonicalPath.length - left.canonicalPath.length) + return canonicalizeUsageWorktreePaths(worktrees, canonicalizePath) } async function canonicalizePath(pathValue: string): Promise { diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 6bde67ff4aa..c65e8398051 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -16,7 +16,9 @@ import { join } from 'path' import { tmpdir } from 'os' import type { PersistedState, + ProjectGroup, Repo, + TerminalPaneLayoutNode, TerminalTab, WorktreeLineage, WorkspaceSessionState @@ -219,6 +221,19 @@ function makeSessionWithBrowserHistory(count: number): WorkspaceSessionState { } } +function makeBalancedLegacyPaneLayout(start: number, end: number): TerminalPaneLayoutNode { + if (end - start === 1) { + return { type: 'leaf', leafId: `pane:${start + 1}` } + } + const midpoint = Math.floor((start + end) / 2) + return { + type: 'split', + direction: 'horizontal', + first: makeBalancedLegacyPaneLayout(start, midpoint), + second: makeBalancedLegacyPaneLayout(midpoint, end) + } +} + describe('Store', () => { beforeEach(() => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) @@ -349,14 +364,24 @@ describe('Store', () => { remoteWorkspaceSyncGracePeriodSeconds: 0 }, { - id: 'ssh-new-grace-period-wins', - label: 'New grace period', + id: 'ssh-synced-grace-wins-over-relay', + label: 'Synced grace wins', host: 'new.example.com', port: 22, username: 'dev', relayGracePeriodSeconds: 120, remoteWorkspaceSyncEnabled: true, remoteWorkspaceSyncGracePeriodSeconds: 0 + }, + { + id: 'ssh-form-default-relay-with-unlimited-sync', + label: 'Form-default relay with unlimited sync', + host: 'unlimited.example.com', + port: 22, + username: 'dev', + relayGracePeriodSeconds: 10800, + remoteWorkspaceSyncEnabled: true, + remoteWorkspaceSyncGracePeriodSeconds: 0 } ] }) @@ -366,7 +391,8 @@ describe('Store', () => { expect(targets[0]).not.toHaveProperty('relayGracePeriodSeconds') expect(targets[1].relayGracePeriodSeconds).toBe(0) - expect(targets[2].relayGracePeriodSeconds).toBe(120) + expect(targets[2].relayGracePeriodSeconds).toBe(0) + expect(targets[3].relayGracePeriodSeconds).toBe(0) for (const target of targets) { expect(target).not.toHaveProperty('remoteWorkspaceSyncEnabled') expect(target).not.toHaveProperty('remoteWorkspaceSyncGracePeriodSeconds') @@ -376,7 +402,8 @@ describe('Store', () => { const persisted = readDataFile() as { sshTargets?: Record[] } expect(persisted.sshTargets?.[0]).not.toHaveProperty('relayGracePeriodSeconds') expect(persisted.sshTargets?.[1]?.relayGracePeriodSeconds).toBe(0) - expect(persisted.sshTargets?.[2]?.relayGracePeriodSeconds).toBe(120) + expect(persisted.sshTargets?.[2]?.relayGracePeriodSeconds).toBe(0) + expect(persisted.sshTargets?.[3]?.relayGracePeriodSeconds).toBe(0) for (const target of persisted.sshTargets ?? []) { expect(target).not.toHaveProperty('remoteWorkspaceSyncEnabled') expect(target).not.toHaveProperty('remoteWorkspaceSyncGracePeriodSeconds') @@ -594,6 +621,56 @@ describe('Store', () => { expect(reloaded.listAutomations()[0].reuseSession).toBe(false) }) + it('persists automation precheck config and run results', async () => { + const store = await createStore() + store.addRepo(makeRepo()) + const automation = store.createAutomation({ + name: 'Conditional', + prompt: 'Run checks', + precheck: { + command: 'test -f ready', + timeoutSeconds: 30 + }, + agentId: 'claude', + projectId: 'r1', + workspaceMode: 'existing', + workspaceId: 'wt1', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-13T00:00:00Z').getTime() + }) + const run = store.createAutomationRun(automation, new Date('2026-05-13T09:00:00Z').getTime()) + + store.updateAutomationRun({ + runId: run.id, + status: 'skipped_precheck', + precheckResult: { + command: 'test -f ready', + exitCode: 1, + timedOut: false, + durationMs: 12, + stdout: '', + stderr: 'missing', + stdoutTruncated: false, + stderrTruncated: false, + error: null, + startedAt: 10, + completedAt: 22 + }, + error: 'Precheck exited with code 1.' + }) + + expect(store.listAutomations()[0].precheck).toEqual({ + command: 'test -f ready', + timeoutSeconds: 30 + }) + expect(store.listAutomationRuns(automation.id)[0].precheckResult).toMatchObject({ + exitCode: 1, + stderr: 'missing' + }) + expect(store.updateAutomation(automation.id, { precheck: null }).precheck).toBeNull() + }) + it('numbers automation run titles per automation', async () => { const store = await createStore() store.addRepo(makeRepo()) @@ -1504,6 +1581,35 @@ describe('Store', () => { expect(store.getRepo('sibling')?.projectGroupId).toBe(sibling.id) }) + it('creates a project group when persisted group history is very large', async () => { + const projectGroups: ProjectGroup[] = Array.from({ length: 130_000 }, (_, index) => ({ + id: `group-${index}`, + name: `Group ${index}`, + parentPath: null, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: index, + isCollapsed: false, + color: null, + createdAt: index, + updatedAt: index + })) + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: {}, + githubCache: { pr: {}, issue: {} }, + projectGroups + }) + const store = await createStore() + + const group = store.createProjectGroup({ name: 'New group', createdFrom: 'manual' }) + + expect(group.tabOrder).toBe(projectGroups.length) + }) + it('sanitizes invalid project group updates before persisting a repo', async () => { const store = await createStore() const group = store.createProjectGroup({ name: 'Platform', createdFrom: 'manual' }) @@ -2047,7 +2153,8 @@ describe('Store', () => { activeBrowserTabIdByWorktree: {}, activeFileIdByWorktree: {}, activeTabTypeByWorktree: {}, - browserUrlHistory: [] + browserUrlHistory: [], + defaultTerminalTabsAppliedByWorktreeId: {} } writeDataFile({ schemaVersion: 1, @@ -2151,6 +2258,23 @@ describe('Store', () => { expect(ui.dismissedUpdateVersion).toBeNull() }) + it('updateUI persists sanitized per-worktree dotfile visibility', async () => { + const store = await createStore() + store.updateUI({ + showDotfilesByWorktree: { + 'repo-1::/repo': false, + 'repo-2::/repo': true, + 'repo-3::/repo': 'bad', + constructor: false + } as never + }) + + expect(store.getUI().showDotfilesByWorktree).toEqual({ + 'repo-1::/repo': false, + 'repo-2::/repo': true + }) + }) + it('migrates missing rightSidebarOpen from the legacy default setting', async () => { writeDataFile({ schemaVersion: 1, @@ -2957,6 +3081,71 @@ describe('Store', () => { expect(store.getWorkspaceSession()).toEqual(session) }) + it('patches workspace session without replacing unchanged slices', async () => { + const store = await createStore() + const tabsByWorktree = { + wt1: [makeTerminalTab({ id: 'tab1', ptyId: null, worktreeId: 'wt1' })] + } + const terminalLayoutsByTabId = { + tab1: { root: null, activeLeafId: null, expandedLeafId: null } + } + store.setWorkspaceSession({ + activeRepoId: 'r1', + activeWorktreeId: 'wt1', + activeTabId: 'tab1', + tabsByWorktree, + terminalLayoutsByTabId, + activeConnectionIdsAtShutdown: ['ssh-1'] + }) + + store.patchWorkspaceSession({ + activeTabId: 'tab2', + activeConnectionIdsAtShutdown: undefined + }) + + const session = store.getWorkspaceSession() + expect(session.activeTabId).toBe('tab2') + expect(session.tabsByWorktree).toEqual(tabsByWorktree) + expect(session.terminalLayoutsByTabId).toEqual(terminalLayoutsByTabId) + expect(session.activeConnectionIdsAtShutdown).toBeUndefined() + }) + + it('uses full normalization for structural workspace session patches', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'local-repo', connectionId: null })) + store.setWorkspaceSession({ + activeRepoId: 'local-repo', + activeWorktreeId: 'local-repo::/worktree', + activeTabId: 'tab-local', + tabsByWorktree: { + 'local-repo::/worktree': [ + makeTerminalTab({ + id: 'tab-local', + ptyId: 'pty-local', + worktreeId: 'local-repo::/worktree' + }) + ] + }, + terminalLayoutsByTabId: {} + }) + + store.patchWorkspaceSession({ + terminalLayoutsByTabId: { + 'tab-local': { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + buffersByLeafId: { [TEST_LEAF_1]: 'local-scrollback' }, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-local' } + } + } + }) + + expect( + store.getWorkspaceSession().terminalLayoutsByTabId['tab-local'].buffersByLeafId + ).toBeUndefined() + }) + it('strips local terminal scrollback buffers when setting workspace session', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'local-repo', connectionId: null })) @@ -3609,6 +3798,62 @@ describe('Store', () => { ) }) + it('loads legacy pane aliases from very large persisted split layouts', async () => { + const leafCount = 130_000 + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: { + activeRepoId: 'r1', + activeWorktreeId: 'wt1', + activeTabId: 'tab1', + tabsByWorktree: { + wt1: [ + { + id: 'tab1', + worktreeId: 'wt1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: 'large-pty' + } + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: makeBalancedLegacyPaneLayout(0, leafCount), + activeLeafId: 'pane:1', + expandedLeafId: null + } + } + } + }) + + const store = await createStore() + store.flush() + + const persisted = readDataFile() as PersistedState + const aliasEntries = persisted.legacyPaneKeyAliasEntries + expect(aliasEntries).toHaveLength(leafCount + 1) + expect( + aliasEntries.some((entry) => entry.ptyId === 'large-pty' && entry.legacyPaneKey === 'tab1:0') + ).toBe(true) + expect( + aliasEntries.some((entry) => entry.ptyId === 'large-pty' && entry.legacyPaneKey === 'tab1:1') + ).toBe(true) + expect( + aliasEntries.some( + (entry) => entry.ptyId === 'large-pty' && entry.legacyPaneKey === `tab1:${leafCount}` + ) + ).toBe(true) + }) + it('converts unambiguous dev migration rows into persisted aliases', async () => { const stablePaneKey = makePaneKey('tab1', TEST_LEAF_1) writeDataFile({ diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 864129326ac..9f44270c2e0 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -21,6 +21,7 @@ import type { Automation, AutomationCreateInput, AutomationDispatchResult, + AutomationPrecheckResult, AutomationRunOutputSnapshot, AutomationRun, AutomationRunTrigger, @@ -30,6 +31,7 @@ import { latestAutomationOccurrenceAtOrBefore, nextAutomationOccurrenceAfter } from '../shared/automation-schedules' +import { normalizeAutomationPrecheck } from '../shared/automation-precheck' import type { PersistedState, Repo, @@ -47,6 +49,7 @@ import type { TerminalPaneLayoutNode, TerminalLayoutSnapshot, TerminalTab, + WorkspaceSessionPatch, WorkspaceSessionState } from '../shared/types' import type { MigrationUnsupportedPtyEntry } from '../shared/agent-status-types' @@ -61,6 +64,7 @@ import { getDefaultUIState, getDefaultRepoHookSettings, getDefaultWorkspaceSession, + normalizeAgentActivityDisplayMode, normalizeWorktreeCardProperties, ONBOARDING_FINAL_STEP } from '../shared/constants' @@ -94,7 +98,7 @@ import { DEFAULT_WORKSPACE_STATUS_ID, clampWorkspaceBoardColumnWidth, clampWorkspaceBoardOpacity, - normalizeWorkspaceBoardCompact, + normalizeWorkspaceBoardColumnLayout, normalizePersistedWorkspaceStatuses, normalizeWorkspaceStatuses } from '../shared/workspace-statuses' @@ -187,6 +191,16 @@ function getDataFile(): string { // >=1-hour spacing cover recent work without churning disk on every debounce. const BACKUP_COUNT = 5 const BACKUP_MIN_INTERVAL_MS = 60 * 60 * 1000 +const WORKSPACE_SESSION_PATCH_FULL_NORMALIZATION_KEYS = new Set([ + 'tabsByWorktree', + 'terminalLayoutsByTabId' +]) + +function workspaceSessionPatchNeedsFullNormalization(patch: WorkspaceSessionPatch): boolean { + return Object.keys(patch).some((key) => + WORKSPACE_SESSION_PATCH_FULL_NORMALIZATION_KEYS.has(key as keyof WorkspaceSessionState) + ) +} function backupPath(dataFile: string, index: number): string { return `${dataFile}.bak.${index}` @@ -241,6 +255,26 @@ function normalizeGroupBy(groupBy: unknown): PersistedState['ui']['groupBy'] { return getDefaultUIState().groupBy } +function normalizeShowDotfilesByWorktree(value: unknown): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return {} + } + const out: Record = {} + for (const [worktreeId, showDotfiles] of Object.entries(value as Record)) { + if ( + !worktreeId || + worktreeId === '__proto__' || + worktreeId === 'constructor' || + worktreeId === 'prototype' || + typeof showDotfiles !== 'boolean' + ) { + continue + } + out[worktreeId] = showDotfiles + } + return out +} + function mergeFeatureInteractions( current: PersistedState['ui']['featureInteractions'], incoming: PersistedState['ui']['featureInteractions'] @@ -356,9 +390,43 @@ function normalizeAutomationRunOutputSnapshot( } } +function normalizeAutomationPrecheckResult( + value: AutomationPrecheckResult | null | undefined +): AutomationPrecheckResult | null { + if (!value || typeof value.command !== 'string' || !value.command.trim()) { + return null + } + const startedAt = + typeof value.startedAt === 'number' && Number.isFinite(value.startedAt) + ? value.startedAt + : Date.now() + const completedAt = + typeof value.completedAt === 'number' && Number.isFinite(value.completedAt) + ? value.completedAt + : startedAt + return { + command: value.command.trim(), + exitCode: + typeof value.exitCode === 'number' && Number.isFinite(value.exitCode) ? value.exitCode : null, + timedOut: value.timedOut === true, + durationMs: + typeof value.durationMs === 'number' && Number.isFinite(value.durationMs) + ? Math.max(0, value.durationMs) + : Math.max(0, completedAt - startedAt), + stdout: typeof value.stdout === 'string' ? value.stdout : '', + stderr: typeof value.stderr === 'string' ? value.stderr : '', + stdoutTruncated: value.stdoutTruncated === true, + stderrTruncated: value.stderrTruncated === true, + error: typeof value.error === 'string' && value.error.trim() ? value.error : null, + startedAt, + completedAt + } +} + function normalizeAutomationSessionReuse(automation: Automation): Automation { return { ...automation, + precheck: normalizeAutomationPrecheck(automation.precheck), reuseSession: automation.workspaceMode === 'existing' && automation.reuseSession === true } } @@ -380,11 +448,12 @@ function normalizeSshTarget(t: SshTarget): SshTarget { delete target.remoteWorkspaceSyncEnabled delete target.remoteWorkspaceSyncGracePeriodSeconds delete target.relayGracePeriodSeconds + // Why: synced legacy targets ignored stale relayGracePeriodSeconds values. + // Prefer the synced grace so a user's "unlimited" (0) survives migration. const relayGracePeriodSeconds = - currentGracePeriodSeconds ?? - (legacySyncEnabled === true && typeof legacyGracePeriodSeconds === 'number' + legacySyncEnabled === true && typeof legacyGracePeriodSeconds === 'number' ? legacyGracePeriodSeconds - : undefined) + : currentGracePeriodSeconds const normalized: SshTarget = { ...target, configHost: target.configHost ?? target.label ?? target.host @@ -478,7 +547,7 @@ function readLegacySidekickFlag(parsed: PersistedState | undefined): boolean | u } function sanitizeRepoUpdatesForPersistence< - T extends Partial> + T extends Partial> >(updates: T): T { const sanitized = { ...updates } if ('badgeColor' in sanitized) { @@ -497,6 +566,13 @@ function sanitizeRepoUpdatesForPersistence< sanitized.repoIcon = repoIcon } } + if ('worktreeBasePath' in sanitized && sanitized.worktreeBasePath !== undefined) { + if (typeof sanitized.worktreeBasePath === 'string') { + sanitized.worktreeBasePath = sanitized.worktreeBasePath.trim() || undefined + } else { + delete sanitized.worktreeBasePath + } + } return sanitized } @@ -1013,8 +1089,14 @@ function normalizeWorkspaceSessionPaneIdentities( normalizedLayout: normalized.snapshot, leafIdByInputLeafId: normalized.leafIdByInputLeafId }) - migrationUnsupportedEntries.push(...migrationEntries.migrationUnsupportedEntries) - legacyPaneKeyAliasEntries.push(...migrationEntries.legacyPaneKeyAliasEntries) + // Why: old persisted split layouts can generate enough alias rows to + // exceed V8's argument limit if the arrays are spread into push(). + for (const entry of migrationEntries.migrationUnsupportedEntries) { + migrationUnsupportedEntries.push(entry) + } + for (const entry of migrationEntries.legacyPaneKeyAliasEntries) { + legacyPaneKeyAliasEntries.push(entry) + } const leafIdByPtyId = new Map() const duplicatePtyIds = new Set() for (const [leafId, ptyId] of Object.entries(normalized.snapshot.ptyIdsByLeafId ?? {})) { @@ -1452,11 +1534,14 @@ export class Store { const raw = readFileSync(dataFile, 'utf-8') const parsed = JSON.parse(raw) as PersistedState - // Why: opencodeSessionCookie is stored encrypted on disk via safeStorage. + // Why: secret settings are stored encrypted on disk via safeStorage. // Decrypt at the load boundary so the rest of the app sees plaintext. if (parsed.settings?.opencodeSessionCookie) { parsed.settings.opencodeSessionCookie = decrypt(parsed.settings.opencodeSessionCookie) } + if (parsed.settings?.httpProxyUrl) { + parsed.settings.httpProxyUrl = decrypt(parsed.settings.httpProxyUrl) + } if (parsed.ui?.browserKagiSessionLink) { parsed.ui.browserKagiSessionLink = decryptOptionalSecret(parsed.ui.browserKagiSessionLink) } @@ -1765,6 +1850,9 @@ export class Store { rightSidebarOpen, rightSidebarTab: normalizeRightSidebarTab(parsed.ui?.rightSidebarTab), sortBy: migrate ? ('smart' as const) : sort, + showDotfilesByWorktree: normalizeShowDotfilesByWorktree( + parsed.ui?.showDotfilesByWorktree + ), workspaceStatuses, _workspaceStatusesDefaultOrderMigrated: true, _workspaceStatusesDefaultWorkflowMigrated: true, @@ -1984,13 +2072,14 @@ export class Store { await mkdir(dir, { recursive: true }).catch(() => {}) const tmpFile = `${dataFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` - // Why: opencodeSessionCookie must be encrypted on disk. Clone state so - // the in-memory this.state stays plaintext for the rest of the app. + // Why: secrets must be encrypted on disk. Clone state so the in-memory + // this.state stays plaintext for the rest of the app. const stateToSave = { ...this.state, settings: { ...this.state.settings, - opencodeSessionCookie: encrypt(this.state.settings.opencodeSessionCookie) + opencodeSessionCookie: encrypt(this.state.settings.opencodeSessionCookie), + httpProxyUrl: encrypt(this.state.settings.httpProxyUrl ?? '') }, ui: { ...this.state.ui, @@ -2038,13 +2127,14 @@ export class Store { } const tmpFile = `${dataFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` - // Why: opencodeSessionCookie must be encrypted on disk. Clone state so - // the in-memory this.state stays plaintext for the rest of the app. + // Why: secrets must be encrypted on disk. Clone state so the in-memory + // this.state stays plaintext for the rest of the app. const stateToSave = { ...this.state, settings: { ...this.state.settings, - opencodeSessionCookie: encrypt(this.state.settings.opencodeSessionCookie) + opencodeSessionCookie: encrypt(this.state.settings.opencodeSessionCookie), + httpProxyUrl: encrypt(this.state.settings.httpProxyUrl ?? '') }, ui: { ...this.state.ui, @@ -2120,10 +2210,11 @@ export class Store { parentGroupId?: string | null createdFrom: ProjectGroup['createdFrom'] }): ProjectGroup { - const maxOrder = Math.max( - -1, - ...(this.state.projectGroups ?? []).map((group) => group.tabOrder) - ) + let maxOrder = -1 + // Why: persisted group lists can be large enough to exceed spread limits. + for (const existingGroup of this.state.projectGroups ?? []) { + maxOrder = Math.max(maxOrder, existingGroup.tabOrder) + } const group = createProjectGroup({ ...input, tabOrder: maxOrder + 1 @@ -2264,6 +2355,7 @@ export class Store { | 'repoIcon' | 'hookSettings' | 'worktreeBaseRef' + | 'worktreeBasePath' | 'kind' | 'symlinkPaths' | 'issueSourcePreference' @@ -2311,6 +2403,10 @@ export class Store { delete repo.issueSourcePreference delete sanitizedUpdates.issueSourcePreference } + if ('worktreeBasePath' in sanitizedUpdates && sanitizedUpdates.worktreeBasePath === undefined) { + delete repo.worktreeBasePath + delete sanitizedUpdates.worktreeBasePath + } if ( 'externalWorktreeVisibility' in sanitizedUpdates && repo.externalWorktreeVisibilityLegacy === undefined @@ -2400,9 +2496,12 @@ export class Store { listAutomationRuns(automationId?: string): AutomationRun[] { const runs = this.state.automationRuns ?? [] - return [ - ...(automationId ? runs.filter((run) => run.automationId === automationId) : runs) - ].sort((left, right) => right.createdAt - left.createdAt) + return [...(automationId ? runs.filter((run) => run.automationId === automationId) : runs)] + .map((run) => ({ + ...run, + precheckResult: normalizeAutomationPrecheckResult(run.precheckResult) + })) + .sort((left, right) => right.createdAt - left.createdAt) } createAutomation(input: AutomationCreateInput): Automation { @@ -2413,6 +2512,7 @@ export class Store { id: randomUUID(), name: input.name.trim() || 'Untitled automation', prompt: input.prompt, + precheck: normalizeAutomationPrecheck(input.precheck), agentId: input.agentId, projectId: input.projectId, executionTargetType, @@ -2456,6 +2556,9 @@ export class Store { ...updates, name: updates.name !== undefined ? updates.name.trim() || 'Untitled automation' : current.name, + precheck: Object.hasOwn(updates, 'precheck') + ? normalizeAutomationPrecheck(updates.precheck) + : normalizeAutomationPrecheck(current.precheck), projectId: repoId, executionTargetType, executionTargetId: executionTargetType === 'ssh' ? (repo?.connectionId ?? '') : 'local', @@ -2525,6 +2628,7 @@ export class Store { chatSessionId: null, terminalSessionId: null, outputSnapshot: null, + precheckResult: null, usage: null, error: null, startedAt: null, @@ -2562,6 +2666,9 @@ export class Store { outputSnapshot: Object.hasOwn(result, 'outputSnapshot') ? normalizeAutomationRunOutputSnapshot(result.outputSnapshot) : normalizeAutomationRunOutputSnapshot(current.outputSnapshot), + precheckResult: Object.hasOwn(result, 'precheckResult') + ? normalizeAutomationPrecheckResult(result.precheckResult) + : normalizeAutomationPrecheckResult(current.precheckResult), usage: Object.hasOwn(result, 'usage') ? (result.usage ?? null) : (current.usage ?? null), error: result.error ?? null, startedAt: current.startedAt ?? now, @@ -2802,12 +2909,20 @@ export class Store { worktreeCardProperties: normalizeWorktreeCardProperties( this.state.ui?.worktreeCardProperties ), + agentActivityDisplayMode: normalizeAgentActivityDisplayMode( + this.state.ui?.agentActivityDisplayMode + ), workspaceStatuses: normalizeWorkspaceStatuses(this.state.ui?.workspaceStatuses), workspaceBoardOpacity: clampWorkspaceBoardOpacity(this.state.ui?.workspaceBoardOpacity), - workspaceBoardCompact: normalizeWorkspaceBoardCompact(this.state.ui?.workspaceBoardCompact), + workspaceBoardColumnLayout: normalizeWorkspaceBoardColumnLayout( + this.state.ui?.workspaceBoardColumnLayout + ), workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth( this.state.ui?.workspaceBoardColumnWidth ), + showDotfilesByWorktree: normalizeShowDotfilesByWorktree( + this.state.ui?.showDotfilesByWorktree + ), featureTipsSeenIds: normalizeFeatureTipIds(this.state.ui?.featureTipsSeenIds), featureInteractions: normalizeFeatureInteractions(this.state.ui?.featureInteractions) } @@ -2831,6 +2946,10 @@ export class Store { updates.worktreeCardProperties !== undefined ? normalizeWorktreeCardProperties(updates.worktreeCardProperties) : normalizeWorktreeCardProperties(this.state.ui?.worktreeCardProperties), + agentActivityDisplayMode: + updates.agentActivityDisplayMode !== undefined + ? normalizeAgentActivityDisplayMode(updates.agentActivityDisplayMode) + : normalizeAgentActivityDisplayMode(this.state.ui?.agentActivityDisplayMode), workspaceStatuses: updates.workspaceStatuses !== undefined ? normalizeWorkspaceStatuses(updates.workspaceStatuses) @@ -2838,12 +2957,16 @@ export class Store { workspaceBoardOpacity: clampWorkspaceBoardOpacity( updates.workspaceBoardOpacity ?? this.state.ui?.workspaceBoardOpacity ), - workspaceBoardCompact: normalizeWorkspaceBoardCompact( - updates.workspaceBoardCompact ?? this.state.ui?.workspaceBoardCompact + workspaceBoardColumnLayout: normalizeWorkspaceBoardColumnLayout( + updates.workspaceBoardColumnLayout ?? this.state.ui?.workspaceBoardColumnLayout ), workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth( updates.workspaceBoardColumnWidth ?? this.state.ui?.workspaceBoardColumnWidth ), + showDotfilesByWorktree: + updates.showDotfilesByWorktree !== undefined + ? normalizeShowDotfilesByWorktree(updates.showDotfilesByWorktree) + : normalizeShowDotfilesByWorktree(this.state.ui?.showDotfilesByWorktree), featureTipsSeenIds: updates.featureTipsSeenIds !== undefined ? normalizeFeatureTipIds(updates.featureTipsSeenIds) @@ -3069,6 +3192,25 @@ export class Store { this.scheduleSave() } + patchWorkspaceSession(patch: WorkspaceSessionPatch): void { + // Why: the renderer's debounced hot path sends only changed top-level + // session slices. Scalar/UI patches avoid the terminal normalization path; + // terminal topology/layout patches still reuse the stale-PTY protections. + let next: WorkspaceSessionState = { + ...this.getWorkspaceSession(), + ...patch + } + if (workspaceSessionPatchNeedsFullNormalization(patch)) { + this.setWorkspaceSession(next) + return + } + if (Object.hasOwn(patch, 'browserUrlHistory')) { + next = pruneWorkspaceSessionBrowserHistory(next) + } + this.state.workspaceSession = next + this.scheduleSave() + } + private getTerminalLayoutLeafIds(root: TerminalPaneLayoutNode | null): Set { const leafIds = new Set() const visit = (node: TerminalPaneLayoutNode | null): void => { diff --git a/src/main/pi/titlebar-extension-overlay-path.test.ts b/src/main/pi/titlebar-extension-overlay-path.test.ts new file mode 100644 index 00000000000..2aba113f13b --- /dev/null +++ b/src/main/pi/titlebar-extension-overlay-path.test.ts @@ -0,0 +1,78 @@ +import { createHash } from 'crypto' +import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { basename, join, sep } from 'path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const userDataDir = mkdtempSync(join(tmpdir(), 'orca-pi-overlay-path-userdata-')) + +vi.mock('electron', () => ({ + app: { + getPath: (name: string) => { + if (name === 'userData') { + return userDataDir + } + throw new Error(`unexpected app.getPath(${name})`) + } + } +})) + +import { PiTitlebarExtensionService } from './titlebar-extension-service' + +const PATH_SHAPED_PTY_ID = [ + '50c010a2-bc8e-4eb1-8847-5812133ad6df', + 'Users', + 'dev', + 'orca', + 'workspaces', + 'noqa', + 'feature@@a1b2c3d4' +].join(sep) + +function overlayPath(kind: 'pi' | 'omp', ptyId: string): string { + const rootDir = kind === 'pi' ? 'pi-agent-overlays' : 'omp-agent-overlays' + const safeName = createHash('sha256').update(ptyId).digest('hex').slice(0, 32) + return join(userDataDir, rootDir, safeName) +} + +function legacyOverlayPath(kind: 'pi' | 'omp', ptyId: string): string { + const rootDir = kind === 'pi' ? 'pi-agent-overlays' : 'omp-agent-overlays' + return join(userDataDir, rootDir, ptyId) +} + +describe('PiTitlebarExtensionService overlay paths', () => { + afterEach(() => { + rmSync(join(userDataDir, 'pi-agent-overlays'), { recursive: true, force: true }) + rmSync(join(userDataDir, 'omp-agent-overlays'), { recursive: true, force: true }) + }) + + it('hashes daemon-shaped pty ids into bounded overlay directory names', () => { + const piHome = mkdtempSync(join(tmpdir(), 'orca-pi-overlay-path-home-')) + const svc = new PiTitlebarExtensionService() + + try { + const env = svc.buildPtyEnv(PATH_SHAPED_PTY_ID, piHome, 'pi') + + expect(env.PI_CODING_AGENT_DIR).toBe(overlayPath('pi', PATH_SHAPED_PTY_ID)) + expect(basename(env.PI_CODING_AGENT_DIR!)).toMatch(/^[a-f0-9]{32}$/) + expect(readdirSync(join(env.PI_CODING_AGENT_DIR!, 'extensions')).sort()).toEqual([ + 'orca-agent-status.ts', + 'orca-prefill.ts', + 'orca-titlebar-spinner.ts' + ]) + } finally { + rmSync(piHome, { recursive: true, force: true }) + } + }) + + it('clears legacy raw path-shaped daemon overlays during teardown', () => { + const legacyOverlayDir = legacyOverlayPath('pi', PATH_SHAPED_PTY_ID) + mkdirSync(legacyOverlayDir, { recursive: true }) + writeFileSync(join(legacyOverlayDir, 'stale.txt'), 'stale overlay') + + const svc = new PiTitlebarExtensionService() + svc.clearPty(PATH_SHAPED_PTY_ID) + + expect(existsSync(legacyOverlayDir)).toBe(false) + }) +}) diff --git a/src/main/pi/titlebar-extension-service.test.ts b/src/main/pi/titlebar-extension-service.test.ts index b5d9259baf1..7cad5626c69 100644 --- a/src/main/pi/titlebar-extension-service.test.ts +++ b/src/main/pi/titlebar-extension-service.test.ts @@ -12,6 +12,7 @@ import { import { tmpdir } from 'os' import type * as osModule from 'os' import { join } from 'path' +import { createHash } from 'crypto' // The service calls app.getPath('userData') for its overlay root. Point that // at a real tmp dir so we can exercise the filesystem behavior end-to-end. @@ -45,6 +46,17 @@ vi.mock('electron', () => ({ import { PiTitlebarExtensionService, isSafeDescendCandidate } from './titlebar-extension-service' +function overlayPath(kind: 'pi' | 'omp', ptyId: string): string { + const rootDir = kind === 'pi' ? 'pi-agent-overlays' : 'omp-agent-overlays' + const safeName = createHash('sha256').update(ptyId).digest('hex').slice(0, 32) + return join(userDataDir, rootDir, safeName) +} + +function legacyOverlayPath(kind: 'pi' | 'omp', ptyId: string): string { + const rootDir = kind === 'pi' ? 'pi-agent-overlays' : 'omp-agent-overlays' + return join(userDataDir, rootDir, ptyId) +} + describe('PiTitlebarExtensionService', () => { let piHome: string @@ -59,6 +71,18 @@ describe('PiTitlebarExtensionService', () => { mkdirSync(join(piHome, 'sessions'), { recursive: true }) writeFileSync(join(piHome, 'sessions', 'session-1.json'), '{}') writeFileSync(join(piHome, 'auth.json'), 'secret token') + writeFileSync( + join(piHome, 'settings.json'), + JSON.stringify({ + defaultProvider: 'amazon-bedrock', + hideThinkingBlock: false, + packages: ['npm:pi-web-access'], + terminal: { + showImages: false, + clearOnShrink: false + } + }) + ) }) afterEach(() => { @@ -79,13 +103,22 @@ describe('PiTitlebarExtensionService', () => { 'user extension' ) expect(readFileSync(join(piHome, 'sessions', 'session-1.json'), 'utf-8')).toBe('{}') + expect(JSON.parse(readFileSync(join(piHome, 'settings.json'), 'utf-8'))).toEqual({ + defaultProvider: 'amazon-bedrock', + hideThinkingBlock: false, + packages: ['npm:pi-web-access'], + terminal: { + showImages: false, + clearOnShrink: false + } + }) } it('buildPtyEnv mirrors the user agent dir into an overlay under userData', () => { const svc = new PiTitlebarExtensionService() const env = svc.buildPtyEnv('pty-1', piHome, 'pi') - expect(env.PI_CODING_AGENT_DIR).toBe(join(userDataDir, 'pi-agent-overlays', 'pty-1')) + expect(env.PI_CODING_AGENT_DIR).toBe(overlayPath('pi', 'pty-1')) // Orca's titlebar extension is added alongside user extensions, not replacing them. const overlayExtensions = readdirSync(join(env.PI_CODING_AGENT_DIR!, 'extensions')).sort() expect(overlayExtensions).toEqual([ @@ -101,6 +134,17 @@ describe('PiTitlebarExtensionService', () => { expect(statusExtensionSource).toContain('/hook/pi') expect(statusExtensionSource).toContain('process.title') expect(statusExtensionSource).toContain("return '/hook/omp'") + expect( + JSON.parse(readFileSync(join(env.PI_CODING_AGENT_DIR!, 'settings.json'), 'utf-8')) + ).toEqual({ + defaultProvider: 'amazon-bedrock', + hideThinkingBlock: true, + packages: ['npm:pi-web-access'], + terminal: { + showImages: false, + clearOnShrink: true + } + }) // User's top-level resources are reachable via the overlay. expect(existsSync(join(env.PI_CODING_AGENT_DIR!, 'skills', 'my-skill', 'SKILL.md'))).toBe(true) expect(existsSync(join(env.PI_CODING_AGENT_DIR!, 'auth.json'))).toBe(true) @@ -109,10 +153,10 @@ describe('PiTitlebarExtensionService', () => { it('clearPty removes the overlay without touching the user Pi dir (issue #1083)', () => { const svc = new PiTitlebarExtensionService() - svc.buildPtyEnv('pty-2', piHome, 'pi') + const env = svc.buildPtyEnv('pty-2', piHome, 'pi') svc.clearPty('pty-2') - expect(existsSync(join(userDataDir, 'pi-agent-overlays', 'pty-2'))).toBe(false) + expect(existsSync(env.PI_CODING_AGENT_DIR!)).toBe(false) // Critical regression guard: destroying the overlay MUST NOT destroy the // user's Pi home, even though every top-level entry in the overlay is a // symlink/junction pointing back into it. @@ -136,16 +180,19 @@ describe('PiTitlebarExtensionService', () => { // Why: simulate an overlay that was left behind by a prior Orca session, // where the original Pi home it mirrored has since moved. The teardown // should unlink the dangling symlinks in place without trying to follow them. - const overlayDir = join(userDataDir, 'pi-agent-overlays', 'pty-4') - mkdirSync(overlayDir, { recursive: true }) - symlinkSync('/nonexistent-pi-target/skills', join(overlayDir, 'skills'), 'dir') - symlinkSync('/nonexistent-pi-target/auth.json', join(overlayDir, 'auth.json'), 'file') + const legacyOverlayDir = legacyOverlayPath('pi', 'pty-4') + mkdirSync(legacyOverlayDir, { recursive: true }) + symlinkSync('/nonexistent-pi-target/skills', join(legacyOverlayDir, 'skills'), 'dir') + symlinkSync('/nonexistent-pi-target/auth.json', join(legacyOverlayDir, 'auth.json'), 'file') const svc = new PiTitlebarExtensionService() const env = svc.buildPtyEnv('pty-4', piHome, 'pi') - expect(env.PI_CODING_AGENT_DIR).toBe(overlayDir) - expect(existsSync(join(overlayDir, 'skills', 'my-skill', 'SKILL.md'))).toBe(true) + expect(env.PI_CODING_AGENT_DIR).toBe(overlayPath('pi', 'pty-4')) + expect(existsSync(legacyOverlayDir)).toBe(false) + expect(existsSync(join(env.PI_CODING_AGENT_DIR!, 'skills', 'my-skill', 'SKILL.md'))).toBe( + true + ) expectPiHomeIntact() } ) @@ -174,7 +221,7 @@ describe('PiTitlebarExtensionService', () => { const svc = new PiTitlebarExtensionService() const env = svc.buildPtyEnv('pty-pi-both', undefined, 'pi') - expect(env.PI_CODING_AGENT_DIR).toBe(join(userDataDir, 'pi-agent-overlays', 'pty-pi-both')) + expect(env.PI_CODING_AGENT_DIR).toBe(overlayPath('pi', 'pty-pi-both')) // The Pi auth file must be the one mirrored (not OMP's). expect(readFileSync(join(env.PI_CODING_AGENT_DIR!, 'auth.json'), 'utf-8')).toBe( 'pi secret token' @@ -208,9 +255,7 @@ describe('PiTitlebarExtensionService', () => { // under userData/pi-agent-overlays. A future refactor that re-shares // the Pi overlay root for OMP would re-introduce cross-agent state // visibility this PR exists to prevent. - expect(env.PI_CODING_AGENT_DIR).toBe( - join(userDataDir, 'omp-agent-overlays', 'pty-omp-both') - ) + expect(env.PI_CODING_AGENT_DIR).toBe(overlayPath('omp', 'pty-omp-both')) // CRITICAL regression guard: even though ~/.pi/agent exists, the OMP // launch MUST resolve OMP's own source dir, not Pi's. expect(readFileSync(join(env.PI_CODING_AGENT_DIR!, 'auth.json'), 'utf-8')).toBe( @@ -250,9 +295,7 @@ describe('PiTitlebarExtensionService', () => { const svc = new PiTitlebarExtensionService() const env = svc.buildPtyEnv('pty-omp-empty', undefined, 'omp') - expect(env.PI_CODING_AGENT_DIR).toBe( - join(userDataDir, 'omp-agent-overlays', 'pty-omp-empty') - ) + expect(env.PI_CODING_AGENT_DIR).toBe(overlayPath('omp', 'pty-omp-empty')) // The Pi-only home must NOT leak into the OMP overlay; the auth // token from ~/.pi/agent/auth.json must be absent. expect(existsSync(join(env.PI_CODING_AGENT_DIR!, 'auth.json'))).toBe(false) @@ -264,6 +307,12 @@ describe('PiTitlebarExtensionService', () => { 'orca-prefill.ts', 'orca-titlebar-spinner.ts' ]) + expect( + JSON.parse(readFileSync(join(env.PI_CODING_AGENT_DIR!, 'settings.json'), 'utf-8')) + ).toEqual({ + hideThinkingBlock: true, + terminal: { clearOnShrink: true } + }) } finally { homedirOverride.current = '' rmSync(join(userDataDir, 'omp-agent-overlays', 'pty-omp-empty'), { diff --git a/src/main/pi/titlebar-extension-service.ts b/src/main/pi/titlebar-extension-service.ts index 16282341a41..6f47d874e2d 100644 --- a/src/main/pi/titlebar-extension-service.ts +++ b/src/main/pi/titlebar-extension-service.ts @@ -1,7 +1,8 @@ -import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'fs' +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'fs' import { homedir } from 'os' import { basename, join } from 'path' import { app } from 'electron' +import { createHash } from 'crypto' import { ORCA_PI_AGENT_STATUS_EXTENSION_FILE, getPiAgentStatusExtensionSource @@ -11,6 +12,7 @@ import { mirrorEntry, safeRemoveOverlay } from '../pty/overlay-mirror' +import { mergePiOverlayUiSettings } from '../../shared/pi-overlay-ui-settings' import type { PiAgentKind } from '../../shared/pi-agent-kind' // Why: the Pi test suite imports `isSafeDescendCandidate` from this module's @@ -22,6 +24,7 @@ export const isSafeDescendCandidate = sharedIsSafeDescendCandidate const ORCA_PI_EXTENSION_FILE = 'orca-titlebar-spinner.ts' const ORCA_PI_PREFILL_EXTENSION_FILE = 'orca-prefill.ts' const PI_AGENT_SUBDIR = 'agent' +const PI_AGENT_SETTINGS_FILE = 'settings.json' // Why: each agent owns its own overlay tree so OMP launches never touch // Pi's overlay dir (and vice versa). Shadowing one inside the other would @@ -151,12 +154,22 @@ function getDefaultPiAgentDir(kind: PiAgentKind): string { return join(homedir(), AGENT_HOME_DIR_NAME[kind], PI_AGENT_SUBDIR) } +function toSafeOverlayDirName(ptyId: string): string { + return createHash('sha256').update(ptyId).digest('hex').slice(0, 32) +} + export class PiTitlebarExtensionService { private getOverlayRoot(kind: PiAgentKind): string { return join(app.getPath('userData'), OVERLAY_ROOT_DIR_NAME[kind]) } private getOverlayDir(ptyId: string, kind: PiAgentKind): string { + // Why: daemon PTY session ids include worktree paths. Hashing keeps the + // overlay stable across daemon cold restore without path-shaped dirs. + return join(this.getOverlayRoot(kind), toSafeOverlayDirName(ptyId)) + } + + private getLegacyOverlayDir(ptyId: string, kind: PiAgentKind): string { return join(this.getOverlayRoot(kind), ptyId) } @@ -175,6 +188,10 @@ export class PiTitlebarExtensionService { for (const entry of readdirSync(sourceAgentDir, { withFileTypes: true })) { const sourcePath = join(sourceAgentDir, entry.name) + if (entry.name === PI_AGENT_SETTINGS_FILE) { + continue + } + if (entry.name === 'extensions' && entry.isDirectory()) { const overlayExtensionsDir = join(overlayDir, 'extensions') mkdirSync(overlayExtensionsDir, { recursive: true }) @@ -195,6 +212,29 @@ export class PiTitlebarExtensionService { } } + private readPiSettings(sourceAgentDir: string): unknown { + const settingsPath = join(sourceAgentDir, PI_AGENT_SETTINGS_FILE) + if (!existsSync(settingsPath)) { + return {} + } + + try { + return JSON.parse(readFileSync(settingsPath, 'utf8')) + } catch { + return {} + } + } + + private writeOverlaySettings(sourceAgentDir: string, overlayDir: string): void { + // Why: settings.json is a real overlay file, not a mirror, so Orca can + // apply UI-only safeguards without modifying the user's Pi / OMP config. + const settings = mergePiOverlayUiSettings(this.readPiSettings(sourceAgentDir)) + writeFileSync( + join(overlayDir, PI_AGENT_SETTINGS_FILE), + `${JSON.stringify(settings, null, 2)}\n` + ) + } + buildPtyEnv( ptyId: string, existingAgentDir: string | undefined, @@ -205,6 +245,7 @@ export class PiTitlebarExtensionService { try { this.safeRemoveOverlay(overlayDir, kind) + this.safeRemoveOverlay(this.getLegacyOverlayDir(ptyId, kind), kind) } catch { // Why: on Windows the overlay directory can be locked by another process // (e.g. antivirus, indexer, or a previous Orca session that didn't clean up). @@ -218,6 +259,7 @@ export class PiTitlebarExtensionService { try { mkdirSync(overlayDir, { recursive: true }) this.mirrorAgentDir(sourceAgentDir, overlayDir) + this.writeOverlaySettings(sourceAgentDir, overlayDir) const extensionsDir = join(overlayDir, 'extensions') mkdirSync(extensionsDir, { recursive: true }) @@ -263,6 +305,7 @@ export class PiTitlebarExtensionService { for (const kind of Object.keys(OVERLAY_ROOT_DIR_NAME) as PiAgentKind[]) { try { this.safeRemoveOverlay(this.getOverlayDir(ptyId, kind), kind) + this.safeRemoveOverlay(this.getLegacyOverlayDir(ptyId, kind), kind) } catch { // Why: on Windows the overlay dir can be locked (EPERM/EBUSY) by // antivirus or indexers. Overlay cleanup is best-effort - a stale diff --git a/src/main/ports/advertised-url-watcher.test.ts b/src/main/ports/advertised-url-watcher.test.ts index 8ec67d0b20b..d7de883a8ab 100644 --- a/src/main/ports/advertised-url-watcher.test.ts +++ b/src/main/ports/advertised-url-watcher.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { AdvertisedUrlWatcher, classifyHost, @@ -123,6 +123,18 @@ describe('AdvertisedUrlWatcher.ingest', () => { expect(watcher.lookup(WORKTREE, 3001)?.origin).toBe('https://local.getmontecarlo.com:3001') }) + it('does not scan buffered PTY text until a line break arrives', () => { + const watcher = bindFresh() + const charCodeAtSpy = vi.spyOn(String.prototype, 'charCodeAt') + watcher.ingest(PTY, ' Network: https://local.getmontecarlo') + const charCodeAtCalls = charCodeAtSpy.mock.calls.length + charCodeAtSpy.mockRestore() + + expect(charCodeAtCalls).toBe(0) + watcher.ingest(PTY, '.com:3001/\n') + expect(watcher.lookup(WORKTREE, 3001)?.origin).toBe('https://local.getmontecarlo.com:3001') + }) + it('reassembles an ANSI escape split across two chunks', () => { const watcher = bindFresh() watcher.ingest(PTY, '\x1b[32mhttps://example.com:3001/\x1b') @@ -186,6 +198,25 @@ describe('AdvertisedUrlWatcher.ingest', () => { expect(watcher.lookup(WORKTREE, 3001)?.host).toBe('app.example.com') }) + it('keeps repeated PTY binds as no-ops once pending data is drained', () => { + const watcher = new AdvertisedUrlWatcher({ now: () => 1_000 }) + watcher.ingest(PTY, 'early https://app.example.com:3001/\n') + watcher.bindPty(PTY, WORKTREE) + + const internals = watcher as unknown as { ptyToWorktree: Map } + const originalSet = internals.ptyToWorktree.set + const setSpy = vi.fn(originalSet.bind(internals.ptyToWorktree)) + internals.ptyToWorktree.set = setSpy + try { + watcher.bindPty(PTY, WORKTREE) + } finally { + internals.ptyToWorktree.set = originalSet + } + + expect(setSpy).not.toHaveBeenCalled() + expect(watcher.lookup(WORKTREE, 3001)?.host).toBe('app.example.com') + }) + it('unbindPty drops buffered state for the PTY', () => { const watcher = bindFresh() watcher.ingest(PTY, 'cached https://cached.example.com:3002/\n') @@ -202,6 +233,28 @@ describe('AdvertisedUrlWatcher.ingest', () => { expect(events).toContainEqual({ worktreeId: WORKTREE, port: 3002 }) }) + it('forgetWorktree drops scan snapshots, PTY buffers, and cached URLs', () => { + const watcher = bindFresh() + watcher.reconcileScan([WORKTREE], [{ port: 3001, pid: 100 }]) + watcher.ingest(PTY, 'cached https://cached.example.com:3001/\n') + watcher.ingest(PTY, 'partial https://example.com:3002') + const events: { worktreeId: string; port: number }[] = [] + watcher.onDidChange((event) => events.push(event)) + + watcher.forgetWorktree(WORKTREE) + + const internals = watcher as unknown as { + buffers: Map + ptyToWorktree: Map + scanSnapshots: Map> + } + expect(watcher.lookup(WORKTREE, 3001)).toBeUndefined() + expect(internals.buffers.has(PTY)).toBe(false) + expect(internals.ptyToWorktree.has(PTY)).toBe(false) + expect(internals.scanSnapshots.has(WORKTREE)).toBe(false) + expect(events).toEqual([{ worktreeId: WORKTREE, port: 3001 }]) + }) + it('different worktrees on the same port are tracked independently', () => { const watcher = bindFresh() watcher.bindPty('pty-2', 'repo::/other') diff --git a/src/main/ports/advertised-url-watcher.ts b/src/main/ports/advertised-url-watcher.ts index 6d338e8fabe..804237e3fd6 100644 --- a/src/main/ports/advertised-url-watcher.ts +++ b/src/main/ports/advertised-url-watcher.ts @@ -82,10 +82,14 @@ class PtyBuffer { * and including the last newline). Whatever follows the last newline stays * buffered so that a URL or ANSI sequence split across chunks survives. */ ingest(chunk: string): string { + const chunkHasLineBreak = chunk.includes('\n') || chunk.includes('\r') this.raw += chunk if (this.raw.length > PER_PTY_BUFFER_LIMIT) { this.raw = this.raw.slice(-PER_PTY_BUFFER_LIMIT) } + if (!chunkHasLineBreak) { + return '' + } const lastNewline = lastLineBreak(this.raw) if (lastNewline === -1) { return '' @@ -281,8 +285,11 @@ export class AdvertisedUrlWatcher { } bindPty(ptyId: string, worktreeId: string): void { - this.ptyToWorktree.set(ptyId, worktreeId) const pending = this.pending.get(ptyId) + if (this.ptyToWorktree.get(ptyId) === worktreeId && pending === undefined) { + return + } + this.ptyToWorktree.set(ptyId, worktreeId) if (pending !== undefined) { this.pending.delete(ptyId) this.ingest(ptyId, pending) @@ -311,6 +318,34 @@ export class AdvertisedUrlWatcher { } } + forgetWorktree(worktreeId: string): void { + // Why: worktree IDs are reused for the same repo/path, so removed + // worktrees must not leave scan baselines for a future workspace. + for (const [ptyId, boundWorktreeId] of this.ptyToWorktree) { + if (boundWorktreeId !== worktreeId) { + continue + } + this.ptyToWorktree.delete(ptyId) + this.buffers.delete(ptyId) + } + + this.scanSnapshots.delete(worktreeId) + const removedEvents: AdvertisedUrlChangeEvent[] = [] + for (const [key, entry] of this.cache) { + const entryWorktreeId = worktreeIdFromCacheKey(key, entry.port) + if (entryWorktreeId !== worktreeId) { + continue + } + this.cache.delete(key) + this.validationBaselines.delete(key) + this.startupAbsentAllowances.delete(key) + removedEvents.push({ worktreeId, port: entry.port }) + } + for (const event of dedupeChangeEvents(removedEvents)) { + this.emitChange(event) + } + } + ingest(ptyId: string, chunk: string, now?: number): void { if (!chunk) { return diff --git a/src/main/ports/local-workspace-port-scanner.test.ts b/src/main/ports/local-workspace-port-scanner.test.ts index be21fa7bc86..c3292390dc3 100644 --- a/src/main/ports/local-workspace-port-scanner.test.ts +++ b/src/main/ports/local-workspace-port-scanner.test.ts @@ -1,12 +1,20 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import path from 'path' import { attributePortToWorkspace, isContainerProcess, parseLsofListeningOutput, parseNetstatListeningOutput, - parseProcNetTcp + parseProcNetTcp, + scanWorkspacePorts } from './local-workspace-port-scanner' +const execFileMock = vi.hoisted(() => vi.fn()) + +vi.mock('child_process', () => ({ + execFile: execFileMock +})) + const worktrees = [ { id: 'repo::/repo', @@ -134,3 +142,93 @@ describe('container process classification', () => { expect(isContainerProcess({ processName: 'node', commandLine: 'node server.js' })).toBe(false) }) }) + +describe('scanWorkspacePorts attribution work', () => { + afterEach(() => { + vi.restoreAllMocks() + execFileMock.mockReset() + }) + + it('normalizes worktree paths once per scan instead of once per port phase', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + const resolveSpy = vi.spyOn(path, 'resolve') + const invokeCallback = (callback: unknown, stdout: string): void => { + if (typeof callback !== 'function') { + throw new Error('missing execFile callback') + } + const execCallback = callback as (error: Error | null, stdout: string) => void + execCallback(null, stdout) + } + execFileMock.mockImplementation( + (command: string, args: string[], _options: unknown, callback: unknown) => { + if (command === 'lsof' && args.includes('-iTCP')) { + invokeCallback( + callback, + ['p123', 'cnode', 'n127.0.0.1:3000', 'p124', 'cnode', 'n127.0.0.1:3001'].join('\n') + ) + } else if (command === 'lsof') { + invokeCallback( + callback, + ['p123', 'n/repo/service', 'p124', 'n/repo/worktrees/feature/app'].join('\n') + ) + } else if (command === 'ps') { + invokeCallback( + callback, + [ + '123 node /repo/service/server.js', + '124 node /repo/worktrees/feature/app/server.js' + ].join('\n') + ) + } else { + invokeCallback(callback, '') + } + return { kill: vi.fn() } + } + ) + + const scan = await scanWorkspacePorts(worktrees, { + lookup: () => undefined, + reconcileScan: vi.fn() + }) + + expect(scan.ports.filter((port) => port.kind === 'workspace')).toHaveLength(2) + const worktreePathResolveCalls = resolveSpy.mock.calls.filter( + ([input]) => input === '/repo' || input === '/repo/worktrees/feature' + ) + expect(worktreePathResolveCalls).toHaveLength(worktrees.length) + }) +}) + +describe('scanWorkspacePorts command timeout', () => { + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + execFileMock.mockReset() + }) + + it('returns an unavailable scan when lsof never reports completion', async () => { + vi.useFakeTimers() + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + const killMock = vi.fn() + execFileMock.mockImplementation(() => ({ kill: killMock })) + + let settled = false + const scanPromise = scanWorkspacePorts([], { + lookup: () => undefined, + reconcileScan: vi.fn() + }).then((scan) => { + settled = true + return scan + }) + + await vi.advanceTimersByTimeAsync(4_000) + + expect(settled).toBe(true) + await expect(scanPromise).resolves.toMatchObject({ + platform: 'darwin', + ports: [], + unavailableReason: 'Port scanning is unavailable on darwin.' + }) + expect(killMock).toHaveBeenCalled() + }) +}) diff --git a/src/main/ports/local-workspace-port-scanner.ts b/src/main/ports/local-workspace-port-scanner.ts index 8691b31af96..31fe8ae3401 100644 --- a/src/main/ports/local-workspace-port-scanner.ts +++ b/src/main/ports/local-workspace-port-scanner.ts @@ -1,7 +1,6 @@ /* eslint-disable max-lines -- Why: the platform-specific scan paths share parsing, attribution, and normalization rules that must stay in lockstep. */ import { execFile } from 'child_process' -import { promisify } from 'util' import { readFile, readdir, readlink } from 'fs/promises' import path from 'path' import type { @@ -12,8 +11,6 @@ import type { } from '../../shared/workspace-ports' import { advertisedUrlWatcher, type AdvertisedUrlWatcher } from './advertised-url-watcher' -const execFileAsync = promisify(execFile) - const COMMAND_TIMEOUT_MS = 4_000 const MAX_PORTS = 200 const HTTP_PORTS = new Set([80, 3000, 3001, 4200, 5000, 5173, 5174, 8000, 8080, 8888]) @@ -34,15 +31,21 @@ type ProcessMetadata = { cwd?: string } +type NormalizedWorkspacePortProbe = { + worktree: WorkspacePortProbe + normalizedPath: string +} + export async function scanWorkspacePorts( worktrees: WorkspacePortProbe[], urlWatcher: Pick = advertisedUrlWatcher ): Promise { try { const rawPorts = await scanPlatformListeningPorts() - reconcileAdvertisedUrls(rawPorts, worktrees, urlWatcher) + const normalizedWorktrees = normalizeWorkspacePortProbes(worktrees) + reconcileAdvertisedUrls(rawPorts, normalizedWorktrees, urlWatcher) const ports = rawPorts - .map((port) => enrichPort(port, worktrees, urlWatcher)) + .map((port) => enrichPort(port, normalizedWorktrees, urlWatcher)) .sort(compareWorkspacePorts) .slice(0, MAX_PORTS) return { platform: process.platform, scannedAt: Date.now(), ports } @@ -60,17 +63,31 @@ export async function scanWorkspacePorts( export function attributePortToWorkspace( port: Pick, worktrees: WorkspacePortProbe[] +): WorkspacePortOwner | undefined { + return attributePortToNormalizedWorkspaces(port, normalizeWorkspacePortProbes(worktrees)) +} + +function normalizeWorkspacePortProbes( + worktrees: readonly WorkspacePortProbe[] +): NormalizedWorkspacePortProbe[] { + return worktrees.map((worktree) => ({ + worktree, + normalizedPath: normalizeComparablePath(worktree.path) + })) +} + +function attributePortToNormalizedWorkspaces( + port: Pick, + worktrees: readonly NormalizedWorkspacePortProbe[] ): WorkspacePortOwner | undefined { const cwd = port.cwd ? normalizeComparablePath(port.cwd) : null const commandLine = port.commandLine ? normalizeComparableText(port.commandLine) : null - const cwdMatches = cwd - ? worktrees - .map((worktree) => ({ worktree, normalizedPath: normalizeComparablePath(worktree.path) })) - .filter(({ normalizedPath }) => isSameOrDescendant(cwd, normalizedPath)) - : [] - - const cwdMatch = pickDeepestMatch(cwdMatches) + const cwdMatch = cwd + ? pickDeepestMatching(worktrees, ({ normalizedPath }) => + isSameOrDescendant(cwd, normalizedPath) + ) + : undefined if (cwdMatch) { return toOwner(cwdMatch.worktree, 'cwd') } @@ -79,10 +96,9 @@ export function attributePortToWorkspace( return undefined } - const commandMatches = worktrees - .map((worktree) => ({ worktree, normalizedPath: normalizeComparablePath(worktree.path) })) - .filter(({ normalizedPath }) => includesPathBoundary(commandLine, normalizedPath)) - const commandMatch = pickDeepestMatch(commandMatches) + const commandMatch = pickDeepestMatching(worktrees, ({ normalizedPath }) => + includesPathBoundary(commandLine, normalizedPath) + ) return commandMatch ? toOwner(commandMatch.worktree, 'command') : undefined } @@ -223,6 +239,9 @@ async function readProcNet( async function mapLinuxInodesToPids(inodes: Set): Promise> { const result = new Map() + if (inodes.size === 0) { + return result + } let pids: string[] try { pids = (await readdir('/proc')).filter((entry) => /^\d+$/.test(entry)) @@ -340,12 +359,50 @@ async function loadWindowsProcessMetadata( } async function runCommand(command: string, args: string[]): Promise<{ stdout: string }> { - const { stdout } = await execFileAsync(command, args, { - timeout: COMMAND_TIMEOUT_MS, - maxBuffer: 2 * 1024 * 1024, - windowsHide: true + return await new Promise((resolve, reject) => { + let settled = false + let child: ReturnType | undefined + const timer = setTimeout(() => { + if (settled) { + return + } + settled = true + child?.kill() + reject(new Error(`${command} timed out after ${COMMAND_TIMEOUT_MS}ms`)) + }, COMMAND_TIMEOUT_MS) + + const settle = (callback: () => void): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + callback() + } + + // Why: Node's execFile timeout only signals the child; if the callback + // never arrives, the workspace port scan would otherwise hang forever. + try { + child = execFile( + command, + args, + { + timeout: COMMAND_TIMEOUT_MS, + maxBuffer: 2 * 1024 * 1024, + windowsHide: true + }, + (error, stdout) => { + if (error) { + settle(() => reject(error)) + return + } + settle(() => resolve({ stdout: String(stdout) })) + } + ) + } catch (error) { + settle(() => reject(error)) + } }) - return { stdout: String(stdout) } } async function readTextIfAvailable(filePath: string): Promise { @@ -358,10 +415,10 @@ async function readTextIfAvailable(filePath: string): Promise ): WorkspacePort { - const owner = attributePortToWorkspace(port, worktrees) + const owner = attributePortToNormalizedWorkspaces(port, worktrees) const base = { id: `${port.host}:${port.port}:${port.pid ?? 'unknown'}`, bindHost: port.host, @@ -393,15 +450,15 @@ function enrichPort( function reconcileAdvertisedUrls( ports: RawListeningPort[], - worktrees: WorkspacePortProbe[], + worktrees: readonly NormalizedWorkspacePortProbe[], urlWatcher: Pick ): void { const observationsByWorktree = new Map() for (const worktree of worktrees) { - observationsByWorktree.set(worktree.id, []) + observationsByWorktree.set(worktree.worktree.id, []) } for (const port of ports) { - const owner = attributePortToWorkspace(port, worktrees) + const owner = attributePortToNormalizedWorkspaces(port, worktrees) if (!owner) { continue } @@ -450,8 +507,20 @@ function toOwner( } } -function pickDeepestMatch(matches: T[]): T | undefined { - return matches.sort((a, b) => b.normalizedPath.length - a.normalizedPath.length)[0] +function pickDeepestMatching( + candidates: readonly T[], + predicate: (candidate: T) => boolean +): T | undefined { + let best: T | undefined + for (const candidate of candidates) { + if (!predicate(candidate)) { + continue + } + if (!best || candidate.normalizedPath.length > best.normalizedPath.length) { + best = candidate + } + } + return best } function isSameOrDescendant(candidate: string, parent: string): boolean { diff --git a/src/main/ports/workspace-port-ownership.ts b/src/main/ports/workspace-port-ownership.ts index ab810e8d818..a87ece6874d 100644 --- a/src/main/ports/workspace-port-ownership.ts +++ b/src/main/ports/workspace-port-ownership.ts @@ -18,28 +18,41 @@ export function getStoreWorkspacePortProbes( store: Pick, repoId?: string ): WorkspacePortProbe[] { - const reposById = new Map(store.getRepos().map((repo) => [repo.id, repo])) - return Object.entries(store.getAllWorktreeMeta()).flatMap(([worktreeId, meta]) => { + const repos = store.getRepos() + const repoForFilter = repoId ? repos.find((repo) => repo.id === repoId) : undefined + if (repoId && (!repoForFilter || repoForFilter.connectionId)) { + return [] + } + const reposById = repoForFilter ? undefined : new Map(repos.map((repo) => [repo.id, repo])) + const probes: WorkspacePortProbe[] = [] + const allMeta = store.getAllWorktreeMeta() + for (const worktreeId in allMeta) { + if (!Object.hasOwn(allMeta, worktreeId)) { + continue + } const parsed = splitWorktreeId(worktreeId) if (!parsed || (repoId && parsed.repoId !== repoId)) { - return [] + continue } - const repo = reposById.get(parsed.repoId) + const repo = repoForFilter ?? reposById?.get(parsed.repoId) if (!repo || repo.connectionId) { - return [] + continue + } + const meta = allMeta[worktreeId] + if (!meta) { + continue } const worktreePath = isFolderRepo(repo) ? (splitWorktreeIdForFilesystem(worktreeId)?.worktreePath ?? parsed.worktreePath) : parsed.worktreePath - return [ - { - id: worktreeId, - repoId: parsed.repoId, - displayName: meta.displayName || path.basename(worktreePath), - path: worktreePath - } - ] - }) + probes.push({ + id: worktreeId, + repoId: parsed.repoId, + displayName: meta.displayName || path.basename(worktreePath), + path: worktreePath + }) + } + return probes } export function filterWorkspacePortProbes( diff --git a/src/main/project-groups/nested-repo-discovery.test.ts b/src/main/project-groups/nested-repo-discovery.test.ts index c1ec7dd19d9..65332a88756 100644 --- a/src/main/project-groups/nested-repo-discovery.test.ts +++ b/src/main/project-groups/nested-repo-discovery.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' +import { mkdtemp, mkdir, writeFile, rm, symlink } from 'fs/promises' import { join } from 'path' import { tmpdir } from 'os' import { afterEach, describe, expect, it } from 'vitest' @@ -76,4 +76,19 @@ describe('scanNestedRepos', () => { expect(result.selectedPathKind).toBe('git_repo') expect(result.repos).toEqual([]) }) + + it.skipIf(process.platform === 'win32')( + 'does not follow symlinked directories outside the selected folder', + async () => { + const root = await tempRoot() + const external = await tempRoot() + await mkdir(join(external, 'outside-repo'), { recursive: true }) + await makeGitRepo(join(external, 'outside-repo')) + await symlink(external, join(root, 'linked'), 'dir') + + const result = await scanNestedRepos({ path: root }) + + expect(result.repos).toEqual([]) + } + ) }) diff --git a/src/main/project-groups/nested-repo-discovery.ts b/src/main/project-groups/nested-repo-discovery.ts index 07b65974a87..129e35b9ecb 100644 --- a/src/main/project-groups/nested-repo-discovery.ts +++ b/src/main/project-groups/nested-repo-discovery.ts @@ -70,13 +70,10 @@ async function hasGitMarker(dirPath: string): Promise { } async function readLocalDirectory(dirPath: string): Promise { - const entries = await readdir(dirPath) - const result: NestedRepoDirectoryEntry[] = [] - for (const name of entries) { - const childStat = await stat(join(dirPath, name)).catch(() => null) - result.push({ name, isDirectory: childStat?.isDirectory() === true }) - } - return result + // Why: Dirent data avoids one stat per child and keeps symlinked directories + // from expanding the scan outside the selected folder. + const entries = await readdir(dirPath, { withFileTypes: true }) + return entries.map((entry) => ({ name: entry.name, isDirectory: entry.isDirectory() })) } export async function scanNestedRepos(args: { diff --git a/src/main/providers/__tests__/shell-ready-framework/README.md b/src/main/providers/__tests__/shell-ready-framework/README.md index a94aa6b2340..f3122326e6c 100644 --- a/src/main/providers/__tests__/shell-ready-framework/README.md +++ b/src/main/providers/__tests__/shell-ready-framework/README.md @@ -104,6 +104,7 @@ Update snapshots with `vitest -u`. ## When to use this framework Use this framework for **new shell-ready tests** where: + - You want the test to be easy to replicate manually - The setup is shell-script-based (file creation, env vars) - You want a declarative snapshot-driven style diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index 9871805b496..4f58591d814 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -296,10 +296,34 @@ describe('LocalPtyProvider', () => { '--', 'bash', '-c', - "cd '/mnt/c/Users/jin/repo' && exec bash -l" + 'cd \'/mnt/c/Users/jin/repo\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l' ]) }) + it('marks Orca terminal handle for WSL import when buildSpawnEnv opts in', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + provider.configure({ + buildSpawnEnv: (_id, env, ctx) => { + env.ORCA_TERMINAL_HANDLE = 'term_wsl' + if (ctx?.isWsl) { + env.WSLENV = 'ORCA_TERMINAL_HANDLE/u' + } + return env + } + }) + + await provider.spawn({ + cols: 80, + rows: 24, + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo' + }) + + const spawnCall = spawnMock.mock.calls.at(-1)! + expect(spawnCall[0]).toBe('wsl.exe') + expect(spawnCall[2].env.ORCA_TERMINAL_HANDLE).toBe('term_wsl') + expect(spawnCall[2].env.WSLENV).toBe('ORCA_TERMINAL_HANDLE/u') + }) + it('does not inherit parent Orca pane identity when caller omits pane env', async () => { const saved = { ORCA_PANE_KEY: process.env.ORCA_PANE_KEY, @@ -425,10 +449,54 @@ describe('LocalPtyProvider', () => { expect(spawnMock).toHaveBeenCalledWith( 'wsl.exe', - ['-d', 'Ubuntu', '--', 'bash', '-c', "cd '/home/jin/repo/subdir' && exec bash -l"], + [ + '-d', + 'Ubuntu', + '--', + 'bash', + '-c', + 'cd \'/home/jin/repo/subdir\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l' + ], expect.objectContaining({ cwd: expect.any(String) }) ) }) + + it('resolves the Git Bash default shell and preserves the requested cwd', async () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + const originalProgramFiles = process.env.ProgramFiles + Object.defineProperty(process, 'platform', { value: 'win32' }) + process.env.ProgramFiles = 'C:\\Program Files' + provider.configure({ getWindowsShell: () => 'git-bash' }) + + try { + await provider.spawn({ + cols: 80, + rows: 24, + cwd: 'C:\\Users\\jin\\repo' + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + if (originalProgramFiles === undefined) { + delete process.env.ProgramFiles + } else { + process.env.ProgramFiles = originalProgramFiles + } + } + + expect(spawnMock).toHaveBeenCalledWith( + 'C:\\Program Files\\Git\\bin\\bash.exe', + ['--login', '-i'], + expect.objectContaining({ + cwd: 'C:\\Users\\jin\\repo', + env: expect.objectContaining({ + CHERE_INVOKING: '1', + PYTHONUTF8: '1' + }) + }) + ) + }) }) describe('write', () => { diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index c14c9b88884..d906f961e21 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -33,6 +33,12 @@ import { import { removeInheritedNoColor } from '../pty/terminal-color-env' import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' import { addWslEnvKeys } from '../wsl-env' +import { + isWindowsGitBashShellPath, + resolveGitBashPath, + resolveWindowsGitBashShellPath +} from '../git-bash' +import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell' const PANE_IDENTITY_ENV_KEYS = ['ORCA_PANE_KEY', 'ORCA_TAB_ID', 'ORCA_WORKTREE_ID'] as const @@ -156,7 +162,7 @@ export type LocalPtyProviderOptions = { buildSpawnEnv?: ( id: string, baseEnv: Record, - ctx?: { command?: string; isWsl?: boolean; wslDistro?: string | null } + ctx?: { command?: string; shellPath?: string; isWsl?: boolean; wslDistro?: string | null } ) => Record /** Whether worktree-scoped shell history is enabled. When true (or absent) * and a worktreeId is provided, HISTFILE is scoped per-worktree. */ @@ -207,11 +213,11 @@ export class LocalPtyProvider implements IPtyProvider { | ((shell: string) => ReturnType) | undefined if (wslInfo) { - const escapedCwd = wslInfo.linuxPath.replace(/'/g, "'\\''") shellPath = 'wsl.exe' - shellArgs = ['-d', wslInfo.distro, '--', 'bash', '-c', `cd '${escapedCwd}' && exec bash -l`] - effectiveCwd = getDefaultCwd() - validationCwd = cwd + const resolved = resolveWindowsShellLaunchArgs(shellPath, cwd, defaultCwd) + shellArgs = resolved.shellArgs + effectiveCwd = resolved.effectiveCwd + validationCwd = resolved.validationCwd } else if (process.platform === 'win32') { // Why: shellOverride lets a single tab open in a different shell than the // persisted default (e.g. "New WSL terminal" from the "+" submenu) without @@ -223,6 +229,7 @@ export class LocalPtyProvider implements IPtyProvider { 'powershell.exe' const shellFamily = worktreeWslContext ? 'wsl.exe' : requestedShellFamily const normalizedShellFamily = pathWin32.basename(shellFamily).toLowerCase() + const resolvedGitBashPath = resolveWindowsGitBashShellPath(shellFamily) // Why: shell selection can arrive either as a canonical setting value // ('powershell.exe') or as a concrete PowerShell executable path from a // one-off override. Normalize both forms back to the PowerShell family so @@ -231,18 +238,24 @@ export class LocalPtyProvider implements IPtyProvider { const powerShellImplementation = this.opts.getWindowsPowerShellImplementation?.() const shouldResolvePowerShellFamily = powerShellImplementation !== undefined || pathWin32.basename(shellFamily) === shellFamily - shellPath = shouldResolvePowerShellFamily - ? (resolveEffectiveWindowsPowerShell({ - shellFamily: - normalizedShellFamily === 'powershell.exe' || normalizedShellFamily === 'pwsh.exe' - ? 'powershell.exe' - : normalizedShellFamily === 'cmd.exe' || normalizedShellFamily === 'wsl.exe' - ? normalizedShellFamily - : undefined, - implementation: powerShellImplementation, - pwshAvailable: this.opts.pwshAvailable?.() ?? false - }) ?? shellFamily) - : shellFamily + if (resolvedGitBashPath) { + shellPath = resolvedGitBashPath + } else if (shellFamily === WINDOWS_GIT_BASH_SHELL) { + shellPath = 'powershell.exe' + } else { + shellPath = shouldResolvePowerShellFamily + ? (resolveEffectiveWindowsPowerShell({ + shellFamily: + normalizedShellFamily === 'powershell.exe' || normalizedShellFamily === 'pwsh.exe' + ? 'powershell.exe' + : normalizedShellFamily === 'cmd.exe' || normalizedShellFamily === 'wsl.exe' + ? normalizedShellFamily + : undefined, + implementation: powerShellImplementation, + pwshAvailable: this.opts.pwshAvailable?.() ?? false + }) ?? shellFamily) + : shellFamily + } // Why: one-off overrides and persisted shell-family selection keep the // same priority, while the shared resolver chooses which PowerShell // executable is safe to run right now if that family is selected. @@ -306,6 +319,11 @@ export class LocalPtyProvider implements IPtyProvider { // Python scripts run inside the terminal. if (process.platform === 'win32') { spawnEnv.PYTHONUTF8 ??= '1' + if (isWindowsGitBashShellPath(shellPath)) { + // Why: Git for Windows login startup files otherwise cd to $HOME, + // ignoring node-pty's cwd for repo-scoped terminals. + spawnEnv.CHERE_INVOKING ??= '1' + } } const isWslShell = Boolean(wslInfo) || pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe' @@ -314,6 +332,7 @@ export class LocalPtyProvider implements IPtyProvider { const finalEnv = this.opts.buildSpawnEnv ? this.opts.buildSpawnEnv(id, spawnEnv, { command: args.command, + shellPath, isWsl: isWslShell, wslDistro: launchWslDistro }) @@ -663,6 +682,10 @@ export class LocalPtyProvider implements IPtyProvider { { name: 'PowerShell', path: 'powershell.exe' }, { name: 'Command Prompt', path: 'cmd.exe' } ] + const gitBashPath = resolveGitBashPath() + if (gitBashPath) { + profiles.push({ name: 'Git Bash', path: gitBashPath }) + } if (isWslAvailable()) { profiles.push({ name: 'WSL', path: 'wsl.exe' }) } diff --git a/src/main/providers/process-cwd.test.ts b/src/main/providers/process-cwd.test.ts index 84a958c68a2..d0b2e8f0830 100644 --- a/src/main/providers/process-cwd.test.ts +++ b/src/main/providers/process-cwd.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { execFileMock, readlinkMock } = vi.hoisted(() => ({ execFileMock: vi.fn(), @@ -26,6 +26,10 @@ describe('resolveProcessCwd', () => { }) }) + afterEach(() => { + vi.useRealTimers() + }) + it('bounds cached cwd results across unique process ids', async () => { const { resolveProcessCwd } = await import('./process-cwd') @@ -41,4 +45,32 @@ describe('resolveProcessCwd', () => { await expect(resolveProcessCwd(1)).resolves.toBe('/cwd/1') expect(readlinkMock).toHaveBeenCalledTimes(258) }) + + it('falls back when lsof never reports completion', async () => { + vi.useFakeTimers() + readlinkMock.mockRejectedValue(new Error('proc unavailable')) + const killMock = vi.fn() + execFileMock.mockImplementation(() => ({ kill: killMock })) + const { resolveProcessCwd } = await import('./process-cwd') + + let settled = false + const cwdPromise = resolveProcessCwd(42).then((cwd) => { + settled = true + return cwd + }) + + await vi.waitFor(() => + expect(execFileMock).toHaveBeenCalledWith( + 'lsof', + ['-a', '-p', '42', '-d', 'cwd', '-Fn'], + { encoding: 'utf-8', timeout: 1500 }, + expect.any(Function) + ) + ) + await vi.advanceTimersByTimeAsync(1500) + + expect(settled).toBe(true) + await expect(cwdPromise).resolves.toBe('') + expect(killMock).toHaveBeenCalled() + }) }) diff --git a/src/main/providers/process-cwd.ts b/src/main/providers/process-cwd.ts index 9df14b6f393..dc70edca089 100644 --- a/src/main/providers/process-cwd.ts +++ b/src/main/providers/process-cwd.ts @@ -1,8 +1,5 @@ import { execFile as execFileCb } from 'child_process' import { readlink } from 'fs/promises' -import { promisify } from 'util' - -const execFile = promisify(execFileCb) /** * Resolve the current working directory of a local process by pid. @@ -92,10 +89,7 @@ async function doResolve(pid: number): Promise { // and emits cwd records for every process on the system, so the n-line // scan below picks up the first unrelated process (often pid ~391 with // cwd `/`) and returns `/` regardless of the target pid's real cwd. - const { stdout } = await execFile('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], { - encoding: 'utf-8', - timeout: LSOF_TIMEOUT_MS - }) + const stdout = await readCwdWithLsof(pid) for (const line of stdout.split('\n')) { if (line.startsWith('n') && line.includes('/')) { // Why: lsof -d cwd is authoritative — don't second-guess it with @@ -111,3 +105,49 @@ async function doResolve(pid: number): Promise { return '' } + +function readCwdWithLsof(pid: number): Promise { + return new Promise((resolve, reject) => { + let settled = false + let child: ReturnType | undefined + const timer = setTimeout(() => { + if (settled) { + return + } + settled = true + child?.kill() + reject(new Error(`lsof timed out after ${LSOF_TIMEOUT_MS}ms`)) + }, LSOF_TIMEOUT_MS) + + const settle = (callback: () => void): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + callback() + } + + // Why: execFile's timeout only signals lsof; a missing callback would + // otherwise leave the shared per-pid cwd lookup promise cached forever. + try { + child = execFileCb( + 'lsof', + ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], + { + encoding: 'utf-8', + timeout: LSOF_TIMEOUT_MS + }, + (error, stdout) => { + if (error) { + settle(() => reject(error)) + return + } + settle(() => resolve(String(stdout))) + } + ) + } catch (error) { + settle(() => reject(error)) + } + }) +} diff --git a/src/main/providers/ssh-filesystem-provider.test.ts b/src/main/providers/ssh-filesystem-provider.test.ts index 07b64914be5..b69662b6ce2 100644 --- a/src/main/providers/ssh-filesystem-provider.test.ts +++ b/src/main/providers/ssh-filesystem-provider.test.ts @@ -117,6 +117,7 @@ describe('SshFilesystemProvider', () => { const written: Buffer[] = [] const writeStream = { on: vi.fn((_event: string, _handler: (...args: unknown[]) => void) => writeStream), + off: vi.fn((_event: string, _handler: (...args: unknown[]) => void) => writeStream), end: vi.fn((buffer: Buffer) => { written.push(buffer) const closeHandler = writeStream.on.mock.calls.find(([event]) => event === 'close')?.[1] @@ -141,6 +142,7 @@ describe('SshFilesystemProvider', () => { it('can append decoded chunks through SFTP', async () => { const writeStream = { on: vi.fn((_event: string, _handler: (...args: unknown[]) => void) => writeStream), + off: vi.fn((_event: string, _handler: (...args: unknown[]) => void) => writeStream), end: vi.fn((_buffer: Buffer) => { const closeHandler = writeStream.on.mock.calls.find(([event]) => event === 'close')?.[1] closeHandler?.() @@ -469,6 +471,43 @@ describe('SshFilesystemProvider', () => { expect(mux.notify).toHaveBeenCalledWith('fs.unwatch', { rootPath: '/home/user/project' }) }) + it('sends fs.unwatch for active roots when disposed', async () => { + const callback = vi.fn() + await provider.watch('/home/user/project', callback) + + provider.dispose() + + expect(mux.notify).toHaveBeenCalledWith('fs.unwatch', { rootPath: '/home/user/project' }) + const notifHandler = mux.onNotification.mock.calls[0][0] + notifHandler('fs.changed', { + events: [{ kind: 'update', absolutePath: '/home/user/project/file.ts' }] + }) + expect(callback).not.toHaveBeenCalled() + }) + + it('unwatches when disposed while fs.watch setup is still resolving', async () => { + let resolveWatch: () => void = () => {} + mux.request.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveWatch = resolve + }) + ) + const callback = vi.fn() + const pendingWatch = provider.watch('/home/user/project', callback) + + provider.dispose() + resolveWatch() + + await expect(pendingWatch).rejects.toThrow('SSH filesystem provider disposed') + expect(mux.notify).toHaveBeenCalledWith('fs.unwatch', { rootPath: '/home/user/project' }) + const notifHandler = mux.onNotification.mock.calls[0][0] + notifHandler('fs.changed', { + events: [{ kind: 'update', absolutePath: '/home/user/project/file.ts' }] + }) + expect(callback).not.toHaveBeenCalled() + }) + it('does not send fs.unwatch while other roots are watched', async () => { const cb1 = vi.fn() const cb2 = vi.fn() diff --git a/src/main/providers/ssh-filesystem-provider.ts b/src/main/providers/ssh-filesystem-provider.ts index 6268a22aa6b..aabefb9293e 100644 --- a/src/main/providers/ssh-filesystem-provider.ts +++ b/src/main/providers/ssh-filesystem-provider.ts @@ -49,6 +49,7 @@ export class SshFilesystemProvider implements IFilesystemProvider { // the provider is torn down on disconnect, routing events to stale state. private unsubscribeNotifications: (() => void) | null = null private tempDirPromise: Promise | null = null + private disposed = false // Why: relays from a previous build may not implement fs.readFileStream. // We log the fallback once per session at warn level so users on stale // relays get diagnosed quickly without per-read log spam. @@ -78,10 +79,17 @@ export class SshFilesystemProvider implements IFilesystemProvider { } dispose(): void { + if (this.disposed) { + return + } + this.disposed = true if (this.unsubscribeNotifications) { this.unsubscribeNotifications() this.unsubscribeNotifications = null } + for (const rootPath of this.watchListeners.keys()) { + this.notifyUnwatch(rootPath) + } this.watchListeners.clear() } @@ -251,10 +259,16 @@ export class SshFilesystemProvider implements IFilesystemProvider { } async watch(rootPath: string, callback: (events: FsChangeEvent[]) => void): Promise<() => void> { + if (this.disposed) { + throw new Error('SSH filesystem provider disposed') + } let registration = this.watchListeners.get(rootPath) if (registration) { registration.callbacks.add(callback) await registration.setupPromise + if (this.disposed || this.watchListeners.get(rootPath) !== registration) { + throw new Error('SSH filesystem provider disposed') + } return this.createWatchUnsubscribe(rootPath, registration, callback) } @@ -271,10 +285,22 @@ export class SshFilesystemProvider implements IFilesystemProvider { registration = { callbacks, setupPromise } this.watchListeners.set(rootPath, registration) await setupPromise + if (this.disposed || this.watchListeners.get(rootPath) !== registration) { + this.notifyUnwatch(rootPath) + throw new Error('SSH filesystem provider disposed') + } return this.createWatchUnsubscribe(rootPath, registration, callback) } + private notifyUnwatch(rootPath: string): void { + try { + this.mux.notify('fs.unwatch', { rootPath }) + } catch { + // Connection teardown may already have closed the mux; disposal must continue. + } + } + private createWatchUnsubscribe( rootPath: string, registration: WatchRegistration, @@ -284,7 +310,7 @@ export class SshFilesystemProvider implements IFilesystemProvider { registration.callbacks.delete(callback) if (registration.callbacks.size === 0 && this.watchListeners.get(rootPath) === registration) { this.watchListeners.delete(rootPath) - this.mux.notify('fs.unwatch', { rootPath }) + this.notifyUnwatch(rootPath) } } } diff --git a/src/main/providers/ssh-git-provider.test.ts b/src/main/providers/ssh-git-provider.test.ts index 0d0c334342b..9de75d67096 100644 --- a/src/main/providers/ssh-git-provider.test.ts +++ b/src/main/providers/ssh-git-provider.test.ts @@ -709,6 +709,43 @@ describe('SshGitProvider', () => { }) }) + it('worktreeIsClean sends git.worktreeIsClean request', async () => { + const cleanResult = { clean: false, stdout: '?? scratch.txt\n' } + mux.request.mockResolvedValue(cleanResult) + + const result = await provider.worktreeIsClean('/home/user/feat') + + expect(mux.request).toHaveBeenCalledWith('git.worktreeIsClean', { + worktreePath: '/home/user/feat' + }) + expect(result).toEqual(cleanResult) + }) + + it('worktreeIsClean falls back to git.status for old relays', async () => { + const methodNotFound = Object.assign(new Error('Method not found: git.worktreeIsClean'), { + code: -32601 + }) + mux.request.mockRejectedValueOnce(methodNotFound).mockResolvedValueOnce({ + entries: [{ path: 'scratch.txt', status: 'untracked', area: 'untracked' }], + conflictOperation: 'unknown' + }) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + try { + const result = await provider.worktreeIsClean('/home/user/feat') + + expect(mux.request).toHaveBeenNthCalledWith(1, 'git.worktreeIsClean', { + worktreePath: '/home/user/feat' + }) + expect(mux.request).toHaveBeenNthCalledWith(2, 'git.status', { + worktreePath: '/home/user/feat' + }) + expect(result).toEqual({ clean: false, stdout: 'untracked untracked: scratch.txt' }) + } finally { + warnSpy.mockRestore() + } + }) + it('renameCurrentBranch sends the narrow branch-rename request', async () => { await provider.renameCurrentBranch('/home/user/feat', 'you/fix-auth') expect(mux.request).toHaveBeenCalledWith('git.renameCurrentBranch', { diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index b96361e527e..effc3aa914d 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -12,10 +12,12 @@ import type { GitConflictOperation, GitPushTarget, GitUpstreamStatus, - GitWorktreeInfo + GitWorktreeInfo, + RemoveWorktreeResult } from '../../shared/types' import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' import { buildHostedRemoteFileUrl } from '../git/hosted-remote-url' +import { JsonRpcErrorCode } from '../ssh/relay-protocol' import type { CommitMessageDraftContext } from '../../shared/commit-message-generation' import type { CommitMessagePlan } from '../../shared/commit-message-plan' import type { RemoteCommitMessageExecResult } from '../text-generation/commit-message-text-generation' @@ -27,10 +29,25 @@ type NonInteractiveExecQueueEntry = { release: () => void } +function isJsonRpcMethodNotFoundError(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false + } + return (error as { code?: unknown }).code === JsonRpcErrorCode.MethodNotFound +} + +function formatStatusEntriesForCleanCheck(entries: GitStatusResult['entries']): string | undefined { + if (entries.length === 0) { + return undefined + } + return entries.map((entry) => `${entry.area} ${entry.status}: ${entry.path}`).join('\n') +} + export class SshGitProvider implements IGitProvider { private connectionId: string private mux: SshChannelMultiplexer private nonInteractiveExecQueues = new Map() + private loggedWorktreeIsCleanFallback = false constructor(connectionId: string, mux: SshChannelMultiplexer) { this.connectionId = connectionId @@ -433,14 +450,35 @@ export class SshGitProvider implements IGitProvider { worktreePath: string, force?: boolean, options?: { deleteBranch?: boolean; forceBranchDelete?: boolean } - ): Promise { - await this.mux.request('git.removeWorktree', { worktreePath, force, ...options }) + ): Promise { + return ((await this.mux.request('git.removeWorktree', { + worktreePath, + force, + ...options + })) ?? {}) as RemoveWorktreeResult } async worktreeIsClean(worktreePath: string): Promise<{ clean: boolean; stdout?: string }> { - return (await this.mux.request('git.worktreeIsClean', { worktreePath })) as { - clean: boolean - stdout?: string + try { + return (await this.mux.request('git.worktreeIsClean', { worktreePath })) as { + clean: boolean + stdout?: string + } + } catch (error) { + if (!isJsonRpcMethodNotFoundError(error)) { + throw error + } + if (!this.loggedWorktreeIsCleanFallback) { + this.loggedWorktreeIsCleanFallback = true + console.warn( + '[ssh-git] Relay does not implement git.worktreeIsClean; falling back to git.status clean check' + ) + } + // Why: existing SSH relays may predate git.worktreeIsClean, but git.status + // is a narrow relay RPC and avoids the generic git.exec allowlist. + const status = await this.getStatus(worktreePath) + const clean = status.entries.length === 0 + return { clean, stdout: formatStatusEntriesForCleanCheck(status.entries) } } } diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts index 42c318568e1..a37b1fe551c 100644 --- a/src/main/providers/ssh-pty-provider.test.ts +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -66,6 +66,65 @@ describe('SshPtyProvider', () => { }) }) + it('injects the relay-backed Orca CLI bridge into remote PTY env', async () => { + mux.request.mockResolvedValue({ id: 'pty-bridge' }) + provider = new SshPtyProvider('conn-1', mux as never, { + binDir: '/home/user/.orca-relay/bin', + relayDir: '/home/user/.orca-relay/relay-v1', + nodePath: '/usr/bin/node', + sockPath: '/home/user/.orca-relay/relay.sock' + }) + + await provider.spawn({ + cols: 120, + rows: 40, + env: { PATH: '/usr/bin', ORCA_TERMINAL_HANDLE: 'term_ssh' } + }) + + expect(mux.request).toHaveBeenCalledWith('pty.spawn', { + cols: 120, + rows: 40, + cwd: undefined, + env: { + PATH: '/home/user/.orca-relay/bin:/usr/bin', + ORCA_TERMINAL_HANDLE: 'term_ssh', + ORCA_REMOTE_CLI_BIN_DIR: '/home/user/.orca-relay/bin', + ORCA_RELAY_DIR: '/home/user/.orca-relay/relay-v1', + ORCA_RELAY_NODE_PATH: '/usr/bin/node', + ORCA_RELAY_SOCKET_PATH: '/home/user/.orca-relay/relay.sock' + } + }) + }) + + it('does not clobber the remote relay PATH when caller env has no PATH', async () => { + mux.request.mockResolvedValue({ id: 'pty-bridge' }) + provider = new SshPtyProvider('conn-1', mux as never, { + binDir: '/home/user/.orca-relay/bin', + relayDir: '/home/user/.orca-relay/relay-v1', + nodePath: '/usr/bin/node', + sockPath: '/home/user/.orca-relay/relay.sock' + }) + + await provider.spawn({ + cols: 120, + rows: 40, + env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } + }) + + expect(mux.request).toHaveBeenCalledWith('pty.spawn', { + cols: 120, + rows: 40, + cwd: undefined, + env: { + ORCA_TERMINAL_HANDLE: 'term_ssh', + ORCA_REMOTE_CLI_BIN_DIR: '/home/user/.orca-relay/bin', + ORCA_RELAY_DIR: '/home/user/.orca-relay/relay-v1', + ORCA_RELAY_NODE_PATH: '/usr/bin/node', + ORCA_RELAY_SOCKET_PATH: '/home/user/.orca-relay/relay.sock' + } + }) + }) + it('reattaches an existing session and returns attach replay separately from snapshot', async () => { mux.request.mockResolvedValue({ replay: 'buffered-output' }) diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index 1c8c72288d3..db91c9cc4e1 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -5,6 +5,12 @@ import { toAppSshPtyId, toRelaySshPtyId } from './ssh-pty-id' type DataCallback = (payload: { id: string; data: string }) => void type ReplayCallback = (payload: { id: string; data: string }) => void type ExitCallback = (payload: { id: string; code: number }) => void +type RemoteCliBridgeEnv = { + binDir: string + relayDir: string + nodePath: string + sockPath: string +} export const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' @@ -29,7 +35,11 @@ export class SshPtyProvider implements IPtyProvider { // the provider is torn down on disconnect, routing events to stale state. private unsubscribeNotifications: (() => void) | null = null - constructor(connectionId: string, mux: SshChannelMultiplexer) { + constructor( + connectionId: string, + mux: SshChannelMultiplexer, + private readonly remoteCliBridgeEnv?: RemoteCliBridgeEnv + ) { this.connectionId = connectionId this.mux = mux @@ -119,7 +129,7 @@ export class SshPtyProvider implements IPtyProvider { cols: opts.cols, rows: opts.rows, cwd: opts.cwd, - env: opts.env, + env: this.withRemoteCliBridgeEnv(opts.env), // Why: the relay's plugin-overlay env augmenter needs to know which // Pi-compatible agent is being launched (`pi` vs `omp`) so it mirrors // the right `~/./agent` source dir on the remote disk. The @@ -135,6 +145,29 @@ export class SshPtyProvider implements IPtyProvider { } } + private withRemoteCliBridgeEnv( + env: Record | undefined + ): Record | undefined { + if (!this.remoteCliBridgeEnv) { + return env + } + const merged = { ...env } + const pathKey = merged.PATH !== undefined ? 'PATH' : merged.Path !== undefined ? 'Path' : null + if (pathKey) { + const pathValue = merged[pathKey] ?? '' + merged[pathKey] = pathValue.split(':').includes(this.remoteCliBridgeEnv.binDir) + ? pathValue + : pathValue + ? `${this.remoteCliBridgeEnv.binDir}:${pathValue}` + : this.remoteCliBridgeEnv.binDir + } + merged.ORCA_REMOTE_CLI_BIN_DIR = this.remoteCliBridgeEnv.binDir + merged.ORCA_RELAY_DIR = this.remoteCliBridgeEnv.relayDir + merged.ORCA_RELAY_NODE_PATH = this.remoteCliBridgeEnv.nodePath + merged.ORCA_RELAY_SOCKET_PATH = this.remoteCliBridgeEnv.sockPath + return merged + } + async attach(id: string): Promise { await this.mux.request('pty.attach', { id: this.toRelayPtyId(id) }) } diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 29761995cb2..07316ce08e8 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -9,6 +9,7 @@ import type { GitPushTarget, GitUpstreamStatus, GitWorktreeInfo, + RemoveWorktreeResult, SearchOptions, SearchResult } from '../../shared/types' @@ -211,7 +212,7 @@ export type IGitProvider = { worktreePath: string, force?: boolean, options?: { deleteBranch?: boolean; forceBranchDelete?: boolean } - ): Promise + ): Promise renameCurrentBranch?(worktreePath: string, newBranch: string): Promise isGitRepo(path: string): boolean isGitRepoAsync(dirPath: string): Promise<{ isRepo: boolean; rootPath: string | null }> diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index b702efaff93..f44ad922e19 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -65,6 +65,30 @@ describe('resolveWindowsShellLaunchArgs', () => { ]) }) + it('starts Git Bash as an interactive login shell without changing cwd', () => { + const result = resolveWindowsShellLaunchArgs( + 'C:\\Program Files\\Git\\bin\\bash.exe', + 'C:\\Users\\alice\\code', + 'C:\\Users\\alice' + ) + + expect(result.shellArgs).toEqual(['--login', '-i']) + expect(result.effectiveCwd).toBe('C:\\Users\\alice\\code') + expect(result.validationCwd).toBe('C:\\Users\\alice\\code') + }) + + it('does not apply Git Bash launch args to unrelated bash.exe paths', () => { + const result = resolveWindowsShellLaunchArgs( + 'C:\\msys64\\usr\\bin\\bash.exe', + 'C:\\Users\\alice\\code', + 'C:\\Users\\alice' + ) + + expect(result.shellArgs).toEqual([]) + expect(result.effectiveCwd).toBe('C:\\Users\\alice\\code') + expect(result.validationCwd).toBe('C:\\Users\\alice\\code') + }) + it('translates Windows cwd to /mnt//... for wsl.exe', () => { const result = resolveWindowsShellLaunchArgs( 'wsl.exe', @@ -75,7 +99,7 @@ describe('resolveWindowsShellLaunchArgs', () => { '--', 'bash', '-c', - "cd '/mnt/c/Users/alice/code' && exec bash -l" + 'cd \'/mnt/c/Users/alice/code\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l' ]) // Why: WSL cannot cd into a Windows path, so node-pty must start from the // user's Windows home and we inject the Linux cd into the shellArgs above. @@ -87,12 +111,16 @@ describe('resolveWindowsShellLaunchArgs', () => { const result = resolveWindowsShellLaunchArgs('wsl.exe', "C:\\weird'path", 'C:\\Users\\alice') // The injected bash cmd must not break out of the surrounding single // quotes when the path contains a ' character. - expect(result.shellArgs[3]).toBe("cd '/mnt/c/weird'\\''path' && exec bash -l") + expect(result.shellArgs[3]).toBe( + "cd '/mnt/c/weird'\\''path' && export PATH=\"$HOME/.local/bin:$PATH\" && exec bash -l" + ) }) it('falls back to /mnt/c when cwd is not a drive-letter path', () => { const result = resolveWindowsShellLaunchArgs('wsl.exe', '\\\\server\\share', 'C:\\Users\\alice') - expect(result.shellArgs[3]).toBe("cd '/mnt/c' && exec bash -l") + expect(result.shellArgs[3]).toBe( + 'cd \'/mnt/c\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l' + ) }) it('keeps WSL UNC worktree cwd inside the matching distro', () => { @@ -114,7 +142,7 @@ describe('resolveWindowsShellLaunchArgs', () => { '--', 'bash', '-c', - "cd '/home/alice/repo' && exec bash -l" + 'cd \'/home/alice/repo\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l' ]) expect(result.effectiveCwd).toBe('C:\\Users\\alice') expect(result.validationCwd).toBe('\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo') @@ -140,7 +168,7 @@ describe('resolveWindowsShellLaunchArgs', () => { '--', 'bash', '-c', - "cd '/home/alice/repo/subdir' && exec bash -l" + 'cd \'/home/alice/repo/subdir\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l' ]) expect(result.effectiveCwd).toBe('C:\\Users\\alice') expect(result.validationCwd).toBe('\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo\\subdir') diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index c032aa15194..0e41f9caa83 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -1,4 +1,5 @@ import { win32 as pathWin32 } from 'path' +import { isWindowsGitBashShellPath } from '../git-bash' import { parseWslPath, toLinuxPath, toWindowsWslPath } from '../wsl' import { encodePowerShellCommand, @@ -32,7 +33,14 @@ export type WindowsShellWslContext = { function buildWslShellArgs(linuxCwd: string, distro?: string): string[] { const escapedLinuxCwd = linuxCwd.replace(/'/g, "'\\''") - const shellArgs = ['--', 'bash', '-c', `cd '${escapedLinuxCwd}' && exec bash -l`] + // Why: Orca's WSL bridge is installed under ~/.local/bin, but distro login + // files do not consistently include that directory before agent commands run. + const shellArgs = [ + '--', + 'bash', + '-c', + `cd '${escapedLinuxCwd}' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l` + ] return distro ? ['-d', distro, ...shellArgs] : shellArgs } @@ -76,6 +84,14 @@ export function resolveWindowsShellLaunchArgs( } } + if (isWindowsGitBashShellPath(shellPath)) { + return { + shellArgs: ['--login', '-i'], + effectiveCwd: cwd, + validationCwd: cwd + } + } + if (shellBasename === 'wsl.exe') { const wslInfo = parseWslPath(cwd) if (wslInfo) { diff --git a/src/main/pty/overlay-mirror.test.ts b/src/main/pty/overlay-mirror.test.ts new file mode 100644 index 00000000000..5499d226422 --- /dev/null +++ b/src/main/pty/overlay-mirror.test.ts @@ -0,0 +1,99 @@ +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtemp } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import type * as NodePath from 'node:path' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { safeRemoveOverlay } from './overlay-mirror' + +const tempRoots: string[] = [] + +afterEach(() => { + vi.doUnmock('fs') + vi.doUnmock('path') + vi.restoreAllMocks() + vi.resetModules() + for (const root of tempRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe('safeRemoveOverlay', () => { + it('removes valid overlay children whose names start with dot-dot', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-overlay-root-')) + tempRoots.push(root) + const overlayDir = join(root, '..session-overlay') + mkdirSync(overlayDir, { recursive: true }) + writeFileSync(join(overlayDir, 'marker.txt'), 'overlay') + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + safeRemoveOverlay(overlayDir, root) + + expect(existsSync(overlayDir)).toBe(false) + expect(warnSpy).not.toHaveBeenCalled() + }) + + it('refuses to remove the overlay root itself', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-overlay-root-')) + tempRoots.push(root) + const marker = join(root, 'marker.txt') + writeFileSync(marker, 'root') + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + safeRemoveOverlay(root, root) + + expect(existsSync(marker)).toBe(true) + expect(warnSpy).toHaveBeenCalledOnce() + }) + + it('refuses parent traversal outside the overlay root', async () => { + const parent = await mkdtemp(join(tmpdir(), 'orca-overlay-parent-')) + tempRoots.push(parent) + const root = join(parent, 'root') + const outside = join(parent, 'outside') + mkdirSync(root) + mkdirSync(outside) + const marker = join(outside, 'marker.txt') + writeFileSync(marker, 'outside') + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + safeRemoveOverlay(join(root, '..', 'outside'), root) + + expect(existsSync(marker)).toBe(true) + expect(warnSpy).toHaveBeenCalledOnce() + }) + + it('refuses Windows overlay targets on a different drive than the root', async () => { + const lstatSyncMock = vi.fn(() => ({ + isSymbolicLink: () => false, + isDirectory: () => false + })) + const unlinkSyncMock = vi.fn() + vi.doMock('fs', () => ({ + cpSync: vi.fn(), + linkSync: vi.fn(), + lstatSync: lstatSyncMock, + readdirSync: vi.fn(), + rmdirSync: vi.fn(), + symlinkSync: vi.fn(), + unlinkSync: unlinkSyncMock + })) + vi.doMock('path', async () => { + const path = await vi.importActual('node:path') + return { + ...path.win32, + default: path.win32 + } + }) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const { safeRemoveOverlay: safeRemoveOverlayWithWinPath } = await import('./overlay-mirror') + safeRemoveOverlayWithWinPath(String.raw`D:\users\me\config`, String.raw`C:\orca\overlays`) + + expect(warnSpy).toHaveBeenCalledWith( + '[overlay-mirror] refusing to remove overlay outside root: target=D:\\users\\me\\config root=C:\\orca\\overlays' + ) + expect(lstatSyncMock).not.toHaveBeenCalled() + expect(unlinkSyncMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/pty/overlay-mirror.ts b/src/main/pty/overlay-mirror.ts index 94e69071473..10245ec1729 100644 --- a/src/main/pty/overlay-mirror.ts +++ b/src/main/pty/overlay-mirror.ts @@ -8,7 +8,7 @@ // consumer cannot accidentally diverge from the audited cleanup behavior. import { cpSync, linkSync, lstatSync, readdirSync, rmdirSync, symlinkSync, unlinkSync } from 'fs' -import { join, relative, resolve, sep } from 'path' +import { isAbsolute, join, relative, resolve, sep } from 'path' export function mirrorEntry(sourcePath: string, targetPath: string): void { // Why: lstatSync (not statSync) so that if the user's source dir contains @@ -118,7 +118,7 @@ export function safeRemoveOverlay(overlayDir: string, overlayRoot: string): void const resolvedRoot = resolve(overlayRoot) const resolvedTarget = resolve(overlayDir) const rel = relative(resolvedRoot, resolvedTarget) - if (rel === '' || rel.startsWith('..') || rel.includes(`..${sep}`)) { + if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { console.warn( `[overlay-mirror] refusing to remove overlay outside root: target=${resolvedTarget} root=${resolvedRoot}` ) diff --git a/src/main/pty/windows-environment-path.test.ts b/src/main/pty/windows-environment-path.test.ts index cc6b832e9f6..236b21d4b25 100644 --- a/src/main/pty/windows-environment-path.test.ts +++ b/src/main/pty/windows-environment-path.test.ts @@ -1,5 +1,15 @@ import { describe, expect, it, vi } from 'vitest' + +const { defaultExecFileSyncMock } = vi.hoisted(() => ({ + defaultExecFileSyncMock: vi.fn() +})) + +vi.mock('node:child_process', () => ({ + execFileSync: defaultExecFileSyncMock +})) + import { + __resetPersistedWindowsPathCacheForTests, mergePersistedWindowsPath, readPersistedWindowsPathSegments } from './windows-environment-path' @@ -37,6 +47,25 @@ describe('readPersistedWindowsPathSegments', () => { expect(readPersistedWindowsPathSegments({ platform: 'linux', execFileSync })).toEqual([]) expect(execFileSync).not.toHaveBeenCalled() }) + + it('caches production registry reads briefly', () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + defaultExecFileSyncMock + .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') + .mockReturnValueOnce(' Path REG_SZ C:\\User\r\n') + __resetPersistedWindowsPathCacheForTests() + + try { + expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) + expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) + expect(defaultExecFileSyncMock).toHaveBeenCalledTimes(2) + } finally { + __resetPersistedWindowsPathCacheForTests() + defaultExecFileSyncMock.mockReset() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) }) describe('mergePersistedWindowsPath', () => { @@ -55,7 +84,7 @@ describe('mergePersistedWindowsPath', () => { [ '', 'HKEY_CURRENT_USER\\Environment', - ' Path REG_EXPAND_SZ C:\\Users\\me\\AppData\\Local\\agy\\bin;C:\\Existing', + ' Path REG_EXPAND_SZ C:\\Users\\me\\AppData\\Local\\Orca\\bin;C:\\Existing', '' ].join('\r\n') ) @@ -64,7 +93,7 @@ describe('mergePersistedWindowsPath', () => { mergePersistedWindowsPath(env, { platform: 'win32', execFileSync }) expect(env.Path).toBe( - 'C:\\Existing;C:\\Windows\\System32;C:\\Users\\me\\AppData\\Local\\agy\\bin' + 'C:\\Existing;C:\\Windows\\System32;C:\\Users\\me\\AppData\\Local\\Orca\\bin' ) }) diff --git a/src/main/pty/windows-environment-path.ts b/src/main/pty/windows-environment-path.ts index e17e29fa421..c0538c40a21 100644 --- a/src/main/pty/windows-environment-path.ts +++ b/src/main/pty/windows-environment-path.ts @@ -13,6 +13,15 @@ const WINDOWS_PATH_REGISTRY_KEYS = [ ['HKCU\\Environment', 'Path'] ] as const +const PERSISTED_WINDOWS_PATH_CACHE_TTL_MS = 30_000 + +let persistedWindowsPathCache: + | { + readAt: number + segments: string[] + } + | undefined + function parseRegistryPathValue(output: string, valueName: string): string | null { const valuePattern = new RegExp(`^\\s*${valueName}\\s+REG_\\w+\\s+(.*)$`, 'i') for (const line of output.split(/\r?\n/)) { @@ -49,6 +58,19 @@ export function readPersistedWindowsPathSegments(options: ReadWindowsPathOptions return [] } + const useProductionCache = + options.execFileSync === undefined && + options.env === undefined && + options.platform === undefined + const now = Date.now() + if ( + useProductionCache && + persistedWindowsPathCache && + now - persistedWindowsPathCache.readAt < PERSISTED_WINDOWS_PATH_CACHE_TTL_MS + ) { + return [...persistedWindowsPathCache.segments] + } + const run = options.execFileSync ?? execFileSync const env = options.env ?? process.env const pathDelimiter = getPathDelimiter(platform) @@ -72,9 +94,23 @@ export function readPersistedWindowsPathSegments(options: ReadWindowsPathOptions } } + if (useProductionCache) { + // Why: local PTY spawn is a hot path on Windows, and each uncached read + // runs two synchronous `reg.exe query` subprocesses. A short TTL keeps + // terminal bursts cheap while still picking up newly installed CLIs soon. + persistedWindowsPathCache = { + readAt: now, + segments: [...segments] + } + } + return segments } +export function __resetPersistedWindowsPathCacheForTests(): void { + persistedWindowsPathCache = undefined +} + export function mergePersistedWindowsPath( env: Record, options: ReadWindowsPathOptions = {} diff --git a/src/main/pty/wsl-orca-env.test.ts b/src/main/pty/wsl-orca-env.test.ts new file mode 100644 index 00000000000..4615a16133d --- /dev/null +++ b/src/main/pty/wsl-orca-env.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { addOrcaWslInteropEnv } from './wsl-orca-env' + +describe('addOrcaWslInteropEnv', () => { + it('marks the Orca terminal handle for Windows to WSL env import', () => { + const env: Record = { ORCA_TERMINAL_HANDLE: 'term_wsl' } + + addOrcaWslInteropEnv(env) + + expect(env.WSLENV).toBe('ORCA_TERMINAL_HANDLE/u') + }) + + it('preserves existing WSLENV entries and does not duplicate the handle entry', () => { + const env: Record = { + WSLENV: 'FOO/u:ORCA_TERMINAL_HANDLE/u:BAR/p' + } + + addOrcaWslInteropEnv(env) + + expect(env.WSLENV).toBe('FOO/u:ORCA_TERMINAL_HANDLE/u:BAR/p') + }) +}) diff --git a/src/main/pty/wsl-orca-env.ts b/src/main/pty/wsl-orca-env.ts new file mode 100644 index 00000000000..37f7e6c7bac --- /dev/null +++ b/src/main/pty/wsl-orca-env.ts @@ -0,0 +1,19 @@ +const WSLENV_ENTRY_SEPARATOR = ':' + +function parseWslenvEntries(value: string | undefined): string[] { + return value ? value.split(WSLENV_ENTRY_SEPARATOR).filter(Boolean) : [] +} + +function hasWslenvVariable(entries: readonly string[], variableName: string): boolean { + return entries.some((entry) => entry.split('/')[0] === variableName) +} + +export function addOrcaWslInteropEnv(env: Record): void { + const entries = parseWslenvEntries(env.WSLENV) + if (!hasWslenvVariable(entries, 'ORCA_TERMINAL_HANDLE')) { + // Why: WSL only imports selected Windows env vars. The terminal handle is + // the trusted orchestration identity, so managed WSL shells must opt it in. + entries.push('ORCA_TERMINAL_HANDLE/u') + } + env.WSLENV = entries.join(WSLENV_ENTRY_SEPARATOR) +} diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts index 22d2aed30f4..42412c3019c 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -21,14 +21,13 @@ import { } from '../claude-accounts/managed-auth-path' import { createOAuthUsageError, OAuthUsageError } from './claude-oauth-usage-error' import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' +import { ensureElectronProxyFromEnvironment } from '../network/proxy-settings' const OAUTH_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage' const OAUTH_BETA_HEADER = 'oauth-2025-04-20' const CLAUDE_CODE_USER_AGENT = 'claude-code/2.1.0' const API_TIMEOUT_MS = 10_000 -let proxyConfigured = false - /** * Bridge standard HTTP proxy env vars into Electron's session proxy config. * @@ -40,36 +39,10 @@ let proxyConfigured = false * Anthropic from an unexpected IP, risking rate-limit signals on the account. */ async function ensureProxyFromEnv(): Promise { - if (proxyConfigured) { - return - } - proxyConfigured = true - - // Why: app.resolveProxy does NOT reflect session-level proxy config — - // only session.defaultSession.resolveProxy does. - const resolved = await session.defaultSession.resolveProxy(OAUTH_USAGE_URL) - if (resolved !== 'DIRECT') { - return - } - - const proxyUrl = - process.env.HTTPS_PROXY ?? - process.env.https_proxy ?? - process.env.ALL_PROXY ?? - process.env.all_proxy ?? - process.env.HTTP_PROXY ?? - process.env.http_proxy - if (!proxyUrl) { - return - } - - try { - new URL(proxyUrl) - await session.defaultSession.setProxy({ proxyRules: proxyUrl }) - } catch { - // Invalid proxy URL — degrade to direct connection rather than crashing. - // The usage bar is cosmetic; a typo'd envvar should not break polling. - } + await ensureElectronProxyFromEnvironment({ + proxySession: session.defaultSession, + probeUrl: OAUTH_USAGE_URL + }).catch(() => {}) } // --------------------------------------------------------------------------- diff --git a/src/main/rate-limits/claude-pty.test.ts b/src/main/rate-limits/claude-pty.test.ts index c416e3b2430..4fbf801975f 100644 --- a/src/main/rate-limits/claude-pty.test.ts +++ b/src/main/rate-limits/claude-pty.test.ts @@ -28,17 +28,23 @@ type MockTerm = { function makeMockTerm(): MockTerm & { emitData: (data: string) => void + emitExit: () => void } { let dataHandler: ((data: string) => void) | null = null + let exitHandler: (() => void) | null = null return { onData: vi.fn((handler: (data: string) => void) => { dataHandler = handler return makeDisposable() }), - onExit: vi.fn(() => makeDisposable()), + onExit: vi.fn((handler: () => void) => { + exitHandler = handler + return makeDisposable() + }), write: vi.fn(), kill: vi.fn(), - emitData: (data: string) => dataHandler?.(data) + emitData: (data: string) => dataHandler?.(data), + emitExit: () => exitHandler?.() } } @@ -73,6 +79,45 @@ describe('fetchViaPty', () => { ) }) + it('clears the startup delay timer when the hidden PTY exits early', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + + const resultPromise = fetchViaPty() + await vi.advanceTimersByTimeAsync(0) + expect(spawnMock).toHaveBeenCalled() + expect(vi.getTimerCount()).toBeGreaterThan(0) + + term.emitExit() + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'claude', + status: 'error' + }) + expect(vi.getTimerCount()).toBe(0) + }) + + it('clears pending settle timers when the hidden PTY exits after usage output starts', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + + const resultPromise = fetchViaPty() + await vi.advanceTimersByTimeAsync(2_000) + term.emitData('Current session\r12% used\r') + expect(vi.getTimerCount()).toBeGreaterThan(0) + + term.emitExit() + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { + usedPercent: 12 + } + }) + expect(vi.getTimerCount()).toBe(0) + }) + it('treats Claude 2.1 tabbed /usage session stats as rendered but unavailable', 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 1e31e26afa8..a010f145734 100644 --- a/src/main/rate-limits/claude-pty.ts +++ b/src/main/rate-limits/claude-pty.ts @@ -163,6 +163,8 @@ export async function fetchViaPty(options?: { let sentUsage = false let stopDetected = false let claude21UsageDetected = false + let startupDelayTimer: ReturnType | null = null + let stopSettleTimer: ReturnType | null = null let claude21UsageSettleTimer: ReturnType | null = null const claudeCommand = resolveClaudeCommand() @@ -207,18 +209,31 @@ export async function fetchViaPty(options?: { env: spawnEnv }) const termDisposables: { dispose: () => void }[] = [] + let enterInterval: ReturnType | null = null + + function clearFollowupTimers(): void { + if (startupDelayTimer) { + clearTimeout(startupDelayTimer) + startupDelayTimer = null + } + if (stopSettleTimer) { + clearTimeout(stopSettleTimer) + stopSettleTimer = null + } + if (claude21UsageSettleTimer) { + clearTimeout(claude21UsageSettleTimer) + claude21UsageSettleTimer = null + } + if (enterInterval) { + clearInterval(enterInterval) + enterInterval = null + } + } const timeout = setTimeout(() => { if (!resolved) { resolved = true - if (claude21UsageSettleTimer) { - clearTimeout(claude21UsageSettleTimer) - claude21UsageSettleTimer = null - } - if (enterInterval) { - clearInterval(enterInterval) - enterInterval = null - } + clearFollowupTimers() cleanupHiddenRateLimitPty(term, termDisposables, { kill: true }) // Even on timeout, try to parse whatever we collected const clean = stripTerminalControlSequences(output) @@ -252,8 +267,6 @@ export async function fetchViaPty(options?: { // Why: the Claude TUI may have scrollable panels or prompts. // Sending Enter every 0.8s advances through them. - let enterInterval: ReturnType | null = null - function startEnterPresses(): void { if (enterInterval) { return @@ -271,13 +284,7 @@ export async function fetchViaPty(options?: { } resolved = true clearTimeout(timeout) - if (claude21UsageSettleTimer) { - clearTimeout(claude21UsageSettleTimer) - claude21UsageSettleTimer = null - } - if (enterInterval) { - clearInterval(enterInterval) - } + clearFollowupTimers() cleanupHiddenRateLimitPty(term, termDisposables, { kill: true }) const clean = stripTerminalControlSequences(output) @@ -306,7 +313,8 @@ export async function fetchViaPty(options?: { // Why: wait 2s for the CLI to initialize, then send `/usage\r` // directly without detecting the prompt character (see comment above). - setTimeout(() => { + startupDelayTimer = setTimeout(() => { + startupDelayTimer = null if (resolved) { return } @@ -360,7 +368,7 @@ export async function fetchViaPty(options?: { stopDetected = true // Why: 2.0s settle time after detecting the stop substring // allows the full panel to finish rendering. - setTimeout(finalize, SETTLE_AFTER_STOP_MS) + stopSettleTimer = setTimeout(finalize, SETTLE_AFTER_STOP_MS) break } } @@ -372,14 +380,7 @@ export async function fetchViaPty(options?: { const onExitDisposable = term.onExit(() => { cleanupHiddenRateLimitPty(term, termDisposables, { kill: false }) - if (claude21UsageSettleTimer) { - clearTimeout(claude21UsageSettleTimer) - claude21UsageSettleTimer = null - } - if (enterInterval) { - clearInterval(enterInterval) - enterInterval = null - } + clearFollowupTimers() if (!resolved) { resolved = true clearTimeout(timeout) diff --git a/src/main/rate-limits/codex-fetcher-pty-settle.test.ts b/src/main/rate-limits/codex-fetcher-pty-settle.test.ts new file mode 100644 index 00000000000..5c729141167 --- /dev/null +++ b/src/main/rate-limits/codex-fetcher-pty-settle.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { childSpawnMock, resolveCodexCommandMock, ptySpawnMock } = vi.hoisted(() => ({ + childSpawnMock: vi.fn(), + resolveCodexCommandMock: vi.fn(), + ptySpawnMock: vi.fn() +})) + +vi.mock('node:child_process', () => ({ + spawn: childSpawnMock +})) + +vi.mock('../codex-cli/command', () => ({ + resolveCodexCommand: resolveCodexCommandMock +})) + +vi.mock('node-pty', () => ({ + spawn: ptySpawnMock +})) + +import { fetchCodexRateLimits } from './codex-fetcher' + +function makeDisposable() { + return { dispose: vi.fn() } +} + +describe('fetchCodexRateLimits PTY settle timers', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + resolveCodexCommandMock.mockReturnValue('codex') + }) + + it('coalesces the PTY fallback status settle timer while output keeps streaming', async () => { + const ptyHandlers: { onData?: (data: string) => void } = {} + + childSpawnMock.mockImplementation(() => { + throw new Error('rpc unavailable') + }) + ptySpawnMock.mockReturnValue({ + onData: vi.fn((callback) => { + ptyHandlers.onData = callback + return makeDisposable() + }), + onExit: vi.fn(() => makeDisposable()), + write: vi.fn(), + kill: vi.fn() + }) + + const resultPromise = fetchCodexRateLimits() + await vi.advanceTimersByTimeAsync(0) + + const onPtyData = ptyHandlers.onData + if (!onPtyData) { + throw new Error('PTY data handler was not registered') + } + + onPtyData('>') + expect(vi.getTimerCount()).toBe(1) + + onPtyData('5h limit: 17%\n') + onPtyData('Weekly limit: 23%\n') + onPtyData('still rendering\n') + expect(vi.getTimerCount()).toBe(2) + + await vi.advanceTimersByTimeAsync(500) + + await expect(resultPromise).resolves.toMatchObject({ + session: { usedPercent: 17 }, + weekly: { usedPercent: 23 }, + status: 'ok' + }) + }) +}) diff --git a/src/main/rate-limits/codex-fetcher.test.ts b/src/main/rate-limits/codex-fetcher.test.ts index 77da7f8d36f..b3f3591c0d9 100644 --- a/src/main/rate-limits/codex-fetcher.test.ts +++ b/src/main/rate-limits/codex-fetcher.test.ts @@ -127,6 +127,28 @@ describe('fetchCodexRateLimits', () => { expect(ptySpawnMock).not.toHaveBeenCalled() }) + it('removes RPC listeners when the app-server timeout settles', async () => { + const rpcChild = makeRpcChild() + childSpawnMock.mockReturnValue(rpcChild) + + const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false }) + await vi.advanceTimersByTimeAsync(10_000) + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'codex', + session: null, + weekly: null, + status: 'error', + error: 'RPC timeout' + }) + expect(rpcChild.kill).toHaveBeenCalledTimes(1) + expect(rpcChild.stdout.listenerCount('data')).toBe(0) + expect(rpcChild.stderr.listenerCount('data')).toBe(0) + expect(rpcChild.listenerCount('error')).toBe(0) + expect(rpcChild.listenerCount('close')).toBe(0) + expect(ptySpawnMock).not.toHaveBeenCalled() + }) + it('normalizes Codex RPC remaining-minute windows to fixed display durations', async () => { const rpcChild = makeRpcChild() childSpawnMock.mockReturnValue(rpcChild) diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index d46da8c40a7..5ac0e3c8c2a 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -160,19 +160,43 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise { - if (!resolved) { - resolved = true + let timeout: ReturnType | null = null + + function cleanupListeners(): void { + if (timeout) { + clearTimeout(timeout) + timeout = null + } + child.stdout.off('data', onStdoutData) + child.stderr.off('data', onStderrData) + child.off('error', onError) + child.off('close', onClose) + } + + function settle(result: ProviderRateLimits, options?: { kill?: boolean }): void { + if (resolved) { + return + } + resolved = true + cleanupListeners() + if (options?.kill) { child.kill() - resolve({ + } + resolve(result) + } + + timeout = setTimeout(() => { + settle( + { provider: 'codex', session: null, weekly: null, updatedAt: Date.now(), error: 'RPC timeout', status: 'error' - }) - } + }, + { kill: true } + ) }, rpcTimeoutMs) function sendRpc(method: string, params?: unknown): number { @@ -196,7 +220,7 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise { + function onStdoutData(chunk: Buffer): void { buffer += chunk.toString() // JSON-RPC messages are newline-delimited @@ -228,19 +252,19 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise { + function onStderrData(chunk: Buffer): void { stderr += chunk.toString() // Why: this background poll only needs recent failure context for hints. if (stderr.length > MAX_DIAGNOSTIC_OUTPUT_LENGTH) { stderr = stderr.slice(-MAX_DIAGNOSTIC_OUTPUT_LENGTH) } - }) + } - child.on('error', (err) => { - if (!resolved) { - resolved = true - clearTimeout(timeout) - const isEnoent = (err as NodeJS.ErrnoException).code === 'ENOENT' - const isBareCommand = codexCommand === 'codex' - resolve({ - provider: 'codex', - session: null, - weekly: null, - updatedAt: Date.now(), - error: isEnoent - ? isBareCommand - ? 'Codex CLI not found' - : 'Codex CLI found but could not run — Node.js may not be in your PATH' - : withMacTailscaleDnsHint(err.message, stderr), - status: isEnoent && isBareCommand ? 'unavailable' : 'error' - }) - } - }) + function onError(err: Error): void { + const isEnoent = (err as NodeJS.ErrnoException).code === 'ENOENT' + const isBareCommand = codexCommand === 'codex' + settle({ + provider: 'codex', + session: null, + weekly: null, + updatedAt: Date.now(), + error: isEnoent + ? isBareCommand + ? 'Codex CLI not found' + : 'Codex CLI found but could not run — Node.js may not be in your PATH' + : withMacTailscaleDnsHint(err.message, stderr), + status: isEnoent && isBareCommand ? 'unavailable' : 'error' + }) + } - child.on('close', () => { - if (!resolved) { - resolved = true - clearTimeout(timeout) - resolve({ - provider: 'codex', - session: null, - weekly: null, - updatedAt: Date.now(), - error: withMacTailscaleDnsHint('RPC process exited unexpectedly', stderr), - status: 'error' - }) - } - }) + function onClose(): void { + settle({ + provider: 'codex', + session: null, + weekly: null, + updatedAt: Date.now(), + error: withMacTailscaleDnsHint('RPC process exited unexpectedly', stderr), + status: 'error' + }) + } + + child.stdout.on('data', onStdoutData) + child.stderr.on('data', onStderrData) + child.on('error', onError) + child.on('close', onClose) }) } @@ -374,6 +398,7 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise | null = null const term = pty.spawn(spawnFile, spawnArgs, { name: 'xterm-256color', @@ -390,6 +415,10 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise { if (!resolved) { resolved = true + if (settleTimer) { + clearTimeout(settleTimer) + settleTimer = null + } cleanupHiddenRateLimitPty(term, termDisposables, { kill: true }) resolve({ provider: 'codex', @@ -418,8 +447,11 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise { + if (sentStatus && !settleTimer && (FIVE_HOUR_RE.test(output) || WEEKLY_RE.test(output))) { + // Why: after status text is parseable the TUI may continue streaming + // chunks; one settle timer is enough to let the panel finish flushing. + settleTimer = setTimeout(() => { + settleTimer = null if (resolved) { return } @@ -451,6 +483,10 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise { cleanupHiddenRateLimitPty(term, termDisposables, { kill: false }) + if (settleTimer) { + clearTimeout(settleTimer) + settleTimer = null + } if (!resolved) { resolved = true clearTimeout(timeout) diff --git a/src/main/rate-limits/gemini-usage-fetcher.test-fixtures.ts b/src/main/rate-limits/gemini-usage-fetcher.test-fixtures.ts index d37053b3458..9e21d7c81f2 100644 --- a/src/main/rate-limits/gemini-usage-fetcher.test-fixtures.ts +++ b/src/main/rate-limits/gemini-usage-fetcher.test-fixtures.ts @@ -7,14 +7,6 @@ export function makeResponse(body: unknown, status = 200): Response { } as Response } -export function makeDirent(name: string, isDir: boolean) { - return { - name, - isDirectory: () => isDir, - isFile: () => !isDir - } -} - export const authJsonGoogle = { google: { type: 'oauth', @@ -45,11 +37,6 @@ export const expiredCreds = { expiry_date: new Date('2026-04-24T11:00:00.000Z').getTime() } -export const oauth2JsContent = ` - const OAUTH_CLIENT_ID = 'client-id-123'; - const OAUTH_CLIENT_SECRET = 'client-secret-456'; -` - export const quotaResponse = [ { remainingFraction: 0.75, resetTime: '2026-04-24T13:00:00.000Z', modelId: 'gemini-2.5-pro' }, { remainingFraction: 0.9, resetTime: '2026-04-24T14:00:00.000Z', modelId: 'gemini-2.5-flash' } diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts index 10d5a74c44b..c083514b3cd 100644 --- a/src/main/rate-limits/service.test.ts +++ b/src/main/rate-limits/service.test.ts @@ -3,6 +3,7 @@ stale-data handling, account-switch generation, and OpenCode config-change semantics covered in service.ts, which already carries the same pragma. Keeping them in one file makes the ordering contract reviewable as a unit. */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'events' import type { ProviderRateLimits } from '../../shared/rate-limit-types' import { RateLimitService } from './service' import { fetchClaudeRateLimits, fetchManagedAccountUsage } from './claude-fetcher' @@ -78,6 +79,34 @@ function serviceInternals(service: RateLimitService): { fetchAll: () => Promise< return service as unknown as { fetchAll: () => Promise } } +type RateLimitWindow = Parameters[0] + +class FakeRateLimitWindow extends EventEmitter { + webContents = { + send: vi.fn() + } + + isDestroyed(): boolean { + return false + } + + isVisible(): boolean { + return true + } + + isMinimized(): boolean { + return false + } + + isFocused(): boolean { + return true + } +} + +function asRateLimitWindow(window: FakeRateLimitWindow): RateLimitWindow { + return window as unknown as RateLimitWindow +} + describe('RateLimitService', () => { beforeEach(() => { vi.clearAllMocks() @@ -111,6 +140,61 @@ describe('RateLimitService', () => { expect(fetchCodexRateLimits).toHaveBeenCalledTimes(2) }) + it('removes all window listeners when replacing the attached window', () => { + const service = new RateLimitService() + const firstWindow = new FakeRateLimitWindow() + const secondWindow = new FakeRateLimitWindow() + + service.attach(asRateLimitWindow(firstWindow)) + expect(firstWindow.listenerCount('focus')).toBe(1) + expect(firstWindow.listenerCount('show')).toBe(1) + expect(firstWindow.listenerCount('restore')).toBe(1) + expect(firstWindow.listenerCount('closed')).toBe(1) + + service.attach(asRateLimitWindow(secondWindow)) + + expect(firstWindow.listenerCount('focus')).toBe(0) + expect(firstWindow.listenerCount('show')).toBe(0) + expect(firstWindow.listenerCount('restore')).toBe(0) + expect(firstWindow.listenerCount('closed')).toBe(0) + expect(secondWindow.listenerCount('focus')).toBe(1) + expect(secondWindow.listenerCount('show')).toBe(1) + expect(secondWindow.listenerCount('restore')).toBe(1) + expect(secondWindow.listenerCount('closed')).toBe(1) + + service.stop() + + expect(secondWindow.listenerCount('focus')).toBe(0) + expect(secondWindow.listenerCount('show')).toBe(0) + expect(secondWindow.listenerCount('restore')).toBe(0) + expect(secondWindow.listenerCount('closed')).toBe(0) + }) + + it('sanitizes renderer-provided polling intervals before scheduling timers', () => { + vi.useFakeTimers() + const intervalSpy = vi.spyOn(globalThis, 'setInterval') + try { + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 12)) + vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 24)) + const service = new RateLimitService() + + service.setPollingInterval(Number.NaN) + service.start() + expect(intervalSpy).toHaveBeenLastCalledWith(expect.any(Function), 15 * 60 * 1000) + + service.setPollingInterval(Number.MAX_SAFE_INTEGER) + expect(intervalSpy).toHaveBeenLastCalledWith(expect.any(Function), 2_147_483_647) + + service.setPollingInterval(10) + expect(intervalSpy).toHaveBeenLastCalledWith(expect.any(Function), 30_000) + + service.stop() + } finally { + intervalSpy.mockRestore() + vi.useRealTimers() + } + }) + it('keeps recent stale data across repeated failures', async () => { const service = new RateLimitService() const internal = serviceInternals(service) @@ -398,7 +482,8 @@ describe('RateLimitService', () => { expect(service.getState().inactiveCodexAccounts).toEqual([ { accountId: 'account-1', - claude: expect.objectContaining({ + rateLimits: expect.objectContaining({ + provider: 'codex', session: expect.objectContaining({ usedPercent: 33 }) }), updatedAt: expect.any(Number), @@ -425,6 +510,66 @@ describe('RateLimitService', () => { await firstFetch }) + it('keeps sibling inactive Codex preview fetches alive when one account is evicted', async () => { + const service = new RateLimitService() + const accountFetch = deferred() + let inactiveAccounts = [ + { id: 'account-a', managedHomePath: '/tmp/account-a/home' }, + { id: 'account-b', managedHomePath: '/tmp/account-b/home' } + ] + service.setInactiveCodexAccountsResolver(() => inactiveAccounts) + vi.mocked(fetchCodexRateLimits).mockReturnValueOnce(accountFetch.promise) + + const fetchOnOpen = service.fetchInactiveCodexAccountsOnOpen() + await Promise.resolve() + expect(service.getState().inactiveCodexAccounts).toEqual([ + { accountId: 'account-a', rateLimits: null, updatedAt: 0, isFetching: true }, + { accountId: 'account-b', rateLimits: null, updatedAt: 0, isFetching: true } + ]) + + inactiveAccounts = [{ id: 'account-a', managedHomePath: '/tmp/account-a/home' }] + service.evictInactiveCodexCache('account-b') + accountFetch.resolve(okProvider('codex', 64, Date.now())) + await fetchOnOpen + + expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1) + expect(service.getState().inactiveCodexAccounts).toEqual([ + { + accountId: 'account-a', + rateLimits: expect.objectContaining({ + provider: 'codex', + session: expect.objectContaining({ usedPercent: 64 }) + }), + updatedAt: expect.any(Number), + isFetching: false + } + ]) + }) + + it('does not recache an inactive Codex account that becomes active during fetch-on-open', async () => { + const service = new RateLimitService() + const accountFetch = deferred() + let inactiveAccounts = [{ id: 'account-b', managedHomePath: '/tmp/account-b/home' }] + service.setInactiveCodexAccountsResolver(() => inactiveAccounts) + service.setCodexHomePathResolver(() => '/tmp/account-b/home') + vi.mocked(fetchCodexRateLimits) + .mockReturnValueOnce(accountFetch.promise) + .mockResolvedValueOnce(okProvider('codex', 7, Date.now())) + + const fetchOnOpen = service.fetchInactiveCodexAccountsOnOpen() + await Promise.resolve() + expect(service.getState().inactiveCodexAccounts).toEqual([ + { accountId: 'account-b', rateLimits: null, updatedAt: 0, isFetching: true } + ]) + + inactiveAccounts = [] + await service.refreshForCodexAccountChange('account-a') + accountFetch.resolve(okProvider('codex', 42, Date.now())) + await fetchOnOpen + + expect(service.getState().inactiveCodexAccounts).toEqual([]) + }) + it('preserves Gemini buckets through getState after fetch', async () => { const service = new RateLimitService() @@ -586,7 +731,7 @@ describe('RateLimitService', () => { const fetchOnOpen = service.fetchInactiveClaudeAccountsOnOpen() await Promise.resolve() expect(service.getState().inactiveClaudeAccounts).toEqual([ - { accountId: 'account-1', claude: null, updatedAt: 0, isFetching: true } + { accountId: 'account-1', rateLimits: null, updatedAt: 0, isFetching: true } ]) service.evictInactiveClaudeCache('account-1') @@ -628,7 +773,8 @@ describe('RateLimitService', () => { expect(service.getState().inactiveClaudeAccounts).toEqual([ { accountId: 'account-1', - claude: expect.objectContaining({ + rateLimits: expect.objectContaining({ + provider: 'claude', session: expect.objectContaining({ usedPercent: 7 }) }), updatedAt: expect.any(Number), diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index 82ac7753f75..7bbbde31435 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -38,12 +38,14 @@ type ClaudeAuthPreparationResolver = ( // state is informational, so prefer keeping a recent snapshot over polling it // into 429s during long focused Orca sessions. const DEFAULT_POLL_MS = 15 * 60 * 1000 // 15 minutes +const MIN_POLL_MS = 30 * 1000 // 30 seconds — renderer input should never create a tight loop. +const MAX_POLL_MS = 2_147_483_647 // Max safe setInterval delay before Node clamps back to 1ms. const MIN_REFETCH_MS = 5 * 60 * 1000 // 5 minutes — debounce resume/manual refresh bursts const STALE_THRESHOLD_MS = 30 * 60 * 1000 // 30 minutes — after this, stale data is dropped const INACTIVE_FETCH_DEBOUNCE_MS = 60 * 1000 // 60 seconds — debounce fetch-on-open -// Why: the internal state only tracks claude and codex. The inactiveClaudeAccounts -// array is derived from the cache on demand in getState() and pushToRenderer(). +// Why: inactive account arrays are derived from provider-specific caches on +// demand in getState() and pushToRenderer(). type InternalRateLimitState = { claude: ProviderRateLimits | null codex: ProviderRateLimits | null @@ -51,6 +53,13 @@ type InternalRateLimitState = { opencodeGo: ProviderRateLimits | null } +function normalizePollingInterval(ms: number): number { + if (!Number.isFinite(ms)) { + return DEFAULT_POLL_MS + } + return Math.min(MAX_POLL_MS, Math.max(MIN_POLL_MS, ms)) +} + export class RateLimitService { private state: InternalRateLimitState = { claude: null, @@ -98,6 +107,7 @@ export class RateLimitService { private lastInactiveClaudeFetchAt = 0 private inactiveClaudeAccountsGeneration = 0 private lastInactiveCodexFetchAt = 0 + private inactiveCodexAccountsGeneration = 0 private stateListeners = new Set<(state: RateLimitState) => void>() constructor() {} @@ -142,6 +152,8 @@ export class RateLimitService { setInactiveCodexAccountsResolver(resolver: () => InactiveCodexAccountInfo[]): void { this.inactiveCodexAccountsResolver = resolver + this.inactiveCodexAccountsGeneration += 1 + this.pruneInactiveCodexState() } attach(mainWindow: BrowserWindow): void { @@ -150,21 +162,28 @@ export class RateLimitService { const refreshOnResume = (): void => { void this.refreshIfWindowActive() } - mainWindow.on('focus', refreshOnResume) - mainWindow.on('show', refreshOnResume) - mainWindow.on('restore', refreshOnResume) - this.detachWindowListeners = () => { + // Why: attach() can replace windows; the previous closed listener also + // captures this service and must be removed with the focus listeners. + const detachWindowListeners = (): void => { mainWindow.removeListener('focus', refreshOnResume) mainWindow.removeListener('show', refreshOnResume) mainWindow.removeListener('restore', refreshOnResume) + mainWindow.removeListener('closed', onClosed) } - mainWindow.on('closed', () => { - this.detachWindowListeners?.() - this.detachWindowListeners = null + const onClosed = (): void => { + detachWindowListeners() + if (this.detachWindowListeners === detachWindowListeners) { + this.detachWindowListeners = null + } if (this.mainWindow === mainWindow) { this.mainWindow = null } - }) + } + mainWindow.on('focus', refreshOnResume) + mainWindow.on('show', refreshOnResume) + mainWindow.on('restore', refreshOnResume) + mainWindow.on('closed', onClosed) + this.detachWindowListeners = detachWindowListeners } start(): void { @@ -181,6 +200,8 @@ export class RateLimitService { } getState(): RateLimitState { + this.pruneInactiveClaudeState() + this.pruneInactiveCodexState() return { ...this.state, claudeTarget: this.claudeFetchTarget, @@ -219,6 +240,8 @@ export class RateLimitService { } this.codexFetchTarget = nextTarget this.codexFetchGeneration += 1 + this.inactiveCodexAccountsGeneration += 1 + this.pruneInactiveCodexState() this.lastInactiveCodexFetchAt = 0 // Why: switching the selected Codex account must immediately clear the old // Codex quota view. Keeping stale values visible would show the previous @@ -350,6 +373,7 @@ export class RateLimitService { if (Date.now() - this.lastInactiveCodexFetchAt < INACTIVE_FETCH_DEBOUNCE_MS) { return } + this.pruneInactiveCodexState() if (this.inactiveCodexFetching.size > 0) { return } @@ -357,6 +381,9 @@ export class RateLimitService { if (accounts.length === 0) { return } + // 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 for (const account of accounts) { this.inactiveCodexFetching.add(account.id) @@ -364,6 +391,17 @@ export class RateLimitService { 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 @@ -375,16 +413,35 @@ export class RateLimitService { codexHomePath: account.managedHomePath, allowPtyFallback: false }) + 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 + } 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) + } } this.inactiveCodexFetching.delete(account.id) this.pushToRenderer() } - this.lastInactiveCodexFetchAt = Date.now() + if (fetchGeneration === this.inactiveCodexAccountsGeneration) { + this.lastInactiveCodexFetchAt = Date.now() + } } evictInactiveClaudeCache(accountId: string): void { @@ -400,6 +457,12 @@ export class RateLimitService { ) } + private isCurrentInactiveCodexAccount(accountId: string): boolean { + return (this.inactiveCodexAccountsResolver?.() ?? []).some( + (account) => account.id === accountId + ) + } + private pruneInactiveClaudeState(): void { const currentIds = new Set( (this.inactiveClaudeAccountsResolver?.() ?? []).map((account) => account.id) @@ -416,14 +479,35 @@ export class RateLimitService { } } + private pruneInactiveCodexState(): void { + const currentIds = new Set( + (this.inactiveCodexAccountsResolver?.() ?? []).map((account) => account.id) + ) + for (const accountId of this.inactiveCodexCache.keys()) { + if (!currentIds.has(accountId)) { + this.inactiveCodexCache.delete(accountId) + } + } + for (const accountId of this.inactiveCodexFetching) { + if (!currentIds.has(accountId)) { + this.inactiveCodexFetching.delete(accountId) + } + } + } + evictInactiveCodexCache(accountId: string): void { + // Why: only the evicted account's state should be cleared. The per-account + // isCurrentInactiveCodexAccount guard in fetchInactiveCodexAccountsOnOpen + // already catches a removed account when its resolver entry disappears, + // so bumping the generation here would also invalidate sibling fetches + // still in flight and discard their fresh results. this.inactiveCodexCache.delete(accountId) this.inactiveCodexFetching.delete(accountId) this.pushToRenderer() } setPollingInterval(ms: number): void { - this.pollInterval = Math.max(30_000, ms) + this.pollInterval = normalizePollingInterval(ms) if (this.timer) { this.stopTimer() this.startTimer() @@ -949,12 +1033,11 @@ export class RateLimitService { cache: Map, fetching: Set ): InactiveAccountUsage[] { - this.pruneInactiveClaudeState() const result: InactiveAccountUsage[] = [] for (const [accountId, limits] of cache) { result.push({ accountId, - claude: limits, + rateLimits: limits, updatedAt: limits.updatedAt, isFetching: fetching.has(accountId) }) @@ -965,7 +1048,7 @@ export class RateLimitService { if (!cache.has(accountId)) { result.push({ accountId, - claude: null, + rateLimits: null, updatedAt: 0, isFetching: true }) diff --git a/src/main/remote-agent-trust-presets.test.ts b/src/main/remote-agent-trust-presets.test.ts new file mode 100644 index 00000000000..6591f741fbd --- /dev/null +++ b/src/main/remote-agent-trust-presets.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getActiveMultiplexer: vi.fn(), + getSshFilesystemProvider: vi.fn() +})) + +vi.mock('./ipc/ssh', () => ({ + getActiveMultiplexer: mocks.getActiveMultiplexer +})) + +vi.mock('./providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: mocks.getSshFilesystemProvider +})) + +const { markRemoteAgentWorkspaceTrusted } = await import('./remote-agent-trust-presets') + +function makeFsProvider(overrides: Record = {}) { + return { + realpath: vi.fn(async (path: string) => `/real${path}`), + readFile: vi.fn(async () => ({ content: '', isBinary: false })), + createDir: vi.fn(async () => undefined), + writeFile: vi.fn(async () => undefined), + stat: vi.fn(async () => { + throw new Error('missing') + }), + ...overrides + } +} + +describe('markRemoteAgentWorkspaceTrusted', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getActiveMultiplexer.mockReturnValue({ + request: vi.fn(async () => ({ resolvedPath: '/home/u/' })) + }) + }) + + it('writes Codex trust to the remote home and canonicalized workspace path', async () => { + const fsProvider = makeFsProvider() + mocks.getSshFilesystemProvider.mockReturnValue(fsProvider) + + await markRemoteAgentWorkspaceTrusted({ + preset: 'codex', + connectionId: 'ssh-1', + workspacePath: '/repo/worktree' + }) + + expect(mocks.getActiveMultiplexer).toHaveBeenCalledWith('ssh-1') + expect(fsProvider.realpath).toHaveBeenCalledWith('/repo/worktree') + expect(fsProvider.createDir).toHaveBeenCalledWith('/home/u/.codex') + expect(fsProvider.writeFile).toHaveBeenCalledWith( + '/home/u/.codex/config.toml', + expect.stringContaining('[projects."/real/repo/worktree"]') + ) + }) + + it('writes Cursor trust marker on the remote host', async () => { + const fsProvider = makeFsProvider() + mocks.getSshFilesystemProvider.mockReturnValue(fsProvider) + + await markRemoteAgentWorkspaceTrusted({ + preset: 'cursor', + connectionId: 'ssh-1', + workspacePath: '/repo/worktree' + }) + + expect(fsProvider.createDir).toHaveBeenCalledWith('/home/u/.cursor/projects/real-repo-worktree') + expect(fsProvider.writeFile).toHaveBeenCalledWith( + '/home/u/.cursor/projects/real-repo-worktree/.workspace-trusted', + expect.stringContaining('"workspacePath": "/real/repo/worktree"') + ) + }) + + it('appends Copilot trusted folder remotely without clobbering config keys', async () => { + const writeFile = vi.fn(async (_filePath: string, _content: string) => undefined) + const fsProvider = makeFsProvider({ + readFile: vi.fn(async () => ({ + content: JSON.stringify({ firstLaunchAt: '2026-01-01', trustedFolders: ['/old'] }), + isBinary: false + })), + writeFile + }) + mocks.getSshFilesystemProvider.mockReturnValue(fsProvider) + + await markRemoteAgentWorkspaceTrusted({ + preset: 'copilot', + connectionId: 'ssh-1', + workspacePath: '/repo/worktree' + }) + + expect(fsProvider.createDir).toHaveBeenCalledWith('/home/u/.copilot') + const written = writeFile.mock.calls[0]?.[1] + expect(typeof written).toBe('string') + expect(JSON.parse(written as string)).toEqual({ + firstLaunchAt: '2026-01-01', + trustedFolders: ['/old', '/real/repo/worktree'] + }) + }) + + it('does nothing when the SSH home cannot be resolved safely', async () => { + const fsProvider = makeFsProvider() + mocks.getActiveMultiplexer.mockReturnValue({ + request: vi.fn(async () => ({ resolvedPath: 'relative/home' })) + }) + mocks.getSshFilesystemProvider.mockReturnValue(fsProvider) + + await markRemoteAgentWorkspaceTrusted({ + preset: 'codex', + connectionId: 'ssh-1', + workspacePath: '/repo/worktree' + }) + + expect(fsProvider.writeFile).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/remote-agent-trust-presets.ts b/src/main/remote-agent-trust-presets.ts new file mode 100644 index 00000000000..4d6e5f94d0d --- /dev/null +++ b/src/main/remote-agent-trust-presets.ts @@ -0,0 +1,135 @@ +import type { AgentTrustPreset } from './agent-trust-presets' +import { upsertProjectTrustLevelInContent } from './codex/config-toml-trust' +import { getActiveMultiplexer } from './ipc/ssh' +import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch' +import type { IFilesystemProvider } from './providers/types' + +export async function markRemoteAgentWorkspaceTrusted(args: { + preset: AgentTrustPreset + connectionId: string + workspacePath: string +}): Promise { + const home = await resolveRemoteHome(args.connectionId) + const fsProvider = getSshFilesystemProvider(args.connectionId) + if (!home || !fsProvider) { + return + } + + const workspacePath = await canonicalizeRemoteWorkspacePath(fsProvider, args.workspacePath) + if (args.preset === 'codex') { + await markRemoteCodexProjectTrusted(fsProvider, home, workspacePath) + } else if (args.preset === 'cursor') { + await markRemoteCursorWorkspaceTrusted(fsProvider, home, workspacePath) + } else if (args.preset === 'copilot') { + await markRemoteCopilotFolderTrusted(fsProvider, home, workspacePath) + } +} + +async function resolveRemoteHome(connectionId: string): Promise { + const mux = getActiveMultiplexer(connectionId) + if (!mux || mux.isDisposed?.()) { + return null + } + const result = (await mux.request('session.resolveHome', { path: '~' })) as { + resolvedPath?: unknown + } + const home = typeof result.resolvedPath === 'string' ? result.resolvedPath.trim() : '' + return home && home.startsWith('/') && !hasRemotePathControlCharacter(home) + ? home.replace(/\/$/, '') + : null +} + +function hasRemotePathControlCharacter(value: string): boolean { + return value.includes(String.fromCharCode(0)) || value.includes('\r') || value.includes('\n') +} + +async function canonicalizeRemoteWorkspacePath( + fsProvider: IFilesystemProvider, + workspacePath: string +): Promise { + try { + return await fsProvider.realpath(workspacePath) + } catch { + return workspacePath + } +} + +async function readRemoteTextFile( + fsProvider: IFilesystemProvider, + filePath: string +): Promise { + try { + const result = await fsProvider.readFile(filePath) + return result.isBinary ? '' : result.content + } catch { + return '' + } +} + +async function markRemoteCodexProjectTrusted( + fsProvider: IFilesystemProvider, + remoteHome: string, + workspacePath: string +): Promise { + const codexDir = `${remoteHome}/.codex` + const configPath = `${codexDir}/config.toml` + const existing = await readRemoteTextFile(fsProvider, configPath) + const updated = upsertProjectTrustLevelInContent(existing, workspacePath, 'trusted') + if (updated === existing) { + return + } + await fsProvider.createDir(codexDir) + await fsProvider.writeFile(configPath, updated) +} + +async function markRemoteCursorWorkspaceTrusted( + fsProvider: IFilesystemProvider, + remoteHome: string, + workspacePath: string +): Promise { + const slug = workspacePath.replace(/^[\\/]+/, '').replace(/[\\/]+/g, '-') + if (!slug) { + return + } + const trustDir = `${remoteHome}/.cursor/projects/${slug}` + const trustFile = `${trustDir}/.workspace-trusted` + try { + await fsProvider.stat(trustFile) + return + } catch { + // Missing marker: write the same shape the local trust preset writes. + } + await fsProvider.createDir(trustDir) + await fsProvider.writeFile( + trustFile, + `${JSON.stringify({ trustedAt: new Date().toISOString(), workspacePath }, null, 2)}\n` + ) +} + +async function markRemoteCopilotFolderTrusted( + fsProvider: IFilesystemProvider, + remoteHome: string, + workspacePath: string +): Promise { + const configDir = `${remoteHome}/.copilot` + const configPath = `${configDir}/config.json` + const raw = await readRemoteTextFile(fsProvider, configPath) + let config: Record = {} + if (raw.trim()) { + try { + const parsed = JSON.parse(raw) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + config = parsed as Record + } + } catch { + return + } + } + const existing = Array.isArray(config.trustedFolders) ? (config.trustedFolders as unknown[]) : [] + if (existing.includes(workspacePath)) { + return + } + config.trustedFolders = [...existing.filter((entry) => typeof entry === 'string'), workspacePath] + await fsProvider.createDir(configDir) + await fsProvider.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`) +} diff --git a/src/main/repo-icon-autodetect.test.ts b/src/main/repo-icon-autodetect.test.ts index cfd5eb3f2c5..7d0aa0cecd1 100644 --- a/src/main/repo-icon-autodetect.test.ts +++ b/src/main/repo-icon-autodetect.test.ts @@ -69,6 +69,51 @@ describe('detectRepoIcon', () => { }) }) + it('resolves relative declared icon hrefs from nested source files', async () => { + const repoPath = await makeTempRepoDir() + await mkdir(join(repoPath, 'src', 'routes', 'brand'), { recursive: true }) + await writeFile( + join(repoPath, 'src', 'routes', '__root.tsx'), + 'export const links = () => [{ rel: "icon", href: "./brand/icon.png" }]' + ) + await writeFile( + join(repoPath, 'src', 'routes', 'brand', 'icon.png'), + Buffer.from(PNG_1X1_BASE64, 'base64') + ) + + await expect(detectRepoIcon({ repoPath, kind: 'folder' })).resolves.toEqual({ + type: 'image', + src: `data:image/png;base64,${PNG_1X1_BASE64}`, + source: 'file', + label: 'src/routes/brand/icon.png' + }) + }) + + it('skips oversized source files when looking for declared icon hrefs', async () => { + const repoPath = await makeTempRepoDir() + await writeFile( + join(repoPath, 'index.html'), + `${'x'.repeat(256 * 1024 + 1)}` + ) + await mkdir(join(repoPath, 'public', 'brand'), { recursive: true }) + await writeFile( + join(repoPath, 'public', 'brand', 'icon.png'), + Buffer.from(PNG_1X1_BASE64, 'base64') + ) + + await expect(detectRepoIcon({ repoPath, kind: 'folder' })).resolves.toBeUndefined() + }) + + it('does not resolve declared icon hrefs outside the repo', async () => { + const parentPath = await makeTempRepoDir() + const repoPath = join(parentPath, 'repo') + await mkdir(repoPath) + await writeFile(join(parentPath, 'outside.png'), Buffer.from(PNG_1X1_BASE64, 'base64')) + await writeFile(join(repoPath, 'index.html'), '') + + await expect(detectRepoIcon({ repoPath, kind: 'folder' })).resolves.toBeUndefined() + }) + it('falls back to the GitHub owner avatar for GitHub repos', async () => { const repoPath = await makeTempRepoDir() await gitExecFileAsync(['init'], { cwd: repoPath }) diff --git a/src/main/repo-icon-autodetect.ts b/src/main/repo-icon-autodetect.ts index 86ef07414d4..6101c3a7972 100644 --- a/src/main/repo-icon-autodetect.ts +++ b/src/main/repo-icon-autodetect.ts @@ -8,6 +8,7 @@ import { import { getRepoSlug } from './github/client' import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch' import type { IFilesystemProvider } from './providers/types' +import { iconHrefCandidates } from './repo-icon-href-candidates' import { joinWorktreeRelativePath } from './runtime/runtime-relative-paths' const REPO_ICON_FILE_CANDIDATES = [ @@ -34,6 +35,10 @@ const REPO_ICON_SOURCE_FILE_CANDIDATES = [ 'src/index.html' ] +// Why: repo icon detection runs while adding repos; declared-icon probing should +// not read large app entrypoints just to find a small favicon href. +const MAX_REPO_ICON_SOURCE_BYTES = 256 * 1024 + const LINK_ICON_HTML_RE = /]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i const LINK_ICON_OBJECT_RE = @@ -75,11 +80,6 @@ function extractIconHref(source: string): string | null { return source.match(LINK_ICON_HTML_RE)?.[1] ?? source.match(LINK_ICON_OBJECT_RE)?.[1] ?? null } -function iconHrefCandidates(href: string): string[] { - const clean = href.replace(/^\/+/, '') - return [`public/${clean}`, clean] -} - async function readLocalPngIcon(repoPath: string, relativePath: string): Promise { const filePath = joinWorktreeRelativePath(repoPath, relativePath) const info = await stat(filePath) @@ -137,12 +137,17 @@ async function detectLocalPngIcon(repoPath: string): Promise { } for (const sourceFile of REPO_ICON_SOURCE_FILE_CANDIDATES) { try { - const source = await readFile(joinWorktreeRelativePath(repoPath, sourceFile), 'utf8') + const sourcePath = joinWorktreeRelativePath(repoPath, sourceFile) + const sourceInfo = await stat(sourcePath) + if (!sourceInfo.isFile() || sourceInfo.size > MAX_REPO_ICON_SOURCE_BYTES) { + continue + } + const source = await readFile(sourcePath, 'utf8') const href = extractIconHref(source) if (!href) { continue } - for (const relativePath of iconHrefCandidates(href)) { + for (const relativePath of iconHrefCandidates(href, sourceFile)) { try { const icon = await readLocalPngIcon(repoPath, relativePath) if (icon) { @@ -175,7 +180,12 @@ async function detectRemotePngIcon( } for (const sourceFile of REPO_ICON_SOURCE_FILE_CANDIDATES) { try { - const result = await fsProvider.readFile(joinWorktreeRelativePath(repoPath, sourceFile)) + const sourcePath = joinWorktreeRelativePath(repoPath, sourceFile) + const sourceInfo = await fsProvider.stat(sourcePath) + if (sourceInfo.type !== 'file' || sourceInfo.size > MAX_REPO_ICON_SOURCE_BYTES) { + continue + } + const result = await fsProvider.readFile(sourcePath) if (result.isBinary) { continue } @@ -183,7 +193,7 @@ async function detectRemotePngIcon( if (!href) { continue } - for (const relativePath of iconHrefCandidates(href)) { + for (const relativePath of iconHrefCandidates(href, sourceFile)) { try { const icon = await readRemotePngIcon(repoPath, fsProvider, relativePath) if (icon) { diff --git a/src/main/repo-icon-href-candidates.ts b/src/main/repo-icon-href-candidates.ts new file mode 100644 index 00000000000..4e2c52accf6 --- /dev/null +++ b/src/main/repo-icon-href-candidates.ts @@ -0,0 +1,38 @@ +import { posix } from 'path' + +function normalizeIconHrefPath(href: string): { path: string; rootRelative: boolean } | null { + const trimmed = href.trim() + if (!trimmed || trimmed.startsWith('//') || /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(trimmed)) { + return null + } + + const rootRelative = trimmed.startsWith('/') + const pathOnly = (trimmed.split(/[?#]/)[0] ?? '').replace(/^\/+/, '').replace(/\\/g, '/') + const parts = pathOnly.split('/').filter((part) => part && part !== '.') + // Why: declared icon hrefs are repo content. Never let a best-effort icon + // probe resolve outside the worktree through `../` path segments. + if (parts.length === 0 || parts.some((part) => part === '..')) { + return null + } + return { path: parts.join('/'), rootRelative } +} + +export function iconHrefCandidates(href: string, sourceFile: string): string[] { + const clean = normalizeIconHrefPath(href) + if (!clean) { + return [] + } + + const candidates = new Set() + if (!clean.rootRelative) { + const sourceDirectory = posix.dirname(sourceFile) + if (sourceDirectory && sourceDirectory !== '.') { + // Why: relative hrefs in nested route/root files resolve next to that + // source file, not from the repository root. + candidates.add(posix.join(sourceDirectory, clean.path)) + } + } + candidates.add(`public/${clean.path}`) + candidates.add(clean.path) + return [...candidates] +} diff --git a/src/main/repo-worktrees.test.ts b/src/main/repo-worktrees.test.ts index 8e3c613efb8..fd905bacf08 100644 --- a/src/main/repo-worktrees.test.ts +++ b/src/main/repo-worktrees.test.ts @@ -8,7 +8,7 @@ vi.mock('./git/worktree', () => ({ listWorktrees: listWorktreesMock })) -import { createFolderWorktree, listRepoWorktrees } from './repo-worktrees' +import { createFolderWorktree, isRepoRoot, listRepoWorktrees } from './repo-worktrees' describe('repo-worktrees', () => { beforeEach(() => { @@ -73,4 +73,19 @@ describe('repo-worktrees', () => { expect(listWorktreesMock).toHaveBeenCalledWith('/workspace/repo') expect(result).toHaveLength(1) }) + + it('treats Windows repo root casing differences as the same local root', () => { + const repos = [ + { + id: 'repo-1', + path: String.raw`C:\Repo`, + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + kind: 'git' as const + } + ] + + expect(isRepoRoot(repos, String.raw`c:\repo`)).toBe(true) + }) }) diff --git a/src/main/repo-worktrees.ts b/src/main/repo-worktrees.ts index 5a6b6925493..4f5832a2a1d 100644 --- a/src/main/repo-worktrees.ts +++ b/src/main/repo-worktrees.ts @@ -1,11 +1,13 @@ import type { GitWorktreeInfo, Repo } from '../shared/types' -import { resolve } from 'path' import { listWorktrees } from './git/worktree' import { isFolderRepo } from '../shared/repo-kind' import { getSshGitProvider } from './providers/ssh-git-dispatch' +import { areWorktreePathsEqual } from './ipc/worktree-logic' export function isRepoRoot(repos: Repo[], resolvedTarget: string): boolean { - return repos.some((repo) => !repo.connectionId && resolve(repo.path) === resolvedTarget) + return repos.some( + (repo) => !repo.connectionId && areWorktreePathsEqual(repo.path, resolvedTarget) + ) } export function createFolderWorktree(repo: Repo): GitWorktreeInfo { diff --git a/src/main/runtime/e2ee-keypair.ts b/src/main/runtime/e2ee-keypair.ts index eee61363191..f138bbf2b63 100644 --- a/src/main/runtime/e2ee-keypair.ts +++ b/src/main/runtime/e2ee-keypair.ts @@ -1,13 +1,14 @@ // Why: the E2EE keypair enables application-layer encryption between mobile // and desktop over plain ws://. The public key is embedded in the QR pairing // offer so the mobile client can derive a shared secret via ECDH. -import { existsSync, readFileSync } from 'fs' +import { existsSync, readFileSync, statSync } from 'fs' import { join } from 'path' import nacl from 'tweetnacl' import { hardenExistingSecureFile, writeSecureJsonFile } from '../../shared/secure-file' const KEYPAIR_FILENAME = 'orca-e2ee-keypair.json' const KEYPAIR_VERSION = 1 +const MAX_KEYPAIR_FILE_BYTES = 8 * 1024 type KeypairFile = { v: number @@ -27,6 +28,11 @@ export function loadOrCreateE2EEKeypair(userDataPath: string): E2EEKeypair { if (existsSync(filePath)) { try { hardenExistingSecureFile(filePath) + // Why: this startup path reads synchronously; valid keypair files are + // tiny, so oversized/corrupt files should be replaced without loading. + if (statSync(filePath).size > MAX_KEYPAIR_FILE_BYTES) { + throw new Error('E2EE keypair file is too large') + } const raw: KeypairFile = JSON.parse(readFileSync(filePath, 'utf-8')) if (raw.v === KEYPAIR_VERSION && raw.publicKeyB64 && raw.secretKeyB64) { const publicKey = Uint8Array.from(Buffer.from(raw.publicKeyB64, 'base64')) diff --git a/src/main/runtime/fetch-remote-cache.test.ts b/src/main/runtime/fetch-remote-cache.test.ts index ea691434295..9016cf060dc 100644 --- a/src/main/runtime/fetch-remote-cache.test.ts +++ b/src/main/runtime/fetch-remote-cache.test.ts @@ -118,6 +118,24 @@ describe('OrcaRuntimeService.fetchRemoteWithCache', () => { expect(fetchCallCount()).toBe(1) }) + it('bounds process-lifetime fetch cache maps for churned repo paths', async () => { + mockFetchResults(Array.from({ length: 520 }, () => ({ stdout: '', stderr: '' }))) + const runtime = new OrcaRuntimeService(null) + const caches = runtime as unknown as { + canonicalFetchKeyCache: Map + fetchLastCompletedAt: Map + } + + for (let i = 0; i < 520; i += 1) { + await runtime.fetchRemoteWithCache(`/repo/cache-${i}`, 'origin') + } + + expect(caches.canonicalFetchKeyCache.size).toBeLessThanOrEqual(512) + expect(caches.fetchLastCompletedAt.size).toBeLessThanOrEqual(512) + expect(caches.canonicalFetchKeyCache.has('/repo/cache-0::origin')).toBe(false) + expect(caches.fetchLastCompletedAt.has('/repo/cache-0::origin')).toBe(false) + }) + it('resolves remote-tracking bases with longest configured remote matching', async () => { gitExecFileAsyncMock.mockResolvedValue({ stdout: 'foo\nfoo/bar\norigin\n', stderr: '' }) const runtime = new OrcaRuntimeService(null) diff --git a/src/main/runtime/orca-runtime-automations.test.ts b/src/main/runtime/orca-runtime-automations.test.ts index 28746f11577..9920e5c12ff 100644 --- a/src/main/runtime/orca-runtime-automations.test.ts +++ b/src/main/runtime/orca-runtime-automations.test.ts @@ -49,6 +49,7 @@ const existingAutomation = { id: 'auto-1', name: 'Daily review', prompt: 'Review changes', + precheck: null, agentId: 'codex', projectId: 'repo-1', executionTargetType: 'local', @@ -77,6 +78,7 @@ describe('OrcaRuntimeService automation methods', () => { const automation = await runtime.createAutomation({ name: 'Daily review', prompt: 'Review changes', + precheck: { command: 'test -f ready', timeoutSeconds: 30 }, agentId: 'codex', repo: 'repo-1', workspaceMode: 'new_per_run', @@ -88,6 +90,7 @@ describe('OrcaRuntimeService automation methods', () => { expect.objectContaining({ name: 'Daily review', prompt: 'Review changes', + precheck: { command: 'test -f ready', timeoutSeconds: 30 }, agentId: 'codex', projectId: 'repo-1', workspaceMode: 'new_per_run', diff --git a/src/main/runtime/orca-runtime-files.test.ts b/src/main/runtime/orca-runtime-files.test.ts index a6207bc0234..1ac31c38d4e 100644 --- a/src/main/runtime/orca-runtime-files.test.ts +++ b/src/main/runtime/orca-runtime-files.test.ts @@ -2,9 +2,11 @@ authorization, and watcher lifecycle fixtures; splitting would duplicate the setup that makes cross-command filesystem behavior comparable. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' import type * as Fs from 'fs' import type * as FsPromises from 'fs/promises' import type * as FilesystemAuth from '../ipc/filesystem-auth' +import type * as GitRunner from '../git/runner' const { lstatMock, @@ -13,14 +15,18 @@ const { resolveAuthorizedPathMock, statMock, subscribeParcelWatcherMock, + checkRgAvailableMock, + wslAwareSpawnMock, watchMock } = vi.hoisted(() => ({ + checkRgAvailableMock: vi.fn(), lstatMock: vi.fn(), readdirMock: vi.fn(), renameMock: vi.fn(), resolveAuthorizedPathMock: vi.fn(), statMock: vi.fn(), subscribeParcelWatcherMock: vi.fn(), + wslAwareSpawnMock: vi.fn(), watchMock: vi.fn() })) @@ -55,6 +61,18 @@ vi.mock('../ipc/filesystem-auth', async () => { } }) +vi.mock('../git/runner', async () => { + const actual = await vi.importActual('../git/runner') + return { + ...actual, + wslAwareSpawn: wslAwareSpawnMock + } +}) + +vi.mock('../ipc/rg-availability', () => ({ + checkRgAvailable: checkRgAvailableMock +})) + vi.mock('../providers/ssh-filesystem-dispatch', () => ({ getSshFilesystemProvider: vi.fn(), SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE: @@ -63,6 +81,13 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ import { awaitRuntimeFileWatcherUnsubscribes, RuntimeFileCommands } from './orca-runtime-files' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { SEARCH_TIMEOUT_MS } from '../../shared/text-search' + +type MockRuntimeSearchChild = EventEmitter & { + stdout: EventEmitter & { setEncoding: ReturnType } + stderr: EventEmitter + kill: ReturnType +} function enoent(): Error { return Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) @@ -94,6 +119,7 @@ function mockLocalPathStats(entries: Record) { function createRuntimeFileCommands(options?: { path?: string openDiff?: ReturnType + resolveRuntimeGitTarget?: ReturnType }) { const store = { getRepo: vi.fn((_repoId?: string) => undefined as { connectionId?: string } | undefined) @@ -107,13 +133,22 @@ function createRuntimeFileCommands(options?: { repoId: 'repo-1', path })), - resolveRuntimeGitTarget: vi.fn(), + resolveRuntimeGitTarget: options?.resolveRuntimeGitTarget ?? vi.fn(), openFile: vi.fn(), ...(options?.openDiff ? { openDiff: options.openDiff } : {}) } as never) return { commands, store } } +function createRuntimeSearchChild(): MockRuntimeSearchChild { + const child = new EventEmitter() as MockRuntimeSearchChild + child.stdout = new EventEmitter() as MockRuntimeSearchChild['stdout'] + child.stdout.setEncoding = vi.fn() + child.stderr = new EventEmitter() + child.kill = vi.fn() + return child +} + describe('RuntimeFileCommands', () => { const originalPlatform = process.platform @@ -126,6 +161,8 @@ describe('RuntimeFileCommands', () => { statMock.mockReset() subscribeParcelWatcherMock.mockReset() watchMock.mockReset() + checkRgAvailableMock.mockReset() + wslAwareSpawnMock.mockReset() readdirMock.mockResolvedValue([]) lstatMock.mockRejectedValue(enoent()) renameMock.mockResolvedValue(undefined) @@ -329,4 +366,67 @@ describe('RuntimeFileCommands', () => { await drainPromise expect(drained).toBe(true) }) + + it('collapses large Parcel watcher batches to an overflow refresh', async () => { + resolveAuthorizedPathMock.mockResolvedValue('/repo') + statMock.mockResolvedValue({ isDirectory: () => true }) + type ParcelCallback = (err: Error | null, events: { type: 'create'; path: string }[]) => void + const parcelCallbackRef: { current: ParcelCallback | null } = { current: null } + subscribeParcelWatcherMock.mockImplementation(async (_rootPath, callback) => { + parcelCallbackRef.current = callback as ParcelCallback + return { unsubscribe: vi.fn() } + }) + const { commands } = createRuntimeFileCommands() + const onEvents = vi.fn() + + await commands.watchFileExplorer('id:wt-1', onEvents) + statMock.mockClear() + if (!parcelCallbackRef.current) { + throw new Error('Parcel watcher callback was not registered') + } + parcelCallbackRef.current( + null, + Array.from({ length: 201 }, (_, index) => ({ + type: 'create', + path: `/repo/generated-${index}.txt` + })) + ) + await Promise.resolve() + + expect(statMock).not.toHaveBeenCalled() + expect(onEvents).toHaveBeenCalledWith([{ kind: 'overflow', absolutePath: '/repo' }]) + }) + + it('settles and detaches runtime rg searches when timeout kill is ignored', async () => { + const resolveRuntimeGitTarget = vi.fn(async () => ({ + worktree: { + id: 'wt-1', + repoId: 'repo-1', + path: '/repo' + }, + connectionId: null + })) + const { commands } = createRuntimeFileCommands({ resolveRuntimeGitTarget }) + const child = createRuntimeSearchChild() + resolveAuthorizedPathMock.mockResolvedValue('/repo') + checkRgAvailableMock.mockResolvedValue(true) + wslAwareSpawnMock.mockReturnValue(child) + + const resultPromise = commands.searchRuntimeFiles('id:wt-1', { + query: 'needle', + maxResults: 10 + }) + await vi.advanceTimersByTimeAsync(SEARCH_TIMEOUT_MS) + + await expect(resultPromise).resolves.toMatchObject({ + files: [], + totalMatches: 0, + truncated: true + }) + expect(child.kill).toHaveBeenCalledTimes(1) + expect(child.stdout.listenerCount('data')).toBe(0) + expect(child.stderr.listenerCount('data')).toBe(0) + expect(child.listenerCount('error')).toBe(0) + expect(child.listenerCount('close')).toBe(0) + }) }) diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index 062fc50fcbe..ecd8f98c30c 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -60,6 +60,7 @@ const MOBILE_FILE_LIST_LIMIT = 5000 const MOBILE_FILE_READ_MAX_BYTES = 512 * 1024 const RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES = 10 * 1024 * 1024 const WINDOWS_RUNTIME_FILE_WATCH_DEBOUNCE_MS = 150 +const RUNTIME_FILE_WATCH_EVENT_STAT_LIMIT = 200 // Why: runtime files.watch subscriptions are cleaned up through synchronous RPC // callbacks. Track native Parcel unsubscribe work so app shutdown can drain it. const pendingRuntimeFileWatcherUnsubscribes = new Set>() @@ -284,6 +285,12 @@ export class RuntimeFileCommands { callback([{ kind: 'overflow', absolutePath: rootPath }]) return } + // Why: large watcher batches usually mean a generated directory or + // branch switch. Avoid stat fanout and ask the renderer to refresh. + if (events.length > RUNTIME_FILE_WATCH_EVENT_STAT_LIMIT) { + callback([{ kind: 'overflow', absolutePath: rootPath }]) + return + } void Promise.all( events.map(async (event): Promise => { let isDirectory = false @@ -717,10 +724,22 @@ export class RuntimeFileCommands { if (this.activeRuntimeTextSearches.get(searchKey) === child) { this.activeRuntimeTextSearches.delete(searchKey) } - clearTimeout(killTimeout) + cleanupListeners() resolvePromise(finalize(acc)) } + let killTimeout: ReturnType | null = null + const cleanupListeners = (): void => { + if (killTimeout) { + clearTimeout(killTimeout) + killTimeout = null + } + child?.stdout?.off('data', onStdoutData) + child?.stderr?.off('data', onStderrData) + child?.off('error', onError) + child?.off('close', onClose) + } + const processLine = (line: string): void => { const verdict = ingestRgJsonLine( line, @@ -742,28 +761,34 @@ export class RuntimeFileCommands { this.activeRuntimeTextSearches.set(searchKey, nextChild) nextChild.stdout!.setEncoding('utf-8') - nextChild.stdout!.on('data', (chunk: string) => { + const onStdoutData = (chunk: string): void => { stdoutBuffer += chunk const lines = stdoutBuffer.split('\n') stdoutBuffer = lines.pop() ?? '' for (const line of lines) { processLine(line) } - }) - nextChild.stderr!.on('data', () => { + } + const onStderrData = (): void => { // Drain stderr so rg cannot block on a full pipe. - }) - nextChild.once('error', () => resolveOnce()) - nextChild.once('close', () => { + } + const onError = (): void => resolveOnce() + const onClose = (): void => { if (stdoutBuffer) { processLine(stdoutBuffer) } resolveOnce() - }) + } - const killTimeout = setTimeout(() => { + nextChild.stdout!.on('data', onStdoutData) + nextChild.stderr!.on('data', onStderrData) + nextChild.once('error', onError) + nextChild.once('close', onClose) + + killTimeout = setTimeout(() => { acc.truncated = true child?.kill() + resolveOnce() }, SEARCH_TIMEOUT_MS) }) } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 6867cca9e93..1bafc00d2cd 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -26,7 +26,12 @@ import { import { getBranchConflictKind, getDefaultBaseRef } from '../git/repo' import type { OrchestrationDb } from './orchestration/db' import type { MessagePriority, MessageRow, MessageType } from './orchestration/types' -import { OrcaRuntimeService } from './orca-runtime' +import { + appendNormalizedToTailBuffer, + appendRecentPtyOutput, + buildPreview, + OrcaRuntimeService +} from './orca-runtime' import { registerSshFilesystemProvider, unregisterSshFilesystemProvider @@ -69,6 +74,7 @@ const { MOCK_GIT_WORKTREES, addWorktreeMock, removeWorktreeMock, + forceDeleteLocalBranchMock, computeWorktreePathMock, ensurePathWithinWorkspaceMock, sshGitProviders, @@ -87,6 +93,7 @@ const { listGitLabMergeRequestsMock, listGitLabWorkItemsMock, listGitLabIssuesMock, + listGitLabLabelsMock, listGitLabTodosMock, getGitLabProjectRefForRemoteMock, getGitLabWorkItemByProjectRefMock, @@ -94,11 +101,16 @@ const { updateGitLabIssueMock, addGitLabIssueCommentMock, addGitLabMRCommentMock, + addGitLabMRInlineCommentMock, + resolveGitLabMRDiscussionMock, + getGitLabJobTraceMock, + retryGitLabJobMock, mergeGitLabMRMock, closeGitLabMRMock, reopenGitLabMRMock, getGlabKnownHostsMock, getGitLabWorkItemDetailsMock, + updateGitLabMRReviewersMock, getIssueMock, deleteWorktreeHistoryDirMock } = vi.hoisted(() => { @@ -118,6 +130,7 @@ const { ], addWorktreeMock: vi.fn(), removeWorktreeMock: vi.fn(), + forceDeleteLocalBranchMock: vi.fn(), computeWorktreePathMock: vi.fn(), ensurePathWithinWorkspaceMock: vi.fn(), sshGitProviders, @@ -140,6 +153,7 @@ const { listGitLabMergeRequestsMock: vi.fn(), listGitLabWorkItemsMock: vi.fn(), listGitLabIssuesMock: vi.fn(), + listGitLabLabelsMock: vi.fn(), listGitLabTodosMock: vi.fn(), getGitLabProjectRefForRemoteMock: vi.fn(), getGitLabWorkItemByProjectRefMock: vi.fn(), @@ -147,11 +161,16 @@ const { updateGitLabIssueMock: vi.fn(), addGitLabIssueCommentMock: vi.fn(), addGitLabMRCommentMock: vi.fn(), + addGitLabMRInlineCommentMock: vi.fn(), + resolveGitLabMRDiscussionMock: vi.fn(), + getGitLabJobTraceMock: vi.fn(), + retryGitLabJobMock: vi.fn(), mergeGitLabMRMock: vi.fn(), closeGitLabMRMock: vi.fn(), reopenGitLabMRMock: vi.fn(), getGlabKnownHostsMock: vi.fn(), getGitLabWorkItemDetailsMock: vi.fn(), + updateGitLabMRReviewersMock: vi.fn(), getIssueMock: vi.fn(), deleteWorktreeHistoryDirMock: vi.fn() } @@ -161,7 +180,8 @@ vi.mock('../git/worktree', () => ({ listWorktrees: vi.fn().mockResolvedValue(MOCK_GIT_WORKTREES), assertWorktreeCleanForRemoval: vi.fn().mockResolvedValue(undefined), addWorktree: addWorktreeMock, - removeWorktree: removeWorktreeMock + removeWorktree: removeWorktreeMock, + forceDeleteLocalBranch: forceDeleteLocalBranchMock })) vi.mock('../terminal-history', () => ({ @@ -200,6 +220,10 @@ vi.mock('../hooks', () => ({ createSetupRunnerScript: vi.fn(), getEffectiveHooks: vi.fn().mockReturnValue(null), getEffectiveHooksFromConfig: vi.fn().mockReturnValue(null), + getDefaultTabCommandTrustContent: vi.fn( + (hooks: { scripts?: { setup?: string } } | null) => hooks?.scripts?.setup?.trim() ?? '' + ), + getDefaultTabsLaunch: vi.fn().mockReturnValue(undefined), getSetupRunnerEnvVars: (_repo: never, worktreePath: string) => ({ ORCA_ROOT_PATH: '/remote/repo', ORCA_WORKTREE_PATH: worktreePath @@ -250,6 +274,7 @@ vi.mock('../gitlab/client', async (importOriginal) => { listMergeRequests: listGitLabMergeRequestsMock, listWorkItems: listGitLabWorkItemsMock, listIssues: listGitLabIssuesMock, + listLabels: listGitLabLabelsMock, listTodos: listGitLabTodosMock, getProjectRefForRemote: getGitLabProjectRefForRemoteMock, getWorkItemByProjectRef: getGitLabWorkItemByProjectRefMock, @@ -257,9 +282,14 @@ vi.mock('../gitlab/client', async (importOriginal) => { updateIssue: updateGitLabIssueMock, addIssueComment: addGitLabIssueCommentMock, addMRComment: addGitLabMRCommentMock, + addMRInlineComment: addGitLabMRInlineCommentMock, + resolveMRDiscussion: resolveGitLabMRDiscussionMock, + getJobTrace: getGitLabJobTraceMock, + retryJob: retryGitLabJobMock, mergeMR: mergeGitLabMRMock, closeMR: closeGitLabMRMock, - reopenMR: reopenGitLabMRMock + reopenMR: reopenGitLabMRMock, + updateMRReviewers: updateGitLabMRReviewersMock } }) @@ -314,6 +344,8 @@ afterEach(() => { vi.mocked(assertWorktreeCleanForRemoval).mockReset() vi.mocked(assertWorktreeCleanForRemoval).mockResolvedValue(undefined) vi.mocked(removeWorktree).mockReset() + vi.mocked(forceDeleteLocalBranchMock).mockReset() + vi.mocked(forceDeleteLocalBranchMock).mockResolvedValue(undefined) sshGitProviders.clear() getSshGitProviderMock.mockReset() getSshGitProviderMock.mockImplementation((connectionId: string) => @@ -381,6 +413,8 @@ afterEach(() => { listGitLabWorkItemsMock.mockResolvedValue({ items: [] }) listGitLabIssuesMock.mockReset() listGitLabIssuesMock.mockResolvedValue({ items: [] }) + listGitLabLabelsMock.mockReset() + listGitLabLabelsMock.mockResolvedValue(['bug']) listGitLabTodosMock.mockReset() listGitLabTodosMock.mockResolvedValue([]) getGitLabProjectRefForRemoteMock.mockReset() @@ -401,6 +435,14 @@ afterEach(() => { addGitLabIssueCommentMock.mockResolvedValue({ ok: true }) addGitLabMRCommentMock.mockReset() addGitLabMRCommentMock.mockResolvedValue({ ok: true }) + addGitLabMRInlineCommentMock.mockReset() + addGitLabMRInlineCommentMock.mockResolvedValue({ ok: true }) + resolveGitLabMRDiscussionMock.mockReset() + resolveGitLabMRDiscussionMock.mockResolvedValue({ ok: true }) + getGitLabJobTraceMock.mockReset() + getGitLabJobTraceMock.mockResolvedValue({ ok: true, trace: 'log' }) + retryGitLabJobMock.mockReset() + retryGitLabJobMock.mockResolvedValue({ ok: true }) mergeGitLabMRMock.mockReset() mergeGitLabMRMock.mockResolvedValue({ ok: true }) closeGitLabMRMock.mockReset() @@ -409,6 +451,8 @@ afterEach(() => { reopenGitLabMRMock.mockResolvedValue({ ok: true }) getGitLabWorkItemDetailsMock.mockReset() getGitLabWorkItemDetailsMock.mockResolvedValue({ body: 'Details' }) + updateGitLabMRReviewersMock.mockReset() + updateGitLabMRReviewersMock.mockResolvedValue({ ok: true, reviewers: [] }) getIssueMock.mockReset() getIssueMock.mockResolvedValue(null) }) @@ -480,7 +524,7 @@ function antigravityPromptBeforeModelReadyScreen(model = 'Gemini 3.5 Flash (High } // Why: these runtime feature tests only need message-queue semantics; using -// SQLite here makes them fail on unrelated better-sqlite3 native ABI drift. +// SQLite here makes them fail on unrelated native runtime ABI drift. class InMemoryOrchestrationMessages { private sequence = 0 @@ -1180,6 +1224,9 @@ describe('OrcaRuntimeService', () => { ensurePathWithinWorkspaceMock.mockReturnValue(createdWorktree.path) vi.mocked(listWorktrees).mockResolvedValueOnce([createdWorktree]) const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => { + if (args[0] === 'rev-parse' && args.includes('refs/heads/cli-fresh-base^{commit}')) { + throw new Error('branch not found') + } if (args[0] === 'remote') { return { stdout: 'origin\n', stderr: '' } } @@ -1364,6 +1411,351 @@ describe('OrcaRuntimeService', () => { } }) + it('creates a same-repo PR branch override from a resolved head SHA and matching push target', async () => { + const runtime = new OrcaRuntimeService(store) + const createdWorktree = { + path: '/tmp/workspaces/fix-title', + head: 'abc123', + branch: 'refs/heads/feature/fix', + isBare: false, + isMainWorktree: false + } + computeWorktreePathMock.mockReturnValue(createdWorktree.path) + ensurePathWithinWorkspaceMock.mockReturnValue(createdWorktree.path) + vi.mocked(getBranchConflictKind).mockResolvedValueOnce('remote') + vi.mocked(listWorktrees).mockResolvedValueOnce([createdWorktree]) + getPRForBranchMock.mockResolvedValueOnce({ + number: 42, + title: 'Selected PR', + state: 'open', + url: 'https://example.com/pr/42', + checksStatus: 'success', + updatedAt: '2026-05-21T00:00:00Z', + mergeable: 'UNKNOWN' + }) + const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockResolvedValue({ + stdout: '', + stderr: '' + }) + + try { + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'fix-title', + baseBranch: 'abc123', + branchNameOverride: 'feature/fix', + linkedPR: 42, + pushTarget: { remoteName: 'origin', branchName: 'feature/fix' } + }) + + expect(getBranchConflictKind).toHaveBeenCalledWith(TEST_REPO_PATH, 'feature/fix', 'abc123') + expect(getPRForBranchMock).toHaveBeenCalledWith(TEST_REPO_PATH, 'feature/fix') + expect(addWorktree).toHaveBeenCalledWith( + TEST_REPO_PATH, + createdWorktree.path, + 'feature/fix', + 'abc123', + false + ) + expect(gitSpy).toHaveBeenCalledWith( + ['branch', '--set-upstream-to', 'origin/feature/fix', 'feature/fix'], + { cwd: createdWorktree.path } + ) + expect(result.worktree).toMatchObject({ + path: createdWorktree.path, + branch: 'refs/heads/feature/fix' + }) + } finally { + gitSpy.mockRestore() + } + }) + + it('rejects an existing PR when a matching push target lacks selected PR metadata', async () => { + const runtime = new OrcaRuntimeService(store) + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/fix-title') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/fix-title') + vi.mocked(getBranchConflictKind).mockResolvedValueOnce(null) + getPRForBranchMock.mockResolvedValueOnce({ + number: 42, + title: 'Existing PR', + state: 'open', + url: 'https://example.com/pr/42', + checksStatus: 'success', + updatedAt: '2026-05-21T00:00:00Z', + mergeable: 'UNKNOWN' + }) + const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => { + if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/fix^{commit}')) { + throw new Error('missing local branch') + } + return { stdout: '', stderr: '' } + }) + + try { + await expect( + runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'fix-title', + baseBranch: 'abc123', + branchNameOverride: 'feature/fix', + pushTarget: { remoteName: 'origin', branchName: 'feature/fix' } + }) + ).rejects.toThrow('Branch "feature/fix" already has PR #42.') + + expect(getPRForBranchMock).toHaveBeenCalledWith(TEST_REPO_PATH, 'feature/fix') + expect(addWorktree).not.toHaveBeenCalled() + } finally { + gitSpy.mockRestore() + } + }) + + it('rejects a matching push target branch when selected PR metadata has no PR number', async () => { + const runtime = new OrcaRuntimeService(store) + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/fix-title') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/fix-title') + vi.mocked(getBranchConflictKind).mockResolvedValueOnce('remote') + const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => { + if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/fix^{commit}')) { + throw new Error('missing local branch') + } + return { stdout: '', stderr: '' } + }) + + try { + await expect( + runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'fix-title', + baseBranch: 'abc123', + branchNameOverride: 'feature/fix', + linkedPR: null, + pushTarget: { remoteName: 'origin', branchName: 'feature/fix' } + }) + ).rejects.toThrow('Branch "feature/fix" already exists on a remote.') + + expect(getPRForBranchMock).not.toHaveBeenCalled() + expect(addWorktree).not.toHaveBeenCalled() + } finally { + gitSpy.mockRestore() + } + }) + + it('rejects a matching push target branch when the existing PR is different', async () => { + const runtime = new OrcaRuntimeService(store) + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/fix-title') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/fix-title') + vi.mocked(getBranchConflictKind).mockResolvedValueOnce('remote') + getPRForBranchMock.mockResolvedValueOnce({ + number: 43, + title: 'Different PR', + state: 'open', + url: 'https://example.com/pr/43', + checksStatus: 'success', + updatedAt: '2026-05-21T00:00:00Z', + mergeable: 'UNKNOWN' + }) + const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => { + if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/fix^{commit}')) { + throw new Error('missing local branch') + } + return { stdout: '', stderr: '' } + }) + + try { + await expect( + runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'fix-title', + baseBranch: 'abc123', + branchNameOverride: 'feature/fix', + linkedPR: 42, + pushTarget: { remoteName: 'origin', branchName: 'feature/fix' } + }) + ).rejects.toThrow('Branch "feature/fix" already has PR #43.') + + expect(getPRForBranchMock).toHaveBeenCalledWith(TEST_REPO_PATH, 'feature/fix') + expect(addWorktree).not.toHaveBeenCalled() + } finally { + gitSpy.mockRestore() + } + }) + + it('rejects a selected PR remote conflict when the PR lookup fails', async () => { + const runtime = new OrcaRuntimeService(store) + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/fix-title') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/fix-title') + vi.mocked(getBranchConflictKind).mockResolvedValueOnce('remote') + getPRForBranchMock.mockRejectedValueOnce(new Error('gh unavailable')) + const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => { + if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/fix^{commit}')) { + throw new Error('missing local branch') + } + return { stdout: '', stderr: '' } + }) + + try { + await expect( + runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'fix-title', + baseBranch: 'abc123', + branchNameOverride: 'feature/fix', + linkedPR: 42, + pushTarget: { remoteName: 'origin', branchName: 'feature/fix' } + }) + ).rejects.toThrow('Could not verify selected PR branch "feature/fix". Try again.') + + expect(getPRForBranchMock).toHaveBeenCalledWith(TEST_REPO_PATH, 'feature/fix') + expect(addWorktree).not.toHaveBeenCalled() + } finally { + gitSpy.mockRestore() + } + }) + + it('checks out an unused runtime PR branch only when it is at the resolved head SHA', async () => { + const runtime = new OrcaRuntimeService(store) + const createdWorktree = { + path: '/tmp/workspaces/fix-title', + head: 'abc123', + branch: 'refs/heads/feature/fix', + isBare: false, + isMainWorktree: false + } + computeWorktreePathMock.mockReturnValue(createdWorktree.path) + ensurePathWithinWorkspaceMock.mockReturnValue(createdWorktree.path) + vi.mocked(getBranchConflictKind).mockClear() + vi.mocked(listWorktrees) + .mockResolvedValueOnce([ + { + path: TEST_REPO_PATH, + head: 'main', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + .mockResolvedValueOnce([createdWorktree]) + const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => { + if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/fix^{commit}')) { + return { stdout: 'abc123\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('abc123^{commit}')) { + return { stdout: 'abc123\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + + try { + await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'fix-title', + baseBranch: 'abc123', + branchNameOverride: 'feature/fix' + }) + + expect(getBranchConflictKind).not.toHaveBeenCalled() + expect(addWorktree).toHaveBeenCalledWith( + TEST_REPO_PATH, + createdWorktree.path, + 'feature/fix', + 'abc123', + false, + false, + { checkoutExistingBranch: true } + ) + } finally { + gitSpy.mockRestore() + } + }) + + it('suffixes only the runtime worktree path when an exact PR branch checkout path exists', async () => { + const runtime = new OrcaRuntimeService(store) + const createdWorktree = { + path: '/tmp/workspaces/fix-title-2', + head: 'abc123', + branch: 'refs/heads/feature/fix', + isBare: false, + isMainWorktree: false + } + computeWorktreePathMock.mockImplementation((sanitizedName: string) => + sanitizedName === 'fix-title' ? process.cwd() : `/tmp/workspaces/${sanitizedName}` + ) + ensurePathWithinWorkspaceMock.mockImplementation((pathValue: string) => pathValue) + vi.mocked(getBranchConflictKind).mockClear() + vi.mocked(listWorktrees) + .mockResolvedValueOnce([ + { + path: TEST_REPO_PATH, + head: 'main', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + .mockResolvedValueOnce([createdWorktree]) + const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => { + if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/fix^{commit}')) { + return { stdout: 'abc123\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('abc123^{commit}')) { + return { stdout: 'abc123\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + + try { + await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'fix-title', + baseBranch: 'abc123', + branchNameOverride: 'feature/fix' + }) + + expect(getBranchConflictKind).not.toHaveBeenCalled() + expect(addWorktree).toHaveBeenCalledWith( + TEST_REPO_PATH, + createdWorktree.path, + 'feature/fix', + 'abc123', + false, + false, + { checkoutExistingBranch: true } + ) + } finally { + gitSpy.mockRestore() + } + }) + + it('rejects when every exact PR branch checkout path suffix is occupied', async () => { + const runtime = new OrcaRuntimeService(store) + computeWorktreePathMock.mockReturnValue(process.cwd()) + ensurePathWithinWorkspaceMock.mockImplementation((pathValue: string) => pathValue) + vi.mocked(getBranchConflictKind).mockResolvedValueOnce(null) + const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => { + if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/fix^{commit}')) { + throw new Error('missing local branch') + } + return { stdout: '', stderr: '' } + }) + + try { + await expect( + runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'fix-title', + baseBranch: 'abc123', + branchNameOverride: 'feature/fix' + }) + ).rejects.toThrow( + 'Could not find an available worktree path for "fix-title". Pick a different worktree name.' + ) + + expect(addWorktree).not.toHaveBeenCalled() + } finally { + gitSpy.mockRestore() + } + }) + it('creates SSH-backed worktrees through the SSH provider for mobile/runtime callers', async () => { vi.mocked(listWorktrees).mockClear() vi.mocked(addWorktree).mockClear() @@ -1428,7 +1820,7 @@ describe('OrcaRuntimeService', () => { expect(provider.addWorktree).toHaveBeenCalledWith( '/remote/repo', 'mobile-feature', - '/remote/repo/../mobile-feature', + '/remote/mobile-feature', { base: 'origin/main' } ) expect(result.worktree).toMatchObject({ @@ -2857,6 +3249,42 @@ describe('OrcaRuntimeService', () => { }) }) + it('bounds disconnected background PTY records and their synthetic handles', async () => { + let nextPtyIndex = 0 + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockImplementation(async () => ({ id: `pty-bg-${nextPtyIndex++}` })), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + + const handles: string[] = [] + for (let index = 0; index < 140; index += 1) { + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + handles.push(handle) + runtime.onPtyExit(`pty-bg-${index}`, 0) + } + + const internals = runtime as unknown as { + ptysById: Map + handles: Map + handleByPtyId: Map + } + expect(internals.ptysById.size).toBeLessThanOrEqual(128) + expect(internals.ptysById.has('pty-bg-0')).toBe(false) + expect(internals.ptysById.has('pty-bg-139')).toBe(true) + expect(internals.handleByPtyId.has('pty-bg-0')).toBe(false) + expect(internals.handles.has(handles[0]!)).toBe(false) + + await expect(runtime.readTerminal(handles[0]!)).rejects.toThrow('terminal_handle_stale') + await expect(runtime.readTerminal(handles.at(-1)!)).resolves.toMatchObject({ + status: 'exited' + }) + }) + it('keeps retained PTY transcript memory when controller refresh omits a record', async () => { const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ @@ -3924,6 +4352,90 @@ describe('OrcaRuntimeService', () => { expect(collected.findIndex((line, index) => line !== lines[index])).toBe(-1) }) + it('trims terminal read preview character budget without per-line array shifts', async () => { + const runtime = new OrcaRuntimeService(store) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + const lines = Array.from({ length: 120 }, (_, index) => `line-${index}-${'x'.repeat(400)}`) + runtime.onPtyData('pty-1', `${lines.join('\n')}\n`, 100) + + const shiftSpy = vi.spyOn(Array.prototype, 'shift') + const preview = await runtime.readTerminal(terminal.handle) + const shiftCallCount = shiftSpy.mock.calls.length + shiftSpy.mockRestore() + + expect(preview.limited).toBe(true) + expect(preview.tail.at(-1)).toBe(lines.at(-1)) + expect(preview.tail.reduce((sum, line) => sum + line.length, 0)).toBeLessThanOrEqual(32 * 1024) + expect(shiftCallCount).toBe(0) + }) + + it('trims oversized terminal output bursts without per-line array shifts', async () => { + const shiftSpy = vi.spyOn(Array.prototype, 'shift') + const lines = Array.from({ length: 5000 }, (_, index) => `line-${index}`) + const result = appendNormalizedToTailBuffer([], '', `${lines.join('\n')}\n`) + const shiftCallCount = shiftSpy.mock.calls.length + shiftSpy.mockRestore() + + expect(result.truncated).toBe(true) + expect(result.lines).toHaveLength(2000) + expect(result.lines.slice(0, 5)).toEqual([ + 'line-3000', + 'line-3001', + 'line-3002', + 'line-3003', + 'line-3004' + ]) + expect(shiftCallCount).toBe(0) + }) + + it('trims terminal tail character budget without per-line array shifts', () => { + const shiftSpy = vi.spyOn(Array.prototype, 'shift') + const lines = Array.from({ length: 1000 }, (_, index) => `line-${index}-${'x'.repeat(300)}`) + const result = appendNormalizedToTailBuffer([], '', `${lines.join('\n')}\n`) + const shiftCallCount = shiftSpy.mock.calls.length + shiftSpy.mockRestore() + + let retainedChars = lines.reduce((sum, line) => sum + line.length, 0) + let expectedStartIndex = 0 + while (expectedStartIndex < lines.length && retainedChars > 256 * 1024) { + retainedChars -= lines[expectedStartIndex].length + expectedStartIndex += 1 + } + + expect(result.truncated).toBe(true) + expect(result.lines).toEqual(lines.slice(expectedStartIndex)) + expect(result.lines.reduce((sum, line) => sum + line.length, 0)).toBeLessThanOrEqual(256 * 1024) + expect(shiftCallCount).toBe(0) + }) + + it('builds terminal previews without mapping the full retained tail', () => { + const lines = Array.from({ length: 5000 }, (_, index) => + index % 2 === 0 ? `line-${index}` : ' ' + ) + const mapSpy = vi.spyOn(Array.prototype, 'map') + + const preview = buildPreview(lines, 'partial-tail') + const mapCallCount = mapSpy.mock.calls.length + mapSpy.mockRestore() + + expect(preview).toBe( + ['line-4990', 'line-4992', 'line-4994', 'line-4996', 'line-4998', 'partial-tail'].join('\n') + ) + expect(mapCallCount).toBe(0) + }) + + it('keeps recent PTY replay output capped without needing previous data for large chunks', () => { + const previous = 'old-output'.repeat(1000) + const data = 'new-output'.repeat(1000) + const expected = `${previous}${data}`.slice(-4096) + + expect(appendRecentPtyOutput(previous, 'tail')).toBe(`${previous}tail`.slice(-4096)) + expect(appendRecentPtyOutput(previous, data)).toBe(expected) + expect(appendRecentPtyOutput(undefined, data)).toBe(data.slice(-4096)) + }) + it('bounds retained partial terminal output before preview reads', async () => { const runtime = new OrcaRuntimeService(store) @@ -5937,6 +6449,21 @@ describe('OrcaRuntimeService', () => { await waitPromise }) + it('removes message waiter abort listeners after message arrival', async () => { + const runtime = new OrcaRuntimeService(store) + const controller = new AbortController() + const removeListenerSpy = vi.spyOn(controller.signal, 'removeEventListener') + + const waitPromise = runtime.waitForMessage('term_abc', { + timeoutMs: 5000, + signal: controller.signal + }) + runtime.notifyMessageArrived('term_abc') + await waitPromise + + expect(removeListenerSpy).toHaveBeenCalledWith('abort', expect.any(Function)) + }) + it('resolves message waiters on timeout when no message arrives', async () => { const runtime = new OrcaRuntimeService(store) @@ -5947,6 +6474,30 @@ describe('OrcaRuntimeService', () => { expect(elapsed).toBeLessThan(500) }) + it('rejects leaf PTY waits when the request signal aborts', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + const controller = new AbortController() + + const waitPromise = runtime + .waitForLeafPtyId('missing-handle', 60_000, controller.signal) + .then(() => 'resolved') + .catch((error: Error) => error.message) + + controller.abort() + const outcomePromise = Promise.race([ + waitPromise, + new Promise<'pending'>((resolve) => setTimeout(() => resolve('pending'), 0)) + ]) + await vi.advanceTimersByTimeAsync(0) + + expect(await outcomePromise).toBe('request_aborted') + } finally { + vi.useRealTimers() + } + }) + it('fails terminal waits closed when the handle goes stale during reload', async () => { const runtime = new OrcaRuntimeService(store) @@ -7533,7 +8084,13 @@ describe('OrcaRuntimeService', () => { } } }) - expect(activateWorktree).toHaveBeenCalledWith('repo-1', expect.any(String), result.setup) + expect(activateWorktree).toHaveBeenCalledWith( + 'repo-1', + expect.any(String), + result.setup, + undefined, + undefined + ) }) it('passes setup payloads through when explicitly activating CLI-created worktrees', async () => { @@ -7585,7 +8142,13 @@ describe('OrcaRuntimeService', () => { activate: true }) - expect(activateWorktree).toHaveBeenCalledWith('repo-1', expect.any(String), result.setup) + expect(activateWorktree).toHaveBeenCalledWith( + 'repo-1', + expect.any(String), + result.setup, + undefined, + undefined + ) }) it('follows normal setup policy for CLI-created worktrees without activating them', async () => { @@ -8220,7 +8783,13 @@ describe('OrcaRuntimeService', () => { expect(detectRemoteAgentsMock).not.toHaveBeenCalled() expect(spawn).not.toHaveBeenCalled() expect(metaById[result.worktree.id]?.createdWithAgent).toBeUndefined() - expect(activateWorktree).toHaveBeenCalledWith('repo-1', result.worktree.id, undefined) + expect(activateWorktree).toHaveBeenCalledWith( + 'repo-1', + result.worktree.id, + undefined, + undefined, + undefined + ) }) it('detects agents on the SSH host before launching remote startup drafts', async () => { @@ -8557,6 +9126,7 @@ describe('OrcaRuntimeService', () => { ] }) listGitLabTodosMock.mockResolvedValue([]) + listGitLabLabelsMock.mockResolvedValue(['bug', 'frontend']) getGitLabWorkItemByProjectRefMock.mockResolvedValue({ id: 'gitlab-issue-7', type: 'issue', @@ -8570,10 +9140,15 @@ describe('OrcaRuntimeService', () => { updateGitLabIssueMock.mockResolvedValue({ ok: true }) addGitLabIssueCommentMock.mockResolvedValue({ ok: true }) addGitLabMRCommentMock.mockResolvedValue({ ok: true }) + addGitLabMRInlineCommentMock.mockResolvedValue({ ok: true }) + resolveGitLabMRDiscussionMock.mockResolvedValue({ ok: true }) + getGitLabJobTraceMock.mockResolvedValue({ ok: true, trace: 'log' }) + retryGitLabJobMock.mockResolvedValue({ ok: true }) mergeGitLabMRMock.mockResolvedValue({ ok: true }) closeGitLabMRMock.mockResolvedValue({ ok: true }) reopenGitLabMRMock.mockResolvedValue({ ok: true }) getGitLabWorkItemDetailsMock.mockResolvedValue({ body: 'Details' }) + updateGitLabMRReviewersMock.mockResolvedValue({ ok: true, reviewers: [] }) const remoteRepo = { id: TEST_REPO_ID, @@ -8595,14 +9170,28 @@ describe('OrcaRuntimeService', () => { await runtime.listGitLabRepoWorkItems(TEST_REPO_ID, 'closed', 2, 25, 'ambiguous selector') const issues = await runtime.listGitLabRepoIssues(TEST_REPO_ID, 'opened', '@me', 50) await runtime.listGitLabRepoTodos(TEST_REPO_ID) + await runtime.listGitLabRepoLabels(TEST_REPO_ID) await runtime.createGitLabRepoIssue(TEST_REPO_ID, 'New issue', 'Body') await runtime.updateGitLabRepoIssue(TEST_REPO_ID, 7, { state: 'closed' }) await runtime.addGitLabRepoIssueComment(TEST_REPO_ID, 7, 'Looks good') await runtime.addGitLabRepoMRComment(TEST_REPO_ID, 8, 'Ship it') + const inlineCommentInput = { + body: 'please fix', + path: 'src/app.ts', + line: 12, + baseSha: 'base', + startSha: 'start', + headSha: 'head' + } + await runtime.addGitLabRepoMRInlineComment(TEST_REPO_ID, 8, inlineCommentInput) + await runtime.resolveGitLabRepoMRDiscussion(TEST_REPO_ID, 8, 'discussion-1', true) + await runtime.getGitLabRepoJobTrace(TEST_REPO_ID, 99) + await runtime.retryGitLabRepoJob(TEST_REPO_ID, 99) await runtime.mergeGitLabRepoMR(TEST_REPO_ID, 8, 'squash') await runtime.updateGitLabRepoMRState(TEST_REPO_ID, 8, 'closed') await runtime.updateGitLabRepoMRState(TEST_REPO_ID, 8, 'opened') await runtime.getGitLabRepoWorkItemDetails(TEST_REPO_ID, 8, 'mr') + await runtime.updateGitLabRepoMRReviewers(TEST_REPO_ID, 8, [1, 2]) await runtime.getGitLabRepoWorkItemByPath( TEST_REPO_ID, { host: 'gitlab.example.com', path: 'group/project' }, @@ -8651,6 +9240,7 @@ describe('OrcaRuntimeService', () => { } ]) expect(listGitLabTodosMock).toHaveBeenCalledWith('/remote/repo', 'ssh-1') + expect(listGitLabLabelsMock).toHaveBeenCalledWith('/remote/repo', 'origin', 'ssh-1') expect(createGitLabIssueMock).toHaveBeenCalledWith( '/remote/repo', 'New issue', @@ -8682,6 +9272,37 @@ describe('OrcaRuntimeService', () => { 'ssh-1', undefined ) + expect(addGitLabMRInlineCommentMock).toHaveBeenCalledWith( + '/remote/repo', + 8, + inlineCommentInput, + 'origin', + 'ssh-1', + undefined + ) + expect(resolveGitLabMRDiscussionMock).toHaveBeenCalledWith( + '/remote/repo', + 8, + 'discussion-1', + true, + 'origin', + 'ssh-1', + undefined + ) + expect(getGitLabJobTraceMock).toHaveBeenCalledWith( + '/remote/repo', + 99, + 'origin', + 'ssh-1', + undefined + ) + expect(retryGitLabJobMock).toHaveBeenCalledWith( + '/remote/repo', + 99, + 'origin', + 'ssh-1', + undefined + ) expect(mergeGitLabMRMock).toHaveBeenCalledWith( '/remote/repo', 8, @@ -8700,6 +9321,14 @@ describe('OrcaRuntimeService', () => { 'ssh-1', undefined ) + expect(updateGitLabMRReviewersMock).toHaveBeenCalledWith( + '/remote/repo', + 8, + [1, 2], + 'origin', + 'ssh-1', + undefined + ) expect(getGitLabWorkItemByProjectRefMock).toHaveBeenCalledWith( '/remote/repo', { host: 'gitlab.example.com', path: 'group/project' }, @@ -9108,7 +9737,13 @@ describe('OrcaRuntimeService', () => { activate: true }) - expect(activateWorktree).toHaveBeenCalledWith('repo-1', expect.any(String), undefined) + expect(activateWorktree).toHaveBeenCalledWith( + 'repo-1', + expect.any(String), + undefined, + undefined, + undefined + ) }) it('stamps createdAt alongside lastActivityAt so CLI-created worktrees get the Recent-sort grace window', async () => { @@ -9166,7 +9801,7 @@ describe('OrcaRuntimeService', () => { archive: 'pnpm worktree:archive' } }) - vi.mocked(removeWorktree).mockResolvedValue(undefined) + vi.mocked(removeWorktree).mockResolvedValue({}) const result = await runtime.removeManagedWorktree(TEST_WORKTREE_ID) @@ -9178,6 +9813,41 @@ describe('OrcaRuntimeService', () => { ) }) + it('force-deletes a branch that was preserved by runtime worktree removal', async () => { + const runtime = new OrcaRuntimeService(store) + vi.mocked(removeWorktree).mockResolvedValue({ + preservedBranch: { branchName: 'feature/test', head: 'def456' } + }) + + await runtime.removeManagedWorktree(TEST_WORKTREE_ID) + const result = await runtime.forceDeletePreservedBranch( + TEST_WORKTREE_ID, + 'feature/test', + 'def456' + ) + + expect(result).toEqual({ deleted: true }) + expect(forceDeleteLocalBranchMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 'feature/test', + 'def456' + ) + }) + + it('rejects stale preserved-branch runtime cleanup actions with an old head', async () => { + const runtime = new OrcaRuntimeService(store) + vi.mocked(removeWorktree).mockResolvedValue({ + preservedBranch: { branchName: 'feature/test', head: 'new456' } + }) + + await runtime.removeManagedWorktree(TEST_WORKTREE_ID) + + await expect( + runtime.forceDeletePreservedBranch(TEST_WORKTREE_ID, 'feature/test', 'old123') + ).rejects.toThrow('No preserved branch cleanup is pending') + expect(forceDeleteLocalBranchMock).not.toHaveBeenCalled() + }) + it('coalesces concurrent runtime worktree removals for the same worktree id', async () => { const runtime = new OrcaRuntimeService(store) const removeStarted = deferred() @@ -9185,6 +9855,7 @@ describe('OrcaRuntimeService', () => { vi.mocked(removeWorktree).mockImplementation(async () => { removeStarted.resolve() await finishRemoval.promise + return {} }) const first = runtime.removeManagedWorktree(TEST_WORKTREE_ID, true) @@ -9205,6 +9876,7 @@ describe('OrcaRuntimeService', () => { vi.mocked(removeWorktree).mockImplementation(async () => { removeStarted.resolve() await finishRemoval.promise + return {} }) const first = runtime.removeManagedWorktree(TEST_WORKTREE_ID) @@ -9494,7 +10166,7 @@ describe('OrcaRuntimeService', () => { } }) vi.mocked(runHook).mockResolvedValue({ success: true, output: '' }) - vi.mocked(removeWorktree).mockResolvedValue(undefined) + vi.mocked(removeWorktree).mockResolvedValue({}) await runtime.removeManagedWorktree(TEST_WORKTREE_ID, false, true) @@ -9523,7 +10195,7 @@ describe('OrcaRuntimeService', () => { terminalFitOverrideChanged: vi.fn(), terminalDriverChanged: vi.fn() }) - vi.mocked(removeWorktree).mockResolvedValue(undefined) + vi.mocked(removeWorktree).mockResolvedValue({}) const token = runtime.recordOptimisticReconcileToken(TEST_WORKTREE_ID) await runtime.removeManagedWorktree(TEST_WORKTREE_ID) @@ -10086,6 +10758,7 @@ describe('OrcaRuntimeService', () => { }) vi.mocked(removeWorktree).mockImplementation(async () => { callOrder.push('git-removeWorktree') + return {} }) const runtime = new OrcaRuntimeService(store, undefined, { @@ -10134,7 +10807,7 @@ describe('OrcaRuntimeService', () => { const runtime = new OrcaRuntimeService(store, undefined, { getLocalProvider: () => currentProvider as never }) - vi.mocked(removeWorktree).mockResolvedValue(undefined) + vi.mocked(removeWorktree).mockResolvedValue({}) // Simulate daemon-init swapping the provider after construction. currentProvider = postDaemonProvider diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 6b38055a34e..df603796237 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -15,7 +15,6 @@ import { deriveValidatedClonePath, getClonePathComparisonKey } from '../git/repo-clone-path' -import { isWslPath, parseWslPath, getWslHome } from '../wsl' import { createHash, randomUUID } from 'crypto' import { isAbsolute, join } from 'path' import { mkdir, readFile, readdir, rm, stat } from 'fs/promises' @@ -33,12 +32,16 @@ import type { CreateWorktreeResult, DetectedWorktree, DetectedWorktreeListResult, + ForceDeleteWorktreeBranchResult, + GitHubPrStartPoint, GitPushTarget, GitWorktreeInfo, + GitHubCreateIssueFields, GitHubOwnerRepo, GlobalSettings, PersistedUIState, Repo, + RemoveWorktreeResult, StatsSummary, Worktree, WorktreeLineage, @@ -47,6 +50,7 @@ import type { WorktreeBaseStatusEvent, WorktreeRemoteBranchConflictEvent, WorktreeStartupLaunch, + LinearCustomViewModel, LinearIssueUpdate, LinearWorkspaceSelection, NestedRepoScanResult, @@ -54,7 +58,8 @@ import type { ProjectGroupImportMode, ProjectGroupImportResult, TabGroupLayoutNode, - TuiAgent + TuiAgent, + WorkspaceCreateTelemetrySource } from '../../shared/types' import type { FeatureInteractionId } from '../../shared/feature-interactions' import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, splitWorktreeId } from '../../shared/worktree-id' @@ -74,8 +79,8 @@ import { markCopilotFolderTrusted, markCursorWorkspaceTrusted } from '../agent-trust-presets' +import { markRemoteAgentWorkspaceTrusted } from '../remote-agent-trust-presets' import { applyAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' -import { upsertProjectTrustLevelInContent } from '../codex/config-toml-trust' import { isWindowsAbsolutePathLike, isPathInsideOrEqual, @@ -188,17 +193,25 @@ import { getRateLimit } from '../github/rate-limit' import { closeMR as closeGitLabMR, createIssue as createGitLabIssue, + diagnoseAuth as diagnoseGitLabAuthClient, + getJobTrace as getGitLabJobTrace, getProjectRefForRemote as getGitLabProjectRefForRemote, + getRateLimit as getGitLabRateLimit, getWorkItemByProjectRef as getGitLabWorkItemByProjectRef, addIssueComment as addGitLabIssueComment, + addMRInlineComment as addGitLabMRInlineComment, addMRComment as addGitLabMRComment, listTodos as listGitLabTodos, listIssues as listGitLabIssues, + listLabels as listGitLabLabels, listMergeRequests as listGitLabMergeRequests, listWorkItems as listGitLabWorkItems, mergeMR as mergeGitLabMR, reopenMR as reopenGitLabMR, + resolveMRDiscussion as resolveGitLabMRDiscussion, + retryJob as retryGitLabJob, updateMR as updateGitLabMR, + updateMRReviewers as updateGitLabMRReviewers, updateIssue as updateGitLabIssue } from '../gitlab/client' import { getGlabKnownHosts } from '../gitlab/gl-utils' @@ -216,6 +229,7 @@ import type { GitHubPRFile, GitHubPRReviewCommentInput, GitLabIssueUpdate, + GitLabMRInlineCommentInput, GitLabProjectRef, GitLabWorkItem, MRListState @@ -250,7 +264,15 @@ import { updateIssue as updateLinearIssue, type LinearListFilter } from '../linear/issues' -import { listProjects as listLinearProjects } from '../linear/projects' +import { + getCustomView as getLinearCustomView, + getProject as getLinearProject, + listCustomViewIssues as listLinearCustomViewIssues, + listCustomViewProjects as listLinearCustomViewProjects, + listCustomViews as listLinearCustomViews, + listProjectIssues as listLinearProjectIssues, + listProjects as listLinearProjects +} from '../linear/projects' import { getTeamLabels as getLinearTeamLabels, getTeamMembers as getLinearTeamMembers, @@ -316,12 +338,15 @@ import { addWorktree, addSparseWorktree, assertWorktreeCleanForRemoval, + forceDeleteLocalBranch, removeWorktree } from '../git/worktree' import type { AddWorktreeResult } from '../git/worktree' import { isENOENT } from '../ipc/filesystem-auth' import { createSetupRunnerScript, + getDefaultTabCommandTrustContent, + getDefaultTabsLaunch, getEffectiveHooks, getEffectiveSetupRunPolicy, hasUnrecognizedOrcaYamlKeys, @@ -351,8 +376,11 @@ import { AgentDetector } from '../stats/agent-detector' import { computeBranchName, computeWorktreePath, + computeWorkspaceRoot, ensurePathWithinWorkspace, formatWorktreeRemovalError, + getWorktreeCreationLayout, + getWorktreePathSettings, isOrphanCompatiblePreflightError, isOrphanedWorktreeError, mergeWorktree, @@ -376,7 +404,6 @@ import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits' import type { IFilesystemProvider, IPtyProvider } from '../providers/types' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { getSshGitProvider, requireSshGitProvider } from '../providers/ssh-git-dispatch' -import { getActiveMultiplexer } from '../ipc/ssh' import { detectRepoIcon } from '../repo-icon-autodetect' import type { ClaudeAccountService } from '../claude-accounts/service' import type { CodexAccountService } from '../codex-accounts/service' @@ -588,6 +615,7 @@ type RuntimePtyWorktreeRecord = { tabId: string | null paneKey: string | null connected: boolean + disconnectedAt: number | null lastExitCode: number | null lastAgentStatus: AgentStatus | null lastOscTitle: string | null @@ -659,7 +687,8 @@ type RuntimeNotifier = { repoId: string, worktreeId: string, setup?: CreateWorktreeResult['setup'], - startup?: WorktreeStartupLaunch + startup?: WorktreeStartupLaunch, + defaultTabs?: CreateWorktreeResult['defaultTabs'] ): void createTerminal(worktreeId: string, opts: { command?: string; title?: string }): void revealTerminalSession?( @@ -740,6 +769,7 @@ type MessageWaiter = { typeFilter: string[] | undefined resolve: (result: void) => void timeout: NodeJS.Timeout | null + abortCleanup: (() => void) | null } function omitUndefinedProperties>(value: T): Partial { @@ -769,7 +799,13 @@ type RuntimeWorktreeRemovalTarget = { type RuntimeWorktreeRemovalInFlight = { optionsKey: string - promise: Promise<{ warning?: string }> + promise: Promise +} + +type PreservedBranchCleanupTarget = { + branchName: string + head: string + pushTarget?: GitPushTarget } function getRuntimeWorktreeRemovalOptionsKey(force: boolean, runHooks: boolean): string { @@ -901,23 +937,87 @@ async function canCheckoutExistingLocalBranch( branchName: string, baseBranch: string ): Promise { - if (normalizeLocalBranchName(baseBranch) !== branchName) { - return false - } + let localHead = '' try { - await gitExecFileAsync( + const { stdout } = await gitExecFileAsync( ['rev-parse', '--verify', '--quiet', `refs/heads/${branchName}^{commit}`], { cwd: repoPath } ) + localHead = stdout.trim() } catch { return false } + if (normalizeLocalBranchName(baseBranch) !== branchName) { + if (!localHead) { + return false + } + try { + const { stdout } = await gitExecFileAsync( + ['rev-parse', '--verify', '--quiet', `${baseBranch}^{commit}`], + { cwd: repoPath } + ) + if (stdout.trim() !== localHead) { + return false + } + } catch { + return false + } + } const worktrees = await listWorktrees(repoPath) return !worktrees.some((worktree) => normalizeLocalBranchName(worktree.branch) === branchName) } +type SelectedPrBranchInput = { + branchNameOverride?: string + linkedPR?: number | null + pushTarget?: GitPushTarget +} + +function isSelectedGitHubPrBranchOverride( + args: SelectedPrBranchInput, + branchName: string +): boolean { + return typeof args.linkedPR === 'number' && args.branchNameOverride === branchName +} + +function isMatchingSelectedGitHubPr( + existingPR: Awaited>, + args: SelectedPrBranchInput, + branchName: string +): boolean { + return Boolean( + existingPR && + isSelectedGitHubPrBranchOverride(args, branchName) && + existingPR.number === args.linkedPR + ) +} + +function isAllowedPushTargetRemoteConflict( + conflictKind: 'local' | 'remote' | null, + branchName: string, + args: SelectedPrBranchInput +): boolean { + return ( + conflictKind === 'remote' && + isSelectedGitHubPrBranchOverride(args, branchName) && + args.pushTarget?.branchName === branchName + ) +} + +async function pathExists(pathValue: string): Promise { + try { + await stat(pathValue) + return true + } catch (error) { + if (isENOENT(error)) { + return false + } + throw error + } +} + type ResolvedWorktree = Worktree & { parentWorktreeId: string | null childWorktreeIds: string[] @@ -1309,6 +1409,7 @@ export class OrcaRuntimeService { private canonicalFetchKeyCache = new Map() private optimisticReconcileTokens = new Map() private removeManagedWorktreeInFlight = new Map() + private preservedBranchCleanupByWorktreeId = new Map() private readonly getLocalProviderFn: (() => IPtyProvider) | null private accountServices: RuntimeAccountServices | null = null private commitMessageAgentEnv: CommitMessageAgentEnvironmentResolvers | null = null @@ -1476,6 +1577,7 @@ export class OrcaRuntimeService { return this.store.createAutomation({ name: input.name, prompt: input.prompt, + precheck: input.precheck, agentId: input.agentId, projectId: target.projectId, workspaceMode: target.workspaceMode, @@ -1502,6 +1604,9 @@ export class OrcaRuntimeService { if (hasRuntimeAutomationUpdateValue(updates, 'prompt')) { patch.prompt = updates.prompt } + if (hasRuntimeAutomationUpdateValue(updates, 'precheck')) { + patch.precheck = updates.precheck + } if (hasRuntimeAutomationUpdateValue(updates, 'agentId')) { patch.agentId = updates.agentId } @@ -2228,6 +2333,7 @@ export class OrcaRuntimeService { const pty = this.getOrCreatePtyWorktreeRecord(ptyId) if (pty) { pty.connected = true + pty.disconnectedAt = null } for (const leaf of this.getLeavesForPty(ptyId)) { leaf.connected = true @@ -2245,7 +2351,7 @@ export class OrcaRuntimeService { this.ptyOutputSequenceById.set(ptyId, outputSequence) this.recentPtyOutputById.set( ptyId, - `${this.recentPtyOutputById.get(ptyId) ?? ''}${data}`.slice(-RECENT_PTY_OUTPUT_LIMIT) + appendRecentPtyOutput(this.recentPtyOutputById.get(ptyId), data) ) // Agent detection runs on raw data before leaf processing, since the // tail buffer logic normalizes away the OSC sequences we need. @@ -2288,6 +2394,7 @@ export class OrcaRuntimeService { let ptyTailAfter: ReturnType | null = null if (pty) { pty.connected = true + pty.disconnectedAt = null pty.lastOutputAt = at const nextTail = appendNormalizedToTailBuffer( pty.tailBuffer, @@ -3461,6 +3568,7 @@ export class OrcaRuntimeService { const pty = this.ptysById.get(ptyId) if (pty) { pty.connected = false + pty.disconnectedAt = Date.now() pty.lastExitCode = exitCode this.resolvePtyExitWaiters(pty, ptyId) this.pruneDisconnectedPtyTranscript(pty) @@ -3474,6 +3582,7 @@ export class OrcaRuntimeService { this.resolveExitWaiters(leaf) this.failActiveDispatchOnExit(leaf, exitCode) } + this.pruneDisconnectedPtyRecords() } // ─── Driver state (mobile-presence lock) ────────────────────────── @@ -4278,6 +4387,9 @@ export class OrcaRuntimeService { this.setDriver(ptyId, { kind: 'idle' }) } }, SOFT_LEAVE_GRACE_MS) + if (typeof softTimer.unref === 'function') { + softTimer.unref() + } this.pendingSoftLeavers.set(ptyId, { clientId, timer: softTimer, @@ -4331,6 +4443,11 @@ export class OrcaRuntimeService { rows: restoreRows }) }, autoRestoreMs) + // Why: a delayed mobile restore should not keep Electron main alive + // after the last window/runtime transport has otherwise shut down. + if (typeof timer.unref === 'function') { + timer.unref() + } this.pendingRestoreTimers.set(ptyId, { timer, clientId }) } @@ -5666,6 +5783,7 @@ export class OrcaRuntimeService { | 'repoIcon' | 'hookSettings' | 'worktreeBaseRef' + | 'worktreeBasePath' | 'kind' | 'symlinkPaths' | 'issueSourcePreference' @@ -5681,10 +5799,17 @@ export class OrcaRuntimeService { throw new Error('runtime_unavailable') } const repo = await this.resolveRepoSelector(repoSelector) - const updated = this.store.updateRepo(repo.id, omitUndefinedProperties(updates)) + const sanitizedUpdates = omitUndefinedProperties(updates) + if ('worktreeBasePath' in updates && updates.worktreeBasePath === undefined) { + sanitizedUpdates.worktreeBasePath = undefined + } + const updated = this.store.updateRepo(repo.id, sanitizedUpdates) if (!updated) { throw new Error('repo_not_found') } + if ('worktreeBasePath' in updates) { + invalidateAuthorizedRootsCache() + } this.invalidateResolvedWorktreeCache() this.notifier?.reposChanged() return updated @@ -6135,6 +6260,24 @@ export class OrcaRuntimeService { return listGitLabTodos(repo.path, repo.connectionId ?? null) } + async diagnoseGitLabAuth(): Promise>> { + return diagnoseGitLabAuthClient() + } + + async getGitLabRateLimit(options?: { + force?: boolean + host?: string | null + }): Promise>> { + return getGitLabRateLimit(options) + } + + async listGitLabRepoLabels( + repoSelector: string + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return listGitLabLabels(repo.path, repo.issueSourcePreference, repo.connectionId ?? null) + } + async createGitLabRepoIssue( repoSelector: string, title: string, @@ -6201,6 +6344,72 @@ export class OrcaRuntimeService { ) } + async addGitLabRepoMRInlineComment( + repoSelector: string, + iid: number, + input: GitLabMRInlineCommentInput, + projectRef?: GitLabProjectRef | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return addGitLabMRInlineComment( + repo.path, + iid, + input, + repo.issueSourcePreference, + repo.connectionId ?? null, + projectRef + ) + } + + async resolveGitLabRepoMRDiscussion( + repoSelector: string, + iid: number, + discussionId: string, + resolved: boolean, + projectRef?: GitLabProjectRef | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return resolveGitLabMRDiscussion( + repo.path, + iid, + discussionId, + resolved, + repo.issueSourcePreference, + repo.connectionId ?? null, + projectRef + ) + } + + async getGitLabRepoJobTrace( + repoSelector: string, + jobId: number, + projectRef?: GitLabProjectRef | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return getGitLabJobTrace( + repo.path, + jobId, + repo.issueSourcePreference, + repo.connectionId ?? null, + projectRef + ) + } + + async retryGitLabRepoJob( + repoSelector: string, + jobId: number, + projectRef?: GitLabProjectRef | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return retryGitLabJob( + repo.path, + jobId, + repo.issueSourcePreference, + repo.connectionId ?? null, + projectRef + ) + } + async mergeGitLabRepoMR( repoSelector: string, iid: number, @@ -6259,6 +6468,23 @@ export class OrcaRuntimeService { ) } + async updateGitLabRepoMRReviewers( + repoSelector: string, + iid: number, + reviewerIds: number[], + projectRef?: GitLabProjectRef | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return updateGitLabMRReviewers( + repo.path, + iid, + reviewerIds, + repo.issueSourcePreference, + repo.connectionId ?? null, + projectRef + ) + } + async getGitLabRepoWorkItemDetails( repoSelector: string, iid: number, @@ -6352,8 +6578,11 @@ export class OrcaRuntimeService { } ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) - this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr_check_details') - return getPRCheckDetails(repo.path, { ...args, prRepo: args.prRepo ?? null }) + return getPRCheckDetails( + repo.path, + { ...args, prRepo: args.prRepo ?? null }, + repo.connectionId ?? null + ) } async getRepoPRComments( @@ -6486,7 +6715,8 @@ export class OrcaRuntimeService { async createRepoIssue( repoSelector: string, title: string, - body: string + body: string, + fields?: GitHubCreateIssueFields ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return createIssue( @@ -6494,7 +6724,8 @@ export class OrcaRuntimeService { title, body, repo.issueSourcePreference, - repo.connectionId ?? null + repo.connectionId ?? null, + fields ) } @@ -6510,10 +6741,11 @@ export class OrcaRuntimeService { async addRepoIssueComment( repoSelector: string, number: number, - body: string + body: string, + prRepo?: GitHubOwnerRepo | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) - return addIssueComment(repo.path, number, body, repo.connectionId ?? null) + return addIssueComment(repo.path, number, body, repo.connectionId ?? null, prRepo ?? null) } async addRepoPRReviewComment( @@ -6537,6 +6769,7 @@ export class OrcaRuntimeService { threadId?: string path?: string line?: number + prRepo?: GitHubOwnerRepo | null } ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) @@ -6548,7 +6781,8 @@ export class OrcaRuntimeService { args.threadId, args.path, args.line, - repo.connectionId ?? null + repo.connectionId ?? null, + args.prRepo ?? null ) } @@ -6648,9 +6882,9 @@ export class OrcaRuntimeService { private getSetupHookTrustPayload( repo: Repo, - setupScript: string | undefined + scriptContentValue: string | undefined ): { contentHash: string; scriptContent: string } | undefined { - const scriptContent = setupScript?.trim() + const scriptContent = scriptContentValue?.trim() if (!scriptContent || repo.hookSettings?.commandSourcePolicy === 'local-only') { return undefined } @@ -6690,7 +6924,10 @@ export class OrcaRuntimeService { hooks, setupRunPolicy: getEffectiveSetupRunPolicy(repo), source: hooks ? 'orca.yaml' : null, - setupTrust: this.getSharedSetupHookTrustPayload(repo, hooks?.scripts?.setup) + setupTrust: this.getSharedSetupHookTrustPayload( + repo, + getDefaultTabCommandTrustContent(hooks) + ) } } catch { return { @@ -6710,7 +6947,10 @@ export class OrcaRuntimeService { hooks, setupRunPolicy, source: hasFile ? 'orca.yaml' : hooks ? 'legacy' : null, - setupTrust: this.getSharedSetupHookTrustPayload(repo, sharedHooks?.scripts?.setup) + setupTrust: this.getSharedSetupHookTrustPayload( + repo, + getDefaultTabCommandTrustContent(sharedHooks) + ) } } @@ -6730,9 +6970,7 @@ export class OrcaRuntimeService { if (result.isBinary) { return { hasHooks: false, hooks: null, mayNeedUpdate: false } } - const { parse } = await import('yaml') - const parsed = parse(result.content) - return { hasHooks: true, hooks: parsed, mayNeedUpdate: false } + return { hasHooks: true, hooks: parseOrcaYaml(result.content), mayNeedUpdate: false } } catch { return { hasHooks: false, hooks: null, mayNeedUpdate: false } } @@ -6991,7 +7229,7 @@ export class OrcaRuntimeService { worktree, meta: this.store?.getWorktreeMeta(worktree.id), settings, - knownOrcaLayouts: repo.connectionId ? [] : buildKnownOrcaWorkspaceLayouts(settings, repo), + knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo), isLegacyRepoForVisibility: isLegacyRepoForExternalWorktreeVisibility(repo) }) } @@ -7165,134 +7403,12 @@ export class OrcaRuntimeService { return } try { - const home = await this.resolveRemoteHome(connectionId) - const fsProvider = getSshFilesystemProvider(connectionId) - if (!home || !fsProvider) { - return - } - const absPath = await this.canonicalizeRemoteWorkspacePath(fsProvider, workspacePath) - if (preset === 'codex') { - await this.markRemoteCodexProjectTrusted(fsProvider, home, absPath) - } else if (preset === 'cursor') { - await this.markRemoteCursorWorkspaceTrusted(fsProvider, home, absPath) - } else if (preset === 'copilot') { - await this.markRemoteCopilotFolderTrusted(fsProvider, home, absPath) - } + await markRemoteAgentWorkspaceTrusted({ preset, connectionId, workspacePath }) } catch { // Best-effort: the user can still accept the remote agent trust prompt manually. } } - private async resolveRemoteHome(connectionId: string): Promise { - const mux = getActiveMultiplexer(connectionId) - if (!mux || mux.isDisposed?.()) { - return null - } - const result = (await mux.request('session.resolveHome', { path: '~' })) as { - resolvedPath?: unknown - } - const home = typeof result.resolvedPath === 'string' ? result.resolvedPath.trim() : '' - return home && home.startsWith('/') && !/[\u0000\r\n]/.test(home) - ? home.replace(/\/$/, '') - : null - } - - private async canonicalizeRemoteWorkspacePath( - fsProvider: IFilesystemProvider, - workspacePath: string - ): Promise { - try { - return await fsProvider.realpath(workspacePath) - } catch { - return workspacePath - } - } - - private async readRemoteTextFile( - fsProvider: IFilesystemProvider, - filePath: string - ): Promise { - try { - const result = await fsProvider.readFile(filePath) - return result.isBinary ? '' : result.content - } catch { - return '' - } - } - - private async markRemoteCodexProjectTrusted( - fsProvider: IFilesystemProvider, - remoteHome: string, - workspacePath: string - ): Promise { - const codexDir = `${remoteHome}/.codex` - const configPath = `${codexDir}/config.toml` - const existing = await this.readRemoteTextFile(fsProvider, configPath) - const updated = upsertProjectTrustLevelInContent(existing, workspacePath, 'trusted') - if (updated === existing) { - return - } - await fsProvider.createDir(codexDir) - await fsProvider.writeFile(configPath, updated) - } - - private async markRemoteCursorWorkspaceTrusted( - fsProvider: IFilesystemProvider, - remoteHome: string, - workspacePath: string - ): Promise { - const slug = workspacePath.replace(/^[\\/]+/, '').replace(/[\\/]+/g, '-') - if (!slug) { - return - } - const trustDir = `${remoteHome}/.cursor/projects/${slug}` - const trustFile = `${trustDir}/.workspace-trusted` - try { - await fsProvider.stat(trustFile) - return - } catch { - // Missing marker: write the same shape the local trust preset writes. - } - await fsProvider.createDir(trustDir) - await fsProvider.writeFile( - trustFile, - `${JSON.stringify({ trustedAt: new Date().toISOString(), workspacePath }, null, 2)}\n` - ) - } - - private async markRemoteCopilotFolderTrusted( - fsProvider: IFilesystemProvider, - remoteHome: string, - workspacePath: string - ): Promise { - const configDir = `${remoteHome}/.copilot` - const configPath = `${configDir}/config.json` - const raw = await this.readRemoteTextFile(fsProvider, configPath) - let config: Record = {} - if (raw.trim()) { - try { - const parsed = JSON.parse(raw) - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - config = parsed as Record - } - } catch { - return - } - } - const existing = Array.isArray(config.trustedFolders) - ? (config.trustedFolders as unknown[]) - : [] - if (existing.includes(workspacePath)) { - return - } - config.trustedFolders = [ - ...existing.filter((entry) => typeof entry === 'string'), - workspacePath - ] - await fsProvider.createDir(configDir) - await fsProvider.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`) - } - private pasteStartupDraftWhenReady(handle: string, draft: WorktreeStartupDraftPaste): void { void this.waitForStartupDraftReady(handle, draft.agent) .then((ptyId) => { @@ -7405,6 +7521,7 @@ export class OrcaRuntimeService { linkedGitLabIssue?: number | null comment?: string displayName?: string + telemetrySource?: WorkspaceCreateTelemetrySource workspaceStatus?: string manualOrder?: number sparseCheckout?: { directories: string[]; presetId?: string } @@ -7548,9 +7665,11 @@ export class OrcaRuntimeService { args.lineage || args.comment ? { ...args.lineage, comment: args.comment } : undefined const lineageResolution = await this.resolveLineageForWorktreeCreate(lineageInput) const settings = createSettings - const requestedName = args.name + const worktreePathSettings = getWorktreePathSettings(repo, settings) + let effectiveRequestedName = args.name const requestedDisplayName = args.displayName?.trim() || undefined const sanitizedName = sanitizeWorktreeName(args.name) + let effectiveSanitizedName = sanitizedName const username = getGitUsername(repo.path) const branchName = await resolveCreateBranchName( repo.path, @@ -7576,10 +7695,12 @@ export class OrcaRuntimeService { branchName, baseBranch ) - const branchConflictKind = checkoutExistingBranch + let branchConflictKind = checkoutExistingBranch ? null : await getBranchConflictKind(repo.path, branchName, baseBranch) - if (branchConflictKind) { + const allowedPushTargetRemoteConflict = + branchConflictKind && isAllowedPushTargetRemoteConflict(branchConflictKind, branchName, args) + if (branchConflictKind && !allowedPushTargetRemoteConflict) { throw new Error( `Branch "${branchName}" already exists ${branchConflictKind === 'local' ? 'locally' : 'on a remote'}.` ) @@ -7590,23 +7711,55 @@ export class OrcaRuntimeService { try { existingPR = await getPRForBranch(repo.path, branchName) } catch { + if (allowedPushTargetRemoteConflict) { + throw new Error(`Could not verify selected PR branch "${branchName}". Try again.`) + } // Why: worktree creation should not hard-fail on transient GitHub reachability // issues because git state is still the source of truth for whether the // worktree can be created locally. } - if (existingPR) { + if ( + allowedPushTargetRemoteConflict && + !isMatchingSelectedGitHubPr(existingPR, args, branchName) + ) { + if (existingPR) { + throw new Error(`Branch "${branchName}" already has PR #${existingPR.number}.`) + } + throw new Error(`Branch "${branchName}" already exists on a remote.`) + } + if (existingPR && !isMatchingSelectedGitHubPr(existingPR, args, branchName)) { throw new Error(`Branch "${branchName}" already has PR #${existingPR.number}.`) } } - let worktreePath = computeWorktreePath(sanitizedName, repo.path, settings) + const workspaceRoot = computeWorkspaceRoot(repo.path, worktreePathSettings) // Why: CLI-managed WSL worktrees live under ~/orca/workspaces inside the - // distro filesystem. If home lookup fails, still validate against the - // configured workspace dir so the traversal guard is never bypassed. - const wslInfo = isWslPath(repo.path) ? parseWslPath(repo.path) : null - const wslHome = wslInfo ? getWslHome(wslInfo.distro) : null - const workspaceRoot = wslHome ? join(wslHome, 'orca', 'workspaces') : settings.workspaceDir - worktreePath = ensurePathWithinWorkspace(worktreePath, workspaceRoot) + // distro filesystem through computeWorkspaceRoot. If home lookup fails, + // still validate against the effective workspace dir. + let worktreePath = '' + let worktreePathResolved = !args.branchNameOverride + for (let suffix = 1; suffix < 100; suffix += 1) { + effectiveSanitizedName = suffix === 1 ? sanitizedName : `${sanitizedName}-${suffix}` + effectiveRequestedName = + suffix === 1 + ? args.name + : args.name.trim() + ? `${args.name}-${suffix}` + : effectiveSanitizedName + worktreePath = ensurePathWithinWorkspace( + computeWorktreePath(effectiveSanitizedName, repo.path, worktreePathSettings), + workspaceRoot + ) + if (!args.branchNameOverride || !(await pathExists(worktreePath))) { + worktreePathResolved = true + break + } + } + if (!worktreePathResolved) { + throw new Error( + `Could not find an available worktree path for "${sanitizedName}". Pick a different worktree name.` + ) + } const remoteTrackingBase = await this.resolveRemoteTrackingBase(repo.path, baseBranch) if (remoteTrackingBase) { const hadLocalBaseRef = await this.hasRemoteTrackingRef(repo.path, remoteTrackingBase) @@ -7714,8 +7867,8 @@ export class OrcaRuntimeService { const now = Date.now() const displayNameMeta = requestedDisplayName ? { displayName: requestedDisplayName } - : shouldSetDisplayName(requestedName, branchName, sanitizedName) - ? { displayName: requestedName } + : shouldSetDisplayName(effectiveRequestedName, branchName, effectiveSanitizedName) + ? { displayName: effectiveRequestedName } : {} const meta = this.store.setWorktreeMeta(worktreeId, { // Why: worktree IDs are path-derived. If a path is deleted outside Orca @@ -7730,10 +7883,7 @@ export class OrcaRuntimeService { createdAt: now, orcaCreatedAt: now, orcaCreationSource: 'runtime', - orcaCreationWorkspaceLayout: { - path: settings.workspaceDir, - nestWorkspaces: settings.nestWorkspaces - }, + orcaCreationWorkspaceLayout: getWorktreeCreationLayout(repo, settings), ...displayNameMeta, baseRef: baseBranch, ...(checkoutExistingBranch ? { preserveBranchOnDelete: true } : {}), @@ -7812,12 +7962,22 @@ export class OrcaRuntimeService { // Why: CLI-created worktrees do not have a renderer preview to mismatch // against. Trust is granted by the direct CLI invocation (`--run-hooks`), // so loading the setup hook from the created worktree is intentional here. + const yamlHooks = loadHooks(worktreePath) const hooks = getEffectiveHooks(repo, worktreePath) // Why: setupDecision lets mobile/CLI callers control whether the setup // script runs. 'skip' suppresses it, 'run' forces it, 'inherit' (default) // defers to the repo's orca.yaml setupRunPolicy. runHooks === true maps // to 'run' for backwards compatibility with the desktop create flow. const effectiveDecision = args.runHooks ? 'run' : (args.setupDecision ?? 'inherit') + let defaultTabs: CreateWorktreeResult['defaultTabs'] + try { + defaultTabs = getDefaultTabsLaunch(yamlHooks, repo, effectiveDecision) + } catch (error) { + console.warn(`[hooks] default tab commands skipped for ${worktreePath}:`, error) + defaultTabs = yamlHooks?.defaultTabs + ? { tabs: yamlHooks.defaultTabs, runCommands: false } + : undefined + } const shouldRunSetup = hooks?.scripts.setup && shouldRunSetupForCreate(repo, effectiveDecision) if (shouldRunSetup && hooks?.scripts.setup) { if (this.authoritativeWindowId !== null) { @@ -7929,9 +8089,21 @@ export class OrcaRuntimeService { // the user can watch prompts/output in a visible pane. const activationSetup = didSpawnSetup ? undefined : setup if (effectiveStartup && !didSpawnStartup) { - this.notifier?.activateWorktree(repo.id, worktree.id, activationSetup, effectiveStartup) + this.notifier?.activateWorktree( + repo.id, + worktree.id, + activationSetup, + effectiveStartup, + defaultTabs + ) } else { - this.notifier?.activateWorktree(repo.id, worktree.id, activationSetup) + this.notifier?.activateWorktree( + repo.id, + worktree.id, + activationSetup, + undefined, + defaultTabs + ) } } else if (this.ptyController?.spawn) { try { @@ -7966,6 +8138,7 @@ export class OrcaRuntimeService { }, ...(lineageInput ? { lineage, warnings: lineageWarnings } : {}), ...(setup ? { setup } : {}), + ...(defaultTabs ? { defaultTabs } : {}), ...(warning ? { warning } : {}), ...(addResult.localBaseRefRefresh ? { localBaseRefRefresh: addResult.localBaseRefRefresh } @@ -8150,6 +8323,7 @@ export class OrcaRuntimeService { const cacheKey = `${repoPath}::${remote}` const cached = this.canonicalFetchKeyCache.get(cacheKey) if (cached !== undefined) { + setBoundedMapEntry(this.canonicalFetchKeyCache, cacheKey, cached, REMOTE_FETCH_CACHE_MAX) return cached } let resolved = cacheKey @@ -8166,7 +8340,7 @@ export class OrcaRuntimeService { // Fall through to the caller-provided path. The fetch still runs from // repoPath; this key only controls cache sharing. } - this.canonicalFetchKeyCache.set(cacheKey, resolved) + setBoundedMapEntry(this.canonicalFetchKeyCache, cacheKey, resolved, REMOTE_FETCH_CACHE_MAX) return resolved } @@ -8188,11 +8362,15 @@ export class OrcaRuntimeService { async getOrStartRemoteFetch(repoPath: string, remote: string): Promise { const key = await this.getCanonicalFetchKey(repoPath, remote) const lastAt = this.fetchLastCompletedAt.get(key) - if (lastAt !== undefined && Date.now() - lastAt < FETCH_FRESHNESS_MS) { - // Why: freshness window hit — skip the fetch entirely. Do NOT reuse any - // in-flight promise here; the timestamp is only written on success, so - // hitting this branch means a previous fetch did succeed recently. - return { ok: true } + if (lastAt !== undefined) { + if (Date.now() - lastAt < FETCH_FRESHNESS_MS) { + // Why: freshness window hit — skip the fetch entirely. Do NOT reuse any + // in-flight promise here; the timestamp is only written on success, so + // hitting this branch means a previous fetch did succeed recently. + setBoundedMapEntry(this.fetchLastCompletedAt, key, lastAt, REMOTE_FETCH_CACHE_MAX) + return { ok: true } + } + this.fetchLastCompletedAt.delete(key) } const existing = this.fetchInflight.get(key) @@ -8207,7 +8385,7 @@ export class OrcaRuntimeService { .then((): RemoteFetchResult => { // Why (§3.3 Lifecycle): timestamp on success ONLY. Writing on rejection // would make the freshness cache lie about the last known remote state. - this.fetchLastCompletedAt.set(key, Date.now()) + setBoundedMapEntry(this.fetchLastCompletedAt, key, Date.now(), REMOTE_FETCH_CACHE_MAX) return { ok: true } }) .catch((err): RemoteFetchResult => { @@ -8587,7 +8765,7 @@ export class OrcaRuntimeService { prNumber: number headRefName?: string isCrossRepository?: boolean - }): Promise<{ baseBranch: string; pushTarget?: GitPushTarget } | { error: string }> { + }): Promise { if (!this.store) { throw new Error('runtime_unavailable') } @@ -8863,17 +9041,115 @@ export class OrcaRuntimeService { } private removeWorktreeMetadataAndHistory(store: RuntimeStore, worktreeId: string): void { - // Why: terminal history is keyed by worktree ID, so metadata cleanup must - // also purge history before the same path-derived ID can be recreated. + // Why: worktree IDs are path-derived and can be recreated, so removal must + // purge history and process-local caches before the ID points at new state. store.removeWorktreeMeta(worktreeId) + advertisedUrlWatcher.forgetWorktree(worktreeId) deleteWorktreeHistoryDir(worktreeId) } + private rememberPreservedBranchCleanupTarget( + worktreeId: string, + result: RemoveWorktreeResult | undefined, + fallbackHead: string | undefined, + pushTarget: GitPushTarget | undefined + ): void { + if (result?.preservedBranch) { + const head = result.preservedBranch.head ?? fallbackHead + if (!head) { + throw new Error( + `Cannot safely offer force-delete for preserved branch "${result.preservedBranch.branchName}" without its saved commit.` + ) + } + this.preservedBranchCleanupByWorktreeId.set(worktreeId, { + branchName: result.preservedBranch.branchName, + head, + ...(pushTarget ? { pushTarget } : {}) + }) + return + } + this.preservedBranchCleanupByWorktreeId.delete(worktreeId) + } + + private preserveBranchHeadFallback( + result: RemoveWorktreeResult | undefined, + fallbackHead: string | undefined + ): RemoveWorktreeResult { + if (!result?.preservedBranch || result.preservedBranch.head || !fallbackHead) { + return result ?? {} + } + return { + ...result, + preservedBranch: { + ...result.preservedBranch, + head: fallbackHead + } + } + } + + async forceDeletePreservedBranch( + worktreeSelector: string, + branchName: string, + expectedHead: string + ): Promise { + if (!this.store) { + throw new Error('runtime_unavailable') + } + const removalTarget = parseExactWorktreeIdSelector(worktreeSelector) + const cleanupTarget = removalTarget + ? this.preservedBranchCleanupByWorktreeId.get(removalTarget.id) + : undefined + if ( + !removalTarget || + !cleanupTarget || + cleanupTarget.branchName !== branchName || + cleanupTarget.head !== expectedHead + ) { + throw new Error(`No preserved branch cleanup is pending for "${branchName}".`) + } + + const repo = this.store.getRepo(removalTarget.repoId) + if (!repo) { + throw new Error('repo_not_found') + } + if (isFolderRepo(repo)) { + throw new Error('Folder workspaces do not have local Git branches.') + } + + if (repo.connectionId) { + const provider = requireSshGitProvider(repo.connectionId) + await forceDeleteLocalBranch( + repo.path, + cleanupTarget.branchName, + cleanupTarget.head, + (argv, cwd) => provider.exec(argv, cwd) + ) + await cleanupUnusedWorktreePushTargetRemoteSsh( + provider, + repo.path, + removalTarget.id, + cleanupTarget.pushTarget, + this.store + ) + } else { + await forceDeleteLocalBranch(repo.path, cleanupTarget.branchName, cleanupTarget.head) + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + removalTarget.id, + cleanupTarget.pushTarget, + this.store + ) + } + + this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) + return { deleted: true } + } + async removeManagedWorktree( worktreeSelector: string, force = false, runHooks = false - ): Promise<{ warning?: string }> { + ): Promise { if (!this.store) { throw new Error('runtime_unavailable') } @@ -8890,7 +9166,7 @@ export class OrcaRuntimeService { // Why: runtime callers can race the same workspace through CLI/mobile // retries. Share one destructive Git/filesystem operation per worktree ID. - const removal = (async (): Promise<{ warning?: string }> => { + const removal = (async (): Promise => { const repo = store.getRepo(removalTarget.repoId) if (!repo) { throw new Error('repo_not_found') @@ -8913,6 +9189,7 @@ export class OrcaRuntimeService { }) } this.removeWorktreeMetadataAndHistory(store, removalTarget.id) + this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) this.invalidateResolvedWorktreeCache() this.notifier?.worktreesChanged(repo.id) return {} @@ -8931,9 +9208,7 @@ export class OrcaRuntimeService { ) if (!registeredWorktree) { let canCleanOrphanedDirectory = false - const knownOrcaLayouts = repo.connectionId - ? [] - : buildKnownOrcaWorkspaceLayouts(store.getSettings(), repo) + const knownOrcaLayouts = buildKnownOrcaWorkspaceLayouts(store.getSettings(), repo) if ( canCleanupUnregisteredOrcaWorktreeDirectory({ meta: removedMeta, @@ -8987,6 +9262,7 @@ export class OrcaRuntimeService { } this.clearOptimisticReconcileToken(removalTarget.id) this.removeWorktreeMetadataAndHistory(store, removalTarget.id) + this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) this.invalidateResolvedWorktreeCache() invalidateAuthorizedRootsCache() this.notifier?.worktreesChanged(repo.id) @@ -9012,6 +9288,7 @@ export class OrcaRuntimeService { )) this.clearOptimisticReconcileToken(removalTarget.id) this.removeWorktreeMetadataAndHistory(store, removalTarget.id) + this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) this.invalidateResolvedWorktreeCache() invalidateAuthorizedRootsCache() this.notifier?.worktreesChanged(repo.id) @@ -9022,9 +9299,13 @@ export class OrcaRuntimeService { const canonicalWorktreePath = registeredWorktree.path const deleteBranch = removedMeta?.preserveBranchOnDelete !== true if (repo.connectionId) { - await (deleteBranch + const rawRemovalResult = await (deleteBranch ? provider!.removeWorktree(canonicalWorktreePath, force) : provider!.removeWorktree(canonicalWorktreePath, force, { deleteBranch })) + const removalResult = this.preserveBranchHeadFallback( + rawRemovalResult, + registeredWorktree.head + ) await cleanupUnusedWorktreePushTargetRemoteSsh( provider!, repo.path, @@ -9032,12 +9313,18 @@ export class OrcaRuntimeService { removedPushTarget, store ) + this.rememberPreservedBranchCleanupTarget( + removalTarget.id, + removalResult, + registeredWorktree.head, + removedPushTarget + ) this.clearOptimisticReconcileToken(removalTarget.id) this.removeWorktreeMetadataAndHistory(store, removalTarget.id) this.invalidateResolvedWorktreeCache() invalidateAuthorizedRootsCache() this.notifier?.worktreesChanged(repo.id) - return {} + return removalResult ?? {} } const hooks = getEffectiveHooks(repo) @@ -9092,10 +9379,14 @@ export class OrcaRuntimeService { }) } + let removalResult: RemoveWorktreeResult | undefined try { - await (deleteBranch - ? removeWorktree(repo.path, canonicalWorktreePath, force) - : removeWorktree(repo.path, canonicalWorktreePath, force, { deleteBranch })) + removalResult = this.preserveBranchHeadFallback( + await (deleteBranch + ? removeWorktree(repo.path, canonicalWorktreePath, force) + : removeWorktree(repo.path, canonicalWorktreePath, force, { deleteBranch })), + registeredWorktree.head + ) } catch (error) { if (isOrphanedWorktreeError(error)) { if (await canSafelyRemoveOrphanedWorktreeDirectory(canonicalWorktreePath, repo.path)) { @@ -9118,6 +9409,7 @@ export class OrcaRuntimeService { ) this.clearOptimisticReconcileToken(removalTarget.id) this.removeWorktreeMetadataAndHistory(store, removalTarget.id) + this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) this.invalidateResolvedWorktreeCache() invalidateAuthorizedRootsCache() this.notifier?.worktreesChanged(repo.id) @@ -9134,12 +9426,19 @@ export class OrcaRuntimeService { removedPushTarget, store ) + this.rememberPreservedBranchCleanupTarget( + removalTarget.id, + removalResult, + registeredWorktree.head, + removedPushTarget + ) this.clearOptimisticReconcileToken(removalTarget.id) this.removeWorktreeMetadataAndHistory(store, removalTarget.id) this.invalidateResolvedWorktreeCache() invalidateAuthorizedRootsCache() this.notifier?.worktreesChanged(repo.id) return { + ...removalResult, ...(warning ? { warning } : {}) } })() @@ -9583,7 +9882,7 @@ export class OrcaRuntimeService { // Why: mobile clients may subscribe before the PTY spawns (the left pane // of a new workspace). Instead of bailing with a bare scrollback+end, // wait for the PTY to appear so the subscribe can proceed with phone-fit. - waitForLeafPtyId(handle: string, timeoutMs = 10_000): Promise { + waitForLeafPtyId(handle: string, timeoutMs = 10_000, signal?: AbortSignal): Promise { const leaf = this.resolveLeafForHandle(handle) if (leaf?.ptyId) { return Promise.resolve(leaf.ptyId) @@ -9597,15 +9896,40 @@ export class OrcaRuntimeService { const savedLeafId = record?.leafId ?? null return new Promise((resolve, reject) => { - const timer = setTimeout(() => { + let timer: ReturnType | null = null + let check: () => void = () => {} + const cleanup = (): void => { + if (timer) { + clearTimeout(timer) + timer = null + } const idx = this.graphSyncCallbacks.indexOf(check) if (idx !== -1) { this.graphSyncCallbacks.splice(idx, 1) } - reject(new Error('Timed out waiting for PTY to spawn')) + signal?.removeEventListener('abort', onAbort) + } + const finish = (ptyId: string): void => { + cleanup() + resolve(ptyId) + } + const fail = (error: Error): void => { + cleanup() + reject(error) + } + const onAbort = (): void => { + fail(new Error('request_aborted')) + } + if (signal?.aborted) { + reject(new Error('request_aborted')) + return + } + signal?.addEventListener('abort', onAbort, { once: true }) + timer = setTimeout(() => { + fail(new Error('Timed out waiting for PTY to spawn')) }, timeoutMs) - const check = (): void => { + check = (): void => { // Try the handle first (works if handle wasn't invalidated yet) let ptyId = this.resolveLeafForHandle(handle)?.ptyId // Why: when ptyId transitions null→real, issueHandle invalidates the @@ -9615,12 +9939,7 @@ export class OrcaRuntimeService { ptyId = directLeaf?.ptyId ?? null } if (ptyId) { - clearTimeout(timer) - const idx = this.graphSyncCallbacks.indexOf(check) - if (idx !== -1) { - this.graphSyncCallbacks.splice(idx, 1) - } - resolve(ptyId) + finish(ptyId) } } this.graphSyncCallbacks.push(check) @@ -10601,6 +10920,7 @@ export class OrcaRuntimeService { tabId: state.tabId ?? null, paneKey: state.paneKey ?? null, connected: state.connected ?? true, + disconnectedAt: state.connected === false ? Date.now() : null, lastExitCode: null, lastAgentStatus: null, lastOscTitle: null, @@ -10628,6 +10948,7 @@ export class OrcaRuntimeService { } if (state.connected !== undefined) { pty.connected = state.connected + pty.disconnectedAt = state.connected ? null : (pty.disconnectedAt ?? Date.now()) } if (state.lastOutputAt !== undefined) { pty.lastOutputAt = maxTimestamp(pty.lastOutputAt, state.lastOutputAt) @@ -10690,8 +11011,10 @@ export class OrcaRuntimeService { for (const pty of this.ptysById.values()) { if (!livePtyIds.has(pty.ptyId) && !this.leafExistsForPty(pty.ptyId)) { pty.connected = false + pty.disconnectedAt ??= Date.now() } } + this.pruneDisconnectedPtyRecords() } private pruneDisconnectedPtyTranscript(pty: RuntimePtyWorktreeRecord): void { @@ -10706,6 +11029,35 @@ export class OrcaRuntimeService { pty.tailLinesTotal = 0 } + private pruneDisconnectedPtyRecords(): void { + const retained = [...this.ptysById.values()] + .filter((pty) => !pty.connected && !this.leafExistsForPty(pty.ptyId)) + .sort((a, b) => (a.disconnectedAt ?? 0) - (b.disconnectedAt ?? 0)) + while (retained.length > DISCONNECTED_PTY_RECORD_MAX) { + const stale = retained.shift() + if (!stale) { + return + } + // Why: exited runtime-owned PTYs stay readable after exit, but long-lived + // runtimes can churn through many background sessions. Bound the archive. + this.dropDisconnectedPtyRecord(stale.ptyId) + } + } + + private dropDisconnectedPtyRecord(ptyId: string): void { + this.ptysById.delete(ptyId) + this.recentPtyOutputById.delete(ptyId) + this.ptyOutputSequenceById.delete(ptyId) + const handle = this.handleByPtyId.get(ptyId) + if (handle) { + this.handleByPtyId.delete(ptyId) + const record = this.handles.get(handle) + if (record?.tabId.startsWith('pty:')) { + this.handles.delete(handle) + } + } + } + private leafExistsForPty(ptyId: string): boolean { return (this.leavesByPtyId.get(ptyId)?.length ?? 0) > 0 } @@ -11262,7 +11614,8 @@ export class OrcaRuntimeService { handle, typeFilter: options?.typeFilter, resolve, - timeout: null + timeout: null, + abortCleanup: null } // Why: if the caller aborts (socket closed on the RPC side — see design @@ -11279,13 +11632,11 @@ export class OrcaRuntimeService { resolve() return } + waiter.abortCleanup = () => signal.removeEventListener('abort', onAbort) signal.addEventListener('abort', onAbort, { once: true }) } waiter.timeout = setTimeout(() => { - if (signal) { - signal.removeEventListener('abort', onAbort) - } this.removeMessageWaiter(waiter) resolve() }, timeoutMs) @@ -11309,6 +11660,10 @@ export class OrcaRuntimeService { clearTimeout(waiter.timeout) waiter.timeout = null } + if (waiter.abortCleanup) { + waiter.abortCleanup() + waiter.abortCleanup = null + } const waiters = this.messageWaitersByHandle.get(waiter.handle) if (waiters) { waiters.delete(waiter) @@ -11991,6 +12346,50 @@ export class OrcaRuntimeService { return listLinearProjects(query, Math.min(Math.max(1, limit), 50), workspaceId) } + linearGetProject(id: string, workspaceId: string): ReturnType { + return getLinearProject(id, workspaceId) + } + + linearListProjectIssues( + projectId: string, + limit = 20, + workspaceId: string + ): ReturnType { + return listLinearProjectIssues(projectId, Math.min(Math.max(1, limit), 50), workspaceId) + } + + linearListCustomViews( + model: LinearCustomViewModel, + limit = 20, + workspaceId?: LinearWorkspaceSelection + ): ReturnType { + return listLinearCustomViews(model, Math.min(Math.max(1, limit), 50), workspaceId) + } + + linearGetCustomView( + viewId: string, + model: LinearCustomViewModel, + workspaceId: string + ): ReturnType { + return getLinearCustomView(viewId, model, workspaceId) + } + + linearListCustomViewIssues( + viewId: string, + limit = 20, + workspaceId: string + ): ReturnType { + return listLinearCustomViewIssues(viewId, Math.min(Math.max(1, limit), 50), workspaceId) + } + + linearListCustomViewProjects( + viewId: string, + limit = 20, + workspaceId: string + ): ReturnType { + return listLinearCustomViewProjects(viewId, Math.min(Math.max(1, limit), 50), workspaceId) + } + linearTeamStates(teamId: string, workspaceId?: string): ReturnType { return getLinearTeamStates(teamId, workspaceId) } @@ -12455,6 +12854,7 @@ const DEFAULT_REPO_SEARCH_REFS_LIMIT = 25 const DEFAULT_TERMINAL_LIST_LIMIT = 200 const DEFAULT_WORKTREE_LIST_LIMIT = 200 const DEFAULT_WORKTREE_PS_LIMIT = 200 +const DISCONNECTED_PTY_RECORD_MAX = 128 const RESOLVED_WORKTREE_CACHE_TTL_MS = 1000 const RESOLVED_WORKTREE_REPO_TIMEOUT_MS = 5000 const PTY_CONTROLLER_LIST_TIMEOUT_MS = 3000 @@ -12464,8 +12864,23 @@ const PTY_CONTROLLER_LIST_TIMEOUT_MS = 3000 // clicks and successive coordinator dispatches feel snappy, while still being // short enough that a genuinely-changed remote is observed on the next action. const FETCH_FRESHNESS_MS = 30_000 +const REMOTE_FETCH_CACHE_MAX = 512 const DRIFT_PROBE_SUBJECT_LIMIT = 5 +function setBoundedMapEntry(map: Map, key: K, value: V, maxEntries: number): void { + if (map.has(key)) { + map.delete(key) + } + map.set(key, value) + while (map.size > maxEntries) { + const oldest = map.keys().next() + if (oldest.done) { + return + } + map.delete(oldest.value) + } +} + function getExplicitWorktreeIdSelector(selector: string | undefined): string | null { if (!selector?.startsWith('id:')) { return null @@ -12507,11 +12922,34 @@ function withTimeoutResult( }) } -function buildPreview(lines: string[], partialLine: string): string { - const previewLines = buildTailLines(lines, partialLine) - .map((line) => line.trim()) - .filter(Boolean) - .slice(-MAX_PREVIEW_LINES) +export function appendRecentPtyOutput(previous: string | undefined, data: string): string { + if (data.length >= RECENT_PTY_OUTPUT_LIMIT) { + return data.slice(-RECENT_PTY_OUTPUT_LIMIT) + } + return `${previous ?? ''}${data}`.slice(-RECENT_PTY_OUTPUT_LIMIT) +} + +export function buildPreview(lines: string[], partialLine: string): string { + const previewLines: string[] = [] + const collectVisibleLine = (line: string): void => { + const trimmed = line.trim() + if (trimmed.length > 0) { + previewLines.push(trimmed) + } + } + + if (partialLine.length > 0) { + collectVisibleLine(partialLine) + } + for ( + let index = lines.length - 1; + index >= 0 && previewLines.length < MAX_PREVIEW_LINES; + index-- + ) { + collectVisibleLine(lines[index]) + } + previewLines.reverse() + const preview = previewLines.join('\n') return preview.length > MAX_PREVIEW_CHARS ? preview.slice(preview.length - MAX_PREVIEW_CHARS) @@ -12528,7 +12966,7 @@ function buildTerminalWaitText(lines: string[], partialLine: string, preview: st return waitText.length > 0 ? waitText : preview } -function appendNormalizedToTailBuffer( +export function appendNormalizedToTailBuffer( previousLines: string[], previousPartialLine: string, normalizedChunk: string @@ -12561,8 +12999,8 @@ function appendNormalizedToTailBuffer( : previousLines let truncated = previousPartialWasCapped || nextPartialLine.length > MAX_TAIL_PARTIAL_CHARS - while (nextLines.length > MAX_TAIL_LINES) { - nextLines.shift() + if (nextLines.length > MAX_TAIL_LINES) { + nextLines = nextLines.slice(nextLines.length - MAX_TAIL_LINES) truncated = true } @@ -12572,8 +13010,13 @@ function appendNormalizedToTailBuffer( } let totalChars = nextLines.reduce((sum, line) => sum + line.length, 0) + retainedPartialLine.length - while (nextLines.length > 0 && totalChars > MAX_TAIL_CHARS) { - totalChars -= nextLines.shift()!.length + let trimStartIndex = 0 + while (trimStartIndex < nextLines.length && totalChars > MAX_TAIL_CHARS) { + totalChars -= nextLines[trimStartIndex].length + trimStartIndex += 1 + } + if (trimStartIndex > 0) { + nextLines = nextLines.slice(trimStartIndex) truncated = true } } @@ -12637,12 +13080,15 @@ function trimTerminalPreviewToCharacterBudget( return { tail: lines, limited: false, omittedLineCount: 0, slicedFirstLine: false } } - const tail = [...lines] let omittedLineCount = 0 - while (tail.length > 0 && totalCharacters - tail[0].length >= characterBudget) { - totalCharacters -= tail.shift()!.length + while ( + omittedLineCount < lines.length && + totalCharacters - lines[omittedLineCount].length >= characterBudget + ) { + totalCharacters -= lines[omittedLineCount].length omittedLineCount += 1 } + const tail = omittedLineCount > 0 ? lines.slice(omittedLineCount) : [...lines] let slicedFirstLine = false if (tail.length > 0 && totalCharacters > characterBudget) { diff --git a/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap b/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap index 8c8fd9f09f5..40f559ca8ac 100644 --- a/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap +++ b/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap @@ -20,10 +20,12 @@ Slack, GitHub comments, or any other channel to reach a human during the run. # # RULE: send worker_done exactly once. Failure is still a worker_done # with subject like "Failed: " — never silently exit. + # Include BOTH taskId and dispatchId in the payload so a late completion + # from a failed retry cannot complete the current dispatch. orca orchestration send --to term_COORD \\ --type worker_done --subject "" \\ --body "<3-sentence summary: what you did, what you found, what's left>" \\ - --payload '{"taskId":"task_SNAP","filesModified":["path/a","path/b"],"reportPath":""}' + --payload '{"taskId":"task_SNAP","dispatchId":"ctx_SNAP","filesModified":["path/a","path/b"],"reportPath":""}' # BEHAVIOR RULE: send a heartbeat every 5 minutes # while actively working on the task. The coordinator uses this to diff --git a/src/main/runtime/orchestration/coordinator.test.ts b/src/main/runtime/orchestration/coordinator.test.ts index 5c1ff4e22f7..da82c14bd19 100644 --- a/src/main/runtime/orchestration/coordinator.test.ts +++ b/src/main/runtime/orchestration/coordinator.test.ts @@ -65,6 +65,34 @@ function createMockRuntime(): CoordinatorRuntime & { return mock } +function insertWorkerDone( + db: OrchestrationDb, + params: { + taskId: string + to?: string + from?: string + dispatchId?: string + filesModified?: string[] + } +): void { + const dispatch = db.getDispatchContext(params.taskId) + const dispatchId = params.dispatchId ?? dispatch?.id + if (!dispatchId) { + throw new Error(`No dispatch for task ${params.taskId}`) + } + db.insertMessage({ + from: params.from ?? dispatch?.assignee_handle ?? 'term_unknown', + to: params.to ?? 'coord', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ + taskId: params.taskId, + dispatchId, + ...(params.filesModified ? { filesModified: params.filesModified } : {}) + }) + }) +} + describe('Coordinator', () => { let db: OrchestrationDb @@ -105,13 +133,7 @@ describe('Coordinator', () => { }) // Simulate the worker completing - db.insertMessage({ - from: 'term_a', - to: 'coord', - subject: 'Done', - type: 'worker_done', - payload: JSON.stringify({ taskId: task.id, filesModified: ['a.ts'] }) - }) + insertWorkerDone(db, { taskId: task.id, filesModified: ['a.ts'] }) const result = await runPromise expect(result.status).toBe('completed') @@ -140,13 +162,7 @@ describe('Coordinator', () => { expect(runtime.createdTerminals.length).toBe(1) // Complete the task - db.insertMessage({ - from: runtime.createdTerminals[0], - to: 'coord', - subject: 'Done', - type: 'worker_done', - payload: JSON.stringify({ taskId: task.id }) - }) + insertWorkerDone(db, { taskId: task.id, from: runtime.createdTerminals[0] }) const result = await runPromise expect(result.status).toBe('completed') @@ -263,13 +279,7 @@ describe('Coordinator', () => { setTimeout(r, 200) }) - db.insertMessage({ - from: 'term_a', - to: 'coord', - subject: 'Done', - type: 'worker_done', - payload: JSON.stringify({ taskId: task.id }) - }) + insertWorkerDone(db, { taskId: task.id }) const result = await runPromise expect(result.status).toBe('completed') @@ -303,13 +313,7 @@ describe('Coordinator', () => { expect(db.getTask(t2.id)?.status).toBe('pending') // Complete t1 - db.insertMessage({ - from: 'term_a', - to: 'coord', - subject: 'Done', - type: 'worker_done', - payload: JSON.stringify({ taskId: t1.id }) - }) + insertWorkerDone(db, { taskId: t1.id }) // Wait for t2 to be promoted and dispatched await new Promise((r) => { @@ -321,13 +325,7 @@ describe('Coordinator', () => { expect(t2Status === 'dispatched' || t2Status === 'ready').toBe(true) // Complete t2 - db.insertMessage({ - from: 'term_a', - to: 'coord', - subject: 'Done', - type: 'worker_done', - payload: JSON.stringify({ taskId: t2.id }) - }) + insertWorkerDone(db, { taskId: t2.id }) const result = await runPromise expect(result.status).toBe('completed') @@ -367,13 +365,7 @@ describe('Coordinator', () => { // Complete all tasks for (const task of [t1, t2, t3]) { - db.insertMessage({ - from: 'term_a', - to: 'coord', - subject: 'Done', - type: 'worker_done', - payload: JSON.stringify({ taskId: task.id }) - }) + insertWorkerDone(db, { taskId: task.id }) await new Promise((r) => { setTimeout(r, 100) }) @@ -453,18 +445,99 @@ describe('Coordinator', () => { expect(db.getDispatchContext(task.id)?.last_heartbeat_at).toBeTruthy() // Complete the task so the coordinator run finishes cleanly. - db.insertMessage({ - from: 'term_a', - to: 'coord', - subject: 'Done', - type: 'worker_done', - payload: JSON.stringify({ taskId: task.id }) - }) + insertWorkerDone(db, { taskId: task.id }) const result = await runPromise expect(result.status).toBe('completed') }) + it('ignores stale worker_done from a failed retry before accepting the active dispatch', async () => { + db = new OrchestrationDb(':memory:') + const runtime = createMockRuntime() + const logs: string[] = [] + + const task = db.createTask({ spec: 'retry-sensitive work' }) + const staleCtx = db.createDispatchContext(task.id, 'term_old') + db.failDispatch(staleCtx.id, 'retry elsewhere') + const activeCtx = db.createDispatchContext(task.id, 'term_current') + + db.insertMessage({ + from: 'term_old', + to: 'coord', + subject: 'Late done', + type: 'worker_done', + payload: JSON.stringify({ taskId: task.id, dispatchId: staleCtx.id }) + }) + + const staleCoordinator = new Coordinator(db, runtime, { + spec: 'go', + coordinatorHandle: 'coord', + pollIntervalMs: 20, + onLog: (m) => logs.push(m) + }) + const staleRun = staleCoordinator.run() + await new Promise((r) => { + setTimeout(r, 80) + }) + staleCoordinator.stop() + await staleRun + + expect(db.getTask(task.id)?.status).toBe('dispatched') + expect(db.getDispatchContextById(staleCtx.id)?.status).toBe('failed') + expect(db.getDispatchContextById(activeCtx.id)?.status).toBe('dispatched') + expect(logs.some((m) => m.includes('inactive dispatch'))).toBe(true) + + insertWorkerDone(db, { + taskId: task.id, + from: 'term_current', + dispatchId: activeCtx.id + }) + const completionCoordinator = new Coordinator(db, runtime, { + spec: 'go', + coordinatorHandle: 'coord', + pollIntervalMs: 20 + }) + const result = await completionCoordinator.run() + + expect(result.status).toBe('completed') + expect(db.getTask(task.id)?.status).toBe('completed') + expect(db.getDispatchContextById(activeCtx.id)?.status).toBe('completed') + }) + + it('ignores worker_done sent by a terminal that does not own the dispatch', async () => { + db = new OrchestrationDb(':memory:') + const runtime = createMockRuntime() + const logs: string[] = [] + + const task = db.createTask({ spec: 'owned work' }) + const ctx = db.createDispatchContext(task.id, 'term_owner') + + db.insertMessage({ + from: 'term_intruder', + to: 'coord', + subject: 'Spoofed done', + type: 'worker_done', + payload: JSON.stringify({ taskId: task.id, dispatchId: ctx.id }) + }) + + const coordinator = new Coordinator(db, runtime, { + spec: 'go', + coordinatorHandle: 'coord', + pollIntervalMs: 20, + onLog: (m) => logs.push(m) + }) + const runPromise = coordinator.run() + await new Promise((r) => { + setTimeout(r, 80) + }) + coordinator.stop() + await runPromise + + expect(db.getTask(task.id)?.status).toBe('dispatched') + expect(db.getDispatchContextById(ctx.id)?.status).toBe('dispatched') + expect(logs.some((m) => m.includes('expected term_owner'))).toBe(true) + }) + it('can be stopped', async () => { db = new OrchestrationDb(':memory:') const runtime = createMockRuntime() @@ -512,13 +585,7 @@ describe('Coordinator', () => { setTimeout(r, 100) }) - db.insertMessage({ - from: 'term_a', - to: 'coord', - subject: 'Done', - type: 'worker_done', - payload: JSON.stringify({ taskId: task.id }) - }) + insertWorkerDone(db, { taskId: task.id }) const result = await runPromise expect(result.status).toBe('completed') @@ -594,13 +661,7 @@ allow-stale-base: true` setTimeout(r, 100) }) - db.insertMessage({ - from: 'term_a', - to: 'coord', - subject: 'Done', - type: 'worker_done', - payload: JSON.stringify({ taskId: task.id }) - }) + insertWorkerDone(db, { taskId: task.id }) const result = await runPromise expect(result.status).toBe('completed') @@ -633,13 +694,7 @@ allow-stale-base: true` setTimeout(r, 100) }) - db.insertMessage({ - from: 'term_a', - to: 'coord', - subject: 'Done', - type: 'worker_done', - payload: JSON.stringify({ taskId: task.id }) - }) + insertWorkerDone(db, { taskId: task.id }) const result = await runPromise expect(result.status).toBe('completed') @@ -669,13 +724,7 @@ allow-stale-base: true` setTimeout(r, 100) }) - db.insertMessage({ - from: 'term_a', - to: 'coord', - subject: 'Done', - type: 'worker_done', - payload: JSON.stringify({ taskId: task.id }) - }) + insertWorkerDone(db, { taskId: task.id }) const result = await runPromise expect(result.status).toBe('completed') @@ -705,13 +754,7 @@ allow-stale-base: true` setTimeout(r, 100) }) - db.insertMessage({ - from: 'term_a', - to: 'coord', - subject: 'Done', - type: 'worker_done', - payload: JSON.stringify({ taskId: task.id }) - }) + insertWorkerDone(db, { taskId: task.id }) const result = await runPromise expect(result.status).toBe('completed') diff --git a/src/main/runtime/orchestration/coordinator.ts b/src/main/runtime/orchestration/coordinator.ts index 2dc02867887..db17fc8d087 100644 --- a/src/main/runtime/orchestration/coordinator.ts +++ b/src/main/runtime/orchestration/coordinator.ts @@ -263,7 +263,9 @@ export class Coordinator { case 'status': this.opts.onLog(`Status from ${msg.from_handle}: ${msg.subject}`) break - default: + case 'dispatch': + case 'handoff': + case 'merge_ready': break } } @@ -303,7 +305,7 @@ export class Coordinator { private handleWorkerDone(msg: MessageRow): void { this.opts.onLog(`Worker done: ${msg.from_handle} — ${msg.subject}`) - let payload: { taskId?: string; filesModified?: string[] } = {} + let payload: { taskId?: unknown; dispatchId?: unknown; filesModified?: unknown } = {} if (msg.payload) { try { payload = JSON.parse(msg.payload) @@ -313,32 +315,65 @@ export class Coordinator { } const taskId = payload.taskId - if (!taskId) { + if (typeof taskId !== 'string' || taskId.length === 0) { this.opts.onLog(`Warning: worker_done without taskId from ${msg.from_handle}`) return } + const dispatchId = payload.dispatchId + if (typeof dispatchId !== 'string' || dispatchId.length === 0) { + this.opts.onLog(`Warning: worker_done without dispatchId from ${msg.from_handle}`) + return + } + const task = this.db.getTask(taskId) if (!task) { this.opts.onLog(`Warning: worker_done for unknown task ${taskId}`) return } + // Why: taskId alone is not a completion authority; retried tasks can have + // stale worker_done messages racing the current active dispatch. + const dispatch = this.db.getDispatchContextById(dispatchId) + if (!dispatch) { + this.opts.onLog(`Warning: worker_done for unknown dispatch ${dispatchId}`) + return + } + if (dispatch.task_id !== taskId) { + this.opts.onLog( + `Warning: worker_done dispatch ${dispatchId} belongs to ${dispatch.task_id}, not ${taskId}` + ) + return + } + if (dispatch.assignee_handle !== msg.from_handle) { + this.opts.onLog( + `Warning: worker_done for dispatch ${dispatchId} came from ${msg.from_handle}, expected ${dispatch.assignee_handle ?? ''}` + ) + return + } + if (dispatch.status !== 'dispatched') { + this.opts.onLog(`Warning: worker_done for inactive dispatch ${dispatchId} ignored`) + return + } + if (this.db.getDispatchContext(taskId)?.id !== dispatchId || task.status !== 'dispatched') { + this.opts.onLog(`Warning: worker_done for stale dispatch ${dispatchId} ignored`) + return + } + + const filesModified = + Array.isArray(payload.filesModified) && + payload.filesModified.every((file) => typeof file === 'string') + ? payload.filesModified + : [] + const result = JSON.stringify({ completedBy: msg.from_handle, - filesModified: payload.filesModified ?? [], + filesModified, completedAt: new Date().toISOString() }) this.db.updateTaskStatus(taskId, 'completed', result) this.state.completedTasks.push(taskId) - // Why: complete the dispatch context so the terminal is freed for - // subsequent task assignments. - const dispatch = this.db.getDispatchContext(taskId) - if (dispatch) { - this.db.completeDispatch(dispatch.id) - } - this.opts.onLog(`Task ${taskId} completed`) } diff --git a/src/main/runtime/orchestration/db.test.ts b/src/main/runtime/orchestration/db.test.ts index 4607afb4bc8..83f09cd131a 100644 --- a/src/main/runtime/orchestration/db.test.ts +++ b/src/main/runtime/orchestration/db.test.ts @@ -1,9 +1,9 @@ /* eslint-disable max-lines -- Why: DB tests cover messages, tasks, dispatch contexts, decision gates, coordinator runs, and lifecycle in one suite to share the createDb() helper and afterEach cleanup. */ -import Database from 'better-sqlite3' import { mkdtempSync, rmSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' import { afterEach, describe, expect, it } from 'vitest' +import Database from '../../sqlite/sync-database' import { OrchestrationDb } from './db' import type { MessageType } from './db' diff --git a/src/main/runtime/orchestration/db.ts b/src/main/runtime/orchestration/db.ts index 570a1aa0404..ad48d48a1b6 100644 --- a/src/main/runtime/orchestration/db.ts +++ b/src/main/runtime/orchestration/db.ts @@ -1,6 +1,6 @@ /* eslint-disable max-lines -- Why: the orchestration DB keeps schema creation, message CRUD, task DAG resolution, and dispatch context management in one class so transactional invariants (e.g. promoteReadyTasks running inside the same writer as updateTaskStatus) are enforced by locality. */ -import Database from 'better-sqlite3' import { randomBytes } from 'crypto' +import Database from '../../sqlite/sync-database' import type { MessageType, MessagePriority, @@ -380,7 +380,15 @@ export class OrchestrationDb { // message for a handle regardless of read/delivered state; never touches the // read bit. Stale-handle safe: if the handle no longer exists, the query // just returns whatever historical rows remain (§3.3). - getAllMessagesForHandle(toHandle: string, limit = 100): MessageRow[] { + getAllMessagesForHandle(toHandle: string, limit = 100, types?: MessageType[]): MessageRow[] { + if (types && types.length > 0) { + const placeholders = types.map(() => '?').join(',') + return this.db + .prepare( + `SELECT * FROM messages WHERE to_handle = ? AND type IN (${placeholders}) ORDER BY sequence DESC LIMIT ?` + ) + .all(toHandle, ...types, limit) as MessageRow[] + } return this.db .prepare('SELECT * FROM messages WHERE to_handle = ? ORDER BY sequence DESC LIMIT ?') .all(toHandle, limit) as MessageRow[] @@ -461,7 +469,7 @@ export class OrchestrationDb { dispatch_id: string | null })[] { const whereClauses: string[] = [] - const params: unknown[] = [] + const params: Database.BindValue[] = [] if (filter?.ready) { whereClauses.push("t.status = 'ready'") } else if (filter?.status) { @@ -588,6 +596,12 @@ export class OrchestrationDb { .get(taskId) as DispatchContextRow | undefined } + getDispatchContextById(dispatchId: string): DispatchContextRow | undefined { + return this.db.prepare('SELECT * FROM dispatch_contexts WHERE id = ?').get(dispatchId) as + | DispatchContextRow + | undefined + } + getActiveDispatchForTerminal(handle: string): DispatchContextRow | undefined { return this.db .prepare( diff --git a/src/main/runtime/orchestration/groups.test.ts b/src/main/runtime/orchestration/groups.test.ts index 61794da9eb1..73bbbb7ad06 100644 --- a/src/main/runtime/orchestration/groups.test.ts +++ b/src/main/runtime/orchestration/groups.test.ts @@ -103,6 +103,25 @@ describe('resolveGroupAddress', () => { expect(result).toEqual(['term_b']) }) + it('matches @openclaude by terminal title', () => { + const terminals = [ + makeSummary('term_a', { title: 'OpenClaude' }), + makeSummary('term_b', { title: 'OpenClaude running' }), + makeSummary('term_c', { title: 'Claude Code' }) + ] + const result = resolveGroupAddress('@openclaude', 'term_a', terminals, noStatus) + expect(result).toEqual(['term_b']) + }) + + it('does not match OpenClaude titles through @claude', () => { + const terminals = [ + makeSummary('term_a', { title: 'Claude Code' }), + makeSummary('term_b', { title: 'OpenClaude running' }) + ] + const result = resolveGroupAddress('@claude', 'term_a', terminals, noStatus) + expect(result).toEqual([]) + }) + it('matches @codex by terminal title', () => { const terminals = [ makeSummary('term_a', { title: 'Codex CLI' }), diff --git a/src/main/runtime/orchestration/groups.ts b/src/main/runtime/orchestration/groups.ts index 8c94759250b..2989c61416f 100644 --- a/src/main/runtime/orchestration/groups.ts +++ b/src/main/runtime/orchestration/groups.ts @@ -4,7 +4,7 @@ import type { RuntimeTerminalSummary } from '../../../shared/runtime-types' // Resolution is done at send-time: one message record per recipient, same thread_id, // so each recipient gets their own read-tracking (Section 4.5). -const AGENT_NAME_GROUPS = ['claude', 'codex', 'opencode', 'gemini'] as const +const AGENT_NAME_GROUPS = ['claude', 'openclaude', 'codex', 'opencode', 'gemini'] as const export type GroupAddress = | '@all' @@ -16,6 +16,15 @@ export function isGroupAddress(to: string): boolean { return to.startsWith('@') } +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function titleMatchesAgentNameGroup(title: string, agentName: string): boolean { + const tokenRe = new RegExp(`(? t.handle) } diff --git a/src/main/runtime/orchestration/preamble.test.ts b/src/main/runtime/orchestration/preamble.test.ts index aeb51e4e9e4..a6cc8ff0194 100644 --- a/src/main/runtime/orchestration/preamble.test.ts +++ b/src/main/runtime/orchestration/preamble.test.ts @@ -32,6 +32,11 @@ describe('buildDispatchPreamble', () => { expect(result).toContain('--body') expect(result).toMatch(/3-sentence summary/) expect(result).toContain('reportPath') + const workerDoneLine = result + .split('\n') + .find((line) => line.includes('"taskId"') && line.includes('filesModified')) + expect(workerDoneLine).toContain('dispatchId') + expect(workerDoneLine).toContain('ctx_def456') }) it('CLI examples parse as valid shell (bash -n on the extracted block)', () => { diff --git a/src/main/runtime/orchestration/preamble.ts b/src/main/runtime/orchestration/preamble.ts index 6223dc94aa8..b10864e8f5b 100644 --- a/src/main/runtime/orchestration/preamble.ts +++ b/src/main/runtime/orchestration/preamble.ts @@ -1,11 +1,10 @@ export type PreambleParams = { taskId: string - // Why: the heartbeat payload attributes liveness to a specific dispatch - // context (not just a task). A retried task has multiple dispatch_contexts - // rows; keying heartbeats on dispatchId prevents a straggler heartbeat from - // a previously-failed dispatch from masking a hung retry. Both call sites - // already have this value in scope (coordinator.ts dispatch.id, rpc - // orchestration.ts ctx.id). + // Why: completion and heartbeat payloads attribute activity to a specific + // dispatch context (not just a task). A retried task has multiple + // dispatch_contexts rows; keying worker_done/heartbeat on dispatchId + // prevents stale messages from a previously-failed dispatch from completing + // or refreshing the retry. dispatchId: string taskSpec: string coordinatorHandle: string @@ -60,10 +59,12 @@ Slack, GitHub comments, or any other channel to reach a human during the run. # # RULE: send worker_done exactly once. Failure is still a worker_done # with subject like "Failed: " — never silently exit. + # Include BOTH taskId and dispatchId in the payload so a late completion + # from a failed retry cannot complete the current dispatch. ${cli} orchestration send --to ${params.coordinatorHandle} \\ --type worker_done --subject "" \\ --body "<3-sentence summary: what you did, what you found, what's left>" \\ - --payload '{"taskId":"${params.taskId}","filesModified":["path/a","path/b"],"reportPath":""}' + --payload '{"taskId":"${params.taskId}","dispatchId":"${params.dispatchId}","filesModified":["path/a","path/b"],"reportPath":""}' # BEHAVIOR RULE: send a heartbeat every ${HEARTBEAT_INTERVAL_MIN} minutes # while actively working on the task. The coordinator uses this to diff --git a/src/main/runtime/rpc/methods/automations.test.ts b/src/main/runtime/rpc/methods/automations.test.ts index b0bb15a7f3f..c4143fb32b8 100644 --- a/src/main/runtime/rpc/methods/automations.test.ts +++ b/src/main/runtime/rpc/methods/automations.test.ts @@ -28,6 +28,7 @@ describe('automation RPC methods', () => { makeRequest('automation.create', { name: 'New review', prompt: 'Review changes', + precheck: { command: 'test -f ready', timeoutSeconds: 30 }, agentId: 'codex', repo: 'repo-1', reuseSession: true, @@ -56,6 +57,7 @@ describe('automation RPC methods', () => { expect.objectContaining({ name: 'New review', prompt: 'Review changes', + precheck: { command: 'test -f ready', timeoutSeconds: 30 }, agentId: 'codex', repo: 'repo-1', reuseSession: true diff --git a/src/main/runtime/rpc/methods/automations.ts b/src/main/runtime/rpc/methods/automations.ts index f2d13e04b9e..20824d8f2f8 100644 --- a/src/main/runtime/rpc/methods/automations.ts +++ b/src/main/runtime/rpc/methods/automations.ts @@ -1,5 +1,9 @@ import { z } from 'zod' import { isValidAutomationSchedule } from '../../../../shared/automation-schedules' +import { + MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS, + normalizeAutomationPrecheckTimeoutSeconds +} from '../../../../shared/automation-precheck' import { isTuiAgent } from '../../../../shared/tui-agent-config' import { defineMethod, type RpcMethod } from '../core' import { @@ -21,6 +25,18 @@ const AutomationSchedule = requiredString('Missing trigger').refine(isValidAutom message: 'Invalid automation trigger' }) +const AutomationPrecheck = z + .object({ + command: requiredString('Missing precheck command'), + timeoutSeconds: OptionalPositiveInt.transform((value) => + normalizeAutomationPrecheckTimeoutSeconds(value) + ).refine((value) => value <= MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS, { + message: 'Precheck timeout is too large' + }) + }) + .nullable() + .optional() + const OptionalNullablePlainString = z .unknown() .transform((value) => (value === null || typeof value === 'string' ? value : undefined)) @@ -38,6 +54,7 @@ const AutomationRuns = z.object({ const AutomationCreate = z.object({ name: requiredString('Missing automation name'), prompt: requiredString('Missing automation prompt'), + precheck: AutomationPrecheck, agentId: TuiAgent, repo: OptionalString, workspace: OptionalString, @@ -54,6 +71,7 @@ const AutomationCreate = z.object({ const AutomationUpdateFields = z.object({ name: OptionalString, prompt: OptionalString, + precheck: AutomationPrecheck, agentId: TuiAgent.optional(), repo: OptionalString, workspace: OptionalString, diff --git a/src/main/runtime/rpc/methods/browser-schemas.ts b/src/main/runtime/rpc/methods/browser-schemas.ts index 1bb7c4cf984..a49376930ea 100644 --- a/src/main/runtime/rpc/methods/browser-schemas.ts +++ b/src/main/runtime/rpc/methods/browser-schemas.ts @@ -195,7 +195,7 @@ export const Check = BrowserTarget.extend({ checked: z .unknown() .optional() - .transform((v) => v !== false) + .transform((v) => (v === undefined ? true : v)) .pipe(z.boolean()) }) diff --git a/src/main/runtime/rpc/methods/browser.test.ts b/src/main/runtime/rpc/methods/browser.test.ts index da91eea03ac..974953ee3b5 100644 --- a/src/main/runtime/rpc/methods/browser.test.ts +++ b/src/main/runtime/rpc/methods/browser.test.ts @@ -213,4 +213,26 @@ describe('browser RPC methods', () => { value: 'enabled' }) }) + + it('rejects non-boolean browser check states instead of coercing them to true', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + browserCheck: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: BROWSER_CORE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('browser.check', { + page: 'page-1', + element: 'ref-1', + checked: 'false' + }) + ) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'invalid_argument' } + }) + expect(runtime.browserCheck).not.toHaveBeenCalled() + }) }) diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index 0cc3b22a71f..d9777620597 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -25,6 +25,7 @@ const WorktreeCardProperty = z.enum([ 'ports', 'inline-agents' ]) +const AgentActivityDisplayMode = z.enum(['compact', 'full']) const StatusBarItem = z.enum(['claude', 'codex', 'gemini', 'opencode-go', 'ssh', 'resource-usage']) const WorkspaceStatusDefinition = z.object({ id: z.string(), @@ -142,9 +143,10 @@ const UiUpdate = z uiZoomLevel: z.number().finite().optional(), editorFontZoomLevel: z.number().finite().optional(), worktreeCardProperties: z.array(WorktreeCardProperty).optional(), + agentActivityDisplayMode: AgentActivityDisplayMode.optional(), workspaceStatuses: z.array(WorkspaceStatusDefinition).optional(), workspaceBoardOpacity: z.number().finite().optional(), - workspaceBoardCompact: z.boolean().optional(), + workspaceBoardColumnLayout: z.enum(['full', 'fit']).optional(), workspaceBoardColumnWidth: z.number().finite().optional(), _workspaceStatusesDefaultOrderMigrated: z.boolean().optional(), _workspaceStatusesDefaultWorkflowMigrated: z.boolean().optional(), diff --git a/src/main/runtime/rpc/methods/file-watch-event-batcher.test.ts b/src/main/runtime/rpc/methods/file-watch-event-batcher.test.ts new file mode 100644 index 00000000000..75af9a11bd8 --- /dev/null +++ b/src/main/runtime/rpc/methods/file-watch-event-batcher.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest' +import type { FsChangeEvent } from '../../../../shared/types' +import { createFileWatchEventBatcher } from './file-watch-event-batcher' + +describe('createFileWatchEventBatcher', () => { + it('keeps precise watcher events while the batch is under the overflow limit', () => { + const emit = vi.fn() + const batcher = createFileWatchEventBatcher('worktree-1', emit) + const events: FsChangeEvent[] = [ + { kind: 'update', absolutePath: '/repo/file-a.ts' }, + { kind: 'delete', absolutePath: '/repo/file-b.ts' } + ] + + batcher.push(events) + batcher.flush() + + expect(emit).toHaveBeenCalledWith({ + type: 'changed', + worktree: 'worktree-1', + events + }) + }) + + it('collapses very large watcher bursts without spreading them onto the stack', () => { + vi.useFakeTimers() + try { + const emit = vi.fn() + const batcher = createFileWatchEventBatcher('worktree-1', emit) + const event: FsChangeEvent = { kind: 'update', absolutePath: '/repo/file.ts' } + const burst = Array.from({ length: 200_000 }, () => event) + + expect(() => batcher.push(burst)).not.toThrow() + batcher.flush() + + expect(emit).toHaveBeenCalledWith({ + type: 'changed', + worktree: 'worktree-1', + events: [{ kind: 'overflow', absolutePath: '/repo/file.ts' }] + }) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/runtime/rpc/methods/file-watch-event-batcher.ts b/src/main/runtime/rpc/methods/file-watch-event-batcher.ts index 146ed251b0b..de96b7b6855 100644 --- a/src/main/runtime/rpc/methods/file-watch-event-batcher.ts +++ b/src/main/runtime/rpc/methods/file-watch-event-batcher.ts @@ -2,6 +2,7 @@ import type { FsChangeEvent } from '../../../../shared/types' const FILE_WATCH_FLUSH_MS = 150 const FILE_WATCH_MAX_WAIT_MS = 500 +const MAX_FILE_WATCH_BATCH_EVENTS = 5_000 export function createFileWatchEventBatcher( worktree: string, @@ -12,6 +13,7 @@ export function createFileWatchEventBatcher( dispose: () => void } { let events: FsChangeEvent[] = [] + let overflowed = false let timer: ReturnType | null = null let firstEventAt = 0 @@ -26,6 +28,7 @@ export function createFileWatchEventBatcher( const flush = (): void => { clearTimer() const nextEvents = events.splice(0) + overflowed = false firstEventAt = 0 if (nextEvents.length === 0) { return @@ -38,7 +41,27 @@ export function createFileWatchEventBatcher( if (nextEvents.length === 0) { return } - events.push(...nextEvents) + if (!overflowed) { + const incomingOverflow = nextEvents.find((event) => event.kind === 'overflow') + if (incomingOverflow) { + events = [incomingOverflow] + overflowed = true + } else if (events.length + nextEvents.length > MAX_FILE_WATCH_BATCH_EVENTS) { + // Why: once precision is too expensive to retain and stream, one + // overflow asks clients to refresh safely without a huge payload. + events = [ + { + kind: 'overflow', + absolutePath: nextEvents[0]?.absolutePath ?? events[0]?.absolutePath ?? '' + } + ] + overflowed = true + } else { + for (const event of nextEvents) { + events.push(event) + } + } + } const now = Date.now() if (firstEventAt === 0) { firstEventAt = now @@ -59,6 +82,7 @@ export function createFileWatchEventBatcher( dispose(): void { clearTimer() events = [] + overflowed = false firstEventAt = 0 } } diff --git a/src/main/runtime/rpc/methods/files.test.ts b/src/main/runtime/rpc/methods/files.test.ts index d3c4edaea5a..9d91ca093c3 100644 --- a/src/main/runtime/rpc/methods/files.test.ts +++ b/src/main/runtime/rpc/methods/files.test.ts @@ -194,6 +194,53 @@ describe('file RPC methods', () => { expect(replies).toEqual([]) }) + it('drops queued file watch events when aborted before setup resolves', async () => { + vi.useFakeTimers() + try { + type WatchCallback = ( + events: { kind: 'update'; absolutePath: string; isDirectory?: boolean }[] + ) => void + const unwatch = vi.fn() + let resolveWatch: (value: () => void) => void = () => {} + const watchFileExplorer = vi.fn((_worktree: string, callback: WatchCallback) => { + callback([{ kind: 'update', absolutePath: '/repo/queued.ts', isDirectory: false }]) + return new Promise<() => void>((resolve) => { + resolveWatch = resolve + }) + }) + const runtime = { + getRuntimeId: () => 'test-runtime', + watchFileExplorer, + registerSubscriptionCleanup: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + const abortController = new AbortController() + const replies: unknown[] = [] + + const dispatch = dispatcher.dispatchStreaming( + makeRequest('files.watch', { worktree: 'id:wt-1' }), + (response) => replies.push(JSON.parse(response)), + { connectionId: 'conn-1', signal: abortController.signal } + ) + await vi.waitFor(() => { + expect(watchFileExplorer).toHaveBeenCalled() + }) + + abortController.abort() + await dispatch + await vi.runOnlyPendingTimersAsync() + resolveWatch(unwatch) + await vi.waitFor(() => { + expect(unwatch).toHaveBeenCalled() + }) + + expect(runtime.registerSubscriptionCleanup).not.toHaveBeenCalled() + expect(replies).toEqual([]) + } finally { + vi.useRealTimers() + } + }) + it('reads a relative file path for a selected worktree', async () => { const runtime = { getRuntimeId: () => 'test-runtime', @@ -391,6 +438,10 @@ describe('file RPC methods', () => { [ 'non-string content', { worktree: 'id:wt-1', relativePath: 'assets/logo.png', contentBase64: 0 } + ], + [ + 'malformed content', + { worktree: 'id:wt-1', relativePath: 'assets/logo.png', contentBase64: '!!!!' } ] ])('rejects a base64 write with %s', async (_name, params) => { const runtime = { @@ -456,6 +507,15 @@ describe('file RPC methods', () => { contentBase64: 0, append: true } + ], + [ + 'malformed content', + { + worktree: 'id:wt-1', + relativePath: 'assets/video.mov', + contentBase64: '!!!!', + append: true + } ] ])('rejects a base64 chunk write with %s (inherits the schema)', async (_name, params) => { const runtime = { diff --git a/src/main/runtime/rpc/methods/files.ts b/src/main/runtime/rpc/methods/files.ts index fbcf31936de..520a48cdfec 100644 --- a/src/main/runtime/rpc/methods/files.ts +++ b/src/main/runtime/rpc/methods/files.ts @@ -4,6 +4,13 @@ import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' import { createFileWatchEventBatcher } from './file-watch-event-batcher' let filesWatchSubscriptionSeq = 0 +const RUNTIME_FILE_BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/ + +function isValidRuntimeFileBase64(value: unknown): value is string { + return ( + typeof value === 'string' && value.length % 4 !== 1 && RUNTIME_FILE_BASE64_PATTERN.test(value) + ) +} const WorktreeSelector = z.object({ worktree: z @@ -43,6 +50,9 @@ const FileWriteBase64 = FileOpen.extend({ contentBase64: z .unknown() .refine((v): v is string => typeof v === 'string', { message: 'Missing file content' }) + // Why: Buffer.from(..., 'base64') accepts malformed input by dropping + // invalid bytes, which can silently create empty or corrupt uploaded files. + .refine(isValidRuntimeFileBase64, 'File content must be base64') }) const FileWriteBase64Chunk = FileWriteBase64.extend({ @@ -290,6 +300,9 @@ export const FILE_METHODS: RpcAnyMethod[] = [ if (unwatch) { cleanup() } else { + // Why: watch setup may have queued events before resolving its + // unwatch callback. Dispose that transient batcher on early abort. + eventBatcher.dispose() finish() } } @@ -303,6 +316,7 @@ export const FILE_METHODS: RpcAnyMethod[] = [ // Why: the connection can close while watch setup is still // resolving. Tear down the late watcher immediately instead of // registering cleanup on a connection that was already reaped. + eventBatcher.dispose() nextUnwatch() return } diff --git a/src/main/runtime/rpc/methods/github.test.ts b/src/main/runtime/rpc/methods/github.test.ts index 37bf218a10f..1311b52a4a1 100644 --- a/src/main/runtime/rpc/methods/github.test.ts +++ b/src/main/runtime/rpc/methods/github.test.ts @@ -461,6 +461,30 @@ describe('github RPC methods', () => { expect(response).toMatchObject({ ok: true, result: { ok: true, number: 3 } }) }) + it('creates issues with metadata on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + createRepoIssue: vi.fn().mockResolvedValue({ ok: true, number: 4, url: 'https://gh/4' }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.createIssue', { + repo: 'repo-1', + title: 'Bug', + body: 'Body', + labels: ['bug'], + assignees: ['octo'] + }) + ) + + expect(runtime.createRepoIssue).toHaveBeenCalledWith('repo-1', 'Bug', 'Body', { + labels: ['bug'], + assignees: ['octo'] + }) + expect(response).toMatchObject({ ok: true, result: { ok: true, number: 4 } }) + }) + it('updates issues on the runtime server', async () => { const runtime = { getRuntimeId: () => 'test-runtime', @@ -495,11 +519,15 @@ describe('github RPC methods', () => { repo: 'repo-1', number: 3, body: 'Looks good', - type: 'pr' + type: 'pr', + prRepo: { owner: 'acme', repo: 'widgets' } }) ) - expect(runtime.addRepoIssueComment).toHaveBeenCalledWith('repo-1', 3, 'Looks good') + expect(runtime.addRepoIssueComment).toHaveBeenCalledWith('repo-1', 3, 'Looks good', { + owner: 'acme', + repo: 'widgets' + }) expect(response).toMatchObject({ ok: true, result: { ok: true, comment: { id: 1 } } }) }) @@ -548,7 +576,8 @@ describe('github RPC methods', () => { body: 'Done', threadId: 'PRRT_1', path: 'src/app.ts', - line: 12 + line: 12, + prRepo: { owner: 'acme', repo: 'widgets' } }) ) @@ -558,7 +587,8 @@ describe('github RPC methods', () => { body: 'Done', threadId: 'PRRT_1', path: 'src/app.ts', - line: 12 + line: 12, + prRepo: { owner: 'acme', repo: 'widgets' } }) expect(response).toMatchObject({ ok: true, result: { ok: true, comment: { id: 4 } } }) }) diff --git a/src/main/runtime/rpc/methods/github.ts b/src/main/runtime/rpc/methods/github.ts index ed48d4c6a53..4c6db9b58d6 100644 --- a/src/main/runtime/rpc/methods/github.ts +++ b/src/main/runtime/rpc/methods/github.ts @@ -148,7 +148,9 @@ const RemovePrReviewers = RepoSelector.extend({ const CreateIssue = RepoSelector.extend({ title: requiredString('Missing title'), - body: z.string() + body: z.string(), + labels: z.array(z.string()).optional(), + assignees: z.array(z.string()).optional() }) const IssueUpdate = z.object({ @@ -169,7 +171,8 @@ const UpdateIssue = RepoSelector.extend({ const IssueComment = RepoSelector.extend({ number: z.number().int().positive(), body: requiredString('Comment body required'), - type: z.enum(['issue', 'pr']).optional() + type: z.enum(['issue', 'pr']).optional(), + prRepo: SlugRepo.nullable().optional() }) const PRReviewComment = RepoSelector.extend({ @@ -187,7 +190,8 @@ const PRReviewCommentReply = RepoSelector.extend({ body: requiredString('Comment body required'), threadId: OptionalString, path: OptionalString, - line: z.number().int().positive().optional() + line: z.number().int().positive().optional(), + prRepo: SlugRepo.nullable().optional() }) const ProjectOwnerType = z.enum(['organization', 'user']) @@ -478,8 +482,15 @@ export const GITHUB_METHODS: RpcMethod[] = [ defineMethod({ name: 'github.createIssue', params: CreateIssue, - handler: async (params, { runtime }) => - runtime.createRepoIssue(params.repo, params.title, params.body) + handler: async (params, { runtime }) => { + const fields = + params.labels !== undefined || params.assignees !== undefined + ? { labels: params.labels, assignees: params.assignees } + : undefined + return fields + ? runtime.createRepoIssue(params.repo, params.title, params.body, fields) + : runtime.createRepoIssue(params.repo, params.title, params.body) + } }), defineMethod({ name: 'github.updateIssue', @@ -491,7 +502,7 @@ export const GITHUB_METHODS: RpcMethod[] = [ name: 'github.addIssueComment', params: IssueComment, handler: async (params, { runtime }) => - runtime.addRepoIssueComment(params.repo, params.number, params.body) + runtime.addRepoIssueComment(params.repo, params.number, params.body, params.prRepo ?? null) }), defineMethod({ name: 'github.addPRReviewComment', @@ -516,7 +527,8 @@ export const GITHUB_METHODS: RpcMethod[] = [ body: params.body, threadId: params.threadId, path: params.path, - line: params.line + line: params.line, + prRepo: params.prRepo ?? null }) }), defineMethod({ diff --git a/src/main/runtime/rpc/methods/gitlab.test.ts b/src/main/runtime/rpc/methods/gitlab.test.ts index 03b38b42162..cb88fbff7bb 100644 --- a/src/main/runtime/rpc/methods/gitlab.test.ts +++ b/src/main/runtime/rpc/methods/gitlab.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Why: one routing test exercises the full GitLab RPC surface so provider parity regressions show up in a single contract fixture. */ import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' @@ -12,23 +13,35 @@ describe('gitlab RPC methods', () => { it('routes GitLab task queries and mutations to the runtime server', async () => { const runtime = { getRuntimeId: () => 'test-runtime', + diagnoseGitLabAuth: vi.fn().mockResolvedValue({ glabAvailable: true }), + getGitLabRateLimit: vi.fn().mockResolvedValue({ ok: true }), listGitLabRepoMRs: vi.fn().mockResolvedValue({ items: [] }), listGitLabRepoWorkItems: vi.fn().mockResolvedValue({ items: [] }), listGitLabRepoIssues: vi.fn().mockResolvedValue({ items: [] }), listGitLabRepoTodos: vi.fn().mockResolvedValue([{ id: 1 }]), + listGitLabRepoLabels: vi.fn().mockResolvedValue(['bug']), createGitLabRepoIssue: vi.fn().mockResolvedValue({ ok: true, number: 7 }), updateGitLabRepoIssue: vi.fn().mockResolvedValue({ ok: true }), addGitLabRepoIssueComment: vi.fn().mockResolvedValue({ ok: true }), addGitLabRepoMRComment: vi.fn().mockResolvedValue({ ok: true }), + addGitLabRepoMRInlineComment: vi.fn().mockResolvedValue({ ok: true }), + resolveGitLabRepoMRDiscussion: vi.fn().mockResolvedValue({ ok: true }), + getGitLabRepoJobTrace: vi.fn().mockResolvedValue({ ok: true, trace: 'log' }), + retryGitLabRepoJob: vi.fn().mockResolvedValue({ ok: true }), mergeGitLabRepoMR: vi.fn().mockResolvedValue({ ok: true }), updateGitLabRepoMRState: vi.fn().mockResolvedValue({ ok: true }), updateGitLabRepoMR: vi.fn().mockResolvedValue({ ok: true }), + updateGitLabRepoMRReviewers: vi.fn().mockResolvedValue({ ok: true, reviewers: [] }), getGitLabRepoWorkItemDetails: vi.fn().mockResolvedValue({ body: 'Details' }), getGitLabRepoWorkItemByPath: vi.fn().mockResolvedValue({ id: 'gitlab-issue-7' }) } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: GITLAB_METHODS }) const projectRef = { host: 'gitlab.example.com', path: 'group/project' } + await dispatcher.dispatch(makeRequest('gitlab.diagnoseAuth')) + await dispatcher.dispatch( + makeRequest('gitlab.rateLimit', { force: true, host: 'gitlab.example.com' }) + ) await dispatcher.dispatch( makeRequest('gitlab.listMRs', { repo: 'id:repo-1', @@ -63,6 +76,7 @@ describe('gitlab RPC methods', () => { }) ) await dispatcher.dispatch(makeRequest('gitlab.todos', { repo: 'id:repo-1' })) + await dispatcher.dispatch(makeRequest('gitlab.listLabels', { repo: 'id:repo-1' })) await dispatcher.dispatch( makeRequest('gitlab.updateIssue', { repo: 'id:repo-1', @@ -87,6 +101,45 @@ describe('gitlab RPC methods', () => { projectRef }) ) + const inlineInput = { + body: 'please fix', + path: 'src/app.ts', + line: 12, + baseSha: 'base', + startSha: 'start', + headSha: 'head' + } + await dispatcher.dispatch( + makeRequest('gitlab.addMRInlineComment', { + repo: 'id:repo-1', + iid: 8, + input: inlineInput, + projectRef + }) + ) + await dispatcher.dispatch( + makeRequest('gitlab.resolveMRDiscussion', { + repo: 'id:repo-1', + iid: 8, + discussionId: 'discussion-1', + resolved: true, + projectRef + }) + ) + await dispatcher.dispatch( + makeRequest('gitlab.jobTrace', { + repo: 'id:repo-1', + jobId: 99, + projectRef + }) + ) + await dispatcher.dispatch( + makeRequest('gitlab.retryJob', { + repo: 'id:repo-1', + jobId: 99, + projectRef + }) + ) await dispatcher.dispatch( makeRequest('gitlab.mergeMR', { repo: 'id:repo-1', @@ -111,6 +164,14 @@ describe('gitlab RPC methods', () => { projectRef }) ) + await dispatcher.dispatch( + makeRequest('gitlab.updateMRReviewers', { + repo: 'id:repo-1', + iid: 8, + reviewerIds: [1, 2], + projectRef + }) + ) await dispatcher.dispatch( makeRequest('gitlab.workItemDetails', { repo: 'id:repo-1', @@ -129,6 +190,11 @@ describe('gitlab RPC methods', () => { }) ) + expect(runtime.diagnoseGitLabAuth).toHaveBeenCalledWith() + expect(runtime.getGitLabRateLimit).toHaveBeenCalledWith({ + force: true, + host: 'gitlab.example.com' + }) expect(runtime.listGitLabRepoMRs).toHaveBeenCalledWith('id:repo-1', 'opened', 1, 25, 'bug') expect(runtime.listGitLabRepoWorkItems).toHaveBeenCalledWith( 'id:repo-1', @@ -140,6 +206,7 @@ describe('gitlab RPC methods', () => { expect(runtime.listGitLabRepoIssues).toHaveBeenCalledWith('id:repo-1', 'opened', '@me', 50) expect(runtime.createGitLabRepoIssue).toHaveBeenCalledWith('id:repo-1', 'Fix bug', 'Details') expect(runtime.listGitLabRepoTodos).toHaveBeenCalledWith('id:repo-1') + expect(runtime.listGitLabRepoLabels).toHaveBeenCalledWith('id:repo-1') expect(runtime.updateGitLabRepoIssue).toHaveBeenCalledWith( 'id:repo-1', 7, @@ -162,6 +229,21 @@ describe('gitlab RPC methods', () => { 'ship it', projectRef ) + expect(runtime.addGitLabRepoMRInlineComment).toHaveBeenCalledWith( + 'id:repo-1', + 8, + inlineInput, + projectRef + ) + expect(runtime.resolveGitLabRepoMRDiscussion).toHaveBeenCalledWith( + 'id:repo-1', + 8, + 'discussion-1', + true, + projectRef + ) + expect(runtime.getGitLabRepoJobTrace).toHaveBeenCalledWith('id:repo-1', 99, projectRef) + expect(runtime.retryGitLabRepoJob).toHaveBeenCalledWith('id:repo-1', 99, projectRef) expect(runtime.mergeGitLabRepoMR).toHaveBeenCalledWith('id:repo-1', 8, 'squash', projectRef) expect(runtime.updateGitLabRepoMRState).toHaveBeenCalledWith( 'id:repo-1', @@ -179,6 +261,12 @@ describe('gitlab RPC methods', () => { }, projectRef ) + expect(runtime.updateGitLabRepoMRReviewers).toHaveBeenCalledWith( + 'id:repo-1', + 8, + [1, 2], + projectRef + ) expect(runtime.getGitLabRepoWorkItemDetails).toHaveBeenCalledWith( 'id:repo-1', 8, diff --git a/src/main/runtime/rpc/methods/gitlab.ts b/src/main/runtime/rpc/methods/gitlab.ts index b7243fcd6fa..c10a7eee7b5 100644 --- a/src/main/runtime/rpc/methods/gitlab.ts +++ b/src/main/runtime/rpc/methods/gitlab.ts @@ -7,6 +7,15 @@ const RepoSelector = z.object({ repo: requiredString('Missing repo selector') }) +const EmptyParams = z.object({}).optional().default({}) +const GitLabRateLimit = z + .object({ + force: z.boolean().optional(), + host: OptionalString + }) + .optional() + .default({}) + const GitLabProjectRef = z .object({ host: requiredString('Missing GitLab host'), @@ -65,6 +74,12 @@ const UpdateMr = RepoSelector.extend({ projectRef: GitLabProjectRef }) +const UpdateMrReviewers = RepoSelector.extend({ + iid: z.number().int().positive(), + reviewerIds: z.array(z.number().int().nonnegative()), + projectRef: GitLabProjectRef +}) + const MergeMr = RepoSelector.extend({ iid: z.number().int().positive(), method: z.enum(['merge', 'squash', 'rebase']).optional(), @@ -83,6 +98,37 @@ const AddMRComment = RepoSelector.extend({ projectRef: GitLabProjectRef }) +const AddMRInlineComment = RepoSelector.extend({ + iid: z.number().int().positive(), + input: z.object({ + body: requiredString('Comment body is required'), + path: requiredString('File path is required'), + oldPath: z.string().optional(), + line: z.number().int().positive(), + baseSha: requiredString('Base SHA is required'), + startSha: requiredString('Start SHA is required'), + headSha: requiredString('Head SHA is required') + }), + projectRef: GitLabProjectRef +}) + +const ResolveMRDiscussion = RepoSelector.extend({ + iid: z.number().int().positive(), + discussionId: requiredString('Discussion id is required'), + resolved: z.boolean(), + projectRef: GitLabProjectRef +}) + +const JobTrace = RepoSelector.extend({ + jobId: z.number().int().positive(), + projectRef: GitLabProjectRef +}) + +const RetryJob = RepoSelector.extend({ + jobId: z.number().int().positive(), + projectRef: GitLabProjectRef +}) + const WorkItemDetails = RepoSelector.extend({ iid: z.number().int().positive(), type: z.enum(['issue', 'mr']), @@ -139,6 +185,21 @@ export const GITLAB_METHODS: RpcMethod[] = [ params: RepoSelector, handler: async (params, { runtime }) => runtime.listGitLabRepoTodos(params.repo) }), + defineMethod({ + name: 'gitlab.diagnoseAuth', + params: EmptyParams, + handler: async (_params, { runtime }) => runtime.diagnoseGitLabAuth() + }), + defineMethod({ + name: 'gitlab.rateLimit', + params: GitLabRateLimit, + handler: async (params, { runtime }) => runtime.getGitLabRateLimit(params) + }), + defineMethod({ + name: 'gitlab.listLabels', + params: RepoSelector, + handler: async (params, { runtime }) => runtime.listGitLabRepoLabels(params.repo) + }), defineMethod({ name: 'gitlab.createIssue', params: CreateIssue, @@ -163,6 +224,36 @@ export const GITLAB_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.addGitLabRepoMRComment(params.repo, params.iid, params.body, params.projectRef) }), + defineMethod({ + name: 'gitlab.addMRInlineComment', + params: AddMRInlineComment, + handler: async (params, { runtime }) => + runtime.addGitLabRepoMRInlineComment(params.repo, params.iid, params.input, params.projectRef) + }), + defineMethod({ + name: 'gitlab.resolveMRDiscussion', + params: ResolveMRDiscussion, + handler: async (params, { runtime }) => + runtime.resolveGitLabRepoMRDiscussion( + params.repo, + params.iid, + params.discussionId, + params.resolved, + params.projectRef + ) + }), + defineMethod({ + name: 'gitlab.jobTrace', + params: JobTrace, + handler: async (params, { runtime }) => + runtime.getGitLabRepoJobTrace(params.repo, params.jobId, params.projectRef) + }), + defineMethod({ + name: 'gitlab.retryJob', + params: RetryJob, + handler: async (params, { runtime }) => + runtime.retryGitLabRepoJob(params.repo, params.jobId, params.projectRef) + }), defineMethod({ name: 'gitlab.mergeMR', params: MergeMr, @@ -181,6 +272,17 @@ export const GITLAB_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.updateGitLabRepoMR(params.repo, params.iid, params.updates, params.projectRef) }), + defineMethod({ + name: 'gitlab.updateMRReviewers', + params: UpdateMrReviewers, + handler: async (params, { runtime }) => + runtime.updateGitLabRepoMRReviewers( + params.repo, + params.iid, + params.reviewerIds, + params.projectRef + ) + }), defineMethod({ name: 'gitlab.workItemDetails', params: WorkItemDetails, diff --git a/src/main/runtime/rpc/methods/host-capabilities.test.ts b/src/main/runtime/rpc/methods/host-capabilities.test.ts index 9e5a6d23ca1..64ff48e69a2 100644 --- a/src/main/runtime/rpc/methods/host-capabilities.test.ts +++ b/src/main/runtime/rpc/methods/host-capabilities.test.ts @@ -3,14 +3,16 @@ import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' import type { OrcaRuntimeService } from '../../orca-runtime' -const { isPwshAvailable, isWslAvailable, listWslDistros } = vi.hoisted(() => ({ +const { isPwshAvailable, isWslAvailable, listWslDistros, isGitBashAvailable } = vi.hoisted(() => ({ isPwshAvailable: vi.fn(), isWslAvailable: vi.fn(), - listWslDistros: vi.fn() + listWslDistros: vi.fn(), + isGitBashAvailable: vi.fn() })) vi.mock('../../../pwsh', () => ({ isPwshAvailable })) vi.mock('../../../wsl', () => ({ isWslAvailable, listWslDistros })) +vi.mock('../../../git-bash', () => ({ isGitBashAvailable })) import { HOST_CAPABILITY_METHODS } from './host-capabilities' @@ -23,12 +25,14 @@ describe('host capability RPC methods', () => { isPwshAvailable.mockReset() isWslAvailable.mockReset() listWslDistros.mockReset() + isGitBashAvailable.mockReset() }) it('reports Windows shell capability probes through explicit methods', async () => { isPwshAvailable.mockReturnValue(true) isWslAvailable.mockReturnValue(true) listWslDistros.mockReturnValue(['Ubuntu']) + isGitBashAvailable.mockReturnValue(true) const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: HOST_CAPABILITY_METHODS }) @@ -44,5 +48,11 @@ describe('host capability RPC methods', () => { ok: true, result: ['Ubuntu'] }) + await expect( + dispatcher.dispatch(makeRequest('host.gitBash.isAvailable')) + ).resolves.toMatchObject({ + ok: true, + result: true + }) }) }) diff --git a/src/main/runtime/rpc/methods/host-capabilities.ts b/src/main/runtime/rpc/methods/host-capabilities.ts index 9d165cd7da2..be5e13dc2f0 100644 --- a/src/main/runtime/rpc/methods/host-capabilities.ts +++ b/src/main/runtime/rpc/methods/host-capabilities.ts @@ -1,6 +1,7 @@ import { defineMethod, type RpcMethod } from '../core' import { isPwshAvailable } from '../../../pwsh' import { isWslAvailable, listWslDistros } from '../../../wsl' +import { isGitBashAvailable } from '../../../git-bash' export const HOST_CAPABILITY_METHODS: RpcMethod[] = [ defineMethod({ @@ -22,5 +23,10 @@ export const HOST_CAPABILITY_METHODS: RpcMethod[] = [ name: 'host.pwsh.isAvailable', params: null, handler: async () => isPwshAvailable() + }), + defineMethod({ + name: 'host.gitBash.isAvailable', + params: null, + handler: async () => isGitBashAvailable() }) ] diff --git a/src/main/runtime/rpc/methods/linear.test.ts b/src/main/runtime/rpc/methods/linear.test.ts index 5e1ee00f872..125c128147c 100644 --- a/src/main/runtime/rpc/methods/linear.test.ts +++ b/src/main/runtime/rpc/methods/linear.test.ts @@ -155,8 +155,14 @@ describe('linear RPC methods', () => { it('routes Linear metadata requests to the runtime server', async () => { const runtime = { getRuntimeId: () => 'test-runtime', + linearGetCustomView: vi.fn().mockResolvedValue({ id: 'view-1' }), + linearGetProject: vi.fn().mockResolvedValue({ id: 'project-1' }), + linearListCustomViewIssues: vi.fn().mockResolvedValue({ items: [{ id: 'issue-1' }] }), + linearListCustomViewProjects: vi.fn().mockResolvedValue({ items: [{ id: 'project-2' }] }), + linearListCustomViews: vi.fn().mockResolvedValue({ items: [{ id: 'view-1' }] }), + linearListProjectIssues: vi.fn().mockResolvedValue({ items: [{ id: 'issue-2' }] }), linearListTeams: vi.fn().mockResolvedValue([{ id: 'team-1' }]), - linearListProjects: vi.fn().mockResolvedValue([{ id: 'project-1' }]), + linearListProjects: vi.fn().mockResolvedValue({ items: [{ id: 'project-1' }] }), linearTeamStates: vi.fn().mockResolvedValue([{ id: 'state-1' }]), linearTeamLabels: vi.fn().mockResolvedValue([{ id: 'label-1' }]), linearTeamMembers: vi.fn().mockResolvedValue([{ id: 'member-1' }]) @@ -165,6 +171,44 @@ describe('linear RPC methods', () => { await dispatcher.dispatch(makeRequest('linear.listTeams', { workspaceId: 'all' })) await dispatcher.dispatch(makeRequest('linear.listProjects', { query: 'roadmap', limit: 5 })) + await dispatcher.dispatch( + makeRequest('linear.getProject', { id: 'project-1', workspaceId: 'workspace-1' }) + ) + await dispatcher.dispatch( + makeRequest('linear.listProjectIssues', { + projectId: 'project-1', + limit: 10, + workspaceId: 'workspace-1' + }) + ) + await dispatcher.dispatch( + makeRequest('linear.listCustomViews', { + model: 'project', + limit: 10, + workspaceId: 'workspace-1' + }) + ) + await dispatcher.dispatch( + makeRequest('linear.getCustomView', { + viewId: 'view-1', + model: 'project', + workspaceId: 'workspace-1' + }) + ) + await dispatcher.dispatch( + makeRequest('linear.listCustomViewIssues', { + viewId: 'view-1', + limit: 10, + workspaceId: 'workspace-1' + }) + ) + await dispatcher.dispatch( + makeRequest('linear.listCustomViewProjects', { + viewId: 'view-2', + limit: 10, + workspaceId: 'workspace-1' + }) + ) await dispatcher.dispatch( makeRequest('linear.teamStates', { teamId: 'team-1', workspaceId: 'workspace-1' }) ) @@ -177,6 +221,12 @@ describe('linear RPC methods', () => { expect(runtime.linearListTeams).toHaveBeenCalledWith('all') expect(runtime.linearListProjects).toHaveBeenCalledWith('roadmap', 5, undefined) + expect(runtime.linearGetProject).toHaveBeenCalledWith('project-1', 'workspace-1') + expect(runtime.linearListProjectIssues).toHaveBeenCalledWith('project-1', 10, 'workspace-1') + expect(runtime.linearListCustomViews).toHaveBeenCalledWith('project', 10, 'workspace-1') + expect(runtime.linearGetCustomView).toHaveBeenCalledWith('view-1', 'project', 'workspace-1') + expect(runtime.linearListCustomViewIssues).toHaveBeenCalledWith('view-1', 10, 'workspace-1') + expect(runtime.linearListCustomViewProjects).toHaveBeenCalledWith('view-2', 10, 'workspace-1') expect(runtime.linearTeamStates).toHaveBeenCalledWith('team-1', 'workspace-1') expect(runtime.linearTeamLabels).toHaveBeenCalledWith('team-1', 'workspace-1') expect(runtime.linearTeamMembers).toHaveBeenCalledWith('team-1', 'workspace-1') diff --git a/src/main/runtime/rpc/methods/linear.ts b/src/main/runtime/rpc/methods/linear.ts index bf661e3532d..72162351dc7 100644 --- a/src/main/runtime/rpc/methods/linear.ts +++ b/src/main/runtime/rpc/methods/linear.ts @@ -3,6 +3,7 @@ import { defineMethod, type RpcMethod } from '../core' import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' const VALID_FILTERS = ['assigned', 'created', 'all', 'completed'] as const +const VALID_CUSTOM_VIEW_MODELS = ['issue', 'project'] as const const LinearPriority = z.number().int().min(0).max(4).optional() const LinearLabelIds = z.array(requiredString('Invalid label ID')).optional() @@ -16,6 +17,11 @@ const WorkspaceSelection = z }) .optional() +const ConcreteWorkspaceId = requiredString('Concrete Linear workspace ID is required').refine( + (value) => value !== 'all', + 'Concrete Linear workspace ID is required' +) + const SelectWorkspace = z.object({ workspaceId: requiredString('Workspace ID is required') }) @@ -66,6 +72,35 @@ const ListProjects = z }) .optional() +const ProjectId = z.object({ + id: requiredString('Project ID is required'), + workspaceId: ConcreteWorkspaceId +}) + +const ProjectIssues = z.object({ + projectId: requiredString('Project ID is required'), + limit: OptionalFiniteNumber, + workspaceId: ConcreteWorkspaceId +}) + +const ListCustomViews = z.object({ + model: z.enum(VALID_CUSTOM_VIEW_MODELS), + limit: OptionalFiniteNumber, + workspaceId: OptionalString +}) + +const CustomViewId = z.object({ + viewId: requiredString('Custom view ID is required'), + model: z.enum(VALID_CUSTOM_VIEW_MODELS), + workspaceId: ConcreteWorkspaceId +}) + +const CustomViewContents = z.object({ + viewId: requiredString('Custom view ID is required'), + limit: OptionalFiniteNumber, + workspaceId: ConcreteWorkspaceId +}) + const TeamId = z.object({ teamId: requiredString('Team ID is required'), workspaceId: OptionalString @@ -181,6 +216,54 @@ export const LINEAR_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.linearListProjects(params?.query, params?.limit, params?.workspaceId) }), + defineMethod({ + name: 'linear.getProject', + params: ProjectId, + handler: async (params, { runtime }) => + runtime.linearGetProject(params.id.trim(), params.workspaceId.trim()) + }), + defineMethod({ + name: 'linear.listProjectIssues', + params: ProjectIssues, + handler: async (params, { runtime }) => + runtime.linearListProjectIssues( + params.projectId.trim(), + params.limit, + params.workspaceId.trim() + ) + }), + defineMethod({ + name: 'linear.listCustomViews', + params: ListCustomViews, + handler: async (params, { runtime }) => + runtime.linearListCustomViews(params.model, params.limit, params.workspaceId) + }), + defineMethod({ + name: 'linear.getCustomView', + params: CustomViewId, + handler: async (params, { runtime }) => + runtime.linearGetCustomView(params.viewId.trim(), params.model, params.workspaceId.trim()) + }), + defineMethod({ + name: 'linear.listCustomViewIssues', + params: CustomViewContents, + handler: async (params, { runtime }) => + runtime.linearListCustomViewIssues( + params.viewId.trim(), + params.limit, + params.workspaceId.trim() + ) + }), + defineMethod({ + name: 'linear.listCustomViewProjects', + params: CustomViewContents, + handler: async (params, { runtime }) => + runtime.linearListCustomViewProjects( + params.viewId.trim(), + params.limit, + params.workspaceId.trim() + ) + }), defineMethod({ name: 'linear.teamStates', params: TeamId, diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index aa5121f5f93..26e2a1c3675 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -8,18 +8,27 @@ import type { RuntimeTerminalSummary } from '../../../../shared/runtime-types' describe('orchestration RPC methods', () => { let db: OrchestrationDb + let dbOpen = false let runtime: OrcaRuntimeService let ctx: RpcContext function setup(): void { db = new OrchestrationDb(':memory:') + dbOpen = true runtime = new OrcaRuntimeService() runtime.setOrchestrationDb(db) ctx = { runtime } } afterEach(() => { - db?.close() + if (!dbOpen) { + return + } + const currentDb = db + // Why: parser-only tests do not call setup(), so cleanup must not reuse + // the previous test's already-closed in-memory DB. + dbOpen = false + currentDb.close() }) function findMethod(name: string) { @@ -310,6 +319,23 @@ describe('orchestration RPC methods', () => { expect(stillUnread).toHaveLength(1) }) + it('--all applies type filters without marking rows as read', async () => { + setup() + db.insertMessage({ from: 'a', to: 'b', subject: 'status', type: 'status' }) + db.insertMessage({ from: 'a', to: 'b', subject: 'dispatch', type: 'dispatch' }) + db.insertMessage({ from: 'a', to: 'b', subject: 'done', type: 'worker_done' }) + + const result = (await call('orchestration.check', { + terminal: 'b', + all: true, + types: 'worker_done,dispatch' + })) as { messages: { type: string }[]; count: number } + + expect(result.count).toBe(2) + expect(result.messages.map((m) => m.type).sort()).toEqual(['dispatch', 'worker_done']) + expect(db.getUnreadMessages('b')).toHaveLength(3) + }) + it('--all returns rows with delivered_at set after push-on-idle stamped them', async () => { setup() const msg = db.insertMessage({ from: 'a', to: 'b', subject: 'hi' }) @@ -949,6 +975,38 @@ describe('orchestration RPC methods', () => { expect(outbound).toBeTruthy() }) + it('returns promptly when the RPC signal aborts while waiting', async () => { + setup() + vi.useFakeTimers() + const controller = new AbortController() + const method = findMethod('orchestration.ask') + const parsed = method.params!.parse({ + from: 'term_worker', + to: 'term_coord', + question: 'still there?', + timeoutMs: 60_000 + }) + + try { + const promise = method.handler(parsed, { + runtime, + signal: controller.signal + }) as Promise<{ timedOut: boolean }> + + controller.abort() + const outcomePromise = Promise.race([ + promise.then((result) => (result.timedOut ? 'aborted' : 'answered')), + new Promise<'pending'>((resolve) => setTimeout(() => resolve('pending'), 0)) + ]) + await vi.advanceTimersByTimeAsync(0) + const outcome = await outcomePromise + + expect(outcome).toBe('aborted') + } finally { + vi.useRealTimers() + } + }) + it('rejects group addresses with a dedicated error (no message persisted)', async () => { setup() await expect( diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index 7584ef6aafa..d6d72a8ceea 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -240,7 +240,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ const readAndReturn = () => { const messages = showUnread ? db.getUnreadMessages(handle, typeFilter) - : db.getAllMessagesForHandle(handle) + : db.getAllMessagesForHandle(handle, undefined, typeFilter) if (showUnread && messages.length > 0) { db.markAsRead(messages.map((m) => m.id)) @@ -511,7 +511,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ defineMethod({ name: 'orchestration.ask', params: AskParams, - handler: async (params, { runtime }) => { + handler: async (params, { runtime, signal }) => { // Why: group addresses have no unambiguous answer semantics (whose // reply wins? first? consensus?) and the ~60-LOC scope is not the // place to design that. Rejecting here closes the silent-timeout @@ -566,11 +566,16 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ timedOut: false } } + if (signal?.aborted) { + return { answer: null, messageId: null, threadId, timedOut: true } + } const remainingMs = deadline - Date.now() if (remainingMs <= 0) { return { answer: null, messageId: null, threadId, timedOut: true } } - await runtime.waitForMessage(from, { timeoutMs: remainingMs }) + // Why: if the asking client disconnects, release the waiter immediately + // while leaving the already-sent decision gate visible to the recipient. + await runtime.waitForMessage(from, { timeoutMs: remainingMs, signal }) } } }), diff --git a/src/main/runtime/rpc/methods/repo.ts b/src/main/runtime/rpc/methods/repo.ts index 611ed827ac5..83a2b6061da 100644 --- a/src/main/runtime/rpc/methods/repo.ts +++ b/src/main/runtime/rpc/methods/repo.ts @@ -54,6 +54,7 @@ const RepoUpdate = RepoSelector.extend({ .optional(), hookSettings: z.unknown().optional(), worktreeBaseRef: OptionalString, + worktreeBasePath: OptionalString, kind: z.enum(['git', 'folder']).optional(), symlinkPaths: z.array(z.string()).optional(), issueSourcePreference: z.enum(['auto', 'upstream', 'origin']).optional(), diff --git a/src/main/runtime/rpc/methods/speech.test.ts b/src/main/runtime/rpc/methods/speech.test.ts new file mode 100644 index 00000000000..4f31ee386cb --- /dev/null +++ b/src/main/runtime/rpc/methods/speech.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { SPEECH_METHODS } from './speech' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('speech RPC methods', () => { + it('feeds valid base64 dictation chunks to the runtime', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + feedMobileDictation: vi.fn().mockReturnValue({ dictationId: 'dict-1' }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SPEECH_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('speech.dictation.chunk', { + dictationId: 'dict-1', + audioBase64: 'AAAA', + sampleRate: 16_000 + }) + ) + + expect(response).toMatchObject({ ok: true, result: { dictationId: 'dict-1' } }) + expect(runtime.feedMobileDictation).toHaveBeenCalledWith({ + dictationId: 'dict-1', + audioBase64: 'AAAA', + sampleRate: 16_000, + clientId: undefined, + connectionId: undefined + }) + }) + + it('rejects malformed base64 dictation chunks before feeding audio', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + feedMobileDictation: vi.fn().mockReturnValue({ dictationId: 'dict-1' }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SPEECH_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('speech.dictation.chunk', { + dictationId: 'dict-1', + audioBase64: '!!!!', + sampleRate: 16_000 + }) + ) + + expect(response).toMatchObject({ ok: false }) + expect(runtime.feedMobileDictation).not.toHaveBeenCalled() + }) + + it('rejects oversized dictation chunks before decoding audio', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + feedMobileDictation: vi.fn().mockReturnValue({ dictationId: 'dict-1' }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SPEECH_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('speech.dictation.chunk', { + dictationId: 'dict-1', + audioBase64: 'A'.repeat(Math.ceil((16_000 * 2 * 5) / 3) * 4 + 1), + sampleRate: 16_000 + }) + ) + + expect(response).toMatchObject({ ok: false }) + expect(runtime.feedMobileDictation).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/speech.ts b/src/main/runtime/rpc/methods/speech.ts index f73386e6176..8e1ceafd5eb 100644 --- a/src/main/runtime/rpc/methods/speech.ts +++ b/src/main/runtime/rpc/methods/speech.ts @@ -2,6 +2,18 @@ import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' import { OptionalString, requiredString } from '../schemas' +const AUDIO_BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/ +const DICTATION_SAMPLE_RATE = 16_000 +const PCM_BYTES_PER_SAMPLE = 2 +const MAX_DICTATION_AUDIO_SECONDS = 5 +const MAX_DICTATION_AUDIO_CHUNK_BYTES = + DICTATION_SAMPLE_RATE * PCM_BYTES_PER_SAMPLE * MAX_DICTATION_AUDIO_SECONDS +const MAX_DICTATION_AUDIO_CHUNK_BASE64_LENGTH = Math.ceil(MAX_DICTATION_AUDIO_CHUNK_BYTES / 3) * 4 + +function isValidAudioBase64(value: string): boolean { + return value.length % 4 !== 1 && AUDIO_BASE64_PATTERN.test(value) +} + const DictationStart = z.object({ dictationId: requiredString('Missing dictation ID'), modelId: OptionalString @@ -9,7 +21,16 @@ const DictationStart = z.object({ const DictationChunk = z.object({ dictationId: requiredString('Missing dictation ID'), - audioBase64: requiredString('Missing audio chunk'), + audioBase64: requiredString('Missing audio chunk') + // Why: feedMobileDictation decodes into Buffer + Float32Array; reject + // oversized chunks before allocation. This mirrors the mobile pending-audio budget. + .refine( + (value) => value.length <= MAX_DICTATION_AUDIO_CHUNK_BASE64_LENGTH, + 'Audio chunk is too large' + ) + // Why: Buffer.from(..., 'base64') silently drops malformed bytes; reject + // bad mobile audio chunks instead of feeding empty/corrupt PCM. + .refine(isValidAudioBase64, 'Audio chunk must be base64'), sampleRate: z.number().finite().positive() }) diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 0bcbb965af1..e7a2e541202 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -159,12 +159,25 @@ function resolveMobileFloorClientId( function appendPendingMultiplexOutput(stream: TerminalMultiplexStream, data: string): void { stream.pendingOutput.push(data) stream.pendingOutputChars += data.length + stream.pendingOutputChars = trimPendingOutputToBudget( + stream.pendingOutput, + stream.pendingOutputChars + ) +} + +function trimPendingOutputToBudget(pendingOutput: string[], pendingOutputChars: number): number { + let omittedChunkCount = 0 while ( - stream.pendingOutputChars > TERMINAL_MULTIPLEX_PENDING_MAX_CHARS && - stream.pendingOutput.length > 0 + pendingOutputChars > TERMINAL_MULTIPLEX_PENDING_MAX_CHARS && + omittedChunkCount < pendingOutput.length ) { - stream.pendingOutputChars -= stream.pendingOutput.shift()?.length ?? 0 + pendingOutputChars -= pendingOutput[omittedChunkCount].length + omittedChunkCount += 1 } + if (omittedChunkCount > 0) { + pendingOutput.splice(0, omittedChunkCount) + } + return pendingOutputChars } function isTerminalReadPayloadIncomplete(read: { truncated: boolean; limited?: boolean }): boolean { @@ -493,6 +506,13 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ process: await runtime.inspectTerminalProcess(params.terminal) }) }), + defineMethod({ + name: 'terminal.isRunningAgent', + params: TerminalHandle, + handler: async (params, { runtime }) => ({ + isRunningAgent: await runtime.isTerminalRunningAgent(params.terminal) + }) + }), defineMethod({ name: 'terminal.rename', params: TerminalRename, @@ -692,7 +712,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ params: TerminalMultiplex, handler: async ( _params, - { runtime, connectionId, sendBinary, registerBinaryStreamHandler }, + { runtime, connectionId, sendBinary, registerBinaryStreamHandler, signal }, emit ) => { if (!sendBinary || !registerBinaryStreamHandler || !connectionId) { @@ -809,9 +829,12 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const isMobile = request.client?.type === 'mobile' if (!leaf?.ptyId && isMobile) { try { - const ptyId = await runtime.waitForLeafPtyId(request.terminal) + const ptyId = await runtime.waitForLeafPtyId(request.terminal, 10_000, signal) leaf = { ptyId } } catch { + if (closed || signal?.aborted) { + return + } // Fall through to the explicit no_connected_pty error below. } } @@ -997,7 +1020,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ params: TerminalSubscribe, handler: async ( params, - { runtime, connectionId, sendBinary, registerBinaryStreamHandler }, + { runtime, connectionId, sendBinary, registerBinaryStreamHandler, signal }, emit ) => { let leaf = runtime.resolveLeafForHandle(params.terminal) @@ -1010,9 +1033,12 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ // the subscribe can proceed normally. if (!leaf?.ptyId && isMobile) { try { - const ptyId = await runtime.waitForLeafPtyId(params.terminal) + const ptyId = await runtime.waitForLeafPtyId(params.terminal, 10_000, signal) leaf = { ptyId } } catch { + if (signal?.aborted) { + return + } // PTY wait timed out — fall through to scrollback-only path below } } @@ -1038,6 +1064,11 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (!useBinaryStream) { const read = await runtime.readTerminal(params.terminal) const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, false) + // Why: legacy JSON streams register cleanup after snapshot awaits; if + // the socket closed meanwhile, registering now would orphan listeners. + if (signal?.aborted) { + return + } const size = runtime.getTerminalSize(ptyId) const displayMode = runtime.getMobileDisplayMode(ptyId) const seq = runtime.getLayout(ptyId)?.seq @@ -1204,12 +1235,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (buffering) { pendingOutput.push(data) pendingOutputChars += data.length - while ( - pendingOutputChars > TERMINAL_MULTIPLEX_PENDING_MAX_CHARS && - pendingOutput.length > 0 - ) { - pendingOutputChars -= pendingOutput.shift()?.length ?? 0 - } + pendingOutputChars = trimPendingOutputToBudget(pendingOutput, pendingOutputChars) return } outputBatcher?.push(data) diff --git a/src/main/runtime/rpc/methods/worktree-schemas.ts b/src/main/runtime/rpc/methods/worktree-schemas.ts index a5c8d834edd..f4baee7b532 100644 --- a/src/main/runtime/rpc/methods/worktree-schemas.ts +++ b/src/main/runtime/rpc/methods/worktree-schemas.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { workspaceSourceSchema } from '../../../../shared/telemetry-events' import { OptionalBoolean, OptionalFiniteNumber, @@ -51,6 +52,13 @@ export const WorktreeCreate = z linkedGitLabIssue: TriStateLinkedIssue, comment: OptionalString, displayName: OptionalString, + telemetrySource: z + .unknown() + .transform((value) => { + const parsed = workspaceSourceSchema.safeParse(value) + return parsed.success ? parsed.data : undefined + }) + .optional(), workspaceStatus: OptionalString, manualOrder: OptionalFiniteNumber, sparseCheckout: z @@ -90,6 +98,7 @@ export const WorktreeCreate = z // Why: mobile clients pass a startup command (e.g. 'claude') so the first // terminal pane launches the selected agent instead of an idle shell. startupCommand: OptionalString, + startupEnv: z.record(z.string(), z.string()).optional(), // Why: task-driven mobile creates need desktop parity: the host chooses // the same default/detected agent and drafts the linked issue/PR URL into it. startupDraft: OptionalString, @@ -153,6 +162,17 @@ export const WorktreeRemove = WorktreeSelector.extend({ runHooks: OptionalBoolean }) +export const WorktreeForceDeleteBranch = WorktreeSelector.extend({ + branchName: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing branch name')), + expectedHead: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing expected branch head')) +}) + export const WorktreeResolvePrBase = z.object({ repo: z .unknown() diff --git a/src/main/runtime/rpc/methods/worktree.test.ts b/src/main/runtime/rpc/methods/worktree.test.ts index 4eb3d54e621..d0e00787e17 100644 --- a/src/main/runtime/rpc/methods/worktree.test.ts +++ b/src/main/runtime/rpc/methods/worktree.test.ts @@ -24,6 +24,7 @@ describe('worktree RPC methods', () => { baseBranch: 'origin/main', setupDecision: 'skip', displayName: 'Feature title', + telemetrySource: 'sidebar', workspaceStatus: 'in-review', manualOrder: 123_456, linkedIssue: 123, @@ -48,6 +49,7 @@ describe('worktree RPC methods', () => { linkedGitLabMR: 321, comment: undefined, displayName: 'Feature title', + telemetrySource: 'sidebar', workspaceStatus: 'in-review', manualOrder: 123_456, sparseCheckout: { directories: ['src'], presetId: 'preset-1' }, @@ -67,6 +69,36 @@ describe('worktree RPC methods', () => { }) }) + it('forwards startup command and env to runtime worktree creation', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-1' } }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + await dispatcher.dispatch( + makeRequest('worktree.create', { + repo: 'repo-1', + name: 'agent-startup', + startupCommand: "codex 'summarize repo'", + startupEnv: { ORCA_AGENT_MODE: 'direct' }, + activate: true + }) + ) + + expect(runtime.createManagedWorktree).toHaveBeenCalledWith( + expect.objectContaining({ + repoSelector: 'repo-1', + name: 'agent-startup', + activate: true, + startup: { + command: "codex 'summarize repo'", + env: { ORCA_AGENT_MODE: 'direct' } + } + }) + ) + }) + it('forwards task startup drafts to runtime worktree creation', async () => { const runtime = { getRuntimeId: () => 'test-runtime', @@ -96,6 +128,31 @@ describe('worktree RPC methods', () => { ) }) + it('maps unknown telemetry sources to the runtime default instead of rejecting create', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-1' } }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('worktree.create', { + repo: 'repo-1', + name: 'feature', + telemetrySource: 'future_surface' + }) + ) + + expect(response).toMatchObject({ ok: true }) + expect(runtime.createManagedWorktree).toHaveBeenCalledWith( + expect.objectContaining({ + repoSelector: 'repo-1', + name: 'feature', + telemetrySource: undefined + }) + ) + }) + it('rejects worktree.create when both parent and no-parent are supplied', async () => { const runtime = { getRuntimeId: () => 'test-runtime', @@ -117,10 +174,15 @@ describe('worktree RPC methods', () => { expect(runtime.createManagedWorktree).not.toHaveBeenCalled() }) - it('passes explicit repo selectors to PR base resolution', async () => { + it('passes explicit repo selectors to PR base resolution and preserves start-point fields', async () => { const runtime = { getRuntimeId: () => 'test-runtime', - resolveManagedPrBase: vi.fn().mockResolvedValue({ baseBranch: 'origin/pr-head' }) + resolveManagedPrBase: vi.fn().mockResolvedValue({ + baseBranch: 'abc123', + headSha: 'abc123', + branchNameOverride: 'feature/pr-head', + pushTarget: { remoteName: 'origin', branchName: 'feature/pr-head' } + }) } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) @@ -134,6 +196,14 @@ describe('worktree RPC methods', () => { ) expect(response).toMatchObject({ ok: true }) + expect(response).toMatchObject({ + result: { + baseBranch: 'abc123', + headSha: 'abc123', + branchNameOverride: 'feature/pr-head', + pushTarget: { remoteName: 'origin', branchName: 'feature/pr-head' } + } + }) expect(runtime.resolveManagedPrBase).toHaveBeenCalledWith({ repoSelector: 'id:repo-1', prNumber: 42, diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index 73bafe305f2..2a2f0f83ae0 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -2,6 +2,7 @@ import { defineMethod, type RpcMethod } from '../core' import { WorktreeCreate, WorktreeDetectedListParams, + WorktreeForceDeleteBranch, WorktreeListParams, WorktreePsParams, WorktreeRemove, @@ -66,6 +67,7 @@ export const WORKTREE_METHODS: RpcMethod[] = [ linkedGitLabIssue: params.linkedGitLabIssue, comment: params.comment, displayName: params.displayName, + telemetrySource: params.telemetrySource, workspaceStatus: params.workspaceStatus, manualOrder: params.manualOrder, sparseCheckout: params.sparseCheckout, @@ -74,7 +76,12 @@ export const WORKTREE_METHODS: RpcMethod[] = [ activate: params.activate === true, setupDecision: params.setupDecision, createdWithAgent: params.createdWithAgent, - startup: params.startupCommand ? { command: params.startupCommand } : undefined, + startup: params.startupCommand + ? { + command: params.startupCommand, + ...(params.startupEnv ? { env: params.startupEnv } : {}) + } + : undefined, startupDraft: params.startupDraft, lineage: { parentWorktree: params.parentWorktree, @@ -160,5 +167,11 @@ export const WORKTREE_METHODS: RpcMethod[] = [ ) return { removed: true, ...result } } + }), + defineMethod({ + name: 'worktree.forceDeleteBranch', + params: WorktreeForceDeleteBranch, + handler: async (params, { runtime }) => + runtime.forceDeletePreservedBranch(params.worktree, params.branchName, params.expectedHead) }) ] diff --git a/src/main/runtime/rpc/terminal-multiplex.test.ts b/src/main/runtime/rpc/terminal-multiplex.test.ts index 4a8815ec2f5..a8260b0e24c 100644 --- a/src/main/runtime/rpc/terminal-multiplex.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex.test.ts @@ -555,6 +555,83 @@ describe('terminal multiplex RPC', () => { await dispatchPromise }) + it('settles mobile multiplex PTY waits when the stream signal aborts before PTY spawn', async () => { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const controller = new AbortController() + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: null }), + waitForLeafPtyId: vi.fn( + (_handle: string, _timeoutMs?: number, signal?: AbortSignal) => + new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(new Error('request_aborted')), { + once: true + }) + }) + ), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.multiplex', {}), + (msg) => messages.push(msg), + { + signal: controller.signal, + connectionId: 'conn-phone-multiplex', + sendBinary: (bytes) => binaryFrames.push(bytes), + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true) + ) + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 7, + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' } + }) + }) + )! + ) + + await vi.waitFor(() => expect(runtime.waitForLeafPtyId).toHaveBeenCalled()) + expect(runtime.waitForLeafPtyId).toHaveBeenCalledWith('terminal-1', 10_000, controller.signal) + + controller.abort() + await Promise.resolve() + await Promise.resolve() + + expect(runtime.readTerminal).not.toHaveBeenCalled() + expect( + messages.map((msg) => JSON.parse(msg).result).filter((result) => result?.streamId === 7) + ).toEqual([]) + expect(binaryFrames.map((frame) => decodeTerminalStreamFrame(frame)?.opcode)).not.toContain( + TerminalStreamOpcode.Error + ) + + cleanups.get('terminal-multiplex:conn-phone-multiplex')?.() + await dispatchPromise + }) + it('bounds live output queued while a multiplex snapshot is loading', async () => { vi.useFakeTimers() try { diff --git a/src/main/runtime/rpc/terminal-send.test.ts b/src/main/runtime/rpc/terminal-send.test.ts index 37e4c503135..1950a211682 100644 --- a/src/main/runtime/rpc/terminal-send.test.ts +++ b/src/main/runtime/rpc/terminal-send.test.ts @@ -16,6 +16,26 @@ function makeRequest(method: string, params?: unknown): RpcRequest { } describe('terminal send RPC', () => { + it('reports whether a terminal handle is running a recognized agent', async () => { + const runtime = stubRuntime({ + isTerminalRunningAgent: vi.fn().mockResolvedValue(true) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.isRunningAgent', { + terminal: 'terminal-1' + }) + ) + + expect(response.ok).toBe(true) + if (!response.ok) { + throw new Error(response.error.message) + } + expect(response.result).toEqual({ isRunningAgent: true }) + expect(runtime.isTerminalRunningAgent).toHaveBeenCalledWith('terminal-1') + }) + it('drops desktop input while a mobile client owns the terminal floor', async () => { const runtime = stubRuntime({ resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), diff --git a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts index 37684303f90..c976090aa73 100644 --- a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts @@ -1,3 +1,4 @@ +/* oxlint-disable max-lines -- Why: terminal subscribe buffering tests share a live dispatcher harness; splitting would duplicate stream setup and weaken lifecycle coverage. */ import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from './dispatcher' import type { RpcRequest } from './core' @@ -23,6 +24,56 @@ function makeRequest(method: string, params?: unknown): RpcRequest { } describe('terminal subscribe buffering', () => { + it('settles mobile subscribe waits when the stream signal aborts before PTY spawn', async () => { + vi.useFakeTimers() + try { + const messages: string[] = [] + const controller = new AbortController() + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: null }), + waitForLeafPtyId: vi.fn( + (_handle: string, _timeoutMs?: number, signal?: AbortSignal) => + new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(new Error('request_aborted')), { + once: true + }) + }) + ), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1 } + }), + (msg) => messages.push(msg), + { + signal: controller.signal, + connectionId: 'conn-phone', + sendBinary: vi.fn(), + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + } + ) + + expect(runtime.waitForLeafPtyId).toHaveBeenCalled() + controller.abort() + const outcomePromise = Promise.race([ + dispatchPromise.then(() => 'settled'), + new Promise<'pending'>((resolve) => setTimeout(() => resolve('pending'), 0)) + ]) + await vi.advanceTimersByTimeAsync(0) + + expect(await outcomePromise).toBe('settled') + expect(runtime.readTerminal).not.toHaveBeenCalled() + expect(messages).toEqual([]) + } finally { + vi.useRealTimers() + } + }) + it('marks scrollback-only subscribed previews truncated when the uncursored read is limited', async () => { const messages: string[] = [] const runtime = stubRuntime({ @@ -98,6 +149,61 @@ describe('terminal subscribe buffering', () => { await dispatchPromise }) + it('does not register legacy JSON listeners after the stream signal aborts during snapshot', async () => { + vi.useFakeTimers() + try { + const messages: string[] = [] + const controller = new AbortController() + let resolveSnapshot: (value: { data: string; cols: number; rows: number }) => void = () => {} + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn( + () => + new Promise<{ data: string; cols: number; rows: number }>((resolve) => { + resolveSnapshot = resolve + }) + ), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn(), + cleanupSubscription: vi.fn(), + waitForTerminal: vi.fn(() => new Promise(() => {})) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' } + }), + (msg) => messages.push(msg), + { connectionId: 'conn-legacy-json', signal: controller.signal } + ) + + await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalled()) + controller.abort() + resolveSnapshot({ data: '', cols: 80, rows: 24 }) + const outcomePromise = Promise.race([ + dispatchPromise.then(() => 'settled'), + new Promise<'pending'>((resolve) => setTimeout(() => resolve('pending'), 0)) + ]) + await vi.advanceTimersByTimeAsync(0) + + expect(await outcomePromise).toBe('settled') + expect(runtime.subscribeToTerminalData).not.toHaveBeenCalled() + expect(runtime.subscribeToFitOverrideChanges).not.toHaveBeenCalled() + expect(runtime.registerSubscriptionCleanup).not.toHaveBeenCalled() + expect(runtime.waitForTerminal).not.toHaveBeenCalled() + expect(messages).toEqual([]) + } finally { + vi.useRealTimers() + } + }) + it('marks binary subscribed previews truncated when the uncursored read is limited', async () => { const messages: string[] = [] const binaryFrames: Uint8Array[] = [] @@ -279,9 +385,12 @@ describe('terminal subscribe buffering', () => { ) await vi.waitFor(() => expect(dataListenerRef.current).toBeDefined()) + const shiftSpy = vi.spyOn(Array.prototype, 'shift') for (let index = 0; index < 400; index += 1) { dataListenerRef.current?.(`${String(index).padStart(3, '0')}${'x'.repeat(1021)}`) } + const shiftCallCount = shiftSpy.mock.calls.length + shiftSpy.mockRestore() await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalled()) resolveSnapshot({ data: '', cols: 120, rows: 40 }) await vi.waitFor(() => @@ -297,6 +406,7 @@ describe('terminal subscribe buffering', () => { expect(output.length).toBeLessThanOrEqual(256 * 1024) expect(output).not.toContain('000') expect(output).toContain('399') + expect(shiftCallCount).toBe(0) runtime.cleanupSubscription('terminal-1:desktop-1') await dispatchPromise diff --git a/src/main/runtime/rpc/unix-socket-transport.ts b/src/main/runtime/rpc/unix-socket-transport.ts index 07866b6ae3d..f74bab9fed3 100644 --- a/src/main/runtime/rpc/unix-socket-transport.ts +++ b/src/main/runtime/rpc/unix-socket-transport.ts @@ -35,6 +35,7 @@ export class UnixSocketTransport implements RpcTransport { private readonly keepaliveIntervalMs: number private server: Server | null = null private messageHandler: MessageHandler | null = null + private readonly activeSockets = new Set() constructor({ endpoint, kind, keepaliveIntervalMs }: UnixSocketTransportOptions) { this.endpoint = endpoint @@ -81,7 +82,7 @@ export class UnixSocketTransport implements RpcTransport { if (!server) { return } - await new Promise((resolve, reject) => { + const closePromise = new Promise((resolve, reject) => { server.close((error) => { if (error) { reject(error) @@ -90,12 +91,19 @@ export class UnixSocketTransport implements RpcTransport { resolve() }) }) + // Why: server.close() stops accepting new connections but waits for + // existing sockets; long-poll keepalives can otherwise hold shutdown open. + for (const socket of Array.from(this.activeSockets)) { + socket.destroy() + } + await closePromise if (this.kind === 'unix' && existsSync(this.endpoint)) { rmSync(this.endpoint, { force: true }) } } private handleConnection(socket: Socket): void { + this.activeSockets.add(socket) let buffer = '' let oversized = false // Why: each in-flight dispatch registers its own AbortController here so @@ -114,11 +122,12 @@ export class UnixSocketTransport implements RpcTransport { socket.on('error', () => { socket.destroy() }) - socket.on('close', () => { + socket.once('close', () => { for (const cleanup of inflight) { cleanup() } inflight.clear() + this.activeSockets.delete(socket) }) socket.on('data', (chunk: string) => { if (oversized) { diff --git a/src/main/runtime/rpc/ws-transport.test.ts b/src/main/runtime/rpc/ws-transport.test.ts index 72af8f18590..a261ba3531d 100644 --- a/src/main/runtime/rpc/ws-transport.test.ts +++ b/src/main/runtime/rpc/ws-transport.test.ts @@ -4,7 +4,7 @@ import { mkdtempSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' -import { describe, expect, it, afterEach } from 'vitest' +import { describe, expect, it, afterEach, vi } from 'vitest' import WebSocket from 'ws' import { WebSocketTransport } from './ws-transport' import { loadOrCreateTlsCertificate } from '../tls-certificate' @@ -241,6 +241,34 @@ describe('WebSocketTransport', () => { expect(calls).toEqual([{ clientId: null, hasOtherConnections: false }]) }) + it('detaches server socket listeners after client close', async () => { + const { transport } = await createTransport() + let cleanupSeen = false + transport.onConnectionClose(() => { + cleanupSeen = true + }) + + await transport.start() + + const client = await connectWs(transport) + const wss = (transport as unknown as { wss: { clients: Set } }).wss + const serverSocket = Array.from(wss.clients)[0] + expect(serverSocket).toBeDefined() + const offSpy = vi.spyOn(serverSocket!, 'off') + + client.close() + + const start = Date.now() + while (!cleanupSeen && Date.now() - start < 2_000) { + await new Promise((resolve) => setTimeout(resolve, 20)) + } + + expect(cleanupSeen).toBe(true) + const removedEvents = offSpy.mock.calls.map(([event]) => event) + expect(removedEvents).toEqual(expect.arrayContaining(['pong', 'message', 'close', 'error'])) + offSpy.mockRestore() + }) + it('terminates every active connection for a revoked client id', async () => { const { transport } = await createTransport() const closedClientIds: (string | null)[] = [] @@ -316,6 +344,27 @@ describe('WebSocketTransport', () => { await transport.stop() }) + it('does not wait for an unresponsive client close handshake during stop', async () => { + const { transport } = await createTransport() + await transport.start() + const ws = await connectWs(transport) + const underlying = (ws as unknown as { _socket: { pause: () => void } })._socket + underlying.pause() + + const stopPromise = transport.stop() + const outcome = await Promise.race([ + stopPromise.then(() => 'stopped' as const), + new Promise<'pending'>((resolve) => setTimeout(() => resolve('pending'), 100)) + ]) + + if (outcome === 'pending') { + ws.terminate() + await stopPromise + } + + expect(outcome).toBe('stopped') + }) + it('falls back to OS-assigned port when preferred port is in use', async () => { const { transport: first } = await createTransport() await first.start() diff --git a/src/main/runtime/rpc/ws-transport.ts b/src/main/runtime/rpc/ws-transport.ts index 07ccc095265..50020bf6e1e 100644 --- a/src/main/runtime/rpc/ws-transport.ts +++ b/src/main/runtime/rpc/ws-transport.ts @@ -251,7 +251,9 @@ export class WebSocketTransport implements RpcTransport { if (wss) { for (const client of wss.clients) { - client.close(1001, 'Server shutting down') + // Why: stop() is a teardown path. A half-open mobile socket may never + // answer a graceful close frame, which keeps httpServer.close pending. + client.terminate() } wss.close() } @@ -275,11 +277,48 @@ export class WebSocketTransport implements RpcTransport { // and dispatch logic to the message handler set by OrcaRuntimeRpcServer. private handleConnection(ws: WebSocket): void { let finalized = false + const onPong = (): void => { + this.wsAlive.add(ws) + } + const onMessage = (data: WebSocket.RawData, isBinary: boolean): void => { + // Why: any inbound traffic counts as proof of life, not just pongs. + // RN's WebSocket runtime auto-pongs server pings transparently, but + // app-level frames also count toward liveness so an actively-talking + // client doesn't get terminated mid-request. + this.wsAlive.add(ws) + const msg = + typeof data === 'string' + ? data + : isBinary + ? new Uint8Array(data as Buffer) + : data.toString() + this.messageHandler?.( + msg, + (response) => { + // Why: mobile clients disconnect frequently (backgrounding, network + // switch, phone locked). Guard writes to avoid errors on dead sockets. + if (ws.readyState === ws.OPEN) { + ws.send(response) + } + }, + ws + ) + } + const onError = (): void => { + // Why: close is not guaranteed after every ws error path; finalize here + // too so pre-auth E2EE channels and connection ids cannot leak. + finalizeConnection() + ws.close() + } const finalizeConnection = (): void => { if (finalized) { return } finalized = true + ws.off('pong', onPong) + ws.off('message', onMessage) + ws.off('close', finalizeConnection) + ws.off('error', onError) this.clearPreAuthTimer(ws) const clientId = this.wsClientIds.get(ws) ?? null this.wsClientIds.delete(ws) @@ -306,46 +345,14 @@ export class WebSocketTransport implements RpcTransport { // re-arm it. this.wsAlive.add(ws) - ws.on('pong', () => { - this.wsAlive.add(ws) - }) - - ws.on('message', (data, isBinary) => { - // Why: any inbound traffic counts as proof of life, not just pongs. - // RN's WebSocket runtime auto-pongs server pings transparently, but - // app-level frames also count toward liveness so an actively-talking - // client doesn't get terminated mid-request. - this.wsAlive.add(ws) - const msg = - typeof data === 'string' - ? data - : isBinary - ? new Uint8Array(data as Buffer) - : data.toString() - this.messageHandler?.( - msg, - (response) => { - // Why: mobile clients disconnect frequently (backgrounding, network - // switch, phone locked). Guard writes to avoid errors on dead sockets. - if (ws.readyState === ws.OPEN) { - ws.send(response) - } - }, - ws - ) - }) + ws.on('pong', onPong) + ws.on('message', onMessage) // Why: mobile clients disconnect when the phone locks, loses wifi, or // backgrounds the app. The runtime must clean up connection-scoped state // (e.g., mobile-fit overrides) to prevent orphaned phone-fit on desktop. ws.on('close', finalizeConnection) - - ws.on('error', () => { - // Why: close is not guaranteed after every ws error path; finalize here - // too so pre-auth E2EE channels and connection ids cannot leak. - finalizeConnection() - ws.close() - }) + ws.on('error', onError) } private clearPreAuthTimer(ws: WebSocket): void { diff --git a/src/main/runtime/runtime-metadata.test.ts b/src/main/runtime/runtime-metadata.test.ts index da369b01d40..40120a571bc 100644 --- a/src/main/runtime/runtime-metadata.test.ts +++ b/src/main/runtime/runtime-metadata.test.ts @@ -254,4 +254,16 @@ describe('runtime metadata', () => { expect(statSync(userDataPath).mode & 0o777).toBe(0o700) } ) + + it('replaces oversized E2EE keypair files instead of reading them as metadata', () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-large-keypair-')) + tempDirs.push(userDataPath) + const keypairPath = join(userDataPath, 'orca-e2ee-keypair.json') + writeFileSync(keypairPath, 'x'.repeat(9 * 1024)) + + const keypair = loadOrCreateE2EEKeypair(userDataPath) + + expect(keypair.publicKey).toHaveLength(32) + expect(statSync(keypairPath).size).toBeLessThan(1024) + }) }) diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 59bb492b837..554edb23995 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -5,8 +5,8 @@ import { join } from 'path' import { createConnection, type Socket } from 'net' import { EventEmitter } from 'events' import { describe, expect, it, vi } from 'vitest' -import Database from 'better-sqlite3' import WebSocket from 'ws' +import Database from '../sqlite/sync-database' import { OrcaRuntimeService } from './orca-runtime' import { OrchestrationDb } from './orchestration/db' import * as runtimeMetadataModule from './runtime-metadata' @@ -818,6 +818,7 @@ describe('OrcaRuntimeRpcServer', () => { const mergeRepoPR = vi.fn().mockResolvedValue({ ok: true }) const addGitLabRepoIssueComment = vi.fn().mockResolvedValue({ ok: true }) const addGitLabRepoMRComment = vi.fn().mockResolvedValue({ ok: true }) + const resolveGitLabRepoMRDiscussion = vi.fn().mockResolvedValue({ ok: true }) const mergeGitLabRepoMR = vi.fn().mockResolvedValue({ ok: true }) const addGitHubIssueCommentBySlug = vi.fn().mockResolvedValue({ ok: true, @@ -877,6 +878,7 @@ describe('OrcaRuntimeRpcServer', () => { mergeRepoPR, addGitLabRepoIssueComment, addGitLabRepoMRComment, + resolveGitLabRepoMRDiscussion, mergeGitLabRepoMR, addGitHubIssueCommentBySlug, updateGitHubIssueCommentBySlug, @@ -1290,6 +1292,21 @@ describe('OrcaRuntimeRpcServer', () => { (response) => replies.push(JSON.parse(response) as Record), () => {} ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_gitlab_resolve_mr_discussion', + method: 'gitlab.resolveMRDiscussion', + deviceToken: mobile.token, + params: { + repo: 'id:repo-1', + iid: 456, + discussionId: 'discussion-1', + resolved: true + } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) await server['handleWebSocketMessage']( JSON.stringify({ id: 'req_gitlab_merge_mr', @@ -1749,7 +1766,7 @@ describe('OrcaRuntimeRpcServer', () => { }) expect(listRepoLabels).toHaveBeenCalledWith('id:repo-1') expect(listRepoAssignableUsers).toHaveBeenCalledWith('id:repo-1') - expect(addRepoIssueComment).toHaveBeenCalledWith('id:repo-1', 123, 'done') + expect(addRepoIssueComment).toHaveBeenCalledWith('id:repo-1', 123, 'done', null) expect(addRepoPRReviewComment).toHaveBeenCalledWith('id:repo-1', { prNumber: 456, commitId: 'abc123', @@ -1764,7 +1781,8 @@ describe('OrcaRuntimeRpcServer', () => { body: 'fixed', threadId: 'thread-1', path: 'src/app.ts', - line: 10 + line: 10, + prRepo: null }) expect(getRepoPRFileContents).toHaveBeenCalledWith('id:repo-1', { prNumber: 456, @@ -1788,6 +1806,13 @@ describe('OrcaRuntimeRpcServer', () => { expect(mergeRepoPR).toHaveBeenCalledWith('id:repo-1', 456, 'squash', null) expect(addGitLabRepoIssueComment).toHaveBeenCalledWith('id:repo-1', 123, 'done', undefined) expect(addGitLabRepoMRComment).toHaveBeenCalledWith('id:repo-1', 456, 'ship it', undefined) + expect(resolveGitLabRepoMRDiscussion).toHaveBeenCalledWith( + 'id:repo-1', + 456, + 'discussion-1', + true, + undefined + ) expect(mergeGitLabRepoMR).toHaveBeenCalledWith('id:repo-1', 456, 'merge', undefined) expect(updateGitHubProjectItemField).toHaveBeenCalledWith({ projectId: 'project-1', @@ -2505,6 +2530,46 @@ describe('OrcaRuntimeRpcServer', () => { } }) + it('destroys active Unix socket connections when the runtime stops', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + const db = new OrchestrationDb(':memory:') + runtime.setOrchestrationDb(db) + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + keepaliveIntervalMs: 1000, + longPollCap: 1 + }) + await server.start() + + try { + const metadata = readRuntimeMetadata(userDataPath) + const endpoint = metadata!.transports[0]!.endpoint + + const session = openFramedSession(endpoint, { + id: 'req_stop', + authToken: metadata!.authToken, + method: 'orchestration.check', + params: { terminal: 'term_stop', wait: true, timeoutMs: 10_000 } + }) + await waitFor(() => server['activeLongPolls'] === 1) + + const stopResult = await Promise.race([ + server.stop().then(() => 'stopped'), + sleep(500).then(() => 'timeout') + ]) + + expect(stopResult).toBe('stopped') + await session.done + await waitFor(() => server['activeLongPolls'] === 0) + expect(session.socket.destroyed).toBe(true) + } finally { + db.close() + await server.stop() + } + }) + it('responds runtime_busy once the long-poll cap is saturated', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) const runtime = new OrcaRuntimeService() @@ -2650,7 +2715,7 @@ describe('OrcaRuntimeRpcServer', () => { }) it('hard-fails startup when the migration cannot be applied', () => { - // Simulate a migration error by monkey-patching better-sqlite3's exec. + // Simulate a migration error by monkey-patching the SQLite wrapper's exec. // If ALTER TABLE throws for any reason (e.g. disk full, permissions), // the constructor must propagate — not swallow and serve half-broken. // diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 4623c3cb021..eadc4b780e6 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -150,6 +150,8 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'git.abortRebase', 'git.bulkStage', 'git.bulkUnstage', + 'git.branchCompare', + 'git.branchDiff', 'git.commit', 'git.discard', 'git.diff', @@ -206,21 +208,29 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'gitlab.addMRComment', 'gitlab.listWorkItems', 'gitlab.mergeMR', + 'gitlab.resolveMRDiscussion', 'gitlab.todos', 'gitlab.updateIssue', 'gitlab.updateMR', 'gitlab.updateMRState', 'gitlab.workItemDetails', + 'host.gitBash.isAvailable', 'host.platform', 'host.pwsh.isAvailable', 'host.wsl.isAvailable', 'host.wsl.listDistros', + 'linear.getCustomView', 'linear.getIssue', + 'linear.getProject', 'linear.addIssueComment', 'linear.connect', 'linear.createIssue', 'linear.issueComments', + 'linear.listCustomViewIssues', + 'linear.listCustomViewProjects', + 'linear.listCustomViews', 'linear.listIssues', + 'linear.listProjectIssues', 'linear.listProjects', 'linear.teamLabels', 'linear.teamMembers', @@ -237,6 +247,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'preflight.check', 'preflight.detectAgents', 'preflight.detectRemoteAgents', + 'repo.baseRefDefault', 'repo.hooks', 'repo.list', 'repo.saveSparsePreset', @@ -267,6 +278,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'terminal.create', 'terminal.focus', 'terminal.getAutoRestoreFit', + 'terminal.isRunningAgent', 'terminal.list', 'terminal.multiplex', 'terminal.read', @@ -277,11 +289,13 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'terminal.subscribe', 'terminal.unsubscribe', 'terminal.updateViewport', + 'terminal.wait', 'ui.get', 'ui.recordFeatureInteraction', 'ui.set', 'worktree.activate', 'worktree.create', + 'worktree.forceDeleteBranch', 'worktree.ps', 'worktree.show', 'worktree.resolveMrBase', diff --git a/src/main/skills/discovery.test.ts b/src/main/skills/discovery.test.ts index 00fe9381314..2eeba6ddac4 100644 --- a/src/main/skills/discovery.test.ts +++ b/src/main/skills/discovery.test.ts @@ -94,4 +94,19 @@ describe('skill discovery', () => { expect(result.skills).toEqual([]) }) + + it('enforces depth limits for valid child directories whose names start with dot-dot', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) + const home = join(root, 'home') + const deepSkill = join(home, '.agents', 'skills', '..deep', 'a', 'b', 'c', 'd', 'too-deep') + await mkdir(deepSkill, { recursive: true }) + await writeFile(join(deepSkill, 'SKILL.md'), '# Too Deep\n\nShould not be discovered.') + + const result = await discoverSkills({ + homeDir: home, + cwd: join(root, 'missing-cwd') + }) + + expect(result.skills.map((skill) => skill.name)).not.toContain('Too Deep') + }) }) diff --git a/src/main/skills/discovery.ts b/src/main/skills/discovery.ts index 9ea8c02d8c1..3b19444cb01 100644 --- a/src/main/skills/discovery.ts +++ b/src/main/skills/discovery.ts @@ -1,19 +1,21 @@ -import { createHash } from 'node:crypto' import type { Dirent } from 'node:fs' import { open, readdir, realpath, stat } from 'node:fs/promises' -import { homedir } from 'node:os' -import { basename, dirname, join, relative, sep } from 'node:path' -import type { Repo } from '../../shared/types' +import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path' import { summarizeSkillMarkdown } from '../../shared/skill-metadata' +import type { Repo } from '../../shared/types' import type { DiscoveredSkill, SkillDiscoveryResult, SkillDiscoverySource, - SkillProvider, SkillSourceKind } from '../../shared/skills' +import { + buildSkillDiscoverySources, + stablePathId, + type SkillScanRoot +} from './skill-discovery-sources' -type SkillScanRoot = Omit +export { buildSkillDiscoverySources } from './skill-discovery-sources' const SKILL_FILE_NAME = 'SKILL.md' const MAX_MARKDOWN_BYTES = 256 * 1024 @@ -28,10 +30,6 @@ async function pathExists(pathValue: string): Promise { } } -function stableId(pathValue: string): string { - return createHash('sha1').update(pathValue).digest('hex').slice(0, 16) -} - function compareSkills(a: DiscoveredSkill, b: DiscoveredSkill): number { return ( a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) || @@ -42,9 +40,13 @@ function compareSkills(a: DiscoveredSkill, b: DiscoveredSkill): number { function isWithinDepth(rootPath: string, childPath: string, maxDepth: number): boolean { const rel = relative(rootPath, childPath) - if (!rel || rel.startsWith('..')) { + if (!rel) { return true } + // Why: `..cache` is a valid child name; only a real parent traversal escapes. + if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + return false + } return rel.split(sep).length <= maxDepth } @@ -210,7 +212,7 @@ async function scanRoot(root: SkillScanRoot): Promise { const summary = await readSkillSummary(skillFilePath) const sourceKind = sourceKindForSkill(root, skillFilePath) return { - id: stableId(skillFilePath), + id: stablePathId(skillFilePath), name: summary.name ?? basename(directoryPath), description: summary.description, providers: root.providers, @@ -228,72 +230,6 @@ async function scanRoot(root: SkillScanRoot): Promise { return skills } -function source( - id: string, - label: string, - path: string, - sourceKind: SkillSourceKind, - providers: SkillProvider[] -): SkillScanRoot { - return { id, label, path, sourceKind, providers } -} - -export function buildSkillDiscoverySources( - args: { - homeDir?: string - cwd?: string - repos?: Repo[] - } = {} -): SkillScanRoot[] { - const home = args.homeDir ?? homedir() - const cwd = args.cwd ?? process.cwd() - const roots: SkillScanRoot[] = [ - source('home-codex', 'Codex home', join(home, '.codex', 'skills'), 'home', ['codex']), - source('home-agents', 'Agent skills home', join(home, '.agents', 'skills'), 'home', [ - 'agent-skills' - ]), - source('home-claude', 'Claude home', join(home, '.claude', 'skills'), 'home', ['claude']), - source( - 'codex-plugin-cache', - 'Codex plugin cache', - join(home, '.codex', 'plugins', 'cache'), - 'plugin', - ['codex', 'agent-skills'] - ) - ] - - const projectPaths = new Set() - for (const repo of args.repos ?? []) { - if (repo.connectionId) { - continue - } - projectPaths.add(repo.path) - } - projectPaths.add(cwd) - - for (const repoPath of projectPaths) { - const label = `Repo ${basename(repoPath)}` - roots.push( - source( - `repo-agents-${stableId(repoPath)}`, - `${label} .agents`, - join(repoPath, '.agents', 'skills'), - 'repo', - ['agent-skills'] - ), - source( - `repo-claude-${stableId(repoPath)}`, - `${label} .claude`, - join(repoPath, '.claude', 'skills'), - 'repo', - ['claude'] - ) - ) - } - - return roots -} - export async function discoverSkills(args: { repos?: Repo[] homeDir?: string diff --git a/src/main/skills/skill-discovery-sources.ts b/src/main/skills/skill-discovery-sources.ts new file mode 100644 index 00000000000..c1bb1072b2e --- /dev/null +++ b/src/main/skills/skill-discovery-sources.ts @@ -0,0 +1,77 @@ +import { createHash } from 'node:crypto' +import { homedir } from 'node:os' +import { basename, join } from 'node:path' +import type { SkillDiscoverySource, SkillProvider, SkillSourceKind } from '../../shared/skills' +import type { Repo } from '../../shared/types' + +export type SkillScanRoot = Omit + +export function stablePathId(pathValue: string): string { + return createHash('sha1').update(pathValue).digest('hex').slice(0, 16) +} + +function source( + id: string, + label: string, + path: string, + sourceKind: SkillSourceKind, + providers: SkillProvider[] +): SkillScanRoot { + return { id, label, path, sourceKind, providers } +} + +export function buildSkillDiscoverySources( + args: { + homeDir?: string + cwd?: string + repos?: Repo[] + } = {} +): SkillScanRoot[] { + const home = args.homeDir ?? homedir() + const cwd = args.cwd ?? process.cwd() + const roots: SkillScanRoot[] = [ + source('home-codex', 'Codex home', join(home, '.codex', 'skills'), 'home', ['codex']), + source('home-agents', 'Agent skills home', join(home, '.agents', 'skills'), 'home', [ + 'agent-skills' + ]), + source('home-claude', 'Claude home', join(home, '.claude', 'skills'), 'home', ['claude']), + source( + 'codex-plugin-cache', + 'Codex plugin cache', + join(home, '.codex', 'plugins', 'cache'), + 'plugin', + ['codex', 'agent-skills'] + ) + ] + + const projectPaths = new Set() + for (const repo of args.repos ?? []) { + if (repo.connectionId) { + continue + } + projectPaths.add(repo.path) + } + projectPaths.add(cwd) + + for (const repoPath of projectPaths) { + const label = `Repo ${basename(repoPath)}` + roots.push( + source( + `repo-agents-${stablePathId(repoPath)}`, + `${label} .agents`, + join(repoPath, '.agents', 'skills'), + 'repo', + ['agent-skills'] + ), + source( + `repo-claude-${stablePathId(repoPath)}`, + `${label} .claude`, + join(repoPath, '.claude', 'skills'), + 'repo', + ['claude'] + ) + ) + } + + return roots +} diff --git a/src/main/source-control/forge-provider.test.ts b/src/main/source-control/forge-provider.test.ts new file mode 100644 index 00000000000..fc023efc086 --- /dev/null +++ b/src/main/source-control/forge-provider.test.ts @@ -0,0 +1,188 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + createGitHubPullRequestMock, + createGitLabMergeRequestMock, + getAzureDevOpsRepoSlugMock, + getBitbucketRepoSlugMock, + getGiteaRepoSlugMock, + getMergeRequestForBranchMock, + getProjectSlugMock, + getPRForBranchMock, + getRepoSlugMock +} = vi.hoisted(() => ({ + createGitHubPullRequestMock: vi.fn(), + createGitLabMergeRequestMock: vi.fn(), + getAzureDevOpsRepoSlugMock: vi.fn(), + getBitbucketRepoSlugMock: vi.fn(), + getGiteaRepoSlugMock: vi.fn(), + getMergeRequestForBranchMock: vi.fn(), + getProjectSlugMock: vi.fn(), + getPRForBranchMock: vi.fn(), + getRepoSlugMock: vi.fn() +})) + +vi.mock('../gitlab/client', () => ({ + getProjectSlug: getProjectSlugMock, + getMergeRequestForBranch: getMergeRequestForBranchMock, + getMergeRequest: vi.fn() +})) + +vi.mock('../gitlab/merge-request-creation', () => ({ + createGitLabMergeRequest: createGitLabMergeRequestMock +})) + +vi.mock('../github/client', () => ({ + createGitHubPullRequest: createGitHubPullRequestMock, + getRepoSlug: getRepoSlugMock, + getPRForBranch: getPRForBranchMock +})) + +vi.mock('../bitbucket/client', () => ({ + getBitbucketRepoSlug: getBitbucketRepoSlugMock, + getBitbucketPullRequestForBranch: vi.fn(), + getBitbucketPullRequest: vi.fn() +})) + +vi.mock('../azure-devops/client', () => ({ + getAzureDevOpsRepoSlug: getAzureDevOpsRepoSlugMock, + getAzureDevOpsPullRequestForBranch: vi.fn(), + getAzureDevOpsPullRequest: vi.fn() +})) + +vi.mock('../gitea/client', () => ({ + getGiteaRepoSlug: getGiteaRepoSlugMock, + getGiteaPullRequestForBranch: vi.fn(), + getGiteaPullRequest: vi.fn() +})) + +import { + FORGE_PROVIDERS, + detectHostedReviewProvider, + getForgeProviderById, + getForgeProviderForRepository +} from './forge-provider' + +describe('forge provider interface', () => { + beforeEach(() => { + createGitHubPullRequestMock.mockReset() + createGitLabMergeRequestMock.mockReset() + getAzureDevOpsRepoSlugMock.mockReset() + getBitbucketRepoSlugMock.mockReset() + getGiteaRepoSlugMock.mockReset() + getMergeRequestForBranchMock.mockReset() + getProjectSlugMock.mockReset() + getPRForBranchMock.mockReset() + getRepoSlugMock.mockReset() + }) + + it('preserves the existing hosted provider detection order', async () => { + getProjectSlugMock.mockResolvedValue({ host: 'gitlab.com', path: 'team/orca' }) + getRepoSlugMock.mockResolvedValue({ owner: 'team', repo: 'orca' }) + + await expect(detectHostedReviewProvider({ repoPath: '/repo' })).resolves.toBe('gitlab') + await expect(getForgeProviderForRepository({ repoPath: '/repo' })).resolves.toMatchObject({ + id: 'gitlab' + }) + expect(getRepoSlugMock).not.toHaveBeenCalled() + }) + + it('keeps review creation capability scoped to providers with creation support', async () => { + expect( + FORGE_PROVIDERS.map((provider) => [provider.id, provider.supportsReviewCreation]) + ).toEqual([ + ['gitlab', true], + ['github', true], + ['bitbucket', false], + ['azure-devops', false], + ['gitea', false] + ]) + createGitHubPullRequestMock.mockResolvedValue({ + ok: true, + number: 12, + url: 'https://github.com/team/orca/pull/12' + }) + + const provider = getForgeProviderById('github') + await expect( + provider.createReview?.('/repo', { + provider: 'github', + base: 'main', + head: 'feature/provider-interface', + title: 'Add provider interface' + }) + ).resolves.toEqual({ + ok: true, + number: 12, + url: 'https://github.com/team/orca/pull/12' + }) + expect(createGitHubPullRequestMock).toHaveBeenCalledWith('/repo', { + provider: 'github', + base: 'main', + head: 'feature/provider-interface', + title: 'Add provider interface' + }) + }) + + it('routes GitLab review creation through the shared provider contract', async () => { + createGitLabMergeRequestMock.mockResolvedValue({ + ok: true, + number: 44, + url: 'https://gitlab.com/team/orca/-/merge_requests/44' + }) + + const provider = getForgeProviderById('gitlab') + await expect( + provider.createReview?.( + '/repo', + { + provider: 'gitlab', + base: 'main', + head: 'feature/provider-interface', + title: 'Add provider interface' + }, + 'ssh-1' + ) + ).resolves.toEqual({ + ok: true, + number: 44, + url: 'https://gitlab.com/team/orca/-/merge_requests/44' + }) + expect(createGitLabMergeRequestMock).toHaveBeenCalledWith( + '/repo', + { + provider: 'gitlab', + base: 'main', + head: 'feature/provider-interface', + title: 'Add provider interface' + }, + 'ssh-1' + ) + }) + + it('adapts GitHub branch lookup through the shared provider contract', async () => { + getPRForBranchMock.mockResolvedValue({ + number: 7, + title: 'Provider branch', + state: 'open', + url: 'https://github.com/team/orca/pull/7', + checksStatus: 'success', + updatedAt: '2026-05-29T00:00:00.000Z', + mergeable: 'MERGEABLE' + }) + + await expect( + getForgeProviderById('github').getReviewForBranch({ + repoPath: '/repo', + connectionId: 'ssh-1', + branch: '', + fallbackReviewNumber: 7 + }) + ).resolves.toMatchObject({ + provider: 'github', + number: 7, + status: 'success' + }) + expect(getPRForBranchMock).toHaveBeenCalledWith('/repo', '', null, 'ssh-1', 7) + }) +}) diff --git a/src/main/source-control/forge-provider.ts b/src/main/source-control/forge-provider.ts new file mode 100644 index 00000000000..41fa093792d --- /dev/null +++ b/src/main/source-control/forge-provider.ts @@ -0,0 +1,266 @@ +import type { + CreateHostedReviewInput, + CreateHostedReviewResult, + HostedReviewInfo, + HostedReviewProvider +} from '../../shared/hosted-review' +import { hostedReviewInfoFromGitHubPRInfo } from '../../shared/hosted-review-github' +import type { MRInfo, PRInfo } from '../../shared/types' +import { + getAzureDevOpsPullRequest, + getAzureDevOpsPullRequestForBranch, + getAzureDevOpsRepoSlug +} from '../azure-devops/client' +import type { AzureDevOpsPullRequestInfo } from '../azure-devops/pull-request-mappers' +import { + getBitbucketPullRequest, + getBitbucketPullRequestForBranch, + getBitbucketRepoSlug +} from '../bitbucket/client' +import type { BitbucketPullRequestInfo } from '../bitbucket/pull-request-mappers' +import { + getGiteaPullRequest, + getGiteaPullRequestForBranch, + getGiteaRepoSlug +} from '../gitea/client' +import type { GiteaPullRequestInfo } from '../gitea/pull-request-mappers' +import { createGitHubPullRequest, getPRForBranch, getRepoSlug } from '../github/client' +import { getMergeRequest, getMergeRequestForBranch, getProjectSlug } from '../gitlab/client' +import { createGitLabMergeRequest } from '../gitlab/merge-request-creation' + +export type ForgeProviderId = Exclude + +export type ForgeProviderRepositoryContext = { + repoPath: string + connectionId?: string | null +} + +export type ForgeReviewForBranchInput = ForgeProviderRepositoryContext & { + branch: string + linkedReviewNumber?: number | null + fallbackReviewNumber?: number | null +} + +export type ForgeReviewByNumberInput = ForgeProviderRepositoryContext & { + number: number +} + +export type ForgeProvider = { + id: ForgeProviderId + supportsReviewCreation: boolean + resolveRepository(context: ForgeProviderRepositoryContext): Promise + getReviewForBranch(input: ForgeReviewForBranchInput): Promise + getReviewByNumber(input: ForgeReviewByNumberInput): Promise + createReview?( + repoPath: string, + input: CreateHostedReviewInput, + connectionId?: string | null + ): Promise +} + +function mapGitHubReview(pr: PRInfo): HostedReviewInfo { + return hostedReviewInfoFromGitHubPRInfo(pr) +} + +function mapGitLabReviewState(state: MRInfo['state']): HostedReviewInfo['state'] { + if (state === 'opened' || state === 'locked') { + return 'open' + } + return state +} + +function mapGitLabReview(mr: MRInfo): HostedReviewInfo { + return { + provider: 'gitlab', + number: mr.number, + title: mr.title, + state: mapGitLabReviewState(mr.state), + url: mr.url, + status: mr.pipelineStatus, + updatedAt: mr.updatedAt, + mergeable: mr.mergeable, + ...(mr.headSha ? { headSha: mr.headSha } : {}), + ...(mr.conflictSummary ? { conflictSummary: mr.conflictSummary } : {}) + } +} + +function mapBitbucketReview(pr: BitbucketPullRequestInfo): HostedReviewInfo { + return { + provider: 'bitbucket', + number: pr.number, + title: pr.title, + state: pr.state, + url: pr.url, + status: pr.status, + updatedAt: pr.updatedAt, + mergeable: pr.mergeable, + ...(pr.headSha ? { headSha: pr.headSha } : {}) + } +} + +function mapAzureDevOpsReview(pr: AzureDevOpsPullRequestInfo): HostedReviewInfo { + return { + provider: 'azure-devops', + number: pr.number, + title: pr.title, + state: pr.state, + url: pr.url, + status: pr.status, + updatedAt: pr.updatedAt, + mergeable: pr.mergeable, + ...(pr.headSha ? { headSha: pr.headSha } : {}) + } +} + +function mapGiteaReview(pr: GiteaPullRequestInfo): HostedReviewInfo { + return { + provider: 'gitea', + number: pr.number, + title: pr.title, + state: pr.state, + url: pr.url, + status: pr.status, + updatedAt: pr.updatedAt, + mergeable: pr.mergeable, + ...(pr.headSha ? { headSha: pr.headSha } : {}) + } +} + +const gitLabForgeProvider = { + id: 'gitlab', + supportsReviewCreation: true, + resolveRepository: ({ repoPath, connectionId }) => getProjectSlug(repoPath, connectionId), + async getReviewForBranch(input) { + const mr = await getMergeRequestForBranch( + input.repoPath, + input.branch, + input.linkedReviewNumber ?? null, + input.connectionId + ) + return mr ? mapGitLabReview(mr) : null + }, + async getReviewByNumber(input) { + const mr = await getMergeRequest(input.repoPath, input.number, input.connectionId) + return mr ? mapGitLabReview(mr) : null + }, + createReview: createGitLabMergeRequest +} satisfies ForgeProvider + +const gitHubForgeProvider = { + id: 'github', + supportsReviewCreation: true, + resolveRepository: ({ repoPath, connectionId }) => getRepoSlug(repoPath, connectionId), + async getReviewForBranch(input) { + const fallbackReviewNumber = + input.linkedReviewNumber == null ? (input.fallbackReviewNumber ?? null) : null + const pr = + fallbackReviewNumber !== null + ? await getPRForBranch( + input.repoPath, + input.branch, + input.linkedReviewNumber ?? null, + input.connectionId, + fallbackReviewNumber + ) + : await getPRForBranch( + input.repoPath, + input.branch, + input.linkedReviewNumber ?? null, + input.connectionId + ) + return pr ? mapGitHubReview(pr) : null + }, + async getReviewByNumber(input) { + const pr = await getPRForBranch(input.repoPath, '', input.number, input.connectionId) + return pr ? mapGitHubReview(pr) : null + }, + createReview: createGitHubPullRequest +} satisfies ForgeProvider + +const bitbucketForgeProvider = { + id: 'bitbucket', + supportsReviewCreation: false, + resolveRepository: ({ repoPath, connectionId }) => getBitbucketRepoSlug(repoPath, connectionId), + async getReviewForBranch(input) { + const pr = await getBitbucketPullRequestForBranch( + input.repoPath, + input.branch, + input.linkedReviewNumber ?? null, + input.connectionId + ) + return pr ? mapBitbucketReview(pr) : null + }, + async getReviewByNumber(input) { + const pr = await getBitbucketPullRequest(input.repoPath, input.number, input.connectionId) + return pr ? mapBitbucketReview(pr) : null + } +} satisfies ForgeProvider + +const azureDevOpsForgeProvider = { + id: 'azure-devops', + supportsReviewCreation: false, + resolveRepository: ({ repoPath, connectionId }) => getAzureDevOpsRepoSlug(repoPath, connectionId), + async getReviewForBranch(input) { + const pr = await getAzureDevOpsPullRequestForBranch( + input.repoPath, + input.branch, + input.linkedReviewNumber ?? null, + input.connectionId + ) + return pr ? mapAzureDevOpsReview(pr) : null + }, + async getReviewByNumber(input) { + const pr = await getAzureDevOpsPullRequest(input.repoPath, input.number, input.connectionId) + return pr ? mapAzureDevOpsReview(pr) : null + } +} satisfies ForgeProvider + +const giteaForgeProvider = { + id: 'gitea', + supportsReviewCreation: false, + resolveRepository: ({ repoPath, connectionId }) => getGiteaRepoSlug(repoPath, connectionId), + async getReviewForBranch(input) { + const pr = await getGiteaPullRequestForBranch( + input.repoPath, + input.branch, + input.linkedReviewNumber ?? null, + input.connectionId + ) + return pr ? mapGiteaReview(pr) : null + }, + async getReviewByNumber(input) { + const pr = await getGiteaPullRequest(input.repoPath, input.number, input.connectionId) + return pr ? mapGiteaReview(pr) : null + } +} satisfies ForgeProvider + +// Why: provider order preserves existing branch-status behavior when remotes +// could be interpreted by more than one hosting integration. +export const FORGE_PROVIDERS = [ + gitLabForgeProvider, + gitHubForgeProvider, + bitbucketForgeProvider, + azureDevOpsForgeProvider, + giteaForgeProvider +] as const satisfies readonly ForgeProvider[] + +export function getForgeProviderById(id: ForgeProviderId): ForgeProvider { + return FORGE_PROVIDERS.find((provider) => provider.id === id) ?? gitHubForgeProvider +} + +export async function getForgeProviderForRepository( + context: ForgeProviderRepositoryContext +): Promise { + for (const provider of FORGE_PROVIDERS) { + if (await provider.resolveRepository(context)) { + return provider + } + } + return null +} + +export async function detectHostedReviewProvider( + context: ForgeProviderRepositoryContext +): Promise { + return (await getForgeProviderForRepository(context))?.id ?? 'unsupported' +} diff --git a/src/main/source-control/hosted-review-azure-devops.integration.test.ts b/src/main/source-control/hosted-review-azure-devops.integration.test.ts index 80f2a06cb42..36c1fa2790f 100644 --- a/src/main/source-control/hosted-review-azure-devops.integration.test.ts +++ b/src/main/source-control/hosted-review-azure-devops.integration.test.ts @@ -125,4 +125,104 @@ describe('Azure DevOps hosted review integration', () => { }) } }) + + it('prefers an active Azure Repos PR over a newer abandoned PR for the same branch', async () => { + const seen: SeenRequest[] = [] + const server = createServer((req: IncomingMessage, res: ServerResponse) => { + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`) + seen.push({ + pathname: url.pathname, + search: url.search, + authorization: req.headers.authorization + }) + + if (url.pathname === '/acme/Project/_apis/git/repositories/repo') { + sendJson(res, { + id: 'repo-guid', + webUrl: 'https://dev.azure.com/acme/Project/_git/repo' + }) + return + } + + if (url.pathname === '/acme/Project/_apis/git/repositories/repo-guid/pullRequests') { + sendJson(res, { + value: [ + { + pullRequestId: 40, + title: 'Abandoned branch', + status: 'abandoned', + creationDate: '2026-05-10T00:00:00Z', + closedDate: '2026-05-20T00:00:00Z', + mergeStatus: 'conflicts', + lastMergeSourceCommit: { commitId: 'old123' } + }, + { + pullRequestId: 41, + title: 'Active branch', + status: 'active', + creationDate: '2026-05-01T00:00:00Z', + mergeStatus: 'succeeded', + lastMergeSourceCommit: { commitId: 'active123' } + } + ] + }) + return + } + + if ( + url.pathname === '/acme/Project/_apis/git/repositories/repo-guid/pullRequests/40/statuses' + ) { + sendJson(res, { value: [{ state: 'failed' }] }) + return + } + + if ( + url.pathname === '/acme/Project/_apis/git/repositories/repo-guid/pullRequests/41/statuses' + ) { + sendJson(res, { value: [{ state: 'succeeded' }] }) + return + } + + res.writeHead(404, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ message: 'not found' })) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + + const repoPath = await mkdtemp(join(tmpdir(), 'orca-azure-review-active-')) + try { + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('expected TCP server address') + } + process.env.ORCA_AZURE_DEVOPS_API_BASE_URL = `http://127.0.0.1:${address.port}/acme/Project` + + await execFileAsync('git', ['init'], { cwd: repoPath }) + await execFileAsync( + 'git', + ['remote', 'add', 'origin', 'https://dev.azure.com/acme/Project/_git/repo'], + { cwd: repoPath } + ) + + await expect( + getHostedReviewForBranch({ repoPath, branch: 'refs/heads/feature/azure' }) + ).resolves.toMatchObject({ + provider: 'azure-devops', + number: 41, + title: 'Active branch', + state: 'open', + status: 'success', + mergeable: 'MERGEABLE', + headSha: 'active123' + }) + + expect(seen.map((request) => request.pathname)).toContain( + '/acme/Project/_apis/git/repositories/repo-guid/pullRequests/41/statuses' + ) + } finally { + await rm(repoPath, { recursive: true, force: true }) + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }) + } + }) }) diff --git a/src/main/source-control/hosted-review-creation.test.ts b/src/main/source-control/hosted-review-creation.test.ts index b5a8475c3ad..117f6ffcbf8 100644 --- a/src/main/source-control/hosted-review-creation.test.ts +++ b/src/main/source-control/hosted-review-creation.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { createGitHubPullRequestMock, + createGitLabMergeRequestMock, getRepoSlugMock, getProjectSlugMock, getBitbucketRepoSlugMock, @@ -10,11 +11,13 @@ const { getGiteaRepoSlugMock, getHostedReviewForBranchMock, ghExecFileAsyncMock, + glabExecFileAsyncMock, gitExecFileAsyncMock, getUpstreamStatusMock, getSshGitProviderMock } = vi.hoisted(() => ({ createGitHubPullRequestMock: vi.fn(), + createGitLabMergeRequestMock: vi.fn(), getRepoSlugMock: vi.fn(), getProjectSlugMock: vi.fn(), getBitbucketRepoSlugMock: vi.fn(), @@ -22,6 +25,7 @@ const { getGiteaRepoSlugMock: vi.fn(), getHostedReviewForBranchMock: vi.fn(), ghExecFileAsyncMock: vi.fn(), + glabExecFileAsyncMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), getUpstreamStatusMock: vi.fn(), getSshGitProviderMock: vi.fn() @@ -29,23 +33,36 @@ const { vi.mock('../github/client', () => ({ createGitHubPullRequest: createGitHubPullRequestMock, - getRepoSlug: getRepoSlugMock + getRepoSlug: getRepoSlugMock, + getPRForBranch: vi.fn() })) vi.mock('../gitlab/client', () => ({ - getProjectSlug: getProjectSlugMock + getProjectSlug: getProjectSlugMock, + getMergeRequestForBranch: vi.fn(), + getMergeRequest: vi.fn() +})) + +vi.mock('../gitlab/merge-request-creation', () => ({ + createGitLabMergeRequest: createGitLabMergeRequestMock })) vi.mock('../bitbucket/client', () => ({ - getBitbucketRepoSlug: getBitbucketRepoSlugMock + getBitbucketRepoSlug: getBitbucketRepoSlugMock, + getBitbucketPullRequestForBranch: vi.fn(), + getBitbucketPullRequest: vi.fn() })) vi.mock('../azure-devops/client', () => ({ - getAzureDevOpsRepoSlug: getAzureDevOpsRepoSlugMock + getAzureDevOpsRepoSlug: getAzureDevOpsRepoSlugMock, + getAzureDevOpsPullRequestForBranch: vi.fn(), + getAzureDevOpsPullRequest: vi.fn() })) vi.mock('../gitea/client', () => ({ - getGiteaRepoSlug: getGiteaRepoSlugMock + getGiteaRepoSlug: getGiteaRepoSlugMock, + getGiteaPullRequestForBranch: vi.fn(), + getGiteaPullRequest: vi.fn() })) vi.mock('../github/gh-utils', () => ({ @@ -55,6 +72,14 @@ vi.mock('../github/gh-utils', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) +vi.mock('../gitlab/gl-utils', () => ({ + acquire: vi.fn(), + release: vi.fn(), + glabExecFileAsync: glabExecFileAsyncMock, + glabRepoExecOptions: (repoPath: string, connectionId?: string | null) => + connectionId ? {} : { cwd: repoPath } +})) + vi.mock('../git/upstream', () => ({ getUpstreamStatus: getUpstreamStatusMock })) @@ -72,6 +97,7 @@ import { createHostedReview, getHostedReviewCreationEligibility } from './hosted function resetMocks(): void { for (const mock of [ createGitHubPullRequestMock, + createGitLabMergeRequestMock, getRepoSlugMock, getProjectSlugMock, getBitbucketRepoSlugMock, @@ -79,6 +105,7 @@ function resetMocks(): void { getGiteaRepoSlugMock, getHostedReviewForBranchMock, ghExecFileAsyncMock, + glabExecFileAsyncMock, gitExecFileAsyncMock, getUpstreamStatusMock, getSshGitProviderMock @@ -95,6 +122,14 @@ function mockGitHubProvider(): void { getGiteaRepoSlugMock.mockResolvedValue(null) } +function mockGitLabProvider(): void { + getProjectSlugMock.mockResolvedValue({ host: 'gitlab.com', path: 'acme/orca' }) + getRepoSlugMock.mockResolvedValue(null) + getBitbucketRepoSlugMock.mockResolvedValue(null) + getAzureDevOpsRepoSlugMock.mockResolvedValue(null) + getGiteaRepoSlugMock.mockResolvedValue(null) +} + describe('createHostedReview', () => { beforeEach(() => { resetMocks() @@ -102,6 +137,7 @@ describe('createHostedReview', () => { mockGitHubProvider() getHostedReviewForBranchMock.mockResolvedValue(null) ghExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + glabExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) getUpstreamStatusMock.mockResolvedValue({ hasUpstream: true, upstreamName: 'origin/feature', @@ -128,6 +164,11 @@ describe('createHostedReview', () => { number: 12, url: 'https://github.com/acme/orca/pull/12' }) + createGitLabMergeRequestMock.mockResolvedValue({ + ok: true, + number: 44, + url: 'https://gitlab.com/acme/orca/-/merge_requests/44' + }) }) it('revalidates ahead commits before creating a GitHub pull request', async () => { @@ -192,6 +233,39 @@ describe('createHostedReview', () => { expect(createGitHubPullRequestMock).toHaveBeenCalledOnce() }) + it('creates a GitLab merge request after fresh main-process validation passes', async () => { + mockGitLabProvider() + + await expect( + createHostedReview('/repo', { + provider: 'gitlab', + base: 'main', + head: 'feature', + title: 'Feature' + }) + ).resolves.toEqual({ + ok: true, + number: 44, + url: 'https://gitlab.com/acme/orca/-/merge_requests/44' + }) + + expect(glabExecFileAsyncMock).toHaveBeenCalledWith( + ['auth', 'status', '--hostname', 'gitlab.com'], + { cwd: '/repo' } + ) + expect(createGitLabMergeRequestMock).toHaveBeenCalledWith( + '/repo', + { + provider: 'gitlab', + base: 'main', + head: 'feature', + title: 'Feature' + }, + undefined + ) + expect(createGitHubPullRequestMock).not.toHaveBeenCalled() + }) + it('uses the SSH git provider for remote hosted-review preflight', async () => { const remoteGit = { getStatus: vi.fn(async () => ({ entries: [], conflictOperation: 'unknown' })), @@ -411,9 +485,8 @@ describe('getHostedReviewCreationEligibility', () => { }) }) - it('blocks unsupported providers before GitHub authentication checks', async () => { - getProjectSlugMock.mockResolvedValue({ host: 'gitlab.com', path: 'acme/orca' }) - getRepoSlugMock.mockResolvedValue(null) + it('enables creation for clean, in-sync, authenticated GitLab feature branches', async () => { + mockGitLabProvider() await expect( getHostedReviewCreationEligibility({ @@ -427,9 +500,15 @@ describe('getHostedReviewCreationEligibility', () => { }) ).resolves.toMatchObject({ provider: 'gitlab', - canCreate: false, - blockedReason: 'unsupported_provider' + canCreate: true, + blockedReason: null, + nextAction: null, + head: 'feature/gitlab' }) expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + expect(glabExecFileAsyncMock).toHaveBeenCalledWith( + ['auth', 'status', '--hostname', 'gitlab.com'], + { cwd: '/repo' } + ) }) }) diff --git a/src/main/source-control/hosted-review-creation.ts b/src/main/source-control/hosted-review-creation.ts index e03fb0e3b49..2dbde4b03ec 100644 --- a/src/main/source-control/hosted-review-creation.ts +++ b/src/main/source-control/hosted-review-creation.ts @@ -12,10 +12,6 @@ import { normalizeHostedReviewBaseRef, normalizeHostedReviewHeadRef } from '../../shared/hosted-review-refs' -import { getAzureDevOpsRepoSlug } from '../azure-devops/client' -import { getBitbucketRepoSlug } from '../bitbucket/client' -import { getGiteaRepoSlug } from '../gitea/client' -import { createGitHubPullRequest, getRepoSlug } from '../github/client' import { acquire, ghExecFileAsync, gitExecFileAsync, release } from '../github/gh-utils' import { isNoUpstreamError, normalizeGitErrorMessage } from '../../shared/git-remote-error' import type { GitUpstreamStatus } from '../../shared/types' @@ -23,7 +19,14 @@ import { gitOptionalLocksDisabledEnv } from '../git/runner' import { resolveDefaultBaseRefViaExec } from '../git/repo' import { getUpstreamStatus } from '../git/upstream' import { getProjectSlug } from '../gitlab/client' +import { + acquire as acquireGlab, + glabExecFileAsync, + glabRepoExecOptions, + release as releaseGlab +} from '../gitlab/gl-utils' import { getSshGitProvider } from '../providers/ssh-git-dispatch' +import { detectHostedReviewProvider, getForgeProviderForRepository } from './forge-provider' import { getHostedReviewForBranch } from './hosted-review' type HostedReviewCreationEligibilityInput = HostedReviewCreationEligibilityArgs & { @@ -34,28 +37,6 @@ function stripRefPrefix(ref: string): string { return normalizeHostedReviewHeadRef(ref) } -async function detectHostedReviewProvider( - repoPath: string, - connectionId?: string | null -): Promise { - if (await getProjectSlug(repoPath, connectionId)) { - return 'gitlab' - } - if (await getRepoSlug(repoPath, connectionId)) { - return 'github' - } - if (await getBitbucketRepoSlug(repoPath)) { - return 'bitbucket' - } - if (await getAzureDevOpsRepoSlug(repoPath)) { - return 'azure-devops' - } - if (await getGiteaRepoSlug(repoPath)) { - return 'gitea' - } - return 'unsupported' -} - async function isGitHubAuthenticated( repoPath: string, connectionId?: string | null @@ -74,6 +55,28 @@ async function isGitHubAuthenticated( } } +async function isGitLabAuthenticated( + repoPath: string, + connectionId?: string | null +): Promise { + const projectRef = await getProjectSlug(repoPath, connectionId) + if (!projectRef) { + return false + } + await acquireGlab() + try { + await glabExecFileAsync( + ['auth', 'status', '--hostname', projectRef.host], + glabRepoExecOptions(repoPath, connectionId) + ) + return true + } catch { + return false + } finally { + releaseGlab() + } +} + async function runGitForHostedReview( repoPath: string, args: string[], @@ -154,56 +157,83 @@ async function getHostedReviewUpstreamStatus( } } -const blockedCreateResultByReason = { - auth_required: { - ok: false, - code: 'auth_required', - error: - 'Create PR failed: GitHub is not authenticated. Next step: run gh auth login in this environment.' - }, - unsupported_provider: { - ok: false, - code: 'unsupported_provider', - error: 'Creating pull requests requires a GitHub remote.' - }, - dirty: { - ok: false, - code: 'validation', - error: 'Create PR failed: commit or discard local changes before creating a pull request.' - }, - detached_head: { - ok: false, - code: 'validation', - error: 'Create PR failed: switch to a branch before creating a pull request.' - }, - default_branch: { - ok: false, - code: 'validation', - error: 'Create PR failed: choose a feature branch before creating a pull request.' - }, - no_upstream: { - ok: false, - code: 'validation', - error: 'Create PR failed: publish this branch before creating a pull request.' - }, - needs_push: { - ok: false, - code: 'validation', - error: 'Create PR failed: push this branch before creating a pull request.' - }, - needs_sync: { - ok: false, - code: 'validation', - error: 'Create PR failed: sync this branch before creating a pull request.' - }, - fork_head_unsupported: { - ok: false, - code: 'validation', - error: 'Create PR failed: refresh source control status and try again.' - } -} satisfies Partial< - Record, CreateHostedReviewResult> -> +function reviewCopy(provider: HostedReviewProvider): { + shortLabel: 'PR' | 'MR' + reviewLabel: 'pull request' | 'merge request' + providerName: 'GitHub' | 'GitLab' + authCommand: 'gh auth login' | 'glab auth login' +} { + return provider === 'gitlab' + ? { + shortLabel: 'MR', + reviewLabel: 'merge request', + providerName: 'GitLab', + authCommand: 'glab auth login' + } + : { + shortLabel: 'PR', + reviewLabel: 'pull request', + providerName: 'GitHub', + authCommand: 'gh auth login' + } +} + +function blockedCreateResultForReason( + reason: NonNullable, + provider: HostedReviewProvider +): CreateHostedReviewResult | null { + const copy = reviewCopy(provider) + const blockedCreateResultByReason = { + auth_required: { + ok: false, + code: 'auth_required', + error: `Create ${copy.shortLabel} failed: ${copy.providerName} is not authenticated. Next step: run ${copy.authCommand} in this environment.` + }, + unsupported_provider: { + ok: false, + code: 'unsupported_provider', + error: `Creating ${copy.reviewLabel}s requires a ${copy.providerName} remote.` + }, + dirty: { + ok: false, + code: 'validation', + error: `Create ${copy.shortLabel} failed: commit or discard local changes before creating a ${copy.reviewLabel}.` + }, + detached_head: { + ok: false, + code: 'validation', + error: `Create ${copy.shortLabel} failed: switch to a branch before creating a ${copy.reviewLabel}.` + }, + default_branch: { + ok: false, + code: 'validation', + error: `Create ${copy.shortLabel} failed: choose a feature branch before creating a ${copy.reviewLabel}.` + }, + no_upstream: { + ok: false, + code: 'validation', + error: `Create ${copy.shortLabel} failed: publish this branch before creating a ${copy.reviewLabel}.` + }, + needs_push: { + ok: false, + code: 'validation', + error: `Create ${copy.shortLabel} failed: push this branch before creating a ${copy.reviewLabel}.` + }, + needs_sync: { + ok: false, + code: 'validation', + error: `Create ${copy.shortLabel} failed: sync this branch before creating a ${copy.reviewLabel}.` + }, + fork_head_unsupported: { + ok: false, + code: 'validation', + error: `Create ${copy.shortLabel} failed: refresh source control status and try again.` + } + } satisfies Partial< + Record, CreateHostedReviewResult> + > + return blockedCreateResultByReason[reason] ?? null +} function blockedEligibilityToCreateResult( eligibility: HostedReviewCreationEligibility @@ -212,20 +242,22 @@ function blockedEligibilityToCreateResult( return null } if (eligibility.review?.url) { + const copy = reviewCopy(eligibility.provider) return { ok: false, code: 'already_exists', - error: 'A pull request already exists for this branch.', + error: `A ${copy.reviewLabel} already exists for this branch.`, existingReview: eligibility.review } } if (eligibility.blockedReason) { - return blockedCreateResultByReason[eligibility.blockedReason] ?? null + return blockedCreateResultForReason(eligibility.blockedReason, eligibility.provider) } + const copy = reviewCopy(eligibility.provider) return { ok: false, code: 'validation', - error: 'Create PR failed: refresh source control status and try again.' + error: `Create ${copy.shortLabel} failed: refresh source control status and try again.` } } @@ -236,11 +268,12 @@ async function validateCurrentBranchCanCreateReview( ): Promise { const requestedHead = input.head ? stripRefPrefix(input.head).trim() : '' const currentBranch = await getCurrentBranch(repoPath, connectionId) + const copy = reviewCopy(input.provider) if (requestedHead && requestedHead !== currentBranch) { return { ok: false, code: 'validation', - error: 'Create PR failed: switch back to the selected branch before creating a pull request.' + error: `Create ${copy.shortLabel} failed: switch back to the selected branch before creating a ${copy.reviewLabel}.` } } @@ -267,8 +300,7 @@ async function validateCurrentBranchCanCreateReview( return { ok: false, code: 'validation', - error: - 'Create PR failed: could not verify branch status. Refresh source control and try again.' + error: `Create ${copy.shortLabel} failed: could not verify branch status. Refresh source control and try again.` } } } @@ -277,7 +309,10 @@ export async function getHostedReviewCreationEligibility( args: HostedReviewCreationEligibilityInput ): Promise { const branch = stripRefPrefix(args.branch).trim() - const provider = await detectHostedReviewProvider(args.repoPath, args.connectionId) + const provider = await detectHostedReviewProvider({ + repoPath: args.repoPath, + connectionId: args.connectionId + }) const defaultBaseRef = args.base?.trim() || (await getDefaultBaseRef(args.repoPath, args.connectionId)) const baseBranch = defaultBaseRef ? normalizeHostedReviewBaseRef(defaultBaseRef) : null @@ -311,7 +346,7 @@ export async function getHostedReviewCreationEligibility( nextAction: 'open_existing_review' } } - if (provider !== 'github') { + if (provider !== 'github' && provider !== 'gitlab') { return { ...baseResult, canCreate: false, @@ -334,7 +369,11 @@ export async function getHostedReviewCreationEligibility( if ((args.behind ?? 0) > 0) { return { ...baseResult, canCreate: false, blockedReason: 'needs_sync', nextAction: 'sync' } } - if (!(await isGitHubAuthenticated(args.repoPath, args.connectionId))) { + const authenticated = + provider === 'gitlab' + ? await isGitLabAuthenticated(args.repoPath, args.connectionId) + : await isGitHubAuthenticated(args.repoPath, args.connectionId) + if (!authenticated) { return { ...baseResult, canCreate: false, @@ -353,24 +392,25 @@ export async function createHostedReview( input: CreateHostedReviewInput, connectionId?: string | null ): Promise { - if (input.provider !== 'github') { + if (input.provider !== 'github' && input.provider !== 'gitlab') { return { ok: false, code: 'unsupported_provider', error: 'Creating reviews for this provider is not supported yet.' } } - const provider = await detectHostedReviewProvider(repoPath, connectionId) - if (provider !== 'github') { + const provider = await getForgeProviderForRepository({ repoPath, connectionId }) + if (provider?.id !== input.provider || !provider.createReview) { + const copy = reviewCopy(input.provider) return { ok: false, code: 'unsupported_provider', - error: 'Creating pull requests requires a GitHub remote.' + error: `Creating ${copy.reviewLabel}s requires a ${copy.providerName} remote.` } } const blocked = await validateCurrentBranchCanCreateReview(repoPath, connectionId, input) if (blocked) { return blocked } - return createGitHubPullRequest(repoPath, input, connectionId) + return provider.createReview(repoPath, input, connectionId) } diff --git a/src/main/source-control/hosted-review.test.ts b/src/main/source-control/hosted-review.test.ts index 2c148e3b20a..cf97faa5291 100644 --- a/src/main/source-control/hosted-review.test.ts +++ b/src/main/source-control/hosted-review.test.ts @@ -32,7 +32,8 @@ vi.mock('../gitlab/client', () => ({ vi.mock('../github/client', () => ({ getRepoSlug: getRepoSlugMock, - getPRForBranch: getPRForBranchMock + getPRForBranch: getPRForBranchMock, + createGitHubPullRequest: vi.fn() })) vi.mock('../bitbucket/client', () => ({ @@ -174,6 +175,7 @@ describe('getHostedReviewForBranch', () => { await expect( getHostedReviewForBranch({ repoPath: '/repo', + connectionId: 'ssh-1', branch: 'feature/bitbucket', linkedBitbucketPR: 11 }) @@ -188,10 +190,12 @@ describe('getHostedReviewForBranch', () => { mergeable: 'UNKNOWN', headSha: 'abc123' }) + expect(getBitbucketRepoSlugMock).toHaveBeenCalledWith('/repo', 'ssh-1') expect(getBitbucketPullRequestForBranchMock).toHaveBeenCalledWith( '/repo', 'feature/bitbucket', - 11 + 11, + 'ssh-1' ) }) @@ -219,6 +223,7 @@ describe('getHostedReviewForBranch', () => { await expect( getHostedReviewForBranch({ repoPath: '/repo', + connectionId: 'ssh-1', branch: 'feature/gitea', linkedGiteaPR: 14 }) @@ -233,7 +238,13 @@ describe('getHostedReviewForBranch', () => { mergeable: 'MERGEABLE', headSha: 'def456' }) - expect(getGiteaPullRequestForBranchMock).toHaveBeenCalledWith('/repo', 'feature/gitea', 14) + expect(getGiteaRepoSlugMock).toHaveBeenCalledWith('/repo', 'ssh-1') + expect(getGiteaPullRequestForBranchMock).toHaveBeenCalledWith( + '/repo', + 'feature/gitea', + 14, + 'ssh-1' + ) }) it('falls through to Azure DevOps before Gitea when origin is an Azure Repos remote', async () => { @@ -260,6 +271,7 @@ describe('getHostedReviewForBranch', () => { await expect( getHostedReviewForBranch({ repoPath: '/repo', + connectionId: 'ssh-1', branch: 'feature/azure', linkedAzureDevOpsPR: 21 }) @@ -274,10 +286,12 @@ describe('getHostedReviewForBranch', () => { mergeable: 'MERGEABLE', headSha: 'abc123' }) + expect(getAzureDevOpsRepoSlugMock).toHaveBeenCalledWith('/repo', 'ssh-1') expect(getAzureDevOpsPullRequestForBranchMock).toHaveBeenCalledWith( '/repo', 'feature/azure', - 21 + 21, + 'ssh-1' ) expect(getGiteaRepoSlugMock).not.toHaveBeenCalled() }) diff --git a/src/main/source-control/hosted-review.ts b/src/main/source-control/hosted-review.ts index 01d37a02bf9..37de21baf66 100644 --- a/src/main/source-control/hosted-review.ts +++ b/src/main/source-control/hosted-review.ts @@ -1,92 +1,28 @@ import type { HostedReviewInfo } from '../../shared/hosted-review' -import type { MRInfo, PRInfo } from '../../shared/types' -import { hostedReviewInfoFromGitHubPRInfo } from '../../shared/hosted-review-github' import { - getAzureDevOpsPullRequest, - getAzureDevOpsPullRequestForBranch, - getAzureDevOpsRepoSlug -} from '../azure-devops/client' -import type { AzureDevOpsPullRequestInfo } from '../azure-devops/pull-request-mappers' -import { - getBitbucketPullRequest, - getBitbucketPullRequestForBranch, - getBitbucketRepoSlug -} from '../bitbucket/client' -import type { BitbucketPullRequestInfo } from '../bitbucket/pull-request-mappers' -import { - getGiteaPullRequest, - getGiteaPullRequestForBranch, - getGiteaRepoSlug -} from '../gitea/client' -import type { GiteaPullRequestInfo } from '../gitea/pull-request-mappers' -import { getPRForBranch, getRepoSlug } from '../github/client' -import { getMergeRequest, getMergeRequestForBranch, getProjectSlug } from '../gitlab/client' + getForgeProviderById, + getForgeProviderForRepository, + type ForgeProviderId +} from './forge-provider' -function mapGitHubReview(pr: PRInfo): HostedReviewInfo { - return hostedReviewInfoFromGitHubPRInfo(pr) -} - -function mapGitLabReviewState(state: MRInfo['state']): HostedReviewInfo['state'] { - if (state === 'opened' || state === 'locked') { - return 'open' - } - return state -} - -function mapGitLabReview(mr: MRInfo): HostedReviewInfo { - return { - provider: 'gitlab', - number: mr.number, - title: mr.title, - state: mapGitLabReviewState(mr.state), - url: mr.url, - status: mr.pipelineStatus, - updatedAt: mr.updatedAt, - mergeable: mr.mergeable, - ...(mr.headSha ? { headSha: mr.headSha } : {}), - ...(mr.conflictSummary ? { conflictSummary: mr.conflictSummary } : {}) - } -} - -function mapBitbucketReview(pr: BitbucketPullRequestInfo): HostedReviewInfo { - return { - provider: 'bitbucket', - number: pr.number, - title: pr.title, - state: pr.state, - url: pr.url, - status: pr.status, - updatedAt: pr.updatedAt, - mergeable: pr.mergeable, - ...(pr.headSha ? { headSha: pr.headSha } : {}) - } -} - -function mapAzureDevOpsReview(pr: AzureDevOpsPullRequestInfo): HostedReviewInfo { - return { - provider: 'azure-devops', - number: pr.number, - title: pr.title, - state: pr.state, - url: pr.url, - status: pr.status, - updatedAt: pr.updatedAt, - mergeable: pr.mergeable, - ...(pr.headSha ? { headSha: pr.headSha } : {}) - } -} - -function mapGiteaReview(pr: GiteaPullRequestInfo): HostedReviewInfo { - return { - provider: 'gitea', - number: pr.number, - title: pr.title, - state: pr.state, - url: pr.url, - status: pr.status, - updatedAt: pr.updatedAt, - mergeable: pr.mergeable, - ...(pr.headSha ? { headSha: pr.headSha } : {}) +function reviewLinkForProvider( + input: Parameters[0], + provider: ForgeProviderId +): { linkedReviewNumber?: number | null; fallbackReviewNumber?: number | null } { + switch (provider) { + case 'github': + return { + linkedReviewNumber: input.linkedGitHubPR ?? null, + fallbackReviewNumber: input.linkedGitHubPR == null ? (input.fallbackGitHubPR ?? null) : null + } + case 'gitlab': + return { linkedReviewNumber: input.linkedGitLabMR ?? null } + case 'bitbucket': + return { linkedReviewNumber: input.linkedBitbucketPR ?? null } + case 'azure-devops': + return { linkedReviewNumber: input.linkedAzureDevOpsPR ?? null } + case 'gitea': + return { linkedReviewNumber: input.linkedGiteaPR ?? null } } } @@ -116,96 +52,25 @@ export async function getHostedReviewForBranch(input: { return null } - // Why: branch review status is tied to the branch publishing remote. - // GitHub and GitLab task/project surfaces may use richer per-provider - // source preferences, but this core status should follow origin. - const gitlabProject = await getProjectSlug(input.repoPath, input.connectionId) - if (gitlabProject) { - const mr = - (await getMergeRequestForBranch( - input.repoPath, - branchName, - input.linkedGitLabMR ?? null, - input.connectionId - )) ?? null - return mr ? mapGitLabReview(mr) : null + const provider = await getForgeProviderForRepository({ + repoPath: input.repoPath, + connectionId: input.connectionId + }) + if (!provider) { + return null } - - const githubRepo = await getRepoSlug(input.repoPath, input.connectionId) - if (githubRepo) { - const fallbackGitHubPR = input.linkedGitHubPR == null ? (input.fallbackGitHubPR ?? null) : null - const pr = - fallbackGitHubPR !== null - ? await getPRForBranch( - input.repoPath, - branchName, - input.linkedGitHubPR ?? null, - input.connectionId, - fallbackGitHubPR - ) - : await getPRForBranch( - input.repoPath, - branchName, - input.linkedGitHubPR ?? null, - input.connectionId - ) - return pr ? mapGitHubReview(pr) : null - } - - const bitbucketRepo = await getBitbucketRepoSlug(input.repoPath) - if (bitbucketRepo) { - const pr = await getBitbucketPullRequestForBranch( - input.repoPath, - branchName, - input.linkedBitbucketPR ?? null - ) - return pr ? mapBitbucketReview(pr) : null - } - - const azureDevOpsRepo = await getAzureDevOpsRepoSlug(input.repoPath) - if (azureDevOpsRepo) { - const pr = await getAzureDevOpsPullRequestForBranch( - input.repoPath, - branchName, - input.linkedAzureDevOpsPR ?? null - ) - return pr ? mapAzureDevOpsReview(pr) : null - } - - const giteaRepo = await getGiteaRepoSlug(input.repoPath) - if (giteaRepo) { - const pr = await getGiteaPullRequestForBranch( - input.repoPath, - branchName, - input.linkedGiteaPR ?? null - ) - return pr ? mapGiteaReview(pr) : null - } - - return null + return provider.getReviewForBranch({ + repoPath: input.repoPath, + connectionId: input.connectionId, + branch: branchName, + ...reviewLinkForProvider(input, provider.id) + }) } export async function getHostedReviewByNumber(input: { repoPath: string - provider: 'github' | 'gitlab' | 'bitbucket' | 'azure-devops' | 'gitea' + provider: ForgeProviderId number: number }): Promise { - if (input.provider === 'gitlab') { - const mr = await getMergeRequest(input.repoPath, input.number) - return mr ? mapGitLabReview(mr) : null - } - if (input.provider === 'bitbucket') { - const pr = await getBitbucketPullRequest(input.repoPath, input.number) - return pr ? mapBitbucketReview(pr) : null - } - if (input.provider === 'azure-devops') { - const pr = await getAzureDevOpsPullRequest(input.repoPath, input.number) - return pr ? mapAzureDevOpsReview(pr) : null - } - if (input.provider === 'gitea') { - const pr = await getGiteaPullRequest(input.repoPath, input.number) - return pr ? mapGiteaReview(pr) : null - } - const pr = await getPRForBranch(input.repoPath, '', input.number) - return pr ? mapGitHubReview(pr) : null + return getForgeProviderById(input.provider).getReviewByNumber(input) } diff --git a/src/main/speech/model-manager-download-error.test.ts b/src/main/speech/model-manager-download-error.test.ts new file mode 100644 index 00000000000..14c33908eb2 --- /dev/null +++ b/src/main/speech/model-manager-download-error.test.ts @@ -0,0 +1,70 @@ +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { SPEECH_MODEL_CATALOG } from './model-catalog' +import { ModelManager } from './model-manager' + +const { httpsGetMock } = vi.hoisted(() => ({ + httpsGetMock: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { + getPath: () => '/tmp/orca-speech-models-test' + } +})) + +vi.mock('https', async () => { + const actual = await vi.importActual('https') + return { ...(actual as Record), get: httpsGetMock } +}) + +describe('ModelManager download failures', () => { + beforeEach(() => { + httpsGetMock.mockReset() + }) + + it('rejects failed model downloads so the caller can surface the error', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-model-manager-')) + try { + const manifest = SPEECH_MODEL_CATALOG[0] + const errorHandlers: ((err: Error) => void)[] = [] + const request = { + destroy: vi.fn(() => request), + setTimeout: vi.fn(() => request), + on: vi.fn((event: string, cb: (err: Error) => void) => { + if (event === 'error') { + errorHandlers.push(cb) + } + return request + }), + off: vi.fn((event: string, cb: (err: Error) => void) => { + if (event === 'error') { + const index = errorHandlers.indexOf(cb) + if (index !== -1) { + errorHandlers.splice(index, 1) + } + } + return request + }) + } + httpsGetMock.mockImplementation(() => { + queueMicrotask(() => { + for (const handler of errorHandlers) { + handler(new Error('network down')) + } + }) + return request + }) + const manager = new ModelManager(dir) + + await expect(manager.downloadModel(manifest.id)).rejects.toThrow('network down') + + expect(request.off).toHaveBeenCalledWith('error', expect.any(Function)) + expect(errorHandlers).toHaveLength(0) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/speech/model-manager-progress-callback.test.ts b/src/main/speech/model-manager-progress-callback.test.ts new file mode 100644 index 00000000000..2e8fe6d639a --- /dev/null +++ b/src/main/speech/model-manager-progress-callback.test.ts @@ -0,0 +1,48 @@ +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { describe, expect, it, vi } from 'vitest' +import { ModelManager } from './model-manager' + +vi.mock('electron', () => ({ + app: { + getPath: () => '/tmp/orca-speech-models-test' + } +})) + +type ModelManagerInternals = { + updateState: ( + modelId: string, + status: 'not-downloaded' | 'downloading' | 'extracting' | 'ready' | 'error', + progress?: number, + error?: string + ) => void +} + +describe('ModelManager progress callbacks', () => { + it('unsubscribes progress callbacks without replacing other listeners', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-model-manager-')) + try { + const manager = new ModelManager(dir) + const internals = manager as unknown as ModelManagerInternals + const first = vi.fn() + const second = vi.fn() + const clearFirst = manager.setProgressCallback(first) + const clearSecond = manager.setProgressCallback(second) + + internals.updateState('model-a', 'downloading', 0.25) + clearFirst() + internals.updateState('model-a', 'extracting') + clearSecond() + internals.updateState('model-a', 'ready') + + expect(first).toHaveBeenCalledTimes(1) + expect(first).toHaveBeenCalledWith('model-a', 0.25) + expect(second).toHaveBeenCalledTimes(2) + expect(second).toHaveBeenNthCalledWith(1, 'model-a', 0.25) + expect(second).toHaveBeenNthCalledWith(2, 'model-a', 0.95) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/speech/model-manager-stream-cleanup.test.ts b/src/main/speech/model-manager-stream-cleanup.test.ts new file mode 100644 index 00000000000..6f665b292c3 --- /dev/null +++ b/src/main/speech/model-manager-stream-cleanup.test.ts @@ -0,0 +1,76 @@ +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { PassThrough } from 'stream' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ModelManager } from './model-manager' + +const { httpsGetMock } = vi.hoisted(() => ({ + httpsGetMock: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { + getPath: () => '/tmp/orca-speech-models-test' + } +})) + +vi.mock('https', async () => { + const actual = await vi.importActual('https') + return { ...(actual as Record), get: httpsGetMock } +}) + +type ModelManagerInternals = { + downloadFile: ( + url: string, + dest: string, + expectedSize: number, + modelId: string, + isAborted: () => boolean, + signal?: AbortSignal + ) => Promise +} + +describe('ModelManager stream cleanup', () => { + beforeEach(() => { + httpsGetMock.mockReset() + }) + + it('removes response progress listeners after a model download finishes', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-model-manager-')) + try { + const response = new PassThrough() as PassThrough & { + statusCode: number + headers: Record + } + response.statusCode = 200 + response.headers = { 'content-length': '4' } + const request = { + destroy: vi.fn(() => request), + setTimeout: vi.fn(() => request), + on: vi.fn(() => request), + off: vi.fn(() => request) + } + httpsGetMock.mockImplementation((_url: URL, cb: (response: unknown) => void) => { + cb(response) + return request + }) + const manager = new ModelManager(dir) as unknown as ModelManagerInternals + + const download = manager.downloadFile( + 'https://example.com/model.tar.bz2', + join(dir, 'model.tar.bz2'), + 4, + 'm', + () => false + ) + response.write(Buffer.from('ab')) + response.end(Buffer.from('cd')) + + await expect(download).resolves.toBeUndefined() + expect(response.listenerCount('data')).toBe(0) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/speech/model-manager.test.ts b/src/main/speech/model-manager.test.ts index d799b768f91..4e6cb5ee754 100644 --- a/src/main/speech/model-manager.test.ts +++ b/src/main/speech/model-manager.test.ts @@ -98,6 +98,7 @@ describe('ModelManager', () => { try { const manifest = SPEECH_MODEL_CATALOG[0] const errorHandlers: ((err: Error) => void)[] = [] + const timeoutHandlers: (() => void)[] = [] const request = { destroy: vi.fn((err?: Error) => { queueMicrotask(() => { @@ -107,11 +108,30 @@ describe('ModelManager', () => { }) return request }), + setTimeout: vi.fn((_ms: number, cb: () => void) => { + timeoutHandlers.push(cb) + return request + }), on: vi.fn((event: string, cb: (err: Error) => void) => { if (event === 'error') { errorHandlers.push(cb) } return request + }), + off: vi.fn((event: string, cb: ((err: Error) => void) | (() => void)) => { + if (event === 'error') { + const index = errorHandlers.indexOf(cb as (err: Error) => void) + if (index !== -1) { + errorHandlers.splice(index, 1) + } + } + if (event === 'timeout') { + const index = timeoutHandlers.indexOf(cb as () => void) + if (index !== -1) { + timeoutHandlers.splice(index, 1) + } + } + return request }) } httpsGetMock.mockImplementation( @@ -135,12 +155,94 @@ describe('ModelManager', () => { await expect(download).resolves.toBeUndefined() expect(request.destroy).toHaveBeenCalledWith(expect.any(Error)) + expect(request.off).toHaveBeenCalledWith('error', expect.any(Function)) + expect(request.off).toHaveBeenCalledWith('timeout', expect.any(Function)) + expect(errorHandlers).toHaveLength(0) + expect(timeoutHandlers).toHaveLength(0) expect((await manager.getModelState(manifest.id)).status).toBe('not-downloaded') } finally { rmSync(dir, { recursive: true, force: true }) } }) + it('times out a model download request that never responds', async () => { + vi.useFakeTimers() + const dir = mkdtempSync(join(tmpdir(), 'orca-model-manager-')) + try { + const errorHandlers: ((err: Error) => void)[] = [] + const timeoutHandlers: (() => void)[] = [] + const request = { + destroy: vi.fn((err?: Error) => { + if (err) { + queueMicrotask(() => { + for (const handler of errorHandlers) { + handler(err) + } + }) + } + return request + }), + setTimeout: vi.fn((ms: number, cb: () => void) => { + timeoutHandlers.push(cb) + setTimeout(() => { + for (const handler of timeoutHandlers) { + handler() + } + }, ms) + return request + }), + on: vi.fn((event: string, cb: (err: Error) => void) => { + if (event === 'error') { + errorHandlers.push(cb) + } + return request + }), + off: vi.fn((event: string, cb: ((err: Error) => void) | (() => void)) => { + if (event === 'error') { + const index = errorHandlers.indexOf(cb as (err: Error) => void) + if (index !== -1) { + errorHandlers.splice(index, 1) + } + } + if (event === 'timeout') { + const index = timeoutHandlers.indexOf(cb as () => void) + if (index !== -1) { + timeoutHandlers.splice(index, 1) + } + } + return request + }) + } + httpsGetMock.mockReturnValue(request) + const manager = new ModelManager(dir) as unknown as ModelManagerInternals + + const download = manager.downloadFile( + 'https://example.com/model.tar.bz2', + join(dir, 'model.tar.bz2'), + 1, + 'm', + () => false + ) + const outcomePromise = download.then( + () => 'resolved', + (error) => (error instanceof Error ? error.message : String(error)) + ) + + await vi.advanceTimersByTimeAsync(120_000) + const outcome = await Promise.race([outcomePromise, Promise.resolve('pending')]) + + expect(outcome).toBe('Model download timed out after 120 seconds without network activity') + expect(request.destroy).toHaveBeenCalledWith() + expect(request.off).toHaveBeenCalledWith('error', expect.any(Function)) + expect(request.off).toHaveBeenCalledWith('timeout', expect.any(Function)) + expect(errorHandlers).toHaveLength(0) + expect(timeoutHandlers).toHaveLength(0) + } finally { + vi.useRealTimers() + rmSync(dir, { recursive: true, force: true }) + } + }) + it('clears extraction abort polling when the child does not close', async () => { vi.useFakeTimers() const dir = mkdtempSync(join(tmpdir(), 'orca-model-manager-')) diff --git a/src/main/speech/model-manager.ts b/src/main/speech/model-manager.ts index 5ddd739d7f1..bc51de602a4 100644 --- a/src/main/speech/model-manager.ts +++ b/src/main/speech/model-manager.ts @@ -22,19 +22,26 @@ type DownloadHandle = { type ProgressCallback = (modelId: string, progress: number) => void +const DOWNLOAD_IDLE_TIMEOUT_MS = 120_000 + export class ModelManager { private modelsDir: string private activeDownloads = new Map() private modelStates = new Map() - private progressCallback: ProgressCallback | null = null + private progressCallbacks = new Set() constructor(customModelsDir?: string) { this.modelsDir = customModelsDir || join(app.getPath('userData'), 'speech-models') mkdirSync(this.modelsDir, { recursive: true }) } - setProgressCallback(cb: ProgressCallback): void { - this.progressCallback = cb + setProgressCallback(cb: ProgressCallback): () => void { + // Why: concurrent settings windows can observe the same download; a + // returned unsubscribe prevents one window from replacing another. + this.progressCallbacks.add(cb) + return () => { + this.progressCallbacks.delete(cb) + } } getModelsDir(): string { @@ -177,6 +184,11 @@ export class ModelManager { this.updateState(modelId, 'error', undefined, String(err)) } this.cleanup(modelId, archivePath) + if (!aborted) { + // Why: the settings UI awaits this promise to show download failures; + // cancellation stays quiet, but real failures must reach the caller. + throw err + } } finally { this.activeDownloads.delete(modelId) try { @@ -219,7 +231,10 @@ export class ModelManager { this.modelStates.set(modelId, state) // Why: notify the renderer on every state change (not just download // progress) so the UI updates for extracting/ready/error transitions. - this.progressCallback?.(modelId, progress ?? (status === 'extracting' ? 0.95 : -1)) + const progressValue = progress ?? (status === 'extracting' ? 0.95 : -1) + for (const callback of this.progressCallbacks) { + callback(modelId, progressValue) + } } private downloadFile( @@ -250,7 +265,43 @@ export class ModelManager { return } - let request: ReturnType + let settled = false + let request: ReturnType | null = null + const cleanupRequestListeners = (): void => { + const activeRequest = request + if (!activeRequest) { + return + } + activeRequest.off('error', onRequestError) + activeRequest.off('timeout', onRequestTimeout) + request = null + } + const resolveOnce = (): void => { + if (settled) { + return + } + settled = true + cleanupRequestListeners() + resolve() + } + const rejectOnce = (error: Error): void => { + if (settled) { + return + } + settled = true + cleanupRequestListeners() + reject(error) + } + const onRequestError = (error: Error): void => rejectOnce(error) + const onRequestTimeout = (): void => { + const activeRequest = request + rejectOnce( + new Error( + `Model download timed out after ${DOWNLOAD_IDLE_TIMEOUT_MS / 1000} seconds without network activity` + ) + ) + activeRequest?.destroy() + } const onResponse = (response: IncomingMessage): void => { if ( response.statusCode === 301 || @@ -262,12 +313,12 @@ export class ModelManager { const redirectUrl = response.headers.location if (!redirectUrl) { response.resume() - reject(new Error('Redirect without location')) + rejectOnce(new Error('Redirect without location')) return } if (redirectCount >= 5) { response.resume() - reject(new Error('Too many redirects')) + rejectOnce(new Error('Too many redirects')) return } let resolvedRedirect: URL @@ -275,12 +326,12 @@ export class ModelManager { resolvedRedirect = new URL(redirectUrl, parsedUrl) } catch { response.resume() - reject(new Error('Invalid redirect URL')) + rejectOnce(new Error('Invalid redirect URL')) return } if (resolvedRedirect.protocol !== 'https:') { response.resume() - reject(new Error('Model download redirect must use HTTPS')) + rejectOnce(new Error('Model download redirect must use HTTPS')) return } response.resume() @@ -293,14 +344,14 @@ export class ModelManager { signal, redirectCount + 1 ) - .then(resolve) - .catch(reject) + .then(resolveOnce) + .catch(rejectOnce) return } if (response.statusCode !== 200) { response.resume() - reject(new Error(`HTTP ${response.statusCode}`)) + rejectOnce(new Error(`HTTP ${response.statusCode}`)) return } @@ -309,9 +360,12 @@ export class ModelManager { const fileStream = createWriteStream(dest) - response.on('data', (chunk: Buffer) => { + const cleanupResponseProgressListener = (): void => { + response.off('data', onResponseData) + } + const onResponseData = (chunk: Buffer): void => { if (isAborted()) { - request.destroy(new Error('Aborted')) + request?.destroy(new Error('Aborted')) response.destroy() fileStream.destroy() return @@ -319,24 +373,33 @@ export class ModelManager { downloaded += chunk.length const progress = Math.min(0.9, downloaded / totalSize) this.updateState(modelId, 'downloading', progress) - }) + } + response.on('data', onResponseData) pipeline(response, fileStream) .then(() => { + cleanupResponseProgressListener() if (isAborted()) { - reject(new Error('Aborted')) + rejectOnce(new Error('Aborted')) } else { - resolve() + resolveOnce() } }) - .catch(reject) + .catch((error: Error) => { + cleanupResponseProgressListener() + rejectOnce(error) + }) } request = signal ? httpsGet(parsedUrl, { signal }, onResponse) : httpsGet(parsedUrl, onResponse) - request.on('error', reject) + // Why: cancellation only helps after the user presses cancel; a peer + // that accepts the socket and goes silent must not leave the model stuck + // in "downloading" forever. + request.setTimeout(DOWNLOAD_IDLE_TIMEOUT_MS, onRequestTimeout) + request.on('error', onRequestError) }) } @@ -344,19 +407,49 @@ export class ModelManager { return new Promise((resolve, reject) => { const hash = createHash('sha256') const stream = createReadStream(archivePath) + let settled = false - stream.on('data', (chunk) => hash.update(chunk)) - stream.on('error', reject) - stream.on('end', () => { + const cleanup = (): void => { + stream.off('data', onData) + stream.off('error', onError) + stream.off('end', onEnd) + } + const settleResolve = (): void => { + if (settled) { + return + } + settled = true + cleanup() + resolve() + } + const settleReject = (error: Error): void => { + if (settled) { + return + } + settled = true + cleanup() + reject(error) + } + const onData = (chunk: Buffer): void => { + hash.update(chunk) + } + const onError = (error: Error): void => { + settleReject(error) + } + const onEnd = (): void => { const actualSha256 = hash.digest('hex') if (actualSha256 !== expectedSha256.toLowerCase()) { // Why: these archives feed native model parsers; filename checks do // not protect against compromised or redirected release assets. - reject(new Error('Downloaded model archive failed integrity verification')) + settleReject(new Error('Downloaded model archive failed integrity verification')) return } - resolve() - }) + settleResolve() + } + + stream.on('data', onData) + stream.on('error', onError) + stream.on('end', onEnd) }) } diff --git a/src/main/speech/stt-service.test.ts b/src/main/speech/stt-service.test.ts index fb4561b1dd7..e83b07c7a1a 100644 --- a/src/main/speech/stt-service.test.ts +++ b/src/main/speech/stt-service.test.ts @@ -98,7 +98,7 @@ vi.mock('./model-catalog', () => ({ }) })) -import { IDLE_WORKER_TEARDOWN_MS, SttService } from './stt-service' +import { IDLE_WORKER_TEARDOWN_MS, START_DICTATION_TIMEOUT_MS, SttService } from './stt-service' describe('SttService', () => { beforeEach(() => { @@ -205,6 +205,36 @@ describe('SttService', () => { ) }) + it('times out startup when the worker never reports ready', async () => { + vi.useFakeTimers() + try { + const service = new SttService({ + getModelState: vi.fn().mockResolvedValue({ id: 'model-a', status: 'ready' }), + getModelDir: vi.fn().mockReturnValue('/tmp/model-a') + } as never) + + MockWorker.emitReadyOnInit = false + const startPromise = service.startDictation('model-a', vi.fn(), undefined, 'desktop').then( + () => 'resolved', + (error) => (error instanceof Error ? error.message : String(error)) + ) + await Promise.resolve() + const worker = getLastWorker() + expect(worker).toBeDefined() + + await vi.advanceTimersByTimeAsync(START_DICTATION_TIMEOUT_MS) + const outcome = await Promise.race([startPromise, Promise.resolve('pending')]) + + expect(outcome).toBe('Speech worker timed out while starting.') + expect(worker!.terminated).toBe(true) + expect(worker!.listenerCount('message')).toBe(0) + expect(worker!.listenerCount('error')).toBe(0) + expect(worker!.listenerCount('exit')).toBe(0) + } finally { + vi.useRealTimers() + } + }) + it('does not treat internal warm-worker replacement as startup cancellation', async () => { const service = new SttService({ getModelState: vi.fn().mockResolvedValue({ id: 'model-a', status: 'ready' }), @@ -219,6 +249,32 @@ describe('SttService', () => { ).resolves.toBe(undefined) }) + it('removes lifecycle listeners when the active worker errors', async () => { + const sink = vi.fn() + const service = new SttService({ + getModelState: vi.fn().mockResolvedValue({ id: 'model-a', status: 'ready' }), + getModelDir: vi.fn().mockReturnValue('/tmp/model-a') + } as never) + + await service.startDictation('model-a', sink, undefined, 'desktop') + const worker = getLastWorker() + expect(worker).toBeDefined() + expect(worker!.listenerCount('message')).toBe(1) + expect(worker!.listenerCount('error')).toBe(1) + expect(worker!.listenerCount('exit')).toBe(1) + + worker!.emit('error', new Error('worker failed')) + + expect(service.isActive()).toBe(false) + expect(sink).toHaveBeenCalledWith({ + type: 'error', + error: 'Error: worker failed' + }) + expect(worker!.listenerCount('message')).toBe(0) + expect(worker!.listenerCount('error')).toBe(0) + expect(worker!.listenerCount('exit')).toBe(0) + }) + it('allows slow offline stop decoding before terminating the worker', async () => { vi.useFakeTimers() try { diff --git a/src/main/speech/stt-service.ts b/src/main/speech/stt-service.ts index e943565bf56..81506a9a9b2 100644 --- a/src/main/speech/stt-service.ts +++ b/src/main/speech/stt-service.ts @@ -1,11 +1,13 @@ /* eslint-disable max-lines -- Why: speech worker ownership, warm reuse, and timeout teardown must stay co-located so dictation lifecycle state cannot drift. */ import { Worker } from 'worker_threads' +import { existsSync } from 'fs' import { join } from 'path' import { app } from 'electron' import { getCatalogModel } from './model-catalog' import type { ModelManager } from './model-manager' +export const START_DICTATION_TIMEOUT_MS = 60_000 const STOP_DICTATION_TIMEOUT_MS = 60_000 export const IDLE_WORKER_TEARDOWN_MS = 60 * 60 * 1000 @@ -29,6 +31,9 @@ export class SttService { private canceledOwners = new Set() private eventSink: SttEventSink | null = null private idleTeardownTimer: NodeJS.Timeout | null = null + // Why: warm workers intentionally keep lifecycle listeners while reusable; + // stale workers must not retain this service after error, exit, or teardown. + private cleanupWorkerLifecycleListeners: (() => void) | null = null constructor(modelManager: ModelManager) { this.modelManager = modelManager @@ -112,11 +117,24 @@ export class SttService { const readyPromise = new Promise((resolve, reject) => { let settled = false + let startupTimeout: ReturnType | null = null const cleanup = () => { + if (startupTimeout) { + clearTimeout(startupTimeout) + startupTimeout = null + } worker.off('message', onReadyOrError) worker.off('error', onStartupError) worker.off('exit', onStartupExit) } + const failStartup = (error: Error): void => { + if (settled) { + return + } + settled = true + cleanup() + reject(error) + } const onReadyOrError = (msg: { type: string; text?: string; error?: string }) => { if (settled) { return @@ -126,56 +144,61 @@ export class SttService { cleanup() resolve() } else if (msg.type === 'error') { - settled = true - cleanup() - reject(new Error(msg.error ?? 'Speech worker failed to initialize')) + failStartup(new Error(msg.error ?? 'Speech worker failed to initialize')) } } const onStartupError = (err: Error) => { - if (settled) { - return - } - settled = true - cleanup() - reject(err) + failStartup(err) } const onStartupExit = (code: number) => { - if (settled) { - return - } - settled = true - cleanup() - reject(new Error(`Speech worker exited before ready: ${code}`)) + failStartup(new Error(`Speech worker exited before ready: ${code}`)) } worker.on('message', onReadyOrError) worker.on('error', onStartupError) worker.on('exit', onStartupExit) + // Why: a native STT worker can wedge while loading model bindings without + // emitting ready/error/exit; startup must leave the UI's Starting state. + startupTimeout = setTimeout(() => { + failStartup(new Error('Speech worker timed out while starting.')) + }, START_DICTATION_TIMEOUT_MS) + startupTimeout.unref?.() }) - worker.on('message', (msg: SttEvent) => { + const onWorkerMessage = (msg: SttEvent) => { this.eventSink?.(msg) - }) + } - worker.on('error', (err) => { + const onWorkerError = (err: Error) => { this.eventSink?.({ type: 'error', error: String(err) }) if (this.worker === worker) { + this.cleanupActiveWorkerLifecycleListeners() this.worker = null this.activeModelId = null this.activeHotwordsFilePath = undefined this.activeOwner = null this.eventSink = null } - }) + } - worker.on('exit', () => { + const onWorkerExit = () => { if (this.worker === worker) { + this.cleanupActiveWorkerLifecycleListeners() this.worker = null this.activeModelId = null this.activeHotwordsFilePath = undefined this.activeOwner = null this.eventSink = null } - }) + } + + worker.on('message', onWorkerMessage) + worker.on('error', onWorkerError) + worker.on('exit', onWorkerExit) + this.cleanupWorkerLifecycleListeners = () => { + worker.off('message', onWorkerMessage) + worker.off('error', onWorkerError) + worker.off('exit', onWorkerExit) + } const modelDir = this.modelManager.getModelDir(modelId) worker.postMessage({ @@ -192,6 +215,7 @@ export class SttService { try { await readyPromise } catch (error) { + this.cleanupActiveWorkerLifecycleListeners() worker.removeAllListeners() void worker.terminate() if (this.worker === worker) { @@ -271,6 +295,7 @@ export class SttService { cleanup() // Why: a worker that cannot finish dictation is no longer reusable; do // not keep it in the warm-worker slot or retain its message listeners. + this.cleanupActiveWorkerLifecycleListeners() worker.removeAllListeners() void worker.terminate().finally(resolve) }, STOP_DICTATION_TIMEOUT_MS) @@ -333,6 +358,7 @@ export class SttService { } const worker = this.worker worker.postMessage({ type: 'teardown' }) + this.cleanupActiveWorkerLifecycleListeners() worker.removeAllListeners() await worker.terminate().catch(() => undefined) if (this.worker === worker) { @@ -343,6 +369,12 @@ export class SttService { } } + private cleanupActiveWorkerLifecycleListeners(): void { + const cleanup = this.cleanupWorkerLifecycleListeners + this.cleanupWorkerLifecycleListeners = null + cleanup?.() + } + private getSherpaModulePath(): string { // Why: the main sherpa-onnx npm package uses WASM, which cannot access // the host filesystem to load model files. The platform-specific native @@ -355,6 +387,10 @@ export class SttService { : `sherpa-onnx-${process.platform}-${process.arch}` if (app.isPackaged) { + const resourcesNodeModule = join(process.resourcesPath, 'node_modules', nativePkg) + if (existsSync(resourcesNodeModule)) { + return resourcesNodeModule + } return join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', nativePkg) } diff --git a/src/main/sqlite/sync-database.ts b/src/main/sqlite/sync-database.ts new file mode 100644 index 00000000000..c57f29399e4 --- /dev/null +++ b/src/main/sqlite/sync-database.ts @@ -0,0 +1,69 @@ +import { existsSync } from 'node:fs' +import { DatabaseSync, type StatementSync, type SQLInputValue } from 'node:sqlite' + +// Why: keep existing synchronous DB call sites on a tiny adapter while using +// Electron's built-in Node SQLite instead of a third-party native addon. +type SqlitePath = ConstructorParameters[0] + +type SyncDatabaseOptions = { + readonly?: boolean + fileMustExist?: boolean + timeout?: number +} + +type PragmaOptions = { + simple?: boolean +} + +export type SqliteStatement = StatementSync + +class SyncDatabase { + private readonly db: DatabaseSync + + constructor(path: SqlitePath, options: SyncDatabaseOptions = {}) { + if ( + options.fileMustExist && + typeof path === 'string' && + path !== ':memory:' && + !existsSync(path) + ) { + throw new Error(`SQLite database does not exist: ${path}`) + } + this.db = new DatabaseSync(path, { + readOnly: options.readonly, + timeout: options.timeout + }) + } + + exec(sql: string): void { + this.db.exec(sql) + } + + prepare(sql: string): StatementSync { + return this.db.prepare(sql) + } + + pragma(sql: string, options?: PragmaOptions): unknown { + const statement = this.db.prepare(`PRAGMA ${sql}`) + if (options?.simple) { + const row = statement.get() + if (!row) { + return undefined + } + return Object.values(row)[0] + } + return statement.all() + } + + close(): void { + this.db.close() + } +} + +namespace SyncDatabase { + export type Database = SyncDatabase + export type Statement = SqliteStatement + export type BindValue = SQLInputValue +} + +export default SyncDatabase diff --git a/src/main/ssh/relay-protocol.ts b/src/main/ssh/relay-protocol.ts index 41efd5d4523..e3927e41924 100644 --- a/src/main/ssh/relay-protocol.ts +++ b/src/main/ssh/relay-protocol.ts @@ -22,10 +22,6 @@ export const MessageType = { export const KEEPALIVE_SEND_MS = 5_000 export const TIMEOUT_MS = 20_000 -/** PTY flow control watermarks (VS Code FlowControlConstants). */ -export const PTY_FLOW_HIGH_WATERMARK = 100_000 -export const PTY_FLOW_LOW_WATERMARK = 5_000 - /** Reconnection grace period (default, overridable by relay --grace-time). */ export const DEFAULT_GRACE_TIME_MS = 3 * 60 * 60 * 1000 // 3 hours diff --git a/src/main/ssh/sftp-upload.test.ts b/src/main/ssh/sftp-upload.test.ts index 40676a34dc9..908b230f995 100644 --- a/src/main/ssh/sftp-upload.test.ts +++ b/src/main/ssh/sftp-upload.test.ts @@ -4,7 +4,7 @@ import { join } from 'path' import { Writable } from 'stream' import { describe, expect, it, vi } from 'vitest' import type { SFTPWrapper } from 'ssh2' -import { uploadBuffer, uploadDirectory, uploadFile } from './sftp-upload' +import { removeDirectorySftp, uploadBuffer, uploadDirectory, uploadFile } from './sftp-upload' function createWritable(): Writable { return new Writable({ @@ -17,7 +17,12 @@ function createWritable(): Writable { function createSftpMock(): SFTPWrapper { return { mkdir: vi.fn((_path: string, cb: (err?: Error | null) => void) => cb(null)), - createWriteStream: vi.fn(() => createWritable()) + createWriteStream: vi.fn(() => createWritable()), + readdir: vi.fn((_path: string, cb: (err?: Error | null, entries?: unknown[]) => void) => + cb(null, []) + ), + unlink: vi.fn((_path: string, cb: (err?: Error | null) => void) => cb(null)), + rmdir: vi.fn((_path: string, cb: (err?: Error | null) => void) => cb(null)) } as unknown as SFTPWrapper } @@ -32,6 +37,9 @@ describe('sftp-upload', () => { expect(sftp.createWriteStream).toHaveBeenCalledWith('/remote/.logo.orca-upload', { flags: 'wx' }) + const writeStream = vi.mocked(sftp.createWriteStream).mock.results[0]?.value as Writable + expect(writeStream.listenerCount('close')).toBe(0) + expect(writeStream.listenerCount('error')).toBe(0) }) it('uses no-clobber writes for nested files during exclusive directory upload', async () => { @@ -48,16 +56,93 @@ describe('sftp-upload', () => { expect(sftp.createWriteStream).toHaveBeenCalledWith('/remote/assets/nested/asset.txt', { flags: 'wx' }) + const writeStream = vi.mocked(sftp.createWriteStream).mock.results[0]?.value as Writable + expect(writeStream.listenerCount('close')).toBe(0) + expect(writeStream.listenerCount('error')).toBe(0) + }) + + it('uploads files from valid dot-dot-prefixed local directories', async () => { + const localDir = await mkdtemp(join(tmpdir(), 'orca-sftp-upload-')) + await mkdir(join(localDir, '..fixtures')) + await writeFile(join(localDir, '..fixtures', 'asset.txt'), 'asset') + const sftp = createSftpMock() + + await uploadDirectory(sftp, localDir, '/remote/assets', await realpath(localDir), { + exclusive: true + }) + + expect(sftp.mkdir).toHaveBeenCalledWith('/remote/assets/..fixtures', expect.any(Function)) + expect(sftp.createWriteStream).toHaveBeenCalledWith('/remote/assets/..fixtures/asset.txt', { + flags: 'wx' + }) + }) + + it('rejects sibling directories outside the upload root', async () => { + const localDir = await mkdtemp(join(tmpdir(), 'orca-sftp-upload-')) + const escapedDir = `${localDir}-sibling` + await mkdir(escapedDir) + await writeFile(join(escapedDir, 'asset.txt'), 'asset') + const sftp = createSftpMock() + + await expect( + uploadDirectory(sftp, escapedDir, '/remote/assets', await realpath(localDir), { + exclusive: true + }) + ).rejects.toThrow('Path escaped upload root') + + expect(sftp.mkdir).not.toHaveBeenCalled() + expect(sftp.createWriteStream).not.toHaveBeenCalled() }) it('does not create the remote file when the local source is a symlink', async () => { const localDir = await mkdtemp(join(tmpdir(), 'orca-sftp-upload-')) - await writeFile(join(localDir, 'target.txt'), 'secret') - await symlink(join(localDir, 'target.txt'), join(localDir, 'link.txt')) + const targetPath = join(localDir, process.platform === 'win32' ? 'target-dir' : 'target.txt') + const linkPath = join(localDir, process.platform === 'win32' ? 'link-dir' : 'link.txt') + if (process.platform === 'win32') { + await mkdir(targetPath) + // Why: file symlinks often require Developer Mode/admin on Windows, while + // junctions still exercise the symlink rejection branch. + await symlink(targetPath, linkPath, 'junction') + } else { + await writeFile(targetPath, 'secret') + await symlink(targetPath, linkPath) + } const sftp = createSftpMock() - await expect(uploadFile(sftp, join(localDir, 'link.txt'), '/remote/link.txt')).rejects.toThrow() + await expect(uploadFile(sftp, linkPath, '/remote/link.txt')).rejects.toThrow() expect(sftp.createWriteStream).not.toHaveBeenCalled() }) + + it('removes remote directory contents before removing the directory', async () => { + const sftp = createSftpMock() + vi.mocked(sftp.readdir).mockImplementation((remotePath, cb) => { + const pathString = String(remotePath) + if (pathString === '/remote/assets') { + cb(undefined, [ + { filename: '.', attrs: { isDirectory: () => true } }, + { filename: '..', attrs: { isDirectory: () => true } }, + { filename: 'nested', attrs: { isDirectory: () => true } }, + { filename: 'logo.png', attrs: { isDirectory: () => false } } + ] as never) + return + } + if (pathString === '/remote/assets/nested') { + cb(undefined, [{ filename: 'copy.txt', attrs: { isDirectory: () => false } }] as never) + return + } + cb(new Error(`unexpected readdir: ${pathString}`), [] as never) + }) + + await removeDirectorySftp(sftp, '/remote/assets') + + expect(sftp.unlink).toHaveBeenNthCalledWith( + 1, + '/remote/assets/nested/copy.txt', + expect.any(Function) + ) + expect(sftp.rmdir).toHaveBeenNthCalledWith(1, '/remote/assets/nested', expect.any(Function)) + expect(sftp.unlink).toHaveBeenNthCalledWith(2, '/remote/assets/logo.png', expect.any(Function)) + expect(sftp.rmdir).toHaveBeenNthCalledWith(2, '/remote/assets', expect.any(Function)) + }) }) diff --git a/src/main/ssh/sftp-upload.ts b/src/main/ssh/sftp-upload.ts index b7dd3389590..c379a34390f 100644 --- a/src/main/ssh/sftp-upload.ts +++ b/src/main/ssh/sftp-upload.ts @@ -1,7 +1,7 @@ import { constants } from 'fs' import type { ReadStream } from 'fs' import { lstat, open, readdir, realpath } from 'fs/promises' -import { isAbsolute, join as pathJoin, relative } from 'path' +import { isAbsolute, join as pathJoin, relative, sep } from 'path' import type { SFTPWrapper } from 'ssh2' export function mkdirSftp( @@ -36,16 +36,25 @@ export function uploadFile( let fileHandle: Awaited> | null = null let writeStream: ReturnType | null = null + const cleanupListeners = (): void => { + writeStream?.off('close', onWriteClose) + writeStream?.off('error', onWriteError) + readStream?.off('error', onReadError) + } const settle = (fn: typeof resolve | typeof reject, val?: unknown): void => { if (settled) { return } settled = true + cleanupListeners() readStream?.destroy() writeStream?.destroy() void fileHandle?.close().catch(() => {}) fn(val as never) } + const onWriteClose = (): void => settle(resolve) + const onWriteError = (err: Error): void => settle(reject, err) + const onReadError = (err: Error): void => settle(reject, err) void open(localPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)) .then(async (handle) => { @@ -72,10 +81,10 @@ export function uploadFile( writeStream = sftp.createWriteStream(remotePath, { flags: options?.exclusive ? 'wx' : 'w' }) - writeStream.on('close', () => settle(resolve)) - writeStream.on('error', (err) => settle(reject, err)) + writeStream.on('close', onWriteClose) + writeStream.on('error', onWriteError) readStream = handle.createReadStream() - readStream.on('error', (err) => settle(reject, err)) + readStream.on('error', onReadError) readStream.pipe(writeStream) }) .catch((err: unknown) => settle(reject, err)) @@ -94,17 +103,24 @@ export function uploadBuffer( flags: options?.append ? 'a' : options?.exclusive ? 'wx' : 'w' }) + const cleanupListeners = (): void => { + writeStream.off('close', onClose) + writeStream.off('error', onError) + } const settle = (fn: typeof resolve | typeof reject, val?: unknown): void => { if (settled) { return } settled = true + cleanupListeners() writeStream.destroy() fn(val as never) } + const onClose = (): void => settle(resolve) + const onError = (err: Error): void => settle(reject, err) - writeStream.on('close', () => settle(resolve)) - writeStream.on('error', (err) => settle(reject, err)) + writeStream.on('close', onClose) + writeStream.on('error', onError) writeStream.end(buffer) }) } @@ -141,13 +157,74 @@ export async function uploadDirectory( } } +export async function removeDirectorySftp(sftp: SFTPWrapper, remoteDir: string): Promise { + const entries = await readdirSftp(sftp, remoteDir) + const normalizedRemoteDir = remoteDir.replace(/\/+$/, '') + for (const entry of entries) { + if (entry.filename === '.' || entry.filename === '..') { + continue + } + const childPath = `${normalizedRemoteDir}/${entry.filename}` + await (entry.attrs?.isDirectory?.() + ? removeDirectorySftp(sftp, childPath) + : unlinkSftp(sftp, childPath)) + } + await rmdirSftp(sftp, remoteDir) +} + +type SftpDirectoryEntry = { + filename: string + attrs?: { + isDirectory?: () => boolean + } +} + +function readdirSftp(sftp: SFTPWrapper, remoteDir: string): Promise { + return new Promise((resolve, reject) => { + sftp.readdir(remoteDir, (err, entries) => { + if (err) { + reject(err) + return + } + resolve((entries ?? []) as SftpDirectoryEntry[]) + }) + }) +} + +function unlinkSftp(sftp: SFTPWrapper, remotePath: string): Promise { + return new Promise((resolve, reject) => { + sftp.unlink(remotePath, (err) => { + if (err) { + reject(err) + return + } + resolve() + }) + }) +} + +function rmdirSftp(sftp: SFTPWrapper, remoteDir: string): Promise { + return new Promise((resolve, reject) => { + sftp.rmdir(remoteDir, (err) => { + if (err) { + reject(err) + return + } + resolve() + }) + }) +} + async function assertLocalUploadPathInsideRoot( rootRealPath: string, candidatePath: string ): Promise { const candidateRealPath = await realpath(candidatePath) const relativeToRoot = relative(rootRealPath, candidateRealPath) - if (relativeToRoot !== '' && (relativeToRoot.startsWith('..') || isAbsolute(relativeToRoot))) { + if ( + relativeToRoot !== '' && + (relativeToRoot === '..' || relativeToRoot.startsWith(`..${sep}`) || isAbsolute(relativeToRoot)) + ) { throw new Error(`Path escaped upload root: ${candidatePath}`) } } diff --git a/src/main/ssh/ssh-agent-identity-filter.test.ts b/src/main/ssh/ssh-agent-identity-filter.test.ts index 536ba3e7c8f..014d51e7b32 100644 --- a/src/main/ssh/ssh-agent-identity-filter.test.ts +++ b/src/main/ssh/ssh-agent-identity-filter.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { join } from 'path' const mocks = vi.hoisted(() => ({ createAgent: vi.fn(), @@ -27,6 +28,12 @@ vi.mock('ssh2', () => { import { createIdentityFilteredAgent } from './ssh-agent-identity-filter' +const TEST_HOME = '/home/testuser' + +function testHomePath(...parts: string[]): string { + return join(TEST_HOME, ...parts) +} + type TestKey = { id: string equals: ReturnType @@ -68,7 +75,31 @@ describe('createIdentityFilteredAgent', () => { }) expect(identities).toEqual([allowedKey]) - expect(mocks.readFileSync).toHaveBeenCalledWith('/home/testuser/.ssh/work_key.pub') + expect(mocks.readFileSync).toHaveBeenCalledWith(testHomePath('.ssh', 'work_key.pub')) + }) + + it('expands Windows-style configured identity file paths before filtering', async () => { + const allowedKey = makeKey('allowed') + mocks.readFileSync.mockReturnValue('ssh-ed25519 AAAA allowed') + mocks.parseKey.mockReturnValue(allowedKey) + mocks.createAgent.mockReturnValue({ + getIdentities: vi.fn((callback) => callback(undefined, [allowedKey])), + sign: vi.fn() + }) + + const agent = createIdentityFilteredAgent('/tmp/agent.sock', ['~\\.ssh\\work_key']) + const identities = await new Promise((resolve, reject) => { + agent?.getIdentities((error, keys) => { + if (error) { + reject(error) + return + } + resolve(keys ?? []) + }) + }) + + expect(identities).toEqual([allowedKey]) + expect(mocks.readFileSync).toHaveBeenCalledWith(testHomePath('.ssh', 'work_key.pub')) }) it('filters nested public key entries returned by ssh2 agents', async () => { diff --git a/src/main/ssh/ssh-agent-identity-filter.ts b/src/main/ssh/ssh-agent-identity-filter.ts index 3e821188f9a..dbdfe477728 100644 --- a/src/main/ssh/ssh-agent-identity-filter.ts +++ b/src/main/ssh/ssh-agent-identity-filter.ts @@ -1,6 +1,4 @@ import { readFileSync } from 'fs' -import { homedir } from 'os' -import { join } from 'path' import { BaseAgent, createAgent, @@ -11,16 +9,10 @@ import { type SignCallback, type SigningRequestOptions } from 'ssh2' +import { resolveSshConfigHomePath } from './ssh-config-path-expansion' type AgentPublicKey = ParsedKey | Buffer | string | PublicKeyEntry -function resolveHomePath(filepath: string): string { - if (filepath.startsWith('~/') || filepath === '~') { - return join(homedir(), filepath.slice(1)) - } - return filepath -} - function comparablePublicKey(key: AgentPublicKey): ParsedKey | Buffer | string { if (typeof key === 'object' && 'pubKey' in key) { const pubKey = key.pubKey @@ -91,7 +83,7 @@ function parseIdentityKeyFile(filePath: string): ParsedKey | undefined { function readIdentityKeys(paths: string[]): ParsedKey[] { const keys: ParsedKey[] = [] for (const path of paths) { - const identityPath = resolveHomePath(path) + const identityPath = resolveSshConfigHomePath(path) const key = parseIdentityKeyFile(`${identityPath}.pub`) ?? parseIdentityKeyFile(identityPath) if (key) { keys.push(key) diff --git a/src/main/ssh/ssh-auth-resolution.ts b/src/main/ssh/ssh-auth-resolution.ts index 07f7f4cf54c..7be6ce405de 100644 --- a/src/main/ssh/ssh-auth-resolution.ts +++ b/src/main/ssh/ssh-auth-resolution.ts @@ -1,10 +1,9 @@ import { existsSync, readFileSync } from 'fs' -import { homedir } from 'os' -import { join } from 'path' import { utils, type BaseAgent, type ParsedKey } from 'ssh2' import type { SshTarget } from '../../shared/ssh-types' import type { SshResolvedConfig } from './ssh-config-parser' import { createIdentityFilteredAgent } from './ssh-agent-identity-filter' +import { resolveSshConfigHomePath } from './ssh-config-path-expansion' // Why: ssh2 only tries keys that are explicitly provided. Users with keys in // standard locations (e.g. ~/.ssh/id_ed25519) but no SSH agent running would @@ -14,15 +13,15 @@ const DEFAULT_KEY_NAMES = ['id_ed25519', 'id_rsa', 'id_ecdsa', 'id_dsa', 'id_xms const DEFAULT_KEY_PATHS = DEFAULT_KEY_NAMES.map((name) => `~/.ssh/${name}`) const WINDOWS_OPENSSH_AGENT_PIPE = '\\\\.\\pipe\\openssh-ssh-agent' -// Why: parseSshGOutput expands ~ to homedir(), so resolved identityFile -// paths won't match the ~/... form in DEFAULT_KEY_PATHS. -const EXPANDED_DEFAULT_KEY_PATHS = DEFAULT_KEY_NAMES.map((name) => join(homedir(), '.ssh', name)) +// Why: resolved IdentityFile paths are expanded before auth resolution, so they +// won't match the ~/... form in DEFAULT_KEY_PATHS. +const EXPANDED_DEFAULT_KEY_PATHS = DEFAULT_KEY_PATHS.map(resolveSshConfigHomePath) export type PrivateKeyFile = { path: string; contents: Buffer } export function findDefaultKeyFile(): PrivateKeyFile | undefined { for (const keyPath of DEFAULT_KEY_PATHS) { - const resolved = keyPath.replace(/^~/, homedir()) + const resolved = resolveSshConfigHomePath(keyPath) try { if (!existsSync(resolved)) { continue @@ -36,13 +35,6 @@ export function findDefaultKeyFile(): PrivateKeyFile | undefined { return undefined } -function resolveHomePath(filepath: string): string { - if (filepath.startsWith('~/') || filepath === '~') { - return join(homedir(), filepath.slice(1)) - } - return filepath -} - function expandIdentityAgentEnv(value: string): string | undefined { if (value === 'SSH_AUTH_SOCK') { return process.env.SSH_AUTH_SOCK || undefined @@ -83,7 +75,7 @@ export function resolveAgentSocket( if (!trimmed || trimmed.toLowerCase() === 'none') { return undefined } - return expandIdentityAgentEnv(resolveHomePath(trimmed)) + return expandIdentityAgentEnv(resolveSshConfigHomePath(trimmed)) } return resolveDefaultAgentSocket() } @@ -103,7 +95,7 @@ function resolveExplicitPrivateKeyPath( function readPrivateKey(keyPath: string): PrivateKeyFile | undefined { try { - const resolvedPath = resolveHomePath(keyPath) + const resolvedPath = resolveSshConfigHomePath(keyPath) return { path: keyPath, contents: readFileSync(resolvedPath) } } catch { return undefined diff --git a/src/main/ssh/ssh-channel-multiplexer.test.ts b/src/main/ssh/ssh-channel-multiplexer.test.ts index 6dc1b6c4b49..f0576a65fb5 100644 --- a/src/main/ssh/ssh-channel-multiplexer.test.ts +++ b/src/main/ssh/ssh-channel-multiplexer.test.ts @@ -1,3 +1,5 @@ +/* oxlint-disable max-lines -- Why: keeps the mux protocol lifecycle harness + together across request, notification, keepalive, and disposal cases. */ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { SshChannelMultiplexer, type MultiplexerTransport } from './ssh-channel-multiplexer' import { encodeFrame, MessageType, HEADER_LENGTH, encodeKeepAliveFrame } from './relay-protocol' @@ -63,6 +65,16 @@ function makeNotificationFrame( return encodeFrame(MessageType.Regular, seq, 0, payload) } +type MuxInternals = { + notificationHandlers: unknown[] + methodNotificationHandlers: Map> + disposeHandlers: unknown[] +} + +function getMuxInternals(instance: SshChannelMultiplexer): MuxInternals { + return instance as unknown as MuxInternals +} + describe('SshChannelMultiplexer', () => { let transport: ReturnType let mux: SshChannelMultiplexer @@ -323,6 +335,43 @@ describe('SshChannelMultiplexer', () => { mux.dispose() expect(mux.isDisposed()).toBe(true) }) + + it('clears registered handlers on dispose', () => { + const disposeHandler = vi.fn() + mux.onNotification(vi.fn()) + mux.onNotificationByMethod('fs.streamChunk', vi.fn()) + mux.onDispose(disposeHandler) + + const internals = getMuxInternals(mux) + expect(internals.notificationHandlers).toHaveLength(1) + expect(internals.methodNotificationHandlers.size).toBe(1) + expect(internals.disposeHandlers).toHaveLength(1) + + mux.dispose() + + expect(disposeHandler).toHaveBeenCalledWith('shutdown') + expect(internals.notificationHandlers).toHaveLength(0) + expect(internals.methodNotificationHandlers.size).toBe(0) + expect(internals.disposeHandlers).toHaveLength(0) + }) + + it('does not retain handlers registered after dispose', () => { + mux.dispose() + + const disposeNotification = mux.onNotification(vi.fn()) + const disposeMethod = mux.onNotificationByMethod('fs.streamChunk', vi.fn()) + const disposeLifecycle = mux.onDispose(vi.fn()) + + const internals = getMuxInternals(mux) + expect(internals.notificationHandlers).toHaveLength(0) + expect(internals.methodNotificationHandlers.size).toBe(0) + expect(internals.disposeHandlers).toHaveLength(0) + expect(() => { + disposeNotification() + disposeMethod() + disposeLifecycle() + }).not.toThrow() + }) }) describe('transport close', () => { diff --git a/src/main/ssh/ssh-channel-multiplexer.ts b/src/main/ssh/ssh-channel-multiplexer.ts index caf7e4f7e93..09642387ee8 100644 --- a/src/main/ssh/ssh-channel-multiplexer.ts +++ b/src/main/ssh/ssh-channel-multiplexer.ts @@ -31,6 +31,7 @@ type PendingRequest = { export type NotificationHandler = (method: string, params: Record) => void export type MethodNotificationHandler = (params: Record) => void +export type RequestHandler = (params: Record) => Promise | unknown const REQUEST_TIMEOUT_MS = 30_000 @@ -44,6 +45,7 @@ export class SshChannelMultiplexer { private lastReceivedAt = Date.now() private pendingRequests = new Map() private notificationHandlers: NotificationHandler[] = [] + private requestHandlers = new Map() // Why: per-method dispatch map keeps streaming consumers (fs.streamChunk, // fs.streamEnd, fs.streamError) from accreting string-match logic in the // generic notification listener that already serves fs.changed. @@ -84,6 +86,9 @@ export class SshChannelMultiplexer { } onNotification(handler: NotificationHandler): () => void { + if (this.disposed) { + return () => {} + } this.notificationHandlers.push(handler) return () => { const idx = this.notificationHandlers.indexOf(handler) @@ -94,6 +99,9 @@ export class SshChannelMultiplexer { } onNotificationByMethod(method: string, handler: MethodNotificationHandler): () => void { + if (this.disposed) { + return () => {} + } let set = this.methodNotificationHandlers.get(method) if (!set) { set = new Set() @@ -112,12 +120,24 @@ export class SshChannelMultiplexer { } } + onRequest(method: string, handler: RequestHandler): () => void { + this.requestHandlers.set(method, handler) + return () => { + if (this.requestHandlers.get(method) === handler) { + this.requestHandlers.delete(method) + } + } + } + // Why: the session needs to know when the relay channel dies so it can // auto-reconnect. Without this, a relay channel close (e.g. --connect // bridge exits) leaves the session in 'ready' state with a dead mux // and no recovery path — the SSH connection stays up so onStateChange // never fires the reconnect logic. onDispose(handler: (reason: 'shutdown' | 'connection_lost') => void): () => void { + if (this.disposed) { + return () => {} + } this.disposeHandlers.push(handler) return () => { const idx = this.disposeHandlers.indexOf(handler) @@ -248,6 +268,9 @@ export class SshChannelMultiplexer { } this.unackedTimestamps.clear() + // Why: relay teardown can race with late provider registration; disposed + // muxes must not retain provider/session closures through subscribers. + this.notificationHandlers.length = 0 this.methodNotificationHandlers.clear() this.decoder.reset() this.transport.close?.() @@ -329,10 +352,41 @@ export class SshChannelMultiplexer { private handleMessage(msg: JsonRpcMessage): void { if ('id' in msg && ('result' in msg || 'error' in msg)) { this.handleResponse(msg as JsonRpcResponse) + } else if ('id' in msg && 'method' in msg) { + void this.handleRequest(msg as JsonRpcRequest) } else if ('method' in msg && !('id' in msg)) { this.handleNotification(msg as JsonRpcNotification) } - // Requests from relay to client are not expected in Phase 2 + } + + private async handleRequest(msg: JsonRpcRequest): Promise { + const handler = this.requestHandlers.get(msg.method) + if (!handler) { + this.sendMessage({ + jsonrpc: '2.0', + id: msg.id, + error: { code: -32601, message: `Method not found: ${msg.method}` } + }) + return + } + + try { + const result = await handler(msg.params ?? {}) + this.sendMessage({ + jsonrpc: '2.0', + id: msg.id, + result: result ?? null + }) + } catch (err) { + this.sendMessage({ + jsonrpc: '2.0', + id: msg.id, + error: { + code: (err as { code?: number }).code ?? -32000, + message: err instanceof Error ? err.message : String(err) + } + }) + } } private handleResponse(msg: JsonRpcResponse): void { diff --git a/src/main/ssh/ssh-config-include-expander.ts b/src/main/ssh/ssh-config-include-expander.ts index b26b32b52c2..b5e2c6e9191 100644 --- a/src/main/ssh/ssh-config-include-expander.ts +++ b/src/main/ssh/ssh-config-include-expander.ts @@ -64,7 +64,7 @@ function expandSshConfigFile( for (const includeArg of includeArgs) { for (const matchedPath of resolveIncludePaths(includeArg, context)) { - expandedLines.push(...expandSshConfigFile(matchedPath, context, nextStack)) + appendExpandedLines(expandedLines, expandSshConfigFile(matchedPath, context, nextStack)) } } } @@ -72,6 +72,14 @@ function expandSshConfigFile( return expandedLines } +function appendExpandedLines(target: string[], lines: readonly string[]): void { + // Why: SSH config includes are user-controlled files, and a large included + // file can exceed the JavaScript call argument limit when spread into push. + for (const line of lines) { + target.push(line) + } +} + function readCachedFile(filePath: string, context: IncludeExpansionContext): string | null { const cached = context.cache.get(filePath) if (cached !== undefined) { diff --git a/src/main/ssh/ssh-config-loader.test.ts b/src/main/ssh/ssh-config-loader.test.ts index 316a4da050b..29fc2bc249b 100644 --- a/src/main/ssh/ssh-config-loader.test.ts +++ b/src/main/ssh/ssh-config-loader.test.ts @@ -23,6 +23,7 @@ vi.mock('os', async () => { const originalEnv = { ...process.env } const tempDirs: string[] = [] +const LARGE_INCLUDE_LINE_COUNT = 150_000 afterEach(() => { for (const key of Object.keys(process.env)) { @@ -158,6 +159,23 @@ describe('loadUserSshConfig', () => { ]) }) + it('continues parsing after a large included file', () => { + const home = makeHome() + writeFile( + home, + '.ssh/config', + ['Include large.conf', 'Host after', ' HostName after.example.com'].join('\n') + ) + writeFile(home, '.ssh/large.conf', '\n'.repeat(LARGE_INCLUDE_LINE_COUNT)) + + expect(loadUserSshConfig()).toEqual([ + { + host: 'after', + hostname: 'after.example.com' + } + ]) + }) + it('ignores unset env includes and target-dependent tokens', () => { const home = makeHome() writeFile( diff --git a/src/main/ssh/ssh-config-parser.test.ts b/src/main/ssh/ssh-config-parser.test.ts index 2b5cf6af34a..f2856fc12cf 100644 --- a/src/main/ssh/ssh-config-parser.test.ts +++ b/src/main/ssh/ssh-config-parser.test.ts @@ -1,11 +1,27 @@ /* eslint-disable max-lines -- Why: SSH config parsing fixtures cover OpenSSH file parsing and ssh -G output together so import and connection resolution stay aligned. */ import { describe, expect, it, vi } from 'vitest' +import { join } from 'path' import { parseSshConfig, sshConfigHostsToTargets, parseSshGOutput } from './ssh-config-parser' vi.mock('os', () => ({ homedir: () => '/home/testuser' })) +const LARGE_HOST_ALIAS_COUNT = 150_000 +const TEST_HOME = '/home/testuser' + +function testHomePath(...parts: string[]): string { + return join(TEST_HOME, ...parts) +} + +function buildHostAliases(count: number): string { + const aliases: string[] = [] + for (let index = 0; index < count; index += 1) { + aliases.push(`generated-${index}`) + } + return aliases.join(' ') +} + describe('parseSshConfig', () => { it('parses a basic host block', () => { const config = ` @@ -75,7 +91,57 @@ Host myserver IdentityFile ~/.ssh/id_ed25519 ` const hosts = parseSshConfig(config) - expect(hosts[0].identityFile).toBe('/home/testuser/.ssh/id_ed25519') + expect(hosts[0].identityFile).toBe(testHomePath('.ssh', 'id_ed25519')) + }) + + it('parses Windows-style IdentityFile with ~ expansion', () => { + const config = ` +Host myserver + HostName example.com + IdentityFile ~\\.ssh\\id_ed25519 +` + const hosts = parseSshConfig(config) + expect(hosts[0].identityFile).toBe(testHomePath('.ssh', 'id_ed25519')) + }) + + it('parses quoted scalar values like OpenSSH', () => { + const config = ` +Host quoted + HostName "localhost" # local test + User "deploy" # deploy user + Port "2202" # ssh port + IdentityFile "~/.ssh/id with space" # private key + IdentityAgent "~/.1password/agent sock" # agent socket + IdentitiesOnly "yes" # limit keys + ProxyJump "bastion" # jump host +` + const hosts = parseSshConfig(config) + expect(hosts[0]).toEqual({ + host: 'quoted', + hostname: 'localhost', + user: 'deploy', + port: 2202, + identityFile: testHomePath('.ssh', 'id with space'), + identityAgent: testHomePath('.1password', 'agent sock'), + identitiesOnly: true, + proxyJump: 'bastion' + }) + }) + + it('parses equals-form scalar directives', () => { + const config = ` +Host eq + HostName=eq.example.com + User=deploy + Port=2202 +` + const hosts = parseSshConfig(config) + expect(hosts[0]).toEqual({ + host: 'eq', + hostname: 'eq.example.com', + user: 'deploy', + port: 2202 + }) }) it('parses IdentityAgent with ~ expansion', () => { @@ -85,7 +151,7 @@ Host myserver IdentityAgent ~/.1password/agent.sock ` const hosts = parseSshConfig(config) - expect(hosts[0].identityAgent).toBe('/home/testuser/.1password/agent.sock') + expect(hosts[0].identityAgent).toBe(testHomePath('.1password', 'agent.sock')) }) it('parses IdentitiesOnly', () => { @@ -112,6 +178,15 @@ Host internal expect(hosts[0].proxyJump).toBe('bastion.example.com') }) + it('preserves ProxyCommand as the rest of the line', () => { + const config = ` +Host internal + ProxyCommand sh -c "nc %h %p" # shell comment +` + const hosts = parseSshConfig(config) + expect(hosts[0].proxyCommand).toBe('sh -c "nc %h %p" # shell comment') + }) + it('ignores comments and blank lines', () => { const config = ` # This is a comment @@ -172,6 +247,31 @@ Host staging stage *.example.com ]) }) + it('parses large concrete alias lists on one Host line', () => { + const config = [ + `Host ${buildHostAliases(LARGE_HOST_ALIAS_COUNT)}`, + ' HostName generated.example.com', + 'Host after', + ' HostName after.example.com' + ].join('\n') + + const hosts = parseSshConfig(config) + + expect(hosts).toHaveLength(LARGE_HOST_ALIAS_COUNT + 1) + expect(hosts[0]).toEqual({ + host: 'generated-0', + hostname: 'generated.example.com' + }) + expect(hosts[LARGE_HOST_ALIAS_COUNT - 1]).toEqual({ + host: `generated-${LARGE_HOST_ALIAS_COUNT - 1}`, + hostname: 'generated.example.com' + }) + expect(hosts.at(-1)).toEqual({ + host: 'after', + hostname: 'after.example.com' + }) + }) + it('applies identity agent settings to every concrete alias on a multi-pattern Host line', () => { const config = ` Host staging stage @@ -182,12 +282,12 @@ Host staging stage expect(hosts).toEqual([ { host: 'staging', - identityAgent: '/home/testuser/.1password/agent.sock', + identityAgent: testHomePath('.1password', 'agent.sock'), identitiesOnly: true }, { host: 'stage', - identityAgent: '/home/testuser/.1password/agent.sock', + identityAgent: testHomePath('.1password', 'agent.sock'), identitiesOnly: true } ]) @@ -306,8 +406,8 @@ describe('parseSshGOutput', () => { const result = parseSshGOutput(output) expect(result.identityFile).toEqual([ - '/home/testuser/.ssh/id_ed25519', - '/home/testuser/.ssh/id_rsa' + testHomePath('.ssh', 'id_ed25519'), + testHomePath('.ssh', 'id_rsa') ]) }) @@ -382,13 +482,19 @@ describe('parseSshGOutput', () => { it('handles ~ expansion in identity file paths', () => { const output = 'hostname example.com\nidentityfile ~/custom_key\nport 22' const result = parseSshGOutput(output) - expect(result.identityFile).toEqual(['/home/testuser/custom_key']) + expect(result.identityFile).toEqual([testHomePath('custom_key')]) + }) + + it('handles Windows-style ~ expansion in identity file paths', () => { + const output = 'hostname example.com\nidentityfile ~\\.ssh\\custom_key\nport 22' + const result = parseSshGOutput(output) + expect(result.identityFile).toEqual([testHomePath('.ssh', 'custom_key')]) }) it('parses identityagent with ~ expansion', () => { const output = 'hostname example.com\nidentityagent ~/.1password/agent.sock\nport 22' const result = parseSshGOutput(output) - expect(result.identityAgent).toBe('/home/testuser/.1password/agent.sock') + expect(result.identityAgent).toBe(testHomePath('.1password', 'agent.sock')) }) it('preserves identityagent none so auth can disable agent fallback', () => { diff --git a/src/main/ssh/ssh-config-parser.ts b/src/main/ssh/ssh-config-parser.ts index 70b0cacdbe0..a504d925003 100644 --- a/src/main/ssh/ssh-config-parser.ts +++ b/src/main/ssh/ssh-config-parser.ts @@ -4,6 +4,7 @@ import { join } from 'path' import { homedir } from 'os' import type { SshTarget } from '../../shared/ssh-types' import { expandSshConfigIncludes } from './ssh-config-include-expander' +import { resolveSshConfigHomePath } from './ssh-config-path-expansion' export type SshConfigHost = { host: string @@ -33,21 +34,19 @@ export function parseSshConfig(content: string): SshConfigHost[] { continue } - const match = line.match(/^(\S+)\s+(.+)$/) - if (!match) { + const directive = parseConfigDirective(line) + if (!directive) { continue } - const [, keyword, rawValue] = match - const key = keyword.toLowerCase() - const value = rawValue.trim() + const { key, rawValue } = directive if (key === 'host') { if (current.length > 0) { - hosts.push(...current) + appendHosts(hosts, current) } - const patterns = splitHostPatterns(value) + const patterns = splitHostPatterns(rawValue) const concretePatterns = patterns.filter( (pattern) => !pattern.startsWith('!') && !pattern.includes('*') && !pattern.includes('?') ) @@ -62,7 +61,7 @@ export function parseSshConfig(content: string): SshConfigHost[] { if (key === 'match') { if (current.length > 0) { - hosts.push(...current) + appendHosts(hosts, current) } current = [] continue @@ -72,6 +71,8 @@ export function parseSshConfig(content: string): SshConfigHost[] { continue } + const value = parseScalarConfigValue(rawValue) + switch (key) { case 'hostname': for (const host of current) { @@ -90,12 +91,12 @@ export function parseSshConfig(content: string): SshConfigHost[] { break case 'identityfile': for (const host of current) { - host.identityFile = resolveHomePath(value) + host.identityFile = resolveSshConfigHomePath(value) } break case 'identityagent': for (const host of current) { - host.identityAgent = resolveHomePath(value) + host.identityAgent = resolveSshConfigHomePath(value) } break case 'identitiesonly': @@ -105,7 +106,9 @@ export function parseSshConfig(content: string): SshConfigHost[] { break case 'proxycommand': for (const host of current) { - host.proxyCommand = value + // Why: OpenSSH treats ProxyCommand as a shell snippet and preserves + // the rest of the line, including quotes and `#` characters. + host.proxyCommand = rawValue.trim() } break case 'proxyusefdpass': @@ -122,13 +125,43 @@ export function parseSshConfig(content: string): SshConfigHost[] { } if (current.length > 0) { - hosts.push(...current) + appendHosts(hosts, current) } return hosts } +function appendHosts(target: SshConfigHost[], entries: SshConfigHost[]): void { + // Why: generated SSH configs can put many concrete aliases on one Host line; + // spreading that block into push can exceed JavaScript's argument limit. + for (const entry of entries) { + target.push(entry) + } +} + +function parseConfigDirective(line: string): { key: string; rawValue: string } | null { + const match = line.match(/^([^=\s]+)(?:\s*=\s*|\s+)(.*)$/) + if (!match) { + return null + } + + return { + key: match[1].toLowerCase(), + rawValue: match[2].trim() + } +} + +function parseScalarConfigValue(input: string): string { + // Why: scalar OpenSSH directives strip wrapping quotes and inline comments; + // keeping them would turn valid config values into bad hostnames/users/paths. + return splitOpenSshArguments(input)[0] ?? '' +} + function splitHostPatterns(input: string): string[] { - const patterns: string[] = [] + return splitOpenSshArguments(input) +} + +function splitOpenSshArguments(input: string): string[] { + const args: string[] = [] let current = '' let inQuotes = false let escaped = false @@ -157,7 +190,7 @@ function splitHostPatterns(input: string): string[] { if (!inQuotes && /\s/.test(char)) { if (current) { - patterns.push(current) + args.push(current) current = '' } continue @@ -167,17 +200,10 @@ function splitHostPatterns(input: string): string[] { } if (current) { - patterns.push(current) + args.push(current) } - return patterns -} - -function resolveHomePath(filepath: string): string { - if (filepath.startsWith('~/') || filepath === '~') { - return join(homedir(), filepath.slice(1)) - } - return filepath + return args } /** Read and parse the user's ~/.ssh/config file. Returns empty array if not found. */ @@ -253,15 +279,41 @@ const SSH_G_TIMEOUT_MS = 5000 // resolution without reimplementing OpenSSH's complex matching logic. export function resolveWithSshG(host: string): Promise { return new Promise((resolve) => { - // Why: '--' prevents a host label starting with '-' from being interpreted - // as an SSH flag (classic argument injection vector). - execFile('ssh', ['-G', '--', host], { timeout: SSH_G_TIMEOUT_MS }, (err, stdout) => { - if (err) { - resolve(null) + let settled = false + let child: ReturnType | undefined + const timer = setTimeout(() => { + if (settled) { return } - resolve(parseSshGOutput(stdout)) - }) + settled = true + child?.kill() + resolve(null) + }, SSH_G_TIMEOUT_MS) + + const settle = (callback: () => void): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + callback() + } + + // Why: '--' prevents a host label starting with '-' from being interpreted + // as an SSH flag (classic argument injection vector). + // Why: execFile's timeout only signals ssh; a stuck callback must still + // release SSH import/connection resolution with the existing null fallback. + try { + child = execFile('ssh', ['-G', '--', host], { timeout: SSH_G_TIMEOUT_MS }, (err, stdout) => { + if (err) { + settle(() => resolve(null)) + return + } + settle(() => resolve(parseSshGOutput(stdout))) + }) + } catch { + settle(() => resolve(null)) + } }) } @@ -277,7 +329,7 @@ export function parseSshGOutput(stdout: string): SshResolvedConfig { const key = line.substring(0, spaceIdx).toLowerCase() const value = line.substring(spaceIdx + 1).trim() if (key === 'identityfile') { - identityFiles.push(resolveHomePath(value)) + identityFiles.push(resolveSshConfigHomePath(value)) } else { map.set(key, value) } @@ -290,7 +342,7 @@ export function parseSshGOutput(stdout: string): SshResolvedConfig { const rawJump = map.get('proxyjump') const proxyJump = rawJump && rawJump !== 'none' ? rawJump : undefined const rawIdentityAgent = map.get('identityagent') - const identityAgent = rawIdentityAgent ? resolveHomePath(rawIdentityAgent) : undefined + const identityAgent = rawIdentityAgent ? resolveSshConfigHomePath(rawIdentityAgent) : undefined return { hostname: map.get('hostname') ?? '', diff --git a/src/main/ssh/ssh-config-path-expansion.ts b/src/main/ssh/ssh-config-path-expansion.ts new file mode 100644 index 00000000000..71ec58fc19b --- /dev/null +++ b/src/main/ssh/ssh-config-path-expansion.ts @@ -0,0 +1,18 @@ +import { homedir } from 'os' +import { join } from 'path' + +export function resolveSshConfigHomePath(filepath: string): string { + if (filepath === '~') { + return homedir() + } + if (filepath.startsWith('~/') || filepath.startsWith('~\\')) { + return join( + homedir(), + ...filepath + .slice(2) + .split(/[\\/]+/) + .filter(Boolean) + ) + } + return filepath +} diff --git a/src/main/ssh/ssh-config-resolver.test.ts b/src/main/ssh/ssh-config-resolver.test.ts index bfc948a2cfe..64b87a5a814 100644 --- a/src/main/ssh/ssh-config-resolver.test.ts +++ b/src/main/ssh/ssh-config-resolver.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { resolveWithSshG } from './ssh-config-parser' vi.mock('os', () => ({ @@ -10,6 +10,15 @@ vi.mock('child_process', () => ({ })) describe('resolveWithSshG', () => { + beforeEach(async () => { + const { execFile } = await import('child_process') + vi.mocked(execFile).mockReset() + }) + + afterEach(() => { + vi.useRealTimers() + }) + it('returns parsed config on success', async () => { const { execFile } = await import('child_process') const mockExecFile = vi.mocked(execFile) @@ -61,4 +70,24 @@ describe('resolveWithSshG', () => { const result = await resolveWithSshG('myhost') expect(result).toBeNull() }) + + it('returns null when ssh -G never reports completion', async () => { + vi.useFakeTimers() + const { execFile } = await import('child_process') + const mockExecFile = vi.mocked(execFile) + const killMock = vi.fn() + mockExecFile.mockImplementation(() => ({ kill: killMock }) as never) + + let settled = false + const resultPromise = resolveWithSshG('stuck-host').then((result) => { + settled = true + return result + }) + + await vi.advanceTimersByTimeAsync(5000) + + expect(settled).toBe(true) + await expect(resultPromise).resolves.toBeNull() + expect(killMock).toHaveBeenCalled() + }) }) diff --git a/src/main/ssh/ssh-connection-utils.test.ts b/src/main/ssh/ssh-connection-utils.test.ts index f075c9532ff..0af7103b17a 100644 --- a/src/main/ssh/ssh-connection-utils.test.ts +++ b/src/main/ssh/ssh-connection-utils.test.ts @@ -1,13 +1,28 @@ /* eslint-disable max-lines -- Why: SSH connection utility tests share mocked filesystem and environment setup across auth, proxy, and retry helpers. */ +import { EventEmitter } from 'events' import { afterEach, describe, expect, it, vi, beforeEach } from 'vitest' +import { join } from 'path' import { BaseAgent, utils, type ParsedKey } from 'ssh2' +const { spawnMock } = vi.hoisted(() => ({ + spawnMock: vi.fn() +})) + +vi.mock('child_process', () => ({ + spawn: spawnMock +})) + vi.mock('os', () => ({ homedir: () => '/home/testuser' })) const mockExistsSync = vi.fn().mockReturnValue(false) const mockReadFileSync = vi.fn() +const TEST_HOME = '/home/testuser' + +function testHomePath(...parts: string[]): string { + return join(TEST_HOME, ...parts) +} vi.mock('fs', () => ({ existsSync: (...args: unknown[]) => mockExistsSync(...args), @@ -27,11 +42,28 @@ import { CONNECT_TIMEOUT_MS, INITIAL_RETRY_ATTEMPTS, INITIAL_RETRY_DELAY_MS, - RECONNECT_BACKOFF_MS + RECONNECT_BACKOFF_MS, + spawnProxyCommand } from './ssh-connection-utils' import type { SshTarget } from '../../shared/ssh-types' import type { SshResolvedConfig } from './ssh-config-parser' +type MockProxyProcess = EventEmitter & { + stdin: EventEmitter & { write: ReturnType } + stdout: EventEmitter + stderr: EventEmitter +} + +function createMockProxyProcess(): MockProxyProcess { + const proc = new EventEmitter() as MockProxyProcess + proc.stdin = Object.assign(new EventEmitter(), { + write: vi.fn((_chunk, cb?: (error?: Error | null) => void) => cb?.()) + }) + proc.stdout = new EventEmitter() + proc.stderr = new EventEmitter() + return proc +} + // ── Constants ──────────────────────────────────────────────────────── describe('SSH connection constants', () => { @@ -205,7 +237,7 @@ describe('findDefaultKeyFile', () => { it('returns the first existing key file', () => { mockExistsSync.mockImplementation((path: unknown) => { - return path === '/home/testuser/.ssh/id_ed25519' + return path === testHomePath('.ssh', 'id_ed25519') }) mockReadFileSync.mockReturnValue(Buffer.from('key-contents')) @@ -225,20 +257,20 @@ describe('findDefaultKeyFile', () => { findDefaultKeyFile() expect(checkedPaths).toEqual([ - '/home/testuser/.ssh/id_ed25519', - '/home/testuser/.ssh/id_rsa', - '/home/testuser/.ssh/id_ecdsa', - '/home/testuser/.ssh/id_dsa', - '/home/testuser/.ssh/id_xmss' + testHomePath('.ssh', 'id_ed25519'), + testHomePath('.ssh', 'id_rsa'), + testHomePath('.ssh', 'id_ecdsa'), + testHomePath('.ssh', 'id_dsa'), + testHomePath('.ssh', 'id_xmss') ]) }) it('skips unreadable key files and tries next', () => { mockExistsSync.mockImplementation((path: unknown) => { - return path === '/home/testuser/.ssh/id_ed25519' || path === '/home/testuser/.ssh/id_rsa' + return path === testHomePath('.ssh', 'id_ed25519') || path === testHomePath('.ssh', 'id_rsa') }) mockReadFileSync.mockImplementation((path: unknown) => { - if (String(path) === '/home/testuser/.ssh/id_ed25519') { + if (String(path) === testHomePath('.ssh', 'id_ed25519')) { throw new Error('permission denied') } return Buffer.from('rsa-key') @@ -311,6 +343,33 @@ describe('buildConnectConfig', () => { expect(config.username).toBe('admin') }) + it('uses ssh -G HostName when a config-host target still points at its alias', () => { + const config = buildConnectConfig( + makeTarget({ label: 'workbox', configHost: 'workbox', host: 'workbox' }), + makeResolved({ hostname: 'workbox.internal' }) + ) + + expect(config.host).toBe('workbox.internal') + }) + + it('uses ssh -G Port when a config-host target still has the default port', () => { + const config = buildConnectConfig( + makeTarget({ configHost: 'workbox', host: 'workbox', port: 22 }), + makeResolved({ port: 2202 }) + ) + + expect(config.port).toBe(2202) + }) + + it('keeps explicit non-default target ports ahead of ssh -G Port', () => { + const config = buildConnectConfig( + makeTarget({ configHost: 'workbox', host: 'workbox', port: 2022 }), + makeResolved({ port: 2202 }) + ) + + expect(config.port).toBe(2022) + }) + it('sets readyTimeout to CONNECT_TIMEOUT_MS', () => { const config = buildConnectConfig(makeTarget(), null) expect(config.readyTimeout).toBe(30_000) @@ -326,6 +385,26 @@ describe('buildConnectConfig', () => { expect(config.agent).toBe('/tmp/agent.sock') }) + it('enables agent forwarding when OpenSSH config requests it and an agent is available', () => { + const config = buildConnectConfig(makeTarget(), makeResolved({ forwardAgent: true })) + + expect(config.agent).toBe('/tmp/agent.sock') + expect(config.agentForward).toBe(true) + }) + + it('does not enable agent forwarding without a usable agent', () => { + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + delete process.env.SSH_AUTH_SOCK + + try { + const config = buildConnectConfig(makeTarget(), makeResolved({ forwardAgent: true })) + expect(config.agent).toBeUndefined() + expect(config.agentForward).toBeUndefined() + } finally { + platformSpy.mockRestore() + } + }) + it('uses configured IdentityAgent before SSH_AUTH_SOCK', () => { const config = buildConnectConfig( makeTarget(), @@ -337,9 +416,9 @@ describe('buildConnectConfig', () => { it('prefers ssh -G resolved IdentityAgent for config-host targets', () => { const config = buildConnectConfig( makeTarget({ configHost: 'work', identityAgent: '%d/.1password/agent.sock' }), - makeResolved({ identityAgent: '/home/testuser/.1password/agent.sock' }) + makeResolved({ identityAgent: testHomePath('.1password', 'agent.sock') }) ) - expect(config.agent).toBe('/home/testuser/.1password/agent.sock') + expect(config.agent).toBe(testHomePath('.1password', 'agent.sock')) }) it('allows IdentityAgent none to disable agent auth', () => { @@ -419,11 +498,29 @@ describe('buildConnectConfig', () => { }) it('uses keyFile auth when target.identityFile is set and no agent is available', () => { + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') delete process.env.SSH_AUTH_SOCK mockReadFileSync.mockReturnValue(Buffer.from('key')) - const config = buildConnectConfig(makeTarget({ identityFile: '/home/user/.ssh/custom' }), null) + try { + const config = buildConnectConfig( + makeTarget({ identityFile: '/home/user/.ssh/custom' }), + null + ) + expect(config.privateKey).toEqual(Buffer.from('key')) + expect(config.agent).toBeUndefined() + } finally { + platformSpy.mockRestore() + } + }) + + it('expands Windows-style target.identityFile before reading private key', () => { + mockReadFileSync.mockReturnValue(Buffer.from('key')) + const config = buildConnectConfig(makeTarget({ identityFile: '~\\.ssh\\custom' }), null, { + includeAgent: false, + includePrivateKey: true + }) expect(config.privateKey).toEqual(Buffer.from('key')) - expect(config.agent).toBeUndefined() + expect(mockReadFileSync).toHaveBeenCalledWith(testHomePath('.ssh', 'custom')) }) it('includes unencrypted resolved identityFile auth when an agent is available', () => { @@ -442,7 +539,7 @@ describe('buildConnectConfig', () => { it('uses agent auth without probing when resolved identityFile is a default path (expanded)', () => { const config = buildConnectConfig( makeTarget(), - makeResolved({ identityFile: ['/home/testuser/.ssh/id_ed25519'] }) + makeResolved({ identityFile: [testHomePath('.ssh', 'id_ed25519')] }) ) expect(config.agent).toBe('/tmp/agent.sock') expect(config.privateKey).toBeUndefined() @@ -451,7 +548,7 @@ describe('buildConnectConfig', () => { it('does not probe default key files before agent auth', () => { mockExistsSync.mockImplementation( - (p: unknown) => String(p) === '/home/testuser/.ssh/id_ed25519' + (p: unknown) => String(p) === testHomePath('.ssh', 'id_ed25519') ) const config = buildConnectConfig(makeTarget(), null) expect(config.agent).toBe('/tmp/agent.sock') @@ -460,14 +557,19 @@ describe('buildConnectConfig', () => { }) it('provides fallback key when no agent is available', () => { + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') delete process.env.SSH_AUTH_SOCK mockExistsSync.mockImplementation( - (p: unknown) => String(p) === '/home/testuser/.ssh/id_ed25519' + (p: unknown) => String(p) === testHomePath('.ssh', 'id_ed25519') ) mockReadFileSync.mockReturnValue(Buffer.from('fallback')) - const config = buildConnectConfig(makeTarget(), null) - expect(config.agent).toBeUndefined() - expect(config.privateKey).toEqual(Buffer.from('fallback')) + try { + const config = buildConnectConfig(makeTarget(), null) + expect(config.agent).toBeUndefined() + expect(config.privateKey).toEqual(Buffer.from('fallback')) + } finally { + platformSpy.mockRestore() + } }) it('can force private key inclusion for the post-agent fallback path', () => { @@ -522,3 +624,35 @@ describe('resolveEffectiveProxy', () => { expect(resolveEffectiveProxy(makeTarget(), null)).toBeUndefined() }) }) + +// ── spawnProxyCommand ─────────────────────────────────────────────── + +describe('spawnProxyCommand', () => { + beforeEach(() => { + spawnMock.mockReset() + }) + + it('removes proxy process listeners when the socket is destroyed', () => { + const proc = createMockProxyProcess() + spawnMock.mockReturnValue(proc) + + const { sock } = spawnProxyCommand( + { kind: 'jump-host', jumpHost: 'bastion.example.com' }, + 'target.example.com', + 22, + 'deploy' + ) + + expect(proc.stdout.listenerCount('data')).toBe(1) + expect(proc.stdout.listenerCount('end')).toBe(1) + expect(proc.stdin.listenerCount('error')).toBe(1) + expect(proc.listenerCount('error')).toBe(1) + + sock.destroy() + + expect(proc.stdout.listenerCount('data')).toBe(0) + expect(proc.stdout.listenerCount('end')).toBe(0) + expect(proc.stdin.listenerCount('error')).toBe(0) + expect(proc.listenerCount('error')).toBe(0) + }) +}) diff --git a/src/main/ssh/ssh-connection-utils.ts b/src/main/ssh/ssh-connection-utils.ts index 013b456cb74..0e22dede82d 100644 --- a/src/main/ssh/ssh-connection-utils.ts +++ b/src/main/ssh/ssh-connection-utils.ts @@ -105,8 +105,8 @@ export function buildConnectConfig( resolved: SshResolvedConfig | null, options: BuildConnectConfigOptions = {} ): ConnectConfig { - const effectiveHost = target.host || resolved?.hostname || target.label - const effectivePort = target.port || resolved?.port || 22 + const effectiveHost = resolveEffectiveHost(target, resolved) + const effectivePort = resolveEffectivePort(target, resolved) const effectiveUser = target.username || resolved?.user || '' const config: Record = { @@ -125,6 +125,10 @@ export function buildConnectConfig( config.agent = agent } + if (agent && resolved?.forwardAgent) { + config.agentForward = true + } + const key = (options.includePrivateKey ?? !agent) ? resolvePrivateKey(target, resolved) @@ -136,6 +140,30 @@ export function buildConnectConfig( return config as ConnectConfig } +function resolveEffectiveHost(target: SshTarget, resolved: SshResolvedConfig | null): string { + if (shouldUseResolvedEndpoint(target, resolved)) { + return resolved!.hostname + } + return target.host || resolved?.hostname || target.label +} + +function resolveEffectivePort(target: SshTarget, resolved: SshResolvedConfig | null): number { + // Why: imported config aliases store 22 as the schema default even when an + // included/wildcard OpenSSH rule later resolves a different effective Port. + if (target.configHost && target.port === 22 && resolved?.port) { + return resolved.port + } + return target.port || resolved?.port || 22 +} + +function shouldUseResolvedEndpoint(target: SshTarget, resolved: SshResolvedConfig | null): boolean { + if (!target.configHost || !resolved?.hostname) { + return false + } + const host = target.host.trim() + return host === '' || host === target.configHost || host === target.label +} + // Why: ProxyJump and jumpHost are syntactic sugar for ProxyCommand. // OpenSSH internally converts `ProxyJump bastion` to // `ProxyCommand ssh -W %h:%p bastion`. We do the same so that ssh2 @@ -198,16 +226,43 @@ export function spawnProxyCommand( // Why: a single PassThrough for both directions creates a feedback loop. // Reads come from the proxy's stdout; writes go to its stdin. + let cleanedUp = false + const cleanup = (): void => { + if (cleanedUp) { + return + } + cleanedUp = true + proc.stdout!.off('data', onStdoutData) + proc.stdout!.off('end', onStdoutEnd) + proc.stdin!.off('error', onInputError) + proc.off('error', onProcessError) + } + const onStdoutData = (data: Buffer): void => { + stream.push(data) + } + const onStdoutEnd = (): void => { + stream.push(null) + } + const onInputError = (err: Error): void => { + stream.destroy(err) + } + const onProcessError = (err: Error): void => { + stream.destroy(err) + } const stream = new Duplex({ read() {}, write(chunk, _encoding, cb) { proc.stdin!.write(chunk, cb) + }, + destroy(err, cb) { + cleanup() + cb(err) } }) - proc.stdout!.on('data', (data) => stream.push(data)) - proc.stdout!.on('end', () => stream.push(null)) - proc.stdin!.on('error', (err) => stream.destroy(err)) - proc.on('error', (err) => stream.destroy(err)) + proc.stdout!.on('data', onStdoutData) + proc.stdout!.on('end', onStdoutEnd) + proc.stdin!.on('error', onInputError) + proc.on('error', onProcessError) return { process: proc, sock: stream as unknown as NetSocket } } diff --git a/src/main/ssh/ssh-connection.test.ts b/src/main/ssh/ssh-connection.test.ts index fb58ad7f27e..baae744637d 100644 --- a/src/main/ssh/ssh-connection.test.ts +++ b/src/main/ssh/ssh-connection.test.ts @@ -11,6 +11,10 @@ let connectBehavior: 'ready' | 'error' = 'ready' let connectErrorMessage = '' let destroyErrorMessage = '' let connectSequence: ('ready' | Error)[] = [] +let execBehavior: 'callback' | 'pending' = 'callback' +let pendingExecCallback: ((err: Error | undefined, channel: unknown) => void) | null = null +let sftpBehavior: 'callback' | 'pending' = 'callback' +let pendingSftpCallback: ((err: Error | undefined, channel: unknown) => void) | null = null type MockSshClient = { setNoDelay: ReturnType @@ -83,9 +87,19 @@ vi.mock('ssh2', () => { } exec(cmd: string, cb: (err: Error | undefined, channel: unknown) => void) { this.lastExecCommand = cmd - cb(undefined, {}) + if (execBehavior === 'pending') { + pendingExecCallback = cb + return + } + cb(undefined, { close: vi.fn() }) + } + sftp(cb: (err: Error | undefined, channel: unknown) => void) { + if (sftpBehavior === 'pending') { + pendingSftpCallback = cb + return + } + cb(undefined, { end: vi.fn() }) } - sftp() {} } return { BaseAgent: MockBaseAgent, @@ -173,6 +187,10 @@ describe('SshConnection', () => { connectErrorMessage = '' destroyErrorMessage = '' connectSequence = [] + execBehavior = 'callback' + pendingExecCallback = null + sftpBehavior = 'callback' + pendingSftpCallback = null clientInstances = [] spawnSystemSshCommandMock.mockReset() spawnSystemSshCommandMock.mockImplementation(() => createSystemCommandChannel()) @@ -544,6 +562,94 @@ describe('SshConnection', () => { ) }) + it('times out when ssh2 never opens an exec channel', async () => { + const conn = new SshConnection(createTarget(), createCallbacks()) + await conn.connect() + execBehavior = 'pending' + + vi.useFakeTimers() + try { + const outcomePromise = conn + .exec('printf ready') + .then(() => 'opened') + .catch((error: Error) => error.message) + + await vi.advanceTimersByTimeAsync(30_000) + const outcome = await Promise.race([outcomePromise, Promise.resolve('pending')]) + + expect(outcome).toBe('SSH exec channel timed out') + } finally { + vi.useRealTimers() + } + }) + + it('closes a late exec callback after the channel-open timeout settles', async () => { + const conn = new SshConnection(createTarget(), createCallbacks()) + await conn.connect() + execBehavior = 'pending' + const lateChannel = { close: vi.fn() } + + vi.useFakeTimers() + try { + const outcomePromise = conn + .exec('printf ready') + .then(() => 'opened') + .catch((error: Error) => error.message) + + await vi.advanceTimersByTimeAsync(30_000) + pendingExecCallback?.(undefined, lateChannel) + + await expect(outcomePromise).resolves.toBe('SSH exec channel timed out') + expect(lateChannel.close).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('times out when ssh2 never opens an SFTP channel', async () => { + const conn = new SshConnection(createTarget(), createCallbacks()) + await conn.connect() + sftpBehavior = 'pending' + + vi.useFakeTimers() + try { + const outcomePromise = conn + .sftp() + .then(() => 'opened') + .catch((error: Error) => error.message) + + await vi.advanceTimersByTimeAsync(30_000) + const outcome = await Promise.race([outcomePromise, Promise.resolve('pending')]) + + expect(outcome).toBe('SSH SFTP channel timed out') + } finally { + vi.useRealTimers() + } + }) + + it('ends a late SFTP callback after the channel-open timeout settles', async () => { + const conn = new SshConnection(createTarget(), createCallbacks()) + await conn.connect() + sftpBehavior = 'pending' + const lateSftp = { end: vi.fn() } + + vi.useFakeTimers() + try { + const outcomePromise = conn + .sftp() + .then(() => 'opened') + .catch((error: Error) => error.message) + + await vi.advanceTimersByTimeAsync(30_000) + pendingSftpCallback?.(undefined, lateSftp) + + await expect(outcomePromise).resolves.toBe('SSH SFTP channel timed out') + expect(lateSftp.end).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + it('uses system SSH transport when ProxyUseFdpass is resolved by OpenSSH', async () => { vi.mocked(resolveWithSshG).mockResolvedValueOnce({ hostname: 'example.com', @@ -565,6 +671,41 @@ describe('SshConnection', () => { 'printf ORCA-SYSTEM-SSH-OK' ) }) + + it('removes system SSH probe listeners after timeout', async () => { + vi.useFakeTimers() + const channel = new EventEmitter() as ReturnType + channel.stdin = { end: vi.fn(), write: vi.fn() } + channel.stderr = new EventEmitter() + channel.close = vi.fn() + spawnSystemSshCommandMock.mockReturnValueOnce(channel) + vi.mocked(resolveWithSshG).mockResolvedValueOnce({ + hostname: 'example.com', + port: 22, + identityFile: [], + forwardAgent: false, + identitiesOnly: false, + proxyUseFdpass: true + }) + const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks()) + + try { + const connect = expect(conn.connect()).rejects.toThrow('System SSH connection timed out') + await vi.advanceTimersByTimeAsync(30_000) + + await connect + expect(channel.close).toHaveBeenCalled() + expect(channel.listenerCount('data')).toBe(0) + expect(channel.listenerCount('error')).toBe(1) + expect(channel.listenerCount('close')).toBe(1) + expect(channel.stderr.listenerCount('data')).toBe(0) + expect( + (conn as unknown as { systemCommandChannels: Set }).systemCommandChannels.size + ).toBe(0) + } finally { + vi.useRealTimers() + } + }) }) describe('shouldUseSystemSshTransport', () => { diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index 86ca9e2554e..a352ab9dbed 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -94,8 +94,11 @@ export class SshConnection { if (!this.client) { throw new Error('Not connected') } - return new Promise((res, rej) => - this.client!.exec(wrapRemoteCommandForPosixShell(cmd), (e, ch) => (e ? rej(e) : res(ch))) + const client = this.client + return this.waitForSshCallback( + 'SSH exec channel timed out', + (callback) => client.exec(wrapRemoteCommandForPosixShell(cmd), callback), + (channel) => channel.close() ) } @@ -106,7 +109,56 @@ export class SshConnection { if (!this.client) { throw new Error('Not connected') } - return new Promise((res, rej) => this.client!.sftp((e, s) => (e ? rej(e) : res(s)))) + const client = this.client + return this.waitForSshCallback( + 'SSH SFTP channel timed out', + (callback) => client.sftp(callback), + (sftp) => sftp.end() + ) + } + + private waitForSshCallback( + timeoutMessage: string, + register: (callback: (error: Error | undefined, value: T) => void) => void, + cleanupLateValue?: (value: T) => void + ): Promise { + return new Promise((resolve, reject) => { + let settled = false + const timer = setTimeout(() => { + settled = true + reject(new Error(timeoutMessage)) + }, CONNECT_TIMEOUT_MS) + const finish = (error: Error | undefined, value?: T): void => { + if (settled) { + // Why: ssh2 can invoke the open callback after our timeout has + // rejected. Close that late resource so the remote channel is not + // left open with no owner. + if (!error && value !== undefined) { + try { + cleanupLateValue?.(value) + } catch { + /* best effort */ + } + } + return + } + settled = true + clearTimeout(timer) + if (error) { + reject(error) + return + } + resolve(value as T) + } + + try { + // Why: higher-level channel timers start only after ssh2 invokes its + // open callback. A stale SSH socket can otherwise keep exec/sftp stuck. + register(finish) + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))) + } + }) } async uploadDirectory(localDir: string, remoteDir: string): Promise { @@ -372,47 +424,60 @@ export class SshConnection { let stdout = '' let stderr = '' let settled = false - const timeout = setTimeout(() => { + const cleanup = (): void => { + clearTimeout(timeout) + channel.off('data', onStdoutData) + channel.stderr.off('data', onStderrData) + channel.off('error', onError) + channel.off('close', onClose) + this.systemCommandChannels.delete(channel) + } + const settle = (callback: () => void): void => { + if (settled) { + return + } settled = true - channel.close() - reject(new Error('System SSH connection timed out')) + cleanup() + callback() + } + const onStdoutData = (data: Buffer): void => { + stdout += data.toString('utf-8') + } + const onStderrData = (data: Buffer): void => { + stderr += data.toString('utf-8') + } + const onError = (err: Error): void => { + settle(() => reject(err)) + } + const onClose = (code: number | null): void => { + settle(() => { + if (this.disposed || connectGeneration !== this.connectGeneration) { + reject(new Error('SSH connection attempt was cancelled')) + return + } + if (code !== 0 || !stdout.includes('ORCA-SYSTEM-SSH-OK')) { + reject( + new Error( + `System SSH probe failed${code != null ? ` (exit ${code})` : ''}.${stderr ? ` stderr: ${stderr.trim()}` : ''}` + ) + ) + return + } + this.setState('connected') + resolve() + }) + } + const timeout = setTimeout(() => { + settle(() => { + channel.close() + reject(new Error('System SSH connection timed out')) + }) }, CONNECT_TIMEOUT_MS) - channel.on('data', (data: Buffer) => { - stdout += data.toString('utf-8') - }) - channel.stderr.on('data', (data: Buffer) => { - stderr += data.toString('utf-8') - }) - channel.on('error', (err: Error) => { - if (settled) { - return - } - settled = true - clearTimeout(timeout) - reject(err) - }) - channel.on('close', (code: number | null) => { - if (settled) { - return - } - settled = true - clearTimeout(timeout) - if (this.disposed || connectGeneration !== this.connectGeneration) { - reject(new Error('SSH connection attempt was cancelled')) - return - } - if (code !== 0 || !stdout.includes('ORCA-SYSTEM-SSH-OK')) { - reject( - new Error( - `System SSH probe failed${code != null ? ` (exit ${code})` : ''}.${stderr ? ` stderr: ${stderr.trim()}` : ''}` - ) - ) - return - } - this.setState('connected') - resolve() - }) + channel.on('data', onStdoutData) + channel.stderr.on('data', onStderrData) + channel.on('error', onError) + channel.on('close', onClose) }) } catch (err) { this.useSystemSshTransport = false diff --git a/src/main/ssh/ssh-relay-deploy-helpers.test.ts b/src/main/ssh/ssh-relay-deploy-helpers.test.ts index 3c30d23318b..a954e03ab0c 100644 --- a/src/main/ssh/ssh-relay-deploy-helpers.test.ts +++ b/src/main/ssh/ssh-relay-deploy-helpers.test.ts @@ -109,6 +109,50 @@ describe('waitForSentinel', () => { expect(err).not.toBeInstanceOf(RelayVersionMismatchError) }) }) + + it('rejects and closes the channel when pre-sentinel stdout exceeds the startup buffer cap', async () => { + const channel = createMockChannel() + const transportPromise = waitForSentinel(channel) + + channel.emit('data', Buffer.alloc(65 * 1024, 'a')) + + const outcome = await Promise.race([ + transportPromise.then( + () => 'resolved', + (err: Error) => `rejected:${err.message}` + ), + new Promise((resolve) => setTimeout(() => resolve('pending'), 0)) + ]) + if (outcome === 'pending') { + channel.emit('close') + await transportPromise.catch(() => {}) + } + + expect(outcome).toContain('Relay startup output exceeded') + expect(channel.close).toHaveBeenCalledTimes(1) + }) + + it('does not count same-chunk post-sentinel payload against the startup buffer cap', async () => { + const channel = createMockChannel() + const transportPromise = waitForSentinel(channel) + const postSentinelPayload = Buffer.alloc(70 * 1024, 'b') + + channel.emit( + 'data', + Buffer.concat([ + Buffer.alloc(64 * 1024, 'a'), + Buffer.from(RELAY_SENTINEL), + postSentinelPayload + ]) + ) + + const transport = await transportPromise + const chunks: Buffer[] = [] + transport.onData((chunk) => chunks.push(chunk)) + + expect(Buffer.concat(chunks)).toEqual(postSentinelPayload) + expect(channel.close).not.toHaveBeenCalled() + }) }) describe('execCommand', () => { @@ -122,5 +166,35 @@ describe('execCommand', () => { await Promise.resolve() expect(() => channel.emit('error', new Error('remote host rebooted'))).not.toThrow() await expect(commandPromise).rejects.toThrow('remote host rebooted') + expect(channel.listenerCount('error')).toBe(0) + expect(channel.listenerCount('data')).toBe(0) + expect(channel.listenerCount('close')).toBe(0) + expect(channel.stderr.listenerCount('error')).toBe(0) + expect(channel.stderr.listenerCount('data')).toBe(0) + }) + + it('cleans command channel listeners when a command times out', async () => { + vi.useFakeTimers() + try { + const channel = createMockChannel() + const conn = { + exec: vi.fn().mockResolvedValue(channel) + } + const commandPromise = execCommand(conn as never, 'sleep 60') + + await Promise.resolve() + const rejection = expect(commandPromise).rejects.toThrow('timed out') + await vi.advanceTimersByTimeAsync(30_000) + + await rejection + expect(channel.close).toHaveBeenCalledOnce() + expect(channel.listenerCount('error')).toBe(0) + expect(channel.listenerCount('data')).toBe(0) + expect(channel.listenerCount('close')).toBe(0) + expect(channel.stderr.listenerCount('error')).toBe(0) + expect(channel.stderr.listenerCount('data')).toBe(0) + } finally { + vi.useRealTimers() + } }) }) diff --git a/src/main/ssh/ssh-relay-deploy-helpers.ts b/src/main/ssh/ssh-relay-deploy-helpers.ts index 4b3146a0cd1..9e2be8c15a4 100644 --- a/src/main/ssh/ssh-relay-deploy-helpers.ts +++ b/src/main/ssh/ssh-relay-deploy-helpers.ts @@ -11,12 +11,15 @@ export { uploadFile, uploadDirectory, mkdirSftp } from './sftp-upload' // ── Sentinel detection ──────────────────────────────────────────────── +const MAX_RELAY_STARTUP_BUFFER_BYTES = 64 * 1024 +const RELAY_SENTINEL_BUFFER = Buffer.from(RELAY_SENTINEL, 'utf-8') + export function waitForSentinel(channel: ClientChannel): Promise { return new Promise((resolve, reject) => { let sentinelReceived = false let settled = false let stderrOutput = '' - let bufferedStdout = Buffer.alloc(0) + let bufferedStdout: Buffer = Buffer.alloc(0) let closedAfterSentinel = false // Why: ssh2 fires 'exit' BEFORE 'close' with the remote exit code. // Capturing it here lets us translate exit-42 (relay handshake mismatch) @@ -65,11 +68,10 @@ export function waitForSentinel(channel: ClientChannel): Promise { stderrOutput += data.toString('utf-8') - if (stderrOutput.length > MAX_BUFFER_CAP) { - stderrOutput = stderrOutput.slice(-MAX_BUFFER_CAP) + if (stderrOutput.length > MAX_RELAY_STARTUP_BUFFER_BYTES) { + stderrOutput = stderrOutput.slice(-MAX_RELAY_STARTUP_BUFFER_BYTES) } }) @@ -98,6 +100,13 @@ export function waitForSentinel(channel: ClientChannel): Promise { + // Why: before the sentinel, stdout is untrusted startup noise; cap it + // like stderr so a broken remote cannot grow memory until timeout. + failOrClose(new Error('Relay startup output exceeded 64 KiB before ready sentinel')) + channel.close() + } + // Why: SSH channel streams emit `error` when the remote host disappears. // Unhandled EventEmitter errors are process-fatal, so convert them into // startup rejection before the sentinel and transport close after it. @@ -157,17 +166,31 @@ export function waitForSentinel(channel: ClientChannel): Promise MAX_RELAY_STARTUP_BUFFER_BYTES) { + failStartupOutputCap() + return + } if (sentinelIdx !== -1) { sentinelReceived = true cancelTimers() - const afterSentinel = bufferedStdout.subarray( - Buffer.byteLength(text.substring(0, sentinelIdx + RELAY_SENTINEL.length), 'utf-8') - ) + const afterSentinelOffset = + sentinelIdx + RELAY_SENTINEL_BUFFER.length - bufferedStdout.length + const afterSentinel = data.subarray(Math.max(0, afterSentinelOffset)) if (afterSentinel.length > 0) { pendingAfterSentinel = afterSentinel @@ -200,7 +223,15 @@ export function waitForSentinel(channel: ClientChannel): Promise MAX_RELAY_STARTUP_BUFFER_BYTES) { + failStartupOutputCap() + return + } + + bufferedStdout = bufferedStdout.length === 0 ? data : Buffer.concat([bufferedStdout, data]) }) }) } @@ -216,45 +247,50 @@ export async function execCommand(conn: SshConnection, command: string): Promise let stderr = '' let settled = false - const timeout = setTimeout(() => { - if (!settled) { - settled = true - channel.close() - reject(new Error(`Command "${command}" timed out after ${EXEC_TIMEOUT_MS / 1000}s`)) - } - }, EXEC_TIMEOUT_MS) - - const fail = (err: Error): void => { + const cleanup = (): void => { + clearTimeout(timeout) + channel.off('error', fail) + channel.stderr.off('error', fail) + channel.off('data', onStdoutData) + channel.stderr.off('data', onStderrData) + channel.off('close', onClose) + } + const settle = (fn: typeof resolve | typeof reject, val: string | Error): void => { if (settled) { return } settled = true - clearTimeout(timeout) - reject(err) + cleanup() + fn(val as never) } + const fail = (err: Error): void => { + settle(reject, err) + } + const onStdoutData = (data: Buffer): void => { + stdout += data.toString('utf-8') + } + const onStderrData = (data: Buffer): void => { + stderr += data.toString('utf-8') + } + const onClose = (code: number): void => { + if (code !== 0) { + settle(reject, new Error(`Command "${command}" failed (exit ${code}): ${stderr.trim()}`)) + } else { + settle(resolve, stdout) + } + } + const timeout = setTimeout(() => { + channel.close() + settle(reject, new Error(`Command "${command}" timed out after ${EXEC_TIMEOUT_MS / 1000}s`)) + }, EXEC_TIMEOUT_MS) // Why: remote reboot tears down exec channels with stream errors. Without // scoped listeners, Node treats those as uncaught exceptions. channel.on('error', fail) channel.stderr.on('error', fail) - channel.on('data', (data: Buffer) => { - stdout += data.toString('utf-8') - }) - channel.stderr.on('data', (data: Buffer) => { - stderr += data.toString('utf-8') - }) - channel.on('close', (code: number) => { - if (settled) { - return - } - settled = true - clearTimeout(timeout) - if (code !== 0) { - reject(new Error(`Command "${command}" failed (exit ${code}): ${stderr.trim()}`)) - } else { - resolve(stdout) - } - }) + channel.on('data', onStdoutData) + channel.stderr.on('data', onStderrData) + channel.on('close', onClose) }) } diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index 51c710da424..d88ceddc624 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -34,6 +34,10 @@ import { export type RelayDeployResult = { transport: MultiplexerTransport platform: RelayPlatform + remoteHome?: string + remoteRelayDir?: string + nodePath?: string + sockPath?: string } // Why: individual exec commands have 30s timeouts, but the full deploy @@ -161,7 +165,7 @@ async function deployAndLaunchRelayInner( onProgress?.('Starting relay...') console.log('[ssh-relay] Launching relay...') - const transport = await launchRelay(conn, remoteRelayDir, graceTimeSeconds, relayInstanceId) + const launched = await launchRelay(conn, remoteRelayDir, graceTimeSeconds, relayInstanceId) console.log('[ssh-relay] Relay started successfully') // Why: best-effort cleanup of unreferenced sibling version dirs. Errors @@ -169,7 +173,14 @@ async function deployAndLaunchRelayInner( // can never block the user from connecting. void gcOldRelayVersions(conn, remoteHome, remoteRelayDir).catch(() => {}) - return { transport, platform } + return { + transport: launched.transport, + platform, + remoteHome, + remoteRelayDir, + nodePath: launched.nodePath, + sockPath: launched.sockPath + } } async function detectRemotePlatform(conn: SshConnection): Promise { @@ -423,7 +434,7 @@ async function launchRelay( remoteDir: string, graceTimeSeconds?: number, relayInstanceId?: string -): Promise { +): Promise<{ transport: MultiplexerTransport; nodePath: string; sockPath: string }> { // Why: Phase 1 of the plan requires Node.js on the remote. We use the // system `node` rather than bundling a node binary, keeping the relay // package small (~100KB JS vs ~60MB with embedded node). @@ -466,7 +477,7 @@ async function launchRelay( ) const transport = await waitForSentinel(channel) console.log('[ssh-relay] Reconnected to existing relay via socket') - return transport + return { transport, nodePath, sockPath: sockFile } } catch (err) { console.warn( '[ssh-relay] Socket reconnect failed, launching fresh relay:', @@ -555,5 +566,5 @@ async function launchRelay( const channel = await conn.exec( `cd ${escapedDir} && ${escapedNode} relay.js --connect --sock-path ${shellEscape(sockFile)}` ) - return waitForSentinel(channel) + return { transport: await waitForSentinel(channel), nodePath, sockPath: sockFile } } diff --git a/src/main/ssh/ssh-relay-session-terminal-error.test.ts b/src/main/ssh/ssh-relay-session-terminal-error.test.ts index 6c06358e1c0..34c67bb55af 100644 --- a/src/main/ssh/ssh-relay-session-terminal-error.test.ts +++ b/src/main/ssh/ssh-relay-session-terminal-error.test.ts @@ -17,6 +17,7 @@ vi.mock('./ssh-channel-multiplexer', () => { notify = vi.fn() request = vi.fn().mockResolvedValue([]) onNotification = vi.fn().mockReturnValue(() => {}) + onRequest = vi.fn().mockReturnValue(() => {}) onDispose = vi.fn().mockReturnValue(() => {}) dispose = vi.fn() isDisposed = vi.fn().mockReturnValue(false) @@ -57,6 +58,7 @@ vi.mock('../ipc/pty', () => ({ clearPtyOwnershipForConnection: vi.fn(), clearProviderPtyState: vi.fn(), deletePtyOwnership: vi.fn(), + isRendererPtyOutputPaused: vi.fn(() => false), setPtyOwnership: vi.fn() })) diff --git a/src/main/ssh/ssh-relay-session.test.ts b/src/main/ssh/ssh-relay-session.test.ts index ee4fbb8bb9a..a45e356d08f 100644 --- a/src/main/ssh/ssh-relay-session.test.ts +++ b/src/main/ssh/ssh-relay-session.test.ts @@ -24,6 +24,7 @@ vi.mock('./ssh-channel-multiplexer', () => { notify = vi.fn() request = muxRequestMock onNotification = vi.fn().mockReturnValue(() => {}) + onRequest = vi.fn().mockReturnValue(() => {}) onDispose = vi.fn().mockReturnValue(() => {}) dispose = vi.fn() isDisposed = vi.fn().mockReturnValue(false) @@ -68,6 +69,7 @@ vi.mock('../ipc/pty', () => ({ clearPtyOwnershipForConnection: vi.fn(), clearProviderPtyState: vi.fn(), deletePtyOwnership: vi.fn(), + isRendererPtyOutputPaused: vi.fn(() => false), setPtyOwnership: vi.fn() })) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 76a9ac4aca7..c696be20683 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -10,6 +10,7 @@ import type { BrowserWindow } from 'electron' import { deployAndLaunchRelay } from './ssh-relay-deploy' +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' @@ -36,6 +37,7 @@ import { clearPtyOwnershipForConnection, clearProviderPtyState, deletePtyOwnership, + isRendererPtyOutputPaused, setPtyOwnership } from '../ipc/pty' import { @@ -48,6 +50,7 @@ import { notifyRemoteWorkspaceHandlers } from '../ipc/remote-workspace-events' import { PortScanner } from './ssh-port-scanner' import type { SshPortForwardManager } from './ssh-port-forward' import type { SshConnection } from './ssh-connection' +import { shellEscape } from './ssh-connection-utils' import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS, type DetectedPort, @@ -57,6 +60,7 @@ import { } from '../../shared/ssh-types' import type { Store } from '../persistence' import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import { runRemoteOrcaCli } from './ssh-remote-orca-cli' export type RelaySessionState = 'idle' | 'deploying' | 'ready' | 'reconnecting' | 'disposed' @@ -96,6 +100,12 @@ export class SshRelaySession { private _onReady: ((targetId: string) => void) | null = null private portScanner: PortScanner | null = null private currentConnection: SshConnection | null = null + private remoteCliBridgeEnv: { + binDir: string + relayDir: string + nodePath: string + sockPath: string + } | null = null constructor( readonly targetId: string, @@ -183,12 +193,17 @@ export class SshRelaySession { this.currentConnection = conn try { - const { transport } = await deployAndLaunchRelay( - conn, - undefined, - graceTimeSeconds, - this.targetId - ) + const { transport, remoteHome, remoteRelayDir, nodePath, sockPath } = + await deployAndLaunchRelay(conn, undefined, graceTimeSeconds, this.targetId) + this.remoteCliBridgeEnv = + remoteHome && remoteRelayDir && nodePath && sockPath + ? { + binDir: `${remoteHome}/.orca-relay/bin`, + relayDir: remoteRelayDir, + nodePath, + sockPath + } + : null // Why: dispose() can fire during the await above (e.g. user clicks // disconnect while relay is deploying). If so, the session is already @@ -302,12 +317,17 @@ export class SshRelaySession { this.teardownProviders('connection_lost') try { - const { transport } = await deployAndLaunchRelay( - conn, - undefined, - graceTimeSeconds, - this.targetId - ) + const { transport, remoteHome, remoteRelayDir, nodePath, sockPath } = + await deployAndLaunchRelay(conn, undefined, graceTimeSeconds, this.targetId) + this.remoteCliBridgeEnv = + remoteHome && remoteRelayDir && nodePath && sockPath + ? { + binDir: `${remoteHome}/.orca-relay/bin`, + relayDir: remoteRelayDir, + nodePath, + sockPath + } + : null if (abortController.signal.aborted || this.isDisposed()) { // Why: the relay is already running on the remote. Creating a temporary @@ -486,7 +506,14 @@ export class SshRelaySession { return false } - const ptyProvider = new SshPtyProvider(this.targetId, mux) + await this.installRemoteOrcaCliShim() + if (shouldContinue && !shouldContinue()) { + return false + } + + this.wireUpRemoteOrcaCli(mux) + + const ptyProvider = new SshPtyProvider(this.targetId, mux, this.remoteCliBridgeEnv ?? undefined) registerSshPtyProvider(this.targetId, ptyProvider) const fsProvider = new SshFilesystemProvider(this.targetId, mux, () => @@ -558,6 +585,70 @@ export class SshRelaySession { } } + private async installRemoteOrcaCliShim(): Promise { + if (!this.remoteCliBridgeEnv) { + return + } + const { binDir, relayDir, nodePath, sockPath } = this.remoteCliBridgeEnv + const shimPath = `${binDir}/orca` + const shim = [ + '#!/usr/bin/env sh', + 'set -eu', + `ORCA_RELAY_NODE_PATH=\${ORCA_RELAY_NODE_PATH:-${quoteSh(nodePath)}}`, + `ORCA_RELAY_DIR=\${ORCA_RELAY_DIR:-${quoteSh(relayDir)}}`, + `ORCA_RELAY_SOCKET_PATH=\${ORCA_RELAY_SOCKET_PATH:-${quoteSh(sockPath)}}`, + 'if [ ! -S "$ORCA_RELAY_SOCKET_PATH" ]; then', + ' echo "Orca SSH CLI bridge cannot find the relay socket: $ORCA_RELAY_SOCKET_PATH" >&2', + ' exit 1', + 'fi', + 'exec "$ORCA_RELAY_NODE_PATH" "$ORCA_RELAY_DIR/relay.js" --sock-path "$ORCA_RELAY_SOCKET_PATH" --orca-cli "$@"', + '' + ].join('\n') + + await execCommand(this.requireReadyConnection(), `mkdir -p ${shellEscape(binDir)}`) + const conn = this.requireReadyConnection() + if (typeof conn.writeFile === 'function') { + await conn.writeFile(shimPath, shim) + } else { + const sftp = await conn.sftp() + try { + await new Promise((resolve, reject) => { + const ws = sftp.createWriteStream(shimPath) + sftp.once('error', reject) + ws.once('close', resolve) + ws.once('error', reject) + ws.end(shim) + }) + } finally { + sftp.end() + } + } + await execCommand(conn, `chmod 755 ${shellEscape(shimPath)}`) + } + + private wireUpRemoteOrcaCli(mux: SshChannelMultiplexer): void { + mux.onRequest('orca.cli', async (params) => { + if (!this.runtime) { + throw new Error('Orca runtime is unavailable') + } + const argv = Array.isArray(params.argv) + ? params.argv.filter((item): item is string => typeof item === 'string') + : [] + const cwd = typeof params.cwd === 'string' && params.cwd.length > 0 ? params.cwd : '/' + const rawEnv = params.env + const env = + rawEnv && typeof rawEnv === 'object' && !Array.isArray(rawEnv) + ? Object.fromEntries( + Object.entries(rawEnv).filter( + (entry): entry is [string, string] => + typeof entry[0] === 'string' && typeof entry[1] === 'string' + ) + ) + : {} + return await runRemoteOrcaCli(this.runtime, { argv, cwd, env }) + }) + } + // Why: ship the OpenCode plugin / Pi extension source bodies to the relay // so it can materialize per-PTY overlay dirs and inject OPENCODE_CONFIG_DIR // / PI_CODING_AGENT_DIR into spawn env. The strings change as we add agent @@ -815,7 +906,7 @@ export class SshRelaySession { ptyProvider.onData((payload) => { const seq = this.runtime?.onPtyData(payload.id, payload.data, Date.now()) const win = this.getMainWindow() - if (win && !win.isDestroyed()) { + if (win && !win.isDestroyed() && !isRendererPtyOutputPaused(payload.id)) { win.webContents.send('pty:data', { ...payload, ...(typeof seq === 'number' ? { seq, rawLength: payload.data.length } : {}) @@ -896,3 +987,7 @@ export class SshRelaySession { } } } + +function quoteSh(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'` +} diff --git a/src/main/ssh/ssh-remote-orca-cli.test.ts b/src/main/ssh/ssh-remote-orca-cli.test.ts new file mode 100644 index 00000000000..9d07d2aa7ed --- /dev/null +++ b/src/main/ssh/ssh-remote-orca-cli.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import { runRemoteOrcaCli } from './ssh-remote-orca-cli' + +describe('runRemoteOrcaCli', () => { + function createRuntime() { + const messages: { + id: string + from_handle: string + to_handle: string + subject: string + body?: string + read_at: string | null + }[] = [] + let nextMessage = 1 + const db = { + insertMessage: vi.fn( + (message: { from: string; to: string; subject: string; body?: string }) => { + const row = { + id: `msg_${nextMessage++}`, + from_handle: message.from, + to_handle: message.to, + subject: message.subject, + body: message.body, + read_at: null + } + messages.push(row) + return row + } + ), + getUnreadMessages: vi.fn((handle: string) => + messages.filter((message) => message.to_handle === handle && message.read_at === null) + ), + getAllMessagesForHandle: vi.fn((handle: string) => + messages.filter((message) => message.to_handle === handle) + ), + markAsRead: vi.fn((ids: string[]) => { + for (const message of messages) { + if (ids.includes(message.id)) { + message.read_at = new Date(0).toISOString() + } + } + }) + } + const runtime = { + getRuntimeId: () => 'runtime-test', + getStatus: () => ({ + runtimeId: 'runtime-test', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: 1, + liveTabCount: 1, + liveLeafCount: 1 + }), + getOrchestrationDb: () => db, + deliverPendingMessagesForHandle: vi.fn(), + notifyMessageArrived: vi.fn() + } as unknown as OrcaRuntimeService + return { runtime, db } + } + + it('uses the remote ORCA_TERMINAL_HANDLE as orchestration sender identity', async () => { + const { runtime, db } = createRuntime() + + const result = await runRemoteOrcaCli(runtime, { + argv: ['orchestration', 'send', '--to', 'term_windows', '--subject', 'ping', '--json'], + cwd: '/home/alice/repo', + env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } + }) + + expect(result.exitCode).toBe(0) + const payload = JSON.parse(result.stdout) as { ok: boolean } + expect(payload.ok).toBe(true) + expect(db.getUnreadMessages('term_windows')[0]?.from_handle).toBe('term_ssh') + }) + + it('accepts equals-style orchestration flags in the remote shim', async () => { + const { runtime, db } = createRuntime() + + const result = await runRemoteOrcaCli(runtime, { + argv: [ + 'orchestration', + 'send', + '--to=term_windows', + '--subject=ping', + '--body=--literal-body', + '--json' + ], + cwd: '/home/alice/repo', + env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } + }) + + expect(result.exitCode).toBe(0) + const payload = JSON.parse(result.stdout) as { ok: boolean } + expect(payload.ok).toBe(true) + const message = db.getUnreadMessages('term_windows')[0] + expect(message?.from_handle).toBe('term_ssh') + expect(message?.body).toBe('--literal-body') + }) + + it('uses the remote ORCA_TERMINAL_HANDLE as orchestration check identity', async () => { + const { runtime, db } = createRuntime() + db.insertMessage({ + from: 'term_windows', + to: 'term_ssh', + subject: 'pong', + body: 'hello' + }) + + const result = await runRemoteOrcaCli(runtime, { + argv: ['orchestration', 'check', '--all', '--json'], + cwd: '/home/alice/repo', + env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } + }) + + expect(result.exitCode).toBe(0) + const payload = JSON.parse(result.stdout) as { + ok: boolean + result: { count: number; messages: { subject: string }[] } + } + expect(payload.ok).toBe(true) + expect(payload.result.count).toBe(1) + expect(payload.result.messages[0]?.subject).toBe('pong') + }) +}) diff --git a/src/main/ssh/ssh-remote-orca-cli.ts b/src/main/ssh/ssh-remote-orca-cli.ts new file mode 100644 index 00000000000..9b61d6b93b9 --- /dev/null +++ b/src/main/ssh/ssh-remote-orca-cli.ts @@ -0,0 +1,216 @@ +import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types' +import { RpcDispatcher } from '../runtime/rpc/dispatcher' +import type { RpcResponse } from '../runtime/rpc/core' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' + +export type RemoteOrcaCliRequest = { + argv: string[] + cwd: string + env: Record +} + +export type RemoteOrcaCliResult = { + stdout: string + stderr: string + exitCode: number +} + +type ParsedRemoteCli = { + commandPath: string[] + flags: Map +} + +export async function runRemoteOrcaCli( + runtime: OrcaRuntimeService, + request: RemoteOrcaCliRequest +): Promise { + const dispatcher = new RpcDispatcher({ runtime }) + const parsed = parseRemoteCliArgs(request.argv) + const json = parsed.flags.has('json') + + try { + const response = await dispatchRemoteCli(dispatcher, parsed, request.env) + return { + stdout: json ? `${JSON.stringify(response, null, 2)}\n` : `${formatRemoteCli(response)}\n`, + stderr: '', + exitCode: response.ok ? 0 : 1 + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + if (json) { + return { + stdout: `${JSON.stringify(buildLocalError(message), null, 2)}\n`, + stderr: '', + exitCode: 1 + } + } + return { stdout: '', stderr: `${message}\n`, exitCode: 1 } + } +} + +async function dispatchRemoteCli( + dispatcher: RpcDispatcher, + parsed: ParsedRemoteCli, + env: Record +): Promise { + const command = parsed.commandPath.join(' ') + switch (command) { + case 'status': { + const response = await call(dispatcher, 'status.get') + if (!response.ok) { + return response + } + const status = response.result as RuntimeStatus + const cliStatus: CliStatusResult = { + app: { running: true, pid: null }, + runtime: { + state: status.graphStatus === 'ready' ? 'ready' : 'graph_not_ready', + reachable: true, + runtimeId: status.runtimeId + }, + graph: { state: status.graphStatus } + } + return { ...response, result: cliStatus } + } + case 'terminal list': + return await call(dispatcher, 'terminal.list', { + worktree: optionalString(parsed.flags, 'worktree'), + limit: optionalNumber(parsed.flags, 'limit') + }) + case 'orchestration send': + return await call(dispatcher, 'orchestration.send', { + from: resolveHandle(parsed.flags, env, 'from'), + to: requiredString(parsed.flags, 'to'), + subject: requiredString(parsed.flags, 'subject'), + body: optionalString(parsed.flags, 'body'), + type: optionalString(parsed.flags, 'type'), + priority: optionalString(parsed.flags, 'priority'), + threadId: optionalString(parsed.flags, 'thread-id'), + payload: optionalString(parsed.flags, 'payload') + }) + case 'orchestration check': + return await call(dispatcher, 'orchestration.check', { + terminal: resolveHandle(parsed.flags, env, 'terminal'), + unread: parsed.flags.has('unread') ? true : undefined, + all: parsed.flags.has('all') ? true : undefined, + types: optionalString(parsed.flags, 'types'), + inject: parsed.flags.has('inject') ? true : undefined, + wait: parsed.flags.has('wait') ? true : undefined, + timeoutMs: optionalNumber(parsed.flags, 'timeout-ms') + }) + case 'orchestration reply': + return await call(dispatcher, 'orchestration.reply', { + id: requiredString(parsed.flags, 'id'), + body: requiredString(parsed.flags, 'body'), + from: resolveHandle(parsed.flags, env, 'from') + }) + case 'orchestration inbox': + return await call(dispatcher, 'orchestration.inbox', { + limit: optionalNumber(parsed.flags, 'limit'), + terminal: optionalString(parsed.flags, 'terminal') + }) + default: + throw new Error(`Unsupported SSH Orca CLI command: ${command}`) + } +} + +async function call( + dispatcher: RpcDispatcher, + method: string, + params?: Record +): Promise { + return await dispatcher.dispatch({ + id: `remote-cli-${Date.now()}`, + authToken: 'remote-cli', + method, + params + }) +} + +function parseRemoteCliArgs(argv: string[]): ParsedRemoteCli { + const commandPath: string[] = [] + const flags = new Map() + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i] + if (!token.startsWith('--')) { + commandPath.push(token) + continue + } + const assignment = token.slice(2) + // Why: the SSH relay-backed shim should accept the same `--flag=value` + // form as the local CLI, including values that themselves start with `--`. + const equalsIndex = assignment.indexOf('=') + if (equalsIndex !== -1) { + flags.set(assignment.slice(0, equalsIndex), assignment.slice(equalsIndex + 1)) + continue + } + + const flag = assignment + const next = argv[i + 1] + if (next && !next.startsWith('--')) { + flags.set(flag, next) + i += 1 + } else { + flags.set(flag, true) + } + } + return { commandPath, flags } +} + +function resolveHandle( + flags: Map, + env: Record, + flagName: string +): string { + return optionalString(flags, flagName) ?? env.ORCA_TERMINAL_HANDLE ?? 'unknown' +} + +function requiredString(flags: Map, name: string): string { + const value = optionalString(flags, name) + if (!value) { + throw new Error(`Missing --${name}`) + } + return value +} + +function optionalString(flags: Map, name: string): string | undefined { + const value = flags.get(name) + return typeof value === 'string' && value.length > 0 ? value : undefined +} + +function optionalNumber(flags: Map, name: string): number | undefined { + const value = optionalString(flags, name) + if (value === undefined) { + return undefined + } + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : undefined +} + +function formatRemoteCli(response: RpcResponse): string { + if (!response.ok) { + return response.error.message + } + const result = response.result as Record + if ('app' in result && 'runtime' in result && 'graph' in result) { + const status = result as CliStatusResult + return [ + `appRunning: ${status.app.running}`, + `pid: ${status.app.pid ?? 'none'}`, + `runtimeState: ${status.runtime.state}`, + `runtimeReachable: ${status.runtime.reachable}`, + `runtimeId: ${status.runtime.runtimeId ?? 'none'}`, + `graphState: ${status.graph.state}` + ].join('\n') + } + return JSON.stringify(response.result) +} + +function buildLocalError(message: string): RpcResponse { + return { + id: 'remote-cli-local', + ok: false, + error: { code: 'runtime_error', message }, + _meta: { runtimeId: 'unknown' } + } +} diff --git a/src/main/ssh/ssh-system-fallback.test.ts b/src/main/ssh/ssh-system-fallback.test.ts index 9d88fdb3d2d..9c7f46fd562 100644 --- a/src/main/ssh/ssh-system-fallback.test.ts +++ b/src/main/ssh/ssh-system-fallback.test.ts @@ -1,3 +1,5 @@ +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' import { describe, expect, it, vi, beforeEach } from 'vitest' const { existsSyncMock, spawnMock } = vi.hoisted(() => ({ @@ -17,7 +19,9 @@ import { buildSshArgs, findSystemSsh, spawnSystemSsh, - spawnSystemSshCommand + spawnSystemSshCommand, + uploadDirectoryViaSystemSsh, + writeFileViaSystemSsh } from './ssh-system-fallback' import type { SshTarget } from '../../shared/ssh-types' @@ -32,6 +36,65 @@ function createTarget(overrides?: Partial): SshTarget { } } +type EventedProcess = EventEmitter & { + stdin: EventEmitter & { + write: ReturnType + end: ReturnType + } + stdout: EventEmitter + stderr: EventEmitter + pid: number + kill: ReturnType + exitCode: number | null + killed: boolean +} + +function createEventedProcess(): EventedProcess { + const proc = new EventEmitter() as EventedProcess + proc.stdin = Object.assign(new EventEmitter(), { + write: vi.fn((_chunk, _encoding, cb?: (err?: Error | null) => void) => cb?.()), + end: vi.fn() + }) + proc.stdout = new EventEmitter() + proc.stderr = new EventEmitter() + proc.pid = 12345 + proc.kill = vi.fn() + proc.exitCode = null + proc.killed = false + return proc +} + +function createMockChildProcess(): EventEmitter & { + stdin: PassThrough + stdout: PassThrough + stderr: PassThrough + pid: number + kill: ReturnType + killed: boolean + exitCode: number | null +} { + const child = new EventEmitter() as EventEmitter & { + stdin: PassThrough + stdout: PassThrough + stderr: PassThrough + pid: number + kill: ReturnType + killed: boolean + exitCode: number | null + } + child.stdin = new PassThrough() + child.stdout = new PassThrough() + child.stderr = new PassThrough() + child.pid = 12345 + child.killed = false + child.exitCode = null + child.kill = vi.fn(() => { + child.killed = true + return true + }) + return child +} + describe('findSystemSsh', () => { beforeEach(() => { existsSyncMock.mockReset() @@ -182,6 +245,37 @@ describe('spawnSystemSsh', () => { expect(mockProc.stdin.end).toHaveBeenCalledWith('contents') }) + it('removes wrapped process listeners after command close', () => { + const proc = createEventedProcess() + spawnMock.mockReturnValue(proc) + + const channel = spawnSystemSshCommand(createTarget(), 'echo hello') + const onClose = vi.fn() + channel.on('close', onClose) + proc.emit('close', 0, null) + + expect(onClose).toHaveBeenCalledWith(0, null) + expect(proc.stdout.listenerCount('data')).toBe(0) + expect(proc.stdout.listenerCount('end')).toBe(0) + expect(proc.stdout.listenerCount('error')).toBe(0) + expect(proc.stdin.listenerCount('error')).toBe(0) + expect(proc.listenerCount('exit')).toBe(0) + expect(proc.listenerCount('close')).toBe(0) + expect(proc.listenerCount('error')).toBe(0) + }) + + it('removes write command wait listeners after close', async () => { + const proc = createEventedProcess() + spawnMock.mockReturnValue(proc) + + const promise = writeFileViaSystemSsh(createTarget(), '/tmp/file', 'contents') + proc.emit('close', 0, null) + + await expect(promise).resolves.toBeUndefined() + expect(proc.stdin.end).toHaveBeenCalledWith('contents') + expect(proc.stderr.listenerCount('data')).toBe(0) + }) + it('throws when no system ssh is found', () => { existsSyncMock.mockReturnValue(false) expect(() => spawnSystemSsh(createTarget())).toThrow('No system ssh binary found') @@ -195,3 +289,60 @@ describe('spawnSystemSsh', () => { expect(typeof result.onExit).toBe('function') }) }) + +describe('system SSH operation aborts', () => { + beforeEach(() => { + existsSyncMock.mockReset() + spawnMock.mockReset() + existsSyncMock.mockImplementation((p: string) => p === '/usr/bin/ssh') + }) + + it('rejects directory uploads when aborted even if child processes do not close', async () => { + const tarCreate = createMockChildProcess() + const sshExtract = createMockChildProcess() + spawnMock.mockReturnValueOnce(tarCreate).mockReturnValueOnce(sshExtract) + const controller = new AbortController() + + const uploadPromise = uploadDirectoryViaSystemSsh( + createTarget(), + '/tmp/local-relay', + '/tmp/remote-relay', + { signal: controller.signal } + ) + controller.abort() + + const outcome = await Promise.race([ + uploadPromise.then( + () => 'resolved', + (error: Error) => error.name + ), + new Promise((resolve) => setTimeout(() => resolve('pending'), 0)) + ]) + + expect(outcome).toBe('AbortError') + expect(tarCreate.kill).toHaveBeenCalledTimes(1) + expect(sshExtract.kill).toHaveBeenCalledTimes(1) + }) + + it('rejects remote file writes when aborted even if ssh never closes', async () => { + const sshProcess = createMockChildProcess() + spawnMock.mockReturnValueOnce(sshProcess) + const controller = new AbortController() + + const writePromise = writeFileViaSystemSsh(createTarget(), '/tmp/remote-file', 'contents', { + signal: controller.signal + }) + controller.abort() + + const outcome = await Promise.race([ + writePromise.then( + () => 'resolved', + (error: Error) => error.name + ), + new Promise((resolve) => setTimeout(() => resolve('pending'), 0)) + ]) + + expect(outcome).toBe('AbortError') + expect(sshProcess.kill).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/ssh/ssh-system-fallback.ts b/src/main/ssh/ssh-system-fallback.ts index cd5b244e021..4be22fc5553 100644 --- a/src/main/ssh/ssh-system-fallback.ts +++ b/src/main/ssh/ssh-system-fallback.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Why: system-ssh process wrapping and fallback file operations share cleanup contracts. */ import { spawn, type ChildProcess } from 'child_process' import { existsSync } from 'fs' import { Duplex } from 'stream' @@ -108,25 +109,25 @@ export async function uploadDirectoryViaSystemSsh( } ) - const abort = (): void => { - killProcess(tarCreate) - killProcess(sshExtract) - } - options?.signal?.addEventListener('abort', abort, { once: true }) let tarResult: ProcessResult | null = null let sshResult: ProcessResult | null = null try { - ;[tarResult, sshResult] = await Promise.all([ - waitForProcess(tarCreate, 'local tar relay upload'), - waitForProcess(sshExtract, 'system ssh relay upload'), - pipeline(tarCreate.stdout!, sshExtract.stdin!) - ]).then(([tar, ssh]) => [tar, ssh]) + ;[tarResult, sshResult] = await awaitWithSystemSshAbort( + options?.signal, + () => { + killProcess(tarCreate) + killProcess(sshExtract) + }, + Promise.all([ + waitForProcess(tarCreate, 'local tar relay upload'), + waitForProcess(sshExtract, 'system ssh relay upload'), + pipeline(tarCreate.stdout!, sshExtract.stdin!) + ]).then(([tar, ssh]) => [tar, ssh] as const) + ) } catch (err) { killProcess(tarCreate) killProcess(sshExtract) throw err - } finally { - options?.signal?.removeEventListener('abort', abort) } if (tarResult?.stderr.trim()) { @@ -145,17 +146,15 @@ export async function writeFileViaSystemSsh( ): Promise { throwIfAborted(options?.signal) const channel = spawnSystemSshCommand(target, `cat > ${shellEscape(remotePath)}`) - const abort = (): void => { - channel.close() - } - options?.signal?.addEventListener('abort', abort, { once: true }) - const closePromise = waitForChannelClose(channel, `write ${remotePath}`) - channel.stdin.end(contents) - try { - await closePromise - } finally { - options?.signal?.removeEventListener('abort', abort) + const closePromise = awaitWithSystemSshAbort( + options?.signal, + () => channel.close(), + waitForChannelClose(channel, `write ${remotePath}`) + ) + if (!options?.signal?.aborted) { + channel.stdin.end(contents) } + await closePromise } export function buildSshArgs(target: SshTarget): string[] { @@ -245,13 +244,46 @@ function wrapCommandProcess(proc: ChildProcess): SystemSshCommandChannel { } } - proc.stdout!.on('data', (data) => duplex.push(data)) - proc.stdout!.on('end', () => duplex.push(null)) - proc.on('exit', (code, signal) => channel.emit('exit', code, signal)) - proc.on('close', (code, signal) => channel.emit('close', code, signal)) - proc.on('error', (err) => duplex.destroy(err)) - proc.stdin!.on('error', (err) => duplex.destroy(err)) - proc.stdout!.on('error', (err) => duplex.destroy(err)) + const cleanupProcessListeners = (): void => { + proc.stdout!.off('data', onStdoutData) + proc.stdout!.off('end', onStdoutEnd) + proc.off('exit', onExit) + proc.off('close', onClose) + proc.off('error', onProcessError) + proc.stdin!.off('error', onStreamError) + proc.stdout!.off('error', onStreamError) + } + const fail = (err: Error): void => { + cleanupProcessListeners() + duplex.destroy(err) + } + const onStdoutData = (data: Buffer): void => { + duplex.push(data) + } + const onStdoutEnd = (): void => { + duplex.push(null) + } + const onExit = (code: number | null, signal?: NodeJS.Signals | null): void => { + channel.emit('exit', code, signal) + } + const onClose = (code: number | null, signal?: NodeJS.Signals | null): void => { + cleanupProcessListeners() + channel.emit('close', code, signal) + } + const onProcessError = (err: Error): void => { + fail(err) + } + const onStreamError = (err: Error): void => { + fail(err) + } + + proc.stdout!.on('data', onStdoutData) + proc.stdout!.on('end', onStdoutEnd) + proc.on('exit', onExit) + proc.on('close', onClose) + proc.on('error', onProcessError) + proc.stdin!.on('error', onStreamError) + proc.stdout!.on('error', onStreamError) return channel } @@ -259,18 +291,33 @@ function wrapCommandProcess(proc: ChildProcess): SystemSshCommandChannel { function waitForChannelClose(channel: SystemSshCommandChannel, label: string): Promise { return new Promise((resolve, reject) => { let stderr = '' - channel.stderr.on('data', (data: Buffer) => { + const cleanup = (): void => { + channel.stderr.off('data', onStderrData) + channel.off('error', onError) + channel.off('close', onClose) + } + const settle = (fn: typeof resolve | typeof reject, val?: unknown): void => { + cleanup() + fn(val as never) + } + const onStderrData = (data: Buffer): void => { stderr += data.toString('utf-8') - }) - channel.on('error', reject) - channel.on('close', (code: number | null, signal?: NodeJS.Signals | null) => { + } + const onError = (err: Error): void => { + settle(reject, err) + } + const onClose = (code: number | null, signal?: NodeJS.Signals | null): void => { if (code !== 0) { const detail = code === null ? `signal ${signal ?? 'unknown'}` : `exit ${code}` - reject(new Error(`${label} failed (${detail}): ${stderr.trim()}`)) + settle(reject, new Error(`${label} failed (${detail}): ${stderr.trim()}`)) return } - resolve() - }) + settle(resolve) + } + + channel.stderr.on('data', onStderrData) + channel.on('error', onError) + channel.on('close', onClose) }) } @@ -279,17 +326,32 @@ type ProcessResult = { label: string; stderr: string } function waitForProcess(proc: ChildProcess, label: string): Promise { return new Promise((resolve, reject) => { let stderr = '' - proc.stderr?.on('data', (data: Buffer) => { + const cleanup = (): void => { + proc.stderr?.off('data', onStderrData) + proc.off('error', onError) + proc.off('close', onClose) + } + const settle = (fn: typeof resolve | typeof reject, val: ProcessResult | Error): void => { + cleanup() + fn(val as never) + } + const onStderrData = (data: Buffer): void => { stderr += data.toString('utf-8') - }) - proc.on('error', reject) - proc.on('close', (code) => { + } + const onError = (err: Error): void => { + settle(reject, err) + } + const onClose = (code: number | null): void => { if (code !== 0) { - reject(new Error(`${label} failed (exit ${code}): ${stderr.trim()}`)) + settle(reject, new Error(`${label} failed (exit ${code}): ${stderr.trim()}`)) return } - resolve({ label, stderr }) - }) + settle(resolve, { label, stderr }) + } + + proc.stderr?.on('data', onStderrData) + proc.on('error', onError) + proc.on('close', onClose) }) } @@ -304,11 +366,54 @@ function killProcess(proc: ChildProcess): void { } } +async function awaitWithSystemSshAbort( + signal: AbortSignal | undefined, + abortChildren: () => void, + operation: Promise +): Promise { + if (!signal) { + return operation + } + let abortReject: ((error: Error) => void) | null = null + let suppressLateOperationError = false + const abortPromise = new Promise((_resolve, reject) => { + abortReject = reject + }) + const abort = (): void => { + // Why: abort is connection teardown; do not wait for stubborn system ssh/tar + // children to emit close after we've already signaled them. + abortChildren() + suppressLateOperationError = true + abortReject?.(createAbortError()) + } + signal.addEventListener('abort', abort, { once: true }) + if (signal.aborted) { + abort() + } + try { + return await Promise.race([ + operation.catch((error: unknown) => { + if (suppressLateOperationError) { + return new Promise(() => {}) + } + throw error + }), + abortPromise + ]) + } finally { + signal.removeEventListener('abort', abort) + } +} + function throwIfAborted(signal: AbortSignal | undefined): void { if (!signal?.aborted) { return } + throw createAbortError() +} + +function createAbortError(): Error & { name: string } { const error = new Error('System SSH operation was cancelled') as Error & { name: string } error.name = 'AbortError' - throw error + return error } diff --git a/src/main/startup/appimage-cli-redirect.test.ts b/src/main/startup/appimage-cli-redirect.test.ts new file mode 100644 index 00000000000..822719dd8c2 --- /dev/null +++ b/src/main/startup/appimage-cli-redirect.test.ts @@ -0,0 +1,92 @@ +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { getAppImageCliArgs, maybeRedirectAppImageCliLaunch } from './appimage-cli-redirect' + +const commandNames = ['status', 'terminal'] + +describe('AppImage CLI redirect', () => { + it('detects direct AppImage CLI commands', () => { + expect( + getAppImageCliArgs( + ['orca-linux.AppImage', 'status', '--json'], + { APPIMAGE: '/opt/orca' }, + { + platform: 'linux', + isPackaged: true, + commandNames + } + ) + ).toEqual(['status', '--json']) + }) + + it('allows CLI global flags before the command', () => { + expect( + getAppImageCliArgs( + ['orca-linux.AppImage', '--pairing-code', 'abc123', '--json', 'terminal', 'list'], + { + APPIMAGE: '/opt/orca' + }, + { + platform: 'linux', + isPackaged: true, + commandNames + } + ) + ).toEqual(['--pairing-code', 'abc123', '--json', 'terminal', 'list']) + }) + + it('does not redirect normal desktop AppImage launches', () => { + expect( + getAppImageCliArgs( + ['AppRun', '--no-sandbox', 'file:///tmp/example.txt'], + { + APPIMAGE: '/opt/orca' + }, + { + platform: 'linux', + isPackaged: true, + commandNames + } + ) + ).toBeNull() + }) + + it('spawns the unpacked CLI entrypoint with Electron node mode', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-appimage-cli-redirect-')) + const cliEntryPath = join(root, 'app.asar.unpacked', 'out', 'cli', 'index.js') + await mkdir(join(root, 'app.asar.unpacked', 'out', 'cli'), { recursive: true }) + await writeFile(cliEntryPath, '', 'utf8') + const spawn = vi.fn((..._args: unknown[]) => ({ status: 0 })) + + const result = maybeRedirectAppImageCliLaunch({ + argv: ['orca-linux.AppImage', 'status', '--json'], + env: { + APPIMAGE: '/opt/orca/orca-linux.AppImage', + NODE_OPTIONS: '--inspect', + NODE_REPL_EXTERNAL_MODULE: '/tmp/repl.js' + }, + platform: 'linux', + isPackaged: true, + resourcesPath: root, + execPath: '/opt/orca/orca-ide', + commandNames, + spawn: spawn as never + }) + + expect(result).toEqual({ redirected: true, status: 0 }) + expect(spawn).toHaveBeenCalledWith('/opt/orca/orca-ide', [cliEntryPath, 'status', '--json'], { + env: expect.objectContaining({ + APPIMAGE: '/opt/orca/orca-linux.AppImage', + ELECTRON_RUN_AS_NODE: '1', + ORCA_NODE_OPTIONS: '--inspect', + ORCA_NODE_REPL_EXTERNAL_MODULE: '/tmp/repl.js' + }), + stdio: 'inherit' + }) + const spawnOptions = spawn.mock.calls[0]?.[2] as { env: NodeJS.ProcessEnv } | undefined + expect(spawnOptions?.env).not.toHaveProperty('NODE_OPTIONS') + expect(spawnOptions?.env).not.toHaveProperty('NODE_REPL_EXTERNAL_MODULE') + }) +}) diff --git a/src/main/startup/appimage-cli-redirect.ts b/src/main/startup/appimage-cli-redirect.ts new file mode 100644 index 00000000000..83aa80709f3 --- /dev/null +++ b/src/main/startup/appimage-cli-redirect.ts @@ -0,0 +1,180 @@ +import { spawnSync, type SpawnSyncReturns } from 'node:child_process' +import { existsSync } from 'node:fs' +import { join } from 'node:path' + +type RedirectResult = + | { + redirected: false + } + | { + redirected: true + status: number + } + +type RedirectOptions = { + argv?: string[] + env?: NodeJS.ProcessEnv + platform?: NodeJS.Platform + isPackaged?: boolean + resourcesPath?: string + execPath?: string + commandNames?: readonly string[] + spawn?: typeof spawnSync +} + +const HELP_FLAGS = new Set(['--help', '-h', 'help']) +const APPIMAGE_DESKTOP_FLAGS = new Set(['--no-sandbox']) +const CLI_FLAGS_WITH_VALUES = new Set(['--environment', '--pairing-code']) +// Why: the main tsconfig cannot import the CLI project, but AppImage direct +// launches need a conservative allow-list before bypassing the GUI startup. +const APPIMAGE_CLI_COMMAND_NAMES = [ + 'agent', + 'automations', + 'back', + 'capture', + 'check', + 'clear', + 'click', + 'clipboard', + 'computer', + 'console', + 'cookie', + 'dblclick', + 'dialog', + 'download', + 'drag', + 'environment', + 'eval', + 'exec', + 'file', + 'fill', + 'find', + 'focus', + 'forward', + 'full-screenshot', + 'geolocation', + 'get', + 'goto', + 'highlight', + 'hover', + 'inserttext', + 'intercept', + 'is', + 'keypress', + 'mouse', + 'network', + 'open', + 'orchestration', + 'pdf', + 'reload', + 'repo', + 'screenshot', + 'scroll', + 'scrollintoview', + 'select', + 'select-all', + 'serve', + 'set', + 'snapshot', + 'status', + 'storage', + 'tab', + 'terminal', + 'type', + 'uncheck', + 'upload', + 'viewport', + 'wait', + 'worktree' +] + +export function maybeRedirectAppImageCliLaunch(options: RedirectOptions = {}): RedirectResult { + const argv = options.argv ?? process.argv + const env = options.env ?? process.env + const platform = options.platform ?? process.platform + const isPackaged = options.isPackaged ?? false + const resourcesPath = options.resourcesPath ?? process.resourcesPath + const execPath = options.execPath ?? process.execPath + const spawn = options.spawn ?? spawnSync + const cliArgs = getAppImageCliArgs(argv, env, { + platform, + isPackaged, + commandNames: options.commandNames ?? APPIMAGE_CLI_COMMAND_NAMES + }) + + if (!cliArgs) { + return { redirected: false } + } + + const cliEntryPath = join(resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js') + if (!existsSync(cliEntryPath)) { + process.stderr.write(`Unable to locate the Orca CLI entrypoint at ${cliEntryPath}\n`) + return { redirected: true, status: 1 } + } + + const childEnv = buildElectronRunAsNodeEnv(env) + const result = spawn(execPath, [cliEntryPath, ...cliArgs], { + env: childEnv, + stdio: 'inherit' + }) as SpawnSyncReturns + + if (result.error) { + process.stderr.write(`${result.error.message}\n`) + return { redirected: true, status: 1 } + } + + return { redirected: true, status: result.status ?? 1 } +} + +export function getAppImageCliArgs( + argv: string[], + env: NodeJS.ProcessEnv, + options: { + platform: NodeJS.Platform + isPackaged: boolean + commandNames: readonly string[] + } +): string[] | null { + if (options.platform !== 'linux' || !options.isPackaged) { + return null + } + if (!env.APPIMAGE && !env.APPDIR) { + return null + } + + const args = argv.slice(1) + if (args.length === 0 || args.some((arg) => APPIMAGE_DESKTOP_FLAGS.has(arg))) { + return null + } + if (args.some((arg) => HELP_FLAGS.has(arg))) { + return args + } + + const commandNames = new Set(options.commandNames) + const firstPositional = findFirstCommandCandidate(args) + return firstPositional && commandNames.has(firstPositional) ? args : null +} + +function findFirstCommandCandidate(args: string[]): string | null { + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] + if (!arg.startsWith('-')) { + return arg + } + const flagName = arg.includes('=') ? arg.slice(0, arg.indexOf('=')) : arg + if (CLI_FLAGS_WITH_VALUES.has(flagName) && !arg.includes('=')) { + index += 1 + } + } + return null +} + +function buildElectronRunAsNodeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const childEnv = { ...env } + childEnv.ORCA_NODE_OPTIONS = env.NODE_OPTIONS ?? '' + childEnv.ORCA_NODE_REPL_EXTERNAL_MODULE = env.NODE_REPL_EXTERNAL_MODULE ?? '' + childEnv.ELECTRON_RUN_AS_NODE = '1' + delete childEnv.NODE_OPTIONS + delete childEnv.NODE_REPL_EXTERNAL_MODULE + return childEnv +} diff --git a/src/main/startup/hydrate-shell-path.test.ts b/src/main/startup/hydrate-shell-path.test.ts index 32a133a44a4..ebd235d59cc 100644 --- a/src/main/startup/hydrate-shell-path.test.ts +++ b/src/main/startup/hydrate-shell-path.test.ts @@ -1,4 +1,6 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'events' +import type { ChildProcessWithoutNullStreams } from 'child_process' import { _resetHydrateShellPathCache, hydrateShellPath, @@ -6,13 +8,33 @@ import { type HydrationResult } from './hydrate-shell-path' +const { spawnMock } = vi.hoisted(() => ({ + spawnMock: vi.fn() +})) + +vi.mock('child_process', () => ({ + spawn: spawnMock +})) + type HydrationSpawner = (shell: string) => Promise +function createMockShellProcess(): ChildProcessWithoutNullStreams { + const proc = new EventEmitter() as ChildProcessWithoutNullStreams + Object.assign(proc, { + stdout: new EventEmitter(), + stderr: new EventEmitter(), + stdin: new EventEmitter(), + kill: vi.fn() + }) + return proc +} + describe('hydrateShellPath', () => { const originalPath = process.env.PATH beforeEach(() => { _resetHydrateShellPathCache() + spawnMock.mockReset() }) afterEach(() => { @@ -109,6 +131,31 @@ describe('hydrateShellPath', () => { }) expect(result).toEqual({ segments: [], ok: false, failureReason: 'empty_path' }) }) + + it('cleans up shell listeners when hydration times out', async () => { + vi.useFakeTimers() + const proc = createMockShellProcess() + spawnMock.mockReturnValue(proc) + + try { + const resultPromise = hydrateShellPath({ shellOverride: '/bin/zsh', force: true }) + const assertion = expect(resultPromise).resolves.toEqual({ + segments: [], + ok: false, + failureReason: 'timeout' + }) + + await vi.advanceTimersByTimeAsync(5000) + + await assertion + expect(proc.kill).toHaveBeenCalledWith('SIGKILL') + expect(proc.stdout.listenerCount('data')).toBe(0) + expect(proc.listenerCount('error')).toBe(0) + expect(proc.listenerCount('close')).toBe(0) + } finally { + vi.useRealTimers() + } + }) }) describe('mergePathSegments', () => { diff --git a/src/main/startup/hydrate-shell-path.ts b/src/main/startup/hydrate-shell-path.ts index 275d5cb9265..96c22145ba2 100644 --- a/src/main/startup/hydrate-shell-path.ts +++ b/src/main/startup/hydrate-shell-path.ts @@ -86,6 +86,7 @@ function spawnShellAndReadPath(shell: string): Promise { const command = `printf '%s' '${DELIMITER}'; printf '%s' "$PATH"; printf '%s' '${DELIMITER}'` let finished = false let stdout = '' + let timer: ReturnType | null = null const child = spawn(shell, ['-ilc', command], { // Why: inherit current env so the shell sees the same baseline, then let @@ -97,11 +98,25 @@ function spawnShellAndReadPath(shell: string): Promise { detached: false }) - const timer = setTimeout(() => { + const cleanup = (): void => { + if (timer) { + clearTimeout(timer) + timer = null + } + child.stdout.off('data', onStdoutData) + child.off('error', onError) + child.off('close', onClose) + } + const finish = (result: HydrationResult): void => { if (finished) { return } finished = true + cleanup() + resolve(result) + } + + timer = setTimeout(() => { // Why: slow rc files (corporate env setup, nvm eager init) can exceed // our budget. Kill the shell and fall back to process.env rather than // blocking the Agents pane indefinitely. @@ -110,35 +125,29 @@ function spawnShellAndReadPath(shell: string): Promise { } catch { // ignore } - resolve({ segments: [], ok: false, failureReason: 'timeout' }) + finish({ segments: [], ok: false, failureReason: 'timeout' }) }, SPAWN_TIMEOUT_MS) - child.stdout.on('data', (chunk: Buffer) => { + const onStdoutData = (chunk: Buffer): void => { stdout += chunk.toString('utf8') - }) + } - child.on('error', () => { - if (finished) { - return - } - finished = true - clearTimeout(timer) - resolve({ segments: [], ok: false, failureReason: 'spawn_error' }) - }) + const onError = (): void => { + finish({ segments: [], ok: false, failureReason: 'spawn_error' }) + } - child.on('close', () => { - if (finished) { - return - } - finished = true - clearTimeout(timer) + const onClose = (): void => { const segments = parseCapturedPath(stdout) if (segments.length === 0) { - resolve({ segments: [], ok: false, failureReason: 'empty_path' }) + finish({ segments: [], ok: false, failureReason: 'empty_path' }) return } - resolve({ segments, ok: true, failureReason: 'none' }) - }) + finish({ segments, ok: true, failureReason: 'none' }) + } + + child.stdout.on('data', onStdoutData) + child.on('error', onError) + child.on('close', onClose) }) } diff --git a/src/main/startup/single-instance-lock.test.ts b/src/main/startup/single-instance-lock.test.ts index 5b917f6ac4d..f5318b7b54b 100644 --- a/src/main/startup/single-instance-lock.test.ts +++ b/src/main/startup/single-instance-lock.test.ts @@ -2,7 +2,10 @@ import type { App } from 'electron' import { describe, expect, it, vi } from 'vitest' import { acquireSingleInstanceLock, + logSingleInstanceLockBypass, logSingleInstanceLockFailure, + shouldBypassSingleInstanceLock, + SINGLE_INSTANCE_LOCK_BYPASS_MESSAGE, SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE } from './single-instance-lock' @@ -71,12 +74,52 @@ describe('acquireSingleInstanceLock', () => { }) describe('logSingleInstanceLockFailure', () => { - it('emits a production-visible diagnostic for the early quit path', () => { - const logger = { error: vi.fn() } + it('emits a production-visible synchronous diagnostic for the early quit path', () => { + const write = vi.fn() - logSingleInstanceLockFailure(logger) + logSingleInstanceLockFailure(write) - expect(logger.error).toHaveBeenCalledWith(SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE) - expect(logger.error.mock.calls[0]?.[0]).toContain('Electron/macOS single-instance lock failure') + expect(write).toHaveBeenCalledWith(2, `${SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE}\n`) + expect(write.mock.calls[0]?.[1]).toContain('Electron/macOS single-instance lock failure') + }) +}) + +describe('shouldBypassSingleInstanceLock', () => { + it('allows the hidden diagnostic bypass only for packaged macOS app launches', () => { + expect( + shouldBypassSingleInstanceLock({ + env: { ORCA_BYPASS_SINGLE_INSTANCE_LOCK: '1' }, + isDev: false, + isServeMode: false, + platform: 'darwin' + }) + ).toBe(true) + expect( + shouldBypassSingleInstanceLock({ + env: { ORCA_BYPASS_SINGLE_INSTANCE_LOCK: '1' }, + isDev: true, + isServeMode: false, + platform: 'darwin' + }) + ).toBe(false) + expect( + shouldBypassSingleInstanceLock({ + env: { ORCA_BYPASS_SINGLE_INSTANCE_LOCK: '1' }, + isDev: false, + isServeMode: false, + platform: 'linux' + }) + ).toBe(false) + }) +}) + +describe('logSingleInstanceLockBypass', () => { + it('emits a warning when the diagnostic bypass is active', () => { + const write = vi.fn() + + logSingleInstanceLockBypass(write) + + expect(write).toHaveBeenCalledWith(2, `${SINGLE_INSTANCE_LOCK_BYPASS_MESSAGE}\n`) + expect(write.mock.calls[0]?.[1]).toContain('bypassing the packaged macOS single-instance lock') }) }) diff --git a/src/main/startup/single-instance-lock.ts b/src/main/startup/single-instance-lock.ts index 67a32c54ae5..5fd9cc27610 100644 --- a/src/main/startup/single-instance-lock.ts +++ b/src/main/startup/single-instance-lock.ts @@ -1,7 +1,11 @@ import type { App } from 'electron' +import { writeStartupDiagnosticLine, type StartupDiagnosticSink } from './startup-diagnostics' export const SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE = '[single-instance] Another Orca instance is already running for this userData profile; exiting this launch after requesting the existing window. If no Orca process is running, this may be an Electron/macOS single-instance lock failure.' +export const SINGLE_INSTANCE_LOCK_BYPASS_ENV = 'ORCA_BYPASS_SINGLE_INSTANCE_LOCK' +export const SINGLE_INSTANCE_LOCK_BYPASS_MESSAGE = + '[single-instance] ORCA_BYPASS_SINGLE_INSTANCE_LOCK=1 is set; bypassing the packaged macOS single-instance lock for diagnostics. Do not use this with another Orca instance running for the same profile.' /** * Why: Orca writes two canonical discovery files into `/`: @@ -29,6 +33,26 @@ export function acquireSingleInstanceLock(app: App, onSecondInstance: () => void return true } -export function logSingleInstanceLockFailure(logger: Pick = console): void { - logger.error(SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE) +export function shouldBypassSingleInstanceLock(options: { + env?: NodeJS.ProcessEnv + isDev: boolean + isServeMode: boolean + platform?: NodeJS.Platform +}): boolean { + const env = options.env ?? process.env + const platform = options.platform ?? process.platform + return ( + platform === 'darwin' && + !options.isDev && + !options.isServeMode && + env[SINGLE_INSTANCE_LOCK_BYPASS_ENV] === '1' + ) +} + +export function logSingleInstanceLockFailure(write?: StartupDiagnosticSink): void { + writeStartupDiagnosticLine(SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE, write) +} + +export function logSingleInstanceLockBypass(write?: StartupDiagnosticSink): void { + writeStartupDiagnosticLine(SINGLE_INSTANCE_LOCK_BYPASS_MESSAGE, write) } diff --git a/src/main/startup/startup-diagnostics.test.ts b/src/main/startup/startup-diagnostics.test.ts new file mode 100644 index 00000000000..dcf7a4237b3 --- /dev/null +++ b/src/main/startup/startup-diagnostics.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest' +import { + isStartupDiagnosticsEnabled, + logStartupDiagnostic, + STARTUP_DIAGNOSTICS_ENV, + writeStartupDiagnosticLine +} from './startup-diagnostics' + +describe('writeStartupDiagnosticLine', () => { + it('writes directly to stderr fd 2 with a newline', () => { + const write = vi.fn() + + writeStartupDiagnosticLine('[startup] test', write) + + expect(write).toHaveBeenCalledWith(2, '[startup] test\n') + }) +}) + +describe('isStartupDiagnosticsEnabled', () => { + it('requires an explicit opt-in env flag', () => { + expect(isStartupDiagnosticsEnabled({ [STARTUP_DIAGNOSTICS_ENV]: '1' })).toBe(true) + expect(isStartupDiagnosticsEnabled({ [STARTUP_DIAGNOSTICS_ENV]: 'true' })).toBe(false) + expect(isStartupDiagnosticsEnabled({})).toBe(false) + }) +}) + +describe('logStartupDiagnostic', () => { + it('formats event details as a synchronous startup diagnostic line', () => { + const write = vi.fn() + + logStartupDiagnostic('before-lock', { packaged: true, userData: '/tmp/orca' }, write) + + expect(write).toHaveBeenCalledWith( + 2, + '[startup] before-lock packaged=true userData="/tmp/orca"\n' + ) + }) +}) diff --git a/src/main/startup/startup-diagnostics.ts b/src/main/startup/startup-diagnostics.ts new file mode 100644 index 00000000000..3e4114d6b8e --- /dev/null +++ b/src/main/startup/startup-diagnostics.ts @@ -0,0 +1,31 @@ +import { writeSync } from 'node:fs' + +export const STARTUP_DIAGNOSTICS_ENV = 'ORCA_STARTUP_DIAGNOSTICS' + +export type StartupDiagnosticSink = (fd: number, text: string) => unknown + +export function writeStartupDiagnosticLine( + message: string, + write: StartupDiagnosticSink = writeSync +): void { + try { + write(2, message.endsWith('\n') ? message : `${message}\n`) + } catch { + console.error(message) + } +} + +export function isStartupDiagnosticsEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + return env[STARTUP_DIAGNOSTICS_ENV] === '1' +} + +export function logStartupDiagnostic( + event: string, + details: Record = {}, + write?: StartupDiagnosticSink +): void { + const detailText = Object.entries(details) + .map(([key, value]) => `${key}=${JSON.stringify(value)}`) + .join(' ') + writeStartupDiagnosticLine(`[startup] ${event}${detailText ? ` ${detailText}` : ''}`, write) +} diff --git a/src/main/stats/agent-detector.test.ts b/src/main/stats/agent-detector.test.ts index cadb2440f6e..8ed3c785c17 100644 --- a/src/main/stats/agent-detector.test.ts +++ b/src/main/stats/agent-detector.test.ts @@ -19,6 +19,35 @@ describe('AgentDetector', () => { expect(stats.onAgentStop).not.toHaveBeenCalled() }) + it('does not inspect unknown non-agent output for meaningful content', () => { + const stats = { + onAgentStart: vi.fn(), + onAgentStop: vi.fn() + } + const detectMeaningfulContent = vi.fn(() => true) + const detector = new AgentDetector(stats as never, detectMeaningfulContent) + + detector.onData('pty-1', '\x1b[2K\x1b[1G'.repeat(100), 100) + + expect(detectMeaningfulContent).not.toHaveBeenCalled() + expect(stats.onAgentStart).not.toHaveBeenCalled() + expect(stats.onAgentStop).not.toHaveBeenCalled() + }) + + it('inspects content once when an agent title starts a session', () => { + const stats = { + onAgentStart: vi.fn(), + onAgentStop: vi.fn() + } + const detectMeaningfulContent = vi.fn(() => true) + const detector = new AgentDetector(stats as never, detectMeaningfulContent) + + detector.onData('pty-1', `${oscTitle('⠂ Writing patch')}real output`, 100) + + expect(detectMeaningfulContent).toHaveBeenCalledTimes(1) + expect(stats.onAgentStart).toHaveBeenCalledWith('pty-1', 100) + }) + it('stops a session on working to idle transition using the last meaningful output time', () => { const stats = { onAgentStart: vi.fn(), diff --git a/src/main/stats/agent-detector.ts b/src/main/stats/agent-detector.ts index b16f3720b1e..371b796191c 100644 --- a/src/main/stats/agent-detector.ts +++ b/src/main/stats/agent-detector.ts @@ -18,6 +18,8 @@ type PtyRecord = { lastMeaningfulOutputAt: number | null } +type MeaningfulContentDetector = (chunk: string) => boolean + /** * Lightweight normalization to detect whether a PTY data chunk contains * meaningful (non-ANSI, non-OSC) output. Mirrors the regex passes in @@ -72,9 +74,11 @@ function hasMeaningfulContent(chunk: string): boolean { export class AgentDetector { private ptys = new Map() private stats: StatsCollector + private meaningfulContentDetector: MeaningfulContentDetector - constructor(stats: StatsCollector) { + constructor(stats: StatsCollector, meaningfulContentDetector = hasMeaningfulContent) { this.stats = stats + this.meaningfulContentDetector = meaningfulContentDetector } /** @@ -99,8 +103,13 @@ export class AgentDetector { return } - const hasMeaningfulOutput = hasMeaningfulContent(rawData) - if (record.sessionOpen && hasMeaningfulOutput) { + let hasMeaningfulOutput: boolean | null = null + const getHasMeaningfulOutput = (): boolean => { + hasMeaningfulOutput ??= this.meaningfulContentDetector(rawData) + return hasMeaningfulOutput + } + + if (record.sessionOpen && getHasMeaningfulOutput()) { record.lastMeaningfulOutputAt = at } @@ -119,7 +128,7 @@ export class AgentDetector { record.lastStatus = status record.sessionOpen = true record.sessionStartAt = at - record.lastMeaningfulOutputAt = hasMeaningfulOutput ? at : null + record.lastMeaningfulOutputAt = getHasMeaningfulOutput() ? at : null this.stats.onAgentStart(ptyId, at) return } @@ -139,7 +148,7 @@ export class AgentDetector { // title is the start boundary for the next tracked session. record.sessionOpen = true record.sessionStartAt = at - record.lastMeaningfulOutputAt = hasMeaningfulOutput ? at : null + record.lastMeaningfulOutputAt = getHasMeaningfulOutput() ? at : null this.stats.onAgentStart(ptyId, at) } diff --git a/src/main/system-fonts.test.ts b/src/main/system-fonts.test.ts new file mode 100644 index 00000000000..3f63d8930f0 --- /dev/null +++ b/src/main/system-fonts.test.ts @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { execFileMock, killMock } = vi.hoisted(() => ({ + execFileMock: vi.fn(), + killMock: vi.fn() +})) + +vi.mock('child_process', () => ({ + execFile: execFileMock +})) + +function expectedFallbackFont(): string { + if (process.platform === 'darwin') { + return 'SF Mono' + } + if (process.platform === 'win32') { + return 'Cascadia Mono' + } + return 'JetBrains Mono' +} + +describe('listSystemFontFamilies', () => { + afterEach(() => { + vi.useRealTimers() + vi.resetModules() + execFileMock.mockReset() + killMock.mockReset() + }) + + it('falls back when the platform font command never exits', async () => { + vi.useFakeTimers() + execFileMock.mockReturnValue({ kill: killMock }) + + const { listSystemFontFamilies } = await import('./system-fonts') + const fontsPromise = listSystemFontFamilies() + let resolvedFonts: string[] | null = null + fontsPromise.then((fonts) => { + resolvedFonts = fonts + }) + + await vi.advanceTimersByTimeAsync(60_000) + + expect(resolvedFonts).not.toBeNull() + expect(resolvedFonts).toContain(expectedFallbackFont()) + expect(killMock).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/system-fonts.ts b/src/main/system-fonts.ts index 7839f447afe..e1b1b032c80 100644 --- a/src/main/system-fonts.ts +++ b/src/main/system-fonts.ts @@ -2,6 +2,7 @@ import { execFile } from 'child_process' let cachedFonts: string[] | null = null let fontsPromise: Promise | null = null +const SYSTEM_FONT_LIST_TIMEOUT_MS = 15_000 export async function listSystemFontFamilies(): Promise { if (cachedFonts) { @@ -96,13 +97,37 @@ $fonts.Families | ForEach-Object { $_.Name } function execFileText(command: string, args: string[], maxBuffer: number): Promise { return new Promise((resolve, reject) => { - execFile(command, args, { encoding: 'utf8', maxBuffer }, (error, stdout) => { + let settled = false + let timer: ReturnType | undefined + const child = execFile(command, args, { encoding: 'utf8', maxBuffer }, (error, stdout) => { + if (settled) { + return + } + settled = true + if (timer) { + clearTimeout(timer) + } if (error) { reject(error) return } resolve(stdout) }) + if (!settled) { + timer = setTimeout(() => { + if (settled) { + return + } + settled = true + // Why: font discovery is a startup convenience; a stuck OS font tool + // should fall back instead of keeping settings IPC pending forever. + child.kill() + reject(new Error(`Timed out listing system fonts with ${command}`)) + }, SYSTEM_FONT_LIST_TIMEOUT_MS) + if (typeof timer === 'object' && 'unref' in timer) { + timer.unref() + } + } }) } diff --git a/src/main/telemetry/validator-warn-cache.test.ts b/src/main/telemetry/validator-warn-cache.test.ts new file mode 100644 index 00000000000..2bc1cbcd76f --- /dev/null +++ b/src/main/telemetry/validator-warn-cache.test.ts @@ -0,0 +1,37 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + _getValidatorWarnCacheSizeForTests, + _resetValidatorWarnCacheForTests, + validate +} from './validator' + +describe('telemetry validator warn cache', () => { + beforeEach(() => { + _resetValidatorWarnCacheForTests() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('bounds warn rate-limit entries for unique invalid event names', () => { + for (let i = 0; i < 300; i++) { + validate(`not_a_real_event_${i}` as never, {}) + } + + expect(_getValidatorWarnCacheSizeForTests()).toBeLessThanOrEqual(256) + }) + + it('prunes expired warn rate-limit entries', () => { + vi.spyOn(Date, 'now').mockReturnValue(0) + validate('not_a_real_event' as never, {}) + + expect(_getValidatorWarnCacheSizeForTests()).toBe(1) + + vi.spyOn(Date, 'now').mockReturnValue(60_000) + validate('another_fake_event' as never, {}) + + expect(_getValidatorWarnCacheSizeForTests()).toBe(1) + }) +}) diff --git a/src/main/telemetry/validator.ts b/src/main/telemetry/validator.ts index 253af32e9bf..7d7785897d7 100644 --- a/src/main/telemetry/validator.ts +++ b/src/main/telemetry/validator.ts @@ -34,15 +34,36 @@ export type ValidationResult = | { ok: false; reason: string } const WARN_WINDOW_MS = 60_000 +const WARN_CACHE_MAX_ENTRIES = 256 const lastWarnAt = new Map() +function pruneWarnCache(now: number): void { + for (const [key, at] of lastWarnAt) { + if (now - at >= WARN_WINDOW_MS) { + lastWarnAt.delete(key) + } + } + while (lastWarnAt.size > WARN_CACHE_MAX_ENTRIES) { + const oldest = lastWarnAt.keys().next() + if (oldest.done) { + break + } + lastWarnAt.delete(oldest.value) + } +} + function warnRateLimited(key: string, message: string): void { const now = Date.now() - const prev = lastWarnAt.get(key) ?? 0 - if (now - prev < WARN_WINDOW_MS) { + pruneWarnCache(now) + const prev = lastWarnAt.get(key) + if (prev !== undefined && now - prev < WARN_WINDOW_MS) { return } + // Why: renderer-originated event names are untrusted; keep the rate-limit + // table bounded even if a bad caller sends unique invalid names forever. + lastWarnAt.delete(key) lastWarnAt.set(key, now) + pruneWarnCache(now) console.warn(`[telemetry] ${message}`) } @@ -76,6 +97,10 @@ export function _resetValidatorWarnCacheForTests(): void { lastWarnAt.clear() } +export function _getValidatorWarnCacheSizeForTests(): number { + return lastWarnAt.size +} + // Re-exported so `client.ts` can re-validate the merged outgoing payload // without reaching into `src/shared/telemetry-events.ts` directly. Keeps the // validator as the single surface the client depends on. diff --git a/src/main/terminal-history.test.ts b/src/main/terminal-history.test.ts index 894623ef158..a300f02181c 100644 --- a/src/main/terminal-history.test.ts +++ b/src/main/terminal-history.test.ts @@ -2,7 +2,7 @@ injection, fallback patching, WSL translation, cleanup, and GC with a TOCTOU age guard — covering each path in one test file keeps assertions co-located with the shared mock harness rather than splitting across files. */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { existsSyncMock, @@ -57,10 +57,15 @@ import { injectHistoryEnv, updateHistFileForFallback, deleteWorktreeHistoryDir, - runHistoryGc + runHistoryGc, + scheduleHistoryGc } from './terminal-history' describe('terminal-history', () => { + afterEach(() => { + vi.useRealTimers() + }) + beforeEach(() => { vi.clearAllMocks() getPathMock.mockReturnValue('/fake/userData') @@ -274,6 +279,19 @@ describe('terminal-history', () => { }) describe('runHistoryGc', () => { + it('coalesces duplicate scheduled startup GC calls', async () => { + vi.useFakeTimers() + existsSyncMock.mockReturnValue(false) + const getLiveWorktreeIds = vi.fn().mockResolvedValue(new Set()) + + scheduleHistoryGc(getLiveWorktreeIds) + scheduleHistoryGc(getLiveWorktreeIds) + + await vi.advanceTimersByTimeAsync(10_000) + + expect(getLiveWorktreeIds).toHaveBeenCalledTimes(1) + }) + it('prunes orphaned directories', () => { existsSyncMock.mockImplementation((p: string) => { // WSL root doesn't exist, so GC skips it diff --git a/src/main/terminal-history.ts b/src/main/terminal-history.ts index 63c7344524d..32df3cee92c 100644 --- a/src/main/terminal-history.ts +++ b/src/main/terminal-history.ts @@ -19,6 +19,9 @@ const HISTORY_DIR_NAME_WSL = 'terminal-history-wsl' type ShellKind = 'zsh' | 'bash' | 'fish' | 'pwsh' | 'powershell' | 'cmd' | 'unknown' +let scheduledHistoryGcTimer: ReturnType | null = null +let historyGcRunning = false + // ─── Shell Detection ─────────────────────────────────────────────── /** Resolve the shell kind from a shell binary path. @@ -62,7 +65,11 @@ function historyFilename(shell: ShellKind): string | null { case 'bash': return 'bash_history' // Phase 2: fish and PowerShell use different mechanisms - default: + case 'fish': + case 'pwsh': + case 'powershell': + case 'cmd': + case 'unknown': return null } } @@ -349,9 +356,16 @@ export function runHistoryGc(liveWorktreeIds: Set): void { /** Schedule GC after a delay so it runs after workspace hydration completes. * `getLiveWorktreeIds` should use already-known IDs, not probe repo paths. */ export function scheduleHistoryGc(getLiveWorktreeIds: () => Promise>): void { + // Why: main-window services can reattach during reload/reactivation; one + // pending/running disk GC is enough and avoids duplicate startup I/O. + if (scheduledHistoryGcTimer !== null || historyGcRunning) { + return + } // Why 10s: avoids competing with startup-critical I/O while still running // early enough to clean up before the user notices disk usage (§7.6). - setTimeout(async () => { + scheduledHistoryGcTimer = setTimeout(async () => { + scheduledHistoryGcTimer = null + historyGcRunning = true try { const liveIds = await getLiveWorktreeIds() runHistoryGc(liveIds) @@ -359,6 +373,8 @@ export function scheduleHistoryGc(getLiveWorktreeIds: () => Promise> console.warn( `[pty:history:gc] Failed to enumerate live worktrees for GC: ${err instanceof Error ? err.message : String(err)}` ) + } finally { + historyGcRunning = false } }, 10_000) } diff --git a/src/main/text-generation/commit-message-text-generation.test.ts b/src/main/text-generation/commit-message-text-generation.test.ts index 14704c7847b..0f0be2b591d 100644 --- a/src/main/text-generation/commit-message-text-generation.test.ts +++ b/src/main/text-generation/commit-message-text-generation.test.ts @@ -3,6 +3,7 @@ cross-path invariants these tests protect. */ import { spawn } from 'child_process' import type * as ChildProcess from 'child_process' +import { EventEmitter } from 'events' import { beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../shared/constants' import { sourceControlAiSettingsFromLegacy } from '../../shared/source-control-ai' @@ -29,6 +30,24 @@ vi.mock('child_process', async (importOriginal) => { const spawnMock = vi.mocked(spawn) +type MockDiscoveryChild = EventEmitter & { + pid: number + kill: ReturnType + stdout: EventEmitter + stderr: EventEmitter + stdin: { end: ReturnType } +} + +function createMockDiscoveryChild(): MockDiscoveryChild { + const child = new EventEmitter() as MockDiscoveryChild + child.pid = 123 + child.kill = vi.fn() + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + child.stdin = { end: vi.fn() } + return child +} + function syncSourceControlAiFromLegacy(settings: GlobalSettings): void { settings.sourceControlAi = sourceControlAiSettingsFromLegacy(settings.commitMessageAi) } @@ -392,6 +411,50 @@ describe('discoverCommitMessageModelsLocal', () => { models: [{ id: 'github-copilot/gpt-5.4-mini' }, { id: 'openai-codex/gpt-5.5' }] }) }) + + it('settles and detaches model discovery when timeout kill is ignored', async () => { + vi.useFakeTimers() + const child = createMockDiscoveryChild() + spawnMock.mockReturnValue(child as never) + + try { + const pending = discoverCommitMessageModelsLocal('cursor', undefined) + const assertion = expect(pending).resolves.toMatchObject({ + success: false, + error: 'Cursor model discovery timed out after 60s.' + }) + + await vi.advanceTimersByTimeAsync(60_000) + + await assertion + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + expect(child.stdout.listenerCount('data')).toBe(0) + expect(child.stderr.listenerCount('data')).toBe(0) + expect(child.listenerCount('error')).toBe(0) + expect(child.listenerCount('close')).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('settles and detaches model discovery when output exceeds the limit', async () => { + const child = createMockDiscoveryChild() + spawnMock.mockReturnValue(child as never) + + const pending = discoverCommitMessageModelsLocal('cursor', undefined) + + child.stdout.emit('data', Buffer.alloc(4 * 1024 * 1024 + 1)) + + await expect(pending).resolves.toMatchObject({ + success: false, + error: 'Cursor returned too much model data.' + }) + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + expect(child.stdout.listenerCount('data')).toBe(0) + expect(child.stderr.listenerCount('data')).toBe(0) + expect(child.listenerCount('error')).toBe(0) + expect(child.listenerCount('close')).toBe(0) + }) }) describe('generateCommitMessageFromContext', () => { @@ -1064,6 +1127,70 @@ describe('generateCommitMessageFromContext', () => { }) }) + it('settles local commit-message cancellation even when the killed child does not close', async () => { + vi.useFakeTimers() + try { + const listeners = new Map void>() + const removeListener = (key: string, callback: (value: unknown) => void): void => { + if (listeners.get(key) === callback) { + listeners.delete(key) + } + } + const child = { + pid: 123, + kill: vi.fn(), + stdout: { + on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)), + off: vi.fn((event, callback) => removeListener(`stdout:${event}`, callback)) + }, + stderr: { + on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)), + off: vi.fn((event, callback) => removeListener(`stderr:${event}`, callback)) + }, + stdin: { end: vi.fn() }, + on: vi.fn((event, callback) => listeners.set(event, callback)), + off: vi.fn((event, callback) => removeListener(event, callback)) + } + spawnMock.mockReturnValue(child as never) + + const pending = generateCommitMessageFromContext( + { + branch: 'main', + stagedSummary: 'M\tREADME.md', + stagedPatch: '+hello' + }, + { + agentId: 'custom', + model: '', + customAgentCommand: 'agent' + }, + { + kind: 'local', + cwd: '/repo' + } + ) + const outcomePromise = pending.then((result) => + !result.success && result.canceled ? 'canceled' : 'other' + ) + + cancelGenerateCommitMessageLocal('/repo') + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + const outcome = await Promise.race([outcomePromise, Promise.resolve('pending')]) + + expect(outcome).toBe('canceled') + expect(listeners.has('stdout:data')).toBe(false) + expect(listeners.has('stderr:data')).toBe(false) + expect(listeners.has('error')).toBe(false) + expect(listeners.has('close')).toBe(false) + } finally { + vi.useRealTimers() + } + }) + it('routes Windows batch-script agent commands through cmd.exe', async () => { const originalComSpec = process.env.ComSpec process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe' @@ -1207,7 +1334,7 @@ describe('generateBranchNameFromContext', () => { }) }) - it('includes branch-name custom instructions in the generated prompt', async () => { + it('includes the branch-name custom prompt in the generated prompt', async () => { let prompt = '' await generateBranchNameFromContext( { firstPrompt: 'Fix login flow' }, @@ -1233,7 +1360,7 @@ describe('generateBranchNameFromContext', () => { } ) - expect(prompt).toContain('Additional user instructions:') + expect(prompt).toContain('Additional user prompt:') expect(prompt).toContain('Prefer auth terminology.') }) }) diff --git a/src/main/text-generation/commit-message-text-generation.ts b/src/main/text-generation/commit-message-text-generation.ts index 4bb5192ae80..18eef88b76f 100644 --- a/src/main/text-generation/commit-message-text-generation.ts +++ b/src/main/text-generation/commit-message-text-generation.ts @@ -293,14 +293,21 @@ export async function discoverCommitMessageModelsLocal( let stderr = '' let outputLimitExceeded = false let settled = false + let timer: ReturnType | null = null + let detachChildListeners = (): void => {} const finish = (result: DiscoverCommitMessageModelsResult): void => { if (settled) { return } settled = true + if (timer) { + clearTimeout(timer) + timer = null + } + detachChildListeners() resolve(result) } - const timer = setTimeout(() => { + timer = setTimeout(() => { killProcessTree(child) finish({ success: false, @@ -312,15 +319,15 @@ export async function discoverCommitMessageModelsLocal( if (stdout.length + stderr.length + chunk.byteLength > MAX_AGENT_OUTPUT_BYTES) { outputLimitExceeded = true killProcessTree(child) + finish({ success: false, error: `${spec.label} returned too much model data.` }) return } append(chunk.toString('utf-8')) } - child.stdout?.on('data', (chunk: Buffer) => onData(chunk, (text) => (stdout += text))) - child.stderr?.on('data', (chunk: Buffer) => onData(chunk, (text) => (stderr += text))) - child.on('error', (error) => { - clearTimeout(timer) + const onStdoutData = (chunk: Buffer): void => onData(chunk, (text) => (stdout += text)) + const onStderrData = (chunk: Buffer): void => onData(chunk, (text) => (stderr += text)) + const onError = (error: Error): void => { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { finish({ success: false, @@ -332,9 +339,8 @@ export async function discoverCommitMessageModelsLocal( success: false, error: `${spec.label} model discovery failed to start. Check the agent CLI configuration and try again.` }) - }) - child.on('close', (code) => { - clearTimeout(timer) + } + const onClose = (code: number | null): void => { if (outputLimitExceeded) { finish({ success: false, error: `${spec.label} returned too much model data.` }) return @@ -344,7 +350,18 @@ export async function discoverCommitMessageModelsLocal( return } finish(finalizeModelDiscoveryOutput(spec, stdout, stderr, code)) - }) + } + + child.stdout?.on('data', onStdoutData) + child.stderr?.on('data', onStderrData) + child.on('error', onError) + child.on('close', onClose) + detachChildListeners = () => { + child.stdout?.off?.('data', onStdoutData) + child.stderr?.off?.('data', onStderrData) + child.off?.('error', onError) + child.off?.('close', onClose) + } }) } @@ -491,11 +508,18 @@ async function runLocalPlan( let canceledByUser = false const laneKey = localLaneKey(operation, cwd) let cancelToken: (() => void) | null = null + let timer: ReturnType | null = null + let detachChildListeners = (): void => {} const finalize = (result: InternalTextGenerationResult): void => { if (settled) { return } settled = true + if (timer) { + clearTimeout(timer) + timer = null + } + detachChildListeners() if (cancelToken && cancelTokensByLane.get(laneKey) === cancelToken) { cancelTokensByLane.delete(laneKey) } @@ -505,10 +529,13 @@ async function runLocalPlan( cancelToken = () => { canceledByUser = true killProcessTree(child) + // Why: cancellation is a user-visible UI command; do not wait for a + // wedged agent CLI to emit `close` before the request leaves loading. + finalize({ success: false, error: 'Generation canceled.', canceled: true }) } cancelTokensByLane.set(laneKey, cancelToken) - const timer = setTimeout(() => { + timer = setTimeout(() => { killProcessTree(child) finalize({ success: false, @@ -516,7 +543,7 @@ async function runLocalPlan( }) }, GENERATION_TIMEOUT_MS) - child.stdout?.on('data', (chunk: Buffer) => { + const onStdoutData = (chunk: Buffer): void => { stdoutBytes += chunk.byteLength if (stdoutBytes > MAX_AGENT_OUTPUT_BYTES) { outputLimitExceeded = true @@ -524,8 +551,8 @@ async function runLocalPlan( return } stdout += chunk.toString('utf-8') - }) - child.stderr?.on('data', (chunk: Buffer) => { + } + const onStderrData = (chunk: Buffer): void => { stderrBytes += chunk.byteLength if (stderrBytes > MAX_AGENT_OUTPUT_BYTES) { outputLimitExceeded = true @@ -533,9 +560,8 @@ async function runLocalPlan( return } stderr += chunk.toString('utf-8') - }) - child.on('error', (error) => { - clearTimeout(timer) + } + const onError = (error: Error): void => { const code = (error as NodeJS.ErrnoException).code if (code === 'ENOENT') { finalize({ @@ -549,9 +575,8 @@ async function runLocalPlan( success: false, error: `${label} failed to start. Check the agent command in Settings and try again.` }) - }) - child.on('close', (code) => { - clearTimeout(timer) + } + const onClose = (code: number | null): void => { if (canceledByUser) { finalize({ success: false, error: 'Generation canceled.', canceled: true }) return @@ -561,7 +586,17 @@ async function runLocalPlan( return } finalizeFromAgentOutput({ code, stdout, stderr, label, emptyResultName, finalize }) - }) + } + child.stdout?.on('data', onStdoutData) + child.stderr?.on('data', onStderrData) + child.on('error', onError) + child.on('close', onClose) + detachChildListeners = () => { + child.stdout?.off?.('data', onStdoutData) + child.stderr?.off?.('data', onStderrData) + child.off?.('error', onError) + child.off?.('close', onClose) + } child.stdin?.end(stdinPayload ?? undefined) }) diff --git a/src/main/updater-nudge.test.ts b/src/main/updater-nudge.test.ts index e121f686bcf..4eb1c98c406 100644 --- a/src/main/updater-nudge.test.ts +++ b/src/main/updater-nudge.test.ts @@ -130,9 +130,15 @@ describe('updater-nudge', () => { it('bounded range match', () => { expect(versionMatchesRange('1.1.5', { minVersion: '1.1.0', maxVersion: '1.1.19' })).toBe(true) expect(versionMatchesRange('1.1.0', { minVersion: '1.1.0', maxVersion: '1.1.19' })).toBe(true) - expect(versionMatchesRange('1.1.19', { minVersion: '1.1.0', maxVersion: '1.1.19' })).toBe(true) - expect(versionMatchesRange('1.0.9', { minVersion: '1.1.0', maxVersion: '1.1.19' })).toBe(false) - expect(versionMatchesRange('1.2.0', { minVersion: '1.1.0', maxVersion: '1.1.19' })).toBe(false) + expect(versionMatchesRange('1.1.19', { minVersion: '1.1.0', maxVersion: '1.1.19' })).toBe( + true + ) + expect(versionMatchesRange('1.0.9', { minVersion: '1.1.0', maxVersion: '1.1.19' })).toBe( + false + ) + expect(versionMatchesRange('1.2.0', { minVersion: '1.1.0', maxVersion: '1.1.19' })).toBe( + false + ) }) it('upper-only range match', () => { diff --git a/src/main/updater-nudge.ts b/src/main/updater-nudge.ts index 983fe9764b1..74cf4236341 100644 --- a/src/main/updater-nudge.ts +++ b/src/main/updater-nudge.ts @@ -33,10 +33,18 @@ export async function fetchNudge(): Promise { return null } - if (minVersion !== undefined && typeof minVersion !== 'string') {return null} - if (maxVersion !== undefined && typeof maxVersion !== 'string') {return null} - if (minVersion !== undefined && !isValidVersion(minVersion)) {return null} - if (maxVersion !== undefined && !isValidVersion(maxVersion)) {return null} + if (minVersion !== undefined && typeof minVersion !== 'string') { + return null + } + if (maxVersion !== undefined && typeof maxVersion !== 'string') { + return null + } + if (minVersion !== undefined && !isValidVersion(minVersion)) { + return null + } + if (maxVersion !== undefined && !isValidVersion(maxVersion)) { + return null + } if ( minVersion !== undefined && maxVersion !== undefined && diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index a50ef2c3abe..0d7aae865b5 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -363,6 +363,56 @@ describe('updater', () => { }) }) + it('runs a fresh prerelease check when Shift-click promotes an in-flight stable check', async () => { + let resolveStableTags: (value: { tags: string[]; state: 'no-newer' }) => void = () => {} + fetchNewerReleaseTagsMock + .mockImplementationOnce( + () => + new Promise<{ tags: string[]; state: 'no-newer' }>((resolve) => { + resolveStableTags = resolve + }) + ) + .mockResolvedValueOnce({ tags: ['v1.4.36-rc.5'], state: 'ready' }) + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + + const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') + + appMock.getVersion.mockReturnValue('1.4.35') + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => null }) + checkForUpdatesFromMenu({ includePrerelease: true }) + + expect(sendMock).toHaveBeenCalledWith('updater:status', { + state: 'checking', + userInitiated: true + }) + expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled() + + resolveStableTags({ tags: [], state: 'no-newer' }) + await vi.waitFor(() => { + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + }) + + autoUpdaterMock.emit('update-not-available') + + await vi.waitFor(() => { + expect(fetchNewerReleaseTagsMock).toHaveBeenCalledWith('1.4.35', 2, { + includePrerelease: true + }) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2) + }) + expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({ + provider: 'generic', + url: 'https://github.com/stablyai/orca/releases/download/v1.4.36-rc.5' + }) + expect( + sendMock.mock.calls + .filter(([channel]) => channel === 'updater:status') + .map(([, status]) => status) + ).not.toContainEqual({ state: 'not-available', userInitiated: true }) + }) + it('keeps promoted background promise failures user-initiated after a paired error event', async () => { let resolveTags: (value: { tags: string[]; state: 'no-newer' }) => void = () => {} fetchNewerReleaseTagsMock.mockImplementation( diff --git a/src/main/updater.ts b/src/main/updater.ts index 0359fc3898b..7669e422dec 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -58,6 +58,7 @@ let backgroundCheckLaunchPending = false // Why: a manually promoted background check can emit an error event before the // paired promise catch runs; keep the promotion attached to that launch. let backgroundCheckPromotedToUserInitiated = false +let pendingPrereleaseUserInitiatedCheckAfterInFlight = false let activeUpdateNudgeId: string | null = null let awaitingNudgeCheckOutcome = false let nudgeCheckInFlight = false @@ -144,6 +145,12 @@ function decorateStatusWithActiveNudge(status: UpdateStatus): UpdateStatus { } function sendStatus(status: UpdateStatus): void { + const shouldLaunchPendingPrereleaseCheck = + pendingPrereleaseUserInitiatedCheckAfterInFlight && + (status.state === 'idle' || + status.state === 'not-available' || + status.state === 'available' || + status.state === 'error') const shouldPreserveNudgeForPublishingWindow = publishingWindowLastGoodCheck !== null && (status.state === 'idle' || @@ -203,6 +210,10 @@ function sendStatus(status: UpdateStatus): void { ) { downloadInFlight = false } + if (shouldLaunchPendingPrereleaseCheck) { + launchPendingPrereleaseUserInitiatedCheckAfterInFlight() + return + } if (statusesEqual(currentStatus, decoratedStatus)) { return } @@ -210,6 +221,19 @@ function sendStatus(status: UpdateStatus): void { mainWindowRef?.webContents.send('updater:status', decoratedStatus) } +function launchPendingPrereleaseUserInitiatedCheckAfterInFlight(): void { + pendingPrereleaseUserInitiatedCheckAfterInFlight = false + 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. + if (currentStatus.state === 'checking') { + currentStatus = { state: 'idle' } + } + checkForUpdatesFromMenu({ includePrerelease: true }) + }, 0) +} + function clearBackgroundCheckLaunchPending(): void { backgroundCheckLaunchPending = false } @@ -696,6 +720,11 @@ export function checkForUpdatesFromMenu(options?: { includePrerelease?: boolean sendStatus({ state: 'checking', userInitiated: true }) if (checkAlreadyInFlight) { backgroundCheckPromotedToUserInitiated = true + 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 + } return } diff --git a/src/main/usage-worktree-canonicalizer.test.ts b/src/main/usage-worktree-canonicalizer.test.ts new file mode 100644 index 00000000000..8ab8de9b580 --- /dev/null +++ b/src/main/usage-worktree-canonicalizer.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { + USAGE_WORKTREE_CANONICALIZATION_CONCURRENCY, + canonicalizeUsageWorktreePaths +} from './usage-worktree-canonicalizer' + +describe('canonicalizeUsageWorktreePaths', () => { + it('caps concurrent canonicalization work and preserves longest-path-first ordering', async () => { + let active = 0 + let maxActive = 0 + const seenPaths: string[] = [] + + const worktrees = Array.from( + { length: USAGE_WORKTREE_CANONICALIZATION_CONCURRENCY + 3 }, + (_value, index) => ({ + path: `/repo/${index}`, + worktreeId: `worktree-${index}` + }) + ) + + const result = await canonicalizeUsageWorktreePaths(worktrees, async (path) => { + active++ + maxActive = Math.max(maxActive, active) + seenPaths.push(path) + await new Promise((resolve) => setTimeout(resolve, 0)) + active-- + return path.endsWith('/10') ? `${path}/nested/longer` : path + }) + + expect(maxActive).toBeLessThanOrEqual(USAGE_WORKTREE_CANONICALIZATION_CONCURRENCY) + expect(seenPaths).toEqual(worktrees.map((worktree) => worktree.path)) + expect(result[0]).toMatchObject({ + path: '/repo/10', + canonicalPath: '/repo/10/nested/longer' + }) + }) +}) diff --git a/src/main/usage-worktree-canonicalizer.ts b/src/main/usage-worktree-canonicalizer.ts new file mode 100644 index 00000000000..b10e9491b98 --- /dev/null +++ b/src/main/usage-worktree-canonicalizer.ts @@ -0,0 +1,36 @@ +export const USAGE_WORKTREE_CANONICALIZATION_CONCURRENCY = 8 + +export type CanonicalizedUsageWorktree = T & { + canonicalPath: string +} + +export async function canonicalizeUsageWorktreePaths( + worktrees: readonly T[], + canonicalizePath: (path: string) => Promise, + concurrency = USAGE_WORKTREE_CANONICALIZATION_CONCURRENCY +): Promise[]> { + if (worktrees.length === 0) { + return [] + } + + // Why: usage scans can see many stale remembered worktrees. Bound realpath + // fanout so opening the usage pane does not stampede the filesystem. + const workerCount = Math.min(worktrees.length, Math.max(1, Math.floor(concurrency))) + const canonicalized = Array.from>({ length: worktrees.length }) + let nextIndex = 0 + + async function worker(): Promise { + while (nextIndex < worktrees.length) { + const index = nextIndex + nextIndex++ + const worktree = worktrees[index] + canonicalized[index] = { + ...worktree, + canonicalPath: await canonicalizePath(worktree.path) + } + } + } + + await Promise.all(Array.from({ length: workerCount }, worker)) + return canonicalized.sort((left, right) => right.canonicalPath.length - left.canonicalPath.length) +} diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index 3e5f1a9dc9c..0dcd5b28b77 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { onMock, removeAllListenersMock, + removeListenerMock, setPermissionRequestHandlerMock, setPermissionCheckHandlerMock, setDisplayMediaRequestHandlerMock, @@ -22,6 +23,7 @@ const { } = vi.hoisted(() => ({ onMock: vi.fn(), removeAllListenersMock: vi.fn(), + removeListenerMock: vi.fn(), setPermissionRequestHandlerMock: vi.fn(), setPermissionCheckHandlerMock: vi.fn(), setDisplayMediaRequestHandlerMock: vi.fn(), @@ -52,6 +54,7 @@ vi.mock('electron', () => ({ ipcMain: { on: onMock, removeAllListeners: removeAllListenersMock, + removeListener: removeListenerMock, removeHandler: removeHandlerMock, handle: handleMock }, @@ -95,6 +98,7 @@ import { attachMainWindowServices } from './attach-main-window-services' type MockFn = ReturnType type MainWindowStub = { + id?: number isDestroyed?: MockFn on: MockFn webContents: { @@ -119,6 +123,7 @@ type RuntimeStub = { function createMainWindow(extraWebContents: { on?: MockFn; send?: MockFn } = {}): MainWindowStub { return { + id: 1, isDestroyed: vi.fn(() => false), on: vi.fn(), webContents: { @@ -148,10 +153,17 @@ function createRuntime(): RuntimeStub { } } +function getClosedHandlers(mainWindowOnMock: MockFn): (() => void)[] { + return mainWindowOnMock.mock.calls + .filter(([event]) => event === 'closed') + .map(([, handler]) => handler as () => void) +} + describe('attachMainWindowServices', () => { beforeEach(() => { onMock.mockReset() removeAllListenersMock.mockReset() + removeListenerMock.mockReset() handleMock.mockReset() removeHandlerMock.mockReset() setPermissionRequestHandlerMock.mockReset() @@ -273,6 +285,48 @@ describe('attachMainWindowServices', () => { expect(mainWindow.webContents.reload).not.toHaveBeenCalled() }) + it('removes the app reload IPC handler when the owning window closes', () => { + const mainWindowOnMock = vi.fn() + const mainWindow = createMainWindow() + mainWindow.on = mainWindowOnMock + + attachMainWindowServices(mainWindow as never, createStore(), createRuntime() as never) + + removeHandlerMock.mockClear() + const closedHandlers = getClosedHandlers(mainWindowOnMock) + expect(closedHandlers.length).toBeGreaterThan(0) + for (const handler of closedHandlers) { + handler() + } + + expect(removeHandlerMock).toHaveBeenCalledWith('app:reload') + }) + + it('keeps a newer app reload handler when an older window closes late', () => { + const oldWindowOnMock = vi.fn() + const oldWindow = createMainWindow() + oldWindow.on = oldWindowOnMock + attachMainWindowServices(oldWindow as never, createStore(), createRuntime() as never) + const oldClosedHandlers = getClosedHandlers(oldWindowOnMock) + + const newWindowOnMock = vi.fn() + const newWindow = createMainWindow() + newWindow.on = newWindowOnMock + attachMainWindowServices(newWindow as never, createStore(), createRuntime() as never) + + removeHandlerMock.mockClear() + for (const handler of oldClosedHandlers) { + handler() + } + + expect(removeHandlerMock).not.toHaveBeenCalledWith('app:reload') + + for (const handler of getClosedHandlers(newWindowOnMock)) { + handler() + } + expect(removeHandlerMock).toHaveBeenCalledWith('app:reload') + }) + it('only allows the explicit permission allowlist', async () => { attachMainWindowServices(createMainWindow() as never, createStore(), createRuntime() as never) @@ -419,14 +473,75 @@ describe('attachMainWindowServices', () => { attachMainWindowServices(mainWindow as never, createStore(), createRuntime() as never) - const closedHandler = mainWindowOnMock.mock.calls - .filter(([event]) => event === 'closed') - .at(-1)?.[1] as (() => void) | undefined + const closedHandler = getClosedHandlers(mainWindowOnMock).at(-1) expect(closedHandler).toBeTypeOf('function') closedHandler?.() expect(browserManagerUnregisterAllMock).toHaveBeenCalledTimes(1) }) + it('removes the native file-drop relay when the main window closes', () => { + const mainWindowOnMock = vi.fn() + const mainWindow = createMainWindow({ send: vi.fn() }) + mainWindow.on = mainWindowOnMock + + attachMainWindowServices(mainWindow as never, createStore(), createRuntime() as never) + + const channel = 'terminal:file-dropped-from-preload' + const relayHandler = onMock.mock.calls.find(([event]) => event === channel)?.[1] + expect(relayHandler).toBeTypeOf('function') + expect(removeAllListenersMock).toHaveBeenCalledWith(channel) + + const closedHandlers = getClosedHandlers(mainWindowOnMock) + for (const handler of closedHandlers) { + handler() + } + + expect(removeListenerMock).toHaveBeenCalledWith(channel, relayHandler) + }) + + it('clears the runtime notifier when the owning window closes', () => { + const mainWindowOnMock = vi.fn() + const mainWindow = createMainWindow() + mainWindow.on = mainWindowOnMock + const runtime = createRuntime() + + attachMainWindowServices(mainWindow as never, createStore(), runtime as never) + + runtime.setNotifier.mockClear() + for (const handler of getClosedHandlers(mainWindowOnMock)) { + handler() + } + + expect(runtime.markGraphUnavailable).toHaveBeenCalledWith(1) + expect(runtime.setNotifier).toHaveBeenCalledWith(null) + }) + + it('keeps a newer runtime notifier when an older window closes late', () => { + const runtime = createRuntime() + const oldWindowOnMock = vi.fn() + const oldWindow = createMainWindow() + oldWindow.on = oldWindowOnMock + attachMainWindowServices(oldWindow as never, createStore(), runtime as never) + const oldClosedHandlers = getClosedHandlers(oldWindowOnMock) + + const newWindowOnMock = vi.fn() + const newWindow = createMainWindow() + newWindow.on = newWindowOnMock + attachMainWindowServices(newWindow as never, createStore(), runtime as never) + + runtime.setNotifier.mockClear() + for (const handler of oldClosedHandlers) { + handler() + } + + expect(runtime.setNotifier).not.toHaveBeenCalledWith(null) + + for (const handler of getClosedHandlers(newWindowOnMock)) { + handler() + } + expect(runtime.setNotifier).toHaveBeenCalledWith(null) + }) + it('forwards runtime notifier events to the renderer', () => { const sendMock = vi.fn() const webContentsOnMock = vi.fn() diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 8bc36c3343b..7cef390b9f4 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -38,6 +38,11 @@ import { requestMobileMarkdownFromRenderer } from './mobile-markdown-request-rel import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' +let appReloadHandlerTokenCounter = 0 +let activeAppReloadHandlerToken: number | null = null +let runtimeNotifierTokenCounter = 0 +let activeRuntimeNotifierToken: number | null = null + export function attachMainWindowServices( mainWindow: BrowserWindow, store: Store, @@ -236,6 +241,8 @@ function registerAppReloadHandler( ): void { // Why: the process-global IPC handler can outlive the BrowserWindow, so keep // the registered WebContents and guard both lifetimes before using it. + const handlerToken = ++appReloadHandlerTokenCounter + activeAppReloadHandlerToken = handlerToken const mainWebContents = mainWindow.webContents ipcMain.removeHandler('app:reload') ipcMain.handle('app:reload', (event) => { @@ -249,12 +256,23 @@ function registerAppReloadHandler( onBeforeRendererReload?.({ webContentsId: mainWebContents.id, ignoreCache: false }) mainWebContents.reload() }) + mainWindow.on('closed', () => { + if (activeAppReloadHandlerToken !== handlerToken) { + return + } + // Why: macOS can keep the process alive with no window, and this global + // handler otherwise keeps the closed BrowserWindow reachable until reopen. + ipcMain.removeHandler('app:reload') + activeAppReloadHandlerToken = null + }) } function registerRuntimeWindowLifecycle( mainWindow: BrowserWindow, runtime: OrcaRuntimeService ): void { + const notifierToken = ++runtimeNotifierTokenCounter + activeRuntimeNotifierToken = notifierToken runtime.attachWindow(mainWindow.id) const send = (channel: string, ...args: unknown[]): void => { if (!mainWindow.isDestroyed()) { @@ -270,13 +288,15 @@ function registerRuntimeWindowLifecycle( repoId, worktreeId, setup?: CreateWorktreeResult['setup'], - startup?: WorktreeStartupLaunch + startup?: WorktreeStartupLaunch, + defaultTabs?: CreateWorktreeResult['defaultTabs'] ) => { send('ui:activateWorktree', { repoId, worktreeId, ...(setup ? { setup } : {}), - ...(startup ? { startup } : {}) + ...(startup ? { startup } : {}), + ...(defaultTabs ? { defaultTabs } : {}) }) }, createTerminal: (worktreeId, opts) => @@ -369,12 +389,20 @@ function registerRuntimeWindowLifecycle( }) mainWindow.on('closed', () => { runtime.markGraphUnavailable(mainWindow.id) + if (activeRuntimeNotifierToken === notifierToken) { + // Why: the notifier closes over the BrowserWindow for mobile/CLI UI + // relays; clear it during the no-window gap so the runtime does not + // retain destroyed window graphs. + runtime.setNotifier(null) + activeRuntimeNotifierToken = null + } }) } function registerFileDropRelay(mainWindow: BrowserWindow): void { - ipcMain.removeAllListeners('terminal:file-dropped-from-preload') - ipcMain.on('terminal:file-dropped-from-preload', (_event, args: NativeFileDropPayload) => { + const channel = 'terminal:file-dropped-from-preload' + ipcMain.removeAllListeners(channel) + const relayFileDrop = (_event: Electron.IpcMainEvent, args: NativeFileDropPayload): void => { if (mainWindow.isDestroyed()) { return } @@ -382,6 +410,12 @@ function registerFileDropRelay(mainWindow: BrowserWindow): void { // Why: relay exactly one IPC event per drop gesture so the renderer // receives the full batch of paths without timer-based reconstruction. mainWindow.webContents.send('terminal:file-drop', args) + } + ipcMain.on(channel, relayFileDrop) + mainWindow.on('closed', () => { + // Why: macOS can keep the app process alive after the window closes; drop + // the relay closure so a destroyed BrowserWindow is not retained. + ipcMain.removeListener(channel, relayFileDrop) }) } diff --git a/src/main/window/clipboard-ipc-handlers.test.ts b/src/main/window/clipboard-ipc-handlers.test.ts index 2eb99778f89..dc73d2332a2 100644 --- a/src/main/window/clipboard-ipc-handlers.test.ts +++ b/src/main/window/clipboard-ipc-handlers.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' const { removeHandlerMock, @@ -131,6 +132,10 @@ describe('registerClipboardHandlers', () => { it('saves clipboard images to a local temp file when no connection is provided', async () => { const png = Buffer.from([0, 1, 2, 3]) + const expectedPath = join( + '/tmp', + 'orca-paste-1760000000000-00000000-0000-4000-8000-000000000000.png' + ) clipboardReadImageMock.mockReturnValue({ isEmpty: () => false, toPNG: () => png @@ -140,12 +145,9 @@ describe('registerClipboardHandlers', () => { const handlers = getRegisteredHandlers() await expect(handlers.get('clipboard:saveImageAsTempFile')?.({}, undefined)).resolves.toBe( - '/tmp/orca-paste-1760000000000-00000000-0000-4000-8000-000000000000.png' - ) - expect(fsWriteFileMock).toHaveBeenCalledWith( - '/tmp/orca-paste-1760000000000-00000000-0000-4000-8000-000000000000.png', - png + expectedPath ) + expect(fsWriteFileMock).toHaveBeenCalledWith(expectedPath, png) expect(getSshFilesystemProviderMock).not.toHaveBeenCalled() }) diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index bc484094d2a..4535c45039d 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -415,6 +415,51 @@ describe('createMainWindow', () => { expect(webContents.send).not.toHaveBeenCalled() }) + it('forwards the platform tab-number jump shortcut to the renderer', () => { + const windowHandlers: Record void> = {} + const webContents = { + on: vi.fn((event, handler) => { + windowHandlers[event] = handler + }), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + setWindowOpenHandler: vi.fn(), + send: vi.fn(), + isDevToolsOpened: vi.fn(), + openDevTools: vi.fn(), + closeDevTools: vi.fn() + } + const browserWindowInstance = { + webContents, + on: vi.fn(), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => true), + isFullScreen: vi.fn(() => false), + getSize: vi.fn(() => [1200, 800]), + setSize: vi.fn(), + maximize: vi.fn(), + show: vi.fn(), + loadFile: vi.fn(), + loadURL: vi.fn() + } + browserWindowMock.mockImplementation(function () { + return browserWindowInstance + }) + + createMainWindow(null) + + const input = + process.platform === 'darwin' + ? { type: 'keyDown', code: 'Digit5', key: '5', meta: false, control: true, alt: false } + : { type: 'keyDown', code: 'Digit5', key: '5', meta: false, control: false, alt: true } + const preventDefault = vi.fn() + windowHandlers['before-input-event']({ preventDefault } as never, input as never) + + expect(preventDefault).toHaveBeenCalledTimes(1) + expect(webContents.send).toHaveBeenCalledWith('ui:jumpToTabIndex', 4) + }) + it('forwards Ctrl+Tab keydown and Ctrl release to the renderer switcher', () => { const windowHandlers: Record void> = {} const webContents = { @@ -835,6 +880,74 @@ describe('createMainWindow', () => { expect(webContents.send).toHaveBeenNthCalledWith(2, 'ui:toggleWorktreePalette') }) + it('forwards the configured workspace delete shortcut while terminal input is focused', () => { + const windowHandlers: Record void> = {} + const webContents = { + on: vi.fn((event, handler) => { + windowHandlers[event] = handler + }), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + setWindowOpenHandler: vi.fn(), + send: vi.fn(), + isDevToolsOpened: vi.fn(), + openDevTools: vi.fn(), + closeDevTools: vi.fn() + } + const browserWindowInstance = { + webContents, + on: vi.fn(), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => true), + isFullScreen: vi.fn(() => false), + getSize: vi.fn(() => [1200, 800]), + setSize: vi.fn(), + maximize: vi.fn(), + show: vi.fn(), + loadFile: vi.fn(), + loadURL: vi.fn() + } + browserWindowMock.mockImplementation(function () { + return browserWindowInstance + }) + + createMainWindow( + { + getUI: () => ({}), + getSettings: () => ({ terminalShortcutPolicy: 'terminal-first' }) + } as never, + { + getKeybindings: () => ({ 'workspace.delete': ['Mod+Shift+Backspace'] }) + } + ) + + const setFocusedListener = vi + .mocked(ipcMain.on) + .mock.calls.find(([channel]) => channel === 'ui:setTerminalInputFocused')?.[1] + expect(setFocusedListener).toBeTypeOf('function') + setFocusedListener?.({ sender: webContents } as never, true) + + const isDarwin = process.platform === 'darwin' + const preventDefault = vi.fn() + windowHandlers['before-input-event']( + { preventDefault } as never, + { + type: 'keyDown', + code: 'Backspace', + key: 'Backspace', + meta: isDarwin, + control: !isDarwin, + alt: false, + shift: true + } as never + ) + + expect(preventDefault).toHaveBeenCalledTimes(1) + expect(webContents.send).toHaveBeenCalledTimes(1) + expect(webContents.send).toHaveBeenCalledWith('ui:deleteCurrentWorkspace') + }) + it('toggles devtools on F12 in development', () => { isMock.dev = true diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 125e4568eb0..22a27c1954d 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -839,6 +839,11 @@ export function createMainWindow( return } + if (action.type === 'deleteCurrentWorkspace') { + mainWindow.webContents.send('ui:deleteCurrentWorkspace') + return + } + if (action.type === 'openTasks') { mainWindow.webContents.send('ui:openTasks') return @@ -854,6 +859,11 @@ export function createMainWindow( return } + if (action.type === 'jumpToTabIndex') { + mainWindow.webContents.send('ui:jumpToTabIndex', action.index) + return + } + if (action.type === 'worktreeHistoryNavigate') { // Why: routed through main so the chord reaches the renderer even when // a terminal (xterm.js) or a browser guest has focus — both surfaces diff --git a/src/main/workspace-space-analysis-du-timeout.test.ts b/src/main/workspace-space-analysis-du-timeout.test.ts new file mode 100644 index 00000000000..f9554650fe6 --- /dev/null +++ b/src/main/workspace-space-analysis-du-timeout.test.ts @@ -0,0 +1,117 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeProcess from 'node:process' +import type { Repo } from '../shared/types' +import type { Store } from './persistence' + +const { execFileMock, listRepoWorktreesMock } = vi.hoisted(() => ({ + execFileMock: vi.fn(), + listRepoWorktreesMock: vi.fn() +})) + +vi.mock('node:child_process', () => ({ + execFile: execFileMock +})) + +vi.mock('node:process', async () => { + const actual = await vi.importActual('node:process') + return { ...actual, platform: 'darwin' } +}) + +vi.mock('./repo-worktrees', () => ({ + createFolderWorktree: (repo: Repo) => ({ + path: repo.path, + head: '', + branch: '', + isBare: false, + isMainWorktree: true + }), + listRepoWorktrees: listRepoWorktreesMock +})) + +vi.mock('./providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: vi.fn() +})) + +vi.mock('./providers/ssh-git-dispatch', () => ({ + getSshGitProvider: vi.fn() +})) + +import { analyzeWorkspaceSpace } from './workspace-space-analysis' + +function createStore(repos: Repo[]): Store { + return { + getRepos: () => repos, + getWorktreeMeta: () => undefined + } as unknown as Store +} + +describe('analyzeWorkspaceSpace local du timeout', () => { + let tempDir: string | null = null + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'orca-space-du-timeout-')) + execFileMock.mockReset() + listRepoWorktreesMock.mockReset() + }) + + afterEach(async () => { + vi.useRealTimers() + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }) + tempDir = null + } + }) + + it('falls back when du never reports completion', async () => { + const repoPath = join(tempDir!, 'repo') + await mkdir(repoPath, { recursive: true }) + await writeFile(join(repoPath, 'app.ts'), 'console.log("ok")\n') + + const repo: Repo = { + id: 'repo-1', + path: repoPath, + displayName: 'orca', + badgeColor: '#000', + addedAt: 0 + } + listRepoWorktreesMock.mockResolvedValue([ + { + path: repoPath, + head: 'a', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + const killMock = vi.fn() + execFileMock.mockImplementation(() => ({ kill: killMock })) + + vi.useFakeTimers() + let settled = false + const scanPromise = analyzeWorkspaceSpace(createStore([repo])).then((scan) => { + settled = true + return scan + }) + + await vi.waitFor(() => + expect(execFileMock).toHaveBeenCalledWith( + 'du', + ['-k', '-d', '1', repoPath], + expect.any(Object), + expect.any(Function) + ) + ) + await vi.advanceTimersByTimeAsync(120_000) + + await vi.waitFor(() => expect(settled).toBe(true)) + await expect(scanPromise).resolves.toMatchObject({ + scannedWorktreeCount: 1, + unavailableWorktreeCount: 0, + worktrees: [expect.objectContaining({ status: 'ok', path: repoPath })] + }) + expect(killMock).toHaveBeenCalled() + }) +}) diff --git a/src/main/workspace-space-analysis.ts b/src/main/workspace-space-analysis.ts index 6cf19721535..f035a426ae3 100644 --- a/src/main/workspace-space-analysis.ts +++ b/src/main/workspace-space-analysis.ts @@ -4,7 +4,6 @@ import { lstat, readdir } from 'node:fs/promises' import { execFile } from 'node:child_process' import { posix, win32 } from 'node:path' import { platform } from 'node:process' -import { promisify } from 'node:util' import type { Dirent } from 'node:fs' import type { Store } from './persistence' import { isFolderRepo } from '../shared/repo-kind' @@ -31,7 +30,6 @@ const LOCAL_FS_CONCURRENCY = 48 const REMOTE_FS_CONCURRENCY = 10 const DU_TIMEOUT_MS = 120_000 const DU_MAX_BUFFER_BYTES = 16 * 1024 * 1024 -const execFileAsync = promisify(execFile) type AsyncLimiter = (task: () => Promise) => Promise @@ -191,11 +189,65 @@ async function readLocalDuDepthOne( rootPath: string, signal?: AbortSignal ): Promise> { - const { stdout } = await execFileAsync('du', ['-k', '-d', '1', rootPath], { - encoding: 'utf8', - maxBuffer: DU_MAX_BUFFER_BYTES, - signal, - timeout: DU_TIMEOUT_MS + const stdout = await new Promise((resolve, reject) => { + let settled = false + let child: ReturnType | undefined + let onAbort: (() => void) | null = null + let timer: ReturnType | null = null + const settle = (callback: () => void): void => { + if (settled) { + return + } + settled = true + if (timer) { + clearTimeout(timer) + } + if (onAbort) { + signal?.removeEventListener('abort', onAbort) + } + callback() + } + timer = setTimeout(() => { + settle(() => { + child?.kill() + reject(new Error(`du timed out after ${DU_TIMEOUT_MS}ms`)) + }) + }, DU_TIMEOUT_MS) + onAbort = () => { + settle(() => { + child?.kill() + reject(new Error('Workspace space scan cancelled')) + }) + } + signal?.addEventListener('abort', onAbort, { once: true }) + if (signal?.aborted) { + onAbort() + return + } + + // Why: execFile's timeout only signals `du`; a wedged child that never + // calls back must not block the Space scan or its portable fallback. + try { + child = execFile( + 'du', + ['-k', '-d', '1', rootPath], + { + encoding: 'utf8', + maxBuffer: DU_MAX_BUFFER_BYTES, + signal, + timeout: DU_TIMEOUT_MS + }, + (error, stdout) => { + if (error) { + settle(() => reject(error)) + return + } + settle(() => resolve(String(stdout))) + } + ) + } catch (error) { + settle(() => reject(error)) + } }) return parseDuDepthOneOutput(stdout) } diff --git a/src/main/worktree-orphan-gitdir-proof.ts b/src/main/worktree-orphan-gitdir-proof.ts index bdde6d9ea69..b1952fd36fb 100644 --- a/src/main/worktree-orphan-gitdir-proof.ts +++ b/src/main/worktree-orphan-gitdir-proof.ts @@ -192,9 +192,13 @@ async function adminGitdirPointsAtCandidate( function containsPath(parentPath: string, childPath: string, pathOps: PathOps): boolean { const relativePath = pathOps.relative(parentPath, childPath) + // Why: `..name` is a valid child name; only `..` and `../...` escape. return ( relativePath === '' || - (!!relativePath && !relativePath.startsWith('..') && !pathOps.isAbsolute(relativePath)) + (!!relativePath && + relativePath !== '..' && + !relativePath.startsWith(`..${pathOps.sep}`) && + !pathOps.isAbsolute(relativePath)) ) } diff --git a/src/main/worktree-removal-safety.test.ts b/src/main/worktree-removal-safety.test.ts index cbecf00f052..5aeee3287c2 100644 --- a/src/main/worktree-removal-safety.test.ts +++ b/src/main/worktree-removal-safety.test.ts @@ -80,6 +80,18 @@ describe('getRegisteredDeletableWorktree', () => { ]) ).toMatchObject({ path: '/workspaces/parent' }) }) + + it('rejects deleting a worktree that contains another registered worktree in a dotdot-prefixed child', () => { + expect(() => + getRegisteredDeletableWorktree('/repo', '/workspaces/parent', [ + makeGitWorktree('/repo', true), + makeGitWorktree('/workspaces/parent'), + makeGitWorktree('/workspaces/parent/..child') + ]) + ).toThrow( + 'Refusing to delete worktree because it contains another registered worktree: /workspaces/parent/..child' + ) + }) }) describe('canSafelyRemoveOrphanedWorktreeDirectory', () => { @@ -97,6 +109,20 @@ describe('canSafelyRemoveOrphanedWorktreeDirectory', () => { ).resolves.toBe(true) }) + it('accepts repo worktree admin entries with dotdot-prefixed directory names', async () => { + await expect( + canSafelyRemoveOrphanedWorktreeDirectory( + '/workspaces/orphan', + '/repo', + makeStatPath(['/workspaces/orphan/.git'], ['/repo/.git']), + makeReadPath([ + ['/workspaces/orphan/.git', 'gitdir: /repo/.git/worktrees/..orphan\n'], + ['/repo/.git/worktrees/..orphan/gitdir', '/workspaces/orphan/.git\n'] + ]) + ) + ).resolves.toBe(true) + }) + it('accepts remote filesystem provider readFile results for linked worktree .git files', async () => { await expect( canSafelyRemoveOrphanedWorktreeDirectory( diff --git a/src/main/worktree-removal-safety.ts b/src/main/worktree-removal-safety.ts index 5fcd962fc8a..32ae5243502 100644 --- a/src/main/worktree-removal-safety.ts +++ b/src/main/worktree-removal-safety.ts @@ -46,9 +46,13 @@ function getPathOps(...paths: string[]): PathOps { function containsPath(parentPath: string, childPath: string, pathOps: PathOps): boolean { const relativePath = pathOps.relative(parentPath, childPath) + // Why: `..name` is a valid child name; only `..` and `../...` escape. return ( relativePath === '' || - (!!relativePath && !relativePath.startsWith('..') && !pathOps.isAbsolute(relativePath)) + (!!relativePath && + relativePath !== '..' && + !relativePath.startsWith(`..${pathOps.sep}`) && + !pathOps.isAbsolute(relativePath)) ) } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 89a1fa75910..d5802406b83 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -26,6 +26,7 @@ import type { CustomPet, DetectedWorktreeListResult, DirEntry, + ForceDeleteWorktreeBranchResult, FsChangedPayload, GhosttyImportPreview, GlobalSettings, @@ -39,6 +40,7 @@ import type { GitHubAssignableUser, GitHubPRFile, GitHubPRFileContents, + GitHubPrStartPoint, GitHubPRReviewCommentInput, GitHubCommentResult, GitHubOwnerRepo, @@ -46,21 +48,32 @@ import type { GitHubWorkItemDetails, GitHubViewer, GitLabAssignableUser, + GitLabAuthDiagnostic, GitLabCommentResult, + GitLabDiscussionResolveResult, GitLabIssueInfo, GitLabIssueUpdate, + GitLabJobTraceResult, + GitLabMRInlineCommentInput, + GitLabMRReviewersUpdateResult, + GitLabMRUpdate, GitLabProjectRef, + GitLabRetryJobResult, GitLabTodo, GitLabViewer, GitLabWorkItem, GitLabWorkItemDetails, + GetGitLabRateLimitResult, ListMergeRequestsResult, MRInfo, MRListState, ListWorkItemsResult, IssueInfo, LinearViewer, + LinearCollectionResult, LinearConnectionStatus, + LinearCustomViewModel, + LinearCustomViewSummary, LinearWorkspaceSelection, LinearIssue, LinearIssueUpdate, @@ -68,6 +81,7 @@ import type { LinearWorkflowState, LinearLabel, LinearMember, + LinearProjectDetail, LinearProjectSummary, LinearTeam, MarkdownDocument, @@ -107,8 +121,11 @@ import type { WorktreeLineage, WorktreeMeta, WorktreeRemoteBranchConflictEvent, + RemoveWorktreeResult, + WorktreeDefaultTabsLaunch, WorktreeSetupLaunch, WorktreeStartupLaunch, + WorkspaceSessionPatch, WorkspaceSessionState } from '../shared/types' import type { SetupScriptImportCandidate } from '../shared/setup-script-imports' @@ -189,9 +206,12 @@ import type { import type { ShellOpenLocalPathResult } from '../shared/shell-open-types' import type { SkillDiscoveryResult } from '../shared/skills' import type { + CrashReportBreadcrumbData, CrashReportRecord, CrashReportSubmitArgs, - CrashReportSubmitResult + CrashReportSubmitResult, + ReactErrorBoundaryReportArgs, + ReactErrorBoundaryReportResult } from '../shared/crash-reporting' export type { ShellOpenLocalPathResult } from '../shared/shell-open-types' @@ -292,6 +312,7 @@ import type { ExternalAutomationRunsPage, ExternalAutomationUpdateInput, AutomationRun, + AutomationPrecheckResult, AutomationUpdateInput } from '../shared/automations-types' import type { @@ -642,6 +663,7 @@ export type PreloadApi = { | 'repoIcon' | 'hookSettings' | 'worktreeBaseRef' + | 'worktreeBasePath' | 'kind' | 'issueSourcePreference' | 'externalWorktreeVisibility' @@ -732,7 +754,7 @@ export type PreloadApi = { prNumber: number headRefName?: string isCrossRepository?: boolean - }) => Promise<{ baseBranch: string; pushTarget?: GitPushTarget } | { error: string }> + }) => Promise /** GitLab parallel of resolvePrBase. For same-project MRs returns * `/`; for fork MRs fetches * refs/merge-requests//head and returns the SHA. */ @@ -742,7 +764,16 @@ export type PreloadApi = { sourceBranch?: string isCrossRepository?: boolean }) => Promise<{ baseBranch: string; pushTarget?: GitPushTarget } | { error: string }> - remove: (args: { worktreeId: string; force?: boolean; skipArchive?: boolean }) => Promise + remove: (args: { + worktreeId: string + force?: boolean + skipArchive?: boolean + }) => Promise + forceDeletePreservedBranch: (args: { + worktreeId: string + branchName: string + expectedHead: string + }) => Promise updateMeta: (args: { worktreeId: string; updates: Partial }) => Promise listLineage: () => Promise> updateLineage: (args: { @@ -819,6 +850,7 @@ export type PreloadApi = { signal: (id: string, signal: string) => void kill: (id: string, opts?: { keepHistory?: boolean }) => Promise ackColdRestore: (id: string) => void + pauseOutput: (id: string, paused: boolean) => void hasChildProcesses: (id: string) => Promise getForegroundProcess: (id: string) => Promise getCwd: (id: string) => Promise @@ -861,6 +893,10 @@ export type PreloadApi = { getLatestPending: () => Promise getLatestReport: () => Promise dismiss: (args: { reportId: string }) => Promise + recordRendererError: ( + args: ReactErrorBoundaryReportArgs + ) => Promise + recordBreadcrumb: (args: { name: string; data?: CrashReportBreadcrumbData }) => void submit: (args: CrashReportSubmitArgs) => Promise copyLatestDiagnostics: (args?: { reportId?: string @@ -937,6 +973,8 @@ export type PreloadApi = { repoId?: string title: string body: string + labels?: string[] + assignees?: string[] }) => Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }> countWorkItems: (args: { repoPath: string; repoId?: string; query?: string }) => Promise listWorkItems: (args: { @@ -1048,6 +1086,7 @@ export type PreloadApi = { * scope the cross-window invalidation broadcast correctly and avoid * evicting an unrelated PR/issue that happens to share the number. */ type?: 'issue' | 'pr' + prRepo?: GitHubOwnerRepo | null }) => Promise addPRReviewCommentReply: (args: { repoPath: string @@ -1058,6 +1097,7 @@ export type PreloadApi = { threadId?: string path?: string line?: number + prRepo?: GitHubOwnerRepo | null }) => Promise addPRReviewComment: ( args: GitHubPRReviewCommentInput & { repoId?: string } @@ -1141,6 +1181,11 @@ export type PreloadApi = { // host, paginated envelope from `glab api -i`). gl: { viewer: () => Promise + diagnoseAuth: () => Promise + rateLimit: (args?: { + force?: boolean + host?: string | null + }) => Promise projectSlug: (args: { repoPath: string }) => Promise mrForBranch: (args: { repoPath: string @@ -1207,11 +1252,44 @@ export type PreloadApi = { iid: number method?: 'merge' | 'squash' | 'rebase' }) => Promise<{ ok: true } | { ok: false; error: string }> + updateMR: (args: { + repoPath: string + iid: number + updates: GitLabMRUpdate + }) => Promise<{ ok: true } | { ok: false; error: string }> + updateMRReviewers: (args: { + repoPath: string + iid: number + reviewerIds: number[] + projectRef?: GitLabProjectRef | null + }) => Promise addMRComment: (args: { repoPath: string iid: number body: string }) => Promise + addMRInlineComment: (args: { + repoPath: string + iid: number + input: GitLabMRInlineCommentInput + projectRef?: GitLabProjectRef | null + }) => Promise + resolveMRDiscussion: (args: { + repoPath: string + iid: number + discussionId: string + resolved: boolean + }) => Promise + jobTrace: (args: { + repoPath: string + jobId: number + projectRef?: GitLabProjectRef | null + }) => Promise + retryJob: (args: { + repoPath: string + jobId: number + projectRef?: GitLabProjectRef | null + }) => Promise workItemByPath: (args: { repoPath: string host: string @@ -1274,7 +1352,33 @@ export type PreloadApi = { query?: string limit?: number workspaceId?: LinearWorkspaceSelection - }) => Promise + }) => Promise> + getProject: (args: { id: string; workspaceId: string }) => Promise + listProjectIssues: (args: { + projectId: string + limit?: number + workspaceId: string + }) => Promise> + listCustomViews: (args: { + model: LinearCustomViewModel + limit?: number + workspaceId?: LinearWorkspaceSelection + }) => Promise> + getCustomView: (args: { + viewId: string + model: LinearCustomViewModel + workspaceId: string + }) => Promise + listCustomViewIssues: (args: { + viewId: string + limit?: number + workspaceId: string + }) => Promise> + listCustomViewProjects: (args: { + viewId: string + limit?: number + workspaceId: string + }) => Promise> teamStates: (args: { teamId: string; workspaceId?: string }) => Promise teamLabels: (args: { teamId: string; workspaceId?: string }) => Promise teamMembers: (args: { teamId: string; workspaceId?: string }) => Promise @@ -1381,6 +1485,7 @@ export type PreloadApi = { } agentHooks: { claudeStatus: () => Promise + openClaudeStatus: () => Promise codexStatus: () => Promise geminiStatus: () => Promise antigravityStatus: () => Promise @@ -1396,6 +1501,7 @@ export type PreloadApi = { markTrusted: (args: { preset: 'cursor' | 'copilot' | 'codex' workspacePath: string + connectionId?: string }) => Promise } preflight: PreflightApi @@ -1434,7 +1540,7 @@ export type PreloadApi = { openInFileManager: (path: string) => Promise openInExternalEditor: (path: string, command?: string) => Promise openUrl: (url: string) => Promise - openFilePath: (path: string) => Promise + openFilePath: (path: string) => Promise openFileUri: (uri: string) => Promise pathExists: (path: string) => Promise pickAttachment: () => Promise @@ -1455,9 +1561,12 @@ export type PreloadApi = { } browser: BrowserApi hooks: { - check: (args: { - repoId: string - }) => Promise<{ hasHooks: boolean; hooks: OrcaHooks | null; mayNeedUpdate: boolean }> + check: (args: { repoId: string }) => Promise<{ + status?: 'ok' | 'error' + hasHooks: boolean + hooks: OrcaHooks | null + mayNeedUpdate: boolean + }> inspectSetupScriptImports: (args: { repoId: string }) => Promise createIssueCommandRunner: (args: { repoId: string @@ -1465,6 +1574,7 @@ export type PreloadApi = { command: string }) => Promise readIssueCommand: (args: { repoId: string }) => Promise<{ + status?: 'ok' | 'error' localContent: string | null sharedContent: string | null effectiveContent: string | null @@ -1488,12 +1598,13 @@ export type PreloadApi = { session: { get: () => Promise set: (args: WorkspaceSessionState) => Promise + patch: (args: WorkspaceSessionPatch) => Promise setSync: (args: WorkspaceSessionState) => void } remoteWorkspace: { get: (args: { targetId: string }) => Promise setForConnectedTargets: (args: { - session: WorkspaceSessionState + session?: WorkspaceSessionState hydratedTargetIds?: string[] }) => Promise<{ targetId: string; result: RemoteWorkspacePatchResult }[]> listEnabledConnectedTargets: () => Promise @@ -1819,10 +1930,13 @@ export type PreloadApi = { ) => () => void onOpenQuickOpen: (callback: () => void) => () => void onOpenNewWorkspace: (callback: () => void) => () => void + onDeleteCurrentWorkspace: (callback: () => void) => () => void onOpenTasks: (callback: () => void) => () => void onJumpToWorktreeIndex: (callback: (index: number) => void) => () => void + onJumpToTabIndex: (callback: (index: number) => void) => () => void onWorktreeHistoryNavigate: (callback: (direction: 'back' | 'forward') => void) => () => void onNewBrowserTab: (callback: () => void) => () => void + onNewMarkdownTab: (callback: () => void) => () => void onRequestTabCreate: ( callback: (data: { requestId: string @@ -1861,6 +1975,7 @@ export type PreloadApi = { worktreeId: string setup?: WorktreeSetupLaunch startup?: WorktreeStartupLaunch + defaultTabs?: WorktreeDefaultTabsLaunch }) => void ) => () => void onCreateTerminal: ( @@ -2124,6 +2239,10 @@ export type PreloadApi = { update: (args: { id: string; updates: AutomationUpdateInput }) => Promise delete: (args: { id: string }) => Promise runNow: (args: { id: string }) => Promise + runPrecheck: (args: { + automationId: string + runId: string + }) => Promise markDispatchResult: (result: AutomationDispatchResult) => Promise snapshotWorkspaceName: (args: { workspaceId: string; displayName: string }) => Promise rendererReady: () => Promise @@ -2136,6 +2255,9 @@ export type PreloadApi = { pwsh: { isAvailable: () => Promise } + gitBash: { + isAvailable: () => Promise + } agentStatus: { /** Listen for agent status updates forwarded from native hook receivers. */ onSet: (callback: (data: AgentStatusIpcPayload) => void) => () => void diff --git a/src/preload/gitlab.ts b/src/preload/gitlab.ts index 6c4011ca159..809545816e5 100644 --- a/src/preload/gitlab.ts +++ b/src/preload/gitlab.ts @@ -6,6 +6,9 @@ import { ipcRenderer } from 'electron' export const glApi = { viewer: (): Promise => ipcRenderer.invoke('gitlab:viewer'), + diagnoseAuth: (): Promise => ipcRenderer.invoke('gitlab:diagnoseAuth'), + rateLimit: (args?: { force?: boolean; host?: string | null }): Promise => + ipcRenderer.invoke('gitlab:rateLimit', args), projectSlug: (args: { repoPath: string }): Promise => ipcRenderer.invoke('gitlab:projectSlug', args), @@ -95,9 +98,43 @@ export const glApi = { }): Promise<{ ok: true } | { ok: false; error: string }> => ipcRenderer.invoke('gitlab:mergeMR', args), + updateMR: (args: { + repoPath: string + iid: number + updates: unknown + }): Promise<{ ok: true } | { ok: false; error: string }> => + ipcRenderer.invoke('gitlab:updateMR', args), + + updateMRReviewers: (args: { + repoPath: string + iid: number + reviewerIds: number[] + projectRef?: unknown + }): Promise => ipcRenderer.invoke('gitlab:updateMRReviewers', args), + addMRComment: (args: { repoPath: string; iid: number; body: string }): Promise => ipcRenderer.invoke('gitlab:addMRComment', args), + addMRInlineComment: (args: { + repoPath: string + iid: number + input: unknown + projectRef?: unknown + }): Promise => ipcRenderer.invoke('gitlab:addMRInlineComment', args), + + resolveMRDiscussion: (args: { + repoPath: string + iid: number + discussionId: string + resolved: boolean + }): Promise => ipcRenderer.invoke('gitlab:resolveMRDiscussion', args), + + jobTrace: (args: { repoPath: string; jobId: number; projectRef?: unknown }): Promise => + ipcRenderer.invoke('gitlab:jobTrace', args), + + retryJob: (args: { repoPath: string; jobId: number; projectRef?: unknown }): Promise => + ipcRenderer.invoke('gitlab:retryJob', args), + workItemByPath: (args: { repoPath: string host: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 580efb1a12f..101d91f918e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -12,7 +12,6 @@ import type { BaseRefSearchResult, BaseRefDefaultResult, BrowserViewportOverride, - CreateWorktreeArgs, CustomPet, FsChangedPayload, GetRateLimitResult, @@ -36,7 +35,9 @@ import type { FloatingTerminalCwdRequest, MarkdownDocument, SearchResult, + UpdateStatus, WorktreeBaseStatusEvent, + WorktreeDefaultTabsLaunch, WorktreeRemoteBranchConflictEvent } from '../shared/types' import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history' @@ -51,22 +52,14 @@ import type { } from '../shared/runtime-types' import type { RuntimeRpcResponse } from '../shared/runtime-rpc-envelope' import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments' +import type { RemoteWorkspaceChangedEvent } from '../shared/remote-workspace-types' import type { RuntimeMobileMarkdownRequest, RuntimeMobileMarkdownResponse } from '../shared/mobile-markdown-document' import type { RateLimitRuntimeTarget, RateLimitState } from '../shared/rate-limit-types' -import type { - WorkspaceSpaceAnalyzeResult, - WorkspaceSpaceScanProgress -} from '../shared/workspace-space-types' -import type { - WorkspacePortAdvertisedUrlChangedEvent, - WorkspacePortKillRequest, - WorkspacePortKillResult, - WorkspacePortScanRequest, - WorkspacePortScanResult -} from '../shared/workspace-ports' +import type { WorkspaceSpaceScanProgress } from '../shared/workspace-space-types' +import type { WorkspacePortAdvertisedUrlChangedEvent } from '../shared/workspace-ports' import type { GhAuthDiagnostic } from '../shared/github-auth-types' import type { AddIssueCommentBySlugArgs, @@ -133,6 +126,7 @@ import type { ExternalAutomationRunsPage, ExternalAutomationUpdateInput, AutomationRun, + AutomationPrecheckResult, AutomationUpdateInput } from '../shared/automations-types' import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybindings' @@ -158,7 +152,14 @@ import { import { subscribeRuntimeEnvironmentFromPreload } from './runtime-environment-subscriptions' import type { RuntimeEnvironmentSubscriptionHandle } from './runtime-environment-subscriptions' import type { HostedReviewForBranchArgs } from '../shared/hosted-review' -import type { CrashReportSubmitArgs, CrashReportSubmitResult } from '../shared/crash-reporting' +import type { + CrashReportBreadcrumbData, + CrashReportSubmitArgs, + CrashReportSubmitResult, + ReactErrorBoundaryReportArgs, + ReactErrorBoundaryReportResult +} from '../shared/crash-reporting' +import type { PreloadApi } from './api-types' type NativeFileDropCallback = (data: NativeFileDropPayload) => void @@ -437,41 +438,32 @@ const api = { isAvailable: (): Promise => ipcRenderer.invoke('pwsh:isAvailable') }, + gitBash: { + isAvailable: (): Promise => ipcRenderer.invoke('gitBash:isAvailable') + }, + repos: { - list: (): Promise => ipcRenderer.invoke('repos:list'), + list: () => ipcRenderer.invoke('repos:list'), - add: (args: { path: string; kind?: 'git' | 'folder' }): Promise => - ipcRenderer.invoke('repos:add', args), + add: (args) => ipcRenderer.invoke('repos:add', args), - addRemote: (args: { - connectionId: string - remotePath: string - displayName?: string - kind?: 'git' | 'folder' - }): Promise => ipcRenderer.invoke('repos:addRemote', args), + addRemote: (args) => ipcRenderer.invoke('repos:addRemote', args), - create: (args: { - parentPath: string - name: string - kind: 'git' | 'folder' - }): Promise => ipcRenderer.invoke('repos:create', args), + create: (args) => ipcRenderer.invoke('repos:create', args), - remove: (args: { repoId: string }): Promise => ipcRenderer.invoke('repos:remove', args), + remove: (args) => ipcRenderer.invoke('repos:remove', args), - reorder: (args: { orderedIds: string[] }): Promise<{ status: 'applied' | 'rejected' }> => - ipcRenderer.invoke('repos:reorder', args), + reorder: (args) => ipcRenderer.invoke('repos:reorder', args), - update: (args: { repoId: string; updates: Record }): Promise => - ipcRenderer.invoke('repos:update', args), + update: (args) => ipcRenderer.invoke('repos:update', args), - pickFolder: (): Promise => ipcRenderer.invoke('repos:pickFolder'), + pickFolder: () => ipcRenderer.invoke('repos:pickFolder'), - pickDirectory: (): Promise => ipcRenderer.invoke('repos:pickDirectory'), + pickDirectory: () => ipcRenderer.invoke('repos:pickDirectory'), - clone: (args: { url: string; destination: string }): Promise => - ipcRenderer.invoke('repos:clone', args), + clone: (args) => ipcRenderer.invoke('repos:clone', args), - cloneAbort: (): Promise => ipcRenderer.invoke('repos:cloneAbort'), + cloneAbort: () => ipcRenderer.invoke('repos:cloneAbort'), onCloneProgress: ( callback: (data: { phase: string; percent: number }) => void @@ -504,52 +496,24 @@ const api = { ipcRenderer.on('repos:changed', listener) return () => ipcRenderer.removeListener('repos:changed', listener) } - }, + } satisfies PreloadApi['repos'], projectGroups: { - list: (): Promise => ipcRenderer.invoke('projectGroups:list'), - create: (args: { - name: string - parentPath?: string | null - parentGroupId?: string | null - createdFrom?: 'manual' | 'folder-scan' | 'migration' - }): Promise => ipcRenderer.invoke('projectGroups:create', args), - update: (args: { groupId: string; updates: Record }): Promise => - ipcRenderer.invoke('projectGroups:update', args), - delete: (args: { groupId: string }): Promise => - ipcRenderer.invoke('projectGroups:delete', args), - moveProject: (args: { - projectId: string - groupId: string | null - order?: number - }): Promise => ipcRenderer.invoke('projectGroups:moveProject', args), - scanNested: (args: { - path: string - connectionId?: string - options?: Record - }): Promise => ipcRenderer.invoke('projectGroups:scanNested', args), - importNested: (args: { - parentPath: string - groupName: string - projectPaths: string[] - connectionId?: string - mode: 'group' | 'separate' - }): Promise => ipcRenderer.invoke('projectGroups:importNested', args) - }, + list: () => ipcRenderer.invoke('projectGroups:list'), + create: (args) => ipcRenderer.invoke('projectGroups:create', args), + update: (args) => ipcRenderer.invoke('projectGroups:update', args), + delete: (args) => ipcRenderer.invoke('projectGroups:delete', args), + moveProject: (args) => ipcRenderer.invoke('projectGroups:moveProject', args), + scanNested: (args) => ipcRenderer.invoke('projectGroups:scanNested', args), + importNested: (args) => ipcRenderer.invoke('projectGroups:importNested', args) + } satisfies PreloadApi['projectGroups'], sparsePresets: { - list: (args: { repoId: string }): Promise => - ipcRenderer.invoke('sparsePresets:list', args), + list: (args) => ipcRenderer.invoke('sparsePresets:list', args), - save: (args: { - repoId: string - id?: string - name: string - directories: string[] - }): Promise => ipcRenderer.invoke('sparsePresets:save', args), + save: (args) => ipcRenderer.invoke('sparsePresets:save', args), - remove: (args: { repoId: string; presetId: string }): Promise => - ipcRenderer.invoke('sparsePresets:remove', args), + remove: (args) => ipcRenderer.invoke('sparsePresets:remove', args), onChanged: (callback: (data: { repoId: string }) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, data: { repoId: string }) => @@ -557,55 +521,33 @@ const api = { ipcRenderer.on('sparsePresets:changed', listener) return () => ipcRenderer.removeListener('sparsePresets:changed', listener) } - }, + } satisfies PreloadApi['sparsePresets'], worktrees: { - list: (args: { repoId: string }): Promise => - ipcRenderer.invoke('worktrees:list', args), + list: (args) => ipcRenderer.invoke('worktrees:list', args), - listDetected: (args: { repoId: string }): Promise => - ipcRenderer.invoke('worktrees:listDetected', args), + listDetected: (args) => ipcRenderer.invoke('worktrees:listDetected', args), - listAll: (): Promise => ipcRenderer.invoke('worktrees:listAll'), + listAll: () => ipcRenderer.invoke('worktrees:listAll'), - create: (args: CreateWorktreeArgs): Promise => - ipcRenderer.invoke('worktrees:create', args), + create: (args) => ipcRenderer.invoke('worktrees:create', args), - resolvePrBase: (args: { - repoId: string - prNumber: number - headRefName?: string - isCrossRepository?: boolean - }): Promise<{ baseBranch: string; pushTarget?: unknown } | { error: string }> => - ipcRenderer.invoke('worktrees:resolvePrBase', args), + resolvePrBase: (args) => ipcRenderer.invoke('worktrees:resolvePrBase', args), - resolveMrBase: (args: { - repoId: string - mrIid: number - sourceBranch?: string - isCrossRepository?: boolean - }): Promise<{ baseBranch: string; pushTarget?: unknown } | { error: string }> => - ipcRenderer.invoke('worktrees:resolveMrBase', args), + resolveMrBase: (args) => ipcRenderer.invoke('worktrees:resolveMrBase', args), - remove: (args: { worktreeId: string; force?: boolean; skipArchive?: boolean }): Promise => - ipcRenderer.invoke('worktrees:remove', args), + remove: (args) => ipcRenderer.invoke('worktrees:remove', args), - updateMeta: (args: { - worktreeId: string - updates: Record - }): Promise => ipcRenderer.invoke('worktrees:updateMeta', args), + forceDeletePreservedBranch: (args) => + ipcRenderer.invoke('worktrees:forceDeletePreservedBranch', args), - listLineage: (): Promise> => - ipcRenderer.invoke('worktrees:listLineage'), + updateMeta: (args) => ipcRenderer.invoke('worktrees:updateMeta', args), - updateLineage: (args: { - worktreeId: string - parentWorktreeId?: string - noParent?: boolean - }): Promise => ipcRenderer.invoke('worktrees:updateLineage', args), + listLineage: () => ipcRenderer.invoke('worktrees:listLineage'), - persistSortOrder: (args: { orderedIds: string[] }): Promise => - ipcRenderer.invoke('worktrees:persistSortOrder', args), + updateLineage: (args) => ipcRenderer.invoke('worktrees:updateLineage', args), + + persistSortOrder: (args) => ipcRenderer.invoke('worktrees:persistSortOrder', args), onChanged: (callback: (data: { repoId: string }) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, data: { repoId: string }) => @@ -631,26 +573,20 @@ const api = { ipcRenderer.on('worktree:remoteBranchConflict', listener) return () => ipcRenderer.removeListener('worktree:remoteBranchConflict', listener) } - }, + } satisfies PreloadApi['worktrees'], workspaceCleanup: { - scan: (args?: { worktreeId?: string; skipGitWorktreeIds?: string[] }): Promise => - ipcRenderer.invoke('workspaceCleanup:scan', args), - dismiss: (args: { dismissals: unknown[] }): Promise => - ipcRenderer.invoke('workspaceCleanup:dismiss', args), - clearDismissals: (): Promise => ipcRenderer.invoke('workspaceCleanup:clearDismissals'), - hasKillableLocalProcesses: (args: { - worktreeId: string - connectionId?: string | null - worktreePath?: string - }): Promise => ipcRenderer.invoke('workspaceCleanup:hasKillableLocalProcesses', args) - }, + scan: (args) => ipcRenderer.invoke('workspaceCleanup:scan', args), + dismiss: (args) => ipcRenderer.invoke('workspaceCleanup:dismiss', args), + clearDismissals: () => ipcRenderer.invoke('workspaceCleanup:clearDismissals'), + hasKillableLocalProcesses: (args) => + ipcRenderer.invoke('workspaceCleanup:hasKillableLocalProcesses', args) + } satisfies PreloadApi['workspaceCleanup'], workspaceSpace: { - analyze: (): Promise => - ipcRenderer.invoke('workspaceSpace:analyze'), - cancel: (): Promise => ipcRenderer.invoke('workspaceSpace:cancel'), - onProgress: (callback: (progress: WorkspaceSpaceScanProgress) => void): (() => void) => { + analyze: () => ipcRenderer.invoke('workspaceSpace:analyze'), + cancel: () => ipcRenderer.invoke('workspaceSpace:cancel'), + onProgress: (callback) => { const listener = ( _event: Electron.IpcRendererEvent, progress: WorkspaceSpaceScanProgress @@ -658,16 +594,12 @@ const api = { ipcRenderer.on('workspaceSpace:progress', listener) return () => ipcRenderer.removeListener('workspaceSpace:progress', listener) } - }, + } satisfies PreloadApi['workspaceSpace'], workspacePorts: { - scan: (args: WorkspacePortScanRequest): Promise => - ipcRenderer.invoke('workspacePorts:scan', args), - kill: (args: WorkspacePortKillRequest): Promise => - ipcRenderer.invoke('workspacePorts:kill', args), - onAdvertisedUrlChanged: ( - callback: (event: WorkspacePortAdvertisedUrlChangedEvent) => void - ): (() => void) => { + scan: (args) => ipcRenderer.invoke('workspacePorts:scan', args), + kill: (args) => ipcRenderer.invoke('workspacePorts:kill', args), + onAdvertisedUrlChanged: (callback) => { const listener = ( _event: Electron.IpcRendererEvent, event: WorkspacePortAdvertisedUrlChangedEvent @@ -675,7 +607,7 @@ const api = { ipcRenderer.on('workspacePorts:advertised-url-changed', listener) return () => ipcRenderer.removeListener('workspacePorts:advertised-url-changed', listener) } - }, + } satisfies PreloadApi['workspacePorts'], pty: { spawn: (opts: { @@ -739,6 +671,10 @@ const api = { ipcRenderer.send('pty:ackColdRestore', { id }) }, + pauseOutput: (id: string, paused: boolean): void => { + ipcRenderer.send('pty:pauseOutput', { id, paused }) + }, + kill: (id: string, opts?: { keepHistory?: boolean }): Promise => ipcRenderer.invoke('pty:kill', { id, keepHistory: opts?.keepHistory ?? false }), @@ -858,6 +794,12 @@ const api = { getLatestPending: () => ipcRenderer.invoke('crashReports:getLatestPending'), getLatestReport: () => ipcRenderer.invoke('crashReports:getLatestReport'), dismiss: (args: { reportId: string }) => ipcRenderer.invoke('crashReports:dismiss', args), + recordRendererError: ( + args: ReactErrorBoundaryReportArgs + ): Promise => + ipcRenderer.invoke('crashReports:recordRendererError', args), + recordBreadcrumb: (args: { name: string; data?: CrashReportBreadcrumbData }): void => + ipcRenderer.send('crashReports:recordBreadcrumb', args), submit: (args: CrashReportSubmitArgs): Promise => ipcRenderer.invoke('crashReports:submit', args), copyLatestDiagnostics: (args?: { reportId?: string; notes?: string }) => @@ -953,6 +895,8 @@ const api = { repoId?: string title: string body: string + labels?: string[] + assignees?: string[] }): Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }> => ipcRenderer.invoke('gh:createIssue', args), @@ -1088,6 +1032,7 @@ const api = { number: number body: string type?: 'issue' | 'pr' + prRepo?: { owner: string; repo: string } | null }): Promise => ipcRenderer.invoke('gh:addIssueComment', args), addPRReviewCommentReply: (args: { @@ -1099,6 +1044,7 @@ const api = { threadId?: string path?: string line?: number + prRepo?: { owner: string; repo: string } | null }): Promise => ipcRenderer.invoke('gh:addPRReviewCommentReply', args), addPRReviewComment: (args: { @@ -1290,7 +1236,40 @@ const api = { query?: string limit?: number workspaceId?: string - }): Promise => ipcRenderer.invoke('linear:listProjects', args), + }): Promise => ipcRenderer.invoke('linear:listProjects', args), + + getProject: (args: { id: string; workspaceId: string }): Promise => + ipcRenderer.invoke('linear:getProject', args), + + listProjectIssues: (args: { + projectId: string + limit?: number + workspaceId: string + }): Promise => ipcRenderer.invoke('linear:listProjectIssues', args), + + listCustomViews: (args: { + model: string + limit?: number + workspaceId?: string + }): Promise => ipcRenderer.invoke('linear:listCustomViews', args), + + getCustomView: (args: { + viewId: string + model: string + workspaceId: string + }): Promise => ipcRenderer.invoke('linear:getCustomView', args), + + listCustomViewIssues: (args: { + viewId: string + limit?: number + workspaceId: string + }): Promise => ipcRenderer.invoke('linear:listCustomViewIssues', args), + + listCustomViewProjects: (args: { + viewId: string + limit?: number + workspaceId: string + }): Promise => ipcRenderer.invoke('linear:listCustomViewProjects', args), teamStates: (args: { teamId: string; workspaceId?: string }): Promise => ipcRenderer.invoke('linear:teamStates', args), @@ -1432,6 +1411,8 @@ const api = { agentHooks: { claudeStatus: (): Promise => ipcRenderer.invoke('agentHooks:claudeStatus'), + openClaudeStatus: (): Promise => + ipcRenderer.invoke('agentHooks:openClaudeStatus'), codexStatus: (): Promise => ipcRenderer.invoke('agentHooks:codexStatus'), geminiStatus: (): Promise => @@ -1456,6 +1437,7 @@ const api = { markTrusted: (args: { preset: 'cursor' | 'copilot' | 'codex' workspacePath: string + connectionId?: string }): Promise => ipcRenderer.invoke('agentTrust:markTrusted', args) }, @@ -1613,7 +1595,8 @@ const api = { openUrl: (url: string): Promise => ipcRenderer.invoke('shell:openUrl', url), - openFilePath: (path: string): Promise => ipcRenderer.invoke('shell:openFilePath', path), + openFilePath: (path: string): Promise => + ipcRenderer.invoke('shell:openFilePath', path), openFileUri: (uri: string): Promise => ipcRenderer.invoke('shell:openFileUri', uri), @@ -1948,8 +1931,12 @@ const api = { hooks: { check: (args: { repoId: string - }): Promise<{ hasHooks: boolean; hooks: unknown; mayNeedUpdate: boolean }> => - ipcRenderer.invoke('hooks:check', args), + }): Promise<{ + status?: 'ok' | 'error' + hasHooks: boolean + hooks: unknown + mayNeedUpdate: boolean + }> => ipcRenderer.invoke('hooks:check', args), inspectSetupScriptImports: (args: { repoId: string }): Promise => ipcRenderer.invoke('hooks:inspectSetupScriptImports', args), @@ -1964,6 +1951,7 @@ const api = { readIssueCommand: (args: { repoId: string }): Promise<{ + status?: 'ok' | 'error' localContent: string | null sharedContent: string | null effectiveContent: string | null @@ -1977,44 +1965,42 @@ const api = { cache: { getGitHub: () => ipcRenderer.invoke('cache:getGitHub'), - setGitHub: (args: { cache: unknown }) => ipcRenderer.invoke('cache:setGitHub', args) - }, + setGitHub: (args) => ipcRenderer.invoke('cache:setGitHub', args) + } satisfies PreloadApi['cache'], session: { - get: (): Promise => ipcRenderer.invoke('session:get'), - set: (args: unknown): Promise => ipcRenderer.invoke('session:set', args), + get: () => ipcRenderer.invoke('session:get'), + set: (args) => ipcRenderer.invoke('session:set', args), + patch: (args) => ipcRenderer.invoke('session:patch', args), /** Synchronous session save for beforeunload — blocks until flushed to disk. */ - setSync: (args: unknown): void => { + setSync: (args) => { ipcRenderer.sendSync('session:set-sync', args) } - }, + } satisfies PreloadApi['session'], remoteWorkspace: { - get: (args: { targetId: string }): Promise => - ipcRenderer.invoke('remoteWorkspace:get', args), - setForConnectedTargets: (args: { - session: unknown - hydratedTargetIds?: string[] - }): Promise => ipcRenderer.invoke('remoteWorkspace:setForConnectedTargets', args), - listEnabledConnectedTargets: (): Promise => + get: (args) => ipcRenderer.invoke('remoteWorkspace:get', args), + setForConnectedTargets: (args) => + ipcRenderer.invoke('remoteWorkspace:setForConnectedTargets', args), + listEnabledConnectedTargets: () => ipcRenderer.invoke('remoteWorkspace:listEnabledConnectedTargets'), - listConnectedClients: (args?: { targetIds?: string[] }): Promise => + listConnectedClients: (args) => ipcRenderer.invoke('remoteWorkspace:listConnectedClients', args), - clientId: (): Promise => ipcRenderer.invoke('remoteWorkspace:clientId'), - onChanged: (callback: (event: unknown) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: unknown) => callback(data) + clientId: () => ipcRenderer.invoke('remoteWorkspace:clientId'), + onChanged: (callback) => { + const listener = (_event: Electron.IpcRendererEvent, data: RemoteWorkspaceChangedEvent) => + callback(data) ipcRenderer.on('remoteWorkspace:changed', listener) return () => ipcRenderer.removeListener('remoteWorkspace:changed', listener) } - }, + } satisfies PreloadApi['remoteWorkspace'], updater: { - getStatus: (): Promise => ipcRenderer.invoke('updater:getStatus'), - getVersion: (): Promise => ipcRenderer.invoke('updater:getVersion'), - check: (options?: { includePrerelease?: boolean }): Promise => - ipcRenderer.invoke('updater:check', options), - download: (): Promise => ipcRenderer.invoke('updater:download'), - dismissNudge: (): Promise => ipcRenderer.invoke('updater:dismissNudge'), + getStatus: () => ipcRenderer.invoke('updater:getStatus'), + getVersion: () => ipcRenderer.invoke('updater:getVersion'), + check: (options) => ipcRenderer.invoke('updater:check', options), + download: () => ipcRenderer.invoke('updater:download'), + dismissNudge: () => ipcRenderer.invoke('updater:dismissNudge'), quitAndInstall: async (): Promise => { // Why: update installs must proceed even when a dirty-file auto-save // fails; otherwise a downloaded update can get stuck behind hidden editor @@ -2033,17 +2019,17 @@ const api = { throw error } }, - onStatus: (callback: (status: unknown) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, status: unknown) => callback(status) + onStatus: (callback) => { + const listener = (_event: Electron.IpcRendererEvent, status: UpdateStatus) => callback(status) ipcRenderer.on('updater:status', listener) return () => ipcRenderer.removeListener('updater:status', listener) }, - onClearDismissal: (callback: () => void): (() => void) => { + onClearDismissal: (callback) => { const listener = (_event: Electron.IpcRendererEvent) => callback() ipcRenderer.on('updater:clearDismissal', listener) return () => ipcRenderer.removeListener('updater:clearDismissal', listener) } - }, + } satisfies PreloadApi['updater'], notebook: { runPythonCell: (args: { @@ -2344,10 +2330,9 @@ const api = { }, ui: { - get: (): Promise => ipcRenderer.invoke('ui:get'), - set: (args: Record): Promise => ipcRenderer.invoke('ui:set', args), - recordFeatureInteraction: (id: string): Promise => - ipcRenderer.invoke('ui:recordFeatureInteraction', id), + get: () => ipcRenderer.invoke('ui:get'), + set: (args) => ipcRenderer.invoke('ui:set', args), + recordFeatureInteraction: (id) => ipcRenderer.invoke('ui:recordFeatureInteraction', id), onOpenSettings: (callback: () => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent) => callback() ipcRenderer.on('ui:openSettings', listener) @@ -2403,6 +2388,11 @@ const api = { ipcRenderer.on('ui:openNewWorkspace', listener) return () => ipcRenderer.removeListener('ui:openNewWorkspace', listener) }, + onDeleteCurrentWorkspace: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:deleteCurrentWorkspace', listener) + return () => ipcRenderer.removeListener('ui:deleteCurrentWorkspace', listener) + }, onOpenTasks: (callback: () => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent) => callback() ipcRenderer.on('ui:openTasks', listener) @@ -2413,6 +2403,11 @@ const api = { ipcRenderer.on('ui:jumpToWorktreeIndex', listener) return () => ipcRenderer.removeListener('ui:jumpToWorktreeIndex', listener) }, + onJumpToTabIndex: (callback: (index: number) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, index: number) => callback(index) + ipcRenderer.on('ui:jumpToTabIndex', listener) + return () => ipcRenderer.removeListener('ui:jumpToTabIndex', listener) + }, onWorktreeHistoryNavigate: ( callback: (direction: 'back' | 'forward') => void ): (() => void) => { @@ -2426,6 +2421,11 @@ const api = { ipcRenderer.on('ui:newBrowserTab', listener) return () => ipcRenderer.removeListener('ui:newBrowserTab', listener) }, + onNewMarkdownTab: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:newMarkdownTab', listener) + return () => ipcRenderer.removeListener('ui:newMarkdownTab', listener) + }, onRequestTabCreate: ( callback: (data: { requestId: string @@ -2556,6 +2556,7 @@ const api = { worktreeId: string setup?: { runnerScriptPath: string; envVars: Record } startup?: { command: string; env?: Record } + defaultTabs?: WorktreeDefaultTabsLaunch }) => void ): (() => void) => { const listener = ( @@ -2565,6 +2566,7 @@ const api = { worktreeId: string setup?: { runnerScriptPath: string; envVars: Record } startup?: { command: string; env?: Record } + defaultTabs?: WorktreeDefaultTabsLaunch } ) => callback(data) ipcRenderer.on('ui:activateWorktree', listener) @@ -2876,7 +2878,7 @@ const api = { confirmWindowClose: (): void => { ipcRenderer.send('window:confirm-close') } - }, + } satisfies PreloadApi['ui'], stats: { getSummary: (): Promise<{ @@ -3227,6 +3229,11 @@ const api = { delete: (args: { id: string }): Promise => ipcRenderer.invoke('automations:delete', args), runNow: (args: { id: string }): Promise => ipcRenderer.invoke('automations:runNow', args), + runPrecheck: (args: { + automationId: string + runId: string + }): Promise => + ipcRenderer.invoke('automations:runPrecheck', args), markDispatchResult: (result: AutomationDispatchResult): Promise => ipcRenderer.invoke('automations:markDispatchResult', result), snapshotWorkspaceName: (args: { workspaceId: string; displayName: string }): Promise => diff --git a/src/preload/runtime-environment-subscriptions.test.ts b/src/preload/runtime-environment-subscriptions.test.ts index 31c4447e616..e007d0a33b5 100644 --- a/src/preload/runtime-environment-subscriptions.test.ts +++ b/src/preload/runtime-environment-subscriptions.test.ts @@ -100,4 +100,42 @@ describe('subscribeRuntimeEnvironmentFromPreload', () => { on.mock.calls[0][1] ) }) + + it('removes the listener when main reports the remote subscription closed', async () => { + const subscription = deferred<{ subscriptionId: string; requestId: string }>() + const invoke = vi.fn(() => subscription.promise as Promise) + const send = vi.fn() + const on = vi.fn() + const removeListener = vi.fn() + const onClose = vi.fn() + + const cleanupPromise = subscribeRuntimeEnvironmentFromPreload( + { invoke, send, on, removeListener }, + { selector: 'desk', method: 'terminal.subscribe' }, + { onResponse: vi.fn(), onClose }, + () => 'sub-closed' + ) + + const listener = on.mock.calls[0][1] as ( + _event: unknown, + payload: { subscriptionId: string; type: 'close' } + ) => void + + listener(null, { subscriptionId: 'other-sub', type: 'close' }) + expect(onClose).not.toHaveBeenCalled() + expect(removeListener).not.toHaveBeenCalled() + + listener(null, { subscriptionId: 'sub-closed', type: 'close' }) + expect(onClose).toHaveBeenCalledTimes(1) + expect(removeListener).toHaveBeenCalledWith('runtimeEnvironments:subscriptionEvent', listener) + + subscription.resolve({ subscriptionId: 'sub-closed', requestId: 'rpc-closed' }) + const cleanup = await cleanupPromise + cleanup.unsubscribe() + + expect(removeListener).toHaveBeenCalledTimes(1) + expect(invoke).toHaveBeenCalledWith('runtimeEnvironments:unsubscribe', { + subscriptionId: 'sub-closed' + }) + }) }) diff --git a/src/preload/runtime-environment-subscriptions.ts b/src/preload/runtime-environment-subscriptions.ts index d9c30325bd3..26b15053eb6 100644 --- a/src/preload/runtime-environment-subscriptions.ts +++ b/src/preload/runtime-environment-subscriptions.ts @@ -55,6 +55,14 @@ export async function subscribeRuntimeEnvironmentFromPreload( createSubscriptionId = createRuntimeEnvironmentSubscriptionId ): Promise { const subscriptionId = createSubscriptionId() + let listenerAttached = false + const detachListener = (): void => { + if (!listenerAttached) { + return + } + listenerAttached = false + ipc.removeListener(SUBSCRIPTION_EVENT_CHANNEL, listener) + } const listener = (_event: unknown, event: RuntimeEnvironmentSubscriptionEvent): void => { if (event.subscriptionId !== subscriptionId) { return @@ -67,29 +75,33 @@ export async function subscribeRuntimeEnvironmentFromPreload( callbacks.onError?.({ code: event.code, message: event.message }) } else { callbacks.onClose?.() + // Why: main has already dropped the remote subscription on close, so + // keeping this per-subscription IPC listener would retain renderer state. + detachListener() } } // Why: streaming RPCs can emit their first frame before ipcMain.handle() // resolves, so preload must subscribe to the event channel before invoking. ipc.on(SUBSCRIPTION_EVENT_CHANNEL, listener) + listenerAttached = true try { const result = (await ipc.invoke('runtimeEnvironments:subscribe', { ...args, subscriptionId })) as { subscriptionId: string; requestId: string } if (result.subscriptionId !== subscriptionId) { - ipc.removeListener(SUBSCRIPTION_EVENT_CHANNEL, listener) + detachListener() throw new Error('Runtime environment subscription id mismatch') } } catch (error) { - ipc.removeListener(SUBSCRIPTION_EVENT_CHANNEL, listener) + detachListener() throw error } return { unsubscribe: () => { - ipc.removeListener(SUBSCRIPTION_EVENT_CHANNEL, listener) + detachListener() void ipc.invoke('runtimeEnvironments:unsubscribe', { subscriptionId }) }, sendBinary: (bytes) => { diff --git a/src/relay/agent-exec-handler-test-harness.ts b/src/relay/agent-exec-handler-test-harness.ts new file mode 100644 index 00000000000..7fe94d6c837 --- /dev/null +++ b/src/relay/agent-exec-handler-test-harness.ts @@ -0,0 +1,46 @@ +import { EventEmitter } from 'events' +import { vi } from 'vitest' +import type { MethodHandler, RequestContext } from './dispatcher' +import { AgentExecHandler } from './agent-exec-handler' + +export function withPlatform(platform: NodeJS.Platform, fn: () => T): T { + const original = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + try { + return fn() + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: original }) + } +} + +export type FakeChild = EventEmitter & { + pid: number + kill: ReturnType + stdout: EventEmitter + stderr: EventEmitter + stdin: { end: ReturnType } +} + +export function createFakeChild(): FakeChild { + return Object.assign(new EventEmitter(), { + pid: 12345, + kill: vi.fn(), + stdout: new EventEmitter(), + stderr: new EventEmitter(), + stdin: { end: vi.fn() } + }) +} + +export function createHandlers(): Map { + const handlers = new Map() + new AgentExecHandler({ + onRequest: (method: string, handler: MethodHandler): void => { + handlers.set(method, handler) + } + } as never) + return handlers +} + +export function requestContext(clientId = 1): RequestContext { + return { clientId, isStale: () => false } +} diff --git a/src/relay/agent-exec-handler-windows.test.ts b/src/relay/agent-exec-handler-windows.test.ts new file mode 100644 index 00000000000..4ba0a97f9de --- /dev/null +++ b/src/relay/agent-exec-handler-windows.test.ts @@ -0,0 +1,108 @@ +import { exec, spawn } from 'child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as ChildProcess from 'child_process' +import { + createFakeChild, + createHandlers, + requestContext, + withPlatform +} from './agent-exec-handler-test-harness' + +vi.mock('child_process', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + exec: vi.fn(), + spawn: vi.fn() + } +}) + +const spawnMock = vi.mocked(spawn) +const execMock = vi.mocked(exec) + +describe('AgentExecHandler Windows command spawning', () => { + beforeEach(() => { + spawnMock.mockReset() + execMock.mockReset() + }) + + it('resolves bare Windows agent commands to batch shims before spawning', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'orca-agent-exec-')) + const originalComSpec = process.env.ComSpec + process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe' + try { + await withPlatform('win32', async () => { + const codexShim = join(tempDir, 'codex.cmd') + writeFileSync(codexShim, '@echo off\r\n') + const child = createFakeChild() + spawnMock.mockReturnValue(child as never) + const handlers = createHandlers() + + const pending = handlers.get('agent.execNonInteractive')!( + { + binary: 'codex', + args: ['exec', '-s', 'read-only'], + cwd: 'C:\\repo', + stdin: 'PROMPT', + timeoutMs: 5_000, + env: { PATH: tempDir } + }, + requestContext() + ) + + child.emit('close', 0) + + await expect(pending).resolves.toMatchObject({ + exitCode: 0, + timedOut: false + }) + expect(spawnMock).toHaveBeenCalledWith( + 'C:\\Windows\\System32\\cmd.exe', + ['/d', '/s', '/c', `"${codexShim}" "exec" "-s" "read-only"`], + { + cwd: 'C:\\repo', + env: expect.objectContaining({ PATH: tempDir }), + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true + } + ) + }) + } finally { + rmSync(tempDir, { recursive: true, force: true }) + if (originalComSpec === undefined) { + delete process.env.ComSpec + } else { + process.env.ComSpec = originalComSpec + } + } + }) + + it('rejects unsafe args when routing Windows batch shims through cmd.exe', async () => { + await withPlatform('win32', async () => { + const handlers = createHandlers() + + const result = await handlers.get('agent.execNonInteractive')!( + { + binary: 'C:\\tools\\agent.cmd', + args: ['hello & goodbye'], + cwd: 'C:\\repo', + stdin: null, + timeoutMs: 5_000 + }, + requestContext() + ) + + expect(result).toEqual({ + stdout: '', + stderr: '', + exitCode: null, + timedOut: false, + spawnError: 'UNSAFE_WINDOWS_BATCH_ARGUMENTS' + }) + expect(spawnMock).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/relay/agent-exec-handler.test.ts b/src/relay/agent-exec-handler.test.ts index edf9c57bcbf..f36c99105cc 100644 --- a/src/relay/agent-exec-handler.test.ts +++ b/src/relay/agent-exec-handler.test.ts @@ -1,12 +1,7 @@ -import { EventEmitter } from 'events' import { exec, spawn } from 'child_process' -import { mkdtempSync, rmSync, writeFileSync } from 'fs' -import { tmpdir } from 'os' -import { join } from 'path' import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as ChildProcess from 'child_process' -import type { MethodHandler, RequestContext } from './dispatcher' -import { AgentExecHandler } from './agent-exec-handler' +import { createFakeChild, createHandlers, requestContext } from './agent-exec-handler-test-harness' vi.mock('child_process', async (importOriginal) => { const actual = await importOriginal() @@ -20,47 +15,7 @@ vi.mock('child_process', async (importOriginal) => { const spawnMock = vi.mocked(spawn) const execMock = vi.mocked(exec) -function withPlatform(platform: NodeJS.Platform, fn: () => T): T { - const original = process.platform - Object.defineProperty(process, 'platform', { configurable: true, value: platform }) - try { - return fn() - } finally { - Object.defineProperty(process, 'platform', { configurable: true, value: original }) - } -} - -type FakeChild = EventEmitter & { - pid: number - kill: ReturnType - stdout: EventEmitter - stderr: EventEmitter - stdin: { end: ReturnType } -} - -function createFakeChild(): FakeChild { - return Object.assign(new EventEmitter(), { - pid: 12345, - kill: vi.fn(), - stdout: new EventEmitter(), - stderr: new EventEmitter(), - stdin: { end: vi.fn() } - }) -} - -function createHandlers(): Map { - const handlers = new Map() - new AgentExecHandler({ - onRequest: (method: string, handler: MethodHandler): void => { - handlers.set(method, handler) - } - } as never) - return handlers -} - -function requestContext(clientId = 1): RequestContext { - return { clientId, isStale: () => false } -} +type AgentExecResult = { exitCode: number | null; timedOut: boolean } describe('AgentExecHandler', () => { beforeEach(() => { @@ -142,83 +97,6 @@ describe('AgentExecHandler', () => { }) }) - it('resolves bare Windows agent commands to batch shims before spawning', async () => { - const tempDir = mkdtempSync(join(tmpdir(), 'orca-agent-exec-')) - const originalComSpec = process.env.ComSpec - process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe' - try { - await withPlatform('win32', async () => { - const codexShim = join(tempDir, 'codex.cmd') - writeFileSync(codexShim, '@echo off\r\n') - const child = createFakeChild() - spawnMock.mockReturnValue(child as never) - const handlers = createHandlers() - - const pending = handlers.get('agent.execNonInteractive')!( - { - binary: 'codex', - args: ['exec', '-s', 'read-only'], - cwd: 'C:\\repo', - stdin: 'PROMPT', - timeoutMs: 5_000, - env: { PATH: tempDir } - }, - requestContext() - ) - - child.emit('close', 0) - - await expect(pending).resolves.toMatchObject({ - exitCode: 0, - timedOut: false - }) - expect(spawnMock).toHaveBeenCalledWith( - 'C:\\Windows\\System32\\cmd.exe', - ['/d', '/s', '/c', `"${codexShim}" "exec" "-s" "read-only"`], - { - cwd: 'C:\\repo', - env: expect.objectContaining({ PATH: tempDir }), - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true - } - ) - }) - } finally { - rmSync(tempDir, { recursive: true, force: true }) - if (originalComSpec === undefined) { - delete process.env.ComSpec - } else { - process.env.ComSpec = originalComSpec - } - } - }) - - it('rejects unsafe args when routing Windows batch shims through cmd.exe', async () => { - await withPlatform('win32', async () => { - const handlers = createHandlers() - - const result = await handlers.get('agent.execNonInteractive')!( - { - binary: 'C:\\tools\\agent.cmd', - args: ['hello & goodbye'], - cwd: 'C:\\repo', - stdin: null, - timeoutMs: 5_000 - }, - requestContext() - ) - - expect(result).toEqual({ - stdout: '', - stderr: '', - exitCode: null, - timedOut: false, - spawnError: 'UNSAFE_WINDOWS_BATCH_ARGUMENTS' - }) - expect(spawnMock).not.toHaveBeenCalled() - }) - }) - it('cancels the in-flight command for the requested cwd', async () => { const child = createFakeChild() spawnMock.mockReturnValue(child as never) @@ -319,6 +197,100 @@ describe('AgentExecHandler', () => { }) }) + it('kills the active command when the request aborts', async () => { + const child = createFakeChild() + spawnMock.mockReturnValue(child as never) + const handlers = createHandlers() + const controller = new AbortController() + + const pending = handlers.get('agent.execNonInteractive')!( + { + binary: 'agent', + args: [], + cwd: '/repo', + stdin: null, + timeoutMs: 5_000 + }, + { clientId: 1, isStale: () => controller.signal.aborted, signal: controller.signal } + ) + + controller.abort() + + if (process.platform === 'win32') { + expect(execMock).toHaveBeenCalledWith('taskkill /pid 12345 /T /F', expect.any(Function)) + } else { + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + } + + child.emit('close', null) + await expect(pending).resolves.toMatchObject({ + exitCode: null, + timedOut: false, + canceled: true + }) + expect(child.stdout.listenerCount('data')).toBe(0) + expect(child.stderr.listenerCount('data')).toBe(0) + expect(child.listenerCount('error')).toBe(0) + expect(child.listenerCount('close')).toBe(0) + }) + + it('cancels a superseded command in the same operation lane', async () => { + const firstChild = createFakeChild() + const secondChild = createFakeChild() + secondChild.pid = 12346 + spawnMock.mockReturnValueOnce(firstChild as never).mockReturnValueOnce(secondChild as never) + const handlers = createHandlers() + + const first = handlers.get('agent.execNonInteractive')!( + { + binary: 'agent', + args: ['first'], + cwd: '/repo', + stdin: null, + timeoutMs: 5_000, + operation: 'commit-message' + }, + requestContext() + ) + const second = handlers.get('agent.execNonInteractive')!( + { + binary: 'agent', + args: ['second'], + cwd: '/repo', + stdin: null, + timeoutMs: 5_000, + operation: 'commit-message' + }, + requestContext() + ) + + if (process.platform === 'win32') { + expect(execMock).toHaveBeenCalledWith('taskkill /pid 12345 /T /F', expect.any(Function)) + expect(execMock).not.toHaveBeenCalledWith('taskkill /pid 12346 /T /F', expect.any(Function)) + } else { + expect(firstChild.kill).toHaveBeenCalledWith('SIGKILL') + expect(secondChild.kill).not.toHaveBeenCalled() + } + + await expect( + handlers.get('agent.cancelExec')!( + { cwd: '/repo', operation: 'commit-message' }, + requestContext() + ) + ).resolves.toEqual({ canceled: true }) + + if (process.platform === 'win32') { + expect(execMock).toHaveBeenCalledWith('taskkill /pid 12346 /T /F', expect.any(Function)) + } else { + expect(secondChild.kill).toHaveBeenCalledWith('SIGKILL') + } + + firstChild.emit('close', null) + secondChild.emit('close', null) + await expect(first).resolves.toMatchObject({ canceled: true }) + await expect(second).resolves.toMatchObject({ canceled: true }) + }) + it('reports when cancellation has no matching in-flight command', async () => { const handlers = createHandlers() @@ -326,4 +298,43 @@ describe('AgentExecHandler', () => { handlers.get('agent.cancelExec')!({ cwd: '/repo' }, requestContext()) ).resolves.toEqual({ canceled: false }) }) + + it('settles timed-out commands even when the killed child does not close', async () => { + vi.useFakeTimers() + try { + const child = createFakeChild() + spawnMock.mockReturnValue(child as never) + const handlers = createHandlers() + + const pending = handlers.get('agent.execNonInteractive')!( + { + binary: 'agent', + args: [], + cwd: '/repo', + stdin: null, + timeoutMs: 5_000 + }, + requestContext() + ) as Promise + const outcomePromise = pending.then((result) => + result.timedOut ? `timed-out:${result.exitCode}` : 'not-timed-out' + ) + + await vi.advanceTimersByTimeAsync(5_000) + const outcome = await Promise.race([outcomePromise, Promise.resolve('pending')]) + + expect(outcome).toBe('timed-out:null') + if (process.platform === 'win32') { + expect(execMock).toHaveBeenCalledWith('taskkill /pid 12345 /T /F', expect.any(Function)) + } else { + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + } + expect(child.stdout.listenerCount('data')).toBe(0) + expect(child.stderr.listenerCount('data')).toBe(0) + expect(child.listenerCount('error')).toBe(0) + expect(child.listenerCount('close')).toBe(0) + } finally { + vi.useRealTimers() + } + }) }) diff --git a/src/relay/agent-exec-handler.ts b/src/relay/agent-exec-handler.ts index 2a0ca4ae61a..3aab57967e1 100644 --- a/src/relay/agent-exec-handler.ts +++ b/src/relay/agent-exec-handler.ts @@ -1,7 +1,7 @@ import { exec, spawn, type ChildProcess } from 'child_process' import { existsSync } from 'fs' import { delimiter, join } from 'path' -import type { RelayDispatcher } from './dispatcher' +import type { RelayDispatcher, RequestContext } from './dispatcher' const DEFAULT_TIMEOUT_MS = 60_000 const MAX_TIMEOUT_MS = 5 * 60 * 1000 @@ -107,7 +107,7 @@ function laneKeyFor(cwd: string, operation: unknown): string { return JSON.stringify([op, cwd]) } -type InFlightExec = { child: ChildProcess; markCanceled: () => void } +type InFlightExec = { child: ChildProcess; cancel: () => void } type ExecResult = { stdout: string @@ -137,7 +137,9 @@ export class AgentExecHandler { } constructor(dispatcher: RelayDispatcher) { - dispatcher.onRequest('agent.execNonInteractive', (p) => this.exec(p as ExecParams)) + dispatcher.onRequest('agent.execNonInteractive', (p, context) => + this.exec(p as ExecParams, context) + ) dispatcher.onRequest('agent.cancelExec', (p) => this.cancel(p as CancelParams)) } @@ -147,12 +149,11 @@ export class AgentExecHandler { if (!entry) { return { canceled: false } } - entry.markCanceled() - killProcessTree(entry.child) + entry.cancel() return { canceled: true } } - private async exec(params: ExecParams): Promise { + private async exec(params: ExecParams, context?: RequestContext): Promise { const binary = typeof params.binary === 'string' ? params.binary : '' if (!binary) { throw new Error('agent.execNonInteractive: binary is required') @@ -199,52 +200,68 @@ export class AgentExecHandler { let settled = false const laneKey = typeof cwd === 'string' ? this.laneKey(cwd, params.operation) : '' let entry: InFlightExec | null = null + let timer: ReturnType | null = null + let detachChildListeners = (): void => {} + let detachRequestAbortListener = (): void => {} const finish = (result: ExecResult): void => { if (settled) { return } settled = true + if (timer) { + clearTimeout(timer) + timer = null + } + detachRequestAbortListener() + detachChildListeners() if (laneKey && entry && this.inFlightByLane.get(laneKey) === entry) { this.inFlightByLane.delete(laneKey) } resolve(result) } + const cancelCurrent = (): void => { + canceled = true + killProcessTree(child) + } if (laneKey) { + // Why: the relay owns one visible non-interactive job per cwd+operation. + // Replacing the lane without canceling the prior child would orphan + // that process until timeout because future cancelExec calls reach only + // the newest map entry. + this.inFlightByLane.get(laneKey)?.cancel() entry = { child, - markCanceled: () => { - canceled = true - } + cancel: cancelCurrent } this.inFlightByLane.set(laneKey, entry) } - const timer = setTimeout(() => { + timer = setTimeout(() => { timedOut = true // Why: tree-kill because some CLIs trap SIGTERM and continue streaming; // also Windows wraps `.cmd` shims in cmd.exe, so the immediate child // is not the real node.exe process. killProcessTree(child) + finish({ stdout, stderr, exitCode: null, timedOut, canceled }) }, timeoutMs) - child.stdout?.on('data', (chunk: Buffer) => { + const onStdoutData = (chunk: Buffer): void => { stdoutBytes += chunk.byteLength if (stdoutBytes > MAX_OUTPUT_BYTES) { killProcessTree(child) return } stdout += chunk.toString('utf-8') - }) - child.stderr?.on('data', (chunk: Buffer) => { + } + const onStderrData = (chunk: Buffer): void => { stderrBytes += chunk.byteLength if (stderrBytes > MAX_OUTPUT_BYTES) { killProcessTree(child) return } stderr += chunk.toString('utf-8') - }) - child.on('error', (error) => { - clearTimeout(timer) + } + const onError = (error: Error): void => { finish({ stdout, stderr, @@ -252,11 +269,31 @@ export class AgentExecHandler { timedOut, spawnError: error.message }) - }) - child.on('close', (code) => { - clearTimeout(timer) + } + const onClose = (code: number | null): void => { finish({ stdout, stderr, exitCode: code, timedOut, canceled }) - }) + } + child.stdout?.on('data', onStdoutData) + child.stderr?.on('data', onStderrData) + child.on('error', onError) + child.on('close', onClose) + detachChildListeners = () => { + child.stdout?.off('data', onStdoutData) + child.stderr?.off('data', onStderrData) + child.off('error', onError) + child.off('close', onClose) + } + + if (context?.signal) { + if (context.signal.aborted) { + cancelCurrent() + } else { + context.signal.addEventListener('abort', cancelCurrent, { once: true }) + detachRequestAbortListener = () => { + context.signal?.removeEventListener('abort', cancelCurrent) + } + } + } if (stdinPayload !== null) { child.stdin?.end(stdinPayload) diff --git a/src/relay/dispatcher.test.ts b/src/relay/dispatcher.test.ts index 5ca845be39d..5598ae46e0a 100644 --- a/src/relay/dispatcher.test.ts +++ b/src/relay/dispatcher.test.ts @@ -1,3 +1,6 @@ +/* eslint-disable max-lines -- Why: dispatcher behavior is stateful across + primary, socket, timeout, and cancellation paths; keeping fixtures shared + makes regression tests easier to audit. */ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { RelayDispatcher } from './dispatcher' import { @@ -201,6 +204,61 @@ describe('RelayDispatcher', () => { expect(socketWritten).toHaveLength(1) }) + it('forwards relay-originated requests to an owning socket client instead of the caller', async () => { + dispatcher.invalidateClient() + const ownerWritten: Buffer[] = [] + const ownerId = dispatcher.attachClient((data) => { + ownerWritten.push(Buffer.from(data)) + }) + const cliId = dispatcher.attachClient(() => {}) + + const pending = dispatcher.requestAnyClient( + 'orca.cli', + { argv: ['status'] }, + { excludeClientId: cliId } + ) + + expect(ownerWritten).toHaveLength(1) + const requestFrame = decodeFirstFrame(ownerWritten[0]) + const request = JSON.parse(requestFrame.payload.toString('utf-8')) as JsonRpcRequest + expect(request.method).toBe('orca.cli') + expect(request.params).toEqual({ argv: ['status'] }) + + dispatcher.feedClient( + ownerId, + encodeJsonRpcFrame({ jsonrpc: '2.0', id: request.id, result: { exitCode: 0 } }, 1, 0) + ) + + await expect(pending).resolves.toEqual({ exitCode: 0 }) + }) + + it('prefers an owning socket client over the synthetic primary client', async () => { + const ownerWritten: Buffer[] = [] + const ownerId = dispatcher.attachClient((data) => { + ownerWritten.push(Buffer.from(data)) + }) + const cliId = dispatcher.attachClient(() => {}) + + const pending = dispatcher.requestAnyClient( + 'orca.cli', + { argv: ['status'] }, + { excludeClientId: cliId } + ) + + expect(written).toHaveLength(0) + expect(ownerWritten).toHaveLength(1) + const requestFrame = decodeFirstFrame(ownerWritten[0]) + const request = JSON.parse(requestFrame.payload.toString('utf-8')) as JsonRpcRequest + expect(request.method).toBe('orca.cli') + + dispatcher.feedClient( + ownerId, + encodeJsonRpcFrame({ jsonrpc: '2.0', id: request.id, result: { exitCode: 0 } }, 1, 0) + ) + + await expect(pending).resolves.toEqual({ exitCode: 0 }) + }) + it('isolates failed socket-client writes from other clients', () => { const goodSocketWritten: Buffer[] = [] const failingClientId = dispatcher.attachClient(() => { diff --git a/src/relay/dispatcher.ts b/src/relay/dispatcher.ts index e349285132e..41809a4d749 100644 --- a/src/relay/dispatcher.ts +++ b/src/relay/dispatcher.ts @@ -1,3 +1,5 @@ +/* eslint-disable max-lines -- Why: the relay protocol dispatcher keeps client + routing, request cancellation, and framing state together. */ import { FrameDecoder, MessageType, @@ -35,16 +37,26 @@ type RelayClient = { closed: boolean } +type PendingRelayRequest = { + resolve: (result: unknown) => void + reject: (error: Error) => void + timer: ReturnType +} + +const RELAY_TO_CLIENT_REQUEST_TIMEOUT_MS = 30_000 + export class RelayDispatcher { private readonly primaryClient: RelayClient private readonly clients = new Map() private requestHandlers = new Map() private notificationHandlers = new Map() private readonly requestAborts = new ClientRequestAborts() + private pendingRelayRequests = new Map() private clientDetachListeners = new Set<(clientId: number) => void>() private keepaliveTimer: ReturnType | null = null private disposed = false private nextClientId = 1 + private nextRequestId = 1 constructor(write: (data: Buffer) => void) { this.primaryClient = this.createClient(write) @@ -153,6 +165,60 @@ export class RelayDispatcher { } } + requestPrimary( + method: string, + params?: Record, + options?: { timeoutMs?: number } + ): Promise { + return this.requestClient(this.primaryClient.id, method, params, options) + } + + requestAnyClient( + method: string, + params?: Record, + options?: { timeoutMs?: number; excludeClientId?: number } + ): Promise { + const candidates = Array.from(this.clients.values()).filter( + (client) => !client.closed && client.id !== options?.excludeClientId + ) + // Why: detached relays keep the synthetic primary client object around even + // though the owning Orca is attached through a Unix-socket client. Prefer a + // real attached client so remote `orca` shims do not forward to dead stdout. + const target = candidates.find((client) => client !== this.primaryClient) ?? candidates[0] + if (!target) { + return Promise.reject(new Error('No owning Orca client is connected to the relay')) + } + return this.requestClient(target.id, method, params, options) + } + + private requestClient( + clientId: number, + method: string, + params?: Record, + options?: { timeoutMs?: number } + ): Promise { + const client = this.clients.get(clientId) + if (this.disposed || !client || client.closed) { + return Promise.reject(new Error('Relay client is not connected')) + } + const id = this.nextRequestId++ + const msg: JsonRpcRequest = { + jsonrpc: '2.0', + id, + method, + ...(params !== undefined ? { params } : {}) + } + const timeoutMs = options?.timeoutMs ?? RELAY_TO_CLIENT_REQUEST_TIMEOUT_MS + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pendingRelayRequests.delete(id) + reject(new Error(`Request "${method}" timed out after ${timeoutMs}ms`)) + }, timeoutMs) + this.pendingRelayRequests.set(id, { resolve, reject, timer }) + this.sendFrame(client, msg) + }) + } + dispose(): void { if (this.disposed) { return @@ -162,6 +228,11 @@ export class RelayDispatcher { clearInterval(this.keepaliveTimer) this.keepaliveTimer = null } + for (const [id, pending] of this.pendingRelayRequests) { + clearTimeout(pending.timer) + pending.reject(new Error('Relay dispatcher disposed')) + this.pendingRelayRequests.delete(id) + } // Why: dispose means this relay instance cannot send responses anymore; // abort in-flight request work so stale SSH-side scans/watchers release. this.requestAborts.abortAll() @@ -216,11 +287,30 @@ export class RelayDispatcher { ): void { if ('id' in msg && 'method' in msg) { void this.handleRequest(client, msg as JsonRpcRequest) + } else if ('id' in msg && ('result' in msg || 'error' in msg)) { + this.handleResponse(msg as JsonRpcResponse) } else if ('method' in msg && !('id' in msg)) { this.handleNotification(client, msg as JsonRpcNotification) } } + private handleResponse(msg: JsonRpcResponse): void { + const pending = this.pendingRelayRequests.get(msg.id) + if (!pending) { + return + } + clearTimeout(pending.timer) + this.pendingRelayRequests.delete(msg.id) + if (msg.error) { + const error = new Error(msg.error.message) as Error & { code?: number; data?: unknown } + error.code = msg.error.code + error.data = msg.error.data + pending.reject(error) + return + } + pending.resolve(msg.result) + } + private async handleRequest(client: RelayClient, req: JsonRpcRequest): Promise { const handler = this.requestHandlers.get(req.method) if (!handler) { diff --git a/src/relay/external-automations-handler-log-path.test.ts b/src/relay/external-automations-handler-log-path.test.ts new file mode 100644 index 00000000000..936cf4cff8a --- /dev/null +++ b/src/relay/external-automations-handler-log-path.test.ts @@ -0,0 +1,47 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { dirname, join } from 'path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RelayDispatcher } from './dispatcher' + +type ReferencedLogReader = { + readReferencedLogFile(content: string): Promise<{ content: string } | null> +} + +describe('ExternalAutomationsHandler referenced log paths', () => { + let hermesHome: string + let previousHermesHome: string | undefined + + beforeEach(async () => { + previousHermesHome = process.env.HERMES_HOME + hermesHome = await mkdtemp(join(tmpdir(), 'relay-hermes-output-')) + process.env.HERMES_HOME = hermesHome + vi.resetModules() + }) + + afterEach(async () => { + if (previousHermesHome === undefined) { + delete process.env.HERMES_HOME + } else { + process.env.HERMES_HOME = previousHermesHome + } + await rm(hermesHome, { recursive: true, force: true }) + vi.resetModules() + }) + + it('hydrates referenced logs in valid dot-dot-prefixed Hermes subdirectories', async () => { + const logPath = join(hermesHome, '..logs', 'x-monitor.log') + await mkdir(dirname(logPath), { recursive: true }) + await writeFile(logPath, 'remote dot-dot-prefixed log line\n', 'utf-8') + + const { ExternalAutomationsHandler } = await import('./external-automations-handler') + const handler = new ExternalAutomationsHandler({ + onRequest: () => {} + } as unknown as RelayDispatcher) as unknown as ReferencedLogReader + + const result = await handler.readReferencedLogFile(`Latest log path: ${logPath} +Run summary: monitor automation completed successfully.`) + + expect(result?.content).toBe('remote dot-dot-prefixed log line\n') + }) +}) diff --git a/src/relay/external-automations-handler.test.ts b/src/relay/external-automations-handler.test.ts index 2228ff57c4d..d2a19d79c2d 100644 --- a/src/relay/external-automations-handler.test.ts +++ b/src/relay/external-automations-handler.test.ts @@ -195,4 +195,46 @@ describe('ExternalAutomationsHandler', () => { ).resolves.toEqual({ total: 2, runs: [] }) expect(readHermesRunRefs).toHaveBeenCalledTimes(2) }) + + it('evicts oldest remote Hermes count cache entries when many job ids are observed', async () => { + const { handler, requestHandlers } = createHandlerHarness() + const readHermesRunRefs = vi.fn(async (jobId: string) => + jobId === 'job-0' + ? [{ id: 'job-0:2026-05-15_09-00-00.md', run_at: '2026-05-15T09:00:00' }] + : [] + ) + const handlerInternals = handler as unknown as { + readHermesRunRefs: typeof readHermesRunRefs + } + handlerInternals.readHermesRunRefs = readHermesRunRefs + + await expect( + requestHandlers.get('externalAutomations.runs')?.({ + provider: 'hermes', + jobId: 'job-0', + page: 1, + pageSize: 0 + }) + ).resolves.toEqual({ total: 1, runs: [] }) + + for (let i = 1; i <= 200; i += 1) { + await requestHandlers.get('externalAutomations.runs')?.({ + provider: 'hermes', + jobId: `job-${i}`, + page: 1, + pageSize: 0 + }) + } + + await expect( + requestHandlers.get('externalAutomations.runs')?.({ + provider: 'hermes', + jobId: 'job-0', + page: 1, + pageSize: 0 + }) + ).resolves.toEqual({ total: 1, runs: [] }) + + expect(readHermesRunRefs).toHaveBeenCalledTimes(202) + }) }) diff --git a/src/relay/external-automations-handler.ts b/src/relay/external-automations-handler.ts index bc9956d1ff7..66635a9836c 100644 --- a/src/relay/external-automations-handler.ts +++ b/src/relay/external-automations-handler.ts @@ -5,9 +5,8 @@ import { existsSync } from 'fs' import { open, readdir, readFile, realpath, stat } from 'fs/promises' import { createRequire } from 'module' import { homedir } from 'os' -import { isAbsolute, join, relative, resolve } from 'path' +import { isAbsolute, join, relative, resolve, sep } from 'path' import { promisify } from 'util' -import type Database from 'better-sqlite3' import type { RelayDispatcher } from './dispatcher' const execFileAsync = promisify(execFile) @@ -23,11 +22,27 @@ const HERMES_OUTPUT_FILE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d const HERMES_RUN_KEY_PATTERN = /^(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})$/ const MAX_SESSION_OUTPUT_GAP_MS = 24 * 60 * 60 * 1000 const MAX_REFERENCED_LOG_BYTES = 5 * 1024 * 1024 +const HERMES_RUN_COUNT_CACHE_MAX_ENTRIES = 200 const FULL_SESSION_LOG_HEADING = '## Full session log' const REFERENCED_LOG_HEADING = '## Latest log file' const LATEST_LOG_PATH_PATTERN = /\bLatest log path:\s*(?(?:[A-Za-z]:[\\/]|\/)[^\r\n]*?)(?=\s+Run summary:|\r?\n|$)/i -type DatabaseConstructor = typeof Database +type SqliteStatement = { + get: (...args: unknown[]) => Record | undefined + all: (...args: unknown[]) => Record[] +} +type SqliteDatabase = { + prepare: (sql: string) => SqliteStatement + close: () => void +} +type DatabaseConstructor = new ( + path: string, + options?: { readonly?: boolean; fileMustExist?: boolean; timeout?: number } +) => SqliteDatabase +type NodeSqliteDatabaseSync = new ( + path: string, + options?: { readOnly?: boolean; timeout?: number } +) => SqliteDatabase let databaseConstructor: DatabaseConstructor | null | undefined type ExternalProvider = 'hermes' | 'openclaw' @@ -232,7 +247,11 @@ export class ExternalAutomationsHandler { const relativeToHermesHome = relative(resolve(homeRealPath), resolve(logRealPath)) // Why: the output body can contain agent-authored text, so only hydrate // referenced files that resolve inside Hermes' own data directory. - if (relativeToHermesHome.startsWith('..') || isAbsolute(relativeToHermesHome)) { + if ( + relativeToHermesHome === '..' || + relativeToHermesHome.startsWith(`..${sep}`) || + isAbsolute(relativeToHermesHome) + ) { return null } const logStat = await stat(logPath) @@ -475,10 +494,41 @@ export class ExternalAutomationsHandler { return databaseConstructor } try { - const loaded = requireOptional('better-sqlite3') as - | DatabaseConstructor - | { default?: DatabaseConstructor } - databaseConstructor = typeof loaded === 'function' ? loaded : (loaded.default ?? null) + // Why: the remote relay still targets Node 18. Hosts without node:sqlite + // should keep listing file-backed runs and simply omit DB transcripts. + const loaded = requireOptional('node:sqlite') as { + DatabaseSync?: NodeSqliteDatabaseSync + } + const DatabaseSync = loaded.DatabaseSync + if (typeof DatabaseSync !== 'function') { + databaseConstructor = null + return databaseConstructor + } + const SqliteDatabaseSync = DatabaseSync + databaseConstructor = class RelaySqliteDatabase { + private readonly db: SqliteDatabase + + constructor( + path: string, + options: { readonly?: boolean; fileMustExist?: boolean; timeout?: number } = {} + ) { + if (options.fileMustExist && !existsSync(path)) { + throw new Error(`SQLite database does not exist: ${path}`) + } + this.db = new SqliteDatabaseSync(path, { + readOnly: options.readonly, + timeout: options.timeout + }) + } + + prepare(sql: string): SqliteStatement { + return this.db.prepare(sql) + } + + close(): void { + this.db.close() + } + } } catch { databaseConstructor = null } @@ -523,6 +573,12 @@ export class ExternalAutomationsHandler { if (cached && cached.expiresAt > now) { return cached.promise } + if (cached) { + this.hermesRunCountCache.delete(jobId) + } + // Why: remote Hermes jobs can churn independently of Orca; relay + // processes are long-lived, so stale job ids need both TTL and a hard cap. + this.pruneHermesRunCountCache(now) const entry: HermesRunCountCacheEntry = { promise: this.readHermesRunRefs(jobId).then((refs) => refs.length), expiresAt: Number.POSITIVE_INFINITY @@ -540,6 +596,21 @@ export class ExternalAutomationsHandler { } } + private pruneHermesRunCountCache(now: number): void { + for (const [jobId, entry] of this.hermesRunCountCache) { + if (entry.expiresAt <= now) { + this.hermesRunCountCache.delete(jobId) + } + } + while (this.hermesRunCountCache.size >= HERMES_RUN_COUNT_CACHE_MAX_ENTRIES) { + const oldestJobId = this.hermesRunCountCache.keys().next().value + if (oldestJobId === undefined) { + return + } + this.hermesRunCountCache.delete(oldestJobId) + } + } + private clearHermesRunCountCache(jobId?: string): void { if (jobId) { this.hermesRunCountCache.delete(jobId) diff --git a/src/relay/fs-handler-git-fallback.ts b/src/relay/fs-handler-git-fallback.ts index ac8732e85ad..d4c46f35e9c 100644 --- a/src/relay/fs-handler-git-fallback.ts +++ b/src/relay/fs-handler-git-fallback.ts @@ -38,24 +38,26 @@ export function listFilesWithGit( ): Promise { const files = new Set() const { primary, ignoredPass } = buildGitLsFilesArgsForQuickOpen(excludePathPrefixes) + const children: { + child: ReturnType + isDone: () => boolean + reject: (error: Error) => void + }[] = [] const runGitLsFiles = (args: string[]): Promise => { return new Promise((resolve, reject) => { let buf = '' let done = false - const processLine = (line: string): void => { - if (line.charCodeAt(line.length - 1) === 13) { - line = line.substring(0, line.length - 1) - } - if (!line) { + const processPath = (path: string): void => { + if (!path) { return } - if (shouldExcludeQuickOpenRelPath(line, excludePathPrefixes)) { + if (shouldExcludeQuickOpenRelPath(path, excludePathPrefixes)) { return } - if (shouldIncludeQuickOpenPath(line)) { - files.add(line) + if (shouldIncludeQuickOpenPath(path)) { + files.add(path) } } @@ -64,59 +66,106 @@ export function listFilesWithGit( env: buildRelayCommandEnv(), stdio: ['ignore', 'pipe', 'pipe'] }) - child.stdout!.setEncoding('utf-8') - child.stdout!.on('data', (chunk: string) => { + let timer: ReturnType | null = null + const cleanup = (): void => { + if (timer) { + clearTimeout(timer) + timer = null + } + child.stdout!.off('data', handleStdoutData) + child.stderr!.off('data', handleStderrData) + child.off('error', handleError) + child.off('close', handleClose) + } + const rejectPass = (error: Error): void => { + if (done) { + return + } + done = true + buf = '' + cleanup() + reject(error) + } + const resolvePass = (): void => { + if (done) { + return + } + done = true + cleanup() + resolve() + } + children.push({ + child, + isDone: () => done, + reject: rejectPass + }) + + function handleStdoutData(chunk: string): void { buf += chunk let start = 0 - let idx = buf.indexOf('\n', start) + let idx = buf.indexOf('\0', start) while (idx !== -1) { - processLine(buf.substring(start, idx)) + processPath(buf.substring(start, idx)) start = idx + 1 - idx = buf.indexOf('\n', start) + idx = buf.indexOf('\0', start) } buf = start < buf.length ? buf.substring(start) : '' - }) - child.stderr!.on('data', () => { + } + function handleStderrData(): void { /* drain */ - }) - child.once('error', (err) => { + } + function handleError(err: Error): void { + rejectPass(err) + } + function handleClose(_code: number | null, signal: NodeJS.Signals | null): void { if (done) { return } - done = true - clearTimeout(timer) - buf = '' - reject(err) - }) - child.once('close', (_code, signal) => { - if (done) { - return - } - done = true - clearTimeout(timer) if (signal) { // Why: a signal exit means the child was killed (timeout or // external). Treat that as a load failure rather than silently // resolving with whatever git had managed to print. - buf = '' - reject(new Error(`git ls-files killed by ${signal}`)) + rejectPass(new Error(`git ls-files killed by ${signal}`)) return } if (buf) { - processLine(buf) + processPath(buf) } - resolve() - }) - const timer = setTimeout(() => { - buf = '' + resolvePass() + } + + child.stdout!.setEncoding('utf-8') + child.stdout!.on('data', handleStdoutData) + child.stderr!.on('data', handleStderrData) + child.once('error', handleError) + child.once('close', handleClose) + timer = setTimeout(() => { child.kill() + rejectPass(new Error('git ls-files timed out')) }, 10_000) }) } - return Promise.all([runGitLsFiles(primary), runGitLsFiles(ignoredPass)]).then(() => - Array.from(files) - ) + const killSurvivors = (): void => { + // Why: Promise.all returns after the first failed pass, but the sibling + // git process can keep streaming on SSH unless we cancel it explicitly. + for (const entry of children) { + if (entry.isDone()) { + continue + } + if (entry.child.exitCode === null && entry.child.signalCode === null) { + entry.child.kill() + } + entry.reject(new Error('git ls-files canceled after sibling failure')) + } + } + + return Promise.all([runGitLsFiles(primary), runGitLsFiles(ignoredPass)]) + .then(() => Array.from(files)) + .catch((err) => { + killSurvivors() + throw err + }) } /** @@ -134,50 +183,69 @@ export function searchWithGitGrep( let stdoutBuffer = '' let done = false - const resolveOnce = (): void => { + const child = spawn('git', gitArgs, { + cwd: rootPath, + env: buildRelayCommandEnv(), + stdio: ['ignore', 'pipe', 'pipe'] + }) + let killTimeout: ReturnType + + function resolveOnce(): void { if (done) { return } done = true clearTimeout(killTimeout) + // Why: child.kill() is advisory. If git ignores it, detach our + // closures so repeated relay searches do not retain old scans. + child.stdout!.off('data', handleStdoutData) + child.stderr!.off('data', handleStderrData) + child.off('error', handleError) + child.off('close', handleClose) resolve(finalize(acc)) } - const processLine = (line: string): void => { + function processLine(line: string): void { const verdict = ingestGitGrepLine(line, rootPath, matchRegex, acc, opts.maxResults) if (verdict === 'stop') { child.kill() } } - const child = spawn('git', gitArgs, { - cwd: rootPath, - env: buildRelayCommandEnv(), - stdio: ['ignore', 'pipe', 'pipe'] - }) - child.stdout!.setEncoding('utf-8') - child.stdout!.on('data', (chunk: string) => { + function handleStdoutData(chunk: string): void { stdoutBuffer += chunk const lines = stdoutBuffer.split('\n') stdoutBuffer = lines.pop() ?? '' for (const l of lines) { processLine(l) } - }) - child.stderr!.on('data', () => { + } + + function handleStderrData(): void { /* drain */ - }) - child.once('error', () => resolveOnce()) - child.once('close', () => { + } + + function handleError(): void { + resolveOnce() + } + + function handleClose(): void { if (stdoutBuffer) { processLine(stdoutBuffer) } resolveOnce() - }) + } - const killTimeout = setTimeout(() => { + child.stdout!.setEncoding('utf-8') + child.stdout!.on('data', handleStdoutData) + child.stderr!.on('data', handleStderrData) + child.once('error', handleError) + child.once('close', handleClose) + + killTimeout = setTimeout(() => { acc.truncated = true child.kill() + resolveOnce() }, SEARCH_TIMEOUT_MS) }) } diff --git a/src/relay/fs-handler-git-search.test.ts b/src/relay/fs-handler-git-search.test.ts new file mode 100644 index 00000000000..0272392cdef --- /dev/null +++ b/src/relay/fs-handler-git-search.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' + +const { spawnMock } = vi.hoisted(() => ({ + spawnMock: vi.fn() +})) + +vi.mock('child_process', () => ({ + spawn: spawnMock +})) + +import { EventEmitter } from 'events' +import type { ChildProcess } from 'child_process' +import { searchWithGitGrep } from './fs-handler-git-fallback' + +function createMockProcess(): ChildProcess { + const p = new EventEmitter() as unknown as ChildProcess + ;(p as unknown as Record).stdout = new EventEmitter() + ;( + (p as unknown as Record).stdout as EventEmitter & { + setEncoding: () => void + } + ).setEncoding = vi.fn() + ;(p as unknown as Record).stderr = new EventEmitter() + ;(p as unknown as Record).kill = vi.fn() + return p +} + +describe('relay git grep fallback', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('settles and detaches when git grep ignores the timeout kill', async () => { + vi.useFakeTimers() + + try { + const proc = createMockProcess() + spawnMock.mockReturnValue(proc) + + const promise = searchWithGitGrep('/remote/root', 'ok', { maxResults: 100 }) + + ;(proc.stdout as unknown as EventEmitter).emit('data', 'valid.ts\x001:ok\npartial') + + await vi.runOnlyPendingTimersAsync() + + const result = await promise + expect(result.truncated).toBe(true) + expect(result.files).toHaveLength(1) + expect(proc.kill).toHaveBeenCalled() + expect((proc.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) + expect((proc.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0) + expect(proc.listenerCount('error')).toBe(0) + expect(proc.listenerCount('close')).toBe(0) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/relay/fs-handler-list-files-ignored.test.ts b/src/relay/fs-handler-list-files-ignored.test.ts index 0eae4db8904..6329c78fcdc 100644 --- a/src/relay/fs-handler-list-files-ignored.test.ts +++ b/src/relay/fs-handler-list-files-ignored.test.ts @@ -12,6 +12,7 @@ import { EventEmitter } from 'events' import type { ChildProcess } from 'child_process' import { listFilesWithGit } from './fs-handler-git-fallback' import { listFilesWithRg } from './fs-handler-list-files' +import { searchWithRg } from './fs-handler-utils' function createMockProcess(): ChildProcess { const p = new EventEmitter() as unknown as ChildProcess @@ -83,19 +84,21 @@ describe('relay quick open ignored file listing', () => { const promise = listFilesWithGit('/remote/root', ['packages/other']) setTimeout(() => { - ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n') + ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\0') + ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'tab\tfile.txt\0') primaryProc.emit('close', 0, null) - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\n') - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'packages/other/src/x.ts\n') + ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\0') + ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'packages/other/src/x.ts\0') ignoredProc.emit('close', 0, null) }, 10) - await expect(promise).resolves.toEqual(['src/index.ts', 'dist/generated.js']) + await expect(promise).resolves.toEqual(['src/index.ts', 'tab\tfile.txt', 'dist/generated.js']) const ignoredArgs = spawnMock.mock.calls[1][1] as string[] expect(ignoredArgs).toEqual([ 'ls-files', + '-z', '--others', '--ignored', '--exclude-standard', @@ -119,13 +122,112 @@ describe('relay quick open ignored file listing', () => { const promise = listFilesWithGit('/remote/root') setTimeout(() => { - ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n') + ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\0') primaryProc.emit('close', 0, null) - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\n') + ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\0') ignoredProc.emit('close', null, 'SIGTERM') }, 10) await expect(promise).rejects.toThrow('git ls-files killed by SIGTERM') }) + + it('git fallback rejects when a timed-out child does not emit close', async () => { + vi.useFakeTimers() + try { + const primaryProc = createMockProcess() + const ignoredProc = createMockProcess() + let callIndex = 0 + + spawnMock.mockImplementation(() => { + callIndex++ + return callIndex === 1 ? primaryProc : ignoredProc + }) + + const promise = listFilesWithGit('/remote/root') + const outcomePromise = promise.then( + () => 'resolved', + (err: Error) => `rejected:${err.message}` + ) + + await vi.advanceTimersByTimeAsync(10_000) + const outcome = await Promise.race([outcomePromise, Promise.resolve('pending')]) + + expect(outcome).toContain('git ls-files timed out') + expect(primaryProc.kill).toHaveBeenCalled() + expect(ignoredProc.kill).toHaveBeenCalled() + expect((primaryProc.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) + expect((primaryProc.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0) + expect(primaryProc.listenerCount('error')).toBe(0) + expect(primaryProc.listenerCount('close')).toBe(0) + expect((ignoredProc.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) + expect((ignoredProc.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0) + expect(ignoredProc.listenerCount('error')).toBe(0) + expect(ignoredProc.listenerCount('close')).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('rg file listing rejects and detaches when a timed-out child does not emit close', async () => { + vi.useFakeTimers() + try { + const primaryProc = createMockProcess() + const ignoredProc = createMockProcess() + let callIndex = 0 + + spawnMock.mockImplementation(() => { + callIndex++ + return callIndex === 1 ? primaryProc : ignoredProc + }) + + const promise = listFilesWithRg('/remote/root') + const outcomePromise = promise.then( + () => 'resolved', + (err: Error) => `rejected:${err.message}` + ) + + await vi.advanceTimersByTimeAsync(25_000) + const outcome = await Promise.race([outcomePromise, Promise.resolve('pending')]) + + expect(outcome).toBe('rejected:rg list timed out') + expect(primaryProc.kill).toHaveBeenCalled() + expect(ignoredProc.kill).toHaveBeenCalled() + expect((primaryProc.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) + expect((primaryProc.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0) + expect(primaryProc.listenerCount('error')).toBe(0) + expect(primaryProc.listenerCount('close')).toBe(0) + expect((ignoredProc.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) + expect((ignoredProc.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0) + expect(ignoredProc.listenerCount('error')).toBe(0) + expect(ignoredProc.listenerCount('close')).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('rg search settles and detaches when a timed-out child does not emit close', async () => { + vi.useFakeTimers() + try { + const proc = createMockProcess() + spawnMock.mockReturnValue(proc) + + const promise = searchWithRg('/remote/root', 'ok', { maxResults: 100 }) + const outcomePromise = promise.then((result) => + result.truncated ? `truncated:${result.totalMatches}` : 'not-truncated' + ) + + await vi.runOnlyPendingTimersAsync() + const outcome = await Promise.race([outcomePromise, Promise.resolve('pending')]) + + expect(outcome).toBe('truncated:0') + expect(proc.kill).toHaveBeenCalled() + expect((proc.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) + expect((proc.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0) + expect(proc.listenerCount('error')).toBe(0) + expect(proc.listenerCount('close')).toBe(0) + } finally { + vi.useRealTimers() + } + }) }) diff --git a/src/relay/fs-handler-list-files.ts b/src/relay/fs-handler-list-files.ts index 9ccee533be4..a28d6a8be9d 100644 --- a/src/relay/fs-handler-list-files.ts +++ b/src/relay/fs-handler-list-files.ts @@ -32,7 +32,11 @@ export function listFilesWithRg( return new Promise((resolve, reject) => { const files = new Set() let done = false - const children: ChildProcess[] = [] + const children: { + child: ChildProcess + isDone: () => boolean + reject: (error: Error) => void + }[] = [] const { primary, ignoredPass } = buildRgArgsForQuickOpen({ // Why: rg only applies root-relative exclude globs as traversal pruning @@ -75,22 +79,48 @@ export function listFilesWithRg( cwd: rootPath, stdio: ['ignore', 'pipe', 'pipe'] }) - children.push(child) - - const timer = setTimeout(() => { + let timer: ReturnType | null = null + const cleanup = (): void => { + if (timer) { + clearTimeout(timer) + timer = null + } + child.stdout!.off('data', handleStdoutData) + child.stderr!.off('data', handleStderrData) + child.off('error', handleError) + child.off('close', handleClose) + } + const rejectPass = (error: Error): void => { if (passDone) { return } passDone = true + passBuf = '' + cleanup() + passReject(error) + } + const resolvePass = (): void => { + if (passDone) { + return + } + passDone = true + cleanup() + passResolve() + } + children.push({ + child, + isDone: () => passDone, + reject: rejectPass + }) + + timer = setTimeout(() => { // Discard residual buffer on abnormal exit — a truncated byte // sequence could look like a valid path. - passBuf = '' child.kill() - passReject(new Error('rg list timed out')) + rejectPass(new Error('rg list timed out')) }, LIST_FILES_TIMEOUT_MS) - child.stdout!.setEncoding('utf-8') - child.stdout!.on('data', (chunk: string) => { + function handleStdoutData(chunk: string): void { passBuf += chunk let start = 0 let idx = passBuf.indexOf('\n', start) @@ -102,31 +132,22 @@ export function listFilesWithRg( idx = passBuf.indexOf('\n', start) } passBuf = start < passBuf.length ? passBuf.substring(start) : '' - }) - child.stderr!.on('data', () => { + } + function handleStderrData(): void { /* drain to prevent backpressure stalls */ - }) - child.once('error', (err) => { + } + function handleError(err: Error): void { + rejectPass(err) + } + function handleClose(code: number | null, signal: NodeJS.Signals | null): void { if (passDone) { return } - passDone = true - clearTimeout(timer) - passBuf = '' - passReject(err) - }) - child.once('close', (code, signal) => { - if (passDone) { - return - } - passDone = true - clearTimeout(timer) // Why signal != null is a failure: the only way spawn gets a signal // is if the process was killed (timeout, OOM, external SIGKILL). // Trusting its stdout could surface a truncated list as a success. if (signal) { - passBuf = '' - passReject(new Error(`rg killed by ${signal}`)) + rejectPass(new Error(`rg killed by ${signal}`)) return } // Flush residual line only on clean exit. @@ -141,13 +162,19 @@ export function listFilesWithRg( // (bad flag, invalid glob). Only trust exit 2 when rg emitted at // least one parseable path — otherwise treat it as a real failure. if (code === 0 || code === 1) { - passResolve() + resolvePass() } else if (code === 2 && passFileCount > 0) { - passResolve() + resolvePass() } else { - passReject(new Error(`rg exited with code ${code}`)) + rejectPass(new Error(`rg exited with code ${code}`)) } - }) + } + + child.stdout!.setEncoding('utf-8') + child.stdout!.on('data', handleStdoutData) + child.stderr!.on('data', handleStderrData) + child.once('error', handleError) + child.once('close', handleClose) }) const killSurvivors = (): void => { @@ -155,10 +182,14 @@ export function listFilesWithRg( // but the sibling rg keeps running up to LIST_FILES_TIMEOUT_MS. Kill it // so repeated Quick Open opens don't pile up orphan rg processes on the // remote. - for (const child of children) { - if (child.exitCode === null && child.signalCode === null) { - child.kill() + for (const entry of children) { + if (entry.isDone()) { + continue } + if (entry.child.exitCode === null && entry.child.signalCode === null) { + entry.child.kill() + } + entry.reject(new Error('rg list canceled after sibling failure')) } } diff --git a/src/relay/fs-handler-rg-availability.test.ts b/src/relay/fs-handler-rg-availability.test.ts new file mode 100644 index 00000000000..7a4a28f8c93 --- /dev/null +++ b/src/relay/fs-handler-rg-availability.test.ts @@ -0,0 +1,50 @@ +import { EventEmitter } from 'events' +import { describe, expect, it, vi } from 'vitest' + +const { execFileMock, spawnMock } = vi.hoisted(() => ({ + execFileMock: vi.fn(), + spawnMock: vi.fn() +})) + +vi.mock('child_process', () => ({ + execFile: execFileMock, + spawn: spawnMock +})) + +import { checkRgAvailable } from './fs-handler-utils' + +class FakeChildProcess extends EventEmitter { + kill = vi.fn() +} + +describe('relay rg availability', () => { + it('removes listeners after a successful probe', async () => { + const child = new FakeChildProcess() + execFileMock.mockReturnValueOnce(child) + + const result = checkRgAvailable() + child.emit('close', 0) + + await expect(result).resolves.toBe(true) + expect(child.listenerCount('error')).toBe(0) + expect(child.listenerCount('close')).toBe(0) + }) + + it('settles and detaches when a wedged probe ignores timeout kill', async () => { + vi.useFakeTimers() + try { + const child = new FakeChildProcess() + execFileMock.mockReturnValueOnce(child) + + const result = checkRgAvailable() + await vi.advanceTimersByTimeAsync(5000) + + await expect(result).resolves.toBe(false) + expect(child.kill).toHaveBeenCalledTimes(1) + expect(child.listenerCount('error')).toBe(0) + expect(child.listenerCount('close')).toBe(0) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/relay/fs-handler-utils.ts b/src/relay/fs-handler-utils.ts index 0ea0fa1799f..f47be5a84cc 100644 --- a/src/relay/fs-handler-utils.ts +++ b/src/relay/fs-handler-utils.ts @@ -116,45 +116,64 @@ export function searchWithRg( return } - const resolveOnce = (): void => { + let killTimeout: ReturnType + + function resolveOnce(): void { if (resolved) { return } resolved = true clearTimeout(killTimeout) + // Why: child.kill() is advisory over SSH; detach listeners if the + // process ignores timeout kill so old searches cannot retain closures. + child.stdout!.off('data', handleStdoutData) + child.stderr!.off('data', handleStderrData) + child.off('error', handleError) + child.off('close', handleClose) resolve(finalize(acc)) } - const processLine = (line: string): void => { + function processLine(line: string): void { const verdict = ingestRgJsonLine(line, rootPath, acc, opts.maxResults) if (verdict === 'stop') { child.kill() } } - child.stdout!.setEncoding('utf-8') - child.stdout!.on('data', (chunk: string) => { + function handleStdoutData(chunk: string): void { buffer += chunk const lines = buffer.split('\n') buffer = lines.pop() ?? '' for (const line of lines) { processLine(line) } - }) - child.stderr!.on('data', () => { + } + + function handleStderrData(): void { /* drain */ - }) - child.once('error', () => resolveOnce()) - child.once('close', () => { + } + + function handleError(): void { + resolveOnce() + } + + function handleClose(): void { if (buffer) { processLine(buffer) } resolveOnce() - }) + } - const killTimeout = setTimeout(() => { + child.stdout!.setEncoding('utf-8') + child.stdout!.on('data', handleStdoutData) + child.stderr!.on('data', handleStderrData) + child.once('error', handleError) + child.once('close', handleClose) + + killTimeout = setTimeout(() => { acc.truncated = true child.kill() + resolveOnce() }, SEARCH_TIMEOUT_MS) }) } @@ -167,24 +186,41 @@ export function searchWithRg( // was uninstalled or broken mid-session. The `settled` flag below closes // the original race between 'error' and 'close' that the cache was added // to paper over, so re-checking per call is both simpler and safer. +const RG_AVAILABILITY_TIMEOUT_MS = 5000 + export function checkRgAvailable(): Promise { return new Promise((resolve) => { let settled = false const child = execFile('rg', ['--version']) - child.once('error', () => { + let timeout: ReturnType | null = null + const cleanup = (): void => { + if (timeout) { + clearTimeout(timeout) + timeout = null + } + child.off('error', onError) + child.off('close', onClose) + } + const settle = (available: boolean, options?: { kill?: boolean }): void => { if (settled) { return } settled = true - resolve(false) - }) - child.once('close', (code) => { - if (settled) { - return + cleanup() + if (options?.kill) { + child.kill() } - settled = true - resolve(code === 0) - }) + resolve(available) + } + const onError = (): void => settle(false) + const onClose = (code: number | null): void => settle(code === 0) + + child.once('error', onError) + child.once('close', onClose) + timeout = setTimeout(() => settle(false, { kill: true }), RG_AVAILABILITY_TIMEOUT_MS) + if (typeof timeout.unref === 'function') { + timeout.unref() + } }) } diff --git a/src/relay/git-exec-validator.test.ts b/src/relay/git-exec-validator.test.ts index b808e692b8a..9f12c83d1f4 100644 --- a/src/relay/git-exec-validator.test.ts +++ b/src/relay/git-exec-validator.test.ts @@ -31,6 +31,7 @@ describe('validateGitExecArgs', () => { [['config', '--list']], [['config', '-l']], [['config', '--get-regexp', 'user']], + [['check-ref-format', '--branch', 'feature/ssh-pr-head']], [['for-each-ref', '--format=%(refname)', 'refs/remotes']], [ [ diff --git a/src/relay/git-exec-validator.ts b/src/relay/git-exec-validator.ts index 0c8df69a4da..aa373957081 100644 --- a/src/relay/git-exec-validator.ts +++ b/src/relay/git-exec-validator.ts @@ -19,6 +19,7 @@ const ALLOWED_GIT_SUBCOMMANDS = new Set([ 'diff', 'ls-files', 'for-each-ref', + 'check-ref-format', 'config' ]) const CONFIG_READ_ONLY_FLAGS = new Set(['--get', '--get-all', '--list', '--get-regexp', '-l']) diff --git a/src/relay/git-handler-status-ops.test.ts b/src/relay/git-handler-status-ops.test.ts new file mode 100644 index 00000000000..c966ce1a97e --- /dev/null +++ b/src/relay/git-handler-status-ops.test.ts @@ -0,0 +1,56 @@ +import { mkdtempSync } from 'fs' +import * as fs from 'fs/promises' +import { tmpdir } from 'os' +import * as path from 'path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { GitExec } from './git-handler-ops' +import { getStatusOp } from './git-handler-status-ops' + +const LARGE_STATUS_ENTRY_COUNT = 150_000 + +function buildLargeStatusOutput(count: number): string { + const lines: string[] = [] + for (let index = 0; index < count; index += 1) { + lines.push(`1 A. N... 100644 100644 100644 000000 111111 generated-${index}.txt`) + } + return lines.join('\n') +} + +describe('getStatusOp', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-git-status-')) + }) + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + it('returns large parsed status entry lists', async () => { + const statusOutput = buildLargeStatusOutput(LARGE_STATUS_ENTRY_COUNT) + const git = vi.fn(async (args) => { + if (args.includes('status')) { + return { stdout: statusOutput, stderr: '' } + } + if (args.includes('diff')) { + return { stdout: '', stderr: '' } + } + throw new Error(`Unexpected git command: ${args.join(' ')}`) + }) + + const result = await getStatusOp(git, { worktreePath: tmpDir }) + + expect(result.entries).toHaveLength(LARGE_STATUS_ENTRY_COUNT) + expect(result.entries[0]).toEqual({ + path: 'generated-0.txt', + status: 'added', + area: 'staged' + }) + expect(result.entries.at(-1)).toEqual({ + path: `generated-${LARGE_STATUS_ENTRY_COUNT - 1}.txt`, + status: 'added', + area: 'staged' + }) + }) +}) diff --git a/src/relay/git-handler-status-ops.ts b/src/relay/git-handler-status-ops.ts index 02cfc3f5195..3c4b9c4ee8f 100644 --- a/src/relay/git-handler-status-ops.ts +++ b/src/relay/git-handler-status-ops.ts @@ -99,7 +99,11 @@ export async function getStatusOp( disableOptionalLocks: true }) const parsed = parseStatusOutput(stdout) - entries.push(...parsed.entries) + // Why: huge worktrees can produce enough status rows to exceed JavaScript's + // spread-argument limit during routine source-control polling. + for (const entry of parsed.entries) { + entries.push(entry) + } head = parsed.head branch = parsed.branch upstreamStatus = parsed.upstreamStatus diff --git a/src/relay/git-handler-utils.ts b/src/relay/git-handler-utils.ts index ce7bcea9a74..e51e99a1223 100644 --- a/src/relay/git-handler-utils.ts +++ b/src/relay/git-handler-utils.ts @@ -133,15 +133,37 @@ export function parseBranchDiff( // ─── Worktree parsing ──────────────────────────────────────────────── -export function parseWorktreeList(output: string): Record[] { - const worktrees: Record[] = [] - const blocks = output.trim().split(/\r?\n\r?\n/) +function getErrorText(error: unknown): string { + if (typeof error === 'object' && error !== null) { + const parts: string[] = [] + if ('message' in error && typeof error.message === 'string') { + parts.push(error.message) + } + if ('stderr' in error && typeof error.stderr === 'string') { + parts.push(error.stderr) + } + return parts.join('\n') + } + return String(error) +} - for (const block of blocks) { - if (!block.trim()) { +export function isUnsupportedWorktreeListZError(error: unknown): boolean { + return /(?:unknown|invalid) (?:switch|option).*`?-z'?|(?:unknown|invalid) (?:switch|option).*`?z'?/i.test( + getErrorText(error) + ) +} + +export function parseWorktreeList( + output: string, + options: { nulDelimited?: boolean } = {} +): Record[] { + const worktrees: Record[] = [] + const blocks = options.nulDelimited ? splitNulWorktreeList(output) : splitLineWorktreeList(output) + + for (const lines of blocks) { + if (lines.length === 0) { continue } - const lines = block.trim().split(/\r?\n/) let wtPath = '' let head = '' let branch = '' @@ -172,6 +194,39 @@ export function parseWorktreeList(output: string): Record[] { return worktrees } +function splitLineWorktreeList(output: string): string[][] { + return output + .trim() + .split(/\r?\n\r?\n/) + .map((block) => block.trim().split(/\r?\n/)) +} + +function splitNulWorktreeList(output: string): string[][] { + if (!output.includes('\0')) { + return splitLineWorktreeList(output) + } + + const blocks: string[][] = [] + let currentBlock: string[] = [] + + for (const field of output.split('\0')) { + if (field) { + currentBlock.push(field) + continue + } + if (currentBlock.length > 0) { + blocks.push(currentBlock) + currentBlock = [] + } + } + + if (currentBlock.length > 0) { + blocks.push(currentBlock) + } + + return blocks +} + // ─── Binary / blob helpers ─────────────────────────────────────────── export const PREVIEWABLE_MIME: Record = { diff --git a/src/relay/git-handler-worktree-clean.test.ts b/src/relay/git-handler-worktree-clean.test.ts new file mode 100644 index 00000000000..f351c0bd50b --- /dev/null +++ b/src/relay/git-handler-worktree-clean.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from 'vitest' +import type { GitExec } from './git-handler-ops' +import { worktreeIsCleanOp } from './git-handler-worktree-ops' + +describe('worktreeIsCleanOp', () => { + it('reports clean SSH worktrees without returning porcelain output', async () => { + const git = vi.fn(async () => ({ stdout: '\n', stderr: '' })) + + await expect(worktreeIsCleanOp(git, { worktreePath: '/repo-feature' })).resolves.toEqual({ + clean: true, + stdout: undefined + }) + + expect(git).toHaveBeenCalledWith( + ['status', '--porcelain', '--untracked-files=all'], + '/repo-feature' + ) + }) + + it('returns porcelain output for dirty SSH worktrees', async () => { + const stdout = ' M src/file.ts\n?? scratch.txt\n' + const git = vi.fn(async () => ({ stdout, stderr: '' })) + + await expect(worktreeIsCleanOp(git, { worktreePath: '/repo-feature' })).resolves.toEqual({ + clean: false, + stdout + }) + }) +}) diff --git a/src/relay/git-handler-worktree-ops.test.ts b/src/relay/git-handler-worktree-ops.test.ts index d1cad513897..e8bc8c0ebb4 100644 --- a/src/relay/git-handler-worktree-ops.test.ts +++ b/src/relay/git-handler-worktree-ops.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { GitExec } from './git-handler-ops' -import { addWorktreeOp, removeWorktreeOp, worktreeIsCleanOp } from './git-handler-worktree-ops' +import { addWorktreeOp, removeWorktreeOp } from './git-handler-worktree-ops' function worktreeList(...entries: { path: string; branch?: string }[]): string { return entries @@ -151,11 +151,11 @@ describe('removeWorktreeOp', () => { expect(calls).toEqual([ '/repo-feature$ rev-parse --git-common-dir', - '/repo$ worktree list --porcelain', + '/repo$ worktree list --porcelain -z', '/repo$ worktree remove /repo-feature', '/repo$ worktree prune', - '/repo$ worktree list --porcelain', - '/repo$ branch -d feature/test' + '/repo$ worktree list --porcelain -z', + '/repo$ branch -d -- feature/test' ]) }) @@ -184,11 +184,13 @@ describe('removeWorktreeOp', () => { return { stdout: '', stderr: '' } }) - // The unmerged-branch refusal must be swallowed, not propagated. - await expect(removeWorktreeOp(git, { worktreePath: '/repo-feature' })).resolves.toBeUndefined() + // The unmerged-branch refusal must be surfaced without failing workspace removal. + await expect(removeWorktreeOp(git, { worktreePath: '/repo-feature' })).resolves.toEqual({ + preservedBranch: { branchName: 'feature/test', head: '1' } + }) - expect(git).toHaveBeenCalledWith(['branch', '-d', 'feature/test'], expect.any(String)) - expect(git).not.toHaveBeenCalledWith(['branch', '-D', 'feature/test'], expect.any(String)) + expect(git).toHaveBeenCalledWith(['branch', '-d', '--', 'feature/test'], expect.any(String)) + expect(git).not.toHaveBeenCalledWith(['branch', '-D', '--', 'feature/test'], expect.any(String)) }) it('force-deletes the just-created branch during failed sparse setup rollback', async () => { @@ -219,8 +221,8 @@ describe('removeWorktreeOp', () => { forceBranchDelete: true }) - expect(git).toHaveBeenCalledWith(['branch', '-D', 'feature/test'], expect.any(String)) - expect(git).not.toHaveBeenCalledWith(['branch', '-d', 'feature/test'], expect.any(String)) + expect(git).toHaveBeenCalledWith(['branch', '-D', '--', 'feature/test'], expect.any(String)) + expect(git).not.toHaveBeenCalledWith(['branch', '-d', '--', 'feature/test'], expect.any(String)) }) it('skips branch deletion entirely when deleteBranch is false', async () => { @@ -246,7 +248,7 @@ describe('removeWorktreeOp', () => { expect(calls).toEqual([ '/repo-feature$ rev-parse --git-common-dir', - '/repo$ worktree list --porcelain', + '/repo$ worktree list --porcelain -z', '/repo$ worktree remove /repo-feature', '/repo$ worktree prune' ]) @@ -279,33 +281,7 @@ describe('removeWorktreeOp', () => { await removeWorktreeOp(git, { worktreePath: '/repo-feature' }) - expect(git).not.toHaveBeenCalledWith(['branch', '-d', 'feature/test'], expect.any(String)) - expect(git).not.toHaveBeenCalledWith(['branch', '-D', 'feature/test'], expect.any(String)) - }) -}) - -describe('worktreeIsCleanOp', () => { - it('reports clean SSH worktrees without returning porcelain output', async () => { - const git = vi.fn(async () => ({ stdout: '\n', stderr: '' })) - - await expect(worktreeIsCleanOp(git, { worktreePath: '/repo-feature' })).resolves.toEqual({ - clean: true, - stdout: undefined - }) - - expect(git).toHaveBeenCalledWith( - ['status', '--porcelain', '--untracked-files=all'], - '/repo-feature' - ) - }) - - it('returns porcelain output for dirty SSH worktrees', async () => { - const stdout = ' M src/file.ts\n?? scratch.txt\n' - const git = vi.fn(async () => ({ stdout, stderr: '' })) - - await expect(worktreeIsCleanOp(git, { worktreePath: '/repo-feature' })).resolves.toEqual({ - clean: false, - stdout - }) + expect(git).not.toHaveBeenCalledWith(['branch', '-d', '--', 'feature/test'], expect.any(String)) + expect(git).not.toHaveBeenCalledWith(['branch', '-D', '--', 'feature/test'], expect.any(String)) }) }) diff --git a/src/relay/git-handler-worktree-ops.ts b/src/relay/git-handler-worktree-ops.ts index 3d6c1b8f82e..50e343b824e 100644 --- a/src/relay/git-handler-worktree-ops.ts +++ b/src/relay/git-handler-worktree-ops.ts @@ -5,9 +5,10 @@ * the oxlint max-lines (300) limit. */ import * as path from 'path' +import type { RemoveWorktreeResult } from '../shared/types' import { resolveWorktreeAddBaseRef } from '../shared/worktree-base-ref' import type { GitExec } from './git-handler-ops' -import { parseWorktreeList } from './git-handler-utils' +import { isUnsupportedWorktreeListZError, parseWorktreeList } from './git-handler-utils' // ─── Worktree management ───────────────────────────────────────────── @@ -121,7 +122,7 @@ export async function addWorktreeOp(git: GitExec, params: Record -): Promise { +): Promise { const worktreePath = params.worktreePath as string const force = params.force as boolean | undefined const deleteBranch = params.deleteBranch !== false @@ -143,6 +144,7 @@ export async function removeWorktreeOp( areRelayWorktreePathsEqual(worktree.path, worktreePath) ) const branchName = normalizeLocalBranchRef(removedWorktree?.branch ?? '') + const branchHead = removedWorktree?.head ?? '' const args = ['worktree', 'remove'] if (force) { @@ -153,10 +155,10 @@ export async function removeWorktreeOp( await git(['worktree', 'prune'], repoPath) if (!branchName) { - return + return {} } if (!deleteBranch) { - return + return {} } // Why: SSH worktree deletion should mirror local deletion. Dropping the @@ -167,7 +169,7 @@ export async function removeWorktreeOp( (worktree) => normalizeLocalBranchRef(worktree.branch ?? '') === branchName ) if (branchStillInUse) { - return + return {} } try { @@ -175,35 +177,58 @@ export async function removeWorktreeOp( // refuses to delete a branch with commits not merged into its upstream or // HEAD, so unpublished work on a remote worktree is preserved rather than // force-deleted. forceBranchDelete is reserved for failed create rollback. - await git(['branch', forceBranchDelete ? '-D' : '-d', branchName], repoPath) + await git(['branch', forceBranchDelete ? '-D' : '-d', '--', branchName], repoPath) + return {} } catch (error) { // Expected when the branch still has unmerged/unpublished commits: keep it. console.warn( `relay removeWorktree: preserved local branch "${branchName}" after removing worktree (not fully merged)`, error ) + return { preservedBranch: { branchName, ...(branchHead ? { head: branchHead } : {}) } } } } type RelayWorktreeInfo = { path: string branch?: string + head?: string } async function listRelayWorktrees(git: GitExec, repoPath: string): Promise { try { - const { stdout } = await git(['worktree', 'list', '--porcelain'], repoPath) - return parseWorktreeList(stdout) - .map((worktree) => ({ - path: typeof worktree.path === 'string' ? worktree.path : '', - branch: typeof worktree.branch === 'string' ? worktree.branch : undefined - })) - .filter((worktree) => worktree.path.length > 0) + return await readRelayWorktreeList(git, repoPath) } catch { return [] } } +async function readRelayWorktreeList(git: GitExec, repoPath: string): Promise { + try { + const { stdout } = await git(['worktree', 'list', '--porcelain', '-z'], repoPath) + return normalizeRelayWorktrees(parseWorktreeList(stdout, { nulDelimited: true })) + } catch (error) { + if (!isUnsupportedWorktreeListZError(error)) { + throw error + } + } + + // Why: `-z` preserves remote worktree paths containing newlines, while this + // fallback keeps SSH worktree operations compatible with Git <2.36. + const { stdout } = await git(['worktree', 'list', '--porcelain'], repoPath) + return normalizeRelayWorktrees(parseWorktreeList(stdout)) +} + +function normalizeRelayWorktrees(worktrees: Record[]): RelayWorktreeInfo[] { + return worktrees + .map((worktree) => ({ + path: typeof worktree.path === 'string' ? worktree.path : '', + head: typeof worktree.head === 'string' ? worktree.head : undefined, + branch: typeof worktree.branch === 'string' ? worktree.branch : undefined + })) + .filter((worktree) => worktree.path.length > 0) +} + function normalizeLocalBranchRef(branch: string): string { return branch.replace(/^refs\/heads\//, '') } diff --git a/src/relay/git-handler-worktree-paths.test.ts b/src/relay/git-handler-worktree-paths.test.ts new file mode 100644 index 00000000000..acae574b876 --- /dev/null +++ b/src/relay/git-handler-worktree-paths.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest' +import type { GitExec } from './git-handler-ops' +import { removeWorktreeOp } from './git-handler-worktree-ops' + +function lineWorktreeList(...entries: { path: string; branch?: string }[]): string { + return entries + .map((entry, index) => + [ + `worktree ${entry.path}`, + `HEAD ${index}`, + ...(entry.branch ? [`branch refs/heads/${entry.branch}`] : []) + ].join('\n') + ) + .join('\n\n') +} + +function nulWorktreeList(...entries: { path: string; branch?: string }[]): string { + return entries + .map((entry, index) => + [ + `worktree ${entry.path}`, + `HEAD ${index}`, + ...(entry.branch ? [`branch refs/heads/${entry.branch}`] : []), + '' + ].join('\0') + ) + .join('\0') +} + +describe('relay worktree path parsing', () => { + it('deletes the matching branch for SSH worktrees whose paths contain newlines', async () => { + const worktreePath = '/repo-feature\nremote' + let listCount = 0 + const git = vi.fn(async (args) => { + if (args[0] === 'rev-parse') { + return { stdout: '/repo/.git\n', stderr: '' } + } + if (args[0] === 'worktree' && args[1] === 'list') { + listCount += 1 + return { + stdout: + listCount === 1 + ? nulWorktreeList( + { path: '/repo', branch: 'main' }, + { path: worktreePath, branch: 'feature/newline' } + ) + : nulWorktreeList({ path: '/repo', branch: 'main' }), + stderr: '' + } + } + return { stdout: '', stderr: '' } + }) + + await removeWorktreeOp(git, { worktreePath }) + + expect(git).toHaveBeenCalledWith(['branch', '-d', '--', 'feature/newline'], '/repo') + }) + + it('falls back to line-block worktree listing when remote Git rejects -z', async () => { + const calls: string[] = [] + let listCount = 0 + const git = vi.fn(async (args, cwd) => { + calls.push(`${cwd}$ ${args.join(' ')}`) + if (args[0] === 'rev-parse') { + return { stdout: '/repo/.git\n', stderr: '' } + } + if (args[0] === 'worktree' && args[1] === 'list' && args.includes('-z')) { + throw Object.assign(new Error("unknown switch `z'"), { + stderr: "error: unknown switch `z'" + }) + } + if (args[0] === 'worktree' && args[1] === 'list') { + listCount += 1 + return { + stdout: + listCount === 1 + ? lineWorktreeList( + { path: '/repo', branch: 'main' }, + { path: '/repo-feature', branch: 'feature/test' } + ) + : lineWorktreeList({ path: '/repo', branch: 'main' }), + stderr: '' + } + } + return { stdout: '', stderr: '' } + }) + + await removeWorktreeOp(git, { worktreePath: '/repo-feature' }) + + expect(calls).toEqual([ + '/repo-feature$ rev-parse --git-common-dir', + '/repo$ worktree list --porcelain -z', + '/repo$ worktree list --porcelain', + '/repo$ worktree remove /repo-feature', + '/repo$ worktree prune', + '/repo$ worktree list --porcelain -z', + '/repo$ worktree list --porcelain', + '/repo$ branch -d -- feature/test' + ]) + }) +}) diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts index 326e42059a4..7bcef632f66 100644 --- a/src/relay/git-handler.test.ts +++ b/src/relay/git-handler.test.ts @@ -20,14 +20,14 @@ import { describe('GitHandler', () => { let dispatcher: MockDispatcher + let handler: GitHandler let tmpDir: string beforeEach(() => { tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-git-')) dispatcher = createMockDispatcher() const ctx = new RelayContext() - // eslint-disable-next-line no-new - new GitHandler(dispatcher as unknown as RelayDispatcher, ctx) + handler = new GitHandler(dispatcher as unknown as RelayDispatcher, ctx) }) afterEach(async () => { @@ -62,6 +62,7 @@ describe('GitHandler', () => { expect(methods).toContain('git.listWorktrees') expect(methods).toContain('git.addWorktree') expect(methods).toContain('git.removeWorktree') + expect(methods).toContain('git.worktreeIsClean') expect(methods).toContain('git.renameCurrentBranch') expect(methods).toContain('git.exec') expect(methods).toContain('git.isGitRepo') @@ -424,6 +425,36 @@ describe('GitHandler', () => { expect(result.originalContent).toBe('original') expect(result.modifiedContent).toBe('staged-content') }) + + it('returns diff for tracked files in valid dot-dot-prefixed directories', async () => { + gitInit(tmpDir) + mkdirSync(path.join(tmpDir, '..fixtures')) + writeFileSync(path.join(tmpDir, '..fixtures', 'file.txt'), 'original') + gitCommit(tmpDir, 'initial') + writeFileSync(path.join(tmpDir, '..fixtures', 'file.txt'), 'modified') + + const result = (await dispatcher.callRequest('git.diff', { + worktreePath: tmpDir, + filePath: '..fixtures/file.txt', + staged: false + })) as { kind: string; originalContent: string; modifiedContent: string } + + expect(result.kind).toBe('text') + expect(result.originalContent).toBe('original') + expect(result.modifiedContent).toBe('modified') + }) + + it('rejects diff paths that traverse outside the worktree', async () => { + gitInit(tmpDir) + + await expect( + dispatcher.callRequest('git.diff', { + worktreePath: tmpDir, + filePath: '../outside.txt', + staged: false + }) + ).rejects.toThrow('outside the worktree') + }) }) describe('discard', () => { @@ -498,6 +529,32 @@ describe('GitHandler', () => { await expect(fs.access(path.join(tmpDir, 'new.txt'))).rejects.toThrow() }) + it('handles large tracked path lists during bulk discard classification', async () => { + const trackedStdout = Array.from({ length: 150_000 }, (_, index) => `docs/file-${index}.ts`) + .join('\0') + .concat('\0') + const gitMock = vi + .spyOn( + handler as unknown as { + git: (args: string[], cwd: string) => Promise<{ stdout: string; stderr: string }> + }, + 'git' + ) + .mockResolvedValueOnce({ stdout: trackedStdout, stderr: '' }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + + await dispatcher.callRequest('git.bulkDiscard', { + worktreePath: tmpDir, + filePaths: ['docs'] + }) + + expect(gitMock).toHaveBeenNthCalledWith( + 2, + ['restore', '--worktree', '--source=HEAD', '--', ':(literal)docs'], + tmpDir + ) + }) + it('rejects path traversal', async () => { gitInit(tmpDir) await expect( @@ -1010,6 +1067,39 @@ describe('GitHandler', () => { expect(result.length).toBeGreaterThanOrEqual(1) expect(result[0].isMainWorktree).toBe(true) }) + + it.skipIf(process.platform === 'win32')( + 'lists worktrees whose paths contain newlines', + async () => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'file.txt'), 'hello') + gitCommit(tmpDir, 'initial') + const worktreePath = path.join( + path.dirname(tmpDir), + `${path.basename(tmpDir)}-linked\nremote` + ) + + try { + execFileSync( + 'git', + ['worktree', 'add', '--quiet', '-b', 'feature/newline', worktreePath], + { + cwd: tmpDir, + stdio: 'pipe' + } + ) + const realWorktreePath = await fs.realpath(worktreePath) + + const result = (await dispatcher.callRequest('git.listWorktrees', { + repoPath: tmpDir + })) as Record[] + + expect(result.map((worktree) => worktree.path)).toContain(realWorktreePath) + } finally { + await fs.rm(worktreePath, { recursive: true, force: true }) + } + } + ) }) describe('addWorktree', () => { diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index b532ab070ff..2abfbd07008 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -6,7 +6,11 @@ import * as path from 'path' import type { RelayDispatcher } from './dispatcher' import type { RelayContext } from './context' import { expandTilde } from './context' -import { parseBranchDiff, parseWorktreeList } from './git-handler-utils' +import { + isUnsupportedWorktreeListZError, + parseBranchDiff, + parseWorktreeList +} from './git-handler-utils' import { parseNumstat } from '../shared/git-uncommitted-line-stats' import { computeDiff, @@ -139,7 +143,7 @@ export class GitHandler { // path.join. Without validation, ../../etc/passwd traverses outside the worktree. const resolved = path.resolve(worktreePath, filePath) const rel = path.relative(path.resolve(worktreePath), resolved) - if (rel.startsWith('..') || path.isAbsolute(rel)) { + if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) { throw new Error(`Path "${filePath}" resolves outside the worktree`) } return computeDiff( @@ -276,7 +280,13 @@ export class GitHandler { ['ls-files', '-z', '--', ...chunk.map((p) => this.literalPathspec(p))], worktreePath ) - trackedPathSpecs.push(...stdout.split('\0').filter(Boolean)) + // Why: selecting a tracked directory can make `ls-files -z` return + // enough descendants for push(...split) to exceed the argument limit. + for (const trackedPathSpec of stdout.split('\0')) { + if (trackedPathSpec) { + trackedPathSpecs.push(trackedPathSpec) + } + } } const trackedPaths = filePaths.filter((filePath) => @@ -611,6 +621,17 @@ export class GitHandler { private async listWorktrees(params: Record) { const repoPath = params.repoPath as string + try { + const { stdout } = await this.git(['worktree', 'list', '--porcelain', '-z'], repoPath) + return parseWorktreeList(stdout, { nulDelimited: true }) + } catch (error) { + if (!isUnsupportedWorktreeListZError(error)) { + return [] + } + } + + // Why: `-z` keeps newline-containing SSH worktree paths intact, but older + // Git rejects it. Fall back to the original line-block parser there. try { const { stdout } = await this.git(['worktree', 'list', '--porcelain'], repoPath) return parseWorktreeList(stdout) diff --git a/src/relay/plugin-overlay.test.ts b/src/relay/plugin-overlay.test.ts index 2adad59e5de..422f0b0cee0 100644 --- a/src/relay/plugin-overlay.test.ts +++ b/src/relay/plugin-overlay.test.ts @@ -104,6 +104,17 @@ describe('PluginOverlayManager', () => { writeFileSync(join(piAgentDir, 'auth.json'), 'secret token') writeFileSync(join(piAgentDir, 'skills', 'my-skill', 'SKILL.md'), 'critical user skill') writeFileSync(join(piAgentDir, 'extensions', 'user-ext', 'ext.ts'), 'user extension') + writeFileSync( + join(piAgentDir, 'settings.json'), + JSON.stringify({ + defaultProvider: 'amazon-bedrock', + hideThinkingBlock: false, + terminal: { + showImages: false, + clearOnShrink: false + } + }) + ) manager.setSources({ piExtensionSource: '// pi extension' }) const dir = manager.materializePi('tab-pi:0') @@ -120,6 +131,22 @@ describe('PluginOverlayManager', () => { 'orca-agent-status.ts', 'user-ext' ]) + expect(JSON.parse(readFileSync(join(dir!, 'settings.json'), 'utf8'))).toEqual({ + defaultProvider: 'amazon-bedrock', + hideThinkingBlock: true, + terminal: { + showImages: false, + clearOnShrink: true + } + }) + expect(JSON.parse(readFileSync(join(piAgentDir, 'settings.json'), 'utf8'))).toEqual({ + defaultProvider: 'amazon-bedrock', + hideThinkingBlock: false, + terminal: { + showImages: false, + clearOnShrink: false + } + }) }) it('mirrors a preexisting remote Pi agent dir instead of the default', () => { @@ -208,6 +235,10 @@ describe('PluginOverlayManager', () => { expect(existsSync(join(dir!, 'auth.json'))).toBe(false) const overlayExtensions = readdirSync(join(dir!, 'extensions')).sort() expect(overlayExtensions).toEqual(['orca-agent-status.ts']) + expect(JSON.parse(readFileSync(join(dir!, 'settings.json'), 'utf8'))).toEqual({ + hideThinkingBlock: true, + terminal: { clearOnShrink: true } + }) }) }) diff --git a/src/relay/plugin-overlay.ts b/src/relay/plugin-overlay.ts index f4904898f2a..745664a969f 100644 --- a/src/relay/plugin-overlay.ts +++ b/src/relay/plugin-overlay.ts @@ -20,6 +20,7 @@ import { createHash } from 'crypto' import { existsSync, mkdirSync, + readFileSync, readdirSync, realpathSync, statSync, @@ -29,6 +30,7 @@ import { import { homedir } from 'os' import { basename, join } from 'path' import { mirrorEntry, safeRemoveOverlay } from '../main/pty/overlay-mirror' +import { mergePiOverlayUiSettings } from '../shared/pi-overlay-ui-settings' import type { PiAgentKind } from '../shared/pi-agent-kind' const RELAY_HOOKS_DIR = '.orca-relay' @@ -40,6 +42,7 @@ const PI_OVERLAY_SUBDIR_BY_KIND: Record = { const OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' const PI_EXTENSION_FILE = 'orca-agent-status.ts' const PI_AGENT_SUBDIR = 'agent' +const PI_AGENT_SETTINGS_FILE = 'settings.json' // Why: source-dir resolution is keyed off the launching agent (Pi or OMP). // Both consume `PI_CODING_AGENT_DIR` but default to different `~/./agent` // paths on the remote disk. The renderer-chosen launch command flows in via @@ -223,6 +226,10 @@ export class PluginOverlayManager { for (const entry of readdirSync(sourceAgentDir, { withFileTypes: true })) { const sourcePath = join(sourceAgentDir, entry.name) + if (entry.name === PI_AGENT_SETTINGS_FILE) { + continue + } + if (entry.name === 'extensions') { const isSymlink = entry.isSymbolicLink() let isLinkPointingToDir = false @@ -255,6 +262,29 @@ export class PluginOverlayManager { } } + private readPiSettings(sourceAgentDir: string): unknown { + const settingsPath = join(sourceAgentDir, PI_AGENT_SETTINGS_FILE) + if (!existsSync(settingsPath)) { + return {} + } + + try { + return JSON.parse(readFileSync(settingsPath, 'utf8')) + } catch { + return {} + } + } + + private writePiOverlaySettings(sourceAgentDir: string, overlayDir: string): void { + // Why: relay overlays run on the remote disk, but the same Pi UI guardrails + // need to stay overlay-local so SSH sessions do not mutate user config. + const settings = mergePiOverlayUiSettings(this.readPiSettings(sourceAgentDir)) + writeFileSync( + join(overlayDir, PI_AGENT_SETTINGS_FILE), + `${JSON.stringify(settings, null, 2)}\n` + ) + } + /** Materialize the Pi extension overlay for `id` and return the directory * path that should be assigned to PI_CODING_AGENT_DIR. `kind` selects which * Pi-compatible agent's source dir to mirror when `existingAgentDir` is @@ -281,6 +311,7 @@ export class PluginOverlayManager { return null } this.mirrorPiAgentDir(sourceAgentDir, dir) + this.writePiOverlaySettings(sourceAgentDir, dir) const extensionsDir = join(dir, 'extensions') mkdirSync(extensionsDir, { recursive: true }) writeFileSync(join(extensionsDir, PI_EXTENSION_FILE), extensionSource) diff --git a/src/relay/pty-shell-launch.ts b/src/relay/pty-shell-launch.ts index 9eb69b3962c..8de35b5f5c2 100644 --- a/src/relay/pty-shell-launch.ts +++ b/src/relay/pty-shell-launch.ts @@ -17,7 +17,10 @@ function quotePosixSingle(value: string): string { function hasOverlayRestoreEnv(env: Record): boolean { return Boolean( - env.ORCA_OPENCODE_CONFIG_DIR || env.ORCA_PI_CODING_AGENT_DIR || env.ORCA_OMP_CODING_AGENT_DIR + env.ORCA_OPENCODE_CONFIG_DIR || + env.ORCA_PI_CODING_AGENT_DIR || + env.ORCA_OMP_CODING_AGENT_DIR || + env.ORCA_REMOTE_CLI_BIN_DIR ) } @@ -84,6 +87,7 @@ if [[ ! -o login ]]; then if [[ -z "\${ORCA_PI_CODING_AGENT_DIR:-}" && -n "\${ORCA_OMP_CODING_AGENT_DIR:-}" ]]; then export PI_CODING_AGENT_DIR="\${ORCA_OMP_CODING_AGENT_DIR}" fi + [[ -n "\${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"\${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="\${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac ${getPosixOmpShellWrapper()} fi ` @@ -101,6 +105,7 @@ fi if [[ -z "\${ORCA_PI_CODING_AGENT_DIR:-}" && -n "\${ORCA_OMP_CODING_AGENT_DIR:-}" ]]; then export PI_CODING_AGENT_DIR="\${ORCA_OMP_CODING_AGENT_DIR}" fi +[[ -n "\${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"\${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="\${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac ${getPosixOmpShellWrapper()} ` const bashRc = `# Orca relay bash overlay wrapper @@ -118,6 +123,7 @@ fi if [[ -z "\${ORCA_PI_CODING_AGENT_DIR:-}" && -n "\${ORCA_OMP_CODING_AGENT_DIR:-}" ]]; then export PI_CODING_AGENT_DIR="\${ORCA_OMP_CODING_AGENT_DIR}" fi +[[ -n "\${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"\${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="\${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac ${getPosixOmpShellWrapper()} # Why: SSH bash sessions need the same command lifecycle markers as local # bash so agent rows stop showing "working" when the foreground command exits. diff --git a/src/relay/relay.ts b/src/relay/relay.ts index 213d71623da..ae62a4fb7a8 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -23,7 +23,15 @@ import { createServer, createConnection, type Socket, type Server } from 'net' import { homedir } from 'os' import { resolve, join } from 'path' import { unlinkSync, existsSync, statSync } from 'fs' -import { RELAY_SENTINEL } from './protocol' +import { + RELAY_SENTINEL, + FrameDecoder, + MessageType, + encodeJsonRpcFrame, + parseJsonRpcMessage, + type DecodedFrame, + type JsonRpcResponse +} from './protocol' import { readLaunchVersion, runConnectHandshake, setupDaemonHandshake } from './relay-handshake' import { RelayDispatcher } from './dispatcher' import { RelayContext } from './context' @@ -91,11 +99,13 @@ function parseArgs(argv: string[]): { graceTimeMs: number connectMode: boolean detached: boolean + cliMode: boolean sockPath: string } { let graceTimeMs = DEFAULT_GRACE_MS let connectMode = false let detached = false + let cliMode = false let sockPath = '' for (let i = 2; i < argv.length; i++) { if (argv[i] === '--grace-time' && argv[i + 1]) { @@ -109,6 +119,8 @@ function parseArgs(argv: string[]): { i++ } else if (argv[i] === '--connect') { connectMode = true + } else if (argv[i] === '--orca-cli') { + cliMode = true } else if (argv[i] === '--detached') { detached = true } else if (argv[i] === '--sock-path' && argv[i + 1]) { @@ -119,7 +131,7 @@ function parseArgs(argv: string[]): { if (!sockPath) { sockPath = join(process.cwd(), SOCK_NAME) } - return { graceTimeMs, connectMode, detached, sockPath } + return { graceTimeMs, connectMode, detached, cliMode, sockPath } } // ── Connect mode ───────────────────────────────────────────────────── @@ -187,15 +199,117 @@ function runConnectMode(sockPath: string): void { }) } +function runOrcaCliMode(sockPath: string, argv: string[]): void { + const myVersion = readLaunchVersion() + const sock = createConnection({ path: sockPath }) + let nextSeq = 1 + let highestReceivedSeq = 0 + const requestId = 1 + + const sendRequest = (): void => { + const env = pickRemoteCliEnv(process.env) + const frame = encodeJsonRpcFrame( + { + jsonrpc: '2.0', + id: requestId, + method: 'orca.cli', + params: { + argv, + cwd: process.cwd(), + env + } + }, + nextSeq++, + highestReceivedSeq + ) + sock.write(frame) + } + + const decoder = new FrameDecoder((frame: DecodedFrame) => { + if (frame.id > highestReceivedSeq) { + highestReceivedSeq = frame.id + } + if (frame.type !== MessageType.Regular) { + return + } + const msg = parseJsonRpcMessage(frame.payload) + if (!('id' in msg) || msg.id !== requestId || !('result' in msg || 'error' in msg)) { + return + } + const response = msg as JsonRpcResponse + if (response.error) { + process.stderr.write(`${response.error.message}\n`) + sock.destroy() + process.exit(1) + } + const result = (response.result ?? {}) as { + stdout?: unknown + stderr?: unknown + exitCode?: unknown + } + if (typeof result.stdout === 'string' && result.stdout.length > 0) { + process.stdout.write(result.stdout) + } + if (typeof result.stderr === 'string' && result.stderr.length > 0) { + process.stderr.write(result.stderr) + } + sock.destroy() + process.exit(typeof result.exitCode === 'number' ? result.exitCode : 0) + }) + + const connectTimeout = setTimeout(() => { + process.stderr.write(`[orca-cli] Relay connection timed out after ${CONNECT_TIMEOUT_MS}ms\n`) + sock.destroy() + process.exit(1) + }, CONNECT_TIMEOUT_MS) + + sock.on('connect', () => { + clearTimeout(connectTimeout) + runConnectHandshake(sock, myVersion, { + onAccepted: (leftover) => { + if (leftover.length > 0) { + decoder.feed(leftover) + } + sock.on('data', (chunk) => + decoder.feed(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + ) + sendRequest() + } + }) + }) + + sock.on('error', (err) => { + clearTimeout(connectTimeout) + process.stderr.write(`[orca-cli] Relay socket error: ${err.message}\n`) + process.exit(1) + }) +} + +function pickRemoteCliEnv(env: NodeJS.ProcessEnv): Record { + const picked: Record = {} + for (const key of ['ORCA_TERMINAL_HANDLE', 'ORCA_USER_DATA_PATH', 'PATH', 'Path']) { + const value = env[key] + if (typeof value === 'string') { + picked[key] = value + } + } + return picked +} + // ── Normal mode ────────────────────────────────────────────────────── async function main(): Promise { - const { graceTimeMs, connectMode, detached, sockPath } = parseArgs(process.argv) + const { graceTimeMs, connectMode, detached, cliMode, sockPath } = parseArgs(process.argv) if (connectMode) { runConnectMode(sockPath) return } + if (cliMode) { + const marker = process.argv.indexOf('--orca-cli') + runOrcaCliMode(sockPath, marker >= 0 ? process.argv.slice(marker + 1) : []) + return + } let ownsSocketPath = false let ownedSocketIdentity: SocketIdentity | null = null @@ -306,6 +420,12 @@ async function main(): Promise { const _workspaceSessionHandler = new WorkspaceSessionHandler(dispatcher) void _workspaceSessionHandler + dispatcher.onRequest('orca.cli', async (params, context) => { + return await dispatcher.requestAnyClient('orca.cli', params, { + excludeClientId: context.clientId + }) + }) + function configureRelayGraceTime(params: Record): { graceTimeMs: number } { const seconds = Number(params.graceTimeSeconds) if (Number.isFinite(seconds) && seconds >= 0) { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index f775133e582..e65ab274a4a 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -51,6 +51,7 @@ import { onOnboardingReopened } from './components/onboarding/show-onboarding-ev import { shouldShowOnboarding } from './components/onboarding/should-show-onboarding' import { SshPassphraseDialog } from './components/settings/SshPassphraseDialog' import DeleteWorktreeDialog from './components/sidebar/DeleteWorktreeDialog' +import { MarkdownTemplatePicker } from './components/editor/MarkdownTemplatePicker' import { FloatingTerminalPanel, FloatingTerminalToggleButton @@ -65,6 +66,7 @@ import { import { DictationController } from './components/dictation/DictationController' import { WorkspacePortScanner } from './components/ports/WorkspacePortScanner' import { CrashReportDialog } from './components/crash-report/CrashReportDialog' +import { RecoverableRenderErrorBoundary } from './components/error-boundaries/RecoverableRenderErrorBoundary' import { ConfirmationDialogProvider } from './components/confirmation-dialog' import RecentTabSwitcher from './components/tab-bar/RecentTabSwitcher' import { useGitStatusPolling } from './components/right-sidebar/useGitStatusPolling' @@ -127,6 +129,7 @@ import { } from '../../shared/keybindings' import { isGitRepoKind } from '../../shared/repo-kind' import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification' +import { resolveMountedLazyModalIds, type LazyModalId } from './lazy-modal-mount-state' const isMac = navigator.userAgent.includes('Mac') const isWindows = !isMac && navigator.userAgent.includes('Windows') @@ -259,7 +262,7 @@ function applyRemoteWorkspacePatchStatus( } function App(): React.JSX.Element { - useUnreadDockBadge() + const clearUnreadDockBadge = useUnreadDockBadge() useRadixBodyPointerEventsRecovery() useWebSessionTabsSync() const [floatingTerminalOpen, setFloatingTerminalOpen] = useState(false) @@ -369,7 +372,16 @@ function App(): React.JSX.Element { floatingTerminalReturnFocusFrameRef.current = null }, []) - useEffect(() => cancelFloatingTerminalReturnFocusFrame, [cancelFloatingTerminalReturnFocusFrame]) + const setAppRootNode = useCallback( + (node: HTMLDivElement | null): void => { + // Why: these best-effort App chrome cleanups share the App root lifetime. + if (!node) { + cancelFloatingTerminalReturnFocusFrame() + clearUnreadDockBadge() + } + }, + [cancelFloatingTerminalReturnFocusFrame, clearUnreadDockBadge] + ) const rememberFloatingTerminalReturnFocus = useCallback((): void => { const active = document.activeElement @@ -404,18 +416,19 @@ function App(): React.JSX.Element { const setFloatingTerminalOpenWithFocus = useCallback( (nextOpen: SetStateAction): void => { - setFloatingTerminalOpen((currentOpen) => { - const resolvedOpen = typeof nextOpen === 'function' ? nextOpen(currentOpen) : nextOpen - if (resolvedOpen && !currentOpen) { - useAppStore.getState().recordFeatureInteraction('floating-workspace') - rememberFloatingTerminalReturnFocus() - } else if (!resolvedOpen && currentOpen) { - restoreFloatingTerminalReturnFocus() - } - return resolvedOpen - }) + const resolvedOpen = + typeof nextOpen === 'function' ? nextOpen(floatingTerminalOpen) : nextOpen + // Why: recordFeatureInteraction updates Zustand subscribers; doing it + // inside React's state updater logs a render-phase update warning. + if (resolvedOpen && !floatingTerminalOpen) { + useAppStore.getState().recordFeatureInteraction('floating-workspace') + rememberFloatingTerminalReturnFocus() + } else if (!resolvedOpen && floatingTerminalOpen) { + restoreFloatingTerminalReturnFocus() + } + setFloatingTerminalOpen(resolvedOpen) }, - [rememberFloatingTerminalReturnFocus, restoreFloatingTerminalReturnFocus] + [floatingTerminalOpen, rememberFloatingTerminalReturnFocus, restoreFloatingTerminalReturnFocus] ) useEffect(() => { @@ -439,6 +452,7 @@ function App(): React.JSX.Element { const sortBy = useAppStore((s) => s.sortBy) const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) + const showDotfilesByWorktree = useAppStore((s) => s.showDotfilesByWorktree) const filterRepoIds = useAppStore((s) => s.filterRepoIds) const acknowledgedAgentsByPaneKey = useAppStore((s) => s.acknowledgedAgentsByPaneKey) const persistedUIReady = useAppStore((s) => s.persistedUIReady) @@ -462,7 +476,7 @@ function App(): React.JSX.Element { const canGoForwardWorktree = useAppStore(canGoForwardWorktreeHistory) const titlebarLeftControlsRef = useRef(null) const [collapsedSidebarHeaderWidth, setCollapsedSidebarHeaderWidth] = useState(0) - const [mountedLazyModalIds, setMountedLazyModalIds] = useState(() => new Set()) + const [mountedLazyModalIds, setMountedLazyModalIds] = useState>(() => new Set()) const [onboarding, setOnboarding] = useState(null) const featureTipsPromptedThisSessionRef = useRef(false) const featureTipsSuppressedByOnboardingThisSessionRef = useRef(false) @@ -913,17 +927,18 @@ function App(): React.JSX.Element { return createSessionWriteSubscriber({ store: useAppStore, shouldSchedulePersist: () => !isRemoteWorkspaceSnapshotApplyInProgress(), - persist: (payload) => { - void window.api.session.set(payload) + persist: ({ patch }) => { + const localWrite = window.api.session.patch(patch) + void localWrite const state = useAppStore.getState() const hydratedTargetIds = Array.from(state.remoteWorkspaceHydratedTargetIds).filter( (targetId) => state.remoteWorkspaceSyncStatusByTargetId[targetId]?.phase !== 'conflict' ) if (hydratedTargetIds.length > 0) { - void window.api.remoteWorkspace - ?.setForConnectedTargets({ session: payload, hydratedTargetIds }) + void localWrite + .then(() => window.api.remoteWorkspace?.setForConnectedTargets({ hydratedTargetIds })) .then((results) => { - for (const { targetId, result } of results) { + for (const { targetId, result } of results ?? []) { applyRemoteWorkspacePatchStatus(targetId, result) } }) @@ -1006,6 +1021,7 @@ function App(): React.JSX.Element { hideSleepingWorkspaces: !showSleepingWorkspaces, showSleepingWorkspaces, hideDefaultBranchWorkspace, + showDotfilesByWorktree, filterRepoIds, // Why: rides the same debounced save so dashboard auto-acks (which fire // on focus/visibility) and the in-memory ack cleanup paths in @@ -1027,6 +1043,7 @@ function App(): React.JSX.Element { sortBy, showSleepingWorkspaces, hideDefaultBranchWorkspace, + showDotfilesByWorktree, filterRepoIds, acknowledgedAgentsByPaneKey ]) @@ -1393,28 +1410,12 @@ function App(): React.JSX.Element { return () => observer.disconnect() }, [isFullScreen, settings?.showTitlebarAppName, showSidebar, workspaceActive, sidebarOpen]) - useEffect(() => { - if ( - activeModal !== 'quick-open' && - activeModal !== 'worktree-palette' && - activeModal !== 'new-workspace-composer' && - activeModal !== 'workspace-cleanup' && - activeModal !== 'feature-wall' && - activeModal !== 'feature-tips' - ) { - return - } - setMountedLazyModalIds((currentIds) => { - if (currentIds.has(activeModal)) { - return currentIds - } - const nextIds = new Set(currentIds) - // Why: lazy-load these modals only after first use, then keep them mounted - // so repeat opens preserve their local state and avoid re-fetch flashes. - nextIds.add(activeModal) - return nextIds - }) - }, [activeModal]) + const resolvedMountedLazyModalIds = resolveMountedLazyModalIds(activeModal, mountedLazyModalIds) + if (resolvedMountedLazyModalIds !== mountedLazyModalIds) { + // Why: lazy-load these modals only after first use, then keep them mounted + // so repeat opens preserve their local state and avoid re-fetch flashes. + setMountedLazyModalIds(new Set(resolvedMountedLazyModalIds)) + } // Why: extracted so both the full-width titlebar (settings/landing) and // the sidebar-width left header (workspace view) can share the same @@ -1562,6 +1563,7 @@ function App(): React.JSX.Element { return (
-
- {/* Why: the non-workspace titlebar lives inside this left+center + +
+ {/* Why: the non-workspace titlebar lives inside this left+center wrapper so it does not span over the right-sidebar column — when the right sidebar is open, its own header anchors at the top alongside the titlebar instead of being pushed below it. */} -
- {/* Why: in workspace view (split groups always enabled), the +
+ {/* Why: in workspace view (split groups always enabled), the full-width titlebar is removed so tab groups + terminal extend to the top of the window. Left titlebar controls move to a header above the sidebar. Settings, landing, and the tasks page keep the titlebar. */} - {!workspaceActive ? ( -
-
- {titlebarLeftControls} -
- {activeView === 'activity' ? ( - - ) : ( + {!workspaceActive ? ( +
- )} - {showTitlebarExpandButton && ( - - - - - - Collapse pane - - - )} - {/* Why: when the right sidebar is open, its own header renders + className={`flex items-center${showSidebar && sidebarOpen ? ' overflow-hidden shrink-0' : ' shrink-0 mr-2'}`} + style={{ width: showSidebar && sidebarOpen ? sidebarWidth : undefined }} + > + {titlebarLeftControls} +
+ {activeView === 'activity' ? ( + + ) : ( +
+ )} + {showTitlebarExpandButton && ( + + + + + + Collapse pane + + + )} + {/* Why: when the right sidebar is open, its own header renders an identical close button — hide this copy so only one is visible at a time. */} - {!rightSidebarOpen && rightSidebarToggle} - {/* Why: reserve space so content is not obscured by the + {!rightSidebarOpen && rightSidebarToggle} + {/* Why: reserve space so content is not obscured by the fixed-position window-controls overlay on Windows. */} - {isWindows &&
} -
- ) : null} -
- {showSidebar ? ( - workspaceActive ? ( - /* Why: left column wraps the sidebar with a titlebar-height + {isWindows &&
} +
+ ) : null} +
+ {showSidebar ? ( + workspaceActive ? ( + /* Why: left column wraps the sidebar with a titlebar-height header above it. The header holds the same controls (traffic lights, sidebar toggle, "Orca" title, agent badge) that the full-width titlebar held while the center and right @@ -1646,148 +1655,282 @@ function App(): React.JSX.Element { When the sidebar is collapsed, take this header out of flex layout so the terminal/editor reclaim the left edge instead of leaving behind a content-width blank strip. */ -
- {titlebarLeftControls} -
-
- {/* Why: the workspace-view wrapper adds a fixed 36px header +
+ {titlebarLeftControls} +
+
+ {/* Why: the workspace-view wrapper adds a fixed 36px header above the sidebar. Without a flex-1/min-h-0 slot here, the sidebar falls back to its content height, so the worktree list loses its scroll viewport and the fixed bottom toolbar (including Add Project) gets pushed offscreen. */} + + + +
+
+ ) : ( + -
-
- ) : ( - - ) - ) : null} -
- {/* Why: right sidebar toggle floats at the top-right of the center + + ) + ) : null} +
+ {/* Why: right sidebar toggle floats at the top-right of the center column so it's always accessible whether the right sidebar is open or closed. Match the RightSidebar header's 36px height and top-0 anchor so the icon's vertical center is identical between open and closed states — otherwise toggling makes the icon jump a few pixels, which reads as layout jitter. */} - {workspaceActive && !rightSidebarOpen && ( -
- {rightSidebarToggle} + {workspaceActive && !rightSidebarOpen && ( +
+ {rightSidebarToggle} +
+ )} +
+
+ + + +
+ + + {activeView === 'settings' ? : null} + {activeView === 'skills' ? : null} + {activeView === 'tasks' ? : null} + {activeView === 'automations' ? : null} + {activeView === 'activity' ? : null} + {activeView === 'space' ? : null} + {activeView === 'mobile' ? : null} + {activeView === 'terminal' && !activeWorktreeId ? : null} + +
- )} -
-
- -
- - {activeView === 'settings' ? : null} - {activeView === 'skills' ? : null} - {activeView === 'tasks' ? : null} - {activeView === 'automations' ? : null} - {activeView === 'activity' ? : null} - {activeView === 'space' ? : null} - {activeView === 'mobile' ? : null} - {activeView === 'terminal' && !activeWorktreeId ? : null} - + {showFloatingTerminalButton ? ( + setFloatingTerminalOpenWithFocus((open) => !open)} + /> + ) : null}
- {showFloatingTerminalButton ? ( - setFloatingTerminalOpenWithFocus((open) => !open)} - /> - ) : null}
-
- {/* Why: keep RightSidebar mounted even when closed so that its + {/* Why: keep RightSidebar mounted even when closed so that its child components (FileExplorer, SourceControl, etc.) and their filesystem watchers + cached directory trees survive across open/close toggles. Unmount on the tasks view since that surface is intentionally distraction-free. */} - {showRightSidebarControls ? : null} -
+ {showRightSidebarControls ? ( + + + + ) : null} +
+ {floatingTerminalEnabled ? ( - + + + ) : null} - + + + {/* Why: root overlays can render Radix s; keep them inside the shared provider so lazy surfaces mount safely from any entry point. */} - {mountedLazyModalIds.has('new-workspace-composer') ? ( - + {resolvedMountedLazyModalIds.has('new-workspace-composer') ? ( + + + + ) : null} + {resolvedMountedLazyModalIds.has('workspace-cleanup') ? ( + + + ) : null} - {mountedLazyModalIds.has('workspace-cleanup') ? : null} - {mountedLazyModalIds.has('quick-open') ? : null} - {mountedLazyModalIds.has('worktree-palette') ? : null} - {mountedLazyModalIds.has('feature-wall') ? : null} - {mountedLazyModalIds.has('feature-tips') ? : null} + {resolvedMountedLazyModalIds.has('quick-open') ? ( + + + + ) : null} + {resolvedMountedLazyModalIds.has('worktree-palette') ? ( + + + + ) : null} + {resolvedMountedLazyModalIds.has('feature-wall') ? ( + + + + ) : null} + {resolvedMountedLazyModalIds.has('feature-tips') ? ( + + + + ) : null} {/* Why: mount PetOverlay only after persisted UI hydration, with both independent pet toggles allowing it; otherwise a hidden pet flashes while the store still has default visibility. */} {renderPetOverlay ? ( - + + + ) : null} - - + + + + + + {/* Why: the existing-user opt-in banner mounts at App root so it renders once per renderer session, not per view. It gates internally on the cohort markers populated by the migration, @@ -1795,22 +1938,90 @@ function App(): React.JSX.Element { release and have not yet resolved consent. New users get no first-launch surface — see telemetry-plan.md §First-launch experience. */} - - - - - + + + + + + + + + + + + + + + + + + {onboarding && shouldRenderOnboarding && !onboardingSettingsDetourActive ? ( - + + + ) : null} - - + + + + + + diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 4df13c3060b..bd752b38837 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -76,6 +76,7 @@ ); --color-terminal-pane-title-on-light-input-bg: var(--terminal-pane-title-on-light-input-bg); --color-terminal-pane-title-on-light-separator: var(--terminal-pane-title-on-light-separator); + --color-ai-action-accent: var(--ai-action-accent); --color-git-graph-ref: var(--git-graph-ref); --color-git-graph-remote-ref: var(--git-graph-remote-ref); --color-git-graph-base-ref: var(--git-graph-base-ref); @@ -124,6 +125,7 @@ --input: #e5e5e5; --ring: #a1a1a1; --terminal-pane-locate: var(--color-blue-600); + --ai-action-accent: var(--color-violet-500); --chart-1: var(--color-blue-300); --chart-2: var(--color-blue-500); --chart-3: var(--color-blue-600); @@ -193,6 +195,7 @@ --input: rgb(255 255 255 / 0.15); --ring: #737373; --terminal-pane-locate: var(--color-blue-400); + --ai-action-accent: var(--color-violet-400); --chart-1: var(--color-blue-300); --chart-2: var(--color-blue-500); --chart-3: var(--color-blue-600); @@ -333,12 +336,16 @@ /* Keep the sidebar gutter reserved; only the thumb fades in on hover. */ .worktree-sidebar-scrollbar { - /* Why: the scrollbar itself owns the gutter; padding is only the card-to-gutter gap. */ - padding-right: 2px; + /* Why: the scrollbar itself owns the gutter; avoid adding a second visual gap beside cards. */ + padding-right: 0; scrollbar-gutter: stable; scrollbar-color: transparent transparent; } +.worktree-sidebar-scrollbar::-webkit-scrollbar { + width: 8px; +} + .worktree-sidebar-scrollbar::-webkit-scrollbar-thumb { background-color: transparent; border-width: 3px 3px 3px 0; @@ -406,6 +413,16 @@ background-clip: padding-box; } +.combined-diff-scroll-container { + -ms-overflow-style: none; + scrollbar-width: none; +} + +.combined-diff-scroll-container::-webkit-scrollbar { + width: 0; + height: 0; +} + /* Hide tab-strip scrollbars to prevent drag-time scrollbar flashes */ .terminal-tab-strip { -ms-overflow-style: none; @@ -829,6 +846,60 @@ background: color-mix(in srgb, var(--accent) 30%, transparent); } +.worktree-agent-send-target-button { + /* Why: the row remains an ordinary agent row during send-target mode; the + action itself carries the AI target outline so the clickable affordance + is explicit without boxing the whole session. */ + border-color: color-mix(in srgb, var(--ai-action-accent) 72%, transparent); + background: color-mix(in srgb, var(--ai-action-accent) 12%, transparent); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--ai-action-accent) 22%, transparent); + color: var(--foreground); +} + +.worktree-agent-send-target-button:hover { + border-color: color-mix(in srgb, var(--ai-action-accent) 88%, transparent); + background: color-mix(in srgb, var(--ai-action-accent) 18%, transparent); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--ai-action-accent) 34%, transparent); +} + +.worktree-agent-send-target-button:focus-visible { + outline: none; + box-shadow: 0 0 0 2px color-mix(in srgb, var(--ring) 50%, transparent); +} + +/* Why: the virtualized card row is height-measured; animating height would + make TanStack reposition sibling cards on every frame. */ +.compact-agent-expansion-grid { + display: grid; + grid-template-rows: 0fr; +} + +.compact-agent-expansion-grid-expanded { + grid-template-rows: 1fr; +} + +/* Why: keyframes run when the content mounts already in its final state. */ +@keyframes compact-agent-expansion-reveal { + from { + opacity: 0; + transform: translateY(-2px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.compact-agent-expansion-grid-expanded .compact-agent-expansion-content { + animation: compact-agent-expansion-reveal 150ms ease-out both; +} + +@media (prefers-reduced-motion: reduce) { + .compact-agent-expansion-grid-expanded .compact-agent-expansion-content { + animation-duration: 0ms; + } +} + .scroll-to-current-workspace-reveal-highlight { position: relative; isolation: isolate; @@ -838,17 +909,24 @@ .scroll-to-current-workspace-reveal-highlight::before { content: ''; position: absolute; - inset: -2px; + inset: 0; z-index: 30; pointer-events: none; border: 1.5px solid color-mix(in srgb, var(--terminal-pane-locate) 68%, transparent); - border-radius: calc(var(--radius-md) + 2px); + border-radius: inherit; box-shadow: - 0 0 0 2px color-mix(in srgb, var(--terminal-pane-locate) 34%, transparent), - 0 0 18px color-mix(in srgb, var(--terminal-pane-locate) 36%, transparent); + 0 0 0 1px color-mix(in srgb, var(--terminal-pane-locate) 34%, transparent), + 0 0 14px color-mix(in srgb, var(--terminal-pane-locate) 36%, transparent); animation: scroll-to-current-workspace-reveal-glow 1.5s ease-out forwards; } +.scroll-to-current-workspace-reveal-highlight--ai::before { + border-color: color-mix(in srgb, var(--ai-action-accent) 68%, transparent); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--ai-action-accent) 34%, transparent), + 0 0 14px color-mix(in srgb, var(--ai-action-accent) 36%, transparent); +} + @keyframes scroll-to-current-workspace-reveal-glow { 0% { opacity: 1; @@ -2452,6 +2530,8 @@ html.onboarding-tour-start-transition::view-transition-new(root) { .orca-diff-comment-actions-pill { display: inline-flex; align-items: center; + gap: 2px; + padding: 0 2px; border: 1px solid color-mix(in srgb, var(--foreground) 10%, var(--border)); border-radius: 6px; background: var(--background); @@ -2468,7 +2548,8 @@ html.onboarding-tour-start-transition::view-transition-new(root) { display: inline-flex; align-items: center; justify-content: center; - padding: 4px 8px; + min-width: 28px; + padding: 4px 7px; font-size: 11px; font-weight: 500; color: var(--muted-foreground); @@ -2481,6 +2562,13 @@ html.onboarding-tour-start-transition::view-transition-new(root) { color 100ms ease; } +.orca-diff-comment-edit { + min-width: 28px; + height: 24px; + padding: 4px 7px; + border-radius: 4px; +} + .orca-diff-comment-pill-btn:hover { background: color-mix(in srgb, var(--foreground) 6%, transparent); color: var(--foreground); diff --git a/src/renderer/src/assets/rich-markdown-editor.css b/src/renderer/src/assets/rich-markdown-editor.css index 23dd55cd508..948207dc586 100644 --- a/src/renderer/src/assets/rich-markdown-editor.css +++ b/src/renderer/src/assets/rich-markdown-editor.css @@ -59,6 +59,34 @@ color: var(--foreground); } +.github-markdown-composer .rich-markdown-editor-toolbar { + padding: 6px 8px; + background: color-mix(in srgb, var(--background) 92%, transparent); +} + +.github-markdown-composer-editor { + min-height: 8rem; + padding: 12px 14px 18px; + font-size: 13px; + line-height: 1.55; +} + +.github-markdown-composer-editor p, +.github-markdown-composer-editor ul, +.github-markdown-composer-editor ol, +.github-markdown-composer-editor blockquote { + margin: 0.55em 0; +} + +.github-markdown-composer-image-row { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + border-bottom: 1px solid color-mix(in srgb, var(--border) 72%, transparent); + background: color-mix(in srgb, var(--muted) 38%, var(--background)); +} + .orca-diff-comment-add-btn.rich-markdown-comment-add-btn { display: flex; width: 22px; @@ -109,7 +137,7 @@ } .rich-markdown-review-rail-toggle, -.rich-markdown-review-rail-send { +.rich-markdown-review-rail-action { display: inline-flex; align-items: center; justify-content: center; @@ -128,20 +156,20 @@ font-weight: 600; } -.rich-markdown-review-rail-send { +.rich-markdown-review-rail-action { width: 26px; padding: 0; } .rich-markdown-review-rail-toggle:hover, .rich-markdown-review-rail-toggle[aria-expanded='true'], -.rich-markdown-review-rail-send:hover:not(:disabled) { +.rich-markdown-review-rail-action:hover:not(:disabled) { background: var(--accent); color: var(--foreground); } -.rich-markdown-review-rail-send:disabled, -.rich-markdown-review-note-send:disabled { +.rich-markdown-review-rail-action:disabled, +.rich-markdown-review-note-action:disabled { cursor: default; opacity: 0.45; } @@ -192,7 +220,7 @@ } } -.rich-markdown-review-note-send { +.rich-markdown-review-note-action { display: inline-flex; align-items: center; justify-content: center; @@ -207,7 +235,7 @@ z-index: 1; } -.rich-markdown-review-note-send:hover:not(:disabled) { +.rich-markdown-review-note-action:hover:not(:disabled) { border-color: color-mix(in srgb, var(--foreground) 20%, transparent); background: color-mix(in srgb, var(--foreground) 6%, transparent); color: var(--foreground); @@ -813,6 +841,131 @@ outline-offset: 2px; } +/* ── Linear Issue Markdown Editor ────────────────────── */ + +.linear-issue-markdown-editor { + position: relative; + display: flex; + min-width: 0; + flex-direction: column; + overflow: hidden; + border: 1px solid transparent; + border-radius: var(--radius-md); + background: transparent; + color: var(--foreground); + transition: + border-color 120ms ease, + background-color 120ms ease, + box-shadow 120ms ease; +} + +.linear-issue-markdown-editor-page { + margin-top: 1.75rem; + font-size: 15px; + line-height: 1.7; +} + +.linear-issue-markdown-editor-drawer { + font-size: 14px; + line-height: 1.625; +} + +.linear-issue-markdown-editor:hover { + border-color: color-mix(in srgb, var(--border) 50%, transparent); + background: color-mix(in srgb, var(--accent) 40%, transparent); +} + +.linear-issue-markdown-editor:focus-within { + border-color: var(--border); + background: var(--background); + box-shadow: 0 0 0 1px var(--ring); +} + +.linear-issue-markdown-editor.is-disabled { + opacity: 0.8; +} + +.linear-issue-markdown-toolbar { + display: flex; + min-width: 0; + align-items: center; + gap: 4px; + overflow-x: auto; + padding: 4px 6px; + border-bottom: 1px solid color-mix(in srgb, var(--border) 60%, transparent); + background: color-mix(in srgb, var(--background) 72%, transparent); +} + +.linear-issue-markdown-toolbar-button { + display: inline-flex; + width: 26px; + min-width: 26px; + height: 26px; + align-items: center; + justify-content: center; + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: transparent; + color: var(--muted-foreground); + font-size: 12px; + font-weight: 600; +} + +.linear-issue-markdown-toolbar-button:hover:not(:disabled), +.linear-issue-markdown-toolbar-button.is-active { + border-color: color-mix(in srgb, var(--border) 78%, transparent); + background: var(--accent); + color: var(--foreground); +} + +.linear-issue-markdown-toolbar-button:disabled { + cursor: default; + opacity: 0.5; +} + +.linear-issue-markdown-toolbar-separator { + width: 1px; + min-width: 1px; + height: 16px; + background: color-mix(in srgb, var(--border) 70%, transparent); +} + +.linear-issue-markdown-scroll { + min-width: 0; +} + +.linear-issue-markdown-editor .rich-markdown-editor { + min-height: 96px; + padding: 10px 12px 26px; + font-size: inherit; + line-height: inherit; +} + +.linear-issue-markdown-editor-page .rich-markdown-editor { + min-height: 132px; + padding: 12px 12px 28px; +} + +.linear-issue-markdown-editor .rich-markdown-editor p, +.linear-issue-markdown-editor .rich-markdown-editor ul, +.linear-issue-markdown-editor .rich-markdown-editor ol, +.linear-issue-markdown-editor .rich-markdown-editor blockquote { + margin: 0.55em 0; +} + +.linear-issue-markdown-editor .rich-markdown-editor p.is-editor-empty:first-child::before { + font-style: italic; +} + +.linear-issue-markdown-save-hint { + opacity: 0; + transition: opacity 120ms ease; +} + +.linear-issue-markdown-editor:focus-within .linear-issue-markdown-save-hint { + opacity: 1; +} + .rich-markdown-slash-menu { position: absolute; z-index: 30; diff --git a/src/renderer/src/components/FirstLaunchBanner.tsx b/src/renderer/src/components/FirstLaunchBanner.tsx index ed93d57168e..ba727f47282 100644 --- a/src/renderer/src/components/FirstLaunchBanner.tsx +++ b/src/renderer/src/components/FirstLaunchBanner.tsx @@ -33,11 +33,12 @@ // off), the notice never returns, because the cohort condition // (`optedIn === null`) clears in all three resolving paths. -import { useEffect, useRef, useState } from 'react' +import { useState } from 'react' import { X } from 'lucide-react' import { Button } from './ui/button' import { acknowledgeBanner, PRIVACY_URL, setOptIn as telemetrySetOptIn } from '../lib/telemetry' +import { useMountedRef } from '@/hooks/useMountedRef' type FirstLaunchBannerProps = { onResolve: () => void @@ -57,14 +58,7 @@ export function FirstLaunchBanner({ // wasted IPC round-trip, but the guard also blocks a Turn-off click // arriving mid-flight after an acknowledge (or vice versa). const [inFlight, setInFlight] = useState(false) - const mountedRef = useRef(true) - - useEffect(() => { - mountedRef.current = true - return () => { - mountedRef.current = false - } - }, []) + const mountedRef = useMountedRef() const handleAcknowledge = async (): Promise => { if (inFlight) { diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index fdeeec7877e..da12c35e0ad 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -25,6 +25,7 @@ import { Copy, ExternalLink, FileText, + FolderKanban, GitMerge, GitPullRequest, GitPullRequestClosed, @@ -34,8 +35,10 @@ import { MessageSquarePlus, PanelLeftOpen, Pencil, + Plus, RefreshCw, Send, + Settings, UndoDot, Users, Wrench, @@ -45,6 +48,7 @@ import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { ButtonGroup } from '@/components/ui/button-group' import { Input } from '@/components/ui/input' +import { useMountedRef } from '@/hooks/useMountedRef' import { useConfirmationDialog } from '@/components/confirmation-dialog' import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet' import { VisuallyHidden } from 'radix-ui' @@ -81,6 +85,24 @@ import { import type { DiffSection } from '@/components/editor/diff-section-types' import type { CombinedDiffFileTreeEntry } from '@/components/editor/combined-diff-file-tree-model' import { CHECK_COLOR, CHECK_ICON } from '@/components/right-sidebar/checks-panel-content' +import { + createGitHubChecksTabState, + resolveGitHubChecksTabState, + toggleGitHubChecksTabExpandedKey, + updateGitHubChecksTabDetails, + updateGitHubChecksTabLocalChecks, + type CheckDetailsLoadState +} from '@/components/github-checks-tab-state' +import { + clearGitHubLinkCopied, + createGitHubLinkCopyState, + markGitHubLinkCopied, + resolveGitHubLinkCopyState +} from '@/components/github-link-copy-state' +import { + resolveGitHubBodyDraft, + shouldSyncGitHubBodyDraft +} from '@/components/github-body-draft-state' import { filterPRCommentsByAudience, getPRCommentAudienceCounts, @@ -99,20 +121,38 @@ import { PR_COMMENT_RESOLVED_CONTAINER_CLASS, type PRCommentGroup } from '@/lib/pr-comment-groups' +import { + createCommentCodeContextExpansionState, + resolveCommentCodeContextExpansionState, + updateCommentCodeContextExpansionState, + type CommentCodeContextLineUpdate +} from '@/components/comment-code-context-state' +import { resolveCommentReplyTarget } from '@/components/comment-reply-target-state' import { useAppStore } from '@/store' +import { useAllWorktrees } from '@/store/selectors' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/useIssueMetadata' import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' +import { GitHubMarkdownComposer } from '@/components/github/GitHubMarkdownComposer' import IssueSourceIndicator, { sameGitHubOwnerRepo } from '@/components/github/IssueSourceIndicator' import { getGitHubPRReviewerRows, normalizeGitHubReviewerLogins } from '@/components/github-pr-reviewer-display' import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state' +import { + GITHUB_PR_MERGE_METHOD_LABELS, + resolveGitHubPRMergeMethods +} from '../../../shared/github-pr-merge-methods' import { AGENT_CATALOG } from '@/lib/agent-catalog' import { filterEnabledTuiAgents } from '../../../shared/tui-agent-selection' import { getConnectionId } from '@/lib/connection-context' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' +import { + findGithubIssueWorkspaceAttachment, + findGithubPrWorkspaceAttachment, + getGithubWorkItemWorkspaceAttachmentLabel +} from '@/lib/github-work-item-workspace-attachment' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' import { launchWorkItemDirect } from '@/lib/launch-work-item-direct' import { activateAndRevealWorktree } from '@/lib/worktree-activation' @@ -125,13 +165,12 @@ import type { GitHubWorkItemDetails, GitHubAssignableUser, GitHubReaction, + GitHubPRMergeMethod, GitBranchChangeEntry, GitDiffResult, PRCheckDetail, - PRCheckRunDetails, PRComment, - TuiAgent, - Worktree + TuiAgent } from '../../../shared/types' import { PER_REPO_FETCH_LIMIT } from '../../../shared/work-items' @@ -159,22 +198,31 @@ function parseOwnerRepoFromItemUrl(url: string): GitHubOwnerRepo | null { } } +function getGitHubRepositoryLabelsUrl(itemUrl: string): string | null { + try { + const parsed = new URL(itemUrl) + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + return null + } + const segments = parsed.pathname.split('/').filter(Boolean) + if (segments.length < 2) { + return null + } + // Why: label management is repository-scoped; preserving the origin keeps + // GitHub Enterprise URLs working while navigating away from the issue path. + parsed.pathname = `/${segments[0]}/${segments[1]}/labels` + parsed.search = '' + parsed.hash = '' + return parsed.toString() + } catch { + return null + } +} + const MonacoCodeExcerpt = lazy(() => import('@/components/editor/MonacoCodeExcerpt')) export type ItemDialogTab = 'conversation' | 'checks' | 'files' -type MentionOption = { - login: string - name?: string | null - avatarUrl?: string - source: string -} - -type MentionQuery = { - atIndex: number - query: string -} - const CODE_CONTEXT_EXPAND_STEP = 5 const CODE_CONTEXT_FALLBACK_LINES = 20 const CODE_CONTEXT_MAX_BLOCK_LINES = CODE_CONTEXT_FALLBACK_LINES * 2 + 1 @@ -258,80 +306,6 @@ function formatRelativeTime(input: string): string { return formatter.format(diffDays, 'day') } -function findMentionQuery(value: string, caret: number): MentionQuery | null { - const beforeCaret = value.slice(0, caret) - const match = /(^|[\s([{,])@([A-Za-z0-9-]*)$/.exec(beforeCaret) - if (!match) { - return null - } - const query = match[2] ?? '' - return { - atIndex: beforeCaret.length - query.length - 1, - query - } -} - -function buildMentionOptions({ - item, - comments, - participants, - assignableUsers -}: { - item: GitHubWorkItem - comments: PRComment[] - participants: GitHubAssignableUser[] - assignableUsers: GitHubAssignableUser[] -}): MentionOption[] { - const byLogin = new Map() - const add = ( - login: string | null | undefined, - source: string, - avatarUrl?: string, - name?: string | null - ): void => { - if (!login || login === 'ghost') { - return - } - const key = login.toLowerCase() - const existing = byLogin.get(key) - if (existing) { - if (!existing.avatarUrl && avatarUrl) { - existing.avatarUrl = avatarUrl - } - if (!existing.name && name) { - existing.name = name - } - return - } - byLogin.set(key, { login, source, avatarUrl, name }) - } - - add(item.author, item.type === 'pr' ? 'PR author' : 'Issue author') - for (const comment of comments) { - add(comment.author, 'Commenter', comment.authorAvatarUrl) - } - for (const user of participants) { - add(user.login, 'Participant', user.avatarUrl, user.name) - } - for (const user of assignableUsers) { - add(user.login, 'Team member', user.avatarUrl, user.name) - } - - return Array.from(byLogin.values()) -} - -function filterMentionOptions(options: MentionOption[], query: string): MentionOption[] { - const normalizedQuery = query.toLowerCase() - const filtered = normalizedQuery - ? options.filter( - (option) => - option.login.toLowerCase().includes(normalizedQuery) || - (option.name ?? '').toLowerCase().includes(normalizedQuery) - ) - : options - return filtered.slice(0, 8) -} - function getStateLabel(item: GitHubWorkItem): string { if (item.type === 'pr') { if (item.state === 'merged') { @@ -470,11 +444,16 @@ function PRReviewersPanel({ const [reviewerInput, setReviewerInput] = useState('') const [reviewerPickerSide, setReviewerPickerSide] = useState<'top' | 'bottom'>('bottom') const [reviewerPickerMaxHeight, setReviewerPickerMaxHeight] = useState(null) - const [activeReviewerIndex, setActiveReviewerIndex] = useState(0) + const [activeReviewerCursor, setActiveReviewerCursor] = useState({ resetKey: '', index: 0 }) const [submitting, setSubmitting] = useState(false) const [localReviewRequests, setLocalReviewRequests] = useState( () => item.reviewRequests ?? [] ) + const [reviewRequestsSource, setReviewRequestsSource] = useState(() => ({ + itemId: item.id, + repoId: item.repoId, + reviewRequests: item.reviewRequests + })) const patchWorkItem = useAppStore((s) => s.patchWorkItem) const settings = useAppStore((s) => s.settings) const reviewerInputRef = useRef(null) @@ -507,9 +486,20 @@ function PRReviewersPanel({ } }, [cancelReviewerInputFocusFrame]) - useEffect(() => { + // Why: reviewer edits are optimistic, but item switches/refetches must clear + // stale local requests before paint; a passive Effect leaves one stale render. + if ( + reviewRequestsSource.itemId !== item.id || + reviewRequestsSource.repoId !== item.repoId || + reviewRequestsSource.reviewRequests !== item.reviewRequests + ) { + setReviewRequestsSource({ + itemId: item.id, + repoId: item.repoId, + reviewRequests: item.reviewRequests + }) setLocalReviewRequests(item.reviewRequests ?? []) - }, [item.id, item.reviewRequests]) + } const reviewerSeedUsers = useMemo(() => { const byLogin = new Map() @@ -619,9 +609,24 @@ function PRReviewersPanel({ [everyoneElseReviewerRows, suggestedReviewerRows] ) - useEffect(() => { - setActiveReviewerIndex(0) - }, [reviewerQuery, actionableReviewerRows.length]) + const reviewerCursorResetKey = `${reviewerQuery}\u0000${actionableReviewerRows.length}` + if (activeReviewerCursor.resetKey !== reviewerCursorResetKey) { + setActiveReviewerCursor({ resetKey: reviewerCursorResetKey, index: 0 }) + } + const activeReviewerIndex = + activeReviewerCursor.resetKey === reviewerCursorResetKey ? activeReviewerCursor.index : 0 + const setActiveReviewerIndex = useCallback( + (nextIndex: number | ((current: number) => number)): void => { + setActiveReviewerCursor((current) => { + const currentIndex = current.resetKey === reviewerCursorResetKey ? current.index : 0 + return { + resetKey: reviewerCursorResetKey, + index: typeof nextIndex === 'function' ? nextIndex(currentIndex) : nextIndex + } + }) + }, + [reviewerCursorResetKey] + ) const hasReviewerMetadata = item.reviewDecision !== undefined || @@ -687,6 +692,9 @@ function PRReviewersPanel({ prNumber: item.number, reviewers: logins }) + if (!reviewerPanelMountedRef.current) { + return + } if (!result.ok) { toast.error(result.error ?? 'Failed to request reviewer') return @@ -702,9 +710,13 @@ function PRReviewersPanel({ setReviewerInput('') toast.success(logins.length === 1 ? 'Reviewer requested' : 'Reviewers requested') } catch { - toast.error('Failed to request reviewer') + if (reviewerPanelMountedRef.current) { + toast.error('Failed to request reviewer') + } } finally { - setSubmitting(false) + if (reviewerPanelMountedRef.current) { + setSubmitting(false) + } } } @@ -740,6 +752,9 @@ function PRReviewersPanel({ prNumber: item.number, reviewers: logins }) + if (!reviewerPanelMountedRef.current) { + return + } if (!result.ok) { toast.error(result.error ?? 'Failed to remove reviewer') return @@ -754,9 +769,13 @@ function PRReviewersPanel({ setReviewerInput('') toast.success(logins.length === 1 ? 'Reviewer removed' : 'Reviewers removed') } catch { - toast.error('Failed to remove reviewer') + if (reviewerPanelMountedRef.current) { + toast.error('Failed to remove reviewer') + } } finally { - setSubmitting(false) + if (reviewerPanelMountedRef.current) { + setSubmitting(false) + } } } @@ -1502,7 +1521,9 @@ function mapPRFileStatus(status: GitHubPRFile['status']): GitBranchChangeEntry[' return 'renamed' case 'copied': return 'copied' - default: + case 'changed': + case 'modified': + case 'unchanged': return 'modified' } } @@ -2075,8 +2096,9 @@ function CommentCodeContext({ }): React.JSX.Element | null { const [contents, setContents] = useState(null) const [error, setError] = useState(false) - const [contextBefore, setContextBefore] = useState(0) - const [contextAfter, setContextAfter] = useState(0) + const [contextExpansionState, setContextExpansionState] = useState(() => + createCommentCodeContextExpansionState(comment.id) + ) const file = useMemo( () => files.find((candidate) => candidate.path === comment.path), [comment.path, files] @@ -2107,10 +2129,37 @@ function CommentCodeContext({ } }, [baseSha, file, headSha, line, prNumber, repoId, repoPath]) - useEffect(() => { - setContextBefore(0) - setContextAfter(0) - }, [comment.id]) + const resolvedContextExpansionState = resolveCommentCodeContextExpansionState( + contextExpansionState, + comment.id + ) + if (resolvedContextExpansionState !== contextExpansionState) { + // Why: comment rows can be reused when a PR refreshes; reset before paint + // so expanded context from the previous comment is never shown on the next. + setContextExpansionState(resolvedContextExpansionState) + } + const contextBefore = resolvedContextExpansionState.contextBefore + const contextAfter = resolvedContextExpansionState.contextAfter + const setContextBefore = useCallback( + (contextBeforeUpdate: CommentCodeContextLineUpdate) => { + setContextExpansionState((current) => + updateCommentCodeContextExpansionState(current, comment.id, { + contextBefore: contextBeforeUpdate + }) + ) + }, + [comment.id] + ) + const setContextAfter = useCallback( + (contextAfterUpdate: CommentCodeContextLineUpdate) => { + setContextExpansionState((current) => + updateCommentCodeContextExpansionState(current, comment.id, { + contextAfter: contextAfterUpdate + }) + ) + }, + [comment.id] + ) if (!comment.path || !line || !file || file.isBinary || error) { return null @@ -2308,7 +2357,6 @@ function ConversationTab({ loading, detailsLoaded, checks, - participants: detailsParticipants, localState, onStateChange, projectOrigin, @@ -2329,7 +2377,6 @@ function ConversationTab({ loading: boolean detailsLoaded: boolean checks: GitHubWorkItemDetails['checks'] - participants: GitHubAssignableUser[] localState: GitHubWorkItem['state'] onStateChange: (state: GitHubWorkItem['state']) => void projectOrigin: GitHubItemDialogProjectOrigin | undefined @@ -2345,57 +2392,26 @@ function ConversationTab({ const [bodyDraft, setBodyDraft] = useState(body) const [bodyEditing, setBodyEditing] = useState(false) const [bodySaving, setBodySaving] = useState(false) - const bodyTextareaRef = useRef(null) - const bodyTextareaFocusFrameRef = useRef(null) - const repoAssignees = useRepoAssignees(repoPath, item.repoId) const commentCounts = useMemo(() => getPRCommentAudienceCounts(comments), [comments]) const visibleComments = useMemo( () => filterPRCommentsByAudience(comments, commentFilter), [commentFilter, comments] ) const visibleCommentGroups = useMemo(() => groupPRComments(visibleComments), [visibleComments]) - const mentionOptions = useMemo( - () => - buildMentionOptions({ - item, - comments, - participants: detailsParticipants, - assignableUsers: repoAssignees.data - }), - [comments, detailsParticipants, item, repoAssignees.data] - ) + const resolvedReplyingTo = resolveCommentReplyTarget(replyingTo, visibleComments) - const cancelBodyTextareaFocusFrame = useCallback((): void => { - if (bodyTextareaFocusFrameRef.current !== null) { - cancelAnimationFrame(bodyTextareaFocusFrameRef.current) - bodyTextareaFocusFrameRef.current = null - } - }, []) + if (resolvedReplyingTo !== replyingTo) { + // Why: comment filters/refetches can hide the active reply target; clear it + // before paint so a stale composer does not flash for the wrong comment set. + setReplyingTo(resolvedReplyingTo) + } - useEffect(() => { - if (replyingTo !== null && !visibleComments.some((comment) => comment.id === replyingTo)) { - setReplyingTo(null) - } - }, [replyingTo, visibleComments]) - - useEffect(() => { - if (!bodyEditing) { - setBodyDraft(body) - } - }, [body, bodyEditing, item.id]) - - useEffect(() => { - if (!bodyEditing) { - cancelBodyTextareaFocusFrame() - return cancelBodyTextareaFocusFrame - } - cancelBodyTextareaFocusFrame() - bodyTextareaFocusFrameRef.current = requestAnimationFrame(() => { - bodyTextareaFocusFrameRef.current = null - bodyTextareaRef.current?.focus() - }) - return cancelBodyTextareaFocusFrame - }, [bodyEditing, cancelBodyTextareaFocusFrame]) + const resolvedBodyDraft = resolveGitHubBodyDraft(bodyDraft, body, bodyEditing) + if (shouldSyncGitHubBodyDraft(bodyDraft, body, bodyEditing)) { + // Why: background detail refreshes can change the body while the editor is + // closed; reconcile before paint so reopening never sees a stale draft. + setBodyDraft(resolvedBodyDraft) + } const bodySlug = useMemo(() => parseOwnerRepoFromItemUrl(item.url), [item.url]) const markdownGitHubRepo = useMemo( @@ -2404,7 +2420,7 @@ function ConversationTab({ ) const canEditBody = item.type === 'pr' ? Boolean(projectOrigin || bodySlug) : Boolean(projectOrigin || repoPath) - const bodyChanged = bodyDraft !== body + const bodyChanged = resolvedBodyDraft !== body const handleSaveBody = useCallback(async (): Promise => { if (bodySaving || !bodyChanged) { @@ -2417,10 +2433,10 @@ function ConversationTab({ item, repoPath, projectOrigin, - body: bodyDraft, + body: resolvedBodyDraft, parsedSlug: bodySlug }) - onBodyUpdated(bodyDraft) + onBodyUpdated(resolvedBodyDraft) setBodyEditing(false) toast.success('Description updated.') } catch (err) { @@ -2428,7 +2444,16 @@ function ConversationTab({ } finally { setBodySaving(false) } - }, [bodyChanged, bodyDraft, bodySaving, bodySlug, item, onBodyUpdated, projectOrigin, repoPath]) + }, [ + bodyChanged, + resolvedBodyDraft, + bodySaving, + bodySlug, + item, + onBodyUpdated, + projectOrigin, + repoPath + ]) const handleReply = useCallback( async (comment: PRComment, replyBody: string): Promise => { @@ -2594,13 +2619,12 @@ function ConversationTab({ className="min-w-0 max-w-full overflow-hidden break-words text-[13px] leading-relaxed [&_a]:break-all [&_code]:break-words [&_pre]:max-w-full" /> - {replyingTo === comment.id && ( + {resolvedReplyingTo === comment.id && ( setReplyingTo(null)} onSubmit={(replyBody) => handleReply(comment, replyBody)} /> @@ -2723,27 +2747,14 @@ function ConversationTab({
) : bodyEditing ? ( - { - if (event.key === 'Escape') { - event.preventDefault() - setBodyDraft(body) - setBodyEditing(false) - return - } - if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { - event.preventDefault() - void handleSaveBody() - } - }} + void handleSaveBody()} /> ) : body.trim() ? ( )} @@ -2851,6 +2861,7 @@ function PRActionsPanel({ const confirm = useConfirmationDialog() const actionItem = { ...item, state: localState } const mergePresentation = presentGitHubPRMergeState(actionItem) + const mergeMethods = resolveGitHubPRMergeMethods(actionItem.mergeMethodSettings) const canMutateState = localState !== 'merged' && (!!repoPath || !!projectOrigin) const nextState: 'open' | 'closed' = localState === 'closed' ? 'open' : 'closed' const mergeDisabled = !repoPath || mergePending || !mergePresentation.directMergeAvailable @@ -2912,12 +2923,11 @@ function PRActionsPanel({ } } - const handleMerge = async (method: 'merge' | 'squash' | 'rebase'): Promise => { + const handleMerge = async (method: GitHubPRMergeMethod): Promise => { if (!repoPath || mergeDisabled) { return } - const label = - method === 'squash' ? 'Squash and merge' : method === 'rebase' ? 'Rebase and merge' : 'Merge' + const label = GITHUB_PR_MERGE_METHOD_LABELS[method] const confirmed = await confirm({ title: `${label} PR #${item.number}?`, description: 'This will update the pull request on GitHub.', @@ -3005,7 +3015,9 @@ function PRActionsPanel({ )} {mergePresentation.autoMergeAction?.label ?? - (mergePresentation.directMergeAvailable ? 'Merge' : mergePresentation.label)} + (mergePresentation.directMergeAvailable + ? mergeMethods.defaultLabel + : mergePresentation.label)} @@ -3025,18 +3037,16 @@ function PRActionsPanel({ )} {mergePresentation.autoMergeAction && } - void handleMerge('squash')}> - - Squash and merge - - void handleMerge('merge')}> - - Create merge commit - - void handleMerge('rebase')}> - - Rebase and merge - + {mergeMethods.methods.map(({ method, label }) => ( + void handleMerge(method)} + > + + {label} + + ))} window.api.shell.openUrl(item.url)}> Open GitHub merge box @@ -3099,31 +3109,17 @@ function CommentReactions({ function CommentReplyForm({ className, placeholder, - mentionOptions, onCancel, onSubmit }: { className?: string placeholder: string - mentionOptions: MentionOption[] onCancel: () => void onSubmit: (body: string) => Promise }): React.JSX.Element { const [body, setBody] = useState('') const [submitting, setSubmitting] = useState(false) - const textareaRef = useRef(null) - const mountedRef = useRef(true) - - useEffect(() => { - mountedRef.current = true - return () => { - mountedRef.current = false - } - }, []) - - useEffect(() => { - textareaRef.current?.focus() - }, []) + const mountedRef = useMountedRef() const submit = useCallback(async () => { const trimmed = body.trim() @@ -3144,29 +3140,18 @@ function CommentReplyForm({ setSubmitting(false) } } - }, [body, onSubmit, submitting]) + }, [body, mountedRef, onSubmit, submitting]) return (
- { - if (e.key === 'Escape') { - e.preventDefault() - onCancel() - return - } - if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { - e.preventDefault() - void submit() - } - }} + onChange={setBody} placeholder={placeholder} - rows={3} - mentionOptions={mentionOptions} - className="scrollbar-sleek min-h-20 w-full resize-y rounded-md border border-input bg-transparent px-3 py-2 text-[13px] placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" + disabled={submitting} + autoFocus + minHeightClassName="min-h-24" + onSubmitShortcut={() => void submit()} />
- ))} -
- )} -