xuym-codex-collab 0.1.0 → 0.3.0

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.
Files changed (43) hide show
  1. package/README.md +61 -110
  2. package/apps/onboarding/bundle.mjs +24 -0
  3. package/apps/onboarding/discovery.mjs +44 -0
  4. package/apps/onboarding/errors.mjs +1 -0
  5. package/apps/onboarding/main.mjs +32 -0
  6. package/apps/onboarding/manager.mjs +405 -0
  7. package/apps/onboarding/server.mjs +56 -0
  8. package/apps/onboarding/web/app.js +128 -0
  9. package/apps/onboarding/web/index.html +13 -0
  10. package/apps/onboarding/web/style.css +3 -0
  11. package/apps/peer/desktop.mjs +8 -6
  12. package/apps/peer/mcp.mjs +1 -1
  13. package/apps/peer/runner.mjs +5 -5
  14. package/apps/peer/runtime.mjs +4 -1
  15. package/apps/peer/wecom.mjs +70 -21
  16. package/bin/codex-collab.mjs +10 -7
  17. package/docs/architecture.md +3 -1
  18. package/docs/desktop-poll.md +15 -7
  19. package/docs/legacy-center.md +109 -0
  20. package/docs/onboarding.md +74 -72
  21. package/docs/peer.md +12 -6
  22. package/docs/protocol.md +2 -0
  23. package/docs/team-registry.md +55 -0
  24. package/docs/terminal-onboarding.md +98 -0
  25. package/package.json +6 -3
  26. package/packages/codex-kit/templates/.agents/skills/team-collab-desktop/SKILL.md +3 -3
  27. package/packages/peer/config.mjs +4 -1
  28. package/packages/peer/protocol.mjs +22 -7
  29. package/packages/peer/registry.mjs +130 -0
  30. package/packages/peer/review.mjs +51 -0
  31. package/packages/peer/service.mjs +24 -16
  32. package/packages/peer/store.mjs +50 -5
  33. package/schemas/encrypted-invite.schema.json +42 -0
  34. package/schemas/event.schema.json +51 -0
  35. package/schemas/peer-event.schema.json +44 -0
  36. package/schemas/peer-task.schema.json +21 -0
  37. package/schemas/task.schema.json +18 -0
  38. package/schemas/team-enrollment.schema.json +61 -0
  39. package/schemas/team-invitation.schema.json +86 -0
  40. package/schemas/team-policy.schema.json +182 -0
  41. package/schemas/team-version.schema.json +70 -0
  42. package/scripts/peer/cli.mjs +5 -2
  43. package/scripts/peer/onboarding.mjs +3 -2
@@ -0,0 +1,130 @@
1
+ import { createPublicKey, sign, verify } from 'node:crypto';
2
+ import { readFile, readdir } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { defaultReviewRules } from './review.mjs';
5
+ import { canonical, digest, validateTeam } from './config.mjs';
6
+
7
+ export const registryPath = '.agent-collab/team';
8
+ const idPattern = /^[a-zA-Z0-9_-]{1,64}$/;
9
+ const uuidPattern = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
10
+ const hashPattern = /^[a-f0-9]{64}$/;
11
+ const fail = () => { throw new Error('Team registry signature, history or enrollment is invalid.'); };
12
+ function fields(value, allowed) {
13
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some((key) => !allowed.includes(key))) fail();
14
+ }
15
+ export function signRecord(value, privateKey) {
16
+ return { ...value, signature: sign(null, Buffer.from(canonical(value)), privateKey).toString('base64') };
17
+ }
18
+ export function verifyRecord(record, publicKey) {
19
+ const { signature, ...body } = record || {};
20
+ try { if (typeof signature !== 'string' || !verify(null, Buffer.from(canonical(body)), publicKey, Buffer.from(signature, 'base64'))) fail(); }
21
+ catch { fail(); }
22
+ return body;
23
+ }
24
+ export function recordHash(record) { const { signature, ...body } = record; return digest(body); }
25
+ export function policyVersion(team, previous, actorId, privateKey, enrollment) {
26
+ validateTeam(team);
27
+ const value = { schema_version: 1, version: previous ? previous.version + 1 : 1,
28
+ parent_hash: previous ? recordHash(previous) : null, team_hash: digest(team), team, actor_id: actorId,
29
+ ...(enrollment ? { enrollment } : {}) };
30
+ return signRecord(value, privateKey);
31
+ }
32
+ export function validateHistory(records, rootHash) {
33
+ if (!Array.isArray(records) || !records.length || !hashPattern.test(rootHash || '') || recordHash(records[0]) !== rootHash) fail();
34
+ const teams = new Map();
35
+ let previous;
36
+ for (const record of records) {
37
+ fields(record, ['schema_version', 'version', 'parent_hash', 'team_hash', 'team', 'actor_id', 'enrollment', 'signature']);
38
+ if (record.schema_version !== 1 || record.version !== (previous?.version || 0) + 1 || record.parent_hash !== (previous ? recordHash(previous) : null)) fail();
39
+ const team = validateTeam(record.team);
40
+ const authority = previous?.team || team;
41
+ if (authority.agents[record.actor_id]?.role !== 'admin' || digest(team) !== record.team_hash || previous && team.repo !== authority.repo) fail();
42
+ verifyRecord(record, authority.agents[record.actor_id].public_key);
43
+ // Existing keys/roles remain stable; old tasks continue to use old routes.
44
+ if (previous && team.schema_version < authority.schema_version) fail();
45
+ if (previous) for (const [id, agent] of Object.entries(authority.agents)) {
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
+ }
48
+ if (record.enrollment) {
49
+ fields(record.enrollment, ['invite_id', 'request_id']);
50
+ if (!uuidPattern.test(record.enrollment.invite_id || '') || !uuidPattern.test(record.enrollment.request_id || '')) fail();
51
+ }
52
+ teams.set(record.team_hash, team); previous = record;
53
+ }
54
+ return { records, teams, latest: previous, root_hash: rootHash };
55
+ }
56
+ export async function readRegistry(checkout, rootHash) {
57
+ const dir = join(checkout, registryPath, 'versions');
58
+ let files;
59
+ try { files = (await readdir(dir)).sort(); }
60
+ catch (error) { if (error.code === 'ENOENT' && !rootHash) return null; throw error; }
61
+ if (!files.length || files.some((file, index) => file !== `${String(index + 1).padStart(6, '0')}.json`)) fail();
62
+ const records = await Promise.all(files.map(async (file) => JSON.parse(await readFile(join(dir, file), 'utf8'))));
63
+ // Legacy peers must explicitly import/adopt a registry before trusting changes.
64
+ if (!rootHash) return null;
65
+ return validateHistory(records, rootHash);
66
+ }
67
+ export function invitationRecord(input, record, actorId, privateKey) {
68
+ const invite = { schema_version: 1, invite_id: input.invite_id, agent_id: input.agent_id,
69
+ capabilities: input.capabilities, bot_id: input.bot_id, chat_ids: input.chat_ids,
70
+ commands_enabled: input.commands_enabled, prefer: input.prefer,
71
+ claim_public_key: input.claim_public_key, expires_at: input.expires_at,
72
+ team_hash: record.team_hash, actor_id: actorId };
73
+ const signed = signRecord(invite, privateKey);
74
+ validateInvitation(signed, { teams: new Map([[record.team_hash, record.team]]) });
75
+ return signed;
76
+ }
77
+ export function validateInvitation(invite, history) {
78
+ 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']);
79
+ const team = history.teams.get(invite.team_hash);
80
+ if (invite.schema_version !== 1 || !uuidPattern.test(invite.invite_id || '') || !idPattern.test(invite.agent_id || '') || !team || team.agents[invite.actor_id]?.role !== 'admin' ||
81
+ !Array.isArray(invite.capabilities) || !invite.capabilities.length || invite.capabilities.length > 16 || invite.capabilities.some((cap) => !idPattern.test(cap)) || new Set(invite.capabilities).size !== invite.capabilities.length ||
82
+ typeof invite.bot_id !== 'string' || !invite.bot_id || invite.bot_id.length > 200 ||
83
+ !Array.isArray(invite.chat_ids) || !invite.chat_ids.length || invite.chat_ids.some((id) => typeof id !== 'string' || !id || id.length > 200) ||
84
+ typeof invite.commands_enabled !== 'boolean' || typeof invite.prefer !== 'boolean' || !Number.isFinite(Date.parse(invite.expires_at))) fail();
85
+ try { if (createPublicKey(invite.claim_public_key).asymmetricKeyType !== 'ed25519') fail(); } catch { fail(); }
86
+ verifyRecord(invite, team.agents[invite.actor_id].public_key);
87
+ return invite;
88
+ }
89
+ export function enrollmentRequest({ invite, publicKey, userid, chatid, requestId, privateKey, claimPrivateKey }) {
90
+ const body = { schema_version: 1, request_id: requestId, invite_id: invite.invite_id, agent_id: invite.agent_id,
91
+ public_key: publicKey, wecom_userid: invite.commands_enabled ? userid : null, chat_id: chatid };
92
+ const member = signRecord(body, privateKey);
93
+ return { ...member, invitation_signature: sign(null, Buffer.from(canonical(body)), claimPrivateKey).toString('base64') };
94
+ }
95
+ export function validateEnrollment(request, invite, history, { now = Date.now(), allowExpired = false } = {}) {
96
+ validateInvitation(invite, history);
97
+ fields(request, ['schema_version', 'request_id', 'invite_id', 'agent_id', 'public_key', 'wecom_userid', 'chat_id', 'signature', 'invitation_signature']);
98
+ if (request.schema_version !== 1 || !uuidPattern.test(request.request_id || '') || request.invite_id !== invite.invite_id || request.agent_id !== invite.agent_id ||
99
+ !invite.chat_ids.includes(request.chat_id) || (invite.commands_enabled ? typeof request.wecom_userid !== 'string' || !request.wecom_userid || request.wecom_userid.length > 200 : request.wecom_userid !== null) ||
100
+ !allowExpired && now > Date.parse(invite.expires_at)) fail();
101
+ const { invitation_signature, ...member } = request;
102
+ try { if (createPublicKey(request.public_key).asymmetricKeyType !== 'ed25519') fail(); } catch { fail(); }
103
+ const body = verifyRecord(member, request.public_key);
104
+ try { if (typeof invitation_signature !== 'string' || !verify(null, Buffer.from(canonical(body)), invite.claim_public_key, Buffer.from(invitation_signature, 'base64'))) fail(); }
105
+ catch { fail(); }
106
+ return request;
107
+ }
108
+ export function approveEnrollment(request, invite, history, actorId, privateKey) {
109
+ const consumed = history.records.find((version) => version.enrollment?.invite_id === invite.invite_id);
110
+ validateEnrollment(request, invite, history, { allowExpired: !!consumed });
111
+ const previous = history.latest;
112
+ if (previous.team.agents[actorId]?.role !== 'admin') fail();
113
+ if (history.records.some((version) => version.enrollment?.invite_id === invite.invite_id)) {
114
+ if (!history.records.some((version) => version.enrollment?.invite_id === invite.invite_id && version.enrollment.request_id === request.request_id) || previous.team.agents[request.agent_id]?.public_key !== request.public_key) fail();
115
+ return null;
116
+ }
117
+ if (previous.team.agents[request.agent_id]) fail();
118
+ const team = structuredClone(previous.team);
119
+ team.agents[request.agent_id] = { role: 'member', capabilities: invite.capabilities, max_concurrent_tasks: 1, public_key: request.public_key,
120
+ ...(request.wecom_userid ? { wecom_userid: request.wecom_userid } : {}) };
121
+ for (const capability of invite.capabilities) {
122
+ const candidates = team.routes[capability] || [];
123
+ team.routes[capability] = invite.prefer ? [request.agent_id, ...candidates] : [...candidates, request.agent_id];
124
+ if (team.schema_version === 3 && !team.review_rules[capability]) team.review_rules[capability] = defaultReviewRules({ [capability]: [] })[capability];
125
+ }
126
+ return policyVersion(team, previous, actorId, privateKey, { invite_id: invite.invite_id, request_id: request.request_id });
127
+ }
128
+ export const versionFile = (record) => `${registryPath}/versions/${String(record.version).padStart(6, '0')}.json`;
129
+ export const invitationFile = (id) => { if (!uuidPattern.test(id || '')) fail(); return `${registryPath}/invitations/${id}.json`; };
130
+ export const requestFile = (id) => { if (!uuidPattern.test(id || '')) fail(); return `${registryPath}/requests/${id}.json`; };
@@ -0,0 +1,51 @@
1
+ const memberSelector = /^member:([a-zA-Z0-9_-]{1,64})$/;
2
+
3
+ export function validateReviewRules(team) {
4
+ if (team.review_rules === undefined) return;
5
+ if (!team.review_rules || typeof team.review_rules !== 'object' || Array.isArray(team.review_rules)) throw new Error('invalid human review rules');
6
+ if (Object.keys(team.routes).some((route) => !Object.hasOwn(team.review_rules, route))) throw new Error('human review rules must cover every route');
7
+ for (const [route, rule] of Object.entries(team.review_rules)) {
8
+ if (!team.routes[route] || !rule || typeof rule !== 'object' || Array.isArray(rule) || Object.keys(rule).some((key) => !['required', 'reviewer', 'notify'].includes(key)) || typeof rule.required !== 'boolean') throw new Error('invalid human review rule');
9
+ for (const field of ['reviewer', 'notify']) {
10
+ const value = rule[field];
11
+ const reserved = field === 'reviewer' ? ['owner', 'admin'] : ['owner', 'reviewer', 'none'];
12
+ if (reserved.includes(value)) continue;
13
+ const member = typeof value === 'string' && memberSelector.exec(value)?.[1];
14
+ if (!member || !team.agents[member]?.wecom_userid) throw new Error('review/notification member needs a verified WeCom account');
15
+ }
16
+ }
17
+ }
18
+
19
+ export function stageReviewRule(team, route) {
20
+ return team.review_rules?.[route] || { required: false, reviewer: 'admin', notify: 'owner' };
21
+ }
22
+
23
+ export function resolveHuman(selector, executorId, team, reviewerId = team.reviewer) {
24
+ if (selector === 'none') return null;
25
+ if (selector === 'reviewer') return reviewerId;
26
+ if (selector === 'admin') return team.reviewer;
27
+ // Notification-only identities cannot send approval commands. Route their review
28
+ // to the administrator, including the one-human/two-bot local simulation.
29
+ if (selector === 'owner') return team.agents[executorId]?.wecom_userid ? executorId : team.reviewer;
30
+ return memberSelector.exec(selector || '')?.[1] || null;
31
+ }
32
+
33
+ export function stageReviewer(team, route, executorId) {
34
+ const rule = stageReviewRule(team, route);
35
+ return rule.required ? resolveHuman(rule.reviewer, executorId, team) : team.reviewer;
36
+ }
37
+
38
+ export function reviewNotificationMembers(task, team) {
39
+ const event = task.events.at(-1);
40
+ const waiting = ['stage_review', 'review'].includes(task.state.status);
41
+ if (!['handoff', 'stage_review_requested', 'review_requested'].includes(event?.type)) return [];
42
+ const completedStage = event.type === 'handoff' ? task.state.stage - 1 : task.state.stage;
43
+ const rule = stageReviewRule(team, task.spec.workflow[completedStage]);
44
+ const reviewer = waiting ? task.state.assignee : stageReviewer(team, task.spec.workflow[completedStage], event.actor_id);
45
+ const notified = resolveHuman(rule.notify, event.actor_id, team, reviewer);
46
+ return [...new Set([notified, ...(waiting ? [reviewer] : [])].filter((id) => id && team.agents[id]?.wecom_userid))];
47
+ }
48
+
49
+ export function defaultReviewRules(routes) {
50
+ return Object.fromEntries(Object.keys(routes).map((route) => [route, { required: true, reviewer: 'owner', notify: 'owner' }]));
51
+ }
@@ -1,27 +1,32 @@
1
+ import { stageReviewRule } from './review.mjs';
1
2
  import { randomUUID } from 'node:crypto';
2
3
  import { digest } from './config.mjs';
3
4
  import { canClaimDesktop, Conflict, Denied, eligible, leaseToken, validRefs } from './protocol.mjs';
4
5
  import { git } from './store.mjs';
5
6
  export class PeerService {
6
- constructor(store, { notify = async () => {} } = {}) { this.store = store; this.team = store.team; this.agentId = store.agentId; this.notify = notify; }
7
+ constructor(store, { notify = async () => {} } = {}) { this.store = store; this.agentId = store.agentId; this.notify = notify; }
8
+ get team() { return this.store.team; }
7
9
  list() { return this.store.list(); }
8
10
  read(id) { return this.store.read(id); }
9
11
  async publish(task, type) { if (task) { try { await this.notify(task, type); } catch { console.error('peer notification failed; Git task event is committed'); } } return task; }
10
12
  async create(input, idempotencyKey = randomUUID()) {
11
13
  const id = input.id || `T-${digest(`${this.agentId}:${idempotencyKey}`).slice(0, 32).toUpperCase()}`;
12
- const spec = { schema_version: 2, id, repo: this.team.repo, title: input.title, description: input.description || '', base_branch: 'main', acceptance: input.acceptance,
14
+ const result = await this.store.transact(id, idempotencyKey, (current) => {
15
+ if (current) throw new Conflict('task already exists');
16
+ const spec = { schema_version: 2, id, repo: this.team.repo, title: input.title, description: input.description || '', base_branch: 'main', acceptance: input.acceptance,
13
17
  workflow: input.workflow || [this.team.default_route], execution_mode: input.execution_mode || 'automatic', ...(input.chatId ? { source: { channel: 'wecom', chat_id: input.chatId } } : {}) };
14
- const result = await this.store.transact(id, idempotencyKey, (current) => { if (current) throw new Conflict('task already exists'); return { spec, event: { type: 'created' } }; });
18
+ return { spec, event: { type: 'created' } };
19
+ });
15
20
  return this.publish(result, 'created');
16
21
  }
17
22
  async nextJob({ executionMode = 'runner' } = {}) {
18
23
  const tasks = await this.list(); const now = this.store.clock();
19
- return tasks.find((task) => (executionMode === 'runner' ? task.spec.execution_mode !== 'desktop_poll' : task.spec.execution_mode === executionMode) && eligible(task, this.agentId, tasks, this.team, now)) || null;
24
+ return tasks.find((task) => (executionMode === 'runner' ? task.spec.execution_mode !== 'desktop_poll' : task.spec.execution_mode === executionMode) && eligible(task, this.agentId, tasks, task.team || this.team, now)) || null;
20
25
  }
21
26
  async claim(id, idempotencyKey = randomUUID(), { executionMode = 'runner' } = {}) {
22
27
  const result = await this.store.transact(id, idempotencyKey, (current, tasks, now) => {
23
- if (!current || (executionMode === 'runner' ? current.spec.execution_mode === 'desktop_poll' : current.spec.execution_mode !== executionMode) || !eligible(current, this.agentId, tasks, this.team, now)) throw new Conflict('task not eligible or capacity exhausted');
24
- return { event: { type: 'claimed', epoch: current.state.epoch + 1, lease_id: randomUUID(), expires_at: new Date(now + this.team.policy.lease_ms).toISOString() } };
28
+ if (!current || (executionMode === 'runner' ? current.spec.execution_mode === 'desktop_poll' : current.spec.execution_mode !== executionMode) || !eligible(current, this.agentId, tasks, current.team || this.team, now)) throw new Conflict('task not eligible or capacity exhausted');
29
+ return { event: { type: 'claimed', epoch: current.state.epoch + 1, lease_id: randomUUID(), expires_at: new Date(now + (current.team || this.team).policy.lease_ms).toISOString() } };
25
30
  });
26
31
  return this.publish(result, result.state.epoch > 1 ? 'taken_over' : 'claimed');
27
32
  }
@@ -29,7 +34,7 @@ export class PeerService {
29
34
  if (!Number.isSafeInteger(expectedSeq) || expectedSeq < 1) throw new Conflict('latest expected_seq required');
30
35
  const result = await this.store.transact(id, idempotencyKey, (task, tasks, now) => {
31
36
  if (!task || task.state.seq !== expectedSeq) throw new Conflict('stale task revision');
32
- if (!canClaimDesktop(task, this.agentId, tasks, this.team, now)) throw new Conflict('desktop handoff not eligible or capacity exhausted');
37
+ if (!canClaimDesktop(task, this.agentId, tasks, task.team || this.team, now)) throw new Conflict('desktop handoff not eligible or capacity exhausted');
33
38
  return { event: { type: 'desktop_claimed' } };
34
39
  });
35
40
  return this.publish(result, 'desktop_claimed');
@@ -41,7 +46,7 @@ export class PeerService {
41
46
  renew(id, token) {
42
47
  return this.store.transact(id, randomUUID(), (task, _tasks, now) => {
43
48
  this.checkLease(task, token, now);
44
- return { event: { type: 'renewed', ...token, expires_at: new Date(now + this.team.policy.lease_ms).toISOString() } };
49
+ return { event: { type: 'renewed', ...token, expires_at: new Date(now + (task.team || this.team).policy.lease_ms).toISOString() } };
45
50
  });
46
51
  }
47
52
  async verifyRefs(refs) {
@@ -53,29 +58,32 @@ export class PeerService {
53
58
  if (tip !== refs.commit.toLowerCase()) throw new Conflict('code commit must be the shared branch tip');
54
59
  }
55
60
  async report(id, payload, token = {}, idempotencyKey = randomUUID(), expectedSeq) {
56
- const allowed = ['progress', 'checkpoint', 'handoff', 'review_requested', 'run_failed', 'blocked', 'reviewed', 'completed', 'cancelled', 'reopened'];
61
+ const allowed = ['progress', 'checkpoint', 'handoff', 'stage_review_requested', 'review_requested', 'run_failed', 'blocked', 'reviewed', 'completed', 'cancelled', 'reopened'];
57
62
  if (!allowed.includes(payload.type)) throw new Error('unsupported peer report');
58
63
  const result = await this.store.transact(id, idempotencyKey, async (task, _tasks, now) => {
59
64
  if (!task) throw new Error('unknown task');
60
- if (expectedSeq !== undefined && expectedSeq !== task.state.seq) throw new Conflict('stale task revision');
61
- const desktop = ['handoff', 'checkpoint', 'review_requested'].includes(payload.type) && task.state.status === 'desktop' && task.state.assignee === this.agentId;
65
+ if (expectedSeq !== undefined && (!Number.isSafeInteger(expectedSeq) || expectedSeq !== task.state.seq)) throw new Conflict('stale task revision');
66
+ const desktop = ['handoff', 'checkpoint', 'stage_review_requested', 'review_requested'].includes(payload.type) && task.state.status === 'desktop' && task.state.assignee === this.agentId;
62
67
  const human = ['reviewed', 'completed', 'cancelled', 'reopened'].includes(payload.type);
63
68
  if (!human && !desktop) this.checkLease(task, token, now);
64
- if (['reviewed', 'completed', 'cancelled', 'reopened'].includes(payload.type) && this.team.agents[this.agentId].role !== 'admin') throw new Denied('human administrator approval required');
65
- if (['checkpoint', 'handoff', 'review_requested'].includes(payload.type)) {
69
+ if (payload.type === 'reviewed' && this.agentId !== task.state.assignee) throw new Denied('only the pinned human reviewer may decide; human administrator approval required for legacy tasks');
70
+ if (payload.type === 'reviewed' && (task.team || this.team).review_rules && expectedSeq === undefined) throw new Conflict('review requires the R-sequence from the current notification');
71
+ if (['completed', 'cancelled', 'reopened'].includes(payload.type) && (task.team || this.team).agents[this.agentId]?.role !== 'admin') throw new Denied('human administrator approval required');
72
+ if (['checkpoint', 'handoff', 'stage_review_requested', 'review_requested'].includes(payload.type)) {
66
73
  await this.verifyRefs(payload.refs);
67
74
  if (task.state.checkpoint) {
68
75
  try { await git(this.store.checkout, 'merge-base', '--is-ancestor', task.state.checkpoint.commit, payload.refs.commit); }
69
76
  catch { throw new Conflict('code must descend from the pinned handoff commit'); }
70
77
  }
71
78
  }
72
- const event = { type: payload.type, ...(!desktop && !human ? token : {}) };
79
+ const type = payload.type === 'handoff' && stageReviewRule(task.team || this.team, task.spec.workflow[task.state.stage]).required ? 'stage_review_requested' : payload.type;
80
+ const event = { type, ...(!desktop && !human ? token : {}) };
73
81
  for (const field of ['summary', 'decision', 'refs']) if (payload[field] !== undefined) event[field] = payload[field];
74
- if (payload.type === 'handoff') event.next_stage = task.state.stage + 1;
82
+ if (['handoff', 'stage_review_requested'].includes(type)) event.next_stage = task.state.stage + 1;
75
83
  if (payload.type === 'completed') event.refs = task.state.checkpoint;
76
84
  return { event };
77
85
  });
78
- return this.publish(result, payload.type);
86
+ return this.publish(result, result.events.at(-1).type);
79
87
  }
80
88
  async handoff(id, payload, expectedSeq, key) { const task = await this.read(id); if (task.spec.execution_mode === 'desktop_poll') throw new Denied('use the bound desktop finish tool'); return this.report(id, { type: 'handoff', ...payload }, leaseToken(task), key, expectedSeq); }
81
89
  async requestReview(id, payload, expectedSeq, key) { const task = await this.read(id); if (task.spec.execution_mode === 'desktop_poll') throw new Denied('use the bound desktop finish tool'); return this.report(id, { type: 'review_requested', ...payload }, leaseToken(task), key, expectedSeq); }
@@ -5,6 +5,7 @@ import { join, resolve } from 'node:path';
5
5
  import { randomUUID } from 'node:crypto';
6
6
  import { digest, makeSigner, validateTeam } from './config.mjs';
7
7
  import { reducePeerTask, taskIdPattern } from './protocol.mjs';
8
+ import { readRegistry, recordHash, registryPath } from './registry.mjs';
8
9
  const exec = promisify(execFile);
9
10
  const pause = (ms) => new Promise((r) => setTimeout(r, ms));
10
11
  export async function git(cwd, ...args) {
@@ -14,9 +15,10 @@ export async function git(cwd, ...args) {
14
15
 
15
16
  /** Disposable ledger clone, cross-process local lock, remote fast-forward CAS and signed replay. */
16
17
  export class PeerGitStore {
17
- constructor({ repo, dataDir, team, agentId, privateKey, clock = Date.now, maxRetries = 8 }) {
18
+ constructor({ repo, dataDir, team, agentId, privateKey, clock = Date.now, maxRetries = 8, registryRoot, enrollmentOnly = false }) {
18
19
  this.repo = resolve(repo); this.dataDir = resolve(dataDir); this.checkout = join(this.dataDir, 'peer-ledger');
19
- this.team = validateTeam(team); this.agentId = agentId; this.sign = makeSigner(team, agentId, privateKey);
20
+ this.team = validateTeam(team); this.agentId = agentId; this.enrollmentOnly = enrollmentOnly; this.sign = enrollmentOnly ? null : makeSigner(team, agentId, privateKey);
21
+ this.privateKey = privateKey; this.registryRoot = registryRoot; this.registry = null;
20
22
  this.clock = clock; this.maxRetries = maxRetries; this.push = true;
21
23
  }
22
24
  async locked(fn) {
@@ -63,6 +65,23 @@ export class PeerGitStore {
63
65
  await git(this.checkout, 'checkout', '-B', 'agent-collab', 'refs/remotes/origin/agent-collab');
64
66
  await git(this.checkout, 'reset', '--hard', 'refs/remotes/origin/agent-collab');
65
67
  await git(this.checkout, 'clean', '-fd');
68
+ if (this.registryRoot) {
69
+ const registry = await readRegistry(this.checkout, this.registryRoot);
70
+ const checkpointPath = join(this.dataDir, 'team-checkpoint.json');
71
+ let checkpoint;
72
+ try { checkpoint = JSON.parse(await readFile(checkpointPath, 'utf8')); }
73
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
74
+ if (checkpoint && (registry.records.length < checkpoint.version || recordHash(registry.records[checkpoint.version - 1]) !== checkpoint.hash)) throw new Error('team registry rolled back or forked; refusing to trust it');
75
+ if (!this.enrollmentOnly) {
76
+ if (!registry.latest.team.agents[this.agentId]) throw new Error('member is not yet approved in the team registry');
77
+ this.sign = makeSigner(registry.latest.team, this.agentId, this.privateKey);
78
+ }
79
+ this.team = registry.latest.team; this.registry = registry;
80
+ const temporary = `${checkpointPath}.${process.pid}.tmp`;
81
+ await writeFile(temporary, JSON.stringify({ version: registry.latest.version, hash: recordHash(registry.latest) }) + '\n', { mode: 0o600 });
82
+ const { rename } = await import('node:fs/promises');
83
+ await rename(temporary, checkpointPath);
84
+ }
66
85
  }
67
86
  root(id) {
68
87
  if (!taskIdPattern.test(id ?? '')) throw new Error('invalid peer task ID');
@@ -74,7 +93,9 @@ export class PeerGitStore {
74
93
  const files = (await readdir(join(root, 'events'))).sort();
75
94
  if (files.some((f) => !/^\d{6}-[a-f0-9-]{36}\.json$/.test(f))) throw new Error('unexpected peer event file');
76
95
  const events = await Promise.all(files.map(async (file) => JSON.parse(await readFile(join(root, 'events', file), 'utf8'))));
77
- return { spec, events, state: reducePeerTask(spec, events, this.team) };
96
+ const team = this.registry ? this.registry.teams.get(events[0]?.team_hash) : this.team;
97
+ if (!team) throw new Error('task references an untrusted team policy');
98
+ return { spec, events, state: reducePeerTask(spec, events, team), ...(this.registry ? { team } : {}) };
78
99
  }
79
100
  async listLocal() {
80
101
  let entries;
@@ -85,7 +106,29 @@ export class PeerGitStore {
85
106
  }
86
107
  async list() { return this.locked(async () => { await this.sync(); return this.listLocal(); }); }
87
108
  async read(id) { return this.locked(async () => { await this.sync(); return this.readLocal(id); }); }
109
+ async registryTransaction(build) {
110
+ return this.locked(async () => {
111
+ for (let attempt = 0; attempt < this.maxRetries; attempt++) {
112
+ await this.sync();
113
+ const result = await build(this.registry);
114
+ if (!result.files?.length) return result.value;
115
+ for (const [path, value] of result.files) {
116
+ if (!new RegExp(`^${registryPath.replace('.', '\\.')}/(?:versions/\\d{6}|(?:invitations|requests)/[a-f0-9-]{36})\\.json$`).test(path)) throw new Error('unsupported team registry file');
117
+ const target = join(this.checkout, path);
118
+ await mkdir(join(target, '..'), { recursive: true });
119
+ await writeFile(target, JSON.stringify(value, null, 2) + '\n', { flag: 'wx' });
120
+ await git(this.checkout, 'add', '--', path);
121
+ }
122
+ await git(this.checkout, 'commit', '-m', 'collab: update signed team enrollment');
123
+ try { await git(this.checkout, 'push', 'origin', 'HEAD:refs/heads/agent-collab'); }
124
+ catch { await pause(20 * (attempt + 1)); continue; }
125
+ await this.sync(); return result.value;
126
+ }
127
+ throw new Error('team enrollment not accepted; check Git access and retry');
128
+ });
129
+ }
88
130
  async transact(id, idempotencyKey, build) {
131
+ if (this.enrollmentOnly) throw new Error('enrollment client cannot write tasks');
89
132
  if (typeof idempotencyKey !== 'string' || !idempotencyKey || idempotencyKey.length > 300) throw new Error('invalid idempotency key');
90
133
  return this.locked(async () => {
91
134
  for (let attempt = 0; attempt < this.maxRetries; attempt++) {
@@ -102,10 +145,12 @@ export class PeerGitStore {
102
145
  if (!built) return null;
103
146
  const spec = current?.spec || built.spec;
104
147
  if (!spec || spec.id !== id) throw new Error('transaction task mismatch');
105
- const event = this.sign({ schema_version: 2, task_id: id, seq: (current?.state.seq || 0) + 1,
148
+ const taskTeam = current?.team || this.team;
149
+ const sign = makeSigner(taskTeam, this.agentId, this.privateKey);
150
+ const event = sign({ schema_version: 2, task_id: id, seq: (current?.state.seq || 0) + 1,
106
151
  event_id: randomUUID(), actor_id: this.agentId, occurred_at: new Date(now).toISOString(), idempotency_key: idempotencyKey,
107
152
  ...built.event, ...(current ? {} : { spec_hash: digest(spec) }) });
108
- reducePeerTask(spec, [...(current?.events || []), event], this.team);
153
+ reducePeerTask(spec, [...(current?.events || []), event], taskTeam);
109
154
  const root = this.root(id); await mkdir(join(root, 'events'), { recursive: true });
110
155
  if (!current) await writeFile(join(root, 'spec.json'), JSON.stringify(spec, null, 2) + '\n', { flag: 'wx' });
111
156
  await writeFile(join(root, 'events', `${String(event.seq).padStart(6, '0')}-${event.event_id}.json`), JSON.stringify(event, null, 2) + '\n', { flag: 'wx' });
@@ -0,0 +1,42 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "urn:codex-collab:encrypted-invite:v1",
4
+ "title": "Codex Collab Encrypted Private Onboarding Package v1",
5
+ "type": "object",
6
+ "additionalProperties": true,
7
+ "required": [
8
+ "schema_version",
9
+ "kind",
10
+ "salt",
11
+ "iv",
12
+ "tag",
13
+ "ciphertext"
14
+ ],
15
+ "properties": {
16
+ "schema_version": {
17
+ "const": 1
18
+ },
19
+ "kind": {
20
+ "const": "codex-collab-encrypted-invite"
21
+ },
22
+ "salt": {
23
+ "type": "string",
24
+ "contentEncoding": "base64"
25
+ },
26
+ "iv": {
27
+ "type": "string",
28
+ "contentEncoding": "base64"
29
+ },
30
+ "tag": {
31
+ "type": "string",
32
+ "contentEncoding": "base64"
33
+ },
34
+ "ciphertext": {
35
+ "type": "string",
36
+ "minLength": 1,
37
+ "maxLength": 500000,
38
+ "contentEncoding": "base64"
39
+ }
40
+ },
41
+ "$comment": "This file is private, not a Git ledger record. Runtime validates decoded salt=16 bytes, IV=12 bytes and tag=16 bytes; scrypt derives AES-256-GCM key from a separate password, then verifies signed history and invitation in the decrypted payload."
42
+ }
@@ -0,0 +1,51 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "urn:codex-collab:event:v1",
4
+ "title": "Codex Collab Event v1",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["schema_version", "event_id", "task_id", "seq", "type", "actor_id", "occurred_at", "idempotency_key"],
8
+ "allOf": [{
9
+ "if": { "properties": { "type": { "const": "handoff" } }, "required": ["type"] },
10
+ "then": {
11
+ "required": ["summary", "refs"],
12
+ "properties": {
13
+ "summary": { "type": "string", "minLength": 1, "maxLength": 4000 },
14
+ "refs": {
15
+ "type": "object", "required": ["branch", "commit", "tests"],
16
+ "properties": {
17
+ "branch": { "type": "string", "pattern": "^codex/[A-Za-z0-9][A-Za-z0-9._/-]{0,150}$" },
18
+ "commit": { "type": "string", "pattern": "^[a-fA-F0-9]{40}([a-fA-F0-9]{24})?$" },
19
+ "tests": { "type": "string", "minLength": 1, "maxLength": 1000 }
20
+ }
21
+ }
22
+ }
23
+ }
24
+ }],
25
+ "properties": {
26
+ "schema_version": { "const": 1 },
27
+ "event_id": { "type": "string", "minLength": 1 },
28
+ "task_id": { "type": "string", "pattern": "^[A-Z][A-Z0-9-]{1,63}$" },
29
+ "seq": { "type": "integer", "minimum": 1 },
30
+ "type": { "enum": ["created", "assigned", "run_started", "progress", "blocked", "run_failed", "handoff", "review_requested", "review_started", "review_note", "reviewed", "completed", "cancelled"] },
31
+ "actor_id": { "type": "string", "minLength": 1 },
32
+ "occurred_at": { "type": "string", "format": "date-time" },
33
+ "idempotency_key": { "type": "string", "minLength": 1 },
34
+ "summary": { "type": "string", "maxLength": 4000 },
35
+ "assignee": { "type": "string", "minLength": 1 },
36
+ "chat_id": { "type": "string", "minLength": 1 },
37
+ "channel": { "enum": ["feishu", "wecom"] },
38
+ "run_id": { "type": "string", "minLength": 1 },
39
+ "decision": { "enum": ["approved", "changes_requested"] },
40
+ "refs": {
41
+ "type": "object",
42
+ "additionalProperties": false,
43
+ "properties": {
44
+ "branch": { "type": "string" },
45
+ "commit": { "type": "string" },
46
+ "pr": { "type": "string" },
47
+ "tests": { "type": "string" }
48
+ }
49
+ }
50
+ }
51
+ }
@@ -0,0 +1,44 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "urn:codex-collab:peer-event:v2",
4
+ "title": "Codex Collab Signed Peer Event v2",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["schema_version", "event_id", "task_id", "seq", "type", "actor_id", "occurred_at", "idempotency_key", "team_hash", "signature"],
8
+ "properties": {
9
+ "schema_version": { "const": 2 },
10
+ "event_id": { "type": "string", "minLength": 1 },
11
+ "task_id": { "type": "string", "pattern": "^T-[A-Z0-9-]{1,62}$" },
12
+ "seq": { "type": "integer", "minimum": 1 },
13
+ "type": { "enum": ["created", "desktop_claimed", "claimed", "renewed", "progress", "checkpoint", "handoff", "stage_review_requested", "review_requested", "run_failed", "blocked", "reopened", "reviewed", "completed", "cancelled"] },
14
+ "actor_id": { "type": "string", "pattern": "^[a-zA-Z0-9_-]{1,64}$" },
15
+ "occurred_at": { "type": "string", "format": "date-time", "pattern": "Z$" },
16
+ "idempotency_key": { "type": "string", "minLength": 1 },
17
+ "team_hash": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
18
+ "signature": { "type": "string", "minLength": 1 },
19
+ "spec_hash": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
20
+ "epoch": { "type": "integer", "minimum": 1 },
21
+ "lease_id": { "type": "string", "minLength": 1 },
22
+ "expires_at": { "type": "string", "format": "date-time" },
23
+ "summary": { "type": "string", "maxLength": 4000 },
24
+ "next_stage": { "type": "integer", "minimum": 1 },
25
+ "decision": { "enum": ["approved", "changes_requested"] },
26
+ "refs": {
27
+ "type": "object", "additionalProperties": false, "required": ["branch", "commit", "tests"],
28
+ "properties": {
29
+ "branch": { "type": "string", "pattern": "^codex/[A-Za-z0-9][A-Za-z0-9._/-]{0,150}$" },
30
+ "commit": { "type": "string", "pattern": "^[a-fA-F0-9]{40}([a-fA-F0-9]{24})?$" },
31
+ "tests": { "type": "string", "minLength": 1, "maxLength": 1000, "pattern": "\\S" }
32
+ }
33
+ }
34
+ },
35
+ "allOf": [
36
+ { "if": { "properties": { "type": { "const": "created" } }, "required": ["type"] }, "then": { "required": ["spec_hash"] } },
37
+ { "if": { "properties": { "type": { "enum": ["claimed", "renewed"] } }, "required": ["type"] }, "then": { "required": ["epoch", "lease_id", "expires_at"] } },
38
+ { "if": { "properties": { "type": { "enum": ["checkpoint", "handoff", "stage_review_requested", "review_requested", "completed"] } }, "required": ["type"] }, "then": { "required": ["refs"] } },
39
+ { "if": { "properties": { "type": { "enum": ["handoff", "stage_review_requested"] } }, "required": ["type"] }, "then": { "required": ["summary", "next_stage"], "properties": { "summary": { "minLength": 1, "pattern": "\\S" } } } },
40
+ { "if": { "properties": { "type": { "const": "reviewed" } }, "required": ["type"] }, "then": { "required": ["decision"] } },
41
+ { "if": { "properties": { "type": { "const": "reopened" } }, "required": ["type"] }, "then": { "required": ["summary"], "properties": { "summary": { "minLength": 1, "pattern": "\\S" } } } }
42
+ ],
43
+ "$comment": "Runtime additionally verifies Ed25519 signatures, pinned team hash, event sequence, role/route/capacity, exact shared branch-tip SHA and state transitions. Manual desktop reservations have no lease and require manual continuation or cancellation. desktop_poll tasks use the claimed/renewed lease events and existing fencing; bound conversation IDs stay private, never in event fields or idempotency keys. Team policy v3 requires nonempty rejection feedback and a current expected_seq on human review writes; runtime checks these policy-dependent requirements."
44
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "urn:codex-collab:peer-task:v2",
4
+ "title": "Codex Collab Peer Task v2",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["schema_version", "id", "repo", "title", "base_branch", "acceptance", "workflow"],
8
+ "properties": {
9
+ "schema_version": { "const": 2 },
10
+ "id": { "type": "string", "pattern": "^T-[A-Z0-9-]{1,62}$" },
11
+ "repo": { "type": "string", "minLength": 1 },
12
+ "title": { "type": "string", "minLength": 1, "maxLength": 200, "pattern": "\\S" },
13
+ "description": { "type": "string", "maxLength": 4000 },
14
+ "base_branch": { "const": "main" },
15
+ "execution_mode": { "enum": ["desktop", "automatic", "desktop_poll"] },
16
+ "acceptance": { "type": "array", "minItems": 1, "maxItems": 32, "items": { "type": "string", "minLength": 1, "maxLength": 1000, "pattern": "\\S" } },
17
+ "workflow": { "type": "array", "minItems": 1, "maxItems": 16, "items": { "type": "string", "pattern": "^[a-zA-Z0-9_-]{1,64}$" } },
18
+ "source": { "type": "object", "additionalProperties": false, "required": ["channel", "chat_id"], "properties": { "channel": { "const": "wecom" }, "chat_id": { "type": "string", "minLength": 1 } } }
19
+ },
20
+ "$comment": "Runtime additionally verifies repo identity and every workflow route against the pinned team policy. desktop_poll starts open and uses signed execution leases for existing-thread heartbeat workers; automatic SDK dispatch excludes that mode."
21
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "urn:codex-collab:task:v1",
4
+ "title": "Codex Collab Task v1",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["schema_version", "id", "repo", "title", "base_branch", "acceptance"],
8
+ "properties": {
9
+ "schema_version": { "const": 1 },
10
+ "id": { "type": "string", "pattern": "^[A-Z][A-Z0-9-]{1,63}$" },
11
+ "repo": { "type": "string", "minLength": 1 },
12
+ "title": { "type": "string", "minLength": 1, "maxLength": 200 },
13
+ "description": { "type": "string" },
14
+ "base_branch": { "type": "string", "minLength": 1 },
15
+ "acceptance": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } },
16
+ "source": { "type": "object", "additionalProperties": { "type": "string" } }
17
+ }
18
+ }