mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 08:03:22 +00:00
Merge branch 'main' into nwparker/fix-8299-input-source
This commit is contained in:
@@ -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"')
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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,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],
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 ?? '')
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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.`)
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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 () => {
|
||||
|
||||
+634
-184
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)`
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user