From 9673922c1ec724aa5e196a44d445650dab050c44 Mon Sep 17 00:00:00 2001 From: corgab Date: Tue, 8 Sep 2026 18:22:38 +0000 Subject: [PATCH 1/2] refactor(bridge): drop the unused env-var channel from PythonBridge buildEnvironment() mapped region, bucket and device_arn to environment variables no script in bin/python ever read; the providers take the same values from the driver_config key of the JSON payload. The mapping, the $driverConfig parameter it existed for (on the PythonExecutor contract, the bridge and every driver call site) and its five tests are removed. The child process now inherits the parent environment untouched, which is what preserves boto3's credential chain; a test pins that behaviour. Closes #51 --- CLAUDE.md | 2 +- src/Bridge/PythonBridge.php | 41 ++--------- src/Contracts/PythonExecutor.php | 6 +- src/Drivers/AbstractQuantumDriver.php | 10 +-- tests/Unit/Bridge/PythonBridgeTest.php | 73 ++++--------------- .../Drivers/AbstractQuantumDriverTest.php | 21 ++---- tests/Unit/Drivers/AwsBraketDriverTest.php | 12 ++- .../Unit/Drivers/LocalSimulatorDriverTest.php | 7 +- 8 files changed, 46 insertions(+), 126 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bdc8121..98e0644 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,7 @@ Quantum (Facade) - **Tests use Pest PHP**, not raw PHPUnit classes. Use `it()` / `test()` with `expect()`. - **Python scripts** live in `bin/python/`, not `resources/`. Each script is self-contained (reads JSON stdin, writes JSON stdout). - **Exceptions** all extend `AetherException` with static factory methods (`::fromPythonError()`, `::forDriver()`, etc.) -- **PythonBridge** only passes non-null env vars to preserve boto3 credential chain (IAM Roles). +- **PythonBridge** adds no env vars of its own: the child inherits the parent environment untouched (boto3 credential chain, IAM Roles included, works as-is). Driver settings reach the scripts only through `driver_config` in the JSON payload. - **QPU safety:** Drivers with `synchronous_safe: false` throw on `->run()` to prevent HTTP timeouts. - **EntropyGenerator::integer()** uses rejection sampling on a 256-bit batch buffer — never modulo. diff --git a/src/Bridge/PythonBridge.php b/src/Bridge/PythonBridge.php index a7a0a0d..7833136 100644 --- a/src/Bridge/PythonBridge.php +++ b/src/Bridge/PythonBridge.php @@ -32,21 +32,23 @@ public function __construct( /** * Execute a Python script with the given payload. * + * The child process inherits the parent environment untouched: nothing is + * added or removed, so boto3's credential chain (IAM roles, profiles, + * AWS_* variables) behaves exactly as it does for the PHP process. Driver + * settings travel inside the JSON payload under `driver_config`, which is + * the only channel the bin/python scripts read. + * * @param array $payload - * @param array $driverConfig * @return array * * @throws PythonEnvironmentException * @throws QuantumExecutionException */ - public function execute(string $script, array $payload, array $driverConfig = []): array + public function execute(string $script, array $payload): array { $scriptPath = $this->scriptsPath.DIRECTORY_SEPARATOR.$script; - $process = new Process( - command: [$this->pythonPath, $scriptPath], - env: $this->buildEnvironment($driverConfig), - ); + $process = new Process([$this->pythonPath, $scriptPath]); $process->setInput(json_encode($payload, JSON_THROW_ON_ERROR)); $process->setTimeout($this->timeout); @@ -148,31 +150,4 @@ public function bitstringToBytes(string $bitstring): string return $bytes; } - - /** - * Build the environment variable array for the child process. - * - * Only non-null values are included to preserve boto3's credential chain. - * - * @param array $driverConfig - * @return array - */ - public function buildEnvironment(array $driverConfig): array - { - $map = [ - 'region' => 'AWS_DEFAULT_REGION', - 'bucket' => 'AETHER_S3_BUCKET', - 'device_arn' => 'AETHER_DEVICE_ARN', - ]; - - $env = []; - - foreach ($map as $configKey => $envKey) { - if (isset($driverConfig[$configKey])) { - $env[$envKey] = (string) $driverConfig[$configKey]; - } - } - - return $env; - } } diff --git a/src/Contracts/PythonExecutor.php b/src/Contracts/PythonExecutor.php index 22ff647..74c92ab 100644 --- a/src/Contracts/PythonExecutor.php +++ b/src/Contracts/PythonExecutor.php @@ -12,11 +12,13 @@ interface PythonExecutor /** * Execute a Python script with the given payload. * + * Driver settings are part of the payload (`driver_config`); implementations + * must not need a second channel for them. + * * @param array $payload - * @param array $driverConfig * @return array */ - public function execute(string $script, array $payload, array $driverConfig = []): array; + public function execute(string $script, array $payload): array; /** * Convert a binary digit string into raw bytes. diff --git a/src/Drivers/AbstractQuantumDriver.php b/src/Drivers/AbstractQuantumDriver.php index af06efa..b0c3dc9 100644 --- a/src/Drivers/AbstractQuantumDriver.php +++ b/src/Drivers/AbstractQuantumDriver.php @@ -187,7 +187,7 @@ public function executeBatch(array $circuits): BatchResult 'circuits' => array_map(static fn (CircuitBuilder $c): array => $c->toArray(), $circuits), ]); - $response = $this->bridge->execute('batch.py', $payload, $this->config); + $response = $this->bridge->execute('batch.py', $payload); if (! array_key_exists('results', $response) || ! is_array($response['results'])) { throw QuantumExecutionException::malformedResponse( @@ -273,7 +273,7 @@ protected function runCircuit(CircuitBuilder $circuit): CircuitResult */ private function runDefinition(array $definition): CircuitResult { - $response = $this->bridge->execute('circuit.py', $this->payload($definition), $this->config); + $response = $this->bridge->execute('circuit.py', $this->payload($definition)); if (! array_key_exists('counts', $response) || ! is_array($response['counts'])) { throw QuantumExecutionException::malformedResponse( @@ -303,7 +303,7 @@ protected function submitTask(CircuitBuilder $circuit): string $this->assertConfigured(); $this->validateCircuits([$circuit]); - $response = $this->bridge->execute('submit.py', $this->payload($circuit->toArray()), $this->config); + $response = $this->bridge->execute('submit.py', $this->payload($circuit->toArray())); $taskArn = $response['task_arn'] ?? null; @@ -330,7 +330,7 @@ protected function pollTask(string $taskArn): TaskSnapshot { $this->assertConfigured(); - $response = $this->bridge->execute('check.py', $this->payload(['task_arn' => $taskArn]), $this->config); + $response = $this->bridge->execute('check.py', $this->payload(['task_arn' => $taskArn])); $status = $response['status'] ?? null; @@ -364,7 +364,7 @@ public function generateEntropy(int $bits): string 'shots' => $shots, ]); - $response = $this->bridge->execute('entropy.py', $payload, $this->config); + $response = $this->bridge->execute('entropy.py', $payload); if (! array_key_exists('bits', $response) || ! is_string($response['bits'])) { throw QuantumExecutionException::malformedResponse( diff --git a/tests/Unit/Bridge/PythonBridgeTest.php b/tests/Unit/Bridge/PythonBridgeTest.php index bcdb1b4..9307f76 100644 --- a/tests/Unit/Bridge/PythonBridgeTest.php +++ b/tests/Unit/Bridge/PythonBridgeTest.php @@ -188,71 +188,24 @@ function fakePython(string $shBody): string }); // ------------------------------------------------------------------------- -// buildEnvironment() +// execute() — environment // ------------------------------------------------------------------------- -it('includes only non-null values in environment', function () { - $bridge = new PythonBridge('python3'); - - $env = $bridge->buildEnvironment([ - 'region' => 'us-east-1', - 'bucket' => null, - 'device_arn' => 'arn:aws:braket:::device/quantum-simulator/amazon/sv1', - ]); - - expect($env)->toHaveKey('AWS_DEFAULT_REGION'); - expect($env['AWS_DEFAULT_REGION'])->toBe('us-east-1'); - expect($env)->not->toHaveKey('AETHER_S3_BUCKET'); - expect($env)->toHaveKey('AETHER_DEVICE_ARN'); - expect($env['AETHER_DEVICE_ARN'])->toBe('arn:aws:braket:::device/quantum-simulator/amazon/sv1'); -}); - -it('does not include AWS credentials in environment', function () { - $bridge = new PythonBridge('python3'); - - $env = $bridge->buildEnvironment([ - 'region' => 'eu-west-1', - 'AWS_ACCESS_KEY_ID' => 'AKIAIOSFODNN7EXAMPLE', - 'AWS_SECRET_ACCESS_KEY' => 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', - ]); - - expect($env)->not->toHaveKey('AWS_ACCESS_KEY_ID'); - expect($env)->not->toHaveKey('AWS_SECRET_ACCESS_KEY'); -}); - -it('returns empty array when config is empty', function () { - $bridge = new PythonBridge('python3'); +it('lets the child process inherit the parent environment untouched', function () { + // boto3 resolves credentials from the environment (AWS_PROFILE, AWS_*, + // container/IAM metadata hints), so the bridge must forward the parent + // environment as-is rather than curating its own variable set. + $_ENV['AETHER_TEST_MARKER'] = 'inherited'; - $env = $bridge->buildEnvironment([]); - - expect($env)->toBe([]); -}); - -it('maps all supported keys in environment', function () { - $bridge = new PythonBridge('python3'); - - $env = $bridge->buildEnvironment([ - 'region' => 'ap-southeast-2', - 'bucket' => 'my-braket-bucket', - 'device_arn' => 'arn:aws:braket:::device/qpu/ionq/ionQdevice', - ]); - - expect($env)->toBe([ - 'AWS_DEFAULT_REGION' => 'ap-southeast-2', - 'AETHER_S3_BUCKET' => 'my-braket-bucket', - 'AETHER_DEVICE_ARN' => 'arn:aws:braket:::device/qpu/ionq/ionQdevice', - ]); -}); - -it('omits unset keys from environment', function () { - $bridge = new PythonBridge('python3'); + try { + $python = fakePython('printf \'{"marker":"%s"}\' "$AETHER_TEST_MARKER"'); - // Only region provided — bucket and device_arn not present at all. - $env = $bridge->buildEnvironment(['region' => 'us-west-2']); + $result = (new PythonBridge($python))->execute('circuit.py', ['qubits' => 1]); + } finally { + unset($_ENV['AETHER_TEST_MARKER']); + } - expect($env)->toBe(['AWS_DEFAULT_REGION' => 'us-west-2']); - expect($env)->not->toHaveKey('AETHER_S3_BUCKET'); - expect($env)->not->toHaveKey('AETHER_DEVICE_ARN'); + expect($result)->toBe(['marker' => 'inherited']); }); // ------------------------------------------------------------------------- diff --git a/tests/Unit/Drivers/AbstractQuantumDriverTest.php b/tests/Unit/Drivers/AbstractQuantumDriverTest.php index 1c79b88..2007478 100644 --- a/tests/Unit/Drivers/AbstractQuantumDriverTest.php +++ b/tests/Unit/Drivers/AbstractQuantumDriverTest.php @@ -42,8 +42,7 @@ protected function driverName(): string ->method('execute') ->with( 'circuit.py', - $this->callback(fn (array $p) => $p['driver'] === 'test' && $p['driver_config'] === ['key' => 'value']), - ['key' => 'value'] + $this->callback(fn (array $p) => $p['driver'] === 'test' && $p['driver_config'] === ['key' => 'value']) ) ->willReturn(['counts' => ['0' => 500, '1' => 500]]); @@ -74,8 +73,7 @@ protected function driverName(): string && $p['qubits'] === 16 && $p['shots'] === 1 && ! array_key_exists('bits', $p); - }), - ['key' => 'value', 'entropy_qubits' => 16] + }) ) ->willReturn(['bits' => str_repeat('1', 16)]); @@ -96,8 +94,7 @@ protected function driverName(): string ->method('execute') ->with( 'entropy.py', - $this->callback(fn (array $p) => $p['qubits'] === 16 && $p['shots'] === 16), - ['key' => 'value', 'entropy_qubits' => 16] + $this->callback(fn (array $p) => $p['qubits'] === 16 && $p['shots'] === 16) ) ->willReturn(['bits' => str_repeat('10', 128)]); @@ -110,8 +107,7 @@ protected function driverName(): string ->method('execute') ->with( 'entropy.py', - $this->callback(fn (array $p) => $p['qubits'] === 16 && $p['shots'] === 1), - ['key' => 'value'] + $this->callback(fn (array $p) => $p['qubits'] === 16 && $p['shots'] === 1) ) ->willReturn(['bits' => str_repeat('1', 16)]); @@ -250,8 +246,7 @@ protected function driverName(): string ->method('execute') ->with( 'entropy.py', - $this->callback(fn (array $p) => $p['qubits'] === 16 && $p['shots'] === 1), - ['entropy_qubits' => 0] + $this->callback(fn (array $p) => $p['qubits'] === 16 && $p['shots'] === 1) ) ->willReturn(['bits' => str_repeat('1', 16)]); @@ -272,8 +267,7 @@ protected function driverName(): string ->method('execute') ->with( 'entropy.py', - $this->callback(fn (array $p) => $p['qubits'] === 16), - ['entropy_qubits' => -4] + $this->callback(fn (array $p) => $p['qubits'] === 16) ) ->willReturn(['bits' => str_repeat('1', 16)]); @@ -294,8 +288,7 @@ protected function driverName(): string && $p['circuits'] === [ ['qubits' => 1, 'gates' => [], 'shots' => 1000], ['qubits' => 2, 'gates' => [], 'shots' => 10], - ]), - ['key' => 'value'] + ]) ) ->willReturn(['results' => [ ['counts' => ['0' => 500, '1' => 500]], diff --git a/tests/Unit/Drivers/AwsBraketDriverTest.php b/tests/Unit/Drivers/AwsBraketDriverTest.php index 548904b..8403cb7 100644 --- a/tests/Unit/Drivers/AwsBraketDriverTest.php +++ b/tests/Unit/Drivers/AwsBraketDriverTest.php @@ -79,7 +79,7 @@ $this->bridge->expects($this->once()) ->method('execute') - ->with('circuit.py', $expectedPayload, $this->config) + ->with('circuit.py', $expectedPayload) ->willReturn(['counts' => ['00' => 512, '11' => 512]]); $result = $driver->executeCircuit($circuit); @@ -132,8 +132,7 @@ ->method('execute') ->with( 'entropy.py', - ['qubits' => 16, 'shots' => 1, 'driver' => 'aws', 'driver_config' => $this->config], - $this->config + ['qubits' => 16, 'shots' => 1, 'driver' => 'aws', 'driver_config' => $this->config] ) ->willReturn(['bits' => '1011001110100101']); @@ -265,7 +264,7 @@ $this->bridge->expects($this->once()) ->method('execute') - ->with('submit.py', $expectedPayload, $this->config) + ->with('submit.py', $expectedPayload) ->willReturn(['task_arn' => 'arn:aws:braket:us-east-1:123456789012:quantum-task/abc']); $taskArn = $driver->submitCircuit($circuit); @@ -282,7 +281,7 @@ $this->bridge->expects($this->once()) ->method('execute') - ->with('submit.py', $this->anything(), $config) + ->with('submit.py', $this->anything()) ->willReturn(['task_arn' => 'arn:aws:braket:us-east-1:123456789012:quantum-task/async']); $taskArn = $driver->submitCircuit($circuit); @@ -356,8 +355,7 @@ ->method('execute') ->with( 'check.py', - ['task_arn' => 'arn:aws:braket:us-east-1:123456789012:quantum-task/abc', 'driver' => 'aws', 'driver_config' => $this->config], - $this->config + ['task_arn' => 'arn:aws:braket:us-east-1:123456789012:quantum-task/abc', 'driver' => 'aws', 'driver_config' => $this->config] ) ->willReturn(['status' => 'RUNNING']); diff --git a/tests/Unit/Drivers/LocalSimulatorDriverTest.php b/tests/Unit/Drivers/LocalSimulatorDriverTest.php index 8dc0b81..c293dfc 100644 --- a/tests/Unit/Drivers/LocalSimulatorDriverTest.php +++ b/tests/Unit/Drivers/LocalSimulatorDriverTest.php @@ -85,7 +85,7 @@ $this->bridge->expects($this->once()) ->method('execute') - ->with('circuit.py', $expectedPayload, $this->config) + ->with('circuit.py', $expectedPayload) ->willReturn(['counts' => ['00' => 512, '11' => 512]]); $result = $this->driver->executeCircuit($circuit); @@ -114,8 +114,7 @@ ->method('execute') ->with( 'entropy.py', - ['qubits' => 16, 'shots' => 1, 'driver' => 'local', 'driver_config' => $this->config], - $this->config + ['qubits' => 16, 'shots' => 1, 'driver' => 'local', 'driver_config' => $this->config] ) ->willReturn(['bits' => '1011001110100101']); @@ -187,7 +186,7 @@ $this->bridge->expects($this->once()) ->method('execute') - ->with('circuit.py', $this->anything(), $this->config) + ->with('circuit.py', $this->anything()) ->willReturn(['counts' => ['0' => 100]]); $this->driver->submitCircuit($circuit); From ce08bd8bc5aa9dbb9f7a736326b274de01abfe7d Mon Sep 17 00:00:00 2001 From: corgab Date: Tue, 8 Sep 2026 18:26:47 +0000 Subject: [PATCH 2/2] docs(bridge): state the environment guarantee precisely The child receives Symfony Process's default inherited environment; under non-CLI SAPIs Process drops request-context variables, so 'untouched' was too strong. The inheritance test now says what it pins: the observable guarantee from the docblock, not how the Process is constructed. --- CLAUDE.md | 2 +- src/Bridge/PythonBridge.php | 10 +++++----- tests/Unit/Bridge/PythonBridgeTest.php | 9 +++++---- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 98e0644..ec2b4a0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,7 @@ Quantum (Facade) - **Tests use Pest PHP**, not raw PHPUnit classes. Use `it()` / `test()` with `expect()`. - **Python scripts** live in `bin/python/`, not `resources/`. Each script is self-contained (reads JSON stdin, writes JSON stdout). - **Exceptions** all extend `AetherException` with static factory methods (`::fromPythonError()`, `::forDriver()`, etc.) -- **PythonBridge** adds no env vars of its own: the child inherits the parent environment untouched (boto3 credential chain, IAM Roles included, works as-is). Driver settings reach the scripts only through `driver_config` in the JSON payload. +- **PythonBridge** adds no env vars of its own: the child gets Symfony Process's default inherited environment (boto3 credential chain, IAM Roles included, works as-is). Driver settings reach the scripts only through `driver_config` in the JSON payload. - **QPU safety:** Drivers with `synchronous_safe: false` throw on `->run()` to prevent HTTP timeouts. - **EntropyGenerator::integer()** uses rejection sampling on a 256-bit batch buffer — never modulo. diff --git a/src/Bridge/PythonBridge.php b/src/Bridge/PythonBridge.php index 7833136..edab3b9 100644 --- a/src/Bridge/PythonBridge.php +++ b/src/Bridge/PythonBridge.php @@ -32,11 +32,11 @@ public function __construct( /** * Execute a Python script with the given payload. * - * The child process inherits the parent environment untouched: nothing is - * added or removed, so boto3's credential chain (IAM roles, profiles, - * AWS_* variables) behaves exactly as it does for the PHP process. Driver - * settings travel inside the JSON payload under `driver_config`, which is - * the only channel the bin/python scripts read. + * The bridge adds no environment variables of its own: the child receives + * Symfony Process's default inherited environment, so boto3's credential + * chain (IAM roles, profiles, AWS_* variables) behaves exactly as it does + * for the PHP process. Driver settings travel inside the JSON payload under + * `driver_config`, which is the only channel the bin/python scripts read. * * @param array $payload * @return array diff --git a/tests/Unit/Bridge/PythonBridgeTest.php b/tests/Unit/Bridge/PythonBridgeTest.php index 9307f76..80c7a6f 100644 --- a/tests/Unit/Bridge/PythonBridgeTest.php +++ b/tests/Unit/Bridge/PythonBridgeTest.php @@ -191,10 +191,11 @@ function fakePython(string $shBody): string // execute() — environment // ------------------------------------------------------------------------- -it('lets the child process inherit the parent environment untouched', function () { - // boto3 resolves credentials from the environment (AWS_PROFILE, AWS_*, - // container/IAM metadata hints), so the bridge must forward the parent - // environment as-is rather than curating its own variable set. +it('lets the child process see the parent environment', function () { + // Pins the guarantee execute()'s docblock makes: a variable set in the PHP + // process (AWS_PROFILE, AWS_*, IAM metadata hints boto3 relies on) reaches + // the interpreter. It asserts the observable behaviour, not how the + // Process is built. $_ENV['AETHER_TEST_MARKER'] = 'inherited'; try {