#!/usr/bin/env python3 """招聘中台 MCP Server(stdio)。只读为主,任务队列可写。 启动时向门户发一次心跳,门户的「连接 Agent」据此显示已连接。 例如配置 Claude Code 时,请使用中台下载的 aihire_mcp.py, 并将 PORTAL_AGENT_CLIENT 设置为 claude-code,与配对时的类型一致。 """ import json import hashlib import urllib.parse import re import ctypes import getpass from pathlib import Path import os import sys import time import threading from datetime import datetime, timedelta import urllib.error import urllib.request import webbrowser import secrets def mac_token(value=None): """Use the current user's macOS Keychain without exposing tokens in argv.""" security = ctypes.CDLL('/System/Library/Frameworks/Security.framework/Security') find = security.SecKeychainFindGenericPassword find.argtypes = [ctypes.c_void_p, ctypes.c_uint32, ctypes.c_char_p, ctypes.c_uint32, ctypes.c_char_p, ctypes.POINTER(ctypes.c_uint32), ctypes.POINTER(ctypes.c_void_p), ctypes.POINTER(ctypes.c_void_p)] find.restype = ctypes.c_int32 add = security.SecKeychainAddGenericPassword add.argtypes = [ctypes.c_void_p, ctypes.c_uint32, ctypes.c_char_p, ctypes.c_uint32, ctypes.c_char_p, ctypes.c_uint32, ctypes.c_void_p, ctypes.c_void_p] add.restype = ctypes.c_int32 modify = security.SecKeychainItemModifyAttributesAndData modify.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint32, ctypes.c_void_p] modify.restype = ctypes.c_int32 security.SecKeychainItemFreeContent.argtypes = [ctypes.c_void_p, ctypes.c_void_p] core = ctypes.CDLL('/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation') core.CFRelease.argtypes = [ctypes.c_void_p] service = b'asia.aihire.agent' account = hashlib.sha256((PORTAL + '\0' + CLIENT).encode()).hexdigest().encode() length, data, item = ctypes.c_uint32(), ctypes.c_void_p(), ctypes.c_void_p() result = find(None, len(service), service, len(account), account, ctypes.byref(length), ctypes.byref(data), ctypes.byref(item)) try: if result == -25300: # errSecItemNotFound if value is None: raise FileNotFoundError('No saved credential') raw = value.encode('utf-8') if add(None, len(service), service, len(account), account, len(raw), raw, None): raise OSError('Keychain credential could not be saved') elif result: raise OSError('Keychain credential unavailable') elif value is None: return ctypes.string_at(data, length.value).decode('utf-8') else: raw = value.encode('utf-8') if modify(item, None, len(raw), raw): raise OSError('Keychain credential could not be updated') finally: if data.value: security.SecKeychainItemFreeContent(None, data) if item.value: core.CFRelease(item) def validated_origin(value): u = urllib.parse.urlsplit(value) if (u.scheme not in ('http', 'https') or not u.hostname or u.username or u.password or u.path not in ('', '/') or u.query or u.fragment): raise ValueError('PORTAL_URL 必须是门户来源地址') if u.scheme != 'https' and u.hostname not in ('localhost', '127.0.0.1', '::1'): raise ValueError('远程门户必须使用 HTTPS') return value.rstrip('/') PORTAL = validated_origin(os.environ.get("PORTAL_URL", "https://aihire.asia")) CLIENT_TYPES = frozenset({'legacy','chatgpt','claude-code','workbuddy','kimi-code','dsh'}) CLIENT = os.environ.get('PORTAL_AGENT_CLIENT', 'legacy') if CLIENT not in CLIENT_TYPES: raise ValueError('PORTAL_AGENT_CLIENT 无效') NAME = os.environ.get("PORTAL_AGENT_NAME", "aihire-mcp") TOKEN = os.environ.get("PORTAL_AGENT_TOKEN", "") class NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): raise urllib.error.HTTPError(req.full_url, code, 'redirect refused', headers, fp) def http(path, payload=None, method="GET", deadline=None): data = json.dumps(payload).encode() if payload is not None else None req = urllib.request.Request(PORTAL + path, data=data, method=method) req.add_header("Content-Type", "application/json") if TOKEN: req.add_header("Authorization", "Bearer " + TOKEN) try: timeout=remaining(deadline) if deadline is not None else 180 with urllib.request.build_opener(NoRedirect()).open(req, timeout=timeout) as resp: raw=read_limited(resp, 4*1024*1024, deadline) if deadline is not None else resp.read() return json.loads(raw.decode()) except urllib.error.HTTPError as exc: # Never echo an upstream body, request, authorization header, or redirect target. exc.close() return {'ok': False, 'error': 'request_failed', 'http_status': exc.code} except (urllib.error.URLError, OSError, ValueError, TimeoutError): return {'ok': False, 'error': 'connection_failed'} def hello(): http("/api/agent/hello", {"name": NAME, "pid": os.getpid()}, "POST") TOOLS = [ {"name": "portal_status", "description": "查看门户与 Agent 连接状态、待处理任务数。", "inputSchema": {"type": "object", "properties": {}, "additionalProperties": False}}, {"name": "list_projects", "description": "列出当前数据月各项目的目标、已入职、缺口与流程人数。", "inputSchema": {"type": "object", "properties": {}, "additionalProperties": False}}, {"name": "list_pipeline", "description": "查看某个项目的待面试 / 面试反馈 / 接 offer 人数。", "inputSchema": {"type": "object", "properties": {"project": {"type": "string", "description": "项目名,支持模糊匹配"}}, "required": ["project"]}}, {"name": "list_pending_tasks", "description": "拉取专员在门户里交给 Agent 的待办任务。", "inputSchema": {"type": "object", "properties": {}, "additionalProperties": False}}, {"name": "finish_task", "description": "把门户任务标记为完成,写回结果摘要。", "inputSchema": {"type": "object", "properties": {"id": {"type": "string"}, "result": {"type": "string", "minLength": 1, "maxLength": 4000}}, "required": ["id", "result"], "additionalProperties": False}}, {"name": "claim_task", "description": "领取自己的任务", "inputSchema": {"type": "object", "properties": {"id": {"type": "string"}}, "required": ["id"]}}, {"name": "get_task", "description": "读取指定的本人任务及已授权的上下文;不会自动领取或执行。", "inputSchema": {"type": "object", "properties": {"id": {"type": "string"}}, "required": ["id"], "additionalProperties": False}}, {"name": "list_due_scheduled_tasks", "description": "仅查看当前连接的本人 Agent 已到期预约,最多返回最早的一项;未预约的普通任务不会返回。", "inputSchema": {"type": "object", "properties": {}, "additionalProperties": False}}, {"name": "claim_due_scheduled", "description": "原子领取一项本人已到期预约。失败或结果不明时停止本轮并由本人核对,禁止重复领取;领取不等于执行完成。", "inputSchema": {"type": "object", "properties": {"id": {"type": "string"}}, "required": ["id"], "additionalProperties": False}}, {"name": "search_knowledge", "description": "搜索已授权知识", "inputSchema": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}}, {"name": "read_knowledge_document", "description": "读取本人有权访问的知识正文与来源。", "inputSchema": {"type": "object", "properties": {"id": {"type": "string"}}, "required": ["id"], "additionalProperties": False}}, {"name": "list_talent_projects", "description": "列出已授权的公司、项目和来源查询范围。 / List authorized company, project and source scopes.", "inputSchema": {"type": "object", "properties": {}, "additionalProperties": False}}, {"name": "search_talent", "description": "在指定授权查询范围内按关键词检索,可分页;范围包括公司、项目或来源。 / Search an authorized company, project or source scope with pagination.", "inputSchema": {"type": "object", "properties": { "query": {"type": "string", "minLength": 1, "maxLength": 200}, "project": {"type": "string", "minLength": 1, "maxLength": 120}, "offset": {"type": "integer", "minimum": 0}, "limit": {"type": "integer", "minimum": 1, "maximum": 100}}, "required": ["query"], "additionalProperties": False}}, {"name": "get_candidate_detail", "description": "读取指定授权范围内的候选资料或来源记录,不提供原件。 / Read a candidate or source record in an authorized scope, without the original file.", "inputSchema": {"type": "object", "properties": {"id": {"oneOf": [{"type": "integer", "minimum": 1}, {"type": "string", "pattern": "^current:[0-9a-f]{64}$"}]}, "project": {"type": "string", "minLength": 1, "maxLength": 120}}, "required": ["id", "project"], "additionalProperties": False}}, {"name": "get_recruitment_positions", "description": "读取指定授权项目的岗位列表。 / List positions within one authorized recruitment project.", "inputSchema": {"type": "object", "properties": {"project": {"type": "string", "minLength": 1, "maxLength": 120}}, "required": ["project"], "additionalProperties": False}}, ] TOOLS.extend([ {"name":"list_skills","description":"查看本人有权访问的共享 Skill。","inputSchema":{"type":"object","properties":{},"additionalProperties":False}}, {"name":"get_skill_info","description":"查看一个共享 Skill 的说明与校验信息。","inputSchema":{"type":"object","properties":{"id":{"type":"string"}},"required":["id"],"additionalProperties":False}}, {"name":"download_skill","description":"下载共享 Skill ZIP 到本机专用目录;不会安装或执行。","inputSchema":{"type":"object","properties":{"id":{"type":"string"}},"required":["id"],"additionalProperties":False}}, ]) SKILL_MAX=20*1024*1024 def skill_id(value): return isinstance(value,str) and re.fullmatch(r'(?:builtin-[a-z0-9-]{1,80}|[A-Za-z0-9_-]{20,64})',value) is not None def skill_info(id, deadline=None): if not skill_id(id): return {'ok':False,'error':'invalid_skill_id'} catalog=http('/api/skills', deadline=deadline) if not catalog.get('ok') or not isinstance(catalog.get('items'),list): return {'ok':False,'error':'skills_unavailable'} matches=[x for x in catalog['items'] if isinstance(x,dict) and x.get('id')==id] if len(matches)!=1: return {'ok':False,'error':'skill_unavailable'} item=matches[0]; size=item.get('size'); digest=item.get('sha256') if not isinstance(size,int) or isinstance(size,bool) or not 0limit: raise ValueError('response too large') blocks.append(block) def download_skill(id): deadline=time.monotonic()+180 info=skill_info(id,deadline) if not info.get('ok'): return info expected=info['skill']; temp=None; final=None try: req=urllib.request.Request(PORTAL+'/api/skills/download?'+urllib.parse.urlencode({'id':id})) if TOKEN: req.add_header('Authorization','Bearer '+TOKEN) with urllib.request.build_opener(NoRedirect()).open(req,timeout=remaining(deadline)) as resp: size=resp.headers.get('Content-Length'); header_hash=resp.headers.get('X-Content-SHA256') if size is None or not size.isdigit() or int(size)!=expected['size'] or header_hash!=expected['sha256']: return {'ok':False,'error':'invalid_download_metadata'} directory=Path(os.environ.get('LOCALAPPDATA') or str(Path.home()))/'aihire-portal'/'skill-downloads' directory.mkdir(parents=True,exist_ok=True) if directory.is_symlink() or directory.parent.is_symlink(): return {'ok':False,'error':'unsafe_download_directory'} temp=directory/('.'+secrets.token_hex(16)+'.tmp') fd=os.open(temp,os.O_WRONLY|os.O_CREAT|os.O_EXCL|getattr(os,'O_NOFOLLOW',0),0o600) digest=hashlib.sha256(); count=0 with os.fdopen(fd,'wb') as out: while True: timeout=remaining(deadline) if resp.fp is None: break try: resp.fp.raw._sock.settimeout(timeout) except (AttributeError,OSError): raise OSError('cannot enforce download deadline') block=resp.read1(min(65536,SKILL_MAX-count+1)) remaining(deadline) if not block: break count+=len(block) if count>SKILL_MAX or count>expected['size']: raise ValueError('size') digest.update(block); out.write(block) out.flush(); os.fsync(out.fileno()) if count!=expected['size'] or digest.hexdigest()!=expected['sha256']: raise ValueError('hash') fresh=skill_info(id,deadline) if not fresh.get('ok') or fresh['skill']['sha256']!=expected['sha256'] or fresh['skill']['size']!=expected['size']: raise ValueError('access changed') final=directory/('skill-'+hashlib.sha256(id.encode()).hexdigest()[:16]+'-'+secrets.token_hex(8)+'.zip') os.link(temp,final); temp.unlink(); temp=None fresh=skill_info(id,deadline) if not fresh.get('ok') or fresh['skill']['sha256']!=expected['sha256'] or fresh['skill']['size']!=expected['size']: final.unlink(); final=None; raise ValueError('access changed') return {'ok':True,'path':str(final),'sha256':expected['sha256'],'size':count,'next_step':'请检查内容;安装前由本人决定。'} except (OSError,ValueError,urllib.error.URLError,TimeoutError) as exc: if isinstance(exc,urllib.error.HTTPError): exc.close() if final is not None: try: final.unlink() except OSError: pass return {'ok':False,'error':'download_failed'} finally: if temp is not None: try: temp.unlink() except OSError: pass def valid_task_id(value): return isinstance(value, str) and re.fullmatch(r'[A-Za-z0-9_-]{1,128}', value) is not None def task_detail(task_id): if not valid_task_id(task_id): return {'ok': False, 'error': 'invalid_task_id'} return http('/api/agent/task?' + urllib.parse.urlencode({'id': task_id})) def list_due_scheduled_tasks(): """Return at most one due scheduled task for this exact agent identity. The server supplies the authoritative due status; the workstation clock is deliberately not used to decide whether an appointment is due. """ identity=http('/api/agent/capabilities') agent=identity.get('agent') if identity.get('ok') else None aid=agent.get('agent_id') if isinstance(agent,dict) else None if not valid_task_id(aid):return {'ok':False,'error':'agent_unavailable'} response=http('/api/agent/tasks') tasks=response.get('tasks') if response.get('ok') else None if not isinstance(tasks,list):return {'ok':False,'error':'tasks_unavailable'} eligible=[] for task in tasks: if not isinstance(task,dict) or task.get('agent_id')!=aid or task.get('status')!='pending' or not valid_task_id(task.get('id')):continue stamp=task.get('scheduled_at');context=task.get('context') if not isinstance(stamp,str) or not isinstance(context,dict):continue try: parsed=datetime.fromisoformat(stamp) except ValueError: continue if parsed.utcoffset()!=timedelta(hours=8):continue position=context.get('position') if (not isinstance(context.get('project_id'),str) or not context['project_id'] or not isinstance(context.get('channel_id'),str) or not context['channel_id'] or not isinstance(position,dict) or position.get('kind')!='user_input' or not isinstance(position.get('value'),str) or not position['value'].strip()):continue eligible.append((parsed,task['id'],task)) eligible.sort(key=lambda row:(row[0],row[1])) return {'ok':True,'tasks':[eligible[0][2]] if eligible else [],'has_more':len(eligible)>1} def claim_due_scheduled(task_id): """Request a one-shot claim; uncertain outcomes must be inspected by a person.""" if not valid_task_id(task_id):return {'ok':False,'error':'invalid_task_id'} result=http('/api/agent/claim-scheduled',{'id':task_id},'POST') if not result.get('ok'): return {'ok':False,'error':'task_not_claimable' if result.get('http_status')==409 else 'claim_result_unknown'} task=result.get('task') if not isinstance(task,dict) or task.get('id')!=task_id or task.get('status')!='claimed' or not task.get('scheduled_at'): return {'ok':False,'error':'claim_result_unknown'} return {'ok':True,'task':task} def valid_project(value): return (isinstance(value, str) and 0 < len(value) <= 120 and value == value.strip() and not any(ord(ch) < 32 or ord(ch) == 127 for ch in value)) def search_talent(args): query = args.get('query') offset, limit = args.get('offset', 0), args.get('limit', 20) if not isinstance(query, str) or not query.strip() or len(query) > 200: return {'ok': False, 'error': 'invalid_query'} if (not isinstance(offset, int) or isinstance(offset, bool) or offset < 0 or not isinstance(limit, int) or isinstance(limit, bool) or not 1 <= limit <= 100): return {'ok': False, 'error': 'invalid_pagination'} project = args.get('project') if 'project' not in args: # Preserve the old one-argument call only when it has one unambiguous scope. status = http('/api/recruitment/status') if not status.get('ok'): return status projects = status.get('projects') if status.get('available') is not True or not isinstance(projects, list): return {'ok': False, 'error': 'recruitment_unavailable'} if len(projects) != 1 or not isinstance(projects[0], dict): return {'ok': False, 'error': 'project_required'} project = projects[0].get('id') if not valid_project(project): return {'ok': False, 'error': 'invalid_project'} return http('/api/recruitment/search?' + urllib.parse.urlencode({ 'q': query.strip(), 'project': project, 'offset': offset, 'limit': limit})) def task_action(action, args): task_id = args.get('id') result = args.get('result', '') if not valid_task_id(task_id): return {'ok': False, 'error': 'invalid_task_id'} if action == 'finish' and (not isinstance(result, str) or not result.strip() or len(result) > 4000): return {'ok': False, 'error': 'invalid_result'} detail = task_detail(task_id) task = detail.get('task') if detail.get('ok') else None if not isinstance(task, dict): return detail if not detail.get('ok') else {'ok': False, 'error': 'task_unavailable'} if task.get('id') != task_id: return {'ok': False, 'error': 'task_mismatch'} allowed = ('pending', 'claimed') if action == 'claim' else ('claimed', 'done') if task.get('status') not in allowed: return {'ok': False, 'error': 'invalid_task_state'} payload = {'id': task_id} if action == 'finish': payload['result'] = result return http('/api/agent/' + action, payload, 'POST') def call_tool(name, args): if name == 'list_skills': if args: return {'ok':False,'error':'invalid_arguments'} result=http('/api/skills') if not result.get('ok') or not isinstance(result.get('items'),list): return {'ok':False,'error':'skills_unavailable'} return {'ok':True,'skills':[{k:item.get(k) for k in ('id','name','description','author','tags','version','size','sha256')} for item in result['items'] if isinstance(item,dict)]} if name in ('get_skill_info','download_skill'): if set(args)!= {'id'}: return {'ok':False,'error':'invalid_arguments'} return skill_info(args['id']) if name=='get_skill_info' else download_skill(args['id']) if name == "portal_status": st = http("/api/agent/status") return st if name == "list_projects": r = http("/api/portal") if not r.get("ok"): return r if r.get("pending"): return {"ok": False, "error": "数据正在同步,请稍后重试", "pending": True} d = r["data"] return {"ok": True, "month": d["meta"]["month"], "stale": r.get("stale"), "refreshing": r.get("refreshing"), "projects": [{"name": f"{p['customer']} · {p['name']}", "target": p["target"], "onboarded": p["onboarded"], "pipeline": p["pipeline"]} for p in d["projects"]]} if name == "list_pipeline": q = (args.get("project") or "").lower() r = http("/api/portal") if not r.get("ok"): return r if r.get("pending"): return {"ok": False, "error": "数据正在同步,请稍后重试", "pending": True} hits = [p for p in r["data"]["projects"] if q in f"{p['customer']}{p['name']}".lower()] return {"ok": True, "matched": len(hits), "projects": hits, "stale": r.get("stale"), "refreshing": r.get("refreshing")} if name == "list_pending_tasks": r = http("/api/agent/tasks") if not r.get("ok"): return r return {"ok": True, "tasks": [t for t in r["tasks"] if t.get("status") == "pending"]} if name == "list_due_scheduled_tasks": return list_due_scheduled_tasks() if not args else {'ok':False,'error':'invalid_arguments'} if name == "claim_due_scheduled": return claim_due_scheduled(args['id']) if set(args)=={'id'} else {'ok':False,'error':'invalid_arguments'} if name == "claim_task": return task_action('claim', args) if name == "get_task": return task_detail(args.get('id')) if name == "search_knowledge": return http("/api/knowledge/search?" + urllib.parse.urlencode({"q": args.get("query", "")})) if name == "finish_task": return task_action('finish', args) if name == "read_knowledge_document": doc_id = args.get('id') if not isinstance(doc_id, str) or not doc_id or len(doc_id) > 512: return {'ok': False, 'error': 'invalid_document_id'} return http('/api/knowledge/doc?' + urllib.parse.urlencode({'id': doc_id})) if name == "list_talent_projects": return http('/api/recruitment/status') if name == "search_talent": return search_talent(args) if name == "get_candidate_detail": candidate_id, project = args.get('id'), args.get('project') if (not ((type(candidate_id) is int and 1 <= candidate_id <= 9223372036854775807) or (isinstance(candidate_id,str) and re.fullmatch(r'current:[0-9a-f]{64}',candidate_id) and project=='feishu:current')) or not valid_project(project)): return {'ok': False, 'error': 'invalid_request'} return http('/api/recruitment/detail?' + urllib.parse.urlencode({'id': candidate_id, 'project': project})) if name == "get_recruitment_positions": project = args.get('project') if not valid_project(project): return {'ok': False, 'error': 'invalid_project'} return http('/api/recruitment/positions?' + urllib.parse.urlencode({'project': project})) return {"ok": False, "error": f"unknown tool {name}"} def available_tools(): """Never advertise data tools without a fresh server capability response.""" response=http('/api/agent/capabilities') caps=response.get('capabilities') if response.get('ok') else {} if not isinstance(caps,dict): caps={} requires={'list_projects':'progress','list_pipeline':'progress', 'search_knowledge':'knowledge','read_knowledge_document':'knowledge', 'list_talent_projects':'recruitment','search_talent':'recruitment', 'get_candidate_detail':'recruitment','get_recruitment_positions':'recruitment'} return [tool for tool in TOOLS if tool['name'] not in requires or caps.get(requires[tool['name']]) is True] def handle(req): if not isinstance(req, dict): return {'jsonrpc': '2.0', 'id': None, 'error': {'code': -32600, 'message': 'invalid request'}} method = req.get("method") rid = req.get("id") if method == "initialize": return {"jsonrpc": "2.0", "id": rid, "result": { "protocolVersion": "2024-11-05", "capabilities": {"tools": {}}, "serverInfo": {"name": "aihire-portal", "version": "0.1.0"}}} if method == "tools/list": return {"jsonrpc": "2.0", "id": rid, "result": {"tools": available_tools()}} if method == "tools/call": params = req.get("params") or {} if not isinstance(params, dict) or not isinstance(params.get('arguments', {}), dict): out = {'ok': False, 'error': 'invalid_arguments'} else: out = call_tool(params.get("name"), params.get("arguments") or {}) return {"jsonrpc": "2.0", "id": rid, "result": { "content": [{"type": "text", "text": json.dumps(out, ensure_ascii=False, indent=2)}], "isError": not out.get("ok", False)}} if method == "ping": return {"jsonrpc": "2.0", "id": rid, "result": {}} if rid is None: return None return {"jsonrpc": "2.0", "id": rid, "error": {"code": -32601, "message": "method not found"}} def protected_token(value=None): """Use macOS Keychain or Windows DPAPI for the current user's credential.""" if sys.platform == 'darwin': return mac_token(value) if os.name != "nt": raise RuntimeError("Token persistence requires Windows DPAPI") class Blob(ctypes.Structure): _fields_ = [("size", ctypes.c_ulong), ("data", ctypes.POINTER(ctypes.c_ubyte))] # Legacy alone keeps the previous origin-only path. Named clients never reuse it. key = PORTAL if CLIENT == 'legacy' else PORTAL + '\0' + CLIENT path = Path(os.environ.get("LOCALAPPDATA", str(Path.home()))) / "AiHirePilot" / ("agent-token-" + hashlib.sha256(key.encode()).hexdigest()[:20] + ".bin") raw = value.encode() if value is not None else path.read_bytes() buf = (ctypes.c_ubyte * len(raw)).from_buffer_copy(raw) src, out = Blob(len(raw), buf), Blob() crypt = ctypes.windll.crypt32 if value is not None: ok=crypt.CryptProtectData(ctypes.byref(src), None, None, None, None, 1, ctypes.byref(out)) else: ok=crypt.CryptUnprotectData(ctypes.byref(src), None, None, None, None, 1, ctypes.byref(out)) if not ok: raise OSError("Windows token protection failed") try: result=ctypes.string_at(out.data,out.size) finally: ctypes.windll.kernel32.LocalFree(out.data) if value is not None: path.parent.mkdir(parents=True,exist_ok=True); path.write_bytes(result); return None return result.decode() def load_task_packet(filename): """Validate an untrusted packet without using it to choose an endpoint/token.""" def unique_pairs(pairs): result = {} for key, value in pairs: if key in result: raise ValueError('duplicate_field') result[key] = value return result try: with open(filename, 'rb') as stream: raw = stream.read(65537) if len(raw) > 65536: raise ValueError('packet_too_large') packet = json.loads(raw.decode('utf-8-sig'), object_pairs_hook=unique_pairs) if not isinstance(packet, dict) or set(packet) - {'portal', 'task_id', 'software', 'instruction'}: raise ValueError('invalid_packet') if not isinstance(packet.get('portal'), str) or validated_origin(packet['portal']) != PORTAL: raise ValueError('portal_mismatch') if not valid_task_id(packet.get('task_id')): raise ValueError('invalid_task_id') software = packet.get('software') if software is not None and (not isinstance(software, str) or software != CLIENT): raise ValueError('software_mismatch') if 'software' in packet and software is None: raise ValueError('invalid_software') if 'instruction' in packet and (not isinstance(packet['instruction'], str) or len(packet['instruction']) > 10000): raise ValueError('invalid_instruction') return {'task_id': packet['task_id'], 'software_unverified': software is None} except (OSError, UnicodeError, ValueError, TypeError) as exc: # Never echo the file contents, path, URL, or arbitrary exception text. known = {'duplicate_field', 'packet_too_large', 'invalid_packet', 'portal_mismatch', 'invalid_task_id', 'software_mismatch', 'invalid_software', 'invalid_instruction'} reason = str(exc) if isinstance(exc, ValueError) and str(exc) in known else 'invalid_packet' return {'error': reason} def write_cli(result): sys.stdout.write(json.dumps(result, ensure_ascii=False) + '\n') return 0 if result.get('ok') else 1 def configure_stdio(): """MCP stdio and redirected CLI input/output use UTF-8 on Windows too.""" for stream in (sys.stdin, sys.stdout, sys.stderr): if hasattr(stream, 'reconfigure'): try: stream.reconfigure(encoding='utf-8', errors='strict') except (ValueError, OSError): pass def main(): global TOKEN args = sys.argv[1:] if args == ['--connect']: # Only a credential in the current origin/client namespace is reusable. TOKEN = '' try: saved = protected_token() except FileNotFoundError: saved = '' except (OSError, RuntimeError, UnicodeError): print('无法读取本机连接凭据,请检查当前系统账户和凭据文件权限。', file=sys.stderr); return 1 if saved: TOKEN = saved status = http('/api/agent/capabilities', deadline=time.monotonic()+15) agent = status.get('agent') if isinstance(status.get('agent'),dict) else {} if status.get('ok') and agent.get('client_type') == CLIENT and isinstance(agent.get('agent_id'),str) and re.fullmatch(r'[A-Za-z0-9_-]{1,128}',agent['agent_id']) and isinstance(status.get('capabilities'),dict): print('已有授权可复用,软件配置完成后即可使用。', file=sys.stderr); return 0 if status.get('ok') and agent.get('client_type') != CLIENT: TOKEN = '' print('本机连接的软件类型不匹配,请检查当前客户端配置。', file=sys.stderr); return 1 if status.get('http_status') not in (401,403): TOKEN = '' print('暂时无法确认连接状态,请检查网络或客户端类型后重试。', file=sys.stderr); return 1 TOKEN = '' start = http('/api/agent/device/start', {'client_type': CLIENT, 'name': NAME}, 'POST') device_id, secret = start.get('device_id'), start.get('device_secret') if not isinstance(device_id, str) or not isinstance(secret, str) or not re.fullmatch(r'[A-Za-z0-9_-]{20,100}', device_id): print('连接请求失败。', file=sys.stderr); return 1 code = start.get('verification_code') if not isinstance(code, str) or not re.fullmatch(r'[0-9]{6}', code): print('连接请求无效。', file=sys.stderr); return 1 print('请核对浏览器中的验证码:' + code, file=sys.stderr) url = PORTAL + '/?' + urllib.parse.urlencode({'device_id': device_id}) print('请在本人浏览器登录并确认连接:' + url, file=sys.stderr) try: webbrowser.open(url) except Exception: pass deadline = time.monotonic() + 300 while time.monotonic() < deadline: time.sleep(3) result = http('/api/agent/device/poll', {'device_id':device_id, 'device_secret':secret, 'client_type':CLIENT}, 'POST') status = result.get('status') if status == 'connected': if result.get('client_type') != CLIENT or not isinstance(result.get('token'), str) or not result.get('agent_id'): print('连接结果无效。', file=sys.stderr); return 1 try: protected_token(result['token']) except (OSError, RuntimeError, UnicodeError): print('无法保存连接凭据,请检查系统账户及钥匙串权限。', file=sys.stderr); return 1 print('连接完成,令牌已保存到当前用户账户。', file=sys.stderr); return 0 if status != 'pending': print('连接未完成,请重新发起。', file=sys.stderr); return 1 print('连接请求已超时。', file=sys.stderr); return 1 if args == ['--pair']: code=getpass.getpass("一次性配对码: ") exchange=http("/api/agent/exchange", {"code":code,"client_type":CLIENT}, "POST") if not exchange.get("token"): print("配对失败、类型不匹配或配对码已过期。",file=sys.stderr); return 1 try: protected_token(exchange["token"]) except (OSError, RuntimeError, UnicodeError): print('无法保存连接凭据,请检查系统账户及钥匙串权限。',file=sys.stderr); return 1 print("配对完成。令牌已保存到当前用户账户。",file=sys.stderr); return 0 commands = {'--task-packet', '--get-task', '--claim-task', '--finish-task', '--finish-file'} cli = args[0] if args else None if args and (cli not in commands or len(args) != (3 if cli == '--finish-file' else 2)): print('用法:--connect(连接),--pair(旧版配对),或 --task-packet 文件 / --get-task 编号 / --claim-task 编号 / --finish-task 编号 / --finish-file 编号 文件', file=sys.stderr) return 2 packet = None if cli == '--task-packet': packet = load_task_packet(args[1]) if packet.get('error'): return write_cli({'ok': False, 'error': packet['error']}) elif cli and not valid_task_id(args[1]): return write_cli({'ok': False, 'error': 'invalid_task_id'}) result_text = None if cli == '--finish-task': if sys.stdin.isatty(): print('请通过标准输入提供结果摘要;完成后再提交。', file=sys.stderr) return 2 result_text = sys.stdin.read(4001) if not result_text.strip() or len(result_text) > 4000: return write_cli({'ok': False, 'error': 'invalid_result'}) elif cli == '--finish-file': try: with open(args[2], 'rb') as stream: raw = stream.read(16004) if len(raw) > 16003: return write_cli({'ok': False, 'error': 'invalid_result'}) result_text = raw.decode('utf-8-sig') except (OSError, UnicodeError): return write_cli({'ok': False, 'error': 'invalid_result_file'}) if not result_text.strip() or len(result_text) > 4000: return write_cli({'ok': False, 'error': 'invalid_result'}) if not TOKEN: try: TOKEN=protected_token() except (OSError, RuntimeError): pass code = os.environ.pop("PORTAL_PAIR_CODE", "") if not TOKEN and code: exchange = http("/api/agent/exchange", {"code": code, "client_type": CLIENT}, "POST") TOKEN = exchange.get("token", "") if TOKEN: protected_token(TOKEN) if not TOKEN: print("Agent 未连接:请先运行客户端脚本并添加 --connect。", file=sys.stderr) return 1 if cli: if cli == '--task-packet': detail = task_detail(packet['task_id']) if detail.get('ok'): task = detail.get('task') if not isinstance(task, dict) or task.get('id') != packet['task_id']: return write_cli({'ok': False, 'error': 'task_mismatch'}) detail = {'ok': True, 'task': task, 'portal': PORTAL, 'software': CLIENT, 'software_unverified': packet['software_unverified']} return write_cli(detail) if cli == '--get-task': return write_cli(task_detail(args[1])) if cli == '--claim-task': return write_cli(task_action('claim', {'id': args[1]})) return write_cli(task_action('finish', {'id': args[1], 'result': result_text})) def heartbeat(): while True: hello() time.sleep(30) threading.Thread(target=heartbeat, daemon=True).start() for line in sys.stdin: line = line.strip() if not line: continue try: req = json.loads(line) except json.JSONDecodeError: continue resp = handle(req) if resp is not None: sys.stdout.write(json.dumps(resp, ensure_ascii=False) + "\n") sys.stdout.flush() if __name__ == "__main__": try: configure_stdio() sys.exit(main()) except KeyboardInterrupt: pass