From 1143b3c8f0717889fd81f0dd91966e81e5a46647 Mon Sep 17 00:00:00 2001 From: dofastted Date: Mon, 19 Oct 2026 22:44:56 +0800 Subject: [PATCH 1/9] =?UTF-8?q?feat(oauth):=20=E5=AE=98=E6=96=B9=20Claude?= =?UTF-8?q?=20Code=20=E7=99=BB=E5=BD=95=E6=94=B9=E7=94=B1=E6=A7=BD?= =?UTF-8?q?=E5=86=85=20cli-node=20=E6=8D=A2=E7=A5=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 容器运行且 cli-node 已安装后,claude auth login 自己生成链接并换票。宿主机不再构造 claude_code 授权链接。 --- scripts/claude-auth-login-pty.py | 368 +++++++++++++++++++ src/lib/admin/panel-routes.mjs | 55 ++- src/lib/oauth/claude-auth-login.mjs | 442 +++++++++++++++++++++++ src/lib/oauth/oauth-auth-url.mjs | 3 + src/lib/vm/cli-node-guard.mjs | 7 +- test/e2e/panel-api.e2e.test.mjs | 19 +- test/unit/claude-auth-login.test.mjs | 211 +++++++++++ test/unit/cli-node-guard.test.mjs | 4 +- test/unit/oauth-auth-url.test.mjs | 39 +- web/src/features/vm/credential-panel.tsx | 6 +- 10 files changed, 1096 insertions(+), 58 deletions(-) create mode 100644 scripts/claude-auth-login-pty.py create mode 100644 src/lib/oauth/claude-auth-login.mjs create mode 100644 test/unit/claude-auth-login.test.mjs diff --git a/scripts/claude-auth-login-pty.py b/scripts/claude-auth-login-pty.py new file mode 100644 index 000000000..3a29fc20c --- /dev/null +++ b/scripts/claude-auth-login-pty.py @@ -0,0 +1,368 @@ +#!/usr/bin/env python3 +"""Host PTY for `claude auth login` inside a slot container. + +The official CLI prints the manual authorize URL, reads the pasted code, and +exchanges it itself. This process only extracts the URL and copies the full +OAuth grant the CLI wrote. It does not build PKCE or call the token endpoint. +""" +from __future__ import annotations + +import fcntl +import json +import os +import re +import select +import struct +import termios +import time +from pathlib import Path + +URL_RE = re.compile( + rb"https://claude\.com/cai/oauth/authorize\?[^\s\x1b\x07\x08\"']+" +) +OSC_URL_RE = re.compile( + rb"\x1b\]8;[^;]*;(https://claude\.com/cai/oauth/authorize\?[^\x07]+)\x07" +) +ANSI_RE = re.compile(rb"\x1b(?:\[[0-9;]*[A-Za-z]|\].*?(?:\x07|\x1b\\))") +INVALID_RE = re.compile(rb"Invalid code|full code was copied", re.I) +OAUTH_FAIL_RE = re.compile(rb"OAuth error:\s*([^\r\n\x1b]+)|Login failed:\s*([^\r\n\x1b]+)", re.I) +RETRY_RE = re.compile(rb"Press Enter to retry", re.I) +PROMPT_RE = re.compile(rb"Paste code here", re.I) +SUCCESS_RE = re.compile(rb"Login successful", re.I) +BRACKET_START = b"\x1b[200~" +BRACKET_END = b"\x1b[201~" + + +def session_dir() -> Path: + return Path(os.environ["KIN_SESSION_DIR"]) + + +def credential_file() -> Path: + return Path(os.environ["KIN_CREDENTIAL_FILE"]) + + +def write_json(path: Path, doc: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(doc, ensure_ascii=False) + "\n", encoding="utf-8") + os.chmod(tmp, 0o600) + tmp.replace(path) + + +def write_status(**fields) -> None: + path = session_dir() / "status.json" + doc = {} + if path.exists(): + try: + doc = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + doc = {} + doc.update(fields) + doc.setdefault("session_id", os.environ.get("KIN_SESSION_ID", "")) + doc.setdefault("vm_id", os.environ.get("KIN_VM_ID", "")) + doc.setdefault("created_at", int(os.environ.get("KIN_CREATED_AT") or "0")) + doc.setdefault("expires_at", int(os.environ.get("KIN_EXPIRES_AT") or "0")) + doc["updated_at"] = int(time.time() * 1000) + write_json(path, doc) + + +def strip_ansi(raw: bytes) -> bytes: + return ANSI_RE.sub(b"", raw) + + +def decode_url(raw: bytes) -> str: + osc = OSC_URL_RE.search(raw) + if osc: + return osc.group(1).decode("ascii", "ignore") + compact = re.sub(rb"[\r\n\s]+", b"", strip_ansi(raw)) + m = URL_RE.search(compact) + return m.group(0).decode("ascii", "ignore") if m else "" + + +def set_winsize(fd: int, rows: int = 40, cols: int = 220) -> None: + try: + fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0)) + except OSError: + pass + + +def set_nonblocking(fd: int) -> None: + flags = fcntl.fcntl(fd, fcntl.F_GETFL) + fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK) + + +def write_all(fd: int, data: bytes) -> bool: + view = memoryview(data) + while view: + try: + n = os.write(fd, view) + except BlockingIOError: + return False + except OSError: + return False + if n <= 0: + return False + view = view[n:] + return True + + +def build_argv() -> list[str]: + raw = os.environ.get("KIN_DOCKER_ARGV", "").strip() + if raw: + argv = json.loads(raw) + if not isinstance(argv, list) or not argv or argv[0] != "exec": + raise SystemExit(2) + return ["docker", *argv] + container = os.environ["KIN_CONTAINER"] + uid = os.environ["KIN_UID"] + gid = os.environ["KIN_GID"] + tz = os.environ.get("TZ", "UTC") + lang = os.environ.get("LANG", "en_US.UTF-8") + cli = os.environ["KIN_CLI_BIN"] + return [ + "docker", "exec", "-it", + "-u", f"{uid}:{gid}", + "-e", "HOME=/home/kincli", + "-e", "TMPDIR=/home/kincli/.cache/tmp", + "-e", "CLAUDE_CONFIG_DIR=/home/kincli/.claude", + "-e", f"TZ={tz}", + "-e", f"LANG={lang}", + "-e", f"LC_ALL={lang}", + "-e", "PATH=/home/kincli/.local/bin:/usr/bin:/bin", + "-e", "CLAUDE_CODE_HOST_REFRESH=1", + "-e", f"CLAUDE_CODE_VERSION={os.environ.get('KIN_CLI_VERSION', '2.1.296')}", + "-e", "USER_TYPE=external", + "-e", "KIN_AUTH_LOGIN=1", + "-e", "CLAUDE_CODE_USE_BEDROCK=0", + "-e", "CLAUDE_CODE_USE_VERTEX=0", + "-e", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=0", + "-e", "DISABLE_TELEMETRY=1", + "-e", "DO_NOT_TRACK=1", + "-e", "ANTHROPIC_BASE_URL=", + "-e", "ANTHROPIC_API_KEY=", + "-e", "ANTHROPIC_AUTH_TOKEN=", + "-e", "CLAUDE_CODE_OAUTH_TOKEN=", + "-e", "CLAUDE_CODE_OAUTH_REFRESH_TOKEN=", + "-e", "TERM=xterm-256color", + "-e", "COLUMNS=220", + "-e", "LINES=40", + "-w", "/home/kincli", + container, + cli, + "auth", + "login", + ] + + +def sanitize_transcript(raw: bytes) -> str: + text = strip_ansi(raw).decode("utf-8", "replace") + text = re.sub(r"sk-ant-[a-z0-9]+-[A-Za-z0-9_-]{8,}", "sk-ant-***", text, flags=re.I) + text = re.sub(r"https://claude\.com/cai/oauth/authorize\S+", "https://claude.com/cai/oauth/authorize?***", text) + text = re.sub(r"(code_challenge|state|code)=[A-Za-z0-9_\-~.]+", r"\1=***", text) + return text[-4000:] + + +def write_transcript(buf: bytes) -> None: + try: + dest = session_dir() / "pty.out" + dest.write_text(sanitize_transcript(buf), encoding="utf-8") + os.chmod(dest, 0o600) + except OSError: + pass + + +def feed_inbox(master: int, last_mtime: int | None, ready: bool) -> int | None: + if not ready: + return last_mtime + inbox = session_dir() / "code.inbox" + if not inbox.exists(): + return last_mtime + try: + st = inbox.stat() + raw = inbox.read_bytes() + except OSError: + return last_mtime + if last_mtime is not None and st.st_mtime_ns <= last_mtime: + return last_mtime + text = raw.decode("utf-8", "replace").strip() + if not text: + return last_mtime + payload = BRACKET_START + text.encode("utf-8", "replace") + BRACKET_END + b"\r" + if not write_all(master, payload): + write_status(status="waiting_auth", error="授权码未能写入官方 CLI 输入框,请重试") + return last_mtime + try: + inbox.unlink() + except OSError: + pass + write_status(status="code_fed", error=None, code_fed=True) + return st.st_mtime_ns + + +def snapshot_credential(before_ns: int) -> bool: + src = credential_file() + try: + st = src.stat() + except OSError: + return False + if st.st_mtime_ns + 2_000_000_000 < before_ns: + return False + try: + raw = src.read_bytes() + doc = json.loads(raw.decode("utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError): + return False + oauth = doc.get("claudeAiOauth") if isinstance(doc, dict) else None + if not isinstance(oauth, dict): + oauth = doc if isinstance(doc, dict) else {} + access = str(oauth.get("accessToken") or oauth.get("access_token") or "") + refresh = str(oauth.get("refreshToken") or oauth.get("refresh_token") or "") + if not access or not refresh: + return False + dest = session_dir() / "credential.json" + dest.write_bytes(raw if raw.endswith(b"\n") else raw + b"\n") + os.chmod(dest, 0o600) + return True + + +def leave_service_cgroup() -> None: + """Survive `systemctl restart vm2api` (default KillMode=control-group).""" + pid = f"{os.getpid()}\n" + for path in ( + "/sys/fs/cgroup/system.slice/cgroup.procs", + "/sys/fs/cgroup/cgroup.procs", + ): + try: + with open(path, "w", encoding="ascii") as fh: + fh.write(pid) + return + except OSError: + continue + + +def main() -> int: + leave_service_cgroup() + session_dir().mkdir(parents=True, exist_ok=True) + started_ns = time.time_ns() + write_status(status="starting", auth_url="", error=None, code_fed=False) + master, slave = os.openpty() + set_winsize(master) + set_winsize(slave) + pid = os.fork() + if pid == 0: + os.setsid() + os.close(master) + os.dup2(slave, 0) + os.dup2(slave, 1) + os.dup2(slave, 2) + if slave > 2: + os.close(slave) + os.execvp("docker", build_argv()) + os.close(slave) + set_nonblocking(master) + (session_dir() / "pty.pid").write_text(f"{os.getpid()}\n", encoding="utf-8") + (session_dir() / "child.pid").write_text(f"{pid}\n", encoding="utf-8") + buf = b"" + url = "" + logged_in = False + last_mtime = None + last_beat = 0.0 + prompt_ready = False + waiting_fresh_prompt = False + last_oauth = b"" + deadline = time.time() + 32 * 60 + try: + while time.time() < deadline: + if logged_in and snapshot_credential(started_ns): + write_status(status="login_ready", auth_url=url, error=None) + return 0 + try: + _, status = os.waitpid(pid, os.WNOHANG) + except ChildProcessError: + status = 0 + pid_alive = False + else: + pid_alive = status == 0 + ready, _, _ = select.select([master], [], [], 0.25) + if ready: + try: + chunk = os.read(master, 8192) + except BlockingIOError: + chunk = b"" + except OSError: + chunk = b"" + if chunk: + buf += chunk + if len(buf) > 64_000: + buf = buf[-32_000:] + write_transcript(buf) + tail = strip_ansi(buf[-8000:]) + if not url: + url = decode_url(buf) + if url: + write_status(status="waiting_auth", auth_url=url, error=None) + prompt_ready = True + if SUCCESS_RE.search(tail): + logged_in = True + if INVALID_RE.search(tail) and not logged_in: + write_status( + status="waiting_auth", + auth_url=url, + code_fed=False, + error="授权码无效或不完整,请粘贴浏览器显示的完整授权码(含 # 后半段)", + ) + prompt_ready = True + oauth_fail = OAUTH_FAIL_RE.search(tail) + if oauth_fail and not logged_in and oauth_fail.group(0) != last_oauth: + last_oauth = oauth_fail.group(0) + detail = (oauth_fail.group(1) or oauth_fail.group(2) or b"").decode( + "utf-8", "replace" + ).strip()[:160] + write_status( + status="waiting_retry", + auth_url=url, + code_fed=False, + error=f"官方 CLI 换票失败({detail})。请再打开当前这条链接拿新码(含 #),不要重新生成", + ) + write_all(master, b"\r") + prompt_ready = False + waiting_fresh_prompt = True + if RETRY_RE.search(tail) and not logged_in: + prompt_ready = False + if waiting_fresh_prompt and chunk and PROMPT_RE.search(strip_ansi(chunk)) and not logged_in: + waiting_fresh_prompt = False + prompt_ready = True + write_status(status="waiting_auth", auth_url=url, code_fed=False) + last_mtime = feed_inbox(master, last_mtime, prompt_ready and not logged_in) + now = time.time() + if now - last_beat > 5: + write_status(auth_url=url or "") + deadline = max(deadline, now + 32 * 60) + last_beat = now + if not pid_alive: + write_transcript(buf) + if logged_in and snapshot_credential(started_ns): + write_status(status="login_ready", auth_url=url, error=None) + return 0 + write_status( + status="exited", + auth_url=url, + error="claude auth login 已退出,且未写入完整 OAuth", + ) + return 1 + write_transcript(buf) + write_status(status="error", auth_url=url, error="等待授权超时") + return 1 + finally: + try: + os.kill(pid, 15) + except OSError: + pass + try: + os.close(master) + except OSError: + pass + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/lib/admin/panel-routes.mjs b/src/lib/admin/panel-routes.mjs index 0d5aae684..bb7b34ab1 100644 --- a/src/lib/admin/panel-routes.mjs +++ b/src/lib/admin/panel-routes.mjs @@ -38,6 +38,13 @@ import { looksLikeOfficialSetupToken, SETUP_TOKEN_FLAVOR, } from '../oauth/claude-setup-token.mjs' +import { + completeClaudeAuthLogin, + readAuthLoginSession, + startClaudeAuthLoginSession, + AUTH_LOGIN_FLAVOR, + AUTH_LOGIN_SOURCE, +} from '../oauth/claude-auth-login.mjs' import { canOfficialCc, isApiKeyMode, @@ -4493,6 +4500,22 @@ export function createPanelHandler(ctx) { ) } const flavor = body.flavor || body.type || 'cai' + if (normalizeOauthFlavor(flavor) === 'claude_code') { + const generated = await startClaudeAuthLoginSession({ + vm: existing, + projectRoot: cfg.paths.project, + force: body.force === true || body.force === 'true' || body.force === 1, + }) + const px = existing.proxy || {} + return json( + res, + 200, + panel.ok({ + ...generated, + proxy_hint: slotProxyHint(px, proxyPool?.labelOf(px.id)), + }), + ) + } const generated = generateAuthUrl({ vmId: id, proxyUrl: slotProxy.proxyUrl, @@ -4580,22 +4603,40 @@ export function createPanelHandler(ctx) { } const code = body.code || body.auth_code || '' const flavor = body.flavor || body.type || '' + const loginSession = readAuthLoginSession(cfg.paths.project, id) + const sessionKey = String(body.session_id || body.sessionId || '') + const officialLogin = + normalizeOauthFlavor(flavor) === 'claude_code' || + (loginSession?.alive && + !loginSession.expired && + loginSession.session_id && + loginSession.session_id === sessionKey) let oauth = looksLikeOfficialSetupToken(code) ? await completeClaudeSetupToken({ projectRoot: cfg.paths.project, vmId: id, - sessionId: body.session_id || body.sessionId, + sessionId: sessionKey, code, }) - : await exchangeAuthCode({ - sessionId: body.session_id || body.sessionId, - code, - proxyUrl: slotProxy.proxyUrl, - vmId: id, - }) + : officialLogin + ? await completeClaudeAuthLogin({ + projectRoot: cfg.paths.project, + vmId: id, + sessionId: sessionKey, + code, + }) + : await exchangeAuthCode({ + sessionId: sessionKey, + code, + proxyUrl: slotProxy.proxyUrl, + vmId: id, + }) if (looksLikeOfficialSetupToken(code) || oauth.flavor === SETUP_TOKEN_FLAVOR) { oauth.type = 'official-setup-token' oauth.mode = 'official-setup-token' + } else if (oauth.flavor === AUTH_LOGIN_FLAVOR || oauth.source === AUTH_LOGIN_SOURCE) { + oauth.type = 'oauth' + oauth.mode = 'oauth' } else if ( normalizeOauthFlavor(flavor) === 'setup_token' || oauth.flavor === 'setup_token' || diff --git a/src/lib/oauth/claude-auth-login.mjs b/src/lib/oauth/claude-auth-login.mjs new file mode 100644 index 000000000..197ff63e2 --- /dev/null +++ b/src/lib/oauth/claude-auth-login.mjs @@ -0,0 +1,442 @@ +/** + * Official `claude auth login` inside a slot that already has a running + * container and an installed cli-node. The CLI owns PKCE, the authorize URL + * and the token exchange. The host only extracts the URL, feeds the pasted + * code, and reads the full OAuth grant the CLI wrote. + * + * This is not `claude setup-token` and not the host-side kin-oauth-auth + * exchange. Cookie import and setup-token stay on their own paths. + */ +import { spawn, spawnSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import crypto from 'node:crypto' +import { fileURLToPath } from 'node:url' +import { containerName, inspectContainer } from '../vm/vm-runtime.mjs' +import { officialCcUidGid, officialCcHome, officialCcHostCli } from './official-cc-bootstrap.mjs' +import { CONTAINER_CLI_NODE_BIN } from '../vm/slot-engine.mjs' +import { OFFICIAL_CLI_VERSION } from '../identity/vm-identity.mjs' +import { slotExecArgv } from '../vm/slot-exec.mjs' +import { getVm } from '../vm/vm-registry.mjs' +import { slotHost } from '../vm/slot-host.mjs' +import { FULL_OAUTH_SCOPE } from './oauth-contract.mjs' +import { flattenOauthIdentity } from './oauth-identity.mjs' + +export const AUTH_LOGIN_SESSION_TTL_MS = 30 * 60 * 1000 +export const AUTH_LOGIN_SOURCE = 'claude-auth-login' +export const AUTH_LOGIN_FLAVOR = 'claude_code' + +const URL_WAIT_MS = 45_000 +const TOKEN_WAIT_MS = 90_000 + +function fail(code, message) { + const err = new Error(message) + err.code = code + return err +} + +function scriptsDir() { + return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../scripts') +} + +export function authLoginSessionDir(projectRoot, vmId) { + return path.join(projectRoot, 'vms', vmId, 'run', 'auth-login') +} + +/** Official `claude auth login` prints the manual CAI URL, not the console authorize page. */ +export function extractAuthLoginUrl(raw = '') { + const text = String(raw || '') + const osc = text.match(/\x1b\]8;[^;]*;(https:\/\/claude\.com\/cai\/oauth\/authorize\?[^\x07]+)\x07/) + if (osc?.[1]) return osc[1].trim() + const stripped = text.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '').replace(/\r/g, '') + const compact = stripped.replace(/\s+/g, '') + const m = compact.match(/https:\/\/claude\.com\/cai\/oauth\/authorize\?[A-Za-z0-9_\-.=&%+]+/) + return m ? m[0] : '' +} + +function readJson(file) { + try { + return JSON.parse(fs.readFileSync(file, 'utf8')) + } catch { + return null + } +} + +function pidAlive(pid) { + if (!pid) return false + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +function readPidFile(file) { + try { + const n = Number(String(fs.readFileSync(file, 'utf8')).trim()) + return Number.isInteger(n) && n > 0 ? n : 0 + } catch { + return 0 + } +} + +export function readAuthLoginSession(projectRoot, vmId) { + const dir = authLoginSessionDir(projectRoot, vmId) + const status = readJson(path.join(dir, 'status.json')) + if (!status) return null + const ptyPid = readPidFile(path.join(dir, 'pty.pid')) + const alive = !!(ptyPid && pidAlive(ptyPid)) + const expired = Number(status.expires_at || 0) && Date.now() > Number(status.expires_at) + return { + session_id: status.session_id || '', + vm_id: status.vm_id || vmId, + status: status.status || '', + auth_url: status.auth_url || '', + error: status.error || null, + code_fed: !!status.code_fed, + created_at: status.created_at || 0, + updated_at: status.updated_at || 0, + expires_at: status.expires_at || 0, + flavor: AUTH_LOGIN_FLAVOR, + source: AUTH_LOGIN_SOURCE, + alive, + expired: !!expired, + } +} + +export function isPersistentAuthLoginSession(session, { force = false } = {}) { + if (force || !session) return false + return !!(session.alive && session.auth_url && !session.expired && session.status !== 'login_ready') +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +async function waitFor(fn, timeoutMs, intervalMs = 200) { + const start = Date.now() + while (Date.now() - start < timeoutMs) { + const value = fn() + if (value) return value + await sleep(intervalMs) + } + return null +} + +/** + * Official login may run only after the slot container exists and cli-node + * is installed in that slot. A seed file or a stopped container is not enough. + */ +export function assertAuthLoginReady(projectRoot, vm, { inspect = inspectContainer } = {}) { + const vmId = vm?.id + if (!vmId || !projectRoot) throw fail('vm_required', 'vm_id required') + if (!slotHost(vm).supports('official_cc')) { + throw fail('remote_unsupported', '集群节点上的虚拟机暂不支持槽内官方登录') + } + const name = containerName(vmId) + const inspected = inspect(name) + if (!inspected?.running) { + throw fail('container_not_ready', '容器尚未创建或未运行,请先完成容器创建再登录') + } + const cli = officialCcHostCli(officialCcHome(projectRoot, vmId)) + if (!fs.existsSync(cli)) { + throw fail('cli_node_missing', 'cli-node 尚未安装,请先同步 wrap-cli') + } + return { container: name, cli } +} + +export async function stopClaudeAuthLoginSession(projectRoot, vmId, { spawn = spawnSync } = {}) { + const dir = authLoginSessionDir(projectRoot, vmId) + const ptyPid = readPidFile(path.join(dir, 'pty.pid')) + const childPid = readPidFile(path.join(dir, 'child.pid')) + for (const pid of [ptyPid, childPid]) { + if (pid && pidAlive(pid)) { + try { + process.kill(pid, 'SIGTERM') + } catch {} + } + } + const vm = { ...(getVm(projectRoot, vmId) || {}), id: vmId } + try { + const env = slotHost(vm).dockerEnv() + spawn( + 'docker', + slotExecArgv(vm, ['sh', '-lc', `pkill -f '${CONTAINER_CLI_NODE_BIN} auth login' >/dev/null 2>&1 || true`]), + { + stdio: 'ignore', + timeout: 5000, + ...(env ? { env } : {}), + }, + ) + } catch {} + + const deadline = Date.now() + 3000 + while (Date.now() < deadline) { + if (![ptyPid, childPid].some((pid) => pid && pidAlive(pid))) break + await sleep(50) + } + for (const pid of [ptyPid, childPid]) { + if (pid && pidAlive(pid)) { + try { + process.kill(pid, 'SIGKILL') + } catch {} + } + } + for (const name of ['code.inbox', 'credential.json', 'pty.pid', 'child.pid', 'pty.out', 'status.json']) { + try { + fs.unlinkSync(path.join(dir, name)) + } catch {} + } + return { ok: true } +} + +function loginEnv(vm) { + const locale = vm.locale || vm.fingerprint?.locale || 'en_US.UTF-8' + const tz = vm.timezone || vm.fingerprint?.timezone || 'UTC' + return [ + 'HOME=/home/kincli', + 'TMPDIR=/home/kincli/.cache/tmp', + 'CLAUDE_CONFIG_DIR=/home/kincli/.claude', + `TZ=${tz}`, + `LANG=${locale}`, + `LC_ALL=${locale}`, + 'PATH=/home/kincli/.local/bin:/usr/bin:/bin', + 'CLAUDE_CODE_HOST_REFRESH=1', + `CLAUDE_CODE_VERSION=${OFFICIAL_CLI_VERSION}`, + 'USER_TYPE=external', + // The slot cli-node guard spares this host-run official login. + 'KIN_AUTH_LOGIN=1', + 'CLAUDE_CODE_USE_BEDROCK=0', + 'CLAUDE_CODE_USE_VERTEX=0', + 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=0', + 'DISABLE_TELEMETRY=1', + 'DO_NOT_TRACK=1', + 'ANTHROPIC_BASE_URL=', + 'ANTHROPIC_API_KEY=', + 'ANTHROPIC_AUTH_TOKEN=', + 'CLAUDE_CODE_OAUTH_TOKEN=', + 'CLAUDE_CODE_OAUTH_REFRESH_TOKEN=', + 'TERM=xterm-256color', + 'COLUMNS=220', + 'LINES=40', + ] +} + +export async function startClaudeAuthLoginSession({ vm, projectRoot, force = false, inspect = inspectContainer } = {}) { + const vmId = vm?.id + if (!vmId || !projectRoot) throw fail('vm_required', 'vm_id required') + assertAuthLoginReady(projectRoot, vm, { inspect }) + const fake = process.env.KIN_FAKE_SESSION_OAUTH === '1' || process.env.KIN_FAKE_SESSION_OAUTH === 'true' + if (fake) { + return { + session_id: 'fake-auth-login', + auth_url: 'https://claude.com/cai/oauth/authorize?code=true&fake=auth-login', + expires_at: Date.now() + AUTH_LOGIN_SESSION_TTL_MS, + vm_id: vmId, + flavor: AUTH_LOGIN_FLAVOR, + source: AUTH_LOGIN_SOURCE, + } + } + const existing = readAuthLoginSession(projectRoot, vmId) + if (isPersistentAuthLoginSession(existing, { force })) { + return { + session_id: existing.session_id, + auth_url: existing.auth_url, + expires_at: existing.expires_at, + vm_id: vmId, + flavor: AUTH_LOGIN_FLAVOR, + source: AUTH_LOGIN_SOURCE, + reused: true, + } + } + await stopClaudeAuthLoginSession(projectRoot, vmId) + const dir = authLoginSessionDir(projectRoot, vmId) + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }) + const sessionId = crypto.randomBytes(16).toString('hex') + const createdAt = Date.now() + const expiresAt = createdAt + AUTH_LOGIN_SESSION_TTL_MS + const ids = officialCcUidGid(vmId) + const ptyScript = path.join(scriptsDir(), 'claude-auth-login-pty.py') + const execArgv = slotExecArgv(vm, [CONTAINER_CLI_NODE_BIN, 'auth', 'login'], { + user: `${ids.uid}:${ids.gid}`, + env: loginEnv(vm), + workdir: '/home/kincli', + interactive: true, + tty: true, + }) + const child = spawn('python3', [ptyScript], { + env: { + PATH: process.env.PATH || '/usr/bin:/bin', + HOME: process.env.HOME || '/root', + LANG: vm.locale || vm.fingerprint?.locale || 'en_US.UTF-8', + LC_ALL: vm.locale || vm.fingerprint?.locale || 'en_US.UTF-8', + TZ: vm.timezone || vm.fingerprint?.timezone || 'UTC', + TERM: 'xterm-256color', + KIN_CONTAINER: containerName(vmId), + KIN_UID: String(ids.uid), + KIN_GID: String(ids.gid), + KIN_CLI_BIN: CONTAINER_CLI_NODE_BIN, + KIN_CLI_VERSION: OFFICIAL_CLI_VERSION, + KIN_SESSION_DIR: dir, + KIN_CREDENTIAL_FILE: path.join(officialCcHome(projectRoot, vmId), '.claude', '.credentials.json'), + KIN_SESSION_ID: sessionId, + KIN_VM_ID: vmId, + KIN_CREATED_AT: String(createdAt), + KIN_EXPIRES_AT: String(expiresAt), + KIN_SESSION_TTL_MS: String(AUTH_LOGIN_SESSION_TTL_MS), + KIN_DOCKER_ARGV: JSON.stringify(execArgv), + }, + detached: true, + stdio: 'ignore', + }) + child.unref() + const found = await waitFor(() => { + const s = readAuthLoginSession(projectRoot, vmId) + if (Number(s?.updated_at || 0) < createdAt) return null + if (s?.error && (s.status === 'error' || s.status === 'exited')) throw fail('auth_login_failed', s.error) + return s?.auth_url ? s : null + }, URL_WAIT_MS) + if (!found?.auth_url) { + const s = readAuthLoginSession(projectRoot, vmId) + throw fail('auth_login_url_timeout', s?.error || '槽内 claude auth login 未给出授权链接') + } + return { + session_id: found.session_id || sessionId, + auth_url: found.auth_url, + expires_at: found.expires_at || expiresAt, + vm_id: vmId, + flavor: AUTH_LOGIN_FLAVOR, + source: AUTH_LOGIN_SOURCE, + reused: false, + } +} + +function scopeText(oauth) { + if (Array.isArray(oauth?.scopes) && oauth.scopes.length) return oauth.scopes.filter(Boolean).join(' ') + return String(oauth?.scope || '').trim() +} + +/** Full OAuth written by official login. Setup-token oats and refresh-less grants are not this flow. */ +export function officialLoginCredentialToOauth(doc, extra = {}) { + const oauth = doc?.claudeAiOauth && typeof doc.claudeAiOauth === 'object' ? doc.claudeAiOauth : doc || {} + const access = String(oauth.accessToken || oauth.access_token || '').trim() + const refresh = String(oauth.refreshToken || oauth.refresh_token || '').trim() + const scope = scopeText(oauth) + const inferenceOnly = scope && !/\buser:profile\b/.test(scope) && /\buser:inference\b/.test(scope) + if (!access || !refresh || inferenceOnly) { + throw fail('auth_login_incomplete', '官方登录未写入完整 OAuth(需要 access、refresh 和 user:profile)') + } + if (!/\buser:profile\b/.test(scope) || !/\buser:inference\b/.test(scope)) { + throw fail('auth_login_scope', '官方登录没有拿到完整 OAuth scope') + } + const expiresAt = Number(oauth.expiresAt || oauth.expires_at || 0) + const expiresIn = expiresAt > Date.now() ? Math.floor((expiresAt - Date.now()) / 1000) : 0 + const identity = flattenOauthIdentity({ + ...oauth, + email: oauth.email || oauth.emailAddress || oauth.email_address, + account_uuid: oauth.accountUuid || oauth.account_uuid, + org_uuid: oauth.orgUuid || oauth.org_uuid, + subscription_type: oauth.subscriptionType || oauth.subscription_type, + }) + return { + access_token: access, + refresh_token: refresh, + token_type: 'Bearer', + expires_in: expiresIn, + expires_at: expiresAt ? Math.floor(expiresAt / 1000) : null, + scope, + email: identity.email, + email_address: identity.email, + account_uuid: identity.account_uuid, + org_uuid: identity.org_uuid, + subscription_type: identity.subscription_type, + type: 'oauth', + mode: 'oauth', + source: AUTH_LOGIN_SOURCE, + flavor: AUTH_LOGIN_FLAVOR, + ...extra, + } +} + +function readLoginCredential(file) { + const doc = readJson(file) + if (!doc) return null + try { + return officialLoginCredentialToOauth(doc) + } catch (err) { + if (err.code === 'auth_login_incomplete') return null + throw err + } +} + +export async function completeClaudeAuthLogin({ projectRoot, vmId, sessionId, code } = {}) { + const raw = String(code || '').trim() + if (!raw) throw fail('code_required', '请粘贴授权码(含 # 后半段)') + if (process.env.KIN_FAKE_SESSION_OAUTH === '1' || process.env.KIN_FAKE_SESSION_OAUTH === 'true') { + return officialLoginCredentialToOauth({ + claudeAiOauth: { + accessToken: 'sk-ant-oat01-FAKE-AUTH-URL', + refreshToken: 'sk-ant-ort01-' + 'R'.repeat(24), + expiresAt: Date.now() + 8 * 3600 * 1000, + scopes: FULL_OAUTH_SCOPE.split(' '), + email: 'fake-oauth@kin.test', + accountUuid: 'acct-fake-auth-login', + orgUuid: 'org-fake-auth-login', + subscriptionType: 'pro', + }, + }) + } + const session = readAuthLoginSession(projectRoot, vmId) + if (!session?.alive || session.expired) { + throw fail('session_expired', '官方登录会话不存在或已过期,请重新生成授权链接') + } + if (sessionId && session.session_id && session.session_id !== String(sessionId)) { + throw fail('session_vm_mismatch', '授权会话与当前虚拟机不匹配') + } + const dir = authLoginSessionDir(projectRoot, vmId) + const credFile = path.join(dir, 'credential.json') + if (session.status === 'login_ready') { + const ready = readLoginCredential(credFile) + if (ready) { + await stopClaudeAuthLoginSession(projectRoot, vmId) + return ready + } + } + const fedAt = Date.now() + const statusPath = path.join(dir, 'status.json') + const prev = readJson(statusPath) || {} + fs.writeFileSync( + statusPath, + JSON.stringify({ + ...prev, + status: 'code_fed', + error: null, + code_fed: true, + updated_at: fedAt, + }) + '\n', + { encoding: 'utf8', mode: 0o600 }, + ) + fs.writeFileSync(path.join(dir, 'code.inbox'), raw + '\n', { mode: 0o600 }) + const credential = await waitFor( + () => { + const s = readAuthLoginSession(projectRoot, vmId) + if (s?.error && Number(s.updated_at || 0) > fedAt) { + throw fail(s.status === 'error' ? 'auth_login_oauth_failed' : 'auth_login_invalid_code', s.error) + } + if (s?.status !== 'login_ready') return null + return readLoginCredential(credFile) + }, + TOKEN_WAIT_MS, + 300, + ) + if (!credential) { + const s = readAuthLoginSession(projectRoot, vmId) + throw fail( + 'auth_login_timeout', + s?.error || '官方 CLI 未完成换票。请再打开当前链接粘贴完整授权码(含 # 后半段),不要重新生成', + ) + } + await stopClaudeAuthLoginSession(projectRoot, vmId) + return credential +} diff --git a/src/lib/oauth/oauth-auth-url.mjs b/src/lib/oauth/oauth-auth-url.mjs index 2231c0242..ba72958a7 100644 --- a/src/lib/oauth/oauth-auth-url.mjs +++ b/src/lib/oauth/oauth-auth-url.mjs @@ -121,6 +121,9 @@ export function buildAuthorizationURL(state, codeChallenge, scope = SCOPE_OAUTH, export function generateAuthUrl({ vmId, proxyUrl, flavor } = {}) { sweepExpired() + if (normalizeOauthFlavor(flavor) === 'claude_code') { + throw fail('claude_code_in_slot', '官方 Claude Code 登录必须由槽内 cli-node 执行,不能由宿主机生成授权链接') + } if (!vmId) throw fail('vm_required', 'vm_id required (先创建虚拟机)') if (normalizeSocks(proxyUrl) == null) { throw fail('proxy_required', '虚拟机未绑定 SOCKS5,请先分配代理再生成授权链接') diff --git a/src/lib/vm/cli-node-guard.mjs b/src/lib/vm/cli-node-guard.mjs index e2c4063bf..5622b8a1d 100644 --- a/src/lib/vm/cli-node-guard.mjs +++ b/src/lib/vm/cli-node-guard.mjs @@ -4,8 +4,9 @@ * still sets CLAUDE_CODE_KIN_NATIVE_SLOTS; that process is the kernel CLI * until sync, not a leak. When both are present, keep the unprefixed one. * Beside it may run: any `claude` (interactive or `-p`) in a live panel shell, - * and host-run init bootstrap / setup-token CLIs (KIN_OFFICIAL_CC=1 / - * KIN_SETUP_TOKEN=1) that have their own lifecycle. Anything else is a leak + * and host-run init bootstrap / setup-token / official auth-login CLIs + * (KIN_OFFICIAL_CC=1 / KIN_SETUP_TOKEN=1 / KIN_AUTH_LOGIN=1) that have their + * own lifecycle. Anything else is a leak * from a closed terminal (cli-node ignores SIGHUP) and is killed. A process * whose environ cannot be read is left alone. */ @@ -55,7 +56,7 @@ for d in "$proc"/[0-9]*; do kill -KILL "$pid" 2>/dev/null || true continue fi - printf '%s\\n' "$env" | grep -qx -e 'KIN_OFFICIAL_CC=1' -e 'KIN_SETUP_TOKEN=1' && continue + printf '%s\\n' "$env" | grep -qx -e 'KIN_OFFICIAL_CC=1' -e 'KIN_SETUP_TOKEN=1' -e 'KIN_AUTH_LOGIN=1' && continue panel=$(printf '%s\\n' "$env" | sed -n 's/^KIN_PANEL_SHELL=//p' | head -n 1) if [ -n "$panel" ]; then for token in "$@"; do diff --git a/test/e2e/panel-api.e2e.test.mjs b/test/e2e/panel-api.e2e.test.mjs index 5c8cffad8..a807cbc8f 100644 --- a/test/e2e/panel-api.e2e.test.mjs +++ b/test/e2e/panel-api.e2e.test.mjs @@ -342,27 +342,14 @@ test('pasted setup token survives identity enrichment and persists inference cre } }) -test('generate-auth-url claude_code flavor then exchange-code', async () => { +test('claude_code flavor refuses login before the slot container and cli-node exist', async () => { const gw = await startGateway({ oauth: false }) try { const gen = await api(gw, 'POST', '/api/panel/vms/vm-sim-01/oauth/generate-auth-url', { body: { flavor: 'claude_code' }, }) - assert.equal(gen.status, 200, gen.text) - const data = gen.json.data || gen.json - assert.match(String(data.auth_url || ''), /claude\.ai\/oauth\/authorize/) - assert.equal(data.flavor, 'claude_code') - assert.ok(data.session_id) - const ex = await api(gw, 'POST', '/api/panel/vms/vm-sim-01/oauth/exchange-code', { - body: { session_id: data.session_id, code: 'pasted-auth-code' }, - }) - assert.equal(ex.status, 200, ex.text) - const out = ex.json.data || ex.json - assert.equal(out.oauth_email, 'fake-oauth@kin.test') - assert.equal(out.flavor, 'claude_code') - const vm = JSON.parse(fs.readFileSync(path.join(gw.project, 'vms', 'vm-sim-01.json'), 'utf8')) - assert.match(vm.claude.scope, /user:sessions:claude_code/) - assert.doesNotMatch(vm.claude.scope, /user:file_upload/) + assert.equal(gen.status, 400, gen.text) + assert.equal(gen.json.error.code, 'container_not_ready') } finally { await gw.stop() } diff --git a/test/unit/claude-auth-login.test.mjs b/test/unit/claude-auth-login.test.mjs new file mode 100644 index 000000000..12b162a38 --- /dev/null +++ b/test/unit/claude-auth-login.test.mjs @@ -0,0 +1,211 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { + assertAuthLoginReady, + authLoginSessionDir, + completeClaudeAuthLogin, + extractAuthLoginUrl, + isPersistentAuthLoginSession, + officialLoginCredentialToOauth, + startClaudeAuthLoginSession, + stopClaudeAuthLoginSession, +} from '../../src/lib/oauth/claude-auth-login.mjs' +import { KVM_GUEST_EXEC } from '../../src/lib/vm/slot-exec.mjs' +import { FULL_OAUTH_SCOPE } from '../../src/lib/oauth/oauth-contract.mjs' + +test('extractAuthLoginUrl reads the official manual CAI link', () => { + const url = + 'https://claude.com/cai/oauth/authorize?code=true&client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e&scope=user%3Aprofile' + const raw = `If the browser didn't open, visit: \x1b]8;;${url}\x07${url}\x1b]8;;\x07` + assert.equal(extractAuthLoginUrl(raw), url) + const wrapped = 'https://claude.com/cai/oauth/authorize?code=true&\nclient_id=abc&state=xyz' + assert.equal(extractAuthLoginUrl(wrapped), 'https://claude.com/cai/oauth/authorize?code=true&client_id=abc&state=xyz') +}) + +test('officialLoginCredentialToOauth keeps a full refresh grant and rejects setup tokens', () => { + const oauth = officialLoginCredentialToOauth({ + claudeAiOauth: { + accessToken: 'sk-ant-oat-full', + refreshToken: 'rt-full', + expiresAt: Date.now() + 8 * 3600 * 1000, + scopes: FULL_OAUTH_SCOPE.split(' '), + email: 'user@example.com', + accountUuid: 'acct-1', + orgUuid: 'org-1', + subscriptionType: 'max', + }, + }) + assert.equal(oauth.type, 'oauth') + assert.equal(oauth.mode, 'oauth') + assert.equal(oauth.flavor, 'claude_code') + assert.equal(oauth.source, 'claude-auth-login') + assert.equal(oauth.refresh_token, 'rt-full') + assert.equal(oauth.email, 'user@example.com') + assert.equal(oauth.subscription_type, 'max') + assert.match(oauth.scope, /user:profile/) + assert.match(oauth.scope, /user:file_upload/) + assert.throws( + () => + officialLoginCredentialToOauth({ + claudeAiOauth: { + accessToken: 'sk-ant-oat01-' + 'a'.repeat(90), + refreshToken: '', + scopes: ['user:inference'], + }, + }), + /完整 OAuth/, + ) + assert.throws( + () => officialLoginCredentialToOauth({ claudeAiOauth: { accessToken: 'at', scopes: ['user:inference'] } }), + /完整 OAuth/, + ) +}) + +test('auth login session stays reusable until force regenerate or login_ready', () => { + const session = { + alive: true, + auth_url: 'https://claude.com/cai/oauth/authorize?code=true', + expired: false, + status: 'waiting_auth', + } + assert.equal(isPersistentAuthLoginSession(session), true) + assert.equal(isPersistentAuthLoginSession(session, { force: true }), false) + assert.equal(isPersistentAuthLoginSession({ ...session, status: 'login_ready' }), false) + assert.equal(isPersistentAuthLoginSession({ ...session, alive: false }), false) +}) + +test('assertAuthLoginReady refuses a missing container and a missing cli-node', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-auth-login-ready-')) + const stopped = () => ({ running: false }) + const running = () => ({ running: true }) + try { + fs.mkdirSync(path.join(root, 'vms', 'vm-01'), { recursive: true }) + fs.writeFileSync(path.join(root, 'vms', 'vm-01.json'), JSON.stringify({ id: 'vm-01' })) + assert.throws( + () => assertAuthLoginReady(root, { id: 'vm-01' }, { inspect: stopped }), + (err) => err.code === 'container_not_ready', + ) + const home = path.join(root, 'vms', 'vm-01', 'cli-home', '.kin') + fs.mkdirSync(home, { recursive: true }) + fs.writeFileSync(path.join(home, 'cli-node'), '') + assert.throws( + () => assertAuthLoginReady(root, { id: 'vm-01' }, { inspect: stopped }), + (err) => err.code === 'container_not_ready', + ) + assert.throws( + () => assertAuthLoginReady(root, { id: 'vm-missing' }, { inspect: running }), + (err) => err.code === 'cli_node_missing', + ) + assert.throws( + () => assertAuthLoginReady(root, { id: 'vm-01', node_id: 'node-a' }, { inspect: running }), + (err) => err.code === 'remote_unsupported', + ) + assert.equal(assertAuthLoginReady(root, { id: 'vm-01' }, { inspect: running }).container, 'kin-01') + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +test('a live login session is not reused after the container stops or cli-node disappears', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-auth-login-reuse-')) + const vm = { id: 'vm-01' } + const running = () => ({ running: true }) + const stopped = () => ({ running: false }) + try { + const home = path.join(root, 'vms', 'vm-01', 'cli-home', '.kin') + fs.mkdirSync(home, { recursive: true }) + const cli = path.join(home, 'cli-node') + fs.writeFileSync(cli, '') + const dir = authLoginSessionDir(root, 'vm-01') + fs.mkdirSync(dir, { recursive: true }) + fs.writeFileSync(path.join(dir, 'pty.pid'), `${process.pid}\n`) + fs.writeFileSync( + path.join(dir, 'status.json'), + JSON.stringify({ + session_id: 'live-login', + auth_url: 'https://claude.com/cai/oauth/authorize?code=true&live=1', + status: 'waiting_auth', + expires_at: Date.now() + 60_000, + updated_at: Date.now(), + }), + ) + const reused = await startClaudeAuthLoginSession({ vm, projectRoot: root, inspect: running }) + assert.equal(reused.reused, true) + assert.equal(reused.session_id, 'live-login') + await assert.rejects( + () => startClaudeAuthLoginSession({ vm, projectRoot: root, inspect: stopped }), + (err) => err.code === 'container_not_ready', + ) + fs.rmSync(cli) + await assert.rejects( + () => startClaudeAuthLoginSession({ vm, projectRoot: root, inspect: running }), + (err) => err.code === 'cli_node_missing', + ) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +test('fake claude_code login returns the official CAI url and a full oauth grant', async () => { + process.env.KIN_FAKE_SESSION_OAUTH = '1' + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-auth-login-fake-')) + try { + fs.mkdirSync(path.join(root, 'vms', 'vm-01', 'cli-home', '.kin'), { recursive: true }) + fs.writeFileSync(path.join(root, 'vms', 'vm-01', 'cli-home', '.kin', 'cli-node'), '') + const started = await startClaudeAuthLoginSession({ + vm: { id: 'vm-01' }, + projectRoot: root, + inspect: () => ({ running: true }), + }) + assert.equal(started.flavor, 'claude_code') + assert.equal(started.source, 'claude-auth-login') + assert.match(started.auth_url, /^https:\/\/claude\.com\/cai\/oauth\/authorize\?code=true/) + const cred = await completeClaudeAuthLogin({ + projectRoot: '/tmp/unused', + vmId: 'vm-01', + sessionId: started.session_id, + code: 'pasted-code#state', + }) + assert.equal(cred.email, 'fake-oauth@kin.test') + assert.equal(cred.mode, 'oauth') + assert.match(cred.scope, /user:file_upload/) + assert.ok(cred.refresh_token) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + delete process.env.KIN_FAKE_SESSION_OAUTH + } +}) + +test('stopClaudeAuthLoginSession pkills auth login, not setup-token', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-auth-login-stop-')) + try { + fs.mkdirSync(path.join(root, 'vms')) + fs.writeFileSync(path.join(root, 'vms', 'vm-02.json'), JSON.stringify({ id: 'vm-02', runtime: { type: 'docker' } })) + const seen = [] + await stopClaudeAuthLoginSession(root, 'vm-02', { + spawn: (bin, argv) => { + seen.push({ bin, argv }) + return { status: 0 } + }, + }) + assert.equal(seen[0].bin, 'docker') + assert.deepEqual(seen[0].argv.slice(0, 4), ['exec', 'kin-02', 'sh', '-lc']) + assert.match(seen[0].argv.at(-1), /auth login/) + assert.doesNotMatch(seen[0].argv.at(-1), /setup-token/) + + fs.writeFileSync(path.join(root, 'vms', 'vm-01.json'), JSON.stringify({ id: 'vm-01', runtime: { type: 'kvm' } })) + const kvm = [] + await stopClaudeAuthLoginSession(root, 'vm-01', { + spawn: (_bin, argv) => { + kvm.push(argv) + return { status: 0 } + }, + }) + assert.equal(kvm[0][2], KVM_GUEST_EXEC) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/test/unit/cli-node-guard.test.mjs b/test/unit/cli-node-guard.test.mjs index 4bd2bc0d7..eaf07c6a9 100644 --- a/test/unit/cli-node-guard.test.mjs +++ b/test/unit/cli-node-guard.test.mjs @@ -55,13 +55,15 @@ test('keeps the kernel CLI, init bootstrap, setup-token and live panel CLIs incl const hello = slot.add(['-p', 'hello', '--permission-mode', 'bypassPermissions'], ['KIN_OFFICIAL_CC=1']) const usage = slot.add(['/usage', '--print'], ['KIN_OFFICIAL_CC=1']) const setupToken = slot.add(['setup-token'], ['KIN_SETUP_TOKEN=1']) + const authLogin = slot.add(['auth', 'login'], ['KIN_AUTH_LOGIN=1']) const panelPrint = slot.add(['-p', 'hello'], ['KIN_PANEL_SHELL=live']) const panelInteractive = slot.add([], ['KIN_PANEL_SHELL=live']) slot.run(['live']) await settle() assert.equal(alive(kernel), true) assert.equal(alive(duplicate), false) - for (const child of [hello, usage, setupToken, panelPrint, panelInteractive]) assert.equal(alive(child), true) + for (const child of [hello, usage, setupToken, authLogin, panelPrint, panelInteractive]) + assert.equal(alive(child), true) }) test('kills leaked CLIs from closed panel shells and unmarked -p runs', async (t) => { diff --git a/test/unit/oauth-auth-url.test.mjs b/test/unit/oauth-auth-url.test.mjs index bc5504b1e..b6732edf5 100644 --- a/test/unit/oauth-auth-url.test.mjs +++ b/test/unit/oauth-auth-url.test.mjs @@ -45,24 +45,17 @@ test('generateAuthUrl builds Claude authorize URL and stores PKCE', () => { assert.equal(session.redirectUri, REDIRECT_URI) }) -test('generateAuthUrl claude_code uses official authorize and callback', () => { +test('generateAuthUrl refuses host-built claude_code links', () => { resetAuthUrlSessions() - const spec = OAUTH_FLAVORS.claude_code - const out = generateAuthUrl({ - vmId: 'vm-01', - proxyUrl: 'socks5h://127.0.0.1:1080', - flavor: 'claude_code', - }) - assert.equal(out.flavor, 'claude_code') - assert.match(out.auth_url, /^https:\/\/claude\.ai\/oauth\/authorize\?code=true/) - assert.ok(out.auth_url.includes(encodeURIComponent(spec.redirectUri))) - assert.ok(out.auth_url.includes(encodeURIComponent(spec.scope).replace(/%20/g, '+'))) - assert.ok(!out.auth_url.includes('cai/oauth')) - const session = peekAuthUrlSession(out.session_id) - assert.equal(session.flavor, 'claude_code') - assert.equal(session.redirectUri, spec.redirectUri) - assert.deepEqual(session.tokenUrls, spec.tokenUrls) - assert.equal(session.source, 'oauth-claude-code') + assert.throws( + () => + generateAuthUrl({ + vmId: 'vm-01', + proxyUrl: 'socks5h://127.0.0.1:1080', + flavor: 'claude_code', + }), + (err) => err.code === 'claude_code_in_slot', + ) }) test('generateAuthUrl setup_token uses full scope on CAI but keeps setup-token flavor', () => { @@ -120,17 +113,5 @@ test('fake exchange writes tokens without network', async () => { assert.equal(cred.source, 'KIN_FAKE_SESSION_OAUTH') assert.match(cred.access_token, /^sk-ant-oat01-FAKE-AUTH-URL/) assert.equal(peekAuthUrlSession(out.session_id), null) - const cc = generateAuthUrl({ - vmId: 'vm-01', - proxyUrl: 'socks5h://127.0.0.1:1080', - flavor: 'claude_code', - }) - const ccCred = await exchangeAuthCode({ - sessionId: cc.session_id, - code: 'pasted-code', - proxyUrl: 'socks5h://127.0.0.1:1080', - vmId: 'vm-01', - }) - assert.equal(ccCred.flavor, 'claude_code') delete process.env.KIN_FAKE_SESSION_OAUTH }) diff --git a/web/src/features/vm/credential-panel.tsx b/web/src/features/vm/credential-panel.tsx index 351d79864..57dbfeef1 100644 --- a/web/src/features/vm/credential-panel.tsx +++ b/web/src/features/vm/credential-panel.tsx @@ -469,7 +469,9 @@ function ClaudeCredentialPanel({ {kind === 'setup-token' ? `申请完整 OAuth 权限,换票后采集身份。浏览器须走 ${proxyHint || '同一槽出口'},授权码须含 # 后半段。以 Setup Token 模式运行,不跑官方初装。` - : `浏览器须走 ${proxyHint || '同一槽出口'},授权码须含 # 后半段。换票后采集身份。`} + : activeMethod === 'cc' + ? `槽内 cli-node 执行官方 claude auth login。链接、PKCE 和换票都由官方 CLI 完成,浏览器须走 ${proxyHint || '同一槽出口'}。需要容器已运行且 cli-node 已安装。` + : `浏览器须走 ${proxyHint || '同一槽出口'},授权码须含 # 后半段。换票后采集身份。`} ) : ( @@ -478,7 +480,7 @@ function ClaudeCredentialPanel({ {kind === 'setup-token' ? `连接形式:生成完整 OAuth 权限授权链接。换票经槽 SOCKS5,浏览器须走 ${proxyHint || '同一槽出口'};落盘后仍以 Setup Token 模式运行。` : activeMethod === 'cc' - ? '官方 Claude Code 授权页。换票经槽 SOCKS5,打开链接仍是本机浏览器。' + ? '在已运行的容器里用已安装的 cli-node 执行官方 claude auth login。不是 Setup Token,也不是宿主机自己造的授权链接。容器或 cli-node 未就绪时不会给出链接。' : `换票经槽 SOCKS5,浏览器须走 ${proxyHint || '同一 SOCKS5'}。`} + + + + 官方登录 + + 终端会执行 claude auth + login。复制里面的链接,授权码粘回这个终端。 + + +
+ {loginOpen ? ( + + ) : null} +
+
+
+ ) : (
{kind === 'setup-token' ? `连接形式:生成完整 OAuth 权限授权链接。换票经槽 SOCKS5,浏览器须走 ${proxyHint || '同一槽出口'};落盘后仍以 Setup Token 模式运行。` - : activeMethod === 'cc' - ? '在已运行的容器里用已安装的 cli-node 执行官方 claude auth login。不是 Setup Token,也不是宿主机自己造的授权链接。容器或 cli-node 未就绪时不会给出链接。' - : `换票经槽 SOCKS5,浏览器须走 ${proxyHint || '同一 SOCKS5'}。`} + : `换票经槽 SOCKS5,浏览器须走 ${proxyHint || '同一 SOCKS5'}。`}