From 7a22bb606781d4cd9aa8a5050155e7848db42f56 Mon Sep 17 00:00:00 2001 From: intech Date: Sat, 13 Jun 2026 15:44:19 +0400 Subject: [PATCH] feat: opt-in Remote Control + bake a default statusLine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remote Control: `--remote-control` was hard-wired into the entrypoint, but it needs a full-scope login token (`claude auth login`) — the long-lived CLAUDE_CODE_OAUTH_TOKEN / `claude setup-token` this image uses is inference-only, so RC was a dead default (Status: disabled). Make it opt-in: the flag is added only when CLAUDE_REMOTE_CONTROL=1. Docs (README, SECURITY, CLAUDE.md, .env.example) updated to state the token requirement. statusLine: bake statusline-command.sh to /usr/local/bin/claude-statusline.sh (fixed, HOME-independent path) and wire settings.local.json's statusLine.command to it, so the compact status line (dir, git, model, duration, context %, 5h/7d rate limits) works by default. Deps (jq, git, awk, date, grep) already in the image. Validated on a fresh local build (amd64): - statusLine: piping a sample JSON to claude-statusline.sh renders the full line ( /workspace · Opus 4.8 · 1h30m · 73% · 19.7k · 5h 82% · 7d 58% ) - RC off by default: entrypoint logs "Starting Claude Code in /workspace..." (no --remote-control); with CLAUDE_REMOTE_CONTROL=1 it announces RC requested - settings.local.json statusLine wired; script is 0755 at the fixed path Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 5 ++ CLAUDE.md | 12 +++- Dockerfile | 26 +++++++-- README.md | 7 ++- SECURITY.md | 10 ++-- settings.local.json | 5 ++ statusline-command.sh | 131 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 181 insertions(+), 15 deletions(-) create mode 100755 statusline-command.sh diff --git a/.env.example b/.env.example index 4e8589d..9586b7f 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,8 @@ CONTEXT7_API_KEY=your_context7_api_key_here # Perplexity - Required for the Perplexity MCP server # Get your API key from https://www.perplexity.ai/settings/api PERPLEXITY_API_KEY=your_perplexity_api_key_here + +# Remote Control (optional, OFF by default). Set to 1 to add --remote-control to +# the entrypoint. NOTE: it needs a full-scope login token (`claude auth login`); +# the inference-only CLAUDE_CODE_OAUTH_TOKEN above cannot drive Remote Control. +# CLAUDE_REMOTE_CONTROL=1 diff --git a/CLAUDE.md b/CLAUDE.md index c01bb34..aedcef9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -216,6 +216,9 @@ pnpm 11 vs Node 20 mismatch was caught before the base was bumped to Node 22). **Runtime variables** (set when running container): - `CLAUDE_CODE_OAUTH_TOKEN` - OAuth token for Claude Code authentication (required) +- `CLAUDE_REMOTE_CONTROL` - set to `1` to add `--remote-control` to the entrypoint (off by default). + Requires a full-scope login token (`claude auth login`); the inference-only `CLAUDE_CODE_OAUTH_TOKEN` + cannot drive Remote Control, so this is a no-op with it. - `MCP_TIMEOUT` - MCP server connection timeout in milliseconds (default: `10000` = 10 seconds) - All variables from `.env` file are automatically passed to the container @@ -236,8 +239,10 @@ pnpm 11 vs Node 20 mismatch was caught before the base was bumped to Node 22). 1. **Run from your project**: `cd /path/to/repo && ./run_claude.sh`. The current directory is mounted **read-write** at `/workspace`; the container runs as your host user (`--user $(id -u):$(id -g)`). -2. **Entrypoint**: `claude --dangerously-skip-permissions --remote-control`; the entrypoint first - copies the baked agent state from `/home/claude` into the writable tmpfs HOME. +2. **Entrypoint**: `claude --dangerously-skip-permissions`; the entrypoint first + copies the baked agent state from `/home/claude` into the writable tmpfs HOME. Remote Control is + opt-in: `--remote-control` is added only when `CLAUDE_REMOTE_CONTROL=1` (it needs a full-scope + login token; the inference-only `CLAUDE_CODE_OAUTH_TOKEN` cannot drive it). 3. **Autonomous agent**: Claude edits/commits the project directly in `/workspace`. For `git push`, set `DEPLOY_KEY=/path/to/repo_deploy_key` (scoped, read-only mounted). Commit identity comes from your host `git config` (passed as env). @@ -379,7 +384,8 @@ Inside the debug shell, you can run diagnostics manually: - `tools/package.json` - Pinned npm CLI toolchain (claude-code, openspec, codegraph, caveman-shrink, MCP servers, dev tools) — exact versions, single source of truth - `tools/package-lock.json` - Lockfile (sha512 integrity) for the toolchain; installed via `npm ci`. Regenerate inside node:22 after editing package.json - `claude-config.json` - Claude Code configuration with all permissions -- `settings.local.json` - Local Claude settings +- `settings.local.json` - Local Claude settings; also wires the `statusLine` to `/usr/local/bin/claude-statusline.sh` +- `statusline-command.sh` - Claude Code statusLine script (compact line: dir, git branch/dirty, model, duration, context %, 5h/7d rate limits); baked to `/usr/local/bin/claude-statusline.sh` (fixed, HOME-independent path). Deps (jq, git, awk, date, grep) are all present in the image - `mcp-servers.json` - Base MCP server configurations (always installed) - `mcp-servers-optional.json` - Optional MCP servers (require API keys) - `install-mcp-servers.sh` - MCP installation script with variable substitution diff --git a/Dockerfile b/Dockerfile index 7cebc9e..e5b0e81 100644 --- a/Dockerfile +++ b/Dockerfile @@ -172,6 +172,11 @@ COPY --chown=${USER_NAME}:${USER_NAME} claude-config.json /home/${USER_NAME}/.cl # COPY --chown creates the .claude dir owned by the user (a root `mkdir` here would # leave it root-owned and break the later openspec/rtk init under USER claude). COPY --chown=${USER_NAME}:${USER_NAME} settings.local.json /home/${USER_NAME}/.claude/settings.local.json +# Statusline command for Claude Code (compact line: dir, git, model, context, rate +# limits). Installed at a fixed, HOME-independent path so settings.local.json's +# statusLine.command works regardless of the runtime HOME relocation. Deps (jq, git, +# awk, date, grep) are all present in the image. +COPY --chmod=0755 statusline-command.sh /usr/local/bin/claude-statusline.sh # Switch to non-root user for the agent-init layers (git config, RTK, caveman). USER ${USER_NAME} @@ -242,11 +247,14 @@ RUN rtk init -g --auto-patch RUN npx -y github:JuliusBrussee/caveman#v1.9.0 --non-interactive --only claude --no-mcp-shrink # Create simple startup script for runtime. -# --remote-control: start with Remote Control enabled by default (per project -# requirement) so the in-container agent can be driven remotely. +# --remote-control: OPT-IN via CLAUDE_REMOTE_CONTROL=1 (off by default). Remote +# Control requires a full-scope login token (`claude auth login`); the +# long-lived CLAUDE_CODE_OAUTH_TOKEN / `claude setup-token` this image uses is +# inference-only, so RC stays disabled with it. The flag is added only when the +# env var is set, so it is never a dead default. See SECURITY.md. # --dangerously-skip-permissions: the container boundary is the perimeter (see -# SECURITY.md). NOTE: Remote Control opens an outbound control channel — covered -# in the threat model. +# SECURITY.md). NOTE: when enabled, Remote Control opens an outbound control +# channel — covered in the threat model. RUN echo '#!/bin/bash\n\ set -e\n\ # Runtime starts with --user $(id -u):$(id -g) to match host ownership of the rw\n\ @@ -258,8 +266,14 @@ if [ "$HOME" != "/home/claude" ] && [ ! -e "$HOME/.claude.json" ]; then\n\ cp -a /home/claude/. "$HOME/" 2>/dev/null || true\n\ fi\n\ cd /workspace 2>/dev/null || cd "$HOME"\n\ -echo "Starting Claude Code (Remote Control enabled) in $(pwd)..."\n\ -exec claude --dangerously-skip-permissions --remote-control "$@"\n\ +RC_ARGS=""\n\ +if [ "${CLAUDE_REMOTE_CONTROL:-0}" = "1" ]; then\n\ + RC_ARGS="--remote-control"\n\ + echo "Starting Claude Code (Remote Control requested — needs a full-scope login token; see SECURITY.md) in $(pwd)..."\n\ +else\n\ + echo "Starting Claude Code in $(pwd)..."\n\ +fi\n\ +exec claude --dangerously-skip-permissions $RC_ARGS "$@"\n\ ' > /home/${USER_NAME}/start-claude.sh \ && chmod +x /home/${USER_NAME}/start-claude.sh diff --git a/README.md b/README.md index e5424f5..4f2a456 100644 --- a/README.md +++ b/README.md @@ -117,8 +117,11 @@ A single mode. The wrapper scripts (`run_claude.sh`, `debug-shell.sh`) apply har - Footgun guards: the scripts refuse `--privileged`, `docker.sock`, `--pid=host`, `--network=host`, `--cap-add`, or running as host root -The entrypoint runs `claude --dangerously-skip-permissions --remote-control` — the container -boundary is the perimeter, and Remote Control lets you drive the in-container agent remotely. +The entrypoint runs `claude --dangerously-skip-permissions` — the container boundary is the +perimeter. Remote Control (driving the in-container agent remotely) is **opt-in**: set +`CLAUDE_REMOTE_CONTROL=1` to add `--remote-control`. Note it needs a full-scope login token +(`claude auth login`); the `CLAUDE_CODE_OAUTH_TOKEN` this image uses is inference-only, so Remote +Control stays disabled with it. > ⚠️ **On a host where the Docker daemon runs as root, `docker run` is equivalent to host root.** > The wrapper scripts and their guards protect against *accidental* misconfiguration, **not** a diff --git a/SECURITY.md b/SECURITY.md index 990ff24..6414ae0 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -54,12 +54,14 @@ may be root. Consequently: ## Known limitations (by design) -- **The image ENTRYPOINT is permissive** (`--dangerously-skip-permissions --remote-control`). The +- **The image ENTRYPOINT is permissive** (`--dangerously-skip-permissions`). The container boundary is the perimeter; in-app permission prompts are disabled. A bare `docker run` without a wrapper still has no host-level hardening. Always use the wrapper scripts. -- **Remote Control opens an outbound control channel.** The entrypoint enables `--remote-control` by - default; this is an outbound connection to the Remote Control service. Disable it (override the - entrypoint / command) if your environment forbids that channel. +- **Remote Control is opt-in and off by default.** Set `CLAUDE_REMOTE_CONTROL=1` to add + `--remote-control`, which opens an outbound connection to the Remote Control service. It also + requires a **full-scope login token** (`claude auth login`): the long-lived `CLAUDE_CODE_OAUTH_TOKEN` + / `claude setup-token` this image normally uses is inference-only, so Remote Control stays disabled + with it even if the flag is set. Leave the env var unset if your environment forbids that channel. - **Outbound network is allowed.** Bridge networking blocks the host network but not the internet. `context7` and `perplexity` MCP servers transmit data (including code context) to third parties. On a root host this is an exfiltration channel under prompt injection — restrict egress at the diff --git a/settings.local.json b/settings.local.json index fbe3c20..e29fda7 100644 --- a/settings.local.json +++ b/settings.local.json @@ -3,5 +3,10 @@ "allow": [], "deny": [], "ask": [] + }, + "statusLine": { + "type": "command", + "command": "/usr/local/bin/claude-statusline.sh", + "padding": 0 } } diff --git a/statusline-command.sh b/statusline-command.sh new file mode 100755 index 0000000..98f6f62 --- /dev/null +++ b/statusline-command.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# Claude Code statusLine command — compact with icons +# Reads JSON from stdin, outputs a single status line + +input=$(cat) + +# --- Parse all fields with a single jq call --- +eval "$(echo "$input" | jq -r ' + @sh "cwd=\(.workspace.current_dir // .cwd // "")", + @sh "project_dir=\(.workspace.project_dir // .cwd // ".")", + @sh "model=\(.model.display_name // "")", + @sh "remaining=\(.context_window.remaining_percentage // "")", + @sh "total_in=\(.context_window.total_input_tokens // 0)", + @sh "total_out=\(.context_window.total_output_tokens // 0)", + @sh "duration_ms=\(.cost.total_duration_ms // 0)", + @sh "five_hour_used=\(.rate_limits.five_hour.used_percentage // "")", + @sh "five_hour_reset=\(.rate_limits.five_hour.resets_at // "")", + @sh "seven_day_used=\(.rate_limits.seven_day.used_percentage // "")" +' 2>/dev/null)" + +# --- Directory (Starship substitution) --- +home="$HOME" +cwd="${cwd/#$home/\~}" +cwd="${cwd/#\~\/WebstormProjects\//}" +IFS='/' read -ra parts <<< "$cwd" +count=${#parts[@]} +if [ "$count" -gt 3 ]; then + cwd="${parts[$((count-3))]}/${parts[$((count-2))]}/${parts[$((count-1))]}" +fi + +# --- Git branch + dirty --- +branch=$(GIT_OPTIONAL_LOCKS=0 git -C "$project_dir" symbolic-ref --short HEAD 2>/dev/null || \ + GIT_OPTIONAL_LOCKS=0 git -C "$project_dir" rev-parse --short HEAD 2>/dev/null) +if [ -n "$branch" ]; then + dirty=$(GIT_OPTIONAL_LOCKS=0 git -C "$project_dir" status --porcelain 2>/dev/null) + staged=$(echo "$dirty" | grep -c '^[MARCDT]' 2>/dev/null || true) + modified=$(echo "$dirty" | grep -c '^.[MARCDT?]' 2>/dev/null || true) +fi + +# --- Format tokens (19734 → 19.7k) --- +total_tok=$((total_in + total_out)) +if [ "$total_tok" -ge 1000000 ]; then + tok_str="$(awk "BEGIN{printf \"%.1f\", $total_tok/1000000}")M" +elif [ "$total_tok" -ge 1000 ]; then + tok_str="$(awk "BEGIN{printf \"%.1f\", $total_tok/1000}")k" +else + tok_str="${total_tok}" +fi + +# --- Format duration --- +duration_s=$((duration_ms / 1000)) +dur_h=$((duration_s / 3600)) +dur_m=$(( (duration_s % 3600) / 60 )) +if [ "$dur_h" -gt 0 ]; then + dur_str="${dur_h}h${dur_m}m" +else + dur_str="${dur_m}m" +fi + +# --- Colors --- +cyan='\033[0;36m' +purple='\033[0;35m' +blue='\033[0;34m' +yellow='\033[0;33m' +green='\033[0;32m' +red='\033[0;31m' +dim='\033[2m' +rst='\033[0m' + +# === Assemble output === + +# Directory (cyan) +printf "${cyan} %s${rst}" "$cwd" + +# Git branch + dirty (purple + yellow) +if [ -n "$branch" ]; then + printf " ${purple} %s${rst}" "$branch" + if [ "$staged" -gt 0 ] 2>/dev/null; then + printf " ${green}+%d${rst}" "$staged" + fi + if [ "$modified" -gt 0 ] 2>/dev/null; then + printf " ${yellow}~%d${rst}" "$modified" + fi +fi + +# Model (blue) +if [ -n "$model" ]; then + printf " ${blue} %s${rst}" "$model" +fi + +# Duration (before context) +printf " ${dim}󱑂 ${dur_str}${rst}" + +# Context remaining + session tokens (yellow) +if [ -n "$remaining" ]; then + remaining_int=$(printf '%.0f' "$remaining") + printf " ${yellow}󰄰 %d%%${rst}" "$remaining_int" + if [ "$total_tok" -gt 0 ]; then + printf " ${dim}· %s${rst}" "$tok_str" + fi +fi + +# Rate limit — 5h (green) +if [ -n "$five_hour_used" ]; then + used_int=$(printf '%.0f' "$five_hour_used") + rem_pct=$((100 - used_int)) + printf " ${green}󱐋 5h - %d%%" "$rem_pct" + if [ -n "$five_hour_reset" ]; then + now=$(date +%s) + diff_s=$((five_hour_reset - now)) + if [ "$diff_s" -gt 0 ]; then + diff_h=$((diff_s / 3600)) + diff_m=$(( (diff_s % 3600) / 60 )) + if [ "$diff_h" -gt 0 ]; then + printf " ${dim}(%dh%dm)${green}" "$diff_h" "$diff_m" + else + printf " ${dim}(%dm)${green}" "$diff_m" + fi + fi + fi + printf "${rst}" +fi + +# Rate limit — 7d (green) +if [ -n "$seven_day_used" ]; then + used7_int=$(printf '%.0f' "$seven_day_used") + rem7_pct=$((100 - used7_int)) + printf " ${green}󰃭 7d - %d%%${rst}" "$rem7_pct" +fi + +printf '\n'