From 7da9a86947d177adc891566dbd38b731fa30baaa Mon Sep 17 00:00:00 2001 From: Miya Date: Tue, 15 Sep 2026 12:58:02 +0200 Subject: [PATCH 1/2] fix: allow bounded cold startup for Windows credential ACL probes Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- CHANGELOG.md | 4 + .../cloud/src/credential-directory-windows.ts | 4 +- .../cases/windows-acl-cold-start/case.json | 21 ++++ .../cases/windows-acl-cold-start/run.mjs | 111 ++++++++++++++++++ 4 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 tests/relayflows/cases/windows-acl-cold-start/case.json create mode 100644 tests/relayflows/cases/windows-acl-cold-start/run.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 838ba6ac1..dde2298ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ambiguous placements require `--node`. - `fleet spawn --sandbox` keeps temporary routing credentials out of project files. +### Fixed + +- Windows credential storage allows bounded cold PowerShell startup time while retaining strict ACL validation. + ## [12.1.1] - 2026-09-15 ### Changed diff --git a/packages/cloud/src/credential-directory-windows.ts b/packages/cloud/src/credential-directory-windows.ts index 9032af24b..b10e43250 100644 --- a/packages/cloud/src/credential-directory-windows.ts +++ b/packages/cloud/src/credential-directory-windows.ts @@ -1,7 +1,9 @@ import { execFileSync } from 'node:child_process'; import path from 'node:path'; -const WINDOWS_ACL_TIMEOUT_MS = 5_000; +// A cold Windows PowerShell process can exceed five seconds before ACL evaluation. +// Keep a finite deadline and fail closed if the complete native probe cannot finish. +const WINDOWS_ACL_TIMEOUT_MS = 15_000; /** * This script is deliberately static. The directory is supplied as JSON on diff --git a/tests/relayflows/cases/windows-acl-cold-start/case.json b/tests/relayflows/cases/windows-acl-cold-start/case.json new file mode 100644 index 000000000..e480fb23a --- /dev/null +++ b/tests/relayflows/cases/windows-acl-cold-start/case.json @@ -0,0 +1,21 @@ +{ + "version": 1, + "id": "windows-acl-cold-start", + "kind": "bugfix", + "title": "Allow bounded cold startup before native Windows credential ACL validation", + "runner": { + "command": ["node", "tests/relayflows/cases/windows-acl-cold-start/run.mjs"] + }, + "requirements": [], + "timeoutSeconds": 90, + "expected": { + "base": { + "outcome": "bug", + "signature": "private_acl_cold_start_times_out" + }, + "head": { + "outcome": "fixed", + "signature": "private_acl_cold_start_validated" + } + } +} diff --git a/tests/relayflows/cases/windows-acl-cold-start/run.mjs b/tests/relayflows/cases/windows-acl-cold-start/run.mjs new file mode 100644 index 000000000..7910e06f1 --- /dev/null +++ b/tests/relayflows/cases/windows-acl-cold-start/run.mjs @@ -0,0 +1,111 @@ +// Portable process-boundary latency proof. Actual Windows ACL semantics remain +// covered by the unchanged native Windows CI tests; this does not emulate ACLs. +import assert from 'node:assert/strict'; +import childProcess from 'node:child_process'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { stripTypeScriptTypes, syncBuiltinESMExports } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const CASE_ID = 'windows-acl-cold-start'; +const target = path.resolve(required('RELAY_PR_PROOF_TARGET_DIR')); +const harness = path.resolve(required('RELAY_PR_PROOF_HARNESS_DIR')); +const resultPath = required('RELAY_PR_PROOF_RESULT_PATH'); +const arm = required('RELAY_PR_PROOF_ARM'); +assert.ok(['base', 'head'].includes(arm)); +const relative = path.relative(harness, fileURLToPath(import.meta.url)); +assert.ok(relative && !relative.startsWith('..') && !path.isAbsolute(relative)); +const realExec = childProcess.execFileSync; +const actualSha = realExec('git', ['-C', target, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); +assert.equal(actualSha, required(arm === 'base' ? 'RELAY_PR_PROOF_BASE_SHA' : 'RELAY_PR_PROOF_HEAD_SHA')); +const source = await readFile( + path.join(target, 'packages/cloud/src/credential-directory-windows.ts'), + 'utf8' +); +const code = stripTypeScriptTypes(source, { mode: 'strip' }); +const originalPlatform = process.platform; +const originalSystemRoot = process.env.SystemRoot; +let scenario; +let observation; +try { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }); + process.env.SystemRoot = 'C:\\Windows'; + childProcess.execFileSync = (executable, args, options) => { + observation.calls++; + assert.equal(executable, 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'); + assert.deepEqual(args.slice(0, 4), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command']); + assert.equal(args.length, 5); + assert.equal(typeof args[4], 'string'); + assert.equal(typeof JSON.parse(options.input).directory, 'string'); + assert.ok(Number.isSafeInteger(options.timeout) && options.timeout > 0 && options.timeout <= 15_000); + // Use a real child and the application's unchanged spawn options. A delayed + // private result models cold native startup; no timer mocks or timeout bypass. + const response = + scenario === 'unsafe' ? { ok: false, reason: 'credential-parent-untrusted-allow' } : { ok: true }; + const delay = scenario === 'slow-private' ? 6500 : scenario === 'hung' ? 20_000 : 0; + const child = `process.stdin.resume(); process.stdin.on('end', () => setTimeout(() => process.stdout.write(${JSON.stringify(JSON.stringify(response))}), ${delay}));`; + try { + return realExec(process.execPath, ['-e', child], options); + } catch (error) { + observation.nativeTimeout = error?.code === 'ETIMEDOUT'; + throw error; + } + }; + syncBuiltinESMExports(); + const { assertWindowsCredentialDirectory } = await import( + `data:text/javascript;base64,${Buffer.from(code).toString('base64')}` + ); + function probe(kind) { + scenario = kind; + observation = { calls: 0, nativeTimeout: false, accepted: false }; + const started = Date.now(); + try { + assertWindowsCredentialDirectory(path.join(target, 'private-probe')); + observation.accepted = true; + } catch (error) { + assert.match(String(error), /Windows Relaycast credential storage requires a private directory/); + } + observation.elapsedMs = Date.now() - started; + assert.equal(observation.calls, 1, 'the native boundary must be evaluated exactly once'); + return observation; + } + const slow = probe('slow-private'); + const unsafe = probe('unsafe'); + assert.equal(unsafe.accepted, false); + assert.equal(unsafe.nativeTimeout, false, 'unsafe ACL result must be evaluated, not time out'); + const hung = probe('hung'); + assert.equal(hung.accepted, false); + assert.equal( + hung.nativeTimeout, + true, + 'an unresponsive native probe must fail closed at a finite deadline' + ); + console.log(JSON.stringify({ caseId: CASE_ID, arm, slow, unsafe, hung })); + const outcome = + slow.accepted && !slow.nativeTimeout ? 'fixed' : !slow.accepted && slow.nativeTimeout ? 'bug' : null; + assert.equal(outcome, arm === 'base' ? 'bug' : 'fixed'); + await mkdir(path.dirname(resultPath), { recursive: true }); + await writeFile( + resultPath, + JSON.stringify({ + version: 1, + caseId: CASE_ID, + arm, + outcome, + signature: + outcome === 'fixed' ? 'private_acl_cold_start_validated' : 'private_acl_cold_start_times_out', + details: `Actual runtime helper with real delayed child boundary: private=${slow.accepted}, unsafe denied, hung timed out; actual Windows ACL rules separately verified by native CI.`, + }) + '\n' + ); +} finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }); + if (originalSystemRoot === undefined) delete process.env.SystemRoot; + else process.env.SystemRoot = originalSystemRoot; + childProcess.execFileSync = realExec; + syncBuiltinESMExports(); +} +function required(key) { + const value = process.env[key]; + assert.ok(value, `Missing ${key}`); + return value; +} From a8701996e97b31727a8175397d8f76e6be5d9e94 Mon Sep 17 00:00:00 2001 From: Miya Date: Tue, 15 Sep 2026 13:23:33 +0200 Subject: [PATCH 2/2] fix: reserve credential lock budget until after ACL validation Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- packages/cloud/src/workspace-store.test.ts | 69 ++++++++++++++++++- packages/cloud/src/workspace-store.ts | 3 +- .../cases/windows-acl-cold-start/run.mjs | 50 ++++++++++++-- 3 files changed, 113 insertions(+), 9 deletions(-) diff --git a/packages/cloud/src/workspace-store.test.ts b/packages/cloud/src/workspace-store.test.ts index 66d41b700..50c594383 100644 --- a/packages/cloud/src/workspace-store.test.ts +++ b/packages/cloud/src/workspace-store.test.ts @@ -4,7 +4,9 @@ import os from 'node:os'; import path from 'node:path'; import ts from 'typescript'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import * as windowsCredentialDirectory from './credential-directory-windows.js'; import { type RelaycastCredential, @@ -144,6 +146,71 @@ describe('workspace store', () => { } ); + it('starts the credential lock budget after slow Windows ACL validation', () => { + const platform = process.platform; + let now = Date.now(); + const clock = vi.spyOn(Date, 'now').mockImplementation(() => now); + const acl = vi + .spyOn(windowsCredentialDirectory, 'assertWindowsCredentialDirectory') + .mockImplementation(() => { + now += 11_000; + }); + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }); + try { + writeRelaycastCredential('slow-private-acl', { + workspaceId: 'rw_slow_private', + route: 'canonical', + baseUrl: 'https://relay.example', + apiKey: 'test-credential', + }); + expect(acl).toHaveBeenCalledOnce(); + expect(readRelaycastCredential('slow-private-acl')?.workspaceId).toBe('rw_slow_private'); + expect(fs.existsSync(`${relaycastCredentialStorePath()}.lock`)).toBe(false); + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: platform }); + acl.mockRestore(); + clock.mockRestore(); + } + }); + + it('still bounds lock contention after slow Windows ACL validation', () => { + const platform = process.platform; + const lock = `${relaycastCredentialStorePath()}.lock`; + const ownerPath = path.join(lock, 'live-owner'); + fs.mkdirSync(lock, { mode: 0o700 }); + const owner = JSON.stringify({ version: 1, pid: process.pid, token: 'live-owner' }); + fs.writeFileSync(ownerPath, owner, { mode: 0o600 }); + let now = Date.now(); + const clock = vi.spyOn(Date, 'now').mockImplementation(() => { + const result = now; + now += 1_000; + return result; + }); + const acl = vi + .spyOn(windowsCredentialDirectory, 'assertWindowsCredentialDirectory') + .mockImplementation(() => { + now += 11_000; + }); + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }); + try { + expect(() => + writeRelaycastCredential('contended-after-acl', { + workspaceId: 'rw_contended', + route: 'canonical', + baseUrl: 'https://relay.example', + apiKey: 'test-credential', + }) + ).toThrow('Timed out waiting for the Relaycast credential store lock.'); + expect(acl).toHaveBeenCalledOnce(); + expect(fs.readFileSync(ownerPath, 'utf8')).toBe(owner); + expect(fs.existsSync(relaycastCredentialStorePath())).toBe(false); + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: platform }); + acl.mockRestore(); + clock.mockRestore(); + } + }); + it('stores route credentials outside the project with a scoped reference', () => { const ref = relaycastCredentialRef( '/checkout/.agentworkforce/relay', diff --git a/packages/cloud/src/workspace-store.ts b/packages/cloud/src/workspace-store.ts index f455b7713..ec1dbaac3 100644 --- a/packages/cloud/src/workspace-store.ts +++ b/packages/cloud/src/workspace-store.ts @@ -169,7 +169,6 @@ function withRelaycastCredentialLock(file: string, fn: () => T): T { const lock = `${file}.lock`; const ownerToken = randomUUID(); const ownerPath = path.join(lock, ownerToken); - const startedAt = Date.now(); fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); // The parent is the credential boundary: other users must not be able to // replace lock entries between inspection and cleanup. @@ -185,6 +184,8 @@ function withRelaycastCredentialLock(file: string, fn: () => T): T { ); } assertCredentialAncestors(directory); + // ACL validation has its own deadline; reserve this budget for lock contention. + const startedAt = Date.now(); while (true) { if (Date.now() - startedAt >= RELAYCAST_CREDENTIAL_LOCK_TIMEOUT_MS) { throw new Error('Timed out waiting for the Relaycast credential store lock.'); diff --git a/tests/relayflows/cases/windows-acl-cold-start/run.mjs b/tests/relayflows/cases/windows-acl-cold-start/run.mjs index 7910e06f1..92a0e824c 100644 --- a/tests/relayflows/cases/windows-acl-cold-start/run.mjs +++ b/tests/relayflows/cases/windows-acl-cold-start/run.mjs @@ -2,7 +2,8 @@ // covered by the unchanged native Windows CI tests; this does not emulate ACLs. import assert from 'node:assert/strict'; import childProcess from 'node:child_process'; -import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; import { stripTypeScriptTypes, syncBuiltinESMExports } from 'node:module'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -23,6 +24,7 @@ const source = await readFile( 'utf8' ); const code = stripTypeScriptTypes(source, { mode: 'strip' }); +const fixture = await mkdtemp(path.join(os.tmpdir(), 'relay-acl-case-')); const originalPlatform = process.platform; const originalSystemRoot = process.env.SystemRoot; let scenario; @@ -42,7 +44,7 @@ try { // private result models cold native startup; no timer mocks or timeout bypass. const response = scenario === 'unsafe' ? { ok: false, reason: 'credential-parent-untrusted-allow' } : { ok: true }; - const delay = scenario === 'slow-private' ? 6500 : scenario === 'hung' ? 20_000 : 0; + const delay = scenario === 'slow-private' ? 11_000 : scenario === 'hung' ? 20_000 : 0; const child = `process.stdin.resume(); process.stdin.on('end', () => setTimeout(() => process.stdout.write(${JSON.stringify(JSON.stringify(response))}), ${delay}));`; try { return realExec(process.execPath, ['-e', child], options); @@ -52,9 +54,8 @@ try { } }; syncBuiltinESMExports(); - const { assertWindowsCredentialDirectory } = await import( - `data:text/javascript;base64,${Buffer.from(code).toString('base64')}` - ); + const helperUrl = `data:text/javascript;base64,${Buffer.from(code).toString('base64')}`; + const { assertWindowsCredentialDirectory } = await import(helperUrl); function probe(kind) { scenario = kind; observation = { calls: 0, nativeTimeout: false, accepted: false }; @@ -80,7 +81,41 @@ try { true, 'an unresponsive native probe must fail closed at a finite deadline' ); - console.log(JSON.stringify({ caseId: CASE_ID, arm, slow, unsafe, hung })); + // Exercise the complete credential write too: a valid slow ACL result must + // not consume the separate lock-contention budget before the first attempt. + const storeSource = await readFile(path.join(target, 'packages/cloud/src/workspace-store.ts'), 'utf8'); + const importSpecifier = "'./credential-directory-windows.js'"; + assert.equal(storeSource.split(importSpecifier).length, 2); + const storeCode = stripTypeScriptTypes(storeSource.replace(importSpecifier, JSON.stringify(helperUrl)), { + mode: 'strip', + }); + const store = await import(`data:text/javascript;base64,${Buffer.from(storeCode).toString('base64')}`); + scenario = 'slow-private'; + observation = { calls: 0, nativeTimeout: false, accepted: false }; + try { + store.writeRelaycastCredential( + 'latency-fixture', + { + workspaceId: 'rw_latency_fixture', + route: 'canonical', + baseUrl: 'https://relay.example', + apiKey: 'test-credential', + }, + { AGENT_RELAY_HOME: fixture } + ); + observation.accepted = true; + } catch (error) { + assert.match(String(error), /Windows Relaycast credential storage requires a private directory/); + } + const write = observation; + assert.equal(write.calls, 1); + assert.equal(write.accepted, arm === 'head'); + assert.equal(write.nativeTimeout, arm === 'base'); + assert.equal( + store.readRelaycastCredential('latency-fixture', { AGENT_RELAY_HOME: fixture })?.workspaceId, + arm === 'head' ? 'rw_latency_fixture' : undefined + ); + console.log(JSON.stringify({ caseId: CASE_ID, arm, slow, unsafe, hung, write })); const outcome = slow.accepted && !slow.nativeTimeout ? 'fixed' : !slow.accepted && slow.nativeTimeout ? 'bug' : null; assert.equal(outcome, arm === 'base' ? 'bug' : 'fixed'); @@ -94,7 +129,7 @@ try { outcome, signature: outcome === 'fixed' ? 'private_acl_cold_start_validated' : 'private_acl_cold_start_times_out', - details: `Actual runtime helper with real delayed child boundary: private=${slow.accepted}, unsafe denied, hung timed out; actual Windows ACL rules separately verified by native CI.`, + details: `Actual runtime helper with real delayed child boundary: private=${slow.accepted}, unsafe denied, hung timed out; full credential write=${write.accepted}; actual Windows ACL rules separately verified by native CI.`, }) + '\n' ); } finally { @@ -103,6 +138,7 @@ try { else process.env.SystemRoot = originalSystemRoot; childProcess.execFileSync = realExec; syncBuiltinESMExports(); + await rm(fixture, { recursive: true, force: true }); } function required(key) { const value = process.env[key];