diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 911c623..d11c8fc 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "3.19.3" + ".": "3.20.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index ac9e78a..d8428d4 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 8 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase%2Fstagehand-b969ce378479c79ee64c05127c0ed6c6ce2edbee017ecd037242fb618a5ebc9f.yml -openapi_spec_hash: a24aabaa5214effb679808b7f2be0ad4 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase%2Fstagehand-1c6caa2891a7f3bdfc0caab143f285badc9145220c9b29cd5e4cf1a9b3ac11cf.yml +openapi_spec_hash: 28c4b734a5309067c39bb4c4b709b9ab config_hash: a962ae71493deb11a1c903256fb25386 diff --git a/CHANGELOG.md b/CHANGELOG.md index bdc1f55..ba25bb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 3.20.0 (2026-04-11) + +Full Changelog: [v3.19.3...v3.20.0](https://github.com/browserbase/stagehand-php/compare/v3.19.3...v3.20.0) + +### Features + +* [STG-1798] feat: support Browserbase verified sessions ([52c4636](https://github.com/browserbase/stagehand-php/commit/52c4636560e5dd556db9c061a3bb9c48ffa20e76)) +* Bedrock auth passthrough ([40cb10e](https://github.com/browserbase/stagehand-php/commit/40cb10eb39c7f53b9c3fd668018c1b3569650e5c)) +* Revert "[STG-1573] Add providerOptions for extensible model auth ([#1822](https://github.com/browserbase/stagehand-php/issues/1822))" ([1f99ac3](https://github.com/browserbase/stagehand-php/commit/1f99ac3957867970c6eaa968e8daaa83ae855b7b)) + + +### Bug Fixes + +* **client:** properly generate file params ([4a6d856](https://github.com/browserbase/stagehand-php/commit/4a6d85692b4bec4a518cf201999e99b2a7129263)) + ## 3.19.3 (2026-04-03) Full Changelog: [v3.18.0...v3.19.3](https://github.com/browserbase/stagehand-php/compare/v3.18.0...v3.19.3) diff --git a/README.md b/README.md index 1749137..fcdd864 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ The REST API documentation can be found on [docs.stagehand.dev](https://docs.sta ``` -composer require "browserbase/stagehand 3.19.3" +composer require "browserbase/stagehand 3.20.0" ``` diff --git a/src/Core/Conversion.php b/src/Core/Conversion.php index 1942914..e9a8688 100644 --- a/src/Core/Conversion.php +++ b/src/Core/Conversion.php @@ -21,6 +21,10 @@ public static function dump_unknown(mixed $value, DumpState $state): mixed } if (is_object($value)) { + if ($value instanceof FileParam) { + return $value; + } + if (is_a($value, class: ConverterSource::class)) { return $value::converter()->dump($value, state: $state); } diff --git a/src/Core/FileParam.php b/src/Core/FileParam.php new file mode 100644 index 0000000..bc52309 --- /dev/null +++ b/src/Core/FileParam.php @@ -0,0 +1,63 @@ +files->upload(file: FileParam::fromResource(fopen('data.csv', 'r'))); + * + * // From a string: + * $client->files->upload(file: FileParam::fromString('csv data...', 'data.csv')); + * ``` + */ +final class FileParam +{ + public const DEFAULT_CONTENT_TYPE = 'application/octet-stream'; + + /** + * @param resource|string $data the file content as a resource or string + */ + private function __construct( + public readonly mixed $data, + public readonly string $filename, + public readonly string $contentType = self::DEFAULT_CONTENT_TYPE, + ) {} + + /** + * Create a FileParam from an open resource (e.g. from fopen()). + * + * @param resource $resource an open file resource + * @param string|null $filename Override the filename. Defaults to the resource URI basename. + * @param string $contentType override the content type + */ + public static function fromResource(mixed $resource, ?string $filename = null, string $contentType = self::DEFAULT_CONTENT_TYPE): self + { + if (!is_resource($resource)) { + throw new \InvalidArgumentException('Expected a resource, got '.get_debug_type($resource)); + } + + if (null === $filename) { + $meta = stream_get_meta_data($resource); + $filename = basename($meta['uri'] ?? 'upload'); + } + + return new self($resource, filename: $filename, contentType: $contentType); + } + + /** + * Create a FileParam from a string. + * + * @param string $content the file content + * @param string $filename the filename for the Content-Disposition header + * @param string $contentType override the content type + */ + public static function fromString(string $content, string $filename, string $contentType = self::DEFAULT_CONTENT_TYPE): self + { + return new self($content, filename: $filename, contentType: $contentType); + } +} diff --git a/src/Core/Util.php b/src/Core/Util.php index 58e82aa..19a90c3 100644 --- a/src/Core/Util.php +++ b/src/Core/Util.php @@ -283,7 +283,7 @@ public static function withSetBody( if (preg_match('/^multipart\/form-data/', $contentType)) { [$boundary, $gen] = self::encodeMultipartStreaming($body); - $encoded = implode('', iterator_to_array($gen)); + $encoded = implode('', iterator_to_array($gen, preserve_keys: false)); $stream = $factory->createStream($encoded); /** @var RequestInterface */ @@ -447,11 +447,18 @@ private static function writeMultipartContent( ): \Generator { $contentLine = "Content-Type: %s\r\n\r\n"; - if (is_resource($val)) { - yield sprintf($contentLine, $contentType ?? 'application/octet-stream'); - while (!feof($val)) { - if ($read = fread($val, length: self::BUF_SIZE)) { - yield $read; + if ($val instanceof FileParam) { + $ct = $val->contentType ?? $contentType; + + yield sprintf($contentLine, $ct); + $data = $val->data; + if (is_string($data)) { + yield $data; + } else { // resource + while (!feof($data)) { + if ($read = fread($data, length: self::BUF_SIZE)) { + yield $read; + } } } } elseif (is_string($val) || is_numeric($val) || is_bool($val)) { @@ -483,17 +490,48 @@ private static function writeMultipartChunk( yield 'Content-Disposition: form-data'; if (!is_null($key)) { - $name = rawurlencode(self::strVal($key)); + $name = str_replace(['"', "\r", "\n"], replace: '', subject: $key); yield "; name=\"{$name}\""; } + // File uploads require a filename in the Content-Disposition header, + // e.g. `Content-Disposition: form-data; name="file"; filename="data.csv"` + // Without this, many servers will reject the upload with a 400. + if ($val instanceof FileParam) { + $filename = str_replace(['"', "\r", "\n"], replace: '', subject: $val->filename); + + yield "; filename=\"{$filename}\""; + } + yield "\r\n"; foreach (self::writeMultipartContent($val, closing: $closing) as $chunk) { yield $chunk; } } + /** + * Expands list arrays into separate multipart parts, applying the configured array key format. + * + * @param list $closing + * + * @return \Generator + */ + private static function writeMultipartField( + string $boundary, + ?string $key, + mixed $val, + array &$closing + ): \Generator { + if (is_array($val) && array_is_list($val)) { + foreach ($val as $item) { + yield from self::writeMultipartField(boundary: $boundary, key: $key, val: $item, closing: $closing); + } + } else { + yield from self::writeMultipartChunk(boundary: $boundary, key: $key, val: $val, closing: $closing); + } + } + /** * @param bool|int|float|string|resource|\Traversable|array|null $body * @@ -508,14 +546,10 @@ private static function encodeMultipartStreaming(mixed $body): array try { if (is_array($body) || is_object($body)) { foreach ((array) $body as $key => $val) { - foreach (static::writeMultipartChunk(boundary: $boundary, key: $key, val: $val, closing: $closing) as $chunk) { - yield $chunk; - } + yield from static::writeMultipartField(boundary: $boundary, key: $key, val: $val, closing: $closing); } } else { - foreach (static::writeMultipartChunk(boundary: $boundary, key: null, val: $body, closing: $closing) as $chunk) { - yield $chunk; - } + yield from static::writeMultipartField(boundary: $boundary, key: null, val: $body, closing: $closing); } yield "--{$boundary}--\r\n"; diff --git a/src/Sessions/SessionStartParams/BrowserbaseSessionCreateParams/BrowserSettings.php b/src/Sessions/SessionStartParams/BrowserbaseSessionCreateParams/BrowserSettings.php index 6014c60..9272ee9 100644 --- a/src/Sessions/SessionStartParams/BrowserbaseSessionCreateParams/BrowserSettings.php +++ b/src/Sessions/SessionStartParams/BrowserbaseSessionCreateParams/BrowserSettings.php @@ -9,6 +9,7 @@ use Stagehand\Core\Contracts\BaseModel; use Stagehand\Sessions\SessionStartParams\BrowserbaseSessionCreateParams\BrowserSettings\Context; use Stagehand\Sessions\SessionStartParams\BrowserbaseSessionCreateParams\BrowserSettings\Fingerprint; +use Stagehand\Sessions\SessionStartParams\BrowserbaseSessionCreateParams\BrowserSettings\Os; use Stagehand\Sessions\SessionStartParams\BrowserbaseSessionCreateParams\BrowserSettings\Viewport; /** @@ -19,12 +20,16 @@ * @phpstan-type BrowserSettingsShape = array{ * advancedStealth?: bool|null, * blockAds?: bool|null, + * captchaImageSelector?: string|null, + * captchaInputSelector?: string|null, * context?: null|Context|ContextShape, * extensionID?: string|null, * fingerprint?: null|Fingerprint|FingerprintShape, * logSession?: bool|null, + * os?: null|Os|value-of, * recordSession?: bool|null, * solveCaptchas?: bool|null, + * verified?: bool|null, * viewport?: null|Viewport|ViewportShape, * } */ @@ -39,6 +44,12 @@ final class BrowserSettings implements BaseModel #[Optional] public ?bool $blockAds; + #[Optional] + public ?string $captchaImageSelector; + + #[Optional] + public ?string $captchaInputSelector; + #[Optional] public ?Context $context; @@ -51,12 +62,19 @@ final class BrowserSettings implements BaseModel #[Optional] public ?bool $logSession; + /** @var value-of|null $os */ + #[Optional(enum: Os::class)] + public ?string $os; + #[Optional] public ?bool $recordSession; #[Optional] public ?bool $solveCaptchas; + #[Optional] + public ?bool $verified; + #[Optional] public ?Viewport $viewport; @@ -72,29 +90,38 @@ public function __construct() * * @param Context|ContextShape|null $context * @param Fingerprint|FingerprintShape|null $fingerprint + * @param Os|value-of|null $os * @param Viewport|ViewportShape|null $viewport */ public static function with( ?bool $advancedStealth = null, ?bool $blockAds = null, + ?string $captchaImageSelector = null, + ?string $captchaInputSelector = null, Context|array|null $context = null, ?string $extensionID = null, Fingerprint|array|null $fingerprint = null, ?bool $logSession = null, + Os|string|null $os = null, ?bool $recordSession = null, ?bool $solveCaptchas = null, + ?bool $verified = null, Viewport|array|null $viewport = null, ): self { $self = new self; null !== $advancedStealth && $self['advancedStealth'] = $advancedStealth; null !== $blockAds && $self['blockAds'] = $blockAds; + null !== $captchaImageSelector && $self['captchaImageSelector'] = $captchaImageSelector; + null !== $captchaInputSelector && $self['captchaInputSelector'] = $captchaInputSelector; null !== $context && $self['context'] = $context; null !== $extensionID && $self['extensionID'] = $extensionID; null !== $fingerprint && $self['fingerprint'] = $fingerprint; null !== $logSession && $self['logSession'] = $logSession; + null !== $os && $self['os'] = $os; null !== $recordSession && $self['recordSession'] = $recordSession; null !== $solveCaptchas && $self['solveCaptchas'] = $solveCaptchas; + null !== $verified && $self['verified'] = $verified; null !== $viewport && $self['viewport'] = $viewport; return $self; @@ -116,6 +143,22 @@ public function withBlockAds(bool $blockAds): self return $self; } + public function withCaptchaImageSelector(string $captchaImageSelector): self + { + $self = clone $this; + $self['captchaImageSelector'] = $captchaImageSelector; + + return $self; + } + + public function withCaptchaInputSelector(string $captchaInputSelector): self + { + $self = clone $this; + $self['captchaInputSelector'] = $captchaInputSelector; + + return $self; + } + /** * @param Context|ContextShape $context */ @@ -154,6 +197,17 @@ public function withLogSession(bool $logSession): self return $self; } + /** + * @param Os|value-of $os + */ + public function withOs(Os|string $os): self + { + $self = clone $this; + $self['os'] = $os; + + return $self; + } + public function withRecordSession(bool $recordSession): self { $self = clone $this; @@ -170,6 +224,14 @@ public function withSolveCaptchas(bool $solveCaptchas): self return $self; } + public function withVerified(bool $verified): self + { + $self = clone $this; + $self['verified'] = $verified; + + return $self; + } + /** * @param Viewport|ViewportShape $viewport */ diff --git a/src/Sessions/SessionStartParams/BrowserbaseSessionCreateParams/BrowserSettings/Os.php b/src/Sessions/SessionStartParams/BrowserbaseSessionCreateParams/BrowserSettings/Os.php new file mode 100644 index 0000000..745cef8 --- /dev/null +++ b/src/Sessions/SessionStartParams/BrowserbaseSessionCreateParams/BrowserSettings/Os.php @@ -0,0 +1,18 @@ + [ 'advancedStealth' => true, 'blockAds' => true, + 'captchaImageSelector' => 'captchaImageSelector', + 'captchaInputSelector' => 'captchaInputSelector', 'context' => ['id' => 'id', 'persist' => true], 'extensionID' => 'extensionId', 'fingerprint' => [ @@ -326,8 +328,10 @@ public function testStartWithOptionalParams(): void ], ], 'logSession' => true, + 'os' => 'windows', 'recordSession' => true, 'solveCaptchas' => true, + 'verified' => true, 'viewport' => ['height' => 0, 'width' => 0], ], 'extensionID' => 'extensionId',