split-identity-delivery.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
  1. """Split the verified handoff into packages with explicit recipients; no installation."""
  2. from pathlib import Path, PurePosixPath
  3. import hashlib
  4. import json
  5. import zipfile
  6. ROOT = Path(__file__).resolve().parent.parent
  7. SOURCE = ROOT / 'work/delivery-aibot-identity-20261102/final/ksf-aibot-identity-20261102.zip'
  8. EXPECTED_SOURCE = '375ba276e88b579887c89420ec6256c90e4afba79ee1393c46e85896c66984e4'
  9. DEST = ROOT / 'work/delivery-by-recipient-20261102'
  10. sha = lambda value: hashlib.sha256(value).hexdigest()
  11. assert sha(SOURCE.read_bytes()) == EXPECTED_SOURCE, 'SOURCE_HASH_MISMATCH'
  12. def verify_manifest(payloads, filename='FILES.sha256', prefix=''):
  13. rows = payloads[filename].decode('utf-8').strip().splitlines()
  14. for row in rows:
  15. fingerprint, name = row.split(' ', 1)
  16. assert not PurePosixPath(name).is_absolute() and '..' not in PurePosixPath(name).parts
  17. assert sha(payloads[prefix + name]) == fingerprint, 'MANIFEST_HASH_MISMATCH'
  18. return len(rows)
  19. with zipfile.ZipFile(SOURCE) as z:
  20. assert z.testzip() is None
  21. assert len(z.namelist()) == len(set(z.namelist())) == 24
  22. source = {name: z.read(name) for name in z.namelist()}
  23. assert verify_manifest(source) == 23
  24. def pack(name, recipient, payloads, manifest_count):
  25. assert 'FILES.sha256' not in payloads
  26. assert len(payloads) == manifest_count
  27. for filename, data in payloads.items():
  28. assert not PurePosixPath(filename).is_absolute() and '..' not in PurePosixPath(filename).parts
  29. assert not filename.endswith(('.pem', '.key', '.env'))
  30. assert 'credential-binding.json' not in filename
  31. assert b'-----BEGIN PRIVATE KEY-----' not in data
  32. payloads['FILES.sha256'] = ''.join(sha(payloads[x]) + ' ' + x + '\n' for x in sorted(payloads)).encode()
  33. DEST.mkdir(parents=True, exist_ok=True)
  34. target = DEST / (name + '.zip')
  35. # Reproducible archive written outside the final destination first.
  36. from io import BytesIO
  37. stream = BytesIO()
  38. with zipfile.ZipFile(stream, 'w', compression=zipfile.ZIP_DEFLATED) as z:
  39. for filename in sorted(payloads):
  40. z.writestr(zipfile.ZipInfo(filename, (2026, 11, 2, 0, 0, 0)), payloads[filename], compress_type=zipfile.ZIP_DEFLATED)
  41. packed = stream.getvalue()
  42. if target.exists():
  43. assert target.read_bytes() == packed, 'DELIVERY_ALREADY_EXISTS'
  44. else:
  45. with target.open('xb') as f:
  46. f.write(packed)
  47. with zipfile.ZipFile(target) as z:
  48. assert z.testzip() is None
  49. unpacked = {filename: z.read(filename) for filename in z.namelist()}
  50. assert unpacked == payloads
  51. assert verify_manifest(unpacked) == manifest_count
  52. if recipient == '王刚001':
  53. assert not any(x.startswith('server/') for x in unpacked)
  54. assert verify_manifest(unpacked, 'skills/ksf-workshop-runtime/FILES.sha256', 'skills/ksf-workshop-runtime/') == 9
  55. else:
  56. assert not any(x.startswith('skills/') for x in unpacked)
  57. assert not any('install-workshop-skill' in x for x in unpacked)
  58. result = dict(recipient=recipient, file=str(target), sizeBytes=len(packed), sha256=sha(packed),
  59. manifestEntries=manifest_count, zipIntegrity=True, manifestsVerified=True,
  60. derivedFromVerifiedSource=True, containsCredentials=False,
  61. installed=False, deployed=False, gatewayModified=False)
  62. target.with_suffix('.json').write_text(json.dumps(result, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
  63. target.with_suffix('.txt').write_text(
  64. f'只发给:{recipient}\n附件:{name}.zip\n字节数:{len(packed)}\nSHA-256:{sha(packed)}\n\n'
  65. '请先按包内 INSTALL.txt 核对附件与清单、审读代码,返回结果。本轮只核验和准备,不执行安装、生产发布、重启或业务写入。\n', encoding='utf-8')
  66. return result
  67. skill_names = [name for name in source if name.startswith('skills/ksf-workshop-runtime/')]
  68. wang = {name: source[name] for name in skill_names + ['scripts/install-workshop-skill.mjs', 'checks/empty.json', 'templates/agent-self.json', 'templates/employee.json', 'templates/channel-context.example.json']}
  69. agent_template = json.loads(wang['templates/agent-self.json'])
  70. agent_template.update(name='王刚001', role='workshop')
  71. wang['templates/agent-self.json'] = (json.dumps(agent_template, ensure_ascii=False, indent=2) + '\n').encode()
  72. wang['INSTALL.txt'] = '''收件数字人:王刚001。此包仅用于升级你现有的 ksf-workshop-runtime,不给运维001安装。
  73. 本轮先核验与审读,不自动执行安装或业务;下面的安装命令供后续执行时使用。
  74. 1. 按同发执行单的字节数/SHA-256核对ZIP,在/opt/data下新建目录解压,不覆盖旧包。
  75. 2. 包根运行sha256sum -c FILES.sha256,预期16/16 OK;技能子清单9/9 OK。
  76. 3. 阅读scripts/install-workshop-skill.mjs、skills/ksf-workshop-runtime/SKILL.md和references/identity.md。
  77. 4. 返回清单核验结果、已安装Skill是否匹配已知升级基线,以及当前自身botid字段是否可从可信运行配置取得;不贴完整配置、bot secret或其他凭据。
  78. 后续安装命令:node scripts/install-workshop-skill.mjs。
  79. 安装器仅升级已知基线,备份旧文件,保留原入口、runtime-lock、加密绑定和员工/Agent档案;未知内容停止,重复安装零改动。安装后重新加载Skill,并在实际安装目录用本包技能子清单检查九文件。
  80. 自身模板固定名称为王刚001、role=workshop;accountId必须读取你实际订阅配置的botid,未知则null,不能填secret或运维001的botid。没有可信ID不声称完成自动识别。
  81. 本包没有后台发布工具,也没有Hermes适配器补丁。运维001负责让当前消息的botId/from.userid等元数据通过可信适配器或hook交付,负责后台两函数的发布准备。元数据未接入前不要自己生成文件冒充消息;姓名缺失可在接通后用员工待绑定标签,不要求员工重复填写账号。
  82. 本轮不创建员工、会话、样例或报告,不改网关、不重启,不重复已有G/H/K任务。后续真实消息验收需接入和后台能力均就绪。
  83. '''.encode()
  84. ops = {name: source[name] for name in source if name.startswith('server/')}
  85. ops['docs/wecom-aibot-identity-handoff.md'] = source['docs/wecom-aibot-identity-handoff.md'].replace(
  86. b'references/identity.md', b'docs/aibot-context-contract.md')
  87. ops['docs/aibot-context-contract.md'] = '''# 给运维001的消息身份交付约定
  88. Skill安装在王刚001,运维001不安装或复制其Skill、加密绑定及私钥。
  89. 当前接入为aibot WebSocket;按当前消息字段取senderId=body.from.userid、messageId=body.msgid、chatType=实际chattype、chatId=实际chatid(单聊可null)。botId来自处理该消息的机器人订阅配置,不是secret、corpId或应用agentId。
  90. 交付结构:
  91. {"version":"ksf.wecom-aibot-context.v1","botId":"本实例实际botid","senderId":"当前消息from.userid","chatType":"single","chatId":null,"messageId":"当前消息msgid"}
  92. 接入层/hook须保证元数据来自已经鉴别的当前消息,与当前执行bot和消息关联;不能接受用户正文替代,不能用全局变量或共享临时文件导致并发串人。若用文件,放/opt/data下,0600,逐消息隔离并提供失效机制,不含正文或凭据。JSON格式本身不证明来源,来源可信性须在接入层落实。
  93. 王刚001的Skill按botId+senderId生成稳定员工键;无姓名显示员工待绑定,企业未知为null。群chatid不作为员工ID,跨bot未经核实不自动合并,不能从相同ID前缀认定类型。实名、工号及跨bot关联需另行受控绑定。
  94. 后台只涉及ksfStartWorkshopSession与ksfGetIngestContract,新增channelIdentitySchema=ksf.wecom-aibot-sender.v1及作用域核对。账号/作用域保持私有,公开看板样式不改。本包没有现场适配器源码和可直接套用的适配器补丁。
  95. '''.encode()
  96. ops['INSTALL.txt'] = '''收件数字人:运维001。此包用于企微/Hermes接入准备与后台两函数发布准备,不安装到王刚001的Skill目录。
  97. 本轮仅只读核验和准备:
  98. 1. 按同发执行单核对ZIP字节数/SHA-256,在/opt/data新目录解压;包根sha256sum -c FILES.sha256,预期8/8 OK。
  99. 2. 阅读docs/wecom-aibot-identity-handoff.md及docs/aibot-context-contract.md。在隔离副本准备适配器/hook补丁,返回相关版本/文件指纹、具体差异、并发与过期处理、离线测试和回退方案。此包不含现场适配器补丁,不按报告行号盲改。
  100. 3. 审读server/scripts/deploy-identity.mjs和server/dist/identity-manifest.json。
  101. 4. 若现有受控管理连接可用,将KSF_CONNECTION_CONFIG指向既有配置路径,运行node server/scripts/deploy-identity.mjs做只读预检。预期readOnly=true、operation=read-only-plan、两函数、schemaChanges=permissionChanges=businessWrites=0。无配置如实报告,不从聊天接收明文凭据。
  102. 5. 返回核验结果与预检JSON/退出码。此轮不运行--apply,不改客户容器配置、不重启、不触发客户消息,也不修改静态看板。
  103. 后续正式部署时才运行node server/scripts/deploy-identity.mjs --apply,涉及两个共享后台Function.code的真实写入,有备份和回退;须等接入方案、安装顺序和生产发布安排明确后执行。Skill包单独交给王刚001,由王刚001核验和安装,运维001不替它登记自身身份或复制凭据。
  104. '''.encode()
  105. results = [pack('ksf-wanggang001-skill-20261102', '王刚001', wang, 16),
  106. pack('ksf-yunwei001-integration-20261102', '运维001', ops, 8)]
  107. (DEST / 'delivery-map.json').write_text(json.dumps(results, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
  108. print(json.dumps(results, ensure_ascii=False, indent=2))