Merge branch 'main' into nwparker/fix-8299-input-source

This commit is contained in:
Neil
2026-07-16 22:33:18 -07:00
committed by GitHub
224 changed files with 14863 additions and 1620 deletions
@@ -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,
@@ -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 () => {
@@ -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"')
})
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 5.8m">
<title>downloads: 5.8m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 5.9m">
<title>downloads: 5.9m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">5.8m</text>
<text x="93.5" y="14">5.8m</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">5.9m</text>
<text x="93.5" y="14">5.9m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 943 B

After

Width:  |  Height:  |  Size: 943 B

@@ -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 `<webview>` 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<string, BrowserCertificateFailure>
```
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:<port>` 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:<port>` 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.
+6 -88
View File
@@ -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 {
@@ -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 =
@@ -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()
})
})
@@ -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
}
@@ -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<string, RpcResponse>): {
sendRequest: (method: string) => Promise<RpcResponse>
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: '<h1>hi</h1>', truncated: false, byteLength: 11 })
})
const doc = await resolveMobileFileTabDoc(client, { ...WT, relativePath: 'page.html' })
expect(doc).toEqual({ status: 'ready', kind: 'html', content: '<h1>hi</h1>' })
})
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')
})
})
+94
View File
@@ -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<RpcClient, 'sendRequest'>
// 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<MobileFileTabDoc> {
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
}
}
@@ -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' },
@@ -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' ||
@@ -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}`,
@@ -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) {
+1 -1
View File
@@ -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",
+1 -10
View File
@@ -1,11 +1,9 @@
{
"schemaVersion": 1,
"appVersion": "1.4.144-rc.2",
"schemaVersion": 2,
"skills": [
{
"name": "computer-use",
"sourcePath": "skills/computer-use",
"appVersion": "1.4.144-rc.2",
"releaseRevision": 5,
"packageDigest": "cd2809474d57fd7277adb277448e6fa446810d3cbad71ac0b473b9e8ff1bad68",
"gitTreeSha": "306c0f8cb63bcac265a5b7975dc2f855be4f1344",
@@ -24,7 +22,6 @@
{
"name": "linear-tickets",
"sourcePath": "skills/linear-tickets",
"appVersion": "1.4.144-rc.2",
"releaseRevision": 4,
"packageDigest": "f198d7b22e5ee1673dac403f9cca0553b124e0a90e4fdd05d2c23b7344e32d2b",
"gitTreeSha": "de9fc106bbb4e313a90ff9a9513a720909bbd176",
@@ -43,7 +40,6 @@
{
"name": "orca-cli",
"sourcePath": "skills/orca-cli",
"appVersion": "1.4.144-rc.2",
"releaseRevision": 32,
"packageDigest": "51740ff13f379ac5743d3fd28a14b17168dcef40f7048c20182dce166098c45f",
"gitTreeSha": "ded93000a5f654e2b4f324501282459bd56afe19",
@@ -62,7 +58,6 @@
{
"name": "orca-emulator",
"sourcePath": "skills/orca-emulator",
"appVersion": "1.4.144-rc.2",
"releaseRevision": 4,
"packageDigest": "453b1d9aa20b51b8a4d32c7b6def6a93f7ef9c730de32abbcbc1788ad1b1820b",
"gitTreeSha": "66be6abe99f1807da85934aee0e22daefc8f7656",
@@ -81,7 +76,6 @@
{
"name": "orca-emulator-android",
"sourcePath": "skills/orca-emulator-android",
"appVersion": "1.4.144-rc.2",
"releaseRevision": 2,
"packageDigest": "12272cf82e0731f11e424822b961882457034e730358cc65ea28e4eb9c8ff7f5",
"gitTreeSha": "f7b0fc8cbf5cd78ca5156f6bbe3a20f1462d8f83",
@@ -100,7 +94,6 @@
{
"name": "orca-linear",
"sourcePath": "skills/orca-linear",
"appVersion": "1.4.144-rc.2",
"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.2",
"releaseRevision": 2,
"packageDigest": "fa3b65a1a107fca3f0375c696852477b62f58c154b9eb5c0663c41edc4bcd30d",
"gitTreeSha": "354e775b79ea6952ec63acac4d3ee8a9ae07a650",
@@ -138,7 +130,6 @@
{
"name": "orchestration",
"sourcePath": "skills/orchestration",
"appVersion": "1.4.144-rc.2",
"releaseRevision": 24,
"packageDigest": "9fbfa2ae3f3f99441563a4b8b1c6302107944480db8718ddb326a862a51f7ab9",
"gitTreeSha": "086c41e0b353b4908d2963694b4a7c791d4b3982",
@@ -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' })
@@ -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)
@@ -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],
+8 -2
View File
@@ -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
@@ -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
}
}
@@ -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
}
}
@@ -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<number, CertificateTrustGrant>()
private readonly grantSessionByGuestId = new Map<number, Session>()
private readonly guardedSessions = new Set<Session>()
private readonly acceptedIdentityBySession = new Map<Session, Map<string, CertificateIdentity>>()
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)
}
}
}
@@ -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<number, string | null>
let guestByPageId: Map<string, number>
let guestById: Map<number, Electron.WebContents>
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()
}
})
})
@@ -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<number, PendingCertificateChallenge>()
private readonly navigationSequenceByGuestId = new Map<number, number>()
private readonly expiryTimerByGuestId = new Map<number, ReturnType<typeof setTimeout>>()
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<Electron.Event, 'preventDefault'>
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()
}
}
@@ -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
+197
View File
@@ -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', () => {
+220 -15
View File
@@ -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<T>(
promise: Promise<T>,
timeoutMs: number,
@@ -238,23 +248,47 @@ export class BrowserManager {
private readonly annotationViewportBridgeOpsByTabId = new Map<string, Promise<unknown>>()
private readonly worktreeIdByTabId = new Map<string, string>()
private readonly policyAttachedGuestIds = new Set<number>()
private readonly offscreenGuestIds = new Set<number>()
private readonly policyCleanupByGuestId = new Map<number, () => void>()
private readonly clickedLinkFrameNameByGuestId = new Map<number, string>()
private readonly loadErrorsByGuestId = new Map<number, BrowserLoadError>()
// 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<number, BrowserLoadError>()
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<number, PendingPermissionEvent[]>()
private readonly pendingPopupEventsByGuestId = new Map<number, PendingPopupEvent[]>()
private readonly pendingDownloadIdsByGuestId = new Map<number, string[]>()
private readonly downloadsById = new Map<string, ActiveDownload>()
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)
@@ -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', () => ({
@@ -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()
}
}))
@@ -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)
+1 -1
View File
@@ -2210,7 +2210,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' })
+115
View File
@@ -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': {
+31
View File
@@ -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')
+32 -10
View File
@@ -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<GitBlobReadResult> {
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 }
}
}
+16 -1
View File
@@ -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 <sub>`), then --force.
await assertWorktreeCleanForRemoval(worktreePath, false, options)
await gitExecFileAsync(
['worktree', 'remove', '--force', worktreePath],
gitExecOptions(repoPath, options)
)
}
if (!branchName) {
return {}
+23 -12
View File
@@ -485,16 +485,32 @@ function mapIssueWorkItem(item: Record<string, unknown>): 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<string, unknown>
): Pick<MainWorkItem, 'author' | 'authorAvatarUrl'> {
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, unknown>): 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 ?? '')
+112
View File
@@ -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<string, string> = {
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<string, { login: string; name: null; avatarUrl: string }> = {}
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)
})
})
+124 -14
View File
@@ -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<GitHubWorkItem, 'repoId'>,
knownUsers: GitHubAssignableUser[]
): Omit<GitHubWorkItem, 'repoId'> {
const avatarByLogin = new Map<string, string>()
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<GitHubWorkItem, 'author' | 'number' | 'type'>,
item: Pick<
GitHubWorkItem,
'author' | 'number' | 'type' | 'reviewRequests' | 'latestReviews' | 'assignees'
>,
comments: PRComment[],
participants: GitHubAssignableUser[],
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<GitHubAssignableUser[]> {
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/<login>) 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,
+21 -1
View File
@@ -163,7 +163,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'
@@ -1710,6 +1710,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)
@@ -1919,6 +1935,9 @@ app.whenReady().then(async () => {
isAgentStatusHooksEnabled(store?.getSettings()) ? agentHookServer.buildPtyEnv() : {}
})
runtime = runtimeService
browserManager.setBrowserGuestStateChangedListener((worktreeId) => {
runtimeService.notifyMobileSessionTabsChanged(worktreeId)
})
automations = new AutomationService(store, {
claudeUsage,
codexUsage,
@@ -2401,6 +2420,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()
+97
View File
@@ -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)
+24 -2
View File
@@ -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.
+35
View File
@@ -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',
+26 -18
View File
@@ -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 extends PetManifestLike = PetManifestLike> = 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<string, SpriteAnimation> = {
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<T extends PetManifestLike>(
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))
}
}
+63
View File
@@ -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<string, { row: number; frames: number; frameDurationsMs?: number[] }>
): Promise<string> {
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'
)
})
})
+8 -1
View File
@@ -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.`)
+1 -1
View File
@@ -6497,7 +6497,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' })
})
+7 -1
View File
@@ -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()
})
})
+5 -2
View File
@@ -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<SkillFreshnessInventory> => {
// 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()
})
})
}
@@ -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<typeof vi.fn>
}
describe('worktree git-common narrow watch (darwin)', () => {
const cleanups: (() => Promise<void>)[] = []
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<string> {
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<void> {
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)
})
})
+36 -17
View File
@@ -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
+1 -1
View File
@@ -2508,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}'
})
})
@@ -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({
+15 -2
View File
@@ -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')
})
+8 -1
View File
@@ -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
}
+12 -1
View File
@@ -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<BrowserCertificateProceedResult> {
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<BrowserTabShowResult> {
const target = await this.resolveBrowserCommandTarget(params)
return { tab: this.describeBrowserTab(params.page, target.worktreeId) }
+255
View File
@@ -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)', () => {
@@ -19722,6 +19849,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()
+133 -19
View File
@@ -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,
@@ -3061,6 +3062,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,
@@ -3430,13 +3437,16 @@ export class OrcaRuntimeService {
allowAttachedWindow?: boolean
onlyServeOwnedTerminals?: boolean
} = {}
): void {
): Set<string> {
// Why: report which worktrees were reconciled in place so callers don't
// reconcile them a second time (see notifyMobileSessionTabsChanged).
const reconciledWorktreeIds = new Set<string>()
if (this.getAvailableAuthoritativeWindow() && options.allowAttachedWindow !== true) {
return
return reconciledWorktreeIds
}
const session = this.store?.getWorkspaceSession?.()
if (!session) {
return
return reconciledWorktreeIds
}
const entries =
worktreeId !== undefined
@@ -3455,6 +3465,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(
@@ -3554,6 +3565,7 @@ export class OrcaRuntimeService {
tabs: mergedTabs
})
}
return reconciledWorktreeIds
}
// Why: keep an existing snapshot's browser tabs in sync with the live bridge
@@ -3568,13 +3580,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')
@@ -4051,6 +4061,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
@@ -4058,6 +4070,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
@@ -4685,26 +4766,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 }
}
@@ -4732,6 +4820,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,
@@ -21545,6 +21646,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.
@@ -25458,6 +25570,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)
+10 -1
View File
@@ -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',
@@ -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,
+21
View File
@@ -1983,6 +1983,20 @@ describe('OrcaRuntimeRpcServer', () => {
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
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<string, unknown>),
() => {}
)
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 })
)
@@ -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)
+3 -8
View File
@@ -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<SkillBund
)
for (const current of manifest.skills) {
if (
current.appVersion !== manifest.appVersion ||
!registry.skills[current.name]?.some(
(snapshot) =>
snapshot.releaseRevision === current.releaseRevision &&
@@ -137,6 +134,8 @@ async function readSkillBundleArtifacts(resourceRoot: string): Promise<SkillBund
}
}
// Why: historical provenance only — the current revision's label is the
// running build's version, supplied at the inventory boundary, not stored here.
const releasedAppVersions: Record<string, Record<number, string>> = {}
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<SkillBund
releasedAppVersions[name][revision] ??= release.appVersion
}
}
for (const current of manifest.skills) {
releasedAppVersions[current.name] ??= {}
releasedAppVersions[current.name][current.releaseRevision] = current.appVersion
}
return {
manifest,
@@ -55,13 +55,12 @@ async function fixture() {
const current: SkillCurrentBundleEntry = {
name: 'orca-cli',
sourcePath: 'skills/orca-cli',
appVersion: '2.0.0',
...snapshots[1]
}
await Promise.all([
writeFile(
join(skillResourceRoot, 'current-manifest.json'),
`${JSON.stringify({ schemaVersion: 1, appVersion: '2.0.0', skills: [current] }, null, 2)}\n`
`${JSON.stringify({ schemaVersion: 2, skills: [current] }, null, 2)}\n`
),
writeFile(
join(skillResourceRoot, 'snapshot-registry.json'),
@@ -111,6 +110,7 @@ describe('read-only skill freshness inventory', () => {
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
+15 -9
View File
@@ -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<SkillFreshnessInventory> {
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<SkillFreshnessInventory> {
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,
@@ -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,
+137 -14
View File
@@ -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' }),
+173 -82
View File
@@ -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<SFTPWrapper> {
async sftp(signal?: AbortSignal): Promise<SFTPWrapper> {
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<T> {
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<NodeJS.EventEmitter> & {
resume?: () => void
stderr?: Partial<NodeJS.EventEmitter> & { 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<NodeJS.EventEmitter> & {
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<void> {
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<void> {
if (!this.useSystemSshTransport) {
const sftp = await this.sftp()
const swallowLateSftpError = (): void => {}
sftp.on('error', swallowLateSftpError)
try {
await new Promise<void>((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<void>((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(
@@ -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<void>(() => {}),
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<void>(() => {}),
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()
}
})
})
+76
View File
@@ -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<T>(
operation: Promise<T>,
signal: AbortSignal,
closeSftp: (onClose: () => void) => void
): Promise<T> {
return new Promise((resolve, reject) => {
let settled = false
let abortError: (Error & { sshChannelCloseConfirmed: boolean }) | null = null
let closeGraceTimer: ReturnType<typeof setTimeout> | 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()
}
})
}
+7 -2
View File
@@ -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<BuildToolchainStatus | null> {
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
}
}
@@ -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)
+143 -4
View File
@@ -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<string>): Promise<Error> {
}
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()
+41 -117
View File
@@ -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<MultiplexerTransport> {
export function waitForSentinel(
channel: ClientChannel,
signal?: AbortSignal
): Promise<MultiplexerTransport> {
return new Promise<MultiplexerTransport>((resolve, reject) => {
let sentinelReceived = false
let settled = false
@@ -39,17 +42,14 @@ export function waitForSentinel(channel: ClientChannel): Promise<MultiplexerTran
const timeout = setTimeout(() => {
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<MultiplexerTran
timeoutGraceTimer = null
}
}
const cleanupStartup = (): void => {
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<MultiplexerTran
}
const failOrClose = (err: Error): void => {
cancelTimers()
if (!sentinelReceived) {
if (!settled) {
settled = true
reject(err)
}
rejectStartup(err)
return
}
notifyClosed()
@@ -113,9 +134,7 @@ export function waitForSentinel(channel: ClientChannel): Promise<MultiplexerTran
channel.on('close', () => {
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<MultiplexerTran
// grace window so the close handler can deliver the exit code.
const versionMismatchError = buildRelayVersionMismatchError(lastExitCode, stderrOutput)
if (versionMismatchError) {
reject(versionMismatchError)
rejectStartup(versionMismatchError)
return
}
const timeoutSuffix = timeoutFired
? ` (after ${RELAY_SENTINEL_TIMEOUT_MS / 1000}s sentinel timeout)`
: ''
reject(
rejectStartup(
new Error(
`Relay process exited before ready${timeoutSuffix}.${stderrOutput ? ` stderr: ${stderrOutput.trim()}` : ''}`
)
@@ -184,7 +203,8 @@ export function waitForSentinel(channel: ClientChannel): Promise<MultiplexerTran
if (sentinelIdx !== -1) {
sentinelReceived = true
cancelTimers()
settled = true
cleanupStartup()
const afterSentinelOffset =
sentinelIdx + RELAY_SENTINEL_BUFFER.length - bufferedStdout.length
@@ -193,8 +213,6 @@ export function waitForSentinel(channel: ClientChannel): Promise<MultiplexerTran
if (afterSentinel.length > 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<MultiplexerTran
})
})
}
// ── Remote command execution ──────────────────────────────────────────
const EXEC_TIMEOUT_MS = 30_000
type ExecCommandOptions = SshExecOptions & {
timeoutMs?: number
}
export async function execCommand(
conn: SshConnection,
command: string,
options?: ExecCommandOptions
): Promise<string> {
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()
}
})
}
+13
View File
@@ -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
+198 -12
View File
@@ -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<void>((_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<void>((_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<string>((resolve) =>
setTimeout(() => resolve('ORCA-NATIVE-DEPS-OK'), 899_900)
)
)
.mockResolvedValueOnce('DEAD')
.mockImplementationOnce((_conn, _command, options) => {
return new Promise<string>((_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 () => {
File diff suppressed because it is too large Load Diff
+156
View File
@@ -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<SshCommandTerminationError>).sshChannelCloseConfirmed === false
)
}
export async function execCommand(
conn: SshConnection,
command: string,
options?: ExecCommandOptions
): Promise<string> {
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<typeof setTimeout> | 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
}
+236
View File
@@ -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<string> {
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<boolean> {
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<string | null> {
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<string | null> {
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<boolean> {
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<RelayGcClaimReleaseResult> {
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<RelayGcClaimReleaseResult> {
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<void> {
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<void>((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()
}
})
}
}
+275
View File
@@ -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<ReturnType<typeof waitForSentinel>>) => void) | undefined
vi.mocked(waitForSentinel).mockImplementationOnce(
async () =>
new Promise<Awaited<ReturnType<typeof waitForSentinel>>>((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)
})
})
+33
View File
@@ -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<void> {
// 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)}`
)
})
}
}
@@ -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)`
}
+162
View File
@@ -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<string> {
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<boolean> {
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<void> {
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<void> {
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()
}
})
}
@@ -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<string>((_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<string>((_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<string>((_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)
})
+153
View File
@@ -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<string> {
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<RelayRepairLockResult> {
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<RelayRepairLockResult> {
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<RelayRepairLockResult> {
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'
}
+585 -47
View File
@@ -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)
})
})
+91 -118
View File
@@ -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<void> {
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<boolean> {
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<void> {
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<void> {
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<boolean> {
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
}
}
+329 -6
View File
@@ -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<string> {
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<string> {
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'")
+21 -33
View File
@@ -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) {
@@ -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
}
}
+23 -11
View File
@@ -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<RemoteHostPlatform | null> {
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<RelayPlatform | null> {
async function detectUnamePlatform(
conn: SshConnection,
signal?: AbortSignal
): Promise<RelayPlatform | null> {
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<RelayPlatform | null> {
async function detectWindowsPlatform(
conn: SshConnection,
signal?: AbortSignal
): Promise<RelayPlatform | null> {
try {
const script = [
'$arch = $env:PROCESSOR_ARCHITECTURE',
@@ -43,9 +51,13 @@ async function detectWindowsPlatform(conn: SshConnection): Promise<RelayPlatform
// Why: Remote startup output may omit its trailing newline and must not absorb the marker.
`Write-Output ("\`n${PLATFORM_PROBE_MARKER} Windows " + $arch)`
].join('; ')
const output = await execCommand(conn, powerShellCommand(script), { wrapCommand: false })
const output = await execCommand(conn, powerShellCommand(script), {
wrapCommand: false,
...(signal ? { signal } : {})
})
return parseRemotePlatformOutput(output)
} catch {
signal?.throwIfAborted()
return null
}
}
+28 -16
View File
@@ -83,6 +83,16 @@ function expectNoOrcaControlMasterArgs(args: string[]): void {
expect(args).not.toContain('ControlPersist=300')
}
function expectOrcaControlMasterArgs(args: string[]): void {
if (process.platform === 'win32') {
expectNoOrcaControlMasterArgs(args)
return
}
expect(args).toContain('ControlMaster=auto')
expect(args.some((arg) => arg.startsWith('ControlPath='))).toBe(true)
expect(args).toContain('ControlPersist=300')
}
type EventedProcess = EventEmitter & {
stdin: EventEmitter & {
write: ReturnType<typeof vi.fn>
@@ -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)
+3
View File
@@ -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 {
@@ -100,7 +100,13 @@ export async function awaitWithSystemSshAbort<T>(
// 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) {
@@ -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')
})
})
+10 -1
View File
@@ -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<void>
}) => Promise<boolean>
unregisterGuest: (args: { browserPageId: string }) => Promise<void>
openDevTools: (args: { browserPageId: string }) => Promise<boolean>
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<BrowserCertificateProceedResult>
onPermissionDenied: (callback: (event: BrowserPermissionDeniedEvent) => void) => () => void
onPopup: (callback: (event: BrowserPopupEvent) => void) => () => void
onDownloadRequested: (callback: (event: BrowserDownloadRequestedEvent) => void) => () => void
+12 -1
View File
@@ -2238,7 +2238,7 @@ const api = {
worktreeId: string
sessionProfileId?: string | null
webContentsId: number
}): Promise<void> => ipcRenderer.invoke('browser:registerGuest', args),
}): Promise<boolean> => ipcRenderer.invoke('browser:registerGuest', args),
unregisterGuest: (args: { browserPageId: string }): Promise<void> =>
ipcRenderer.invoke('browser:unregisterGuest', args),
@@ -2271,6 +2271,17 @@ const api = {
return () => ipcRenderer.removeListener('browser:guest-load-failed', listener)
},
onCertificateFailureChanged: (callback): (() => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
data: Parameters<typeof callback>[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) => {
+2 -1
View File
@@ -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 })
})
})
+16 -5
View File
@@ -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 `:<path>` 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 ──────────────────────────────────────────────────
+111
View File
@@ -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<GitExec>(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<GitExec>(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<GitExec>(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<GitExec>(async (args) => {

Some files were not shown because too many files have changed in this diff Show More