From 7745c5eb5c200104e189a3bc1301c4cd0d1289ea Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 28 Apr 2026 23:12:15 -0700 Subject: [PATCH] =?UTF-8?q?clients/php:=20apply=20audit=20findings=20?= =?UTF-8?q?=E2=80=94=20token=20redaction=20+=20uploadStream=20+=20tests=20?= =?UTF-8?q?(1cff9b8=20=E2=86=92=20next)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- clients/php/README.md | 41 +- clients/php/src/AppToken.php | 23 +- clients/php/src/Client.php | 121 ++++- clients/php/src/Exception/ApiException.php | 5 + .../Exception/MalformedResponseException.php | 29 ++ clients/php/src/RunRequest.php | 6 + clients/php/tests/ClientTest.php | 446 ++++++++++++++++++ 7 files changed, 649 insertions(+), 22 deletions(-) create mode 100644 clients/php/src/Exception/MalformedResponseException.php diff --git a/clients/php/README.md b/clients/php/README.md index 34e7b52..ca96d8f 100644 --- a/clients/php/README.md +++ b/clients/php/README.md @@ -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 @@ -110,16 +130,19 @@ PHP-side identifiers are camelCase; the wire JSON uses snake_case. The conversio ### Exceptions ``` -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\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\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( diff --git a/clients/php/src/AppToken.php b/clients/php/src/AppToken.php index 8dd75e4..51b100d 100644 --- a/clients/php/src/AppToken.php +++ b/clients/php/src/AppToken.php @@ -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, 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. diff --git a/clients/php/src/Client.php b/clients/php/src/Client.php index 0309c1b..749c7b8 100644 --- a/clients/php/src/Client.php +++ b/clients/php/src/Client.php @@ -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,10 +323,15 @@ 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; } - 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); diff --git a/clients/php/src/Exception/ApiException.php b/clients/php/src/Exception/ApiException.php index dcfdd38..e65078d 100644 --- a/clients/php/src/Exception/ApiException.php +++ b/clients/php/src/Exception/ApiException.php @@ -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 { diff --git a/clients/php/src/Exception/MalformedResponseException.php b/clients/php/src/Exception/MalformedResponseException.php new file mode 100644 index 0000000..961720b --- /dev/null +++ b/clients/php/src/Exception/MalformedResponseException.php @@ -0,0 +1,29 @@ + 600)) { throw new InvalidArgumentException('timeoutSecs must be in range 5..600'); } diff --git a/clients/php/tests/ClientTest.php b/clients/php/tests/ClientTest.php index e292698..73ce069 100644 --- a/clients/php/tests/ClientTest.php +++ b/clients/php/tests/ClientTest.php @@ -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'], 'not json'), + ]); + + try { + $client->run(new RunRequest(prompt: 'hi')); + self::fail('expected MalformedResponseException'); + } catch (MalformedResponseException $e) { + self::assertSame(200, $e->statusCode); + self::assertSame('not json', $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, [], '<<>>'), + ]); + + $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 : " 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}" + ); + } + } }