#!/usr/bin/env bash # Codeb服务器工具箱;只安装官方 Codex、配置当前用户,不创建后台服务。 set -euo pipefail command -v python3 >/dev/null || { echo '请先安装 Python 3(系统软件包),然后重试。' >&2; exit 1; } python3 - "$@" <<'PY' import fcntl, getpass, json, os, re, shutil, subprocess, sys, tempfile from datetime import datetime from pathlib import Path from urllib.parse import urlsplit, urlunsplit INSTALLER = 'https://chatgpt.com/codex/install.sh' ROOT_KEYS = {'model', 'model_provider', 'cli_auth_credentials_store', 'forced_login_method', 'profile', 'openai_base_url', 'chatgpt_base_url'} def base_url(value): value = value.strip() if not re.match(r'^https?://', value, re.I): value = 'https://' + value u = urlsplit(value) if not u.hostname or u.username is not None or u.password is not None or u.query or u.fragment or any(c.isspace() for c in value): raise ValueError('API 地址不能包含账号密码、参数、片段或空格') if u.scheme != 'https' and not (u.scheme == 'http' and u.hostname in ('localhost', '127.0.0.1', '::1')): raise ValueError('公网 API 必须使用 HTTPS') u.port # 验证端口 path = re.sub(r'/+', '/', u.path).rstrip('/') path = re.sub(r'/(?:chat/completions|responses|models)$', '', path) if not path.endswith('/v1'): path += '/v1' return urlunsplit((u.scheme, u.netloc, path, '', '')) def config_text(source, mode, url='', model=''): # ponytail: 仅编辑简单根键与本工具标记块;复杂多行 TOML 拒绝自动改,未来可换完整 TOML 编辑器。 if '"""' in source or "'''" in source: raise ValueError('已有配置包含多行 TOML 字符串,请先手工整理;本工具不会覆盖它') source = re.sub(r'(?ms)^# BEGIN CODEB (?:SETTINGS|PROVIDER)\n.*?^# END CODEB (?:SETTINGS|PROVIDER)\n?', '', source) if mode == 'auth' and '\\' in source: raise ValueError('已有 TOML 包含转义字符,切换官方授权请手工检查供应商覆盖;原配置未改') lines, root, table = [], True, '' for line in source.splitlines(keepends=True): header = re.match(r'^\s*\[([^\]]+)\]', line) if header: root = False; table = re.sub(r'''[\s"']''', '', header[1]) if mode == 'auth' and table == 'model_providers': raise ValueError('内联供应商配置需要手工检查,原文件未改') # 官方模式清除对内置 openai 的覆盖,防止把官方认证发送到旧中转地址。 name = re.sub(r'''[\s"']''', '', line.partition('=')[0]) if mode == 'auth': if table == 'model_providers.openai' or table.startswith('model_providers.openai.'): continue if root and name.startswith('model_providers.openai.'): continue if root and name == 'model_providers': raise ValueError('内联供应商配置需要手工检查,原文件未改') key = re.match(r'^\s*([A-Za-z_][A-Za-z_0-9]*)\s*=', line) if root and key and key[1] in ROOT_KEYS: continue lines.append(line) settings = '# BEGIN CODEB SETTINGS\ncli_auth_credentials_store = "file"\n' settings += 'model_provider = ' + json.dumps('openai' if mode == 'auth' else 'codeb_relay') + '\n' if mode == 'api': settings += 'model = ' + json.dumps(model) + '\n' result = settings + '# END CODEB SETTINGS\n' + ''.join(lines).lstrip('\n') if mode == 'api': result = result.rstrip() + '\n\n# BEGIN CODEB PROVIDER\n[model_providers.codeb_relay]\nname = "Codeb API"\nbase_url = ' + json.dumps(url) + '\nwire_api = "responses"\nrequires_openai_auth = true\nsupports_websockets = false\n# END CODEB PROVIDER\n' return result def atomic(path, body): fd, name = tempfile.mkstemp(prefix='.codeb-', dir=str(path.parent)) try: with os.fdopen(fd, 'wb') as out: out.write(body); out.flush(); os.fsync(out.fileno()) os.chmod(name, 0o600) os.replace(name, str(path)) finally: if os.path.exists(name): os.unlink(name) def snapshot(path): if path.is_symlink(): raise ValueError('配置或认证文件是符号链接,请手工处理') if not path.exists(): return None if path.stat().st_size > 1048576: raise ValueError('配置文件过大,请手工处理') return path.read_bytes() def self_test(): for value in ('https://api.baiyun.si', 'https://api.baiyun.si/v1/', 'api.baiyun.si/v1/responses', 'https://api.baiyun.si/v1/chat/completions'): assert base_url(value) == 'https://api.baiyun.si/v1' assert base_url('https://example.com/proxy/v1/models') == 'https://example.com/proxy/v1' assert base_url('http://127.0.0.1:8080') == 'http://127.0.0.1:8080/v1' for value in ('https://user:pass@example.com', 'https://example.com?key=x', 'http://example.com', 'https://example.com/#x'): try: base_url(value) except ValueError: pass else: raise AssertionError(value) original = 'model = "old"\nmodel_provider = "old"\n# keep\nsandbox_mode = "workspace-write"\n[projects."/work"]\ntrust_level = "trusted"\n' new = config_text(original, 'api', 'https://api.baiyun.si/v1', 'example-model') assert '# keep\nsandbox_mode' in new and '[projects."/work"]' in new and new.count('[model_providers.codeb_relay]') == 1 assert config_text(new, 'api', 'https://api.baiyun.si/v1', 'example-model') == new auth = config_text(new, 'auth') assert 'model_provider = "openai"' in auth and 'base_url' not in auth and 'model =' not in auth overridden = '[model_providers."openai"]\nbase_url = "https://old.example/v1"\n[model_providers.other]\nbase_url = "https://keep.example/v1"\n' switched = config_text(overridden, 'auth') assert 'old.example' not in switched and 'keep.example' in switched with tempfile.TemporaryDirectory(prefix='codeb-self-test-') as tmp: target = Path(tmp) / 'config.toml'; atomic(target, b'test') assert target.read_bytes() == b'test' and target.stat().st_mode & 0o777 == 0o600 print('CODEB_TOOLBOX_SELF_TEST_OK') def main(): if sys.argv[1:] == ['--self-test']: return self_test() if sys.argv[1:]: raise ValueError('用法:bash codeb-server-toolbox.sh [--self-test]') if sys.platform != 'linux': raise ValueError('此工具面向 Linux 公网服务器') os.environ['PATH'] = str(Path.home() / '.local/bin') + os.pathsep + os.environ.get('PATH', '') with open('/dev/tty', 'r') as tty_in, open('/dev/tty', 'w') as tty: def ask(prompt, default=''): tty.write(prompt); tty.flush(); value = tty_in.readline() if not value: raise ValueError('终端输入结束,已取消') return value.strip() or default print('Codeb服务器工具箱 · 当前用户:' + getpass.getuser()) binary = shutil.which('codex') if binary: subprocess.run([binary, '--version'], check=True) else: print('尚未发现 Codex。') choice = ask('1 安装/更新最新版本 2 安装指定版本 3 保留已有版本/退出\n选择 [默认 1]:', '1') if choice in ('1', '2'): version = 'latest' if choice == '1' else re.sub(r'^v', '', ask('版本号(如 0.160.0):')) if not re.fullmatch(r'latest|[0-9]+\.[0-9]+\.[0-9]+(?:-(?:alpha(?:\.[0-9]+){0,2}|beta(?:\.[0-9]+)?))?', version): raise ValueError('版本号格式不正确') if not shutil.which('curl'): raise ValueError('请先通过系统软件包安装 curl') with tempfile.TemporaryDirectory(prefix='codeb-install-') as tmp: installer = str(Path(tmp) / 'install.sh') subprocess.run(['curl','-fsSL','--proto','=https','--proto-redir','=https','--tlsv1.2','--max-time','120','-o',installer,INSTALLER], check=True) env = dict(os.environ, CODEX_NON_INTERACTIVE='1') subprocess.run(['sh',installer,'--release',version], env=env, check=True) binary = shutil.which('codex') elif choice != '3': raise ValueError('请选择 1、2 或 3') if not binary: print('未安装 Codex,已退出。'); return mode = ask('1 OpenAI 官方授权 2 API 中转站 3 退出\n选择:') if mode == '3': return if mode not in ('1','2'): raise ValueError('请选择 1、2 或 3') if mode == '1' and any(os.environ.get(name) for name in ('OPENAI_BASE_URL','OPENAI_API_KEY','CODEX_API_KEY','CODEX_ACCESS_TOKEN')): raise ValueError('当前 Shell 有旧 API 环境变量;请先 unset OPENAI_BASE_URL OPENAI_API_KEY CODEX_API_KEY CODEX_ACCESS_TOKEN,再切换官方授权。原认证未改') url, model, key = '', '', '' if mode == '2': url = base_url(ask('API 地址 [https://api.baiyun.si]:', 'https://api.baiyun.si')) print('识别后的接口地址:' + url) model = ask('模型 ID [gpt-6.1-sol]:', 'gpt-6.1-sol') if not re.fullmatch(r'[A-Za-z0-9_./:\-]{1,256}', model): raise ValueError('模型 ID 格式不正确') key = getpass.getpass('粘贴 API 密钥(输入不显示):') if not (8 <= len(key) <= 512) or any(c.isspace() for c in key) or '\0' in key: raise ValueError('密钥为空、过长或包含空白字符') home = Path(os.environ.get('CODEX_HOME', str(Path.home() / '.codex'))).expanduser().absolute() if home.is_symlink(): raise ValueError('CODEX_HOME 是符号链接,请手工处理') home.mkdir(mode=0o700, parents=True, exist_ok=True) with open(home / '.codeb-toolbox.lock', 'a') as lock: os.chmod(lock.name, 0o600); fcntl.flock(lock, fcntl.LOCK_EX) paths = [home / 'config.toml', home / 'auth.json'] originals = [snapshot(p) for p in paths] candidate = config_text((originals[0] or b'').decode('utf-8'), 'auth' if mode == '1' else 'api', url, model).encode('utf-8') with tempfile.TemporaryDirectory(prefix='.codeb-stage-', dir=str(home)) as tmp: stage = Path(tmp); atomic(stage / 'config.toml', candidate) env = dict(os.environ, CODEX_HOME=str(stage)) check = subprocess.run([binary,'features','list'], env=env, capture_output=True) if check.returncode: raise ValueError('Codex 配置校验失败,原配置未改;请检查版本和现有 TOML 结构') if mode == '1': print('请按官方提示,在浏览器中完成设备码授权。未完成授权不会替换原认证。') result = subprocess.run([binary,'login','--device-auth'], env=env) else: result = subprocess.run([binary,'login','--with-api-key'], input=(key+'\n').encode(), env=env, capture_output=True) key = '' if result.returncode: raise ValueError('登录失败,原配置与认证未改') status = subprocess.run([binary,'login','status'], env=env, capture_output=True) if status.returncode: raise ValueError('登录状态检查失败,原配置与认证未改') auth = snapshot(stage / 'auth.json') if not auth: raise ValueError('未生成文件认证,原配置与认证未改') if [snapshot(p) for p in paths] != originals: raise ValueError('检测到其他程序同时修改配置,已取消发布') backup = Path(tempfile.mkdtemp(prefix='codeb-' + datetime.now().strftime('%Y%m%d-%H%M%S') + '-', dir=str(home))) for path, body in zip(paths, originals): if body is not None: atomic(backup / path.name, body) atomic(backup / 'restore.json', json.dumps({p.name: b is not None for p,b in zip(paths, originals)}).encode()) try: atomic(paths[0], candidate); atomic(paths[1], auth) except BaseException: for path, body in zip(paths, originals): if body is not None: atomic(path, body) elif path.exists(): path.unlink() raise print('切换成功:' + ('OpenAI 官方授权' if mode == '1' else 'API 中转站')) print('原配置备份:' + str(backup)) print('API 模式仅验证本地登录与配置;模型是否可用,以实际请求为准。' if mode == '2' else '官方授权已完成。') print('运行 codex 开始使用,或在 Codeb 中添加 SSH 服务器。已有 Codex 进程需重新连接才使用新配置。') try: main() except (ValueError, OSError, subprocess.CalledProcessError, KeyboardInterrupt) as error: # 不输出配置正文、API 密钥或第三方命令 stderr。 print('操作未完成:' + (str(error) if isinstance(error, ValueError) else '命令失败、终端不可用或操作取消;请检查网络与权限'), file=sys.stderr) sys.exit(1) PY