xuym-codex-collab 0.3.1 → 0.4.1

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.
@@ -3,17 +3,86 @@ import { randomUUID } from 'node:crypto';
3
3
  import { digest } from './config.mjs';
4
4
  import { canClaimDesktop, Conflict, Denied, eligible, leaseToken, validRefs } from './protocol.mjs';
5
5
  import { git } from './store.mjs';
6
+ import { inboxIdPattern, inboxMessageKey, inboxTaskId } from './inbox.mjs';
7
+ const validText = (value, max) => typeof value === 'string' && !!value.trim() && value.length <= max;
8
+ const validList = (value, maxItems, maxText) => Array.isArray(value) && value.length > 0 && value.length <= maxItems && value.every((item) => validText(item, maxText));
9
+ const plainFields = (value, allowed) => !!value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).every((key) => allowed.includes(key));
6
10
  export class PeerService {
7
11
  constructor(store, { notify = async () => {} } = {}) { this.store = store; this.agentId = store.agentId; this.notify = notify; }
8
12
  get team() { return this.store.team; }
9
13
  list() { return this.store.list(); }
10
14
  read(id) { return this.store.read(id); }
11
15
  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; }
16
+ listRequests() { return this.store.listInbox(); }
17
+ async pendingRequests({ chatIds } = {}) {
18
+ return (await this.listRequests()).filter((request) => request.spec.target_agent === this.agentId && request.state.status === 'pending' && (!chatIds || chatIds.includes(request.spec.chat_id)))
19
+ .sort((a, b) => a.events[0].occurred_at.localeCompare(b.events[0].occurred_at)).slice(0, 8);
20
+ }
21
+ async receiveRequest(input) {
22
+ const { chatId, messageId, senderId, content, allowAdmin = false } = input || {};
23
+ if (!plainFields(input, ['chatId', 'messageId', 'senderId', 'content', 'allowAdmin']) || !validText(chatId, 200) || !validText(messageId, 200) || !validText(senderId, 200) || !validText(content, 4000) || typeof allowAdmin !== 'boolean') throw new Conflict('invalid group request');
24
+ const key = inboxMessageKey(this.agentId, chatId, messageId);
25
+ return this.store.transactInbox(key, (requests) => {
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');
29
+ const referencedId = content.match(/\bN-[A-F0-9]{32}\b/)?.[0];
30
+ const mine = requests.filter((request) => request.spec.target_agent === this.agentId && request.spec.chat_id === chatId && request.spec.sender_id === senderId);
31
+ const waiting = mine.filter((request) => request.state.status === 'clarification');
32
+ const parent = referencedId ? mine.find((request) => request.spec.id === referencedId) : waiting.length === 1 ? waiting[0] : null;
33
+ if (referencedId && (!parent || parent.state.status !== 'clarification')) throw new Conflict('that request is not awaiting a reply from this sender in this group');
34
+ if (!referencedId && waiting.length > 1) throw Object.assign(new Conflict('multiple requests await clarification; include the N-request ID in your reply'), { code: 'AMBIGUOUS_INBOX_REPLY' });
35
+ if (parent) return { id: parent.spec.id, event: { type: 'replied', content, message_key: key } };
36
+ const id = `N-${digest([this.agentId, key]).slice(0, 32).toUpperCase()}`;
37
+ return { id, spec: { schema_version: 1, id, repo: this.team.repo, target_agent: this.agentId, chat_id: chatId,
38
+ sender_id: senderId, content, message_key: key, admin_dispatch: !!admin }, event: { type: 'received' } };
39
+ }, { replay: (request) => {
40
+ const event = request.events.find((item) => item.idempotency_key === key);
41
+ const original = event.type === 'received' ? request.spec.content : event.content;
42
+ 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
+ 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
+ } });
47
+ }
48
+ async resolveRequest(id, expectedSeq, resolution, { defaultWorkflow } = {}) {
49
+ const { action, response, task: input } = resolution || {};
50
+ if (!plainFields(resolution, ['action', 'response', 'task']) || response !== undefined && !validText(response, 2000)) throw new Conflict('invalid request resolution');
51
+ if (!inboxIdPattern.test(id || '') || !Number.isSafeInteger(expectedSeq) || expectedSeq < 1) throw new Conflict('provide request ID and latest expected_seq');
52
+ if (!['create_task', 'clarify', 'answer'].includes(action)) throw new Conflict('unsupported request resolution');
53
+ if (action === 'create_task') {
54
+ if (!plainFields(input, ['title', 'description', 'acceptance', 'workflow']) || !validText(input.title, 200) || input.description !== undefined && (typeof input.description !== 'string' || input.description.length > 4000) || !validList(input.acceptance, 32, 1000) || input.workflow !== undefined && !validList(input.workflow, 16, 64)) throw new Conflict('invalid structured task');
55
+ } else if (input !== undefined || !validText(response, 2000)) throw new Conflict('answer and clarification require a response without a task');
56
+ const key = `inbox-resolve:${id}:${expectedSeq}`;
57
+ const result = await this.store.transactInbox(key, (requests) => {
58
+ const request = requests.find((item) => item.spec.id === id);
59
+ if (!request || request.spec.target_agent !== this.agentId) throw new Denied('only the receiving bot identity may interpret this request');
60
+ if (request.state.status !== 'pending' || request.state.seq !== expectedSeq) throw new Conflict('request changed or is no longer pending');
61
+ if (action !== 'create_task') return { id, event: { type: action === 'clarify' ? 'clarified' : 'answered', response } };
62
+ if (!input) throw new Conflict('a structured task is required');
63
+ const taskSpec = { schema_version: 2, id: inboxTaskId(id), repo: this.team.repo, title: input.title,
64
+ description: input.description ?? '', base_branch: 'main', acceptance: input.acceptance,
65
+ workflow: input.workflow || defaultWorkflow || [this.team.default_route], execution_mode: 'desktop_poll', source: { channel: 'wecom', chat_id: request.spec.chat_id } };
66
+ return { id, taskSpec, event: { type: 'task_created', task_id: taskSpec.id, task_hash: digest(taskSpec), response: response || `已创建任务 ${taskSpec.id}:${taskSpec.title}。分工:${taskSpec.workflow.join(' → ')};等待绑定对话认领。` } };
67
+ }, { replay: async (request) => {
68
+ const event = request.events.find((item) => item.idempotency_key === key);
69
+ const expectedType = { create_task: 'task_created', clarify: 'clarified', answer: 'answered' }[action];
70
+ if (event.type !== expectedType || response !== undefined && event.response !== response) throw new Conflict('request already resolved with different content');
71
+ if (action === 'create_task') {
72
+ const accepted = await this.store.readLocal(event.task_id);
73
+ const taskSpec = { schema_version: 2, id: inboxTaskId(id), repo: this.team.repo, title: input.title, description: input.description ?? '', base_branch: 'main', acceptance: input.acceptance, workflow: input.workflow || accepted.spec.workflow, execution_mode: 'desktop_poll', source: { channel: 'wecom', chat_id: request.spec.chat_id } };
74
+ if (digest(taskSpec) !== event.task_hash) throw new Conflict('request already resolved with a different task');
75
+ }
76
+ } });
77
+ if (result.state.task_id) await this.publish(await this.read(result.state.task_id), 'created');
78
+ else await this.publish(result, 'inbox_response');
79
+ return { request: result, ...(result.state.task_id ? { task: await this.read(result.state.task_id) } : {}) };
80
+ }
12
81
  async create(input, idempotencyKey = randomUUID()) {
13
82
  const id = input.id || `T-${digest(`${this.agentId}:${idempotencyKey}`).slice(0, 32).toUpperCase()}`;
14
83
  const result = await this.store.transact(id, idempotencyKey, (current) => {
15
84
  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,
85
+ const spec = { schema_version: 2, id, repo: this.team.repo, title: input.title, description: input.description ?? '', base_branch: 'main', acceptance: input.acceptance,
17
86
  workflow: input.workflow || [this.team.default_route], execution_mode: input.execution_mode || 'automatic', ...(input.chatId ? { source: { channel: 'wecom', chat_id: input.chatId } } : {}) };
18
87
  return { spec, event: { type: 'created' } };
19
88
  });
@@ -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 { inboxIdPattern, reduceInbox } from './inbox.mjs';
8
9
  import { readRegistry, recordHash, registryPath } from './registry.mjs';
9
10
  const exec = promisify(execFile);
10
11
  const pause = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -113,6 +114,86 @@ export class PeerGitStore {
113
114
  }
114
115
  async list() { return this.locked(async () => { await this.sync(); return this.listLocal(); }); }
115
116
  async read(id) { return this.locked(async () => { await this.sync(); return this.readLocal(id); }); }
117
+ inboxRoot(id) {
118
+ if (!inboxIdPattern.test(id || '')) throw new Error('invalid inbox request ID');
119
+ return join(this.checkout, '.agent-collab', 'peer', 'inbox', id);
120
+ }
121
+ async readInboxLocal(id) {
122
+ const root = this.inboxRoot(id);
123
+ const spec = JSON.parse(await readFile(join(root, 'spec.json'), 'utf8'));
124
+ if (spec.id !== id) throw new Error('inbox directory/spec mismatch');
125
+ const files = (await readdir(join(root, 'events'))).sort();
126
+ if (files.some((file) => !/^\d{6}-[a-f0-9-]{36}\.json$/.test(file))) throw new Error('unexpected inbox event file');
127
+ const events = await Promise.all(files.map(async (file) => JSON.parse(await readFile(join(root, 'events', file), 'utf8'))));
128
+ const team = this.registry ? this.registry.teams.get(events[0]?.team_hash) : this.team;
129
+ if (!team) throw new Error('inbox references an untrusted team policy');
130
+ const state = reduceInbox(spec, events, team);
131
+ if (state.task_id) {
132
+ const task = await this.readLocal(state.task_id); const resolution = events.at(-1);
133
+ if (digest(task.spec) !== resolution.task_hash || task.events[0].actor_id !== spec.target_agent || task.spec.execution_mode !== 'desktop_poll' || task.spec.source?.chat_id !== spec.chat_id || task.events[0].idempotency_key !== `inbox-task:${id}`) throw new Error('inbox resolution does not match its signed task');
134
+ }
135
+ return { spec, events, state, team };
136
+ }
137
+ async listInboxLocal() {
138
+ let entries;
139
+ try { entries = await readdir(join(this.checkout, '.agent-collab', 'peer', 'inbox')); }
140
+ catch (error) { if (error.code === 'ENOENT') return []; throw error; }
141
+ if (entries.some((id) => !inboxIdPattern.test(id))) throw new Error('unexpected inbox entry');
142
+ return Promise.all(entries.sort().map((id) => this.readInboxLocal(id)));
143
+ }
144
+ async listInbox() { return this.locked(async () => { await this.sync(); return this.listInboxLocal(); }); }
145
+ // One remote Git CAS publishes BOTH the interpreted task and its resolution.
146
+ // Retries cannot leave a task created with a still-pending request.
147
+ async transactInbox(idempotencyKey, build, { replay = () => {} } = {}) {
148
+ if (this.enrollmentOnly) throw new Error('enrollment client cannot write inbox requests');
149
+ if (typeof idempotencyKey !== 'string' || !idempotencyKey || idempotencyKey.length > 300) throw new Error('invalid inbox idempotency key');
150
+ return this.locked(async () => {
151
+ for (let attempt = 0; attempt < this.maxRetries; attempt++) {
152
+ await this.sync();
153
+ const requests = await this.listInboxLocal();
154
+ const duplicate = requests.find((request) => request.events.some((event) => event.idempotency_key === idempotencyKey));
155
+ if (duplicate) {
156
+ if (duplicate.spec.target_agent !== this.agentId) throw new Error('inbox idempotency key belongs to another identity');
157
+ await replay(duplicate); return duplicate;
158
+ }
159
+ const built = await build(requests, this.clock());
160
+ const current = requests.find((request) => request.spec.id === built.id);
161
+ const spec = current?.spec || built.spec;
162
+ if (!spec || spec.id !== built.id || spec.target_agent !== this.agentId) throw new Error('inbox transaction identity mismatch');
163
+ const now = new Date(this.clock()).toISOString();
164
+ const team = current?.team || this.team;
165
+ const event = makeSigner(team, this.agentId, this.privateKey)({ schema_version: 1, request_id: spec.id,
166
+ seq: (current?.state.seq || 0) + 1, event_id: randomUUID(), actor_id: this.agentId, occurred_at: now,
167
+ idempotency_key: idempotencyKey, ...built.event, ...(current ? {} : { spec_hash: digest(spec) }) });
168
+ reduceInbox(spec, [...(current?.events || []), event], team);
169
+ if ((event.type === 'task_created') !== !!built.taskSpec) throw new Error('inbox task must be published with its resolution');
170
+ const paths = [this.inboxRoot(spec.id)];
171
+ if (built.taskSpec) {
172
+ const taskSpec = built.taskSpec;
173
+ const taskEvent = this.sign({ schema_version: 2, task_id: taskSpec.id, seq: 1, event_id: randomUUID(), actor_id: this.agentId,
174
+ occurred_at: now, idempotency_key: `inbox-task:${spec.id}`, type: 'created', spec_hash: digest(taskSpec) });
175
+ reducePeerTask(taskSpec, [taskEvent], this.team);
176
+ if (taskSpec.id !== event.task_id || digest(taskSpec) !== event.task_hash || taskSpec.execution_mode !== 'desktop_poll' || taskSpec.source?.chat_id !== spec.chat_id) throw new Error('inbox/task provenance mismatch');
177
+ const taskRoot = this.root(taskSpec.id); paths.push(taskRoot);
178
+ await mkdir(join(taskRoot, 'events'), { recursive: true });
179
+ await writeFile(join(taskRoot, 'spec.json'), JSON.stringify(taskSpec, null, 2) + '\n', { flag: 'wx' });
180
+ await writeFile(join(taskRoot, 'events', `000001-${taskEvent.event_id}.json`), JSON.stringify(taskEvent, null, 2) + '\n', { flag: 'wx' });
181
+ }
182
+ const root = this.inboxRoot(spec.id); await mkdir(join(root, 'events'), { recursive: true });
183
+ if (!current) await writeFile(join(root, 'spec.json'), JSON.stringify(spec, null, 2) + '\n', { flag: 'wx' });
184
+ await writeFile(join(root, 'events', `${String(event.seq).padStart(6, '0')}-${event.event_id}.json`), JSON.stringify(event, null, 2) + '\n', { flag: 'wx' });
185
+ await git(this.checkout, 'add', '--', ...paths); await git(this.checkout, 'commit', '-m', `collab(inbox): ${spec.id} ${event.type}`);
186
+ try { await git(this.checkout, 'push', 'origin', 'HEAD:refs/heads/agent-collab'); return this.readInboxLocal(spec.id); }
187
+ catch {
188
+ await this.sync();
189
+ const accepted = (await this.listInboxLocal()).find((request) => request.events.some((item) => item.idempotency_key === idempotencyKey));
190
+ if (accepted) { await replay(accepted); return accepted; }
191
+ await pause(20 * (attempt + 1));
192
+ }
193
+ }
194
+ throw new Error('inbox transaction not accepted; check Git access or retry');
195
+ });
196
+ }
116
197
  async registryTransaction(build) {
117
198
  return this.locked(async () => {
118
199
  for (let attempt = 0; attempt < this.maxRetries; attempt++) {
@@ -0,0 +1,284 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "urn:codex-collab:peer-inbox-event:v1",
4
+ "title": "Codex Collab Signed Natural Language Inbox Event v1",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": [
8
+ "schema_version",
9
+ "request_id",
10
+ "seq",
11
+ "event_id",
12
+ "actor_id",
13
+ "occurred_at",
14
+ "idempotency_key",
15
+ "team_hash",
16
+ "signature",
17
+ "type"
18
+ ],
19
+ "properties": {
20
+ "schema_version": {
21
+ "const": 1
22
+ },
23
+ "request_id": {
24
+ "type": "string",
25
+ "pattern": "^N-[A-F0-9]{32}$"
26
+ },
27
+ "seq": {
28
+ "type": "integer",
29
+ "minimum": 1
30
+ },
31
+ "event_id": {
32
+ "type": "string",
33
+ "pattern": "^[a-f0-9-]{36}$"
34
+ },
35
+ "actor_id": {
36
+ "type": "string",
37
+ "pattern": "^[a-zA-Z0-9_-]{1,64}$"
38
+ },
39
+ "occurred_at": {
40
+ "type": "string",
41
+ "format": "date-time",
42
+ "pattern": "Z$"
43
+ },
44
+ "idempotency_key": {
45
+ "type": "string",
46
+ "minLength": 1,
47
+ "maxLength": 300,
48
+ "pattern": "\\S"
49
+ },
50
+ "team_hash": {
51
+ "type": "string",
52
+ "pattern": "^[a-f0-9]{64}$"
53
+ },
54
+ "signature": {
55
+ "type": "string",
56
+ "minLength": 1
57
+ },
58
+ "type": {
59
+ "enum": [
60
+ "received",
61
+ "clarified",
62
+ "replied",
63
+ "task_created",
64
+ "answered"
65
+ ]
66
+ },
67
+ "spec_hash": {
68
+ "type": "string",
69
+ "pattern": "^[a-f0-9]{64}$"
70
+ },
71
+ "content": {
72
+ "type": "string",
73
+ "minLength": 1,
74
+ "maxLength": 4000,
75
+ "pattern": "\\S"
76
+ },
77
+ "message_key": {
78
+ "type": "string",
79
+ "minLength": 1,
80
+ "maxLength": 300,
81
+ "pattern": "\\S"
82
+ },
83
+ "response": {
84
+ "type": "string",
85
+ "minLength": 1,
86
+ "maxLength": 2000,
87
+ "pattern": "\\S"
88
+ },
89
+ "task_id": {
90
+ "type": "string",
91
+ "pattern": "^T-[A-F0-9]{32}$"
92
+ },
93
+ "task_hash": {
94
+ "type": "string",
95
+ "pattern": "^[a-f0-9]{64}$"
96
+ }
97
+ },
98
+ "oneOf": [
99
+ {
100
+ "properties": {
101
+ "type": {
102
+ "const": "received"
103
+ }
104
+ },
105
+ "required": [
106
+ "spec_hash"
107
+ ],
108
+ "not": {
109
+ "anyOf": [
110
+ {
111
+ "required": [
112
+ "content"
113
+ ]
114
+ },
115
+ {
116
+ "required": [
117
+ "message_key"
118
+ ]
119
+ },
120
+ {
121
+ "required": [
122
+ "response"
123
+ ]
124
+ },
125
+ {
126
+ "required": [
127
+ "task_hash"
128
+ ]
129
+ },
130
+ {
131
+ "required": [
132
+ "task_id"
133
+ ]
134
+ }
135
+ ]
136
+ }
137
+ },
138
+ {
139
+ "properties": {
140
+ "type": {
141
+ "const": "clarified"
142
+ }
143
+ },
144
+ "required": [
145
+ "response"
146
+ ],
147
+ "not": {
148
+ "anyOf": [
149
+ {
150
+ "required": [
151
+ "content"
152
+ ]
153
+ },
154
+ {
155
+ "required": [
156
+ "message_key"
157
+ ]
158
+ },
159
+ {
160
+ "required": [
161
+ "spec_hash"
162
+ ]
163
+ },
164
+ {
165
+ "required": [
166
+ "task_hash"
167
+ ]
168
+ },
169
+ {
170
+ "required": [
171
+ "task_id"
172
+ ]
173
+ }
174
+ ]
175
+ }
176
+ },
177
+ {
178
+ "properties": {
179
+ "type": {
180
+ "const": "replied"
181
+ }
182
+ },
183
+ "required": [
184
+ "content",
185
+ "message_key"
186
+ ],
187
+ "not": {
188
+ "anyOf": [
189
+ {
190
+ "required": [
191
+ "response"
192
+ ]
193
+ },
194
+ {
195
+ "required": [
196
+ "spec_hash"
197
+ ]
198
+ },
199
+ {
200
+ "required": [
201
+ "task_hash"
202
+ ]
203
+ },
204
+ {
205
+ "required": [
206
+ "task_id"
207
+ ]
208
+ }
209
+ ]
210
+ }
211
+ },
212
+ {
213
+ "properties": {
214
+ "type": {
215
+ "const": "task_created"
216
+ }
217
+ },
218
+ "required": [
219
+ "task_id",
220
+ "task_hash",
221
+ "response"
222
+ ],
223
+ "not": {
224
+ "anyOf": [
225
+ {
226
+ "required": [
227
+ "content"
228
+ ]
229
+ },
230
+ {
231
+ "required": [
232
+ "message_key"
233
+ ]
234
+ },
235
+ {
236
+ "required": [
237
+ "spec_hash"
238
+ ]
239
+ }
240
+ ]
241
+ }
242
+ },
243
+ {
244
+ "properties": {
245
+ "type": {
246
+ "const": "answered"
247
+ }
248
+ },
249
+ "required": [
250
+ "response"
251
+ ],
252
+ "not": {
253
+ "anyOf": [
254
+ {
255
+ "required": [
256
+ "content"
257
+ ]
258
+ },
259
+ {
260
+ "required": [
261
+ "message_key"
262
+ ]
263
+ },
264
+ {
265
+ "required": [
266
+ "spec_hash"
267
+ ]
268
+ },
269
+ {
270
+ "required": [
271
+ "task_hash"
272
+ ]
273
+ },
274
+ {
275
+ "required": [
276
+ "task_id"
277
+ ]
278
+ }
279
+ ]
280
+ }
281
+ }
282
+ ],
283
+ "$comment": "Runtime also verifies Ed25519 signatures and historical team hash, receiving-agent authorship, exact sequence, unique IDs and idempotency keys, nondecreasing UTC timestamps, signed spec digest and allowed transitions. received opens pending; clarified waits for a reply; replied returns to pending; task_created and answered resolve pending. A task_created event and its desktop_poll task commit atomically; runtime validates the exact task digest, deterministic task ID, original group, creator and creation key. Replies are scoped to the same bot, group and sender."
284
+ }
@@ -0,0 +1,63 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "urn:codex-collab:peer-inbox:v1",
4
+ "title": "Codex Collab Natural Language Inbox Request v1",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": [
8
+ "schema_version",
9
+ "id",
10
+ "repo",
11
+ "target_agent",
12
+ "chat_id",
13
+ "sender_id",
14
+ "content",
15
+ "message_key",
16
+ "admin_dispatch"
17
+ ],
18
+ "properties": {
19
+ "schema_version": {
20
+ "const": 1
21
+ },
22
+ "id": {
23
+ "type": "string",
24
+ "pattern": "^N-[A-F0-9]{32}$"
25
+ },
26
+ "repo": {
27
+ "type": "string",
28
+ "minLength": 1
29
+ },
30
+ "target_agent": {
31
+ "type": "string",
32
+ "pattern": "^[a-zA-Z0-9_-]{1,64}$"
33
+ },
34
+ "chat_id": {
35
+ "type": "string",
36
+ "minLength": 1,
37
+ "maxLength": 200,
38
+ "pattern": "\\S"
39
+ },
40
+ "sender_id": {
41
+ "type": "string",
42
+ "minLength": 1,
43
+ "maxLength": 200,
44
+ "pattern": "\\S"
45
+ },
46
+ "content": {
47
+ "type": "string",
48
+ "minLength": 1,
49
+ "maxLength": 4000,
50
+ "pattern": "\\S"
51
+ },
52
+ "message_key": {
53
+ "type": "string",
54
+ "minLength": 1,
55
+ "maxLength": 300,
56
+ "pattern": "\\S"
57
+ },
58
+ "admin_dispatch": {
59
+ "type": "boolean"
60
+ }
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."
63
+ }
@@ -0,0 +1,76 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { chmod, lstat, mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { readPeerConfig } from '../../apps/peer/runtime.mjs';
6
+ import { git } from '../../packages/peer/store.mjs';
7
+ import { bindingPrompt } from './setup-desktop-project.mjs';
8
+ const root = fileURLToPath(new URL('../../', import.meta.url));
9
+ const legacyHashes = new Set(['6e4d1ea6a644fb3078c541e40bcc9e8bf5069462994152f3ec2fc3aa5fedd5fa', 'f98ad425e2ea3c8ab6fb86febef7d9cb288029ef55bea6937dda6de8b467fece']);
10
+ const hash = (value) => createHash('sha256').update(value).digest('hex');
11
+ const inside = (parent, child) => { const path = relative(parent, child); return !path || path !== '..' && !path.startsWith('../') && !isAbsolute(path); };
12
+ async function plainPath(base, path) {
13
+ let current = base;
14
+ for (const part of relative(base, path).split('/').filter(Boolean)) {
15
+ current = join(current, part);
16
+ if ((await lstat(current)).isSymbolicLink()) throw new Error('协作配置或 Skill 是符号链接,请先手动检查;未覆盖文件。');
17
+ }
18
+ }
19
+ async function privateWrite(path, contents) {
20
+ const temporary = `${path}.${randomUUID()}.tmp`;
21
+ try { await writeFile(temporary, contents, { mode: 0o600, flag: 'wx' }); await rename(temporary, path); await chmod(path, 0o600); }
22
+ finally { await rm(temporary, { force: true }); }
23
+ }
24
+ // Only recognize our generated MCP entry. Never replace another identity's entry.
25
+ export async function installedProjectConfig({ repoPath, agentId }) {
26
+ const repo = await realpath(repoPath);
27
+ const path = join(repo, '.codex/config.toml'); await plainPath(repo, path);
28
+ const before = await readFile(path, 'utf8');
29
+ const server = `team-collab-${agentId}`;
30
+ const headers = [...before.matchAll(/^\s*\[([^\]\n]+)\][^\n]*$/gm)];
31
+ if (headers.some((item) => item[1].startsWith(`mcp_servers.${server}.`))) throw new Error('协作 MCP 有自定义嵌套配置,请手动检查;未覆盖文件。');
32
+ const matching = headers.filter((item) => item[1] === `mcp_servers.${server}`);
33
+ if (matching.length !== 1) throw new Error('未找到唯一的已安装协作 MCP,请先完成项目接入。');
34
+ const header = matching[0]; const end = headers.find((item) => item.index > header.index)?.index ?? before.length;
35
+ const section = before.slice(header.index, end);
36
+ const argsLine = section.match(/^args\s*=\s*(\[[^\n]+\])\s*$/m);
37
+ const commandLine = section.match(/^command\s*=\s*("[^\n]+")\s*$/m);
38
+ let args, command;
39
+ try { args = JSON.parse(argsLine?.[1]); command = JSON.parse(commandLine?.[1]); } catch { /* reject below */ }
40
+ if (!Array.isArray(args) || args.length !== 2 || typeof args[0] !== 'string' || !args[0].endsWith('/apps/peer/mcp-main.mjs') || !isAbsolute(args[1] || '') || typeof command !== 'string' || !command) throw new Error('协作 MCP 不是可识别的安装配置,请手动检查;未覆盖文件。');
41
+ const keys = section.split(/\r?\n/).filter((line) => line.trim() && !/^\s*(?:#|\[)/.test(line));
42
+ if (keys.length !== 2) throw new Error('协作 MCP 有自定义设置,请手动检查;未覆盖文件。');
43
+ return { repo, path, before, section, config_path: args[1], command, args };
44
+ }
45
+ export async function refreshProject({ configPath }) {
46
+ if (!isAbsolute(configPath || '')) throw new Error('usage: codex-collab refresh-project /absolute/private-peer.json');
47
+ const { config } = await readPeerConfig(configPath);
48
+ if (config.execution_mode !== 'desktop_poll' || config.auto_execute !== false) throw new Error('refresh-project 需要 desktop_poll 配置且 SDK 已关闭。');
49
+ const installed = await installedProjectConfig({ repoPath: config.repo, agentId: config.agent_id });
50
+ const { repo, path: configFile, before, section } = installed;
51
+ if (await git(repo, 'rev-parse', '--show-toplevel') !== repo || await realpath(installed.config_path) !== await realpath(configPath)) throw new Error('项目 MCP 已绑定其他配置,请使用该 MCP 实际使用的个人配置路径;未更换身份。');
52
+ const skillFile = join(repo, '.agents/skills/team-collab-desktop/SKILL.md');
53
+ await plainPath(repo, skillFile);
54
+ if (await git(repo, 'ls-files', '--', '.codex/config.toml', '.agents/skills/team-collab-desktop')) throw new Error('协作配置或 Skill 已提交到项目 Git,请手动合并;未覆盖共享文件。');
55
+ const oldSkill = await readFile(skillFile, 'utf8');
56
+ const template = await readFile(join(root, 'packages/codex-kit/templates/.agents/skills/team-collab-desktop/SKILL.md'), 'utf8');
57
+ if (oldSkill !== template && !legacyHashes.has(hash(oldSkill))) throw new Error('协作 Skill 已自定义,请手动合并自然语言处理说明;未覆盖文件。');
58
+ const nextSection = section.replace(/^command\s*=.*$/m, `command = ${JSON.stringify(process.execPath)}`).replace(/^args\s*=.*$/m, `args = ${JSON.stringify([join(root, 'apps/peer/mcp-main.mjs'), installed.config_path])}`);
59
+ const next = before.replace(section, () => nextSection);
60
+ if (inside(repo, resolve(config.data_dir))) throw new Error('个人数据目录必须位于项目仓库之外。');
61
+ await mkdir(config.data_dir, { recursive: true, mode: 0o700 });
62
+ const dataDir = await realpath(config.data_dir);
63
+ if (inside(repo, dataDir)) throw new Error('个人数据目录必须位于项目仓库之外。');
64
+ const prompt = join(dataDir, 'desktop-binding-prompt.txt');
65
+ const changed = next !== before || oldSkill !== template;
66
+ let backup = null;
67
+ if (changed) {
68
+ backup = join(dataDir, `project-refresh-${randomUUID()}.json`);
69
+ await writeFile(backup, JSON.stringify({ repo, config_toml: before, skill: oldSkill, created_at: new Date().toISOString() }, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
70
+ try { await privateWrite(configFile, next); await privateWrite(skillFile, template); }
71
+ catch (error) { await privateWrite(configFile, before); await privateWrite(skillFile, oldSkill); throw error; }
72
+ }
73
+ await privateWrite(prompt, bindingPrompt(config.agent_id, repo));
74
+ return { agent_id: config.agent_id, repo, changed, mcp_config: configFile, skill: skillFile, backup, binding_prompt: prompt,
75
+ next_step: '重新加载项目 MCP;把 binding_prompt 内容发送到原绑定对话,以更新原协作 heartbeat。', heartbeat_updated: false };
76
+ }
@@ -8,7 +8,10 @@ const absent = async (path) => { try { await stat(path); return false; } catch (
8
8
  const readOptional = async (path) => { try { return await readFile(path, 'utf8'); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } };
9
9
 
10
10
  export function bindingPrompt(agentId, repo) {
11
- return `请把当前已有 Codex 对话设为 ${agentId} 的企微协作执行对话,不要新建独立执行线程。\n项目:${repo}\n读取 ${join(repo, '.agents/skills/team-collab-desktop/SKILL.md')}。\n1. 用本项目 MCP 的 get_identity 确认身份是 ${agentId},execution_mode 是 desktop_poll;若不匹配,停止并提示重新加载 MCP。\n2. 获取当前真实对话 ID:优先使用已提供的应用上下文,或当前终端 CODEX_THREAD_ID(如果存在);不可猜 ID 或读取/修改应用数据库。没有可用 ID 时请让我复制当前对话 ID/链接。\n3. 调用 bind_desktop_thread 绑定当前真实对话,然后用应用 automation_update 工具在当前已有对话创建一个 heartbeat:每分钟检查(仅当工具接受该周期;否则说明限制),不要创建 cron、新对话或后台 SDK。先查现有 automation,避免重复;不要修改无关 automation 或 CC Switch。\n4. heartbeat 的保存提示词必须包含 ${agentId}、当前真实 thread_id 和该 skill 路径;每次 get_identity → poll_desktop_task。job=null 时保持安静;有任务就只在本对话和返回的隔离 worktree 执行本阶段,主动续租、测试、明确提交,然后 finish_desktop_task;失败时按 skill 处理。禁止启动 SDK/CLI 代替当前对话。仅新任务、结果、失败或需我操作时通知。\n5. 创建完成后立即调用一次 poll_desktop_task 验证,无任务应正常返回,不要创建测试任务。告诉我绑定身份、对话 ID 和 heartbeat 状态;若工具不可用明确说明未启用自动轮询。\n`;
11
+ return `使用 $team-collab-desktop,启用当前项目的团队协作,将 ${agentId} 机器人绑定到当前对话。项目:${repo}`;
12
+ }
13
+ export function detailedBindingPrompt(agentId, repo) {
14
+ return `请把当前已有 Codex 对话设为 ${agentId} 的企微协作执行对话,不要新建独立执行线程。\n项目:${repo}\n读取 ${join(repo, '.agents/skills/team-collab-desktop/SKILL.md')}。\n1. 用本项目 MCP 的 get_identity 确认身份是 ${agentId},execution_mode 是 desktop_poll;若不匹配,停止并提示重新加载 MCP。\n2. 获取当前真实对话 ID:优先使用已提供的应用上下文,或当前终端 CODEX_THREAD_ID(如果存在);不可猜 ID 或读取/修改应用数据库。没有可用 ID 时请让我复制当前对话 ID/链接。\n3. 调用 bind_desktop_thread 绑定当前真实对话,然后用应用 automation_update 工具在当前已有对话创建一个 heartbeat:每分钟检查(仅当工具接受该周期;否则说明限制),不要创建 cron、新对话或后台 SDK。先查现有 automation,避免重复;不要修改无关 automation 或 CC Switch。\n4. heartbeat 的保存提示词必须包含 ${agentId}、当前真实 thread_id 和该 skill 路径;每次 get_identity → poll_desktop_task。job=null 且 requests 为空 时保持安静;有任务就只在本对话和返回的隔离 worktree 执行本阶段,主动续租、测试、明确提交,然后 finish_desktop_task;失败时按 skill 处理。requests 非空时按 Skill 理解群消息,调用 resolve_desktop_request 创建任务、追问或回答;使用 team_context 的现有路由、能力与默认分工,不能猜测不存在的同事。解决后再次 poll 开始工作。自然语言“通过/完成”不能代替人工审核命令。复用或更新当前对话已有的协作 heartbeat,避免重复创建。禁止启动 SDK/CLI 代替当前对话。仅新任务、结果、失败或需我操作时通知。\n5. 创建完成后立即调用一次 poll_desktop_task 验证,无任务应正常返回,不要创建测试任务。告诉我绑定身份、对话 ID 和 heartbeat 状态;若工具不可用明确说明未启用自动轮询。\n`;
12
15
  }
13
16
 
14
17
  // An explicit project attachment: preserve unrelated TOML settings, keep a local