clawdforge/clients/php/README.md
Kayos 7745c5eb5c 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
2026-04-28 23:12:34 -07:00

9.7 KiB

clawdforge — PHP SDK

PHP 8.2+ client for the clawdforge LAN-only HTTP service that wraps claude -p subprocess calls behind a bearer-token-gated REST API.

  • PSR-4 autoload, PSR-12 code style, strict types everywhere.
  • Guzzle 7 transport, injectable ClientInterface for tests / custom middleware.
  • Readonly value objects for request input, run results, file tokens, app tokens.
  • Typed exception hierarchy so callers can discriminate auth / api / transport failures without sniffing status codes.

Install

Once published, install via Composer:

composer require clawdforge/clawdforge

To install from a local checkout (e.g. while the SDK lives in this repo):

{
    "repositories": [
        { "type": "path", "url": "../path/to/clawdforge/clients/php" }
    ],
    "require": {
        "clawdforge/clawdforge": "*"
    }
}

Quickstart

<?php
require 'vendor/autoload.php';

use Clawdforge\Client;
use Clawdforge\RunRequest;
use Clawdforge\Exception\ForgeException;

$forge = new Client(
    baseUrl: 'http://localhost:8800',
    token: 'cf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
);

// Liveness + claude --version smoke check.
$health = $forge->healthz();
echo $health['claude_version'], "\n";

// Run a prompt.
try {
    $result = $forge->run(new RunRequest(
        prompt: 'Reply with JSON: {"hello": "world"}',
        model: 'sonnet',
        system: 'Be terse.',
        timeoutSecs: 60,
    ));
} catch (ForgeException $e) {
    // ForgeException is the abstract base; sub-types tell you what failed.
    fwrite(STDERR, "forge: {$e->getMessage()}\n");
    exit(1);
}

echo "{$result->durationMs}ms, stop_reason={$result->stopReason}\n";

// `result` is mixed: array if the model produced valid JSON, string otherwise.
if (is_array($result->result)) {
    echo $result->result['hello'] ?? 'no key', "\n";
} else {
    echo (string) $result->result, "\n";
}

API surface

Method Endpoint Purpose
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 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.

// 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

PHP-side identifiers are camelCase; the wire JSON uses snake_case. The conversion happens at the boundary inside RunRequest::toWire() and the response object factories.

PHP Wire
RunRequest::$timeoutSecs timeout_secs
FileToken::$fileToken file_token
FileToken::$ttlSecs ttl_secs
RunResult::$durationMs duration_ms
RunResult::$stopReason stop_reason
AppToken::$ipCidrs ip_cidrs

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\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:

use GuzzleHttp\Client as GuzzleClient;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;

$stack = HandlerStack::create();
$stack->push(Middleware::retry(/* decider */, /* delay */));

$forge = new Client(
    baseUrl: 'http://localhost:8800',
    token: $token,
    http: new GuzzleClient(['handler' => $stack, 'http_errors' => false]),
);

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

Bind the client as a singleton in app/Providers/AppServiceProvider.php:

use Clawdforge\Client;

public function register(): void
{
    $this->app->singleton(Client::class, function () {
        return new Client(
            baseUrl: config('services.clawdforge.url'),
            token: config('services.clawdforge.token'),
        );
    });
}

config/services.php:

'clawdforge' => [
    'url' => env('CLAWDFORGE_URL', 'http://localhost:8800'),
    'token' => env('CLAWDFORGE_TOKEN'),
],

Then inject anywhere:

use Clawdforge\Client;
use Clawdforge\RunRequest;

class RecipeParser
{
    public function __construct(private readonly Client $forge) {}

    public function parse(string $line): array
    {
        $r = $this->forge->run(new RunRequest(
            prompt: "Sterilize this ingredient line: '{$line}'",
            system: 'You are a precise recipe parser. Always reply with valid JSON.',
            timeoutSecs: 30,
        ));
        return is_array($r->result) ? $r->result : [];
    }
}

WordPress

require_once ABSPATH . 'vendor/autoload.php';

use Clawdforge\Client;
use Clawdforge\RunRequest;
use Clawdforge\Exception\ForgeException;

function my_plugin_clawdforge(): Client {
    static $client = null;
    if ($client === null) {
        $client = new Client(
            baseUrl: defined('CLAWDFORGE_URL') ? CLAWDFORGE_URL : 'http://localhost:8800',
            token: defined('CLAWDFORGE_TOKEN') ? CLAWDFORGE_TOKEN : '',
        );
    }
    return $client;
}

add_action('rest_api_init', function () {
    register_rest_route('myplugin/v1', '/summarize', [
        'methods' => 'POST',
        'permission_callback' => fn() => current_user_can('edit_posts'),
        'callback' => function (WP_REST_Request $req) {
            try {
                $r = my_plugin_clawdforge()->run(new RunRequest(
                    prompt: 'Summarize: ' . (string) $req->get_param('text'),
                    timeoutSecs: 30,
                ));
                return new WP_REST_Response(['summary' => $r->result], 200);
            } catch (ForgeException $e) {
                return new WP_Error('clawdforge', $e->getMessage(), ['status' => 502]);
            }
        },
    ]);
});

Testing

The SDK ships with PHPUnit 10 tests that mock Guzzle via GuzzleHttp\Handler\MockHandler — no live network.

composer install
vendor/bin/phpunit

php -l over every source file:

find src -name '*.php' -exec php -l {} \;

Design notes

  • No retries. claude -p runs are not idempotent (they spawn subprocesses with side effects). Wrap calls in your own retry layer if you need one — Guzzle's Middleware::retry plugs right into the constructor.
  • HTTP timeout = subprocess timeout + 30 s margin. Keeps the client from bailing while clawdforge is still doing legitimate work for us. Override via the httpTimeoutMargin constructor argument.
  • Streamed file uploads. uploadFile() opens the file via fopen($path, 'r') and hands the resource to Guzzle's multipart adapter; payloads never get slurped into memory.
  • Plaintext token discipline. createToken() returns the plaintext bearer in AppToken::$token exactly once. listTokens() always returns null for that field — the server keeps only a sha256 hash. Persist immediately on mint.

License

MIT.