package-workshop-identity.mjs 12 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374
  1. import fs from 'node:fs';
  2. import path from 'node:path';
  3. import crypto from 'node:crypto';
  4. import {execFileSync} from 'node:child_process';
  5. const output=process.argv[2];if(!output||!path.isAbsolute(output))throw Error('请指定交付目录绝对路径');
  6. const sha=x=>crypto.createHash('sha256').update(x).digest('hex'),name='ksf-aibot-identity-20261102';
  7. const staging=fs.mkdtempSync(path.resolve('work/identity-registry-delivery-')),root=path.join(staging,name);fs.mkdirSync(root);
  8. function write(file,content){const target=path.join(root,file);fs.mkdirSync(path.dirname(target),{recursive:true});fs.writeFileSync(target,content);}
  9. function copy(file){write(file,fs.readFileSync(file));}
  10. const skill='skills/ksf-workshop-runtime',skillNames=['SKILL.md','references/identity.md','references/workshop-tasks.md','references/write-tasks.md','runtime-lock.json','scripts/ksf-identity.mjs','scripts/ksf-secure.mjs','scripts/ksf-workshop.mjs','scripts/ksf.mjs'].sort();
  11. const entries=fs.readFileSync(skill+'/FILES.sha256','utf8').trim().split(/\r?\n/).map(line=>{const m=line.match(/^([a-f0-9]{64}) ([A-Za-z0-9_./-]+)$/);if(!m)throw Error('BAD_SKILL_MANIFEST');return{hash:m[1],file:m[2]};});
  12. if(JSON.stringify(entries.map(x=>x.file).sort())!==JSON.stringify(skillNames))throw Error('UNEXPECTED_SKILL_FILES');
  13. for(const x of entries){const file=skill+'/'+x.file;if(sha(fs.readFileSync(file))!==x.hash)throw Error('SKILL_BUILD_REQUIRED');copy(file);}
  14. copy(skill+'/FILES.sha256');copy('scripts/install-workshop-skill.mjs');copy('docs/workshop-identity.md');
  15. copy('docs/wecom-aibot-identity-handoff.md');
  16. for(const x of ['ksfStartWorkshopSession','ksfGetIngestContract'])copy('server/dist/'+x+'.js');
  17. copy('server/dist/identity-manifest.json');copy('server/scripts/deploy-identity.mjs');copy('server/scripts/platform-client.mjs');
  18. write('templates/agent-self.json',JSON.stringify({name:'替换为当前数字人的真实名称',role:'workshop',identityBasis:'operator_registration',runtimePlatform:'hermes',channel:'wecom',account:{platform:'wecom',tenantId:null,accountId:null,accountName:null,runtimeAgentId:null,wecomAppAgentId:null}},null,2)+'\n');
  19. write('templates/employee.json',JSON.stringify({name:'替换为当前员工显示名',identityBasis:'participant_registration',account:{platform:'wecom',tenantId:null,accountId:null,accountName:null,employeeNumber:null}},null,2)+'\n');
  20. write('checks/empty.json','{}\n');
  21. write('templates/channel-context.example.json',JSON.stringify({version:'ksf.wecom-aibot-context.v1',botId:'只可来自本实例订阅配置',senderId:'只可来自当前消息body.from.userid',chatType:'single',chatId:null,messageId:'只可来自当前消息body.msgid'},null,2)+'\n');
  22. write('INSTALL.txt',`康师傅工作坊 · aibot发送者身份接收升级 · 2026-11-02
  23. 先读 docs/wecom-aibot-identity-handoff.md。本包支持无姓名/无企业ID时接收已有from.userid,但不含Hermes适配器补丁,不会自动生成可信消息上下文。此前通用登记包原件保留,本附件使用新名字。当前只涉及王刚001与运维001,不新增数字人。
  24. 这是通用登记版,支持新员工、新数字人;不使用旧“双固定槽”包。只含 Skill 九文件和两函数代码,不含真实账号、凭据、私钥或 credential-binding.json。看板样式、静态发布、网关、s6、冻结 r7 不在范围内;不重复 G/H 或技术联调。
  25. 一、核对附件
  26. 按外部执行单的字节数和 SHA-256 校验本 ZIP,解压到 /opt/data/ksf-aibot-identity-20261102/,目录已存在则新建,不覆盖。cd 包根执行 sha256sum -c FILES.sha256,预期 23/23 OK;技能子目录 FILES.sha256 预期 9/9 OK。不以旧包代替。
  27. 二、已有工作坊 Skill 的数字人:先升级,不跑业务
  28. 1. 阅读 scripts/install-workshop-skill.mjs 和 skills/ksf-workshop-runtime/references/identity.md。执行 node scripts/install-workshop-skill.mjs。
  29. 仅升级已知原版/I-J/旧双槽/通用登记版,未知目标内容停止。保留既有加密绑定、原业务入口、runtime-lock、所有旧档案和动态 agents/participants 状态目录;重复安装 changed=[]。不自动迁移或删旧身份。
  30. 2. skills_list / skillview 重读 ksf-workshop-runtime;在实际安装技能目录核对本包技能子清单九文件。
  31. 3. node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-identity.mjs status
  32. 4. 从自己的可信运行配置和当前消息上下文提取白名单身份,参考 templates/agent-self.json,仅替换实际取得的字段。不能直接登记模板占位名,不能把另一机器人当自己,也不能说空账号模板代表读取成功。摘要在 /opt/data 下保存为 0600;未知账号 null,不全盘扫描配置。
  33. 王刚001 如实际身份相符则 name=王刚001、role=workshop;运维001 则 name=运维001、role=operations。其他新数字人填自己的真实名称及用途,不套用这两个名字。
  34. 5. 新档案:node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-identity.mjs register --input /实际绝对路径/自身摘要.json
  35. 已有通用档案:check --agent-profile <返回的完整Agent键> --input /实际绝对路径/自身摘要.json。
  36. 旧 agent-self.json 或 agent-self-workshop/operations.json:先读懂旧档案来源,再用 adopt-agent --input /旧档案绝对路径;原件不删除,迁入后 check 当前自身摘要。冲突停,不换键绕过。
  37. 无真实稳定 ID 用 operator_registration 生成 UUID;runtime_config/channel_context 必须有真实稳定 ID。返回 needsReview=true 只表示有同名候选,需明确 reuseKey 或 confirmNew 才完成登记。
  38. 6. 优先使用aibot发送者自动登记:仅当运维已实现接入层逐消息上下文后,用 register-channel-employee --agent-profile <当前Agent完整键> --context /接入层当前消息私有文件。上下文结构见templates/channel-context.example.json,但不能填模板或用户贴文冒充真实消息。自身档案account.accountId必须为实际订阅botid,与上下文botId一致。
  39. 员工account保存identifierType=wecom_aibot_sender、scopeId=botid、accountId=from.userid,企业未知仍null。无姓名先显示员工待绑定,不要求员工回答账号;同sender跨单群复用,跨bot先独立。程序不校验企微签名,来源可信由接入层保证;不把channelContextOriginVerified=false说成身份来源已认证。
  40. 完全没有可信发送者时才走 register-employee --input /实际绝对路径/员工摘要.json,参考templates/employee.json;无ID用participant_registration,同名需明确复用或新建。已知员工档案用check-employee核对。不得从User提示文字或报告中的姓名推导当前员工。
  41. 本轮只做本地登记,不创建会话或业务成果。本地登记零网络、零数据库写入;跨容器本地 UUID 不会自动同步,需受控核对后复用同一档案。userId 仅可填本应用已有 _User.objectId,企微 userId 写 accountId。
  42. 7. 回报安装、登记/check/status JSON、退出码及 invocationId,说明已取得/缺失字段名,不输出完整账号值或配置原文。
  43. 8. 可用现有加密入口只读检查:
  44. node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-secure.mjs workshop-contract --input /实际解压目录/checks/empty.json --request-id KSF-身份合同-20261102-L
  45. 预期 workshop.identitySchema=ksf.agent-self.v1,aibot还需channelIdentitySchema=ksf.wecom-aibot-sender.v1;未就绪照实报告,不执行 workshop-start/save。
  46. 三、新数字人或运维001没有既有 Skill/绑定
  47. 本包安装器用于已知基线升级,不是首次凭据开通工具。不复制王刚001的 credential-binding.json/私钥。可直接用本包 skills/ksf-workshop-runtime/scripts/ksf-identity.mjs 完成本地自身登记,它不需要服务凭据;实际业务调用前另配该实例受控授权的 Skill 与加密绑定。
  48. 四、运维001:只做两个云函数的发布预检
  49. 1. 阅读 server/scripts/deploy-identity.mjs 和 server/dist/identity-manifest.json,仅涉及两个现有 Function.code:ksfStartWorkshopSession、ksfGetIngestContract,不更新 /opt/kangshifu-web、不重启 pm2。
  50. 2. 在已有受控管理连接的环境将 KSF_CONNECTION_CONFIG 指向现有键值表配置文件;字段格式同 platform-client.mjs。不在聊天中传密钥。
  51. 3. node server/scripts/deploy-identity.mjs
  52. 预期 readOnly=true、operation=read-only-plan、functions 恰好两项、schemaChanges=permissionChanges=businessWrites=0。只接受已审计基线或目标版本,未知代码停止。
  53. 4. 返回原始预检 JSON 与退出码,本轮不执行 --apply。正式发布命令另行执行 node server/scripts/deploy-identity.mjs --apply;工具备份、回读、检查结构权限,失败只在目标仍为本次代码时回退,不覆盖并发更改。
  54. 5. 运维接入准备:按handoff文档在隔离副本准备适配器/hook补丁及离线测试,提供当前版本/相关文件SHA-256、具体代码差异、逐消息隔离/过期机制与回退方案。本包没有现场源码,不按报告行号盲补丁;本轮不改客户容器、不重启、不发送测试消息。
  55. 五、后续联合验收(本轮不执行)
  56. 两函数发布后先读合同,再从新的真实企微消息确认当前员工与 Agent 档案。workshop-start 输入 participant:{profileKey:"完整员工键"},命令传 --agent-profile <完整Agent键>;不再传 --agent-profile workshop 或 --slot。
  57. 沿用尚未执行的 KSF-企微实测-20261101-K 业务键,S1待查清单从start就用sample,save继承;身份人工登记不等于材料manual。按真实sessionId/artifactId回读,再由管理端独立核对私有归属快照和公开看板,不把本地登记当远端入库或平台认证。
  58. 原服务令牌到期为北京时间2026-11-06 18:42,本次未续期。保留写入保护、不改网关、不重启、不自动清理。
  59. `);
  60. const files=[];function walk(dir){for(const e of fs.readdirSync(dir,{withFileTypes:true})){const file=path.join(dir,e.name);if(e.isSymbolicLink())throw Error('SYMLINK_IN_PACKAGE');if(e.isDirectory())walk(file);else files.push(path.relative(root,file).replaceAll('\\','/'));}}walk(root);files.sort();if(files.length!==23)throw Error('UNEXPECTED_PACKAGE_SIZE');
  61. for(const file of files){if(/(?:credential-binding|recipient-private|\.pem$|\.env$|agent-self-(?:workshop|operations)\.json$)/.test(file)||/-----BEGIN (?:RSA )?PRIVATE KEY-----/.test(fs.readFileSync(path.join(root,file),'utf8')))throw Error('PRIVATE_MATERIAL_IN_PACKAGE');}
  62. write('FILES.sha256',files.map(file=>sha(fs.readFileSync(path.join(root,file)))+' '+file).join('\n')+'\n');
  63. const python=process.env.KSF_PACKAGE_PYTHON||'C:/Users/JSHY/AppData/Local/Python/pythoncore-3.14-64/python.exe',packed=path.join(staging,name+'.zip');
  64. execFileSync(python,['-c','import sys,zipfile,pathlib; root=pathlib.Path(sys.argv[1]); z=zipfile.ZipFile(sys.argv[2],"w",zipfile.ZIP_DEFLATED); [(lambda p: z.writestr(zipfile.ZipInfo(p.relative_to(root).as_posix(),(2026,11,2,0,0,0)),p.read_bytes(),compress_type=zipfile.ZIP_DEFLATED))(p) for p in sorted(root.rglob("*")) if p.is_file()]; z.close(); z=zipfile.ZipFile(sys.argv[2]); assert z.testzip() is None',root,packed],{windowsHide:true});
  65. fs.mkdirSync(output,{recursive:true});const file=path.join(output,name+'.zip'),bytes=fs.readFileSync(packed);if(fs.existsSync(file)){if(sha(fs.readFileSync(file))!==sha(bytes))throw Error('DELIVERY_ALREADY_EXISTS');}else fs.copyFileSync(packed,file,fs.constants.COPYFILE_EXCL);
  66. const result={at:new Date().toISOString(),file,sizeBytes:bytes.length,sha256:sha(bytes),manifestEntries:files.length,skillManifestEntries:skillNames.length,containsCredentials:false,serverPublished:false,hermesInstalled:false,adapterModified:false,adapterIntegrationComplete:false,channelVerifiedLive:false};
  67. fs.writeFileSync(path.join(output,name+'.json'),JSON.stringify(result,null,2)+'\n');console.log(JSON.stringify(result,null,2));