mirror of
https://github.com/stablyai/orca.git
synced 2026-10-06 16:02:25 +00:00
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:
@@ -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:
|
||||
|
||||
@@ -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') }
|
||||
|
||||
@@ -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(',')}`)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
@@ -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',
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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']
|
||||
});
|
||||
}
|
||||
})()`
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 } } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user