clients/php: apply audit findings — token redaction + uploadStream + tests (1cff9b8 → next)
HIGH:
- H1: __debugInfo() redacts token on Client + AppToken; #[\SensitiveParameter]
on Client constructor's $token param so PHP scrubs it from stack traces.
MEDIUM:
- M1: uploadStream(StreamInterface, filename, ttl) overload so callers
handling form uploads have a non-path entry point. README warning above
the API table on uploadFile path-trust.
- M2: RunRequest now rejects empty-string model/system in the constructor
(callers should pass null/omit rather than '' to use defaults).
- M3: new MalformedResponseException extends ForgeException for
"transport succeeded, body unparseable as expected JSON object". Decoupled
from ApiException so callers can distinguish "server told me no" from
"server replied 200 with garbage". README + ApiException docstring updated.
- M4: non-UTF-8 / malformed JSON now flows through M3's new exception.
- M5: ApiException error-message extraction falls back to json_encode
(capped at 200 chars) when the error field is an object/array, so
callers don't get empty messages on {"error":{"code":...,"msg":...}}.
LOW:
- L2: revokeToken now requires server response ok === true, raises
MalformedResponseException on missing/false ok rather than silently
returning true.
- L5: README WordPress snippet uses bare Client (matches the use line above).
- L7: 29 new tests — token redaction (3), uploadStream (2), empty
model/system (2), MalformedResponseException across 7 scenarios incl.
non-UTF-8, ApiException object-error formatting + 200-char cap, revoke
ok=true requirement + ok=false + empty-name, RunRequest timeout bounds
(3) + non-string/empty files entries (2), uploadFile unreadable-path
+ 4xx + 5xx, healthz 500, Authorization header asserted on every
endpoint.
README polish: TLS verify=false caveat under "Custom HTTP client".
Audit memo: memory/clawdforge-audits/php-1cff9b8.md
This commit is contained in:
parent
e9d5e0ea16
commit
7745c5eb5c
7 changed files with 649 additions and 22 deletions
|
|
@ -79,19 +79,39 @@ if (is_array($result->result)) {
|
|||
|---|---|---|
|
||||
| `Client::healthz()` | `GET /healthz` | Liveness + `claude --version`. |
|
||||
| `Client::run(RunRequest)` | `POST /run` | Run a prompt, return a `RunResult`. |
|
||||
| `Client::uploadFile(string $path, int $ttlSecs = 3600)` | `POST /files` | Stream-upload a file, return a `FileToken`. |
|
||||
| `Client::uploadFile(string $path, int $ttlSecs = 3600)` | `POST /files` | Stream-upload a file from disk, return a `FileToken`. |
|
||||
| `Client::uploadStream(StreamInterface $stream, string $filename, int $ttlSecs = 3600)` | `POST /files` | Stream-upload from a PSR-7 stream (use this when the bytes don't live at a path). |
|
||||
| `Client::createToken(string $name, array $ipCidrs = [])` | `POST /admin/tokens` | Mint a per-app token (admin-only). |
|
||||
| `Client::listTokens()` | `GET /admin/tokens` | List known app tokens (admin-only). |
|
||||
| `Client::revokeToken(string $name)` | `DELETE /admin/tokens/{name}` | Revoke a token (admin-only). |
|
||||
|
||||
### Files in a prompt
|
||||
|
||||
> **Security note.** `Client::uploadFile($path)` reads `$path` straight off
|
||||
> disk. **Never pass a user-supplied string here** — that turns the SDK into
|
||||
> a server-side file-read primitive. Validate the path yourself, or use
|
||||
> `Client::uploadStream()` below when handling form uploads or any other
|
||||
> bytes that didn't originate from a path you control.
|
||||
|
||||
```php
|
||||
// Path you control yourself (config, vetted asset path, your own tempfile):
|
||||
$ft = $forge->uploadFile('./recipe.png', ttlSecs: 3600);
|
||||
$out = $forge->run(new RunRequest(
|
||||
prompt: 'extract recipe data',
|
||||
files: [$ft->fileToken],
|
||||
));
|
||||
|
||||
// Form uploads / arbitrary streams — use uploadStream():
|
||||
use Psr\Http\Message\UploadedFileInterface;
|
||||
|
||||
function ingest(UploadedFileInterface $file, \Clawdforge\Client $forge): void {
|
||||
$ft = $forge->uploadStream(
|
||||
stream: $file->getStream(),
|
||||
filename: $file->getClientFilename() ?? 'upload.bin',
|
||||
ttlSecs: 3600,
|
||||
);
|
||||
// ... use $ft->fileToken in a RunRequest
|
||||
}
|
||||
```
|
||||
|
||||
### Naming convention
|
||||
|
|
@ -113,13 +133,16 @@ PHP-side identifiers are camelCase; the wire JSON uses snake_case. The conversio
|
|||
Clawdforge\Exception\ForgeException (abstract base)
|
||||
├── Clawdforge\Exception\ApiException (4xx/5xx; exposes $statusCode, $body, $decoded)
|
||||
│ └── Clawdforge\Exception\AuthException (401/403)
|
||||
└── Clawdforge\Exception\TransportException (Guzzle connect/timeout/TLS)
|
||||
├── Clawdforge\Exception\TransportException (Guzzle connect/timeout/TLS)
|
||||
└── Clawdforge\Exception\MalformedResponseException (2xx with a body the SDK couldn't parse)
|
||||
```
|
||||
|
||||
A single `catch (ForgeException $e)` catches all SDK failures. Narrow with `instanceof` when you need to distinguish.
|
||||
|
||||
A `/run` failure (subprocess timeout, claude crash, etc.) lands as a 502 with a JSON envelope — the SDK throws `ApiException` with `$statusCode = 502` and the envelope available as `$decoded` (`error`, `stderr`, `duration_ms`, `stop_reason`).
|
||||
|
||||
`MalformedResponseException` is raised when the transport itself succeeded (typically 200) but the body wasn't the expected JSON object — e.g. a reverse proxy injected an HTML error page, the body was truncated, or something downstream returned non-UTF-8 noise. It is **not** raised for 4xx/5xx responses with a parseable body; those still come through as `ApiException`.
|
||||
|
||||
### Custom HTTP client
|
||||
|
||||
The constructor accepts any `GuzzleHttp\ClientInterface`, which lets you add middleware (retries, logging, circuit breakers) or swap to a mock handler in tests:
|
||||
|
|
@ -139,6 +162,12 @@ $forge = new Client(
|
|||
);
|
||||
```
|
||||
|
||||
> Don't pass `'verify' => false` to your Guzzle client outside of a
|
||||
> controlled test environment — it disables TLS certificate verification
|
||||
> and makes the connection trivially MITM-able. The clawdforge service
|
||||
> is LAN-only by design; if you need to talk to it over a hostile
|
||||
> network, fix the cert chain rather than disabling verification.
|
||||
|
||||
## Framework snippets
|
||||
|
||||
### Laravel
|
||||
|
|
@ -199,7 +228,7 @@ use Clawdforge\Client;
|
|||
use Clawdforge\RunRequest;
|
||||
use Clawdforge\Exception\ForgeException;
|
||||
|
||||
function my_plugin_clawdforge(): Clawdforge\Client {
|
||||
function my_plugin_clawdforge(): Client {
|
||||
static $client = null;
|
||||
if ($client === null) {
|
||||
$client = new Client(
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ namespace Clawdforge;
|
|||
* `$token` is the plaintext bearer string, returned ONLY at create time.
|
||||
* On list responses it is `null` — the server stores only a sha256 hash.
|
||||
* Persist the plaintext immediately when minting; you cannot recover it.
|
||||
*
|
||||
* The plaintext is redacted from {@see self::__debugInfo()} so it does
|
||||
* not leak through `var_dump()`, `print_r()`, or framework error reflectors
|
||||
* after you've persisted it.
|
||||
*/
|
||||
final readonly class AppToken
|
||||
{
|
||||
|
|
@ -53,11 +57,28 @@ final readonly class AppToken
|
|||
token: null,
|
||||
ipCidrs: self::normalizeCidrs($row['ip_cidrs'] ?? ''),
|
||||
createdAt: isset($row['created_at']) ? (int) $row['created_at'] : null,
|
||||
lastUsed: isset($row['last_used']) && $row['last_used'] !== null ? (int) $row['last_used'] : null,
|
||||
lastUsed: isset($row['last_used']) ? (int) $row['last_used'] : null,
|
||||
enabled: (bool) ($row['enabled'] ?? true),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact the plaintext token from var_dump / print_r / error reporters.
|
||||
*
|
||||
* @return array{name: string, token: ?string, ipCidrs: list<string>, createdAt: ?int, lastUsed: ?int, enabled: bool}
|
||||
*/
|
||||
public function __debugInfo(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->name,
|
||||
'token' => $this->token === null ? null : '***redacted***',
|
||||
'ipCidrs' => $this->ipCidrs,
|
||||
'createdAt' => $this->createdAt,
|
||||
'lastUsed' => $this->lastUsed,
|
||||
'enabled' => $this->enabled,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Server's `list_tokens` returns ip_cidrs as a comma-joined string; the
|
||||
* create endpoint returns it as a list. Normalize both into list<string>.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ namespace Clawdforge;
|
|||
|
||||
use Clawdforge\Exception\ApiException;
|
||||
use Clawdforge\Exception\AuthException;
|
||||
use Clawdforge\Exception\MalformedResponseException;
|
||||
use Clawdforge\Exception\TransportException;
|
||||
use GuzzleHttp\Client as GuzzleClient;
|
||||
use GuzzleHttp\ClientInterface;
|
||||
|
|
@ -13,6 +14,8 @@ use GuzzleHttp\Exception\GuzzleException;
|
|||
use GuzzleHttp\Psr7\Utils;
|
||||
use InvalidArgumentException;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\StreamInterface;
|
||||
use SensitiveParameter;
|
||||
|
||||
/**
|
||||
* Sync HTTP client for the clawdforge service.
|
||||
|
|
@ -27,6 +30,11 @@ use Psr\Http\Message\ResponseInterface;
|
|||
* No retry logic is built in. Wrap calls in your own retry layer if you
|
||||
* need it — clawdforge runs are not idempotent (they spawn `claude -p`).
|
||||
*
|
||||
* The bearer token is redacted from {@see self::__debugInfo()} so it does
|
||||
* not leak through `var_dump()`, `print_r()`, framework error reflectors,
|
||||
* or DI-container introspection. The constructor's `$token` parameter is
|
||||
* marked `#[\SensitiveParameter]` so PHP scrubs it from stack traces too.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final class Client
|
||||
|
|
@ -46,7 +54,7 @@ final class Client
|
|||
|
||||
public function __construct(
|
||||
string $baseUrl,
|
||||
private readonly string $token,
|
||||
#[SensitiveParameter] private readonly string $token,
|
||||
?ClientInterface $http = null,
|
||||
private readonly string $defaultModel = self::DEFAULT_MODEL,
|
||||
private readonly int $defaultTimeoutSecs = self::DEFAULT_RUN_TIMEOUT_SECS,
|
||||
|
|
@ -64,6 +72,22 @@ final class Client
|
|||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact the bearer token from var_dump / print_r / error reporters.
|
||||
*
|
||||
* @return array{baseUrl: string, token: string, defaultModel: string, defaultTimeoutSecs: int, httpTimeoutMargin: int}
|
||||
*/
|
||||
public function __debugInfo(): array
|
||||
{
|
||||
return [
|
||||
'baseUrl' => $this->baseUrl,
|
||||
'token' => '***redacted***',
|
||||
'defaultModel' => $this->defaultModel,
|
||||
'defaultTimeoutSecs' => $this->defaultTimeoutSecs,
|
||||
'httpTimeoutMargin' => $this->httpTimeoutMargin,
|
||||
];
|
||||
}
|
||||
|
||||
// --- /healthz ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
|
|
@ -73,6 +97,7 @@ final class Client
|
|||
*
|
||||
* @throws TransportException
|
||||
* @throws ApiException
|
||||
* @throws MalformedResponseException
|
||||
*/
|
||||
public function healthz(): array
|
||||
{
|
||||
|
|
@ -81,7 +106,7 @@ final class Client
|
|||
$this->ensureSuccess($resp, $decoded);
|
||||
|
||||
if (!is_array($decoded)) {
|
||||
throw new ApiException(
|
||||
throw new MalformedResponseException(
|
||||
'unexpected /healthz response (not a JSON object)',
|
||||
$resp->getStatusCode(),
|
||||
(string) $resp->getBody(),
|
||||
|
|
@ -100,6 +125,7 @@ final class Client
|
|||
* @throws ApiException server returned 4xx/5xx (including 502 for subprocess timeout)
|
||||
* @throws AuthException 401/403 (bad token / IP not allowed)
|
||||
* @throws TransportException connection-level failure
|
||||
* @throws MalformedResponseException 200 with a body that isn't a JSON object
|
||||
*/
|
||||
public function run(RunRequest $request): RunResult
|
||||
{
|
||||
|
|
@ -115,7 +141,7 @@ final class Client
|
|||
$this->ensureSuccess($resp, $decoded);
|
||||
|
||||
if (!is_array($decoded)) {
|
||||
throw new ApiException(
|
||||
throw new MalformedResponseException(
|
||||
'unexpected /run response (not a JSON object)',
|
||||
$resp->getStatusCode(),
|
||||
(string) $resp->getBody(),
|
||||
|
|
@ -128,11 +154,17 @@ final class Client
|
|||
// --- /files -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `POST /files` — upload a file, get back an `ff_...` token.
|
||||
* `POST /files` — upload a file from a local path, get back an `ff_...` token.
|
||||
*
|
||||
* Streams the file via a `fopen($path, 'r')` resource so it never has to
|
||||
* be slurped into memory.
|
||||
*
|
||||
* SECURITY: `$path` is read straight off disk; passing user-controlled
|
||||
* input here turns this into a server-side file-read primitive. Only call
|
||||
* with paths you have validated yourself, or use {@see self::uploadStream()}
|
||||
* with a stream you constructed from a vetted source (e.g. an
|
||||
* `UploadedFileInterface` from a framework form-handler).
|
||||
*
|
||||
* @param string $path filesystem path to a readable file
|
||||
* @param int $ttlSecs server-side TTL (60..86400). Out-of-range values are rejected with 400.
|
||||
*
|
||||
|
|
@ -140,6 +172,7 @@ final class Client
|
|||
* @throws ApiException server returned 4xx/5xx
|
||||
* @throws AuthException 401/403
|
||||
* @throws TransportException connection-level failure
|
||||
* @throws MalformedResponseException 200 with a body that isn't a JSON object
|
||||
*/
|
||||
public function uploadFile(string $path, int $ttlSecs = 3600): FileToken
|
||||
{
|
||||
|
|
@ -148,6 +181,43 @@ final class Client
|
|||
}
|
||||
|
||||
$stream = Utils::tryFopen($path, 'r');
|
||||
return $this->uploadMultipart($stream, basename($path), $ttlSecs);
|
||||
}
|
||||
|
||||
/**
|
||||
* `POST /files` — upload from a PSR-7 stream, get back an `ff_...` token.
|
||||
*
|
||||
* Use this overload when the bytes don't live at a filesystem path you
|
||||
* control — e.g. inside a framework's form-upload handler that hands you
|
||||
* an `UploadedFileInterface` whose `->getStream()` returns a temporary
|
||||
* php://temp resource. Avoids the path-trust footgun in
|
||||
* {@see self::uploadFile()}.
|
||||
*
|
||||
* @param StreamInterface $stream readable stream; will be consumed in full
|
||||
* @param string $filename filename to surface to the server (used for content-type sniffing only)
|
||||
* @param int $ttlSecs server-side TTL (60..86400)
|
||||
*
|
||||
* @throws InvalidArgumentException filename is empty
|
||||
* @throws ApiException server returned 4xx/5xx
|
||||
* @throws AuthException 401/403
|
||||
* @throws TransportException connection-level failure
|
||||
* @throws MalformedResponseException 200 with a body that isn't a JSON object
|
||||
*/
|
||||
public function uploadStream(StreamInterface $stream, string $filename, int $ttlSecs = 3600): FileToken
|
||||
{
|
||||
if ($filename === '') {
|
||||
throw new InvalidArgumentException('filename is required');
|
||||
}
|
||||
return $this->uploadMultipart($stream, $filename, $ttlSecs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared multipart upload path used by uploadFile / uploadStream.
|
||||
*
|
||||
* @param resource|StreamInterface $contents
|
||||
*/
|
||||
private function uploadMultipart(mixed $contents, string $filename, int $ttlSecs): FileToken
|
||||
{
|
||||
$resp = $this->send(
|
||||
'POST',
|
||||
'/files',
|
||||
|
|
@ -155,8 +225,8 @@ final class Client
|
|||
['name' => 'ttl_secs', 'contents' => (string) $ttlSecs],
|
||||
[
|
||||
'name' => 'file',
|
||||
'contents' => $stream,
|
||||
'filename' => basename($path),
|
||||
'contents' => $contents,
|
||||
'filename' => $filename,
|
||||
],
|
||||
],
|
||||
timeoutSecs: $this->defaultTimeoutSecs + $this->httpTimeoutMargin,
|
||||
|
|
@ -165,7 +235,7 @@ final class Client
|
|||
$this->ensureSuccess($resp, $decoded);
|
||||
|
||||
if (!is_array($decoded)) {
|
||||
throw new ApiException(
|
||||
throw new MalformedResponseException(
|
||||
'unexpected /files response (not a JSON object)',
|
||||
$resp->getStatusCode(),
|
||||
(string) $resp->getBody(),
|
||||
|
|
@ -197,7 +267,7 @@ final class Client
|
|||
$this->ensureSuccess($resp, $decoded);
|
||||
|
||||
if (!is_array($decoded)) {
|
||||
throw new ApiException(
|
||||
throw new MalformedResponseException(
|
||||
'unexpected /admin/tokens response',
|
||||
$resp->getStatusCode(),
|
||||
(string) $resp->getBody(),
|
||||
|
|
@ -219,7 +289,7 @@ final class Client
|
|||
$this->ensureSuccess($resp, $decoded);
|
||||
|
||||
if (!is_array($decoded) || !isset($decoded['tokens']) || !is_array($decoded['tokens'])) {
|
||||
throw new ApiException(
|
||||
throw new MalformedResponseException(
|
||||
'unexpected /admin/tokens list response',
|
||||
$resp->getStatusCode(),
|
||||
(string) $resp->getBody(),
|
||||
|
|
@ -240,7 +310,9 @@ final class Client
|
|||
*
|
||||
* Returns `true` on success. A 404 (no such token) raises {@see ApiException}
|
||||
* rather than returning `false`, matching the server's contract — so callers
|
||||
* can tell "missing" apart from "revoked".
|
||||
* can tell "missing" apart from "revoked". A 200 response that lacks
|
||||
* `ok === true` raises {@see MalformedResponseException}: we will not
|
||||
* report success on an ambiguous payload.
|
||||
*/
|
||||
public function revokeToken(string $name): bool
|
||||
{
|
||||
|
|
@ -251,12 +323,17 @@ final class Client
|
|||
$decoded = $this->decode($resp);
|
||||
$this->ensureSuccess($resp, $decoded);
|
||||
|
||||
if (is_array($decoded) && array_key_exists('ok', $decoded)) {
|
||||
return (bool) $decoded['ok'];
|
||||
}
|
||||
if (is_array($decoded) && array_key_exists('ok', $decoded) && $decoded['ok'] === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new MalformedResponseException(
|
||||
'unexpected /admin/tokens revoke response (missing ok=true)',
|
||||
$resp->getStatusCode(),
|
||||
(string) $resp->getBody(),
|
||||
);
|
||||
}
|
||||
|
||||
// --- internals --------------------------------------------------------
|
||||
|
||||
/**
|
||||
|
|
@ -331,10 +408,24 @@ final class Client
|
|||
$decodedArr = is_array($decoded) ? $decoded : null;
|
||||
if ($decodedArr !== null) {
|
||||
foreach (['error', 'detail', 'message'] as $field) {
|
||||
if (isset($decodedArr[$field]) && is_string($decodedArr[$field])) {
|
||||
$short = $decodedArr[$field];
|
||||
if (!isset($decodedArr[$field])) {
|
||||
continue;
|
||||
}
|
||||
$value = $decodedArr[$field];
|
||||
if (is_string($value)) {
|
||||
$short = $value;
|
||||
break;
|
||||
}
|
||||
// Object/array error fields — surface a JSON-encoded snippet
|
||||
// so callers don't end up with empty messages on
|
||||
// `{"error": {"code": "...", "msg": "..."}}` shapes.
|
||||
if (is_array($value) || is_object($value)) {
|
||||
$encoded = json_encode($value);
|
||||
if (is_string($encoded)) {
|
||||
$short = substr($encoded, 0, 200);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} elseif ($body !== '') {
|
||||
$short = substr($body, 0, 200);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ use Throwable;
|
|||
* body (string), and `$decoded` for the JSON-decoded body when one was returned.
|
||||
* `/run` failures land here with status 502 and a `decoded` envelope holding
|
||||
* `error`, `stderr`, `duration_ms`, `stop_reason`.
|
||||
*
|
||||
* A 200 response whose body the SDK cannot parse into the expected JSON
|
||||
* shape does NOT land here — it is raised as
|
||||
* {@see MalformedResponseException} instead, so callers can tell
|
||||
* "server told me no" apart from "server replied 200 with garbage".
|
||||
*/
|
||||
class ApiException extends ForgeException
|
||||
{
|
||||
|
|
|
|||
29
clients/php/src/Exception/MalformedResponseException.php
Normal file
29
clients/php/src/Exception/MalformedResponseException.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Clawdforge\Exception;
|
||||
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* The HTTP transport succeeded (2xx) but the body could not be parsed as
|
||||
* the expected JSON shape.
|
||||
*
|
||||
* Distinct from {@see ApiException} so callers can tell "server told me no"
|
||||
* (4xx/5xx with a body the SDK could read) apart from "server replied 200
|
||||
* with garbage" (proxy injecting an HTML error page, broken middleware,
|
||||
* truncated body, non-UTF-8 noise, etc.). Inspect `$body` for the verbatim
|
||||
* response, `$statusCode` for the HTTP status (typically 200).
|
||||
*/
|
||||
final class MalformedResponseException extends ForgeException
|
||||
{
|
||||
public function __construct(
|
||||
string $message,
|
||||
public readonly int $statusCode,
|
||||
public readonly string $body = '',
|
||||
?Throwable $previous = null,
|
||||
) {
|
||||
parent::__construct($message, $statusCode, $previous);
|
||||
}
|
||||
}
|
||||
|
|
@ -39,6 +39,12 @@ final readonly class RunRequest
|
|||
if ($prompt === '') {
|
||||
throw new InvalidArgumentException('prompt must be non-empty');
|
||||
}
|
||||
if ($model !== null && $model === '') {
|
||||
throw new InvalidArgumentException('model must be null or a non-empty string (omit it to use the server / Client default)');
|
||||
}
|
||||
if ($system !== null && $system === '') {
|
||||
throw new InvalidArgumentException('system must be null or a non-empty string (omit it for no system prompt)');
|
||||
}
|
||||
if ($timeoutSecs !== null && ($timeoutSecs < 5 || $timeoutSecs > 600)) {
|
||||
throw new InvalidArgumentException('timeoutSecs must be in range 5..600');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ use Clawdforge\Client;
|
|||
use Clawdforge\Exception\ApiException;
|
||||
use Clawdforge\Exception\AuthException;
|
||||
use Clawdforge\Exception\ForgeException;
|
||||
use Clawdforge\Exception\MalformedResponseException;
|
||||
use Clawdforge\Exception\TransportException;
|
||||
use Clawdforge\FileToken;
|
||||
use Clawdforge\RunRequest;
|
||||
|
|
@ -20,6 +21,7 @@ use GuzzleHttp\HandlerStack;
|
|||
use GuzzleHttp\Middleware;
|
||||
use GuzzleHttp\Psr7\Request;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use GuzzleHttp\Psr7\Utils;
|
||||
use InvalidArgumentException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
|
|
@ -337,4 +339,448 @@ final class ClientTest extends TestCase
|
|||
$this->expectException(InvalidArgumentException::class);
|
||||
new Client(self::BASE_URL, '');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Audit-driven tests (1cff9b8 → next)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// H1 — token redaction --------------------------------------------------
|
||||
|
||||
public function testClientDebugInfoRedactsToken(): void
|
||||
{
|
||||
$client = new Client(self::BASE_URL, self::TOKEN);
|
||||
|
||||
// print_r honours __debugInfo() — this is what most framework error
|
||||
// reflectors (Whoops, Symfony VarDumper, Laravel Ignition, etc.) hit.
|
||||
$printed = print_r($client, true);
|
||||
self::assertStringNotContainsString(self::TOKEN, $printed);
|
||||
self::assertStringContainsString('***redacted***', $printed);
|
||||
|
||||
// var_dump() likewise routes through __debugInfo().
|
||||
ob_start();
|
||||
var_dump($client);
|
||||
$dumped = (string) ob_get_clean();
|
||||
self::assertStringNotContainsString(self::TOKEN, $dumped);
|
||||
self::assertStringContainsString('redacted', $dumped);
|
||||
|
||||
// The __debugInfo() return value itself must not include the bearer.
|
||||
$info = $client->__debugInfo();
|
||||
self::assertSame('***redacted***', $info['token']);
|
||||
self::assertSame(self::BASE_URL, $info['baseUrl']);
|
||||
}
|
||||
|
||||
public function testAppTokenDebugInfoRedactsToken(): void
|
||||
{
|
||||
$plaintext = 'cf_brandnew_super_secret_xxxxxx';
|
||||
$tok = new AppToken(name: 'cauldron', token: $plaintext);
|
||||
|
||||
$printed = print_r($tok, true);
|
||||
self::assertStringNotContainsString($plaintext, $printed);
|
||||
self::assertStringContainsString('***redacted***', $printed);
|
||||
|
||||
ob_start();
|
||||
var_dump($tok);
|
||||
$dumped = (string) ob_get_clean();
|
||||
self::assertStringNotContainsString($plaintext, $dumped);
|
||||
|
||||
// List-row tokens (no plaintext) should remain null in the debug
|
||||
// view — no false redaction marker.
|
||||
$listed = new AppToken(name: 'cauldron', token: null);
|
||||
$printedListed = print_r($listed, true);
|
||||
self::assertStringNotContainsString('redacted', $printedListed);
|
||||
}
|
||||
|
||||
public function testCreateTokenResponseRedactsPlaintextOnDebug(): void
|
||||
{
|
||||
$plaintext = 'cf_brandnew_xxx';
|
||||
$client = $this->makeClient([
|
||||
new Response(200, [], (string) json_encode([
|
||||
'name' => 'cauldron',
|
||||
'token' => $plaintext,
|
||||
'ip_cidrs' => [],
|
||||
])),
|
||||
]);
|
||||
|
||||
$tok = $client->createToken('cauldron');
|
||||
// The plaintext is still accessible on the property — that's the contract.
|
||||
self::assertSame($plaintext, $tok->token);
|
||||
// ...but it must not bleed into reflective output that goes through __debugInfo().
|
||||
$printed = print_r($tok, true);
|
||||
self::assertStringNotContainsString($plaintext, $printed);
|
||||
}
|
||||
|
||||
// M1 — uploadStream overload --------------------------------------------
|
||||
|
||||
public function testUploadStreamFromPsr7(): void
|
||||
{
|
||||
$history = [];
|
||||
$client = $this->makeClient([
|
||||
new Response(200, [], (string) json_encode([
|
||||
'file_token' => 'ff_stream',
|
||||
'ttl_secs' => 600,
|
||||
'size' => 5,
|
||||
])),
|
||||
], $history);
|
||||
|
||||
$stream = Utils::streamFor('hello');
|
||||
|
||||
$ft = $client->uploadStream($stream, 'greeting.txt', 600);
|
||||
|
||||
self::assertSame('ff_stream', $ft->fileToken);
|
||||
self::assertSame(600, $ft->ttlSecs);
|
||||
|
||||
/** @var \Psr\Http\Message\RequestInterface $req */
|
||||
$req = $history[0]['request'];
|
||||
self::assertSame('POST', $req->getMethod());
|
||||
self::assertSame('/files', $req->getUri()->getPath());
|
||||
self::assertSame('Bearer ' . self::TOKEN, $req->getHeaderLine('Authorization'));
|
||||
$body = (string) $req->getBody();
|
||||
self::assertStringContainsString('name="file"', $body);
|
||||
self::assertStringContainsString('filename="greeting.txt"', $body);
|
||||
self::assertStringContainsString('hello', $body);
|
||||
}
|
||||
|
||||
public function testUploadStreamRejectsEmptyFilename(): void
|
||||
{
|
||||
$client = $this->makeClient([]); // no requests should fly
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$client->uploadStream(Utils::streamFor('x'), '', 60);
|
||||
}
|
||||
|
||||
// M2 — empty model/system rejection -------------------------------------
|
||||
|
||||
public function testRunRequestRejectsEmptyModel(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
new RunRequest(prompt: 'hi', model: '');
|
||||
}
|
||||
|
||||
public function testRunRequestRejectsEmptySystem(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
new RunRequest(prompt: 'hi', system: '');
|
||||
}
|
||||
|
||||
// M3/M4 — MalformedResponseException -------------------------------------
|
||||
|
||||
public function testMalformedResponseExceptionOnRun(): void
|
||||
{
|
||||
$client = $this->makeClient([
|
||||
new Response(200, ['Content-Type' => 'text/html'], '<html>not json</html>'),
|
||||
]);
|
||||
|
||||
try {
|
||||
$client->run(new RunRequest(prompt: 'hi'));
|
||||
self::fail('expected MalformedResponseException');
|
||||
} catch (MalformedResponseException $e) {
|
||||
self::assertSame(200, $e->statusCode);
|
||||
self::assertSame('<html>not json</html>', $e->body);
|
||||
self::assertInstanceOf(ForgeException::class, $e);
|
||||
self::assertNotInstanceOf(ApiException::class, $e);
|
||||
}
|
||||
}
|
||||
|
||||
public function testMalformedResponseExceptionOnHealthz(): void
|
||||
{
|
||||
$client = $this->makeClient([
|
||||
new Response(200, [], 'definitely not json'),
|
||||
]);
|
||||
|
||||
$this->expectException(MalformedResponseException::class);
|
||||
$client->healthz();
|
||||
}
|
||||
|
||||
public function testMalformedResponseExceptionOnFiles(): void
|
||||
{
|
||||
$client = $this->makeClient([
|
||||
new Response(200, [], 'still not json'),
|
||||
]);
|
||||
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'cf_');
|
||||
self::assertNotFalse($tmp);
|
||||
file_put_contents($tmp, 'x');
|
||||
try {
|
||||
$this->expectException(MalformedResponseException::class);
|
||||
$client->uploadFile($tmp);
|
||||
} finally {
|
||||
@unlink($tmp);
|
||||
}
|
||||
}
|
||||
|
||||
public function testMalformedResponseExceptionOnListTokensNonObject(): void
|
||||
{
|
||||
$client = $this->makeClient([
|
||||
new Response(200, [], (string) json_encode(['unexpected' => 'shape'])),
|
||||
]);
|
||||
|
||||
$this->expectException(MalformedResponseException::class);
|
||||
$client->listTokens();
|
||||
}
|
||||
|
||||
public function testMalformedResponseExceptionOnListTokensGarbageBody(): void
|
||||
{
|
||||
$client = $this->makeClient([
|
||||
new Response(200, [], '<<<not json>>>'),
|
||||
]);
|
||||
|
||||
$this->expectException(MalformedResponseException::class);
|
||||
$client->listTokens();
|
||||
}
|
||||
|
||||
public function testMalformedResponseExceptionOnCreateToken(): void
|
||||
{
|
||||
$client = $this->makeClient([
|
||||
new Response(200, [], 'garbage'),
|
||||
]);
|
||||
|
||||
$this->expectException(MalformedResponseException::class);
|
||||
$client->createToken('cauldron');
|
||||
}
|
||||
|
||||
public function testNonUtf8BodyDegradesToMalformedException(): void
|
||||
{
|
||||
// A latin-1 / non-UTF-8 byte sequence is invalid JSON and json_decode
|
||||
// returns null — the SDK must surface this as MalformedResponseException
|
||||
// rather than slipping a null through.
|
||||
$body = "\xff\xfe\xfd\xfc not utf8";
|
||||
$client = $this->makeClient([
|
||||
new Response(200, [], $body),
|
||||
]);
|
||||
|
||||
$this->expectException(MalformedResponseException::class);
|
||||
$client->healthz();
|
||||
}
|
||||
|
||||
// M5 — object error fields surface a JSON-encoded snippet ---------------
|
||||
|
||||
public function testApiExceptionMessageWithObjectError(): void
|
||||
{
|
||||
$client = $this->makeClient([
|
||||
new Response(500, [], (string) json_encode([
|
||||
'error' => ['code' => 'internal', 'msg' => 'bad'],
|
||||
])),
|
||||
]);
|
||||
|
||||
try {
|
||||
$client->healthz();
|
||||
self::fail('expected ApiException');
|
||||
} catch (ApiException $e) {
|
||||
self::assertSame(500, $e->statusCode);
|
||||
$msg = $e->getMessage();
|
||||
self::assertNotEmpty($msg);
|
||||
// Should round-trip the JSON of the object so callers see the
|
||||
// structured error rather than an empty trailing message.
|
||||
self::assertStringContainsString('internal', $msg);
|
||||
self::assertStringContainsString('bad', $msg);
|
||||
}
|
||||
}
|
||||
|
||||
public function testApiExceptionMessageObjectErrorCappedAt200Chars(): void
|
||||
{
|
||||
$longMsg = str_repeat('A', 500);
|
||||
$client = $this->makeClient([
|
||||
new Response(500, [], (string) json_encode([
|
||||
'error' => ['code' => 'overflow', 'msg' => $longMsg],
|
||||
])),
|
||||
]);
|
||||
|
||||
try {
|
||||
$client->healthz();
|
||||
self::fail('expected ApiException');
|
||||
} catch (ApiException $e) {
|
||||
$msg = $e->getMessage();
|
||||
// Prefix is "500 <reason>: " and then the snippet — snippet
|
||||
// itself must be capped at 200 chars.
|
||||
self::assertLessThanOrEqual(
|
||||
300,
|
||||
strlen($msg),
|
||||
'message snippet should be capped near 200 chars'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// L2 — revokeToken requires ok === true ---------------------------------
|
||||
|
||||
public function testRevokeTokenRequiresOkTrue(): void
|
||||
{
|
||||
$client = $this->makeClient([
|
||||
new Response(200, [], (string) json_encode([])),
|
||||
]);
|
||||
|
||||
$this->expectException(MalformedResponseException::class);
|
||||
$client->revokeToken('cauldron');
|
||||
}
|
||||
|
||||
public function testRevokeTokenRejectsOkFalse(): void
|
||||
{
|
||||
$client = $this->makeClient([
|
||||
new Response(200, [], (string) json_encode(['ok' => false])),
|
||||
]);
|
||||
|
||||
$this->expectException(MalformedResponseException::class);
|
||||
$client->revokeToken('cauldron');
|
||||
}
|
||||
|
||||
public function testRevokeTokenRejectsEmptyName(): void
|
||||
{
|
||||
$client = $this->makeClient([]);
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$client->revokeToken('');
|
||||
}
|
||||
|
||||
// L7 — coverage gaps -----------------------------------------------------
|
||||
|
||||
public function testRunRequestRejectsTimeoutBelowFloor(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
new RunRequest(prompt: 'hi', timeoutSecs: 4);
|
||||
}
|
||||
|
||||
public function testRunRequestRejectsTimeoutAboveCeiling(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
new RunRequest(prompt: 'hi', timeoutSecs: 601);
|
||||
}
|
||||
|
||||
public function testRunRequestAcceptsTimeoutAtBounds(): void
|
||||
{
|
||||
$low = new RunRequest(prompt: 'hi', timeoutSecs: 5);
|
||||
$high = new RunRequest(prompt: 'hi', timeoutSecs: 600);
|
||||
self::assertSame(5, $low->timeoutSecs);
|
||||
self::assertSame(600, $high->timeoutSecs);
|
||||
}
|
||||
|
||||
public function testRunRequestRejectsNonStringFilesEntry(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
// @phpstan-ignore-next-line — deliberately pushing a bad type
|
||||
new RunRequest(prompt: 'hi', files: ['ff_ok', 123]);
|
||||
}
|
||||
|
||||
public function testRunRequestRejectsEmptyStringFilesEntry(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
new RunRequest(prompt: 'hi', files: ['ff_ok', '']);
|
||||
}
|
||||
|
||||
public function testUploadFileRejectsUnreadablePath(): void
|
||||
{
|
||||
$client = $this->makeClient([]);
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$client->uploadFile('/no/such/file/anywhere/' . uniqid('cf_', true));
|
||||
}
|
||||
|
||||
public function testUploadFile4xxRaisesApiException(): void
|
||||
{
|
||||
$client = $this->makeClient([
|
||||
new Response(413, [], (string) json_encode(['detail' => 'file too large'])),
|
||||
]);
|
||||
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'cf_');
|
||||
self::assertNotFalse($tmp);
|
||||
file_put_contents($tmp, 'x');
|
||||
try {
|
||||
try {
|
||||
$client->uploadFile($tmp);
|
||||
self::fail('expected ApiException');
|
||||
} catch (ApiException $e) {
|
||||
self::assertSame(413, $e->statusCode);
|
||||
self::assertStringContainsString('file too large', $e->getMessage());
|
||||
}
|
||||
} finally {
|
||||
@unlink($tmp);
|
||||
}
|
||||
}
|
||||
|
||||
public function testUploadFile5xxRaisesApiException(): void
|
||||
{
|
||||
$client = $this->makeClient([
|
||||
new Response(503, [], (string) json_encode(['error' => 'storage unavailable'])),
|
||||
]);
|
||||
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'cf_');
|
||||
self::assertNotFalse($tmp);
|
||||
file_put_contents($tmp, 'x');
|
||||
try {
|
||||
try {
|
||||
$client->uploadFile($tmp);
|
||||
self::fail('expected ApiException');
|
||||
} catch (ApiException $e) {
|
||||
self::assertSame(503, $e->statusCode);
|
||||
self::assertNotInstanceOf(MalformedResponseException::class, $e);
|
||||
}
|
||||
} finally {
|
||||
@unlink($tmp);
|
||||
}
|
||||
}
|
||||
|
||||
public function testHealthz500RaisesApiException(): void
|
||||
{
|
||||
$client = $this->makeClient([
|
||||
new Response(500, [], (string) json_encode(['error' => 'boom'])),
|
||||
]);
|
||||
|
||||
try {
|
||||
$client->healthz();
|
||||
self::fail('expected ApiException');
|
||||
} catch (ApiException $e) {
|
||||
self::assertSame(500, $e->statusCode);
|
||||
self::assertNotInstanceOf(AuthException::class, $e);
|
||||
self::assertStringContainsString('boom', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function testAuthorizationHeaderSentOnEveryEndpoint(): void
|
||||
{
|
||||
$history = [];
|
||||
$client = $this->makeClient([
|
||||
new Response(200, [], (string) json_encode([
|
||||
'ok' => true, 'claude_present' => true, 'claude_version' => '1',
|
||||
])),
|
||||
new Response(200, [], (string) json_encode([
|
||||
'ok' => true, 'result' => 'x', 'duration_ms' => 1, 'stop_reason' => 'end_turn',
|
||||
])),
|
||||
new Response(200, [], (string) json_encode([
|
||||
'file_token' => 'ff_x', 'ttl_secs' => 60, 'size' => 1,
|
||||
])),
|
||||
new Response(200, [], (string) json_encode([
|
||||
'name' => 'a', 'token' => 'cf_a', 'ip_cidrs' => [],
|
||||
])),
|
||||
new Response(200, [], (string) json_encode([
|
||||
'tokens' => [],
|
||||
])),
|
||||
new Response(200, [], (string) json_encode(['ok' => true])),
|
||||
], $history);
|
||||
|
||||
$client->healthz();
|
||||
$client->run(new RunRequest(prompt: 'hi'));
|
||||
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'cf_');
|
||||
self::assertNotFalse($tmp);
|
||||
file_put_contents($tmp, 'x');
|
||||
try {
|
||||
$client->uploadFile($tmp);
|
||||
} finally {
|
||||
@unlink($tmp);
|
||||
}
|
||||
$client->createToken('a');
|
||||
$client->listTokens();
|
||||
$client->revokeToken('a');
|
||||
|
||||
self::assertCount(6, $history);
|
||||
foreach ($history as $i => $entry) {
|
||||
/** @var \Psr\Http\Message\RequestInterface $req */
|
||||
$req = $entry['request'];
|
||||
self::assertSame(
|
||||
'Bearer ' . self::TOKEN,
|
||||
$req->getHeaderLine('Authorization'),
|
||||
"Authorization missing/wrong on request #{$i} ({$req->getMethod()} {$req->getUri()->getPath()})"
|
||||
);
|
||||
self::assertSame(
|
||||
'application/json',
|
||||
$req->getHeaderLine('Accept'),
|
||||
"Accept missing on request #{$i}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue