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
285 lines
9.7 KiB
Markdown
285 lines
9.7 KiB
Markdown
# clawdforge — PHP SDK
|
|
|
|
PHP 8.2+ client for the [clawdforge](http://192.168.0.5:3001/Sulkta-Coop/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:
|
|
|
|
```bash
|
|
composer require clawdforge/clawdforge
|
|
```
|
|
|
|
To install from a local checkout (e.g. while the SDK lives in this repo):
|
|
|
|
```jsonc
|
|
{
|
|
"repositories": [
|
|
{ "type": "path", "url": "../path/to/clawdforge/clients/php" }
|
|
],
|
|
"require": {
|
|
"clawdforge/clawdforge": "*"
|
|
}
|
|
}
|
|
```
|
|
|
|
## Quickstart
|
|
|
|
```php
|
|
<?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.
|
|
|
|
```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
|
|
|
|
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:
|
|
|
|
```php
|
|
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`:
|
|
|
|
```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`:
|
|
|
|
```php
|
|
'clawdforge' => [
|
|
'url' => env('CLAWDFORGE_URL', 'http://localhost:8800'),
|
|
'token' => env('CLAWDFORGE_TOKEN'),
|
|
],
|
|
```
|
|
|
|
Then inject anywhere:
|
|
|
|
```php
|
|
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
|
|
|
|
```php
|
|
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.
|
|
|
|
```bash
|
|
composer install
|
|
vendor/bin/phpunit
|
|
```
|
|
|
|
`php -l` over every source file:
|
|
|
|
```bash
|
|
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.
|