diff --git a/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml b/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml index 6430c3f4793..d5c8134934b 100644 --- a/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml +++ b/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml @@ -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 diff --git a/.github/workflows/cloud-deploy-relay-production-same-cap.yml b/.github/workflows/cloud-deploy-relay-production-same-cap.yml index 1994966d083..fba5df0dcb9 100644 --- a/.github/workflows/cloud-deploy-relay-production-same-cap.yml +++ b/.github/workflows/cloud-deploy-relay-production-same-cap.yml @@ -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 } diff --git a/.github/workflows/cloud-operate-relay-production-rehome-job.yml b/.github/workflows/cloud-operate-relay-production-rehome-job.yml index 682953af5e7..fdb1aca45e0 100644 --- a/.github/workflows/cloud-operate-relay-production-rehome-job.yml +++ b/.github/workflows/cloud-operate-relay-production-rehome-job.yml @@ -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: diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index ca1651325c9..a749214e232 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -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 diff --git a/cloud/apps/relay-ops/src/incident-live-preflight-cli.test.ts b/cloud/apps/relay-ops/src/incident-live-preflight-cli.test.ts index 18ee4495078..3252ec7645e 100644 --- a/cloud/apps/relay-ops/src/incident-live-preflight-cli.test.ts +++ b/cloud/apps/relay-ops/src/incident-live-preflight-cli.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 diff --git a/cloud/apps/relay-ops/src/incident-live-preflight-cli.ts b/cloud/apps/relay-ops/src/incident-live-preflight-cli.ts index e82627a3e80..fc2c3a99751 100644 --- a/cloud/apps/relay-ops/src/incident-live-preflight-cli.ts +++ b/cloud/apps/relay-ops/src/incident-live-preflight-cli.ts @@ -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}`) diff --git a/cloud/apps/relay-ops/src/resource-inventory.test.ts b/cloud/apps/relay-ops/src/resource-inventory.test.ts index 4bf43fe9a7a..e2cfa13dccb 100644 --- a/cloud/apps/relay-ops/src/resource-inventory.test.ts +++ b/cloud/apps/relay-ops/src/resource-inventory.test.ts @@ -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') } diff --git a/cloud/apps/relay-ops/src/resource-inventory.ts b/cloud/apps/relay-ops/src/resource-inventory.ts index c1d01191baf..da490685198 100644 --- a/cloud/apps/relay-ops/src/resource-inventory.ts +++ b/cloud/apps/relay-ops/src/resource-inventory.ts @@ -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 => + 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, + wait: (ms: number) => Promise +): Promise { + 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 { 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 +} + export async function readResourceInventory( environment: RelayOpsEnvironment, gcloud: GcloudClient, - fetchImpl: typeof fetch = fetch + fetchImpl: typeof fetch = fetch, + options: ResourceInventoryOptions = {} ): Promise { + 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) diff --git a/cloud/dev/scripts/operate-relay-regional-rehome.mjs b/cloud/dev/scripts/operate-relay-regional-rehome.mjs index 2ccce39926d..3887408520f 100644 --- a/cloud/dev/scripts/operate-relay-regional-rehome.mjs +++ b/cloud/dev/scripts/operate-relay-regional-rehome.mjs @@ -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') { diff --git a/cloud/dev/scripts/operate-relay-regional-rehome.test.mjs b/cloud/dev/scripts/operate-relay-regional-rehome.test.mjs index 8ffe38dfe09..bfea6769ec4 100644 --- a/cloud/dev/scripts/operate-relay-regional-rehome.test.mjs +++ b/cloud/dev/scripts/operate-relay-regional-rehome.test.mjs @@ -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) +}) diff --git a/cloud/dev/scripts/prepare-relay-production-capacity-canary.mjs b/cloud/dev/scripts/prepare-relay-production-capacity-canary.mjs index 5791c9f20e6..e5c18237453 100644 --- a/cloud/dev/scripts/prepare-relay-production-capacity-canary.mjs +++ b/cloud/dev/scripts/prepare-relay-production-capacity-canary.mjs @@ -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) diff --git a/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs b/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs index c5d0a9db3bc..ff397802860 100644 --- a/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs +++ b/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs @@ -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) + }) }) diff --git a/cloud/dev/scripts/probe-relay-rehome-trust.mjs b/cloud/dev/scripts/probe-relay-rehome-trust.mjs index 7500d8bd14c..9a7d505d4bb 100644 --- a/cloud/dev/scripts/probe-relay-rehome-trust.mjs +++ b/cloud/dev/scripts/probe-relay-rehome-trust.mjs @@ -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) { diff --git a/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs b/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs index 509e9d53c7d..7d7b2cd95ac 100644 --- a/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs +++ b/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs @@ -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) +}) diff --git a/cloud/dev/scripts/relay-admin-endpoint-retry-workflow.test.mjs b/cloud/dev/scripts/relay-admin-endpoint-retry-workflow.test.mjs new file mode 100644 index 00000000000..196fff9edf3 --- /dev/null +++ b/cloud/dev/scripts/relay-admin-endpoint-retry-workflow.test.mjs @@ -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 /) +}) diff --git a/cloud/dev/scripts/relay-admin-transient-retry.mjs b/cloud/dev/scripts/relay-admin-transient-retry.mjs new file mode 100644 index 00000000000..9995be96a8f --- /dev/null +++ b/cloud/dev/scripts/relay-admin-transient-retry.mjs @@ -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() +} diff --git a/cloud/dev/scripts/relay-admin-transient-retry.test.mjs b/cloud/dev/scripts/relay-admin-transient-retry.test.mjs new file mode 100644 index 00000000000..ec041084344 --- /dev/null +++ b/cloud/dev/scripts/relay-admin-transient-retry.test.mjs @@ -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}') +}) diff --git a/cloud/dev/scripts/relay-evidence-code-provenance.mjs b/cloud/dev/scripts/relay-evidence-code-provenance.mjs new file mode 100644 index 00000000000..233a8139b85 --- /dev/null +++ b/cloud/dev/scripts/relay-evidence-code-provenance.mjs @@ -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(',')}`) + } +} diff --git a/cloud/dev/scripts/relay-monitor-evidence.mjs b/cloud/dev/scripts/relay-monitor-evidence.mjs index 7f387663f60..26eb37d0d4d 100644 --- a/cloud/dev/scripts/relay-monitor-evidence.mjs +++ b/cloud/dev/scripts/relay-monitor-evidence.mjs @@ -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') ) diff --git a/cloud/dev/scripts/relay-monitor-evidence.test.mjs b/cloud/dev/scripts/relay-monitor-evidence.test.mjs index 45116761119..43d2ac02763 100644 --- a/cloud/dev/scripts/relay-monitor-evidence.test.mjs +++ b/cloud/dev/scripts/relay-monitor-evidence.test.mjs @@ -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` + ) + } +}) diff --git a/cloud/dev/scripts/relay-production-same-cap-wave.mjs b/cloud/dev/scripts/relay-production-same-cap-wave.mjs index e391c4c4381..6e84c1c9104 100644 --- a/cloud/dev/scripts/relay-production-same-cap-wave.mjs +++ b/cloud/dev/scripts/relay-production-same-cap-wave.mjs @@ -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 } diff --git a/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs b/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs index 0b45ae85a99..d636c324b33 100644 --- a/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs +++ b/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs @@ -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 }) + } +}) diff --git a/cloud/dev/scripts/relay-regional-rehome-workflow.test.mjs b/cloud/dev/scripts/relay-regional-rehome-workflow.test.mjs index e31403f6dd6..a9e92d6cd57 100644 --- a/cloud/dev/scripts/relay-regional-rehome-workflow.test.mjs +++ b/cloud/dev/scripts/relay-regional-rehome-workflow.test.mjs @@ -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. diff --git a/cloud/dev/scripts/relay-repository.mjs b/cloud/dev/scripts/relay-repository.mjs index 7e8b01e4799..040acef5fe5 100644 --- a/cloud/dev/scripts/relay-repository.mjs +++ b/cloud/dev/scripts/relay-repository.mjs @@ -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}` } diff --git a/cloud/dev/scripts/verify-relay-capacity-transition.mjs b/cloud/dev/scripts/verify-relay-capacity-transition.mjs index e5ebe77d45f..b81ea15afb3 100644 --- a/cloud/dev/scripts/verify-relay-capacity-transition.mjs +++ b/cloud/dev/scripts/verify-relay-capacity-transition.mjs @@ -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 diff --git a/cloud/dev/scripts/verify-relay-capacity-transition.test.mjs b/cloud/dev/scripts/verify-relay-capacity-transition.test.mjs index fb865257c4a..e596ffbced7 100644 --- a/cloud/dev/scripts/verify-relay-capacity-transition.test.mjs +++ b/cloud/dev/scripts/verify-relay-capacity-transition.test.mjs @@ -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) +}) diff --git a/cloud/infra/terraform/relay-observability.tf b/cloud/infra/terraform/relay-observability.tf index 964fc1060e6..6bc938100c6 100644 --- a/cloud/infra/terraform/relay-observability.tf +++ b/cloud/infra/terraform/relay-observability.tf @@ -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 { diff --git a/cloud/package.json b/cloud/package.json index c75d027d3d2..788b6ea2629 100644 --- a/cloud/package.json +++ b/cloud/package.json @@ -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": { diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 11b837f0cb6..3df3e2579fa 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -2855,7 +2855,7 @@ "https://github.com/stablyai/orca/pull/13876" ], "invariant": "Opening one HTML preview from a paired client renders the workspace document in exactly one client-local browser tab, located by that document and served over the orca-preview scheme. The client gains exactly that one browser workspace and it is the document one — blank where a URL page carries a URL, named by the document, with the chip naming the file — while the host gains no browser page at all, neither in its own page registry nor in the tab snapshot its clients publish into. The preview occupies its own split without taking focus from the source editor; an explicit click activates it, and closing it removes only the preview. Following a document from a file link is the other half of that switch and does move the reader to it, tab group included, whether the preview is new or already open, because opening a file is a request to look at it. A document tab quit with the client comes back as the same row on a grant the relaunched client mints afresh. A preview is named by the browser page it is open in, not by a namespace of its own, and the page registry has two halves: a workspace-document guest is registered in its own map and is absent from the browsing one entirely. That absence is the fence. Page, session and profile management, agent tab enumeration and command targeting, download routing and certificate attribution all read the browsing map directly, in more places than a per-channel guard could be remembered in, so none of them can name a document page and none of them carries a guard. Browser tools the reader drives (element grab, hover describe, selection capture, the annotation viewport bridge) are the one operation that legitimately spans the halves, and they go through the single authority that reads both, keyed by the page and its hosting renderer. The halves are disjoint in both directions: browsing registration refuses a page the document half already holds, and minting a grant refuses a page the browsing half already holds, so one id can never name a surface in both. The headless backend acts on that refusal by destroying the window it had already opened rather than leaving a policy-less page behind an id nothing can drive, keeping nothing under that id for its own shutdown to hand back. Registration refuses on the same terms when the guest it was asked about is already gone. The exit door is guarded in both its halves: a preview withdraws by revoking its grant and never through the unregister channel, so a page the document half holds arriving there is refused before either the registration teardown or the grab-state disposal beside it, which would otherwise drop the intent an in-flight preview grab compares by identity and leave that grab answering ok without ever arming its guest. A bridge request whose guest does not resolve is refused without tearing down the page it named, so a misaddressed request cannot cancel a healthy page's in-flight downloads and grabs. The annotation viewport bridge resolves its guest when its serialized op actually runs rather than when the request arrived, so a cross-process navigation while it waited cannot leave the bridge installed in a retired guest while the reader looks at a new one. State main keys by a preview's page is disposed when that page's grant is revoked, which is the only signal a preview's surface is gone. A tool asking for a page whose guest has not attached yet waits for that registration and arms when it arrives, rather than answering not-ready at the reader; that wait resolves only the request already naming this page, never the worktree-wide or any-tab waits the CLI and agents use to ask for a browser tab to drive. Handing the previewed document to the reader's own machine routes on the owners its grant was minted against — the file's own connection owner and the worktree's own runtime owner, neither read from the tab's stored fields. Only a document proven to live on this machine reaches the client OS; one with a resolved remote owner is downloaded first; and one whose owner cannot be resolved at all, workspace root included, is refused with a message naming that, because the download route would otherwise read the same absolute path on the client and hand back a same-named local file under the remote document's name. A runtime-owned path that falls outside its worktree root is refused by that route itself and surfaces as a failure toast rather than a download. Nothing the document does writes a file to this machine either: the preview partition denies downloads outright instead of routing them through the browser download flow, which has no page to attribute a preview's bytes to and would otherwise reserve a name in this desktop's Downloads folder and write them there unprompted. That refusal is visible to the reader and invisible to the document: the preview's shell carries a fixed sentence saying downloads are off, published at most once per preview per interval so a document asking in a loop cannot fill Orca's chrome, while the page itself gets back exactly what it got before, which is nothing. The sentence names no file, because the document chooses the name it offers; and a refusal never takes the document away the way an entry document's own failure does, whatever it names. A preview is a browser tab, not an editor tab in a preview mode: it is named the way a browser tab is named — by the document it shows when that document declares a title, and by the file it shows when it does not — while the chip goes on naming the file and the host whatever the document calls itself. A title is refused on the same terms the url is: a document that declares none has Chromium report the grant URL as its title, and that title is stored, mirrored onto the tab and written to disk, so anything carrying the scheme falls back to the file instead. It is created by the preview action as a page located by its document, it carries the workspace-relative path copy the editor's path header owned, and closing it revokes the grant that made the document readable while a URL tab closing beside it revokes nothing. Chrome persisted by builds that made previews editor tabs is dropped on restore rather than coming back naming a surface no restore can produce, and the ordinary editor tab for the same document is left alone. A document tab is held back at the mobile publish boundary — no client holds its grant, and the wire has no tab kind for it — while an ordinary browser tab beside it still publishes. It is held back from the group projection that publishes tab order, recency and group activity as well as from the tab list itself, so no published group names a tab the phone is never sent. A browser page can be located by a workspace document instead of a URL, and the document is the whole of its stored identity. The grant and the orca-preview URL that document is served over are minted when the page mounts and replaced by a hard reload, so neither is ever written to the page's url, mirrored onto its tab, persisted or published: such a page's url is the blank URL from creation through restore, including when a session written elsewhere carries a grant URL in, and what the session carries is the worktree and path a restored page mints afresh against today's owners. Every door onto a page's url holds that line — creation, the title update, and the navigation commit alike — so a report about a document page cannot give it a URL it never had, and the title fallback and the loading affordance follow the url each door actually wrote. The mirror carries the document too, so a tab entry cannot go on naming a document its active page has left. Every guest in the app is policy-attached through one door: a workspace document takes a restricted profile there rather than a separate installer beside it, so the attachment bookkeeping that door owns — what registration refuses, and what teardown frees — covers a preview on the same terms as a browsing page, and a preview takes none of the browsing machinery that door installs. That authority answers from the moment the embedder hands the guest over rather than only after a later navigation: the guest binds to the grant it is already showing, so the tools reach the document the reader opened and not just one they navigated to. A read the host reports as truncated or over-cap is refused rather than served partially, and a document outside the paired worktree is refused with a message naming that boundary instead of a bare read failure. The rendered document reaches nothing off-machine on its own: every served response carries a self-only content security policy, the preview session cancels any request that is not in-document, subframes cannot navigate outside the grant, a guest no document has yet bound to a grant may not navigate at all, the guest gathers no ICE candidates, and an SSH path that canonicalizes outside the grant root is refused before it is read. The one route out is a link the reader presses: a trusted click on an anchor, reported by the preview's own preload from a guest still bound to a live grant, leaves as an Orca browser tab rather than a native window or a dead click — and only after the reader confirms the exact destination URL, so a document cannot spend a single stray press exfiltrating what it can read into a link it authored. The preview hands its guest that focus itself whenever it is the surface the reader is in — a browsing page gets it from the chrome around it, and a preview has no chrome to get it from — and it does so only then, so a preview mounted behind a terminal or an editor never takes the keyboard from what the reader is actually in. It offers again when the window itself takes focus back and nothing in the embedder has claimed that focus, because another app coming to the front lands focus on the embedder rather than the guest and the route out would otherwise stay shut until something remounted the pane — while the same window focus also arrives when the reader presses a tab, that being the guest's own blur returning, and taking focus back from there would fight the reader for their own click. Nothing else does. A navigation or popup the document starts by itself is swallowed whatever else is happening, including immediately after a genuine press elsewhere in the document, so a page that can read its grant cannot hand it to a browser tab; a middle click opens nothing; and a fragment link is answered inside the document. A preview attach carries the preview preload and no renderer-supplied one, and no other attach path can acquire it. A subresource the workspace will not send degrades the document to a notice naming that file, never to a failure panel over a page that rendered. A grant outlives neither the tab that owns it nor the renderer document that minted it, and only the trusted renderer can mint or revoke one. For the browser creations this gate still owns, owner-pinned creation returns the canonical host page identity before navigation readiness; delayed navigation cannot turn a created page into an unidentifiable failure or a duplicate retry. Capability rejection before host mutation must preserve the original error, issue no RPC, surface a failure toast, and remove only a caller-declared newly-created empty split. Post-create reconciliation failure requires exact rollback; ambiguous rollback rejects without local fallback.", - "oracle": "In paired Electron, write an HTML fixture that declares its own title on the host, invoke the Explorer preview action on the client, and require the document text to be readable out of the orca-preview guest before judging any absence. With that presence established, require the client to hold exactly one browser workspace more than its baseline and that workspace to be the document one: page and tab url blank, the document path mirrored onto both, the tab named by the document's title, the chip naming the file, no editor row of the retired preview species anywhere, and the guest URL carrying the orca-preview scheme. Ask the host through its own page registry as well as through the tab snapshot, and in the same run open an ordinary URL browser tab from the same client and require that one to arrive in both — the presence precondition without which “the host gained nothing” is satisfied just as well by an oracle that cannot see browser pages at all. Require the preview to sit in a group other than the source editor's while the active group and tab remain the source editor's. Then click away to the terminal, click the preview tab, require it to reactivate and still render, close it with its own X, and require the document tab to be gone while the host still holds only the URL tab and the source group, source editor and terminal survive. Quit the client with a document tab open and relaunch it on the same profile: require the same workspace row to come back, blank and named by the document, rendering the document again over a grant URL that differs from the one that was quit, with no preview-scheme or document-named page anywhere in what the host holds. Prove the halves are live by flipping one product property at a time and requiring the run to fail: publish document workspaces to the host like ordinary ones, and stop mirroring the document onto the workspace row. Have the fixture document attempt its own egress on every load — an unattended window.open and location.href to an off-machine URL, plus an inline ICE gathering probe — and require the same baseline counts and a candidate count of zero, so the document's own attempts are measured rather than assumed. Then, as a separate phase after the close oracle has already run, bring the client window to the front, press the document's heading with a real mouse event, and require the document to report that the same press drove it to attempt a second window.open and location.href while both browser counts stay at that phase's baseline and nothing routes — the case a recent-input gate cannot distinguish from the press's own effect. Only then press the target=_blank link with a real mouse event and require both a recorded routing call that returned success and a browser count above that baseline, with the preview tab still open. Drive the preload's click policy as a unit oracle over a real document: a dispatched click, a trusted press on an external anchor, an anchor reached through what it wraps, an SVG animated href, a sibling preview link, fragment and percent-encoded fragment targets, a bare hash, and a middle click. Create a browser page located by a workspace document, handing creation a live grant URL, and require its stored url, its mirrored tab url and the written session payload all to be blank with no orca-preview string anywhere in what was written, while an ordinary page created the same way keeps the URL it was given and asks for the address bar the document page never does. Parse the written page and tab through the session schema and require the document to survive both halves. Hydrate them back and require the document page to return blank and still named — including when its page row was salvaged away and only the tab's own copy remains, and when a foreign session carried a grant URL into both rows. Drive the mirror across a page switch out of the document and back, and across a repair in which the document is the only mirrored field that differs. Name a document page from its document and require the tab to take that name, name it with an empty title and require the file, name it with a live grant URL and require the file again with no preview scheme anywhere in the written session, and require an ordinary blank browser tab beside it to still be called New Tab. Dispatch a title update out of a rendered preview's own guest and require it to reach the page state while the identity chip still reads the document's workspace-relative path. Attach a browsing guest and a workspace-document guest through the same method in one run and require the browsing one to take clicked-link routing, popup handling and anti-detection while the document guest takes none of them, stays inside the grant it is showing, denies every window it asks for, and is dropped from the page-keyed document registry by the same teardown that frees its id for a later attach. Register a browsing guest and attach a workspace-document guest in one run against the real manager, require the one door to answer each page with the guest of its own half, and require the document page to be absent from the browsing map and from its enumeration. Drive both browsing registration entry points with a page the document half already holds and require them to register nothing, and drive the mint channel with a page the browsing half already holds and require it to refuse; and drive the offscreen one with a guest that is missing and with one already destroyed, requiring the same refusal. Arm a grab on a live preview target, drive the unregister channel at that same target in the window before the queued operation runs, and require the grab to reach the guest anyway — then drive the same sequence for an ordinary browser page and require its grab state to be disposed after all. Hold one viewport-bridge op open, queue a second behind it, swap the page's guest while that second op waits, and require the injection to land in the guest the page has then. Revoke a grant after a tool has run against its page and require that page's grab state to be cancelled and disposed. Ask a tool for a document page whose guest has not attached, require the request to park in the registration wait, attach the guest, and require the same request to arm on it; require a page nothing ever renders to answer not-ready once that wait elapses. With a document open, ask a tool for a browsing page id and require it to be answered by the browsing half or not at all, with the same channel reaching the document guest under the page it really renders. Drive open-externally for a document whose per-file owner is remote while the workspace-scoped owner is unresolved, for a runtime-owned worktree whose preview tab carries no runtime id of its own, and for a worktree that resolves no runtime owner while the tab still carries one, requiring the download route in each; and for an owner that cannot be resolved at all, and for an unknown workspace root while nothing names another host, requiring a refusal that neither opens nor downloads. Drive the headless backend with a page the document half already holds and require it to reject, destroy the window, and unregister nothing — then shut the backend down and require it still to have unregistered nothing. Navigate a bound preview guest at a second grant through both latch events and require it to stay on the grant it bound to. Mount a preview while a renderer drag is already in flight and require its guest to be click-through at the moment it is appended, not a turn later. Render the editor panel shell in each remaining tab mode and require the path header exactly where the surface does not already name itself. Drive the preview action and require a browser tab located by the document rather than an editor tab, require a second open of the same document to activate the tab it is already in, and require closing that tab to revoke its grant while a URL tab closed beside it revokes none. Hydrate a session carrying preview chrome from a build that made previews editor tabs and require it dropped while the ordinary editor tab for the same document survives. Publish a worktree holding a document tab and a URL tab and require only the URL tab to reach the mobile snapshot. Install the shared partition policies for a preview partition and for an ordinary browsing partition in the same run, fire each one's own will-download listener, and require the preview's to cancel while the browsing one still reaches the download router — then require the preview protocol installer to be what asks for that deny. In the same run, require the cancelled download to raise a reader-facing notice and the routed one to raise none. Drive that notice directly for a guest bound to a live grant, for repeated attempts inside and outside its interval, for two previews at once, and for a contents no preview is bound to; require the guest registry to name the bound grant for a live preview guest and nothing for a contents that is not one, has committed no document, or is gone. Drive the shell with a refusal and require one fixed sentence, still one row after three more refusals, standing beside an asset failure rather than being counted with it, gone behind the failure panel, and ignored when it names another preview's grant. Drive the main-side report gate directly for a sender that is no preview guest, a guest with no bound or a revoked grant, a genuine press Electron's webview focus flag misreports as unfocused, and non-web URLs; drive the reader-facing confirmation for accept, cancel, and a confirmed tab the browser refuses; and drive will-attach-webview in both preload directions. Run the per-owner reader, grant-containment, scheme-admission, guest-policy, and plan-routing contracts as unit oracles, including a host-reported truncation, an over-cap binary, and an out-of-worktree paired path. Drive the reader-facing component with the payloads the reader can actually produce — the entry document fails only as truncated or unreadable, a subresource additionally as a refused format — and require the asset case to leave the guest mounted. Drive the closed-tab cleanup hook, the window installer, and the grant IPC handlers directly, requiring the grant to be released when the preview tab closes, cleared at window creation and on a cross-document main-frame navigation, and refused to any sender that is not the trusted renderer. For the browser creations this gate still owns, run the unchanged contract oracle for direct create and side-preview callers with absent status, unknown capabilities, and a mixed-version host, requiring the original unsupported error or visible toast, zero RPCs, and no retained new split; hold a real navigation response beyond the 15-second client deadline after host creation and require the first RPC to return the exact host inventory page ID, one host page, and no retry; repeat reconciliation faults against headless serve and retain the separate exact-page reconciliation rollback oracle.", + "oracle": "In paired Electron, write an HTML fixture that declares its own title on the host, invoke the Explorer preview action on the client, and require the document text to be readable out of the orca-preview guest before judging any absence. With that presence established, require the client to hold exactly one browser workspace more than its baseline and that workspace to be the document one: page and tab url blank, the document path mirrored onto both, the tab named by the document's title, the chip naming the file, no editor row of the retired preview species anywhere, and the guest URL carrying the orca-preview scheme. Ask the host through its own page registry as well as through the tab snapshot, and in the same run open an ordinary URL browser tab from the same client and require that one to arrive in both — the presence precondition without which “the host gained nothing” is satisfied just as well by an oracle that cannot see browser pages at all. Require the preview to sit in a group other than the source editor's while the active group and tab remain the source editor's. Then click away to the terminal, click the preview tab, require it to reactivate and still render, close it with its own X, and require the document tab to be gone while the host still holds only the URL tab and the source group, source editor and terminal survive. Quit the client with a document tab open and relaunch it on the same profile: require the same workspace row to come back, blank and named by the document, rendering the document again over a grant URL that differs from the one that was quit, with no preview-scheme or document-named page anywhere in what the host holds. Prove the halves are live by flipping one product property at a time and requiring the run to fail: publish document workspaces to the host like ordinary ones, and stop mirroring the document onto the workspace row. Have the fixture document attempt its own egress on every load — an unattended window.open and location.href to an off-machine URL, plus an inline ICE gathering probe — and require the same baseline counts and a candidate count of zero, so the document's own attempts are measured rather than assumed. Then, as a separate phase after the close oracle has already run, bring the client window to the front, press the document's heading with a real mouse event, and require the document to report that the same press drove it to attempt a second window.open and location.href while both browser counts stay at that phase's baseline and nothing routes — the case a recent-input gate cannot distinguish from the press's own effect. Only then press the target=_blank link with a real mouse event and require both a recorded routing call that returned success and a browser count above that baseline, with the preview tab still open. Drive the preload's click policy as a unit oracle over a real document: a dispatched click, a trusted press on an external anchor, an anchor reached through what it wraps, an SVG animated href, a sibling preview link, fragment and percent-encoded fragment targets, a bare hash, and a middle click. Create a browser page located by a workspace document, handing creation a live grant URL, and require its stored url, its mirrored tab url and the written session payload all to be blank with no orca-preview string anywhere in what was written, while an ordinary page created the same way keeps the URL it was given and asks for the address bar the document page never does. Parse the written page and tab through the session schema and require the document to survive both halves. Hydrate them back and require the document page to return blank and still named — including when its page row was salvaged away and only the tab's own copy remains, and when a foreign session carried a grant URL into both rows. Drive the mirror across a page switch out of the document and back, and across a repair in which the document is the only mirrored field that differs. Name a document page from its document and require the tab to take that name, name it with an empty title and require the file, name it with a live grant URL and require the file again with no preview scheme anywhere in the written session, and require an ordinary blank browser tab beside it to still be called New Tab. Dispatch a title update out of a rendered preview's own guest and require it to reach the page state while the identity chip still reads the document's workspace-relative path. Attach a browsing guest and a workspace-document guest through the same method in one run and require the browsing one to take clicked-link routing, popup handling and auth-identity detach tracking while the document guest takes none of them, stays inside the grant it is showing, denies every window it asks for, and is dropped from the page-keyed document registry by the same teardown that frees its id for a later attach. Register a browsing guest and attach a workspace-document guest in one run against the real manager, require the one door to answer each page with the guest of its own half, and require the document page to be absent from the browsing map and from its enumeration. Drive both browsing registration entry points with a page the document half already holds and require them to register nothing, and drive the mint channel with a page the browsing half already holds and require it to refuse; and drive the offscreen one with a guest that is missing and with one already destroyed, requiring the same refusal. Arm a grab on a live preview target, drive the unregister channel at that same target in the window before the queued operation runs, and require the grab to reach the guest anyway — then drive the same sequence for an ordinary browser page and require its grab state to be disposed after all. Hold one viewport-bridge op open, queue a second behind it, swap the page's guest while that second op waits, and require the injection to land in the guest the page has then. Revoke a grant after a tool has run against its page and require that page's grab state to be cancelled and disposed. Ask a tool for a document page whose guest has not attached, require the request to park in the registration wait, attach the guest, and require the same request to arm on it; require a page nothing ever renders to answer not-ready once that wait elapses. With a document open, ask a tool for a browsing page id and require it to be answered by the browsing half or not at all, with the same channel reaching the document guest under the page it really renders. Drive open-externally for a document whose per-file owner is remote while the workspace-scoped owner is unresolved, for a runtime-owned worktree whose preview tab carries no runtime id of its own, and for a worktree that resolves no runtime owner while the tab still carries one, requiring the download route in each; and for an owner that cannot be resolved at all, and for an unknown workspace root while nothing names another host, requiring a refusal that neither opens nor downloads. Drive the headless backend with a page the document half already holds and require it to reject, destroy the window, and unregister nothing — then shut the backend down and require it still to have unregistered nothing. Navigate a bound preview guest at a second grant through both latch events and require it to stay on the grant it bound to. Mount a preview while a renderer drag is already in flight and require its guest to be click-through at the moment it is appended, not a turn later. Render the editor panel shell in each remaining tab mode and require the path header exactly where the surface does not already name itself. Drive the preview action and require a browser tab located by the document rather than an editor tab, require a second open of the same document to activate the tab it is already in, and require closing that tab to revoke its grant while a URL tab closed beside it revokes none. Hydrate a session carrying preview chrome from a build that made previews editor tabs and require it dropped while the ordinary editor tab for the same document survives. Publish a worktree holding a document tab and a URL tab and require only the URL tab to reach the mobile snapshot. Install the shared partition policies for a preview partition and for an ordinary browsing partition in the same run, fire each one's own will-download listener, and require the preview's to cancel while the browsing one still reaches the download router — then require the preview protocol installer to be what asks for that deny. In the same run, require the cancelled download to raise a reader-facing notice and the routed one to raise none. Drive that notice directly for a guest bound to a live grant, for repeated attempts inside and outside its interval, for two previews at once, and for a contents no preview is bound to; require the guest registry to name the bound grant for a live preview guest and nothing for a contents that is not one, has committed no document, or is gone. Drive the shell with a refusal and require one fixed sentence, still one row after three more refusals, standing beside an asset failure rather than being counted with it, gone behind the failure panel, and ignored when it names another preview's grant. Drive the main-side report gate directly for a sender that is no preview guest, a guest with no bound or a revoked grant, a genuine press Electron's webview focus flag misreports as unfocused, and non-web URLs; drive the reader-facing confirmation for accept, cancel, and a confirmed tab the browser refuses; and drive will-attach-webview in both preload directions. Run the per-owner reader, grant-containment, scheme-admission, guest-policy, and plan-routing contracts as unit oracles, including a host-reported truncation, an over-cap binary, and an out-of-worktree paired path. Drive the reader-facing component with the payloads the reader can actually produce — the entry document fails only as truncated or unreadable, a subresource additionally as a refused format — and require the asset case to leave the guest mounted. Drive the closed-tab cleanup hook, the window installer, and the grant IPC handlers directly, requiring the grant to be released when the preview tab closes, cleared at window creation and on a cross-document main-frame navigation, and refused to any sender that is not the trusted renderer. For the browser creations this gate still owns, run the unchanged contract oracle for direct create and side-preview callers with absent status, unknown capabilities, and a mixed-version host, requiring the original unsupported error or visible toast, zero RPCs, and no retained new split; hold a real navigation response beyond the 15-second client deadline after host creation and require the first RPC to return the exact host inventory page ID, one host page, and no retry; repeat reconciliation faults against headless serve and retain the separate exact-page reconciliation rollback oracle.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-browser.test.ts src/main/runtime/rpc/methods/browser.test.ts src/renderer/src/lib/file-preview.test.ts src/renderer/src/runtime/web-session-browser-placement.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/runtime/remote-server-parity.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-materialization.test.ts", diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index f5a73f6239f..15ded915c67 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -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', diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index c240c965fee..fe660c42295 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 39m + + downloads: 40m @@ -15,7 +15,7 @@ downloads downloads - 39m - 39m + 40m + 40m diff --git a/docs/site/content/docs/browser/profiles.mdx b/docs/site/content/docs/browser/profiles.mdx index 97286a6cb0c..102dd1e443d 100644 --- a/docs/site/content/docs/browser/profiles.mdx +++ b/docs/site/content/docs/browser/profiles.mdx @@ -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 diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt index 54837bf4d65..b79ed543494 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt @@ -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 diff --git a/src/main/automations/precheck-runner.ts b/src/main/automations/precheck-runner.ts index 753bd784b06..ab38fd42355 100644 --- a/src/main/automations/precheck-runner.ts +++ b/src/main/automations/precheck-runner.ts @@ -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 | null { +/** Exported for the refusal-fallback test; the timeout path is otherwise unreachable. */ +export function killLocalPrecheckProcessTree( + child: ChildProcess +): ReturnType | null { const pid = child.pid if (!pid) { child.kill() @@ -81,6 +85,18 @@ function killLocalPrecheckProcessTree(child: ChildProcess): ReturnType void) => Promise - } - PermissionStatus: PermissionStatusConstructor - dispatchPermissionChange: (name: string) => void - navigator: { - permissions: { - query: (descriptor: { name: string }) => Promise - } - } -} - -function createContext(args: { - nativeNotificationPermission: string - requestedNotificationPermission: string - rejectedPermissions?: string[] -}): AntiDetectionContext & Record { - 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() - const rejectedPermissions = new Set(args.rejectedPermissions) - - class Permissions { - query(descriptor: { name: string }): Promise { - 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 { - 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 -} - -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([]) - }) -}) diff --git a/src/main/browser/anti-detection.test.ts b/src/main/browser/anti-detection.test.ts deleted file mode 100644 index ddb904cb436..00000000000 --- a/src/main/browser/anti-detection.test.ts +++ /dev/null @@ -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 - } - navigator: { - userAgent: string - permissions: { - query: (descriptor: { name: string }) => Promise - } - } - window: { - chrome?: { - runtime?: unknown - csi?: () => unknown - loadTimes?: () => unknown - } - } -} - -function createContext(args: { - nativeNotificationPermission: string - requestedNotificationPermission: string - userAgent?: string -}): AntiDetectionContext & Record { - class Permissions { - query(): Promise { - return Promise.resolve({ state: 'denied', onchange: null }) - } - } - - const Notification = { - permission: args.nativeNotificationPermission, - requestPermission(callback?: (permission: string) => void): Promise { - 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 -} - -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 - }) - }) -}) diff --git a/src/main/browser/anti-detection.ts b/src/main/browser/anti-detection.ts deleted file mode 100644 index d725fbf965f..00000000000 --- a/src/main/browser/anti-detection.ts +++ /dev/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'] - }); - } -})()` diff --git a/src/main/browser/browser-google-auth-ua.ts b/src/main/browser/browser-google-auth-ua.ts index 16ed14eb80f..e9b802f6d70 100644 --- a/src/main/browser/browser-google-auth-ua.ts +++ b/src/main/browser/browser-google-auth-ua.ts @@ -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. diff --git a/src/main/browser/browser-manager-auth-user-agent.test.ts b/src/main/browser/browser-manager-auth-user-agent.test.ts index 405b689e850..eb0bd75b354 100644 --- a/src/main/browser/browser-manager-auth-user-agent.test.ts +++ b/src/main/browser/browser-manager-auth-user-agent.test.ts @@ -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) } }) }) diff --git a/src/main/browser/browser-manager-guest-lifecycle.test.ts b/src/main/browser/browser-manager-guest-lifecycle.test.ts index c8550083dfc..e136f532474 100644 --- a/src/main/browser/browser-manager-guest-lifecycle.test.ts +++ b/src/main/browser/browser-manager-guest-lifecycle.test.ts @@ -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 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() }) }) diff --git a/src/main/browser/browser-manager-guest-policy-profile.test.ts b/src/main/browser/browser-manager-guest-policy-profile.test.ts index 47871f64ea9..c5c4c2521fe 100644 --- a/src/main/browser/browser-manager-guest-policy-profile.test.ts +++ b/src/main/browser/browser-manager-guest-policy-profile.test.ts @@ -52,6 +52,8 @@ type GuestFake = { isAttached: () => boolean attach: ReturnType sendCommand: ReturnType + on: ReturnType + off: ReturnType } 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() }) diff --git a/src/main/browser/browser-manager-guest-policy.ts b/src/main/browser/browser-manager-guest-policy.ts index c0d522235c8..3952a31c08b 100644 --- a/src/main/browser/browser-manager-guest-policy.ts +++ b/src/main/browser/browser-manager-guest-policy.ts @@ -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. */ diff --git a/src/main/browser/browser-manager-navigation.ts b/src/main/browser/browser-manager-navigation.ts index 4e061d288aa..5cb46ccb682 100644 --- a/src/main/browser/browser-manager-navigation.ts +++ b/src/main/browser/browser-manager-navigation.ts @@ -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() }) ) } diff --git a/src/main/browser/browser-manager-state.ts b/src/main/browser/browser-manager-state.ts index bc65cc3d2dc..54b0f99b3c1 100644 --- a/src/main/browser/browser-manager-state.ts +++ b/src/main/browser/browser-manager-state.ts @@ -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 | 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 { diff --git a/src/main/browser/browser-manager-types.ts b/src/main/browser/browser-manager-types.ts index a1b832a65bc..b91e2b741fe 100644 --- a/src/main/browser/browser-manager-types.ts +++ b/src/main/browser/browser-manager-types.ts @@ -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 = diff --git a/src/main/browser/browser-manager-viewport-override.test.ts b/src/main/browser/browser-manager-viewport-override.test.ts index b7d3bbabe0a..0ffc3c2a6e1 100644 --- a/src/main/browser/browser-manager-viewport-override.test.ts +++ b/src/main/browser/browser-manager-viewport-override.test.ts @@ -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() }) diff --git a/src/main/browser/browser-manager-viewport-test-fixtures.ts b/src/main/browser/browser-manager-viewport-test-fixtures.ts index 8d68977f62e..076524ce5d0 100644 --- a/src/main/browser/browser-manager-viewport-test-fixtures.ts +++ b/src/main/browser/browser-manager-viewport-test-fixtures.ts @@ -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 { diff --git a/src/main/browser/browser-manager-viewport.ts b/src/main/browser/browser-manager-viewport.ts index 1e79e760942..ce31dbe37e1 100644 --- a/src/main/browser/browser-manager-viewport.ts +++ b/src/main/browser/browser-manager-viewport.ts @@ -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 diff --git a/src/main/browser/browser-session-partition-policies.test.ts b/src/main/browser/browser-session-partition-policies.test.ts index 78ce34d95fd..952c199536c 100644 --- a/src/main/browser/browser-session-partition-policies.test.ts +++ b/src/main/browser/browser-session-partition-policies.test.ts @@ -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() diff --git a/src/main/browser/browser-session-partition-policies.ts b/src/main/browser/browser-session-partition-policies.ts index 9f25d8840a2..ec3c68fb45e 100644 --- a/src/main/browser/browser-session-partition-policies.ts +++ b/src/main/browser/browser-session-partition-policies.ts @@ -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) */ } diff --git a/src/main/browser/browser-session-partition-proxy-install.test.ts b/src/main/browser/browser-session-partition-proxy-install.test.ts index 0841ed94785..4beeaab04c9 100644 --- a/src/main/browser/browser-session-partition-proxy-install.test.ts +++ b/src/main/browser/browser-session-partition-proxy-install.test.ts @@ -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(), diff --git a/src/main/browser/browser-session-registry.persistence.test.ts b/src/main/browser/browser-session-registry.persistence.test.ts index fdba71e16d6..67653c0c76c 100644 --- a/src/main/browser/browser-session-registry.persistence.test.ts +++ b/src/main/browser/browser-session-registry.persistence.test.ts @@ -27,7 +27,7 @@ function installModuleMocks( copyFailures = new Set() ): { sessionFromPartitionMock: ReturnType - setupClientHintsOverrideMock: ReturnType + setupGoogleAuthUserAgentOverrideMock: ReturnType browserManagerHandleGuestWillDownloadMock: ReturnType browserManagerNotifyPermissionDeniedMock: ReturnType requestSystemMediaAccessMock: ReturnType @@ -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) diff --git a/src/main/browser/browser-session-registry.test.ts b/src/main/browser/browser-session-registry.test.ts index d5111483fcf..ae81ffda4e2 100644 --- a/src/main/browser/browser-session-registry.test.ts +++ b/src/main/browser/browser-session-registry.test.ts @@ -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) => 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') }) }) }) diff --git a/src/main/browser/browser-session-ua-wire-identity.electron.test.ts b/src/main/browser/browser-session-ua-wire-identity.electron.test.ts new file mode 100644 index 00000000000..4e0719b2745 --- /dev/null +++ b/src/main/browser/browser-session-ua-wire-identity.electron.test.ts @@ -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 { + 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([]) + }) +}) diff --git a/src/main/browser/browser-session-ua.ts b/src/main/browser/browser-session-ua.ts index 96c55cf5ac9..1375ebc66f5 100644 --- a/src/main/browser/browser-session-ua.ts +++ b/src/main/browser/browser-session-ua.ts @@ -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"` - } -} diff --git a/src/main/browser/browser-viewport-user-agent.ts b/src/main/browser/browser-viewport-user-agent.ts index 7dedf8d8a6e..b8159a44c2a 100644 --- a/src/main/browser/browser-viewport-user-agent.ts +++ b/src/main/browser/browser-viewport-user-agent.ts @@ -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) diff --git a/src/main/browser/browser-webauthn-profile-delete.test.ts b/src/main/browser/browser-webauthn-profile-delete.test.ts index 9a5e129885f..3c471fe2dc2 100644 --- a/src/main/browser/browser-webauthn-profile-delete.test.ts +++ b/src/main/browser/browser-webauthn-profile-delete.test.ts @@ -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 } diff --git a/src/main/browser/cdp-debugger-channel.ts b/src/main/browser/cdp-debugger-channel.ts index 352bc741ef0..18b819a1ce9 100644 --- a/src/main/browser/cdp-debugger-channel.ts +++ b/src/main/browser/cdp-debugger-channel.ts @@ -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 */ } diff --git a/src/main/browser/cdp-debugger-events.ts b/src/main/browser/cdp-debugger-events.ts index 023d648a8ca..59d30105910 100644 --- a/src/main/browser/cdp-debugger-events.ts +++ b/src/main/browser/cdp-debugger-events.ts @@ -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') { diff --git a/src/main/browser/cdp-debugger-lifecycle.ts b/src/main/browser/cdp-debugger-lifecycle.ts index f969f113d59..225eb689dba 100644 --- a/src/main/browser/cdp-debugger-lifecycle.ts +++ b/src/main/browser/cdp-debugger-lifecycle.ts @@ -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) diff --git a/src/main/browser/cdp-ws-proxy-focus-replay.test.ts b/src/main/browser/cdp-ws-proxy-focus-replay.test.ts index 71b00cd9ff6..8404614ef8d 100644 --- a/src/main/browser/cdp-ws-proxy-focus-replay.test.ts +++ b/src/main/browser/cdp-ws-proxy-focus-replay.test.ts @@ -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() }) diff --git a/src/main/browser/cdp-ws-proxy.test.ts b/src/main/browser/cdp-ws-proxy.test.ts index c5f098ffa91..d5a2d14a05b 100644 --- a/src/main/browser/cdp-ws-proxy.test.ts +++ b/src/main/browser/cdp-ws-proxy.test.ts @@ -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() }) diff --git a/src/main/codex/codex-app-server-session.ts b/src/main/codex/codex-app-server-session.ts index 2e176e13ae2..35537f59d0d 100644 --- a/src/main/codex/codex-app-server-session.ts +++ b/src/main/codex/codex-app-server-session.ts @@ -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. diff --git a/src/main/codex/codex-structured-turn-processes.ts b/src/main/codex/codex-structured-turn-processes.ts index 6bb4b960a1b..163cbb098fb 100644 --- a/src/main/codex/codex-structured-turn-processes.ts +++ b/src/main/codex/codex-structured-turn-processes.ts @@ -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' })) ) diff --git a/src/main/crash-reporting/self-initiated-tree-kill-log.test.ts b/src/main/crash-reporting/self-initiated-tree-kill-log.test.ts index c57c1796a35..e958dac42f8 100644 --- a/src/main/crash-reporting/self-initiated-tree-kill-log.test.ts +++ b/src/main/crash-reporting/self-initiated-tree-kill-log.test.ts @@ -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) }) }) diff --git a/src/main/crash-reporting/self-initiated-tree-kill-log.ts b/src/main/crash-reporting/self-initiated-tree-kill-log.ts index e819795074f..809d421ac61 100644 --- a/src/main/crash-reporting/self-initiated-tree-kill-log.ts +++ b/src/main/crash-reporting/self-initiated-tree-kill-log.ts @@ -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 diff --git a/src/main/daemon/agent-startup-prompt-latency.node-pty.test.ts b/src/main/daemon/agent-startup-prompt-latency.node-pty.test.ts new file mode 100644 index 00000000000..cfa01fed8e5 --- /dev/null +++ b/src/main/daemon/agent-startup-prompt-latency.node-pty.test.ts @@ -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 | undefined + let legacyTimer: ReturnType | 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 + ) +}) diff --git a/src/main/daemon/daemon-bash-shell-ready-rcfile.ts b/src/main/daemon/daemon-bash-shell-ready-rcfile.ts index 142c0c131d9..f7834dba9d7 100644 --- a/src/main/daemon/daemon-bash-shell-ready-rcfile.ts +++ b/src/main/daemon/daemon-bash-shell-ready-rcfile.ts @@ -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" diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index fc2f9365f23..5eba73d8ad2 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -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() 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', () => { diff --git a/src/main/daemon/daemon-pty-session-spawn.ts b/src/main/daemon/daemon-pty-session-spawn.ts index 235b286b0bc..70519439919 100644 --- a/src/main/daemon/daemon-pty-session-spawn.ts +++ b/src/main/daemon/daemon-pty-session-spawn.ts @@ -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, diff --git a/src/main/daemon/daemon-pty-startup-delivery.test.ts b/src/main/daemon/daemon-pty-startup-delivery.test.ts new file mode 100644 index 00000000000..4b72070bb42 --- /dev/null +++ b/src/main/daemon/daemon-pty-startup-delivery.test.ts @@ -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 + let lastSpawnOpts: Parameters[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`) + }) +}) diff --git a/src/main/daemon/post-ready-flush-gate.test.ts b/src/main/daemon/post-ready-flush-gate.test.ts index f8e58ba7c7d..06ff64db99f 100644 --- a/src/main/daemon/post-ready-flush-gate.test.ts +++ b/src/main/daemon/post-ready-flush-gate.test.ts @@ -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() diff --git a/src/main/daemon/post-ready-flush-gate.ts b/src/main/daemon/post-ready-flush-gate.ts index 199bb601024..f7f7a2f1869 100644 --- a/src/main/daemon/post-ready-flush-gate.ts +++ b/src/main/daemon/post-ready-flush-gate.ts @@ -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 | null = null private fallbackTimer: ReturnType | 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() diff --git a/src/main/daemon/pty-subprocess-managed-agent-env.test.ts b/src/main/daemon/pty-subprocess-managed-agent-env.test.ts index 5267778088c..d5dbc33246c 100644 --- a/src/main/daemon/pty-subprocess-managed-agent-env.test.ts +++ b/src/main/daemon/pty-subprocess-managed-agent-env.test.ts @@ -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 () => { diff --git a/src/main/daemon/pty-subprocess/shell-launch-plan.ts b/src/main/daemon/pty-subprocess/shell-launch-plan.ts index ed8cd5e6a22..12e049d9f72 100644 --- a/src/main/daemon/pty-subprocess/shell-launch-plan.ts +++ b/src/main/daemon/pty-subprocess/shell-launch-plan.ts @@ -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 diff --git a/src/main/daemon/session-shell-ready-barrier.ts b/src/main/daemon/session-shell-ready-barrier.ts index e7562e23224..538fd57a49a 100644 --- a/src/main/daemon/session-shell-ready-barrier.ts +++ b/src/main/daemon/session-shell-ready-barrier.ts @@ -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 { diff --git a/src/main/daemon/shell-ready.test.ts b/src/main/daemon/shell-ready.test.ts index 6772a52d46e..01cdad10be9 100644 --- a/src/main/daemon/shell-ready.test.ts +++ b/src/main/daemon/shell-ready.test.ts @@ -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) }) diff --git a/src/main/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts index 9dc60b7c980..47acc567f6f 100644 --- a/src/main/daemon/shell-ready.ts +++ b/src/main/daemon/shell-ready.ts @@ -101,6 +101,11 @@ export function resolvePtyShellPath(env: Record): 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): boolean { - if (process.platform === 'win32') { - return false - } - return shellPathSupportsPtyStartupBarrier(resolvePtyShellPath(env)) -} - export type ShellLaunchConfig = { args: string[] | null env: Record diff --git a/src/main/git/command-runner/spawned-command-tree-kill.ts b/src/main/git/command-runner/spawned-command-tree-kill.ts index c2fefcba1bc..324e04f1db3 100644 --- a/src/main/git/command-runner/spawned-command-tree-kill.ts +++ b/src/main/git/command-runner/spawned-command-tree-kill.ts @@ -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 { 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 { diff --git a/src/main/ipc/notebook.ts b/src/main/ipc/notebook.ts index 9255ab7383d..d90ab3616de 100644 --- a/src/main/ipc/notebook.ts +++ b/src/main/ipc/notebook.ts @@ -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 | 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. diff --git a/src/main/ipc/pty/delivery/attached-pty-size.test.ts b/src/main/ipc/pty/delivery/attached-pty-size.test.ts new file mode 100644 index 00000000000..90ff37c744d --- /dev/null +++ b/src/main/ipc/pty/delivery/attached-pty-size.test.ts @@ -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) + }) +}) diff --git a/src/main/ipc/pty/delivery/attached-pty-size.ts b/src/main/ipc/pty/delivery/attached-pty-size.ts new file mode 100644 index 00000000000..d8b14267cb8 --- /dev/null +++ b/src/main/ipc/pty/delivery/attached-pty-size.ts @@ -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 + 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 + 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 +} diff --git a/src/main/ipc/pty/ipc/spawn-commit-persist.ts b/src/main/ipc/pty/ipc/spawn-commit-persist.ts index 540ada9397d..d9bee3e6934 100644 --- a/src/main/ipc/pty/ipc/spawn-commit-persist.ts +++ b/src/main/ipc/pty/ipc/spawn-commit-persist.ts @@ -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 } } diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index f02eb215c87..90b700f24b2 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -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 { 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 0 && diff --git a/src/main/ipc/pty/ipc/spawn-options.ts b/src/main/ipc/pty/ipc/spawn-options.ts index ed75b989f6d..78747e6a462 100644 --- a/src/main/ipc/pty/ipc/spawn-options.ts +++ b/src/main/ipc/pty/ipc/spawn-options.ts @@ -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, diff --git a/src/main/ipc/pty/ipc/spawn-reattach-size-cache.test.ts b/src/main/ipc/pty/ipc/spawn-reattach-size-cache.test.ts new file mode 100644 index 00000000000..53b361bc8fe --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-reattach-size-cache.test.ts @@ -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): 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 + 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 }) + }) +}) diff --git a/src/main/ipc/pty/runtime/spawn-commit-pty-size.test.ts b/src/main/ipc/pty/runtime/spawn-commit-pty-size.test.ts new file mode 100644 index 00000000000..84d87b75005 --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn-commit-pty-size.test.ts @@ -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) { + 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 + ) + }) +}) diff --git a/src/main/ipc/pty/runtime/spawn-commit-pty-size.ts b/src/main/ipc/pty/runtime/spawn-commit-pty-size.ts new file mode 100644 index 00000000000..9b73f093761 --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn-commit-pty-size.ts @@ -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 + ) + }) +} diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index 23592604bea..7f8a9e38267 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -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) } diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index fcba7770a39..2e81bdeafdb 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -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) diff --git a/src/main/main-process-tree-kill-gate.test.ts b/src/main/main-process-tree-kill-gate.test.ts new file mode 100644 index 00000000000..40b1421c32f --- /dev/null +++ b/src/main/main-process-tree-kill-gate.test.ts @@ -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 /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 ` 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([ + [ + '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([]) + }) +}) diff --git a/src/main/orca-chromium-process-pids.ts b/src/main/orca-chromium-process-pids.ts index b2613e42b79..3282f3a4d66 100644 --- a/src/main/orca-chromium-process-pids.ts +++ b/src/main/orca-chromium-process-pids.ts @@ -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 diff --git a/src/main/own-chromium-tree-kill-guard.test.ts b/src/main/own-chromium-tree-kill-guard.test.ts index bd3b1674e18..7b9c30687bc 100644 --- a/src/main/own-chromium-tree-kill-guard.test.ts +++ b/src/main/own-chromium-tree-kill-guard.test.ts @@ -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 diff --git a/src/main/own-chromium-tree-kill-guard.ts b/src/main/own-chromium-tree-kill-guard.ts index 4daedf4faa4..24b6a4b7327 100644 --- a/src/main/own-chromium-tree-kill-guard.ts +++ b/src/main/own-chromium-tree-kill-guard.ts @@ -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)) } diff --git a/src/main/providers/local-pty-provider-spawn-session.test.ts b/src/main/providers/local-pty-provider-spawn-session.test.ts index bb224009117..7513d9c72cb 100644 --- a/src/main/providers/local-pty-provider-spawn-session.test.ts +++ b/src/main/providers/local-pty-provider-spawn-session.test.ts @@ -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() diff --git a/src/main/providers/local-pty-spawn-state.ts b/src/main/providers/local-pty-spawn-state.ts index 283f8b40c62..d41f858cf98 100644 --- a/src/main/providers/local-pty-spawn-state.ts +++ b/src/main/providers/local-pty-spawn-state.ts @@ -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 } } : {}) } } diff --git a/src/main/providers/pty-spawn-result.ts b/src/main/providers/pty-spawn-result.ts index 43e9665de45..56de0b89115 100644 --- a/src/main/providers/pty-spawn-result.ts +++ b/src/main/providers/pty-spawn-result.ts @@ -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. */ diff --git a/src/main/refused-tree-kill-root-termination.test.ts b/src/main/refused-tree-kill-root-termination.test.ts new file mode 100644 index 00000000000..ada4b5942a9 --- /dev/null +++ b/src/main/refused-tree-kill-root-termination.test.ts @@ -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>()), + 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' }) + }) + ]) + }) +}) diff --git a/src/main/runtime/orca-runtime-create-pty-headless-terminal-state.ts b/src/main/runtime/orca-runtime-create-pty-headless-terminal-state.ts index 59fe493118a..da5d824ef62 100644 --- a/src/main/runtime/orca-runtime-create-pty-headless-terminal-state.ts +++ b/src/main/runtime/orca-runtime-create-pty-headless-terminal-state.ts @@ -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 { diff --git a/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts b/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts index cfb8b421966..aef04bde6bc 100644 --- a/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts +++ b/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts @@ -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() }) } diff --git a/src/main/runtime/orca-runtime-tests/reattach-headless-grid.spec.ts b/src/main/runtime/orca-runtime-tests/reattach-headless-grid.spec.ts new file mode 100644 index 00000000000..43ba687c4bf --- /dev/null +++ b/src/main/runtime/orca-runtime-tests/reattach-headless-grid.spec.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { createRuntime, syncSinglePty } from '../orca-runtime-test-fixtures.spec' + +describe('headless model grid after a reattach', () => { + it('reflows a model that live bytes created at the 80x24 default onto the PTY grid', async () => { + const runtime = createRuntime() + // No controller size: mirrors a reattach whose real grid main only learns from the reply. + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + getSize: () => null + }) + syncSinglePty(runtime, 'pty-1') + + runtime.onPtyData('pty-1', 'user@host % claude\r\n', 100) + await expect( + runtime.serializeMainTerminalBuffer('pty-1', { scrollbackRows: 100 }) + ).resolves.toMatchObject({ cols: 80, rows: 24 }) + + runtime.reflowHeadlessTerminalToPtyGrid('pty-1', 211, 57) + + await expect( + runtime.serializeMainTerminalBuffer('pty-1', { scrollbackRows: 100 }) + ).resolves.toMatchObject({ cols: 211, rows: 57, source: 'headless' }) + }) + + it('never creates a model for a PTY that has none', async () => { + const runtime = createRuntime() + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + getSize: () => null + }) + syncSinglePty(runtime, 'pty-1') + + runtime.reflowHeadlessTerminalToPtyGrid('pty-1', 211, 57) + runtime.onPtyData('pty-1', 'hello\r\n', 100) + + // Why it matters: commit reflows every reattach, and pre-creating here would defeat the + // renderer-authority gate that deliberately leaves the model unseeded. + await expect( + runtime.serializeMainTerminalBuffer('pty-1', { scrollbackRows: 100 }) + ).resolves.toMatchObject({ cols: 80, rows: 24 }) + }) +}) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 5735154f6ac..4dd03c27e18 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -34,6 +34,7 @@ await import('./orca-runtime-tests/terminal-side-effect-facts-part-03.spec') await import('./orca-runtime-tests/decorative-title-fact-throttle.spec') await import('./orca-runtime-tests/headless-snapshots.spec') await import('./orca-runtime-tests/headless-snapshots-part-02.spec') +await import('./orca-runtime-tests/reattach-headless-grid.spec') await import('./orca-runtime-tests/agent-status-and-waits.spec') await import('./orca-runtime-tests/agent-status-and-waits-part-02.spec') await import('./orca-runtime-tests/agent-status-and-waits-part-03.spec') diff --git a/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap b/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap index 6557589260e..a6740b7d90c 100644 --- a/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap +++ b/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap @@ -10,6 +10,7 @@ Slack, GitHub comments, or any other channel to reach a human during the run. === CLI COMMANDS === +\`\`\`sh # Report the terminal task outcome (REQUIRED exactly once). # # RULE: --body must be a 3-sentence executive summary (what you did, @@ -61,6 +62,7 @@ Slack, GitHub comments, or any other channel to reach a human during the run. # start a new file and after a test run — and once more immediately before # you send worker_done, so a redirect lands before the task settles. orca orchestration check --terminal term_WORKER --json +\`\`\` === AFTER YOU SEND worker_done === diff --git a/src/main/runtime/orchestration/coordinator-task-dispatch.ts b/src/main/runtime/orchestration/coordinator-task-dispatch.ts index 4a7b5d05495..f2dc506ca9a 100644 --- a/src/main/runtime/orchestration/coordinator-task-dispatch.ts +++ b/src/main/runtime/orchestration/coordinator-task-dispatch.ts @@ -132,7 +132,7 @@ export async function dispatchTaskToWorker(params: { let gateContext = '' if (gates.length > 0) { const latest = gates.at(-1)! - gateContext = `\n\n--- DECISION GATE RESOLVED ---\nQuestion: ${latest.question}\nResolution: ${latest.resolution}\n---\n` + gateContext = `\n\n--- DECISION GATE RESOLVED ---\nQuestion: ${latest.question}\nResolution: ${latest.resolution}\n\n---\n` } try { diff --git a/src/main/runtime/orchestration/preamble.test.ts b/src/main/runtime/orchestration/preamble.test.ts index 2631cae2773..cc890a101ef 100644 --- a/src/main/runtime/orchestration/preamble.test.ts +++ b/src/main/runtime/orchestration/preamble.test.ts @@ -1,4 +1,6 @@ import { spawnSync } from 'node:child_process' +import remarkParse from 'remark-parse' +import { unified } from 'unified' import { describe, expect, it } from 'vitest' import { buildDispatchPreamble } from './preamble' @@ -23,6 +25,22 @@ function afterWorkerDoneSection(result: string) { return result.slice(sectionStart, sectionEnd) } +function cliFence(result: string): string { + const match = result.match(/=== CLI COMMANDS ===\n\n```sh\n([\s\S]*?)\n```/) + expect(match).not.toBeNull() + return match?.[1] ?? '' +} + +function markdownBlocks(result: string) { + const tree = unified().use(remarkParse).parse(result) + return { + headings: tree.children.filter((node) => node.type === 'heading'), + codeBlocks: tree.children.filter((node) => node.type === 'code') + } +} + +const driftParams = { base: 'origin/main', behind: 3, recentSubjects: ['fix: a', 'feat: b'] } + describe('buildDispatchPreamble', () => { it('substitutes template variables', () => { const result = buildDispatchPreamble(baseParams()) @@ -59,21 +77,7 @@ describe('buildDispatchPreamble', () => { { timeout: 15_000 }, () => { const result = buildDispatchPreamble(baseParams()) - // Why: feeding `bash -n` the full preamble falsely fails on apostrophes - // in the surrounding prose. Slice between the CLI markers and strip - // shell-style comment lines so we only syntax-check the commands. - const cliStart = result.indexOf('=== CLI COMMANDS ===') - const cliEnd = result.indexOf('=== AFTER YOU SEND worker_done ===') - expect(cliStart).toBeGreaterThan(-1) - expect(cliEnd).toBeGreaterThan(cliStart) - const block = result.slice(cliStart, cliEnd) - const stripped = block - .split('\n') - .filter((line) => !line.trim().startsWith('#')) - .filter((line) => !line.trim().startsWith('===')) - .join('\n') - - const check = spawnSync('bash', ['-n'], { input: stripped, encoding: 'utf8' }) + const check = spawnSync('bash', ['-n'], { input: cliFence(result), encoding: 'utf8' }) expect(check.status).toBe(0) } ) @@ -92,6 +96,38 @@ describe('buildDispatchPreamble', () => { expect(commandLines.filter((line) => line.includes('--type escalation'))).toHaveLength(1) }) + it('fences shell comments so Markdown does not promote them to headings', () => { + const result = buildDispatchPreamble(baseParams()) + const { headings, codeBlocks } = markdownBlocks(result) + + expect(headings).toHaveLength(0) + expect(codeBlocks).toHaveLength(1) + expect(codeBlocks[0]).toMatchObject({ lang: 'sh', value: cliFence(result) }) + }) + + // Why: a `---` rule directly under a paragraph is a setext H2, so the optional + // sections' closing rules must not turn their last sentence into a heading. + it('renders no Markdown headings when the sub-dispatch and drift sections are present', () => { + const result = buildDispatchPreamble( + baseParams({ canDispatchSubWorkers: true, baseDrift: driftParams }) + ) + const { headings, codeBlocks } = markdownBlocks(result) + + expect(headings).toHaveLength(0) + expect(codeBlocks).toHaveLength(2) + expect(codeBlocks[1]).toMatchObject({ lang: 'sh' }) + expect(codeBlocks[1].value).toContain('orchestration worker-start --task ') + expect(result).toContain('able to dispatch further.\n\n---') + expect(result).toContain('before starting.\n\n---') + }) + + it('sub-dispatch fence passes bash -n', { timeout: 15_000 }, () => { + const result = buildDispatchPreamble(baseParams({ canDispatchSubWorkers: true })) + const { codeBlocks } = markdownBlocks(result) + const check = spawnSync('bash', ['-n'], { input: codeBlocks[1].value, encoding: 'utf8' }) + expect(check.status).toBe(0) + }) + it('includes heartbeat CLI block with taskId and dispatchId and 5-minute cadence', () => { const result = buildDispatchPreamble(baseParams()) expect(result).toContain('--type heartbeat') diff --git a/src/main/runtime/orchestration/preamble.ts b/src/main/runtime/orchestration/preamble.ts index 8ddc2277695..597e7bba89d 100644 --- a/src/main/runtime/orchestration/preamble.ts +++ b/src/main/runtime/orchestration/preamble.ts @@ -60,6 +60,7 @@ export function buildDispatchPreamble(params: PreambleParams): string { : '' // Why: one-line recipes paste unchanged in POSIX shells, PowerShell, and cmd.exe. + // Why fenced: keeps the shell comments executable without rendering them as Chat UI headings. const header = `You are working inside Orca, a multi-agent IDE. You are a dispatched worker. Your coordinator's terminal handle is: ${params.coordinatorHandle} Your task ID is: ${params.taskId} @@ -69,6 +70,7 @@ Slack, GitHub comments, or any other channel to reach a human during the run. === CLI COMMANDS === +\`\`\`sh # Report the terminal task outcome (REQUIRED exactly once). # # RULE: --body must be a 3-sentence executive summary (what you did, @@ -120,6 +122,7 @@ Slack, GitHub comments, or any other channel to reach a human during the run. # start a new file and after a test run — and once more immediately before # you send worker_done, so a redirect lands before the task settles. ${cli} orchestration check --terminal ${params.workerHandle} --json +\`\`\` ${postDoneInstructions}` @@ -184,6 +187,8 @@ work under the new Dispatch; ignore stale follow-ups from the settled task.` // Why the whole section is omitted rather than softened when nesting is off: a // worker told it "usually cannot" delegate still tries, then reports the refusal // as a blocker. +// Why fenced + blank line before the closing `---`: unfenced `` are stripped as raw +// HTML by the Chat UI, and a rule directly under a paragraph is a setext H2 (giant last sentence). function buildSubDispatchSection(cli: string): string { return ` @@ -191,13 +196,16 @@ function buildSubDispatchSection(cli: string): string { You may dispatch sub-workers for this task. Bind your own Run first, then create and start each one: +\`\`\`sh ${cli} orchestration run-create --objective "" --json ${cli} orchestration task-create --spec "" --json ${cli} orchestration worker-start --task --worktree current --agent --json +\`\`\` You own those sub-workers: wait for their worker_done, and do not report your own until they have settled. Nesting is capped, so a sub-worker of yours may not be able to dispatch further. + ---` } @@ -212,5 +220,6 @@ ${subjects} If any look relevant to your task, either pull them in (\`git pull --rebase ${drift.base}\` or equivalent) or escalate to the coordinator before starting. + ---` } diff --git a/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts b/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts index a2a93533b6f..183f981ccee 100644 --- a/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts +++ b/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts @@ -50,6 +50,8 @@ const METHODS = [ } ] as const +const DESTRUCTIVE_METHOD_NAMES = new Set(['session.tabs.close', 'session.tabs.closeLifecycle']) + describe('session tab structured capability mutations', () => { for (const method of METHODS) { it(`rejects ${method.name} when the structured row is hidden`, async () => { @@ -88,6 +90,38 @@ describe('session tab structured capability mutations', () => { }) } + for (const method of METHODS) { + const expectedToAllowPromptedRow = !DESTRUCTIVE_METHOD_NAMES.has(method.name) + + it(`${expectedToAllowPromptedRow ? 'allows' : 'rejects'} ${method.name} on a row an old mobile client was prompted to update`, async () => { + const { calls, dispatch } = createFixture([], { + clientKind: 'mobile', + structuredNativeChatEnabled: true + }) + + const response = await dispatch(method.name, method.params('codex-session')) + + expect(response.ok).toBe(expectedToAllowPromptedRow) + expect(calls[method.runtimeMethod as keyof typeof calls]).toHaveBeenCalledTimes( + expectedToAllowPromptedRow ? 1 : 0 + ) + }) + + it(`${expectedToAllowPromptedRow ? 'allows' : 'rejects'} ${method.name} on a prompted Claude row for a mobile client without the Claude capability`, async () => { + const { calls, dispatch } = createFixture([STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], { + clientKind: 'mobile', + structuredNativeChatEnabled: true + }) + + const response = await dispatch(method.name, method.params('claude-session')) + + expect(response.ok).toBe(expectedToAllowPromptedRow) + expect(calls[method.runtimeMethod as keyof typeof calls]).toHaveBeenCalledTimes( + expectedToAllowPromptedRow ? 1 : 0 + ) + }) + } + it.each(['session.tabs.close', 'session.tabs.closeLifecycle'] as const)( 'allows capable mobile clients to close structured tabs when the experiment is enabled (%s)', async (method) => { @@ -131,7 +165,10 @@ describe('session tab structured capability mutations', () => { ) }) -function createFixture(capabilities: RuntimeCapability[]) { +function createFixture( + capabilities: RuntimeCapability[], + options: { clientKind?: 'mobile' | 'runtime'; structuredNativeChatEnabled?: boolean } = {} +) { const snapshot = agentSnapshot() const calls = { closeMobileSessionTab: vi.fn().mockResolvedValue({ closed: true }), @@ -142,11 +179,14 @@ function createFixture(capabilities: RuntimeCapability[]) { const runtime = { getRuntimeId: () => 'test-runtime', listMobileSessionTabs: vi.fn().mockResolvedValue(snapshot), + getClientSettings: () => ({ + experimentalStructuredNativeChat: options.structuredNativeChatEnabled === true + }), ...calls } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) const context: RpcDispatchStreamingOptions = { - clientKind: 'runtime', + clientKind: options.clientKind ?? 'runtime', pairedDeviceId: 'paired-client', clientCapabilities: capabilities } diff --git a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts index 7128f756d6e..cf68f7739c0 100644 --- a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts +++ b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts @@ -5,7 +5,11 @@ import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import type { RuntimeMobileSessionTabsSnapshot } from '../../../../shared/runtime-types' -import { projectSessionTabAgentStatus } from './session-tab-agent-status-projection' +import { + CLAUDE_STRUCTURED_CHAT_DESKTOP_ONLY_TAB_TITLE, + STRUCTURED_CHAT_UPDATE_REQUIRED_TAB_TITLE, + projectSessionTabAgentStatus +} from './session-tab-agent-status-projection' function makeSnapshot(sessionBoundary: boolean): RuntimeMobileSessionTabsSnapshot { return { @@ -160,23 +164,96 @@ describe('projectSessionTabAgentStatus', () => { CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY ] - it.each([ - ['mobile', 'mobile' as const, [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]], - ['runtime', 'runtime' as const, [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]] - ])( - 'withholds Claude rows from a paired %s client that never negotiated them', - (_name, clientKind, capabilities) => { - const projected = projectSessionTabAgentStatus(claudeSnapshot, clientKind, capabilities, true) + it('withholds Claude rows from a paired runtime client that never negotiated them', () => { + const projected = projectSessionTabAgentStatus( + claudeSnapshot, + 'runtime', + [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], + true + ) - expect(projected.tabs.map((tab) => tab.id)).toEqual(['agent-session:codex']) - // A row pruned from `tabs` but left in the layout is its own dead tab. - expect(projected.tabGroups?.map((group) => group.id)).toEqual(['group-a']) - expect(projected.tabGroupLayout).toEqual({ type: 'leaf', groupId: 'group-a' }) - expect(projected.activeGroupId).toBe('group-a') - expect(projected.activeTabId).toBe('agent-session:codex') - expect(projected.activeTabType).toBe('agent-session') + expect(projected.tabs.map((tab) => tab.id)).toEqual(['agent-session:codex']) + // A row pruned from `tabs` but left in the layout is its own dead tab. + expect(projected.tabGroups?.map((group) => group.id)).toEqual(['group-a']) + expect(projected.tabGroupLayout).toEqual({ type: 'leaf', groupId: 'group-a' }) + expect(projected.activeGroupId).toBe('group-a') + expect(projected.activeTabId).toBe('agent-session:codex') + expect(projected.activeTabType).toBe('agent-session') + }) + + it('uses a desktop fallback for an unsupported Claude row instead of withholding it', () => { + const projected = projectSessionTabAgentStatus( + claudeSnapshot, + 'mobile', + [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], + true + ) + + // The row survives so the chat the desktop shows is not simply absent on the phone. + expect(projected.tabs.map((tab) => tab.id)).toEqual([ + 'agent-session:codex', + 'agent-session:claude' + ]) + expect(projected.tabs.map((tab) => tab.title)).toEqual([ + 'Codex Chat', + CLAUDE_STRUCTURED_CHAT_DESKTOP_ONLY_TAB_TITLE + ]) + // Nothing is removed, so the layout it belonged to is untouched. + expect(projected.tabGroups?.map((group) => group.id)).toEqual(['group-a', 'group-b']) + expect(projected.tabGroupLayout).toEqual(claudeSnapshot.tabGroupLayout) + expect(projected.activeTabId).toBe('agent-session:codex') + }) + + it('projects agent-specific fallback titles for a mobile client with no capabilities', () => { + const projected = projectSessionTabAgentStatus(claudeSnapshot, 'mobile', [], true) + + expect(projected.tabs.map((tab) => tab.title)).toEqual([ + STRUCTURED_CHAT_UPDATE_REQUIRED_TAB_TITLE, + CLAUDE_STRUCTURED_CHAT_DESKTOP_ONLY_TAB_TITLE + ]) + expect(projected.tabGroupLayout).toEqual(claudeSnapshot.tabGroupLayout) + }) + + it('does not treat the Claude capability as a substitute for the base structured capability', () => { + const projected = projectSessionTabAgentStatus( + claudeSnapshot, + 'mobile', + [CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], + true + ) + + expect(projected.tabs.map((tab) => tab.title)).toEqual([ + STRUCTURED_CHAT_UPDATE_REQUIRED_TAB_TITLE, + CLAUDE_STRUCTURED_CHAT_DESKTOP_ONLY_TAB_TITLE + ]) + }) + + it('shows both real titles once mobile negotiates Claude', () => { + expect(projectSessionTabAgentStatus(claudeSnapshot, 'mobile', structuredMobile, true)).toBe( + claudeSnapshot + ) + }) + + // Why: updating cannot reveal a chat the desktop is not serving, so the prompt would lie. + it('withholds rather than prompts when the desktop experiment is off', () => { + for (const capabilities of [ + [], + [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], + structuredMobile + ]) { + const projected = projectSessionTabAgentStatus(claudeSnapshot, 'mobile', capabilities, false) + expect(projected.tabs).toEqual([]) } - ) + }) + + it('never emits an empty structured tab title', () => { + for (const capabilities of [[], [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]]) { + const projected = projectSessionTabAgentStatus(claudeSnapshot, 'mobile', capabilities, true) + for (const tab of projected.tabs) { + expect(tab.title.length).toBeGreaterThan(0) + } + } + }) it.each([ ['mobile', 'mobile' as const, structuredMobile], diff --git a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts index 0e0d9c716a5..4496fdc5435 100644 --- a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts +++ b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts @@ -1,6 +1,7 @@ import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../../../shared/protocol-version' import type { @@ -13,6 +14,43 @@ import { structuredNativeChatProjectionEnabled } from './structured-agent-sessio type SessionTabsPayload = RuntimeMobileSessionTabsResult | RuntimeMobileSessionTabsSnapshot +/** Capped at 128px / one line in every shipped mobile build, so ~15-18 characters render. */ +export const STRUCTURED_CHAT_UPDATE_REQUIRED_TAB_TITLE = 'Update to view' +export const CLAUDE_STRUCTURED_CHAT_DESKTOP_ONLY_TAB_TITLE = 'Open on desktop' + +function clientCanRenderStructuredAgentSessionTab( + tab: RuntimeMobileSessionAgentTab, + clientCapabilities: readonly RuntimeCapability[] | undefined +): boolean { + if (!clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)) { + return false + } + return ( + tab.agent === 'codex' || + clientCapabilities.includes(CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) + ) +} + +function resolveMobileStructuredChatFallbackTitle( + tab: RuntimeMobileSessionAgentTab, + args: { + clientKind: 'mobile' | 'runtime' | undefined + clientCapabilities: readonly RuntimeCapability[] | undefined + structuredNativeChatEnabled?: boolean + } +): string | null { + if ( + args.clientKind !== 'mobile' || + args.structuredNativeChatEnabled !== true || + clientCanRenderStructuredAgentSessionTab(tab, args.clientCapabilities) + ) { + return null + } + return tab.agent === 'claude' + ? CLAUDE_STRUCTURED_CHAT_DESKTOP_ONLY_TAB_TITLE + : STRUCTURED_CHAT_UPDATE_REQUIRED_TAB_TITLE +} + export function projectSessionTabAgentStatus( payload: TPayload, clientKind: 'mobile' | 'runtime' | undefined, @@ -24,16 +62,27 @@ export function projectSessionTabAgentStatus true) - // Why: a paired client renders only codex structured tabs unless it says otherwise - // (mobile's resolveMobileNativeChat returns null for every other agent), so an - // ungated row would list and select into a pane that shows neither chat nor terminal. - if ( - structuredVisible && - clientKind !== undefined && - !clientCapabilities?.includes(CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) - ) { - projected = projectAgentSessionTabsOut(projected, (tab) => tab.agent !== 'codex') + let projected: TPayload + if (clientKind === 'mobile' && structuredNativeChatEnabled === true) { + // Why: deleting the row left the user hunting for a chat the desktop says exists; the row + // survives with a title naming the fix. Nothing is removed, so no group/layout repair applies. + projected = projectUnsupportedAgentSessionTabTitles(payload, { + clientKind, + clientCapabilities, + structuredNativeChatEnabled + }) + } else { + projected = structuredVisible ? payload : projectAgentSessionTabsOut(payload, () => true) + // Why: a paired client renders only codex structured tabs unless it says otherwise + // (mobile's resolveMobileNativeChat returns null for every other agent), so an + // ungated row would list and select into a pane that shows neither chat nor terminal. + if ( + structuredVisible && + clientKind !== undefined && + !clientCapabilities?.includes(CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) + ) { + projected = projectAgentSessionTabsOut(projected, (tab) => tab.agent !== 'codex') + } } // Why: only paired runtimes have legacy `done` completion side effects; mobile must keep its row without changing the exact v2 auth shape. if ( @@ -55,6 +104,47 @@ export function projectSessionTabAgentStatus( + payload: TPayload, + args: { + clientKind: 'mobile' + clientCapabilities: readonly RuntimeCapability[] | undefined + structuredNativeChatEnabled: true + } +): TPayload { + let changed = false + const tabs = payload.tabs.map((tab) => { + if (tab.type !== 'agent-session') { + return tab + } + const title = resolveMobileStructuredChatFallbackTitle(tab, args) + if (title === null) { + return tab + } + changed = true + return { ...tab, title } + }) + return changed ? ({ ...payload, tabs } as TPayload) : payload +} + +export function assertAgentSessionTabDestructiveMutationSupported( + payload: SessionTabsPayload, + tabId: string, + clientKind: 'mobile' | 'runtime' | undefined, + clientCapabilities: readonly RuntimeCapability[] | undefined +): void { + if (clientKind === undefined) { + return + } + const tab = payload.tabs.find((candidate) => candidate.id === tabId) + if ( + tab?.type === 'agent-session' && + !clientCanRenderStructuredAgentSessionTab(tab, clientCapabilities) + ) { + throw new Error('structured_agent_session_unsupported') + } +} + function projectAgentSessionTabsOut( payload: TPayload, shouldHide: (tab: RuntimeMobileSessionAgentTab) => boolean diff --git a/src/main/runtime/rpc/methods/session-tab-close-methods.ts b/src/main/runtime/rpc/methods/session-tab-close-methods.ts index bd60ecd6ddf..361ba8e4c51 100644 --- a/src/main/runtime/rpc/methods/session-tab-close-methods.ts +++ b/src/main/runtime/rpc/methods/session-tab-close-methods.ts @@ -3,6 +3,7 @@ import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from '../../../../shared/ import { defineMethod, type RpcAnyMethod } from '../core' import { CloseLifecycleTab, CloseTab } from './session-tabs-schemas' import { assertProjectedSessionTabVisible } from './session-tab-browser-placement-projection' +import { assertAgentSessionTabDestructiveMutationSupported } from './session-tab-agent-status-projection' import { projectSessionTabsForClient } from './session-tabs-inventory' import { isStructuredNativeChatEnabled } from './structured-agent-session-policy' @@ -12,8 +13,12 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [ params: CloseTab, handler: async (params, context) => { if (context.clientKind) { + const raw = await context.runtime.listMobileSessionTabs( + params.worktree, + context.pairedDeviceId + ) const visible = projectSessionTabsForClient( - await context.runtime.listMobileSessionTabs(params.worktree, context.pairedDeviceId), + raw, context.clientKind, context.clientCapabilities, context.clientKind === 'mobile' @@ -21,6 +26,12 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [ : undefined ) assertProjectedSessionTabVisible(visible, params.tabId) + assertAgentSessionTabDestructiveMutationSupported( + raw, + params.tabId, + context.clientKind, + context.clientCapabilities + ) } const requiresIntent = context.clientKind === undefined || @@ -81,8 +92,12 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [ params: CloseLifecycleTab, handler: async (params, context) => { if (context.clientKind) { + const raw = await context.runtime.listMobileSessionTabs( + params.worktree, + context.pairedDeviceId + ) const visible = projectSessionTabsForClient( - await context.runtime.listMobileSessionTabs(params.worktree, context.pairedDeviceId), + raw, context.clientKind, context.clientCapabilities, context.clientKind === 'mobile' @@ -90,6 +105,12 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [ : undefined ) assertProjectedSessionTabVisible(visible, params.tabId) + assertAgentSessionTabDestructiveMutationSupported( + raw, + params.tabId, + context.clientKind, + context.clientCapabilities + ) } return withSpan( 'runtime.session-tabs.close-lifecycle', diff --git a/src/main/runtime/rpc/methods/session-tabs-structured-restore.test.ts b/src/main/runtime/rpc/methods/session-tabs-structured-restore.test.ts new file mode 100644 index 00000000000..083f334285e --- /dev/null +++ b/src/main/runtime/rpc/methods/session-tabs-structured-restore.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { SESSION_TAB_METHODS } from './session-tabs' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('session tab structured restore gating', () => { + it('does not restore structured tabs for mobile while the host setting is off', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: false })), + restoreStructuredAgentSessionTabs: vi.fn(), + listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot()) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('session.tabs.list', { worktree: 'id:wt-1' }), + { + clientKind: 'mobile', + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + } + ) + + expect(response.ok).toBe(true) + expect(runtime.restoreStructuredAgentSessionTabs).not.toHaveBeenCalled() + }) + + // Why: an old build has no capability to advertise, and skipping the restore left it with + // nothing to project after a desktop restart — neither the chat nor its fallback row. + it('restores structured tabs for a mobile client that advertises no capability', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: true })), + restoreStructuredAgentSessionTabs: vi.fn(), + listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot()) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('session.tabs.list', { worktree: 'id:wt-1' }), + { clientKind: 'mobile', clientCapabilities: [] } + ) + + expect(response.ok).toBe(true) + expect(runtime.restoreStructuredAgentSessionTabs).toHaveBeenCalledTimes(1) + }) + + it('restores structured tabs for mobile once the setting is present', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: true })), + restoreStructuredAgentSessionTabs: vi.fn(), + listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot()) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('session.tabs.list', { worktree: 'id:wt-1' }), + { + clientKind: 'mobile', + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + } + ) + + expect(response.ok).toBe(true) + expect(runtime.restoreStructuredAgentSessionTabs).toHaveBeenCalledTimes(1) + }) +}) + +function visibleSnapshot() { + return { + worktree: 'wt-1', + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: 'tab-1::leaf-1', + activeTabType: 'terminal' as const, + tabGroups: [{ id: 'group-1', activeTabId: 'tab-1', tabOrder: ['tab-1'] }], + tabs: [ + { + type: 'terminal' as const, + id: 'tab-1::leaf-1', + parentTabId: 'tab-1', + leafId: 'leaf-1', + title: 'Terminal', + status: 'ready' as const, + terminal: 'pty-1', + isActive: true + } + ] + } +} diff --git a/src/main/runtime/rpc/methods/session-tabs.test.ts b/src/main/runtime/rpc/methods/session-tabs.test.ts index 29131869fa0..be61fc55edf 100644 --- a/src/main/runtime/rpc/methods/session-tabs.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs.test.ts @@ -2,10 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' import type { OrcaRuntimeService } from '../../orca-runtime' -import { - SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY -} from '../../../../shared/protocol-version' +import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { SESSION_TAB_METHODS } from './session-tabs' function makeRequest(method: string, params?: unknown): RpcRequest { @@ -13,48 +10,6 @@ function makeRequest(method: string, params?: unknown): RpcRequest { } describe('session tab RPC methods', () => { - it('does not restore structured tabs for mobile while the host setting is off', async () => { - const runtime = { - getRuntimeId: () => 'test-runtime', - getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: false })), - restoreStructuredAgentSessionTabs: vi.fn(), - listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot()) - } as unknown as OrcaRuntimeService - const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) - - const response = await dispatcher.dispatch( - makeRequest('session.tabs.list', { worktree: 'id:wt-1' }), - { - clientKind: 'mobile', - clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] - } - ) - - expect(response.ok).toBe(true) - expect(runtime.restoreStructuredAgentSessionTabs).not.toHaveBeenCalled() - }) - - it('restores structured tabs for mobile only after capability and setting are present', async () => { - const runtime = { - getRuntimeId: () => 'test-runtime', - getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: true })), - restoreStructuredAgentSessionTabs: vi.fn(), - listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot()) - } as unknown as OrcaRuntimeService - const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) - - const response = await dispatcher.dispatch( - makeRequest('session.tabs.list', { worktree: 'id:wt-1' }), - { - clientKind: 'mobile', - clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] - } - ) - - expect(response.ok).toBe(true) - expect(runtime.restoreStructuredAgentSessionTabs).toHaveBeenCalledTimes(1) - }) - it('routes mobile-only activation without notifying desktop clients', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts index 33018c21f4d..e15544b2d24 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts @@ -2,8 +2,11 @@ // // Shared by every structured method file so one gate governs the whole surface: a client that does // not advertise `agent-session.structured.v1` is told the surface does not exist rather than being -// handed a session it cannot render or drive — and, just as importantly, cannot make the host EXIST -// by calling into it, which is an observable side effect. +// handed the session journal or mutation surface. +// +// This gate no longer implies such a client cannot make the host exist: session-tab restore runs +// for old mobile clients while structured chat is enabled so they receive a fallback row, and that +// path constructs the host. `agentSession.*` stays refused either way, which is what this gate is for. import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 3b18f6b0ef1..abe196bd636 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -2,9 +2,8 @@ // // Every method here is gated on the client advertising // `agent-session.structured.v1`. A client that does not is told the surface does -// not exist rather than being handed a session it cannot render or drive; that -// is the whole visibility rule, because nothing else on the runtime publishes a -// structured session. +// not exist rather than receiving the journal or mutation surface. Session-tab +// inventory may expose only a metadata placeholder for an incapable mobile client. import { agentSessionFingerprintConflict, diff --git a/src/main/runtime/rpc/methods/structured-session-tab-restore.ts b/src/main/runtime/rpc/methods/structured-session-tab-restore.ts index 4333713a445..f9efa46d16d 100644 --- a/src/main/runtime/rpc/methods/structured-session-tab-restore.ts +++ b/src/main/runtime/rpc/methods/structured-session-tab-restore.ts @@ -1,13 +1,24 @@ import type { RpcContext } from '../core' -import { supportsStructuredAgentSessions } from './structured-agent-session-policy' +import { + isStructuredNativeChatEnabled, + supportsStructuredAgentSessions +} from './structured-agent-session-policy' +/** Republishes structured tabs into the host's own snapshot map. + * + * Mobile is gated on the host setting alone, NOT on the client's capability: an old build is + * shown a fallback prompt in place of each chat, and gating on capability left it with nothing to + * project after a desktop restart — no chat and no prompt. The setting still gates it, because + * with structured chat off there is nothing for any mobile client to reach. Restoring spawns no + * provider child for a cleanly closed session. */ export async function restoreStructuredTabsIfSupported( context: Pick ): Promise { - if ( - supportsStructuredAgentSessions(context) && - typeof context.runtime.restoreStructuredAgentSessionTabs === 'function' - ) { + const shouldRestore = + context.clientKind === 'mobile' + ? isStructuredNativeChatEnabled(context.runtime) + : supportsStructuredAgentSessions(context) + if (shouldRestore && typeof context.runtime.restoreStructuredAgentSessionTabs === 'function') { await context.runtime.restoreStructuredAgentSessionTabs() } } diff --git a/src/main/runtime/structured-agent-session-support-probe.test.ts b/src/main/runtime/structured-agent-session-support-probe.test.ts new file mode 100644 index 00000000000..e393e41f3a4 --- /dev/null +++ b/src/main/runtime/structured-agent-session-support-probe.test.ts @@ -0,0 +1,174 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { + getStructuredAgentSessionHost, + setStructuredAgentSessionHost +} from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' + +type InstallEffects = { + storeOpened: boolean + writeGateAttached: boolean + reaperStarted: boolean +} + +/** Stands in for `install()` by performing the three effects it performs, so a probe that + * reinstalls the host is caught by what the install *does*, not by a call count alone. */ +function stubStructuredHostInstall(runtime: OrcaRuntimeService): { + effects: InstallEffects + ensure: ReturnType +} { + const effects: InstallEffects = { + storeOpened: false, + writeGateAttached: false, + reaperStarted: false + } + // `supportsCreate` answers as the real Codex adapter would, so a probe that reinstalls the host + // still returns the right answer and fails on the install effects alone. + const host = { + reconcileRestartLeases: vi.fn(async () => {}), + supportsCreate: (location: { executionHostId: string; wslDistro: string | null }) => + location.executionHostId === 'local' && location.wslDistro === null + } + const ensure = vi.fn(async () => { + effects.storeOpened = true + effects.reaperStarted = true + agentSessionPtyWriteGate.attachRecordLookup(() => null) + effects.writeGateAttached = true + setStructuredAgentSessionHost(host as never) + }) + vi.spyOn(runtime, 'ensureStructuredAgentSessionHost').mockImplementation(ensure) + return { effects, ensure } +} + +type TestLocation = { + executionHostId: string + wslDistro: string | null + workspaceKind?: 'folder' | 'git-worktree' +} + +type SupportResult = { + supported: boolean + reason?: 'agent' | 'remote' | 'wsl' +} + +function createRuntime(location: TestLocation): OrcaRuntimeService { + const runtime = new OrcaRuntimeService({ getSettings: () => ({}) } as never) + const internal = runtime as unknown as { + resolveStructuredAgentSessionLocation: () => Promise + } + internal.resolveStructuredAgentSessionLocation = vi.fn(async () => ({ + executionHostId: location.executionHostId, + wslDistro: location.wslDistro, + workspaceId: 'workspace-1', + workspaceKind: location.workspaceKind ?? 'git-worktree' + })) + return runtime +} + +async function expectSupportWithoutInstall(input: { + agent: 'claude' | 'codex' + location: TestLocation + expected: SupportResult + repetitions?: number +}): Promise { + const runtime = createRuntime(input.location) + const { effects, ensure } = stubStructuredHostInstall(runtime) + + const answers: SupportResult[] = [] + for (let index = 0; index < (input.repetitions ?? 1); index += 1) { + answers.push( + await runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', input.agent) + ) + } + + expect(answers).toEqual(Array(input.repetitions ?? 1).fill(input.expected)) + expect(ensure).not.toHaveBeenCalled() + expect(effects).toEqual({ + storeOpened: false, + writeGateAttached: false, + reaperStarted: false + }) + expect(getStructuredAgentSessionHost()).toBeNull() +} + +describe('structured agent-session create-support probe', () => { + afterEach(() => { + setStructuredAgentSessionHost(null) + agentSessionPtyWriteGate.detachRecordLookup() + vi.restoreAllMocks() + }) + + it.each(['codex', 'claude'] as const)( + 'answers %s support repeatedly without installing the host', + async (agent) => { + await expectSupportWithoutInstall({ + agent, + location: { executionHostId: 'local', wslDistro: null }, + expected: { supported: true }, + repetitions: 3 + }) + } + ) + + it.each(['codex', 'claude'] as const)( + 'still reports an unsupported remote %s location without installing the host', + async (agent) => { + await expectSupportWithoutInstall({ + agent, + location: { executionHostId: 'ssh-host-1', wslDistro: null }, + expected: { supported: false, reason: 'remote' } + }) + } + ) + + it.each(['codex', 'claude'] as const)( + 'still reports an unsupported WSL %s location without installing the host', + async (agent) => { + await expectSupportWithoutInstall({ + agent, + location: { executionHostId: 'local', wslDistro: 'Ubuntu' }, + expected: { supported: false, reason: 'wsl' } + }) + } + ) + + it.each(['codex', 'claude'] as const)( + 'supports a local folder workspace for %s without installing the host', + async (agent) => { + await expectSupportWithoutInstall({ + agent, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceKind: 'folder' + }, + expected: { supported: true } + }) + } + ) + + it('still installs and reconciles on startup when a store is already persisted', async () => { + const runtime = createRuntime({ executionHostId: 'local', wslDistro: null }) + const { effects, ensure } = stubStructuredHostInstall(runtime) + const internal = runtime as unknown as { + hasPersistedStructuredAgentSessionStore: () => boolean + refreshMobileSessionPtyRecords: () => Promise + } + internal.hasPersistedStructuredAgentSessionStore = () => true + internal.refreshMobileSessionPtyRecords = vi.fn(async () => {}) + + await runtime.prepareStructuredAgentSessionStartupRestoration() + + expect(ensure).toHaveBeenCalledTimes(1) + expect(effects).toEqual({ + storeOpened: true, + writeGateAttached: true, + reaperStarted: true + }) + expect( + (getStructuredAgentSessionHost() as unknown as { reconcileRestartLeases: () => void }) + .reconcileRestartLeases + ).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/shell-wrapper-generated-file-snapshot.test.ts b/src/main/shell-wrapper-generated-file-snapshot.test.ts index ddbf1537984..36cd837e4fd 100644 --- a/src/main/shell-wrapper-generated-file-snapshot.test.ts +++ b/src/main/shell-wrapper-generated-file-snapshot.test.ts @@ -73,6 +73,7 @@ const CONTRACT_GLOBALS = new Set([ 'OPENCODE_CONFIG_DIR', 'PATH', 'PROMPT_COMMAND', + 'PS1', // Bash appends its non-printing Readline readiness marker. 'CURSOR', 'ZDOTDIR', 'precmd_functions', diff --git a/src/main/startup/main-process-preflight.ts b/src/main/startup/main-process-preflight.ts index 269eb2212e7..177a2357441 100644 --- a/src/main/startup/main-process-preflight.ts +++ b/src/main/startup/main-process-preflight.ts @@ -48,7 +48,7 @@ import { } from './single-instance-lock' import { setAppEnvironment } from '../../shared/app-environment' import { ElectronAppEnvironment } from '../host/electron-app-environment' -import { installProcessTreeKillBreadcrumbObserver } from '../crash-reporting/self-initiated-tree-kill-log' +import { installMainProcessTreeKillGate } from '../own-chromium-tree-kill-guard' import { setSecretStore } from '../../shared/secret-store' import { ElectronSecretStore } from '../host/electron-secret-store' import { setPtyHostBindings } from '../ipc/pty-host-bindings' @@ -163,8 +163,8 @@ export function runMainProcessPreflight(options: MainProcessPreflightOptions): b }) } // Why before any spawn: `signalProcessTree` is shared with the CLI and relay, so - // it can only reach the main-process breadcrumb store through a registered observer. - installProcessTreeKillBreadcrumbObserver() + // it can only reach the main-process guard and breadcrumb store once this is registered. + installMainProcessTreeKillGate() const isDev = is.dev configureDevUserDataPath(isDev) configureOrcaUserDataPathEnv() diff --git a/src/main/text-generation/commit-message-text-generation-cancellation.test.ts b/src/main/text-generation/commit-message-text-generation-cancellation.test.ts index 9cfde774384..820af710fb9 100644 --- a/src/main/text-generation/commit-message-text-generation-cancellation.test.ts +++ b/src/main/text-generation/commit-message-text-generation-cancellation.test.ts @@ -101,7 +101,7 @@ describe('generateCommitMessageFromContext', () => { cancelGenerateCommitMessageLocal('/repo') - expectChildTerminated(children[0]!) + await expectChildTerminated(children[0]!) expect(children[1]?.kill).not.toHaveBeenCalled() children[0]?.listeners.get('close')?.(null) @@ -192,7 +192,7 @@ describe('generateCommitMessageFromContext', () => { cancelGeneratePullRequestFieldsLocal('/repo') expect(children[0]?.kill).not.toHaveBeenCalled() - expectChildTerminated(children[1]!) + await expectChildTerminated(children[1]!) const commitStdout = children[0]?.listeners.get('stdout:data') commitStdout?.(Buffer.from('Update README\n')) @@ -250,7 +250,7 @@ describe('generateCommitMessageFromContext', () => { cancelGeneratePullRequestFieldsLocal('/repo') listeners.get('close')?.(null) - expectChildTerminated(child) + await expectChildTerminated(child) await expect(pullRequest).resolves.toEqual({ success: false, error: 'Generation canceled.', @@ -306,7 +306,7 @@ describe('generateCommitMessageFromContext', () => { ) cancelGenerateCommitMessageLocal('/repo') - expectChildTerminated(child) + await expectChildTerminated(child) await Promise.resolve() await Promise.resolve() await Promise.resolve() @@ -344,7 +344,7 @@ describe('generateCommitMessageFromContext', () => { error: 'Generation canceled.', canceled: true }) - expectChildTerminated(firstChild) + await expectChildTerminated(firstChild) const second = generateCommitMessageFromContext(context, params, { kind: 'local', @@ -377,7 +377,7 @@ describe('generateCommitMessageFromContext', () => { await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1)) cancelGenerateCommitMessageLocal('/descendant-repo') await expect(first).resolves.toMatchObject({ canceled: true }) - expectChildTerminated(firstChild) + await expectChildTerminated(firstChild) // SIGKILL reaches the codex process but not a grandchild that inherited its // stdout, so 'exit' arrives and 'close' never does. diff --git a/src/main/text-generation/commit-message-text-generation-local-subprocess.test.ts b/src/main/text-generation/commit-message-text-generation-local-subprocess.test.ts index c124cb85779..f73152255da 100644 --- a/src/main/text-generation/commit-message-text-generation-local-subprocess.test.ts +++ b/src/main/text-generation/commit-message-text-generation-local-subprocess.test.ts @@ -71,7 +71,7 @@ describe('generateCommitMessageFromContext', () => { error: 'agent CLI command produced too much output. Check the agent CLI configuration and try again.' }) - expectChildTerminated(child) + await expectChildTerminated(child) }) it('passes prepared provider environment to local agent subprocesses', async () => { diff --git a/src/main/text-generation/commit-message-text-generation-model-discovery.test.ts b/src/main/text-generation/commit-message-text-generation-model-discovery.test.ts index 4bc1a720f05..9a1f932f0a0 100644 --- a/src/main/text-generation/commit-message-text-generation-model-discovery.test.ts +++ b/src/main/text-generation/commit-message-text-generation-model-discovery.test.ts @@ -325,7 +325,7 @@ describe('discoverCommitMessageModelsLocal', () => { await vi.advanceTimersByTimeAsync(60_000) await assertion - expectChildTerminated(child) + await expectChildTerminated(child) expect(child.stdout.listenerCount('data')).toBe(0) expect(child.stderr.listenerCount('data')).toBe(0) expect(child.listenerCount('error')).toBe(0) @@ -352,7 +352,7 @@ describe('discoverCommitMessageModelsLocal', () => { success: false, error: 'Codex model discovery timed out after 60s.' }) - expectChildTerminated(firstChild) + await expectChildTerminated(firstChild) expect(spawnMock).toHaveBeenCalledTimes(1) firstChild.emit('close', null) @@ -413,7 +413,7 @@ describe('discoverCommitMessageModelsLocal', () => { success: false, error: 'Cursor returned too much model data.' }) - expectChildTerminated(child) + await expectChildTerminated(child) expect(child.stdout.listenerCount('data')).toBe(0) expect(child.stderr.listenerCount('data')).toBe(0) expect(child.listenerCount('error')).toBe(0) diff --git a/src/main/text-generation/commit-message-text-generation-test-harness.ts b/src/main/text-generation/commit-message-text-generation-test-harness.ts index 21103d71f40..8ba925ef087 100644 --- a/src/main/text-generation/commit-message-text-generation-test-harness.ts +++ b/src/main/text-generation/commit-message-text-generation-test-harness.ts @@ -33,15 +33,17 @@ export function withPlatform(platform: NodeJS.Platform, fn: () => T): T { // expectChildTerminated(child) with no extra argument. export function createChildTerminationExpectation( terminateWindowsProcessTreeMock: ReturnType -): (child: { pid: number; kill: ReturnType }) => void { - return (child) => { +): (child: { pid: number; kill: ReturnType }) => Promise { + return async (child) => { if (process.platform === 'win32') { expect(terminateWindowsProcessTreeMock).toHaveBeenCalledWith(child.pid, { site: 'source-control-text-generation' }) - expect(child.kill).not.toHaveBeenCalled() - return } - expect(child.kill).toHaveBeenCalledWith('SIGKILL') + // Every platform kills the root by its own handle. On win32 that is not a + // duplicate of the tree walk: it is what keeps a refused walk from resolving + // having killed nothing while the caller releases the managed-home lock. It + // runs after the walk there, so it can be a tick behind the caller. + await vi.waitFor(() => expect(child.kill).toHaveBeenCalledWith('SIGKILL')) } } diff --git a/src/main/text-generation/source-control-local-process.ts b/src/main/text-generation/source-control-local-process.ts index 170dead6b52..3f046494f0f 100644 --- a/src/main/text-generation/source-control-local-process.ts +++ b/src/main/text-generation/source-control-local-process.ts @@ -22,22 +22,25 @@ import type { TextGenerationOperation } from './source-control-text-generation-types' -export function killSourceControlAgentProcess( +export async function killSourceControlAgentProcess( child: SpawnedSourceControlAgentProcess ): Promise { const pid = child.pid if (!pid) { - return Promise.resolve() + return } if (process.platform === 'win32') { - return terminateWindowsProcessTree(pid, { site: 'source-control-text-generation' }) + // taskkill owns the tree, but the own-Chromium gate can refuse the + // pid-addressed walk; the handle-addressed root kill below cannot reach the + // recycled pid it refused, and callers release the managed-home lock on this + // promise, so it must not resolve having killed nothing. + await terminateWindowsProcessTree(pid, { site: 'source-control-text-generation' }) } try { child.kill('SIGKILL') } catch { // The process may exit between the PID check and kill. } - return Promise.resolve() } export function runLocalSourceControlPlan(input: { diff --git a/src/main/window/main-window-webview-security.ts b/src/main/window/main-window-webview-security.ts index da6e4159a58..a662449eb58 100644 --- a/src/main/window/main-window-webview-security.ts +++ b/src/main/window/main-window-webview-security.ts @@ -111,7 +111,7 @@ export function installMainWindowWebviewSecurity(mainWindow: BrowserWindow): voi mainWindow.webContents.on('did-attach-webview', (_event, guest) => { if (isDocPreviewSession(guest.session)) { - // Why: preview guests never join browser-tab routing, popups or anti-detection; the + // Why: preview guests never join browser-tab routing, popups or auth-identity tracking; the // workspace-doc profile is what refuses all three. The attach is also the point a live window // exists to receive read failures for that guest. setDocPreviewFailureSink(mainWindow.webContents) diff --git a/src/main/windows-live-tree-kill.win32.test.ts b/src/main/windows-live-tree-kill.win32.test.ts new file mode 100644 index 00000000000..45563e82c89 --- /dev/null +++ b/src/main/windows-live-tree-kill.win32.test.ts @@ -0,0 +1,197 @@ +import { existsSync, mkdtempSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawn, ChildProcess } from 'node:child_process' +import { subscribe, unsubscribe } from 'node:diagnostics_channel' +import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest' +import { setAppEnvironment, type AppEnvironment } from '../shared/app-environment' +import { setProcessTreeKillGate } from '../shared/child-process/process-tree-kill-gate' +import { signalProcessTree } from '../shared/child-process/process-tree-termination' +import { removeTreeSync } from '../shared/windows-transient-lock-removal' +import { + findSelfInitiatedTreeKills, + resetSelfInitiatedTreeKillLogForTest +} from './crash-reporting/self-initiated-tree-kill-log' +import { installMainProcessTreeKillGate } from './own-chromium-tree-kill-guard' +import { terminateWindowsProcessTree } from './windows-process-tree-kill' + +/** + * The unit tests pin the gate's decision against a mocked `taskkill`; this pins + * what that decision does to real Windows processes. + * + * Both are needed. Every claim the gate makes is about a mechanism the mocks + * cannot show: that `taskkill /T /F` actually reaps a detached grandchild, that + * a refusal actually leaves that tree standing, and that the handle-addressed + * root kill the refusal path falls back to actually reaps the root while + * orphaning its descendants — the asymmetry the PR discloses rather than fixes. + * + * Runs only on win32; skipped elsewhere. + */ +const describeOnWindows = process.platform === 'win32' ? describe : describe.skip + +/** Read live by the guard on every kill, so a case can flip it mid-test. */ +let orcaChromiumPids: number[] = [] + +function appEnvironment(): AppEnvironment { + return { + getPath: () => process.cwd(), + getAppPath: () => process.cwd(), + getVersion: () => '0.0.0-live', + isPackaged: () => false, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: (() => + orcaChromiumPids.map((pid) => ({ + pid, + type: 'Tab' + }))) as unknown as AppEnvironment['getAppMetrics'] + } +} + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) + +async function waitFor(predicate: () => boolean, timeoutMs = 10_000): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline && !predicate()) { + await sleep(100) + } + return predicate() +} + +let markerDirectory = '' +let markerSequence = 0 +const spawnedRoots: ChildProcess[] = [] +const spawnedLeaves: number[] = [] +const observedSpawns: ChildProcess[] = [] + +function observeSpawn(message: unknown): void { + if ( + typeof message === 'object' && + message !== null && + 'process' in message && + message.process instanceof ChildProcess + ) { + observedSpawns.push(message.process) + } +} + +/** A real root with a real grandchild; the grandchild reports its pid on disk. */ +async function spawnLiveTree(): Promise<{ + child: ChildProcess + rootPid: number + leafPid: number +}> { + const marker = join(markerDirectory, `leaf-${markerSequence++}.pid`) + const leafSource = `require('node:fs').writeFileSync(${JSON.stringify(marker)}, String(process.pid)); setTimeout(() => {}, 600000)` + // Non-detached Windows children can die with the root's libuv Job Object. + const rootSource = `require('node:child_process').spawn(process.execPath, ['-e', ${JSON.stringify(leafSource)}], { stdio: 'ignore', detached: true, windowsHide: true }); setTimeout(() => {}, 600000)` + const child = spawn(process.execPath, ['-e', rootSource], { + stdio: 'ignore', + windowsHide: true + }) + spawnedRoots.push(child) + const rootPid = child.pid as number + expect(rootPid).toBeGreaterThan(0) + expect(await waitFor(() => existsSync(marker))).toBe(true) + const leafPid = Number(readFileSync(marker, 'utf8')) + spawnedLeaves.push(leafPid) + expect(await waitFor(() => isAlive(leafPid))).toBe(true) + return { child, rootPid, leafPid } +} + +describeOnWindows('own-Chromium gate against real Windows process trees', () => { + beforeEach(() => { + markerDirectory ||= mkdtempSync(join(tmpdir(), 'orca-live-tree-kill-')) + resetSelfInitiatedTreeKillLogForTest() + orcaChromiumPids = [] + setAppEnvironment(appEnvironment()) + installMainProcessTreeKillGate() + observedSpawns.length = 0 + subscribe('child_process', observeSpawn) + }) + + afterEach(async () => { + unsubscribe('child_process', observeSpawn) + orcaChromiumPids = [] + for (const leafPid of spawnedLeaves.splice(0)) { + await terminateWindowsProcessTree(leafPid, { site: 'live-tree-kill-cleanup' }) + } + for (const root of spawnedRoots.splice(0)) { + root.kill('SIGKILL') + } + setProcessTreeKillGate(null) + }) + + afterAll(() => { + if (markerDirectory) { + removeTreeSync(markerDirectory) + } + }) + + it('admitted: taskkill reaps the root and its detached grandchild, and the kill is recorded', async () => { + const { rootPid, leafPid } = await spawnLiveTree() + + await terminateWindowsProcessTree(rootPid, { site: 'live-tree-kill-admit' }) + + expect(await waitFor(() => !isAlive(rootPid))).toBe(true) + expect(await waitFor(() => !isAlive(leafPid))).toBe(true) + expect( + findSelfInitiatedTreeKills(Date.now()).some( + (kill) => kill.pid === rootPid && kill.site === 'live-tree-kill-admit' + ) + ).toBe(true) + }) + + it('refused: the tree survives, nothing is recorded, and the handle kill still reaps the root', async () => { + const { child, rootPid, leafPid } = await spawnLiveTree() + orcaChromiumPids = [rootPid] + + await terminateWindowsProcessTree(rootPid, { site: 'live-tree-kill-refuse' }) + + await sleep(1_000) + expect(isAlive(rootPid)).toBe(true) + expect(isAlive(leafPid)).toBe(true) + expect(findSelfInitiatedTreeKills(Date.now())).toEqual([]) + + // The fallback every gated site runs after a refusal. + child.kill('SIGKILL') + expect(await waitFor(() => !isAlive(rootPid))).toBe(true) + // Let root-owned job cleanup finish before asserting independent survival. + await sleep(250) + // Disclosed asymmetry: a refusal orphans descendants rather than reaping them. + expect(isAlive(leafPid)).toBe(true) + }) + + it('signalProcessTree refused: the root goes by handle and the barrier reports unverified', async () => { + const { child, rootPid, leafPid } = await spawnLiveTree() + orcaChromiumPids = [rootPid] + observedSpawns.length = 0 + + await expect(signalProcessTree(child, 'SIGKILL')).resolves.toBe(false) + + expect(observedSpawns).toHaveLength(0) + expect(await waitFor(() => !isAlive(rootPid))).toBe(true) + await sleep(250) + expect(isAlive(leafPid)).toBe(true) + }) + + it('signalProcessTree admitted: the whole tree goes and the barrier reports verified', async () => { + const { child, rootPid, leafPid } = await spawnLiveTree() + observedSpawns.length = 0 + + await expect(signalProcessTree(child, 'SIGKILL')).resolves.toBe(true) + + expect(observedSpawns.map((child) => child.spawnfile)).toEqual(['taskkill']) + expect(await waitFor(() => !isAlive(rootPid))).toBe(true) + expect(await waitFor(() => !isAlive(leafPid))).toBe(true) + }) +}) diff --git a/src/main/windows-process-tree-kill.ts b/src/main/windows-process-tree-kill.ts index ea7b756ada4..31e6b18da2a 100644 --- a/src/main/windows-process-tree-kill.ts +++ b/src/main/windows-process-tree-kill.ts @@ -11,8 +11,9 @@ export const WINDOWS_PROCESS_TREE_KILL_TIMEOUT_MS = 5_000 * Best-effort: missing/already-dead roots still resolve so callers can finish * their own handle cleanup via killRoot. * - * Nearly every main-process taskkill runs through here; the two account-login - * teardowns keep their own spawn but share the same gate, so the refusal and the + * Most main-process taskkills run through here; the families that keep their own + * spawn (account-login teardowns, codex app-server deadline, git-command abort, + * notebook and precheck timeouts) share the same gate, so the refusal and the * breadcrumb live in `admitSelfInitiatedTreeKill` rather than in this function. */ export function terminateWindowsProcessTree( diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPane.webview-preferences.test.ts b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPane.webview-preferences.test.ts index 5e6dc77f67d..aff65e4eb3e 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPane.webview-preferences.test.ts +++ b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPane.webview-preferences.test.ts @@ -56,7 +56,7 @@ describe('BrowserPane webview preferences', () => { 'persist:orca-browser-session-profile-1' ) expect(ensuredWebview?.webview.getAttribute('webpreferences')).toBe( - ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE + `${ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE},transparent=false` ) expect(registryMocks.registerPersistentWebview).toHaveBeenCalledWith( 'browser-page-1', diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-surface.test.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-surface.test.ts new file mode 100644 index 00000000000..2d156ed926c --- /dev/null +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-surface.test.ts @@ -0,0 +1,93 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ORCA_BROWSER_BLANK_URL } from '../../../../../shared/constants' +import { ensureBrowserPageWebview } from './browser-page-webview' +import { webviewRegistry } from './webview-registry' + +vi.mock('./webview-registry', () => { + const webviewRegistry = new Map() + return { + webviewRegistry, + registerPersistentWebview: vi.fn((id, guest) => webviewRegistry.set(id, guest)), + replacePersistentWebview: vi.fn(), + destroyPersistentWebview: vi.fn() + } +}) + +afterEach(() => { + document.body.replaceChildren() + webviewRegistry.clear() +}) + +function createGuest(): Electron.WebviewTag { + const container = document.createElement('div') + document.body.appendChild(container) + return ensureBrowserPageWebview({ + browserTabId: 'surface-test', + container, + inputLocked: false, + webviewPartition: 'persist:browser-test', + resolveContainer: () => container + })!.webview +} + +function commit(guest: Electron.WebviewTag, url: string, isMainFrame = true): void { + guest.dispatchEvent(Object.assign(new Event('load-commit'), { url, isMainFrame })) +} + +describe('browser page surface ownership', () => { + it('themes the host before attach and uses an opaque native canvas for real pages', () => { + const guest = createGuest() + expect(guest.style.background).toBe('var(--background)') + expect(guest.getAttribute('webpreferences')).toContain('transparent=false') + expect(guest.getAttribute('webpreferences')).toContain('disableHtmlFullscreenWindowResize=true') + }) + + it.each(['about:blank', ORCA_BROWSER_BLANK_URL])( + 'keeps %s unavailable through first navigation, then reveals the committed page', + (url) => { + const guest = createGuest() + commit(guest, url) + expect(guest.style.visibility).toBe('hidden') + guest.dispatchEvent(new Event('did-start-loading')) + expect(guest.style.visibility).toBe('hidden') + commit(guest, 'https://example.test') + expect(guest.style.visibility).toBe('visible') + guest.dispatchEvent(new Event('did-start-loading')) + expect(guest.style.visibility).toBe('visible') + commit(guest, 'about:blank', false) + expect(guest.style.visibility).toBe('visible') + } + ) + + it('preserves a reused guest and initializes the same surface after a container remount', () => { + const guest = createGuest() + commit(guest, 'https://example.test') + const container = guest.parentElement as HTMLDivElement + const reused = ensureBrowserPageWebview({ + browserTabId: 'surface-test', + container, + inputLocked: false, + webviewPartition: 'persist:browser-test', + resolveContainer: () => container + })! + expect(reused.created).toBe(false) + expect(reused.webview).toBe(guest) + expect(reused.webview.style.visibility).toBe('visible') + const replacement = createGuest() + expect(replacement).not.toBe(guest) + expect(replacement.style.background).toBe('var(--background)') + expect(replacement.getAttribute('webpreferences')).toContain('transparent=false') + commit(replacement, ORCA_BROWSER_BLANK_URL) + expect(replacement.style.visibility).toBe('hidden') + }) + + it('exposes the themed host after renderer loss until a recovered document commits', () => { + const guest = createGuest() + commit(guest, 'https://example.test') + guest.dispatchEvent(new Event('render-process-gone')) + expect(guest.style.visibility).toBe('hidden') + commit(guest, 'https://example.test') + expect(guest.style.visibility).toBe('visible') + }) +}) diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-webview.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview.ts index 30e1cc18442..3c959751051 100644 --- a/src/renderer/src/components/browser-pane/host-guest/browser-page-webview.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview.ts @@ -1,3 +1,4 @@ +import { ORCA_BROWSER_BLANK_URL } from '../../../../../shared/constants' import { ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE } from '../../../../../shared/browser-guest-web-preferences' import { destroyPersistentWebview, @@ -59,16 +60,29 @@ export function ensureBrowserPageWebview({ webview.setAttribute('allowpopups', '') // Why: Electron spreads the webpreferences keys verbatim, so the shared // camelCase attribute must stay intact for fullscreen containment to work. - webview.setAttribute('webpreferences', ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE) + // Keep Chromium's normal page canvas opaque while the host underneath follows Orca's theme. + webview.setAttribute( + 'webpreferences', + `${ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE},transparent=false` + ) webview.style.display = 'flex' webview.style.flex = '1' webview.style.width = '100%' webview.style.height = '100%' webview.style.border = 'none' setBrowserPageWebviewInputLock(webview, inputLocked) - // Why: some pages never paint a background, and a white viewport matches - // normal browser behavior instead of leaking Orca chrome through the guest. - webview.style.background = '#ffffff' + webview.style.background = 'var(--background)' + const guest = webview + // A committed synthetic blank document belongs to New Tab, including while its first URL waits. + guest.addEventListener('load-commit', (event) => { + if (event.isMainFrame) { + guest.style.visibility = + event.url === 'about:blank' || event.url === ORCA_BROWSER_BLANK_URL ? 'hidden' : 'visible' + } + }) + guest.addEventListener('render-process-gone', () => { + guest.style.visibility = 'hidden' + }) registerPersistentWebview(browserTabId, webview) activeContainer.appendChild(webview) created = true diff --git a/src/renderer/src/components/cmd-j/native-chat-split-quick-actions.ts b/src/renderer/src/components/cmd-j/native-chat-split-quick-actions.ts new file mode 100644 index 00000000000..d2019fb429a --- /dev/null +++ b/src/renderer/src/components/cmd-j/native-chat-split-quick-actions.ts @@ -0,0 +1,61 @@ +import { PanelBottomClose, PanelRightClose } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import type { CmdJQuickAction } from './quick-actions' +import type { CmdJQuickActionContext } from './quick-action-context' +import type { NativeChatSplitDirection } from '@/components/native-chat/native-chat-split-shortcut' + +function availability(ctx: CmdJQuickActionContext) { + return ctx.canSplitActiveChat + ? ({ available: true } as const) + : ({ available: false, reason: 'no-active-chat' } as const) +} + +function splitAction( + direction: NativeChatSplitDirection, + action: Pick +): CmdJQuickAction { + return { + ...action, + kind: 'action', + isAvailable: availability, + run: async (ctx) => { + if (!availability(ctx).available || !ctx.splitActiveChat?.(direction)) { + return { status: 'unavailable', reason: 'no-active-chat' } + } + return { status: 'ok' } + } + } +} + +export function getNativeChatSplitQuickActions(): CmdJQuickAction[] { + return [ + splitAction('right', { + id: 'split-chat-right', + title: translate('auto.components.cmd.j.quick.actions.splitChatRight', 'Split Chat Right'), + description: translate( + 'auto.components.cmd.j.quick.actions.splitChatRightDescription', + 'Open the active chat in a split pane to the right.' + ), + icon: PanelRightClose, + verbKeywords: [ + translate('auto.components.cmd.j.quick.actions.verbs.splitChatRight', 'split chat right'), + translate('auto.components.cmd.j.quick.actions.verbs.moveChatRight', 'move chat right'), + translate('auto.components.cmd.j.quick.actions.verbs.chatPaneRight', 'chat pane right') + ] + }), + splitAction('down', { + id: 'split-chat-down', + title: translate('auto.components.cmd.j.quick.actions.splitChatDown', 'Split Chat Down'), + description: translate( + 'auto.components.cmd.j.quick.actions.splitChatDownDescription', + 'Open the active chat in a split pane below.' + ), + icon: PanelBottomClose, + verbKeywords: [ + translate('auto.components.cmd.j.quick.actions.verbs.splitChatDown', 'split chat down'), + translate('auto.components.cmd.j.quick.actions.verbs.moveChatDown', 'move chat down'), + translate('auto.components.cmd.j.quick.actions.verbs.chatPaneBelow', 'chat pane below') + ] + }) + ] +} diff --git a/src/renderer/src/components/cmd-j/quick-action-context.test.ts b/src/renderer/src/components/cmd-j/quick-action-context.test.ts index 325252c4dc2..843634309df 100644 --- a/src/renderer/src/components/cmd-j/quick-action-context.test.ts +++ b/src/renderer/src/components/cmd-j/quick-action-context.test.ts @@ -254,6 +254,62 @@ describe('Cmd+J quick action context', () => { expect(context.isLoading).toBe(true) }) + it('exposes chat split actions only on the workspace surface', () => { + const worktree = { + id: 'wt-1', + repoId: 'repo-1', + path: '/repo/wt', + displayName: 'Workspace', + branch: 'main', + createdAt: 0 + } as Worktree + const state = { + activeWorktreeId: 'wt-1', + worktreesByRepo: { 'repo-1': [worktree] }, + repos: [{ id: 'repo-1', path: '/repo', displayName: 'Repo', addedAt: 0 }], + sshConnectionStates: new Map(), + activeGroupIdByWorktree: { 'wt-1': 'group-1' }, + groupsByWorktree: { + 'wt-1': [ + { + id: 'group-1', + worktreeId: 'wt-1', + activeTabId: 'chat-1', + tabOrder: ['chat-1', 'other-1'] + } + ] + }, + unifiedTabsByWorktree: { + 'wt-1': [ + { + id: 'chat-1', + entityId: 'session-1', + groupId: 'group-1', + worktreeId: 'wt-1', + contentType: 'agent-session' + } + ] + }, + activeView: 'settings', + settings: null + } as unknown as AppState + + const buildContext = (activeView: AppState['activeView']) => + buildCmdJQuickActionContext({ + state: { ...state, activeView }, + activeGroupSnapshot: null, + openNewBrowserTab: async () => {}, + openNewMarkdownFile: async () => {}, + openNewTerminalTab: async () => {}, + openCreateWorkspace: () => {}, + deleteActiveWorkspace: () => {}, + openAddQuickCommand: () => {} + }) + + expect(buildContext('terminal').canSplitActiveChat).toBe(true) + expect(buildContext('settings').canSplitActiveChat).toBe(false) + }) + it('runtime re-check returns unavailable without invoking the action helper', async () => { const calls: string[] = [] const action = getCmdJQuickActions().find((entry) => entry.id === 'new-terminal-tab') @@ -335,4 +391,33 @@ describe('Cmd+J quick action context', () => { await expect(action?.run(context)).resolves.toEqual({ status: 'ok' }) expect(calls).toEqual(['delete']) }) + + it('offers and runs split actions only for an active movable chat', async () => { + const calls: string[] = [] + const action = getCmdJQuickActions().find((entry) => entry.id === 'split-chat-right') + const context = { + ...ctx({}), + activeWorktree: null, + runtimeMode: 'local-desktop' as const, + openNewBrowserTab: async () => {}, + openNewMarkdownFile: async () => {}, + openNewTerminalTab: async () => {}, + openCreateWorkspace: () => {}, + deleteActiveWorkspace: () => {}, + openAddQuickCommand: () => {}, + canSplitActiveChat: true, + splitActiveChat: (direction: string) => { + calls.push(direction) + return true + } + } satisfies CmdJQuickActionContext + + expect(action?.isAvailable(context)).toEqual({ available: true }) + await expect(action?.run(context)).resolves.toEqual({ status: 'ok' }) + expect(calls).toEqual(['right']) + expect(action?.isAvailable({ ...context, canSplitActiveChat: false })).toEqual({ + available: false, + reason: 'no-active-chat' + }) + }) }) diff --git a/src/renderer/src/components/cmd-j/quick-action-context.ts b/src/renderer/src/components/cmd-j/quick-action-context.ts index 88a8cfad533..bbed9671a4e 100644 --- a/src/renderer/src/components/cmd-j/quick-action-context.ts +++ b/src/renderer/src/components/cmd-j/quick-action-context.ts @@ -3,12 +3,19 @@ import { findWorktreeById } from '@/store/slices/worktree-helpers' import type { Worktree } from '../../../../shared/worktree/types' import type { SshConnectionStatus } from '../../../../shared/ssh-types' import { getClientCreationActionPolicy } from '@/lib/client-creation-action-policy' +import { + canRunNativeChatSplitTarget, + resolveActiveNativeChatSplitTarget, + runActiveNativeChatSplit +} from '@/components/native-chat/native-chat-layout-actions' +import type { NativeChatSplitDirection } from '@/components/native-chat/native-chat-split-shortcut' export type CmdJUnavailableReason = | 'loading' | 'no-active-workspace' | 'ssh-disconnected' | 'no-active-group' + | 'no-active-chat' | 'client-action-unsupported' export type CmdJQuickActionAvailability = @@ -35,6 +42,8 @@ export type CmdJQuickActionContext = { openCreateWorkspace: () => void deleteActiveWorkspace: () => void openAddQuickCommand: () => void + canSplitActiveChat?: boolean + splitActiveChat?: (direction: NativeChatSplitDirection) => boolean } export function resolveCmdJActiveGroupId( @@ -166,6 +175,10 @@ export function buildCmdJQuickActionContext(args: { const managedBrowserCreationEnabled = getClientCreationActionPolicy(args.state, activeWorktreeId)['managed-browser'].state === 'enabled' + const activeChatTarget = + args.state.activeView === 'terminal' + ? resolveActiveNativeChatSplitTarget(args.state, activeWorktreeId, activeGroupId) + : null return { activeView: args.state.activeView, @@ -181,7 +194,10 @@ export function buildCmdJQuickActionContext(args: { openNewTerminalTab: args.openNewTerminalTab, openCreateWorkspace: args.openCreateWorkspace, deleteActiveWorkspace: args.deleteActiveWorkspace, - openAddQuickCommand: args.openAddQuickCommand + openAddQuickCommand: args.openAddQuickCommand, + canSplitActiveChat: canRunNativeChatSplitTarget(args.state, activeChatTarget), + splitActiveChat: (direction) => + runActiveNativeChatSplit(activeWorktreeId, activeGroupId, direction) } } @@ -198,6 +214,8 @@ export function getUnavailableQuickActionMessage( return `Can't ${actionTitle.toLowerCase()} — workspace is disconnected.` case 'no-active-group': return `Can't ${actionTitle.toLowerCase()} — no tab group is available.` + case 'no-active-chat': + return `Can't ${actionTitle.toLowerCase()} — no movable chat is active.` case 'client-action-unsupported': return `Can't ${actionTitle.toLowerCase()} — this client and runtime do not support it.` } diff --git a/src/renderer/src/components/cmd-j/quick-actions.ts b/src/renderer/src/components/cmd-j/quick-actions.ts index 70f0e5f6859..00ccf127bad 100644 --- a/src/renderer/src/components/cmd-j/quick-actions.ts +++ b/src/renderer/src/components/cmd-j/quick-actions.ts @@ -8,6 +8,7 @@ import { } from './quick-action-context' import { translate } from '@/i18n/i18n' import { createLocalizedCatalog } from '@/i18n/localized-catalog' +import { getNativeChatSplitQuickActions } from './native-chat-split-quick-actions' export type CmdJQuickActionRunResult = | { status: 'ok' } @@ -125,6 +126,7 @@ export const getCmdJQuickActions = createLocalizedCatalog((): CmdJQuickAction[] isAvailable: workspaceActionAvailability, run: (ctx) => runWorkspaceAction(ctx, ctx.openNewTerminalTab) }, + ...getNativeChatSplitQuickActions(), { id: CREATE_WORKSPACE_QUICK_ACTION_ID, kind: 'action', diff --git a/src/renderer/src/components/mobile/mobile-platform-copy.ts b/src/renderer/src/components/mobile/mobile-platform-copy.ts index fd70176609d..cd6669891a3 100644 --- a/src/renderer/src/components/mobile/mobile-platform-copy.ts +++ b/src/renderer/src/components/mobile/mobile-platform-copy.ts @@ -22,7 +22,7 @@ const IOS_CHANNEL_COPY: Record = { const ANDROID_COPY: InstallCopy = { ctaLabel: 'Download APK', - url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk' + url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk' } export function getInstallCopy(platform: Platform, iosChannel: IosChannel): InstallCopy { diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index a1aecd23419..a6f233b96a3 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -6,7 +6,6 @@ import type { SessionOptionDescriptor, SessionOptionsSurface } from '../../../../shared/native-chat-session-options' -import type * as nativeChatAgentProfiles from '../../../../shared/native-chat-agent-profiles' import { clearNativeChatSessionOptionCacheForTests } from './native-chat-session-option-cache' import { clearNativeChatModelEnrichmentForTests } from './native-chat-session-option-enrichment' @@ -27,6 +26,7 @@ const mocks = vi.hoisted(() => ({ sessionOptionsSnapshot?: SessionOptionDescriptor[] attachDisabled?: boolean sendButtonDisabled?: boolean + autocomplete?: { mode: string; items?: { kind: string; name: string }[] } } | null, modelSwitchOutcome: 'applied' as 'applied' | 'rejected' | 'unknown', confirmationObserver: null as { @@ -90,10 +90,6 @@ vi.mock('./claude-model-switch-confirmation', () => ({ createClaudeModelSwitchConfirmationObserver: (...args: unknown[]) => mocks.createClaudeModelSwitchConfirmationObserver(...args) })) -vi.mock('../../../../shared/native-chat-agent-profiles', async (importOriginal) => ({ - ...(await importOriginal()), - getVerifiedNativeChatCommands: () => [] -})) vi.mock('@/lib/native-chat-telemetry', () => ({ emitNativeChatMessageSent: vi.fn(), emitNativeChatPickerItemAccepted: vi.fn(), @@ -308,6 +304,43 @@ describe('NativeChatComposer', () => { expect(mocks.setDraft).toHaveBeenCalledWith('') }) + // The structured slash menu must offer the running agent's own catalog. Offering + // another agent's tokens sends them past the command guard as literal prompt text. + it.each([ + ['claude', 'compact', 'vim'], + ['codex', 'vim', 'help'] + ] as const)('offers %s its own structured slash commands', (agent, offered, withheld) => { + mocks.draft = '/' + render( + true), + dispatchCommand: vi.fn(async () => ({ handled: false, accepted: false, error: null })), + optionsSurface: { + getSnapshot: () => [], + setOption: vi.fn(), + invokeAction: vi.fn(), + subscribe: () => () => {} + }, + optionSnapshot: [], + onError: vi.fn(), + runtime: 'local' + }} + /> + ) + + const names = (mocks.fieldProps?.autocomplete?.items ?? []) + .filter((item) => item.kind === 'command') + .map((item) => item.name) + expect(names).toContain(offered) + expect(names).toContain('effort') + expect(names).not.toContain(withheld) + }) + it('sends structured image attachments through the durable transport', async () => { mocks.draft = '' mocks.imageAttachments = [{ id: 'image-1', path: '/tmp/image.png' }] diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index e0b1d97a057..06ae0ec5c8c 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -3,7 +3,7 @@ import { useAppStore } from '../../store' import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' import { getVerifiedNativeChatCommands } from '../../../../shared/native-chat-agent-profiles' -import { STRUCTURED_AGENT_SESSION_SLASH_COMMANDS } from '../../../../shared/structured-agent-session-composer' +import { structuredSlashCommands } from '../../../../shared/structured-agent-session-composer' import { applyMentionSuggestion, EMPTY_HISTORY, @@ -111,9 +111,7 @@ const NativeChatComposerPane = forwardRef - structuredTransport - ? STRUCTURED_AGENT_SESSION_SLASH_COMMANDS - : getVerifiedNativeChatCommands(agent), + structuredTransport ? structuredSlashCommands(agent) : getVerifiedNativeChatCommands(agent), [agent, structuredTransport] ) const picker = useNativeChatPickerState({ diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index 12b10e0f714..53ed2cb8244 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -156,6 +156,7 @@ function MessageRow({ className="text-sm" onLinkClick={onLinkClick} allowFileUriLinks={allowFileUriLinks} + linkifyFilePaths={onLinkClick !== undefined} /> ) : null} {tools.length > 0 ? ( @@ -200,7 +201,7 @@ export function NativeChatMessageList({ onLinkClick?: CommentMarkdownLinkClickHandler allowFileUriLinks?: boolean failedDeliveryMessageIds?: ReadonlySet - /** Turn timing/disclosure is available only on the structured Codex lane. */ + /** Turn timing and disclosure are available on structured agent sessions. */ showTurnStatus?: boolean runtimeContext?: RuntimeFileOperationArgs | null }): React.JSX.Element { diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx index dc152df4c70..6f934f66a6e 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx @@ -52,6 +52,9 @@ import { useNativeChatFileLinkClick } from './use-native-chat-file-link-click' import type { NativeChatResolvedViewProps } from './native-chat-view-types' import { useNativeChatFileLinkContext } from './use-native-chat-file-link-context' import { NativeChatOrchestrationPausedNotice } from './NativeChatOrchestrationPausedNotice' +import { matchNativeChatSplitShortcut } from './native-chat-split-shortcut' +import { getShortcutPlatform } from '@/lib/shortcut-platform' +import { formatShortcutLabel } from '@/hooks/useShortcutLabel' /** Renders the bridge UI after NativeChatSessionGate resolves its agent session. */ export function NativeChatResolvedView({ @@ -73,6 +76,7 @@ export function NativeChatResolvedView({ const runtimeEnvironmentId = useAppStore((s) => selectNativeChatRuntimeEnvironmentId(s, terminalTabId) ) + const keybindings = useAppStore((s) => s.keybindings) const session = useNativeChatRetainedSession({ paneKey, agent, @@ -131,6 +135,10 @@ export function NativeChatResolvedView({ const contextMenu = useNativeChatContextMenu({ rootRef, onSwitchToTerminal, + splitShortcutLabels: { + right: formatShortcutLabel('terminal.splitRight', keybindings), + down: formatShortcutLabel('terminal.splitDown', keybindings) + }, actions: { onPaste: pasteClipboardIntoComposer, ...(contextMenuActions ?? emptyNativeChatContextMenuActions) @@ -337,6 +345,19 @@ export function NativeChatResolvedView({ } }} onKeyDownCapture={(event) => { + const splitDirection = event.repeat + ? null + : matchNativeChatSplitShortcut(event, getShortcutPlatform(), keybindings) + if (splitDirection && contextMenuActions) { + event.preventDefault() + event.stopPropagation() + if (splitDirection === 'right') { + contextMenuActions.onSplitRight() + } else { + contextMenuActions.onSplitDown() + } + return + } // Backspace/Delete outside an input focuses the composer (like typing) // but inserts nothing — let the now-focused field handle the keystroke. if (shouldFocusNativeChatComposerFromEditingKey(event)) { diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx index d3c13a48ef1..356a482dfa3 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx @@ -14,6 +14,8 @@ const mocks = vi.hoisted(() => ({ messageListProps: null as null | { allowFileUriLinks?: boolean onLinkClick?: (...args: unknown[]) => void + showTurnStatus?: boolean + runtimeContext?: unknown }, composerProps: null as null | { structuredTransport?: Record }, questionCardProps: null as NativeChatQuestionCardProps | null, @@ -163,7 +165,6 @@ describe('NativeChatStructuredSession', () => { sessionId="session-paste" target={{ kind: 'local' }} agent="codex" - allowFileUriLinks /> ) @@ -174,15 +175,14 @@ describe('NativeChatStructuredSession', () => { expect(mocks.pasteFromClipboard).toHaveBeenCalledOnce() }) - it('wires local structured file links through the native chat opener', () => { + it('wires remote structured file links through the host-aware native chat opener', () => { render( ) @@ -190,6 +190,26 @@ describe('NativeChatStructuredSession', () => { expect(mocks.messageListProps?.onLinkClick).toBe(mocks.fileLinkClick) }) + // Turn status and transcript image previews shipped Codex-first. Every + // structured session renders through the same list, so neither is agent-gated. + it.each(['codex', 'claude'] as const)( + 'renders the same structured transcript chrome for %s', + (agent) => { + render( + + ) + + expect(mocks.messageListProps?.showTurnStatus).toBe(true) + expect(mocks.messageListProps?.runtimeContext).not.toBeUndefined() + } + ) + it('routes a bare model command to the native option picker', async () => { render( { sessionId="session-1" target={{ kind: 'local' }} agent="codex" - allowFileUriLinks /> ) const dispatchCommand = mocks.composerProps?.structuredTransport?.dispatchCommand as @@ -234,7 +253,6 @@ describe('NativeChatStructuredSession', () => { sessionId="session-1" target={{ kind: 'local' }} agent="codex" - allowFileUriLinks /> ) @@ -266,7 +284,6 @@ describe('NativeChatStructuredSession', () => { sessionId="session-wedge" target={{ kind: 'local' }} agent="codex" - allowFileUriLinks /> ) @@ -298,7 +315,6 @@ describe('NativeChatStructuredSession', () => { sessionId="session-probe-flag" target={{ kind: 'local' }} agent="codex" - allowFileUriLinks /> ) @@ -331,7 +347,6 @@ describe('NativeChatStructuredSession', () => { sessionId="session-parked" target={{ kind: 'local' }} agent="codex" - allowFileUriLinks /> ) @@ -381,7 +396,6 @@ describe('NativeChatStructuredSession', () => { sessionId="session-churn" target={{ kind: 'local' }} agent="codex" - allowFileUriLinks /> ) const { rerender } = render(makeView()) @@ -435,7 +449,6 @@ describe('NativeChatStructuredSession', () => { sessionId="session-target-switch" target={target} agent="codex" - allowFileUriLinks /> ) const { rerender } = render(makeView({ kind: 'local' })) @@ -470,7 +483,6 @@ describe('NativeChatStructuredSession', () => { sessionId="session-forced" target={{ kind: 'local' }} agent="codex" - allowFileUriLinks /> ) @@ -509,7 +521,6 @@ describe('NativeChatStructuredSession', () => { sessionId="session-pending" target={{ kind: 'local' }} agent="codex" - allowFileUriLinks /> ) @@ -539,7 +550,6 @@ describe('NativeChatStructuredSession', () => { sessionId="session-budget" target={{ kind: 'local' }} agent="codex" - allowFileUriLinks /> ) @@ -610,7 +620,6 @@ describe('NativeChatStructuredSession', () => { sessionId="session-questions" target={{ kind: 'local' }} agent="claude" - allowFileUriLinks={false} /> ) @@ -669,7 +678,6 @@ describe('NativeChatStructuredSession', () => { sessionId="session-legacy-question" target={{ kind: 'local' }} agent="claude" - allowFileUriLinks={false} /> ) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index d6464c27760..4c5038b6dd6 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -1,14 +1,9 @@ import { useMemo, useRef, useState } from 'react' import { RotateCcw } from 'lucide-react' -import type { - AgentStatusOrchestrationContext, - AgentType -} from '../../../../shared/agent-status-types' import { encodeAgentSessionQuestionAnswers } from '../../../../shared/agent-session-question-answer' import { dispatchStructuredAgentSessionComposerCommand } from '../../../../shared/structured-agent-session-composer' import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' import type { NativeChatLiveSession } from './use-native-chat-live-session' -import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { Button } from '@/components/ui/button' import { NativeChatApprovalCard } from './NativeChatApprovalCard' import { NativeChatComposer, type NativeChatComposerHandle } from './NativeChatComposer' @@ -22,22 +17,17 @@ import { useNativeChatFileLinkContext } from './use-native-chat-file-link-contex import { useStructuredAgentSession } from './use-structured-agent-session' import { translate } from '@/i18n/i18n' import { NativeChatOrchestrationPausedNotice } from './NativeChatOrchestrationPausedNotice' -import { useNativeChatPasteBridge } from './use-native-chat-paste-bridge' import { useNativeChatImageRuntimeContext } from './native-chat-image-runtime-context' +import { useStructuredNativeChatPaneCommands } from './use-structured-native-chat-pane-commands' +import type { NativeChatStructuredViewProps } from './native-chat-view-types' function encodeQuestionAnswer(questionId: string, answer: string): string { return `${encodeURIComponent(questionId)}:${encodeURIComponent(answer)}` } -export function NativeChatStructuredSession(props: { - tabId: string - sessionId: string - target: RuntimeClientTarget - agent: AgentType - isVisible: boolean - allowFileUriLinks: boolean - orchestrationDispatchStatus?: AgentStatusOrchestrationContext['dispatchStatus'] -}): React.JSX.Element { +export function NativeChatStructuredSession( + props: Omit +): React.JSX.Element { const controller = useStructuredAgentSession(props) const [composerError, setComposerError] = useState(null) const [optionPickerRequest, setOptionPickerRequest] = useState<{ @@ -50,7 +40,14 @@ export function NativeChatStructuredSession(props: { ) const rootRef = useRef(null) const composerRef = useRef(null) - useNativeChatPasteBridge({ rootRef, composerRef }) + const paneCommands = useStructuredNativeChatPaneCommands({ + tabId: props.tabId, + groupId: props.groupId, + isVisible: props.isVisible, + rootRef, + composerRef, + terminalPaneActions: props.contextMenuActions + }) const session = useMemo( () => ({ messages: controller.messages, @@ -83,7 +80,7 @@ export function NativeChatStructuredSession(props: { const fontScale = useNativeChatFontScale(viewState.kind === 'ready') const fileLinkContext = useNativeChatFileLinkContext(props.tabId) const imageRuntimeContext = useNativeChatImageRuntimeContext(props.tabId) - const fileLinkClick = useNativeChatFileLinkClick(props.allowFileUriLinks ? fileLinkContext : null) + const fileLinkClick = useNativeChatFileLinkClick(fileLinkContext) const prompt = controller.prompts[0] ?? null const questionBody = prompt?.body.kind === 'question' ? prompt.body : null const questions = @@ -143,6 +140,15 @@ export function NativeChatStructuredSession(props: { data-native-chat-root="true" data-native-chat-working={controller.isWorking ? 'true' : 'false'} tabIndex={-1} + onPointerDownCapture={(event) => { + if (event.button === 2) { + paneCommands.onSelectionCapture() + } + }} + onMouseUpCapture={paneCommands.onSelectionCapture} + onKeyUpCapture={paneCommands.onSelectionCapture} + onKeyDownCapture={paneCommands.onKeyDownCapture} + onContextMenuCapture={paneCommands.onContextMenuCapture} className="flex h-full min-h-0 w-full flex-col bg-background focus:outline-none" > @@ -160,10 +166,10 @@ export function NativeChatStructuredSession(props: { expandSignal={false} fontScale={fontScale.scale} workingStartedAt={null} - showTurnStatus={props.agent === 'codex'} + showTurnStatus onLinkClick={fileLinkClick} allowFileUriLinks={fileLinkClick !== undefined} - runtimeContext={props.agent === 'codex' ? imageRuntimeContext : undefined} + runtimeContext={imageRuntimeContext} /> )} @@ -283,6 +289,7 @@ export function NativeChatStructuredSession(props: { structuredTransport={structuredTransport} /> )} + {paneCommands.menu} ) } diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.test.tsx index 25991eac3ff..05101f14fd1 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.test.tsx @@ -8,7 +8,6 @@ type MockAppState = { unifiedTabsByWorktree: Record groupsByWorktree: Record runtimeEnvironmentId: string | null - executionHostId: string focusGroup: (worktreeId: string, groupId: string) => void } @@ -16,7 +15,8 @@ const mocks = vi.hoisted(() => ({ store: null as null | { setState: (state: Partial) => void }, focusGroup: vi.fn(), mountsByTabId: new Map(), - unmountsByTabId: new Map() + unmountsByTabId: new Map(), + groupIdByTabId: new Map() })) vi.mock('@/store', async () => { @@ -25,7 +25,6 @@ vi.mock('@/store', async () => { unifiedTabsByWorktree: {}, groupsByWorktree: {}, runtimeEnvironmentId: null, - executionHostId: 'local', focusGroup: mocks.focusGroup })) mocks.store = useAppStore @@ -33,8 +32,7 @@ vi.mock('@/store', async () => { }) vi.mock('@/lib/worktree-runtime-owner', () => ({ - getRuntimeEnvironmentIdForWorktree: (state: MockAppState) => state.runtimeEnvironmentId, - getExecutionHostIdForWorktree: (state: MockAppState) => state.executionHostId + getRuntimeEnvironmentIdForWorktree: (state: MockAppState) => state.runtimeEnvironmentId })) vi.mock('@/runtime/runtime-rpc-client', () => ({ @@ -53,11 +51,14 @@ vi.mock('./NativeChatView', async () => { return { default: function MockNativeChatView({ tabId, + groupId, isVisible }: { tabId: string + groupId?: string isVisible: boolean }) { + mocks.groupIdByTabId.set(tabId, groupId) useEffect(() => { mocks.mountsByTabId.set(tabId, (mocks.mountsByTabId.get(tabId) ?? 0) + 1) return () => { @@ -87,6 +88,7 @@ describe('StructuredAgentSessionPaneOverlayLayer', () => { mocks.focusGroup.mockClear() mocks.mountsByTabId.clear() mocks.unmountsByTabId.clear() + mocks.groupIdByTabId.clear() mocks.store?.setState(createState(FIRST_TAB_ID)) }) @@ -125,6 +127,12 @@ describe('StructuredAgentSessionPaneOverlayLayer', () => { expect(mocks.mountsByTabId.get(FIRST_TAB_ID)).toBe(1) expect(mocks.mountsByTabId.get(SECOND_TAB_ID)).toBe(1) expect(mocks.unmountsByTabId.size).toBe(0) + expect(mocks.groupIdByTabId).toEqual( + new Map([ + [FIRST_TAB_ID, GROUP_ID], + [SECOND_TAB_ID, GROUP_ID] + ]) + ) }) it('routes overlay interaction back to the owning split group', () => { @@ -166,7 +174,6 @@ function createState(activeTabId: string): MockAppState { }, groupsByWorktree: { [WORKTREE_ID]: [createGroup(activeTabId)] }, runtimeEnvironmentId: null, - executionHostId: 'local', focusGroup: mocks.focusGroup } } diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.tsx index 555d23b9ec4..01b8c256277 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.tsx @@ -3,10 +3,7 @@ import { useShallow } from 'zustand/react/shallow' import type { Tab, TabGroup } from '../../../../shared/tab-types' import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' import { useAppStore } from '@/store' -import { - getExecutionHostIdForWorktree, - getRuntimeEnvironmentIdForWorktree -} from '@/lib/worktree-runtime-owner' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { getActiveRuntimeTarget, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { tabGroupBodyAnchorName } from '../tab-group/tab-group-body-anchor' import NativeChatView from './NativeChatView' @@ -24,14 +21,12 @@ const StructuredAgentSessionOverlaySlot = memo(function StructuredAgentSessionOv groupId, isActive, target, - allowFileUriLinks, onFocusOwningGroup }: { tab: StructuredAgentSessionTab groupId: string | undefined isActive: boolean target: RuntimeClientTarget - allowFileUriLinks: boolean onFocusOwningGroup: ((groupId: string) => void) | undefined }): React.JSX.Element { const anchorName = groupId !== undefined ? tabGroupBodyAnchorName(groupId) : undefined @@ -69,11 +64,11 @@ const StructuredAgentSessionOverlaySlot = memo(function StructuredAgentSessionOv ) @@ -87,12 +82,11 @@ const StructuredAgentSessionPaneOverlayLayer = memo( worktreeId: string isWorktreeActive: boolean }): React.JSX.Element { - const { unifiedTabs, groups, runtimeEnvironmentId, allowFileUriLinks } = useAppStore( + const { unifiedTabs, groups, runtimeEnvironmentId } = useAppStore( useShallow((state) => ({ unifiedTabs: state.unifiedTabsByWorktree[worktreeId] ?? EMPTY_UNIFIED_TABS, groups: state.groupsByWorktree[worktreeId] ?? EMPTY_GROUPS, - runtimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(state, worktreeId), - allowFileUriLinks: getExecutionHostIdForWorktree(state, worktreeId) === 'local' + runtimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(state, worktreeId) })) ) const focusGroup = useAppStore((state) => state.focusGroup) @@ -127,7 +121,6 @@ const StructuredAgentSessionPaneOverlayLayer = memo( groupId={tab.groupId} isActive={Boolean(isWorktreeActive && groupActiveTabById.get(tab.groupId) === tab.id)} target={target} - allowFileUriLinks={allowFileUriLinks} onFocusOwningGroup={focusOwningGroup} /> ))} diff --git a/src/renderer/src/components/native-chat/native-chat-layout-actions.test.ts b/src/renderer/src/components/native-chat/native-chat-layout-actions.test.ts new file mode 100644 index 00000000000..6f3a3868bdf --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-layout-actions.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import type { AppState } from '@/store/types' +import { + canRunNativeChatSplitTarget, + resolveActiveNativeChatSplitTarget +} from './native-chat-layout-actions' + +function stateWithActiveTab(tab: Record, tabOrder = ['chat', 'other']) { + return { + groupsByWorktree: { + workspace: [{ id: 'group', worktreeId: 'workspace', activeTabId: 'chat', tabOrder }] + }, + unifiedTabsByWorktree: { + workspace: [ + { + id: 'chat', + entityId: 'session', + groupId: 'group', + worktreeId: 'workspace', + ...tab + } + ] + } + } as unknown as Pick +} + +describe('native chat layout actions', () => { + it('resolves structured chats to the reusable workspace-tab move path', () => { + const state = stateWithActiveTab({ contentType: 'agent-session' }) + const target = resolveActiveNativeChatSplitTarget(state, 'workspace', 'group') + + expect(target).toEqual({ kind: 'workspace-tab', unifiedTabId: 'chat', groupId: 'group' }) + expect(canRunNativeChatSplitTarget(state, target)).toBe(true) + expect( + canRunNativeChatSplitTarget( + stateWithActiveTab({ contentType: 'agent-session' }, ['chat']), + target + ) + ).toBe(false) + }) + + it('resolves terminal-backed chat mode to the existing pane split path', () => { + const state = stateWithActiveTab({ contentType: 'terminal', viewMode: 'chat' }) + + expect(resolveActiveNativeChatSplitTarget(state, 'workspace', 'group')).toEqual({ + kind: 'terminal-pane', + terminalTabId: 'session' + }) + expect( + resolveActiveNativeChatSplitTarget( + stateWithActiveTab({ contentType: 'terminal', viewMode: 'terminal' }), + 'workspace', + 'group' + ) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-layout-actions.ts b/src/renderer/src/components/native-chat/native-chat-layout-actions.ts new file mode 100644 index 00000000000..4f24a8a663c --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-layout-actions.ts @@ -0,0 +1,74 @@ +import type { AppState } from '@/store/types' +import { useAppStore } from '@/store' +import { + canMoveTabToNewPaneColumnFromState, + moveTabToNewPaneColumn +} from '@/components/tab-bar/tab-move-to-pane-column' +import { requestActiveTerminalPaneSplit } from '@/components/tab-bar/request-active-terminal-pane-split' +import type { NativeChatSplitDirection } from './native-chat-split-shortcut' + +export type NativeChatSplitTarget = + | { kind: 'terminal-pane'; terminalTabId: string } + | { kind: 'workspace-tab'; unifiedTabId: string; groupId: string } + +export function resolveActiveNativeChatSplitTarget( + state: Pick, + worktreeId: string | null, + groupId: string | null +): NativeChatSplitTarget | null { + if (!worktreeId || !groupId) { + return null + } + const group = (state.groupsByWorktree?.[worktreeId] ?? []).find((entry) => entry.id === groupId) + const tab = (state.unifiedTabsByWorktree?.[worktreeId] ?? []).find( + (entry) => entry.id === group?.activeTabId && entry.groupId === groupId + ) + if (tab?.contentType === 'agent-session') { + return { kind: 'workspace-tab', unifiedTabId: tab.id, groupId } + } + if (tab?.contentType === 'terminal' && tab.viewMode === 'chat') { + return { kind: 'terminal-pane', terminalTabId: tab.entityId } + } + return null +} + +export function canRunNativeChatSplitTarget( + state: Pick, + target: NativeChatSplitTarget | null +): boolean { + if (!target) { + return false + } + return ( + target.kind === 'terminal-pane' || + canMoveTabToNewPaneColumnFromState(state, target.unifiedTabId, target.groupId) + ) +} + +export function runNativeChatSplitTarget( + target: NativeChatSplitTarget, + direction: NativeChatSplitDirection +): boolean { + if (target.kind === 'terminal-pane') { + requestActiveTerminalPaneSplit({ + tabId: target.terminalTabId, + direction: direction === 'right' ? 'vertical' : 'horizontal' + }) + return true + } + return moveTabToNewPaneColumn({ + unifiedTabId: target.unifiedTabId, + groupId: target.groupId, + direction + }) +} + +export function runActiveNativeChatSplit( + worktreeId: string | null, + groupId: string | null, + direction: NativeChatSplitDirection +): boolean { + const state = useAppStore.getState() + const target = resolveActiveNativeChatSplitTarget(state, worktreeId, groupId) + return target ? runNativeChatSplitTarget(target, direction) : false +} diff --git a/src/renderer/src/components/native-chat/native-chat-split-shortcut.test.ts b/src/renderer/src/components/native-chat/native-chat-split-shortcut.test.ts new file mode 100644 index 00000000000..0a5b2bd6688 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-split-shortcut.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { matchNativeChatSplitShortcut } from './native-chat-split-shortcut' + +describe('matchNativeChatSplitShortcut', () => { + it('uses the existing platform split bindings', () => { + expect( + matchNativeChatSplitShortcut( + { key: 'd', metaKey: true, ctrlKey: false, altKey: false, shiftKey: false }, + 'darwin', + {} + ) + ).toBe('right') + expect( + matchNativeChatSplitShortcut( + { key: 'd', metaKey: false, ctrlKey: true, altKey: false, shiftKey: true }, + 'win32', + {} + ) + ).toBe('right') + expect( + matchNativeChatSplitShortcut( + { key: 'd', metaKey: false, ctrlKey: false, altKey: true, shiftKey: true }, + 'linux', + {} + ) + ).toBe('down') + }) + + it('respects customized bindings', () => { + expect( + matchNativeChatSplitShortcut( + { key: 'ArrowRight', metaKey: false, ctrlKey: true, altKey: true, shiftKey: false }, + 'linux', + { 'terminal.splitRight': ['Ctrl+Alt+Right'] } + ) + ).toBe('right') + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-split-shortcut.ts b/src/renderer/src/components/native-chat/native-chat-split-shortcut.ts new file mode 100644 index 00000000000..5ded10fa6bb --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-split-shortcut.ts @@ -0,0 +1,21 @@ +import { + keybindingMatchesAction, + type KeybindingInput, + type KeybindingOverrides +} from '../../../../shared/keybindings' + +export type NativeChatSplitDirection = 'right' | 'down' + +export function matchNativeChatSplitShortcut( + input: KeybindingInput, + platform: NodeJS.Platform, + keybindings: KeybindingOverrides +): NativeChatSplitDirection | null { + if (keybindingMatchesAction('terminal.splitRight', input, platform, keybindings)) { + return 'right' + } + if (keybindingMatchesAction('terminal.splitDown', input, platform, keybindings)) { + return 'down' + } + return null +} diff --git a/src/renderer/src/components/native-chat/native-chat-view-types.ts b/src/renderer/src/components/native-chat/native-chat-view-types.ts index 106b29b429d..920bede7028 100644 --- a/src/renderer/src/components/native-chat/native-chat-view-types.ts +++ b/src/renderer/src/components/native-chat/native-chat-view-types.ts @@ -37,11 +37,12 @@ export type NativeChatBridgeViewProps = NativeChatOrchestrationProps & { export type NativeChatStructuredViewProps = NativeChatOrchestrationProps & { mode: 'structured' tabId: string + groupId?: string sessionId: string target: RuntimeClientTarget agent: AgentType isVisible: boolean - allowFileUriLinks: boolean + contextMenuActions?: Omit } export type NativeChatResolvedViewProps = NativeChatOrchestrationProps & { diff --git a/src/renderer/src/components/native-chat/use-native-chat-context-menu.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-context-menu.test.tsx index f4e8b3efc72..14841e6c56d 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-context-menu.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-context-menu.test.tsx @@ -3,7 +3,8 @@ */ import React, { createRef, type ReactNode } from 'react' import { renderToStaticMarkup } from 'react-dom/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { emptyNativeChatContextMenuActions, useNativeChatContextMenu, @@ -52,6 +53,10 @@ vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('@/components/tab-bar/TabWorkspaceLayoutMenuSection', () => ({ + TabWorkspaceLayoutMenuSection: () => 'Move Tab to Split' +})) + function childrenText(children: ReactNode): string { return React.Children.toArray(children) .map((child) => { @@ -65,11 +70,22 @@ function childrenText(children: ReactNode): string { .join('') } -function Harness({ onSwitchToTerminal }: { onSwitchToTerminal?: () => void }) { +function Harness({ + onSwitchToTerminal, + structured = false, + enabled = true +}: { + onSwitchToTerminal?: () => void + structured?: boolean + enabled?: boolean +}) { const rootRef = createRef() const { menu } = useNativeChatContextMenu({ rootRef, + enabled, onSwitchToTerminal, + showTerminalPaneActions: !structured, + workspaceLayout: structured ? { unifiedTabId: 'chat-tab', groupId: 'group-1' } : undefined, actions: { ...emptyNativeChatContextMenuActions, onPaste: vi.fn() @@ -83,6 +99,11 @@ describe('useNativeChatContextMenu', () => { items.list = [] }) + afterEach(() => { + cleanup() + vi.restoreAllMocks() + }) + it('restores the bridge switch-to-terminal action when supplied', () => { const onSwitchToTerminal = vi.fn() @@ -107,4 +128,31 @@ describe('useNativeChatContextMenu', () => { items.list.some((candidate) => childrenText(candidate.children) === 'Switch to terminal view') ).toBe(false) }) + + it('reuses workspace layout actions without terminal-only pane commands', () => { + const markup = renderToStaticMarkup() + + expect(markup).toContain('Move Tab to Split') + expect(markup).not.toContain('Split Terminal Right') + expect(markup).not.toContain('Fork Agent Session') + }) + + it('subscribes to selection changes only while its retained chat is visible', () => { + const getSelection = vi.spyOn(window, 'getSelection').mockReturnValue(null) + const view = render() + + getSelection.mockClear() + document.dispatchEvent(new Event('selectionchange')) + expect(getSelection).not.toHaveBeenCalled() + + view.rerender() + getSelection.mockClear() + document.dispatchEvent(new Event('selectionchange')) + expect(getSelection).toHaveBeenCalledOnce() + + view.rerender() + getSelection.mockClear() + document.dispatchEvent(new Event('selectionchange')) + expect(getSelection).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx b/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx index a2d7dae4c93..48d896d4aea 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx @@ -30,6 +30,8 @@ import { } from '@/components/ui/dropdown-menu' import { translate } from '@/i18n/i18n' import { isMacPlatform, nativeChatToggleShortcutLabel } from './native-chat-shortcut' +import { TabWorkspaceLayoutMenuSection } from '@/components/tab-bar/TabWorkspaceLayoutMenuSection' +import type { TabSplitDirection } from '@/store/slices/tabs' type NativeChatContextMenuState = { open: boolean @@ -39,9 +41,17 @@ type NativeChatContextMenuState = { type UseNativeChatContextMenuArgs = { rootRef: RefObject - /** Bridge-only escape hatch; structured sessions never mount this menu. */ + enabled?: boolean + /** Bridge-only escape hatch; structured sessions have no terminal view. */ onSwitchToTerminal?: () => void actions: NativeChatContextMenuActions + showTerminalPaneActions?: boolean + splitShortcutLabels?: { right: string; down: string } + workspaceLayout?: { + unifiedTabId: string + groupId: string + shortcutLabels?: Partial> + } } export type NativeChatContextMenuActions = { @@ -88,8 +98,12 @@ export const emptyNativeChatContextMenuActions: Omit onSelectionCapture: () => void @@ -112,9 +126,18 @@ export function useNativeChatContextMenu({ }, [rootRef]) useEffect(() => { + if (!enabled) { + return + } document.addEventListener('selectionchange', rememberCurrentSelection) return () => document.removeEventListener('selectionchange', rememberCurrentSelection) - }, [rememberCurrentSelection]) + }, [enabled, rememberCurrentSelection]) + + useEffect(() => { + if (!enabled) { + setState((current) => (current.open ? { ...current, open: false } : current)) + } + }, [enabled]) const onContextMenuCapture = useCallback( (event: React.MouseEvent) => { @@ -142,7 +165,7 @@ export function useNativeChatContextMenu({ onContextMenuCapture, onSelectionCapture: rememberCurrentSelection, menu: ( - + {translate('auto.components.terminal.pane.TerminalContextMenu.0a917b591a', 'Paste')} - {onSwitchToTerminal ? ( - - - {translate( - 'components.tab.bar.SortableTabContextMenu.switchToTerminalView', - 'Switch to terminal view' - )} - {shortcutLabel} - - ) : null} - {actions.canContinueAgentSessionInNewSession ? ( - - - {translate( - 'components.agentSessionContinuation.continueInNewSession', - 'Continue in New Session…' - )} - - ) : null} - - - {translate( - 'auto.components.terminal.pane.TerminalContextMenu.8a7ddb8b8a', - 'Fork Agent Session…' - )} - - - - - {translate( - 'auto.components.terminal.pane.TerminalContextMenu.20e565d865', - 'Split Terminal Right' - )} - - - - {translate( - 'auto.components.terminal.pane.TerminalContextMenu.98bccf4fa2', - 'Split Terminal Down' - )} - - {actions.canEqualizePaneSizes ? ( - - - {translate( - 'auto.components.terminal.pane.TerminalContextMenu.06c2b0f043', - 'Equalize Pane Sizes' - )} - - ) : null} - {actions.canExpandPane ? ( - - {actions.isPaneExpanded ? : } - {actions.isPaneExpanded - ? translate( - 'auto.components.terminal.pane.TerminalContextMenu.df766809e0', - 'Collapse Pane' - ) - : translate( - 'auto.components.terminal.pane.TerminalContextMenu.925f49f210', - 'Expand Pane' - )} - - ) : null} - - - - {translate( - 'auto.components.terminal.pane.TerminalContextMenu.39809d152f', - 'Set Title…' - )} - - {actions.canCopyAgentSessionId ? ( - - - {translate( - 'components.terminalPane.TerminalContextMenu.copySessionId', - 'Copy Session ID' - )} - - ) : null} - - - {translate( - 'auto.components.terminal.pane.TerminalContextMenu.copyTerminalId', - 'Copy Terminal ID' - )} - - - - {translate( - 'auto.components.terminal.pane.TerminalContextMenu.2cf85a6a55', - 'Copy Pane ID' - )} - - {actions.canClosePane ? ( + {showTerminalPaneActions ? ( <> - - - + {onSwitchToTerminal ? ( + + + {translate( + 'components.tab.bar.SortableTabContextMenu.switchToTerminalView', + 'Switch to terminal view' + )} + {shortcutLabel} + + ) : null} + {actions.canContinueAgentSessionInNewSession ? ( + + + {translate( + 'components.agentSessionContinuation.continueInNewSession', + 'Continue in New Session…' + )} + + ) : null} + + {translate( - 'auto.components.terminal.pane.TerminalContextMenu.8c17d6786d', - 'Close Pane' + 'auto.components.terminal.pane.TerminalContextMenu.8a7ddb8b8a', + 'Fork Agent Session…' )} ) : null} + {workspaceLayout ? ( + + ) : null} + {showTerminalPaneActions ? ( + <> + + + + {translate( + 'auto.components.terminal.pane.TerminalContextMenu.20e565d865', + 'Split Terminal Right' + )} + {splitShortcutLabels ? ( + {splitShortcutLabels.right} + ) : null} + + + + {translate( + 'auto.components.terminal.pane.TerminalContextMenu.98bccf4fa2', + 'Split Terminal Down' + )} + {splitShortcutLabels ? ( + {splitShortcutLabels.down} + ) : null} + + {actions.canEqualizePaneSizes ? ( + + + {translate( + 'auto.components.terminal.pane.TerminalContextMenu.06c2b0f043', + 'Equalize Pane Sizes' + )} + + ) : null} + {actions.canExpandPane ? ( + + {actions.isPaneExpanded ? : } + {actions.isPaneExpanded + ? translate( + 'auto.components.terminal.pane.TerminalContextMenu.df766809e0', + 'Collapse Pane' + ) + : translate( + 'auto.components.terminal.pane.TerminalContextMenu.925f49f210', + 'Expand Pane' + )} + + ) : null} + + + + {translate( + 'auto.components.terminal.pane.TerminalContextMenu.39809d152f', + 'Set Title…' + )} + + {actions.canCopyAgentSessionId ? ( + + + {translate( + 'components.terminalPane.TerminalContextMenu.copySessionId', + 'Copy Session ID' + )} + + ) : null} + + + {translate( + 'auto.components.terminal.pane.TerminalContextMenu.copyTerminalId', + 'Copy Terminal ID' + )} + + + + {translate( + 'auto.components.terminal.pane.TerminalContextMenu.2cf85a6a55', + 'Copy Pane ID' + )} + + {actions.canClosePane ? ( + <> + + + + {translate( + 'auto.components.terminal.pane.TerminalContextMenu.8c17d6786d', + 'Close Pane' + )} + + + ) : null} + + ) : null} ) diff --git a/src/renderer/src/components/native-chat/use-structured-native-chat-pane-commands.ts b/src/renderer/src/components/native-chat/use-structured-native-chat-pane-commands.ts new file mode 100644 index 00000000000..d479c6ac03a --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-native-chat-pane-commands.ts @@ -0,0 +1,90 @@ +import { useCallback, type KeyboardEventHandler, type RefObject } from 'react' +import { useAppStore } from '@/store' +import { formatShortcutLabel } from '@/hooks/useShortcutLabel' +import { getShortcutPlatform } from '@/lib/shortcut-platform' +import type { NativeChatComposerHandle } from './NativeChatComposer' +import { useNativeChatPasteBridge } from './use-native-chat-paste-bridge' +import { + emptyNativeChatContextMenuActions, + useNativeChatContextMenu, + type NativeChatContextMenuActions +} from './use-native-chat-context-menu' +import { matchNativeChatSplitShortcut } from './native-chat-split-shortcut' +import { runNativeChatSplitTarget } from './native-chat-layout-actions' + +export function useStructuredNativeChatPaneCommands({ + tabId, + groupId, + isVisible, + rootRef, + composerRef, + terminalPaneActions +}: { + tabId: string + groupId?: string + isVisible: boolean + rootRef: RefObject + composerRef: RefObject + terminalPaneActions?: Omit +}) { + const keybindings = useAppStore((state) => state.keybindings) + const pasteClipboardIntoComposer = useNativeChatPasteBridge({ rootRef, composerRef }) + const contextMenu = useNativeChatContextMenu({ + rootRef, + actions: { + ...emptyNativeChatContextMenuActions, + ...terminalPaneActions, + onPaste: pasteClipboardIntoComposer + }, + enabled: isVisible, + showTerminalPaneActions: terminalPaneActions !== undefined, + splitShortcutLabels: { + right: formatShortcutLabel('terminal.splitRight', keybindings), + down: formatShortcutLabel('terminal.splitDown', keybindings) + }, + workspaceLayout: + groupId && terminalPaneActions === undefined + ? { + unifiedTabId: tabId, + groupId, + shortcutLabels: { + right: formatShortcutLabel('terminal.splitRight', keybindings), + down: formatShortcutLabel('terminal.splitDown', keybindings) + } + } + : undefined + }) + const onKeyDownCapture = useCallback>( + (event) => { + if (event.repeat) { + return + } + const direction = matchNativeChatSplitShortcut(event, getShortcutPlatform(), keybindings) + if (!direction) { + return + } + let handled = false + if (terminalPaneActions) { + if (direction === 'right') { + terminalPaneActions.onSplitRight() + } else { + terminalPaneActions.onSplitDown() + } + handled = true + } else if (groupId) { + handled = runNativeChatSplitTarget( + { kind: 'workspace-tab', unifiedTabId: tabId, groupId }, + direction + ) + } + if (!handled) { + return + } + event.preventDefault() + event.stopPropagation() + }, + [groupId, keybindings, tabId, terminalPaneActions] + ) + + return { ...contextMenu, onKeyDownCapture } +} diff --git a/src/renderer/src/components/new-workspace/ProjectCombobox.dialog-handoff.test.tsx b/src/renderer/src/components/new-workspace/ProjectCombobox.dialog-handoff.test.tsx new file mode 100644 index 00000000000..e1f3a50f777 --- /dev/null +++ b/src/renderer/src/components/new-workspace/ProjectCombobox.dialog-handoff.test.tsx @@ -0,0 +1,152 @@ +// @vitest-environment happy-dom + +import React, { act, useRef, useState } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' +import ProjectCombobox from './ProjectCombobox' + +vi.mock('./use-recent-project-ids', () => ({ useRecentProjectIds: () => [] })) + +function Composer({ populated }: { populated: boolean }): React.JSX.Element { + const [adding, setAdding] = useState(false) + const nameRef = useRef(null) + return ( + + + New workspace + setAdding(true)} + /> + + + { + event.preventDefault() + nameRef.current?.focus() + }} + > + Add a project + + + + + + + ) +} + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + // Radix Presence retains closing content when the production exit animation runs. + const getStyle = window.getComputedStyle.bind(window) + vi.spyOn(window, 'getComputedStyle').mockImplementation((element, ...args) => { + const style = getStyle(element, ...args) + if (element.getAttribute('data-slot') === 'popover-content') { + return new Proxy(style, { + get: (target, property) => + property === 'animationName' + ? element.getAttribute('data-state') === 'closed' + ? 'exit' + : 'enter' + : Reflect.get(target, property) + }) + } + return style + }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + vi.restoreAllMocks() +}) + +function projectField(): HTMLInputElement { + return document.querySelector('[role="combobox"][aria-label="Project"]')! +} + +function addOption(): HTMLElement { + return Array.from(document.querySelectorAll('[role="option"]')).find((option) => + option.textContent?.includes('Add a new project') + )! +} + +describe.each([false, true])( + 'project selector to nested dialog handoff (populated: %s)', + (populated) => { + it.each(['mouse', 'keyboard'] as const)( + 'removes the selector before the creation dialog is active via %s', + async (input) => { + await act(async () => root.render()) + await act(async () => projectField().click()) + expect(document.querySelector('[role="listbox"]')).not.toBeNull() + + await act(async () => { + if (input === 'mouse') { + addOption().dispatchEvent( + new MouseEvent('mousedown', { bubbles: true, cancelable: true }) + ) + addOption().click() + } else if (populated) { + projectField().dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }) + ) + } + }) + if (input === 'keyboard') { + await act(async () => { + projectField().dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }) + ) + }) + } + + expect(document.querySelector('[role="listbox"]')).toBeNull() + expect(document.querySelector('[data-slot="popover-content"]')).toBeNull() + expect(projectField().getAttribute('aria-expanded')).toBe('false') + expect(projectField().hasAttribute('aria-activedescendant')).toBe(false) + const path = document.querySelector('[aria-label="Project path"]')! + expect(document.activeElement).toBe(path) + expect(path.closest('[aria-hidden="true"]')).toBeNull() + expect(projectField().closest('[aria-hidden="true"]')).not.toBeNull() + + await act(async () => { + Array.from(document.querySelectorAll('button')) + .find((b) => b.textContent === 'Cancel')! + .click() + }) + await vi.waitFor(() => + expect(document.activeElement).toBe( + document.querySelector('[aria-label="Workspace name"]') + ) + ) + await act(async () => projectField().click()) + expect(projectField().getAttribute('aria-expanded')).toBe('true') + expect(document.querySelector('[role="listbox"]')).not.toBeNull() + } + ) + } +) diff --git a/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx b/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx index 3b7931238b3..aa6b8df7b2d 100644 --- a/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx +++ b/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx @@ -72,7 +72,7 @@ function field(): HTMLInputElement { function openList(): void { act(() => { - field().dispatchEvent(new FocusEvent('focus', { bubbles: true })) + field().focus() }) } diff --git a/src/renderer/src/components/new-workspace/ProjectCombobox.tsx b/src/renderer/src/components/new-workspace/ProjectCombobox.tsx index c305964ca0e..8561465d57e 100644 --- a/src/renderer/src/components/new-workspace/ProjectCombobox.tsx +++ b/src/renderer/src/components/new-workspace/ProjectCombobox.tsx @@ -229,129 +229,132 @@ export default function ProjectCombobox({ - event.preventDefault()} - onCloseAutoFocus={(event) => event.preventDefault()} - // Why: the field lives in the anchor, not inside the content, so Radix - // sees a focus/pointer event "outside" the layer and dismisses it the - // instant you tab in. Keep the layer open whenever the interaction is - // within this control; genuine outside events still close it. - onFocusOutside={(event) => { - if (isWithinComboboxRoot(event.target, ROOT_ATTRIBUTE)) { - event.preventDefault() - } - }} - onInteractOutside={(event) => { - if (isWithinComboboxRoot(event.target, ROOT_ATTRIBUTE)) { - event.preventDefault() - } - }} - > - {/* The listbox wraps a scrolling pane plus the pinned Add row, so both - stay `option` children of one listbox. */} -
event.preventDefault()} + onCloseAutoFocus={(event) => event.preventDefault()} + // Why: the field lives in the anchor, not inside the content, so Radix + // sees a focus/pointer event "outside" the layer and dismisses it the + // instant you tab in. Keep the layer open whenever the interaction is + // within this control; genuine outside events still close it. + onFocusOutside={(event) => { + if (isWithinComboboxRoot(event.target, ROOT_ATTRIBUTE)) { + event.preventDefault() + } + }} + onInteractOutside={(event) => { + if (isWithinComboboxRoot(event.target, ROOT_ATTRIBUTE)) { + event.preventDefault() + } + }} > - {/* Why: `presentation` — this element exists to scroll, and an + {/* The listbox wraps a scrolling pane plus the pinned Add row, so both + stay `option` children of one listbox. */} +
+ {/* Why: `presentation` — this element exists to scroll, and an unroled div between a listbox and its options breaks the ownership relationship assistive tech relies on. */} -
- {matches.length === 0 ? ( - // Why: row-height rather than a tall centred block — a 60px panel - // next to 32px rows reads as a different kind of surface and - // makes an empty result feel like an error. -

- {options.length === 0 - ? translate( - 'auto.components.new.workspace.ProjectCombobox.noProjects', - 'No projects yet.' - ) - : translate( - 'auto.components.new.workspace.ProjectCombobox.empty', - 'No projects match your search.' - )} -

- ) : null} - {sections.map((section) => ( - // Why: `role="group"` — a bare div between a listbox and its - // options breaks the ownership relationship for screen readers, - // which is the only thing that makes the headings announceable. -
- {section.heading ? ( - - ) : null} - {section.items.map((scored) => ( - arm(scored.option.id)} - onCommit={() => commit(scored.option.id)} - /> - ))} -
- ))} -
- {onAddProject ? (
event.preventDefault()} - onMouseMove={() => arm(ADD_PROJECT_KEY)} - onClick={() => commit(ADD_PROJECT_KEY)} - className={cn( - 'flex h-9 shrink-0 cursor-default items-center gap-2 border-t border-border px-2 text-sm', - armedKey === ADD_PROJECT_KEY && 'bg-accent text-accent-foreground' - )} + ref={setListNode} + role="presentation" + className="max-h-72 min-h-0 flex-1 overflow-y-auto p-1 scrollbar-sleek" > - - - {translate( - 'auto.components.new.workspace.ProjectCombobox.addProject', - 'Add a new project' - )} - + {matches.length === 0 ? ( + // Why: row-height rather than a tall centred block — a 60px panel + // next to 32px rows reads as a different kind of surface and + // makes an empty result feel like an error. +

+ {options.length === 0 + ? translate( + 'auto.components.new.workspace.ProjectCombobox.noProjects', + 'No projects yet.' + ) + : translate( + 'auto.components.new.workspace.ProjectCombobox.empty', + 'No projects match your search.' + )} +

+ ) : null} + {sections.map((section) => ( + // Why: `role="group"` — a bare div between a listbox and its + // options breaks the ownership relationship for screen readers, + // which is the only thing that makes the headings announceable. +
+ {section.heading ? ( + + ) : null} + {section.items.map((scored) => ( + arm(scored.option.id)} + onCommit={() => commit(scored.option.id)} + /> + ))} +
+ ))}
- ) : null} -
- + {onAddProject ? ( +
event.preventDefault()} + onMouseMove={() => arm(ADD_PROJECT_KEY)} + onClick={() => commit(ADD_PROJECT_KEY)} + className={cn( + 'flex h-9 shrink-0 cursor-default items-center gap-2 border-t border-border px-2 text-sm', + armedKey === ADD_PROJECT_KEY && 'bg-accent text-accent-foreground' + )} + > + + + {translate( + 'auto.components.new.workspace.ProjectCombobox.addProject', + 'Add a new project' + )} + +
+ ) : null} +
+ + ) : null} ) } diff --git a/src/renderer/src/components/settings/MobileSettingsPane.tsx b/src/renderer/src/components/settings/MobileSettingsPane.tsx index 2e98a5ccb43..ff8de2b16e5 100644 --- a/src/renderer/src/components/settings/MobileSettingsPane.tsx +++ b/src/renderer/src/components/settings/MobileSettingsPane.tsx @@ -13,7 +13,7 @@ export { getMobileSettingsPaneSearchEntries } const ORCA_IOS_APP_STORE_URL = 'https://apps.apple.com/app/orca-ide/id6766130217' const ORCA_ANDROID_APK_URL = - 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk' + 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk' export function MobileSettingsPane(): React.JSX.Element { const showMobileButton = useAppStore((s) => s.settings?.showMobileButton !== false) diff --git a/src/renderer/src/components/sidebar/CommentMarkdown.link-click.test.tsx b/src/renderer/src/components/sidebar/CommentMarkdown.link-click.test.tsx index 575d4343c3b..a3f7c7aad1f 100644 --- a/src/renderer/src/components/sidebar/CommentMarkdown.link-click.test.tsx +++ b/src/renderer/src/components/sidebar/CommentMarkdown.link-click.test.tsx @@ -3,6 +3,10 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' +import { + NATIVE_CHAT_FILE_HREF_PREFIX, + routeNativeChatHref +} from '../../../../shared/native-chat-href-routing' import CommentMarkdown from './CommentMarkdown' describe('CommentMarkdown link click handler', () => { @@ -48,6 +52,72 @@ describe('CommentMarkdown link click handler', () => { expect(event.defaultPrevented).toBe(true) }) + it('intercepts auxiliary clicks on generated native file links', () => { + const onLinkClick = vi.fn((event: React.MouseEvent) => { + event.preventDefault() + }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + act(() => { + root?.render( + + ) + }) + + const anchor = container.querySelector('a') + const event = new window.MouseEvent('auxclick', { + bubbles: true, + cancelable: true, + button: 1 + }) + + act(() => { + anchor?.dispatchEvent(event) + }) + + expect(onLinkClick).toHaveBeenCalledWith(expect.any(Object), expect.stringMatching(/^#orca-/)) + expect(event.defaultPrevented).toBe(true) + }) + + it('does not activate generated native file links on right-click', () => { + const onLinkClick = vi.fn() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + act(() => { + root?.render( + + ) + }) + + const anchor = container.querySelector('a') + const event = new window.MouseEvent('auxclick', { + bubbles: true, + cancelable: true, + button: 2 + }) + + act(() => { + anchor?.dispatchEvent(event) + }) + + expect(onLinkClick).not.toHaveBeenCalled() + expect(event.defaultPrevented).toBe(false) + }) + it('sanitizes file URI links unless the caller opts in', () => { container = document.createElement('div') document.body.appendChild(container) @@ -146,4 +216,388 @@ describe('CommentMarkdown link click handler', () => { expect(onLinkClick).toHaveBeenCalledWith(expect.any(Object), 'assets/diagram.png') expect(event.defaultPrevented).toBe(true) }) + + it('linkifies bare POSIX and Windows document paths without an extension allowlist', () => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + act(() => { + root?.render( + + ) + }) + + const routes = Array.from(container.querySelectorAll('a')).map((anchor) => + routeNativeChatHref(anchor.getAttribute('href')) + ) + expect(routes).toEqual([ + { kind: 'file', pathText: '/tmp/sta-6481-explainer.html', line: null }, + { kind: 'file', pathText: 'docs/review.docx', line: null }, + { kind: 'file', pathText: String.raw`C:\Reports\final.pages`, line: null }, + { kind: 'file', pathText: './scripts/release', line: null }, + { kind: 'file', pathText: 'src/release:12', line: null } + ]) + }) + + it('makes an inline-code file path clickable while preserving code styling', () => { + const onLinkClick = vi.fn((event: React.MouseEvent) => event.preventDefault()) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + act(() => { + root?.render( + + ) + }) + + const code = container.querySelector('code') + const anchor = code?.closest('a') + expect(anchor).not.toBeNull() + expect(routeNativeChatHref(anchor?.getAttribute('href'))).toEqual({ + kind: 'file', + pathText: String.raw`C:\Reports\release.docx`, + line: null + }) + + act(() => { + anchor?.dispatchEvent(new window.MouseEvent('click', { bubbles: true, cancelable: true })) + }) + expect(onLinkClick).toHaveBeenCalledOnce() + }) + + it('leaves prose-shaped slash tokens and numeric versions unlinked', () => { + const proseFalsePositives = ['and/or', 'TCP/IP', '24/7', 'N/A', 'km/h', 'A/B test'] + const inlineCodeFalsePositives = ['origin/main', 'v1.2.3', '1.0'] + const quotedFalsePositives = ['"and/or"', '"A/B test"'] + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + act(() => { + root?.render( + `\`${value}\``).join(', ')}; ${quotedFalsePositives.join(', ')}`} + onLinkClick={vi.fn()} + linkifyFilePaths + /> + ) + }) + + expect(container.querySelectorAll('a')).toHaveLength(0) + for (const value of proseFalsePositives) { + expect(container.textContent).toContain(value) + } + for (const value of quotedFalsePositives) { + expect(container.textContent).toContain(value) + } + expect(Array.from(container.querySelectorAll('code')).map((code) => code.textContent)).toEqual( + inlineCodeFalsePositives + ) + }) + + it('links each relative path separately when prose joins them', () => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + act(() => { + root?.render( + + ) + }) + + expect(Array.from(container.querySelectorAll('a')).map((anchor) => anchor.textContent)).toEqual( + ['src/foo.ts', 'src/bar.ts', 'docs/My Folder/notes.md'] + ) + }) + + it('links quoted spaced-first-segment paths around apostrophes', () => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + act(() => { + root?.render( + + ) + }) + + const anchors = container.querySelectorAll('a') + expect(Array.from(anchors).map((anchor) => anchor.textContent)).toEqual([ + "Brennan's Folder/notes.md", + 'My Folder/guide.md' + ]) + expect(container.textContent).toBe( + "Don't skip \"Brennan's Folder/notes.md\"; open 'My Folder/guide.md'." + ) + expect( + Array.from(anchors).map((anchor) => routeNativeChatHref(anchor.getAttribute('href'))) + ).toEqual([ + { kind: 'file', pathText: "Brennan's Folder/notes.md", line: null }, + { kind: 'file', pathText: 'My Folder/guide.md', line: null } + ]) + }) + + it('links a spaced-first-segment relative path when inline code disambiguates it', () => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + act(() => { + root?.render( + + ) + }) + + const anchor = container.querySelector('a') + expect(anchor?.textContent).toBe('My Folder/notes.md') + expect(routeNativeChatHref(anchor?.getAttribute('href'))).toEqual({ + kind: 'file', + pathText: 'My Folder/notes.md', + line: null + }) + }) + + it('requires path shape before a spaced line suffix can make a link', () => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + act(() => { + root?.render( + + ) + }) + + expect(container.querySelectorAll('a')).toHaveLength(0) + expect(container.querySelector('code')?.textContent).toBe('aspect 16:9') + expect(container.textContent).toContain('"John 3:16"') + }) + + it('preserves line suffixes on valid spaced path shapes', () => { + const content = + 'Open "My Folder/notes:12", `My Notes.md:7`, and "C:\\My Folder\\notes.txt:12:3".' + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + act(() => { + root?.render( + + ) + }) + + const anchors = Array.from(container.querySelectorAll('a')) + expect(anchors.map((anchor) => anchor.textContent)).toEqual([ + 'My Folder/notes:12', + 'My Notes.md:7', + String.raw`C:\My Folder\notes.txt:12:3` + ]) + expect(anchors.map((anchor) => routeNativeChatHref(anchor.getAttribute('href')))).toEqual([ + { kind: 'file', pathText: 'My Folder/notes:12', line: null }, + { kind: 'file', pathText: 'My Notes.md:7', line: null }, + { kind: 'file', pathText: String.raw`C:\My Folder\notes.txt:12:3`, line: null } + ]) + }) + + it('links complete Unicode paths and extensions that begin with a digit', () => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + act(() => { + root?.render( + + ) + }) + + expect(Array.from(container.querySelectorAll('a')).map((anchor) => anchor.textContent)).toEqual( + ['/tmp/报告.html', 'docs/报告/file.html', 'docs/café/report.pdf', 'docs/archive.7z'] + ) + }) + + it('never links an ASCII suffix inside a path containing an unsupported character', () => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + act(() => { + root?.render( + + ) + }) + + expect(container.querySelectorAll('a')).toHaveLength(0) + expect(container.textContent).toContain('/tmp/$draft/report.html') + }) + + it('links paths before common sentence punctuation', () => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + act(() => { + root?.render( + + ) + }) + + expect(Array.from(container.querySelectorAll('a')).map((anchor) => anchor.textContent)).toEqual( + ['src/foo.ts', 'docs/guide.md', 'assets/report.pdf'] + ) + }) + + it('links paths after CLI assignment and before Unicode sentence punctuation', () => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + act(() => { + root?.render( + + ) + }) + + expect(Array.from(container.querySelectorAll('a')).map((anchor) => anchor.textContent)).toEqual( + ['./config.yaml', 'docs/指南.md', 'docs/报告.pdf'] + ) + }) + + it('does not link partial paths across unsupported punctuation', () => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + act(() => { + root?.render( + + ) + }) + + expect(container.querySelectorAll('a')).toHaveLength(0) + }) + + it('prevents the default action for an unresolved internal file href', () => { + const onLinkClick = vi.fn() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + act(() => { + root?.render( + + ) + }) + + const anchor = container.querySelector('a') + expect(anchor?.getAttribute('href')).toMatch(new RegExp(`^${NATIVE_CHAT_FILE_HREF_PREFIX}`)) + const event = new window.MouseEvent('click', { bubbles: true, cancelable: true }) + + act(() => { + anchor?.dispatchEvent(event) + }) + + expect(onLinkClick).toHaveBeenCalledOnce() + expect(event.defaultPrevented).toBe(true) + }) + + it('normalizes Windows markdown hrefs but leaves fenced paths as source text', () => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + act(() => { + root?.render( + + ) + }) + + const anchors = container.querySelectorAll('a') + expect(anchors).toHaveLength(1) + expect(routeNativeChatHref(anchors[0]?.getAttribute('href'))).toEqual({ + kind: 'file', + pathText: String.raw`C:\Reports\summary.pdf`, + line: null + }) + expect(container.querySelector('pre')?.textContent).toContain('/tmp/not-a-link.html') + }) }) diff --git a/src/renderer/src/components/sidebar/CommentMarkdown.tsx b/src/renderer/src/components/sidebar/CommentMarkdown.tsx index 8673baedefa..0999c23c8c6 100644 --- a/src/renderer/src/components/sidebar/CommentMarkdown.tsx +++ b/src/renderer/src/components/sidebar/CommentMarkdown.tsx @@ -13,6 +13,7 @@ import { isTrustedCompactImageSrc, type CommentMarkdownLinkClickHandler } from './comment-markdown-element-renderers' +import { remarkNativeChatFileLinks } from './comment-markdown-native-chat-file-links' export type { CommentMarkdownLinkClickHandler } from './comment-markdown-element-renderers' @@ -185,6 +186,7 @@ type CommentMarkdownProps = React.ComponentPropsWithoutRef<'div'> & { githubRepo?: GitHubRepoReference | null onLinkClick?: CommentMarkdownLinkClickHandler allowFileUriLinks?: boolean + linkifyFilePaths?: boolean expandImages?: boolean } @@ -200,6 +202,7 @@ const CommentMarkdown = React.memo( githubRepo, onLinkClick, allowFileUriLinks = false, + linkifyFilePaths = false, expandImages = false, ...rest }, @@ -217,10 +220,12 @@ const CommentMarkdown = React.memo( ? createDocumentCommentMarkdownComponents(onLinkClick) : createCompactCommentMarkdownComponents(onLinkClick, expandImages) }, [expandImages, variant, onLinkClick]) - const activeRemarkPlugins = React.useMemo( - () => (githubRepo ? [...remarkPlugins, remarkGitHubReferences(githubRepo)] : remarkPlugins), - [githubRepo] - ) + const activeRemarkPlugins = React.useMemo(() => { + const plugins = linkifyFilePaths + ? [...remarkPlugins, remarkNativeChatFileLinks] + : remarkPlugins + return githubRepo ? [...plugins, remarkGitHubReferences(githubRepo)] : plugins + }, [githubRepo, linkifyFilePaths]) return (
, + href: string | undefined, + onLinkClick: CommentMarkdownLinkClickHandler | undefined +): void { + if (event.button === 1) { + handleMarkdownAnchorClick(event, href, onLinkClick) + } +} + function handleMarkdownImageClick( event: React.MouseEvent, src: string | undefined, @@ -65,6 +80,7 @@ export function createCompactCommentMarkdownComponents( rel="noreferrer" className="underline underline-offset-2 text-foreground/80 hover:text-foreground" onClick={(e) => handleMarkdownAnchorClick(e, href, onLinkClick)} + onAuxClick={(e) => handleMarkdownAnchorAuxClick(e, href, onLinkClick)} > {children} @@ -154,6 +170,7 @@ export function createCompactCommentMarkdownComponents( rel="noreferrer" className="underline underline-offset-2 text-foreground/80 hover:text-foreground" onClick={(e) => handleMarkdownAnchorClick(e, src, onLinkClick)} + onAuxClick={(e) => handleMarkdownAnchorAuxClick(e, src, onLinkClick)} > {alt || src} @@ -184,6 +201,7 @@ export function createCompactCommentMarkdownComponents( target="_blank" rel="noreferrer" onClick={(e) => handleMarkdownAnchorClick(e, src, onLinkClick)} + onAuxClick={(e) => handleMarkdownAnchorAuxClick(e, src, onLinkClick)} > {image} @@ -221,6 +239,7 @@ export function createDocumentCommentMarkdownComponents( rel="noreferrer" className="break-all text-primary underline underline-offset-2 hover:text-primary/80" onClick={(e) => handleMarkdownAnchorClick(e, href, onLinkClick)} + onAuxClick={(e) => handleMarkdownAnchorAuxClick(e, href, onLinkClick)} > {children} diff --git a/src/renderer/src/components/sidebar/comment-markdown-native-chat-file-links.ts b/src/renderer/src/components/sidebar/comment-markdown-native-chat-file-links.ts new file mode 100644 index 00000000000..ee81b859dcc --- /dev/null +++ b/src/renderer/src/components/sidebar/comment-markdown-native-chat-file-links.ts @@ -0,0 +1,243 @@ +import { + createNativeChatFileHref, + routeNativeChatHref +} from '../../../../shared/native-chat-href-routing' +import { parseFileLinkLocation } from '../../../../shared/file-link-location' +import { extractTerminalFileLinks, type ParsedTerminalFileLink } from '@/lib/terminal-links' + +type MarkdownNode = { + type: string + value?: string + url?: string + children?: MarkdownNode[] +} + +const ROOTED_PATH_PREFIX_PATTERN = /^(?:~[\\/]|\.{1,2}[\\/]|[\\/]|[A-Za-z]:[\\/])/ + +function isLinkifiableFile(link: ParsedTerminalFileLink, requireSeparator: boolean): boolean { + const hasRootedPrefix = ROOTED_PATH_PREFIX_PATTERN.test(link.pathText) + const hasLineSuffix = link.line !== null || link.column !== null + const hasAlphabeticExtension = /\.[\p{L}][\p{L}\p{N}\p{M}_+-]*$/u.test(link.pathText) + const hasPathExtension = /\.[\p{L}\p{N}][\p{L}\p{N}\p{M}_+-]*$/u.test(link.pathText) + return ( + (!requireSeparator || /[\\/]/.test(link.pathText)) && + (hasRootedPrefix || + hasLineSuffix || + (requireSeparator ? hasPathExtension : hasAlphabeticExtension)) && + routeNativeChatHref(link.displayText).kind === 'file' + ) +} + +const SAFE_LEADING_BOUNDARY_PATTERN = /[\s([{'",;=]/ +const SAFE_TRAILING_BOUNDARY_PATTERN = /[\s)\]}>'",;.:。!?,、;:]/ +const SENTENCE_PATH_PUNCTUATION_PATTERN = + /\.[\p{L}\p{N}][\p{L}\p{N}\p{M}_+-]*([!?—。!?,、;:])/gu +const QUOTED_TEXT_PATTERN = /"([^"\r\n]+)"|'([^"'\r\n]+)'/gu +const MAX_DASHED_PROSE_WORD_LENGTH = 32 + +function hasBoundedProseAfterDash(value: string, startIndex: number): boolean { + const endIndex = Math.min(value.length, startIndex + MAX_DASHED_PROSE_WORD_LENGTH) + for (let index = startIndex; index < endIndex; index += 1) { + const char = value[index] + if (!char || SAFE_TRAILING_BOUNDARY_PATTERN.test(char)) { + return true + } + if (char === '/' || char === '\\') { + return false + } + } + return endIndex === value.length +} + +function isSafeTrailingBoundary(value: string, endIndex: number): boolean { + const boundary = value[endIndex] + if (boundary === undefined || SAFE_TRAILING_BOUNDARY_PATTERN.test(boundary)) { + return true + } + if (boundary === '!' || boundary === '?') { + const next = value[endIndex + 1] + return next === undefined || SAFE_TRAILING_BOUNDARY_PATTERN.test(next) + } + if (boundary === '—') { + return hasBoundedProseAfterDash(value, endIndex + 1) + } + return false +} + +function hasPartialPathBoundary(value: string, link: ParsedTerminalFileLink): boolean { + const before = value[link.startIndex - 1] + return ( + (before !== undefined && !SAFE_LEADING_BOUNDARY_PATTERN.test(before)) || + !isSafeTrailingBoundary(value, link.endIndex) + ) +} + +function createFileLinkNode(value: string, child: MarkdownNode): MarkdownNode { + return { + type: 'link', + url: createNativeChatFileHref(value), + children: [child] + } +} + +// Why: the terminal extractor spans "src/a.ts and src/b.ts" as one spaced path. +// An unrooted span holding a bare word or several linkable tokens is prose +// joining paths, so link the tokens on their own; a spaced folder name keeps +// every token path-shaped and stays one link. +function splitProseJoinedLinks(link: ParsedTerminalFileLink): ParsedTerminalFileLink[] { + if (ROOTED_PATH_PREFIX_PATTERN.test(link.pathText)) { + return [link] + } + const tokens = Array.from(link.displayText.matchAll(/\S+/g)) + const tokenLinks: ParsedTerminalFileLink[] = [] + for (const match of tokens) { + const token = match[0] + const exactLink = extractTerminalFileLinks(token).find( + (candidate) => candidate.startIndex === 0 && candidate.endIndex === token.length + ) + if (exactLink && isLinkifiableFile(exactLink, true)) { + const startIndex = link.startIndex + (match.index ?? 0) + tokenLinks.push({ ...exactLink, startIndex, endIndex: startIndex + token.length }) + } + } + const hasBareWord = tokens.some((match) => !/[\\/.]/.test(match[0])) + return hasBareWord || tokenLinks.length > 1 ? tokenLinks : [link] +} + +function splitTextSegment(value: string): MarkdownNode[] { + const links = extractTerminalFileLinks(value) + .filter((link) => !hasPartialPathBoundary(value, link)) + .filter((link) => isLinkifiableFile(link, true)) + .flatMap(splitProseJoinedLinks) + if (links.length === 0) { + return [{ type: 'text', value }] + } + + const children: MarkdownNode[] = [] + let cursor = 0 + for (const link of links) { + if (link.startIndex < cursor) { + continue + } + if (link.startIndex > cursor) { + children.push({ type: 'text', value: value.slice(cursor, link.startIndex) }) + } + children.push(createFileLinkNode(link.displayText, { type: 'text', value: link.displayText })) + cursor = link.endIndex + } + if (cursor < value.length) { + children.push({ type: 'text', value: value.slice(cursor) }) + } + return children +} + +function splitUnquotedText(value: string): MarkdownNode[] { + const children: MarkdownNode[] = [] + let cursor = 0 + for (const match of value.matchAll(SENTENCE_PATH_PUNCTUATION_PATTERN)) { + const punctuationIndex = (match.index ?? 0) + match[0].length - 1 + if (!isSafeTrailingBoundary(value, punctuationIndex)) { + continue + } + children.push(...splitTextSegment(value.slice(cursor, punctuationIndex))) + children.push({ type: 'text', value: value[punctuationIndex] }) + cursor = punctuationIndex + 1 + } + if (cursor === 0) { + return splitTextSegment(value) + } + children.push(...splitTextSegment(value.slice(cursor))) + return children +} + +function exactFileLink(value: string, allowSpacedRelative: boolean): ParsedTerminalFileLink | null { + const exactLink = extractTerminalFileLinks(value).find( + (link) => link.startIndex === 0 && link.endIndex === value.length + ) + if (exactLink && isLinkifiableFile(exactLink, false)) { + return exactLink + } + if (!allowSpacedRelative || !/\s/.test(value)) { + return null + } + const parsed = parseFileLinkLocation(value) + if (!parsed) { + return null + } + const hasPathShape = + ROOTED_PATH_PREFIX_PATTERN.test(parsed.pathText) || + /[\\/]/.test(parsed.pathText) || + /\.[\p{L}][\p{L}\p{N}\p{M}_+-]*$/u.test(parsed.pathText) + if (!hasPathShape) { + return null + } + const explicitLink = { + ...parsed, + startIndex: 0, + endIndex: value.length, + displayText: value + } + return isLinkifiableFile(explicitLink, false) ? explicitLink : null +} + +function splitTextNode(value: string): MarkdownNode[] { + const children: MarkdownNode[] = [] + let cursor = 0 + for (const match of value.matchAll(QUOTED_TEXT_PATTERN)) { + const content = match[1] ?? match[2] + if (!content || !exactFileLink(content, true)) { + continue + } + const matchIndex = match.index ?? 0 + const quote = match[0][0] + children.push(...splitUnquotedText(value.slice(cursor, matchIndex))) + children.push({ type: 'text', value: quote }) + children.push(createFileLinkNode(content, { type: 'text', value: content })) + children.push({ type: 'text', value: quote }) + cursor = matchIndex + match[0].length + } + if (cursor === 0) { + return splitUnquotedText(value) + } + children.push(...splitUnquotedText(value.slice(cursor))) + return children +} + +function inlineCodeFileLink(node: MarkdownNode): MarkdownNode | null { + const value = node.value?.trim() + if (!value) { + return null + } + return exactFileLink(value, true) ? createFileLinkNode(value, node) : null +} + +function transformFileLinks(node: MarkdownNode): void { + if (node.type === 'link') { + if (node.url && routeNativeChatHref(node.url).kind === 'file') { + node.url = createNativeChatFileHref(node.url) + } + return + } + if (!node.children || node.type === 'image') { + return + } + + const children: MarkdownNode[] = [] + for (const child of node.children) { + if (child.type === 'text' && child.value !== undefined) { + children.push(...splitTextNode(child.value)) + continue + } + if (child.type === 'inlineCode') { + children.push(inlineCodeFileLink(child) ?? child) + continue + } + transformFileLinks(child) + children.push(child) + } + node.children = children +} + +export function remarkNativeChatFileLinks(): (tree: MarkdownNode) => void { + return (tree) => transformFileLinks(tree) +} diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index 8e793f96773..c138e36bd6e 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -58,6 +58,7 @@ type SortableTabProps = { isChatView?: boolean /** Toggle the tab between terminal and native chat view. */ onToggleViewMode?: () => void + canSplitTerminal?: boolean } export const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus' @@ -86,7 +87,8 @@ export default function SortableTab({ includeTopTabBorder = true, canToggleViewMode = false, isChatView = false, - onToggleViewMode + onToggleViewMode, + canSplitTerminal = true }: SortableTabProps): React.JSX.Element { // Why: agent-completion unread exists even with terminal-attention off; collapse both sources to one primitive so unrelated tabs don't re-render. const hasUnreadActivity = useAppStore((s) => @@ -399,6 +401,7 @@ export default function SortableTab({ canToggleViewMode={canToggleViewMode} isChatView={isChatView} onToggleViewMode={onToggleViewMode} + canSplitTerminal={canSplitTerminal} /> ) diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx index 9d3e506e3f1..c01b028ecb4 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx @@ -250,6 +250,13 @@ describe('SortableTabContextMenu', () => { }) }) + it('hides terminal-only split actions for structured chat tabs', () => { + const { container } = renderMenu({ canSplitTerminal: false }) + + expect(container.textContent).toContain('Move Tab to Split') + expect(container.textContent).not.toContain('Split terminal') + }) + it('routes the directional close actions to their handlers with the tab id', () => { const onCloseOthers = vi.fn() const onCloseToRight = vi.fn() diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx index 5b6caaecb55..1d66f143210 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx @@ -116,6 +116,7 @@ type SortableTabContextMenuProps = { isChatView?: boolean /** Toggle the tab between terminal and native chat view. */ onToggleViewMode?: () => void + canSplitTerminal?: boolean } export function SortableTabContextMenu({ @@ -140,7 +141,8 @@ export function SortableTabContextMenu({ onTogglePin, canToggleViewMode = false, isChatView = false, - onToggleViewMode + onToggleViewMode, + canSplitTerminal = true }: SortableTabContextMenuProps): React.JSX.Element { const keybindings = useAppStore((state) => state.keybindings) const splitRightShortcut = formatShortcutLabel('terminal.splitRight', keybindings) @@ -168,6 +170,7 @@ export function SortableTabContextMenu({ onActivate={onActivate} splitRightShortcut={splitRightShortcut} splitDownShortcut={splitDownShortcut} + showTerminalSplit={canSplitTerminal} /> {canToggleViewMode && onToggleViewMode ? ( <> diff --git a/src/renderer/src/components/tab-bar/TabWorkspaceLayoutMenuSection.tsx b/src/renderer/src/components/tab-bar/TabWorkspaceLayoutMenuSection.tsx index faa784240b7..34ad57b1d62 100644 --- a/src/renderer/src/components/tab-bar/TabWorkspaceLayoutMenuSection.tsx +++ b/src/renderer/src/components/tab-bar/TabWorkspaceLayoutMenuSection.tsx @@ -3,7 +3,8 @@ import { DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, - DropdownMenuItem + DropdownMenuItem, + DropdownMenuShortcut } from '@/components/ui/dropdown-menu' import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Columns2 } from 'lucide-react' import type { TabSplitDirection } from '../../store/slices/tabs' @@ -42,11 +43,15 @@ function paneColumnDirectionLabel(direction: TabSplitDirection): string { export function TabWorkspaceLayoutMenuSection({ unifiedTabId, groupId, - trailingSeparator = false + leadingSeparator = false, + trailingSeparator = false, + shortcutLabels }: { unifiedTabId: string groupId: string + leadingSeparator?: boolean trailingSeparator?: boolean + shortcutLabels?: Partial> }): React.JSX.Element | null { if (!canMoveTabToNewPaneColumn(unifiedTabId, groupId)) { return null @@ -54,6 +59,7 @@ export function TabWorkspaceLayoutMenuSection({ return ( <> + {leadingSeparator ? : null} @@ -72,6 +78,9 @@ export function TabWorkspaceLayoutMenuSection({ > {paneColumnDirectionIcon(direction)} {paneColumnDirectionLabel(direction)} + {shortcutLabels?.[direction] ? ( + {shortcutLabels[direction]} + ) : null} ))} diff --git a/src/renderer/src/components/tab-bar/TerminalTabSplitMenuSection.tsx b/src/renderer/src/components/tab-bar/TerminalTabSplitMenuSection.tsx index 1b620e35dbe..a2226036f27 100644 --- a/src/renderer/src/components/tab-bar/TerminalTabSplitMenuSection.tsx +++ b/src/renderer/src/components/tab-bar/TerminalTabSplitMenuSection.tsx @@ -21,6 +21,7 @@ export function TerminalTabSplitMenuSection({ onActivate, splitRightShortcut, splitDownShortcut, + showTerminalSplit = true, trailingSeparator = false }: { unifiedTabId: string @@ -30,6 +31,7 @@ export function TerminalTabSplitMenuSection({ onActivate: (tabId: string) => void splitRightShortcut: string splitDownShortcut: string + showTerminalSplit?: boolean trailingSeparator?: boolean }): React.JSX.Element { const splitActiveTerminalPane = (direction: 'vertical' | 'horizontal'): void => { @@ -42,33 +44,37 @@ export function TerminalTabSplitMenuSection({ return ( <> - - - - {translate( - 'auto.components.tab.bar.TerminalTabSplitMenuSection.splitTerminal', - 'Split terminal' - )} - - - splitActiveTerminalPane('vertical')}> - + {showTerminalSplit ? ( + + + {translate( - 'auto.components.tab.bar.SortableTabContextMenu.splitTerminalRight', - 'Split terminal right' + 'auto.components.tab.bar.TerminalTabSplitMenuSection.splitTerminal', + 'Split terminal' )} - {splitRightShortcut} - - splitActiveTerminalPane('horizontal')}> - - {translate( - 'auto.components.tab.bar.SortableTabContextMenu.splitTerminalDown', - 'Split terminal down' - )} - {splitDownShortcut} - - - + + + splitActiveTerminalPane('vertical')}> + + {translate( + 'auto.components.tab.bar.SortableTabContextMenu.splitTerminalRight', + 'Split terminal right' + )} + {splitRightShortcut} + + splitActiveTerminalPane('horizontal')}> + + {translate( + 'auto.components.tab.bar.SortableTabContextMenu.splitTerminalDown', + 'Split terminal down' + )} + {splitDownShortcut} + + + + ) : null} {trailingSeparator ? : null} ) diff --git a/src/renderer/src/components/tab-bar/tab-bar-item-surface.tsx b/src/renderer/src/components/tab-bar/tab-bar-item-surface.tsx index 24114f42a16..95e9a98e7bb 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-item-surface.tsx +++ b/src/renderer/src/components/tab-bar/tab-bar-item-surface.tsx @@ -266,6 +266,7 @@ export function renderTabBarItems({ onSetTabColor={onSetTabColor} onTogglePin={() => togglePinned(item)} onToggleExpand={() => {}} + canSplitTerminal={false} dragData={dragData} dropIndicator={dropIndicatorByVisibleId.get(item.id) ?? null} includeTopTabBorder={includeTopTabBorder} diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneNativeChatPortal.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneNativeChatPortal.tsx index 1b5e94b4a11..da2a3f02133 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneNativeChatPortal.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneNativeChatPortal.tsx @@ -41,6 +41,31 @@ export function TerminalPaneNativeChatPortal({ return null } + const contextMenuActions = { + onSplitRight: () => contextMenu.runForPane(chatPane.id, contextMenu.onSplitRight), + onSplitDown: () => contextMenu.runForPane(chatPane.id, contextMenu.onSplitDown), + canEqualizePaneSizes: managedPanes.length > 1 && expandedPaneId === null, + onEqualizePaneSizes: () => contextMenu.runForPane(chatPane.id, contextMenu.onEqualizePaneSizes), + canExpandPane: managedPanes.length > 1, + isPaneExpanded: expandedPaneId === chatPane.id, + onToggleExpand: () => contextMenu.runForPane(chatPane.id, contextMenu.onToggleExpand), + canContinueAgentSessionInNewSession: canContinueAgentSessionInNewSession( + resolveAgentForLeaf(chatPane.leafId) + ), + onContinueAgentSessionInNewSession: () => + contextMenu.runForPane(chatPane.id, contextMenu.onContinueAgentSessionInNewSession), + onForkAgentSession: () => + void contextMenu.runForPane(chatPane.id, contextMenu.onForkAgentSession), + onSetTitle: () => contextMenu.runForPane(chatPane.id, contextMenu.onSetTitle), + onCopyTerminalId: () => void contextMenu.runForPane(chatPane.id, contextMenu.onCopyTerminalId), + onCopyPaneId: () => void contextMenu.runForPane(chatPane.id, contextMenu.onCopyPaneId), + canCopyAgentSessionId: chatPaneSessionId !== null, + onCopyAgentSessionId: () => + void contextMenu.runForPane(chatPane.id, contextMenu.onCopyAgentSessionId), + canClosePane: managedPanes.length > 1, + onClosePane: () => contextMenu.runForPane(chatPane.id, contextMenu.onClosePane) + } + return createPortal(
{structuredSessionId && structuredChatAgent ? ( @@ -51,7 +76,7 @@ export function TerminalPaneNativeChatPortal({ agent={structuredChatAgent} isVisible={isRendererVisible} target={structuredChatTarget} - allowFileUriLinks + contextMenuActions={contextMenuActions} orchestrationDispatchStatus={chatPaneDispatchStatus} /> ) : ( @@ -65,32 +90,7 @@ export function TerminalPaneNativeChatPortal({ ownsTabWideLaunchDraft={chatPaneOwnsTabWideLaunchDraft} onSwitchToTerminal={switchNativeChatToTerminal} readTerminalScreen={readNativeChatTerminalScreen} - contextMenuActions={{ - onSplitRight: () => contextMenu.runForPane(chatPane.id, contextMenu.onSplitRight), - onSplitDown: () => contextMenu.runForPane(chatPane.id, contextMenu.onSplitDown), - canEqualizePaneSizes: managedPanes.length > 1 && expandedPaneId === null, - onEqualizePaneSizes: () => - contextMenu.runForPane(chatPane.id, contextMenu.onEqualizePaneSizes), - canExpandPane: managedPanes.length > 1, - isPaneExpanded: expandedPaneId === chatPane.id, - onToggleExpand: () => contextMenu.runForPane(chatPane.id, contextMenu.onToggleExpand), - canContinueAgentSessionInNewSession: canContinueAgentSessionInNewSession( - resolveAgentForLeaf(chatPane.leafId) - ), - onContinueAgentSessionInNewSession: () => - contextMenu.runForPane(chatPane.id, contextMenu.onContinueAgentSessionInNewSession), - onForkAgentSession: () => - void contextMenu.runForPane(chatPane.id, contextMenu.onForkAgentSession), - onSetTitle: () => contextMenu.runForPane(chatPane.id, contextMenu.onSetTitle), - onCopyTerminalId: () => - void contextMenu.runForPane(chatPane.id, contextMenu.onCopyTerminalId), - onCopyPaneId: () => void contextMenu.runForPane(chatPane.id, contextMenu.onCopyPaneId), - canCopyAgentSessionId: chatPaneSessionId !== null, - onCopyAgentSessionId: () => - void contextMenu.runForPane(chatPane.id, contextMenu.onCopyAgentSessionId), - canClosePane: managedPanes.length > 1, - onClosePane: () => contextMenu.runForPane(chatPane.id, contextMenu.onClosePane) - }} + contextMenuActions={contextMenuActions} orchestrationDispatchStatus={chatPaneDispatchStatus} /> )} diff --git a/src/renderer/src/components/use-worktree-jump-palette-quick-actions.ts b/src/renderer/src/components/use-worktree-jump-palette-quick-actions.ts index 0783a7e5d23..32cddf92a17 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-quick-actions.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-quick-actions.ts @@ -56,6 +56,7 @@ export function useWorktreeJumpPaletteQuickActions({ sshConnectionStates, activeGroupIdByWorktree, groupsByWorktree, + unifiedTabsByWorktree, isLoading, settings, runtimeStatusByEnvironmentId, @@ -129,6 +130,7 @@ export function useWorktreeJumpPaletteQuickActions({ void sshConnectionStates void activeGroupIdByWorktree void groupsByWorktree + void unifiedTabsByWorktree void isLoading void settings?.activeRuntimeEnvironmentId void runtimeStatusByEnvironmentId @@ -144,6 +146,7 @@ export function useWorktreeJumpPaletteQuickActions({ sshConnectionStates, activeGroupIdByWorktree, groupsByWorktree, + unifiedTabsByWorktree, isLoading, settings?.activeRuntimeEnvironmentId, runtimeStatusByEnvironmentId diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c667e21f4c1..95de4fab458 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -15304,8 +15304,18 @@ "removeWorktree": "remove worktree", "trashWorktree": "trash worktree", "addQuickCommand": "add quick command", - "newQuickCommand": "new quick command" - } + "newQuickCommand": "new quick command", + "splitChatRight": "split chat right", + "moveChatRight": "move chat right", + "chatPaneRight": "chat pane right", + "splitChatDown": "split chat down", + "moveChatDown": "move chat down", + "chatPaneBelow": "chat pane below" + }, + "splitChatRight": "Split Chat Right", + "splitChatRightDescription": "Open the active chat in a split pane to the right.", + "splitChatDown": "Split Chat Down", + "splitChatDownDescription": "Open the active chat in a split pane below." } }, "palette": { diff --git a/src/renderer/src/lib/terminal-links.test.ts b/src/renderer/src/lib/terminal-links.test.ts index 66ff8d870a8..d16e3d3b0cc 100644 --- a/src/renderer/src/lib/terminal-links.test.ts +++ b/src/renderer/src/lib/terminal-links.test.ts @@ -115,6 +115,15 @@ describe('terminal path helpers', () => { }) describe('extractTerminalFileLinks local path tokens', () => { + it('keeps Unicode path segments in the detected range', () => { + expect(extractTerminalFileLinks('/tmp/报告.html').map((link) => link.displayText)).toEqual([ + '/tmp/报告.html' + ]) + expect( + extractTerminalFileLinks('docs/café/report.pdf').map((link) => link.displayText) + ).toEqual(['docs/café/report.pdf']) + }) + it('detects tilde-prefixed POSIX paths', () => { const links = extractTerminalFileLinks('~/Documents/Path/file_name') expect(links).toHaveLength(1) @@ -215,6 +224,15 @@ describe('terminal path helpers', () => { expect(links).toHaveLength(20_000) expect(links[0].pathText).toBe('/tmp/Foo Bar/file') }, 5_000) + + it('keeps extension-heavy assistant prose on a bounded scan path', () => { + const filenames = Array.from({ length: 20_000 }, (_, index) => `report-${index}.txt`) + const links = extractTerminalFileLinks(`/tmp/root/${filenames.join(' ')}`) + + expect(links).toHaveLength(2) + expect(links[0].pathText).toBe(`/tmp/root/${filenames.slice(0, -1).join(' ')}`) + expect(links.at(-1)?.pathText).toBe('report-19999.txt') + }) }) it('supports Windows cwd resolution for terminal file links', () => { diff --git a/src/renderer/src/lib/terminal-links.ts b/src/renderer/src/lib/terminal-links.ts index 729f2d839e7..970c6d9208c 100644 --- a/src/renderer/src/lib/terminal-links.ts +++ b/src/renderer/src/lib/terminal-links.ts @@ -34,7 +34,7 @@ export type ResolvedTerminalFileLink = Pick 1) { + while (nextPathStart && nextPathStart.index + nextPathStart[0].length <= end) { + pathStartCount += 1 + nextPathStart = pathStartPattern.exec(range.text) + } + if (pathStartCount > 1) { continue } if ( @@ -150,15 +157,6 @@ function trimSpacedPathTrailingProse( } } -function countPathStarts(text: string): number { - let count = 0 - for (const match of text.matchAll(/(?:^|\s)(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/])/g)) { - void match - count += 1 - } - return count -} - function trimTrailingWhitespace( range: DetectedTerminalFileLinkRange ): DetectedTerminalFileLinkRange { diff --git a/src/shared/child-process/process-tree-kill-gate.ts b/src/shared/child-process/process-tree-kill-gate.ts new file mode 100644 index 00000000000..420fbfeda9b --- /dev/null +++ b/src/shared/child-process/process-tree-kill-gate.ts @@ -0,0 +1,43 @@ +/** + * Seam that lets the main process decide, and record, the tree-kills issued + * from code it does not own. + * + * Why a seam and not a direct call: `signalProcessTree` is the choke point every + * `runProcess` termination funnels through, and the codex app-server and + * ephemeral-VM kills are shared with the CLI — all of them live outside + * `src/main` and cannot import the own-Chromium guard or the crash breadcrumb + * store. Main registers the guard at startup; everywhere else this admits every + * kill and records nothing. + */ + +/** Blast radius, not mechanism: `win-taskkill-tree` is addressed by pid and walks + * whatever tree that pid has *now*, so it can land on a recycled pid that is + * since one of Orca's own Chromium processes. A process group can only contain + * processes Orca itself put there. */ +export type ProcessTreeKillScope = 'win-taskkill-tree' | 'posix-process-group' + +export type ProcessTreeKill = { + pid: number + site: string + scope: ProcessTreeKillScope +} + +/** False means the caller must not walk that pid's tree — main is accounting for + * it. Killing the root through its own child handle stays correct and required: + * a handle cannot land on the recycled pid the refusal is about. */ +type ProcessTreeKillGate = (kill: ProcessTreeKill) => boolean + +let gate: ProcessTreeKillGate | null = null + +export function setProcessTreeKillGate(next: ProcessTreeKillGate | null): void { + gate = next +} + +export function admitProcessTreeKill(kill: ProcessTreeKill): boolean { + try { + return gate?.(kill) ?? true + } catch { + // Diagnostics must never turn a successful termination into a failed one. + return true + } +} diff --git a/src/shared/child-process/process-tree-kill-observer.ts b/src/shared/child-process/process-tree-kill-observer.ts deleted file mode 100644 index 8abf971798a..00000000000 --- a/src/shared/child-process/process-tree-kill-observer.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Seam that lets the main process record the tree-kills issued from here. - * - * Why a seam and not a direct call: `signalProcessTree` is the choke point every - * `runProcess` termination funnels through, but it lives in `src/shared` and so - * runs in the CLI and relay too — it cannot import the main-process crash - * breadcrumb store. Main registers the recorder at startup; everywhere else this - * stays a no-op. - */ - -/** Blast radius, not mechanism: `win-taskkill-tree` is addressed by pid and walks - * whatever tree that pid has *now*, so it can land on a recycled pid that is - * since one of Orca's own Chromium processes. A process group can only contain - * processes Orca itself put there. */ -export type ProcessTreeKillScope = 'win-taskkill-tree' | 'posix-process-group' - -export type ProcessTreeKill = { - pid: number - site: string - scope: ProcessTreeKillScope -} - -type ProcessTreeKillObserver = (kill: ProcessTreeKill) => void - -let observer: ProcessTreeKillObserver | null = null - -export function setProcessTreeKillObserver(next: ProcessTreeKillObserver | null): void { - observer = next -} - -export function notifyProcessTreeKill(kill: ProcessTreeKill): void { - try { - observer?.(kill) - } catch { - // Diagnostics must never turn a successful termination into a failed one. - } -} diff --git a/src/shared/child-process/process-tree-termination.test.ts b/src/shared/child-process/process-tree-termination.test.ts index cc3394fb5a3..10a4c725089 100644 --- a/src/shared/child-process/process-tree-termination.test.ts +++ b/src/shared/child-process/process-tree-termination.test.ts @@ -7,7 +7,7 @@ const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() })) vi.mock('node:child_process', () => ({ spawn: spawnMock })) import { forceTerminateProcessTree, signalProcessTree } from './process-tree-termination' -import { setProcessTreeKillObserver, type ProcessTreeKill } from './process-tree-kill-observer' +import { setProcessTreeKillGate, type ProcessTreeKill } from './process-tree-kill-gate' function mockProcess(pid: number): ChildProcess { const child = new EventEmitter() as EventEmitter & { @@ -103,11 +103,14 @@ describe('process-tree-kill breadcrumb seam', () => { beforeEach(() => { observed.length = 0 - setProcessTreeKillObserver((kill) => observed.push(kill)) + setProcessTreeKillGate((kill) => { + observed.push(kill) + return true + }) }) afterEach(() => { - setProcessTreeKillObserver(null) + setProcessTreeKillGate(null) spawnMock.mockReset() vi.restoreAllMocks() }) diff --git a/src/shared/child-process/process-tree-termination.ts b/src/shared/child-process/process-tree-termination.ts index 15264b2f6ce..ffcb93b2245 100644 --- a/src/shared/child-process/process-tree-termination.ts +++ b/src/shared/child-process/process-tree-termination.ts @@ -1,5 +1,5 @@ import { spawn as nodeSpawn, type ChildProcess } from 'node:child_process' -import { notifyProcessTreeKill } from './process-tree-kill-observer' +import { admitProcessTreeKill } from './process-tree-kill-gate' const PROBE_INTERVAL_MS = 25 const SUBPROCESS_TIMEOUT_MS = 2_000 @@ -13,8 +13,17 @@ const MAX_PS_OUTPUT_BYTES = 8 * 1024 * 1024 * leader would hand it to whatever group it inherited instead. * * Runs in every host — Electron main, the daemon, the relay, the CLI — so the - * main-process own-Chromium guard cannot reach here; the exit check below is - * what keeps the Windows branch off a pid that is no longer ours. + * own-Chromium guard arrives through `process-tree-kill-gate`, which main + * installs and every other host leaves admitting. The exit check below is what + * keeps the Windows branch off a pid that is no longer ours on those hosts. + * + * Both arms ask before walking the tree, so a refused pid never gets a + * pid-addressed kill; that also means the recorded crumb says "about to kill", + * not "killed". The root is still killed through its handle, which cannot reach + * the recycled pid the refusal was about, so a refusal is never a leak. Main's + * gate only ever refuses the `win-taskkill-tree` scope — a POSIX group holds + * only what Orca put in it — so the POSIX refusal arm is the seam's contract, + * not something any installed gate exercises today. */ export function signalProcessTree(child: ChildProcess, signal?: NodeJS.Signals): Promise { if (!child.pid) { @@ -39,13 +48,20 @@ export function signalProcessTree(child: ChildProcess, signal?: NodeJS.Signals): } return taskkillTree(child, child.pid, signal) } - try { - process.kill(-child.pid, signal) - notifyProcessTreeKill({ + if ( + !admitProcessTreeKill({ pid: child.pid, site: 'run-process-tree', scope: 'posix-process-group' }) + ) { + // Same shape as the reaped-pid skip above: refuse the group, still kill the + // root by handle, and report unverified. + killRoot(child, signal) + return Promise.resolve(false) + } + try { + process.kill(-child.pid, signal) return Promise.resolve(true) } catch { return Promise.resolve(!processGroupExists(child.pid)) @@ -73,6 +89,13 @@ function taskkillTree( rootPid: number, signal?: NodeJS.Signals ): Promise { + // Asked before the spawn, not after: a refusal has to prevent the taskkill. + if ( + !admitProcessTreeKill({ pid: rootPid, site: 'run-process-tree', scope: 'win-taskkill-tree' }) + ) { + killRoot(child, signal) + return Promise.resolve(false) + } return new Promise((resolve) => { let killer: ChildProcess try { @@ -86,7 +109,6 @@ function taskkillTree( resolve(false) return } - notifyProcessTreeKill({ pid: rootPid, site: 'run-process-tree', scope: 'win-taskkill-tree' }) let settled = false const finish = (fallback: boolean): void => { if (settled) { diff --git a/src/shared/ephemeral-vm-recipe-process.ts b/src/shared/ephemeral-vm-recipe-process.ts index b258a1b2704..30d44ac1dbc 100644 --- a/src/shared/ephemeral-vm-recipe-process.ts +++ b/src/shared/ephemeral-vm-recipe-process.ts @@ -1,5 +1,6 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' import type { EphemeralVmRecipeContext } from './ephemeral-vm-recipe-runner' +import { admitProcessTreeKill } from './child-process/process-tree-kill-gate' const DEFAULT_MAX_CAPTURE_BYTES = 1024 * 1024 const CANCEL_FORCE_KILL_DELAY_MS = 5_000 @@ -132,13 +133,26 @@ export async function runRecipeCommand(args: { }) } -function killRecipeProcess(child: ChildProcessWithoutNullStreams, force = false): void { +/** Exported for the refusal-fallback test; the abort path is otherwise unreachable. */ +export function killRecipeProcess(child: ChildProcessWithoutNullStreams, force = false): void { const signal = force ? 'SIGKILL' : 'SIGTERM' if (process.platform === 'win32') { // Recipes run through `cmd.exe /c` (shell: true), so child.kill() would only // terminate the wrapper and orphan the actual recipe subprocess (e.g. a cloud // CLI mid-provision). taskkill /T walks and kills the whole tree. if (child.pid) { + if ( + !admitProcessTreeKill({ + pid: child.pid, + site: 'ephemeral-vm-recipe', + 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(signal) + return + } const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { windowsHide: true, stdio: 'ignore' diff --git a/src/shared/native-chat-href-routing.test.ts b/src/shared/native-chat-href-routing.test.ts index 1b7c276ffd3..5f6e7ad4595 100644 --- a/src/shared/native-chat-href-routing.test.ts +++ b/src/shared/native-chat-href-routing.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { routeNativeChatHref } from './native-chat-href-routing' +import { createNativeChatFileHref, routeNativeChatHref } from './native-chat-href-routing' describe('routeNativeChatHref', () => { it('classifies web and mail links', () => { @@ -47,6 +47,35 @@ describe('routeNativeChatHref', () => { }) }) + it('routes encoded renderer file targets without treating Windows drives as schemes', () => { + expect( + routeNativeChatHref(createNativeChatFileHref(String.raw`C:\repo\report.docx:12`)) + ).toEqual({ + kind: 'file', + pathText: String.raw`C:\repo\report.docx:12`, + line: null + }) + expect(routeNativeChatHref(createNativeChatFileHref('/tmp/report.html'))).toEqual({ + kind: 'file', + pathText: '/tmp/report.html', + line: null + }) + }) + + it('bounds nested renderer file target decoding', () => { + let href = '/tmp/report.html' + for (let depth = 0; depth < 4; depth += 1) { + href = createNativeChatFileHref(` ${href}`) + } + expect(routeNativeChatHref(href)).toEqual({ + kind: 'file', + pathText: '/tmp/report.html', + line: null + }) + + expect(routeNativeChatHref(createNativeChatFileHref(` ${href}`))).toEqual({ kind: 'none' }) + }) + it('drops anchors, unknown schemes, malformed file URIs, and empty hrefs', () => { expect(routeNativeChatHref('#section')).toEqual({ kind: 'none' }) expect(routeNativeChatHref(undefined)).toEqual({ kind: 'none' }) diff --git a/src/shared/native-chat-href-routing.ts b/src/shared/native-chat-href-routing.ts index 005ab957f65..edf36565dab 100644 --- a/src/shared/native-chat-href-routing.ts +++ b/src/shared/native-chat-href-routing.ts @@ -8,6 +8,24 @@ export type NativeChatHrefRoute = const WEB_SCHEME_PATTERN = /^(?:https?|mailto):/i const SCHEME_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*:/ +export const NATIVE_CHAT_FILE_HREF_PREFIX = '#orca-native-chat-file=' +const MAX_NATIVE_CHAT_FILE_HREF_DECODES = 4 + +export function createNativeChatFileHref(pathText: string): string { + return `${NATIVE_CHAT_FILE_HREF_PREFIX}${encodeURIComponent(pathText)}` +} + +function decodeNativeChatFileHref(href: string): string | null { + if (!href.startsWith(NATIVE_CHAT_FILE_HREF_PREFIX)) { + return null + } + try { + const decoded = decodeURIComponent(href.slice(NATIVE_CHAT_FILE_HREF_PREFIX.length)) + return decoded && !decoded.startsWith(NATIVE_CHAT_FILE_HREF_PREFIX) ? decoded : null + } catch { + return null + } +} function parseLineFragment(hash: string): number | null { if (!hash) { @@ -45,8 +63,21 @@ function maybeDecodeHrefPath(value: string): string { } export function routeNativeChatHref(href: string | null | undefined): NativeChatHrefRoute { - const trimmed = href?.trim() - if (!trimmed || trimmed.startsWith('#')) { + let trimmed = href?.trim() + if (!trimmed) { + return { kind: 'none' } + } + for (let depth = 0; depth < MAX_NATIVE_CHAT_FILE_HREF_DECODES; depth += 1) { + const encodedFileHref = decodeNativeChatFileHref(trimmed) + if (!encodedFileHref) { + break + } + trimmed = encodedFileHref.trim() + } + if (!trimmed || trimmed.startsWith(NATIVE_CHAT_FILE_HREF_PREFIX)) { + return { kind: 'none' } + } + if (trimmed.startsWith('#')) { return { kind: 'none' } } if (WEB_SCHEME_PATTERN.test(trimmed)) { diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 2d3a9dfe245..baa791a38a4 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -129,13 +129,12 @@ export const AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY = 'agent-session.host-authority.v1' as const export const AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY = 'agent-session.omp-resume-path.v1' as const -// Why: structured sessions are journal-backed, not PTY-backed, so a client that -// cannot read them must not see them at all — it would render an agent tab it -// can neither display nor drive. The host also refuses every agentSession.* -// method from a connection that does not advertise this. +// Why: structured sessions are journal-backed, not PTY-backed, so an incapable client must not +// receive their journal or drive their lifecycle. Mobile may receive a metadata-only placeholder; +// the host still refuses agentSession.* methods and destructive tab mutations without capability. export const STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY = 'agent-session.structured.v1' as const -// Why: mobile clients advertise Claude-structured session support during E2EE -// pairing so the desktop can keep structured-specific affordances enabled. +// Why: paired clients advertise Claude-structured support so the host can gate its agent-specific +// journal and lifecycle surfaces independently from Codex support. export const CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY = 'agent-session.structured.claude.v1' as const // Why: paired structured clients explicitly hold every visible session surface, allowing the host diff --git a/src/shared/structured-agent-session-composer.test.ts b/src/shared/structured-agent-session-composer.test.ts new file mode 100644 index 00000000000..38fed5ddbc1 --- /dev/null +++ b/src/shared/structured-agent-session-composer.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { + isStructuredAgentSessionComposerCommand, + structuredSlashCommands +} from './structured-agent-session-composer' + +describe('structuredSlashCommands', () => { + // The composer menu and the dispatcher read this one list. When they disagreed, + // a Claude session was offered Codex-only tokens that missed the command guard + // and reached the model as literal prompt text instead of erroring. + it.each(['codex', 'claude'] as const)('offers %s only commands it also accepts', (agent) => { + const offered = structuredSlashCommands(agent) + expect(offered.length).toBeGreaterThan(0) + for (const command of offered) { + expect(isStructuredAgentSessionComposerCommand(`/${command.name}`, agent)).toBe(true) + } + }) + + it('offers each agent its own catalog', () => { + const claude = structuredSlashCommands('claude').map((command) => command.name) + expect(claude).toContain('compact') + expect(claude).not.toContain('vim') + expect(structuredSlashCommands('codex').map((command) => command.name)).toContain('vim') + }) + + it('offers effort to every structured agent', () => { + for (const agent of ['codex', 'claude'] as const) { + expect(structuredSlashCommands(agent).map((command) => command.name)).toContain('effort') + } + }) +}) diff --git a/src/shared/structured-agent-session-composer.ts b/src/shared/structured-agent-session-composer.ts index 420651aba5b..18bdeab001d 100644 --- a/src/shared/structured-agent-session-composer.ts +++ b/src/shared/structured-agent-session-composer.ts @@ -35,7 +35,10 @@ function commandParts(text: string): { name: string; argument: string } | null { return match ? { name: match[1]!.toLowerCase(), argument: match[2]?.trim() ?? '' } : null } -function structuredSlashCommands(agent: AgentType): readonly SlashCommandSuggestion[] { +/** The command catalog a structured session offers and accepts. The composer menu + * and the dispatcher must read the same list, or a menu pick falls through the + * command guard and reaches the model as literal prompt text. */ +export function structuredSlashCommands(agent: AgentType): readonly SlashCommandSuggestion[] { if (agent === 'codex') { return STRUCTURED_AGENT_SESSION_SLASH_COMMANDS } diff --git a/tests/e2e/browser-loading-surface-oracle.ts b/tests/e2e/browser-loading-surface-oracle.ts new file mode 100644 index 00000000000..5fab1c78c94 --- /dev/null +++ b/tests/e2e/browser-loading-surface-oracle.ts @@ -0,0 +1,300 @@ +import { createServer } from 'node:http' +import { writeFile } from 'node:fs/promises' +import type { AddressInfo } from 'node:net' +import { expect, type Page } from '@stablyai/playwright-test' +import { PNG } from 'pngjs' + +// Hold the response, not a timer: every screenshot precedes the first document commit. +export async function observeBrowserLoadingSurface( + page: Page, + outputPath: (name: string) => string, + crashGuest?: (id: number) => Promise +) { + const pendingResponses: (() => void)[] = [] + const release = (): void => { + pendingResponses.splice(0).forEach((send) => send()) + } + let requestCount = 0 + let flushPrefix: (() => void) | undefined + let retryReady = false + const server = createServer((request, response) => { + if (request.url === '/fail' && !retryReady) { + response.destroy() + return + } + if (request.url !== '/held' && request.url !== '/fail') { + response.writeHead(204).end() + return + } + requestCount += 1 + let prefixSent = false + flushPrefix = () => { + prefixSent = true + response.writeHead(200, { 'Content-Type': 'text/html' }) + response.write( + `Surface oracle` + ) + } + pendingResponses.push(() => + response.end( + `${prefixSent ? '' : 'Surface oracle'}

Usable webpage

` + ) + ) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}/held` + const observations: Record[] = [] + // Freezing attachment for a screenshot must also freeze the missing-guest watchdog. + await page.clock.pauseAt(new Date()) + const attachGate = await page.evaluateHandle((heldUrl) => { + const original = Element.prototype.setAttribute + const pending: (() => void)[] = [] + Element.prototype.setAttribute = function (name, value) { + if (this.tagName === 'WEBVIEW' && name === 'src' && value === heldUrl) { + pending.push(() => original.call(this, name, value)) + return + } + original.call(this, name, value) + } + return () => { + Element.prototype.setAttribute = original + pending.splice(0).forEach((release) => release()) + } + }, url) + try { + await page.evaluate(async () => { + await window.__store!.getState().updateSettingsOrThrow({ theme: 'dark' }) + }) + await expect(page.locator('html')).toHaveClass(/dark/) + const tab = await page.evaluate((url) => { + const s = window.__store!.getState() + return s.createBrowserTab(s.activeWorktreeId!, url, { + activate: true, + title: 'Surface oracle' + }) + }, url) + let guest = page.locator(`[data-browser-overlay-tab-id="${tab.id}"] webview`) + await expect(guest).toHaveCount(1) + const capture = async (phase: string, expected: 'theme' | 'white', loading = false) => { + const state = await guest.evaluate((element) => { + const webview = element as Electron.WebviewTag + const rect = webview.closest('[data-browser-page-container]')!.getBoundingClientRect() + let loading = false + let url = '' + let attached = false + try { + loading = webview.isLoading() + url = webview.getURL() + attached = webview.getWebContentsId() > 0 + } catch { + /* Guest creation is held by the oracle. */ + } + return { + attached, + background: getComputedStyle(webview).backgroundColor, + theme: getComputedStyle(webview.closest('[data-browser-page-container]')!) + .backgroundColor, + visibility: getComputedStyle(webview).visibility, + display: getComputedStyle(webview).display, + loading, + url, + rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } + } + }) + const target = + expected === 'white' ? [255, 255, 255] : state.theme.match(/\d+/g)!.slice(0, 3).map(Number) + let samples: number[][] = [] + const sampleSurface = async (): Promise => { + const screenshot = await page.screenshot({ path: outputPath(`${phase}.png`), scale: 'css' }) + const png = PNG.sync.read(screenshot) + samples = [0.08, 0.92].flatMap((x) => + [0.08, 0.85, 0.92].map((y) => { + const offset = + (Math.floor(state.rect.y + state.rect.height * y) * png.width + + Math.floor(state.rect.x + state.rect.width * x)) * + 4 + return [...png.data.subarray(offset, offset + 3)] + }) + ) + return samples.every((rgb) => rgb.every((c, i) => Math.abs(c - target[i]) <= 2)) + } + let pixelPass = await sampleSurface() + if (expected === 'white' && !pixelPass) { + // Loading can stop before the compositor presents the recovered guest's first frame. + await expect.poll(async () => (pixelPass = await sampleSurface())).toBe(true) + } + const statePass = + expected === 'theme' + ? state.background === state.theme || + state.visibility === 'hidden' || + state.display === 'none' + : state.url.startsWith('http://127.0.0.1:') + expect(state.loading).toBe(loading) + observations.push({ + phase, + ...state, + samples, + pixelPass, + statePass, + pass: pixelPass && statePass + }) + } + const dismissDrawHint = page.getByRole('button', { name: 'Got it', exact: true }) + if (await dismissDrawHint.isVisible()) { + await dismissDrawHint.click() + await expect(dismissDrawHint).not.toBeVisible() + } + await page.keyboard.press('Escape') + await page.mouse.move(0, 0) + await capture('pre-attach', 'theme') + await attachGate.evaluate((release) => release()) + await page.clock.resume() + await expect.poll(() => requestCount).toBe(1) + await capture('dark-held', 'theme', true) + await page.evaluate(async () => { + await window.__store!.getState().updateSettingsOrThrow({ theme: 'light' }) + }) + await expect(page.locator('html')).toHaveClass(/light/) + await capture('light-held', 'theme', true) + await page.evaluate(async () => { + await window.__store!.getState().updateSettingsOrThrow({ theme: 'dark' }) + }) + await expect(page.locator('html')).toHaveClass(/dark/) + await capture('dark-again-held', 'theme', true) + await page.emulateMedia({ colorScheme: 'light' }) + await page.evaluate(async () => { + await window.__store!.getState().updateSettingsOrThrow({ theme: 'system' }) + }) + await expect(page.locator('html')).toHaveClass(/light/) + await capture('system-light-held', 'theme', true) + await page.emulateMedia({ colorScheme: 'dark' }) + await expect(page.locator('html')).toHaveClass(/dark/) + await capture('system-dark-held', 'theme', true) + await page.emulateMedia({ colorScheme: null }) + await page.evaluate(async () => { + await window.__store!.getState().updateSettingsOrThrow({ theme: 'dark' }) + }) + flushPrefix!() + await expect + .poll(() => guest.evaluate((e) => (e as Electron.WebviewTag).getTitle())) + .toBe('Surface oracle') + await capture('committed-empty', 'theme', true) + release!() + await expect + .poll(() => guest.evaluate((e) => (e as Electron.WebviewTag).isLoading())) + .toBe(false) + expect( + await guest.evaluate((e) => + (e as Electron.WebviewTag).executeJavaScript( + '({ text: document.querySelector("h1").textContent, style: document.body.getAttribute("style") })' + ) + ) + ).toEqual({ text: 'Usable webpage', style: null }) + await capture('unstyled-painted', 'white') + await page.evaluate(async () => { + await window.__store!.getState().updateSettingsOrThrow({ theme: 'light' }) + }) + await expect(page.locator('html')).toHaveClass(/light/) + await capture('unstyled-light', 'white') + await page.evaluate(async () => { + await window.__store!.getState().updateSettingsOrThrow({ theme: 'dark' }) + }) + await expect(page.locator('html')).toHaveClass(/dark/) + const pane = page.locator(`[data-browser-overlay-tab-id="${tab.id}"]`) + await pane.getByRole('button', { name: 'Reload', exact: true }).click() + await expect.poll(() => requestCount).toBe(2) + await capture('reload-retained', 'white', true) + release!() + await expect + .poll(() => guest.evaluate((e) => (e as Electron.WebviewTag).isLoading())) + .toBe(false) + const guestId = await guest.evaluate((e) => (e as Electron.WebviewTag).getWebContentsId()) + const worktreeId = await page.evaluate(() => window.__store!.getState().activeWorktreeId!) + await page.evaluate(() => window.__store!.getState().setActiveWorktree(null)) + await expect(pane).not.toBeVisible() + await page.evaluate( + ({ worktreeId, tabId }) => { + const s = window.__store!.getState() + s.setActiveWorktree(worktreeId) + s.setActiveBrowserTab(tabId) + }, + { worktreeId, tabId: tab.id } + ) + await expect(pane).toBeVisible() + expect(await guest.evaluate((e) => (e as Electron.WebviewTag).getWebContentsId())).toBe(guestId) + await capture('unpark-retained', 'white') + if (crashGuest) { + await crashGuest(guestId) + await expect.poll(() => requestCount).toBe(3) + await capture('recovery-held', 'theme', true) + release!() + await expect + .poll(() => guest.evaluate((e) => (e as Electron.WebviewTag).isLoading())) + .toBe(false) + await capture('recovery-painted', 'white') + } + const blank = await page.evaluate(() => { + const s = window.__store!.getState() + return s.createBrowserTab(s.activeWorktreeId!, 'about:blank', { activate: true }) + }) + guest = page.locator(`[data-browser-overlay-tab-id="${blank.id}"] webview`) + await expect + .poll(() => + guest.evaluate((e) => { + try { + return (e as Electron.WebviewTag).getURL() + } catch { + return '' + } + }) + ) + .toMatch(/about:blank|data:text\/html,/) + await expect + .poll(() => guest.evaluate((e) => (e as Electron.WebviewTag).isLoading())) + .toBe(false) + await page.keyboard.press('Escape') + await capture('new-tab', 'theme') + // Navigate through the real address input, retaining the blank document while the server waits. + const address = page.locator(`[data-browser-overlay-tab-id="${blank.id}"] input`).first() + await address.fill(url) + await address.press('Enter') + await expect.poll(() => requestCount).toBe(crashGuest ? 4 : 3) + await capture('new-tab-first-navigation-held', 'theme', true) + release!() + await expect + .poll(() => guest.evaluate((e) => (e as Electron.WebviewTag).isLoading())) + .toBe(false) + await capture('new-tab-painted', 'white') + await address.fill(url.replace('/held', '/fail')) + await address.press('Enter') + const retry = page + .locator(`[data-browser-overlay-tab-id="${blank.id}"]`) + .getByRole('button', { name: 'Retry', exact: true }) + .filter({ hasText: 'Retry' }) + await expect(retry).toBeVisible() + await expect + .poll(() => guest.evaluate((e) => (e as Electron.WebviewTag).isLoading())) + .toBe(false) + await capture('network-error', 'theme') + retryReady = true + const countBeforeRetry = requestCount + await retry.click() + await expect.poll(() => requestCount).toBeGreaterThan(countBeforeRetry) + await capture('network-retry-held', 'theme', true) + release!() + await expect(retry).not.toBeVisible() + await expect + .poll(() => guest.evaluate((e) => (e as Electron.WebviewTag).isLoading())) + .toBe(false) + await capture('network-retry-painted', 'white') + await writeFile(outputPath('observations.json'), JSON.stringify(observations, null, 2)) + return observations + } finally { + await attachGate.evaluate((release) => release()).catch(() => {}) + await page.clock.resume().catch(() => {}) + await attachGate.dispose() + release?.() + server.closeAllConnections() + await new Promise((resolve) => server.close(() => resolve())) + } +} diff --git a/tests/e2e/browser-loading-surface.spec.ts b/tests/e2e/browser-loading-surface.spec.ts new file mode 100644 index 00000000000..ac1f21eac57 --- /dev/null +++ b/tests/e2e/browser-loading-surface.spec.ts @@ -0,0 +1,21 @@ +import { expect, test } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { crashGuestRenderer } from './browser-guest-runtime-oracle' +import { observeBrowserLoadingSurface } from './browser-loading-surface-oracle' + +test('browser host follows the theme before content and preserves the webpage canvas', async ({ + orcaPage, + electronApp +}, testInfo) => { + await waitForSessionReady(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveWorktree(orcaPage) + const observations = await observeBrowserLoadingSurface( + orcaPage, + (name) => testInfo.outputPath(name), + async (id) => { + await crashGuestRenderer(electronApp, id) + } + ) + expect(observations.filter((entry) => !entry.pass)).toEqual([]) +}) diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index 2ad8bd0647f..a2a64897fcb 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -2,9 +2,14 @@ // way the terminal wire harness is: current code against a real published release. // // Three skews matter here, and none can be checked from one build alone — an old -// client must not be shown a session it cannot render, a new client must find an -// old host's missing surface cleanly, and a client's cursor must survive the host -// process that minted it. +// client must not receive a journal-backed RPC surface it cannot read, a new client +// must find an old host's missing surface cleanly, and a client's cursor must survive +// the host process that minted it. +// +// The session-tabs projection may keep a metadata-only row for an incapable mobile client so the +// chat is not simply absent on the phone. Every `agentSession.*` method and destructive close stays +// refused, which is what the tests below pin; the row-level behaviour is pinned in +// src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' diff --git a/tests/tools/google-signin-ua-probe.cjs b/tests/tools/google-signin-ua-probe.cjs index 7adf06bb7a0..70c834b3d9d 100644 --- a/tests/tools/google-signin-ua-probe.cjs +++ b/tests/tools/google-signin-ua-probe.cjs @@ -10,8 +10,8 @@ const MODES = new Set([ 'electron-fixed', 'firefox-auth', 'firefox-fixed', - // Replicates the SHIPPED app exactly (setupClientHintsOverride + - // applyGoogleAuthUserAgent): Firefox UA is written to the WebContents on auth + // Replicates the app as it shipped before the UA rewrite was removed + // (cleaned Chrome-shaped session UA + the Google auth Firefox switch): Firefox UA is written to the WebContents on auth // navs and to the request header only for auth-host URLs; every other request // keeps whatever UA the WebContents carries. Logs incoming vs outgoing // identity for ALL requests to expose cross-host mismatches during the flow. @@ -185,7 +185,7 @@ app.whenReady().then(async () => { if (mode === 'app-fixed' && currentUa === identities.firefox) { removeClientHints(headers) } else { - // Real setupClientHintsOverride builds Chrome hints once from the + // The retired client-hints rewrite built Chrome hints once from the // session's cleaned UA (a closure), never from the per-request UA. applyChromeClientHints(headers, identities.cleaned) }