feat: Alpine ベースの Claude Code リモート実行 Helm チャート初版 (v2.1.259)
Helm Chart Release / release-chart (push) Successful in 12s
Build Session Image / build-push (push) Successful in 2m15s

gateway(nginx) + manager(python 標準ライブラリ) + セッションごとの使い捨て Pod +
reaper CronJob の構成。ブラウザ(PC/スマホ)からアクセスし、セッション単位で
独立した Alpine コンテナに Git リポジトリ(GitHub/Gitea など)をチェックアウトして
編集・検証・プッシュする。無操作が続いたセッションは Pod ごと自動破棄。

- セキュリティ: セッション NS 分離 / restricted PodSecurity / RBAC はセッション NS の
  pods,services のみの Role / セッション Pod は SA トークン非マウント・非 root・
  readOnlyRootFilesystem・drop ALL caps・seccomp / NetworkPolicy で egress を
  DNS+HTTPS+SSH に限定 / Git ホスト allow-list / ResourceQuota+LimitRange
- ライフサイクル: idleTimeout(Pod 内ウォッチドッグ + manager 回収) / maxLifetime
  (activeDeadlineSeconds) / maxSessions / 予備の孤児掃除 CronJob
- 認証: token(既定) / basic / forward(外部SSO) / none。WebSocket は Cookie トークン
- Claude 認証: ANTHROPIC_API_KEY または CLAUDE_CODE_OAUTH_TOKEN
- CI: helm-release.yaml(未リリース版のみ tag+release+publish) /
  image-update-and-release.yaml(週次 npm 追従) / session-image.yaml(multi-arch build)
- files/ 実体を .Files.Get で ConfigMap 化。helm lint / template 検証済み
This commit is contained in:
2026-09-03 08:10:12 +09:00
parent 457a81a17f
commit 1bd0fec23a
39 changed files with 3745 additions and 1 deletions
+37
View File
@@ -0,0 +1,37 @@
# ==========================================================================
# claude-code セッションイメージ(事前ビルド版・推奨)
#
# 既定の values.image.session(node:22-alpine + ブート時インストール)でも
# 動作するが、初回起動が遅い。このイメージを CI でビルド・publish し
# values.image.session に指定すると、セッションのコールドスタートが数秒に短縮される。
#
# ビルド:
# docker buildx build --platform linux/amd64,linux/arm64 \
# --build-arg CLAUDE_CODE_VERSION=2.1.259 \
# -f files/Dockerfile.session \
# -t git.cafepieters.com/helmchart/claude-code/session:2.1.259 --push .
# ==========================================================================
ARG NODE_VERSION=22-alpine
FROM node:${NODE_VERSION}
ARG CLAUDE_CODE_VERSION=""
# git(SSH 込み) / ttyd / bash / 基本ツール。community に ttyd がある。
RUN apk add --no-cache \
bash git openssh-client ca-certificates curl jq less tini \
ttyd \
&& update-ca-certificates
# Claude Code CLI をグローバル導入(ビルド時のみ root、実行時は read-only でOK)
RUN npm install -g --no-fund --no-audit \
"@anthropic-ai/claude-code${CLAUDE_CODE_VERSION:+@${CLAUDE_CODE_VERSION}}" \
&& npm cache clean --force
# 非 root 実行ユーザー(Helm 側の runAsUser と一致させる: 既定 10001)
ARG RUN_UID=10001
RUN adduser -D -u ${RUN_UID} -h /home/claude claude
USER ${RUN_UID}
ENV HOME=/home/claude
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["/bin/sh", "/bootstrap/entrypoint.sh"]
+68
View File
@@ -0,0 +1,68 @@
#!/bin/sh
# claude-code gateway 起動スクリプト
# - /etc/resolv.conf から nginx 用 resolver を導出
# - auth.mode に応じて認証インクルードを生成
# - envsubst で nginx.conf を展開して起動
set -eu
TPL="/etc/gateway/gateway.conf.template"
OUT_DIR="/tmp/nginx"
mkdir -p "$OUT_DIR/body" "$OUT_DIR/proxy" "$OUT_DIR/fastcgi" "$OUT_DIR/uwsgi" "$OUT_DIR/scgi"
: "${GATEWAY_PORT:=8080}"
: "${MANAGER_PORT:=8081}"
: "${TTYD_PORT:=7681}"
: "${SESSIONS_NS:=default}"
: "${AUTH_MODE:=token}"
: "${NGINX_ERROR_LEVEL:=warn}"
# ---- resolver(CoreDNS)を resolv.conf から取得 ----
NGINX_RESOLVER="$(awk '/^nameserver/ { print $2; exit }' /etc/resolv.conf 2>/dev/null || true)"
[ -n "$NGINX_RESOLVER" ] || NGINX_RESOLVER="kube-dns.kube-system.svc.cluster.local"
export NGINX_RESOLVER
export GATEWAY_PORT MANAGER_PORT TTYD_PORT SESSIONS_NS NGINX_ERROR_LEVEL
# ---- 認証インクルードの生成 ----
: > "$OUT_DIR/auth_basic.conf"
: > "$OUT_DIR/auth_request.conf"
: > "$OUT_DIR/auth_backend.conf"
case "$AUTH_MODE" in
token)
echo 'auth_request /_auth;' > "$OUT_DIR/auth_request.conf"
echo "proxy_pass http://127.0.0.1:${MANAGER_PORT}/auth;" > "$OUT_DIR/auth_backend.conf"
;;
forward)
if [ -z "${AUTH_FORWARD_URL:-}" ]; then
echo "AUTH_MODE=forward だが AUTH_FORWARD_URL が空です" >&2; exit 1
fi
echo 'auth_request /_auth;' > "$OUT_DIR/auth_request.conf"
{
echo "proxy_pass ${AUTH_FORWARD_URL};"
echo 'proxy_set_header X-Original-URL $scheme://$http_host$request_uri;'
} > "$OUT_DIR/auth_backend.conf"
;;
basic)
if [ ! -r /etc/gateway-secret/htpasswd ]; then
echo "AUTH_MODE=basic だが htpasswd がマウントされていません" >&2; exit 1
fi
{
echo 'auth_basic "claude-code";'
echo 'auth_basic_user_file /etc/gateway-secret/htpasswd;'
} > "$OUT_DIR/auth_basic.conf"
;;
none)
echo "WARNING: AUTH_MODE=none — 認証なしで公開されます" >&2
;;
*)
echo "未知の AUTH_MODE: $AUTH_MODE" >&2; exit 1
;;
esac
# ---- nginx.conf 展開 ----
envsubst '${GATEWAY_PORT} ${MANAGER_PORT} ${TTYD_PORT} ${SESSIONS_NS} ${NGINX_RESOLVER} ${NGINX_ERROR_LEVEL}' \
< "$TPL" > "$OUT_DIR/nginx.conf"
echo "[gateway] auth_mode=$AUTH_MODE resolver=$NGINX_RESOLVER sessions_ns=$SESSIONS_NS"
exec nginx -c "$OUT_DIR/nginx.conf" -g 'daemon off;'
+100
View File
@@ -0,0 +1,100 @@
# nginx.conf (envsubst テンプレート) — claude-code gateway
# 受付・認証・WebSocket プロキシの単一入口。read-only rootfs / 非 root で動作する。
worker_processes auto;
pid /tmp/nginx/nginx.pid;
error_log /dev/stderr ${NGINX_ERROR_LEVEL};
events { worker_connections 1024; }
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /dev/stdout;
client_body_temp_path /tmp/nginx/body;
proxy_temp_path /tmp/nginx/proxy;
fastcgi_temp_path /tmp/nginx/fastcgi;
uwsgi_temp_path /tmp/nginx/uwsgi;
scgi_temp_path /tmp/nginx/scgi;
sendfile on;
tcp_nopush on;
server_tokens off;
client_max_body_size 25m;
map $http_upgrade $connection_upgrade { default upgrade; '' close; }
resolver ${NGINX_RESOLVER} valid=10s ipv6=off;
server {
listen ${GATEWAY_PORT};
listen [::]:${GATEWAY_PORT};
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
add_header Referrer-Policy no-referrer always;
# ---- HTTP Basic(auth.mode=basic のときのみ内容を持つ)----
include /tmp/nginx/auth_basic.conf;
location = /healthz {
auth_basic off;
proxy_pass http://127.0.0.1:${MANAGER_PORT}/healthz;
}
# ---- auth_request の実体(auth.mode=token|forward)----
location = /_auth {
internal;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-URI $request_uri;
proxy_set_header X-Original-Method $request_method;
include /tmp/nginx/auth_backend.conf;
}
# ---- 制御プレーン(UI / API)----
location / {
include /tmp/nginx/auth_request.conf;
proxy_pass http://127.0.0.1:${MANAGER_PORT};
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# UI とログインは常に到達可能にする(token モードのログイン導線のため)
location = / { proxy_pass http://127.0.0.1:${MANAGER_PORT}; proxy_set_header Host $host; }
location = /index.html { proxy_pass http://127.0.0.1:${MANAGER_PORT}; proxy_set_header Host $host; }
location = /login { proxy_pass http://127.0.0.1:${MANAGER_PORT}; proxy_set_header Host $host; }
location = /api/config {
include /tmp/nginx/auth_request.conf;
proxy_pass http://127.0.0.1:${MANAGER_PORT};
proxy_set_header Host $host;
}
# ---- セッションへのリバースプロキシ(WebSocket 対応)----
location ~ ^/s/(?<sid>[a-f0-9]{10})(?<rest>/.*)?$ {
include /tmp/nginx/auth_basic.conf;
include /tmp/nginx/auth_request.conf;
error_page 401 = @needlogin;
set $rest_path $rest;
if ($rest_path = "") { set $rest_path /; }
set $sess_backend "http://claude-sess-$sid.${SESSIONS_NS}.svc.cluster.local:${TTYD_PORT}";
proxy_pass $sess_backend$rest_path$is_args$args;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
location @needlogin {
return 302 /?next=$request_uri;
}
}
}
+685
View File
@@ -0,0 +1,685 @@
#!/usr/bin/env python3
"""
claude-code manager — 制御プレーン
責務:
* Web UI(モバイル対応・ビルド不要の素の HTML/JS)
* セッションのライフサイクル API(作成 / 一覧 / 破棄 / touch)
* gateway(nginx) からの auth_request 検証(token モード)
* アイドル / 寿命超過セッションの回収ループ
依存: Python 標準ライブラリのみ(追加 pip 不要)。
Kubernetes API へは in-cluster ServiceAccount トークンで直接アクセスする。
セッション NS 内の Pod / Service のみを操作する(RBAC で制限済み)。
"""
import base64
import hmac
import html
import json
import logging
import os
import re
import secrets
import ssl
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
# --------------------------------------------------------------------------
# 設定(すべて環境変数から)
# --------------------------------------------------------------------------
def env(key, default=""):
return os.environ.get(key, default)
def env_json(key, default):
raw = os.environ.get(key, "").strip()
if not raw:
return default
try:
return json.loads(raw)
except ValueError:
logging.error("invalid JSON in env %s", key)
return default
LISTEN_PORT = int(env("LISTEN_PORT", "8080"))
SESSIONS_NS = env("SESSIONS_NAMESPACE", "default")
FULLNAME = env("FULLNAME", "claude-code")
SESSION_SA = env("SESSION_SERVICE_ACCOUNT", "claude-code-session")
SESSION_IMAGE = env("SESSION_IMAGE", "docker.io/library/node:22-alpine")
SESSION_IMAGE_PULL_POLICY = env("SESSION_IMAGE_PULL_POLICY", "IfNotPresent")
SESSION_IMAGE_PULL_SECRETS = env_json("SESSION_IMAGE_PULL_SECRETS_JSON", [])
BOOTSTRAP_CM = env("SESSION_BOOTSTRAP_CONFIGMAP", FULLNAME + "-session")
ANTHROPIC_SECRET = env("ANTHROPIC_SECRET", FULLNAME + "-anthropic")
GIT_IDENTITIES = env_json("GIT_IDENTITIES_JSON", {})
GIT_ALLOWED_HOSTS = [h.strip().lower() for h in env("GIT_ALLOWED_HOSTS", "").split(",") if h.strip()]
GIT_ALLOW_ARBITRARY = env("GIT_ALLOW_ARBITRARY", "false").lower() == "true"
GIT_AUTHOR_NAME = env("GIT_DEFAULT_AUTHOR_NAME", "Claude")
GIT_AUTHOR_EMAIL = env("GIT_DEFAULT_AUTHOR_EMAIL", "claude@cafepieters.com")
IDLE_TIMEOUT = int(env("IDLE_TIMEOUT_SECONDS", "1800"))
MAX_LIFETIME = int(env("MAX_LIFETIME_SECONDS", "43200"))
REAP_INTERVAL = int(env("REAP_INTERVAL_SECONDS", "60"))
MAX_SESSIONS = int(env("MAX_SESSIONS", "10"))
TTYD_PORT = int(env("TTYD_PORT", "7681"))
SESSION_RESOURCES = env_json("SESSION_RESOURCES_JSON", {})
WORKSPACE_DIR = env("WORKSPACE_DIR", "/workspace")
AUTOSTART_CLAUDE = env("AUTOSTART_CLAUDE", "true")
VERIFY_COMMAND = env("VERIFY_COMMAND", "")
AUTOPUSH = env_json("AUTOPUSH_JSON", {"enabled": False})
EXTRA_APK = env("EXTRA_APK_PACKAGES", "")
CLAUDE_CODE_VERSION = env("CLAUDE_CODE_VERSION", "")
ANTHROPIC_EXTRA_ENV = env_json("ANTHROPIC_EXTRA_ENV_JSON", {})
POD_LABELS = env_json("SESSION_POD_LABELS_JSON", {})
POD_ANNOTATIONS = env_json("SESSION_POD_ANNOTATIONS_JSON", {})
NODE_SELECTOR = env_json("SESSION_NODE_SELECTOR_JSON", {})
TOLERATIONS = env_json("SESSION_TOLERATIONS_JSON", [])
PRIORITY_CLASS = env("SESSION_PRIORITY_CLASS", "")
SESSION_RUN_UID = int(env("SESSION_RUN_UID", "10001"))
AUTH_MODE = env("AUTH_MODE", "token")
_auth_token_file = env("AUTH_TOKEN_FILE", "")
if _auth_token_file and os.path.exists(_auth_token_file):
with open(_auth_token_file, "r", encoding="utf-8") as fh:
AUTH_TOKEN = fh.read().strip()
else:
AUTH_TOKEN = env("AUTH_TOKEN", "")
logging.basicConfig(
level=getattr(logging, env("LOG_LEVEL", "info").upper(), logging.INFO),
format="%(asctime)s %(levelname)s %(message)s",
)
log = logging.getLogger("manager")
LABEL_APP = "app.kubernetes.io/name"
LABEL_COMPONENT = "app.kubernetes.io/component"
LABEL_SID = "claude-code.cafepieters.com/session-id"
ANN_PREFIX = "claude-code.cafepieters.com/"
SESSION_SELECTOR = LABEL_COMPONENT + "=session," + LABEL_APP + "=claude-code"
SID_RE = re.compile(r"^[a-f0-9]{10}$")
# 受け入れる repo URL: https://host/... または ssh://git@host/... または git@host:path
REPO_RE = re.compile(
r"^(?:https://(?P<h1>[A-Za-z0-9.\-]+)(?::\d+)?/[\w./\-~%]+"
r"|ssh://git@(?P<h2>[A-Za-z0-9.\-]+)(?::\d+)?/[\w./\-~%]+"
r"|git@(?P<h3>[A-Za-z0-9.\-]+):[\w./\-~%]+)$"
)
REF_RE = re.compile(r"^[\w./\-]{1,200}$")
# --------------------------------------------------------------------------
# Kubernetes API クライアント(標準ライブラリのみ)
# --------------------------------------------------------------------------
K8S_HOST = "https://kubernetes.default.svc"
_sa_dir = "/var/run/secrets/kubernetes.io/serviceaccount"
with open(_sa_dir + "/token", "r", encoding="utf-8") as fh:
K8S_TOKEN = fh.read().strip()
_ssl_ctx = ssl.create_default_context(cafile=_sa_dir + "/ca.crt")
def k8s(method, path, body=None, ctype="application/json"):
url = K8S_HOST + path
data = None
if body is not None:
data = body if isinstance(body, bytes) else json.dumps(body).encode("utf-8")
req = urllib.request.Request(url=url, method=method, data=data)
req.add_header("Authorization", "Bearer " + K8S_TOKEN)
req.add_header("Accept", "application/json")
if data is not None:
req.add_header("Content-Type", ctype)
try:
with urllib.request.urlopen(req, context=_ssl_ctx, timeout=30) as resp:
raw = resp.read()
return resp.status, (json.loads(raw) if raw else {})
except urllib.error.HTTPError as exc:
raw = exc.read()
try:
payload = json.loads(raw)
except ValueError:
payload = {"message": raw.decode("utf-8", "replace")}
return exc.code, payload
def core(path):
return "/api/v1/namespaces/" + urllib.parse.quote(SESSIONS_NS) + path
# --------------------------------------------------------------------------
# セッション操作
# --------------------------------------------------------------------------
def repo_host(url):
m = REPO_RE.match(url)
if not m:
return None
return (m.group("h1") or m.group("h2") or m.group("h3") or "").lower()
def list_sessions():
status, payload = k8s("GET", core("/pods?labelSelector=" + urllib.parse.quote(SESSION_SELECTOR)))
if status != 200:
log.error("list pods failed: %s %s", status, payload)
return []
out = []
for pod in payload.get("items", []):
meta = pod.get("metadata", {})
ann = meta.get("annotations", {})
st = pod.get("status", {})
out.append({
"id": meta.get("labels", {}).get(LABEL_SID, ""),
"phase": st.get("phase", "Unknown"),
"repo": ann.get(ANN_PREFIX + "repo", ""),
"ref": ann.get(ANN_PREFIX + "ref", ""),
"identity": ann.get(ANN_PREFIX + "git-identity", ""),
"owner": ann.get(ANN_PREFIX + "owner", ""),
"createdAt": ann.get(ANN_PREFIX + "created-at", ""),
"lastActivity": ann.get(ANN_PREFIX + "last-activity", ""),
})
return out
def active_session_count():
return sum(1 for s in list_sessions() if s["phase"] in ("Pending", "Running"))
def build_pod(sid, repo, ref, identity_name, owner):
now = str(int(time.time()))
env_list = [
{"name": "SESSION_ID", "value": sid},
{"name": "REPO_URL", "value": repo},
{"name": "REPO_REF", "value": ref},
{"name": "HOME", "value": "/home/claude"},
{"name": "WORKSPACE_DIR", "value": WORKSPACE_DIR},
{"name": "TTYD_PORT", "value": str(TTYD_PORT)},
{"name": "IDLE_TIMEOUT_SECONDS", "value": str(IDLE_TIMEOUT)},
{"name": "AUTOSTART_CLAUDE", "value": AUTOSTART_CLAUDE},
{"name": "VERIFY_COMMAND", "value": VERIFY_COMMAND},
{"name": "AUTOPUSH_ENABLED", "value": str(bool(AUTOPUSH.get("enabled"))).lower()},
{"name": "AUTOPUSH_BRANCH_PREFIX", "value": AUTOPUSH.get("branchPrefix", "claude/")},
{"name": "AUTOPUSH_PROTECTED", "value": ",".join(AUTOPUSH.get("protectedBranches", []))},
{"name": "EXTRA_APK_PACKAGES", "value": EXTRA_APK},
{"name": "CLAUDE_CODE_VERSION", "value": CLAUDE_CODE_VERSION},
{"name": "GIT_AUTHOR_NAME", "value": GIT_AUTHOR_NAME},
{"name": "GIT_AUTHOR_EMAIL", "value": GIT_AUTHOR_EMAIL},
{"name": "GIT_COMMITTER_NAME", "value": GIT_AUTHOR_NAME},
{"name": "GIT_COMMITTER_EMAIL", "value": GIT_AUTHOR_EMAIL},
]
for key, val in ANTHROPIC_EXTRA_ENV.items():
env_list.append({"name": str(key), "value": str(val)})
volumes = [
{"name": "workspace", "emptyDir": {"sizeLimit": "4Gi"}},
{"name": "home", "emptyDir": {"sizeLimit": "1Gi"}},
{"name": "tmp", "emptyDir": {"medium": "Memory", "sizeLimit": "64Mi"}},
{"name": "npm-cache", "emptyDir": {"sizeLimit": "1Gi"}},
{"name": "bootstrap", "configMap": {"name": BOOTSTRAP_CM, "defaultMode": 0o555}},
]
mounts = [
{"name": "workspace", "mountPath": WORKSPACE_DIR},
{"name": "home", "mountPath": "/home/claude"},
{"name": "tmp", "mountPath": "/tmp"},
{"name": "npm-cache", "mountPath": "/home/claude/.npm"},
{"name": "bootstrap", "mountPath": "/bootstrap", "readOnly": True},
]
ident = GIT_IDENTITIES.get(identity_name)
if ident:
env_list.append({"name": "GIT_IDENTITY_TYPE", "value": ident.get("type", "")})
if ident.get("type") == "ssh":
volumes.append({
"name": "git-identity",
"secret": {
"secretName": ident["secret"],
"defaultMode": 0o400,
"items": [
{"key": ident.get("keyPrivateKey", "id"), "path": "id"},
{"key": ident.get("keyKnownHosts", "known_hosts"), "path": "known_hosts"},
],
},
})
mounts.append({"name": "git-identity", "mountPath": "/git-identity", "readOnly": True})
elif ident.get("type") == "https-token":
volumes.append({
"name": "git-identity",
"secret": {
"secretName": ident["secret"],
"defaultMode": 0o400,
"items": [{"key": ident.get("keyToken", "token"), "path": "token"}],
},
})
mounts.append({"name": "git-identity", "mountPath": "/git-identity", "readOnly": True})
env_list.append({"name": "GIT_HTTPS_USERNAME", "value": ident.get("username", "x-access-token")})
container = {
"name": "claude",
"image": SESSION_IMAGE,
"imagePullPolicy": SESSION_IMAGE_PULL_POLICY,
"command": ["/bin/sh", "/bootstrap/entrypoint.sh"],
"env": env_list,
"envFrom": [{"secretRef": {"name": ANTHROPIC_SECRET}}],
"ports": [{"name": "ttyd", "containerPort": TTYD_PORT}],
"volumeMounts": mounts,
"resources": SESSION_RESOURCES,
"securityContext": {
"allowPrivilegeEscalation": False,
"readOnlyRootFilesystem": True,
"runAsNonRoot": True,
"runAsUser": SESSION_RUN_UID,
"capabilities": {"drop": ["ALL"]},
},
"readinessProbe": {
"tcpSocket": {"port": TTYD_PORT},
"initialDelaySeconds": 5,
"periodSeconds": 5,
"failureThreshold": 60,
},
}
labels = {
LABEL_APP: "claude-code",
LABEL_COMPONENT: "session",
"app.kubernetes.io/managed-by": "claude-code-manager",
LABEL_SID: sid,
}
labels.update({str(k): str(v) for k, v in POD_LABELS.items()})
annotations = {
ANN_PREFIX + "repo": repo,
ANN_PREFIX + "ref": ref,
ANN_PREFIX + "git-identity": identity_name or "",
ANN_PREFIX + "owner": owner or "anon",
ANN_PREFIX + "created-at": now,
ANN_PREFIX + "last-activity": now,
}
annotations.update({str(k): str(v) for k, v in POD_ANNOTATIONS.items()})
spec = {
"serviceAccountName": SESSION_SA,
"automountServiceAccountToken": False,
"restartPolicy": "Never",
"activeDeadlineSeconds": MAX_LIFETIME,
"terminationGracePeriodSeconds": 30,
"enableServiceLinks": False,
"securityContext": {
"runAsNonRoot": True,
"runAsUser": SESSION_RUN_UID,
"runAsGroup": SESSION_RUN_UID,
"fsGroup": SESSION_RUN_UID,
"seccompProfile": {"type": "RuntimeDefault"},
},
"containers": [container],
"volumes": volumes,
}
if SESSION_IMAGE_PULL_SECRETS:
spec["imagePullSecrets"] = SESSION_IMAGE_PULL_SECRETS
if NODE_SELECTOR:
spec["nodeSelector"] = NODE_SELECTOR
if TOLERATIONS:
spec["tolerations"] = TOLERATIONS
if PRIORITY_CLASS:
spec["priorityClassName"] = PRIORITY_CLASS
return {
"apiVersion": "v1",
"kind": "Pod",
"metadata": {"name": "claude-sess-" + sid, "namespace": SESSIONS_NS,
"labels": labels, "annotations": annotations},
"spec": spec,
}
def build_service(sid):
return {
"apiVersion": "v1",
"kind": "Service",
"metadata": {
"name": "claude-sess-" + sid,
"namespace": SESSIONS_NS,
"labels": {LABEL_APP: "claude-code", LABEL_COMPONENT: "session", LABEL_SID: sid},
},
"spec": {
"selector": {LABEL_SID: sid},
"ports": [{"name": "ttyd", "port": TTYD_PORT, "targetPort": TTYD_PORT}],
},
}
def create_session(repo, ref, identity_name, owner):
repo = (repo or "").strip()
ref = (ref or "main").strip()
if not REPO_RE.match(repo):
return None, "リポジトリ URL の形式が不正です"
if not REF_RE.match(ref):
return None, "ref の形式が不正です"
host = repo_host(repo)
if not GIT_ALLOW_ARBITRARY and host not in GIT_ALLOWED_HOSTS:
return None, "許可されていない Git ホストです: " + str(host)
if identity_name and identity_name not in GIT_IDENTITIES:
return None, "未知の git identity: " + identity_name
if active_session_count() >= MAX_SESSIONS:
return None, "同時セッション数の上限(%d)に達しています" % MAX_SESSIONS
sid = secrets.token_hex(5)
status, payload = k8s("POST", core("/pods"), build_pod(sid, repo, ref, identity_name, owner))
if status not in (200, 201):
return None, "Pod 作成に失敗: %s" % payload.get("message", status)
status, payload = k8s("POST", core("/services"), build_service(sid))
if status not in (200, 201):
k8s("DELETE", core("/pods/claude-sess-" + sid))
return None, "Service 作成に失敗: %s" % payload.get("message", status)
log.info("session %s created repo=%s ref=%s owner=%s", sid, repo, ref, owner)
return sid, None
def delete_session(sid):
if not SID_RE.match(sid):
return False
k8s("DELETE", core("/pods/claude-sess-" + sid), {"gracePeriodSeconds": 10})
k8s("DELETE", core("/services/claude-sess-" + sid))
log.info("session %s deleted", sid)
return True
_touch_cache = {}
def touch_session(sid, min_interval=15):
if not SID_RE.match(sid):
return
now = time.time()
if now - _touch_cache.get(sid, 0) < min_interval:
return
_touch_cache[sid] = now
patch = {"metadata": {"annotations": {ANN_PREFIX + "last-activity": str(int(now))}}}
k8s("PATCH", core("/pods/claude-sess-" + sid), patch,
ctype="application/merge-patch+json")
# --------------------------------------------------------------------------
# 回収ループ
# --------------------------------------------------------------------------
def reaper_loop():
while True:
time.sleep(REAP_INTERVAL)
try:
reap_once()
except Exception as exc: # noqa: BLE001 - ループを止めない
log.exception("reaper error: %s", exc)
def reap_once():
now = int(time.time())
live_ids = set()
for s in list_sessions():
sid = s["id"]
live_ids.add(sid)
reason = None
if s["phase"] in ("Succeeded", "Failed"):
reason = "phase=" + s["phase"]
else:
try:
created = int(s["createdAt"] or 0)
last = int(s["lastActivity"] or 0)
except ValueError:
created = last = now
if created and now - created > MAX_LIFETIME:
reason = "max-lifetime"
elif last and now - last > IDLE_TIMEOUT:
reason = "idle"
if reason:
log.info("reaping session %s (%s)", sid, reason)
delete_session(sid)
live_ids.discard(sid)
# 孤児 Service の掃除
status, payload = k8s("GET", core("/services?labelSelector=" + urllib.parse.quote(SESSION_SELECTOR)))
if status == 200:
for svc in payload.get("items", []):
sid = svc.get("metadata", {}).get("labels", {}).get(LABEL_SID, "")
if sid and sid not in live_ids:
log.info("reaping orphan service %s", sid)
k8s("DELETE", core("/services/claude-sess-" + sid))
# --------------------------------------------------------------------------
# 認証(token モード)
# --------------------------------------------------------------------------
def extract_token(handler):
auth = handler.headers.get("Authorization", "")
if auth.startswith("Bearer "):
return auth[7:].strip()
cookie = handler.headers.get("Cookie", "")
for part in cookie.split(";"):
part = part.strip()
if part.startswith("cc_token="):
return urllib.parse.unquote(part[9:])
qs = urllib.parse.urlparse(handler.path).query
params = urllib.parse.parse_qs(qs)
if "token" in params:
return params["token"][0]
return ""
def token_ok(handler):
if AUTH_MODE != "token":
return True
if not AUTH_TOKEN:
return False
return hmac.compare_digest(extract_token(handler), AUTH_TOKEN)
# --------------------------------------------------------------------------
# HTTP ハンドラ
# --------------------------------------------------------------------------
INDEX_HTML = """<!doctype html>
<html lang="ja"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Claude Code — Remote Sessions</title>
<style>
:root{color-scheme:light dark}
*{box-sizing:border-box}
body{font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;margin:0;padding:1rem;max-width:760px;margin-inline:auto}
h1{font-size:1.15rem;margin:.2rem 0 1rem}
form,.card{border:1px solid #8884;border-radius:10px;padding:1rem;margin-bottom:1rem}
label{display:block;font-weight:600;margin:.5rem 0 .2rem}
input,select,button{font:inherit;padding:.55rem .6rem;border-radius:8px;border:1px solid #8886;width:100%}
button{background:#4338ca;color:#fff;border:0;font-weight:600;cursor:pointer;margin-top:.8rem}
button.sec{background:#8883;color:inherit}
.row{display:flex;gap:.5rem;align-items:center}
.row>*{width:auto}
table{width:100%;border-collapse:collapse;font-size:.92rem}
td,th{text-align:left;padding:.45rem .3rem;border-bottom:1px solid #8883;vertical-align:top}
a.open{display:inline-block;background:#0891b2;color:#fff;padding:.3rem .7rem;border-radius:6px;text-decoration:none}
.muted{opacity:.65;font-size:.85rem}
code{background:#8882;padding:.1rem .3rem;border-radius:4px}
</style></head><body>
<h1>Claude Code — Remote Sessions</h1>
<div id="login" hidden>
<form onsubmit="return doLogin(event)">
<label for="tk">アクセストークン</label>
<input id="tk" type="password" autocomplete="current-password" placeholder="Bearer token">
<button type="submit">ログイン</button>
</form>
</div>
<div id="app" hidden>
<form onsubmit="return doCreate(event)">
<label for="repo">リポジトリ URL</label>
<input id="repo" placeholder="https://github.com/owner/repo.git または git@host:owner/repo.git" required>
<div class="row">
<div style="flex:1"><label for="ref">ブランチ / タグ</label><input id="ref" value="main"></div>
<div style="flex:1"><label for="ident">認証 identity</label><select id="ident"></select></div>
</div>
<button type="submit">セッション作成</button>
</form>
<div class="card">
<div class="row" style="justify-content:space-between">
<strong>セッション一覧</strong>
<button class="sec" style="width:auto;margin:0" onclick="refresh()">更新</button>
</div>
<table><thead><tr><th>ID</th><th>状態</th><th>リポジトリ</th><th></th></tr></thead>
<tbody id="rows"></tbody></table>
<p class="muted">アイドル <span id="idle"></span> 秒で自動破棄されます。</p>
</div>
</div>
<script>
const $=s=>document.querySelector(s);
let identities=[];
function api(path,opts){opts=opts||{};opts.credentials='same-origin';return fetch(path,opts);}
async function boot(){
const r=await api('/api/config');
if(r.status===401){$('#login').hidden=false;return;}
const c=await r.json();
identities=c.identities||[];
$('#idle').textContent=c.idleTimeoutSeconds;
const sel=$('#ident');
sel.innerHTML='<option value="">(認証なし / 公開リポジトリ)</option>'+identities.map(i=>`<option>${i}</option>`).join('');
$('#app').hidden=false;
refresh();
}
async function doLogin(e){e.preventDefault();
const r=await api('/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:$('#tk').value})});
if(r.ok){location.reload();}else{alert('トークンが違います');}
return false;
}
async function doCreate(e){e.preventDefault();
const body={repo:$('#repo').value.trim(),ref:$('#ref').value.trim()||'main',identity:$('#ident').value};
const r=await api('/api/sessions',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const j=await r.json();
if(!r.ok){alert(j.error||'作成に失敗しました');return false;}
$('#repo').value='';refresh();return false;
}
async function del(id){if(!confirm('セッション '+id+' を破棄しますか?'))return;
await api('/api/sessions/'+id,{method:'DELETE'});refresh();}
async function refresh(){
const r=await api('/api/sessions');if(!r.ok)return;
const list=(await r.json()).sessions||[];
$('#rows').innerHTML=list.map(s=>`<tr>
<td><code>${s.id}</code></td>
<td>${s.phase}</td>
<td style="word-break:break-all">${s.repo||''}<div class="muted">${s.ref||''}</div></td>
<td><a class="open" href="/s/${s.id}/" target="_blank" rel="noopener">開く</a>
<button class="sec" style="width:auto;margin:.3rem 0 0" onclick="del('${s.id}')">破棄</button></td>
</tr>`).join('')||'<tr><td colspan=4 class="muted">セッションはありません</td></tr>';
}
boot();
</script></body></html>
"""
class Handler(BaseHTTPRequestHandler):
server_version = "claude-code-manager"
protocol_version = "HTTP/1.1"
def _send(self, code, body=b"", ctype="application/json; charset=utf-8", extra=None):
if isinstance(body, str):
body = body.encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
for key, val in (extra or {}).items():
self.send_header(key, val)
self.end_headers()
if self.command != "HEAD":
self.wfile.write(body)
def _json(self, code, obj, extra=None):
self._send(code, json.dumps(obj), "application/json; charset=utf-8", extra)
def _body(self):
length = int(self.headers.get("Content-Length", "0") or "0")
if length <= 0:
return {}
try:
return json.loads(self.rfile.read(length) or b"{}")
except ValueError:
return {}
def log_message(self, fmt, *args):
log.debug("%s - %s", self.address_string(), fmt % args)
# ---- ルーティング ----
def do_GET(self):
path = urllib.parse.urlparse(self.path).path
if path == "/healthz":
return self._send(200, b'{"ok":true}')
if path == "/auth":
# nginx auth_request 用(token モードのみ意味を持つ)
if token_ok(self):
orig = self.headers.get("X-Original-URI", "")
m = re.search(r"/s/([a-f0-9]{10})/", orig)
if m:
try:
touch_session(m.group(1))
except Exception: # noqa: BLE001
pass
return self._send(200, b"ok", "text/plain")
return self._send(401, b"unauthorized", "text/plain")
if path == "/" or path == "/index.html":
return self._send(200, INDEX_HTML, "text/html; charset=utf-8")
if path == "/api/config":
if not token_ok(self):
return self._json(401, {"error": "unauthorized"})
return self._json(200, {
"identities": sorted(GIT_IDENTITIES.keys()),
"idleTimeoutSeconds": IDLE_TIMEOUT,
"maxLifetimeSeconds": MAX_LIFETIME,
"maxSessions": MAX_SESSIONS,
"authMode": AUTH_MODE,
})
if path == "/api/sessions":
if not token_ok(self):
return self._json(401, {"error": "unauthorized"})
return self._json(200, {"sessions": list_sessions()})
return self._send(404, b'{"error":"not found"}')
def do_POST(self):
path = urllib.parse.urlparse(self.path).path
if path == "/login":
body = self._body()
token = str(body.get("token", ""))
if AUTH_MODE == "token" and AUTH_TOKEN and hmac.compare_digest(token, AUTH_TOKEN):
cookie = ("cc_token=" + urllib.parse.quote(token) +
"; HttpOnly; SameSite=Lax; Path=/; Max-Age=2592000")
return self._json(200, {"ok": True}, {"Set-Cookie": cookie})
return self._json(401, {"error": "invalid token"})
if not token_ok(self):
return self._json(401, {"error": "unauthorized"})
if path == "/api/sessions":
body = self._body()
owner = self.headers.get("X-Forwarded-User", "") or "anon"
sid, err = create_session(body.get("repo", ""), body.get("ref", "main"),
body.get("identity", ""), owner)
if err:
return self._json(400, {"error": err})
return self._json(201, {"id": sid, "url": "/s/" + sid + "/"})
m = re.match(r"^/api/sessions/([a-f0-9]{10})/touch$", path)
if m:
touch_session(m.group(1), min_interval=0)
return self._json(200, {"ok": True})
return self._send(404, b'{"error":"not found"}')
def do_DELETE(self):
if not token_ok(self):
return self._json(401, {"error": "unauthorized"})
m = re.match(r"^/api/sessions/([a-f0-9]{10})$", urllib.parse.urlparse(self.path).path)
if not m:
return self._send(404, b'{"error":"not found"}')
delete_session(m.group(1))
return self._json(200, {"ok": True})
def main():
log.info("claude-code manager starting: ns=%s image=%s idle=%ds maxLife=%ds maxSessions=%d authMode=%s",
SESSIONS_NS, SESSION_IMAGE, IDLE_TIMEOUT, MAX_LIFETIME, MAX_SESSIONS, AUTH_MODE)
if AUTH_MODE == "token" and not AUTH_TOKEN:
log.warning("AUTH_MODE=token だがトークンが空。全リクエストを拒否します。")
threading.Thread(target=reaper_loop, name="reaper", daemon=True).start()
httpd = ThreadingHTTPServer(("0.0.0.0", LISTEN_PORT), Handler)
httpd.serve_forever()
if __name__ == "__main__":
import sys
if "--reap-once" in sys.argv[1:]:
# 予備の CronJob から呼ばれる一回きりの回収。manager 障害時の保険。
log.info("one-shot reap starting (ns=%s)", SESSIONS_NS)
reap_once()
log.info("one-shot reap done")
else:
main()
+230
View File
@@ -0,0 +1,230 @@
#!/bin/sh
# ==========================================================================
# claude-code セッションブート(使い捨てコンテナ内で1回だけ実行)
#
# 1. git 認証(SSH 鍵 / HTTPS トークン)を $HOME にセットアップ
# 2. 許可済みリポジトリを $WORKSPACE_DIR へ clone
# 3. `claude` を PATH に用意(無ければ非 root で $HOME にインストール)
# 4. ttyd でブラウザにシェル(+ 任意で claude 自動起動)を提供
# 5. アイドルウォッチドッグ:無通信が続いたら自身を終了 → Pod 破棄
#
# 前提: 非 root(uid=10001) / readOnlyRootFilesystem。
# 書き込み可能なのは $HOME, $WORKSPACE_DIR, /tmp のみ。
# git / ttyd / node は事前ビルドイメージに同梱されている想定
# (files/Dockerfile.session)。未同梱時は下記フォールバックが走る。
# ==========================================================================
set -eu
log() { printf '\033[36m[boot]\033[0m %s\n' "$*"; }
err() { printf '\033[31m[boot:ERROR]\033[0m %s\n' "$*" >&2; }
: "${HOME:=/home/claude}"
: "${WORKSPACE_DIR:=/workspace}"
: "${TTYD_PORT:=7681}"
: "${IDLE_TIMEOUT_SECONDS:=1800}"
: "${AUTOSTART_CLAUDE:=true}"
: "${REPO_REF:=main}"
export HOME
export NPM_CONFIG_CACHE="$HOME/.npm"
export PATH="$HOME/.npm-global/bin:$HOME/.local/bin:$PATH"
mkdir -p "$HOME/.ssh" "$WORKSPACE_DIR" "$HOME/.npm-global"
chmod 700 "$HOME/.ssh"
# --------------------------------------------------------------------------
# 1. git 認証セットアップ
# --------------------------------------------------------------------------
git config --global user.name "${GIT_AUTHOR_NAME:-Claude}"
git config --global user.email "${GIT_AUTHOR_EMAIL:-claude@cafepieters.com}"
git config --global safe.directory '*'
git config --global advice.detachedHead false
git config --global credential.helper ''
ASKPASS_DISABLE() { export GIT_TERMINAL_PROMPT=0; }
ASKPASS_DISABLE
case "${GIT_IDENTITY_TYPE:-}" in
ssh)
if [ ! -r /git-identity/id ]; then err "SSH 鍵がマウントされていません"; exit 1; fi
install -m 600 /git-identity/id "$HOME/.ssh/id_session"
# known_hosts を必須にし、ホスト鍵の TOFU を無効化する
if [ -r /git-identity/known_hosts ]; then
install -m 644 /git-identity/known_hosts "$HOME/.ssh/known_hosts"
else
err "known_hosts がありません(StrictHostKeyChecking を維持するため必須)"; exit 1
fi
cat > "$HOME/.ssh/config" <<EOF
Host *
IdentityFile $HOME/.ssh/id_session
IdentitiesOnly yes
StrictHostKeyChecking yes
UserKnownHostsFile $HOME/.ssh/known_hosts
PasswordAuthentication no
EOF
chmod 600 "$HOME/.ssh/config"
export GIT_SSH_COMMAND="ssh -F $HOME/.ssh/config"
log "SSH 認証を構成しました"
;;
https-token)
if [ ! -r /git-identity/token ]; then err "HTTPS トークンがマウントされていません"; exit 1; fi
_u="${GIT_HTTPS_USERNAME:-x-access-token}"
_t="$(cat /git-identity/token)"
# トークンをプロセス環境にもファイルにも平文で残さず、credential helper で都度供給
cat > "$HOME/.git-credentials" <<EOF
https://$(printf '%s' "$_u" | sed 's/@/%40/g'):$(printf '%s' "$_t" | sed 's/@/%40/g')@$(printf '%s\n' "$REPO_URL" | sed -E 's#^https://([^/]+)/.*#\1#')
EOF
chmod 600 "$HOME/.git-credentials"
git config --global credential.helper "store --file=$HOME/.git-credentials"
unset _t
log "HTTPS トークン認証を構成しました"
;;
*)
log "git 認証なし(公開リポジトリのみ clone 可能)"
;;
esac
# --------------------------------------------------------------------------
# 2. clone
# --------------------------------------------------------------------------
REPO_DIR="$WORKSPACE_DIR/$(basename "${REPO_URL%.git}")"
if [ -d "$REPO_DIR/.git" ]; then
log "既存チェックアウトを再利用: $REPO_DIR"
else
log "clone 中: $REPO_URL ($REPO_REF)"
if ! git clone --filter=blob:none --no-tags "$REPO_URL" "$REPO_DIR"; then
err "clone に失敗しました"; exit 1
fi
fi
cd "$REPO_DIR"
git checkout "$REPO_REF" 2>/dev/null || git checkout -b "$REPO_REF" 2>/dev/null || true
log "作業ディレクトリ: $REPO_DIR ($(git rev-parse --short HEAD 2>/dev/null || echo '?'))"
# --------------------------------------------------------------------------
# 3. claude CLI を用意
# --------------------------------------------------------------------------
if ! command -v claude >/dev/null 2>&1; then
log "claude が未同梱のため $HOME にインストールします(初回は時間がかかります)"
npm config set prefix "$HOME/.npm-global" >/dev/null 2>&1 || true
_ver="${CLAUDE_CODE_VERSION:-}"
_pkg="@anthropic-ai/claude-code${_ver:+@$_ver}"
if ! npm install -g --no-fund --no-audit "$_pkg"; then
err "claude-code のインストールに失敗しました。事前ビルドイメージの利用を推奨します。"
exit 1
fi
fi
log "claude: $(claude --version 2>/dev/null || echo 'unknown')"
# 追加 apk(root 権限がある事前ビルドイメージのみ有効。非 root では警告のみ)
if [ -n "${EXTRA_APK_PACKAGES:-}" ]; then
if [ "$(id -u)" = "0" ]; then
apk add --no-cache $EXTRA_APK_PACKAGES || err "apk add に一部失敗"
else
err "EXTRA_APK_PACKAGES は非 root では無視されます: $EXTRA_APK_PACKAGES"
fi
fi
# --------------------------------------------------------------------------
# 4. 補助コマンド(claude-verify / claude-push)
# --------------------------------------------------------------------------
mkdir -p "$HOME/.local/bin"
cat > "$HOME/.local/bin/claude-verify" <<'EOF'
#!/bin/sh
# セッション設定の検証コマンドを実行する
if [ -z "${VERIFY_COMMAND:-}" ]; then echo "verifyCommand は未設定です"; exit 0; fi
echo "+ $VERIFY_COMMAND"
exec sh -c "$VERIFY_COMMAND"
EOF
chmod +x "$HOME/.local/bin/claude-verify"
cat > "$HOME/.local/bin/claude-push" <<'EOF'
#!/bin/sh
# 検証 → 安全なブランチへ push(デフォルト/保護ブランチ・--force は禁止)
set -eu
if [ "${AUTOPUSH_ENABLED:-false}" != "true" ]; then
echo "autoPush は無効です(values: session.autoPush.enabled)。手動 push は 'git push' を使ってください。" >&2
exit 1
fi
cur="$(git rev-parse --abbrev-ref HEAD)"
prefix="${AUTOPUSH_BRANCH_PREFIX:-claude/}"
protected="${AUTOPUSH_PROTECTED:-main,master,develop,release}"
case ",$protected," in *",$cur,"*)
echo "現在のブランチ '$cur' は保護対象です。作業ブランチへ切り替えてください。" >&2; exit 1;;
esac
case "$cur" in "$prefix"*) target="$cur" ;; *) target="${prefix}${cur}" ;; esac
if [ -n "${VERIFY_COMMAND:-}" ]; then
echo "== verify: $VERIFY_COMMAND"
sh -c "$VERIFY_COMMAND" || { echo "検証に失敗したため push を中止します。" >&2; exit 1; }
fi
if [ -n "$(git status --porcelain)" ]; then
git add -A
git commit -m "${1:-chore: work from claude-code session $SESSION_ID}"
fi
[ "$target" = "$cur" ] || git branch -f "$target" "$cur"
echo "== push origin $target(--force なし)"
exec git push origin "HEAD:$target"
EOF
chmod +x "$HOME/.local/bin/claude-push"
# --------------------------------------------------------------------------
# 5. アイドルウォッチドッグ
# --------------------------------------------------------------------------
idle_watchdog() {
# ttyd への ESTABLISHED 接続が IDLE_TIMEOUT_SECONDS の間ゼロなら Pod を終了させる
last_seen="$(date +%s)"
while true; do
sleep 30
conns=0
if command -v ss >/dev/null 2>&1; then
conns="$(ss -Htn "sport = :$TTYD_PORT" state established 2>/dev/null | wc -l | tr -d ' ')"
elif [ -r /proc/net/tcp ]; then
hexport="$(printf '%04X' "$TTYD_PORT")"
conns="$(awk -v p=":$hexport" '$2 ~ p"$" && $4=="01"{n++} END{print n+0}' /proc/net/tcp)"
fi
now="$(date +%s)"
if [ "${conns:-0}" -gt 0 ]; then
last_seen="$now"
elif [ $(( now - last_seen )) -ge "$IDLE_TIMEOUT_SECONDS" ]; then
err "アイドル ${IDLE_TIMEOUT_SECONDS}s を超過。セッションを終了します。"
kill -TERM "$TTYD_PID" 2>/dev/null || true
exit 0
fi
done
}
# --------------------------------------------------------------------------
# 6. ttyd 起動
# --------------------------------------------------------------------------
if ! command -v ttyd >/dev/null 2>&1; then
err "ttyd が見つかりません。事前ビルドイメージ(files/Dockerfile.session)を利用してください。"
exit 1
fi
SHELL_CMD="/bin/bash"
command -v bash >/dev/null 2>&1 || SHELL_CMD="/bin/sh"
cat > "$HOME/.session-shell" <<EOF
#!/bin/sh
cd "$REPO_DIR" || cd "$WORKSPACE_DIR"
export PATH="$PATH"
echo "── Claude Code セッション $SESSION_ID ──────────────────────────"
echo "リポジトリ : $REPO_URL ($REPO_REF)"
echo "検証コマンド: ${VERIFY_COMMAND:-(未設定)} 補助: claude-verify / claude-push"
echo "─────────────────────────────────────────────────────────────"
if [ "${AUTOSTART_CLAUDE}" = "true" ] && command -v claude >/dev/null 2>&1; then
exec claude
fi
exec $SHELL_CMD -l
EOF
chmod +x "$HOME/.session-shell"
log "ttyd を 0.0.0.0:$TTYD_PORT で起動します"
ttyd --port "$TTYD_PORT" --interface 0.0.0.0 --writable \
--client-option fontSize=14 --client-option 'theme={"background":"#1e1e1e"}' \
--terminal-type xterm-256color \
"$HOME/.session-shell" &
TTYD_PID=$!
idle_watchdog &
wait "$TTYD_PID"