xuym-codex-collab 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,6 +22,8 @@ codex-collab setup
22
22
 
23
23
  同一个真人可以拥有多个 Agent,每个 Agent 使用独立机器人、签名身份、业务 clone 和 Codex 对话。管理员在项目页点击“给自己增加机器人”,验证同一个企业微信账号后自动接入,无需下载包或填写包密码;给同事分配时仍使用加密接入包,同事可以领取多份。相同 userid 不会合并 Agent 权限,审核通知会指明应使用的机器人。
24
24
 
25
+ 机器人在企业微信后台设为多人可用并加入协作群后,已批准入队的成员可以 `@` 任意团队机器人的自然语言需求;未入队的群成员仍会被工具拒绝。普通成员可提交需求、创建任务和查询状态,审核与取消、完成等控制操作继续受原有身份权限限制。接入完成后,团队成员可直接互相派发,不必再改机器人配置。
26
+
25
27
  本机配置完成后,向导只要求在目标 Codex 对话发送一条指令,例如:
26
28
 
27
29
  ```text
@@ -32,6 +34,8 @@ Skill 完成身份核对、对话绑定和 heartbeat 配置,页面自动观察
32
34
 
33
35
  完整步骤与旧配置迁移见 [团队接入指南](docs/onboarding.md)。
34
36
 
37
+ 若管理员工作台显示接入包密码为空,通常是旧版本未保存原密码:无法从密文恢复,只能在管理员项目的“已分配的机器人”中重新生成包和密码。详情见[密码遗失恢复步骤](docs/onboarding.md#接入包密码遗失)。
38
+
35
39
  ## 一次任务如何接力
36
40
 
37
41
  ```mermaid
@@ -8,6 +8,7 @@ export function encryptBundle(value, password) {
8
8
  return { schema_version: 1, kind: 'codex-collab-encrypted-invite', salt: salt.toString('base64'), iv: iv.toString('base64'), tag: cipher.getAuthTag().toString('base64'), ciphertext: ciphertext.toString('base64') };
9
9
  }
10
10
  export function decryptBundle(value, password) {
11
+ if (typeof password !== 'string' || !password.trim()) throw new SetupError('请填写接入包密码;若管理员页面未保存密码,请由管理员重新生成接入包。');
11
12
  try {
12
13
  if (value?.schema_version !== 1 || value.kind !== 'codex-collab-encrypted-invite' || typeof password !== 'string' || password.length > 256) throw new Error();
13
14
  const salt = Buffer.from(value.salt, 'base64'), iv = Buffer.from(value.iv, 'base64'), tag = Buffer.from(value.tag, 'base64');
@@ -315,6 +315,7 @@ export class SetupManager {
315
315
  }
316
316
  if (state.role === 'member' && ['verified', 'waiting'].includes(state.phase)) {
317
317
  let store = await this.profileStore(state, { enrollmentOnly: true });
318
+ if (store.registry.revoked_invites.has(state.bundle.invitation.invite_id)) throw new SetupError('这份接入包已被替换,请重新导入最新接入包并使用新密码。');
318
319
  if (state.phase === 'verified') {
319
320
  await store.registryTransaction(async (history) => {
320
321
  const invitation = JSON.parse(await readFile(join(store.checkout, invitationFile(state.request.invite_id)), 'utf8'));
@@ -403,12 +404,24 @@ export class SetupManager {
403
404
  const selected = await ensureRepo(repo);
404
405
  if (inside(selected.repo, this.home)) throw new SetupError('本机配置目录必须在业务仓库之外。');
405
406
  if (remoteIdentity(selected.remote) !== remoteIdentity(bundle.remote)) throw new SetupError('该目录的 origin 与邀请仓库不一致,请选择正确仓库;工具不会改写 origin。');
407
+ let recovered;
406
408
  for (const profile of (await this.overview()).profiles) {
407
409
  const current = await this.state(profile.id);
408
410
  if (current.bundle?.invitation.invite_id === bundle.invitation.invite_id) return this.detail(current.id, { localOnly: true });
409
- if (current.repo === selected.repo) throw new SetupError('这个 clone 已接入其他身份,请选择独立 clone。');
411
+ if (current.repo !== selected.repo) continue;
412
+ const oldId = current.bundle?.invitation.invite_id;
413
+ const replacement = history.records.find((record) => record.invitation_replacement?.old_invite_id === oldId && record.invitation_replacement.new_invite_id === bundle.invitation.invite_id);
414
+ if (!replacement || current.agent_id !== bundle.invitation.agent_id || current.root_hash !== bundle.root_hash ||
415
+ current.role !== 'member' || !['imported', 'verified', 'waiting'].includes(current.phase)) throw new SetupError('这个 clone 已接入其他身份,请选择独立 clone。');
416
+ const latest = await this.profileStore(current, { enrollmentOnly: true });
417
+ if (!latest.registry.revoked_invites.has(oldId) || latest.team.agents[current.agent_id]) throw new SetupError('旧接入尚未被替换或成员已经获批准,请检查管理员配置。');
418
+ validateInvitation(bundle.invitation, latest.registry);
419
+ const published = JSON.parse(await readFile(join(latest.checkout, invitationFile(bundle.invitation.invite_id)), 'utf8'));
420
+ if (canonical(published) !== canonical(bundle.invitation)) throw new SetupError('仓库邀请与接入包不一致。');
421
+ await savePrivate(join(this.path(current.id), 'replaced-invitations', `${oldId}.json`), current);
422
+ recovered = current;
410
423
  }
411
- const id = randomUUID(); const identity = await getOrCreateIdentityFiles(bundle.invitation.agent_id, join(this.home, 'identities'));
424
+ const id = recovered?.id || randomUUID(); const identity = await getOrCreateIdentityFiles(bundle.invitation.agent_id, join(this.home, 'identities'));
412
425
  const state = { id, role: 'member', agent_id: bundle.invitation.agent_id, display_name: bundle.display_name || bundle.invitation.agent_id, team_name: bundle.team_name || null, repo: selected.repo, remote: selected.remote, identity_path: identity.private_key_file,
413
426
  bootstrap_team: history.latest.team, root_hash: bundle.root_hash, bundle, test_argv: bundle.test_argv, phase: 'imported',
414
427
  ...(ownAdminId ? { own_admin_id: ownAdminId } : {}) };
@@ -449,6 +462,7 @@ export class SetupManager {
449
462
  const existing = await readdir(join(store.checkout, registryPath, 'invitations')).catch((error) => error.code === 'ENOENT' ? [] : Promise.reject(error));
450
463
  for (const file of existing) {
451
464
  const invite = JSON.parse(await readFile(join(store.checkout, registryPath, 'invitations', file), 'utf8'));
465
+ if (history.revoked_invites.has(invite.invite_id)) continue;
452
466
  validateInvitation(invite, history);
453
467
  if (prepared.some((item) => (item.agent_id === invite.agent_id || item.bot_id === invite.bot_id) && item.invite_id !== invite.invite_id) &&
454
468
  (Date.now() < Date.parse(invite.expires_at) || history.records.some((record) => record.enrollment?.invite_id === invite.invite_id))) throw new SetupError('成员或机器人已有有效接入包,请使用已生成的文件。');
@@ -470,12 +484,65 @@ export class SetupManager {
470
484
  return { assignments: prepared.map((item) => ({ invite_id: item.invite_id, agent_id: item.agent_id,
471
485
  display_name: item.display_name, filename: `${item.agent_id}-collab-invite.json`, password: item.password })) };
472
486
  }
487
+ async reissueBundle({ id, invite_id, secret }) {
488
+ const state = await this.state(id);
489
+ if (state.role !== 'admin' || state.phase !== 'ready' || !uuidPattern.test(invite_id || '')) throw new SetupError('只有已接入的管理员可重新生成接入包。');
490
+ if (typeof secret !== 'string' || !secret.trim() || secret.length > 2000) throw new SetupError('请在本机填写该机器人的 Secret。');
491
+ const saved = await readPrivateJson(join(this.path(id), 'exports', `${invite_id}.json`));
492
+ if (!saved.encrypted || saved.own_bundle) throw new SetupError('本人机器人无需重新生成分发接入包。');
493
+ const store = await this.profileStore(state), identity = await readPrivateJson(state.identity_path);
494
+ // Persist a stable replacement before publication so retries survive a lost Git acknowledgment.
495
+ const directory = join(this.path(id), 'exports');
496
+ let candidate;
497
+ for (const file of await readdir(directory)) {
498
+ const value = await readPrivateJson(join(directory, file));
499
+ if (value.replaces_invite_id === invite_id) { candidate = value; break; }
500
+ }
501
+ const newId = candidate?.invitation.invite_id || randomUUID();
502
+ const claim = candidate ? null : generateIdentity(), password = candidate?.delivery_password || randomBytes(18).toString('base64url');
503
+ await store.registryTransaction(async (history) => {
504
+ const replacement = history.records.find((record) => record.invitation_replacement?.old_invite_id === invite_id);
505
+ if (replacement) {
506
+ if (replacement.invitation_replacement.new_invite_id !== newId || !candidate) throw new SetupError('该接入包已由其他操作替换,请刷新并使用最新接入包。');
507
+ const published = JSON.parse(await readFile(join(store.checkout, invitationFile(newId)), 'utf8'));
508
+ validateInvitation(published, history);
509
+ if (canonical(published) !== canonical(candidate.invitation)) throw new SetupError('恢复记录与仓库邀请不一致,请检查本机文件。');
510
+ return { value: null };
511
+ }
512
+ validateInvitation(saved.invitation, history);
513
+ if (history.latest.team.agents[saved.invitation.agent_id] || history.records.some((record) => record.enrollment?.invite_id === invite_id)) throw new SetupError('该成员已获批准,请使用原配置或更新机器人凭据,无需重新生成接入包。');
514
+ const originalPath = invitationFile(invite_id);
515
+ if (await exists(join(store.checkout, originalPath))) {
516
+ const original = JSON.parse(await readFile(join(store.checkout, originalPath), 'utf8'));
517
+ if (canonical(original) !== canonical(saved.invitation)) throw new SetupError('原接入包与仓库邀请不一致。');
518
+ }
519
+ const invitation = candidate?.invitation || invitationRecord({ ...saved.invitation, invite_id: newId,
520
+ claim_public_key: claim.public_key, expires_at: new Date(Date.now() + 7 * 86400000).toISOString() }, history.latest, state.agent_id, identity.private_key);
521
+ validateInvitation(invitation, history);
522
+ if (Date.now() > Date.parse(invitation.expires_at)) throw new SetupError('恢复接入包已过期,请检查本机恢复记录。');
523
+ const version = policyVersion(history.latest.team, history.latest, state.agent_id, identity.private_key, undefined,
524
+ { old_invite_id: invite_id, new_invite_id: newId });
525
+ // Use the latest history on CAS retries; the encryption password and claim key stay stable.
526
+ const previousBundle = candidate ? decryptBundle(candidate.encrypted, password) : null;
527
+ const bundle = { schema_version: 1, kind: 'codex-collab-invite', root_hash: state.root_hash, remote: state.remote,
528
+ history: [...history.records, version], invitation, claim_private_key: previousBundle?.claim_private_key || claim.private_key,
529
+ display_name: saved.display_name || saved.invitation.agent_id, team_name: state.team_name || null,
530
+ wecom: { bot_id: invitation.bot_id, secret }, test_argv: state.test_argv };
531
+ candidate = { invitation, display_name: bundle.display_name, encrypted: encryptBundle(bundle, password),
532
+ delivery_password: password, replaces_invite_id: invite_id };
533
+ await savePrivate(join(directory, `${newId}.json`), candidate);
534
+ return { files: [[invitationFile(newId), invitation], [versionFile(version), version]], value: null };
535
+ });
536
+ return { invite_id: newId, agent_id: candidate.invitation.agent_id };
537
+ }
473
538
  async delivery({ id, invite_id }) {
474
539
  const state = await this.state(id);
475
540
  if (state.role !== 'admin' || state.phase !== 'ready' || !uuidPattern.test(invite_id || '')) throw new SetupError('只有管理员可查看已分配的接入说明。');
476
541
  const saved = await this.publishAssignment(id, invite_id);
477
542
  if (!saved.encrypted) throw new SetupError('本人机器人直接在本机接入,无需分发接入包。');
478
543
  return { agent_id: saved.invitation.agent_id, display_name: saved.display_name || saved.invitation.agent_id, password: saved.delivery_password || null,
544
+ can_reissue: !saved.approved, bot_id: saved.invitation.bot_id,
545
+ password_missing: !saved.delivery_password,
479
546
  instructions: `已为你分配 ${saved.display_name || saved.invitation.agent_id} 协作机器人。\n1. 安装 Node.js 20 或以上版本及 Git。\n2. 执行 npx xuym-codex-collab@latest setup 打开接入向导。\n3. 导入我提供的加密接入包,使用单独发送的密码,选择已有项目或自动拉取。\n4. 按页面提示在群里确认身份;批准后自动完成配置。\n5. 在目标 Codex 项目对话发送页面提供的启用指令。` };
480
547
  }
481
548
  async issueBundle(input, { ownRepo } = {}) {
@@ -495,6 +562,7 @@ export class SetupManager {
495
562
  const files = await readdir(join(store.checkout, registryPath, 'invitations')).catch((error) => { if (error.code === 'ENOENT') return []; throw error; });
496
563
  for (const file of files) {
497
564
  const existing = JSON.parse(await readFile(join(store.checkout, registryPath, 'invitations', file), 'utf8'));
565
+ if (history.revoked_invites.has(existing.invite_id)) continue;
498
566
  validateInvitation(existing, history);
499
567
  if (existing.invite_id === inviteId) return { value: { invitation: existing } };
500
568
  if ((existing.agent_id === agentId || existing.bot_id === input.bot_id.trim()) && (Date.now() < Date.parse(existing.expires_at) || history.records.some((record) => record.enrollment?.invite_id === existing.invite_id))) throw new SetupError('该成员或机器人已有有效接入包,请使用已生成的文件。');
@@ -518,20 +586,29 @@ export class SetupManager {
518
586
  if (!uuidPattern.test(inviteId || '')) throw new SetupError('无效邀请标识。');
519
587
  const state = await this.state(id); const bundle = await readPrivateJson(join(this.path(id), 'exports', `${inviteId}.json`));
520
588
  const store = await this.profileStore(state);
589
+ if (bundle.replaces_invite_id) {
590
+ const replacement = store.registry.records.find((record) => record.invitation_replacement?.old_invite_id === bundle.replaces_invite_id);
591
+ if (!replacement || replacement.invitation_replacement.new_invite_id !== inviteId || !store.registry.revoked_invites.has(bundle.replaces_invite_id)) {
592
+ throw new SetupError('接入包替换尚未发布到仓库,请返回原接入包并重试“重新生成”。');
593
+ }
594
+ }
521
595
  await store.registryTransaction(async (history) => {
596
+ if (history.revoked_invites.has(inviteId)) throw new SetupError('这份接入包已被替换,请下载最新接入包并使用新密码。');
522
597
  const path = invitationFile(inviteId); if (await exists(join(store.checkout, path))) return { value: null };
523
598
  validateInvitation(bundle.invitation, history);
524
599
  if (history.latest.team.agents[bundle.invitation.agent_id] || Date.now() > Date.parse(bundle.invitation.expires_at)) throw new SetupError('邀请已失效,请重新生成。');
525
600
  const files = await readdir(join(store.checkout, registryPath, 'invitations')).catch(() => []);
526
601
  for (const file of files) {
527
- const other = JSON.parse(await readFile(join(store.checkout, registryPath, 'invitations', file), 'utf8')); validateInvitation(other, history);
602
+ const other = JSON.parse(await readFile(join(store.checkout, registryPath, 'invitations', file), 'utf8'));
603
+ if (history.revoked_invites.has(other.invite_id)) continue;
604
+ validateInvitation(other, history);
528
605
  if ((other.agent_id === bundle.invitation.agent_id || other.bot_id === bundle.invitation.bot_id) && (Date.now() < Date.parse(other.expires_at) || history.records.some((record) => record.enrollment?.invite_id === other.invite_id))) throw new SetupError('邀请与另一已发布的接入包冲突。');
529
606
  }
530
607
  return { files: [[path, bundle.invitation]], value: null };
531
608
  });
532
609
  const published = JSON.parse(await readFile(join(store.checkout, invitationFile(inviteId)), 'utf8'));
533
610
  if (canonical(published) !== canonical(bundle.invitation)) throw new SetupError('邀请尚未发布成功,请重新生成接入包。');
534
- return bundle;
611
+ return { ...bundle, approved: !!store.team.agents[bundle.invitation.agent_id] };
535
612
  }
536
613
  async exportedBundle(id, inviteId) {
537
614
  const bundle = await this.publishAssignment(id, inviteId);
@@ -631,6 +708,8 @@ export class SetupManager {
631
708
  const exported = await readdir(join(this.path(id), 'exports')).catch((error) => { if (error.code === 'ENOENT') return []; throw error; });
632
709
  for (const file of exported) {
633
710
  const value = await readPrivateJson(join(this.path(id), 'exports', file));
711
+ if (store.registry.revoked_invites.has(value.invitation.invite_id)) continue;
712
+ if (value.replaces_invite_id && !store.registry.records.some((record) => record.invitation_replacement?.old_invite_id === value.replaces_invite_id && record.invitation_replacement.new_invite_id === value.invitation.invite_id)) continue;
634
713
  result.exports.push({ invite_id: value.invitation.invite_id, agent_id: value.invitation.agent_id,
635
714
  filename: `${value.invitation.agent_id}-collab-invite.json`, display_name: value.display_name || value.invitation.agent_id, expires_at: value.invitation.expires_at, own_agent: !!value.own_bundle });
636
715
  }
@@ -41,7 +41,7 @@ export async function startSetupServer({ manager = new SetupManager(), port = 0
41
41
  const value = await manager.exclusive(() => manager.exportedBundle(url.searchParams.get('id'), url.searchParams.get('invite')));
42
42
  res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Disposition': `attachment; filename="${value.filename}"` }); res.end(value.content); return;
43
43
  }
44
- const actions = { 'project-check': () => manager.prepareProject(body), 'admin-prepare': () => manager.prepareAdmin(body), 'admin-complete': () => manager.completeAdmin(body), 'admin-retry': () => manager.adminDraft({ id: body.id, reconnect: true }), 'inspect-invite': () => manager.inspectBundle(body), progress: () => manager.progress(body), 'issue-batch': () => manager.issueBatch(body), delivery: () => manager.delivery(body), 'reset-cancel': () => manager.resetCancel(body), 'reset-preview': () => manager.resetPreview(body), 'reset-discovery': () => manager.resetDiscovery(body), reset: () => manager.reset(body), 'own-agent': () => manager.createOwnAgent(body), 'own-agent-resume': () => manager.resumeOwnAgent(body), discovery: () => manager.beginDiscovery(body), admin: () => manager.createAdmin(body), import: () => manager.importBundle(body), existing: () => manager.importExisting(body), verify: () => manager.verifyMember(body), resume: () => manager.resume(body.id), issue: () => manager.issueBundle(body), approve: () => manager.approve(body), runner: () => manager.runner(body), 'review-rules': () => manager.setReviewRules(body) };
44
+ const actions = { 'project-check': () => manager.prepareProject(body), 'admin-prepare': () => manager.prepareAdmin(body), 'admin-complete': () => manager.completeAdmin(body), 'admin-retry': () => manager.adminDraft({ id: body.id, reconnect: true }), 'inspect-invite': () => manager.inspectBundle(body), progress: () => manager.progress(body), 'issue-batch': () => manager.issueBatch(body), 'reissue-invite': () => manager.reissueBundle(body), delivery: () => manager.delivery(body), 'reset-cancel': () => manager.resetCancel(body), 'reset-preview': () => manager.resetPreview(body), 'reset-discovery': () => manager.resetDiscovery(body), reset: () => manager.reset(body), 'own-agent': () => manager.createOwnAgent(body), 'own-agent-resume': () => manager.resumeOwnAgent(body), discovery: () => manager.beginDiscovery(body), admin: () => manager.createAdmin(body), import: () => manager.importBundle(body), existing: () => manager.importExisting(body), verify: () => manager.verifyMember(body), resume: () => manager.resume(body.id), issue: () => manager.issueBundle(body), approve: () => manager.approve(body), runner: () => manager.runner(body), 'review-rules': () => manager.setReviewRules(body) };
45
45
  const key = url.pathname.replace('/api/', ''); const action = Object.hasOwn(actions, key) ? actions[key] : null;
46
46
  if (req.method === 'POST' && url.pathname.startsWith('/api/') && action) return json(200, await manager.exclusive(action));
47
47
  return json(404, { error: '操作不存在。' });
@@ -1,11 +1,11 @@
1
1
  const $ = (id) => document.getElementById(id);
2
2
  let currentId, pending, discovery, timer, busy = false, reviewVersion = null, reviewId, ownAdminId, resetPlan, resetDiscovery;
3
- let viewEpoch = 0, currentView = 'home', completing = false;
3
+ let viewEpoch = 0, currentView = 'home', completing = false, deliveryTarget;
4
4
  const storageKey = 'codex-collab-onboarding';
5
5
  function remember(value) { try { if (value) sessionStorage.setItem(storageKey, JSON.stringify(value)); else sessionStorage.removeItem(storageKey); } catch {} }
6
6
  function remembered() { try { return JSON.parse(sessionStorage.getItem(storageKey) || 'null'); } catch { return null; } }
7
7
  function notice(text) { $('notice').textContent = text; $('notice').hidden = !text; }
8
- function show(view) { if (view !== 'profile' || currentView === 'profile') { $('delivery-panel').hidden = true; $('delivery-password').value = ''; $('delivery-instructions').textContent = ''; } clearInterval(timer); clearTimeout(timer); viewEpoch++; currentView = view; for (const section of document.querySelectorAll('main>section')) section.hidden = section.id !== view; }
8
+ function show(view) { if (view !== 'profile' || currentView === 'profile') { $('delivery-panel').hidden = true; $('delivery-password').value = ''; $('delivery-instructions').textContent = ''; $('reissue-secret').value = ''; $('reissue-form').hidden = true; deliveryTarget = null; } clearInterval(timer); clearTimeout(timer); viewEpoch++; currentView = view; for (const section of document.querySelectorAll('main>section')) section.hidden = section.id !== view; }
9
9
  async function api(path, body) {
10
10
  const response = await fetch(`/api/${path}`, body ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : {});
11
11
  const value = await response.json(); if (!response.ok) throw new Error(value.error); return value;
@@ -209,6 +209,7 @@ $('admin-form').onsubmit = (event) => { event.preventDefault(); action(async ()
209
209
  }); };
210
210
  async function invitationInput(form) { const input = values(form); input.content = await input.file.text(); delete input.file; return input; }
211
211
  $('inspect-invite').onclick = () => action(async () => {
212
+ $('invite-info').hidden = true; $('invite-summary').textContent = ''; $('invite-remote').textContent = '';
212
213
  const value = await api('inspect-invite', await invitationInput($('join-form')));
213
214
  $('invite-info').hidden = false; $('invite-summary').textContent = `${value.display_name || value.agent_id}(${value.agent_id})· 职责:${value.capabilities.join('、')}${value.team_name ? ' · ' + value.team_name : ''}`;
214
215
  $('invite-remote').textContent = value.remote;
@@ -318,11 +319,28 @@ async function showDelivery(id, inviteId) {
318
319
  const epoch = viewEpoch, value = await api('delivery', { id, invite_id: inviteId }); if (epoch !== viewEpoch) return;
319
320
  $('delivery-title').textContent = `${value.display_name}(${value.agent_id})的接入说明`;
320
321
  $('delivery-instructions').textContent = value.instructions; $('delivery-password').value = value.password || '';
321
- $('copy-password').hidden = !value.password; $('delivery-panel').hidden = false;
322
+ deliveryTarget = { id, invite_id: inviteId };
323
+ $('delivery-password-label').hidden = !value.password; $('copy-password').hidden = !value.password;
324
+ $('delivery-password-note').hidden = !!value.password;
325
+ $('delivery-password-note').textContent = value.can_reissue ? '这份旧接入包未保存密码,无法从加密文件取回。请重新生成接入包与密码,再加入团队。' : '本机未保存这份接入包密码。成员已获批准,请使用现有配置;如需恢复,请使用原密码或更新机器人凭据。';
326
+ $('delivery-download').href = `/api/export?id=${id}&invite=${inviteId}`;
327
+ $('reissue-invite').hidden = !value.can_reissue; $('reissue-bot-id').value = value.bot_id || '';
328
+ $('reissue-secret').value = ''; $('reissue-form').hidden = true; $('delivery-panel').hidden = false;
322
329
  }
323
330
  $('copy-delivery').onclick = () => action(() => navigator.clipboard.writeText($('delivery-instructions').textContent));
324
331
  $('copy-password').onclick = () => action(() => navigator.clipboard.writeText($('delivery-password').value));
325
- $('hide-delivery').onclick = () => action(async () => { $('delivery-panel').hidden = true; $('delivery-password').value = ''; });
332
+ $('reissue-invite').onclick = () => action(async () => { $('reissue-form').hidden = false; $('reissue-secret').value = ''; clearTimeout(timer); });
333
+ $('reissue-cancel').onclick = () => action(async () => { $('reissue-form').hidden = true; $('reissue-secret').value = ''; watchProfile(currentId, viewEpoch); });
334
+ $('reissue-form').onsubmit = (event) => { event.preventDefault(); action(async () => {
335
+ if (!deliveryTarget || deliveryTarget.id !== currentId) throw new Error('请重新打开该机器人的接入说明。');
336
+ const target = { ...deliveryTarget };
337
+ let result;
338
+ try { result = await api('reissue-invite', { ...target, secret: $('reissue-secret').value }); }
339
+ finally { $('reissue-secret').value = ''; }
340
+ await loadProfile(target.id); await showDelivery(target.id, result.invite_id);
341
+ notice('新接入包与密码已生成,旧包已失效。请下载新文件,在“加入团队”中使用新密码。');
342
+ }); };
343
+ $('hide-delivery').onclick = () => action(async () => { $('delivery-panel').hidden = true; $('delivery-password').value = ''; $('reissue-secret').value = ''; $('reissue-form').hidden = true; deliveryTarget = null; watchProfile(currentId, viewEpoch); });
326
344
  assignmentRow();
327
345
  action(async () => {
328
346
  const token = location.hash.slice(1); if (token) { await api('session', { token }); history.replaceState(null, '', '/'); }
@@ -30,7 +30,7 @@
30
30
  <details id="manage-project"><summary>管理与重置</summary><p>选择要调整的配置,先查看影响,再确认。每次只操作当前 Agent 和 clone。</p><div class="manage-actions"><button id="reset-conversation" class="secondary">重新绑定对话</button><button id="reset-robot" class="secondary">更新机器人凭据</button><button id="reset-project" class="danger">移除本机接入</button></div></details>
31
31
  <div id="admin-tools" hidden><details><summary>团队分工、审核与通知设置</summary><h3>团队 Agent 与分工</h3><div id="members"></div><div id="owners"></div><button id="add-own-agent" class="secondary">给自己增加机器人</button><div id="routes"></div><h3>真人审核与通知</h3><p>按职责设置:阶段结果提交后由谁审核、通知谁。关闭中间审核可直接接力;最终提交仍需真人审核,并由管理员完成任务。关闭审核的职责作为最后阶段时由管理员审核。</p><p id="review-policy-note" class="muted"></p><form id="review-form" hidden><div id="review-rules"></div><button type="submit">保存审核规则</button></form><p class="muted">规则保存后用于新任务。现有任务继续使用创建时的规则。审核通知附带分支、提交、测试和可复制的群命令;企业微信原生 @ 的显示效果需要在实际群中确认。</p></details><h3>批量分配机器人给同事</h3><p>每行分配一个独立机器人。同一个同事可以领取多行,分别绑定独立项目和对话。接入包密码自动生成。</p>
32
32
  <form id="batch-form"><div id="assignment-rows"></div><div class="row"><button id="add-assignment" type="button" class="secondary">增加一位成员/机器人</button><button type="submit">生成全部接入包</button></div></form>
33
- <div id="delivery-panel" hidden><h3 id="delivery-title"></h3><p>接入包与密码请分开传递。下面的接入说明不包含密码或 Secret。</p><pre id="delivery-instructions"></pre><button id="copy-delivery">复制接入说明</button><label>接入包密码<input id="delivery-password" type="password" readonly></label><button id="copy-password" type="button" class="secondary">复制密码</button><button id="hide-delivery" type="button" class="secondary">收起</button></div>
33
+ <div id="delivery-panel" hidden><h3 id="delivery-title"></h3><p>接入包与密码请分开传递。下面的接入说明不包含密码或 Secret。</p><pre id="delivery-instructions"></pre><button id="copy-delivery">复制接入说明</button><label id="delivery-password-label">接入包密码<input id="delivery-password" type="password" readonly></label><button id="copy-password" type="button" class="secondary">复制密码</button><p id="delivery-password-note" class="muted" hidden></p><a id="delivery-download" class="secondary">下载这份接入包</a><button id="reissue-invite" type="button" class="secondary" hidden>重新生成接入包与密码</button><form id="reissue-form" hidden><p>旧包和未批准的旧入队申请将失效。保持原机器人和职责;请输入这个机器人的 Secret,自动生成新的密码。</p><label>机器人 Bot ID<input id="reissue-bot-id" readonly></label><label>机器人 Secret<input id="reissue-secret" name="secret" type="password" required autocomplete="off"></label><button type="submit">生成新的接入包与密码</button><button id="reissue-cancel" type="button" class="secondary">取消</button></form><button id="hide-delivery" type="button" class="secondary">收起</button></div>
34
34
  <details><summary>单独分配(兼容入口)</summary><form id="issue-form"><div class="row"><label>Agent ID<input name="agent_id" required pattern="[a-zA-Z0-9_-]{1,64}" placeholder="bob"></label><label>职责标签<input name="capabilities" required placeholder="backend"></label></div><div class="row"><label>机器人 Bot ID<input name="bot_id" required autocomplete="off"></label><label>机器人 Secret<input name="secret" type="password" required autocomplete="off"></label></div><label>自定义密码(可选,留空自动生成)<input name="password" type="password" minlength="12" autocomplete="off"></label><label class="checkbox"><input name="prefer" type="checkbox" checked>设为这些职责的首选执行者</label><label class="checkbox"><input name="commands_enabled" type="checkbox" checked>允许所有者通过此机器人发送群命令</label><button type="submit">生成接入包</button></form></details>
35
35
  <h3>已分配的机器人</h3><div id="exports"></div><h3>待确认的成员</h3><div id="requests"></div></div>
36
36
  </section>
@@ -37,10 +37,12 @@ export async function dispatchPeerWecom(frame, { service, config, reply }) {
37
37
  const sender = body.from?.userid;
38
38
  const owner = !!sender && sender === service.team.agents[service.agentId]?.wecom_userid;
39
39
  const admin = config.allow_admin_commands === true && !!sender && Object.values(service.team.agents).some((agent) => agent.role === 'admin' && agent.wecom_userid === sender);
40
+ const member = !!sender && Object.values(service.team.agents).some((agent) => agent.role === 'member' && agent.wecom_userid === sender);
40
41
  // Shared owners do not merge agent roles: the receiving bot selects its signer.
41
- // Admins may dispatch/query via a member bot. Review mutations must be signed by
42
- // the human reviewer's own identity, never by impersonating another member.
43
- if (!owner && !(admin && ['create', 'status', 'natural'].includes(command?.action || 'natural'))) return false;
42
+ // Enrolled team members may submit work to any bot; review/control mutations
43
+ // still require the bot owner. Admin dispatch retains its explicit config gate.
44
+ const dispatchOnly = ['create', 'status', 'natural'].includes(command?.action || 'natural');
45
+ if (!owner && !(dispatchOnly && (member || admin))) return false;
44
46
  const key = `wecom:${body.chatid}:${body.msgid}`;
45
47
  let text;
46
48
  try {
@@ -57,7 +59,7 @@ export async function dispatchPeerWecom(frame, { service, config, reply }) {
57
59
  if (config.execution_mode !== 'desktop_poll') {
58
60
  await reply(frame, '自然语言任务需要绑定已有 Codex 对话,并启用 desktop_poll 轮询。'); return true;
59
61
  }
60
- const request = await service.receiveRequest({ chatId: body.chatid, messageId: body.msgid, senderId: sender, content, allowAdmin: config.allow_admin_commands === true });
62
+ const request = await service.receiveRequest({ chatId: body.chatid, messageId: body.msgid, senderId: sender, content, allowAdmin: admin });
61
63
  text = request.state.task_id ? `该消息已创建任务 ${request.state.task_id},不会重复创建。`
62
64
  : request.state.status === 'answered' ? '这条消息已处理。'
63
65
  : request.state.status === 'clarification' ? `需求 ${request.spec.id} 等待你补充说明,请查看群里的追问。`
@@ -138,7 +138,7 @@ sh test.sh 要通过,完成后在群里发总结。
138
138
  已有绑定不需要重新创建身份。先处理正在执行的任务,再对每个 Agent 用其**项目 MCP 实际使用的私有配置路径**操作:
139
139
 
140
140
  ```sh
141
- npm install --global xuym-codex-collab@0.4.1
141
+ npm install --global xuym-codex-collab@0.4.2
142
142
  codex-collab stop /absolute/private-peer.json
143
143
  codex-collab refresh-project /absolute/private-peer.json
144
144
  codex-collab start /absolute/private-peer.json
@@ -30,8 +30,22 @@ codex-collab setup
30
30
 
31
31
  同一位同事可以领取多行分配,用同一个企业微信账号分别验证不同机器人;每份包选择独立 clone,并绑定不同 Codex 对话,不需要关闭群命令权限。
32
32
 
33
+ ### 接入包密码遗失
34
+
35
+ 接入包经过加密;旧版本若没有保存单独生成的密码,不能从密文或旧 JSON 中还原。管理员可为尚未批准的成员重新签发:
36
+
37
+ 1. 先让团队所有成员把工具升级到 `0.4.3` 或以上,并重启 Runner 与 MCP。旧客户端对新签名版本会拒绝继续,不能在同一个团队里混用旧版本。
38
+ 2. 在管理员项目的“已分配的机器人”中,打开对应成员的“查看接入说明与密码”。如果显示密码缺失,点击“重新生成接入包与密码”。
39
+ 3. 页面会预填原 Bot ID;管理员在本机输入该机器人的 Secret。工具保留原 Agent 与职责,生成新加密文件和新密码,并在 Git 签名版本链中撤销旧邀请。Secret 不会发到聊天或写入 Git。
40
+ 4. 通过可信渠道分别交给成员新接入包和新密码。旧文件以及基于旧包创建、尚未批准的申请都不能再使用。
41
+ 5. 成员在“加入团队”选择 Bob 原来的本机 clone,上传新包并输入新密码,点击“读取接入信息”后再接入。工具会在确认签名替换关系后复用该 clone 的本地身份记录,清除旧申请并要求重新验证群身份;管理员随后确认新申请。
42
+
43
+ 该恢复入口仅用于未批准的普通成员邀请。已获批准的成员不能重签,应继续使用已有本机配置;管理员自己的机器人也不使用分发包恢复流程。若 Git 发布中断,半成品不会显示为可下载包;回到原邀请重试生成即可复用同一恢复记录。
44
+
33
45
  显示名称保存在本机和加密接入包中;团队签名和群协议仍用内部 Agent ID。真人通过自己的企业微信账号验证接入,以便核对审核权限;额外通知对象不会因此获得审核权限。
34
46
 
47
+ 机器人应在企业微信后台设为多人可用,并把实际协作成员加入可使用范围。平台允许使用后,工具还会核对发言人的 `userid` 是否属于已批准入队的成员;成员可以 `@` 任意团队机器人提交需求、创建任务或查询状态。审核、取消、完成等操作仍按任务和本人权限限制。未完成团队接入的群成员不能派发任务。
48
+
35
49
  机器人必须由一个本机 Runner 保持连接。同一个 Bot ID 不要同时运行旧服务、群发现命令和新 Runner。向导检查可识别的本机重复连接;其他电脑上的连接需由团队核实。
36
50
 
37
51
  ## 成员操作
@@ -122,6 +136,7 @@ codex-collab setup
122
136
  - 项目接单状态:同时检查 MCP、Skill、认证连接、目标对话绑定和最近三分钟内实际轮询。配置文件存在不等于 Codex 已加载,自动化启用状态仍需在绑定对话确认。
123
137
  - 群验证断线/过期:点击“重新连接”,使用新的验证码。接入进度保存于本机,页面刷新能恢复原验证码;工作台进程重启后临时连接失效,会重新连接。
124
138
  - 管理员群验证尚未完成:从首页“待完成群验证”打开“继续创建团队”,恢复保存的创建资料。
139
+ - 管理员丢失旧接入包密码:按[接入包密码遗失](#接入包密码遗失)重新签发;不要尝试从旧加密文件读取或猜测密码。
125
140
  - Git/MCP 配置中断:从“我的项目”打开原项目,若显示错误,处理后点击“继续接入”;不要反复创建另一身份。
126
141
  - 管理员生成邀请时网络失败:回项目页面刷新已生成接入包,再下载;本人机器人点击“继续接入我的机器人”。工具重试发布同一签名邀请。
127
142
  - 本机工作台关闭:重新运行 `codex-collab setup`;已启动的机器人 Runner 会继续运行。
@@ -13,7 +13,7 @@
13
13
 
14
14
  ## 团队版本
15
15
 
16
- `schema_version: 1` 包含 `version`、`parent_hash`、`team_hash`、v2/v3 `team` 策略、`actor_id`、可选的 `enrollment` 和 Ed25519 `signature`。根的 `parent_hash` 为 null;后续记录指向前一条去掉 signature 后 canonical JSON 的 SHA-256。签名覆盖其余全部字段。
16
+ `schema_version: 1` 包含 `version`、`parent_hash`、`team_hash`、v2/v3 `team` 策略、`actor_id`、可选的 `enrollment`、可选的 `invitation_replacement` 和 Ed25519 `signature`。根的 `parent_hash` 为 null;后续记录指向前一条去掉 signature 后 canonical JSON 的 SHA-256。签名覆盖其余全部字段。
17
17
 
18
18
  客户端通过加密接入包或明确导入的旧可信策略 pin 根 hash。本机保存最高版本与对应 hash,拒绝回滚和从已观察版本分叉。后续版本须由上一策略中的管理员签名;已有成员公钥、角色和 userid 不可偷偷替换。
19
19
 
@@ -44,6 +44,8 @@ Agent ID 与签名公钥保持独立;多个 Agent 可以使用同一个已验
44
44
 
45
45
  成员的私钥在成员本机生成。通过群验证码得到 userid/chatid 后,申请包括成员公钥和这些公开绑定,并由成员密钥与邀请 claim 密钥分别签名。管理员核对后发布新的团队版本;版本的 enrollment 包含对应 invite_id 和 request_id。相同申请重试幂等,另一个身份复用已消费邀请会被拒绝。已经批准的同一身份恢复本机配置不受后续邀请过期影响。
46
46
 
47
+ 密码遗失的未批准邀请可由管理员重新签发:一个独立的 `invitation_replacement` 版本签名记录旧、新 invite UUID,保持 `team_hash` 不变且不能同时批准成员。验证历史时旧邀请立即进入撤销集合;之后不得再以它提交或批准入队请求。恢复包只视为发布完成当邀请和替换版本均已推送。已批准邀请不能按此方式替换。该可选字段由 `team-version.schema.json` 描述;不认识此字段的旧客户端会 fail closed,因此成员必须升级并重启 Runner/MCP 后再做恢复。
48
+
47
49
  管理员在本人第二个 clone 的接入流程通过群验证码证明相同账号后,自动调用同一套签名入队批准协议;新 Agent 仍为 member。普通成员的多个 Agent 仍分别提交申请并由管理员确认。
48
50
 
49
51
  目前版本链用于追加成员、能力路由、审核规则和本机恢复。机器人轮换、密钥轮换、成员撤销与群迁移没有网页操作;不要手改签名文件实现这些动作。多个管理员同时写入时,经本机锁、Git fast-forward push 与重新校验重试处理。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xuym-codex-collab",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "Git-backed collaboration tools for Codex teams, with MCP, WeCom, and local runners.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -9,8 +9,10 @@ const fields = (value, allowed) => value && typeof value === 'object' && !Array.
9
9
  export function validateInboxSpec(spec, team) {
10
10
  if (!fields(spec, ['schema_version', 'id', 'repo', 'target_agent', 'chat_id', 'sender_id', 'content', 'message_key', 'admin_dispatch']) || spec.schema_version !== 1 || !inboxIdPattern.test(spec.id || '') || spec.repo !== team.repo || !text(spec.target_agent, 64) || !/^[a-zA-Z0-9_-]+$/.test(spec.target_agent) || !Object.hasOwn(team.agents, spec.target_agent) || !text(spec.chat_id, 200) || !text(spec.sender_id, 200) || !text(spec.content, 4000) || !text(spec.message_key, 300) || typeof spec.admin_dispatch !== 'boolean' || spec.id !== `N-${digest([spec.target_agent, spec.message_key]).slice(0, 32).toUpperCase()}`) throw new Error('invalid natural-language inbox specification');
11
11
  const owner = team.agents[spec.target_agent].wecom_userid === spec.sender_id;
12
- const admin = spec.admin_dispatch && Object.values(team.agents).some((agent) => agent.role === 'admin' && agent.wecom_userid === spec.sender_id);
13
- if (!owner && !admin) throw new Denied('only the bot owner or an authorized team administrator may submit requests');
12
+ const senders = Object.values(team.agents).filter((agent) => agent.wecom_userid === spec.sender_id);
13
+ const member = senders.some((agent) => agent.role === 'member');
14
+ const admin = spec.admin_dispatch && senders.some((agent) => agent.role === 'admin');
15
+ if (!owner && !member && !admin) throw new Denied('only the bot owner or an enrolled team member may submit requests');
14
16
  }
15
17
 
16
18
  /** Append-only requests: only the receiving agent signs interpretation/replies. */
@@ -22,19 +22,19 @@ export function verifyRecord(record, publicKey) {
22
22
  return body;
23
23
  }
24
24
  export function recordHash(record) { const { signature, ...body } = record; return digest(body); }
25
- export function policyVersion(team, previous, actorId, privateKey, enrollment) {
25
+ export function policyVersion(team, previous, actorId, privateKey, enrollment, invitationReplacement) {
26
26
  validateTeam(team);
27
27
  const value = { schema_version: 1, version: previous ? previous.version + 1 : 1,
28
28
  parent_hash: previous ? recordHash(previous) : null, team_hash: digest(team), team, actor_id: actorId,
29
- ...(enrollment ? { enrollment } : {}) };
29
+ ...(enrollment ? { enrollment } : {}), ...(invitationReplacement ? { invitation_replacement: invitationReplacement } : {}) };
30
30
  return signRecord(value, privateKey);
31
31
  }
32
32
  export function validateHistory(records, rootHash) {
33
33
  if (!Array.isArray(records) || !records.length || !hashPattern.test(rootHash || '') || recordHash(records[0]) !== rootHash) fail();
34
- const teams = new Map();
34
+ const teams = new Map(), revokedInvites = new Set(), replacementTargets = new Set(), consumedInvites = new Set();
35
35
  let previous;
36
36
  for (const record of records) {
37
- fields(record, ['schema_version', 'version', 'parent_hash', 'team_hash', 'team', 'actor_id', 'enrollment', 'signature']);
37
+ fields(record, ['schema_version', 'version', 'parent_hash', 'team_hash', 'team', 'actor_id', 'enrollment', 'invitation_replacement', 'signature']);
38
38
  if (record.schema_version !== 1 || record.version !== (previous?.version || 0) + 1 || record.parent_hash !== (previous ? recordHash(previous) : null)) fail();
39
39
  const team = validateTeam(record.team);
40
40
  const authority = previous?.team || team;
@@ -45,13 +45,25 @@ export function validateHistory(records, rootHash) {
45
45
  if (previous) for (const [id, agent] of Object.entries(authority.agents)) {
46
46
  if (!team.agents[id] || team.agents[id].public_key !== agent.public_key || team.agents[id].role !== agent.role || team.agents[id].wecom_userid !== agent.wecom_userid) fail();
47
47
  }
48
+ if (record.invitation_replacement) {
49
+ const replacement = record.invitation_replacement;
50
+ fields(replacement, ['old_invite_id', 'new_invite_id']);
51
+ const { old_invite_id: oldId, new_invite_id: newId } = replacement;
52
+ if (!previous || record.enrollment || record.team_hash !== previous.team_hash ||
53
+ !uuidPattern.test(oldId || '') || !uuidPattern.test(newId || '') || oldId === newId ||
54
+ revokedInvites.has(oldId) || revokedInvites.has(newId) || replacementTargets.has(newId) ||
55
+ consumedInvites.has(oldId) || consumedInvites.has(newId)) fail();
56
+ revokedInvites.add(oldId); replacementTargets.add(newId);
57
+ }
48
58
  if (record.enrollment) {
59
+ if (revokedInvites.has(record.enrollment.invite_id)) fail();
60
+ consumedInvites.add(record.enrollment.invite_id);
49
61
  fields(record.enrollment, ['invite_id', 'request_id']);
50
62
  if (!uuidPattern.test(record.enrollment.invite_id || '') || !uuidPattern.test(record.enrollment.request_id || '')) fail();
51
63
  }
52
64
  teams.set(record.team_hash, team); previous = record;
53
65
  }
54
- return { records, teams, latest: previous, root_hash: rootHash };
66
+ return { records, teams, latest: previous, root_hash: rootHash, revoked_invites: revokedInvites };
55
67
  }
56
68
  export async function readRegistry(checkout, rootHash) {
57
69
  const dir = join(checkout, registryPath, 'versions');
@@ -76,6 +88,7 @@ export function invitationRecord(input, record, actorId, privateKey) {
76
88
  return signed;
77
89
  }
78
90
  export function validateInvitation(invite, history) {
91
+ if (history.revoked_invites?.has(invite?.invite_id)) fail();
79
92
  fields(invite, ['schema_version', 'invite_id', 'agent_id', 'capabilities', 'bot_id', 'chat_ids', 'commands_enabled', 'prefer', 'claim_public_key', 'expires_at', 'team_hash', 'actor_id', 'signature', 'owner_userid']);
80
93
  const team = history.teams.get(invite.team_hash);
81
94
  if (invite.schema_version !== 1 || !uuidPattern.test(invite.invite_id || '') || !idPattern.test(invite.agent_id || '') || !team || team.agents[invite.actor_id]?.role !== 'admin' ||
@@ -24,8 +24,10 @@ export class PeerService {
24
24
  const key = inboxMessageKey(this.agentId, chatId, messageId);
25
25
  return this.store.transactInbox(key, (requests) => {
26
26
  const owner = this.team.agents[this.agentId]?.wecom_userid === senderId;
27
- const admin = allowAdmin && Object.values(this.team.agents).some((agent) => agent.role === 'admin' && agent.wecom_userid === senderId);
28
- if (!senderId || !owner && !admin) throw new Denied('sender is not authorized for this bot');
27
+ const senderAgents = Object.values(this.team.agents).filter((agent) => agent.wecom_userid === senderId);
28
+ const member = senderAgents.some((agent) => agent.role === 'member');
29
+ const admin = allowAdmin && senderAgents.some((agent) => agent.role === 'admin');
30
+ if (!senderId || !owner && !member && !admin) throw new Denied('sender is not authorized for this bot');
29
31
  const referencedId = content.match(/\bN-[A-F0-9]{32}\b/)?.[0];
30
32
  const mine = requests.filter((request) => request.spec.target_agent === this.agentId && request.spec.chat_id === chatId && request.spec.sender_id === senderId);
31
33
  const waiting = mine.filter((request) => request.state.status === 'clarification');
@@ -41,8 +43,10 @@ export class PeerService {
41
43
  const original = event.type === 'received' ? request.spec.content : event.content;
42
44
  if (request.spec.chat_id !== chatId || request.spec.sender_id !== senderId || original !== content) throw new Conflict('group message ID already used with different content or sender');
43
45
  const owner = this.team.agents[this.agentId]?.wecom_userid === senderId;
44
- const admin = allowAdmin && Object.values(this.team.agents).some((agent) => agent.role === 'admin' && agent.wecom_userid === senderId);
45
- if (!owner && !admin) throw new Denied('sender is not authorized for this bot');
46
+ const senderAgents = Object.values(this.team.agents).filter((agent) => agent.wecom_userid === senderId);
47
+ const member = senderAgents.some((agent) => agent.role === 'member');
48
+ const admin = allowAdmin && senderAgents.some((agent) => agent.role === 'admin');
49
+ if (!owner && !member && !admin) throw new Denied('sender is not authorized for this bot');
46
50
  } });
47
51
  }
48
52
  async resolveRequest(id, expectedSeq, resolution, { defaultWorkflow } = {}) {
@@ -59,5 +59,5 @@
59
59
  "type": "boolean"
60
60
  }
61
61
  },
62
- "$comment": "Runtime also verifies the repo and target against the pinned team, a deterministic request ID derived from target_agent and message_key, and bot-owner or allowed administrator authorization. The original group request is shared in Git; private Codex transcripts, bindings and credentials stay local."
62
+ "$comment": "Runtime also verifies the repo and target against the pinned team, a deterministic request ID derived from target_agent and message_key, and authorization by the bot owner, an enrolled team member, or an explicitly allowed administrator. The original group request is shared in Git; private Codex transcripts, bindings and credentials stay local."
63
63
  }
@@ -64,7 +64,26 @@
64
64
  "signature": {
65
65
  "type": "string",
66
66
  "minLength": 1
67
+ },
68
+ "invitation_replacement": {
69
+ "type": "object",
70
+ "additionalProperties": false,
71
+ "required": [
72
+ "old_invite_id",
73
+ "new_invite_id"
74
+ ],
75
+ "properties": {
76
+ "old_invite_id": {
77
+ "type": "string",
78
+ "pattern": "^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$"
79
+ },
80
+ "new_invite_id": {
81
+ "type": "string",
82
+ "pattern": "^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$"
83
+ }
84
+ },
85
+ "description": "Administrator replaces an unconsumed invitation. Old invitation and enrollment requests become invalid; team policy remains identical."
67
86
  }
68
87
  },
69
- "$comment": "Runtime verifies pinned root hash, contiguous version and parent chain, canonical team_hash, previous-admin signature, immutable existing identities and local anti-rollback checkpoint."
88
+ "$comment": "Runtime verifies pinned root hash, contiguous version and parent chain, canonical team_hash, previous-admin signature, immutable existing identities and local anti-rollback checkpoint. Invitation replacement must keep the team hash unchanged, cannot accompany enrollment, and cannot revoke an approved invitation or reuse a replacement ID. Clients lacking this field fail closed and must upgrade."
70
89
  }