xuym-codex-collab 0.4.0 → 0.4.2

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.
@@ -0,0 +1,247 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { chmod, lstat, mkdir, readFile, readdir, realpath, rename, rm, rmdir, writeFile } from 'node:fs/promises';
3
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { SetupError } from './errors.mjs';
6
+ import { readPeerConfig, loadRuntime } from '../peer/runtime.mjs';
7
+ import { PrivateDesktopState } from '../peer/private-state.mjs';
8
+ import { alive, readRunnerLock } from '../peer/process.mjs';
9
+ import { installedProjectConfig } from '../../scripts/peer/refresh-project.mjs';
10
+ import { bindingPrompt } from '../../scripts/peer/setup-desktop-project.mjs';
11
+ import { digest, readPrivateJson } from '../../packages/peer/config.mjs';
12
+ import { git } from '../../packages/peer/store.mjs';
13
+
14
+ const root = fileURLToPath(new URL('../../', import.meta.url));
15
+ const kinds = { conversation: '重新绑定对话', robot: '更新机器人凭据', project: '移除本机接入', restore: '恢复本机接入' };
16
+ const hash = (value) => createHash('sha256').update(value).digest('hex');
17
+ const legacySkill = 'f98ad425e2ea3c8ab6fb86febef7d9cb288029ef55bea6937dda6de8b467fece';
18
+ const inside = (parent, child) => { const path = relative(parent, child); return !path || path !== '..' && !path.startsWith('../') && !isAbsolute(path); };
19
+ async function optional(path) { return readFile(path, 'utf8').catch((error) => error.code === 'ENOENT' ? null : Promise.reject(error)); }
20
+ async function plain(base, path) {
21
+ if (!inside(base, path)) throw new SetupError('配置路径不属于当前项目。');
22
+ let current = base;
23
+ for (const part of relative(base, path).split('/').filter(Boolean)) {
24
+ current = join(current, part);
25
+ const entry = await lstat(current).catch((error) => error.code === 'ENOENT' ? null : Promise.reject(error));
26
+ if (!entry) break;
27
+ if (entry.isSymbolicLink()) throw new SetupError('协作配置或 Skill 是符号链接,请先手动检查。');
28
+ }
29
+ }
30
+ async function replace(path, contents) {
31
+ if (contents === null) { await rm(path, { force: true }); return; }
32
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
33
+ const temp = `${path}.${randomUUID()}.tmp`;
34
+ try { await writeFile(temp, contents, { flag: 'wx', mode: 0o600 }); await rename(temp, path); await chmod(path, 0o600); }
35
+ finally { await rm(temp, { force: true }); }
36
+ }
37
+ const json = (value) => JSON.stringify(value, null, 2) + '\n';
38
+
39
+ // Mutations are serialized by SetupManager and the same private lock used by MCP.
40
+ // A preview is a short-lived capability for one profile and one exact snapshot.
41
+ export class ResetActions {
42
+ constructor(manager) { this.manager = manager; this.previews = new Map(); }
43
+ async context(id, kind) {
44
+ if (!Object.hasOwn(kinds, kind)) throw new SetupError('请选择有效的重置操作。');
45
+ const state = await this.manager.state(id);
46
+ if (kind === 'restore' ? state.phase !== 'removed' : state.phase === 'removed') throw new SetupError('项目状态已改变,请刷新工作台。');
47
+ if (['conversation', 'robot'].includes(kind) && state.phase !== 'ready') throw new SetupError('请先完成项目接入,或选择移除本机接入。');
48
+ const configPath = state.existing_config || join(this.manager.path(id), 'peer.json');
49
+ const configText = await optional(configPath);
50
+ const config = configText === null ? null : (await readPeerConfig(configPath, { allowDisabled: kind === 'restore' })).config;
51
+ if (config && (config.agent_id !== state.agent_id || await realpath(config.repo) !== await realpath(state.repo))) throw new SetupError('私有配置与当前项目身份不一致,请先手动检查。');
52
+ const dataDir = config?.data_dir || state.data_dir || join(this.manager.path(id), 'data');
53
+ const actualData = await realpath(dataDir).catch((error) => error.code === 'ENOENT' ? resolve(dataDir) : Promise.reject(error));
54
+ if (inside(await realpath(state.repo), actualData) || inside(await realpath(state.repo), this.manager.home)) throw new SetupError('个人配置与备份必须位于业务仓库之外。');
55
+ for (const profile of (await this.manager.overview()).profiles) {
56
+ if (profile.id === id) continue;
57
+ const other = await this.manager.state(profile.id);
58
+ if (resolve(other.data_dir || join(this.manager.path(other.id), 'data')) === resolve(dataDir) || other.repo === state.repo) throw new SetupError('其他本机项目正在使用此 clone 或数据目录,请先检查重复导入。');
59
+ }
60
+ return { state, config, configPath, dataDir, private: new PrivateDesktopState(dataDir) };
61
+ }
62
+ async configs(context, { allowDisabled = false } = {}) {
63
+ const { state, config, configPath } = context;
64
+ if (!config) return [];
65
+ const paths = new Set([resolve(configPath)]);
66
+ const local = join(this.manager.path(state.id), 'peer.json');
67
+ if (await optional(local) !== null) paths.add(local);
68
+ const toml = await optional(join(state.repo, '.codex/config.toml'));
69
+ if (toml?.includes(`[mcp_servers.team-collab-${state.agent_id}]`)) {
70
+ const installed = await installedProjectConfig({ repoPath: state.repo, agentId: state.agent_id });
71
+ paths.add(installed.config_path);
72
+ }
73
+ if (allowDisabled && state.reset_backup) {
74
+ const backup = await readPrivateJson(state.reset_backup);
75
+ if (backup.kind !== 'project' || backup.state.id !== state.id) throw new SetupError('恢复记录与项目不一致,请先手动检查。');
76
+ for (const path of backup.config_paths || []) paths.add(path);
77
+ }
78
+ const result = [];
79
+ for (const path of paths) {
80
+ if (inside(state.repo, path)) throw new SetupError('私有配置位于业务仓库内,请先手动移出。');
81
+ const item = (await readPeerConfig(path, { allowDisabled })).config;
82
+ if (item.agent_id !== state.agent_id || item.private_key !== config.private_key || await realpath(item.repo) !== await realpath(config.repo) || resolve(item.data_dir) !== resolve(config.data_dir)) throw new SetupError('项目 MCP 使用其他身份或数据目录,请先手动检查。');
83
+ if ((await lstat(path)).isSymbolicLink()) throw new SetupError('私有配置是符号链接,请先手动检查。');
84
+ result.push({ path, before: await readFile(path, 'utf8'), value: item });
85
+ }
86
+ return result;
87
+ }
88
+ async projectFiles(context, kind) {
89
+ const { state } = context; const repo = await realpath(state.repo);
90
+ if (await git(repo, 'rev-parse', '--show-toplevel') !== repo) throw new SetupError('请选择 Git clone 的根目录。');
91
+ const configFile = join(repo, '.codex/config.toml'); const skillDir = join(repo, '.agents/skills/team-collab-desktop'); const skillFile = join(skillDir, 'SKILL.md');
92
+ await plain(repo, configFile); await plain(repo, skillFile);
93
+ const before = await optional(configFile); const skill = await optional(skillFile);
94
+ if (/^\s*\[mcp_servers\.team-collab-/m.test(before || '') && (before || '').match(/^\s*\[mcp_servers\.team-collab-([^\].\n]+)[^\n]*$/gm)?.some((line) => line.trim() !== `[mcp_servers.team-collab-${state.agent_id}]`)) throw new SetupError('该 clone 中还有其他协作身份,请先手动检查。');
95
+ const hasMcp = before?.includes(`[mcp_servers.team-collab-${state.agent_id}]`) || before?.includes(`[mcp_servers.team-collab-${state.agent_id}.`);
96
+ let installed = null;
97
+ if (hasMcp) {
98
+ installed = await installedProjectConfig({ repoPath: repo, agentId: state.agent_id });
99
+ if (!context.config) throw new SetupError('无法验证原 MCP 身份,请恢复私有配置后重试。');
100
+ }
101
+ const skillEntries = await readdir(skillDir).catch((error) => error.code === 'ENOENT' ? [] : Promise.reject(error));
102
+ if (skillEntries.some((name) => name !== 'SKILL.md')) throw new SetupError('协作 Skill 目录包含自定义文件,请先手动处理。');
103
+ if (skill !== null) {
104
+ const template = await readFile(join(root, 'packages/codex-kit/templates/.agents/skills/team-collab-desktop/SKILL.md'), 'utf8');
105
+ if (skill !== template && hash(skill) !== legacySkill) throw new SetupError('协作 Skill 已自定义,请先手动备份与移除。');
106
+ }
107
+ const touched = [hasMcp ? '.codex/config.toml' : null, skill !== null ? '.agents/skills/team-collab-desktop' : null].filter(Boolean);
108
+ if (touched.length && await git(repo, 'ls-files', '--', ...touched)) throw new SetupError('协作配置或 Skill 已提交到项目 Git,请先手动处理共享文件。');
109
+ if (kind === 'restore' && (hasMcp || skill !== null)) throw new SetupError('原项目已存在协作配置,请先处理当前接入,再恢复。');
110
+ const result = [];
111
+ if (installed) { const next = before.replace(installed.section, ''); result.push({ path: configFile, before, after: next.trim() ? next : null }); }
112
+ if (skill !== null) result.push({ path: skillFile, before: skill, after: null });
113
+ return { files: result, skillDir, configFile, skillFile, before, skill };
114
+ }
115
+ async plan(context, kind, desktop) {
116
+ const configs = await this.configs(context, { allowDisabled: kind === 'restore' });
117
+ const project = ['project', 'restore'].includes(kind) ? await this.projectFiles(context, kind) : null;
118
+ const lock = await readRunnerLock(join(context.dataDir, 'runner.pid.json'));
119
+ if (lock && alive(lock.pid) && !configs.some((item) => resolve(item.path) === resolve(lock.config || ''))) throw new SetupError('Runner 配置与当前项目不一致,无法安全停止。');
120
+ if (kind !== 'restore' && context.config) {
121
+ try {
122
+ const { service } = await loadRuntime(context.configPath);
123
+ const tasks = await service.list();
124
+ if (tasks.some((task) => task.state.status === 'running' && task.state.assignee === context.state.agent_id && Date.now() < Date.parse(task.state.lease.expires_at))) throw new SetupError('该机器人有任务正在运行,请等待本阶段结束或先在原对话报告失败,再重置。');
125
+ } catch (error) {
126
+ if (error instanceof SetupError) throw error;
127
+ throw new SetupError('无法确认任务是否运行,请检查 Git 网络与权限后重试;未重置。');
128
+ }
129
+ }
130
+ if (!context.config && Object.values(desktop.jobs).some((job) => ['claimed', 'prepared', 'executing'].includes(job.status))) throw new SetupError('私有配置缺失且本机有未结束任务,请先恢复配置。');
131
+ return { configs, project, lock, fingerprint: digest({ state: context.state, configs, project, desktop: { binding: desktop.binding, disabled: desktop.disabled, jobs: desktop.jobs } }) };
132
+ }
133
+ async preview({ id, kind }) {
134
+ const context = await this.context(id, kind);
135
+ return context.private.locked(async (desktop) => {
136
+ const plan = await this.plan(context, kind, desktop);
137
+ const token = randomUUID();
138
+ for (const [key, value] of this.previews) if (value.expires < Date.now()) this.previews.delete(key);
139
+ this.previews.set(token, { id, kind, fingerprint: plan.fingerprint, expires: Date.now() + 600000 });
140
+ const effects = {
141
+ conversation: ['停止此机器人的本机连接;解除当前 Codex 对话绑定与轮询状态。', '保留机器人凭据和项目接入;在目标对话重新启用,然后启动机器人。'],
142
+ robot: ['停止此机器人的本机连接;用新 Secret 在原协作群完成验证码验证。', '保持 Bot ID、群、本人身份和 Codex 对话绑定;保存后可重新启动连接。'],
143
+ project: ['停止此机器人的本机连接;解除对话绑定;移除本 clone 的协作 MCP 与生成的 Skill。', '项目代码、其他 MCP、模型配置、Git 历史和团队任务记录保留。', '本机身份、私有配置和任务 worktree 保留,可从“已移除的接入”恢复。'],
144
+ restore: ['恢复原成员身份与本 clone 的协作 MCP、Skill。', '机器人保持停止、对话保持未绑定;重新启用对话并启动连接。']
145
+ }[kind];
146
+ return { id, kind, title: kinds[kind], agent_id: context.state.agent_id, repo: context.state.repo, bot_id: context.config?.wecom?.bot_id || null,
147
+ thread_id: desktop.binding?.thread_id || null, preview_token: token, effects, requires_pause: ['conversation', 'project'].includes(kind) };
148
+ });
149
+ }
150
+ ticket(input, fingerprint) {
151
+ const value = this.previews.get(input.preview_token);
152
+ if (!value || value.id !== input.id || value.kind !== input.kind || value.expires < Date.now() || value.fingerprint !== fingerprint) throw new SetupError('项目配置或绑定已改变,或预览已过期,请重新预览。');
153
+ }
154
+ async stop(context, plan) {
155
+ if (!plan.lock || !alive(plan.lock.pid)) return;
156
+ try { this.manager.runCli('stop', plan.lock.config); }
157
+ catch { throw new SetupError('机器人停止失败,未修改配置;请检查 Runner 后重试。'); }
158
+ const lock = await readRunnerLock(join(context.dataDir, 'runner.pid.json'));
159
+ if (lock && alive(lock.pid)) throw new SetupError('机器人仍在停止,请稍后重试;未修改配置。');
160
+ }
161
+ async discover(input) {
162
+ if (input.kind !== 'robot' || typeof input.secret !== 'string' || !input.secret.trim()) throw new SetupError('请填写新机器人 Secret。');
163
+ const context = await this.context(input.id, input.kind);
164
+ return context.private.locked(async (desktop) => {
165
+ const plan = await this.plan(context, input.kind, desktop); this.ticket(input, plan.fingerprint);
166
+ await this.stop(context, plan);
167
+ await this.manager.checkBotAvailable(context.config.wecom.bot_id);
168
+ const session = this.manager.discovery.begin({ bot_id: context.config.wecom.bot_id, secret: input.secret, chat_ids: context.config.wecom.chat_ids });
169
+ this.previews.get(input.preview_token).discovery_id = session.id;
170
+ return session;
171
+ });
172
+ }
173
+ cancel({ id, preview_token }) {
174
+ const ticket = this.previews.get(preview_token);
175
+ if (!ticket || ticket.id !== id) return { cancelled: true };
176
+ if (ticket.discovery_id) this.manager.discovery.cancel(ticket.discovery_id);
177
+ this.previews.delete(preview_token);
178
+ return { cancelled: true, runner_restarted: false };
179
+ }
180
+ async apply(input) {
181
+ const context = await this.context(input.id, input.kind);
182
+ if (input.confirmation !== context.state.agent_id) throw new SetupError('请填写当前 Agent 名称确认操作。');
183
+ if (['conversation', 'project'].includes(input.kind) && input.heartbeat_paused !== true) throw new SetupError('请先在原 Codex 对话暂停协作轮询,再确认重置。');
184
+ return context.private.locked(async (desktop) => {
185
+ const plan = await this.plan(context, input.kind, desktop); this.ticket(input, plan.fingerprint);
186
+ let proof;
187
+ if (input.kind === 'robot') {
188
+ const ticket = this.previews.get(input.preview_token);
189
+ if (!ticket.discovery_id || ticket.discovery_id !== input.discovery_id) throw new SetupError('请先使用本次新凭据完成群验证码验证。');
190
+ const view = this.manager.discovery.view(input.discovery_id);
191
+ const { team } = await readPeerConfig(context.configPath);
192
+ if (view.status !== 'verified' || !context.config.wecom.chat_ids.includes(view.proof?.chatid) || view.proof?.userid !== team.agents[context.state.agent_id].wecom_userid) throw new SetupError('请用原绑定本人账号,在原协作群完成验证码验证。');
193
+ proof = this.manager.discovery.consume(input.discovery_id);
194
+ }
195
+ await this.stop(context, plan);
196
+ const oldState = structuredClone(context.state); const oldDesktop = structuredClone(desktop);
197
+ const nextState = { ...structuredClone(oldState), last_error: null }; const changes = [];
198
+ if (input.kind === 'robot') {
199
+ for (const item of plan.configs) changes.push({ path: item.path, before: item.before, after: json({ ...item.value, wecom: { ...item.value.wecom, secret: proof.secret } }) });
200
+ nextState.wecom = { ...nextState.wecom, secret: proof.secret };
201
+ if (nextState.bundle?.wecom) nextState.bundle.wecom.secret = proof.secret;
202
+ } else if (input.kind === 'project') {
203
+ changes.push(...plan.project.files);
204
+ for (const item of plan.configs) changes.push({ path: item.path, before: item.before, after: json({ ...item.value, local_disabled: true }) });
205
+ nextState.phase = 'removed';
206
+ } else if (input.kind === 'restore') {
207
+ const backup = await readPrivateJson(oldState.reset_backup);
208
+ // Restore only the generated MCP section, preserving new unrelated settings.
209
+ const original = backup.changes.find((item) => item.path === plan.project.configFile);
210
+ if (original) {
211
+ const section = backup.mcp_section;
212
+ if (!section) throw new SetupError('恢复记录缺少 MCP 配置,请先手动检查。');
213
+ const before = plan.project.before || '';
214
+ changes.push({ path: plan.project.configFile, before: plan.project.before, after: `${before}${before && !before.endsWith('\n') ? '\n' : ''}${section}` });
215
+ }
216
+ const skill = backup.changes.find((item) => item.path === plan.project.skillFile);
217
+ if (skill) changes.push({ path: skill.path, before: null, after: skill.before });
218
+ for (const item of plan.configs) { const value = { ...item.value }; delete value.local_disabled; changes.push({ path: item.path, before: item.before, after: json(value) }); }
219
+ nextState.phase = backup.state.phase;
220
+ }
221
+ const backupPath = join(this.manager.home, 'reset-backups', `${randomUUID()}.json`);
222
+ const mcpSection = input.kind === 'project' && plan.project.files.some((item) => item.path === plan.project.configFile)
223
+ ? (await installedProjectConfig({ repoPath: oldState.repo, agentId: oldState.agent_id })).section : null;
224
+ await replace(backupPath, json({ schema_version: 1, created_at: new Date().toISOString(), kind: input.kind, state: oldState, desktop: oldDesktop, changes, config_paths: plan.configs.map((item) => item.path), mcp_section: mcpSection }));
225
+ nextState.reset_backup = backupPath;
226
+ if (input.kind !== 'robot') { desktop.binding = null; desktop.last_poll_at = null; desktop.poll_count = 0; desktop.disabled = input.kind === 'project'; }
227
+ const applied = [];
228
+ try {
229
+ for (const change of changes) {
230
+ if (await optional(change.path) !== change.before) throw new SetupError('文件已改变,请刷新后重新预览。');
231
+ applied.push(change); await replace(change.path, change.after);
232
+ }
233
+ await context.private.write(desktop); await this.manager.save(nextState);
234
+ } catch (error) {
235
+ for (const change of applied.reverse()) await replace(change.path, change.before);
236
+ await context.private.write(oldDesktop); await this.manager.save(oldState);
237
+ throw error;
238
+ }
239
+ if (input.kind === 'project' && plan.project.skill !== null) await rmdir(plan.project.skillDir).catch((error) => { if (!['ENOENT', 'ENOTEMPTY'].includes(error.code)) throw error; });
240
+ await rm(join(context.dataDir, 'wecom-health.json'), { force: true });
241
+ this.previews.delete(input.preview_token);
242
+ return { id: input.id, kind: input.kind, done: true, backup_path: backupPath, runner_online: false, automation_paused: false,
243
+ binding_prompt: bindingPrompt(oldState.agent_id, oldState.repo),
244
+ message: input.kind === 'restore' ? '已恢复原身份的本机接入。重新加载项目 MCP,复制启用指令到目标 Codex 对话,然后启动机器人连接。' : input.kind === 'project' ? '已移除本机接入。项目代码与团队记录保留,可在首页恢复或接入其他身份。' : input.kind === 'robot' ? '新凭据已验证并保存。点击“启动机器人连接”恢复群连接。' : '已解除对话绑定。复制启用指令到目标 Codex 对话,完成绑定后启动机器人连接。' };
245
+ });
246
+ }
247
+ }
@@ -34,13 +34,14 @@ export async function startSetupServer({ manager = new SetupManager(), port = 0
34
34
  const session = (req.headers.cookie || '').split(';').map((item) => item.trim()).find((item) => item.startsWith(`${cookieName}=`))?.slice(cookieName.length + 1);
35
35
  if (!same(session, token)) return json(401, { error: '请从终端重新打开向导链接。' });
36
36
  if (req.method === 'GET' && url.pathname === '/api/overview') return json(200, await manager.overview());
37
+ if (req.method === 'GET' && url.pathname === '/api/admin-draft') return json(200, await manager.exclusive(() => manager.adminDraft({ id: url.searchParams.get('id') })));
37
38
  if (req.method === 'GET' && url.pathname === '/api/discovery') return json(200, manager.discovery.view(url.searchParams.get('id')));
38
39
  if (req.method === 'GET' && url.pathname === '/api/profile') return json(200, await manager.exclusive(() => manager.detail(url.searchParams.get('id'), { localOnly: url.searchParams.get('local') === '1' })));
39
40
  if (req.method === 'GET' && url.pathname === '/api/export') {
40
41
  const value = await manager.exclusive(() => manager.exportedBundle(url.searchParams.get('id'), url.searchParams.get('invite')));
41
42
  res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Disposition': `attachment; filename="${value.filename}"` }); res.end(value.content); return;
42
43
  }
43
- const actions = { '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), 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
45
  const key = url.pathname.replace('/api/', ''); const action = Object.hasOwn(actions, key) ? actions[key] : null;
45
46
  if (req.method === 'POST' && url.pathname.startsWith('/api/') && action) return json(200, await manager.exclusive(action));
46
47
  return json(404, { error: '操作不存在。' });