Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
2fe3f00
Pass socket read timeouts by name in test fixtures
binaryfire Oct 20, 2026
0f10b28
Verify QUERY requests against request forgery
binaryfire Oct 20, 2026
b4763f4
Limit remote file downloads in kilobytes and reject large declared sizes
binaryfire Oct 20, 2026
8c91c61
Open rebuilt Gemini turns with their thought step
binaryfire Oct 20, 2026
981edba
Port Scout's queued import queue and connection tests
binaryfire Oct 20, 2026
40c847e
Port Scout's Typesense indexable-name collection tests
binaryfire Oct 20, 2026
54936e7
Port Scout's Meilisearch whereIn escaping tests
binaryfire Oct 20, 2026
9c1e1a8
Format query previews by SQL dialect and test them with Vitest
binaryfire Oct 20, 2026
959f9ab
Port Wayfinder's QUERY method tests
binaryfire Oct 20, 2026
b493df7
Add a Wayfinder option to skip the database
binaryfire Oct 20, 2026
1a0886e
Document MCP client authentication options
binaryfire Oct 20, 2026
78b10b7
Document the @stack render order limitation
binaryfire Oct 20, 2026
5e8a1a9
Warn that request input can override signed URL query parameters
binaryfire Oct 20, 2026
a103dbf
Filter role models by guard in hasRole checks
binaryfire Oct 20, 2026
572cf41
Keep indexed permission lookups consistent with filtering
binaryfire Oct 20, 2026
bc18bc5
Port the rate limit plugin's cache hit tests
binaryfire Oct 20, 2026
871f241
Run benchmark measurements without an idle-confirmation flag
binaryfire Oct 20, 2026
b5197b1
Match enum lookup values in cached permission filters
binaryfire Oct 20, 2026
1a88332
Match role models by guard in hasAllRoles and hasExactRoles
binaryfire Oct 20, 2026
648b66a
Require at least 20 permissions in the lookup benchmark
binaryfire Oct 20, 2026
70957b5
Correct what a URL signature covers in the signed URL warning
binaryfire Oct 20, 2026
d743afa
Explain why PreventRequestForgery verifies QUERY requests
binaryfire Oct 20, 2026
130cba2
Assert the exact Meilisearch whereNotIn result
binaryfire Oct 20, 2026
47d51a7
Test Telescope's driver aliases and formatted query output
binaryfire Oct 20, 2026
48c1c8a
Test that a compressed download is limited by its decoded size
binaryfire Oct 20, 2026
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
12 changes: 12 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,15 @@ jobs:

- name: Type-check generated Wayfinder output
run: pnpm typecheck:wayfinder

# Telescope's dashboard is a standalone npm package whose devEngines require npm 11.10 or newer.
- name: Install npm for Telescope
run: npm install --global npm@11.21.0

- name: Install Telescope dependencies
working-directory: src/telescope
run: npm ci

- name: Run Telescope dashboard tests
working-directory: src/telescope
run: npm test
2 changes: 1 addition & 1 deletion src/ai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Skill discovery is cached for the fixed paths in `ai.skills.cached_paths`. [Relo

The conversation migrations use Hypervel's column types, indexes, and approval claim fields. Existing Laravel conversation data requires a [schema migration](https://hypervel.org/docs/ai-sdk#migrating-conversation-data) before use.

Remote downloads share the HTTP client's destination checks. The protected `UntrustedUrl::validate()`, `resolve()`, and `isBlocked()` helpers and hostname-blocklist constants are removed; `resolveUsing()` remains available. URLs containing embedded credentials are rejected. Downloads are limited to 32 MiB of decoded content by default; configure `ai.remote_files.max_size` to change or disable the limit. See [remote attachments](https://hypervel.org/docs/ai-sdk#remote-attachments).
Remote downloads share the HTTP client's destination checks. The protected `UntrustedUrl::validate()`, `resolve()`, and `isBlocked()` helpers and hostname-blocklist constants are removed; `resolveUsing()` remains available. URLs containing embedded credentials are rejected. Downloads are limited to 32 MiB of decoded content by default, instead of Laravel's 100 MB; set `ai.remote_files.max_size` in kilobytes to change the limit, or to `null` to disable it. See [remote attachments](https://hypervel.org/docs/ai-sdk#remote-attachments).

## Known Issues

Expand Down
4 changes: 2 additions & 2 deletions src/ai/config/ai.php
Original file line number Diff line number Diff line change
Expand Up @@ -89,13 +89,13 @@
|
| Remote attachments must resolve to public addresses unless their host
| is listed below. The size limit applies to each decoded download in
| bytes. Set max_size to null to allow downloads of any size.
| kilobytes. Set max_size to null to allow downloads of any size.
|
*/

'remote_files' => [
'allowed_hosts' => [],
'max_size' => 32 * 1024 * 1024,
'max_size' => 32 * 1024,
],

/*
Expand Down
46 changes: 32 additions & 14 deletions src/ai/src/Files/UntrustedUrl.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,17 @@
use Hypervel\Http\Client\Response;
use Hypervel\Support\Facades\Http;
use InvalidArgumentException;
use Psr\Http\Message\ResponseInterface;
use RuntimeException;
use Swoole\Coroutine\CanceledException;
use Throwable;

class UntrustedUrl
{
public const int DEFAULT_MAX_SIZE = 32 * 1024 * 1024;

protected const int MAX_REDIRECTS = 5;

public const int MAX_KILOBYTES = 32 * 1024;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This replacement removes the public UntrustedUrl::DEFAULT_MAX_SIZE constant without a compatibility alias. Keep the old byte-valued constant as an alias, or document and deliberately version this breaking API removal.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/ai/src/Files/UntrustedUrl.php, line 26:

<comment>This replacement removes the public `UntrustedUrl::DEFAULT_MAX_SIZE` constant without a compatibility alias. Keep the old byte-valued constant as an alias, or document and deliberately version this breaking API removal.</comment>

<file context>
@@ -14,15 +14,17 @@
-
     protected const int MAX_REDIRECTS = 5;
 
+    public const int MAX_KILOBYTES = 32 * 1024;
+
     // REMOVED: hostname blocklists; the HTTP destination policy checks the resolved addresses.
</file context>
Suggested change
public const int MAX_KILOBYTES = 32 * 1024;
public const int MAX_KILOBYTES = 32 * 1024;
public const int DEFAULT_MAX_SIZE = self::MAX_KILOBYTES * 1024;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining. DEFAULT_MAX_SIZE was never released either, so there's nothing to keep compatible. MAX_KILOBYTES replaces it and matches the shipped config value.


// REMOVED: hostname blocklists; the HTTP destination policy checks the resolved addresses.

protected static ?Closure $resolver = null;
Expand All @@ -36,16 +38,18 @@ public static function fetch(string $url): Response
{
$allowedHosts = config()->array('ai.remote_files.allowed_hosts', []);
$policy = new RemoteFileDestinationPolicy(static::$resolver);
$maxSize = config()->get('ai.remote_files.max_size', static::DEFAULT_MAX_SIZE);
$kilobytes = config()->get('ai.remote_files.max_size', static::MAX_KILOBYTES);

if ($maxSize !== null && (! is_int($maxSize) || $maxSize <= 0)) {
if ($kilobytes !== null && (! is_int($kilobytes) || $kilobytes <= 0)) {
throw new InvalidArgumentException('The remote file maximum size must be a positive integer or null.');
}

$limit = $kilobytes === null ? null : $kilobytes * 1024;
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Preserve the old byte-valued max_size contract or provide an explicit migration before multiplying this value; published configurations now allow downloads 1024 times larger than intended.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/ai/src/Files/UntrustedUrl.php, line 47:

<comment>Preserve the old byte-valued `max_size` contract or provide an explicit migration before multiplying this value; published configurations now allow downloads 1024 times larger than intended.</comment>

<file context>
@@ -36,16 +38,18 @@ public static function fetch(string $url): Response
             throw new InvalidArgumentException('The remote file maximum size must be a positive integer or null.');
         }
 
+        $limit = $kilobytes === null ? null : $kilobytes * 1024;
+
         for ($hop = 0; $hop <= static::MAX_REDIRECTS; ++$hop) {
</file context>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining. The byte-valued setting was never released: the AI package isn't in any tagged release, and the setting only existed on the unreleased 0.4 branch, which makes breaking changes without compatibility layers. Upstream reads this key in kilobytes, so there's no published value to migrate. The Laravel porting guide covers the different default.


for ($hop = 0; $hop <= static::MAX_REDIRECTS; ++$hop) {
$uri = new Uri($url);
$options = [];
$tooLarge = null;
$tooLarge = false;
$trusted = in_array(rtrim(trim($uri->getHost(), '[]'), '.'), $allowedHosts, true);

if ($trusted) {
Expand All @@ -54,20 +58,30 @@ public static function fetch(string $url): Response
}
}

if ($maxSize !== null) {
$buffer = Utils::streamFor();
if ($limit !== null) {
$body = Utils::streamFor();

// The transport drops a compressed response's Content-Length before this runs, so only decoded lengths are checked.
$options['on_headers'] = static function (ResponseInterface $response) use ($limit, &$tooLarge): void {
if ((int) $response->getHeaderLine('Content-Length') > $limit) {
$tooLarge = true;

// Count decoded bytes at the sink; transfer sizes may describe compressed data.
$options['sink'] = FnStream::decorate($buffer, [
'write' => static function (string $chunk) use ($buffer, $maxSize, &$tooLarge): int {
if (strlen($chunk) > $maxSize - $buffer->tell()) {
$tooLarge = new InvalidArgumentException("The remote file exceeds the maximum size of [{$maxSize}] bytes.");
throw new RuntimeException('The remote file declares a body larger than the size limit.');
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
}
};

// Count decoded bytes at the sink, which also bounds compressed and chunked bodies that declare no decoded length.
$options['sink'] = FnStream::decorate($body, [
'write' => static function (string $chunk) use ($body, $limit, &$tooLarge): int {
// The sink appends sequentially, so tell() gives its size without a stat call for every chunk.
if (strlen($chunk) > $limit - $body->tell()) {
$tooLarge = true;

// A short write aborts the transfer while preserving transport cleanup.
return 0;
}

return $buffer->write($chunk);
return $body->write($chunk);
},
]);
}
Expand All @@ -87,7 +101,11 @@ public static function fetch(string $url): Response
try {
$response = $request->get((string) $uri);
} catch (Throwable $exception) {
throw $exception instanceof CanceledException ? $exception : $tooLarge ?? $exception;
if ($tooLarge && ! $exception instanceof CanceledException) {
throw new InvalidArgumentException("The remote file exceeds the maximum size of {$kilobytes} kilobytes.");
}

throw $exception;
}

if (! $response->redirect() || blank($response->header('Location'))) {
Expand Down
10 changes: 5 additions & 5 deletions src/ai/src/Gateway/Gemini/Concerns/MapsMessages.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ protected function mapAssistantMessage(AssistantMessage|Message $message, array
return;
}

// A signature never rides on a function call, so a persisted turn opens with the rebuilt thought step that held it...
if ($message instanceof AssistantMessage && filled($signature = $message->toolCalls->first()?->thoughtSignature)) {
$input[] = ['type' => 'thought', 'signature' => $signature];
}

if (filled($message->content)) {
$input[] = [
'type' => 'model_output',
Expand All @@ -75,11 +80,6 @@ protected function mapAssistantMessage(AssistantMessage|Message $message, array
}

if ($message instanceof AssistantMessage) {
// A signature never rides on a function call, so a persisted turn rebuilds the thought step that held it...
if (filled($signature = $message->toolCalls->first()?->thoughtSignature)) {
$input[] = ['type' => 'thought', 'signature' => $signature];
}

foreach ($message->toolCalls as $toolCall) {
$input[] = [
'type' => 'function_call',
Expand Down
14 changes: 10 additions & 4 deletions src/docs/ai-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -1063,16 +1063,16 @@ Downloads inherit your application's outgoing proxy configuration; no separate A

Pinning an untrusted hostname requires cURL with SSL support and libcurl 7.75 or later; see the HTTP client's [destination policy limitations](/docs/{{version}}/http-client#destination-policy-limitations). Explicitly trusted hosts bypass destination checks and do not require hostname pinning.

Downloads are limited to 32 MiB of decoded content by default. You may set a different positive byte limit, or `null` to disable the limit, in your `config/ai.php` file:
Downloads are limited to 32 MiB of decoded content by default. You may set a different limit in kilobytes, or `null` to disable the limit, in your `config/ai.php` file:

```php
'remote_files' => [
'allowed_hosts' => ['files.internal.example'],
'max_size' => 64 * 1024 * 1024,
'max_size' => 64 * 1024,
],
```

The limit also applies to allowed hosts and compressed responses. A blocked destination or oversized download throws an `InvalidArgumentException`. The limit applies to each file; concurrent downloads and preparing files for a provider can use additional memory.
The limit also applies to allowed hosts and compressed responses. A response that declares a larger `Content-Length` is rejected before its body is downloaded. A blocked destination or oversized download throws an `InvalidArgumentException`. The limit applies to each file; concurrent downloads and preparing files for a provider can use additional memory.

If a file requires HTTP authentication, download it using the HTTP client's authentication methods and pass the downloaded content or local path to the SDK. The attachment's `withHeaders` method configures the upload to the AI provider, not the remote download.

Expand Down Expand Up @@ -1642,7 +1642,7 @@ When using Anthropic, additional provider-specific options may be passed to the
<a name="file-storage-tools"></a>
### File Storage Tools

The `FileStorage` tool factory allows you to give agents access to a Hypervel [filesystem disk](/docs/{{version}}/filesystem). The `all` method returns tools that allow the agent to list, read, inspect, generate URLs for, write, delete, and copy files on the given disk:
The `FileStorage` tool factory allows you to give agents access to a Hypervel [filesystem disk](/docs/{{version}}/filesystem). The `all` method returns tools that allow the agent to list, read, inspect, generate URLs for, write, delete, copy, and move files on the given disk:

```php
use Hypervel\Ai\Tools\FileStorage;
Expand Down Expand Up @@ -1826,6 +1826,9 @@ To refine search results based on user location, use the `location` method:
);
```

> [!NOTE]
> Groq only supports web search on its GPT-OSS models, and ignores the `max`, `allow`, and `location` methods.

<a name="web-fetch"></a>
#### Web Fetch

Expand Down Expand Up @@ -1921,6 +1924,9 @@ When using OpenAI or Azure, you may make [stored files](#files) available to the
]);
```

> [!NOTE]
> Groq only supports code execution on its GPT-OSS models.

<a name="sub-agents"></a>
### Sub-Agents

Expand Down
2 changes: 2 additions & 0 deletions src/docs/blade.md
Original file line number Diff line number Diff line change
Expand Up @@ -1881,6 +1881,8 @@ You may push to a stack as many times as needed. To render the complete stack co
</head>
```

The `@stack` directive renders the content that has been pushed at the moment it is reached. Content pushed afterwards, such as from a component rendered below the `@stack` directive in the same layout component, will not be included. If you need to push from such components, place the `@stack` directive after them, for example at the end of the `<body>` tag.

If you would like to prepend content onto the beginning of a stack, you should use the `@prepend` directive:

```blade
Expand Down
4 changes: 2 additions & 2 deletions src/docs/csrf.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Without CSRF protection, a malicious website could create an HTML form that poin

If the malicious website automatically submits the form when the page is loaded, the malicious user only needs to lure an unsuspecting user of your application to visit their website and their email address will be changed in your application.

To prevent this vulnerability, we need to inspect every incoming `POST`, `PUT`, `PATCH`, or `DELETE` request for a secret session value that the malicious application is unable to access.
To prevent this vulnerability, we need to inspect every incoming `POST`, `PUT`, `PATCH`, `DELETE`, or `QUERY` request for a secret session value that the malicious application is unable to access.

<a name="preventing-csrf-requests"></a>
## Preventing CSRF Requests
Expand Down Expand Up @@ -147,4 +147,4 @@ Hypervel stores the current CSRF token in an encrypted `XSRF-TOKEN` cookie that
This cookie is primarily sent as a developer convenience since some JavaScript frameworks and libraries, like Angular and Axios, automatically place its value in the `X-XSRF-TOKEN` header on same-origin requests.

> [!NOTE]
> For JavaScript requests, read the `XSRF-TOKEN` cookie and send its value in the `X-XSRF-TOKEN` header on same-origin `POST`, `PUT`, `PATCH`, and `DELETE` requests.
> For JavaScript requests, read the `XSRF-TOKEN` cookie and send its value in the `X-XSRF-TOKEN` header on same-origin `POST`, `PUT`, `PATCH`, `DELETE`, and `QUERY` requests.
2 changes: 0 additions & 2 deletions src/docs/http-tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,6 @@ $response = $this->query('/search', ['filter' => 'active']);
$response = $this->queryJson('/search', ['filter' => 'active']);
```

Because these requests are simulated, they aren't affected by the [HTTP/1.1 limitation on `QUERY` routes](/docs/{{version}}/routing#available-router-methods) that applies in production.

Instead of returning a `Hypervel\Http\Response` instance, test request methods return an instance of `Hypervel\Testing\TestResponse`, which provides a [variety of helpful assertions](#available-assertions) that allow you to inspect your application's responses:

```php tab=Pest
Expand Down
Loading
Loading