diff --git a/config/scripts/generate-skill-bundle-manifest.mjs b/config/scripts/generate-skill-bundle-manifest.mjs index c3542d6f0e2..d4ba49c9b52 100644 --- a/config/scripts/generate-skill-bundle-manifest.mjs +++ b/config/scripts/generate-skill-bundle-manifest.mjs @@ -6,7 +6,11 @@ import path from 'node:path' import process from 'node:process' import { isDeepStrictEqual } from 'node:util' -const SCHEMA_VERSION = 1 +// Why: the three artifacts version independently — bumping one shape must not +// rewrite the others or bypass the registry's schema-gated append-only guard. +const CURRENT_MANIFEST_SCHEMA_VERSION = 2 +const SNAPSHOT_REGISTRY_SCHEMA_VERSION = 1 +const RELEASE_MAPPING_SCHEMA_VERSION = 1 const SCRIPT_DIR = import.meta.dirname const REPO_ROOT = path.resolve(SCRIPT_DIR, '..', '..') const SKILLS_ROOT = path.join(REPO_ROOT, 'skills') @@ -313,8 +317,8 @@ function skillsTreeShasAtRefs(refs) { } function buildReleasedHistory() { - const registry = { schemaVersion: SCHEMA_VERSION, skills: {} } - const mapping = { schemaVersion: SCHEMA_VERSION, releases: [] } + const registry = { schemaVersion: SNAPSHOT_REGISTRY_SCHEMA_VERSION, skills: {} } + const mapping = { schemaVersion: RELEASE_MAPPING_SCHEMA_VERSION, releases: [] } const tags = releaseTags() const treeShas = skillsTreeShasAtRefs(tags) const distinctTreeShas = [...new Set(treeShas.filter(Boolean))] @@ -366,7 +370,10 @@ function buildReleasedHistory() { return { registry, mapping } } -async function buildArtifacts(appVersion) { +// Why: the artifacts must be pure functions of skills/ bytes and release-tag +// history. Stamping the app version made every release cut invalidate the +// committed output on all open branches and drag skill CI onto unrelated PRs. +async function buildArtifacts() { const { registry, mapping } = buildReleasedHistory() const releasedSnapshotCounts = Object.fromEntries( Object.entries(registry.skills).map(([name, snapshots]) => [name, snapshots.length]) @@ -399,14 +406,12 @@ async function buildArtifacts(appVersion) { currentSkills.push({ name, sourcePath: `skills/${name}`, - appVersion, ...snapshot }) } return { currentManifest: { - schemaVersion: SCHEMA_VERSION, - appVersion, + schemaVersion: CURRENT_MANIFEST_SCHEMA_VERSION, skills: currentSkills }, snapshotRegistry: registry, @@ -419,7 +424,7 @@ async function buildArtifacts(appVersion) { // so a generation-logic change must not rewrite them silently. Only the one // unreleased working-tree append per skill may change between runs. function assertReleasedHistoryPreserved(committedRegistry, artifacts) { - if (!committedRegistry || committedRegistry.schemaVersion !== SCHEMA_VERSION) { + if (!committedRegistry || committedRegistry.schemaVersion !== SNAPSHOT_REGISTRY_SCHEMA_VERSION) { return } for (const [name, committedSnapshots] of Object.entries(committedRegistry.skills ?? {})) { @@ -466,17 +471,50 @@ async function writeArtifacts(artifacts) { ]) } +// Why: cutting a release tag adds a trailing mapping row on every checkout at +// once, before any branch can regenerate. A trailing row whose revisions all +// equal the current manifest is provably redundant until the next real +// regeneration (installs of those bytes classify as current and are labeled by +// the running build), so verify must not fail branches over it. +function isToleratedReleaseMappingPrefix(committedText, artifacts) { + let committed + try { + committed = JSON.parse(committedText) + } catch { + return false + } + const derived = artifacts.releaseMapping + const committedCount = Array.isArray(committed?.releases) ? committed.releases.length : -1 + if (committedCount < 0 || committedCount >= derived.releases.length) { + return false + } + const prefix = { + schemaVersion: derived.schemaVersion, + releases: derived.releases.slice(0, committedCount) + } + if (committedText !== serialized(prefix)) { + return false + } + const currentRevisions = Object.fromEntries( + artifacts.currentManifest.skills.map((skill) => [skill.name, skill.releaseRevision]) + ) + return derived.releases + .slice(committedCount) + .every((release) => isDeepStrictEqual(release.skills, currentRevisions)) +} + async function verifyArtifacts(artifacts) { const expected = [ - [CURRENT_MANIFEST_PATH, artifacts.currentManifest], - [SNAPSHOT_REGISTRY_PATH, artifacts.snapshotRegistry], - [RELEASE_MAPPING_PATH, artifacts.releaseMapping] + [CURRENT_MANIFEST_PATH, artifacts.currentManifest, null], + [SNAPSHOT_REGISTRY_PATH, artifacts.snapshotRegistry, null], + [RELEASE_MAPPING_PATH, artifacts.releaseMapping, isToleratedReleaseMappingPrefix] ] const stale = [] - for (const [filePath, value] of expected) { + for (const [filePath, value, tolerated] of expected) { try { await access(filePath, constants.R_OK) - if ((await readFile(filePath, 'utf8')) !== serialized(value)) { + const committedText = await readFile(filePath, 'utf8') + if (committedText !== serialized(value) && !tolerated?.(committedText, artifacts)) { stale.push(filePath) } } catch { @@ -493,8 +531,7 @@ async function verifyArtifacts(artifacts) { } async function main() { - const packageJson = JSON.parse(await readFile(path.join(REPO_ROOT, 'package.json'), 'utf8')) - const artifacts = await buildArtifacts(packageJson.version) + const artifacts = await buildArtifacts() assertReleasedHistoryPreserved(await readCommittedRegistry(), artifacts) await (process.argv.includes('--write') ? writeArtifacts : verifyArtifacts)(artifacts) } @@ -514,6 +551,7 @@ export { collectPackageFiles, describeFile, gitTreeSha, + isToleratedReleaseMappingPrefix, normalizeText, packageDigest, sortManifestFiles, diff --git a/config/scripts/generate-skill-bundle-manifest.test.mjs b/config/scripts/generate-skill-bundle-manifest.test.mjs index cf4a8a100e5..a6539f800a0 100644 --- a/config/scripts/generate-skill-bundle-manifest.test.mjs +++ b/config/scripts/generate-skill-bundle-manifest.test.mjs @@ -9,6 +9,7 @@ import { collectPackageFiles, describeFile, gitTreeSha, + isToleratedReleaseMappingPrefix, normalizeText, packageDigest, sortManifestFiles @@ -136,6 +137,56 @@ describe('skill bundle manifest generator', () => { expect(() => assertReleasedHistoryPreserved(null, artifacts)).not.toThrow() }) + it('tolerates only redundant trailing release-mapping rows', () => { + const serialized = (value) => `${JSON.stringify(value, null, 2)}\n` + const rows = [ + { appVersion: '1.0.0', skills: { 'orca-cli': 1 } }, + { appVersion: '1.1.0', skills: { 'orca-cli': 2 } } + ] + const artifacts = { + currentManifest: { skills: [{ name: 'orca-cli', releaseRevision: 2 }] }, + releaseMapping: { schemaVersion: 1, releases: rows } + } + const committedPrefix = serialized({ schemaVersion: 1, releases: [rows[0]] }) + + // A just-cut tag whose bytes equal the working tree may lag in the mapping. + expect(isToleratedReleaseMappingPrefix(committedPrefix, artifacts)).toBe(true) + // The committed file matching the derived mapping is byte-equality's job, not tolerance. + expect(isToleratedReleaseMappingPrefix(serialized(artifacts.releaseMapping), artifacts)).toBe( + false + ) + // A trailing row for bytes the committed artifacts do not describe is a real gap. + expect( + isToleratedReleaseMappingPrefix(committedPrefix, { + ...artifacts, + currentManifest: { skills: [{ name: 'orca-cli', releaseRevision: 3 }] } + }) + ).toBe(false) + expect( + isToleratedReleaseMappingPrefix(committedPrefix, { + ...artifacts, + currentManifest: { + skills: [ + { name: 'orca-cli', releaseRevision: 2 }, + { name: 'orca-linear', releaseRevision: 1 } + ] + } + }) + ).toBe(false) + // Rewritten earlier rows never pass, with or without trailing rows. + expect( + isToleratedReleaseMappingPrefix( + serialized({ + schemaVersion: 1, + releases: [{ appVersion: '0.9.0', skills: { 'orca-cli': 1 } }] + }), + artifacts + ) + ).toBe(false) + expect(isToleratedReleaseMappingPrefix('not json', artifacts)).toBe(false) + expect(isToleratedReleaseMappingPrefix(serialized({ schemaVersion: 1 }), artifacts)).toBe(false) + }) + it.runIf(process.platform !== 'win32')( 'rejects executable files in shipped skill packages', async () => { diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index 0514453128b..c18272d56a4 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -374,16 +374,27 @@ describe('Electron runtime package contract', () => { expect(afterInstallScript).not.toContain('chmod 0755 "$sandbox"') }) - it('lets release-cut tag a version that is already present on main', () => { + it('keeps release-cut version commits skill-independent and taggable on retries', () => { const releaseWorkflow = readFileSync( join(projectDir, '.github/workflows/release-cut.yml'), 'utf8' ) const parsedWorkflow = parse(releaseWorkflow) + const checkoutStep = parsedWorkflow.jobs.cut.steps.find((step) => step.name === 'Checkout ref') const bumpStep = parsedWorkflow.jobs.cut.steps.find( (step) => step.name === 'Bump package.json and tag' ) + const bumpIndex = bumpStep.run.indexOf( + 'npm version "$VERSION" --no-git-tag-version --allow-same-version' + ) + const stageIndex = bumpStep.run.indexOf('git add package.json') + expect(checkoutStep.with['fetch-depth']).toBe(0) + expect(bumpIndex).toBeGreaterThanOrEqual(0) + expect(stageIndex).toBeGreaterThan(bumpIndex) + // Why: version-only cuts must not mutate content-addressed skill artifacts. + expect(bumpStep.run).not.toContain('generate-skill-bundle-manifest') + expect(bumpStep.run).not.toContain('resources/skills') expect(bumpStep.run).toContain('git diff --cached --quiet') expect(bumpStep.run).toContain('git commit --allow-empty -m "$commit_message"') }) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 417924fc997..d96e1bddd40 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 5.8m + + downloads: 5.9m @@ -15,7 +15,7 @@ downloads downloads - 5.8m - 5.8m + 5.9m + 5.9m diff --git a/docs/reference/browser-local-https-certificate-trust.md b/docs/reference/browser-local-https-certificate-trust.md new file mode 100644 index 00000000000..d858e030cb9 --- /dev/null +++ b/docs/reference/browser-local-https-certificate-trust.md @@ -0,0 +1,926 @@ +# Built-in Browser Local HTTPS and Certificate Trust + +## Status + +Implemented reference design for +[issue #8454](https://github.com/stablyai/orca/issues/8454). + +The branch includes scheme-less local-dev URL classification, certificate-specific +failure copy, `Try HTTPS`, exact per-WebContents certificate grants, +`Proceed Anyway (Unsafe)`, local IPC, remote/headless runtime propagation, and +focused unit and real-Electron coverage. A session-level request gate contains +Chromium's cached certificate continuation so approval cannot leak to sibling +tabs, assets, fetches, iframes, or WebSockets. The release gates below remain +the acceptance contract for Electron upgrades and cross-platform validation. + +## Summary + +Orca's built-in browser cannot open a local HTTPS development server when the +server presents an untrusted certificate. A scheme-less local address also +defaults to HTTP, so entering `localhost:3000` does not discover an HTTPS-only +server. + +This design keeps HTTP as the default for the scheme-less local forms that +already use it, adds a visible `Try HTTPS` recovery action after a failed local +HTTP navigation, and adds an explicit `Proceed Anyway (Unsafe)` path for an +untrusted local HTTPS certificate. Certificate approval is narrow and +temporary: it is bound to one browser WebContents, one secure endpoint, the +SHA-256 digest of the exact leaf certificate, and the specific certificate +error. A secure endpoint treats `https` and its companion `wss` scheme as one +TLS endpoint but still includes the canonical hostname and effective port. No +approval state is persisted. + +The certificate decision runs in the process that owns the browser page. Local +desktop webviews use the desktop main process; SSH/headless pages use the remote +runtime process and expose the decision through runtime RPC. + +## Problem + +There are three independent behaviors behind the current failure. + +1. `normalizeBrowserNavigationUrl` and the tab-create entry classifier prepend + `http://` to scheme-less loopback input. Explicit `https://` input is already + preserved. +2. Electron rejects an untrusted server certificate by default. Orca has no + `certificate-error` decision flow, so a self-signed development certificate + fails with a Chromium certificate error such as + `ERR_CERT_AUTHORITY_INVALID (-202)`. +3. Browser load errors are presented as generic connectivity failures. For a + loopback URL, Orca advises the user to check that the server is running even + when the server responded and only certificate verification failed. + +The same trust limitation exists in the offscreen browser backend used by +headless and SSH-owned browser pages. A desktop-only handler would fix the +visible local webview while leaving remote browser ownership inconsistent. + +## Goals + +- Show certificate-specific failure copy for main-frame certificate errors. +- Let a user explicitly approve an untrusted local development certificate. +- Bind approval to the exact browser surface, secure endpoint, leaf certificate + digest, and certificate error. +- Keep approval in memory only and clear it with the owning browser surface. +- Support both desktop webviews and SSH/headless offscreen browser pages. +- Give scheme-less local input a discoverable path to HTTPS without silently + changing the existing HTTP default. +- Preserve the current browser sandbox, navigation allowlist, session profiles, + and mixed-content policy. +- Keep browser failure state accurate across event-order races, early webview + attachment, reload, stale user actions, and remote latency. + +## Non-goals + +- Do not automatically trust a self-signed certificate. +- Do not disable `webSecurity`, enable insecure mixed content, or add + `--ignore-certificate-errors`. +- Do not install a certificate into the operating-system trust store. +- Do not persist certificate exceptions across app restarts, browser surface + recreation, or session-profile changes. +- Do not default every scheme-less localhost URL to HTTPS. +- Do not automatically probe HTTPS and then downgrade to HTTP. +- Do not add a general-purpose exception flow for public websites in v1. +- Do not make every Chromium certificate error overridable in v1. +- Do not extend localhost worktree-label proxying to HTTPS targets. + +## Product Decisions + +### Scheme-less local input continues to use HTTP + +`localhost:3000` remains `http://localhost:3000/`. HTTP is still the common +development-server default, and changing the inference globally would turn +working HTTP servers into certificate or TLS failures. + +When an HTTP navigation to a loopback URL fails, the browser failure overlay +offers `Try HTTPS`. The action replaces only the URL scheme and preserves the +host, explicit non-default port, path, query, and fragment. An absent port, or +an explicit `:80` normalized away by the URL parser, becomes HTTPS's default +port 443. Orca does not automatically retry because an invisible +cross-protocol retry would add latency, send an unexpected second request, and +make downgrade behavior opaque. + +Explicit input remains authoritative: + +- `http://localhost:3000` stays HTTP. +- `https://localhost:3000` stays HTTPS. +- URLs advertised by a workspace process keep their advertised scheme. + +The shared browser URL domain becomes the source of truth for the existing +scheme-less local-dev classification. The tab-create entry classifier calls +that shared logic instead of keeping a second regular expression and scheme +rule. This consolidation must not reuse the stricter certificate-eligibility +predicate: doing so would change current behavior for inputs such as +`0.0.0.0`, bracketed non-loopback IPv6 addresses, and `.localhost` subdomains. + +### V1 approval covers untrusted local authorities only + +The first version offers `Proceed Anyway (Unsafe)` only when all of these are +true: + +- the failure is for the main frame; +- the attempted URL is HTTPS; +- Electron reports `net::ERR_CERT_AUTHORITY_INVALID`, corresponding to + Chromium load error `-202`; +- the host is an eligible loopback host; +- the WebContents is a browser surface managed by Orca; and +- Orca retained a live pending challenge for that browser surface. + +Other certificate errors receive accurate copy but no bypass action. In +particular, v1 does not bypass revoked certificates, malformed certificates, +weak keys, date errors, or hostname/SAN mismatches. Expanding the allowlist +requires a separate security decision and dedicated tests. + +### Eligible loopback hosts + +The shared predicate accepts: + +- `localhost`, case-insensitively, with an optional trailing dot; +- valid subdomains of `.localhost`, also with an optional trailing dot; +- valid IPv4 addresses in `127.0.0.0/8`; and +- IPv6 loopback `::1`. + +The predicate does not treat `0.0.0.0`, `::`, arbitrary bracketed IPv6 +addresses, private LAN addresses, or a DNS name that merely resolves to +loopback as certificate-bypass eligible. Wildcard bind addresses should already +be normalized to a connectable loopback address before navigation. + +SSH local forwards are eligible when their browser-facing URL uses one of these +loopback hosts. Forwarding does not fix certificate names: a URL using +`127.0.0.1` still needs an IP SAN for `127.0.0.1`; a certificate containing only +`localhost` will fail with `ERR_CERT_COMMON_NAME_INVALID`, which v1 does not +bypass. Custom advertised DNS names remain subject to normal system trust in +v1, even if the name maps to loopback. + +### Approval lifetime + +An accepted grant is scoped to: + +```text +browser WebContents ID + secure endpoint + leaf SHA-256 digest + error name +``` + +The secure endpoint canonicalizes `wss:` to its companion `https:` endpoint and +includes the normalized hostname and effective port. This lets an approved +local page load same-endpoint HTTPS assets and its WSS development socket. A +grant for `https://localhost:3000` does not apply to port 3001, to +`https://127.0.0.1:3000`, to another browser tab, or to a replacement +certificate. + +Construct the secure endpoint from the parsed request URL: map `https:` and +`wss:` to `https:`, lowercase the hostname, remove one optional trailing dot, +use the URL parser's canonical IP representation, and use port 443 when the +parsed port is empty. Exclude credentials, path, query, and fragment. Reject +every other scheme. + +Accepted grants survive reloads and same-surface navigation back to the exact +endpoint. They are cleared when the WebContents commits a main-frame navigation +whose canonical secure endpoint differs from the grant's endpoint (reusing the +per-WebContents navigation-sequence tracking), when the WebContents is destroyed +or replaced, the session profile changes, the browser-owning process exits, or +the app restarts. Binding grant lifetime to the granting main-frame document +prevents a later top-level document in the same tab — for example a navigation +to a public site — from silently reusing the loopback grant for a same-endpoint +iframe, fetch, or WSS subrequest. +Clearing a grant affects future requests; it cannot retroactively terminate a +document that already loaded. Grants are not written to settings, browser +history, workspace session state, or logs. + +Chromium caches an accepted `certificate-error` continuation across the +Electron session partition. Orca therefore retains every endpoint/certificate +identity accepted in that partition until the browser-owning process exits and +gates all later HTTPS/WSS requests with `webRequest.onBeforeRequest`. Only the +WebContents holding the matching grant may reach a cached endpoint. A sibling +main-frame navigation receives a synthetic challenge for the known identity; +sibling subresources, fetches, iframes, workers, and WSS requests fail silently. + +Because a preflight request gate cannot inspect the certificate that will be +presented after Chromium's cache is consulted, one endpoint is locked to the +first accepted untrusted leaf identity for that process lifetime. If that +endpoint later presents a different untrusted leaf, strict verification rejects +it and Orca does not offer another bypass until the browser-owning process +restarts. A replacement system-trusted certificate may load normally; the +conservative request gate still keeps sibling WebContents blocked until restart. + +## User Experience + +### Local HTTP failure + +For a failed `http://` loopback navigation, keep the current connectivity +title and recovery hint. Add `Try HTTPS` when an HTTPS form can be constructed. + +Example: + +```text +Can't reach localhost:3000 +We couldn't connect to your local server. + +[Try HTTPS] [Retry] [Copy Address] [Open Externally] +``` + +`Try HTTPS` is the default button because it is the targeted recovery action +for a local HTTP failure. Keep the existing `Copy Address` recovery action; +`Retry` uses `outline`, while `Copy Address` and `Open Externally` stay quiet +secondary actions. `Try HTTPS` does not appear for an already-HTTPS URL or a +non-eligible host. + +### Certificate failure + +For `ERR_CERT_AUTHORITY_INVALID (-202)` on an eligible local endpoint, replace +the generic connectivity content with: + +```text +Connection isn't secure +Orca doesn't trust the authority that issued the certificate for localhost:3000. + +For local development, use a trusted local certificate when possible. + +[Open Externally] [Retry] [Copy Address] [Proceed Anyway (Unsafe)] +``` + +- `Open Externally` is the default safe recovery action when the URL is + reachable from the desktop. +- When `Open Externally` is unavailable, `Retry` becomes the default action. +- `Proceed Anyway (Unsafe)` uses `outline`; `Copy Address` stays quiet. +- `Proceed Anyway (Unsafe)` is always visibly labeled; it is not hidden behind + a tooltip. +- The unsafe action does not use the destructive color. It does not delete or + irreversibly mutate user data, and the explicit label carries the warning. +- Use a muted `ShieldAlert` icon from `lucide-react`; do not add a warning color + or new token. + +For a remote-owned page whose URL is remote localhost, `Open Externally` is +hidden because the desktop system browser cannot reach the remote loopback +address. It remains available for a desktop-owned page or a locally forwarded +URL. + +For certificate errors that v1 does not permit bypassing, show the same +certificate-specific title with error-specific body text, `Retry`, +`Copy Address`, and an eligible `Open Externally` action. `Copy Address` remains +present as a quiet secondary action in every certificate-failure branch, +eligible or not, so only `Try HTTPS` and `Proceed Anyway (Unsafe)` toggle in and +out across cases. The same default-action rule applies as for `-202`: +`Open Externally` is the default when the URL is reachable from the desktop, +otherwise `Retry` becomes the default. Do not show the local-server-running +hint. + +Use this presentation mapping, inserting the display host into the copy: + +| Chromium code | Body copy | Proceed in v1 | +| ----------------------- | -------------------------------------------------------------------------- | ----------------------------- | +| `-200` | `The certificate doesn't match {host}.` | No | +| `-201` | `The certificate for {host} isn't valid at the current date and time.` | No | +| `-202` | `Orca doesn't trust the authority that issued the certificate for {host}.` | Eligible local endpoints only | +| Other certificate error | `Orca couldn't verify the certificate for {host}.` | No | + +Keep the raw error name/code available to diagnostics and optional details, but +do not make users interpret it to understand the primary failure. + +### Interaction behavior + +- The overlay is persistent inline UI because the user must read and act on the + failure; do not use a toast. +- The overlay does not steal focus when it appears. +- Actions are reachable by Tab and have visible focus rings. +- Use `aria-live="polite"`; repeated background failures must not repeatedly + announce the same message. +- Clicking `Proceed Anyway (Unsafe)` disables the action immediately, and also + disables the other overlay actions (`Retry`, `Try HTTPS`, `Copy Address`, + `Open Externally`) for the duration of the approval round-trip so a stale + click cannot race the controller's own `loadURL`. Re-enable them once a + success or typed-failure response returns. +- Map every `proceedCertificate` failure reason to an overlay outcome: + `expired`, `changed`, `ineligible`, and `missing` keep the overlay open and + show the inline recovery message below; `navigated` shows no message because + the overlay for that navigation is already cleared. In every non-success case + `Proceed Anyway (Unsafe)` stays disabled until a fresh pending challenge is + announced rather than re-enabling on its own. +- Delay the spinner by 200 ms, matching the style guide's remote-latency rule. + After that delay, show the canonical `Loader2` with `Connecting…` and use a + fixed button width so the label swap cannot move adjacent actions. +- If approval expired or the certificate changed, keep the overlay open and + show `The certificate changed or the approval expired. Retry the page.` +- A successful main-frame navigation clears the visible certificate failure. + +The UI must use existing `background`, `foreground`, `muted-foreground`, +`border`, and `ring` tokens plus the existing shadcn `Button` primitive. No new +color, radius, or shadow tier is required. Do not apply a blanket opacity to +the overlay's interactive content; use semantic muted text classes so button +and focus-ring contrast remains intact. Put every new user-visible string +through the existing localization catalog. + +## Architecture + +### Ownership + +The browser-owning main process is the only authority that can accept a +certificate challenge. + +- Desktop-owned `` pages are controlled by the desktop main process. +- Headless/SSH-owned pages are controlled by the remote `orca serve` main + process through `OffscreenBrowserBackend`. +- The renderer only presents a pending challenge and requests approval. It + never decides whether a certificate is trusted. + +### New main-process controller + +Add `src/main/browser/browser-certificate-trust-controller.ts` to manage pending +certificate challenges, and +`src/main/browser/browser-certificate-request-guard.ts` to manage grants plus +the session-level cached-certificate request boundary. + +The controller owns: + +```ts +type BrowserCertificateFailure = { + challengeId: string + browserPageId: string + errorCode: number | null + error: string + origin: string + displayHost: string + canProceed: boolean + observedAt: number +} + +type PendingBrowserCertificateChallenge = { + challengeId: string + guestWebContentsId: number + browserPageId: string | null + navigationSequence: number + navigationUrl: string + origin: string + secureEndpoint: string + leafCertificateSha256: string + errorCode: number | null + error: string + expiresAt: number +} + +type BrowserCertificateGrant = { + guestWebContentsId: number + secureEndpoint: string + leafCertificateSha256: string + error: string +} +``` + +Generate challenge IDs with `randomUUID()`. Compute the certificate identity in +the browser-owning main process as SHA-256 over the DER bytes of Electron's leaf +`certificate.data`; do not depend on the undocumented formatting or algorithm +of `certificate.fingerprint`. Renderer and runtime payloads expose no +certificate bytes or digest—only the challenge ID, display host, origin, error +code, error name, `canProceed`, and observation time. + +Pending challenges expire after five minutes and are bounded to 32 entries. +Accepted grants are bounded to 32 entries per browser-owning process and are +also removed with their WebContents. Bounds evict the oldest entry and notify +the affected UI when a live challenge is evicted. Ordinary cleanup should keep +both collections much smaller. Accepted endpoint identities are not evicted: +Chromium's corresponding cache cannot be cleared narrowly, so forgetting one +would reopen cross-WebContents access until process exit. + +### Certificate event handling + +Register exactly one app-level `certificate-error` listener after Electron is +ready but before either a desktop window or the offscreen backend can create a +browser page. Every branch must call Electron's callback exactly once; an +unexpected exception fails closed with `callback(false)`. + +Electron supplies the certificate error as a string on this event, while +`did-fail-load` supplies the Chromium number later. Normalize the string to one +canonical error name and map only known names to numbers for presentation. An +unknown name keeps `errorCode: null` and is never bypass-eligible. + +For every event: + +1. Resolve the WebContents through `BrowserManager` and reject unmanaged, + retired, or popup WebContents. +2. Parse the request URL, normalize its secure endpoint, and compute the leaf + certificate SHA-256 digest. Invalid data fails closed. +3. Compare the normalized error name, endpoint, and digest against an accepted + grant for this exact WebContents. On an exact match, first retain the accepted + identity in the owning Electron session, then call `event.preventDefault()` + and `callback(true)`. +4. If no grant matches, reject non-main-frame failures without creating a + challenge. Subresources and iframes can consume an existing grant but cannot + mint one. +5. For a main-frame failure, record a pending challenge only when the URL and + error are eligible. Notify the owning page when its page ID is known, then + call `callback(false)`. + +Track a monotonically increasing main-frame navigation sequence per +WebContents. A new main-frame navigation invalidates its pending challenge in +both main and renderer state before the next certificate decision. Repeated +identical events within one sequence reuse the challenge ID; a retry or a +different navigation gets a new ID. This prevents an old button or compromised +renderer from approving a page the user has already left. + +The first failure is always rejected. User approval records a grant and reloads +the page; the next certificate event is the one that succeeds. + +Do not leave Electron's callback pending while waiting for user input. Rejecting +first gives Orca a normal load-failure lifecycle, avoids an unbounded blocked +navigation, and makes stale approval cleanup deterministic. + +Do not use `session.setCertificateVerifyProc`. It applies to an entire session +partition, cannot bind a decision to one browser page, and verification results +can be cached by Chromium's network service. + +### Session request gate and Electron upgrade guard + +The pinned Electron 43 binary caches `callback(true)` broadly enough that a +second WebContents in the same partition can load the endpoint without another +`certificate-error` event. The certificate callback alone is therefore not an +isolation boundary. + +Install exactly one `webRequest.onBeforeRequest` listener on every Orca browser +session before its first page loads. Electron permits only one listener for this +event, so the browser session registry owns installation and removal. Once an +endpoint identity has been accepted, the listener blocks every HTTPS/WSS +request to that endpoint unless `details.webContentsId` has the matching grant. +Requests without a WebContents ID, including background/worker traffic, fail +closed. A blocked main-frame request recreates the challenge and certificate +load error; other resource types are canceled without replacing the current +page's overlay. + +The real-Electron integration suite must prove same-tab reload, same-endpoint +HTTPS assets and WSS, different-tab documents and subresources, and +different-port isolation against the exact Electron version in +`pnpm-lock.yaml`. Any Electron upgrade that breaks those tests blocks release +until the design is revised; unit mocks are insufficient. + +### BrowserManager integration + +Add a narrow BrowserManager query API instead of exposing its internal maps: + +```ts +getManagedBrowserGuestContext(webContentsId: number): { + browserPageId: string | null + worktreeId: string | null + sessionProfileId: string | null + owner: 'desktop-webview' | 'offscreen' +} | null +``` + +For guest lifecycle, follow the existing extracted-controller precedent +(`browser-grab-session-controller.ts`, `browser-guest-ui.ts`, +`browser-download-destination.ts`), all of which BrowserManager drives by +calling a plain method on the controller directly from `registerGuest` / +`unregisterGuest`. Have BrowserManager call +`certificateTrustController.onGuestRegistered({ browserPageId, webContentsId })` +and `.onGuestRetired(webContentsId)` from those same bodies, rather than adding a +new listener/emitter registration API to BrowserManager — there is no such +pub-sub surface in `src/main/browser` today and this single consumer does not +warrant introducing one. + +`getManagedBrowserGuestContext` must recognize an attached primary desktop +guest before renderer registration, returning a null page ID, while rejecting +popup descendants that merely inherited browser policies. It must also +recognize offscreen pages after `registerOffscreenGuest` and stop recognizing a +guest immediately when BrowserManager retires or unregisters it. The controller +re-checks this ownership on every certificate event even if it still has an +in-memory grant. + +An attached desktop webview can encounter a certificate error before its +document reaches `dom-ready`, which is when BrowserPane currently registers the +guest. Keep pending challenges keyed by WebContents ID until registration +supplies the page ID, then flush the failure to the renderer. + +BrowserPane should also register idempotently on the webview's `did-attach` +event, with `dom-ready` retained as a fallback. `registerGuest` should report +whether the main process accepted the registration so a rare attach-policy race +can retry at `dom-ready`; the renderer must not cache the WebContents ID as +registered until it receives `true`. + +Offscreen pages already register before `loadURL`, so their page ID is normally +available when the certificate event fires. Bring their main-frame navigation +and `did-fail-load` observation under the same BrowserManager lifecycle used by +desktop guests; the current offscreen loader only logs a rejected `loadURL`, so +it cannot by itself populate runtime failure snapshots. + +### Renderer state + +Certificate challenges are transient runtime state and must not be added to +the persisted `BrowserPage` schema. + +Add a transient map to the browser store: + +```ts +browserCertificateFailuresByPageId: Record +``` + +The workspace session serializer must omit this map. Clear an entry when: + +- the page completes a successful main-frame navigation; +- the user starts a different navigation; +- the page or workspace closes; +- the owning browser WebContents is replaced; or +- the main process reports that the challenge expired. + +The existing `BrowserLoadError` remains the persisted diagnostic source. Add a +pure Chromium-error classifier for Electron 43's certificate range (`-200` +through `-219`, excluding the unused values) so certificate failures still +render accurate copy after restore even when no live challenge exists. A live +certificate failure's error code takes presentation precedence over a +synthesized `-1` Chromium-error-page fallback. Independently, only a live, +matching challenge controls whether `Proceed Anyway (Unsafe)` is enabled. + +Use both the page ID and attempted origin when combining the live challenge +with `BrowserLoadError`. A stale challenge from a prior navigation must never +add a proceed action to a different failure. Main-process navigation-sequence +invalidation is the security boundary; renderer cleanup is defense in depth and +keeps the UI honest. + +### Local IPC + +Extend the browser preload API with: + +```ts +onCertificateFailureChanged( + callback: (event: { + browserPageId: string + failure: BrowserCertificateFailure | null + }) => void +): () => void + +proceedCertificate(args: { + browserPageId: string + challengeId: string +}): Promise< + | { ok: true } + | { + ok: false + reason: 'expired' | 'changed' | 'ineligible' | 'missing' | 'navigated' + } +> +``` + +Register the handler with the existing browser IPC group and reuse +`isTrustedBrowserRenderer`. The main process resolves the page to its current +WebContents and then consumes the exact pending challenge. Renderer-provided +origin, error code, or certificate identity are never trusted. + +On success, main consumes the challenge, records the grant, and calls +`loadURL` with the challenge's main-owned `navigationUrl`. It must not use +`webContents.getURL()`, which may be `chrome-error://chromewebdata/` or a +previously committed page. It also must not reload if the page's current +navigation sequence no longer matches the challenge. The renderer does not +assign `webview.src` as a second side effect. + +### Remote runtime RPC + +The remote browser owner exposes equivalent behavior through: + +```text +browser.certificate.proceed +``` + +with the runtime worktree selector, remote browser page ID, and challenge ID. +Advertise support through a new `browser.certificate-trust.v1` capability only +when the runtime has both a browser backend and the certificate controller. +Keep the method out of the mobile-scope RPC allowlist; it is for an +authenticated runtime-scope Orca client presenting browser chrome, not a guest +page or unauthenticated caller. + +Extend `RuntimeMobileSessionBrowserTab` compatibly with optional `loadError` +and `certificateFailure` fields. Both are emitted from live runtime state. +`loadError` keeps the existing `BrowserPage` last-known diagnostic semantics, +including workspace persistence; `certificateFailure` is transient and is +never written to workspace persistence. Controller changes, challenge expiry, +navigation, and offscreen `did-fail-load` must mark the runtime session snapshot +dirty so clients do not wait for unrelated state to change. Web clients and +desktop clients reconcile these fields through the existing remote page-handle +mapping. Each full snapshot replaces the prior transient certificate failure +for that page, including clearing it when the field is absent. An older runtime +without the capability always clears any previously mirrored proceed action. + +When the active page has a remote owner, BrowserPane routes approval to that +environment. It must not call local desktop IPC. If the runtime lacks the new +capability, the UI still classifies the certificate failure accurately but does +not render `Proceed Anyway (Unsafe)`. `Try HTTPS`, retry, and other navigation +actions continue through the existing owner-aware navigation path rather than +touching a local webview for a remote-owned page. + +### Scheme recovery + +Add pure functions in the shared browser URL domain: + +```ts +classifySchemeLessLocalDevAddress(rawInput: string): URL | null +isEligibleLocalCertificateHost(hostname: string): boolean +toHttpsRecoveryUrl(rawUrl: string): string | null +``` + +`classifySchemeLessLocalDevAddress` preserves the exact current HTTP-default +input set and is shared by address-bar normalization and tab-create entry. It +is deliberately broader than certificate eligibility. + +`isEligibleLocalCertificateHost` accepts a parsed/canonical hostname; normalize +case and an optional trailing dot and handle the brackets returned by +`URL.hostname` for IPv6. Validate IPv4 octets and DNS label boundaries instead +of using a substring or loose suffix check. + +`toHttpsRecoveryUrl` returns a value only for an HTTP URL with an eligible +loopback host. It changes `protocol` to `https:` on a parsed `URL` object and +preserves credentials, hostname, an explicit non-default port, path, query, and +fragment. When no port is present, including when the parsed HTTP URL normalized +`:80` away, the result uses HTTPS's default port 443. It never probes either +protocol. + +Both address-bar submission and tab-create entry use the shared local-address +classifier. Existing advertised workspace-port URLs remain authoritative and +require no new probing. + +## Security Model + +### Invariants + +- Default certificate verification remains strict. +- Only a trusted Orca renderer or authenticated runtime-scope Orca client can + request approval. +- The renderer cannot choose the origin, certificate digest, error, + WebContents, or eligibility result. +- A grant is exact-match only and never applies across tabs, ports, + certificates, or browser-owning processes. +- Session-cached accepted endpoints remain request-gated until process exit, + even after their original WebContents or bounded grant is removed. +- Certificate PEM/DER data and certificate digests do not enter workspace + state, history, telemetry, or ordinary logs. +- Subresource and iframe certificate failures cannot mint an approval action; + they can consume an already-approved exact-endpoint grant on the same + WebContents. +- The proceed affordance exists only in Orca chrome; guest content has no + direct approval channel. Main-process challenge validation remains the + security boundary and does not assume proof of a physical click. +- A remote client cannot approve a certificate in a different runtime + environment. +- Starting another main-frame navigation invalidates the pending approval in + the browser-owning process, not only in renderer state. + +### Threat cases + +| Threat | Required behavior | +| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| Compromised web page sends forged IPC | Guest WebContents has no preload API; trusted-renderer check rejects it. | +| Compromised renderer invents a challenge | Main consumes only a server-created pending challenge tied to the current page and WebContents. | +| Certificate changes after approval | Leaf-digest mismatch rejects; a second untrusted leaf for that endpoint cannot be bypassed until restart. | +| Same host uses a different port | Secure-endpoint mismatch rejects. | +| Another tab visits the approved endpoint | WebContents mismatch rejects. | +| Approved tab later loads a foreign top document | Grant is invalidated when the main frame navigates to a different endpoint; the new document cannot reuse it. | +| Challenge is replayed | Challenge ID is single-use and expires. | +| User leaves the failed page before clicking | Navigation-sequence mismatch rejects without reloading the stale URL. | +| Approved page opens same-endpoint WSS/HMR | Existing endpoint grant permits it; WSS cannot create a grant by itself. | +| Public site presents an untrusted certificate | Host eligibility fails; no proceed action is available. | +| Remote page approval is sent locally | Ownership routing and remote page handles select the browser-owning runtime. | +| App restarts after approval | No persisted grant exists. | + +## Failure Handling + +- If a certificate event arrives before guest registration, retain it by + WebContents ID and flush after registration. +- If guest registration never completes, expire the pending challenge without + notifying unrelated pages. +- If `Proceed` arrives after expiry, navigation, or WebContents replacement, + return a typed failure and leave strict verification in place. +- If reload fails for a different reason, clear the certificate challenge and + present the new load error. +- If the same rejected challenge fires repeatedly in one navigation, coalesce + it without rotating the challenge ID or stacking announcements. +- If the certificate-error listener cannot parse the URL or leaf certificate, + reject normally and do not offer approval. +- If the remote connection drops during approval, keep the local UI stable and + surface the runtime error inline after the latency threshold. +- If an unexpected controller error occurs, call Electron's callback once with + `false`; never leave the request suspended. +- If an accepted main-frame page later opens a new same-endpoint TLS connection, + apply the exact grant to that HTTPS/WSS subrequest. Reject unmatched + subrequests silently without replacing the main-frame overlay. +- If an accepted endpoint presents a different untrusted leaf, reject it without + replacing the process-lifetime accepted identity or offering a second bypass. + +## Data and Privacy + +No migration is required. + +Add no new product analytics in v1. Do not record URLs, certificate digests, +challenge IDs, certificate subjects, or approval results. If product analytics +becomes a requirement later, define coarse actions in the existing typed +feature-interaction catalog as a separate reviewed change. + +Existing diagnostics may retain the Chromium numerical error code, as they do +for other browser load failures. Debug logging must contain at most the error +name, page ID, owner kind, and normalized loopback host class; omit query +strings, full URLs, and certificate digests. + +## Test Plan + +### Shared URL tests + +- Explicit HTTP and HTTPS local URLs remain unchanged. +- Scheme-less `localhost`, IPv4 loopback, and IPv6 loopback still normalize to + HTTP. +- Tab-create and address-bar local-address classification stay in parity for + the full legacy set, including wildcard and bracketed IPv6 inputs. +- The broader scheme-less classifier is not accidentally substituted for the + stricter certificate-eligibility predicate. +- `toHttpsRecoveryUrl` preserves host, an explicit non-default port, path, + query, and fragment; a URL with no parsed port changes from HTTP's default 80 + to HTTPS's default 443. +- `Try HTTPS` is unavailable for HTTPS, file URLs, public hosts, wildcard bind + hosts, private LAN hosts, and invalid URLs. +- Loopback eligibility covers `localhost`, trailing-dot localhost, + `.localhost` subdomains with and without a trailing dot, the full valid IPv4 + 127/8 range, and bracketed or unbracketed `::1` after URL parsing. +- Reject malformed IPv4, arbitrary bracketed IPv6, `0.0.0.0`, `::`, and names + that only contain the word `localhost`. + +### Main-process trust-controller tests + +- Unmanaged, retired, and popup WebContents are rejected without pending + challenges. +- A managed `-202` loopback failure creates one pending challenge and calls + `callback(false)`. +- Every event path calls its callback exactly once; only an accepted grant calls + `preventDefault()` and `callback(true)`. +- The digest is SHA-256 over DER leaf bytes and is stable across PEM formatting. +- An exact accepted grant covers same-endpoint HTTPS assets, WSS, and iframes on + the same WebContents without allowing those requests to mint a challenge. +- Wrong page, WebContents, origin, port, certificate digest, error type, and + challenge ID fail closed. +- A token is single-use and expires after five minutes. +- Repeated identical failures in one navigation coalesce; a new navigation + invalidates the token and rotates the challenge ID. +- Approving after navigation returns `navigated` and never reloads the old URL. +- Approval loads the main-owned challenged URL, not the current Chromium error + URL or a renderer-supplied URL. +- Certificate rotation fails closed and cannot replace the process-lifetime + accepted endpoint identity. +- WebContents destruction removes pending challenges and grants. +- Bounds evict the oldest pending challenge/grant. +- Registering a page after an early certificate event flushes the event to the + correct renderer. +- Concurrent tabs and session profiles remain isolated. + +### IPC and runtime tests + +- Browser certificate IPC rejects untrusted senders and malformed arguments. +- Renderer-supplied origin or certificate-identity fields are neither accepted + nor used. +- Local approval reloads only the currently mapped guest. +- Remote approval calls the owning environment's RPC with the remote page ID. +- Local IPC is not called for a remote-owned page. +- Mobile-scope runtime clients cannot call the approval method. +- Older runtimes without `browser.certificate-trust.v1` never show an enabled + proceed action. +- Runtime snapshots carry live load errors and certificate failure state, + publish immediately on changes, and do not add certificate challenges to + persisted session schemas. +- A snapshot that omits or clears a challenge removes the mirrored proceed + action rather than leaving a stale one behind. +- Two runtime environments with identical page IDs cannot approve each other's + challenge. + +### Renderer tests + +- `-202` renders certificate-specific copy and no server-running hint. +- A live certificate challenge still wins when Chromium's error-page fallback + produced diagnostic code `-1`. +- A live eligible challenge renders `Proceed Anyway (Unsafe)`. +- A restored `-202` load error without a live challenge remains accurate but + cannot proceed. +- Other certificate errors render accurate copy without v1 bypass. +- Local HTTP failures render `Try HTTPS`; HTTPS and non-loopback failures do not. +- Starting a new navigation clears stale certificate state. +- Success clears the overlay. +- Proceed disables immediately, delays the spinner, and preserves its width. +- Expired/changed responses show the inline recovery message. +- Keyboard focus and `aria-live` behavior do not regress the address bar. +- `Copy Address` remains available, interactive content is not blanket-dimmed, + and all new strings are present in the localization catalog. + +### Electron integration + +Run a local HTTPS server with a generated certificate whose SAN includes +`localhost` but whose authority is not trusted. + +1. `https://localhost:` fails with `-202` and shows certificate copy. +2. `Proceed Anyway (Unsafe)` reloads and renders the page. +3. HTTPS assets and a WSS echo/HMR endpoint on the same host and port load after + approval; a different-port asset remains blocked. +4. Reload in the same tab remains allowed without broadening the grant. +5. A second tab using the same session profile, origin, and certificate remains + blocked. This is a release-blocking assertion against the pinned Electron + binary, not a mocked controller test. +6. Navigating away before clicking makes the old challenge unusable and does + not pull the tab back to the failed URL. +7. Replacing the certificate keeps the original tab blocked and offers no + second bypass until the browser-owning process restarts. +8. Closing and recreating the browser surface removes the grant. +9. Restarting Orca removes the grant. +10. A system-trusted local certificate loads without an interstitial. +11. A public/non-loopback untrusted origin has no proceed action. +12. A hostname/SAN mismatch shows certificate copy but no proceed action. +13. `localhost:` first attempts HTTP and its failure offers `Try HTTPS`. + +Repeat the ownership-sensitive cases for: + +- a local desktop webview; +- an SSH local port forward opened in the desktop browser, using a certificate + whose SAN matches the browser-facing forwarded hostname; and +- a headless/offscreen browser page owned by `orca serve` under Linux/Xvfb. + +## Implementation Plan + +1. Add the shared scheme-less local classifier, strict certificate-host + predicate, secure-endpoint canonicalizer, and HTTPS-recovery function with + focused tests. +2. Consolidate address-bar and tab-create local classification without changing + current scheme defaults. +3. Add BrowserManager ownership/registration/retirement hooks, early + `did-attach` registration with a `dom-ready` fallback, and offscreen load + failure observation. +4. Add shared certificate failure/result types and the main-process trust + controller, including navigation-sequence invalidation and the app-level + listener. Its certificate-event handling resolves ownership through the + BrowserManager hooks from step 3, so those must land first. +5. Add local IPC, preload APIs, and transient renderer state. +6. Add the certificate-specific classification and copy as new exported + functions/branches in the existing `browser-notices.ts` (which already owns + load-failure copy via `formatLoadFailureDescription` / + `formatLoadFailureRecoveryHint`), and extract only the overlay JSX into a + focused `browser-load-failure-overlay.tsx` module instead of growing the + already grandfathered `BrowserPane.tsx`; do not add or extend a max-lines + disable. +7. Add certificate-specific and `Try HTTPS` UI, preserve existing recovery + actions, and update the localization catalog. +8. Add the offscreen/runtime capability, live snapshot fields and publication, + and approval RPC. +9. Add unit, IPC, renderer, runtime, and real-Electron integration coverage. +10. Run formatting, typecheck, lint, localization verification, targeted tests, + and cross-platform manual validation. + +## Rollout + +No feature flag is required for accurate error copy or `Try HTTPS` because both +paths preserve strict certificate verification. + +The proceed path should ship only after desktop and offscreen ownership tests +pass. If remote runtime support cannot land in the same release, gate the +button on `browser.certificate-trust.v1`; local desktop support may ship while +older/remote owners retain accurate error copy and trusted-CA guidance. + +Do not close issue #8454 until a packaged build has been validated against a +real self-signed local HTTPS server. Unit mocks alone do not establish Electron +certificate-event ordering. + +## UI Quality Bar + +- Follow `docs/STYLEGUIDE.md` and the adjacent browser failure overlay. +- Use only existing semantic tokens, shadcn buttons, and lucide icons. +- Keep the overlay quiet and monochrome; certificate failure must not introduce + an amber warning treatment. +- Copy must distinguish connection, DNS, and certificate failures without + claiming more than the Chromium error proves. +- Buttons must remain on one line at supported pane widths or wrap as one + deliberate group without overlap. +- Validate light/dark mode, macOS/Windows/Linux font metrics, and 200 ms remote + latency. +- Capture review evidence for local HTTP failure, eligible certificate failure, + ineligible certificate failure, accepted certificate, and the same browser + chrome after recovery. + +## Release Gates and Open Risks + +- The pinned Electron binary must prove that the session request gate blocks a + second tab, sibling HTTPS subresources/WSS, and a second port even when + Chromium does not re-emit `certificate-error`. If any isolation assertion + fails, omit the proceed path or redesign it around a dedicated ephemeral + partition; do not silently widen the grant. +- Same-endpoint WSS and fresh HTTPS subresource connections must work after + approval. A main document that renders while HMR/API traffic remains blocked + does not fix the local-development use case. +- A remote/headless certificate failure must reach the client as both accurate + load-error copy and a live challenge. Logging a rejected offscreen `loadURL` + is not sufficient. +- Stale approval must be rejected in the browser-owning process after any new + main-frame navigation, including when renderer cleanup or a remote snapshot + is delayed. +- SSH-forward validation must use a certificate whose SAN matches the + browser-facing hostname. Authority bypass must not mask a hostname mismatch. +- The app-level listener and real-certificate tests must pass on macOS, Windows, + and Linux/Xvfb. Platform trust-store differences do not justify enabling a + broader error allowlist. + +## Review Decisions and Follow-ups + +### From 2026-07-12 review + +- The integrated browser is part of the development workflow (including + automation, annotations, and remote ownership), so `Open Externally` and trusted + local-certificate guidance remain safer alternatives but do not replace an + explicit in-browser decision. +- V1 intentionally limits approval to `ERR_CERT_AUTHORITY_INVALID (-202)`. + Hostname/SAN, date, revoked, and malformed-certificate failures remain blocked; + broadening that set requires a separate security review and real-certificate + tests. +- Raw error details remain available to existing diagnostics rather than adding a + new disclosure affordance to the quiet recovery overlay. diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 1059c95502a..fe89a82c4a0 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -184,6 +184,7 @@ export default function RootLayout() { /> + diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 0251da70490..eaa04a7436a 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -141,7 +141,6 @@ import { saveCustomKeys, type CustomKey } from '../../../../src/components/CustomKeyModal' -import { buildMobileDiffLines } from '../../../../src/session/mobile-diff-lines' import { addMobileDiffComment, formatDiffComments, @@ -177,7 +176,7 @@ import { useMobileTerminalPaste } from '../../../../src/session/use-mobile-termi import { useTerminalLiveInputModePreference } from '../../../../src/session/use-terminal-live-input-mode-preference' import { MobileTerminalLiveInputStatus } from '../../../../src/session/MobileTerminalLiveInputStatus' import { MobileTerminalInputActions } from '../../../../src/session/MobileTerminalInputActions' -import { classifyMobileArtifact } from '../../../../src/session/mobile-artifact-kind' +import { resolveMobileFileTabDoc } from '../../../../src/files/mobile-file-tab-doc' import { openMobileTerminalFileTap } from '../../../../src/session/mobile-terminal-file-tap-open' import { useLiveWorktreeName } from '../../../../src/session/use-live-worktree-name' import { @@ -1200,7 +1199,7 @@ export default function SessionScreen() { nativeChatInputLeaseReady, onSendError: showNativeChatSendError }) - const { chatTabIds, toggleTabChatView, showNativeChat, showNativeChatRef } = nativeChatController + const { toggleTabChatView, showNativeChat, showNativeChatRef } = nativeChatController const dictation = useMobileDictation({ client, @@ -2043,93 +2042,12 @@ export default function SessionScreen() { } setFileDocs((prev) => new Map(prev).set(tab.id, { status: 'loading' })) try { - if (tab.diffSource === 'staged' || tab.diffSource === 'unstaged') { - const response = await client.sendRequest('git.diff', { - worktree: `id:${worktreeId}`, - filePath: tab.relativePath, - staged: tab.diffSource === 'staged' - }) - if (!response.ok) { - throw new Error((response as RpcFailure).error.message) - } - const result = (response as RpcSuccess).result as - | { - kind: 'text' - originalContent: string - modifiedContent: string - } - | { kind: 'binary' } - if (result.kind !== 'text') { - throw new Error('binary_file') - } - const diff = buildMobileDiffLines(result.originalContent, result.modifiedContent) - setFileDocs((prev) => - new Map(prev).set(tab.id, { - status: 'ready', - kind: 'diff', - lines: diff.lines, - truncated: diff.truncated - }) - ) - return - } - const artifactKind = classifyMobileArtifact(tab.relativePath) - if (artifactKind === 'image') { - const preview = await client.sendRequest('files.readPreview', { - worktree: `id:${worktreeId}`, - relativePath: tab.relativePath - }) - if (!preview.ok) { - throw new Error((preview as RpcFailure).error.message) - } - const result = (preview as RpcSuccess).result as { - content: string - isImage?: boolean - mimeType?: string - } - if (!result.isImage || !result.mimeType || result.content.length === 0) { - throw new Error('binary_file') - } - setFileDocs((prev) => - new Map(prev).set(tab.id, { - status: 'ready', - kind: 'image', - dataUri: `data:${result.mimeType};base64,${result.content}` - }) - ) - return - } - const response = await client.sendRequest('files.read', { - worktree: `id:${worktreeId}`, - relativePath: tab.relativePath + const doc = await resolveMobileFileTabDoc(client, { + worktreeId, + relativePath: tab.relativePath, + diffSource: tab.diffSource }) - if (!response.ok) { - throw new Error((response as RpcFailure).error.message) - } - const result = (response as RpcSuccess).result as { - content: string - truncated: boolean - byteLength: number - } - if (artifactKind === 'html') { - setFileDocs((prev) => - new Map(prev).set(tab.id, { - status: 'ready', - kind: 'html', - content: result.content - }) - ) - return - } - setFileDocs((prev) => - new Map(prev).set(tab.id, { - status: 'ready', - kind: 'file', - content: result.content, - truncated: result.truncated, - byteLength: result.byteLength - }) - ) + setFileDocs((prev) => new Map(prev).set(tab.id, doc)) } catch (err) { const message = err instanceof Error ? err.message : '' const previewMessage = @@ -5416,7 +5334,7 @@ export default function SessionScreen() { actions={getMobileTerminalActionSheetActions({ target: actionTarget, tabs: sessionTabs.filter((tab) => tab.type === 'terminal'), - chatTabIds, + isTabChatView: nativeChatController.isTabChatView, nativeChatTranscriptIsLocalReadable, onDismiss: () => setActionTarget(null), onToggleChat: toggleTabChatView, diff --git a/mobile/app/native-chat-settings.tsx b/mobile/app/native-chat-settings.tsx new file mode 100644 index 00000000000..bd358162d5b --- /dev/null +++ b/mobile/app/native-chat-settings.tsx @@ -0,0 +1,126 @@ +import { View, Text, StyleSheet, Pressable, ScrollView, Switch } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { useRouter } from 'expo-router' +import { ChevronLeft } from 'lucide-react-native' +import { colors, radii, spacing, typography } from '../src/theme/mobile-theme' +import { useMobileDefaultSessionViewPreference } from '../src/session/use-mobile-default-session-view-preference' + +export default function NativeChatSettingsScreen() { + const router = useRouter() + const insets = useSafeAreaInsets() + + const { defaultView, setDefaultView } = useMobileDefaultSessionViewPreference() + const chatDefault = defaultView === 'chat' + + return ( + + + router.back()} + > + + + Native chat + + + + DEFAULT VIEW + + Choose how supported agent sessions (Claude, Codex, and other chat-capable agents) open on + this device. Terminal shows the raw CLI; native chat shows a chat interface like the + desktop app. You can still switch any individual session from its long-press menu. + + + + + Open sessions in native chat + {chatDefault ? 'On' : 'Off'} + + setDefaultView(next ? 'chat' : 'terminal')} + trackColor={{ false: colors.bgRaised, true: colors.textSecondary }} + thumbColor={colors.textPrimary} + /> + + + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase, + paddingHorizontal: spacing.lg + }, + topRow: { + flexDirection: 'row', + alignItems: 'center', + marginTop: spacing.sm, + marginBottom: spacing.lg + }, + backButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + marginRight: spacing.sm + }, + heading: { + fontSize: 20, + fontWeight: '700', + color: colors.textPrimary + }, + groupHeading: { + fontSize: 11, + fontWeight: '600', + color: colors.textMuted, + letterSpacing: 0.5, + marginBottom: spacing.xs, + paddingHorizontal: spacing.xs + }, + groupDescription: { + fontSize: typography.bodySize - 1, + color: colors.textSecondary, + lineHeight: 20, + paddingHorizontal: spacing.xs + }, + section: { + backgroundColor: colors.bgPanel, + borderRadius: radii.card, + overflow: 'hidden' + }, + sectionTopGap: { + marginTop: spacing.sm + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + 2, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + rowContent: { + flex: 1 + }, + rowLabel: { + fontSize: typography.bodySize, + fontWeight: '500', + color: colors.textPrimary + }, + rowSublabel: { + fontSize: typography.bodySize - 2, + color: colors.textSecondary, + marginTop: 2 + } +}) diff --git a/mobile/app/settings.tsx b/mobile/app/settings.tsx index b0bd73f651f..7a406ed70ee 100644 --- a/mobile/app/settings.tsx +++ b/mobile/app/settings.tsx @@ -20,6 +20,7 @@ import { LifeBuoy, Mic, Globe, + MessageSquare, Terminal as TerminalIcon, KeyRound } from 'lucide-react-native' @@ -114,6 +115,15 @@ export default function SettingsScreen() { + [styles.row, pressed && styles.rowPressed]} + onPress={() => router.push('/native-chat-settings')} + > + + Native chat + + + [styles.row, pressed && styles.rowPressed]} onPress={() => router.push('/browser-settings')} diff --git a/mobile/src/files/mobile-diff-image-preview.test.ts b/mobile/src/files/mobile-diff-image-preview.test.ts new file mode 100644 index 00000000000..e63f532b28d --- /dev/null +++ b/mobile/src/files/mobile-diff-image-preview.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest' +import { mobileDiffImageDataUri } from './mobile-diff-image-preview' + +describe('mobileDiffImageDataUri', () => { + it('renders a modified image diff from the post-change bytes', () => { + expect( + mobileDiffImageDataUri({ + kind: 'binary', + originalContent: 'b2xk', + modifiedContent: 'bmV3', + isImage: true, + mimeType: 'image/png' + }) + ).toBe('data:image/png;base64,bmV3') + }) + + it('renders an added image diff (no original) from the modified bytes', () => { + expect( + mobileDiffImageDataUri({ + kind: 'binary', + originalContent: '', + modifiedContent: 'bmV3', + isImage: true, + mimeType: 'image/png' + }) + ).toBe('data:image/png;base64,bmV3') + }) + + it('falls back to the original bytes for a proven deletion (modifiedDeleted)', () => { + expect( + mobileDiffImageDataUri({ + kind: 'binary', + originalContent: 'b2xk', + originalIsBinary: true, + modifiedContent: '', + modifiedIsBinary: false, + modifiedDeleted: true, + isImage: true, + mimeType: 'image/jpeg' + }) + ).toBe('data:image/jpeg;base64,b2xk') + }) + + // The reviewer's read-failure case: a relay/SSH read returns an empty modified + // side with modifiedIsBinary false and no modifiedDeleted flag. Without a proven + // deletion, falling back to the original would show a stale pre-change image, so + // "unavailable" (null) is the honest result. + it('returns null on a read failure (empty modified, no modifiedDeleted flag)', () => { + expect( + mobileDiffImageDataUri({ + kind: 'binary', + originalContent: 'b2xk', + originalIsBinary: true, + modifiedContent: '', + modifiedIsBinary: false, + isImage: true, + mimeType: 'image/png' + }) + ).toBeNull() + }) + + // Guards the >size-cap case: the modified side IS a binary image but its bytes + // arrive empty. Falling back to the original would render the stale pre-change + // image; "unavailable" is the honest result. + it('returns null for a modify whose binary modified side is empty (no stale fallback)', () => { + expect( + mobileDiffImageDataUri({ + kind: 'binary', + originalContent: 'b2xk', + originalIsBinary: true, + modifiedContent: '', + modifiedIsBinary: true, + isImage: true, + mimeType: 'image/png' + }) + ).toBeNull() + }) + + it('returns null for a non-previewable binary (no isImage/mimeType)', () => { + expect( + mobileDiffImageDataUri({ + kind: 'binary', + originalContent: '', + modifiedContent: 'AAAA' + }) + ).toBeNull() + }) + + it('returns null when flagged as image but carrying no bytes', () => { + expect( + mobileDiffImageDataUri({ + kind: 'binary', + originalContent: '', + modifiedContent: '', + isImage: true, + mimeType: 'image/png' + }) + ).toBeNull() + }) + + it('returns null for application/pdf even when flagged as previewable', () => { + expect( + mobileDiffImageDataUri({ + kind: 'binary', + originalContent: '', + modifiedContent: 'JVBER', + isImage: true, + mimeType: 'application/pdf' + }) + ).toBeNull() + }) + + it('returns null when flagged as image but mimeType is missing', () => { + expect( + mobileDiffImageDataUri({ + kind: 'binary', + originalContent: '', + modifiedContent: 'bmV3', + isImage: true + }) + ).toBeNull() + }) +}) diff --git a/mobile/src/files/mobile-diff-image-preview.ts b/mobile/src/files/mobile-diff-image-preview.ts new file mode 100644 index 00000000000..09d7cd2ef36 --- /dev/null +++ b/mobile/src/files/mobile-diff-image-preview.ts @@ -0,0 +1,32 @@ +import { buildImageDataUri } from '../../../src/shared/image-data-uri' + +// modifiedDeleted marks a proven deletion (modified side genuinely absent); an +// empty modifiedContent alone can't, since a relay/SSH read failure also arrives +// empty with modifiedIsBinary false. +export type MobileBinaryDiffResult = { + kind: 'binary' + originalContent?: string + modifiedContent?: string + originalIsBinary?: boolean + modifiedIsBinary?: boolean + modifiedDeleted?: boolean + isImage?: boolean + mimeType?: string +} + +// Falls back to the original bytes only for a proven deletion; a read failure or +// size-capped modify — also empty on the modified side — returns null instead of +// the stale pre-change image. +export function mobileDiffImageDataUri(result: MobileBinaryDiffResult): string | null { + if (result.isImage !== true) { + return null + } + const modified = result.modifiedContent || '' + if (modified) { + return buildImageDataUri(result.mimeType, modified) + } + if (result.modifiedDeleted === true) { + return buildImageDataUri(result.mimeType, result.originalContent || '') + } + return null +} diff --git a/mobile/src/files/mobile-file-tab-doc.test.ts b/mobile/src/files/mobile-file-tab-doc.test.ts new file mode 100644 index 00000000000..a9570e6cf44 --- /dev/null +++ b/mobile/src/files/mobile-file-tab-doc.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest' +import type { RpcResponse } from '../transport/types' +import { resolveMobileFileTabDoc } from './mobile-file-tab-doc' + +function ok(result: unknown): RpcResponse { + return { id: 'x', ok: true, result, _meta: { runtimeId: 'r' } } +} + +function fail(code: string, message: string): RpcResponse { + return { id: 'x', ok: false, error: { code, message }, _meta: { runtimeId: 'r' } } +} + +// Fake client that returns a canned response per RPC method. +function clientOf(byMethod: Record): { + sendRequest: (method: string) => Promise + calls: string[] +} { + const calls: string[] = [] + return { + calls, + sendRequest: (method: string) => { + calls.push(method) + const response = byMethod[method] + if (!response) { + throw new Error(`unexpected method ${method}`) + } + return Promise.resolve(response) + } + } +} + +const WT = { worktreeId: 'wt1' } + +describe('resolveMobileFileTabDoc', () => { + it('renders a staged text diff', async () => { + const client = clientOf({ + 'git.diff': ok({ kind: 'text', originalContent: 'a\n', modifiedContent: 'a\nb\n' }) + }) + const doc = await resolveMobileFileTabDoc(client, { + ...WT, + relativePath: 'a.ts', + diffSource: 'staged' + }) + expect(doc.kind).toBe('diff') + expect(client.calls).toEqual(['git.diff']) + }) + + it('renders an unstaged image diff from the modified bytes', async () => { + const client = clientOf({ + 'git.diff': ok({ + kind: 'binary', + originalContent: 'b2xk', + modifiedContent: 'bmV3', + modifiedIsBinary: true, + isImage: true, + mimeType: 'image/png' + }) + }) + const doc = await resolveMobileFileTabDoc(client, { + ...WT, + relativePath: 'm1.png', + diffSource: 'unstaged' + }) + expect(doc).toEqual({ status: 'ready', kind: 'image', dataUri: 'data:image/png;base64,bmV3' }) + }) + + it('throws binary_file for an image modify whose bytes are empty (no stale fallback)', async () => { + const client = clientOf({ + 'git.diff': ok({ + kind: 'binary', + originalContent: 'b2xk', + modifiedContent: '', + modifiedIsBinary: true, + isImage: true, + mimeType: 'image/png' + }) + }) + await expect( + resolveMobileFileTabDoc(client, { ...WT, relativePath: 'm1.png', diffSource: 'unstaged' }) + ).rejects.toThrow('binary_file') + }) + + it('throws binary_file for a non-image binary diff', async () => { + const client = clientOf({ 'git.diff': ok({ kind: 'binary', modifiedContent: 'AAAA' }) }) + await expect( + resolveMobileFileTabDoc(client, { ...WT, relativePath: 'a.bin', diffSource: 'unstaged' }) + ).rejects.toThrow('binary_file') + }) + + it('renders a live image preview via files.readPreview', async () => { + const client = clientOf({ + 'files.readPreview': ok({ content: 'bmV3', isImage: true, mimeType: 'image/png' }) + }) + const doc = await resolveMobileFileTabDoc(client, { ...WT, relativePath: 'logo.png' }) + expect(doc).toEqual({ status: 'ready', kind: 'image', dataUri: 'data:image/png;base64,bmV3' }) + expect(client.calls).toEqual(['files.readPreview']) + }) + + it('throws binary_file when readPreview returns no image bytes', async () => { + const client = clientOf({ + 'files.readPreview': ok({ content: '', isImage: true, mimeType: 'image/png' }) + }) + await expect( + resolveMobileFileTabDoc(client, { ...WT, relativePath: 'logo.png' }) + ).rejects.toThrow('binary_file') + }) + + it('renders html source via files.read', async () => { + const client = clientOf({ + 'files.read': ok({ content: '

hi

', truncated: false, byteLength: 11 }) + }) + const doc = await resolveMobileFileTabDoc(client, { ...WT, relativePath: 'page.html' }) + expect(doc).toEqual({ status: 'ready', kind: 'html', content: '

hi

' }) + }) + + it('renders a plain text file via files.read', async () => { + const client = clientOf({ + 'files.read': ok({ content: 'hello', truncated: true, byteLength: 5 }) + }) + const doc = await resolveMobileFileTabDoc(client, { ...WT, relativePath: 'notes.txt' }) + expect(doc).toEqual({ + status: 'ready', + kind: 'file', + content: 'hello', + truncated: true, + byteLength: 5 + }) + }) + + it('propagates the RPC error message when a read fails', async () => { + const client = clientOf({ 'files.read': fail('EIO', 'file_too_large') }) + await expect( + resolveMobileFileTabDoc(client, { ...WT, relativePath: 'notes.txt' }) + ).rejects.toThrow('file_too_large') + }) +}) diff --git a/mobile/src/files/mobile-file-tab-doc.ts b/mobile/src/files/mobile-file-tab-doc.ts new file mode 100644 index 00000000000..78977b5fd30 --- /dev/null +++ b/mobile/src/files/mobile-file-tab-doc.ts @@ -0,0 +1,94 @@ +import { buildImageDataUri } from '../../../src/shared/image-data-uri' +import { classifyMobileArtifact } from '../session/mobile-artifact-kind' +import { buildMobileDiffLines, type MobileDiffLine } from '../session/mobile-diff-lines' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcFailure, RpcSuccess } from '../transport/types' +import { mobileDiffImageDataUri, type MobileBinaryDiffResult } from './mobile-diff-image-preview' + +type FileTabDocClient = Pick + +// The ready doc a session file tab renders. Mirrors the ready arm of the route's +// FileDocState; kept in src so the loader stays testable without the route. +export type MobileFileTabDoc = + | { status: 'ready'; kind: 'file'; content: string; truncated: boolean; byteLength: number } + | { status: 'ready'; kind: 'diff'; lines: MobileDiffLine[]; truncated: boolean } + | { status: 'ready'; kind: 'image'; dataUri: string } + | { status: 'ready'; kind: 'html'; content: string } + +export type MobileFileTabDocRequest = { + worktreeId: string + relativePath: string + diffSource?: 'staged' | 'unstaged' | 'branch' | 'commit' +} + +// Throws 'binary_file'/'file_too_large'/the RPC error message; callers map those +// to error docs. +export async function resolveMobileFileTabDoc( + client: FileTabDocClient, + request: MobileFileTabDocRequest +): Promise { + const worktree = `id:${request.worktreeId}` + const { relativePath } = request + if (request.diffSource === 'staged' || request.diffSource === 'unstaged') { + const response = await client.sendRequest('git.diff', { + worktree, + filePath: relativePath, + staged: request.diffSource === 'staged' + }) + if (!response.ok) { + throw new Error((response as RpcFailure).error.message) + } + const result = (response as RpcSuccess).result as + | { kind: 'text'; originalContent: string; modifiedContent: string } + | MobileBinaryDiffResult + if (result.kind !== 'text') { + // Render image diffs (add/modify/delete) from the base64 the host already + // sends; only non-previewable binaries stay unavailable. + const dataUri = mobileDiffImageDataUri(result) + if (!dataUri) { + throw new Error('binary_file') + } + return { status: 'ready', kind: 'image', dataUri } + } + const diff = buildMobileDiffLines(result.originalContent, result.modifiedContent) + return { status: 'ready', kind: 'diff', lines: diff.lines, truncated: diff.truncated } + } + + const artifactKind = classifyMobileArtifact(relativePath) + if (artifactKind === 'image') { + const preview = await client.sendRequest('files.readPreview', { worktree, relativePath }) + if (!preview.ok) { + throw new Error((preview as RpcFailure).error.message) + } + const result = (preview as RpcSuccess).result as { + content: string + isImage?: boolean + mimeType?: string + } + const dataUri = result.isImage ? buildImageDataUri(result.mimeType, result.content) : null + if (!dataUri) { + throw new Error('binary_file') + } + return { status: 'ready', kind: 'image', dataUri } + } + + const response = await client.sendRequest('files.read', { worktree, relativePath }) + if (!response.ok) { + throw new Error((response as RpcFailure).error.message) + } + const result = (response as RpcSuccess).result as { + content: string + truncated: boolean + byteLength: number + } + if (artifactKind === 'html') { + return { status: 'ready', kind: 'html', content: result.content } + } + return { + status: 'ready', + kind: 'file', + content: result.content, + truncated: result.truncated, + byteLength: result.byteLength + } +} diff --git a/mobile/src/session/mobile-native-chat-toggle-action.ts b/mobile/src/session/mobile-native-chat-toggle-action.ts index 32ae700671a..efdf4f53102 100644 --- a/mobile/src/session/mobile-native-chat-toggle-action.ts +++ b/mobile/src/session/mobile-native-chat-toggle-action.ts @@ -11,19 +11,19 @@ type ToggleTab = MobileNativeChatTab & { export function getMobileNativeChatToggleActions(args: { terminalHandle: string | null tabs: readonly ToggleTab[] - chatTabIds: ReadonlySet + isTabChatView: (tabId: string) => boolean nativeChatTranscriptIsLocalReadable: boolean onClose: () => void onToggle: (tabId: string) => void }): ActionSheetAction[] { - const { terminalHandle, tabs, chatTabIds, onClose, onToggle } = args + const { terminalHandle, tabs, isTabChatView, onClose, onToggle } = args const tab = terminalHandle ? tabs.find((candidate) => candidate.terminal === terminalHandle) : null if (!tab || !resolveMobileNativeChat(tab, args.nativeChatTranscriptIsLocalReadable)) { return [] } - const isChat = chatTabIds.has(tab.id) + const isChat = isTabChatView(tab.id) return [ { label: isChat ? 'Switch to terminal view' : 'Switch to chat view', diff --git a/mobile/src/session/mobile-terminal-action-sheet-actions.ts b/mobile/src/session/mobile-terminal-action-sheet-actions.ts index 3b29a6dfaf7..e263e32b80f 100644 --- a/mobile/src/session/mobile-terminal-action-sheet-actions.ts +++ b/mobile/src/session/mobile-terminal-action-sheet-actions.ts @@ -10,7 +10,7 @@ type TerminalTab = MobileNativeChatTab & { id: string; terminal: string | null } export function getMobileTerminalActionSheetActions(args: { target: Target | null tabs: readonly TerminalTab[] - chatTabIds: ReadonlySet + isTabChatView: (tabId: string) => boolean nativeChatTranscriptIsLocalReadable: boolean onDismiss: () => void onToggleChat: (tabId: string) => void @@ -29,7 +29,7 @@ export function getMobileTerminalActionSheetActions(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +vi.mock('../storage/session-view-preferences', () => ({ + DEFAULT_SESSION_VIEW: 'terminal', + loadDefaultSessionView: vi.fn(), + saveDefaultSessionView: vi.fn() +})) + +describe('useMobileDefaultSessionViewPreference', () => { + let renderer: ReactTestRenderer | null = null + let preference: MobileDefaultSessionViewPreference | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + vi.mocked(loadDefaultSessionView).mockReset().mockResolvedValue('terminal') + vi.mocked(saveDefaultSessionView).mockReset().mockResolvedValue(undefined) + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + preference = null + }) + + async function mount(): Promise { + function Harness(): null { + preference = useMobileDefaultSessionViewPreference() + return null + } + await act(async () => { + renderer = create(createElement(Harness)) + await Promise.resolve() + }) + } + + it('keeps a fast toggle authoritative over the initial read', async () => { + const initialLoad = deferred() + vi.mocked(loadDefaultSessionView).mockReturnValue(initialLoad.promise) + await mount() + + act(() => preference?.setDefaultView('chat')) + expect(preference?.defaultView).toBe('chat') + + await act(async () => { + initialLoad.resolve('terminal') + await initialLoad.promise + }) + + expect(preference?.defaultView).toBe('chat') + expect(saveDefaultSessionView).toHaveBeenCalledWith('chat') + }) + + it('submits every change immediately so shared persistence preserves event order', async () => { + const firstSave = deferred() + vi.mocked(saveDefaultSessionView) + .mockImplementationOnce(() => firstSave.promise) + .mockResolvedValue(undefined) + await mount() + + act(() => preference?.setDefaultView('chat')) + await act(async () => { + await Promise.resolve() + }) + act(() => preference?.setDefaultView('terminal')) + act(() => preference?.setDefaultView('chat')) + await act(async () => { + await Promise.resolve() + }) + expect(saveDefaultSessionView).toHaveBeenCalledTimes(3) + expect(saveDefaultSessionView).toHaveBeenNthCalledWith(2, 'terminal') + expect(saveDefaultSessionView).toHaveBeenNthCalledWith(3, 'chat') + + await act(async () => { + firstSave.resolve() + await firstSave.promise + await Promise.resolve() + }) + expect(saveDefaultSessionView).toHaveBeenCalledTimes(3) + }) + + it('reloads the persisted value when the latest write fails', async () => { + vi.mocked(loadDefaultSessionView) + .mockResolvedValueOnce('terminal') + .mockResolvedValueOnce('terminal') + vi.mocked(saveDefaultSessionView).mockRejectedValue(new Error('storage unavailable')) + await mount() + + await act(async () => { + preference?.setDefaultView('chat') + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + }) + + expect(preference?.defaultView).toBe('terminal') + expect(loadDefaultSessionView).toHaveBeenCalledTimes(2) + }) + + it('does not let an older failed write roll back a newer choice', async () => { + const recoveryLoad = deferred() + vi.mocked(loadDefaultSessionView) + .mockResolvedValueOnce('terminal') + .mockReturnValueOnce(recoveryLoad.promise) + vi.mocked(saveDefaultSessionView) + .mockRejectedValueOnce(new Error('storage unavailable')) + .mockResolvedValueOnce(undefined) + await mount() + + act(() => preference?.setDefaultView('chat')) + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + act(() => preference?.setDefaultView('terminal')) + + await act(async () => { + recoveryLoad.resolve('chat') + await recoveryLoad.promise + await Promise.resolve() + }) + + expect(preference?.defaultView).toBe('terminal') + expect(saveDefaultSessionView).toHaveBeenNthCalledWith(2, 'terminal') + }) + + it('submits the latest write before the Settings route unmounts', async () => { + const firstSave = deferred() + vi.mocked(saveDefaultSessionView) + .mockImplementationOnce(() => firstSave.promise) + .mockResolvedValueOnce(undefined) + await mount() + + act(() => preference?.setDefaultView('chat')) + await act(async () => { + await Promise.resolve() + }) + act(() => preference?.setDefaultView('terminal')) + expect(saveDefaultSessionView).toHaveBeenNthCalledWith(2, 'terminal') + act(() => renderer?.unmount()) + renderer = null + + await act(async () => { + firstSave.resolve() + await firstSave.promise + await Promise.resolve() + }) + }) +}) diff --git a/mobile/src/session/use-mobile-default-session-view-preference.ts b/mobile/src/session/use-mobile-default-session-view-preference.ts new file mode 100644 index 00000000000..ad305d2ded4 --- /dev/null +++ b/mobile/src/session/use-mobile-default-session-view-preference.ts @@ -0,0 +1,51 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { + DEFAULT_SESSION_VIEW, + loadDefaultSessionView, + saveDefaultSessionView, + type MobileSessionView +} from '../storage/session-view-preferences' + +export type MobileDefaultSessionViewPreference = { + defaultView: MobileSessionView + setDefaultView: (view: MobileSessionView) => void +} + +/** Owns the optimistic Settings value while keeping AsyncStorage writes ordered. */ +export function useMobileDefaultSessionViewPreference(): MobileDefaultSessionViewPreference { + const [defaultView, setDefaultViewState] = useState(DEFAULT_SESSION_VIEW) + const mountedRef = useRef(false) + const mutationRevisionRef = useRef(0) + + useEffect(() => { + mountedRef.current = true + const loadRevision = mutationRevisionRef.current + let stale = false + void loadDefaultSessionView().then((view) => { + // Why: a fast toggle is authoritative over the older storage read. + if (!stale && mutationRevisionRef.current === loadRevision) { + setDefaultViewState(view) + } + }) + return () => { + stale = true + mountedRef.current = false + } + }, []) + + const setDefaultView = useCallback((view: MobileSessionView) => { + const revision = mutationRevisionRef.current + 1 + mutationRevisionRef.current = revision + setDefaultViewState(view) + // Why: persistence owns a shared queue, so invoking it at event time preserves + // mutation order even when this route unmounts and a new instance takes over. + void saveDefaultSessionView(view).catch(async () => { + const persisted = await loadDefaultSessionView() + if (mountedRef.current && mutationRevisionRef.current === revision) { + setDefaultViewState(persisted) + } + }) + }, []) + + return { defaultView, setDefaultView } +} diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index cca9ae3f923..c5d771b3635 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -1,13 +1,11 @@ import { useCallback, - useEffect, useRef, - useState, type Dispatch, type MutableRefObject, type SetStateAction } from 'react' -import { loadNativeChatTabIds, saveNativeChatTabIds } from '../storage/preferences' +import { useMobileSessionViewMode } from './use-mobile-session-view-mode' import type { RpcClient } from '../transport/rpc-client' import { parseAskFromStatus, @@ -31,7 +29,8 @@ import { useThrottledLatestValue } from './use-throttled-latest-value' const NATIVE_CHAT_STREAM_THROTTLE_MS = 50 export type MobileNativeChatController = { - chatTabIds: Set + /** Whether a tab's effective view is chat (per-tab override, else the default). */ + isTabChatView: (tabId: string) => boolean toggleTabChatView: (tabId: string) => void showNativeChat: boolean showNativeChatRef: MutableRefObject @@ -85,46 +84,10 @@ export function useMobileNativeChatController(args: { nativeChatInputLeaseReady, onSendError } = args - const [chatTabIds, setChatTabIds] = useState>(new Set()) - const chatTabIdsToggledRef = useRef(false) - - useEffect(() => { - let active = true - // Re-arm per host/worktree so the fresh load can seed the new selection. - chatTabIdsToggledRef.current = false - void loadNativeChatTabIds(hostId, worktreeId).then((ids) => { - // Why: a toggle before this load resolves is authoritative; the load must - // not revert the user's in-memory choice back to the persisted state. - if (active && !chatTabIdsToggledRef.current) { - // Why: route reuse must clear the prior worktree's selection even when - // the newly loaded worktree has no saved chat tabs. - setChatTabIds(new Set(ids)) - } - }) - return () => { - active = false - } - }, [hostId, worktreeId]) - - const toggleTabChatView = useCallback( - (tabId: string) => { - chatTabIdsToggledRef.current = true - setChatTabIds((previous) => { - const next = new Set(previous) - if (next.has(tabId)) { - next.delete(tabId) - } else { - next.add(tabId) - } - void saveNativeChatTabIds(hostId, worktreeId, [...next]) - return next - }) - }, - [hostId, worktreeId] - ) + const { isTabChatView, toggleTabChatView } = useMobileSessionViewMode({ hostId, worktreeId }) const activeChatResolution = - activeSessionTab && activeSessionTabId && chatTabIds.has(activeSessionTabId) + activeSessionTab && activeSessionTabId && isTabChatView(activeSessionTabId) ? resolveMobileNativeChat(activeSessionTab, nativeChatTranscriptIsLocalReadable) : null const showNativeChat = activeChatResolution != null @@ -288,7 +251,7 @@ export function useMobileNativeChatController(args: { ) return { - chatTabIds, + isTabChatView, toggleTabChatView, showNativeChat, showNativeChatRef, diff --git a/mobile/src/session/use-mobile-session-view-mode.test.ts b/mobile/src/session/use-mobile-session-view-mode.test.ts new file mode 100644 index 00000000000..4315bda35bf --- /dev/null +++ b/mobile/src/session/use-mobile-session-view-mode.test.ts @@ -0,0 +1,384 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + loadDefaultSessionView, + readSessionViewOverridesPreference, + updateSessionViewOverride, + type MobileSessionView +} from '../storage/session-view-preferences' +import { + useMobileSessionViewMode, + type MobileSessionViewModeController +} from './use-mobile-session-view-mode' + +const focusEffectRuntime = vi.hoisted(() => ({ + callback: null as null | (() => undefined | (() => void)) +})) + +function deferred(): { + promise: Promise + resolve: (value: T) => void + reject: (reason?: unknown) => void +} { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + +vi.mock('expo-router', async () => { + const react = await import('react') + return { + // Run the focus callback once on mount, mirroring a focus. + useFocusEffect: (cb: () => undefined | (() => void)) => { + focusEffectRuntime.callback = cb + react.useEffect(() => cb(), [cb]) + } + } +}) + +vi.mock('../storage/session-view-preferences', () => ({ + loadDefaultSessionView: vi.fn(), + readSessionViewOverridesPreference: vi.fn(), + updateSessionViewOverride: vi.fn() +})) + +describe('useMobileSessionViewMode', () => { + let renderer: ReactTestRenderer | null = null + let controller: MobileSessionViewModeController | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + vi.mocked(loadDefaultSessionView).mockReset().mockResolvedValue('terminal') + vi.mocked(readSessionViewOverridesPreference) + .mockReset() + .mockResolvedValue({ overrides: new Map(), loaded: true }) + vi.mocked(updateSessionViewOverride).mockReset().mockResolvedValue(undefined) + focusEffectRuntime.callback = null + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + controller = null + }) + + async function mount(args: { + defaultView: MobileSessionView + overrides?: Map + }): Promise { + vi.mocked(loadDefaultSessionView).mockResolvedValue(args.defaultView) + vi.mocked(readSessionViewOverridesPreference).mockResolvedValue({ + overrides: args.overrides ?? new Map(), + loaded: true + }) + function Harness(): null { + controller = useMobileSessionViewMode({ hostId: 'h', worktreeId: 'w' }) + return null + } + await act(async () => { + renderer = create(createElement(Harness)) + await Promise.resolve() + await Promise.resolve() + }) + } + + it('follows the default when a tab has no override', async () => { + await mount({ defaultView: 'terminal' }) + expect(controller?.isTabChatView('t1')).toBe(false) + + act(() => renderer?.unmount()) + renderer = null + await mount({ defaultView: 'chat' }) + expect(controller?.isTabChatView('t1')).toBe(true) + }) + + it('lets a per-tab override win over the default', async () => { + await mount({ + defaultView: 'chat', + overrides: new Map([['t1', 'terminal']]) + }) + expect(controller?.isTabChatView('t1')).toBe(false) + expect(controller?.isTabChatView('t2')).toBe(true) + }) + + it('reloads the default on refocus after Settings changes it', async () => { + await mount({ defaultView: 'terminal' }) + expect(controller?.isTabChatView('t1')).toBe(false) + vi.mocked(loadDefaultSessionView).mockResolvedValue('chat') + + await act(async () => { + focusEffectRuntime.callback?.() + await Promise.resolve() + }) + + expect(controller?.isTabChatView('t1')).toBe(true) + }) + + it('toggles from the effective view and persists the override', async () => { + await mount({ defaultView: 'chat' }) + + await act(async () => { + controller?.toggleTabChatView('t1') + await Promise.resolve() + }) + + expect(updateSessionViewOverride).toHaveBeenLastCalledWith('h', 'w', 't1', 'terminal') + expect(controller?.isTabChatView('t1')).toBe(false) + expect(readSessionViewOverridesPreference).toHaveBeenCalledTimes(1) + }) + + it('toggles a terminal-default tab into chat', async () => { + await mount({ defaultView: 'terminal' }) + + await act(async () => { + controller?.toggleTabChatView('t1') + await Promise.resolve() + }) + + expect(updateSessionViewOverride).toHaveBeenLastCalledWith('h', 'w', 't1', 'chat') + expect(controller?.isTabChatView('t1')).toBe(true) + }) + + it('does not expose overrides from the previous host while the next scope loads', async () => { + const nextScopeLoad = deferred>() + vi.mocked(readSessionViewOverridesPreference).mockImplementation((hostId) => + hostId === 'h1' + ? Promise.resolve({ + overrides: new Map([['same-tab-id', 'chat' as const]]), + loaded: true + }) + : nextScopeLoad.promise.then((overrides) => ({ overrides, loaded: true })) + ) + function Harness(props: { hostId: string; worktreeId: string }): null { + controller = useMobileSessionViewMode(props) + return null + } + await act(async () => { + renderer = create(createElement(Harness, { hostId: 'h1', worktreeId: 'w1' })) + await Promise.resolve() + await Promise.resolve() + }) + expect(controller?.isTabChatView('same-tab-id')).toBe(true) + + await act(async () => { + renderer?.update(createElement(Harness, { hostId: 'h2', worktreeId: 'w2' })) + await Promise.resolve() + }) + expect(controller?.isTabChatView('same-tab-id')).toBe(false) + + await act(async () => { + nextScopeLoad.resolve(new Map()) + await Promise.resolve() + }) + }) + + it('merges a toggle made during load with the other persisted overrides', async () => { + const overridesLoad = deferred>() + vi.mocked(readSessionViewOverridesPreference).mockReturnValue( + overridesLoad.promise.then((overrides) => ({ overrides, loaded: true })) + ) + function Harness(): null { + controller = useMobileSessionViewMode({ hostId: 'h', worktreeId: 'w' }) + return null + } + await act(async () => { + renderer = create(createElement(Harness)) + await Promise.resolve() + }) + + act(() => controller?.toggleTabChatView('new-tab')) + expect(controller?.isTabChatView('new-tab')).toBe(true) + expect(updateSessionViewOverride).toHaveBeenCalledWith('h', 'w', 'new-tab', 'chat') + + await act(async () => { + overridesLoad.resolve(new Map([['saved-tab', 'chat']])) + await Promise.resolve() + await Promise.resolve() + }) + + expect(controller?.isTabChatView('saved-tab')).toBe(true) + expect(controller?.isTabChatView('new-tab')).toBe(true) + }) + + it('toggles from the fail-closed view while overrides load under a chat default', async () => { + const overridesLoad = deferred>() + vi.mocked(loadDefaultSessionView).mockResolvedValue('chat') + vi.mocked(readSessionViewOverridesPreference).mockReturnValue( + overridesLoad.promise.then((overrides) => ({ overrides, loaded: true })) + ) + function Harness(): null { + controller = useMobileSessionViewMode({ hostId: 'h', worktreeId: 'w' }) + return null + } + await act(async () => { + renderer = create(createElement(Harness)) + await Promise.resolve() + await Promise.resolve() + }) + + expect(controller?.isTabChatView('new-tab')).toBe(false) + act(() => controller?.toggleTabChatView('new-tab')) + + expect(updateSessionViewOverride).toHaveBeenCalledWith('h', 'w', 'new-tab', 'chat') + expect(controller?.isTabChatView('new-tab')).toBe(true) + + await act(async () => { + overridesLoad.resolve(new Map()) + await overridesLoad.promise + await Promise.resolve() + }) + }) + + it('submits rapid mutations in event order', async () => { + await mount({ defaultView: 'terminal' }) + act(() => controller?.toggleTabChatView('t1')) + act(() => controller?.toggleTabChatView('t2')) + + expect(updateSessionViewOverride).toHaveBeenNthCalledWith(1, 'h', 'w', 't1', 'chat') + expect(updateSessionViewOverride).toHaveBeenNthCalledWith(2, 'h', 'w', 't2', 'chat') + }) + + it('reconciles the latest optimistic override when persistence fails', async () => { + vi.mocked(updateSessionViewOverride).mockRejectedValue(new Error('storage unavailable')) + vi.mocked(readSessionViewOverridesPreference) + .mockResolvedValueOnce({ + overrides: new Map([['t1', 'terminal']]), + loaded: true + }) + .mockResolvedValueOnce({ + overrides: new Map([['t1', 'terminal']]), + loaded: true + }) + await mount({ + defaultView: 'terminal', + overrides: new Map([['t1', 'terminal']]) + }) + + await act(async () => { + controller?.toggleTabChatView('t1') + await Promise.resolve() + await Promise.resolve() + }) + + expect(controller?.isTabChatView('t1')).toBe(false) + expect(readSessionViewOverridesPreference).toHaveBeenCalledTimes(2) + }) + + it('does not let an older failed override roll back a newer choice', async () => { + const recoveryLoad = deferred>() + vi.mocked(updateSessionViewOverride) + .mockRejectedValueOnce(new Error('storage unavailable')) + .mockResolvedValueOnce(undefined) + await mount({ defaultView: 'terminal' }) + vi.mocked(readSessionViewOverridesPreference).mockReturnValueOnce( + recoveryLoad.promise.then((overrides) => ({ overrides, loaded: true })) + ) + + await act(async () => { + controller?.toggleTabChatView('t1') + await Promise.resolve() + }) + act(() => controller?.toggleTabChatView('t1')) + + await act(async () => { + recoveryLoad.resolve(new Map([['t1', 'chat']])) + await recoveryLoad.promise + await Promise.resolve() + }) + + expect(controller?.isTabChatView('t1')).toBe(false) + expect(updateSessionViewOverride).toHaveBeenNthCalledWith(2, 'h', 'w', 't1', 'terminal') + }) + + it('fails closed when both an override write and its recovery read fail', async () => { + vi.mocked(loadDefaultSessionView).mockResolvedValue('chat') + vi.mocked(updateSessionViewOverride).mockRejectedValue(new Error('storage unavailable')) + vi.mocked(readSessionViewOverridesPreference) + .mockResolvedValueOnce({ + overrides: new Map([['t1', 'terminal']]), + loaded: true + }) + .mockResolvedValueOnce({ overrides: new Map(), loaded: false }) + await mount({ + defaultView: 'chat', + overrides: new Map([['t1', 'terminal']]) + }) + + await act(async () => { + controller?.toggleTabChatView('t1') + await Promise.resolve() + await Promise.resolve() + }) + + expect(controller?.isTabChatView('t1')).toBe(false) + expect(readSessionViewOverridesPreference).toHaveBeenCalledTimes(2) + }) + + it('finishes an early toggle save after the route unmounts', async () => { + const overridesLoad = deferred>() + vi.mocked(readSessionViewOverridesPreference).mockReturnValue( + overridesLoad.promise.then((overrides) => ({ overrides, loaded: true })) + ) + function Harness(): null { + controller = useMobileSessionViewMode({ hostId: 'h', worktreeId: 'w' }) + return null + } + await act(async () => { + renderer = create(createElement(Harness)) + await Promise.resolve() + }) + act(() => controller?.toggleTabChatView('new-tab')) + act(() => renderer?.unmount()) + renderer = null + + await act(async () => { + overridesLoad.resolve(new Map([['saved-tab', 'terminal']])) + await Promise.resolve() + await Promise.resolve() + }) + + expect(updateSessionViewOverride).toHaveBeenCalledWith('h', 'w', 'new-tab', 'chat') + }) + + it('does not reload a failed override after the route unmounts', async () => { + const failedWrite = deferred() + vi.mocked(updateSessionViewOverride).mockReturnValue(failedWrite.promise) + await mount({ defaultView: 'terminal' }) + + act(() => controller?.toggleTabChatView('t1')) + act(() => renderer?.unmount()) + renderer = null + await act(async () => { + failedWrite.reject(new Error('storage unavailable')) + await Promise.resolve() + await Promise.resolve() + }) + + expect(readSessionViewOverridesPreference).toHaveBeenCalledTimes(1) + }) + + it('fails closed to terminal when overrides cannot be read under a chat default', async () => { + vi.mocked(loadDefaultSessionView).mockResolvedValue('chat') + vi.mocked(readSessionViewOverridesPreference).mockResolvedValue({ + overrides: new Map(), + loaded: false + }) + + function Harness(): null { + controller = useMobileSessionViewMode({ hostId: 'h', worktreeId: 'w' }) + return null + } + await act(async () => { + renderer = create(createElement(Harness)) + await Promise.resolve() + await Promise.resolve() + }) + + expect(controller?.isTabChatView('t1')).toBe(false) + }) +}) diff --git a/mobile/src/session/use-mobile-session-view-mode.ts b/mobile/src/session/use-mobile-session-view-mode.ts new file mode 100644 index 00000000000..ef1888b1332 --- /dev/null +++ b/mobile/src/session/use-mobile-session-view-mode.ts @@ -0,0 +1,208 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useFocusEffect } from 'expo-router' +import { + loadDefaultSessionView, + readSessionViewOverridesPreference, + updateSessionViewOverride, + type MobileSessionView, + type SessionViewOverridesPreference +} from '../storage/session-view-preferences' + +type ViewOverridesState = { + hostId: string + worktreeId: string + overrides: Map + loaded: boolean +} + +type ViewOverridesRuntime = { + hostId: string + worktreeId: string + loadPromise: Promise + currentOverrides: Map + mutationRevisions: Map +} + +function isOverrideScope(state: ViewOverridesState, hostId: string, worktreeId: string): boolean { + return state.hostId === hostId && state.worktreeId === worktreeId +} + +function mergeOverrides( + persisted: ReadonlyMap, + current: ReadonlyMap +): Map { + const merged = new Map(persisted) + for (const [tabId, view] of current) { + merged.set(tabId, view) + } + return merged +} + +export type MobileSessionViewModeController = { + /** Whether a tab's effective view is chat (per-tab override, else the default). */ + isTabChatView: (tabId: string) => boolean + toggleTabChatView: (tabId: string) => void +} + +/** Resolves each tab's terminal/chat view: a per-device default (reloaded on focus + * so a Settings change applies without remounting the route) overlaid by persisted + * per-tab overrides that pin a session regardless of what the default later becomes. */ +export function useMobileSessionViewMode(args: { + hostId: string + worktreeId: string +}): MobileSessionViewModeController { + const { hostId, worktreeId } = args + const [viewOverridesState, setViewOverridesState] = useState(() => ({ + hostId, + worktreeId, + overrides: new Map(), + loaded: false + })) + const viewOverridesStateRef = useRef(viewOverridesState) + viewOverridesStateRef.current = viewOverridesState + const viewOverridesRuntimeRef = useRef(null) + const mountedRef = useRef(true) + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + const ensureViewOverridesRuntime = useCallback((scopeHostId: string, scopeWorktreeId: string) => { + const current = viewOverridesRuntimeRef.current + if (current?.hostId === scopeHostId && current.worktreeId === scopeWorktreeId) { + return current + } + const next: ViewOverridesRuntime = { + hostId: scopeHostId, + worktreeId: scopeWorktreeId, + loadPromise: readSessionViewOverridesPreference(scopeHostId, scopeWorktreeId), + currentOverrides: new Map(), + mutationRevisions: new Map() + } + viewOverridesRuntimeRef.current = next + return next + }, []) + const [defaultView, setDefaultView] = useState('terminal') + // Why: the toggle callback reads the live default without depending on it, so + // its identity stays stable and it never captures a stale default. + const defaultViewRef = useRef(defaultView) + defaultViewRef.current = defaultView + + useEffect(() => { + let active = true + const runtime = ensureViewOverridesRuntime(hostId, worktreeId) + void runtime.loadPromise.then((preference) => { + if (!active) { + return + } + // Why: toggles made during the read are authoritative, but must not + // discard unrelated persisted overrides from the same worktree. + const merged = mergeOverrides(preference.overrides, runtime.currentOverrides) + runtime.currentOverrides = merged + // Why: an unreadable override store cannot safely be treated as empty when + // the default is chat; fail closed to terminal until a user toggles. + const next = { hostId, worktreeId, overrides: merged, loaded: preference.loaded } + viewOverridesStateRef.current = next + setViewOverridesState(next) + }) + return () => { + active = false + } + }, [ensureViewOverridesRuntime, hostId, worktreeId]) + + // Why: reload on focus so returning from Settings picks up a changed default. + useFocusEffect( + useCallback(() => { + let active = true + void loadDefaultSessionView().then((view) => { + if (active) { + setDefaultView(view) + } + }) + return () => { + active = false + } + }, []) + ) + + const isTabChatView = useCallback( + (tabId: string): boolean => { + if (!isOverrideScope(viewOverridesState, hostId, worktreeId)) { + return false + } + const override = viewOverridesState.overrides.get(tabId) + // Until this scope loads, only an immediate user toggle is authoritative; + // defaulting other tabs to terminal avoids activating stale cross-host chat. + return (override ?? (viewOverridesState.loaded ? defaultView : 'terminal')) === 'chat' + }, + [defaultView, hostId, viewOverridesState, worktreeId] + ) + + const toggleTabChatView = useCallback( + (tabId: string) => { + const current = viewOverridesStateRef.current + const currentScope = isOverrideScope(current, hostId, worktreeId) + ? current + : { + hostId, + worktreeId, + overrides: new Map(), + loaded: false + } + const overrides = new Map(currentScope.overrides) + // Flip from the tab's effective view (its override, else the default), so + // a tab following a chat default can still be pinned back to terminal. + const fallbackView = currentScope.loaded ? defaultViewRef.current : 'terminal' + const currentlyChat = (overrides.get(tabId) ?? fallbackView) === 'chat' + const nextView = currentlyChat ? 'terminal' : 'chat' + overrides.set(tabId, nextView) + const next = { ...currentScope, overrides } + viewOverridesStateRef.current = next + setViewOverridesState(next) + + const runtime = ensureViewOverridesRuntime(hostId, worktreeId) + runtime.currentOverrides = overrides + const revision = (runtime.mutationRevisions.get(tabId) ?? 0) + 1 + runtime.mutationRevisions.set(tabId, revision) + // Why: enqueue the individual mutation immediately so a remounted route + // cannot reorder it or replace unrelated overrides with a stale snapshot. + void updateSessionViewOverride(hostId, worktreeId, tabId, nextView).catch(async () => { + if (!mountedRef.current || viewOverridesRuntimeRef.current !== runtime) { + return + } + const preference = await readSessionViewOverridesPreference(hostId, worktreeId) + // Why: a failed older write must not roll back a newer choice for this tab. + if ( + !mountedRef.current || + viewOverridesRuntimeRef.current !== runtime || + runtime.mutationRevisions.get(tabId) !== revision + ) { + return + } + // Why: if recovery is also unreadable, fail closed instead of treating an + // unknown store as empty or restoring an earlier optimistic mutation. + const reconciled = preference.loaded + ? mergeOverrides(preference.overrides, runtime.currentOverrides) + : new Map(runtime.currentOverrides) + const recoveredOverride = preference.loaded ? preference.overrides.get(tabId) : 'terminal' + if (recoveredOverride) { + reconciled.set(tabId, recoveredOverride) + } else { + reconciled.delete(tabId) + } + runtime.currentOverrides = reconciled + const latest = viewOverridesStateRef.current + if (!isOverrideScope(latest, hostId, worktreeId)) { + return + } + const reconciledState = { ...latest, overrides: reconciled, loaded: preference.loaded } + viewOverridesStateRef.current = reconciledState + setViewOverridesState(reconciledState) + }) + }, + [ensureViewOverridesRuntime, hostId, worktreeId] + ) + + return { isTabChatView, toggleTabChatView } +} diff --git a/mobile/src/source-control/mobile-git-status.test.ts b/mobile/src/source-control/mobile-git-status.test.ts index 9c3575a75b2..a9e2b554f91 100644 --- a/mobile/src/source-control/mobile-git-status.test.ts +++ b/mobile/src/source-control/mobile-git-status.test.ts @@ -2,6 +2,7 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import type { GitStatusResult } from '../../../src/shared/git-status-types' import { buildMobileSourceControlSections, + canOpenMobileGitStatusEntry, countStagedEntries, countUnstagedEntries, getStageablePaths, @@ -65,6 +66,31 @@ describe('mobile source control status helpers', () => { expect(isMobileGitDiscardableEntry(conflictedEntries[2])).toBe(false) }) + it('allows opening deleted files so pre-delete text/image diffs can load', () => { + expect( + canOpenMobileGitStatusEntry({ path: 'deleted.png', status: 'deleted', area: 'unstaged' }) + ).toBe(true) + expect( + canOpenMobileGitStatusEntry({ path: 'logo.png', status: 'modified', area: 'unstaged' }) + ).toBe(true) + expect( + canOpenMobileGitStatusEntry({ + path: 'conflict.ts', + status: 'modified', + area: 'unstaged', + conflictStatus: 'unresolved' + }) + ).toBe(false) + expect( + canOpenMobileGitStatusEntry({ + path: 'resolved.ts', + status: 'modified', + area: 'unstaged', + conflictStatus: 'resolved_locally' + }) + ).toBe(true) + }) + it('sorts entries by desktop-compatible conflict rank, then path', () => { const sections = buildMobileSourceControlSections([ { path: 'zeta.ts', status: 'modified', area: 'unstaged' }, diff --git a/mobile/src/source-control/mobile-git-status.ts b/mobile/src/source-control/mobile-git-status.ts index c6bfde9b8c2..65fcf61e211 100644 --- a/mobile/src/source-control/mobile-git-status.ts +++ b/mobile/src/source-control/mobile-git-status.ts @@ -90,6 +90,12 @@ export function isMobileGitDiscardableEntry(entry: MobileGitStatusEntry): boolea return entry.conflictStatus !== 'unresolved' && entry.conflictStatus !== 'resolved_locally' } +// Why: unresolved conflicts are not a stable file to open. Deletions are — +// git.diff still returns the pre-delete side (text or image via modifiedDeleted). +export function canOpenMobileGitStatusEntry(entry: MobileGitStatusEntry): boolean { + return entry.conflictStatus !== 'unresolved' +} + export function isMobileGitUnavailable(code: string | undefined, message: string | undefined) { return ( code === 'forbidden' || diff --git a/mobile/src/source-control/mobile-source-control-screen-state.ts b/mobile/src/source-control/mobile-source-control-screen-state.ts index eb20cdaa3ac..78a588d19ff 100644 --- a/mobile/src/source-control/mobile-source-control-screen-state.ts +++ b/mobile/src/source-control/mobile-source-control-screen-state.ts @@ -20,6 +20,7 @@ import type { MobileGitBranchCompareSummary } from './mobile-branch-compare' import { + canOpenMobileGitStatusEntry, isMobileGitDiscardableEntry, isMobileGitStageableEntry, type MobileGitFileStatus, @@ -58,15 +59,14 @@ export type MobileGitStatusEntryView = MobileGitStatusEntry & { } // Decorate raw status entries with the row-level capability/action-id fields the -// file list needs. Deleted/unresolved entries are not openable (matches the -// opener guards). +// file list needs. Opener guards must use the same canOpen rule. export function buildMobileGitStatusEntryViews( entries: readonly MobileGitStatusEntry[] ): MobileGitStatusEntryView[] { return entries.map((entry) => ({ ...entry, canDiscard: isMobileGitDiscardableEntry(entry), - canOpen: entry.status !== 'deleted' && entry.conflictStatus !== 'unresolved', + canOpen: canOpenMobileGitStatusEntry(entry), canStage: isMobileGitStageableEntry(entry), discardActionId: `discard:${entry.path}`, stageActionId: `stage:${entry.path}`, diff --git a/mobile/src/source-control/use-mobile-source-control-openers.ts b/mobile/src/source-control/use-mobile-source-control-openers.ts index be3febb4a80..bf0eff63cf6 100644 --- a/mobile/src/source-control/use-mobile-source-control-openers.ts +++ b/mobile/src/source-control/use-mobile-source-control-openers.ts @@ -12,7 +12,11 @@ import { canOpenMobileBranchCompareDiff, type MobileGitBranchChangeEntry } from './mobile-branch-compare' -import { isMobileGitUnavailable, type MobileGitStatusEntry } from './mobile-git-status' +import { + canOpenMobileGitStatusEntry, + isMobileGitUnavailable, + type MobileGitStatusEntry +} from './mobile-git-status' import { buildMobileReviewFileRoute } from './mobile-review-route' import type { GitDiffTextResult, @@ -70,7 +74,9 @@ export function useMobileSourceControlOpeners(params: Params) { const openFile = useCallback( async (entry: MobileGitStatusEntry) => { - if (entry.status === 'deleted' || entry.conflictStatus === 'unresolved') { + // Deletions are openable (pre-delete text/image via git.diff); only block + // unresolved conflicts, matching canOpenMobileGitStatusEntry / row UI. + if (!canOpenMobileGitStatusEntry(entry)) { return } if (openingPathRef.current || busyActionRef.current) { diff --git a/mobile/src/storage/preferences.test.ts b/mobile/src/storage/preferences.test.ts index 881391db0bd..13e43d45b43 100644 --- a/mobile/src/storage/preferences.test.ts +++ b/mobile/src/storage/preferences.test.ts @@ -8,7 +8,6 @@ import { HOST_SIDEBAR_MIN_WIDTH, clampHostDockWidth, clampHostSidebarWidth, - loadNativeChatTabIds, loadDisabledTerminalLiveInputHandles, loadHostSidebarWidth, loadPushNotificationsEnabled, @@ -18,11 +17,17 @@ import { readDisabledTerminalLiveInputHandlesPreference, saveDisabledTerminalLiveInputHandles, saveHostSidebarWidth, - saveNativeChatTabIds, savePushNotificationsEnabled, saveTerminalAutocompleteEnabled, saveTerminalLinkOpenMode } from './preferences' +import { + loadDefaultSessionView, + loadSessionViewOverrides, + readSessionViewOverridesPreference, + saveDefaultSessionView, + updateSessionViewOverride +} from './session-view-preferences' vi.mock('@react-native-async-storage/async-storage', () => ({ default: { @@ -31,27 +36,204 @@ vi.mock('@react-native-async-storage/async-storage', () => ({ } })) -describe('native chat tab preference', () => { +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +describe('session view preference', () => { beforeEach(() => { vi.mocked(AsyncStorage.getItem).mockReset() vi.mocked(AsyncStorage.setItem).mockReset() }) - it('loads and saves tab ids under a host-and-worktree scoped key', async () => { - vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(['tab-1', 42, 'tab-2'])) + it('defaults to terminal and persists the chat default', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValue(null) + await expect(loadDefaultSessionView()).resolves.toBe('terminal') + expect(AsyncStorage.getItem).toHaveBeenCalledWith('orca:defaultSessionView') - await expect(loadNativeChatTabIds('host/one', 'folder:C:\\repo')).resolves.toEqual([ - 'tab-1', - 'tab-2' + await saveDefaultSessionView('chat') + expect(AsyncStorage.setItem).toHaveBeenCalledWith('orca:defaultSessionView', 'chat') + + vi.mocked(AsyncStorage.getItem).mockResolvedValue('bogus') + await expect(loadDefaultSessionView()).resolves.toBe('terminal') + }) + + it('loads and updates per-tab overrides under a host-and-worktree scoped key', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValue( + JSON.stringify({ 'tab-1': 'chat', 'tab-2': 'terminal', 'tab-3': 'bogus' }) + ) + + const loaded = await loadSessionViewOverrides('host/one', 'folder:C:\\repo') + expect([...loaded.entries()]).toEqual([ + ['tab-1', 'chat'], + ['tab-2', 'terminal'] ]) expect(AsyncStorage.getItem).toHaveBeenCalledWith( 'orca:nativeChatTabs:host%2Fone:folder%3AC%3A%5Crepo' ) - await saveNativeChatTabIds('host/one', 'folder:C:\\repo', ['tab-2']) + await updateSessionViewOverride('host/one', 'folder:C:\\repo', 'tab-2', 'chat') expect(AsyncStorage.setItem).toHaveBeenCalledWith( 'orca:nativeChatTabs:host%2Fone:folder%3AC%3A%5Crepo', - JSON.stringify(['tab-2']) + JSON.stringify({ 'tab-1': 'chat', 'tab-2': 'chat' }) + ) + }) + + it('migrates the legacy array format to chat overrides', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(['tab-1', 42, 'tab-2'])) + + const loaded = await loadSessionViewOverrides('host', 'wt') + expect([...loaded.entries()]).toEqual([ + ['tab-1', 'chat'], + ['tab-2', 'chat'] + ]) + }) + + it('serializes default writes across callers and makes reads wait for the latest', async () => { + let stored = 'terminal' + const firstWrite = deferred() + vi.mocked(AsyncStorage.getItem).mockImplementation(async () => stored) + vi.mocked(AsyncStorage.setItem) + .mockImplementationOnce(async (_key, value) => { + await firstWrite.promise + stored = value + }) + .mockImplementation(async (_key, value) => { + stored = value + }) + + const older = saveDefaultSessionView('chat') + await Promise.resolve() + const newer = saveDefaultSessionView('terminal') + const reloaded = loadDefaultSessionView() + await Promise.resolve() + + expect(AsyncStorage.setItem).toHaveBeenCalledTimes(1) + firstWrite.resolve() + await Promise.all([older, newer]) + + await expect(reloaded).resolves.toBe('terminal') + expect(AsyncStorage.setItem).toHaveBeenNthCalledWith(1, 'orca:defaultSessionView', 'chat') + expect(AsyncStorage.setItem).toHaveBeenNthCalledWith(2, 'orca:defaultSessionView', 'terminal') + }) + + it('continues the shared default queue after a failed write', async () => { + let stored = 'terminal' + vi.mocked(AsyncStorage.getItem).mockImplementation(async () => stored) + vi.mocked(AsyncStorage.setItem) + .mockRejectedValueOnce(new Error('storage unavailable')) + .mockImplementation(async (_key, value) => { + stored = value + }) + + const failed = expect(saveDefaultSessionView('terminal')).rejects.toThrow('storage unavailable') + const latest = saveDefaultSessionView('chat') + await failed + await latest + + await expect(loadDefaultSessionView()).resolves.toBe('chat') + expect(AsyncStorage.setItem).toHaveBeenCalledTimes(2) + }) + + it('orders per-tab mutations across callers without losing saved siblings', async () => { + let stored = JSON.stringify({ saved: 'chat' }) + const firstWrite = deferred() + vi.mocked(AsyncStorage.getItem).mockImplementation(async () => stored) + vi.mocked(AsyncStorage.setItem) + .mockImplementationOnce(async (_key, value) => { + await firstWrite.promise + stored = value + }) + .mockImplementation(async (_key, value) => { + stored = value + }) + + const first = updateSessionViewOverride('host', 'worktree', 'first', 'terminal') + await vi.waitFor(() => expect(AsyncStorage.setItem).toHaveBeenCalledTimes(1)) + const second = updateSessionViewOverride('host', 'worktree', 'second', 'chat') + const reloaded = loadSessionViewOverrides('host', 'worktree') + + expect(AsyncStorage.setItem).toHaveBeenCalledTimes(1) + firstWrite.resolve() + await Promise.all([first, second]) + + await expect(reloaded).resolves.toEqual( + new Map([ + ['saved', 'chat'], + ['first', 'terminal'], + ['second', 'chat'] + ]) + ) + }) + + it('does not globally block updates for a different host and worktree', async () => { + const blockedWrite = deferred() + vi.mocked(AsyncStorage.getItem).mockResolvedValue(null) + vi.mocked(AsyncStorage.setItem).mockImplementation(async (key) => { + if (key.includes('blocked-host')) { + await blockedWrite.promise + } + }) + + const blocked = updateSessionViewOverride('blocked-host', 'worktree', 'tab', 'chat') + await Promise.resolve() + await Promise.resolve() + await updateSessionViewOverride('other-host', 'worktree', 'tab', 'terminal') + + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + 'orca:nativeChatTabs:other-host:worktree', + JSON.stringify({ tab: 'terminal' }) + ) + blockedWrite.resolve() + await blocked + }) + + it('continues a scoped override queue after a failed write', async () => { + let stored: string | null = null + vi.mocked(AsyncStorage.getItem).mockImplementation(async () => stored) + vi.mocked(AsyncStorage.setItem) + .mockRejectedValueOnce(new Error('storage unavailable')) + .mockImplementation(async (_key, value) => { + stored = value + }) + + const failed = updateSessionViewOverride('host', 'worktree', 'first', 'chat') + const latest = updateSessionViewOverride('host', 'worktree', 'second', 'terminal') + await expect(failed).rejects.toThrow('storage unavailable') + await latest + + await expect(loadSessionViewOverrides('host', 'worktree')).resolves.toEqual( + new Map([['second', 'terminal']]) + ) + }) + + it('does not replace saved overrides after a transient read failure', async () => { + vi.mocked(AsyncStorage.getItem).mockRejectedValue(new Error('storage unavailable')) + + await expect(readSessionViewOverridesPreference('host', 'worktree')).resolves.toEqual({ + overrides: new Map(), + loaded: false + }) + + await expect(updateSessionViewOverride('host', 'worktree', 'tab', 'chat')).rejects.toThrow( + 'Session view overrides could not be read' + ) + + expect(AsyncStorage.setItem).not.toHaveBeenCalled() + }) + + it('repairs invalid preference data on the next user mutation', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValue('not-json') + + await updateSessionViewOverride('host', 'worktree', 'tab', 'chat') + + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + 'orca:nativeChatTabs:host:worktree', + JSON.stringify({ tab: 'chat' }) ) }) }) diff --git a/mobile/src/storage/preferences.ts b/mobile/src/storage/preferences.ts index a5af9bf350f..5173ac5bc8a 100644 --- a/mobile/src/storage/preferences.ts +++ b/mobile/src/storage/preferences.ts @@ -2,35 +2,6 @@ import AsyncStorage from '@react-native-async-storage/async-storage' const PINS_PREFIX = 'orca:pins:' const NOTIF_KEY = 'orca:pushNotificationsEnabled' -const NATIVE_CHAT_TABS_PREFIX = 'orca:nativeChatTabs:' - -function nativeChatTabIdsKey(hostId: string, worktreeId: string): string { - return `${NATIVE_CHAT_TABS_PREFIX}${encodeURIComponent(hostId)}:${encodeURIComponent(worktreeId)}` -} - -/** Tab ids currently showing native chat, scoped to the paired host and worktree - * so colliding remote ids cannot activate a transcript watcher on another host. */ -export async function loadNativeChatTabIds(hostId: string, worktreeId: string): Promise { - try { - const raw = await AsyncStorage.getItem(nativeChatTabIdsKey(hostId, worktreeId)) - const parsed = raw ? (JSON.parse(raw) as unknown) : null - return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : [] - } catch { - return [] - } -} - -export async function saveNativeChatTabIds( - hostId: string, - worktreeId: string, - ids: string[] -): Promise { - try { - await AsyncStorage.setItem(nativeChatTabIdsKey(hostId, worktreeId), JSON.stringify(ids)) - } catch { - // Best-effort persistence; a failed write just forgets the view choice. - } -} export type PushNotificationsPreference = { readonly value: boolean | null diff --git a/mobile/src/storage/session-view-preferences.ts b/mobile/src/storage/session-view-preferences.ts new file mode 100644 index 00000000000..750167e3d53 --- /dev/null +++ b/mobile/src/storage/session-view-preferences.ts @@ -0,0 +1,141 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' + +/** How a supported agent session opens: the raw terminal or the native chat view. */ +export type MobileSessionView = 'terminal' | 'chat' + +const DEFAULT_SESSION_VIEW_KEY = 'orca:defaultSessionView' +const NATIVE_CHAT_TABS_PREFIX = 'orca:nativeChatTabs:' + +// Why: default stays terminal so native chat remains strictly opt-in. +export const DEFAULT_SESSION_VIEW: MobileSessionView = 'terminal' + +let defaultViewWriteBarrier: Promise | null = null +const overrideUpdateBarriers = new Map>() + +function sessionViewOverridesKey(hostId: string, worktreeId: string): string { + return `${NATIVE_CHAT_TABS_PREFIX}${encodeURIComponent(hostId)}:${encodeURIComponent(worktreeId)}` +} + +function clearDefaultViewWriteBarrier(barrier: Promise): void { + if (defaultViewWriteBarrier === barrier) { + defaultViewWriteBarrier = null + } +} + +/** Global (per-device) default for how supported agent sessions open. */ +export async function loadDefaultSessionView(): Promise { + await defaultViewWriteBarrier + try { + const raw = await AsyncStorage.getItem(DEFAULT_SESSION_VIEW_KEY) + return raw === 'chat' || raw === 'terminal' ? raw : DEFAULT_SESSION_VIEW + } catch { + return DEFAULT_SESSION_VIEW + } +} + +export function saveDefaultSessionView(view: MobileSessionView): Promise { + // Why: callers can outlive their route; a shared barrier keeps remounted + // Settings screens from letting an older write land after a newer choice. + const write = (defaultViewWriteBarrier ?? Promise.resolve()).then(() => + AsyncStorage.setItem(DEFAULT_SESSION_VIEW_KEY, view) + ) + const barrier = write.catch(() => undefined) + defaultViewWriteBarrier = barrier + void barrier.then(() => clearDefaultViewWriteBarrier(barrier)) + return write +} + +export type SessionViewOverridesPreference = { + overrides: Map + loaded: boolean +} + +async function readSessionViewOverridesStorage( + key: string +): Promise { + let raw: string | null + try { + raw = await AsyncStorage.getItem(key) + } catch { + return { overrides: new Map(), loaded: false } + } + if (!raw) { + return { overrides: new Map(), loaded: true } + } + let parsed: unknown + try { + parsed = JSON.parse(raw) as unknown + } catch { + // Invalid preference data is safe to replace on the next user mutation. + return { overrides: new Map(), loaded: true } + } + // Legacy format: an array of tab ids that were showing native chat. + if (Array.isArray(parsed)) { + return { + overrides: new Map( + parsed + .filter((id): id is string => typeof id === 'string') + .map((id) => [id, 'chat' as const]) + ), + loaded: true + } + } + if (parsed && typeof parsed === 'object') { + const entries = Object.entries(parsed as Record).filter( + (entry): entry is [string, MobileSessionView] => + entry[1] === 'terminal' || entry[1] === 'chat' + ) + return { overrides: new Map(entries), loaded: true } + } + return { overrides: new Map(), loaded: true } +} + +/** Per-tab session-view overrides that win over the global default, scoped to the + * paired host and worktree so colliding remote ids cannot activate a transcript + * watcher on another host. */ +export async function loadSessionViewOverrides( + hostId: string, + worktreeId: string +): Promise> { + return (await readSessionViewOverridesPreference(hostId, worktreeId)).overrides +} + +/** Reads overrides without conflating an empty preference with unavailable storage. */ +export async function readSessionViewOverridesPreference( + hostId: string, + worktreeId: string +): Promise { + const key = sessionViewOverridesKey(hostId, worktreeId) + await overrideUpdateBarriers.get(key) + return readSessionViewOverridesStorage(key) +} + +/** Persists one user mutation without replacing sibling overrides from another mount. */ +export async function updateSessionViewOverride( + hostId: string, + worktreeId: string, + tabId: string, + view: MobileSessionView +): Promise { + const key = sessionViewOverridesKey(hostId, worktreeId) + const previous = overrideUpdateBarriers.get(key) ?? Promise.resolve() + const update = previous.then(async () => { + const current = await readSessionViewOverridesStorage(key) + // Why: a transient read failure must not replace valid saved siblings with + // a partial map containing only the latest tab. + if (!current.loaded) { + throw new Error('Session view overrides could not be read') + } + current.overrides.set(tabId, view) + await AsyncStorage.setItem(key, JSON.stringify(Object.fromEntries(current.overrides))) + }) + const barrier = update.catch(() => undefined) + overrideUpdateBarriers.set(key, barrier) + try { + await update + } finally { + if (overrideUpdateBarriers.get(key) === barrier) { + overrideUpdateBarriers.delete(key) + } + } +} diff --git a/package.json b/package.json index 292873687f1..147a7c89226 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.144-rc.2", + "version": "1.4.144-rc.4", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", diff --git a/resources/skills/current-manifest.json b/resources/skills/current-manifest.json index fca1162fa5e..b0053b7b118 100644 --- a/resources/skills/current-manifest.json +++ b/resources/skills/current-manifest.json @@ -1,11 +1,9 @@ { - "schemaVersion": 1, - "appVersion": "1.4.144-rc.1", + "schemaVersion": 2, "skills": [ { "name": "computer-use", "sourcePath": "skills/computer-use", - "appVersion": "1.4.144-rc.1", "releaseRevision": 5, "packageDigest": "cd2809474d57fd7277adb277448e6fa446810d3cbad71ac0b473b9e8ff1bad68", "gitTreeSha": "306c0f8cb63bcac265a5b7975dc2f855be4f1344", @@ -24,7 +22,6 @@ { "name": "linear-tickets", "sourcePath": "skills/linear-tickets", - "appVersion": "1.4.144-rc.1", "releaseRevision": 4, "packageDigest": "f198d7b22e5ee1673dac403f9cca0553b124e0a90e4fdd05d2c23b7344e32d2b", "gitTreeSha": "de9fc106bbb4e313a90ff9a9513a720909bbd176", @@ -43,7 +40,6 @@ { "name": "orca-cli", "sourcePath": "skills/orca-cli", - "appVersion": "1.4.144-rc.1", "releaseRevision": 32, "packageDigest": "51740ff13f379ac5743d3fd28a14b17168dcef40f7048c20182dce166098c45f", "gitTreeSha": "ded93000a5f654e2b4f324501282459bd56afe19", @@ -62,7 +58,6 @@ { "name": "orca-emulator", "sourcePath": "skills/orca-emulator", - "appVersion": "1.4.144-rc.1", "releaseRevision": 4, "packageDigest": "453b1d9aa20b51b8a4d32c7b6def6a93f7ef9c730de32abbcbc1788ad1b1820b", "gitTreeSha": "66be6abe99f1807da85934aee0e22daefc8f7656", @@ -81,7 +76,6 @@ { "name": "orca-emulator-android", "sourcePath": "skills/orca-emulator-android", - "appVersion": "1.4.144-rc.1", "releaseRevision": 2, "packageDigest": "12272cf82e0731f11e424822b961882457034e730358cc65ea28e4eb9c8ff7f5", "gitTreeSha": "f7b0fc8cbf5cd78ca5156f6bbe3a20f1462d8f83", @@ -100,7 +94,6 @@ { "name": "orca-linear", "sourcePath": "skills/orca-linear", - "appVersion": "1.4.144-rc.1", "releaseRevision": 2, "packageDigest": "d44d09e6ecb6a64da177083aad26a95f031cd1cf26ba059fdc888c2628aef64f", "gitTreeSha": "c34f42030f43e5a85737996fa375bbd79cb5bea8", @@ -119,7 +112,6 @@ { "name": "orca-per-workspace-env", "sourcePath": "skills/orca-per-workspace-env", - "appVersion": "1.4.144-rc.1", "releaseRevision": 2, "packageDigest": "fa3b65a1a107fca3f0375c696852477b62f58c154b9eb5c0663c41edc4bcd30d", "gitTreeSha": "354e775b79ea6952ec63acac4d3ee8a9ae07a650", @@ -138,7 +130,6 @@ { "name": "orchestration", "sourcePath": "skills/orchestration", - "appVersion": "1.4.144-rc.1", "releaseRevision": 24, "packageDigest": "9fbfa2ae3f3f99441563a4b8b1c6302107944480db8718ddb326a862a51f7ab9", "gitTreeSha": "086c41e0b353b4908d2963694b4a7c791d4b3982", 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 c1f46a602fb..1df5dbba930 100644 --- a/src/main/agent-hooks/first-work-branch-rename.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -140,7 +140,7 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { }) it('strips a prefix the model leaked into the slug from both branch and display name', async () => { - // Model ignored "no prefixes" and echoed `you/worktree-spinner`, which the + // Model echoed `you/worktree-spinner`, which the // sanitizer folds to `you-worktree-spinner`; without stripping it would // double-prefix the branch (`you/you-...`) and show "You worktree spinner". generateBranchNameMock.mockResolvedValue({ success: true, slug: 'you-worktree-spinner' }) diff --git a/src/main/agent-hooks/first-work-branch-rename.ts b/src/main/agent-hooks/first-work-branch-rename.ts index c6e7fb8d10e..d669192907f 100644 --- a/src/main/agent-hooks/first-work-branch-rename.ts +++ b/src/main/agent-hooks/first-work-branch-rename.ts @@ -283,9 +283,8 @@ async function runAutoRename( const username = provider ? (await getSshGitUsername(provider, repo.path)) || null : (await resolveLocalGitUsername(repo.path)) || null - // The model is told not to add a prefix, but sometimes echoes the configured - // one (e.g. `tmchow/...`); strip it so it doesn't double-prefix the branch or - // leak into the display name. + // The model sometimes echoes the configured prefix (e.g. `tmchow/...`); strip + // it so it doesn't double-prefix the branch or leak into the display name. const slug = stripConfiguredBranchPrefix( generated.slug, getConfiguredBranchPrefix(settings, username) diff --git a/src/main/azure-devops/azure-devops-api-request.ts b/src/main/azure-devops/azure-devops-api-request.ts index 136b21ab0d1..089975cf5a2 100644 --- a/src/main/azure-devops/azure-devops-api-request.ts +++ b/src/main/azure-devops/azure-devops-api-request.ts @@ -1,5 +1,6 @@ import { Buffer } from 'node:buffer' import type { AzureDevOpsRepoRef } from './repository-ref' +import { cancelUnreadResponseBody } from '../lib/unread-response-body' const REQUEST_TIMEOUT_MS = 5000 @@ -84,6 +85,7 @@ export async function requestAzureDevOpsJsonAtBase( signal: AbortSignal.timeout(options.timeoutMs ?? REQUEST_TIMEOUT_MS) }) if (!response.ok) { + await cancelUnreadResponseBody(response) return null } return (await response.json()) as T diff --git a/src/main/azure-devops/client.test.ts b/src/main/azure-devops/client.test.ts index 1b3e5fb1196..d394962fcff 100644 --- a/src/main/azure-devops/client.test.ts +++ b/src/main/azure-devops/client.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cancelTrackingResponse } from '../lib/unread-response-body.test-fixtures' import { getAzureDevOpsAuthStatus, getAzureDevOpsPullRequestForBranch, @@ -176,4 +177,22 @@ describe('Azure DevOps client', () => { headSha: 'newsha' }) }) + + it('cancels unread error-response bodies so bundled undici cannot crash on socket close', async () => { + gitExecFileAsyncMock.mockResolvedValue({ + stdout: 'https://dev.azure.com/acme/Project/_git/repo\n' + }) + let cancelledBodies = 0 + const fetchMock = vi.fn(async () => + cancelTrackingResponse(502, () => { + cancelledBodies += 1 + }) + ) + globalThis.fetch = fetchMock as never + + await getAzureDevOpsPullRequestForBranch('/repo', 'refs/heads/feature/azure') + + expect(fetchMock).toHaveBeenCalled() + expect(cancelledBodies).toBe(fetchMock.mock.calls.length) + }) }) diff --git a/src/main/bitbucket/client.test.ts b/src/main/bitbucket/client.test.ts index 9ab872358c6..4e552ab59cd 100644 --- a/src/main/bitbucket/client.test.ts +++ b/src/main/bitbucket/client.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { cancelTrackingResponse } from '../lib/unread-response-body.test-fixtures' const { gitExecFileAsyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn() @@ -122,4 +123,19 @@ describe('Bitbucket client', () => { account: 'bitbucket-user' }) }) + + it('cancels unread error-response bodies so bundled undici cannot crash on socket close', async () => { + let cancelledBodies = 0 + const fetchMock = vi.fn(async () => + cancelTrackingResponse(502, () => { + cancelledBodies += 1 + }) + ) + vi.stubGlobal('fetch', fetchMock) + + await getBitbucketPullRequestForBranch('/repo', 'refs/heads/feature/bitbucket') + + expect(fetchMock).toHaveBeenCalled() + expect(cancelledBodies).toBe(fetchMock.mock.calls.length) + }) }) diff --git a/src/main/bitbucket/client.ts b/src/main/bitbucket/client.ts index 5eea09b1662..6dbc11f65da 100644 --- a/src/main/bitbucket/client.ts +++ b/src/main/bitbucket/client.ts @@ -12,6 +12,7 @@ import { getHostedReviewLocalGitOptions, type HostedReviewExecutionOptions } from '../source-control/hosted-review-git-options' +import { cancelUnreadResponseBody } from '../lib/unread-response-body' const DEFAULT_API_BASE_URL = 'https://api.bitbucket.org/2.0' const REQUEST_TIMEOUT_MS = 5000 @@ -97,6 +98,7 @@ async function requestJson(path: string, options: RequestOptions = {}): Promi signal: AbortSignal.timeout(options.timeoutMs ?? REQUEST_TIMEOUT_MS) }) if (!response.ok) { + await cancelUnreadResponseBody(response) return null } return (await response.json()) as T diff --git a/src/main/browser/agent-browser-bridge.test.ts b/src/main/browser/agent-browser-bridge.test.ts index f55f726f356..4629c844f08 100644 --- a/src/main/browser/agent-browser-bridge.test.ts +++ b/src/main/browser/agent-browser-bridge.test.ts @@ -79,6 +79,8 @@ function mockBrowserManager( getWebContentsIdByTabId: () => tabs, getWorktreeIdForTab: (tabId: string) => worktrees.get(tabId), getGuestWebContentsId: vi.fn(() => null), + getBrowserPageLoadError: vi.fn(() => null), + getBrowserPageCertificateFailure: vi.fn(() => null), unregisterGuest: vi.fn(), ensureWebviewVisible: vi.fn(async () => () => {}), acquireAutomationVisibility: vi.fn(async () => () => {}), @@ -531,6 +533,43 @@ describe('AgentBrowserBridge', () => { expect(result.tabs[0].url).toBe('https://a.com') }) + it('surfaces the browser-manager load error on each listed tab', () => { + const tabs = new Map([['tab-a', 1]]) + const wc1 = mockWebContents(1, 'chrome-error://chromewebdata/', '') + webContentsFromIdMock.mockImplementation((id: number) => (id === 1 ? wc1 : null)) + const loadError = { + code: -202, + description: 'ERR_CERT_AUTHORITY_INVALID', + validatedUrl: 'https://localhost:3443/' + } + const certificateFailure = { + challengeId: 'challenge-1', + browserPageId: 'tab-a', + errorCode: -202, + error: 'ERR_CERT_AUTHORITY_INVALID', + origin: 'https://localhost:3443', + displayHost: 'localhost:3443', + canProceed: true, + observedAt: 123 + } + const b = new AgentBrowserBridge( + mockBrowserManager(tabs, new Map(), { + getBrowserPageLoadError: vi.fn((tabId: string) => (tabId === 'tab-a' ? loadError : null)), + getBrowserPageCertificateFailure: vi.fn((tabId: string) => + tabId === 'tab-a' ? certificateFailure : null + ) + }) + ) + + // Why: an agent driving the browser must see the structured cert failure, + // not just chrome-error:// from getURL(). + expect(b.tabList().tabs[0]).toMatchObject({ + url: 'https://localhost:3443/', + loadError, + certificateFailure + }) + }) + it('does not mutate active-tab routing when tab-list infers the first live tab', () => { const tabs = new Map([ ['tab-a', 1], diff --git a/src/main/browser/agent-browser-bridge.ts b/src/main/browser/agent-browser-bridge.ts index 80817c81d58..0bec7bf68fa 100644 --- a/src/main/browser/agent-browser-bridge.ts +++ b/src/main/browser/agent-browser-bridge.ts @@ -698,12 +698,18 @@ export class AgentBrowserBridge { if (firstLiveWcId === null) { firstLiveWcId = wcId } + const loadError = this.browserManager.getBrowserPageLoadError(tabId) + const certificateFailure = this.browserManager.getBrowserPageCertificateFailure(tabId) result.push({ browserPageId: tabId, index: index++, - url: wc.getURL() ?? '', + // Why: failed WebContents report chrome-error://, which is neither + // actionable nor the address the user asked to load. + url: loadError?.validatedUrl ?? wc.getURL() ?? '', title: wc.getTitle() ?? '', - active: wcId === activeWcId + active: wcId === activeWcId, + loadError, + certificateFailure }) } // Why: if no tab has been explicitly activated yet, surface the first live diff --git a/src/main/browser/browser-certificate-challenge.ts b/src/main/browser/browser-certificate-challenge.ts new file mode 100644 index 00000000000..2e49685b5d3 --- /dev/null +++ b/src/main/browser/browser-certificate-challenge.ts @@ -0,0 +1,77 @@ +import type { BrowserCertificateFailure } from '../../shared/types' +import { SUPPORTED_CERTIFICATE_ERROR_CODE } from './browser-certificate-identity' + +export const CERTIFICATE_CHALLENGE_TTL_MS = 5 * 60_000 +export const MAX_PENDING_CERTIFICATE_CHALLENGES = 32 +export const MAX_CERTIFICATE_GRANTS = 32 + +export type ManagedBrowserGuestContext = { + browserPageId: string | null + worktreeId: string | null + sessionProfileId: string | null + owner: 'desktop-webview' | 'offscreen' +} + +export type BrowserCertificateTrustControllerDependencies = { + resolveManagedGuestContext: (webContentsId: number) => ManagedBrowserGuestContext | null + resolveWebContentsIdForPage: (browserPageId: string) => number | null + resolveWebContents: (webContentsId: number) => Electron.WebContents | null + onFailureChanged: ( + webContentsId: number, + failure: BrowserCertificateFailure | null, + navigationUrl?: string + ) => void + now?: () => number + createChallengeId?: () => string +} + +export type PendingCertificateChallenge = { + challengeId: string + guestWebContentsId: number + browserPageId: string | null + navigationSequence: number + navigationUrl: string + origin: string + displayHost: string + secureEndpoint: string + leafCertificateSha256: string + errorCode: number | null + error: string + observedAt: number + expiresAt: number +} + +export type CertificateTrustGrant = Pick< + PendingCertificateChallenge, + 'guestWebContentsId' | 'secureEndpoint' | 'leafCertificateSha256' | 'error' +> + +export function certificateChallengeIdentityMatches( + challenge: PendingCertificateChallenge, + candidate: Pick< + PendingCertificateChallenge, + 'navigationSequence' | 'secureEndpoint' | 'leafCertificateSha256' | 'error' + > +): boolean { + return ( + challenge.navigationSequence === candidate.navigationSequence && + challenge.secureEndpoint === candidate.secureEndpoint && + challenge.leafCertificateSha256 === candidate.leafCertificateSha256 && + challenge.error === candidate.error + ) +} + +export function toBrowserCertificateFailure( + challenge: PendingCertificateChallenge +): BrowserCertificateFailure { + return { + challengeId: challenge.challengeId, + browserPageId: challenge.browserPageId ?? '', + errorCode: challenge.errorCode, + error: challenge.error, + origin: challenge.origin, + displayHost: challenge.displayHost, + canProceed: challenge.errorCode === SUPPORTED_CERTIFICATE_ERROR_CODE, + observedAt: challenge.observedAt + } +} diff --git a/src/main/browser/browser-certificate-identity.ts b/src/main/browser/browser-certificate-identity.ts new file mode 100644 index 00000000000..9170f8edde4 --- /dev/null +++ b/src/main/browser/browser-certificate-identity.ts @@ -0,0 +1,32 @@ +import { createHash } from 'node:crypto' + +export const SUPPORTED_CERTIFICATE_ERROR = 'ERR_CERT_AUTHORITY_INVALID' +export const SUPPORTED_CERTIFICATE_ERROR_CODE = -202 + +export function normalizeCertificateError(error: string): string { + return error + .trim() + .replace(/^net::/i, '') + .toUpperCase() +} + +export function getSupportedCertificateErrorCode(error: string): number | null { + return normalizeCertificateError(error) === SUPPORTED_CERTIFICATE_ERROR + ? SUPPORTED_CERTIFICATE_ERROR_CODE + : null +} + +export function getLeafCertificateSha256(certificate: Electron.Certificate): string | null { + const match = certificate.data.match( + /-----BEGIN CERTIFICATE-----([\s\S]*?)-----END CERTIFICATE-----/ + ) + if (!match) { + return null + } + try { + const der = Buffer.from(match[1].replace(/\s+/g, ''), 'base64') + return der.length > 0 ? createHash('sha256').update(der).digest('hex') : null + } catch { + return null + } +} diff --git a/src/main/browser/browser-certificate-request-guard.ts b/src/main/browser/browser-certificate-request-guard.ts new file mode 100644 index 00000000000..0a4602804a9 --- /dev/null +++ b/src/main/browser/browser-certificate-request-guard.ts @@ -0,0 +1,203 @@ +import type { Session } from 'electron' + +import { toSecureCertificateEndpoint } from '../../shared/browser-url' +import { MAX_CERTIFICATE_GRANTS, type CertificateTrustGrant } from './browser-certificate-challenge' + +type CertificateIdentity = Pick< + CertificateTrustGrant, + 'secureEndpoint' | 'leafCertificateSha256' | 'error' +> + +type BlockedMainFrame = CertificateIdentity & { + webContentsId: number + navigationUrl: string + origin: string + displayHost: string +} + +type RequestGuardDependencies = { + onBlockedMainFrame: (blocked: BlockedMainFrame) => void +} + +function certificateIdentitiesMatch( + left: CertificateIdentity | undefined, + right: CertificateIdentity +): boolean { + return Boolean( + left && + left.secureEndpoint === right.secureEndpoint && + left.leafCertificateSha256 === right.leafCertificateSha256 && + left.error === right.error + ) +} + +export class BrowserCertificateRequestGuard { + private readonly grantsByGuestId = new Map() + private readonly grantSessionByGuestId = new Map() + private readonly guardedSessions = new Set() + private readonly acceptedIdentityBySession = new Map>() + + constructor(private readonly dependencies: RequestGuardDependencies) {} + + installSession(session: Session): void { + if (this.guardedSessions.has(session)) { + return + } + // Why: Chromium caches certificate continuations at session scope. This + // request gate restores the narrower per-WebContents approval boundary. + session.webRequest.onBeforeRequest((details, callback) => { + callback(this.shouldBlockRequest(session, details) ? { cancel: true } : {}) + }) + this.guardedSessions.add(session) + } + + removeSession(session: Session): void { + if (!this.guardedSessions.delete(session)) { + return + } + session.webRequest.onBeforeRequest(null) + this.acceptedIdentityBySession.delete(session) + for (const [webContentsId, grantSession] of this.grantSessionByGuestId) { + if (grantSession === session) { + this.revokeGuest(webContentsId) + } + } + } + + canOfferCertificate(session: Session, identity: CertificateIdentity): boolean { + if (!this.guardedSessions.has(session)) { + return false + } + const accepted = this.acceptedIdentityBySession.get(session)?.get(identity.secureEndpoint) + // Why: the request gate cannot inspect TLS after Chromium caches a decision. + // Never accept a second bad leaf for one endpoint in the same app process. + return !accepted || certificateIdentitiesMatch(accepted, identity) + } + + grant(session: Session, grant: CertificateTrustGrant): boolean { + if (!this.guardedSessions.has(session)) { + return false + } + let acceptedByEndpoint = this.acceptedIdentityBySession.get(session) + const accepted = acceptedByEndpoint?.get(grant.secureEndpoint) + // Why: once one leaf is accepted for an endpoint in this session, Chromium + // caches the TLS decision. Refuse a conflicting pending leaf so proceed() + // cannot report success when the next certificate-error will still fail. + if (accepted && !certificateIdentitiesMatch(accepted, grant)) { + return false + } + // Why: pin the identity at grant time, not only on the later certificate + // callback, so a concurrent sibling proceed cannot race in a second leaf. + if (!acceptedByEndpoint) { + acceptedByEndpoint = new Map() + this.acceptedIdentityBySession.set(session, acceptedByEndpoint) + } + acceptedByEndpoint.set(grant.secureEndpoint, { + secureEndpoint: grant.secureEndpoint, + leafCertificateSha256: grant.leafCertificateSha256, + error: grant.error + }) + this.grantsByGuestId.delete(grant.guestWebContentsId) + this.grantsByGuestId.set(grant.guestWebContentsId, grant) + this.grantSessionByGuestId.set(grant.guestWebContentsId, session) + this.enforceGrantBound() + return true + } + + shouldTrustCertificate( + session: Session, + webContentsId: number, + identity: CertificateIdentity + ): boolean { + if ( + !this.guardedSessions.has(session) || + this.grantSessionByGuestId.get(webContentsId) !== session || + !certificateIdentitiesMatch(this.grantsByGuestId.get(webContentsId), identity) + ) { + return false + } + let acceptedByEndpoint = this.acceptedIdentityBySession.get(session) + if (!acceptedByEndpoint) { + acceptedByEndpoint = new Map() + this.acceptedIdentityBySession.set(session, acceptedByEndpoint) + } + const accepted = acceptedByEndpoint.get(identity.secureEndpoint) + if (accepted && !certificateIdentitiesMatch(accepted, identity)) { + return false + } + acceptedByEndpoint.set(identity.secureEndpoint, identity) + return true + } + + revokeGuest(webContentsId: number): void { + this.grantsByGuestId.delete(webContentsId) + this.grantSessionByGuestId.delete(webContentsId) + } + + revokeForCommittedNavigation(webContentsId: number, url: string): void { + const grant = this.grantsByGuestId.get(webContentsId) + if (grant && toSecureCertificateEndpoint(url) !== grant.secureEndpoint) { + this.revokeGuest(webContentsId) + } + } + + private shouldBlockRequest( + session: Session, + details: Electron.OnBeforeRequestListenerDetails + ): boolean { + const secureEndpoint = toSecureCertificateEndpoint(details.url) + if (!secureEndpoint) { + return false + } + const accepted = this.acceptedIdentityBySession.get(session)?.get(secureEndpoint) + if (!accepted) { + return false + } + const webContentsId = details.webContentsId ?? details.webContents?.id + const grant = webContentsId === undefined ? undefined : this.grantsByGuestId.get(webContentsId) + if ( + webContentsId !== undefined && + grant && + this.grantSessionByGuestId.get(webContentsId) === session && + certificateIdentitiesMatch(grant, accepted) + ) { + return false + } + if (details.resourceType === 'mainFrame' && webContentsId !== undefined) { + this.reportBlockedMainFrame(webContentsId, details.url, accepted) + } + return true + } + + private reportBlockedMainFrame( + webContentsId: number, + navigationUrl: string, + identity: CertificateIdentity + ): void { + try { + const parsed = new URL(navigationUrl) + if (parsed.protocol !== 'https:') { + return + } + this.dependencies.onBlockedMainFrame({ + webContentsId, + navigationUrl, + origin: parsed.origin, + displayHost: parsed.host, + ...identity + }) + } catch { + // The request remains blocked even if Chromium supplies a malformed URL. + } + } + + private enforceGrantBound(): void { + while (this.grantsByGuestId.size > MAX_CERTIFICATE_GRANTS) { + const oldest = this.grantsByGuestId.keys().next().value as number | undefined + if (oldest === undefined) { + return + } + this.revokeGuest(oldest) + } + } +} diff --git a/src/main/browser/browser-certificate-trust-controller.test.ts b/src/main/browser/browser-certificate-trust-controller.test.ts new file mode 100644 index 00000000000..85153aef5a7 --- /dev/null +++ b/src/main/browser/browser-certificate-trust-controller.test.ts @@ -0,0 +1,336 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { BrowserCertificateTrustController } from './browser-certificate-trust-controller' + +const FIRST_CERTIFICATE = certificate('first certificate') +const SECOND_CERTIFICATE = certificate('replacement certificate') +let beforeRequestListener: + | (( + details: Electron.OnBeforeRequestListenerDetails, + callback: (response: Electron.CallbackResponse) => void + ) => void) + | null = null +const browserSession = { + webRequest: { + onBeforeRequest: vi.fn( + ( + listener: + | (( + details: Electron.OnBeforeRequestListenerDetails, + callback: (response: Electron.CallbackResponse) => void + ) => void) + | null + ) => { + beforeRequestListener = listener + } + ) + } +} as unknown as Electron.Session + +function certificate(contents: string): Electron.Certificate { + return { + data: `-----BEGIN CERTIFICATE-----\n${Buffer.from(contents).toString('base64')}\n-----END CERTIFICATE-----` + } as Electron.Certificate +} + +function createGuest(id: number): Electron.WebContents { + return { + id, + session: browserSession, + isDestroyed: vi.fn(() => false), + loadURL: vi.fn(() => Promise.resolve()) + } as unknown as Electron.WebContents +} + +function beforeRequest(args: { + url?: string + webContentsId?: number + resourceType?: Electron.OnBeforeRequestListenerDetails['resourceType'] +}) { + const callback = vi.fn() + beforeRequestListener?.( + { + id: 1, + url: args.url ?? 'https://localhost:3443/app', + method: 'GET', + webContentsId: args.webContentsId, + resourceType: args.resourceType ?? 'mainFrame', + referrer: '', + timestamp: 1, + uploadData: [] + }, + callback + ) + return callback +} + +function certificateEvent(args: { + controller: BrowserCertificateTrustController + guest: Electron.WebContents + url?: string + error?: string + certificate?: Electron.Certificate + isMainFrame?: boolean +}) { + const preventDefault = vi.fn() + const callback = vi.fn() + args.controller.handleCertificateError({ + event: { preventDefault }, + webContents: args.guest, + url: args.url ?? 'https://localhost:3443/app', + error: args.error ?? 'net::ERR_CERT_AUTHORITY_INVALID', + certificate: args.certificate ?? FIRST_CERTIFICATE, + callback, + isMainFrame: args.isMainFrame ?? true + }) + return { preventDefault, callback } +} + +describe('BrowserCertificateTrustController', () => { + const guest = createGuest(7) + const otherGuest = createGuest(8) + const onFailureChanged = vi.fn() + let now = 1_000 + let challengeNumber = 0 + let pageByGuestId: Map + let guestByPageId: Map + let guestById: Map + let controller: BrowserCertificateTrustController + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(guest.isDestroyed).mockReturnValue(false) + vi.mocked(otherGuest.isDestroyed).mockReturnValue(false) + vi.mocked(guest.loadURL).mockResolvedValue(undefined) + vi.mocked(otherGuest.loadURL).mockResolvedValue(undefined) + now = 1_000 + challengeNumber = 0 + pageByGuestId = new Map([ + [guest.id, 'page-1'], + [otherGuest.id, 'page-2'] + ]) + guestByPageId = new Map([ + ['page-1', guest.id], + ['page-2', otherGuest.id] + ]) + guestById = new Map([ + [guest.id, guest], + [otherGuest.id, otherGuest] + ]) + controller = new BrowserCertificateTrustController({ + resolveManagedGuestContext: (webContentsId) => { + if (!pageByGuestId.has(webContentsId)) { + return null + } + return { + browserPageId: pageByGuestId.get(webContentsId) ?? null, + worktreeId: 'worktree-1', + sessionProfileId: 'profile-1', + owner: 'desktop-webview' + } + }, + resolveWebContentsIdForPage: (browserPageId) => guestByPageId.get(browserPageId) ?? null, + resolveWebContents: (webContentsId) => guestById.get(webContentsId) ?? null, + onFailureChanged, + now: () => now, + createChallengeId: () => `challenge-${++challengeNumber}` + }) + controller.installSessionRequestGuard(browserSession) + }) + + it('rejects first, then trusts only the approved guest, endpoint, certificate, and error', () => { + controller.onMainFrameNavigationStarted(guest.id) + const first = certificateEvent({ controller, guest }) + + expect(first.callback).toHaveBeenCalledOnce() + expect(first.callback).toHaveBeenCalledWith(false) + expect(first.preventDefault).not.toHaveBeenCalled() + expect(controller.getFailure('page-1')).toMatchObject({ + challengeId: 'challenge-1', + browserPageId: 'page-1', + origin: 'https://localhost:3443', + displayHost: 'localhost:3443', + errorCode: -202, + canProceed: true + }) + + expect(controller.proceed('page-1', 'challenge-1')).toEqual({ ok: true }) + expect(guest.loadURL).toHaveBeenCalledWith('https://localhost:3443/app') + expect(onFailureChanged).toHaveBeenLastCalledWith(guest.id, null) + + const sameEndpoint = certificateEvent({ + controller, + guest, + url: 'wss://localhost:3443/socket' + }) + expect(sameEndpoint.preventDefault).toHaveBeenCalledOnce() + expect(sameEndpoint.callback).toHaveBeenCalledOnce() + expect(sameEndpoint.callback).toHaveBeenCalledWith(true) + + const otherTab = certificateEvent({ controller, guest: otherGuest }) + expect(otherTab.callback).toHaveBeenCalledWith(false) + expect(otherTab.preventDefault).not.toHaveBeenCalled() + + const otherPort = certificateEvent({ + controller, + guest, + url: 'https://localhost:3444/' + }) + expect(otherPort.callback).toHaveBeenCalledWith(false) + expect(otherPort.preventDefault).not.toHaveBeenCalled() + + const otherCertificate = certificateEvent({ + controller, + guest, + certificate: SECOND_CERTIFICATE + }) + expect(otherCertificate.callback).toHaveBeenCalledWith(false) + expect(otherCertificate.preventDefault).not.toHaveBeenCalled() + }) + + it('blocks a session-cached certificate for sibling requests until that guest approves it', () => { + certificateEvent({ controller, guest }) + expect(controller.proceed('page-1', 'challenge-1')).toEqual({ ok: true }) + expect(certificateEvent({ controller, guest }).callback).toHaveBeenCalledWith(true) + + const siblingMainFrame = beforeRequest({ webContentsId: otherGuest.id }) + expect(siblingMainFrame).toHaveBeenCalledWith({ cancel: true }) + expect(controller.getFailure('page-2')).toMatchObject({ + challengeId: 'challenge-2', + browserPageId: 'page-2', + origin: 'https://localhost:3443', + canProceed: true + }) + + expect( + beforeRequest({ webContentsId: otherGuest.id, resourceType: 'webSocket' }) + ).toHaveBeenCalledWith({ cancel: true }) + expect(beforeRequest({ resourceType: 'webSocket' })).toHaveBeenCalledWith({ cancel: true }) + expect( + beforeRequest({ url: 'https://localhost:3444/app', webContentsId: otherGuest.id }) + ).toHaveBeenCalledWith({}) + expect(beforeRequest({ webContentsId: guest.id })).toHaveBeenCalledWith({}) + + expect(controller.proceed('page-2', 'challenge-2')).toEqual({ ok: true }) + expect(beforeRequest({ webContentsId: otherGuest.id })).toHaveBeenCalledWith({}) + }) + + it('fails closed when an accepted endpoint presents a replacement bad certificate', () => { + certificateEvent({ controller, guest }) + expect(controller.proceed('page-1', 'challenge-1')).toEqual({ ok: true }) + expect(certificateEvent({ controller, guest }).callback).toHaveBeenCalledWith(true) + + controller.onMainFrameNavigationStarted(guest.id) + const replacement = certificateEvent({ controller, guest, certificate: SECOND_CERTIFICATE }) + + expect(replacement.callback).toHaveBeenCalledWith(false) + expect(replacement.preventDefault).not.toHaveBeenCalled() + expect(controller.getFailure('page-1')).toBeNull() + }) + + it('rejects proceed for a second tab whose pending leaf conflicts with an accepted identity', () => { + // Both tabs observe different leaves before either is approved. + certificateEvent({ controller, guest }) + certificateEvent({ controller, guest: otherGuest, certificate: SECOND_CERTIFICATE }) + expect(controller.getFailure('page-1')?.challengeId).toBe('challenge-1') + expect(controller.getFailure('page-2')?.challengeId).toBe('challenge-2') + + expect(controller.proceed('page-1', 'challenge-1')).toEqual({ ok: true }) + // First proceed pins the session identity; the sibling's conflicting leaf + // must not report success (Chromium cannot honor a second bad leaf). + expect(controller.proceed('page-2', 'challenge-2')).toEqual({ + ok: false, + reason: 'ineligible' + }) + expect(otherGuest.loadURL).not.toHaveBeenCalled() + }) + + it('never offers approval for public hosts, non-authority failures, or subframes', () => { + for (const event of [ + certificateEvent({ controller, guest, url: 'https://example.com/' }), + certificateEvent({ controller, guest, error: 'ERR_CERT_DATE_INVALID' }), + certificateEvent({ controller, guest, isMainFrame: false }) + ]) { + expect(event.callback).toHaveBeenCalledOnce() + expect(event.callback).toHaveBeenCalledWith(false) + expect(event.preventDefault).not.toHaveBeenCalled() + } + expect(controller.getFailure('page-1')).toBeNull() + expect(onFailureChanged).not.toHaveBeenCalled() + }) + + it('queues an early challenge until the guest receives its browser page identity', () => { + pageByGuestId.set(guest.id, null) + certificateEvent({ controller, guest }) + + expect(onFailureChanged).not.toHaveBeenCalled() + expect(controller.getFailure('page-1')).toBeNull() + + pageByGuestId.set(guest.id, 'page-1') + controller.onGuestRegistered(guest.id, 'page-1') + + expect(onFailureChanged).toHaveBeenCalledWith( + guest.id, + expect.objectContaining({ challengeId: 'challenge-1', browserPageId: 'page-1' }), + 'https://localhost:3443/app' + ) + }) + + it('rejects stale, expired, destroyed, and mismatched approval attempts', () => { + certificateEvent({ controller, guest }) + + expect(controller.proceed('page-1', 'wrong-challenge')).toEqual({ + ok: false, + reason: 'changed' + }) + now += 5 * 60_000 + expect(controller.proceed('page-1', 'challenge-1')).toEqual({ + ok: false, + reason: 'expired' + }) + + certificateEvent({ controller, guest }) + vi.mocked(guest.isDestroyed).mockReturnValue(true) + expect(controller.proceed('page-1', 'challenge-2')).toEqual({ + ok: false, + reason: 'missing' + }) + expect(guest.loadURL).not.toHaveBeenCalled() + }) + + it('clears pending approval and grants when navigation or guest ownership changes', () => { + certificateEvent({ controller, guest }) + controller.onMainFrameNavigationStarted(guest.id) + expect(controller.getFailure('page-1')).toBeNull() + expect(controller.proceed('page-1', 'challenge-1')).toEqual({ + ok: false, + reason: 'missing' + }) + + certificateEvent({ controller, guest }) + expect(controller.proceed('page-1', 'challenge-2')).toEqual({ ok: true }) + controller.onMainFrameNavigationCommitted(guest.id, 'https://example.com/') + const afterCommit = certificateEvent({ controller, guest }) + expect(afterCommit.callback).toHaveBeenCalledWith(false) + + controller.onGuestRetired(guest.id) + expect(controller.getFailure('page-1')).toBeNull() + }) + + it('answers malformed and unmanaged events exactly once without overriding Electron', () => { + pageByGuestId.delete(guest.id) + const unmanaged = certificateEvent({ controller, guest }) + const malformedUrl = certificateEvent({ controller, guest: otherGuest, url: 'not a URL' }) + const malformedCertificate = certificateEvent({ + controller, + guest: otherGuest, + certificate: { data: 'not a PEM certificate' } as Electron.Certificate + }) + + for (const event of [unmanaged, malformedUrl, malformedCertificate]) { + expect(event.callback).toHaveBeenCalledOnce() + expect(event.callback).toHaveBeenCalledWith(false) + expect(event.preventDefault).not.toHaveBeenCalled() + } + }) +}) diff --git a/src/main/browser/browser-certificate-trust-controller.ts b/src/main/browser/browser-certificate-trust-controller.ts new file mode 100644 index 00000000000..e69d7ad2ca3 --- /dev/null +++ b/src/main/browser/browser-certificate-trust-controller.ts @@ -0,0 +1,300 @@ +import { randomUUID } from 'node:crypto' +import type { BrowserCertificateFailure, BrowserCertificateProceedResult } from '../../shared/types' +import { + isEligibleLocalCertificateHost, + toSecureCertificateEndpoint +} from '../../shared/browser-url' +import { + certificateChallengeIdentityMatches, + CERTIFICATE_CHALLENGE_TTL_MS, + MAX_PENDING_CERTIFICATE_CHALLENGES, + toBrowserCertificateFailure, + type BrowserCertificateTrustControllerDependencies, + type PendingCertificateChallenge +} from './browser-certificate-challenge' +import { + getLeafCertificateSha256, + getSupportedCertificateErrorCode, + normalizeCertificateError, + SUPPORTED_CERTIFICATE_ERROR, + SUPPORTED_CERTIFICATE_ERROR_CODE +} from './browser-certificate-identity' +import { BrowserCertificateRequestGuard } from './browser-certificate-request-guard' + +export type { ManagedBrowserGuestContext } from './browser-certificate-challenge' + +export class BrowserCertificateTrustController { + private readonly pendingByGuestId = new Map() + private readonly navigationSequenceByGuestId = new Map() + private readonly expiryTimerByGuestId = new Map>() + private readonly requestGuard: BrowserCertificateRequestGuard + + constructor(private readonly dependencies: BrowserCertificateTrustControllerDependencies) { + this.requestGuard = new BrowserCertificateRequestGuard({ + onBlockedMainFrame: (blocked) => { + const context = this.dependencies.resolveManagedGuestContext(blocked.webContentsId) + if (context) { + this.recordPendingChallenge({ + ...blocked, + browserPageId: context.browserPageId + }) + } + } + }) + } + + installSessionRequestGuard(session: Electron.Session): void { + this.requestGuard.installSession(session) + } + removeSessionRequestGuard(session: Electron.Session): void { + this.requestGuard.removeSession(session) + } + + handleCertificateError(args: { + event: Pick + webContents: Electron.WebContents + url: string + error: string + certificate: Electron.Certificate + callback: (isTrusted: boolean) => void + isMainFrame: boolean + }): void { + let answered = false + const answer = (trusted: boolean): void => { + if (!answered) { + answered = true + args.callback(trusted) + } + } + try { + const context = this.dependencies.resolveManagedGuestContext(args.webContents.id) + const parsed = new URL(args.url) + const endpoint = toSecureCertificateEndpoint(args.url) + const digest = getLeafCertificateSha256(args.certificate) + const error = normalizeCertificateError(args.error) + if (!context || !endpoint || !digest) { + answer(false) + return + } + const identity = { secureEndpoint: endpoint, leafCertificateSha256: digest, error } + if ( + this.requestGuard.shouldTrustCertificate( + args.webContents.session, + args.webContents.id, + identity + ) + ) { + args.event.preventDefault() + answer(true) + return + } + if ( + args.isMainFrame && + parsed.protocol === 'https:' && + error === SUPPORTED_CERTIFICATE_ERROR && + isEligibleLocalCertificateHost(parsed.hostname) && + this.requestGuard.canOfferCertificate(args.webContents.session, identity) + ) { + this.recordPendingChallenge({ + webContentsId: args.webContents.id, + browserPageId: context.browserPageId, + navigationUrl: args.url, + origin: parsed.origin, + displayHost: parsed.host, + secureEndpoint: endpoint, + leafCertificateSha256: digest, + error + }) + } + answer(false) + } catch { + answer(false) + } + } + + onGuestRegistered(webContentsId: number, browserPageId: string): void { + const pending = this.pendingByGuestId.get(webContentsId) + if (!pending) { + return + } + pending.browserPageId = browserPageId + this.emitFailure(pending) + } + + onGuestRetired(webContentsId: number): void { + this.clearPending(webContentsId, true) + this.requestGuard.revokeGuest(webContentsId) + this.navigationSequenceByGuestId.delete(webContentsId) + } + + onMainFrameNavigationStarted(webContentsId: number): void { + this.navigationSequenceByGuestId.set( + webContentsId, + (this.navigationSequenceByGuestId.get(webContentsId) ?? 0) + 1 + ) + this.clearPending(webContentsId, true) + } + + onMainFrameNavigationCommitted(webContentsId: number, url: string): void { + this.requestGuard.revokeForCommittedNavigation(webContentsId, url) + } + + getFailure(browserPageId: string): BrowserCertificateFailure | null { + this.pruneExpired() + for (const pending of this.pendingByGuestId.values()) { + if (pending.browserPageId === browserPageId) { + return toBrowserCertificateFailure(pending) + } + } + return null + } + + proceed(browserPageId: string, challengeId: string): BrowserCertificateProceedResult { + const webContentsId = this.dependencies.resolveWebContentsIdForPage(browserPageId) + if (webContentsId === null) { + return { ok: false, reason: 'missing' } + } + const pending = this.pendingByGuestId.get(webContentsId) + if (!pending) { + return { ok: false, reason: 'missing' } + } + if (pending.challengeId !== challengeId || pending.browserPageId !== browserPageId) { + return { ok: false, reason: 'changed' } + } + if (pending.expiresAt <= this.now()) { + this.clearPending(webContentsId, true) + return { ok: false, reason: 'expired' } + } + if (pending.errorCode !== SUPPORTED_CERTIFICATE_ERROR_CODE) { + return { ok: false, reason: 'ineligible' } + } + if (pending.navigationSequence !== (this.navigationSequenceByGuestId.get(webContentsId) ?? 0)) { + return { ok: false, reason: 'navigated' } + } + const context = this.dependencies.resolveManagedGuestContext(webContentsId) + const guest = this.dependencies.resolveWebContents(webContentsId) + if (!context || context.browserPageId !== browserPageId || !guest || guest.isDestroyed()) { + return { ok: false, reason: 'missing' } + } + const granted = this.requestGuard.grant(guest.session, { + guestWebContentsId: webContentsId, + secureEndpoint: pending.secureEndpoint, + leafCertificateSha256: pending.leafCertificateSha256, + error: pending.error + }) + if (!granted) { + return { ok: false, reason: 'ineligible' } + } + const navigationUrl = pending.navigationUrl + this.clearPending(webContentsId, true) + void guest.loadURL(navigationUrl).catch(() => {}) + return { ok: true } + } + + private recordPendingChallenge(args: { + webContentsId: number + browserPageId: string | null + navigationUrl: string + origin: string + displayHost: string + secureEndpoint: string + leafCertificateSha256: string + error: string + }): void { + const navigationSequence = this.navigationSequenceByGuestId.get(args.webContentsId) ?? 0 + const existing = this.pendingByGuestId.get(args.webContentsId) + if ( + existing && + certificateChallengeIdentityMatches(existing, { + navigationSequence, + secureEndpoint: args.secureEndpoint, + leafCertificateSha256: args.leafCertificateSha256, + error: args.error + }) + ) { + return + } + this.clearPending(args.webContentsId, true) + const observedAt = this.now() + const pending: PendingCertificateChallenge = { + challengeId: this.dependencies.createChallengeId?.() ?? randomUUID(), + guestWebContentsId: args.webContentsId, + browserPageId: args.browserPageId, + navigationSequence, + navigationUrl: args.navigationUrl, + origin: args.origin, + displayHost: args.displayHost, + secureEndpoint: args.secureEndpoint, + leafCertificateSha256: args.leafCertificateSha256, + errorCode: getSupportedCertificateErrorCode(args.error), + error: args.error, + observedAt, + expiresAt: observedAt + CERTIFICATE_CHALLENGE_TTL_MS + } + this.pendingByGuestId.set(args.webContentsId, pending) + this.scheduleExpiry(pending) + this.enforcePendingBound() + this.emitFailure(pending) + } + + private emitFailure(pending: PendingCertificateChallenge): void { + if (pending.browserPageId) { + this.dependencies.onFailureChanged( + pending.guestWebContentsId, + toBrowserCertificateFailure(pending), + pending.navigationUrl + ) + } + } + + private clearPending(webContentsId: number, notify: boolean): void { + const pending = this.pendingByGuestId.get(webContentsId) + this.pendingByGuestId.delete(webContentsId) + const timer = this.expiryTimerByGuestId.get(webContentsId) + if (timer) { + clearTimeout(timer) + this.expiryTimerByGuestId.delete(webContentsId) + } + if (notify && pending?.browserPageId) { + this.dependencies.onFailureChanged(webContentsId, null) + } + } + + private scheduleExpiry(pending: PendingCertificateChallenge): void { + const timer = setTimeout( + () => { + if ( + this.pendingByGuestId.get(pending.guestWebContentsId)?.challengeId === pending.challengeId + ) { + this.clearPending(pending.guestWebContentsId, true) + } + }, + Math.max(0, pending.expiresAt - this.now()) + ) + timer.unref?.() + this.expiryTimerByGuestId.set(pending.guestWebContentsId, timer) + } + + private pruneExpired(): void { + const now = this.now() + for (const pending of this.pendingByGuestId.values()) { + if (pending.expiresAt <= now) { + this.clearPending(pending.guestWebContentsId, true) + } + } + } + + private enforcePendingBound(): void { + while (this.pendingByGuestId.size > MAX_PENDING_CERTIFICATE_CHALLENGES) { + const oldest = this.pendingByGuestId.keys().next().value as number | undefined + if (oldest === undefined) { + return + } + this.clearPending(oldest, true) + } + } + + private now(): number { + return this.dependencies.now?.() ?? Date.now() + } +} diff --git a/src/main/browser/browser-manager-grab.test.ts b/src/main/browser/browser-manager-grab.test.ts index 0d10e915fa8..43652e6b9db 100644 --- a/src/main/browser/browser-manager-grab.test.ts +++ b/src/main/browser/browser-manager-grab.test.ts @@ -781,7 +781,7 @@ describe('browserManager grab operations', () => { const promise = browserManager.awaitGrabSelection('tab-1', 'op-1', guest) // Find the did-start-navigation handler and trigger it with isMainFrame=true - const navHandler = guestOnMock.mock.calls.find( + const navHandler = guestOnMock.mock.calls.findLast( ([event]) => event === 'did-start-navigation' )?.[1] as ((...args: unknown[]) => void) | undefined @@ -798,7 +798,7 @@ describe('browserManager grab operations', () => { void browserManager.awaitGrabSelection('tab-1', 'op-1', guest) // Trigger did-start-navigation with isMainFrame=false (subframe) - const navHandler = guestOnMock.mock.calls.find( + const navHandler = guestOnMock.mock.calls.findLast( ([event]) => event === 'did-start-navigation' )?.[1] as ((...args: unknown[]) => void) | undefined diff --git a/src/main/browser/browser-manager.test.ts b/src/main/browser/browser-manager.test.ts index 912923fc8f5..6a4d6400e44 100644 --- a/src/main/browser/browser-manager.test.ts +++ b/src/main/browser/browser-manager.test.ts @@ -104,6 +104,7 @@ describe('browserManager', () => { webContentsFromIdMock.mockReset() openPopupWithOriginBarMock.mockReset() browserManager.unregisterAll() + browserManager.setBrowserGuestStateChangedListener(null) browserManager.setDictationShortcutForwardingPredicate(null) browserManager.setSettingsResolver(() => ({})) }) @@ -583,6 +584,45 @@ describe('browserManager', () => { expect(browserManager.getSessionProfileIdForTab('browser-1')).toBe('work') }) + it('tracks offscreen load failures for the owning worktree snapshot', () => { + const stateChanged = vi.fn() + const offscreenGuest = { + id: 605, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'window'), + setBackgroundThrottling: vi.fn(), + setWindowOpenHandler: vi.fn(), + on: vi.fn(), + off: vi.fn(), + getURL: vi.fn(() => 'https://localhost:3443/') + } + webContentsFromIdMock.mockReturnValue(offscreenGuest) + browserManager.setBrowserGuestStateChangedListener(stateChanged) + + browserManager.registerOffscreenGuest({ + browserPageId: 'offscreen-page', + worktreeId: 'remote-worktree', + webContentsId: offscreenGuest.id + }) + const didFailLoad = offscreenGuest.on.mock.calls.find( + ([event]) => event === 'did-fail-load' + )?.[1] as ( + event: unknown, + errorCode: number, + errorDescription: string, + validatedUrl: string, + isMainFrame: boolean + ) => void + didFailLoad(null, -202, 'Certificate authority invalid', 'https://localhost:3443/', true) + + expect(browserManager.getBrowserPageLoadError('offscreen-page')).toEqual({ + code: -202, + description: 'Certificate authority invalid', + validatedUrl: 'https://localhost:3443/' + }) + expect(stateChanged).toHaveBeenCalledWith('remote-worktree') + }) + it('falls back to opening popup URLs externally before a guest is registered', () => { const guest = { id: 105, @@ -1588,6 +1628,163 @@ describe('browserManager', () => { validatedUrl: 'http://localhost:3000/' } }) + expect(browserManager.getBrowserPageLoadError('browser-1')).toEqual({ + code: -105, + description: 'Name not resolved', + validatedUrl: 'http://localhost:3000/' + }) + + const didStartNavigationHandler = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as + | ((event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void) + | undefined + didStartNavigationHandler?.(null, 'http://localhost:3000/retry', false, true) + expect(browserManager.getBrowserPageLoadError('browser-1')).toBeNull() + }) + + it('drops a queued failure when a replacement navigation starts before registration', () => { + const rendererSendMock = vi.fn() + const guest = { + id: 407, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock, + getURL: vi.fn(() => 'https://example.com/') + } + webContentsFromIdMock.mockImplementation((id: number) => + id === guest.id + ? guest + : id === rendererWebContentsId + ? { isDestroyed: vi.fn(() => false), send: rendererSendMock } + : null + ) + + browserManager.attachGuestPolicies(guest as never) + const didFailLoad = guestOnMock.mock.calls.find( + ([event]) => event === 'did-fail-load' + )?.[1] as ( + event: unknown, + errorCode: number, + errorDescription: string, + validatedUrl: string, + isMainFrame: boolean + ) => void + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + + didFailLoad(null, -202, 'Certificate authority invalid', 'https://localhost:3443/', true) + didStartNavigation(null, 'https://example.com/', false, true) + expect( + browserManager.registerGuest({ + browserPageId: 'browser-late-registration', + webContentsId: guest.id, + rendererWebContentsId + }) + ).toBe(true) + + expect(rendererSendMock).not.toHaveBeenCalledWith( + 'browser:guest-load-failed', + expect.anything() + ) + expect(browserManager.getBrowserPageLoadError('browser-late-registration')).toBeNull() + }) + + it('keeps a certificate failure until a real main-frame navigation starts', () => { + const guest = { + id: 405, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock, + send: vi.fn(), + getURL: vi.fn(() => 'chrome-error://chromewebdata/') + } + webContentsFromIdMock.mockReturnValue(guest) + + browserManager.attachGuestPolicies(guest as never) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + const didFailLoad = guestOnMock.mock.calls.find( + ([event]) => event === 'did-fail-load' + )?.[1] as ( + event: unknown, + errorCode: number, + errorDescription: string, + validatedUrl: string, + isMainFrame: boolean + ) => void + + browserManager.registerGuest({ + browserPageId: 'browser-certificate-page', + webContentsId: guest.id, + rendererWebContentsId + }) + didFailLoad(null, -202, 'Certificate authority invalid', 'https://localhost:3443/', true) + didStartNavigation(null, 'chrome-error://chromewebdata/', false, true) + expect(browserManager.getBrowserPageLoadError('browser-certificate-page')?.code).toBe(-202) + + didStartNavigation(null, 'https://localhost:3443/', false, true) + expect(browserManager.getBrowserPageLoadError('browser-certificate-page')).toBeNull() + }) + + it('restores a certificate failure when a retry navigation aborts before committing', () => { + const guest = { + id: 406, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock, + send: vi.fn(), + getURL: vi.fn(() => 'chrome-error://chromewebdata/') + } + webContentsFromIdMock.mockReturnValue(guest) + + browserManager.attachGuestPolicies(guest as never) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + const didFailLoad = guestOnMock.mock.calls.find( + ([event]) => event === 'did-fail-load' + )?.[1] as ( + event: unknown, + errorCode: number, + errorDescription: string, + validatedUrl: string, + isMainFrame: boolean + ) => void + + browserManager.registerGuest({ + browserPageId: 'browser-retry-abort-page', + webContentsId: guest.id, + rendererWebContentsId + }) + didFailLoad(null, -202, 'Certificate authority invalid', 'https://localhost:3443/', true) + // Retry: the new navigation starts (overlay optimistically cleared)... + didStartNavigation(null, 'https://localhost:3443/', false, true) + expect(browserManager.getBrowserPageLoadError('browser-retry-abort-page')).toBeNull() + // ...then aborts (ERR_ABORTED) before committing, so the error is restored. + didFailLoad(null, -3, 'Aborted', 'https://localhost:3443/', true) + expect(browserManager.getBrowserPageLoadError('browser-retry-abort-page')?.code).toBe(-202) + + // A fresh navigation from a non-errored state drops the stash, so a later + // abort cannot resurrect the old error. + didStartNavigation(null, 'https://localhost:3443/', false, true) // stashes -202, clears active + didStartNavigation(null, 'https://example.com/', false, true) // active empty -> drops stash + didFailLoad(null, -3, 'Aborted', 'https://example.com/', true) + expect(browserManager.getBrowserPageLoadError('browser-retry-abort-page')).toBeNull() }) it('queues permission denials and download requests until the guest registers', () => { diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index b9232bcd8d6..60352732efa 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -9,7 +9,8 @@ import { ORCA_BROWSER_BLANK_URL } from '../../shared/constants' import { normalizeBrowserNavigationUrl, normalizeExternalBrowserUrl, - redactKagiSessionToken + redactKagiSessionToken, + toSecureCertificateEndpoint } from '../../shared/browser-url' import type { BrowserDownloadFinishedEvent, @@ -52,9 +53,18 @@ import { buildBrowserAnnotationViewportBridgeScript } from '../../shared/browser-annotation-viewport-bridge' import type { KeybindingOverrides } from '../../shared/keybindings' +import type { BrowserCertificateFailure, BrowserLoadError } from '../../shared/types' +import { + BrowserCertificateTrustController, + type ManagedBrowserGuestContext +} from './browser-certificate-trust-controller' const AUTOMATION_VISIBILITY_ACQUIRE_TIMEOUT_MS = 2_000 +function isChromiumInternalErrorUrl(url: string): boolean { + return url.startsWith('chrome-error://') +} + function resolveWithTimeout( promise: Promise, timeoutMs: number, @@ -238,23 +248,47 @@ export class BrowserManager { private readonly annotationViewportBridgeOpsByTabId = new Map>() private readonly worktreeIdByTabId = new Map() private readonly policyAttachedGuestIds = new Set() + private readonly offscreenGuestIds = new Set() private readonly policyCleanupByGuestId = new Map void>() private readonly clickedLinkFrameNameByGuestId = new Map() + private readonly loadErrorsByGuestId = new Map() + // Why: did-start-navigation optimistically hides the overlay, but an aborted + // nav never commits — stash the cleared error so did-fail-load(-3) can restore + // it instead of stranding the user on a blank surface. + private readonly clearedLoadErrorsByGuestId = new Map() + private browserGuestStateChangedListener: ((worktreeId: string) => void) | null = null + private certificateTrustController: BrowserCertificateTrustController | null = null private shouldForwardDictationShortcut: (() => boolean) | null = null private readonly pendingLoadFailuresByGuestId = new Map< number, { code: number; description: string; validatedUrl: string } >() - - setDictationShortcutForwardingPredicate(predicate: (() => boolean) | null): void { - this.shouldForwardDictationShortcut = predicate - } private readonly pendingPermissionEventsByGuestId = new Map() private readonly pendingPopupEventsByGuestId = new Map() private readonly pendingDownloadIdsByGuestId = new Map() private readonly downloadsById = new Map() private readonly grabSessionController = new BrowserGrabSessionController() + setDictationShortcutForwardingPredicate(predicate: (() => boolean) | null): void { + this.shouldForwardDictationShortcut = predicate + } + + setBrowserGuestStateChangedListener(listener: ((worktreeId: string) => void) | null): void { + this.browserGuestStateChangedListener = listener + } + + setCertificateTrustController(controller: BrowserCertificateTrustController): void { + this.certificateTrustController = controller + } + + installCertificateRequestGuard(session: Electron.Session): void { + this.certificateTrustController?.installSessionRequestGuard(session) + } + + removeCertificateRequestGuard(session: Electron.Session): void { + this.certificateTrustController?.removeSessionRequestGuard(session) + } + setSettingsResolver( resolver: () => { keybindings?: KeybindingOverrides @@ -827,18 +861,84 @@ export class BrowserManager { validatedURL: string, isMainFrame: boolean ): void => { - if (!isMainFrame || errorCode === -3) { + if (!isMainFrame) { return } - this.forwardOrQueueGuestLoadFailure(guest.id, { + const browserPageId = this.tabIdByWebContentsId.get(guest.id) + const certificateFailure = browserPageId + ? this.certificateTrustController?.getFailure(browserPageId) + : null + if ( + certificateFailure && + toSecureCertificateEndpoint(validatedURL || guest.getURL()) === + toSecureCertificateEndpoint(certificateFailure.origin) + ) { + // Why: a request-guard cancellation is the transport for the existing + // certificate warning; do not replace it with ERR_ABORTED/blocked copy. + return + } + if (errorCode === -3) { + // Why: an aborted main-frame nav never committed, so restore the error + // did-start-navigation optimistically cleared — otherwise a retry that + // aborts leaves the failed page with no overlay. + const clearedError = this.clearedLoadErrorsByGuestId.get(guest.id) + if (clearedError !== undefined) { + this.clearedLoadErrorsByGuestId.delete(guest.id) + this.loadErrorsByGuestId.set(guest.id, clearedError) + this.forwardOrQueueGuestLoadFailure(guest.id, clearedError) + this.notifyBrowserGuestStateChanged(guest.id) + } + return + } + this.clearedLoadErrorsByGuestId.delete(guest.id) + const loadError = { code: errorCode, description: errorDescription || 'This site could not be reached.', - validatedUrl: validatedURL || guest.getURL() || 'about:blank' - }) + validatedUrl: redactKagiSessionToken(validatedURL || guest.getURL() || 'about:blank') + } + this.loadErrorsByGuestId.set(guest.id, loadError) + this.forwardOrQueueGuestLoadFailure(guest.id, loadError) + this.notifyBrowserGuestStateChanged(guest.id) + } + + const didStartNavigationHandler = ( + _event: Electron.Event, + url: string, + _isInPlace: boolean, + isMainFrame: boolean + ): void => { + if (!isMainFrame || isChromiumInternalErrorUrl(url)) { + return + } + this.certificateTrustController?.onMainFrameNavigationStarted(guest.id) + // Why: a failure queued before renderer registration belongs only to the + // navigation that produced it. A replacement navigation must not replay + // that stale failure when its later dom-ready registers the guest. + this.pendingLoadFailuresByGuestId.delete(guest.id) + const activeError = this.loadErrorsByGuestId.get(guest.id) + if (activeError === undefined) { + // Why: no error to hide; drop any stale stash so a later abort cannot + // resurrect an error from a navigation that already succeeded. + this.clearedLoadErrorsByGuestId.delete(guest.id) + return + } + this.clearedLoadErrorsByGuestId.set(guest.id, activeError) + this.loadErrorsByGuestId.delete(guest.id) + this.notifyBrowserGuestStateChanged(guest.id) + } + + const didNavigateHandler = (_event: Electron.Event, url: string): void => { + // Why: a committed navigation means the optimistic stash from + // did-start-navigation is obsolete — drop it so a later ERR_ABORTED + // cannot restore a failure over the already-committed page. + this.clearedLoadErrorsByGuestId.delete(guest.id) + this.certificateTrustController?.onMainFrameNavigationCommitted(guest.id, url) } guest.on('will-navigate', navigationGuard) guest.on('will-redirect', navigationGuard) + guest.on('did-start-navigation', didStartNavigationHandler) + guest.on('did-navigate', didNavigateHandler) guest.on('did-fail-load', didFailLoadHandler) const handleDestroyed = (): void => { // Why: guests can be destroyed before renderer registration. Without @@ -874,6 +974,8 @@ export class BrowserManager { if (!guest.isDestroyed()) { guest.off('will-navigate', navigationGuard) guest.off('will-redirect', navigationGuard) + guest.off('did-start-navigation', didStartNavigationHandler) + guest.off('did-navigate', didNavigateHandler) guest.off('did-fail-load', didFailLoadHandler) } }) @@ -918,6 +1020,7 @@ export class BrowserManager { private cleanupGuestPolicyAttachment(guestWebContentsId: number): void { const isPrimaryGuest = this.tabIdByWebContentsId.has(guestWebContentsId) + this.certificateTrustController?.onGuestRetired(guestWebContentsId) const policyCleanup = this.policyCleanupByGuestId.get(guestWebContentsId) if (policyCleanup) { policyCleanup() @@ -925,6 +1028,7 @@ export class BrowserManager { } this.policyAttachedGuestIds.delete(guestWebContentsId) this.clickedLinkFrameNameByGuestId.delete(guestWebContentsId) + this.offscreenGuestIds.delete(guestWebContentsId) this.popupOwnerContextByGuestId.delete(guestWebContentsId) // Why: a popup must stop inheriting authorization as soon as its primary // owner is retired, even if Chromium has not destroyed the child yet. @@ -936,6 +1040,8 @@ export class BrowserManager { } } this.pendingLoadFailuresByGuestId.delete(guestWebContentsId) + this.loadErrorsByGuestId.delete(guestWebContentsId) + this.clearedLoadErrorsByGuestId.delete(guestWebContentsId) this.pendingPermissionEventsByGuestId.delete(guestWebContentsId) this.pendingPopupEventsByGuestId.delete(guestWebContentsId) this.cancelPendingDownloadsForGuest(guestWebContentsId) @@ -949,10 +1055,10 @@ export class BrowserManager { sessionProfileId, webContentsId, rendererWebContentsId - }: BrowserGuestRegistration): void { + }: BrowserGuestRegistration): boolean { const browserTabId = browserPageId ?? legacyBrowserTabId if (!browserTabId) { - return + return false } // Why: re-registering the same browser tab can happen when Chromium swaps // or recreates the underlying guest surface. Any active grab is bound to @@ -968,7 +1074,7 @@ export class BrowserManager { const guest = webContents.fromId(webContentsId) if (!guest || guest.isDestroyed()) { - return + return false } // Why: the renderer sends webContentsId, which we must not blindly trust. @@ -976,21 +1082,20 @@ export class BrowserManager { // causing us to overwrite its setWindowOpenHandler or attach unintended // context menus. Only accept genuine webview guest surfaces. if (guest.getType() !== 'webview') { - return + return false } if (!this.policyAttachedGuestIds.has(webContentsId)) { // Why: renderer registration is only the second half of the guest setup. // Main must only trust guests that already passed attach-time policy // installation; otherwise a trusted renderer could point us at some other // arbitrary webview and bypass the intended host-window attach boundary. - return + return false } const previousWebContentsId = this.webContentsIdByTabId.get(browserTabId) if (previousWebContentsId !== undefined && previousWebContentsId !== webContentsId) { this.retireStaleGuestWebContents(previousWebContentsId) } - this.webContentsIdByTabId.set(browserTabId, webContentsId) this.tabIdByWebContentsId.set(webContentsId, browserTabId) if (workspaceId) { @@ -1001,6 +1106,7 @@ export class BrowserManager { if (worktreeId) { this.worktreeIdByTabId.set(browserTabId, worktreeId) } + this.certificateTrustController?.onGuestRegistered(webContentsId, browserTabId) this.setupContextMenu(browserTabId, guest) this.setupGrabShortcut(browserTabId, guest) @@ -1010,6 +1116,7 @@ export class BrowserManager { this.flushPendingPermissionEvents(browserTabId, webContentsId) this.flushPendingPopupEvents(browserTabId, webContentsId) this.flushPendingDownloadRequests(browserTabId, webContentsId) + return true } unregisterGuest(browserTabId: string): void { @@ -1088,6 +1195,10 @@ export class BrowserManager { if (!guest || guest.isDestroyed()) { return } + // Why: offscreen pages have no renderer webview listeners, so main owns + // their load-failure lifecycle for remote browser chrome. + this.offscreenGuestIds.add(webContentsId) + this.attachGuestPolicies(guest) const previousWebContentsId = this.webContentsIdByTabId.get(browserPageId) if (previousWebContentsId !== undefined && previousWebContentsId !== webContentsId) { this.retireStaleGuestWebContents(previousWebContentsId) @@ -1098,6 +1209,7 @@ export class BrowserManager { if (worktreeId) { this.worktreeIdByTabId.set(browserPageId, worktreeId) } + this.certificateTrustController?.onGuestRegistered(webContentsId, browserPageId) } unregisterAll(): void { @@ -1111,6 +1223,7 @@ export class BrowserManager { this.unregisterGuest(browserTabId) } this.policyAttachedGuestIds.clear() + this.offscreenGuestIds.clear() // Why: unregisterGuest only cleans up guests that were registered (have an // entry in webContentsIdByTabId). Guests that went through // attachGuestPolicies but were never registered still have cleanup closures @@ -1125,6 +1238,8 @@ export class BrowserManager { this.worktreeIdByTabId.clear() this.sessionProfileIdByPageId.clear() this.pendingLoadFailuresByGuestId.clear() + this.loadErrorsByGuestId.clear() + this.clearedLoadErrorsByGuestId.clear() this.pendingPermissionEventsByGuestId.clear() this.pendingPopupEventsByGuestId.clear() this.pendingDownloadIdsByGuestId.clear() @@ -1148,6 +1263,86 @@ export class BrowserManager { return this.sessionProfileIdByPageId.get(browserTabId) ?? null } + getBrowserPageLoadError(browserPageId: string): BrowserLoadError | null { + const webContentsId = this.webContentsIdByTabId.get(browserPageId) + return webContentsId === undefined + ? null + : (this.loadErrorsByGuestId.get(webContentsId) ?? null) + } + + getBrowserPageCertificateFailure(browserPageId: string): BrowserCertificateFailure | null { + return this.certificateTrustController?.getFailure(browserPageId) ?? null + } + + getManagedBrowserGuestContext(webContentsId: number): ManagedBrowserGuestContext | null { + if (this.popupOwnerContextByGuestId.has(webContentsId)) { + return null + } + const browserPageId = this.tabIdByWebContentsId.get(webContentsId) ?? null + const offscreen = this.offscreenGuestIds.has(webContentsId) + if (!offscreen && !this.policyAttachedGuestIds.has(webContentsId)) { + return null + } + if (!offscreen) { + const guest = webContents.fromId(webContentsId) + if (!guest || guest.isDestroyed() || guest.getType() !== 'webview') { + return null + } + } + return { + browserPageId, + worktreeId: browserPageId ? (this.worktreeIdByTabId.get(browserPageId) ?? null) : null, + sessionProfileId: browserPageId + ? (this.sessionProfileIdByPageId.get(browserPageId) ?? null) + : null, + owner: offscreen ? 'offscreen' : 'desktop-webview' + } + } + + notifyCertificateFailureChanged( + webContentsId: number, + failure: BrowserCertificateFailure | null, + navigationUrl?: string + ): void { + if (failure && navigationUrl) { + const loadError = { + code: failure.errorCode ?? -1, + description: failure.error, + validatedUrl: redactKagiSessionToken(navigationUrl) + } + this.loadErrorsByGuestId.set(webContentsId, loadError) + this.forwardOrQueueGuestLoadFailure(webContentsId, loadError) + } + const browserPageId = this.tabIdByWebContentsId.get(webContentsId) + if (!browserPageId) { + return + } + if (this.offscreenGuestIds.has(webContentsId)) { + this.notifyBrowserGuestStateChanged(webContentsId) + return + } + const renderer = this.resolveRendererForBrowserTab(browserPageId) + renderer?.send('browser:certificate-failure-changed', { browserPageId, failure }) + } + + private notifyBrowserGuestStateChanged(webContentsId: number): void { + if (!this.offscreenGuestIds.has(webContentsId)) { + return + } + const browserPageId = this.tabIdByWebContentsId.get(webContentsId) + const worktreeId = browserPageId ? this.worktreeIdByTabId.get(browserPageId) : null + if (worktreeId) { + // Why: this runs inside an Electron guest event dispatch; the listener + // synchronously reconciles mobile-session tabs, and an escaping throw would + // become a fatal uncaught exception (no catch-all main-process guard). + try { + this.browserGuestStateChangedListener?.(worktreeId) + } catch (error) { + console.error('[browser-manager] browserGuestStateChanged listener failed', error) + } + } + } + notifyPermissionDenied(args: { guestWebContentsId: number permission: string @@ -2033,3 +2228,13 @@ export class BrowserManager { } export const browserManager = new BrowserManager() +export const browserCertificateTrustController = new BrowserCertificateTrustController({ + resolveManagedGuestContext: (webContentsId) => + browserManager.getManagedBrowserGuestContext(webContentsId), + resolveWebContentsIdForPage: (browserPageId) => + browserManager.getGuestWebContentsId(browserPageId), + resolveWebContents: (webContentsId) => webContents.fromId(webContentsId) ?? null, + onFailureChanged: (webContentsId, failure, navigationUrl) => + browserManager.notifyCertificateFailureChanged(webContentsId, failure, navigationUrl) +}) +browserManager.setCertificateTrustController(browserCertificateTrustController) diff --git a/src/main/browser/browser-session-registry.persistence.test.ts b/src/main/browser/browser-session-registry.persistence.test.ts index 583afff0815..174b5aa7715 100644 --- a/src/main/browser/browser-session-registry.persistence.test.ts +++ b/src/main/browser/browser-session-registry.persistence.test.ts @@ -109,7 +109,9 @@ function installModuleMocks( vi.doMock('./browser-manager', () => ({ browserManager: { notifyPermissionDenied: browserManagerNotifyPermissionDeniedMock, - handleGuestWillDownload: browserManagerHandleGuestWillDownloadMock + handleGuestWillDownload: browserManagerHandleGuestWillDownloadMock, + installCertificateRequestGuard: vi.fn(), + removeCertificateRequestGuard: vi.fn() } })) vi.doMock('./browser-media-access', () => ({ diff --git a/src/main/browser/browser-session-registry.test.ts b/src/main/browser/browser-session-registry.test.ts index 8d81590ae5c..f116e08c0da 100644 --- a/src/main/browser/browser-session-registry.test.ts +++ b/src/main/browser/browser-session-registry.test.ts @@ -21,7 +21,9 @@ vi.mock('electron', () => ({ vi.mock('./browser-manager', () => ({ browserManager: { notifyPermissionDenied: vi.fn(), - handleGuestWillDownload: vi.fn() + handleGuestWillDownload: vi.fn(), + installCertificateRequestGuard: vi.fn(), + removeCertificateRequestGuard: vi.fn() } })) diff --git a/src/main/browser/browser-session-registry.ts b/src/main/browser/browser-session-registry.ts index 3603de11568..a70aff4e6b2 100644 --- a/src/main/browser/browser-session-registry.ts +++ b/src/main/browser/browser-session-registry.ts @@ -551,6 +551,7 @@ class BrowserSessionRegistry { } const sess = session.fromPartition(partition) + browserManager.installCertificateRequestGuard(sess) if (typeof sess.getUserAgent === 'function') { const cleanUA = cleanElectronUserAgent(sess.getUserAgent()) sess.setUserAgent(cleanUA) @@ -622,6 +623,7 @@ class BrowserSessionRegistry { // Electron Session object survives; clear policy callbacks and listener // bookkeeping so removed profiles do not leave retained closures behind. this.configuredPartitions.delete(partition) + browserManager.removeCertificateRequestGuard(sess) sess.removeListener('will-download', this.handleWillDownload) clearBrowserWebAuthnAccessHandlers(sess) sess.setPermissionRequestHandler(null) diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 8ab662b8a7f..2a2f5a1bdd4 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -2283,7 +2283,7 @@ describe('createPtySubprocess', () => { expect(spawnMock).toHaveBeenCalledWith( 'C:\\PortableGit\\bin\\bash.exe', - ['--login', '-i'], + ['-c', 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i'], expect.objectContaining({ cwd: 'C:\\Users\\jin\\repo', env: expect.objectContaining({ CHERE_INVOKING: '1' }) diff --git a/src/main/git/remove-worktree.test.ts b/src/main/git/remove-worktree.test.ts index 121a8208754..c0789d1529c 100644 --- a/src/main/git/remove-worktree.test.ts +++ b/src/main/git/remove-worktree.test.ts @@ -280,6 +280,121 @@ branch refs/heads/main expect(getGitCalls()).toContain('git worktree remove --force /repo-feature') }) + it('force-retries removal when git refuses a clean worktree containing an initialised submodule', async () => { + mockGitCommands({ + 'git worktree list --porcelain': { + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main + +worktree /repo-feature +HEAD def456 +branch refs/heads/feature/test +` + }, + 'git worktree list --porcelain#2': { + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main +` + }, + 'git worktree remove /repo-feature': { + error: new Error('git worktree remove failed'), + stderr: 'fatal: working trees containing submodules cannot be moved or removed' + }, + 'git status --porcelain --untracked-files=all': { stdout: '' } + }) + + await removeWorktree('/repo', '/repo-feature') + + const calls = getGitCalls() + expectGitCallOrder( + calls, + 'git worktree remove /repo-feature', + 'git status --porcelain --untracked-files=all' + ) + expectGitCallOrder( + calls, + 'git status --porcelain --untracked-files=all', + 'git worktree remove --force /repo-feature' + ) + expect(calls).toContain('git branch -d -- feature/test') + }) + + it('surfaces uncommitted changes instead of force-removing a dirty submodule worktree', async () => { + mockGitCommands({ + 'git worktree list --porcelain': { + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main + +worktree /repo-feature +HEAD def456 +branch refs/heads/feature/test +` + }, + 'git worktree remove /repo-feature': { + error: new Error('git worktree remove failed'), + stderr: 'fatal: working trees containing submodules cannot be moved or removed' + }, + 'git status --porcelain --untracked-files=all': { stdout: ' M sub\n' } + }) + + await expect(removeWorktree('/repo', '/repo-feature')).rejects.toThrow( + 'Worktree has uncommitted or untracked changes.' + ) + expect(getGitCalls()).not.toContain('git worktree remove --force /repo-feature') + }) + + it('does not force-retry when the caller already forced removal', async () => { + mockGitCommands({ + 'git worktree list --porcelain': { + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main + +worktree /repo-feature +HEAD def456 +branch refs/heads/feature/test +` + }, + 'git worktree remove --force /repo-feature': { + error: new Error('git worktree remove failed'), + stderr: 'fatal: working trees containing submodules cannot be moved or removed' + } + }) + + await expect(removeWorktree('/repo', '/repo-feature', true)).rejects.toThrow() + expect( + getGitCalls().filter((call) => call === 'git worktree remove --force /repo-feature') + ).toHaveLength(1) + }) + + it('does not force-retry unrelated non-force remove failures', async () => { + mockGitCommands({ + 'git worktree list --porcelain': { + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main + +worktree /repo-feature +HEAD def456 +branch refs/heads/feature/test +` + }, + 'git worktree remove /repo-feature': { + error: new Error('git worktree remove failed'), + stderr: 'fatal: contains modified or untracked files, use --force to delete it' + } + }) + + await expect(removeWorktree('/repo', '/repo-feature')).rejects.toThrow( + 'git worktree remove failed' + ) + expect(getGitCalls()).not.toContain('git worktree remove --force /repo-feature') + expect(getGitCalls()).not.toContain('git status --porcelain --untracked-files=all') + }) + it('rejects a locked worktree with stable app-owned copy before invoking remove', async () => { mockGitCommands({ 'git worktree list --porcelain': { diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index 8af536a31a6..fd5e16f91c5 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -476,6 +476,37 @@ describe('getDiff', () => { }) }) + it('flags a deleted image so previewers can fall back to the original bytes', async () => { + const pngBuffer = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00]) + gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: pngBuffer }) + statMock.mockRejectedValueOnce(Object.assign(new Error('missing'), { code: 'ENOENT' })) + + const result = await getDiff('/repo', 'assets/deleted.png', false) + + expect(result.kind).toBe('binary') + if (result.kind !== 'binary') { + throw new Error('expected binary diff result') + } + expect(result.modifiedDeleted).toBe(true) + expect(result.originalContent).toBe(pngBuffer.toString('base64')) + expect(result.modifiedContent).toBe('') + }) + + it('does not treat an unreadable working-tree image as a deletion', async () => { + const pngBuffer = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00]) + gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: pngBuffer }) + statMock.mockResolvedValueOnce({ isFile: () => true, size: 5 }) + readFileMock.mockRejectedValueOnce(new Error('EIO')) + + const result = await getDiff('/repo', 'assets/unreadable.png', false) + + expect(result.kind).toBe('binary') + if (result.kind !== 'binary') { + throw new Error('expected binary diff result') + } + expect(result.modifiedDeleted).toBeUndefined() + }) + it('coalesces concurrent identical staged diff reads while in flight', async () => { const leftBlob = deferredBuffer('head-content\n') const rightBlob = deferredBuffer('index-content\n') diff --git a/src/main/git/status.ts b/src/main/git/status.ts index a0c103b744c..ae818b52567 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -1329,6 +1329,7 @@ async function loadDiff( let modifiedContent = '' let originalIsBinary = false let modifiedIsBinary = false + let modifiedDeleted = false try { const leftBlob = staged @@ -1343,22 +1344,30 @@ async function loadDiff( const rightBlob = await readGitBlobAtIndexPath(worktreePath, filePath, options) modifiedContent = rightBlob.content modifiedIsBinary = rightBlob.isBinary + modifiedDeleted = !rightBlob.exists } else { const workingTreeBlob = await readWorkingTreeFile(path.join(worktreePath, filePath)) modifiedContent = workingTreeBlob.content modifiedIsBinary = workingTreeBlob.isBinary + modifiedDeleted = !workingTreeBlob.exists } } catch { // Fallback } - return buildDiffResult( + const result = buildDiffResult( originalContent, modifiedContent, originalIsBinary, modifiedIsBinary, filePath ) + // Why: mark a proven deletion so previewers can fall back to the original bytes + // without mistaking a read failure's empty modified side for a deletion. + if (result.kind === 'binary' && modifiedDeleted) { + return { ...result, modifiedDeleted: true } + } + return result } export async function getBranchCompare( @@ -1859,20 +1868,33 @@ async function readGitBlobAtOidPath( } async function readWorkingTreeFile(filePath: string): Promise { + let fileStat 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 } + fileStat = await stat(filePath) + } catch (error) { + // Why: ENOENT means the working-tree file is genuinely gone (a deletion); + // any other stat error is a read failure, which must not be reported as an + // absence since callers fall back to the original bytes only for deletions. + return { + content: '', + isBinary: false, + exists: (error as NodeJS.ErrnoException)?.code !== 'ENOENT' } + } + 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 } + } + try { const buffer = await readFile(filePath) return bufferToBlob(buffer, filePath) } catch { - return { content: '', isBinary: false, exists: false } + // Why: the file exists but could not be read — a read failure, not a deletion. + return { content: '', isBinary: false, exists: true } } } diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index 8823c683b60..84ce3963192 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -14,6 +14,7 @@ import type { RemoveWorktreeResult } from '../../shared/types' import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree-removal' +import { isSubmoduleWorktreeRemovalRefusal } from '../../shared/worktree-submodule-removal' import { decodeGitCQuotedPath } from '../../shared/git-cquoted-path' import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-output' import { parseWslUncPath } from '../../shared/wsl-paths' @@ -1221,7 +1222,21 @@ async function performRemoveWorktree( args.push('--force') } args.push(worktreePath) - await gitExecFileAsync(args, gitExecOptions(repoPath, options)) + try { + await gitExecFileAsync(args, gitExecOptions(repoPath, options)) + } catch (error) { + if (force || !isSubmoduleWorktreeRemovalRefusal(error)) { + throw error + } + // Why: Git refuses non-force removal of any worktree with an initialised + // submodule even when everything is clean. Re-prove cleanliness (parent + // status reports dirty submodule content as ` M `), then --force. + await assertWorktreeCleanForRemoval(worktreePath, false, options) + await gitExecFileAsync( + ['worktree', 'remove', '--force', worktreePath], + gitExecOptions(repoPath, options) + ) + } if (!branchName) { return {} diff --git a/src/main/gitea/client.test.ts b/src/main/gitea/client.test.ts index bf1fe13ca72..a0ae0b3ce4f 100644 --- a/src/main/gitea/client.test.ts +++ b/src/main/gitea/client.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { cancelTrackingResponse } from '../lib/unread-response-body.test-fixtures' const { gitExecFileAsyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn() @@ -301,4 +302,19 @@ describe('Gitea client', () => { }) expect(String(fetchMock.mock.calls[0]?.[0])).toBe('https://git.example.com/api/v1/user') }) + + it('cancels unread error-response bodies so bundled undici cannot crash on socket close', async () => { + let cancelledBodies = 0 + const fetchMock = vi.fn(async () => + cancelTrackingResponse(502, () => { + cancelledBodies += 1 + }) + ) + vi.stubGlobal('fetch', fetchMock) + + await getGiteaPullRequestForBranch('/repo', 'refs/heads/feature/gitea') + + expect(fetchMock).toHaveBeenCalled() + expect(cancelledBodies).toBe(fetchMock.mock.calls.length) + }) }) diff --git a/src/main/gitea/client.ts b/src/main/gitea/client.ts index 30545f83ad7..9889598b612 100644 --- a/src/main/gitea/client.ts +++ b/src/main/gitea/client.ts @@ -11,6 +11,7 @@ import { getHostedReviewLocalGitOptions, type HostedReviewExecutionOptions } from '../source-control/hosted-review-git-options' +import { cancelUnreadResponseBody } from '../lib/unread-response-body' const REQUEST_TIMEOUT_MS = 5000 // Why: self-hosted Forgejo can take ~5s to serve one /pulls page (it loads @@ -89,6 +90,7 @@ async function requestJsonAtBase( signal: AbortSignal.timeout(options.timeoutMs ?? REQUEST_TIMEOUT_MS) }) if (!response.ok) { + await cancelUnreadResponseBody(response) return null } return (await response.json()) as T diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 54572bd406f..ac85669836a 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -485,16 +485,32 @@ function mapIssueWorkItem(item: Record): MainWorkItem { .filter(Boolean) : [], updatedAt: String(item.updated_at ?? item.updatedAt ?? ''), - author: - typeof item.user === 'object' && item.user !== null && 'login' in item.user - ? String((item.user as { login?: unknown }).login ?? '') - : typeof item.author === 'object' && item.author !== null && 'login' in item.author - ? String((item.author as { login?: unknown }).login ?? '') - : null, + ...authorFieldsFromUnknown(item), ...(item.assignees !== undefined ? { assignees: usersFromUnknown(item.assignees) } : {}) } } +/** + * Derive both the author login and its API avatar_url in one place so GHE + * avatars render (the login-only `github.com/{login}.png` URL 404s on GHE). + * + * REST exposes `user.avatar_url`; gh/GraphQL expose `author.avatarUrl`. `gh pr + * view` omits the avatar entirely, so authorAvatarUrl is left undefined and the + * UI falls back to the login URL then a placeholder. See #8784. + */ +function authorFieldsFromUnknown( + item: Record +): Pick { + const user = userFromUnknown(item.user ?? item.author) + if (!user) { + return { author: null } + } + return { + author: user.login, + ...(user.avatarUrl ? { authorAvatarUrl: user.avatarUrl } : {}) + } +} + function extractHeadOwnerLogin(item: Record): string | null { // gh CLI `pr list --json headRepositoryOwner` shape: { login } if (typeof item.headRepositoryOwner === 'object' && item.headRepositoryOwner !== null) { @@ -746,12 +762,7 @@ function mapPullRequestWorkItem( .filter(Boolean) : [], updatedAt: String(item.updated_at ?? item.updatedAt ?? ''), - author: - typeof item.user === 'object' && item.user !== null && 'login' in item.user - ? String((item.user as { login?: unknown }).login ?? '') - : typeof item.author === 'object' && item.author !== null && 'login' in item.author - ? String((item.author as { login?: unknown }).login ?? '') - : null, + ...authorFieldsFromUnknown(item), branchName: typeof item.head === 'object' && item.head !== null && 'ref' in item.head ? String((item.head as { ref?: unknown }).ref ?? '') diff --git a/src/main/github/work-item-details.test.ts b/src/main/github/work-item-details.test.ts index 6d3d14c99b7..4fd21131c23 100644 --- a/src/main/github/work-item-details.test.ts +++ b/src/main/github/work-item-details.test.ts @@ -216,6 +216,56 @@ describe('getWorkItemDetails', () => { expect(details?.participants?.[0]?.login).toBe('issue-author') }) + it('enriches a non-participating assignee avatar from the GraphQL assignees connection', async () => { + // Why: a GHE assignee who never commented is absent from `participants`, so + // the enrichment must also draw avatars from the `assignees` connection or + // item.assignees keeps the blank avatar `gh` returns. + getWorkItemMock.mockResolvedValueOnce({ + id: 'issue:924', + type: 'issue', + number: 924, + title: 'Assignee avatar', + state: 'open', + url: 'https://github.com/acme/widgets/issues/924', + labels: [], + updatedAt: '2026-04-01T00:00:00Z', + author: 'issue-author', + assignees: [{ login: 'ghe-assignee', name: 'GHE Assignee', avatarUrl: '' }] + }) + getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + data: { + repository: { + issue: { + body: 'Issue body', + assignees: { + nodes: [ + { + login: 'ghe-assignee', + name: 'GHE Assignee', + avatarUrl: 'https://ghe.example.com/avatars/ghe-assignee' + } + ] + }, + participants: { nodes: [{ login: 'issue-author', avatarUrl: 'https://x/y' }] }, + comments: { nodes: [] } + } + } + } + }) + }) + .mockResolvedValueOnce({ stdout: '' }) + + const details = await getWorkItemDetails('/repo-root', 924, 'issue') + + expect(details?.item.assignees?.[0]).toMatchObject({ + login: 'ghe-assignee', + avatarUrl: 'https://ghe.example.com/avatars/ghe-assignee' + }) + }) + it('caps issue timeline pagination by supported activity items', async () => { getWorkItemMock.mockResolvedValueOnce({ id: 'issue:923', @@ -568,4 +618,66 @@ describe('getWorkItemDetails', () => { expect(details?.filesUnavailable).toBe(false) expect(details?.files).toEqual([]) }) + + // Why: `gh pr view` omits avatar_url, so the login-based github.com URL 404s on + // GHE. getWorkItemDetails must resolve author/reviewer/assignee avatars via the + // GraphQL user(login:) batch and stamp them onto the returned item. See #8784. + it('enriches PR author, reviewer, and assignee avatars from the GraphQL user lookup', async () => { + getWorkItemMock.mockResolvedValueOnce({ + id: 'pr:1102', + type: 'pr', + number: 1102, + title: 'Enterprise PR', + state: 'open', + url: 'https://ghe.example.com/acme/widgets/pull/1102', + labels: [], + updatedAt: '2026-07-11T00:00:00Z', + author: 'seah', + reviewRequests: [{ login: 'ludi', name: null, avatarUrl: '' }], + latestReviews: [{ login: 'inho', state: 'APPROVED', avatarUrl: '' }], + // A default `u/0` placeholder must be replaced by the resolved avatar. + assignees: [{ login: 'seah', name: 'Seah', avatarUrl: 'https://avatars.example.com/u/0?v=4' }] + }) + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + getPRCommentsMock.mockResolvedValue([]) + getPRChecksMock.mockResolvedValue([]) + const avatars: Record = { + seah: 'https://avatars.example.com/u/1?v=4', + ludi: 'https://avatars.example.com/u/2?v=4', + inho: 'https://avatars.example.com/u/3?v=4' + } + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const target = args.at(-1) + if (target === 'repos/acme/widgets/pulls/1102') { + return { + stdout: JSON.stringify({ body: 'PR body', head: { sha: 'h' }, base: { sha: 'b' } }) + } + } + if (target === 'repos/acme/widgets/pulls/1102/files?per_page=100') { + return { stdout: '[]' } + } + const query = args.find((arg) => arg.startsWith('query=')) ?? '' + if (query.includes('user(login:')) { + // Return the aliased users the batch asked for, keyed by their login. + const data: Record = {} + let index = 0 + for (const login of Object.keys(avatars)) { + if (query.includes(`user(login: "${login}")`)) { + data[`u${index}`] = { login, name: null, avatarUrl: avatars[login] } + index += 1 + } + } + return { stdout: JSON.stringify({ data }) } + } + return { stdout: JSON.stringify({ data: {} }) } + }) + + const details = await getWorkItemDetails('/repo-root', 1102, 'pr') + + expect(details?.item.authorAvatarUrl).toBe(avatars.seah) + expect(details?.item.reviewRequests?.[0]?.avatarUrl).toBe(avatars.ludi) + expect(details?.item.latestReviews?.[0]?.avatarUrl).toBe(avatars.inho) + // The default u/0 placeholder is overridden by the resolved avatar. + expect(details?.item.assignees?.[0]?.avatarUrl).toBe(avatars.seah) + }) }) diff --git a/src/main/github/work-item-details.ts b/src/main/github/work-item-details.ts index 9aa2de2b26b..bd126641fcb 100644 --- a/src/main/github/work-item-details.ts +++ b/src/main/github/work-item-details.ts @@ -85,7 +85,7 @@ const ISSUE_DETAILS_QUERY = `query($owner: String!, $repo: String!, $number: Int repository(owner: $owner, name: $repo) { issue(number: $number) { body - assignees(first: 50) { nodes { login } } + assignees(first: 50) { nodes { login avatarUrl(size: 48) ... on User { name } } } participants(first: 100) { nodes { login avatarUrl(size: 48) ... on User { name } } } @@ -111,7 +111,7 @@ type GraphQLIssueDetailsResponse = { repository?: { issue?: { body?: string | null - assignees?: { nodes?: { login?: string }[] } + assignees?: { nodes?: { login?: string; avatarUrl?: string; name?: string | null }[] } participants?: { nodes?: GitHubAssignableUser[] } comments?: { nodes?: { @@ -323,6 +323,12 @@ async function getIssueTimelineItems( } } +/** + * Fetch an issue's body, comments, assignees, participants, and timeline in a + * single GraphQL round-trip. Returns null on any partial error so the caller + * falls back to the strict REST path. Assignee/participant avatars are resolved + * here so GitHub Enterprise users don't render blank. + */ async function getIssueDetailsViaGraphQL( repoPath: string, issueNumber: number, @@ -332,6 +338,10 @@ async function getIssueDetailsViaGraphQL( body: string comments: PRComment[] assignees: string[] + // Avatar-bearing assignees, kept separate from the `assignees` login list so + // callers that only need logins are unaffected; used to enrich assignee + // avatars that `gh` leaves blank (notably GitHub Enterprise users). + assigneeUsers: GitHubAssignableUser[] participants: GitHubAssignableUser[] timelineItems: GitHubIssueTimelineItem[] } | null> { @@ -386,9 +396,16 @@ async function getIssueDetailsViaGraphQL( url: c.url ?? '', isBot: c.author?.__typename === 'Bot' })) - const assignees = (issue.assignees?.nodes ?? []) - .map((a) => a.login) - .filter((login): login is string => Boolean(login)) + const assigneeUsers: GitHubAssignableUser[] = (issue.assignees?.nodes ?? []) + .filter((a): a is { login: string; avatarUrl?: string; name?: string | null } => + Boolean(a.login) + ) + .map((a) => ({ + login: a.login, + name: a.name ?? null, + avatarUrl: a.avatarUrl ?? '' + })) + const assignees = assigneeUsers.map((a) => a.login) const participants: GitHubAssignableUser[] = (issue.participants?.nodes ?? []) .filter((u) => Boolean(u.login)) .map((u) => ({ @@ -401,6 +418,7 @@ async function getIssueDetailsViaGraphQL( body: issue.body ?? '', comments, assignees, + assigneeUsers, participants, timelineItems } @@ -877,6 +895,11 @@ async function getGitHubUsersByLogin( return [] } if (rateLimitGuard('graphql').blocked) { + // Why: surface the skip. Callers degrade silently to login-based avatar URLs + // (blank on GHE), so without this log "why are avatars missing" is untraceable. + console.warn( + `getGitHubUsersByLogin skipped: GraphQL rate-limit budget exhausted (${uniqueLogins.length} logins unresolved)` + ) return [] } const fields = uniqueLogins @@ -921,19 +944,100 @@ async function getGitHubUsersByLogin( } } +/** + * Stamp GraphQL-resolved avatars onto a work item's author, reviewers, review + * requests, latest reviews, and assignees. + * + * The work item's users come from `gh pr view`, which omits avatar_url. On + * GitHub Enterprise the login-based `github.com/{login}.png` URL 404s, so those + * avatars render blank while comment avatars (fetched via GraphQL) work. + * `knownUsers` (participants + the mention-author batch, which already includes + * the item's reviewers/assignees) carries the resolved avatars, so we apply + * them without any extra round-trip. See #8784. + */ +function enrichItemDisplayAvatars( + item: Omit, + knownUsers: GitHubAssignableUser[] +): Omit { + const avatarByLogin = new Map() + for (const user of knownUsers) { + if (user.login && user.avatarUrl) { + avatarByLogin.set(user.login.toLowerCase(), user.avatarUrl) + } + } + if (avatarByLogin.size === 0) { + return item + } + // Why: prefer the GraphQL-resolved avatar over whatever `gh` returned. `gh pr + // view` often yields an empty avatar or the default `u/0` placeholder for + // enterprise users; the resolved avatar is the authoritative one for that + // login. Fall back to the original only when the lookup found nothing. + const avatarFor = (login: string): string | undefined => avatarByLogin.get(login.toLowerCase()) + const resolvedAvatar = (login: string, existing?: string | null): string | undefined => + avatarFor(login) || existing || undefined + // Callers coalesce a missing result to the field's "no avatar" sentinel ('' + // for GitHubAssignableUser, null for GitHubPRReviewSummary); GitHubUserAvatar + // treats both as falsy and falls back to the login URL then initials. + const authorAvatarUrl = (item.author ? avatarFor(item.author) : undefined) || item.authorAvatarUrl + return { + ...item, + ...(authorAvatarUrl ? { authorAvatarUrl } : {}), + ...(item.reviewRequests + ? { + reviewRequests: item.reviewRequests.map((user) => ({ + ...user, + avatarUrl: resolvedAvatar(user.login, user.avatarUrl) ?? '' + })) + } + : {}), + ...(item.latestReviews + ? { + latestReviews: item.latestReviews.map((review) => ({ + ...review, + avatarUrl: resolvedAvatar(review.login, review.avatarUrl) ?? null + })) + } + : {}), + ...(item.assignees + ? { + assignees: item.assignees.map((user) => ({ + ...user, + avatarUrl: resolvedAvatar(user.login, user.avatarUrl) ?? '' + })) + } + : {}) + } +} + async function getMentionParticipants( repoPath: string, - item: Pick, + item: Pick< + GitHubWorkItem, + 'author' | 'number' | 'type' | 'reviewRequests' | 'latestReviews' | 'assignees' + >, comments: PRComment[], participants: GitHubAssignableUser[], connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const visibleLogins = [item.author ?? '', ...comments.map((comment) => comment.author)] - // Why: one aliased GraphQL query returns login/name/avatarUrl for every - // mentioned author in a single round-trip. The previous REST fan-out - // (/users/) returned the same fields but cost one rate-limit point - // per user. + // Why: resolve mention authors AND the item's reviewers/assignees in ONE + // aliased GraphQL round-trip. Folding the display users in here means the + // caller can reuse this result to stamp avatars (see enrichItemDisplayAvatars) + // without a second, separately rate-limited lookup — which is what made GHE + // reviewer/assignee avatars resolve intermittently. See #8784. + // Why: order the always-visible display users (author, reviewers, assignees) + // before comment authors so the 40-login cap in getGitHubUsersByLogin never + // drops a reviewer/assignee avatar on a PR with many commenters. On a PR with + // 40+ distinct participants the trailing comment authors are dropped from this + // batch and lose only their @mention-suggestion avatar; rendered comment + // avatars are unaffected (they carry their own avatarUrl from the comment). + const visibleLogins = [ + item.author ?? '', + ...(item.reviewRequests ?? []).map((user) => user.login), + ...(item.latestReviews ?? []).map((review) => review.login), + ...(item.assignees ?? []).map((user) => user.login), + ...comments.map((comment) => comment.author) + ] const graphQlUsers = await getGitHubUsersByLogin( repoPath, visibleLogins, @@ -1007,7 +1111,13 @@ export async function getWorkItemDetails( ) if (collapsed) { return { - item, + // Include assigneeUsers: a non-participating assignee is absent from + // the participants connection, so without them item.assignees keeps a + // blank/placeholder avatar (common for GitHub Enterprise users). + item: enrichItemDisplayAvatars(item, [ + ...collapsed.participants, + ...collapsed.assigneeUsers + ]), body: collapsed.body, comments: collapsed.comments, assignees: collapsed.assignees, @@ -1030,7 +1140,7 @@ export async function getWorkItemDetails( localGitOptions ) return { - item, + item: enrichItemDisplayAvatars(item, mentionParticipants), body, comments, assignees, @@ -1064,7 +1174,7 @@ export async function getWorkItemDetails( ]) return { - item, + item: enrichItemDisplayAvatars(item, mentionParticipants), body, comments, headSha: shas?.headSha, diff --git a/src/main/global-fetch-call-site-audit.test.ts b/src/main/global-fetch-call-site-audit.test.ts new file mode 100644 index 00000000000..52049b16d59 --- /dev/null +++ b/src/main/global-fetch-call-site-audit.test.ts @@ -0,0 +1,97 @@ +import { readdirSync, readFileSync } from 'node:fs' +import { join, relative, sep } from 'node:path' +import { describe, expect, it } from 'vitest' + +// Global fetch (bare or via globalThis/global — unlike Electron's net.fetch) +// goes through Node's bundled undici, which can crash the whole process when +// an unread response body pauses the HTTP/1 parser and the peer closes the +// socket (nodejs/undici#5360, orca#8695). This applies to every Node process +// we ship: Electron main, the CLI, and the SSH relay. +// +// Each entry below maps an audited file to its expected number of matching +// lines. Real call sites must consume or cancel the body on every path, +// including !response.ok (see main/lib/unread-response-body.ts). A count +// change means a call site was added, removed, or moved: re-audit the file +// and update the count. +const AUDITED_GLOBAL_FETCH_LINES = new Map([ + // HTTP call sites — body consumed or cancelled on every path, including !ok + ['main/azure-devops/azure-devops-api-request.ts', 1], + ['main/bitbucket/client.ts', 1], + ['main/gitea/client.ts', 1], + ['main/orca-profiles/profile-cloud-client.ts', 1], + ['main/orca-profiles/profile-cloud-org-members-client.ts', 1], + ['main/rate-limits/codex-fetcher.ts', 3], + ['main/runtime/relay/relay-http-client.ts', 2], + ['main/source-control/hosted-review-api-request.ts', 1], + ['main/speech/openai-transcription-client.ts', 1], + // fetch appears only inside injected-page script source strings, not as a + // call this process makes + ['main/amp/hook-service.ts', 1], + ['main/opencode/hook-service.ts', 1], + ['main/pi/agent-status-extension-source.ts', 1], + // local identifiers named `fetch` (git fetch), not HTTP + ['main/ipc/worktree-remote.ts', 2], + ['relay/git-handler.ts', 1], + // fetch mentioned only in a comment + ['main/ipc/feedback.ts', 1] +]) + +// A line is a hit when it calls bare `fetch(` or touches `globalThis.fetch` / +// `global.fetch` in any way (call, alias, fallback like `input.fetch ?? +// globalThis.fetch`). `typeof globalThis.fetch` type annotations are exempt. +const GLOBAL_FETCH_LINE = /(^|[^.\w])fetch\(|(? { + const counts = new Map() + for (const root of SCANNED_ROOTS) { + for (const entry of readdirSync(join(srcRoot, root), { + recursive: true, + withFileTypes: true + })) { + if (!entry.isFile() || !entry.name.endsWith('.ts')) { + continue + } + if ( + entry.name.endsWith('.test.ts') || + entry.name.endsWith('.test-fixtures.ts') || + entry.name.endsWith('.d.ts') + ) { + continue + } + const filePath = join(entry.parentPath, entry.name) + const content = readFileSync(filePath, 'utf8') + if (!GLOBAL_FETCH_LINE.test(content)) { + continue + } + const hits = content.split('\n').filter((line) => GLOBAL_FETCH_LINE.test(line)).length + if (hits > 0) { + counts.set(relative(srcRoot, filePath).split(sep).join('/'), hits) + } + } + } + return counts +} + +describe('global fetch call-site audit (main, cli, relay)', () => { + it('keeps every global-fetch line audited with its expected count', () => { + const found = globalFetchLineCounts(join(__dirname, '..')) + + const drifted = [...found] + .filter(([file, count]) => AUDITED_GLOBAL_FETCH_LINES.get(file) !== count) + .map(([file, count]) => `${file}: found ${count} line(s)`) + .sort() + expect( + drifted, + 'Global fetch (bare, globalThis.fetch, or global.fetch) uses undici, ' + + 'where an unread response body can crash the whole process (orca#8695). ' + + 'New or moved call sites must either use Electron net.fetch or consume/' + + 'cancel the response body on ALL paths (cancelUnreadResponseBody in ' + + 'main/lib/unread-response-body.ts), then update AUDITED_GLOBAL_FETCH_LINES.' + ).toEqual([]) + + const stale = [...AUDITED_GLOBAL_FETCH_LINES.keys()].filter((file) => !found.has(file)).sort() + expect(stale, 'Remove audited entries whose global-fetch lines are gone.').toEqual([]) + }) +}) diff --git a/src/main/index.ts b/src/main/index.ts index 11278e73a37..c4a8b1ea877 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -171,7 +171,7 @@ import { } from './ipc/pty' import { AgentBrowserBridge } from './browser/agent-browser-bridge' import { EmulatorBridge } from './emulator/emulator-bridge' -import { browserManager } from './browser/browser-manager' +import { browserCertificateTrustController, browserManager } from './browser/browser-manager' import { OffscreenBrowserBackend } from './browser/offscreen-browser-backend' import { initializeBrowserSessionsForApp } from './browser/browser-session-startup' import { setUnreadDockBadgeCount } from './dock/unread-badge' @@ -1739,6 +1739,22 @@ function shouldSuppressCodexAutoApprovalSyntheticTitleFromHook(args: { app.whenReady().then(async () => { logStartupMilestone('app-ready') + // Why: certificate decisions must be installed before either desktop + // webviews or headless browser windows can issue their first TLS request. + app.on( + 'certificate-error', + (event, webContents, url, error, certificate, callback, isMainFrame) => { + browserCertificateTrustController.handleCertificateError({ + event, + webContents, + url, + error, + certificate, + callback, + isMainFrame + }) + } + ) electronApp.setAppUserModelId(devInstanceIdentity.appUserModelId) app.setName(devInstanceIdentity.name) @@ -2007,6 +2023,9 @@ app.whenReady().then(async () => { isAgentStatusHooksEnabled(store?.getSettings()) ? agentHookServer.buildPtyEnv() : {} }) runtime = runtimeService + browserManager.setBrowserGuestStateChangedListener((worktreeId) => { + runtimeService.notifyMobileSessionTabsChanged(worktreeId) + }) automations = new AutomationService(store, { claudeUsage, codexUsage, @@ -2273,7 +2292,15 @@ app.whenReady().then(async () => { enableWebSocket: true, ...(isE2E ? { wsPort: 0 } : {}), ...(devWsPort !== undefined ? { wsPort: devWsPort } : {}), - ...(serveOptions?.wsPort !== undefined ? { wsPort: serveOptions.wsPort } : {}), + ...(serveOptions?.wsPort !== undefined + ? { + wsPort: serveOptions.wsPort, + // Why: only an explicit `orca serve --port` pin prefers the requested + // port over a stale STA-1511 fallback (issue #8535). Default 6768 / + // dev 6769 keep fallback-first so mobile pairings stay stable. + preferPinnedWsPort: true + } + : {}), webClientRoot: getBundledWebClientRoot() }) registerMobileHandlers(runtimeRpc, { getRelayStatus: () => desktopRelayStatus }) @@ -2488,6 +2515,7 @@ app.on('will-quit', (e) => { // Why: headless offscreen browser windows are main-process owned; tear them // down explicitly on quit alongside the other browser/session shutdowns. runtime?.getOffscreenBrowserBackend()?.destroyAll?.() + browserManager.setBrowserGuestStateChangedListener(null) const emulatorShutdown = runtime?.getEmulatorBridge()?.destroyAllSessions() ?? Promise.resolve() killAllPty() const watcherShutdown = shutdownWatchersOnce() diff --git a/src/main/ipc/browser.test.ts b/src/main/ipc/browser.test.ts index 042140cd38b..7651d130651 100644 --- a/src/main/ipc/browser.test.ts +++ b/src/main/ipc/browser.test.ts @@ -11,6 +11,7 @@ const { openDevToolsMock, setAnnotationViewportBridgeMock, cancelDownloadMock, + proceedCertificateMock, browserWindowFromWebContentsMock, webContentsFromIdMock } = vi.hoisted(() => ({ @@ -24,6 +25,7 @@ const { openDevToolsMock: vi.fn().mockResolvedValue(true), setAnnotationViewportBridgeMock: vi.fn().mockResolvedValue(true), cancelDownloadMock: vi.fn(), + proceedCertificateMock: vi.fn(), browserWindowFromWebContentsMock: vi.fn(), webContentsFromIdMock: vi.fn() })) @@ -42,6 +44,9 @@ vi.mock('electron', () => ({ })) vi.mock('../browser/browser-manager', () => ({ + browserCertificateTrustController: { + proceed: proceedCertificateMock + }, browserManager: { registerGuest: registerGuestMock, unregisterGuest: unregisterGuestMock, @@ -68,6 +73,7 @@ describe('registerBrowserHandlers', () => { removeHandlerMock.mockReset() handleMock.mockReset() registerGuestMock.mockReset() + registerGuestMock.mockReturnValue(true) unregisterGuestMock.mockReset() getGuestWebContentsIdMock.mockReset() getWebContentsIdByTabIdMock.mockReset() @@ -76,6 +82,8 @@ describe('registerBrowserHandlers', () => { openDevToolsMock.mockReset() setAnnotationViewportBridgeMock.mockReset() cancelDownloadMock.mockReset() + proceedCertificateMock.mockReset() + proceedCertificateMock.mockReturnValue({ ok: true }) browserWindowFromWebContentsMock.mockReset() webContentsFromIdMock.mockReset() webContentsFromIdMock.mockReturnValue({ isDestroyed: () => false }) @@ -110,6 +118,41 @@ describe('registerBrowserHandlers', () => { expect(registerGuestMock).not.toHaveBeenCalled() }) + it('does not resolve registration waiters when BrowserManager rejects the guest', async () => { + vi.useFakeTimers() + try { + registerGuestMock.mockReturnValue(false) + const settled = Promise.allSettled([waitForTabRegistration('page-1', 1000)]) + registerBrowserHandlers() + const registerHandler = handleMock.mock.calls.find( + ([channel]) => channel === 'browser:registerGuest' + )?.[1] as (event: { sender: Electron.WebContents }, args: object) => 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(false) + await vi.advanceTimersByTimeAsync(1001) + expect(await settled).toEqual([{ status: 'rejected', reason: expect.any(Error) }]) + } finally { + vi.useRealTimers() + } + }) + it('authorizes browser download cancellation through the owning renderer', () => { cancelDownloadMock.mockReturnValue(true) registerBrowserHandlers() @@ -157,6 +200,60 @@ describe('registerBrowserHandlers', () => { expect(cancelDownloadMock).not.toHaveBeenCalled() }) + it('allows only the trusted renderer to approve an exact certificate challenge', () => { + registerBrowserHandlers() + const proceedHandler = handleMock.mock.calls.find( + ([channel]) => channel === 'browser:proceedCertificate' + )?.[1] as (event: { sender: Electron.WebContents }, args: unknown) => unknown + const trustedSender = { + id: 91, + isDestroyed: () => false, + getType: () => 'window', + getURL: () => 'file:///renderer/index.html' + } as Electron.WebContents + + expect( + proceedHandler( + { sender: trustedSender }, + { browserPageId: 'page-1', challengeId: 'challenge-1' } + ) + ).toEqual({ ok: true }) + expect(proceedCertificateMock).toHaveBeenCalledWith('page-1', 'challenge-1') + + proceedCertificateMock.mockClear() + const untrustedSender = { + id: 92, + isDestroyed: () => false, + getType: () => 'webview', + getURL: () => 'https://localhost:3443/' + } as Electron.WebContents + expect( + proceedHandler( + { sender: untrustedSender }, + { browserPageId: 'page-1', challengeId: 'challenge-1' } + ) + ).toEqual({ ok: false, reason: 'missing' }) + expect(proceedCertificateMock).not.toHaveBeenCalled() + }) + + it('rejects malformed certificate approval IPC arguments', () => { + registerBrowserHandlers() + const proceedHandler = handleMock.mock.calls.find( + ([channel]) => channel === 'browser:proceedCertificate' + )?.[1] as (event: { sender: Electron.WebContents }, args: unknown) => unknown + const sender = { + id: 91, + isDestroyed: () => false, + getType: () => 'window', + getURL: () => 'file:///renderer/index.html' + } as Electron.WebContents + + for (const args of [null, {}, { browserPageId: 1, challengeId: 'challenge-1' }]) { + expect(proceedHandler({ sender }, args)).toEqual({ ok: false, reason: 'missing' }) + } + expect(proceedCertificateMock).not.toHaveBeenCalled() + }) + it('updates the bridge active tab for the owning worktree', async () => { const onTabChangedMock = vi.fn() getGuestWebContentsIdMock.mockReturnValue(4242) diff --git a/src/main/ipc/browser.ts b/src/main/ipc/browser.ts index 1bc405c9d0a..f5c1c8fb268 100644 --- a/src/main/ipc/browser.ts +++ b/src/main/ipc/browser.ts @@ -1,7 +1,7 @@ /* eslint-disable max-lines -- Why: browser IPC handlers must be registered together so the trust boundary (isTrustedBrowserRenderer) and handler teardown stay consistent. */ import { BrowserWindow, ipcMain, webContents } from 'electron' -import { browserManager } from '../browser/browser-manager' +import { browserCertificateTrustController, browserManager } from '../browser/browser-manager' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' import { browserSessionRegistry } from '../browser/browser-session-registry' import { @@ -24,6 +24,7 @@ import type { } from '../../shared/browser-grab-types' import type { BrowserCookieImportResult, + BrowserCertificateProceedResult, BrowserSessionProfile, BrowserSessionProfileScope, BrowserViewportOverride @@ -180,6 +181,7 @@ export function registerBrowserHandlers(): void { ipcMain.removeHandler('browser:captureSelectionScreenshot') ipcMain.removeHandler('browser:extractHoverPayload') ipcMain.removeHandler('browser:activeTabChanged') + ipcMain.removeHandler('browser:proceedCertificate') ipcMain.handle( 'browser:registerGuest', @@ -201,10 +203,13 @@ export function registerBrowserHandlers(): void { // with a new webContentsId. The bridge must destroy the old session's // proxy (its webContents is gone) and let the next command recreate it. const previousWcId = browserManager.getGuestWebContentsId(args.browserPageId) - browserManager.registerGuest({ + const registered = browserManager.registerGuest({ ...args, rendererWebContentsId: event.sender.id }) + if (!registered) { + return false + } if (agentBrowserBridgeRef && previousWcId !== null && previousWcId !== args.webContentsId) { agentBrowserBridgeRef.onProcessSwap(args.browserPageId, args.webContentsId, previousWcId) } @@ -235,6 +240,23 @@ export function registerBrowserHandlers(): void { return true }) + ipcMain.handle( + 'browser:proceedCertificate', + ( + event, + args: { browserPageId?: unknown; challengeId?: unknown } + ): BrowserCertificateProceedResult => { + if ( + !isTrustedBrowserRenderer(event.sender) || + typeof args?.browserPageId !== 'string' || + typeof args.challengeId !== 'string' + ) { + return { ok: false, reason: 'missing' } + } + return browserCertificateTrustController.proceed(args.browserPageId, args.challengeId) + } + ) + // Why: keeps the bridge's active tab in sync with the renderer's UI state. // Without this, a user switching tabs in the UI would leave the agent operating // on the previous tab, which is confusing. diff --git a/src/main/ipc/pet-bundle.test.ts b/src/main/ipc/pet-bundle.test.ts index f8d4f880f4b..096dd37e853 100644 --- a/src/main/ipc/pet-bundle.test.ts +++ b/src/main/ipc/pet-bundle.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { applyCodexSpriteTimingDefaults } from '../../shared/codex-pet-sprite-defaults' import { CODEX_PET_ANIMATIONS, CODEX_PET_FRAME, @@ -63,6 +64,40 @@ describe('applyCodexPetDefaults', () => { expect(manifest.animations).toEqual(CODEX_PET_ANIMATIONS) }) + it('bakes uniform per-frame durations when a Codex-layout bundle pins an explicit fps', () => { + // The requested fps is baked as durations so it is honored rather than + // overridden by the timed table, and so the sprite never enters the + // no-durations legacy retiming path. + const manifest = applyCodexPetDefaults({ id: 'zippy', displayName: 'Zippy', fps: 4 }) + + expect(manifest.fps).toBe(4) + // 4 fps → 250ms per frame; idle keeps its six frames. + expect(manifest.animations?.idle).toEqual({ + row: 0, + frames: 6, + frameDurationsMs: [250, 250, 250, 250, 250, 250] + }) + }) + + it('keeps an explicit fps=8 bundle out of the legacy retiming path', () => { + // fps=8 matches the Codex default and the legacy geometry, but baking + // durations makes it a non-match so the uniform pacing is preserved. + const manifest = applyCodexPetDefaults({ id: 'octo', displayName: 'Octo', fps: 8 }) + const sprite = { + frameWidth: 192, + frameHeight: 208, + columns: 8, + rows: 9, + sheetWidth: 1536, + sheetHeight: 1872, + fps: 8, + defaultAnimation: 'idle', + animations: manifest.animations + } + expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite) + expect(sprite.animations?.idle.frameDurationsMs).toEqual([125, 125, 125, 125, 125, 125]) + }) + it('does not override explicit Orca bundle sprite metadata', () => { const manifest = applyCodexPetDefaults({ spritesheetPath: 'custom.png', diff --git a/src/main/ipc/pet-bundle.ts b/src/main/ipc/pet-bundle.ts index 1d231ebaa21..1b701a7a105 100644 --- a/src/main/ipc/pet-bundle.ts +++ b/src/main/ipc/pet-bundle.ts @@ -1,4 +1,22 @@ import type { SpriteAnimation } from '../../shared/types' +import { + CODEX_PET_ANIMATIONS, + CODEX_PET_DEFAULT_ANIMATION, + CODEX_PET_DEFAULT_FPS, + CODEX_PET_FRAME, + CODEX_PET_SPRITESHEET_PATH, + codexAnimationsAtUniformFps +} from '../../shared/codex-pet-sprite-defaults' + +// Re-exported so main-side consumers keep their import path. The tables live +// in shared/ so the renderer can reuse the same fingerprint for upgrades. +export { + CODEX_PET_ANIMATIONS, + CODEX_PET_DEFAULT_ANIMATION, + CODEX_PET_DEFAULT_FPS, + CODEX_PET_FRAME, + CODEX_PET_SPRITESHEET_PATH +} export type PetManifestLike = { id?: string @@ -19,23 +37,6 @@ export type ResolvedPetManifest = T spritesheetPath: string } -export const CODEX_PET_SPRITESHEET_PATH = 'spritesheet.webp' -export const CODEX_PET_FRAME = { width: 192, height: 208 } as const -export const CODEX_PET_DEFAULT_ANIMATION = 'idle' -export const CODEX_PET_DEFAULT_FPS = 8 - -export const CODEX_PET_ANIMATIONS: Record = { - idle: { row: 0, frames: 6 }, - 'running-right': { row: 1, frames: 8 }, - 'running-left': { row: 2, frames: 8 }, - waving: { row: 3, frames: 4 }, - jumping: { row: 4, frames: 5 }, - failed: { row: 5, frames: 8 }, - waiting: { row: 6, frames: 6 }, - running: { row: 7, frames: 6 }, - review: { row: 8, frames: 6 } -} - function isCodexPetSpritePath(spritesheetPath: string | undefined): boolean { return spritesheetPath === undefined || /(^|[/\\])spritesheet\.webp$/i.test(spritesheetPath) } @@ -61,7 +62,14 @@ export function applyCodexPetDefaults( frame: manifest.frame ?? CODEX_PET_FRAME, fps: manifest.fps ?? CODEX_PET_DEFAULT_FPS, defaultAnimation: manifest.defaultAnimation ?? CODEX_PET_DEFAULT_ANIMATION, - animations: manifest.animations ?? CODEX_PET_ANIMATIONS + // Why: with no fps, bake Codex's intended uneven pacing; with an explicit + // fps, bake that as uniform durations so it is honored instead of being + // overridden by the timed table (and stays out of the legacy retiming path). + animations: + manifest.animations ?? + (manifest.fps === undefined + ? CODEX_PET_ANIMATIONS + : codexAnimationsAtUniformFps(manifest.fps)) } } diff --git a/src/main/ipc/pet.test.ts b/src/main/ipc/pet.test.ts index 59311df5613..5e857bb1244 100644 --- a/src/main/ipc/pet.test.ts +++ b/src/main/ipc/pet.test.ts @@ -109,4 +109,67 @@ describe('registerPetHandlers', () => { readFile(join(userDataDir, 'sidekicks', 'custom', result.id, 'spritesheet.png')) ).resolves.toEqual(sheetBytes) }) + + function webpVp8x(width: number, height: number): Buffer { + const u24 = (value: number): Buffer => + Buffer.from([value & 0xff, (value >> 8) & 0xff, (value >> 16) & 0xff]) + const payload = Buffer.concat([Buffer.from([0, 0, 0, 0]), u24(width - 1), u24(height - 1)]) + const size = Buffer.alloc(4) + size.writeUInt32LE(payload.byteLength, 0) + const riffSize = Buffer.alloc(4) + riffSize.writeUInt32LE(4 + 8 + payload.byteLength, 0) + return Buffer.concat([ + Buffer.from('RIFF'), + riffSize, + Buffer.from('WEBP'), + Buffer.from('VP8X'), + size, + payload + ]) + } + + async function writeSpriteBundle( + animations: Record + ): Promise { + const bundleDir = join(tempDir, 'durations.codex-pet') + await mkdir(bundleDir, { recursive: true }) + await writeFile( + join(bundleDir, 'pet.json'), + JSON.stringify({ + id: 'durations', + displayName: 'Durations', + spritesheetPath: 'sheet.webp', + frame: { width: 2, height: 2 }, + animations + }) + ) + await writeFile(join(bundleDir, 'sheet.webp'), webpVp8x(4, 2)) + return bundleDir + } + + it('imports a bundle whose animations declare per-frame durations', async () => { + const bundleDir = await writeSpriteBundle({ + idle: { row: 0, frames: 2, frameDurationsMs: [1680, 1920] } + }) + showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: [bundleDir] }) + + const result = (await getHandler('pet:importPetBundle')({ sender: {} })) as CustomPet + + expect(result.sprite?.animations?.idle).toEqual({ + row: 0, + frames: 2, + frameDurationsMs: [1680, 1920] + }) + }) + + it('rejects a bundle whose frame durations do not match the frame count', async () => { + const bundleDir = await writeSpriteBundle({ + idle: { row: 0, frames: 2, frameDurationsMs: [1680] } + }) + showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: [bundleDir] }) + + await expect(getHandler('pet:importPetBundle')({ sender: {} })).rejects.toThrow( + 'declares 1 frame durations but 2 frames' + ) + }) }) diff --git a/src/main/ipc/pet.ts b/src/main/ipc/pet.ts index 2a6c0e13c73..8e4b0d60ea0 100644 --- a/src/main/ipc/pet.ts +++ b/src/main/ipc/pet.ts @@ -112,7 +112,9 @@ const PetManifestSchema = z z.string().min(1).max(64), z.object({ row: z.number().int().min(0).max(256), - frames: z.number().int().positive().max(512) + frames: z.number().int().positive().max(512), + // Why: cap each hold at 60s so a bad manifest can't freeze the overlay. + frameDurationsMs: z.array(z.number().positive().max(60_000)).max(512).optional() }) ) .optional() @@ -394,6 +396,11 @@ export function registerPetHandlers(): void { `Animation "${name}" has ${anim.frames} frames but sheet only has ${columns} columns.` ) } + if (anim.frameDurationsMs && anim.frameDurationsMs.length !== anim.frames) { + throw new Error( + `Animation "${name}" declares ${anim.frameDurationsMs.length} frame durations but ${anim.frames} frames.` + ) + } } if (manifest.defaultAnimation && !manifest.animations[manifest.defaultAnimation]) { throw new Error(`defaultAnimation "${manifest.defaultAnimation}" not in animations.`) diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 44405a17d15..733e2f74e68 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -6607,7 +6607,7 @@ describe('registerPtyHandlers', () => { expect(spawnMock).toHaveBeenCalledWith( 'C:\\Program Files\\Git\\bin\\bash.exe', - ['--login', '-i'], + ['-c', 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i'], expect.objectContaining({ env: expect.objectContaining({ CHERE_INVOKING: '1' }) }) diff --git a/src/main/ipc/skills.test.ts b/src/main/ipc/skills.test.ts index bc390b2beb7..91c449f751f 100644 --- a/src/main/ipc/skills.test.ts +++ b/src/main/ipc/skills.test.ts @@ -15,6 +15,9 @@ const { })) vi.mock('electron', () => ({ + app: { + getVersion: () => '9.9.9-test' + }, ipcMain: { handle: handleMock } @@ -168,7 +171,10 @@ describe('registerSkillsHandlers', () => { await handler(null) - expect(inventorySkillFreshnessMock).toHaveBeenCalledWith({ repos }) + expect(inventorySkillFreshnessMock).toHaveBeenCalledWith({ + currentAppVersion: '9.9.9-test', + repos + }) expect(getWslHomeMock).not.toHaveBeenCalled() }) }) diff --git a/src/main/ipc/skills.ts b/src/main/ipc/skills.ts index d210123a744..711162927fe 100644 --- a/src/main/ipc/skills.ts +++ b/src/main/ipc/skills.ts @@ -1,4 +1,4 @@ -import { ipcMain } from 'electron' +import { app, ipcMain } from 'electron' import type { Store } from '../persistence' import { discoverSkills } from '../skills/discovery' import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../../shared/skills' @@ -61,6 +61,9 @@ export function registerSkillsHandlers(store: Store): void { ipcMain.handle('skills:freshnessInventory', async (): Promise => { // Why: the update command targets this machine's global homes. WSL and SSH // inventories stay out until their installer rail has an equivalent proof. - return inventorySkillFreshness({ repos: store.getRepos() }) + return inventorySkillFreshness({ + currentAppVersion: app.getVersion(), + repos: store.getRepos() + }) }) } diff --git a/src/main/ipc/worktree-git-common-watch.test.ts b/src/main/ipc/worktree-git-common-watch.test.ts new file mode 100644 index 00000000000..9cc70f1d7e0 --- /dev/null +++ b/src/main/ipc/worktree-git-common-watch.test.ts @@ -0,0 +1,189 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdtemp, mkdir, realpath, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { subscribeViaWatcherProcess } from './parcel-watcher-process' +import type { + WatcherProcessCallback, + WatcherProcessHooks +} from './parcel-watcher-process-subscription' +import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' +import type { WorktreeBasePollEvent } from './worktree-base-directory-poller' +import { startGitCommonWatch } from './worktree-git-common-watch' + +vi.mock('./parcel-watcher-process', () => ({ + subscribeViaWatcherProcess: vi.fn() +})) + +const POLL_MS = 25 + +type ChildSubscription = { + dir: string + callback: WatcherProcessCallback + hooks: WatcherProcessHooks + unsubscribe: ReturnType +} + +describe('worktree git-common narrow watch (darwin)', () => { + const cleanups: (() => Promise)[] = [] + const subscribeMock = vi.mocked(subscribeViaWatcherProcess) + let childSubscriptions: ChildSubscription[] = [] + + afterEach(async () => { + await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())) + childSubscriptions = [] + subscribeMock.mockReset() + }) + + function installSubscribeMock(): void { + subscribeMock.mockImplementation(async (dir, callback, _opts, hooks = {}) => { + const unsubscribe = vi.fn(async () => {}) + childSubscriptions.push({ dir, callback, hooks, unsubscribe }) + return { unsubscribe } + }) + } + + async function makeCommonDir(withWorktrees: boolean): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-git-common-watch-')) + cleanups.push(() => rm(root, { recursive: true, force: true })) + const commonDir = await realpath(root) + if (withWorktrees) { + await mkdir(join(commonDir, 'worktrees')) + } + return commonDir + } + + function makeTarget(path: string): WorktreeBaseWatchTarget { + return { + key: `git-common:local:${path}`, + kind: 'git-common', + path, + repos: new Map([['repo-1', { repoId: 'repo-1', repoName: 'project', nestWorkspaces: false }]]) + } + } + + async function startWatch(commonDir: string, received: WorktreeBasePollEvent[][]): Promise { + const watch = await startGitCommonWatch( + makeTarget(commonDir), + (events) => received.push(events), + POLL_MS, + 'darwin' + ) + cleanups.push(() => watch.unsubscribe()) + } + + it('hosts the narrow stream in the watcher child, not in-process', async () => { + installSubscribeMock() + const commonDir = await makeCommonDir(true) + const received: WorktreeBasePollEvent[][] = [] + await startWatch(commonDir, received) + + expect(subscribeMock).toHaveBeenCalledTimes(1) + const [dir, , opts] = subscribeMock.mock.calls[0] + expect(dir).toBe(join(commonDir, 'worktrees')) + expect(opts).toEqual({}) + + const entryPath = join(commonDir, 'worktrees', 'wt-a') + childSubscriptions[0].callback(null, [{ type: 'create', path: entryPath }]) + expect(received.flat()).toContainEqual({ type: 'create', path: entryPath }) + }) + + it('tears down and re-arms when the watched root is deleted', async () => { + installSubscribeMock() + const commonDir = await makeCommonDir(true) + const worktreesDir = join(commonDir, 'worktrees') + const received: WorktreeBasePollEvent[][] = [] + await startWatch(commonDir, received) + + await rm(worktreesDir, { recursive: true, force: true }) + childSubscriptions[0].callback(null, [{ type: 'delete', path: worktreesDir }]) + await vi.waitFor(() => { + expect(childSubscriptions[0].unsubscribe).toHaveBeenCalledTimes(1) + }) + expect(received.flat()).toContainEqual({ type: 'delete', path: worktreesDir }) + + // The existence poll re-subscribes once a new first worktree recreates it. + await mkdir(worktreesDir) + await vi.waitFor(() => { + expect(subscribeMock).toHaveBeenCalledTimes(2) + }) + expect(received.flat()).toContainEqual({ type: 'create', path: worktreesDir }) + }) + + it('tears down and re-arms on watcher errors', async () => { + installSubscribeMock() + const commonDir = await makeCommonDir(true) + const worktreesDir = join(commonDir, 'worktrees') + const received: WorktreeBasePollEvent[][] = [] + await startWatch(commonDir, received) + + childSubscriptions[0].callback(new Error('watcher child reported failure'), []) + await vi.waitFor(() => { + expect(childSubscriptions[0].unsubscribe).toHaveBeenCalledTimes(1) + }) + // The error is surfaced as a structural change so worktrees re-sync. + expect(received.flat()).toContainEqual({ type: 'update', path: worktreesDir }) + + // The dir still exists, so the existence poll re-subscribes. + await vi.waitFor(() => { + expect(subscribeMock).toHaveBeenCalledTimes(2) + }) + + const receivedAfterRearm = received.length + childSubscriptions[0].callback(new Error('late error from replaced watcher'), []) + childSubscriptions[0].callback(null, [ + { type: 'create', path: join(worktreesDir, 'late-old-event') } + ]) + childSubscriptions[0].hooks.onInterruption?.() + + // A replaced watch cannot tear down its successor or report stale events. + expect(received).toHaveLength(receivedAfterRearm) + expect(childSubscriptions[1].unsubscribe).not.toHaveBeenCalled() + }) + + it('reports a structural change after a watcher-child interruption', async () => { + installSubscribeMock() + const commonDir = await makeCommonDir(true) + const worktreesDir = join(commonDir, 'worktrees') + const received: WorktreeBasePollEvent[][] = [] + await startWatch(commonDir, received) + + childSubscriptions[0].hooks.onInterruption?.() + expect(received.flat()).toContainEqual({ type: 'update', path: worktreesDir }) + // The supervisor resubscribed the same record; no teardown should happen. + expect(childSubscriptions[0].unsubscribe).not.toHaveBeenCalled() + }) + + it('arms via existence polling when the worktrees dir appears later', async () => { + installSubscribeMock() + const commonDir = await makeCommonDir(false) + const received: WorktreeBasePollEvent[][] = [] + await startWatch(commonDir, received) + expect(subscribeMock).not.toHaveBeenCalled() + + await mkdir(join(commonDir, 'worktrees')) + await vi.waitFor(() => { + expect(subscribeMock).toHaveBeenCalledTimes(1) + }) + }) + + it('stops forwarding events and unsubscribes the child on dispose', async () => { + installSubscribeMock() + const commonDir = await makeCommonDir(true) + const received: WorktreeBasePollEvent[][] = [] + const watch = await startGitCommonWatch( + makeTarget(commonDir), + (events) => received.push(events), + POLL_MS, + 'darwin' + ) + await watch.unsubscribe() + expect(childSubscriptions[0].unsubscribe).toHaveBeenCalledTimes(1) + + received.length = 0 + childSubscriptions[0].callback(null, [ + { type: 'create', path: join(commonDir, 'worktrees', 'late') } + ]) + expect(received).toHaveLength(0) + }) +}) diff --git a/src/main/ipc/worktree-git-common-watch.ts b/src/main/ipc/worktree-git-common-watch.ts index 39899b1e149..705963ed726 100644 --- a/src/main/ipc/worktree-git-common-watch.ts +++ b/src/main/ipc/worktree-git-common-watch.ts @@ -1,5 +1,6 @@ import { stat } from 'node:fs/promises' import { join } from 'node:path' +import { subscribeViaWatcherProcess } from './parcel-watcher-process' import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' import type { WorktreeBasePollEvent, @@ -20,6 +21,10 @@ import { // included). Other platforms: dir-listing poll (no fseventsd to protect, and // on Windows an open directory handle on `worktrees/` could interfere with // `git worktree prune` removing it). +// The native stream is hosted in the crash-isolated watcher child, never the +// Electron main process: watcher.node teardown races heap-corrupt the hosting +// process when unsubscribe overlaps in-flight callbacks (issue #8732), and +// root deletion via `git worktree prune` makes that overlap routine here. // Why: branch switches and commits made in the primary checkout rewrite these // top-level files (linked-worktree equivalents live under `worktrees/`). @@ -157,12 +162,14 @@ async function startGitCommonNarrowWatch( return false } let errored = false + let active = true // Why: parcel tears its native stream down when the watched root is // deleted (e.g. `git worktree prune` removing an empty worktrees dir) — // sometimes surfaced as an error, sometimes as a delete event for the // root. Either way: notify, drop the dead stream, and let the existence // poll re-arm when a future worktree add recreates the dir. const teardownAndRearm = (): void => { + active = false errored = true const current = subscription subscription = null @@ -172,26 +179,38 @@ async function startGitCommonNarrowWatch( armExistencePoll() } try { - const watcher = await import('@parcel/watcher') - const sub = await watcher.subscribe(worktreesDir, (error, events) => { - if (disposed) { - return - } - if (error) { - onEvents([{ type: 'update', path: worktreesDir }]) - teardownAndRearm() - return - } - if (events.length > 0) { - const rootGone = events.some( - (event) => event.type === 'delete' && event.path === worktreesDir - ) - onEvents(events.map((event) => ({ type: event.type, path: event.path }))) - if (rootGone) { + const sub = await subscribeViaWatcherProcess( + worktreesDir, + (error, events) => { + if (disposed || !active) { + return + } + if (error) { + onEvents([{ type: 'update', path: worktreesDir }]) teardownAndRearm() + return + } + if (events.length > 0) { + const rootGone = events.some( + (event) => event.type === 'delete' && event.path === worktreesDir + ) + onEvents(events.map((event) => ({ type: event.type, path: event.path }))) + if (rootGone) { + teardownAndRearm() + } + } + }, + {}, + { + // Why: a watcher-child crash drops events during the automatic + // resubscribe gap; report a structural change so worktrees re-sync. + onInterruption: () => { + if (!disposed && active) { + onEvents([{ type: 'update', path: worktreesDir }]) + } } } - }) + ) if (disposed || errored) { void sub.unsubscribe().catch(() => {}) return !errored diff --git a/src/main/lib/unread-response-body.test-fixtures.ts b/src/main/lib/unread-response-body.test-fixtures.ts new file mode 100644 index 00000000000..c80ece2b374 --- /dev/null +++ b/src/main/lib/unread-response-body.test-fixtures.ts @@ -0,0 +1,13 @@ +/** Response whose body reports cancellation, for asserting that error paths + * cancel unread bodies (see unread-response-body.ts). */ +export function cancelTrackingResponse(status: number, onCancel: () => void): Response { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('unread error body')) + }, + cancel() { + onCancel() + } + }) + return new Response(body, { status }) +} diff --git a/src/main/lib/unread-response-body.test.ts b/src/main/lib/unread-response-body.test.ts new file mode 100644 index 00000000000..5138de950b3 --- /dev/null +++ b/src/main/lib/unread-response-body.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { cancelUnreadResponseBody } from './unread-response-body' +import { cancelTrackingResponse } from './unread-response-body.test-fixtures' + +describe('cancelUnreadResponseBody', () => { + it('cancels an unread body stream', async () => { + let cancelled = false + await cancelUnreadResponseBody( + cancelTrackingResponse(500, () => { + cancelled = true + }) + ) + expect(cancelled).toBe(true) + }) + + it('no-ops on a body-less response', async () => { + await expect( + cancelUnreadResponseBody(new Response(null, { status: 500 })) + ).resolves.toBeUndefined() + }) + + it('swallows cancellation failures on a locked stream', async () => { + const response = cancelTrackingResponse(500, () => {}) + response.body?.getReader() + await expect(cancelUnreadResponseBody(response)).resolves.toBeUndefined() + }) +}) diff --git a/src/main/lib/unread-response-body.ts b/src/main/lib/unread-response-body.ts new file mode 100644 index 00000000000..c34342482af --- /dev/null +++ b/src/main/lib/unread-response-body.ts @@ -0,0 +1,12 @@ +/** + * Cancel a fetch Response body that no code path will read. Why: leaving it + * unread can crash the whole process from inside Node's bundled undici + * (nodejs/undici#5360, orca#8695); see global-fetch-call-site-audit.test.ts. + */ +export async function cancelUnreadResponseBody(response: Response): Promise { + try { + await response.body?.cancel() + } catch { + // Cancelling an already-errored, locked, or closed stream is harmless. + } +} diff --git a/src/main/orca-profiles/profile-cloud-client.test.ts b/src/main/orca-profiles/profile-cloud-client.test.ts index d57f7a041ca..5ff6f2e13c7 100644 --- a/src/main/orca-profiles/profile-cloud-client.test.ts +++ b/src/main/orca-profiles/profile-cloud-client.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { cancelTrackingResponse } from '../lib/unread-response-body.test-fixtures' import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config' import type { OrcaCloudSession } from './profile-cloud-session-store' import { @@ -248,4 +249,16 @@ describe('Orca cloud client', () => { } }) }) + + it('cancels the unread error-response body so bundled undici cannot crash on socket close', async () => { + let cancelledBodies = 0 + fetchMock.mockResolvedValue( + cancelTrackingResponse(502, () => { + cancelledBodies += 1 + }) + ) + + await expect(refreshOrcaCloudCapabilities(config, session)).rejects.toThrow() + expect(cancelledBodies).toBe(1) + }) }) diff --git a/src/main/orca-profiles/profile-cloud-client.ts b/src/main/orca-profiles/profile-cloud-client.ts index 81d88f72e22..e7657bbdb87 100644 --- a/src/main/orca-profiles/profile-cloud-client.ts +++ b/src/main/orca-profiles/profile-cloud-client.ts @@ -6,6 +6,7 @@ import type { import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config' import type { OrcaCloudSession } from './profile-cloud-session-store' import type { OrcaCloudSessionExchangeResponse } from './profile-cloud-session-exchange' +import { cancelUnreadResponseBody } from '../lib/unread-response-body' type ExchangeCodeArgs = { code: string @@ -173,6 +174,7 @@ async function postJson(url: string, body: unknown, accessToken?: string): Pr signal: AbortSignal.timeout(CLOUD_REQUEST_TIMEOUT_MS) }) if (!response.ok) { + await cancelUnreadResponseBody(response) throw new OrcaCloudRequestError(response.status) } return (await response.json()) as T diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index acde5dd02c3..39771208b1d 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -690,6 +690,31 @@ describe('Store', () => { expect(store.getSettings().minimizeToTrayOnClose).toBe(false) }) + it('persists native chat session options with per-model isolation', async () => { + const store = await createStore() + store.updateSettings({ + nativeChatSessionOptions: { + claude: { + model: 'opus', + valuesByModel: { + opus: { effort: 'xhigh', fastMode: true }, + sonnet: { effort: 'medium' } + } + } + } + }) + store.flush() + + const reloaded = await createStore() + expect(reloaded.getSettings().nativeChatSessionOptions?.claude).toEqual({ + model: 'opus', + valuesByModel: { + opus: { effort: 'xhigh', fastMode: true }, + sonnet: { effort: 'medium' } + } + }) + }) + it('coerces non-boolean minimizeToTrayOnClose payloads to a strict boolean', async () => { const store = await createStore() // Why: a renderer-supplied non-bool must never persist as a truthy non-bool @@ -2483,7 +2508,7 @@ describe('Store', () => { }) expect(persisted.settings.sourceControlAi.actions.branchName).toEqual({ agentId: 'claude', - commandInputTemplate: '{basePrompt}\n\nRollback commit prompt' + commandInputTemplate: 'Rollback commit prompt\n\n{basePrompt}' }) }) diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index f69505161f2..54f8c2a110c 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -936,7 +936,7 @@ describe('LocalPtyProvider', () => { expect(spawnMock).toHaveBeenCalledWith( 'C:\\Program Files\\Git\\bin\\bash.exe', - ['--login', '-i'], + ['-c', 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i'], expect.objectContaining({ cwd: 'C:\\Users\\jin\\repo', env: expect.objectContaining({ diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index 41e9b3b9488..273763f7017 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -196,14 +196,27 @@ describe('resolveWindowsShellLaunchArgs', () => { ]) }) - it('starts Git Bash as an interactive login shell without changing cwd', () => { + it('starts Git Bash as an interactive login shell with UTF-8 console setup', () => { const result = resolveWindowsShellLaunchArgs( 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Users\\alice\\code', 'C:\\Users\\alice' ) - expect(result.shellArgs).toEqual(['--login', '-i']) + // Why: Git Bash inherits the ConPTY OEM code page (CP437), so a byte-writing + // UTF-8 TUI mojibakes unless the console is switched to UTF-8 before it runs. + // Assert the contract behaviorally: chcp.com 65001 must precede the exec'd + // interactive login shell (which preserves `--login -i` and the shell's + // foreground identity), rather than pinning the exact command string. + expect(result.shellArgs[0]).toBe('-c') + const bashCommand = result.shellArgs[1] + expect(bashCommand).toContain('chcp.com 65001') + expect(bashCommand).toMatch(/exec "\$BASH" --login -i$/) + // The UTF-8 switch must run before the login shell is exec'd. + expect(bashCommand.indexOf('chcp.com')).toBeLessThan(bashCommand.indexOf('exec')) + // Must stay fail-open: `;` (not `&&`) so a missing chcp.com can't abort the + // exec and kill the terminal on startup. + expect(bashCommand).not.toContain('&&') expect(result.effectiveCwd).toBe('C:\\Users\\alice\\code') expect(result.validationCwd).toBe('C:\\Users\\alice\\code') }) diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index 4977cc11158..523aee72a6a 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -16,6 +16,13 @@ const CMD_EXE_COMMAND_LINE_MAX_CHARS = 8191 const STARTUP_COMMAND_TEXT_MAX_CHARS = 6000 const POWERSHELL_ENCODED_COMMAND_ARG_MAX_CHARS = 28_000 const CMD_UTF8_SETUP_COMMAND = 'chcp 65001 > nul' +// Why: Git for Windows' bash inherits the ConPTY console's OEM code page +// (CP437), so a TUI that writes UTF-8 bytes straight to the console — agents +// like Claude Code use WriteFile, not WriteConsoleW — renders as mojibake +// (`❯` -> `Γ¥»`). Switch the console to UTF-8, then exec the normal interactive +// login shell; cmd.exe and PowerShell already do the equivalent. The `;` (not +// `&&`) keeps startup working even if chcp.com is missing. +const GIT_BASH_UTF8_LOGIN_COMMAND = 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i' /** Result of resolving a Windows shell to its launch args + effective cwd. * @@ -176,7 +183,7 @@ export function resolveWindowsShellLaunchArgs( if (isWindowsGitBashShellPath(shellPath)) { return { - shellArgs: ['--login', '-i'], + shellArgs: ['-c', GIT_BASH_UTF8_LOGIN_COMMAND], effectiveCwd: nativeCwd, validationCwd: nativeCwd } diff --git a/src/main/rate-limits/codex-fetcher-backend.test.ts b/src/main/rate-limits/codex-fetcher-backend.test.ts index 3028bee551e..d345d94a5bf 100644 --- a/src/main/rate-limits/codex-fetcher-backend.test.ts +++ b/src/main/rate-limits/codex-fetcher-backend.test.ts @@ -1,4 +1,5 @@ import { join } from 'node:path' +import { cancelTrackingResponse } from '../lib/unread-response-body.test-fixtures' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { childSpawnMock, readFileMock, ptySpawnMock } = vi.hoisted(() => ({ @@ -193,4 +194,26 @@ describe('Codex backend rate-limit requests', () => { }) ) }) + + it('cancels the unread error-response body so bundled undici cannot crash on socket close', async () => { + readFileMock.mockResolvedValue( + JSON.stringify({ + tokens: { access_token: 'access-token', account_id: 'account-id' } + }) + ) + let cancelledBodies = 0 + vi.mocked(fetch).mockResolvedValue( + cancelTrackingResponse(429, () => { + cancelledBodies += 1 + }) + ) + + await expect( + consumeCodexRateLimitResetCredit({ + codexHomePath: '/managed/codex-home', + idempotencyKey: 'redeem-429' + }) + ).rejects.toThrow('Codex reset failed: HTTP 429') + expect(cancelledBodies).toBe(1) + }) }) diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index d606a12e850..f7ce2a5db08 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -9,6 +9,7 @@ import type { import { spawn } from 'node:child_process' import { readFile } from 'node:fs/promises' import { homedir } from 'node:os' +import { cancelUnreadResponseBody } from '../lib/unread-response-body' import { join } from 'node:path' import { probeCodexAuthPresence } from './codex-auth-presence' import { resolveCodexCommand } from '../codex-cli/command' @@ -381,6 +382,7 @@ async function fetchBackendRateLimitResetCredits( signal }) if (!response.ok) { + await cancelUnreadResponseBody(response) return null } const payload = (await response.json()) as BackendRateLimitResetCreditsResponse @@ -448,6 +450,7 @@ export async function consumeCodexRateLimitResetCredit(options: { } ) if (!response.ok) { + await cancelUnreadResponseBody(response) throw new Error(`Codex reset failed: HTTP ${response.status}`) } const payload = (await response.json()) as BackendConsumeRateLimitResetCreditResponse @@ -531,6 +534,7 @@ async function fetchViaBackend( signal }) if (!response.ok) { + await cancelUnreadResponseBody(response) return null } const payload = (await response.json()) as BackendUsageResponse diff --git a/src/main/runtime/orca-runtime-browser.ts b/src/main/runtime/orca-runtime-browser.ts index 053f614663c..c7da17da95d 100644 --- a/src/main/runtime/orca-runtime-browser.ts +++ b/src/main/runtime/orca-runtime-browser.ts @@ -49,9 +49,10 @@ import type { BrowserViewportResult, BrowserWaitResult } from '../../shared/runtime-types' +import type { BrowserCertificateProceedResult } from '../../shared/types' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' import type { BrowserBackend } from '../browser/browser-backend' -import { browserManager } from '../browser/browser-manager' +import { browserCertificateTrustController, browserManager } from '../browser/browser-manager' import { BrowserError } from '../browser/cdp-bridge' import { startBrowserScreencast, @@ -633,6 +634,16 @@ export class RuntimeBrowserCommands { } } + async browserProceedCertificate( + params: { challengeId: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + if (!target.browserPageId) { + return { ok: false, reason: 'missing' } + } + return browserCertificateTrustController.proceed(target.browserPageId, params.challengeId) + } + async browserTabShow(params: { page: string; worktree?: string }): Promise { const target = await this.resolveBrowserCommandTarget(params) return { tab: this.describeBrowserTab(params.page, target.worktreeId) } diff --git a/src/main/runtime/orca-runtime-files.test.ts b/src/main/runtime/orca-runtime-files.test.ts index e38d0adf0d0..562dbb5602b 100644 --- a/src/main/runtime/orca-runtime-files.test.ts +++ b/src/main/runtime/orca-runtime-files.test.ts @@ -252,6 +252,8 @@ describe('RuntimeFileCommands', () => { it('opens text files through the renderer host (inheriting active runtime env)', async () => { const openFile = vi.fn() const { commands } = createRuntimeFileCommands({ openFile }) + resolveAuthorizedPathMock.mockResolvedValue('/repo/docs/readme.md') + statMock.mockResolvedValue({ isDirectory: () => false }) const result = await commands.openMobileFile('id:wt-1', 'docs/readme.md') @@ -272,6 +274,8 @@ describe('RuntimeFileCommands', () => { it('opens previewable images through the renderer host as an image tab', async () => { const openFile = vi.fn() const { commands } = createRuntimeFileCommands({ openFile }) + resolveAuthorizedPathMock.mockResolvedValue('/repo/assets/logo.png') + statMock.mockResolvedValue({ isDirectory: () => false }) const result = await commands.openMobileFile('id:wt-1', 'assets/logo.png') @@ -296,6 +300,7 @@ describe('RuntimeFileCommands', () => { const result = await commands.openMobileFile('id:wt-1', 'dist/bundle.zip') expect(openFile).not.toHaveBeenCalled() + expect(statMock).not.toHaveBeenCalled() expect(result).toEqual({ worktree: 'wt-1', relativePath: 'dist/bundle.zip', @@ -304,6 +309,43 @@ describe('RuntimeFileCommands', () => { }) }) + it('rejects missing local files without creating an editor tab', async () => { + const openFile = vi.fn() + const { commands } = createRuntimeFileCommands({ openFile }) + resolveAuthorizedPathMock.mockResolvedValue('/repo/docs/missing.md') + statMock.mockRejectedValue(enoent()) + + await expect(commands.openMobileFile('id:wt-1', 'docs/missing.md')).rejects.toThrow( + "ENOENT: no such file or directory, open '/repo/docs/missing.md'" + ) + expect(openFile).not.toHaveBeenCalled() + }) + + it('rejects missing remote files without creating an editor tab', async () => { + const openFile = vi.fn() + const resolveRuntimeFileTarget = vi.fn(async () => ({ + worktree: { + id: 'wt-1', + repoId: 'repo-1', + path: '/remote/repo' + }, + connectionId: 'ssh-1' + })) + const { commands } = createRuntimeFileCommands({ + openFile, + path: '/remote/repo', + resolveRuntimeFileTarget + }) + vi.mocked(getSshFilesystemProvider).mockReturnValue({ + stat: vi.fn().mockRejectedValue(new Error('ENOENT: no such file or directory')) + } as never) + + await expect(commands.openMobileFile('id:wt-1', 'docs/missing.md')).rejects.toThrow( + "ENOENT: no such file or directory, open '/remote/repo/docs/missing.md'" + ) + expect(openFile).not.toHaveBeenCalled() + }) + it('does not follow symlinks when reading runtime-local file explorer dirs', async () => { const { commands } = createRuntimeFileCommands() resolveAuthorizedPathMock.mockResolvedValue('/repo') diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index 5af3d0d22a3..822006701fd 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -498,7 +498,7 @@ export class RuntimeFileCommands { worktreeSelector: string, relativePath: string ): Promise { - const { worktree } = await this.host.resolveRuntimeFileTarget(worktreeSelector) + const { worktree, connectionId } = await this.host.resolveRuntimeFileTarget(worktreeSelector) if (!isSafeMobileRelativePath(relativePath)) { throw new Error('invalid_relative_path') } @@ -515,6 +515,9 @@ export class RuntimeFileCommands { return { worktree: worktree.id, relativePath, kind, opened: false } } const filePath = joinWorktreeRelativePath(worktree.path, relativePath) + // Why: CLI/agents treat opened:true as success. Stat first so missing paths + // fail the RPC instead of creating a ghost editor tab that only errors on read. + await this.assertMobileOpenTargetExists(filePath, connectionId) // Why: the service's internal runtimeId is not a registered runtime env selector // (those live in orca-environments.json). Passing it caused Unknown environment // errors on content load for CLI-initiated opens (via files.open from orca cli @@ -525,6 +528,25 @@ export class RuntimeFileCommands { return { worktree: worktree.id, relativePath, kind, opened: true } } + private async assertMobileOpenTargetExists( + filePath: string, + connectionId?: string + ): Promise { + try { + await (connectionId + ? this.statRemoteTerminalPath(filePath, connectionId) + : stat(await resolveAuthorizedPath(filePath, this.host.requireStore()))) + } catch (error) { + if ( + isENOENT(error) || + (connectionId && RuntimeFileCommands.isRemoteNotFoundErrorMessage(error)) + ) { + throw new Error(`ENOENT: no such file or directory, open '${filePath}'`) + } + throw error + } + } + async openMobileDiff( worktreeSelector: string, relativePath: string, diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 30645294b47..d652268500b 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1538,6 +1538,123 @@ describe('OrcaRuntimeService', () => { expect(capabilities).toContain('browser.screencast.v1') // ...and the headless marker tells clients not to fall back to a local tab. expect(capabilities).toContain('browser.headless.v1') + expect(capabilities).toContain('browser.certificate-trust.v1') + }) + + it('surfaces live offscreen load failures in headless browser snapshots', () => { + const runtime = createRuntime() + runtime.setOffscreenBrowserBackend({ createTab: vi.fn(), closeTab: vi.fn() }) + runtime.setAgentBrowserBridge({ + tabList: vi.fn(() => ({ + tabs: [ + { + browserPageId: 'page-certificate-error', + index: 0, + url: 'https://localhost:3443/', + title: 'Local HTTPS', + active: true, + loadError: { + code: -202, + description: 'ERR_CERT_AUTHORITY_INVALID', + validatedUrl: 'https://localhost:3443/' + }, + certificateFailure: { + challengeId: 'challenge-1', + browserPageId: 'page-certificate-error', + errorCode: -202, + error: 'ERR_CERT_AUTHORITY_INVALID', + origin: 'https://localhost:3443', + displayHost: 'localhost:3443', + canProceed: true, + observedAt: 123 + } + } + ] + })) + } as never) + const browserTabs = runtime['buildHeadlessMobileSessionBrowserTabs'](TEST_WORKTREE_ID) + expect(browserTabs).toContainEqual( + expect.objectContaining({ + type: 'browser', + browserPageId: 'page-certificate-error', + loadError: { + code: -202, + description: 'ERR_CERT_AUTHORITY_INVALID', + validatedUrl: 'https://localhost:3443/' + }, + certificateFailure: { + challengeId: 'challenge-1', + browserPageId: 'page-certificate-error', + errorCode: -202, + error: 'ERR_CERT_AUTHORITY_INVALID', + origin: 'https://localhost:3443', + displayHost: 'localhost:3443', + canProceed: true, + observedAt: 123 + } + }) + ) + }) + + it('detects headless browser-tab changes by field, treating absent and null loadError alike', () => { + const runtime = createRuntime() + const base = { + type: 'browser' as const, + id: 'page-1', + title: 'Local', + browserWorkspaceId: 'page-1', + browserPageId: 'page-1', + url: 'https://localhost:3443/', + loading: false, + canGoBack: false, + canGoForward: false, + isActive: true + } + const err = { + code: -202, + description: 'ERR_CERT_AUTHORITY_INVALID', + validatedUrl: 'https://localhost:3443/' + } + const certificateFailure = { + challengeId: 'challenge-1', + browserPageId: 'page-1', + errorCode: -202, + error: 'ERR_CERT_AUTHORITY_INVALID', + origin: 'https://localhost:3443', + displayHost: 'localhost:3443', + canProceed: true, + observedAt: 123 + } + const unchanged = (a: unknown[], b: unknown[]): boolean => + runtime['headlessBrowserTabsUnchanged'](a as never, b as never) + + // Absent vs explicit null loadError are equivalent (the JSON.stringify trap). + expect(unchanged([{ ...base }], [{ ...base, loadError: null }])).toBe(true) + expect(unchanged([{ ...base, loadError: err }], [{ ...base, loadError: { ...err } }])).toBe( + true + ) + // A load-error-only change (identical ids/order) must not be missed. + expect(unchanged([{ ...base }], [{ ...base, loadError: err }])).toBe(false) + expect( + unchanged([{ ...base, loadError: err }], [{ ...base, loadError: { ...err, code: -200 } }]) + ).toBe(false) + expect( + unchanged( + [{ ...base, certificateFailure }], + [{ ...base, certificateFailure: { ...certificateFailure } }] + ) + ).toBe(true) + expect(unchanged([{ ...base }], [{ ...base, certificateFailure }])).toBe(false) + expect( + unchanged( + [{ ...base, certificateFailure }], + [{ ...base, certificateFailure: { ...certificateFailure, challengeId: 'challenge-2' } }] + ) + ).toBe(false) + // Scalar and length changes are detected. + expect(unchanged([{ ...base }], [{ ...base, title: 'Changed' }])).toBe(false) + expect(unchanged([{ ...base }], [{ ...base, isActive: false }])).toBe(false) + expect(unchanged([{ ...base }], [{ ...base }, { ...base, id: 'page-2' }])).toBe(false) }) it('does not advertise headless browser capability when a renderer window exists', () => { @@ -1547,6 +1664,16 @@ describe('OrcaRuntimeService', () => { runtime.setOffscreenBrowserBackend({ createTab: vi.fn(), closeTab: vi.fn() }) expect(runtime.getStatus().capabilities).not.toContain('browser.headless.v1') + // Desktop webviews still host certificate trust, so the proceed capability + // remains advertised for remote clients controlling those pages. + expect(runtime.getStatus().capabilities).toContain('browser.certificate-trust.v1') + }) + + it('does not advertise certificate trust when no browser backend is available', () => { + const runtime = createRuntime() + + expect(runtime.getStatus().capabilities).not.toContain('browser.certificate-trust.v1') + expect(runtime.getStatus().capabilities).not.toContain('browser.screencast.v1') }) it('closes a worktree’s offscreen browser pages when its metadata is removed (leak fix)', () => { @@ -19726,6 +19853,134 @@ describe('OrcaRuntimeService', () => { expect(getSession().terminalLayoutsByTabId['host-tab']).toBeUndefined() }) + it('durably tears down a runtime-owned SSH headless tab when renderer cleanup fails', async () => { + // #8958 regression: the acknowledged renderer relay cannot see headless tabs; + // its advisory fallback must not prevent authoritative teardown or flushing. + const persistedPtyId = 'ssh:ssh-1@@relay-pty' + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'host-tab', + ptyId: persistedPtyId, + worktreeId: TEST_WORKTREE_ID, + title: 'Remote Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: persistedPtyId }) + } + }) + ) + const kill = vi.fn(() => true) + const flushOrThrow = vi.fn() + const rendererError = new Error('renderer unavailable') + const closeTerminal = vi.fn(() => { + throw rendererError + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const closeTerminalTab = vi.fn(async () => {}) + const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow } as never) + runtime.setPtyController({ + write: () => true, + kill, + getForegroundProcess: async () => null, + listProcesses: async () => [{ id: persistedPtyId, cwd: TEST_WORKTREE_PATH, title: 'Remote' }] + }) + runtime.setNotifier({ closeTerminal, closeTerminalTab } as never) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + + await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab') + + expect(closeTerminalTab).not.toHaveBeenCalled() + expect(kill).toHaveBeenCalledWith(persistedPtyId) + expect(closeTerminal).toHaveBeenCalledWith('host-tab') + expect(flushOrThrow).toHaveBeenCalledTimes(1) + expect(warn).toHaveBeenCalledWith( + '[runtime] failed to notify renderer after headless terminal close', + { parentTabId: 'host-tab', error: rendererError } + ) + expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([]) + expect(getSession().terminalLayoutsByTabId['host-tab']).toBeUndefined() + }) + + it('keeps the renderer close transaction for an adopted runtime-owned tab', async () => { + // The renderer pin state can be newer than the debounced workspace session. + // Once adopted, its live close guard must win over stale persisted metadata. + const servePtyId = 'serve-adopted-1' + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'host-tab', + ptyId: servePtyId, + worktreeId: TEST_WORKTREE_ID, + title: 'Adopted Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + isPinned: false + } + ] + }, + terminalLayoutsByTabId: { + 'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: servePtyId }) + } + }) + ) + const kill = vi.fn(() => true) + const closeTerminal = vi.fn() + const closeTerminalTab = vi.fn(async () => { + throw new Error('terminal_tab_pinned') + }) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.setPtyController({ + write: () => true, + kill, + getForegroundProcess: async () => null, + listProcesses: async () => [{ id: servePtyId, cwd: TEST_WORKTREE_PATH, title: 'Adopted' }] + }) + runtime.setNotifier({ closeTerminal, closeTerminalTab } as never) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'host-tab', + worktreeId: TEST_WORKTREE_ID, + title: 'Adopted Terminal', + activeLeafId: HEADLESS_LEAF_ID, + layout: null + } + ], + leaves: [ + { + tabId: 'host-tab', + worktreeId: TEST_WORKTREE_ID, + leafId: HEADLESS_LEAF_ID, + paneRuntimeId: 1, + ptyId: servePtyId + } + ] + }) + + await expect( + runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab') + ).rejects.toThrow('terminal_tab_pinned') + + expect(closeTerminalTab).toHaveBeenCalledWith('host-tab') + expect(closeTerminal).not.toHaveBeenCalled() + expect(kill).not.toHaveBeenCalled() + expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toHaveLength(1) + expect(getSession().terminalLayoutsByTabId['host-tab']).toBeDefined() + }) + it('materializes hydrated pending headless terminals with the persisted session identity', async () => { const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( makeWorkspaceSessionWithHeadlessTerminal() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 762a85c18c5..4a8c8a4d9f3 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -264,6 +264,7 @@ import { } from '../../shared/worktree-ownership' import { BROWSER_HEADLESS_RUNTIME_CAPABILITY, + BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY, MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, RUNTIME_CAPABILITIES, RUNTIME_PROTOCOL_VERSION, @@ -3070,6 +3071,12 @@ export class OrcaRuntimeService { if (hasOffscreen) { capabilities.push(BROWSER_HEADLESS_RUNTIME_CAPABILITY) } + // Why: certificate proceed is owned by the browser-hosting process for both + // desktop webviews and offscreen pages. Advertise whenever either backend + // can host a page so remote clients can surface Proceed Anyway (Unsafe). + if (canBrowse) { + capabilities.push(BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY) + } return { runtimeId: this.runtimeId, rendererGraphEpoch: this.rendererGraphEpoch, @@ -3439,13 +3446,16 @@ export class OrcaRuntimeService { allowAttachedWindow?: boolean onlyServeOwnedTerminals?: boolean } = {} - ): void { + ): Set { + // Why: report which worktrees were reconciled in place so callers don't + // reconcile them a second time (see notifyMobileSessionTabsChanged). + const reconciledWorktreeIds = new Set() if (this.getAvailableAuthoritativeWindow() && options.allowAttachedWindow !== true) { - return + return reconciledWorktreeIds } const session = this.store?.getWorkspaceSession?.() if (!session) { - return + return reconciledWorktreeIds } const entries = worktreeId !== undefined @@ -3464,6 +3474,7 @@ export class OrcaRuntimeService { // Reconcile just the browser tabs against the live bridge instead of // leaving a stale snapshot that omits a freshly-opened browser tab. this.reconcileHeadlessMobileSessionBrowserTabs(entryWorktreeId, existing) + reconciledWorktreeIds.add(entryWorktreeId) continue } const terminalTabs = this.buildHeadlessMobileSessionTerminalTabs( @@ -3563,6 +3574,7 @@ export class OrcaRuntimeService { tabs: mergedTabs }) } + return reconciledWorktreeIds } // Why: keep an existing snapshot's browser tabs in sync with the live bridge @@ -3577,13 +3589,11 @@ export class OrcaRuntimeService { } const liveBrowserTabs = this.buildHeadlessMobileSessionBrowserTabs(worktreeId) const liveIds = liveBrowserTabs.map((tab) => tab.id) - const existingBrowserIds = existing.tabs - .filter((tab): tab is RuntimeMobileSessionBrowserTab => tab.type === 'browser') - .map((tab) => tab.id) - const unchanged = - liveIds.length === existingBrowserIds.length && - liveIds.every((id, index) => existingBrowserIds[index] === id) - if (unchanged) { + const existingBrowserTabs = existing.tabs.filter( + (tab): tab is RuntimeMobileSessionBrowserTab => tab.type === 'browser' + ) + const existingBrowserIds = existingBrowserTabs.map((tab) => tab.id) + if (this.headlessBrowserTabsUnchanged(liveBrowserTabs, existingBrowserTabs)) { return } const nonBrowserTabs = existing.tabs.filter((tab) => tab.type !== 'browser') @@ -4060,6 +4070,8 @@ export class OrcaRuntimeService { loading: false, canGoBack: false, canGoForward: false, + loadError: tab.loadError ?? undefined, + certificateFailure: tab.certificateFailure ?? undefined, ...(persistedProps ? { color: persistedProps.color } : {}), ...(persistedProps ? { isPinned: persistedProps.isPinned === true } : {}), isActive: tab.active === true @@ -4067,6 +4079,75 @@ export class OrcaRuntimeService { }) } + // Why: change detection for headless browser tabs. Compares the fields that + // actually vary (a JSON.stringify equality was order-sensitive and silently + // dropped `undefined` keys, so it only worked while both sides shared one + // construction path). + private headlessBrowserTabsUnchanged( + live: RuntimeMobileSessionBrowserTab[], + existing: RuntimeMobileSessionBrowserTab[] + ): boolean { + if (live.length !== existing.length) { + return false + } + return live.every((tab, index) => { + const prev = existing[index] + return ( + tab.id === prev.id && + tab.title === prev.title && + tab.url === prev.url && + tab.isActive === prev.isActive && + (tab.isPinned ?? false) === (prev.isPinned ?? false) && + (tab.color ?? null) === (prev.color ?? null) && + this.browserLoadErrorsEqual(tab.loadError, prev.loadError) && + this.browserCertificateFailuresEqual(tab.certificateFailure, prev.certificateFailure) + ) + }) + } + + private browserLoadErrorsEqual( + a: RuntimeMobileSessionBrowserTab['loadError'], + b: RuntimeMobileSessionBrowserTab['loadError'] + ): boolean { + const left = a ?? null + const right = b ?? null + if (left === right) { + return true + } + if (!left || !right) { + return false + } + return ( + left.code === right.code && + left.description === right.description && + left.validatedUrl === right.validatedUrl + ) + } + + private browserCertificateFailuresEqual( + a: RuntimeMobileSessionBrowserTab['certificateFailure'], + b: RuntimeMobileSessionBrowserTab['certificateFailure'] + ): boolean { + const left = a ?? null + const right = b ?? null + if (left === right) { + return true + } + if (!left || !right) { + return false + } + return ( + left.challengeId === right.challengeId && + left.browserPageId === right.browserPageId && + left.errorCode === right.errorCode && + left.error === right.error && + left.origin === right.origin && + left.displayHost === right.displayHost && + left.canProceed === right.canProceed && + left.observedAt === right.observedAt + ) + } + private getPersistedUnifiedSessionTabProps( worktreeId: string, tabId: string @@ -4694,26 +4775,33 @@ export class OrcaRuntimeService { (candidate) => candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId ).length const closingWholeParent = tab.id !== tabId || parentLeafCount <= 1 + // Why: a runtime-owned headless tab is absent from renderer state, so the + // closeTerminalTab relay below would ack success without killing its PTY, + // and syncMobileSessionTabs would republish the "closed" tab. Only bypass + // the relay when no renderer owns the parent: an adopted tab needs the + // renderer's live pin guard and durable close transaction. + if (closingWholeParent && !this.tabs.has(tab.parentTabId)) { + this.closeHeadlessMobileTerminalTab(worktreeId, snapshot!, tab) + this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId) + this.store?.flushOrThrow?.() + return { closed: true } + } if (closingWholeParent && this.notifier?.closeTerminalTab) { // Why: whole-tab close is a lifecycle transaction. The renderer reply // arrives only after canonical retirement and a forced session flush. await this.notifier.closeTerminalTab(tab.parentTabId) return { closed: true } } - if (!this.notifier?.closeTerminal) { + // Why: notifier implementations without the acknowledged relay may expose + // only raw pane close. Runtime-owned parents still need de-persist + kill. + if (closingWholeParent && this.isRuntimeOwnedHeadlessMobileTab(worktreeId, tab)) { this.closeHeadlessMobileTerminalTab(worktreeId, snapshot!, tab) + this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId) this.store?.flushOrThrow?.() return { closed: true } } - // Why: a runtime-owned headless tab whose whole parent is being closed must - // be torn down authoritatively even with a renderer attached — kill the - // PTY, drop the persisted binding, and prune+emit — or syncMobileSessionTabs - // keeps republishing the "closed" tab with a live PTY. Best-effort notify the - // renderer too so any adopted pane closes (no dead pane). A single split leaf - // (exact id, multi-leaf parent) keeps the per-leaf path so siblings survive. - if (closingWholeParent && this.isRuntimeOwnedHeadlessMobileTab(worktreeId, tab)) { + if (!this.notifier?.closeTerminal) { this.closeHeadlessMobileTerminalTab(worktreeId, snapshot!, tab) - this.notifier?.closeTerminal(tab.parentTabId) this.store?.flushOrThrow?.() return { closed: true } } @@ -4741,6 +4829,19 @@ export class OrcaRuntimeService { return { closed: true } } + private notifyRendererOfHeadlessTerminalClose(parentTabId: string): void { + // Why: this relay is advisory after main owns teardown; renderer failure must + // not prevent the authoritative session flush or turn the close into failure. + try { + this.notifier?.closeTerminal(parentTabId) + } catch (error) { + console.warn('[runtime] failed to notify renderer after headless terminal close', { + parentTabId, + error + }) + } + } + private async closeHeadlessMobileBrowserTab( worktreeId: string, snapshot: RuntimeMobileSessionTabsSnapshot, @@ -21567,6 +21668,17 @@ export class OrcaRuntimeService { this.notifyMobileSessionTabSnapshots() return } + if (this.offscreenBrowserBackend) { + const reconciled = this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) + // Why: hydrate already reconciles an existing snapshot in place; only + // reconcile here when it didn't (fresh build or an early-returned hydrate). + if (!reconciled.has(worktreeId)) { + const existing = this.mobileSessionTabsByWorktree.get(worktreeId) + if (existing) { + this.reconcileHeadlessMobileSessionBrowserTabs(worktreeId, existing) + } + } + } // Why: structural changes (tab add/remove/activate) must propagate promptly, // so cancel any pending coalesced title/status notify — this immediate emit // already reflects the latest snapshot and supersedes it. @@ -25480,6 +25592,8 @@ export class OrcaRuntimeService { browserTabList: RuntimeBrowserCommands['browserTabList'] = this.browserCommands.browserTabList.bind(this.browserCommands) + browserProceedCertificate: RuntimeBrowserCommands['browserProceedCertificate'] = + this.browserCommands.browserProceedCertificate.bind(this.browserCommands) browserTabShow: RuntimeBrowserCommands['browserTabShow'] = this.browserCommands.browserTabShow.bind(this.browserCommands) diff --git a/src/main/runtime/relay/relay-http-client.test.ts b/src/main/runtime/relay/relay-http-client.test.ts index 3a786210ba1..57beb527497 100644 --- a/src/main/runtime/relay/relay-http-client.test.ts +++ b/src/main/runtime/relay/relay-http-client.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import nacl from 'tweetnacl' +import { cancelTrackingResponse } from '../../lib/unread-response-body.test-fixtures' import { exchangeRelayAuthorization, requestRelayAssignment } from './relay-http-client' describe('relay HTTP client', () => { @@ -72,4 +73,35 @@ describe('relay HTTP client', () => { }) ).rejects.toThrow('relay_assignment_failed_502') }) + + it('cancels unread error-response bodies so bundled undici cannot crash on socket close', async () => { + const keypair = nacl.box.keyPair() + let cancelledBodies = 0 + const fetch = vi.fn(async () => + cancelTrackingResponse(503, () => { + cancelledBodies += 1 + }) + ) + + await expect( + exchangeRelayAuthorization({ + endpoint: 'https://auth.example/v1/desktop/auth/relay-token', + accessToken: 'ordinary-access-token', + keypair: { + ...keypair, + publicKeyB64: Buffer.from(keypair.publicKey).toString('base64') + }, + fetch + }) + ).rejects.toThrow() + await expect( + requestRelayAssignment({ + directorUrl: 'https://relay.example', + relayToken: 'scoped-token', + relayHostId: 'AbCdEf0123_-xyZ9', + fetch + }) + ).rejects.toThrow() + expect(cancelledBodies).toBe(2) + }) }) diff --git a/src/main/runtime/relay/relay-http-client.ts b/src/main/runtime/relay/relay-http-client.ts index ddda205d12b..47118b88cfc 100644 --- a/src/main/runtime/relay/relay-http-client.ts +++ b/src/main/runtime/relay/relay-http-client.ts @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto' import { z } from 'zod' import type { E2EEKeypair } from '../e2ee-keypair' +import { cancelUnreadResponseBody } from '../../lib/unread-response-body' const RelayTokenResponseSchema = z .object({ @@ -69,6 +70,7 @@ export async function exchangeRelayAuthorization(input: { body: JSON.stringify({ relayHostId, hostPublicKeyB64: input.keypair.publicKeyB64 }) }) if (!response.ok) { + await cancelUnreadResponseBody(response) throw new RelayHttpError('token-exchange', response.status) } const parsed = RelayTokenResponseSchema.safeParse(await response.json()) @@ -96,6 +98,7 @@ export async function requestRelayAssignment(input: { body: JSON.stringify({ v: 1, relayHostId: input.relayHostId }) }) if (!response.ok) { + await cancelUnreadResponseBody(response) throw new RelayHttpError('assignment', response.status) } const parsed = AssignmentResponseSchema.safeParse(await response.json()) diff --git a/src/main/runtime/rpc/methods/browser-core.ts b/src/main/runtime/rpc/methods/browser-core.ts index fc1b1277fce..41484d20768 100644 --- a/src/main/runtime/rpc/methods/browser-core.ts +++ b/src/main/runtime/rpc/methods/browser-core.ts @@ -1,5 +1,5 @@ import { defineMethod, type RpcMethod } from '../core' -import { BrowserTarget } from '../schemas' +import { BrowserTarget, requiredString } from '../schemas' import { Check, Drag, @@ -34,6 +34,10 @@ import { } from './browser-schemas' import { BROWSER_TEXT_METHODS } from './browser-text-rpc-methods' +const CertificateProceed = BrowserTarget.extend({ + challengeId: requiredString('Missing required challengeId') +}) + export const BROWSER_CORE_METHODS: RpcMethod[] = [ defineMethod({ name: 'browser.snapshot', @@ -50,6 +54,11 @@ export const BROWSER_CORE_METHODS: RpcMethod[] = [ params: Goto, handler: async (params, { runtime }) => runtime.browserGoto(params) }), + defineMethod({ + name: 'browser.certificate.proceed', + params: CertificateProceed, + handler: async (params, { runtime }) => runtime.browserProceedCertificate(params) + }), ...BROWSER_TEXT_METHODS, defineMethod({ name: 'browser.select', diff --git a/src/main/runtime/rpc/methods/browser-schemas.ts b/src/main/runtime/rpc/methods/browser-schemas.ts index 72a2893444e..9eff1456662 100644 --- a/src/main/runtime/rpc/methods/browser-schemas.ts +++ b/src/main/runtime/rpc/methods/browser-schemas.ts @@ -1,7 +1,5 @@ -// Why: the browser method surface area is large enough that keeping every -// schema in the same file as its handler registration pushes the file past -// the 300-line lint cap. Grouping all browser schemas here keeps each -// handler file focused on dispatch wiring. +// Why: browser schemas stay separate from handler registration so both sides +// remain under the line cap and dispatch wiring stays scannable. import { z } from 'zod' import { BrowserTarget, diff --git a/src/main/runtime/rpc/ws-transport.test.ts b/src/main/runtime/rpc/ws-transport.test.ts index 391e5e18284..ca34340ccb1 100644 --- a/src/main/runtime/rpc/ws-transport.test.ts +++ b/src/main/runtime/rpc/ws-transport.test.ts @@ -472,6 +472,43 @@ describe('WebSocketTransport', () => { expect(transport.resolvedPort).toBe(fallbackPort) }) + it('binds the preferred port first when preferPinnedPort is set and both are free', async () => { + // Why: issue #8535 — `orca serve --port

` clients dial the pin. A + // free but stale mobile-ws-fallback-port.json must not pre-empt it. + const preferredPort = await reserveFreePort() + const fallbackPort = await reserveFreePort() + + const transport = new WebSocketTransport({ + host: '127.0.0.1', + port: preferredPort, + fallbackPort, + preferPinnedPort: true + }) + transports.push(transport) + await transport.start() + expect(transport.resolvedPort).toBe(preferredPort) + }) + + it('falls back when preferPinnedPort is set but the preferred port is taken', async () => { + // Why: explicit pins still degrade to the STA-1511 fallback on + // EADDRINUSE so previously-paired mobile devices remain reachable. + const preferredHolder = new WebSocketTransport({ host: '127.0.0.1', port: 0 }) + transports.push(preferredHolder) + await preferredHolder.start() + const preferredPort = preferredHolder.resolvedPort + const fallbackPort = await reserveFreePort() + + const transport = new WebSocketTransport({ + host: '127.0.0.1', + port: preferredPort, + fallbackPort, + preferPinnedPort: true + }) + transports.push(transport) + await transport.start() + expect(transport.resolvedPort).toBe(fallbackPort) + }) + it('binds the preferred port when the persisted fallback is taken', async () => { const fallbackHolder = new WebSocketTransport({ host: '127.0.0.1', port: 0 }) transports.push(fallbackHolder) diff --git a/src/main/runtime/rpc/ws-transport.ts b/src/main/runtime/rpc/ws-transport.ts index 8b88c7a820e..0d5403c5991 100644 --- a/src/main/runtime/rpc/ws-transport.ts +++ b/src/main/runtime/rpc/ws-transport.ts @@ -54,6 +54,11 @@ export type WebSocketTransportOptions = { // the (now free) preferred port instead would strand those pairings // (STA-1511). Callers pass the previously assigned fallback port here. fallbackPort?: number + // Why: `orca serve --port

` clients dial the pinned port. Prefer that port + // first (fallback second) so a stale mobile-ws-fallback-port.json cannot + // silently steal the pin (issue #8535). Default auto/desktop keeps + // fallback-first for STA-1511 pairing stability. + preferPinnedPort?: boolean } export class WebSocketTransport implements RpcTransport { @@ -65,6 +70,7 @@ export class WebSocketTransport implements RpcTransport { private readonly preAuthTimeoutMs: number private readonly staticRoot: string | undefined private readonly fallbackPort: number | undefined + private readonly preferPinnedPort: boolean private httpServer: HttpsServer | HttpServer | null = null private wss: WebSocketServer | null = null private heartbeatTimer: ReturnType | null = null @@ -89,7 +95,8 @@ export class WebSocketTransport implements RpcTransport { heartbeatIntervalMs, preAuthTimeoutMs, staticRoot, - fallbackPort + fallbackPort, + preferPinnedPort }: WebSocketTransportOptions) { this.host = host this.port = port @@ -99,6 +106,7 @@ export class WebSocketTransport implements RpcTransport { this.preAuthTimeoutMs = preAuthTimeoutMs ?? PRE_AUTH_TIMEOUT_MS this.staticRoot = staticRoot this.fallbackPort = fallbackPort + this.preferPinnedPort = preferPinnedPort === true } onMessage(handler: WebSocketMessageHandler): void { @@ -150,10 +158,12 @@ export class WebSocketTransport implements RpcTransport { return } - // Why: a persisted fallback port is bound FIRST — devices paired while it - // was active store ws://ip: and would be permanently stranded if - // a later launch grabbed the (now free) preferred port instead (STA-1511). - // Without a persisted fallback the preferred port is tried first. On + // Why: default order binds a persisted fallback FIRST — devices paired + // while it was active store ws://ip: and would be stranded if a + // later launch grabbed the (now free) preferred port instead (STA-1511). + // Explicit serve --port flips the order so the pinned port wins when free + // (issue #8535); fallback remains a secondary candidate for EADDRINUSE. + // Without a persisted fallback only the preferred port is tried. On // EADDRINUSE each candidate falls through to the next, ending at port 0 // (OS-assigned) so mobile pairing still works when everything is taken. // The QR code reads resolvedPort after start, so it always advertises the @@ -163,7 +173,11 @@ export class WebSocketTransport implements RpcTransport { ? this.fallbackPort : undefined const candidatePorts = - persistedFallbackPort !== undefined ? [persistedFallbackPort, this.port] : [this.port] + persistedFallbackPort === undefined + ? [this.port] + : this.preferPinnedPort + ? [this.port, persistedFallbackPort] + : [persistedFallbackPort, this.port] for (const port of candidatePorts) { try { await this.tryListen(port) diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 03c8225b44d..1b0a31dd2b0 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -1983,6 +1983,20 @@ describe('OrcaRuntimeRpcServer', () => { (response) => replies.push(JSON.parse(response) as Record), () => {} ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_browser_certificate_proceed', + method: 'browser.certificate.proceed', + deviceToken: mobile.token, + params: { + worktree: 'id:wt-1', + page: 'page-1', + challengeId: 'challenge-1' + } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) await server['handleWebSocketMessage']( JSON.stringify({ id: 'req_browser_dialog_accept', @@ -2123,6 +2137,13 @@ describe('OrcaRuntimeRpcServer', () => { expect(replies).toContainEqual( expect.objectContaining({ id: 'req_browser_viewport', ok: true }) ) + expect(replies).toContainEqual( + expect.objectContaining({ + id: 'req_browser_certificate_proceed', + ok: false, + error: expect.objectContaining({ code: 'forbidden' }) + }) + ) expect(replies).toContainEqual( expect.objectContaining({ id: 'req_browser_dialog_accept', ok: true }) ) diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index d7623065634..5c7c44574d4 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -53,6 +53,10 @@ type OrcaRuntimeRpcServerOptions = { platform?: NodeJS.Platform enableWebSocket?: boolean wsPort?: number + // Why: true when the caller set an explicit port (e.g. `orca serve --port`). + // Distinguishes that pin from the DEFAULT_WS_PORT default so transport bind + // order can prefer the pin over a stale STA-1511 fallback (issue #8535). + preferPinnedWsPort?: boolean webClientRoot?: string // Why: test-only overrides for the two time-bound constants below. // Production callers must not pass these — defaults are set by the design @@ -450,6 +454,7 @@ export class OrcaRuntimeRpcServer { private readonly platform: NodeJS.Platform private readonly enableWebSocket: boolean private readonly wsPort: number + private readonly preferPinnedWsPort: boolean private readonly webClientRoot: string | undefined private readonly authToken = randomBytes(24).toString('hex') private readonly keepaliveIntervalMs: number @@ -482,6 +487,7 @@ export class OrcaRuntimeRpcServer { platform = process.platform, enableWebSocket = false, wsPort = DEFAULT_WS_PORT, + preferPinnedWsPort = false, webClientRoot, keepaliveIntervalMs = KEEPALIVE_INTERVAL_MS, longPollCap = LONG_POLL_CAP @@ -493,6 +499,7 @@ export class OrcaRuntimeRpcServer { this.platform = platform this.enableWebSocket = enableWebSocket this.wsPort = wsPort + this.preferPinnedWsPort = preferPinnedWsPort this.webClientRoot = webClientRoot this.keepaliveIntervalMs = keepaliveIntervalMs this.longPollCap = longPollCap @@ -863,10 +870,11 @@ export class OrcaRuntimeRpcServer { staticRoot: this.webClientRoot, // Why: keep the fallback port stable across restarts so paired // devices' stored endpoints stay valid (STA-1511) — the transport - // binds a persisted fallback before the preferred port. wsPort 0 - // means the caller explicitly wants a random port (E2E) — don't - // pin it. - ...(this.wsPort !== 0 ? { fallbackPort: readWsFallbackPort(this.userDataPath) } : {}) + // binds a persisted fallback before the preferred port unless the + // caller explicitly pinned a port (serve --port). wsPort 0 means + // the caller wants a random port (E2E) — don't pin it. + ...(this.wsPort !== 0 ? { fallbackPort: readWsFallbackPort(this.userDataPath) } : {}), + ...(this.preferPinnedWsPort ? { preferPinnedPort: true } : {}) }) const mobileSocketWiring = new MobileSocketWiring({ deviceRegistry: this.deviceRegistry, diff --git a/src/main/skills/skill-bundle-artifacts.test.ts b/src/main/skills/skill-bundle-artifacts.test.ts index f86d5c382d7..cf58caebe09 100644 --- a/src/main/skills/skill-bundle-artifacts.test.ts +++ b/src/main/skills/skill-bundle-artifacts.test.ts @@ -11,6 +11,48 @@ afterEach(async () => { }) describe('skill bundle artifacts', () => { + it('loads the committed schema-2 artifacts and rejects a stamped schema-1 manifest', async () => { + const resourceRoot = await mkdtemp(join(tmpdir(), 'orca-skill-artifacts-')) + temporaryDirectories.push(resourceRoot) + const target = join(resourceRoot, 'skills') + const source = resolve('resources', 'skills') + await mkdir(target, { recursive: true }) + const [manifest, registry, releaseMapping] = await Promise.all( + ['current-manifest.json', 'snapshot-registry.json', 'release-mapping.json'].map((name) => + readFile(join(source, name), 'utf8') + ) + ) + await Promise.all([ + writeFile(join(target, 'current-manifest.json'), manifest), + writeFile(join(target, 'snapshot-registry.json'), registry), + writeFile(join(target, 'release-mapping.json'), releaseMapping) + ]) + + const artifacts = await loadSkillBundleArtifacts(resourceRoot) + expect(artifacts.manifest.schemaVersion).toBe(2) + expect(artifacts.manifest.skills.length).toBeGreaterThan(0) + + const legacyRoot = await mkdtemp(join(tmpdir(), 'orca-skill-artifacts-')) + temporaryDirectories.push(legacyRoot) + const legacyTarget = join(legacyRoot, 'skills') + await mkdir(legacyTarget, { recursive: true }) + const legacyManifest = JSON.parse(manifest) + legacyManifest.schemaVersion = 1 + legacyManifest.appVersion = '1.0.0' + for (const skill of legacyManifest.skills) { + skill.appVersion = '1.0.0' + } + await Promise.all([ + writeFile(join(legacyTarget, 'current-manifest.json'), JSON.stringify(legacyManifest)), + writeFile(join(legacyTarget, 'snapshot-registry.json'), registry), + writeFile(join(legacyTarget, 'release-mapping.json'), releaseMapping) + ]) + + await expect(loadSkillBundleArtifacts(legacyRoot)).rejects.toThrow( + 'Invalid skill bundle manifest' + ) + }) + it('rejects malformed nested release entries before building provenance', async () => { const resourceRoot = await mkdtemp(join(tmpdir(), 'orca-skill-artifacts-')) temporaryDirectories.push(resourceRoot) diff --git a/src/main/skills/skill-bundle-artifacts.ts b/src/main/skills/skill-bundle-artifacts.ts index e7d403442b5..08e33a0eeba 100644 --- a/src/main/skills/skill-bundle-artifacts.ts +++ b/src/main/skills/skill-bundle-artifacts.ts @@ -41,14 +41,12 @@ const snapshotShape = { const knownSnapshotSchema = z.object(snapshotShape).strict() const manifestSchema = z .object({ - schemaVersion: z.literal(1), - appVersion: z.string().min(1), + schemaVersion: z.literal(2), skills: z.array( z .object({ name: z.string().regex(/^[a-z0-9][a-z0-9._-]*$/), sourcePath: z.string().min(1), - appVersion: z.string().min(1), ...snapshotShape }) .strict() @@ -126,7 +124,6 @@ async function readSkillBundleArtifacts(resourceRoot: string): Promise snapshot.releaseRevision === current.releaseRevision && @@ -137,6 +134,8 @@ async function readSkillBundleArtifacts(resourceRoot: string): Promise> = {} for (const release of releaseMapping.releases) { for (const [name, revision] of Object.entries(release.skills)) { @@ -147,10 +146,6 @@ async function readSkillBundleArtifacts(resourceRoot: string): Promise { await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown) const inventory = await inventorySkillFreshness({ + currentAppVersion: '2.0.0', homeDir: test.homeDir, repos: [], resourceRoot: test.resourceRoot @@ -130,6 +130,7 @@ describe('read-only skill freshness inventory', () => { ) const inventory = await inventorySkillFreshness({ + currentAppVersion: '2.0.0', homeDir: test.homeDir, repos: [], resourceRoot: test.resourceRoot @@ -151,6 +152,7 @@ describe('read-only skill freshness inventory', () => { ) const inventory = await inventorySkillFreshness({ + currentAppVersion: '2.0.0', homeDir: test.homeDir, repos: [], resourceRoot: test.resourceRoot @@ -176,6 +178,7 @@ describe('read-only skill freshness inventory', () => { await symlink(canonical, join(claudeRoot, 'orca-cli')) const inventory = await inventorySkillFreshness({ + currentAppVersion: '2.0.0', homeDir: test.homeDir, repos: [], resourceRoot: test.resourceRoot @@ -205,6 +208,7 @@ describe('read-only skill freshness inventory', () => { ) const inventory = await inventorySkillFreshness({ + currentAppVersion: '2.0.0', homeDir: test.homeDir, repos, resourceRoot: test.resourceRoot @@ -223,6 +227,7 @@ describe('read-only skill freshness inventory', () => { const inaccessiblePath = join(test.homeDir, '.codex', 'skills', 'orca-cli') const inventory = await inventorySkillFreshness({ + currentAppVersion: '2.0.0', homeDir: test.homeDir, repos: [], resourceRoot: test.resourceRoot, @@ -248,6 +253,7 @@ describe('read-only skill freshness inventory', () => { const inaccessiblePath = join(repoPath, '.agents', 'skills', 'orca-cli') const inventory = await inventorySkillFreshness({ + currentAppVersion: '2.0.0', homeDir: test.homeDir, repos: [{ id: 'repo', path: repoPath }] as unknown as Repo[], resourceRoot: test.resourceRoot, @@ -292,6 +298,7 @@ describe('read-only skill freshness inventory', () => { } const inventory = await inventorySkillFreshness({ + currentAppVersion: '2.0.0', homeDir: test.homeDir, repos, resourceRoot: test.resourceRoot @@ -310,6 +317,7 @@ describe('read-only skill freshness inventory', () => { ) const inventory = await inventorySkillFreshness({ + currentAppVersion: '2.0.0', homeDir: test.homeDir, repos: [], resourceRoot: test.resourceRoot @@ -326,7 +334,10 @@ describe('read-only skill freshness inventory', () => { await writeFile(registryPath, `${JSON.stringify(registry, null, 2)}\n`) await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.currentMarkdown) + // Why: the injected version deliberately differs from every mapping entry + // to prove current placements are labeled by the running build, not history. const inventory = await inventorySkillFreshness({ + currentAppVersion: '2.1.0-unreleased', homeDir: test.homeDir, repos: [], resourceRoot: test.resourceRoot @@ -335,7 +346,8 @@ describe('read-only skill freshness inventory', () => { expect(inventory.installations[0]).toMatchObject({ status: 'current', installedReleaseRevision: 2, - installedAppVersion: '2.0.0' + installedAppVersion: '2.1.0-unreleased', + currentAppVersion: '2.1.0-unreleased' }) }) @@ -348,6 +360,7 @@ describe('read-only skill freshness inventory', () => { ) const inventory = await inventorySkillFreshness({ + currentAppVersion: '2.0.0', homeDir: test.homeDir, repos, resourceRoot: test.resourceRoot diff --git a/src/main/skills/skill-freshness-inventory.ts b/src/main/skills/skill-freshness-inventory.ts index e5bbae31da9..8b1c5c19094 100644 --- a/src/main/skills/skill-freshness-inventory.ts +++ b/src/main/skills/skill-freshness-inventory.ts @@ -30,15 +30,16 @@ export function boundRepositorySkillRoots(roots: readonly SkillScanRoot[]): { } } -export async function inventorySkillFreshness( - args: { - homeDir?: string - cwd?: string - repos?: Repo[] - resourceRoot?: string - candidateLstat?: CandidateLstat - } = {} -): Promise { +export async function inventorySkillFreshness(args: { + // Why: the bundled artifacts are content-only; the running build supplies + // its own version here so current placements can be labeled honestly. + currentAppVersion: string + homeDir?: string + cwd?: string + repos?: Repo[] + resourceRoot?: string + candidateLstat?: CandidateLstat +}): Promise { const artifacts = await loadSkillBundleArtifacts(args.resourceRoot) const currentByName = new Map(artifacts.manifest.skills.map((skill) => [skill.name, skill])) const discoveryArgs = { @@ -65,6 +66,7 @@ export async function inventorySkillFreshness( classifyHomeSkillCandidate({ root, current, + currentAppVersion: args.currentAppVersion, artifacts, canonicalRootPath, candidateLstat: args.candidateLstat ?? ((path) => lstat(path)) @@ -84,6 +86,7 @@ export async function inventorySkillFreshness( classifyUnsupportedSkillCandidate({ root, current, + currentAppVersion: args.currentAppVersion, artifacts, unresolvedPath: join(root.path, current.name), candidateLstat @@ -99,6 +102,7 @@ export async function inventorySkillFreshness( (current) => () => observeSkillFreshnessInstallation({ current, + currentAppVersion: args.currentAppVersion, artifacts, rootId: 'repo-scan-limit', providers: [...new Set(omittedRepoRoots.flatMap((root) => root.providers))], @@ -128,6 +132,7 @@ export async function inventorySkillFreshness( classifyUnsupportedSkillCandidate({ root, current, + currentAppVersion: args.currentAppVersion, artifacts, unresolvedPath: candidate.path, candidateLstat @@ -142,6 +147,7 @@ export async function inventorySkillFreshness( (current) => () => observeSkillFreshnessInstallation({ current, + currentAppVersion: args.currentAppVersion, artifacts, rootId: root.id, providers: root.providers, diff --git a/src/main/skills/skill-freshness-placement-observation.ts b/src/main/skills/skill-freshness-placement-observation.ts index 2f8d32ca663..4218a4cc316 100644 --- a/src/main/skills/skill-freshness-placement-observation.ts +++ b/src/main/skills/skill-freshness-placement-observation.ts @@ -57,6 +57,7 @@ function knownSnapshots( export async function observeSkillFreshnessInstallation(args: { current: SkillCurrentBundleEntry + currentAppVersion: string artifacts: SkillBundleArtifacts rootId: string providers: SkillFreshnessInstallation['providers'] @@ -78,7 +79,7 @@ export async function observeSkillFreshnessInstallation(args: { topology: args.topology.topology, currentReleaseRevision: args.current.releaseRevision, currentPackageDigest: args.current.packageDigest, - currentAppVersion: args.current.appVersion, + currentAppVersion: args.currentAppVersion, errorCategory: args.topology.errorCategory } if (!args.topology.resolvedPath || !args.topology.identity) { @@ -105,9 +106,14 @@ export async function observeSkillFreshnessInstallation(args: { ...base, status: freshnessStatus(snapshot, args.current), installedReleaseRevision: snapshot?.releaseRevision ?? null, + // Why: the current revision may be unreleased (or trail the newest tag), + // so its label is the running build's version; only historical revisions + // resolve through the release mapping. installedAppVersion: snapshot - ? (args.artifacts.releasedAppVersions[args.current.name]?.[snapshot.releaseRevision] ?? - null) + ? snapshot.releaseRevision === args.current.releaseRevision + ? args.currentAppVersion + : (args.artifacts.releasedAppVersions[args.current.name]?.[snapshot.releaseRevision] ?? + null) : null, observedPackageDigest: observed.observedDigest } @@ -126,6 +132,7 @@ export async function observeSkillFreshnessInstallation(args: { export async function classifyHomeSkillCandidate(args: { root: SkillScanRoot current: SkillCurrentBundleEntry + currentAppVersion: string artifacts: SkillBundleArtifacts canonicalRootPath: string candidateLstat: CandidateLstat @@ -139,6 +146,7 @@ export async function classifyHomeSkillCandidate(args: { } return observeSkillFreshnessInstallation({ current: args.current, + currentAppVersion: args.currentAppVersion, artifacts: args.artifacts, rootId: args.root.id, providers: args.root.providers, @@ -167,6 +175,7 @@ export async function classifyHomeSkillCandidate(args: { } return observeSkillFreshnessInstallation({ current: args.current, + currentAppVersion: args.currentAppVersion, artifacts: args.artifacts, rootId: args.root.id, providers: args.root.providers, @@ -180,6 +189,7 @@ export async function classifyHomeSkillCandidate(args: { export async function classifyUnsupportedSkillCandidate(args: { root: SkillScanRoot current: SkillCurrentBundleEntry + currentAppVersion: string artifacts: SkillBundleArtifacts unresolvedPath: string candidateLstat: CandidateLstat @@ -192,6 +202,7 @@ export async function classifyUnsupportedSkillCandidate(args: { } return observeSkillFreshnessInstallation({ current: args.current, + currentAppVersion: args.currentAppVersion, artifacts: args.artifacts, rootId: args.root.id, providers: args.root.providers, @@ -208,6 +219,7 @@ export async function classifyUnsupportedSkillCandidate(args: { } return observeSkillFreshnessInstallation({ current: args.current, + currentAppVersion: args.currentAppVersion, artifacts: args.artifacts, rootId: args.root.id, providers: args.root.providers, diff --git a/src/main/ssh/ssh-connection.test.ts b/src/main/ssh/ssh-connection.test.ts index e8c5d71234c..3274d3f5daa 100644 --- a/src/main/ssh/ssh-connection.test.ts +++ b/src/main/ssh/ssh-connection.test.ts @@ -23,6 +23,7 @@ type MockSshClient = { lastExecCommand?: string lastConnectConfig?: unknown exec: (cmd: string, cb: (err: Error | undefined, channel: unknown) => void) => void + sftp: (cb: (err: Error | undefined, channel: unknown) => void) => void } let clientInstances: MockSshClient[] = [] @@ -690,15 +691,15 @@ describe('SshConnection', () => { vi.useFakeTimers() try { - const outcomePromise = conn - .exec('printf ready') - .then(() => 'opened') - .catch((error: Error) => error.message) + const outcomePromise = conn.exec('printf ready').catch((error: Error) => error) await vi.advanceTimersByTimeAsync(30_000) const outcome = await Promise.race([outcomePromise, Promise.resolve('pending')]) - expect(outcome).toBe('SSH exec channel timed out') + expect(outcome).toMatchObject({ + message: 'SSH exec channel timed out', + sshChannelCloseConfirmed: false + }) } finally { vi.useRealTimers() } @@ -784,15 +785,54 @@ describe('SshConnection', () => { try { const outcomePromise = conn .exec('printf ready', { signal: controller.signal }) - .then(() => 'opened') - .catch((error: Error) => error.name) + .catch((error: Error) => error) controller.abort() // Why: a hung socket must not pin the aborted caller for the full 30s // connect timeout — the abort settles at the 5s grace bound instead. await vi.advanceTimersByTimeAsync(5_000) - await expect(outcomePromise).resolves.toBe('AbortError') + await expect(outcomePromise).resolves.toMatchObject({ + name: 'AbortError', + sshChannelCloseConfirmed: false + }) + } finally { + vi.useRealTimers() + } + }) + + it('drains an exec channel that opens after the abort grace has settled', async () => { + const conn = new SshConnection(createTarget(), createCallbacks()) + await conn.connect() + execBehavior = 'pending' + const controller = new AbortController() + const lateChannel = Object.assign(new EventEmitter(), { + close: vi.fn(), + resume: vi.fn(), + stderr: { resume: vi.fn() } + }) + + vi.useFakeTimers() + try { + const outcomePromise = conn + .exec('printf ready', { signal: controller.signal }) + .catch((error: Error) => error) + + controller.abort() + await vi.advanceTimersByTimeAsync(5_000) + const outcome = await outcomePromise + expect(outcome).toMatchObject({ + name: 'AbortError', + sshChannelCloseConfirmed: false + }) + + pendingExecCallback?.(undefined, lateChannel) + + expect(lateChannel.resume).toHaveBeenCalledTimes(1) + expect(lateChannel.stderr.resume).toHaveBeenCalledTimes(1) + expect(lateChannel.close).toHaveBeenCalledTimes(1) + lateChannel.emit('close') + expect(outcome).toMatchObject({ sshChannelCloseConfirmed: true }) } finally { vi.useRealTimers() } @@ -837,7 +877,7 @@ describe('SshConnection', () => { const lateChannel = Object.assign(new EventEmitter(), { close: vi.fn(), resume: vi.fn(), - stderr: { resume: vi.fn() } + stderr: Object.assign(new EventEmitter(), { resume: vi.fn() }) }) const outcomePromise = conn @@ -855,11 +895,41 @@ describe('SshConnection', () => { expect(early).toBe('pending') expect(lateChannel.close).toHaveBeenCalledTimes(1) expect(lateChannel.resume).toHaveBeenCalled() + expect(() => lateChannel.emit('error', new Error('late channel teardown'))).not.toThrow() + expect(() => lateChannel.stderr.emit('error', new Error('late stderr teardown'))).not.toThrow() lateChannel.emit('close') await expect(outcomePromise).resolves.toBe('AbortError') }) + it('removes the late-channel close listener when abort grace expires', async () => { + const conn = new SshConnection(createTarget(), createCallbacks()) + await conn.connect() + vi.useFakeTimers() + try { + sftpBehavior = 'pending' + const controller = new AbortController() + const lateSftp = Object.assign(new EventEmitter(), { end: vi.fn() }) + + const outcomePromise = conn + .sftp(controller.signal) + .then(() => 'opened') + .catch((error: Error) => error.name) + await vi.advanceTimersByTimeAsync(0) + controller.abort() + pendingSftpCallback?.(undefined, lateSftp) + expect(lateSftp.listenerCount('close')).toBe(1) + expect(() => lateSftp.emit('error', new Error('late SFTP teardown'))).not.toThrow() + + await vi.advanceTimersByTimeAsync(5_000) + + await expect(outcomePromise).resolves.toBe('AbortError') + expect(lateSftp.listenerCount('close')).toBe(0) + } finally { + vi.useRealTimers() + } + }) + it('times out when ssh2 never opens an SFTP channel', async () => { const conn = new SshConnection(createTarget(), createCallbacks()) await conn.connect() @@ -867,15 +937,13 @@ describe('SshConnection', () => { vi.useFakeTimers() try { - const outcomePromise = conn - .sftp() - .then(() => 'opened') - .catch((error: Error) => error.message) + const outcomePromise = conn.sftp().catch((error: Error) => error) await vi.advanceTimersByTimeAsync(30_000) const outcome = await Promise.race([outcomePromise, Promise.resolve('pending')]) - expect(outcome).toBe('SSH SFTP channel timed out') + expect(outcome).toMatchObject({ message: 'SSH SFTP channel timed out' }) + expect(outcome).not.toHaveProperty('sshChannelCloseConfirmed') } finally { vi.useRealTimers() } @@ -1368,6 +1436,61 @@ describe('SshConnection', () => { ) }) + it('composes a caller abort into system SSH relay uploads', async () => { + vi.mocked(resolveWithSshG).mockResolvedValueOnce(createResolvedConfig()) + const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks()) + const controller = new AbortController() + let transferSignal: AbortSignal | undefined + vi.mocked(uploadDirectoryViaSystemSsh).mockImplementationOnce( + (_target, _localDir, _remoteDir, options) => { + transferSignal = options?.signal + return new Promise((_resolve, reject) => { + transferSignal?.addEventListener('abort', () => reject(transferSignal?.reason), { + once: true + }) + }) + } + ) + + await conn.connect() + const upload = conn.uploadDirectory('/tmp/local-relay', '/remote/relay', { + signal: controller.signal + }) + await vi.waitFor(() => expect(transferSignal).toBeDefined()) + controller.abort() + + await expect(upload).rejects.toMatchObject({ name: 'AbortError' }) + expect(transferSignal?.aborted).toBe(true) + }) + + it('keeps connection disconnect cancellation linked to caller-scoped relay writes', async () => { + vi.mocked(resolveWithSshG).mockResolvedValueOnce(createResolvedConfig()) + const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks()) + const controller = new AbortController() + let transferSignal: AbortSignal | undefined + vi.mocked(writeFileViaSystemSsh).mockImplementationOnce( + (_target, _remotePath, _contents, options) => { + transferSignal = options?.signal + return new Promise((_resolve, reject) => { + transferSignal?.addEventListener('abort', () => reject(transferSignal?.reason), { + once: true + }) + }) + } + ) + + await conn.connect() + const write = conn.writeFile('/remote/relay/.version', '0.1.0', { + signal: controller.signal + }) + await vi.waitFor(() => expect(transferSignal).toBeDefined()) + await conn.disconnect() + + await expect(write).rejects.toMatchObject({ name: 'AbortError' }) + expect(controller.signal.aborted).toBe(false) + expect(transferSignal?.aborted).toBe(true) + }) + it('keeps an upload session cancelled after the connection disconnects', async () => { const conn = new SshConnection( createTarget({ proxyCommand: 'ssh -W %h:%p bastion.example.com' }), diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index 2856d33b2f0..b8bd0cbd57f 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -41,6 +41,10 @@ import { import type { RemoteHostPlatform } from './ssh-remote-platform' import type { FileUploadSession } from '../providers/types' import { isSshSessionLimitError } from './ssh-session-limit-error' +import { + createLinkedSshFileTransferSignal, + raceSftpFileTransferWithAbort +} from './ssh-file-transfer-abort' export type { SshConnectionCallbacks } from './ssh-connection-utils' type SshRemoteFileOptions = { @@ -157,13 +161,14 @@ export class SshConnection { 'SSH exec channel timed out', (callback) => client.exec(remoteCommand, callback), (channel) => channel.close(), - options?.signal + options?.signal, + true ), options?.signal ) } - async sftp(): Promise { + async sftp(signal?: AbortSignal): Promise { if (this.useSystemSshTransport) { throw new Error('SFTP is not available when using system SSH transport') } @@ -171,12 +176,15 @@ export class SshConnection { throw new Error('Not connected') } const client = this.client - return this.openSessionChannelWithRetry(() => - this.waitForSshCallback( - 'SSH SFTP channel timed out', - (callback) => client.sftp(callback), - (sftp) => sftp.end() - ) + return this.openSessionChannelWithRetry( + () => + this.waitForSshCallback( + 'SSH SFTP channel timed out', + (callback) => client.sftp(callback), + (sftp) => sftp.end(), + signal + ), + signal ) } @@ -219,10 +227,20 @@ export class SshConnection { timeoutMessage: string, register: (callback: (error: Error | undefined, value: T) => void) => void, cleanupLateValue?: (value: T) => void, - signal?: AbortSignal + signal?: AbortSignal, + trackRemoteCommandTermination = false ): Promise { return new Promise((resolve, reject) => { + type ChannelOpenTerminationError = Error & { sshChannelCloseConfirmed: boolean } let settled = false + let unconfirmedOpenError: ChannelOpenTerminationError | null = null + const markOpenUnconfirmed = (error: Error): Error => { + if (!trackRemoteCommandTermination) { + return error + } + unconfirmedOpenError = Object.assign(error, { sshChannelCloseConfirmed: false }) + return unconfirmedOpenError + } // Why: rejecting the instant the signal aborts lets the caller proceed // while the in-flight channel open completes in the background and holds // a server-side session slot (MaxSessions). Mark the abort and settle @@ -241,16 +259,41 @@ export class SshConnection { abortDeadlineTimer = setTimeout(() => { settled = true cleanup() - reject(createSshOperationAbortError()) + reject(markOpenUnconfirmed(createSshOperationAbortError())) }, ABORTED_CHANNEL_CLOSE_GRACE_MS) } const timer = setTimeout(() => { settled = true cleanup() - reject(abortRequested ? createSshOperationAbortError() : new Error(timeoutMessage)) + reject( + markOpenUnconfirmed( + abortRequested ? createSshOperationAbortError() : new Error(timeoutMessage) + ) + ) }, CONNECT_TIMEOUT_MS) + const discardLateValue = (value: T, onClose?: () => void): void => { + const emitter = value as Partial & { + resume?: () => void + stderr?: Partial & { resume?: () => void } + } + const swallowLateError = (): void => {} + emitter.on?.('error', swallowLateError) + emitter.stderr?.on?.('error', swallowLateError) + if (onClose) { + emitter.once?.('close', onClose) + } + // Why: ssh2 can withhold CHANNEL_CLOSE while discarded exec streams + // remain unread, and teardown errors have no other owner. + emitter.resume?.() + emitter.stderr?.resume?.() + try { + cleanupLateValue?.(value) + } catch { + /* best effort */ + } + } const rejectAfterClose = (value: T): void => { - const abortError = createSshOperationAbortError() + const abortError = markOpenUnconfirmed(createSshOperationAbortError()) const emitter = value as Partial & { resume?: () => void stderr?: { resume?: () => void } @@ -262,23 +305,24 @@ export class SshConnection { } finished = true clearTimeout(closeGraceTimer) + emitter.removeListener?.('close', confirmAndDone) reject(abortError) } + const confirmAndDone = (): void => { + if (unconfirmedOpenError === abortError) { + unconfirmedOpenError.sshChannelCloseConfirmed = true + } + done() + } // Why: bounded — a remote that never confirms the close must not hang // the aborted operation forever. const closeGraceTimer = setTimeout(done, ABORTED_CHANNEL_CLOSE_GRACE_MS) if (typeof emitter.once === 'function') { - emitter.once('close', done) + emitter.once('close', confirmAndDone) } // Why: ssh2 withholds the 'close' event until the channel's streams // are drained; nobody else will ever read this discarded channel. - emitter.resume?.() - emitter.stderr?.resume?.() - try { - cleanupLateValue?.(value) - } catch { - /* best effort */ - } + discardLateValue(value) if (typeof emitter.once !== 'function') { done() } @@ -289,11 +333,11 @@ export class SshConnection { // 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 */ - } + discardLateValue(value, () => { + if (unconfirmedOpenError) { + unconfirmedOpenError.sshChannelCloseConfirmed = true + } + }) } return } @@ -334,23 +378,51 @@ export class SshConnection { async uploadDirectory( localDir: string, remoteDir: string, - options?: SshRemoteFileOptions + options?: SshRemoteFileOptions & { signal?: AbortSignal } ): Promise { - if (!this.useSystemSshTransport) { - const sftp = await this.sftp() - try { - const { uploadDirectory } = await import('./ssh-relay-deploy-helpers') - await uploadDirectory(sftp, localDir, remoteDir) - } finally { - sftp.end() + // Why: relay deployment timeout and connection teardown are independent; + // either owner must stop a transfer that can otherwise outlive its lock. + const linkedSignal = createLinkedSshFileTransferSignal( + [this.systemOperationAbortController.signal, options?.signal].filter( + (signal): signal is AbortSignal => signal !== undefined + ) + ) + try { + if (!this.useSystemSshTransport) { + const sftp = await this.sftp(linkedSignal.signal) + const swallowLateSftpError = (): void => {} + let sftpEndRequested = false + const endSftp = (): void => { + if (!sftpEndRequested) { + sftpEndRequested = true + sftp.end() + } + } + sftp.on('error', swallowLateSftpError) + sftp.once('close', () => sftp.removeListener('error', swallowLateSftpError)) + try { + const { uploadDirectory } = await import('./ssh-relay-deploy-helpers') + await raceSftpFileTransferWithAbort( + uploadDirectory(sftp, localDir, remoteDir), + linkedSignal.signal, + (onClose) => { + sftp.once('close', onClose) + endSftp() + } + ) + } finally { + endSftp() + } + return } - return + await uploadDirectoryViaSystemSsh(this.target, localDir, remoteDir, { + signal: linkedSignal.signal, + hostPlatform: options?.hostPlatform, + ...this.getSystemSshBuildArgsOptions() + }) + } finally { + linkedSignal.dispose() } - await uploadDirectoryViaSystemSsh(this.target, localDir, remoteDir, { - signal: this.systemOperationAbortController.signal, - hostPlatform: options?.hostPlatform, - ...this.getSystemSshBuildArgsOptions() - }) } async downloadFile( @@ -404,55 +476,74 @@ export class SshConnection { async writeFile( remotePath: string, contents: string, - options?: SshRemoteFileOptions + options?: SshRemoteFileOptions & { signal?: AbortSignal } ): Promise { - if (!this.useSystemSshTransport) { - const sftp = await this.sftp() - const swallowLateSftpError = (): void => {} - sftp.on('error', swallowLateSftpError) - try { - await new Promise((resolve, reject) => { - const ws = sftp.createWriteStream(remotePath) - let settled = false - const cleanup = (): void => { - ws.removeListener('close', onClose) - ws.removeListener('error', onError) + // Keep package/version writes under the same dual cancellation contract as uploads. + const linkedSignal = createLinkedSshFileTransferSignal( + [this.systemOperationAbortController.signal, options?.signal].filter( + (signal): signal is AbortSignal => signal !== undefined + ) + ) + try { + if (!this.useSystemSshTransport) { + const sftp = await this.sftp(linkedSignal.signal) + const swallowLateSftpError = (): void => {} + let sftpEndRequested = false + const endSftp = (): void => { + if (!sftpEndRequested) { + sftpEndRequested = true + sftp.end() } - const onClose = (): void => { - if (settled) { - return + } + sftp.on('error', swallowLateSftpError) + sftp.once('close', () => sftp.removeListener('error', swallowLateSftpError)) + try { + const write = new Promise((resolve, reject) => { + const ws = sftp.createWriteStream(remotePath) + let settled = false + const cleanup = (): void => { + sftp.removeListener('error', onError) + ws.removeListener('close', onClose) + ws.removeListener('error', onError) } - settled = true - cleanup() - resolve() - } - const onError = (err: Error): void => { - sftp.removeListener('error', onError) - if (settled) { - return + const onClose = (): void => { + if (settled) { + return + } + settled = true + cleanup() + resolve() } - settled = true - cleanup() - reject(err) - } - sftp.prependOnceListener('error', onError) - ws.once('close', onClose) - ws.once('error', onError) - ws.end(contents) - }) - } finally { - sftp.end() - setImmediate(() => { - sftp.removeListener('error', swallowLateSftpError) - }) + const onError = (err: Error): void => { + if (settled) { + return + } + settled = true + cleanup() + reject(err) + } + sftp.prependOnceListener('error', onError) + ws.once('close', onClose) + ws.once('error', onError) + ws.end(contents) + }) + await raceSftpFileTransferWithAbort(write, linkedSignal.signal, (onClose) => { + sftp.once('close', onClose) + endSftp() + }) + } finally { + endSftp() + } + return } - return + await writeFileViaSystemSsh(this.target, remotePath, contents, { + signal: linkedSignal.signal, + hostPlatform: options?.hostPlatform, + ...this.getSystemSshBuildArgsOptions() + }) + } finally { + linkedSignal.dispose() } - await writeFileViaSystemSsh(this.target, remotePath, contents, { - signal: this.systemOperationAbortController.signal, - hostPlatform: options?.hostPlatform, - ...this.getSystemSshBuildArgsOptions() - }) } async writeBuffer( diff --git a/src/main/ssh/ssh-file-transfer-abort.test.ts b/src/main/ssh/ssh-file-transfer-abort.test.ts new file mode 100644 index 00000000000..49abe0a1839 --- /dev/null +++ b/src/main/ssh/ssh-file-transfer-abort.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest' +import { raceSftpFileTransferWithAbort } from './ssh-file-transfer-abort' + +describe('raceSftpFileTransferWithAbort', () => { + it('waits for confirmed SFTP close before rejecting an abort', async () => { + const controller = new AbortController() + let confirmClose: () => void = () => {} + const promise = raceSftpFileTransferWithAbort( + new Promise(() => {}), + controller.signal, + (onClose) => { + confirmClose = onClose + } + ) + + controller.abort() + const pending = await Promise.race([ + promise.then( + () => 'settled', + () => 'settled' + ), + Promise.resolve('pending') + ]) + expect(pending).toBe('pending') + + confirmClose() + await expect(promise).rejects.toMatchObject({ + name: 'AbortError', + sshChannelCloseConfirmed: true + }) + }) + + it('marks teardown unconfirmed when SFTP never closes', async () => { + vi.useFakeTimers() + try { + const controller = new AbortController() + const promise = raceSftpFileTransferWithAbort( + new Promise(() => {}), + controller.signal, + () => {} + ) + const outcome = promise.catch((error: Error) => error) + + controller.abort() + await vi.advanceTimersByTimeAsync(5_000) + + await expect(outcome).resolves.toMatchObject({ + name: 'AbortError', + sshChannelCloseConfirmed: false + }) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/ssh/ssh-file-transfer-abort.ts b/src/main/ssh/ssh-file-transfer-abort.ts new file mode 100644 index 00000000000..a7b6cce2e49 --- /dev/null +++ b/src/main/ssh/ssh-file-transfer-abort.ts @@ -0,0 +1,76 @@ +import { createSshOperationAbortError } from './ssh-connection-utils' + +export function createLinkedSshFileTransferSignal(signals: readonly AbortSignal[]): { + signal: AbortSignal + dispose: () => void +} { + const controller = new AbortController() + const listeners = signals.map((signal) => { + const listener = (): void => controller.abort(signal.reason) + if (signal.aborted) { + listener() + } else { + signal.addEventListener('abort', listener, { once: true }) + } + return { signal, listener } + }) + return { + signal: controller.signal, + dispose: () => { + for (const { signal, listener } of listeners) { + signal.removeEventListener('abort', listener) + } + } + } +} + +export function raceSftpFileTransferWithAbort( + operation: Promise, + signal: AbortSignal, + closeSftp: (onClose: () => void) => void +): Promise { + return new Promise((resolve, reject) => { + let settled = false + let abortError: (Error & { sshChannelCloseConfirmed: boolean }) | null = null + let closeGraceTimer: ReturnType | null = null + const settle = (fn: typeof resolve | typeof reject, value: T | Error): void => { + if (settled) { + return + } + settled = true + if (closeGraceTimer) { + clearTimeout(closeGraceTimer) + } + signal.removeEventListener('abort', onAbort) + fn(value as never) + } + const onAbort = (): void => { + // Why: rejecting the caller is insufficient; ending SFTP stops the + // abandoned transfer from mutating a successor relay install. + abortError = Object.assign(createSshOperationAbortError(), { + sshChannelCloseConfirmed: false + }) + closeGraceTimer = setTimeout(() => settle(reject, abortError!), 5_000) + closeSftp(() => { + abortError!.sshChannelCloseConfirmed = true + settle(reject, abortError!) + }) + } + signal.addEventListener('abort', onAbort, { once: true }) + void operation.then( + (value) => { + if (!abortError) { + settle(resolve, value) + } + }, + (error: unknown) => { + if (!abortError) { + settle(reject, error instanceof Error ? error : new Error(String(error))) + } + } + ) + if (signal.aborted) { + onAbort() + } + }) +} diff --git a/src/main/ssh/ssh-relay-build-toolchain.ts b/src/main/ssh/ssh-relay-build-toolchain.ts index cdbdb676441..208deceb6e0 100644 --- a/src/main/ssh/ssh-relay-build-toolchain.ts +++ b/src/main/ssh/ssh-relay-build-toolchain.ts @@ -141,15 +141,20 @@ export function formatMissingToolchainError( // callers fall back to the original install error in those cases. export async function probeBuildToolchain( conn: SshConnection, - hostPlatform: RemoteHostPlatform + hostPlatform: RemoteHostPlatform, + signal?: AbortSignal ): Promise { if (isWindowsRemoteHost(hostPlatform)) { return null } try { - const output = await execCommand(conn, buildToolchainProbeCommand(), { wrapCommand: true }) + const output = await execCommand(conn, buildToolchainProbeCommand(), { + wrapCommand: true, + signal + }) return parseBuildToolchainProbe(output) } catch { + signal?.throwIfAborted() return null } } diff --git a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts index a29801c3632..14f063e3bf1 100644 --- a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts +++ b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts @@ -33,6 +33,9 @@ vi.mock('./ssh-relay-deploy-helpers', () => ({ onData: vi.fn(), onClose: vi.fn() }), + isUnconfirmedSshCommandTermination: (error: unknown) => + error instanceof Error && + (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, execCommand: vi.fn() })) @@ -101,8 +104,10 @@ describe('cross-version isolation', () => { '__ORCA_REMOTE_PLATFORM__ Linux x86_64', // tagged POSIX platform probe '/home/u', // echo $HOME 'MISSING', // isRelayAlreadyInstalled (v2 dir doesn't exist) + 'OPEN', // no sibling GC claim '', // mkdir -p remoteRelayDir (v2) 'OK', // mkdir lock OK + 'OPEN', // GC did not claim while the install lock was acquired 'MISSING', // re-probe after lock → still missing → proceed with install '', // mkdir remoteDir (uploadRelay) '', // chmod +x node @@ -111,9 +116,9 @@ describe('cross-version isolation', () => { 'ORCA-NPTY-PROBE-OK\n', // node -e require() load-test (post-install verify) '', // rm -f probe-stderr (best-effort cleanup after probe resolved) '', // touch .install-complete (finalizeInstall) - '', // rm -rf .install-lock 'DEAD', // launch socket probe 'READY', // socket poll + '', // release .install-lock after relay liveness is observable // GC scan begins here 'relay-0.1.0+v1hash\nrelay-0.1.0+v2hash\n', // ls listing 'OPEN', // v1 lock probe (siblings only — current dir is v2) diff --git a/src/main/ssh/ssh-relay-deploy-helpers.test.ts b/src/main/ssh/ssh-relay-deploy-helpers.test.ts index cb5477091d6..4df9c8ad28a 100644 --- a/src/main/ssh/ssh-relay-deploy-helpers.test.ts +++ b/src/main/ssh/ssh-relay-deploy-helpers.test.ts @@ -11,9 +11,10 @@ import { function createMockChannel(): ClientChannel { return Object.assign(new EventEmitter(), { - stderr: new EventEmitter(), + stderr: Object.assign(new EventEmitter(), { resume: vi.fn() }), stdin: { write: vi.fn() }, - close: vi.fn() + close: vi.fn(), + resume: vi.fn() }) as unknown as ClientChannel } @@ -29,6 +30,44 @@ async function execCommandRejection(promise: Promise): Promise { } describe('waitForSentinel', () => { + it('closes and rejects with AbortError while clearing startup resources on abort', async () => { + vi.useFakeTimers() + try { + const channel = createMockChannel() + const controller = new AbortController() + const removeListener = vi.spyOn(controller.signal, 'removeEventListener') + const transportPromise = waitForSentinel(channel, controller.signal) + + expect(vi.getTimerCount()).toBe(1) + controller.abort() + + await expect(transportPromise).rejects.toMatchObject({ name: 'AbortError' }) + expect(channel.close).toHaveBeenCalledTimes(1) + expect(removeListener).toHaveBeenCalledWith('abort', expect.any(Function)) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('removes the abort listener and timers when startup fails before the sentinel', async () => { + vi.useFakeTimers() + try { + const channel = createMockChannel() + const controller = new AbortController() + const removeListener = vi.spyOn(controller.signal, 'removeEventListener') + const transportPromise = waitForSentinel(channel, controller.signal) + + channel.emit('error', new Error('remote host rebooted')) + + await expect(transportPromise).rejects.toThrow('remote host rebooted') + expect(removeListener).toHaveBeenCalledWith('abort', expect.any(Function)) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + it('buffers post-sentinel chunks until the transport subscribes', async () => { const channel = createMockChannel() const transportPromise = waitForSentinel(channel) @@ -168,6 +207,32 @@ describe('waitForSentinel', () => { }) describe('execCommand', () => { + it('waits for channel close before rejecting a timed-out remote command', async () => { + vi.useFakeTimers() + try { + const channel = createMockChannel() + const conn = { exec: vi.fn().mockResolvedValue(channel) } + const commandPromise = execCommand(conn as never, 'npm rebuild', { timeoutMs: 1_000 }) + + await vi.advanceTimersByTimeAsync(1_000) + expect(channel.close).toHaveBeenCalledTimes(1) + expect( + await Promise.race([ + commandPromise.then( + () => 'settled', + () => 'settled' + ), + Promise.resolve('pending') + ]) + ).toBe('pending') + + channel.emit('close', 0) + await expect(commandPromise).rejects.toThrow('timed out after 1s') + } finally { + vi.useRealTimers() + } + }) + it('rejects on command channel errors instead of emitting uncaught errors', async () => { const channel = createMockChannel() const conn = { @@ -177,6 +242,8 @@ describe('execCommand', () => { await Promise.resolve() expect(() => channel.emit('error', new Error('remote host rebooted'))).not.toThrow() + expect(channel.close).toHaveBeenCalledOnce() + channel.emit('close', 1) await expect(commandPromise).rejects.toThrow('remote host rebooted') expect(channel.listenerCount('error')).toBe(0) expect(channel.listenerCount('data')).toBe(0) @@ -224,6 +291,22 @@ describe('execCommand', () => { expect(error.message).toContain('gnu++20') }) + it('bounds command output while preserving the actionable tail', async () => { + const channel = createMockChannel() + const conn = { exec: vi.fn().mockResolvedValue(channel) } + const commandPromise = execCommand(conn as never, 'npm rebuild') + + await Promise.resolve() + channel.emit('data', Buffer.from(`old-prefix-${'x'.repeat(1024 * 1024)}`)) + channel.emit('data', Buffer.from('gyp ERR! tail diagnosis')) + channel.emit('close', 1) + + const error = await execCommandRejection(commandPromise) + expect(error.message).not.toContain('old-prefix') + expect(error.message).toContain('gyp ERR! tail diagnosis') + expect(error.message.length).toBeLessThan(1024 * 1024 + 200) + }) + it('keeps the merged error message greppable by the build-toolchain probe', async () => { const channel = createMockChannel() const conn = { @@ -260,14 +343,26 @@ describe('execCommand', () => { await Promise.resolve() const rejection = expect(commandPromise).rejects.toThrow('timed out') await vi.advanceTimersByTimeAsync(30_000) + expect(channel.close).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(5_000) await rejection expect(channel.close).toHaveBeenCalledOnce() - expect(channel.listenerCount('error')).toBe(0) + expect(channel.resume).toHaveBeenCalledOnce() + expect(channel.stderr.resume).toHaveBeenCalledOnce() + expect(() => channel.emit('error', new Error('late channel error'))).not.toThrow() + expect(() => channel.stderr.emit('error', new Error('late stderr error'))).not.toThrow() + channel.emit('data', Buffer.from('late stdout')) + channel.stderr.emit('data', Buffer.from('late stderr')) + expect(channel.listenerCount('error')).toBe(1) expect(channel.listenerCount('data')).toBe(0) + expect(channel.listenerCount('close')).toBe(1) + expect(channel.stderr.listenerCount('error')).toBe(1) + expect(channel.stderr.listenerCount('data')).toBe(0) + channel.emit('close', 0) + expect(channel.listenerCount('error')).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() } @@ -309,6 +404,48 @@ describe('execCommand', () => { expect(channel.stderr.listenerCount('data')).toBe(0) }) + it('keeps system-SSH command termination unconfirmed after the local child closes', async () => { + const channel = createMockChannel() + const controller = new AbortController() + const conn = { + exec: vi.fn().mockResolvedValue(channel), + usesSystemSshTransport: vi.fn().mockReturnValueOnce(true).mockReturnValue(false) + } + const commandPromise = execCommand(conn as never, 'npm install', { + signal: controller.signal + }) + + await Promise.resolve() + controller.abort() + channel.emit('close', 0) + + await expect(commandPromise).rejects.toMatchObject({ + name: 'AbortError', + sshChannelCloseConfirmed: false + }) + }) + + it('tags an externally closed system-SSH command as unconfirmed', async () => { + const channel = createMockChannel() as ClientChannel & { _closeRequested?: boolean } + channel.close = vi.fn(() => { + channel._closeRequested = true + }) + const conn = { + exec: vi.fn().mockResolvedValue(channel), + usesSystemSshTransport: vi.fn().mockReturnValue(true) + } + const commandPromise = execCommand(conn as never, 'npm install') + + await Promise.resolve() + channel.close() + channel.emit('close', 1) + + await expect(commandPromise).rejects.toMatchObject({ + name: 'AbortError', + sshChannelCloseConfirmed: false + }) + }) + it('handles aborts that happen while the SSH exec channel is still opening', async () => { const channel = createMockChannel() const controller = new AbortController() @@ -357,6 +494,8 @@ describe('execCommand', () => { expect(conn.exec).toHaveBeenCalledWith('npm install', { wrapCommand: false }) const rejection = expect(commandPromise).rejects.toThrow('timed out after 240s') await vi.advanceTimersByTimeAsync(240_000) + expect(channel.close).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(5_000) await rejection expect(channel.close).toHaveBeenCalledOnce() diff --git a/src/main/ssh/ssh-relay-deploy-helpers.ts b/src/main/ssh/ssh-relay-deploy-helpers.ts index 1b1cdd27074..80d2b78ced2 100644 --- a/src/main/ssh/ssh-relay-deploy-helpers.ts +++ b/src/main/ssh/ssh-relay-deploy-helpers.ts @@ -1,18 +1,21 @@ import type { ClientChannel } from 'ssh2' -import type { SshConnection } from './ssh-connection' -import { createSshOperationAbortError, type SshExecOptions } from './ssh-connection-utils' +import { createSshOperationAbortError } from './ssh-connection-utils' import { RELAY_SENTINEL, RELAY_SENTINEL_TIMEOUT_MS } from './relay-protocol' import type { MultiplexerTransport } from './ssh-channel-multiplexer' import { buildRelayVersionMismatchError } from './ssh-relay-handshake-mismatch' export { uploadFile, uploadDirectory, mkdirSftp } from './sftp-upload' +export { execCommand, isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' // ── 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 { +export function waitForSentinel( + channel: ClientChannel, + signal?: AbortSignal +): Promise { return new Promise((resolve, reject) => { let sentinelReceived = false let settled = false @@ -39,17 +42,14 @@ export function waitForSentinel(channel: ClientChannel): Promise { timeoutFired = true - channel.close() timeoutGraceTimer = setTimeout(() => { - if (!settled) { - settled = true - reject( - new Error( - `Relay failed to start within ${RELAY_SENTINEL_TIMEOUT_MS / 1000}s.${stderrOutput ? ` stderr: ${stderrOutput.trim()}` : ''}` - ) + rejectStartup( + new Error( + `Relay failed to start within ${RELAY_SENTINEL_TIMEOUT_MS / 1000}s.${stderrOutput ? ` stderr: ${stderrOutput.trim()}` : ''}` ) - } + ) }, TIMEOUT_GRACE_MS) + channel.close() }, RELAY_SENTINEL_TIMEOUT_MS) const cancelTimers = (): void => { @@ -59,6 +59,31 @@ export function waitForSentinel(channel: ClientChannel): Promise { + cancelTimers() + signal?.removeEventListener('abort', onAbort) + } + const rejectStartup = (err: Error): void => { + if (settled) { + return + } + settled = true + cleanupStartup() + reject(err) + } + const onAbort = (): void => { + if (settled) { + return + } + settled = true + cleanupStartup() + channel.close() + reject(createSshOperationAbortError()) + } + signal?.addEventListener('abort', onAbort, { once: true }) + if (signal?.aborted) { + onAbort() + } channel.on('exit', (code: number | null) => { if (typeof code === 'number') { @@ -87,12 +112,8 @@ export function waitForSentinel(channel: ClientChannel): Promise { - cancelTimers() if (!sentinelReceived) { - if (!settled) { - settled = true - reject(err) - } + rejectStartup(err) return } notifyClosed() @@ -113,9 +134,7 @@ export function waitForSentinel(channel: ClientChannel): Promise { if (!sentinelReceived) { - cancelTimers() if (!settled) { - settled = true // Why: a wire-handshake mismatch on the daemon side closes the // socket; --connect prints the mismatch detail to stderr and exits // with code 42 BEFORE writing the sentinel. Translate that into a @@ -126,13 +145,13 @@ export function waitForSentinel(channel: ClientChannel): Promise 0) { pendingAfterSentinel = afterSentinel } - settled = true - const transport: MultiplexerTransport = { write: (buf: Buffer) => channel.stdin.write(buf), onData: (cb) => { @@ -233,97 +251,3 @@ export function waitForSentinel(channel: ClientChannel): Promise { - const { timeoutMs = EXEC_TIMEOUT_MS, ...execOptions } = options ?? {} - const signal = options?.signal - if (signal?.aborted) { - throw createSshOperationAbortError() - } - const channel = await conn.exec(command, execOptions) - return new Promise((resolve, reject) => { - let stdout = '' - let stderr = '' - let settled = false - - const cleanup = (): void => { - clearTimeout(timeout) - signal?.removeEventListener('abort', onAbort) - 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 - cleanup() - fn(val as never) - } - const fail = (err: Error): void => { - settle(reject, abortRequested ? createSshOperationAbortError() : err) - } - // Why: sshd counts the session against MaxSessions until CHANNEL_CLOSE - // completes. Settling on abort before the channel actually closes lets the - // concurrent-bootstrap sequential fallback reissue an exec while the slot - // is still held, so it gets refused again. Close and settle from onClose. - let abortRequested = false - const onAbort = (): void => { - abortRequested = true - channel.close() - } - 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 (abortRequested) { - settle(reject, createSshOperationAbortError()) - } else if (code !== 0) { - // Why: on the system-ssh transport channel.stderr carries local OpenSSH - // client noise; preferring it masks the real failure in stdout (2>&1). - const output = [stderr.trim(), stdout.trim()].filter(Boolean).join('\n') - settle(reject, new Error(`Command "${command}" failed (exit ${code}): ${output}`)) - } else { - settle(resolve, stdout) - } - } - const timeout = setTimeout(() => { - channel.close() - settle( - reject, - abortRequested - ? createSshOperationAbortError() - : new Error(`Command "${command}" timed out after ${timeoutMs / 1000}s`) - ) - }, timeoutMs) - - // Why: remote reboot tears down exec channels with stream errors. Without - // scoped listeners, Node treats those as uncaught exceptions. - signal?.addEventListener('abort', onAbort, { once: true }) - channel.on('error', fail) - channel.stderr.on('error', fail) - channel.on('data', onStdoutData) - channel.stderr.on('data', onStderrData) - channel.on('close', onClose) - if (signal?.aborted) { - onAbort() - } - }) -} diff --git a/src/main/ssh/ssh-relay-deploy-timing.ts b/src/main/ssh/ssh-relay-deploy-timing.ts new file mode 100644 index 00000000000..5dc24f937f8 --- /dev/null +++ b/src/main/ssh/ssh-relay-deploy-timing.ts @@ -0,0 +1,13 @@ +// npm install on a cold Windows cache plus antivirus scanning can exceed the +// default 30s exec timeout. +export const NATIVE_DEPS_COMMAND_TIMEOUT_MS = 240_000 + +// Why: a missing binding can require both install and rebuild while the same +// install lock is held. Concurrent first installs must wait for that valid +// holder instead of failing halfway through its bounded work. +const NATIVE_DEPS_REPAIR_BUDGET_MS = 2 * NATIVE_DEPS_COMMAND_TIMEOUT_MS + +// Why: native repair also runs separately bounded chmod/probe/diagnostic/ +// finalize commands around install + rebuild. Keep the outer bound above that +// valid worst path while remaining below the 20-minute stale-lock threshold. +export const RELAY_DEPLOY_TIMEOUT_MS = NATIVE_DEPS_REPAIR_BUDGET_MS + 7 * 60_000 diff --git a/src/main/ssh/ssh-relay-deploy.test.ts b/src/main/ssh/ssh-relay-deploy.test.ts index 33467819035..28fe2a3e49c 100644 --- a/src/main/ssh/ssh-relay-deploy.test.ts +++ b/src/main/ssh/ssh-relay-deploy.test.ts @@ -42,6 +42,9 @@ vi.mock('./ssh-relay-deploy-helpers', () => ({ onData: vi.fn(), onClose: vi.fn() }), + isUnconfirmedSshCommandTermination: (error: unknown) => + error instanceof Error && + (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, execCommand: vi.fn().mockResolvedValue('__ORCA_REMOTE_PLATFORM__ Linux x86_64') })) @@ -49,27 +52,38 @@ vi.mock('./ssh-remote-node-resolution', () => ({ resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') })) -// Why: the versioned-install module shells out to the remote for install -// state, lock acquisition, and GC. Tests stub these to no-ops so the deploy -// happy-path is exercised without a real SSH connection. +// Why: the versioned-install modules shell out for install state, locking, +// and GC. Stub them so deploy tests need no real SSH connection. vi.mock('./ssh-relay-versioned-install', () => ({ readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+abcdef012345'), computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, isRelayAlreadyInstalled: vi.fn().mockResolvedValue(true), - acquireInstallLock: vi.fn().mockResolvedValue(undefined), finalizeInstall: vi.fn().mockResolvedValue(undefined), abandonInstall: vi.fn().mockResolvedValue(undefined), gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) })) +vi.mock('./ssh-relay-install-lock', () => ({ + acquireInstallLock: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('./ssh-relay-repair-lock', () => ({ + tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') +})) + vi.mock('./ssh-connection-utils', () => ({ - shellEscape: (s: string) => `'${s}'` + shellEscape: (s: string) => `'${s}'`, + createSshOperationAbortError: () => + Object.assign(new Error('SSH operation was cancelled'), { + name: 'AbortError' + }) })) import { deployAndLaunchRelay } from './ssh-relay-deploy' import { execCommand, waitForSentinel } from './ssh-relay-deploy-helpers' import { resolveRemoteNodePath } from './ssh-remote-node-resolution' import { isRelayAlreadyInstalled } from './ssh-relay-versioned-install' +import { acquireInstallLock } from './ssh-relay-install-lock' import type { SshConnection } from './ssh-connection' import type * as SshRemoteNodeResolution from './ssh-remote-node-resolution' import { @@ -133,7 +147,8 @@ describe('deployAndLaunchRelay', () => { expect(mockExecCommand).toHaveBeenCalledWith( conn, - "printf '\\n%s ' '__ORCA_REMOTE_PLATFORM__'; uname -sm" + "printf '\\n%s ' '__ORCA_REMOTE_PLATFORM__'; uname -sm", + expect.objectContaining({ signal: expect.any(AbortSignal) }) ) }) @@ -153,6 +168,26 @@ describe('deployAndLaunchRelay', () => { expect(progress).toContain('Starting relay...') }) + it('does not launch fresh after unconfirmed stale-socket cleanup', async () => { + const conn = makeMockConnection() + const unconfirmedCleanup = Object.assign(new Error('socket cleanup still running'), { + sshChannelCloseConfirmed: false + }) + vi.mocked(waitForSentinel).mockRejectedValueOnce(new Error('stale relay reconnect failed')) + vi.mocked(execCommand) + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') + .mockResolvedValueOnce('/home/user') + .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') + .mockResolvedValueOnce('ALIVE') + .mockRejectedValueOnce(unconfirmedCleanup) + + await expect(deployAndLaunchRelay(conn)).rejects.toBe(unconfirmedCleanup) + + const commands = vi.mocked(conn.exec).mock.calls.map(([command]) => command) + expect(commands).toHaveLength(1) + expect(commands.some((command) => command.includes('--detached'))).toBe(false) + }) + it('resolves the remote node path once per deploy', async () => { const conn = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) @@ -202,7 +237,7 @@ describe('deployAndLaunchRelay', () => { assertionError = err } finally { // Drain the rest of the happy path so a failed assertion does not leave - // the deploy promise pending until its 300s timeout. + // the deploy promise pending until the overall deploy timeout. mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe mockExecCommand.mockResolvedValueOnce('DEAD') // socket probe mockExecCommand.mockResolvedValueOnce('READY') // socket poll @@ -287,7 +322,10 @@ describe('deployAndLaunchRelay', () => { await deployAndLaunchRelay(conn) - expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(2) + expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(3) + expect(vi.mocked(isRelayAlreadyInstalled).mock.calls[2]?.[3]).toMatchObject({ + rethrowSessionLimitErrors: true + }) expect(resolveRemoteNodePath).toHaveBeenCalledTimes(2) }) @@ -314,11 +352,17 @@ describe('deployAndLaunchRelay', () => { await deployAndLaunchRelay(conn) - expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(2) + expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(3) expect(vi.mocked(isRelayAlreadyInstalled).mock.calls[0]?.[3]).toMatchObject({ rethrowSessionLimitErrors: true }) - expect(vi.mocked(isRelayAlreadyInstalled).mock.calls[1]?.[3]).toBeUndefined() + expect(vi.mocked(isRelayAlreadyInstalled).mock.calls[1]?.[3]).toMatchObject({ + rethrowSessionLimitErrors: undefined, + signal: expect.any(AbortSignal) + }) + expect(vi.mocked(isRelayAlreadyInstalled).mock.calls[2]?.[3]).toMatchObject({ + rethrowSessionLimitErrors: true + }) expect(resolveRemoteNodePath).toHaveBeenCalledTimes(2) }) @@ -499,7 +543,10 @@ describe('deployAndLaunchRelay', () => { expect(sawLegacyDir).toBe(false) }) - it('has a 300-second overall timeout', async () => { + it('bounds the overall deploy so install + rebuild both fit under the timeout', async () => { + // Why: the outer bound must exceed the worst-case sequential native-deps + // work — a first install (240s) AND a follow-up rebuild (240s) — so a + // legitimate install-then-rebuild is not falsely timed out mid-repair. const conn = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) @@ -511,15 +558,140 @@ describe('deployAndLaunchRelay', () => { // Catch the rejection immediately to avoid unhandled rejection warning const promise = deployAndLaunchRelay(conn).catch((err: Error) => err) + // Not timed out yet at the old 300s bound (install + rebuild need more). await vi.advanceTimersByTimeAsync(301_000) + expect(await Promise.race([promise, Promise.resolve('pending')])).toBe('pending') + + await vi.advanceTimersByTimeAsync(600_000) const result = await promise expect(result).toBeInstanceOf(Error) - expect((result as Error).message).toBe('Relay deployment timed out after 300s') + expect((result as Error).message).toBe('Relay deployment timed out after 900s') vi.useRealTimers() }) + it('aborts a contended install-lock wait at the overall deploy timeout', async () => { + vi.useFakeTimers() + try { + const conn = makeMockConnection() + vi.mocked(execCommand) + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') + .mockResolvedValueOnce('/home/user') + vi.mocked(isRelayAlreadyInstalled).mockResolvedValueOnce(false) + let lockSignal: AbortSignal | undefined + vi.mocked(acquireInstallLock).mockImplementationOnce((_conn, _dir, _host, options) => { + lockSignal = options?.signal + return new Promise((_resolve, reject) => { + lockSignal?.addEventListener('abort', () => reject(lockSignal?.reason), { once: true }) + }) + }) + + const promise = deployAndLaunchRelay(conn).catch((err: Error) => err) + await vi.advanceTimersByTimeAsync(0) + expect(acquireInstallLock).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(900_000) + + const result = await promise + expect(result).toBeInstanceOf(Error) + expect((result as Error).message).toBe('Relay deployment timed out after 900s') + expect(lockSignal?.aborted).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('aborts an in-progress relay upload at the overall deploy timeout', async () => { + vi.useFakeTimers() + try { + const conn = makeMockConnection() + vi.mocked(execCommand) + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') + .mockResolvedValueOnce('/home/user') + .mockResolvedValueOnce('') // mkdir remote relay dir + vi.mocked(isRelayAlreadyInstalled).mockResolvedValueOnce(false).mockResolvedValueOnce(false) + let uploadSignal: AbortSignal | undefined + conn.uploadDirectory = vi.fn((_localDir, _remoteDir, options) => { + uploadSignal = options?.signal + return new Promise((_resolve, reject) => { + uploadSignal?.addEventListener('abort', () => reject(uploadSignal?.reason), { + once: true + }) + }) + }) + + const promise = deployAndLaunchRelay(conn).catch((err: Error) => err) + await vi.advanceTimersByTimeAsync(0) + expect(conn.uploadDirectory).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(900_000) + + const result = await promise + expect(result).toBeInstanceOf(Error) + expect((result as Error).message).toBe('Relay deployment timed out after 900s') + expect(uploadSignal?.aborted).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('aborts a launch started near the deploy deadline and closes its channel once', async () => { + vi.useFakeTimers() + try { + const launchChannel = { + on: vi.fn(), + stderr: { on: vi.fn() }, + stdin: {}, + stdout: { on: vi.fn() }, + close: vi.fn() + } + const conn = makeMockConnection() + vi.mocked(conn.exec).mockResolvedValue(launchChannel as never) + const mockExecCommand = vi.mocked(execCommand) + mockExecCommand + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') + .mockResolvedValueOnce('/home/user') + .mockImplementationOnce( + () => + new Promise((resolve) => + setTimeout(() => resolve('ORCA-NATIVE-DEPS-OK'), 899_900) + ) + ) + .mockResolvedValueOnce('DEAD') + .mockImplementationOnce((_conn, _command, options) => { + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener( + 'abort', + () => { + const error = new Error('SSH operation was cancelled') + error.name = 'AbortError' + reject(error) + }, + { once: true } + ) + }) + }) + + const promise = deployAndLaunchRelay(conn).catch((err: Error) => err) + await vi.advanceTimersByTimeAsync(899_900) + expect(conn.exec).toHaveBeenCalledTimes(1) + expect(launchChannel.close).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(100) + + const result = await promise + expect(result).toBeInstanceOf(Error) + expect((result as Error).message).toBe('Relay deployment timed out after 900s') + expect(launchChannel.close).toHaveBeenCalledTimes(1) + expect(mockExecCommand).toHaveBeenCalledTimes(5) + await vi.advanceTimersByTimeAsync(10_000) + expect(mockExecCommand).toHaveBeenCalledTimes(5) + } finally { + vi.useRealTimers() + } + }) + it('uses distinct target-specific relay socket paths', async () => { const connA = makeMockConnection() const connB = makeMockConnection() @@ -596,6 +768,20 @@ describe('deployAndLaunchRelay', () => { expect(launchScript).not.toContain('\\\\.\\pipe\\agent-hooks') const waitScript = decodedScripts.find((script) => script.includes('deadline=Date.now()')) ?? '' expect(waitScript).toContain('setTimeout(attempt,intervalMs)') + const windowsLaunchCalls = mockExecCommand.mock.calls.filter(([, command]) => { + const script = decodePowerShellCommand(command) + return ( + script?.includes('.windows-active-pipe') || + script?.includes('Invoke-CimMethod') || + script?.includes('deadline=Date.now()') + ) + }) + expect(windowsLaunchCalls.length).toBeGreaterThan(0) + expect( + windowsLaunchCalls.every(([, , options]) => options?.signal instanceof AbortSignal) + ).toBe(true) + expect(vi.mocked(conn.exec).mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal) + expect(vi.mocked(waitForSentinel).mock.calls[0]?.[1]).toBeInstanceOf(AbortSignal) }) it('relaunches Windows remotes on a fallback pipe when reconnecting the occupied pipe fails', async () => { diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index 26501a8371b..56779b87f0f 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -8,18 +8,30 @@ import { app } from 'electron' import type { SshConnection } from './ssh-connection' import type { RelayPlatform } from './relay-protocol' import type { MultiplexerTransport } from './ssh-channel-multiplexer' -import { uploadDirectory, waitForSentinel, execCommand } from './ssh-relay-deploy-helpers' +import { + uploadDirectory, + waitForSentinel, + execCommand, + isUnconfirmedSshCommandTermination +} from './ssh-relay-deploy-helpers' import { resolveRemoteNodePath } from './ssh-remote-node-resolution' import { readLocalFullVersion, computeRemoteRelayDir, isRelayAlreadyInstalled, - acquireInstallLock, finalizeInstall, abandonInstall, gcOldRelayVersions } from './ssh-relay-versioned-install' -import { shellEscape } from './ssh-connection-utils' +import { acquireInstallLock } from './ssh-relay-install-lock' +import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock' +import { + releaseRelayGcClaimWithRetry, + tryAcquireRelayGcClaim, + waitForRelayGcClaimRelease +} from './ssh-relay-gc-claim' +import { NATIVE_DEPS_COMMAND_TIMEOUT_MS, RELAY_DEPLOY_TIMEOUT_MS } from './ssh-relay-deploy-timing' +import { createSshOperationAbortError, shellEscape } from './ssh-connection-utils' import { probeBuildToolchain, formatMissingToolchainError, @@ -66,16 +78,14 @@ export type RelayDeployResult = { sockPath?: string } -// Why: individual exec commands have 30s timeouts, but the full deploy -// pipeline (detect platform → check existing → upload → npm install → -// launch) has no overall bound. A hanging `npm install` or slow SFTP -// upload could block the connection indefinitely. First-time installs need -// room for the longer native dependency install bound below. -const RELAY_DEPLOY_TIMEOUT_MS = 300_000 - -// npm install on a cold Windows cache plus antivirus scanning can exceed the -// default 30s exec timeout. -const NATIVE_DEPS_INSTALL_TIMEOUT_MS = 240_000 +class RelayDirectoryGcConflictError extends Error { + constructor( + readonly remoteRelayDir: string, + readonly hostPlatform: RemoteHostPlatform + ) { + super(`Relay directory GC is in progress at ${remoteRelayDir}`) + } +} function execHostCommand( conn: SshConnection, @@ -108,15 +118,25 @@ export async function deployAndLaunchRelay( relayInstanceId?: string ): Promise { let timeoutHandle: ReturnType + // Why: Promise.race does not cancel its loser. Stop a contended install-lock + // waiter so it cannot acquire and mutate the relay after this call times out. + const deployAbortController = new AbortController() const timeoutPromise = new Promise((_resolve, reject) => { timeoutHandle = setTimeout(() => { + deployAbortController.abort() reject(new Error(`Relay deployment timed out after ${RELAY_DEPLOY_TIMEOUT_MS / 1000}s`)) }, RELAY_DEPLOY_TIMEOUT_MS) }) try { return await Promise.race([ - deployAndLaunchRelayInner(conn, onProgress, graceTimeSeconds, relayInstanceId), + deployAndLaunchRelayInner( + conn, + onProgress, + graceTimeSeconds, + relayInstanceId, + deployAbortController.signal + ), timeoutPromise ]) } finally { @@ -179,22 +199,29 @@ type RelayBootstrapState = { async function resolveRelayBootstrapStateSequentially( conn: SshConnection, hostPlatform: RemoteHostPlatform, - fullVersion: string + fullVersion: string, + signal?: AbortSignal ): Promise { - const installState = await resolveRemoteInstallState(conn, hostPlatform, fullVersion) - const nodePath = await resolveRemoteNodePath(conn, hostPlatform) + const installState = await resolveRemoteInstallState(conn, hostPlatform, fullVersion, { signal }) + const nodePath = await resolveRemoteNodePath(conn, hostPlatform, { signal }) return { ...installState, nodePath } } async function resolveRelayBootstrapState( conn: SshConnection, hostPlatform: RemoteHostPlatform, - fullVersion: string + fullVersion: string, + signal?: AbortSignal ): Promise { if (!conn.canRunConcurrentExecCommands()) { - return resolveRelayBootstrapStateSequentially(conn, hostPlatform, fullVersion) + return resolveRelayBootstrapStateSequentially(conn, hostPlatform, fullVersion, signal) } const abortController = new AbortController() + const abortForDeploy = (): void => abortController.abort() + signal?.addEventListener('abort', abortForDeploy, { once: true }) + if (signal?.aborted) { + abortForDeploy() + } const installStatePromise = resolveRemoteInstallState(conn, hostPlatform, fullVersion, { rethrowSessionLimitErrors: true, signal: abortController.signal @@ -205,10 +232,12 @@ async function resolveRelayBootstrapState( }) try { const [installState, nodePath] = await Promise.all([installStatePromise, nodePathPromise]) + signal?.throwIfAborted() return { ...installState, nodePath } } catch (err) { abortController.abort() const settled = await Promise.allSettled([installStatePromise, nodePathPromise]) + signal?.throwIfAborted() if (!isSshSessionLimitError(err)) { throw err } @@ -224,7 +253,9 @@ async function resolveRelayBootstrapState( console.warn( '[ssh-relay] Concurrent bootstrap probes hit the remote SSH session limit; retrying sequentially.' ) - return resolveRelayBootstrapStateSequentially(conn, hostPlatform, fullVersion) + return resolveRelayBootstrapStateSequentially(conn, hostPlatform, fullVersion, signal) + } finally { + signal?.removeEventListener('abort', abortForDeploy) } } @@ -241,11 +272,40 @@ async function deployAndLaunchRelayInner( conn: SshConnection, onProgress?: (status: string) => void, graceTimeSeconds?: number, - relayInstanceId?: string + relayInstanceId?: string, + deploySignal?: AbortSignal +): Promise { + while (true) { + deploySignal?.throwIfAborted() + try { + return await deployAndLaunchRelayAttempt( + conn, + onProgress, + graceTimeSeconds, + relayInstanceId, + deploySignal + ) + } catch (err) { + if (!(err instanceof RelayDirectoryGcConflictError)) { + throw err + } + // Why: GC atomically moves the old install aside. Wait for its stable + // sibling claim to clear, then recompute install state from scratch. + await waitForRelayGcClaimRelease(conn, err.remoteRelayDir, err.hostPlatform, deploySignal) + } + } +} + +async function deployAndLaunchRelayAttempt( + conn: SshConnection, + onProgress?: (status: string) => void, + graceTimeSeconds?: number, + relayInstanceId?: string, + deploySignal?: AbortSignal ): Promise { onProgress?.('Detecting remote platform...') console.log('[ssh-relay] Detecting remote platform...') - const hostPlatform = await detectRemoteHostPlatform(conn) + const hostPlatform = await detectRemoteHostPlatform(conn, { signal: deploySignal }) if (!hostPlatform) { throw new Error( 'Unsupported remote platform. Orca relay supports: linux-x64, linux-arm64, darwin-x64, darwin-arm64, win32-x64, win32-arm64.' @@ -275,57 +335,104 @@ async function deployAndLaunchRelayInner( // fail-fast; remotes that reject overlapping session channels retry the old // sequential order so restrictive SSH servers keep working. const { remoteHome, remoteRelayDir, alreadyInstalled, nodePath } = - await resolveRelayBootstrapState(conn, hostPlatform, fullVersion) + await resolveRelayBootstrapState(conn, hostPlatform, fullVersion, deploySignal) console.log(`[ssh-relay] Remote dir: ${remoteRelayDir}`) console.log(`[ssh-relay] Already installed at ${fullVersion}: ${alreadyInstalled}`) + let ownsInstallLock = false + let launchGcClaimToken: string | undefined if (alreadyInstalled) { - await repairInstalledNativeDeps(conn, remoteRelayDir, platform, hostPlatform, nodePath) + const launchFence = await repairInstalledNativeDeps( + conn, + remoteRelayDir, + platform, + hostPlatform, + nodePath, + deploySignal + ) + ownsInstallLock = launchFence.ownsInstallLock + launchGcClaimToken = launchFence.gcClaimToken + deploySignal?.throwIfAborted() } else { // Why: serialize concurrent first-installs of the same version against - // each other via an atomic mkdir lock. The losing caller polls and either + // each other via a host-native exclusive lock. The losing caller polls and either // re-checks `alreadyInstalled` (now true) or steals a stale lock. - await acquireInstallLock(conn, remoteRelayDir, hostPlatform) + await acquireInstallLock(conn, remoteRelayDir, hostPlatform, { signal: deploySignal }) + ownsInstallLock = true try { // Re-probe after acquiring the lock — a sibling installer may have // finished while we were waiting. - if (!(await isRelayAlreadyInstalled(conn, remoteRelayDir, hostPlatform))) { + if ( + !(await isRelayAlreadyInstalled(conn, remoteRelayDir, hostPlatform, { + signal: deploySignal + })) + ) { onProgress?.('Uploading relay...') console.log('[ssh-relay] Uploading relay...') - await uploadRelay(conn, platform, remoteRelayDir, fullVersion, hostPlatform) + await uploadRelay(conn, platform, remoteRelayDir, fullVersion, hostPlatform, deploySignal) console.log('[ssh-relay] Upload complete') onProgress?.('Installing native dependencies...') console.log('[ssh-relay] Installing native dependencies...') - await installNativeDeps(conn, remoteRelayDir, platform, hostPlatform, nodePath) + await installNativeDeps( + conn, + remoteRelayDir, + platform, + hostPlatform, + nodePath, + deploySignal + ) console.log('[ssh-relay] Native deps installed') - // Why: write `.install-complete` BEFORE releasing the lock so a - // sibling never observes the dir as "complete but locked", which - // would lead GC to skip a recoverable dir indefinitely. - await finalizeInstall(conn, remoteRelayDir, hostPlatform) - } else { - await abandonInstall(conn, remoteRelayDir, hostPlatform) + // Why: mark complete but retain the lock until launch makes daemon + // liveness observable to cross-version GC. + await finalizeInstall(conn, remoteRelayDir, hostPlatform, { + signal: deploySignal, + releaseLock: false + }) } } catch (err) { // Why: leave a partial install dir in place (no `.install-complete`) // so the next deploy detects the partial and re-runs upload + install. - // Just release the lock so a concurrent caller can retry. - await abandonInstall(conn, remoteRelayDir, hostPlatform) + // Keep the lock if remote command termination was not confirmed; stale + // recovery is safer than overlapping a still-running npm process. + if (!isUnconfirmedSshCommandTermination(err)) { + await abandonInstall(conn, remoteRelayDir, hostPlatform) + ownsInstallLock = false + } throw err } } - onProgress?.('Starting relay...') - console.log('[ssh-relay] Launching relay...') - const launched = await launchRelay( - conn, - remoteRelayDir, - hostPlatform, - nodePath, - graceTimeSeconds, - relayInstanceId - ) + let launched: Awaited> + let launchLivenessObserved = false + try { + deploySignal?.throwIfAborted() + onProgress?.('Starting relay...') + console.log('[ssh-relay] Launching relay...') + launched = await launchRelay( + conn, + remoteRelayDir, + hostPlatform, + nodePath, + graceTimeSeconds, + relayInstanceId, + deploySignal + ) + launchLivenessObserved = true + } finally { + // Why: older clients understand only the install lock. If launch never + // becomes live, retain it for stale recovery so their GC cannot race a + // concurrent caller that was waiting behind this owner. + if (ownsInstallLock && launchLivenessObserved) { + await abandonInstall(conn, remoteRelayDir, hostPlatform) + } + // The detached start may outlive a timed-out SSH command. Preserve either + // fence on failed launch until stale recovery can prove the handoff ended. + if (launchGcClaimToken && launchLivenessObserved) { + await releaseRelayGcClaimWithRetry(conn, remoteRelayDir, launchGcClaimToken, hostPlatform) + } + } console.log('[ssh-relay] Relay started successfully') // Why: best-effort cleanup of unreferenced sibling version dirs. Errors @@ -352,7 +459,8 @@ async function uploadRelay( platform: RelayPlatform, remoteDir: string, fullVersion: string, - hostPlatform: RemoteHostPlatform + hostPlatform: RemoteHostPlatform, + signal?: AbortSignal ): Promise { const localRelayDir = getLocalRelayPath(platform) if (!localRelayDir || !existsSync(localRelayDir)) { @@ -363,16 +471,19 @@ async function uploadRelay( } // Create remote directory - await execHostCommand(conn, hostPlatform, makeRemoteDirectoryCommand(hostPlatform, remoteDir)) + await execHostCommand(conn, hostPlatform, makeRemoteDirectoryCommand(hostPlatform, remoteDir), { + signal + }) - await uploadDirectoryForConnection(conn, localRelayDir, remoteDir, hostPlatform) + await uploadDirectoryForConnection(conn, localRelayDir, remoteDir, hostPlatform, signal) // Make the node binary executable if (!isWindowsRemoteHost(hostPlatform)) { await execHostCommand( conn, hostPlatform, - makeRemoteExecutableCommand(hostPlatform, joinRemotePath(hostPlatform, remoteDir, 'node')) + makeRemoteExecutableCommand(hostPlatform, joinRemotePath(hostPlatform, remoteDir, 'node')), + { signal } ) } @@ -383,7 +494,8 @@ async function uploadRelay( conn, hostPlatform, joinRemotePath(hostPlatform, remoteDir, '.version'), - fullVersion + fullVersion, + signal ) } @@ -391,10 +503,11 @@ async function uploadDirectoryForConnection( conn: SshConnection, localRelayDir: string, remoteDir: string, - hostPlatform: RemoteHostPlatform + hostPlatform: RemoteHostPlatform, + signal?: AbortSignal ): Promise { if (typeof conn.uploadDirectory === 'function') { - await conn.uploadDirectory(localRelayDir, remoteDir, { hostPlatform }) + await conn.uploadDirectory(localRelayDir, remoteDir, { hostPlatform, signal }) return } @@ -410,10 +523,11 @@ async function writeRemoteFile( conn: SshConnection, hostPlatform: RemoteHostPlatform, remotePath: string, - contents: string + contents: string, + signal?: AbortSignal ): Promise { if (typeof conn.writeFile === 'function') { - await conn.writeFile(remotePath, contents, { hostPlatform }) + await conn.writeFile(remotePath, contents, { hostPlatform, signal }) return } @@ -438,31 +552,64 @@ const RELAY_NATIVE_DEPS = { '@parcel/watcher': '2.5.6' } as const -async function hasRequiredNativeDeps( +type RelayNativeDepName = keyof typeof RELAY_NATIVE_DEPS +const RELAY_NATIVE_DEP_NAMES = Object.keys(RELAY_NATIVE_DEPS) as RelayNativeDepName[] +const NATIVE_DEPS_MISSING_PREFIX = 'ORCA-NATIVE-DEPS-MISSING:' + +// Why: npm 12 blocks dependency lifecycle scripts unless each exact package +// version is approved, even when ignore-scripts is explicitly disabled. +const RELAY_NATIVE_DEP_SCRIPT_ALLOWLIST = Object.fromEntries( + Object.entries(RELAY_NATIVE_DEPS).map(([name, version]) => [`${name}@${version}`, true]) +) + +function nativeDepsProbeJs(successToken: string): string { + // Why: node-pty's Windows wrapper defers loading conpty.node until first + // spawn, so require("node-pty") alone cannot prove the binding is healthy. + const loadNodePty = + 'require("node-pty"); require("node-pty/lib/utils").loadNativeModule(process.platform==="win32"&&Number(require("os").release().split(".")[2])>=18309?"conpty":"pty")' + return `(()=>{const missing=[];try{${loadNodePty}}catch{missing.push("node-pty")}try{require("@parcel/watcher")}catch{missing.push("@parcel/watcher")}if(missing.length){console.log("${NATIVE_DEPS_MISSING_PREFIX}"+missing.join(","));process.exitCode=1}else{console.log(${JSON.stringify(successToken)})}})()` +} + +function missingNativeDepsFromProbe(output: string): RelayNativeDepName[] { + const marker = output + .split(/\r?\n/) + .find((line) => line.trim().startsWith(NATIVE_DEPS_MISSING_PREFIX)) + if (!marker) { + return [...RELAY_NATIVE_DEP_NAMES] + } + const reported = marker.trim().slice(NATIVE_DEPS_MISSING_PREFIX.length).split(',') + return RELAY_NATIVE_DEP_NAMES.filter((name) => reported.includes(name)) +} + +async function probeRequiredNativeDeps( conn: SshConnection, remoteDir: string, hostPlatform: RemoteHostPlatform, - nodePath: string -): Promise { + nodePath: string, + signal?: AbortSignal +): Promise<{ available: boolean; missing: RelayNativeDepName[] }> { const escapedNode = shellEscape(nodePath) + const probeJs = nativeDepsProbeJs('ORCA-NATIVE-DEPS-OK') try { const command = isWindowsRemoteHost(hostPlatform) ? commandWithNodePath( hostPlatform, nodePath, remoteDir, - `try { & ${powerShellLiteral(nodePath)} -e ${powerShellNativeArg('require.resolve("node-pty"); require.resolve("@parcel/watcher"); console.log("ORCA-NATIVE-DEPS-OK")')} } catch { 'MISSING' }` + `try { & ${powerShellLiteral(nodePath)} -e ${powerShellNativeArg(probeJs)} } catch { 'MISSING' }` ) : commandWithNodePath( hostPlatform, nodePath, remoteDir, - `(${escapedNode} -e 'require.resolve("node-pty"); require.resolve("@parcel/watcher"); console.log("ORCA-NATIVE-DEPS-OK")' 2>/dev/null || echo MISSING)` + `(${escapedNode} -e ${shellEscape(probeJs)} 2>/dev/null || echo MISSING)` ) - const probe = await execHostCommand(conn, hostPlatform, command) - return probe.includes('ORCA-NATIVE-DEPS-OK') + const probe = await execHostCommand(conn, hostPlatform, command, { signal }) + const available = probe.includes('ORCA-NATIVE-DEPS-OK') + return { available, missing: available ? [] : missingNativeDepsFromProbe(probe) } } catch { - return false + signal?.throwIfAborted() + return { available: false, missing: [...RELAY_NATIVE_DEP_NAMES] } } } @@ -471,25 +618,116 @@ async function repairInstalledNativeDeps( remoteDir: string, platform: RelayPlatform, hostPlatform: RemoteHostPlatform, - nodePath: string -): Promise { - if (await hasRequiredNativeDeps(conn, remoteDir, hostPlatform, nodePath)) { - return + nodePath: string, + signal?: AbortSignal +): Promise<{ ownsInstallLock: boolean; gcClaimToken?: string }> { + const initialProbe = await probeRequiredNativeDeps( + conn, + remoteDir, + hostPlatform, + nodePath, + signal + ) + const lockResult = await tryAcquireRelayRepairLock(conn, remoteDir, hostPlatform, { signal }) + if (lockResult === 'gc') { + throw new RelayDirectoryGcConflictError(remoteDir, hostPlatform) + } + if (lockResult === 'acquired') { + let stillInstalled: boolean + try { + stillInstalled = await isRelayAlreadyInstalled(conn, remoteDir, hostPlatform, { + rethrowSessionLimitErrors: true, + signal + }) + } catch (err) { + await abandonInstall(conn, remoteDir, hostPlatform) + throw err + } + if (!stillInstalled) { + // Why: GC may finish its rename before our lock attempt recreates the + // original path. Never trust probes made before this locked recheck. + await abandonInstall(conn, remoteDir, hostPlatform) + throw new RelayDirectoryGcConflictError(remoteDir, hostPlatform) + } + } + const gcClaimToken = + lockResult === 'busy' || lockResult === 'error' + ? await acquireRelayLaunchGcFence(conn, remoteDir, hostPlatform, signal) + : undefined + if (initialProbe.available) { + // Why: even a healthy reconnect must stay fenced until launch liveness is + // observable; otherwise cross-version GC can rename after this probe. + return { ownsInstallLock: lockResult === 'acquired', gcClaimToken } } + // Why: an already-installed relay can launch in degraded mode (fs/git/preflight + // still work; native-backed ops fail), so native-deps repair is best-effort: + // lock contention and repair failures must not abort the connection. Pre-repair + // this path used require.resolve (which passed for a present-but-unloadable + // binding), so a fatal repair here would be a straight regression. console.warn(`[ssh-relay] Repairing missing native deps at ${remoteDir}`) - await acquireInstallLock(conn, remoteDir, hostPlatform) + if (lockResult === 'busy' || lockResult === 'error') { + console.warn( + `[ssh-relay] Native-deps repair lock is ${lockResult} at ${remoteDir}; launching degraded` + ) + return { ownsInstallLock: false, gcClaimToken } + } try { // Why: older complete relay dirs were created before @parcel/watcher was // installed. Re-probe under the lock so only one reconnect mutates the dir. - if (!(await hasRequiredNativeDeps(conn, remoteDir, hostPlatform, nodePath))) { - await installNativeDeps(conn, remoteDir, platform, hostPlatform, nodePath) - await finalizeInstall(conn, remoteDir, hostPlatform) - } else { - await abandonInstall(conn, remoteDir, hostPlatform) + const probe = await probeRequiredNativeDeps(conn, remoteDir, hostPlatform, nodePath, signal) + if (!probe.available) { + await installNativeDeps( + conn, + remoteDir, + platform, + hostPlatform, + nodePath, + signal, + probe.missing + ) + await finalizeInstall(conn, remoteDir, hostPlatform, { signal, releaseLock: false }) } + return { ownsInstallLock: true } } catch (err) { - await abandonInstall(conn, remoteDir, hostPlatform) + const terminationUnconfirmed = isUnconfirmedSshCommandTermination(err) + // Why: keep a confirmed-failure lock through degraded launch so GC cannot + // move the relay before liveness is visible. Unconfirmed remote mutation + // keeps its stale-recoverable lock beyond this connection. + console.warn( + `[ssh-relay] Native deps repair failed at ${remoteDir}; launching degraded: ${ + err instanceof Error ? err.message : String(err) + }` + ) + return { ownsInstallLock: !terminationUnconfirmed } + } +} + +async function acquireRelayLaunchGcFence( + conn: SshConnection, + remoteDir: string, + hostPlatform: RemoteHostPlatform, + signal?: AbortSignal +): Promise { + const token = await tryAcquireRelayGcClaim(conn, remoteDir, hostPlatform, signal) + if (!token) { + signal?.throwIfAborted() + throw new RelayDirectoryGcConflictError(remoteDir, hostPlatform) + } + try { + signal?.throwIfAborted() + const stillInstalled = await isRelayAlreadyInstalled(conn, remoteDir, hostPlatform, { + rethrowSessionLimitErrors: true, + signal + }) + if (!stillInstalled) { + throw new RelayDirectoryGcConflictError(remoteDir, hostPlatform) + } + // Why: a caller that cannot own the install lock still needs its own + // durable fence; never borrow another connection's lock through launch. + return token + } catch (err) { + await releaseRelayGcClaimWithRetry(conn, remoteDir, token, hostPlatform) throw err } } @@ -507,14 +745,14 @@ async function installNativeDeps( remoteDir: string, platform: RelayPlatform, hostPlatform: RemoteHostPlatform, - nodePath: string + nodePath: string, + signal?: AbortSignal, + resetDeps: RelayNativeDepName[] = [] ): Promise { - // Why: node's bin directory must be in PATH for npm's child processes. + // Why: commandWithNodePath puts node's bin directory in PATH for npm's child processes. // npm install runs node-pty's prebuild script (`node scripts/prebuild.js`) // which spawns `node` as a child — if node isn't in PATH, that child // fails with exit 127 even though we invoked npm via its full path. - const escapedNode = shellEscape(nodePath) - // npm init -y rejects '+' in derived package names (content-hashed dir // names like relay-0.1.0+abc123). Bypass it with a fixed minimal // package.json. type:commonjs pins module resolution against Node default @@ -524,38 +762,51 @@ async function installNativeDeps( version: '1.0.0', private: true, type: 'commonjs', - dependencies: RELAY_NATIVE_DEPS + dependencies: RELAY_NATIVE_DEPS, + allowScripts: RELAY_NATIVE_DEP_SCRIPT_ALLOWLIST })}\n` await writeRemoteFile( conn, hostPlatform, joinRemotePath(hostPlatform, remoteDir, 'package.json'), - pkgJson + pkgJson, + signal ) try { const installArgs = Object.entries(RELAY_NATIVE_DEPS) .map(([dep, version]) => shellEscape(`${dep}@${version}`)) .join(' ') + // Why: npm reports a present package as up to date even when one packaged + // native file was deleted. Reset only dependencies the probe found broken. + const resetCommand = resetNativeDepsCommand(hostPlatform, resetDeps) + const resetPrefix = resetCommand ? `${resetCommand}; ` : '' const command = isWindowsRemoteHost(hostPlatform) ? commandWithNodePath( hostPlatform, nodePath, remoteDir, - `npm install --omit=dev --no-audit --no-fund ${Object.entries(RELAY_NATIVE_DEPS) + `${resetPrefix}npm install --ignore-scripts=false --omit=dev --no-audit --no-fund ${Object.entries( + RELAY_NATIVE_DEPS + ) .map(([dep, version]) => powerShellLiteral(`${dep}@${version}`)) - .join(' ')}` + .join(' ')}; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }` ) : commandWithNodePath( hostPlatform, nodePath, remoteDir, - `npm install --omit=dev --no-audit --no-fund ${installArgs} 2>&1` + `${resetPrefix}npm install --ignore-scripts=false --omit=dev --no-audit --no-fund ${installArgs} 2>&1` ) await execHostCommand(conn, hostPlatform, command, { - timeoutMs: NATIVE_DEPS_INSTALL_TIMEOUT_MS + timeoutMs: NATIVE_DEPS_COMMAND_TIMEOUT_MS, + signal }) } catch (err) { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } + signal?.throwIfAborted() // Don't write .install-complete on hard fail; reconnect retries on a // partial install. Greppable token so user bug reports paste something // searchable. @@ -568,7 +819,7 @@ async function installNativeDeps( // remote and replace node-gyp's opaque `not found: make` with an actionable // install hint instead of leaking the raw npm output to the user. if (platform.startsWith('linux') && shouldProbeBuildToolchainAfterNativeDepsFailure(msg)) { - const toolchain = await probeBuildToolchain(conn, hostPlatform) + const toolchain = await probeBuildToolchain(conn, hostPlatform, signal) if (toolchain?.toolchainMissing) { throw new Error(formatMissingToolchainError(toolchain, msg)) } @@ -576,54 +827,178 @@ async function installNativeDeps( throw err } - // SFTP doesn't preserve execute bits; node-pty's spawn-helper prebuild - // must be +x for posix_spawnp. - if (!isWindowsRemoteHost(hostPlatform)) { - await execHostCommand( - conn, - hostPlatform, - `find ${shellEscape(joinRemotePath(hostPlatform, remoteDir, 'node_modules/node-pty/prebuilds'))} -name spawn-helper -exec chmod +x {} + 2>/dev/null; true` - ) + await makeNodePtySpawnHelperExecutable(conn, remoteDir, hostPlatform, signal) + + let probe = await probeInstalledNativeDeps(conn, remoteDir, hostPlatform, nodePath, signal) + if (!probe.available) { + // Why: npm treats an already-present package as up to date, so enabling + // lifecycle scripts on install cannot repair a binding skipped earlier. + console.warn(`[ssh-relay] Rebuilding unloadable native deps at ${remoteDir}`) + let rebuilt = false + try { + await rebuildNativeDeps(conn, remoteDir, hostPlatform, nodePath, signal) + rebuilt = true + } catch (err) { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } + signal?.throwIfAborted() + console.warn( + `[ssh-relay][NATIVE-DEPS-REBUILD-FAIL] npm rebuild native deps failed at ${remoteDir} (${platform}): ${(err as Error).message}` + ) + } + signal?.throwIfAborted() + if (rebuilt) { + await makeNodePtySpawnHelperExecutable(conn, remoteDir, hostPlatform, signal) + probe = await probeInstalledNativeDeps(conn, remoteDir, hostPlatform, nodePath, signal) + } } - // node -e require() catches unloadable installs (wrong arch, missing - // prebuild, broken native binding) that test -d cannot. Stderr → file - // so .bashrc noise can't pollute the sentinel match; preserved for the - // [NPTY-MISSING] breadcrumb. MISSING is non-fatal by design — see - // docs/ssh-relay-versioned-install-dirs.md (relay still serves - // fs/git/preflight; only pty.spawn fails at runtime). - const PROBE_OK = 'ORCA-NPTY-PROBE-OK' - const stderrFile = joinRemotePath(hostPlatform, remoteDir, '.npty-probe.stderr') - const escapedStderr = shellEscape(stderrFile) - const probeCommand = isWindowsRemoteHost(hostPlatform) + // MISSING is non-fatal by design: the relay still serves fs/git/preflight; + // only native-backed operations fail on hosts that cannot build the addons. + if (!probe.available) { + console.warn( + `[ssh-relay][NPTY-MISSING] native deps installed but require() failed at ${remoteDir} (${platform}). stdout=${probe.output.trim().slice(-200)} stderr=${probe.stderr.trim().slice(-500)}` + ) + } +} + +function resetNativeDepsCommand( + hostPlatform: RemoteHostPlatform, + resetDeps: RelayNativeDepName[] +): string { + if (resetDeps.length === 0) { + return '' + } + const resetNodePty = resetDeps.includes('node-pty') + const resetWatcher = resetDeps.includes('@parcel/watcher') + if (isWindowsRemoteHost(hostPlatform)) { + const commands: string[] = [] + if (resetNodePty) { + commands.push( + `Remove-Item -LiteralPath ${powerShellLiteral('node_modules/node-pty')} -Recurse -Force -ErrorAction SilentlyContinue` + ) + } + if (resetWatcher) { + commands.push( + `Remove-Item -LiteralPath ${powerShellLiteral('node_modules/@parcel/watcher')} -Recurse -Force -ErrorAction SilentlyContinue`, + `$parcelScope = ${powerShellLiteral('node_modules/@parcel')}`, + `Get-ChildItem -LiteralPath $parcelScope -Force -ErrorAction SilentlyContinue | Where-Object { $_.Name.StartsWith('watcher-', [StringComparison]::Ordinal) } | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue` + ) + } + return commands.join('; ') + } + const commands: string[] = [] + if (resetNodePty) { + commands.push(`rm -rf ${shellEscape('node_modules/node-pty')}`) + } + if (resetWatcher) { + commands.push( + `rm -rf ${shellEscape('node_modules/@parcel/watcher')}`, + `find ${shellEscape('node_modules/@parcel')} -maxdepth 1 -name 'watcher-*' -exec rm -rf {} + 2>/dev/null || true` + ) + } + return commands.join('; ') +} + +async function rebuildNativeDeps( + conn: SshConnection, + remoteDir: string, + hostPlatform: RemoteHostPlatform, + nodePath: string, + signal?: AbortSignal +): Promise { + const depNames = Object.keys(RELAY_NATIVE_DEPS) + const command = isWindowsRemoteHost(hostPlatform) ? commandWithNodePath( hostPlatform, nodePath, remoteDir, - `try { & ${powerShellLiteral(nodePath)} -e ${powerShellNativeArg('require("node-pty"); console.log(process.argv[1])')} ${powerShellLiteral(PROBE_OK)}; if ($LASTEXITCODE -ne 0) { 'MISSING' } } catch { 'MISSING' }` + `npm rebuild --ignore-scripts=false ${depNames.map(powerShellLiteral).join(' ')}; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }` ) : commandWithNodePath( hostPlatform, nodePath, remoteDir, - `(${escapedNode} -e 'require("node-pty"); console.log(process.argv[1])' ${shellEscape(PROBE_OK)} 2>${escapedStderr} || echo MISSING)` + `npm rebuild --ignore-scripts=false ${depNames.map(shellEscape).join(' ')} 2>&1` ) - const probeOutput = await execHostCommand(conn, hostPlatform, probeCommand) - if (!probeOutput.includes(PROBE_OK)) { - const remoteStderr = isWindowsRemoteHost(hostPlatform) - ? '' - : await execHostCommand(conn, hostPlatform, `cat ${escapedStderr} 2>/dev/null; true`).catch( - () => '' - ) - console.warn( - `[ssh-relay][NPTY-MISSING] node-pty installed but require() failed at ${remoteDir} (${platform}). stdout=${probeOutput.trim().slice(-200)} stderr=${remoteStderr.trim().slice(-500)}` - ) + await execHostCommand(conn, hostPlatform, command, { + timeoutMs: NATIVE_DEPS_COMMAND_TIMEOUT_MS, + signal + }) +} + +async function makeNodePtySpawnHelperExecutable( + conn: SshConnection, + remoteDir: string, + hostPlatform: RemoteHostPlatform, + signal?: AbortSignal +): Promise { + if (isWindowsRemoteHost(hostPlatform)) { + return } + // SFTP doesn't preserve execute bits; node-pty's spawn-helper prebuild + // must be +x for posix_spawnp. await execHostCommand( conn, hostPlatform, - removeRemoteFileCommand(hostPlatform, stderrFile) - ).catch(() => {}) + `find ${shellEscape(joinRemotePath(hostPlatform, remoteDir, 'node_modules/node-pty/prebuilds'))} -name spawn-helper -exec chmod +x {} + 2>/dev/null; true`, + { signal } + ) +} + +async function probeInstalledNativeDeps( + conn: SshConnection, + remoteDir: string, + hostPlatform: RemoteHostPlatform, + nodePath: string, + signal?: AbortSignal +): Promise<{ + available: boolean + missing: RelayNativeDepName[] + output: string + stderr: string +}> { + // require() catches unloadable installs (wrong arch, missing prebuild, or a + // skipped lifecycle script) that require.resolve() and test -d both miss. + const PROBE_OK = 'ORCA-NPTY-PROBE-OK' + const stderrFile = joinRemotePath(hostPlatform, remoteDir, '.npty-probe.stderr') + const escapedStderr = shellEscape(stderrFile) + const probeJs = nativeDepsProbeJs(PROBE_OK) + const probeCommand = isWindowsRemoteHost(hostPlatform) + ? commandWithNodePath( + hostPlatform, + nodePath, + remoteDir, + `try { & ${powerShellLiteral(nodePath)} -e ${powerShellNativeArg(probeJs)} ${powerShellLiteral(PROBE_OK)}; if ($LASTEXITCODE -ne 0) { 'MISSING' } } catch { 'MISSING' }` + ) + : commandWithNodePath( + hostPlatform, + nodePath, + remoteDir, + `(${shellEscape(nodePath)} -e ${shellEscape(probeJs)} ${shellEscape(PROBE_OK)} 2>${escapedStderr} || echo MISSING)` + ) + const probeOutput = await execHostCommand(conn, hostPlatform, probeCommand, { signal }) + const remoteStderr = + probeOutput.includes(PROBE_OK) || isWindowsRemoteHost(hostPlatform) + ? '' + : await execHostCommand(conn, hostPlatform, `cat ${escapedStderr} 2>/dev/null; true`, { + signal + }).catch(() => '') + signal?.throwIfAborted() + if (!isWindowsRemoteHost(hostPlatform)) { + // The POSIX probe redirects stderr to this file; the Windows probe does not. + await execHostCommand(conn, hostPlatform, removeRemoteFileCommand(hostPlatform, stderrFile), { + signal + }).catch(() => {}) + signal?.throwIfAborted() + } + return { + available: probeOutput.includes(PROBE_OK), + missing: probeOutput.includes(PROBE_OK) ? [] : missingNativeDepsFromProbe(probeOutput), + output: probeOutput, + stderr: remoteStderr + } } function getLocalRelayPath(platform: RelayPlatform): string | null { @@ -663,7 +1038,8 @@ async function launchRelay( hostPlatform: RemoteHostPlatform, nodePath: string, graceTimeSeconds?: number, - relayInstanceId?: string + relayInstanceId?: string, + signal?: AbortSignal ): 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 @@ -695,21 +1071,27 @@ async function launchRelay( conn, hostPlatform, remoteDir, - activePipeMarkerPath + activePipeMarkerPath, + signal )) ?? { sockPath: sockFile, endpointDir } const fallbackEndpoint = buildWindowsRelayFallbackEndpoint(hostPlatform, remoteDir, sockName) - return launchWindowsRelay(conn, hostPlatform, { - remoteDir, - nodePath, - sockPath: activeEndpoint.sockPath, - endpointDir: activeEndpoint.endpointDir, - graceTime, - activePipeMarkerPath, - reconnectFallback: fallbackEndpoint - }) + return launchWindowsRelay( + conn, + hostPlatform, + { + remoteDir, + nodePath, + sockPath: activeEndpoint.sockPath, + endpointDir: activeEndpoint.endpointDir, + graceTime, + activePipeMarkerPath, + reconnectFallback: fallbackEndpoint + }, + signal + ) } // Why: after an app restart a relay may still be running in its grace @@ -719,29 +1101,43 @@ async function launchRelay( try { const probeOutput = await execCommand( conn, - `test -S ${shellEscape(sockFile)} && echo ALIVE || echo DEAD` + `test -S ${shellEscape(sockFile)} && echo ALIVE || echo DEAD`, + { signal } ) console.warn(`[ssh-relay] Socket probe result: "${probeOutput.trim()}"`) if (probeOutput.trim() === 'ALIVE') { console.log('[ssh-relay] Existing relay socket found, attempting reconnect...') try { const channel = await conn.exec( - `cd ${escapedDir} && ${escapedNode} relay.js --connect --sock-path ${shellEscape(sockFile)}` + `cd ${escapedDir} && ${escapedNode} relay.js --connect --sock-path ${shellEscape(sockFile)}`, + { signal } ) - const transport = await waitForSentinel(channel) + const transport = await waitForSentinel(channel, signal) console.log('[ssh-relay] Reconnected to existing relay via socket') return { transport, nodePath, sockPath: sockFile } } catch (err) { + signal?.throwIfAborted() console.warn( '[ssh-relay] Socket reconnect failed, launching fresh relay:', err instanceof Error ? err.message : String(err) ) // Why: stale socket from a crashed relay — remove it so the // fresh launch can bind a new socket at the same path. - await execCommand(conn, `rm -f ${shellEscape(sockFile)}`).catch(() => {}) + await execCommand(conn, `rm -f ${shellEscape(sockFile)}`, { signal }).catch( + (cleanupErr) => { + if (isUnconfirmedSshCommandTermination(cleanupErr)) { + throw cleanupErr + } + } + ) + signal?.throwIfAborted() } } - } catch { + } catch (err) { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } + signal?.throwIfAborted() // Probe failed — fall through to fresh launch } @@ -762,7 +1158,7 @@ async function launchRelay( // subsequent logging (it wraps process.stderr/stdout), so the current log // stays at relay.log for the `tail relay.log` diagnostics workflow. const launchCmd = `cd ${escapedDir} && nohup ${escapedNode} relay.js --detached --grace-time ${graceTime} --sock-path ${shellEscape(sockFile)} --log-file ${shellEscape(logFile)} > ${shellEscape(logFile)} 2>&1 {}) launchChannel.on('error', () => {}) launchChannel.stderr.on('data', () => {}) @@ -784,36 +1180,39 @@ async function launchRelay( const POLL_TIMEOUT_MS = 10_000 const pollStart = Date.now() let socketReady = false - while (Date.now() - pollStart < POLL_TIMEOUT_MS) { - try { - // Why: node is guaranteed to exist on the remote (we just deployed - // the relay with it). Using it to probe the socket is more portable - // than python3/socat/perl which may not be installed. The socket - // path is passed as argv[1] to avoid shell quoting issues with -e. - const result = await execCommand( - conn, - `${escapedNode} -e 'var s=require("net").connect(process.argv[1]);s.on("connect",function(){s.destroy();process.stdout.write("READY")});s.on("error",function(){process.stdout.write("WAITING")})' ${shellEscape(sockFile)} 2>/dev/null || (test -S ${shellEscape(sockFile)} && echo READY || echo WAITING)` - ) - if (result.trim() === 'READY') { - socketReady = true - break + try { + while (Date.now() - pollStart < POLL_TIMEOUT_MS) { + try { + // Why: node is guaranteed to exist on the remote (we just deployed + // the relay with it). Using it to probe the socket is more portable + // than python3/socat/perl which may not be installed. The socket + // path is passed as argv[1] to avoid shell quoting issues with -e. + const result = await execCommand( + conn, + `${escapedNode} -e 'var s=require("net").connect(process.argv[1]);s.on("connect",function(){s.destroy();process.stdout.write("READY")});s.on("error",function(){process.stdout.write("WAITING")})' ${shellEscape(sockFile)} 2>/dev/null || (test -S ${shellEscape(sockFile)} && echo READY || echo WAITING)`, + { signal } + ) + if (result.trim() === 'READY') { + socketReady = true + break + } + } catch { + signal?.throwIfAborted() + /* exec failed, retry */ } - } catch { - /* exec failed, retry */ + await waitForRelayPoll(POLL_INTERVAL_MS, signal) } - await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)) + } finally { + launchChannel.close() } - // Why: close the fire-and-forget launch channel now that the relay's - // socket is either ready or the poll timed out. Leaving it open leaks - // an SSH channel per relay restart. - launchChannel.close() - if (!socketReady) { const logOutput = await execCommand( conn, - `tail -20 ${shellEscape(logFile)} 2>/dev/null || echo "(no log)"` + `tail -20 ${shellEscape(logFile)} 2>/dev/null || echo "(no log)"`, + { signal } ).catch(() => '(could not read log)') + signal?.throwIfAborted() throw new Error(`Relay failed to start within ${POLL_TIMEOUT_MS / 1000}s. Log:\n${logOutput}`) } @@ -822,9 +1221,28 @@ async function launchRelay( // stdin/stdout to the relay's Unix socket — same path used for reconnect // after app restart. const channel = await conn.exec( - `cd ${escapedDir} && ${escapedNode} relay.js --connect --sock-path ${shellEscape(sockFile)}` + `cd ${escapedDir} && ${escapedNode} relay.js --connect --sock-path ${shellEscape(sockFile)}`, + { signal } ) - return { transport: await waitForSentinel(channel), nodePath, sockPath: sockFile } + return { transport: await waitForSentinel(channel, signal), nodePath, sockPath: sockFile } +} + +function waitForRelayPoll(delayMs: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const onAbort = (): void => { + clearTimeout(timeout) + signal?.removeEventListener('abort', onAbort) + reject(createSshOperationAbortError()) + } + const timeout = setTimeout(() => { + signal?.removeEventListener('abort', onAbort) + resolve() + }, delayMs) + signal?.addEventListener('abort', onAbort, { once: true }) + if (signal?.aborted) { + onAbort() + } + }) } function buildWindowsRelayFallbackEndpoint( @@ -844,15 +1262,20 @@ async function readWindowsActiveRelayEndpoint( conn: SshConnection, hostPlatform: RemoteHostPlatform, remoteDir: string, - markerPath: string + markerPath: string, + signal?: AbortSignal ): Promise { const output = await execHostCommand( conn, hostPlatform, powerShellCommand( `if (Test-Path -LiteralPath ${powerShellLiteral(markerPath)} -PathType Leaf) { Get-Content -LiteralPath ${powerShellLiteral(markerPath)} -Raw -ErrorAction SilentlyContinue }` - ) - ).catch(() => '') + ), + { signal } + ).catch(() => { + signal?.throwIfAborted() + return '' + }) const sockPath = output.trim() if (!isWindowsRelayPipePath(sockPath)) { return null @@ -867,15 +1290,18 @@ async function rememberWindowsActiveRelayEndpoint( conn: SshConnection, hostPlatform: RemoteHostPlatform, markerPath: string, - sockPath: string + sockPath: string, + signal?: AbortSignal ): Promise { await execHostCommand( conn, hostPlatform, powerShellCommand( `Set-Content -LiteralPath ${powerShellLiteral(markerPath)} -Value ${powerShellLiteral(sockPath)} -NoNewline` - ) + ), + { signal } ).catch((err) => { + signal?.throwIfAborted() // Why: fallback pipe names are deterministic, so losing this marker does // not force the next deploy to orphan an undiscoverable relay. console.warn( @@ -901,17 +1327,19 @@ type WindowsRelayLaunchOptions = { async function launchWindowsRelay( conn: SshConnection, hostPlatform: RemoteHostPlatform, - opts: WindowsRelayLaunchOptions + opts: WindowsRelayLaunchOptions, + signal?: AbortSignal ): Promise<{ transport: MultiplexerTransport; nodePath: string; sockPath: string }> { let launchOpts = opts - if ((await probeWindowsRelayPipe(conn, hostPlatform, opts)) === 'READY') { + if ((await probeWindowsRelayPipe(conn, hostPlatform, opts, signal)) === 'READY') { try { - const transport = await connectWindowsRelay(conn, hostPlatform, opts) + const transport = await connectWindowsRelay(conn, hostPlatform, opts, signal) await rememberWindowsActiveRelayEndpoint( conn, hostPlatform, opts.activePipeMarkerPath, - opts.sockPath + opts.sockPath, + signal ) return { transport, @@ -919,6 +1347,7 @@ async function launchWindowsRelay( sockPath: opts.sockPath } } catch (err) { + signal?.throwIfAborted() console.warn( '[ssh-relay] Windows named pipe reconnect failed, launching fresh relay:', err instanceof Error ? err.message : String(err) @@ -936,15 +1365,16 @@ async function launchWindowsRelay( if ( launchOpts !== opts && - (await probeWindowsRelayPipe(conn, hostPlatform, launchOpts)) === 'READY' + (await probeWindowsRelayPipe(conn, hostPlatform, launchOpts, signal)) === 'READY' ) { try { - const transport = await connectWindowsRelay(conn, hostPlatform, launchOpts) + const transport = await connectWindowsRelay(conn, hostPlatform, launchOpts, signal) await rememberWindowsActiveRelayEndpoint( conn, hostPlatform, launchOpts.activePipeMarkerPath, - launchOpts.sockPath + launchOpts.sockPath, + signal ) return { transport, @@ -952,6 +1382,7 @@ async function launchWindowsRelay( sockPath: launchOpts.sockPath } } catch (err) { + signal?.throwIfAborted() console.warn( '[ssh-relay] Windows fallback pipe reconnect failed, relaunching relay:', err instanceof Error ? err.message : String(err) @@ -973,20 +1404,29 @@ async function launchWindowsRelay( launchOpts.graceTime, logFile, errFile - ) + ), + { signal } ) const POLL_INTERVAL_MS = 200 const POLL_TIMEOUT_MS = 10_000 if ( - await waitForWindowsRelayPipe(conn, hostPlatform, launchOpts, POLL_TIMEOUT_MS, POLL_INTERVAL_MS) + await waitForWindowsRelayPipe( + conn, + hostPlatform, + launchOpts, + POLL_TIMEOUT_MS, + POLL_INTERVAL_MS, + signal + ) ) { - const transport = await connectWindowsRelay(conn, hostPlatform, launchOpts) + const transport = await connectWindowsRelay(conn, hostPlatform, launchOpts, signal) await rememberWindowsActiveRelayEndpoint( conn, hostPlatform, launchOpts.activePipeMarkerPath, - launchOpts.sockPath + launchOpts.sockPath, + signal ) return { transport, @@ -998,8 +1438,12 @@ async function launchWindowsRelay( const logOutput = await execHostCommand( conn, hostPlatform, - windowsRelayTailLogCommand(logFile, errFile) - ).catch(() => '(could not read log)') + windowsRelayTailLogCommand(logFile, errFile), + { signal } + ).catch(() => { + signal?.throwIfAborted() + return '(could not read log)' + }) throw new Error(`Relay failed to start within ${POLL_TIMEOUT_MS / 1000}s. Log:\n${logOutput}`) } @@ -1010,13 +1454,14 @@ async function connectWindowsRelay( remoteDir: string nodePath: string sockPath: string - } + }, + signal?: AbortSignal ): Promise { const channel = await conn.exec( windowsRelayConnectCommand(hostPlatform, opts.nodePath, opts.remoteDir, opts.sockPath), - { wrapCommand: false } + { wrapCommand: false, signal } ) - return waitForSentinel(channel) + return waitForSentinel(channel, signal) } function windowsRelayConnectCommand( @@ -1083,12 +1528,14 @@ async function probeWindowsRelayPipe( remoteDir: string nodePath: string sockPath: string - } + }, + signal?: AbortSignal ): Promise<'READY' | 'WAITING'> { const result = await execHostCommand( conn, hostPlatform, - windowsRelayProbeCommand(hostPlatform, opts.nodePath, opts.remoteDir, opts.sockPath) + windowsRelayProbeCommand(hostPlatform, opts.nodePath, opts.remoteDir, opts.sockPath), + { signal } ) return result.trim() === 'READY' ? 'READY' : 'WAITING' } @@ -1102,7 +1549,8 @@ async function waitForWindowsRelayPipe( sockPath: string }, timeoutMs: number, - intervalMs: number + intervalMs: number, + signal?: AbortSignal ): Promise { try { const result = await execHostCommand( @@ -1111,10 +1559,12 @@ async function waitForWindowsRelayPipe( windowsRelayWaitCommand(hostPlatform, opts.nodePath, opts.remoteDir, opts.sockPath, { timeoutMs, intervalMs - }) + }), + { signal } ) return result.trim() === 'READY' } catch { + signal?.throwIfAborted() return false } } diff --git a/src/main/ssh/ssh-relay-exec-command.ts b/src/main/ssh/ssh-relay-exec-command.ts new file mode 100644 index 00000000000..e4bfd8bc042 --- /dev/null +++ b/src/main/ssh/ssh-relay-exec-command.ts @@ -0,0 +1,156 @@ +import type { SshConnection } from './ssh-connection' +import { createSshOperationAbortError, type SshExecOptions } from './ssh-connection-utils' +import type { SystemSshCommandChannel } from './system-ssh-command' + +const EXEC_TIMEOUT_MS = 30_000 +const COMMAND_CLOSE_GRACE_MS = 5_000 +const MAX_EXEC_OUTPUT_CHARS = 1024 * 1024 + +type ExecCommandOptions = SshExecOptions & { + timeoutMs?: number +} + +type SshCommandTerminationError = Error & { + sshChannelCloseConfirmed: boolean +} + +export function isUnconfirmedSshCommandTermination( + error: unknown +): error is SshCommandTerminationError { + return ( + error instanceof Error && + (error as Partial).sshChannelCloseConfirmed === false + ) +} + +export async function execCommand( + conn: SshConnection, + command: string, + options?: ExecCommandOptions +): Promise { + const { timeoutMs = EXEC_TIMEOUT_MS, ...execOptions } = options ?? {} + const signal = options?.signal + if (signal?.aborted) { + throw createSshOperationAbortError() + } + // Why: reconnect/disconnect can flip the connection back to ssh2 before a + // killed local OpenSSH child emits close; the channel's transport is immutable. + const openedWithSystemSsh = conn.usesSystemSshTransport?.() === true + const channel = await conn.exec(command, execOptions) + return new Promise((resolve, reject) => { + let stdout = '' + let stderr = '' + let settled = false + let terminationError: SshCommandTerminationError | null = null + let closeGraceTimer: ReturnType | null = null + + const cleanup = (): void => { + clearTimeout(timeout) + if (closeGraceTimer) { + clearTimeout(closeGraceTimer) + } + signal?.removeEventListener('abort', onAbort) + 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 + cleanup() + fn(val as never) + } + const guardUnconfirmedTeardown = (): void => { + const swallowLateError = (): void => {} + const cleanupGuards = (): void => { + channel.off('error', swallowLateError) + channel.stderr.off('error', swallowLateError) + channel.off('close', cleanupGuards) + } + channel.on('error', swallowLateError) + channel.stderr.on('error', swallowLateError) + channel.once('close', cleanupGuards) + // Why: an unconfirmed close can arrive after the caller's bounded wait; + // keep draining discarded streams so ssh2 can finish CHANNEL_CLOSE. + channel.resume() + channel.stderr.resume() + } + // Why: sshd counts the session against MaxSessions until CHANNEL_CLOSE + // completes. Settling on abort before the channel actually closes lets the + // concurrent-bootstrap sequential fallback reissue an exec while the slot + // is still held, so it gets refused again. Close and settle from onClose. + const requestTermination = (error: Error): void => { + if (terminationError) { + return + } + terminationError = Object.assign(error, { sshChannelCloseConfirmed: false }) + clearTimeout(timeout) + // Why: callers must not release an install lock while its remote npm + // process can still mutate node_modules. Prefer confirmed channel close, + // but bound a broken transport's teardown wait. + closeGraceTimer = setTimeout(() => { + guardUnconfirmedTeardown() + settle(reject, error) + }, COMMAND_CLOSE_GRACE_MS) + channel.close() + } + const fail = (err: Error): void => requestTermination(err) + const onAbort = (): void => requestTermination(createSshOperationAbortError()) + const onStdoutData = (data: Buffer): void => { + stdout = appendExecOutputTail(stdout, data.toString('utf-8')) + } + const onStderrData = (data: Buffer): void => { + stderr = appendExecOutputTail(stderr, data.toString('utf-8')) + } + const onClose = (code: number): void => { + if ( + !terminationError && + openedWithSystemSsh && + (channel as SystemSshCommandChannel)._closeRequested + ) { + terminationError = Object.assign(createSshOperationAbortError(), { + sshChannelCloseConfirmed: false + }) + } + if (terminationError) { + // Why: a system-SSH channel closes when the local OpenSSH child exits; + // that does not prove the remote command stopped, especially with a ControlMaster. + if (!openedWithSystemSsh) { + terminationError.sshChannelCloseConfirmed = true + } + settle(reject, terminationError) + } else if (code !== 0) { + // Why: on the system-ssh transport channel.stderr carries local OpenSSH + // client noise; preferring it masks the real failure in stdout (2>&1). + const output = [stderr.trim(), stdout.trim()].filter(Boolean).join('\n') + settle(reject, new Error(`Command "${command}" failed (exit ${code}): ${output}`)) + } else { + settle(resolve, stdout) + } + } + const timeout = setTimeout(() => { + requestTermination(new Error(`Command "${command}" timed out after ${timeoutMs / 1000}s`)) + }, timeoutMs) + + // Why: remote reboot tears down exec channels with stream errors. Without + // scoped listeners, Node treats those as uncaught exceptions. + signal?.addEventListener('abort', onAbort, { once: true }) + channel.on('error', fail) + channel.stderr.on('error', fail) + channel.on('data', onStdoutData) + channel.stderr.on('data', onStderrData) + channel.on('close', onClose) + if (signal?.aborted) { + onAbort() + } + }) +} + +function appendExecOutputTail(existing: string, chunk: string): string { + const combined = existing + chunk + return combined.length > MAX_EXEC_OUTPUT_CHARS ? combined.slice(-MAX_EXEC_OUTPUT_CHARS) : combined +} diff --git a/src/main/ssh/ssh-relay-gc-claim.ts b/src/main/ssh/ssh-relay-gc-claim.ts new file mode 100644 index 00000000000..d0c8cc1221f --- /dev/null +++ b/src/main/ssh/ssh-relay-gc-claim.ts @@ -0,0 +1,236 @@ +import { randomUUID } from 'node:crypto' +import type { SshConnection } from './ssh-connection' +import { shellEscape } from './ssh-connection-utils' +import { execCommand } from './ssh-relay-deploy-helpers' +import { + probeInstallLockExistsCommand, + tryCreateInstallLockCommand, + tryStealInstallLockCommand +} from './ssh-relay-install-lock-commands' +import { removeRemoteTreeCommand } from './ssh-remote-commands' +import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' +import { + getRemoteHostPlatform, + isWindowsRemoteHost, + joinRemotePath, + type RemoteHostPlatform +} from './ssh-remote-platform' + +const DEFAULT_REMOTE_HOST = getRemoteHostPlatform('linux-x64') +const RELAY_GC_CLAIM_SUFFIX = '.gc-claim' +const RELAY_GC_OWNER_NAME = '.gc-owner' +// Why: the claim guards only a bounded sibling rename or launch handoff, not +// npm or deletion. Ten minutes bounds crashes while exceeding either sequence. +const RELAY_GC_CLAIM_STALE_SECONDS = 10 * 60 + +function execHostCommand( + conn: SshConnection, + host: RemoteHostPlatform, + command: string, + signal?: AbortSignal +): Promise { + return execCommand(conn, command, { + wrapCommand: !isWindowsRemoteHost(host), + signal + }) +} + +export function relayGcClaimPath(remoteRelayDir: string): string { + return `${remoteRelayDir}${RELAY_GC_CLAIM_SUFFIX}` +} + +export async function isRelayGcClaimed( + conn: SshConnection, + remoteRelayDir: string, + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, + signal?: AbortSignal +): Promise { + const claimPath = relayGcClaimPath(remoteRelayDir) + const output = await execHostCommand( + conn, + host, + probeInstallLockExistsCommand(host, claimPath), + signal + ) + const markers = new Set( + output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line === 'LOCKED' || line === 'OPEN') + ) + if (markers.size !== 1) { + throw new Error(`Inconclusive relay GC claim probe at ${claimPath}`) + } + return markers.has('LOCKED') +} + +export async function tryAcquireRelayGcClaim( + conn: SshConnection, + remoteRelayDir: string, + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, + signal?: AbortSignal +): Promise { + const claimPath = relayGcClaimPath(remoteRelayDir) + try { + const created = await execHostCommand( + conn, + host, + tryCreateInstallLockCommand(host, claimPath), + signal + ) + if (created.trim().endsWith('OK')) { + return writeRelayGcClaimOwner(conn, remoteRelayDir, host, signal) + } + const stolen = await execHostCommand( + conn, + host, + tryStealInstallLockCommand(host, claimPath, RELAY_GC_CLAIM_STALE_SECONDS), + signal + ) + if (!stolen.trim().endsWith('OK')) { + return null + } + return writeRelayGcClaimOwner(conn, remoteRelayDir, host, signal) + } catch { + signal?.throwIfAborted() + return null + } +} + +async function writeRelayGcClaimOwner( + conn: SshConnection, + remoteRelayDir: string, + host: RemoteHostPlatform, + signal?: AbortSignal +): Promise { + const token = `${process.pid}-${Date.now()}-${randomUUID()}` + const claimPath = relayGcClaimPath(remoteRelayDir) + const ownerPath = joinRemotePath(host, claimPath, RELAY_GC_OWNER_NAME) + const command = isWindowsRemoteHost(host) + ? powerShellCommand( + `Set-Content -LiteralPath ${powerShellLiteral(ownerPath)} -Value ${powerShellLiteral(token)} -NoNewline -ErrorAction Stop` + ) + : `printf %s ${shellEscape(token)} > ${shellEscape(ownerPath)}` + try { + await execHostCommand(conn, host, command, signal) + return token + } catch { + // Why: the write may have succeeded remotely before SSH lost its reply. + // A conditional release removes only the claim generation with our token. + await releaseRelayGcClaim(conn, remoteRelayDir, token, host) + return null + } +} + +export async function isRelayGcClaimOwned( + conn: SshConnection, + remoteRelayDir: string, + token: string, + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST +): Promise { + const ownerPath = joinRemotePath(host, relayGcClaimPath(remoteRelayDir), RELAY_GC_OWNER_NAME) + const command = isWindowsRemoteHost(host) + ? powerShellCommand( + `if ((Get-Content -LiteralPath ${powerShellLiteral(ownerPath)} -Raw -ErrorAction SilentlyContinue) -ceq ${powerShellLiteral(token)}) { 'OWNED' } else { 'LOST' }` + ) + : `test "$(cat ${shellEscape(ownerPath)} 2>/dev/null)" = ${shellEscape(token)} && echo OWNED || echo LOST` + const output = await execHostCommand(conn, host, command).catch(() => 'LOST') + return output.trim() === 'OWNED' +} + +export type RelayGcClaimReleaseResult = 'released' | 'lost' | 'unknown' + +export async function releaseRelayGcClaim( + conn: SshConnection, + remoteRelayDir: string, + token: string, + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST +): Promise { + const claimPath = relayGcClaimPath(remoteRelayDir) + const ownerPath = joinRemotePath(host, claimPath, RELAY_GC_OWNER_NAME) + const command = isWindowsRemoteHost(host) + ? powerShellCommand( + `$claim = ${powerShellLiteral(claimPath)}; ` + + `if (-not (Test-Path -LiteralPath $claim)) { 'RELEASED' } ` + + `elseif ((Get-Content -LiteralPath ${powerShellLiteral(ownerPath)} -Raw -ErrorAction SilentlyContinue) -cne ${powerShellLiteral(token)}) { 'LOST' } ` + + 'else { try { Remove-Item -LiteralPath $claim -Recurse -Force -ErrorAction Stop } catch {}; ' + + "if (Test-Path -LiteralPath $claim) { 'UNKNOWN' } else { 'RELEASED' } }" + ) + : [ + `if ! test -e ${shellEscape(claimPath)}; then echo RELEASED;`, + `elif test "$(cat ${shellEscape(ownerPath)} 2>/dev/null)" != ${shellEscape(token)}; then echo LOST;`, + `else ${removeRemoteTreeCommand(host, claimPath)} 2>/dev/null;`, + `if test -e ${shellEscape(claimPath)}; then echo UNKNOWN; else echo RELEASED; fi; fi` + ].join(' ') + const output = await execHostCommand(conn, host, command).catch(() => 'UNKNOWN') + switch (output.trim()) { + case 'RELEASED': + return 'released' + case 'LOST': + return 'lost' + default: + return 'unknown' + } +} + +export async function releaseRelayGcClaimWithRetry( + conn: SshConnection, + remoteRelayDir: string, + token: string, + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST +): Promise { + let result: RelayGcClaimReleaseResult = 'unknown' + for (let attempt = 0; attempt < 3 && result === 'unknown'; attempt++) { + result = await releaseRelayGcClaim(conn, remoteRelayDir, token, host) + } + return result +} + +export async function waitForRelayGcClaimRelease( + conn: SshConnection, + remoteRelayDir: string, + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, + signal?: AbortSignal +): Promise { + while (true) { + const claimed = await isRelayGcClaimed(conn, remoteRelayDir, host, signal).catch(() => true) + signal?.throwIfAborted() + if (!claimed) { + return + } + const claimPath = relayGcClaimPath(remoteRelayDir) + const recovered = await execHostCommand( + conn, + host, + tryStealInstallLockCommand(host, claimPath, RELAY_GC_CLAIM_STALE_SECONDS), + signal + ).catch(() => 'BUSY') + signal?.throwIfAborted() + if (recovered.trim().endsWith('OK')) { + const token = await writeRelayGcClaimOwner(conn, remoteRelayDir, host, signal) + if (token) { + const release = await releaseRelayGcClaim(conn, remoteRelayDir, token, host) + if (release === 'released') { + return + } + } + // A lost or uncertain release can mean a successor owns the stable path. + // Probe again instead of treating recovery as complete. + continue + } + await new Promise((resolve, reject) => { + const onAbort = (): void => { + clearTimeout(timer) + reject(signal?.reason) + } + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort) + resolve() + }, 1_000) + signal?.addEventListener('abort', onAbort, { once: true }) + if (signal?.aborted) { + onAbort() + } + }) + } +} diff --git a/src/main/ssh/ssh-relay-gc-retry.test.ts b/src/main/ssh/ssh-relay-gc-retry.test.ts new file mode 100644 index 00000000000..fee4946d356 --- /dev/null +++ b/src/main/ssh/ssh-relay-gc-retry.test.ts @@ -0,0 +1,275 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ app: { getAppPath: () => '/mock/app' } })) +vi.mock('fs', () => ({ + existsSync: vi.fn().mockReturnValue(true), + readFileSync: vi.fn().mockReturnValue('0.1.0+gc-retry') +})) +vi.mock('./relay-protocol', () => ({ + RELAY_VERSION: '0.1.0', + RELAY_REMOTE_DIR: '.orca-remote', + parseUnameToRelayPlatform: vi.fn().mockReturnValue('linux-x64'), + RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', + RELAY_SENTINEL_TIMEOUT_MS: 10_000 +})) +vi.mock('./ssh-relay-deploy-helpers', () => ({ + uploadDirectory: vi.fn(), + waitForSentinel: vi.fn().mockResolvedValue({ + write: vi.fn(), + onData: vi.fn(), + onClose: vi.fn() + }), + isUnconfirmedSshCommandTermination: () => false, + execCommand: vi.fn() +})) +vi.mock('./ssh-remote-node-resolution', () => ({ + resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') +})) +vi.mock('./ssh-relay-versioned-install', () => ({ + readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+gc-retry'), + computeRemoteRelayDir: (home: string, version: string) => `${home}/.orca-remote/relay-${version}`, + isRelayAlreadyInstalled: vi.fn().mockResolvedValue(true), + finalizeInstall: vi.fn(), + abandonInstall: vi.fn(), + gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) +})) +vi.mock('./ssh-relay-install-lock', () => ({ acquireInstallLock: vi.fn() })) +vi.mock('./ssh-relay-repair-lock', () => ({ + tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') +})) +vi.mock('./ssh-relay-gc-claim', () => ({ + releaseRelayGcClaimWithRetry: vi.fn().mockResolvedValue('released'), + tryAcquireRelayGcClaim: vi.fn().mockResolvedValue('launch-token'), + waitForRelayGcClaimRelease: vi.fn().mockResolvedValue(undefined) +})) +vi.mock('./ssh-connection-utils', () => ({ + shellEscape: (value: string) => `'${value}'`, + createSshOperationAbortError: () => Object.assign(new Error('cancelled'), { name: 'AbortError' }) +})) + +import { deployAndLaunchRelay } from './ssh-relay-deploy' +import { execCommand, waitForSentinel } from './ssh-relay-deploy-helpers' +import { + releaseRelayGcClaimWithRetry, + tryAcquireRelayGcClaim, + waitForRelayGcClaimRelease +} from './ssh-relay-gc-claim' +import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock' +import { abandonInstall, isRelayAlreadyInstalled } from './ssh-relay-versioned-install' +import type { SshConnection } from './ssh-connection' + +function makeConnection(): SshConnection { + const channel = { + on: vi.fn(), + stderr: { on: vi.fn() }, + close: vi.fn() + } + return { + canRunConcurrentExecCommands: vi.fn().mockReturnValue(true), + exec: vi.fn().mockResolvedValue(channel) + } as unknown as SshConnection +} + +describe('relay GC deploy retry', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('recomputes install state when GC wins before a healthy relay launch', async () => { + const conn = makeConnection() + vi.mocked(tryAcquireRelayRepairLock).mockResolvedValueOnce('gc') + vi.mocked(execCommand).mockImplementation(async (_conn, command) => { + if (command.includes('__ORCA_REMOTE_PLATFORM__')) { + return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' + } + if (command === 'echo $HOME') { + return '/home/user' + } + if (command.includes('node-pty')) { + return 'ORCA-NATIVE-DEPS-OK' + } + if (command.includes('var s=require("net").connect')) { + return 'READY' + } + if (command.includes('test -S')) { + return 'DEAD' + } + return '' + }) + + await deployAndLaunchRelay(conn) + + expect(waitForRelayGcClaimRelease).toHaveBeenCalledTimes(1) + expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(3) + expect(conn.exec).toHaveBeenCalledTimes(2) + }) + + it('recomputes state when GC finishes between the install probe and lock acquisition', async () => { + const conn = makeConnection() + vi.mocked(isRelayAlreadyInstalled) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockResolvedValue(true) + vi.mocked(execCommand).mockImplementation(async (_conn, command) => { + if (command.includes('__ORCA_REMOTE_PLATFORM__')) { + return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' + } + if (command === 'echo $HOME') { + return '/home/user' + } + if (command.includes('node-pty')) { + return 'ORCA-NATIVE-DEPS-OK' + } + if (command.includes('var s=require("net").connect')) { + return 'READY' + } + if (command.includes('test -S')) { + return 'DEAD' + } + return '' + }) + + await deployAndLaunchRelay(conn) + + expect(abandonInstall).toHaveBeenCalledTimes(2) + expect(waitForRelayGcClaimRelease).toHaveBeenCalledTimes(1) + expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(4) + expect(tryAcquireRelayRepairLock).toHaveBeenCalledTimes(2) + }) + + it('owns a GC claim through launch when the install-lock state is indeterminate', async () => { + const conn = makeConnection() + vi.mocked(tryAcquireRelayRepairLock).mockResolvedValueOnce('error') + let resolveReady: ((value: Awaited>) => void) | undefined + vi.mocked(waitForSentinel).mockImplementationOnce( + async () => + new Promise>>((resolve) => { + resolveReady = resolve + }) + ) + vi.mocked(execCommand).mockImplementation(async (_conn, command) => { + if (command.includes('__ORCA_REMOTE_PLATFORM__')) { + return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' + } + if (command === 'echo $HOME') { + return '/home/user' + } + if (command.includes('node-pty')) { + return 'ORCA-NATIVE-DEPS-OK' + } + if (command.includes('var s=require("net").connect')) { + return 'READY' + } + if (command.includes('test -S')) { + return 'DEAD' + } + return '' + }) + + const deploy = deployAndLaunchRelay(conn) + await vi.waitFor(() => expect(resolveReady).toBeDefined()) + expect(releaseRelayGcClaimWithRetry).not.toHaveBeenCalled() + resolveReady?.({ + write: vi.fn(), + onData: vi.fn(), + onClose: vi.fn() + }) + await deploy + + expect(waitForRelayGcClaimRelease).not.toHaveBeenCalled() + expect(tryAcquireRelayRepairLock).toHaveBeenCalledTimes(1) + expect(tryAcquireRelayGcClaim).toHaveBeenCalledTimes(1) + expect(releaseRelayGcClaimWithRetry).toHaveBeenCalledTimes(1) + expect(conn.exec).toHaveBeenCalledTimes(2) + }) + + it('retains an owned install lock when launch never becomes live', async () => { + const conn = makeConnection() + vi.mocked(waitForSentinel).mockRejectedValueOnce(new Error('launch failed')) + vi.mocked(execCommand).mockImplementation(async (_conn, command) => { + if (command.includes('__ORCA_REMOTE_PLATFORM__')) { + return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' + } + if (command === 'echo $HOME') { + return '/home/user' + } + if (command.includes('node-pty')) { + return 'ORCA-NATIVE-DEPS-OK' + } + if (command.includes('var s=require("net").connect')) { + return 'READY' + } + if (command.includes('test -S')) { + return 'DEAD' + } + return '' + }) + + await expect(deployAndLaunchRelay(conn)).rejects.toThrow('launch failed') + + expect(abandonInstall).not.toHaveBeenCalled() + expect(releaseRelayGcClaimWithRetry).not.toHaveBeenCalled() + }) + + it('retains an owned launch claim when launch never becomes live', async () => { + const conn = makeConnection() + vi.mocked(tryAcquireRelayRepairLock).mockResolvedValueOnce('busy') + vi.mocked(waitForSentinel).mockRejectedValueOnce(new Error('launch failed')) + vi.mocked(execCommand).mockImplementation(async (_conn, command) => { + if (command.includes('__ORCA_REMOTE_PLATFORM__')) { + return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' + } + if (command === 'echo $HOME') { + return '/home/user' + } + if (command.includes('node-pty')) { + return 'ORCA-NATIVE-DEPS-OK' + } + if (command.includes('var s=require("net").connect')) { + return 'READY' + } + if (command.includes('test -S')) { + return 'DEAD' + } + return '' + }) + + await expect(deployAndLaunchRelay(conn)).rejects.toThrow('launch failed') + + expect(abandonInstall).not.toHaveBeenCalled() + expect(releaseRelayGcClaimWithRetry).not.toHaveBeenCalled() + }) + + it('keeps retrying repeated launch-claim contention within the deploy bound', async () => { + const conn = makeConnection() + vi.mocked(tryAcquireRelayRepairLock).mockResolvedValue('busy') + vi.mocked(tryAcquireRelayGcClaim) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValue('launch-token') + vi.mocked(execCommand).mockImplementation(async (_conn, command) => { + if (command.includes('__ORCA_REMOTE_PLATFORM__')) { + return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' + } + if (command === 'echo $HOME') { + return '/home/user' + } + if (command.includes('node-pty')) { + return 'ORCA-NATIVE-DEPS-OK' + } + if (command.includes('var s=require("net").connect')) { + return 'READY' + } + if (command.includes('test -S')) { + return 'DEAD' + } + return '' + }) + + await deployAndLaunchRelay(conn) + + expect(waitForRelayGcClaimRelease).toHaveBeenCalledTimes(2) + expect(tryAcquireRelayGcClaim).toHaveBeenCalledTimes(3) + expect(releaseRelayGcClaimWithRetry).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/ssh/ssh-relay-gc-tombstone.ts b/src/main/ssh/ssh-relay-gc-tombstone.ts new file mode 100644 index 00000000000..95684d4d20e --- /dev/null +++ b/src/main/ssh/ssh-relay-gc-tombstone.ts @@ -0,0 +1,33 @@ +import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' +import { removeRemoteTreeCommand } from './ssh-remote-commands' +import { + getRemoteHostPlatform, + isWindowsRemoteHost, + joinRemotePath, + type RemoteHostPlatform +} from './ssh-remote-platform' + +const DEFAULT_REMOTE_HOST = getRemoteHostPlatform('linux-x64') +const RELAY_GC_TOMBSTONE_REGEX = + /^relay-(?:v?\d+\.\d+\.\d+(?:\+[0-9a-f]+)?)\.gc-tombstone\.\d+\.\d+$/ + +export async function cleanupRelayGcTombstones( + conn: SshConnection, + baseDir: string, + entries: string[], + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST +): Promise { + // Why: a confirmed rename isolates these paths from any recreated install. + // Strictly named leftovers are safe to retry after an interrupted GC pass. + for (const name of entries.filter((entry) => RELAY_GC_TOMBSTONE_REGEX.test(entry))) { + const tombstone = joinRemotePath(host, baseDir, name) + await execCommand(conn, removeRemoteTreeCommand(host, tombstone), { + wrapCommand: !isWindowsRemoteHost(host) + }).catch((err) => { + console.warn( + `[ssh-relay] GC failed to remove tombstone ${tombstone}: ${err instanceof Error ? err.message : String(err)}` + ) + }) + } +} diff --git a/src/main/ssh/ssh-relay-install-lock-commands.ts b/src/main/ssh/ssh-relay-install-lock-commands.ts new file mode 100644 index 00000000000..d6eab561a2d --- /dev/null +++ b/src/main/ssh/ssh-relay-install-lock-commands.ts @@ -0,0 +1,176 @@ +import { shellEscape } from './ssh-connection-utils' +import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' +import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' + +export function acquireInstallLockParentCommand( + host: RemoteHostPlatform, + remoteRelayDir: string +): string { + if (!isWindowsRemoteHost(host)) { + return `mkdir -p ${shellEscape(remoteRelayDir)}` + } + return powerShellCommand( + `$null = New-Item -ItemType Directory -Force -Path ${powerShellLiteral(remoteRelayDir)}` + ) +} + +export function tryCreateInstallLockCommand(host: RemoteHostPlatform, lockDir: string): string { + if (!isWindowsRemoteHost(host)) { + return `mkdir ${shellEscape(lockDir)} 2>&1 && echo OK || echo BUSY` + } + // Why: old Orca clients recognize only a directory at `.install-lock`, while + // concurrent New-Item calls can both report success in PowerShell 5.1. Keep + // that directory marker and arbitrate ownership with an atomic child file. + return powerShellCommand( + [ + `$lock = ${powerShellLiteral(lockDir)}`, + '$stream = $null', + 'try {', + "if (Test-Path -LiteralPath $lock) { 'BUSY' } else {", + '$null = New-Item -ItemType Directory -Path $lock -ErrorAction Stop', + "$owner = Join-Path $lock '.owner'", + '$stream = [System.IO.File]::Open($owner, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)', + "'OK'", + '}', + `} catch { 'BUSY' } finally { if ($null -ne $stream) { $stream.Dispose() } }` + ].join('; ') + ) +} + +export function probeInstallLockExistsCommand(host: RemoteHostPlatform, lockPath: string): string { + if (!isWindowsRemoteHost(host)) { + return `test -e ${shellEscape(lockPath)} && echo LOCKED || echo OPEN` + } + // Why: one prerelease briefly wrote file locks; accept both shapes so those + // hosts remain recoverable after upgrading to directory-plus-owner locks. + return powerShellCommand( + `if (Test-Path -LiteralPath ${powerShellLiteral(lockPath)}) { 'LOCKED' } else { 'OPEN' }` + ) +} + +export function lockAgeSecondsCommand(host: RemoteHostPlatform, lockDir: string): string { + if (!isWindowsRemoteHost(host)) { + return `${posixLockAgeSecondsAssignment(lockDir)} && echo "$age" || echo` + } + return powerShellCommand( + [ + `$item = Get-Item -LiteralPath ${powerShellLiteral(lockDir)} -ErrorAction Stop`, + '$mtime = ([DateTimeOffset]$item.LastWriteTimeUtc).ToUnixTimeSeconds()', + '$now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()', + 'Write-Output ($now - $mtime)' + ].join('; ') + ) +} + +export function tryStealInstallLockCommand( + host: RemoteHostPlatform, + lockDir: string, + staleAfterSeconds: number +): string { + if (!isWindowsRemoteHost(host)) { + return posixStealInstallLockCommand(lockDir, staleAfterSeconds) + } + return windowsStealInstallLockCommand(lockDir, staleAfterSeconds) +} + +function posixStealInstallLockCommand(lockDir: string, staleAfterSeconds: number): string { + const escapedLockDir = shellEscape(lockDir) + const escapedStealLockPrefix = shellEscape(`${lockDir}.steal`) + return [ + `${posixLockIdentityAssignment(lockDir, 'lock_key')} && mtime=\${lock_key%%:*} && now=$(date +%s) && age=$((now - mtime)) || age=0;`, + `if [ "\${age:-0}" -le ${staleAfterSeconds} ] 2>/dev/null; then echo BUSY; else`, + `steal_root=${escapedStealLockPrefix};`, + 'steal_generation=0;', + 'steal="$steal_root.$steal_generation";', + 'owns_steal=0;', + 'lock_tombstone=;', + 'while [ "$owns_steal" != 1 ]; do', + 'if mkdir "$steal" 2>/dev/null; then owns_steal=1; break; fi;', + `steal_mtime=$(stat -c %Y "$steal" 2>/dev/null || stat -f %m "$steal" 2>/dev/null) && steal_now=$(date +%s) && steal_age=$((steal_now - steal_mtime)) || break;`, + 'if [ "${steal_age:-0}" -le 120 ] 2>/dev/null; then break; fi;', + 'steal_generation=$((steal_generation + 1));', + 'steal="$steal_root.$steal_generation";', + 'done;', + 'if [ "$owns_steal" = 1 ]; then', + `trap 'rm -rf "$steal_root".* 2>/dev/null || true; rm -rf "$lock_tombstone" 2>/dev/null || true' EXIT;`, + `${posixLockIdentityAssignment(lockDir, 'current_key')} && current_mtime=\${current_key%%:*} && current_now=$(date +%s) && current_age=$((current_now - current_mtime)) || current_age=0;`, + `if [ "$current_key" = "$lock_key" ] && [ "\${current_age:-0}" -gt ${staleAfterSeconds} ] 2>/dev/null; then`, + `lock_tombstone=${escapedLockDir}.tombstone.$$.$(date +%s);`, + `if [ ! -e "$lock_tombstone" ] && mv ${escapedLockDir} "$lock_tombstone" 2>/dev/null; then mkdir ${escapedLockDir} 2>&1 && echo OK || echo BUSY; else echo BUSY; fi;`, + 'else echo BUSY; fi;', + 'else echo BUSY; fi; fi' + ].join(' ') +} + +function windowsStealInstallLockCommand(lockDir: string, staleAfterSeconds: number): string { + return powerShellCommand( + [ + `$lock = ${powerShellLiteral(lockDir)}`, + 'try {', + '$item = Get-Item -LiteralPath $lock -ErrorAction Stop', + '$mtime = ([DateTimeOffset]$item.LastWriteTimeUtc).ToUnixTimeSeconds()', + '$lockIdentity = "${mtime}:$($item.CreationTimeUtc.Ticks)"', + '$now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()', + `if (($now - $mtime) -le ${staleAfterSeconds}) { 'BUSY' } else {`, + '$stealRoot = "$lock.steal"', + '$stealGeneration = 0', + '$steal = "$stealRoot.$stealGeneration"', + '$ownsSteal = $false', + '$lockTombstone = $null', + 'try {', + 'while (-not $ownsSteal) {', + 'try {', + '$stealStream = [System.IO.File]::Open($steal, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)', + '$stealStream.Dispose()', + '$ownsSteal = $true', + 'break', + '} catch {', + 'try {', + '$stealItem = Get-Item -LiteralPath $steal -ErrorAction Stop', + '$stealMtime = ([DateTimeOffset]$stealItem.LastWriteTimeUtc).ToUnixTimeSeconds()', + '$stealAge = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $stealMtime', + 'if ($stealAge -le 120) { break }', + '$stealGeneration++', + '$steal = "$stealRoot.$stealGeneration"', + '} catch { break }', + '}', + '}', + "if (-not $ownsSteal) { 'BUSY' } else {", + '$current = Get-Item -LiteralPath $lock -ErrorAction Stop', + '$currentMtime = ([DateTimeOffset]$current.LastWriteTimeUtc).ToUnixTimeSeconds()', + '$currentIdentity = "${currentMtime}:$($current.CreationTimeUtc.Ticks)"', + '$currentNow = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()', + `if (($currentIdentity -eq $lockIdentity) -and (($currentNow - $currentMtime) -gt ${staleAfterSeconds})) {`, + '$lockTombstone = "$lock.tombstone.$PID.$([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds())"', + 'Move-Item -LiteralPath $lock -Destination $lockTombstone -ErrorAction Stop', + '$successorStream = $null', + "try { $null = New-Item -ItemType Directory -Path $lock -ErrorAction Stop; $successorOwner = Join-Path $lock '.owner'; $successorStream = [System.IO.File]::Open($successorOwner, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None); 'OK' } catch { 'BUSY' } finally { if ($null -ne $successorStream) { $successorStream.Dispose() } }", + "} else { 'BUSY' }", + '}', + "} catch { 'BUSY' } finally {", + 'if ($ownsSteal) {', + '$stealParent = Split-Path -Parent $stealRoot', + '$stealLeaf = Split-Path -Leaf $stealRoot', + 'Get-ChildItem -LiteralPath $stealParent -Force -ErrorAction SilentlyContinue | Where-Object { $_.Name.StartsWith($stealLeaf + ".", [StringComparison]::Ordinal) } | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue', + '}', + 'if ($null -ne $lockTombstone) { Remove-Item -LiteralPath $lockTombstone -Recurse -Force -ErrorAction SilentlyContinue }', + '}', + '}', + "} catch { 'BUSY' }" + ].join('; ') + ) +} + +function posixLockAgeSecondsAssignment(lockDir: string): string { + return `${posixLockMtimeSecondsAssignment(lockDir, 'mtime')} && now=$(date +%s) && age=$((now - mtime))` +} + +function posixLockIdentityAssignment(lockDir: string, variableName: string): string { + const escapedLockDir = shellEscape(lockDir) + return `${variableName}=$(stat -c %Y:%i ${escapedLockDir} 2>/dev/null || stat -f %m:%i ${escapedLockDir} 2>/dev/null)` +} + +function posixLockMtimeSecondsAssignment(lockDir: string, variableName: string): string { + const escapedLockDir = shellEscape(lockDir) + return `${variableName}=$(stat -c %Y ${escapedLockDir} 2>/dev/null || stat -f %m ${escapedLockDir} 2>/dev/null)` +} diff --git a/src/main/ssh/ssh-relay-install-lock.ts b/src/main/ssh/ssh-relay-install-lock.ts new file mode 100644 index 00000000000..7cfd561b1ad --- /dev/null +++ b/src/main/ssh/ssh-relay-install-lock.ts @@ -0,0 +1,162 @@ +import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' +import { RELAY_DEPLOY_TIMEOUT_MS } from './ssh-relay-deploy-timing' +import { isRelayGcClaimed, waitForRelayGcClaimRelease } from './ssh-relay-gc-claim' +import { + acquireInstallLockParentCommand, + lockAgeSecondsCommand, + tryCreateInstallLockCommand, + tryStealInstallLockCommand +} from './ssh-relay-install-lock-commands' +import { + getRemoteHostPlatform, + joinRemotePath, + type RemoteHostPlatform +} from './ssh-remote-platform' +import { removeRemoteTreeCommand } from './ssh-remote-commands' + +export const RELAY_INSTALL_LOCK_NAME = '.install-lock' + +const INSTALL_LOCK_POLL_MS = 1_000 +// Why: a fresh lock can cross the stale threshold during our bounded wait. +// Recheck infrequently so it becomes recoverable without adding an exec per poll. +const INSTALL_LOCK_STALE_RECHECK_MS = 60_000 +// Why: the lock holder can legitimately use the full deploy bound for upload, +// native install/rebuild, probes, and finalization. A concurrent first install +// must not fail earlier; completed-relay repair uses a separate one-shot path. +const INSTALL_LOCK_TIMEOUT_MS = RELAY_DEPLOY_TIMEOUT_MS +// Why: native-deps repair can keep running after the deploy backstop wins its +// Promise.race, so stale takeover must leave room for that bounded work. +export const INSTALL_LOCK_STALE_MS = 20 * 60_000 +export const INSTALL_LOCK_STALE_SECONDS = INSTALL_LOCK_STALE_MS / 1000 +const DEFAULT_REMOTE_HOST = getRemoteHostPlatform('linux-x64') + +function execHostCommand( + conn: SshConnection, + host: RemoteHostPlatform, + command: string, + options?: { signal?: AbortSignal } +): Promise { + return execCommand(conn, command, { + wrapCommand: host.commandDialect !== 'powershell', + signal: options?.signal + }) +} + +export async function isRelayInstallLockStale( + conn: SshConnection, + lockDir: string, + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST +): Promise { + try { + // Why: remote time avoids clock skew between Orca clients making a live + // repair lock look old enough for GC or another installer to recover. + const out = await execHostCommand(conn, host, lockAgeSecondsCommand(host, lockDir)) + const ageSec = Number.parseInt(out.trim(), 10) + return Number.isFinite(ageSec) && ageSec >= 0 && ageSec * 1000 > INSTALL_LOCK_STALE_MS + } catch { + return false + } +} + +/** + * Acquire the per-version install lock via a host-native exclusive create. + * Why: POSIX mkdir and a Windows directory plus atomic owner file each give + * one winner while keeping the marker visible to older Windows Orca clients. + */ +export async function acquireInstallLock( + conn: SshConnection, + remoteRelayDir: string, + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, + options?: { signal?: AbortSignal } +): Promise { + const lockDir = joinRemotePath(host, remoteRelayDir, RELAY_INSTALL_LOCK_NAME) + + const start = Date.now() + let lastStaleCheckAt = Number.NEGATIVE_INFINITY + while (true) { + // Why: a crashed GC can leave the stable sibling claim behind. The shared + // waiter recovers stale claims instead of polling that orphan forever. + await waitForRelayGcClaimRelease(conn, remoteRelayDir, host, options?.signal) + options?.signal?.throwIfAborted() + await execHostCommand(conn, host, acquireInstallLockParentCommand(host, remoteRelayDir), { + signal: options?.signal + }) + try { + const result = await execHostCommand(conn, host, tryCreateInstallLockCommand(host, lockDir), { + signal: options?.signal + }) + if (result.trim().endsWith('OK')) { + // Why: GC may claim the sibling path between our first probe and lock + // creation. Recheck while holding the in-tree lock; one side backs off. + const claimedAfterAcquire = await isRelayGcClaimed( + conn, + remoteRelayDir, + host, + options?.signal + ).catch(() => true) + if (!claimedAfterAcquire && !options?.signal?.aborted) { + return + } + await execHostCommand(conn, host, removeRemoteTreeCommand(host, lockDir)).catch(() => {}) + options?.signal?.throwIfAborted() + } + } catch { + options?.signal?.throwIfAborted() + // A failed mkdir is lock contention; keep the connection-specific error + // out of the user path until the bounded wait expires. + } + if (Date.now() - lastStaleCheckAt >= INSTALL_LOCK_STALE_RECHECK_MS) { + lastStaleCheckAt = Date.now() + // Why: recover an already-stale lock immediately, then keep checking in + // case a fresh holder crosses the stale threshold while we are waiting. + const steal = await execHostCommand( + conn, + host, + tryStealInstallLockCommand(host, lockDir, INSTALL_LOCK_STALE_SECONDS), + { signal: options?.signal } + ).catch(() => 'BUSY') + options?.signal?.throwIfAborted() + if (steal.trim().endsWith('OK')) { + console.warn(`[ssh-relay] Stealing stale install lock at ${lockDir}`) + const claimedAfterSteal = await isRelayGcClaimed( + conn, + remoteRelayDir, + host, + options?.signal + ).catch(() => true) + if (!claimedAfterSteal && !options?.signal?.aborted) { + return + } + await execHostCommand(conn, host, removeRemoteTreeCommand(host, lockDir)).catch(() => {}) + options?.signal?.throwIfAborted() + } + } + if (Date.now() - start >= INSTALL_LOCK_TIMEOUT_MS) { + throw new Error( + `Could not acquire relay install lock at ${lockDir} after ${ + INSTALL_LOCK_TIMEOUT_MS / 1000 + }s; another install is still in progress.` + ) + } + await waitForInstallLockPoll(options?.signal) + } +} + +function waitForInstallLockPoll(signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const handleAbort = (): void => { + clearTimeout(timeout) + signal?.removeEventListener('abort', handleAbort) + reject(signal?.reason) + } + const timeout = setTimeout(() => { + signal?.removeEventListener('abort', handleAbort) + resolve() + }, INSTALL_LOCK_POLL_MS) + signal?.addEventListener('abort', handleAbort, { once: true }) + if (signal?.aborted) { + handleAbort() + } + }) +} diff --git a/src/main/ssh/ssh-relay-native-deps-install.test.ts b/src/main/ssh/ssh-relay-native-deps-install.test.ts index 4d2f92eb947..f38a2b1065b 100644 --- a/src/main/ssh/ssh-relay-native-deps-install.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-install.test.ts @@ -1,6 +1,6 @@ // Why: regression coverage for the install-probe contract. The original // "node-pty is not available" bug shipped because every layer that should -// have caught it (chained shell, swallowing catch, dir-only probe) was +// have caught it (chained shell, swallowing catch, resolve-only probe) was // silent. Tests below pin the parts that, individually, would have caught // it. @@ -30,6 +30,9 @@ vi.mock('./ssh-relay-deploy-helpers', () => ({ onData: vi.fn(), onClose: vi.fn() }), + isUnconfirmedSshCommandTermination: (error: unknown) => + error instanceof Error && + (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, execCommand: vi.fn() })) @@ -41,26 +44,41 @@ vi.mock('./ssh-relay-versioned-install', () => ({ readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+testhash'), computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, isRelayAlreadyInstalled: vi.fn().mockResolvedValue(false), - acquireInstallLock: vi.fn().mockResolvedValue(undefined), finalizeInstall: vi.fn().mockResolvedValue(undefined), abandonInstall: vi.fn().mockResolvedValue(undefined), gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) })) +vi.mock('./ssh-relay-install-lock', () => ({ + acquireInstallLock: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('./ssh-relay-repair-lock', () => ({ + tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') +})) + +vi.mock('./ssh-relay-gc-claim', () => ({ + releaseRelayGcClaimWithRetry: vi.fn().mockResolvedValue('released'), + tryAcquireRelayGcClaim: vi.fn().mockResolvedValue('launch-token'), + waitForRelayGcClaimRelease: vi.fn().mockResolvedValue(undefined) +})) + vi.mock('./ssh-connection-utils', () => ({ shellEscape: (s: string) => `'${s}'` })) import { deployAndLaunchRelay } from './ssh-relay-deploy' -import { execCommand } from './ssh-relay-deploy-helpers' +import { execCommand, uploadDirectory } from './ssh-relay-deploy-helpers' +import { RELAY_DEPLOY_TIMEOUT_MS } from './ssh-relay-deploy-timing' import { parseUnameToRelayPlatform } from './relay-protocol' import { resolveRemoteNodePath } from './ssh-remote-node-resolution' import { - acquireInstallLock, abandonInstall, finalizeInstall, isRelayAlreadyInstalled } from './ssh-relay-versioned-install' +import { acquireInstallLock } from './ssh-relay-install-lock' +import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock' import type { SshConnection } from './ssh-connection' type SftpWriteCapture = { @@ -129,6 +147,7 @@ function decodePowerShellCommand(command: string): string | null { // 7: probe (cd && node -e require) // [8: cat stderr — only when probe stdout is MISSING (graceful path)] // 8 or 9: rm probe-stderr (best-effort cleanup; runs whenever probe resolved) +// [next: npm rebuild → chmod → second probe when the first probe is MISSING] // next: socket DEAD next: socket READY // // When the probe rejects (SSH channel close or cd-failure when the install @@ -143,6 +162,9 @@ function makeExecResponses(opts: { // Override probe stdout for shell-noise pressure tests. If set, replaces // the load-test stdout entirely (useful for testing pollution prefixes). probeStdoutOverride?: string + // Result after the automatic rebuild. Defaults to missing so legacy tests + // continue to exercise the final degraded-mode warning. + repairProbe?: 'ok' | 'missing' // Raw stdout for the build-toolchain probe that runs in installNativeDeps' // catch when `npm install` rejects on Linux. Defaults to a fully-present // toolchain so the original npm error propagates unchanged. @@ -187,6 +209,16 @@ function makeExecResponses(opts: { slots.push('') // cat stderr (graceful failure path captures detail) } slots.push('') // rm -f stderr (best-effort cleanup) + if (!probeOk) { + slots.push('') // npm rebuild with lifecycle scripts explicitly enabled + slots.push('') // chmod prebuilds after rebuild + const repairProbe = opts.repairProbe === 'ok' ? 'ORCA-NPTY-PROBE-OK\n' : 'MISSING\n' + slots.push(repairProbe) + if (!repairProbe.includes('ORCA-NPTY-PROBE-OK')) { + slots.push('') // cat stderr after unsuccessful rebuild + } + slots.push('') // rm -f stderr after rebuild probe + } } slots.push('DEAD', 'READY') return slots @@ -207,6 +239,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { // a leaked response. clearAllMocks doesn't drop the queue (it only clears // .mock.calls), so we explicitly mockReset. vi.mocked(execCommand).mockReset() + vi.mocked(uploadDirectory).mockResolvedValue(undefined) sftpCapture.paths.length = 0 for (const k of Object.keys(sftpCapture.contents)) { delete sftpCapture.contents[k] @@ -256,12 +289,17 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { // break `require('node-pty')`. expect(parsed.type).toBe('commonjs') expect(parsed.dependencies).toEqual({ '@parcel/watcher': '2.5.6', 'node-pty': '1.1.0' }) + expect(parsed.allowScripts).toEqual({ + '@parcel/watcher@2.5.6': true, + 'node-pty@1.1.0': true + }) const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) const npmInstallIdx = execCalls.findIndex( (c) => c.includes('npm install') && c.includes('node-pty') && c.includes('@parcel/watcher') ) expect(npmInstallIdx).toBeGreaterThanOrEqual(0) + expect(execCalls[npmInstallIdx]).toContain('--ignore-scripts=false') // Pin actual ordering: number of execCommand calls observed at the moment // ws.end() ran for package.json must be < the index of `npm install`. // Catches a future refactor that fires SFTP-write and npm install via @@ -376,7 +414,96 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(true) expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) - expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() + expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) + }) + + it('rebuilds unloadable native deps and recovers before first relay launch', async () => { + const conn = makeMockConnection(sftpCapture) + feed(makeExecResponses({ npmInstall: 'ok', probe: 'missing', repairProbe: 'ok' })) + + await deployAndLaunchRelay(conn) + + const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) + const failedProbeIdx = execCalls.findIndex((c) => c.includes('require("node-pty")')) + const rebuildIdx = execCalls.findIndex((c) => c.includes('npm rebuild')) + const repairedProbeIdx = execCalls.findIndex( + (c, index) => index > rebuildIdx && c.includes('require("node-pty")') + ) + expect(rebuildIdx).toBeGreaterThan(failedProbeIdx) + expect(execCalls[rebuildIdx]).toContain('--ignore-scripts=false') + expect(repairedProbeIdx).toBeGreaterThan(rebuildIdx) + + const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) + expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(false) + expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) + }) + + it('propagates an SSH-channel failure from the post-rebuild re-probe', async () => { + // Why: the rebuild itself degrades gracefully, but the verification probe + // after it must still surface transport death — conflating a dead channel + // with "native deps missing" would finalize a half-repaired install. + const conn = makeMockConnection(sftpCapture) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', // uname + '/home/u', // $HOME + '', // mkdir remoteDir (uploadRelay) + '', // chmod +x node + '', // npm install native deps + '', // chmod prebuilds + 'MISSING\n', // first probe: require() fails + '', // cat probe stderr + '', // rm probe stderr + '', // npm rebuild native deps + '', // chmod prebuilds after rebuild + { reject: 'SSH channel closed during native deps re-probe' } // re-probe rejects + ]) + + await expect(deployAndLaunchRelay(conn)).rejects.toThrow(/SSH channel closed/) + + // Rebuild failure is swallowed; a re-probe transport failure must not be. + const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) + expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(false) + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) + }) + + it('aborts an in-progress native install and releases its lock at deploy timeout', async () => { + vi.useFakeTimers() + try { + const conn = makeMockConnection(sftpCapture) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + '', // mkdir remoteDir + '' // chmod +x node + ]) + let installSignal: AbortSignal | undefined + vi.mocked(execCommand).mockImplementationOnce((_conn, command, options) => { + expect(command).toContain('npm install') + installSignal = options?.signal + return new Promise((_resolve, reject) => { + installSignal?.addEventListener('abort', () => reject(installSignal?.reason), { + once: true + }) + }) + }) + + const promise = deployAndLaunchRelay(conn).catch((err: Error) => err) + await vi.waitFor(() => expect(installSignal).toBeDefined()) + + await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TIMEOUT_MS) + + const result = await promise + expect(result).toBeInstanceOf(Error) + expect((result as Error).message).toBe( + `Relay deployment timed out after ${RELAY_DEPLOY_TIMEOUT_MS / 1000}s` + ) + expect(installSignal?.aborted).toBe(true) + expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } }) it('lets a probe SSH-channel failure bubble up rather than silently mapping to MISSING', async () => { @@ -479,9 +606,9 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { expect(chmodPrebuildsIdx).toBeGreaterThan(npmIdx) expect(probeIdx).toBeGreaterThan(chmodPrebuildsIdx) - // Happy path: finalize exactly once, abandon never. + // Hold the install lock through launch, then release it exactly once. expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) - expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() + expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) }) it('matches the sentinel even with bashrc/MOTD noise prefixed to probe stdout', async () => { @@ -522,7 +649,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(true) expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) - expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() + expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) }) it('keeps Windows node-pty probe failures non-fatal by checking LASTEXITCODE', async () => { @@ -535,7 +662,8 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { '', // mkdir remoteDir '', // npm install native deps 'MISSING\n', // native process exit normalized by PowerShell command - '', // remove probe stderr file + '', // npm rebuild native deps + 'MISSING\n', // rebuilt native process still cannot load '', // no persisted active pipe marker 'WAITING', '', // WMI relay launch @@ -554,11 +682,29 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { const probeScript = decodePowerShellCommand(probeCommand) ?? '' expect(probeScript).toContain('$LASTEXITCODE -ne 0') expect(probeScript).toContain("'MISSING'") + expect(probeScript).toContain('loadNativeModule') + + const npmScripts = vi + .mocked(execCommand) + .mock.calls.map(([, command]) => decodePowerShellCommand(command) ?? '') + .filter((script) => script.includes('npm install') || script.includes('npm rebuild')) + expect(npmScripts).toHaveLength(2) + expect(npmScripts.every((script) => script.includes('--ignore-scripts=false'))).toBe(true) + expect( + npmScripts.every((script) => + script.includes('if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }') + ) + ).toBe(true) + expect( + vi + .mocked(execCommand) + .mock.calls.some(([, command]) => command.includes('.npty-probe.stderr')) + ).toBe(false) const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(true) expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) - expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() + expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) }) it('includes the platform tuple in NPTY-MISSING and native install failure logs', async () => { @@ -607,8 +753,8 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { feed([ '__ORCA_REMOTE_PLATFORM__ Linux x86_64', '/home/u', - 'MISSING', // first native-deps probe before lock - 'MISSING', // re-probe after lock + 'ORCA-NATIVE-DEPS-MISSING:@parcel/watcher\nMISSING', // first probe before lock + 'ORCA-NATIVE-DEPS-MISSING:@parcel/watcher\nMISSING', // re-probe after lock '', // npm install native deps '', // chmod prebuilds 'ORCA-NPTY-PROBE-OK\n', @@ -619,7 +765,10 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { await deployAndLaunchRelay(conn) - expect(vi.mocked(acquireInstallLock)).toHaveBeenCalledTimes(1) + expect(vi.mocked(tryAcquireRelayRepairLock)).toHaveBeenCalledTimes(1) + expect(vi.mocked(tryAcquireRelayRepairLock).mock.calls[0]?.[3]?.signal).toBeInstanceOf( + AbortSignal + ) expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) expect( @@ -627,6 +776,228 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { (c) => c.includes('npm install') && c.includes('node-pty') && c.includes('@parcel/watcher') ) ).toBe(true) + const installCommand = execCalls.find((c) => c.includes('npm install')) ?? '' + expect(installCommand).toContain('node_modules/@parcel/watcher') + expect(installCommand).toContain("-name 'watcher-*'") + expect(installCommand).not.toContain("rm -rf 'node_modules/node-pty'") + }) + + it('launches an already-installed relay in degraded mode when repair throws', async () => { + // Why: a repair failure on a completed dir (e.g. offline/proxy-locked npm) + // must not block the connection — the relay still serves fs/git/preflight. + // Pre-fix this rethrew and aborted the whole deploy. The next reconnect + // retries, so a transient failure self-heals. + vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) + const conn = makeMockConnection(sftpCapture) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + 'MISSING', // health probe: require() fails + 'MISSING', // re-probe after lock + { reject: 'npm ERR! network ETIMEDOUT' }, // npm install fails (offline) + 'DEAD', + 'READY' + ]) + + // Deploy must resolve (degraded), not reject. + await deployAndLaunchRelay(conn) + + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) + const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) + expect(warnMessages.some((m) => m.includes('launching degraded'))).toBe(true) + }) + + it('retains the repair lock when remote command termination is unconfirmed', async () => { + vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) + const conn = makeMockConnection(sftpCapture) + vi.mocked(execCommand) + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') + .mockResolvedValueOnce('/home/u') + .mockResolvedValueOnce('MISSING') + .mockResolvedValueOnce('MISSING') + .mockRejectedValueOnce( + Object.assign(new Error('npm termination was not confirmed'), { + sshChannelCloseConfirmed: false + }) + ) + .mockResolvedValueOnce('DEAD') + .mockResolvedValueOnce('READY') + + await deployAndLaunchRelay(conn) + + expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() + const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) + expect(warnMessages.some((message) => message.includes('launching degraded'))).toBe(true) + }) + + it('retains the first-install lock when an aborted npm install has unconfirmed teardown', async () => { + vi.useFakeTimers() + try { + const conn = makeMockConnection(sftpCapture) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + '', // mkdir remoteDir + '' // chmod +x node + ]) + let installSignal: AbortSignal | undefined + vi.mocked(execCommand).mockImplementationOnce((_conn, command, options) => { + expect(command).toContain('npm install') + installSignal = options?.signal + return new Promise((_resolve, reject) => { + installSignal?.addEventListener( + 'abort', + () => { + // Mirrors execCommand's bounded close grace when ssh2 never + // confirms that the remote npm process stopped. + setTimeout( + () => + reject( + Object.assign(new Error('npm teardown remained unconfirmed'), { + sshChannelCloseConfirmed: false + }) + ), + 5_000 + ) + }, + { once: true } + ) + }) + }) + + const deploy = deployAndLaunchRelay(conn).catch((err: Error) => err) + await vi.waitFor(() => expect(installSignal).toBeDefined()) + await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TIMEOUT_MS) + const result = await deploy + expect(result).toBeInstanceOf(Error) + expect((result as Error).message).toContain('Relay deployment timed out') + await vi.advanceTimersByTimeAsync(5_000) + + expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('does not finalize or release a first-install lock after unconfirmed rebuild teardown', async () => { + const conn = makeMockConnection(sftpCapture) + vi.mocked(execCommand) + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') + .mockResolvedValueOnce('/home/u') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('MISSING') + .mockResolvedValueOnce('rebuild diagnostics') + .mockResolvedValueOnce('') + .mockRejectedValueOnce( + Object.assign(new Error('rebuild termination was not confirmed'), { + sshChannelCloseConfirmed: false + }) + ) + + await expect(deployAndLaunchRelay(conn)).rejects.toThrow( + 'rebuild termination was not confirmed' + ) + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() + }) + + it('retains the first-install lock when an aborted rebuild has unconfirmed teardown', async () => { + vi.useFakeTimers() + try { + const conn = makeMockConnection(sftpCapture) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + '', // mkdir remoteDir + '', // chmod +x node + '', // npm install + '', // chmod prebuilds + 'MISSING', + 'rebuild diagnostics', + '' // remove probe diagnostics + ]) + let rebuildSignal: AbortSignal | undefined + vi.mocked(execCommand).mockImplementationOnce((_conn, command, options) => { + expect(command).toContain('npm rebuild') + rebuildSignal = options?.signal + return new Promise((_resolve, reject) => { + rebuildSignal?.addEventListener( + 'abort', + () => { + setTimeout( + () => + reject( + Object.assign(new Error('rebuild teardown remained unconfirmed'), { + sshChannelCloseConfirmed: false + }) + ), + 5_000 + ) + }, + { once: true } + ) + }) + }) + + const deploy = deployAndLaunchRelay(conn).catch((err: Error) => err) + await vi.waitFor(() => expect(rebuildSignal).toBeDefined()) + await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TIMEOUT_MS) + const result = await deploy + expect(result).toBeInstanceOf(Error) + expect((result as Error).message).toContain('Relay deployment timed out') + await vi.advanceTimersByTimeAsync(5_000) + + expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it.each(['busy', 'error'] as const)('launches degraded when lock is %s', async (lockResult) => { + // Why: lock contention/wedge must not block a completed relay from launching + // in degraded mode — repair is best-effort and we hold no lock to release. + vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) + vi.mocked(tryAcquireRelayRepairLock).mockResolvedValueOnce(lockResult) + const conn = makeMockConnection(sftpCapture) + feed(['__ORCA_REMOTE_PLATFORM__ Linux x86_64', '/home/u', 'MISSING', 'DEAD', 'READY']) + + await deployAndLaunchRelay(conn) + + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() + const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) + expect(execCalls.some((c) => c.includes('npm install'))).toBe(false) + const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) + expect(warnMessages.some((m) => m.includes(`repair lock is ${lockResult}`))).toBe(true) + }) + + it('loads native bindings when checking whether a completed relay needs repair', async () => { + vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) + const conn = makeMockConnection(sftpCapture) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + 'ORCA-NATIVE-DEPS-OK', + 'DEAD', + 'READY' + ]) + + await deployAndLaunchRelay(conn) + + const healthProbe = vi + .mocked(execCommand) + .mock.calls.map(([, c]) => c) + .find((c) => c.includes('ORCA-NATIVE-DEPS-OK')) + expect(healthProbe).toContain('require("node-pty")') + expect(healthProbe).toContain('loadNativeModule') + expect(healthProbe).toContain('require("@parcel/watcher")') + expect(healthProbe).not.toContain('require.resolve') }) it('does not mutate an existing relay dir when required native deps are present', async () => { @@ -643,7 +1014,9 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { await deployAndLaunchRelay(conn) expect(vi.mocked(acquireInstallLock)).not.toHaveBeenCalled() + expect(vi.mocked(tryAcquireRelayRepairLock)).toHaveBeenCalledTimes(1) expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) expect(execCalls.some((c) => c.includes('npm install'))).toBe(false) }) diff --git a/src/main/ssh/ssh-relay-repair-lock.ts b/src/main/ssh/ssh-relay-repair-lock.ts new file mode 100644 index 00000000000..01d58e390e1 --- /dev/null +++ b/src/main/ssh/ssh-relay-repair-lock.ts @@ -0,0 +1,153 @@ +import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' +import { + acquireInstallLockParentCommand, + lockAgeSecondsCommand, + probeInstallLockExistsCommand, + tryCreateInstallLockCommand, + tryStealInstallLockCommand +} from './ssh-relay-install-lock-commands' +import { isRelayGcClaimed } from './ssh-relay-gc-claim' +import { + getRemoteHostPlatform, + isWindowsRemoteHost, + joinRemotePath, + type RemoteHostPlatform +} from './ssh-remote-platform' +import { INSTALL_LOCK_STALE_SECONDS, RELAY_INSTALL_LOCK_NAME } from './ssh-relay-install-lock' +import { removeRemoteTreeCommand } from './ssh-remote-commands' + +const DEFAULT_REMOTE_HOST = getRemoteHostPlatform('linux-x64') + +export type RelayRepairLockResult = 'acquired' | 'busy' | 'gc' | 'error' + +function execHostCommand( + conn: SshConnection, + host: RemoteHostPlatform, + command: string, + signal?: AbortSignal +): Promise { + return execCommand(conn, command, { wrapCommand: !isWindowsRemoteHost(host), signal }) +} + +/** + * Try once to acquire the install lock for best-effort repair work. + * + * Why: a completed relay can launch in degraded mode, so repair must not wait + * behind another installer. An already-stale lock is still recovered now. + */ +export async function tryAcquireRelayRepairLock( + conn: SshConnection, + remoteRelayDir: string, + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, + options?: { signal?: AbortSignal } +): Promise { + const lockDir = joinRemotePath(host, remoteRelayDir, RELAY_INSTALL_LOCK_NAME) + try { + const gcClaimedBeforeAcquire = await isRelayGcClaimed( + conn, + remoteRelayDir, + host, + options?.signal + ).catch(() => undefined) + options?.signal?.throwIfAborted() + if (gcClaimedBeforeAcquire === true) { + return 'gc' + } + if (gcClaimedBeforeAcquire !== false) { + return 'error' + } + await execHostCommand( + conn, + host, + acquireInstallLockParentCommand(host, remoteRelayDir), + options?.signal + ) + const firstAttempt = await execHostCommand( + conn, + host, + tryCreateInstallLockCommand(host, lockDir), + options?.signal + ) + if (firstAttempt.trim().endsWith('OK')) { + return finishRepairLockAcquire(conn, remoteRelayDir, lockDir, host, options?.signal) + } + const steal = await execHostCommand( + conn, + host, + tryStealInstallLockCommand(host, lockDir, INSTALL_LOCK_STALE_SECONDS), + options?.signal + ) + if (steal.trim().endsWith('OK')) { + console.warn(`[ssh-relay] Stealing stale install lock at ${lockDir}`) + return finishRepairLockAcquire(conn, remoteRelayDir, lockDir, host, options?.signal) + } + return classifyRepairLockContention(conn, remoteRelayDir, lockDir, host, options?.signal) + } catch { + options?.signal?.throwIfAborted() + return classifyRepairLockContention(conn, remoteRelayDir, lockDir, host, options?.signal) + } +} + +async function classifyRepairLockContention( + conn: SshConnection, + remoteRelayDir: string, + lockDir: string, + host: RemoteHostPlatform, + signal?: AbortSignal +): Promise { + const gcClaimed = await isRelayGcClaimed(conn, remoteRelayDir, host, signal).catch( + () => undefined + ) + signal?.throwIfAborted() + if (gcClaimed === true) { + return 'gc' + } + if (gcClaimed !== false) { + return 'error' + } + + const lockProbe = await execHostCommand( + conn, + host, + probeInstallLockExistsCommand(host, lockDir), + signal + ).catch(() => '') + signal?.throwIfAborted() + if (lockProbe.trim() !== 'LOCKED') { + return 'error' + } + const ageOutput = await execHostCommand( + conn, + host, + lockAgeSecondsCommand(host, lockDir), + signal + ).catch(() => '') + signal?.throwIfAborted() + const ageSeconds = Number.parseInt(ageOutput.trim(), 10) + // Why: GC may remove stale locks, so only a positively observed fresh lock + // proves that another launch/repair owner is fencing this directory. + return Number.isFinite(ageSeconds) && ageSeconds >= 0 && ageSeconds <= INSTALL_LOCK_STALE_SECONDS + ? 'busy' + : 'error' +} + +async function finishRepairLockAcquire( + conn: SshConnection, + remoteRelayDir: string, + lockDir: string, + host: RemoteHostPlatform, + signal?: AbortSignal +): Promise { + const gcClaimed = await isRelayGcClaimed(conn, remoteRelayDir, host, signal).catch( + () => undefined + ) + if (gcClaimed === false && !signal?.aborted) { + return 'acquired' + } + // Why: GC may win its stable sibling claim while this command creates the + // in-tree lock. Back out before npm can mutate a directory being renamed. + await execHostCommand(conn, host, removeRemoteTreeCommand(host, lockDir)).catch(() => {}) + signal?.throwIfAborted() + return gcClaimed ? 'gc' : 'error' +} diff --git a/src/main/ssh/ssh-relay-versioned-install.test.ts b/src/main/ssh/ssh-relay-versioned-install.test.ts index 244ae80a48c..9bd3f73c8df 100644 --- a/src/main/ssh/ssh-relay-versioned-install.test.ts +++ b/src/main/ssh/ssh-relay-versioned-install.test.ts @@ -18,11 +18,19 @@ import { readLocalFullVersion, computeRemoteRelayDir, isRelayAlreadyInstalled, - acquireInstallLock, finalizeInstall, abandonInstall, gcOldRelayVersions } from './ssh-relay-versioned-install' +import { acquireInstallLock } from './ssh-relay-install-lock' +import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock' +import { + isRelayGcClaimed, + relayGcClaimPath, + releaseRelayGcClaim, + tryAcquireRelayGcClaim, + waitForRelayGcClaimRelease +} from './ssh-relay-gc-claim' import { execCommand } from './ssh-relay-deploy-helpers' import { getRemoteHostPlatform } from './ssh-remote-platform' import type { SshConnection } from './ssh-connection' @@ -88,6 +96,18 @@ describe('isRelayAlreadyInstalled', () => { expect(await isRelayAlreadyInstalled(conn, '/r')).toBe(false) }) + it('does not convert an aborted install probe into a missing install', async () => { + const controller = new AbortController() + mockExec.mockImplementationOnce(async () => { + controller.abort() + throw Object.assign(new Error('cancelled'), { name: 'AbortError' }) + }) + + await expect( + isRelayAlreadyInstalled(conn, '/r', undefined, { signal: controller.signal }) + ).rejects.toMatchObject({ name: 'AbortError' }) + }) + it('keeps default probe failures as not installed for SSH session-limit-shaped errors', async () => { const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { reason: 4 @@ -124,26 +144,130 @@ describe('acquireInstallLock', () => { }) it('returns when mkdir reports OK', async () => { - // 1st call: mkdir -p remoteRelayDir - // 2nd call: mkdir lockDir → OK - mockExec.mockResolvedValueOnce('').mockResolvedValueOnce('OK') + mockExec + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('OK') + .mockResolvedValueOnce('OPEN') await acquireInstallLock(conn, '/r') - expect(mockExec).toHaveBeenCalledTimes(2) + expect(mockExec).toHaveBeenCalledTimes(4) + }) + + it('recovers a stale sibling GC claim before first-install lock acquisition', async () => { + mockExec + .mockResolvedValueOnce('LOCKED') + .mockResolvedValueOnce('OK') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('RELEASED') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('OK') + .mockResolvedValueOnce('OPEN') + + await expect(acquireInstallLock(conn, '/r')).resolves.toBeUndefined() + + const commands = mockExec.mock.calls.map(([, command]) => command) + expect(commands[1]).toContain('lock_tombstone') + expect(commands[4]).toBe("mkdir -p '/r'") + }) + + it('returns immediately without deleting a live repair lock', async () => { + // Why: repair can run npm install plus rebuild under the same lock; a + // second reconnect must launch degraded rather than corrupt node_modules. + mockExec + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('BUSY') + .mockResolvedValueOnce('BUSY') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('LOCKED') + .mockResolvedValueOnce('0') + + await expect(tryAcquireRelayRepairLock(conn, '/r')).resolves.toBe('busy') + + const commands = mockExec.mock.calls.map(([, command]) => command) + expect(commands.some((command) => command.includes('lock_tombstone'))).toBe(true) + expect(commands.filter((command) => command.startsWith('rm -rf'))).toHaveLength(0) + }) + + it('does not report stale or indeterminate contention as a launch fence', async () => { + mockExec + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('BUSY') + .mockResolvedValueOnce('BUSY') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('LOCKED') + .mockResolvedValueOnce(`${21 * 60}`) + + await expect(tryAcquireRelayRepairLock(conn, '/r')).resolves.toBe('error') + + mockExec.mockReset().mockRejectedValueOnce(new Error('claim probe failed')) + await expect(tryAcquireRelayRepairLock(conn, '/r')).resolves.toBe('error') + }) + + it('recovers a stale best-effort repair lock without polling', async () => { + mockExec + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('BUSY') + .mockResolvedValueOnce('OK') + .mockResolvedValueOnce('OPEN') + + await expect(tryAcquireRelayRepairLock(conn, '/r')).resolves.toBe('acquired') + + const commands = mockExec.mock.calls.map(([, command]) => command) + expect(commands.some((command) => command.includes('lock_tombstone'))).toBe(true) + expect(commands.filter((command) => command.includes('lock_tombstone'))).toHaveLength(1) + }) + + it('backs out when GC claims the sibling path during repair lock acquisition', async () => { + mockExec + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('OK') + .mockResolvedValueOnce('LOCKED') + .mockResolvedValueOnce('') + + await expect(tryAcquireRelayRepairLock(conn, '/r')).resolves.toBe('gc') + + const lastCommand = mockExec.mock.calls.at(-1)?.[1] ?? '' + expect(lastCommand).toBe("rm -rf '/r/.install-lock'") + }) + + it('propagates cancellation through best-effort repair lock commands', async () => { + const abortController = new AbortController() + const abortError = Object.assign(new Error('cancelled'), { name: 'AbortError' }) + mockExec.mockRejectedValueOnce(abortError) + + const promise = tryAcquireRelayRepairLock(conn, '/r', undefined, { + signal: abortController.signal + }) + abortController.abort() + + await expect(promise).rejects.toMatchObject({ name: 'AbortError' }) + expect(mockExec.mock.calls[0]?.[2]?.signal).toBe(abortController.signal) }) it('polls until the lock becomes available (concurrent installer wins, then we acquire)', async () => { vi.useFakeTimers() try { - // Sequence: - // 1. mkdir -p (parent dir prep) - // 2. mkdir lockDir → BUSY (someone else holds it) - // 3. mkdir lockDir → BUSY again - // 4. mkdir lockDir → OK (concurrent installer released) - mockExec - .mockResolvedValueOnce('') - .mockResolvedValueOnce('BUSY') - .mockResolvedValueOnce('BUSY') - .mockResolvedValueOnce('OK') + let createAttempts = 0 + mockExec.mockImplementation(async (_conn: unknown, command: string) => { + if (command.includes('.gc-claim')) { + return 'OPEN' + } + if (command.startsWith('mkdir -p')) { + return '' + } + if (command.includes('lock_tombstone')) { + return 'BUSY' + } + if (command.includes('.install-lock')) { + createAttempts++ + return createAttempts >= 3 ? 'OK' : 'BUSY' + } + return '' + }) const promise = acquireInstallLock(conn, '/r') // Drive the polling loop: each iteration awaits a 1s timer. @@ -159,36 +283,48 @@ describe('acquireInstallLock', () => { } }) - it('steals a stale lock and retries with a reset timeout window', async () => { + it('immediately tries to steal an already-stale lock', async () => { + mockExec + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('BUSY') + .mockResolvedValueOnce('OK') + .mockResolvedValueOnce('OPEN') + + await expect(acquireInstallLock(conn, '/r')).resolves.toBeUndefined() + + const cmds = mockExec.mock.calls.map(([, c]) => c) + expect(cmds).toHaveLength(5) + expect(cmds[3]).toContain('lock_tombstone') + }) + + it('retries stale takeover when a fresh lock ages out during the wait', async () => { vi.useFakeTimers({ now: 1_700_000_000_000 }) try { - let mkdirCalls = 0 + const recoverableAt = Date.now() + 60_000 mockExec.mockImplementation(async (_conn: unknown, cmd: string) => { + if (cmd.includes('.gc-claim')) { + return 'OPEN' + } if (cmd.startsWith('mkdir -p')) { return '' } + if (cmd.includes('lock_tombstone')) { + return Date.now() >= recoverableAt ? 'OK' : 'BUSY' + } if (cmd.includes('mkdir') && cmd.includes('.install-lock')) { - mkdirCalls++ - return mkdirCalls > 200 ? 'OK' : 'BUSY' - } - if (cmd.includes('stat')) { - return `${Math.floor((Date.now() - 10 * 60 * 1000) / 1000)}\n` - } - if (cmd.startsWith('rm -rf')) { - mkdirCalls = 1000 - return '' + return 'BUSY' } return '' }) const promise = acquireInstallLock(conn, '/r') - // Drive through the full timeout (120s) so the stale-recovery branch - // fires, then drive a few more seconds for the post-recovery retry. - await vi.advanceTimersByTimeAsync(125_000) - await promise + await vi.advanceTimersByTimeAsync(61_000) - const cmds = mockExec.mock.calls.map(([, c]) => c) - expect(cmds.some((c) => c.includes('rm -rf') && c.includes('.install-lock'))).toBe(true) + await expect(promise).resolves.toBeUndefined() + expect(mockExec.mock.calls.filter(([, cmd]) => cmd.includes('lock_tombstone'))).toHaveLength( + 2 + ) } finally { vi.useRealTimers() } @@ -198,26 +334,86 @@ describe('acquireInstallLock', () => { vi.useFakeTimers({ now: 1_700_000_000_000 }) try { mockExec.mockImplementation(async (_conn: unknown, cmd: string) => { + if (cmd.includes('.gc-claim')) { + return 'OPEN' + } if (cmd.startsWith('mkdir -p')) { return '' } + if (cmd.includes('lock_tombstone')) { + return 'BUSY' + } if (cmd.includes('mkdir') && cmd.includes('.install-lock')) { return 'BUSY' } if (cmd.includes('stat')) { - return `${Math.floor(Date.now() / 1000)}\n` + return '0\n' } return '' }) - const rejection = expect(acquireInstallLock(conn, '/r')).rejects.toThrow(/not yet stale/i) - await vi.advanceTimersByTimeAsync(125_000) + const rejection = expect(acquireInstallLock(conn, '/r')).rejects.toThrow( + /another install is still in progress/i + ) + await vi.advanceTimersByTimeAsync(905_000) await rejection } finally { vi.useRealTimers() } }) + it('stops polling when the caller aborts the lock wait', async () => { + vi.useFakeTimers() + try { + const abortController = new AbortController() + mockExec.mockImplementation(async (_conn: unknown, cmd: string) => + cmd.includes('.gc-claim') ? 'OPEN' : 'BUSY' + ) + + const promise = acquireInstallLock(conn, '/r', undefined, { + signal: abortController.signal + }) + await vi.advanceTimersByTimeAsync(1_000) + abortController.abort() + await expect(promise).rejects.toMatchObject({ name: 'AbortError' }) + + const callCountAfterAbort = mockExec.mock.calls.length + await vi.advanceTimersByTimeAsync(10_000) + expect(mockExec).toHaveBeenCalledTimes(callCountAfterAbort) + } finally { + vi.useRealTimers() + } + }) + + it('keeps waiting while a slow first installer is within the deploy bound', async () => { + vi.useFakeTimers({ now: 1_700_000_000_000 }) + try { + const availableAt = Date.now() + 500_000 + mockExec.mockImplementation(async (_conn: unknown, cmd: string) => { + if (cmd.includes('.gc-claim')) { + return 'OPEN' + } + if (cmd.startsWith('mkdir -p')) { + return '' + } + if (cmd.includes('mkdir') && cmd.includes('.install-lock')) { + return Date.now() >= availableAt ? 'OK' : 'BUSY' + } + return '' + }) + + const promise = acquireInstallLock(conn, '/r') + await vi.advanceTimersByTimeAsync(501_000) + + await expect(promise).resolves.toBeUndefined() + expect( + mockExec.mock.calls.filter(([, cmd]) => cmd.includes('lock_tombstone')).length + ).toBeGreaterThan(1) + } finally { + vi.useRealTimers() + } + }) + it('finalizeInstall writes .install-complete then removes the lock', async () => { mockExec.mockResolvedValueOnce('').mockResolvedValueOnce('') await finalizeInstall(conn, '/r') @@ -238,26 +434,220 @@ describe('acquireInstallLock', () => { }) }) +describe('relay GC claim', () => { + beforeEach(() => { + vi.clearAllMocks() + mockExec.mockReset() + }) + + it('parses explicit claim markers through remote startup noise', async () => { + mockExec.mockResolvedValueOnce('Welcome to host\nLOCKED\n') + await expect(isRelayGcClaimed(conn, '/relay/version')).resolves.toBe(true) + + mockExec.mockResolvedValueOnce('Last login: today\nOPEN\n') + await expect(isRelayGcClaimed(conn, '/relay/version')).resolves.toBe(false) + }) + + it('rejects missing or conflicting claim markers', async () => { + mockExec.mockResolvedValueOnce('Welcome to host\n') + await expect(isRelayGcClaimed(conn, '/relay/version')).rejects.toThrow('Inconclusive') + + mockExec.mockResolvedValueOnce('LOCKED\nOPEN\n') + await expect(isRelayGcClaimed(conn, '/relay/version')).rejects.toThrow('Inconclusive') + }) + + it('uses a stable sibling path outside the recursively deleted install', async () => { + mockExec.mockResolvedValueOnce('OK').mockResolvedValueOnce('') + + await expect(tryAcquireRelayGcClaim(conn, '/relay/version')).resolves.toEqual( + expect.any(String) + ) + + expect(relayGcClaimPath('/relay/version')).toBe('/relay/version.gc-claim') + expect(mockExec.mock.calls[0]?.[1]).toContain("mkdir '/relay/version.gc-claim'") + }) + + it('recovers and removes a stale sibling claim before retrying deploy', async () => { + mockExec + .mockResolvedValueOnce('LOCKED') + .mockResolvedValueOnce('OK') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('RELEASED') + + await waitForRelayGcClaimRelease(conn, '/relay/version') + + const commands = mockExec.mock.calls.map(([, command]) => command) + expect(commands[1]).toContain('lock_tombstone') + expect(commands[3]).toContain("rm -rf '/relay/version.gc-claim'") + }) + + it('keeps waiting after losing a recovered claim until the claim is actually gone', async () => { + mockExec + .mockResolvedValueOnce('LOCKED') + .mockResolvedValueOnce('OK') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('LOST') + .mockResolvedValueOnce('OPEN') + + await waitForRelayGcClaimRelease(conn, '/relay/version') + + expect(mockExec).toHaveBeenCalledTimes(5) + }) + + it('writes and conditionally releases the Windows sibling claim owner token', async () => { + const windows = getRemoteHostPlatform('win32-x64') + mockExec.mockResolvedValueOnce('OK').mockResolvedValueOnce('').mockResolvedValueOnce('RELEASED') + + const token = await tryAcquireRelayGcClaim(conn, 'C:/relay/version', windows) + expect(token).toEqual(expect.any(String)) + await expect(releaseRelayGcClaim(conn, 'C:/relay/version', token!, windows)).resolves.toBe( + 'released' + ) + + const ownerScript = decodePowerShellCommand(mockExec.mock.calls[1]?.[1] ?? '') + const releaseScript = decodePowerShellCommand(mockExec.mock.calls[2]?.[1] ?? '') + expect(ownerScript).toContain('Set-Content -LiteralPath') + expect(ownerScript).toContain('.gc-claim/.gc-owner') + expect(releaseScript).toContain('Get-Content -LiteralPath') + expect(releaseScript).toContain('-cne') + expect(releaseScript).toContain('Remove-Item -LiteralPath') + expect(releaseScript).toContain("'RELEASED'") + expect(releaseScript).toContain("'LOST'") + expect(releaseScript).toContain("'UNKNOWN'") + expect(releaseScript).not.toContain('}; elseif') + expect(releaseScript).not.toContain('{;') + }) + + it('conditionally releases a claim when the owner write reply is lost', async () => { + mockExec + .mockResolvedValueOnce('OK') + .mockRejectedValueOnce(new Error('owner write reply lost')) + .mockResolvedValueOnce('RELEASED') + + await expect(tryAcquireRelayGcClaim(conn, '/relay/version')).resolves.toBeNull() + + const ownerCommand = mockExec.mock.calls[1]?.[1] ?? '' + const releaseCommand = mockExec.mock.calls[2]?.[1] ?? '' + const token = ownerCommand.match(/printf %s '([^']+)'/)?.[1] + expect(token).toBeTruthy() + expect(releaseCommand).toContain(`!= '${token}'`) + }) +}) + describe('gcOldRelayVersions', () => { beforeEach(() => { vi.clearAllMocks() + mockExec.mockReset() }) it('removes a sibling that is complete, unlocked, and has no live socket', async () => { // ls listing mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\nrelay-0.1.0+bbb\n') - // For sibling "aaa": LOCKED probe → OPEN, COMPLETE probe → COMPLETE, sock probe → empty (no ALIVE), then rm -rf + // For sibling "aaa": safety probes pass, then GC claims the install lock before removal. mockExec .mockResolvedValueOnce('OPEN') .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('DEAD') + .mockResolvedValueOnce('OK') .mockResolvedValueOnce('') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('DEAD') + .mockResolvedValueOnce('OWNED') + .mockResolvedValueOnce('MOVED') + .mockResolvedValueOnce('RELEASED') .mockResolvedValueOnce('') await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') const lastCmd = mockExec.mock.calls.at(-1)?.[1] ?? '' expect(lastCmd).toContain('rm -rf') - expect(lastCmd).toContain('relay-0.1.0+aaa') + expect(lastCmd).toContain('relay-0.1.0+aaa.gc-tombstone') + const commands = mockExec.mock.calls.map(([, command]) => command) + expect( + commands.some((command) => command === "rm -rf '/home/u/.orca-remote/relay-0.1.0+aaa'") + ).toBe(false) + }) + + it('cleans only strict POSIX orphan tombstones even with no relay candidates', async () => { + mockExec + .mockResolvedValueOnce( + [ + 'relay-0.1.0+abc.gc-tombstone.123.456', + 'relay-0.1.0+abc.gc-tombstone.bad.456', + 'xrelay-0.1.0+abc.gc-tombstone.123.456', + 'relay-0.1.0+abc.gc-tombstone.123.456.extra', + 'relay-0.1.0+abc.gc-tombstone.123.456/child', + 'logs' + ].join('\n') + ) + .mockResolvedValueOnce('') + + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + + const removeCommands = mockExec.mock.calls + .map(([, command]) => command) + .filter((command) => command.startsWith('rm -rf')) + expect(removeCommands).toEqual([ + "rm -rf '/home/u/.orca-remote/relay-0.1.0+abc.gc-tombstone.123.456'" + ]) + }) + + it('cleans only strict Windows orphan tombstones even with no relay candidates', async () => { + const windows = getRemoteHostPlatform('win32-x64') + mockExec + .mockResolvedValueOnce( + 'relay-v0.1.0.gc-tombstone.123.456\nrelay-v0.1.0.gc-tombstone.latest.456\n' + ) + .mockResolvedValueOnce('') + + await gcOldRelayVersions(conn, 'C:/Users/u', 'C:/Users/u/.orca-remote/relay-0.1.0+bbb', windows) + + const removeScript = decodePowerShellCommand(mockExec.mock.calls[1]?.[1] ?? '') + expect(removeScript).toContain('relay-v0.1.0.gc-tombstone.123.456') + expect(removeScript).not.toContain('relay-v0.1.0.gc-tombstone.latest.456') + }) + + it('retries orphan tombstone cleanup on a later GC pass', async () => { + const tombstone = 'relay-0.1.0+abc.gc-tombstone.123.456' + mockExec + .mockResolvedValueOnce(tombstone) + .mockRejectedValueOnce(new Error('remove failed')) + .mockResolvedValueOnce(tombstone) + .mockResolvedValueOnce('') + + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + + const removeCommands = mockExec.mock.calls + .map(([, command]) => command) + .filter((command) => command.startsWith('rm -rf')) + expect(removeCommands).toHaveLength(2) + }) + + it('retries only an unknown claim release and stops after observing a lost generation', async () => { + mockExec + .mockResolvedValueOnce('relay-0.1.0+aaa\n') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('DEAD') + .mockResolvedValueOnce('OK') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('DEAD') + .mockResolvedValueOnce('OWNED') + .mockResolvedValueOnce('MOVED') + .mockResolvedValueOnce('UNKNOWN') + .mockResolvedValueOnce('LOST') + .mockResolvedValueOnce('') + + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + + const releaseCommands = mockExec.mock.calls + .map(([, command]) => command) + .filter((command) => command.includes('.gc-owner') && command.includes('echo RELEASED')) + expect(releaseCommands).toHaveLength(2) }) it('skips siblings that are missing .install-complete (mid-install or partial)', async () => { @@ -273,8 +663,8 @@ describe('gcOldRelayVersions', () => { it('skips siblings whose .install-lock is held and fresh', async () => { mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\n') mockExec.mockResolvedValueOnce('LOCKED') - // isLockStale: mtime ~now → not stale. - mockExec.mockResolvedValueOnce(`${Math.floor(Date.now() / 1000)}\n`) + // isLockStale: age ~now → not stale. + mockExec.mockResolvedValueOnce('0\n') await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') const cmds = mockExec.mock.calls.map(([, c]) => c) expect(cmds.some((c) => c.includes('rm -rf'))).toBe(false) @@ -283,12 +673,20 @@ describe('gcOldRelayVersions', () => { it('removes a sibling with a stale lock + .install-complete (rm-lock failed mid-finalize)', async () => { mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\n') mockExec.mockResolvedValueOnce('LOCKED') - // isLockStale: mtime well in the past → stale. - const staleSec = Math.floor((Date.now() - 10 * 60 * 1000) / 1000) - mockExec.mockResolvedValueOnce(`${staleSec}\n`) + // isLockStale: age well above the stale window → stale. + mockExec.mockResolvedValueOnce(`${21 * 60}\n`) mockExec.mockResolvedValueOnce('COMPLETE') // .install-complete present - mockExec.mockResolvedValueOnce('') // socket probe → no ALIVE - mockExec.mockResolvedValueOnce('') // rm -rf + mockExec.mockResolvedValueOnce('DEAD') // socket probe + mockExec.mockResolvedValueOnce('OK') // stable sibling GC claim + mockExec.mockResolvedValueOnce('') // write claim ownership token + mockExec.mockResolvedValueOnce('LOCKED') + mockExec.mockResolvedValueOnce(`${21 * 60}\n`) + mockExec.mockResolvedValueOnce('COMPLETE') + mockExec.mockResolvedValueOnce('DEAD') + mockExec.mockResolvedValueOnce('OWNED') + mockExec.mockResolvedValueOnce('MOVED') + mockExec.mockResolvedValueOnce('RELEASED') // release sibling claim + mockExec.mockResolvedValueOnce('') // remove tombstone await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') const lastCmd = mockExec.mock.calls.at(-1)?.[1] ?? '' expect(lastCmd).toContain('rm -rf') @@ -298,8 +696,15 @@ describe('gcOldRelayVersions', () => { it('GCs a legacy relay-v0.1.0 dir whose daemon is dead (no .install-complete required)', async () => { mockExec.mockResolvedValueOnce('relay-v0.1.0\n') mockExec.mockResolvedValueOnce('OPEN') // not locked - mockExec.mockResolvedValueOnce('') // socket probe → no ALIVE (no completeProbe — legacy) - mockExec.mockResolvedValueOnce('') // rm -rf + mockExec.mockResolvedValueOnce('DEAD') // socket probe (no completeProbe — legacy) + mockExec.mockResolvedValueOnce('OK') // GC claims candidate + mockExec.mockResolvedValueOnce('') + mockExec.mockResolvedValueOnce('OPEN') + mockExec.mockResolvedValueOnce('DEAD') + mockExec.mockResolvedValueOnce('OWNED') + mockExec.mockResolvedValueOnce('MOVED') + mockExec.mockResolvedValueOnce('RELEASED') + mockExec.mockResolvedValueOnce('') await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') const cmds = mockExec.mock.calls.map(([, c]) => c) expect(cmds.some((c) => c.includes('rm -rf') && c.includes('relay-v0.1.0'))).toBe(true) @@ -327,6 +732,32 @@ describe('gcOldRelayVersions', () => { expect(cmds.some((c) => c.includes('rm -rf'))).toBe(false) }) + it('keeps a sibling when the install-lock probe fails or returns unexpected output', async () => { + mockExec + .mockResolvedValueOnce('relay-0.1.0+aaa\nrelay-0.1.0+ccc\n') + .mockRejectedValueOnce(new Error('lock probe failed')) + .mockResolvedValueOnce('INCONCLUSIVE') + + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + + expect(mockExec).toHaveBeenCalledTimes(3) + }) + + it('keeps a sibling when the relay-liveness probe fails or returns unexpected output', async () => { + mockExec + .mockResolvedValueOnce('relay-0.1.0+aaa\nrelay-0.1.0+ccc\n') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockRejectedValueOnce(new Error('liveness probe failed')) + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('INCONCLUSIVE') + + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + + expect(mockExec).toHaveBeenCalledTimes(7) + }) + it('probes Windows GC liveness by connecting to named pipes, not process command lines', async () => { const windows = getRemoteHostPlatform('win32-x64') mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\n') @@ -334,6 +765,14 @@ describe('gcOldRelayVersions', () => { .mockResolvedValueOnce('OPEN') .mockResolvedValueOnce('COMPLETE') .mockResolvedValueOnce('WAITING') + .mockResolvedValueOnce('OK') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('WAITING') + .mockResolvedValueOnce('OWNED') + .mockResolvedValueOnce('MOVED') + .mockResolvedValueOnce('RELEASED') .mockResolvedValueOnce('') await gcOldRelayVersions( @@ -366,14 +805,113 @@ describe('gcOldRelayVersions', () => { mockExec .mockResolvedValueOnce('OPEN') .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('DEAD') + .mockResolvedValueOnce('OK') .mockResolvedValueOnce('') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('DEAD') + .mockResolvedValueOnce('OWNED') + .mockResolvedValueOnce('MOVED') + .mockResolvedValueOnce('RELEASED') .mockResolvedValueOnce('') await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') const cmds = mockExec.mock.calls.map(([, c]) => c) - const rmCmds = cmds.filter((c) => c.includes('rm -rf')) + const rmCmds = cmds.filter((c) => c.startsWith('rm') && c.includes('gc-tombstone')) expect(rmCmds).toHaveLength(1) expect(rmCmds[0]).toContain('relay-0.1.0+aaa') expect(rmCmds[0]).not.toContain('logs') expect(rmCmds[0]).not.toContain('backup') }) + + it('does not remove a candidate claimed by repair after the initial lock probe', async () => { + mockExec + .mockResolvedValueOnce('relay-0.1.0+aaa\n') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('DEAD') // socket probe + .mockResolvedValueOnce('OK') // GC sibling claim + .mockResolvedValueOnce('') // write claim ownership token + .mockResolvedValueOnce('LOCKED') // repair won before the safety recheck + .mockResolvedValueOnce('0') + .mockResolvedValueOnce('RELEASED') // release GC sibling claim + + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + + const commands = mockExec.mock.calls.map(([, command]) => command) + expect( + commands.some( + (command) => + command.startsWith("rm -rf '/home/u/.orca-remote/relay-0.1.0+aaa'") && + !command.includes('.install-lock') + ) + ).toBe(false) + }) + + it('does not move a candidate after losing the sibling claim generation', async () => { + mockExec + .mockResolvedValueOnce('relay-0.1.0+aaa\n') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('DEAD') + .mockResolvedValueOnce('OK') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('DEAD') + .mockResolvedValueOnce('LOST') + .mockResolvedValueOnce('LOST') + + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + + const commands = mockExec.mock.calls.map(([, command]) => command) + expect(commands.some((command) => command.startsWith('mv '))).toBe(false) + }) + + it('releases the GC claim after a confirmed move failure', async () => { + mockExec + .mockResolvedValueOnce('relay-0.1.0+aaa\n') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('DEAD') + .mockResolvedValueOnce('OK') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('DEAD') + .mockResolvedValueOnce('OWNED') + .mockRejectedValueOnce(new Error('move failed')) + .mockResolvedValueOnce('RELEASED') + + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + + const lastCommand = mockExec.mock.calls.at(-1)?.[1] ?? '' + expect(lastCommand).toContain('rm -rf') + expect(lastCommand).toContain('relay-0.1.0+aaa.gc-claim') + }) + + it('keeps the GC claim when remote move termination is unconfirmed', async () => { + const unconfirmed = Object.assign(new Error('move timed out'), { + sshChannelCloseConfirmed: false + }) + mockExec + .mockResolvedValueOnce('relay-0.1.0+aaa\n') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('DEAD') + .mockResolvedValueOnce('OK') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('DEAD') + .mockResolvedValueOnce('OWNED') + .mockRejectedValueOnce(unconfirmed) + + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + + const releaseCommands = mockExec.mock.calls + .map(([, command]) => command) + .filter((command) => command.includes('relay-0.1.0+aaa.gc-claim') && command.startsWith('rm')) + expect(releaseCommands).toHaveLength(0) + }) }) diff --git a/src/main/ssh/ssh-relay-versioned-install.ts b/src/main/ssh/ssh-relay-versioned-install.ts index a669f0b7875..b211fec4dd0 100644 --- a/src/main/ssh/ssh-relay-versioned-install.ts +++ b/src/main/ssh/ssh-relay-versioned-install.ts @@ -15,16 +15,21 @@ import { existsSync, readFileSync } from 'node:fs' import type { SshConnection } from './ssh-connection' import { RELAY_REMOTE_DIR } from './relay-protocol' import { execCommand } from './ssh-relay-deploy-helpers' +import { probeInstallLockExistsCommand } from './ssh-relay-install-lock-commands' +import { isRelayInstallLockStale, RELAY_INSTALL_LOCK_NAME } from './ssh-relay-install-lock' +import { + isRelayGcClaimOwned, + releaseRelayGcClaimWithRetry, + tryAcquireRelayGcClaim +} from './ssh-relay-gc-claim' +import { cleanupRelayGcTombstones } from './ssh-relay-gc-tombstone' import { - acquireInstallLockParentCommand, listRelayBaseDirsCommand, - lockMtimeEpochCommand, - probeDirectoryExistsCommand, + moveRemoteTreeCommand, probeFileExistsCommand, probeRelayInstalledCommand, relayLivenessProbeCommand, removeRemoteTreeCommand, - tryCreateInstallLockCommand, writeRemoteEmptyFileCommand } from './ssh-remote-commands' import { @@ -36,6 +41,7 @@ import { type RemotePathFlavor } from './ssh-remote-platform' import { windowsRelayPipePathsForSocketName } from './ssh-relay-endpoints' +import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' import { isSshSessionLimitError } from './ssh-session-limit-error' // Why: the GC pass and the version-dir parser must agree on what counts as a @@ -51,18 +57,7 @@ const RELAY_VERSION_DIR_REGEX = /^relay-(v?\d+\.\d+\.\d+(\+[0-9a-f]+)?)$/ // living on remote disks forever. const LEGACY_RELAY_DIR_REGEX = /^relay-v\d+\.\d+\.\d+$/ -const INSTALL_LOCK_NAME = '.install-lock' const INSTALL_COMPLETE_NAME = '.install-complete' - -const INSTALL_LOCK_POLL_MS = 1_000 -const INSTALL_LOCK_TIMEOUT_MS = 120_000 -// Why: a stale lock dir from a crashed installer must be recoverable without -// user intervention. After the timeout we check the lock's mtime; if it's -// older than this window the previous installer is assumed dead and we steal -// the lock. 2 minutes is well above a normal `npm install node-pty` runtime -// (10–60s on slow hosts) so a slow concurrent installer is not falsely -// declared dead. -const INSTALL_LOCK_STALE_MS = 120_000 const DEFAULT_REMOTE_HOST = getRemoteHostPlatform('linux-x64') type RelayInstalledProbeOptions = { @@ -145,6 +140,7 @@ export async function isRelayAlreadyInstalled( ) return probe.trim() === 'OK' } catch (err) { + options?.signal?.throwIfAborted() if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { throw err } @@ -153,101 +149,27 @@ export async function isRelayAlreadyInstalled( } /** - * Acquire the per-version install lock via atomic `mkdir`. Returns when the - * caller owns the lock; throws if the lock could not be acquired within - * INSTALL_LOCK_TIMEOUT_MS even after one stale-lock recovery attempt. - * - * Why mkdir: POSIX `mkdir` is atomic and fails with EEXIST if the dir already - * exists, giving us a free mutex. A second concurrent caller polls and - * eventually either acquires the lock or steals it after the stale window. - */ -export async function acquireInstallLock( - conn: SshConnection, - remoteRelayDir: string, - host: RemoteHostPlatform = DEFAULT_REMOTE_HOST -): Promise { - const lockDir = joinRemotePath(host, remoteRelayDir, INSTALL_LOCK_NAME) - // Why: the parent dir may not exist yet on a first install. mkdir -p is - // safe to run multiple times — it's a no-op if the dir already exists. - await execHostCommand(conn, host, acquireInstallLockParentCommand(host, remoteRelayDir)) - - let start = Date.now() - let recoveredOnce = false - while (true) { - try { - const result = await execHostCommand(conn, host, tryCreateInstallLockCommand(host, lockDir)) - if (result.trim().endsWith('OK')) { - return - } - } catch { - /* mkdir failed with non-zero — fall through to BUSY treatment */ - } - if (Date.now() - start >= INSTALL_LOCK_TIMEOUT_MS) { - if (recoveredOnce) { - throw new Error( - `Could not acquire relay install lock at ${lockDir} after ${ - INSTALL_LOCK_TIMEOUT_MS / 1000 - }s; another install is in progress or the lock is wedged.` - ) - } - // Stale-lock recovery: if the lock dir's mtime is older than the stale - // window, the previous installer crashed. Steal it and retry once, - // resetting the timeout window so a single post-recovery race doesn't - // immediately exhaust the budget. - const ageOk = await isLockStale(conn, lockDir, host) - if (ageOk) { - console.warn(`[ssh-relay] Stealing stale install lock at ${lockDir}`) - await execHostCommand(conn, host, removeRemoteTreeCommand(host, lockDir)).catch(() => {}) - recoveredOnce = true - start = Date.now() - continue - } - throw new Error( - `Could not acquire relay install lock at ${lockDir} after ${ - INSTALL_LOCK_TIMEOUT_MS / 1000 - }s and the lock is not yet stale.` - ) - } - await new Promise((r) => setTimeout(r, INSTALL_LOCK_POLL_MS)) - } -} - -async function isLockStale( - conn: SshConnection, - lockDir: string, - host: RemoteHostPlatform = DEFAULT_REMOTE_HOST -): Promise { - try { - // Why: `stat` flags differ between GNU coreutils (Linux) and BSD (macOS). - // We try GNU first, then BSD; both produce a Unix epoch in seconds on - // stdout. If both fail we conservatively treat the lock as not stale. - const out = await execHostCommand(conn, host, lockMtimeEpochCommand(host, lockDir)) - const mtimeSec = Number.parseInt(out.trim(), 10) - if (!Number.isFinite(mtimeSec)) { - return false - } - const ageMs = Date.now() - mtimeSec * 1000 - return ageMs > INSTALL_LOCK_STALE_MS - } catch { - return false - } -} - -/** - * Mark the install as complete and release the lock. Sentinel ordering is: - * write `.install-complete` FIRST, then remove `.install-lock`. This ensures - * a sibling dir is never observed by GC as "complete but locked", which - * would lead GC to skip a recoverable dir indefinitely. + * Mark the install as complete, then normally release the lock. Deploy keeps + * the lock through first launch so cross-version GC cannot move the directory + * between finalization and daemon liveness becoming observable. */ export async function finalizeInstall( conn: SshConnection, remoteRelayDir: string, - host: RemoteHostPlatform = DEFAULT_REMOTE_HOST + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, + options?: { signal?: AbortSignal; releaseLock?: boolean } ): Promise { const sentinel = joinRemotePath(host, remoteRelayDir, INSTALL_COMPLETE_NAME) - const lock = joinRemotePath(host, remoteRelayDir, INSTALL_LOCK_NAME) - await execHostCommand(conn, host, writeRemoteEmptyFileCommand(host, sentinel)) - await execHostCommand(conn, host, removeRemoteTreeCommand(host, lock)).catch(() => {}) + const lock = joinRemotePath(host, remoteRelayDir, RELAY_INSTALL_LOCK_NAME) + await execHostCommand(conn, host, writeRemoteEmptyFileCommand(host, sentinel), { + signal: options?.signal + }) + if (options?.releaseLock !== false) { + await execHostCommand(conn, host, removeRemoteTreeCommand(host, lock), { + signal: options?.signal + }).catch(() => {}) + } + options?.signal?.throwIfAborted() } /** @@ -260,7 +182,7 @@ export async function abandonInstall( remoteRelayDir: string, host: RemoteHostPlatform = DEFAULT_REMOTE_HOST ): Promise { - const lock = joinRemotePath(host, remoteRelayDir, INSTALL_LOCK_NAME) + const lock = joinRemotePath(host, remoteRelayDir, RELAY_INSTALL_LOCK_NAME) await execHostCommand(conn, host, removeRemoteTreeCommand(host, lock)).catch(() => {}) } @@ -295,10 +217,14 @@ export async function gcOldRelayVersions( } catch { return } - const candidates = listing + const entries = listing .split('\n') .map((s) => s.trim()) .filter(Boolean) + + await cleanupRelayGcTombstones(conn, baseDir, entries, host) + + const candidates = entries .filter((name) => RELAY_VERSION_DIR_REGEX.test(name)) .filter((name) => name !== currentDirName) @@ -316,7 +242,47 @@ export async function gcOldRelayVersions( kept.push(name) continue } - await execHostCommand(conn, host, removeRemoteTreeCommand(host, dir)) + // Why: the claim is a sibling, so it survives moving/deleting the + // candidate and lets installers back out before mutating the old path. + const gcClaimToken = await tryAcquireRelayGcClaim(conn, dir, host) + if (!gcClaimToken) { + kept.push(name) + continue + } + let preserveGcClaim = false + let gcClaimReleaseNeeded = true + try { + // Recheck under the stable claim. New installers probe the claim both + // before and after creating their in-tree lock, closing both orders. + if (!(await isCandidateSafeToRemove(conn, dir, name, host, options))) { + kept.push(name) + continue + } + if (!(await isRelayGcClaimOwned(conn, dir, gcClaimToken, host))) { + kept.push(name) + continue + } + const tombstone = `${dir}.gc-tombstone.${process.pid}.${Date.now()}` + const moved = await execHostCommand(conn, host, moveRemoteTreeCommand(host, dir, tombstone)) + if (moved.trim() !== 'MOVED') { + kept.push(name) + continue + } + // Once renamed, a fresh install at the original path is isolated from + // deletion of the tombstone, so the sibling claim can be released. + const release = await releaseRelayGcClaimWithRetry(conn, dir, gcClaimToken, host) + gcClaimReleaseNeeded = release === 'unknown' + await execHostCommand(conn, host, removeRemoteTreeCommand(host, tombstone)) + } catch (err) { + if (isUnconfirmedSshCommandTermination(err)) { + preserveGcClaim = true + } + throw err + } finally { + if (!preserveGcClaim && gcClaimReleaseNeeded) { + await releaseRelayGcClaimWithRetry(conn, dir, gcClaimToken, host) + } + } removed.push(name) } catch (err) { console.warn( @@ -346,23 +312,28 @@ async function isCandidateSafeToRemove( ): Promise { const isLegacy = LEGACY_RELAY_DIR_REGEX.test(name) - const lockDir = joinRemotePath(host, dir, INSTALL_LOCK_NAME) - const lockProbe = await execHostCommand( - conn, - host, - probeDirectoryExistsCommand(host, lockDir) - ).catch(() => 'OPEN') - const locked = lockProbe.trim() === 'LOCKED' + const lockDir = joinRemotePath(host, dir, RELAY_INSTALL_LOCK_NAME) + let lockProbe: string + try { + lockProbe = await execHostCommand(conn, host, probeInstallLockExistsCommand(host, lockDir)) + } catch { + return false + } + const lockState = lockProbe.trim() + if (lockState !== 'OPEN' && lockState !== 'LOCKED') { + return false + } + const locked = lockState === 'LOCKED' if (locked) { // Why: a locked dir is normally unsafe to remove — but a STALE lock - // (mtime older than INSTALL_LOCK_STALE_MS) means the previous installer + // (remote age older than INSTALL_LOCK_STALE_MS) means the previous installer // crashed and is never coming back. If the dir also has the // .install-complete sentinel (touch succeeded but the rm-lock at the // end of finalizeInstall failed), removing the dir is safe — no // installer is racing us, and the daemon (if any) keeps running off // its already-loaded code regardless of disk state. - if (!(await isLockStale(conn, lockDir, host))) { + if (!(await isRelayInstallLockStale(conn, lockDir, host))) { return false } process.stderr.write?.(`[ssh-relay] GC: lock at ${lockDir} is stale; treating as recoverable\n`) @@ -419,8 +390,10 @@ async function hasLiveRelaySocket( host, relayLivenessProbeCommand(host, dir, windowsOptions) ) - return out.includes('ALIVE') + const state = out.trim() + return state !== 'DEAD' && state !== 'WAITING' } catch { - return false + // Why: an inconclusive liveness probe must never authorize deletion. + return true } } diff --git a/src/main/ssh/ssh-remote-commands.test.ts b/src/main/ssh/ssh-remote-commands.test.ts index 952daf9edde..96cc8925515 100644 --- a/src/main/ssh/ssh-remote-commands.test.ts +++ b/src/main/ssh/ssh-remote-commands.test.ts @@ -1,24 +1,109 @@ +import { execFileSync, spawn, spawnSync } from 'node:child_process' +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + statSync, + utimesSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { describe, expect, it } from 'vitest' +import { + lockAgeSecondsCommand, + tryCreateInstallLockCommand, + tryStealInstallLockCommand +} from './ssh-relay-install-lock-commands' import { commandInRemoteDirectory, commandWithNodePath, listRelayBaseDirsCommand, makeRemoteDirectoryCommand, + moveRemoteTreeCommand, + probeDirectoryExistsCommand, probeRelayInstalledCommand, readRemoteHomeCommand, - relayLivenessProbeCommand, - tryCreateInstallLockCommand + relayLivenessProbeCommand } from './ssh-remote-commands' import { getRemoteHostPlatform } from './ssh-remote-platform' const posix = getRemoteHostPlatform('linux-x64') const windows = getRemoteHostPlatform('win32-x64') +const powerShellExecutable = ( + process.platform === 'win32' ? ['pwsh.exe', 'powershell.exe'] : ['pwsh'] +).find((candidate) => { + const result = spawnSync(candidate, ['-NoProfile', '-NonInteractive', '-Command', 'exit 0'], { + stdio: 'ignore' + }) + return result.status === 0 +}) +const powerShell51Executable = + process.platform === 'win32' && + spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', 'exit 0'], { + stdio: 'ignore' + }).status === 0 + ? 'powershell.exe' + : undefined function decodePowerShellCommand(command: string): string { const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/) return match ? Buffer.from(match[1], 'base64').toString('utf16le') : '' } +function runShellCommand(command: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn('/bin/sh', ['-c', command], { + stdio: ['ignore', 'pipe', 'pipe'] + }) + let stdout = '' + let stderr = '' + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.stdout.on('data', (chunk) => { + stdout += chunk + }) + child.stderr.on('data', (chunk) => { + stderr += chunk + }) + child.on('error', reject) + child.on('close', (code) => { + if (code === 0) { + resolve(stdout) + return + } + reject(new Error(`shell exited ${code}: ${stderr}`)) + }) + }) +} + +function runPowerShellCommand(executable: string, script: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn(executable, ['-NoProfile', '-NonInteractive', '-Command', script], { + stdio: ['ignore', 'pipe', 'pipe'] + }) + let stdout = '' + let stderr = '' + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.stdout.on('data', (chunk) => { + stdout += chunk + }) + child.stderr.on('data', (chunk) => { + stderr += chunk + }) + child.on('error', reject) + child.on('close', (code) => { + if (code === 0) { + resolve(stdout) + return + } + reject(new Error(`PowerShell exited ${code}: ${stderr}`)) + }) + }) +} + describe('ssh remote command builders', () => { it('keeps POSIX deploy commands POSIX-native', () => { expect(readRemoteHomeCommand(posix)).toBe('echo $HOME') @@ -34,7 +119,7 @@ describe('ssh remote command builders', () => { expect(probeRelayInstalledCommand(windows, 'C:/Users/me/relay')).toContain('-EncodedCommand') }) - it('uses -Path for Windows New-Item commands', () => { + it('uses a legacy-visible Windows lock directory with an exclusive owner file', () => { const mkdirScript = decodePowerShellCommand( makeRemoteDirectoryCommand(windows, 'C:/Users/me/.orca-remote') ) @@ -43,9 +128,31 @@ describe('ssh remote command builders', () => { ) expect(mkdirScript).toContain('New-Item -ItemType Directory -Force -Path') - expect(lockScript).toContain('New-Item -ItemType Directory -Path') + expect(lockScript).toContain('[System.IO.FileMode]::CreateNew') + expect(lockScript).toContain('[System.IO.FileShare]::None') + expect(lockScript).toContain('New-Item -ItemType Directory -Path $lock') + expect(lockScript).toContain("Join-Path $lock '.owner'") expect(mkdirScript).not.toContain('New-Item -ItemType Directory -Force -LiteralPath') - expect(lockScript).not.toContain('New-Item -ItemType Directory -LiteralPath') + }) + + it('moves GC candidates to a sibling tombstone with host-native commands', () => { + expect(moveRemoteTreeCommand(posix, '/relay/old', '/relay/old.gc-tombstone')).toContain( + "mv '/relay/old' '/relay/old.gc-tombstone'" + ) + const windowsScript = decodePowerShellCommand( + moveRemoteTreeCommand(windows, 'C:/Users/me/relay/old', 'C:/Users/me/relay/old.gc-tombstone') + ) + expect(windowsScript).toContain('Move-Item -LiteralPath') + expect(windowsScript).toContain("-Destination 'C:/Users/me/relay/old.gc-tombstone'") + expect(windowsScript).toContain("'MOVED'") + }) + + it('emits an explicit POSIX liveness result so GC can fail closed', () => { + const command = relayLivenessProbeCommand(posix, '/home/u/.orca-remote/relay-0.1.0') + + expect(command).toContain('state=DEAD') + expect(command).toContain('[ -S "$f" ] && state=ALIVE') + expect(command).toContain('echo "$state"') }) it('uses named pipe try-connect liveness for Windows GC', () => { @@ -96,11 +203,227 @@ describe('ssh remote command builders', () => { tryCreateInstallLockCommand(windows, 'C:/Users/me/.orca-remote/relay/.install-lock') ) - expect(script).toContain('$ErrorActionPreference = "Stop"; try {') + expect(script).toContain('$stream = $null; try {') expect(script).toContain("} catch { 'BUSY' }") expect(script).not.toContain('}; catch') }) + it('computes install-lock age on the remote host clock', () => { + const posixCommand = lockAgeSecondsCommand(posix, '/home/me/.orca-remote/relay/.install-lock') + const windowsScript = decodePowerShellCommand( + lockAgeSecondsCommand(windows, 'C:/Users/me/.orca-remote/relay/.install-lock') + ) + + expect(posixCommand).toContain('date +%s') + expect(posixCommand).toContain('echo "$age"') + expect(windowsScript).toContain('[DateTimeOffset]::UtcNow.ToUnixTimeSeconds()') + expect(windowsScript).toContain('Write-Output ($now - $mtime)') + }) + + it('serializes stale recovery with unbounded numbered sibling claims', () => { + const posixCommand = tryStealInstallLockCommand( + posix, + '/home/me/.orca-remote/relay/.install-lock', + 20 * 60 + ) + const windowsScript = decodePowerShellCommand( + tryStealInstallLockCommand(windows, 'C:/Users/me/.orca-remote/relay/.install-lock', 20 * 60) + ) + + expect(posixCommand).toContain('.install-lock') + expect(posixCommand).toContain('.install-lock.steal') + expect(posixCommand).toContain('steal_generation') + expect(posixCommand).toContain('mtime=${lock_key%%:*}') + expect(posixCommand).toContain('-gt 1200') + expect(posixCommand).toContain('current_mtime') + expect(posixCommand).toContain('steal_generation + 1') + expect(posixCommand).not.toContain('.next.') + expect(posixCommand).toContain('trap') + expect(windowsScript).toContain('$lock.steal') + expect(windowsScript).toContain('$stealGeneration++') + expect(windowsScript).toContain('-gt 1200') + expect(windowsScript).toContain('$currentIdentity -eq $lockIdentity') + expect(windowsScript).toContain('[System.IO.FileMode]::CreateNew') + expect(windowsScript).not.toContain('.next.') + expect(windowsScript).toContain('finally') + }) + + it.runIf(powerShellExecutable)( + 'emits a parseable Windows stale-lock recovery command', + () => { + const script = decodePowerShellCommand( + tryStealInstallLockCommand( + windows, + 'C:/Users/orca-missing/.orca-remote/relay/.install-lock', + 20 * 60 + ) + ) + const result = spawnSync( + powerShellExecutable!, + ['-NoProfile', '-NonInteractive', '-Command', script], + { encoding: 'utf8' } + ) + + expect(result.status, result.stderr).toBe(0) + expect(result.stdout.trim()).toBe('BUSY') + }, + 15_000 + ) + + it.runIf(powerShell51Executable)( + 'lets only one Windows PowerShell 5.1 caller acquire an install lock', + async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-install-lock-windows-race-')) + try { + const lockPath = join(root, '.install-lock') + const script = decodePowerShellCommand(tryCreateInstallLockCommand(windows, lockPath)) + const outputs = await Promise.all( + Array.from({ length: 16 }, () => runPowerShellCommand(powerShell51Executable!, script)) + ) + + expect(outputs.filter((output) => output.trim().endsWith('OK'))).toHaveLength(1) + expect(statSync(lockPath).isDirectory()).toBe(true) + expect(statSync(join(lockPath, '.owner')).isFile()).toBe(true) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }, + 30_000 + ) + + it.runIf(powerShell51Executable)( + 'recovers a stale Windows lock past abandoned steal generations', + async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-install-lock-windows-stale-')) + try { + const lockPath = join(root, '.install-lock') + mkdirSync(lockPath) + const staleDate = new Date(Date.now() - 60 * 60_000) + utimesSync(lockPath, staleDate, staleDate) + for (let i = 0; i < 12; i++) { + const orphan = `${lockPath}.steal.${i}` + mkdirSync(orphan) + utimesSync(orphan, staleDate, staleDate) + } + const script = decodePowerShellCommand( + tryStealInstallLockCommand(windows, lockPath, 20 * 60) + ) + + const output = await runPowerShellCommand(powerShell51Executable!, script) + + expect(output.trim()).toBe('OK') + expect(statSync(lockPath).isDirectory()).toBe(true) + expect(statSync(join(lockPath, '.owner')).isFile()).toBe(true) + expect(readdirSync(root).filter((name) => name.includes('.steal.'))).toHaveLength(0) + expect(readdirSync(root).filter((name) => name.includes('.tombstone.'))).toHaveLength(0) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }, + 15_000 + ) + + it.runIf(powerShell51Executable)( + 'lets only one Windows PowerShell 5.1 caller replace a stale lock', + async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-install-lock-windows-steal-race-')) + try { + const lockPath = join(root, '.install-lock') + mkdirSync(lockPath) + const staleDate = new Date(Date.now() - 60 * 60_000) + utimesSync(lockPath, staleDate, staleDate) + const script = decodePowerShellCommand( + tryStealInstallLockCommand(windows, lockPath, 20 * 60) + ) + + const outputs = await Promise.all( + Array.from({ length: 16 }, () => runPowerShellCommand(powerShell51Executable!, script)) + ) + + expect(outputs.filter((output) => output.trim().endsWith('OK'))).toHaveLength(1) + expect(statSync(lockPath).isDirectory()).toBe(true) + expect(statSync(join(lockPath, '.owner')).isFile()).toBe(true) + expect(readdirSync(root).filter((name) => name.includes('.tombstone.'))).toHaveLength(0) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }, + 30_000 + ) + + it.runIf(powerShellExecutable)( + 'keeps a new Windows lock visible to the previous directory-only GC probe', + async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-install-lock-windows-compat-')) + try { + const lockPath = join(root, '.install-lock') + const acquire = decodePowerShellCommand(tryCreateInstallLockCommand(windows, lockPath)) + const legacyProbe = decodePowerShellCommand(probeDirectoryExistsCommand(windows, lockPath)) + + await expect(runPowerShellCommand(powerShellExecutable!, acquire)).resolves.toMatch(/OK/) + await expect(runPowerShellCommand(powerShellExecutable!, legacyProbe)).resolves.toMatch( + /LOCKED/ + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }, + 15_000 + ) + + it.runIf(process.platform !== 'win32')( + 'recovers and cleans more than eight orphaned numbered steal claims', + () => { + const root = mkdtempSync(join(tmpdir(), 'orca-install-lock-')) + try { + const lockDir = join(root, '.install-lock') + mkdirSync(lockDir) + const staleDate = new Date(Date.now() - 60 * 60_000) + utimesSync(lockDir, staleDate, staleDate) + const lockMtimeSeconds = Math.floor(statSync(lockDir).mtimeMs / 1000) + for (let i = 0; i < 12; i++) { + const orphan = `${lockDir}.steal.${i}` + mkdirSync(orphan) + utimesSync(orphan, staleDate, staleDate) + } + + const command = tryStealInstallLockCommand(posix, lockDir, 20 * 60) + const output = execFileSync('/bin/sh', ['-c', command], { encoding: 'utf8' }) + + expect(output.trim()).toBe('OK') + expect(existsSync(lockDir)).toBe(true) + expect(readdirSync(root).filter((name) => name.includes('.steal.'))).toHaveLength(0) + expect(Math.floor(statSync(lockDir).mtimeMs / 1000)).toBeGreaterThan(lockMtimeSeconds) + } finally { + rmSync(root, { recursive: true, force: true }) + } + } + ) + + it.runIf(process.platform !== 'win32')( + 'lets only one POSIX caller move and recreate a stale install lock', + async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-install-lock-race-')) + try { + const lockDir = join(root, '.install-lock') + mkdirSync(lockDir) + const staleDate = new Date(Date.now() - 60 * 60_000) + utimesSync(lockDir, staleDate, staleDate) + const command = tryStealInstallLockCommand(posix, lockDir, 20 * 60) + const outputs = await Promise.all( + Array.from({ length: 64 }, () => runShellCommand(command)) + ) + const okCount = outputs.filter((output) => output.trim().endsWith('OK')).length + + expect(okCount).toBe(1) + expect(existsSync(lockDir)).toBe(true) + expect(readdirSync(root).some((name) => name.includes('.tombstone'))).toBe(false) + } finally { + rmSync(root, { recursive: true, force: true }) + } + } + ) + it('makes Windows remote directory changes fail before running scoped commands', () => { const scopedCommand = decodePowerShellCommand( commandInRemoteDirectory(windows, 'C:/Users/me/.orca-remote/relay-0.1.0', "'READY'") diff --git a/src/main/ssh/ssh-remote-commands.ts b/src/main/ssh/ssh-remote-commands.ts index 339f4aef594..b70761f8d54 100644 --- a/src/main/ssh/ssh-remote-commands.ts +++ b/src/main/ssh/ssh-remote-commands.ts @@ -45,6 +45,24 @@ export function removeRemoteTreeCommand(host: RemoteHostPlatform, remotePath: st ) } +export function moveRemoteTreeCommand( + host: RemoteHostPlatform, + sourcePath: string, + destinationPath: string +): string { + if (!isWindowsRemoteHost(host)) { + return `mv ${shellEscape(sourcePath)} ${shellEscape(destinationPath)} 2>&1 && echo MOVED || echo BUSY` + } + return powerShellCommand( + [ + 'try {', + `Move-Item -LiteralPath ${powerShellLiteral(sourcePath)} -Destination ${powerShellLiteral(destinationPath)} -ErrorAction Stop`, + "'MOVED'", + `} catch { 'BUSY' }` + ].join('; ') + ) +} + export function writeRemoteEmptyFileCommand(host: RemoteHostPlatform, remotePath: string): string { if (!isWindowsRemoteHost(host)) { return `touch ${shellEscape(remotePath)}` @@ -81,36 +99,6 @@ export function probeRelayInstalledCommand( ) } -export function acquireInstallLockParentCommand( - host: RemoteHostPlatform, - remoteRelayDir: string -): string { - return makeRemoteDirectoryCommand(host, remoteRelayDir) -} - -export function tryCreateInstallLockCommand(host: RemoteHostPlatform, lockDir: string): string { - if (!isWindowsRemoteHost(host)) { - return `mkdir ${shellEscape(lockDir)} 2>&1 && echo OK || echo BUSY` - } - // New-Item has no -LiteralPath parameter; using it breaks stock Windows PowerShell. - return powerShellCommand( - `$ErrorActionPreference = "Stop"; try { $null = New-Item -ItemType Directory -Path ${powerShellLiteral(lockDir)}; 'OK' } catch { 'BUSY' }` - ) -} - -export function lockMtimeEpochCommand(host: RemoteHostPlatform, lockDir: string): string { - if (!isWindowsRemoteHost(host)) { - return `stat -c %Y ${shellEscape(lockDir)} 2>/dev/null || stat -f %m ${shellEscape(lockDir)} 2>/dev/null || echo` - } - return powerShellCommand( - [ - `$item = Get-Item -LiteralPath ${powerShellLiteral(lockDir)} -ErrorAction Stop`, - '$dto = [DateTimeOffset]$item.LastWriteTimeUtc', - 'Write-Output $dto.ToUnixTimeSeconds()' - ].join('; ') - ) -} - export function listRelayBaseDirsCommand(host: RemoteHostPlatform, baseDir: string): string { if (!isWindowsRemoteHost(host)) { return `ls -1 ${shellEscape(baseDir)} 2>/dev/null || true` @@ -155,9 +143,9 @@ export function relayLivenessProbeCommand( ): string { if (!isWindowsRemoteHost(host)) { return ( - `for f in ${shellEscape(dir)}/relay-*.sock ${shellEscape(dir)}/relay.sock; do ` + - `[ -S "$f" ] && echo ALIVE && break; ` + - 'done; true' + `state=DEAD; for f in ${shellEscape(dir)}/relay-*.sock ${shellEscape(dir)}/relay.sock; do ` + + `[ -S "$f" ] && state=ALIVE && break; ` + + 'done; echo "$state"' ) } if (!windowsOptions) { diff --git a/src/main/ssh/ssh-remote-node-resolution.ts b/src/main/ssh/ssh-remote-node-resolution.ts index 01d6a0c7d48..a5041ed158d 100644 --- a/src/main/ssh/ssh-remote-node-resolution.ts +++ b/src/main/ssh/ssh-remote-node-resolution.ts @@ -122,6 +122,7 @@ true if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { throw err } + throwIfAborted(options) // Fall through to login shell. } return null @@ -167,6 +168,7 @@ async function tryResolveViaLoginShell( if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { throw err } + throwIfAborted(options) // Fall through. } return null @@ -191,6 +193,7 @@ async function nodeMeetsVersionRequirement( if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { throw err } + throwIfAborted(options) // Binary missing or fails to run — not usable. return false } @@ -240,6 +243,7 @@ async function resolveRemoteWindowsNodePath( if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { throw err } + throwIfAborted(options) // Fall through to the shared error below. } @@ -262,6 +266,7 @@ async function windowsNodeMeetsVersionRequirement( if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { throw err } + throwIfAborted(options) return false } } diff --git a/src/main/ssh/ssh-remote-platform-detection.ts b/src/main/ssh/ssh-remote-platform-detection.ts index f72b5384335..44c31e617e4 100644 --- a/src/main/ssh/ssh-remote-platform-detection.ts +++ b/src/main/ssh/ssh-remote-platform-detection.ts @@ -11,30 +11,38 @@ import { powerShellCommand } from './ssh-remote-powershell' const PLATFORM_PROBE_MARKER = '__ORCA_REMOTE_PLATFORM__' export async function detectRemoteHostPlatform( - conn: SshConnection + conn: SshConnection, + options?: { signal?: AbortSignal } ): Promise { - const unamePlatform = await detectUnamePlatform(conn) + const unamePlatform = await detectUnamePlatform(conn, options?.signal) if (unamePlatform) { return getRemoteHostPlatform(unamePlatform) } - const windowsPlatform = await detectWindowsPlatform(conn) + const windowsPlatform = await detectWindowsPlatform(conn, options?.signal) return windowsPlatform ? getRemoteHostPlatform(windowsPlatform) : null } -async function detectUnamePlatform(conn: SshConnection): Promise { +async function detectUnamePlatform( + conn: SshConnection, + signal?: AbortSignal +): Promise { try { - const output = await execCommand( - conn, - // Why: Remote startup output may omit its trailing newline and must not absorb the marker. - `printf '\\n%s ' '${PLATFORM_PROBE_MARKER}'; uname -sm` - ) + // Why: Remote startup output may omit its trailing newline and must not absorb the marker. + const command = `printf '\\n%s ' '${PLATFORM_PROBE_MARKER}'; uname -sm` + const output = signal + ? await execCommand(conn, command, { signal }) + : await execCommand(conn, command) return parseRemotePlatformOutput(output) } catch { + signal?.throwIfAborted() return null } } -async function detectWindowsPlatform(conn: SshConnection): Promise { +async function detectWindowsPlatform( + conn: SshConnection, + signal?: AbortSignal +): Promise { try { const script = [ '$arch = $env:PROCESSOR_ARCHITECTURE', @@ -43,9 +51,13 @@ async function detectWindowsPlatform(conn: SshConnection): Promise arg.startsWith('ControlPath='))).toBe(true) + expect(args).toContain('ControlPersist=300') +} + type EventedProcess = EventEmitter & { stdin: EventEmitter & { write: ReturnType @@ -360,9 +370,7 @@ describe('spawnSystemSsh', () => { }) }) - expect(args).toContain('ControlMaster=auto') - expect(args.some((arg) => arg.startsWith('ControlPath='))).toBe(true) - expect(args).toContain('ControlPersist=300') + expectOrcaControlMasterArgs(args) expect(args).not.toContain('-S') }) @@ -374,9 +382,7 @@ describe('spawnSystemSsh', () => { }) }) - expect(args).toContain('ControlMaster=auto') - expect(args.some((arg) => arg.startsWith('ControlPath='))).toBe(true) - expect(args).toContain('ControlPersist=300') + expectOrcaControlMasterArgs(args) expect(args).not.toContain('-S') }) @@ -387,9 +393,7 @@ describe('spawnSystemSsh', () => { }) }) - expect(args).toContain('ControlMaster=auto') - expect(args.some((arg) => arg.startsWith('ControlPath='))).toBe(true) - expect(args).toContain('ControlPersist=300') + expectOrcaControlMasterArgs(args) expect(args).not.toContain('-S') }) @@ -414,9 +418,7 @@ describe('spawnSystemSsh', () => { resolvedConfig: createResolvedConfig() }) - expect(args).toContain('ControlMaster=auto') - expect(args.some((arg) => arg.startsWith('ControlPath='))).toBe(true) - expect(args).toContain('ControlPersist=300') + expectOrcaControlMasterArgs(args) expect(args).not.toContain('-S') }) @@ -432,10 +434,11 @@ describe('spawnSystemSsh', () => { it('adds keepalive options to Orca-owned ControlMaster connections', () => { const args = buildSshArgs(createTarget(), { resolvedConfig: createResolvedConfig() }) - expect(args).toContain('ControlMaster=auto') - expect(args).toContain('ControlPersist=300') - expect(args).toContain('ServerAliveInterval=15') - expect(args).toContain('ServerAliveCountMax=3') + expectOrcaControlMasterArgs(args) + if (process.platform !== 'win32') { + expect(args).toContain('ServerAliveInterval=15') + expect(args).toContain('ServerAliveCountMax=3') + } }) it('spawns a remote command through the system ssh target', () => { @@ -507,6 +510,15 @@ describe('spawnSystemSsh', () => { expect(mockProc.stdin.end).toHaveBeenCalledWith('contents') }) + it('marks a system command channel when local teardown is requested', () => { + const channel = spawnSystemSshCommand(createTarget(), 'npm install') + + channel.close() + + expect(channel._closeRequested).toBe(true) + expect(mockProc.kill).toHaveBeenCalledWith('SIGTERM') + }) + it('removes wrapped process listeners after command close', () => { const proc = createEventedProcess() spawnMock.mockReturnValue(proc) diff --git a/src/main/ssh/system-ssh-command.ts b/src/main/ssh/system-ssh-command.ts index 1f0ce5ed6df..eab96245ba5 100644 --- a/src/main/ssh/system-ssh-command.ts +++ b/src/main/ssh/system-ssh-command.ts @@ -17,6 +17,7 @@ export type SystemSshProcess = { export type SystemSshCommandChannel = ClientChannel & { _process?: ChildProcess + _closeRequested?: boolean } type SystemSshCommandOptions = SshExecOptions & SystemSshBuildArgsOptions @@ -103,12 +104,14 @@ function wrapCommandProcess(proc: ChildProcess): SystemSshCommandChannel { stdin: NodeJS.WritableStream stderr: NodeJS.ReadableStream _process?: ChildProcess + _closeRequested?: boolean close: () => void } mutableChannel.stdin = proc.stdin! mutableChannel.stderr = proc.stderr! mutableChannel._process = proc mutableChannel.close = () => { + mutableChannel._closeRequested = true try { proc.kill('SIGTERM') } catch { diff --git a/src/main/ssh/system-ssh-operation-lifecycle.ts b/src/main/ssh/system-ssh-operation-lifecycle.ts index 76a2ae3fb12..c9c4424325b 100644 --- a/src/main/ssh/system-ssh-operation-lifecycle.ts +++ b/src/main/ssh/system-ssh-operation-lifecycle.ts @@ -100,7 +100,13 @@ export async function awaitWithSystemSshAbort( // children to emit close after we've already signaled them. abortChildren() suppressLateOperationError = true - abortReject?.(createAbortError()) + abortReject?.( + Object.assign(createAbortError(), { + // The child was signaled, but this fast abort path intentionally does + // not wait for process exit; callers holding remote locks must retain them. + sshChannelCloseConfirmed: false + }) + ) } signal.addEventListener('abort', abort, { once: true }) if (signal.aborted) { 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 4464bb8f4ed..3b921daf65b 100644 --- a/src/main/text-generation/commit-message-text-generation.test.ts +++ b/src/main/text-generation/commit-message-text-generation.test.ts @@ -1853,7 +1853,7 @@ describe('generateBranchNameFromContext', () => { }) }) - it('includes the branch-name custom prompt in the generated prompt', async () => { + it('keeps branch-name guidance first without dropping the output contract', async () => { let prompt = '' await generateBranchNameFromContext( { firstPrompt: 'Fix login flow' }, @@ -1861,7 +1861,8 @@ describe('generateBranchNameFromContext', () => { agentId: 'custom', model: '', customAgentCommand: 'agent', - customPrompt: 'Prefer auth terminology.' + customPrompt: 'Prefer auth terminology.', + commandInputTemplate: 'Prefer auth terminology.\n\n{basePrompt}' }, { kind: 'remote', @@ -1879,8 +1880,11 @@ describe('generateBranchNameFromContext', () => { } ) - expect(prompt).toContain('Additional user prompt:') + expect(prompt.startsWith('Prefer auth terminology.')).toBe(true) expect(prompt).toContain('Prefer auth terminology.') + expect(prompt).not.toContain('Additional user prompt:') + expect(prompt).toContain('Generate a short git branch name') + expect(prompt).toContain('Output ONLY the branch name on a single line') }) }) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 77457c01422..099aa15bcd1 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -67,6 +67,8 @@ import type { BaseRefDefaultResult, BaseRefSearchResult, BrowserCookieImportResult, + BrowserCertificateFailure, + BrowserCertificateProceedResult, BrowserLoadError, BrowserSessionProfile, BrowserSessionProfileScope, @@ -479,7 +481,7 @@ export type BrowserApi = { worktreeId: string sessionProfileId?: string | null webContentsId: number - }) => Promise + }) => Promise unregisterGuest: (args: { browserPageId: string }) => Promise openDevTools: (args: { browserPageId: string }) => Promise setViewportOverride: (args: { @@ -490,6 +492,13 @@ export type BrowserApi = { onGuestLoadFailed: ( callback: (args: { browserPageId: string; loadError: BrowserLoadError }) => void ) => () => void + onCertificateFailureChanged: ( + callback: (event: { browserPageId: string; failure: BrowserCertificateFailure | null }) => void + ) => () => void + proceedCertificate: (args: { + browserPageId: string + challengeId: string + }) => Promise onPermissionDenied: (callback: (event: BrowserPermissionDeniedEvent) => void) => () => void onPopup: (callback: (event: BrowserPopupEvent) => void) => () => void onDownloadRequested: (callback: (event: BrowserDownloadRequestedEvent) => void) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 8e33b6ff2e1..910f8a0809b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2239,7 +2239,7 @@ const api = { worktreeId: string sessionProfileId?: string | null webContentsId: number - }): Promise => ipcRenderer.invoke('browser:registerGuest', args), + }): Promise => ipcRenderer.invoke('browser:registerGuest', args), unregisterGuest: (args: { browserPageId: string }): Promise => ipcRenderer.invoke('browser:unregisterGuest', args), @@ -2272,6 +2272,17 @@ const api = { return () => ipcRenderer.removeListener('browser:guest-load-failed', listener) }, + onCertificateFailureChanged: (callback): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: Parameters[0] + ): void => callback(data) + ipcRenderer.on('browser:certificate-failure-changed', listener) + return () => ipcRenderer.removeListener('browser:certificate-failure-changed', listener) + }, + + proceedCertificate: (args) => ipcRenderer.invoke('browser:proceedCertificate', args), + onPermissionDenied: ( callback: (event: { browserPageId: string; permission: string; origin: string }) => void ): (() => void) => { diff --git a/src/relay/git-handler-blob-readers.test.ts b/src/relay/git-handler-blob-readers.test.ts index 3a32eccaed1..b2b9e87ecbb 100644 --- a/src/relay/git-handler-blob-readers.test.ts +++ b/src/relay/git-handler-blob-readers.test.ts @@ -44,6 +44,7 @@ describe('git blob readers', () => { const result = await readBlobAtIndex(gitBuffer, '/repo', 'large.log') - expect(result).toEqual({ content: '', isBinary: true }) + // Why: overflow is size-capped content, not a staged deletion (missing: false). + expect(result).toEqual({ content: '', isBinary: true, missing: false }) }) }) diff --git a/src/relay/git-handler-ops.ts b/src/relay/git-handler-ops.ts index 395e31badb3..afe75a2b582 100644 --- a/src/relay/git-handler-ops.ts +++ b/src/relay/git-handler-ops.ts @@ -52,17 +52,19 @@ export async function readBlobAtIndex( gitBuffer: GitBufferExec, cwd: string, filePath: string -): Promise<{ content: string; isBinary: boolean }> { +): Promise<{ content: string; isBinary: boolean; missing: boolean }> { // Why: Git's `:` syntax expects forward slashes even on Windows. const gitPath = filePath.replace(/\\/g, '/') try { const buf = await gitBuffer(['show', '--end-of-options', `:${gitPath}`], cwd) - return bufferToBlob(buf, filePath) + return { ...bufferToBlob(buf, filePath), missing: false } } catch (error) { if (isGitBufferOverflowError(error)) { - return { content: '', isBinary: true } + return { content: '', isBinary: true, missing: false } } - return { content: '', isBinary: false } + // Why: a non-overflow failure means the path is absent from the index (a + // staged deletion), distinct from the size-capped case handled above. + return { content: '', isBinary: false, missing: true } } } @@ -91,6 +93,7 @@ export async function computeDiff( let modifiedContent = '' let originalIsBinary = false let modifiedIsBinary = false + let modifiedDeleted = false try { if (staged) { @@ -101,6 +104,7 @@ export async function computeDiff( const right = await readBlobAtIndex(git, worktreePath, filePath) modifiedContent = right.content modifiedIsBinary = right.isBinary + modifiedDeleted = right.missing } else { const left = compareAgainstHead ? await readBlobAtOid(git, worktreePath, 'HEAD', filePath) @@ -111,18 +115,25 @@ export async function computeDiff( const right = await readWorkingDiffFile(path.join(worktreePath, filePath)) modifiedContent = right.content modifiedIsBinary = right.isBinary + modifiedDeleted = right.missing } } catch { // Fallback to empty } - return buildDiffResult( + const result = buildDiffResult( originalContent, modifiedContent, originalIsBinary, modifiedIsBinary, filePath ) + // Why: mark a proven deletion so previewers can fall back to the original bytes + // without mistaking a read failure's empty modified side for a deletion. + if (result.kind === 'binary' && modifiedDeleted) { + return { ...result, modifiedDeleted: true } + } + return result } // ─── Branch compare ────────────────────────────────────────────────── diff --git a/src/relay/git-handler-worktree-ops.test.ts b/src/relay/git-handler-worktree-ops.test.ts index ed252df6a46..cedcf8b797c 100644 --- a/src/relay/git-handler-worktree-ops.test.ts +++ b/src/relay/git-handler-worktree-ops.test.ts @@ -196,6 +196,117 @@ describe('removeWorktreeOp', () => { ]) }) + it('force-retries removal when git refuses a clean worktree containing an initialised submodule', 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') { + listCount += 1 + return { + stdout: + listCount === 1 + ? worktreeList( + { path: '/repo', branch: 'main' }, + { path: '/repo-feature', branch: 'feature/test' } + ) + : worktreeList({ path: '/repo', branch: 'main' }), + stderr: '' + } + } + if (args[0] === 'worktree' && args[1] === 'remove' && !args.includes('--force')) { + throw Object.assign(new Error('git worktree remove failed'), { + stderr: 'fatal: working trees containing submodules cannot be moved or removed' + }) + } + return { stdout: '', stderr: '' } + }) + + await removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' }) + + expect(calls).toEqual([ + '/repo-feature$ rev-parse --git-common-dir', + `${resolvedRepoPath()}$ worktree list --porcelain -z`, + `${resolvedRepoPath()}$ worktree remove /repo-feature`, + '/repo-feature$ status --porcelain --untracked-files=all', + `${resolvedRepoPath()}$ worktree remove --force /repo-feature`, + `${resolvedRepoPath()}$ branch -d -- feature/test` + ]) + }) + + it('surfaces uncommitted changes instead of force-removing a dirty submodule worktree', async () => { + const git = vi.fn(async (args) => { + if (args[0] === 'rev-parse') { + return { stdout: '/repo/.git\n', stderr: '' } + } + if (args[0] === 'worktree' && args[1] === 'list') { + return { + stdout: worktreeList( + { path: '/repo', branch: 'main' }, + { path: '/repo-feature', branch: 'feature/test' } + ), + stderr: '' + } + } + if (args[0] === 'worktree' && args[1] === 'remove') { + throw Object.assign(new Error('git worktree remove failed'), { + stderr: 'fatal: working trees containing submodules cannot be moved or removed' + }) + } + if (args[0] === 'status') { + return { stdout: ' M sub\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + + await expect( + removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' }) + ).rejects.toThrow('Worktree has uncommitted or untracked changes.') + expect(git).not.toHaveBeenCalledWith( + ['worktree', 'remove', '--force', '/repo-feature'], + expect.any(String) + ) + }) + + it('does not force-retry when the caller already forced SSH removal', async () => { + const git = vi.fn(async (args) => { + if (args[0] === 'rev-parse') { + return { stdout: '/repo/.git\n', stderr: '' } + } + if (args[0] === 'worktree' && args[1] === 'list') { + return { + stdout: worktreeList( + { path: '/repo', branch: 'main' }, + { path: '/repo-feature', branch: 'feature/test' } + ), + stderr: '' + } + } + if (args[0] === 'worktree' && args[1] === 'remove') { + throw Object.assign(new Error('git worktree remove failed'), { + stderr: 'fatal: working trees containing submodules cannot be moved or removed' + }) + } + return { stdout: '', stderr: '' } + }) + + await expect( + removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature', force: true }) + ).rejects.toThrow('git worktree remove failed') + expect( + git.mock.calls.filter( + ([args]) => args[0] === 'worktree' && args[1] === 'remove' && args.includes('--force') + ) + ).toHaveLength(1) + expect(git).not.toHaveBeenCalledWith( + ['status', '--porcelain', '--untracked-files=all'], + expect.any(String) + ) + }) + it('preserves the branch (does not throw) when `branch -d` refuses an unmerged branch', async () => { let listCount = 0 const git = vi.fn(async (args) => { diff --git a/src/relay/git-handler-worktree-remove.ts b/src/relay/git-handler-worktree-remove.ts index 2c2ce54a8cc..46ebaf8a355 100644 --- a/src/relay/git-handler-worktree-remove.ts +++ b/src/relay/git-handler-worktree-remove.ts @@ -1,6 +1,7 @@ import * as path from 'node:path' import type { RemoveWorktreeResult } from '../shared/types' import { assertWorktreeUnlockedForRemoval } from '../shared/worktree-removal' +import { isSubmoduleWorktreeRemovalRefusal } from '../shared/worktree-submodule-removal' import { deleteAlreadyMergedRelayBranchAfterSafeDeleteFailure } from './git-handler-branch-cleanup' import type { GitExec } from './git-handler-ops' import type { GitCapabilityCache } from '../shared/git-capability-cache' @@ -156,7 +157,23 @@ export async function removeWorktreeOp( args.push('--force') } args.push(worktreePath) - await git(args, repoPath) + try { + await git(args, repoPath) + } catch (error) { + if (force || !isSubmoduleWorktreeRemovalRefusal(error)) { + throw error + } + // Why: Git refuses non-force removal of any worktree with an initialised + // submodule even when everything is clean. Re-prove cleanliness (parent + // status reports dirty submodule content as ` M `), then --force. + const { stdout } = await git(['status', '--porcelain', '--untracked-files=all'], worktreePath) + if (stdout.trim()) { + const dirtyError = new Error('Worktree has uncommitted or untracked changes.') + ;(dirtyError as Error & { stdout?: string }).stdout = stdout + throw dirtyError + } + await git(['worktree', 'remove', '--force', worktreePath], repoPath) + } if (!branchName) { return {} diff --git a/src/relay/git-working-file-read.test.ts b/src/relay/git-working-file-read.test.ts index b4f8e3e6f7f..7f9824ead94 100644 --- a/src/relay/git-working-file-read.test.ts +++ b/src/relay/git-working-file-read.test.ts @@ -21,7 +21,8 @@ describe('readWorkingDiffFile', () => { await expect(readWorkingDiffFile(filePath)).resolves.toEqual({ content: 'hello', - isBinary: false + isBinary: false, + missing: false }) }) @@ -32,7 +33,61 @@ describe('readWorkingDiffFile', () => { await expect(readWorkingDiffFile(filePath)).resolves.toEqual({ content: '', - isBinary: true + isBinary: true, + missing: false + }) + }) + + it('base64-encodes a previewable image by its extension', async () => { + tmpDir = await mkdtemp(path.join(tmpdir(), 'relay-working-file-')) + const filePath = path.join(tmpDir, 'icon.png') + const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x01]) + await writeFile(filePath, pngBytes) + + await expect(readWorkingDiffFile(filePath)).resolves.toEqual({ + content: pngBytes.toString('base64'), + isBinary: true, + missing: false + }) + }) + + it('reports an absent working-tree file as a deletion', async () => { + tmpDir = await mkdtemp(path.join(tmpdir(), 'relay-working-file-')) + const filePath = path.join(tmpDir, 'deleted.png') + + await expect(readWorkingDiffFile(filePath)).resolves.toEqual({ + content: '', + isBinary: false, + missing: true + }) + }) + + it('returns base64 content for previewable image working-tree files', async () => { + tmpDir = await mkdtemp(path.join(tmpdir(), 'relay-working-file-')) + const filePath = path.join(tmpDir, 'image.png') + // Why: PNG signature + trailing null byte forces the binary-detection heuristic to trip. + const pngBuffer = Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + Buffer.from([0x00]) + ]) + await writeFile(filePath, pngBuffer) + + await expect(readWorkingDiffFile(filePath)).resolves.toEqual({ + content: pngBuffer.toString('base64'), + isBinary: true, + missing: false + }) + }) + + it('returns empty content for non-previewable binary working-tree files', async () => { + tmpDir = await mkdtemp(path.join(tmpdir(), 'relay-working-file-')) + const filePath = path.join(tmpDir, 'archive.bin') + await writeFile(filePath, Buffer.from([0x00, 0x01, 0x02, 0x00, 0x03])) + + await expect(readWorkingDiffFile(filePath)).resolves.toEqual({ + content: '', + isBinary: true, + missing: false }) }) }) diff --git a/src/relay/git-working-file-read.ts b/src/relay/git-working-file-read.ts index 2c7db8e9778..05cf1ebcdce 100644 --- a/src/relay/git-working-file-read.ts +++ b/src/relay/git-working-file-read.ts @@ -5,19 +5,31 @@ const MAX_RELAY_DIFF_WORKING_FILE_BYTES = 10 * 1024 * 1024 export async function readWorkingDiffFile( absPath: string -): Promise<{ content: string; isBinary: boolean }> { +): Promise<{ content: string; isBinary: boolean; missing: boolean }> { + let fileStat + try { + fileStat = await stat(absPath) + } catch (error) { + // Why: ENOENT means the working-tree file is genuinely gone (a deletion); + // any other stat error is a read failure we must not mistake for one, since + // callers fall back to the original bytes only for a proven deletion. + const missing = (error as NodeJS.ErrnoException)?.code === 'ENOENT' + return { content: '', isBinary: false, missing } + } + if (!fileStat.isFile()) { + return { content: '', isBinary: false, missing: true } + } + if (fileStat.size > MAX_RELAY_DIFF_WORKING_FILE_BYTES) { + // Why: mirror local git diff reads, which cap blob transfer at 10MB. + return { content: '', isBinary: true, missing: false } + } try { - const fileStat = await stat(absPath) - if (!fileStat.isFile()) { - return { content: '', isBinary: false } - } - if (fileStat.size > MAX_RELAY_DIFF_WORKING_FILE_BYTES) { - // Why: mirror local git diff reads, which cap blob transfer at 10MB. - return { content: '', isBinary: true } - } const buffer = await readFile(absPath) - return bufferToBlob(buffer) + // Why: bufferToBlob needs the path's extension to know an image is + // previewable; omitting it made every relay-side binary diff empty. + return { ...bufferToBlob(buffer, absPath), missing: false } } catch { - return { content: '', isBinary: false } + // Why: the file exists but could not be read — a read failure, not a deletion. + return { content: '', isBinary: false, missing: false } } } diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index efab6bce51f..8de7ec8051b 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -181,6 +181,29 @@ describe('PtyHandler', () => { expect(handler.activePtyCount).toBe(1) }) + it('normalizes a missing native binding as degraded node-pty availability', async () => { + mockPtySpawn.mockImplementationOnce(() => { + throw new Error( + 'Failed to load native module: conpty.node, checked: build/Release, prebuilds/win32-x64' + ) + }) + + await expect(dispatcher.callRequest('pty.spawn', {})).rejects.toThrow( + 'node-pty is not available on this remote host' + ) + expect(handler.activePtyCount).toBe(0) + }) + + it('preserves unrelated node-pty spawn failures', async () => { + mockPtySpawn.mockImplementationOnce(() => { + throw new Error('File not found: missing-shell.exe') + }) + + await expect(dispatcher.callRequest('pty.spawn', {})).rejects.toThrow( + 'File not found: missing-shell.exe' + ) + }) + it('atomically caps concurrent PTY spawn admission', async () => { const results = await Promise.allSettled( Array.from({ length: MAX_RELAY_PTY_SESSIONS + 1 }, () => diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index c1de6809c15..fb0d38bf53c 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -1,6 +1,8 @@ /* oxlint-disable max-lines */ import type { IPty } from 'node-pty' import type * as NodePty from 'node-pty' +import { existsSync } from 'node:fs' +import { join } from 'node:path' import { resolveWindowsGitBashShellPath } from '../main/git-bash' import { WINDOWS_GIT_BASH_SHELL } from '../shared/windows-terminal-shell' import type { RelayDispatcher, RequestContext } from './dispatcher' @@ -38,21 +40,11 @@ import { import { isTuiAgent } from '../shared/tui-agent-config' import { forceKillPosixPtyProcessGroups } from '../main/pty/posix-pty-process-groups' -// Why: node-pty is a native addon that may not be installed on the remote. -// Dynamic import keeps the require() lazy so loadPty() returns null gracefully -// when the native module is unavailable. The static type import lets vitest -// intercept it in tests. -let ptyModule: typeof NodePty | null = null -async function loadPty(): Promise { - if (ptyModule) { - return ptyModule - } - try { - ptyModule = await import('node-pty') - return ptyModule - } catch { - return null - } +function isMissingNodePtyNativeBinding(error: unknown): boolean { + return ( + error instanceof Error && + /Failed to load native module: (?:conpty|pty)\.node(?:,|$)/.test(error.message) + ) } type ManagedPty = { @@ -296,6 +288,9 @@ export class PtyHandler { private pendingCreationDrainResolvers = new Set<() => void>() private worktreeRemovalCoordinator: RelayPtyWorktreeRemovalCoordinator | null = null private disposePromise: Promise | null = null + private ptyModule: typeof NodePty | null = null + private ptyModuleLoadPromise: Promise | null = null + private reloadPtyModuleFromDisk = false // Why: external observers need to drop per-pane state when a PTY exits. // Today the relay composes multiple consumers (hook-server cache eviction // and plugin-overlay dir cleanup) into a single callback at the call site @@ -316,6 +311,55 @@ export class PtyHandler { this.registerHandlers() } + private async loadPty(): Promise { + if (this.ptyModule) { + return this.ptyModule + } + if (this.ptyModuleLoadPromise) { + return this.ptyModuleLoadPromise + } + this.ptyModuleLoadPromise = this.loadPtyUncached() + try { + return await this.ptyModuleLoadPromise + } finally { + this.ptyModuleLoadPromise = null + } + } + + private async loadPtyUncached(): Promise { + if (!this.reloadPtyModuleFromDisk) { + try { + this.ptyModule = await import('node-pty') + return this.ptyModule + } catch { + this.reloadPtyModuleFromDisk = true + } + } + // Why: the relay is launched from its install dir today, but module + // resolution must remain tied to the deployed bundle rather than cwd. + const moduleEntry = join(__dirname, 'node_modules', 'node-pty', 'lib', 'index.js') + if (!existsSync(moduleEntry)) { + return null + } + try { + this.ptyModule = require(moduleEntry) as typeof NodePty + return this.ptyModule + } catch { + return null + } + } + + private invalidatePtyModuleAfterBindingFailure(): void { + this.ptyModule = null + this.reloadPtyModuleFromDisk = true + const moduleRoot = join(__dirname, 'node_modules', 'node-pty') + for (const cachedPath of Object.keys(require.cache)) { + if (isPathInsideOrEqual(moduleRoot, cachedPath)) { + delete require.cache[cachedPath] + } + } + } + setGraceTimeMs(graceTimeMs: number): void { this.graceTimeMs = Math.max(0, Math.floor(graceTimeMs)) } @@ -741,7 +785,7 @@ export class PtyHandler { params: Record, context?: RequestContext ): Promise<{ id: string }> { - const pty = await loadPty() + const pty = await this.loadPty() if (!pty) { throw new Error('node-pty is not available on this remote host') } @@ -814,17 +858,28 @@ export class PtyHandler { // includes Homebrew, nvm, and user-installed CLIs (claude, codex, gh). // When overlays are injected, the launch wrapper keeps those paths after // user startup files re-export their defaults. - const term = pty.spawn(shell, shellLaunch.args, { - // Why: node-pty overwrites env.TERM with `name`; keep caller-selected - // terminal identities instead of losing them at the final spawn boundary. - name: spawnEnv.TERM ?? 'xterm-256color', - cols, - rows, - cwd, - // Why: relay shells inherit process.env; never let an ambient Orca marker - // enable shell-ready behavior unless this spawn explicitly requested it. - env: { ...spawnEnv, ORCA_SHELL_READY_MARKER: '0', ...shellLaunch.env } - }) + let term: IPty + try { + term = pty.spawn(shell, shellLaunch.args, { + // Why: node-pty overwrites env.TERM with `name`; keep caller-selected + // terminal identities instead of losing them at the final spawn boundary. + name: spawnEnv.TERM ?? 'xterm-256color', + cols, + rows, + cwd, + // Why: relay shells inherit process.env; never let an ambient Orca marker + // enable shell-ready behavior unless this spawn explicitly requested it. + env: { ...spawnEnv, ORCA_SHELL_READY_MARKER: '0', ...shellLaunch.env } + }) + } catch (error) { + // Why: Windows node-pty loads conpty.node only on first spawn, after the + // wrapper import succeeded. Keep that late failure on the degraded path. + if (isMissingNodePtyNativeBinding(error)) { + this.invalidatePtyModuleAfterBindingFailure() + throw new Error('node-pty is not available on this remote host') + } + throw error + } // Why: capture the renderer-supplied paneKey on the managed entry so the // exit listener can evict per-pane caches without the relay needing a @@ -1215,7 +1270,7 @@ export class PtyHandler { } private async reviveEntry(entry: SerializedPtyEntry): Promise { - const ptyMod = await loadPty() + const ptyMod = await this.loadPty() if (!ptyMod) { return } diff --git a/src/relay/subprocess-test-utils.ts b/src/relay/subprocess-test-utils.ts index 69a6e607a06..b721f17968f 100644 --- a/src/relay/subprocess-test-utils.ts +++ b/src/relay/subprocess-test-utils.ts @@ -25,7 +25,7 @@ export type RelayProcess = { export function spawnRelay( entryPath: string, args: string[] = [], - options: Pick = {} + options: Pick = {} ): RelayProcess { const proc = spawn('node', [entryPath, ...args], { stdio: ['pipe', 'pipe', 'pipe'], diff --git a/src/relay/subprocess.test.ts b/src/relay/subprocess.test.ts index ee20461dd68..c772a8e514a 100644 --- a/src/relay/subprocess.test.ts +++ b/src/relay/subprocess.test.ts @@ -1,5 +1,13 @@ import { afterAll, beforeAll, describe, expect, it, afterEach } from 'vitest' -import { existsSync, mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + unlinkSync, + writeFileSync +} from 'node:fs' import { rm } from 'node:fs/promises' import * as path from 'node:path' import { tmpdir } from 'node:os' @@ -46,9 +54,14 @@ afterAll(async () => { } }) -function spawn(args: string[] = [], env?: NodeJS.ProcessEnv): RelayProcess { +function spawnRelayEntry( + entryPath: string, + args: string[] = [], + env?: NodeJS.ProcessEnv +): RelayProcess { let relayArgs = args if (!args.includes('--sock-path')) { + // Why: Windows relays require a named pipe; filesystem socket paths fail with EACCES. const socketDir = mkdtempSync(path.join(tmpdir(), 'relay-sock-')) spawnedSocketDirs.push(socketDir) relayArgs = [ @@ -59,7 +72,11 @@ function spawn(args: string[] = [], env?: NodeJS.ProcessEnv): RelayProcess { path.join(socketDir, 'agent-hooks') ] } - return spawnRelay(relayEntry, relayArgs, env ? { env } : undefined) + return spawnRelay(entryPath, relayArgs, env ? { env } : undefined) +} + +function spawn(args: string[] = [], env?: NodeJS.ProcessEnv): RelayProcess { + return spawnRelayEntry(relayEntry, args, env) } function waitForChildExit( @@ -75,6 +92,22 @@ function waitForChildExit( }) } +function writeMockNodePty(root: string, source: string, withPackageEntry = false): void { + const nodePtyDir = path.join(root, 'node_modules', 'node-pty') + const libDir = path.join(nodePtyDir, 'lib') + mkdirSync(libDir, { recursive: true }) + if (withPackageEntry) { + writeFileSync(path.join(nodePtyDir, 'package.json'), '{"main":"lib/index.js"}\n') + } + writeFileSync(path.join(libDir, 'index.js'), source) +} + +const WORKING_NODE_PTY_MODULE = `module.exports = { spawn() { return { + pid: process.pid, + process: 'mock-shell', + onData() {}, onExit() {}, write() {}, resize() {}, kill() {}, clear() {} +} } }\n` + describe('Subprocess: Relay entry point', () => { let relay: RelayProcess | null = null let tmpDir: string @@ -103,6 +136,50 @@ describe('Subprocess: Relay entry point', () => { expect(readFileSync(relayEntry, 'utf8')).not.toContain('.toReversed(') }) + it('loads node-pty after an in-place dependency repair without restarting', async () => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-native-repair-')) + const repairedRelayEntry = path.join(tmpDir, 'relay.js') + copyFileSync(relayEntry, repairedRelayEntry) + + relay = spawnRelayEntry(repairedRelayEntry) + await relay.sentinelReceived + + const failedId = relay.send('pty.spawn', { cols: 80, rows: 24 }) + const failed = await relay.waitForResponse(failedId) + expect(failed.error?.message).toContain('node-pty is not available') + + writeMockNodePty(tmpDir, WORKING_NODE_PTY_MODULE) + + const repairedId = relay.send('pty.spawn', { cols: 80, rows: 24 }) + const repaired = await relay.waitForResponse(repairedId) + expect(repaired.error).toBeUndefined() + expect(repaired.result).toMatchObject({ id: 'pty-1' }) + }, 10_000) + + it('reloads node-pty after a late native binding failure without restarting', async () => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-native-late-repair-')) + const repairedRelayEntry = path.join(tmpDir, 'relay.js') + copyFileSync(relayEntry, repairedRelayEntry) + writeMockNodePty( + tmpDir, + `module.exports = { spawn() { throw new Error('Failed to load native module: conpty.node, checked: prebuilds/win32-x64') } }\n`, + true + ) + + relay = spawnRelayEntry(repairedRelayEntry) + await relay.sentinelReceived + + const failedId = relay.send('pty.spawn', { cols: 80, rows: 24 }) + const failed = await relay.waitForResponse(failedId) + expect(failed.error?.message).toContain('node-pty is not available') + + writeMockNodePty(tmpDir, WORKING_NODE_PTY_MODULE, true) + const repairedId = relay.send('pty.spawn', { cols: 80, rows: 24 }) + const repaired = await relay.waitForResponse(repairedId) + expect(repaired.error).toBeUndefined() + expect(repaired.result).toMatchObject({ id: 'pty-2' }) + }, 10_000) + it('responds to fs.stat over stdin/stdout', async () => { tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-sub-')) writeFileSync(path.join(tmpDir, 'test.txt'), 'hello') diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index d076f0b325d..6f6b7fb2424 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -181,7 +181,7 @@ import { filterGitHubPRReviewerCandidates, getGitHubPRReviewerQueryState } from '@/components/github/github-pr-reviewer-candidate-filter' -import { githubAvatarUrl } from '@/components/github/github-issue-comment-helpers' +import { GitHubUserAvatar } from '@/components/github/github-user-avatar' import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state' import { GITHUB_PR_MERGE_METHOD_LABELS, @@ -421,16 +421,7 @@ function ReviewerAvatar({ login: string avatarUrl: string }): React.JSX.Element { - return ( - - ) + return } function mergeReviewerSuggestions( diff --git a/src/renderer/src/components/PullRequestPage.tsx b/src/renderer/src/components/PullRequestPage.tsx index d807dacf89f..b53ba354618 100644 --- a/src/renderer/src/components/PullRequestPage.tsx +++ b/src/renderer/src/components/PullRequestPage.tsx @@ -157,7 +157,7 @@ import { filterGitHubPRReviewerCandidates, getGitHubPRReviewerQueryState } from '@/components/github/github-pr-reviewer-candidate-filter' -import { githubAvatarUrl } from '@/components/github/github-issue-comment-helpers' +import { GitHubUserAvatar } from '@/components/github/github-user-avatar' import { filterGitHubMentionOptions } from '@/components/github/github-mention-option-filter' import { getCommentBodySubmitState, @@ -468,16 +468,7 @@ function ReviewerAvatar({ login: string avatarUrl: string }): React.JSX.Element { - return ( - - ) + return } function mergeReviewerSuggestions( @@ -7148,12 +7139,10 @@ export default function PullRequestPage({ {workItem.author ? ( - ) : null} diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index af47d26736d..e43cc6a8089 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -133,6 +133,7 @@ import { } from '../../../shared/linear-links' import PRFilterDropdowns, { type PRFilterChange } from '@/components/github/PRFilterDropdowns' import { GitHubMarkdownComposer } from '@/components/github/GitHubMarkdownComposer' +import { GitHubUserAvatar } from '@/components/github/github-user-avatar' import { buildGitHubRepoUrl, parseGitHubIssueOrPRLink } from '@/lib/github-links' import { findGithubWorkItemWorkspaceAttachment, @@ -1520,17 +1521,16 @@ function ReviewChipAvatar({ reviewer: GitHubPRPrimaryReviewer | null }): React.JSX.Element { if (reviewer?.login) { - // Why: `gh pr list --json reviewRequests` can return only logins; GitHub's - // public avatar endpoint keeps the list visual aligned with assignee cells. - const avatarUrl = reviewer.avatarUrl || `https://github.com/${reviewer.login}.png?size=40` + // Why: `gh pr list --json reviewRequests` can return only logins. Prefer the + // API avatar_url so GHE renders; GitHubUserAvatar falls back to the login URL + // and then an initials placeholder when no avatar loads. See #8784. return ( - ) } diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index 20cde69e56f..50b4dc45852 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -63,6 +63,7 @@ import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner import { ORCA_BROWSER_BLANK_URL, ORCA_BROWSER_PARTITION } from '../../../../shared/constants' import { getOrcaProfileBrowserDefaultPartition } from '../../../../shared/orca-profiles' import type { + BrowserCertificateProceedResult, BrowserLoadError, BrowserPage as BrowserPageState, BrowserWorkspace as BrowserWorkspaceState @@ -70,7 +71,9 @@ import type { import { normalizeBrowserNavigationUrl, normalizeExternalBrowserUrl, - redactKagiSessionToken + redactKagiSessionToken, + resolveRemoteFailureExternalUrl, + toHttpsRecoveryUrl } from '../../../../shared/browser-url' import { keybindingMatchesAction } from '../../../../shared/keybindings' import { getScreenSubmitModifierLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' @@ -165,13 +168,7 @@ import { type BrowserScreencastFrameMetadata } from '../../../../shared/browser-screencast-protocol' import { withBrowserPaneUiRuntimeRpcSource } from '../../../../shared/runtime-rpc-feature-interaction-source' -import { - formatByteCount, - formatLoadFailureDescription, - formatLoadFailureRecoveryHint, - formatPermissionNotice, - formatPopupNotice -} from './browser-notices' +import { formatByteCount, formatPermissionNotice, formatPopupNotice } from './browser-notices' import { getDriverForBrowserPage, onBrowserDriverChange, @@ -186,6 +183,7 @@ import { useMarkupMode, type MarkupCaptureContext } from './markup/useMarkupMode import { MarkupOverlay } from './markup/MarkupOverlay' import { MarkupDrawButton } from './markup/MarkupDrawButton' import { deliverMarkupToClipboard } from './markup/markup-clipboard-delivery' +import { BrowserLoadFailureOverlay } from './browser-load-failure-overlay' type BrowserTabPageState = Partial< Pick< @@ -700,28 +698,6 @@ function getRemoteBrowserDeviceScaleFactor(): number { return Math.min(2, Math.max(1, Number(scale.toFixed(2)))) } -function getLoadErrorMetadata(loadError: BrowserLoadError | null): { - displayUrl: string - host: string | null - isLocalhostLike: boolean -} { - const rawUrl = loadError?.validatedUrl ?? 'about:blank' - const displayUrl = toDisplayUrl(rawUrl) - try { - const parsed = new URL(rawUrl) - const host = parsed.host || null - const hostname = parsed.hostname - const isLocalhostLike = - hostname === 'localhost' || - hostname === '127.0.0.1' || - hostname === '0.0.0.0' || - hostname === '::1' - return { displayUrl, host, isLocalhostLike } - } catch { - return { displayUrl, host: null, isLocalhostLike: false } - } -} - function getOpenableExternalUrl( webview: Electron.WebviewTag | null, fallbackUrl: string @@ -960,6 +936,12 @@ function RemoteBrowserPagePane({ (token: RemoteBrowserOperationToken) => Promise >(async () => null) const setRemoteBrowserPageHandle = useAppStore((s) => s.setRemoteBrowserPageHandle) + const certificateFailure = useAppStore( + (s) => s.browserCertificateFailuresByPageId[browserTab.id] ?? null + ) + const remotePageHandle = useAppStore( + (s) => s.remoteBrowserPageHandlesByPageId[browserTab.id] ?? null + ) const createBrowserTab = useAppStore((s) => s.createBrowserTab) const closeBrowserPage = useAppStore((s) => s.closeBrowserPage) const closeBrowserTab = useAppStore((s) => s.closeBrowserTab) @@ -1985,7 +1967,13 @@ function RemoteBrowserPagePane({ setRemoteError(message) onUpdatePageState(browserTab.id, { loading: false, - loadError: { code: 0, description: message, validatedUrl: url ?? browserTab.url } + // Why: validatedUrl crosses process/persistence boundaries, so redact a + // Kagi session token the same way the main-process failure path does. + loadError: { + code: 0, + description: message, + validatedUrl: redactKagiSessionToken(url ?? browserTab.url) + } }) } finally { if (isCurrentRemoteOperationToken(pageToken)) { @@ -2398,6 +2386,12 @@ function RemoteBrowserPagePane({ }, [frameUrl, handleRemoteScreenshotWheel]) const remoteFrameStyle = useMemo(() => getRemoteBrowserFrameStyle(frameMetadata), [frameMetadata]) + const remoteFailureUrl = browserTab.loadError?.validatedUrl ?? browserTab.url + const remoteFailureExternalUrl = resolveRemoteFailureExternalUrl(remoteFailureUrl) + const showRemoteFailureOverlay = + Boolean(browserTab.loadError) && + remoteFailureUrl !== 'about:blank' && + remoteFailureUrl !== ORCA_BROWSER_BLANK_URL // Why: markup works on remote panes by snapshotting the already-displayed // screencast — no in-page injection needed, so it is enabled here even @@ -2692,6 +2686,44 @@ function RemoteBrowserPagePane({ )} + {showRemoteFailureOverlay && browserTab.loadError ? ( + void runRemoteNavigation('browser.reload')} + onTryHttps={(url) => void runRemoteNavigation('browser.goto', url)} + onCopy={(url) => void window.api.ui.writeClipboardText(url)} + onOpenExternal={(url) => void window.api.shell.openUrl(url)} + certificateFailure={certificateFailure} + expectedBrowserPageId={ + remotePageHandle?.environmentId === activeRuntimeEnvironmentId + ? remotePageHandle.remotePageId + : null + } + onProceedCertificate={async (challengeId) => { + const target = runtimeTarget() + if ( + !target || + remotePageHandle?.environmentId !== target.environmentId || + remotePageHandle.remotePageId !== certificateFailure?.browserPageId + ) { + return { ok: false, reason: 'missing' } + } + return callRuntimeRpc( + target, + 'browser.certificate.proceed', + { + worktree: runtimeWorktree, + page: remotePageHandle.remotePageId, + challengeId + }, + { timeoutMs: 15_000, suppressFeatureInteraction: true } + ) + }} + /> + ) : null} {remoteError ? (

{remoteError} @@ -2879,6 +2911,9 @@ function BrowserPagePane({ const browserAnnotations = useAppStore( (s) => s.browserAnnotationsByPageId[browserTab.id] ?? EMPTY_BROWSER_ANNOTATIONS ) + const certificateFailure = useAppStore( + (s) => s.browserCertificateFailuresByPageId[browserTab.id] ?? null + ) const activeGroupId = useAppStore((s) => s.activeGroupIdByWorktree[worktreeId]) const browserAnnotationsRef = useRef(browserAnnotations) browserAnnotationsRef.current = browserAnnotations @@ -3731,21 +3766,53 @@ function BrowserPagePane({ dismissAddressBarSuggestionsRef.current?.() } - const handleDomReady = (): void => { + let registrationInFlight: { webContentsId: number; promise: Promise } | null = null + const registerGuest = (): Promise => { const webContentsId = webview.getWebContentsId() - let queuedAnnotationViewportBridgeSync = false - if (registeredWebContentsIds.get(browserTab.id) !== webContentsId) { - registeredWebContentsIds.set(browserTab.id, webContentsId) - queuedAnnotationViewportBridgeSync = true - void window.api.browser - .registerGuest({ - browserPageId: browserTab.id, - workspaceId, - worktreeId, - sessionProfileId, - webContentsId - }) - .finally(() => syncBrowserAnnotationViewportBridge()) + if (registeredWebContentsIds.get(browserTab.id) === webContentsId) { + return Promise.resolve(true) + } + if (registrationInFlight?.webContentsId === webContentsId) { + return registrationInFlight.promise + } + const promise = window.api.browser + .registerGuest({ + browserPageId: browserTab.id, + workspaceId, + worktreeId, + sessionProfileId, + webContentsId + }) + .then((registered) => { + if (registered) { + registeredWebContentsIds.set(browserTab.id, webContentsId) + } + return registered + }) + // Why: normalize IPC rejection to false so the dom-ready fallback can + // retry attach-policy races without an unhandled promise rejection. + .catch(() => false) + .finally(() => { + if (registrationInFlight?.promise === promise) { + registrationInFlight = null + } + }) + registrationInFlight = { webContentsId, promise } + return promise + } + + const handleDidAttach = (): void => { + // Why: certificate failures can happen before dom-ready. Register at + // attach so main can map the initial failure to this page; dom-ready below + // remains an idempotent fallback for attach-policy races. + void registerGuest().finally(() => syncBrowserAnnotationViewportBridge()) + } + + const handleDomReady = (): void => { + const queuedAnnotationViewportBridgeSync = + registeredWebContentsIds.get(browserTab.id) !== webview.getWebContentsId() + if (queuedAnnotationViewportBridgeSync) { + void registerGuest().finally(() => syncBrowserAnnotationViewportBridge()) } syncNavigationState(webview) if (keepAddressBarFocusRef.current) { @@ -3964,6 +4031,7 @@ function BrowserPagePane({ } } + webview.addEventListener('did-attach', handleDidAttach) webview.addEventListener('dom-ready', handleDomReady) webview.addEventListener('focus', dismissAddressBarSuggestions) webview.addEventListener('did-start-loading', handleDidStartLoading) @@ -3997,6 +4065,7 @@ function BrowserPagePane({ } return () => { + webview.removeEventListener('did-attach', handleDidAttach) webview.removeEventListener('dom-ready', handleDomReady) webview.removeEventListener('focus', dismissAddressBarSuggestions) webview.removeEventListener('did-start-loading', handleDidStartLoading) @@ -4672,8 +4741,8 @@ function BrowserPagePane({ const isBlankTab = browserTab.url === 'about:blank' || browserTab.url === ORCA_BROWSER_BLANK_URL const externalUrl = getOpenableExternalUrl(webviewRef.current, browserTab.url) const currentBrowserUrl = getCurrentBrowserUrl(webviewRef.current, browserTab.url) - const loadErrorMeta = getLoadErrorMetadata(browserTab.loadError) - const loadErrorHint = formatLoadFailureRecoveryHint(loadErrorMeta) + const failedNavigationUrl = browserTab.loadError?.validatedUrl ?? currentBrowserUrl + const failureExternalUrl = normalizeExternalBrowserUrl(failedNavigationUrl) const showFailureOverlay = Boolean(browserTab.loadError) && !isBlankTab const visibleDownloads = (() => { const active = downloadStates.filter((download) => download.status === 'downloading') @@ -5476,114 +5545,40 @@ function BrowserPagePane({ onClose={() => setFindOpen(false)} webviewRef={webviewRef} /> - {showFailureOverlay ? ( -
-
-
- -
-

- {loadErrorMeta.host - ? translate( - 'auto.components.browser.pane.BrowserPane.db325a7eeb', - "Can't reach {{value0}}", - { value0: loadErrorMeta.host } - ) - : translate( - 'auto.components.browser.pane.BrowserPane.b2856516e2', - "Can't load this page" - )} -

-

- {formatLoadFailureDescription(browserTab.loadError, loadErrorMeta)} -

- {loadErrorHint ? ( -

{loadErrorHint}

- ) : null} -
- - - {externalUrl ? ( - - ) : null} -
-
-
+ {showFailureOverlay && browserTab.loadError ? ( + { + const webview = webviewRef.current + if (!webview) { + return + } + onUpdatePageStateRef.current(browserTab.id, { loading: true }) + retryBrowserTabLoad(webview, browserTab, onUpdatePageStateRef.current) + }} + onTryHttps={navigateToUrl} + onCopy={(url) => { + void window.api.ui.writeClipboardText(url) + setResourceNotice( + translate( + 'browser.loadFailure.addressCopied', + 'Copied the current page address.' + ) + ) + }} + onOpenExternal={(url) => void window.api.shell.openUrl(url)} + certificateFailure={certificateFailure} + expectedBrowserPageId={browserTab.id} + onProceedCertificate={(challengeId) => + window.api.browser.proceedCertificate({ + browserPageId: browserTab.id, + challengeId + }) + } + /> ) : null} {isBlankTab ? (
diff --git a/src/renderer/src/components/browser-pane/browser-load-failure-overlay.test.tsx b/src/renderer/src/components/browser-pane/browser-load-failure-overlay.test.tsx new file mode 100644 index 00000000000..d49e8ccdc9f --- /dev/null +++ b/src/renderer/src/components/browser-pane/browser-load-failure-overlay.test.tsx @@ -0,0 +1,319 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { BrowserLoadFailureOverlay } from './browser-load-failure-overlay' + +const callbacks = { + onRetry: vi.fn(), + onTryHttps: vi.fn(), + onCopy: vi.fn(), + onOpenExternal: vi.fn(), + onProceedCertificate: vi.fn() +} + +const certificateFailure = { + challengeId: 'challenge-1', + browserPageId: 'page-1', + errorCode: -202, + error: 'ERR_CERT_AUTHORITY_INVALID', + origin: 'https://localhost:3443', + displayHost: 'localhost:3443', + canProceed: true, + observedAt: 123 +} + +afterEach(() => { + cleanup() + vi.useRealTimers() + vi.clearAllMocks() +}) + +describe('BrowserLoadFailureOverlay', () => { + it('uses certificate-specific copy while keeping strict verification', () => { + render( + + ) + + expect(screen.getByText("Connection isn't secure")).toBeInTheDocument() + expect( + screen.getByText( + "Orca doesn't trust the authority that issued the certificate for localhost:3443." + ) + ).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Proceed Anyway (Unsafe)' })).toBeNull() + expect(screen.queryByText(/make sure the server is running/i)).not.toBeInTheDocument() + }) + + it('keeps restored certificate errors accurate without allowing approval', () => { + render( + + ) + + expect(screen.getByText("The certificate doesn't match localhost:3443.")).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Proceed Anyway (Unsafe)' })).toBeNull() + expect(screen.getByRole('button', { name: 'Retry' })).toBeEnabled() + expect(screen.getByRole('button', { name: 'Copy Address' })).toBeEnabled() + }) + + it('offers HTTPS recovery only for an eligible failed HTTP address', () => { + const { rerender } = render( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Try HTTPS' })) + expect(callbacks.onTryHttps).toHaveBeenCalledWith('https://localhost:3000/app') + + rerender( + + ) + expect(screen.queryByRole('button', { name: 'Try HTTPS' })).toBeNull() + }) + + it('hides Open Externally and works without an external handler', () => { + render( + + ) + + // externalUrl / onOpenExternal omitted (e.g. a remote loopback failure): + // the action is absent and the overlay still renders its other recovery UI. + expect(screen.queryByRole('button', { name: 'Open Externally' })).toBeNull() + expect(screen.getByRole('button', { name: 'Retry' })).toBeEnabled() + expect(screen.getByRole('button', { name: 'Copy Address' })).toBeEnabled() + }) + + it('does not show local-certificate guidance for a public-host failure', () => { + render( + + ) + + expect(screen.queryByText(/use a trusted local certificate/i)).not.toBeInTheDocument() + }) + + it('offers approval only for a matching live challenge', () => { + const { rerender } = render( + + ) + + expect(screen.getByRole('button', { name: 'Proceed Anyway (Unsafe)' })).toBeEnabled() + + rerender( + + ) + expect(screen.getByText("Connection isn't secure")).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Proceed Anyway (Unsafe)' })).toBeEnabled() + + rerender( + + ) + expect(screen.queryByRole('button', { name: 'Proceed Anyway (Unsafe)' })).toBeNull() + + rerender( + + ) + expect(screen.queryByRole('button', { name: 'Proceed Anyway (Unsafe)' })).toBeNull() + }) + + it('disables every action immediately and delays connecting feedback', async () => { + vi.useFakeTimers() + let resolveProceed: ((result: { ok: true }) => void) | null = null + callbacks.onProceedCertificate.mockReturnValue( + new Promise((resolve) => { + resolveProceed = resolve + }) + ) + const { rerender } = render( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Proceed Anyway (Unsafe)' })) + expect(callbacks.onProceedCertificate).toHaveBeenCalledWith('challenge-1') + for (const name of ['Proceed Anyway (Unsafe)', 'Open Externally', 'Retry', 'Copy Address']) { + expect(screen.getByRole('button', { name })).toBeDisabled() + } + expect(screen.queryByText('Connecting…')).toBeNull() + + act(() => vi.advanceTimersByTime(199)) + expect(screen.queryByText('Connecting…')).toBeNull() + act(() => vi.advanceTimersByTime(1)) + expect(screen.getByRole('button', { name: 'Connecting…' })).toBeDisabled() + + await act(async () => resolveProceed?.({ ok: true })) + rerender( + + ) + expect(screen.queryByRole('button', { name: 'Proceed Anyway (Unsafe)' })).toBeNull() + expect(screen.getByRole('button', { name: 'Retry' })).toBeEnabled() + }) + + it('recovers from typed approval failures and resets for a new challenge', async () => { + callbacks.onProceedCertificate.mockResolvedValue({ ok: false, reason: 'expired' }) + const props = { + loadError: { + code: -202, + description: 'ERR_CERT_AUTHORITY_INVALID', + validatedUrl: 'https://localhost:3443/app' + }, + externalUrl: null, + currentUrl: 'https://localhost:3443/app', + httpsRecoveryUrl: null, + expectedBrowserPageId: 'page-1', + ...callbacks + } + const { rerender } = render( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Proceed Anyway (Unsafe)' })) + await waitFor(() => + expect(screen.getByRole('alert')).toHaveTextContent(/certificate approval expired/i) + ) + expect(screen.getByRole('button', { name: 'Retry' })).toBeEnabled() + + callbacks.onProceedCertificate.mockReturnValue(new Promise(() => {})) + fireEvent.click(screen.getByRole('button', { name: 'Proceed Anyway (Unsafe)' })) + expect(screen.getByRole('button', { name: 'Retry' })).toBeDisabled() + rerender( + + ) + expect(screen.getByRole('button', { name: 'Proceed Anyway (Unsafe)' })).toBeEnabled() + expect(screen.queryByRole('alert')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/browser-pane/browser-load-failure-overlay.tsx b/src/renderer/src/components/browser-pane/browser-load-failure-overlay.tsx new file mode 100644 index 00000000000..06fa7249108 --- /dev/null +++ b/src/renderer/src/components/browser-pane/browser-load-failure-overlay.tsx @@ -0,0 +1,340 @@ +import { useEffect, useRef, useState } from 'react' +import { Copy, ExternalLink, Globe, Loader2, RefreshCw, ShieldAlert } from 'lucide-react' + +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import type { + BrowserCertificateFailure, + BrowserCertificateProceedFailureReason, + BrowserCertificateProceedResult, + BrowserLoadError +} from '../../../../shared/types' +import { isEligibleLocalCertificateHost } from '../../../../shared/browser-url' +import { + formatLoadFailureDescription, + formatLoadFailureRecoveryHint, + isCertificateLoadError, + type LoadFailureMeta +} from './browser-notices' + +type BrowserLoadFailureOverlayProps = { + loadError: BrowserLoadError + externalUrl?: string | null + currentUrl: string + httpsRecoveryUrl: string | null + onRetry: () => void + onTryHttps: (url: string) => void + onCopy: (url: string) => void + onOpenExternal?: (url: string) => void + certificateFailure?: BrowserCertificateFailure | null + expectedBrowserPageId?: string | null + onProceedCertificate?: (challengeId: string) => Promise +} + +type CertificateProceedAttempt = { + challengeId: string + state: 'submitting' | 'failed' + showConnecting: boolean + reason?: BrowserCertificateProceedFailureReason | 'request-failed' +} + +function getLoadErrorMetadata(loadError: BrowserLoadError): LoadFailureMeta { + try { + const parsed = new URL(loadError.validatedUrl) + return { + host: parsed.host || null, + isLocalhostLike: + parsed.hostname === '0.0.0.0' || isEligibleLocalCertificateHost(parsed.hostname) + } + } catch { + return { host: null, isLocalhostLike: false } + } +} + +function normalizeCertificateError(error: string): string { + return error + .trim() + .replace(/^net::/i, '') + .toUpperCase() +} + +function getMatchingCertificateFailure(args: { + loadError: BrowserLoadError + certificateFailure?: BrowserCertificateFailure | null + expectedBrowserPageId?: string | null + canProceed: boolean +}): BrowserCertificateFailure | null { + const { loadError, certificateFailure, expectedBrowserPageId, canProceed } = args + const errorMatchesChallenge = Boolean( + certificateFailure && + (loadError.code === -1 || + (loadError.code === certificateFailure.errorCode && + normalizeCertificateError(certificateFailure.error) === + normalizeCertificateError(loadError.description))) + ) + if ( + !canProceed || + !certificateFailure?.canProceed || + !expectedBrowserPageId || + certificateFailure.browserPageId !== expectedBrowserPageId || + certificateFailure.errorCode !== -202 || + !errorMatchesChallenge + ) { + return null + } + try { + return new URL(loadError.validatedUrl).origin === certificateFailure.origin + ? certificateFailure + : null + } catch { + return null + } +} + +function formatCertificateProceedFailure( + reason: BrowserCertificateProceedFailureReason | 'request-failed' +): string { + if (reason === 'expired') { + return translate( + 'browser.loadFailure.certificateChallengeExpired', + 'This certificate approval expired. Retry the page to request a new one.' + ) + } + if (reason === 'changed' || reason === 'navigated') { + return translate( + 'browser.loadFailure.certificateChallengeChanged', + 'The certificate request changed. Retry the page and review the new warning.' + ) + } + if (reason === 'request-failed') { + return translate( + 'browser.loadFailure.certificateProceedFailed', + 'Orca could not approve this certificate request. Retry the page and try again.' + ) + } + return translate( + 'browser.loadFailure.certificateChallengeUnavailable', + 'This certificate request is no longer available. Retry the page to request a new one.' + ) +} + +export function BrowserLoadFailureOverlay({ + loadError, + externalUrl, + currentUrl, + httpsRecoveryUrl, + onRetry, + onTryHttps, + onCopy, + onOpenExternal, + certificateFailure, + expectedBrowserPageId, + onProceedCertificate +}: BrowserLoadFailureOverlayProps): React.JSX.Element { + const connectingTimerRef = useRef<{ + challengeId: string + timer: ReturnType + } | null>(null) + const [proceedAttempt, setProceedAttempt] = useState(null) + const matchingCertificateFailure = getMatchingCertificateFailure({ + loadError, + certificateFailure, + expectedBrowserPageId, + canProceed: Boolean(onProceedCertificate) + }) + // Why: Chromium's error-page fallback can replace the diagnostic code with + // -1 after main has already captured an exact live certificate challenge. + const presentationLoadError = + matchingCertificateFailure && loadError.code === -1 + ? { + ...loadError, + code: matchingCertificateFailure.errorCode ?? loadError.code, + description: matchingCertificateFailure.error + } + : loadError + const meta = getLoadErrorMetadata(presentationLoadError) + const certificateError = isCertificateLoadError(presentationLoadError) + const recoveryHint = formatLoadFailureRecoveryHint(meta, presentationLoadError) + const activeProceedAttempt = + proceedAttempt?.challengeId === matchingCertificateFailure?.challengeId ? proceedAttempt : null + const actionsDisabled = activeProceedAttempt?.state === 'submitting' + + useEffect(() => { + return () => { + if (connectingTimerRef.current) { + clearTimeout(connectingTimerRef.current.timer) + connectingTimerRef.current = null + } + } + }, [matchingCertificateFailure?.challengeId]) + + const proceedCertificate = (): void => { + if (!matchingCertificateFailure || !onProceedCertificate || actionsDisabled) { + return + } + const challengeId = matchingCertificateFailure.challengeId + setProceedAttempt({ challengeId, state: 'submitting', showConnecting: false }) + connectingTimerRef.current = { + challengeId, + timer: setTimeout(() => { + setProceedAttempt((current) => + current?.challengeId === challengeId && current.state === 'submitting' + ? { ...current, showConnecting: true } + : current + ) + }, 200) + } + void onProceedCertificate(challengeId) + .then((result) => { + if (connectingTimerRef.current?.challengeId === challengeId) { + clearTimeout(connectingTimerRef.current.timer) + connectingTimerRef.current = null + } + if (!result.ok) { + setProceedAttempt((current) => + current?.challengeId === challengeId + ? { + challengeId, + state: 'failed', + showConnecting: false, + reason: result.reason + } + : current + ) + } + }) + .catch(() => { + if (connectingTimerRef.current?.challengeId === challengeId) { + clearTimeout(connectingTimerRef.current.timer) + connectingTimerRef.current = null + } + setProceedAttempt((current) => + current?.challengeId === challengeId + ? { + challengeId, + state: 'failed', + showConnecting: false, + reason: 'request-failed' + } + : current + ) + }) + } + const retryButton = ( + + ) + const copyButton = ( + + ) + const externalButton = + externalUrl && onOpenExternal ? ( + + ) : null + + return ( +
+
+
+ {certificateError ? : } +
+

+ {certificateError + ? translate('browser.loadFailure.connectionNotSecure', "Connection isn't secure") + : meta.host + ? translate('browser.loadFailure.cantReachHost', "Can't reach {{value0}}", { + value0: meta.host + }) + : translate('browser.loadFailure.cantLoadPage', "Can't load this page")} +

+

+ {formatLoadFailureDescription(presentationLoadError, meta)} +

+ {certificateError && meta.isLocalhostLike ? ( +

+ {translate( + 'browser.loadFailure.trustedCertificateGuidance', + 'For local development, use a trusted local certificate when possible.' + )} +

+ ) : null} + {recoveryHint ?

{recoveryHint}

: null} + {activeProceedAttempt?.state === 'failed' && activeProceedAttempt.reason ? ( +

+ {formatCertificateProceedFailure(activeProceedAttempt.reason)} +

+ ) : null} +
+ {certificateError ? ( + <> + {externalButton} + {retryButton} + {copyButton} + {matchingCertificateFailure ? ( + + ) : null} + + ) : ( + <> + {httpsRecoveryUrl ? ( + + ) : null} + {retryButton} + {copyButton} + {externalButton} + + )} +
+
+
+ ) +} diff --git a/src/renderer/src/components/browser-pane/browser-notices.test.ts b/src/renderer/src/components/browser-pane/browser-notices.test.ts index b08bca7ba3c..24f31d3866a 100644 --- a/src/renderer/src/components/browser-pane/browser-notices.test.ts +++ b/src/renderer/src/components/browser-pane/browser-notices.test.ts @@ -5,7 +5,8 @@ import { formatLoadFailureDescription, formatLoadFailureRecoveryHint, formatPermissionNotice, - formatPopupNotice + formatPopupNotice, + isCertificateLoadError } from './browser-notices' describe('browser notice formatting', () => { @@ -114,4 +115,29 @@ describe('browser notice formatting', () => { }) ).toBeNull() }) + + it('formats certificate failures without local-server recovery advice', () => { + const meta = { host: 'localhost:3443', isLocalhostLike: true } + const loadError = (code: number) => ({ + code, + description: 'certificate error', + validatedUrl: 'https://localhost:3443/' + }) + + expect(formatLoadFailureDescription(loadError(-200), meta)).toBe( + "The certificate doesn't match localhost:3443." + ) + expect(formatLoadFailureDescription(loadError(-201), meta)).toBe( + "The certificate for localhost:3443 isn't valid at the current date and time." + ) + expect(formatLoadFailureDescription(loadError(-202), meta)).toBe( + "Orca doesn't trust the authority that issued the certificate for localhost:3443." + ) + expect(formatLoadFailureDescription(loadError(-208), meta)).toBe( + "Orca couldn't verify the certificate for localhost:3443." + ) + expect(isCertificateLoadError(loadError(-219))).toBe(true) + expect(isCertificateLoadError(loadError(-215))).toBe(false) + expect(formatLoadFailureRecoveryHint(meta, loadError(-202))).toBeNull() + }) }) diff --git a/src/renderer/src/components/browser-pane/browser-notices.ts b/src/renderer/src/components/browser-pane/browser-notices.ts index 81e31c8d81b..8c7b22800e0 100644 --- a/src/renderer/src/components/browser-pane/browser-notices.ts +++ b/src/renderer/src/components/browser-pane/browser-notices.ts @@ -4,8 +4,10 @@ import type { BrowserPopupEvent } from '../../../../shared/browser-guest-events' import type { BrowserLoadError } from '../../../../shared/types' +import { isChromiumCertificateErrorCode } from '../../../../shared/browser-certificate-errors' +import { translate } from '@/i18n/i18n' -type LoadFailureMeta = { +export type LoadFailureMeta = { host: string | null isLocalhostLike: boolean } @@ -73,6 +75,35 @@ export function formatLoadFailureDescription( if (!loadError) { return 'The page did not respond.' } + if (isChromiumCertificateErrorCode(loadError.code)) { + const host = meta.host ?? 'this address' + if (loadError.code === -200) { + return translate( + 'browser.loadFailure.certificateNameMismatch', + "The certificate doesn't match {{value0}}.", + { value0: host } + ) + } + if (loadError.code === -201) { + return translate( + 'browser.loadFailure.certificateDateInvalid', + "The certificate for {{value0}} isn't valid at the current date and time.", + { value0: host } + ) + } + if (loadError.code === -202) { + return translate( + 'browser.loadFailure.certificateAuthorityInvalid', + "Orca doesn't trust the authority that issued the certificate for {{value0}}.", + { value0: host } + ) + } + return translate( + 'browser.loadFailure.certificateVerificationFailed', + "Orca couldn't verify the certificate for {{value0}}.", + { value0: host } + ) + } if (meta.isLocalhostLike) { return "We couldn't connect to your local server." } @@ -82,9 +113,16 @@ export function formatLoadFailureDescription( return "We couldn't connect to this page." } -export function formatLoadFailureRecoveryHint(meta: LoadFailureMeta): string | null { - if (!meta.isLocalhostLike) { +export function formatLoadFailureRecoveryHint( + meta: LoadFailureMeta, + loadError?: BrowserLoadErrorLike +): string | null { + if (!meta.isLocalhostLike || (loadError && isChromiumCertificateErrorCode(loadError.code))) { return null } return 'If this should be a local app, make sure the server is running and listening on the expected port.' } + +export function isCertificateLoadError(loadError: BrowserLoadErrorLike): boolean { + return Boolean(loadError && isChromiumCertificateErrorCode(loadError.code)) +} diff --git a/src/renderer/src/components/editor/ImageViewer.tsx b/src/renderer/src/components/editor/ImageViewer.tsx index 408afd795b3..88b57b092b6 100644 --- a/src/renderer/src/components/editor/ImageViewer.tsx +++ b/src/renderer/src/components/editor/ImageViewer.tsx @@ -20,6 +20,7 @@ import { getZoomedImageLayoutSize } from './image-viewer-zoom' import { translate } from '@/i18n/i18n' +import { buildImageDataUri } from '../../../../shared/image-data-uri' const FALLBACK_IMAGE_MIME_TYPE = 'image/png' @@ -59,8 +60,8 @@ export default function ImageViewer({ const isPdf = mimeType === 'application/pdf' const isIntrinsicLayout = layout === 'intrinsic' const previewSrc = useMemo( - () => (cleanedContent && !isPdf ? `data:${mimeType};base64,${cleanedContent}` : null), - [cleanedContent, isPdf, mimeType] + () => buildImageDataUri(mimeType, cleanedContent), + [cleanedContent, mimeType] ) const imageError = previewSrc !== null && failedPreviewSrc === previewSrc const estimatedSize = useMemo(() => { diff --git a/src/renderer/src/components/editor/MarkdownPreview.tsx b/src/renderer/src/components/editor/MarkdownPreview.tsx index 3b4c53c3fbc..6e7a80e67b1 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.tsx @@ -68,6 +68,10 @@ import { isMarkdownPreviewFindShortcut, setActiveMarkdownPreviewSearchMatch } from './markdown-preview-search' +import { + getMarkdownAnnotationBlockKeyForSelection, + isMarkdownPreviewAddReviewNoteShortcut +} from './markdown-preview-annotation-shortcut' import { usePreserveSectionDuringExternalEdit } from './usePreserveSectionDuringExternalEdit' import { openHttpLink, type HttpLinkSourceOwner } from '@/lib/http-link-routing' import { getShortcutPlatform } from '@/lib/shortcut-platform' @@ -916,6 +920,20 @@ export default function MarkdownPreview({ return } + if ( + isMarkdownPreviewAddReviewNoteShortcut(event, getShortcutPlatform(), keybindings) && + targetInsidePreview && + markdownAnnotationsEnabled + ) { + const blockKey = getMarkdownAnnotationBlockKeyForSelection(root, window.getSelection()) + if (blockKey) { + event.preventDefault() + event.stopPropagation() + setActiveAnnotationBlockKey(blockKey) + } + return + } + if (!isSearchOpen) { return } @@ -930,7 +948,7 @@ export default function MarkdownPreview({ window.addEventListener('keydown', handleKeyDown, { capture: true }) return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }) - }, [closeSearch, isSearchOpen, keybindings, openSearch]) + }, [closeSearch, isSearchOpen, keybindings, markdownAnnotationsEnabled, openSearch]) const handleCopyMarkdownReviewNotes = useCallback(async (): Promise => { if (markdownReviewNotes.length === 0) { @@ -1248,6 +1266,7 @@ export default function MarkdownPreview({ className={`markdown-annotation-block ${hasReviewNotes ? 'has-review-notes' : ''}`.trim()} data-source-line={range.startLine} data-source-end-line={range.endLine} + data-annotation-block-key={blockKey} onClick={(event) => handleAnnotatedMarkdownBlockClick(range, event)} > {rendered} @@ -1668,6 +1687,11 @@ export default function MarkdownPreview({ } const blockKey = `li:${range.startLine}-${range.endLine}` const hasReviewNotes = getMarkdownCommentsForRange(range).length > 0 + const controls = renderAnnotationControls( + range, + blockKey, + getMarkdownPreviewAnnotationQuote(children) + ) return (
  • handleAnnotatedMarkdownBlockClick(range, event)} > {children} - {renderAnnotationControls( - range, - blockKey, - getMarkdownPreviewAnnotationQuote(children) - )} + {controls}
  • ) diff --git a/src/renderer/src/components/editor/MonacoEditor.tsx b/src/renderer/src/components/editor/MonacoEditor.tsx index daf288d8ce9..a1945794b8b 100644 --- a/src/renderer/src/components/editor/MonacoEditor.tsx +++ b/src/renderer/src/components/editor/MonacoEditor.tsx @@ -48,7 +48,11 @@ import { getDiffCommentPopoverTop } from '../diff-comments/diff-comment-popover-position' import { isLinuxUserAgent } from '../terminal-pane/pane-helpers' -import { installEditorSaveShortcut, installMonacoEditorFindShortcut } from './editor-shortcuts' +import { + installEditorAddReviewNoteShortcut, + installEditorSaveShortcut, + installMonacoEditorFindShortcut +} from './editor-shortcuts' import { Plus } from 'lucide-react' import { getMonacoMarkdownSelectionAnnotationTarget, @@ -198,6 +202,10 @@ export default function MonacoEditor({ const [commentPopover, setCommentPopover] = useState(null) const [selectionAnnotationTarget, setSelectionAnnotationTarget] = useState(null) + // Why: the Monaco mount closure installs its keydown listeners once, so the + // add-review-note shortcut reads the live selection target through a ref. + const selectionAnnotationTargetRef = useRef(null) + selectionAnnotationTargetRef.current = selectionAnnotationTarget const isDark = settings?.theme === 'dark' || (settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) @@ -413,6 +421,18 @@ export default function MonacoEditor({ propsRef.current.onSave(value) }) const cleanupFindShortcut = installMonacoEditorFindShortcut(editorInstance) + // Opens the same composer as the selection "+" button; the target ref + // mirrors the last-rendered selection target and is null unless markdown + // annotations are enabled and text is selected. + const cleanupAddReviewNoteShortcut = installEditorAddReviewNoteShortcut(editorDomNode, () => { + const target = selectionAnnotationTargetRef.current + if (!target) { + return false + } + setCommentPopover(target) + setSelectionAnnotationTarget(null) + return true + }) const searchInFilesAction = editorInstance.addAction({ id: 'orca.searchInFiles', label: translate('auto.components.editor.MonacoEditor.fd68ae03b3', 'Search in Files'), @@ -512,6 +532,7 @@ export default function MonacoEditor({ gutterMouseDownSub.dispose() cleanupSaveShortcut() cleanupFindShortcut() + cleanupAddReviewNoteShortcut() editorDomNode.removeEventListener('paste', onLargeTextPaste, { capture: true }) searchInFilesAction.dispose() autoHeightSub?.dispose() diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.tsx index fdbaee0dfcb..2f499ba6b65 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditor.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditor.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import React, { useCallback, useEffect, useRef, useState } from 'react' import { useEditorState, type Editor } from '@tiptap/react' import type { DiffComment, MarkdownDocument } from '../../../../shared/types' import { useAppStore } from '@/store' @@ -10,9 +10,7 @@ import { useLinkBubble } from './useLinkBubble' import { useEditorScrollRestore } from './useEditorScrollRestore' import { useModifierHeldClass } from './useModifierHeldClass' import { registerPendingEditorFlush } from './editor-pending-flush' -import type { MarkdownTocItem } from './markdown-table-of-contents' -import { findRichMarkdownTocHeadingTarget } from './rich-markdown-toc-heading-target' -import { selectMarkdownTableOfContents } from './markdown-toc-visibility-gate' +import { useRichMarkdownTableOfContents } from './use-rich-markdown-table-of-contents' import { RichMarkdownEditorSurface } from './RichMarkdownEditorSurface' import { useRichMarkdownEditorInstance } from './useRichMarkdownEditorInstance' import { useRichMarkdownMenuController } from './useRichMarkdownMenuController' @@ -56,10 +54,6 @@ type RichMarkdownEditorProps = { headerSlot?: React.ReactNode } -function flattenMarkdownTocItems(items: MarkdownTocItem[]): MarkdownTocItem[] { - return items.flatMap((item) => [item, ...flattenMarkdownTocItems(item.children)]) -} - export default function RichMarkdownEditor({ fileId, content, @@ -112,6 +106,7 @@ export default function RichMarkdownEditor({ const onOpenDocLinkRef = useRef(onOpenDocLink) const handleLocalImagePickRef = useRef<() => void>(() => {}) const openSearchRef = useRef<() => void>(() => {}) + const openAnnotationPopoverRef = useRef<(requireLiveSelection?: boolean) => boolean>(() => false) // Why: ProseMirror keeps the initial handleKeyDown closure, so `editor` stays // stuck at the first-render null value unless we read the live instance here. const editorRef = useRef(null) @@ -145,17 +140,10 @@ export default function RichMarkdownEditor({ worktreeId, worktreeRoot }) - // Why: building the table of contents runs a full-document remark parse on - // every content change. The result is only used while the panel is open - // (closed by default), so gate the parse on visibility; including - // showTableOfContents in deps rebuilds the outline the moment it opens. - const tableOfContentsItems = useMemo( - () => selectMarkdownTableOfContents(showTableOfContents, content), - [content, showTableOfContents] - ) - const flatTableOfContentsItems = useMemo( - () => flattenMarkdownTocItems(tableOfContentsItems), - [tableOfContentsItems] + const { tableOfContentsItems, navigateToTableOfContentsItem } = useRichMarkdownTableOfContents( + showTableOfContents, + content, + scrollContainerRef ) // Why: assigning callback refs during render keeps them current before any @@ -166,6 +154,7 @@ export default function RichMarkdownEditor({ onSaveRef.current = onSave onOpenDocLinkRef.current = onOpenDocLink isEditingLinkRef.current = isEditingLink + openAnnotationPopoverRef.current = review.openAnnotationPopover const reconcileRoundTripRef = useRichMarkdownReconcileRoundTrip({ htmlSuperscriptLinkContext, filePath, @@ -258,6 +247,7 @@ export default function RichMarkdownEditor({ markdownSourceLineOffsetRef: review.markdownSourceLineOffsetRef, flushPendingSerialization, openSearchRef, + openAnnotationPopoverRef, syncAnnotationTarget: review.syncAnnotationTarget, clearAnnotationTarget: review.clearAnnotationTarget, scrollRichMarkdownReviewNoteCardIntoView: review.scrollRichMarkdownReviewNoteCardIntoView, @@ -360,18 +350,6 @@ export default function RichMarkdownEditor({ }) openSearchRef.current = openSearch - const navigateToTableOfContentsItem = useCallback( - (id: string): void => { - const container = scrollContainerRef.current - if (!container) { - return - } - const heading = findRichMarkdownTocHeadingTarget(container, flatTableOfContentsItems, id) - heading?.scrollIntoView({ block: 'center' }) - }, - [flatTableOfContentsItems] - ) - return ( ({ })) import { + installEditorAddReviewNoteShortcut, installEditorFindShortcut, installMonacoDiffChangeNavigationShortcut, installMonacoEditorFindShortcut @@ -201,6 +202,74 @@ describe('installEditorFindShortcut', () => { }) }) +describe('installEditorAddReviewNoteShortcut', () => { + it('invokes add-review-note on its default binding and honors overrides', () => { + const container = document.createElement('div') + const input = document.createElement('textarea') + const onAddReviewNote = vi.fn(() => true) + container.appendChild(input) + document.body.appendChild(container) + const dispose = installEditorAddReviewNoteShortcut(container, onAddReviewNote) + + const defaultEvent = dispatchKeyDown(input, { + key: 'n', + code: 'KeyN', + metaKey: true, + altKey: true + }) + const repeatEvent = dispatchKeyDown(input, { + key: 'n', + code: 'KeyN', + metaKey: true, + altKey: true, + repeat: true + }) + const unrelatedEvent = dispatchKeyDown(input, { key: 'n', code: 'KeyN', metaKey: true }) + + expect(defaultEvent.defaultPrevented).toBe(true) + expect(repeatEvent.defaultPrevented).toBe(false) + expect(unrelatedEvent.defaultPrevented).toBe(false) + expect(onAddReviewNote).toHaveBeenCalledTimes(1) + + shortcutState.keybindings = { 'editor.addReviewNote': ['Mod+Shift+A'] } + const overriddenEvent = dispatchKeyDown(input, { + key: 'a', + code: 'KeyA', + metaKey: true, + shiftKey: true + }) + expect(overriddenEvent.defaultPrevented).toBe(true) + expect(onAddReviewNote).toHaveBeenCalledTimes(2) + + dispose() + dispatchKeyDown(input, { key: 'a', code: 'KeyA', metaKey: true, shiftKey: true }) + expect(onAddReviewNote).toHaveBeenCalledTimes(2) + }) + + it('leaves the chord unconsumed when the handler reports it did not act', () => { + const container = document.createElement('div') + const input = document.createElement('textarea') + const onDownstreamKeyDown = vi.fn() + const onAddReviewNote = vi.fn(() => false) + container.appendChild(input) + document.body.appendChild(container) + input.addEventListener('keydown', onDownstreamKeyDown) + const dispose = installEditorAddReviewNoteShortcut(container, onAddReviewNote) + + const event = dispatchKeyDown(input, { + key: 'n', + code: 'KeyN', + metaKey: true, + altKey: true + }) + + expect(onAddReviewNote).toHaveBeenCalledTimes(1) + expect(event.defaultPrevented).toBe(false) + expect(onDownstreamKeyDown).toHaveBeenCalledTimes(1) + dispose() + }) +}) + describe('installMonacoDiffChangeNavigationShortcut', () => { function createDiffNavigationFixture(): { container: HTMLDivElement diff --git a/src/renderer/src/components/editor/editor-shortcuts.ts b/src/renderer/src/components/editor/editor-shortcuts.ts index 825caf6f1ee..5038498a561 100644 --- a/src/renderer/src/components/editor/editor-shortcuts.ts +++ b/src/renderer/src/components/editor/editor-shortcuts.ts @@ -79,6 +79,27 @@ export function installMonacoDiffChangeNavigationShortcut( return () => target.removeEventListener('keydown', handleKeyDown, true) } +export function installEditorAddReviewNoteShortcut( + target: HTMLElement, + onAddReviewNote: () => boolean +): () => void { + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.repeat || !editorShortcutMatches('editor.addReviewNote', event)) { + return + } + // Why: only consume the chord when a composer actually opens; on files + // where review notes can never apply the key must stay available to + // whatever else the user bound it to. + if (onAddReviewNote()) { + event.preventDefault() + event.stopPropagation() + } + } + + target.addEventListener('keydown', handleKeyDown, true) + return () => target.removeEventListener('keydown', handleKeyDown, true) +} + type MonacoFindShortcutEditor = { getAction: (id: string) => { run: () => void | Promise } | null getContainerDomNode: () => HTMLElement diff --git a/src/renderer/src/components/editor/markdown-preview-annotation-shortcut.test.ts b/src/renderer/src/components/editor/markdown-preview-annotation-shortcut.test.ts new file mode 100644 index 00000000000..ed16221dfbb --- /dev/null +++ b/src/renderer/src/components/editor/markdown-preview-annotation-shortcut.test.ts @@ -0,0 +1,95 @@ +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it } from 'vitest' +import { + getMarkdownAnnotationBlockKeyForSelection, + isMarkdownPreviewAddReviewNoteShortcut +} from './markdown-preview-annotation-shortcut' + +function createPreviewFixture(): { + root: HTMLDivElement + block: HTMLDivElement + paragraph: HTMLParagraphElement +} { + const root = document.createElement('div') + const block = document.createElement('div') + block.className = 'markdown-annotation-block' + block.setAttribute('data-annotation-block-key', 'p:3-5') + const paragraph = document.createElement('p') + paragraph.textContent = 'Some rendered markdown text' + block.appendChild(paragraph) + root.appendChild(block) + document.body.appendChild(root) + return { root, block, paragraph } +} + +function selectTextIn(node: Node): Selection { + const selection = window.getSelection() + if (!selection) { + throw new Error('Selection API unavailable in test environment') + } + const range = document.createRange() + range.selectNodeContents(node) + selection.removeAllRanges() + selection.addRange(range) + return selection +} + +afterEach(() => { + window.getSelection()?.removeAllRanges() + document.body.replaceChildren() +}) + +describe('getMarkdownAnnotationBlockKeyForSelection', () => { + it('returns the block key for a selection inside an annotation block', () => { + const { root, paragraph } = createPreviewFixture() + const selection = selectTextIn(paragraph) + + expect(getMarkdownAnnotationBlockKeyForSelection(root, selection)).toBe('p:3-5') + }) + + it('returns null for a collapsed selection', () => { + const { root, paragraph } = createPreviewFixture() + const selection = selectTextIn(paragraph) + selection.collapseToStart() + + expect(getMarkdownAnnotationBlockKeyForSelection(root, selection)).toBeNull() + }) + + it('returns null when the selection is outside the preview root', () => { + const { root } = createPreviewFixture() + const outside = document.createElement('p') + outside.textContent = 'other text' + document.body.appendChild(outside) + const selection = selectTextIn(outside) + + expect(getMarkdownAnnotationBlockKeyForSelection(root, selection)).toBeNull() + }) + + it('returns null without a selection', () => { + const { root } = createPreviewFixture() + + expect(getMarkdownAnnotationBlockKeyForSelection(root, null)).toBeNull() + }) +}) + +describe('isMarkdownPreviewAddReviewNoteShortcut', () => { + it('matches the default binding and respects overrides', () => { + const defaultEvent = { + key: 'n', + code: 'KeyN', + metaKey: true, + ctrlKey: false, + altKey: true, + shiftKey: false + } + + expect(isMarkdownPreviewAddReviewNoteShortcut(defaultEvent, 'darwin')).toBe(true) + expect(isMarkdownPreviewAddReviewNoteShortcut(defaultEvent, 'linux')).toBe(false) + expect( + isMarkdownPreviewAddReviewNoteShortcut(defaultEvent, 'darwin', { + 'editor.addReviewNote': ['Mod+Shift+A'] + }) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/editor/markdown-preview-annotation-shortcut.ts b/src/renderer/src/components/editor/markdown-preview-annotation-shortcut.ts new file mode 100644 index 00000000000..b64dc4c67f5 --- /dev/null +++ b/src/renderer/src/components/editor/markdown-preview-annotation-shortcut.ts @@ -0,0 +1,38 @@ +import { keybindingMatchesAction, type KeybindingOverrides } from '../../../../shared/keybindings' + +export function isMarkdownPreviewAddReviewNoteShortcut( + event: Pick, + platform: NodeJS.Platform, + keybindings?: KeybindingOverrides +): boolean { + return keybindingMatchesAction('editor.addReviewNote', event, platform, keybindings) +} + +function closestAnnotationBlockKey(node: Node | null, root: HTMLElement): string | null { + const element = node instanceof Element ? node : (node?.parentElement ?? null) + const block = element?.closest('[data-annotation-block-key]') ?? null + if (!block || !root.contains(block)) { + return null + } + return block.getAttribute('data-annotation-block-key') +} + +/** + * Maps the current DOM text selection to the annotation block that should host + * the review-note composer. Returns null when the selection is collapsed or + * falls outside an annotatable block of this preview root. + */ +export function getMarkdownAnnotationBlockKeyForSelection( + root: HTMLElement, + selection: Selection | null +): string | null { + if (!selection || selection.isCollapsed || selection.rangeCount === 0) { + return null + } + // Why: a selection spanning multiple blocks anchors the composer on the + // block where the selection started, falling back to where it ended. + return ( + closestAnnotationBlockKey(selection.anchorNode, root) ?? + closestAnnotationBlockKey(selection.focusNode, root) + ) +} diff --git a/src/renderer/src/components/editor/rich-markdown-annotation-shortcut.ts b/src/renderer/src/components/editor/rich-markdown-annotation-shortcut.ts new file mode 100644 index 00000000000..318154d412e --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-annotation-shortcut.ts @@ -0,0 +1,21 @@ +import type { KeyHandlerContext } from './rich-markdown-key-handler' +import { editorShortcutMatches } from './editor-shortcuts' + +/** + * Mod+Alt+N: open the review-note composer for the current selection. + */ +export function handleRichMarkdownAddReviewNoteShortcut( + ctx: KeyHandlerContext, + event: KeyboardEvent +): boolean { + if (!editorShortcutMatches('editor.addReviewNote', event)) { + return false + } + // Why: require the live selection so a collapsed selection cannot reopen a + // stale target; consume the chord only when a composer actually opens. + if (!ctx.openAnnotationPopoverRef.current(true)) { + return false + } + event.preventDefault() + return true +} diff --git a/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts b/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts index 8573ddea973..dd58ff40838 100644 --- a/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts @@ -73,6 +73,7 @@ function createConfigParams(overrides: Partial = {}): Editor markdownSourceLineOffsetRef: ref(0), flushPendingSerialization: vi.fn(), openSearchRef: ref(vi.fn()), + openAnnotationPopoverRef: ref(vi.fn()), syncAnnotationTarget: vi.fn(), clearAnnotationTarget: vi.fn(), scrollRichMarkdownReviewNoteCardIntoView: vi.fn(), diff --git a/src/renderer/src/components/editor/rich-markdown-editor-config.ts b/src/renderer/src/components/editor/rich-markdown-editor-config.ts index ec94eda94d4..5877d9eb31e 100644 --- a/src/renderer/src/components/editor/rich-markdown-editor-config.ts +++ b/src/renderer/src/components/editor/rich-markdown-editor-config.ts @@ -77,6 +77,7 @@ export type EditorConfigParams = { markdownSourceLineOffsetRef: MutableRefObject flushPendingSerialization: () => void openSearchRef: MutableRefObject<() => void> + openAnnotationPopoverRef: MutableRefObject<(requireLiveSelection?: boolean) => boolean> syncAnnotationTarget: (editor: Editor) => void clearAnnotationTarget: () => void scrollRichMarkdownReviewNoteCardIntoView: (commentId: string) => void @@ -109,17 +110,7 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE reconcileRoundTripRef, onContentChangeRef, onDirtyStateHintRef, - onSaveRef, onOpenDocLinkRef, - isEditingLinkRef, - slashMenuRef, - filteredSlashCommandsRef, - selectedCommandIndexRef, - docLinkMenuRef, - filteredDocLinkRowsRef, - selectedDocLinkIndexRef, - handleLocalImagePickRef, - handleEmojiPickRef, typedEmptyOrderedListMarkerRef, cancelAutoFocusRef, serializeTimerRef, @@ -127,15 +118,11 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE isApplyingProgrammaticUpdateRef, markdownCommentsRef, markdownSourceLineOffsetRef, - flushPendingSerialization, - openSearchRef, syncAnnotationTarget, clearAnnotationTarget, scrollRichMarkdownReviewNoteCardIntoView, setIsEditingLink, setLinkBubble, - setSelectedCommandIndex, - setSelectedDocLinkIndex, setSlashMenu, setDocLinkMenu } = params @@ -172,36 +159,12 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE typedEmptyOrderedListMarkerRef.current = /^\d+\.$/.test(beforeCursor) return false }, + // Why: KeyHandlerContext is a typed subset of EditorConfigParams, so the + // spread stays type-checked while new context fields avoid re-listing + // every ref here. handleKeyDown: createRichMarkdownKeyHandler({ - isMac, - editorRef, - rootRef, - lastCommittedMarkdownRef, - originalSourceRef, - baseCanonicalRef, - reconcileRoundTripRef, - onContentChangeRef, - onSaveRef, - isEditingLinkRef, - slashMenuRef, - filteredSlashCommandsRef, - selectedCommandIndexRef, - docLinkMenuRef, - filteredDocLinkRowsRef, - selectedDocLinkIndexRef, - handleLocalImagePickRef, - handleEmojiPickRef, - typedEmptyOrderedListMarkerRef, - flushPendingSerialization, - openSearchRef, + ...params, linkBubbleOwnerId: codec.transport.key, - htmlSuperscriptLinkContext, - setIsEditingLink, - setLinkBubble, - setSelectedCommandIndex, - setSelectedDocLinkIndex, - setSlashMenu, - setDocLinkMenu, openSelectedHtmlSuperscriptLink: () => openSelectedHtmlSuperscriptLink({ activateMarkdownLink, diff --git a/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts b/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts index 02d53e1b626..ebd71f84f31 100644 --- a/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts @@ -4,6 +4,12 @@ import StarterKit from '@tiptap/starter-kit' import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests' import { createRichMarkdownKeyHandler, type KeyHandlerContext } from './rich-markdown-key-handler' +// Why: keybinding matching resolves the platform from navigator.userAgent, +// which is environment-dependent under vitest; pin it for determinism. +vi.mock('@/lib/shortcut-platform', () => ({ + getShortcutPlatform: () => 'darwin' as NodeJS.Platform +})) + const extensions = [StarterKit, createIsolatedMarkdownExtensionForTests()] function createEditor(content: object): Editor { @@ -82,6 +88,7 @@ function createContext(editor: Editor, typedMarker: boolean): KeyHandlerContext subscribe: () => () => {}, update: () => {} }, + openAnnotationPopoverRef: { current: vi.fn(() => true) }, setIsEditingLink: vi.fn(), setLinkBubble: vi.fn(), setSelectedCommandIndex: vi.fn(), @@ -105,6 +112,37 @@ function emptyTopLevelOrderedList(): object { } describe('rich markdown key handler', () => { + it('opens the review-note composer on the add-review-note shortcut', () => { + const editor = createEditor(emptyTopLevelOrderedList()) + + try { + const ctx = createContext(editor, false) + const event = keyEvent('n', { metaKey: true, altKey: true, code: 'KeyN' }) + + expect(createRichMarkdownKeyHandler(ctx)(null, event)).toBe(true) + expect(event.preventDefault).toHaveBeenCalled() + expect(ctx.openAnnotationPopoverRef.current).toHaveBeenCalledWith(true) + } finally { + editor.destroy() + } + }) + + it('leaves the add-review-note chord unconsumed when no composer opens', () => { + const editor = createEditor(emptyTopLevelOrderedList()) + + try { + const ctx = createContext(editor, false) + ctx.openAnnotationPopoverRef.current = vi.fn(() => false) + const event = keyEvent('n', { metaKey: true, altKey: true, code: 'KeyN' }) + + expect(createRichMarkdownKeyHandler(ctx)(null, event)).toBe(false) + expect(event.preventDefault).not.toHaveBeenCalled() + expect(ctx.openAnnotationPopoverRef.current).toHaveBeenCalledTimes(1) + } finally { + editor.destroy() + } + }) + it('preserves a typed empty ordered-list shortcut on Enter', () => { const editor = createEditor(emptyTopLevelOrderedList()) diff --git a/src/renderer/src/components/editor/rich-markdown-key-handler.ts b/src/renderer/src/components/editor/rich-markdown-key-handler.ts index d8995f3c913..b0a223887b5 100644 --- a/src/renderer/src/components/editor/rich-markdown-key-handler.ts +++ b/src/renderer/src/components/editor/rich-markdown-key-handler.ts @@ -3,6 +3,7 @@ import type { Editor } from '@tiptap/react' import { getShortcutPlatform } from '@/lib/shortcut-platform' import { useAppStore } from '@/store' import { isMarkdownPreviewFindShortcut } from './markdown-preview-search' +import { handleRichMarkdownAddReviewNoteShortcut } from './rich-markdown-annotation-shortcut' import type { LinkBubbleState } from './RichMarkdownLinkBubble' import { commitRow, type DocLinkMenuRow, type DocLinkMenuState } from './rich-markdown-commands' import { @@ -21,6 +22,7 @@ import { handleRichMarkdownCitationKey } from './rich-markdown-citation-keyboard import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context' import { handleRichMarkdownLinkShortcut } from './rich-markdown-link-shortcut' import { handleRichMarkdownSaveShortcut } from './rich-markdown-save-shortcut' +import { flushPendingProseMirrorSelection } from './rich-markdown-selection-flush' export type KeyHandlerContext = { isMac: boolean @@ -44,6 +46,7 @@ export type KeyHandlerContext = { typedEmptyOrderedListMarkerRef: MutableRefObject flushPendingSerialization: () => void openSearchRef: MutableRefObject<() => void> + openAnnotationPopoverRef: MutableRefObject<(requireLiveSelection?: boolean) => boolean> setIsEditingLink: (editing: boolean) => void setLinkBubble: (bubble: LinkBubbleState | null) => void setSelectedCommandIndex: Dispatch> @@ -59,47 +62,6 @@ function isComposingMarkdownInput(event: KeyboardEvent, editor: Editor | null): return event.isComposing || editor?.view.composing === true } -type NativeSelectionSnapshot = { - anchorNode: Node | null - anchorOffset: number - focusNode: Node | null - focusOffset: number -} - -type ProseMirrorDomObserver = { - currentSelection?: { - set?: (selection: NativeSelectionSnapshot) => void - } - flush?: () => void -} - -type ProseMirrorViewWithDomObserver = Editor['view'] & { - domObserver?: ProseMirrorDomObserver -} - -function flushPendingProseMirrorSelection(editor: Editor): void { - let observer: ProseMirrorDomObserver | undefined - try { - observer = (editor.view as ProseMirrorViewWithDomObserver).domObserver - } catch { - return - } - - if (typeof observer?.flush !== 'function') { - return - } - - // Why: immediate Tab after a mouse click can run before ProseMirror has - // copied the native selection into editor state, so list commands hit stale item state. - observer.currentSelection?.set?.({ - anchorNode: null, - anchorOffset: 0, - focusNode: null, - focusOffset: 0 - }) - observer.flush() -} - /** * Why: extracted from RichMarkdownEditor to stay under the file line-limit * while keeping the keyboard handler logic co-located and testable. @@ -133,6 +95,9 @@ export function createRichMarkdownKeyHandler( if (handleRichMarkdownSaveShortcut(ctx, event)) { return true } + if (handleRichMarkdownAddReviewNoteShortcut(ctx, event)) { + return true + } // Strikethrough: Cmd/Ctrl+Shift+X (standard shortcut used by Google // Docs, Notion, etc. — supplements Tiptap's built-in Mod+Shift+S). diff --git a/src/renderer/src/components/editor/rich-markdown-selection-flush.ts b/src/renderer/src/components/editor/rich-markdown-selection-flush.ts new file mode 100644 index 00000000000..a7dd336ed2e --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-selection-flush.ts @@ -0,0 +1,42 @@ +import type { Editor } from '@tiptap/react' + +type NativeSelectionSnapshot = { + anchorNode: Node | null + anchorOffset: number + focusNode: Node | null + focusOffset: number +} + +type ProseMirrorDomObserver = { + currentSelection?: { + set?: (selection: NativeSelectionSnapshot) => void + } + flush?: () => void +} + +type ProseMirrorViewWithDomObserver = Editor['view'] & { + domObserver?: ProseMirrorDomObserver +} + +export function flushPendingProseMirrorSelection(editor: Editor): void { + let observer: ProseMirrorDomObserver | undefined + try { + observer = (editor.view as ProseMirrorViewWithDomObserver).domObserver + } catch { + return + } + + if (typeof observer?.flush !== 'function') { + return + } + + // Why: immediate Tab after a mouse click can run before ProseMirror has + // copied the native selection into editor state, so list commands hit stale item state. + observer.currentSelection?.set?.({ + anchorNode: null, + anchorOffset: 0, + focusNode: null, + focusOffset: 0 + }) + observer.flush() +} diff --git a/src/renderer/src/components/editor/rich-markdown-tab-key-handler.test.ts b/src/renderer/src/components/editor/rich-markdown-tab-key-handler.test.ts index b9675d5e385..de6b9934d27 100644 --- a/src/renderer/src/components/editor/rich-markdown-tab-key-handler.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-tab-key-handler.test.ts @@ -89,6 +89,7 @@ function createContext(editor: Editor): KeyHandlerContext { subscribe: () => () => {}, update: () => {} }, + openAnnotationPopoverRef: { current: vi.fn() }, setIsEditingLink: vi.fn(), setLinkBubble: vi.fn(), setSelectedCommandIndex: vi.fn(), diff --git a/src/renderer/src/components/editor/use-rich-markdown-table-of-contents.ts b/src/renderer/src/components/editor/use-rich-markdown-table-of-contents.ts new file mode 100644 index 00000000000..de2040627cd --- /dev/null +++ b/src/renderer/src/components/editor/use-rich-markdown-table-of-contents.ts @@ -0,0 +1,44 @@ +import { useCallback, useMemo, type MutableRefObject } from 'react' +import type { MarkdownTocItem } from './markdown-table-of-contents' +import { findRichMarkdownTocHeadingTarget } from './rich-markdown-toc-heading-target' +import { selectMarkdownTableOfContents } from './markdown-toc-visibility-gate' + +function flattenMarkdownTocItems(items: MarkdownTocItem[]): MarkdownTocItem[] { + return items.flatMap((item) => [item, ...flattenMarkdownTocItems(item.children)]) +} + +export function useRichMarkdownTableOfContents( + showTableOfContents: boolean, + content: string, + scrollContainerRef: MutableRefObject +): { + tableOfContentsItems: MarkdownTocItem[] + navigateToTableOfContentsItem: (id: string) => void +} { + // Why: building the table of contents runs a full-document remark parse on + // every content change. The result is only used while the panel is open + // (closed by default), so gate the parse on visibility; including + // showTableOfContents in deps rebuilds the outline the moment it opens. + const tableOfContentsItems = useMemo( + () => selectMarkdownTableOfContents(showTableOfContents, content), + [content, showTableOfContents] + ) + const flatTableOfContentsItems = useMemo( + () => flattenMarkdownTocItems(tableOfContentsItems), + [tableOfContentsItems] + ) + + const navigateToTableOfContentsItem = useCallback( + (id: string): void => { + const container = scrollContainerRef.current + if (!container) { + return + } + const heading = findRichMarkdownTocHeadingTarget(container, flatTableOfContentsItems, id) + heading?.scrollIntoView({ block: 'center' }) + }, + [flatTableOfContentsItems, scrollContainerRef] + ) + + return { tableOfContentsItems, navigateToTableOfContentsItem } +} diff --git a/src/renderer/src/components/editor/useRichMarkdownReviewController.ts b/src/renderer/src/components/editor/useRichMarkdownReviewController.ts index 43bade891b8..82b408006b1 100644 --- a/src/renderer/src/components/editor/useRichMarkdownReviewController.ts +++ b/src/renderer/src/components/editor/useRichMarkdownReviewController.ts @@ -201,41 +201,51 @@ export function useRichMarkdownReviewController({ ] ) - const openAnnotationPopover = useCallback((): void => { - if (!annotationTarget || !canAnnotateRichMarkdown) { - return - } - const editor = editorRef.current - const root = rootRef.current - const liveTarget = editor && root ? getRichMarkdownAnnotationTarget(editor, root) : null - const target = editor - ? clampRichMarkdownAnnotationTarget(editor, liveTarget ?? annotationTarget) - : annotationTarget - if ( - !target || - hasRichMarkdownCommentForRange(markdownComments, target, markdownSourceLineOffset) - ) { + // Why: reports whether a composer actually opened so keyboard callers can + // leave the chord unconsumed when this no-ops (mouse callers ignore it). + const openAnnotationPopover = useCallback( + (requireLiveSelection = false): boolean => { + if (!canAnnotateRichMarkdown) { + return false + } + const editor = editorRef.current + const root = rootRef.current + // Why: keyboard callers require the live selection to avoid stale-target + // races; the mouse button may use the target from the render that exposed it. + const liveTarget = editor && root ? getRichMarkdownAnnotationTarget(editor, root) : null + const baseTarget = liveTarget ?? (requireLiveSelection ? null : annotationTarget) + if (!baseTarget) { + return false + } + const target = editor ? clampRichMarkdownAnnotationTarget(editor, baseTarget) : baseTarget + if ( + !target || + hasRichMarkdownCommentForRange(markdownComments, target, markdownSourceLineOffset) + ) { + setAnnotationTarget(null) + return false + } + editor?.view.dispatch( + editor.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, { + activeRange: { from: target.from, to: target.to } + }) + ) + // Why: opening a draft should reserve the notes rail immediately; saved notes stay visible. + setReviewRailOpen(true) + setAnnotationPopover(target) setAnnotationTarget(null) - return - } - editor?.view.dispatch( - editor.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, { - activeRange: { from: target.from, to: target.to } - }) - ) - // Why: opening a draft should reserve the notes rail immediately; saved notes stay visible. - setReviewRailOpen(true) - setAnnotationPopover(target) - setAnnotationTarget(null) - }, [ - annotationTarget, - canAnnotateRichMarkdown, - editorRef, - markdownComments, - markdownSourceLineOffset, - rootRef, - setReviewRailOpen - ]) + return true + }, + [ + annotationTarget, + canAnnotateRichMarkdown, + editorRef, + markdownComments, + markdownSourceLineOffset, + rootRef, + setReviewRailOpen + ] + ) useEffect(() => { if (canAnnotateRichMarkdown) { diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx index 03213e9820e..a0699332738 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx @@ -17,6 +17,8 @@ import { } from '../../../../shared/tui-agent-launch-defaults' import { translate } from '@/i18n/i18n' import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' +import { resolveNativeChatSessionOptionDefaults } from '../../../../shared/native-chat-session-option-defaults' +import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' type FloatingTerminalWindowControlsProps = { maximized: boolean @@ -73,6 +75,10 @@ export function FloatingTerminalWindowControls({ cmdOverrides: state.settings?.agentCmdOverrides ?? {}, agentArgs: resolveTuiAgentLaunchArgs(defaultAgent, state.settings?.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(defaultAgent, state.settings?.agentDefaultEnv), + sessionOptions: resolveNativeChatSessionOptionDefaults( + state.settings?.nativeChatSessionOptions, + defaultAgent + ), platform: CLIENT_PLATFORM, allowEmptyPromptLaunch: true }) @@ -87,6 +93,7 @@ export function FloatingTerminalWindowControls({ return } const tab = createTab(FLOATING_TERMINAL_WORKTREE_ID, undefined, undefined, { activate: false }) + seedNativeChatAppliedSessionOptions(tab.id, defaultAgent, startupPlan.sessionOptions) state.queueTabStartupCommand(tab.id, { command: startupPlan.launchCommand, ...(startupPlan.env ? { env: startupPlan.env } : {}), diff --git a/src/renderer/src/components/github/github-user-avatar.test.tsx b/src/renderer/src/components/github/github-user-avatar.test.tsx new file mode 100644 index 00000000000..5eeed156426 --- /dev/null +++ b/src/renderer/src/components/github/github-user-avatar.test.tsx @@ -0,0 +1,107 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it } from 'vitest' +import { GitHubUserAvatar, resolveGitHubUserAvatarSrc } from './github-user-avatar' + +function render(node: React.JSX.Element): { root: Root; container: HTMLDivElement } { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + act(() => { + root.render(node) + }) + return { root, container } +} + +describe('GitHubUserAvatar', () => { + let root: Root | null = null + let container: HTMLDivElement | null = null + + afterEach(() => { + act(() => root?.unmount()) + container?.remove() + root = null + container = null + }) + + it('renders the API avatar_url when provided (works for github.com and GHE)', () => { + const url = 'https://avatars.example.com/u/42?u=hash&v=4' + ;({ root, container } = render()) + const img = container.querySelector('img') + expect(img?.getAttribute('src')).toBe(url) + }) + + it('falls back to the login URL when no avatar_url is available', () => { + ;({ root, container } = render()) + const img = container.querySelector('img') + expect(img?.getAttribute('src')).toBe('https://github.com/octocat.png?size=64') + }) + + it('degrades to an initials placeholder when the image fails to load (GHE 404)', () => { + ;({ root, container } = render( + + )) + const img = container.querySelector('img') + expect(img).not.toBeNull() + act(() => { + img?.dispatchEvent(new Event('error')) + }) + expect(container.querySelector('img')).toBeNull() + expect(container.textContent).toBe('AL') + }) + + it('skips leading non-BMP chars so initials never render a broken surrogate', () => { + ;({ root, container } = render()) + act(() => { + container?.querySelector('img')?.dispatchEvent(new Event('error')) + }) + // Emoji word is skipped entirely rather than split into a broken half. + expect(container.textContent).toBe('D') + }) + + it('derives initials from the login when no name is given', () => { + ;({ root, container } = render()) + act(() => { + container?.querySelector('img')?.dispatchEvent(new Event('error')) + }) + expect(container.textContent).toBe('OC') + }) + + it('retries when avatarUrl changes after an earlier src failed (late-arriving avatar)', () => { + // Why: PR detail enrichment supplies avatar_url after first paint. A row that + // 404s on the early login-based URL must recover once the real URL lands. + ;({ root, container } = render()) + // First src is the login fallback; simulate the GHE 404. + act(() => { + container?.querySelector('img')?.dispatchEvent(new Event('error')) + }) + expect(container.querySelector('img')).toBeNull() + // Enriched avatar_url arrives → component must render the new image, not the + // latched placeholder. + const url = 'https://avatars.example.com/u/7?v=4' + act(() => { + root?.render() + }) + expect(container.querySelector('img')?.getAttribute('src')).toBe(url) + }) + + it('shows initials immediately when login and avatarUrl are both empty', () => { + ;({ root, container } = render()) + expect(container.querySelector('img')).toBeNull() + expect(container.textContent).toBe('AD') + }) +}) + +describe('resolveGitHubUserAvatarSrc', () => { + it('prefers trimmed API avatar over login.png', () => { + expect(resolveGitHubUserAvatarSrc('u', ' https://avatars.example.com/u/1?v=4 ')).toBe( + 'https://avatars.example.com/u/1?v=4' + ) + }) + + it('skips empty login so no github.com/.png request is built', () => { + expect(resolveGitHubUserAvatarSrc('', null)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/github/github-user-avatar.tsx b/src/renderer/src/components/github/github-user-avatar.tsx new file mode 100644 index 00000000000..385c7feda93 --- /dev/null +++ b/src/renderer/src/components/github/github-user-avatar.tsx @@ -0,0 +1,104 @@ +import { useState } from 'react' +import { cn } from '@/lib/utils' +import { githubAvatarUrl } from '@/components/github/github-issue-comment-helpers' + +/** + * Build a 1-2 character initials placeholder from a display name or login, + * used when no avatar image is available or the image fails to load. + */ +function initialsFor(login: string, name?: string | null): string { + const source = (name?.trim() || login).trim() + if (!source) { + return '?' + } + // Iterate by code point (not UTF-16 unit) and keep only letters/digits, so a + // display name that leads with an emoji or other non-BMP char yields clean + // initials instead of a broken surrogate half. Words that are all symbols are + // skipped; an all-symbol name falls back to '?'. + const alnum = (word: string): string[] => [...word].filter((ch) => /[\p{L}\p{N}]/u.test(ch)) + const parts = source.split(/\s+/).filter(Boolean) + const letters = + parts.length >= 2 + ? parts + .map((word) => alnum(word)[0]) + .filter(Boolean) + .slice(0, 2) + .join('') + : alnum(source).slice(0, 2).join('') + return letters.toUpperCase() || '?' +} + +/** + * Prefer the API `avatar_url`, then the public login.png URL. Empty/whitespace + * API values and empty logins never produce a bogus request. See #8784. + */ +export function resolveGitHubUserAvatarSrc( + login: string, + avatarUrl?: string | null +): string | null { + const fromApi = avatarUrl?.trim() + if (fromApi) { + return fromApi + } + const trimmedLogin = login.trim() + if (!trimmedLogin) { + return null + } + return githubAvatarUrl(trimmedLogin) +} + +/** + * Avatar for a GitHub user that renders correctly on github.com and GitHub + * Enterprise. GHE logins don't exist on github.com, so the login-based + * `github.com/{login}.png` URL 404s. We prefer the API-provided `avatarUrl` + * and, when it (or the login fallback) fails to load, degrade to an initials + * placeholder instead of a broken image. See #8784. + */ +export function GitHubUserAvatar({ + login, + name, + avatarUrl, + title, + className +}: { + login: string + name?: string | null + avatarUrl?: string | null + title?: string + className?: string +}): React.JSX.Element { + // Why: track the specific src that failed rather than a boolean. Avatar data + // arrives after the first paint (PR detail enrichment), so a row that 404s on + // the early login-based URL must retry once the real avatar_url lands — a + // latched boolean would keep showing the placeholder forever. See #8784. + const [failedSrc, setFailedSrc] = useState(null) + const src = resolveGitHubUserAvatarSrc(login, avatarUrl) + if (src && failedSrc !== src) { + return ( + setFailedSrc(src)} + className={cn( + 'shrink-0 rounded-full border border-border/50 bg-muted object-cover', + className + )} + /> + ) + } + return ( + + {initialsFor(login, name)} + + ) +} diff --git a/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts b/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts new file mode 100644 index 00000000000..7b831270910 --- /dev/null +++ b/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts @@ -0,0 +1,55 @@ +/** + * Issue #8784 — GHE PR avatars must prefer API avatar_url over github.com/{login}.png. + * + * Regression guard (was a repro that documented the broken path). After the fix: + * - resolveGitHubUserAvatarSrc prefers API URLs + * - PullRequestPage author uses authorAvatarUrl via GitHubUserAvatar + * - TaskPage ReviewChipAvatar no longer hardcodes github.com login.png + * + * Re-run: + * pnpm exec vitest run --config config/vitest.config.ts \ + * src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts + */ +import { describe, expect, it } from 'vitest' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { githubAvatarUrl } from './github-issue-comment-helpers' +import { resolveGitHubUserAvatarSrc } from './github-user-avatar' + +describe('issue #8784 GHE avatar fallback (regression)', () => { + it('prefers API avatar_url over login.png (GHE healthy path)', () => { + const api = 'https://ghe.example.com/avatars/u/42?v=4' + expect(resolveGitHubUserAvatarSrc('enterprise-only-user', api)).toBe(api) + }) + + it('trims whitespace-only avatarUrl and falls back to login.png for github.com', () => { + expect(resolveGitHubUserAvatarSrc('octocat', ' ')).toBe( + 'https://github.com/octocat.png?size=64' + ) + expect(resolveGitHubUserAvatarSrc('octocat', null)).toBe(githubAvatarUrl('octocat')) + }) + + it('returns null when neither avatarUrl nor login is usable (no bogus request)', () => { + expect(resolveGitHubUserAvatarSrc('', '')).toBeNull() + expect(resolveGitHubUserAvatarSrc(' ', null)).toBeNull() + }) + + it('login-only fallback still hardcodes public github.com png (github.com path)', () => { + // Why: github.com users without avatar_url still use this; GHE relies on + // enrichment + image onError → initials when this 404s. + expect(githubAvatarUrl('corp-user')).toBe('https://github.com/corp-user.png?size=64') + }) + + it('source routes PR author/reviewer avatars through GitHubUserAvatar + authorAvatarUrl', () => { + const prPage = readFileSync(join(__dirname, '../PullRequestPage.tsx'), 'utf8') + expect(prPage).toMatch(/GitHubUserAvatar/) + expect(prPage).toMatch(/authorAvatarUrl/) + // Why: author chip must not ignore API avatar_url and only pass login. + expect(prPage).not.toMatch(/githubAvatarUrl\(workItem\.author\)/) + + const taskPage = readFileSync(join(__dirname, '../TaskPage.tsx'), 'utf8') + expect(taskPage).toMatch(/GitHubUserAvatar/) + // Why: list chip must not hardcode github.com/{login}.png. + expect(taskPage).not.toMatch(/github\.com\/\$\{reviewer\.login\}\.png/) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index c8be699f8f6..f1f1794cc3b 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -1,21 +1,48 @@ // @vitest-environment happy-dom -import { act, cleanup, render } from '@testing-library/react' +import { act, cleanup, render, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + SessionOptionDescriptor, + SessionOptionsSurface +} from '../../../../shared/native-chat-session-options' +import { clearNativeChatSessionOptionCacheForTests } from './native-chat-session-option-cache' const mocks = vi.hoisted(() => ({ cancelPendingSends: vi.fn(), - fieldProps: null as { onSend?: () => void; onStop?: () => void } | null, + fieldProps: null as { + onSend?: () => void + onStop?: () => void + sessionOptionsSurface?: SessionOptionsSurface | null + sessionOptionsSnapshot?: SessionOptionDescriptor[] + } | null, + modelSwitchOutcome: 'applied' as 'applied' | 'rejected' | 'interaction-required' | 'unknown', + confirmationObserver: null as { + ready: Promise + result: Promise<'applied' | 'rejected' | 'interaction-required' | 'unknown'> + arm: ReturnType + startDetection: ReturnType + dispose: ReturnType + } | null, + createClaudeModelSwitchConfirmationObserver: vi.fn(), + getMainBufferSnapshot: vi.fn(), sendHandle: { cancel: vi.fn(), settleAfterMs: 500 }, sendNativeChatMessage: vi.fn(), + sendNativeChatMessageVerified: vi.fn(), trackPendingSend: vi.fn(), setDraft: vi.fn() })) -vi.mock('../../store', () => ({ - useAppStore: (selector: (state: unknown) => unknown) => - selector({ dictationState: 'idle', settings: { voice: { enabled: false } } }) -})) +vi.mock('../../store', () => { + const state = { + dictationState: 'idle', + settings: { voice: { enabled: false }, nativeChatSessionOptions: {} }, + updateSettings: vi.fn() + } + const useAppStore = (selector: (value: typeof state) => unknown) => selector(state) + useAppStore.getState = () => state + return { useAppStore } +}) vi.mock('@/runtime/runtime-terminal-inspection', () => ({ isRemoteRuntimePtyId: () => false, @@ -26,15 +53,27 @@ vi.mock('@/lib/agent-paste-draft', () => ({ })) vi.mock('./native-chat-runtime-send', () => ({ sendNativeChatMessage: (...args: unknown[]) => mocks.sendNativeChatMessage(...args), + sendNativeChatMessageVerified: (...args: unknown[]) => + mocks.sendNativeChatMessageVerified(...args), sendNativeChatMessageWithImageAttachments: vi.fn(), submitNativeChatPrompt: vi.fn() })) -vi.mock('./native-chat-agent-commands', () => ({ getAgentSlashCommands: () => [] })) -vi.mock('@/lib/native-chat-telemetry', () => ({ emitNativeChatMessageSent: vi.fn() })) +vi.mock('./claude-model-switch-confirmation', () => ({ + createClaudeModelSwitchConfirmationObserver: (...args: unknown[]) => + mocks.createClaudeModelSwitchConfirmationObserver(...args) +})) +vi.mock('./native-chat-agent-commands', () => ({ + getAgentSlashCommands: () => [] +})) +vi.mock('@/lib/native-chat-telemetry', () => ({ + emitNativeChatMessageSent: vi.fn() +})) vi.mock('./use-native-chat-draft', () => ({ useNativeChatDraft: () => ({ draft: 'hello', setDraft: mocks.setDraft }) })) -vi.mock('./native-chat-draft-cache', () => ({ readNativeChatDraftCache: () => '' })) +vi.mock('./native-chat-draft-cache', () => ({ + readNativeChatDraftCache: () => '' +})) vi.mock('./NativeChatComposerField', () => ({ NativeChatComposerField: (props: { onSend?: () => void; onStop?: () => void }) => { mocks.fieldProps = props @@ -51,7 +90,10 @@ vi.mock('./use-native-chat-composer-attachments', () => ({ }) })) vi.mock('./use-native-chat-composer-paste', () => ({ - useNativeChatComposerPaste: () => ({ handlePaste: vi.fn(), pasteFromClipboard: vi.fn() }) + useNativeChatComposerPaste: () => ({ + handlePaste: vi.fn(), + pasteFromClipboard: vi.fn() + }) })) vi.mock('./use-native-chat-external-attachments', () => ({ useNativeChatExternalAttachments: () => ({ @@ -59,7 +101,9 @@ vi.mock('./use-native-chat-external-attachments', () => ({ resolveAttachmentOwner: vi.fn() }) })) -vi.mock('../dictation/dictation-control-events', () => ({ dispatchDictationControl: vi.fn() })) +vi.mock('../dictation/dictation-control-events', () => ({ + dispatchDictationControl: vi.fn() +})) vi.mock('./use-native-chat-composer-keydown', () => ({ useNativeChatComposerKeyDown: () => vi.fn() })) @@ -75,11 +119,31 @@ import { NativeChatComposer } from './NativeChatComposer' describe('NativeChatComposer', () => { beforeEach(() => { vi.clearAllMocks() + clearNativeChatSessionOptionCacheForTests() mocks.fieldProps = null + mocks.modelSwitchOutcome = 'applied' + mocks.confirmationObserver = null + mocks.createClaudeModelSwitchConfirmationObserver.mockImplementation(() => { + const observer = { + ready: Promise.resolve(), + result: Promise.resolve(mocks.modelSwitchOutcome), + arm: vi.fn(), + startDetection: vi.fn(), + dispose: vi.fn() + } + mocks.confirmationObserver = observer + return observer + }) + mocks.getMainBufferSnapshot.mockResolvedValue(null) mocks.sendNativeChatMessage.mockReturnValue(mocks.sendHandle) + mocks.sendNativeChatMessageVerified.mockResolvedValue(true) + mocks.sendHandle.settleAfterMs = 500 Object.defineProperty(window, 'api', { configurable: true, - value: { ui: { onFileDrop: () => vi.fn() } } + value: { + pty: { getMainBufferSnapshot: mocks.getMainBufferSnapshot }, + ui: { onFileDrop: () => vi.fn() } + } }) }) @@ -122,4 +186,201 @@ describe('NativeChatComposer', () => { expect(onOptimisticSend).toHaveBeenCalledWith('hello', []) expect(mocks.trackPendingSend).toHaveBeenCalledWith(mocks.sendHandle, 'pending-1') }) + + it('shows the model already selected in the Claude TUI when chat opens', async () => { + mocks.getMainBufferSnapshot.mockResolvedValue({ + data: 'Claude Code v2.1.211\r\nOpus 4.8 with medium effort · API Usage Billing', + cols: 120, + rows: 40 + }) + render( + null} + /> + ) + + await waitFor(() => + expect(mocks.fieldProps?.sessionOptionsSnapshot).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'model', + valueSource: 'reported', + kind: expect.objectContaining({ currentValue: 'opus' }) + }), + expect.objectContaining({ + id: 'effort', + valueSource: 'reported', + kind: expect.objectContaining({ currentValue: 'medium' }) + }) + ]) + ) + ) + expect(mocks.getMainBufferSnapshot).toHaveBeenCalledWith('pty-1', { scrollbackRows: 0 }) + }) + + it('reads Claude state from mounted xterm while its alternate screen is active', async () => { + mocks.getMainBufferSnapshot.mockResolvedValue({ + data: 'Claude Code v2.1.211\r\nOpus 4.8 with high effort · stale main buffer', + cols: 120, + rows: 40, + alternateScreen: true + }) + render( + + '\u001b[?1049h\u001b[HClaude Codev2.1.211\r\n' + + 'Sonnet 5 with medium effort · API Usage Billing' + } + /> + ) + + await waitFor(() => + expect(mocks.fieldProps?.sessionOptionsSnapshot).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'model', + valueSource: 'reported', + kind: expect.objectContaining({ currentValue: 'sonnet' }) + }), + expect.objectContaining({ + id: 'effort', + valueSource: 'reported', + kind: expect.objectContaining({ currentValue: 'medium' }) + }) + ]) + ) + ) + }) + + it('observes a fresh Claude model choice and stays native on success', async () => { + mocks.sendHandle.settleAfterMs = 0 + const onSlashCommand = vi.fn() + const onOptimisticSend = vi.fn() + const onSwitchToTerminal = vi.fn() + render( + + ) + + await act(async () => { + await mocks.fieldProps?.sessionOptionsSurface?.setOption('model', 'opus') + }) + + expect(mocks.sendNativeChatMessageVerified).toHaveBeenCalledWith( + {}, + 'pty-1', + '/model opus', + expect.any(AbortSignal) + ) + expect(onSlashCommand).toHaveBeenCalledWith('/model opus') + expect(onOptimisticSend).not.toHaveBeenCalled() + expect(mocks.createClaudeModelSwitchConfirmationObserver).toHaveBeenCalledWith({ + ptyId: 'pty-1', + settings: {}, + expectedModelLabel: 'Opus 4.8' + }) + expect(onSwitchToTerminal).not.toHaveBeenCalled() + }) + + it('keeps a successful Claude model change after a conversation in native chat', async () => { + mocks.sendHandle.settleAfterMs = 0 + const onSwitchToTerminal = vi.fn() + render( + + ) + + await act(async () => { + await mocks.fieldProps?.sessionOptionsSurface?.setOption('model', 'fable') + }) + + expect(mocks.sendNativeChatMessageVerified).toHaveBeenCalledWith( + {}, + 'pty-1', + '/model fable', + expect.any(AbortSignal) + ) + expect(mocks.createClaudeModelSwitchConfirmationObserver).toHaveBeenCalledWith({ + ptyId: 'pty-1', + settings: {}, + expectedModelLabel: 'Fable 5' + }) + expect(mocks.confirmationObserver?.arm).toHaveBeenCalledOnce() + expect(mocks.confirmationObserver?.arm.mock.invocationCallOrder[0]).toBeLessThan( + mocks.sendNativeChatMessageVerified.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY + ) + expect(mocks.confirmationObserver?.startDetection).toHaveBeenCalledOnce() + expect(mocks.confirmationObserver?.startDetection.mock.invocationCallOrder[0]).toBeGreaterThan( + mocks.sendNativeChatMessageVerified.mock.invocationCallOrder[0] ?? Number.NEGATIVE_INFINITY + ) + expect(mocks.confirmationObserver?.dispose).toHaveBeenCalledOnce() + expect(onSwitchToTerminal).not.toHaveBeenCalled() + }) + + it('reveals Claude interaction only when the model switch needs user input', async () => { + mocks.sendHandle.settleAfterMs = 0 + mocks.modelSwitchOutcome = 'interaction-required' + const onSwitchToTerminal = vi.fn() + render( + + ) + + await act(async () => { + await mocks.fieldProps?.sessionOptionsSurface?.setOption('model', 'fable') + }) + + expect(mocks.sendNativeChatMessageVerified).toHaveBeenCalledWith( + {}, + 'pty-1', + '/model fable', + expect.any(AbortSignal) + ) + expect(onSwitchToTerminal).toHaveBeenCalledOnce() + }) + + it('waits for the Codex picker command before switching to the terminal', async () => { + mocks.sendHandle.settleAfterMs = 0 + const onSwitchToTerminal = vi.fn() + render( + + ) + + await act(async () => { + await mocks.fieldProps?.sessionOptionsSurface?.setOption('model', '') + }) + + expect(mocks.sendNativeChatMessageVerified).toHaveBeenCalledWith( + {}, + 'pty-1', + '/model', + expect.any(AbortSignal) + ) + expect(onSwitchToTerminal).toHaveBeenCalledOnce() + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 41b254639dc..dd624ba06ed 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -1,15 +1,6 @@ -import { - forwardRef, - useCallback, - useEffect, - useImperativeHandle, - useMemo, - useRef, - useState -} from 'react' +import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react' import { useAppStore } from '../../store' import type { AgentType } from '../../../../shared/agent-status-types' -import { NATIVE_FILE_DROP_TARGET } from '../../../../shared/native-file-drop' import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' import { @@ -43,9 +34,12 @@ import { useNativeChatSkills } from './use-native-chat-skills' import { useNativeChatComposerAttachments } from './use-native-chat-composer-attachments' import { useNativeChatComposerPaste } from './use-native-chat-composer-paste' import { useNativeChatExternalAttachments } from './use-native-chat-external-attachments' -import { dispatchDictationControl } from '../dictation/dictation-control-events' import { useNativeChatComposerKeyDown } from './use-native-chat-composer-keydown' import { useNativeChatSendLifecycle } from './use-native-chat-send-lifecycle' +import { useNativeChatSessionOptions } from './use-native-chat-session-options' +import { useNativeChatFileAttachmentActions } from './use-native-chat-file-attachment-actions' +import { useNativeChatDictationActions } from './use-native-chat-dictation-actions' +import { useNativeChatSessionOptionCommand } from './use-native-chat-session-option-command' // Why: a plain ESC byte is what the agent TUIs read as the interrupt key over a // PTY (matching how xterm forwards Escape). The richer interrupt-intent @@ -79,6 +73,10 @@ export type NativeChatComposerProps = { * a small "Ran /clear" system line — slash commands aren't chat turns and * otherwise leave no visible trace that anything happened. */ onSlashCommand?: (command: string) => void + /** Picker-only agent commands continue in the hosted TUI after dispatch. */ + onSwitchToTerminal?: () => void + /** Reads the hosted TUI's current rendered screen when chat is entered. */ + readTerminalScreen?: () => string | null } export type NativeChatComposerHandle = { @@ -116,7 +114,9 @@ export const NativeChatComposer = forwardRef { - return window.api.ui.onFileDrop((payload) => { - if (payload.target !== NATIVE_FILE_DROP_TARGET.composer) { - return - } - attachExternalPaths(payload.paths) + const { pickAttachment } = useNativeChatFileAttachmentActions(attachExternalPaths) + const { toggleDictation, startHoldDictation, stopHoldDictation } = + useNativeChatDictationActions({ textareaRef, setDictationPressed }) + const { dispatch: dispatchSessionOptionCommand, isDispatching: isDispatchingSessionOption } = + useNativeChatSessionOptionCommand({ + agent, + disabled, + onSlashCommand, + resolveTarget, + setHistory }) - }, [attachExternalPaths]) - const pickAttachment = useCallback(() => { - void (async () => { - const filePath = await window.api.shell.pickAttachment() - if (!filePath) { - return - } - attachExternalPaths([filePath]) - })() - }, [attachExternalPaths]) - - const focusForDictation = useCallback(() => { - textareaRef.current?.focus() - }, []) - - const toggleDictation = useCallback(() => { - focusForDictation() - dispatchDictationControl('toggle') - }, [focusForDictation]) - - const startHoldDictation = useCallback(() => { - setDictationPressed(true) - focusForDictation() - dispatchDictationControl('start') - }, [focusForDictation]) - - const stopHoldDictation = useCallback(() => { - setDictationPressed(false) - dispatchDictationControl('stop') - }, []) + const { surface: sessionOptionsSurface, snapshot: sessionOptionsSnapshot } = + useNativeChatSessionOptions({ + agent, + terminalTabId, + targetPtyId, + dispatchCommand: dispatchSessionOptionCommand, + onAgentPicker: onSwitchToTerminal, + readTerminalScreen + }) const send = useCallback(() => { const text = draft @@ -293,6 +276,12 @@ export const NativeChatComposer = forwardRef ) } diff --git a/src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx new file mode 100644 index 00000000000..2e1b02ac249 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx @@ -0,0 +1,62 @@ +// @vitest-environment happy-dom + +import { cleanup, render, screen } from '@testing-library/react' +import type { ReactNode } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('@/components/ui/button', () => ({ + Button: ({ + children, + variant: _variant, + size: _size, + ...props + }: { + children: ReactNode + variant?: string + size?: string + } & React.ButtonHTMLAttributes) => +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) =>
    {children}
    +})) + +vi.mock('./NativeChatSessionOptionPickers', () => ({ + NativeChatSessionOptionPickers: () =>
    +})) + +import { NativeChatComposerActions } from './NativeChatComposerActions' + +afterEach(() => cleanup()) + +describe('NativeChatComposerActions', () => { + it('places session option pickers immediately beside dictation', () => { + render( + + ) + + const pickers = screen.getByTestId('session-option-pickers') + const dictation = screen.getByRole('button', { name: 'Start dictation' }) + expect(pickers.nextElementSibling).toBe(dictation) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx index fb67917b332..aee0f79c3c0 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx @@ -2,6 +2,11 @@ import { ArrowUp, Mic, Plus, Square } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' +import type { + SessionOptionDescriptor, + SessionOptionsSurface +} from '../../../../shared/native-chat-session-options' +import { NativeChatSessionOptionPickers } from './NativeChatSessionOptionPickers' export type NativeChatComposerActionsProps = { attachDisabled: boolean @@ -16,6 +21,8 @@ export type NativeChatComposerActionsProps = { onDictationHoldEnd: () => void onSend: () => void onStop?: () => void + sessionOptionsSurface: SessionOptionsSurface | null + sessionOptionsSnapshot: SessionOptionDescriptor[] } export function NativeChatComposerActions({ @@ -30,32 +37,43 @@ export function NativeChatComposerActions({ onDictationHoldStart, onDictationHoldEnd, onSend, - onStop + onStop, + sessionOptionsSurface, + sessionOptionsSnapshot }: NativeChatComposerActionsProps): React.JSX.Element { const dictationLabel = isDictating ? translate('components.native-chat.composer.stopDictation', 'Stop dictation') : translate('components.native-chat.composer.startDictation', 'Start dictation') return (
    - - - - - - {translate('components.native-chat.composer.attach', 'Attach file')} - - +
    + + + + + + {translate('components.native-chat.composer.attach', 'Attach file')} + + +
    + {/* Why: keep session controls beside the actions they affect; the + model trigger is ordered last so it sits directly next to dictation. */} +
    diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx new file mode 100644 index 00000000000..8d40a791788 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx @@ -0,0 +1,230 @@ +// @vitest-environment happy-dom + +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => + fallback.replace('{{value0}}', values?.value0 ?? '') +})) + +vi.mock('@/components/ui/button', () => ({ + Button: ({ children, ...props }: React.ButtonHTMLAttributes) => ( + + ) +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) =>
    {children}
    +})) + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: ({ children }: { children: React.ReactNode }) =>
    {children}
    , + DropdownMenuTrigger: ({ + children, + disabled + }: { + children: React.ReactNode + disabled?: boolean + }) =>
    {children}
    , + DropdownMenuContent: ({ children }: { children: React.ReactNode }) =>
    {children}
    , + DropdownMenuLabel: ({ children }: { children: React.ReactNode }) =>
    {children}
    , + DropdownMenuSeparator: () =>
    , + DropdownMenuItem: ({ children, disabled }: React.ButtonHTMLAttributes) => ( + + ), + DropdownMenuCheckboxItem: ({ + children, + disabled + }: React.ButtonHTMLAttributes) => ( + + ), + DropdownMenuRadioGroup: ({ children }: { children: React.ReactNode }) =>
    {children}
    , + DropdownMenuRadioItem: ({ + children, + disabled, + value + }: React.ButtonHTMLAttributes & { value: string }) => ( + + ) +})) + +import { NativeChatSessionOptionPickers } from './NativeChatSessionOptionPickers' + +const surface = { + getSnapshot: vi.fn(() => []), + setOption: vi.fn(), + subscribe: vi.fn(() => vi.fn()) +} + +function model(overrides: Partial = {}): SessionOptionDescriptor { + return { + id: 'model', + label: 'Model', + category: 'model', + kind: { + type: 'select', + currentValue: 'opus', + choices: [ + { value: 'opus', label: 'Opus 4.8' }, + { value: 'sonnet', label: 'Sonnet 5' } + ] + }, + valueSource: 'applied', + settable: true, + ...overrides + } +} + +const effort: SessionOptionDescriptor = { + id: 'effort', + label: 'Effort', + category: 'thought_level', + kind: { + type: 'select', + currentValue: 'high', + choices: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + }, + valueSource: 'applied', + settable: true +} + +const fast: SessionOptionDescriptor = { + id: 'fastMode', + label: 'Fast mode', + category: 'mode', + kind: { type: 'boolean', currentValue: true }, + valueSource: 'applied', + settable: true +} + +afterEach(() => cleanup()) + +describe('NativeChatSessionOptionPickers', () => { + it('renders model and joined option labels, and hides an empty options pill', () => { + const { rerender } = render( + + ) + expect(screen.getByRole('button', { name: 'Model' }).textContent).toContain('Model: Opus 4.8') + expect(screen.getByRole('button', { name: 'Effort' }).textContent).toContain( + 'Effort: High · Fast' + ) + expect( + screen + .getByRole('button', { name: 'Effort' }) + .compareDocumentPosition(screen.getByRole('button', { name: 'Model' })) & + Node.DOCUMENT_POSITION_FOLLOWING + ).not.toBe(0) + + rerender( + + ) + expect(screen.queryByRole('button', { name: 'Effort' })).toBeNull() + }) + + it('names a lone unknown effort control explicitly', () => { + render( + + ) + + expect(screen.getByRole('button', { name: 'Effort' }).textContent).toContain('Effort') + }) + + it('disables both picker triggers while the agent is working', () => { + render( + + ) + expect( + screen.getByRole('button', { name: 'Model' }).parentElement?.getAttribute('data-disabled') + ).toBe('true') + expect( + screen.getByRole('button', { name: 'Effort' }).parentElement?.getAttribute('data-disabled') + ).toBe('true') + }) + + it('does not duplicate titles for unknown values or misname generic controls', () => { + const { rerender } = render( + + ) + expect(screen.getByRole('button', { name: 'Model' }).textContent).toContain('Model') + expect(screen.getByRole('button', { name: 'Model' }).textContent).not.toContain('Model: Model') + expect(screen.getByRole('button', { name: 'Effort' }).textContent).not.toContain( + 'Effort: Effort' + ) + + rerender( + + ) + expect(screen.getByRole('button', { name: 'Session options' }).textContent).toContain('Fast') + expect(screen.queryByRole('button', { name: 'Effort' })).toBeNull() + }) + + it('shows the unconfirmed hint for dispatched values', () => { + render( + + ) + expect(screen.getByText('Sent to the agent — not confirmed')).not.toBeNull() + }) + + it('renders agent-picker routes as one action instead of radio choices', () => { + render( + + ) + expect(screen.getByRole('button', { name: 'Choose in agent picker…' })).not.toBeNull() + expect(screen.queryByText('GPT-5.5')).toBeNull() + expect(screen.queryByText('GPT-5.2 Codex')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx new file mode 100644 index 00000000000..15852d27db5 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx @@ -0,0 +1,276 @@ +import { memo, useState } from 'react' +import { ChevronDown } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' +import type { + SessionOptionDescriptor, + SessionOptionsSurface, + SessionOptionValue +} from '../../../../shared/native-chat-session-options' +import { + nativeChatModelPillLabel, + nativeChatOptionsPillLabel, + nativeChatOptionsPillTitle, + nativeChatSessionChoiceLabel, + nativeChatSessionOptionDisabledReason, + nativeChatSessionOptionLabel +} from './native-chat-session-option-labels' + +export type NativeChatSessionOptionPickersProps = { + surface: SessionOptionsSurface | null + snapshot: SessionOptionDescriptor[] + isWorking: boolean +} + +const CATEGORY_ORDER: Record = { + thought_level: 0, + model_config: 1, + mode: 2 +} + +function sortedOptions(snapshot: readonly SessionOptionDescriptor[]): SessionOptionDescriptor[] { + return snapshot + .filter((descriptor) => descriptor.category !== 'model') + .sort((left, right) => { + const leftOrder = CATEGORY_ORDER[left.category ?? ''] ?? 3 + const rightOrder = CATEGORY_ORDER[right.category ?? ''] ?? 3 + return leftOrder - rightOrder + }) +} + +function PickerTooltipContent(props: { + label: string + disabledReason?: string | null + dispatched: boolean +}): React.JSX.Element { + return ( +
    +
    {props.disabledReason ?? props.label}
    + {props.dispatched ? ( +
    + {translate( + 'components.native-chat.composer.sentNotConfirmed', + 'Sent to the agent — not confirmed' + )} +
    + ) : null} +
    + ) +} + +function PickerTrigger(props: { + label: string + tooltipLabel: string + disabled: boolean + disabledReason?: string | null + dispatched: boolean +}): React.JSX.Element { + return ( + + + + + + + + + + + ) +} + +function ChoiceBody(props: { label: string; description?: string }): React.JSX.Element { + return ( +
    +
    {props.label}
    + {props.description ? ( +
    {props.description}
    + ) : null} +
    + ) +} + +function actionLabel(descriptor: SessionOptionDescriptor): string { + if (descriptor.action?.type === 'agent-picker') { + return translate( + 'components.native-chat.composer.chooseInAgentPicker', + 'Choose in agent picker…' + ) + } + return translate('components.native-chat.composer.toggleOption', 'Toggle {{value0}}', { + value0: nativeChatSessionOptionLabel(descriptor).toLowerCase() + }) +} + +function DescriptorMenuRows(props: { + descriptor: SessionOptionDescriptor + pending: boolean + setValue: (value: SessionOptionValue) => void +}): React.JSX.Element { + const { descriptor, pending, setValue } = props + if (descriptor.action) { + return ( + + setValue( + descriptor.kind.type === 'boolean' + ? !(descriptor.kind.currentValue ?? false) + : (descriptor.kind.currentValue ?? '') + ) + } + > + {actionLabel(descriptor)} + + ) + } + if (descriptor.kind.type === 'boolean') { + return ( + setValue(checked === true)} + > + {nativeChatSessionOptionLabel(descriptor)} + + ) + } + return ( + setValue(value)} + > + {descriptor.kind.choices.map((choice) => ( + + + + ))} + + ) +} + +function NativeChatSessionOptionPickersInner({ + surface, + snapshot, + isWorking +}: NativeChatSessionOptionPickersProps): React.JSX.Element | null { + const [pendingId, setPendingId] = useState(null) + const model = snapshot.find((descriptor) => descriptor.category === 'model') + const options = sortedOptions(snapshot) + if (!surface || !model) { + return null + } + + const setOption = (descriptor: SessionOptionDescriptor, value: SessionOptionValue): void => { + setPendingId(descriptor.id) + void surface + .setOption(descriptor.id, value) + .catch((error) => { + toast.error( + translate( + 'components.native-chat.composer.optionUpdateFailed', + 'Could not update option' + ), + { description: error instanceof Error ? error.message : String(error) } + ) + }) + .finally(() => setPendingId(null)) + } + + const modelReason = nativeChatSessionOptionDisabledReason(model.disabledReason) + const modelTooltip = translate('components.native-chat.composer.model', 'Model') + const optionsTooltip = nativeChatOptionsPillTitle(options) + const optionsReason = + options.length > 0 && options.every((descriptor) => !descriptor.settable) + ? nativeChatSessionOptionDisabledReason(options[0]?.disabledReason) + : null + + return ( +
    + {options.length > 0 ? ( + + descriptor.valueSource === 'dispatched')} + /> + + {options.map((descriptor, index) => { + const reason = nativeChatSessionOptionDisabledReason(descriptor.disabledReason) + return ( +
    + {index > 0 ? : null} + {nativeChatSessionOptionLabel(descriptor)} + {reason && !descriptor.settable ? ( + {reason} + ) : null} + setOption(descriptor, value)} + /> +
    + ) + })} +
    +
    + ) : null} + + + + {modelReason && !model.settable ? ( + {modelReason} + ) : null} + setOption(model, value)} + /> + + +
    + ) +} + +export const NativeChatSessionOptionPickers = memo(NativeChatSessionOptionPickersInner) diff --git a/src/renderer/src/components/native-chat/NativeChatView.tsx b/src/renderer/src/components/native-chat/NativeChatView.tsx index 8caf7d835c8..d2267281eca 100644 --- a/src/renderer/src/components/native-chat/NativeChatView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatView.tsx @@ -48,14 +48,10 @@ import { useNativeChatContextMenu } from './use-native-chat-context-menu' import type { NativeChatContextMenuActions } from './use-native-chat-context-menu' -import { - resolveNativeChatFileLink, - resolveNativeChatFileLinkContext -} from './native-chat-file-link' +import { resolveNativeChatFileLinkContext } from './native-chat-file-link' import { selectNativeChatRuntimeEnvironmentId } from './native-chat-runtime-owner' import { useNativeChatPasteBridge } from './use-native-chat-paste-bridge' -import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown' -import { openDetectedFilePath } from '@/components/terminal-pane/terminal-file-open-routing' +import { useNativeChatFileLinkClick } from './use-native-chat-file-link-click' export type NativeChatViewProps = { /** The terminal tab hosting the agent. paneKey is `${tabId}:${leafId}`. */ @@ -70,17 +66,12 @@ export type NativeChatViewProps = { resolvedAgent?: TuiAgent | null /** Return this pane to the hosted terminal surface. */ onSwitchToTerminal?: () => void + /** Current xterm screen reader used to recover agent-reported session state. */ + readTerminalScreen?: () => string | null contextMenuActions?: Omit } -/** - * Native chat surface for an agent terminal. Resolves the pane to its agent + - * session id, streams the assembled conversation via the U4 live-session hook, - * and renders the message list, live status, and all empty/loading/error - * states. When no session id is known yet the hook surfaces live hook state on - * an empty transcript; a true scrollback-scrape fallback (U6) is wired but only - * runs when scrollback is obtainable — it degrades to the empty state otherwise. - */ +/** Resolves an agent terminal into its native conversation and composer UI. */ export default function NativeChatView({ terminalTabId, paneKey: preferredPaneKey, @@ -88,6 +79,7 @@ export default function NativeChatView({ launchAgent, resolvedAgent, onSwitchToTerminal, + readTerminalScreen, contextMenuActions }: NativeChatViewProps): React.JSX.Element { // Select only this tab's status entry (shallow-compared) so an unrelated @@ -120,6 +112,7 @@ export default function NativeChatView({ targetPtyId={targetPtyId} terminalTabId={terminalTabId} onSwitchToTerminal={onSwitchToTerminal} + readTerminalScreen={readTerminalScreen} contextMenuActions={contextMenuActions} /> )} @@ -135,6 +128,7 @@ function NativeChatResolvedView({ targetPtyId, terminalTabId, onSwitchToTerminal, + readTerminalScreen, contextMenuActions }: { paneKey: string @@ -144,6 +138,7 @@ function NativeChatResolvedView({ targetPtyId: string | null terminalTabId: string onSwitchToTerminal?: () => void + readTerminalScreen?: () => string | null contextMenuActions?: Omit }): React.JSX.Element { // Primitive owner selection (no useShallow): routes the pane's read/subscribe to @@ -356,24 +351,7 @@ function NativeChatResolvedView({ setWorkingInterrupted(true) interactiveSend.cancel() }, [interactiveSend]) - const openNativeChatFileLink = useCallback( - (event, href) => { - const target = resolveNativeChatFileLink(href, fileLinkContext) - if (!target || !fileLinkContext) { - return - } - event.preventDefault() - event.stopPropagation() - openDetectedFilePath(target.absolutePath, target.line, target.column, { - worktreeId: fileLinkContext.worktreeId, - worktreePath: fileLinkContext.worktreePath, - runtimeEnvironmentId: fileLinkContext.runtimeEnvironmentId, - openWithSystemDefault: event.shiftKey - }) - }, - [fileLinkContext] - ) - const nativeChatFileLinkClick = fileLinkContext ? openNativeChatFileLink : undefined + const nativeChatFileLinkClick = useNativeChatFileLinkClick(fileLinkContext) // Chat-only font zoom via Cmd/Ctrl +/-/0, gated to the live conversation so // the chord is inert on the loading/empty/error states and elsewhere. @@ -460,6 +438,8 @@ function NativeChatResolvedView({ onOptimisticSend={onOptimisticSend} onOptimisticSendCanceled={onOptimisticSendCanceled} onSlashCommand={onSlashCommand} + onSwitchToTerminal={onSwitchToTerminal} + readTerminalScreen={readTerminalScreen} /> )} {contextMenu.menu} diff --git a/src/renderer/src/components/native-chat/claude-model-switch-confirmation.test.ts b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.test.ts new file mode 100644 index 00000000000..e57f42e2efe --- /dev/null +++ b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.test.ts @@ -0,0 +1,176 @@ +// @vitest-environment happy-dom + +import { describe, expect, it, vi } from 'vitest' +import { + createClaudeModelSwitchConfirmationObserver, + hasClaudeModelSwitchConfirmation +} from './claude-model-switch-confirmation' + +describe('Claude model switch confirmation detection', () => { + it('recognizes the rendered cache warning across ANSI and wrapped whitespace', () => { + expect( + hasClaudeModelSwitchConfirmation( + '\u001b[1mSwitch model?\u001b[0m\r\nThis\u001b[9Gconversation\u001b[22Gis\u001b[25Gcached\r\nfor the current model.' + ) + ).toBe(true) + expect(hasClaudeModelSwitchConfirmation('Set model to Haiku 4.5')).toBe(false) + }) + + it('reports matching success output split across PTY chunks', async () => { + const dataObserver = { current: (_data: string): void => {} } + const unsubscribe = vi.fn(() => {}) + const observer = createClaudeModelSwitchConfirmationObserver({ + ptyId: 'pty-1', + settings: {}, + expectedModelLabel: 'Fable 5', + subscribeToData: (watcher) => { + dataObserver.current = watcher + return unsubscribe + }, + timeoutMs: 100 + }) + + await observer.ready + dataObserver.current('historical Set model to Fable 5') + observer.arm() + dataObserver.current('\u001b[2mSet\u001b[10Gmo') + dataObserver.current('del\u001b[16Gto\u001b[19GFable 5 and saved as your default\u001b[0m') + + await expect(observer.result).resolves.toBe('applied') + expect(unsubscribe).toHaveBeenCalledOnce() + }) + + it('accepts the exact cached-history confirmation once and keeps observing', async () => { + const dataObserver = { current: (_data: string): void => {} } + const submitConfirmation = vi.fn() + const observer = createClaudeModelSwitchConfirmationObserver({ + ptyId: 'pty-1', + settings: {}, + expectedModelLabel: 'Fable 5', + subscribeToData: (watcher) => { + dataObserver.current = watcher + return vi.fn(() => {}) + }, + submitConfirmation, + timeoutMs: 100 + }) + + await observer.ready + observer.arm() + dataObserver.current('Switch model? This\u001b[9Gconversation\u001b[22Gis\u001b[25Gcached ') + dataObserver.current('for\u001b[36Gthe\u001b[40Gcurrent\u001b[48Gmodel.') + dataObserver.current(' redraw of the same Switch model? prompt') + + expect(submitConfirmation).toHaveBeenCalledOnce() + dataObserver.current('Set model to Fable 5 and saved as your default for new sessions') + + await expect(observer.result).resolves.toBe('applied') + }) + + it('reports a canceled model switch without opening an interaction', async () => { + const dataObserver = { current: (_data: string): void => {} } + const observer = createClaudeModelSwitchConfirmationObserver({ + ptyId: 'pty-1', + settings: {}, + expectedModelLabel: 'Haiku', + subscribeToData: (watcher) => { + dataObserver.current = watcher + return vi.fn(() => {}) + }, + timeoutMs: 100 + }) + + await observer.ready + observer.arm() + dataObserver.current('\u001b[2mKept\u001b[9Gmodel\u001b[16Gas ') + dataObserver.current('Fable 5\u001b[0m') + + await expect(observer.result).resolves.toBe('rejected') + }) + + it('requests interaction for Fable one-time usage-credit consent', async () => { + const dataObserver = { current: (_data: string): void => {} } + const observer = createClaudeModelSwitchConfirmationObserver({ + ptyId: 'pty-1', + settings: {}, + expectedModelLabel: 'Fable 5', + subscribeToData: (watcher) => { + dataObserver.current = watcher + return vi.fn(() => {}) + }, + timeoutMs: 100 + }) + + await observer.ready + observer.arm() + dataObserver.current('Fable 5 uses usage credits and needs a one-time consent — ') + dataObserver.current('pick Fable from /model in an interactive session to set it up') + + await expect(observer.result).resolves.toBe('interaction-required') + }) + + it('reports unknown when the PTY observer cannot be established', async () => { + const observer = createClaudeModelSwitchConfirmationObserver({ + ptyId: 'pty-1', + settings: {}, + expectedModelLabel: 'Fable 5', + subscribeToData: () => Promise.reject(new Error('unavailable')), + timeoutMs: 100 + }) + + await observer.ready + observer.arm() + await expect(observer.result).resolves.toBe('unknown') + }) + + it('reports unknown on timeout instead of requesting the terminal', async () => { + vi.useFakeTimers() + try { + const observer = createClaudeModelSwitchConfirmationObserver({ + ptyId: 'pty-1', + settings: {}, + expectedModelLabel: 'Fable 5', + subscribeToData: () => vi.fn(() => {}), + timeoutMs: 100 + }) + + await observer.ready + observer.arm() + observer.startDetection() + await vi.advanceTimersByTimeAsync(100) + await expect(observer.result).resolves.toBe('unknown') + } finally { + vi.useRealTimers() + } + }) + + it('does not start the detection timeout until startDetection() is called', async () => { + vi.useFakeTimers() + try { + let settled = false + const observer = createClaudeModelSwitchConfirmationObserver({ + ptyId: 'pty-1', + settings: {}, + expectedModelLabel: 'Fable 5', + subscribeToData: () => vi.fn(() => {}), + timeoutMs: 100 + }) + void observer.result.then(() => { + settled = true + }) + + await observer.ready + observer.arm() + // Simulates slow SSH send latency between arm() and command delivery: the + // clock must not run yet, or a successful switch would time out to unknown. + await vi.advanceTimersByTimeAsync(500) + expect(settled).toBe(false) + + observer.startDetection() + await vi.advanceTimersByTimeAsync(100) + await expect(observer.result).resolves.toBe('unknown') + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts new file mode 100644 index 00000000000..64828018181 --- /dev/null +++ b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts @@ -0,0 +1,199 @@ +import type { GlobalSettings } from '../../../../shared/types' +import { subscribeToPtyData } from '../terminal-pane/pty-data-sidecar-subscriptions' +import { isRemoteRuntimePtyId, sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' +import { subscribeToRuntimeTerminalData } from '@/runtime/runtime-terminal-stream' +import { NATIVE_CHAT_SUBMIT } from './native-chat-send' +import { stripScrollbackAnsi } from './native-chat-scrape-fallback' + +const DETECTION_TIMEOUT_MS = 5_000 +const MAX_OBSERVED_BYTES = 64 * 1024 + +type SubscribeToData = (watcher: (data: string) => void) => Promise<() => void> | (() => void) + +export type ClaudeModelSwitchOutcome = 'applied' | 'rejected' | 'interaction-required' | 'unknown' + +export type ClaudeModelSwitchConfirmationObserver = { + ready: Promise + result: Promise + arm(): void + startDetection(): void + dispose(): void +} + +export function hasClaudeModelSwitchConfirmation(buffer: string): boolean { + const text = compactTerminalText(buffer) + return ( + text.includes('switchmodel?') && text.includes('thisconversationiscachedforthecurrentmodel') + ) +} + +function compactTerminalText(buffer: string): string { + // Why: Claude positions TUI words with cursor-column escapes instead of + // emitting literal spaces, so matching must not depend on rendered gaps. + return stripScrollbackAnsi(buffer).replace(/\s+/g, '').toLowerCase() +} + +function hasClaudeModelSwitchSuccess(buffer: string, modelLabel: string): boolean { + const text = compactTerminalText(buffer) + const marker = `setmodelto${modelLabel.replace(/\s+/g, '').toLowerCase()}` + return text.includes(marker) +} + +function hasClaudeModelSwitchRejection(buffer: string): boolean { + return compactTerminalText(buffer).includes('keptmodelas') +} + +function hasClaudeModelSwitchInteraction(buffer: string): boolean { + const text = compactTerminalText(buffer) + return ( + text.includes('fable5usesusagecreditsandneedsaone-timeconsent') || + text.includes('pickfablefrom/modelinaninteractivesessiontosetitup') || + (text.includes('switchtofable5?') && text.includes('usagecredits')) + ) +} + +function subscribeToClaudeModelSwitchData(args: { + ptyId: string + settings: Pick | null | undefined + subscribeToData?: SubscribeToData + watcher: (data: string) => void +}): Promise<() => void> | (() => void) { + if (args.subscribeToData) { + return args.subscribeToData(args.watcher) + } + if (isRemoteRuntimePtyId(args.ptyId)) { + return subscribeToRuntimeTerminalData( + args.settings, + args.ptyId, + `desktop:native-chat-model-switch:${args.ptyId}`, + args.watcher, + { startAtLiveTail: true } + ) + } + return subscribeToPtyData(args.ptyId, args.watcher) +} + +export function createClaudeModelSwitchConfirmationObserver(args: { + ptyId: string + settings: Pick | null | undefined + expectedModelLabel: string | null + subscribeToData?: SubscribeToData + submitConfirmation?: () => boolean | void + timeoutMs?: number +}): ClaudeModelSwitchConfirmationObserver { + let armed = false + let settled = false + let confirmationSubmitted = false + let observed = '' + let timeout: ReturnType | null = null + let unsubscribe: (() => void) | null = null + let resolveResult!: (outcome: ClaudeModelSwitchOutcome) => void + let resolveReady!: () => void + const result = new Promise((resolve) => { + resolveResult = resolve + }) + const ready = new Promise((resolve) => { + resolveReady = resolve + }) + + const finish = (outcome: ClaudeModelSwitchOutcome): void => { + if (settled) { + return + } + settled = true + if (timeout !== null) { + clearTimeout(timeout) + timeout = null + } + unsubscribe?.() + unsubscribe = null + resolveResult(outcome) + } + + const scheduleTimeout = (): void => { + if (timeout !== null) { + clearTimeout(timeout) + } + timeout = setTimeout(() => finish('unknown'), args.timeoutMs ?? DETECTION_TIMEOUT_MS) + } + + const observeData = (data: string): void => { + if (!armed || settled) { + return + } + observed = `${observed}${data}`.slice(-MAX_OBSERVED_BYTES) + if (args.expectedModelLabel && hasClaudeModelSwitchSuccess(observed, args.expectedModelLabel)) { + finish('applied') + return + } + if (hasClaudeModelSwitchRejection(observed)) { + finish('rejected') + return + } + if (hasClaudeModelSwitchInteraction(observed)) { + finish('interaction-required') + return + } + if (!confirmationSubmitted && hasClaudeModelSwitchConfirmation(observed)) { + confirmationSubmitted = true + try { + // Why: the picker selection already expresses consent to switch; this + // exact Claude warning defaults to “Yes” and needs only one Enter. + const accepted = args.submitConfirmation + ? args.submitConfirmation() !== false + : sendRuntimePtyInput(args.settings, args.ptyId, NATIVE_CHAT_SUBMIT) + if (!accepted) { + finish('unknown') + return + } + scheduleTimeout() + } catch { + finish('unknown') + } + } + } + + try { + const subscription = subscribeToClaudeModelSwitchData({ + ptyId: args.ptyId, + settings: args.settings, + subscribeToData: args.subscribeToData, + watcher: observeData + }) + void Promise.resolve(subscription) + .then((dispose) => { + if (settled) { + dispose() + } else { + unsubscribe = dispose + } + }) + .catch(() => finish('unknown')) + .finally(resolveReady) + } catch { + finish('unknown') + resolveReady() + } + + return { + ready, + result, + arm: () => { + if (settled || armed) { + return + } + armed = true + }, + startDetection: () => { + // Why: measure the detection window from when the command is actually + // delivered, not from arm(). On SSH/remote the body+Enter round-trips can + // otherwise burn the timeout before the agent has even responded, turning + // a successful switch into a false "could not verify the model change". + if (settled) { + return + } + scheduleTimeout() + }, + dispose: () => finish('unknown') + } +} diff --git a/src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts b/src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts new file mode 100644 index 00000000000..e095adf1132 --- /dev/null +++ b/src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { readClaudeSessionOptionsFromTerminalScreen } from './claude-terminal-session-options' + +describe('Claude terminal session option detection', () => { + it('reads the current model and effort from Claude header chrome', () => { + const screen = + '\u001b[1mClaude Code\u001b[0m v2.1.211\r\n' + + '\u001b[38;2;102;102;102mOpus 4.8 with high effort · API Usage Billing\r\n' + + '~/Documents/projects/orca' + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({ + model: 'opus', + effort: 'high' + }) + }) + + it('reads a Claude header whose xterm serialization joins the version to the title', () => { + const screen = + '\u001b[?1049h\u001b[H▐▛███▜▌Claude Codev2.1.211\r\n' + + '▝▜█████▛▘Sonnet 5 with medium effort · API Usage Billing' + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({ + model: 'sonnet', + effort: 'medium' + }) + }) + + it('does not mistake old conversation output for the current model', () => { + const screen = + 'Set model to Opus 4.8 and saved as your default\r\n' + + 'Claude Code v2.1.211\r\n' + + 'Sonnet 5 with medium effort · API Usage Billing' + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({ + model: 'sonnet', + effort: 'medium' + }) + }) + + it('reports an option-less Haiku model without inventing effort', () => { + expect( + readClaudeSessionOptionsFromTerminalScreen( + 'Claude Code v2.1.211\r\nHaiku · API Usage Billing\r\n~/repo' + ) + ).toEqual({ model: 'haiku' }) + }) + + it('ignores text without Claude header chrome', () => { + expect( + readClaudeSessionOptionsFromTerminalScreen('I recommend Opus 4.8 for this task.') + ).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/claude-terminal-session-options.ts b/src/renderer/src/components/native-chat/claude-terminal-session-options.ts new file mode 100644 index 00000000000..7a1e5b8fbd8 --- /dev/null +++ b/src/renderer/src/components/native-chat/claude-terminal-session-options.ts @@ -0,0 +1,54 @@ +import { getAgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog' +import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' +import { stripScrollbackAnsi } from './native-chat-scrape-fallback' + +const EFFORT_ID_BY_LABEL: Record = { + low: 'low', + medium: 'medium', + high: 'high', + 'extra high': 'xhigh', + xhigh: 'xhigh', + max: 'max' +} + +function normalizedScreenLines(screen: string): string[] { + return stripScrollbackAnsi(screen) + .split('\n') + .map((line) => line.replace(/\s+/g, ' ').trim()) +} + +export function readClaudeSessionOptionsFromTerminalScreen( + screen: string | null | undefined +): Record | null { + if (!screen) { + return null + } + const lines = normalizedScreenLines(screen) + const headerIndex = lines.findIndex((line) => + // xterm serialization can remove cursor-positioning cells between the + // product name and version, producing `Claude Codev2.1.211`. + /\bClaude Code\s*v?\d+(?:\.\d+){1,2}\b/i.test(line) + ) + if (headerIndex < 0) { + return null + } + // Why: only Claude's fixed header rows describe live state; conversation + // text and old command confirmations elsewhere in the buffer can be stale. + const header = lines.slice(headerIndex, headerIndex + 3).join(' ') + const catalog = getAgentSessionOptionCatalog('claude') + const model = [...(catalog?.models ?? [])] + .sort((left, right) => right.label.length - left.label.length) + .find(({ label }) => header.toLowerCase().includes(label.toLowerCase())) + if (!model) { + return null + } + const result: Record = { model: model.id } + const effortLabel = header.match( + /\bwith\s+(low|medium|high|extra high|xhigh|max)\s+effort\b/i + )?.[1] + const effort = effortLabel ? EFFORT_ID_BY_LABEL[effortLabel.toLowerCase()] : undefined + if (effort && model.options.some((option) => option.id === 'effort')) { + result.effort = effort + } + return result +} diff --git a/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts b/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts new file mode 100644 index 00000000000..771daacd23e --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts @@ -0,0 +1,340 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + clearNativeChatSessionOptionCacheForTests, + seedNativeChatAppliedSessionOptions +} from './native-chat-session-option-cache' +import { createNativeChatPtySessionOptions } from './native-chat-pty-session-options' + +describe('native chat PTY session options', () => { + beforeEach(() => clearNativeChatSessionOptionCacheForTests()) + + it('starts attached sessions unknown and hides model-scoped options', () => { + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: vi.fn() + })! + expect(surface.getSnapshot()).toHaveLength(1) + expect(surface.getSnapshot()[0]).toMatchObject({ + id: 'model', + valueSource: 'unknown' + }) + }) + + it('uses model and effort reported by the live Claude terminal', () => { + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + reportedValues: { model: 'opus', effort: 'medium' }, + dispatchCommand: vi.fn() + })! + + expect(surface.getSnapshot()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'model', + valueSource: 'reported', + kind: expect.objectContaining({ currentValue: 'opus' }) + }), + expect.objectContaining({ + id: 'effort', + valueSource: 'reported', + kind: expect.objectContaining({ currentValue: 'medium' }) + }) + ]) + ) + }) + + it('restores launch-backed values through the tab-to-PTY cache handoff', () => { + seedNativeChatAppliedSessionOptions('tab-1', 'claude', { + model: 'opus', + effort: 'xhigh' + }) + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + fallbackScopeKey: 'tab-1', + mode: 'live', + dispatchCommand: vi.fn() + })! + expect(surface.getSnapshot()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'model', valueSource: 'applied' }), + expect.objectContaining({ id: 'effort', valueSource: 'applied' }), + expect.objectContaining({ id: 'fastMode', valueSource: 'unknown' }) + ]) + ) + }) + + it('dispatches a Claude effort setter and publishes the full snapshot', async () => { + seedNativeChatAppliedSessionOptions('pty-1', 'claude', { + model: 'opus', + effort: 'xhigh' + }) + const dispatch = vi.fn() + const persist = vi.fn() + const listener = vi.fn() + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: dispatch, + persistSelection: persist + })! + surface.subscribe(listener) + + const effortResult = await surface.setOption('effort', 'high') + expect(dispatch).toHaveBeenCalledWith('/effort high') + expect(effortResult.snapshot.map(({ id }) => id)).toEqual(['model', 'effort', 'fastMode']) + expect(effortResult.snapshot.find(({ id }) => id === 'effort')).toMatchObject({ + valueSource: 'dispatched', + kind: { currentValue: 'high' } + }) + expect(listener).toHaveBeenCalledOnce() + expect(listener.mock.calls.every(([snapshot]) => Array.isArray(snapshot))).toBe(true) + expect(persist).toHaveBeenCalledWith({ + modelId: 'opus', + optionId: 'effort', + value: 'high' + }) + }) + + it('keeps a normal Claude model choice native and dispatches the selected model', async () => { + seedNativeChatAppliedSessionOptions('pty-1', 'claude', { + model: 'sonnet', + effort: 'high' + }) + const dispatch = vi.fn().mockResolvedValue({ outcome: 'applied' }) + const onAgentPicker = vi.fn() + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: dispatch, + onAgentPicker + })! + expect(surface.getSnapshot()[0]?.action).toBeUndefined() + + const result = await surface.setOption('model', 'fable') + + expect(dispatch).toHaveBeenCalledWith('/model fable', { + detectAgentInteraction: 'claude-model-switch-confirmation', + expectedChoiceLabel: 'Fable 5' + }) + expect(onAgentPicker).not.toHaveBeenCalled() + expect(result.snapshot[0]).toMatchObject({ + valueSource: 'dispatched', + kind: { currentValue: 'fable' } + }) + }) + + it('reveals the terminal only when Claude actually requires model-switch interaction', async () => { + seedNativeChatAppliedSessionOptions('pty-1', 'claude', { model: 'sonnet' }) + const dispatch = vi.fn().mockResolvedValue({ outcome: 'interaction-required' }) + const onAgentPicker = vi.fn() + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: dispatch, + onAgentPicker + })! + + const result = await surface.setOption('model', 'haiku') + + expect(dispatch).toHaveBeenCalledWith('/model haiku', { + detectAgentInteraction: 'claude-model-switch-confirmation', + expectedChoiceLabel: 'Haiku' + }) + expect(onAgentPicker).toHaveBeenCalledOnce() + expect(result.snapshot[0]).toMatchObject({ valueSource: 'unknown' }) + }) + + it('keeps the prior model and persistence when Claude rejects the switch', async () => { + seedNativeChatAppliedSessionOptions('pty-1', 'claude', { + model: 'fable', + effort: 'high' + }) + const persist = vi.fn() + const onAgentPicker = vi.fn() + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: vi.fn().mockResolvedValue({ outcome: 'rejected' }), + persistSelection: persist, + onAgentPicker + })! + + await expect(surface.setOption('model', 'haiku')).rejects.toThrow( + 'Claude kept the current model.' + ) + + expect(surface.getSnapshot()[0]).toMatchObject({ + valueSource: 'applied', + kind: { currentValue: 'fable' } + }) + expect(persist).not.toHaveBeenCalled() + expect(onAgentPicker).not.toHaveBeenCalled() + }) + + it('stays native and clears stale truth when the switch cannot be verified', async () => { + seedNativeChatAppliedSessionOptions('pty-1', 'claude', { + model: 'fable', + effort: 'high' + }) + const persist = vi.fn() + const onAgentPicker = vi.fn() + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: vi.fn().mockResolvedValue({ outcome: 'unknown' }), + persistSelection: persist, + onAgentPicker + })! + + await expect(surface.setOption('model', 'haiku')).rejects.toThrow( + 'Could not verify the model change; open the terminal to check.' + ) + + expect(surface.getSnapshot()).toHaveLength(1) + expect(surface.getSnapshot()[0]).toMatchObject({ valueSource: 'unknown' }) + expect(persist).not.toHaveBeenCalled() + expect(onAgentPicker).not.toHaveBeenCalled() + }) + + it('keeps an unknown toggle unknown after the one-shot action', async () => { + seedNativeChatAppliedSessionOptions('pty-1', 'claude', { + model: 'opus', + effort: 'high' + }) + const dispatch = vi.fn() + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: dispatch + })! + const fastBefore = surface.getSnapshot().find(({ id }) => id === 'fastMode') + expect(fastBefore?.action?.type).toBe('toggle-command') + + const result = await surface.setOption('fastMode', true) + expect(dispatch).toHaveBeenCalledWith('/fast') + expect(result.snapshot.find(({ id }) => id === 'fastMode')).toMatchObject({ + valueSource: 'unknown', + action: { type: 'toggle-command' } + }) + }) + + it('hands Codex model changes to the TUI picker and drops stale truth', async () => { + seedNativeChatAppliedSessionOptions('pty-1', 'codex', { + model: 'gpt-5.5', + effort: 'high' + }) + const dispatch = vi.fn() + const onAgentPicker = vi.fn() + const surface = createNativeChatPtySessionOptions({ + agent: 'codex', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: dispatch, + onAgentPicker + })! + expect(surface.getSnapshot().find(({ id }) => id === 'effort')?.action?.type).toBe( + 'agent-picker' + ) + + const result = await surface.setOption('effort', 'xhigh') + expect(dispatch).toHaveBeenCalledWith('/model') + expect(onAgentPicker).toHaveBeenCalledOnce() + expect(result.snapshot).toHaveLength(1) + expect(result.snapshot[0]).toMatchObject({ valueSource: 'unknown' }) + }) + + it('tracks typed effort commands and downgrades typed toggles', () => { + seedNativeChatAppliedSessionOptions('pty-1', 'claude', { + model: 'opus', + effort: 'xhigh' + }) + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: vi.fn() + })! + surface.recordOutgoingCommand('/effort high') + expect(surface.getSnapshot().find(({ id }) => id === 'effort')).toMatchObject({ + valueSource: 'dispatched', + kind: { currentValue: 'high' } + }) + surface.recordOutgoingCommand('/fast') + expect(surface.getSnapshot().find(({ id }) => id === 'fastMode')).toMatchObject({ + valueSource: 'unknown' + }) + }) + + it('switches to the terminal and drops stale truth for a typed picker command', () => { + seedNativeChatAppliedSessionOptions('pty-1', 'claude', { + model: 'opus', + effort: 'xhigh' + }) + const onAgentPicker = vi.fn() + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: vi.fn(), + onAgentPicker + })! + + surface.recordOutgoingCommand('/model') + + expect(onAgentPicker).toHaveBeenCalledOnce() + expect(surface.getSnapshot()).toHaveLength(1) + expect(surface.getSnapshot()[0]).toMatchObject({ valueSource: 'unknown' }) + }) + + it('passes an unknown persisted model through as a literal choice', () => { + seedNativeChatAppliedSessionOptions('pty-1', 'claude', { + model: 'future-model' + }) + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: vi.fn() + })! + const model = surface.getSnapshot()[0] + expect(model.kind).toMatchObject({ + currentValue: 'future-model', + choices: expect.arrayContaining([{ value: 'future-model', label: 'future-model' }]) + }) + }) + + it('recomposes Cursor model slugs for live option changes', async () => { + seedNativeChatAppliedSessionOptions('pty-1', 'cursor', { + model: 'gpt-5.3-codex', + effort: 'medium', + fastMode: true + }) + const dispatch = vi.fn() + const surface = createNativeChatPtySessionOptions({ + agent: 'cursor', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: dispatch + })! + expect(surface.getSnapshot().find(({ id }) => id === 'effort')?.settable).toBe(true) + + await surface.setOption('effort', 'high') + + expect(dispatch).toHaveBeenCalledWith('/model gpt-5.3-codex-high-fast') + expect(surface.getSnapshot().find(({ id }) => id === 'effort')).toMatchObject({ + valueSource: 'dispatched', + kind: { currentValue: 'high' } + }) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts b/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts new file mode 100644 index 00000000000..41ce304011c --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts @@ -0,0 +1,276 @@ +import { + findCatalogModel, + findCatalogOption, + getAgentSessionOptionCatalog, + type CatalogMidSessionApply, + type CatalogModel, + type CatalogOptionApply +} from '../../../../shared/agent-session-option-catalog' +import type { AgentType } from '../../../../shared/agent-status-types' +import type { + SessionOptionDescriptor, + SessionOptionsSurface, + SessionOptionValue +} from '../../../../shared/native-chat-session-options' +import { + createNativeChatSessionOptionRecord, + readNativeChatSessionOptionCache, + writeNativeChatSessionOptionCache +} from './native-chat-session-option-cache' +import { + buildNativeChatSessionOptionSnapshot, + flattenNativeChatSessionOptionRecord, + type NativeChatSessionOptionMode +} from './native-chat-session-option-snapshot' +import { buildNativeChatSessionOptionCommand } from './native-chat-session-option-command-builder' +import type { + NativeChatSessionOptionDispatchCommand, + NativeChatSessionOptionDispatchResult +} from './native-chat-session-option-command-dispatch' +import { applyNativeChatReportedSessionOptions } from './native-chat-session-option-reporting' +import { recordNativeChatSessionOptionCommand } from './native-chat-session-option-command-recording' + +type PersistSelection = (args: { + modelId: string + optionId: string + value: SessionOptionValue +}) => Promise | void + +export type NativeChatPtySessionOptionsSurface = SessionOptionsSurface & { + recordOutgoingCommand(command: string): void + reportSessionOptions(values: Record): void + replaceModels(models: CatalogModel[]): void +} + +export type CreateNativeChatPtySessionOptionsArgs = { + agent: AgentType + scopeKey: string + fallbackScopeKey?: string + initialModels?: readonly CatalogModel[] + mode: NativeChatSessionOptionMode + reportedValues?: Record | null + dispatchCommand: NativeChatSessionOptionDispatchCommand + onAgentPicker?: () => void + persistSelection?: PersistSelection + onDraftValuesChanged?: (values: Record) => void +} + +export function createNativeChatPtySessionOptions( + args: CreateNativeChatPtySessionOptionsArgs +): NativeChatPtySessionOptionsSurface | null { + const catalog = getAgentSessionOptionCatalog(args.agent) + if (!catalog) { + return null + } + let models = [...(args.initialModels ?? catalog.models)] + let record = + readNativeChatSessionOptionCache(args.scopeKey, args.fallbackScopeKey) ?? + createNativeChatSessionOptionRecord(args.agent) + if (record.agent !== args.agent) { + record = createNativeChatSessionOptionRecord(args.agent) + } + + if (args.reportedValues && applyNativeChatReportedSessionOptions(record, args.reportedValues)) { + writeNativeChatSessionOptionCache(args.scopeKey, record) + } + let snapshot = buildNativeChatSessionOptionSnapshot({ + catalog, + models, + record, + mode: args.mode + }) + const listeners = new Set<(value: SessionOptionDescriptor[]) => void>() + + const publish = (): SessionOptionDescriptor[] => { + writeNativeChatSessionOptionCache(args.scopeKey, record) + snapshot = buildNativeChatSessionOptionSnapshot({ + catalog, + models, + record, + mode: args.mode + }) + for (const listener of listeners) { + listener(snapshot) + } + return snapshot + } + + const clearModelTruth = (): void => { + const modelId = typeof record.model?.value === 'string' ? record.model.value : null + record.model = undefined + if (modelId) { + delete record.valuesByModel[modelId] + } + } + + const setTrackedValue = ( + optionId: string, + value: SessionOptionValue, + source: 'applied' | 'dispatched' + ): string | null => { + if (optionId === 'model') { + record.model = { value, source } + return typeof value === 'string' ? value : null + } + const modelId = typeof record.model?.value === 'string' ? record.model.value : null + if (!modelId) { + return null + } + record.valuesByModel[modelId] = { + ...record.valuesByModel[modelId], + [optionId]: { value, source } + } + return modelId + } + + const persist = (modelId: string | null, optionId: string, value: SessionOptionValue): void => { + if (modelId) { + void args.persistSelection?.({ modelId, optionId, value }) + } + } + + const currentApply = ( + optionId: string + ): { apply: CatalogOptionApply; modelId: string | null } | null => { + const modelId = typeof record.model?.value === 'string' ? record.model.value : null + if (optionId === 'model') { + return { apply: catalog.modelApply, modelId } + } + const model = modelId ? findCatalogModel({ ...catalog, models }, modelId) : undefined + const option = findCatalogOption(model, optionId) + return option ? { apply: option.apply, modelId } : null + } + + const handleAgentPicker = async (midSession: CatalogMidSessionApply): Promise => { + if (midSession.kind !== 'agent-picker') { + return + } + await args.dispatchCommand(midSession.command) + clearModelTruth() + publish() + args.onAgentPicker?.() + } + + const setOption = async (id: string, value: SessionOptionValue) => { + const resolved = currentApply(id) + if (!resolved) { + throw new Error(`Unknown session option: ${id}`) + } + const { apply, modelId: previousModelId } = resolved + if (args.mode === 'live' && apply.midSession?.kind === 'agent-picker') { + await handleAgentPicker(apply.midSession) + return { snapshot } + } + const source = args.mode === 'live' ? 'dispatched' : 'applied' + let dispatchResult: NativeChatSessionOptionDispatchResult | void = undefined + const toggleWasKnown = + apply.midSession?.kind === 'toggle-command' && previousModelId + ? record.valuesByModel[previousModelId]?.[id] !== undefined + : false + if (args.mode === 'live') { + const command = buildNativeChatSessionOptionCommand({ + optionId: id, + value, + apply, + modelId: previousModelId, + catalog, + models, + record + }) + if (!command) { + throw new Error('This option can only be set when the session starts.') + } + const detectAgentInteraction = + apply.midSession?.kind === 'command' + ? apply.midSession.detectAgentInteraction + : apply.composedIntoModel && catalog.modelApply.midSession?.kind === 'command' + ? catalog.modelApply.midSession.detectAgentInteraction + : undefined + const expectedChoiceLabel = + id === 'model' && typeof value === 'string' + ? (findCatalogModel({ ...catalog, models }, value)?.label ?? value) + : undefined + dispatchResult = detectAgentInteraction + ? await args.dispatchCommand(command, { + detectAgentInteraction, + expectedChoiceLabel + }) + : await args.dispatchCommand(command) + } else if (!apply.launchArgs && !apply.composedIntoModel) { + throw new Error('This option is only available after the session starts.') + } + + if (dispatchResult?.outcome === 'rejected') { + throw new Error('Claude kept the current model.') + } + if (dispatchResult?.outcome === 'unknown') { + clearModelTruth() + publish() + throw new Error('Could not verify the model change; open the terminal to check.') + } + if (dispatchResult?.outcome === 'interaction-required') { + clearModelTruth() + publish() + args.onAgentPicker?.() + return { snapshot } + } + + if (apply.midSession?.kind === 'toggle-command' && !toggleWasKnown) { + return { snapshot: publish() } + } + if (id === 'model' && previousModelId !== value) { + record.model = undefined + if (args.mode === 'live' && typeof value === 'string') { + // Why: switching models can reset its effort/toggles. A value cached + // from an earlier visit to that model is no longer live evidence. + delete record.valuesByModel[value] + } + } + const modelId = setTrackedValue(id, value, source) + if (apply.midSession?.kind === 'toggle-command' && previousModelId && source === 'dispatched') { + record.valuesByModel[previousModelId] = { + ...record.valuesByModel[previousModelId], + [id]: { value, source } + } + } + persist(modelId ?? previousModelId, id, value) + const next = publish() + if (args.mode === 'draft' && typeof record.model?.value === 'string') { + args.onDraftValuesChanged?.(flattenNativeChatSessionOptionRecord(record, record.model.value)) + } + return { snapshot: next } + } + + return { + getSnapshot: () => snapshot, + setOption, + subscribe: (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + recordOutgoingCommand: (command) => { + const result = recordNativeChatSessionOptionCommand({ + catalog, + models, + record, + command, + persist + }) + if (result.changed) { + publish() + } + if (result.opensAgentPicker) { + args.onAgentPicker?.() + } + }, + reportSessionOptions: (values) => { + if (applyNativeChatReportedSessionOptions(record, values)) { + publish() + } + }, + replaceModels: (nextModels) => { + models = [...nextModels] + publish() + } + } +} diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts index 626e126ffa5..730ad84f1a6 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts @@ -3,12 +3,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' // Mock the IO seam so the test stays pure: we only assert the write order and // the inter-write delay, not the local-vs-remote pty branching. const sendRuntimePtyInput = vi.fn() +const sendRuntimePtyInputVerified = vi.fn() vi.mock('@/runtime/runtime-terminal-inspection', () => ({ - sendRuntimePtyInput: (...args: unknown[]) => sendRuntimePtyInput(...args) + sendRuntimePtyInput: (...args: unknown[]) => sendRuntimePtyInput(...args), + sendRuntimePtyInputVerified: (...args: unknown[]) => sendRuntimePtyInputVerified(...args) })) import { sendNativeChatMessage, + sendNativeChatMessageVerified, sendNativeChatMessageWithImageAttachments, submitNativeChatPrompt, sendNativeChatAskAnswer, @@ -75,6 +78,52 @@ describe('sendNativeChatMessage', () => { }) }) +describe('sendNativeChatMessageVerified', () => { + beforeEach(() => { + vi.useFakeTimers() + sendRuntimePtyInputVerified.mockReset().mockResolvedValue(true) + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('awaits body acceptance before the delayed Enter write', async () => { + const result = sendNativeChatMessageVerified(SETTINGS, PTY, '/model sonnet') + await Promise.resolve() + + expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1) + expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith( + SETTINGS, + PTY, + buildNativeChatPasteBytes('/model sonnet') + ) + + await vi.advanceTimersByTimeAsync(NATIVE_CHAT_SUBMIT_DELAY_MS) + + expect(await result).toBe(true) + expect(sendRuntimePtyInputVerified).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) + }) + + it('does not send Enter when the body is rejected', async () => { + sendRuntimePtyInputVerified.mockResolvedValueOnce(false) + + await expect(sendNativeChatMessageVerified(SETTINGS, PTY, '/model sonnet')).resolves.toBe(false) + await vi.runAllTimersAsync() + + expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1) + }) + + it('cancels the pending Enter when its composer detaches', async () => { + const controller = new AbortController() + const result = sendNativeChatMessageVerified(SETTINGS, PTY, '/model sonnet', controller.signal) + await Promise.resolve() + controller.abort() + + expect(await result).toBe(false) + expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1) + }) +}) + describe('sendNativeChatMessageWithImageAttachments', () => { beforeEach(() => { vi.useFakeTimers() diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts index eed1782df95..a1dca86b65f 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts @@ -2,7 +2,10 @@ // as a SEPARATE delayed pty write. Kept apart from the pure byte builders in // native-chat-send.ts so those stay IO-free and unit-testable without aliases. -import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' +import { + sendRuntimePtyInput, + sendRuntimePtyInputVerified +} from '@/runtime/runtime-terminal-inspection' import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' import type { AskAnswerKeyGroup } from './native-chat-interactive-prompt' import { @@ -46,6 +49,46 @@ export function sendNativeChatMessage( return { cancel: () => clearTimeout(timer), settleAfterMs: NATIVE_CHAT_SUBMIT_DELAY_MS } } +function waitForNativeChatSubmit(signal?: AbortSignal): Promise { + if (signal?.aborted) { + return Promise.resolve(false) + } + return new Promise((resolve) => { + let timer: ReturnType | null = null + const finish = (completed: boolean): void => { + if (timer === null) { + return + } + clearTimeout(timer) + timer = null + signal?.removeEventListener('abort', onAbort) + resolve(completed) + } + const onAbort = (): void => finish(false) + timer = setTimeout(() => finish(true), NATIVE_CHAT_SUBMIT_DELAY_MS) + signal?.addEventListener('abort', onAbort, { once: true }) + }) +} + +export async function sendNativeChatMessageVerified( + settings: ReturnType, + ptyId: string, + text: string, + signal?: AbortSignal +): Promise { + // Why: option commands await remote/SSH acceptance so the Enter cannot race + // ahead of the body while a model-change observer is already armed. + const bodyAccepted = await sendRuntimePtyInputVerified( + settings, + ptyId, + buildNativeChatPasteBytes(text) + ) + if (!bodyAccepted || signal?.aborted || !(await waitForNativeChatSubmit(signal))) { + return false + } + return sendRuntimePtyInputVerified(settings, ptyId, NATIVE_CHAT_SUBMIT) +} + export function sendNativeChatMessageWithImageAttachments( settings: ReturnType, ptyId: string, diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-cache.ts b/src/renderer/src/components/native-chat/native-chat-session-option-cache.ts new file mode 100644 index 00000000000..ae9418cd650 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-cache.ts @@ -0,0 +1,84 @@ +import type { AgentType } from '../../../../shared/agent-status-types' +import type { + SessionOptionValue, + SessionOptionValueSource +} from '../../../../shared/native-chat-session-options' +import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache' + +export type TrackedNativeChatSessionOption = { + value: SessionOptionValue + source: Exclude +} + +export type NativeChatSessionOptionRecord = { + agent: AgentType + model?: TrackedNativeChatSessionOption + valuesByModel: Record> +} + +const sessionOptionCache = new Map() + +export function createNativeChatSessionOptionRecord( + agent: AgentType +): NativeChatSessionOptionRecord { + return { agent, valuesByModel: {} } +} + +export function cloneNativeChatSessionOptionRecord( + record: NativeChatSessionOptionRecord +): NativeChatSessionOptionRecord { + return { + agent: record.agent, + ...(record.model ? { model: { ...record.model } } : {}), + valuesByModel: Object.fromEntries( + Object.entries(record.valuesByModel).map(([modelId, values]) => [ + modelId, + Object.fromEntries(Object.entries(values).map(([id, tracked]) => [id, { ...tracked }])) + ]) + ) + } +} + +export function readNativeChatSessionOptionCache( + scopeKey: string, + fallbackScopeKey?: string +): NativeChatSessionOptionRecord | null { + const record = sessionOptionCache.get(scopeKey) ?? sessionOptionCache.get(fallbackScopeKey ?? '') + return record ? cloneNativeChatSessionOptionRecord(record) : null +} + +export function writeNativeChatSessionOptionCache( + scopeKey: string, + record: NativeChatSessionOptionRecord +): void { + setBoundedScopeCacheEntry( + sessionOptionCache, + scopeKey, + cloneNativeChatSessionOptionRecord(record) + ) +} + +export function seedNativeChatAppliedSessionOptions( + scopeKey: string, + agent: AgentType, + values: Record | null | undefined +): void { + const modelId = typeof values?.model === 'string' ? values.model : null + if (!modelId) { + return + } + const record = createNativeChatSessionOptionRecord(agent) + record.model = { value: modelId, source: 'applied' } + const modelValues: Record = {} + for (const [id, value] of Object.entries(values ?? {})) { + if (id !== 'model') { + modelValues[id] = { value, source: 'applied' } + } + } + record.valuesByModel[modelId] = modelValues + writeNativeChatSessionOptionCache(scopeKey, record) +} + +export function clearNativeChatSessionOptionCacheForTests(): void { + sessionOptionCache.clear() +} diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-command-builder.ts b/src/renderer/src/components/native-chat/native-chat-session-option-command-builder.ts new file mode 100644 index 00000000000..94de397b0ef --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-command-builder.ts @@ -0,0 +1,40 @@ +import { + findCatalogModel, + type AgentSessionOptionCatalog, + type CatalogModel, + type CatalogOptionApply +} from '../../../../shared/agent-session-option-catalog' +import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' +import type { NativeChatSessionOptionRecord } from './native-chat-session-option-cache' +import { flattenNativeChatSessionOptionRecord } from './native-chat-session-option-snapshot' + +export function buildNativeChatSessionOptionCommand(args: { + optionId: string + value: SessionOptionValue + apply: CatalogOptionApply + modelId: string | null + catalog: AgentSessionOptionCatalog + models: CatalogModel[] + record: NativeChatSessionOptionRecord +}): string | null { + const midSession = args.apply.midSession + if (midSession?.kind === 'command') { + return midSession.build(args.value) + } + if (midSession?.kind === 'toggle-command' || midSession?.kind === 'agent-picker') { + return midSession.command + } + if (!args.apply.composedIntoModel || !args.modelId || !args.catalog.composeModelValue) { + return null + } + const model = findCatalogModel({ ...args.catalog, models: args.models }, args.modelId) + const values = flattenNativeChatSessionOptionRecord(args.record, args.modelId) + for (const option of model?.options ?? []) { + values[option.id] ??= option.kind.defaultValue + } + values[args.optionId] = args.value + const composed = args.catalog.composeModelValue(args.modelId, values) + return args.catalog.modelApply.midSession?.kind === 'command' + ? args.catalog.modelApply.midSession.build(composed) + : null +} diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-command-dispatch.ts b/src/renderer/src/components/native-chat/native-chat-session-option-command-dispatch.ts new file mode 100644 index 00000000000..c510990dc5e --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-command-dispatch.ts @@ -0,0 +1,17 @@ +import type { CatalogAgentInteractionDetection } from '../../../../shared/agent-session-option-catalog' +import type { ClaudeModelSwitchOutcome } from './claude-model-switch-confirmation' + +export type NativeChatSessionOptionDispatchResult = { + outcome?: ClaudeModelSwitchOutcome +} + +export type NativeChatSessionOptionDispatchCommand = ( + command: string, + options?: { + detectAgentInteraction?: CatalogAgentInteractionDetection + expectedChoiceLabel?: string + } +) => + | Promise + | NativeChatSessionOptionDispatchResult + | void diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-command-matching.ts b/src/renderer/src/components/native-chat/native-chat-session-option-command-matching.ts new file mode 100644 index 00000000000..08509c36935 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-command-matching.ts @@ -0,0 +1,31 @@ +import type { CatalogMidSessionApply } from '../../../../shared/agent-session-option-catalog' +import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' + +export function parseBuiltSessionOptionCommand( + build: (value: SessionOptionValue) => string, + command: string +): string | null { + const marker = '__orca_session_option_value__' + const template = build(marker) + const markerIndex = template.indexOf(marker) + if (markerIndex < 0) { + return null + } + const prefix = template.slice(0, markerIndex) + const suffix = template.slice(markerIndex + marker.length) + if (!command.startsWith(prefix) || !command.endsWith(suffix)) { + return null + } + const value = command.slice(prefix.length, command.length - suffix.length).trim() + return value || null +} + +export function isSessionOptionAgentPickerCommand( + midSession: CatalogMidSessionApply | undefined, + command: string +): boolean { + return ( + (midSession?.kind === 'agent-picker' && command === midSession.command) || + (midSession?.kind === 'command' && command === midSession.pickerCommand) + ) +} diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-command-recording.ts b/src/renderer/src/components/native-chat/native-chat-session-option-command-recording.ts new file mode 100644 index 00000000000..051e14b1c1d --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-command-recording.ts @@ -0,0 +1,105 @@ +import { + findCatalogModel, + type AgentSessionOptionCatalog, + type CatalogMidSessionApply, + type CatalogModel +} from '../../../../shared/agent-session-option-catalog' +import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' +import type { NativeChatSessionOptionRecord } from './native-chat-session-option-cache' +import { + isSessionOptionAgentPickerCommand, + parseBuiltSessionOptionCommand +} from './native-chat-session-option-command-matching' + +function clearModel(record: NativeChatSessionOptionRecord): void { + const modelId = typeof record.model?.value === 'string' ? record.model.value : null + record.model = undefined + if (modelId) { + delete record.valuesByModel[modelId] + } +} + +function recordCommandApply(args: { + record: NativeChatSessionOptionRecord + optionId: string + midSession: CatalogMidSessionApply | undefined + command: string + persist: (modelId: string | null, optionId: string, value: SessionOptionValue) => void +}): boolean { + const { record, optionId, midSession, command, persist } = args + if (!midSession || midSession.kind === 'unsupported') { + return false + } + if (midSession.kind === 'toggle-command' && command === midSession.command) { + const modelId = typeof record.model?.value === 'string' ? record.model.value : null + if (modelId) { + delete record.valuesByModel[modelId]?.[optionId] + } + return true + } + if (isSessionOptionAgentPickerCommand(midSession, command)) { + clearModel(record) + return true + } + if (midSession.kind !== 'command') { + return false + } + const value = parseBuiltSessionOptionCommand(midSession.build, command) + if (!value) { + return false + } + const previousModelId = typeof record.model?.value === 'string' ? record.model.value : null + if (optionId === 'model') { + if (previousModelId !== value) { + // Why: a model command can reset model-scoped state, so an older value + // from a prior visit is no longer evidence about this live session. + delete record.valuesByModel[value] + } + record.model = { value, source: 'dispatched' } + persist(value, optionId, value) + return true + } + if (!previousModelId) { + return true + } + record.valuesByModel[previousModelId] = { + ...record.valuesByModel[previousModelId], + [optionId]: { value, source: 'dispatched' } + } + persist(previousModelId, optionId, value) + return true +} + +export function recordNativeChatSessionOptionCommand(args: { + catalog: AgentSessionOptionCatalog + models: CatalogModel[] + record: NativeChatSessionOptionRecord + command: string + persist: (modelId: string | null, optionId: string, value: SessionOptionValue) => void +}): { changed: boolean; opensAgentPicker: boolean } { + const { catalog, models, record, persist } = args + const command = args.command.trim() + let opensAgentPicker = isSessionOptionAgentPickerCommand(catalog.modelApply.midSession, command) + let changed = recordCommandApply({ + record, + optionId: 'model', + midSession: catalog.modelApply.midSession, + command, + persist + }) + const modelId = typeof record.model?.value === 'string' ? record.model.value : null + const model = modelId ? findCatalogModel({ ...catalog, models }, modelId) : undefined + for (const option of model?.options ?? []) { + opensAgentPicker = + opensAgentPicker || isSessionOptionAgentPickerCommand(option.apply.midSession, command) + changed = + recordCommandApply({ + record, + optionId: option.id, + midSession: option.apply.midSession, + command, + persist + }) || changed + } + return { changed, opensAgentPicker } +} diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts b/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts new file mode 100644 index 00000000000..bdf8ee6d1a2 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts @@ -0,0 +1,57 @@ +import type { AgentType } from '../../../../shared/agent-status-types' +import type { CatalogModel } from '../../../../shared/agent-session-option-catalog' +import { getCommitMessageModelDiscoveryHostKeyForScope } from '../../../../shared/commit-message-host-key' +import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' +import { getConnectionIdFromState } from '@/lib/connection-context' +import { + discoverRuntimeCommitMessageModels, + getRuntimeGitScope, + type RuntimeGitContext +} from '@/runtime/runtime-git-client' +import { useAppStore } from '@/store' + +export type NativeChatModelDiscoveryContext = { + hostKey: string + runtime: RuntimeGitContext +} + +export function resolveNativeChatModelDiscoveryContext( + terminalTabId: string +): NativeChatModelDiscoveryContext | null { + const state = useAppStore.getState() + const worktreeId = + Object.entries(state.tabsByWorktree ?? {}).find(([, tabs]) => + tabs.some((tab) => tab.id === terminalTabId) + )?.[0] ?? null + const connectionId = getConnectionIdFromState(state, worktreeId) + if (worktreeId && connectionId === undefined) { + return null + } + const settings = getSettingsForAgentTabRuntimeOwner(terminalTabId) + const worktreePath = worktreeId ? (state.getKnownWorktreeById?.(worktreeId)?.path ?? '') : '' + const scope = getRuntimeGitScope(settings, connectionId) + return { + hostKey: getCommitMessageModelDiscoveryHostKeyForScope(scope), + runtime: { + settings, + worktreeId, + worktreePath, + ...(connectionId ? { connectionId } : {}) + } + } +} + +export async function discoverNativeChatCatalogModels( + agent: AgentType, + context: RuntimeGitContext +): Promise { + const result = await discoverRuntimeCommitMessageModels(context, agent) + if (!result.success || result.models.length === 0) { + return null + } + return result.models.map((model) => ({ + id: model.id, + label: model.label, + options: [] + })) +} diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts new file mode 100644 index 00000000000..008d857ec9c --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { CatalogModel } from '../../../../shared/agent-session-option-catalog' +import { + clearNativeChatModelEnrichmentForTests, + ensureNativeChatModelEnrichment, + readNativeChatEnrichedModels, + subscribeNativeChatEnrichedModels +} from './native-chat-session-option-enrichment' + +describe('native chat session option enrichment', () => { + beforeEach(() => clearNativeChatModelEnrichmentForTests()) + + it('keeps reads synchronous while one host-scoped probe is in flight', async () => { + let resolveDiscovery: ((models: CatalogModel[]) => void) | undefined + const discover = vi.fn( + () => + new Promise((resolve) => { + resolveDiscovery = resolve + }) + ) + const listener = vi.fn() + subscribeNativeChatEnrichedModels('cursor', 'ssh:one', listener) + + ensureNativeChatModelEnrichment({ agent: 'cursor', hostKey: 'ssh:one', discover }) + ensureNativeChatModelEnrichment({ agent: 'cursor', hostKey: 'ssh:one', discover }) + + expect(readNativeChatEnrichedModels('cursor', 'ssh:one')).toBeNull() + expect(discover).toHaveBeenCalledOnce() + + resolveDiscovery?.([ + { id: 'gpt-5.3-codex', label: 'GPT 5.3 live', options: [] }, + { id: 'account-model', label: 'Account model', options: [] } + ]) + await vi.waitFor(() => expect(listener).toHaveBeenCalledOnce()) + + const models = readNativeChatEnrichedModels('cursor', 'ssh:one')! + expect(models.find((model) => model.id === 'gpt-5.3-codex')).toMatchObject({ + label: 'GPT 5.3 live', + options: expect.arrayContaining([expect.objectContaining({ id: 'effort' })]) + }) + expect(models.at(-1)).toMatchObject({ id: 'account-model' }) + expect(readNativeChatEnrichedModels('cursor', 'ssh:two')).toBeNull() + }) + + it('falls back permanently to the seed after a failed once-per-host probe', async () => { + const discover = vi.fn().mockRejectedValue(new Error('offline')) + ensureNativeChatModelEnrichment({ agent: 'cursor', hostKey: 'local', discover }) + await vi.waitFor(() => expect(discover).toHaveBeenCalledOnce()) + await Promise.resolve() + + ensureNativeChatModelEnrichment({ agent: 'cursor', hostKey: 'local', discover }) + expect(discover).toHaveBeenCalledOnce() + expect(readNativeChatEnrichedModels('cursor', 'local')).toBeNull() + }) + + it('does not probe agents whose catalogs have no discovery command', () => { + const discover = vi.fn() + ensureNativeChatModelEnrichment({ agent: 'claude', hostKey: 'local', discover }) + expect(discover).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts new file mode 100644 index 00000000000..5fde5625c52 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts @@ -0,0 +1,87 @@ +import type { AgentType } from '../../../../shared/agent-status-types' +import { + getAgentSessionOptionCatalog, + mergeCatalogModels, + type CatalogModel +} from '../../../../shared/agent-session-option-catalog' + +type CatalogEnrichmentEntry = { + state: 'idle' | 'pending' | 'settled' + models: CatalogModel[] | null + listeners: Set<(models: CatalogModel[]) => void> +} + +const enrichmentByAgentHost = new Map() + +function enrichmentKey(agent: AgentType, hostKey: string): string { + return JSON.stringify([agent, hostKey]) +} + +export function readNativeChatEnrichedModels( + agent: AgentType, + hostKey: string +): CatalogModel[] | null { + const models = enrichmentByAgentHost.get(enrichmentKey(agent, hostKey))?.models + return models ? [...models] : null +} + +export function subscribeNativeChatEnrichedModels( + agent: AgentType, + hostKey: string, + listener: (models: CatalogModel[]) => void +): () => void { + const key = enrichmentKey(agent, hostKey) + const entry = enrichmentByAgentHost.get(key) ?? { + state: 'idle' as const, + models: null, + listeners: new Set<(models: CatalogModel[]) => void>() + } + entry.listeners.add(listener) + enrichmentByAgentHost.set(key, entry) + return () => entry.listeners.delete(listener) +} + +export function ensureNativeChatModelEnrichment(args: { + agent: AgentType + hostKey: string + discover: () => Promise +}): void { + const catalog = getAgentSessionOptionCatalog(args.agent) + if (!catalog?.listModels) { + return + } + const key = enrichmentKey(args.agent, args.hostKey) + const existing = enrichmentByAgentHost.get(key) + if (existing?.state === 'pending' || existing?.state === 'settled') { + return + } + const entry: CatalogEnrichmentEntry = existing ?? { + state: 'idle', + models: null, + listeners: new Set() + } + entry.state = 'pending' + enrichmentByAgentHost.set(key, entry) + + // Why: model discovery must never delay rendering or launching; the seed is + // immediately usable while this once-per-host probe runs in the background. + void args + .discover() + .then((discovered) => { + entry.state = 'settled' + if (!discovered || discovered.length === 0) { + return + } + entry.models = mergeCatalogModels(catalog.models, discovered) + for (const listener of entry.listeners) { + listener([...entry.models]) + } + }) + .catch(() => { + entry.state = 'settled' + }) +} + +export function clearNativeChatModelEnrichmentForTests(): void { + enrichmentByAgentHost.clear() +} diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-labels.ts b/src/renderer/src/components/native-chat/native-chat-session-option-labels.ts new file mode 100644 index 00000000000..33b940d88f3 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-labels.ts @@ -0,0 +1,135 @@ +import type { + SessionOptionDescriptor, + SessionOptionDisabledReason, + SessionOptionSelectChoice +} from '../../../../shared/native-chat-session-options' +import { translate } from '@/i18n/i18n' + +export function nativeChatSessionOptionLabel(descriptor: SessionOptionDescriptor): string { + switch (descriptor.id) { + case 'model': + return translate('components.native-chat.composer.model', 'Model') + case 'effort': + return translate('components.native-chat.composer.effort', descriptor.label) + case 'fastMode': + return translate('components.native-chat.composer.fastMode', 'Fast mode') + case 'thinking': + return translate('components.native-chat.composer.thinking', 'Thinking') + default: + return descriptor.label + } +} + +export function nativeChatSessionChoiceLabel(choice: SessionOptionSelectChoice): string { + switch (choice.value) { + case 'minimal': + return translate('components.native-chat.composer.optionValue.minimal', 'Minimal') + case 'low': + return translate('components.native-chat.composer.optionValue.low', 'Low') + case 'medium': + return translate('components.native-chat.composer.optionValue.medium', 'Medium') + case 'high': + return translate('components.native-chat.composer.optionValue.high', 'High') + case 'xhigh': + return translate('components.native-chat.composer.optionValue.xhigh', 'Extra high') + case 'max': + return translate('components.native-chat.composer.optionValue.max', 'Max') + default: + return choice.label + } +} + +export function nativeChatSessionOptionDisabledReason( + reason: SessionOptionDisabledReason | undefined +): string | null { + // Exhaustive over SessionOptionDisabledReason: a new key is a compile error + // here, so the localized label can never silently drift from the producer. + switch (reason) { + case 'set-when-session-starts': + return translate( + 'components.native-chat.composer.setWhenSessionStarts', + 'Set when the session starts.' + ) + case 'available-after-session-start': + return translate( + 'components.native-chat.composer.availableAfterSessionStarts', + 'Available after the session starts.' + ) + default: + return null + } +} + +export function nativeChatModelPillLabel(descriptor: SessionOptionDescriptor): string { + const modelLabel = translate('components.native-chat.composer.model', 'Model') + if ( + descriptor.valueSource === 'unknown' || + descriptor.kind.type !== 'select' || + !descriptor.kind.currentValue + ) { + return modelLabel + } + const valueLabel = nativeChatSessionChoiceLabel( + descriptor.kind.choices.find((choice) => choice.value === descriptor.kind.currentValue) ?? { + value: descriptor.kind.currentValue, + label: descriptor.kind.currentValue + } + ) + return translate('components.native-chat.composer.modelWithValue', 'Model: {{value0}}', { + value0: valueLabel + }) +} + +export function nativeChatOptionsPillTitle( + descriptors: readonly SessionOptionDescriptor[] +): string { + const effort = descriptors.find((descriptor) => descriptor.id === 'effort') + // Why: an effort-backed group is primarily the effort picker, even when it also reports modes. + return effort + ? nativeChatSessionOptionLabel(effort) + : translate('components.native-chat.composer.sessionOptions', 'Session options') +} + +export function nativeChatOptionsPillLabel( + descriptors: readonly SessionOptionDescriptor[] +): string { + const effort = descriptors.find((descriptor) => descriptor.id === 'effort') + const labels: string[] = [] + for (const descriptor of descriptors) { + if (descriptor.valueSource === 'unknown') { + continue + } + if (descriptor.kind.type === 'select' && descriptor.kind.currentValue) { + const choice = descriptor.kind.choices.find( + (candidate) => candidate.value === descriptor.kind.currentValue + ) + labels.push( + nativeChatSessionChoiceLabel( + choice ?? { + value: descriptor.kind.currentValue, + label: descriptor.kind.currentValue + } + ) + ) + } else if (descriptor.kind.type === 'boolean' && descriptor.kind.currentValue === true) { + labels.push( + descriptor.id === 'fastMode' + ? translate('components.native-chat.composer.optionValue.fast', 'Fast') + : nativeChatSessionOptionLabel(descriptor) + ) + } + } + if (labels.length > 0) { + const joinedLabels = labels.join(' · ') + if (effort) { + return translate('components.native-chat.composer.effortWithValue', 'Effort: {{value0}}', { + value0: joinedLabels + }) + } + return joinedLabels + } + if (effort) { + return nativeChatSessionOptionLabel(effort) + } + return translate('components.native-chat.composer.options', 'Options') +} diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-reporting.ts b/src/renderer/src/components/native-chat/native-chat-session-option-reporting.ts new file mode 100644 index 00000000000..411a0f988d7 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-reporting.ts @@ -0,0 +1,28 @@ +import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' +import type { NativeChatSessionOptionRecord } from './native-chat-session-option-cache' + +export function applyNativeChatReportedSessionOptions( + record: NativeChatSessionOptionRecord, + values: Record +): boolean { + const modelId = typeof values.model === 'string' ? values.model : null + if (!modelId) { + return false + } + const modelChanged = record.model?.value !== modelId + let changed = modelChanged || record.model?.source !== 'reported' + record.model = { value: modelId, source: 'reported' } + const modelValues = modelChanged ? {} : { ...record.valuesByModel[modelId] } + for (const [id, value] of Object.entries(values)) { + if (id === 'model') { + continue + } + const current = modelValues[id] + if (current?.value !== value || current.source !== 'reported') { + changed = true + } + modelValues[id] = { value, source: 'reported' } + } + record.valuesByModel[modelId] = modelValues + return changed +} diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-snapshot.ts b/src/renderer/src/components/native-chat/native-chat-session-option-snapshot.ts new file mode 100644 index 00000000000..987e66edf4b --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-snapshot.ts @@ -0,0 +1,178 @@ +import { + findCatalogModel, + type AgentSessionOptionCatalog, + type CatalogMidSessionApply, + type CatalogModel, + type CatalogOption +} from '../../../../shared/agent-session-option-catalog' +import type { + SessionOptionDescriptor, + SessionOptionSelectChoice, + SessionOptionValue +} from '../../../../shared/native-chat-session-options' +import type { + NativeChatSessionOptionRecord, + TrackedNativeChatSessionOption +} from './native-chat-session-option-cache' +import { translate } from '@/i18n/i18n' + +export type NativeChatSessionOptionMode = 'draft' | 'live' + +function choiceWithCurrent( + choices: readonly SessionOptionSelectChoice[], + tracked: TrackedNativeChatSessionOption | undefined +): SessionOptionSelectChoice[] { + const result = [...choices] + const current = typeof tracked?.value === 'string' ? tracked.value : null + if (current && !result.some((choice) => choice.value === current)) { + result.push({ value: current, label: current }) + } + return result +} + +function settableState(args: { + mode: NativeChatSessionOptionMode + apply: { launchArgs?: unknown; composedIntoModel?: true; midSession?: CatalogMidSessionApply } + composedModelApply?: { midSession?: CatalogMidSessionApply } +}): Pick { + if (args.mode === 'draft') { + return args.apply.launchArgs || args.apply.composedIntoModel + ? { settable: true } + : { settable: false, disabledReason: 'available-after-session-start' } + } + const midSession = args.apply.midSession + if (args.apply.composedIntoModel && args.composedModelApply?.midSession?.kind === 'command') { + return { settable: true } + } + return midSession && midSession.kind !== 'unsupported' + ? { settable: true } + : { settable: false, disabledReason: 'set-when-session-starts' } +} + +function actionForApply( + apply: { midSession?: CatalogMidSessionApply }, + tracked: TrackedNativeChatSessionOption | undefined, + mode: NativeChatSessionOptionMode +): SessionOptionDescriptor['action'] { + if (mode !== 'live') { + return undefined + } + if (apply.midSession?.kind === 'agent-picker') { + return { type: 'agent-picker' } + } + if (apply.midSession?.kind === 'toggle-command' && !tracked) { + return { type: 'toggle-command' } + } + return undefined +} + +function optionDescriptor(args: { + option: CatalogOption + tracked: TrackedNativeChatSessionOption | undefined + mode: NativeChatSessionOptionMode + composedModelApply: AgentSessionOptionCatalog['modelApply'] +}): SessionOptionDescriptor | null { + const { option, tracked, mode, composedModelApply } = args + const action = actionForApply(option.apply, tracked, mode) + const settable = settableState({ mode, apply: option.apply, composedModelApply }) + if (option.kind.type === 'select') { + const choices = choiceWithCurrent(option.kind.choices, tracked) + if (choices.length <= 1) { + return null + } + return { + id: option.id, + label: option.label, + ...(option.description ? { description: option.description } : {}), + ...(option.category ? { category: option.category } : {}), + kind: { + type: 'select', + ...(typeof tracked?.value === 'string' ? { currentValue: tracked.value } : {}), + choices + }, + valueSource: tracked?.source ?? 'unknown', + ...settable, + ...(action ? { action } : {}) + } + } + return { + id: option.id, + label: option.label, + ...(option.description ? { description: option.description } : {}), + ...(option.category ? { category: option.category } : {}), + kind: { + type: 'boolean', + ...(typeof tracked?.value === 'boolean' ? { currentValue: tracked.value } : {}) + }, + valueSource: tracked?.source ?? 'unknown', + ...settable, + ...(action ? { action } : {}) + } +} + +export function buildNativeChatSessionOptionSnapshot(args: { + catalog: AgentSessionOptionCatalog + models: readonly CatalogModel[] + record: NativeChatSessionOptionRecord + mode: NativeChatSessionOptionMode +}): SessionOptionDescriptor[] { + const { catalog, models, record, mode } = args + const modelTracked = record.model + const modelChoices = choiceWithCurrent( + models.map(({ id, label, description }) => ({ + value: id, + label, + ...(description ? { description } : {}) + })), + modelTracked + ) + const modelSettable = settableState({ mode, apply: catalog.modelApply }) + const modelAction = actionForApply(catalog.modelApply, modelTracked, mode) + const snapshot: SessionOptionDescriptor[] = [ + { + id: 'model', + label: translate('components.native-chat.composer.model', 'Model'), + category: 'model', + kind: { + type: 'select', + ...(typeof modelTracked?.value === 'string' ? { currentValue: modelTracked.value } : {}), + choices: modelChoices + }, + valueSource: modelTracked?.source ?? 'unknown', + ...modelSettable, + ...(modelAction ? { action: modelAction } : {}) + } + ] + if (typeof modelTracked?.value !== 'string') { + return snapshot + } + const model = findCatalogModel({ ...catalog, models: [...models] }, modelTracked.value) + const trackedValues = record.valuesByModel[modelTracked.value] ?? {} + for (const option of model?.options ?? []) { + const descriptor = optionDescriptor({ + option, + tracked: trackedValues[option.id], + mode, + composedModelApply: catalog.modelApply + }) + if (descriptor) { + snapshot.push(descriptor) + } + } + return snapshot +} + +export function flattenNativeChatSessionOptionRecord( + record: NativeChatSessionOptionRecord, + modelId: string +): Record { + return { + model: modelId, + ...Object.fromEntries( + Object.entries(record.valuesByModel[modelId] ?? {}).map(([id, tracked]) => [ + id, + tracked.value + ]) + ) + } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-dictation-actions.ts b/src/renderer/src/components/native-chat/use-native-chat-dictation-actions.ts new file mode 100644 index 00000000000..7d4caf72b8b --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-dictation-actions.ts @@ -0,0 +1,28 @@ +import { useCallback, type Dispatch, type RefObject, type SetStateAction } from 'react' +import { dispatchDictationControl } from '../dictation/dictation-control-events' + +export function useNativeChatDictationActions(args: { + textareaRef: RefObject + setDictationPressed: Dispatch> +}): { + toggleDictation: () => void + startHoldDictation: () => void + stopHoldDictation: () => void +} { + const { setDictationPressed, textareaRef } = args + const focusForDictation = useCallback(() => textareaRef.current?.focus(), [textareaRef]) + const toggleDictation = useCallback(() => { + focusForDictation() + dispatchDictationControl('toggle') + }, [focusForDictation]) + const startHoldDictation = useCallback(() => { + setDictationPressed(true) + focusForDictation() + dispatchDictationControl('start') + }, [focusForDictation, setDictationPressed]) + const stopHoldDictation = useCallback(() => { + setDictationPressed(false) + dispatchDictationControl('stop') + }, [setDictationPressed]) + return { toggleDictation, startHoldDictation, stopHoldDictation } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-file-attachment-actions.ts b/src/renderer/src/components/native-chat/use-native-chat-file-attachment-actions.ts new file mode 100644 index 00000000000..5a6094cfea3 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-file-attachment-actions.ts @@ -0,0 +1,27 @@ +import { useCallback, useEffect } from 'react' +import { NATIVE_FILE_DROP_TARGET } from '../../../../shared/native-file-drop' + +export function useNativeChatFileAttachmentActions( + attachExternalPaths: (paths: string[]) => void +): { pickAttachment: () => void } { + useEffect( + () => + window.api.ui.onFileDrop((payload) => { + if (payload.target === NATIVE_FILE_DROP_TARGET.composer) { + attachExternalPaths(payload.paths) + } + }), + [attachExternalPaths] + ) + + const pickAttachment = useCallback(() => { + void (async () => { + const filePath = await window.api.shell.pickAttachment() + if (filePath) { + attachExternalPaths([filePath]) + } + })() + }, [attachExternalPaths]) + + return { pickAttachment } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-file-link-click.ts b/src/renderer/src/components/native-chat/use-native-chat-file-link-click.ts new file mode 100644 index 00000000000..3e745bcd2bc --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-file-link-click.ts @@ -0,0 +1,27 @@ +import { useCallback } from 'react' +import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown' +import { openDetectedFilePath } from '@/components/terminal-pane/terminal-file-open-routing' +import { resolveNativeChatFileLink, type NativeChatFileLinkContext } from './native-chat-file-link' + +export function useNativeChatFileLinkClick( + context: NativeChatFileLinkContext | null +): CommentMarkdownLinkClickHandler | undefined { + const openFileLink = useCallback( + (event, href) => { + const target = resolveNativeChatFileLink(href, context) + if (!target || !context) { + return + } + event.preventDefault() + event.stopPropagation() + openDetectedFilePath(target.absolutePath, target.line, target.column, { + worktreeId: context.worktreeId, + worktreePath: context.worktreePath, + runtimeEnvironmentId: context.runtimeEnvironmentId, + openWithSystemDefault: event.shiftKey + }) + }, + [context] + ) + return context ? openFileLink : undefined +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts b/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts new file mode 100644 index 00000000000..9720ab9f950 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts @@ -0,0 +1,114 @@ +import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react' +import type { AgentType } from '../../../../shared/agent-status-types' +import { emitNativeChatMessageSent } from '@/lib/native-chat-telemetry' +import { + nativeChatComposerTargetIsRemote, + type NativeChatResolvedTarget +} from './native-chat-composer-target' +import { pushHistory, type HistoryState } from './native-chat-composer-state' +import { sendNativeChatMessageVerified } from './native-chat-runtime-send' +import { + createClaudeModelSwitchConfirmationObserver, + type ClaudeModelSwitchConfirmationObserver +} from './claude-model-switch-confirmation' +import type { NativeChatSessionOptionDispatchCommand } from './native-chat-session-option-command-dispatch' + +export function useNativeChatSessionOptionCommand(args: { + agent: AgentType + disabled: boolean + onSlashCommand?: (command: string) => void + resolveTarget: () => NativeChatResolvedTarget | null + setHistory: Dispatch> +}): { dispatch: NativeChatSessionOptionDispatchCommand; isDispatching: boolean } { + const { agent, disabled, onSlashCommand, resolveTarget, setHistory } = args + const mountedRef = useRef(true) + const activeObserversRef = useRef(new Set()) + const activeSendsRef = useRef(new Set()) + // Why: expose an in-flight flag so the composer can block a normal send while + // an option command's body+delayed-Enter (and its confirmation observer) is + // still writing to the same pty — otherwise the two write sequences interleave + // on one input line and corrupt both. + const [isDispatching, setIsDispatching] = useState(false) + + useEffect(() => { + mountedRef.current = true + const observers = activeObserversRef.current + const sends = activeSendsRef.current + return () => { + mountedRef.current = false + for (const send of sends) { + send.abort() + } + sends.clear() + for (const observer of observers) { + observer.dispose() + } + observers.clear() + } + }, []) + + const dispatch = useCallback( + async (command, options) => { + const target = resolveTarget() + if (!target || disabled) { + throw new Error('No live terminal is available.') + } + const detectClaudeConfirmation = + options?.detectAgentInteraction === 'claude-model-switch-confirmation' + let observer: ClaudeModelSwitchConfirmationObserver | null = null + if (detectClaudeConfirmation) { + observer = createClaudeModelSwitchConfirmationObserver({ + ptyId: target.ptyId, + settings: target.settings, + expectedModelLabel: options?.expectedChoiceLabel ?? null + }) + activeObserversRef.current.add(observer) + await observer.ready + if (!mountedRef.current) { + activeObserversRef.current.delete(observer) + observer.dispose() + throw new Error('Native chat command was canceled because the composer closed.') + } + // Why: arm only after the observer reaches the live PTY tail, then + // submit immediately so historical output cannot satisfy the match. + observer.arm() + } + const sendController = new AbortController() + activeSendsRef.current.add(sendController) + setIsDispatching(true) + try { + const accepted = await sendNativeChatMessageVerified( + target.settings, + target.ptyId, + command, + sendController.signal + ) + if (!accepted) { + throw new Error('The terminal did not accept the command.') + } + // Why: start the model-switch detection clock only now that the command + // has been accepted, so SSH/remote send latency doesn't eat the window + // before the agent has responded. + observer?.startDetection() + onSlashCommand?.(command.trim()) + emitNativeChatMessageSent({ + agent, + runtime: nativeChatComposerTargetIsRemote(target.ptyId) ? 'remote' : 'local' + }) + setHistory((previous) => pushHistory(previous, command)) + const outcome = observer ? await observer.result : undefined + return { outcome } + } finally { + activeSendsRef.current.delete(sendController) + setIsDispatching(activeSendsRef.current.size > 0) + if (observer) { + activeObserversRef.current.delete(observer) + observer.dispose() + } + } + }, + [agent, disabled, onSlashCommand, resolveTarget, setHistory] + ) + + return { dispatch, isDispatching } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-session-options.ts b/src/renderer/src/components/native-chat/use-native-chat-session-options.ts new file mode 100644 index 00000000000..23c5f87a294 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-session-options.ts @@ -0,0 +1,155 @@ +import { useEffect, useMemo, useSyncExternalStore } from 'react' +import type { AgentType } from '../../../../shared/agent-status-types' +import { updateNativeChatSessionOptionDefaults } from '../../../../shared/native-chat-session-option-defaults' +import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options' +import { useAppStore } from '../../store' +import { + createNativeChatPtySessionOptions, + type NativeChatPtySessionOptionsSurface +} from './native-chat-pty-session-options' +import type { NativeChatSessionOptionDispatchCommand } from './native-chat-session-option-command-dispatch' +import { + ensureNativeChatModelEnrichment, + readNativeChatEnrichedModels, + subscribeNativeChatEnrichedModels +} from './native-chat-session-option-enrichment' +import { + discoverNativeChatCatalogModels, + resolveNativeChatModelDiscoveryContext +} from './native-chat-session-option-discovery' +import { readClaudeSessionOptionsFromTerminalScreen } from './claude-terminal-session-options' + +const EMPTY_SNAPSHOT: SessionOptionDescriptor[] = [] +const subscribeEmpty = (): (() => void) => () => {} +const getEmptySnapshot = (): SessionOptionDescriptor[] => EMPTY_SNAPSHOT + +export function useNativeChatSessionOptions(args: { + agent: AgentType + terminalTabId: string + targetPtyId: string | null + dispatchCommand: NativeChatSessionOptionDispatchCommand + onAgentPicker?: () => void + readTerminalScreen?: () => string | null +}): { + surface: NativeChatPtySessionOptionsSurface | null + snapshot: SessionOptionDescriptor[] +} { + const { agent, terminalTabId, targetPtyId, dispatchCommand, onAgentPicker, readTerminalScreen } = + args + const discoveryContext = useMemo( + () => resolveNativeChatModelDiscoveryContext(terminalTabId), + [terminalTabId] + ) + const surface = useMemo(() => { + // Why: native chat currently attaches only after startup is already queued; + // exposing a draft picker here would claim it can still mutate that command. + if (!targetPtyId) { + return null + } + const scopeKey = targetPtyId ?? terminalTabId + const reportedValues = + agent === 'claude' ? readClaudeSessionOptionsFromTerminalScreen(readTerminalScreen?.()) : null + let settingsWrite = Promise.resolve() + return createNativeChatPtySessionOptions({ + agent, + scopeKey, + ...(targetPtyId ? { fallbackScopeKey: terminalTabId } : {}), + ...(discoveryContext + ? { + initialModels: + readNativeChatEnrichedModels(agent, discoveryContext.hostKey) ?? undefined + } + : {}), + mode: targetPtyId ? 'live' : 'draft', + reportedValues, + dispatchCommand, + onAgentPicker, + persistSelection: async ({ modelId, optionId, value }) => { + // Why: read the live persisted defaults at write time (after any prior + // write in this chain settles) and merge only this selection onto them, + // rather than a baseline captured once at surface creation. A frozen + // baseline would let a second same-agent pane's write be clobbered, + // since updateSettings shallow-merges nativeChatSessionOptions. Chaining + // still keeps rapid consecutive picks in selection order. + settingsWrite = settingsWrite + .catch(() => undefined) + .then(() => { + const base = useAppStore.getState().settings?.nativeChatSessionOptions + const next = updateNativeChatSessionOptionDefaults({ + persisted: base, + agent, + modelId, + optionId, + value + }) + return useAppStore.getState().updateSettings({ nativeChatSessionOptions: next }) + }) + await settingsWrite + } + }) + }, [ + agent, + dispatchCommand, + discoveryContext, + onAgentPicker, + readTerminalScreen, + targetPtyId, + terminalTabId + ]) + + useEffect(() => { + if (!surface || agent !== 'claude') { + return + } + let cancelled = false + const reportCurrentValues = async (): Promise => { + let authoritativeScreen: string | null = null + if (targetPtyId && window.api?.pty?.getMainBufferSnapshot) { + try { + const snapshot = await window.api.pty.getMainBufferSnapshot(targetPtyId, { + scrollbackRows: 0 + }) + // Why: the API snapshots the main buffer, which is stale while a TUI + // owns the alternate screen. The mounted xterm is authoritative then. + authoritativeScreen = snapshot?.alternateScreen ? null : (snapshot?.data ?? null) + } catch { + // The mounted renderer buffer remains a transport-neutral fallback. + } + } + const reportedValues = + readClaudeSessionOptionsFromTerminalScreen(authoritativeScreen) ?? + readClaudeSessionOptionsFromTerminalScreen(readTerminalScreen?.()) + if (!cancelled && reportedValues) { + surface.reportSessionOptions(reportedValues) + } + } + void reportCurrentValues() + return () => { + cancelled = true + } + }, [agent, readTerminalScreen, surface, targetPtyId]) + + useEffect(() => { + if (!surface || !discoveryContext) { + return + } + const unsubscribe = subscribeNativeChatEnrichedModels( + agent, + discoveryContext.hostKey, + (models) => surface.replaceModels(models) + ) + ensureNativeChatModelEnrichment({ + agent, + hostKey: discoveryContext.hostKey, + discover: () => discoverNativeChatCatalogModels(agent, discoveryContext.runtime) + }) + return unsubscribe + }, [agent, discoveryContext, surface]) + + const snapshot = useSyncExternalStore( + surface?.subscribe ?? subscribeEmpty, + surface?.getSnapshot ?? getEmptySnapshot, + surface?.getSnapshot ?? getEmptySnapshot + ) + return { surface, snapshot } +} diff --git a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx index 6beb281bbc5..a0b8e894b47 100644 --- a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx +++ b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx @@ -294,6 +294,7 @@ export function OnboardingInlineCommandTerminal({ cwd={cwd} isActive isVisible + showSplitButton={false} onPtyExit={() => { onTerminalExit?.() closeTab(tabId, { recordInteraction: false, reason: 'pty-exit' }) diff --git a/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts b/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts index 73571a2f330..18068817358 100644 --- a/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts +++ b/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts @@ -22,6 +22,7 @@ describe('buildOnboardingFolderAgentStartup', () => { agentArgs: '--dangerously-bypass-approvals-and-sandbox', agentEnv: {} }, + sessionOptions: undefined, telemetry: { agent_kind: 'codex', launch_source: 'onboarding', @@ -105,6 +106,7 @@ describe('buildOnboardingFolderAgentStartup', () => { agentArgs: '--dangerously-bypass-approvals-and-sandbox', agentEnv: {} }, + sessionOptions: undefined, telemetry: { agent_kind: 'codex', launch_source: 'onboarding', diff --git a/src/renderer/src/components/pet/PetOverlay.frame-durations.test.tsx b/src/renderer/src/components/pet/PetOverlay.frame-durations.test.tsx new file mode 100644 index 00000000000..1fb5819119a --- /dev/null +++ b/src/renderer/src/components/pet/PetOverlay.frame-durations.test.tsx @@ -0,0 +1,112 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const storeState = vi.hoisted(() => ({ + agentStatusByPaneKey: {}, + agentStatusEpoch: 0, + retainedAgentsByPaneKey: {}, + petSize: 180 +})) + +vi.mock('../../store', () => ({ + useAppStore: Object.assign( + (selector: (state: typeof storeState) => T): T => { + return selector(storeState) + }, + { + getState: () => storeState + } + ) +})) + +vi.mock('./usePetUrl', () => ({ + usePetUrl: () => ({ + url: 'blob:custom-pet', + ready: true, + sprite: { + frameWidth: 192, + frameHeight: 208, + columns: 8, + rows: 9, + sheetWidth: 1536, + sheetHeight: 1872, + fps: 8, + defaultAnimation: 'idle', + animations: { + // Codex ambient idle pacing: 6.6s cycle with long bookend holds. + idle: { row: 0, frames: 6, frameDurationsMs: [1680, 660, 660, 840, 840, 1920] } + } + }, + detected: null + }) +})) + +import { PetOverlay } from './PetOverlay' + +function renderPetOverlay(): { container: HTMLDivElement; root: Root } { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + act(() => { + root.render() + }) + return { container, root } +} + +function installLocalStorage(): void { + const values = new Map() + Object.defineProperty(window, 'localStorage', { + configurable: true, + value: { + clear: () => values.clear(), + getItem: (key: string) => values.get(key) ?? null, + removeItem: (key: string) => values.delete(key), + setItem: (key: string, value: string) => values.set(key, value) + } + }) +} + +describe('PetOverlay per-frame sprite durations', () => { + let root: Root | null = null + let container: HTMLDivElement | null = null + + beforeEach(() => { + installLocalStorage() + }) + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + root = null + container = null + }) + + it('emits one held keyframe stop per frame instead of uniform steps()', () => { + ;({ container, root } = renderPetOverlay()) + + const css = Array.from(container.querySelectorAll('style')) + .map((style) => style.textContent ?? '') + .join('\n') + + // Cumulative stops for [1680, 660, 660, 840, 840, 1920] over 6600ms. + expect(css).toContain('0% { background-position: 0px 0px; }') + expect(css).toContain('25.4545%') + expect(css).toContain('35.4545%') + expect(css).toContain('45.4545%') + expect(css).toContain('58.1818%') + expect(css).toContain('70.9091%') + expect(css).not.toContain('to { background-position:') + + const spriteDiv = Array.from(container.querySelectorAll('div')).find( + (div) => div.style.backgroundImage !== '' + ) + expect(spriteDiv?.style.animation).toContain('6.6s') + expect(spriteDiv?.style.animation).toContain('step-end') + expect(spriteDiv?.style.animation).toContain('infinite') + }) +}) diff --git a/src/renderer/src/components/pet/PetOverlay.pet-switch.test.tsx b/src/renderer/src/components/pet/PetOverlay.pet-switch.test.tsx new file mode 100644 index 00000000000..3467119ee18 --- /dev/null +++ b/src/renderer/src/components/pet/PetOverlay.pet-switch.test.tsx @@ -0,0 +1,107 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const storeState = vi.hoisted(() => ({ + agentStatusByPaneKey: {}, + agentStatusEpoch: 0, + retainedAgentsByPaneKey: {}, + petSize: 180 +})) + +// Why: a mutable pet so a re-render can simulate switching between two cached +// custom pets that both resolve the idle state to different rows. +const petUrlState = vi.hoisted(() => ({ + url: 'blob:pet-a', + idleRow: 0 +})) + +vi.mock('../../store', () => ({ + useAppStore: Object.assign( + (selector: (state: typeof storeState) => T): T => selector(storeState), + { getState: () => storeState } + ) +})) + +vi.mock('./usePetUrl', () => ({ + usePetUrl: () => ({ + url: petUrlState.url, + ready: true, + sprite: { + frameWidth: 192, + frameHeight: 208, + columns: 8, + rows: 9, + sheetWidth: 1536, + sheetHeight: 1872, + fps: 8, + defaultAnimation: 'idle', + animations: { idle: { row: petUrlState.idleRow, frames: 6 } } + }, + detected: null + }) +})) + +import { PetOverlay } from './PetOverlay' + +function installLocalStorage(): void { + const values = new Map() + Object.defineProperty(window, 'localStorage', { + configurable: true, + value: { + clear: () => values.clear(), + getItem: (key: string) => values.get(key) ?? null, + removeItem: (key: string) => values.delete(key), + setItem: (key: string, value: string) => values.set(key, value) + } + }) +} + +function spriteDiv(container: HTMLElement): HTMLDivElement | undefined { + return Array.from(container.querySelectorAll('div')).find( + (div) => div.style.backgroundImage !== '' + ) +} + +describe('PetOverlay pet switching', () => { + let root: Root | null = null + let container: HTMLDivElement | null = null + + beforeEach(() => { + installLocalStorage() + petUrlState.url = 'blob:pet-a' + petUrlState.idleRow = 0 + }) + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + root = null + container = null + }) + + it('remounts the sprite on a pet change so it cannot inherit the prior timeline', () => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => root?.render()) + const before = spriteDiv(container) + expect(before).toBeDefined() + + // Switch to a different cached pet whose idle maps to another row. + petUrlState.url = 'blob:pet-b' + petUrlState.idleRow = 2 + act(() => root?.render()) + + // key={url} makes React replace the element rather than update it in place; + // a fresh element starts its CSS animation at frame 0 instead of carrying + // the prior pet's currentTime. Without the key it would be the same node. + const after = spriteDiv(container) + expect(after).toBeDefined() + expect(after).not.toBe(before) + }) +}) diff --git a/src/renderer/src/components/pet/PetOverlay.pointer-interaction.test.tsx b/src/renderer/src/components/pet/PetOverlay.pointer-interaction.test.tsx new file mode 100644 index 00000000000..5533b5f9052 --- /dev/null +++ b/src/renderer/src/components/pet/PetOverlay.pointer-interaction.test.tsx @@ -0,0 +1,172 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const storeState = vi.hoisted(() => ({ + agentStatusByPaneKey: {}, + agentStatusEpoch: 0, + retainedAgentsByPaneKey: {}, + petSize: 180 +})) + +vi.mock('../../store', () => ({ + useAppStore: Object.assign( + (selector: (state: typeof storeState) => T): T => selector(storeState), + { getState: () => storeState } + ) +})) + +vi.mock('./usePetUrl', () => ({ + usePetUrl: () => ({ + url: 'blob:custom-pet', + ready: true, + sprite: { + frameWidth: 192, + frameHeight: 208, + columns: 8, + rows: 9, + sheetWidth: 1536, + sheetHeight: 1872, + fps: 8, + defaultAnimation: 'idle', + animations: { + idle: { row: 0, frames: 6, frameDurationsMs: [1680, 660, 660, 840, 840, 1920] }, + 'running-right': { row: 1, frames: 8 } + } + }, + detected: null + }) +})) + +import { PetOverlay } from './PetOverlay' + +function renderPetOverlay(): { container: HTMLDivElement; root: Root } { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + act(() => { + root.render() + }) + return { container, root } +} + +function installLocalStorage(): void { + const values = new Map() + Object.defineProperty(window, 'localStorage', { + configurable: true, + value: { + clear: () => values.clear(), + getItem: (key: string) => values.get(key) ?? null, + removeItem: (key: string) => values.delete(key), + setItem: (key: string, value: string) => values.set(key, value) + } + }) +} + +function spriteDiv(container: HTMLElement): HTMLDivElement { + const div = Array.from(container.querySelectorAll('div')).find( + (candidate) => candidate.style.backgroundImage !== '' + ) + if (!div) { + throw new Error('sprite div not found') + } + return div +} + +// The @keyframes name is `pet----`: the +// resolved track keeps a switched-to row from reusing the prior timeline, and +// the generation suffix restarts a same-row grab from frame 0. +function animationName(container: HTMLElement): string { + return spriteDiv(container).style.animation.split(' ')[0] +} + +function firePointer(target: Element, type: string, clientX: number, clientY: number): void { + act(() => { + target.dispatchEvent( + new PointerEvent(type, { clientX, clientY, button: 0, pointerId: 1, bubbles: true }) + ) + }) +} + +describe('PetOverlay grab-and-hold pointer interaction', () => { + let root: Root | null = null + let container: HTMLDivElement | null = null + + beforeEach(() => { + installLocalStorage() + }) + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + root = null + container = null + }) + + it('freezes on a stationary grab, then animates once dragged past the deadzone', () => { + ;({ container, root } = renderPetOverlay()) + const wrapper = container.querySelector('.pointer-events-auto') + if (!wrapper) { + throw new Error('draggable wrapper not found') + } + + // Baseline: the live idle row animates (idle carries per-frame durations, so + // it renders as step-end rather than steps()). + expect(spriteDiv(container).style.animationPlayState).toBe('running') + expect(spriteDiv(container).style.animation).toContain('step-end') + const idleName = animationName(container) + + // Grab and hold still: mint a fresh restart (frame 0) and freeze there while + // staying on the live idle row. + firePointer(wrapper, 'pointerdown', 50, 50) + expect(spriteDiv(container).style.animationPlayState).toBe('paused') + expect(spriteDiv(container).style.animation).toContain('step-end') + expect(animationName(container)).not.toBe(idleName) + const heldName = animationName(container) + + // A sub-4px twitch stays frozen (deadzone). + firePointer(wrapper, 'pointermove', 52, 51) + expect(spriteDiv(container).style.animationPlayState).toBe('paused') + + // A large vertical-only move keeps the hold (no horizontal direction yet). + firePointer(wrapper, 'pointermove', 52, 80) + expect(spriteDiv(container).style.animationPlayState).toBe('paused') + + // Drag right past the 4px deadzone: switch to the running-right row (row 1, + // 8 frames, no durations → steps(8)) and resume animating. The keyframes + // name changes with the resolved row, so it starts from frame 0 rather than + // reusing the idle timeline. + firePointer(wrapper, 'pointermove', 70, 80) + expect(spriteDiv(container).style.animationPlayState).toBe('running') + expect(spriteDiv(container).style.animation).toContain('steps(8)') + expect(animationName(container)).not.toBe(heldName) + + // Release returns to the idle row (step-end) and resumes animating. Same row + // and generation as the grab-hold, so it's the held-idle identity again. + firePointer(wrapper, 'pointerup', 70, 80) + expect(spriteDiv(container).style.animationPlayState).toBe('running') + expect(spriteDiv(container).style.animation).toContain('step-end') + expect(animationName(container)).toBe(heldName) + }) + + it('does not restart the idle track when hover falls back to the same row', () => { + ;({ container, root } = renderPetOverlay()) + const wrapper = container.querySelector('.pointer-events-auto') + if (!wrapper) { + throw new Error('draggable wrapper not found') + } + // The mocked sprite has no jumping row, so hover (→ jumping) resolves back to + // idle. Keying the keyframes on the resolved row means the name must not + // change — otherwise the unchanged idle animation restarts on hover. React + // synthesizes onPointerEnter/Leave from pointerover/pointerout. + const idleName = animationName(container) + firePointer(wrapper, 'pointerover', 0, 0) + expect(animationName(container)).toBe(idleName) + firePointer(wrapper, 'pointerout', 0, 0) + expect(animationName(container)).toBe(idleName) + }) +}) diff --git a/src/renderer/src/components/pet/PetOverlay.tsx b/src/renderer/src/components/pet/PetOverlay.tsx index 89fc81dc6da..052ff64750a 100644 --- a/src/renderer/src/components/pet/PetOverlay.tsx +++ b/src/renderer/src/components/pet/PetOverlay.tsx @@ -5,11 +5,21 @@ import type { DetectedSpriteCacheEntry } from './pet-blob-cache' import type { CustomPet } from '../../../../shared/types' import { useAppStore } from '../../store' import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types' -import { selectPetAnimationName, type PetAnimationName } from './pet-agent-state' +import { + selectPetAnimationName, + type PetAnimationName, + type PetDragAnimation +} from './pet-agent-state' +import { usePetPointerInteraction } from './usePetPointerInteraction' +import { buildSpriteAnimationCss } from './sprite-animation-css' type Sprite = NonNullable -function usePetAnimationName(dragging: boolean): PetAnimationName { +function usePetAnimationName( + dragging: boolean, + dragAnimation: PetDragAnimation, + hovering: boolean +): PetAnimationName { const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey) const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch) const retainedAgentsByPaneKey = useAppStore((s) => s.retainedAgentsByPaneKey) @@ -22,6 +32,8 @@ function usePetAnimationName(dragging: boolean): PetAnimationName { entries: Object.values(agentStatusByPaneKey), retainedCount: Object.keys(retainedAgentsByPaneKey).length, dragging, + dragAnimation, + hovering, now: Date.now(), staleAfterMs: AGENT_STATUS_STALE_AFTER_MS }) @@ -37,15 +49,19 @@ function SpriteFrame({ sprite, animate, maxSize, - animationName + animationName, + restartKey }: { url: string sprite: Sprite animate: boolean maxSize: number animationName: PetAnimationName + // Why: folded into the keyframes name, so bumping it mints a fresh animation + // that restarts from frame 0 even when the state row is unchanged. + restartKey: number }): React.JSX.Element { - const animKeyframesId = useId().replace(/[^a-zA-Z0-9_-]/g, '') + const baseId = useId().replace(/[^a-zA-Z0-9_-]/g, '') const anim = sprite.animations?.[animationName] || (sprite.defaultAnimation && sprite.animations?.[sprite.defaultAnimation]) || @@ -54,6 +70,11 @@ function SpriteFrame({ // Why: clamp to >=1 so an empty/invalid manifest can't produce steps(0), // which is rejected as invalid CSS and freezes the animation. const frames = Math.max(1, anim?.frames ?? sprite.columns ?? 1) + // Why: name the @keyframes by the RESOLVED track (+restartKey for same-row + // grabs), so a genuine row change starts at frame 0 while a state that falls + // back to the same row (e.g. hover on a pet without a jumping row) doesn't + // needlessly restart. + const animKeyframesId = `${baseId}-${row}-${frames}-${restartKey}` // Why: allow fractional downscaling so frames larger than maxSize shrink to // fit instead of overflowing the overlay; mirrors DetectedSpriteFrame's math. const scale = Math.min(maxSize / sprite.frameWidth, maxSize / sprite.frameHeight) @@ -63,11 +84,15 @@ function SpriteFrame({ const bgH = sprite.sheetHeight * scale const startX = 0 const startY = -(row * sprite.frameHeight * scale) - const endX = -(frames * sprite.frameWidth * scale) - const duration = Math.max(0.1, frames / Math.max(0.1, sprite.fps)) - // Why: sprite keyframes are runtime CSS, not user-visible copy; translated - // CSS keywords make the browser discard the animation. - const keyframesCss = `@keyframes pet-${animKeyframesId} { from { background-position: ${startX}px ${startY}px; } to { background-position: ${endX}px ${startY}px; } }` + const { keyframesCss, animationCss } = buildSpriteAnimationCss({ + keyframesId: animKeyframesId, + frames, + fps: sprite.fps, + frameWidth: sprite.frameWidth, + scale, + rowOffsetY: startY, + frameDurationsMs: anim?.frameDurationsMs + }) return ( <> @@ -80,7 +105,7 @@ function SpriteFrame({ backgroundSize: `${bgW}px ${bgH}px`, backgroundPosition: `${startX}px ${startY}px`, imageRendering: 'pixelated', - animation: `pet-${animKeyframesId} ${duration}s steps(${frames}) infinite`, + animation: animationCss, animationPlayState: animate ? 'running' : 'paused' }} /> @@ -324,8 +349,10 @@ export function PetOverlay(): React.JSX.Element { }, [size] ) - const [dragging, setDragging] = useState(false) - const dragOffsetRef = useRef({ x: 0, y: 0 }) + const { dragging, dragAnimation, hovering, dragGeneration, handlers } = usePetPointerInteraction( + position, + (next) => setPosition(clampToViewport(next, size)) + ) useEffect(() => { const onResize = (): void => setPosition((prev) => clampToViewport(prev, size)) @@ -344,46 +371,12 @@ export function PetOverlay(): React.JSX.Element { } }, [dragging, position]) - const animate = documentVisible && !reducedMotion && !dragging - const animationName = usePetAnimationName(dragging) - - // Why: setPointerCapture routes subsequent pointer events to this element - // even when the cursor leaves the OS window, so dragging can't get stuck in - // the "true" state if the user releases outside the app. - const onPointerDown = (event: React.PointerEvent): void => { - if (event.button !== 0) { - return - } - dragOffsetRef.current = { - x: event.clientX - position.x, - y: event.clientY - position.y - } - event.currentTarget.setPointerCapture(event.pointerId) - setDragging(true) - event.preventDefault() - } - - const onPointerMove = (event: React.PointerEvent): void => { - if (!dragging) { - return - } - setPosition( - clampToViewport( - { - x: event.clientX - dragOffsetRef.current.x, - y: event.clientY - dragOffsetRef.current.y - }, - size - ) - ) - } - - const endDrag = (event: React.PointerEvent): void => { - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId) - } - setDragging(false) - } + const motionAllowed = documentVisible && !reducedMotion + // Why: a still/vertical grab freezes on frame 0 (Codex grab-and-hold); a + // horizontal drag keeps animating so the running rows show. Bob always pauses. + const spriteAnimate = motionAllowed && (!dragging || dragAnimation !== null) + const bobAnimate = motionAllowed && !dragging + const animationName = usePetAnimationName(dragging, dragAnimation, hovering) return ( // Why: the outer box and middle layer stay pointer-events-none so app chrome @@ -401,15 +394,12 @@ export function PetOverlay(): React.JSX.Element { >
    {sprite ? ( + // Why: remount per pet so a switched-to sprite starts a fresh + // animation instead of inheriting the prior pet's currentTime. ) : detected ? ( - + ) : ( // Why: cap explicitly at the pet size — the w-fit/h-fit wrapper is // fit-content, so max-w/h-full has no fixed box to resolve against diff --git a/src/renderer/src/components/pet/pet-agent-state.test.ts b/src/renderer/src/components/pet/pet-agent-state.test.ts index 4c6d28b7135..09dc0813b5b 100644 --- a/src/renderer/src/components/pet/pet-agent-state.test.ts +++ b/src/renderer/src/components/pet/pet-agent-state.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { AgentStatusEntry, AgentStatusState } from '../../../../shared/agent-status-types' -import { selectPetAnimationName } from './pet-agent-state' +import { nextPetDragAnimation, selectPetAnimationName } from './pet-agent-state' const NOW = 1_000 const STALE_AFTER_MS = 500 @@ -28,6 +28,8 @@ function select( entries, retainedCount: 0, dragging: false, + dragAnimation: null, + hovering: false, now: NOW, staleAfterMs: STALE_AFTER_MS, ...options @@ -63,7 +65,57 @@ describe('selectPetAnimationName', () => { expect(select([entry('working'), entry('done', { interrupted: true })])).toBe('running') }) - it('uses jumping while the pet is being dragged', () => { - expect(select([entry('blocked')], { dragging: true })).toBe('jumping') + it('keeps the live agent state while grabbed and held still (no drag direction)', () => { + expect(select([entry('blocked')], { dragging: true })).toBe('waiting') + expect(select([entry('working')], { dragging: true })).toBe('running') + expect(select([], { dragging: true })).toBe('idle') + }) + + it('runs toward the drag direction while the pet is dragged horizontally', () => { + expect(select([entry('blocked')], { dragging: true, dragAnimation: 'running-right' })).toBe( + 'running-right' + ) + expect(select([], { dragging: true, dragAnimation: 'running-left' })).toBe('running-left') + }) + + it('uses jumping while hovered but not dragging', () => { + expect(select([], { hovering: true })).toBe('jumping') + expect(select([entry('working')], { hovering: true })).toBe('jumping') + }) + + it('prefers the live state over hover while grabbed (drag suppresses the hover jump)', () => { + expect(select([entry('working')], { dragging: true, hovering: true })).toBe('running') + }) +}) + +describe('nextPetDragAnimation', () => { + it('keeps the direction and baseline for sub-threshold horizontal travel', () => { + expect(nextPetDragAnimation(null, 3)).toEqual({ animation: null, accepted: false }) + expect(nextPetDragAnimation('running-right', -3)).toEqual({ + animation: 'running-right', + accepted: false + }) + }) + + it('picks the horizontal direction at the 4px threshold and advances the baseline', () => { + expect(nextPetDragAnimation(null, 4)).toEqual({ + animation: 'running-right', + accepted: true + }) + expect(nextPetDragAnimation(null, -4)).toEqual({ + animation: 'running-left', + accepted: true + }) + }) + + it('keeps the last direction without advancing when horizontal travel stays under 4px', () => { + // A vertical/near-vertical drag has ~0 horizontal delta: keep the direction + // but do not reset the baseline, so accumulated horizontal travel still adds + // up toward the threshold on a slow diagonal drag. + expect(nextPetDragAnimation('running-left', 0)).toEqual({ + animation: 'running-left', + accepted: false + }) + expect(nextPetDragAnimation(null, 0)).toEqual({ animation: null, accepted: false }) }) }) diff --git a/src/renderer/src/components/pet/pet-agent-state.ts b/src/renderer/src/components/pet/pet-agent-state.ts index f45d54bad35..72a0f520f66 100644 --- a/src/renderer/src/components/pet/pet-agent-state.ts +++ b/src/renderer/src/components/pet/pet-agent-state.ts @@ -1,27 +1,48 @@ import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { isExplicitAgentStatusFresh } from '@/lib/agent-status' -export type PetAnimationName = 'idle' | 'running' | 'waiting' | 'review' | 'jumping' +export type PetAnimationName = + | 'idle' + | 'running' + | 'waiting' + | 'review' + | 'jumping' + | 'running-right' + | 'running-left' + +export type PetDragAnimation = 'running-right' | 'running-left' | null + +// Why: direction tracks horizontal travel only; `accepted` (advance the baseline) +// fires only on a >=4px horizontal move so slow diagonal drags still accumulate. +export function nextPetDragAnimation( + current: PetDragAnimation, + deltaX: number +): { animation: PetDragAnimation; accepted: boolean } { + if (deltaX >= 4) { + return { animation: 'running-right', accepted: true } + } + if (deltaX <= -4) { + return { animation: 'running-left', accepted: true } + } + return { animation: current, accepted: false } +} export type PetAnimationInput = { entries: AgentStatusEntry[] retainedCount: number dragging: boolean + dragAnimation: PetDragAnimation + hovering: boolean now: number staleAfterMs: number } -export function selectPetAnimationName({ - entries, - retainedCount, - dragging, - now, - staleAfterMs -}: PetAnimationInput): PetAnimationName { - if (dragging) { - return 'jumping' - } - +function agentStateAnimation( + entries: AgentStatusEntry[], + retainedCount: number, + now: number, + staleAfterMs: number +): PetAnimationName { let hasWorking = false let hasDone = false @@ -47,3 +68,24 @@ export function selectPetAnimationName({ } return 'idle' } + +export function selectPetAnimationName({ + entries, + retainedCount, + dragging, + dragAnimation, + hovering, + now, + staleAfterMs +}: PetAnimationInput): PetAnimationName { + const base = agentStateAnimation(entries, retainedCount, now, staleAfterMs) + // Why: aligned with Codex. A horizontal drag runs toward the pointer, + // grab-and-hold keeps the live agent state, and only a plain hover jumps. + if (dragging) { + return dragAnimation ?? base + } + if (hovering) { + return 'jumping' + } + return base +} diff --git a/src/renderer/src/components/pet/sprite-animation-css.test.ts b/src/renderer/src/components/pet/sprite-animation-css.test.ts new file mode 100644 index 00000000000..cf694e66bd4 --- /dev/null +++ b/src/renderer/src/components/pet/sprite-animation-css.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import { buildSpriteAnimationCss } from './sprite-animation-css' + +const BASE = { + keyframesId: 'kf', + frameWidth: 100, + scale: 1, + rowOffsetY: -200 +} + +describe('buildSpriteAnimationCss', () => { + it('emits one step-end stop per frame for uneven Codex-style pacing', () => { + const { keyframesCss, animationCss } = buildSpriteAnimationCss({ + ...BASE, + frames: 6, + fps: 8, + frameDurationsMs: [1680, 660, 660, 840, 840, 1920] + }) + + // Cumulative stops for [1680, 660, 660, 840, 840, 1920] over 6600ms. + expect(keyframesCss).toContain('0% { background-position: 0px -200px; }') + expect(keyframesCss).toContain('25.4545% { background-position: -100px -200px; }') + expect(keyframesCss).toContain('70.9091% { background-position: -500px -200px; }') + expect(keyframesCss).not.toContain(' to {') + expect(animationCss).toBe('pet-kf 6.6s step-end infinite') + }) + + it('falls back to uniform steps() when durations are absent, invalid, or corrupt', () => { + for (const frameDurationsMs of [ + undefined, + [100, 100], + [100, -1, 100], + [100, Number.NaN, 100], + // Above the importer's 60s cap: corrupt/hand-edited persisted holds must + // not freeze the overlay. + [100, 70_000, 100], + // Untrusted persisted data: non-arrays with a matching `length` must not + // reach .every and throw. + { length: 3 } as unknown as number[], + 'aaa' as unknown as number[] + ]) { + const { keyframesCss, animationCss } = buildSpriteAnimationCss({ + ...BASE, + frames: 3, + fps: 6, + frameDurationsMs + }) + expect(keyframesCss).toBe( + '@keyframes pet-kf { from { background-position: 0px -200px; } to { background-position: -300px -200px; } }' + ) + expect(animationCss).toBe('pet-kf 0.5s steps(3) infinite') + } + }) + + it('degrades to uniform pacing rather than dropping a frame that rounds away', () => { + // A sub-precision frame among a ~minute total loses its own stop: a leading + // one collapses two stops to 0%, a trailing one rounds the final stop to + // 100% (no interval before the loop). Either way, degrade to steps() so + // every frame still renders instead of letting one vanish. + for (const frameDurationsMs of [ + [0.001, 60_000], + [60_000, 0.001] + ]) { + const { keyframesCss, animationCss } = buildSpriteAnimationCss({ + ...BASE, + frames: 2, + fps: 8, + frameDurationsMs + }) + expect(keyframesCss).not.toContain('step') + expect(keyframesCss).toContain(' to {') + expect(animationCss).toContain('steps(2)') + } + }) + + it('renders a genuinely short-but-representable frame as its own stop', () => { + const { keyframesCss } = buildSpriteAnimationCss({ + ...BASE, + frames: 2, + fps: 8, + frameDurationsMs: [10, 990] + }) + expect(keyframesCss).toContain('0% { background-position: 0px -200px; }') + expect(keyframesCss).toContain('1% { background-position: -100px -200px; }') + }) +}) diff --git a/src/renderer/src/components/pet/sprite-animation-css.ts b/src/renderer/src/components/pet/sprite-animation-css.ts new file mode 100644 index 00000000000..0970acaa7aa --- /dev/null +++ b/src/renderer/src/components/pet/sprite-animation-css.ts @@ -0,0 +1,101 @@ +// Sprite-sheet keyframe CSS for the pet overlay. Kept as a pure module so the +// pacing math is unit-testable without mounting the overlay or a DOM. + +// Mirror of the importer's per-frame cap (pet.ts zod schema): a hold longer than +// this at render would freeze the overlay, so reject it on the render side too. +const MAX_FRAME_DURATION_MS = 60_000 + +export type SpriteAnimationCss = { + keyframesCss: string + animationCss: string +} + +export type SpriteAnimationCssInput = { + // Sanitized `@keyframes` identifier; folded with the restart key by the caller. + keyframesId: string + frames: number + fps: number + frameWidth: number + scale: number + // Vertical offset selecting the animation's row on the sheet. + rowOffsetY: number + // Per-frame holds in ms; uneven pacing renders when they pass validation. + frameDurationsMs: number[] | undefined +} + +// Why: sprite keyframes are runtime CSS, not user-visible copy; translated CSS +// keywords make the browser discard the animation, so keep them out of i18n. +export function buildSpriteAnimationCss({ + keyframesId, + frames, + fps, + frameWidth, + scale, + rowOffsetY, + frameDurationsMs +}: SpriteAnimationCssInput): SpriteAnimationCss { + const name = `pet-${keyframesId}` + const durations = validFrameDurations(frameDurationsMs, frames) + if (durations) { + const totalMs = durations.reduce((sum, ms) => sum + ms, 0) + // Why: Codex pets hold frames unevenly (idle rests ~1.9s on its last frame). + // steps() can't express that, so emit one step-end stop per frame. + const stops = stepEndStops(durations, totalMs, frameWidth, scale, rowOffsetY) + if (stops) { + return { + keyframesCss: `@keyframes ${name} { ${stops.join(' ')} }`, + animationCss: `${name} ${totalMs / 1000}s step-end infinite` + } + } + } + // Uniform sheet fps: one steps() run across the row. + const duration = Math.max(0.1, frames / Math.max(0.1, fps)) + const endX = -(frames * frameWidth * scale) + return { + keyframesCss: `@keyframes ${name} { from { background-position: 0px ${rowOffsetY}px; } to { background-position: ${endX}px ${rowOffsetY}px; } }`, + animationCss: `${name} ${duration}s steps(${frames}) infinite` + } +} + +function validFrameDurations( + frameDurationsMs: number[] | undefined, + frames: number +): number[] | null { + // Why: Array.isArray + bounds, not a truthiness check — persisted/RPC-synced + // sprites are untrusted, so a corrupt non-array or out-of-range hold falls back + // to uniform pacing instead of throwing or freezing the overlay. + if ( + Array.isArray(frameDurationsMs) && + frameDurationsMs.length === frames && + frameDurationsMs.every((ms) => Number.isFinite(ms) && ms > 0 && ms <= MAX_FRAME_DURATION_MS) + ) { + return frameDurationsMs + } + return null +} + +// Cumulative step-end stops, one per frame. Returns null (→ uniform fallback) +// when a frame is too short to survive 4-decimal precision (two stops collapse, +// or the final stop rounds to 100%) so no frame is silently dropped. +function stepEndStops( + durations: number[], + totalMs: number, + frameWidth: number, + scale: number, + rowOffsetY: number +): string[] | null { + const stops: string[] = [] + let elapsedMs = 0 + let previousPct = -1 + for (let index = 0; index < durations.length; index++) { + const pct = +((elapsedMs / totalMs) * 100).toFixed(4) + if (pct <= previousPct || pct >= 100) { + return null + } + previousPct = pct + const x = -(index * frameWidth * scale) + stops.push(`${pct}% { background-position: ${x}px ${rowOffsetY}px; }`) + elapsedMs += durations[index] + } + return stops +} diff --git a/src/renderer/src/components/pet/usePetPointerInteraction.test.ts b/src/renderer/src/components/pet/usePetPointerInteraction.test.ts new file mode 100644 index 00000000000..e234c32986d --- /dev/null +++ b/src/renderer/src/components/pet/usePetPointerInteraction.test.ts @@ -0,0 +1,142 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { usePetPointerInteraction } from './usePetPointerInteraction' + +type HandlerName = keyof ReturnType['handlers'] + +function fakeTarget(): { + setPointerCapture: (id: number) => void + hasPointerCapture: (id: number) => boolean + releasePointerCapture: (id: number) => void +} { + const captured = new Set() + return { + setPointerCapture: (id) => void captured.add(id), + hasPointerCapture: (id) => captured.has(id), + releasePointerCapture: (id) => void captured.delete(id) + } +} + +function setup() { + const moveTo = vi.fn() + const target = fakeTarget() + const { result } = renderHook(() => usePetPointerInteraction({ x: 0, y: 0 }, moveTo)) + + const event = (props: { + clientX?: number + clientY?: number + pointerId?: number + button?: number + }) => + ({ + button: 0, + pointerId: 1, + clientX: 0, + clientY: 0, + currentTarget: target, + preventDefault: () => {}, + ...props + }) as unknown as Parameters< + ReturnType['handlers']['onPointerDown'] + >[0] + + const fire = (name: HandlerName, props: Parameters[0] = {}): void => { + act(() => result.current.handlers[name](event(props))) + } + + return { result, moveTo, fire, event } +} + +describe('usePetPointerInteraction', () => { + it('starts a drag and bumps the restart generation on a primary grab', () => { + const { result, fire } = setup() + fire('onPointerDown', { clientX: 50, clientY: 50 }) + expect(result.current.dragging).toBe(true) + expect(result.current.dragAnimation).toBe(null) + expect(result.current.dragGeneration).toBe(1) + }) + + it('runs toward the horizontal drag direction past the 4px deadzone', () => { + const { result, fire, moveTo } = setup() + fire('onPointerDown', { clientX: 50, clientY: 50 }) + fire('onPointerMove', { clientX: 58, clientY: 50 }) + expect(result.current.dragAnimation).toBe('running-right') + expect(moveTo).toHaveBeenLastCalledWith({ x: 8, y: 0 }) + fire('onPointerMove', { clientX: 40, clientY: 50 }) + expect(result.current.dragAnimation).toBe('running-left') + }) + + it('ignores sub-deadzone jitter and keeps the last direction on vertical moves', () => { + const { result, fire } = setup() + fire('onPointerDown', { clientX: 50, clientY: 50 }) + fire('onPointerMove', { clientX: 52, clientY: 52 }) + expect(result.current.dragAnimation).toBe(null) + fire('onPointerMove', { clientX: 58, clientY: 52 }) + expect(result.current.dragAnimation).toBe('running-right') + fire('onPointerMove', { clientX: 58, clientY: 100 }) + expect(result.current.dragAnimation).toBe('running-right') + }) + + it('accumulates horizontal travel across a slow diagonal drag', () => { + // Regression: the baseline advances only on an accepted direction, so a + // sequence of sub-4px diagonal moves still crosses the threshold instead of + // resetting each time and never triggering. + const { result, fire } = setup() + fire('onPointerDown', { clientX: 50, clientY: 50 }) + fire('onPointerMove', { clientX: 53, clientY: 55 }) + expect(result.current.dragAnimation).toBe(null) + fire('onPointerMove', { clientX: 56, clientY: 60 }) + expect(result.current.dragAnimation).toBe('running-right') + }) + + it('keeps the latest horizontal direction when two moves land in one commit', () => { + // Regression: the direction lives in a ref, not the state closure, so a + // vertical move batched right after a horizontal one cannot resurrect the + // stale prior direction. + const { result } = renderHook(() => usePetPointerInteraction({ x: 0, y: 0 }, vi.fn())) + const target = fakeTarget() + const ev = (clientX: number, clientY: number) => + ({ + button: 0, + pointerId: 1, + clientX, + clientY, + currentTarget: target, + preventDefault: () => {} + }) as never + + act(() => result.current.handlers.onPointerDown(ev(50, 50))) + act(() => { + result.current.handlers.onPointerMove(ev(40, 50)) // left + result.current.handlers.onPointerMove(ev(40, 90)) // vertical from the new sample + }) + expect(result.current.dragAnimation).toBe('running-left') + }) + + it('scopes the drag to the owning pointer and ignores a second touch', () => { + const { result, fire, moveTo } = setup() + fire('onPointerDown', { clientX: 50, clientY: 50, pointerId: 1 }) + // A second pointer must not hijack the drag or mint a new restart. + fire('onPointerDown', { clientX: 50, clientY: 50, pointerId: 2 }) + expect(result.current.dragGeneration).toBe(1) + fire('onPointerMove', { clientX: 90, clientY: 50, pointerId: 2 }) + expect(moveTo).not.toHaveBeenCalled() + // Releasing the non-owning pointer leaves the drag alive. + fire('onPointerUp', { pointerId: 2 }) + expect(result.current.dragging).toBe(true) + // The owning pointer ends it. + fire('onPointerUp', { pointerId: 1 }) + expect(result.current.dragging).toBe(false) + expect(result.current.dragAnimation).toBe(null) + }) + + it('ends the drag when pointer capture is lost', () => { + const { result, fire } = setup() + fire('onPointerDown', { clientX: 50, clientY: 50 }) + fire('onLostPointerCapture', { pointerId: 1 }) + expect(result.current.dragging).toBe(false) + expect(result.current.dragAnimation).toBe(null) + }) +}) diff --git a/src/renderer/src/components/pet/usePetPointerInteraction.ts b/src/renderer/src/components/pet/usePetPointerInteraction.ts new file mode 100644 index 00000000000..92d3a45c6c9 --- /dev/null +++ b/src/renderer/src/components/pet/usePetPointerInteraction.ts @@ -0,0 +1,117 @@ +import { useRef, useState, type PointerEvent as ReactPointerEvent } from 'react' +import { nextPetDragAnimation, type PetDragAnimation } from './pet-agent-state' + +type Point = { x: number; y: number } + +export type PetPointerInteraction = { + dragging: boolean + dragAnimation: PetDragAnimation + hovering: boolean + // Why: bumped on grab so the sprite restarts from frame 0, aligned with the + // Codex mascot. + dragGeneration: number + handlers: { + onPointerDown: (event: ReactPointerEvent) => void + onPointerMove: (event: ReactPointerEvent) => void + onPointerUp: (event: ReactPointerEvent) => void + onPointerCancel: (event: ReactPointerEvent) => void + onLostPointerCapture: (event: ReactPointerEvent) => void + onPointerEnter: () => void + onPointerLeave: () => void + } +} + +// Drag/hover state for the pet overlay. `position` is the overlay's current +// top-left corner and `moveTo` receives the unclamped position the drag wants. +export function usePetPointerInteraction( + position: Point, + moveTo: (next: Point) => void +): PetPointerInteraction { + const [dragging, setDragging] = useState(false) + const [dragAnimation, setDragAnimation] = useState(null) + const [hovering, setHovering] = useState(false) + const [dragGeneration, setDragGeneration] = useState(0) + const dragOffsetRef = useRef({ x: 0, y: 0 }) + // Why: horizontal baseline for the drag-direction hysteresis, advanced only on + // an accepted direction. Kept separate from dragOffsetRef (position math). + const dragBaselineXRef = useRef(0) + // Why: read+written inside handlers, so keep in refs immune to render batching + // (state would let two coalesced moves resurrect a stale direction). + const dragDirectionRef = useRef(null) + const activePointerRef = useRef(null) + + // Why: setPointerCapture routes subsequent pointer events to this element + // even when the cursor leaves the OS window, so dragging can't get stuck in + // the "true" state if the user releases outside the app. + const onPointerDown = (event: ReactPointerEvent): void => { + // Why: primary button only, and one pointer owns the drag — a second touch + // must not hijack the anchors mid-drag. + if (event.button !== 0 || activePointerRef.current !== null) { + return + } + activePointerRef.current = event.pointerId + dragOffsetRef.current = { + x: event.clientX - position.x, + y: event.clientY - position.y + } + dragBaselineXRef.current = event.clientX + dragDirectionRef.current = null + event.currentTarget.setPointerCapture(event.pointerId) + setDragging(true) + setDragAnimation(null) + setDragGeneration((generation) => generation + 1) + event.preventDefault() + } + + const onPointerMove = (event: ReactPointerEvent): void => { + if (event.pointerId !== activePointerRef.current) { + return + } + const next = nextPetDragAnimation( + dragDirectionRef.current, + event.clientX - dragBaselineXRef.current + ) + if (next.accepted) { + dragBaselineXRef.current = event.clientX + if (next.animation !== dragDirectionRef.current) { + dragDirectionRef.current = next.animation + setDragAnimation(next.animation) + } + } + moveTo({ + x: event.clientX - dragOffsetRef.current.x, + y: event.clientY - dragOffsetRef.current.y + }) + } + + const endDrag = (event: ReactPointerEvent): void => { + if (event.pointerId !== activePointerRef.current) { + return + } + activePointerRef.current = null + dragDirectionRef.current = null + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId) + } + setDragging(false) + setDragAnimation(null) + } + + return { + dragging, + dragAnimation, + hovering, + dragGeneration, + handlers: { + onPointerDown, + onPointerMove, + onPointerUp: endDrag, + onPointerCancel: endDrag, + // Why: capture can be revoked without a pointerup (e.g. the element loses + // it); treat that as the end of the drag so it can't wedge on. + onLostPointerCapture: endDrag, + onPointerEnter: () => setHovering(true), + onPointerLeave: () => setHovering(false) + } + } +} diff --git a/src/renderer/src/components/pet/usePetUrl.ts b/src/renderer/src/components/pet/usePetUrl.ts index 60c5dbef9d7..efc9fc05568 100644 --- a/src/renderer/src/components/pet/usePetUrl.ts +++ b/src/renderer/src/components/pet/usePetUrl.ts @@ -1,5 +1,6 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react' import type { CustomPet } from '../../../../shared/types' +import { applyCodexSpriteTimingDefaults } from '../../../../shared/codex-pet-sprite-defaults' import { useAppStore } from '../../store' import { BUNDLED_PET, findBundledPet, isBundledPetId } from './pet-models' import { @@ -114,7 +115,14 @@ export function usePetUrl(): ResolvedPet { customMeta.sprite.frameHeight > 0 && customMeta.sprite.fps > 0 ) { - return { url: customUrl, ready: true, sprite: customMeta.sprite, detected: null } + // Why: sprites persisted before per-frame durations existed pace ~9x too + // fast. Upgrade the legacy Codex fingerprint without forcing a re-import. + return { + url: customUrl, + ready: true, + sprite: applyCodexSpriteTimingDefaults(customMeta.sprite), + detected: null + } } const detected = detectedSpriteCache.get(customMeta.id) if (detected) { diff --git a/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts b/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts index 336a3882b3d..d3afd06e139 100644 --- a/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts +++ b/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts @@ -3,6 +3,7 @@ import { useAppStore } from '@/store' import type { TuiAgent } from '../../../../shared/types' import type { SourceControlAgentActionDeliveryPlanState } from './SourceControlAgentActionDialogForm' import { buildSourceControlAgentConnectionErrorPlan } from './source-control-agent-action-dialog-support' +import { resolveNativeChatSessionOptionDefaults } from '../../../../shared/native-chat-session-option-defaults' type BuildSourceControlAgentDeliveryPlanArgs = { selectedAgent: TuiAgent | null @@ -34,6 +35,12 @@ export function buildSourceControlAgentDeliveryPlan({ agent: selectedAgent, commandInput, agentArgs, + sessionOptions: selectedAgent + ? resolveNativeChatSessionOptionDefaults( + useAppStore.getState().settings?.nativeChatSessionOptions, + selectedAgent + ) + : undefined, promptDelivery, detectedAgents, disabledAgents: useAppStore.getState().settings?.disabledTuiAgents, diff --git a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx index 3228c54669c..ad68464b3e1 100644 --- a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx +++ b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx @@ -22,6 +22,7 @@ import { USAGE_PERCENTAGE_DISPLAY_SETTING_ID } from './appearance-usage-percenta import { LeftSidebarAppearanceSetting } from './LeftSidebarAppearanceSetting' import { getLeftSidebarAppearanceEntry, + getShowPinnedWorktreesInGroupsEntry, getWorkspaceCardLayoutEntry } from './appearance-sidebar-search' import { translate } from '@/i18n/i18n' @@ -308,6 +309,31 @@ export function AppearanceWindowSidebarSection({ } /> + + + + updateSettings({ + showPinnedWorktreesInGroups: !(settings.showPinnedWorktreesInGroups === true) + }) + } + /> +
    ) : null} diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 0d4c2d5d537..ef50a504a3a 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -12,6 +12,7 @@ import { import { toast } from 'sonner' import type { GlobalSettings, OrcaHooks, ProjectHostSetup, Repo } from '../../../../shared/types' import type { SpeechModelState } from '../../../../shared/speech-types' +import type { SkillFreshnessInventory } from '../../../../shared/skill-freshness' import type { SourceControlAiSettings, SourceControlAiSettingsPatch @@ -101,6 +102,7 @@ import { } from '@/hooks/useInstalledAgentSkills' import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' import { useSkillFreshness } from '@/hooks/useSkillFreshness' +import { getSkillFreshnessDisplayStatus } from '@/lib/skill-freshness-display-status' import { deriveNeededSectionIds, getInitialMountedSectionIds } from './settings-load-performance' import { translate } from '@/i18n/i18n' import { getProjectHostSetupProjectionFromState } from '../../store/selectors' @@ -206,9 +208,10 @@ function getSettingsNavGroupDefinitionsForSearch( } function getSkillNavInstallStatus(skill: { + name: string installed: boolean loading: boolean - updateAvailable?: boolean + inventory: SkillFreshnessInventory | null }): SettingsNavInstallStatus { if (skill.loading) { return 'checking' @@ -216,7 +219,7 @@ function getSkillNavInstallStatus(skill: { if (!skill.installed) { return 'install' } - return skill.updateAvailable ? 'update-available' : 'installed' + return getSkillFreshnessDisplayStatus(skill.inventory, skill.name) } function hasReadyVoiceModel( @@ -733,16 +736,16 @@ function Settings(): React.JSX.Element { orchestrationSkill const { installed: computerUseSkillInstalled, loading: computerUseSkillLoading } = computerUseSkill - const eligibleUpdateSkillNames = skillFreshnessInventory?.eligibleUpdateNames const capabilityInstallStatusBySectionId = useMemo(() => { - const eligibleUpdates = new Set(skillFreshnessApplies ? (eligibleUpdateSkillNames ?? []) : []) + const applicableFreshnessInventory = skillFreshnessApplies ? skillFreshnessInventory : null const next = new Map([ [ 'orchestration', getSkillNavInstallStatus({ + name: ORCHESTRATION_SKILL_NAME, installed: orchestrationSkillInstalled, loading: orchestrationSkillLoading, - updateAvailable: eligibleUpdates.has(ORCHESTRATION_SKILL_NAME) + inventory: applicableFreshnessInventory }) ] ]) @@ -750,9 +753,10 @@ function Settings(): React.JSX.Element { next.set( 'computer-use', getSkillNavInstallStatus({ + name: COMPUTER_USE_SKILL_NAME, installed: computerUseSkillInstalled, loading: computerUseSkillLoading, - updateAvailable: eligibleUpdates.has(COMPUTER_USE_SKILL_NAME) + inventory: applicableFreshnessInventory }) ) if (settings) { @@ -770,13 +774,13 @@ function Settings(): React.JSX.Element { }, [ computerUseSkillInstalled, computerUseSkillLoading, - eligibleUpdateSkillNames, modelStates, orchestrationSkillInstalled, orchestrationSkillLoading, settings, showDesktopOnlySettings, skillFreshnessApplies, + skillFreshnessInventory, voiceModelStatesLoading ]) const navSections = useMemo( diff --git a/src/renderer/src/components/settings/SettingsFormControls.tsx b/src/renderer/src/components/settings/SettingsFormControls.tsx index 146e0000f62..7c55b38899c 100644 --- a/src/renderer/src/components/settings/SettingsFormControls.tsx +++ b/src/renderer/src/components/settings/SettingsFormControls.tsx @@ -62,6 +62,7 @@ type SettingsRowProps = { label: React.ReactNode description?: React.ReactNode control: React.ReactNode + className?: string /** Optional id applied to the label so the control can reference it via aria-labelledby. */ labelId?: string /** When true, top-align label/description and control. Useful for tall control columns. */ @@ -73,6 +74,7 @@ export function SettingsRow({ label, description, control, + className, labelId, alignTop }: SettingsRowProps): React.JSX.Element { @@ -81,7 +83,8 @@ export function SettingsRow({ className={cn( 'flex gap-4', description ? 'py-3' : 'py-2', - alignTop ? 'items-start' : 'items-center justify-between' + alignTop ? 'items-start' : 'items-center justify-between', + className )} >
    @@ -102,6 +105,7 @@ type SettingsSwitchRowProps = { description?: React.ReactNode checked: boolean onChange: () => void + className?: string ariaLabel?: string } @@ -110,12 +114,14 @@ export function SettingsSwitchRow({ description, checked, onChange, + className, ariaLabel }: SettingsSwitchRowProps): React.JSX.Element { return ( { expect(markup).toContain('Not installed') expect(markup).toContain('Installed') + expect(markup).toContain('Up to date') expect(markup).toContain('Optional') }) diff --git a/src/renderer/src/components/settings/SettingsSidebar.tsx b/src/renderer/src/components/settings/SettingsSidebar.tsx index 8029eabda1a..54908713bc6 100644 --- a/src/renderer/src/components/settings/SettingsSidebar.tsx +++ b/src/renderer/src/components/settings/SettingsSidebar.tsx @@ -155,6 +155,8 @@ export function SettingsSidebar({ ) case 'installed': return translate('auto.components.settings.AgentSkillSetupPanel.9fcebceb2a', 'Installed') + case 'up-to-date': + return translate('auto.components.skills.SkillFreshnessStatusPill.upToDate', 'Up to date') case 'update-available': return translate( 'auto.components.skills.SkillFreshnessStatusPill.updateAvailable', @@ -167,7 +169,7 @@ export function SettingsSidebar({ const installStatusClassName = (status: SettingsNavInstallStatus): string => cn( 'ml-auto shrink-0 rounded-full border px-1.5 py-0.5 text-[10px] font-medium leading-none', - status === 'installed' + status === 'installed' || status === 'up-to-date' ? 'border-status-success-border bg-status-success-background text-status-success' : status === 'update-available' ? 'border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300' diff --git a/src/renderer/src/components/settings/ShortcutCommandBlock.tsx b/src/renderer/src/components/settings/ShortcutCommandBlock.tsx index 853e6ae3c62..c4eaa9cbe84 100644 --- a/src/renderer/src/components/settings/ShortcutCommandBlock.tsx +++ b/src/renderer/src/components/settings/ShortcutCommandBlock.tsx @@ -106,6 +106,8 @@ export function ShortcutCommandBlock({ /> ) + // Why: ShortcutsPane already filters rows against the global query; + // forceVisible skips the re-match here that would hide rows it kept. return (
    diff --git a/src/renderer/src/components/settings/ShortcutFilterRail.test.ts b/src/renderer/src/components/settings/ShortcutFilterRail.test.ts index d979415798a..8dd6a039010 100644 --- a/src/renderer/src/components/settings/ShortcutFilterRail.test.ts +++ b/src/renderer/src/components/settings/ShortcutFilterRail.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import type { KeybindingActionId } from '../../../../shared/keybindings' import { SHORTCUT_LOCAL_SEARCH_QUERY_MAX_BYTES, + buildShortcutGlobalSearchMatcher, isShortcutLocalSearchQueryTooLarge, matchesShortcutLocalSearch, normalizeShortcutLocalSearchQuery, @@ -52,6 +53,40 @@ describe('ShortcutFilterRail search helpers', () => { expect(normalizeShortcutLocalSearchQuery(query)).toBeNull() }) + it('narrows rows when the global settings query matches specific rows', () => { + const settingsRow = createShortcutRow() + const worktreeRow: ShortcutRowModel = { + ...createShortcutRow(), + item: { + ...createShortcutRow().item, + id: 'worktree.create' as KeybindingActionId, + title: 'Create worktree', + searchKeywords: ['worktree', 'create'] + } + } + + const matcher = buildShortcutGlobalSearchMatcher([settingsRow, worktreeRow], 'worktree') + + expect(matcher(settingsRow)).toBe(false) + expect(matcher(worktreeRow)).toBe(true) + }) + + it('keeps every row visible on a pane-title-only global query', () => { + const rows = [createShortcutRow()] + + // Mirrors sidebar search selecting the pane with a query that matches only + // pane-level metadata (e.g. a localized pane title) and no row metadata. + const matcher = buildShortcutGlobalSearchMatcher(rows, '단축키') + + expect(matcher(rows[0])).toBe(true) + }) + + it('keeps every row visible when the global query is empty', () => { + const rows = [createShortcutRow()] + + expect(buildShortcutGlobalSearchMatcher(rows, '')(rows[0])).toBe(true) + }) + it('rejects oversized pasted shortcut searches before reading row metadata', () => { const oversizedQuery = 'secret-shortcut-search'.repeat(SHORTCUT_LOCAL_SEARCH_QUERY_MAX_BYTES) const row = { diff --git a/src/renderer/src/components/settings/ShortcutFilterRail.tsx b/src/renderer/src/components/settings/ShortcutFilterRail.tsx index 76445a67e9a..222127f88ed 100644 --- a/src/renderer/src/components/settings/ShortcutFilterRail.tsx +++ b/src/renderer/src/components/settings/ShortcutFilterRail.tsx @@ -6,7 +6,7 @@ import { cn } from '../../lib/utils' import { Button } from '../ui/button' import { Input } from '../ui/input' import type { ShortcutTerminalStatus } from './shortcut-terminal-status' -import type { SettingsSearchEntry } from './settings-search' +import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search' import { translate } from '@/i18n/i18n' export type ShortcutFilter = 'all' | 'modified' | 'unassigned' | 'conflicts' @@ -60,6 +60,17 @@ export function getShortcutSearchEntry(row: ShortcutRowModel): SettingsSearchEnt } } +// Why: a sidebar query can select this pane by title alone while matching zero +// rows; that would blank the list, so zero-row queries keep every row visible. +export function buildShortcutGlobalSearchMatcher( + rows: readonly ShortcutRowModel[], + searchQuery: string +): (row: ShortcutRowModel) => boolean { + const rowMatches = (row: ShortcutRowModel): boolean => + matchesSettingsSearch(searchQuery, getShortcutSearchEntry(row)) + return rows.some(rowMatches) ? rowMatches : () => true +} + export function matchesShortcutFilter(row: ShortcutRowModel, filter: ShortcutFilter): boolean { switch (filter) { case 'modified': diff --git a/src/renderer/src/components/settings/ShortcutsPane.tsx b/src/renderer/src/components/settings/ShortcutsPane.tsx index b6fe5b3556c..29f9661d196 100644 --- a/src/renderer/src/components/settings/ShortcutsPane.tsx +++ b/src/renderer/src/components/settings/ShortcutsPane.tsx @@ -25,7 +25,7 @@ import { sameBindings } from './keybinding-override-edits' import { - getShortcutSearchEntry, + buildShortcutGlobalSearchMatcher, matchesShortcutFilter, matchesShortcutLocalSearch, normalizeShortcutLocalSearchQuery, @@ -138,9 +138,10 @@ export function ShortcutsPane(): React.JSX.Element { ) const shortcutSearchQuery = normalizeShortcutLocalSearchQuery(shortcutQuery) const shortcutRows = shortcutGroups.flatMap((group) => group.rows) + const matchesShortcutGlobalSearch = buildShortcutGlobalSearchMatcher(shortcutRows, searchQuery) const matchesShortcutSearch = (row: ShortcutRowsByGroup['rows'][number]): boolean => shortcutSearchQuery !== null && - matchesSettingsSearch(searchQuery, getShortcutSearchEntry(row)) && + matchesShortcutGlobalSearch(row) && matchesShortcutLocalSearch(row, shortcutSearchQuery, platform) const baseVisibleRows = shortcutRows.filter((row) => matchesShortcutSearch(row)) const filterCounts: Record = { diff --git a/src/renderer/src/components/settings/appearance-sidebar-search.ts b/src/renderer/src/components/settings/appearance-sidebar-search.ts index fd66f0f00a9..bb3ac17e7a3 100644 --- a/src/renderer/src/components/settings/appearance-sidebar-search.ts +++ b/src/renderer/src/components/settings/appearance-sidebar-search.ts @@ -79,6 +79,54 @@ export const getWorkspaceCardLayoutEntry = createLocalizedCatalog( }) ) +export const getShowPinnedWorktreesInGroupsEntry = createLocalizedCatalog( + (): SettingsSearchEntry => ({ + title: translate( + 'auto.components.settings.appearance.search.showPinnedWorktreesInGroups.title', + 'Also show pinned worktrees in their original lists' + ), + description: translate( + 'auto.components.settings.appearance.search.showPinnedWorktreesInGroups.description', + 'Pinned worktrees stay in Pinned and also appear in All, Project, Status, and PR.' + ), + keywords: [ + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.showPinnedWorktreesInGroups.pinned', + 'pinned' + ), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.showPinnedWorktreesInGroups.worktree', + 'worktree' + ), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.showPinnedWorktreesInGroups.workspace', + 'workspace' + ), + ...translateSearchKeyword('auto.components.settings.appearance.search.5bff6a2ef0', 'sidebar'), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.showPinnedWorktreesInGroups.all', + 'all' + ), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.showPinnedWorktreesInGroups.project', + 'project' + ), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.showPinnedWorktreesInGroups.status', + 'status' + ), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.showPinnedWorktreesInGroups.pr', + 'pr' + ), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.showPinnedWorktreesInGroups.duplicate', + 'duplicate' + ) + ] + }) +) + export const getSidebarEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [ { title: translate('auto.components.settings.appearance.search.155a1e7438', 'Show Tasks Button'), @@ -144,5 +192,6 @@ export const getSidebarEntries = createLocalizedCatalog((): SettingsSearchEntry[ ] }, getWorkspaceCardLayoutEntry(), - getLeftSidebarAppearanceEntry() + getLeftSidebarAppearanceEntry(), + getShowPinnedWorktreesInGroupsEntry() ]) diff --git a/src/renderer/src/components/settings/terminal-search.test.ts b/src/renderer/src/components/settings/terminal-search.test.ts index bf642e83735..cc82051b774 100644 --- a/src/renderer/src/components/settings/terminal-search.test.ts +++ b/src/renderer/src/components/settings/terminal-search.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest' import { getTerminalPaneSearchEntries } from './terminal-search' import { getAppearancePaneSearchEntries, getSidebarEntries } from './appearance-search' -import { getWorkspaceCardLayoutEntry } from './appearance-sidebar-search' +import { + getShowPinnedWorktreesInGroupsEntry, + getWorkspaceCardLayoutEntry +} from './appearance-sidebar-search' import { matchesSettingsSearch } from './settings-search' describe('getTerminalPaneSearchEntries', () => { @@ -214,4 +217,12 @@ describe('getTerminalPaneSearchEntries', () => { it('matches the Appearance catalog for compact workspace card searches', () => { expect(matchesSettingsSearch('compact', getAppearancePaneSearchEntries())).toBe(true) }) + + it('includes pinned worktree duplicate display in sidebar and Appearance search', () => { + const entry = getShowPinnedWorktreesInGroupsEntry() + + expect(getSidebarEntries()).toContainEqual(entry) + expect(getAppearancePaneSearchEntries()).toContainEqual(entry) + expect(matchesSettingsSearch('duplicate', entry)).toBe(true) + }) }) diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts index fba1e48b1d5..7de4eb12f95 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts @@ -10,6 +10,7 @@ import type { WorktreeLineage } from '../../../../shared/types' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import type * as WorktreeListModule from './WorktreeList' import { cloneDefaultWorkspaceStatuses } from '../../../../shared/workspace-statuses' const mockStore = vi.hoisted(() => ({ @@ -22,6 +23,7 @@ type WorktreeListComponent = React.ComponentType<{ }> let WorktreeList: WorktreeListComponent +let getPinnedWorktreeRevealCollapsedGroupKeys: typeof WorktreeListModule.getPinnedWorktreeRevealCollapsedGroupKeys function makeFolderWorkspacePathStatusMockState(): Record { return { @@ -434,12 +436,12 @@ function setFolderWorkspaceFixtureState( } } -function setPinnedDuplicateFixtureState(): void { +function setPinnedFixtureState(): void { const repo = makeRepo() const pinned = makeWorktree({ id: 'pinned', instanceId: 'pinned-instance', - displayName: 'pinned duplicate', + displayName: 'pinned workspace', branch: 'pinned-branch', sortOrder: 20 }) @@ -850,8 +852,10 @@ function escapeRegExp(value: string): string { describe('WorktreeList lineage child card renderer', () => { beforeAll(async () => { - WorktreeList = (await import('./WorktreeList')).default as WorktreeListComponent - }, 20_000) + const module = await import('./WorktreeList') + WorktreeList = module.default as WorktreeListComponent + getPinnedWorktreeRevealCollapsedGroupKeys = module.getPinnedWorktreeRevealCollapsedGroupKeys + }, 60_000) it('renders project group headers when repos import before worktree rows load', async () => { setProjectGroupWithoutWorktreeRowsState() @@ -885,6 +889,24 @@ describe('WorktreeList lineage child card renderer', () => { expect(markup).not.toContain('data-repo-header-collapse-affordance=""') }) + it('uncollapses pinned reveal through the pinned section after host expansion', () => { + const worktree = makeWorktree({ + id: 'pinned-ssh', + instanceId: 'pinned-ssh-instance', + displayName: 'Pinned SSH workspace', + branch: 'pinned-ssh', + sortOrder: 1 + }) + worktree.isPinned = true + + expect( + getPinnedWorktreeRevealCollapsedGroupKeys({ + worktree, + collapsedGroups: new Set(['host:ssh:builder-1', 'pinned', 'done']) + }) + ).toEqual(['pinned']) + }) + it('renders a collapse chevron on status group headers with worktrees', async () => { setLineageFixtureState('workspace-status') const markup = await renderWorktreeListMarkup() @@ -1112,12 +1134,23 @@ describe('WorktreeList lineage child card renderer', () => { ).toBe(30) }) - it('points aria-activedescendant at the natural row for active pinned duplicates', async () => { - setPinnedDuplicateFixtureState() + it('points aria-activedescendant at the pinned row for active pinned workspaces', async () => { + setPinnedFixtureState() + const markup = await renderWorktreeListMarkup() + + expect(markup).toContain('aria-activedescendant="worktree-list-option-pinned%3Apinned"') + expect(markup).toContain('id="worktree-list-option-pinned%3Apinned"') + expect(markup).not.toContain('id="worktree-list-option-all%3Apinned"') + }) + + it('points aria-activedescendant at the natural duplicate when enabled', async () => { + setPinnedFixtureState() + mockStore.state.settings = { showPinnedWorktreesInGroups: true } const markup = await renderWorktreeListMarkup() expect(markup).toContain('aria-activedescendant="worktree-list-option-all%3Apinned"') expect(markup).toContain('id="worktree-list-option-pinned%3Apinned"') + expect(markup).toContain('id="worktree-list-option-all%3Apinned"') }) it('opens inline rename only for the row-scoped lineage child request', async () => { diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 81671c20616..e73e9142f3e 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -88,7 +88,9 @@ import { buildRows, getProjectGroupHeaderKey, getGroupKeysForWorktree, - getLineageGroupKey + getLineageGroupKey, + getPinnedWorktreeDisplayPolicy, + type PinnedWorktreeDisplayPolicy } from './worktree-list-groups' import { estimateRenderRowSize, @@ -213,6 +215,7 @@ import { ALL_EXECUTION_HOSTS_SCOPE, getRepoExecutionHostId, getSettingsFocusedExecutionHostId, + getWorktreeExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' import { getRepoHeaderCreateState } from './repo-header-create-state' @@ -289,6 +292,10 @@ import { getProjectGroupExecutionHostIdForRows } from './worktree-list-host-filtering' import { getFolderWorkspaceCardPrDisplay } from './folder-workspace-card-pr-display' +import { + getPreferredWorktreeRows, + getRenderedWorktreesInSidebarOrder +} from './worktree-sidebar-row-preference' export { getScrollTopToRevealBounds, @@ -449,9 +456,9 @@ function markSidebarWorktreeActiveImmediately(worktreeId: string, primaryRowKey? ? option.dataset.worktreeRowKey === primaryRowKey ? 'primary' : 'secondary' - : option.dataset.worktreeSectionKey === PINNED_GROUP_KEY - ? 'secondary' - : 'primary' + : option === nextOption + ? 'primary' + : 'secondary' const surface = option.matches('[data-worktree-card-surface]') ? option : option.querySelector('[data-worktree-card-surface]') @@ -615,6 +622,7 @@ type VirtualizedWorktreeViewportProps = { activeWorktreeId: string | null currentWorktreeId: string | null groupBy: WorktreeGroupBy + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy projectOrderBy: ProjectOrderBy toggleGroup: (key: string) => void collapsedGroups: Set @@ -653,6 +661,7 @@ type VirtualizedWorktreeViewportProps = { worktree: Worktree ) => readonly Worktree[] repoMap: Map + defaultHostId: ExecutionHostId worktreeMap: Map worktreeLineageById: Record workspaceLineageByChildKey: Record @@ -1050,6 +1059,17 @@ export function renderRowContainsWorktree(row: RenderRow, worktreeId: string | n return row.type === 'item' && row.worktree.id === worktreeId } +function isPinnedWorktreeRow(row: WorktreeItemRow): boolean { + return row.sectionKey === PINNED_GROUP_KEY +} + +function getRenderRowWorktreeItem(row: RenderRow, worktreeId: string): WorktreeItemRow | null { + if (row.type === 'lineage-group') { + return row.rows.find((item) => item.worktree.id === worktreeId) ?? null + } + return row.type === 'item' && row.worktree.id === worktreeId ? row : null +} + function getRenderRowOptionId( row: RenderRow | undefined, worktreeId?: string | null @@ -1070,20 +1090,10 @@ function getRenderRowOptionId( return undefined } -function renderRowContainsNaturalWorktree(row: RenderRow, worktreeId: string): boolean { - if (row.type === 'lineage-group') { - return row.rows.some( - (item) => item.worktree.id === worktreeId && item.sectionKey !== PINNED_GROUP_KEY - ) - } - return ( - row.type === 'item' && row.worktree.id === worktreeId && row.sectionKey !== PINNED_GROUP_KEY - ) -} - function getActiveDescendantOptionId(args: { activeWorktreeId: string | null primaryActiveRowKey?: string + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy renderRows: readonly RenderRow[] virtualItems: readonly { index: number }[] }): string | undefined { @@ -1099,19 +1109,67 @@ function getActiveDescendantOptionId(args: { } } } - for (const item of args.virtualItems) { - const row = args.renderRows[item.index] - if (row && renderRowContainsNaturalWorktree(row, args.activeWorktreeId)) { - return getRenderRowOptionId(row, args.activeWorktreeId) - } - } + let fallbackOptionId: string | undefined for (const item of args.virtualItems) { const row = args.renderRows[item.index] if (row && renderRowContainsWorktree(row, args.activeWorktreeId)) { - return getRenderRowOptionId(row, args.activeWorktreeId) + const optionId = getRenderRowOptionId(row, args.activeWorktreeId) + if (!optionId) { + continue + } + const itemRow = getRenderRowWorktreeItem(row, args.activeWorktreeId) + if ( + args.pinnedDisplayPolicy === 'duplicate-in-groups' && + itemRow && + !isPinnedWorktreeRow(itemRow) + ) { + return optionId + } + fallbackOptionId ??= optionId } } - return undefined + return fallbackOptionId +} + +function findPreferredRenderRowIndexForWorktree( + renderRows: readonly RenderRow[], + worktreeId: string, + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy +): number { + let fallbackIndex = -1 + for (let index = 0; index < renderRows.length; index++) { + const row = renderRows[index] + if (!renderRowContainsWorktree(row, worktreeId)) { + continue + } + if (fallbackIndex === -1) { + fallbackIndex = index + } + const itemRow = getRenderRowWorktreeItem(row, worktreeId) + if (pinnedDisplayPolicy === 'duplicate-in-groups' && itemRow && !isPinnedWorktreeRow(itemRow)) { + return index + } + } + return fallbackIndex +} + +export function getPinnedWorktreeRevealCollapsedGroupKeys({ + worktree, + collapsedGroups +}: { + worktree: Worktree + collapsedGroups: ReadonlySet +}): string[] { + if (!worktree.isPinned) { + return [] + } + const keys: string[] = [] + // Why: the reveal effect opens the owning host first; returning it here would + // toggle the same host closed again while React state is still stale. + if (collapsedGroups.has(PINNED_GROUP_KEY)) { + keys.push(PINNED_GROUP_KEY) + } + return keys } function uniqueWorktreeIds(ids: readonly string[]): string[] { @@ -1181,6 +1239,11 @@ export function getRenderRowKey(row: RenderRow): string { export function getWorktreeDragGroups(rows: HostSectionRow[]): WorktreeDragGroup[] { const groups: WorktreeDragGroup[] = [] let current: { key: string; ids: string[] } | null = null + const naturalWorktreeIds = new Set( + rows.flatMap((row) => + row.type === 'item' && row.sectionKey !== PINNED_GROUP_KEY ? [row.worktree.id] : [] + ) + ) for (const row of rows) { if (row.type === 'header') { @@ -1197,7 +1260,7 @@ export function getWorktreeDragGroups(rows: HostSectionRow[]): WorktreeDragGroup ) { continue } - if (row.sectionKey === PINNED_GROUP_KEY) { + if (row.sectionKey === PINNED_GROUP_KEY && naturalWorktreeIds.has(row.worktree.id)) { continue } if (!current) { @@ -1217,19 +1280,27 @@ export function canKeepImportedWorktreesHidden( return row.placement === 'repo-group' && actionState?.forceVisible !== true } -function getWorktreeDragIndexes(rows: readonly HostSectionRow[]): { +export function getWorktreeDragIndexes(rows: readonly HostSectionRow[]): { groupKeyByRowKey: Map groupIndexByRowKey: Map } { const groupKeyByRowKey = new Map() const groupIndexByRowKey = new Map() const groupIndexes = new Map() + const naturalWorktreeIds = new Set( + rows.flatMap((row) => + row.type === 'item' && row.sectionKey !== PINNED_GROUP_KEY ? [row.worktree.id] : [] + ) + ) for (const row of rows) { if (row.type === 'header') { groupIndexes.set(row.key, 0) continue } - if (row.type !== 'item' || row.sectionKey === PINNED_GROUP_KEY) { + if (row.type !== 'item') { + continue + } + if (row.sectionKey === PINNED_GROUP_KEY && naturalWorktreeIds.has(row.worktree.id)) { continue } const index = groupIndexes.get(row.sectionKey) ?? 0 @@ -1254,6 +1325,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp activeWorktreeId, currentWorktreeId, groupBy, + pinnedDisplayPolicy, projectOrderBy, toggleGroup, collapsedGroups, @@ -1289,6 +1361,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp onImmediateWorktreeActivate, onContextMenuSelect, repoMap, + defaultHostId, worktreeMap, worktreeLineageById, workspaceLineageByChildKey, @@ -1466,13 +1539,25 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp useEffect(() => () => onHostDragActiveChange(false), [onHostDragActiveChange]) const worktreeDragGroups = useMemo(() => getWorktreeDragGroups(rows), [rows]) const worktreeDragUnitGroups = useMemo(() => getWorktreeDragUnitGroups(rows), [rows]) + const naturalDragWorktreeIds = useMemo( + () => + new Set( + rows.flatMap((row) => + row.type === 'item' && row.sectionKey !== PINNED_GROUP_KEY ? [row.worktree.id] : [] + ) + ), + [rows] + ) const worktreeLineageDragRows = useMemo( () => rows .filter((row): row is WorktreeItemRow => row.type === 'item') - .filter((row) => row.sectionKey !== PINNED_GROUP_KEY) + .filter( + (row) => + row.sectionKey !== PINNED_GROUP_KEY || !naturalDragWorktreeIds.has(row.worktree.id) + ) .map((row) => ({ worktreeId: row.worktree.id, depth: row.depth })), - [rows] + [naturalDragWorktreeIds, rows] ) const getReorderDraggedIds = useCallback( (draggedIds: readonly string[]) => @@ -1687,28 +1772,21 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp return rowStillVisible ? current : null }) }, [activeWorktreeId, rows]) - const activeWorktreeHasNaturalRow = useMemo( - () => - activeWorktreeId !== null && - rows.some( - (row) => - row.type === 'item' && - row.worktree.id === activeWorktreeId && - row.sectionKey !== PINNED_GROUP_KEY - ), - [activeWorktreeId, rows] - ) const getActiveSurfaceVariant = useCallback( (row: WorktreeItemRow): ActiveSurfaceVariant => { if (primaryActiveWorktreeRow?.worktreeId === row.worktree.id) { return primaryActiveWorktreeRow.rowKey === row.rowKey ? 'primary' : 'secondary' } - if (activeWorktreeHasNaturalRow && row.sectionKey === PINNED_GROUP_KEY) { + if ( + pinnedDisplayPolicy === 'duplicate-in-groups' && + activeWorktreeId === row.worktree.id && + isPinnedWorktreeRow(row) + ) { return 'secondary' } return 'primary' }, - [activeWorktreeHasNaturalRow, primaryActiveWorktreeRow] + [activeWorktreeId, pinnedDisplayPolicy, primaryActiveWorktreeRow] ) const handleImmediateWorktreeRowActivate = useCallback( (worktreeId: string, rowKey: string | undefined): void => { @@ -2036,7 +2114,14 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp } } else { const targetWorktree = worktrees.find((w) => w.id === pendingRevealWorktree.worktreeId) - if (targetWorktree && !targetWorktree.isPinned) { + const targetRepo = targetWorktree ? repoMap.get(targetWorktree.repoId) : undefined + if (targetWorktree) { + const hostId = getWorktreeExecutionHostId(targetWorktree, targetRepo, defaultHostId) + const hostGroupKey = `host:${hostId}` + if (collapsedGroups.has(hostGroupKey)) { + toggleGroup(hostGroupKey) + } + const seen = new Set() let current: Worktree | undefined = targetWorktree while (current && !seen.has(current.id)) { @@ -2057,26 +2142,23 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp } current = parent } - } - if (targetWorktree?.isPinned) { - // Why: pinned worktrees live in the dedicated "Pinned" section regardless - // of their PR-status / project group. Only uncollapse the Pinned header - // itself — expanding the underlying status group would be surprising since - // the user intentionally collapsed it. - if (collapsedGroups.has(PINNED_GROUP_KEY)) { - toggleGroup(PINNED_GROUP_KEY) - } - } else if (targetWorktree) { - const groupKeys = getGroupKeysForWorktree( - groupBy, - targetWorktree, - repoMap, - prCache, - workspaceStatuses, - settings, - projectGroups - ) + const groupKeys = + targetWorktree.isPinned && pinnedDisplayPolicy === 'single-location' + ? getPinnedWorktreeRevealCollapsedGroupKeys({ + worktree: targetWorktree, + collapsedGroups + }) + : getGroupKeysForWorktree( + groupBy, + targetWorktree, + repoMap, + prCache, + workspaceStatuses, + settings, + projectGroups, + projectGrouping + ) for (const groupKey of groupKeys) { if (collapsedGroups.has(groupKey)) { toggleGroup(groupKey) @@ -2096,8 +2178,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp worktrees, folderWorkspaces ) - const targetIndex = renderRows.findIndex((row) => - renderRowContainsWorktree(row, pendingRevealWorktree.worktreeId) + const targetIndex = findPreferredRenderRowIndexForWorktree( + renderRows, + pendingRevealWorktree.worktreeId, + pinnedDisplayPolicy ) const outcome = resolvePendingSidebarReveal({ targetIndex, targetWorktreeStillExists }) if (outcome === 'scroll-and-clear') { @@ -2197,8 +2281,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp clearPendingRevealWorktreeId, toggleGroup, collapsedGroups, + defaultHostId, workspaceStatuses, settings, + pinnedDisplayPolicy, + projectGrouping, projectGroups, pendingRevealRetryTick, flashRevealedRow, @@ -2442,16 +2529,13 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp new Map(), new Map(), [], - projectGrouping + projectGrouping, + [], + undefined, + defaultHostId, + pinnedDisplayPolicy ).filter((r): r is Extract => r.type === 'item') - const seenWorktreeIds = new Set() - const worktreeRows = allWorktreeRows.filter((row) => { - if (seenWorktreeIds.has(row.worktree.id)) { - return false - } - seenWorktreeIds.add(row.worktree.id) - return true - }) + const worktreeRows = getPreferredWorktreeRows(allWorktreeRows, pinnedDisplayPolicy) if (worktreeRows.length === 0) { return } @@ -2478,7 +2562,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp // it must flow through the same activation helper that records history. activateAndRevealWorktree(nextWorktreeId) - const rowIndex = renderRows.findIndex((row) => renderRowContainsWorktree(row, nextWorktreeId)) + const rowIndex = findPreferredRenderRowIndexForWorktree( + renderRows, + nextWorktreeId, + pinnedDisplayPolicy + ) if (rowIndex !== -1) { virtualizer.scrollToIndex(rowIndex, { align: 'auto' }) } @@ -2491,6 +2579,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp projectOrderBy, worktrees, repoMap, + defaultHostId, prCache, repoOrder, workspaceStatuses, @@ -2498,7 +2587,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp worktreeMap, settings, projectGroups, - projectGrouping + projectGrouping, + pinnedDisplayPolicy ] ) @@ -3740,6 +3830,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp primaryActiveWorktreeRow?.worktreeId === activeWorktreeId ? primaryActiveWorktreeRow.rowKey : undefined, + pinnedDisplayPolicy, renderRows, virtualItems }) @@ -4828,8 +4919,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp onCardDragStart={handleWorktreeCardDragStart} onCardDragEnd={clearWorktreeDrag} hideRepoBadge={groupBy === 'repo'} - // Why: pinned worktrees also render in their natural group; - // only the overlay row is the mixed-repo section needing icons. + // Why: pinned worktrees mix repos in one section; only that + // section needs the leading repo identity chip. hostContextLabel={itemRow.hostContextLabel} inPinnedSection={isPinnedOverlayRow} renameRowKey={itemRow.rowKey} @@ -5262,6 +5353,7 @@ const WorktreeList = React.memo(function WorktreeList({ useShallow((s) => selectWorktreeListReviewCacheInputs(s, groupBy, cardProps)) ) const settings = useAppStore((s) => s.settings) + const pinnedDisplayPolicy = getPinnedWorktreeDisplayPolicy(settings) const sshTargetLabels = useAppStore((s) => s.sshTargetLabels) const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) @@ -5711,9 +5803,10 @@ const WorktreeList = React.memo(function WorktreeList({ groupBy, repos: visibleReposForRows, worktreesByRepo, + visibleWorktrees, filterRepoIds }) - }, [filterRepoIds, groupBy, visibleReposForRows, worktreesByRepo]) + }, [filterRepoIds, groupBy, visibleReposForRows, visibleWorktrees, worktreesByRepo]) const allRepoIds = useMemo(() => repos.map((r) => r.id), [repos]) // Why: buildRows only needs which creates exist and their repo. Subscribe on a @@ -5786,7 +5879,9 @@ const WorktreeList = React.memo(function WorktreeList({ pendingCreations, projectGrouping, visibleFolderWorkspacesForRows, - hostLabelById + hostLabelById, + defaultHostId, + pinnedDisplayPolicy ), [ groupBy, @@ -5794,6 +5889,7 @@ const WorktreeList = React.memo(function WorktreeList({ repoMap, prCache, effectiveCollapsedGroups, + defaultHostId, repoOrder, workspaceStatuses, projectOrderBy, @@ -5807,7 +5903,8 @@ const WorktreeList = React.memo(function WorktreeList({ importedWorktreesByRepo, newExternalWorktreesInboxByRepo, pendingCreations, - hostLabelById + hostLabelById, + pinnedDisplayPolicy ] ) const orderedHostOptions = useMemo( @@ -5890,17 +5987,8 @@ const WorktreeList = React.memo(function WorktreeList({ // order would cause Cmd+1–9 shortcuts to not match the visual card // positions when grouping is active. const renderedWorktrees = useMemo( - () => - sectionRows.flatMap((row) => { - if (row.type === 'item') { - return [row.worktree] - } - if (row.type === 'folder-workspace') { - return [folderWorkspaceToWorktree(row.folderWorkspace)] - } - return [] - }), - [sectionRows] + () => getRenderedWorktreesInSidebarOrder(sectionRows, pinnedDisplayPolicy), + [pinnedDisplayPolicy, sectionRows] ) const renderedWorktreeIds = useMemo( () => uniqueWorktreeIds(renderedWorktrees.map((worktree) => worktree.id)), @@ -6807,6 +6895,7 @@ const WorktreeList = React.memo(function WorktreeList({ activeWorktreeId={selectedSidebarWorktreeId} currentWorktreeId={currentSidebarWorktreeId} groupBy={groupBy} + pinnedDisplayPolicy={pinnedDisplayPolicy} projectOrderBy={projectOrderBy} toggleGroup={toggleGroup} collapsedGroups={effectiveCollapsedGroups} @@ -6842,6 +6931,7 @@ const WorktreeList = React.memo(function WorktreeList({ onImmediateWorktreeActivate={handleImmediateWorktreeActivate} onContextMenuSelect={selectForContextMenu} repoMap={repoMap} + defaultHostId={defaultHostId} worktreeMap={worktreeMap} worktreeLineageById={worktreeLineageById} workspaceLineageByChildKey={workspaceLineageByChildKey} diff --git a/src/renderer/src/components/sidebar/empty-project-placeholder-repos.test.ts b/src/renderer/src/components/sidebar/empty-project-placeholder-repos.test.ts index aaa3be238e9..8a33151f73a 100644 --- a/src/renderer/src/components/sidebar/empty-project-placeholder-repos.test.ts +++ b/src/renderer/src/components/sidebar/empty-project-placeholder-repos.test.ts @@ -38,6 +38,7 @@ describe('getEmptyProjectPlaceholderRepoIds', () => { groupBy: 'repo', repos: [repo], worktreesByRepo: { [repo.id]: [] }, + visibleWorktrees: [], filterRepoIds: [] }) ) @@ -51,6 +52,7 @@ describe('getEmptyProjectPlaceholderRepoIds', () => { groupBy: 'repo', repos: [repo], worktreesByRepo: {}, + visibleWorktrees: [], filterRepoIds: [] }) ) @@ -67,6 +69,7 @@ describe('getEmptyProjectPlaceholderRepoIds', () => { groupBy: 'repo', repos: [selectedRepo, hiddenRepo], worktreesByRepo: { [selectedRepo.id]: [], [hiddenRepo.id]: [] }, + visibleWorktrees: [], filterRepoIds: [selectedRepo.id] }) ) @@ -79,6 +82,7 @@ describe('getEmptyProjectPlaceholderRepoIds', () => { groupBy: 'none', repos: [repo], worktreesByRepo: { [repo.id]: [] }, + visibleWorktrees: [], filterRepoIds: [] }).size ).toBe(0) @@ -90,8 +94,109 @@ describe('getEmptyProjectPlaceholderRepoIds', () => { groupBy: 'repo', repos: [repo], worktreesByRepo: { [repo.id]: [worktree] }, + visibleWorktrees: [], filterRepoIds: [] }).size ).toBe(0) }) + + it('keeps grouped repos visible when workspace filters hide all of their rows', () => { + const groupedRepo: Repo = { ...repo, projectGroupId: 'group-1' } + const groupedWorktree: Worktree = { ...worktree, repoId: groupedRepo.id } + + expect( + Array.from( + getEmptyProjectPlaceholderRepoIds({ + groupBy: 'repo', + repos: [groupedRepo], + worktreesByRepo: { [groupedRepo.id]: [groupedWorktree] }, + visibleWorktrees: [], + filterRepoIds: [] + }) + ) + ).toEqual([groupedRepo.id]) + }) + + it('does not create a grouped repo placeholder when one of its workspaces is visible', () => { + const groupedRepo: Repo = { ...repo, projectGroupId: 'group-1' } + const groupedWorktree: Worktree = { ...worktree, repoId: groupedRepo.id } + + expect( + getEmptyProjectPlaceholderRepoIds({ + groupBy: 'repo', + repos: [groupedRepo], + worktreesByRepo: { [groupedRepo.id]: [groupedWorktree] }, + visibleWorktrees: [groupedWorktree], + filterRepoIds: [] + }).size + ).toBe(0) + }) + + it('still respects explicit project filters for sleep-filtered grouped members', () => { + const selected: Repo = { ...repo, id: 'repo-selected', projectGroupId: 'group-1' } + const filteredOut: Repo = { ...repo, id: 'repo-hidden', projectGroupId: 'group-1' } + const selectedWt: Worktree = { ...worktree, id: 'wt-selected', repoId: selected.id } + const hiddenWt: Worktree = { ...worktree, id: 'wt-hidden', repoId: filteredOut.id } + + expect( + Array.from( + getEmptyProjectPlaceholderRepoIds({ + groupBy: 'repo', + repos: [selected, filteredOut], + worktreesByRepo: { + [selected.id]: [selectedWt], + [filteredOut.id]: [hiddenWt] + }, + // Why: simulate Hide sleeping removing every card while the project + // filter still intentionally excludes `filteredOut`. + visibleWorktrees: [], + filterRepoIds: [selected.id] + }) + ) + ).toEqual([selected.id]) + }) + + it('placeholders only the fully-filtered members of a multi-project group', () => { + const sleeping: Repo = { ...repo, id: 'repo-sleeping', projectGroupId: 'group-1' } + const awake: Repo = { ...repo, id: 'repo-awake', projectGroupId: 'group-1' } + const sleepingWt: Worktree = { ...worktree, id: 'wt-sleeping', repoId: sleeping.id } + const awakeWt: Worktree = { ...worktree, id: 'wt-awake', repoId: awake.id } + + expect( + Array.from( + getEmptyProjectPlaceholderRepoIds({ + groupBy: 'repo', + repos: [sleeping, awake], + worktreesByRepo: { + [sleeping.id]: [sleepingWt], + [awake.id]: [awakeWt] + }, + visibleWorktrees: [awakeWt], + filterRepoIds: [] + }) + ) + ).toEqual([sleeping.id]) + }) + + it('does not placeholder ungrouped neighbors of a filtered grouped member', () => { + const grouped: Repo = { ...repo, id: 'repo-grouped', projectGroupId: 'group-1' } + const ungrouped: Repo = { ...repo, id: 'repo-ungrouped' } + const groupedWt: Worktree = { ...worktree, id: 'wt-grouped', repoId: grouped.id } + const ungroupedWt: Worktree = { ...worktree, id: 'wt-ungrouped', repoId: ungrouped.id } + + expect( + Array.from( + getEmptyProjectPlaceholderRepoIds({ + groupBy: 'repo', + repos: [grouped, ungrouped], + worktreesByRepo: { + [grouped.id]: [groupedWt], + [ungrouped.id]: [ungroupedWt] + }, + visibleWorktrees: [], + filterRepoIds: [] + }) + ) + ).toEqual([grouped.id]) + }) }) diff --git a/src/renderer/src/components/sidebar/empty-project-placeholder-repos.ts b/src/renderer/src/components/sidebar/empty-project-placeholder-repos.ts index 26c248a2a65..57aee59c1bd 100644 --- a/src/renderer/src/components/sidebar/empty-project-placeholder-repos.ts +++ b/src/renderer/src/components/sidebar/empty-project-placeholder-repos.ts @@ -5,6 +5,7 @@ export function getEmptyProjectPlaceholderRepoIds(args: { groupBy: WorktreeGroupBy repos: readonly Repo[] worktreesByRepo: Readonly> + visibleWorktrees: readonly Worktree[] filterRepoIds: readonly string[] }): Set { if (args.groupBy !== 'repo') { @@ -12,12 +13,17 @@ export function getEmptyProjectPlaceholderRepoIds(args: { } const filterSet = args.filterRepoIds.length > 0 ? new Set(args.filterRepoIds) : null + const visibleRepoIds = new Set(args.visibleWorktrees.map((worktree) => worktree.repoId)) const placeholderRepoIds = new Set() for (const repo of args.repos) { if (filterSet && !filterSet.has(repo.id)) { continue } - if ((args.worktreesByRepo[repo.id]?.length ?? 0) === 0) { + const hasNoWorktrees = (args.worktreesByRepo[repo.id]?.length ?? 0) === 0 + // Why: workspace filters hide cards, but must not rewrite the visible + // membership of a persisted Project Group. #8865 + const isFilteredProjectGroupMember = repo.projectGroupId != null && !visibleRepoIds.has(repo.id) + if (hasNoWorktrees || isFilteredProjectGroupMember) { placeholderRepoIds.add(repo.id) } } diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index ee48eac66f2..faf2b6ede75 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -20,6 +20,7 @@ import { isWslUncPath } from '../../../../shared/wsl-paths' import { resolveLocalWindowsAgentStartupShell } from '../../../../shared/windows-terminal-shell' import type { AgentStartupShell } from '../../../../shared/tui-agent-startup-shell' import type { LaunchSource } from '../../../../shared/telemetry-events' +import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import { getLinkedItemDisplayName, @@ -46,6 +47,7 @@ type SubmitFolderWorkspaceCreateParams = { agentCmdOverrides: Record | undefined agentArgs?: string | null agentEnv?: Record + sessionOptions?: Record terminalWindowsShell?: string | null isRemote?: boolean launchSource?: LaunchSource @@ -71,6 +73,7 @@ export function buildFolderWorkspaceLinkedStartupPlan(args: { agentCmdOverrides: Record | undefined agentArgs?: string | null agentEnv?: Record + sessionOptions?: Record platform: NodeJS.Platform shell?: AgentStartupShell isRemote: boolean @@ -87,6 +90,7 @@ export function buildFolderWorkspaceLinkedStartupPlan(args: { cmdOverrides: args.agentCmdOverrides ?? {}, agentArgs: args.agentArgs, agentEnv: args.agentEnv, + sessionOptions: args.sessionOptions, platform: args.platform, shell: args.shell, isRemote: args.isRemote @@ -99,6 +103,7 @@ export function buildFolderWorkspaceLinkedStartupPlan(args: { expectedProcess: draftLaunchPlan.expectedProcess, followupPrompt: null, launchConfig: draftLaunchPlan.launchConfig, + ...(draftLaunchPlan.sessionOptions ? { sessionOptions: draftLaunchPlan.sessionOptions } : {}), ...(draftLaunchPlan.startupCommandDelivery ? { startupCommandDelivery: draftLaunchPlan.startupCommandDelivery } : {}), @@ -114,6 +119,7 @@ export function buildFolderWorkspaceLinkedStartupPlan(args: { cmdOverrides: args.agentCmdOverrides ?? {}, agentArgs: args.agentArgs, agentEnv: args.agentEnv, + sessionOptions: args.sessionOptions, platform: args.platform, shell: args.shell, isRemote: args.isRemote, @@ -159,6 +165,7 @@ export async function submitFolderWorkspaceCreate({ agentCmdOverrides, agentArgs, agentEnv, + sessionOptions, terminalWindowsShell, launchSource = 'sidebar', runtimeEnvironmentId = null, @@ -189,6 +196,7 @@ export async function submitFolderWorkspaceCreate({ agentCmdOverrides, agentArgs, agentEnv, + sessionOptions, platform: launchPlatform, shell: launchShell, isRemote: launchIsRemote @@ -200,6 +208,7 @@ export async function submitFolderWorkspaceCreate({ cmdOverrides: agentCmdOverrides ?? {}, agentArgs, agentEnv, + sessionOptions, platform: launchPlatform, shell: launchShell, isRemote: launchIsRemote, @@ -247,6 +256,7 @@ export async function submitFolderWorkspaceCreate({ launchConfig: startupPlan.launchConfig, ...(startupPlan.launchToken ? { launchToken: startupPlan.launchToken } : {}), launchAgent: quickAgent, + ...(startupPlan.sessionOptions ? { sessionOptions: startupPlan.sessionOptions } : {}), ...(startupPlan.draftPrompt ? { draftPrompt: startupPlan.draftPrompt } : {}), ...(startupPlan.startupCommandDelivery ? { startupCommandDelivery: startupPlan.startupCommandDelivery } diff --git a/src/renderer/src/components/sidebar/host-section-rows.test.ts b/src/renderer/src/components/sidebar/host-section-rows.test.ts index e03ddbb63cc..702356e26a6 100644 --- a/src/renderer/src/components/sidebar/host-section-rows.test.ts +++ b/src/renderer/src/components/sidebar/host-section-rows.test.ts @@ -46,6 +46,19 @@ function header(key: string, label = key): Extract { } } +function pinnedHeader( + counts: ReadonlyMap<'local' | 'ssh:ssh-1', number>, + idsByHost?: ReadonlyMap<'local' | 'ssh:ssh-1', readonly string[]> +): Extract { + return { + ...header(PINNED_GROUP_KEY, 'Pinned'), + count: Array.from(counts.values()).reduce((sum, count) => sum + count, 0), + hostWorktreeCounts: counts, + hostWorktreeIds: idsByHost, + worktreeIds: idsByHost ? Array.from(idsByHost.values()).flat() : undefined + } +} + function repoHeader(project: Repo): Extract { return { ...header(`repo:${project.id}`, project.displayName), @@ -294,17 +307,20 @@ describe('addHostSectionRows', () => { ]) }) - it('copies global pinned and all headers into each mixed-host section', () => { + it('copies global pinned and all headers into each mixed-host section without duplicating pins', () => { const local = repo('local') const ssh = repo('ssh', 'ssh-1') const rows = [ - header(PINNED_GROUP_KEY, 'Pinned'), + pinnedHeader( + new Map([ + ['local', 1], + ['ssh:ssh-1', 1] + ]) + ), pinnedItem('local-pinned', local, PINNED_GROUP_KEY), pinnedItem('ssh-pinned', ssh, PINNED_GROUP_KEY), header('all', 'All'), - pinnedItem('local-pinned', local, 'all'), item('local-normal', local), - pinnedItem('ssh-pinned', ssh, 'all'), item('ssh-normal', ssh) ] @@ -329,19 +345,182 @@ describe('addHostSectionRows', () => { 'pinned', 'pinned:local-pinned', 'all', - 'all:local-pinned', 'repo:local:local-normal', 'host:ssh:ssh-1', 'pinned', 'pinned:ssh-pinned', 'all', - 'all:ssh-pinned', 'repo:ssh:ssh-normal' ]) expect(sectioned.filter((row) => row.type === 'host-header')).toMatchObject([ { label: 'Local Mac', count: 2 }, { label: 'Builder', count: 2 } ]) + expect( + sectioned.filter((row) => row.type === 'header' && row.key === PINNED_GROUP_KEY) + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ count: 1, hostId: 'local' }), + expect.objectContaining({ count: 1, hostId: 'ssh:ssh-1' }) + ]) + ) + }) + + it('keeps collapsed pinned rows attributable to their owning hosts', () => { + const rows = [ + pinnedHeader( + new Map([ + ['local', 1], + ['ssh:ssh-1', 2] + ]) + ) + ] + + const sectioned = addHostSectionRows({ + rows, + hostOptions: [ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + }, + { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } + ], + workspaceHostScope: 'all', + defaultHostId: 'local', + collapsedHostKeys: new Set(['host:ssh:ssh-1']) + }) + + expect(sectioned.map(rowKey)).toEqual(['host:local', 'pinned', 'host:ssh:ssh-1']) + expect(sectioned.filter((row) => row.type === 'host-header')).toMatchObject([ + { hostId: 'local', collapsed: false, count: 1 }, + { hostId: 'ssh:ssh-1', collapsed: true, count: 2 } + ]) + expect(sectioned.filter((row) => row.type === 'header')).toMatchObject([ + { key: PINNED_GROUP_KEY, count: 1, hostId: 'local' } + ]) + }) + + it('keeps a pinned row with its explicit worktree host', () => { + const local = repo('local') + const pinned = pinnedItem('ssh-pinned', local, PINNED_GROUP_KEY) + pinned.worktree.hostId = 'ssh:ssh-1' + const rows = [ + pinnedHeader(new Map([['ssh:ssh-1', 1]]), new Map([['ssh:ssh-1', ['ssh-pinned']]])), + pinned, + repoHeader(local), + item('local-wt', local) + ] + + const sectioned = addHostSectionRows({ + rows, + hostOptions: [ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + }, + { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } + ], + workspaceHostScope: 'all', + defaultHostId: 'local' + }) + + expect(sectioned.map(rowKey)).toEqual([ + 'host:local', + 'repo:local', + 'local-wt', + 'host:ssh:ssh-1', + PINNED_GROUP_KEY, + 'ssh-pinned' + ]) + expect(sectioned.filter((row) => row.type === 'host-header')).toMatchObject([ + { hostId: 'local', count: 1 }, + { hostId: 'ssh:ssh-1', count: 1 } + ]) + }) + + it('does not double-count a collapsed pinned header and its natural duplicate row', () => { + const local = repo('local') + const ssh = repo('ssh', 'ssh-1') + const rows = [ + pinnedHeader(new Map([['local', 1]]), new Map([['local', ['wt-1']]])), + { ...header('all', 'All'), count: 1, worktreeIds: ['wt-1'] }, + item('wt-1', local), + repoHeader(ssh), + item('ssh-wt', ssh) + ] + + const sectioned = addHostSectionRows({ + rows, + hostOptions: [ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + }, + { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } + ], + workspaceHostScope: 'all', + defaultHostId: 'local' + }) + + expect( + sectioned.find((row) => row.type === 'host-header' && row.hostId === 'local') + ).toMatchObject({ count: 1 }) + }) + + it('localizes collapsed natural headers even when the hidden rows are owned by one host', () => { + const ssh = repo('ssh', 'ssh-1') + const localHostId = 'local' as const + const rows = [ + { + ...header('all', 'All'), + count: 1, + hostWorktreeCounts: new Map([[localHostId, 1]]), + hostWorktreeIds: new Map([[localHostId, ['local-wt']]]), + worktreeIds: ['local-wt'] + }, + repoHeader(ssh), + item('ssh-wt', ssh) + ] + + const sectioned = addHostSectionRows({ + rows, + hostOptions: [ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + }, + { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } + ], + workspaceHostScope: 'all', + defaultHostId: 'local' + }) + + expect(sectioned.map(rowKey)).toEqual([ + 'host:local', + 'all', + 'host:ssh:ssh-1', + 'repo:ssh', + 'ssh-wt' + ]) + expect(sectioned.filter((row) => row.type === 'host-header')).toMatchObject([ + { hostId: 'local', count: 1 }, + { hostId: 'ssh:ssh-1', count: 1 } + ]) + expect(sectioned.filter((row) => row.type === 'header' && row.key === 'all')).toMatchObject([ + { hostId: 'local', count: 1, worktreeIds: ['local-wt'] } + ]) }) it('groups explicitly runtime-owned repos under their owner host, not the focused host', () => { diff --git a/src/renderer/src/components/sidebar/host-section-rows.ts b/src/renderer/src/components/sidebar/host-section-rows.ts index 3283e305a04..7ca084d7520 100644 --- a/src/renderer/src/components/sidebar/host-section-rows.ts +++ b/src/renderer/src/components/sidebar/host-section-rows.ts @@ -3,6 +3,7 @@ import { LOCAL_EXECUTION_HOST_ID, getLocalExecutionHostLabel, getRepoExecutionHostId, + getWorktreeExecutionHostId, type ExecutionHostId, type ExecutionHostKind, type ExecutionHostScope @@ -11,7 +12,6 @@ import type { ExecutionHostHealth } from '../../../../shared/execution-host-regi import type { RuntimeCompatVerdict } from '../../../../shared/protocol-compat' import type { SshConnectionStatus } from '../../../../shared/ssh-types' import type { FolderWorkspace, ProjectGroup, Repo } from '../../../../shared/types' -import { PINNED_GROUP_KEY } from './worktree-list-groups' import type { Row } from './worktree-list-groups' export type HostHeaderRow = { @@ -70,7 +70,7 @@ function getFolderWorkspaceHostId( function getRowHostId(row: Row, defaultHostId: ExecutionHostId): ExecutionHostId | null { switch (row.type) { case 'item': - return getRepoHostId(row.repo, defaultHostId) + return getWorktreeExecutionHostId(row.worktree, row.repo, defaultHostId) case 'pending-creation': case 'imported-worktrees-card': case 'new-external-worktrees-inbox': @@ -99,19 +99,28 @@ function countWorktreeRows(rows: readonly Row[]): number { // while a visibly populated project sits right under it. let count = 0 const seenWorktreeIds = new Set() - let pendingHeaderCount: number | null = null + let pendingHeader: Extract | null = null let pendingHeaderHadItems = false const flushHeader = (): void => { - if (pendingHeaderCount !== null && !pendingHeaderHadItems) { - count += pendingHeaderCount + if (pendingHeader && !pendingHeaderHadItems) { + if (pendingHeader.worktreeIds) { + for (const worktreeId of pendingHeader.worktreeIds) { + if (!seenWorktreeIds.has(worktreeId)) { + count += 1 + seenWorktreeIds.add(worktreeId) + } + } + } else { + count += pendingHeader.count + } } - pendingHeaderCount = null + pendingHeader = null pendingHeaderHadItems = false } for (const row of rows) { if (row.type === 'header') { flushHeader() - pendingHeaderCount = row.key === PINNED_GROUP_KEY ? null : row.count + pendingHeader = row continue } if (row.type === 'item') { @@ -119,13 +128,45 @@ function countWorktreeRows(rows: readonly Row[]): number { count += 1 seenWorktreeIds.add(row.worktree.id) } - pendingHeaderHadItems = pendingHeaderCount !== null + pendingHeaderHadItems = pendingHeader !== null } } flushHeader() return count } +function localizePendingRowsForHost( + rows: readonly Extract[], + hostId: ExecutionHostId +): Extract[] { + const localized: Extract[] = [] + for (const row of rows) { + if (!row.hostWorktreeCounts) { + localized.push(row) + continue + } + const count = row.hostWorktreeCounts.get(hostId) + if (count !== undefined && count > 0) { + localized.push({ + ...row, + count, + hostId, + worktreeIds: row.hostWorktreeIds?.get(hostId) ?? row.worktreeIds + }) + } + } + return localized +} + +function getPendingRowsKey(rows: readonly Extract[]): string { + return rows + .map( + (pendingRow) => + `${pendingRow.key}:${pendingRow.count}:${pendingRow.worktreeIds?.join(',') ?? ''}` + ) + .join('\0') +} + export function addHostSectionRows(args: { rows: readonly Row[] hostOptions: readonly HostSectionOption[] @@ -161,18 +202,48 @@ export function addHostSectionRows(args: { let pendingRows: Extract[] = [] let pendingRowsWereUsed = false const pendingRowsKeyByHostId = new Map() + const flushUnusedPendingRows = (): void => { + if (pendingRows.length === 0 || pendingRowsWereUsed) { + return + } + const hostScopedRow = pendingRows.some((row) => row.hostWorktreeCounts) + if (!hostScopedRow) { + globalRows.push(...pendingRows) + return + } + for (const row of pendingRows) { + for (const [hostId, count] of row.hostWorktreeCounts ?? []) { + if (count <= 0) { + continue + } + const hostRows = rowsByHostId.get(hostId) ?? [] + const hostIds = row.hostWorktreeIds?.get(hostId) + hostRows.push({ + ...row, + count, + hostId, + worktreeIds: hostIds ?? row.worktreeIds + }) + rowsByHostId.set(hostId, hostRows) + } + } + } for (const row of args.rows) { const rowHostId = getRowHostId(row, args.defaultHostId) if (rowHostId) { const hostRows = rowsByHostId.get(rowHostId) ?? [] if (pendingRows.length > 0) { - const pendingRowsKey = pendingRows.map((pendingRow) => pendingRow.key).join('\0') - if (pendingRowsKeyByHostId.get(rowHostId) !== pendingRowsKey) { - hostRows.push(...pendingRows) + const localizedPendingRows = localizePendingRowsForHost(pendingRows, rowHostId) + const pendingRowsKey = getPendingRowsKey(localizedPendingRows) + if ( + localizedPendingRows.length > 0 && + pendingRowsKeyByHostId.get(rowHostId) !== pendingRowsKey + ) { + hostRows.push(...localizedPendingRows) pendingRowsKeyByHostId.set(rowHostId, pendingRowsKey) } - pendingRowsWereUsed = true + pendingRowsWereUsed = pendingRowsWereUsed || localizedPendingRows.length > 0 } hostRows.push(row) rowsByHostId.set(rowHostId, hostRows) @@ -181,6 +252,7 @@ export function addHostSectionRows(args: { // Why: status/"All" headers describe the rows that follow. Buffer them // for every host-owned run so host remains above the existing grouping. if (row.type === 'header') { + flushUnusedPendingRows() pendingRows = [row] pendingRowsWereUsed = false } else { @@ -188,9 +260,7 @@ export function addHostSectionRows(args: { } } - if (pendingRows.length > 0 && !pendingRowsWereUsed) { - globalRows.push(...pendingRows) - } + flushUnusedPendingRows() const hostOrder: ExecutionHostId[] = [] for (const host of args.hostOptions) { diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts index 61a537be594..678816e3959 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts @@ -139,51 +139,32 @@ describe('runSleepWorktree', () => { expect(requestAnimationFrame).toHaveBeenCalledTimes(1) }) - it('anchors sleep restoration to the primary duplicate row', async () => { - let frameCount = 0 - const requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { - frameCount += 1 - if (frameCount === 1) { - callback(0) - } - return frameCount - }) + it('anchors sleep restoration to the natural duplicate row when no primary row is marked', async () => { + const requestAnimationFrame = vi.fn(() => 1) const scroller = { dispatchEvent: vi.fn(), scrollHeight: 100, scrollTop: 0 } + const pinnedGetBoundingClientRect = vi.fn(() => ({ top: 10 })) + const naturalGetBoundingClientRect = vi.fn(() => ({ top: 42 })) const pinnedRow = { - closest: (selector: string) => - selector === '[data-worktree-virtual-row]' ? pinnedRow : null, - getBoundingClientRect: () => ({ top: 10 }) + getBoundingClientRect: pinnedGetBoundingClientRect } - let naturalTop = 40 const naturalRow = { - closest: (selector: string) => - selector === '[data-worktree-virtual-row]' ? naturalRow : null, - getBoundingClientRect: () => ({ top: naturalTop }) + getBoundingClientRect: naturalGetBoundingClientRect } const pinnedOption = { - dataset: { - worktreeId: 'wt-1', - worktreeRowKey: 'pinned:wt-1', - worktreeSectionKey: 'pinned' - }, + dataset: { worktreeId: 'wt-1', worktreeRowKey: 'pinned:wt-1' }, closest: (selector: string) => selector === '[data-worktree-virtual-row]' ? pinnedRow : null, querySelector: () => null } const naturalOption = { - dataset: { - worktreeId: 'wt-1', - worktreeRowKey: 'all:wt-1', - worktreeSectionKey: 'all' - }, + dataset: { worktreeId: 'wt-1', worktreeRowKey: 'all:wt-1' }, closest: (selector: string) => selector === '[data-worktree-virtual-row]' ? naturalRow : null, - querySelector: (selector: string) => - selector === '[data-worktree-card-active="primary"]' ? {} : null + querySelector: () => null } vi.stubGlobal('document', { querySelector: (selector: string) => @@ -194,13 +175,10 @@ describe('runSleepWorktree', () => { vi.stubGlobal('window', { requestAnimationFrame }) mocks.state.activeWorktreeId = 'wt-1' - mocks.state.setActiveWorktree.mockImplementation(() => { - naturalTop = 45 - }) - await runSleepWorktree('wt-1') - expect(scroller.scrollTop).toBe(5) + expect(naturalGetBoundingClientRect).toHaveBeenCalled() + expect(pinnedGetBoundingClientRect).not.toHaveBeenCalled() }) it('leaves activeWorktreeId alone when sleeping a background worktree', async () => { diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts index 5cde695f78e..905ce91901c 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts @@ -3,7 +3,6 @@ import { useAppStore } from '@/store' import { clearWorktreeSleepIntent, markWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' import { VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT } from '@/hooks/useVirtualizedScrollAnchor' import { translate } from '@/i18n/i18n' -import { PINNED_GROUP_KEY } from './worktree-list-groups' /** * Shared "sleep worktree" flow (close all panels to free memory / CPU) @@ -28,13 +27,19 @@ function getSidebarWorktreeOptions(worktreeId: string): HTMLElement[] { ) } +function isPinnedSidebarWorktreeOption(element: HTMLElement): boolean { + // Why: duplicated pinned rows share a worktree id; the row-key prefix is the + // stable row-scoped signal that distinguishes the pinned copy from natural rows. + return element.dataset.worktreeRowKey?.startsWith('pinned:') === true +} + function findPrimarySidebarWorktreeOption(worktreeId: string): HTMLElement | null { const options = getSidebarWorktreeOptions(worktreeId) return ( options.find((element) => element.querySelector('[data-worktree-card-active="primary"]') ) ?? - options.find((element) => element.dataset.worktreeSectionKey !== PINNED_GROUP_KEY) ?? + options.find((element) => !isPinnedSidebarWorktreeOption(element)) ?? options[0] ?? null ) diff --git a/src/renderer/src/components/sidebar/visible-worktrees.test.ts b/src/renderer/src/components/sidebar/visible-worktrees.test.ts index c1da9409447..939ca46a1e9 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.test.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.test.ts @@ -329,6 +329,25 @@ describe('computeVisibleWorktreeIds', () => { expect(result).toEqual([local.id]) }) + it('uses explicit worktree ownership when it differs from the repo host', () => { + const runtime = makeWorktree('runtime', 'repo1') + runtime.hostId = 'runtime:env-1' + + const runtimeResult = computeVisibleWorktreeIds( + { repo1: [runtime] }, + [runtime.id], + visibleOptions({ workspaceHostScope: 'runtime:env-1' }) + ) + const localResult = computeVisibleWorktreeIds( + { repo1: [runtime] }, + [runtime.id], + visibleOptions({ workspaceHostScope: 'local' }) + ) + + expect(runtimeResult).toEqual([runtime.id]) + expect(localResult).toEqual([]) + }) + it('keeps every host visible when workspace host scope is all', () => { const local = makeWorktree('local', 'repo1') const remote = makeWorktree('remote', 'repo2') diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index 7ac1b111832..8809e7f7835 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -6,8 +6,8 @@ import { getAllWorktreesFromState, getRepoMapFromState } from '@/store/selectors import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../shared/constants' import { ALL_EXECUTION_HOSTS_SCOPE, - getRepoExecutionHostId, getSettingsFocusedExecutionHostId, + getWorktreeExecutionHostId, type ExecutionHostId, type ExecutionHostScope } from '../../../../shared/execution-host' @@ -153,10 +153,7 @@ export function computeVisibleWorktreeIds( if (!repo) { return false } - const hostId = - repo.connectionId || repo.executionHostId - ? getRepoExecutionHostId(repo) - : opts.defaultHostId + const hostId = getWorktreeExecutionHostId(w, repo, opts.defaultHostId) return visibleHostIdSet.has(hostId) }) } diff --git a/src/renderer/src/components/sidebar/worktree-drag-units.test.ts b/src/renderer/src/components/sidebar/worktree-drag-units.test.ts index f77ea72b2ee..9d6f230d5cd 100644 --- a/src/renderer/src/components/sidebar/worktree-drag-units.test.ts +++ b/src/renderer/src/components/sidebar/worktree-drag-units.test.ts @@ -70,7 +70,26 @@ describe('getWorktreeDragUnitGroups', () => { ]) }) - it('ignores pinned overlay rows', () => { + it('includes pinned rows when they are the only rendered copy', () => { + const groups = getWorktreeDragUnitGroups([ + header('pinned'), + item('pinned-copy', 0, 'pinned'), + item('other-pinned', 0, 'pinned') + ]) + + expect(groups).toEqual([ + { + key: 'pinned', + worktreeIds: ['pinned-copy', 'other-pinned'], + units: [ + { worktreeId: 'pinned-copy', worktreeIds: ['pinned-copy'] }, + { worktreeId: 'other-pinned', worktreeIds: ['other-pinned'] } + ] + } + ]) + }) + + it('uses natural drag units when pinned rows have duplicate natural copies', () => { const groups = getWorktreeDragUnitGroups([ header('pinned'), item('pinned-copy', 0, 'pinned'), diff --git a/src/renderer/src/components/sidebar/worktree-drag-units.ts b/src/renderer/src/components/sidebar/worktree-drag-units.ts index 39cd9f97f6a..c38d8b8643c 100644 --- a/src/renderer/src/components/sidebar/worktree-drag-units.ts +++ b/src/renderer/src/components/sidebar/worktree-drag-units.ts @@ -19,6 +19,11 @@ export function getWorktreeDragUnitGroups( ): WorktreeDragUnitGroup[] { const groups: WorktreeDragUnitGroup[] = [] let current: { key: string; units: WorktreeDragUnitGroup['units'] } | null = null + const naturalWorktreeIds = new Set( + rows.flatMap((row) => + row.type === 'item' && row.sectionKey !== PINNED_GROUP_KEY ? [row.worktree.id] : [] + ) + ) for (const row of rows) { if (row.type === 'header') { @@ -39,7 +44,7 @@ export function getWorktreeDragUnitGroups( ) { continue } - if (row.sectionKey === PINNED_GROUP_KEY) { + if (row.sectionKey === PINNED_GROUP_KEY && naturalWorktreeIds.has(row.worktree.id)) { continue } if (!current) { diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts index b967c4cd356..6261443c3e6 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -12,6 +12,7 @@ import { getLineageGroupKey, getLineageRenderInfo, getPRGroupKey, + PINNED_GROUP_KEY, type PendingCreationRef } from './worktree-list-groups' import { @@ -216,10 +217,39 @@ describe('buildRows with pinned worktrees', () => { const rows = buildRows('none', [unpinned1, pinned, unpinned2], repoMap, null, new Set()) expect(rows[0]).toMatchObject({ type: 'header', key: 'pinned', label: 'Pinned' }) expect(rows[1]).toMatchObject({ type: 'item', worktree: { id: 'wt-pinned' } }) - expect(rows[2]).toMatchObject({ type: 'header', key: 'all', label: 'All', count: 3 }) + expect(rows[2]).toMatchObject({ type: 'header', key: 'all', label: 'All', count: 2 }) expect(rows[2]).toMatchObject({ type: 'header', icon: ALL_GROUP_META.icon }) }) + it('uses worktree host ownership for pinned header host counts', () => { + const runtimePinned = { + ...pinned, + hostId: 'runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3' as const + } + + const rows = buildRows( + 'none', + [runtimePinned, unpinned1], + repoMap, + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([ + [runtimePinned.id, runtimePinned], + [unpinned1.id, unpinned1] + ]) + ) + const pinnedHeader = rows[0] + + expect(pinnedHeader).toMatchObject({ type: 'header', key: 'pinned' }) + expect(pinnedHeader.type === 'header' ? pinnedHeader.hostWorktreeCounts : undefined).toEqual( + new Map([['runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3', 1]]) + ) + }) + it('groups all worktrees under All in groupBy none', () => { const rows = buildRows('none', [unpinned1, unpinned2], repoMap, null, new Set()) @@ -230,30 +260,55 @@ describe('buildRows with pinned worktrees', () => { ]) }) - it('keeps pinned worktrees above the All group', () => { + it('moves pinned worktrees out of the All group', () => { const rows = buildRows('none', [unpinned1, pinned, unpinned2], repoMap, null, new Set()) expect(rows).toMatchObject([ { type: 'header', key: 'pinned' }, { type: 'item', worktree: { id: 'wt-pinned' } }, - { type: 'header', key: 'all', count: 3 }, + { type: 'header', key: 'all', count: 2 }, { type: 'item', worktree: { id: 'wt-1' } }, - { type: 'item', worktree: { id: 'wt-pinned' } }, { type: 'item', worktree: { id: 'wt-2' } } ]) }) + it('duplicates pinned worktrees into All when the policy allows it', () => { + const rows = buildRows( + 'none', + [unpinned1, pinned, unpinned2], + repoMap, + null, + new Set(), + undefined, + undefined, + undefined, + {}, + undefined, + false, + { showPinnedWorktreesInGroups: true } as never + ) + + expect(rows).toMatchObject([ + { type: 'header', key: 'pinned' }, + { type: 'item', sectionKey: PINNED_GROUP_KEY, worktree: { id: 'wt-pinned' } }, + { type: 'header', key: 'all', count: 3 }, + { type: 'item', sectionKey: 'all', worktree: { id: 'wt-1' } }, + { type: 'item', sectionKey: 'all', worktree: { id: 'wt-pinned' } }, + { type: 'item', sectionKey: 'all', worktree: { id: 'wt-2' } } + ]) + }) + it('collapses the All group in groupBy none', () => { const rows = buildRows('none', [unpinned1, pinned, unpinned2], repoMap, null, new Set(['all'])) expect(rows).toMatchObject([ { type: 'header', key: 'pinned' }, { type: 'item', worktree: { id: 'wt-pinned' } }, - { type: 'header', key: 'all', count: 3 } + { type: 'header', key: 'all', count: 2 } ]) }) - it('emits status headers for all matching worktrees in groupBy workspace-status', () => { + it('emits status headers for unpinned matching worktrees in groupBy workspace-status', () => { const rows = buildRows( 'workspace-status', [unpinned1, pinned, unpinned2], @@ -265,21 +320,45 @@ describe('buildRows with pinned worktrees', () => { type: 'header', key: 'workspace-status:in-progress', label: 'In progress', - count: 3 + count: 2 }) expect(rows[3]).toMatchObject({ type: 'item', worktree: { id: 'wt-1' } }) - expect(rows[4]).toMatchObject({ type: 'item', worktree: { id: 'wt-pinned' } }) - expect(rows[5]).toMatchObject({ type: 'item', worktree: { id: 'wt-2' } }) + expect(rows[4]).toMatchObject({ type: 'item', worktree: { id: 'wt-2' } }) }) - it('keeps pinned items in regular groups in pr-status mode', () => { + it('duplicates pinned worktrees into status groups when the policy allows it', () => { + const rows = buildRows( + 'workspace-status', + [unpinned1, pinned], + repoMap, + null, + new Set(), + undefined, + undefined, + undefined, + {}, + undefined, + false, + { showPinnedWorktreesInGroups: true } as never + ) + + expect(rows).toMatchObject([ + { type: 'header', key: 'pinned', count: 1 }, + { type: 'item', sectionKey: PINNED_GROUP_KEY, worktree: { id: 'wt-pinned' } }, + { type: 'header', key: 'workspace-status:in-progress', count: 2 }, + { type: 'item', sectionKey: 'workspace-status:in-progress', worktree: { id: 'wt-1' } }, + { type: 'item', sectionKey: 'workspace-status:in-progress', worktree: { id: 'wt-pinned' } } + ]) + }) + + it('moves pinned items out of regular groups in pr-status mode', () => { const rows = buildRows('pr-status', [unpinned1, pinned], repoMap, null, new Set()) const pinnedHeader = rows.find((r) => r.type === 'header' && r.key === 'pinned') expect(pinnedHeader).toBeDefined() const prGroup = rows.filter((r) => r.type === 'header' && r.key.startsWith('pr:')) for (const header of prGroup) { if (header.type === 'header') { - expect(header.count).toBe(2) + expect(header.count).toBe(1) } } }) @@ -305,20 +384,14 @@ describe('buildRows with pinned worktrees', () => { ) expect(rows[0]).toMatchObject({ type: 'header', key: 'pinned' }) expect(rows[1]).toMatchObject({ type: 'header', key: 'workspace-status:in-progress' }) - expect(rows[2]).toMatchObject({ type: 'item', worktree: { id: 'wt-pinned' } }) - expect(rows[3]).toMatchObject({ type: 'item', worktree: { id: 'wt-1' } }) + expect(rows[2]).toMatchObject({ type: 'item', worktree: { id: 'wt-1' } }) }) - it('keeps status sections complete when all worktrees are pinned', () => { + it('omits status sections when all matching worktrees are pinned', () => { const allPinned = { ...unpinned1, isPinned: true } const rows = buildRows('workspace-status', [pinned, allPinned], repoMap, null, new Set()) - expect(rows.filter((r) => r.type === 'header')).toHaveLength(2) + expect(rows.filter((r) => r.type === 'header')).toHaveLength(1) expect(rows[0]).toMatchObject({ type: 'header', key: 'pinned', count: 2 }) - expect(rows[3]).toMatchObject({ - type: 'header', - key: 'workspace-status:in-progress', - count: 2 - }) }) it('preserves repo display casing in group labels', () => { @@ -1291,6 +1364,123 @@ describe('buildRows with pinned worktrees', () => { expect(rows.some((row) => row.type === 'imported-worktrees-card')).toBe(false) }) + it('places non-repo imported fallbacks after each repo last pinned row when expanded', () => { + const repoTwo: Repo = { ...repo, id: 'repo-2', displayName: 'auth-service' } + const pinnedOneA = { ...worktree, id: 'repo-1-pinned-a', isPinned: true } + const pinnedTwo = { + ...worktree, + id: 'repo-2-pinned', + repoId: repoTwo.id, + isPinned: true, + displayName: 'auth-main' + } + const pinnedOneB = { ...worktree, id: 'repo-1-pinned-b', isPinned: true } + + const rows = buildRows( + 'none', + [pinnedOneA, pinnedTwo, pinnedOneB], + new Map([ + [repo.id, repo], + [repoTwo.id, repoTwo] + ]), + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([ + [pinnedOneA.id, pinnedOneA], + [pinnedTwo.id, pinnedTwo], + [pinnedOneB.id, pinnedOneB] + ]), + false, + undefined, + [], + new Set(), + new Map([ + [repo.id, { repo, hiddenWorktrees: [makeDetectedWorktree({ id: 'hidden-one' })] }], + [ + repoTwo.id, + { + repo: repoTwo, + hiddenWorktrees: [makeDetectedWorktree({ id: 'hidden-two', repoId: repoTwo.id })] + } + ] + ]) + ) + + expect( + rows.map((row) => + row.type === 'item' + ? row.worktree.id + : row.type === 'imported-worktrees-card' + ? `${row.placement}:${row.repo.id}` + : row.key + ) + ).toEqual([ + 'pinned', + 'repo-1-pinned-a', + 'repo-2-pinned', + 'pinned-fallback:repo-2', + 'repo-1-pinned-b', + 'pinned-fallback:repo-1' + ]) + }) + + it('places collapsed non-repo imported fallbacks after Pinned in pinned repo order', () => { + const repoTwo: Repo = { ...repo, id: 'repo-2', displayName: 'auth-service' } + const pinnedOneA = { ...worktree, id: 'repo-1-pinned-a', isPinned: true } + const pinnedTwo = { + ...worktree, + id: 'repo-2-pinned', + repoId: repoTwo.id, + isPinned: true, + displayName: 'auth-main' + } + const pinnedOneB = { ...worktree, id: 'repo-1-pinned-b', isPinned: true } + + const rows = buildRows( + 'workspace-status', + [pinnedOneA, pinnedTwo, pinnedOneB], + new Map([ + [repo.id, repo], + [repoTwo.id, repoTwo] + ]), + null, + new Set([PINNED_GROUP_KEY]), + undefined, + undefined, + undefined, + {}, + new Map([ + [pinnedOneA.id, pinnedOneA], + [pinnedTwo.id, pinnedTwo], + [pinnedOneB.id, pinnedOneB] + ]), + false, + undefined, + [], + new Set(), + new Map([ + [repo.id, { repo, hiddenWorktrees: [makeDetectedWorktree({ id: 'hidden-one' })] }], + [ + repoTwo.id, + { + repo: repoTwo, + hiddenWorktrees: [makeDetectedWorktree({ id: 'hidden-two', repoId: repoTwo.id })] + } + ] + ]) + ) + + expect(rows).toMatchObject([ + { type: 'header', key: PINNED_GROUP_KEY, count: 3 }, + { type: 'imported-worktrees-card', placement: 'pinned-fallback', repo: { id: repo.id } }, + { type: 'imported-worktrees-card', placement: 'pinned-fallback', repo: { id: repoTwo.id } } + ]) + }) + it('emits a new external worktrees inbox row before repo worktree rows', () => { const inboxWorktrees = [ makeDetectedWorktree({ id: 'inbox-1', displayName: 'payments-refactor' }), @@ -1379,6 +1569,51 @@ describe('buildRows with pinned worktrees', () => { ]) }) + it('keeps the inbox group when the repo only has a pinned worktree', () => { + const pinnedWorktree = { ...worktree, id: 'wt-pinned', isPinned: true } + const rows = buildRows( + 'repo', + [pinnedWorktree], + repoMap, + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([[pinnedWorktree.id, pinnedWorktree]]), + false, + undefined, + [], + new Set(), + new Map(), + new Map([[repo.id, { repo, inboxWorktrees: [makeDetectedWorktree()] }]]) + ) + + expect(rows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'item', + sectionKey: PINNED_GROUP_KEY, + worktree: expect.objectContaining({ id: pinnedWorktree.id }) + }), + expect.objectContaining({ type: 'header', key: `repo:${repo.id}` }), + expect.objectContaining({ + type: 'new-external-worktrees-inbox', + key: `new-external-worktrees-inbox:${repo.id}` + }) + ]) + ) + expect( + rows.some( + (row) => + row.type === 'item' && + row.sectionKey === `repo:${repo.id}` && + row.worktree.id === pinnedWorktree.id + ) + ).toBe(false) + }) + it('does not emit new external worktrees inbox rows outside repo grouping', () => { const rows = buildRows( 'workspace-status', @@ -1457,24 +1692,112 @@ describe('buildRows with pinned worktrees', () => { placement: 'repo-group' } ]) + expect( + rows.filter((row) => row.type === 'item' && row.sectionKey !== PINNED_GROUP_KEY) + ).toEqual([]) expect(rows).toEqual( expect.arrayContaining([ expect.objectContaining({ type: 'item', + sectionKey: PINNED_GROUP_KEY, worktree: expect.objectContaining({ id: 'repo-1-pinned-a' }) }), expect.objectContaining({ type: 'item', + sectionKey: PINNED_GROUP_KEY, worktree: expect.objectContaining({ id: 'repo-1-pinned-b' }) }), expect.objectContaining({ type: 'item', + sectionKey: PINNED_GROUP_KEY, worktree: expect.objectContaining({ id: 'repo-2-pinned' }) }) ]) ) }) + it('duplicates pinned worktrees into repo groups when the policy allows it', () => { + const pinnedWorktree = { ...worktree, id: 'wt-pinned', isPinned: true } + const rows = buildRows( + 'repo', + [pinnedWorktree], + repoMap, + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([[pinnedWorktree.id, pinnedWorktree]]), + false, + { showPinnedWorktreesInGroups: true } as never, + [], + new Set(), + new Map([[repo.id, { repo, hiddenWorktrees: [makeDetectedWorktree()] }]]) + ) + + expect(rows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'item', + sectionKey: PINNED_GROUP_KEY, + worktree: expect.objectContaining({ id: 'wt-pinned' }) + }), + expect.objectContaining({ + type: 'item', + sectionKey: `repo:${repo.id}`, + worktree: expect.objectContaining({ id: 'wt-pinned' }) + }) + ]) + ) + }) + + it('suppresses duplicate-mode imported fallback only when a natural anchor renders', () => { + const pinnedWorktree = { ...worktree, id: 'wt-pinned', isPinned: true } + const imported = new Map([[repo.id, { repo, hiddenWorktrees: [makeDetectedWorktree()] }]]) + const expanded = buildRows( + 'none', + [pinnedWorktree], + repoMap, + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([[pinnedWorktree.id, pinnedWorktree]]), + false, + { showPinnedWorktreesInGroups: true } as never, + [], + new Set(), + imported + ) + const collapsedAll = buildRows( + 'none', + [pinnedWorktree], + repoMap, + null, + new Set(['all']), + undefined, + undefined, + undefined, + {}, + new Map([[pinnedWorktree.id, pinnedWorktree]]), + false, + { showPinnedWorktreesInGroups: true } as never, + [], + new Set(), + imported + ) + + expect(expanded.some((row) => row.type === 'imported-worktrees-card')).toBe(false) + expect(collapsedAll).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'imported-worktrees-card', placement: 'pinned-fallback' }) + ]) + ) + }) + it('suppresses pinned imported worktree fallback when the repo has visible unpinned rows', () => { const pinnedWorktree = { ...worktree, id: 'wt-pinned', isPinned: true } const rows = buildRows( @@ -1526,9 +1849,11 @@ describe('buildRows with pinned worktrees', () => { expect(rows).toMatchObject([ { type: 'header', key: 'pinned' }, { type: 'header', key: 'repo:repo-1' }, - { type: 'imported-worktrees-card', placement: 'repo-group' }, - { type: 'item', worktree: { id: 'wt-pinned' } } + { type: 'imported-worktrees-card', placement: 'repo-group' } ]) + expect( + rows.filter((row) => row.type === 'item' && row.sectionKey !== PINNED_GROUP_KEY) + ).toEqual([]) }) it('groups folder-mode workspaces under their folder name', () => { @@ -1933,6 +2258,73 @@ describe('project groups', () => { expect(rows[0]).toMatchObject({ label: 'Platform' }) }) + it('keeps sleep-filtered Project Group members as empty project headers', () => { + // Why: #8865 — Hide sleeping removes workspace cards; membership placeholders + // must still project the grouped project header so the group count stays honest. + const group: ProjectGroup = { + id: 'group-1', + name: 'Platform', + parentPath: '/platform', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + const sleepingRepo: Repo = { + ...repo, + id: 'repo-sleeping', + displayName: 'sleeping-project', + projectGroupId: group.id + } + const awakeRepo: Repo = { + ...repo, + id: 'repo-awake', + displayName: 'awake-project', + projectGroupId: group.id + } + const awakeWorktree: Worktree = { + ...worktree, + id: 'wt-awake', + repoId: awakeRepo.id, + path: '/tmp/awake' + } + + const rows = buildRows( + 'repo', + [awakeWorktree], + new Map([ + [sleepingRepo.id, sleepingRepo], + [awakeRepo.id, awakeRepo] + ]), + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([[awakeWorktree.id, awakeWorktree]]), + false, + undefined, + [group], + new Set([sleepingRepo.id]) + ) + + expect(rows[0]).toMatchObject({ + type: 'header', + key: 'project-group:group-1', + count: 2 + }) + // Why: empty/placeholder projects sort after projects with visible activity. + expect(rows.filter((row) => row.type === 'header').map((row) => row.key)).toEqual([ + 'project-group:group-1', + `repo:${awakeRepo.id}`, + `repo:${sleepingRepo.id}` + ]) + }) + it('renders ungrouped repos as top-level repo rows when Project Groups exist', () => { const group: ProjectGroup = { id: 'group-1', diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index df31f09f8fb..41fcdaf9c3b 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -34,13 +34,26 @@ import type { AppState } from '../../store/types' import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from '../../store/slices/github-cache-key' import { getRepoDisplayLabelKey, getRepoDisplayLabelsByPath } from '@/lib/repo-display-labels' import { translate } from '@/i18n/i18n' -import { getExecutionHostLabel, getRepoExecutionHostId } from '../../../../shared/execution-host' +import { + getExecutionHostLabel, + LOCAL_EXECUTION_HOST_ID, + getRepoExecutionHostId, + getWorktreeExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' import { parseWslUncPath } from '../../../../shared/wsl-paths' import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path' export { branchName } export type WorktreeGroupBy = 'none' | 'workspace-status' | 'repo' | 'pr-status' +export type PinnedWorktreeDisplayPolicy = 'single-location' | 'duplicate-in-groups' + +export function getPinnedWorktreeDisplayPolicy( + settings?: { showPinnedWorktreesInGroups?: boolean } | null +): PinnedWorktreeDisplayPolicy { + return settings?.showPinnedWorktreesInGroups === true ? 'duplicate-in-groups' : 'single-location' +} export type GroupHeaderRow = { type: 'header' @@ -52,6 +65,10 @@ export type GroupHeaderRow = { repo?: Repo projectGroup?: ProjectGroup | { id: null; name: 'Ungrouped'; tabOrder: number } projectGroupDepth?: number + hostId?: ExecutionHostId + hostWorktreeCounts?: ReadonlyMap + hostWorktreeIds?: ReadonlyMap + worktreeIds?: readonly string[] } export type WorktreeRow = { @@ -438,15 +455,15 @@ export function getPRGroupKey( /** * Emit a "Pinned" header + its items into `result`. * - * Why: pinned is a shortcut overlay, not the worktree's canonical grouping. - * Normal sections still include pinned worktrees so labels like "All" and - * "In progress" remain literal. + * Why: the dedicated Pinned section is always present for pinned worktrees; + * the display policy decides whether their natural group rows also render. */ function emitPinnedGroup( worktrees: Worktree[], repoMap: Map, + defaultHostId: ExecutionHostId, collapsedGroups: Set, - visibleUnpinnedRepoIds: ReadonlySet, + renderedNaturalAnchorRepoIds: ReadonlySet, importedWorktreesByRepo: ReadonlyMap, allowImportedFallback: boolean, result: Row[] @@ -455,6 +472,21 @@ function emitPinnedGroup( if (pinned.length === 0) { return } + const hostWorktreeCounts = new Map() + const hostWorktreeIds = new Map() + const pinnedRepoOrder: string[] = [] + const seenPinnedRepoIds = new Set() + for (const worktree of pinned) { + const hostId = getWorktreeExecutionHostId(worktree, repoMap.get(worktree.repoId), defaultHostId) + hostWorktreeCounts.set(hostId, (hostWorktreeCounts.get(hostId) ?? 0) + 1) + const hostIds = hostWorktreeIds.get(hostId) ?? [] + hostIds.push(worktree.id) + hostWorktreeIds.set(hostId, hostIds) + if (!seenPinnedRepoIds.has(worktree.repoId)) { + pinnedRepoOrder.push(worktree.repoId) + seenPinnedRepoIds.add(worktree.repoId) + } + } result.push({ type: 'header', @@ -462,9 +494,19 @@ function emitPinnedGroup( label: PINNED_GROUP_META.label, count: pinned.length, tone: PINNED_GROUP_META.tone, - icon: PINNED_GROUP_META.icon + icon: PINNED_GROUP_META.icon, + hostWorktreeCounts, + hostWorktreeIds, + worktreeIds: pinned.map((worktree) => worktree.id) }) - if (!collapsedGroups.has(PINNED_GROUP_KEY)) { + if (collapsedGroups.has(PINNED_GROUP_KEY)) { + for (const repoId of pinnedRepoOrder) { + const candidate = importedWorktreesByRepo.get(repoId) + if (allowImportedFallback && candidate && !renderedNaturalAnchorRepoIds.has(repoId)) { + result.push(buildImportedWorktreesCardRow(candidate, 'pinned-fallback')) + } + } + } else { const lastPinnedIndexByRepoId = new Map() pinned.forEach((worktree, index) => lastPinnedIndexByRepoId.set(worktree.repoId, index)) for (const [index, worktree] of pinned.entries()) { @@ -484,7 +526,7 @@ function emitPinnedGroup( if ( allowImportedFallback && candidate && - !visibleUnpinnedRepoIds.has(worktree.repoId) && + !renderedNaturalAnchorRepoIds.has(worktree.repoId) && lastPinnedIndexByRepoId.get(worktree.repoId) === index ) { result.push(buildImportedWorktreesCardRow(candidate, 'pinned-fallback')) @@ -689,6 +731,101 @@ function getMixedHostContextLabels( return uniqueLabels.size > 1 ? labelsByRepoId : undefined } +function getHostWorktreeCounts( + worktrees: readonly Worktree[], + repoMap: Map, + defaultHostId: ExecutionHostId +): Map | undefined { + if (worktrees.length === 0) { + return undefined + } + const counts = new Map() + const seenWorktreeIds = new Set() + for (const worktree of worktrees) { + if (seenWorktreeIds.has(worktree.id)) { + continue + } + seenWorktreeIds.add(worktree.id) + const hostId = getWorktreeExecutionHostId(worktree, repoMap.get(worktree.repoId), defaultHostId) + counts.set(hostId, (counts.get(hostId) ?? 0) + 1) + } + return counts +} + +function getHostWorktreeIds( + worktrees: readonly Worktree[], + repoMap: Map, + defaultHostId: ExecutionHostId +): Map | undefined { + if (worktrees.length === 0) { + return undefined + } + const idsByHost = new Map() + const seenWorktreeIds = new Set() + for (const worktree of worktrees) { + if (seenWorktreeIds.has(worktree.id)) { + continue + } + seenWorktreeIds.add(worktree.id) + const hostId = getWorktreeExecutionHostId(worktree, repoMap.get(worktree.repoId), defaultHostId) + const ids = idsByHost.get(hostId) ?? [] + ids.push(worktree.id) + idsByHost.set(hostId, ids) + } + return idsByHost +} + +function getRenderedNaturalAnchorRepoIds({ + groupBy, + worktrees, + repoMap, + prCache, + collapsedGroups, + workspaceStatuses, + settings, + projectGrouping +}: { + groupBy: WorktreeGroupBy + worktrees: readonly Worktree[] + repoMap: Map + prCache: Record | null + collapsedGroups: ReadonlySet + workspaceStatuses: readonly WorkspaceStatusDefinition[] + settings?: AppState['settings'] + projectGrouping?: ProjectGroupingModel +}): Set { + const renderedRepoIds = new Set() + if (groupBy === 'none') { + if (!collapsedGroups.has(ALL_GROUP_KEY)) { + for (const worktree of worktrees) { + renderedRepoIds.add(worktree.repoId) + } + } + return renderedRepoIds + } + if (groupBy === 'repo') { + for (const worktree of worktrees) { + renderedRepoIds.add(worktree.repoId) + } + return renderedRepoIds + } + for (const worktree of worktrees) { + const groupKey = getGroupKeyForWorktree( + groupBy, + worktree, + repoMap, + prCache, + workspaceStatuses, + settings, + projectGrouping + ) + if (groupKey && !collapsedGroups.has(groupKey)) { + renderedRepoIds.add(worktree.repoId) + } + } + return renderedRepoIds +} + function orderMainWorktreeFirst(worktrees: Worktree[]): Worktree[] { const mainWorktrees = worktrees.filter((worktree) => worktree.isMainWorktree) if (mainWorktrees.length === 0) { @@ -856,7 +993,9 @@ export function buildRows( pendingCreations: readonly PendingCreationRef[] = [], projectGrouping?: ProjectGroupingModel, folderWorkspaces: readonly FolderWorkspace[] = [], - hostLabelById?: ReadonlyMap + hostLabelById?: ReadonlyMap, + defaultHostId: ExecutionHostId = LOCAL_EXECUTION_HOST_ID, + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy = getPinnedWorktreeDisplayPolicy(settings) ): Row[] { const result: Row[] = [] const projectIndex = buildProjectGroupingIndex(projectGrouping) @@ -877,34 +1016,45 @@ export function buildRows( } } - const visibleUnpinnedRepoIds = new Set( - worktrees.filter((worktree) => !worktree.isPinned).map((worktree) => worktree.repoId) - ) - const visiblePinnedRepoIds = new Set( - worktrees.filter((worktree) => worktree.isPinned).map((worktree) => worktree.repoId) - ) + const naturalWorktrees = + pinnedDisplayPolicy === 'duplicate-in-groups' + ? worktrees + : worktrees.filter((worktree) => !worktree.isPinned) + const renderedNaturalAnchorRepoIds = getRenderedNaturalAnchorRepoIds({ + groupBy, + worktrees: naturalWorktrees, + repoMap, + prCache, + collapsedGroups, + workspaceStatuses, + settings, + projectGrouping + }) emitPinnedGroup( worktrees, repoMap, + defaultHostId, collapsedGroups, - visibleUnpinnedRepoIds, + renderedNaturalAnchorRepoIds, importedWorktreesByRepo, groupBy !== 'repo', result ) - if (groupBy === 'none') { - if (worktrees.length > 0) { + if (naturalWorktrees.length > 0) { result.push({ type: 'header', key: ALL_GROUP_KEY, label: ALL_GROUP_META.label, - count: worktrees.length, + count: naturalWorktrees.length, tone: ALL_GROUP_META.tone, - icon: ALL_GROUP_META.icon + icon: ALL_GROUP_META.icon, + hostWorktreeCounts: getHostWorktreeCounts(naturalWorktrees, repoMap, defaultHostId), + hostWorktreeIds: getHostWorktreeIds(naturalWorktrees, repoMap, defaultHostId), + worktreeIds: naturalWorktrees.map((worktree) => worktree.id) }) if (!collapsedGroups.has(ALL_GROUP_KEY)) { - appendWorktreeRows(result, worktrees, repoMap, lineageById, worktreeMap, { + appendWorktreeRows(result, naturalWorktrees, repoMap, lineageById, worktreeMap, { nestLineage, collapsedGroups, groupDepth: 0, @@ -916,7 +1066,7 @@ export function buildRows( } const grouped = new Map() - for (const w of worktrees) { + for (const w of naturalWorktrees) { let key: string let label: string let repo: Repo | undefined @@ -967,7 +1117,7 @@ export function buildRows( for (const [repoId, candidate] of importedWorktreesByRepo) { const grouping = getProjectGroupingForRepo(repoId, repoMap, projectIndex) const key = grouping.key - if (!grouped.has(key) && !visiblePinnedRepoIds.has(repoId)) { + if (!grouped.has(key)) { grouped.set(key, { label: grouping.label, items: [], @@ -983,7 +1133,9 @@ export function buildRows( for (const [repoId, candidate] of newExternalWorktreesInboxByRepo) { const grouping = getProjectGroupingForRepo(repoId, repoMap, projectIndex) const key = grouping.key - if (!grouped.has(key) && !visiblePinnedRepoIds.has(repoId)) { + if (!grouped.has(key)) { + // Why: the default policy removes pinned worktrees from natural groups, + // but actionable inbox rows still need a project section to render in. grouped.set(key, { label: grouping.label, items: [], @@ -1085,7 +1237,10 @@ export function buildRows( label: definition?.label ?? workspaceStatus, count: group.items.length, tone: meta.tone, - icon: meta.icon + icon: meta.icon, + hostWorktreeCounts: getHostWorktreeCounts(group.items, repoMap, defaultHostId), + hostWorktreeIds: getHostWorktreeIds(group.items, repoMap, defaultHostId), + worktreeIds: group.items.map((worktree) => worktree.id) } })() : (() => { @@ -1097,7 +1252,10 @@ export function buildRows( label: meta.label, count: group.items.length, tone: meta.tone, - icon: meta.icon + icon: meta.icon, + hostWorktreeCounts: getHostWorktreeCounts(group.items, repoMap, defaultHostId), + hostWorktreeIds: getHostWorktreeIds(group.items, repoMap, defaultHostId), + worktreeIds: group.items.map((worktree) => worktree.id) } })() diff --git a/src/renderer/src/components/sidebar/worktree-list-imported-rows.test.ts b/src/renderer/src/components/sidebar/worktree-list-imported-rows.test.ts index 2bb4e44a9dd..67f3760c91c 100644 --- a/src/renderer/src/components/sidebar/worktree-list-imported-rows.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-imported-rows.test.ts @@ -3,6 +3,7 @@ import { canKeepImportedWorktreesHidden, getRenderRowKey, getWorktreeDragGroups, + getWorktreeDragIndexes, renderRowContainsWorktree } from './WorktreeList' import type { Repo, Worktree } from '../../../../shared/types' @@ -59,6 +60,13 @@ const makeWorktreeRow = (id: string): Extract => ({ lineageChildCount: 0 }) +const makePinnedWorktreeRow = (id: string): Extract => ({ + ...makeWorktreeRow(id), + rowKey: `pinned:${id}`, + sectionKey: 'pinned', + worktree: { ...makeWorktree(id), isPinned: true } +}) + const makeImportedCardRow = (): Extract => ({ type: 'imported-worktrees-card', key: 'imported-worktrees-card:repo-group:repo-1', @@ -86,6 +94,29 @@ describe('imported worktree virtual rows', () => { ).toEqual([{ key: 'repo:repo-1', worktreeIds: ['main', 'feature'] }]) }) + it('indexes pinned rows for sidebar drag when they are the only rendered copy', () => { + const rows = [makeHeaderRow('pinned'), makePinnedWorktreeRow('main')] + const { groupIndexByRowKey, groupKeyByRowKey } = getWorktreeDragIndexes(rows) + + expect(getWorktreeDragGroups(rows)).toEqual([{ key: 'pinned', worktreeIds: ['main'] }]) + expect(groupKeyByRowKey.get('pinned:main')).toBe('pinned') + expect(groupIndexByRowKey.get('pinned:main')).toBe(0) + }) + + it('uses natural drag metadata when pinned rows have duplicate natural copies', () => { + const rows = [ + makeHeaderRow('pinned'), + makePinnedWorktreeRow('main'), + makeHeaderRow('all'), + makeWorktreeRow('main') + ] + const { groupKeyByRowKey } = getWorktreeDragIndexes(rows) + + expect(getWorktreeDragGroups(rows)).toEqual([{ key: 'all', worktreeIds: ['main'] }]) + expect(groupKeyByRowKey.has('pinned:main')).toBe(false) + expect(groupKeyByRowKey.get('all:main')).toBe('all') + }) + it('only allows keep-hidden actions for repo-group cards that are not forced visible', () => { expect(canKeepImportedWorktreesHidden(makeImportedCardRow(), undefined)).toBe(true) expect( diff --git a/src/renderer/src/components/sidebar/worktree-parent-candidates.ts b/src/renderer/src/components/sidebar/worktree-parent-candidates.ts index 4b3daa74238..6509743ba09 100644 --- a/src/renderer/src/components/sidebar/worktree-parent-candidates.ts +++ b/src/renderer/src/components/sidebar/worktree-parent-candidates.ts @@ -1,4 +1,4 @@ -import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import { getWorktreeExecutionHostId } from '../../../../shared/execution-host' import type { Repo, Worktree, WorktreeLineage } from '../../../../shared/types' import { canAssignWorktreeParent } from './worktree-parent-eligibility' @@ -15,7 +15,7 @@ function getWorktreeOwnerHostId( repoMap: Map> ): string | null { const repo = repoMap.get(worktree.repoId) - return worktree.hostId ?? (repo ? getRepoExecutionHostId(repo) : null) + return repo ? getWorktreeExecutionHostId(worktree, repo) : (worktree.hostId ?? null) } export function getEligibleWorktreeParents({ diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-row-preference.test.ts b/src/renderer/src/components/sidebar/worktree-sidebar-row-preference.test.ts new file mode 100644 index 00000000000..cd4672dc9a7 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-sidebar-row-preference.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import type { FolderWorkspace, ProjectGroup, Repo, Worktree } from '../../../../shared/types' +import type { HostSectionRow } from './host-section-rows' +import { getRenderedWorktreesInSidebarOrder } from './worktree-sidebar-row-preference' + +const repo: Repo = { + id: 'repo-1', + path: '/repo-1', + displayName: 'Repo 1', + badgeColor: '#737373', + addedAt: 1 +} + +function worktree(id: string, isPinned = false): Worktree { + return { + id, + repoId: repo.id, + path: `/repo-1/${id}`, + displayName: id, + branch: id, + head: 'abc123', + isBare: false, + isMainWorktree: false, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned, + sortOrder: 1, + lastActivityAt: 1 + } +} + +function item(workspace: Worktree, sectionKey: string): HostSectionRow { + return { + type: 'item', + rowKey: `${sectionKey}:${workspace.id}`, + sectionKey, + worktree: workspace, + repo, + depth: 0, + groupDepth: 0, + lineageTrail: [], + isLastLineageChild: false, + lineageChildCount: 0 + } +} + +const projectGroup: ProjectGroup = { + id: 'group-1', + name: 'Group 1', + parentPath: '/group-1', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 +} + +const folderWorkspace: FolderWorkspace = { + id: 'folder-1', + projectGroupId: projectGroup.id, + name: 'Folder 1', + folderPath: '/group-1/folder-1', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1 +} + +describe('getRenderedWorktreesInSidebarOrder', () => { + it('keeps folder workspaces in visual order while preferring natural pinned rows', () => { + const pinned = worktree('pinned', true) + const afterFolder = worktree('after-folder') + const rows: HostSectionRow[] = [ + item(pinned, 'pinned'), + { + type: 'folder-workspace', + key: 'folder-workspace:folder-1', + folderWorkspace, + projectGroup, + depth: 0, + groupDepth: 0 + }, + item(pinned, 'repo:repo-1'), + item(afterFolder, 'repo:repo-1') + ] + + expect( + getRenderedWorktreesInSidebarOrder(rows, 'duplicate-in-groups').map(({ id }) => id) + ).toEqual(['folder:folder-1', 'pinned', 'after-folder']) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-row-preference.ts b/src/renderer/src/components/sidebar/worktree-sidebar-row-preference.ts new file mode 100644 index 00000000000..c73107cbed8 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-sidebar-row-preference.ts @@ -0,0 +1,64 @@ +import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree' +import type { Worktree } from '../../../../shared/types' +import type { HostSectionRow } from './host-section-rows' +import { + PINNED_GROUP_KEY, + type PinnedWorktreeDisplayPolicy, + type WorktreeRow +} from './worktree-list-groups' + +export function getPreferredWorktreeRows( + rows: readonly WorktreeRow[], + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy +): WorktreeRow[] { + // Why: duplicated pinned rows represent one workspace to navigation and selection. + // Prefer its natural-group copy, falling back to Pinned when that copy is hidden. + if (pinnedDisplayPolicy === 'single-location') { + const seen = new Set() + return rows.filter((row) => { + if (seen.has(row.worktree.id)) { + return false + } + seen.add(row.worktree.id) + return true + }) + } + + const preferredRows: WorktreeRow[] = [] + const seen = new Set() + for (const row of rows) { + if (row.sectionKey === PINNED_GROUP_KEY || seen.has(row.worktree.id)) { + continue + } + preferredRows.push(row) + seen.add(row.worktree.id) + } + for (const row of rows) { + if (seen.has(row.worktree.id)) { + continue + } + preferredRows.push(row) + seen.add(row.worktree.id) + } + return preferredRows +} + +export function getRenderedWorktreesInSidebarOrder( + rows: readonly HostSectionRow[], + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy +): Worktree[] { + const itemRows = rows.filter((row): row is WorktreeRow => row.type === 'item') + const preferredRowKeys = new Set( + getPreferredWorktreeRows(itemRows, pinnedDisplayPolicy).map((row) => row.rowKey) + ) + const renderedWorktrees: Worktree[] = [] + + for (const row of rows) { + if (row.type === 'item' && preferredRowKeys.has(row.rowKey)) { + renderedWorktrees.push(row.worktree) + } else if (row.type === 'folder-workspace') { + renderedWorktrees.push(folderWorkspaceToWorktree(row.folderWorkspace)) + } + } + return renderedWorktrees +} diff --git a/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx b/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx index 3c858686591..d71bf0d8ba7 100644 --- a/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx +++ b/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx @@ -1,6 +1,7 @@ import { useSkillFreshness } from '@/hooks/useSkillFreshness' import { translate } from '@/i18n/i18n' import { IntegrationStatusPill } from '@/components/integration-status-pill' +import { getSkillFreshnessDisplayStatus } from '@/lib/skill-freshness-display-status' // Why: the setup rails' Installed pill is presence-only; when freshness knows a // safe update exists (or that every copy is current) the pill should say so. @@ -8,7 +9,8 @@ import { IntegrationStatusPill } from '@/components/integration-status-pill' // placement is never advertised as updatable here. export function SkillFreshnessStatusPill({ skillName }: { skillName: string }): React.JSX.Element { const { inventory } = useSkillFreshness() - if (inventory?.eligibleUpdateNames.includes(skillName)) { + const status = getSkillFreshnessDisplayStatus(inventory, skillName) + if (status === 'update-available') { return ( {translate( @@ -18,14 +20,7 @@ export function SkillFreshnessStatusPill({ skillName }: { skillName: string }): ) } - const placements = inventory?.installations.filter( - (installation) => installation.name === skillName - ) - if ( - placements && - placements.length > 0 && - placements.every((installation) => installation.status === 'current') - ) { + if (status === 'up-to-date') { return ( {translate('auto.components.skills.SkillFreshnessStatusPill.upToDate', 'Up to date')} diff --git a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx index a810ae06cc6..fc9926f6ed6 100644 --- a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx +++ b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import { act, useState, type ReactNode } from 'react' +import { act, useLayoutEffect, useRef, useState, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { @@ -24,6 +24,7 @@ const mocks = vi.hoisted(() => ({ onInteracted?: (method: 'keyboard' | 'pointer', event?: { key?: string }) => void onTerminalExit?: () => void }[], + terminalCommits: [] as [command: string, inert: boolean][], notifyChanged: vi.fn() })) @@ -42,8 +43,20 @@ vi.mock('@/hooks/useInstalledAgentSkills', () => ({ vi.mock('@/components/onboarding/OnboardingInlineCommandTerminal', () => ({ OnboardingInlineCommandTerminal: (props: (typeof mocks.terminalProps)[number]) => { + const terminalRef = useRef(null) mocks.terminalProps.push(props) - return
    {props.command}
    + useLayoutEffect(() => { + mocks.terminalCommits.push([ + props.command, + terminalRef.current?.parentElement?.inert ?? false + ]) + }) + return ( +
    + {props.command} +