From 5f687f7e844115b24cb75a665ee7c56ce4f06afb Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:48:17 +0000 Subject: [PATCH 1/8] fix(ai): Resolve issue #2064 - Publish packaged Windows Connect READY through a b Implemented by ProPR AI using gpt-5.6-sol model. Implementation completed successfully. --- .../scripts/packaged-connect-lifecycle.mjs | 102 ++++++++---- .../packaged-connect-lifecycle.test.mjs | 97 ++++++++++-- .../scripts/packaged-connect-ready-child.mjs | 15 ++ .../packaged-connect-ready-regression.mjs | 93 +++++++++++ .../scripts/packaged-connect-ready.d.mts | 38 +++++ .../scripts/packaged-connect-ready.mjs | 78 +++++++++ .../scripts/packaged-connect-ready.test.mjs | 148 ++++++++++++++++++ .../scripts/smoke-packaged-connect.mjs | 8 + apps/desktop/src/main.ts | 31 ++-- apps/desktop/src/smoke-test-evidence.test.ts | 17 ++ apps/desktop/src/smoke-test-evidence.ts | 14 +- 11 files changed, 590 insertions(+), 51 deletions(-) create mode 100644 apps/desktop/scripts/packaged-connect-ready-child.mjs create mode 100644 apps/desktop/scripts/packaged-connect-ready-regression.mjs create mode 100644 apps/desktop/scripts/packaged-connect-ready.d.mts create mode 100644 apps/desktop/scripts/packaged-connect-ready.mjs create mode 100644 apps/desktop/scripts/packaged-connect-ready.test.mjs diff --git a/apps/desktop/scripts/packaged-connect-lifecycle.mjs b/apps/desktop/scripts/packaged-connect-lifecycle.mjs index 93deaaffb..e7861371f 100644 --- a/apps/desktop/scripts/packaged-connect-lifecycle.mjs +++ b/apps/desktop/scripts/packaged-connect-lifecycle.mjs @@ -1,10 +1,14 @@ import { spawn as nodeSpawn } from 'node:child_process'; -import { lstat, realpath, rm } from 'node:fs/promises'; +import { lstat, open, realpath, rm } from 'node:fs/promises'; import { basename, dirname, isAbsolute, relative } from 'node:path'; import { TextDecoder } from 'node:util'; import { fileURLToPath } from 'node:url'; +import { + CONNECT_READY_EVENT, + isExactConnectReadyRecord, +} from './packaged-connect-ready.mjs'; -export const CONNECT_READY_EVENT = 'desktop.renderer.connect_discovery.ready'; +export { CONNECT_READY_EVENT }; export const CHILD_CAPTURE_MAX_BYTES = 64 * 1024; export const CHILD_DIAGNOSTIC_MAX_RECORDS = 20; @@ -60,6 +64,13 @@ const diagnosticCategories = new Set([ 'type-mismatch', 'unexpected', ]); +const failureMilestones = new Map([ + ['desktop.app.ready', 'app-ready'], + ['desktop.renderer.ready', 'renderer-ready'], + ['desktop.renderer.connect_discovery.proof', 'connect-proof'], + [CONNECT_READY_EVENT, 'ready-publication'], +]); +const allowedFailureMilestones = new Set(failureMilestones.values()); export const boundedChildDiagnostics = records => records.flatMap(record => { if (!record || typeof record !== 'object' || !diagnosticEvents.has(record.event)) return []; @@ -81,25 +92,41 @@ export const boundedChildDiagnostics = records => records.flatMap(record => { }]; }).slice(0, CHILD_DIAGNOSTIC_MAX_RECORDS); -const exactKeys = (record, expected) => { - const actual = Object.keys(record).sort(); - return actual.length === expected.length && actual.every((key, index) => key === expected[index]); -}; +export const isExactReadyRecord = isExactConnectReadyRecord; -export const isExactReadyRecord = (record, { platform, arch, authorityMechanism }) => { - if (!record || typeof record !== 'object' || Array.isArray(record)) return false; - if (!exactKeys(record, [ - 'authorityMechanism', 'event', 'level', 'rendererSchemaValid', - 'selectedArch', 'selectedPlatform', 'timestamp', - ])) return false; - return record.event === CONNECT_READY_EVENT - && record.level === 'info' - && typeof record.timestamp === 'string' - && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u.test(record.timestamp) - && record.selectedPlatform === platform - && record.selectedArch === arch - && record.authorityMechanism === authorityMechanism - && record.rendererSchemaValid === true; +export const readPackagedConnectFailureMilestone = async evidencePath => { + if (typeof evidencePath !== 'string' || !isAbsolute(evidencePath)) return undefined; + let handle; + try { + handle = await open(evidencePath, 'r'); + const before = await handle.stat(); + if (!before.isFile() || before.size <= 0 || before.size > CHILD_CAPTURE_MAX_BYTES) return undefined; + const bytes = Buffer.alloc(before.size); + let offset = 0; + while (offset < bytes.byteLength) { + const result = await handle.read(bytes, offset, bytes.byteLength - offset, offset); + if (!Number.isSafeInteger(result.bytesRead) || result.bytesRead <= 0) return undefined; + offset += result.bytesRead; + } + const after = await handle.stat(); + if (after.dev !== before.dev || after.ino !== before.ino || after.size !== before.size) return undefined; + const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + if (!text.endsWith('\n')) return undefined; + let lastMilestone; + for (const line of text.slice(0, -1).split('\n')) { + let record; + try { record = JSON.parse(line); } catch { continue; } + if (!record || typeof record !== 'object' || Array.isArray(record) + || Object.keys(record).length !== 1 || typeof record.event !== 'string') continue; + const milestone = failureMilestones.get(record.event); + if (milestone) lastMilestone = milestone; + } + return lastMilestone; + } catch { + return undefined; + } finally { + await handle?.close().catch(() => undefined); + } }; const createRecordCapture = ({ sensitiveNeedles, onRecord, onSensitiveOutput }) => { @@ -366,11 +393,13 @@ export const runPackagedConnectLifecycle = async ({ terminationTimeoutMs = 10_000, streamDrainTimeoutMs = 5_000, requestShutdown = () => undefined, + readFailureMilestone, }) => { const records = []; const first = deferred(); let firstSettled = false; let invalidReadyObserved = false; + let readyRecordCount = 0; let child; const settleFirst = value => { if (firstSettled) return; @@ -383,6 +412,7 @@ export const runPackagedConnectLifecycle = async ({ onRecord: record => { if (records.length < RECORD_MAX_COUNT) records.push(record); if (record.event !== CONNECT_READY_EVENT) return; + readyRecordCount += 1; const valid = isExactReadyRecord(record, { platform, arch, authorityMechanism }); if (!valid) invalidReadyObserved = true; settleFirst(valid ? { category: 'ready' } : { category: 'ready-validation' }); @@ -432,11 +462,9 @@ export const runPackagedConnectLifecycle = async ({ }); close = await waitForClose(child, streamDrainTimeoutMs); streamsDrained = await drainChildStreams(child, streamDrainTimeoutMs); - primary = closeIsClean(close) && streamsDrained - ? 'ready-clean-exit' - : terminationSucceeded && close.closed && streamsDrained - ? 'ready-forced-exit' - : 'tree-termination'; + primary = terminationSucceeded && close.closed && streamsDrained + ? 'child-remained-alive' + : 'tree-termination'; } } else if (primary === 'child-exit') { primary = 'child-exit-before-ready'; @@ -454,10 +482,12 @@ export const runPackagedConnectLifecycle = async ({ if (!streamsDrained) streamsDrained = await drainChildStreams(child, streamDrainTimeoutMs); capture.finish(); const captureResult = capture.result(); - if (primary === 'ready-clean-exit' || primary === 'ready-forced-exit') { + if (primary === 'ready-clean-exit') { if (captureResult.sensitiveOutput || captureResult.capture === 'truncated') { primary = 'output-rejected'; - } else if (invalidReadyObserved) primary = 'ready-validation'; + } else if (invalidReadyObserved) { + primary = 'ready-validation'; + } else if (readyRecordCount !== 1) primary = 'ready-duplicate'; } const secondary = []; if (terminationAttempted && !terminationSucceeded && primary !== 'ready-clean-exit') { @@ -470,11 +500,27 @@ export const runPackagedConnectLifecycle = async ({ child.stderr?.destroy(); child.unref?.(); } + let lastMilestone; + const failureDiagnosticsAuthorized = primary !== 'ready-clean-exit' + && close?.closed + && streamsDrained + && (!terminationAttempted || terminationSucceeded); + if (failureDiagnosticsAuthorized && typeof readFailureMilestone === 'function') { + try { + const boundedMilestone = await withTimeout( + Promise.resolve().then(() => readFailureMilestone()), + streamDrainTimeoutMs, + ); + const candidate = boundedMilestone.timedOut ? undefined : boundedMilestone.value; + if (allowedFailureMilestones.has(candidate)) lastMilestone = candidate; + } catch { /* Diagnostic attribution cannot replace the primary lifecycle result. */ } + } return { - ok: primary === 'ready-clean-exit' || primary === 'ready-forced-exit', + ok: primary === 'ready-clean-exit', category: primary, capture: captureResult.capture, records: boundedChildDiagnostics(records), + ...(lastMilestone ? { lastMilestone } : {}), ...(secondary.length ? { secondary } : {}), }; }; diff --git a/apps/desktop/scripts/packaged-connect-lifecycle.test.mjs b/apps/desktop/scripts/packaged-connect-lifecycle.test.mjs index 65c9639e0..ae32e50d0 100644 --- a/apps/desktop/scripts/packaged-connect-lifecycle.test.mjs +++ b/apps/desktop/scripts/packaged-connect-lifecycle.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; -import { lstat, mkdtemp, realpath, rm } from 'node:fs/promises'; +import { lstat, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { PassThrough } from 'node:stream'; @@ -10,6 +10,7 @@ import { CONNECT_READY_EVENT, isExactReadyRecord, preservePrimaryWithCleanup, + readPackagedConnectFailureMilestone, removeAuthorizedConnectFixture, runPackagedConnectLifecycle, } from './packaged-connect-lifecycle.mjs'; @@ -106,7 +107,7 @@ describe('packaged Connect bounded child lifecycle', () => { assert.equal(invocations.length, 1); }); - test('forces a ready app with a hung descendant through an exact bounded taskkill invocation', async () => { + test('terminates but rejects a ready app that remains alive past the shutdown bound', async () => { const { result, invocations } = await run({ onApp: app => app.write(readyRecord()), onKiller: (killer, app) => { @@ -114,8 +115,8 @@ describe('packaged Connect bounded child lifecycle', () => { killer.close(0, null); }, }); - assert.equal(result.ok, true); - assert.equal(result.category, 'ready-forced-exit'); + assert.equal(result.ok, false); + assert.equal(result.category, 'child-remained-alive'); assert.equal(invocations.length, 2); assert.deepEqual(invocations[1].args, ['/PID', '4242', '/T', '/F']); assert.equal(invocations[1].options.shell, false); @@ -159,7 +160,7 @@ describe('packaged Connect bounded child lifecycle', () => { assert.equal(result.ok, false); }); - test('accepts a clean post-proof close racing a taskkill no-process result', async () => { + test('rejects a post-bound close racing a taskkill no-process result', async () => { const { result } = await run({ onApp: app => app.write(readyRecord()), onKiller: (killer, app) => { @@ -167,12 +168,9 @@ describe('packaged Connect bounded child lifecycle', () => { killer.close(128, null); }, }); - assert.deepEqual(result, { - ok: true, - category: 'ready-clean-exit', - capture: 'complete', - records: [{ event: CONNECT_READY_EVENT }], - }); + assert.equal(result.ok, false); + assert.equal(result.category, 'tree-termination'); + assert.deepEqual(result.secondary, ['tree-termination-failed']); }); test('rejects malformed, partial, truncated, and extra-field ready records', async () => { @@ -208,6 +206,22 @@ describe('packaged Connect bounded child lifecycle', () => { assert.deepEqual(result.records, [{ event: CONNECT_READY_EVENT }]); }); + test('rejects duplicate exact READY records after a clean close', async () => { + const { result } = await run({ + onApp: app => { + app.write(readyRecord()); + app.write(readyRecord({ timestamp: '2026-09-01T22:00:01.000Z' })); + queueMicrotask(() => app.close(0, null)); + }, + }); + assert.equal(result.ok, false); + assert.equal(result.category, 'ready-duplicate'); + assert.deepEqual(result.records, [ + { event: CONNECT_READY_EVENT }, + { event: CONNECT_READY_EVENT }, + ]); + }); + test('fails after proof when Windows tree termination cannot be proven', async () => { const { result } = await run({ onApp: app => app.write(readyRecord()), @@ -337,6 +351,67 @@ describe('packaged Connect bounded child lifecycle', () => { }); }); +describe('packaged Connect fixed failure milestone attribution', () => { + test('reads only the last allowlisted event-only milestone from bounded evidence', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-connect-milestone-')); + const evidence = join(directory, 'application.smoke-evidence.jsonl'); + try { + await writeFile(evidence, [ + JSON.stringify({ event: 'desktop.app.ready' }), + JSON.stringify({ event: 'desktop.renderer.ready' }), + JSON.stringify({ event: 'desktop.renderer.connect_discovery.proof' }), + JSON.stringify({ event: CONNECT_READY_EVENT, path: privateWindowsPath }), + JSON.stringify({ event: 'untrusted.event' }), + '', + ].join('\n')); + assert.equal(await readPackagedConnectFailureMilestone(evidence), 'connect-proof'); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + test('reads diagnostics only after failed lifecycle termination and stream drain', async () => { + let diagnosticReads = 0; + const { result } = await run({ + onKiller: (killer, app) => { + app.close(null, 'SIGKILL'); + killer.close(0, null); + }, + readFailureMilestone: async () => { + diagnosticReads += 1; + return 'connect-proof'; + }, + }); + assert.equal(diagnosticReads, 1); + assert.equal(result.category, 'timeout-before-ready'); + assert.equal(result.lastMilestone, 'connect-proof'); + }); + + test('does not read evidence after success or unproven tree termination', async () => { + let diagnosticReads = 0; + const readFailureMilestone = async () => { + diagnosticReads += 1; + return privateWindowsPath; + }; + const success = await run({ + onApp: app => { + app.write(readyRecord()); + queueMicrotask(() => app.close(0, null)); + }, + readFailureMilestone, + }); + assert.equal(success.result.ok, true); + const failedTermination = await run({ + onKiller: killer => killer.close(1, null), + readFailureMilestone, + }); + assert.equal(failedTermination.result.category, 'timeout-before-ready'); + assert.equal(diagnosticReads, 0); + assert.equal(failedTermination.result.lastMilestone, undefined); + assert.doesNotMatch(JSON.stringify(failedTermination.result), /private-user|SENTINEL/u); + }); +}); + describe('packaged Connect fixture cleanup', () => { const fixture = '/canonical-temp/propr-desktop-connect-smoke-AbC123'; const stats = { isDirectory: () => true, isSymbolicLink: () => false }; diff --git a/apps/desktop/scripts/packaged-connect-ready-child.mjs b/apps/desktop/scripts/packaged-connect-ready-child.mjs new file mode 100644 index 000000000..c00118e73 --- /dev/null +++ b/apps/desktop/scripts/packaged-connect-ready-child.mjs @@ -0,0 +1,15 @@ +import { + createConnectReadyPublisher, + createConnectReadyRecord, +} from './packaged-connect-ready.mjs'; + +if (process.platform !== 'win32' || !['x64', 'arm64'].includes(process.arch)) process.exit(2); + +const expected = { + platform: process.platform, + arch: process.arch, + authorityMechanism: 'inherited-standard-handle', +}; +const result = createConnectReadyPublisher().publish(createConnectReadyRecord(expected), expected); +if (!result.ok) process.exit(3); +if (process.argv[2] === 'remain-alive') setInterval(() => undefined, 1000); diff --git a/apps/desktop/scripts/packaged-connect-ready-regression.mjs b/apps/desktop/scripts/packaged-connect-ready-regression.mjs new file mode 100644 index 000000000..4b772aa98 --- /dev/null +++ b/apps/desktop/scripts/packaged-connect-ready-regression.mjs @@ -0,0 +1,93 @@ +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + CONNECT_READY_EVENT, + createConnectReadyPublisher, + createConnectReadyRecord, + isExactConnectReadyRecord, +} from './packaged-connect-ready.mjs'; +import { runPackagedConnectLifecycle } from './packaged-connect-lifecycle.mjs'; + +const childFixture = fileURLToPath(new URL('./packaged-connect-ready-child.mjs', import.meta.url)); + +/** Run inside the ordinary-user Windows gate so Node owns the actual inherited pipe. */ +export const verifyNativeWindowsReadyPipeRegression = async ({ treeKillerPath }) => { + if (process.platform !== 'win32') return; + const expected = { + platform: process.platform, + arch: process.arch, + authorityMechanism: 'inherited-standard-handle', + }; + const record = createConnectReadyRecord(expected); + const chunks = []; + const partial = createConnectReadyPublisher({ + writeSync(_descriptor, bytes, offset, length) { + const progress = Math.min(7, length); + chunks.push(Buffer.from(bytes.subarray(offset, offset + progress))); + return progress; + }, + }); + if (!partial.publish(record, expected).ok) { + throw new Error('Native Windows READY partial-write regression failed'); + } + const partialOutput = Buffer.concat(chunks).toString('utf8'); + let parsedPartial; + try { parsedPartial = JSON.parse(partialOutput); } catch { /* Fixed failure below. */ } + if (!partialOutput.endsWith('\n') || partialOutput.slice(0, -1).includes('\n') + || !isExactConnectReadyRecord(parsedPartial, expected)) { + throw new Error('Native Windows READY partial-write regression failed'); + } + const zeroProgress = createConnectReadyPublisher({ writeSync: () => 0 }).publish(record, expected); + if (zeroProgress.ok || zeroProgress.category !== 'zero-progress') { + throw new Error('Native Windows READY zero-progress regression failed'); + } + const brokenPipe = createConnectReadyPublisher({ + writeSync() { throw new Error('discarded'); }, + }).publish(record, expected); + if (brokenPipe.ok || brokenPipe.category !== 'broken-pipe') { + throw new Error('Native Windows READY broken-pipe regression failed'); + } + const duplicateBytes = []; + const duplicate = createConnectReadyPublisher({ + writeSync(_descriptor, bytes, offset, length) { + duplicateBytes.push(Buffer.from(bytes.subarray(offset, offset + length))); + return length; + }, + }); + if (!duplicate.publish(record, expected).ok + || duplicate.publish(record, expected).category !== 'duplicate' + || Buffer.concat(duplicateBytes).toString('utf8').trimEnd().split('\n').length !== 1) { + throw new Error('Native Windows READY duplicate regression failed'); + } + let invalidWriteAttempted = false; + const wrongSchema = createConnectReadyPublisher({ + writeSync() { invalidWriteAttempted = true; return 1; }, + }).publish({ ...record, rendererSchemaValid: 'true' }, expected); + if (wrongSchema.ok || wrongSchema.category !== 'schema' || invalidWriteAttempted) { + throw new Error('Native Windows READY schema regression failed'); + } + const run = behavior => runPackagedConnectLifecycle({ + binaryPath: process.execPath, + args: [childFixture, behavior], + env: {}, + cwd: dirname(childFixture), + ...expected, + treeKillerPath, + readyTimeoutMs: 5_000, + shutdownGraceMs: 100, + terminationTimeoutMs: 5_000, + streamDrainTimeoutMs: 2_000, + }); + const clean = await run('clean'); + if (!clean.ok || clean.category !== 'ready-clean-exit' + || clean.capture !== 'complete' + || clean.records.length !== 1 + || clean.records[0]?.event !== CONNECT_READY_EVENT) { + throw new Error('Native Windows READY pipe clean-close regression failed'); + } + const remainedAlive = await run('remain-alive'); + if (remainedAlive.ok || remainedAlive.category !== 'child-remained-alive' + || remainedAlive.secondary !== undefined) { + throw new Error('Native Windows READY pipe live-child regression failed'); + } +}; diff --git a/apps/desktop/scripts/packaged-connect-ready.d.mts b/apps/desktop/scripts/packaged-connect-ready.d.mts new file mode 100644 index 000000000..84294ed3d --- /dev/null +++ b/apps/desktop/scripts/packaged-connect-ready.d.mts @@ -0,0 +1,38 @@ +export const CONNECT_READY_EVENT: 'desktop.renderer.connect_discovery.ready'; +export const CONNECT_READY_MAX_BYTES: number; + +export interface ConnectReadyExpected { + platform: string; + arch: string; + authorityMechanism: string; +} + +export interface ConnectReadyRecord { + timestamp: string; + level: 'info'; + event: typeof CONNECT_READY_EVENT; + selectedPlatform: string; + selectedArch: string; + authorityMechanism: string; + rendererSchemaValid: true; +} + +export type ConnectReadyPublication = + | { ok: true; byteLength: number } + | { ok: false; category: 'duplicate' | 'schema' | 'byte-bound' | 'broken-pipe' | 'zero-progress' }; + +export function isExactConnectReadyRecord( + record: unknown, + expected: ConnectReadyExpected, +): record is ConnectReadyRecord; + +export function createConnectReadyRecord( + expected: ConnectReadyExpected & { timestamp?: string }, +): ConnectReadyRecord; + +export function createConnectReadyPublisher(options?: { + writeSync?: (fd: number, buffer: Buffer, offset: number, length: number) => number; + maximumBytes?: number; +}): { + publish(record: unknown, expected: ConnectReadyExpected): ConnectReadyPublication; +}; diff --git a/apps/desktop/scripts/packaged-connect-ready.mjs b/apps/desktop/scripts/packaged-connect-ready.mjs new file mode 100644 index 000000000..f53a87b90 --- /dev/null +++ b/apps/desktop/scripts/packaged-connect-ready.mjs @@ -0,0 +1,78 @@ +import { writeSync as nodeWriteSync } from 'node:fs'; + +export const CONNECT_READY_EVENT = 'desktop.renderer.connect_discovery.ready'; +export const CONNECT_READY_MAX_BYTES = 1024; + +const exactKeys = (record, expected) => { + const actual = Object.keys(record).sort(); + return actual.length === expected.length && actual.every((key, index) => key === expected[index]); +}; + +export const isExactConnectReadyRecord = (record, { platform, arch, authorityMechanism }) => { + if (!record || typeof record !== 'object' || Array.isArray(record)) return false; + if (!exactKeys(record, [ + 'authorityMechanism', 'event', 'level', 'rendererSchemaValid', + 'selectedArch', 'selectedPlatform', 'timestamp', + ])) return false; + return record.event === CONNECT_READY_EVENT + && record.level === 'info' + && typeof record.timestamp === 'string' + && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u.test(record.timestamp) + && record.selectedPlatform === platform + && record.selectedArch === arch + && record.authorityMechanism === authorityMechanism + && record.rendererSchemaValid === true; +}; + +export const createConnectReadyRecord = ({ + platform, + arch, + authorityMechanism, + timestamp = new Date().toISOString(), +}) => ({ + timestamp, + level: 'info', + event: CONNECT_READY_EVENT, + selectedPlatform: platform, + selectedArch: arch, + authorityMechanism, + rendererSchemaValid: true, +}); + +/** + * Publish the smoke-only READY authority once through fd 1. The result contains + * only fixed classifications: OS error details and written bytes never escape. + */ +export const createConnectReadyPublisher = ({ + writeSync = nodeWriteSync, + maximumBytes = CONNECT_READY_MAX_BYTES, +} = {}) => { + let attempted = false; + return { + publish(record, expected) { + if (attempted) return { ok: false, category: 'duplicate' }; + attempted = true; + if (!isExactConnectReadyRecord(record, expected)) { + return { ok: false, category: 'schema' }; + } + const bytes = Buffer.from(`${JSON.stringify(record)}\n`, 'utf8'); + if (bytes.byteLength <= 1 || bytes.byteLength > maximumBytes) { + return { ok: false, category: 'byte-bound' }; + } + let offset = 0; + while (offset < bytes.byteLength) { + let written; + try { + written = writeSync(1, bytes, offset, bytes.byteLength - offset); + } catch { + return { ok: false, category: 'broken-pipe' }; + } + if (!Number.isSafeInteger(written) || written <= 0 || written > bytes.byteLength - offset) { + return { ok: false, category: 'zero-progress' }; + } + offset += written; + } + return { ok: true, byteLength: bytes.byteLength }; + }, + }; +}; diff --git a/apps/desktop/scripts/packaged-connect-ready.test.mjs b/apps/desktop/scripts/packaged-connect-ready.test.mjs new file mode 100644 index 000000000..93251b8a8 --- /dev/null +++ b/apps/desktop/scripts/packaged-connect-ready.test.mjs @@ -0,0 +1,148 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, test } from 'node:test'; +import { + createConnectReadyPublisher, + createConnectReadyRecord, + isExactConnectReadyRecord, +} from './packaged-connect-ready.mjs'; +import { runPackagedConnectLifecycle } from './packaged-connect-lifecycle.mjs'; + +const expected = Object.freeze({ + platform: 'win32', + arch: 'x64', + authorityMechanism: 'inherited-standard-handle', +}); +const record = () => createConnectReadyRecord({ + ...expected, + timestamp: '2026-09-02T15:27:57.000Z', +}); + +describe('packaged Connect READY fd-1 publisher', () => { + test('completes partial writes and publishes one exact UTF-8 line', () => { + const chunks = []; + const publisher = createConnectReadyPublisher({ + writeSync(fd, bytes, offset, length) { + assert.equal(fd, 1); + const progress = Math.min(7, length); + chunks.push(Buffer.from(bytes.subarray(offset, offset + progress))); + return progress; + }, + }); + const result = publisher.publish(record(), expected); + assert.equal(result.ok, true); + const output = Buffer.concat(chunks).toString('utf8'); + assert.equal(output.endsWith('\n'), true); + assert.equal(output.slice(0, -1).includes('\n'), false); + assert.equal(isExactConnectReadyRecord(JSON.parse(output), expected), true); + }); + + test('classifies zero progress without retrying indefinitely', () => { + let calls = 0; + const result = createConnectReadyPublisher({ + writeSync() { calls += 1; return 0; }, + }).publish(record(), expected); + assert.deepEqual(result, { ok: false, category: 'zero-progress' }); + assert.equal(calls, 1); + }); + + test('classifies a broken pipe without exposing exception text', () => { + const result = createConnectReadyPublisher({ + writeSync() { throw new Error('private-pipe-path-SENTINEL'); }, + }).publish(record(), expected); + assert.deepEqual(result, { ok: false, category: 'broken-pipe' }); + assert.doesNotMatch(JSON.stringify(result), /private|SENTINEL/u); + }); + + test('rejects a duplicate publication and writes no duplicate bytes', () => { + const chunks = []; + const publisher = createConnectReadyPublisher({ + writeSync(_fd, bytes, offset, length) { + chunks.push(Buffer.from(bytes.subarray(offset, offset + length))); + return length; + }, + }); + assert.equal(publisher.publish(record(), expected).ok, true); + assert.deepEqual(publisher.publish(record(), expected), { ok: false, category: 'duplicate' }); + assert.equal(Buffer.concat(chunks).toString('utf8').trimEnd().split('\n').length, 1); + }); + + test('rejects the wrong schema before writing fd 1', () => { + let called = false; + const publisher = createConnectReadyPublisher({ + writeSync() { called = true; return 1; }, + }); + assert.deepEqual(publisher.publish({ ...record(), rendererSchemaValid: 'true' }, expected), { + ok: false, + category: 'schema', + }); + assert.equal(called, false); + }); + + test('rejects the exact record before writing when the byte bound is exceeded', () => { + let called = false; + const result = createConnectReadyPublisher({ + maximumBytes: 1, + writeSync() { called = true; return 1; }, + }).publish(record(), expected); + assert.deepEqual(result, { ok: false, category: 'byte-bound' }); + assert.equal(called, false); + }); + + test('keeps the native pipe regression in front of the real packaged lifecycle', () => { + const smoke = readFileSync(fileURLToPath(new URL('./smoke-packaged-connect.mjs', import.meta.url)), 'utf8'); + const regression = smoke.indexOf('await verifyNativeWindowsReadyPipeRegression({ treeKillerPath });'); + const lifecycle = smoke.indexOf('outcome = await runPackagedConnectLifecycle({'); + assert.ok(regression !== -1 && regression < lifecycle); + assert.doesNotMatch(smoke.slice(lifecycle, smoke.indexOf(' });', lifecycle)), /readyTimeoutMs/u); + const lifecycleSource = readFileSync( + fileURLToPath(new URL('./packaged-connect-lifecycle.mjs', import.meta.url)), + 'utf8', + ); + assert.match(lifecycleSource, /readyTimeoutMs = 240_000/u); + }); +}); + +const windowsTreeKiller = process.platform === 'win32' && process.env.SystemRoot + ? join(process.env.SystemRoot, 'System32', 'taskkill.exe') + : undefined; +const childFixture = fileURLToPath(new URL('./packaged-connect-ready-child.mjs', import.meta.url)); + +describe('native Windows inherited READY pipe', { skip: process.platform !== 'win32' }, () => { + const nativeExpected = { + platform: 'win32', + arch: process.arch, + authorityMechanism: 'inherited-standard-handle', + }; + const runNative = behavior => runPackagedConnectLifecycle({ + binaryPath: process.execPath, + args: [childFixture, behavior], + env: {}, + cwd: fileURLToPath(new URL('.', import.meta.url)), + ...nativeExpected, + treeKillerPath: windowsTreeKiller, + readyTimeoutMs: 5_000, + shutdownGraceMs: 100, + terminationTimeoutMs: 5_000, + streamDrainTimeoutMs: 2_000, + }); + + test('receives one exact line from an actual child and closes cleanly', async () => { + const result = await runNative('clean'); + assert.deepEqual(result, { + ok: true, + category: 'ready-clean-exit', + capture: 'complete', + records: [{ event: 'desktop.renderer.connect_discovery.ready' }], + }); + }); + + test('terminates but rejects a child that remains alive after READY', async () => { + const result = await runNative('remain-alive'); + assert.equal(result.ok, false); + assert.equal(result.category, 'child-remained-alive'); + assert.equal(result.secondary, undefined); + }); +}); diff --git a/apps/desktop/scripts/smoke-packaged-connect.mjs b/apps/desktop/scripts/smoke-packaged-connect.mjs index bc5dcd743..bcb1e0976 100644 --- a/apps/desktop/scripts/smoke-packaged-connect.mjs +++ b/apps/desktop/scripts/smoke-packaged-connect.mjs @@ -7,9 +7,11 @@ import { tmpdir } from 'node:os'; import { basename, dirname, join, relative, resolve } from 'node:path'; import { preservePrimaryWithCleanup, + readPackagedConnectFailureMilestone, removeAuthorizedConnectFixture, runPackagedConnectLifecycle, } from './packaged-connect-lifecycle.mjs'; +import { verifyNativeWindowsReadyPipeRegression } from './packaged-connect-ready-regression.mjs'; import { canonicalizeWindowsFixtureEntry, encodedWindowsFixtureAcl, @@ -229,6 +231,8 @@ try { failurePhase = 'package-validation'; await assertPackageAuthority(); const treeKillerPath = await windowsTreeKiller(); + failurePhase = 'ready-pipe-regression'; + await verifyNativeWindowsReadyPipeRegression({ treeKillerPath }); const sensitiveNeedles = [ ...secrets, fixture, configRoot, stackRoot, identity, 'S-1-5-', 'volumeSerialNumber', 'fileId', 'authorityDiagnostic', @@ -242,6 +246,9 @@ try { authorityMechanism: authorityMechanism(), sensitiveNeedles, treeKillerPath, + readFailureMilestone: () => readPackagedConnectFailureMilestone( + join(userDataPath, 'application.smoke-evidence.jsonl'), + ), env: { ...process.env, PROPR_DESKTOP_CONNECT_SMOKE_TEST: '1', @@ -273,6 +280,7 @@ try { category: outcome.category, capture: outcome.capture, records: outcome.records, + ...(outcome.lastMilestone ? { lastMilestone: outcome.lastMilestone } : {}), ...(outcome.secondary?.length ? { secondary: outcome.secondary } : {}), })}\n`); process.exitCode = 1; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 7065b87b9..d2b7ac387 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -37,6 +37,11 @@ import { DESKTOP_PROTOCOL, IPC_CHANNELS } from './shared/contract'; import { checkForSignedUpdates } from './signed-updates'; import { authorizePackagedSmokeTest } from './smoke-test-authorization'; import { createPackagedSmokeEvidenceSink } from './smoke-test-evidence'; +import { + CONNECT_READY_EVENT, + createConnectReadyPublisher, + createConnectReadyRecord, +} from '../scripts/packaged-connect-ready.mjs'; import { createBrowserWindowOptions, MINIMUM_BROWSER_WINDOW_SIZE, @@ -83,6 +88,7 @@ const deepLinkDelivery = new DeepLinkDelivery( ); let logger: DesktopLogger | null = null; let shutdownStarted = false; +const packagedConnectReadyPublisher = createConnectReadyPublisher(); if (process.platform === 'win32') { app.setAppUserModelId('dev.propr.desktop'); } @@ -364,16 +370,21 @@ const runPackagedConnectDiscoverySmoke = async (window: BrowserWindow): Promise< || candidate.apiBaseUrl !== 'https://t-packaged123.propr.dev') { throw new Error('Packaged Connect renderer discovery proof was invalid'); } - log('info', 'desktop.renderer.connect_discovery.ready', { - selectedPlatform: process.platform, - selectedArch: process.arch, - authorityMechanism: process.platform === 'darwin' - ? 'packaged-broker' - : process.platform === 'linux' - ? 'in-process-native-addon' - : 'inherited-standard-handle', - rendererSchemaValid: true, - }); + packagedSmokeEvidence?.write('desktop.renderer.connect_discovery.proof'); + const authorityMechanism = process.platform === 'darwin' + ? 'packaged-broker' + : process.platform === 'linux' + ? 'in-process-native-addon' + : 'inherited-standard-handle'; + const expected = { platform: process.platform, arch: process.arch, authorityMechanism }; + const publication = packagedConnectReadyPublisher.publish( + createConnectReadyRecord(expected), + expected, + ); + if (!publication.ok) { + throw new Error(`Packaged Connect READY publication failed: ${publication.category}`); + } + packagedSmokeEvidence?.write(CONNECT_READY_EVENT); }; const runPackagedTransportSmoke = async ( diff --git a/apps/desktop/src/smoke-test-evidence.test.ts b/apps/desktop/src/smoke-test-evidence.test.ts index d0ff7beea..140603040 100644 --- a/apps/desktop/src/smoke-test-evidence.test.ts +++ b/apps/desktop/src/smoke-test-evidence.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { describe, it } from 'node:test'; import { createPackagedSmokeEvidenceSink, + PACKAGED_CONNECT_SMOKE_EVIDENCE_EVENTS, PACKAGED_SMOKE_EVIDENCE_EVENTS, PACKAGED_SMOKE_EVIDENCE_FILE, } from './smoke-test-evidence'; @@ -73,4 +74,20 @@ describe('packaged smoke evidence', () => { assert.ok(Buffer.byteLength(contents, 'utf8') < 1024); }); }); + + it('retains only the two fixed Connect attribution milestones', () => { + withSmokeDirectory(directory => { + const sink = createPackagedSmokeEvidenceSink(directory); + assert.ok(sink); + for (const event of PACKAGED_CONNECT_SMOKE_EVIDENCE_EVENTS) sink.write(event); + sink.write('desktop.renderer.connect_discovery.private-value'); + sink.close(); + + const contents = readFileSync(join(directory, PACKAGED_SMOKE_EVIDENCE_FILE), 'utf8'); + assert.deepEqual(contents.trimEnd().split('\n').map(line => JSON.parse(line)), [ + { event: 'desktop.renderer.connect_discovery.proof' }, + { event: 'desktop.renderer.connect_discovery.ready' }, + ]); + }); + }); }); diff --git a/apps/desktop/src/smoke-test-evidence.ts b/apps/desktop/src/smoke-test-evidence.ts index a9d26bfb6..8ad43448b 100644 --- a/apps/desktop/src/smoke-test-evidence.ts +++ b/apps/desktop/src/smoke-test-evidence.ts @@ -23,9 +23,19 @@ export const PACKAGED_SMOKE_EVIDENCE_EVENTS = [ 'desktop.log.write_failed', ] as const; -export type PackagedSmokeEvidenceEvent = typeof PACKAGED_SMOKE_EVIDENCE_EVENTS[number]; +export const PACKAGED_CONNECT_SMOKE_EVIDENCE_EVENTS = [ + 'desktop.renderer.connect_discovery.proof', + 'desktop.renderer.connect_discovery.ready', +] as const; + +export type PackagedSmokeEvidenceEvent = + | typeof PACKAGED_SMOKE_EVIDENCE_EVENTS[number] + | typeof PACKAGED_CONNECT_SMOKE_EVIDENCE_EVENTS[number]; -const allowedEvents = new Set(PACKAGED_SMOKE_EVIDENCE_EVENTS); +const allowedEvents = new Set([ + ...PACKAGED_SMOKE_EVIDENCE_EVENTS, + ...PACKAGED_CONNECT_SMOKE_EVIDENCE_EVENTS, +]); export interface PackagedSmokeEvidenceSink { write(event: string): void; From 422fadd219b5214a83e60b46732ec1208c8b92e9 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:52:52 +0000 Subject: [PATCH 2/8] feat(ai): Fixed the CI failure in [package-lock.json](/tmp/git-processor/worktrees/integry/propr/pr-2069-followup-2026-09-02T15-49-05/package-lock.json). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed the CI failure in [package-lock.json](/tmp/git-processor/worktrees/integry/propr/pr-2069-followup-2026-09-02T15-49-05/package-lock.json). Root cause: the [failed audit step](https://github.com/integry/propr/actions/runs/33650933723/job/100317490402) flagged `qs@6.15.3`. Updated the lockfile to patched `qs@6.16.0`. Verified: - `npm run audit:runtime` — 0 vulnerabilities - `npm run desktop:audit:packaging` — 0 vulnerabilities - Clean `npm ci --ignore-scripts` succeeds - `git diff --check` passes - Only `package-lock.json` changed; no commit created. PR: #2069 Comment by: @propr-dev[bot] (ID: 5512318317) Model: gpt-5.6-sol --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6c50b90c4..a27ac651a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12020,9 +12020,9 @@ } }, "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", From bde1c5ea3f4e2a9eb410a5cedd81f76f50b92b57 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:33:15 +0000 Subject: [PATCH 3/8] feat(ai): Implemented the Windows authority-inspection ultrafix without committing. Implemented the Windows authority-inspection ultrafix without committing. Key changes: - Replaced serial `spawnSync` inspection with concurrent asynchronous PowerShell brokers sharing one 60-second wall deadline. - Preserved pinned descriptors as inherited stdin handles and removed target metadata from broker command lines. - Added aggregate output/entry bounds, deterministic ordering, identity and executable revalidation, sibling termination/drain, and fixed `spawn:cleanup`. - Added x64/ARM64 native PowerShell regressions for slow concurrency, hangs, early failure, overflow, reordered/missing/duplicate results, and zero remaining brokers. - Removed top-level `lastMilestone`; milestones now remain bounded diagnostic records. - Preserved synchronous exact-byte READY publication and the 240-second parent deadline. Verification: - Desktop suite: 348 passed, 7 platform-skipped. - Platform-safe Connect: 88/88 passed. - Focused authority/harness: 23/23 passed. - CLI typecheck and lint passed. - `git diff --check` passed. Native packaged Windows runs cannot execute on this Linux host, so no new x64/ARM64 job IDs or remaining fixed stages are available. The existing workflow now runs the new regressions and ordinary-user packaged smoke on both architectures. PR: #2069 Comment by: @integry (ID: 5512533916) Model: gpt-5.6-sol --- .../desktop-connect-discovery-guard.yml | 4 + .../scripts/packaged-connect-lifecycle.mjs | 17 +- .../packaged-connect-lifecycle.test.mjs | 5 +- .../scripts/smoke-packaged-connect.mjs | 3 +- .../windows-authority-batch-regression.mjs | 136 +++++++++ packages/cli/src/connectRootAuthority.test.ts | 163 +++++++++-- packages/cli/src/connectRootAuthority.ts | 2 +- packages/cli/src/connectWindowsAuthority.ts | 273 ++++++++++++++---- scripts/verify-platform-safe-connect.mjs | 8 +- .../verify-windows-standard-user-connect.mjs | 5 +- test/fixtures/windowsConnectProcessMock.mjs | 114 ++++++-- .../windowsStandardUserConnectHarness.test.ts | 53 ++-- 12 files changed, 633 insertions(+), 150 deletions(-) create mode 100644 apps/desktop/scripts/windows-authority-batch-regression.mjs diff --git a/.github/workflows/desktop-connect-discovery-guard.yml b/.github/workflows/desktop-connect-discovery-guard.yml index 6290ab9f7..b98f6808e 100644 --- a/.github/workflows/desktop-connect-discovery-guard.yml +++ b/.github/workflows/desktop-connect-discovery-guard.yml @@ -82,6 +82,10 @@ jobs: - name: Package the target-native desktop app run: npm run desktop:package + - name: Run native concurrent Windows authority broker regressions + if: matrix.platform == 'win32' + run: node apps/desktop/scripts/windows-authority-batch-regression.mjs + - name: Run packaged Linux main-to-renderer discovery if: matrix.platform == 'linux' shell: bash diff --git a/apps/desktop/scripts/packaged-connect-lifecycle.mjs b/apps/desktop/scripts/packaged-connect-lifecycle.mjs index e7861371f..9fd717d09 100644 --- a/apps/desktop/scripts/packaged-connect-lifecycle.mjs +++ b/apps/desktop/scripts/packaged-connect-lifecycle.mjs @@ -28,6 +28,8 @@ const diagnosticEvents = new Set([ 'desktop.main_process.uncaught_exception', CONNECT_READY_EVENT, 'desktop.renderer.connect_discovery.phase', + // #2056 must enumerate this event in its nested diagnostic-record allowlist. + 'desktop.renderer.connect_discovery.proof', 'desktop.renderer.connect_discovery.status', 'desktop.renderer.gone', 'desktop.renderer.ready', @@ -71,6 +73,9 @@ const failureMilestones = new Map([ [CONNECT_READY_EVENT, 'ready-publication'], ]); const allowedFailureMilestones = new Set(failureMilestones.values()); +const failureMilestoneEvents = new Map( + [...failureMilestones].map(([event, milestone]) => [milestone, event]), +); export const boundedChildDiagnostics = records => records.flatMap(record => { if (!record || typeof record !== 'object' || !diagnosticEvents.has(record.event)) return []; @@ -462,6 +467,9 @@ export const runPackagedConnectLifecycle = async ({ }); close = await waitForClose(child, streamDrainTimeoutMs); streamsDrained = await drainChildStreams(child, streamDrainTimeoutMs); + // These two categories are intentionally fixed protocol values. The + // #2056 PowerShell parser must enumerate child-remained-alive and + // ready-duplicate in its lifecycle allowlist; they are not a broad union. primary = terminationSucceeded && close.closed && streamsDrained ? 'child-remained-alive' : 'tree-termination'; @@ -500,7 +508,6 @@ export const runPackagedConnectLifecycle = async ({ child.stderr?.destroy(); child.unref?.(); } - let lastMilestone; const failureDiagnosticsAuthorized = primary !== 'ready-clean-exit' && close?.closed && streamsDrained @@ -512,7 +519,12 @@ export const runPackagedConnectLifecycle = async ({ streamDrainTimeoutMs, ); const candidate = boundedMilestone.timedOut ? undefined : boundedMilestone.value; - if (allowedFailureMilestones.has(candidate)) lastMilestone = candidate; + if (allowedFailureMilestones.has(candidate)) { + const event = failureMilestoneEvents.get(candidate); + // Evidence is appended only to the bounded diagnostic input. It never + // increments readyRecordCount and can never authorize READY. + if (event && records.length < RECORD_MAX_COUNT) records.push({ event }); + } } catch { /* Diagnostic attribution cannot replace the primary lifecycle result. */ } } return { @@ -520,7 +532,6 @@ export const runPackagedConnectLifecycle = async ({ category: primary, capture: captureResult.capture, records: boundedChildDiagnostics(records), - ...(lastMilestone ? { lastMilestone } : {}), ...(secondary.length ? { secondary } : {}), }; }; diff --git a/apps/desktop/scripts/packaged-connect-lifecycle.test.mjs b/apps/desktop/scripts/packaged-connect-lifecycle.test.mjs index ae32e50d0..1db7b3ca7 100644 --- a/apps/desktop/scripts/packaged-connect-lifecycle.test.mjs +++ b/apps/desktop/scripts/packaged-connect-lifecycle.test.mjs @@ -384,7 +384,8 @@ describe('packaged Connect fixed failure milestone attribution', () => { }); assert.equal(diagnosticReads, 1); assert.equal(result.category, 'timeout-before-ready'); - assert.equal(result.lastMilestone, 'connect-proof'); + assert.deepEqual(result.records, [{ event: 'desktop.renderer.connect_discovery.proof' }]); + assert.equal(Object.hasOwn(result, 'lastMilestone'), false); }); test('does not read evidence after success or unproven tree termination', async () => { @@ -407,7 +408,7 @@ describe('packaged Connect fixed failure milestone attribution', () => { }); assert.equal(failedTermination.result.category, 'timeout-before-ready'); assert.equal(diagnosticReads, 0); - assert.equal(failedTermination.result.lastMilestone, undefined); + assert.equal(Object.hasOwn(failedTermination.result, 'lastMilestone'), false); assert.doesNotMatch(JSON.stringify(failedTermination.result), /private-user|SENTINEL/u); }); }); diff --git a/apps/desktop/scripts/smoke-packaged-connect.mjs b/apps/desktop/scripts/smoke-packaged-connect.mjs index bcb1e0976..36cdcc64f 100644 --- a/apps/desktop/scripts/smoke-packaged-connect.mjs +++ b/apps/desktop/scripts/smoke-packaged-connect.mjs @@ -275,12 +275,13 @@ try { if (outcome.ok && cleanup.ok) { process.stdout.write(`Packaged Connect discovery passed for ${process.platform}-${process.arch}: ${authorityMechanism()}.\n`); } else { + // Keep this exact #2056 producer schema: event/category/capture/records + // with optional secondary, and no top-level milestone extension. process.stderr.write(`${JSON.stringify({ event: 'packaged_connect.smoke_failed', category: outcome.category, capture: outcome.capture, records: outcome.records, - ...(outcome.lastMilestone ? { lastMilestone: outcome.lastMilestone } : {}), ...(outcome.secondary?.length ? { secondary: outcome.secondary } : {}), })}\n`); process.exitCode = 1; diff --git a/apps/desktop/scripts/windows-authority-batch-regression.mjs b/apps/desktop/scripts/windows-authority-batch-regression.mjs new file mode 100644 index 000000000..6d9c65f39 --- /dev/null +++ b/apps/desktop/scripts/windows-authority-batch-regression.mjs @@ -0,0 +1,136 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { closeSync, mkdtempSync, openSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { performance } from 'node:perf_hooks'; + +if (process.platform !== 'win32' || !['x64', 'arm64'].includes(process.arch)) { + process.stderr.write('Windows authority batch regression requires native win32-x64 or win32-arm64.\n'); + process.exit(1); +} + +const { + parseWindowsBrokerDocument, + runWindowsInspectionBrokerBatch, + WINDOWS_INSPECTION_SOURCE, + WindowsNativeStageError, +} = await import('../../../packages/cli/dist/connectWindowsAuthority.js'); + +const systemRoot = process.env.SystemRoot; +assert.match(systemRoot ?? '', /^[A-Za-z]:\\/u); +const executable = join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); +const environment = { SystemRoot: systemRoot, WINDIR: systemRoot }; +const directory = mkdtempSync(join(tmpdir(), 'propr-authority-batch-')); +const descriptors = []; +const livePids = new Set(); + +const argumentsFor = source => [ + '-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', + '-EncodedCommand', Buffer.from(source, 'utf16le').toString('base64'), +]; + +const startPowerShell = (source, fd) => { + const child = spawn(executable, argumentsFor(source), { + shell: false, + windowsHide: true, + cwd: dirname(executable), + env: environment, + stdio: [fd, 'pipe', 'pipe'], + }); + if (Number.isSafeInteger(child.pid)) livePids.add(child.pid); + child.once('close', () => livePids.delete(child.pid)); + return child; +}; + +const assertStage = async (promise, stage) => { + await assert.rejects( + promise, + error => error instanceof WindowsNativeStageError && error.stage === stage, + ); + assert.equal(livePids.size, 0, `${stage} left a broker process alive`); +}; + +try { + for (let index = 0; index < 4; index += 1) { + const path = join(directory, `target-${index}`); + writeFileSync(path, `fixture-${index}`); + descriptors.push(openSync(path, 'r')); + } + + const deliberatelySlowInspector = `Start-Sleep -Milliseconds 1500\n${WINDOWS_INSPECTION_SOURCE}`; + const started = performance.now(); + const slow = await runWindowsInspectionBrokerBatch({ + entryCount: descriptors.length, + startBroker: index => startPowerShell(deliberatelySlowInspector, descriptors[index]), + deadlineMs: 60_000, + cleanupTimeoutMs: 5_000, + maxOutputBytes: 128 * 1024, + }); + assert.equal(slow.length, descriptors.length); + slow.forEach(output => parseWindowsBrokerDocument(output)); + assert.ok(performance.now() - started < 60_000, 'slow brokers exceeded one wall-clock bound'); + assert.equal(livePids.size, 0, 'successful slow batch left a broker process alive'); + + const reorderedSources = [80, 10, 45].map((delay, index) => ( + `Start-Sleep -Milliseconds ${delay};[Console]::Out.Write('${index}')` + )); + const reordered = await runWindowsInspectionBrokerBatch({ + entryCount: reorderedSources.length, + startBroker: index => startPowerShell(reorderedSources[index], descriptors[index]), + deadlineMs: 60_000, + cleanupTimeoutMs: 5_000, + maxOutputBytes: 128, + }); + assert.deepEqual(reordered.map(output => output.toString('utf8')), ['0', '1', '2']); + assert.equal(livePids.size, 0, 'reordered batch left a broker process alive'); + + await assertStage(runWindowsInspectionBrokerBatch({ + entryCount: 2, + startBroker: index => startPowerShell( + index === 0 ? 'Start-Sleep -Seconds 120' : "[Console]::Out.Write('sibling')", + descriptors[index], + ), + deadlineMs: 1_000, + cleanupTimeoutMs: 5_000, + maxOutputBytes: 128, + }), 'spawn:timeout'); + + await assertStage(runWindowsInspectionBrokerBatch({ + entryCount: 3, + startBroker: index => startPowerShell( + index === 0 ? 'exit 70' : 'Start-Sleep -Seconds 120', + descriptors[index], + ), + deadlineMs: 60_000, + cleanupTimeoutMs: 5_000, + maxOutputBytes: 128, + }), 'spawn:status'); + + await assertStage(runWindowsInspectionBrokerBatch({ + entryCount: 2, + startBroker: index => startPowerShell( + index === 0 ? "[Console]::Out.Write(('x'*2048))" : 'Start-Sleep -Seconds 120', + descriptors[index], + ), + deadlineMs: 60_000, + cleanupTimeoutMs: 5_000, + maxOutputBytes: 1024, + }), 'parent:utf8'); + + const validEntry = parseWindowsBrokerDocument(slow[0]); + for (const entries of [[], [validEntry, validEntry]]) assert.throws( + () => parseWindowsBrokerDocument(JSON.stringify({ version: 1, entries })), + error => error instanceof WindowsNativeStageError && error.stage === 'parent:entry-count', + ); + assert.throws( + () => parseWindowsBrokerDocument(JSON.stringify({ version: 1, entries: [{ ...validEntry, extra: true }] })), + error => error instanceof WindowsNativeStageError && error.stage === 'parent:entry-shape', + ); + + process.stdout.write(`Windows ${process.arch} authority batch regression passed.\n`); +} finally { + for (const fd of descriptors) closeSync(fd); + rmSync(directory, { recursive: true, force: true }); +} diff --git a/packages/cli/src/connectRootAuthority.test.ts b/packages/cli/src/connectRootAuthority.test.ts index 7839adbaf..02c152fd2 100644 --- a/packages/cli/src/connectRootAuthority.test.ts +++ b/packages/cli/src/connectRootAuthority.test.ts @@ -1,7 +1,11 @@ import assert from "node:assert/strict"; +import type { ChildProcess } from "node:child_process"; +import { EventEmitter } from "node:events"; import { closeSync, mkdtempSync, openSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { performance } from "node:perf_hooks"; +import { PassThrough } from "node:stream"; import { test } from "node:test"; import { assertNativeWindowsEntriesAuthority, @@ -17,7 +21,9 @@ import { } from "./connectRootAuthority.js"; import { parseWindowsNativeProbeOutput, - WINDOWS_INSPECTION_CUMULATIVE_TIMEOUT_MS, + parseWindowsBrokerDocument, + runWindowsInspectionBrokerBatch, + WINDOWS_INSPECTION_CLEANUP_TIMEOUT_MS, WINDOWS_INSPECTION_SOURCE, WINDOWS_INSPECTION_TIMEOUT_MS, WINDOWS_INSPECTOR_CREATES_CHILD_PROCESSES, @@ -29,7 +35,6 @@ import { WINDOWS_UINT64_COMPOSER_SOURCE, WINDOWS_UNSIGNED_FIELD_DECODER_SOURCE, windowsBrokerFailureStage, - windowsInspectionTimeoutForElapsed, WindowsNativeStageError, windowsNativeTimingBucket, windowsPowerShellEnvironment, @@ -38,6 +43,58 @@ import { const USER = "S-1-5-21-100-200-300-1001"; const SYSTEM = "S-1-5-18"; const ADMINISTRATORS = "S-1-5-32-544"; +let fixtureBrokerPid = 20_000; + +function fixtureBroker({ + delayMs = 0, + stdout = "", + stderr = "", + status = 0, + hang = false, + onStart = () => undefined, + onClose = () => undefined, +}: { + delayMs?: number; + stdout?: string; + stderr?: string; + status?: number; + hang?: boolean; + onStart?: () => void; + onClose?: () => void; +} = {}): ChildProcess { + const child = new EventEmitter() as EventEmitter & Record; + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.pid = fixtureBrokerPid += 1; + child.exitCode = null; + child.signalCode = null; + let closed = false; + let timer: ReturnType | undefined; + const close = (code: number | null, signal: NodeJS.Signals | null): void => { + if (closed) return; + closed = true; + if (timer !== undefined) clearTimeout(timer); + child.exitCode = code; + child.signalCode = signal; + (child.stdout as PassThrough).end(); + (child.stderr as PassThrough).end(); + onClose(); + setImmediate(() => child.emit("close", code, signal)); + }; + child.kill = (): boolean => { + close(null, "SIGKILL"); + return true; + }; + onStart(); + if (!hang) timer = setTimeout(() => { + (child.stdout as PassThrough).write(stdout); + if (closed) return; + (child.stderr as PassThrough).write(stderr); + if (closed) return; + close(status, null); + }, delayMs); + return child as unknown as ChildProcess; +} test("unpackaged Connect authority brokers reject group/other-writable modes", () => { assert.equal(isConnectAuthorityBrokerModeSafe(0o644n, false), true); @@ -197,30 +254,89 @@ test("Windows native timing uses only coarse fixed buckets", () => { assert.throws(() => windowsNativeTimingBucket(Number.NaN), WindowsNativeStageError); }); -test("Windows production inspection has one cold-start deadline and a cumulative batch cap", () => { +test("Windows production inspection has one fixed batch deadline and short cleanup bound", () => { assert.equal(WINDOWS_INSPECTION_TIMEOUT_MS, 60_000); - assert.equal(WINDOWS_INSPECTION_CUMULATIVE_TIMEOUT_MS, 240_000); + assert.equal(WINDOWS_INSPECTION_CLEANUP_TIMEOUT_MS, 5_000); assert.equal(WINDOWS_NATIVE_TIMING_PROBE_TIMEOUT_MS, 60_000); - assert.equal(WINDOWS_INSPECTION_CUMULATIVE_TIMEOUT_MS, 4 * WINDOWS_INSPECTION_TIMEOUT_MS); - assert.notEqual( - WINDOWS_INSPECTION_CUMULATIVE_TIMEOUT_MS / WINDOWS_INSPECTION_TIMEOUT_MS, - 32, - ); - assert.equal(windowsInspectionTimeoutForElapsed(0), 60_000); - assert.equal(windowsInspectionTimeoutForElapsed(60_000), 60_000); - assert.equal(windowsInspectionTimeoutForElapsed(120_000), 60_000); - assert.equal(windowsInspectionTimeoutForElapsed(180_000), 60_000); - assert.equal(windowsInspectionTimeoutForElapsed(180_001), 59_999); - assert.equal(windowsInspectionTimeoutForElapsed(210_000), 30_000); - assert.equal(windowsInspectionTimeoutForElapsed(225_000), 15_000); - assert.equal(windowsInspectionTimeoutForElapsed(239_999.9), 1); - assert.throws( - () => windowsInspectionTimeoutForElapsed(240_000), - (error) => error instanceof WindowsNativeStageError && error.stage === "spawn:cumulative-timeout", + assert.ok(WINDOWS_INSPECTION_CLEANUP_TIMEOUT_MS < WINDOWS_INSPECTION_TIMEOUT_MS); + assert.ok(WINDOWS_NATIVE_STAGE_CODES.includes("spawn:cleanup")); + assert.equal((WINDOWS_NATIVE_STAGE_CODES as readonly string[]).includes("spawn:cumulative-timeout"), false); +}); + +test("Windows broker supervisor starts slow entries concurrently and preserves input order", async () => { + let active = 0; + let maximumActive = 0; + const started = performance.now(); + const outputs = await runWindowsInspectionBrokerBatch({ + entryCount: 4, + startBroker: (index) => fixtureBroker({ + delayMs: [70, 20, 50, 35][index], + stdout: String(index), + onStart: () => { active += 1; maximumActive = Math.max(maximumActive, active); }, + onClose: () => { active -= 1; }, + }), + deadlineMs: 500, + cleanupTimeoutMs: 100, + maxOutputBytes: 64, + }); + const elapsed = performance.now() - started; + assert.deepEqual(outputs.map((value) => value.toString("utf8")), ["0", "1", "2", "3"]); + assert.equal(maximumActive, 4); + assert.equal(active, 0); + assert.ok(elapsed < 500, `concurrent batch exceeded its one wall bound: ${elapsed}ms`); +}); + +test("Windows broker supervisor terminates and drains all siblings on timeout, failure, and overflow", async () => { + const runFailure = async ( + expectedStage: string, + startBroker: (index: number, closed: () => void) => ChildProcess, + overrides: Partial[0]> = {}, + ): Promise => { + let active = 0; + let started = 0; + await assert.rejects( + runWindowsInspectionBrokerBatch({ + entryCount: 3, + startBroker: (index) => { + active += 1; + started += 1; + return startBroker(index, () => { active -= 1; }); + }, + deadlineMs: 30, + cleanupTimeoutMs: 100, + maxOutputBytes: 32, + ...overrides, + }), + (error) => error instanceof WindowsNativeStageError && error.stage === expectedStage, + ); + assert.equal(started, 3); + assert.equal(active, 0); + }; + + await runFailure("spawn:timeout", (_index, closed) => fixtureBroker({ hang: true, onClose: closed })); + await runFailure("spawn:status", (index, closed) => fixtureBroker({ + hang: index !== 0, + status: index === 0 ? 70 : 0, + onClose: closed, + }), { deadlineMs: 200 }); + await runFailure("parent:utf8", (index, closed) => fixtureBroker({ + hang: index !== 0, + stdout: index === 0 ? "x".repeat(33) : "", + onClose: closed, + }), { deadlineMs: 200 }); +}); + +test("Windows one-target broker documents reject missing, duplicate, and extra results", () => { + const { index: _index, kind: _kind, authorityKind: _authorityKind, ...entry } = inspection(); + const document = (entries: readonly unknown[]): string => JSON.stringify({ version: 1, entries }); + assert.deepEqual(parseWindowsBrokerDocument(document([entry])), entry); + for (const entries of [[], [entry, entry]]) assert.throws( + () => parseWindowsBrokerDocument(document(entries)), + (error) => error instanceof WindowsNativeStageError && error.stage === "parent:entry-count", ); assert.throws( - () => windowsInspectionTimeoutForElapsed(240_001), - (error) => error instanceof WindowsNativeStageError && error.stage === "spawn:cumulative-timeout", + () => parseWindowsBrokerDocument(document([{ ...entry, extra: true }])), + (error) => error instanceof WindowsNativeStageError && error.stage === "parent:entry-shape", ); }); @@ -330,7 +446,7 @@ test("Windows production isolates entry fields and retains private handle lifeti assert.equal(entryConstruction, [ "$stage=83", " $entry=[pscustomobject][ordered]@{", - " index=__PROPR_INDEX__;kind='__PROPR_ENTRY_KIND__';authorityKind='__PROPR_AUTHORITY_KIND__';currentUserSid=$currentSid;ownerSid=$ownerSid", + " currentUserSid=$currentSid;ownerSid=$ownerSid", " daclProtected=$daclProtected;reparsePoint=$reparsePoint", " volumeSerialNumber=$beforeVolumeDecimal", " fileId=$beforeIdDecimal", @@ -339,6 +455,7 @@ test("Windows production isolates entry fields and retains private handle lifeti " }", " ", ].join("\n")); + assert.doesNotMatch(WINDOWS_INSPECTION_SOURCE, /__PROPR_|\bindex=|\bkind=|authorityKind=/); assert.doesNotMatch(entryConstruction, /Marshal|\.ToString|InvariantCulture|@\(\$rules\)|ReferenceEquals|-band|\bfor\s*\(/); assert.doesNotMatch(composedIdentity, /ToString|\$entry=/); diff --git a/packages/cli/src/connectRootAuthority.ts b/packages/cli/src/connectRootAuthority.ts index 0f21ab92e..38e1a4624 100644 --- a/packages/cli/src/connectRootAuthority.ts +++ b/packages/cli/src/connectRootAuthority.ts @@ -385,7 +385,7 @@ async function nativeWindowsAcls( entries: readonly WindowsAuthorityTarget[], ): Promise { try { - return runWindowsReadOnlyInspection(entries); + return await runWindowsReadOnlyInspection(entries); } catch (error) { if (error instanceof WindowsNativeStageError) reportWindowsNativeStage(error.stage); throw new WindowsAuthorityInspectionError(); diff --git a/packages/cli/src/connectWindowsAuthority.ts b/packages/cli/src/connectWindowsAuthority.ts index 0ff26064e..da4e5f167 100644 --- a/packages/cli/src/connectWindowsAuthority.ts +++ b/packages/cli/src/connectWindowsAuthority.ts @@ -1,4 +1,4 @@ -import { spawnSync } from "node:child_process"; +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import { closeSync, constants, @@ -16,11 +16,10 @@ import type { } from "./connectRootAuthority.js"; // Hosted alternate-user Windows can spend more than fifteen seconds entering -// the fixed PowerShell/Reflection.Emit boundary. Each production call gets one -// bounded cold-start allowance. The cumulative cap is a fixed four-process -// proof ceiling and is independent of the 32-entry input-schema bound. +// the fixed PowerShell/Reflection.Emit boundary. All one-target brokers start +// concurrently and share this single wall-clock allowance. export const WINDOWS_INSPECTION_TIMEOUT_MS = 60_000; -export const WINDOWS_INSPECTION_CUMULATIVE_TIMEOUT_MS = 240_000; +export const WINDOWS_INSPECTION_CLEANUP_TIMEOUT_MS = 5_000; export const WINDOWS_NATIVE_TIMING_PROBE_TIMEOUT_MS = 60_000; const WINDOWS_INSPECTION_MAX_BYTES = 128 * 1024; const WINDOWS_NATIVE_PROBE_MAX_BYTES = 2 * 1024; @@ -29,7 +28,7 @@ const GLOBAL_SYSTEM_ROOT = String.raw`\\?\GLOBALROOT\SystemRoot`; export const WINDOWS_NATIVE_STAGE_CODES = Object.freeze([ "resolver:env", "resolver:canonical", "resolver:global-open", "resolver:global-id", - "spawn:create", "spawn:error", "spawn:timeout", "spawn:cumulative-timeout", "spawn:status", "spawn:stderr", + "spawn:create", "spawn:error", "spawn:timeout", "spawn:status", "spawn:stderr", "spawn:cleanup", "probe:entry", "probe:baseline", "probe:reflection-emit", "probe:win32", "probe:standard-handle", "probe:output", "broker:ps-version", "broker:job", "broker:fd", "broker:fd-duplicate", "broker:index-info-initial", "broker:security-info", "broker:acl", "broker:json", "broker:current-user-sid", @@ -213,7 +212,7 @@ try { } $stage=83 $entry=[pscustomobject][ordered]@{ - index=__PROPR_INDEX__;kind='__PROPR_ENTRY_KIND__';authorityKind='__PROPR_AUTHORITY_KIND__';currentUserSid=$currentSid;ownerSid=$ownerSid + currentUserSid=$currentSid;ownerSid=$ownerSid daclProtected=$daclProtected;reparsePoint=$reparsePoint volumeSerialNumber=$beforeVolumeDecimal fileId=$beforeIdDecimal @@ -378,19 +377,29 @@ function resolveWindowsPowerShell(): HeldExecutable { function revalidateWindowsPowerShell(executable: HeldExecutable): void { let namedFd: number | undefined; + let globalFd: number | undefined; try { try { namedFd = openSync(executable.path, constants.O_RDONLY | constants.O_NOFOLLOW); } catch { throw stageError("resolver:global-id"); } + try { + globalFd = openSync( + `${GLOBAL_SYSTEM_ROOT}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`, + constants.O_RDONLY | constants.O_NOFOLLOW, + ); + } catch { throw stageError("resolver:global-id"); } const held = fstatSync(executable.fd, { bigint: true }); const named = fstatSync(namedFd, { bigint: true }); + const global = fstatSync(globalFd, { bigint: true }); if ( - !held.isFile() || !named.isFile() + !held.isFile() || !named.isFile() || !global.isFile() || held.dev.toString(10) !== executable.device || held.ino.toString(10) !== executable.file || named.dev.toString(10) !== executable.device || named.ino.toString(10) !== executable.file + || global.dev.toString(10) !== executable.device || global.ino.toString(10) !== executable.file ) throw stageError("resolver:global-id"); } finally { if (namedFd !== undefined) closeSync(namedFd); + if (globalFd !== undefined) closeSync(globalFd); } } @@ -431,33 +440,29 @@ export function windowsBrokerFailureStage(status: number | null): WindowsNativeS return status === null ? "spawn:status" : (stages[status] ?? "spawn:status"); } -function inspectionSource(target: WindowsAuthorityTarget, index: number): string { - const entryKind = target.kind === "env" ? "file" : "directory"; - return WINDOWS_INSPECTION_SOURCE - .replace("__PROPR_INDEX__", String(index)) - .replace("__PROPR_ENTRY_KIND__", entryKind) - .replace("__PROPR_AUTHORITY_KIND__", target.kind); -} - /** The fixed inspector receives no caller-controlled executable/module/profile/temp authority. */ export function windowsPowerShellEnvironment(systemRoot: string): Readonly> { if (!ordinaryDosPath(systemRoot)) throw stageError("resolver:env"); return Object.freeze({ SystemRoot: systemRoot, WINDIR: systemRoot }); } -function spawnPowerShell( +function powerShellArguments(source: string): readonly string[] { + const encoded = Buffer.from(source, "utf16le").toString("base64"); + if (encoded.length > 28_000) throw stageError("spawn:create"); + return [ + "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded, + ]; +} + +function spawnPowerShellSync( executable: HeldExecutable, source: string, stdin: "ignore" | number, timeout = WINDOWS_INSPECTION_TIMEOUT_MS, maxBuffer = WINDOWS_INSPECTION_MAX_BYTES, ) { - const encoded = Buffer.from(source, "utf16le").toString("base64"); - if (encoded.length > 28_000) throw stageError("spawn:create"); try { - return spawnSync(executable.path, [ - "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded, - ], { + return spawnSync(executable.path, powerShellArguments(source), { shell: false, windowsHide: true, encoding: "buffer", @@ -471,6 +476,18 @@ function spawnPowerShell( } catch { throw stageError("spawn:create"); } } +function spawnInspectionBroker(executable: HeldExecutable, pinnedFd: number): ChildProcess { + try { + return spawn(executable.path, powerShellArguments(WINDOWS_INSPECTION_SOURCE), { + shell: false, + windowsHide: true, + cwd: win32.dirname(executable.path), + env: windowsPowerShellEnvironment(executable.systemRoot), + stdio: [pinnedFd, "pipe", "pipe"], + }); + } catch { throw stageError("spawn:create"); } +} + export interface WindowsNativeProbeRecord { readonly milestone: WindowsNativeProbeMilestone; readonly timingBucket: WindowsNativeTimingBucket; @@ -528,49 +545,188 @@ export function parseWindowsNativeProbeOutput( return records; } -function assertSpawnSuccess(result: ReturnType): void { - if (result.error) { - if ((result.error as NodeJS.ErrnoException).code === "ETIMEDOUT") throw stageError("spawn:timeout"); - throw stageError("spawn:error"); +export type WindowsBrokerInspection = Omit; + +const WINDOWS_BROKER_ENTRY_KEYS = Object.freeze([ + "currentUserSid", "ownerSid", "daclProtected", "reparsePoint", "volumeSerialNumber", "fileId", + "verifiedVolumeSerialNumber", "verifiedFileId", "rules", +]); + +export function parseWindowsBrokerDocument(value: Buffer | string): WindowsBrokerInspection { + const text = strictUtf8(value); + let parsed: unknown; + try { parsed = JSON.parse(text); } catch { throw stageError("parent:json-parse"); } + if (JSON.stringify(parsed) !== text) throw stageError("parent:json-canonical"); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw stageError("parent:document-shape"); + } + const document = parsed as Record; + if (Object.keys(document).sort().join(",") !== "entries,version" || document.version !== 1 + || !Array.isArray(document.entries)) throw stageError("parent:document-shape"); + if (document.entries.length !== 1) throw stageError("parent:entry-count"); + const entry = document.entries[0]; + if (!entry || typeof entry !== "object" || Array.isArray(entry) + || Object.keys(entry).sort().join(",") !== [...WINDOWS_BROKER_ENTRY_KEYS].sort().join(",")) { + throw stageError("parent:entry-shape"); } - if (result.signal) throw stageError(result.signal === "SIGKILL" ? "spawn:timeout" : "spawn:status"); - if (result.status !== 0) throw stageError(windowsBrokerFailureStage(result.status)); - const stderrBytes = typeof result.stderr === "string" - ? Buffer.byteLength(result.stderr, "utf8") - : (result.stderr?.byteLength ?? 0); - if (stderrBytes !== 0) throw stageError("spawn:stderr"); + return entry as WindowsBrokerInspection; } -export function windowsInspectionTimeoutForElapsed(elapsedMs: number): number { - if (!Number.isFinite(elapsedMs) || elapsedMs < 0) throw stageError("spawn:cumulative-timeout"); - const remaining = WINDOWS_INSPECTION_CUMULATIVE_TIMEOUT_MS - Math.floor(elapsedMs); - if (remaining <= 0) throw stageError("spawn:cumulative-timeout"); - return Math.min(WINDOWS_INSPECTION_TIMEOUT_MS, remaining); +interface InspectionBrokerState { + readonly child: ChildProcess; + readonly stdout: Buffer[]; + closed: boolean; } -export function runWindowsReadOnlyInspection( +/** + * Start all fixed one-target brokers before yielding and supervise them under + * one deadline. A resolved batch proves that every broker closed after both + * output streams drained; a rejected batch first terminates every live broker. + */ +export interface WindowsInspectionBrokerBatchOptions { + readonly entryCount: number; + readonly startBroker: (index: number) => ChildProcess; + readonly deadlineMs: number; + readonly cleanupTimeoutMs: number; + readonly maxOutputBytes: number; +} + +export function runWindowsInspectionBrokerBatch({ + entryCount, + startBroker, + deadlineMs, + cleanupTimeoutMs, + maxOutputBytes, +}: WindowsInspectionBrokerBatchOptions): Promise { + if (!Number.isInteger(entryCount) || entryCount < 1 || entryCount > WINDOWS_INSPECTION_MAX_ENTRIES + || !Number.isInteger(deadlineMs) || deadlineMs < 1 || deadlineMs > WINDOWS_INSPECTION_TIMEOUT_MS + || !Number.isInteger(cleanupTimeoutMs) || cleanupTimeoutMs < 1 + || cleanupTimeoutMs > WINDOWS_INSPECTION_CLEANUP_TIMEOUT_MS + || !Number.isInteger(maxOutputBytes) || maxOutputBytes < 1 + || maxOutputBytes > WINDOWS_INSPECTION_MAX_BYTES) throw stageError("spawn:create"); + return new Promise((resolve, reject) => { + const brokers: InspectionBrokerState[] = []; + let aggregateBytes = 0; + let closedCount = 0; + let spawningComplete = false; + let failure: WindowsNativeStageError | undefined; + let settled = false; + let cleanupTimer: ReturnType | undefined; + + const settle = (): void => { + if (settled || !spawningComplete || closedCount !== brokers.length) return; + settled = true; + clearTimeout(deadlineTimer); + if (cleanupTimer !== undefined) clearTimeout(cleanupTimer); + if (failure) reject(failure); + else resolve(brokers.map((broker) => Buffer.concat(broker.stdout))); + }; + const terminateLiveBrokers = (): void => { + for (const broker of brokers) { + if (broker.closed || broker.child.exitCode !== null || broker.child.signalCode !== null) continue; + try { broker.child.kill("SIGKILL"); } catch { /* close/error decides the fixed result. */ } + } + }; + const fail = (stage: WindowsNativeStageCode): void => { + if (failure || settled) return; + failure = stageError(stage); + clearTimeout(deadlineTimer); + terminateLiveBrokers(); + cleanupTimer = setTimeout(() => { + if (settled) return; + terminateLiveBrokers(); + settled = true; + reject(stageError("spawn:cleanup")); + }, cleanupTimeoutMs); + settle(); + }; + const accept = (broker: InspectionBrokerState, stream: "stdout" | "stderr", chunk: unknown): void => { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array); + aggregateBytes += bytes.byteLength; + if (aggregateBytes > maxOutputBytes) { + fail("parent:utf8"); + return; + } + if (stream === "stderr") { + if (bytes.byteLength > 0) fail("spawn:stderr"); + return; + } + broker.stdout.push(bytes); + }; + + const deadlineTimer = setTimeout(() => fail("spawn:timeout"), deadlineMs); + try { + for (let index = 0; index < entryCount; index += 1) { + const child = startBroker(index); + const broker: InspectionBrokerState = { child, stdout: [], closed: false }; + brokers.push(broker); + if (!child.stdout || !child.stderr) { + fail("spawn:create"); + break; + } + child.stdout.on("data", (chunk) => accept(broker, "stdout", chunk)); + child.stderr.on("data", (chunk) => accept(broker, "stderr", chunk)); + child.once("error", (error) => fail( + (error as NodeJS.ErrnoException).code === "ETIMEDOUT" ? "spawn:timeout" : "spawn:error", + )); + child.once("close", (status, signal) => { + if (broker.closed) return; + broker.closed = true; + closedCount += 1; + if (!failure) { + if (signal !== null) fail("spawn:status"); + else if (status !== 0) fail(windowsBrokerFailureStage(status)); + } + settle(); + }); + } + } catch (error) { + fail(error instanceof WindowsNativeStageError ? error.stage : "spawn:create"); + } finally { + spawningComplete = true; + if (failure) terminateLiveBrokers(); + settle(); + } + }); +} + +function revalidateWindowsTargets(targets: readonly WindowsAuthorityTarget[]): void { + try { + for (const target of targets) { + const held = fstatSync(target.pinnedFd, { bigint: true }); + if (held.dev.toString(10) !== target.expectedIdentity.device + || held.ino.toString(10) !== target.expectedIdentity.file) throw new Error(); + } + } catch { throw stageError("parent:post-bind"); } +} + +export async function runWindowsReadOnlyInspection( targets: readonly WindowsAuthorityTarget[], -): readonly WindowsAuthorityInspection[] { +): Promise { if (targets.length < 1 || targets.length > WINDOWS_INSPECTION_MAX_ENTRIES) { throw stageError("parent:entry-count"); } const executable = resolveWindowsPowerShell(); - const inspections: WindowsAuthorityInspection[] = []; - let totalOutputBytes = 0; - const inspectionStarted = performance.now(); try { + revalidateWindowsPowerShell(executable); + revalidateWindowsTargets(targets); + const outputs = await runWindowsInspectionBrokerBatch({ + entryCount: targets.length, + startBroker: (index) => spawnInspectionBroker(executable, targets[index].pinnedFd), + deadlineMs: WINDOWS_INSPECTION_TIMEOUT_MS, + cleanupTimeoutMs: WINDOWS_INSPECTION_CLEANUP_TIMEOUT_MS, + maxOutputBytes: WINDOWS_INSPECTION_MAX_BYTES, + }); + const inspections: WindowsAuthorityInspection[] = []; for (let index = 0; index < targets.length; index += 1) { const target = targets[index]; - const timeout = windowsInspectionTimeoutForElapsed(performance.now() - inspectionStarted); - const result = spawnPowerShell(executable, inspectionSource(target, index), target.pinnedFd, timeout); - assertSpawnSuccess(result); - totalOutputBytes += typeof result.stdout === "string" - ? Buffer.byteLength(result.stdout, "utf8") - : (result.stdout?.byteLength ?? 0); - if (totalOutputBytes > WINDOWS_INSPECTION_MAX_BYTES) throw stageError("parent:utf8"); - const entries = parseWindowsInspectionDocument(result.stdout ?? Buffer.alloc(0)); - if (entries.length !== 1) throw stageError("parent:entry-count"); - const entry = entries[0]; + const raw = parseWindowsBrokerDocument(outputs[index]); + const entry: WindowsAuthorityInspection = { + index, + kind: target.kind === "env" ? "file" : "directory", + authorityKind: target.kind, + ...raw, + }; try { if ( entry.index !== index @@ -582,16 +738,15 @@ export function runWindowsReadOnlyInspection( || BigInt(entry.fileId) !== BigInt(entry.verifiedFileId) ) throw new Error(); } catch { throw stageError("parent:descriptor-bind"); } - const after = fstatSync(target.pinnedFd, { bigint: true }); - if (after.dev.toString(10) !== target.expectedIdentity.device || after.ino.toString(10) !== target.expectedIdentity.file) { - throw stageError("parent:post-bind"); - } inspections.push(entry); } - revalidateWindowsPowerShell(executable); return inspections; } finally { - closeSync(executable.fd); + try { + revalidateWindowsTargets(targets); + } finally { + try { revalidateWindowsPowerShell(executable); } finally { closeSync(executable.fd); } + } } } @@ -610,7 +765,7 @@ export function runWindowsNativeTimingProbe(targetFd: number): WindowsNativeTimi const executable = resolveWindowsPowerShell(); try { const started = performance.now(); - const result = spawnPowerShell( + const result = spawnPowerShellSync( executable, WINDOWS_NATIVE_TIMING_PROBE_SOURCE, targetFd, diff --git a/scripts/verify-platform-safe-connect.mjs b/scripts/verify-platform-safe-connect.mjs index 2ff1d452c..80993ae82 100644 --- a/scripts/verify-platform-safe-connect.mjs +++ b/scripts/verify-platform-safe-connect.mjs @@ -37,14 +37,14 @@ const tapValue = (name) => { const valid = result.status === 0 && !result.error && !result.signal - && tapValue('tests') === 85 - && tapValue('pass') === 85 + && tapValue('tests') === 88 + && tapValue('pass') === 88 && tapValue('fail') === 0 && tapValue('skipped') === 0; if (!valid) { - process.stderr.write('Platform-safe Connect proof did not complete 85/85 within 90000ms.\n'); + process.stderr.write('Platform-safe Connect proof did not complete 88/88 within 90000ms.\n'); process.exitCode = 1; } else { - process.stdout.write('Platform-safe Connect proof: tests=85 pass=85 fail=0 skipped=0 budgetMs=90000\n'); + process.stdout.write('Platform-safe Connect proof: tests=88 pass=88 fail=0 skipped=0 budgetMs=90000\n'); } diff --git a/scripts/verify-windows-standard-user-connect.mjs b/scripts/verify-windows-standard-user-connect.mjs index 010d7c4f0..9176e232b 100644 --- a/scripts/verify-windows-standard-user-connect.mjs +++ b/scripts/verify-windows-standard-user-connect.mjs @@ -105,7 +105,7 @@ const reasonCodeAllowlist = Object.freeze([ ]); const nativeStageAllowlist = Object.freeze([ "resolver:env", "resolver:canonical", "resolver:global-open", "resolver:global-id", - "spawn:create", "spawn:error", "spawn:timeout", "spawn:cumulative-timeout", "spawn:status", "spawn:stderr", + "spawn:create", "spawn:error", "spawn:timeout", "spawn:status", "spawn:stderr", "spawn:cleanup", "probe:entry", "probe:baseline", "probe:reflection-emit", "probe:win32", "probe:standard-handle", "probe:output", "broker:ps-version", "broker:job", "broker:fd", "broker:fd-duplicate", "broker:index-info-initial", "broker:security-info", "broker:acl", "broker:json", "broker:current-user-sid", @@ -226,8 +226,7 @@ try { currentStage = "native-timing"; const nativeAuthority = await import(windowsAuthorityModule); const WINDOWS_PRODUCT_SCENARIO_TIMEOUT_MS = ( - WINDOWS_PRODUCT_AUTHORITY_PHASE_COUNT - * nativeAuthority.WINDOWS_INSPECTION_CUMULATIVE_TIMEOUT_MS + WINDOWS_PRODUCT_AUTHORITY_PHASE_COUNT * nativeAuthority.WINDOWS_INSPECTION_TIMEOUT_MS ) + WINDOWS_PRODUCT_SCENARIO_OVERHEAD_MS; const probeFd = openSync( fixture, diff --git a/test/fixtures/windowsConnectProcessMock.mjs b/test/fixtures/windowsConnectProcessMock.mjs index a9405c0b9..5aaf1008d 100644 --- a/test/fixtures/windowsConnectProcessMock.mjs +++ b/test/fixtures/windowsConnectProcessMock.mjs @@ -1,7 +1,9 @@ import childProcess from "node:child_process"; +import { EventEmitter } from "node:events"; import { fstatSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { syncBuiltinESMExports } from "node:module"; import { join, resolve } from "node:path"; +import { PassThrough } from "node:stream"; const WINDOWS_ROOT_MISSING_MARKER = "PROPR_TEST_WINDOWS_ROOT_MISSING"; const WINDOWS_ROOT_MISSING_MARKER_VALUE = "windows-root-missing-v1"; @@ -43,11 +45,13 @@ consumeMissingWindowsRootFixtureMarker(); consumeUntrustedWindowsRootFixtureMarker(); const originalSpawnSync = childProcess.spawnSync; +const originalSpawn = childProcess.spawn; const forbidden = /(?:connect-authority|ProPRConnectAuthority|pwsh|csc|msiexec)(?:\.exe)?$/i; let abaPerformed = false; +let authorityInvocation = 0; const nativeStages = new Set([ "resolver:env", "resolver:canonical", "resolver:global-open", "resolver:global-id", - "spawn:create", "spawn:error", "spawn:timeout", "spawn:cumulative-timeout", "spawn:status", "spawn:stderr", + "spawn:create", "spawn:error", "spawn:timeout", "spawn:status", "spawn:stderr", "spawn:cleanup", "probe:entry", "probe:baseline", "probe:reflection-emit", "probe:win32", "probe:standard-handle", "probe:output", "broker:ps-version", "broker:job", "broker:fd", "broker:fd-duplicate", "broker:index-info-initial", "broker:security-info", "broker:acl", "broker:json", "broker:current-user-sid", @@ -61,27 +65,19 @@ globalThis[Symbol.for("propr.test.windowsNativeDiagnostic")] = (stage) => { process.stderr.write(`[propr-windows-native-stage:${fixed}]\n`); }; -function authorityDocument(args, options, mode) { - const encodedIndex = args.indexOf("-EncodedCommand") + 1; - const source = Buffer.from(args[encodedIndex], "base64").toString("utf16le"); - const specs = [...source.matchAll(/index=(\d+);kind='(directory|file)';authorityKind='(ancestor|home|root|data|env)'/g)]; - const identities = [options.stdio[0]].map((fd) => { - const stat = fstatSync(fd, { bigint: true }); - return { device: stat.dev.toString(10), file: stat.ino.toString(10) }; - }); +function authorityDocument(args, options, mode, invocation = authorityInvocation) { + const stat = fstatSync(options.stdio[0], { bigint: true }); + const identity = { device: stat.dev.toString(10), file: stat.ino.toString(10) }; const userSid = "S-1-5-21-100-200-300-1001"; - const entries = specs.map((spec, index) => ({ - index: Number(spec[1]), - kind: spec[2], - authorityKind: spec[3], + const entries = [{ currentUserSid: userSid, ownerSid: userSid, daclProtected: true, reparsePoint: false, - volumeSerialNumber: identities[index].device, - fileId: identities[index].file, - verifiedVolumeSerialNumber: identities[index].device, - verifiedFileId: identities[index].file, + volumeSerialNumber: identity.device, + fileId: identity.file, + verifiedVolumeSerialNumber: identity.device, + verifiedFileId: identity.file, rules: [{ identitySid: userSid, inherited: false, @@ -89,17 +85,17 @@ function authorityDocument(args, options, mode) { appliesToSelf: true, rights: "2032127", }], - })); - const protectedEntry = entries.find((entry) => ["root", "data", "env"].includes(entry.authorityKind)); + }]; + const protectedEntry = entries[0]; if (mode === "descriptor-mismatch") { entries[0].fileId = (BigInt(entries[0].fileId) + 1n).toString(10); entries[0].verifiedFileId = entries[0].fileId; - } else if (mode === "index-mismatch") entries[0].index += 1; - else if (mode === "kind-mismatch") entries[0].kind = entries[0].kind === "file" ? "directory" : "file"; - else if (mode === "authority-kind-mismatch") entries[0].authorityKind = entries[0].authorityKind === "root" ? "data" : "root"; + } else if (mode === "index-mismatch") entries[0].extraIndex = 1; + else if (mode === "kind-mismatch") entries[0].extraKind = "file"; + else if (mode === "authority-kind-mismatch") entries[0].extraAuthorityKind = "root"; else if (mode === "identity-mismatch") { entries[0].fileId = (BigInt(entries[0].fileId) + 1n).toString(10); - } else if (mode === "sid-mismatch" && entries[0].index > 0) { + } else if (mode === "sid-mismatch" && invocation > 1) { entries[0].currentUserSid = "S-1-5-21-100-200-300-1002"; } else if (mode === "broad-write" && protectedEntry) { protectedEntry.rules = [{ @@ -117,6 +113,78 @@ function authorityDocument(args, options, mode) { return JSON.stringify({ version: 1, entries }); } +function fakeAuthorityChild(args, options, mode) { + const invocation = authorityInvocation += 1; + const child = new EventEmitter(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.pid = 0x7000_0000 + authorityInvocation; + child.exitCode = null; + child.signalCode = null; + let closed = false; + const close = (status, signal = null) => { + if (closed) return; + closed = true; + child.exitCode = status; + child.signalCode = signal; + child.stdout.end(); + child.stderr.end(); + setImmediate(() => child.emit("close", status, signal)); + }; + child.kill = () => { + close(null, "SIGKILL"); + return true; + }; + queueMicrotask(() => { + if (closed) return; + if (mode === "timeout") { + const error = Object.assign(new Error("private-path-SENTINEL"), { code: "ETIMEDOUT" }); + child.emit("error", error); + return; + } + if (mode === "malformed") child.stdout.write("{"); + else if (mode === "oversized") child.stdout.write("x".repeat(128 * 1024 + 1)); + else if (mode === "extra-key") child.stdout.write('{"version":1,"entries":[],"extra":true}'); + else if (mode === "duplicate") child.stdout.write('{"version":1,"version":1,"entries":[]}'); + else if (mode === "entry-count") child.stdout.write('{"version":1,"entries":[]}'); + else if (mode === "entry-shape") { + const document = JSON.parse(authorityDocument(args, options, mode, invocation)); + document.entries[0].extra = true; + child.stdout.write(JSON.stringify(document)); + } else if (mode === "stderr") child.stderr.write("private-path-SENTINEL S-1-5-21-999 raw-error-SENTINEL"); + else if (mode !== "nonzero") child.stdout.write(authorityDocument(args, options, mode, invocation)); + close(mode === "nonzero" ? 70 : 0); + }); + return child; +} + +childProcess.spawn = (command, args, options) => { + const executable = String(command); + if (forbidden.test(executable)) throw new Error("forbidden Windows authority executable"); + if (!/powershell\.exe$/i.test(executable)) return originalSpawn(command, args, options); + const mode = process.env.PROPR_TEST_AUTHORITY_MODE; + if (mode === "path-aba" && !abaPerformed) { + abaPerformed = true; + const envPath = join(process.env.PROPR_TEST_AUTHORITY_ROOT, ".env"); + const detached = `${envPath}-aba-detached`; + renameSync(envPath, detached); + writeFileSync(envPath, [ + "PROPR_STACK=attacker-replacement-SENTINEL", + "PROPR_INSTANCE_ID=attacker", + "PROPR_UI_PUBLIC_API_URL=https://t-attacker.propr.dev", + "PROPR_UI_TUNNEL_ENABLED=true", + "PROPR_UI_TUNNEL_TOKEN=attacker-replacement-SENTINEL", + "", + ].join("\n")); + process.once("exit", () => { + rmSync(envPath, { force: true }); + renameSync(detached, envPath); + }); + } + if (!mode || mode === "path-aba") return originalSpawn(command, args, options); + return fakeAuthorityChild(args, options, mode); +}; + childProcess.spawnSync = (command, args, options) => { const executable = String(command); if (forbidden.test(executable)) throw new Error("forbidden Windows authority executable"); diff --git a/test/windowsStandardUserConnectHarness.test.ts b/test/windowsStandardUserConnectHarness.test.ts index 856705bde..477d6b013 100644 --- a/test/windowsStandardUserConnectHarness.test.ts +++ b/test/windowsStandardUserConnectHarness.test.ts @@ -5,10 +5,9 @@ import { runInNewContext } from 'node:vm'; import { test } from 'node:test'; import { parseWindowsNativeProbeOutput, - WINDOWS_INSPECTION_CUMULATIVE_TIMEOUT_MS, + WINDOWS_INSPECTION_CLEANUP_TIMEOUT_MS, WINDOWS_INSPECTION_TIMEOUT_MS, WINDOWS_NATIVE_TIMING_PROBE_TIMEOUT_MS, - windowsInspectionTimeoutForElapsed, WindowsNativeStageError, windowsNativeTimingBucket, } from '../packages/cli/src/connectWindowsAuthority.js'; @@ -321,7 +320,7 @@ test('the ordinary-user Windows proof retains native security paths and bounds r } assert.match( processMock, - /if \(mode === "valid-authority"\) return result\(0, authorityDocument\(args, options, mode\)\);/, + /else if \(mode !== "nonzero"\) child\.stdout\.write\(authorityDocument\(args, options, mode, invocation\)\);/, ); assert.match(harness, /\{ name: "path-aba", mode: "path-aba", reason: "INVALID_ROOT" \}/); assert.match(harness, /\{ name: "authority-missing-system-root", systemRootMode: "missing", nativeStage: "resolver:env" \}/); @@ -360,7 +359,7 @@ test('the ordinary-user Windows diagnostic has fixed allowlists and redacts all ]); assert.deepEqual([...definitions.nativeStageAllowlist], [ 'resolver:env', 'resolver:canonical', 'resolver:global-open', 'resolver:global-id', - 'spawn:create', 'spawn:error', 'spawn:timeout', 'spawn:cumulative-timeout', 'spawn:status', 'spawn:stderr', + 'spawn:create', 'spawn:error', 'spawn:timeout', 'spawn:status', 'spawn:stderr', 'spawn:cleanup', 'probe:entry', 'probe:baseline', 'probe:reflection-emit', 'probe:win32', 'probe:standard-handle', 'probe:output', 'broker:ps-version', 'broker:job', 'broker:fd', 'broker:fd-duplicate', 'broker:index-info-initial', 'broker:security-info', 'broker:acl', 'broker:json', 'broker:current-user-sid', @@ -463,6 +462,10 @@ test('the staged hosted probe and production inspector both use the inherited st assert.match(productionSource, /GetStdHandle\(-10\)/); assert.doesNotMatch(productionSource, /_get_osfhandle|AssignProcessToJobObject|CreateJobObject|Start-Process|CreateProcess/); assert.match(windowsAuthority, /stdio: \[stdin, "pipe", "pipe"\]/); + assert.match(windowsAuthority, /stdio: \[pinnedFd, "pipe", "pipe"\]/); + assert.doesNotMatch(productionSource, /__PROPR_|\bindex=|\bkind=|authorityKind=/); + assert.match(windowsAuthority, /powerShellArguments\(WINDOWS_INSPECTION_SOURCE\)/); + assert.doesNotMatch(windowsAuthority, /inspectionSource\(target|\.replace\("__PROPR_/); assert.match(windowsAuthority, /WINDOWS_INSPECTOR_CREATES_CHILD_PROCESSES = false/); assert.match(windowsAuthority, /WINDOWS_INSPECTOR_WRITES_FILESYSTEM = false/); }); @@ -510,47 +513,35 @@ test('the staged probe accepts only ordered milestone tokens and coarse timing b assert.match(windowsAuthority, /GetFileInformationByHandle/); }); -test('the diagnostic allowance precedes a cumulatively bounded production standard-handle proof', () => { +test('the diagnostic allowance precedes one bounded concurrent standard-handle proof', () => { assert.equal(WINDOWS_NATIVE_TIMING_PROBE_TIMEOUT_MS, 60_000); assert.equal(WINDOWS_INSPECTION_TIMEOUT_MS, 60_000); - assert.equal(WINDOWS_INSPECTION_CUMULATIVE_TIMEOUT_MS, 240_000); + assert.equal(WINDOWS_INSPECTION_CLEANUP_TIMEOUT_MS, 5_000); assert.match( windowsAuthority, - /export const WINDOWS_INSPECTION_CUMULATIVE_TIMEOUT_MS = 240_000;/, + /export const WINDOWS_INSPECTION_TIMEOUT_MS = 60_000;/, ); assert.match(harness, /const WINDOWS_PRODUCT_AUTHORITY_PHASE_COUNT = 2;/); assert.match(harness, /const WINDOWS_PRODUCT_SCENARIO_OVERHEAD_MS = 15_000;/); assert.match( harness, - /const WINDOWS_PRODUCT_SCENARIO_TIMEOUT_MS = \(\s*WINDOWS_PRODUCT_AUTHORITY_PHASE_COUNT\s*\* nativeAuthority\.WINDOWS_INSPECTION_CUMULATIVE_TIMEOUT_MS\s*\) \+ WINDOWS_PRODUCT_SCENARIO_OVERHEAD_MS;/, + /const WINDOWS_PRODUCT_SCENARIO_TIMEOUT_MS = \(\s*WINDOWS_PRODUCT_AUTHORITY_PHASE_COUNT \* nativeAuthority\.WINDOWS_INSPECTION_TIMEOUT_MS\s*\) \+ WINDOWS_PRODUCT_SCENARIO_OVERHEAD_MS;/, ); const windowsProductScenarioTimeoutMs = ( - 2 * WINDOWS_INSPECTION_CUMULATIVE_TIMEOUT_MS + 2 * WINDOWS_INSPECTION_TIMEOUT_MS ) + 15_000; - assert.equal(windowsProductScenarioTimeoutMs, 495_000); + assert.equal(windowsProductScenarioTimeoutMs, 135_000); assert.equal(Number.isFinite(windowsProductScenarioTimeoutMs), true); assert.equal(Number.isSafeInteger(windowsProductScenarioTimeoutMs), true); - assert.equal(WINDOWS_INSPECTION_CUMULATIVE_TIMEOUT_MS, 4 * WINDOWS_INSPECTION_TIMEOUT_MS); - assert.notEqual( - WINDOWS_INSPECTION_CUMULATIVE_TIMEOUT_MS / WINDOWS_INSPECTION_TIMEOUT_MS, - 32, - ); - assert.equal(windowsInspectionTimeoutForElapsed(0), 60_000); - assert.equal(windowsInspectionTimeoutForElapsed(60_000), 60_000); - assert.equal(windowsInspectionTimeoutForElapsed(120_000), 60_000); - assert.equal(windowsInspectionTimeoutForElapsed(180_000), 60_000); - assert.equal(windowsInspectionTimeoutForElapsed(180_001), 59_999); - assert.equal(windowsInspectionTimeoutForElapsed(210_000), 30_000); - assert.equal(windowsInspectionTimeoutForElapsed(225_000), 15_000); - assert.equal(windowsInspectionTimeoutForElapsed(239_999.9), 1); - assert.throws( - () => windowsInspectionTimeoutForElapsed(240_000), - (error) => error instanceof WindowsNativeStageError && error.stage === 'spawn:cumulative-timeout', - ); - assert.throws( - () => windowsInspectionTimeoutForElapsed(240_001), - (error) => error instanceof WindowsNativeStageError && error.stage === 'spawn:cumulative-timeout', + assert.ok(WINDOWS_INSPECTION_CLEANUP_TIMEOUT_MS < WINDOWS_INSPECTION_TIMEOUT_MS); + assert.match(windowsAuthority, /startBroker: \(index\) => spawnInspectionBroker\(executable, targets\[index\]\.pinnedFd\)/u); + assert.match(windowsAuthority, /const deadlineTimer = setTimeout\(\(\) => fail\("spawn:timeout"\), deadlineMs\)/u); + assert.match(windowsAuthority, /deadlineMs: WINDOWS_INSPECTION_TIMEOUT_MS/u); + const productionInspection = windowsAuthority.slice( + windowsAuthority.indexOf('export async function runWindowsReadOnlyInspection'), + windowsAuthority.indexOf('function probeFailureStage'), ); + assert.doesNotMatch(productionInspection, /spawnSync|windowsInspectionTimeoutForElapsed/u); const probeCall = harness.indexOf('runWindowsNativeTimingProbe(probeFd)'); const productionMatrix = harness.indexOf('for (const scenario of cases)', probeCall); const productionSpawn = harness.indexOf('const result = spawnSync(process.execPath', productionMatrix); @@ -570,7 +561,7 @@ test('the hostile path ABA remains replaced through validation and is rejected a assert.match(processMock, /process\.once\("exit", \(\) => \{/); const replacement = processMock.indexOf('writeFileSync(envPath'); const exitHook = processMock.indexOf('process.once("exit"', replacement); - const spawn = processMock.indexOf('return originalSpawnSync(command, args, options);', replacement); + const spawn = processMock.indexOf('return originalSpawn(command, args, options);', replacement); const restore = processMock.indexOf('renameSync(detached, envPath);', replacement); assert.ok( replacement < exitHook && exitHook < restore && restore < spawn, From 8156d7a70d8c197f71632b4581ea9f2c79cf0ef4 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:40:44 +0000 Subject: [PATCH 4/8] feat(ai): Fixed the CI failure with the requested lockfile-only update. Fixed the CI failure with the requested lockfile-only update. - Root cause: `fast-uri` 3.1.5 triggered four high-severity advisories during the [dependency audit job](https://github.com/integry/propr/actions/runs/33655642050/job/100333449719). - Updated `fast-uri` to 3.1.6 in [package-lock.json](/home/node/workspace/package-lock.json:7430), including the canonical tarball and integrity. - Preserved `qs` 6.16.0. - No manifest, source, workflow, or unrelated dependency changes. Validation passed: - Runtime audit: 0 vulnerabilities - Desktop packaging audit: 0 vulnerabilities - Lockfile regeneration check - `npm ci` - `npm ls fast-uri qs` - `git diff --check` Final diff: one file, 3 insertions and 3 deletions. No commit created. PR: #2069 Comment by: @propr-dev[bot] (ID: 5512938633) Model: gpt-5.6-sol --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index a27ac651a..fad20a9ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7428,9 +7428,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", "funding": [ { "type": "github", From 4177d009169fca4b944947c538aaae8dac9851a8 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:19:11 +0000 Subject: [PATCH 5/8] feat(ai): Implemented the queued corrections on exact head `8156d7a70d8c197f71632b4581ea9f2c79cf0ef4`. No commit or merge was performed. Implemented the queued corrections on exact head `8156d7a70d8c197f71632b4581ea9f2c79cf0ef4`. No commit or merge was performed. Key changes: - Replaced N cold PowerShell brokers with one fixed-fd, multi-handle broker per authority generation in [connectWindowsAuthority.ts](/tmp/git-processor/worktrees/integry/propr/pr-2069-followup-2026-09-02T16-41-44/packages/cli/src/connectWindowsAuthority.ts:993). - Ordered the two required Windows generations sequentially so their unchanged 60-second bounds do not overlap in [connectCommand.ts](/tmp/git-processor/worktrees/integry/propr/pr-2069-followup-2026-09-02T16-41-44/packages/cli/src/commands/connectCommand.ts:386). - Added before/after descriptor, executable, ACL, identity, and policy revalidation without cross-generation caching. - Made broker attribution fixed, total, bounded, and secret-free. - Cleanup now retains ownership and retries termination until close plus both stream drains; cleanup expiry changes the eventual result to `spawn:cleanup` but never resolves while a broker remains tracked. - Added deterministic unkillable-child and delayed-close/drain coverage. - Updated native regression evidence for 1, 2, and 4 broker/resource cases in [windows-authority-batch-regression.mjs](/tmp/git-processor/worktrees/integry/propr/pr-2069-followup-2026-09-02T16-41-44/apps/desktop/scripts/windows-authority-batch-regression.mjs:93). Validation passed: - Focused security/CLI tests: 75/75. - Platform-safe Connect proof: 90/90. - Desktop suite: 348 passed, 7 platform skips, 0 failed. - CLI build and lint. - Desktop and UI type checks. - Runtime audit: 0 vulnerabilities. - Packaging audit: 0 vulnerabilities. - `git diff --check`. `package-lock.json`, the synchronous READY writer, desktop READY wiring, and strict nested milestone schema are unchanged. `qs` remains exactly 6.16.0 and `fast-uri` exactly 3.1.6 with the requested tarball and integrity. Native x64/ARM64 Windows regression and ordinary-user packaged Connect could not be executed on this Linux x64 host. Their gates and regression scripts are updated, but those two native proofs still require Windows x64 and ARM64 runners. PR: #2069 Comment by: @integry (ID: 5513022841) Model: gpt-5.6-sol --- .../windows-authority-batch-regression.mjs | 98 ++- packages/cli/src/commands/connectCommand.ts | 12 +- packages/cli/src/connectIdentity.ts | 79 ++- packages/cli/src/connectRootAuthority.test.ts | 221 +++--- packages/cli/src/connectRootAuthority.ts | 126 +++- packages/cli/src/connectWindowsAuthority.ts | 630 ++++++++++++++---- scripts/verify-platform-safe-connect.mjs | 8 +- .../verify-windows-standard-user-connect.mjs | 2 +- test/fixtures/windowsConnectProcessMock.mjs | 95 +-- .../windowsStandardUserConnectHarness.test.ts | 40 +- 10 files changed, 979 insertions(+), 332 deletions(-) diff --git a/apps/desktop/scripts/windows-authority-batch-regression.mjs b/apps/desktop/scripts/windows-authority-batch-regression.mjs index 6d9c65f39..ca082450e 100644 --- a/apps/desktop/scripts/windows-authority-batch-regression.mjs +++ b/apps/desktop/scripts/windows-authority-batch-regression.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; -import { closeSync, mkdtempSync, openSync, rmSync, writeFileSync } from 'node:fs'; +import { closeSync, fstatSync, mkdtempSync, openSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { performance } from 'node:perf_hooks'; @@ -13,6 +13,7 @@ if (process.platform !== 'win32' || !['x64', 'arm64'].includes(process.arch)) { const { parseWindowsBrokerDocument, + runWindowsReadOnlyInspection, runWindowsInspectionBrokerBatch, WINDOWS_INSPECTION_SOURCE, WindowsNativeStageError, @@ -31,19 +32,49 @@ const argumentsFor = source => [ '-EncodedCommand', Buffer.from(source, 'utf16le').toString('base64'), ]; -const startPowerShell = (source, fd) => { +const startPowerShell = (source, fds) => { + const inherited = Array.isArray(fds) ? fds : [fds]; const child = spawn(executable, argumentsFor(source), { shell: false, windowsHide: true, cwd: dirname(executable), env: environment, - stdio: [fd, 'pipe', 'pipe'], + stdio: ['ignore', 'pipe', 'pipe', ...inherited], }); if (Number.isSafeInteger(child.pid)) livePids.add(child.pid); child.once('close', () => livePids.delete(child.pid)); return child; }; +const targetFor = (fd, index) => { + const stat = fstatSync(fd, { bigint: true }); + return { + path: '', + kind: 'env', + pinnedFd: fd, + expectedIdentity: { device: stat.dev.toString(10), file: stat.ino.toString(10) }, + index, + }; +}; + +const oneRoundSource = WINDOWS_INSPECTION_SOURCE + .replace('__PROPR_ENTRY_COUNT__', '1') + .replace('__PROPR_ROUND_COUNT__', '1'); + +const fixedEvidence = (count, outcome, stage, diagnostics) => ({ + brokers: count, + outcome, + stage, + results: diagnostics, +}); + +const canonicalFrame = output => { + assert.equal(output.at(-1), 0x0a); + const end = output.at(-2) === 0x0d ? output.length - 2 : output.length - 1; + assert.equal(output.subarray(0, end).includes(0x0a), false); + return output.subarray(0, end); +}; + const assertStage = async (promise, stage) => { await assert.rejects( promise, @@ -59,19 +90,45 @@ try { descriptors.push(openSync(path, 'r')); } - const deliberatelySlowInspector = `Start-Sleep -Milliseconds 1500\n${WINDOWS_INSPECTION_SOURCE}`; - const started = performance.now(); - const slow = await runWindowsInspectionBrokerBatch({ - entryCount: descriptors.length, - startBroker: index => startPowerShell(deliberatelySlowInspector, descriptors[index]), - deadlineMs: 60_000, - cleanupTimeoutMs: 5_000, - maxOutputBytes: 128 * 1024, - }); - assert.equal(slow.length, descriptors.length); - slow.forEach(output => parseWindowsBrokerDocument(output)); - assert.ok(performance.now() - started < 60_000, 'slow brokers exceeded one wall-clock bound'); - assert.equal(livePids.size, 0, 'successful slow batch left a broker process alive'); + // Production proof: 1, 2, and 4 targets each use one cold PowerShell process + // and remain inside the unchanged single 60-second wall bound. + for (const count of [1, 2, 4]) { + const targets = descriptors.slice(0, count).map(targetFor); + const started = performance.now(); + const inspections = await runWindowsReadOnlyInspection(targets); + assert.equal(inspections.length, count); + assert.ok(performance.now() - started < 60_000, `one-broker ${count}-handle batch exceeded its wall bound`); + } + + // Resource evidence remains non-authoritative and total: reproduce 1, 2, + // then 4 independent full cold starts, but allow a fixed failure now that + // production does not depend on N-process concurrency. No native text enters + // the record, and the supervisor still drains every process before return. + for (const count of [1, 2, 4]) { + const diagnostics = []; + let outcome = 'passed'; + let stage = 'ok'; + try { + const outputs = await runWindowsInspectionBrokerBatch({ + entryCount: count, + startBroker: index => startPowerShell(oneRoundSource, descriptors[index]), + deadlineMs: 60_000, + cleanupTimeoutMs: 5_000, + maxOutputBytes: 128 * 1024, + onBrokerResult: diagnostic => diagnostics.push(diagnostic), + }); + outputs.forEach(output => parseWindowsBrokerDocument(canonicalFrame(output))); + } catch (error) { + assert.ok(error instanceof WindowsNativeStageError); + outcome = 'failed'; + stage = error.stage; + } + assert.equal(diagnostics.length, count); + assert.equal(livePids.size, 0, `${count}-broker evidence left a process alive`); + process.stdout.write(`Windows authority concurrency evidence ${JSON.stringify( + fixedEvidence(count, outcome, stage, diagnostics), + )}\n`); + } const reorderedSources = [80, 10, 45].map((delay, index) => ( `Start-Sleep -Milliseconds ${delay};[Console]::Out.Write('${index}')` @@ -119,7 +176,14 @@ try { maxOutputBytes: 1024, }), 'parent:utf8'); - const validEntry = parseWindowsBrokerDocument(slow[0]); + const validOutput = await runWindowsInspectionBrokerBatch({ + entryCount: 1, + startBroker: () => startPowerShell(oneRoundSource, descriptors[0]), + deadlineMs: 60_000, + cleanupTimeoutMs: 5_000, + maxOutputBytes: 128 * 1024, + }); + const validEntry = parseWindowsBrokerDocument(canonicalFrame(validOutput[0])); for (const entries of [[], [validEntry, validEntry]]) assert.throws( () => parseWindowsBrokerDocument(JSON.stringify({ version: 1, entries })), error => error instanceof WindowsNativeStageError && error.stage === 'parent:entry-count', diff --git a/packages/cli/src/commands/connectCommand.ts b/packages/cli/src/commands/connectCommand.ts index eb1ac66b2..e0c4c8d0b 100644 --- a/packages/cli/src/commands/connectCommand.ts +++ b/packages/cli/src/commands/connectCommand.ts @@ -380,9 +380,17 @@ export async function getLocalConnectStatus( dependencies.reportSmokeDiagnostic?.(phase, 'STARTED'); try { const prepared = await prepareConnectHostConfig(); + // A native Windows generation owns one broker under one fixed 60-second + // deadline. Finish the independent trusted-home generation before opening + // the root generation so neither cold start consumes the other's bound. + const windowsTunnelEnabledOverride = process.platform === "win32" && root + ? await readTrustedConnectTunnelOverride(root) + : undefined; const local = await withOwnedConnectRootSnapshot(root, async (snapshot) => { const cfg = prepared.resolveSnapshot(snapshot); - const tunnelEnabledOverride = await readTrustedConnectTunnelOverride(snapshot.requestedRoot); + const tunnelEnabledOverride = process.platform === "win32" + ? windowsTunnelEnabledOverride + : await readTrustedConnectTunnelOverride(snapshot.requestedRoot); const effectiveCfg = tunnelEnabledOverride === undefined ? cfg : { ...cfg, uiTunnelEnabled: tunnelEnabledOverride }; @@ -400,7 +408,7 @@ export async function getLocalConnectStatus( publicInstanceIdentity, sidecarInspection, }; - }, { parseEnvFile: prepared.parseEnvFile }); + }, { parseEnvFile: prepared.parseEnvFile, pinPublicIdentityAuthority: process.platform === "win32" }); dependencies.reportSmokeDiagnostic?.(phase, 'PASSED'); phase = 'status-resolution'; dependencies.reportSmokeDiagnostic?.(phase, 'STARTED'); diff --git a/packages/cli/src/connectIdentity.ts b/packages/cli/src/connectIdentity.ts index c0831d108..948cb66f8 100644 --- a/packages/cli/src/connectIdentity.ts +++ b/packages/cli/src/connectIdentity.ts @@ -19,6 +19,7 @@ import { readPublicInstanceIdentityPinned, type PinnedPublicIdentityDirectory, } from "@propr/local-setup"; +import { PUBLIC_INSTANCE_IDENTITY_FILENAME } from "@propr/shared"; import { directoryDescriptorAccess, mkdirAt, @@ -31,11 +32,13 @@ import { import { assertNativeEntryAuthority, assertNativeWindowsEntriesAuthority, + beginNativeWindowsEntriesAuthorityGeneration, nativeConnectRootAuthorityInspector, WindowsAuthorityInspectionError, WindowsAuthorityPolicyError, type ConnectAuthorityEntryKind, type ConnectRootAuthorityInspector, + type NativeWindowsEntriesAuthorityGeneration, } from "./connectRootAuthority.js"; import { canonicalRootKey } from "./config/rootKey.js"; @@ -96,6 +99,8 @@ export interface ConnectRootSnapshotOptions { authorityInspector?: ConnectRootAuthorityInspector; onBoundary?: (boundary: ConnectRootSnapshotBoundary) => void | Promise; parseEnvFile?: (contents: string) => Record; + /** @internal Discovery pre-pins the read-only identity into the same Windows authority generation. */ + pinPublicIdentityAuthority?: boolean; } interface HeldDirectory { @@ -357,6 +362,7 @@ export async function readTrustedConnectTunnelOverride( let homeAncestorsClosed = false; let configDir: HeldDirectory | undefined; let configFd: number | undefined; + let windowsAuthorityGeneration: NativeWindowsEntriesAuthorityGeneration | undefined; try { if (!sameResolvedPath(realpathSync.native(homePath), homePath, platform)) { throw new TrustedConnectConfigError("REPARSE_POINT"); @@ -487,7 +493,7 @@ export async function readTrustedConnectTunnelOverride( if (platform === "darwin") { await authorityEntry(inspector, platform, join(configDir.visiblePath, "config.json"), "env", configFd); } else if (platform === "win32") { - await authorityEntries(inspector, [ + windowsAuthorityGeneration = await beginNativeWindowsEntriesAuthorityGeneration(inspector, [ ...home.ancestry.slice(0, -1).map((entry) => ({ path: entry.path, kind: "ancestor" as const, pinnedFd: entry.fd, })), @@ -495,8 +501,6 @@ export async function readTrustedConnectTunnelOverride( { path: configDir.visiblePath, kind: "data", pinnedFd: configDir.fd }, { path: join(configDir.visiblePath, "config.json"), kind: "env", pinnedFd: configFd }, ]); - closeAcquiredAncestors(home); - homeAncestorsClosed = true; } verifyNamedConfigDirectory(); await options.onBoundary?.("config-opened"); @@ -516,7 +520,10 @@ export async function readTrustedConnectTunnelOverride( await authorityEntry(inspector, platform, configDir.visiblePath, "data", configDir.fd); await authorityEntry(inspector, platform, join(configDir.visiblePath, "config.json"), "env", configFd); } else if (platform === "win32") { - await authorityEntries(inspector, [ + await windowsAuthorityGeneration!.revalidate([ + ...home.ancestry.slice(0, -1).map((entry) => ({ + path: entry.path, kind: "ancestor" as const, pinnedFd: entry.fd, + })), { path: home.root.visiblePath, kind: "home", pinnedFd: home.root.fd }, { path: configDir.visiblePath, kind: "data", pinnedFd: configDir.fd }, { path: join(configDir.visiblePath, "config.json"), kind: "env", pinnedFd: configFd }, @@ -531,11 +538,15 @@ export async function readTrustedConnectTunnelOverride( if (error instanceof ConnectRootError) throw new TrustedConnectConfigError(error.reason); throw new TrustedConnectConfigError(); } finally { - if (configFd !== undefined) closeSync(configFd); - if (configDir !== undefined) closeSync(configDir.fd); - if (home !== undefined) { - if (!homeAncestorsClosed) closeAcquiredAncestors(home); - closeSync(home.root.fd); + try { + await windowsAuthorityGeneration?.abort(); + } finally { + if (configFd !== undefined) closeSync(configFd); + if (configDir !== undefined) closeSync(configDir.fd); + if (home !== undefined) { + if (!homeAncestorsClosed) closeAcquiredAncestors(home); + closeSync(home.root.fd); + } } } } @@ -683,8 +694,10 @@ export async function withOwnedConnectRootSnapshot( let root: HeldDirectory | undefined; let data: HeldDirectory | undefined; let envFd: number | undefined; + let publicIdentityAuthorityFd: number | undefined; let acquiredRoot: AcquiredRoot | undefined; let acquiredAncestorsClosed = false; + let windowsAuthorityGeneration: NativeWindowsEntriesAuthorityGeneration | undefined; try { const acquired = openRootNoFollow(requestedRoot, ioPlatform); acquiredRoot = acquired; @@ -719,19 +732,33 @@ export async function withOwnedConnectRootSnapshot( const initialEnvStat = fstatSync(envFd); assertPrivateEnv(initialEnvStat, callerUid, platform); assertNamedEntry(requestedRoot, ".env", initialEnvStat); + if (platform === "win32" && options.pinPublicIdentityAuthority === true) { + publicIdentityAuthorityFd = data.openChild( + PUBLIC_INSTANCE_IDENTITY_FILENAME, + constants.O_RDONLY | constants.O_NOFOLLOW, + ); + const identityStat = fstatSync(publicIdentityAuthorityFd); + if (!identityStat.isFile() || identityStat.isSymbolicLink() || identityStat.nlink !== 1) { + throw new PublicInstanceIdentityError(); + } + assertNamedEntry(data.visiblePath, PUBLIC_INSTANCE_IDENTITY_FILENAME, identityStat); + } if (platform === "darwin") { await authorityEntry(inspector, platform, join(requestedRoot, ".env"), "env", envFd); } else if (platform === "win32") { - await authorityEntries(inspector, [ + windowsAuthorityGeneration = await beginNativeWindowsEntriesAuthorityGeneration(inspector, [ ...acquired.ancestry.slice(0, -1).map((entry) => ({ path: entry.path, kind: "ancestor" as const, pinnedFd: entry.fd, })), { path: root.visiblePath, kind: "root", pinnedFd: root.fd }, { path: data.visiblePath, kind: "data", pinnedFd: data.fd }, { path: join(requestedRoot, ".env"), kind: "env", pinnedFd: envFd }, + ...(publicIdentityAuthorityFd === undefined ? [] : [{ + path: join(data.visiblePath, PUBLIC_INSTANCE_IDENTITY_FILENAME), + kind: "env" as const, + pinnedFd: publicIdentityAuthorityFd, + }]), ]); - closeAcquiredAncestors(acquired); - acquiredAncestorsClosed = true; } await options.onBoundary?.("acquired"); @@ -777,7 +804,13 @@ export async function withOwnedConnectRootSnapshot( }, validateEntry: async (name, fd) => { const entryPath = join(data!.visiblePath, name); - if (platform !== "linux") { + if (platform === "win32" && publicIdentityAuthorityFd !== undefined + && name === PUBLIC_INSTANCE_IDENTITY_FILENAME) { + const retained = fstatSync(publicIdentityAuthorityFd); + const current = fstatSync(fd); + if (!sameIdentity(retained, current)) throw new PublicInstanceIdentityError(); + assertNamedEntry(data!.visiblePath, name, current); + } else if (platform !== "linux") { await authorityEntry(inspector, platform, entryPath, "env", fd); } }, @@ -834,13 +867,18 @@ export async function withOwnedConnectRootSnapshot( || before.some((entry, index) => !sameIdentity(entry.stat, after[index].stat)) ) throw new ConnectRootError(); if (platform === "win32") { - await authorityEntries(inspector, [ + await windowsAuthorityGeneration!.revalidate([ ...reacquired.ancestry.slice(0, -1).map((entry) => ({ path: entry.path, kind: "ancestor" as const, pinnedFd: entry.fd, })), { path: reacquired.root.visiblePath, kind: "root", pinnedFd: reacquired.root.fd }, { path: data.visiblePath, kind: "data", pinnedFd: data.fd }, { path: join(requestedRoot, ".env"), kind: "env", pinnedFd: envFd }, + ...(publicIdentityAuthorityFd === undefined ? [] : [{ + path: join(data.visiblePath, PUBLIC_INSTANCE_IDENTITY_FILENAME), + kind: "env" as const, + pinnedFd: publicIdentityAuthorityFd, + }]), ]); } else { await assertPlatformAuthority(reacquired, platform, inspector, callerUid); @@ -860,10 +898,15 @@ export async function withOwnedConnectRootSnapshot( } throw new ConnectRootError(); } finally { - if (acquiredRoot !== undefined && !acquiredAncestorsClosed) closeAcquiredAncestors(acquiredRoot); - if (envFd !== undefined) closeSync(envFd); - if (data !== undefined) closeSync(data.fd); - if (root !== undefined) closeSync(root.fd); + try { + await windowsAuthorityGeneration?.abort(); + } finally { + if (acquiredRoot !== undefined && !acquiredAncestorsClosed) closeAcquiredAncestors(acquiredRoot); + if (publicIdentityAuthorityFd !== undefined) closeSync(publicIdentityAuthorityFd); + if (envFd !== undefined) closeSync(envFd); + if (data !== undefined) closeSync(data.fd); + if (root !== undefined) closeSync(root.fd); + } } } diff --git a/packages/cli/src/connectRootAuthority.test.ts b/packages/cli/src/connectRootAuthority.test.ts index 02c152fd2..cfa9d6b77 100644 --- a/packages/cli/src/connectRootAuthority.test.ts +++ b/packages/cli/src/connectRootAuthority.test.ts @@ -38,6 +38,7 @@ import { WindowsNativeStageError, windowsNativeTimingBucket, windowsPowerShellEnvironment, + type WindowsBrokerResultDiagnostic, } from "./connectWindowsAuthority.js"; const USER = "S-1-5-21-100-200-300-1001"; @@ -51,6 +52,10 @@ function fixtureBroker({ stderr = "", status = 0, hang = false, + killReturns = true, + releaseAfterKillMs, + closeEventDelayMs = 0, + streamDrainDelayMs = 0, onStart = () => undefined, onClose = () => undefined, }: { @@ -59,6 +64,10 @@ function fixtureBroker({ stderr?: string; status?: number; hang?: boolean; + killReturns?: boolean; + releaseAfterKillMs?: number; + closeEventDelayMs?: number; + streamDrainDelayMs?: number; onStart?: () => void; onClose?: () => void; } = {}): ChildProcess { @@ -68,22 +77,31 @@ function fixtureBroker({ child.pid = fixtureBrokerPid += 1; child.exitCode = null; child.signalCode = null; + let closing = false; let closed = false; let timer: ReturnType | undefined; const close = (code: number | null, signal: NodeJS.Signals | null): void => { - if (closed) return; - closed = true; + if (closing) return; + closing = true; if (timer !== undefined) clearTimeout(timer); child.exitCode = code; child.signalCode = signal; - (child.stdout as PassThrough).end(); - (child.stderr as PassThrough).end(); - onClose(); - setImmediate(() => child.emit("close", code, signal)); + setTimeout(() => { + (child.stdout as PassThrough).end(); + (child.stderr as PassThrough).end(); + }, streamDrainDelayMs); + setTimeout(() => { + closed = true; + onClose(); + child.emit("close", code, signal); + }, closeEventDelayMs); }; child.kill = (): boolean => { - close(null, "SIGKILL"); - return true; + if (killReturns) close(null, "SIGKILL"); + else if (releaseAfterKillMs !== undefined && !closing) { + setTimeout(() => close(null, "SIGKILL"), releaseAfterKillMs); + } + return killReturns; }; onStart(); if (!hang) timer = setTimeout(() => { @@ -294,6 +312,7 @@ test("Windows broker supervisor terminates and drains all siblings on timeout, f ): Promise => { let active = 0; let started = 0; + const diagnostics: WindowsBrokerResultDiagnostic[] = []; await assert.rejects( runWindowsInspectionBrokerBatch({ entryCount: 3, @@ -305,12 +324,19 @@ test("Windows broker supervisor terminates and drains all siblings on timeout, f deadlineMs: 30, cleanupTimeoutMs: 100, maxOutputBytes: 32, + onBrokerResult: (diagnostic) => diagnostics.push(diagnostic), ...overrides, }), (error) => error instanceof WindowsNativeStageError && error.stage === expectedStage, ); assert.equal(started, 3); assert.equal(active, 0); + assert.deepEqual(diagnostics.map(({ brokerIndex }) => brokerIndex).sort(), ["0", "1", "2-3"]); + assert.ok(diagnostics.every((diagnostic) => ( + Object.keys(diagnostic).sort().join(",") + === "brokerIndex,cleanup,deadline,statusStage,stderr,stdout" + ))); + assert.doesNotMatch(JSON.stringify(diagnostics), /SENTINEL|powershell|S-1-|[A-Za-z]:\\/i); }; await runFailure("spawn:timeout", (_index, closed) => fixtureBroker({ hang: true, onClose: closed })); @@ -326,6 +352,83 @@ test("Windows broker supervisor terminates and drains all siblings on timeout, f }), { deadlineMs: 200 }); }); +test("Windows broker cleanup deadline never settles before an unkillable child is contained", async () => { + let active = 0; + let settled = false; + const diagnostics: unknown[] = []; + const started = performance.now(); + const result = runWindowsInspectionBrokerBatch({ + entryCount: 1, + startBroker: () => fixtureBroker({ + hang: true, + killReturns: false, + releaseAfterKillMs: 80, + onStart: () => { active += 1; }, + onClose: () => { active -= 1; }, + }), + deadlineMs: 10, + cleanupTimeoutMs: 20, + maxOutputBytes: 32, + onBrokerResult: (diagnostic) => diagnostics.push(diagnostic), + }).then( + () => { settled = true; throw new Error("unkillable broker unexpectedly resolved"); }, + (error) => { settled = true; throw error; }, + ); + const rejection = assert.rejects(result, (error) => error instanceof WindowsNativeStageError + && error.stage === "spawn:cleanup" && error.primaryStage === "spawn:timeout"); + await new Promise((resolve) => setTimeout(resolve, 45)); + assert.equal(settled, false); + assert.equal(active, 1); + await rejection; + assert.equal(active, 0); + assert.ok(performance.now() - started >= 75); + assert.deepEqual(diagnostics, [{ + brokerIndex: "0", + statusStage: "spawn:timeout", + stderr: "empty", + stdout: "empty", + deadline: "expired", + cleanup: "deadline-expired", + }]); + assert.doesNotMatch(JSON.stringify(diagnostics), /SENTINEL|powershell|S-1-|[A-Za-z]:\\/i); +}); + +test("Windows broker supervisor waits for delayed close and both stream drains", async () => { + let active = 0; + let settled = false; + const diagnostics: unknown[] = []; + const started = performance.now(); + const result = runWindowsInspectionBrokerBatch({ + entryCount: 1, + startBroker: () => fixtureBroker({ + hang: true, + closeEventDelayMs: 15, + streamDrainDelayMs: 55, + onStart: () => { active += 1; }, + onClose: () => { active -= 1; }, + }), + deadlineMs: 5, + cleanupTimeoutMs: 100, + maxOutputBytes: 32, + onBrokerResult: (diagnostic) => diagnostics.push(diagnostic), + }).finally(() => { settled = true; }); + const rejection = assert.rejects(result, (error) => error instanceof WindowsNativeStageError + && error.stage === "spawn:timeout" && error.primaryStage === "spawn:timeout"); + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.equal(active, 0); + assert.equal(settled, false); + await rejection; + assert.ok(performance.now() - started >= 50); + assert.deepEqual(diagnostics, [{ + brokerIndex: "0", + statusStage: "spawn:timeout", + stderr: "empty", + stdout: "empty", + deadline: "expired", + cleanup: "contained", + }]); +}); + test("Windows one-target broker documents reject missing, duplicate, and extra results", () => { const { index: _index, kind: _kind, authorityKind: _authorityKind, ...entry } = inspection(); const document = (entries: readonly unknown[]): string => JSON.stringify({ version: 1, entries }); @@ -340,7 +443,7 @@ test("Windows one-target broker documents reject missing, duplicate, and extra r ); }); -test("Windows production isolates entry fields and retains private handle lifetime", () => { +test("Windows production batches fixed inherited handles and revalidates each entry in-process", () => { assert.ok(WINDOWS_NATIVE_STAGE_CODES.includes("broker:fd-duplicate")); assert.ok(WINDOWS_NATIVE_STAGE_CODES.includes("broker:index-info-initial")); assert.ok(WINDOWS_NATIVE_STAGE_CODES.includes("broker:current-user-sid")); @@ -359,32 +462,20 @@ test("Windows production isolates entry fields and retains private handle lifeti assert.equal(windowsBrokerFailureStage(84), "broker:entry-format"); assert.equal(windowsBrokerFailureStage(85), "broker:entry-flags"); assert.equal(windowsBrokerFailureStage(86), "broker:entry-rules"); - - const duplicate = WINDOWS_INSPECTION_SOURCE.indexOf("$stage=80"); + assert.equal(windowsBrokerFailureStage(87), "broker:control"); + assert.match(WINDOWS_INSPECTION_SOURCE, /\$n=__PROPR_ENTRY_COUNT__\n\$r=__PROPR_ROUND_COUNT__/); + assert.match(WINDOWS_INSPECTION_SOURCE, /for\(\$i=0;\$i-lt \$n;\$i\+\+\)/); + assert.match(WINDOWS_INSPECTION_SOURCE, /_get_osfhandle\(3\+\$i\)/); + assert.match(WINDOWS_INSPECTION_SOURCE, + /DuplicateHandle\(\s*\[ProprReadOnlyAuthority\]::GetCurrentProcess\(\),\$originalHandle,\s*\[ProprReadOnlyAuthority\]::GetCurrentProcess\(\),\[ref\]\$privateHandle,0,\$false,2\)\)\{exit \$stage\}/); const initial = WINDOWS_INSPECTION_SOURCE.indexOf("$stage=74"); - const sid = WINDOWS_INSPECTION_SOURCE.indexOf("$stage=78"); - const revalidation = WINDOWS_INSPECTION_SOURCE.indexOf("$stage=79"); + const revalidation = WINDOWS_INSPECTION_SOURCE.indexOf("$stage=79", initial); const decode = WINDOWS_INSPECTION_SOURCE.indexOf("$stage=81", revalidation); const compose = WINDOWS_INSPECTION_SOURCE.indexOf("$stage=82", decode); - const entryFormat = WINDOWS_INSPECTION_SOURCE.indexOf("$stage=84", compose); - const entryFlags = WINDOWS_INSPECTION_SOURCE.indexOf("$stage=85", entryFormat); - const entryRules = WINDOWS_INSPECTION_SOURCE.indexOf("$stage=86", entryFlags); - const entryBuild = WINDOWS_INSPECTION_SOURCE.indexOf("$stage=83", entryRules); - const json = WINDOWS_INSPECTION_SOURCE.indexOf("$stage=77", entryBuild); - assert.ok(duplicate >= 0 && duplicate < initial && initial < sid && sid < revalidation - && revalidation < decode && decode < compose && compose < entryFormat - && entryFormat < entryFlags && entryFlags < entryRules && entryRules < entryBuild - && entryBuild < json); - assert.match(WINDOWS_INSPECTION_SOURCE.slice(duplicate, initial), - /DuplicateHandle\(\s*\[ProprReadOnlyAuthority\]::GetCurrentProcess\(\),\$originalHandle,\s*\[ProprReadOnlyAuthority\]::GetCurrentProcess\(\),\[ref\]\$privateHandle,0,\$false,2\)\)\{exit \$stage\}/); - assert.match(WINDOWS_INSPECTION_SOURCE.slice(initial, sid), - /^\$stage=74\n \$before=.*AllocHGlobal\(52\)\n if\(-not .*GetFileInformationByHandle\(\$privateHandle,\$before\)\)\{exit \$stage\}\n $/s); - assert.match(WINDOWS_INSPECTION_SOURCE.slice(sid, WINDOWS_INSPECTION_SOURCE.indexOf("$stage=75", sid)), - /^\$stage=78\n \$current=.*WindowsIdentity\]::GetCurrent\(\)\.User\n if\(\$null-eq \$current\)\{exit \$stage\}\n \$currentSid=\$current\.Value\n $/s); - assert.match(WINDOWS_INSPECTION_SOURCE.slice(revalidation, decode), - /^\$stage=79\n \$after=.*AllocHGlobal\(52\)\n if\(-not .*GetFileInformationByHandle\(\$privateHandle,\$after\)\)\{exit \$stage\}\n $/s); + const entryBuild = WINDOWS_INSPECTION_SOURCE.indexOf("$stage=83", compose); + assert.ok(initial >= 0 && initial < revalidation && revalidation < decode + && decode < compose && compose < entryBuild); const decodedIdentity = WINDOWS_INSPECTION_SOURCE.slice(decode, compose); - assert.match(decodedIdentity, /^\$stage=81\n \$beforeVolume=/); for (const [field, structure, offset] of [ ["beforeVolume", "before", 28], ["afterVolume", "after", 28], ["beforeHigh", "before", 44], ["beforeLow", "before", 48], @@ -395,69 +486,17 @@ test("Windows production isolates entry fields and retains private handle lifeti assert.equal(WINDOWS_INSPECTION_SOURCE.match(/function Read-ProprUInt32/g)?.length, 1); assert.equal(WINDOWS_INSPECTION_SOURCE.match(/Read-ProprUInt32 \$(?:before|after) (?:28|44|48)/g)?.length, 6); assert.match(decodedIdentity, - /\$afterHigh=Read-ProprUInt32 \$after 44;\$afterLow=Read-ProprUInt32 \$after 48\n $/); + /\$afterHigh=Read-ProprUInt32 \$after 44;\$afterLow=Read-ProprUInt32 \$after 48\n\s*$/); assert.doesNotMatch(WINDOWS_INSPECTION_SOURCE, /\[uint32\]\[Runtime\.InteropServices\.Marshal\]::ReadInt32/); assert.match(WINDOWS_UNSIGNED_FIELD_DECODER_SOURCE, /if\(-not \[BitConverter\]::IsLittleEndian\)\{exit \$stage\}\n \$signed=\[int32\]\[Runtime\.InteropServices\.Marshal\]::ReadInt32\(\$pointer,\$offset\)\n \$bytes=\[BitConverter\]::GetBytes\(\$signed\)\n \[BitConverter\]::ToUInt32\(\$bytes,0\)/); - const composedIdentity = WINDOWS_INSPECTION_SOURCE.slice( - compose, entryFormat, - ); + const composedIdentity = WINDOWS_INSPECTION_SOURCE.slice(compose, WINDOWS_INSPECTION_SOURCE.indexOf("$stage=84", compose)); assert.match(composedIdentity, - /^\$stage=82\n \$beforeId=Join-ProprUInt64 \$beforeLow \$beforeHigh\n if\(\$beforeId-isnot \[uint64\]\)\{exit \$stage\}\n \$afterId=Join-ProprUInt64 \$afterLow \$afterHigh\n if\(\$afterId-isnot \[uint64\]\)\{exit \$stage\}\n $/); - const formattedIdentity = WINDOWS_INSPECTION_SOURCE.slice(entryFormat, entryFlags); - assert.equal(formattedIdentity, [ - "$stage=84", - " $beforeVolumeDecimal=$beforeVolume.ToString([Globalization.CultureInfo]::InvariantCulture)", - " $afterVolumeDecimal=$afterVolume.ToString([Globalization.CultureInfo]::InvariantCulture)", - " $beforeIdDecimal=$beforeId.ToString([Globalization.CultureInfo]::InvariantCulture)", - " $afterIdDecimal=$afterId.ToString([Globalization.CultureInfo]::InvariantCulture)", - " if($beforeVolumeDecimal-isnot [string]-or $beforeVolumeDecimal.Length-eq 0-or $beforeVolumeDecimal.Length-gt 10-or $beforeVolumeDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage}", - " if($afterVolumeDecimal-isnot [string]-or $afterVolumeDecimal.Length-eq 0-or $afterVolumeDecimal.Length-gt 10-or $afterVolumeDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage}", - " if($beforeIdDecimal-isnot [string]-or $beforeIdDecimal.Length-eq 0-or $beforeIdDecimal.Length-gt 20-or $beforeIdDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage}", - " if($afterIdDecimal-isnot [string]-or $afterIdDecimal.Length-eq 0-or $afterIdDecimal.Length-gt 20-or $afterIdDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage}", - " ", - ].join("\n")); - assert.equal(formattedIdentity.match(/\.ToString\(\[Globalization\.CultureInfo\]::InvariantCulture\)/g)?.length, 4); - assert.doesNotMatch(formattedIdentity, /\$entry=|Console|Write-|Out\./); - const entryFlagValidation = WINDOWS_INSPECTION_SOURCE.slice(entryFlags, entryRules); - assert.equal(entryFlagValidation, [ - "$stage=85", - " $daclProtected=[bool](($control-band 0x1000)-ne 0)", - " $reparsePoint=[bool](([Runtime.InteropServices.Marshal]::ReadInt32($before,0)-band 0x400)-ne 0)", - " if($daclProtected-isnot [bool]-or $reparsePoint-isnot [bool]){exit $stage}", - " ", - ].join("\n")); - assert.doesNotMatch(entryFlagValidation, /Console|Write-|Out\./); - const entryRuleValidation = WINDOWS_INSPECTION_SOURCE.slice(entryRules, entryBuild); - assert.equal(entryRuleValidation, [ - "$stage=86", - " [object[]]$rulesArray=$rules.ToArray()", - " if($rulesArray-isnot [object[]]-or $rulesArray.Count-ne $rules.Count-or $rulesArray.Count-gt 128){exit $stage}", - " for($ruleIndex=0;$ruleIndex-lt $rulesArray.Count;$ruleIndex++){", - " if(-not [object]::ReferenceEquals($rulesArray[$ruleIndex],$rules[$ruleIndex])){exit $stage}", - " }", - " ", - ].join("\n")); + /^\$stage=82\n\s+\$beforeId=Join-ProprUInt64 \$beforeLow \$beforeHigh\n\s+if\(\$beforeId-isnot \[uint64\]\)\{exit \$stage\}\n\s+\$afterId=Join-ProprUInt64 \$afterLow \$afterHigh\n\s+if\(\$afterId-isnot \[uint64\]\)\{exit \$stage\}\n\s*$/); assert.equal(WINDOWS_INSPECTION_SOURCE.match(/\[object\[\]\]\$rulesArray=\$rules\.ToArray\(\)/g)?.length, 1); assert.doesNotMatch(WINDOWS_INSPECTION_SOURCE, /@\(\s*\$rules\s*\)/); - assert.doesNotMatch(entryRuleValidation, /ConvertTo-Json|\.ToString|Console|Write-|Out\./); - const entryConstruction = WINDOWS_INSPECTION_SOURCE.slice(entryBuild, json); - assert.equal(entryConstruction, [ - "$stage=83", - " $entry=[pscustomobject][ordered]@{", - " currentUserSid=$currentSid;ownerSid=$ownerSid", - " daclProtected=$daclProtected;reparsePoint=$reparsePoint", - " volumeSerialNumber=$beforeVolumeDecimal", - " fileId=$beforeIdDecimal", - " verifiedVolumeSerialNumber=$afterVolumeDecimal", - " verifiedFileId=$afterIdDecimal;rules=$rulesArray", - " }", - " ", - ].join("\n")); - assert.doesNotMatch(WINDOWS_INSPECTION_SOURCE, /__PROPR_|\bindex=|\bkind=|authorityKind=/); - assert.doesNotMatch(entryConstruction, - /Marshal|\.ToString|InvariantCulture|@\(\$rules\)|ReferenceEquals|-band|\bfor\s*\(/); + assert.doesNotMatch(WINDOWS_INSPECTION_SOURCE, /\bpath=|\bkind=|authorityKind=|S-1-/); assert.doesNotMatch(composedIdentity, /ToString|\$entry=/); assert.doesNotMatch(WINDOWS_INSPECTION_SOURCE, /4294967296|\[uint64\]\$(?:before|after)High\*/); assert.match(WINDOWS_UINT64_COMPOSER_SOURCE, @@ -486,10 +525,10 @@ test("Windows production isolates entry fields and retains private handle lifeti assert.match(WINDOWS_INSPECTION_SOURCE, /GetSecurityInfo\(\$privateHandle,1,5,\[ref\]\$owner,\[ref\]\$group,\[ref\]\$dacl,\[ref\]\$sacl,\[ref\]\$descriptor\)/); assert.equal(WINDOWS_INSPECTION_SOURCE.match(/::CloseHandle\(\$privateHandle\)/g)?.length, 1); - assert.match(WINDOWS_INSPECTION_SOURCE, - /finally \{if\(\$privateHandleOwned\)\{\$null=\[ProprReadOnlyAuthority\]::CloseHandle\(\$privateHandle\)\}\}/); + assert.match(WINDOWS_INSPECTION_SOURCE, /ReadLine\(\)-cne 'PROPR_REVALIDATE_V1'/); + assert.match(WINDOWS_INSPECTION_SOURCE, /\[Console\]::Out\.WriteLine\(\$json\)/); + assert.match(WINDOWS_INSPECTION_SOURCE, /FreeHGlobal\(\$before\)/); assert.doesNotMatch(WINDOWS_INSPECTION_SOURCE, /CloseHandle\(\$originalHandle\)/); - assert.doesNotMatch(WINDOWS_INSPECTION_SOURCE.slice(initial), /\$originalHandle/); }); test("Windows PowerShell boundary retains a derived minimal environment and no filesystem writes", () => { @@ -503,7 +542,7 @@ test("Windows PowerShell boundary retains a derived minimal environment and no f ]) assert.equal(forbidden in windowsPowerShellEnvironment("C:\\Windows"), false); assert.equal(WINDOWS_INSPECTOR_CREATES_CHILD_PROCESSES, false); assert.equal(WINDOWS_INSPECTOR_WRITES_FILESYSTEM, false); - assert.equal(WINDOWS_INSPECTOR_TRANSPORT, "inherited-standard-handle"); + assert.equal(WINDOWS_INSPECTOR_TRANSPORT, "inherited-fixed-fd-table"); for (const source of [WINDOWS_INSPECTION_SOURCE, WINDOWS_NATIVE_TIMING_PROBE_SOURCE]) { assert.doesNotMatch(source, /Add-Type|Start-Process|Set-Content|Out-File|New-Item|Remove-Item|Invoke-Expression/i); } diff --git a/packages/cli/src/connectRootAuthority.ts b/packages/cli/src/connectRootAuthority.ts index 38e1a4624..586e5606a 100644 --- a/packages/cli/src/connectRootAuthority.ts +++ b/packages/cli/src/connectRootAuthority.ts @@ -18,6 +18,7 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { + beginWindowsReadOnlyInspectionGeneration, parseWindowsInspectionDocument, reportWindowsNativeStage, runWindowsReadOnlyInspection, @@ -128,6 +129,15 @@ export interface ConnectRootAuthorityInspector { kind?: ConnectAuthorityEntryKind, ): Promise; inspectWindowsAcls?(entries: readonly WindowsAuthorityTarget[]): Promise; + beginWindowsAclsGeneration?( + entries: readonly WindowsAuthorityTarget[], + ): Promise; +} + +export interface WindowsAuthorityInspectionGeneration { + readonly initial: readonly WindowsAuthorityInspection[]; + revalidate(entries: readonly WindowsAuthorityTarget[]): Promise; + abort(): Promise; } export type WindowsAuthorityPolicyReason = @@ -392,6 +402,32 @@ async function nativeWindowsAcls( } } +async function beginNativeWindowsAclsGeneration( + entries: readonly WindowsAuthorityTarget[], +): Promise { + try { + const generation = await beginWindowsReadOnlyInspectionGeneration(entries); + return { + initial: generation.initial, + revalidate: async (finalEntries) => { + try { return await generation.revalidate(finalEntries); } catch (error) { + if (error instanceof WindowsNativeStageError) reportWindowsNativeStage(error.stage); + throw new WindowsAuthorityInspectionError(); + } + }, + abort: async () => { + try { await generation.abort(); } catch (error) { + if (error instanceof WindowsNativeStageError) reportWindowsNativeStage(error.stage); + throw new WindowsAuthorityInspectionError(); + } + }, + }; + } catch (error) { + if (error instanceof WindowsNativeStageError) reportWindowsNativeStage(error.stage); + throw new WindowsAuthorityInspectionError(); + } +} + async function nativeWindowsAcl( path: string, expectedIdentity: StableAuthorityIdentity, @@ -411,6 +447,7 @@ export const nativeConnectRootAuthorityInspector: ConnectRootAuthorityInspector inspectDarwinAcl: nativeDarwinAcl, inspectWindowsAcl: nativeWindowsAcl, inspectWindowsAcls: nativeWindowsAcls, + beginWindowsAclsGeneration: beginNativeWindowsAclsGeneration, }; /** Windows mutation is unsupported until the separately reviewed authority work lands. */ @@ -599,23 +636,23 @@ export async function assertNativeEntryAuthority( } } -/** Inspect and bind one Windows descriptor batch before applying entry policy. */ -export async function assertNativeWindowsEntriesAuthority( - inspector: ConnectRootAuthorityInspector, - entries: readonly { path: string; kind: ConnectAuthorityEntryKind; pinnedFd: number }[], -): Promise { - const targets = entries.map((entry) => ({ +type WindowsAuthorityEntry = { path: string; kind: ConnectAuthorityEntryKind; pinnedFd: number }; + +function windowsAuthorityTargets(entries: readonly WindowsAuthorityEntry[]): readonly WindowsAuthorityTarget[] { + return entries.map((entry) => ({ path: entry.path, kind: entry.kind, expectedIdentity: stableAuthorityIdentity(entry.pinnedFd), pinnedFd: entry.pinnedFd, })); - const batched = inspector.inspectWindowsAcls !== undefined; - const inspections = inspector.inspectWindowsAcls - ? await inspector.inspectWindowsAcls(targets) - : await Promise.all(targets.map((target) => inspector.inspectWindowsAcl( - target.path, target.expectedIdentity, target.pinnedFd, target.kind, - ))); +} + +function assertWindowsAuthorityInspectionResults( + inspections: readonly WindowsAuthorityInspection[], + targets: readonly WindowsAuthorityTarget[], + entries: readonly WindowsAuthorityEntry[], + batched: boolean, +): void { if (inspections.length !== targets.length) { reportWindowsNativeStage("parent:entry-count"); throw new WindowsAuthorityInspectionError(); @@ -660,4 +697,69 @@ export async function assertNativeWindowsEntriesAuthority( } } +/** Inspect and bind one Windows descriptor batch before applying entry policy. */ +export async function assertNativeWindowsEntriesAuthority( + inspector: ConnectRootAuthorityInspector, + entries: readonly WindowsAuthorityEntry[], +): Promise { + const targets = windowsAuthorityTargets(entries); + const batched = inspector.inspectWindowsAcls !== undefined; + const inspections = inspector.inspectWindowsAcls + ? await inspector.inspectWindowsAcls(targets) + : await Promise.all(targets.map((target) => inspector.inspectWindowsAcl( + target.path, target.expectedIdentity, target.pinnedFd, target.kind, + ))); + assertWindowsAuthorityInspectionResults(inspections, targets, entries, batched); +} + +export interface NativeWindowsEntriesAuthorityGeneration { + revalidate(entries: readonly WindowsAuthorityEntry[]): Promise; + abort(): Promise; +} + +/** + * Keep native before/after authority in one cold-start generation when the + * inspector supports it. Fixture/custom inspectors retain the two-call path. + */ +export async function beginNativeWindowsEntriesAuthorityGeneration( + inspector: ConnectRootAuthorityInspector, + entries: readonly WindowsAuthorityEntry[], +): Promise { + if (!inspector.beginWindowsAclsGeneration) { + await assertNativeWindowsEntriesAuthority(inspector, entries); + let consumed = false; + return { + revalidate: async (finalEntries) => { + if (consumed) throw new WindowsAuthorityInspectionError(); + consumed = true; + await assertNativeWindowsEntriesAuthority(inspector, finalEntries); + }, + abort: async () => { consumed = true; }, + }; + } + const targets = windowsAuthorityTargets(entries); + const generation = await inspector.beginWindowsAclsGeneration(targets); + try { + assertWindowsAuthorityInspectionResults(generation.initial, targets, entries, true); + } catch (error) { + await generation.abort(); + throw error; + } + let consumed = false; + return { + revalidate: async (finalEntries) => { + if (consumed) throw new WindowsAuthorityInspectionError(); + consumed = true; + const finalTargets = windowsAuthorityTargets(finalEntries); + const inspections = await generation.revalidate(finalTargets); + assertWindowsAuthorityInspectionResults(inspections, finalTargets, finalEntries, true); + }, + abort: async () => { + if (consumed) return; + consumed = true; + await generation.abort(); + }, + }; +} + export { parseWindowsInspectionDocument }; diff --git a/packages/cli/src/connectWindowsAuthority.ts b/packages/cli/src/connectWindowsAuthority.ts index da4e5f167..e0618d402 100644 --- a/packages/cli/src/connectWindowsAuthority.ts +++ b/packages/cli/src/connectWindowsAuthority.ts @@ -33,7 +33,7 @@ export const WINDOWS_NATIVE_STAGE_CODES = Object.freeze([ "broker:ps-version", "broker:job", "broker:fd", "broker:fd-duplicate", "broker:index-info-initial", "broker:security-info", "broker:acl", "broker:json", "broker:current-user-sid", "broker:index-info-revalidation", "broker:index-info-decode", "broker:index-info-compose", "broker:entry-format", - "broker:entry-flags", "broker:entry-rules", "broker:entry-build", + "broker:entry-flags", "broker:entry-rules", "broker:entry-build", "broker:control", "parent:utf8", "parent:json-parse", "parent:json-canonical", "parent:document-shape", "parent:entry-count", "parent:entry-shape", "parent:json-shape", "parent:descriptor-bind", "parent:post-bind", ] as const); @@ -44,7 +44,11 @@ const WINDOWS_NATIVE_STAGE_SET: ReadonlySet = new Set(WINDOWS_NATIVE_STA const WINDOWS_NATIVE_DIAGNOSTIC_HOOK = Symbol.for("propr.test.windowsNativeDiagnostic"); export class WindowsNativeStageError extends Error { - constructor(readonly stage: WindowsNativeStageCode) { + constructor( + readonly stage: WindowsNativeStageCode, + /** The initiating failure retained when cleanup becomes the terminal result. */ + readonly primaryStage: WindowsNativeStageCode = stage, + ) { super("Windows native authority inspection failed"); this.name = "WindowsNativeStageError"; } @@ -57,18 +61,22 @@ export function reportWindowsNativeStage(stage: WindowsNativeStageCode): void { try { (hook as (value: string) => void)(stage); } catch { /* Diagnostics never alter production status. */ } } -function stageError(stage: WindowsNativeStageCode): WindowsNativeStageError { - return new WindowsNativeStageError(stage); +function stageError( + stage: WindowsNativeStageCode, + primaryStage: WindowsNativeStageCode = stage, +): WindowsNativeStageError { + return new WindowsNativeStageError(stage, primaryStage); } -// Each production inspector receives exactly one already-open target as its -// standard-input HANDLE. Unlike Node extra stdio slots, STARTF_USESTDHANDLES is -// a documented Windows process boundary and GetStdHandle returns the inherited -// HANDLE directly. The script contains no process-creation API or external -// command; terminating powershell.exe therefore terminates the complete tree. +// Each production inspector receives every already-open target in one fixed, +// bounded fd table beginning at fd 3. The only per-spawn source substitutions +// are canonical entry/round counts; target paths, HANDLE values, identities, +// kinds, SIDs, and metadata never cross argv/environment. The script contains +// no process-creation API or external command, so powershell.exe is the entire +// owned process tree. export const WINDOWS_INSPECTOR_CREATES_CHILD_PROCESSES = false; export const WINDOWS_INSPECTOR_WRITES_FILESYSTEM = false; -export const WINDOWS_INSPECTOR_TRANSPORT = "inherited-standard-handle" as const; +export const WINDOWS_INSPECTOR_TRANSPORT = "inherited-fixed-fd-table" as const; export const WINDOWS_UNSIGNED_FIELD_DECODER_SOURCE = String.raw` function Read-ProprUInt32([IntPtr]$pointer,[int]$offset){ @@ -95,10 +103,11 @@ $ProgressPreference='SilentlyContinue' Set-StrictMode -Version 2 ${WINDOWS_UNSIGNED_FIELD_DECODER_SOURCE} ${WINDOWS_UINT64_COMPOSER_SOURCE} +$n=__PROPR_ENTRY_COUNT__ +$r=__PROPR_ROUND_COUNT__ $stage=71 -$privateHandle=[IntPtr]::Zero -$privateHandleOwned=$false try { + if($n-lt 1-or $n-gt 32-or ($r-ne 1-and $r-ne 2)){exit $stage} if($PSVersionTable.PSVersion.Major-ne 5-or $PSVersionTable.PSVersion.Minor-ne 1-or $PSVersionTable.PSEdition-ne 'Desktop'-or -not [Environment]::Is64BitProcess){exit $stage} $assembly=[AppDomain]::CurrentDomain.DefineDynamicAssembly( @@ -114,7 +123,7 @@ try { } $winapi=[Runtime.InteropServices.CallingConvention]::Winapi $intptr=[IntPtr];$intptrRef=$intptr.MakeByRefType();$uint=[uint32];$uintRef=$uint.MakeByRefType();$ushortRef=([uint16]).MakeByRefType();$boolRef=([bool]).MakeByRefType() - Add-NativeMethod 'GetStdHandle' 'kernel32.dll' $intptr @([int]) $winapi + Add-NativeMethod '_get_osfhandle' 'msvcrt.dll' $intptr @([int]) $winapi Add-NativeMethod 'DuplicateHandle' 'kernel32.dll' ([bool]) @($intptr,$intptr,$intptr,$intptrRef,$uint,[bool],$uint) $winapi Add-NativeMethod 'CloseHandle' 'kernel32.dll' ([bool]) @($intptr) $winapi Add-NativeMethod 'GetFileInformationByHandle' 'kernel32.dll' ([bool]) @($intptr,$intptr) $winapi @@ -129,25 +138,24 @@ try { $stage=72 $inJob=$false if(-not [ProprReadOnlyAuthority]::IsProcessInJob([ProprReadOnlyAuthority]::GetCurrentProcess(),[IntPtr]::Zero,[ref]$inJob)){exit $stage} - $stage=73 - $originalHandle=[ProprReadOnlyAuthority]::GetStdHandle(-10) - if($originalHandle-eq [IntPtr](-1)-or $originalHandle-eq [IntPtr](-2)-or $originalHandle-eq [IntPtr]::Zero){exit $stage} - $stage=80 - if(-not [ProprReadOnlyAuthority]::DuplicateHandle( - [ProprReadOnlyAuthority]::GetCurrentProcess(),$originalHandle, - [ProprReadOnlyAuthority]::GetCurrentProcess(),[ref]$privateHandle,0,$false,2)){exit $stage} - $privateHandleOwned=$true - if($privateHandle-eq [IntPtr](-1)-or $privateHandle-eq [IntPtr](-2)-or $privateHandle-eq [IntPtr]::Zero){exit $stage} - $stage=74 - $before=[Runtime.InteropServices.Marshal]::AllocHGlobal(52) - if(-not [ProprReadOnlyAuthority]::GetFileInformationByHandle($privateHandle,$before)){exit $stage} - $stage=78 - $current=[Security.Principal.WindowsIdentity]::GetCurrent().User - if($null-eq $current){exit $stage} - $currentSid=$current.Value - $stage=75 - $owner=[IntPtr]::Zero;$group=[IntPtr]::Zero;$dacl=[IntPtr]::Zero;$sacl=[IntPtr]::Zero;$descriptor=[IntPtr]::Zero + function Inspect-ProprEntry([int]$i,[string]$currentSid){ + $privateHandle=[IntPtr]::Zero + $before=[IntPtr]::Zero;$after=[IntPtr]::Zero;$aclInfo=[IntPtr]::Zero + $descriptor=[IntPtr]::Zero try { + $stage=73 + $originalHandle=[ProprReadOnlyAuthority]::_get_osfhandle(3+$i) + if($originalHandle-eq [IntPtr](-1)-or $originalHandle-eq [IntPtr](-2)-or $originalHandle-eq [IntPtr]::Zero){exit $stage} + $stage=80 + if(-not [ProprReadOnlyAuthority]::DuplicateHandle( + [ProprReadOnlyAuthority]::GetCurrentProcess(),$originalHandle, + [ProprReadOnlyAuthority]::GetCurrentProcess(),[ref]$privateHandle,0,$false,2)){exit $stage} + if($privateHandle-eq [IntPtr](-1)-or $privateHandle-eq [IntPtr](-2)-or $privateHandle-eq [IntPtr]::Zero){exit $stage} + $stage=74 + $before=[Runtime.InteropServices.Marshal]::AllocHGlobal(52) + if(-not [ProprReadOnlyAuthority]::GetFileInformationByHandle($privateHandle,$before)){exit $stage} + $stage=75 + $owner=[IntPtr]::Zero;$group=[IntPtr]::Zero;$dacl=[IntPtr]::Zero;$sacl=[IntPtr]::Zero if([ProprReadOnlyAuthority]::GetSecurityInfo($privateHandle,1,5,[ref]$owner,[ref]$group,[ref]$dacl,[ref]$sacl,[ref]$descriptor)-ne 0){exit $stage} if($owner-eq [IntPtr]::Zero-or $dacl-eq [IntPtr]::Zero-or $descriptor-eq [IntPtr]::Zero){exit $stage} $ownerSid=(New-Object Security.Principal.SecurityIdentifier($owner)).Value @@ -177,56 +185,76 @@ try { rights=$mask.ToString([Globalization.CultureInfo]::InvariantCulture) }) } - } finally {if($descriptor-ne [IntPtr]::Zero){$null=[ProprReadOnlyAuthority]::LocalFree($descriptor)}} - $stage=79 - $after=[Runtime.InteropServices.Marshal]::AllocHGlobal(52) - if(-not [ProprReadOnlyAuthority]::GetFileInformationByHandle($privateHandle,$after)){exit $stage} - $stage=81 - $beforeVolume=Read-ProprUInt32 $before 28 - $afterVolume=Read-ProprUInt32 $after 28 - $beforeHigh=Read-ProprUInt32 $before 44;$beforeLow=Read-ProprUInt32 $before 48 - $afterHigh=Read-ProprUInt32 $after 44;$afterLow=Read-ProprUInt32 $after 48 - $stage=82 - $beforeId=Join-ProprUInt64 $beforeLow $beforeHigh - if($beforeId-isnot [uint64]){exit $stage} - $afterId=Join-ProprUInt64 $afterLow $afterHigh - if($afterId-isnot [uint64]){exit $stage} - $stage=84 - $beforeVolumeDecimal=$beforeVolume.ToString([Globalization.CultureInfo]::InvariantCulture) - $afterVolumeDecimal=$afterVolume.ToString([Globalization.CultureInfo]::InvariantCulture) - $beforeIdDecimal=$beforeId.ToString([Globalization.CultureInfo]::InvariantCulture) - $afterIdDecimal=$afterId.ToString([Globalization.CultureInfo]::InvariantCulture) - if($beforeVolumeDecimal-isnot [string]-or $beforeVolumeDecimal.Length-eq 0-or $beforeVolumeDecimal.Length-gt 10-or $beforeVolumeDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage} - if($afterVolumeDecimal-isnot [string]-or $afterVolumeDecimal.Length-eq 0-or $afterVolumeDecimal.Length-gt 10-or $afterVolumeDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage} - if($beforeIdDecimal-isnot [string]-or $beforeIdDecimal.Length-eq 0-or $beforeIdDecimal.Length-gt 20-or $beforeIdDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage} - if($afterIdDecimal-isnot [string]-or $afterIdDecimal.Length-eq 0-or $afterIdDecimal.Length-gt 20-or $afterIdDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage} - $stage=85 - $daclProtected=[bool](($control-band 0x1000)-ne 0) - $reparsePoint=[bool](([Runtime.InteropServices.Marshal]::ReadInt32($before,0)-band 0x400)-ne 0) - if($daclProtected-isnot [bool]-or $reparsePoint-isnot [bool]){exit $stage} - $stage=86 - [object[]]$rulesArray=$rules.ToArray() - if($rulesArray-isnot [object[]]-or $rulesArray.Count-ne $rules.Count-or $rulesArray.Count-gt 128){exit $stage} - for($ruleIndex=0;$ruleIndex-lt $rulesArray.Count;$ruleIndex++){ - if(-not [object]::ReferenceEquals($rulesArray[$ruleIndex],$rules[$ruleIndex])){exit $stage} + $stage=79 + $after=[Runtime.InteropServices.Marshal]::AllocHGlobal(52) + if(-not [ProprReadOnlyAuthority]::GetFileInformationByHandle($privateHandle,$after)){exit $stage} + $stage=81 + $beforeVolume=Read-ProprUInt32 $before 28 + $afterVolume=Read-ProprUInt32 $after 28 + $beforeHigh=Read-ProprUInt32 $before 44;$beforeLow=Read-ProprUInt32 $before 48 + $afterHigh=Read-ProprUInt32 $after 44;$afterLow=Read-ProprUInt32 $after 48 + $stage=82 + $beforeId=Join-ProprUInt64 $beforeLow $beforeHigh + if($beforeId-isnot [uint64]){exit $stage} + $afterId=Join-ProprUInt64 $afterLow $afterHigh + if($afterId-isnot [uint64]){exit $stage} + $stage=84 + $beforeVolumeDecimal=$beforeVolume.ToString([Globalization.CultureInfo]::InvariantCulture) + $afterVolumeDecimal=$afterVolume.ToString([Globalization.CultureInfo]::InvariantCulture) + $beforeIdDecimal=$beforeId.ToString([Globalization.CultureInfo]::InvariantCulture) + $afterIdDecimal=$afterId.ToString([Globalization.CultureInfo]::InvariantCulture) + if($beforeVolumeDecimal-isnot [string]-or $beforeVolumeDecimal.Length-eq 0-or $beforeVolumeDecimal.Length-gt 10-or $beforeVolumeDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage} + if($afterVolumeDecimal-isnot [string]-or $afterVolumeDecimal.Length-eq 0-or $afterVolumeDecimal.Length-gt 10-or $afterVolumeDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage} + if($beforeIdDecimal-isnot [string]-or $beforeIdDecimal.Length-eq 0-or $beforeIdDecimal.Length-gt 20-or $beforeIdDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage} + if($afterIdDecimal-isnot [string]-or $afterIdDecimal.Length-eq 0-or $afterIdDecimal.Length-gt 20-or $afterIdDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage} + $stage=85 + $daclProtected=[bool](($control-band 0x1000)-ne 0) + $reparsePoint=[bool](([Runtime.InteropServices.Marshal]::ReadInt32($before,0)-band 0x400)-ne 0) + if($daclProtected-isnot [bool]-or $reparsePoint-isnot [bool]){exit $stage} + $stage=86 + [object[]]$rulesArray=$rules.ToArray() + if($rulesArray-isnot [object[]]-or $rulesArray.Count-ne $rules.Count-or $rulesArray.Count-gt 128){exit $stage} + for($ruleIndex=0;$ruleIndex-lt $rulesArray.Count;$ruleIndex++){ + if(-not [object]::ReferenceEquals($rulesArray[$ruleIndex],$rules[$ruleIndex])){exit $stage} + } + $stage=83 + return [pscustomobject][ordered]@{ + currentUserSid=$currentSid;ownerSid=$ownerSid + daclProtected=$daclProtected;reparsePoint=$reparsePoint + volumeSerialNumber=$beforeVolumeDecimal + fileId=$beforeIdDecimal + verifiedVolumeSerialNumber=$afterVolumeDecimal + verifiedFileId=$afterIdDecimal;rules=$rulesArray + } + } finally { + if($descriptor-ne [IntPtr]::Zero){$null=[ProprReadOnlyAuthority]::LocalFree($descriptor)};if($aclInfo-ne [IntPtr]::Zero){[Runtime.InteropServices.Marshal]::FreeHGlobal($aclInfo)} + if($after-ne [IntPtr]::Zero){[Runtime.InteropServices.Marshal]::FreeHGlobal($after)};if($before-ne [IntPtr]::Zero){[Runtime.InteropServices.Marshal]::FreeHGlobal($before)} + if($privateHandle-ne [IntPtr]::Zero){$null=[ProprReadOnlyAuthority]::CloseHandle($privateHandle)} } - $stage=83 - $entry=[pscustomobject][ordered]@{ - currentUserSid=$currentSid;ownerSid=$ownerSid - daclProtected=$daclProtected;reparsePoint=$reparsePoint - volumeSerialNumber=$beforeVolumeDecimal - fileId=$beforeIdDecimal - verifiedVolumeSerialNumber=$afterVolumeDecimal - verifiedFileId=$afterIdDecimal;rules=$rulesArray } - $stage=77 - $json=ConvertTo-Json ([pscustomobject][ordered]@{version=1;entries=@($entry)}) -Compress -Depth 5 - if([Text.Encoding]::UTF8.GetByteCount($json)-gt 131072){exit $stage} [Console]::OutputEncoding=New-Object Text.UTF8Encoding($false,$true) - [Console]::Out.Write($json) + for($q=0;$q-lt $r;$q++){ + if($q-ne 0){ + $stage=87 + if([Console]::In.ReadLine()-cne 'PROPR_REVALIDATE_V1'){exit $stage} + } + $stage=78 + $current=[Security.Principal.WindowsIdentity]::GetCurrent().User + if($null-eq $current){exit $stage} + $entries=New-Object Collections.Generic.List[object] + for($i=0;$i-lt $n;$i++){ + $entries.Add((Inspect-ProprEntry $i $current.Value)) + } + $stage=77 + [object[]]$entryArray=$entries.ToArray() + if($entryArray.Count-ne $n){exit $stage} + $json=ConvertTo-Json ([pscustomobject][ordered]@{version=1;entries=$entryArray}) -Compress -Depth 5 + if([Text.Encoding]::UTF8.GetByteCount($json)-gt 131072){exit $stage} + [Console]::Out.WriteLine($json) + [Console]::Out.Flush() + } exit 0 }catch{exit $stage} -finally {if($privateHandleOwned){$null=[ProprReadOnlyAuthority]::CloseHandle($privateHandle)}} `; export const WINDOWS_NATIVE_PROBE_MILESTONES = Object.freeze([ @@ -435,7 +463,7 @@ export function windowsBrokerFailureStage(status: number | null): WindowsNativeS 75: "broker:security-info", 76: "broker:acl", 77: "broker:json", 78: "broker:current-user-sid", 79: "broker:index-info-revalidation", 80: "broker:fd-duplicate", 81: "broker:index-info-decode", 82: "broker:index-info-compose", 83: "broker:entry-build", - 84: "broker:entry-format", 85: "broker:entry-flags", 86: "broker:entry-rules", + 84: "broker:entry-format", 85: "broker:entry-flags", 86: "broker:entry-rules", 87: "broker:control", }; return status === null ? "spawn:status" : (stages[status] ?? "spawn:status"); } @@ -476,14 +504,27 @@ function spawnPowerShellSync( } catch { throw stageError("spawn:create"); } } -function spawnInspectionBroker(executable: HeldExecutable, pinnedFd: number): ChildProcess { +function inspectionSource(entryCount: number, roundCount: 1 | 2): string { + if (!Number.isInteger(entryCount) || entryCount < 1 || entryCount > WINDOWS_INSPECTION_MAX_ENTRIES) { + throw stageError("spawn:create"); + } + return WINDOWS_INSPECTION_SOURCE + .replace("__PROPR_ENTRY_COUNT__", String(entryCount)) + .replace("__PROPR_ROUND_COUNT__", String(roundCount)); +} + +function spawnInspectionBroker( + executable: HeldExecutable, + targets: readonly WindowsAuthorityTarget[], + roundCount: 1 | 2, +): ChildProcess { try { - return spawn(executable.path, powerShellArguments(WINDOWS_INSPECTION_SOURCE), { + return spawn(executable.path, powerShellArguments(inspectionSource(targets.length, roundCount)), { shell: false, windowsHide: true, cwd: win32.dirname(executable.path), env: windowsPowerShellEnvironment(executable.systemRoot), - stdio: [pinnedFd, "pipe", "pipe"], + stdio: [roundCount === 2 ? "pipe" : "ignore", "pipe", "pipe", ...targets.map((target) => target.pinnedFd)], }); } catch { throw stageError("spawn:create"); } } @@ -553,6 +594,16 @@ const WINDOWS_BROKER_ENTRY_KEYS = Object.freeze([ ]); export function parseWindowsBrokerDocument(value: Buffer | string): WindowsBrokerInspection { + const entries = parseWindowsBrokerBatchDocument(value, 1); + return entries[0]; +} + +export function parseWindowsBrokerBatchDocument( + value: Buffer | string, + expectedEntryCount: number, +): readonly WindowsBrokerInspection[] { + if (!Number.isInteger(expectedEntryCount) || expectedEntryCount < 1 + || expectedEntryCount > WINDOWS_INSPECTION_MAX_ENTRIES) throw stageError("parent:entry-count"); const text = strictUtf8(value); let parsed: unknown; try { parsed = JSON.parse(text); } catch { throw stageError("parent:json-parse"); } @@ -563,19 +614,51 @@ export function parseWindowsBrokerDocument(value: Buffer | string): WindowsBroke const document = parsed as Record; if (Object.keys(document).sort().join(",") !== "entries,version" || document.version !== 1 || !Array.isArray(document.entries)) throw stageError("parent:document-shape"); - if (document.entries.length !== 1) throw stageError("parent:entry-count"); - const entry = document.entries[0]; - if (!entry || typeof entry !== "object" || Array.isArray(entry) - || Object.keys(entry).sort().join(",") !== [...WINDOWS_BROKER_ENTRY_KEYS].sort().join(",")) { - throw stageError("parent:entry-shape"); + if (document.entries.length !== expectedEntryCount) throw stageError("parent:entry-count"); + for (const entry of document.entries) { + if (!entry || typeof entry !== "object" || Array.isArray(entry) + || Object.keys(entry).sort().join(",") !== [...WINDOWS_BROKER_ENTRY_KEYS].sort().join(",")) { + throw stageError("parent:entry-shape"); + } } - return entry as WindowsBrokerInspection; + return document.entries as readonly WindowsBrokerInspection[]; } interface InspectionBrokerState { readonly child: ChildProcess; readonly stdout: Buffer[]; + stdoutState: WindowsBrokerStreamState; + stderrState: WindowsBrokerStreamState; + stdoutEnded: boolean; + stderrEnded: boolean; closed: boolean; + statusStage: WindowsBrokerStatusStage; + diagnosticReported: boolean; +} + +export type WindowsBrokerIndexBucket = "0" | "1" | "2-3" | "4-7" | "8-15" | "16-31"; +export type WindowsBrokerStreamState = "empty" | "nonempty" | "overflow"; +export type WindowsBrokerStatusStage = WindowsNativeStageCode | "ok" | "sibling-termination"; +export type WindowsBrokerDeadlineState = "active" | "expired"; +export type WindowsBrokerCleanupState = "not-started" | "contained" | "deadline-expired"; + +/** Fixed, secret-free post-drain attribution for native regression evidence. */ +export interface WindowsBrokerResultDiagnostic { + readonly brokerIndex: WindowsBrokerIndexBucket; + readonly statusStage: WindowsBrokerStatusStage; + readonly stderr: WindowsBrokerStreamState; + readonly stdout: WindowsBrokerStreamState; + readonly deadline: WindowsBrokerDeadlineState; + readonly cleanup: WindowsBrokerCleanupState; +} + +function windowsBrokerIndexBucket(index: number): WindowsBrokerIndexBucket { + if (index === 0) return "0"; + if (index === 1) return "1"; + if (index <= 3) return "2-3"; + if (index <= 7) return "4-7"; + if (index <= 15) return "8-15"; + return "16-31"; } /** @@ -589,6 +672,7 @@ export interface WindowsInspectionBrokerBatchOptions { readonly deadlineMs: number; readonly cleanupTimeoutMs: number; readonly maxOutputBytes: number; + readonly onBrokerResult?: (diagnostic: WindowsBrokerResultDiagnostic) => void; } export function runWindowsInspectionBrokerBatch({ @@ -597,6 +681,7 @@ export function runWindowsInspectionBrokerBatch({ deadlineMs, cleanupTimeoutMs, maxOutputBytes, + onBrokerResult, }: WindowsInspectionBrokerBatchOptions): Promise { if (!Number.isInteger(entryCount) || entryCount < 1 || entryCount > WINDOWS_INSPECTION_MAX_ENTRIES || !Number.isInteger(deadlineMs) || deadlineMs < 1 || deadlineMs > WINDOWS_INSPECTION_TIMEOUT_MS @@ -611,32 +696,63 @@ export function runWindowsInspectionBrokerBatch({ let spawningComplete = false; let failure: WindowsNativeStageError | undefined; let settled = false; + let deadlineExpired = false; + let cleanupDeadlineExpired = false; let cleanupTimer: ReturnType | undefined; + let containmentTimer: ReturnType | undefined; + + const report = (broker: InspectionBrokerState, index: number): void => { + if (broker.diagnosticReported || !broker.closed || !broker.stdoutEnded || !broker.stderrEnded) return; + broker.diagnosticReported = true; + try { + onBrokerResult?.(Object.freeze({ + brokerIndex: windowsBrokerIndexBucket(index), + statusStage: broker.statusStage, + stderr: broker.stderrState, + stdout: broker.stdoutState, + deadline: deadlineExpired ? "expired" : "active", + cleanup: cleanupDeadlineExpired + ? "deadline-expired" + : failure ? "contained" : "not-started", + })); + } catch { /* Diagnostics never alter containment or the production result. */ } + }; const settle = (): void => { - if (settled || !spawningComplete || closedCount !== brokers.length) return; + brokers.forEach(report); + if (settled || !spawningComplete || closedCount !== brokers.length + || brokers.some((broker) => !broker.stdoutEnded || !broker.stderrEnded)) return; settled = true; clearTimeout(deadlineTimer); if (cleanupTimer !== undefined) clearTimeout(cleanupTimer); + if (containmentTimer !== undefined) clearInterval(containmentTimer); if (failure) reject(failure); else resolve(brokers.map((broker) => Buffer.concat(broker.stdout))); }; const terminateLiveBrokers = (): void => { for (const broker of brokers) { - if (broker.closed || broker.child.exitCode !== null || broker.child.signalCode !== null) continue; - try { broker.child.kill("SIGKILL"); } catch { /* close/error decides the fixed result. */ } + if (broker.closed) continue; + try { broker.child.kill("SIGKILL"); } catch { /* Retain ownership and retry until close/drain. */ } } }; const fail = (stage: WindowsNativeStageCode): void => { if (failure || settled) return; failure = stageError(stage); clearTimeout(deadlineTimer); + for (const broker of brokers) { + if (!broker.closed && broker.statusStage === "ok") broker.statusStage = "sibling-termination"; + } terminateLiveBrokers(); cleanupTimer = setTimeout(() => { if (settled) return; + cleanupDeadlineExpired = true; + failure = stageError("spawn:cleanup", failure!.primaryStage); terminateLiveBrokers(); - settled = true; - reject(stageError("spawn:cleanup")); + // A cleanup deadline classifies the eventual fixed failure; it never + // proves containment. Keep every listener and a referenced retry alive + // until child close plus both stream terminals prove ownership ended. + containmentTimer = setInterval(terminateLiveBrokers, Math.min(250, cleanupTimeoutMs)); + settle(); }, cleanupTimeoutMs); settle(); }; @@ -644,39 +760,73 @@ export function runWindowsInspectionBrokerBatch({ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array); aggregateBytes += bytes.byteLength; if (aggregateBytes > maxOutputBytes) { + broker[stream === "stdout" ? "stdoutState" : "stderrState"] = "overflow"; + broker.statusStage = "parent:utf8"; fail("parent:utf8"); return; } if (stream === "stderr") { - if (bytes.byteLength > 0) fail("spawn:stderr"); + if (bytes.byteLength > 0) { + broker.stderrState = "nonempty"; + broker.statusStage = "spawn:stderr"; + fail("spawn:stderr"); + } return; } + if (bytes.byteLength > 0) broker.stdoutState = "nonempty"; broker.stdout.push(bytes); }; - const deadlineTimer = setTimeout(() => fail("spawn:timeout"), deadlineMs); + const deadlineTimer = setTimeout(() => { + deadlineExpired = true; + for (const broker of brokers) { + if (!broker.closed && broker.statusStage === "ok") broker.statusStage = "spawn:timeout"; + } + fail("spawn:timeout"); + }, deadlineMs); try { for (let index = 0; index < entryCount; index += 1) { const child = startBroker(index); - const broker: InspectionBrokerState = { child, stdout: [], closed: false }; + const broker: InspectionBrokerState = { + child, + stdout: [], + stdoutState: "empty", + stderrState: "empty", + stdoutEnded: !child.stdout, + stderrEnded: !child.stderr, + closed: false, + statusStage: "ok", + diagnosticReported: false, + }; brokers.push(broker); if (!child.stdout || !child.stderr) { + broker.statusStage = "spawn:create"; fail("spawn:create"); break; } child.stdout.on("data", (chunk) => accept(broker, "stdout", chunk)); child.stderr.on("data", (chunk) => accept(broker, "stderr", chunk)); - child.once("error", (error) => fail( - (error as NodeJS.ErrnoException).code === "ETIMEDOUT" ? "spawn:timeout" : "spawn:error", - )); + child.stdout.once("end", () => { broker.stdoutEnded = true; settle(); }); + child.stderr.once("end", () => { broker.stderrEnded = true; settle(); }); + child.once("error", (error) => { + const stage = (error as NodeJS.ErrnoException).code === "ETIMEDOUT" ? "spawn:timeout" : "spawn:error"; + broker.statusStage = stage; + fail(stage); + }); child.once("close", (status, signal) => { if (broker.closed) return; broker.closed = true; closedCount += 1; - if (!failure) { - if (signal !== null) fail("spawn:status"); - else if (status !== 0) fail(windowsBrokerFailureStage(status)); + if (broker.statusStage === "ok" || broker.statusStage === "sibling-termination") { + if (signal !== null && !failure) { + broker.statusStage = "spawn:status"; + } else if (status !== 0) { + broker.statusStage = windowsBrokerFailureStage(status); + } } + if (!failure && broker.statusStage !== "ok") fail( + broker.statusStage === "sibling-termination" ? "spawn:status" : broker.statusStage, + ); settle(); }); } @@ -700,6 +850,251 @@ function revalidateWindowsTargets(targets: readonly WindowsAuthorityTarget[]): v } catch { throw stageError("parent:post-bind"); } } +function splitWindowsBrokerFrames(output: Buffer, expectedCount: 1 | 2): readonly Buffer[] { + const frames: Buffer[] = []; + let offset = 0; + while (offset < output.byteLength) { + const newline = output.indexOf(0x0a, offset); + if (newline < 0) throw stageError("parent:json-canonical"); + const end = newline > offset && output[newline - 1] === 0x0d ? newline - 1 : newline; + if (end === offset) throw stageError("parent:json-canonical"); + frames.push(output.subarray(offset, end)); + offset = newline + 1; + } + if (frames.length !== expectedCount) throw stageError("parent:entry-count"); + return frames; +} + +function bindWindowsInspectionTargets( + frame: Buffer, + targets: readonly WindowsAuthorityTarget[], +): readonly WindowsAuthorityInspection[] { + const rawEntries = parseWindowsBrokerBatchDocument(frame, targets.length); + const inspections: WindowsAuthorityInspection[] = []; + for (let index = 0; index < targets.length; index += 1) { + const target = targets[index]; + const entry: WindowsAuthorityInspection = { + index, + kind: target.kind === "env" ? "file" : "directory", + authorityKind: target.kind, + ...rawEntries[index], + }; + try { + if ( + entry.index !== index + || entry.kind !== (target.kind === "env" ? "file" : "directory") + || entry.authorityKind !== target.kind + || BigInt(entry.volumeSerialNumber) !== BigInt(target.expectedIdentity.device) + || BigInt(entry.fileId) !== BigInt(target.expectedIdentity.file) + || BigInt(entry.volumeSerialNumber) !== BigInt(entry.verifiedVolumeSerialNumber) + || BigInt(entry.fileId) !== BigInt(entry.verifiedFileId) + ) throw new Error(); + } catch { throw stageError("parent:descriptor-bind"); } + inspections.push(entry); + } + return inspections; +} + +interface WindowsInspectionProcess { + readonly child: ChildProcess; + readonly terminal: Promise; + readonly firstFrame: Promise; +} + +function startWindowsInspectionProcess( + executable: HeldExecutable, + targets: readonly WindowsAuthorityTarget[], + roundCount: 1 | 2, +): WindowsInspectionProcess { + const child = spawnInspectionBroker(executable, targets, roundCount); + const terminal = runWindowsInspectionBrokerBatch({ + entryCount: 1, + startBroker: () => child, + deadlineMs: WINDOWS_INSPECTION_TIMEOUT_MS, + cleanupTimeoutMs: WINDOWS_INSPECTION_CLEANUP_TIMEOUT_MS, + maxOutputBytes: WINDOWS_INSPECTION_MAX_BYTES, + }); + const firstFrame = new Promise((resolve, reject) => { + if (!child.stdout) { + reject(stageError("spawn:create")); + return; + } + const chunks: Buffer[] = []; + let byteLength = 0; + let finished = false; + child.stdout.on("data", (chunk: Buffer | Uint8Array) => { + if (finished) return; + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + chunks.push(bytes); + byteLength += bytes.byteLength; + if (byteLength > WINDOWS_INSPECTION_MAX_BYTES) { + finished = true; + reject(stageError("parent:utf8")); + return; + } + const combined = Buffer.concat(chunks, byteLength); + const newline = combined.indexOf(0x0a); + if (newline < 0) return; + const end = newline > 0 && combined[newline - 1] === 0x0d ? newline - 1 : newline; + if (end === 0 || combined.subarray(newline + 1).byteLength !== 0) { + finished = true; + reject(stageError("parent:json-canonical")); + return; + } + finished = true; + resolve(combined.subarray(0, end)); + }); + }); + // A close/failure before the first complete frame must reject the initial + // proof instead of leaving the frame waiter pending. + return { + child, + terminal, + firstFrame: Promise.race([ + firstFrame, + terminal.then((outputs) => splitWindowsBrokerFrames(outputs[0], roundCount)[0]), + ]), + }; +} + +async function terminateWindowsInspectionProcess(processState: WindowsInspectionProcess): Promise { + if (processState.child.exitCode === null && processState.child.signalCode === null) { + try { processState.child.kill("SIGKILL"); } catch { /* Supervisor retains ownership and retries. */ } + } + try { + await processState.terminal; + } catch (error) { + if (error instanceof WindowsNativeStageError && error.stage === "spawn:cleanup") throw error; + } +} + +function sameWindowsInspectionTargets( + initial: readonly WindowsAuthorityTarget[], + final: readonly WindowsAuthorityTarget[], +): boolean { + return initial.length === final.length && initial.every((target, index) => ( + target.kind === final[index].kind + && target.expectedIdentity.device === final[index].expectedIdentity.device + && target.expectedIdentity.file === final[index].expectedIdentity.file + )); +} + +export interface WindowsReadOnlyInspectionGeneration { + readonly initial: readonly WindowsAuthorityInspection[]; + revalidate(targets: readonly WindowsAuthorityTarget[]): Promise; + abort(): Promise; +} + +/** + * Hold one multi-handle broker across a snapshot generation. The second round + * re-reads every descriptor/ACL after the caller's operation; no initial result + * is reused as final authority and the one 60-second broker deadline is fixed. + */ +export async function beginWindowsReadOnlyInspectionGeneration( + targets: readonly WindowsAuthorityTarget[], +): Promise { + if (targets.length < 1 || targets.length > WINDOWS_INSPECTION_MAX_ENTRIES) { + throw stageError("parent:entry-count"); + } + const executable = resolveWindowsPowerShell(); + let processState: WindowsInspectionProcess | undefined; + let finished = false; + const closeExecutable = (): void => { + if (finished) return; + finished = true; + closeSync(executable.fd); + }; + try { + revalidateWindowsPowerShell(executable); + revalidateWindowsTargets(targets); + processState = startWindowsInspectionProcess(executable, targets, 2); + const initialFrame = await processState.firstFrame; + const initial = bindWindowsInspectionTargets(initialFrame, targets); + revalidateWindowsTargets(targets); + revalidateWindowsPowerShell(executable); + let consumed = false; + return { + initial, + revalidate: async (finalTargets) => { + if (consumed || finished || !processState) throw stageError("parent:post-bind"); + consumed = true; + let result: readonly WindowsAuthorityInspection[] | undefined; + let finalError: unknown; + try { + revalidateWindowsPowerShell(executable); + revalidateWindowsTargets(finalTargets); + if (!sameWindowsInspectionTargets(targets, finalTargets)) throw stageError("parent:post-bind"); + if (!processState.child.stdin) throw stageError("spawn:create"); + processState.child.stdin.end("PROPR_REVALIDATE_V1\n", "ascii"); + const outputs = await processState.terminal; + const frames = splitWindowsBrokerFrames(outputs[0], 2); + result = bindWindowsInspectionTargets(frames[1], finalTargets); + revalidateWindowsTargets(finalTargets); + revalidateWindowsPowerShell(executable); + } catch (error) { + finalError = error; + } finally { + if (processState.child.exitCode === null && processState.child.signalCode === null) { + try { await terminateWindowsInspectionProcess(processState); } catch (error) { finalError = error; } + } + try { + revalidateWindowsTargets(finalTargets); + revalidateWindowsPowerShell(executable); + } catch (error) { + if (!(finalError instanceof WindowsNativeStageError && finalError.stage === "spawn:cleanup")) { + const validation = error instanceof WindowsNativeStageError ? error : stageError("parent:post-bind"); + finalError = stageError( + validation.stage, + finalError instanceof WindowsNativeStageError ? finalError.primaryStage : validation.primaryStage, + ); + } + } + closeExecutable(); + } + if (finalError !== undefined) throw finalError; + return result!; + }, + abort: async () => { + if (consumed || finished || !processState) return; + consumed = true; + let finalError: unknown; + try { await terminateWindowsInspectionProcess(processState); } catch (error) { finalError = error; } + try { + revalidateWindowsTargets(targets); + revalidateWindowsPowerShell(executable); + } catch (error) { + if (!(finalError instanceof WindowsNativeStageError && finalError.stage === "spawn:cleanup")) { + finalError = error; + } + } finally { closeExecutable(); } + if (finalError !== undefined) throw finalError; + }, + }; + } catch (error) { + let finalError: unknown = error; + try { + if (processState) { + try { await terminateWindowsInspectionProcess(processState); } catch (cleanupError) { finalError = cleanupError; } + } + try { + revalidateWindowsTargets(targets); + revalidateWindowsPowerShell(executable); + } catch (validationError) { + if (!(finalError instanceof WindowsNativeStageError && finalError.stage === "spawn:cleanup")) { + const validation = validationError instanceof WindowsNativeStageError + ? validationError + : stageError("parent:post-bind"); + finalError = stageError( + validation.stage, + finalError instanceof WindowsNativeStageError ? finalError.primaryStage : validation.primaryStage, + ); + } + } + } finally { closeExecutable(); } + throw finalError; + } +} + export async function runWindowsReadOnlyInspection( targets: readonly WindowsAuthorityTarget[], ): Promise { @@ -710,36 +1105,11 @@ export async function runWindowsReadOnlyInspection( try { revalidateWindowsPowerShell(executable); revalidateWindowsTargets(targets); - const outputs = await runWindowsInspectionBrokerBatch({ - entryCount: targets.length, - startBroker: (index) => spawnInspectionBroker(executable, targets[index].pinnedFd), - deadlineMs: WINDOWS_INSPECTION_TIMEOUT_MS, - cleanupTimeoutMs: WINDOWS_INSPECTION_CLEANUP_TIMEOUT_MS, - maxOutputBytes: WINDOWS_INSPECTION_MAX_BYTES, - }); - const inspections: WindowsAuthorityInspection[] = []; - for (let index = 0; index < targets.length; index += 1) { - const target = targets[index]; - const raw = parseWindowsBrokerDocument(outputs[index]); - const entry: WindowsAuthorityInspection = { - index, - kind: target.kind === "env" ? "file" : "directory", - authorityKind: target.kind, - ...raw, - }; - try { - if ( - entry.index !== index - || entry.kind !== (target.kind === "env" ? "file" : "directory") - || entry.authorityKind !== target.kind - || BigInt(entry.volumeSerialNumber) !== BigInt(target.expectedIdentity.device) - || BigInt(entry.fileId) !== BigInt(target.expectedIdentity.file) - || BigInt(entry.volumeSerialNumber) !== BigInt(entry.verifiedVolumeSerialNumber) - || BigInt(entry.fileId) !== BigInt(entry.verifiedFileId) - ) throw new Error(); - } catch { throw stageError("parent:descriptor-bind"); } - inspections.push(entry); - } + const processState = startWindowsInspectionProcess(executable, targets, 1); + const outputs = await processState.terminal; + const inspections = bindWindowsInspectionTargets(splitWindowsBrokerFrames(outputs[0], 1)[0], targets); + revalidateWindowsTargets(targets); + revalidateWindowsPowerShell(executable); return inspections; } finally { try { diff --git a/scripts/verify-platform-safe-connect.mjs b/scripts/verify-platform-safe-connect.mjs index 80993ae82..b1400b68f 100644 --- a/scripts/verify-platform-safe-connect.mjs +++ b/scripts/verify-platform-safe-connect.mjs @@ -37,14 +37,14 @@ const tapValue = (name) => { const valid = result.status === 0 && !result.error && !result.signal - && tapValue('tests') === 88 - && tapValue('pass') === 88 + && tapValue('tests') === 90 + && tapValue('pass') === 90 && tapValue('fail') === 0 && tapValue('skipped') === 0; if (!valid) { - process.stderr.write('Platform-safe Connect proof did not complete 88/88 within 90000ms.\n'); + process.stderr.write('Platform-safe Connect proof did not complete 90/90 within 90000ms.\n'); process.exitCode = 1; } else { - process.stdout.write('Platform-safe Connect proof: tests=88 pass=88 fail=0 skipped=0 budgetMs=90000\n'); + process.stdout.write('Platform-safe Connect proof: tests=90 pass=90 fail=0 skipped=0 budgetMs=90000\n'); } diff --git a/scripts/verify-windows-standard-user-connect.mjs b/scripts/verify-windows-standard-user-connect.mjs index 9176e232b..657792e27 100644 --- a/scripts/verify-windows-standard-user-connect.mjs +++ b/scripts/verify-windows-standard-user-connect.mjs @@ -110,7 +110,7 @@ const nativeStageAllowlist = Object.freeze([ "broker:ps-version", "broker:job", "broker:fd", "broker:fd-duplicate", "broker:index-info-initial", "broker:security-info", "broker:acl", "broker:json", "broker:current-user-sid", "broker:index-info-revalidation", "broker:index-info-decode", "broker:index-info-compose", "broker:entry-format", - "broker:entry-flags", "broker:entry-rules", "broker:entry-build", + "broker:entry-flags", "broker:entry-rules", "broker:entry-build", "broker:control", "parent:utf8", "parent:json-parse", "parent:json-canonical", "parent:document-shape", "parent:entry-count", "parent:entry-shape", "parent:json-shape", "parent:descriptor-bind", "parent:post-bind", ]); diff --git a/test/fixtures/windowsConnectProcessMock.mjs b/test/fixtures/windowsConnectProcessMock.mjs index 5aaf1008d..f71d31ee1 100644 --- a/test/fixtures/windowsConnectProcessMock.mjs +++ b/test/fixtures/windowsConnectProcessMock.mjs @@ -56,7 +56,7 @@ const nativeStages = new Set([ "broker:ps-version", "broker:job", "broker:fd", "broker:fd-duplicate", "broker:index-info-initial", "broker:security-info", "broker:acl", "broker:json", "broker:current-user-sid", "broker:index-info-revalidation", "broker:index-info-decode", "broker:index-info-compose", "broker:entry-format", - "broker:entry-flags", "broker:entry-rules", "broker:entry-build", + "broker:entry-flags", "broker:entry-rules", "broker:entry-build", "broker:control", "parent:utf8", "parent:json-parse", "parent:json-canonical", "parent:document-shape", "parent:entry-count", "parent:entry-shape", "parent:json-shape", "parent:descriptor-bind", "parent:post-bind", ]); @@ -66,27 +66,29 @@ globalThis[Symbol.for("propr.test.windowsNativeDiagnostic")] = (stage) => { }; function authorityDocument(args, options, mode, invocation = authorityInvocation) { - const stat = fstatSync(options.stdio[0], { bigint: true }); - const identity = { device: stat.dev.toString(10), file: stat.ino.toString(10) }; const userSid = "S-1-5-21-100-200-300-1001"; - const entries = [{ - currentUserSid: userSid, - ownerSid: userSid, - daclProtected: true, - reparsePoint: false, - volumeSerialNumber: identity.device, - fileId: identity.file, - verifiedVolumeSerialNumber: identity.device, - verifiedFileId: identity.file, - rules: [{ - identitySid: userSid, - inherited: false, - accessType: "allow", - appliesToSelf: true, - rights: "2032127", - }], - }]; - const protectedEntry = entries[0]; + const descriptors = options.stdio.slice(3).filter(Number.isInteger); + const entries = descriptors.map((descriptor) => { + const stat = fstatSync(descriptor, { bigint: true }); + const identity = { device: stat.dev.toString(10), file: stat.ino.toString(10) }; + return { + currentUserSid: userSid, + ownerSid: userSid, + daclProtected: true, + reparsePoint: false, + volumeSerialNumber: identity.device, + fileId: identity.file, + verifiedVolumeSerialNumber: identity.device, + verifiedFileId: identity.file, + rules: [{ + identitySid: userSid, + inherited: false, + accessType: "allow", + appliesToSelf: true, + rights: "2032127", + }], + }; + }); if (mode === "descriptor-mismatch") { entries[0].fileId = (BigInt(entries[0].fileId) + 1n).toString(10); entries[0].verifiedFileId = entries[0].fileId; @@ -95,20 +97,20 @@ function authorityDocument(args, options, mode, invocation = authorityInvocation else if (mode === "authority-kind-mismatch") entries[0].extraAuthorityKind = "root"; else if (mode === "identity-mismatch") { entries[0].fileId = (BigInt(entries[0].fileId) + 1n).toString(10); - } else if (mode === "sid-mismatch" && invocation > 1) { - entries[0].currentUserSid = "S-1-5-21-100-200-300-1002"; - } else if (mode === "broad-write" && protectedEntry) { - protectedEntry.rules = [{ + } else if (mode === "sid-mismatch" && entries.length > 1) { + entries[1].currentUserSid = "S-1-5-21-100-200-300-1002"; + } else if (mode === "broad-write") { + for (const entry of entries) entry.rules = [{ identitySid: "S-1-1-0", inherited: false, accessType: "allow", appliesToSelf: true, rights: "2", }]; - } else if (mode === "inherited-write" && protectedEntry) { - protectedEntry.rules[0].inherited = true; - } else if (mode === "unprotected" && protectedEntry) { - protectedEntry.daclProtected = false; - } else if (mode === "owner-mismatch" && protectedEntry) { - protectedEntry.ownerSid = "S-1-5-18"; - } else if (mode === "reparse" && protectedEntry) { - protectedEntry.reparsePoint = true; + } else if (mode === "inherited-write") { + for (const entry of entries) entry.rules[0].inherited = true; + } else if (mode === "unprotected") { + for (const entry of entries) entry.daclProtected = false; + } else if (mode === "owner-mismatch") { + for (const entry of entries) entry.ownerSid = "S-1-5-18"; + } else if (mode === "reparse") { + for (const entry of entries) entry.reparsePoint = true; } return JSON.stringify({ version: 1, entries }); } @@ -118,6 +120,7 @@ function fakeAuthorityChild(args, options, mode) { const child = new EventEmitter(); child.stdout = new PassThrough(); child.stderr = new PassThrough(); + child.stdin = options.stdio[0] === "pipe" ? new PassThrough() : null; child.pid = 0x7000_0000 + authorityInvocation; child.exitCode = null; child.signalCode = null; @@ -135,26 +138,36 @@ function fakeAuthorityChild(args, options, mode) { close(null, "SIGKILL"); return true; }; - queueMicrotask(() => { + const publish = () => { if (closed) return; if (mode === "timeout") { const error = Object.assign(new Error("private-path-SENTINEL"), { code: "ETIMEDOUT" }); child.emit("error", error); return; } - if (mode === "malformed") child.stdout.write("{"); - else if (mode === "oversized") child.stdout.write("x".repeat(128 * 1024 + 1)); - else if (mode === "extra-key") child.stdout.write('{"version":1,"entries":[],"extra":true}'); - else if (mode === "duplicate") child.stdout.write('{"version":1,"version":1,"entries":[]}'); - else if (mode === "entry-count") child.stdout.write('{"version":1,"entries":[]}'); + if (mode === "malformed") child.stdout.write("{\n"); + else if (mode === "oversized") child.stdout.write(`${"x".repeat(128 * 1024 + 1)}\n`); + else if (mode === "extra-key") child.stdout.write('{"version":1,"entries":[],"extra":true}\n'); + else if (mode === "duplicate") child.stdout.write('{"version":1,"version":1,"entries":[]}\n'); + else if (mode === "entry-count") child.stdout.write('{"version":1,"entries":[]}\n'); else if (mode === "entry-shape") { const document = JSON.parse(authorityDocument(args, options, mode, invocation)); document.entries[0].extra = true; - child.stdout.write(JSON.stringify(document)); + child.stdout.write(`${JSON.stringify(document)}\n`); } else if (mode === "stderr") child.stderr.write("private-path-SENTINEL S-1-5-21-999 raw-error-SENTINEL"); - else if (mode !== "nonzero") child.stdout.write(authorityDocument(args, options, mode, invocation)); + else if (mode !== "nonzero") child.stdout.write(`${authorityDocument(args, options, mode, invocation)}\n`); + if (child.stdin && !closed && mode !== "nonzero") return; close(mode === "nonzero" ? 70 : 0); + }; + child.stdin?.once("data", (chunk) => { + if (Buffer.from(chunk).toString("ascii") !== "PROPR_REVALIDATE_V1\n") { + close(87); + return; + } + child.stdin = null; + publish(); }); + queueMicrotask(publish); return child; } diff --git a/test/windowsStandardUserConnectHarness.test.ts b/test/windowsStandardUserConnectHarness.test.ts index 477d6b013..12a138bd9 100644 --- a/test/windowsStandardUserConnectHarness.test.ts +++ b/test/windowsStandardUserConnectHarness.test.ts @@ -15,6 +15,7 @@ import { const harness = readFileSync('scripts/verify-windows-standard-user-connect.mjs', 'utf8'); const processMock = readFileSync('test/fixtures/windowsConnectProcessMock.mjs', 'utf8'); const windowsAuthority = readFileSync('packages/cli/src/connectWindowsAuthority.ts', 'utf8'); +const connectCommand = readFileSync('packages/cli/src/commands/connectCommand.ts', 'utf8'); function diagnosticDefinitions(): { scenarioAllowlist: string[]; @@ -320,7 +321,7 @@ test('the ordinary-user Windows proof retains native security paths and bounds r } assert.match( processMock, - /else if \(mode !== "nonzero"\) child\.stdout\.write\(authorityDocument\(args, options, mode, invocation\)\);/, + /else if \(mode !== "nonzero"\) child\.stdout\.write\(`\$\{authorityDocument\(args, options, mode, invocation\)\}\\n`\);/, ); assert.match(harness, /\{ name: "path-aba", mode: "path-aba", reason: "INVALID_ROOT" \}/); assert.match(harness, /\{ name: "authority-missing-system-root", systemRootMode: "missing", nativeStage: "resolver:env" \}/); @@ -364,7 +365,7 @@ test('the ordinary-user Windows diagnostic has fixed allowlists and redacts all 'broker:ps-version', 'broker:job', 'broker:fd', 'broker:fd-duplicate', 'broker:index-info-initial', 'broker:security-info', 'broker:acl', 'broker:json', 'broker:current-user-sid', 'broker:index-info-revalidation', 'broker:index-info-decode', 'broker:index-info-compose', 'broker:entry-format', - 'broker:entry-flags', 'broker:entry-rules', 'broker:entry-build', + 'broker:entry-flags', 'broker:entry-rules', 'broker:entry-build', 'broker:control', 'parent:utf8', 'parent:json-parse', 'parent:json-canonical', 'parent:document-shape', 'parent:entry-count', 'parent:entry-shape', 'parent:json-shape', 'parent:descriptor-bind', 'parent:post-bind', ]); @@ -448,8 +449,8 @@ test('the ordinary-user Windows diagnostic has fixed allowlists and redacts all assert.doesNotMatch(catchBody, /(?:result|api|error)\.(?:stdout|stderr|message|path|argv|env|config)/i); }); -test('the staged hosted probe and production inspector both use the inherited standard handle', () => { - assert.doesNotMatch(windowsAuthority, /_get_osfhandle|AssignProcessToJobObject|CreateJobObject/); +test('the staged probe uses stdin while production inherits a fixed multi-handle fd table', () => { + assert.doesNotMatch(windowsAuthority, /AssignProcessToJobObject|CreateJobObject/); assert.match(harness, /runWindowsNativeTimingProbe\(probeFd\)/); assert.match(harness, /openSync\(\s*fixture,\s*constants\.O_RDONLY \| constants\.O_DIRECTORY \| constants\.O_NOFOLLOW,\s*\)/); assert.match(harness, /native-timing=\$\{nativeProbe\.evidence\}/); @@ -459,28 +460,29 @@ test('the staged hosted probe and production inspector both use the inherited st const productionSourceStart = windowsAuthority.indexOf('export const WINDOWS_INSPECTION_SOURCE'); const productionSourceEnd = windowsAuthority.indexOf('export const WINDOWS_NATIVE_PROBE_MILESTONES', productionSourceStart); const productionSource = windowsAuthority.slice(productionSourceStart, productionSourceEnd); - assert.match(productionSource, /GetStdHandle\(-10\)/); - assert.doesNotMatch(productionSource, /_get_osfhandle|AssignProcessToJobObject|CreateJobObject|Start-Process|CreateProcess/); + assert.match(productionSource, /_get_osfhandle\(3\+\$i\)/); + assert.doesNotMatch(productionSource, /AssignProcessToJobObject|CreateJobObject|Start-Process|CreateProcess/); assert.match(windowsAuthority, /stdio: \[stdin, "pipe", "pipe"\]/); - assert.match(windowsAuthority, /stdio: \[pinnedFd, "pipe", "pipe"\]/); - assert.doesNotMatch(productionSource, /__PROPR_|\bindex=|\bkind=|authorityKind=/); - assert.match(windowsAuthority, /powerShellArguments\(WINDOWS_INSPECTION_SOURCE\)/); - assert.doesNotMatch(windowsAuthority, /inspectionSource\(target|\.replace\("__PROPR_/); + assert.match(windowsAuthority, /stdio: \[roundCount === 2 \? "pipe" : "ignore", "pipe", "pipe", \.\.\.targets\.map/); + assert.doesNotMatch(productionSource, /\bpath=|\bkind=|authorityKind=|S-1-/); + assert.match(windowsAuthority, /powerShellArguments\(inspectionSource\(targets\.length, roundCount\)\)/); + assert.match(windowsAuthority, /\.replace\("__PROPR_ENTRY_COUNT__", String\(entryCount\)\)/); + assert.match(windowsAuthority, /\.replace\("__PROPR_ROUND_COUNT__", String\(roundCount\)\)/); assert.match(windowsAuthority, /WINDOWS_INSPECTOR_CREATES_CHILD_PROCESSES = false/); assert.match(windowsAuthority, /WINDOWS_INSPECTOR_WRITES_FILESYSTEM = false/); }); -test('the production inspector duplicates its standard handle before the split native operations', () => { +test('the production inspector duplicates each fixed fd before split native operations', () => { const productionSourceStart = windowsAuthority.indexOf('export const WINDOWS_INSPECTION_SOURCE'); const productionSourceEnd = windowsAuthority.indexOf('export const WINDOWS_NATIVE_PROBE_MILESTONES', productionSourceStart); const productionSource = windowsAuthority.slice(productionSourceStart, productionSourceEnd); assert.match(productionSource, /\$stage=80\s+if\(-not \[ProprReadOnlyAuthority\]::DuplicateHandle\(\s*\[ProprReadOnlyAuthority\]::GetCurrentProcess\(\),\$originalHandle,\s*\[ProprReadOnlyAuthority\]::GetCurrentProcess\(\),\[ref\]\$privateHandle,0,\$false,2\)\)\{exit \$stage\}/); assert.match(productionSource, /\$stage=74\s+\$before=\[Runtime\.InteropServices\.Marshal\]::AllocHGlobal\(52\)\s+if\(-not \[ProprReadOnlyAuthority\]::GetFileInformationByHandle\(\$privateHandle,\$before\)\)\{exit \$stage\}/); - assert.match(productionSource, /\$stage=78\s+\$current=\[Security\.Principal\.WindowsIdentity\]::GetCurrent\(\)\.User\s+if\(\$null-eq \$current\)\{exit \$stage\}\s+\$currentSid=\$current\.Value/); + assert.match(productionSource, /\$stage=78\s+\$current=\[Security\.Principal\.WindowsIdentity\]::GetCurrent\(\)\.User\s+if\(\$null-eq \$current\)\{exit \$stage\}/); assert.match(productionSource, /GetSecurityInfo\(\$privateHandle,1,5,\[ref\]\$owner,\[ref\]\$group,\[ref\]\$dacl,\[ref\]\$sacl,\[ref\]\$descriptor\)/); assert.match(productionSource, /\$stage=79\s+\$after=\[Runtime\.InteropServices\.Marshal\]::AllocHGlobal\(52\)\s+if\(-not \[ProprReadOnlyAuthority\]::GetFileInformationByHandle\(\$privateHandle,\$after\)\)\{exit \$stage\}/); assert.equal(productionSource.match(/::CloseHandle\(\$privateHandle\)/g)?.length, 1); - assert.match(productionSource, /finally \{if\(\$privateHandleOwned\)\{\$null=\[ProprReadOnlyAuthority\]::CloseHandle\(\$privateHandle\)\}\}/); + assert.match(productionSource, /if\(\$privateHandle-ne \[IntPtr\]::Zero\)\{\$null=\[ProprReadOnlyAuthority\]::CloseHandle\(\$privateHandle\)\}/); assert.doesNotMatch(productionSource, /CloseHandle\(\$originalHandle\)/); assert.doesNotMatch(windowsAuthority, /"broker:index-info"/); assert.match(windowsAuthority, /74: "broker:index-info-initial"/); @@ -513,7 +515,7 @@ test('the staged probe accepts only ordered milestone tokens and coarse timing b assert.match(windowsAuthority, /GetFileInformationByHandle/); }); -test('the diagnostic allowance precedes one bounded concurrent standard-handle proof', () => { +test('the diagnostic allowance precedes two bounded one-broker authority generations', () => { assert.equal(WINDOWS_NATIVE_TIMING_PROBE_TIMEOUT_MS, 60_000); assert.equal(WINDOWS_INSPECTION_TIMEOUT_MS, 60_000); assert.equal(WINDOWS_INSPECTION_CLEANUP_TIMEOUT_MS, 5_000); @@ -534,9 +536,15 @@ test('the diagnostic allowance precedes one bounded concurrent standard-handle p assert.equal(Number.isFinite(windowsProductScenarioTimeoutMs), true); assert.equal(Number.isSafeInteger(windowsProductScenarioTimeoutMs), true); assert.ok(WINDOWS_INSPECTION_CLEANUP_TIMEOUT_MS < WINDOWS_INSPECTION_TIMEOUT_MS); - assert.match(windowsAuthority, /startBroker: \(index\) => spawnInspectionBroker\(executable, targets\[index\]\.pinnedFd\)/u); - assert.match(windowsAuthority, /const deadlineTimer = setTimeout\(\(\) => fail\("spawn:timeout"\), deadlineMs\)/u); + assert.match(windowsAuthority, /startBroker: \(\) => child/u); + assert.match(windowsAuthority, /const deadlineTimer = setTimeout\(\(\) => \{\s*deadlineExpired = true;/u); assert.match(windowsAuthority, /deadlineMs: WINDOWS_INSPECTION_TIMEOUT_MS/u); + const trustedGeneration = connectCommand.indexOf('await readTrustedConnectTunnelOverride(root)'); + const rootGeneration = connectCommand.indexOf('await withOwnedConnectRootSnapshot(root'); + assert.ok(trustedGeneration >= 0 && trustedGeneration < rootGeneration); + const rootSnapshot = connectCommand.slice(rootGeneration, connectCommand.indexOf("dependencies.reportSmokeDiagnostic?.(phase, 'PASSED')", rootGeneration)); + assert.match(rootSnapshot, /process\.platform === "win32"\s*\? windowsTunnelEnabledOverride/u); + assert.doesNotMatch(rootSnapshot, /process\.platform === "win32"\s*\? await readTrustedConnectTunnelOverride/u); const productionInspection = windowsAuthority.slice( windowsAuthority.indexOf('export async function runWindowsReadOnlyInspection'), windowsAuthority.indexOf('function probeFailureStage'), From 55eb9231c625647bdeb3072d3d5d7130400adfa3 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:51:37 +0000 Subject: [PATCH 6/8] feat(ai): Implemented all five follow-up corrections on exact head `4177d009169fca4b944947c538aaae8dac9851a8`: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented all five follow-up corrections on exact head `4177d009169fca4b944947c538aaae8dac9851a8`: - Preserved `WindowsAuthorityInspectionError` through trusted-config reads, restoring unavailable-root-authority status mapping. - Kept native 1/2/4-handle proofs sequential and moved supervisor/error lifecycle coverage to deterministic portable children. - Raised only the test fixture’s fixed PS5.1 bound to 60 seconds with redacted failure attribution. - Made revalidation stdin callback/error/EPIPE failures total at `broker:control`. - Added bounded `00`–`31` entry indexes and allowlisted operation tokens to broker failures without secret metadata. Production stderr rejection, 60/135/240-second contracts, aggregate limits, reinspection, handle revalidation, containment retries, READY schema, and dependency lockfile remain unchanged. Validation: - Focused authority/status/supervisor tests: pass - Platform-safe Connect: 92/92 pass - Desktop suite: 348 pass, 7 platform skips - CLI and desktop typechecks: pass - CLI lint: pass - Runtime and packaging audits: zero vulnerabilities - Linux x64 package: pass - `git diff --check`: pass - Lockfile unchanged; `qs` 6.16.0 and `fast-uri` 3.1.6 confirmed The packaged Linux GUI smoke could not start because this worker has no D-Bus session launcher or X display. Native Windows x64/ARM64 ordinary-user/package gates and Darwin gates require their respective runners and were not executable here. No commit was created. PR: #2069 Comment by: @integry (ID: 5513619048) Model: gpt-5.6-sol --- .../windows-authority-batch-regression.mjs | 131 ++++------ apps/desktop/scripts/windows-fixture-acl.mjs | 3 +- .../scripts/windows-fixture-acl.test.mjs | 32 ++- packages/cli/src/connectIdentity.ts | 1 + packages/cli/src/connectRootAuthority.test.ts | 90 ++++++- packages/cli/src/connectRootAuthority.ts | 31 ++- packages/cli/src/connectWindowsAuthority.ts | 243 +++++++++++++----- scripts/verify-platform-safe-connect.mjs | 8 +- test/publicInstanceIdentity.test.ts | 24 ++ .../windowsStandardUserConnectHarness.test.ts | 8 +- 10 files changed, 392 insertions(+), 179 deletions(-) diff --git a/apps/desktop/scripts/windows-authority-batch-regression.mjs b/apps/desktop/scripts/windows-authority-batch-regression.mjs index ca082450e..20dd1a674 100644 --- a/apps/desktop/scripts/windows-authority-batch-regression.mjs +++ b/apps/desktop/scripts/windows-authority-batch-regression.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; import { closeSync, fstatSync, mkdtempSync, openSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { join } from 'node:path'; import { performance } from 'node:perf_hooks'; if (process.platform !== 'win32' || !['x64', 'arm64'].includes(process.arch)) { @@ -15,31 +15,32 @@ const { parseWindowsBrokerDocument, runWindowsReadOnlyInspection, runWindowsInspectionBrokerBatch, - WINDOWS_INSPECTION_SOURCE, WindowsNativeStageError, } = await import('../../../packages/cli/dist/connectWindowsAuthority.js'); const systemRoot = process.env.SystemRoot; assert.match(systemRoot ?? '', /^[A-Za-z]:\\/u); -const executable = join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); -const environment = { SystemRoot: systemRoot, WINDIR: systemRoot }; const directory = mkdtempSync(join(tmpdir(), 'propr-authority-batch-')); const descriptors = []; const livePids = new Set(); -const argumentsFor = source => [ - '-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', - '-EncodedCommand', Buffer.from(source, 'utf16le').toString('base64'), -]; - -const startPowerShell = (source, fds) => { - const inherited = Array.isArray(fds) ? fds : [fds]; - const child = spawn(executable, argumentsFor(source), { +const portableSupervisorFixture = String.raw` +const mode=process.argv[1];const value=process.argv[2]??'';const delay=Number(process.argv[3]??0); +if(mode==='hang'){setInterval(()=>{},60000)} +else if(mode==='status'){process.exit(70)} +else setTimeout(()=>{ + if(mode==='stderr')process.stderr.write('fixed-fixture-stderr'); + else if(mode==='overflow')process.stdout.write('x'.repeat(2048)); + else process.stdout.write(value); +},delay); +`; + +const startPortableFixture = (mode, value = '', delay = 0) => { + const child = spawn(process.execPath, ['-e', portableSupervisorFixture, mode, String(value), String(delay)], { shell: false, windowsHide: true, - cwd: dirname(executable), - env: environment, - stdio: ['ignore', 'pipe', 'pipe', ...inherited], + env: { SystemRoot: systemRoot }, + stdio: ['ignore', 'pipe', 'pipe'], }); if (Number.isSafeInteger(child.pid)) livePids.add(child.pid); child.once('close', () => livePids.delete(child.pid)); @@ -57,10 +58,6 @@ const targetFor = (fd, index) => { }; }; -const oneRoundSource = WINDOWS_INSPECTION_SOURCE - .replace('__PROPR_ENTRY_COUNT__', '1') - .replace('__PROPR_ROUND_COUNT__', '1'); - const fixedEvidence = (count, outcome, stage, diagnostics) => ({ brokers: count, outcome, @@ -68,13 +65,6 @@ const fixedEvidence = (count, outcome, stage, diagnostics) => ({ results: diagnostics, }); -const canonicalFrame = output => { - assert.equal(output.at(-1), 0x0a); - const end = output.at(-2) === 0x0d ? output.length - 2 : output.length - 1; - assert.equal(output.subarray(0, end).includes(0x0a), false); - return output.subarray(0, end); -}; - const assertStage = async (promise, stage) => { await assert.rejects( promise, @@ -92,51 +82,45 @@ try { // Production proof: 1, 2, and 4 targets each use one cold PowerShell process // and remain inside the unchanged single 60-second wall bound. + let validEntry; for (const count of [1, 2, 4]) { const targets = descriptors.slice(0, count).map(targetFor); const started = performance.now(); const inspections = await runWindowsReadOnlyInspection(targets); assert.equal(inspections.length, count); assert.ok(performance.now() - started < 60_000, `one-broker ${count}-handle batch exceeded its wall bound`); + if (count === 1) { + const { index: _index, kind: _kind, authorityKind: _authorityKind, ...entry } = inspections[0]; + validEntry = entry; + } } - // Resource evidence remains non-authoritative and total: reproduce 1, 2, - // then 4 independent full cold starts, but allow a fixed failure now that - // production does not depend on N-process concurrency. No native text enters - // the record, and the supervisor still drains every process before return. + // Supervisor concurrency is process-level behavior, so prove it with a + // deterministic portable child instead of recreating a PS5.1 cold-start + // storm after the dedicated sequential native proofs above. for (const count of [1, 2, 4]) { const diagnostics = []; - let outcome = 'passed'; - let stage = 'ok'; - try { - const outputs = await runWindowsInspectionBrokerBatch({ - entryCount: count, - startBroker: index => startPowerShell(oneRoundSource, descriptors[index]), - deadlineMs: 60_000, - cleanupTimeoutMs: 5_000, - maxOutputBytes: 128 * 1024, - onBrokerResult: diagnostic => diagnostics.push(diagnostic), - }); - outputs.forEach(output => parseWindowsBrokerDocument(canonicalFrame(output))); - } catch (error) { - assert.ok(error instanceof WindowsNativeStageError); - outcome = 'failed'; - stage = error.stage; - } + const outputs = await runWindowsInspectionBrokerBatch({ + entryCount: count, + startBroker: index => startPortableFixture('output', index, 40 - index), + deadlineMs: 10_000, + cleanupTimeoutMs: 5_000, + maxOutputBytes: 128 * 1024, + onBrokerResult: diagnostic => diagnostics.push(diagnostic), + }); + assert.deepEqual(outputs.map(output => output.toString('utf8')), Array.from({ length: count }, (_, i) => String(i))); assert.equal(diagnostics.length, count); - assert.equal(livePids.size, 0, `${count}-broker evidence left a process alive`); - process.stdout.write(`Windows authority concurrency evidence ${JSON.stringify( - fixedEvidence(count, outcome, stage, diagnostics), + assert.equal(livePids.size, 0, `${count}-child supervisor evidence left a process alive`); + process.stdout.write(`Windows authority supervisor evidence ${JSON.stringify( + fixedEvidence(count, 'passed', 'ok', diagnostics), )}\n`); } - const reorderedSources = [80, 10, 45].map((delay, index) => ( - `Start-Sleep -Milliseconds ${delay};[Console]::Out.Write('${index}')` - )); + const reorderedDelays = [80, 10, 45]; const reordered = await runWindowsInspectionBrokerBatch({ - entryCount: reorderedSources.length, - startBroker: index => startPowerShell(reorderedSources[index], descriptors[index]), - deadlineMs: 60_000, + entryCount: reorderedDelays.length, + startBroker: index => startPortableFixture('output', index, reorderedDelays[index]), + deadlineMs: 10_000, cleanupTimeoutMs: 5_000, maxOutputBytes: 128, }); @@ -145,10 +129,7 @@ try { await assertStage(runWindowsInspectionBrokerBatch({ entryCount: 2, - startBroker: index => startPowerShell( - index === 0 ? 'Start-Sleep -Seconds 120' : "[Console]::Out.Write('sibling')", - descriptors[index], - ), + startBroker: index => startPortableFixture(index === 0 ? 'hang' : 'output', 'sibling'), deadlineMs: 1_000, cleanupTimeoutMs: 5_000, maxOutputBytes: 128, @@ -156,34 +137,30 @@ try { await assertStage(runWindowsInspectionBrokerBatch({ entryCount: 3, - startBroker: index => startPowerShell( - index === 0 ? 'exit 70' : 'Start-Sleep -Seconds 120', - descriptors[index], - ), - deadlineMs: 60_000, + startBroker: index => startPortableFixture(index === 0 ? 'status' : 'hang'), + deadlineMs: 10_000, cleanupTimeoutMs: 5_000, maxOutputBytes: 128, }), 'spawn:status'); await assertStage(runWindowsInspectionBrokerBatch({ entryCount: 2, - startBroker: index => startPowerShell( - index === 0 ? "[Console]::Out.Write(('x'*2048))" : 'Start-Sleep -Seconds 120', - descriptors[index], - ), - deadlineMs: 60_000, + startBroker: index => startPortableFixture(index === 0 ? 'overflow' : 'hang'), + deadlineMs: 10_000, cleanupTimeoutMs: 5_000, maxOutputBytes: 1024, }), 'parent:utf8'); - const validOutput = await runWindowsInspectionBrokerBatch({ - entryCount: 1, - startBroker: () => startPowerShell(oneRoundSource, descriptors[0]), - deadlineMs: 60_000, + await assertStage(runWindowsInspectionBrokerBatch({ + entryCount: 2, + startBroker: index => startPortableFixture(index === 0 ? 'stderr' : 'hang'), + deadlineMs: 10_000, cleanupTimeoutMs: 5_000, - maxOutputBytes: 128 * 1024, - }); - const validEntry = parseWindowsBrokerDocument(canonicalFrame(validOutput[0])); + maxOutputBytes: 1024, + }), 'spawn:stderr'); + + assert.ok(validEntry); + assert.deepEqual(parseWindowsBrokerDocument(JSON.stringify({ version: 1, entries: [validEntry] })), validEntry); for (const entries of [[], [validEntry, validEntry]]) assert.throws( () => parseWindowsBrokerDocument(JSON.stringify({ version: 1, entries })), error => error instanceof WindowsNativeStageError && error.stage === 'parent:entry-count', diff --git a/apps/desktop/scripts/windows-fixture-acl.mjs b/apps/desktop/scripts/windows-fixture-acl.mjs index e935478f0..51fc5c678 100644 --- a/apps/desktop/scripts/windows-fixture-acl.mjs +++ b/apps/desktop/scripts/windows-fixture-acl.mjs @@ -74,6 +74,7 @@ export const encodedWindowsFixtureAcl = Buffer.from(windowsFixtureAclSource, 'ut const WINDOWS_FIXTURE_PATH_MAX_BYTES = 4 * 1024; const WINDOWS_FIXTURE_PROCESS_MAX_BYTES = 8 * 1024; +export const WINDOWS_FIXTURE_PROCESS_TIMEOUT_MS = 60_000; const windowsFixtureCanonicalPathSource = String.raw` $ErrorActionPreference='Stop' @@ -160,7 +161,7 @@ export const canonicalizeWindowsFixtureEntry = ({ entryKind, entryPath, powershe ], { shell: false, windowsHide: true, - timeout: 10_000, + timeout: WINDOWS_FIXTURE_PROCESS_TIMEOUT_MS, maxBuffer: WINDOWS_FIXTURE_PROCESS_MAX_BYTES, env: { ...process.env, diff --git a/apps/desktop/scripts/windows-fixture-acl.test.mjs b/apps/desktop/scripts/windows-fixture-acl.test.mjs index c46767bc2..4327f435d 100644 --- a/apps/desktop/scripts/windows-fixture-acl.test.mjs +++ b/apps/desktop/scripts/windows-fixture-acl.test.mjs @@ -7,6 +7,7 @@ import { it } from 'node:test'; import { canonicalizeWindowsFixtureEntry, encodedWindowsFixtureAcl, + WINDOWS_FIXTURE_PROCESS_TIMEOUT_MS, windowsFixtureAclSource, windowsPowerShell51Path, } from './windows-fixture-acl.mjs'; @@ -140,7 +141,7 @@ const runAclProof = (powershell, entry, proofKind, ownerCategory = '') => spawnS { shell: false, windowsHide: true, - timeout: 30_000, + timeout: WINDOWS_FIXTURE_PROCESS_TIMEOUT_MS, env: { ...process.env, PROPR_FIXTURE_ACL_KIND: entry.kind, @@ -161,9 +162,18 @@ const proofFailureCategory = status => new Map([ [79, 'owner-category-mismatch'], ]).get(status) ?? 'unexpected-exit'; -const assertProofProcess = result => { - assert.ifError(result.error); +const assertPowerShellInvocation = (result, category) => { + if (result.error) { + const reason = result.error.code === 'ETIMEDOUT' ? 'timeout' : 'spawn'; + const error = new Error(`Windows fixture ACL helper failed [category=${category} reason=${reason}]`); + error.stack = error.message; + throw error; + } assert.equal(result.signal, null); +}; + +const assertProofProcess = (result, category = 'acl-proof') => { + assertPowerShellInvocation(result, category); assertPowerShellStreamEmpty(result.stdout, 'dacl-proof-stdout'); assertPowerShellStreamEmpty(result.stderr, 'dacl-proof-stderr'); }; @@ -197,12 +207,15 @@ const assertOwnerCategoryMismatch = (powershell, entry, ownerCategory) => { }; windowsIt('keeps the encoded Windows PowerShell 5.1 ACL helper fail-closed and byte-empty', t => { + assert.equal(WINDOWS_FIXTURE_PROCESS_TIMEOUT_MS, 60_000); const powershell = windowsPowerShell51Path(); const version = spawnSync(powershell, [ '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '[Console]::Out.Write($PSVersionTable.PSVersion.ToString(2))', - ], { shell: false, windowsHide: true, encoding: 'utf8', timeout: 10_000 }); - assert.ifError(version.error); + ], { + shell: false, windowsHide: true, encoding: 'utf8', timeout: WINDOWS_FIXTURE_PROCESS_TIMEOUT_MS, + }); + assertPowerShellInvocation(version, 'version'); assert.equal(version.status, 0); assert.equal(version.stdout, '5.1'); assert.equal(version.stderr, ''); @@ -221,8 +234,8 @@ windowsIt('keeps the encoded Windows PowerShell 5.1 ACL helper fail-closed and b const classifierRegression = spawnSync(powershell, [ '-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', encodedOwnerClassifierRegression, - ], { shell: false, windowsHide: true, timeout: 10_000 }); - assertProofProcess(classifierRegression); + ], { shell: false, windowsHide: true, timeout: WINDOWS_FIXTURE_PROCESS_TIMEOUT_MS }); + assertProofProcess(classifierRegression, 'owner-classifier'); const classifierCategory = new Map([ [78, 'unknown-owner'], [80, 'allowlisted-owner'], @@ -308,7 +321,7 @@ windowsIt('keeps the encoded Windows PowerShell 5.1 ACL helper fail-closed and b ], { shell: false, windowsHide: true, - timeout: 30_000, + timeout: WINDOWS_FIXTURE_PROCESS_TIMEOUT_MS, env: { ...process.env, PROPR_FIXTURE_ACL_KIND: entry.kind, @@ -316,8 +329,7 @@ windowsIt('keeps the encoded Windows PowerShell 5.1 ACL helper fail-closed and b }, }); - assert.ifError(result.error); - assert.equal(result.signal, null); + assertPowerShellInvocation(result, 'mutation-case'); assertPowerShellStreamEmpty(result.stdout, 'powershell-stdout'); assertPowerShellStreamEmpty(result.stderr, 'powershell-stderr'); assert.equal(result.status, entry.status, `${entry.label} returned the wrong redacted phase code`); diff --git a/packages/cli/src/connectIdentity.ts b/packages/cli/src/connectIdentity.ts index 948cb66f8..8a05839da 100644 --- a/packages/cli/src/connectIdentity.ts +++ b/packages/cli/src/connectIdentity.ts @@ -532,6 +532,7 @@ export async function readTrustedConnectTunnelOverride( return parseTrustedTunnelOverride(contents, requestedRoot, platform); } catch (error) { if (error instanceof TrustedConnectConfigError) throw error; + if (error instanceof WindowsAuthorityInspectionError) throw error; if (error instanceof WindowsAuthorityPolicyError) { throw new TrustedConnectConfigError(`NATIVE_ENTRY_${error.entryIndex}_${error.policyReason}`); } diff --git a/packages/cli/src/connectRootAuthority.test.ts b/packages/cli/src/connectRootAuthority.test.ts index cfa9d6b77..10e7b5b76 100644 --- a/packages/cli/src/connectRootAuthority.test.ts +++ b/packages/cli/src/connectRootAuthority.test.ts @@ -5,7 +5,7 @@ import { closeSync, mkdtempSync, openSync, rmSync, writeFileSync } from "node:fs import { tmpdir } from "node:os"; import { join } from "node:path"; import { performance } from "node:perf_hooks"; -import { PassThrough } from "node:stream"; +import { PassThrough, Writable } from "node:stream"; import { test } from "node:test"; import { assertNativeWindowsEntriesAuthority, @@ -23,6 +23,7 @@ import { parseWindowsNativeProbeOutput, parseWindowsBrokerDocument, runWindowsInspectionBrokerBatch, + writeWindowsInspectionRevalidationControl, WINDOWS_INSPECTION_CLEANUP_TIMEOUT_MS, WINDOWS_INSPECTION_SOURCE, WINDOWS_INSPECTION_TIMEOUT_MS, @@ -35,6 +36,7 @@ import { WINDOWS_UINT64_COMPOSER_SOURCE, WINDOWS_UNSIGNED_FIELD_DECODER_SOURCE, windowsBrokerFailureStage, + windowsBrokerFailureAttribution, WindowsNativeStageError, windowsNativeTimingBucket, windowsPowerShellEnvironment, @@ -334,7 +336,7 @@ test("Windows broker supervisor terminates and drains all siblings on timeout, f assert.deepEqual(diagnostics.map(({ brokerIndex }) => brokerIndex).sort(), ["0", "1", "2-3"]); assert.ok(diagnostics.every((diagnostic) => ( Object.keys(diagnostic).sort().join(",") - === "brokerIndex,cleanup,deadline,statusStage,stderr,stdout" + === "brokerIndex,cleanup,deadline,entryIndex,operation,statusStage,stderr,stdout" ))); assert.doesNotMatch(JSON.stringify(diagnostics), /SENTINEL|powershell|S-1-|[A-Za-z]:\\/i); }; @@ -385,6 +387,8 @@ test("Windows broker cleanup deadline never settles before an unkillable child i assert.deepEqual(diagnostics, [{ brokerIndex: "0", statusStage: "spawn:timeout", + entryIndex: null, + operation: null, stderr: "empty", stdout: "empty", deadline: "expired", @@ -422,6 +426,8 @@ test("Windows broker supervisor waits for delayed close and both stream drains", assert.deepEqual(diagnostics, [{ brokerIndex: "0", statusStage: "spawn:timeout", + entryIndex: null, + operation: null, stderr: "empty", stdout: "empty", deadline: "expired", @@ -429,6 +435,64 @@ test("Windows broker supervisor waits for delayed close and both stream drains", }]); }); +test("Windows broker entry failures carry only a bounded index and operation token", async () => { + const diagnostics: WindowsBrokerResultDiagnostic[] = []; + const encodedAclFailure = 1_000 + (7 * 100) + 76; + await assert.rejects(runWindowsInspectionBrokerBatch({ + entryCount: 1, + startBroker: () => fixtureBroker({ status: encodedAclFailure }), + deadlineMs: 200, + cleanupTimeoutMs: 100, + maxOutputBytes: 32, + onBrokerResult: diagnostic => diagnostics.push(diagnostic), + }), (error) => error instanceof WindowsNativeStageError + && error.stage === "broker:acl" + && error.entryIndex === "07" + && error.operation === "acl" + && error.message === "Windows native authority inspection failed [entry=07 operation=acl]"); + assert.deepEqual(windowsBrokerFailureAttribution(1_000 + (31 * 100) + 86), { + stage: "broker:entry-rules", entryIndex: "31", operation: "entry-rules", + }); + assert.deepEqual(windowsBrokerFailureAttribution(1_000 + (32 * 100) + 76), { + stage: "spawn:status", entryIndex: null, operation: null, + }); + assert.deepEqual(diagnostics, [{ + brokerIndex: "0", + statusStage: "broker:acl", + entryIndex: "07", + operation: "acl", + stderr: "empty", + stdout: "empty", + deadline: "active", + cleanup: "contained", + }]); + assert.doesNotMatch(JSON.stringify(diagnostics), /SENTINEL|HANDLE|S-1-|[A-Za-z]:\\/i); +}); + +test("Windows revalidation control write maps callback, error, and EPIPE failures to broker control", async () => { + const success = new PassThrough(); + success.resume(); + await writeWindowsInspectionRevalidationControl({ stdin: success } as unknown as ChildProcess); + + for (const input of [ + new Writable({ + write(_chunk, _encoding, callback) { + callback(Object.assign(new Error("private-path-SENTINEL"), { code: "EPIPE" })); + }, + }), + Object.assign(new PassThrough(), { + end: () => { throw new Error("private-path-SENTINEL"); }, + }), + ]) { + await assert.rejects( + writeWindowsInspectionRevalidationControl({ stdin: input } as unknown as ChildProcess), + (error) => error instanceof WindowsNativeStageError + && error.stage === "broker:control" + && error.message === "Windows native authority inspection failed", + ); + } +}); + test("Windows one-target broker documents reject missing, duplicate, and extra results", () => { const { index: _index, kind: _kind, authorityKind: _authorityKind, ...entry } = inspection(); const document = (entries: readonly unknown[]): string => JSON.stringify({ version: 1, entries }); @@ -467,7 +531,11 @@ test("Windows production batches fixed inherited handles and revalidates each en assert.match(WINDOWS_INSPECTION_SOURCE, /for\(\$i=0;\$i-lt \$n;\$i\+\+\)/); assert.match(WINDOWS_INSPECTION_SOURCE, /_get_osfhandle\(3\+\$i\)/); assert.match(WINDOWS_INSPECTION_SOURCE, - /DuplicateHandle\(\s*\[ProprReadOnlyAuthority\]::GetCurrentProcess\(\),\$originalHandle,\s*\[ProprReadOnlyAuthority\]::GetCurrentProcess\(\),\[ref\]\$privateHandle,0,\$false,2\)\)\{exit \$stage\}/); + /if\(\$script:e-ge 0-and \$script:e-lt 32\)\{\s+exit \(1000\+\(\$script:e\*100\)\+\$stage\)\s+\}/); + assert.match(WINDOWS_INSPECTION_SOURCE, /\$script:e=\$i/); + assert.match(WINDOWS_INSPECTION_SOURCE, /\} catch \{ Fail \} finally \{/); + assert.match(WINDOWS_INSPECTION_SOURCE, + /DuplicateHandle\(\s*\[ProprReadOnlyAuthority\]::GetCurrentProcess\(\),\$originalHandle,\s*\[ProprReadOnlyAuthority\]::GetCurrentProcess\(\),\[ref\]\$privateHandle,0,\$false,2\)\)\{Fail\}/); const initial = WINDOWS_INSPECTION_SOURCE.indexOf("$stage=74"); const revalidation = WINDOWS_INSPECTION_SOURCE.indexOf("$stage=79", initial); const decode = WINDOWS_INSPECTION_SOURCE.indexOf("$stage=81", revalidation); @@ -490,17 +558,17 @@ test("Windows production batches fixed inherited handles and revalidates each en assert.doesNotMatch(WINDOWS_INSPECTION_SOURCE, /\[uint32\]\[Runtime\.InteropServices\.Marshal\]::ReadInt32/); assert.match(WINDOWS_UNSIGNED_FIELD_DECODER_SOURCE, - /if\(-not \[BitConverter\]::IsLittleEndian\)\{exit \$stage\}\n \$signed=\[int32\]\[Runtime\.InteropServices\.Marshal\]::ReadInt32\(\$pointer,\$offset\)\n \$bytes=\[BitConverter\]::GetBytes\(\$signed\)\n \[BitConverter\]::ToUInt32\(\$bytes,0\)/); + /if\(-not \[BitConverter\]::IsLittleEndian\)\{Exit-ProprStage\}\n \$signed=\[int32\]\[Runtime\.InteropServices\.Marshal\]::ReadInt32\(\$pointer,\$offset\)\n \$bytes=\[BitConverter\]::GetBytes\(\$signed\)\n \[BitConverter\]::ToUInt32\(\$bytes,0\)/); const composedIdentity = WINDOWS_INSPECTION_SOURCE.slice(compose, WINDOWS_INSPECTION_SOURCE.indexOf("$stage=84", compose)); assert.match(composedIdentity, - /^\$stage=82\n\s+\$beforeId=Join-ProprUInt64 \$beforeLow \$beforeHigh\n\s+if\(\$beforeId-isnot \[uint64\]\)\{exit \$stage\}\n\s+\$afterId=Join-ProprUInt64 \$afterLow \$afterHigh\n\s+if\(\$afterId-isnot \[uint64\]\)\{exit \$stage\}\n\s*$/); + /^\$stage=82\n\s+\$beforeId=Join-ProprUInt64 \$beforeLow \$beforeHigh\n\s+if\(\$beforeId-isnot \[uint64\]\)\{Fail\}\n\s+\$afterId=Join-ProprUInt64 \$afterLow \$afterHigh\n\s+if\(\$afterId-isnot \[uint64\]\)\{Fail\}\n\s*$/); assert.equal(WINDOWS_INSPECTION_SOURCE.match(/\[object\[\]\]\$rulesArray=\$rules\.ToArray\(\)/g)?.length, 1); assert.doesNotMatch(WINDOWS_INSPECTION_SOURCE, /@\(\s*\$rules\s*\)/); assert.doesNotMatch(WINDOWS_INSPECTION_SOURCE, /\bpath=|\bkind=|authorityKind=|S-1-/); assert.doesNotMatch(composedIdentity, /ToString|\$entry=/); assert.doesNotMatch(WINDOWS_INSPECTION_SOURCE, /4294967296|\[uint64\]\$(?:before|after)High\*/); assert.match(WINDOWS_UINT64_COMPOSER_SOURCE, - /function Join-ProprUInt64\(\[uint32\]\$low,\[uint32\]\$high\)\{\n if\(-not \[BitConverter\]::IsLittleEndian\)\{exit \$stage\}\n \$bytes=New-Object byte\[\] 8\n \[Array\]::Copy\(\[BitConverter\]::GetBytes\(\[uint32\]\$low\),0,\$bytes,0,4\)\n \[Array\]::Copy\(\[BitConverter\]::GetBytes\(\[uint32\]\$high\),0,\$bytes,4,4\)\n \[BitConverter\]::ToUInt64\(\$bytes,0\)\n\}/); + /function Join-ProprUInt64\(\[uint32\]\$low,\[uint32\]\$high\)\{\n if\(-not \[BitConverter\]::IsLittleEndian\)\{Exit-ProprStage\}\n \$bytes=New-Object byte\[\] 8\n \[Array\]::Copy\(\[BitConverter\]::GetBytes\(\[uint32\]\$low\),0,\$bytes,0,4\)\n \[Array\]::Copy\(\[BitConverter\]::GetBytes\(\[uint32\]\$high\),0,\$bytes,4,4\)\n \[BitConverter\]::ToUInt64\(\$bytes,0\)\n\}/); const unsignedDecimal = (value: number): string => { const bytes = Buffer.alloc(4); bytes.writeInt32LE(value, 0); @@ -529,6 +597,10 @@ test("Windows production batches fixed inherited handles and revalidates each en assert.match(WINDOWS_INSPECTION_SOURCE, /\[Console\]::Out\.WriteLine\(\$json\)/); assert.match(WINDOWS_INSPECTION_SOURCE, /FreeHGlobal\(\$before\)/); assert.doesNotMatch(WINDOWS_INSPECTION_SOURCE, /CloseHandle\(\$originalHandle\)/); + const maximumSource = WINDOWS_INSPECTION_SOURCE + .replace("__PROPR_ENTRY_COUNT__", "32") + .replace("__PROPR_ROUND_COUNT__", "2"); + assert.ok(Buffer.from(maximumSource, "utf16le").toString("base64").length <= 28_000); }); test("Windows PowerShell boundary retains a derived minimal environment and no filesystem writes", () => { @@ -573,12 +645,12 @@ test("Windows timing probe isolates baseline, Reflection.Emit, Win32, and standa assert.equal(WINDOWS_NATIVE_TIMING_PROBE_SOURCE.match(/function Read-ProprUInt32/g)?.length, 1); assert.equal(WINDOWS_NATIVE_TIMING_PROBE_SOURCE.match(/Read-ProprUInt32 \$info (?:28|44|48)/g)?.length, 3); assert.match(WINDOWS_NATIVE_TIMING_PROBE_SOURCE.slice(probeCompose, probeFormat), - /^Join-ProprUInt64 \$probeLow \$probeHigh\n if\(\$probeId-isnot \[uint64\]\)\{exit \$stage\}\n $/); + /^Join-ProprUInt64 \$probeLow \$probeHigh\n if\(\$probeId-isnot \[uint64\]\)\{Fail\}\n $/); assert.equal(WINDOWS_NATIVE_TIMING_PROBE_SOURCE.slice(probeFormat, milestones[4]), [ "$probeVolumeDecimal=$probeVolume.ToString([Globalization.CultureInfo]::InvariantCulture)", " $probeIdDecimal=$probeId.ToString([Globalization.CultureInfo]::InvariantCulture)", - " if($probeVolumeDecimal-isnot [string]-or $probeVolumeDecimal.Length-eq 0-or $probeVolumeDecimal.Length-gt 10-or $probeVolumeDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage}", - " if($probeIdDecimal-isnot [string]-or $probeIdDecimal.Length-eq 0-or $probeIdDecimal.Length-gt 20-or $probeIdDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage}", + " if($probeVolumeDecimal-isnot [string]-or $probeVolumeDecimal.Length-eq 0-or $probeVolumeDecimal.Length-gt 10-or $probeVolumeDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){Fail}", + " if($probeIdDecimal-isnot [string]-or $probeIdDecimal.Length-eq 0-or $probeIdDecimal.Length-gt 20-or $probeIdDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){Fail}", " ", ].join("\n")); }); diff --git a/packages/cli/src/connectRootAuthority.ts b/packages/cli/src/connectRootAuthority.ts index 586e5606a..8a9d08ce3 100644 --- a/packages/cli/src/connectRootAuthority.ts +++ b/packages/cli/src/connectRootAuthority.ts @@ -24,6 +24,8 @@ import { runWindowsReadOnlyInspection, WindowsNativeStageError, windowsInspectionEntryKind, + type WindowsBrokerEntryIndexToken, + type WindowsBrokerEntryOperationToken, } from "./connectWindowsAuthority.js"; import { assertCanonicalNativeArtifactParents, @@ -161,12 +163,25 @@ export class WindowsAuthorityPolicyError extends Error { /** Fixed, redacted boundary for a failed read-only Windows ACL inspection. */ export class WindowsAuthorityInspectionError extends Error { - constructor() { - super("Windows ACL authority inspection is unavailable"); + constructor( + readonly entryIndex: WindowsBrokerEntryIndexToken | null = null, + readonly operation: WindowsBrokerEntryOperationToken | null = null, + ) { + super(entryIndex === null || operation === null + ? "Windows ACL authority inspection is unavailable" + : `Windows ACL authority inspection is unavailable [entry=${entryIndex} operation=${operation}]`); this.name = "WindowsAuthorityInspectionError"; } } +function unavailableWindowsAuthority(error?: unknown): WindowsAuthorityInspectionError { + if (error instanceof WindowsNativeStageError) { + reportWindowsNativeStage(error.stage); + return new WindowsAuthorityInspectionError(error.entryIndex, error.operation); + } + return new WindowsAuthorityInspectionError(); +} + export function stableAuthorityIdentity(fd: number): StableAuthorityIdentity { const stat = fstatSync(fd, { bigint: true }); return { device: stat.dev.toString(10), file: stat.ino.toString(10) }; @@ -397,8 +412,7 @@ async function nativeWindowsAcls( try { return await runWindowsReadOnlyInspection(entries); } catch (error) { - if (error instanceof WindowsNativeStageError) reportWindowsNativeStage(error.stage); - throw new WindowsAuthorityInspectionError(); + throw unavailableWindowsAuthority(error); } } @@ -411,20 +425,17 @@ async function beginNativeWindowsAclsGeneration( initial: generation.initial, revalidate: async (finalEntries) => { try { return await generation.revalidate(finalEntries); } catch (error) { - if (error instanceof WindowsNativeStageError) reportWindowsNativeStage(error.stage); - throw new WindowsAuthorityInspectionError(); + throw unavailableWindowsAuthority(error); } }, abort: async () => { try { await generation.abort(); } catch (error) { - if (error instanceof WindowsNativeStageError) reportWindowsNativeStage(error.stage); - throw new WindowsAuthorityInspectionError(); + throw unavailableWindowsAuthority(error); } }, }; } catch (error) { - if (error instanceof WindowsNativeStageError) reportWindowsNativeStage(error.stage); - throw new WindowsAuthorityInspectionError(); + throw unavailableWindowsAuthority(error); } } diff --git a/packages/cli/src/connectWindowsAuthority.ts b/packages/cli/src/connectWindowsAuthority.ts index e0618d402..6ffd7d35b 100644 --- a/packages/cli/src/connectWindowsAuthority.ts +++ b/packages/cli/src/connectWindowsAuthority.ts @@ -42,14 +42,31 @@ export type WindowsNativeStageCode = (typeof WINDOWS_NATIVE_STAGE_CODES)[number] const WINDOWS_NATIVE_STAGE_SET: ReadonlySet = new Set(WINDOWS_NATIVE_STAGE_CODES); const WINDOWS_NATIVE_DIAGNOSTIC_HOOK = Symbol.for("propr.test.windowsNativeDiagnostic"); +const WINDOWS_BROKER_ENTRY_FAILURE_BASE = 1_000; +const WINDOWS_BROKER_ENTRY_FAILURE_STRIDE = 100; + +export type WindowsBrokerEntryIndexToken = + | "00" | "01" | "02" | "03" | "04" | "05" | "06" | "07" + | "08" | "09" | "10" | "11" | "12" | "13" | "14" | "15" + | "16" | "17" | "18" | "19" | "20" | "21" | "22" | "23" + | "24" | "25" | "26" | "27" | "28" | "29" | "30" | "31"; + +export type WindowsBrokerEntryOperationToken = + | "fd" | "fd-duplicate" | "identity-initial" | "security-info" | "acl" + | "identity-revalidation" | "identity-decode" | "identity-compose" + | "entry-format" | "entry-flags" | "entry-rules" | "entry-build"; export class WindowsNativeStageError extends Error { constructor( readonly stage: WindowsNativeStageCode, /** The initiating failure retained when cleanup becomes the terminal result. */ readonly primaryStage: WindowsNativeStageCode = stage, + readonly entryIndex: WindowsBrokerEntryIndexToken | null = null, + readonly operation: WindowsBrokerEntryOperationToken | null = null, ) { - super("Windows native authority inspection failed"); + super(entryIndex === null || operation === null + ? "Windows native authority inspection failed" + : `Windows native authority inspection failed [entry=${entryIndex} operation=${operation}]`); this.name = "WindowsNativeStageError"; } } @@ -64,8 +81,10 @@ export function reportWindowsNativeStage(stage: WindowsNativeStageCode): void { function stageError( stage: WindowsNativeStageCode, primaryStage: WindowsNativeStageCode = stage, + entryIndex: WindowsBrokerEntryIndexToken | null = null, + operation: WindowsBrokerEntryOperationToken | null = null, ): WindowsNativeStageError { - return new WindowsNativeStageError(stage, primaryStage); + return new WindowsNativeStageError(stage, primaryStage, entryIndex, operation); } // Each production inspector receives every already-open target in one fixed, @@ -78,9 +97,17 @@ export const WINDOWS_INSPECTOR_CREATES_CHILD_PROCESSES = false; export const WINDOWS_INSPECTOR_WRITES_FILESYSTEM = false; export const WINDOWS_INSPECTOR_TRANSPORT = "inherited-fixed-fd-table" as const; +const WINDOWS_BROKER_STAGE_EXIT_SOURCE = String.raw` +function Exit-ProprStage { + if($script:proprEntryIndex-ge 0-and $script:proprEntryIndex-lt 32){ + exit (${WINDOWS_BROKER_ENTRY_FAILURE_BASE}+($script:proprEntryIndex*${WINDOWS_BROKER_ENTRY_FAILURE_STRIDE})+$stage) + } + exit $stage +}`; + export const WINDOWS_UNSIGNED_FIELD_DECODER_SOURCE = String.raw` function Read-ProprUInt32([IntPtr]$pointer,[int]$offset){ - if(-not [BitConverter]::IsLittleEndian){exit $stage} + if(-not [BitConverter]::IsLittleEndian){Exit-ProprStage} $signed=[int32][Runtime.InteropServices.Marshal]::ReadInt32($pointer,$offset) $bytes=[BitConverter]::GetBytes($signed) [BitConverter]::ToUInt32($bytes,0) @@ -88,7 +115,7 @@ function Read-ProprUInt32([IntPtr]$pointer,[int]$offset){ export const WINDOWS_UINT64_COMPOSER_SOURCE = String.raw` function Join-ProprUInt64([uint32]$low,[uint32]$high){ - if(-not [BitConverter]::IsLittleEndian){exit $stage} + if(-not [BitConverter]::IsLittleEndian){Exit-ProprStage} $bytes=New-Object byte[] 8 [Array]::Copy([BitConverter]::GetBytes([uint32]$low),0,$bytes,0,4) [Array]::Copy([BitConverter]::GetBytes([uint32]$high),0,$bytes,4,4) @@ -97,19 +124,24 @@ function Join-ProprUInt64([uint32]$low,[uint32]$high){ // Reflection.Emit keeps the fixed P/Invoke surface in memory. Add-Type and its // writable compiler workspace are deliberately absent. +// Two fixed internal spellings are compacted before UTF-16/base64 encoding so +// this audited source remains below the existing 28k argv bound. They contain +// no caller-controlled value and do not alter the protocol. export const WINDOWS_INSPECTION_SOURCE = String.raw` $ErrorActionPreference='Stop' $ProgressPreference='SilentlyContinue' Set-StrictMode -Version 2 +$script:proprEntryIndex=-1 +${WINDOWS_BROKER_STAGE_EXIT_SOURCE} ${WINDOWS_UNSIGNED_FIELD_DECODER_SOURCE} ${WINDOWS_UINT64_COMPOSER_SOURCE} $n=__PROPR_ENTRY_COUNT__ $r=__PROPR_ROUND_COUNT__ $stage=71 try { - if($n-lt 1-or $n-gt 32-or ($r-ne 1-and $r-ne 2)){exit $stage} + if($n-lt 1-or $n-gt 32-or ($r-ne 1-and $r-ne 2)){Exit-ProprStage} if($PSVersionTable.PSVersion.Major-ne 5-or $PSVersionTable.PSVersion.Minor-ne 1-or - $PSVersionTable.PSEdition-ne 'Desktop'-or -not [Environment]::Is64BitProcess){exit $stage} + $PSVersionTable.PSEdition-ne 'Desktop'-or -not [Environment]::Is64BitProcess){Exit-ProprStage} $assembly=[AppDomain]::CurrentDomain.DefineDynamicAssembly( (New-Object Reflection.AssemblyName('ProprReadOnlyAuthorityAssembly')), [Reflection.Emit.AssemblyBuilderAccess]::Run) @@ -137,48 +169,49 @@ try { $null=$builder.CreateType() $stage=72 $inJob=$false - if(-not [ProprReadOnlyAuthority]::IsProcessInJob([ProprReadOnlyAuthority]::GetCurrentProcess(),[IntPtr]::Zero,[ref]$inJob)){exit $stage} + if(-not [ProprReadOnlyAuthority]::IsProcessInJob([ProprReadOnlyAuthority]::GetCurrentProcess(),[IntPtr]::Zero,[ref]$inJob)){Exit-ProprStage} function Inspect-ProprEntry([int]$i,[string]$currentSid){ + $script:proprEntryIndex=$i $privateHandle=[IntPtr]::Zero $before=[IntPtr]::Zero;$after=[IntPtr]::Zero;$aclInfo=[IntPtr]::Zero $descriptor=[IntPtr]::Zero try { $stage=73 $originalHandle=[ProprReadOnlyAuthority]::_get_osfhandle(3+$i) - if($originalHandle-eq [IntPtr](-1)-or $originalHandle-eq [IntPtr](-2)-or $originalHandle-eq [IntPtr]::Zero){exit $stage} + if($originalHandle-eq [IntPtr](-1)-or $originalHandle-eq [IntPtr](-2)-or $originalHandle-eq [IntPtr]::Zero){Exit-ProprStage} $stage=80 if(-not [ProprReadOnlyAuthority]::DuplicateHandle( [ProprReadOnlyAuthority]::GetCurrentProcess(),$originalHandle, - [ProprReadOnlyAuthority]::GetCurrentProcess(),[ref]$privateHandle,0,$false,2)){exit $stage} - if($privateHandle-eq [IntPtr](-1)-or $privateHandle-eq [IntPtr](-2)-or $privateHandle-eq [IntPtr]::Zero){exit $stage} + [ProprReadOnlyAuthority]::GetCurrentProcess(),[ref]$privateHandle,0,$false,2)){Exit-ProprStage} + if($privateHandle-eq [IntPtr](-1)-or $privateHandle-eq [IntPtr](-2)-or $privateHandle-eq [IntPtr]::Zero){Exit-ProprStage} $stage=74 $before=[Runtime.InteropServices.Marshal]::AllocHGlobal(52) - if(-not [ProprReadOnlyAuthority]::GetFileInformationByHandle($privateHandle,$before)){exit $stage} + if(-not [ProprReadOnlyAuthority]::GetFileInformationByHandle($privateHandle,$before)){Exit-ProprStage} $stage=75 $owner=[IntPtr]::Zero;$group=[IntPtr]::Zero;$dacl=[IntPtr]::Zero;$sacl=[IntPtr]::Zero - if([ProprReadOnlyAuthority]::GetSecurityInfo($privateHandle,1,5,[ref]$owner,[ref]$group,[ref]$dacl,[ref]$sacl,[ref]$descriptor)-ne 0){exit $stage} - if($owner-eq [IntPtr]::Zero-or $dacl-eq [IntPtr]::Zero-or $descriptor-eq [IntPtr]::Zero){exit $stage} + if([ProprReadOnlyAuthority]::GetSecurityInfo($privateHandle,1,5,[ref]$owner,[ref]$group,[ref]$dacl,[ref]$sacl,[ref]$descriptor)-ne 0){Exit-ProprStage} + if($owner-eq [IntPtr]::Zero-or $dacl-eq [IntPtr]::Zero-or $descriptor-eq [IntPtr]::Zero){Exit-ProprStage} $ownerSid=(New-Object Security.Principal.SecurityIdentifier($owner)).Value $control=[uint16]0;$revision=[uint32]0 - if(-not [ProprReadOnlyAuthority]::GetSecurityDescriptorControl($descriptor,[ref]$control,[ref]$revision)){exit $stage} + if(-not [ProprReadOnlyAuthority]::GetSecurityDescriptorControl($descriptor,[ref]$control,[ref]$revision)){Exit-ProprStage} $stage=76 $aclInfo=[Runtime.InteropServices.Marshal]::AllocHGlobal(12) - if(-not [ProprReadOnlyAuthority]::GetAclInformation($dacl,$aclInfo,12,2)){exit $stage} + if(-not [ProprReadOnlyAuthority]::GetAclInformation($dacl,$aclInfo,12,2)){Exit-ProprStage} $aceCount=Read-ProprUInt32 $aclInfo 0 $aclBytes=Read-ProprUInt32 $aclInfo 4 - if($aceCount-gt 128-or $aclBytes-lt 8-or $aclBytes-gt 65535){exit $stage} + if($aceCount-gt 128-or $aclBytes-lt 8-or $aclBytes-gt 65535){Exit-ProprStage} $aclRevision=[Runtime.InteropServices.Marshal]::ReadByte($dacl,0) - if(($aclRevision-ne 2-and $aclRevision-ne 4)-or [Runtime.InteropServices.Marshal]::ReadByte($dacl,1)-ne 0){exit $stage} + if(($aclRevision-ne 2-and $aclRevision-ne 4)-or [Runtime.InteropServices.Marshal]::ReadByte($dacl,1)-ne 0){Exit-ProprStage} $rules=New-Object Collections.Generic.List[object] for($aceIndex=0;$aceIndex-lt $aceCount;$aceIndex++){ $ace=[IntPtr]::Zero - if(-not [ProprReadOnlyAuthority]::GetAce($dacl,$aceIndex,[ref]$ace)-or $ace-eq [IntPtr]::Zero){exit $stage} + if(-not [ProprReadOnlyAuthority]::GetAce($dacl,$aceIndex,[ref]$ace)-or $ace-eq [IntPtr]::Zero){Exit-ProprStage} $aceType=[Runtime.InteropServices.Marshal]::ReadByte($ace,0);$flags=[Runtime.InteropServices.Marshal]::ReadByte($ace,1) $aceSize=[uint16][Runtime.InteropServices.Marshal]::ReadInt16($ace,2) - if(($aceType-ne 0-and $aceType-ne 1)-or ($flags-band 0xE0)-ne 0-or $aceSize-lt 16-or $aceSize-gt 4096){exit $stage} + if(($aceType-ne 0-and $aceType-ne 1)-or ($flags-band 0xE0)-ne 0-or $aceSize-lt 16-or $aceSize-gt 4096){Exit-ProprStage} $mask=Read-ProprUInt32 $ace 4 $sidPointer=[IntPtr]::Add($ace,8);$sid=New-Object Security.Principal.SecurityIdentifier($sidPointer) - if($sid.BinaryLength-gt ($aceSize-8)){exit $stage} + if($sid.BinaryLength-gt ($aceSize-8)){Exit-ProprStage} $rules.Add([pscustomobject][ordered]@{ identitySid=$sid.Value;inherited=[bool](($flags-band 0x10)-ne 0) accessType=$(if($aceType-eq 0){'allow'}else{'deny'});appliesToSelf=[bool](($flags-band 8)-eq 0) @@ -187,7 +220,7 @@ try { } $stage=79 $after=[Runtime.InteropServices.Marshal]::AllocHGlobal(52) - if(-not [ProprReadOnlyAuthority]::GetFileInformationByHandle($privateHandle,$after)){exit $stage} + if(-not [ProprReadOnlyAuthority]::GetFileInformationByHandle($privateHandle,$after)){Exit-ProprStage} $stage=81 $beforeVolume=Read-ProprUInt32 $before 28 $afterVolume=Read-ProprUInt32 $after 28 @@ -195,27 +228,27 @@ try { $afterHigh=Read-ProprUInt32 $after 44;$afterLow=Read-ProprUInt32 $after 48 $stage=82 $beforeId=Join-ProprUInt64 $beforeLow $beforeHigh - if($beforeId-isnot [uint64]){exit $stage} + if($beforeId-isnot [uint64]){Exit-ProprStage} $afterId=Join-ProprUInt64 $afterLow $afterHigh - if($afterId-isnot [uint64]){exit $stage} + if($afterId-isnot [uint64]){Exit-ProprStage} $stage=84 $beforeVolumeDecimal=$beforeVolume.ToString([Globalization.CultureInfo]::InvariantCulture) $afterVolumeDecimal=$afterVolume.ToString([Globalization.CultureInfo]::InvariantCulture) $beforeIdDecimal=$beforeId.ToString([Globalization.CultureInfo]::InvariantCulture) $afterIdDecimal=$afterId.ToString([Globalization.CultureInfo]::InvariantCulture) - if($beforeVolumeDecimal-isnot [string]-or $beforeVolumeDecimal.Length-eq 0-or $beforeVolumeDecimal.Length-gt 10-or $beforeVolumeDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage} - if($afterVolumeDecimal-isnot [string]-or $afterVolumeDecimal.Length-eq 0-or $afterVolumeDecimal.Length-gt 10-or $afterVolumeDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage} - if($beforeIdDecimal-isnot [string]-or $beforeIdDecimal.Length-eq 0-or $beforeIdDecimal.Length-gt 20-or $beforeIdDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage} - if($afterIdDecimal-isnot [string]-or $afterIdDecimal.Length-eq 0-or $afterIdDecimal.Length-gt 20-or $afterIdDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage} + if($beforeVolumeDecimal-isnot [string]-or $beforeVolumeDecimal.Length-eq 0-or $beforeVolumeDecimal.Length-gt 10-or $beforeVolumeDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){Exit-ProprStage} + if($afterVolumeDecimal-isnot [string]-or $afterVolumeDecimal.Length-eq 0-or $afterVolumeDecimal.Length-gt 10-or $afterVolumeDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){Exit-ProprStage} + if($beforeIdDecimal-isnot [string]-or $beforeIdDecimal.Length-eq 0-or $beforeIdDecimal.Length-gt 20-or $beforeIdDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){Exit-ProprStage} + if($afterIdDecimal-isnot [string]-or $afterIdDecimal.Length-eq 0-or $afterIdDecimal.Length-gt 20-or $afterIdDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){Exit-ProprStage} $stage=85 $daclProtected=[bool](($control-band 0x1000)-ne 0) $reparsePoint=[bool](([Runtime.InteropServices.Marshal]::ReadInt32($before,0)-band 0x400)-ne 0) - if($daclProtected-isnot [bool]-or $reparsePoint-isnot [bool]){exit $stage} + if($daclProtected-isnot [bool]-or $reparsePoint-isnot [bool]){Exit-ProprStage} $stage=86 [object[]]$rulesArray=$rules.ToArray() - if($rulesArray-isnot [object[]]-or $rulesArray.Count-ne $rules.Count-or $rulesArray.Count-gt 128){exit $stage} + if($rulesArray-isnot [object[]]-or $rulesArray.Count-ne $rules.Count-or $rulesArray.Count-gt 128){Exit-ProprStage} for($ruleIndex=0;$ruleIndex-lt $rulesArray.Count;$ruleIndex++){ - if(-not [object]::ReferenceEquals($rulesArray[$ruleIndex],$rules[$ruleIndex])){exit $stage} + if(-not [object]::ReferenceEquals($rulesArray[$ruleIndex],$rules[$ruleIndex])){Exit-ProprStage} } $stage=83 return [pscustomobject][ordered]@{ @@ -226,7 +259,7 @@ try { verifiedVolumeSerialNumber=$afterVolumeDecimal verifiedFileId=$afterIdDecimal;rules=$rulesArray } - } finally { + } catch { Exit-ProprStage } finally { if($descriptor-ne [IntPtr]::Zero){$null=[ProprReadOnlyAuthority]::LocalFree($descriptor)};if($aclInfo-ne [IntPtr]::Zero){[Runtime.InteropServices.Marshal]::FreeHGlobal($aclInfo)} if($after-ne [IntPtr]::Zero){[Runtime.InteropServices.Marshal]::FreeHGlobal($after)};if($before-ne [IntPtr]::Zero){[Runtime.InteropServices.Marshal]::FreeHGlobal($before)} if($privateHandle-ne [IntPtr]::Zero){$null=[ProprReadOnlyAuthority]::CloseHandle($privateHandle)} @@ -236,26 +269,27 @@ try { for($q=0;$q-lt $r;$q++){ if($q-ne 0){ $stage=87 - if([Console]::In.ReadLine()-cne 'PROPR_REVALIDATE_V1'){exit $stage} + if([Console]::In.ReadLine()-cne 'PROPR_REVALIDATE_V1'){Exit-ProprStage} } $stage=78 $current=[Security.Principal.WindowsIdentity]::GetCurrent().User - if($null-eq $current){exit $stage} + if($null-eq $current){Exit-ProprStage} $entries=New-Object Collections.Generic.List[object] for($i=0;$i-lt $n;$i++){ $entries.Add((Inspect-ProprEntry $i $current.Value)) + $script:proprEntryIndex=-1 } $stage=77 [object[]]$entryArray=$entries.ToArray() - if($entryArray.Count-ne $n){exit $stage} + if($entryArray.Count-ne $n){Exit-ProprStage} $json=ConvertTo-Json ([pscustomobject][ordered]@{version=1;entries=$entryArray}) -Compress -Depth 5 - if([Text.Encoding]::UTF8.GetByteCount($json)-gt 131072){exit $stage} + if([Text.Encoding]::UTF8.GetByteCount($json)-gt 131072){Exit-ProprStage} [Console]::Out.WriteLine($json) [Console]::Out.Flush() } exit 0 -}catch{exit $stage} -`; +}catch{Exit-ProprStage} +`.replaceAll("Exit-ProprStage", "Fail").replaceAll("proprEntryIndex", "e"); export const WINDOWS_NATIVE_PROBE_MILESTONES = Object.freeze([ "entry-ps51-desktop-x64", @@ -277,6 +311,8 @@ export const WINDOWS_NATIVE_TIMING_PROBE_SOURCE = String.raw` $ErrorActionPreference='Stop' $ProgressPreference='SilentlyContinue' Set-StrictMode -Version 2 +$script:proprEntryIndex=-1 +${WINDOWS_BROKER_STAGE_EXIT_SOURCE} ${WINDOWS_UNSIGNED_FIELD_DECODER_SOURCE} ${WINDOWS_UINT64_COMPOSER_SOURCE} $clock=[Diagnostics.Stopwatch]::StartNew() @@ -289,11 +325,11 @@ function Write-ProprMilestone([string]$name){ $stage=91 try { if($PSVersionTable.PSVersion.Major-ne 5-or $PSVersionTable.PSVersion.Minor-ne 1-or - $PSVersionTable.PSEdition-ne 'Desktop'-or -not [Environment]::Is64BitProcess){exit $stage} + $PSVersionTable.PSEdition-ne 'Desktop'-or -not [Environment]::Is64BitProcess){Exit-ProprStage} Write-ProprMilestone 'entry-ps51-desktop-x64' $stage=92 $baseline='{"version":1,"baseline":"constant"}' - if($baseline-ne '{"version":1,"baseline":"constant"}'){exit $stage} + if($baseline-ne '{"version":1,"baseline":"constant"}'){Exit-ProprStage} Write-ProprMilestone 'constant-json' $stage=93 $assembly=[AppDomain]::CurrentDomain.DefineDynamicAssembly( @@ -314,25 +350,25 @@ try { $null=$builder.CreateType() Write-ProprMilestone 'reflection-emit' $stage=94 - if([ProprNativeTimingProbe]::GetCurrentProcessId()-eq 0){exit $stage} + if([ProprNativeTimingProbe]::GetCurrentProcessId()-eq 0){Exit-ProprStage} Write-ProprMilestone 'harmless-win32' $stage=95 $handle=[ProprNativeTimingProbe]::GetStdHandle(-10) - if($handle-eq [IntPtr](-1)-or $handle-eq [IntPtr](-2)-or $handle-eq [IntPtr]::Zero){exit $stage} + if($handle-eq [IntPtr](-1)-or $handle-eq [IntPtr](-2)-or $handle-eq [IntPtr]::Zero){Exit-ProprStage} $info=[Runtime.InteropServices.Marshal]::AllocHGlobal(52) - if(-not [ProprNativeTimingProbe]::GetFileInformationByHandle($handle,$info)){exit $stage} + if(-not [ProprNativeTimingProbe]::GetFileInformationByHandle($handle,$info)){Exit-ProprStage} $probeVolume=Read-ProprUInt32 $info 28 $probeHigh=Read-ProprUInt32 $info 44;$probeLow=Read-ProprUInt32 $info 48 $probeId=Join-ProprUInt64 $probeLow $probeHigh - if($probeId-isnot [uint64]){exit $stage} + if($probeId-isnot [uint64]){Exit-ProprStage} $probeVolumeDecimal=$probeVolume.ToString([Globalization.CultureInfo]::InvariantCulture) $probeIdDecimal=$probeId.ToString([Globalization.CultureInfo]::InvariantCulture) - if($probeVolumeDecimal-isnot [string]-or $probeVolumeDecimal.Length-eq 0-or $probeVolumeDecimal.Length-gt 10-or $probeVolumeDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage} - if($probeIdDecimal-isnot [string]-or $probeIdDecimal.Length-eq 0-or $probeIdDecimal.Length-gt 20-or $probeIdDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage} + if($probeVolumeDecimal-isnot [string]-or $probeVolumeDecimal.Length-eq 0-or $probeVolumeDecimal.Length-gt 10-or $probeVolumeDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){Exit-ProprStage} + if($probeIdDecimal-isnot [string]-or $probeIdDecimal.Length-eq 0-or $probeIdDecimal.Length-gt 20-or $probeIdDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){Exit-ProprStage} Write-ProprMilestone 'standard-handle-identity' exit 0 -}catch{exit $stage} -`; +}catch{Exit-ProprStage} +`.replaceAll("Exit-ProprStage", "Fail").replaceAll("proprEntryIndex", "e"); interface HeldExecutable { readonly path: string; @@ -457,15 +493,51 @@ export function parseWindowsInspectionDocument(value: Buffer | string): readonly return document.entries as WindowsAuthorityInspection[]; } +const WINDOWS_BROKER_FAILURE_STAGES: Readonly> = { + 71: "broker:ps-version", 72: "broker:job", 73: "broker:fd", 74: "broker:index-info-initial", + 75: "broker:security-info", 76: "broker:acl", 77: "broker:json", + 78: "broker:current-user-sid", 79: "broker:index-info-revalidation", 80: "broker:fd-duplicate", + 81: "broker:index-info-decode", 82: "broker:index-info-compose", 83: "broker:entry-build", + 84: "broker:entry-format", 85: "broker:entry-flags", 86: "broker:entry-rules", 87: "broker:control", +}; + +const WINDOWS_BROKER_ENTRY_OPERATIONS: Readonly> = { + 73: "fd", 74: "identity-initial", 75: "security-info", 76: "acl", + 79: "identity-revalidation", 80: "fd-duplicate", 81: "identity-decode", + 82: "identity-compose", 83: "entry-build", 84: "entry-format", + 85: "entry-flags", 86: "entry-rules", +}; + +export interface WindowsBrokerFailureAttribution { + readonly stage: WindowsNativeStageCode; + readonly entryIndex: WindowsBrokerEntryIndexToken | null; + readonly operation: WindowsBrokerEntryOperationToken | null; +} + +export function windowsBrokerFailureAttribution(status: number | null): WindowsBrokerFailureAttribution { + if (status !== null && Number.isInteger(status) && status >= WINDOWS_BROKER_ENTRY_FAILURE_BASE) { + const encoded = status - WINDOWS_BROKER_ENTRY_FAILURE_BASE; + const index = Math.floor(encoded / WINDOWS_BROKER_ENTRY_FAILURE_STRIDE); + const operationCode = encoded % WINDOWS_BROKER_ENTRY_FAILURE_STRIDE; + const stage = WINDOWS_BROKER_FAILURE_STAGES[operationCode]; + const operation = WINDOWS_BROKER_ENTRY_OPERATIONS[operationCode]; + if (index >= 0 && index < WINDOWS_INSPECTION_MAX_ENTRIES && stage && operation) { + return Object.freeze({ + stage, + entryIndex: String(index).padStart(2, "0") as WindowsBrokerEntryIndexToken, + operation, + }); + } + } + return Object.freeze({ + stage: status === null ? "spawn:status" : (WINDOWS_BROKER_FAILURE_STAGES[status] ?? "spawn:status"), + entryIndex: null, + operation: null, + }); +} + export function windowsBrokerFailureStage(status: number | null): WindowsNativeStageCode { - const stages: Readonly> = { - 71: "broker:ps-version", 72: "broker:job", 73: "broker:fd", 74: "broker:index-info-initial", - 75: "broker:security-info", 76: "broker:acl", 77: "broker:json", - 78: "broker:current-user-sid", 79: "broker:index-info-revalidation", 80: "broker:fd-duplicate", - 81: "broker:index-info-decode", 82: "broker:index-info-compose", 83: "broker:entry-build", - 84: "broker:entry-format", 85: "broker:entry-flags", 86: "broker:entry-rules", 87: "broker:control", - }; - return status === null ? "spawn:status" : (stages[status] ?? "spawn:status"); + return windowsBrokerFailureAttribution(status).stage; } /** The fixed inspector receives no caller-controlled executable/module/profile/temp authority. */ @@ -633,6 +705,8 @@ interface InspectionBrokerState { stderrEnded: boolean; closed: boolean; statusStage: WindowsBrokerStatusStage; + entryIndex: WindowsBrokerEntryIndexToken | null; + operation: WindowsBrokerEntryOperationToken | null; diagnosticReported: boolean; } @@ -646,6 +720,8 @@ export type WindowsBrokerCleanupState = "not-started" | "contained" | "deadline- export interface WindowsBrokerResultDiagnostic { readonly brokerIndex: WindowsBrokerIndexBucket; readonly statusStage: WindowsBrokerStatusStage; + readonly entryIndex: WindowsBrokerEntryIndexToken | null; + readonly operation: WindowsBrokerEntryOperationToken | null; readonly stderr: WindowsBrokerStreamState; readonly stdout: WindowsBrokerStreamState; readonly deadline: WindowsBrokerDeadlineState; @@ -708,6 +784,8 @@ export function runWindowsInspectionBrokerBatch({ onBrokerResult?.(Object.freeze({ brokerIndex: windowsBrokerIndexBucket(index), statusStage: broker.statusStage, + entryIndex: broker.entryIndex, + operation: broker.operation, stderr: broker.stderrState, stdout: broker.stdoutState, deadline: deadlineExpired ? "expired" : "active", @@ -735,9 +813,9 @@ export function runWindowsInspectionBrokerBatch({ try { broker.child.kill("SIGKILL"); } catch { /* Retain ownership and retry until close/drain. */ } } }; - const fail = (stage: WindowsNativeStageCode): void => { + const fail = (stageOrError: WindowsNativeStageCode | WindowsNativeStageError): void => { if (failure || settled) return; - failure = stageError(stage); + failure = stageOrError instanceof WindowsNativeStageError ? stageOrError : stageError(stageOrError); clearTimeout(deadlineTimer); for (const broker of brokers) { if (!broker.closed && broker.statusStage === "ok") broker.statusStage = "sibling-termination"; @@ -746,7 +824,12 @@ export function runWindowsInspectionBrokerBatch({ cleanupTimer = setTimeout(() => { if (settled) return; cleanupDeadlineExpired = true; - failure = stageError("spawn:cleanup", failure!.primaryStage); + failure = stageError( + "spawn:cleanup", + failure!.primaryStage, + failure!.entryIndex, + failure!.operation, + ); terminateLiveBrokers(); // A cleanup deadline classifies the eventual fixed failure; it never // proves containment. Keep every listener and a referenced retry alive @@ -796,6 +879,8 @@ export function runWindowsInspectionBrokerBatch({ stderrEnded: !child.stderr, closed: false, statusStage: "ok", + entryIndex: null, + operation: null, diagnosticReported: false, }; brokers.push(broker); @@ -821,12 +906,17 @@ export function runWindowsInspectionBrokerBatch({ if (signal !== null && !failure) { broker.statusStage = "spawn:status"; } else if (status !== 0) { - broker.statusStage = windowsBrokerFailureStage(status); + const attribution = windowsBrokerFailureAttribution(status); + broker.statusStage = attribution.stage; + broker.entryIndex = attribution.entryIndex; + broker.operation = attribution.operation; } } - if (!failure && broker.statusStage !== "ok") fail( - broker.statusStage === "sibling-termination" ? "spawn:status" : broker.statusStage, - ); + if (!failure && broker.statusStage !== "ok") { + fail(broker.statusStage === "sibling-termination" + ? "spawn:status" + : stageError(broker.statusStage, broker.statusStage, broker.entryIndex, broker.operation)); + } settle(); }); } @@ -901,6 +991,32 @@ interface WindowsInspectionProcess { readonly firstFrame: Promise; } +export function writeWindowsInspectionRevalidationControl(child: ChildProcess): Promise { + return new Promise((resolve, reject) => { + const input = child.stdin; + if (!input || input.destroyed || !input.writable) { + reject(stageError("broker:control")); + return; + } + let completed = false; + const complete = (error?: Error | null): void => { + if (completed) return; + completed = true; + if (error) reject(stageError("broker:control")); + else resolve(); + }; + const onError = (): void => complete(stageError("broker:control")); + // Keep the listener through the stream's terminal lifetime. Node may invoke + // the end callback before emitting its paired EPIPE/error event. + input.on("error", onError); + try { + input.end("PROPR_REVALIDATE_V1\n", "ascii", complete); + } catch { + complete(stageError("broker:control")); + } + }); +} + function startWindowsInspectionProcess( executable: HeldExecutable, targets: readonly WindowsAuthorityTarget[], @@ -1024,8 +1140,7 @@ export async function beginWindowsReadOnlyInspectionGeneration( revalidateWindowsPowerShell(executable); revalidateWindowsTargets(finalTargets); if (!sameWindowsInspectionTargets(targets, finalTargets)) throw stageError("parent:post-bind"); - if (!processState.child.stdin) throw stageError("spawn:create"); - processState.child.stdin.end("PROPR_REVALIDATE_V1\n", "ascii"); + await writeWindowsInspectionRevalidationControl(processState.child); const outputs = await processState.terminal; const frames = splitWindowsBrokerFrames(outputs[0], 2); result = bindWindowsInspectionTargets(frames[1], finalTargets); diff --git a/scripts/verify-platform-safe-connect.mjs b/scripts/verify-platform-safe-connect.mjs index b1400b68f..6720a2fd8 100644 --- a/scripts/verify-platform-safe-connect.mjs +++ b/scripts/verify-platform-safe-connect.mjs @@ -37,14 +37,14 @@ const tapValue = (name) => { const valid = result.status === 0 && !result.error && !result.signal - && tapValue('tests') === 90 - && tapValue('pass') === 90 + && tapValue('tests') === 92 + && tapValue('pass') === 92 && tapValue('fail') === 0 && tapValue('skipped') === 0; if (!valid) { - process.stderr.write('Platform-safe Connect proof did not complete 90/90 within 90000ms.\n'); + process.stderr.write('Platform-safe Connect proof did not complete 92/92 within 90000ms.\n'); process.exitCode = 1; } else { - process.stdout.write('Platform-safe Connect proof: tests=90 pass=90 fail=0 skipped=0 budgetMs=90000\n'); + process.stdout.write('Platform-safe Connect proof: tests=92 pass=92 fail=0 skipped=0 budgetMs=90000\n'); } diff --git a/test/publicInstanceIdentity.test.ts b/test/publicInstanceIdentity.test.ts index a8d3e7a3b..db6a1bb30 100644 --- a/test/publicInstanceIdentity.test.ts +++ b/test/publicInstanceIdentity.test.ts @@ -45,6 +45,7 @@ import { assertSafeDarwinAclOutput, assertSafeWindowsAuthority, stableAuthorityIdentity, + WindowsAuthorityInspectionError, type ConnectRootAuthorityInspector, type WindowsAuthorityInspection, } from '../packages/cli/src/connectRootAuthority.js'; @@ -926,6 +927,29 @@ test('trusted Connect config read is bounded, root-specific, replacement-safe, a } }); +test('trusted Connect config preserves unavailable Windows root authority for status mapping', async () => { + const parent = temporaryRoot('propr-connect-trusted-authority-'); + const home = join(parent, 'os-home'); + const configDir = join(home, '.propr'); + privateDirectory(configDir); + writeFileSync(join(configDir, 'config.json'), JSON.stringify({ + tunnelEnabledByRoot: { 'C:\\trusted\\stack': true }, + }), { mode: 0o600 }); + const unavailable = new WindowsAuthorityInspectionError(); + const inspector: ConnectRootAuthorityInspector = { + inspectDarwinAcl: () => { throw new Error('unused'); }, + inspectWindowsAcl: async () => { throw unavailable; }, + inspectWindowsAcls: async () => { throw unavailable; }, + }; + try { + await assert.rejects(readTrustedConnectTunnelOverride('C:\\trusted\\stack', { + platform: 'win32', trustedHome: home, authorityInspector: inspector, + }), (error) => error === unavailable); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + test('trusted config authenticates absence only at the exact config child open', async () => { const root = '/trusted/stack'; const boundaries = [ diff --git a/test/windowsStandardUserConnectHarness.test.ts b/test/windowsStandardUserConnectHarness.test.ts index 12a138bd9..f2e9511eb 100644 --- a/test/windowsStandardUserConnectHarness.test.ts +++ b/test/windowsStandardUserConnectHarness.test.ts @@ -476,11 +476,11 @@ test('the production inspector duplicates each fixed fd before split native oper const productionSourceStart = windowsAuthority.indexOf('export const WINDOWS_INSPECTION_SOURCE'); const productionSourceEnd = windowsAuthority.indexOf('export const WINDOWS_NATIVE_PROBE_MILESTONES', productionSourceStart); const productionSource = windowsAuthority.slice(productionSourceStart, productionSourceEnd); - assert.match(productionSource, /\$stage=80\s+if\(-not \[ProprReadOnlyAuthority\]::DuplicateHandle\(\s*\[ProprReadOnlyAuthority\]::GetCurrentProcess\(\),\$originalHandle,\s*\[ProprReadOnlyAuthority\]::GetCurrentProcess\(\),\[ref\]\$privateHandle,0,\$false,2\)\)\{exit \$stage\}/); - assert.match(productionSource, /\$stage=74\s+\$before=\[Runtime\.InteropServices\.Marshal\]::AllocHGlobal\(52\)\s+if\(-not \[ProprReadOnlyAuthority\]::GetFileInformationByHandle\(\$privateHandle,\$before\)\)\{exit \$stage\}/); - assert.match(productionSource, /\$stage=78\s+\$current=\[Security\.Principal\.WindowsIdentity\]::GetCurrent\(\)\.User\s+if\(\$null-eq \$current\)\{exit \$stage\}/); + assert.match(productionSource, /\$stage=80\s+if\(-not \[ProprReadOnlyAuthority\]::DuplicateHandle\(\s*\[ProprReadOnlyAuthority\]::GetCurrentProcess\(\),\$originalHandle,\s*\[ProprReadOnlyAuthority\]::GetCurrentProcess\(\),\[ref\]\$privateHandle,0,\$false,2\)\)\{Exit-ProprStage\}/); + assert.match(productionSource, /\$stage=74\s+\$before=\[Runtime\.InteropServices\.Marshal\]::AllocHGlobal\(52\)\s+if\(-not \[ProprReadOnlyAuthority\]::GetFileInformationByHandle\(\$privateHandle,\$before\)\)\{Exit-ProprStage\}/); + assert.match(productionSource, /\$stage=78\s+\$current=\[Security\.Principal\.WindowsIdentity\]::GetCurrent\(\)\.User\s+if\(\$null-eq \$current\)\{Exit-ProprStage\}/); assert.match(productionSource, /GetSecurityInfo\(\$privateHandle,1,5,\[ref\]\$owner,\[ref\]\$group,\[ref\]\$dacl,\[ref\]\$sacl,\[ref\]\$descriptor\)/); - assert.match(productionSource, /\$stage=79\s+\$after=\[Runtime\.InteropServices\.Marshal\]::AllocHGlobal\(52\)\s+if\(-not \[ProprReadOnlyAuthority\]::GetFileInformationByHandle\(\$privateHandle,\$after\)\)\{exit \$stage\}/); + assert.match(productionSource, /\$stage=79\s+\$after=\[Runtime\.InteropServices\.Marshal\]::AllocHGlobal\(52\)\s+if\(-not \[ProprReadOnlyAuthority\]::GetFileInformationByHandle\(\$privateHandle,\$after\)\)\{Exit-ProprStage\}/); assert.equal(productionSource.match(/::CloseHandle\(\$privateHandle\)/g)?.length, 1); assert.match(productionSource, /if\(\$privateHandle-ne \[IntPtr\]::Zero\)\{\$null=\[ProprReadOnlyAuthority\]::CloseHandle\(\$privateHandle\)\}/); assert.doesNotMatch(productionSource, /CloseHandle\(\$originalHandle\)/); From b52e788fe16507c33f1146c39f7dadb14f8ce31b Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:07:50 +0000 Subject: [PATCH 7/8] feat(ai): Implemented the narrow harness correction on exact head `55eb9231c`. Implemented the narrow harness correction on exact head `55eb9231c`. - Removed `runWindowsNativeTimingProbe` and probe telemetry from the gating ordinary-user path in [verify-windows-standard-user-connect.mjs](/tmp/git-processor/worktrees/integry/propr/pr-2069-followup-2026-09-02T17-56-37/scripts/verify-windows-standard-user-connect.mjs:116). - Preserved fixed 60/135/240-second contracts, status predicates, stderr rejection, and bounded secret-free diagnostics. - Added regressions proving the path never invokes the timing probe and exercises `ready` plus `authority-malformed` mapping in [windowsStandardUserConnectHarness.test.ts](/tmp/git-processor/worktrees/integry/propr/pr-2069-followup-2026-09-02T17-56-37/test/windowsStandardUserConnectHarness.test.ts:482). - Production authority/supervisor code and lockfile were untouched. Validation passed: - Harness regressions: 9/9 - Authority/supervisor tests: 18/18 - Platform-safe Connect: 92/92 - Packaged lifecycle/READY: 32/32 - Native durability: 115/115 - CLI typecheck - Linux x64 desktop packaging - `git diff --check` Target-native Windows x64/ARM64 ordinary-user and packaged Connect, Linux ARM64, and Darwin x64/ARM64 smoke reruns remain for CI. This Linux x64 container lacks `dbus-run-session`/`xvfb-run`, so packaged Linux launch could not run locally. No commit was created. PR: #2069 Comment by: @integry (ID: 5514001116) Model: gpt-5.6-sol --- .../verify-windows-standard-user-connect.mjs | 74 ++++--------------- .../windowsStandardUserConnectHarness.test.ts | 73 +++++++----------- 2 files changed, 40 insertions(+), 107 deletions(-) diff --git a/scripts/verify-windows-standard-user-connect.mjs b/scripts/verify-windows-standard-user-connect.mjs index 657792e27..7b45f626e 100644 --- a/scripts/verify-windows-standard-user-connect.mjs +++ b/scripts/verify-windows-standard-user-connect.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { closeSync, constants, mkdtempSync, openSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; import { userInfo } from "node:os"; import { dirname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; @@ -87,7 +87,7 @@ const scenarioAllowlist = Object.freeze([ "authority-missing-system-root", "authority-mismatched-system-root", "authority-untrusted-system-root", ]); const assertionStageAllowlist = Object.freeze([ - "native-timing", "authority-probe", "scaffold", "identity-assertion", "config-init", "config-save", + "authority-probe", "scaffold", "identity-assertion", "config-init", "config-save", "config-assertion", "write-env", "spawn", "signal", "exit", "bounds", "schema", "status", "endpoint", "identity", "reasons", "api-ready", "restart", "stderr", "sentinel", "api-spawn", @@ -106,7 +106,6 @@ const reasonCodeAllowlist = Object.freeze([ const nativeStageAllowlist = Object.freeze([ "resolver:env", "resolver:canonical", "resolver:global-open", "resolver:global-id", "spawn:create", "spawn:error", "spawn:timeout", "spawn:status", "spawn:stderr", "spawn:cleanup", - "probe:entry", "probe:baseline", "probe:reflection-emit", "probe:win32", "probe:standard-handle", "probe:output", "broker:ps-version", "broker:job", "broker:fd", "broker:fd-duplicate", "broker:index-info-initial", "broker:security-info", "broker:acl", "broker:json", "broker:current-user-sid", "broker:index-info-revalidation", "broker:index-info-decode", "broker:index-info-compose", "broker:entry-format", @@ -114,23 +113,18 @@ const nativeStageAllowlist = Object.freeze([ "parent:utf8", "parent:json-parse", "parent:json-canonical", "parent:document-shape", "parent:entry-count", "parent:entry-shape", "parent:json-shape", "parent:descriptor-bind", "parent:post-bind", ]); -const probeMilestoneAllowlist = Object.freeze([ - "none", "entry-ps51-desktop-x64", "constant-json", "reflection-emit", "harmless-win32", - "standard-handle-identity", -]); -const probeTimingAllowlist = Object.freeze([ - "under-5s", "5-to-15s", "15-to-30s", "30-to-45s", "45-to-60s", "at-least-60s", -]); +const WINDOWS_PRODUCT_AUTHORITY_TIMEOUT_MS = 60_000; const WINDOWS_PRODUCT_AUTHORITY_PHASE_COUNT = 2; const WINDOWS_PRODUCT_SCENARIO_OVERHEAD_MS = 15_000; +const WINDOWS_PRODUCT_SCENARIO_TIMEOUT_MS = ( + WINDOWS_PRODUCT_AUTHORITY_PHASE_COUNT * WINDOWS_PRODUCT_AUTHORITY_TIMEOUT_MS +) + WINDOWS_PRODUCT_SCENARIO_OVERHEAD_MS; const scenarioNames = new Set(scenarioAllowlist); const assertionStages = new Set(assertionStageAllowlist); const statusKinds = new Set(statusKindAllowlist); const diagnosticStatuses = new Set([null, ...statusKindAllowlist]); const reasonCodes = new Set(reasonCodeAllowlist); const nativeStages = new Set(nativeStageAllowlist); -const probeMilestones = new Set(probeMilestoneAllowlist); -const probeTimings = new Set(probeTimingAllowlist); function parseBoundedFailureStatus(stdout) { if (typeof stdout !== "string" || stdout.length === 0 || Buffer.byteLength(stdout, "utf8") >= 2048) return null; @@ -148,25 +142,18 @@ function parseBoundedFailureStatus(stdout) { } } -function createFailureDiagnostic(scenario, stage, failureStatus, nativeStage, probe) { +function createFailureDiagnostic(scenario, stage, failureStatus, nativeStage) { const status = failureStatus?.status ?? null; const codes = failureStatus?.reasonCodes ?? []; if (!scenarioNames.has(scenario) || !assertionStages.has(stage) || !diagnosticStatuses.has(status) || (nativeStage !== null && !nativeStages.has(nativeStage)) - || (probe.milestone !== null && !probeMilestones.has(probe.milestone)) - || (probe.timing !== null && !probeTimings.has(probe.timing)) || !Array.isArray(codes) || codes.length > reasonCodes.size || new Set(codes).size !== codes.length || codes.some((code) => !reasonCodes.has(code))) { return { scenario: "ready", stage: "write-env", nativeStage: null, status: null, reasonCodes: [], - probeMilestone: null, probeTiming: null, }; } - return { - scenario, stage, nativeStage, status, reasonCodes: [...codes], - probeMilestone: probe.milestone, - probeTiming: probe.timing, - }; + return { scenario, stage, nativeStage, status, reasonCodes: [...codes] }; } function extractNativeDiagnostic(stderr) { @@ -220,46 +207,13 @@ let currentScenario = "ready"; let currentStage = "write-env"; let failureStatus = null; let currentNativeStage = null; -const nativeProbe = { milestone: null, timing: null, evidence: null }; try { assert.ok(expectedUser && actualUser.toLowerCase() === expectedUser.toLowerCase(), "proof did not run as the limited user"); - currentStage = "native-timing"; - const nativeAuthority = await import(windowsAuthorityModule); - const WINDOWS_PRODUCT_SCENARIO_TIMEOUT_MS = ( - WINDOWS_PRODUCT_AUTHORITY_PHASE_COUNT * nativeAuthority.WINDOWS_INSPECTION_TIMEOUT_MS - ) + WINDOWS_PRODUCT_SCENARIO_OVERHEAD_MS; - const probeFd = openSync( - fixture, - constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, - ); - try { - try { - const proof = nativeAuthority.runWindowsNativeTimingProbe(probeFd); - assert.equal(proof.version, 1); - nativeProbe.milestone = proof.lastMilestone; - nativeProbe.timing = proof.timingBucket; - if (proof.outcome === "timeout") currentNativeStage = "spawn:timeout"; - assert.equal(proof.outcome, "complete"); - assert.deepEqual(proof.milestones.map(({ milestone }) => milestone), [ - "entry-ps51-desktop-x64", "constant-json", "reflection-emit", "harmless-win32", - "standard-handle-identity", - ]); - assert.ok(proof.milestones.every(({ milestone, timingBucket }) => ( - probeMilestones.has(milestone) && probeTimings.has(timingBucket) - ))); - nativeProbe.evidence = proof.milestones.map( - ({ milestone, timingBucket }) => `${milestone}:${timingBucket}`, - ).join(","); - } catch (error) { - currentNativeStage = nativeStages.has(error?.stage) - ? error.stage - : (currentNativeStage ?? "parent:json-shape"); - throw error; - } - } finally { - closeSync(probeFd); - } currentStage = "authority-probe"; + // Keep the diagnostic timing probe out of this gate: the first PowerShell + // cold start must belong to a production status/authority scenario. + const nativeAuthority = await import(windowsAuthorityModule); + assert.equal(nativeAuthority.WINDOWS_INSPECTION_TIMEOUT_MS, WINDOWS_PRODUCT_AUTHORITY_TIMEOUT_MS); const authority = await import(authorityModule); await assert.rejects( authority.protectWindowsSetupEntries([{ path: root, kind: "directory" }]), @@ -464,10 +418,10 @@ try { const fail = [...api.stdout.matchAll(/^# fail (\d+)$/gm)].at(-1); assert.ok(pass && Number(pass[1]) > 0, "API discovery tests did not report passes"); assert.equal(Number(fail?.[1]), 0, "API discovery tests reported failures"); - process.stdout.write(`Windows ordinary-user discovery proof: ready=standard-handle-passed native-timing=${nativeProbe.evidence};total:${nativeProbe.timing} cli=${cases.length} api=${pass[1]} authority=${authorityFailures.length}\n`); + process.stdout.write(`Windows ordinary-user discovery proof: ready=standard-handle-passed cli=${cases.length} api=${pass[1]} authority=${authorityFailures.length}\n`); } catch { const diagnostic = createFailureDiagnostic( - currentScenario, currentStage, failureStatus, currentNativeStage, nativeProbe, + currentScenario, currentStage, failureStatus, currentNativeStage, ); process.stderr.write(`Windows ordinary-user discovery assertion failed: ${JSON.stringify( diagnostic, diff --git a/test/windowsStandardUserConnectHarness.test.ts b/test/windowsStandardUserConnectHarness.test.ts index f2e9511eb..5bd8541ad 100644 --- a/test/windowsStandardUserConnectHarness.test.ts +++ b/test/windowsStandardUserConnectHarness.test.ts @@ -16,6 +16,7 @@ const harness = readFileSync('scripts/verify-windows-standard-user-connect.mjs', const processMock = readFileSync('test/fixtures/windowsConnectProcessMock.mjs', 'utf8'); const windowsAuthority = readFileSync('packages/cli/src/connectWindowsAuthority.ts', 'utf8'); const connectCommand = readFileSync('packages/cli/src/commands/connectCommand.ts', 'utf8'); +const packagedConnectLifecycle = readFileSync('apps/desktop/scripts/packaged-connect-lifecycle.mjs', 'utf8'); function diagnosticDefinitions(): { scenarioAllowlist: string[]; @@ -23,14 +24,11 @@ function diagnosticDefinitions(): { statusKindAllowlist: string[]; reasonCodeAllowlist: string[]; nativeStageAllowlist: string[]; - probeMilestoneAllowlist: string[]; - probeTimingAllowlist: string[]; createFailureDiagnostic: ( scenario: string, stage: string, failureStatus: { status?: unknown; reasonCodes?: unknown } | null, nativeStage: string | null, - probe: { milestone: string | null; timing: string | null }, ) => Record; } { const start = harness.indexOf('const scenarioAllowlist ='); @@ -43,8 +41,6 @@ function diagnosticDefinitions(): { statusKindAllowlist, reasonCodeAllowlist, nativeStageAllowlist, - probeMilestoneAllowlist, - probeTimingAllowlist, createFailureDiagnostic, })`) as ReturnType; } @@ -342,7 +338,7 @@ test('the ordinary-user Windows diagnostic has fixed allowlists and redacts all 'authority-missing-system-root', 'authority-mismatched-system-root', 'authority-untrusted-system-root', ]); assert.deepEqual([...definitions.assertionStageAllowlist], [ - 'native-timing', 'authority-probe', 'scaffold', 'identity-assertion', 'config-init', 'config-save', + 'authority-probe', 'scaffold', 'identity-assertion', 'config-init', 'config-save', 'config-assertion', 'write-env', 'spawn', 'signal', 'exit', 'bounds', 'schema', 'status', 'endpoint', 'identity', 'reasons', 'api-ready', 'restart', 'stderr', 'sentinel', 'api-spawn', @@ -361,7 +357,6 @@ test('the ordinary-user Windows diagnostic has fixed allowlists and redacts all assert.deepEqual([...definitions.nativeStageAllowlist], [ 'resolver:env', 'resolver:canonical', 'resolver:global-open', 'resolver:global-id', 'spawn:create', 'spawn:error', 'spawn:timeout', 'spawn:status', 'spawn:stderr', 'spawn:cleanup', - 'probe:entry', 'probe:baseline', 'probe:reflection-emit', 'probe:win32', 'probe:standard-handle', 'probe:output', 'broker:ps-version', 'broker:job', 'broker:fd', 'broker:fd-duplicate', 'broker:index-info-initial', 'broker:security-info', 'broker:acl', 'broker:json', 'broker:current-user-sid', 'broker:index-info-revalidation', 'broker:index-info-decode', 'broker:index-info-compose', 'broker:entry-format', @@ -369,13 +364,6 @@ test('the ordinary-user Windows diagnostic has fixed allowlists and redacts all 'parent:utf8', 'parent:json-parse', 'parent:json-canonical', 'parent:document-shape', 'parent:entry-count', 'parent:entry-shape', 'parent:json-shape', 'parent:descriptor-bind', 'parent:post-bind', ]); - assert.deepEqual([...definitions.probeMilestoneAllowlist], [ - 'none', 'entry-ps51-desktop-x64', 'constant-json', 'reflection-emit', 'harmless-win32', - 'standard-handle-identity', - ]); - assert.deepEqual([...definitions.probeTimingAllowlist], [ - 'under-5s', '5-to-15s', '15-to-30s', '30-to-45s', '45-to-60s', 'at-least-60s', - ]); const assignedStages = [...harness.matchAll(/currentStage = "([^"]+)";/g)] .map((match) => match[1]); assert.deepEqual(new Set(assignedStages), new Set(definitions.assertionStageAllowlist)); @@ -393,12 +381,9 @@ test('the ordinary-user Windows diagnostic has fixed allowlists and redacts all identity: 'identity-SENTINEL', endpoint: 'endpoint-SENTINEL', secret: 'secret-SENTINEL', - } as { status: string; reasonCodes: string[] }, 'broker:fd', { - milestone: 'standard-handle-identity', timing: '15-to-30s', - }); + } as { status: string; reasonCodes: string[] }, 'broker:fd'); assert.deepEqual(Object.keys(diagnostic), [ 'scenario', 'stage', 'nativeStage', 'status', 'reasonCodes', - 'probeMilestone', 'probeTiming', ]); assert.deepEqual(JSON.parse(JSON.stringify(diagnostic)), { scenario: 'ready', @@ -406,30 +391,14 @@ test('the ordinary-user Windows diagnostic has fixed allowlists and redacts all nativeStage: 'broker:fd', status: 'ready', reasonCodes: ['ACL_DIAGNOSTIC_UNAVAILABLE'], - probeMilestone: 'standard-handle-identity', - probeTiming: '15-to-30s', }); assert.equal(JSON.stringify(diagnostic).includes('SENTINEL'), false); - assert.deepEqual(JSON.parse(JSON.stringify(definitions.createFailureDiagnostic( - 'ready', 'native-timing', null, 'spawn:timeout', - { milestone: 'reflection-emit', timing: 'at-least-60s' }, - ))), { - scenario: 'ready', - stage: 'native-timing', - nativeStage: 'spawn:timeout', - status: null, - reasonCodes: [], - probeMilestone: 'reflection-emit', - probeTiming: 'at-least-60s', - }); - const rejected = definitions.createFailureDiagnostic( 'private-scenario-SENTINEL', 'raw-output-SENTINEL', { status: 'secret-status-SENTINEL', reasonCodes: ['secret-reason-SENTINEL'] }, 'raw-native-stage-SENTINEL', - { milestone: 'secret-SENTINEL', timing: '12345ms-SENTINEL' }, ); assert.deepEqual(JSON.parse(JSON.stringify(rejected)), { scenario: 'ready', @@ -437,24 +406,19 @@ test('the ordinary-user Windows diagnostic has fixed allowlists and redacts all nativeStage: null, status: null, reasonCodes: [], - probeMilestone: null, - probeTiming: null, }); const catchStart = harness.lastIndexOf('} catch {'); const catchEnd = harness.indexOf('} finally {', catchStart); const catchBody = harness.slice(catchStart, catchEnd); - assert.match(catchBody, /createFailureDiagnostic\(\s*currentScenario, currentStage, failureStatus, currentNativeStage, nativeProbe,/); + assert.match(catchBody, /createFailureDiagnostic\(\s*currentScenario, currentStage, failureStatus, currentNativeStage,/); assert.match(catchBody, /JSON\.stringify\(\s*diagnostic,\s*\)/); assert.doesNotMatch(catchBody, /(?:result|api|error)\.(?:stdout|stderr|message|path|argv|env|config)/i); }); -test('the staged probe uses stdin while production inherits a fixed multi-handle fd table', () => { +test('the ordinary-user path skips the isolated timing probe and production inherits a fixed multi-handle fd table', () => { assert.doesNotMatch(windowsAuthority, /AssignProcessToJobObject|CreateJobObject/); - assert.match(harness, /runWindowsNativeTimingProbe\(probeFd\)/); - assert.match(harness, /openSync\(\s*fixture,\s*constants\.O_RDONLY \| constants\.O_DIRECTORY \| constants\.O_NOFOLLOW,\s*\)/); - assert.match(harness, /native-timing=\$\{nativeProbe\.evidence\}/); - assert.match(harness, /;total:\$\{nativeProbe\.timing\}/); + assert.doesNotMatch(harness, /runWindowsNativeTimingProbe|native-timing|nativeProbe|probeMilestone|probeTiming/); assert.match(harness, /ready=standard-handle-passed/); const productionSourceStart = windowsAuthority.indexOf('export const WINDOWS_INSPECTION_SOURCE'); @@ -515,7 +479,7 @@ test('the staged probe accepts only ordered milestone tokens and coarse timing b assert.match(windowsAuthority, /GetFileInformationByHandle/); }); -test('the diagnostic allowance precedes two bounded one-broker authority generations', () => { +test('the fixed product contracts run ready and malformed authority scenarios without a timing-probe gate', () => { assert.equal(WINDOWS_NATIVE_TIMING_PROBE_TIMEOUT_MS, 60_000); assert.equal(WINDOWS_INSPECTION_TIMEOUT_MS, 60_000); assert.equal(WINDOWS_INSPECTION_CLEANUP_TIMEOUT_MS, 5_000); @@ -525,9 +489,10 @@ test('the diagnostic allowance precedes two bounded one-broker authority generat ); assert.match(harness, /const WINDOWS_PRODUCT_AUTHORITY_PHASE_COUNT = 2;/); assert.match(harness, /const WINDOWS_PRODUCT_SCENARIO_OVERHEAD_MS = 15_000;/); + assert.match(harness, /const WINDOWS_PRODUCT_AUTHORITY_TIMEOUT_MS = 60_000;/); assert.match( harness, - /const WINDOWS_PRODUCT_SCENARIO_TIMEOUT_MS = \(\s*WINDOWS_PRODUCT_AUTHORITY_PHASE_COUNT \* nativeAuthority\.WINDOWS_INSPECTION_TIMEOUT_MS\s*\) \+ WINDOWS_PRODUCT_SCENARIO_OVERHEAD_MS;/, + /const WINDOWS_PRODUCT_SCENARIO_TIMEOUT_MS = \(\s*WINDOWS_PRODUCT_AUTHORITY_PHASE_COUNT \* WINDOWS_PRODUCT_AUTHORITY_TIMEOUT_MS\s*\) \+ WINDOWS_PRODUCT_SCENARIO_OVERHEAD_MS;/, ); const windowsProductScenarioTimeoutMs = ( 2 * WINDOWS_INSPECTION_TIMEOUT_MS @@ -535,6 +500,7 @@ test('the diagnostic allowance precedes two bounded one-broker authority generat assert.equal(windowsProductScenarioTimeoutMs, 135_000); assert.equal(Number.isFinite(windowsProductScenarioTimeoutMs), true); assert.equal(Number.isSafeInteger(windowsProductScenarioTimeoutMs), true); + assert.match(packagedConnectLifecycle, /readyTimeoutMs = 240_000,/u); assert.ok(WINDOWS_INSPECTION_CLEANUP_TIMEOUT_MS < WINDOWS_INSPECTION_TIMEOUT_MS); assert.match(windowsAuthority, /startBroker: \(\) => child/u); assert.match(windowsAuthority, /const deadlineTimer = setTimeout\(\(\) => \{\s*deadlineExpired = true;/u); @@ -550,10 +516,23 @@ test('the diagnostic allowance precedes two bounded one-broker authority generat windowsAuthority.indexOf('function probeFailureStage'), ); assert.doesNotMatch(productionInspection, /spawnSync|windowsInspectionTimeoutForElapsed/u); - const probeCall = harness.indexOf('runWindowsNativeTimingProbe(probeFd)'); - const productionMatrix = harness.indexOf('for (const scenario of cases)', probeCall); + assert.doesNotMatch(harness, /runWindowsNativeTimingProbe/); + assert.equal(fixtureScenarios()[0]?.name, 'ready'); + assert.match(harness, /\{ name: "authority-malformed", mode: "malformed", nativeStage: "parent:json-parse" \}/); + const productionMatrix = harness.indexOf('for (const scenario of cases)'); const productionSpawn = harness.indexOf('const result = spawnSync(process.execPath', productionMatrix); - assert.ok(probeCall < productionMatrix && productionMatrix < productionSpawn); + const authorityMatrix = harness.indexOf('for (const scenario of authorityFailures)', productionSpawn); + const authoritySpawn = harness.indexOf('const result = spawnSync(process.execPath', authorityMatrix); + assert.ok(productionMatrix < productionSpawn && productionSpawn < authorityMatrix && authorityMatrix < authoritySpawn); + const productScenarioLoop = harness.slice(productionMatrix, authorityMatrix); + assert.match(productScenarioLoop, /currentScenario = scenario\.name;[\s\S]*assert\.equal\(document\.status, scenario\.status, scenario\.name\);/u); + assert.match(productScenarioLoop, /const expectedStderr = scenario\.status === "ready" \? "" : `ProPR Connect discovery: \$\{scenario\.status\}\.\\n`;/u); + assert.match(productScenarioLoop, /assert\.equal\(nativeDiagnostic\.applicationStderr, expectedStderr, scenario\.name\);/u); + const authorityScenarioLoop = harness.slice(authorityMatrix, harness.indexOf('currentScenario = "api"', authorityMatrix)); + assert.match(authorityScenarioLoop, /assert\.equal\(currentNativeStage, scenario\.nativeStage, scenario\.name\);/u); + assert.match(authorityScenarioLoop, /assert\.equal\(document\.status, "invalidConfig", scenario\.name\);/u); + assert.match(authorityScenarioLoop, /scenario\.reason \?\? "ACL_DIAGNOSTIC_UNAVAILABLE"/u); + assert.match(authorityScenarioLoop, /assert\.equal\(nativeDiagnostic\.applicationStderr, "ProPR Connect discovery: invalidConfig\.\\n", scenario\.name\);/u); const probeStart = windowsAuthority.indexOf('export function runWindowsNativeTimingProbe'); const probeEnd = windowsAuthority.indexOf('\n}\n\nexport function windowsInspectionEntryKind', probeStart); const probe = windowsAuthority.slice(probeStart, probeEnd); From 77f6aaebf0d0e1cf68b93bcbe5f1271c1eb2dc13 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:23:58 +0000 Subject: [PATCH 8/8] feat(ai): Implemented the async `authority-oversized` lifecycle correction without changing production broker behavior, timeout bounds, workflows, or lockfile. Implemented the async `authority-oversized` lifecycle correction without changing production broker behavior, timeout bounds, workflows, or lockfile. Changes: - Fixed `fakeAuthorityChild` termination, stdio draining, control destruction, backpressure ordering, and idempotent terminal emission in [windowsConnectProcessMock.mjs](/tmp/git-processor/worktrees/integry/propr/pr-2069-followup-2026-09-02T18-15-25/test/fixtures/windowsConnectProcessMock.mjs:118). - Added a short-bound regression publishing exactly 128 KiB + 1 and proving `parent:utf8`, complete stdio cleanup, single exit/close, idempotent kill, failed post-kill control, and zero remaining referenced resources in [windowsStandardUserConnectHarness.test.ts](/tmp/git-processor/worktrees/integry/propr/pr-2069-followup-2026-09-02T18-15-25/test/windowsStandardUserConnectHarness.test.ts:329). Validation passed: - Async broker/control/overflow: 18/18 - Ordinary-user harness: 10/10 - CLI typecheck - Normal two-round fake-authority revalidation smoke - `git diff --check` - Production authority source and `package-lock.json` unchanged Exact checked-out head remains `b52e788fe16507c33f1146c39f7dadb14f8ce31b`; changes are intentionally uncommitted and unmerged. Native x64/ARM64 ordinary-user acceptance requires the Windows CI runners. PR: #2069 Comment by: @integry (ID: 5514238705) Model: gpt-5.6-sol --- test/fixtures/windowsConnectProcessMock.mjs | 81 +++++++++--- .../windowsStandardUserConnectHarness.test.ts | 120 ++++++++++++++++++ 2 files changed, 184 insertions(+), 17 deletions(-) diff --git a/test/fixtures/windowsConnectProcessMock.mjs b/test/fixtures/windowsConnectProcessMock.mjs index f71d31ee1..7dea4dc74 100644 --- a/test/fixtures/windowsConnectProcessMock.mjs +++ b/test/fixtures/windowsConnectProcessMock.mjs @@ -124,29 +124,59 @@ function fakeAuthorityChild(args, options, mode) { child.pid = 0x7000_0000 + authorityInvocation; child.exitCode = null; child.signalCode = null; - let closed = false; + child.killed = false; + let terminalRequested = false; + let terminalEmitted = false; + let closeEmitted = false; + let terminalStatus = null; + let terminalSignal = null; + const streams = [child.stdin, child.stdout, child.stderr].filter(Boolean); + const emitCloseWhenDrained = () => { + if (!terminalEmitted || closeEmitted || streams.some((stream) => !stream.closed)) return; + closeEmitted = true; + child.emit("close", terminalStatus, terminalSignal); + }; + for (const stream of streams) stream.once("close", emitCloseWhenDrained); const close = (status, signal = null) => { - if (closed) return; - closed = true; + if (terminalRequested) return; + terminalRequested = true; + terminalStatus = status; + terminalSignal = signal; child.exitCode = status; child.signalCode = signal; - child.stdout.end(); - child.stderr.end(); - setImmediate(() => child.emit("close", status, signal)); + // Make the control side terminal synchronously with kill. A control end + // attempted after this point must observe the destroyed pipe, never race + // the deferred output drain and appear to succeed. + if (child.stdin && !child.stdin.destroyed) child.stdin.destroy(); + queueMicrotask(() => { + if (terminalEmitted) return; + terminalEmitted = true; + child.emit("exit", terminalStatus, terminalSignal); + // A real ChildProcess closes its control pipe on termination and emits + // `close` only after both output pipes have reached their terminals. + // Defer this work out of a possibly re-entrant stdout `data` handler so + // overflow, backpressure, end callbacks, errors, and EPIPE are ordered. + for (const output of [child.stdout, child.stderr]) { + output.resume(); + if (!output.destroyed && !output.writableEnded) output.end(); + } + emitCloseWhenDrained(); + }); }; child.kill = () => { + child.killed = true; close(null, "SIGKILL"); return true; }; const publish = () => { - if (closed) return; + if (terminalRequested) return; if (mode === "timeout") { const error = Object.assign(new Error("private-path-SENTINEL"), { code: "ETIMEDOUT" }); child.emit("error", error); return; } if (mode === "malformed") child.stdout.write("{\n"); - else if (mode === "oversized") child.stdout.write(`${"x".repeat(128 * 1024 + 1)}\n`); + else if (mode === "oversized") child.stdout.write("x".repeat(128 * 1024 + 1)); else if (mode === "extra-key") child.stdout.write('{"version":1,"entries":[],"extra":true}\n'); else if (mode === "duplicate") child.stdout.write('{"version":1,"version":1,"entries":[]}\n'); else if (mode === "entry-count") child.stdout.write('{"version":1,"entries":[]}\n'); @@ -156,17 +186,34 @@ function fakeAuthorityChild(args, options, mode) { child.stdout.write(`${JSON.stringify(document)}\n`); } else if (mode === "stderr") child.stderr.write("private-path-SENTINEL S-1-5-21-999 raw-error-SENTINEL"); else if (mode !== "nonzero") child.stdout.write(`${authorityDocument(args, options, mode, invocation)}\n`); - if (child.stdin && !closed && mode !== "nonzero") return; + if (child.stdin && !terminalRequested && mode !== "nonzero") return; close(mode === "nonzero" ? 70 : 0); }; - child.stdin?.once("data", (chunk) => { - if (Buffer.from(chunk).toString("ascii") !== "PROPR_REVALIDATE_V1\n") { - close(87); - return; - } - child.stdin = null; - publish(); - }); + if (child.stdin) { + const controlChunks = []; + let controlBytes = 0; + child.stdin.on("data", (chunk) => { + if (terminalRequested) return; + const bytes = Buffer.from(chunk); + controlBytes += bytes.byteLength; + if (controlBytes > Buffer.byteLength("PROPR_REVALIDATE_V1\n", "ascii")) { + close(87); + return; + } + controlChunks.push(bytes); + }); + child.stdin.once("finish", () => { + if (terminalRequested) return; + const control = Buffer.concat(controlChunks, controlBytes).toString("ascii"); + if (control !== "PROPR_REVALIDATE_V1\n") { + close(87); + return; + } + publish(); + if (!terminalRequested) close(0); + }); + child.stdin.once("error", () => close(87)); + } queueMicrotask(publish); return child; } diff --git a/test/windowsStandardUserConnectHarness.test.ts b/test/windowsStandardUserConnectHarness.test.ts index 5bd8541ad..be4e0fe03 100644 --- a/test/windowsStandardUserConnectHarness.test.ts +++ b/test/windowsStandardUserConnectHarness.test.ts @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; import { readFileSync, readdirSync } from 'node:fs'; import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; import { runInNewContext } from 'node:vm'; import { test } from 'node:test'; import { @@ -324,6 +326,124 @@ test('the ordinary-user Windows proof retains native security paths and bounds r assert.match(harness, /\{ name: "authority-untrusted-system-root", systemRootMode: "untrusted", nativeStage: "resolver:global-id" \}/); }); +test('the async two-round fake authority child contains exact-cap overflow promptly', () => { + const processFixture = pathToFileURL(resolve('test/fixtures/windowsConnectProcessMock.mjs')).href; + const authorityModule = pathToFileURL(resolve('packages/cli/src/connectWindowsAuthority.ts')).href; + const regression = ` + import assert from "node:assert/strict"; + import childProcess from "node:child_process"; + import { + runWindowsInspectionBrokerBatch, + WindowsNativeStageError, + writeWindowsInspectionRevalidationControl, + } from ${JSON.stringify(authorityModule)}; + + const tick = () => new Promise((resolve) => setImmediate(resolve)); + await tick(); + const baselineHandles = new Set(process._getActiveHandles()); + const baselineResources = process.getActiveResourcesInfo().reduce((counts, name) => { + counts.set(name, (counts.get(name) ?? 0) + 1); + return counts; + }, new Map()); + const child = childProcess.spawn("powershell.exe", [], { + shell: false, + windowsHide: true, + stdio: ["pipe", "pipe", "pipe"], + }); + const input = child.stdin; + assert.ok(input && child.stdout && child.stderr); + let publishedBytes = 0; + let exitEvents = 0; + let closeEvents = 0; + let stdoutEnds = 0; + let stderrEnds = 0; + let stdoutCloses = 0; + let stderrCloses = 0; + let stdinCloses = 0; + child.stdout.on("data", (chunk) => { publishedBytes += Buffer.byteLength(chunk); }); + child.stdout.on("end", () => { stdoutEnds += 1; }); + child.stderr.on("end", () => { stderrEnds += 1; }); + child.stdout.on("close", () => { stdoutCloses += 1; }); + child.stderr.on("close", () => { stderrCloses += 1; }); + input.on("close", () => { stdinCloses += 1; }); + child.on("exit", () => { exitEvents += 1; }); + child.on("close", () => { closeEvents += 1; }); + + const started = performance.now(); + await assert.rejects(runWindowsInspectionBrokerBatch({ + entryCount: 1, + startBroker: () => child, + deadlineMs: 1_000, + cleanupTimeoutMs: 250, + maxOutputBytes: 128 * 1024, + }), (error) => error instanceof WindowsNativeStageError && error.stage === "parent:utf8"); + assert.ok(performance.now() - started < 750, "overflow did not reject inside its short bound"); + assert.equal(publishedBytes, 128 * 1024 + 1); + assert.equal(child.exitCode, null); + assert.equal(child.signalCode, "SIGKILL"); + assert.equal(child.killed, true); + assert.equal(exitEvents, 1); + assert.equal(closeEvents, 1); + assert.equal(stdoutEnds, 1); + assert.equal(stderrEnds, 1); + assert.equal(stdoutCloses, 1); + assert.equal(stderrCloses, 1); + assert.equal(stdinCloses, 1); + for (const output of [child.stdout, child.stderr]) { + assert.equal(output.readableEnded, true); + assert.equal(output.destroyed, true); + assert.equal(output.closed, true); + } + assert.equal(input.destroyed, true); + assert.equal(input.closed, true); + assert.equal(input.writable, false); + await assert.rejects( + writeWindowsInspectionRevalidationControl(child), + (error) => error instanceof WindowsNativeStageError && error.stage === "broker:control", + ); + assert.equal(child.kill("SIGKILL"), true); + assert.equal(child.kill("SIGKILL"), true); + await tick(); + assert.equal(exitEvents, 1); + assert.equal(closeEvents, 1); + + const referencedHandles = process._getActiveHandles().filter((handle) => ( + !baselineHandles.has(handle) + && (typeof handle.hasRef !== "function" || handle.hasRef()) + )); + assert.deepEqual(referencedHandles.map((handle) => handle.constructor?.name ?? "unknown"), []); + const finalResources = process.getActiveResourcesInfo().reduce((counts, name) => { + counts.set(name, (counts.get(name) ?? 0) + 1); + return counts; + }, new Map()); + for (const [name, count] of finalResources) { + assert.ok(count <= (baselineResources.get(name) ?? 0), name + " remained referenced"); + } + `; + const started = performance.now(); + const result = spawnSync(process.execPath, [ + '--no-warnings', + '--import', 'tsx', + '--import', processFixture, + '--input-type=module', + '--eval', regression, + ], { + cwd: resolve('.'), + shell: false, + windowsHide: true, + encoding: 'utf8', + timeout: 3_000, + maxBuffer: 16 * 1024, + env: { ...process.env, PROPR_TEST_AUTHORITY_MODE: 'oversized' }, + }); + assert.equal(result.error, undefined, result.error?.message); + assert.equal(result.signal, null, result.stderr); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, ''); + assert.equal(result.stderr, ''); + assert.ok(performance.now() - started < 3_000); +}); + test('the ordinary-user Windows diagnostic has fixed allowlists and redacts all other values', () => { const definitions = diagnosticDefinitions(); assert.deepEqual([...definitions.scenarioAllowlist], [