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}" + ); + } + } }