-
-
Notifications
You must be signed in to change notification settings - Fork 3
feat(claude): OpenCode-local parity — permission bridge + Ask mode #230
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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="" | ||||||||||||||
|
Comment on lines
+99
to
+101
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [bug · medium] |
||||||||||||||
| 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)) | ||||||||||||||
|
Comment on lines
+126
to
+128
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [bug · high] Suggestion:
Suggested change
|
||||||||||||||
| done | ||||||||||||||
|
|
||||||||||||||
| rm -f "$REQUEST_FILE" 2>/dev/null || true | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [other · low] Suggestion:
Suggested change
|
||||||||||||||
| printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","permissionDecision":"deny","permissionDecisionReason":"User did not respond in time"}}' | ||||||||||||||
| exit 0 | ||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,22 +7,26 @@ 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 | ||
| 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<OpenCodeEvent>(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) | ||
|
Comment on lines
+194
to
+198
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [security · high] |
||
|
|
||
| fun answerQuestion( | ||
| requestId: String, | ||
| answers: List<List<String>>, | ||
| ): Boolean { | ||
| val pending = permissionBridge.readPending(requestId) ?: return false | ||
| val question = permissionBridge.toQuestionRequest(pending) ?: return false | ||
| val mapped = linkedMapOf<String, String>() | ||
| 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 -> | ||
|
Comment on lines
+218
to
+223
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [bug · medium] |
||
| 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 | ||
| } | ||
|
Comment on lines
+336
to
+341
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [bug · high] |
||
|
|
||
| val stderrLog = File(runtimeDirectory, "logs/claude-stderr.log").also { it.parentFile?.mkdirs() } | ||
| val process = | ||
|
|
@@ -274,15 +347,16 @@ 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) | ||
| .redirectError(ProcessBuilder.Redirect.appendTo(stderrLog)) | ||
| .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 } | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[security · low]
フォールバックの JSON 生成は
$KIND/$REQUEST_ID/$ANDROID_SESSION/$TOOL_NAME/$LABELを無エスケープで文字列連結して書き出しています。値に\や二重引用符・改行が含まれると、無効な JSON がブリッジ配下に置かれ、Android 側のClaudePermissionBridgeがStoredRequestを復元できず権限確認フローが破綻します。sed 等でエスケープしてから埋め込むようにしてください。Suggestion: