clawdforge/clients/java/README.md
Kayos 9866e97977 clients/java: apply audit findings — true streaming upload + token redaction (0d3ee26 → next)
MEDIUM:
- C1: multipart upload now actually streams via SequenceInputStream + Files.newInputStream. Code comment + README + javadoc updated to match reality. Test added uploading 10 MiB file with received-bytes assertion bounding envelope overhead.
- S1: AppToken.toString() override redacts token (was leaking plaintext via record auto-toString).

LOW:
- C2: RunResult.result null/missing-field handling — canonical-constructor coerces null/NullNode to MissingNode, javadoc updated.
- C3: HTTP timeout lower bound: Math.max(5L, n + 30L).
- C4: ForgeClient implements AutoCloseable (no-op on JDK 17, documented).
- S4: javadoc warning on uploadFile path traversal / symlink follow.

Quality:
- Q1: package-info.java added for com.clawdforge.exception (clears pom.xml dead exclude).
- C7: @JsonInclude(NON_DEFAULT) on POST DTOs (drops wire "created_at": 0).

Deps:
- jackson-databind/core/annotations 2.17.2 → 2.18.2 (2.17 EOL'd Aug 2025).

Tests: 14 → 23 (9 added).

Audit: memory/clawdforge-audits/java-0d3ee26.md
2026-04-28 23:20:45 -07:00

7.3 KiB

clawdforge-client (Java)

Java SDK for clawdforge — a LAN-only HTTP service that wraps claude -p subprocess calls behind a bearer-token-gated REST API.

  • Java 17+ (records, sealed-friendly, switch expressions)
  • HTTP: JDK built-in java.net.http.HttpClient — no Apache HttpClient, no OkHttp
  • JSON: Jackson (jackson-databind 2.x)
  • Build: Maven
  • License: MIT

Install

This SDK isn't published to Maven Central. Build from source and install to your local Maven repo:

git clone http://192.168.0.5:3001/Sulkta-Coop/clawdforge.git
cd clawdforge/clients/java
mvn install

Then depend on it:

<dependency>
    <groupId>com.clawdforge</groupId>
    <artifactId>clawdforge-client</artifactId>
    <version>0.1.0</version>
</dependency>

Quickstart

import com.clawdforge.ForgeClient;
import com.clawdforge.RunRequest;
import com.clawdforge.RunResult;
import com.fasterxml.jackson.databind.JsonNode;

try (ForgeClient client = ForgeClient.builder()
        .baseUrl("http://192.168.0.5:8800")
        .token(System.getenv("CLAWDFORGE_TOKEN"))
        .build()) {

    RunResult res = client.run(RunRequest.builder()
        .prompt("Reply with JSON: {\"hello\": \"world\"}")
        .model("sonnet")
        .timeoutSecs(60)
        .build());

    System.out.println(res.durationMs() + "ms");

    JsonNode r = res.result();
    if (r.isObject()) {
        System.out.println(r.get("hello").asText());
    }
}

ForgeClient is immutable and safe to share across threads — for long-lived multi-tenant deployments, construct once and reuse, rather than relying on close().

Naming & wire format

The clawdforge HTTP API uses snake_case on the wire (timeout_secs, file_token, created_at, ...). This SDK uses idiomatic Java camelCase everywhere; Jackson @JsonProperty annotations on each record handle the mapping. Examples:

Java accessor Wire field
RunRequest#timeoutSecs timeout_secs
RunResult#durationMs duration_ms
RunResult#stopReason stop_reason
FileToken#fileToken file_token
FileToken#ttlSecs ttl_secs
HealthStatus#claudePresent claude_present
HealthStatus#claudeVersion claude_version
AppToken#ipCidrs ip_cidrs
AppToken#createdAt created_at

Public surface

ForgeClient

Build via ForgeClient.builder():

ForgeClient client = ForgeClient.builder()
    .baseUrl("http://192.168.0.5:8800")  // trailing slash trimmed
    .token("cf_...")                     // required bearer token
    .defaultTimeout(Duration.ofSeconds(120))  // optional, default 120s
    .httpClient(myCustomHttpClient)      // optional, default is JDK default
    .build();

Methods (all may throw subclasses of ForgeException):

Method Endpoint Returns
healthz() GET /healthz HealthStatus
run(RunRequest) POST /run RunResult
uploadFile(Path, int ttlSecs) POST /files FileToken
createToken(String name, List<String> ipCidrs) POST /admin/tokens AppToken
listTokens() GET /admin/tokens List<AppToken>
revokeToken(String name) DELETE /admin/tokens/<name> void

RunRequest

Build via RunRequest.builder():

RunRequest req = RunRequest.builder()
    .prompt("...")               // required
    .model("sonnet")             // optional, server default = "sonnet"
    .system("You are ...")       // optional system prompt
    .files(List.of("ff_..."))    // optional file tokens from uploadFile()
    .timeoutSecs(60)             // optional, server clamps to [5, 600]
    .build();

Records

Public state is exposed via Java 17 records:

  • HealthStatus(boolean ok, boolean claudePresent, String claudeVersion)
  • RunResult(boolean ok, JsonNode result, long durationMs, String stopReason)
  • FileToken(String fileToken, int ttlSecs, long size)
  • AppToken(String name, String token, List<String> ipCidrs, long createdAt)

RunResult.result() is a Jackson JsonNode because the server may return either a structured JSON value (when the prompt asked for JSON) or a plain JSON string. Narrow with isObject() / isTextual() / etc.

Error model — why unchecked?

All SDK errors extend ForgeException, which extends RuntimeException. Checked exceptions feel un-modern in Java 17 — they bleed across API boundaries, force throws chains in lambdas, and don't pair well with the records / sealed-interface idioms the rest of this SDK leans on.

ForgeException                    (RuntimeException)
├── ApiException                  (any non-2xx HTTP)
│   └── AuthException             (401 / 403)
└── TransportException            (DNS, connect, IO, decode, timeout)

Catch order:

try {
    client.run(req);
} catch (AuthException e) {
    // token revoked, IP not allowed
} catch (ApiException e) {
    // 502 from /run failure, 404 from missing token, etc.
    // e.statusCode() and e.body() are both available
} catch (TransportException e) {
    // network / IO / JSON-decode failures
} catch (ForgeException e) {
    // catch-all for anything else SDK-level
}

File uploads

uploadFile streams the file body through HttpRequest.BodyPublishers.ofInputStream chained over a SequenceInputStream (multipart preamble + Files.newInputStream(path) + trailing boundary). The file body is never fully resident in heap — peak upload memory is bounded by the HttpClient read-ahead buffer, so a multi-GiB upload won't OOM a default-heap JVM:

FileToken ft = client.uploadFile(Path.of("./recipe.png"), 3600);
client.run(RunRequest.builder()
    .prompt("Extract recipe data from the attached image.")
    .files(List.of(ft.fileToken()))
    .build());

ttlSecs is clamped server-side to [60, 86400]. Pass 0 to use the server default of 3600.

Path safety. uploadFile does not constrain its Path argument to any sandbox root, and Files.isRegularFile follows symlinks by default. Callers that pass user-supplied paths must canonicalise and sandbox them first (e.g. resolve under a known directory and reject any Path whose toRealPath() escapes it).

Lifecycle

ForgeClient implements AutoCloseable for try-with-resources symmetry:

try (ForgeClient client = ForgeClient.builder()
        .baseUrl("http://192.168.0.5:8800")
        .token(System.getenv("CLAWDFORGE_TOKEN"))
        .build()) {
    client.healthz();
}

On JDK 17 HttpClient has no close() (added in JDK 21), so the SDK's close() is currently a no-op — the underlying client is reclaimed when its last reference goes out of scope. For long-lived multi-tenant deployments, prefer constructing a single shared HttpClient (injected via Builder.httpClient(HttpClient)) and reusing it across ForgeClient instances.

Build

mvn package        # produces target/clawdforge-client-0.1.0.jar
mvn test           # runs the JUnit 5 suite
mvn javadoc:javadoc

The test suite uses the JDK built-in com.sun.net.httpserver.HttpServer in-process — no external mock-server dependency.

Threading

ForgeClient instances are immutable and safe to share across threads. Construct once, reuse everywhere.