clawdforge/clients/kotlin
Kayos 3c77ef523e clients/kotlin: apply audit findings — per-call HTTP timeout + token redaction (cc54cfb → next)
MEDIUM:
- B1: per-call HTTP timeout on /run via Ktor request-scoped timeout block — RunRequest.timeoutSecs > defaultTimeout no longer HTTP-disconnects

LOW:
- L3: AppToken.toString() redacts plaintext token (preserves null distinguishability)
- L4: uploadFile validates filename has no control chars; typed IllegalArgumentException upfront
- L5: RunResult.resultAsObjectOrNull / resultAsTextOrNull added (matched KDoc claim)
- L1/L2: KDoc + README docs for symlink-follow + TOCTOU on uploadFile

Dep:
- ktor 2.3.12 → 2.3.13 — clears CVE-2024-49580 (HttpCache, plugin not used) by version-range

Tests added: runHttpTimeoutHonorsPerCallTimeoutSecs, appTokenToStringRedactsTokenWhenSet (+ null preserve), uploadFileRejectsControlCharFilename, runResultAsObjectOrNull/AsTextOrNull, revokeTokenEmptyName, closeIdempotent.

Audit: memory/clawdforge-audits/kotlin-cc54cfb.md
2026-04-28 23:33:08 -07:00
..
examples/main/kotlin clients/kotlin: initial Kotlin SDK for clawdforge 2026-04-28 23:04:24 -07:00
gradle/wrapper clients/kotlin: initial Kotlin SDK for clawdforge 2026-04-28 23:04:24 -07:00
src clients/kotlin: apply audit findings — per-call HTTP timeout + token redaction (cc54cfb → next) 2026-04-28 23:33:08 -07:00
.gitignore clients/kotlin: initial Kotlin SDK for clawdforge 2026-04-28 23:04:24 -07:00
build.gradle.kts clients/kotlin: apply audit findings — per-call HTTP timeout + token redaction (cc54cfb → next) 2026-04-28 23:33:08 -07:00
gradle.properties clients/kotlin: initial Kotlin SDK for clawdforge 2026-04-28 23:04:24 -07:00
gradlew clients/kotlin: initial Kotlin SDK for clawdforge 2026-04-28 23:04:24 -07:00
gradlew.bat clients/kotlin: initial Kotlin SDK for clawdforge 2026-04-28 23:04:24 -07:00
README.md clients/kotlin: apply audit findings — per-call HTTP timeout + token redaction (cc54cfb → next) 2026-04-28 23:33:08 -07:00
settings.gradle.kts clients/kotlin: initial Kotlin SDK for clawdforge 2026-04-28 23:04:24 -07:00

clawdforge-kotlin

Async Kotlin client for the clawdforge HTTP service — a LAN-only bearer-token-gated REST wrapper around claude -p subprocess calls.

  • Kotlin 1.9+, JVM 17 target
  • Coroutines — every I/O method is suspend. No blocking variants.
  • Ktor Client (CIO engine) for HTTP, kotlinx.serialization for JSON
  • ForgeClient is Closeable and works inside use { }

Install

The jar is published to your local Maven repo; consume it from any JVM build.

./gradlew publishToMavenLocal

Then in the consumer's build.gradle.kts:

repositories {
    mavenLocal()
    mavenCentral()
}

dependencies {
    implementation("com.clawdforge:clawdforge:0.1.0")
}

You'll also pull Ktor + kotlinx-serialization transitively (they're on the public API surface — RunResult.result is a JsonElement).

Quickstart

import com.clawdforge.ForgeClient
import com.clawdforge.RunRequest
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.nio.file.Path

suspend fun main() {
    ForgeClient(
        baseUrl = "http://localhost:8800",
        token   = System.getenv("CLAWDFORGE_TOKEN"),
    ).use { client ->

        val h = client.healthz()
        println(h.claudeVersion)

        val res = client.run(RunRequest(
            prompt      = """Reply with JSON: {"hello": "world"}""",
            model       = "sonnet",
            timeoutSecs = 60,
        ))
        println("duration: ${res.durationMs}ms")
        println(res.result.jsonObject["hello"]?.jsonPrimitive?.content)

        // Upload + reference a file
        val ft = client.uploadFile(Path.of("./recipe.png"), ttlSecs = 3600)
        client.run(RunRequest(
            prompt = "Extract recipe data from the attached image.",
            files  = listOf(ft.fileToken),
        ))
    }
}

Public API

All methods are suspend — call them from a coroutine scope.

Method HTTP Notes
healthz() GET /healthz No bearer required; IP allowlist still applies.
run(RunRequest) POST /run 200 → RunResult; 502 → ForgeRunFailureException.
uploadFile(Path, ttlSecs?) POST /files Streams via Ktor's submitFormWithBinaryData.
createToken(CreateTokenRequest) POST /admin/tokens Admin bootstrap token only.
listTokens() GET /admin/tokens Admin only.
revokeToken(name) DELETE /admin/tokens/{name} Admin only.
close() Disposes the underlying Ktor client.

Constructor

ForgeClient(
    baseUrl: String,
    token:   String,
    options: ForgeOptions = ForgeOptions(),
)

ForgeOptions knobs:

data class ForgeOptions(
    val defaultModel:   String  = "sonnet",
    val defaultTimeout: Duration = 60.seconds,
    val requestMargin:  Duration = 30.seconds,
    val engine:         HttpClientEngine? = null, // override (e.g. MockEngine in tests)
    val configure:     (HttpClientConfig<*>.() -> Unit)? = null,
)

JSON wire format

The server speaks snake_case (timeout_secs, claude_version, file_token, ip_cidrs). The Kotlin SDK exposes idiomatic camelCase properties and uses @SerialName to bridge — callers never see the snake_case names:

@Serializable
data class RunRequest(
    val prompt: String,
    val model: String? = null,
    val system: String? = null,
    val files: List<String>? = null,
    @SerialName("timeout_secs") val timeoutSecs: Int? = null,
)

RunResult.result is a kotlinx.serialization.json.JsonElement — the server may return either a parsed JSON object/array (when the prompt asked for JSON) or a plain string. Narrow it at the call site:

val obj = res.result.jsonObject                       // throws if not object
val txt = res.result.jsonPrimitive.contentOrNull      // null if not primitive

Errors

A sealed exception hierarchy makes when exhaustive:

try {
    client.run(req)
} catch (e: ForgeException) {
    when (e) {
        is ForgeAuthException        -> rotateToken()                            // 401/403
        is ForgeRunFailureException  -> log("claude died: ${e.errorMessage}")    // 502
        is ForgeApiException         -> log("api ${e.statusCode}: ${e.body}")    // other 4xx/5xx
        is ForgeTransportException   -> backoffAndRetry()                        // network/decode
    }
}

Cancellation propagates correctly: CancellationException is rethrown unmodified, so structured concurrency (timeouts, parent-cancel) keep working.

File uploads stream

uploadFile(path, ttlSecs) uses Ktor's submitFormWithBinaryData with an InputProvider backed by the file's InputStream. A 100 MB file uses roughly the buffer size of heap, not 100 MB.

A few caller-trust assumptions worth flagging:

  • Symlinks are followed. Files.isRegularFile resolves symlinks by default, so a symlink-to-/etc/passwd (or anywhere else readable by the process) will be accepted and streamed upstream. If you're relaying paths from untrusted input, resolve them yourself with path.toRealPath() and reject anything outside an allowlisted root before calling uploadFile.
  • Don't mutate the file mid-upload. The size is sampled once for the multipart Content-Length, then the stream is opened separately. Live edits between those two reads will produce a truncated or padded body on the wire.
  • Filename safety. The leaf filename is interpolated into a Content-Disposition header. Control characters (CR, LF, NUL, ...) in the filename are rejected upfront with IllegalArgumentException; embedded " is stripped. Anything else is passed through verbatim.

Security

  • CVE-2024-49580 (ktor-client-core). Cleared by the Ktor 2.3.13 bump. The vulnerable code path is in the HttpCache plugin, which the SDK doesn't install — but if you reach into ForgeOptions.configure and install HttpCache yourself on a client linked against an older Ktor, you'd expose yourself.
  • CVE-2025-29904 (ktor-server-*). Server-side request smuggling. The SDK only depends on ktor-client-*, so this is not exposed by the client surface. There is no 2.3.x backport from JetBrains; if/when this SDK migrates to Ktor 3.x it will pick up the patch alongside.
  • Bearer token never logged. ForgeClient is a regular class (no auto-toString()); ForgeOptions doesn't carry the token. Ktor's Logging plugin is not installed by default. AppToken.toString() redacts the plaintext when it's set. If you're consuming this library and adding logging, double-check you're not capturing Authorization headers.

Builds & tests

./gradlew build              # compile + tests + jar
./gradlew test               # tests only
./gradlew publishToMavenLocal

Tests use Ktor's MockEngine — no network, no extra deps.

Compatibility

  • JVM 17+ (toolchain pinned in build.gradle.kts)
  • Kotlin 1.9.25
  • Ktor 2.3.x
  • kotlinx-serialization 1.6.x
  • kotlinx-coroutines 1.8.x

Multiplatform isn't enabled out of the box, but the only JVM-only choices in the SDK are the CIO engine and java.nio.file.Path for uploadFile. A KMP fork would replace those with okio.Path and a per-target engine.

License

MIT.