diff --git a/app/src/main/assets/scripts/and-code-claude-permission-hook.sh b/app/src/main/assets/scripts/and-code-claude-permission-hook.sh new file mode 100644 index 00000000..f73f56cb --- /dev/null +++ b/app/src/main/assets/scripts/and-code-claude-permission-hook.sh @@ -0,0 +1,133 @@ +#!/bin/sh +# AndCode PermissionRequest hook for Claude Code. +# Reads hook JSON on stdin, asks the Android app via the file bridge, prints a decision. + +set -eu + +BRIDGE="${ANDCODE_CLAUDE_BRIDGE:-/root/.andcode/claude-bridge}" +PENDING="$BRIDGE/pending" +RESPONSES="$BRIDGE/responses" +ALWAYS="$BRIDGE/always-rules.json" +TIMEOUT_SEC="${ANDCODE_PERMISSION_TIMEOUT_SEC:-300}" +SLEEP_SEC=0.25 + +mkdir -p "$PENDING" "$RESPONSES" + +INPUT=$(cat) +if [ -z "$INPUT" ]; then + exit 0 +fi + +# Prefer jq; fall back to a tiny python helper when present. +if command -v jq >/dev/null 2>&1; then + TOOL_NAME=$(printf '%s' "$INPUT" | jq -r '.tool_name // .toolName // empty') + SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // .sessionId // empty') + TOOL_INPUT=$(printf '%s' "$INPUT" | jq -c '.tool_input // .toolInput // {}') + HOOK_EVENT=$(printf '%s' "$INPUT" | jq -r '.hook_event_name // .hookEventName // "PermissionRequest"') +else + TOOL_NAME=$(printf '%s' "$INPUT" | sed -n 's/.*"tool_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1) + SESSION_ID=$(printf '%s' "$INPUT" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1) + TOOL_INPUT='{}' + HOOK_EVENT="PermissionRequest" +fi + +if [ -z "$TOOL_NAME" ]; then + TOOL_NAME="Tool" +fi + +# Auto-allow remembered rules without waking the UI. +if [ -f "$ALWAYS" ] && command -v jq >/dev/null 2>&1; then + CMD=$(printf '%s' "$TOOL_INPUT" | jq -r '.command // empty') + MATCH=$(jq -r --arg t "$TOOL_NAME" --arg c "$CMD" ' + .rules[]? | select(.toolName == $t) | + if (.commandPrefix == null or .commandPrefix == "") then "yes" + elif ($c | startswith(.commandPrefix)) then "yes" + else empty end + ' "$ALWAYS" 2>/dev/null | head -n1 || true) + if [ "$MATCH" = "yes" ]; then + printf '%s\n' "{\"hookSpecificOutput\":{\"hookEventName\":\"PermissionRequest\",\"permissionDecision\":\"allow\",\"permissionDecisionReason\":\"AndCode always-allow rule\"}}" + exit 0 + fi +fi + +KIND="permission" +if [ "$TOOL_NAME" = "AskUserQuestion" ]; then + KIND="question" +fi + +REQUEST_ID=$(cat /proc/sys/kernel/random/uuid 2>/dev/null || date +%s%N) +ANDROID_SESSION="${ANDCODE_ANDROID_SESSION_ID:-$SESSION_ID}" +if [ -z "$ANDROID_SESSION" ]; then + ANDROID_SESSION="unknown" +fi + +LABEL="$TOOL_NAME" +if command -v jq >/dev/null 2>&1; then + DESC=$(printf '%s' "$TOOL_INPUT" | jq -r '.description // .command // empty' 2>/dev/null || true) + if [ -n "$DESC" ]; then + LABEL="$TOOL_NAME: $DESC" + fi +fi + +REQUEST_FILE="$PENDING/$REQUEST_ID.json" +RESPONSE_FILE="$RESPONSES/$REQUEST_ID.json" + +if command -v jq >/dev/null 2>&1; then + jq -n \ + --arg kind "$KIND" \ + --arg requestId "$REQUEST_ID" \ + --arg androidSessionId "$ANDROID_SESSION" \ + --arg claudeSessionId "$SESSION_ID" \ + --arg toolName "$TOOL_NAME" \ + --arg permissionLabel "$LABEL" \ + --argjson toolInput "$TOOL_INPUT" \ + --argjson createdAtMs "$(date +%s000)" \ + '{v:1,kind:$kind,requestId:$requestId,androidSessionId:$androidSessionId,claudeSessionId:$claudeSessionId,toolName:$toolName,toolInput:$toolInput,permissionLabel:$permissionLabel,createdAtMs:$createdAtMs}' \ + >"$REQUEST_FILE" +else + printf '%s\n' "{\"v\":1,\"kind\":\"$KIND\",\"requestId\":\"$REQUEST_ID\",\"androidSessionId\":\"$ANDROID_SESSION\",\"claudeSessionId\":\"$SESSION_ID\",\"toolName\":\"$TOOL_NAME\",\"toolInput\":{},\"permissionLabel\":\"$LABEL\",\"createdAtMs\":0}" >"$REQUEST_FILE" +fi + +elapsed=0 +while [ "$elapsed" -lt "$TIMEOUT_SEC" ]; do + if [ -f "$RESPONSE_FILE" ]; then + if command -v jq >/dev/null 2>&1; then + DECISION=$(jq -r '.decision // "deny"' "$RESPONSE_FILE") + MESSAGE=$(jq -r '.message // empty' "$RESPONSE_FILE") + UPDATED=$(jq -c '.updatedInput // empty' "$RESPONSE_FILE") + else + DECISION=$(sed -n 's/.*"decision"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$RESPONSE_FILE" | head -n1) + MESSAGE="" + UPDATED="" + fi + rm -f "$REQUEST_FILE" "$RESPONSE_FILE" 2>/dev/null || true + if [ "$DECISION" = "allow" ]; then + if [ -n "$UPDATED" ] && [ "$UPDATED" != "null" ] && [ "$UPDATED" != "" ]; then + if command -v jq >/dev/null 2>&1; then + jq -n --argjson updated "$UPDATED" \ + '{hookSpecificOutput:{hookEventName:"PermissionRequest",permissionDecision:"allow",updatedInput:$updated}}' + else + printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","permissionDecision":"allow"}}' + fi + else + printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","permissionDecision":"allow"}}' + fi + exit 0 + fi + REASON=${MESSAGE:-User rejected this action} + if command -v jq >/dev/null 2>&1; then + jq -n --arg reason "$REASON" \ + '{hookSpecificOutput:{hookEventName:"PermissionRequest",permissionDecision:"deny",permissionDecisionReason:$reason}}' + else + printf '%s\n' "{\"hookSpecificOutput\":{\"hookEventName\":\"PermissionRequest\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"$REASON\"}}" + fi + exit 0 + fi + # shellcheck disable=SC2039 + sleep "$SLEEP_SEC" 2>/dev/null || sleep 1 + elapsed=$((elapsed + 1)) +done + +rm -f "$REQUEST_FILE" 2>/dev/null || true +printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","permissionDecision":"deny","permissionDecisionReason":"User did not respond in time"}}' +exit 0 diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudeCodeInstaller.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudeCodeInstaller.kt index 5c74030f..84ddc50d 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudeCodeInstaller.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudeCodeInstaller.kt @@ -102,7 +102,7 @@ object ClaudeCodeInstaller { ) = """ $apk update ${repairBrokenPackages(apk)} - if ! $apk add --no-cache claude-code util-linux; then + if ! $apk add --no-cache claude-code util-linux jq; then if [ -z "$S($apk info -e claude-code)" ] || [ -z "$S($apk info -e util-linux)" ]; then echo 'and-code: apk failed and the requested packages are not installed' >&2 exit 1 @@ -196,7 +196,7 @@ object ClaudeCodeInstaller { $APK policy claude-code 2>&1 || true """.trimIndent() - private val INSTALL_DIAGNOSTICS_SCRIPT = diagnosticsScript("add -s claude-code util-linux") + private val INSTALL_DIAGNOSTICS_SCRIPT = diagnosticsScript("add -s claude-code util-linux jq") private val UPDATE_DIAGNOSTICS_SCRIPT = diagnosticsScript("add -s --upgrade claude-code") diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudeCodeRuntime.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudeCodeRuntime.kt index 703cd4b8..f57915cf 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudeCodeRuntime.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudeCodeRuntime.kt @@ -7,15 +7,18 @@ import com.yugahashimoto.andcode.core.api.OpenCodePart import com.yugahashimoto.andcode.core.api.OpenCodeTime import com.yugahashimoto.andcode.core.api.OpenCodeTodo import com.yugahashimoto.andcode.core.api.PromptAttachment +import com.yugahashimoto.andcode.runtime.PermissionResponse import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.serialization.builtins.MapSerializer import kotlinx.serialization.builtins.serializer @@ -23,6 +26,7 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject import java.io.File import java.util.UUID @@ -56,6 +60,9 @@ class ClaudeCodeRuntime( private val events = MutableSharedFlow(extraBufferCapacity = 256) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val messageStore = ClaudeMessageStore(File(runtimeDirectory, "claude-messages.json"), json) + internal val permissionBridge = + ClaudePermissionBridge(File(runtimeDirectory, ClaudePermissionBridge.HOST_DIR_NAME)) + private var bridgeWatchJob: Job? = null /** One live CLI process, plus what is needed to decide whether it can be reused. */ private class SessionProcess( @@ -178,6 +185,64 @@ class ClaudeCodeRuntime( } } + /** True when the PermissionRequest hook is present in the guest rootfs. */ + fun permissionBridgeReady(): Boolean { + val rootfs = installedRuntimeProvider()?.rootfs ?: return false + return ClaudePermissionHooks.isInstalled(rootfs) + } + + fun respondToPermission( + permissionId: String, + response: PermissionResponse, + remember: Boolean, + ): Boolean = permissionBridge.respond(permissionId, response, remember) + + fun answerQuestion( + requestId: String, + answers: List>, + ): Boolean { + val pending = permissionBridge.readPending(requestId) ?: return false + val question = permissionBridge.toQuestionRequest(pending) ?: return false + val mapped = linkedMapOf() + question.questions.forEachIndexed { index, prompt -> + val selected = answers.getOrNull(index).orEmpty().joinToString(", ") + if (selected.isNotBlank()) mapped[prompt.question] = selected + } + val questionsJson = + runCatching { + json.parseToJsonElement(pending.toolInputJson).jsonObject["questions"]?.toString() ?: "[]" + }.getOrDefault("[]") + return permissionBridge.answerQuestion(requestId, questionsJson, mapped) + } + + private fun ensureBridgeWatcher() { + if (bridgeWatchJob?.isActive == true) return + bridgeWatchJob = + scope.launch { + while (isActive) { + permissionBridge.pollPending().forEach { request -> + when (request.kind) { + ClaudePermissionBridge.Kind.QUESTION -> { + val question = permissionBridge.toQuestionRequest(request) + if (question != null) { + events.tryEmit(OpenCodeEvent.QuestionAsked(question)) + } else { + events.tryEmit( + OpenCodeEvent.PermissionAsked(permissionBridge.toPermissionRequest(request)), + ) + } + } + else -> + events.tryEmit( + OpenCodeEvent.PermissionAsked(permissionBridge.toPermissionRequest(request)), + ) + } + } + delay(250) + } + } + } + fun update() { val runtime = installedRuntimeProvider() ?: error(messages.runtimeMissing) cachedVersion = null @@ -266,6 +331,14 @@ class ClaudeCodeRuntime( val runtime = installedRuntimeProvider() ?: error(messages.runtimeMissing) require(ClaudeCodeInstaller.isInstalledIn(runtime.rootfs)) { "Claude Code is not installed" } ClaudeCodeInstaller.ensureDnsPreload(runtime.rootfs) + ensureBridgeWatcher() + + val effectiveMode = + if (permissionMode.requiresBridge && !ClaudePermissionHooks.isInstalled(runtime.rootfs)) { + ClaudePermissionMode.ACCEPT_EDITS + } else { + permissionMode + } val stderrLog = File(runtimeDirectory, "logs/claude-stderr.log").also { it.parentFile?.mkdirs() } val process = @@ -274,7 +347,7 @@ class ClaudeCodeRuntime( runtime = runtime, workspaceHostDir = File(runtimeDirectory, "workspace").apply { mkdirs() }, workingDirectory = directory, - arguments = processArguments(sessionId, permissionMode, model, effort), + arguments = processArguments(sessionId, effectiveMode, model, effort), pty = false, ), ).directory(runtimeDirectory) @@ -282,7 +355,8 @@ class ClaudeCodeRuntime( .apply { environment().clear() environment().putAll( - ClaudeSandboxLauncher.environment(runtime, File(runtimeDirectory, "proot-tmp").apply { mkdirs() }, githubToken()), + ClaudeSandboxLauncher.environment(runtime, File(runtimeDirectory, "proot-tmp").apply { mkdirs() }, githubToken()) + + mapOf("ANDCODE_ANDROID_SESSION_ID" to sessionId), ) } .start() @@ -304,7 +378,7 @@ class ClaudeCodeRuntime( } } - return SessionProcess(process, readerJob, permissionMode, directory, model, effort) + return SessionProcess(process, readerJob, effectiveMode, directory, model, effort) .also { sessions[sessionId] = it } } diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudeCodeTarget.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudeCodeTarget.kt index f03e9cdb..2e87efb7 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudeCodeTarget.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudeCodeTarget.kt @@ -20,6 +20,7 @@ import com.yugahashimoto.andcode.core.api.PromptRequest import com.yugahashimoto.andcode.runtime.BackendKind import com.yugahashimoto.andcode.runtime.LocalAgent import com.yugahashimoto.andcode.runtime.PermissionResponse +import com.yugahashimoto.andcode.runtime.RuntimeCapabilities import com.yugahashimoto.andcode.runtime.RuntimeState import com.yugahashimoto.andcode.runtime.RuntimeTarget import com.yugahashimoto.andcode.runtime.RuntimeType @@ -63,6 +64,17 @@ class ClaudeCodeTarget( override val kind = BackendKind.LOCAL override val type = RuntimeType.LOCAL + override val capabilities: RuntimeCapabilities + get() { + val bridge = runtime.permissionBridgeReady() + return RuntimeCapabilities( + permissions = bridge, + questions = bridge, + toolEvents = true, + resume = true, + ) + } + private val mutableState = MutableStateFlow(RuntimeState.Disconnected) override val state: StateFlow = mutableState.asStateFlow() @@ -303,27 +315,40 @@ class ClaudeCodeTarget( } /** - * Permission responses are not part of this runtime's contract. + * Answers a PermissionRequest that the guest hook parked on the file bridge. * - * Streaming-JSON mode has no channel for answering an individual tool prompt, so permissions are - * decided per session through [ClaudePermissionMode] instead. Reporting false keeps the chat - * layer from believing a prompt was answered. + * Returns false when the bridge has no matching request (already timed out or unknown id). */ override suspend fun respondToPermission( sessionId: String, permissionId: String, response: PermissionResponse, remember: Boolean, - ): Boolean = false + ): Boolean = withContext(Dispatchers.IO) { runtime.respondToPermission(permissionId, response, remember) } override suspend fun answerQuestion( requestId: String, answers: List>, directory: String?, - ): Boolean = false + ): Boolean = withContext(Dispatchers.IO) { runtime.answerQuestion(requestId, answers) } override fun events(): Flow = runtime.events() + /** + * Working-tree diff for the session's workspace. + * + * Claude Code has no server-side sessionDiff; the workspace git diff is the equivalent surface + * OpenCode exposes for local review. + */ + override suspend fun sessionDiff( + sessionId: String, + directory: String?, + messageId: String?, + ): List { + val dir = directory ?: records[sessionId]?.session?.directory ?: WORKSPACE_ROOT + return vcsDiff(dir, mode = "unified", context = 3) + } + // OpenCode answers these over HTTP. Claude Code has no server, but /workspace is a real // directory on the device, so they are read from disk instead of throwing "unsupported". override suspend fun listFiles( diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudePermissionBridge.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudePermissionBridge.kt new file mode 100644 index 00000000..aad99a32 --- /dev/null +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudePermissionBridge.kt @@ -0,0 +1,321 @@ +package com.yugahashimoto.andcode.runtime.local + +import com.yugahashimoto.andcode.core.api.PermissionRequest +import com.yugahashimoto.andcode.core.api.QuestionOption +import com.yugahashimoto.andcode.core.api.QuestionPrompt +import com.yugahashimoto.andcode.core.api.QuestionRequest +import com.yugahashimoto.andcode.runtime.PermissionResponse +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +/** + * File bridge between Claude Code's PermissionRequest hook (guest) and the Android approval UI. + * + * The hook writes a request under [hostRoot]/pending], the app surfaces it as a + * [PermissionRequest]/[QuestionRequest], and [respond]/[answerQuestion] writes the matching + * response the hook is polling for. + */ +class ClaudePermissionBridge( + hostRoot: File, + private val json: Json = + Json { + ignoreUnknownKeys = true + isLenient = true + encodeDefaults = true + }, +) { + enum class Kind { + PERMISSION, + QUESTION, + ELICITATION, + } + + data class Request( + val requestId: String, + val androidSessionId: String, + val kind: Kind, + val toolName: String, + val toolInputJson: String, + val permissionLabel: String, + val claudeSessionId: String? = null, + ) + + @Serializable + data class StoredRequest( + val v: Int = 1, + val kind: String, + val requestId: String, + val androidSessionId: String, + val claudeSessionId: String? = null, + val toolName: String, + val toolInput: JsonElement = JsonObject(emptyMap()), + val permissionLabel: String = "", + val createdAtMs: Long = System.currentTimeMillis(), + ) + + @Serializable + data class StoredResponse( + val v: Int = 1, + val decision: String, + val remember: Boolean = false, + val message: String? = null, + val updatedInput: JsonElement? = null, + val answers: JsonElement? = null, + ) { + val answersJson: String? + get() = answers?.toString() + } + + @Serializable + private data class AlwaysRule( + val toolName: String, + val commandPrefix: String? = null, + ) + + @Serializable + private data class AlwaysRulesFile( + val rules: List = emptyList(), + ) + + private val root = hostRoot.apply { mkdirs() } + private val pendingDir = File(root, "pending").apply { mkdirs() } + private val responsesDir = File(root, "responses").apply { mkdirs() } + private val alwaysFile = File(root, "always-rules.json") + private val emitted = ConcurrentHashMap.newKeySet() + + fun writeGuestRequest(request: Request) { + val toolInput = + runCatching { json.parseToJsonElement(request.toolInputJson) } + .getOrDefault(JsonObject(emptyMap())) + val stored = + StoredRequest( + kind = request.kind.name.lowercase(), + requestId = request.requestId, + androidSessionId = request.androidSessionId, + claudeSessionId = request.claudeSessionId, + toolName = request.toolName, + toolInput = toolInput, + permissionLabel = request.permissionLabel.ifBlank { request.toolName }, + ) + val file = File(pendingDir, "${request.requestId}.json") + file.parentFile?.mkdirs() + file.writeText(json.encodeToString(stored)) + } + + fun pollPending(): List { + val files = pendingDir.listFiles().orEmpty().filter { it.extension == "json" }.sortedBy { it.name } + val out = mutableListOf() + for (file in files) { + val stored = runCatching { json.decodeFromString(file.readText()) }.getOrNull() ?: continue + if (!emitted.add(stored.requestId)) continue + out += + Request( + requestId = stored.requestId, + androidSessionId = stored.androidSessionId, + kind = + when (stored.kind.lowercase()) { + "question" -> Kind.QUESTION + "elicitation" -> Kind.ELICITATION + else -> Kind.PERMISSION + }, + toolName = stored.toolName, + toolInputJson = stored.toolInput.toString(), + permissionLabel = stored.permissionLabel.ifBlank { stored.toolName }, + claudeSessionId = stored.claudeSessionId, + ) + } + return out + } + + fun respond( + requestId: String, + response: PermissionResponse, + remember: Boolean, + message: String? = null, + ): Boolean { + val pending = File(pendingDir, "$requestId.json") + if (!pending.isFile && !File(responsesDir, "$requestId.json").isFile) { + // Still allow writing if the guest already cleaned pending but we know the id. + if (!emitted.contains(requestId) && !pending.isFile) return false + } + val storedRequest = + pending.takeIf { it.isFile }?.let { + runCatching { json.decodeFromString(it.readText()) }.getOrNull() + } + val decision = + when (response) { + PermissionResponse.REJECT -> "deny" + PermissionResponse.ONCE, PermissionResponse.ALWAYS -> "allow" + } + val rememberFlag = remember || response == PermissionResponse.ALWAYS + if (rememberFlag && storedRequest != null) { + rememberRule(storedRequest) + } + writeResponse( + requestId, + StoredResponse( + decision = decision, + remember = rememberFlag, + message = message ?: if (decision == "deny") "User rejected this action" else null, + ), + ) + return true + } + + fun answerQuestion( + requestId: String, + questionsJson: String, + answers: Map, + ): Boolean { + val questions = + runCatching { json.parseToJsonElement(questionsJson) } + .getOrDefault(JsonArray(emptyList())) + val answersElement = + buildJsonObject { + answers.forEach { (key, value) -> put(key, value) } + } + writeResponse( + requestId, + StoredResponse( + decision = "allow", + updatedInput = + buildJsonObject { + put("questions", questions) + put("answers", answersElement) + }, + answers = answersElement, + ), + ) + return true + } + + fun readResponse(requestId: String): StoredResponse? { + val file = File(responsesDir, "$requestId.json") + if (!file.isFile) return null + return runCatching { json.decodeFromString(file.readText()) }.getOrNull() + } + + fun readPending(requestId: String): Request? { + val file = File(pendingDir, "$requestId.json") + if (!file.isFile) return null + val stored = runCatching { json.decodeFromString(file.readText()) }.getOrNull() ?: return null + return Request( + requestId = stored.requestId, + androidSessionId = stored.androidSessionId, + kind = + when (stored.kind.lowercase()) { + "question" -> Kind.QUESTION + "elicitation" -> Kind.ELICITATION + else -> Kind.PERMISSION + }, + toolName = stored.toolName, + toolInputJson = stored.toolInput.toString(), + permissionLabel = stored.permissionLabel.ifBlank { stored.toolName }, + claudeSessionId = stored.claudeSessionId, + ) + } + + fun isAlwaysAllowed( + toolName: String, + toolInputJson: String, + ): Boolean { + val rules = loadAlwaysRules() + val command = + runCatching { + json.parseToJsonElement(toolInputJson).jsonObject["command"]?.jsonPrimitive?.contentOrNull + }.getOrNull() + return rules.any { rule -> + rule.toolName == toolName && + (rule.commandPrefix.isNullOrBlank() || (command?.startsWith(rule.commandPrefix) == true)) + } + } + + fun toPermissionRequest(request: Request): PermissionRequest = + PermissionRequest( + id = request.requestId, + sessionId = request.androidSessionId, + permission = request.permissionLabel.ifBlank { request.toolName }, + patterns = listOf(request.toolName), + metadata = + mapOf( + "toolName" to JsonPrimitive(request.toolName), + "toolInput" to runCatching { json.parseToJsonElement(request.toolInputJson) }.getOrDefault(JsonObject(emptyMap())), + "kind" to JsonPrimitive(request.kind.name.lowercase()), + ), + ) + + fun toQuestionRequest(request: Request): QuestionRequest? { + if (request.kind != Kind.QUESTION) return null + val root = runCatching { json.parseToJsonElement(request.toolInputJson).jsonObject }.getOrNull() ?: return null + val questions = + root["questions"]?.jsonArray?.mapNotNull { element -> + val obj = element as? JsonObject ?: return@mapNotNull null + val question = obj["question"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null + val options = + obj["options"]?.jsonArray?.mapNotNull { opt -> + val o = opt as? JsonObject ?: return@mapNotNull null + val label = o["label"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null + QuestionOption(label = label, description = o["description"]?.jsonPrimitive?.contentOrNull) + }.orEmpty() + QuestionPrompt( + question = question, + header = obj["header"]?.jsonPrimitive?.contentOrNull, + options = options, + multiple = obj["multiSelect"]?.jsonPrimitive?.contentOrNull == "true", + custom = true, + ) + }.orEmpty() + if (questions.isEmpty()) return null + return QuestionRequest(id = request.requestId, sessionId = request.androidSessionId, questions = questions) + } + + private fun writeResponse( + requestId: String, + response: StoredResponse, + ) { + responsesDir.mkdirs() + val file = File(responsesDir, "$requestId.json") + val tmp = File(responsesDir, "$requestId.json.tmp") + tmp.writeText(json.encodeToString(response)) + if (!tmp.renameTo(file)) { + file.writeText(json.encodeToString(response)) + tmp.delete() + } + } + + private fun rememberRule(stored: StoredRequest) { + val command = + (stored.toolInput as? JsonObject)?.get("command")?.jsonPrimitive?.contentOrNull + val prefix = command?.split(" ")?.take(2)?.joinToString(" ") + val existing = loadAlwaysRules().toMutableList() + val rule = AlwaysRule(toolName = stored.toolName, commandPrefix = prefix) + if (existing.none { it.toolName == rule.toolName && it.commandPrefix == rule.commandPrefix }) { + existing += rule + alwaysFile.writeText(json.encodeToString(AlwaysRulesFile(existing))) + } + } + + private fun loadAlwaysRules(): List { + if (!alwaysFile.isFile) return emptyList() + return runCatching { json.decodeFromString(alwaysFile.readText()).rules } + .getOrDefault(emptyList()) + } + + companion object { + const val GUEST_BRIDGE_PATH = "/root/.andcode/claude-bridge" + const val HOST_DIR_NAME = "claude-bridge" + } +} diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudePermissionHooks.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudePermissionHooks.kt new file mode 100644 index 00000000..032f33b8 --- /dev/null +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudePermissionHooks.kt @@ -0,0 +1,130 @@ +package com.yugahashimoto.andcode.runtime.local + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import java.io.File + +/** + * Installs AndCode's Claude Code PermissionRequest hook into the guest settings and binary path. + */ +object ClaudePermissionHooks { + const val HOOK_GUEST_PATH = "/usr/local/bin/and-code-claude-permission-hook.sh" + const val HOOK_MARKER = "and-code-claude-permission" + private const val SETTINGS_RELATIVE = "root/.claude/settings.json" + private const val HOOK_RELATIVE = "usr/local/bin/and-code-claude-permission-hook.sh" + + private val json = + Json { + ignoreUnknownKeys = true + isLenient = true + prettyPrint = true + } + + fun settingsFragment(): String = + """ + { + "hooks": { + "PermissionRequest": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "$HOOK_GUEST_PATH", + "timeout": 360, + "statusMessage": "Waiting for AndCode approval" + } + ] + } + ] + } + } + """.trimIndent() + + fun mergeSettingsJson(existing: String?): String { + val root = + if (existing.isNullOrBlank()) { + buildJsonObject {} + } else { + runCatching { json.parseToJsonElement(existing).jsonObject } + .getOrDefault(buildJsonObject {}) + } + val hooks = root["hooks"]?.jsonObject ?: buildJsonObject {} + val permissionGroups = hooks["PermissionRequest"]?.jsonArray?.toMutableList() ?: mutableListOf() + permissionGroups.removeAll { group -> + group.jsonObject["hooks"]?.jsonArray?.any { handler -> + handler.jsonObject["command"]?.jsonPrimitive?.contentOrNull?.contains(HOOK_MARKER) == true + } == true + } + permissionGroups += + buildJsonObject { + put("matcher", "*") + put( + "hooks", + buildJsonArray { + add( + buildJsonObject { + put("type", "command") + put("command", HOOK_GUEST_PATH) + put("timeout", 360) + put("statusMessage", "Waiting for AndCode approval") + }, + ) + }, + ) + } + val mergedHooks = + buildJsonObject { + hooks.forEach { (key, value) -> + if (key != "PermissionRequest") put(key, value) + } + put("PermissionRequest", JsonArray(permissionGroups)) + } + return json.encodeToString( + JsonObject.serializer(), + buildJsonObject { + root.forEach { (key, value) -> + if (key != "hooks") put(key, value) + } + put("hooks", mergedHooks) + }, + ) + } + + /** + * Writes the hook script and merges settings into [rootfs]. + * + * @return true when the guest is ready for interactive approvals. + */ + fun installInto( + rootfs: File, + hookScript: String, + ): Boolean { + return runCatching { + val scriptFile = File(rootfs, HOOK_RELATIVE).apply { parentFile?.mkdirs() } + scriptFile.writeText(hookScript) + scriptFile.setExecutable(true, false) + val settingsFile = File(rootfs, SETTINGS_RELATIVE).apply { parentFile?.mkdirs() } + val existing = settingsFile.takeIf { it.isFile }?.readText() + settingsFile.writeText(mergeSettingsJson(existing)) + scriptFile.isFile && settingsFile.isFile + }.getOrDefault(false) + } + + fun isInstalled(rootfs: File): Boolean { + val script = File(rootfs, HOOK_RELATIVE) + val settings = File(rootfs, SETTINGS_RELATIVE) + if (!script.isFile) return false + val text = settings.takeIf { it.isFile }?.readText().orEmpty() + return text.contains(HOOK_MARKER) + } +} diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudePermissionMode.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudePermissionMode.kt index e033318a..f8c7e1f6 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudePermissionMode.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudePermissionMode.kt @@ -5,10 +5,9 @@ import com.yugahashimoto.andcode.R /** * How Claude Code handles tool permissions for a session. * - * Claude Code's streaming-JSON mode has no channel for answering a per-call permission prompt - * without hosting an MCP permission tool, so the decision is made once per session instead. The - * CLI's own `default` mode is deliberately not offered: with no prompt channel it can only deny, - * which looks like a hang to the user. + * When AndCode's PermissionRequest hook bridge is installed, [ASK] routes unmatched tools to the + * Android approval UI. Without the bridge, prefer [ACCEPT_EDITS] (pre-approves Bash) so the CLI + * does not hang waiting for a prompt nobody can answer. */ enum class ClaudePermissionMode( val cliValue: String, @@ -17,25 +16,33 @@ enum class ClaudePermissionMode( /** * Tools pre-approved for the session, passed as `--allowedTools`. * - * Without this, `acceptEdits` asks before every command and the answer never arrives: git, gh - * and everything else simply stop, with Claude explaining that it needs approval nobody can - * give. Naming the tools up front is how a transport with no prompt channel says yes. + * Used when the mode auto-approves a subset and still needs a prompt channel for the rest. + * [ASK] leaves this empty so every unmatched call reaches the PermissionRequest hook. */ val allowedTools: List = emptyList(), + /** True when this mode expects the AndCode permission bridge to answer prompts. */ + val requiresBridge: Boolean = false, ) { PLAN("plan", R.string.claude_permission_plan, R.string.claude_permission_plan_desc), + ASK( + "default", + R.string.claude_permission_ask, + R.string.claude_permission_ask_desc, + requiresBridge = true, + ), ACCEPT_EDITS( "acceptEdits", R.string.claude_permission_accept_edits, R.string.claude_permission_accept_edits_desc, - // Commands, but still inside Claude Code's own permission system: unlike full access it - // keeps the checks that stop it writing outside the directories it was given. + // Commands stay auto-approved so Accept edits remains useful without prompting every Bash. + // File edits are covered by acceptEdits itself; other tools may still hit the bridge. allowedTools = listOf("Bash"), ), FULL_ACCESS("bypassPermissions", R.string.claude_permission_full_access, R.string.claude_permission_full_access_desc), ; companion object { + /** Safe default until the user opts into [ASK] after the bridge is ready. */ val DEFAULT = ACCEPT_EDITS fun fromCliValue(value: String?): ClaudePermissionMode = entries.firstOrNull { it.cliValue == value } ?: DEFAULT diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudeSandboxLauncher.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudeSandboxLauncher.kt index a56ffd19..50660adb 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudeSandboxLauncher.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudeSandboxLauncher.kt @@ -74,6 +74,10 @@ object ClaudeSandboxLauncher { add("/system") add("-b") add("${workspaceHostDir.absolutePath}:/workspace") + // PermissionRequest hook ↔ Android approval UI file bridge. + val bridgeHost = File(workspaceHostDir.parentFile, ClaudePermissionBridge.HOST_DIR_NAME).apply { mkdirs() } + add("-b") + add("${bridgeHost.absolutePath}:${ClaudePermissionBridge.GUEST_BRIDGE_PATH}") // Empty until the user grants all-files access, so the sandbox is unchanged without it. addAll(DeviceStorage.bindArguments()) add("-w") @@ -98,5 +102,6 @@ object ClaudeSandboxLauncher { "BUN_OPTIONS" to "--preload ${ClaudeCodeInstaller.DNS_PRELOAD}", "TERM" to "xterm-256color", "CI" to "1", + "ANDCODE_CLAUDE_BRIDGE" to ClaudePermissionBridge.GUEST_BRIDGE_PATH, ) } diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeInstaller.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeInstaller.kt index 3ec965c7..a45832a9 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeInstaller.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeInstaller.kt @@ -153,6 +153,7 @@ class LocalRuntimeInstaller( if (LocalAgent.CLAUDE_CODE in requestedAgents) { onClaude(0.93f, context.getString(R.string.install_step_installing_claude_code)) ClaudeCodeInstaller.installInto(rootfs, commandSuite, runtimeDirectory) + provisionClaudePermissionHook(rootfs) } if (LocalAgent.ANTIGRAVITY in requestedAgents) { onAntigravity(0.94f, context.getString(R.string.install_step_downloading_antigravity)) @@ -451,9 +452,20 @@ class LocalRuntimeInstaller( .forEach { rootfs -> installAndroidHelperScripts(rootfs) provisionBrowserMcp(rootfs) + provisionClaudePermissionHook(rootfs) } } + /** Installs the Claude Code PermissionRequest hook into an Alpine rootfs. */ + fun provisionClaudePermissionHook(rootfs: File = File(runtimeDirectory, "environment/rootfs")) { + if (!rootfs.isDirectory) return + runCatching { + val script = + context.assets.open("scripts/and-code-claude-permission-hook.sh").bufferedReader().use { it.readText() } + ClaudePermissionHooks.installInto(rootfs, script) + } + } + /** * Registers the guest-browser MCP server with every agent (OpenCode, Claude Code, * Antigravity) so they all expose the same browser_* tools. User-added servers are kept. diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 929a08ed..0d203e92 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -839,6 +839,8 @@ يمكن لـ Antigravity تنفيذ أي أمر دون سؤال. استخدمه بحذر. التخطيط فقط يقرأ Claude ويخطّط فقط دون تعديل الملفات أو تنفيذ الأوامر. + السؤال في كل مرة + يسأل Claude قبل الأدوات التي تحتاج موافقة. اسمح أو ارفض من التطبيق أو الإشعار. قبول التعديلات يمكن لـ Claude تعديل الملفات وتنفيذ الأوامر في مساحة العمل دون سؤال. موصى به. وصول كامل diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 6aa8d2f2..5b337197 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -839,6 +839,8 @@ Antigravity puede ejecutar cualquier comando sin preguntar. Úsalo con cuidado. Solo planificar Claude lee y planifica, pero nunca edita archivos ni ejecuta comandos. + Preguntar cada vez + Claude pregunta antes de herramientas que requieren aprobación. Permite o rechaza en la app o la notificación. Aceptar ediciones Claude puede editar archivos y ejecutar comandos en el espacio de trabajo sin preguntar. Recomendado. Acceso completo diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index e8c71fb4..1cac2deb 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -839,6 +839,8 @@ Antigravity peut exécuter n\'importe quelle commande sans demander. À utiliser avec prudence. Planification seule Claude lit et planifie, sans jamais modifier de fichiers ni exécuter de commandes. + Demander à chaque fois + Claude demande avant les outils nécessitant une approbation. Autorisez ou refusez dans l’app ou la notification. Accepter les modifications Claude peut modifier des fichiers et exécuter des commandes dans l\'espace de travail sans demander. Recommandé. Accès complet diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index fe8bd49d..10c9902d 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -851,6 +851,8 @@ Antigravityが確認なしにあらゆるコマンドを実行できます。取り扱いにご注意ください。 プランのみ Claudeは読み取りと計画のみ行い、ファイル編集やコマンド実行はしません。 + 毎回確認 + 承認が必要なツールの前に確認します。アプリまたは通知で許可/拒否できます。 編集を許可 Claudeがワークスペース内のファイル編集とコマンド実行を確認なしに行えます。推奨設定です。 フルアクセス diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index f286f47a..c1548251 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -839,6 +839,8 @@ O Antigravity pode executar qualquer comando sem perguntar. Use com cuidado. Apenas planejar O Claude lê e planeja, mas nunca edita arquivos nem executa comandos. + Perguntar sempre + O Claude pergunta antes de ferramentas que precisam de aprovação. Permita ou recuse no app ou na notificação. Aceitar edições O Claude pode editar arquivos e executar comandos no espaço de trabalho sem perguntar. Recomendado. Acesso total diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 774ba22c..c2845c5c 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -839,6 +839,8 @@ Antigravity может выполнять любые команды без подтверждения. Используйте с осторожностью. Только планирование Claude только читает и планирует, не изменяя файлы и не выполняя команды. + Спрашивать каждый раз + Claude спрашивает перед инструментами, требующими одобрения. Разрешите или отклоните в приложении или уведомлении. Разрешить правки Claude может изменять файлы и выполнять команды в рабочей папке без подтверждения. Рекомендуется. Полный доступ diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 844f5aba..46556231 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -839,6 +839,8 @@ Antigravity 可以在不询问的情况下运行任何命令。请谨慎使用。 仅制定计划 Claude 只读取和规划,不会修改文件或执行命令。 + 每次询问 + 在需要批准的工具前询问。可在应用或通知中允许或拒绝。 允许编辑 Claude 可在工作区内编辑文件并运行命令,无需确认。推荐。 完全访问 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 455cbbf4..3d9c6210 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -851,6 +851,8 @@ Antigravity may run any command without asking. Use with care. Plan only Claude reads and plans but never edits files or runs commands. + Ask each time + Claude asks before tools that need approval. Approve or reject in the app or notification. Accept edits Claude may edit files and run commands in the workspace without asking. Recommended. Full access diff --git a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/ClaudeCodeInstallerTest.kt b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/ClaudeCodeInstallerTest.kt index 90f4be37..6acb4c74 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/ClaudeCodeInstallerTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/ClaudeCodeInstallerTest.kt @@ -215,7 +215,7 @@ class ClaudeCodeInstallerTest { runPackageCommands( ClaudeCodeInstaller::installPackageCommands, "ADD_STATUS" to "1", - "INSTALLED" to "claude-code util-linux", + "INSTALLED" to "claude-code util-linux jq", ) assertEquals(0, run.exitCode) diff --git a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/ClaudePermissionBridgeTest.kt b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/ClaudePermissionBridgeTest.kt new file mode 100644 index 00000000..fe66f645 --- /dev/null +++ b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/ClaudePermissionBridgeTest.kt @@ -0,0 +1,163 @@ +package com.yugahashimoto.andcode.runtime.local + +import com.yugahashimoto.andcode.runtime.PermissionResponse +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.util.UUID + +class ClaudePermissionBridgeTest { + @get:Rule + val folder = TemporaryFolder() + + @Test + fun `pollPending surfaces a new permission request once`() { + val bridge = ClaudePermissionBridge(folder.root) + val requestId = UUID.randomUUID().toString() + bridge.writeGuestRequest( + ClaudePermissionBridge.Request( + requestId = requestId, + androidSessionId = "session-1", + kind = ClaudePermissionBridge.Kind.PERMISSION, + toolName = "Bash", + toolInputJson = """{"command":"ls"}""", + permissionLabel = "Bash", + ), + ) + + val first = bridge.pollPending() + assertEquals(1, first.size) + assertEquals(requestId, first.single().requestId) + assertEquals("Bash", first.single().toolName) + assertTrue(first.single().permissionLabel.contains("Bash")) + + assertTrue(bridge.pollPending().isEmpty()) + } + + @Test + fun `respond writes a response the hook can read`() { + val bridge = ClaudePermissionBridge(folder.root) + val requestId = UUID.randomUUID().toString() + bridge.writeGuestRequest( + ClaudePermissionBridge.Request( + requestId = requestId, + androidSessionId = "session-1", + kind = ClaudePermissionBridge.Kind.PERMISSION, + toolName = "Bash", + toolInputJson = "{}", + permissionLabel = "Bash", + ), + ) + bridge.pollPending() + + assertTrue(bridge.respond(requestId, PermissionResponse.ONCE, remember = false)) + val response = bridge.readResponse(requestId) + assertNotNull(response) + assertEquals("allow", response!!.decision) + assertFalse(response.remember) + } + + @Test + fun `reject writes deny`() { + val bridge = ClaudePermissionBridge(folder.root) + val requestId = UUID.randomUUID().toString() + bridge.writeGuestRequest( + ClaudePermissionBridge.Request( + requestId = requestId, + androidSessionId = "session-1", + kind = ClaudePermissionBridge.Kind.PERMISSION, + toolName = "Bash", + toolInputJson = "{}", + permissionLabel = "Bash", + ), + ) + bridge.pollPending() + assertTrue(bridge.respond(requestId, PermissionResponse.REJECT, remember = false, message = "nope")) + assertEquals("deny", bridge.readResponse(requestId)!!.decision) + assertEquals("nope", bridge.readResponse(requestId)!!.message) + } + + @Test + fun `always allow records a remembered rule`() { + val bridge = ClaudePermissionBridge(folder.root) + val requestId = UUID.randomUUID().toString() + bridge.writeGuestRequest( + ClaudePermissionBridge.Request( + requestId = requestId, + androidSessionId = "session-1", + kind = ClaudePermissionBridge.Kind.PERMISSION, + toolName = "Bash", + toolInputJson = """{"command":"git status"}""", + permissionLabel = "Bash", + ), + ) + bridge.pollPending() + assertTrue(bridge.respond(requestId, PermissionResponse.ALWAYS, remember = true)) + assertTrue(bridge.isAlwaysAllowed("Bash", """{"command":"git status"}""")) + assertFalse(bridge.isAlwaysAllowed("Write", """{"file_path":"a"}""")) + } + + @Test + fun `answer question writes answers payload`() { + val bridge = ClaudePermissionBridge(folder.root) + val requestId = UUID.randomUUID().toString() + bridge.writeGuestRequest( + ClaudePermissionBridge.Request( + requestId = requestId, + androidSessionId = "session-1", + kind = ClaudePermissionBridge.Kind.QUESTION, + toolName = "AskUserQuestion", + toolInputJson = """{"questions":[{"question":"Pick?","options":[{"label":"A"}]}]}""", + permissionLabel = "AskUserQuestion", + ), + ) + bridge.pollPending() + assertTrue( + bridge.answerQuestion( + requestId, + questionsJson = """[{"question":"Pick?","options":[{"label":"A"}]}]""", + answers = mapOf("Pick?" to "A"), + ), + ) + val response = bridge.readResponse(requestId)!! + assertEquals("allow", response.decision) + assertNotNull(response.answersJson) + assertTrue(response.answersJson!!.contains("Pick?")) + } + + @Test + fun `unknown request id returns false`() { + val bridge = ClaudePermissionBridge(folder.root) + assertFalse(bridge.respond("missing", PermissionResponse.ONCE, remember = false)) + assertNull(bridge.readResponse("missing")) + } + + @Test + fun `hook settings fragment marks and-code permission hook`() { + val fragment = ClaudePermissionHooks.settingsFragment() + assertTrue(fragment.contains("PermissionRequest")) + assertTrue(fragment.contains(ClaudePermissionHooks.HOOK_GUEST_PATH)) + assertTrue(fragment.contains("and-code-claude-permission")) + } + + @Test + fun `mergeSettings injects hook without dropping existing hooks`() { + val existing = + """ + { + "hooks": { + "Stop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "echo hi" }] }] + } + } + """.trimIndent() + val merged = ClaudePermissionHooks.mergeSettingsJson(existing) + assertTrue(merged.contains("echo hi")) + assertTrue(merged.contains("PermissionRequest")) + assertTrue(merged.contains(ClaudePermissionHooks.HOOK_GUEST_PATH)) + } +} diff --git a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/ClaudePermissionModeTest.kt b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/ClaudePermissionModeTest.kt index 6ce1f748..55da9464 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/ClaudePermissionModeTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/ClaudePermissionModeTest.kt @@ -6,15 +6,19 @@ import org.junit.Test class ClaudePermissionModeTest { @Test - fun `accept edits pre-approves commands because nothing can answer a prompt`() { - // Without this the CLI asks before every command and the answer never arrives: git and gh - // stopped working while Claude explained it needed approval the transport cannot give. + fun `accept edits pre-approves bash so common commands keep working`() { assertEquals(listOf("Bash"), ClaudePermissionMode.ACCEPT_EDITS.allowedTools) } + @Test + fun `ask mode uses default CLI permission mode and needs the bridge`() { + assertEquals("default", ClaudePermissionMode.ASK.cliValue) + assertTrue(ClaudePermissionMode.ASK.requiresBridge) + assertTrue(ClaudePermissionMode.ASK.allowedTools.isEmpty()) + } + @Test fun `plan approves nothing`() { - // Plan is the mode that is meant to stop before acting, so silence is correct there. assertTrue(ClaudePermissionMode.PLAN.allowedTools.isEmpty()) } @@ -29,5 +33,11 @@ class ClaudePermissionModeTest { assertEquals(ClaudePermissionMode.DEFAULT, ClaudePermissionMode.fromCliValue("nonsense")) assertEquals(ClaudePermissionMode.DEFAULT, ClaudePermissionMode.fromCliValue(null)) assertEquals(ClaudePermissionMode.PLAN, ClaudePermissionMode.fromCliValue("plan")) + assertEquals(ClaudePermissionMode.ASK, ClaudePermissionMode.fromCliValue("default")) + } + + @Test + fun `default remains accept edits until ask is opted in`() { + assertEquals(ClaudePermissionMode.ACCEPT_EDITS, ClaudePermissionMode.DEFAULT) } } diff --git a/docs/CLAUDE_CODE.md b/docs/CLAUDE_CODE.md index 9166f1f3..9ea4997a 100644 --- a/docs/CLAUDE_CODE.md +++ b/docs/CLAUDE_CODE.md @@ -99,18 +99,25 @@ stable line structure, and the heuristics needed to read it misfire on ordinary ## Permissions -Streaming-JSON mode has no channel for answering an individual tool prompt without hosting an MCP -permission tool, so permissions are decided per session via `--permission-mode`: +Per-tool approvals use Claude Code's **PermissionRequest hook** bridged to the Android approval UI +(chat chip + notification). AndCode installs `and-code-claude-permission-hook.sh` into the guest and +merges a `PermissionRequest` handler into `~/.claude/settings.json`. The hook writes a request under +`/root/.andcode/claude-bridge` (bind-mounted from the app runtime directory); the app responds with +allow/deny JSON the hook is polling for. | Mode | CLI value | Effect | | --- | --- | --- | | Plan only | `plan` | Reads and plans; never edits or runs commands | -| Accept edits (default) | `acceptEdits` | May edit files in the workspace | +| Ask each time | `default` | Unmatched tools prompt in AndCode (requires the hook bridge) | +| Accept edits (default) | `acceptEdits` | Auto file ops; Bash pre-approved via `--allowedTools` | | Full access | `bypassPermissions` | Runs any command without asking | -The CLI's own `default` mode is not offered: with no prompt channel it can only deny, which is -indistinguishable from a hang. `ClaudeCodeTarget.respondToPermission` therefore returns false rather -than pretending a prompt was answered. +If the hook is missing (older install not yet re-provisioned), Ask mode falls back to Accept edits +for that process so the CLI cannot hang. `ClaudeCodeTarget.capabilities.permissions` / `questions` +are true only when the bridge is installed. "Always allow" stores a rule in the bridge's +`always-rules.json` so matching tools skip the UI on later turns. + +See `docs/superpowers/specs/2026-08-08-claude-code-opencode-local-parity-design.md`. ## Sign-in diff --git a/docs/superpowers/plans/2026-08-08-claude-code-opencode-local-parity.md b/docs/superpowers/plans/2026-08-08-claude-code-opencode-local-parity.md new file mode 100644 index 00000000..b95514fe --- /dev/null +++ b/docs/superpowers/plans/2026-08-08-claude-code-opencode-local-parity.md @@ -0,0 +1,20 @@ +# Claude Code OpenCode-local parity Implementation Plan + +> **For agentic workers:** Implemented inline from the approved design. + +**Goal:** On-device Claude Code gains interactive per-tool approvals via PermissionRequest hook bridge, sessionDiff, and Ask permission mode — toward OpenCode local confidence. + +**Architecture:** Guest hook writes pending requests under a bind-mounted bridge dir; Kotlin `ClaudePermissionBridge` polls and maps to existing Permission/Question UI; responses write back for the hook. + +**Tech Stack:** Kotlin, Claude Code CLI hooks, PRoot bind mounts, JUnit. + +## Delivered + +- [x] `ClaudePermissionBridge` + tests +- [x] Guest hook script + settings merge (`ClaudePermissionHooks`) +- [x] Sandbox bind mount + env +- [x] Runtime watcher + `respondToPermission` / `answerQuestion` +- [x] `ClaudePermissionMode.ASK` + strings (8 locales) +- [x] `sessionDiff` via workspace git +- [x] Install path provisions hook + `jq` +- [x] Docs: `docs/CLAUDE_CODE.md` + design spec diff --git a/docs/superpowers/specs/2026-08-08-claude-code-opencode-local-parity-design.md b/docs/superpowers/specs/2026-08-08-claude-code-opencode-local-parity-design.md new file mode 100644 index 00000000..6fb85acb --- /dev/null +++ b/docs/superpowers/specs/2026-08-08-claude-code-opencode-local-parity-design.md @@ -0,0 +1,301 @@ +# Claude Code → OpenCode local parity (on-device) + +**Date:** 2026-08-08 +**Status:** Draft for review +**Scope:** On-device Claude Code only. Remote PC and Antigravity are out of scope. +**Goal:** Claude Code on Android feels as trustworthy as local OpenCode: stable stream/resume, real per-tool approvals and questions, and the same day-to-day workspace surfaces. + +## Problem + +OpenCode is the reference agent: HTTP + SSE, `RuntimeCapabilities(permissions=true, providerModelList=true)`, live multi-provider catalog, MCP connect/OAuth, session archive/summarize/diff. + +Claude Code is a long-lived process bridge (`claude --print --input-format stream-json --output-format stream-json`). It already covers chat, attachments, git, MCP list/add, commands/skills, and session permission modes — but: + +1. **Stability lag (Beta signal)** — duplicated assistant text, stuck tool spinners, activity key collisions when tool ids reuse, resume “session already in use”, failed activity not settling. +2. **No interactive permission/question channel** — stream-json has no `canUseTool` callback (that lives in the Agent SDK). Docs and code deliberately omit CLI `default` mode because unmatched tools hang or deny with no UI. `respondToPermission` / `answerQuestion` return false; `capabilities.permissions` and `questions` stay false. +3. **Surface gaps vs OpenCode local** — sessionDiff, archive/summarize, MCP connect toggle + OAuth, model catalog depth (fixed aliases), subagent session track (optional stretch). + +User choice (brainstorming): **on-device first**, **Claude Code first**, success bar **C = OpenCode local equivalent** including per-tool approvals/questions. + +## Non-goals + +- Remote PC for Claude or Antigravity (separate product: PC-side bridge). +- Antigravity parity (follow-up after Claude). +- Replacing Claude CLI with the TypeScript/Python Agent SDK on device (Node weight, dual install, auth divergence). Prefer the installed `claude` binary. +- Full OpenCode sub-agent parent/child UI unless it falls out of stream events cheaply. +- Changing OpenCode behavior. + +## Approach (chosen) + +**Phase 0 — Stability** then **Phase 1 — Hook-based permission/question bridge** then **Phase 2 — Remaining OpenCode-local surfaces**. + +Rejected alternatives: + +| Option | Why not | +| --- | --- | +| Session modes only + shell parity | Never reaches bar C; Beta label stays honest. | +| Ship Agent SDK (Node) beside Alpine | Heavy, auth/session split, two runtimes to maintain. | +| “MCP permission tool” only | Older idea in `docs/CLAUDE_CODE.md`; Claude Code now documents **PermissionRequest hooks** as the CLI-native interactive path. Hooks are first-class for `-p` / stream-json. | + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Android app │ +│ Chat / Activity / Notifications (existing Permission UI) │ +│ ▲ PermissionAsked / QuestionAsked │ +│ │ respondToPermission / answerQuestion │ +│ ClaudeCodeTarget (capabilities.permissions/questions=true) │ +│ ▲ │ +│ ClaudePermissionBridge (Kotlin) │ +│ - watches bridge dir under runtimeDirectory │ +│ - maps request JSON → PermissionRequest / QuestionRequest│ +│ - writes response JSON for waiting hook │ +└─────────────┬───────────────────────────────────────────────┘ + │ host FS (PRoot bind: runtime dir ↔ guest path) +┌─────────────▼───────────────────────────────────────────────┐ +│ Alpine guest │ +│ claude --print stream-json --permission-mode │ +│ │ PermissionRequest / AskUserQuestion │ +│ ▼ │ +│ and-code-claude-permission-hook.sh (command hook) │ +│ - stdin: hook JSON │ +│ - write request file, poll response file (timeout) │ +│ - stdout: hookSpecificOutput allow/deny (+ answers) │ +│ ~/.claude/settings.json (AndCode-managed hooks block) │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Why hooks, not SDK `canUseTool` + +AndCode already drives the **CLI** with stream-json stdin/stdout. Interactive approvals for that path are documented as: + +- **`PermissionRequest` hook** — fires when a tool needs a permission decision; handler returns allow/deny. +- **`AskUserQuestion`** — appears as a tool; can be approved with `updatedInput` containing `answers` (same shape as SDK docs). +- **`Elicitation`** — MCP user input; map if present, otherwise deny with reason. + +The hook runs inside the guest as a shell command. It cannot call Kotlin directly; it uses a **file bridge** on a directory both sides already share (same pattern as transcript/session records under the runtime directory). + +### Bridge protocol (v1) + +Directory (guest): `/root/.andcode/claude-bridge//` +(Host path via existing runtime directory mapping.) + +**Request file** (hook writes, exclusive create): +`pending/.json` + +```json +{ + "v": 1, + "kind": "permission", + "requestId": "uuid", + "androidSessionId": "…", + "claudeSessionId": "…", + "toolName": "Bash", + "toolInput": { "command": "git status", "description": "…" }, + "createdAtMs": 0 +} +``` + +`kind` values: `permission` | `question` | `elicitation`. + +**Response file** (app writes): +`responses/.json` + +```json +{ + "v": 1, + "decision": "allow", + "remember": false, + "message": null, + "updatedInput": null, + "answers": null +} +``` + +- `decision`: `allow` | `deny` | `timeout` (app may write timeout; hook also times out client-side). +- `remember: true` → map to OpenCode “always” / apply a session-scoped allow rule when CLI supports `updatedPermissions` from hooks; if not, app records allow-list in bridge and hook short-circuits matching tools (see Remember). +- Questions: `decision: allow` + `answers` object + pass-through `questions` in `updatedInput`. + +**Hook behavior:** + +1. Parse stdin JSON; detect event (`PermissionRequest` vs tool name `AskUserQuestion`). +2. Create request file; optionally touch `notify` for inotify-less poll. +3. Poll for response file up to **N seconds** (default 300; configurable). Spinner via hook `statusMessage` if supported. +4. On allow: emit hook JSON with `permissionDecision: "allow"` (and `updatedInput` when needed). +5. On deny/timeout: `permissionDecision: "deny"` + reason string Claude can read. +6. Always clean up request/response pair (best-effort). + +**Concurrency:** one pending request per session is normal; multiple parallel tool calls may spawn parallel hooks. Bridge uses unique `requestId`s; app shows a queue (existing `permissions: List` already supports multiple). + +### Permission modes after the bridge + +| Mode | CLI value | With bridge | +| --- | --- | --- | +| Plan | `plan` | Unchanged; writes still need approval via bridge when CLI asks | +| **Ask (new default for interactive)** | `default` | Unmatched tools → PermissionRequest → Android UI | +| Accept edits | `acceptEdits` | Auto file ops; Bash/network still ask via bridge | +| Full access | `bypassPermissions` | No prompts (existing dangerous mode + warning) | + +- Add **`DEFAULT` / Ask** to `ClaudePermissionMode` and make it the product default once bridge health-checks pass. +- Keep Accept edits and Full access. +- If bridge fails to install or hook errors at session start, **fall back** to current Accept-edits + `allowedTools` behavior and set `capabilities.permissions=false` for that session so UI does not show dead approval chrome. +- Update `docs/CLAUDE_CODE.md` Permissions section: replace “no channel” with hook bridge description. + +### Mapping to existing Android types + +| Bridge | App | +| --- | --- | +| `kind=permission` | `OpenCodeEvent.PermissionAsked(PermissionRequest)` | +| `kind=question` | `OpenCodeEvent.QuestionAsked(QuestionRequest)` | +| User Once | `respondToPermission(..., ONCE, remember=false)` → allow | +| User Always | `remember=true` → allow + persist rule | +| User Reject | deny + message | +| Notification actions | existing `RuntimeNotificationHelper` paths | + +`ClaudeCodeTarget.respondToPermission` becomes real: resolve pending bridge entry, write response file. +`answerQuestion` same for questions. + +`RuntimeCapabilities` for Claude: + +```kotlin +RuntimeCapabilities( + permissions = bridgeReady, + questions = bridgeReady, + toolEvents = true, // already true via stream + resume = true, +) +``` + +### Remember (always allow) + +Preferred: if PermissionRequest hook output supports permission rule updates (CLI version-dependent), pass them through. + +Fallback (must work on pinned Alpine package): + +- App stores `always` rules per workspace: `toolName` + optional command prefix. +- Hook checks `always-rules.json` before prompting; matching calls auto-allow without Android round-trip. + +### Install / lifecycle + +On Claude install and every session process start: + +1. Ensure `jq` (or pure shell JSON) available in guest — prefer `jq` via apk if missing. +2. Install hook script to fixed path under guest AndCode share dir (not project `.claude/` only — works for any workspace). +3. Merge hooks into **user** settings `~/.claude/settings.json` under a namespaced marker so AndCode can re-merge without clobbering user hooks: + +```json +{ + "hooks": { + "PermissionRequest": [ /* and-code matcher group */ ], + "PreToolUse": [ /* optional: only AskUserQuestion if PermissionRequest insufficient */ ] + } +} +``` + +4. Do not disable user/project hooks; merge arrays. +5. Diagnostics: Claude agent settings card shows “Interactive approvals: ready / degraded”. + +## Phase 0 — Stability (before or parallel with bridge UI) + +Must land before claiming parity: + +1. **Stream parser** — keep #224-class fixes: tool_result routed to originating assistant message; no duplicate text parts; settle open tools on `result` / error / process death. +2. **Activity keys** — unique group keys when Claude reuses tool call ids (#226). +3. **Resume ids** — single source of truth for `--session-id` vs `--resume`; never double-create; clear “session already in use” on relaunch after crash. +4. **Process death** — emit SessionError + idle; clear stuck spinners; no orphan busy state. +5. **Regression tests** — golden stream-json fixtures for: partial text + tool_use + tool_result; reused tool ids; result after kill; resume handshake. + +## Phase 2 — Remaining OpenCode-local surfaces + +After bridge + stability: + +| Feature | Plan | +| --- | --- | +| **sessionDiff** | Shell `git diff` / ClaudeWorkspaceGit extended; same models as OpenCode UI expects | +| **Session archive / delete bulk** | App-side session store flags (Claude has no server archive API) | +| **Session summarize** | Optional one-shot `claude -p` summarize of transcript; or hide action when unsupported | +| **MCP connect toggle** | If `claude mcp` supports enable/disable, wire it; else document delete-only and hide toggle (`supportsConnectToggle=false` stays) | +| **MCP OAuth** | `claude mcp login` with same PTY URL+code pattern as auth login when CLI ≥ required version | +| **Models** | Keep aliases; if CLI exposes model list in `system/init`, parse and populate picker; no fake multi-provider auth UI | +| **Subagent text** | Optional `--forward-subagent-text` when version supports; display nested tools under parent | +| **In-app maturity** | When Phase 0+1 acceptance pass on device matrix, drop README Beta for Claude or change to “Stable (on-device)” | + +## Error handling + +| Failure | Behavior | +| --- | --- | +| Hook timeout | Deny tool with “User did not respond in time”; toast on Android | +| Bridge dir not writable | Degrade capabilities; log; do not start in `default` mode | +| Malformed hook stdin | exit 0 no decision only if safe; prefer deny with reason for PermissionRequest | +| App killed mid-prompt | Hook times out → deny; on relaunch no stale pending UI | +| User force-stops Claude process | Abort pending bridge requests as deny | + +## Testing + +**Unit** + +- Bridge request/response serialization. +- Hook script with fixture stdin → writes request; with injected response → correct stdout JSON (run under host shell in CI where possible; guest script syntax-checked). +- `ClaudeStreamJsonParser` fixtures (Phase 0). +- `ClaudeCodeTarget.respondToPermission` writes response and clears pending. + +**Instrumented / device** + +- Sign-in smoke (existing). +- Prompt that triggers Bash under Ask mode → notification + chat chip → Allow once → command runs. +- Reject → Claude sees denial and continues without hang. +- AskUserQuestion path if model emits it in plan mode. +- Resume after process kill mid-turn. +- Accept edits mode still auto-edits without prompt. +- Full access still skips bridge prompts. + +**Non-goals for test** + +- Full multi-provider OpenCode catalog parity. +- Remote. + +## Rollout + +1. Land Phase 0 behind no flag (bugfixes). +2. Land bridge + Ask mode; default remains Accept edits until device validation checklist green, then switch default to Ask. +3. Phase 2 incrementally; each feature gated by capability flags. +4. Update README agent table and `docs/CLAUDE_CODE.md` when acceptance criteria met. +5. Antigravity follow-up reuses bridge pattern only if `agy` gains equivalent hooks (likely different design). + +## Acceptance criteria (done means) + +- [ ] No known class of stuck tool spinner / duplicated assistant bubble on fixture suite + manual smoke. +- [ ] Resume after force-stop works without “session already in use”. +- [ ] Ask permission mode: dangerous Bash prompts Android UI; Allow / Always / Reject work; notifications work. +- [ ] `capabilities.permissions == true` and `questions == true` when bridge ready. +- [ ] Plan / Accept edits / Full access still available and documented. +- [ ] sessionDiff available for Claude workspace (or explicit unsupported UI, not crash). +- [ ] `docs/CLAUDE_CODE.md` matches implementation. +- [ ] Device validation notes for arm64 + emulator in `docs/DEVICE_VALIDATION.md` or Claude section. + +## Key files (expected touch list) + +- `runtime/local/ClaudeCodeRuntime.kt`, `ClaudeStreamJsonParser.kt`, `ClaudeCodeTarget.kt` +- `runtime/local/ClaudePermissionMode.kt`, new `ClaudePermissionBridge.kt`, guest hook script under `assets` or `runtime_tools` +- `runtime/RuntimeCapabilities.kt` usage sites +- `feature/chat/*` only if question UI needs Claude-specific fields +- `docs/CLAUDE_CODE.md`, README agent table +- Tests under `app/src/test/.../runtime/local/` + +## Open risks + +1. **Pinned `claude-code` apk version** may lag docs (PermissionRequest hook shape). Mitigation: version-gate bridge; probe with a dry-run hook; degrade gracefully. +2. **Hook timeout vs long user away** — 300s default; align with notification; optional extend. +3. **Parallel PermissionRequest hooks** — file bridge must be race-safe (unique ids, atomic create). +4. **User `~/.claude/settings.json` merge** — must not destroy existing hooks; use idempotent merge keyed by AndCode command path. +5. **jq dependency** — add to Claude install package set or write minimal JSON with python/node if present; prefer apk `jq`. + +## Implementation order (for writing-plans) + +1. Phase 0 parser/resume/activity tests + fixes +2. Bridge protocol + hook script + settings merge +3. Wire Target capabilities + respond/answer +4. Mode enum + default switch strategy +5. sessionDiff + MCP OAuth/toggle as available +6. Docs + device validation + README maturity