Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions .github/workflows/contract-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,26 @@ jobs:
- name: Checkout Canio package
uses: actions/checkout@v5

- name: Clone Canio Cloud
- name: Check cross-repo token
id: cross-repo-token
run: |
if [[ -z "${CANIO_CROSS_REPO_TOKEN:-}" ]]; then
echo "Missing CANIO_CROSS_REPO_TOKEN secret." >&2
exit 1
echo "available=false" >> "$GITHUB_OUTPUT"
echo "::notice::Skipping contract smoke because CANIO_CROSS_REPO_TOKEN is not available for this run."
else
echo "available=true" >> "$GITHUB_OUTPUT"
fi

- name: Clone Canio Cloud
if: steps.cross-repo-token.outputs.available == 'true'
run: |
rm -rf "$CANIO_CLOUD_DIR"
git clone --depth 1 \
"https://x-access-token:${CANIO_CROSS_REPO_TOKEN}@github.com/oxhq/canio-cloud.git" \
"$CANIO_CLOUD_DIR"

- name: Setup PHP
if: steps.cross-repo-token.outputs.available == 'true'
uses: shivammathur/setup-php@v2
with:
php-version: "8.4"
Expand All @@ -44,25 +51,30 @@ jobs:
extensions: intl, zip

- name: Setup Node
if: steps.cross-repo-token.outputs.available == 'true'
uses: actions/setup-node@v6
with:
node-version: "22"

- name: Install Canio Cloud dependencies
if: steps.cross-repo-token.outputs.available == 'true'
working-directory: ${{ env.CANIO_CLOUD_DIR }}
run: composer install --no-interaction --prefer-dist

- name: Setup Go
if: steps.cross-repo-token.outputs.available == 'true'
uses: actions/setup-go@v6
with:
go-version-file: ${{ env.CANIO_PACKAGE_DIR }}/runtime/stagehand/go.mod
cache-dependency-path: ${{ env.CANIO_PACKAGE_DIR }}/runtime/stagehand/go.sum

- name: Setup Chrome
if: steps.cross-repo-token.outputs.available == 'true'
id: setup-chrome
uses: browser-actions/setup-chrome@v2

- name: Run cross-repo contract smoke
if: steps.cross-repo-token.outputs.available == 'true'
env:
CANIO_CHROMIUM_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
CANIO_CHROMIUM_NO_SANDBOX: "true"
Expand Down
28 changes: 28 additions & 0 deletions docs/releases/v1.0.2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# v1.0.2

This release fixes a runtime/package drift that could make local Stagehand startup fail after installing the Laravel package.

The Laravel package had learned to launch Stagehand with newer navigation and request-limit flags, but the default installer could still fetch the older `v1.0.1` Stagehand binary. That binary did not know those flags, so it exited before serving. `v1.0.2` aligns the package and runtime surfaces and adds a compatibility guard so this class of mismatch fails early with a clear installer error instead of reaching end users as a broken local runtime.

## Fixed

- The Laravel installer now validates downloaded Stagehand binaries before accepting them.
- Stale Stagehand binaries that do not expose required serve flags are rejected and removed.
- The default Stagehand runtime release now points at `v1.0.2`, matching the package command surface.
- Windows binary resolution now handles absolute drive-letter paths and runnable `.exe`, `.cmd`, and `.bat` files correctly.
- Stagehand Chromium profiles are namespaced per runtime process, so stale browser children from a crashed or force-stopped runtime cannot lock the next process out of local PDF rendering.
- Browser-pool startup no longer returns a fresh lease with a cancelled browser context after acquire-timeout cleanup races.

## For Laravel Users

If you use embedded local rendering, run the installer again after upgrading:

```bash
php artisan canio:runtime:install
```

The installer will fetch the `v1.0.2` Stagehand binary for your platform and verify that it supports the flags required by this package.

## Release Assets

This release publishes Stagehand binaries for Linux, macOS, and Windows on amd64 and arm64, plus `checksums.txt`.
6 changes: 4 additions & 2 deletions packages/laravel/src/CanioServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
use Illuminate\Support\ServiceProvider;
use Oxhq\Canio\Bridge\CloudStagehandClient;
use Oxhq\Canio\Bridge\HttpStagehandClient;
use Oxhq\Canio\Contracts\CanioCloudSyncer;
use Oxhq\Canio\Console\CanioDoctorCommand;
use Oxhq\Canio\Console\CanioInstallCommand;
use Oxhq\Canio\Console\CanioRuntimeArtifactCommand;
Expand All @@ -24,14 +23,16 @@
use Oxhq\Canio\Console\CanioRuntimeRetryCommand;
use Oxhq\Canio\Console\CanioRuntimeStatusCommand;
use Oxhq\Canio\Console\CanioServeCommand;
use Oxhq\Canio\Contracts\CanioCloudSyncer;
use Oxhq\Canio\Contracts\StagehandClient;
use Oxhq\Canio\Contracts\StagehandRuntimeBootstrapper;
use Oxhq\Canio\Support\CanioCloudRequestor;
use Oxhq\Canio\Support\CanioCloudSyncFailureRecorder;
use Oxhq\Canio\Support\EmbeddedStagehandRuntimeBootstrapper;
use Oxhq\Canio\Support\HttpCanioCloudSyncer;
use Oxhq\Canio\Support\NullStagehandRuntimeBootstrapper;
use Oxhq\Canio\Support\NullCanioCloudSyncer;
use Oxhq\Canio\Support\NullStagehandRuntimeBootstrapper;
use Oxhq\Canio\Support\StagehandBinaryCompatibility;
use Oxhq\Canio\Support\StagehandBinaryResolver;
use Oxhq\Canio\Support\StagehandHealthProbe;
use Oxhq\Canio\Support\StagehandProcessLauncher;
Expand All @@ -44,6 +45,7 @@ public function register(): void
{
$this->mergeConfigFrom(__DIR__.'/../config/canio.php', 'canio');

$this->app->singleton(StagehandBinaryCompatibility::class);
$this->app->singleton(StagehandBinaryResolver::class);
$this->app->singleton(StagehandReleaseInstaller::class);
$this->app->singleton(StagehandServeCommandBuilder::class);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public function handle(StagehandReleaseInstaller $installer): int
$os = $installer->resolveOperatingSystem($this->option('os'));
$arch = $installer->resolveArchitecture($this->option('arch'));

$this->line(sprintf('Downloading %s for %s/%s', $tag, $os, $arch));
$this->line(sprintf('Downloading %s for %s/%s...', $tag, $os, $arch));

$result = $installer->install(
config: $config,
Expand Down
2 changes: 1 addition & 1 deletion packages/laravel/src/Support/PackageVersion.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

final class PackageVersion
{
public const TAG = 'v1.0.1';
public const TAG = 'v1.0.2';

public static function label(): string
{
Expand Down
49 changes: 49 additions & 0 deletions packages/laravel/src/Support/StagehandBinaryCompatibility.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

namespace Oxhq\Canio\Support;

use RuntimeException;
use Symfony\Component\Process\Process;

final class StagehandBinaryCompatibility
{
/**
* @var list<string>
*/
private const REQUIRED_SERVE_FLAGS = [
'allow-private-targets',
'request-body-limit-bytes',
];

public function assertCompatible(string $binary): void
{
$process = new Process([$binary, 'serve', '--help']);
$process->setTimeout(10);
$process->run();

$this->assertCompatibleHelp(
$process->getOutput().PHP_EOL.$process->getErrorOutput(),
$binary,
);
}

public function assertCompatibleHelp(string $help, string $binary): void
{
$missing = array_values(array_filter(
self::REQUIRED_SERVE_FLAGS,
static fn (string $flag): bool => ! str_contains($help, '-'.$flag),
));

if ($missing === []) {
return;
}

throw new RuntimeException(sprintf(
'Stagehand binary %s is incompatible with this Canio package. Missing serve flags: %s.',
$binary,
implode(', ', array_map(static fn (string $flag): string => '--'.$flag, $missing)),
));
}
}
29 changes: 26 additions & 3 deletions packages/laravel/src/Support/StagehandBinaryResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public function resolve(array $config, string $workingDirectory): string
if ($this->looksLikePath($configured)) {
$candidate = $this->normalizePath($configured, $workingDirectory);

if (is_file($candidate) && is_executable($candidate)) {
if ($this->isRunnableFile($candidate)) {
return $candidate;
}

Expand All @@ -46,7 +46,7 @@ public function resolve(array $config, string $workingDirectory): string
if ($installPath !== '') {
$installedBinary = $this->normalizePath($installPath, $workingDirectory);

if (is_file($installedBinary) && is_executable($installedBinary)) {
if ($this->isRunnableFile($installedBinary)) {
return $installedBinary;
}

Expand All @@ -69,6 +69,8 @@ public function resolve(array $config, string $workingDirectory): string
private function looksLikePath(string $binary): bool
{
return str_contains($binary, DIRECTORY_SEPARATOR)
|| str_contains($binary, '/')
|| str_contains($binary, '\\')
|| str_starts_with($binary, '.')
|| str_starts_with($binary, '~');
}
Expand All @@ -81,10 +83,31 @@ private function normalizePath(string $binary, string $workingDirectory): string
return ($home !== '' ? rtrim($home, DIRECTORY_SEPARATOR) : '').DIRECTORY_SEPARATOR.ltrim(substr($binary, 2), DIRECTORY_SEPARATOR);
}

if (str_starts_with($binary, DIRECTORY_SEPARATOR)) {
if ($this->isAbsolutePath($binary)) {
return $binary;
}

return rtrim($workingDirectory, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.ltrim($binary, DIRECTORY_SEPARATOR);
}

private function isAbsolutePath(string $path): bool
{
return str_starts_with($path, DIRECTORY_SEPARATOR)
|| str_starts_with($path, '/')
|| preg_match('/^[A-Za-z]:[\/\\\\]/', $path) === 1;
}

private function isRunnableFile(string $path): bool
{
if (! is_file($path)) {
return false;
}

if (is_executable($path)) {
return true;
}

return PHP_OS_FAMILY === 'Windows'
&& in_array(strtolower(pathinfo($path, PATHINFO_EXTENSION)), ['bat', 'cmd', 'exe'], true);
}
}
25 changes: 24 additions & 1 deletion packages/laravel/src/Support/StagehandReleaseInstaller.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@

final class StagehandReleaseInstaller
{
private readonly StagehandBinaryCompatibility $compatibility;

public function __construct(
?StagehandBinaryCompatibility $compatibility = null,
) {
$this->compatibility = $compatibility ?? new StagehandBinaryCompatibility;
}

/**
* @param array<string, mixed> $config
*/
Expand Down Expand Up @@ -85,6 +93,14 @@ public function install(
@chmod($installPath, 0755);
}

try {
$this->compatibility->assertCompatible($installPath);
} catch (RuntimeException $exception) {
File::delete($installPath);

throw $exception;
}

return new StagehandInstallResult(
tag: $tag,
os: $resolvedOs,
Expand Down Expand Up @@ -153,7 +169,7 @@ public function resolveInstallPath(string $path, string $os): string
throw new RuntimeException('Install path is empty. Set CANIO_RUNTIME_INSTALL_PATH or pass a path.');
}

$resolved = str_starts_with($trimmed, DIRECTORY_SEPARATOR)
$resolved = $this->isAbsolutePath($trimmed)
? $trimmed
: base_path($trimmed);

Expand All @@ -164,6 +180,13 @@ public function resolveInstallPath(string $path, string $os): string
return $resolved;
}

private function isAbsolutePath(string $path): bool
{
return str_starts_with($path, DIRECTORY_SEPARATOR)
|| str_starts_with($path, '/')
|| preg_match('/^[A-Za-z]:[\/\\\\]/', $path) === 1;
}

public function assetName(string $tag, string $os, string $arch): string
{
return sprintf(
Expand Down
20 changes: 11 additions & 9 deletions packages/laravel/tests/Feature/InstallRuntimeCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,39 +8,41 @@

it('downloads and installs the matching stagehand binary', function () {
$directory = sys_get_temp_dir().'/canio-install-'.bin2hex(random_bytes(6));
$binaryPath = $directory.'/bin/stagehand';
$contents = "fake-stagehand-binary\n";
$binaryPath = $directory.'/bin/stagehand'.(PHP_OS_FAMILY === 'Windows' ? '.bat' : '');
$contents = PHP_OS_FAMILY === 'Windows'
? "@echo off\r\necho Usage of serve:\r\necho -allow-private-targets\r\necho -request-body-limit-bytes int\r\nexit /b 1\r\n"
: "#!/usr/bin/env sh\necho 'Usage of serve:'\necho ' -allow-private-targets'\necho ' -request-body-limit-bytes int'\nexit 1\n";
$checksum = hash('sha256', $contents);

config()->set('canio.runtime.release.repository', 'oxhq/canio');
config()->set('canio.runtime.release.base_url', 'https://github.com');

Http::fake([
'https://github.com/oxhq/canio/releases/download/v1.0.1/checksums.txt' => Http::response(
"{$checksum} stagehand_v1.0.1_linux_amd64\n",
'https://github.com/oxhq/canio/releases/download/v1.0.2/checksums.txt' => Http::response(
"{$checksum} stagehand_v1.0.2_linux_amd64\n",
),
'https://github.com/oxhq/canio/releases/download/v1.0.1/stagehand_v1.0.1_linux_amd64' => Http::response(
'https://github.com/oxhq/canio/releases/download/v1.0.2/stagehand_v1.0.2_linux_amd64' => Http::response(
$contents,
200,
['Content-Type' => 'application/octet-stream'],
),
]);

$this->artisan('canio:runtime:install', [
'version' => 'v1.0.1',
'version' => 'v1.0.2',
'--path' => $binaryPath,
'--os' => 'linux',
'--arch' => 'amd64',
])
->expectsOutput('Downloading v1.0.1 for linux/amd64')
->expectsOutput(sprintf('Installed Stagehand v1.0.1 to %s', $binaryPath))
->expectsOutput('Downloading v1.0.2 for linux/amd64...')
->expectsOutput(sprintf('Installed Stagehand v1.0.2 to %s', $binaryPath))
->assertSuccessful();

expect(File::exists($binaryPath))->toBeTrue()
->and(File::get($binaryPath))->toBe($contents);

Http::assertSent(fn (Request $request): bool => str_contains($request->url(), 'checksums.txt'));
Http::assertSent(fn (Request $request): bool => str_contains($request->url(), 'stagehand_v1.0.1_linux_amd64'));
Http::assertSent(fn (Request $request): bool => str_contains($request->url(), 'stagehand_v1.0.2_linux_amd64'));

File::deleteDirectory($directory);
});
28 changes: 28 additions & 0 deletions packages/laravel/tests/Unit/StagehandBinaryCompatibilityTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

use Oxhq\Canio\Support\StagehandBinaryCompatibility;

it('accepts a stagehand help surface with every required serve flag', function () {
$compatibility = new StagehandBinaryCompatibility;

$compatibility->assertCompatibleHelp(implode(PHP_EOL, [
'Usage of serve:',
' -allowed-target-hosts string',
' -allow-private-targets',
' -request-body-limit-bytes int',
]), 'stagehand');

expect(true)->toBeTrue();
});

it('rejects stale stagehand binaries that do not expose required serve flags', function () {
$compatibility = new StagehandBinaryCompatibility;

$compatibility->assertCompatibleHelp(implode(PHP_EOL, [
'Usage of serve:',
' -allowed-target-hosts string',
' -request-logging',
]), 'stagehand_v1.0.1_windows_amd64.exe');
})->throws(RuntimeException::class, 'Stagehand binary stagehand_v1.0.1_windows_amd64.exe is incompatible with this Canio package. Missing serve flags: --allow-private-targets, --request-body-limit-bytes.');
Loading
Loading