Merge remote-tracking branch 'origin/main' into orchestration-v3

# Conflicts:
#	src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap
#	src/main/runtime/orchestration/preamble.test.ts
#	src/main/runtime/orchestration/preamble.ts
This commit is contained in:
Jinwoo-H
2026-09-05 00:04:20 -04:00
183 changed files with 6390 additions and 1746 deletions
@@ -91,7 +91,11 @@ jobs:
test -n "${CAPACITY_SERVICE_ACCOUNT}"
test -n "${DIRECTOR_RUNTIME_SERVICE_ACCOUNT}"
# Full history: the monitor evidence this job verifies is sealed at an ancestor commit,
# and the provenance check fails closed on a commit a shallow clone left out.
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pnpm/action-setup@v4
with: { package_json_file: cloud/package.json }
@@ -177,11 +181,12 @@ jobs:
env:
ORCA_RELAY_ADMIN_ID_TOKEN: ${{ steps.deploy-auth.outputs.id_token }}
run: |
RETRY_ARGS=()
if test "${WAVE_INDEX}" != 0; then RETRY_ARGS=(--retry-freshness); fi
# Freshness-only failures are publish lag, not health, on every wave
# including the first; the CLI still caps the retry at the wave's
# evidence-age budget, so this cannot mutate on aged evidence.
pnpm incident:relay-preflight -- \
--state-file "${OUTPUT_DIRECTORY}/relay-${MONITOR_RUN_ID}-dry-run.state.json" \
--wave-index "${WAVE_INDEX}" "${RETRY_ARGS[@]}"
--wave-index "${WAVE_INDEX}" --retry-freshness
- name: Require durable rehome disabled and exact selector
env:
@@ -271,10 +276,22 @@ jobs:
env:
ORCA_RELAY_ADMIN_ID_TOKEN: ${{ steps.deploy-auth.outputs.id_token }}
run: |
CURRENT_RUNTIME="$(curl --fail-with-body --max-time 30 \
--request POST "${CELL_ORIGIN}/v1/admin/runtime-status" \
--header "Authorization: Bearer ${ORCA_RELAY_ADMIN_ID_TOKEN}" \
--header 'Content-Type: application/json' --data '{"v":1}')"
# A single transient 5xx (LB warm-up behind a fresh instance) must not
# fail a canary; 4xx (auth, generation mismatch) still fails fast.
admin_post() {
local out="${RUNNER_TEMP}/$1.json"
if ! curl --fail-with-body --max-time 30 \
--retry 3 --retry-delay 2 --retry-connrefused --output "${out}" \
--request POST "$2" \
--header "Authorization: Bearer ${ORCA_RELAY_ADMIN_ID_TOKEN}" \
--header 'Content-Type: application/json' --data "$3"; then
cat "${out}" >&2
return 1
fi
cat "${out}"
}
CURRENT_RUNTIME="$(admin_post current-runtime \
"${CELL_ORIGIN}/v1/admin/runtime-status" '{"v":1}')"
# A rollback that failed between template apply and admission restore
# leaves the cell already on the rollback image; resume from that
# state instead of demanding the pre-rollback predecessor.
@@ -370,11 +387,9 @@ jobs:
if .regionalRehomeProtocol == null then "regionalRehomeProtocol" else empty end
] | if length > 0 then "runtime predecessor normalized legacy fields=" + join(",") else empty end' \
<<< "${CURRENT_RUNTIME}"
CURRENT_DIRECTOR_STATUS="$(curl --fail-with-body --max-time 30 \
--request POST "${DIRECTOR_ORIGIN}/v1/admin/cell-status" \
--header "Authorization: Bearer ${ORCA_RELAY_ADMIN_ID_TOKEN}" \
--header 'Content-Type: application/json' \
--data "$(jq -cn --arg cell "${TARGET_CELL_ID}" '{v:1,cellId:$cell}')")"
CURRENT_DIRECTOR_STATUS="$(admin_post current-cell-status \
"${DIRECTOR_ORIGIN}/v1/admin/cell-status" \
"$(jq -cn --arg cell "${TARGET_CELL_ID}" '{v:1,cellId:$cell}')")"
SOURCE_INCARNATION="$(jq -er '.status.runtime.cellIncarnation' \
<<< "${CURRENT_DIRECTOR_STATUS}")"
if test "${ROLLBACK_RESUME}" = true && ! jq -e \
@@ -532,6 +547,20 @@ jobs:
env:
ORCA_RELAY_ADMIN_ID_TOKEN: ${{ steps.post-auth.outputs.id_token }}
run: |
# A single transient 5xx (LB warm-up behind a fresh instance) must not
# fail a canary; 4xx (auth, generation mismatch) still fails fast.
admin_post() {
local out="${RUNNER_TEMP}/$1.json"
if ! curl --fail-with-body --max-time 30 \
--retry 3 --retry-delay 2 --retry-connrefused --output "${out}" \
--request POST "$2" \
--header "Authorization: Bearer ${ORCA_RELAY_ADMIN_ID_TOKEN}" \
--header 'Content-Type: application/json' --data "$3"; then
cat "${out}" >&2
return 1
fi
cat "${out}"
}
node dev/scripts/verify-relay-capacity-transition.mjs \
--director-origin "${DIRECTOR_ORIGIN}" --cell-origin "${CELL_ORIGIN}" \
--cell-id "${TARGET_CELL_ID}" --hard-cap "${EXPECTED_HARD_CAP}" \
@@ -539,19 +568,15 @@ jobs:
--heartbeat fresh --admission migration-only --draining forbidden \
--activity allowed --expected-image-digests "${DESIRED_IMAGE_DIGEST}" \
--regional-rehome-protocol "${DESIRED_REHOME_PROTOCOL}" --timeout-ms 900000
TARGET_RUNTIME="$(curl --fail-with-body --max-time 30 \
--request POST "${CELL_ORIGIN}/v1/admin/runtime-status" \
--header "Authorization: Bearer ${ORCA_RELAY_ADMIN_ID_TOKEN}" \
--header 'Content-Type: application/json' --data '{"v":1}')"
TARGET_RUNTIME="$(admin_post target-runtime \
"${CELL_ORIGIN}/v1/admin/runtime-status" '{"v":1}')"
jq -e --arg digest "${DESIRED_IMAGE_DIGEST}" \
--argjson protocol "${DESIRED_REHOME_PROTOCOL}" \
'.imageDigest == $digest and (.regionalRehomeProtocol // 0) == $protocol' \
<<< "${TARGET_RUNTIME}" >/dev/null
TARGET_DIRECTOR_STATUS="$(curl --fail-with-body --max-time 30 \
--request POST "${DIRECTOR_ORIGIN}/v1/admin/cell-status" \
--header "Authorization: Bearer ${ORCA_RELAY_ADMIN_ID_TOKEN}" \
--header 'Content-Type: application/json' \
--data "$(jq -cn --arg cell "${TARGET_CELL_ID}" '{v:1,cellId:$cell}')")"
TARGET_DIRECTOR_STATUS="$(admin_post target-cell-status \
"${DIRECTOR_ORIGIN}/v1/admin/cell-status" \
"$(jq -cn --arg cell "${TARGET_CELL_ID}" '{v:1,cellId:$cell}')")"
TARGET_INCARNATION="$(jq -er '.status.runtime.cellIncarnation' \
<<< "${TARGET_DIRECTOR_STATUS}")"
if test "${ROLLBACK_RESUME}" = true; then
@@ -87,13 +87,18 @@ jobs:
gate:
if: ${{ vars.ORCA_CLOUD_OPERATIONS_ENABLED == 'true' && (github.ref == 'refs/heads/main') }}
runs-on: blacksmith-2vcpu-ubuntu-2204
timeout-minutes: 10
# Headroom for the full-history checkout the canary provenance check needs.
timeout-minutes: 15
environment: production
outputs:
cells: ${{ steps.wave.outputs.cells }}
job-mode: ${{ steps.wave.outputs.job-mode }}
steps:
# Full history: the canary authority a batch verifies is sealed at an ancestor commit, and
# the provenance check fails closed on a commit a shallow clone left out.
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with: { node-version: 24 }
@@ -95,7 +95,11 @@ jobs:
;;
esac
# Full history: the monitor evidence this job verifies is sealed at an ancestor commit,
# and the provenance check fails closed on a commit a shallow clone left out.
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
+1
View File
@@ -807,6 +807,7 @@ jobs:
src/main/agent-hooks/windows-hook-payload-delivery.test.ts
src/main/windows/windows-pty-job.win32.test.ts
src/main/windows/windows-host-job.win32.test.ts
src/main/windows-live-tree-kill.win32.test.ts
src/main/wsl/wsl-runner.test.ts
src/main/wsl/wsl-guest-environment.test.ts
src/main/wsl/wsl-invocation-boundary.test.ts
@@ -331,6 +331,39 @@ describe('relay incident live preflight', () => {
expect(wait).toHaveBeenNthCalledWith(2, 15_000)
})
it('retries a first-wave stale sample and passes on the fresh one', async () => {
const stale = sample()
stale.sources['cloud-monitoring']!.signals['cloud_sql.cpu']!.observedAt =
new Date(now - 180_001).toISOString()
const collect = vi.fn().mockResolvedValueOnce(stale).mockResolvedValueOnce(sample())
const wait = vi.fn(async () => undefined)
await expect(runIncidentLivePreflight(
['--state-file', stateFile(), '--wave-index', '0', '--retry-freshness'],
{ now: () => now, collect, wait }
)).resolves.toBeUndefined()
expect(collect).toHaveBeenCalledTimes(2)
expect(wait).toHaveBeenCalledOnce()
})
it('stops retrying when the next wait would exceed the evidence-age bound', async () => {
const completedAt = now - 290_000
const stale = sample()
stale.sources['cloud-monitoring']!.observedAt = new Date(now - 180_001).toISOString()
const collect = vi.fn(async () => stale)
const wait = vi.fn(async () => undefined)
await expect(runIncidentLivePreflight(
['--state-file', stateFile('strict', {
startedAt: new Date(completedAt - 17 * 60_000).toISOString(),
windowStartedAt: new Date(completedAt - 16 * 60_000).toISOString(),
lastSampleAt: new Date(completedAt - 30_000).toISOString(),
completedAt: new Date(completedAt).toISOString()
}), '--retry-freshness'],
{ now: () => now, collect, wait }
)).rejects.toThrow('cloud-monitoring/source_stale')
expect(collect).toHaveBeenCalledOnce()
expect(wait).not.toHaveBeenCalled()
})
it('does not retry a threshold failure', async () => {
const unhealthy = sample()
unhealthy.sources['cloud-monitoring']!.signals['cloud_sql.cpu']!.value = 0.9
@@ -173,7 +173,11 @@ export async function runIncidentLivePreflight(
const freshnessOnly = evaluation.failures.every((failure) =>
FRESHNESS_FAILURE_CODES.has(failure.code)
)
if (!freshnessOnly || attempt === attempts) {
// Waiting must never carry the mutation past the same evidence-age bound
// the entry check enforces, so the wave budget also caps the retry window.
const budgetExhausted =
now() + FRESHNESS_RETRY_INTERVAL_MS - completedAt > maxEvidenceAgeMs
if (!freshnessOnly || attempt === attempts || budgetExhausted) {
throw new Error(
`relay live preflight failed: ${evaluation.failures
.map((failure) => `${failure.source}/${failure.code}`)
@@ -13,6 +13,41 @@ const runService = {
latestReadyRevision: 'projects/project/revisions/revision-one'
}
const sleepingStagingGcloud: GcloudClient = { accessToken: async () => 'a'.repeat(40) }
// Staging's Cloud SQL is stopped, so this inventory reads REST only and probes no endpoint.
type MigOutcome = 'ok' | 'throw' | 'missing'
const sleepingStagingFetch = (migOutcome: (migName: string) => MigOutcome): typeof fetch =>
async (input) => {
const url = new URL(String(input))
if (url.hostname === 'run.googleapis.com') return Response.json(runService)
if (url.hostname === 'sqladmin.googleapis.com') return Response.json({
state: 'STOPPED',
databaseVersion: 'POSTGRES_17',
settings: { activationPolicy: 'NEVER', availabilityType: 'ZONAL', tier: 'db-custom-1-3840' }
})
if (url.hostname === 'certificatemanager.googleapis.com') return Response.json({
managed: { domains: ['*.relay-staging.onorca.dev'], state: 'ACTIVE' }
})
if (url.pathname.includes('/instanceGroupManagers/')) {
const name = url.pathname.split('/').at(-1)!
const outcome = migOutcome(name)
if (outcome === 'throw') throw new TypeError('fetch failed')
if (outcome === 'missing') return new Response(null, { status: 404 })
return Response.json({
name,
targetSize: 0,
size: '0',
instanceGroup: `projects/project/zones/zone/instanceGroups/${name}`,
instanceTemplate: `projects/project/global/instanceTemplates/template-${name}`,
status: { isStable: true }
})
}
if (url.pathname.includes('/instanceTemplates/')) return Response.json({ properties: {} })
if (url.pathname.endsWith('/getHealth')) return Response.json([])
throw new Error(`Unexpected request to ${url.hostname}${url.pathname}`)
}
describe('readResourceInventory', () => {
it('does not delay a healthy endpoint sample', async () => {
let calls = 0
@@ -249,6 +284,74 @@ describe('readResourceInventory', () => {
expect(JSON.stringify(result)).not.toContain('SECRET_TEXT')
})
it('re-asks a MIG read that failed once before calling a cell powered-unknown', async () => {
const parkedCell = RELAY_OPS_ENVIRONMENTS.staging.cells[0]!
const waits: number[] = []
let parkedMigCalls = 0
const result = await readResourceInventory(
RELAY_OPS_ENVIRONMENTS.staging,
sleepingStagingGcloud,
sleepingStagingFetch((migName) => {
if (!migName.endsWith(parkedCell.hostname)) return 'ok'
parkedMigCalls += 1
return parkedMigCalls === 1 ? 'throw' : 'ok'
}),
{ wait: async (ms) => { waits.push(ms) } }
)
const parked = result.cells.find((cell) => cell.cellId === parkedCell.cellId)!
// The MIG was fine and parked at zero; one transient read must not erase that reading.
expect(parked.targetSize).toBe(0)
expect(parkedMigCalls).toBe(2)
expect(waits).toEqual([1_000])
expect(result.warnings).toEqual([])
})
it('reports a MIG unavailable only when the retry fails too', async () => {
const parkedCell = RELAY_OPS_ENVIRONMENTS.staging.cells[0]!
const waits: number[] = []
let parkedMigCalls = 0
const result = await readResourceInventory(
RELAY_OPS_ENVIRONMENTS.staging,
sleepingStagingGcloud,
sleepingStagingFetch((migName) => {
if (!migName.endsWith(parkedCell.hostname)) return 'ok'
parkedMigCalls += 1
return 'throw'
}),
{ wait: async (ms) => { waits.push(ms) } }
)
const parked = result.cells.find((cell) => cell.cellId === parkedCell.cellId)!
expect(parked.targetSize).toBeNull()
expect(parked.backendHealth).toBe('unknown')
expect(parkedMigCalls).toBe(2)
expect(waits).toEqual([1_000])
expect(result.warnings).toEqual([
`${parkedCell.hostname.toUpperCase()} MIG inventory is unavailable.`
])
})
it('does not re-ask a MIG read the API answered with 404', async () => {
const missingCell = RELAY_OPS_ENVIRONMENTS.staging.cells[0]!
const waits: number[] = []
let missingMigCalls = 0
const result = await readResourceInventory(
RELAY_OPS_ENVIRONMENTS.staging,
sleepingStagingGcloud,
sleepingStagingFetch((migName) => {
if (!migName.endsWith(missingCell.hostname)) return 'ok'
missingMigCalls += 1
return 'missing'
}),
{ wait: async (ms) => { waits.push(ms) } }
)
expect(result.cells.find((cell) => cell.cellId === missingCell.cellId)!.targetSize).toBeNull()
expect(missingMigCalls).toBe(1)
expect(waits).toEqual([])
})
it('represents missing credentials as unknown inventory, never sleeping', async () => {
const gcloud: GcloudClient = {
accessToken: async () => { throw new Error('sensitive context') }
+35 -5
View File
@@ -103,6 +103,8 @@ export type ResourceInventory = {
const unavailableEndpoint = (): EndpointHealth => ({ health: null, ready: null, latencyMs: null })
const independentEndpointRetryDelayMs = 11_000
const transientProbeRetryDelayMs = 1_000
const sleep = async (ms: number): Promise<void> =>
await new Promise((resolvePromise) => setTimeout(resolvePromise, ms))
function finalSegment(value: string): string {
return value.split('/').at(-1) ?? value
@@ -121,6 +123,12 @@ function parseService(value: unknown): ServiceInventory {
}
}
class GoogleApiError extends Error {
constructor(readonly status: number) {
super(`Google API returned ${status}`)
}
}
async function googleRequest(
fetchImpl: typeof fetch,
token: string,
@@ -135,10 +143,24 @@ async function googleRequest(
},
signal: AbortSignal.timeout(30_000)
})
if (!response.ok) throw new Error(`Google API returned ${response.status}`)
if (!response.ok) throw new GoogleApiError(response.status)
return await response.json()
}
// A 404 is the API's answer about the resource; anything else is the absence of a reading, so re-ask.
async function readOnceMore(
read: () => Promise<unknown>,
wait: (ms: number) => Promise<void>
): Promise<unknown> {
try {
return await read()
} catch (error) {
if (error instanceof GoogleApiError && error.status === 404) throw error
await wait(transientProbeRetryDelayMs)
return await read()
}
}
// A reading the endpoint actually produced: ok is its answer, latencyMs is that answer's round trip.
type PathReading = { ok: boolean; latencyMs: number | null }
@@ -201,8 +223,7 @@ export async function probeEndpointHealth(
options: EndpointProbeOptions = {}
): Promise<EndpointHealth> {
const requiresReady = options.requiresReady ?? true
const wait = options.wait ??
(async (ms: number) => await new Promise((resolvePromise) => setTimeout(resolvePromise, ms)))
const wait = options.wait ?? sleep
const accepted = (probe: EndpointHealth): boolean =>
probe.health === true &&
(!requiresReady || probe.ready === true) &&
@@ -325,11 +346,17 @@ function unavailableInventory(environment: RelayOpsEnvironment, warning: string)
}
}
export type ResourceInventoryOptions = {
wait?: (ms: number) => Promise<void>
}
export async function readResourceInventory(
environment: RelayOpsEnvironment,
gcloud: GcloudClient,
fetchImpl: typeof fetch = fetch
fetchImpl: typeof fetch = fetch,
options: ResourceInventoryOptions = {}
): Promise<ResourceInventory> {
const wait = options.wait ?? sleep
let token: string
try {
token = await gcloud.accessToken()
@@ -356,7 +383,10 @@ export async function readResourceInventory(
token,
`https://certificatemanager.googleapis.com/v1/projects/${environment.project}/locations/global/certificates/${environment.certificateName}`
),
...environment.cells.map((cell) => googleRequest(fetchImpl, token, migUrl(cell)))
// One transient Compute read must never become a verdict on a cell's power state.
...environment.cells.map((cell) =>
readOnceMore(async () => await googleRequest(fetchImpl, token, migUrl(cell)), wait)
)
])
const warnings: string[] = []
const directorValue = parsed(settled[0]!, RunServiceSchema, 'Director service inventory is unavailable.', warnings)
@@ -1,4 +1,5 @@
import { pathToFileURL } from 'node:url'
import { fetchAdminOnceMore } from './relay-admin-transient-retry.mjs'
import { inspectAdmissionSelector } from './relay-admission-selector.mjs'
const DIRECTOR_ORIGIN = 'https://relay.onorca.dev'
@@ -229,15 +230,20 @@ export async function recoverRegionalRehomeEnable(config, post) {
export async function operateRegionalRehome(config, dependencies = {}) {
const fetchImpl = dependencies.fetch ?? fetch
const post = dependencies.post ?? (async (path, body) => await responseJson(
await fetchImpl(`${config.directorOrigin}${path}`, {
method: 'POST',
headers: {
authorization: `Bearer ${config.token}`,
'content-type': 'application/json'
// Generation-guarded writes make a retry a no-op or an explicit mismatch, never a double apply.
await fetchAdminOnceMore(
fetchImpl,
`${config.directorOrigin}${path}`,
{
method: 'POST',
headers: {
authorization: `Bearer ${config.token}`,
'content-type': 'application/json'
},
body: JSON.stringify(body)
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(30_000)
}),
{ wait: dependencies.wait }
),
path
))
if (config.mode === 'recover-enable') {
@@ -263,3 +263,55 @@ test('main executes recovery mode and emits verified disabled control', async ()
control: control(6, false)
})
})
test('retries a transient 503 on the director control endpoint', async () => {
const config = parseRegionalRehomeArguments(
argumentsFor('inspect'),
{ ORCA_RELAY_ADMIN_ID_TOKEN: 'token' }
)
const paths = []
let selectorCalls = 0
const result = await operateRegionalRehome(config, {
wait: async () => {},
fetch: async (url) => {
const path = new URL(url).pathname
paths.push(path)
if (path === '/v1/admin/admission-selector/status') {
selectorCalls += 1
// The first read of each admin path 503s the way a warming instance does.
if (selectorCalls === 1) return new Response('warming up', { status: 503 })
return Response.json({ selector: { generation: 11, membership } })
}
if (paths.filter((value) => value === path).length === 1) {
return new Response('warming up', { status: 503 })
}
return Response.json({ v: 1, control: control(4, false) })
}
})
assert.equal(result.control.generation, 4)
assert.deepEqual(paths, [
'/v1/admin/admission-selector/status',
'/v1/admin/admission-selector/status',
'/v1/admin/regional-rehome-control',
'/v1/admin/regional-rehome-control'
])
})
test('fails when both attempts at the director control endpoint return 503', async () => {
const config = parseRegionalRehomeArguments(
argumentsFor('inspect'),
{ ORCA_RELAY_ADMIN_ID_TOKEN: 'token' }
)
let calls = 0
await assert.rejects(
operateRegionalRehome(config, {
wait: async () => {},
fetch: async () => {
calls += 1
return new Response('warming up', { status: 503 })
}
}),
/returned 503/
)
assert.equal(calls, 2)
})
@@ -1,4 +1,5 @@
import { pathToFileURL } from 'node:url'
import { fetchAdminOnceMore } from './relay-admin-transient-retry.mjs'
import {
applyExactAdmissionSelector,
inspectAdmissionSelector,
@@ -72,12 +73,16 @@ export async function prepareProductionCapacityCell(config, overrides = {}) {
if (!token || token.length > 8_192) throw new Error('admin identity token is unavailable')
const postAt = async (origin, path, body) =>
await responseJson(
await fetchImpl(`${origin}${path}`, {
method: 'POST',
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(30_000)
}),
await fetchAdminOnceMore(
fetchImpl,
`${origin}${path}`,
{
method: 'POST',
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
body: JSON.stringify(body)
},
{ wait: overrides.wait }
),
path
)
const post = async (path, body) => await postAt(config.directorOrigin, path, body)
@@ -170,4 +170,42 @@ describe('production Relay capacity cell admission', () => {
/irreversible/
)
})
it('retries a transient 503 on the cell drain endpoint', async () => {
let calls = 0
const result = await prepareProductionCapacityCell(
{ ...config, mode: 'drain' },
{
token: 'token',
wait: async () => {},
fetch: async (url) => {
assert.equal(new URL(url).pathname, '/v1/admin/drain')
calls += 1
if (calls === 1) return response({ error: 'warming up' }, 503)
return response({ v: 1, draining: true })
}
}
)
assert.equal(calls, 2)
assert.deepEqual(result, { changed: false, drained: true })
})
it('fails when both drain attempts return a transient 503', async () => {
let calls = 0
await assert.rejects(
prepareProductionCapacityCell(
{ ...config, mode: 'drain' },
{
token: 'token',
wait: async () => {},
fetch: async () => {
calls += 1
return response({ error: 'warming up' }, 503)
}
}
),
/returned 503/
)
assert.equal(calls, 2)
})
})
@@ -1,4 +1,5 @@
import { pathToFileURL } from 'node:url'
import { fetchAdminOnceMore } from './relay-admin-transient-retry.mjs'
const PRODUCTION_CELL = /^production-gce-c(?:7|8|9|10|13|14|15|16|19|20|21|22|23|24|25|26)$/
const DIRECTOR_ORIGIN = 'https://relay.onorca.dev'
@@ -35,7 +36,8 @@ export function parseRehomeTrustProbeArguments(argv, environment = process.env)
export async function probeRehomeTrust(config, dependencies = {}) {
const fetchImpl = dependencies.fetch ?? fetch
const response = await fetchImpl(
const response = await fetchAdminOnceMore(
fetchImpl,
`${config.directorOrigin}/v1/admin/regional-rehome-trust-probe`,
{
method: 'POST',
@@ -47,9 +49,9 @@ export async function probeRehomeTrust(config, dependencies = {}) {
v: 1,
sourceCellId: config.cellId,
sourceCellIncarnation: config.cellIncarnation
}),
signal: AbortSignal.timeout(30_000)
}
})
},
{ wait: dependencies.wait }
)
const body = await response.json().catch(() => ({}))
if (!response.ok) {
@@ -68,3 +68,46 @@ test('rejects partial or mismatched proof', async () => {
/incomplete/
)
})
const provenProbe = {
v: 1,
dedicatedIdentity: {
firstOutcome: 'host-not-connected',
secondOutcome: 'host-not-connected',
accepted: true,
idempotent: true
},
sharedRuntimeIdentityRejected: true,
proven: true
}
test('retries a transient 503 on the trust probe and proves on the second answer', async () => {
const config = parseRehomeTrustProbeArguments(argv, environment)
let calls = 0
const result = await probeRehomeTrust(config, {
wait: async () => {},
fetch: async () => {
calls += 1
if (calls === 1) return new Response('warming up', { status: 503 })
return Response.json(provenProbe)
}
})
assert.equal(calls, 2)
assert.equal(result.proven, true)
})
test('fails when both trust-probe attempts return a transient 503', async () => {
const config = parseRehomeTrustProbeArguments(argv, environment)
let calls = 0
await assert.rejects(
probeRehomeTrust(config, {
wait: async () => {},
fetch: async () => {
calls += 1
return new Response('warming up', { status: 503 })
}
}),
/returned 503/
)
assert.equal(calls, 2)
})
@@ -0,0 +1,43 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { test } from 'node:test'
import { fileURLToPath } from 'node:url'
import { relayWorkflowUrl } from './relay-repository.mjs'
const WORKFLOWS = [
'deploy-relay-production-same-cap-job.yml',
'operate-relay-production-rehome-job.yml'
]
function workflow(name) {
return readFileSync(fileURLToPath(relayWorkflowUrl(name)), 'utf8')
}
// A single transient 5xx from a warming instance behind the global load balancer
// must not fail a canary, so no admin endpoint may be read by a bare curl.
test('no admin endpoint is reached by a curl without a bounded retry', () => {
for (const name of WORKFLOWS) {
for (const invocation of workflow(name).split(/\bcurl\b/).slice(1)) {
const flags = invocation.split('\n }')[0]
assert.match(flags, /--retry 3 --retry-delay 2 --retry-connrefused/, name)
assert.match(flags, /--max-time 30/, name)
// --retry-all-errors would also retry 401, 403, and 409, which are final.
assert.doesNotMatch(flags, /--retry-all-errors/, name)
}
}
})
test('every retried admin request captures only the final attempt body', () => {
const job = workflow('deploy-relay-production-same-cap-job.yml')
// --fail-with-body writes every failed attempt to stdout, so a retried
// request must land in a file curl truncates per attempt.
assert.match(job, /--output "\$\{out\}"/)
assert.equal(job.split('admin_post() {').length - 1, 2)
for (const call of [
/CURRENT_RUNTIME="\$\(admin_post current-runtime/,
/CURRENT_DIRECTOR_STATUS="\$\(admin_post current-cell-status/,
/TARGET_RUNTIME="\$\(admin_post target-runtime/,
/TARGET_DIRECTOR_STATUS="\$\(admin_post target-cell-status/
]) assert.match(job, call)
assert.doesNotMatch(job, /\$\(curl /)
})
@@ -0,0 +1,29 @@
// A single transient 5xx (load-balancer warm-up behind a fresh instance) must not fail a
// deploy step. 4xx is never retried: auth and generation-mismatch answers are final.
const TRANSIENT_STATUSES = [500, 502, 503, 504]
const RETRY_DELAY_MS = 2_000
const REQUEST_TIMEOUT_MS = 30_000
export function isTransientAdminStatus(status) {
return TRANSIENT_STATUSES.includes(status)
}
// Each attempt gets its own timeout budget, so a reused signal cannot abort the retry.
export async function fetchAdminOnceMore(fetchImpl, url, init, overrides = {}) {
const wait = overrides.wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)))
const timeoutMs = overrides.timeoutMs ?? REQUEST_TIMEOUT_MS
const retryDelayMs = overrides.retryDelayMs ?? RETRY_DELAY_MS
const attempt = async () =>
await fetchImpl(url, { ...init, signal: AbortSignal.timeout(timeoutMs) })
let response
try {
response = await attempt()
} catch {
await wait(retryDelayMs)
return await attempt()
}
if (!isTransientAdminStatus(response.status)) return response
await response.arrayBuffer?.().catch(() => undefined)
await wait(retryDelayMs)
return await attempt()
}
@@ -0,0 +1,130 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { fetchAdminOnceMore } from './relay-admin-transient-retry.mjs'
const url = 'https://relay.onorca.dev/v1/admin/cell-status'
const init = { method: 'POST', body: '{"v":1}' }
function recordingWait(waits) {
return async (ms) => { waits.push(ms) }
}
test('a single transient 5xx is retried and the second answer is returned', async () => {
const waits = []
const statuses = [503, 200]
let calls = 0
const response = await fetchAdminOnceMore(
async () => {
calls += 1
const status = statuses.shift()
return new Response(JSON.stringify({ ok: status === 200 }), { status })
},
url,
init,
{ wait: recordingWait(waits) }
)
assert.equal(calls, 2)
assert.equal(response.status, 200)
assert.deepEqual(waits, [2_000])
assert.deepEqual(await response.json(), { ok: true })
})
test('a connection failure is retried and the second answer is returned', async () => {
const waits = []
let calls = 0
const response = await fetchAdminOnceMore(
async () => {
calls += 1
if (calls === 1) throw new TypeError('fetch failed')
return Response.json({ ok: true })
},
url,
init,
{ wait: recordingWait(waits) }
)
assert.equal(calls, 2)
assert.equal(response.status, 200)
assert.deepEqual(waits, [2_000])
})
test('two transient failures surface the second answer without a third attempt', async () => {
let calls = 0
const response = await fetchAdminOnceMore(
async () => {
calls += 1
return new Response('down', { status: 503 })
},
url,
init,
{ wait: async () => {} }
)
assert.equal(calls, 2)
assert.equal(response.status, 503)
})
test('two connection failures rethrow the second error', async () => {
let calls = 0
await assert.rejects(
fetchAdminOnceMore(
async () => {
calls += 1
throw new TypeError(`fetch failed ${calls}`)
},
url,
init,
{ wait: async () => {} }
),
/fetch failed 2/
)
assert.equal(calls, 2)
})
test('4xx is final: auth and generation-mismatch answers are never retried', async () => {
for (const status of [400, 401, 403, 404, 409, 429]) {
let calls = 0
const response = await fetchAdminOnceMore(
async () => {
calls += 1
return new Response('no', { status })
},
url,
init,
{ wait: async () => { throw new Error('must not wait') } }
)
assert.equal(calls, 1, `status ${status} must not be retried`)
assert.equal(response.status, status)
}
})
test('each attempt carries its own unexpired timeout signal', async () => {
const signals = []
await fetchAdminOnceMore(
async (_url, attemptInit) => {
signals.push(attemptInit.signal)
return new Response('down', { status: 502 })
},
url,
init,
{ wait: async () => {}, timeoutMs: 30_000 }
)
assert.equal(signals.length, 2)
assert.notEqual(signals[0], signals[1])
assert.equal(signals[1].aborted, false)
})
test('the caller init is forwarded unchanged apart from the signal', async () => {
let seen
await fetchAdminOnceMore(
async (seenUrl, attemptInit) => {
seen = { seenUrl, attemptInit }
return Response.json({})
},
url,
{ method: 'POST', headers: { authorization: 'Bearer t' }, body: '{"v":1}' },
{ wait: async () => {} }
)
assert.equal(seen.seenUrl, url)
assert.equal(seen.attemptInit.method, 'POST')
assert.deepEqual(seen.attemptInit.headers, { authorization: 'Bearer t' })
assert.equal(seen.attemptInit.body, '{"v":1}')
})
@@ -0,0 +1,94 @@
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import {
RELAY_REPOSITORY_ROOT,
relayTreePath,
relayWorkflowPath
} from './relay-repository.mjs'
const SHA = /^[a-f0-9]{40}$/
// Every file that decides how relay evidence is produced, sealed, verified, and then spent against
// production; identical content across two commits is what makes the older commit's verdict binding.
export const TRUSTED_EVIDENCE_CODE_PATHS = [
// Produces and seals the 15-minute dry-run evidence.
relayWorkflowPath('monitor-relay-production.yml'),
relayWorkflowPath('monitor-relay-production-job.yml'),
// Download it, verify its authority, and mutate production on it.
relayWorkflowPath('deploy-relay-production-same-cap.yml'),
relayWorkflowPath('deploy-relay-production-same-cap-job.yml'),
relayWorkflowPath('operate-relay-production-rehome.yml'),
relayWorkflowPath('operate-relay-production-rehome-job.yml'),
// Sealing, verification, the wave/canary authority, and the path constants below.
relayTreePath('dev/scripts/relay-evidence-code-provenance.mjs'),
relayTreePath('dev/scripts/relay-monitor-evidence.mjs'),
relayTreePath('dev/scripts/relay-production-same-cap-wave.mjs'),
relayTreePath('dev/scripts/relay-repository.mjs'),
// Every other script those jobs run against live production.
relayTreePath('dev/scripts/infra.mjs'),
relayTreePath('dev/scripts/operate-relay-regional-rehome.mjs'),
relayTreePath('dev/scripts/prepare-relay-production-capacity-canary.mjs'),
relayTreePath('dev/scripts/probe-relay-rehome-trust.mjs'),
relayTreePath('dev/scripts/validate-relay-capacity-plan.mjs'),
relayTreePath('dev/scripts/verify-relay-capacity-transition.mjs'),
// The monitor itself and the live preflight recheck, plus anything that changes their behaviour.
relayTreePath('apps/relay-ops'),
relayTreePath('package.json'),
relayTreePath('pnpm-lock.yaml'),
relayTreePath('pnpm-workspace.yaml'),
// The Cloud SQL rollout lease every mutation job takes and releases.
'.github/actions/cloud-sql-rollout-lease'
]
function git(root, args) {
const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' })
if (result.error) throw new Error('relay evidence provenance cannot run git')
return result
}
/**
* Accepts evidence sealed at a different commit only when the current commit descends from it and
* every trusted path is byte-identical, so the verdict provably came from this exact code. Anything
* git cannot answer (no checkout, unknown commit, shallow clone) fails closed.
*/
export function requireSameEvidenceCode({
sealedSha,
currentSha,
label,
repositoryRoot = fileURLToPath(RELAY_REPOSITORY_ROOT)
}) {
if (!SHA.test(sealedSha ?? '') || !SHA.test(currentSha ?? '')) {
throw new Error(`${label} commit is invalid`)
}
if (sealedSha === currentSha) return
if (git(repositoryRoot, ['rev-parse', '--git-dir']).status !== 0) {
throw new Error(`${label} commit cannot be compared without a git checkout`)
}
for (const sha of [sealedSha, currentSha]) {
if (git(repositoryRoot, ['rev-parse', '--verify', '--quiet', `${sha}^{commit}`]).status !== 0) {
throw new Error(
`${label} commit ${sha} is unknown to this checkout; check out with fetch-depth: 0`
)
}
}
const ancestry = git(repositoryRoot, ['merge-base', '--is-ancestor', sealedSha, currentSha])
if (ancestry.status === 1) {
throw new Error(`${label} commit ${sealedSha} is not an ancestor of ${currentSha}`)
}
if (ancestry.status !== 0) {
throw new Error(`${label} commit ancestry could not be determined`)
}
const diff = git(repositoryRoot, [
'diff',
'--name-only',
sealedSha,
currentSha,
'--',
...TRUSTED_EVIDENCE_CODE_PATHS
])
if (diff.status !== 0) throw new Error(`${label} commit comparison failed`)
const changed = diff.stdout.split('\n').filter(Boolean)
if (changed.length > 0) {
throw new Error(`${label} code changed after it was sealed: ${changed.join(',')}`)
}
}
+18 -5
View File
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto'
import { chmod, readFile, readdir, stat, writeFile } from 'node:fs/promises'
import { basename, join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { requireSameEvidenceCode } from './relay-evidence-code-provenance.mjs'
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{1,127}$/
const SHA = /^[a-f0-9]{40}$/
@@ -102,7 +103,7 @@ export async function createEvidenceManifest(argv) {
return manifest
}
async function readAndVerifyManifest(directory, expected) {
async function readAndVerifyManifest(directory, expected, sameCodeCommit) {
const manifest = JSON.parse(
await readFile(join(directory, 'evidence-manifest.json'), 'utf8')
)
@@ -111,11 +112,23 @@ async function readAndVerifyManifest(directory, expected) {
manifest.incidentId !== expected.incidentId ||
manifest.runId !== expected.runId ||
manifest.runAttempt !== expected.runAttempt ||
manifest.commitSha !== expected.commitSha ||
manifest.mode !== expected.mode
!SHA.test(manifest.commitSha ?? '') ||
manifest.mode !== expected.mode ||
(!sameCodeCommit && manifest.commitSha !== expected.commitSha)
) {
throw new Error('relay monitor evidence provenance does not match')
}
// Unrelated merges land on main every few minutes, so the deployer resolves a newer commit than
// the monitor it must trust; identical monitor and mutation code is the property the SHA stood in
// for. Restore and mutation keep the exact-SHA bind: both run at the commit that sealed them.
if (sameCodeCommit) {
requireSameEvidenceCode({
sealedSha: manifest.commitSha,
currentSha: expected.commitSha,
label: 'relay monitor evidence',
...sameCodeCommit
})
}
const names = Object.keys(manifest.files ?? {})
if (!names.includes(`${expected.incidentId}.state.json`)) {
throw new Error('relay monitor evidence has no durable state')
@@ -209,12 +222,12 @@ function validCompletedDryRunState(state, expected, nowMs, maxAgeMs) {
)
}
export async function verifyDryRunAuthority(argv, now = Date.now) {
export async function verifyDryRunAuthority(argv, now = Date.now, repositoryRoot) {
const values = argumentsByName(argv)
const directory = resolve(values.directory ?? '')
const expected = provenance(values)
if (expected.mode !== 'dry-run') throw new Error('relay mutation requires dry-run evidence')
const manifest = await readAndVerifyManifest(directory, expected)
const manifest = await readAndVerifyManifest(directory, expected, { repositoryRoot })
const state = JSON.parse(
await readFile(join(directory, `${expected.incidentId}.state.json`), 'utf8')
)
@@ -1,9 +1,15 @@
import assert from 'node:assert/strict'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { execFileSync } from 'node:child_process'
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { dirname, join } from 'node:path'
import test from 'node:test'
import { relayWorkflowPath, relayWorkflowUrl } from './relay-repository.mjs'
import { TRUSTED_EVIDENCE_CODE_PATHS } from './relay-evidence-code-provenance.mjs'
import {
RELAY_REPOSITORY_ROOT,
relayWorkflowPath,
relayWorkflowUrl
} from './relay-repository.mjs'
import {
createEvidenceManifest,
verifyDryRunAuthority,
@@ -12,7 +18,7 @@ import {
} from './relay-monitor-evidence.mjs'
const now = Date.parse('2026-07-28T12:00:00.000Z')
const provenance = [
const provenanceFor = (commitSha) => [
'--incident-id',
'relay-123',
'--run-id',
@@ -20,10 +26,11 @@ const provenance = [
'--run-attempt',
'1',
'--commit-sha',
'a'.repeat(40),
commitSha,
'--mode',
'dry-run'
]
const provenance = provenanceFor('a'.repeat(40))
const selector = {
generation: 2,
membership: {
@@ -513,3 +520,157 @@ test('monitor uses a reusable job so exact job_workflow_ref is present', async (
assert.match(job, /workflow_call:/)
assert.match(job, /environment: production/)
})
function gitIn(root, ...args) {
return execFileSync('git', ['-C', root, ...args], { encoding: 'utf8' }).trim()
}
// A real repository shaped like main under unrelated merge traffic: one sealed commit, a
// descendant that only touched untrusted files, a descendant that touched the monitor, and a
// sibling that never descended from the seal.
async function trustedCodeRepository() {
const root = await mkdtemp(join(tmpdir(), 'relay-evidence-repository-'))
gitIn(root, 'init', '--quiet')
gitIn(root, 'config', 'user.email', 'relay@example.test')
gitIn(root, 'config', 'user.name', 'Relay Evidence Test')
gitIn(root, 'config', 'commit.gpgsign', 'false')
const commit = async (path, body, message) => {
await mkdir(dirname(join(root, path)), { recursive: true })
await writeFile(join(root, path), body)
gitIn(root, 'add', '--all')
gitIn(root, 'commit', '--quiet', '--no-verify', '--message', message)
return gitIn(root, 'rev-parse', 'HEAD')
}
const base = await commit(
'cloud/apps/relay-ops/src/incident-monitor.ts',
'export const v = 1\n',
'monitor'
)
const sealed = await commit('README.md', 'base\n', 'base')
const sameCode = await commit('README.md', 'an unrelated merge\n', 'unrelated')
const changedCode = await commit(
'cloud/apps/relay-ops/src/incident-monitor.ts',
'export const v = 2\n',
'monitor change'
)
// Branches before the seal, so the seal is not in its history even though its code matches.
gitIn(root, 'checkout', '--quiet', '--detach', base)
const sibling = await commit('README.md', 'a divergent line\n', 'divergent')
return { root, sealed, sameCode, changedCode, sibling }
}
const authorityAt = (directory, commitSha, repositoryRoot) => verifyDryRunAuthority(
[
'--directory',
directory,
...provenanceFor(commitSha),
'--required-migration-policy',
'strict'
],
() => now,
repositoryRoot
)
test('accepts dry-run evidence sealed by identical code at an ancestor commit', async () => {
const repository = await trustedCodeRepository()
const directory = await evidenceDirectory()
try {
await createEvidenceManifest([
'--directory',
directory,
...provenanceFor(repository.sealed)
])
// An exact match never consults git: a root with no checkout at all still verifies.
await assert.doesNotReject(authorityAt(directory, repository.sealed, directory))
await assert.doesNotReject(authorityAt(directory, repository.sameCode, repository.root))
} finally {
await rm(repository.root, { recursive: true, force: true })
await rm(directory, { recursive: true, force: true })
}
})
test('rejects dry-run evidence whose monitor code or lineage differs', async () => {
const repository = await trustedCodeRepository()
const directory = await evidenceDirectory()
try {
await createEvidenceManifest([
'--directory',
directory,
...provenanceFor(repository.sealed)
])
await assert.rejects(
authorityAt(directory, repository.changedCode, repository.root),
/code changed after it was sealed: cloud\/apps\/relay-ops\/src\/incident-monitor\.ts/
)
await assert.rejects(
authorityAt(directory, repository.sibling, repository.root),
/is not an ancestor of/
)
// Fails closed: a shallow clone that never fetched the sealed commit proves nothing.
await assert.rejects(
authorityAt(directory, 'f'.repeat(40), repository.root),
/unknown to this checkout/
)
// Fails closed: no checkout to compare against.
await assert.rejects(
authorityAt(directory, repository.sameCode, directory),
/cannot be compared without a git checkout/
)
} finally {
await rm(repository.root, { recursive: true, force: true })
await rm(directory, { recursive: true, force: true })
}
})
test('keeps restore and mutation bound to the exact sealing commit', async () => {
const repository = await trustedCodeRepository()
const directory = await evidenceDirectory()
try {
await createEvidenceManifest([
'--directory',
directory,
...provenanceFor(repository.sealed)
])
await assert.rejects(
verifyRestoredEvidence([
'--directory',
directory,
...provenanceFor(repository.sameCode)
]),
/provenance does not match/
)
await assert.rejects(
verifyMutationEvidence(
[
'--directory',
directory,
...provenanceFor(repository.sameCode),
'--mutation-mode',
'execute',
'--source-cell-id',
'c1',
'--director-origin',
'https://relay.example'
],
{ ORCA_RELAY_ADMIN_ID_TOKEN: 'aaa.bbb.ccc' },
async () => Response.json({ selector }),
() => now
),
/provenance does not match/
)
} finally {
await rm(repository.root, { recursive: true, force: true })
await rm(directory, { recursive: true, force: true })
}
})
// A trusted path that no longer exists silently stops being compared, so the same-code rule would
// pass over code it was written to pin.
test('every trusted provenance path exists in this checkout', async () => {
for (const path of TRUSTED_EVIDENCE_CODE_PATHS) {
await assert.doesNotReject(
stat(new URL(path, RELAY_REPOSITORY_ROOT)),
`${path} is missing`
)
}
})
@@ -1,5 +1,6 @@
import { readFileSync } from 'node:fs'
import { pathToFileURL } from 'node:url'
import { requireSameEvidenceCode } from './relay-evidence-code-provenance.mjs'
export const SAME_CAP_CELLS = [
'production-gce-c7', 'production-gce-c8', 'production-gce-c9', 'production-gce-c10',
@@ -85,10 +86,10 @@ export function canaryAuthority(input) {
}
}
export function verifyCanaryAuthority(authority, expected) {
export function verifyCanaryAuthority(authority, expected, repositoryRoot) {
if (
authority?.v !== 1 ||
authority.commitSha !== expected.commitSha ||
!/^[0-9a-f]{40}$/.test(authority.commitSha ?? '') ||
authority.runId !== expected.runId ||
authority.targetDigest !== expected.targetDigest ||
authority.rollbackDigest !== expected.rollbackDigest ||
@@ -96,6 +97,14 @@ export function verifyCanaryAuthority(authority, expected) {
authority.rehomeGeneration !== Number(expected.rehomeGeneration) ||
!SAME_CAP_CELLS.includes(authority.cellId)
) throw new Error('canary authority does not match this batch')
// The batch dispatch resolves main after the canary sealed, so bind to the same code, not the
// same SHA; every field above still pins this batch to that exact canary.
requireSameEvidenceCode({
sealedSha: authority.commitSha,
currentSha: expected.commitSha,
label: 'relay same-cap canary authority',
repositoryRoot
})
return authority
}
@@ -1,4 +1,8 @@
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { test } from 'node:test'
import {
canaryAuthority,
@@ -104,3 +108,66 @@ test('seals and verifies canary authority for later batches', () => {
rehomeGeneration: '4'
}), /does not match/)
})
function gitIn(root, ...args) {
return execFileSync('git', ['-C', root, ...args], { encoding: 'utf8' }).trim()
}
async function canaryRepository() {
const root = await mkdtemp(join(tmpdir(), 'relay-same-cap-canary-'))
gitIn(root, 'init', '--quiet')
gitIn(root, 'config', 'user.email', 'relay@example.test')
gitIn(root, 'config', 'user.name', 'Relay Wave Test')
gitIn(root, 'config', 'commit.gpgsign', 'false')
const commit = async (path, body, message) => {
await mkdir(dirname(join(root, path)), { recursive: true })
await writeFile(join(root, path), body)
gitIn(root, 'add', '--all')
gitIn(root, 'commit', '--quiet', '--no-verify', '--message', message)
return gitIn(root, 'rev-parse', 'HEAD')
}
const sealed = await commit(
'cloud/dev/scripts/relay-production-same-cap-wave.mjs',
'export const v = 1\n',
'wave'
)
const sameCode = await commit('README.md', 'an unrelated merge\n', 'unrelated')
const changedCode = await commit(
'cloud/dev/scripts/relay-production-same-cap-wave.mjs',
'export const v = 2\n',
'wave change'
)
return { root, sealed, sameCode, changedCode }
}
test('a batch trusts a canary sealed by identical code at an ancestor commit', async () => {
const repository = await canaryRepository()
try {
const authority = canaryAuthority({
cellIds: 'production-gce-c7',
targetDigest,
rollbackDigest,
confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} production-gce-c7`,
commitSha: repository.sealed,
runId: '42',
selectorGeneration: '11',
rehomeGeneration: '4'
})
const verifyAt = (commitSha, repositoryRoot) => verifyCanaryAuthority(authority, {
commitSha,
runId: '42',
targetDigest,
rollbackDigest,
selectorGeneration: '13',
rehomeGeneration: '4'
}, repositoryRoot)
assert.equal(verifyAt(repository.sameCode, repository.root).cellId, 'production-gce-c7')
assert.throws(
() => verifyAt(repository.changedCode, repository.root),
/code changed after it was sealed/
)
assert.throws(() => verifyAt('f'.repeat(40), repository.root), /unknown to this checkout/)
} finally {
await rm(repository.root, { recursive: true, force: true })
}
})
@@ -92,7 +92,11 @@ test('same-cap wrapper is reusable, canary-bound, and sequential', () => {
// age checks must scale by wave or cell_2+ can never pass; the bound's
// per-wave step is the cell job timeout, so the two must move together.
assert.match(job, /--required-migration-policy strict \\\n --wave-index "\$\{WAVE_INDEX\}"/)
assert.match(job, /dry-run\.state\.json" \\\n --wave-index "\$\{WAVE_INDEX\}" "\$\{RETRY_ARGS\[@\]\}"/)
// Wave 0 must retry freshness-only failures too: one Cloud Monitoring publish
// lag at the sample instant is not health evidence, and single-shot wave 0
// failed a whole batch on a series that was fresh again a minute later.
assert.match(job, /dry-run\.state\.json" \\\n --wave-index "\$\{WAVE_INDEX\}" --retry-freshness/)
assert.doesNotMatch(job, /RETRY_ARGS/)
assert.match(job, /timeout-minutes: 75/)
// Both age gates step by the cell job timeout above; the constant is
// duplicated across the two languages, so pin each copy to it.
+15
View File
@@ -1,4 +1,6 @@
import { readFileSync } from 'node:fs'
import { relative } from 'node:path'
import { fileURLToPath } from 'node:url'
// Single place naming the repository the Relay workflows live in and where their files sit. The
// public-repo copy moves this tree under cloud/, prefixes every workflow filename, and changes the
@@ -11,6 +13,19 @@ export const RELAY_WORKFLOW_FILE_PREFIX = 'cloud-'
// this tree moves under cloud/, so the depth changes at the copy even though the layout does not.
export const RELAY_WORKFLOW_DIRECTORY = new URL('../../../.github/workflows/', import.meta.url)
// Repository root, derived from the one directory above that already tracks the copy's depth.
export const RELAY_REPOSITORY_ROOT = new URL('../../', RELAY_WORKFLOW_DIRECTORY)
// Repository-relative path for a file in this tree. The prefix is 'cloud/' here and empty where
// the tree is the repository root, so callers naming git paths never restate the layout.
export function relayTreePath(suffix) {
const prefix = relative(
fileURLToPath(RELAY_REPOSITORY_ROOT),
fileURLToPath(new URL('../../', import.meta.url))
).split(/[\\/]/).filter(Boolean)
return [...prefix, suffix].join('/')
}
export function relayWorkflowFile(name) {
return `${RELAY_WORKFLOW_FILE_PREFIX}${name}`
}
@@ -1,4 +1,5 @@
import { pathToFileURL } from 'node:url'
import { fetchAdminOnceMore } from './relay-admin-transient-retry.mjs'
const CAPACITY_PROTOCOL = 2
@@ -378,9 +379,12 @@ export async function verifyCapacityTransition(config, overrides = {}) {
const token = overrides.token ?? process.env.ORCA_RELAY_ADMIN_ID_TOKEN
if (!token || token.length > 8_192) throw new Error('admin identity token is unavailable')
const health = await responseJson(
await fetchImpl(`${config.directorOrigin}/health`, {
signal: AbortSignal.timeout(15_000)
}),
await fetchAdminOnceMore(
fetchImpl,
`${config.directorOrigin}/health`,
{},
{ wait, timeoutMs: 15_000 }
),
'director health'
)
if (health.ok !== true || health.connectionCapacityProtocol !== CAPACITY_PROTOCOL) {
@@ -394,12 +398,16 @@ export async function verifyCapacityTransition(config, overrides = {}) {
lastObservation = { runtimeAvailable: runtime !== null }
if ((runtime === null) === (config.runtime === 'unavailable')) {
const result = await responseJson(
await fetchImpl(`${config.directorOrigin}/v1/admin/cell-status`, {
method: 'POST',
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
body: JSON.stringify({ v: 1, cellId: config.cellId }),
signal: AbortSignal.timeout(30_000)
}),
await fetchAdminOnceMore(
fetchImpl,
`${config.directorOrigin}/v1/admin/cell-status`,
{
method: 'POST',
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
body: JSON.stringify({ v: 1, cellId: config.cellId })
},
{ wait }
),
'cell status'
)
const status = result.status
@@ -1094,3 +1094,77 @@ test('does not retry a rejected cell admin token', async () => {
)
assert.equal(waits, 0)
})
test('retries a transient 503 on the director cell-status read', async () => {
const base = harness()
const statusCalls = []
const result = await verifyCapacityTransition(config, {
token: 'masked-token',
wait: async () => {},
fetch: async (url, options) => {
const path = new URL(url).pathname
if (path !== '/v1/admin/cell-status') return await base(url, options)
statusCalls.push(path)
if (statusCalls.length === 1) return new Response('warming up', { status: 503 })
return await base(url, options)
}
})
assert.equal(statusCalls.length, 2)
assert.equal(result.cellId, config.cellId)
})
test('fails when both director cell-status attempts return a transient 503', async () => {
const base = harness()
let statusCalls = 0
await assert.rejects(
verifyCapacityTransition(config, {
token: 'masked-token',
wait: async () => {},
fetch: async (url, options) => {
const path = new URL(url).pathname
if (path !== '/v1/admin/cell-status') return await base(url, options)
statusCalls += 1
return new Response('warming up', { status: 503 })
}
}),
/cell status returned 503/
)
assert.equal(statusCalls, 2)
})
test('retries a transient 503 on the director health preflight', async () => {
const base = harness()
let healthCalls = 0
const result = await verifyCapacityTransition(config, {
token: 'masked-token',
wait: async () => {},
fetch: async (url, options) => {
const path = new URL(url).pathname
if (path !== '/health') return await base(url, options)
healthCalls += 1
if (healthCalls === 1) return new Response('warming up', { status: 503 })
return await base(url, options)
}
})
assert.equal(healthCalls, 2)
assert.equal(result.cellId, config.cellId)
})
test('fails when both director health attempts return a transient 503', async () => {
const base = harness()
let healthCalls = 0
await assert.rejects(
verifyCapacityTransition(config, {
token: 'masked-token',
wait: async () => {},
fetch: async (url, options) => {
const path = new URL(url).pathname
if (path !== '/health') return await base(url, options)
healthCalls += 1
return new Response('warming up', { status: 503 })
}
}),
/director health returned 503/
)
assert.equal(healthCalls, 2)
})
+2 -7
View File
@@ -211,7 +211,8 @@ resource "google_logging_metric" "relay_snapshot" {
label_extractors = {
role = "EXTRACT(jsonPayload.role)"
cell_id = "EXTRACT(jsonPayload.cellId)"
region = "EXTRACT(jsonPayload.region)"
# No region label: adding one replaces all 21 live metrics (label change = delete+create),
# which resets history and blanks the relay alert policies during the swap.
}
metric_descriptor {
@@ -230,12 +231,6 @@ resource "google_logging_metric" "relay_snapshot" {
value_type = "STRING"
description = "Durable relay cell identifier."
}
labels {
key = "region"
value_type = "STRING"
description = "Coarse Relay region."
}
}
bucket_options {
+1 -1
View File
@@ -21,7 +21,7 @@
"load:relay:recovery-gate": "node dev/scripts/run-relay-recovery-wave-gate.mjs",
"ops:relay": "pnpm --filter @orca-cloud/relay-ops dev",
"pretest": "node --test dev/scripts/capture-terraform-plan-baseline.test.mjs dev/scripts/operate-relay-asia-admission.test.mjs dev/scripts/prepare-relay-asia-director-cells.test.mjs dev/scripts/prepare-relay-asia-topology-input.test.mjs dev/scripts/production-cloud-sql-rollout-lock.test.mjs dev/scripts/read-relay-serving-regional-placement-version.test.mjs dev/scripts/relay-asia-admission-workflow.test.mjs dev/scripts/relay-asia-rollout-evidence.test.mjs dev/scripts/relay-asia-topology-workflow.test.mjs dev/scripts/relay-cloud-sql-connection-budget.test.mjs dev/scripts/relay-load-reader-evidence.test.mjs dev/scripts/relay-staging-deploy-identity.test.mjs dev/scripts/sanitize-relay-asia-admission-result.test.mjs dev/scripts/terraform-root-partition.test.mjs dev/scripts/validate-relay-asia-topology-plan.test.mjs ../.github/actions/cloud-sql-rollout-lease/action-contract.test.mjs ../.github/actions/cloud-sql-rollout-lease/storage-lease.test.mjs",
"test": "pnpm -r test && node --test dev/scripts/classify-relay-production-capacity-director.test.mjs dev/scripts/classify-relay-staging-bootstrap.test.mjs dev/scripts/deploy-relay-blue-green.test.mjs dev/scripts/deploy-relay-gce-candidate.test.mjs dev/scripts/deploy-relay-gce-multi-target.test.mjs dev/scripts/github-smoke-token.test.mjs dev/scripts/infra.test.mjs dev/scripts/operate-relay-regional-rehome.test.mjs dev/scripts/power-staging-relay.test.mjs dev/scripts/prepare-relay-capacity-canary.test.mjs dev/scripts/prepare-relay-production-capacity-canary.test.mjs dev/scripts/probe-relay-legacy-admission.test.mjs dev/scripts/probe-relay-rehome-trust.test.mjs dev/scripts/production-cell-image-digest-consistency.test.mjs dev/scripts/read-relay-production-capacity-identity.test.mjs dev/scripts/relay-admission-selector.test.mjs dev/scripts/relay-gce-terraform-fence.test.mjs dev/scripts/relay-load-connection-failure.test.mjs dev/scripts/relay-load-control-peer.test.mjs dev/scripts/relay-load-director-capacity-gate.test.mjs dev/scripts/relay-load-model.test.mjs dev/scripts/relay-load-phase-barrier.test.mjs dev/scripts/relay-load-placement-boundary.test.mjs dev/scripts/relay-load-profile.test.mjs dev/scripts/relay-load-rebind-boundary.test.mjs dev/scripts/relay-load-region-behavior.test.mjs dev/scripts/relay-load-request-unit-boundary.test.mjs dev/scripts/relay-load-run-lifecycle.test.mjs dev/scripts/relay-monitor-evidence.test.mjs dev/scripts/relay-production-capacity-wave.test.mjs dev/scripts/relay-production-capacity-workflow.test.mjs dev/scripts/relay-production-identity-boundaries.test.mjs dev/scripts/relay-production-same-cap-wave.test.mjs dev/scripts/relay-public-workflow-contract.test.mjs dev/scripts/relay-recovery-wave-gate.test.mjs dev/scripts/relay-region-observation-evidence.test.mjs dev/scripts/relay-regional-rehome-workflow.test.mjs dev/scripts/relay-rehome-aggregate-evidence.test.mjs dev/scripts/relay-repository.test.mjs dev/scripts/relay-staging-c4-refresh-workflow.test.mjs dev/scripts/relay-staging-capacity-identity.test.mjs dev/scripts/staging-relay-apply-guard.test.mjs dev/scripts/validate-relay-capacity-plan.test.mjs dev/scripts/verify-relay-capacity-transition.test.mjs dev/scripts/verify-relay-legacy-bootstrap.test.mjs dev/scripts/workload-identity-attribute-conditions.test.mjs",
"test": "pnpm -r test && node --test dev/scripts/classify-relay-production-capacity-director.test.mjs dev/scripts/classify-relay-staging-bootstrap.test.mjs dev/scripts/deploy-relay-blue-green.test.mjs dev/scripts/deploy-relay-gce-candidate.test.mjs dev/scripts/deploy-relay-gce-multi-target.test.mjs dev/scripts/github-smoke-token.test.mjs dev/scripts/infra.test.mjs dev/scripts/operate-relay-regional-rehome.test.mjs dev/scripts/power-staging-relay.test.mjs dev/scripts/prepare-relay-capacity-canary.test.mjs dev/scripts/prepare-relay-production-capacity-canary.test.mjs dev/scripts/probe-relay-legacy-admission.test.mjs dev/scripts/probe-relay-rehome-trust.test.mjs dev/scripts/production-cell-image-digest-consistency.test.mjs dev/scripts/read-relay-production-capacity-identity.test.mjs dev/scripts/relay-admin-endpoint-retry-workflow.test.mjs dev/scripts/relay-admin-transient-retry.test.mjs dev/scripts/relay-admission-selector.test.mjs dev/scripts/relay-gce-terraform-fence.test.mjs dev/scripts/relay-load-connection-failure.test.mjs dev/scripts/relay-load-control-peer.test.mjs dev/scripts/relay-load-director-capacity-gate.test.mjs dev/scripts/relay-load-model.test.mjs dev/scripts/relay-load-phase-barrier.test.mjs dev/scripts/relay-load-placement-boundary.test.mjs dev/scripts/relay-load-profile.test.mjs dev/scripts/relay-load-rebind-boundary.test.mjs dev/scripts/relay-load-region-behavior.test.mjs dev/scripts/relay-load-request-unit-boundary.test.mjs dev/scripts/relay-load-run-lifecycle.test.mjs dev/scripts/relay-monitor-evidence.test.mjs dev/scripts/relay-production-capacity-wave.test.mjs dev/scripts/relay-production-capacity-workflow.test.mjs dev/scripts/relay-production-identity-boundaries.test.mjs dev/scripts/relay-production-same-cap-wave.test.mjs dev/scripts/relay-public-workflow-contract.test.mjs dev/scripts/relay-recovery-wave-gate.test.mjs dev/scripts/relay-region-observation-evidence.test.mjs dev/scripts/relay-regional-rehome-workflow.test.mjs dev/scripts/relay-rehome-aggregate-evidence.test.mjs dev/scripts/relay-repository.test.mjs dev/scripts/relay-staging-c4-refresh-workflow.test.mjs dev/scripts/relay-staging-capacity-identity.test.mjs dev/scripts/staging-relay-apply-guard.test.mjs dev/scripts/validate-relay-capacity-plan.test.mjs dev/scripts/verify-relay-capacity-transition.test.mjs dev/scripts/verify-relay-legacy-bootstrap.test.mjs dev/scripts/workload-identity-attribute-conditions.test.mjs",
"typecheck": "pnpm -r typecheck"
},
"devDependencies": {
File diff suppressed because one or more lines are too long
+1
View File
@@ -219,6 +219,7 @@ const WINDOWS_PACKAGE_TESTS = [
'src/main/agent-hooks/windows-hook-payload-delivery.test.ts',
'src/main/windows/windows-pty-job.win32.test.ts',
'src/main/windows/windows-host-job.win32.test.ts',
'src/main/windows-live-tree-kill.win32.test.ts',
'src/main/wsl/wsl-runner.test.ts',
'src/main/wsl/wsl-guest-environment.test.ts',
'src/main/wsl/wsl-invocation-boundary.test.ts',
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 39m">
<title>downloads: 39m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 40m">
<title>downloads: 40m</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="90" y="15" fill="#010101" fill-opacity=".3">39m</text>
<text x="90" y="14">39m</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">40m</text>
<text x="90" y="14">40m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 935 B

After

Width:  |  Height:  |  Size: 935 B

+1 -3
View File
@@ -9,9 +9,7 @@ Browser-use profiles let you run the Orca browser with a specific identity — a
1. Open [Settings → Browser → Profiles](/docs/settings).
1. Click **Add profile**, give it a name.
1. Optionally seed it with cookies, a user-agent, and a viewport size.
1. For sites that reject Orca's default Chrome-shaped UA (some Google sign-in flows), create a profile that keeps the **native Electron user agent** instead of spoofing. Default profiles still use the cleaned Chrome UA for broader Cloudflare compatibility.
You can also create a no-spoof profile from the CLI with `orca tab profile create --no-ua-spoof` when you script browser setup.
1. Every profile presents Electron's own user agent. Orca no longer rewrites it to look like Chrome, because Cloudflare Turnstile rejects a Chrome-shaped UA that sends no client hints and accepts a declared Electron client. The only exception is Google's sign-in hosts, where Orca presents a Firefox identity so Google issues cookies bound to the embedded browser. A **native user agent** profile (`orca tab profile create --no-ua-spoof`) also skips that Google exception.
## Cookie import and Google sign-in
@@ -127,10 +127,6 @@ __orca_osc133_precmd() {
unset __orca_in_command
fi
printf "\033]133;A\007"
# Why: emit the shell-ready marker here (not a trailing PROMPT_COMMAND entry)
# so a framework that must be last in PROMPT_COMMAND — bash-preexec — is not
# displaced by one of Orca's own hooks.
[[ -n "$__orca_ready_marker" ]] && printf "\033]777;orca-shell-ready\007"
return "$exit_code"
}
__orca_osc133_preexec() {
@@ -188,6 +184,11 @@ __orca_osc133_epilogue() {
unset __orca_in_prompt_command
__orca_adopt_outer_debug_trap
trap '__orca_osc133_preexec' DEBUG
# Readline renders PS1 after entering raw mode; prompt hooks still run in cooked mode.
if [[ -n "$__orca_ready_marker" ]]; then
PS1="${PS1-}"'\[\e]777;orca-shell-ready\a\]'
__orca_ready_marker=""
fi
}
__orca_normalize_prompt_command_part() {
local __orca_value="$1" __orca_output_name="$2" __orca_character __orca_chunk
+17 -1
View File
@@ -4,6 +4,7 @@ import type { AutomationPrecheck, AutomationPrecheckResult } from '../../shared/
import { MAX_AUTOMATION_PRECHECK_OUTPUT_CHARS } from '../../shared/automation-precheck'
import { getSshConnectionManager } from '../ipc/ssh'
import { shellEscape } from '../ssh/ssh-connection-utils'
import { admitSelfInitiatedTreeKill } from '../own-chromium-tree-kill-guard'
type AutomationPrecheckExecutionTarget =
| {
@@ -73,7 +74,10 @@ function failedPrecheckResult(
})
}
function killLocalPrecheckProcessTree(child: ChildProcess): ReturnType<typeof setTimeout> | null {
/** Exported for the refusal-fallback test; the timeout path is otherwise unreachable. */
export function killLocalPrecheckProcessTree(
child: ChildProcess
): ReturnType<typeof setTimeout> | null {
const pid = child.pid
if (!pid) {
child.kill()
@@ -81,6 +85,18 @@ function killLocalPrecheckProcessTree(child: ChildProcess): ReturnType<typeof se
}
if (process.platform === 'win32') {
if (
!admitSelfInitiatedTreeKill({
pid,
site: 'automation-precheck-timeout',
scope: 'win-taskkill-tree'
})
) {
// Refusal blocks the tree walk, not the termination: killing the root by
// handle cannot reach a recycled pid, and a timed-out precheck must stop.
child.kill()
return null
}
try {
// Why: shell prechecks can launch child processes; taskkill walks the
// Windows process tree so timeout means the command is actually stopped.
@@ -1,210 +0,0 @@
import { runInNewContext } from 'node:vm'
import { describe, expect, it } from 'vitest'
import { ANTI_DETECTION_SCRIPT } from './anti-detection'
type PermissionQueryResult = EventTarget & {
state: string
onchange: EventListener | null
marker: string
}
type PermissionStatusConstructor = {
new (): PermissionQueryResult
prototype: PermissionQueryResult
}
type AntiDetectionContext = {
Notification: {
permission: string
requestPermission: (callback?: (permission: string) => void) => Promise<string>
}
PermissionStatus: PermissionStatusConstructor
dispatchPermissionChange: (name: string) => void
navigator: {
permissions: {
query: (descriptor: { name: string }) => Promise<PermissionQueryResult>
}
}
}
function createContext(args: {
nativeNotificationPermission: string
requestedNotificationPermission: string
rejectedPermissions?: string[]
}): AntiDetectionContext & Record<string, unknown> {
class PermissionStatus extends EventTarget {
#state = 'denied'
#onchange: EventListener | null = null
marker = 'real-status'
get state(): string {
return this.#state
}
get onchange(): EventListener | null {
return this.#onchange
}
set onchange(listener: EventListener | null) {
if (this.#onchange) {
super.removeEventListener('change', this.#onchange)
}
this.#onchange = typeof listener === 'function' ? listener : null
if (this.#onchange) {
super.addEventListener('change', this.#onchange)
}
}
}
const statuses = new Map<string, PermissionStatus[]>()
const rejectedPermissions = new Set(args.rejectedPermissions)
class Permissions {
query(descriptor: { name: string }): Promise<PermissionStatus> {
if (rejectedPermissions.has(descriptor.name)) {
return Promise.reject(new Error('Unsupported permission'))
}
const status = new PermissionStatus()
const permissionStatuses = statuses.get(descriptor.name) ?? []
permissionStatuses.push(status)
statuses.set(descriptor.name, permissionStatuses)
return Promise.resolve(status)
}
}
const Notification = {
permission: args.nativeNotificationPermission,
requestPermission(callback?: (permission: string) => void): Promise<string> {
callback?.(args.requestedNotificationPermission)
return Promise.resolve(args.requestedNotificationPermission)
}
}
Object.defineProperty(Notification, 'permission', {
configurable: true,
get: () => args.nativeNotificationPermission
})
return {
Date,
Event,
EventTarget,
Object,
Promise,
Set,
performance: { now: () => 0 },
// Why: the script's Firefox gate reads navigator.userAgent, so a non-Firefox UA keeps these
// tests on the ordinary-page path where the PermissionStatus override applies.
window: { chrome: {} },
navigator: {
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36',
plugins: [],
languages: [],
permissions: new Permissions()
},
Permissions,
PermissionStatus,
Notification,
dispatchPermissionChange(name: string): void {
for (const status of statuses.get(name) ?? []) {
status.dispatchEvent(new Event('change'))
}
}
} as AntiDetectionContext & Record<string, unknown>
}
describe('ANTI_DETECTION_SCRIPT — PermissionStatus', () => {
it('keeps an existing notification status current after permission changes', async () => {
const context = createContext({
nativeNotificationPermission: 'denied',
requestedNotificationPermission: 'granted'
})
runInNewContext(ANTI_DETECTION_SCRIPT, context)
const status = await context.navigator.permissions.query({ name: 'notifications' })
expect(context.Notification.permission).toBe('default')
expect(status.state).toBe('prompt')
await expect(context.Notification.requestPermission()).resolves.toBe('granted')
expect(context.Notification.permission).toBe('granted')
expect(status.state).toBe('granted')
})
it('preserves native PermissionStatus identity and methods', async () => {
const context = createContext({
nativeNotificationPermission: 'denied',
requestedNotificationPermission: 'granted'
})
runInNewContext(ANTI_DETECTION_SCRIPT, context)
const status = await context.navigator.permissions.query({ name: 'camera' })
const expectedSource = Function.prototype.toString.call(
context.PermissionStatus.prototype.addEventListener
)
expect(status).toBeInstanceOf(context.PermissionStatus)
expect(status.state).toBe('prompt')
expect(status.constructor.name).toBe('PermissionStatus')
expect(status.marker).toBe('real-status')
expect(status.addEventListener.name).toBe('addEventListener')
expect(status.addEventListener).toBe(status.addEventListener)
expect(Function.prototype.toString.call(status.addEventListener)).toBe(expectedSource)
expect(expectedSource).toContain('addEventListener')
})
it('delivers change events through the returned status with the overridden state', async () => {
const context = createContext({
nativeNotificationPermission: 'denied',
requestedNotificationPermission: 'granted'
})
runInNewContext(ANTI_DETECTION_SCRIPT, context)
const status = await context.navigator.permissions.query({ name: 'notifications' })
const events: { receiver: EventTarget; target: EventTarget | null; state: string }[] = []
const recordEvent = function (this: EventTarget, event: Event): void {
events.push({
receiver: this,
target: event.target,
state: (event.target as PermissionQueryResult).state
})
}
status.addEventListener('change', recordEvent)
expect(() => {
status.onchange = function (this: EventTarget, event): void {
recordEvent.call(this, event)
}
}).not.toThrow()
await context.Notification.requestPermission()
context.dispatchPermissionChange('notifications')
expect(events).toHaveLength(2)
expect(events).toEqual([
{ receiver: status, target: status, state: 'granted' },
{ receiver: status, target: status, state: 'granted' }
])
})
// Why: 'camera' rather than 'storage-access' — #14685 narrowed the intercepted set to
// camera/microphone, so a name outside it falls through to the real query and never reaches
// the fallback at all.
it('uses a non-enumerable EventTarget fallback when the native query rejects', async () => {
const context = createContext({
nativeNotificationPermission: 'denied',
requestedNotificationPermission: 'granted',
rejectedPermissions: ['camera']
})
runInNewContext(ANTI_DETECTION_SCRIPT, context)
const status = await context.navigator.permissions.query({ name: 'camera' })
expect(status).toBeInstanceOf(EventTarget)
expect(status).not.toBeInstanceOf(context.PermissionStatus)
expect(status.state).toBe('prompt')
expect(Object.keys(status)).toEqual([])
})
})
-157
View File
@@ -1,157 +0,0 @@
import { runInNewContext } from 'node:vm'
import { describe, expect, it } from 'vitest'
import { ANTI_DETECTION_SCRIPT } from './anti-detection'
import { googleAuthUserAgent } from './browser-google-auth-ua'
type PermissionQueryResult = {
state: string
onchange: null
}
type AntiDetectionContext = {
Notification: {
permission: string
requestPermission: (callback?: (permission: string) => void) => Promise<string>
}
navigator: {
userAgent: string
permissions: {
query: (descriptor: { name: string }) => Promise<PermissionQueryResult>
}
}
window: {
chrome?: {
runtime?: unknown
csi?: () => unknown
loadTimes?: () => unknown
}
}
}
function createContext(args: {
nativeNotificationPermission: string
requestedNotificationPermission: string
userAgent?: string
}): AntiDetectionContext & Record<string, unknown> {
class Permissions {
query(): Promise<PermissionQueryResult> {
return Promise.resolve({ state: 'denied', onchange: null })
}
}
const Notification = {
permission: args.nativeNotificationPermission,
requestPermission(callback?: (permission: string) => void): Promise<string> {
callback?.(args.requestedNotificationPermission)
return Promise.resolve(args.requestedNotificationPermission)
}
}
Object.defineProperty(Notification, 'permission', {
configurable: true,
get: () => args.nativeNotificationPermission
})
return {
Date,
Object,
Promise,
Set,
performance: { now: () => 0 },
// Electron 43 exposes this native object before the anti-detection script runs.
window: { chrome: {} },
navigator: {
userAgent:
args.userAgent ??
'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36',
plugins: [],
languages: [],
permissions: new Permissions()
},
Permissions,
Notification
} as AntiDetectionContext & Record<string, unknown>
}
describe('ANTI_DETECTION_SCRIPT', () => {
it('does not expose Chrome globals under a Firefox identity', () => {
const context = createContext({
nativeNotificationPermission: 'denied',
requestedNotificationPermission: 'denied',
userAgent: googleAuthUserAgent()
})
runInNewContext(ANTI_DETECTION_SCRIPT, context)
expect(context.window.chrome).toBeUndefined()
expect('chrome' in context.window).toBe(false)
})
it('keeps Chrome API stubs aligned with an ordinary Chrome page', () => {
const context = createContext({
nativeNotificationPermission: 'denied',
requestedNotificationPermission: 'denied'
})
runInNewContext(ANTI_DETECTION_SCRIPT, context)
expect(context.window.chrome?.runtime).toBeUndefined()
expect(context.window.chrome?.csi).toBeTypeOf('function')
expect(context.window.chrome?.loadTimes).toBeTypeOf('function')
})
it.each(['geolocation', 'idle-detection', 'midi', 'storage-access'])(
'passes non-intercepted permission queries through to the native state for %s',
async (name) => {
const context = createContext({
nativeNotificationPermission: 'denied',
requestedNotificationPermission: 'denied'
})
runInNewContext(ANTI_DETECTION_SCRIPT, context)
await expect(context.navigator.permissions.query({ name })).resolves.toEqual({
state: 'denied',
onchange: null
})
}
)
it('reports notification permission as granted after a site permission request succeeds', async () => {
const context = createContext({
nativeNotificationPermission: 'denied',
requestedNotificationPermission: 'granted'
})
runInNewContext(ANTI_DETECTION_SCRIPT, context)
expect(context.Notification.permission).toBe('default')
await expect(context.navigator.permissions.query({ name: 'notifications' })).resolves.toEqual({
state: 'prompt',
onchange: null
})
await expect(context.Notification.requestPermission()).resolves.toBe('granted')
expect(context.Notification.permission).toBe('granted')
await expect(context.navigator.permissions.query({ name: 'notifications' })).resolves.toEqual({
state: 'granted',
onchange: null
})
})
it('preserves notification permission when Electron already reports a grant', async () => {
const context = createContext({
nativeNotificationPermission: 'granted',
requestedNotificationPermission: 'granted'
})
runInNewContext(ANTI_DETECTION_SCRIPT, context)
expect(context.Notification.permission).toBe('granted')
await expect(context.navigator.permissions.query({ name: 'notifications' })).resolves.toEqual({
state: 'granted',
onchange: null
})
})
})
-161
View File
@@ -1,161 +0,0 @@
// Why: Cloudflare Turnstile and similar bot detectors probe multiple browser
// APIs beyond navigator.webdriver. This script runs via
// Page.addScriptToEvaluateOnNewDocument before any page JS to mask automation
// signals that CDP debugger attachment and Electron's webview expose.
export const ANTI_DETECTION_SCRIPT = `(function() {
Object.defineProperty(navigator, 'webdriver', { get: () => false });
// Why: Electron webviews expose an empty plugins array. Real Chrome always
// has at least a few default plugins (PDF Viewer, etc.). An empty array is
// a strong automation signal.
if (navigator.plugins.length === 0) {
Object.defineProperty(navigator, 'plugins', {
get: () => [
{ name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer' },
{ name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai' },
{ name: 'Native Client', filename: 'internal-nacl-plugin' }
]
});
}
// Why: auth hosts present Firefox, where Electron's native window.chrome is an identity mismatch.
if (navigator.userAgent.includes('Firefox/')) {
try {
delete window.chrome;
if ('chrome' in window) {
window.chrome = undefined;
}
} catch {}
} else {
// Why: Electron webviews may not have the window.chrome object that real
// Chrome exposes. Turnstile checks for its presence. The csi() and
// loadTimes() stubs satisfy deeper probes of Chrome-specific APIs.
if (!window.chrome) {
window.chrome = {};
}
if (!window.chrome.csi) {
window.chrome.csi = function() {
return {
startE: Date.now(),
onloadT: Date.now(),
pageT: performance.now(),
tran: 15
};
};
}
if (!window.chrome.loadTimes) {
window.chrome.loadTimes = function() {
return {
commitLoadTime: Date.now() / 1000,
connectionInfo: 'h2',
finishDocumentLoadTime: Date.now() / 1000,
finishLoadTime: Date.now() / 1000,
firstPaintAfterLoadTime: 0,
firstPaintTime: Date.now() / 1000,
navigationType: 'Other',
npnNegotiatedProtocol: 'h2',
requestTime: Date.now() / 1000 - 0.16,
startLoadTime: Date.now() / 1000 - 0.3,
wasAlternateProtocolAvailable: false,
wasFetchedViaSpdy: true,
wasNpnNegotiated: true
};
};
}
}
// Why: Electron's Permission API defaults to 'denied' for most permissions,
// but real Chrome returns 'prompt' for ungranted permissions. Returning
// 'denied' is a strong bot signal. Override the query result for common
// permissions that Turnstile and similar detectors probe.
var notificationPermission = 'default';
var setNotificationPermission = function(permission) {
if (permission === 'granted' || permission === 'denied') {
notificationPermission = permission;
return permission;
}
notificationPermission = 'default';
return 'default';
};
var notificationPermissionState = function() {
return notificationPermission === 'default' ? 'prompt' : notificationPermission;
};
try {
if (Notification.permission === 'granted') {
notificationPermission = 'granted';
}
} catch {}
const promptPerms = new Set([
'camera', 'microphone'
]);
const origQuery = Permissions.prototype.query;
// Why: sites must receive the genuine PermissionStatus so native events, brand checks and method
// identity survive. Shadow only state, and resolve it lazily so existing statuses stay current.
function withOverriddenState(realStatus, stateProvider) {
Object.defineProperty(realStatus, 'state', {
configurable: true,
get: stateProvider
});
return realStatus;
}
// Why: some names the real implementation rejects outright; fall back to an EventTarget so
// listener registration still works instead of throwing.
function fallbackStatus(stateProvider) {
const status = new EventTarget();
Object.defineProperties(status, {
state: { configurable: true, get: stateProvider },
onchange: { configurable: true, value: null, writable: true }
});
return status;
}
function queryWithState(permissions, desc, stateProvider) {
let real;
try {
real = origQuery.call(permissions, desc);
} catch {
return Promise.resolve(fallbackStatus(stateProvider));
}
return Promise.resolve(real).then(
(status) => withOverriddenState(status, stateProvider),
() => fallbackStatus(stateProvider)
);
}
Permissions.prototype.query = function(desc) {
if (desc.name === 'notifications') {
return queryWithState(this, desc, notificationPermissionState);
}
if (promptPerms.has(desc.name)) {
return queryWithState(this, desc, () => 'prompt');
}
return origQuery.call(this, desc);
};
// Why: Electron may report Notification.permission as 'denied' by default
// whereas real Chrome reports 'default' for sites that haven't been granted
// or blocked. Turnstile cross-references this with the Permissions API.
try {
Object.defineProperty(Notification, 'permission', {
get: () => notificationPermission
});
const origRequestPermission = Notification.requestPermission;
if (typeof origRequestPermission === 'function') {
Notification.requestPermission = function(callback) {
var wrappedCallback = typeof callback === 'function'
? function(permission) {
callback(setNotificationPermission(permission));
}
: undefined;
var result = origRequestPermission.call(Notification, wrappedCallback);
if (result && typeof result.then === 'function') {
return result.then(function(permission) {
return setNotificationPermission(permission);
});
}
return result;
};
}
} catch {}
// Why: Electron webviews may have an empty languages array. Real Chrome
// always has at least one entry. An empty array is an automation signal.
if (!navigator.languages || navigator.languages.length === 0) {
Object.defineProperty(navigator, 'languages', {
get: () => ['en-US', 'en']
});
}
})()`
+4 -4
View File
@@ -1,12 +1,12 @@
// Why: Google binds a signed-in session to the browser identity that created it.
// Cookies copied in from another browser (or sent under an Electron/Chrome-shaped
// UA that doesn't match a real first-party browser) get flagged by anti-fraud on
// accounts.google.com and expire within ~1h. Presenting a Firefox identity scoped
// Cookies copied in from another browser (or sent under a UA that doesn't match a
// real first-party browser) get flagged by anti-fraud on accounts.google.com and
// expire within ~1h. Presenting a Firefox identity scoped
// to Google's auth hosts lets the user sign in *inside* the embedded browser, so
// Google issues cookies bound to THIS browser that self-refresh — instead of us
// transplanting cookies that go stale. Scope is deliberately the auth hosts only:
// post-auth app surfaces (mail.google.com, myaccount.google.com, drive, etc.) keep
// the profile's real Chrome-shaped identity so nothing else about the session shifts.
// the profile's real identity so nothing else about the session shifts.
// Why: exact hostname match — subdomains such as myaccount.google.com are post-auth
// app surfaces, not the sign-in flow, and must retain the profile's real identity.
@@ -51,7 +51,7 @@ import {
import {
createViewportGuestFactory,
flushViewportOps,
GUEST_CLEAN_UA
GUEST_ELECTRON_UA
} from './browser-manager-viewport-test-fixtures'
const {
@@ -197,8 +197,9 @@ describe('browserManager', () => {
// Why: popup child windows get attachGuestPolicies but are never entered into tabIdByWebContentsId,
// so a direct lookup of the UA mode misses the native opt-out. That is worse than doing nothing —
// native sessions skip setupClientHintsOverride, so the popup would send the raw Electron UA on the
// wire while navigator.userAgent claimed Firefox. Google sign-in popups are a first-class surface.
// native sessions never install the header-level Firefox switch, so the popup would send the
// Electron UA on the wire while navigator.userAgent claimed Firefox. Google sign-in popups are a
// first-class surface.
it('leaves the UA untouched on auth hosts for a popup owned by a native-UA profile', () => {
const ownerGuest = {
id: 415,
@@ -543,7 +544,7 @@ describe('browserManager', () => {
)
expect(uaWrites.length).toBeGreaterThan(0)
for (const [, params] of uaWrites) {
expect((params as { userAgent: string }).userAgent).toBe(GUEST_CLEAN_UA)
expect((params as { userAgent: string }).userAgent).toBe(GUEST_ELECTRON_UA)
}
})
})
@@ -637,9 +637,9 @@ describe('browserManager', () => {
).toHaveLength(2)
})
it('cancels pending anti-detection reattach timers when unregistering a guest', () => {
vi.useFakeTimers()
// Why: a plain browsing tab must never attach a debugger (Cloudflare treats CDP as a bot signal);
// the only debugger wiring it keeps is the detach listener that invalidates the auth-host UA override.
it('never attaches a debugger to a browsing guest and drops its detach listener on unregister', () => {
const debuggerHandlers = new Map<string, () => void>()
const debuggerAttachMock = vi.fn()
const guest = {
@@ -670,18 +670,17 @@ describe('browserManager', () => {
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({
browserPageId: 'browser-reattach',
browserPageId: 'browser-no-debugger',
webContentsId: 809,
rendererWebContentsId
})
debuggerHandlers.get('detach')?.()
expect(vi.getTimerCount()).toBe(1)
expect(debuggerAttachMock).not.toHaveBeenCalled()
expect(guest.debugger.sendCommand).not.toHaveBeenCalled()
expect(debuggerHandlers.has('detach')).toBe(true)
browserManager.unregisterGuest('browser-reattach')
expect(vi.getTimerCount()).toBe(0)
vi.advanceTimersByTime(500)
expect(debuggerAttachMock).toHaveBeenCalledTimes(1)
browserManager.unregisterGuest('browser-no-debugger')
expect(debuggerHandlers.has('detach')).toBe(false)
expect(debuggerAttachMock).not.toHaveBeenCalled()
})
})
@@ -52,6 +52,8 @@ type GuestFake = {
isAttached: () => boolean
attach: ReturnType<typeof vi.fn>
sendCommand: ReturnType<typeof vi.fn>
on: ReturnType<typeof vi.fn>
off: ReturnType<typeof vi.fn>
}
on: (event: string, listener: (...args: never[]) => void) => void
once: (event: string, listener: (...args: never[]) => void) => void
@@ -81,7 +83,9 @@ function createGuest(id: number, url: string): GuestFake {
debugger: {
isAttached: () => true,
attach: vi.fn(),
sendCommand: vi.fn(async () => undefined)
sendCommand: vi.fn(async () => undefined),
on: vi.fn(),
off: vi.fn()
},
on: (event, listener) => {
listeners.set(event, [...(listeners.get(event) ?? []), listener])
@@ -143,7 +147,7 @@ describe('guest policy profiles', () => {
// The presence half of every absence below: a browsing guest observably takes all of it through
// the same method, so a profile that fenced nothing — or an attach path that stopped installing
// anything at all — cannot pass these by being uniformly empty.
it('gives a browsing guest link routing, popups and anti-detection', () => {
it('gives a browsing guest link routing, popups and auth-identity detach tracking', () => {
const guest = createGuest(300, 'https://example.com/')
browserManager.attachGuestPolicies(guest as never)
@@ -151,7 +155,10 @@ describe('guest policy profiles', () => {
expect(listenerCount(guest, 'dom-ready')).toBe(1)
expect(listenerCount(guest, 'frame-created')).toBe(1)
expect(listenerCount(guest, 'did-create-window')).toBe(1)
expect(guest.debugger.sendCommand).toHaveBeenCalled()
expect(guest.debugger.on).toHaveBeenCalledWith('detach', expect.any(Function))
// Why: a plain browsing tab must never attach a debugger; Cloudflare treats CDP as a bot signal.
expect(guest.debugger.attach).not.toHaveBeenCalled()
expect(guest.debugger.sendCommand).not.toHaveBeenCalled()
expect(navigateTo(guest, 'https://elsewhere.example/')).toBe(false)
})
@@ -161,6 +168,7 @@ describe('guest policy profiles', () => {
expect(listenerCount(guest, 'dom-ready')).toBe(0)
expect(listenerCount(guest, 'frame-created')).toBe(0)
expect(listenerCount(guest, 'did-create-window')).toBe(0)
expect(guest.debugger.on).not.toHaveBeenCalled()
expect(guest.debugger.sendCommand).not.toHaveBeenCalled()
expect(guest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled()
})
@@ -35,8 +35,7 @@ export abstract class BrowserManagerGuestPolicy extends BrowserManagerGuestClean
this.clickedLinkFrameNameByGuestId.set(guest.id, clickedLinkFrameName)
}
// Why: bot detectors probe APIs that differ in Electron webviews; inject overrides each load so manual browsing passes.
const disposeAntiDetection = this.injectAntiDetection(guest)
const disposeAuthDetachTracking = this.trackDebuggerDetachForAuthUserAgent(guest)
// Why: disable throttling so background screenshots still get frames; else the compositor stalls and capture returns empty.
guest.setBackgroundThrottling(false)
const disposePopupPolicy = this.installGuestPopupPolicy(guest, clickedLinkFrameName)
@@ -44,14 +43,14 @@ export abstract class BrowserManagerGuestPolicy extends BrowserManagerGuestClean
// Why: store cleanup so unregisterGuest can drop these listeners on teardown and let the WebContents wrapper GC.
this.policyCleanupByGuestId.set(guest.id, () => {
disposeAntiDetection()
disposeAuthDetachTracking()
disposePopupPolicy()
disposeNavigationPolicy()
})
}
/**
* A workspace document is not the web: no popups, no link routing, no anti-detection, and no
* A workspace document is not the web: no popups, no link routing, no auth-identity tracking, and no
* navigation bookkeeping for chrome it does not have. What it does share with a browsing guest is
* this method's teardown, so a retired preview drops its listeners on the same path.
*/
+10 -12
View File
@@ -1,5 +1,4 @@
import { openPopupWithOriginBar, type PopupChildWindowOptions } from './popup-origin-bar-window'
import { cleanElectronUserAgent } from './browser-session-ua'
import { getBrowserSessionUserAgentMode } from './browser-session-user-agent-mode'
import { googleAuthUserAgent, isGoogleAuthUrl } from './browser-google-auth-ua'
import { buildViewportUserAgentOverride } from './browser-viewport-user-agent'
@@ -12,10 +11,10 @@ import { BrowserManagerVisibility } from './browser-manager-visibility'
export abstract class BrowserManagerNavigation extends BrowserManagerVisibility {
// Why: navigator.userAgent (read by Google's auth JS) reflects the WebContents UA,
// not the request header, so the header-level Firefox switch in setupClientHintsOverride
// not the request header, so the header-level Firefox switch in setupGoogleAuthUserAgentOverride
// must be matched here per navigation or the two layers disagree — itself a bot tell.
// Restores the session's base identity off the auth hosts. Native-UA profiles opt out
// of the whole clean-UA path, so they keep their untouched identity everywhere.
// of the Firefox switch, so they keep their untouched identity everywhere.
protected applyGoogleAuthUserAgent(
guest: Electron.WebContents,
url: string,
@@ -24,8 +23,8 @@ export abstract class BrowserManagerNavigation extends BrowserManagerVisibility
const browserPageId = this.tabIdByWebContentsId.get(guest.id)
// Why: popup child windows get these policies but are never in tabIdByWebContentsId, so a direct
// lookup misses the native-UA opt-out and would hand a native profile's popup the Firefox UA.
// That is worse than doing nothing: native sessions skip setupClientHintsOverride entirely, so
// the popup would send the raw Electron UA on the wire while navigator.userAgent claims Firefox.
// That is worse than doing nothing: native sessions never install the header-level Firefox
// switch, so the popup would send the Electron UA on the wire while navigator.userAgent claims Firefox.
const ownerTabId = this.resolveBrowserTabIdForGuestWebContentsId(guest.id)
// Session state is authoritative before renderer registration and after a native profile imports a source UA.
const mode =
@@ -56,15 +55,14 @@ export abstract class BrowserManagerNavigation extends BrowserManagerVisibility
// navigation (ERR_ABORTED) and replay the original request, which a POST-started OAuth chain
// cannot survive — the sign-in lands on a blank tab. CDP retargets navigator.userAgent without
// touching the navigation, and it outranks the WebContents UA from then on, so a guest that
// switches to it stays on it. The wire UA never depended on this write: setupClientHintsOverride
// rewrites User-Agent per request for auth-host URLs on its own.
// switches to it stays on it. The wire UA never depended on this write:
// setupGoogleAuthUserAgentOverride rewrites User-Agent per request for auth-host URLs on its own.
if (options.duringRedirect === true || overrideState !== undefined) {
if (this.canOverrideUserAgentOverCdp(guest)) {
authOverrideIssuedOverCdp = true
// Why: go through the viewport builder rather than writing nextUa raw, so both CDP writers
// resolve one identity for this URL — Firefox on auth hosts, the profile's clean base off
// them, any mobile preset preserved. Writing the session UA directly would put the
// unlaundered Electron token back on the wire.
// resolve one identity for this URL — Firefox on auth hosts, the session's base identity
// off them, any mobile preset preserved.
void this.applyAuthUserAgentOverrideOverCdp(
guest,
(browserPageId ? this.viewportUaOverrideMobileByTabId.get(browserPageId) : undefined) ??
@@ -188,7 +186,7 @@ export abstract class BrowserManagerNavigation extends BrowserManagerVisibility
// Why: Emulation.setUserAgentOverride is set once and stands across every later navigation,
// outranking setUserAgent for navigator.userAgent. A viewport preset applied before reaching an
// auth host would otherwise pin navigator.userAgent to the Chrome-shaped preset UA while the
// auth host would otherwise pin navigator.userAgent to the session's preset UA while the
// request header says Firefox — the two-layer disagreement this scope exists to remove.
protected reapplyViewportUserAgentOverride(
guest: Electron.WebContents,
@@ -222,7 +220,7 @@ export abstract class BrowserManagerNavigation extends BrowserManagerVisibility
// Why: the session UA is the profile's stable base identity. guest.getUserAgent() is not:
// applyGoogleAuthUserAgent leaves it pinned to the Firefox auth UA once a guest switches to
// the CDP override, so reading it back here would republish that identity on ordinary hosts.
baseUserAgent: cleanElectronUserAgent(baseUserAgent ?? guest.session.getUserAgent())
baseUserAgent: baseUserAgent ?? guest.session.getUserAgent()
})
)
}
+5 -43
View File
@@ -1,4 +1,3 @@
import { ANTI_DETECTION_SCRIPT } from './anti-detection'
import { BrowserGrabSessionController } from './browser-grab-session-controller'
import type { BrowserCertificateTrustController } from './browser-certificate-trust-controller'
import {
@@ -190,56 +189,19 @@ export abstract class BrowserManagerState extends BrowserManagerViewportScrollSt
this.settingsResolver = resolver
}
// Why: addScriptToEvaluateOnNewDocument (CDP) is the only reliable pre-page-script hook per nav; executeJavaScript ran on the old page context.
protected injectAntiDetection(guest: Electron.WebContents): () => void {
let disposed = false
let reattachTimer: ReturnType<typeof setTimeout> | null = null
const attach = (): void => {
if (disposed || guest.isDestroyed()) {
return
}
try {
if (!guest.debugger.isAttached()) {
guest.debugger.attach('1.3')
}
void guest.debugger
.sendCommand('Page.enable', {})
.then(() =>
guest.debugger.sendCommand('Page.addScriptToEvaluateOnNewDocument', {
source: ANTI_DETECTION_SCRIPT
})
)
.catch(() => {})
} catch {
/* best-effort — debugger may be unavailable */
}
}
// Why: proxy/bridge stop detaches the debugger and drops injections; re-attach (500ms delay to avoid racing a mid-restart) to keep overrides.
// Why: a debugger detach clears every CDP override Chromium holds, including the Google auth-host
// UA override, so the confirmed-override record must be dropped or the next auth navigation
// believes the identity is still installed and skips the write.
protected trackDebuggerDetachForAuthUserAgent(guest: Electron.WebContents): () => void {
const onDetach = (): void => {
this.authUserAgentOverrideStateByGuestId.delete(guest.id)
if (!disposed && !guest.isDestroyed() && reattachTimer === null) {
reattachTimer = setTimeout(() => {
reattachTimer = null
attach()
}, 500)
}
}
try {
attach()
guest.debugger.on('detach', onDetach)
} catch {
/* best-effort */
/* debugger may be unavailable */
}
return () => {
disposed = true
if (reattachTimer !== null) {
clearTimeout(reattachTimer)
reattachTimer = null
}
try {
guest.debugger.off('detach', onDetach)
} catch {
+1 -1
View File
@@ -117,7 +117,7 @@ export type PopupOwnerContext = {
/**
* What a guest is allowed to be. A browsing guest is the web — popups, clicked-link routing and
* anti-detection all apply. A workspace-document guest renders one granted document and gets none
* auth-identity tracking all apply. A workspace-document guest renders one granted document and gets none
* of that; `host` is the renderer that minted its grant, and the only sink for what it reports.
*/
export type BrowserGuestPolicy =
@@ -49,7 +49,6 @@ import {
import {
createViewportGuestFactory,
flushViewportOps,
GUEST_CLEAN_UA,
GUEST_ELECTRON_UA
} from './browser-manager-viewport-test-fixtures'
@@ -207,7 +206,7 @@ describe('browserManager', () => {
mobile: false
})
expect(debuggerSendCommand).toHaveBeenLastCalledWith('Emulation.setUserAgentOverride', {
userAgent: GUEST_CLEAN_UA
userAgent: GUEST_ELECTRON_UA
})
// Navigating to the auth host must move the standing override to the Firefox identity.
@@ -218,11 +217,11 @@ describe('browserManager', () => {
userAgent: googleAuthUserAgent()
})
// Leaving the auth host restores the clean Chrome-shaped preset UA.
// Leaving the auth host restores the session's own preset UA.
debuggerSendCommand.mockClear()
willRedirect({ preventDefault: vi.fn() }, 'https://example.com/', false, true)
await flushViewportOps()
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA })
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_ELECTRON_UA })
})
// Why: not an ordering race — debugger.sendCommand dispatches in call order over one channel, so
@@ -241,9 +240,9 @@ describe('browserManager', () => {
}
// Why mobile: on the desktop branch the break is masked by coincidence — applyGoogleAuthUserAgent
// has already switched the WebContents UA to Firefox, and cleanElectronUserAgent passes a Firefox
// UA through untouched, so the stale-URL desktop path happens to emit Firefox anyway. The mobile
// branch derives a Chrome-shaped iPhone UA from that same base and exposes the real defect.
// has already switched the WebContents UA to Firefox, so the stale-URL desktop path happens to
// emit Firefox anyway. The mobile branch derives a Chrome-shaped iPhone UA from the session base
// and exposes the real defect.
it('does not leave the Chrome preset UA standing when a mobile preset lands mid-navigation onto an auth host', async () => {
const { guest, debuggerSendCommand } = makeGuest(4251, 'https://example.com/')
// Hold the preset's first CDP command open so the navigation lands inside its await window.
@@ -332,7 +331,7 @@ describe('browserManager', () => {
// Without the fix the resuming preset re-reads getURL() as the auth host and clobbers the
// navigation's correct write, stranding the Firefox UA on a non-auth page.
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA })
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_ELECTRON_UA })
})
it('falls back to the committed URL once a navigation commits or fails', async () => {
@@ -378,7 +377,7 @@ describe('browserManager', () => {
await flushViewportOps()
expect(guest.setUserAgent).toHaveBeenLastCalledWith(GUEST_ELECTRON_UA)
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA })
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_ELECTRON_UA })
// A later preset must also resolve the committed, non-auth URL.
debuggerSendCommand.mockClear()
@@ -457,7 +456,7 @@ describe('browserManager', () => {
expect(guest.setUserAgent).not.toHaveBeenCalled()
expect(debuggerSendCommand).not.toHaveBeenCalledWith(
'Emulation.setUserAgentOverride',
expect.objectContaining({ userAgent: GUEST_CLEAN_UA })
expect.objectContaining({ userAgent: GUEST_ELECTRON_UA })
)
})
@@ -517,7 +516,7 @@ describe('browserManager', () => {
didFailLoad(null, -3, 'Aborted', 'https://accounts.google.com/redirected', true)
await flushViewportOps()
expect(guest.setUserAgent).not.toHaveBeenCalled()
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA })
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_ELECTRON_UA })
})
it('preserves the auth identity when a viewport preset is cleared after a redirect', async () => {
@@ -592,7 +591,7 @@ describe('browserManager', () => {
didStartNavigation(null, 'https://example.com/', false, true)
await flushViewportOps()
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA })
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_ELECTRON_UA })
})
it('reapplies a preset when navigation starts during its final UA write', async () => {
@@ -849,8 +848,7 @@ describe('browserManager', () => {
expect(debuggerAttach).toHaveBeenCalledWith('1.3')
expect(debuggerSendCommand).toHaveBeenCalled()
// Why: detaching would clear Page.addScriptToEvaluateOnNewDocument
// (anti-detection). Guard regression.
// Why: detaching would clear every standing CDP override (viewport, auth UA). Guard regression.
expect((guest.debugger as { detach?: unknown }).detach ?? undefined).toBeUndefined()
})
@@ -3,8 +3,6 @@ import type { BrowserManagerMocks } from './browser-manager-test-harness'
export const GUEST_ELECTRON_UA =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) orca/1.0.0 Chrome/134.0.0.0 Electron/30.0.0 Safari/537.36'
export const GUEST_CLEAN_UA =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36'
// Why: viewport UA writes are queued on the per-tab chain, so draining it takes more than one
// microtask hop; loop until the chain is empty rather than guessing a tick count.
@@ -53,7 +51,9 @@ export function createViewportGuestFactory(
debugger: {
isAttached: debuggerIsAttached,
attach: debuggerAttach,
sendCommand: debuggerSendCommand
sendCommand: debuggerSendCommand,
on: vi.fn(),
off: vi.fn()
}
}
return {
+1 -1
View File
@@ -30,7 +30,7 @@ export abstract class BrowserManagerViewport extends BrowserManagerDownloadLifec
return true
}
// Why: emulate viewport via CDP; never detach the debugger here or per-guest overrides (addScriptToEvaluateOnNewDocument) are cleared.
// Why: emulate viewport via CDP; never detach the debugger here or the agent bridge's per-guest state is cleared.
async setViewportOverride(
browserTabId: string,
override: BrowserViewportOverride | null
@@ -82,8 +82,7 @@ vi.mock('./browser-media-access', () => ({
requestSystemMediaAccess: async () => false
}))
vi.mock('./browser-session-ua', () => ({
cleanElectronUserAgent: (userAgent: string) => userAgent,
setupClientHintsOverride: vi.fn()
setupGoogleAuthUserAgentOverride: vi.fn()
}))
vi.mock('./browser-session-user-agent-mode', () => ({
setBrowserSessionUserAgentMode: vi.fn()
@@ -9,7 +9,7 @@ import {
} from './browser-session-proxy'
import { hasSystemMediaAccess, requestSystemMediaAccess } from './browser-media-access'
import { isAutoGrantedBrowserSessionPermission } from './browser-session-permission-policy'
import { cleanElectronUserAgent, setupClientHintsOverride } from './browser-session-ua'
import { setupGoogleAuthUserAgentOverride } from './browser-session-ua'
import { setBrowserSessionUserAgentMode } from './browser-session-user-agent-mode'
import {
allowsBrowserWebAuthnPermission,
@@ -92,10 +92,8 @@ export function installBrowserSessionPartitionPolicies(
}
browserManager.installCertificateRequestGuard(sess)
if (profile.userAgentMode !== 'native' && typeof sess.getUserAgent === 'function') {
const cleanUA = cleanElectronUserAgent(sess.getUserAgent())
sess.setUserAgent(cleanUA)
setupClientHintsOverride(sess, cleanUA)
if (profile.userAgentMode !== 'native') {
setupGoogleAuthUserAgentOverride(sess)
}
if (options?.permissions === 'deny') {
sess.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false))
@@ -191,11 +189,7 @@ export function applyBrowserSessionUserAgentModes(profiles: BrowserSessionProfil
if (profile.userAgentMode === 'native') {
continue
}
// Why: the default Electron UA leaks "Electron/X.X.X" + app name, which trips Cloudflare Turnstile.
const cleanUA = cleanElectronUserAgent(sess.getUserAgent())
sess.setUserAgent(cleanUA)
setupClientHintsOverride(sess, cleanUA)
setupGoogleAuthUserAgentOverride(sess)
} catch {
/* session not available yet (e.g. unit tests or pre-ready) */
}
@@ -44,8 +44,7 @@ vi.mock('./browser-media-access', () => ({
requestSystemMediaAccess: vi.fn(async () => false)
}))
vi.mock('./browser-session-ua', () => ({
cleanElectronUserAgent: vi.fn((ua: string) => ua),
setupClientHintsOverride: vi.fn()
setupGoogleAuthUserAgentOverride: vi.fn()
}))
vi.mock('./browser-session-user-agent-mode', () => ({
setBrowserSessionUserAgentMode: vi.fn(),
@@ -27,7 +27,7 @@ function installModuleMocks(
copyFailures = new Set<string>()
): {
sessionFromPartitionMock: ReturnType<typeof vi.fn>
setupClientHintsOverrideMock: ReturnType<typeof vi.fn>
setupGoogleAuthUserAgentOverrideMock: ReturnType<typeof vi.fn>
browserManagerHandleGuestWillDownloadMock: ReturnType<typeof vi.fn>
browserManagerNotifyPermissionDeniedMock: ReturnType<typeof vi.fn>
requestSystemMediaAccessMock: ReturnType<typeof vi.fn>
@@ -36,6 +36,7 @@ function installModuleMocks(
partition,
setUserAgent: vi.fn(),
getUserAgent: vi.fn(() => 'Mozilla/5.0 Electron/31 Orca'),
webRequest: { onBeforeSendHeaders: vi.fn() },
setPermissionRequestHandler: vi.fn(),
setPermissionCheckHandler: vi.fn(),
setDevicePermissionHandler: vi.fn(),
@@ -45,7 +46,7 @@ function installModuleMocks(
clearStorageData: vi.fn().mockResolvedValue(undefined),
clearCache: vi.fn().mockResolvedValue(undefined)
}))
const setupClientHintsOverrideMock = vi.fn()
const setupGoogleAuthUserAgentOverrideMock = vi.fn()
const browserManagerHandleGuestWillDownloadMock = vi.fn()
const browserManagerNotifyPermissionDeniedMock = vi.fn()
const requestSystemMediaAccessMock = vi.fn().mockResolvedValue(true)
@@ -119,8 +120,7 @@ function installModuleMocks(
requestSystemMediaAccess: requestSystemMediaAccessMock
}))
vi.doMock('./browser-session-ua', () => ({
cleanElectronUserAgent: vi.fn((ua: string) => ua.replace(/\s*Electron\/\S+/, '')),
setupClientHintsOverride: setupClientHintsOverrideMock
setupGoogleAuthUserAgentOverride: setupGoogleAuthUserAgentOverrideMock
}))
// This suite models replay with an in-memory filesystem. The real file-backed SQLite merge has
// dedicated coverage; these fixtures are legacy unmarked images and keep the copy path.
@@ -149,7 +149,7 @@ function installModuleMocks(
return {
sessionFromPartitionMock,
setupClientHintsOverrideMock,
setupGoogleAuthUserAgentOverrideMock,
browserManagerHandleGuestWillDownloadMock,
browserManagerNotifyPermissionDeniedMock,
requestSystemMediaAccessMock
@@ -234,21 +234,24 @@ describe('BrowserSessionRegistry persistence', () => {
})
})
it('keeps UA cleaning as the fallback for profiles without an override', async () => {
// Why: the stock Electron UA is what clears Cloudflare; only the Google auth switch installs.
it('keeps the stock UA and installs the Google auth switch for profiles without an override', async () => {
const fsState = createFsState()
const { sessionFromPartitionMock, setupClientHintsOverrideMock } = installModuleMocks(fsState)
const { sessionFromPartitionMock, setupGoogleAuthUserAgentOverrideMock } =
installModuleMocks(fsState)
const { browserSessionRegistry } = await import('./browser-session-registry')
await browserSessionRegistry.createProfile('isolated', 'Default identity')
const profileSession = sessionFromPartitionMock.mock.results.at(-1)?.value
expect(profileSession.setUserAgent).toHaveBeenCalledWith('Mozilla/5.0 Orca')
expect(setupClientHintsOverrideMock).toHaveBeenCalledWith(profileSession, 'Mozilla/5.0 Orca')
expect(profileSession.setUserAgent).not.toHaveBeenCalled()
expect(setupGoogleAuthUserAgentOverrideMock).toHaveBeenCalledWith(profileSession)
})
it('leaves UA and client hints untouched for native-mode profiles', async () => {
const fsState = createFsState()
const { sessionFromPartitionMock, setupClientHintsOverrideMock } = installModuleMocks(fsState)
const { sessionFromPartitionMock, setupGoogleAuthUserAgentOverrideMock } =
installModuleMocks(fsState)
const { browserSessionRegistry } = await import('./browser-session-registry')
await browserSessionRegistry.createProfile('isolated', 'Google', { userAgentMode: 'native' })
@@ -256,7 +259,7 @@ describe('BrowserSessionRegistry persistence', () => {
const profileSession = sessionFromPartitionMock.mock.results.at(-1)?.value
const { getBrowserSessionUserAgentMode } = await import('./browser-session-user-agent-mode')
expect(profileSession.setUserAgent).not.toHaveBeenCalled()
expect(setupClientHintsOverrideMock).not.toHaveBeenCalled()
expect(setupGoogleAuthUserAgentOverrideMock).not.toHaveBeenCalled()
expect(getBrowserSessionUserAgentMode(profileSession as never)).toBe('native')
})
@@ -379,7 +382,7 @@ describe('BrowserSessionRegistry persistence', () => {
// Why: imports before Aug 2026 persisted a synthesized source-browser UA
// (fork imports as a broken Chrome/1.x, Chrome imports as a valid version).
// Neither may ever be applied again — the engine-derived UA is the only one.
it('ignores legacy persisted UAs, valid or broken, and applies the engine UA', async () => {
it('ignores legacy persisted UAs, valid or broken, and keeps the engine UA', async () => {
const importedPartition = 'persist:orca-browser-session-11111111-1111-4111-8111-111111111111'
const brokenUa =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/1.158.1 Safari/537.36'
@@ -405,7 +408,8 @@ describe('BrowserSessionRegistry persistence', () => {
]
})
const { sessionFromPartitionMock, setupClientHintsOverrideMock } = installModuleMocks(fsState)
const { sessionFromPartitionMock, setupGoogleAuthUserAgentOverrideMock } =
installModuleMocks(fsState)
const { browserSessionRegistry } = await import('./browser-session-registry')
browserSessionRegistry.initializeBrowserSessionsFromPersistedState()
@@ -413,16 +417,9 @@ describe('BrowserSessionRegistry persistence', () => {
const appliedUas = sessionFromPartitionMock.mock.results.flatMap((r) =>
r.value.setUserAgent.mock.calls.map((c: unknown[]) => c[0])
)
expect(appliedUas).not.toContain(brokenUa)
expect(appliedUas).not.toContain(validUa)
// Why: every non-native profile falls to Orca's own cleaned engine UA.
expect(appliedUas.length).toBeGreaterThan(0)
expect(appliedUas.every((ua) => ua === 'Mozilla/5.0 Orca')).toBe(true)
expect(
setupClientHintsOverrideMock.mock.calls.every(
(c: unknown[]) => c[1] !== brokenUa && c[1] !== validUa
)
).toBe(true)
// Why: no persisted UA is ever written back; every profile keeps the engine's stock UA.
expect(appliedUas).toEqual([])
expect(setupGoogleAuthUserAgentOverrideMock).toHaveBeenCalled()
})
it('never applies a legacy persisted UA to a native-mode profile', async () => {
@@ -487,7 +484,8 @@ describe('BrowserSessionRegistry persistence', () => {
]
})
const { sessionFromPartitionMock, setupClientHintsOverrideMock } = installModuleMocks(fsState)
const { sessionFromPartitionMock, setupGoogleAuthUserAgentOverrideMock } =
installModuleMocks(fsState)
const { browserSessionRegistry } = await import('./browser-session-registry')
browserSessionRegistry.initializeBrowserSessionsFromPersistedState()
@@ -498,7 +496,7 @@ describe('BrowserSessionRegistry persistence', () => {
expect(importedSessions.length).toBeGreaterThan(0)
expect(importedSessions.every((sess) => sess.setUserAgent.mock.calls.length === 0)).toBe(true)
expect(
setupClientHintsOverrideMock.mock.calls.some(
setupGoogleAuthUserAgentOverrideMock.mock.calls.some(
([sess]) => (sess as { partition?: string }).partition === importedPartition
)
).toBe(false)
+31 -151
View File
@@ -33,7 +33,7 @@ vi.mock('./browser-manager', () => ({
import { browserSessionRegistry } from './browser-session-registry'
import { googleAuthUserAgent } from './browser-google-auth-ua'
import { setupClientHintsOverride } from './browser-session-ua'
import { setupGoogleAuthUserAgentOverride } from './browser-session-ua'
import { setBrowserNetworkProxySettingsResolver } from './browser-session-proxy'
import { handleElectronProxyLogin } from '../network/electron-proxy-credentials'
import { applyProxySettingsToSession } from '../network/proxy-settings'
@@ -54,6 +54,7 @@ describe('BrowserSessionRegistry', () => {
askForMediaAccessMock.mockResolvedValue(true)
getMediaAccessStatusMock.mockReturnValue('granted')
sessionFromPartitionMock.mockReturnValue({
webRequest: { onBeforeSendHeaders: vi.fn() },
setPermissionRequestHandler: vi.fn(),
setPermissionCheckHandler: vi.fn(),
setDevicePermissionHandler: vi.fn(),
@@ -528,80 +529,46 @@ describe('BrowserSessionRegistry', () => {
})
})
describe('setupClientHintsOverride', () => {
it('overrides sec-ch-ua headers for Edge UA', () => {
describe('setupGoogleAuthUserAgentOverride', () => {
const STOCK_UA =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) orca/1.0.0 Chrome/147.0.6890.3 Electron/43.0.0 Safari/537.36'
function install(): (details: unknown, callback: ReturnType<typeof vi.fn>) => void {
const onBeforeSendHeaders = vi.fn()
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
const edgeUa =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36 Edg/147.0.3210.5'
setupClientHintsOverride(mockSess, edgeUa)
setupGoogleAuthUserAgentOverride({ webRequest: { onBeforeSendHeaders } } as never)
expect(onBeforeSendHeaders).toHaveBeenCalledWith(
{ urls: ['https://*/*'] },
expect.any(Function)
)
return onBeforeSendHeaders.mock.calls[0][1]
}
// Why: the Electron token is what clears Cloudflare Turnstile; a Chrome-shaped UA with no
// client hints is what it rejects, so ordinary hosts must see the session's UA untouched.
it('leaves the stock Electron UA and its client hints alone off the auth hosts', () => {
const listener = install()
const callback = vi.fn()
const listener = onBeforeSendHeaders.mock.calls[0][1]
listener(
{ requestHeaders: { 'sec-ch-ua': 'old', 'sec-ch-ua-full-version-list': 'old' } },
{
url: 'https://example.com/api',
requestHeaders: { 'User-Agent': STOCK_UA, 'sec-ch-ua': 'old', Cookie: 'abc=123' }
},
callback
)
const modified = callback.mock.calls[0][0].requestHeaders
expect(modified['sec-ch-ua']).toContain('Microsoft Edge')
expect(modified['sec-ch-ua']).toContain('"147"')
expect(modified['sec-ch-ua-full-version-list']).toContain('147.0.3210.5')
})
it('overrides sec-ch-ua headers for Chrome UA', () => {
const onBeforeSendHeaders = vi.fn()
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
const chromeUa =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36'
setupClientHintsOverride(mockSess, chromeUa)
const callback = vi.fn()
const listener = onBeforeSendHeaders.mock.calls[0][1]
listener({ requestHeaders: { 'sec-ch-ua': 'old' } }, callback)
const modified = callback.mock.calls[0][0].requestHeaders
expect(modified['sec-ch-ua']).toContain('Google Chrome')
expect(modified['sec-ch-ua']).not.toContain('Microsoft Edge')
})
it('registers handler even for non-Chrome UA but leaves sec-ch-ua untouched off auth hosts', () => {
const onBeforeSendHeaders = vi.fn()
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
// Why: the Google-auth Firefox switch must install regardless of the base UA.
setupClientHintsOverride(mockSess, 'Mozilla/5.0 (compatible; MSIE 10.0)')
expect(onBeforeSendHeaders).toHaveBeenCalledWith(
{ urls: ['https://*/*'] },
expect.any(Function)
)
const callback = vi.fn()
const listener = onBeforeSendHeaders.mock.calls[0][1]
listener({ url: 'https://example.com/', requestHeaders: { 'sec-ch-ua': 'old' } }, callback)
expect(callback.mock.calls[0][0].requestHeaders['sec-ch-ua']).toBe('old')
expect(modified['User-Agent']).toBe(STOCK_UA)
expect(modified['sec-ch-ua']).toBe('old')
expect(modified.Cookie).toBe('abc=123')
})
it('presents a Firefox UA and strips client hints on Google auth hosts', () => {
const onBeforeSendHeaders = vi.fn()
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
setupClientHintsOverride(
mockSess,
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36'
)
const listener = install()
const callback = vi.fn()
const listener = onBeforeSendHeaders.mock.calls[0][1]
listener(
{
url: 'https://accounts.google.com/v3/signin/identifier',
requestHeaders: {
'User-Agent': 'Chrome/147',
'User-Agent': STOCK_UA,
'sec-ch-ua': 'old',
'sec-ch-ua-full-version-list': 'old',
'sec-ch-ua-platform': '"macOS"'
@@ -610,7 +577,7 @@ describe('BrowserSessionRegistry', () => {
callback
)
const modified = callback.mock.calls[0][0].requestHeaders
expect(modified['User-Agent']).toMatch(/Firefox\/\d/)
expect(modified['User-Agent']).toBe(googleAuthUserAgent())
expect(modified['User-Agent']).not.toContain('Chrome')
expect(modified['sec-ch-ua']).toBeUndefined()
expect(modified['sec-ch-ua-full-version-list']).toBeUndefined()
@@ -618,15 +585,8 @@ describe('BrowserSessionRegistry', () => {
})
it('strips client hints on a cross-host request that carries the Firefox auth UA', () => {
const onBeforeSendHeaders = vi.fn()
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
setupClientHintsOverride(
mockSess,
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36'
)
const listener = install()
const callback = vi.fn()
const listener = onBeforeSendHeaders.mock.calls[0][1]
// Subresource/XHR to a non-auth Google host while the auth document is on
// screen: the WebContents Firefox UA leaks onto the request header.
listener(
@@ -651,99 +611,19 @@ describe('BrowserSessionRegistry', () => {
expect(modified['sec-ch-ua-mobile']).toBeUndefined()
})
it('keeps the clean Chrome identity on cross-host requests that carry the Chrome UA', () => {
const onBeforeSendHeaders = vi.fn()
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
const chromeUa =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36'
setupClientHintsOverride(mockSess, chromeUa)
it('keeps the session identity on Google app subdomains (not auth hosts)', () => {
const listener = install()
const callback = vi.fn()
const listener = onBeforeSendHeaders.mock.calls[0][1]
// Regression guard: non-Google sites (Cloudflare) must keep Chrome hints.
listener(
{
url: 'https://example.com/api',
requestHeaders: { 'User-Agent': chromeUa, 'sec-ch-ua': 'old' }
},
callback
)
expect(callback.mock.calls[0][0].requestHeaders['sec-ch-ua']).toContain('Google Chrome')
})
it('does not strip hints for the Firefox UA when googleAuthOverride is disabled', () => {
const onBeforeSendHeaders = vi.fn()
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
const chromeUa =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36'
setupClientHintsOverride(mockSess, chromeUa, { googleAuthOverride: false })
const callback = vi.fn()
const listener = onBeforeSendHeaders.mock.calls[0][1]
listener(
{
url: 'https://play.google.com/log',
requestHeaders: { 'User-Agent': googleAuthUserAgent(), 'sec-ch-ua': 'old' }
},
callback
)
// Imported-native profiles never install the Firefox switch, so the strip
// branch stays inert and hints are aligned to Chrome instead.
expect(callback.mock.calls[0][0].requestHeaders['sec-ch-ua']).toContain('Google Chrome')
})
it('keeps Chrome client hints on Google app subdomains (not auth hosts)', () => {
const onBeforeSendHeaders = vi.fn()
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
setupClientHintsOverride(
mockSess,
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36'
)
const callback = vi.fn()
const listener = onBeforeSendHeaders.mock.calls[0][1]
listener(
{ url: 'https://myaccount.google.com/', requestHeaders: { 'sec-ch-ua': 'old' } },
callback
)
expect(callback.mock.calls[0][0].requestHeaders['sec-ch-ua']).toContain('Google Chrome')
})
it('keeps an imported native UA on auth hosts while aligning its Chrome hints', () => {
const onBeforeSendHeaders = vi.fn()
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
const importedUa =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36'
setupClientHintsOverride(mockSess, importedUa, { googleAuthOverride: false })
const callback = vi.fn()
const listener = onBeforeSendHeaders.mock.calls[0][1]
listener(
{
url: 'https://accounts.google.com/v3/signin/identifier',
requestHeaders: { 'User-Agent': importedUa, 'sec-ch-ua': 'old' }
url: 'https://myaccount.google.com/',
requestHeaders: { 'User-Agent': STOCK_UA, 'sec-ch-ua': 'old' }
},
callback
)
const modified = callback.mock.calls[0][0].requestHeaders
expect(modified['User-Agent']).toBe(importedUa)
expect(modified['sec-ch-ua']).toContain('Google Chrome')
})
it('leaves non-Client-Hints headers unchanged', () => {
const onBeforeSendHeaders = vi.fn()
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
setupClientHintsOverride(mockSess, 'Mozilla/5.0 Chrome/147.0.0.0 Safari/537.36')
const callback = vi.fn()
const listener = onBeforeSendHeaders.mock.calls[0][1]
listener(
{ requestHeaders: { Cookie: 'abc=123', 'sec-ch-ua': 'old', Accept: 'text/html' } },
callback
)
const modified = callback.mock.calls[0][0].requestHeaders
expect(modified.Cookie).toBe('abc=123')
expect(modified.Accept).toBe('text/html')
expect(modified['User-Agent']).toBe(STOCK_UA)
expect(modified['sec-ch-ua']).toBe('old')
})
})
})
@@ -0,0 +1,180 @@
import { spawnSync } from 'node:child_process'
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, describe, expect, it } from 'vitest'
import { build as buildVite } from 'vite'
// Why this runs a real Electron: Cloudflare Turnstile rejects a Chrome-shaped UA that ships no
// client hints (error 600010) and clears a declared Electron client. The header layer is the
// only place that identity can be proven, and the vm-based unit tests cannot see Chromium's
// header emission at all. Every partition must therefore keep the stock Electron UA on the wire
// for ordinary hosts and present the Firefox identity on Google's sign-in hosts only.
const electronBinary = createRequire(import.meta.url)('electron') as string
const fixtureRoots: string[] = []
afterAll(() => {
for (const root of fixtureRoots) {
rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
}
})
// Retry once when Electron startup times out before `ready`; keep later failures fatal.
const FIXTURE_LAUNCH_ATTEMPTS = 2
type CapturedRequest = {
url: string
userAgent: string | null
clientHints: string[]
}
type FixtureResult = {
sessionUserAgent: string
navigatorUserAgent: string
requests: CapturedRequest[]
}
function neverReachedElectronReady(fixtureResult: string): boolean {
try {
return (JSON.parse(fixtureResult) as { step?: string }).step === 'timed out after starting'
} catch {
return false
}
}
function buildFixtureMain(modulePath: string, resultPath: string): string {
return `
const { app, BrowserWindow, session } = require('electron')
const { writeFileSync } = require('node:fs')
const { setupGoogleAuthUserAgentOverride } = require(${JSON.stringify(modulePath)})
const resultPath = ${JSON.stringify(resultPath)}
let currentStep = 'starting'
const mark = (step) => {
currentStep = step
writeFileSync(resultPath, JSON.stringify({ step }))
}
async function run() {
const timeout = setTimeout(() => {
writeFileSync(resultPath, JSON.stringify({ step: 'timed out after ' + currentStep }))
app.exit(1)
}, 15000)
await app.whenReady()
mark('ready')
const partition = 'persist:wire-identity-test'
const sess = session.fromPartition(partition)
setupGoogleAuthUserAgentOverride(sess)
mark('auth switch installed')
// Why: onSendHeaders reports the headers exactly as they leave the network stack, after the
// product's onBeforeSendHeaders listener has rewritten them. The requests must actually be
// dispatched for it to fire, so the session is pointed at a proxy that refuses every
// connection: nothing reaches the real hosts and every load fails fast.
await sess.setProxy({ proxyRules: 'http://127.0.0.1:9', proxyBypassRules: '<-loopback>' })
const requests = []
sess.webRequest.onSendHeaders({ urls: ['https://*/*'] }, (details) => {
const headers = details.requestHeaders || {}
const uaKey = Object.keys(headers).find((key) => key.toLowerCase() === 'user-agent')
requests.push({
url: details.url,
userAgent: uaKey ? headers[uaKey] : null,
clientHints: Object.keys(headers)
.filter((key) => key.toLowerCase().startsWith('sec-ch-ua'))
.sort()
})
})
const window = new BrowserWindow({ show: false, webPreferences: { partition } })
mark('window created')
for (const url of ['https://example.com/', 'https://accounts.google.com/v3/signin/identifier']) {
await window.loadURL(url).catch(() => {})
}
mark('navigations attempted')
const navigatorUserAgent = await window.webContents.executeJavaScript('navigator.userAgent')
clearTimeout(timeout)
writeFileSync(resultPath, JSON.stringify({
sessionUserAgent: sess.getUserAgent(),
navigatorUserAgent,
requests
}))
window.destroy()
app.exit(0)
}
run().catch((error) => {
writeFileSync(resultPath, JSON.stringify({ step: currentStep, error: String(error?.stack || error) }))
app.exit(1)
})
`
}
async function runFixture(): Promise<FixtureResult> {
const root = mkdtempSync(join(tmpdir(), 'orca-wire-identity-'))
fixtureRoots.push(root)
const modulePath = join(root, 'browser-session-ua.cjs')
const resultPath = join(root, 'result.json')
const fixturePath = join(root, 'main.cjs')
await buildVite({
configFile: false,
logLevel: 'silent',
build: {
emptyOutDir: false,
lib: {
entry: join(process.cwd(), 'src/main/browser/browser-session-ua.ts'),
formats: ['cjs'],
fileName: () => 'browser-session-ua.cjs'
},
outDir: root,
target: 'node20',
rollupOptions: { external: ['electron', /^node:/] }
}
})
writeFileSync(fixturePath, buildFixtureMain(modulePath, resultPath))
const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env
const executable = process.platform === 'linux' ? 'xvfb-run' : electronBinary
for (let attempt = 1; ; attempt += 1) {
rmSync(resultPath, { force: true })
// Why a fresh profile per attempt: a launch that never reached `ready` may have left the
// Chromium profile mid-initialization, and reusing it would bias the retry.
const electronArgs = [fixturePath, `--user-data-dir=${join(root, `profile-${attempt}`)}`]
const run = spawnSync(
executable,
process.platform === 'linux'
? ['--auto-servernum', electronBinary, ...electronArgs, '--no-sandbox']
: electronArgs,
{ encoding: 'utf8', env, timeout: 60_000 }
)
const fixtureResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result'
if (attempt < FIXTURE_LAUNCH_ATTEMPTS && neverReachedElectronReady(fixtureResult)) {
continue
}
expect(run.error).toBeUndefined()
expect(run.status, `${fixtureResult}\n${run.stdout}\n${run.stderr}`).toBe(0)
return JSON.parse(fixtureResult) as FixtureResult
}
}
describe('browser session wire identity under Electron', () => {
it('sends the stock Electron UA to ordinary hosts and Firefox to Google auth hosts', async () => {
const result = await runFixture()
// Presence precondition: the stock identity still carries the Electron token that the old
// Chrome-shaped rewrite stripped, so an identity check below cannot pass on an empty UA.
expect(result.sessionUserAgent).toMatch(/ Electron\/\d/)
const ordinary = result.requests.find((request) => request.url === 'https://example.com/')
expect(ordinary, JSON.stringify(result.requests)).toBeDefined()
expect(ordinary?.userAgent).toBe(result.sessionUserAgent)
expect(result.navigatorUserAgent).toBe(result.sessionUserAgent)
const auth = result.requests.find((request) =>
request.url.startsWith('https://accounts.google.com/')
)
expect(auth, JSON.stringify(result.requests)).toBeDefined()
expect(auth?.userAgent).toMatch(/Firefox\/\d/)
expect(auth?.userAgent).not.toContain('Chrome')
expect(auth?.clientHints).toEqual([])
})
})
+11 -76
View File
@@ -8,93 +8,28 @@ import {
stripClientHints
} from './browser-google-auth-ua'
// Why: Electron's default UA includes "Electron/X.X.X" and the app name
// (e.g. "orca/1.2.3"), which Cloudflare Turnstile and other bot detectors
// flag as non-human traffic. Strip those tokens so the webview's UA and
// sec-ch-ua Client Hints look like standard Chrome.
export function cleanElectronUserAgent(ua: string): string {
return (
ua
.replace(/\s+Electron\/\S+/, '')
// Why: \S+ matches any non-whitespace token (e.g. "orca/1.3.8-rc.0")
// including pre-release semver strings that [\d.]+ would miss.
.replace(/(\)\s+)\S+\s+(Chrome\/)/, '$1$2')
)
}
// Why: Electron emits sec-ch-ua brands like "Not A(Brand" without a
// "Google Chrome" entry, which disagrees with the Chrome-shaped UA the session
// presents. Rewrite the hint headers to the brand set Chrome ships for the same
// engine version so the two surfaces tell one story. Also owns the Google
// auth-host Firefox switch, which must install even for a non-Chrome-shaped UA.
export function setupClientHintsOverride(
sess: Session,
ua: string,
options: { googleAuthOverride?: boolean } = {}
): void {
// Why: only Chrome-shaped base UAs carry sec-ch-ua hints to rewrite, but the
// Google-auth Firefox switch below must install regardless, so keep the hints
// optional rather than bailing out of the whole handler.
const chromeHints = buildChromeClientHints(ua)
// Why: the session keeps Electron's stock UA. Stripping the Electron/app tokens to look like
// plain Chrome is what Cloudflare Turnstile rejects (error 600010): a Chrome UA that ships no
// client hints reads as a spoof, while a declared Electron client clears the same challenge.
// This handler only owns the Google auth-host Firefox switch, which is a proven, host-scoped
// exception that must stay consistent across the header and every cross-host subresource.
export function setupGoogleAuthUserAgentOverride(sess: Session): void {
const firefoxUa = googleAuthUserAgent()
sess.webRequest.onBeforeSendHeaders({ urls: ['https://*/*'] }, (details, callback) => {
const headers = details.requestHeaders
if (options.googleAuthOverride !== false && isGoogleAuthUrl(details.url)) {
if (isGoogleAuthUrl(details.url)) {
// Why: present a Firefox identity on Google's sign-in hosts so the user logs
// in inside the app and Google issues self-refreshing bound cookies. Strip
// sec-ch-ua* because real Firefox sends none.
setUserAgentHeader(headers, firefoxUa)
stripClientHints(headers)
callback({ requestHeaders: headers })
return
}
if (options.googleAuthOverride !== false && currentUserAgent(headers) === firefoxUa) {
// Why: while the auth document is on screen the WebContents UA is Firefox,
// so its cross-host subresource/XHR requests (gstatic, play.google.com, the
// sign-in challenge endpoints) reach here carrying the Firefox UA yet still
// bearing Chromium client hints. Rewriting those to Chrome pairs a Firefox
// UA with Chrome hints — a sharper cross-host identity tell than either
// alone, which can stall Google's password-submit challenge. Real Firefox
// sends no client hints, so strip them to keep one identity for the flow.
} else if (currentUserAgent(headers) === firefoxUa) {
// Why: while the auth document is on screen the WebContents UA is Firefox, so its
// cross-host subresource/XHR requests carry the Firefox UA yet still bear Chromium
// client hints — a sharper cross-host identity tell than either alone.
stripClientHints(headers)
callback({ requestHeaders: headers })
return
}
if (chromeHints) {
for (const key of Object.keys(headers)) {
const lower = key.toLowerCase()
if (lower === 'sec-ch-ua') {
headers[key] = chromeHints.secChUa
} else if (lower === 'sec-ch-ua-full-version-list') {
headers[key] = chromeHints.secChUaFull
}
}
}
callback({ requestHeaders: headers })
})
}
function buildChromeClientHints(ua: string): { secChUa: string; secChUaFull: string } | null {
const chromeMatch = ua.match(/Chrome\/([\d.]+)/)
if (!chromeMatch) {
return null
}
const fullChromeVersion = chromeMatch[1]
const majorVersion = fullChromeVersion.split('.')[0]
let brand = 'Google Chrome'
let brandFullVersion = fullChromeVersion
const edgeMatch = ua.match(/Edg\/([\d.]+)/)
if (edgeMatch) {
brand = 'Microsoft Edge'
brandFullVersion = edgeMatch[1]
}
const brandMajor = brandFullVersion.split('.')[0]
return {
secChUa: `"${brand}";v="${brandMajor}", "Chromium";v="${majorVersion}", "Not/A)Brand";v="24"`,
secChUaFull: `"${brand}";v="${brandFullVersion}", "Chromium";v="${fullChromeVersion}", "Not/A)Brand";v="24.0.0.0"`
}
}
@@ -23,7 +23,7 @@ export type ViewportUserAgentOverride = {
}
// Why: responsive sites UA-sniff; this is Chrome DevTools' default iPhone UA template with the real
// Chrome major spliced in to keep sec-ch-ua consistent (see setupClientHintsOverride).
// Chrome major spliced in so the userAgentMetadata brands below agree with it.
function buildMobileUserAgent(chromeMajor: string): string {
return `Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/${chromeMajor}.0.0.0 Mobile/15E148 Safari/604.1`
}
@@ -44,7 +44,7 @@ export function buildViewportUserAgentOverride(args: {
return { userAgent: googleAuthUserAgent() }
}
if (!args.mobile) {
// Why: desktop presets still need the clean (non-Electron) UA so Cloudflare/Turnstile don't flag the session.
// Why: desktop presets republish the session's own identity unchanged.
return { userAgent: args.baseUserAgent }
}
const chromeMajor = extractChromeMajor(args.baseUserAgent)
@@ -48,7 +48,8 @@ function mockSession(): MockSession {
setDevicePermissionHandler: vi.fn(),
setDisplayMediaRequestHandler: vi.fn(),
setPermissionCheckHandler: vi.fn(),
setPermissionRequestHandler: vi.fn()
setPermissionRequestHandler: vi.fn(),
webRequest: { onBeforeSendHeaders: vi.fn() }
}) as unknown as MockSession
}
-7
View File
@@ -1,6 +1,5 @@
import { WebSocket } from 'ws'
import type { WebContents } from 'electron'
import { ANTI_DETECTION_SCRIPT } from './anti-detection'
import { acquireElectronDebugger, type ElectronDebuggerLease } from './electron-debugger-lease'
import type { CdpClientResponseWriter } from './cdp-client-response-writer'
import type { CdpSyntheticSessionRegistry } from './cdp-synthetic-session-registry'
@@ -34,14 +33,8 @@ export class CdpDebuggerChannel {
}
this.attached = true
// Why: attaching the CDP debugger sets navigator.webdriver = true and
// exposes other automation signals that Cloudflare Turnstile checks.
// Inject before any page loads so challenges succeed.
try {
await this.webContents.debugger.sendCommand('Page.enable', {})
await this.webContents.debugger.sendCommand('Page.addScriptToEvaluateOnNewDocument', {
source: ANTI_DETECTION_SCRIPT
})
} catch {
/* best-effort — page domain may not be ready yet */
}
+3 -1
View File
@@ -32,9 +32,11 @@ export function createCdpDebuggerMessageListener(
| undefined
if (p?.sessionId && p.targetInfo?.type === 'iframe' && p.targetInfo.targetId) {
state.iframeSessions.set(p.targetInfo.targetId, p.sessionId)
// Why: no Runtime.enable here. Cross-origin iframes include challenge widgets
// (Cloudflare Turnstile), and the Runtime domain's console/Error.stack serialization
// is the CDP tell they detect; nothing reads iframe Runtime events anyway.
guest.debugger.sendCommand('DOM.enable', {}, p.sessionId).catch(() => {})
guest.debugger.sendCommand('Accessibility.enable', {}, p.sessionId).catch(() => {})
guest.debugger.sendCommand('Runtime.enable', {}, p.sessionId).catch(() => {})
}
}
if (method === 'Target.detachedFromTarget') {
@@ -1,5 +1,4 @@
import type { WebContents } from 'electron'
import { ANTI_DETECTION_SCRIPT } from './anti-detection'
import { BrowserError } from './browser-error'
import type { CdpTabState } from './cdp-auxiliary-commands'
import type { CdpCommandSender } from './snapshot-engine'
@@ -62,11 +61,6 @@ export class CdpDebuggerLifecycle {
flatten: true
})
// Why: CDP attach exposes automation signals (navigator.webdriver) that Cloudflare checks; override per new document.
await sender('Page.addScriptToEvaluateOnNewDocument', {
source: ANTI_DETECTION_SCRIPT
})
// Why: only remove this bridge's listeners; screencast/proxy sessions share the debugger and own their teardown.
this.removeDebuggerListeners(guest, state)
@@ -52,7 +52,6 @@ describe('CdpWsProxy DOM.focus replay', () => {
expect(mock.webContents.focus).toHaveBeenCalledTimes(1)
expect(getSendCommandCalls(mock)).toEqual([
['Page.enable', {}],
['Page.addScriptToEvaluateOnNewDocument', expect.any(Object)],
['DOM.focus', { backendNodeId: 99 }],
['DOM.focus', { backendNodeId: 99 }],
['Input.insertText', { text: 'hello' }]
@@ -80,7 +79,6 @@ describe('CdpWsProxy DOM.focus replay', () => {
expect(insertResponse.result).toEqual({})
expect(getSendCommandCalls(mock)).toEqual([
['Page.enable', {}],
['Page.addScriptToEvaluateOnNewDocument', expect.any(Object)],
['DOM.focus', { backendNodeId: 123 }, 'oopif-session-123'],
['DOM.focus', { backendNodeId: 123 }, 'oopif-session-123'],
['Input.insertText', { text: 'frame text' }, 'oopif-session-123']
@@ -112,7 +110,6 @@ describe('CdpWsProxy DOM.focus replay', () => {
expect(mock.webContents.focus).toHaveBeenCalledTimes(1)
expect(getSendCommandCalls(mock)).toEqual([
['Page.enable', {}],
['Page.addScriptToEvaluateOnNewDocument', expect.any(Object)],
['DOM.focus', { backendNodeId: 44 }],
['Runtime.callFunctionOn', { functionDeclaration: '() => document.activeElement?.id' }],
['Input.insertText', { text: 'after eval' }]
@@ -155,7 +152,6 @@ describe('CdpWsProxy DOM.focus replay', () => {
expect(mock.webContents.focus).toHaveBeenCalledTimes(1)
expect(getSendCommandCalls(mock)).toEqual([
['Page.enable', {}],
['Page.addScriptToEvaluateOnNewDocument', expect.any(Object)],
['DOM.focus', { backendNodeId: 55 }],
['Input.insertText', { text: 'fallback' }]
])
@@ -197,7 +193,6 @@ describe('CdpWsProxy DOM.focus replay', () => {
expect(mock.webContents.focus).toHaveBeenCalledTimes(1)
expect(getSendCommandCalls(mock)).toEqual([
['Page.enable', {}],
['Page.addScriptToEvaluateOnNewDocument', expect.any(Object)],
['DOM.focus', { backendNodeId: 77 }],
['DOM.focus', { backendNodeId: 77 }]
])
@@ -242,7 +237,6 @@ describe('CdpWsProxy DOM.focus replay', () => {
expect(insertResponse?.result).toEqual({})
expect(getSendCommandMethods(mock)).toEqual([
'Page.enable',
'Page.addScriptToEvaluateOnNewDocument',
'DOM.focus',
'DOM.focus',
'Input.insertText'
@@ -270,12 +264,7 @@ describe('CdpWsProxy DOM.focus replay', () => {
// Why: both Page.bringToFront and Input.insertText natively call focus(),
// independent of the (now-cleared) DOM.focus replay.
expect(mock.webContents.focus).toHaveBeenCalledTimes(2)
expect(getSendCommandMethods(mock)).toEqual([
'Page.enable',
'Page.addScriptToEvaluateOnNewDocument',
'DOM.focus',
'Input.insertText'
])
expect(getSendCommandMethods(mock)).toEqual(['Page.enable', 'DOM.focus', 'Input.insertText'])
client.close()
})
@@ -298,7 +287,6 @@ describe('CdpWsProxy DOM.focus replay', () => {
expect(insertResponse.result).toEqual({})
expect(getSendCommandMethods(mock)).toEqual([
'Page.enable',
'Page.addScriptToEvaluateOnNewDocument',
'DOM.focus',
'Page.captureScreenshot',
'Input.insertText'
@@ -322,12 +310,7 @@ describe('CdpWsProxy DOM.focus replay', () => {
expect(insertResponse.id).toBe(34)
expect(insertResponse.result).toEqual({})
expect(getSendCommandMethods(mock)).toEqual([
'Page.enable',
'Page.addScriptToEvaluateOnNewDocument',
'DOM.focus',
'Input.insertText'
])
expect(getSendCommandMethods(mock)).toEqual(['Page.enable', 'DOM.focus', 'Input.insertText'])
second.close()
})
+4 -14
View File
@@ -400,11 +400,7 @@ describe('CdpWsProxy', () => {
})
expect(mock.webContents.focus).toHaveBeenCalledTimes(1)
expect(getSendCommandMethods(mock)).toEqual([
'Page.enable',
'Page.addScriptToEvaluateOnNewDocument',
'Input.insertText'
])
expect(getSendCommandMethods(mock)).toEqual(['Page.enable', 'Input.insertText'])
client.close()
})
@@ -421,7 +417,6 @@ describe('CdpWsProxy', () => {
expect(response.result).toEqual({})
expect(getSendCommandMethods(mock)).toEqual([
'Page.enable',
'Page.addScriptToEvaluateOnNewDocument',
'Network.enable',
'Page.enable',
'Page.setLifecycleEventsEnabled',
@@ -442,7 +437,6 @@ describe('CdpWsProxy', () => {
expect(response.result).toEqual({})
expect(getSendCommandMethods(mock)).toEqual([
'Page.enable',
'Page.addScriptToEvaluateOnNewDocument',
'Network.enable',
'Page.enable',
'Page.setLifecycleEventsEnabled'
@@ -462,7 +456,7 @@ describe('CdpWsProxy', () => {
sessionId: 'iframe-session-123'
})
expect(getSendCommandCalls(mock).slice(2)).toEqual([
expect(getSendCommandCalls(mock).slice(1)).toEqual([
['Network.enable', {}, 'iframe-session-123'],
['Page.enable', {}, 'iframe-session-123'],
['Page.setLifecycleEventsEnabled', { enabled: true }, 'iframe-session-123'],
@@ -481,7 +475,7 @@ describe('CdpWsProxy', () => {
sessionId: 'iframe-session-123'
})
expect(getSendCommandCalls(mock).slice(2)).toEqual([
expect(getSendCommandCalls(mock).slice(1)).toEqual([
['Network.enable', {}, 'iframe-session-123'],
['Page.enable', {}, 'iframe-session-123'],
['Page.setLifecycleEventsEnabled', { enabled: true }, 'iframe-session-123'],
@@ -562,11 +556,7 @@ describe('CdpWsProxy', () => {
expect(response.id).toBe(13)
expect(response.result).toEqual({})
expect(getSendCommandMethods(mock)).toEqual([
'Page.enable',
'Page.addScriptToEvaluateOnNewDocument',
'Runtime.evaluate'
])
expect(getSendCommandMethods(mock)).toEqual(['Page.enable', 'Runtime.evaluate'])
client.close()
})
@@ -2,6 +2,7 @@ import { spawn, type ChildProcess, type ChildProcessWithoutNullStreams } from 'n
import { waitForProcessExitUntil } from './codex-process-exit-deadline'
import { stderrIndicatesMissingAppServer } from './codex-app-server-capability-signal'
import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution'
import { admitProcessTreeKill } from '../../shared/child-process/process-tree-kill-gate'
// Why: `codex app-server` is Orca's sanctioned RPC surface into Codex-owned
// state (hook trust hashes, the sqlite thread index). This module owns the
@@ -74,6 +75,18 @@ export function killCodexAppServerProcessTree(
const platform = options.platform ?? process.platform
const spawnImpl = options.spawnImpl ?? spawn
if (platform === 'win32' && child.pid) {
if (
!admitProcessTreeKill({
pid: child.pid,
site: 'codex-app-server-session-deadline',
scope: 'win-taskkill-tree'
})
) {
// Refusal blocks the tree walk, not the termination: the root kill is
// handle-addressed, so it cannot reach the recycled pid we refused.
child.kill('SIGKILL')
return
}
try {
// Why: npm-installed Codex runs behind cmd.exe; killing only that wrapper
// leaves the app-server child alive after a timeout or failed shutdown.
@@ -57,6 +57,8 @@ async function terminateWindowsAddedProcesses(
const added = current.filter((row) => baseline.get(row.pid) !== windowsIdentity(row))
const addedPids = new Set(added.map((row) => row.pid))
const roots = added.filter((row) => !addedPids.has(row.ppid))
// Added roots come from a table walk, not a spawn, so a refused tree walk has
// no handle to fall back to: the row stays in `remaining` and this reports false.
await Promise.all(
roots.map((row) => terminateWindowsProcessTree(row.pid, { site: 'codex-turn-added-roots' }))
)
@@ -17,17 +17,17 @@ import { recordProcessGoneCrash, type ProcessGoneCrashEvent } from './process-go
import { resetProcessGoneSiblingCorrelationForTest } from './process-gone-sibling-correlation'
import {
findSelfInitiatedTreeKills,
installProcessTreeKillBreadcrumbObserver,
recordRefusedOwnChromiumTreeKill,
recordSelfInitiatedTreeKill,
resetSelfInitiatedTreeKillLogForTest,
selfInitiatedTreeKillDetails
} from './self-initiated-tree-kill-log'
import {
notifyProcessTreeKill,
setProcessTreeKillObserver
} from '../../shared/child-process/process-tree-kill-observer'
admitProcessTreeKill,
setProcessTreeKillGate
} from '../../shared/child-process/process-tree-kill-gate'
import { terminateWindowsProcessTree } from '../windows-process-tree-kill'
import { installMainProcessTreeKillGate } from '../own-chromium-tree-kill-guard'
import { _resetTracerForTests, setActiveSink } from '../observability/tracer'
/** The field shape: renderer, `reason=killed exitCode=1`, win32 (#G2). */
@@ -252,12 +252,90 @@ describe('self-initiated tree kill breadcrumb', () => {
expect(String(details.selfInitiatedKills)).toContain('more)')
})
it('records a kill issued through the shared runProcess choke point', () => {
installProcessTreeKillBreadcrumbObserver()
it('keeps the pid-addressed kill when a window-close burst overruns the ring', () => {
// Review probe: one taskkill, then 32 routine Job Object teardowns. Under
// plain FIFO the discriminating entry is evicted and the persisted detail
// becomes byte-identical to the external-kill arm.
const goneAt = 5_000_000
recordSelfInitiatedTreeKill({
pid: 4242,
site: 'pty-descendant-sweep',
scope: 'win-taskkill-tree',
at: goneAt - 4_000
})
for (let index = 0; index < 32; index += 1) {
recordSelfInitiatedTreeKill({
pid: 6000 + index,
site: 'windows-pty-job-teardown',
scope: 'win-pty-job',
at: goneAt - 100
})
}
notifyProcessTreeKill({ pid: 3131, site: 'run-process-tree', scope: 'posix-process-group' })
const details = selfInitiatedTreeKillDetails(goneAt)
expect(details.selfInitiatedTreeKillCount).toBe(1)
expect(details.selfInitiatedGroupKillCount).toBe(31)
expect(String(details.selfInitiatedKills)).toMatch(
/^win-taskkill-tree\/pty-descendant-sweep\/pid4242 -4000ms/
)
})
it('keeps the newest teardown when a session has saturated the ring with pid kills', () => {
// Review probe, the mirror of the case above: 32 session-old taskkills (six
// routine families feed them) then the Job Object teardown 50ms before the
// death. A scope-preference eviction with no floor splices the entry it just
// pushed, and `{}` is byte-identical to the external-kill arm.
const goneAt = 5_000_000
for (let index = 0; index < 32; index += 1) {
recordSelfInitiatedTreeKill({
pid: 6000 + index,
site: 'pty-descendant-sweep',
scope: 'win-taskkill-tree',
at: goneAt - 600_000 + index * 1_000
})
}
recordSelfInitiatedTreeKill({
pid: 7777,
site: 'windows-pty-job-teardown',
scope: 'win-pty-job',
at: goneAt - 50
})
const details = selfInitiatedTreeKillDetails(goneAt)
expect(details.selfInitiatedGroupKillCount).toBe(1)
expect(String(details.selfInitiatedKills)).toContain(
'win-pty-job/windows-pty-job-teardown/pid7777 -50ms'
)
})
it('evicts the oldest pid kill, not the newest, once every candidate is pid-addressed', () => {
const goneAt = 5_000_000
for (let index = 0; index < 33; index += 1) {
recordSelfInitiatedTreeKill({
pid: 6000 + index,
site: 'git-command-tree-kill',
scope: 'win-taskkill-tree',
at: goneAt - 1_000
})
}
const pids = findSelfInitiatedTreeKills(goneAt).map((kill) => kill.pid)
expect(pids).toHaveLength(32)
expect(pids).toContain(6032)
expect(pids).not.toContain(6000)
})
it('records a kill issued through the shared runProcess choke point', () => {
installMainProcessTreeKillGate()
expect(
admitProcessTreeKill({ pid: 3131, site: 'run-process-tree', scope: 'posix-process-group' })
).toBe(true)
expect(findSelfInitiatedTreeKills(Date.now()).map((kill) => kill.pid)).toEqual([3131])
setProcessTreeKillObserver(null)
setProcessTreeKillGate(null)
})
})
@@ -1,8 +1,5 @@
import type { CrashReportDetailValue } from '../../shared/crash-reporting'
import {
setProcessTreeKillObserver,
type ProcessTreeKillScope
} from '../../shared/child-process/process-tree-kill-observer'
import type { ProcessTreeKillScope } from '../../shared/child-process/process-tree-kill-gate'
import { recordCoalescedDurableCrashBreadcrumb } from './durable-crash-breadcrumb'
/**
@@ -19,24 +16,38 @@ import { recordCoalescedDurableCrashBreadcrumb } from './durable-crash-breadcrum
* The ring is per-process and its only reader is `process-gone-recorder`, which
* exists in Electron main. So a count reported on a `render-process-gone` covers
* kills issued *from Electron main*, and nothing else:
* - Main only: the three `taskkill /T /F` families that gate on
* `admitSelfInitiatedTreeKill` (`terminateWindowsProcessTree` and the codex /
* claude account-login teardowns) and the codex app-server POSIX group
* - Main only: the families that import the gate directly —
* `terminateWindowsProcessTree`, the codex and claude account-login
* teardowns, the git command-runner abort, the notebook-cell and
* automation-precheck timeouts — plus the codex app-server POSIX group
* teardowns.
* - Main *and* other hosts: `signalProcessTree` (the `runProcess` choke point,
* reached from the CLI, relay and daemon too — a fourth pid-addressed
* `taskkill` family, gated on the child not being reaped rather than on the
* Chromium set it cannot read), the POSIX PTY process-group sweep and the
* Windows PTY Job Object (relay `pty-handler`, daemon
* `subprocess-handle`). When those run outside main they record into that
* process's own ring, which nothing reads — no observer is installed there,
* and the tracer sink is a no-op.
* - Never instrumented: the direct `process.kill(-pid)` calls in the browser
* routes, notebooks, automation prechecks and ephemeral-VM recipes.
* - Main *and* other hosts, through the `process-tree-kill-gate` seam main
* installs the same guard into: `signalProcessTree` (the `runProcess` choke
* point, reached from the CLI, relay and daemon too), the codex app-server
* deadline kill (compiled into the CLI as well) and the ephemeral-VM recipe
* kill. Also host-spanning but recording directly: the POSIX PTY
* process-group sweep and the Windows PTY Job Object (relay `pty-handler`,
* daemon `subprocess-handle`). When any of these run outside main they record
* into that process's own ring, which nothing reads — no gate is installed
* there, and the tracer sink is a no-op.
* - Never instrumented, and none of them a pid-addressed kill issued from main:
* the POSIX `process.kill(-pid, …)` group arms of the notebook, precheck,
* browser-route and ephemeral-VM kills, plus the macOS keyboard-input-source
* probe's group kill in `ipc/app.ts`; the relay's own
* `subprocess-tree-termination` taskkill and the CLI's login-interruption
* taskkill (neither runs in main); and the browser-route Electron probes,
* which are reached only from `*.electron.test.ts`.
*
* `main-process-tree-kill-gate.test.ts` is the ratchet that keeps that list
* closed: it counts `/pid` call sites against gate admissions per file, so a new
* pid-addressed kill fails it whether it lands in a new file or inside a family
* that already asks the gate. It does not see a `/pid` argument built from a
* variable.
*
* A daemon or relay kill missing from the count is a diagnostics gap, not a
* missed suspect: those hosts cannot reach a Chromium pid in the first place
* (see `orca-chromium-process-pids.ts`). Absence is evidence, not proof.
* (see `orca-chromium-process-pids.ts`), and a group or Job-Object kill can
* only contain what Orca put in it. Absence is evidence, not proof.
*/
/** Which mechanism issued the kill; each has a different blast radius. */
@@ -81,6 +92,25 @@ function isPidAddressedTreeKill(scope: SelfInitiatedTreeKillScope): boolean {
return scope === 'win-taskkill-tree'
}
/**
* Drop one entry, newest-first-preserving.
*
* Two rules, in order. The entry just recorded is never a candidate: it is the
* one closest to any death that follows, and evicting it leaves a detail
* byte-identical to the external-kill arm. Among the rest, routine group/job
* teardown goes before a pid-addressed kill — a window-close burst is 30+ group
* kills and plain FIFO would drop the one entry that can explain the death —
* falling back to plain FIFO once every candidate is pid-addressed, which is
* what an ordinary session saturates the ring with.
*/
function evictOneSelfInitiatedTreeKill(): void {
const lastCandidate = selfInitiatedKills.length - 1
const oldestGroupKill = selfInitiatedKills.findIndex(
(kill, index) => index < lastCandidate && !isPidAddressedTreeKill(kill.scope)
)
selfInitiatedKills.splice(Math.max(oldestGroupKill, 0), 1)
}
export function recordSelfInitiatedTreeKill({
pid,
site,
@@ -96,13 +126,15 @@ export function recordSelfInitiatedTreeKill({
return
}
selfInitiatedKills.push({ pid, site, scope, at })
if (selfInitiatedKills.length > MAX_TRACKED_SELF_KILLS) {
selfInitiatedKills = selfInitiatedKills.slice(-MAX_TRACKED_SELF_KILLS)
while (selfInitiatedKills.length > MAX_TRACKED_SELF_KILLS) {
evictOneSelfInitiatedTreeKill()
}
// Durable so it survives into the diagnostic bundle even when the kill takes
// the reporting renderer with it; coalesced because the crash detail above is
// the primary record and a teardown burst must not cost 30 ring slots plus a
// forced disk flush each. The newest pid still rides the emitted crumb.
// forced disk flush each. The retained ring crumb carries the newest pid, but
// the span trail emits only the first of a coalesced burst — read
// `selfInitiatedKills` for the rest.
recordCoalescedDurableCrashBreadcrumb({
name: 'self_tree_kill',
data: { pid, site, scope },
@@ -133,11 +165,6 @@ export function recordRefusedOwnChromiumTreeKill(target: {
})
}
/** Routes the `runProcess` choke point's kills here; shared code cannot import us. */
export function installProcessTreeKillBreadcrumbObserver(): void {
setProcessTreeKillObserver((kill) => recordSelfInitiatedTreeKill(kill))
}
export function findSelfInitiatedTreeKills(at: number): SelfInitiatedTreeKill[] {
return selfInitiatedKills.filter((kill) => {
const offsetMs = kill.at - at
@@ -0,0 +1,123 @@
import { appendFileSync, existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createPtySubprocess } from './pty-subprocess'
import { Session } from './session'
const SHELLS = process.platform === 'win32' ? [] : ['/bin/bash', '/bin/zsh'].filter(existsSync)
const COMMAND = "printf 'AGENT_%s\\n' STARTED"
async function launch(
shell: string,
slow: boolean,
legacy = false
): Promise<{ output: string; ms: number }> {
const root = mkdtempSync(join(tmpdir(), 'orca-startup-latency-'))
const bash = shell.endsWith('bash')
const pause = slow ? 'sleep 0.6\n' : ''
const prompt = slow ? "PS1='$(sleep 0.3)prompt> '\n" : "PS1='prompt> '\n"
writeFileSync(
join(root, bash ? '.bash_profile' : '.zshrc'),
`${pause}${bash ? '' : 'setopt PROMPT_SUBST\n'}${prompt}`
)
vi.stubEnv('HOME', root)
vi.stubEnv('ZDOTDIR', root)
vi.stubEnv('ORCA_ORIG_ZDOTDIR', root)
let session: Session | undefined
let timer: ReturnType<typeof setTimeout> | undefined
let legacyTimer: ReturnType<typeof setTimeout> | undefined
const readinessEvents: string[] = []
const started = performance.now()
try {
const subprocess = await createPtySubprocess({
sessionId: 'startup-latency',
cols: 120,
rows: 30,
cwd: root,
shellOverride: shell,
command: COMMAND,
env: { HOME: root, SHELL: shell, TERM: 'xterm-256color' }
})
session = new Session({
sessionId: 'startup-latency',
cols: 120,
rows: 30,
subprocess,
shellReadySupported: !legacy,
reportReadinessEvent: (event) => readinessEvents.push(event)
})
const active = session
return await new Promise((resolve, reject) => {
let output = ''
timer = setTimeout(
() => reject(new Error(`Startup timed out: ${JSON.stringify(output)}`)),
5000
)
active.attachClient({
onExit: () => {},
onData: (data) => {
output += data
if (output.includes('AGENT_STARTED')) {
resolve({ output, ms: performance.now() - started })
}
}
})
if (legacy) {
legacyTimer = setTimeout(() => active.write(`${COMMAND}\n`), 300)
} else {
active.write(`${COMMAND}\n`)
}
})
} finally {
clearTimeout(timer)
clearTimeout(legacyTimer)
if (session) {
await session.forceKillAndWaitForExit(3000)
session.dispose()
}
vi.unstubAllEnvs()
rmSync(root, { recursive: true, force: true })
expect(readinessEvents).toEqual([])
}
}
describe('agent startup at the rendered shell prompt', () => {
afterEach(() => vi.unstubAllEnvs())
it.each(SHELLS)(
'%s displays the command once after slow startup and prompt expansion',
async (shell) => {
const before = await launch(shell, true, true)
expect(before.output.split(COMMAND)).toHaveLength(3)
const result = await launch(shell, true)
expect(result.output).not.toContain('orca-shell-ready')
expect(result.output.split(COMMAND)).toHaveLength(2)
expect(result.output.indexOf('prompt> ')).toBeLessThan(result.output.indexOf(COMMAND))
}
)
it.skipIf(!process.env.ORCA_STARTUP_BENCH || SHELLS.length === 0)(
'compares legacy input timing with prompt delivery',
async () => {
for (const shell of SHELLS) {
for (const slow of [false, true]) {
const legacy: number[] = []
const current: number[] = []
for (let i = 0; i < 5; i++) {
legacy.push((await launch(shell, slow, true)).ms)
const result = await launch(shell, slow)
expect(result.output).not.toContain('orca-shell-ready')
expect(result.output.split(COMMAND)).toHaveLength(2)
current.push(result.ms)
}
const result = JSON.stringify({ shell, slow, legacy, current })
if (process.env.ORCA_STARTUP_BENCH_OUTPUT) {
appendFileSync(process.env.ORCA_STARTUP_BENCH_OUTPUT, `${result}\n`)
}
console.log(result)
}
}
},
60_000
)
})
@@ -2,7 +2,6 @@ import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper'
import { getPosixCodexShellLaunchPreflight } from '../pty/codex-shell-launch-preflight'
import { BASH_PROMPT_COMMAND_COMPOSITION_BLOCK } from '../bash-prompt-command-composition'
import { BASH_FEATURE_CHANNEL_BLOCK, SHELL_STARTUP_IDENTITY_MARKER_BLOCK } from '../shell-templates'
import { SHELL_READY_MARKER } from './daemon-shell-ready-marker'
export function getDaemonBashShellReadyRcfileContent(): string {
return `# Orca daemon bash shell-ready wrapper
@@ -56,10 +55,6 @@ __orca_osc133_precmd() {
unset __orca_in_command
fi
printf "\\033]133;A\\007"
# Why: emit the shell-ready marker here (not a trailing PROMPT_COMMAND entry)
# so a framework that must be last in PROMPT_COMMAND — bash-preexec — is not
# displaced by one of Orca's own hooks.
[[ -n "$__orca_ready_marker" ]] && printf "${SHELL_READY_MARKER}"
return "$exit_code"
}
__orca_osc133_preexec() {
@@ -117,6 +112,11 @@ __orca_osc133_epilogue() {
unset __orca_in_prompt_command
__orca_adopt_outer_debug_trap
trap '__orca_osc133_preexec' DEBUG
# Readline renders PS1 after entering raw mode; prompt hooks still run in cooked mode.
if [[ -n "$__orca_ready_marker" ]]; then
PS1="\${PS1-}"'\\[\\e]777;orca-shell-ready\\a\\]'
__orca_ready_marker=""
fi
}
${BASH_PROMPT_COMMAND_COMPOSITION_BLOCK}
__orca_prepend_prompt_command "__orca_osc133_precmd"
@@ -27,8 +27,6 @@ const {
isDaemonStaleForCurrentBundleMock: vi.fn(async () => false)
}))
const itOnPosix = process.platform === 'win32' ? it.skip : it
vi.mock('./daemon-health', async (importOriginal) => {
const actual = await importOriginal<typeof DaemonHealthModule>()
return {
@@ -343,37 +341,6 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
}
}
})
itOnPosix('keeps plain Codex startup on the short daemon shell-ready timeout', async () => {
await adapter.spawn({
cols: 80,
rows: 24,
command: 'codex',
env: { SHELL: '/bin/zsh' }
})
await waitFor(() => vi.mocked(lastSubprocess.write).mock.calls.length > 0)
expect(lastSubprocess.write).toHaveBeenCalledWith('codex\n')
})
itOnPosix('waits for shell-ready for delivery-hinted Codex startup', async () => {
await adapter.spawn({
cols: 80,
rows: 24,
command: "codex 'linked issue context'",
startupCommandDelivery: 'shell-ready',
env: { SHELL: '/bin/zsh' }
})
await new Promise((resolve) => setTimeout(resolve, 350))
expect(lastSubprocess.write).not.toHaveBeenCalled()
lastSubprocess._simulateData('\x1b]777;orca-shell-ready\x07')
lastSubprocess._simulateData('\r\nuser@host $ ')
await waitFor(() => vi.mocked(lastSubprocess.write).mock.calls.length > 0)
expect(lastSubprocess.write).toHaveBeenCalledWith("codex 'linked issue context'\n")
})
})
describe('write', () => {
+20 -12
View File
@@ -1,5 +1,6 @@
import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition'
import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery'
import { CODEX_SHELL_READY_TIMEOUT_MS } from './session-shell-ready-barrier'
import type {
HistoryRecoveryContext,
PendingDaemonSpawnOperation
@@ -11,8 +12,11 @@ import { DaemonPtySpawnResult } from './daemon-pty-spawn-result'
import type { DaemonPtySpawnContext } from './daemon-pty-spawn-request'
import type { ColdRestoreInfo } from './history-reader'
import { mintPtySessionId } from './pty-session-id'
import { CODEX_SHELL_READY_TIMEOUT_MS } from './session-shell-ready-barrier'
import { supportsPtyStartupBarrier } from './shell-ready'
import {
shellPathSupportsPtyStartupBarrier,
shellReadyMarkerComesFromLineEditor,
resolvePtyShellPath
} from './shell-ready'
import { getRecoveredHistorySeedSegments } from './terminal-history-seed-segments'
import { AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, type CreateOrAttachResult } from './types'
import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd'
@@ -212,22 +216,26 @@ export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult {
let effectiveCols = restoreInfo?.cols ?? opts.cols
let effectiveRows = restoreInfo?.rows ?? opts.rows
const shellReadySupported = opts.command ? supportsPtyStartupBarrier(opts.env ?? {}) : false
const isCodexStartupCommand =
recognizeAgentProcessFromCommandLine(opts.command)?.agent === 'codex'
const shouldWaitForShellReady =
isCodexStartupCommand &&
shouldUseShellReadyStartupDelivery({
const effectiveShellPath =
process.platform !== 'win32' && opts.command
? resolveUnixShellPath(opts.shellOverride || resolvePtyShellPath(opts.env ?? {}))
: ''
const shellReadySupported = shellPathSupportsPtyStartupBarrier(effectiveShellPath)
const immediateMarker = shellReadyMarkerComesFromLineEditor(effectiveShellPath)
const shellReadyTimeoutMs =
shellReadySupported &&
!immediateMarker &&
recognizeAgentProcessFromCommandLine(opts.command)?.agent === 'codex' &&
!shouldUseShellReadyStartupDelivery({
command: opts.command,
startupCommandDelivery: opts.startupCommandDelivery
})
const shellReadyTimeoutMs =
shellReadySupported && isCodexStartupCommand && !shouldWaitForShellReady
? CODEX_SHELL_READY_TIMEOUT_MS
: undefined
const context: DaemonPtySpawnContext = {
opts,
// Older daemons also need the existing hint to enable their ready marker.
opts:
opts.command && immediateMarker ? { ...opts, startupCommandDelivery: 'shell-ready' } : opts,
operation,
historyRecovery,
requestedSessionId,
@@ -0,0 +1,107 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { rmSync } from 'node:fs'
import { basename, join } from 'node:path'
import * as localPtyUtils from '../providers/local-pty-utils'
import {
createMockSubprocess,
startDaemonAdapterHarness,
waitFor,
type DaemonAdapterHarness,
type SpawnSubprocess
} from './daemon-pty-adapter-test-harness'
const itOnPosix = process.platform === 'win32' ? it.skip : it
describe('DaemonPtyAdapter startup delivery', () => {
let harness: DaemonAdapterHarness
let adapter: DaemonAdapterHarness['adapter']
let dir: string
let lastSubprocess: ReturnType<typeof createMockSubprocess>
let lastSpawnOpts: Parameters<SpawnSubprocess>[0] | null
beforeEach(async () => {
lastSpawnOpts = null
harness = await startDaemonAdapterHarness((opts) => {
lastSpawnOpts = opts
lastSubprocess = createMockSubprocess()
return lastSubprocess
})
adapter = harness.adapter
dir = harness.dir
})
afterEach(async () => {
adapter.dispose()
await harness.server.shutdown()
rmSync(dir, { recursive: true, force: true })
})
itOnPosix('preserves the existing fast-start timing for fish', async () => {
// The mock subprocess represents installed fish even on hosts without it.
const resolveShell = vi
.spyOn(localPtyUtils, 'resolveUnixShellPath')
.mockReturnValue('/usr/bin/fish')
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
try {
await adapter.spawn({
cols: 80,
rows: 24,
command: 'codex',
env: { SHELL: '/usr/bin/fish' }
})
await vi.advanceTimersByTimeAsync(299)
expect(lastSubprocess.write).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(lastSubprocess.write).toHaveBeenCalledExactlyOnceWith('codex\n')
expect(lastSpawnOpts).not.toEqual(
expect.objectContaining({ startupCommandDelivery: 'shell-ready' })
)
} finally {
vi.useRealTimers()
resolveShell.mockRestore()
}
})
itOnPosix.for(['environment', 'override'] as const)(
'waits for the fallback shell when the %s shell is missing',
async (source, context) => {
const missingShell = join(dir, 'missing-fish')
const fallbackName = basename(localPtyUtils.resolveUnixShellPath(missingShell))
context.skip(!['bash', 'zsh'].includes(fallbackName), 'Requires a Bash/zsh fallback')
await adapter.spawn({
cols: 80,
rows: 24,
command: 'codex',
env: { SHELL: source === 'environment' ? missingShell : '/bin/sh' },
...(source === 'override' ? { shellOverride: missingShell } : {})
})
await new Promise((resolve) => setTimeout(resolve, 350))
expect(lastSubprocess.write).not.toHaveBeenCalled()
expect(lastSpawnOpts).toEqual(
expect.objectContaining({ startupCommandDelivery: 'shell-ready' })
)
lastSubprocess._simulateData('\x1b]777;orca-shell-ready\x07\r\nuser@host $ ')
await waitFor(() => vi.mocked(lastSubprocess.write).mock.calls.length > 0)
expect(lastSubprocess.write).toHaveBeenCalledExactlyOnceWith('codex\n')
}
)
itOnPosix.each([
{ command: 'codex' },
{ command: 'codex', startupCommandDelivery: 'fast' as const },
{ command: "codex 'linked issue context'", startupCommandDelivery: 'shell-ready' as const }
])('waits past 300ms and submits once after readiness: %j', async (startup) => {
await adapter.spawn({ cols: 80, rows: 24, ...startup, env: { SHELL: '/bin/zsh' } })
await new Promise((resolve) => setTimeout(resolve, 350))
expect(lastSubprocess.write).not.toHaveBeenCalled()
expect(lastSpawnOpts).toEqual(
expect.objectContaining({ startupCommandDelivery: 'shell-ready' })
)
lastSubprocess._simulateData('\x1b]777;orca-shell-ready\x07')
lastSubprocess._simulateData('\r\nuser@host $ ')
await waitFor(() => vi.mocked(lastSubprocess.write).mock.calls.length > 0)
expect(lastSubprocess.write).toHaveBeenCalledExactlyOnceWith(`${startup.command}\n`)
})
})
@@ -20,6 +20,14 @@ describe('PostReadyFlushGate', () => {
vi.useRealTimers()
})
it('flushes synchronously when the marker comes from the line editor', () => {
gate = new PostReadyFlushGate(onFlush, true)
gate.arm()
expect(onFlush).toHaveBeenCalledTimes(1)
expect(gate.isPending).toBe(false)
expect(vi.getTimerCount()).toBe(0)
})
it('does not flush immediately when armed', () => {
gate.arm()
expect(onFlush).not.toHaveBeenCalled()
+10 -21
View File
@@ -1,23 +1,5 @@
/**
* Defers a flush callback until after the shell has drawn its prompt and
* switched the PTY into raw mode.
*
* Why: the OSC 777 shell-ready marker fires from zsh's precmd_functions /
* bash's PROMPT_COMMAND — before the shell draws its prompt and before
* zle/readline flips the PTY into raw mode. Flushing queued input then lets
* the kernel (ECHO still on) echo the command once, and the line editor
* redraws it under the prompt — producing a visible duplicate (e.g. "claude"
* appears twice on agent launch).
*
* Strategy: after arm() is called, wait for prompt bytes plus a short delay
* for the tcsetattr() that enables raw mode. If the marker-completing scan
* already saw post-marker bytes, use that same short path immediately.
* A conservative wall-clock fallback covers ambiguous marker-only cases.
*
* Mirrors the gate in local-pty-shell-ready.ts::writeStartupCommandWhenShellReady,
* which solves the same race on the non-daemon path.
*/
// Bash's prompt and zsh's line-init marker are ready for input immediately.
// Other shells retain the existing settling delay.
export const POST_READY_FLUSH_DELAY_MS = 30
export const POST_READY_FLUSH_FALLBACK_MS = 200
@@ -26,7 +8,10 @@ export class PostReadyFlushGate {
private postDataTimer: ReturnType<typeof setTimeout> | null = null
private fallbackTimer: ReturnType<typeof setTimeout> | null = null
constructor(private readonly onFlush: () => void) {}
constructor(
private readonly onFlush: () => void,
private readonly markerIsLineEditorReady = false
) {}
/** True between arm() and the actual flush firing. Callers should treat
* input as still-queued during this window to preserve ordering. */
@@ -38,6 +23,10 @@ export class PostReadyFlushGate {
* wall-clock fallback unless the marker scan already observed post-marker
* bytes, in which case the short post-data settle path is enough. */
arm(postMarkerBytesObserved = false): void {
if (this.markerIsLineEditorReady) {
this.onFlush()
return
}
this.awaitingPromptDraw = true
if (postMarkerBytesObserved) {
this.notifyData()
@@ -261,7 +261,7 @@ describe('createPtySubprocess', () => {
expect(lastCall[2].env.ORCA_SHELL_FEATURES).not.toContain('ready')
})
it('keeps plain Codex startup commands on the no-marker wrapper', async () => {
it('enables readiness and shell identity for plain Codex startup', async () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
@@ -285,7 +285,8 @@ describe('createPtySubprocess', () => {
const lastCall = spawnMock.mock.calls.at(-1)!
expect(lastCall[1]).toEqual(['-l'])
expect(lastCall[2].env.ZDOTDIR).toMatch(ZSH_SHELL_READY_DIR)
expect(lastCall[2].env.ORCA_SHELL_FEATURES).not.toContain('ready')
expect(lastCall[2].env.ORCA_SHELL_FEATURES).toContain('ready')
expect(lastCall[2].env.ORCA_SHELL_FEATURES).toContain('identity')
})
it('uses shell-ready wrapper for delivery-hinted Codex startup commands', async () => {
@@ -1,3 +1,4 @@
import { shouldUseShellReadyStartupDelivery } from '../../../shared/codex-startup-delivery'
import { win32 as pathWin32 } from 'node:path'
import { isWindowsGitBashShellPath, resolveWindowsGitBashShellPath } from '../../git-bash'
import { isPwshAvailable } from '../../pwsh'
@@ -32,10 +33,13 @@ import {
recognizeAgentProcessFromCommandLine,
type RecognizedAgentProcess
} from '../../../shared/agent-process-recognition'
import { shouldUseShellReadyStartupDelivery } from '../../../shared/codex-startup-delivery'
import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../../shared/hermes-startup-query'
import { WINDOWS_GIT_BASH_SHELL } from '../../../shared/windows-terminal-shell'
import { getShellLaunchConfig, resolvePtyShellPath } from '../shell-ready'
import {
getShellLaunchConfig,
resolvePtyShellPath,
shellReadyMarkerComesFromLineEditor
} from '../shell-ready'
import { resolveWslSessionContext } from '../wsl-session-context'
import { finalizeDaemonPtyEnvironment, rescrubDaemonPtyEnvironment } from './spawn-environment'
import type { PtySubprocessOptions } from '../pty-subprocess'
@@ -60,7 +64,6 @@ export function createPtyShellLaunchPlan(
let startupCommandDeliveredInShellArgs = false
let windowsFallbackAttempts: WindowsShellSpawnAttempt[] = []
const startupAgentRecognition = recognizeAgentProcessFromCommandLine(opts.command)
const isCodexStartupCommand = startupAgentRecognition?.agent === 'codex'
const requestedCwd = opts.cwd || resolveSafePtyDefaultCwd()
if (opts.command && startupAgentRecognition) {
assertSafeAgentStartupCwd(requestedCwd, opts.command)
@@ -192,9 +195,10 @@ export function createPtyShellLaunchPlan(
}
const waitsForShellReady =
Boolean(opts.command) &&
(!isCodexStartupCommand ||
(startupAgentRecognition?.agent !== 'codex' ||
shellReadyMarkerComesFromLineEditor(shellPath) ||
shouldUseShellReadyStartupDelivery({
command: opts.command as string,
command: opts.command,
startupCommandDelivery: opts.startupCommandDelivery
}))
delete env.ORCA_SHELL_FEATURES
@@ -1,3 +1,4 @@
import { shellReadyMarkerComesFromLineEditor } from './shell-ready'
import {
installDeviceAttributesResponder,
STARTUP_DA1_RESPONSE
@@ -19,7 +20,6 @@ import { basename } from 'node:path'
import type { ShellReadyState } from './types'
const SHELL_READY_TIMEOUT_MS = 15_000
// Why: Codex skips marker-gated command delivery; this only bounds older daemon/local paths that still report shell-ready for Codex.
export const CODEX_SHELL_READY_TIMEOUT_MS = 300
export type SessionShellReadyBarrierDeps = {
@@ -69,7 +69,10 @@ export class SessionShellReadyBarrier {
this._state = 'unsupported'
}
this.postReadyFlushGate = new PostReadyFlushGate(() => this.flushPreReadyQueue())
this.postReadyFlushGate = new PostReadyFlushGate(
() => this.flushPreReadyQueue(),
shellReadyMarkerComesFromLineEditor(deps.subprocess.shellPath ?? '')
)
}
get state(): ShellReadyState {
+1 -3
View File
@@ -198,11 +198,9 @@ describePosix('daemon shell-ready launch config', () => {
})
it('extends the startup barrier to fish so launch commands queue until the prompt', async () => {
const { shellPathSupportsPtyStartupBarrier, supportsPtyStartupBarrier } =
await importFreshShellReady()
const { shellPathSupportsPtyStartupBarrier } = await importFreshShellReady()
expect(shellPathSupportsPtyStartupBarrier('/opt/homebrew/bin/fish')).toBe(true)
expect(supportsPtyStartupBarrier({ SHELL: '/usr/local/bin/fish' })).toBe(true)
// Why: unwrapped shells must stay off the barrier or their first command queues forever.
expect(shellPathSupportsPtyStartupBarrier('/usr/bin/tcsh')).toBe(false)
})
+5 -7
View File
@@ -101,6 +101,11 @@ export function resolvePtyShellPath(env: Record<string, string>): string {
return env.SHELL || process.env.SHELL || '/bin/zsh'
}
export function shellReadyMarkerComesFromLineEditor(shellPath: string): boolean {
const shellName = pathWin32.basename(basename(shellPath)).toLowerCase()
return shellName === 'bash' || shellName === 'zsh'
}
export function shellPathSupportsPtyStartupBarrier(shellPath: string): boolean {
const shellName = pathWin32.basename(basename(shellPath)).toLowerCase()
// Why fish: markerless, its startup command is written before fish's reader owns
@@ -108,13 +113,6 @@ export function shellPathSupportsPtyStartupBarrier(shellPath: string): boolean {
return shellName === 'zsh' || shellName === 'bash' || shellName === 'fish'
}
export function supportsPtyStartupBarrier(env: Record<string, string>): boolean {
if (process.platform === 'win32') {
return false
}
return shellPathSupportsPtyStartupBarrier(resolvePtyShellPath(env))
}
export type ShellLaunchConfig = {
args: string[] | null
env: Record<string, string>
@@ -1,4 +1,5 @@
import { spawn, type ChildProcess } from 'node:child_process'
import { admitSelfInitiatedTreeKill } from '../../own-chromium-tree-kill-guard'
const WINDOWS_TREE_KILL_WAIT_MS = 2_000
@@ -8,6 +9,14 @@ export function killSpawnedCommandTree(child: ChildProcess): Promise<void> {
child.kill()
return Promise.resolve()
}
if (
!admitSelfInitiatedTreeKill({ pid, site: 'git-command-tree-kill', scope: 'win-taskkill-tree' })
) {
// Refusal blocks the pid-addressed tree walk, never the termination: the
// handle-addressed root kill cannot reach a recycled pid.
child.kill()
return Promise.resolve()
}
return new Promise((resolve) => {
let killer: ChildProcess
try {
+15 -1
View File
@@ -4,6 +4,7 @@ import { dirname } from 'node:path'
import { ipcMain } from 'electron'
import type { Store } from '../persistence'
import { resolveAuthorizedPath } from './filesystem-auth'
import { admitSelfInitiatedTreeKill } from '../own-chromium-tree-kill-guard'
export type NotebookRunResult = {
stdout: string
@@ -53,7 +54,8 @@ function appendBounded(capture: BoundedCapture, chunk: Buffer): void {
capture.truncated = true
}
function terminateNotebookProcessTree(
/** Exported for the refusal-fallback test; the timeout path is otherwise unreachable. */
export function terminateNotebookProcessTree(
child: ChildProcessWithoutNullStreams
): ReturnType<typeof setTimeout> | null {
if (!child.pid) {
@@ -62,6 +64,18 @@ function terminateNotebookProcessTree(
}
if (process.platform === 'win32') {
if (
!admitSelfInitiatedTreeKill({
pid: child.pid,
site: 'notebook-cell-timeout',
scope: 'win-taskkill-tree'
})
) {
// Refusal blocks the tree walk, not the termination: killing the root by
// handle cannot reach a recycled pid, and a timed-out cell must still stop.
child.kill()
return null
}
try {
// Why: a timed-out cell can spawn descendants. taskkill /T is the
// Windows equivalent of terminating the whole process group.
@@ -0,0 +1,166 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
commitAttachedPtySize,
resolveCommittedPtySize,
shouldSeedPreAttachPtySize
} from './attached-pty-size'
import { ptySizes } from './visibility-state'
const REQUESTED = { cols: 80, rows: 24 }
const CACHED = { cols: 180, rows: 50 }
const LIVE = { cols: 211, rows: 57 }
describe('shouldSeedPreAttachPtySize', () => {
it('seeds a fresh session id even when the pane never measured itself', () => {
expect(
shouldSeedPreAttachPtySize({
isFreshSessionId: true,
hasCachedSize: true,
requestIsUnmeasured: true
})
).toBe(true)
})
it('never overwrites a size main already holds for the session', () => {
expect(
shouldSeedPreAttachPtySize({
isFreshSessionId: false,
hasCachedSize: true,
requestIsUnmeasured: false
})
).toBe(false)
})
it('refuses an unmeasured request on an attach even with nothing cached', () => {
expect(
shouldSeedPreAttachPtySize({
isFreshSessionId: false,
hasCachedSize: false,
requestIsUnmeasured: true
})
).toBe(false)
})
it('seeds a measured attach request when main holds nothing better', () => {
expect(
shouldSeedPreAttachPtySize({
isFreshSessionId: false,
hasCachedSize: false,
requestIsUnmeasured: false
})
).toBe(true)
})
})
describe('resolveCommittedPtySize', () => {
it('records the requested grid for a fresh spawn, ignoring any stale cache', () => {
expect(
resolveCommittedPtySize({
result: {},
requested: REQUESTED,
cachedBeforeAttach: CACHED
})
).toEqual(REQUESTED)
})
it('prefers a grid the provider applied on attach', () => {
expect(
resolveCommittedPtySize({
result: {
isReattach: true,
attachedGrid: { cols: 100, rows: 30 },
snapshotCols: LIVE.cols,
snapshotRows: LIVE.rows
},
requested: REQUESTED,
cachedBeforeAttach: CACHED
})
).toEqual({ cols: 100, rows: 30 })
})
it('falls back to the reattach snapshot grid', () => {
expect(
resolveCommittedPtySize({
result: { isReattach: true, snapshotCols: LIVE.cols, snapshotRows: LIVE.rows },
requested: REQUESTED,
cachedBeforeAttach: CACHED
})
).toEqual(LIVE)
})
it('falls back to the size main held when the provider proves nothing', () => {
expect(
resolveCommittedPtySize({
result: { isReattach: true },
requested: REQUESTED,
cachedBeforeAttach: CACHED
})
).toEqual(CACHED)
})
it('rejects a non-integer provider grid as unproven', () => {
expect(
resolveCommittedPtySize({
result: { isReattach: true, snapshotCols: 120.5, snapshotRows: 40 },
requested: REQUESTED,
cachedBeforeAttach: CACHED
})
).toEqual(CACHED)
})
it('takes the request only when nothing better exists', () => {
expect(
resolveCommittedPtySize({
result: { isReattach: true },
requested: REQUESTED,
cachedBeforeAttach: undefined
})
).toEqual(REQUESTED)
})
it('rejects a non-positive provider grid rather than publishing a zero-width model', () => {
expect(
resolveCommittedPtySize({
result: { isReattach: true, snapshotCols: 0, snapshotRows: 0 },
requested: REQUESTED,
cachedBeforeAttach: CACHED
})
).toEqual(CACHED)
})
})
describe('commitAttachedPtySize', () => {
afterEach(() => {
ptySizes.delete('pty-commit')
})
it('records the resolved grid and reflows the model onto it for a reattach', () => {
const reflow = vi.fn()
const committed = commitAttachedPtySize({
result: {
id: 'pty-commit',
isReattach: true,
snapshotCols: LIVE.cols,
snapshotRows: LIVE.rows
},
requested: REQUESTED,
cachedBeforeAttach: undefined,
reflowHeadlessTerminalToPtyGrid: reflow
})
expect(committed).toEqual(LIVE)
expect(ptySizes.get('pty-commit')).toEqual(LIVE)
expect(reflow).toHaveBeenCalledWith('pty-commit', LIVE.cols, LIVE.rows)
})
it('reflows a fresh spawn onto the request too: bytes can create the model before the reply', () => {
const reflow = vi.fn()
commitAttachedPtySize({
result: { id: 'pty-commit' },
requested: REQUESTED,
cachedBeforeAttach: CACHED,
reflowHeadlessTerminalToPtyGrid: reflow
})
expect(ptySizes.get('pty-commit')).toEqual(REQUESTED)
expect(reflow).toHaveBeenCalledWith('pty-commit', REQUESTED.cols, REQUESTED.rows)
})
})
@@ -0,0 +1,81 @@
import type { PtySpawnResult } from '../../../providers/types'
import { ptySizes } from './visibility-state'
export type PtyGrid = { cols: number; rows: number }
function positiveGrid(cols: unknown, rows: unknown): PtyGrid | undefined {
return typeof cols === 'number' &&
typeof rows === 'number' &&
Number.isInteger(cols) &&
Number.isInteger(rows) &&
cols > 0 &&
rows > 0
? { cols, rows }
: undefined
}
/** Pre-attach seed for `ptySizes`. Daemon PTYs can emit before spawn() resolves, so a genuinely
* fresh session must record its geometry now or early bytes parse at xterm's 80x24 default.
* An attach must not seed: a pane that mounted while hidden reports xterm's unmeasured default,
* and the live PTY's real grid is either already cached or arrives with the attach result. */
export function shouldSeedPreAttachPtySize(args: {
isFreshSessionId: boolean
hasCachedSize: boolean
requestIsUnmeasured: boolean
}): boolean {
return args.isFreshSessionId || (!args.hasCachedSize && !args.requestIsUnmeasured)
}
/** Grid to record for a settled spawn. Daemon and relay attach never resize the session they hand
* back, so on a reattach the requested grid describes the pane, not the live process — take the
* provider's proven grid, then the size main already held, before trusting the request. */
export function resolveCommittedPtySize(args: {
result: Pick<PtySpawnResult, 'isReattach' | 'attachedGrid' | 'snapshotCols' | 'snapshotRows'>
requested: PtyGrid
cachedBeforeAttach: PtyGrid | undefined
}): PtyGrid {
if (args.result.isReattach !== true) {
return args.requested
}
return (
positiveGrid(args.result.attachedGrid?.cols, args.result.attachedGrid?.rows) ??
positiveGrid(args.result.snapshotCols, args.result.snapshotRows) ??
positiveGrid(args.cachedBeforeAttach?.cols, args.cachedBeforeAttach?.rows) ??
args.requested
)
}
type HeadlessReflow = ((ptyId: string, cols: number, rows: number) => void) | undefined
/** Reflow main's model onto the committed grid, whatever the spawn was. Why unconditional: live
* bytes can lazily create the model at the 80x24 default before the reply arrives, a seed skips an
* existing model, and the pre-attach seed is now withheld for unmeasured attaches, so a session the
* daemon re-created instead of attaching would otherwise keep the default forever. */
export function reflowHeadlessTerminalToCommittedGrid(args: {
result: Pick<PtySpawnResult, 'id'>
committedSize: PtyGrid
reflowHeadlessTerminalToPtyGrid: HeadlessReflow
}): void {
args.reflowHeadlessTerminalToPtyGrid?.(
args.result.id,
args.committedSize.cols,
args.committedSize.rows
)
}
/** Record the settled grid, then reflow the model onto it. Callers that seed the model between the
* two steps (ipc spawn commit) call the halves separately. */
export function commitAttachedPtySize(args: {
result: Pick<
PtySpawnResult,
'id' | 'isReattach' | 'attachedGrid' | 'snapshotCols' | 'snapshotRows'
>
requested: PtyGrid
cachedBeforeAttach: PtyGrid | undefined
reflowHeadlessTerminalToPtyGrid: HeadlessReflow
}): PtyGrid {
const committedSize = resolveCommittedPtySize(args)
ptySizes.set(args.result.id, committedSize)
reflowHeadlessTerminalToCommittedGrid({ ...args, committedSize })
return committedSize
}
+9 -2
View File
@@ -11,12 +11,14 @@ import {
} from '../pane/serializer-state'
import { ptyOwnership, ptyIncarnationById, deletePtyOwnership } from '../provider/ownership-state'
import { ptySizes } from '../delivery/visibility-state'
import { resolveCommittedPtySize, type PtyGrid } from '../delivery/attached-pty-size'
import { clearProviderPtyState } from '../provider/state-cleanup'
import type { PtyIpcSpawnState } from './spawn-state'
export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{
rendererPreSignaled: boolean
rendererAlreadyRegistered: boolean
committedSize: PtyGrid
}> {
const args = ctx.args
try {
@@ -89,7 +91,12 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{
ctx.agentTeamsLeaderHandle = null
}
}
ptySizes.set(ctx.result.id, { cols: args.cols, rows: args.rows })
const committedSize = resolveCommittedPtySize({
result: ctx.result,
requested: { cols: args.cols, rows: args.rows },
cachedBeforeAttach: ctx.sessionSizeBeforeAttach
})
ptySizes.set(ctx.result.id, committedSize)
if (ctx.effectiveSessionAppId !== undefined && ctx.effectiveSessionAppId !== ctx.result.id) {
ptySizes.delete(ctx.effectiveSessionAppId)
}
@@ -157,5 +164,5 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{
pendingPtyIdBySerializerGeneration.set(pending.gen, ctx.result.id)
}
}
return { rendererPreSignaled, rendererAlreadyRegistered }
return { rendererPreSignaled, rendererAlreadyRegistered, committedSize }
}
+12 -1
View File
@@ -24,10 +24,12 @@ import {
} from '../pane/launch-authority'
import type { PtyIpcSpawnState } from './spawn-state'
import { persistPtyIpcSpawnCommit } from './spawn-commit-persist'
import { reflowHeadlessTerminalToCommittedGrid } from '../delivery/attached-pty-size'
export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawnResult> {
const args = ctx.args
const { rendererPreSignaled, rendererAlreadyRegistered } = await persistPtyIpcSpawnCommit(ctx)
const { rendererPreSignaled, rendererAlreadyRegistered, committedSize } =
await persistPtyIpcSpawnCommit(ctx)
// Why: seed the headless emulator before registerPty so concurrent live PTY data lands on top of the seed, not replacing it (mobile keeps the daemon-restored scrollback).
// Skip when the renderer will be authoritative — its xterm buffer is richer than the daemon snapshot.
@@ -71,6 +73,15 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawn
ctx.deps.runtime.seedHeadlessTerminal(ctx.result.id, ctx.result.replay)
}
}
// Why after the seed: a seed skips an existing model, and live bytes may have lazily created
// one at the 80x24 default before the spawn reply revealed the session's real grid.
reflowHeadlessTerminalToCommittedGrid({
result: ctx.result,
committedSize,
reflowHeadlessTerminalToPtyGrid: ctx.deps.runtime?.reflowHeadlessTerminalToPtyGrid?.bind(
ctx.deps.runtime
)
})
if (
typeof args.worktreeId === 'string' &&
args.worktreeId.length > 0 &&
+9 -1
View File
@@ -17,6 +17,7 @@ import {
pendingRuntimePaneCreatesByOwnerKey
} from '../pane/spawn-reservation'
import { ptySizes } from '../delivery/visibility-state'
import { shouldSeedPreAttachPtySize } from '../delivery/attached-pty-size'
import { getStartupTerminalColorQueryReplyColors } from '../../terminal-startup-color-query-replies'
import type { PtyIpcSpawnState } from './spawn-state'
@@ -97,7 +98,14 @@ export async function buildPtyIpcSpawnOptions(
ctx.effectiveSessionAppId !== undefined ? ptySizes.has(ctx.effectiveSessionAppId) : false
ctx.sessionSizeBeforeAttach =
ctx.effectiveSessionAppId !== undefined ? ptySizes.get(ctx.effectiveSessionAppId) : undefined
if (ctx.effectiveSessionId !== undefined) {
if (
ctx.effectiveSessionId !== undefined &&
shouldSeedPreAttachPtySize({
isFreshSessionId: ctx.isMintedSessionId,
hasCachedSize: ctx.hadSessionSizeBeforeAttach,
requestIsUnmeasured: args.initiallyHidden === true
})
) {
// Why: daemon PTYs can emit before spawn() resolves; set real geometry now or early bytes default to 80x24 and wrap TUIs.
ptySizes.set(ctx.effectiveSessionAppId ?? ctx.effectiveSessionId, {
cols: args.cols,
@@ -0,0 +1,136 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { PtySpawnResult } from '../../../providers/types'
import { ptySizes } from '../delivery/visibility-state'
import { buildPtyIpcSpawnOptions } from './spawn-options'
import { commitPtyIpcSpawn } from './spawn-commit'
import { createPtyIpcSpawnState, type PtyIpcSpawnState } from './spawn-state'
import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types'
const SESSION_ID = 'orca-pty-session-1'
/** What a pane that mounted while `display:none` reports: xterm's unmeasured default. */
const HIDDEN_PANE_REQUEST = { cols: 80, rows: 24 }
/** The grid the surviving daemon session is actually running at. */
const LIVE_GRID = { cols: 211, rows: 57 }
function makeRuntime() {
return {
seedHeadlessTerminal: vi.fn(),
reflowHeadlessTerminalToPtyGrid: vi.fn(),
registerPty: vi.fn(),
cancelPendingPtyRegistration: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
seedTerminalRestoreTail: vi.fn(),
registerPreAllocatedHandleForPty: vi.fn()
}
}
function makeCtx(args: PtySpawnIpcArgs, runtime: ReturnType<typeof makeRuntime>): PtyIpcSpawnState {
const deps = {
transitionSpawnHiddenRendererPtyDeliveryState: vi.fn(),
syncPtyBackgroundedDelivery: vi.fn(),
sendPtySpawnedToRenderer: vi.fn(),
runtime
} as unknown as PtySpawnIpcDeps
const ctx = createPtyIpcSpawnState(deps, args)
ctx.env = {}
ctx.isDaemonHostSpawn = true
ctx.effectiveSessionId = SESSION_ID
ctx.effectiveSessionAppId = SESSION_ID
// Mirrors spawn-preflight: a caller-supplied sessionId is an attach, never a fresh mint.
ctx.isMintedSessionId = args.sessionId === undefined
return ctx
}
async function runSpawn(
args: PtySpawnIpcArgs,
result: PtySpawnResult
): Promise<{
runtime: ReturnType<typeof makeRuntime>
preAttachSize: { cols: number; rows: number } | undefined
}> {
const runtime = makeRuntime()
const ctx = makeCtx(args, runtime)
await buildPtyIpcSpawnOptions(ctx)
const preAttachSize = ptySizes.get(SESSION_ID)
ctx.result = result
await commitPtyIpcSpawn(ctx)
return { runtime, preAttachSize }
}
describe('spawn size cache on reattach', () => {
afterEach(() => {
ptySizes.delete(SESSION_ID)
vi.restoreAllMocks()
})
it('records the reattached session real grid, not a hidden pane placeholder', async () => {
const { runtime, preAttachSize } = await runSpawn(
{ ...HIDDEN_PANE_REQUEST, sessionId: SESSION_ID, initiallyHidden: true },
{
id: SESSION_ID,
isReattach: true,
snapshotCols: LIVE_GRID.cols,
snapshotRows: LIVE_GRID.rows
}
)
// Pre-attach: an unmeasured request must not be published as the live PTY's size.
expect(preAttachSize).toBeUndefined()
expect(ptySizes.get(SESSION_ID)).toEqual(LIVE_GRID)
expect(runtime.reflowHeadlessTerminalToPtyGrid).toHaveBeenCalledWith(
SESSION_ID,
LIVE_GRID.cols,
LIVE_GRID.rows
)
})
it('records the requested grid for a genuinely fresh spawn', async () => {
const { runtime, preAttachSize } = await runSpawn({ cols: 120, rows: 40 }, { id: SESSION_ID })
expect(preAttachSize).toEqual({ cols: 120, rows: 40 })
expect(ptySizes.get(SESSION_ID)).toEqual({ cols: 120, rows: 40 })
expect(runtime.reflowHeadlessTerminalToPtyGrid).toHaveBeenCalledWith(SESSION_ID, 120, 40)
})
// Why: the pre-attach seed is withheld for an unmeasured attach, and a daemon that restarted
// re-creates the session instead of attaching, so only the commit can size the model.
it('reflows a hidden attach the daemon answered with a fresh session onto the request', async () => {
const { runtime, preAttachSize } = await runSpawn(
{ cols: 100, rows: 30, sessionId: SESSION_ID, initiallyHidden: true },
{ id: SESSION_ID }
)
expect(preAttachSize).toBeUndefined()
expect(ptySizes.get(SESSION_ID)).toEqual({ cols: 100, rows: 30 })
expect(runtime.reflowHeadlessTerminalToPtyGrid).toHaveBeenCalledWith(SESSION_ID, 100, 30)
})
it('keeps the size main already held when the reattach carries no snapshot grid', async () => {
ptySizes.set(SESSION_ID, { cols: 180, rows: 50 })
const { preAttachSize } = await runSpawn(
{ ...HIDDEN_PANE_REQUEST, sessionId: SESSION_ID, initiallyHidden: true },
{ id: SESSION_ID, isReattach: true }
)
expect(preAttachSize).toEqual({ cols: 180, rows: 50 })
expect(ptySizes.get(SESSION_ID)).toEqual({ cols: 180, rows: 50 })
})
it('prefers the grid the provider applied on attach over every other source', async () => {
ptySizes.set(SESSION_ID, { cols: 180, rows: 50 })
await runSpawn(
{ ...HIDDEN_PANE_REQUEST, sessionId: SESSION_ID, initiallyHidden: true },
{
id: SESSION_ID,
isReattach: true,
attachedGrid: { cols: 100, rows: 30 },
snapshotCols: LIVE_GRID.cols,
snapshotRows: LIVE_GRID.rows
}
)
expect(ptySizes.get(SESSION_ID)).toEqual({ cols: 100, rows: 30 })
})
})
@@ -0,0 +1,80 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ptySizes } from '../delivery/visibility-state'
import { commitRuntimePtySpawn } from './spawn-commit'
import { createRuntimePtySpawnState, type RuntimePtySpawnArgs } from './spawn-state'
import type { PtyRuntimeControllerDeps } from './controller-deps'
const PTY_ID = 'orca-pty-adopted'
const LIVE_GRID = { cols: 211, rows: 57 }
function makeRuntime() {
return {
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
reflowHeadlessTerminalToPtyGrid: vi.fn(),
seedHeadlessTerminal: vi.fn(),
noteTerminalSpawnCommand: vi.fn()
}
}
describe('runtime spawn commit: adopted agent-session claim', () => {
afterEach(() => {
ptySizes.delete(PTY_ID)
})
function makeAdoptedCtx(result: Record<string, unknown>) {
const runtime = makeRuntime()
const deps = { runtime, store: undefined, options: {} } as unknown as PtyRuntimeControllerDeps
const args = { cols: 120, rows: 40, worktreeId: 'wt-1' } as unknown as RuntimePtySpawnArgs
const ctx = createRuntimePtySpawnState(deps, args)
ctx.result = {
id: PTY_ID,
...result,
agentSessionEnsure: {
disposition: 'adopted',
owner: {
claim: { kind: 'terminal' },
generation: 'g1',
phase: 'live',
ptyId: PTY_ID,
surface: { worktreeId: 'wt-1', tabId: 'tab-1', leafId: 'leaf-1', terminalHandle: 'h1' }
}
}
} as unknown as typeof ctx.result
return { runtime, ctx }
}
it('commits the live grid from the adoption reply before the early return', async () => {
const { runtime, ctx } = makeAdoptedCtx({
isReattach: true,
snapshotCols: LIVE_GRID.cols,
snapshotRows: LIVE_GRID.rows
})
await commitRuntimePtySpawn(ctx)
expect(ptySizes.get(PTY_ID)).toEqual(LIVE_GRID)
expect(runtime.reflowHeadlessTerminalToPtyGrid).toHaveBeenCalledWith(
PTY_ID,
LIVE_GRID.cols,
LIVE_GRID.rows
)
})
// Why: the SSH relay's adopted reply carries neither isReattach nor snapshot dims; the
// adoption itself proves a live owner, so main keeps what it held rather than the request.
it('treats an adoption without a reattach flag as an attach and keeps the held size', async () => {
ptySizes.set(PTY_ID, LIVE_GRID)
const { runtime, ctx } = makeAdoptedCtx({})
ctx.sessionSizeBeforeAttach = LIVE_GRID
await commitRuntimePtySpawn(ctx)
expect(ptySizes.get(PTY_ID)).toEqual(LIVE_GRID)
expect(runtime.reflowHeadlessTerminalToPtyGrid).toHaveBeenCalledWith(
PTY_ID,
LIVE_GRID.cols,
LIVE_GRID.rows
)
})
})
@@ -0,0 +1,18 @@
import { commitAttachedPtySize } from '../delivery/attached-pty-size'
import type { RuntimePtySpawnState } from './spawn-state'
/** Record the settled grid for a runtime-path spawn; `result` is passed explicitly because the
* adopted-claim branch commits before it returns early. */
export function commitRuntimePtySize(
ctx: RuntimePtySpawnState,
result: RuntimePtySpawnState['result']
): void {
commitAttachedPtySize({
result,
requested: { cols: ctx.args.cols, rows: ctx.args.rows },
cachedBeforeAttach: ctx.sessionSizeBeforeAttach,
reflowHeadlessTerminalToPtyGrid: ctx.deps.runtime?.reflowHeadlessTerminalToPtyGrid?.bind(
ctx.deps.runtime
)
})
}
+13 -5
View File
@@ -1,6 +1,7 @@
import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id'
import { ptyOwnership, ptyIncarnationById, deletePtyOwnership } from '../provider/ownership-state'
import { ptySizes } from '../delivery/visibility-state'
import { commitRuntimePtySize } from './spawn-commit-pty-size'
import {
shouldSkipCodexHomeEnvForWindowsShell,
recordCodexPaneAccountForSpawn,
@@ -57,6 +58,9 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
})
}
if (ctx.result.agentSessionEnsure?.disposition === 'adopted') {
// Why: an adoption is an attach to a live owner by definition, but the SSH relay's adopted
// reply omits isReattach; derive it once so the size commit and the reservation agree.
const adoptedResult = { ...ctx.result, isReattach: true }
const owner = ctx.result.agentSessionEnsure.owner
ptyOwnership.set(ctx.result.id, args.connectionId ?? ptyOwnership.get(ctx.result.id) ?? null)
ctx.deps.runtime?.registerPreAllocatedHandleForPty(ctx.result.id, owner.surface.terminalHandle)
@@ -86,13 +90,17 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
...(ctx.env ? { launchEnv: ctx.env } : {})
})
}
// Why: this branch returns before the normal commit site; without this the cache keeps
// whatever the caller requested.
commitRuntimePtySize(ctx, adoptedResult)
// Why: the adopted branch returns before the normal settle site, so the
// reservation must be resolved here or every later spawn for this pane
// awaits a promise that never settles.
resolvePaneSpawnReservation(ctx.paneSpawnReservationKey, ctx.paneSpawnReservation, {
...ctx.result,
isReattach: true
})
resolvePaneSpawnReservation(
ctx.paneSpawnReservationKey,
ctx.paneSpawnReservation,
adoptedResult
)
return {
id: ctx.result.id,
...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}),
@@ -125,7 +133,7 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
if (!ctx.hostSessionBinding) {
persistSshLease()
}
ptySizes.set(ctx.result.id, { cols: args.cols, rows: args.rows })
commitRuntimePtySize(ctx, ctx.result)
if (ctx.effectiveSessionAppId !== undefined && ctx.effectiveSessionAppId !== ctx.result.id) {
ptySizes.delete(ctx.effectiveSessionAppId)
}
+12 -1
View File
@@ -3,6 +3,7 @@ import { LocalPtyProvider } from '../../../providers/local-pty-provider'
import { makePaneKey, isTerminalLeafId } from '../../../../shared/stable-pane-id'
import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id'
import { ptySizes } from '../delivery/visibility-state'
import { shouldSeedPreAttachPtySize } from '../delivery/attached-pty-size'
import { CODEX_HOME_ENV_KEYS } from '../host-env/codex-home'
import {
mergePtyEnvDeletions,
@@ -110,7 +111,17 @@ export async function buildRuntimePtySpawnOptions(
ctx.effectiveSessionAppId !== undefined ? ptySizes.get(ctx.effectiveSessionAppId) : undefined
if (ctx.sessionId !== undefined) {
ctx.spawnOptions.sessionId = ctx.sessionId
ptySizes.set(ctx.effectiveSessionAppId ?? ctx.sessionId, { cols: args.cols, rows: args.rows })
if (
shouldSeedPreAttachPtySize({
isFreshSessionId: ctx.isNewDaemonSession,
hasCachedSize: ctx.hadSessionSizeBeforeAttach,
// Why false: runtime callers (CLI, headless serve) have no hidden pane to report, so a
// cached size is the only source that can outrank their requested grid here.
requestIsUnmeasured: false
})
) {
ptySizes.set(ctx.effectiveSessionAppId ?? ctx.sessionId, { cols: args.cols, rows: args.rows })
}
}
ctx.materializedPaneKey = ctx.hostSessionBinding
? makePaneKey(ctx.hostSessionBinding.tabId, ctx.hostSessionBinding.leafId)
@@ -0,0 +1,184 @@
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { join, relative, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
/**
* The ratchet behind the guard's claim to be a choke point.
*
* `admitSelfInitiatedTreeKill` is only "one decision" for as long as every
* pid-addressed `taskkill /pid <pid> /t /f` in Electron main asks it. Each such
* kill can land on a recycled pid that is now one of Orca's own Chromium
* processes (#10680), and an ungated one is also invisible to
* `selfInitiatedTreeKillCount`, which makes a zero read as exculpatory when it
* is not. A new family fails here rather than in the field.
*
* Exactly what is enforced, so no comment elsewhere claims more: per file, the
* number of gate admissions must be at least the number of `/pid` call sites.
* Counting sites rather than files is the point — a file-granular scan would let
* a second, ungated taskkill land inside a family that already mentions the gate,
* which is the shape the six highest-risk files now have. What it still cannot
* see: a site that pairs an ungated kill with a second admission of an already
* gated one in the same file, and a kill whose `/pid` argument is itself built
* from a variable.
*/
const REPOSITORY_ROOT = resolve(__dirname, '..', '..')
const MAIN_DIRECTORY = 'src/main/'
const SCANNED_EXTENSIONS = ['.ts', '.tsx']
const IGNORED_DIRECTORIES = new Set([
'node_modules',
'dist',
'out',
'build',
'.git',
'__fixtures__'
])
/**
* One match per call site. Keyed on the `/pid` argument rather than the program
* name because `/pid <n>` is what makes the kill pid-addressed — it walks
* whatever tree owns that pid *now* — and because the literal survives a
* `taskkill` spawned through a constant or a variable, which a quoted-program
* pattern misses entirely.
*/
const PID_ADDRESSED_KILL_SITE = /['"]\/pid['"]/gi
/**
* A call, not an import or a comment: `admitSelfInitiatedTreeKill` in main, and
* `admitProcessTreeKill` for the `src/shared` seam main installs the same gate
* into, which shared code cannot import directly.
*/
const GATE_ADMISSION = /\badmit(?:SelfInitiatedTreeKill|ProcessTreeKill)\s*\(/g
function countMatches(source: string, pattern: RegExp): number {
return source.match(pattern)?.length ?? 0
}
/** Sites left over once each admission in the file has claimed one. */
function ungatedKillSiteCount(source: string): number {
return Math.max(
countMatches(source, PID_ADDRESSED_KILL_SITE) - countMatches(source, GATE_ADMISSION),
0
)
}
/**
* Only ever shrinks. Each entry states why the gate cannot reach it — never
* "not got to yet", which is what a new ungated family would also look like.
*/
const UNGATED_TASKKILL_ALLOWLIST = new Map<string, string>([
[
'src/main/browser/browser-route-egress-electron-launch.ts',
'Electron probe reached only from *.electron.test.ts; kills the probe Electron it spawned'
],
[
'src/main/browser/browser-route-persisted-worker-electron-process.ts',
'Electron probe reached only from *.electron.test.ts; kills the probe Electron it spawned'
],
[
'src/cli/handlers/interactive-login-interruption.ts',
'CLI host: no Chromium pid on the machine to reach, and no reader for the ring'
],
[
'src/relay/subprocess-tree-termination.ts',
'Relay host: same, and the relay cannot import the main-process gate'
]
])
function isTestFile(path: string): boolean {
return /\.(?:test|spec)\.tsx?$/.test(path) || /(?:test-harness|test-fixture|fixture)/.test(path)
}
function scanSourceFiles(directory: string, found: string[] = []): string[] {
for (const entry of readdirSync(directory)) {
if (IGNORED_DIRECTORIES.has(entry)) {
continue
}
const path = join(directory, entry)
if (statSync(path).isDirectory()) {
scanSourceFiles(path, found)
continue
}
if (SCANNED_EXTENSIONS.some((extension) => entry.endsWith(extension)) && !isTestFile(path)) {
found.push(path)
}
}
return found
}
// Only the Node-side hosts: a renderer or preload cannot spawn a process at all.
const SCANNED_HOSTS = ['src/main', 'src/shared', 'src/cli', 'src/relay']
/** Scanned once at import: 10k files is seconds, and every case below reuses it. */
const PID_ADDRESSED_KILL_FILES = SCANNED_HOSTS.flatMap((host) =>
scanSourceFiles(join(REPOSITORY_ROOT, host))
.map((path) => ({
path: relative(REPOSITORY_ROOT, path).split('\\').join('/'),
source: readFileSync(path, 'utf8')
}))
.filter((file) => countMatches(file.source, PID_ADDRESSED_KILL_SITE) > 0)
)
function pidAddressedKillFiles(): { path: string; source: string }[] {
return PID_ADDRESSED_KILL_FILES
}
describe('main-process tree-kill gate', () => {
it('finds the taskkill families it is meant to police', () => {
// Falsifiable: a scanner that matched nothing would pass every case below.
expect(pidAddressedKillFiles().map((file) => file.path)).toContain(
'src/main/windows-process-tree-kill.ts'
)
})
it('routes every pid-addressed taskkill in Electron main through the gate', () => {
const ungated = pidAddressedKillFiles()
.filter((file) => file.path.startsWith(MAIN_DIRECTORY))
.filter((file) => ungatedKillSiteCount(file.source) > 0)
.map((file) => file.path)
.filter((path) => !UNGATED_TASKKILL_ALLOWLIST.has(path))
expect(ungated).toEqual([])
})
it('leaves no pid-addressed taskkill outside main unaccounted for', () => {
const unaccounted = pidAddressedKillFiles()
.filter((file) => !file.path.startsWith(MAIN_DIRECTORY))
.filter((file) => ungatedKillSiteCount(file.source) > 0)
.map((file) => file.path)
.filter((path) => !UNGATED_TASKKILL_ALLOWLIST.has(path))
expect(unaccounted).toEqual([])
})
it('counts call sites, not files: a second ungated kill in a gated file is caught', () => {
// The failure a file-granular scan let through: one gate mention exempting
// every taskkill in the file.
const gated = `
import { admitSelfInitiatedTreeKill } from './own-chromium-tree-kill-guard'
if (admitSelfInitiatedTreeKill({ pid, site: 's', scope: 'win-taskkill-tree' })) {
spawn('taskkill', ['/pid', String(pid), '/t', '/f'])
}
`
expect(ungatedKillSiteCount(gated)).toBe(0)
expect(
ungatedKillSiteCount(`${gated}\nspawn('taskkill', ['/pid', String(other), '/t', '/f'])`)
).toBe(1)
})
it('sees a kill whose program name comes from a constant', () => {
// A quoted-program pattern misses this shape; the `/pid` argument does not.
expect(
ungatedKillSiteCount(`
const KILLER = 'taskkill'
spawn(KILLER, ['/pid', String(pid), '/t', '/f'])
`)
).toBe(1)
})
it('keeps the allowlist honest: every entry still spawns a taskkill', () => {
const spawning = new Set(pidAddressedKillFiles().map((file) => file.path))
expect([...UNGATED_TASKKILL_ALLOWLIST.keys()].filter((path) => !spawning.has(path))).toEqual([])
})
})
+6
View File
@@ -12,6 +12,12 @@ import { recordCoalescedDurableCrashBreadcrumb } from './crash-reporting/durable
* Empty on a Node host and empty on failure: that is "no refusal proven", never
* "safe to kill" — callers must keep every other guard they already have.
*
* The other direction is real too, and bounded by design: `getAppMetrics()` can
* still list a renderer Electron has not finished reaping, so on Windows a pid
* already recycled onto an unrelated child of ours reads as `own` and its tree
* walk is refused. That is why a refusal only blocks the pid-addressed walk and
* every gated site still kills its own root through the child handle.
*
* Why failure stays open rather than refusing everything: a refusal is not free.
* `terminateWindowsProcessTree` resolves without killing, and
* `killSourceControlAgentProcess` returns that straight to a caller that then
+40 -1
View File
@@ -13,7 +13,12 @@ import {
import { readOrcaChromiumProcessPids } from './orca-chromium-process-pids'
import { classifyWindowsTreeKillTarget } from './windows-pty-root-identity'
import { terminateWindowsProcessTree } from './windows-process-tree-kill'
import { admitSelfInitiatedTreeKill } from './own-chromium-tree-kill-guard'
import {
admitSelfInitiatedTreeKill,
installMainProcessTreeKillGate
} from './own-chromium-tree-kill-guard'
import { killCodexAppServerProcessTree } from './codex/codex-app-server-session'
import { setProcessTreeKillGate } from '../shared/child-process/process-tree-kill-gate'
import { resetSelfInitiatedTreeKillLogForTest } from './crash-reporting/self-initiated-tree-kill-log'
import {
clearCrashBreadcrumbsForTest,
@@ -57,6 +62,7 @@ beforeEach(() => {
setActiveSink({ push: () => {}, flush: () => {}, close: () => {} })
clearCrashBreadcrumbsForTest()
resetSelfInitiatedTreeKillLogForTest()
installMainProcessTreeKillGate()
})
afterEach(() => {
@@ -66,6 +72,7 @@ afterEach(() => {
vi.restoreAllMocks()
_resetTracerForTests()
clearCrashBreadcrumbsForTest()
setProcessTreeKillGate(null)
})
describe('refusing to tree-kill our own Chromium processes', () => {
@@ -143,6 +150,38 @@ describe('refusing to tree-kill our own Chromium processes', () => {
)
})
it('refuses the codex app-server deadline kill against one of our own pids', () => {
const spawnImpl = vi.fn(() => ({ on: vi.fn(), unref: vi.fn() }))
const child = { pid: RENDERER_PID, kill: vi.fn() }
killCodexAppServerProcessTree(child as never, {
platform: 'win32',
spawnImpl: spawnImpl as never
})
// The deadline timer fires on `child.pid` alone; a reaped-then-recycled pid
// is the stale-pid mechanism this gate exists to stop.
expect(spawnImpl).not.toHaveBeenCalled()
expect(getCrashBreadcrumbSnapshot()).toEqual([
expect.objectContaining({ name: 'self_tree_kill_refused_own_chromium' })
])
})
it('still lets the codex app-server deadline kill reach a foreign pid', () => {
const killer = { on: vi.fn(), unref: vi.fn() }
const spawnImpl = vi.fn(() => killer)
killCodexAppServerProcessTree({ pid: 7777, kill: vi.fn() } as never, {
platform: 'win32',
spawnImpl: spawnImpl as never
})
expect(spawnImpl).toHaveBeenCalledWith('taskkill', ['/pid', '7777', '/t', '/f'], {
stdio: 'ignore',
windowsHide: true
})
})
/**
* Fail-open is the deliberate choice — see `orca-chromium-process-pids.ts` for
* why refusing everything is worse — so the crumb is the only thing that keeps
+34 -12
View File
@@ -4,16 +4,23 @@ import {
type SelfInitiatedTreeKillScope
} from './crash-reporting/self-initiated-tree-kill-log'
import { readOrcaChromiumProcessPids } from './orca-chromium-process-pids'
import { setProcessTreeKillGate } from '../shared/child-process/process-tree-kill-gate'
/**
* Gate every main-process tree-kill through one decision: refuse the pid when
* Electron is currently accounting for it, otherwise put it on the record.
* Gate every main-process tree-kill through one decision: refuse a pid-addressed
* walk when Electron is currently accounting for the pid, otherwise put the
* kill on the record.
*
* Why a shared gate rather than a check inside `terminateWindowsProcessTree`:
* the codex and claude account-login teardowns run their own `taskkill /T /F`
* with different lifetimes (one sync, one with its own timeout ladder), so a
* guard that only lived in the tree-kill helper would cover one of three
* families. Returns false when the caller must not kill.
* five other families in main run their own `taskkill /T /F` with different
* lifetimes (sync, fire-and-forget, timeout ladder), and three more live in
* `src/shared` and reach this through `process-tree-kill-gate`, so a guard that
* only lived in the tree-kill helper would cover one of nine.
* `main-process-tree-kill-gate.test.ts` holds that set closed by counting `/pid`
* call sites against gate admissions per file, not by file. Returns false
* when the caller must not walk that pid's tree; the caller still kills its own
* root through the child handle (`refused-tree-kill-root-termination.test.ts`),
* so a refusal is never a process leak.
*
* Electron main only, by construction. `terminateWindowsProcessTree` also runs
* in the standalone daemon (the `pty-descendant-sweep` site), where
@@ -30,11 +37,26 @@ export function admitSelfInitiatedTreeKill(target: {
}): boolean {
// Why: no PTY root, codex root or git child is ever one of our own Chromium
// processes, so a pid that is means the caller is about to kill a renderer,
// the GPU or the browser itself (#10680).
if (readOrcaChromiumProcessPids().has(target.pid)) {
recordRefusedOwnChromiumTreeKill(target)
return false
// the GPU or the browser itself (#10680). Only the pid-addressed scope can
// land there: a POSIX group holds only what Orca put in it, so that arm is
// recorded and admitted like every other group kill in main, and a stale
// `getAppMetrics()` entry cannot orphan a macOS/Linux tree.
const isOwnChromiumPid =
target.scope === 'win-taskkill-tree' && readOrcaChromiumProcessPids().has(target.pid)
try {
if (isOwnChromiumPid) {
recordRefusedOwnChromiumTreeKill(target)
} else {
recordSelfInitiatedTreeKill(target)
}
} catch {
// Recording must never turn a successful termination into a failed one, and
// never flip the decision: it is taken above, before anything can throw.
}
recordSelfInitiatedTreeKill(target)
return true
return !isOwnChromiumPid
}
/** Hands the gate to the shared choke points, which cannot import main. */
export function installMainProcessTreeKillGate(): void {
setProcessTreeKillGate((kill) => admitSelfInitiatedTreeKill(kill))
}
@@ -173,7 +173,10 @@ describe('LocalPtyProvider', () => {
expect(second).toEqual({
id: 'serve-session-1',
pid: 12345,
isReattach: true
isReattach: true,
// Why published: this attach really moved the PTY, unlike daemon/relay attach, so main
// must record 120x40 rather than preserving the size it held for the session.
attachedGrid: { cols: 120, rows: 40 }
})
expect(mockProc.resize).toHaveBeenCalledWith(120, 40)
expect(spawnMock).not.toHaveBeenCalled()
+5 -1
View File
@@ -51,8 +51,10 @@ export function reattachLocalPty(id: string, cols: number, rows: number): PtySpa
if (!existing) {
return null
}
let resized = false
try {
existing.resize(cols, rows)
resized = true
} catch {
/* Existing PTY may reject resize during teardown; still return the live handle. */
}
@@ -60,6 +62,8 @@ export function reattachLocalPty(id: string, cols: number, rows: number): PtySpa
id,
pid: existing.pid,
...(ptyWslDistroById.has(id) ? { wslDistro: ptyWslDistroById.get(id) ?? null } : {}),
isReattach: true
isReattach: true,
// Why: unlike daemon/relay attach, this one really moved the live PTY to the caller's grid.
...(resized ? { attachedGrid: { cols, rows } } : {})
}
}
+4
View File
@@ -57,6 +57,10 @@ export type PtySpawnResult = {
snapshotTerminalOwner?: TerminalOwner
/** True when the spawn reattached to an existing daemon session. */
isReattach?: boolean
/** Grid the PTY is proven to be at once this spawn settled. Only providers whose attach
* applies the requested size set it; daemon/relay attach leave the live grid alone, so main
* must not read the requested dims back as a measurement (see `resolveCommittedPtySize`). */
attachedGrid?: { cols: number; rows: number }
/** Last OSC title tracked by the daemon session the snapshot came from.
* Seeds main's terminal title records after a relaunch; never replayed
* into a terminal. */
@@ -0,0 +1,220 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { spawnMock, execFileMock, queryWindowsProcessDescendantsMock } = vi.hoisted(() => ({
spawnMock: vi.fn(),
execFileMock: vi.fn(),
queryWindowsProcessDescendantsMock: vi.fn()
}))
vi.mock('node:child_process', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
spawn: spawnMock,
execFile: execFileMock
}))
vi.mock('electron', () => ({ ipcMain: { handle: vi.fn(), on: vi.fn() } }))
vi.mock('./providers/windows-foreground-process-rows', () => ({
queryWindowsProcessDescendants: queryWindowsProcessDescendantsMock
}))
import {
getAppEnvironment,
hasAppEnvironment,
setAppEnvironment,
type AppEnvironment
} from '../shared/app-environment'
import { installMainProcessTreeKillGate } from './own-chromium-tree-kill-guard'
import { setProcessTreeKillGate } from '../shared/child-process/process-tree-kill-gate'
import { resetSelfInitiatedTreeKillLogForTest } from './crash-reporting/self-initiated-tree-kill-log'
import {
clearCrashBreadcrumbsForTest,
getCrashBreadcrumbSnapshot
} from './crash-reporting/crash-breadcrumb-store'
import { _resetTracerForTests, setActiveSink } from './observability/tracer'
import { terminateNotebookProcessTree } from './ipc/notebook'
import { killLocalPrecheckProcessTree } from './automations/precheck-runner'
import { killRecipeProcess } from '../shared/ephemeral-vm-recipe-process'
import { killSpawnedCommandTree } from './git/command-runner/spawned-command-tree-kill'
import { killCodexAppServerProcessTree } from './codex/codex-app-server-session'
import { signalProcessTree } from '../shared/child-process/process-tree-termination'
import { killSourceControlAgentProcess } from './text-generation/source-control-local-process'
import { terminateCodexTurnProcesses } from './codex/codex-structured-turn-processes'
/** A pid Electron reports as one of ours: every gate below must refuse it. */
const RENDERER_PID = 1001
function appEnvironment(): AppEnvironment {
return {
getPath: () => process.cwd(),
getAppPath: () => process.cwd(),
getVersion: () => '0.0.0-test',
isPackaged: () => false,
onWillQuit: () => {},
exit: () => {},
getAppMetrics: (() => [
{ pid: RENDERER_PID, type: 'Tab' }
]) as unknown as AppEnvironment['getAppMetrics']
}
}
let previousEnvironment: AppEnvironment | null = null
let previousPlatform: PropertyDescriptor | undefined
function setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', { value: platform, configurable: true })
}
beforeEach(() => {
previousEnvironment = hasAppEnvironment() ? getAppEnvironment() : null
previousPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
setAppEnvironment(appEnvironment())
setActiveSink(null)
clearCrashBreadcrumbsForTest()
resetSelfInitiatedTreeKillLogForTest()
installMainProcessTreeKillGate()
spawnMock.mockReset()
execFileMock.mockReset()
queryWindowsProcessDescendantsMock.mockReset()
spawnMock.mockReturnValue({ on: vi.fn(), once: vi.fn(), unref: vi.fn(), kill: vi.fn() })
})
afterEach(() => {
setProcessTreeKillGate(null)
if (previousPlatform) {
Object.defineProperty(process, 'platform', previousPlatform)
}
if (previousEnvironment) {
setAppEnvironment(previousEnvironment)
}
_resetTracerForTests()
})
/**
* A refusal must never become a process leak. The gate only blocks the
* pid-addressed tree walk; the root kill is addressed by the child handle, so it
* cannot reach the recycled pid we refused, and skipping it would report a
* timed-out command as stopped while its tree keeps running.
*/
describe('a refused tree-kill still terminates the root it owns', () => {
it('kills the git command root when the tree walk is refused', async () => {
setPlatform('win32')
const child = { pid: RENDERER_PID, kill: vi.fn() }
await killSpawnedCommandTree(child as never)
expect(spawnMock).not.toHaveBeenCalled()
expect(child.kill).toHaveBeenCalledTimes(1)
})
it('kills the notebook cell root when the tree walk is refused', () => {
setPlatform('win32')
const child = { pid: RENDERER_PID, kill: vi.fn() }
expect(terminateNotebookProcessTree(child as never)).toBeNull()
expect(spawnMock).not.toHaveBeenCalled()
expect(child.kill).toHaveBeenCalledTimes(1)
})
it('kills the automation precheck root when the tree walk is refused', () => {
setPlatform('win32')
const child = { pid: RENDERER_PID, kill: vi.fn() }
expect(killLocalPrecheckProcessTree(child as never)).toBeNull()
expect(spawnMock).not.toHaveBeenCalled()
expect(child.kill).toHaveBeenCalledTimes(1)
})
it('kills the ephemeral-VM recipe root when the tree walk is refused', () => {
setPlatform('win32')
const child = { pid: RENDERER_PID, kill: vi.fn() }
killRecipeProcess(child as never, true)
expect(spawnMock).not.toHaveBeenCalled()
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
})
it('kills the codex app-server root when the deadline tree walk is refused', () => {
const child = { pid: RENDERER_PID, kill: vi.fn() }
killCodexAppServerProcessTree(child as never, {
platform: 'win32',
spawnImpl: spawnMock as never
})
expect(spawnMock).not.toHaveBeenCalled()
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
})
it('kills the commit-message agent root when the tree walk is refused', async () => {
setPlatform('win32')
const child = { pid: RENDERER_PID, kill: vi.fn() }
await killSourceControlAgentProcess(child as never)
expect(execFileMock).not.toHaveBeenCalled()
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
})
it('kills the runProcess root when the Windows arm of the shared choke point is refused', async () => {
setPlatform('win32')
const windowsChild = { pid: RENDERER_PID, kill: vi.fn(), exitCode: null, signalCode: null }
await expect(signalProcessTree(windowsChild as never, 'SIGKILL')).resolves.toBe(false)
expect(spawnMock).not.toHaveBeenCalled()
expect(windowsChild.kill).toHaveBeenCalledWith('SIGKILL')
})
it('still signals the POSIX process group: a group only holds what Orca put in it', async () => {
// Same contract as main and as the other three POSIX group arms in main
// (claude-login, codex teardown, PTY sweep): record, never refuse. A stale
// `getAppMetrics()` entry must not orphan a macOS/Linux tree.
setPlatform('linux')
const posixChild = { pid: RENDERER_PID, kill: vi.fn(), exitCode: null, signalCode: null }
const processKill = vi.spyOn(process, 'kill').mockImplementation(() => true)
await expect(signalProcessTree(posixChild as never, 'SIGKILL')).resolves.toBe(true)
expect(processKill).toHaveBeenCalledWith(-RENDERER_PID, 'SIGKILL')
expect(posixChild.kill).not.toHaveBeenCalled()
expect(getCrashBreadcrumbSnapshot()).toEqual([
expect.objectContaining({
name: 'self_tree_kill',
data: expect.objectContaining({ pid: RENDERER_PID, scope: 'posix-process-group' })
})
])
processKill.mockRestore()
})
})
/**
* The one gated site with nothing to fall back to: the roots it kills are found
* by a process-table walk, not spawned here, so there is no child handle. A
* refusal must then be visible — the refusal crumb is written and the turn is
* reported as not cancelled — rather than resolving as if the tree had gone.
*/
describe('a refused tree-kill with no handle to fall back to', () => {
it('reports the codex turn as not cancelled and records the refused added root', async () => {
const appServerPid = 500
const addedRoot = {
pid: RENDERER_PID,
ppid: appServerPid,
name: 'node.exe',
command: 'node',
depth: 1
}
queryWindowsProcessDescendantsMock.mockResolvedValue([addedRoot])
await expect(
terminateCodexTurnProcesses(appServerPid, { platform: 'win32', identities: new Map() })
).resolves.toBe(false)
expect(execFileMock).not.toHaveBeenCalled()
expect(getCrashBreadcrumbSnapshot()).toEqual([
expect.objectContaining({
name: 'self_tree_kill_refused_own_chromium',
data: expect.objectContaining({ pid: RENDERER_PID, site: 'codex-turn-added-roots' })
})
])
})
})
@@ -163,6 +163,17 @@ export class OrcaRuntimeWithCreatePtyHeadlessTerminalState extends OrcaRuntimeWi
})
}
/** Public: reflow an already-created model onto a grid the PROVIDER proved — a reattach learns
* the live session's real size only from its spawn reply, after live bytes may have lazily
* created the model at the 80x24 default. Not onExternalPtyResize: nothing measured a pane
* here, so the renderer-geometry baselines behind mobile take-back must stay untouched. */
reflowHeadlessTerminalToPtyGrid(ptyId: string, cols: number, rows: number): void {
if (cols <= 0 || rows <= 0) {
return
}
this.resizeHeadlessTerminal(ptyId, cols, rows)
}
// Public: desktop-initiated clears (ipc/pty.ts) must also drop this mobile
// mirror or a resubscribing mobile client resurrects the cleared scrollback.
async clearHeadlessTerminalBuffer(ptyId: string): Promise<void> {
@@ -3,6 +3,8 @@ import { OrcaRuntimeWithStopStructuredSessionProcess } from './orca-runtime-stop
import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority'
import { agentSessionOwnerBindingsEqual } from '../../shared/claimed-agent-pty-owner-snapshot'
import { resolvePinnedCodexRolloutProof } from '../codex/codex-tui-rollout-proof'
import { supportsCodexStructuredLocation } from '../codex/codex-structured-location-support'
import { supportsClaudeStructuredLocation } from '../claude/claude-structured-location-support'
import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
import { resolveStructuredAgentSessionCreateSupport } from '../native-chat/structured-agent-session-create-support'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
@@ -51,13 +53,13 @@ export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends Orca
agent: 'claude' | 'codex'
): Promise<{ supported: boolean; reason?: 'agent' | 'remote' | 'wsl' }> {
const location = await this.resolveStructuredAgentSessionLocation(worktreeSelector)
await this.ensureStructuredAgentSessionHost()
// The verdict lives in a typechecked module; this file is @ts-nocheck.
return resolveStructuredAgentSessionCreateSupport({
agent,
location,
adapterSupportsCreate:
getStructuredAgentSessionHost()?.supportsCreate(location, agent) === true,
agent === 'claude'
? supportsClaudeStructuredLocation(location)
: supportsCodexStructuredLocation(location),
getSettings: () => this.requireStore().getSettings()
})
}

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