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
7.2 KiB
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
ForgeClientisCloseableand works insideuse { }
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.isRegularFileresolves 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 withpath.toRealPath()and reject anything outside an allowlisted root before callinguploadFile. - 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-Dispositionheader. Control characters (CR, LF, NUL, ...) in the filename are rejected upfront withIllegalArgumentException; 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 theHttpCacheplugin, which the SDK doesn't install — but if you reach intoForgeOptions.configureand installHttpCacheyourself 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 onktor-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.
ForgeClientis a regular class (no auto-toString());ForgeOptionsdoesn't carry the token. Ktor'sLoggingplugin 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 capturingAuthorizationheaders.
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.