ksf-identity.mjs 20 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889
  1. import fs from 'node:fs';
  2. import path from 'node:path';
  3. import crypto from 'node:crypto';
  4. import {fileURLToPath} from 'node:url';
  5. const self=fileURLToPath(import.meta.url);
  6. export const STATE_DIR='/opt/data/ksf-workshop-state';
  7. export const PROFILE_DIR=path.join(STATE_DIR,'agents');
  8. export const PARTICIPANT_DIR=path.join(STATE_DIR,'participants');
  9. const fail=code=>{throw Object.assign(Error(code),{code});};
  10. const object=x=>x&&typeof x==='object'&&!Array.isArray(x);
  11. const canonical=x=>x&&typeof x==='object'?Array.isArray(x)?'['+x.map(canonical).join(',')+']':'{'+Object.keys(x).sort().map(k=>JSON.stringify(k)+':'+canonical(x[k])).join(',')+'}':JSON.stringify(x);
  12. const validDate=x=>typeof x==='string'&&/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{1,3})?Z$/.test(x)&&Number.isFinite(Date.parse(x));
  13. function exact(x,keys){if(!object(x)||Object.keys(x).some(k=>!keys.includes(k)))fail('INVALID_IDENTITY_FIELDS');}
  14. function text(x){if(typeof x!=='string'||!x.trim()||x.length>200||/[\u0000-\u001f]/.test(x))fail('INVALID_IDENTITY_VALUE');return x.trim();}
  15. function safePath(file){if(typeof file!=='string'||!path.isAbsolute(file))fail('ABSOLUTE_PATH_REQUIRED');for(let p=path.resolve(file);;p=path.dirname(p)){if(fs.existsSync(p)&&fs.lstatSync(p).isSymbolicLink())fail('IDENTITY_SYMLINK_PATH');if(path.dirname(p)===p)break;}}
  16. function readJSON(file,code){safePath(file);if(!fs.existsSync(file))fail(code);const st=fs.lstatSync(file);if(!st.isFile())fail('INVALID_IDENTITY_FILE');if(process.platform==='linux'&&(st.uid!==process.geteuid()||(st.mode&0o777)!==0o600))fail('IDENTITY_FILE_PERMISSIONS');try{return JSON.parse(fs.readFileSync(file,'utf8'));}catch{fail('INVALID_IDENTITY_FILE');}}
  17. function writeJSON(directory,file,value){safePath(directory);safePath(file);fs.mkdirSync(directory,{recursive:true,mode:0o700});safePath(directory);const fd=fs.openSync(file,'wx',0o600);try{fs.writeFileSync(fd,JSON.stringify(value,null,2)+'\n');fs.fsyncSync(fd);}finally{fs.closeSync(fd);}}
  18. function account(value,fields){exact(value,fields);if(typeof value.platform!=='string'||!value.platform.trim())fail('INVALID_IDENTITY_VALUE');for(const[k,v]of Object.entries(value))if(v!==null&&(typeof v!=='string'||!v.trim()||v.length>200||/[\u0000-\u001f]/.test(v)))fail('INVALID_IDENTITY_VALUE');return Object.fromEntries(fields.map(k=>[k,k==='platform'?text(value[k]):value[k]==null?null:text(value[k])]));}
  19. const AGENT_ACCOUNT=['platform','tenantId','accountId','accountName','runtimeAgentId','wecomAppAgentId'];
  20. const PARTICIPANT_ACCOUNT=['platform','tenantId','accountId','accountName','employeeNumber'];
  21. export function normalizeAgentAccount(v){return account(v,AGENT_ACCOUNT);}
  22. export function normalizeParticipantAccount(v){const scoped=object(v)&&(Object.hasOwn(v,'identifierType')||Object.hasOwn(v,'scopeId'));const a=account(v,scoped?[...PARTICIPANT_ACCOUNT,'identifierType','scopeId']:PARTICIPANT_ACCOUNT);if(scoped&&(a.platform!=='wecom'||a.identifierType!=='wecom_aibot_sender'||!a.scopeId||!a.accountId))fail('INVALID_CHANNEL_ACCOUNT');return a;}
  23. function stableAgentKey(p){const a=p.account;if(!a.accountId&&!a.runtimeAgentId&&!(a.tenantId&&a.wecomAppAgentId))return null;const identity={runtimePlatform:p.runtimePlatform??null,platform:a.platform,tenantId:a.tenantId,accountId:a.accountId,runtimeAgentId:a.runtimeAgentId,wecomAppAgentId:a.wecomAppAgentId};return'agent-account:'+crypto.createHash('sha256').update(canonical(identity)).digest('hex');}
  24. function stableParticipantKey(p){const a=p.account,anchor=a.identifierType==='wecom_aibot_sender'?{kind:'wecom_aibot_sender',platform:'wecom',scopeId:a.scopeId,id:a.accountId}:p.userId?{kind:'application_user',appId:'FKieRbIoGt_main',id:p.userId}:a.accountId&&(a.platform!=='wecom'||a.tenantId)?{kind:'channel_account',platform:a.platform,tenantId:a.tenantId,id:a.accountId}:a.employeeNumber&&a.tenantId?{kind:'employee_number',platform:a.platform,tenantId:a.tenantId,id:a.employeeNumber}:null;return anchor?'participant-account:'+crypto.createHash('sha256').update(canonical(anchor)).digest('hex'):null;}
  25. const UUID='[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}';
  26. function agentFile(key,dir=PROFILE_DIR){if(typeof key!=='string'||!new RegExp('^agent-(?:registration:'+UUID+'|account:[a-f0-9]{64})$').test(key))fail('INVALID_AGENT_KEY');return path.join(dir,key.split(':')[1]+'.json');}
  27. function participantFile(key,dir=PARTICIPANT_DIR){if(typeof key!=='string'||!new RegExp('^participant-(?:registration:'+UUID+'|account:[a-f0-9]{64})$').test(key))fail('INVALID_PARTICIPANT_KEY');return path.join(dir,key.split(':')[1]+'.json');}
  28. function agentInput(input){exact(input,['name','role','identityBasis','runtimePlatform','channel','account','collectedAt','confirmNew','reuseKey']);const name=text(input.name),role=text(input.role);if(!/^[\p{L}\p{N}][\p{L}\p{N} _.-]{0,59}$/u.test(role)||!['operator_registration','runtime_config','channel_context'].includes(input.identityBasis))fail('INVALID_IDENTITY_VALUE');const normalized={name,role,identityBasis:input.identityBasis,runtimePlatform:input.runtimePlatform==null?null:text(input.runtimePlatform),channel:input.channel==null?null:text(input.channel),account:normalizeAgentAccount(input.account)};if(input.identityBasis!=='operator_registration'&&!stableAgentKey(normalized))fail('RUNTIME_AGENT_ID_REQUIRED');if(input.collectedAt!==undefined&&!validDate(input.collectedAt))fail('INVALID_IDENTITY_VALUE');if(input.confirmNew!==undefined&&input.confirmNew!==true)fail('INVALID_IDENTITY_VALUE');if(input.reuseKey!==undefined)agentFile(input.reuseKey);if(input.reuseKey&&input.confirmNew)fail('INVALID_IDENTITY_VALUE');return normalized;}
  29. function participantInput(input){exact(input,['name','identityBasis','account','userId','reuseKey','confirmNew']);const name=text(input.name);if(!['channel_context','participant_registration','existing_record'].includes(input.identityBasis))fail('INVALID_IDENTITY_VALUE');const normalized={name,identityBasis:input.identityBasis,account:normalizeParticipantAccount(input.account),userId:input.userId==null?null:text(input.userId)};if(normalized.account.identifierType==='wecom_aibot_sender'&&(normalized.identityBasis!=='channel_context'||normalized.userId))fail('INVALID_CHANNEL_ACCOUNT');if(input.identityBasis==='existing_record'&&!normalized.userId&&!stableParticipantKey(normalized))fail('STABLE_EMPLOYEE_ID_REQUIRED');if(input.identityBasis==='channel_context'&&!stableParticipantKey(normalized))fail('STABLE_EMPLOYEE_ID_REQUIRED');if(input.reuseKey!==undefined)participantFile(input.reuseKey);if(input.confirmNew!==undefined&&input.confirmNew!==true)fail('INVALID_IDENTITY_VALUE');if(input.reuseKey&&input.confirmNew)fail('INVALID_IDENTITY_VALUE');return normalized;}
  30. function profiles(dir,kind){safePath(dir);if(!fs.existsSync(dir))return[];const rows=[];for(const e of fs.readdirSync(dir,{withFileTypes:true})){if(!/^(?:[a-f0-9-]{36}|[a-f0-9]{64})\.json$/.test(e.name))continue;const file=path.join(dir,e.name);if(fs.lstatSync(file).isSymbolicLink())fail('IDENTITY_SYMLINK_PATH');if(!e.isFile())fail('INVALID_IDENTITY_FILE');const row=kind==='agent'?readAgentProfile(file):readParticipantProfileByPath(file);if(e.name!==row.key.split(':')[1]+'.json')fail('INVALID_IDENTITY_FILE');rows.push(row);}return rows;}
  31. function agentSummary(p){return p?{key:p.key,name:p.name,role:p.role,identityBasis:p.identityBasis,runtimePlatform:p.runtimePlatform,channel:p.channel,platform:p.account.platform,configuredAccountFields:Object.keys(p.account).filter(k=>k!=='platform'&&p.account[k]!==null),collectedAt:p.collectedAt,registeredAt:p.registeredAt}:null;}
  32. function participantSummary(p){return p?{key:p.key,name:p.name,identityBasis:p.identityBasis,platform:p.account.platform,configuredAccountFields:Object.keys(p.account).filter(k=>k!=='platform'&&p.account[k]!==null),hasApplicationUserRef:Boolean(p.userId),registeredAt:p.registeredAt}:null;}
  33. function savedAgentFields(p){return{name:p.name,role:p.role,identityBasis:p.identityBasis,runtimePlatform:p.runtimePlatform,channel:p.channel,account:p.account};}
  34. function savedParticipantFields(p){return{name:p.name,identityBasis:p.identityBasis,account:p.account,userId:p.userId};}
  35. export function readAgentProfile(file){const p=readJSON(file,'AGENT_PROFILE_REQUIRED');exact(p,['version','key','name','role','identityBasis','runtimePlatform','channel','account','collectedAt','registeredAt']);const collectedAt=p.collectedAt??p.registeredAt;if(p.version!=='ksf.agent-self.v1'||typeof p.key!=='string'||!validDate(p.registeredAt)||!validDate(collectedAt))fail('INVALID_IDENTITY_FILE');const fields=agentInput({...savedAgentFields(p),collectedAt}),validKey=fields.identityBasis==='operator_registration'&&new RegExp('^agent-registration:'+UUID+'$').test(p.key)||p.key===stableAgentKey(fields);if(!validKey)fail('INVALID_IDENTITY_FILE');return{...p,...fields,collectedAt};}
  36. export function readAgentProfileByKey(key,dir=PROFILE_DIR){const p=readAgentProfile(agentFile(key,dir));if(p.key!==key)fail('AGENT_PROFILE_CONFLICT');return p;}
  37. function readParticipantProfileByPath(file){const p=readJSON(file,'PARTICIPANT_PROFILE_REQUIRED');exact(p,['version','key','name','identityBasis','account','userId','registeredAt']);if(p.version!=='ksf.participant.v1'||typeof p.key!=='string'||!validDate(p.registeredAt))fail('INVALID_IDENTITY_FILE');const fields=participantInput(savedParticipantFields(p)),validKey=fields.identityBasis==='participant_registration'&&/^participant-registration:[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(p.key)||p.key===stableParticipantKey(fields);if(!validKey)fail('INVALID_IDENTITY_FILE');return{...p,...fields};}
  38. export function readParticipantProfile(key,dir=PARTICIPANT_DIR){const p=readParticipantProfileByPath(participantFile(key,dir));if(p.key!==key)fail('PARTICIPANT_PROFILE_CONFLICT');return p;}
  39. export function registerAgentProfile(input,{profileDir=PROFILE_DIR}={}){const fields=agentInput(input),all=profiles(profileDir,'agent'),stable=stableAgentKey(fields);if(input.reuseKey){const prior=readAgentProfileByKey(input.reuseKey,profileDir);if(canonical(savedAgentFields(prior))!==canonical(fields))fail('AGENT_PROFILE_CONFLICT');return{changed:false,profile:agentSummary(prior)};}const same=all.filter(p=>p.name===fields.name&&p.role===fields.role);if(stable){const file=agentFile(stable,profileDir);if(fs.existsSync(file)){const prior=readAgentProfile(file);if(canonical(savedAgentFields(prior))!==canonical(fields))fail('AGENT_PROFILE_CONFLICT');return{changed:false,profile:agentSummary(prior)};}}if(same.length&&!stable&&!input.confirmNew)return{changed:false,needsReview:true,candidates:same.map(agentSummary)};const key=stable||'agent-registration:'+crypto.randomUUID(),file=agentFile(key,profileDir);const at=new Date().toISOString(),profile={version:'ksf.agent-self.v1',key,...fields,collectedAt:input.collectedAt??at,registeredAt:at};writeJSON(profileDir,file,profile);return{changed:true,profile:agentSummary(profile)};}
  40. export function checkAgentProfile(key,input,{profileDir=PROFILE_DIR}={}){const prior=readAgentProfileByKey(key,profileDir),fields=agentInput(input);if(canonical(savedAgentFields(prior))!==canonical(fields))fail('AGENT_PROFILE_CONFLICT');return{changed:false,matches:true,profile:agentSummary(prior)};}
  41. export function registerParticipantProfile(input,{participantDir=PARTICIPANT_DIR}={}){const fields=participantInput(input),all=profiles(participantDir,'participant'),stable=stableParticipantKey(fields);if(input.reuseKey){const prior=readParticipantProfile(input.reuseKey,participantDir);if(prior.name!==fields.name||canonical(savedParticipantFields(prior))!==canonical(fields))fail('PARTICIPANT_PROFILE_CONFLICT');return{changed:false,profile:participantSummary(prior)};}if(stable){const key=stable,file=participantFile(key,participantDir);if(fs.existsSync(file)){const prior=readParticipantProfile(key,participantDir);if(canonical(savedParticipantFields(prior))!==canonical(fields))fail('PARTICIPANT_PROFILE_CONFLICT');return{changed:false,profile:participantSummary(prior)};}}
  42. const same=all.filter(p=>p.name===fields.name);if(same.length&&!input.confirmNew)return{changed:false,needsReview:true,candidates:same.map(participantSummary)};const key=stable||'participant-registration:'+crypto.randomUUID(),file=participantFile(key,participantDir);if(fs.existsSync(file))fail('PARTICIPANT_PROFILE_CONFLICT');const profile={version:'ksf.participant.v1',key,...fields,registeredAt:new Date().toISOString()};writeJSON(participantDir,file,profile);return{changed:true,profile:participantSummary(profile)};}
  43. export function checkParticipantProfile(key,input,{participantDir=PARTICIPANT_DIR}={}){const prior=readParticipantProfile(key,participantDir),fields=participantInput(input);if(canonical(savedParticipantFields(prior))!==canonical(fields))fail('PARTICIPANT_PROFILE_CONFLICT');return{changed:false,matches:true,profile:participantSummary(prior)};}
  44. // The caller must supply context from the authenticated adapter, never chat text.
  45. // This module validates its shape and scope; a JSON file is not an authentication proof.
  46. export function registerChannelParticipant(context,{agentProfile,participantDir=PARTICIPANT_DIR}={}){
  47. exact(context,['version','botId','senderId','chatType','chatId','messageId']);
  48. if(context.version!=='ksf.wecom-aibot-context.v1')fail('INVALID_CHANNEL_CONTEXT');
  49. const botId=text(context.botId),senderId=text(context.senderId);text(context.messageId);
  50. if(!['single','group'].includes(context.chatType))fail('INVALID_CHANNEL_CONTEXT');
  51. if(context.chatType==='group'||context.chatId!=null)text(context.chatId);
  52. if(!agentProfile)fail('AGENT_PROFILE_REQUIRED');
  53. if(agentProfile.channel!=='wecom'||agentProfile.account?.platform!=='wecom'||agentProfile.account.accountId!==botId)fail('CHANNEL_AGENT_MISMATCH');
  54. const fields={identityBasis:'channel_context',userId:null,account:normalizeParticipantAccount({platform:'wecom',tenantId:null,accountId:senderId,identifierType:'wecom_aibot_sender',scopeId:botId})};
  55. const key=stableParticipantKey(fields),label='员工待绑定·'+key.split(':')[1].slice(0,12),file=participantFile(key,participantDir);
  56. let result;
  57. if(fs.existsSync(file)){
  58. const prior=readParticipantProfile(key,participantDir);
  59. if(prior.identityBasis!=='channel_context'||prior.userId!==null||canonical(prior.account)!==canonical(fields.account))fail('PARTICIPANT_PROFILE_CONFLICT');
  60. result={changed:false,profile:participantSummary(prior)};
  61. }else result=registerParticipantProfile({name:label,...fields,confirmNew:true},{participantDir});
  62. return{...result,needsNameBinding:result.profile.name===label,displayNameAuthenticated:false,channelContextOriginVerified:false};
  63. }
  64. export function findParticipantProfiles(name,{participantDir=PARTICIPANT_DIR}={}){const q=text(name),rows=profiles(participantDir,'participant').filter(p=>p.name===q);return rows.map(participantSummary);}
  65. export function listIdentityProfiles({profileDir=PROFILE_DIR,participantDir=PARTICIPANT_DIR}={}){return{agents:profiles(profileDir,'agent').map(agentSummary),participants:profiles(participantDir,'participant').map(participantSummary)};}
  66. export function adoptLegacyAgentProfile(file,{profileDir=PROFILE_DIR}={}){const prior=readAgentProfile(file),target=agentFile(prior.key,profileDir);if(fs.existsSync(target)){if(canonical(readAgentProfile(target))!==canonical(prior))fail('AGENT_PROFILE_CONFLICT');return{changed:false,profile:agentSummary(prior),legacyPreserved:true};}writeJSON(profileDir,target,prior);return{changed:true,profile:agentSummary(prior),legacyPreserved:true};}
  67. export function bindOwnAgent(task,profile){if(!profile)fail('AGENT_PROFILE_REQUIRED');if(profile.role!=='workshop')fail('AGENT_ROLE_MISMATCH');const agent={key:profile.key,name:profile.name,identityBasis:profile.identityBasis,account:normalizeAgentAccount(profile.account),role:profile.role,runtimePlatform:profile.runtimePlatform??null,channel:profile.channel??null,collectedAt:profile.collectedAt??profile.registeredAt};if(task.agent!==undefined){exact(task.agent,['key','name','identityBasis','account','role','runtimePlatform','channel','collectedAt']);for(const key of ['key','name','identityBasis','role','runtimePlatform','channel','collectedAt'])if(task.agent[key]!==undefined&&task.agent[key]!==agent[key])fail('AGENT_IDENTITY_MISMATCH');if(task.agent.account!==undefined&&canonical(normalizeAgentAccount(task.agent.account))!==canonical(agent.account))fail('AGENT_IDENTITY_MISMATCH');}return{...task,agent};}
  68. export function bindRegisteredParticipant(input,profile){if(!profile)fail('PARTICIPANT_PROFILE_REQUIRED');if(!object(input)||Object.keys(input).some(k=>!['profileKey','name'].includes(k)))fail('INVALID_IDENTITY_FIELDS');if(input.profileKey!==profile.key||input.name&&input.name!==profile.name)fail('PARTICIPANT_PROFILE_CONFLICT');return{key:profile.key,name:profile.name,identityBasis:profile.identityBasis,...(profile.userId?{userId:profile.userId}:{}),account:normalizeParticipantAccount(profile.account)};}
  69. export function assertOwnSession(row,profile){if(!profile)fail('AGENT_PROFILE_REQUIRED');if(profile.role!=='workshop')fail('AGENT_ROLE_MISMATCH');const a=row?.meta?.attribution;if(a?.agentKey!==profile.key||a?.agentName!==profile.name||a?.agentRuntimePlatform!==(profile.runtimePlatform??null)||a?.agentChannel!==(profile.channel??null))fail('AGENT_SESSION_MISMATCH');}
  70. export function redactIdentity(value){if(Array.isArray(value))return value.map(redactIdentity);if(!object(value))return value;const hidden=new Set(['participantAccount','agentAccount','conversationKey','ownerId','userRef','accountId','tenantId','runtimeAgentId','wecomAppAgentId','employeeNumber','scopeId','botId','senderId']);return Object.fromEntries(Object.entries(value).filter(([k,v])=>!hidden.has(k)&&v!==undefined).map(([k,v])=>[k,k==='producer'&&object(v)?redactIdentity({...v,...(v.agent?{agent:{key:v.agent.key,name:v.agent.name,identityBasis:v.agent.identityBasis}}:{})}):redactIdentity(v)]));}
  71. export function executeIdentity(argv,options={}){
  72. const[command,...args]=argv;let result;
  73. if(command==='status'&&args.length===0)result={...listIdentityProfiles(options)};
  74. else if(command==='register'&&args.length===2&&args[0]==='--input'&&path.isAbsolute(args[1]))result=registerAgentProfile(JSON.parse(fs.readFileSync(args[1],'utf8')),options);
  75. else if(command==='adopt-agent'&&args.length===2&&args[0]==='--input'&&path.isAbsolute(args[1]))result=adoptLegacyAgentProfile(args[1],options);
  76. else if(command==='check'&&args.length===4&&args[0]==='--agent-profile'&&args[2]==='--input'&&path.isAbsolute(args[3]))result=checkAgentProfile(args[1],JSON.parse(fs.readFileSync(args[3],'utf8')),options);
  77. else if(command==='register-employee'&&args.length===2&&args[0]==='--input'&&path.isAbsolute(args[1])){
  78. const input=JSON.parse(fs.readFileSync(args[1],'utf8'));
  79. if(input?.identityBasis==='channel_context'||input?.account?.identifierType==='wecom_aibot_sender')fail('TRUSTED_IDENTITY_TOOL_REQUIRED');
  80. result=registerParticipantProfile(input,options);
  81. }else if(command==='register-channel-employee'){
  82. fail('TRUSTED_IDENTITY_TOOL_REQUIRED');
  83. }else if(command==='check-employee'&&args.length===4&&args[0]==='--participant-profile'&&args[2]==='--input'&&path.isAbsolute(args[3]))result=checkParticipantProfile(args[1],JSON.parse(fs.readFileSync(args[3],'utf8')),options);
  84. else if(command==='lookup-employee'&&args.length===2&&args[0]==='--name')result={candidates:findParticipantProfiles(args[1],options)};
  85. else fail('USAGE');
  86. return{skill:'ksf-workshop-runtime',command,invocationId:crypto.randomUUID(),at:new Date().toISOString(),readOnly:!['register','register-employee','register-channel-employee','adopt-agent'].includes(command),networkRequests:0,databaseWrites:0,ok:true,...result,accountAuthenticated:false};
  87. }
  88. if(process.argv[1]&&path.resolve(process.argv[1])===self){const base={skill:'ksf-workshop-runtime',command:process.argv[2],invocationId:crypto.randomUUID(),at:new Date().toISOString(),networkRequests:0,databaseWrites:0};try{if(process.platform!=='linux')fail('LINUX_REQUIRED');console.log(JSON.stringify(executeIdentity(process.argv.slice(2)),null,2));}catch(e){console.error(JSON.stringify({...base,ok:false,error:{code:/^[A-Z][A-Z0-9_]+$/.test(e.code||'')?e.code:'IDENTITY_FAILED',message:'身份处理停止;未输出账号值、配置原文或凭据。'}},null,2));process.exitCode=1;}}