Files
claude-code/files/manager.py
T
claude 6514747b4e
Helm Chart Release / release-chart (push) Failing after 9s
Build Session Image / build-push (push) Successful in 2m9s
feat: リポジトリ URL バリデーションを緩和しエラー文言を具体化 (v2.1.263-e)
claude@host:path のような SSH ユーザー違いの URL が「形式が不正」で弾かれ
原因が分かりにくかった。Gitea/GitHub の SSH ユーザーは常に git。

- REPO_RE を ssh://<user>@host/… / <user>@host:path / host:path も受理するよう緩和
  (// 始まりは拒否、ホスト allowlist は全形式に適用)
- URL 形式エラーで受理形式と「SSH ユーザーは常に git」を明記、
  ホスト不許可で許可済み一覧を併記
- UI のリポジトリ URL 入力欄にヒントを追加、README に URL 形式表を追加
2026-09-09 11:41:41 +09:00

799 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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")
ANTHROPIC_AUTH_MODE = env("ANTHROPIC_AUTH_MODE", "apiKey")
ANTHROPIC_CREDENTIALS_KEY = env("ANTHROPIC_CREDENTIALS_KEY", ".credentials.json")
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"))
# 終了済み(Succeeded / Failed)セッション Pod を残す秒数。0 なら即回収。
# 死因を kubectl logs / UI「ログ」で調べられるよう既定で猶予を持たせる。
TERMINATED_RETENTION = int(env("TERMINATED_RETENTION_SECONDS", "900"))
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[:port]/path
# ssh://[user@]host[:port]/path
# [user@]host:path (SCP 形式。Gitea/GitHub の SSH ユーザーは常に "git")
_USER = r"(?:[A-Za-z0-9_.\-]+@)?"
REPO_RE = re.compile(
r"^(?:https://(?P<h1>[A-Za-z0-9.\-]+)(?::\d+)?/[\w./\-~%]+"
r"|ssh://" + _USER + r"(?P<h2>[A-Za-z0-9.\-]+)(?::\d+)?/[\w./\-~%]+"
r"|" + _USER + r"(?P<h3>[A-Za-z0-9.\-]+):(?!//)[\w./\-~%]+)$"
)
REPO_HELP = ("URL の形式: https://<host>/<org>/<repo>.git / "
"ssh://git@<host>/<org>/<repo>.git / git@<host>:<org>/<repo>.git "
"(Gitea・GitHub の SSH ユーザーは常に 'git'。あなたのアカウント名ではありません)")
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 k8s_text(path):
"""text/plain を返すエンドポイント(Pod ログ)用。(status, text) を返す。"""
req = urllib.request.Request(url=K8S_HOST + path, method="GET")
req.add_header("Authorization", "Bearer " + K8S_TOKEN)
try:
with urllib.request.urlopen(req, context=_ssl_ctx, timeout=30) as resp:
return resp.status, resp.read().decode("utf-8", "replace")
except urllib.error.HTTPError as exc:
return exc.code, exc.read().decode("utf-8", "replace")
def session_log(sid, tail=800):
if not SID_RE.match(sid):
return 400, "bad session id"
return k8s_text(core("/pods/claude-sess-" + sid + "/log?container=claude&tailLines=" + str(int(tail))))
# --------------------------------------------------------------------------
# セッション操作
# --------------------------------------------------------------------------
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", {})
info = {
"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", ""),
"reason": st.get("reason", ""), # DeadlineExceeded 等
"message": st.get("message", ""),
"finishedAt": "",
"exitCode": None,
}
for cs in st.get("containerStatuses", []) or []:
term = (cs.get("state") or {}).get("terminated")
if term:
info["finishedAt"] = term.get("finishedAt", "")
info["exitCode"] = term.get("exitCode")
if not info["reason"]:
info["reason"] = term.get("reason", "")
out.append(info)
return out
def _rfc3339_epoch(value):
"""RFC3339(...Z)→ epoch 秒。失敗時は 0。"""
if not value:
return 0
try:
import datetime
return int(datetime.datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp())
except Exception: # noqa: BLE001
return 0
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")})
# ---- Claude 認証情報の渡し方 ----
# credentials モード: Secret をファイルとしてマウントし、エントリポイントが
# $HOME/.claude/.credentials.json へコピーする(書き込み可能にして claude 自身の
# リフレッシュを許す)。env var 名に "." を含められないため envFrom は使わない。
# apiKey / oauth モード: 有効な env var 名なので envFrom でそのまま注入する。
env_from = []
if ANTHROPIC_AUTH_MODE == "credentials":
volumes.append({
"name": "anthropic-creds",
"secret": {
"secretName": ANTHROPIC_SECRET,
"defaultMode": 0o400,
"items": [{"key": ANTHROPIC_CREDENTIALS_KEY, "path": "credentials.json"}],
},
})
mounts.append({"name": "anthropic-creds", "mountPath": "/anthropic", "readOnly": True})
env_list.append({"name": "SEED_CREDENTIALS_FILE", "value": "/anthropic/credentials.json"})
else:
env_from = [{"secretRef": {"name": ANTHROPIC_SECRET}}]
container = {
"name": "claude",
"image": SESSION_IMAGE,
"imagePullPolicy": SESSION_IMAGE_PULL_POLICY,
"command": ["/bin/sh", "/bootstrap/entrypoint.sh"],
"env": env_list,
"envFrom": env_from,
"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 の形式が不正です。" + REPO_HELP
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 ホストです: %s(許可済み: %s)"
% (host, ", ".join(GIT_ALLOWED_HOSTS) or "なし"))
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"):
# 終了済み Pod は TERMINATED_RETENTION の猶予後に回収(死因調査のため残す)
fin = _rfc3339_epoch(s.get("finishedAt")) or (int(s["createdAt"] or 0) + 120)
if now - fin >= TERMINATED_RETENTION:
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}
.st-Running{color:#059669;font-weight:600}
.st-Failed{color:#dc2626;font-weight:600}
.st-Pending,.st-Succeeded{color:#d97706;font-weight:600}
pre#logbox{white-space:pre-wrap;background:#111;color:#eee;padding:.8rem;border-radius:8px;max-height:50vh;overflow:auto;font-size:.8rem}
</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://host/org/repo.git または git@host:org/repo.git(SSH ユーザーは常に git)" required>
<div class="muted">SSH は <code>git@&lt;host&gt;:&lt;org&gt;/&lt;repo&gt;.git</code>(ユーザー名は自分のアカウントではなく必ず <code>git</code>)。認証は下で選ぶ identity の鍵で行われます。</div>
<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 class="card" id="logcard" hidden>
<div class="row" style="justify-content:space-between">
<strong>ログ: <code id="logid"></code></strong>
<button class="sec" style="width:auto;margin:0" onclick="$('#logcard').hidden=true">閉じる</button>
</div>
<pre id="logbox"></pre>
</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 showLog(id){
$('#logcard').hidden=false;$('#logid').textContent=id;
$('#logbox').textContent='読み込み中...';
const r=await api('/api/sessions/'+id+'/log');
$('#logbox').textContent=await r.text();
$('#logbox').scrollTop=$('#logbox').scrollHeight;
$('#logcard').scrollIntoView({behavior:'smooth'});
}
function esc(s){return (s||'').replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));}
async function refresh(){
const r=await api('/api/sessions');if(!r.ok)return;
const list=(await r.json()).sessions||[];
$('#rows').innerHTML=list.map(s=>{
let sub=esc(s.ref);
if(s.phase==='Failed'||s.reason){
const d=[s.reason,(s.exitCode!=null?'exit '+s.exitCode:''),esc(s.message)].filter(Boolean).join(' / ');
if(d) sub+=` — <span class="st-Failed">${d}</span>`;
}
const canOpen=s.phase==='Running';
return `<tr>
<td><code>${s.id}</code></td>
<td><span class="st-${s.phase}">${s.phase}</span></td>
<td style="word-break:break-all">${esc(s.repo)}<div class="muted">${sub}</div></td>
<td>${canOpen?`<a class="open" href="/s/${s.id}/" target="_blank" rel="noopener">開く</a> `:''}
<button class="sec" style="width:auto;margin:.3rem .3rem 0 0" onclick="showLog('${s.id}')">ログ</button>
<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()})
m = re.match(r"^/api/sessions/([a-f0-9]{10})/log$", path)
if m:
if not token_ok(self):
return self._json(401, {"error": "unauthorized"})
status, text = session_log(m.group(1))
return self._send(status if status == 200 else 200,
text or "(ログはまだありません)", "text/plain; charset=utf-8")
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()