Просмотр исходного кода

feat: add scoped read-only employee lookup for workshop skill

pengzhikang 1 день назад
Родитель
Сommit
6a0edbb29a

+ 53 - 0
docs/employee-read-patch-20261103.md

@@ -0,0 +1,53 @@
+# 运维001:补齐最小实名查询入口
+
+此前测试单依赖王刚001没有的同步函数入口。这个补丁新增 `employee-read` → `ksfReadWecomEmployee` 受权查询链路,不把运维的identity:write权限或平台管理凭据交给课堂数字人。
+
+此包 `ksf-employee-read-patch-20261103-r1.zip` 用于已经部署r5后端/看板、已经安装Skill员工识别r1的环境。仅新增一个查询Function,并更新现有Skill的两个执行脚本、三份说明和派生清单。r5四个函数、34个静态文件、runtime-lock、身份档案、会话索引及凭据绑定保持现状;不重新发布r5包,也不重跑旧工作坊。
+
+## 1. 包核验及查询函数部署
+
+在站点外独立目录解包,先 `sha256sum -c FILES.sha256`。使用运维既有受控配置,环境变量名为 `KSF_PLATFORM_CONFIG`,指向包外私有0600的platform JSON。沿用已有文件,不生成或转发密钥。
+
+```sh
+node server/identity/manage.mjs deploy-employee-read
+node server/identity/manage.mjs deploy-employee-read --apply --receipt-dir /opt/ksf-identity-private/receipts
+```
+
+默认只读计划;预检验证现网ksfGetPublicBoard仍为r5哈希 `d4c7e2923a13b56b60821f02c8ca4ab472fca3664cde8fcdbf3f621e51047b23`,并确认ksfReadWecomEmployee不存在或已是本包版本。apply只新增ksfReadWecomEmployee,函数ACL沿用现有公开查询Function的记录ACL;服务请求仍须通过服务凭据认证及data:read授权。既有四函数不更新,权限不放宽,匿名调用应拒绝。计划/执行逐步保留stdout、stderr、exitcode及工具返回的私有备份回执;失败即停。
+
+新函数按服务owner、员工工作坊参与键及Agent键核对归属,随后从会话私有快照查询企微;未配置、缺映射或权限不足均返回真实错误码。查询不调用身份同步,不更新档案、审计或业务表;token只在进程内缓存。包不包含企微配置,也不自动configure或sync。
+
+## 2. 安装Skill到王刚001实际运行容器
+
+将同一包解到容器 `/opt/data/` 内独立目录,以现有Skill属主hermes执行:
+
+```sh
+node scripts/install-workshop-skill.mjs --check
+node scripts/install-workshop-skill.mjs
+```
+
+已安装的Skill员工识别r1基线,预计changed恰六个文件:SKILL.md、references/identity.md、references/workshop-tasks.md、scripts/ksf-workshop.mjs、scripts/ksf-secure.mjs、FILES.sha256。未知目标内容停止,不改指纹放行。安装器会备份并保持credential-binding.json和整个ksf-workshop-state目录,包括刚恢复的sessions/btrFqkz0yw.json。核对绑定文件仍为1129字节、sha256 `24ef684e53f2d93421da79aab3ab8382419741f8b33fa530c38a7b88311110ee`;若现场已有经授权的新修改则先说明,不覆盖。
+
+```sh
+cd /opt/data/skills/ksf-workshop-runtime
+sha256sum -c FILES.sha256
+```
+
+安装后按现有加载机制更新Skill。入口仍逐次在内存解密现有服务凭据,无需identity:write;沿用原本workshop-read已使用的data:read权限。若凭据过期或缺data:read,报告具体缺项,不能借用运维masterKey。
+
+## 3. 交王刚001一次查询
+
+把同包 `docs/wanggang-employee-read-test-20261103.md` 发给王刚001,由用户通过企微真实新消息触发。他先核对当前发送者和原会话,再用新增employee-read命令完成一次查询;输入和回执只放 `/opt/data/` 私有目录。
+
+验收分层记录:命令白名单已生效;受权函数调用已发出并收到返回;是否实际取得通讯录姓名。通讯录仍未配置时,可返回WECOM_DIRECTORY_NOT_CONFIGURED,这说明入口已打通,但实名尚未取得。查询成功不会更新看板姓名,正式同步另走已部署的运维流程。不要为测试上传报告、创建会话或更新项目。
+
+## 回退
+
+查询函数按工具返回的新回执路径处理:
+
+```sh
+node server/identity/manage.mjs rollback --receipt /实际私有目录/identity-deploy-本次编号.json
+node server/identity/manage.mjs rollback --receipt /实际私有目录/identity-deploy-本次编号.json --apply
+```
+
+只删除本次新增查询函数,遇并发修改停止;不使用旧r5回执回退整个系统。Skill按本次安装备份恢复changed六个文件及原清单,事先确认当前仍为本包版本,保留身份与会话状态。无需重跑任何业务。

+ 52 - 0
docs/employee-read-validation-20261103.json

@@ -0,0 +1,52 @@
+{
+  "release": "ksf.employee-read-patch.20261103.r1",
+  "validatedAt": "2026-11-03T13:15:22.679Z",
+  "workspaceTests": {
+    "passed": 108,
+    "failed": 0
+  },
+  "staticChecks": {
+    "passed": 13,
+    "failed": 0
+  },
+  "skillValidation": {
+    "valid": true,
+    "encoding": "UTF-8"
+  },
+  "generatedCode": {
+    "ok": true,
+    "generatedProductionCodeAuthenticatedWithDataRead": true,
+    "generatedProductionCodeDispatchVerified": true,
+    "directoryFixtureReads": 2,
+    "productionNetworkRequests": 0,
+    "businessWrites": 0
+  },
+  "installedSkillUpgrade": {
+    "ok": true,
+    "baseline": "installed Skill employee r1",
+    "readOnlyCheck": true,
+    "changed": [
+      "scripts/ksf-workshop.mjs",
+      "SKILL.md",
+      "references/identity.md",
+      "scripts/ksf-secure.mjs",
+      "references/workshop-tasks.md",
+      "FILES.sha256"
+    ],
+    "filesVerified": 11,
+    "backupVerified": true,
+    "repeatInstallChanges": 0,
+    "credentialBindingPreserved": true,
+    "recoveredSessionIndexPreserved": true,
+    "productionNetworkRequests": 0,
+    "productionBusinessWrites": 0
+  },
+  "functionsAdded": 1,
+  "existingFunctionsChanged": 0,
+  "staticFilesChanged": 0,
+  "identityWriteGranted": false,
+  "productionBusinessWrites": 0,
+  "deployed": false,
+  "realWecomDirectoryVerified": false,
+  "browserVerified": false
+}

+ 27 - 0
docs/wanggang-employee-read-test-20261103.md

@@ -0,0 +1,27 @@
+# 王刚001:一次最小实名查询
+
+运维部署最小查询补丁并安装Skill后,请从本条真实企微消息执行,直接回答能否取得我的通讯录姓名。
+
+1. 调用无参数 `ksf_register_current_employee`;核对当前员工profile.key及agentProfileKey与 `/opt/data/ksf-workshop-state/sessions/btrFqkz0yw.json` 的employeeProfileKey、agentProfileKey一致。不按显示名匹配,不改档案。
+2. 在 `/opt/data/` 下新建本次私有目录(0700),将以下JSON以0600独占新建为input.json。profileKey须与本条消息工具返回值逐字一致;若不一致停下报告,不测试他人的会话。
+
+```json
+{
+  "sessionId": "btrFqkz0yw",
+  "participant": {
+    "profileKey": "participant-account:f83edd27dda7e0989a0d313c158314043fd129efb86b8f6a07db8140033c5708"
+  }
+}
+```
+
+3. 将下列input路径替换为实际新建的绝对路径,执行一次:
+
+```sh
+node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-secure.mjs employee-read --agent-profile agent-account:7c9f85363ca4c5a529a5b363308b065998924e2394e846145a5c62c904506d2e --input /opt/data/本次私有目录/input.json --request-id KSF:employee-realname:read:20261103:v1
+```
+
+此命令是本次新增的正式入口,使用已有data:read权限,先读会话核对归属,再调用ksfReadWecomEmployee查询通讯录。不要调用运维用的ksfSyncWecomEmployee,不新建会话、不上传报告或修改看板。
+
+成功读取 `entryResult.result.employee`,确认source=wecom_directory、nameVerified=true、姓名非空,回答“已取得你的企微通讯录姓名:XXX”,附verifiedAt。成功还应readOnly=true、databaseWrites=0、employeeIdentity.persisted=false;本次姓名未同步到看板。
+
+失败只报告失败环节与实际错误码:例如WECOM_DIRECTORY_NOT_CONFIGURED、WECOM_SENDER_MAPPING_REQUIRED、FORBIDDEN或凭据过期。远端错误中remoteFunction和remoteResponseReceived可证明函数是否已收到请求并返回;本地校验失败则未发起对应查询。不要从公开标签、旧回执或聊天自报姓名猜测实名,不自动重试或换键。留存stdout/stderr/exitcode;目录和回执只放 `/opt/data/`,不输出账号原值、完整上下文或密钥。

+ 3 - 2
scripts/install-workshop-skill.mjs

@@ -57,6 +57,7 @@ const expectedUploadBaseline={
  'scripts/ksf.mjs':'7ef128f7d022bfbc15c0ec813bfd87995c6297c6efe407a563c840f014aba477',
  'runtime-lock.json':'2605cbe8fd7be43b5969a4991f978dc0768dee5b4d65d18f7af15f75e9004a79'
 };
+const expectedEmployeeR1={"references/workshop-tasks.md":"032a2502cd7a74a33f800638fd2a3c3568233b7118f5a9adbfea0446ae33f30d","references/write-tasks.md":"197a02db39a78bd14803c27b455a51681b6ec32f8d50e69d6e32486a2e9fe2cc","scripts/ksf-secure.mjs":"1fe19bf41e3b57aa78061a15d04d346b3a4c189452d15e98434c35a727bb81c1","runtime-lock.json":"2605cbe8fd7be43b5969a4991f978dc0768dee5b4d65d18f7af15f75e9004a79","references/identity.md":"619568567035417f018f25b73ceb32f704274a3c5668d3b591215b41acce5ae3","scripts/ksf-identity-bridge.mjs":"6ba3f0cb6527bc4a0a6218a898472ac65aa3dd9f3f57b597d77efcee641a7540","scripts/ksf.mjs":"7ef128f7d022bfbc15c0ec813bfd87995c6297c6efe407a563c840f014aba477","scripts/ksf-identity.mjs":"9224ef89a96425890d54961fda1f4fcae3403aa7c753fe1d70ac62082f2ff77e","scripts/ksf-workshop.mjs":"95d05ac21310b7ccdd204cd5d01b4dcb7fa6944694acda089f1ff32eacda4fc7","SKILL.md":"b3161c29cf0c7b6c8a6ce1f0308d99c60acab801ca7affc52d39c4ea1c61e27e"};
 function plain(p){const s=fs.lstatSync(p);if(!s.isFile()||s.isSymbolicLink())fail('NOT_PLAIN_FILE');}
 function stateSnapshot(root){if(!fs.existsSync(root))return{};const result={};function walk(dir){if(fs.lstatSync(dir).isSymbolicLink())fail('SYMLINK_STATE_DIRECTORY');for(const e of fs.readdirSync(dir,{withFileTypes:true})){const file=path.join(dir,e.name);if(e.isSymbolicLink())fail('SYMLINK_STATE_DIRECTORY');if(e.isDirectory())walk(file);else{plain(file);result[path.relative(root,file)]=hash(fs.readFileSync(file));}}}walk(root);return result;}
 export function installWorkshopSkill(source,destination,{apply=true}={}){
@@ -74,14 +75,14 @@ export function installWorkshopSkill(source,destination,{apply=true}={}){
  if(JSON.stringify(entries.map(x=>x.name).sort())!==JSON.stringify(expectedNames))fail('UNEXPECTED_SKILL_FILES');
  const changes=[];
  for(const e of entries){
-  if(fs.existsSync(e.target)){plain(e.target);const before=fs.readFileSync(e.target),current=hash(before);if(current===e.hash)continue;if(![expectedBefore[e.name],expectedWorkshop[e.name],expectedTwoAgents[e.name],expectedRegistry[e.name],expectedAibot[e.name],expectedCurrentIntegration[e.name],expectedUploadBaseline[e.name]].includes(current))fail('TARGET_HAS_UNEXPECTED_CONTENT');changes.push({...e,before});}
+  if(fs.existsSync(e.target)){plain(e.target);const before=fs.readFileSync(e.target),current=hash(before);if(current===e.hash)continue;if(![expectedBefore[e.name],expectedWorkshop[e.name],expectedTwoAgents[e.name],expectedRegistry[e.name],expectedAibot[e.name],expectedCurrentIntegration[e.name],expectedUploadBaseline[e.name],expectedEmployeeR1[e.name]].includes(current))fail('TARGET_HAS_UNEXPECTED_CONTENT');changes.push({...e,before});}
   else{if(expectedBefore[e.name])fail('BASELINE_FILE_MISSING');changes.push({...e,before:null});}
  }
  const manifest={name:'FILES.sha256',origin:path.join(source,'FILES.sha256'),target:path.join(destination,'FILES.sha256')};
  plain(manifest.origin);manifest.hash=hash(fs.readFileSync(manifest.origin));
  if(fs.existsSync(manifest.target)){
   plain(manifest.target);const before=fs.readFileSync(manifest.target),current=hash(before);
-  if(current!==manifest.hash){if(current!=='6d0ab7383ef7556c820bcb322fd8e853273273b948f79e64113dc6050e6b2055')fail('TARGET_MANIFEST_HAS_UNEXPECTED_CONTENT');changes.push({...manifest,before});}
+  if(current!==manifest.hash){if(!['6d0ab7383ef7556c820bcb322fd8e853273273b948f79e64113dc6050e6b2055','edcafe16e164eda1e6b3b8764b2ff26eee51b697516824138561894c61d79c3f'].includes(current))fail('TARGET_MANIFEST_HAS_UNEXPECTED_CONTENT');changes.push({...manifest,before});}
  }else changes.push({...manifest,before:null});
  entries.push(manifest);
  if(!apply)return{ok:true,readOnly:true,changed:changes.map(x=>x.name),destination,filesChecked:entries.length,credentialBindingPreserved:true,stateDirectoryPreserved:true};

+ 31 - 0
scripts/package-employee-read-patch.mjs

@@ -0,0 +1,31 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import crypto from 'node:crypto';
+import { fileURLToPath } from 'node:url';
+import { buildEmployeeRead } from '../server/identity/build-employee-read.mjs';
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const sha = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
+const args = process.argv.slice(2);
+if (args.length !== 4 || args[0] !== '--base' || args[2] !== '--out' || !path.isAbsolute(args[3]) || fs.existsSync(args[3])) throw Error('Use --base PRIVATE_R5_SNAPSHOT --out NEW_ABSOLUTE_DIRECTORY');
+const base = JSON.parse(fs.readFileSync(args[1], 'utf8'));
+if (base.objectId !== 'inPfC51rvn' || sha(base.code) !== 'd4c7e2923a13b56b60821f02c8ca4ab472fca3664cde8fcdbf3f621e51047b23') throw Error('EXPECTED_DEPLOYED_R5_BASE');
+const built = buildEmployeeRead(base), skill = 'skills/ksf-workshop-runtime';
+const files = fs.readFileSync(path.join(root, skill, 'FILES.sha256'), 'utf8').trim().split(/\r?\n/).map(line => {
+  const m = line.match(/^([a-f0-9]{64})  ([\w./-]+)$/);
+  if (!m || m[2].split('/').includes('..') || sha(fs.readFileSync(path.join(root, skill, m[2]))) !== m[1]) throw Error('SKILL_MANIFEST_MISMATCH');
+  return skill + '/' + m[2];
+});
+if (new Set(files).size !== 10) throw Error('UNEXPECTED_SKILL_FILES');
+files.push(skill + '/FILES.sha256', 'scripts/install-workshop-skill.mjs', 'scripts/build-workshop-skill.mjs', 'scripts/package-employee-read-patch.mjs',
+  'server/identity/employee-read.mjs', 'server/identity/wecom-directory.mjs', 'server/identity/build-employee-read.mjs', 'server/identity/manage.mjs',
+  'server/identity/platform.example.json', 'docs/employee-read-patch-20261103.md', 'docs/wanggang-employee-read-test-20261103.md', 'docs/employee-read-validation-20261103.json');
+for (const file of files) { const stat = fs.lstatSync(path.join(root, file)); if (!stat.isFile() || stat.isSymbolicLink()) throw Error('NOT_PLAIN_PACKAGE_FILE'); }
+const out = args[3]; fs.mkdirSync(out, { recursive: true });
+for (const file of files) { const target = path.join(out, file); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.copyFileSync(path.join(root, file), target, fs.constants.COPYFILE_EXCL); }
+const dist = path.join(out, 'server/identity/read-dist'); fs.mkdirSync(dist, { recursive: true });
+for (const entry of built.functions) { const relative = 'server/identity/read-dist/' + entry.name + '.js'; fs.writeFileSync(path.join(out, relative), entry.code, { flag: 'wx' }); files.push(relative); }
+const manifest = 'server/identity/read-dist/manifest.json';
+fs.writeFileSync(path.join(out, manifest), JSON.stringify({ ...built, functions: built.functions.map(({ code, ...meta }) => meta) }, null, 2), { flag: 'wx' }); files.push(manifest);
+fs.writeFileSync(path.join(out, 'FILES.sha256'), files.sort().map(file => sha(fs.readFileSync(path.join(out, file))) + '  ' + file).join('\n') + '\n', { flag: 'wx' });
+console.log(JSON.stringify({ ok: true, release: 'ksf.employee-read-patch.20261103.r1', files: files.length + 1, functionsAdded: 1, existingFunctionsChanged: 0, staticFilesChanged: 0, out, deployed: false }));

+ 28 - 0
server/identity/build-employee-read.mjs

@@ -0,0 +1,28 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import crypto from 'node:crypto';
+import { fileURLToPath } from 'node:url';
+
+const root = path.dirname(fileURLToPath(import.meta.url));
+const sha = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
+export function buildEmployeeRead(base) {
+  const hook = 'attachWecomEmployeeHandlers(app,{store,settings});';
+  const call = 'runDatabaseFunction("ksfGetPublicBoard",request,response,';
+  if (base.name !== 'ksfGetPublicBoard' || !base.objectId || typeof base.code !== 'string' ||
+      base.code.split(hook).length !== 2 || base.code.split(call).length !== 2 || base.code.includes('attachWecomEmployeeReadHandler')) throw Error('UNEXPECTED_R5_BASE');
+  const source = fs.readFileSync(path.join(root, 'employee-read.mjs'), 'utf8').replace(/^import .*;\r?\n/gm, '').replace(/^export /gm, '');
+  const code = source + '\n' + base.code.replace(hook, hook + '\n    attachWecomEmployeeReadHandler(app,{store,settings});').replace(call, 'runDatabaseFunction("ksfReadWecomEmployee",request,response,');
+  new Function(code);
+  return { version: 'ksf.wecom-employee-read.20261103.v1',
+    sourceBase: { name: base.name, objectId: base.objectId, sha256: sha(base.code) },
+    functions: [{ name: 'ksfReadWecomEmployee', path: '/ksfReadWecomEmployee', priorSha256: null, sha256: sha(code), code }] };
+}
+if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+  const args = process.argv.slice(2);
+  if (args.length !== 4 || args[0] !== '--base' || args[2] !== '--out' || !path.isAbsolute(args[3]) || fs.existsSync(args[3])) throw Error('Use --base PRIVATE_R5_SNAPSHOT --out NEW_ABSOLUTE_DIRECTORY');
+  const release = buildEmployeeRead(JSON.parse(fs.readFileSync(args[1], 'utf8')));
+  fs.mkdirSync(args[3], { recursive: true });
+  for (const entry of release.functions) fs.writeFileSync(path.join(args[3], entry.name + '.js'), entry.code);
+  fs.writeFileSync(path.join(args[3], 'manifest.json'), JSON.stringify({ ...release, functions: release.functions.map(({ code, ...meta }) => meta) }, null, 2));
+  console.log(JSON.stringify({ ok: true, functions: 1, productionWrites: 0, out: args[3] }));
+}

+ 27 - 0
server/identity/employee-read.mjs

@@ -0,0 +1,27 @@
+import { createWecomDirectory } from './wecom-directory.mjs';
+
+/** One scoped directory read; never stores a profile or starts a business operation. */
+export function attachWecomEmployeeReadHandler(app, { store, settings, fetchImpl = fetch, now = () => Date.now() }) {
+  const { need, fail, hash, canonical } = app;
+  const directory = createWecomDirectory({ config: settings.wecomDirectory, hash, canonical, fetchImpl, now });
+  app.handlers.ksfReadWecomEmployee = async (p, actor) => {
+    need(actor, 'data:read');
+    if (!p || typeof p !== 'object' || Array.isArray(p) ||
+        Object.keys(p).some(k => !['sessionId', 'participantProfileKey', 'agentProfileKey', 'requestId'].includes(k)) ||
+        typeof p.sessionId !== 'string' || !/^[\w-]{1,40}$/.test(p.sessionId) ||
+        !/^participant-account:[a-f0-9]{64}$/.test(p.participantProfileKey || '') ||
+        !/^agent-account:[a-f0-9]{64}$/.test(p.agentProfileKey || '')) fail('VALIDATION_FAILED', '需要当前员工、数字人及已有会话的真实编号');
+    const session = await store.get('KfSession', p.sessionId);
+    if (!session) fail('REFERENCE_NOT_FOUND', '会话不存在');
+    if (!actor.scopes.includes('*') && session.meta?.ownerId !== actor.id) fail('FORBIDDEN', '会话不属于当前服务身份');
+    const a = session.meta?.attribution;
+    const expected = 'participant:' + hash(canonical([session.workshopKey, p.participantProfileKey]));
+    if (!session.workshopKey || a?.participantKey !== expected || a?.agentKey !== p.agentProfileKey) fail('IDENTITY_CONFLICT', '当前员工或数字人与会话不一致');
+    const account = a.participantAccount;
+    if (a.identityBasis !== 'channel_context' || account?.identifierType !== 'wecom_aibot_sender' ||
+        a.agentAccount?.platform !== 'wecom' || a.agentAccount.accountId !== account.scopeId) fail('IDENTITY_CONFLICT', '会话没有有效的企微发送者归属');
+    const employee = await directory.resolve(account);
+    return { readOnly: true, databaseWrites: 0, sessionId: session.objectId, employee,
+      employeeIdentity: { automatic: true, status: 'verified', persisted: false } };
+  };
+}

+ 19 - 7
server/identity/manage.mjs

@@ -14,9 +14,9 @@ function privateJson(file) {
   if (!stat.isFile() || stat.size > 1024 * 1024 || process.platform !== 'win32' && (stat.mode & 0o077)) fail('UNSAFE_PRIVATE_CONFIG');
   return JSON.parse(fs.readFileSync(file, 'utf8'));
 }
-export async function manage({ args, request, invoke, releaseDir = path.join(root, 'dist') }) {
+export async function manage({ args, request, invoke, releaseDir = path.join(root, args[0] === 'deploy-employee-read' ? 'read-dist' : 'dist') }) {
   const command = args[0], apply = args.includes('--apply');
-  const flags = { deploy: ['--apply', '--receipt-dir'], configure: ['--apply', '--file', '--receipt-dir'], sync: ['--apply', '--sessions', '--request-id'], rollback: ['--apply', '--receipt'] }[command];
+  const flags = { deploy: ['--apply', '--receipt-dir'], 'deploy-employee-read': ['--apply', '--receipt-dir'], configure: ['--apply', '--file', '--receipt-dir'], sync: ['--apply', '--sessions', '--request-id'], rollback: ['--apply', '--receipt'] }[command];
   if (!flags) fail('USAGE');
   const seen = new Set();
   for (let i = 1; i < args.length; i++) { const flag = args[i]; if (!flags.includes(flag) || seen.has(flag)) fail('USAGE'); seen.add(flag); if (flag !== '--apply') { i++; if (!args[i] || args[i].startsWith('--')) fail('USAGE'); } }
@@ -64,11 +64,12 @@ export async function manage({ args, request, invoke, releaseDir = path.join(roo
   if (command === 'rollback') {
     const receiptFile = value('--receipt'); if (!receiptFile) fail('USAGE');
     const receipt = privateJson(receiptFile);
-    if (receipt.version !== 'ksf.wecom-identity.20261103.v1' || !Array.isArray(receipt.entries) || ![2, 4].includes(receipt.entries.length)) fail('INVALID_RECEIPT');
+    const readRelease = receipt.version === 'ksf.wecom-employee-read.20261103.v1';
+    if (!Array.isArray(receipt.entries) || (readRelease ? receipt.entries.length !== 1 || receipt.entries[0].name !== 'ksfReadWecomEmployee' : receipt.version !== 'ksf.wecom-identity.20261103.v1' || ![2, 4].includes(receipt.entries.length))) fail('INVALID_RECEIPT');
     const restore = [];
     if (new Set(receipt.entries.map((e) => e.name)).size !== receipt.entries.length) fail('INVALID_RECEIPT');
     for (const entry of receipt.entries) {
-      if (!['ksfGetPublicBoard', 'ksfSyncWecomEmployee', 'ksfStartWorkshopSession', 'ksfSaveWorkshopTask'].includes(entry.name)) fail('INVALID_RECEIPT');
+      if (!(readRelease ? ['ksfReadWecomEmployee'] : ['ksfGetPublicBoard', 'ksfSyncWecomEmployee', 'ksfStartWorkshopSession', 'ksfSaveWorkshopTask']).includes(entry.name)) fail('INVALID_RECEIPT');
       if (!entry.id) { if ((await scan(entry.name)).length) fail('UNKNOWN_CREATED_FUNCTION'); continue; }
       if (!/^[\w-]+$/.test(entry.id)) fail('INVALID_RECEIPT');
       const row = await request('GET', '/classes/Function/' + entry.id);
@@ -85,9 +86,16 @@ export async function manage({ args, request, invoke, releaseDir = path.join(roo
     }
     return { ok: true, operation: 'rollback', applied: apply };
   }
-  if (command !== 'deploy') fail('USAGE');
+  if (!['deploy', 'deploy-employee-read'].includes(command)) fail('USAGE');
   const manifest = JSON.parse(fs.readFileSync(path.join(releaseDir, 'manifest.json'), 'utf8'));
-  if (manifest.version !== 'ksf.wecom-identity.20261103.v1') fail('BAD_RELEASE');
+  const readRelease = command === 'deploy-employee-read';
+  if (manifest.version !== (readRelease ? 'ksf.wecom-employee-read.20261103.v1' : 'ksf.wecom-identity.20261103.v1')) fail('BAD_RELEASE');
+  let sourceBase;
+  if (readRelease) {
+    if (manifest.functions?.length !== 1 || manifest.functions[0].name !== 'ksfReadWecomEmployee' || manifest.functions[0].priorSha256 !== null || manifest.sourceBase?.name !== 'ksfGetPublicBoard') fail('BAD_RELEASE');
+    const sources = await scan(manifest.sourceBase.name); [sourceBase] = sources;
+    if (sources.length !== 1 || sourceBase.objectId !== manifest.sourceBase.objectId || sha(sourceBase.code) !== manifest.sourceBase.sha256) fail('FUNCTION_BASELINE_MISMATCH');
+  }
   const planned = [];
   for (const entry of manifest.functions) {
     const code = fs.readFileSync(path.join(releaseDir, entry.name + '.js'), 'utf8');
@@ -102,6 +110,10 @@ export async function manage({ args, request, invoke, releaseDir = path.join(roo
   if (planned.every((p) => p.unchanged)) return { ok: true, unchanged: true };
   // Refuse a mixed partial install; preserve its receipt for an explicit recovery.
   if (planned.some((p) => p.unchanged)) fail('PARTIAL_INSTALL_REQUIRES_REVIEW');
+  if (readRelease) {
+    const current = await request('GET', '/classes/Function/' + sourceBase.objectId);
+    if (current.name !== sourceBase.name || sha(current.code) !== manifest.sourceBase.sha256 || JSON.stringify(current.ACL) !== JSON.stringify(sourceBase.ACL)) fail('CONCURRENT_FUNCTION_CHANGE');
+  }
   const receiptDir = value('--receipt-dir'); if (!receiptDir || !path.isAbsolute(receiptDir)) fail('RECEIPT_DIRECTORY_REQUIRED');
   fs.mkdirSync(receiptDir, { recursive: true, mode: 0o700 });
   const receiptFile = path.join(receiptDir, 'identity-deploy-' + crypto.randomUUID() + '.json');
@@ -113,7 +125,7 @@ export async function manage({ args, request, invoke, releaseDir = path.join(roo
     if (entry.prior ? current.length !== 1 || sha(current[0].code) !== entry.priorSha256 : current.length !== 0) fail('CONCURRENT_FUNCTION_CHANGE');
     let id = entry.prior?.objectId;
     if (id) await request('PUT', '/classes/Function/' + id, { code: entry.code });
-    else id = (await request('POST', '/classes/Function', { name: entry.name, path: entry.path, code: entry.code, ACL: planned[0].prior.ACL })).objectId;
+    else id = (await request('POST', '/classes/Function', { name: entry.name, path: entry.path, code: entry.code, ACL: (sourceBase || planned[0].prior).ACL })).objectId;
     receipt.entries.find((r) => r.name === entry.name).id = id; save();
     const read = await request('GET', '/classes/Function/' + id); if (sha(read.code) !== entry.sha256) fail('FUNCTION_READBACK_MISMATCH');
   }

+ 5 - 5
skills/ksf-workshop-runtime/FILES.sha256

@@ -1,10 +1,10 @@
-032a2502cd7a74a33f800638fd2a3c3568233b7118f5a9adbfea0446ae33f30d  references/workshop-tasks.md
 197a02db39a78bd14803c27b455a51681b6ec32f8d50e69d6e32486a2e9fe2cc  references/write-tasks.md
-1fe19bf41e3b57aa78061a15d04d346b3a4c189452d15e98434c35a727bb81c1  scripts/ksf-secure.mjs
+1e3176196a5582a50678db5eb55c20dad8b5f34e41ebeac9a6861d263ec950d8  scripts/ksf-workshop.mjs
 2605cbe8fd7be43b5969a4991f978dc0768dee5b4d65d18f7af15f75e9004a79  runtime-lock.json
-619568567035417f018f25b73ceb32f704274a3c5668d3b591215b41acce5ae3  references/identity.md
+27a95c50a01d191368cebb6adf94f824eb81f2278ad79441d093f064d35ec61d  SKILL.md
+2fb2df62c72c948a19516b74213c050ab296aecc14018125958aa5a006395e22  references/identity.md
+4bc9869d0e35f550315869754fa11d64f8316115960df0411bd1eaebaba658bf  scripts/ksf-secure.mjs
 6ba3f0cb6527bc4a0a6218a898472ac65aa3dd9f3f57b597d77efcee641a7540  scripts/ksf-identity-bridge.mjs
 7ef128f7d022bfbc15c0ec813bfd87995c6297c6efe407a563c840f014aba477  scripts/ksf.mjs
+822b63029c65078cf42d21304aa704ad00fbcb56ca52754e66ce3c04013fc7be  references/workshop-tasks.md
 9224ef89a96425890d54961fda1f4fcae3403aa7c753fe1d70ac62082f2ff77e  scripts/ksf-identity.mjs
-95d05ac21310b7ccdd204cd5d01b4dcb7fa6944694acda089f1ff32eacda4fc7  scripts/ksf-workshop.mjs
-b3161c29cf0c7b6c8a6ce1f0308d99c60acab801ca7affc52d39c4ea1c61e27e  SKILL.md

+ 3 - 0
skills/ksf-workshop-runtime/SKILL.md

@@ -18,6 +18,7 @@ node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-secure.mjs <命令> --age
 | 任务 | 命令 |
 |---|---|
 | 绑定实际员工、当前 Agent、小组和对话 | `workshop-start` |
+| 查询当前员工的企微通讯录姓名,不保存成果或同步档案 | `employee-read` |
 | 保存阶段报告、原素材关联、待验证项;可同时生成机会卡 | `workshop-save` |
 | 选择已有机会卡建立课堂项目 | `project-create` |
 | 记录阶段推进、卡住或课堂完成 | `project-progress` |
@@ -46,6 +47,8 @@ node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-secure.mjs <命令> --age
 3. 核对成功回执中的 `employee` 与 `employeeIdentity`;加密入口的路径为 `entryResult.result`。只有 `status=verified` 且 `employee.nameVerified=true`、`source=wecom_directory`、姓名非空时,才能说明已取得企微通讯录实名。`pending` 或旧服务无这两个字段时,分别说明“发送者已登记,实名待同步”或“服务尚未返回实名核验结果”,不说已经实名接入。
 4. 报告保存成功后按原编号回读,保留业务键与身份核对结果。实名待同步不等于保存失败,不重存、不换键、不重建员工或会话来消除标签;通讯录查询报错则保留原键按实际回执核查。看板显示验收另按返回的成果链接完成,不把 API 成功说成浏览器验收通过。
 
+用户只问“能否取得我的实名”时,在当前消息登记成功并核对已有会话后,直接执行 `employee-read` 做最小查询;输入和示例见 [references/workshop-tasks.md](references/workshop-tasks.md#员工实名最小查询employee-read)。它使用现有 `data:read` 权限,不需要 `identity:write` 或运维凭据;查询不更新看板,不新建会话或报告。无受控会话时先报告缺项,不为测试创建业务记录。
+
 具体回执、缺权限处理及本地待绑定标签与看板姓名的区别见 [references/identity.md](references/identity.md#上传时查询企业微信通讯录实名)。
 
 身份来源与业务 dataMode 独立:人工登记身份不要求 manual。构造待查清单的 session 从 start 就填 sample;workshop-save 继承 session 的 dataMode,不接收该字段。账号标识来自登记/配置仍是未认证资料,不代表平台认证成功。

+ 4 - 0
skills/ksf-workshop-runtime/references/identity.md

@@ -98,6 +98,10 @@ Skill 负责顺序与结果核对:当前消息可信登记 → 核对员工/Ag
 
 交付代码、安装 Skill、配置权限、取得本次通讯录回执及生产看板显示是不同验收项。尚未部署或缺配置时如实说明,不能用夹具姓名、历史回执或本地测试替代真实员工验收。后端依赖及维护人配置见仓库 `docs/wecom-identity-integration-20261103.md`,不将私有运行配置复制进 Skill 包。
 
+只读取当前员工姓名用 `employee-read`:登记本条可信发送者后传入已有sessionId和participant.profileKey,Agent键沿用 `--agent-profile`。入口及服务端均核对员工与Agent归属,然后由 `ksfReadWecomEmployee` 以 `data:read` 权限查询通讯录。结果位于 `entryResult.result.employee`,`readOnly=true`、`databaseWrites=0`、`employeeIdentity.persisted=false`;成功仅说明本次姓名读取成功,不表示看板已同步。此路径不调用运维用的 `ksfSyncWecomEmployee`,也不需要其identity:write权限。
+
+本地员工档案键与工作坊参与键原本就是不同层级。服务端 `participantKey = "participant:" + SHA256(canonical([workshopKey, employeeProfileKey]))`,摘要为UTF-8输入的十六进制SHA256;不能将工作坊键当作旧算法并改写员工档案。缺本地会话索引时,先只读核对服务端记录、上述派生关系和Agent,再恢复私有索引;不据本地目录缺失推断服务端无会话。
+
 ### 无可信发送者时人工登记
 
 从当前消息可信发送者信息取得姓名和账号。无法取得时,只问缺少的显示名/身份依据;已确认的信息直接复用,不让员工反复填写四项。

+ 16 - 0
skills/ksf-workshop-runtime/references/workshop-tasks.md

@@ -4,6 +4,22 @@
 
 业务写命令必须同时传 `--agent-profile <当前Agent完整档案键>`,读取已登记的自身身份。新员工、新数字人先按 identity.md 登记,任何名称都适用;仅 role=workshop 可课堂写入。只读命令可不传档案键。
 
+## 员工实名最小查询:employee-read
+
+本命令虽只读,仍必须传当前workshop角色的 `--agent-profile`,并先调用 `ksf_register_current_employee` 取得本条真实消息的员工profile.key。输入仅为已有会话和员工档案引用,不含姓名、账号或密钥:
+
+```json
+{"sessionId":"已核对归属的会话编号","participant":{"profileKey":"本条消息登记返回的员工完整档案键"}}
+```
+
+```sh
+node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-secure.mjs employee-read --agent-profile <本条消息登记返回的Agent完整键> --input /opt/data/实际私有目录/employee-read.json --request-id <本次查询标记>
+```
+
+入口先读取KfSession并核对Agent及工作坊参与键派生关系,再通过现有加密凭据调用 `ksfReadWecomEmployee`。后端只需 `data:read`,验证服务owner、员工与Agent后从会话私有快照查询企微;不写服务端身份档案、审计或业务记录,不需要identity:write,不改变已有身份/同步函数。未知姓名不能从公开看板或模型猜测补齐。
+
+成功查看 `entryResult.result` 的 `employee.name`、`source=wecom_directory`、`nameVerified=true`、`verifiedAt`,以及 `readOnly=true`、`databaseWrites=0`、`employeeIdentity.persisted=false`。错误按真实回执报告:`WECOM_DIRECTORY_NOT_CONFIGURED`为服务端通讯录配置缺失,`WECOM_SENDER_MAPPING_REQUIRED`为映射缺失,`FORBIDDEN`为读取权限/归属受限。命令在本地失败和远端返回错误要区分;远端错误附 `remoteFunction`、`remoteResponseReceived`。不重试、不换键、不使用运维凭据;临时输入及回执位于 `/opt/data/` 下私有目录。查询不更新看板,展示同步仍由运维或正常上传流程完成。
+
 ## 1. workshop-start
 
 部署前可用 workshop-contract 输入 `{}` 只读读取合同;身份扩展标识应为 `workshop.identitySchema: ksf.agent-self.v1`。新版 start 会检查,不支持时在写入之前停止。本地身份登记不依赖该能力。

+ 2 - 2
skills/ksf-workshop-runtime/scripts/ksf-secure.mjs

@@ -9,9 +9,9 @@ const self = fileURLToPath(import.meta.url);
 const sha256 = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
 const ENTRY_HASH = '7ef128f7d022bfbc15c0ec813bfd87995c6297c6efe407a563c840f014aba477';
 const LOCK_HASH = '2605cbe8fd7be43b5969a4991f978dc0768dee5b4d65d18f7af15f75e9004a79';
-const WORKSHOP_ENTRY_HASH = '95d05ac21310b7ccdd204cd5d01b4dcb7fa6944694acda089f1ff32eacda4fc7';
+const WORKSHOP_ENTRY_HASH = '1e3176196a5582a50678db5eb55c20dad8b5f34e41ebeac9a6861d263ec950d8';
 const IDENTITY_MODULE_HASH = '9224ef89a96425890d54961fda1f4fcae3403aa7c753fe1d70ac62082f2ff77e';
-const WORKSHOP_COMMANDS = ['workshop-start','workshop-save','project-create','project-progress','concept-save','project-status','workshop-import','workshop-dedup','workshop-search','workshop-read','workshop-contract'];
+const WORKSHOP_COMMANDS = ['workshop-start','workshop-save','project-create','project-progress','concept-save','project-status','workshop-import','workshop-dedup','workshop-search','workshop-read','workshop-contract','employee-read'];
 const fail = code => { throw Object.assign(new Error(code), { code }); };
 const requireThat = (value, code) => { if (!value) fail(code); };
 

+ 20 - 9
skills/ksf-workshop-runtime/scripts/ksf-workshop.mjs

@@ -6,7 +6,7 @@ import {readAgentProfileByKey,readParticipantProfile,bindOwnAgent,bindRegistered
 const self=fileURLToPath(import.meta.url);
 const commands={
  'workshop-start':'ksfStartWorkshopSession','workshop-save':'ksfSaveWorkshopTask',
- 'project-create':'ksfCreateProjectFromOpportunity','project-progress':'ksfTransitionProject',
+ 'project-create':'ksfCreateProjectFromOpportunity','project-progress':'ksfTransitionProject','employee-read':'ksfReadWecomEmployee',
  'concept-save':'ksfSaveWorkshopConcept','project-status':'ksfGetWorkshopProject','workshop-search':'ksfSearchRecords',
  'workshop-import':null,'workshop-dedup':'ksfCheckDuplicates','workshop-read':'ksfGetRecord','workshop-contract':'ksfGetIngestContract'
 };
@@ -17,7 +17,18 @@ export async function runWorkshop(command,task,{requestId,call,agentProfile,part
  if(!Object.hasOwn(commands,command)||!task||typeof task!=='object'||Array.isArray(task))fail('INVALID_INPUT');
  if(typeof requestId!=='string'||!requestId||task.requestId&&task.requestId!==requestId)fail('BAD_REQUEST_ID');
  if(!['project-status','workshop-search','workshop-read','workshop-contract','workshop-dedup'].includes(command)&&!agentProfile)fail('AGENT_PROFILE_REQUIRED');
- if(command==='workshop-start'&&!participantProfile)fail('PARTICIPANT_PROFILE_REQUIRED');
+ if(['workshop-start','employee-read'].includes(command)&&!participantProfile)fail('PARTICIPANT_PROFILE_REQUIRED');
+ if(command==='employee-read'){
+  if(Object.keys(task).some(k=>!['sessionId','participant','requestId'].includes(k))||typeof task.sessionId!=='string'||!task.sessionId)fail('INVALID_INPUT');
+  if(agentProfile.role!=='workshop')fail('AGENT_ROLE_MISMATCH');
+  bindRegisteredParticipant(task.participant,participantProfile);
+  const current=await call('ksfGetRecord',{className:'KfSession',objectId:task.sessionId});
+  assertOwnSession(current.record,agentProfile);
+  if(current.record?.meta?.attribution?.participantKey!=='participant:'+sha(canonical([current.record.workshopKey,participantProfile.key])))fail('PARTICIPANT_SESSION_MISMATCH');
+  const result=await call(commands[command],{sessionId:task.sessionId,participantProfileKey:participantProfile.key,agentProfileKey:agentProfile.key,requestId});
+  if(result?.readOnly!==true||result.databaseWrites!==0||result.sessionId!==task.sessionId||result.employeeIdentity?.status!=='verified'||result.employeeIdentity.persisted!==false||result.employee?.nameVerified!==true||result.employee.source!=='wecom_directory'||typeof result.employee.name!=='string'||!result.employee.name.trim())fail('EMPLOYEE_READ_RECEIPT_MISMATCH');
+  return redactIdentity(result);
+ }
  if(agentProfile){
   if(!['project-status','workshop-search','workshop-read','workshop-contract','workshop-dedup'].includes(command)&&agentProfile.role!=='workshop')fail('AGENT_ROLE_MISMATCH');
   if(command==='workshop-start'){
@@ -82,7 +93,7 @@ export function createWorkshopCaller({token,url='https://kangshifu.fmode.cn/api/
  return async(name,params)=>{
   let r;try{r=await fetchImpl(url,{method:'POST',headers:{'Content-Type':'application/json','X-Parse-Application-Id':appId,'X-Ksf-Service-Token':token},body:JSON.stringify({path:'/'+name,params}),redirect:'error',signal:AbortSignal.timeout(45000)});}catch{fail('REMOTE_REQUEST_UNCERTAIN','请求结果未知,请保持原 requestId 查回状态;不自动重试或换键');}
   const result=(await r.json().catch(()=>null))?.result;
-  if(!r.ok||result?.ok!==true||result.errors?.length)fail(result?.errors?.[0]?.code||'REMOTE_FAILED',result?.errors?.[0]?.message||'服务调用失败');return result.data;
+  if(!r.ok||result?.ok!==true||result.errors?.length)throw Object.assign(new Error('服务调用失败'),{code:result?.errors?.[0]?.code||'REMOTE_FAILED',remoteFunction:name,remoteResponseReceived:true});return result.data;
  };
 }
 if(process.argv[1]&&path.resolve(process.argv[1])===self){
@@ -91,15 +102,15 @@ if(process.argv[1]&&path.resolve(process.argv[1])===self){
   const [command,...args]=process.argv.slice(2);const flags={};for(let i=0;i<args.length;i+=2){if(!['--input','--request-id','--agent-profile'].includes(args[i])||!args[i+1]||flags[args[i]])fail('USAGE');flags[args[i]]=args[i+1];}
   if(!flags['--input']||!path.isAbsolute(flags['--input']))fail('INPUT_PATH_MUST_BE_ABSOLUTE');
   const task=JSON.parse(fs.readFileSync(flags['--input'],'utf8'));
-  const readOnly=['project-status','workshop-search','workshop-read','workshop-contract','workshop-dedup'].includes(command);
+  const readOnly=['project-status','workshop-search','workshop-read','workshop-contract','workshop-dedup','employee-read'].includes(command);
   if(flags['--agent-profile']&&!/^agent-(?:registration:[a-f0-9-]{36}|account:[a-f0-9]{64})$/.test(flags['--agent-profile']))fail('INVALID_AGENT_KEY');
-  if(!readOnly&&!flags['--agent-profile'])fail('AGENT_PROFILE_REQUIRED');
+  if((!readOnly||command==='employee-read')&&!flags['--agent-profile'])fail('AGENT_PROFILE_REQUIRED');
   const agentProfile=flags['--agent-profile']?readAgentProfileByKey(flags['--agent-profile']):null;
-  if(command==='workshop-start'&&!task.participant?.profileKey)fail('PARTICIPANT_PROFILE_REQUIRED');
-  const participantProfile=command==='workshop-start'?readParticipantProfile(task.participant?.profileKey):null;
-  if(command==='workshop-start'&&!participantProfile)fail('PARTICIPANT_PROFILE_REQUIRED');
+  if(['workshop-start','employee-read'].includes(command)&&!task.participant?.profileKey)fail('PARTICIPANT_PROFILE_REQUIRED');
+  const participantProfile=['workshop-start','employee-read'].includes(command)?readParticipantProfile(task.participant?.profileKey):null;
+  if(['workshop-start','employee-read'].includes(command)&&!participantProfile)fail('PARTICIPANT_PROFILE_REQUIRED');
   const result=await runWorkshop(command,task,{requestId:flags['--request-id'],agentProfile,participantProfile,call:createWorkshopCaller({token:process.env.KSF_SERVICE_TOKEN})});
   const out=JSON.stringify({...base,command,requestId:flags['--request-id'],ok:true,readOnly,...(agentProfile?{executingAgent:{key:agentProfile.key,name:agentProfile.name,identityBasis:agentProfile.identityBasis}}:{}),result},null,2);
   if(process.env.KSF_SERVICE_TOKEN&&out.includes(process.env.KSF_SERVICE_TOKEN))fail('OUTPUT_CONTAINS_SECRET');console.log(out);
- }catch(e){const out=JSON.stringify({...base,command:process.argv[2],requestId:process.argv[process.argv.indexOf('--request-id')+1],ok:false,error:{code:/^[A-Z][A-Z0-9_]+$/.test(e.code||'')?e.code:'WORKSHOP_FAILED',message:'执行停止,保留原任务及业务键核查;未输出原始异常或凭据。'},...(e.partial?{partial:e.partial}:{})},null,2);console.error(process.env.KSF_SERVICE_TOKEN&&out.includes(process.env.KSF_SERVICE_TOKEN)?JSON.stringify({...base,ok:false,error:{code:'OUTPUT_CONTAINS_SECRET'}}):out);process.exitCode=1;}
+ }catch(e){const out=JSON.stringify({...base,command:process.argv[2],requestId:process.argv[process.argv.indexOf('--request-id')+1],ok:false,error:{code:/^[A-Z][A-Z0-9_]+$/.test(e.code||'')?e.code:'WORKSHOP_FAILED',message:'执行停止,保留原任务及业务键核查;未输出原始异常或凭据。',...(typeof e.remoteFunction==='string'?{remoteFunction:e.remoteFunction,remoteResponseReceived:e.remoteResponseReceived===true}:{})},...(e.partial?{partial:e.partial}:{})},null,2);console.error(process.env.KSF_SERVICE_TOKEN&&out.includes(process.env.KSF_SERVICE_TOKEN)?JSON.stringify({...base,ok:false,error:{code:'OUTPUT_CONTAINS_SECRET'}}):out);process.exitCode=1;}
 }

+ 134 - 0
test/employee-read.test.js

@@ -0,0 +1,134 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import crypto from 'node:crypto';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { attachWecomEmployeeReadHandler } from '../server/identity/employee-read.mjs';
+import { buildEmployeeRead } from '../server/identity/build-employee-read.mjs';
+import { manage } from '../server/identity/manage.mjs';
+import { runWorkshop, createWorkshopCaller } from '../skills/ksf-workshop-runtime/scripts/ksf-workshop.mjs';
+import { parseArgs } from '../skills/ksf-workshop-runtime/scripts/ksf-secure.mjs';
+
+const hash = v => crypto.createHash('sha256').update(v).digest('hex');
+const canonical = v => v && typeof v === 'object' ? Array.isArray(v) ? '[' + v.map(canonical).join(',') + ']' : '{' + Object.keys(v).sort().map(k => JSON.stringify(k) + ':' + canonical(v[k])).join(',') + '}' : JSON.stringify(v);
+const account = { platform: 'wecom', identifierType: 'wecom_aibot_sender', scopeId: 'private-bot', accountId: 'private-sender' };
+const participant = { key: 'participant-account:' + 'a'.repeat(64), name: '员工待绑定', identityBasis: 'channel_context', account };
+const agent = { key: 'agent-account:' + 'b'.repeat(64), name: '测试数字人', role: 'workshop', runtimePlatform: 'hermes', channel: 'wecom', account: { platform: 'wecom', accountId: account.scopeId } };
+const params = { sessionId: 'session1', participantProfileKey: participant.key, agentProfileKey: agent.key, requestId: 'read1' };
+function fixture({ configured = true, apiError = false } = {}) {
+  const calls = [], writes = [];
+  const session = { objectId: 'session1', workshopKey: 'test-workshop', meta: { ownerId: 'service1', attribution: {
+    participantKey: 'participant:' + hash(canonical(['test-workshop', participant.key])), participantAccount: account, identityBasis: 'channel_context',
+    agentKey: agent.key, agentName: agent.name, agentRuntimePlatform: agent.runtimePlatform, agentChannel: agent.channel, agentAccount: agent.account,
+  } } };
+  const fail = code => { throw Object.assign(Error(code), { code }); };
+  const app = { handlers: {}, hash, canonical, fail, need: (actor, scope) => { if (!actor) fail('AUTH_REQUIRED'); if (!actor.scopes.some(s => s === scope || s === '*')) fail('FORBIDDEN'); }, operation: () => { writes.push('operation'); throw Error('must not write'); } };
+  const config = { enabled: true, corpId: 'private-corp', secret: 'fixture-secret', bots: { 'private-bot': { mappingVerified: true, senderIdType: 'mapped', userIds: { 'private-sender': 'private-user' } } } };
+  const fetchImpl = async url => {
+    calls.push(url);
+    const body = apiError ? { errcode: 48002, errmsg: 'fixture-secret must not appear in error' } : new URL(url).pathname.endsWith('/gettoken')
+      ? { errcode: 0, access_token: 'fixture-access-token', expires_in: 7200 }
+      : { errcode: 0, userid: 'private-user', status: 1, name: '测试员工甲', department: [], mobile: 'private-mobile' };
+    return { ok: true, json: async () => body };
+  };
+  const store = { get: async (kind, id) => kind === 'KfSession' && id === 'session1' ? structuredClone(session) : null,
+    update: () => { writes.push('update'); throw Error('must not write'); } };
+  const settings = configured ? { wecomDirectory: config } : {};
+  attachWecomEmployeeReadHandler(app, { store, settings, fetchImpl });
+  return { app, session, settings, store, calls, writes, actor: { id: 'service1', scopes: ['data:read'] } };
+}
+
+test('employee-read从Skill到服务端实际查询姓名,沿用data:read且服务端零写入', async () => {
+  const f = fixture(), calls = [];
+  const result = await runWorkshop('employee-read', { sessionId: 'session1', participant: { profileKey: participant.key } }, {
+    requestId: 'minimal-read', agentProfile: agent, participantProfile: participant,
+    call: async (name, p) => { calls.push({ name, p }); return name === 'ksfGetRecord' ? { record: structuredClone(f.session) } : f.app.handlers[name](p, f.actor); },
+  });
+  assert.deepEqual(calls.map(c => c.name), ['ksfGetRecord', 'ksfReadWecomEmployee']);
+  assert.equal(calls[1].p.requestId, 'minimal-read');
+  assert.equal(result.employee.name, '测试员工甲'); assert.equal(result.employee.nameVerified, true);
+  assert.equal(result.readOnly, true); assert.equal(result.databaseWrites, 0); assert.equal(result.employeeIdentity.persisted, false);
+  assert.equal(f.calls.length, 2); assert.deepEqual(f.writes, []);
+  for (const secret of ['fixture-secret', 'fixture-access-token', 'private-user', 'private-mobile', 'private-bot', 'private-sender']) assert.ok(!JSON.stringify(result).includes(secret));
+});
+
+test('匿名、其他服务、其他员工、其他Agent或无读取权限在通讯录请求前被拒绝', async () => {
+  for (const [input, actor, code] of [
+    [params, null, 'AUTH_REQUIRED'], [params, { id: 'service1', scopes: ['ingest:write'] }, 'FORBIDDEN'],
+    [params, { id: 'another', scopes: ['data:read'] }, 'FORBIDDEN'],
+    [{ ...params, participantProfileKey: 'participant-account:' + 'c'.repeat(64) }, null, 'IDENTITY_CONFLICT'],
+    [{ ...params, agentProfileKey: 'agent-account:' + 'c'.repeat(64) }, null, 'IDENTITY_CONFLICT'],
+    [{ ...params, apply: true }, null, 'VALIDATION_FAILED'],
+  ]) {
+    const f = fixture();
+    const effectiveActor = actor || (code === 'AUTH_REQUIRED' ? null : f.actor);
+    await assert.rejects(() => f.app.handlers.ksfReadWecomEmployee(input, effectiveActor), { code });
+    assert.equal(f.calls.length, 0); assert.deepEqual(f.writes, []);
+  }
+});
+
+test('缺配置、缺映射和无通讯录权限给出真实错误,不退回看板占位名', async () => {
+  const absent = fixture({ configured: false });
+  await assert.rejects(() => absent.app.handlers.ksfReadWecomEmployee(params, absent.actor), { code: 'WECOM_DIRECTORY_NOT_CONFIGURED' });
+  assert.equal(absent.calls.length, 0);
+  const mapping = fixture(); mapping.settings.wecomDirectory.bots['private-bot'].userIds = {};
+  await assert.rejects(() => mapping.app.handlers.ksfReadWecomEmployee(params, mapping.actor), { code: 'WECOM_SENDER_MAPPING_REQUIRED' });
+  assert.equal(mapping.calls.length, 0);
+  const denied = fixture({ apiError: true });
+  await assert.rejects(() => denied.app.handlers.ksfReadWecomEmployee(params, denied.actor), e => e.code === 'WECOM_DIRECTORY_PERMISSION_REQUIRED' && !e.message.includes('fixture-secret'));
+  assert.deepEqual(denied.writes, []);
+});
+
+test('CLI白名单接受employee-read但仍要求Agent,错员工在远端姓名查询前停止', async () => {
+  const input = path.resolve('input.json');
+  assert.equal(parseArgs(['employee-read', '--input', input, '--request-id', 'r1', '--agent-profile', agent.key]).command, 'employee-read');
+  assert.throws(() => parseArgs(['employee-read', '--input', input, '--request-id', 'r1']), { code: 'AGENT_PROFILE_REQUIRED' });
+  const f = fixture(), calls = [];
+  await assert.rejects(() => runWorkshop('employee-read', { sessionId: 'session1', participant: { profileKey: participant.key } }, {
+    requestId: 'r1', agentProfile: agent, participantProfile: participant, call: async name => { calls.push(name); return { record: { ...f.session, meta: { attribution: { ...f.session.meta.attribution, participantKey: 'another-participant' } } } }; },
+  }), { code: 'PARTICIPANT_SESSION_MISMATCH' });
+  assert.deepEqual(calls, ['ksfGetRecord']);
+});
+
+test('受权网络调用保留远端函数及真实错误码且不自动重试', async () => {
+  const calls = [];
+  const caller = createWorkshopCaller({ token: 'fixture-token', fetchImpl: async (url, options) => {
+    calls.push({ url, options });
+    return { ok: true, json: async () => ({ result: { ok: false, errors: [{ code: 'WECOM_DIRECTORY_NOT_CONFIGURED', message: 'private-secret' }] } }) };
+  } });
+  await assert.rejects(() => caller('ksfReadWecomEmployee', params), e => e.code === 'WECOM_DIRECTORY_NOT_CONFIGURED' && e.remoteFunction === 'ksfReadWecomEmployee' && e.remoteResponseReceived === true && !e.message.includes('private-secret'));
+  assert.equal(calls.length, 1); assert.equal(JSON.parse(calls[0].options.body).path, '/ksfReadWecomEmployee');
+});
+
+test('单函数补丁只新增读取函数,部署可重复、可回退且保留r5四函数', async t => {
+  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ksf-employee-read-'));
+  t.after(() => fs.rmSync(dir, { recursive: true }));
+  const base = { name: 'ksfGetPublicBoard', objectId: 'public1', path: '/ksfGetPublicBoard', ACL: {},
+    code: 'function bootstrap(){attachWecomEmployeeHandlers(app,{store,settings});}\nfunction handler(request,response){return runDatabaseFunction("ksfGetPublicBoard",request,response,{});}' };
+  const built = buildEmployeeRead(base);
+  new Function(built.functions[0].code);
+  for (const entry of built.functions) fs.writeFileSync(path.join(dir, entry.name + '.js'), entry.code);
+  fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify({ ...built, functions: built.functions.map(({ code, ...meta }) => meta) }));
+  const rows = [structuredClone(base), ...['ksfStartWorkshopSession', 'ksfSaveWorkshopTask', 'ksfSyncWecomEmployee'].map(name => ({ name, objectId: name, code: 'preserved', ACL: {} }))];
+  const before = structuredClone(rows), writes = [];
+  const request = async (method, route, body) => {
+    if (route.includes('?')) return { results: structuredClone(rows.filter(r => r.name === JSON.parse(new URL('https://test' + route).searchParams.get('where')).name)) };
+    const id = route.split('/').at(-1);
+    if (method === 'GET') return structuredClone(rows.find(r => r.objectId === id));
+    writes.push({ method, route, body });
+    if (method === 'POST') { rows.push({ objectId: 'read1', ...body }); return { objectId: 'read1' }; }
+    if (method === 'DELETE') { rows.splice(rows.findIndex(r => r.objectId === id), 1); return {}; }
+    throw Error('must not edit existing functions');
+  };
+  const run = args => manage({ args, request, releaseDir: dir });
+  const dry = await run(['deploy-employee-read']); assert.equal(dry.readOnly, true); assert.equal(writes.length, 0);
+  const applied = await run(['deploy-employee-read', '--apply', '--receipt-dir', dir]);
+  assert.equal(applied.functions, 1); assert.equal(writes.length, 1); assert.equal(rows.length, 5);
+  assert.deepEqual(rows.slice(0, 4), before);
+  assert.equal((await run(['deploy-employee-read', '--apply', '--receipt-dir', dir])).unchanged, true); assert.equal(writes.length, 1);
+  await run(['rollback', '--receipt', applied.receipt, '--apply']); assert.deepEqual(rows, before);
+  assert.throws(() => buildEmployeeRead({ ...base, code: base.code.replace('attachWecomEmployeeHandlers', 'changed') }), /UNEXPECTED_R5_BASE/);
+  rows[0].code += '// concurrent edit';
+  await assert.rejects(() => run(['deploy-employee-read']), { code: 'FUNCTION_BASELINE_MISMATCH' });
+});