xuym-codex-collab 0.1.1 → 0.3.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.
- package/README.md +64 -109
- package/apps/onboarding/bundle.mjs +24 -0
- package/apps/onboarding/discovery.mjs +44 -0
- package/apps/onboarding/errors.mjs +1 -0
- package/apps/onboarding/main.mjs +32 -0
- package/apps/onboarding/manager.mjs +532 -0
- package/apps/onboarding/server.mjs +56 -0
- package/apps/onboarding/web/app.js +154 -0
- package/apps/onboarding/web/index.html +15 -0
- package/apps/onboarding/web/style.css +3 -0
- package/apps/peer/desktop.mjs +8 -6
- package/apps/peer/mcp.mjs +1 -1
- package/apps/peer/runner.mjs +5 -5
- package/apps/peer/runtime.mjs +4 -1
- package/apps/peer/wecom.mjs +76 -21
- package/bin/codex-collab.mjs +10 -7
- package/docs/architecture.md +3 -1
- package/docs/desktop-poll.md +16 -8
- package/docs/legacy-center.md +109 -0
- package/docs/local-automatic-test.md +1 -1
- package/docs/local-peer-test.md +1 -1
- package/docs/onboarding-audit.md +88 -0
- package/docs/onboarding.md +92 -69
- package/docs/peer.md +13 -7
- package/docs/protocol.md +2 -0
- package/docs/team-registry.md +65 -0
- package/docs/terminal-onboarding.md +98 -0
- package/package.json +6 -3
- package/packages/codex-kit/templates/.agents/skills/team-collab-desktop/SKILL.md +3 -3
- package/packages/peer/config.mjs +7 -3
- package/packages/peer/protocol.mjs +22 -7
- package/packages/peer/registry.mjs +133 -0
- package/packages/peer/review.mjs +52 -0
- package/packages/peer/service.mjs +24 -16
- package/packages/peer/store.mjs +59 -7
- package/schemas/encrypted-invite.schema.json +42 -0
- package/schemas/event.schema.json +51 -0
- package/schemas/peer-event.schema.json +44 -0
- package/schemas/peer-task.schema.json +21 -0
- package/schemas/task.schema.json +18 -0
- package/schemas/team-enrollment.schema.json +61 -0
- package/schemas/team-invitation.schema.json +108 -0
- package/schemas/team-policy.schema.json +182 -0
- package/schemas/team-version.schema.json +70 -0
- package/scripts/peer/cli.mjs +5 -2
- package/scripts/peer/onboarding.mjs +16 -4
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
import { defaultReviewRules } from '../../packages/peer/review.mjs';
|
|
2
|
+
import { SetupError } from './errors.mjs';
|
|
3
|
+
import { createPublicKey, randomUUID } from 'node:crypto';
|
|
4
|
+
import { chmod, mkdir, readFile, readdir, realpath, rename, stat, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { canonical, digest, generateIdentity, readPrivateJson, validateTeam } from '../../packages/peer/config.mjs';
|
|
10
|
+
import { PeerGitStore, git } from '../../packages/peer/store.mjs';
|
|
11
|
+
import { approveEnrollment, enrollmentRequest, invitationFile, invitationRecord, policyVersion, recordHash, registryPath, requestFile, validateEnrollment, validateHistory, validateInvitation, versionFile, readRegistry } from '../../packages/peer/registry.mjs';
|
|
12
|
+
import { attachProject, buildTeamPolicy, createMemberConfig, diagnosePeer, getOrCreateIdentityFiles } from '../../scripts/peer/onboarding.mjs';
|
|
13
|
+
import { bindingPrompt } from '../../scripts/peer/setup-desktop-project.mjs';
|
|
14
|
+
import { readPeerConfig, loadRuntime } from '../peer/runtime.mjs';
|
|
15
|
+
import { DesktopExecutor } from '../peer/desktop.mjs';
|
|
16
|
+
import { alive, readRunnerLock } from '../peer/process.mjs';
|
|
17
|
+
import { BotDiscovery } from './discovery.mjs';
|
|
18
|
+
import { encryptBundle, decryptBundle, remoteIdentity } from './bundle.mjs';
|
|
19
|
+
const packageRoot = fileURLToPath(new URL('../../', import.meta.url));
|
|
20
|
+
const idPattern = /^[a-zA-Z0-9_-]{1,64}$/;
|
|
21
|
+
const uuidPattern = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
22
|
+
const inside = (parent, child) => { const path = relative(parent, child); return path === '' || path !== '..' && !path.startsWith('../') && !isAbsolute(path); };
|
|
23
|
+
async function exists(path) { try { await stat(path); return true; } catch (error) { if (error.code === 'ENOENT') return false; throw error; } }
|
|
24
|
+
export async function savePrivate(path, value) {
|
|
25
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
26
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
27
|
+
await writeFile(temporary, JSON.stringify(value, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
|
|
28
|
+
await rename(temporary, path); await chmod(path, 0o600);
|
|
29
|
+
}
|
|
30
|
+
function testArgv(value) {
|
|
31
|
+
if (!Array.isArray(value) || !value.length || value.length > 32 || value.some((part) => typeof part !== 'string' || !part || part.length > 2000)) throw new SetupError('验证命令应是 JSON 字符串数组,例如 ["npm","test"]。');
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
function capabilities(value) {
|
|
35
|
+
const list = typeof value === 'string' ? value.split(/[\s,,]+/).filter(Boolean) : value;
|
|
36
|
+
if (!Array.isArray(list) || !list.length || list.length > 16 || list.some((cap) => !idPattern.test(cap))) throw new SetupError('职责标签请使用英文、数字、下划线或连字符。');
|
|
37
|
+
return [...new Set(list)];
|
|
38
|
+
}
|
|
39
|
+
function gitFailure(error) {
|
|
40
|
+
const detail = `${error?.stderr || ''}\n${error?.message || ''}`.toLowerCase();
|
|
41
|
+
if (error?.killed || error?.code === 'ETIMEDOUT' || /timed out|timeout/.test(detail)) {
|
|
42
|
+
return new SetupError('Git 访问远程仓库超时,请检查网络或代理后重试。');
|
|
43
|
+
}
|
|
44
|
+
if (/authentication failed|could not read username|permission denied|access denied|repository not found|403|401/.test(detail)) {
|
|
45
|
+
return new SetupError('无法访问远程仓库,请检查 GitHub 登录状态、仓库地址和读写权限。不要把 Token 或 Secret 填入向导。');
|
|
46
|
+
}
|
|
47
|
+
if (/could not resolve host|failed to connect|network is unreachable|connection refused|network failure/.test(detail)) {
|
|
48
|
+
return new SetupError('无法连接远程仓库,请检查网络或代理后重试。');
|
|
49
|
+
}
|
|
50
|
+
return new SetupError('Git 无法读取远程仓库,请检查 origin 地址、Git 权限和网络后重试。');
|
|
51
|
+
}
|
|
52
|
+
function shellQuote(value) { return `'${String(value).replaceAll("'", "'\\''")}'`; }
|
|
53
|
+
function mcpSection(text, serverName) {
|
|
54
|
+
let active = false; const lines = [];
|
|
55
|
+
for (const line of String(text || '').split(/\r?\n/)) {
|
|
56
|
+
if (/^\s*\[/.test(line)) {
|
|
57
|
+
active = line.trim() === `[mcp_servers.${serverName}]`;
|
|
58
|
+
if (active) lines.push(line);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (active) lines.push(line);
|
|
62
|
+
}
|
|
63
|
+
return lines.join('\n');
|
|
64
|
+
}
|
|
65
|
+
function projectMcpConfigured(projectConfig, configPath, config) {
|
|
66
|
+
const paths = [configPath];
|
|
67
|
+
if (config.execution_mode === 'desktop_poll') paths.push(join(dirname(configPath), `${config.agent_id}-desktop-peer.json`));
|
|
68
|
+
const section = mcpSection(projectConfig, `team-collab-${config.agent_id}`);
|
|
69
|
+
return !/^\s*enabled\s*=\s*false\s*(?:#.*)?$/m.test(section) && paths.some((path) => section.includes(JSON.stringify(path)));
|
|
70
|
+
}
|
|
71
|
+
async function ensureRepo(path) {
|
|
72
|
+
if (typeof path !== 'string' || !isAbsolute(path)) throw new SetupError('请选择业务仓库的本机绝对路径。');
|
|
73
|
+
let repo;
|
|
74
|
+
try { repo = await realpath(path); } catch { throw new SetupError('请选择存在的业务仓库目录。'); }
|
|
75
|
+
try {
|
|
76
|
+
if (await git(repo, 'rev-parse', '--show-toplevel') !== repo) throw new SetupError('请选择 Git 仓库根目录。');
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (error instanceof SetupError) throw error;
|
|
79
|
+
throw new SetupError('请选择 Git 仓库根目录。');
|
|
80
|
+
}
|
|
81
|
+
let remote;
|
|
82
|
+
try { remote = await git(repo, 'remote', 'get-url', 'origin'); }
|
|
83
|
+
catch (error) {
|
|
84
|
+
const detail = `${error?.stderr || ''}\n${error?.message || ''}`.toLowerCase();
|
|
85
|
+
if (/no such remote|does not appear to be a git repository/.test(detail)) throw new SetupError('该目录没有名为 origin 的远程仓库,请先配置 GitHub origin。');
|
|
86
|
+
throw gitFailure(error);
|
|
87
|
+
}
|
|
88
|
+
let main;
|
|
89
|
+
try { main = await git(repo, 'ls-remote', '--heads', 'origin', 'refs/heads/main'); }
|
|
90
|
+
catch (error) { throw gitFailure(error); }
|
|
91
|
+
if (!main) {
|
|
92
|
+
let heads;
|
|
93
|
+
try { heads = await git(repo, 'ls-remote', '--heads', 'origin'); }
|
|
94
|
+
catch (error) { throw gitFailure(error); }
|
|
95
|
+
if (!heads) throw new SetupError(`远程仓库为空,未找到 origin/main。请先在终端执行:\ngit -C ${shellQuote(repo)} push -u origin main`);
|
|
96
|
+
throw new SetupError(`远程仓库可访问,但没有 main 分支。请先将本地 main 推送到 origin:\ngit -C ${shellQuote(repo)} push -u origin main`);
|
|
97
|
+
}
|
|
98
|
+
return { repo, remote };
|
|
99
|
+
}
|
|
100
|
+
function validateBundle(value) {
|
|
101
|
+
if (!value || value.schema_version !== 1 || value.kind !== 'codex-collab-invite' || typeof value.remote !== 'string' || !value.remote || !value.history || !value.invitation || !value.wecom || typeof value.wecom.secret !== 'string' || !value.wecom.secret) throw new SetupError('接入包格式无效。');
|
|
102
|
+
const history = validateHistory(value.history, value.root_hash);
|
|
103
|
+
validateInvitation(value.invitation, history); testArgv(value.test_argv);
|
|
104
|
+
if (Date.now() > Date.parse(value.invitation.expires_at)) throw new SetupError('接入包已过期,请管理员重新生成。');
|
|
105
|
+
const claim = createPublicKey(value.claim_private_key).export({ type: 'spki', format: 'pem' });
|
|
106
|
+
if (claim !== value.invitation.claim_public_key || value.wecom.bot_id !== value.invitation.bot_id) throw new SetupError('接入包验证失败。');
|
|
107
|
+
return { bundle: value, history };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export class SetupManager {
|
|
111
|
+
constructor({ home = join(homedir(), '.config', 'codex-collab', 'workspace'), discovery = new BotDiscovery(), runCli } = {}) {
|
|
112
|
+
this.home = resolve(home); this.discovery = discovery; this.queue = Promise.resolve();
|
|
113
|
+
this.runCli = runCli || ((command, config) => {
|
|
114
|
+
const result = spawnSync(process.execPath, [join(packageRoot, 'bin/codex-collab.mjs'), command, config], { encoding: 'utf8', timeout: 35000, maxBuffer: 200000 });
|
|
115
|
+
if (result.status !== 0) throw new Error(`本机 ${command} 操作未完成,请检查项目 Git 权限或 Runner 日志。`);
|
|
116
|
+
return result.stdout.trim();
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
async exclusive(fn) { const action = this.queue.then(fn); this.queue = action.catch(() => {}); return action; }
|
|
120
|
+
path(id) { if (!uuidPattern.test(id || '')) throw new SetupError('无效项目标识。'); return join(this.home, 'profiles', id); }
|
|
121
|
+
async state(id) { return readPrivateJson(join(this.path(id), 'setup.json')); }
|
|
122
|
+
async save(state) { return savePrivate(join(this.path(state.id), 'setup.json'), state); }
|
|
123
|
+
async profileStore(state, { enrollmentOnly = false } = {}) {
|
|
124
|
+
const identity = await readPrivateJson(state.identity_path);
|
|
125
|
+
const initial = state.bootstrap_team;
|
|
126
|
+
return new PeerGitStore({ repo: state.repo, dataDir: state.data_dir || join(this.path(state.id), 'data'), team: initial,
|
|
127
|
+
agentId: state.agent_id, privateKey: identity.private_key, registryRoot: state.root_hash, enrollmentOnly }).init();
|
|
128
|
+
}
|
|
129
|
+
async overview() {
|
|
130
|
+
await mkdir(join(this.home, 'profiles'), { recursive: true, mode: 0o700 });
|
|
131
|
+
const entries = (await readdir(join(this.home, 'profiles'))).filter((id) => uuidPattern.test(id));
|
|
132
|
+
const profiles = [];
|
|
133
|
+
for (const id of entries) {
|
|
134
|
+
const state = await this.state(id);
|
|
135
|
+
profiles.push({ id, agent_id: state.agent_id, role: state.role, repo: state.repo, phase: state.phase, error: state.last_error || null });
|
|
136
|
+
}
|
|
137
|
+
return { profiles };
|
|
138
|
+
}
|
|
139
|
+
async checkBotAvailable(botId) {
|
|
140
|
+
for (const profile of (await this.overview()).profiles) {
|
|
141
|
+
const state = await this.state(profile.id);
|
|
142
|
+
const lock = await readRunnerLock(join(state.data_dir || join(this.path(profile.id), 'data'), 'runner.pid.json'));
|
|
143
|
+
if (state.wecom?.bot_id === botId && lock && alive(lock.pid)) throw new SetupError('此机器人已有本机 Runner 连接,请先在项目状态中停止机器人连接。');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async beginDiscovery(input) {
|
|
147
|
+
let credentials = input;
|
|
148
|
+
if (input.profile_id) {
|
|
149
|
+
const state = await this.state(input.profile_id);
|
|
150
|
+
if (!state.bundle) throw new SetupError('此项目没有待验证的接入包。');
|
|
151
|
+
credentials = { ...state.bundle.wecom, chat_ids: state.bundle.invitation.chat_ids };
|
|
152
|
+
}
|
|
153
|
+
await this.checkBotAvailable(credentials.bot_id);
|
|
154
|
+
if (input.repo) {
|
|
155
|
+
const { repo } = await ensureRepo(input.repo);
|
|
156
|
+
const toml = await readFile(join(repo, '.codex/config.toml'), 'utf8').catch(() => '');
|
|
157
|
+
for (const match of toml.matchAll(/"((?:[^"\\]|\\.)*\.json)"/g)) {
|
|
158
|
+
try {
|
|
159
|
+
const path = JSON.parse(match[0]); const { config } = await readPeerConfig(path);
|
|
160
|
+
const lock = await readRunnerLock(join(config.data_dir, 'runner.pid.json'));
|
|
161
|
+
if (config.wecom?.bot_id === credentials.bot_id && lock && alive(lock.pid)) throw new SetupError('此项目原有机器人仍在连接,请先停止原 Runner 或导入已有配置。');
|
|
162
|
+
} catch (error) { if (error instanceof SetupError) throw error; }
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return this.discovery.begin(credentials);
|
|
166
|
+
}
|
|
167
|
+
async createAdmin(input) {
|
|
168
|
+
const agentId = input.agent_id?.trim(); if (!idPattern.test(agentId || '')) throw new SetupError('请填写有效的成员名称。');
|
|
169
|
+
const caps = capabilities(input.capabilities); const argv = testArgv(input.test_argv || ['sh', 'test.sh']);
|
|
170
|
+
const { repo, remote } = await ensureRepo(input.repo);
|
|
171
|
+
if (inside(repo, this.home)) throw new SetupError('本机配置目录必须在业务仓库之外。');
|
|
172
|
+
for (const profile of (await this.overview()).profiles) if (profile.repo === repo) throw new SetupError('这个 clone 已接入其他身份,请选择独立 clone。');
|
|
173
|
+
const proof = this.discovery.consume(input.discovery_id);
|
|
174
|
+
const id = randomUUID(); const directory = this.path(id);
|
|
175
|
+
const identity = await getOrCreateIdentityFiles(agentId, join(this.home, 'identities'));
|
|
176
|
+
const team = buildTeamPolicy({ repo: input.repo_id?.trim() || remote, reviewer: agentId, defaultRoute: caps[0],
|
|
177
|
+
agents: [{ id: agentId, role: 'admin', capabilities: caps, publicKey: await readFile(identity.public_key_file, 'utf8'), wecomUserid: proof.userid }],
|
|
178
|
+
routes: Object.fromEntries(caps.map((cap) => [cap, [agentId]])) });
|
|
179
|
+
team.schema_version = 3; team.review_rules = defaultReviewRules(team.routes);
|
|
180
|
+
const record = policyVersion(team, null, agentId, (await readPrivateJson(identity.private_key_file)).private_key);
|
|
181
|
+
const state = { id, role: 'admin', agent_id: agentId, repo, remote, identity_path: identity.private_key_file, bootstrap_team: team,
|
|
182
|
+
root_hash: recordHash(record), genesis: record, test_argv: argv,
|
|
183
|
+
wecom: { bot_id: proof.bot_id, secret: proof.secret, chat_ids: [proof.chatid], commands_enabled: true, allow_admin_commands: true }, phase: 'initializing' };
|
|
184
|
+
await this.save(state);
|
|
185
|
+
return this.resume(id);
|
|
186
|
+
}
|
|
187
|
+
async importExisting({ config_path }) {
|
|
188
|
+
if (typeof config_path !== 'string' || !isAbsolute(config_path)) throw new SetupError('请选择已有配置的完整路径。');
|
|
189
|
+
const { config, team } = await readPeerConfig(config_path);
|
|
190
|
+
if (config.execution_mode !== 'desktop_poll') throw new SetupError('目前向导导入需要已有 desktop_poll 配置。');
|
|
191
|
+
for (const profile of (await this.overview()).profiles) if (profile.repo === config.repo) {
|
|
192
|
+
const existing = await this.state(profile.id); if (existing.existing_config === resolve(config_path)) return this.resume(profile.id);
|
|
193
|
+
throw new SetupError('该项目已导入。');
|
|
194
|
+
}
|
|
195
|
+
const lock = await readRunnerLock(join(config.data_dir, 'runner.pid.json'));
|
|
196
|
+
if (lock && alive(lock.pid)) throw new SetupError('请先用原配置停止该机器人的 Runner,再导入;原有对话绑定会保留。');
|
|
197
|
+
if (!config.wecom) throw new SetupError('已有配置需要包含企业微信机器人。');
|
|
198
|
+
const identity = { schema_version: 1, agent_id: config.agent_id, private_key: config.private_key };
|
|
199
|
+
const id = randomUUID(); const identityPath = join(this.path(id), 'identity.json');
|
|
200
|
+
await savePrivate(identityPath, identity);
|
|
201
|
+
let genesis, rootHash = config.team_registry_root;
|
|
202
|
+
const store = await new PeerGitStore({ repo: config.repo, dataDir: config.data_dir, team, agentId: config.agent_id, privateKey: config.private_key, registryRoot: rootHash }).init();
|
|
203
|
+
if (!rootHash) {
|
|
204
|
+
const firstPath = join(store.checkout, '.agent-collab/team/versions/000001.json');
|
|
205
|
+
if (await exists(firstPath)) {
|
|
206
|
+
const first = JSON.parse(await readFile(firstPath, 'utf8'));
|
|
207
|
+
if (digest(first.team) !== digest(team)) throw new SetupError('仓库团队根策略与本机已信任策略不一致,请联系管理员。');
|
|
208
|
+
rootHash = recordHash(first); await readRegistry(store.checkout, rootHash);
|
|
209
|
+
} else {
|
|
210
|
+
if (team.agents[config.agent_id].role !== 'admin') throw new SetupError('请先由旧团队管理员导入,之后成员也可导入原配置。');
|
|
211
|
+
genesis = policyVersion(team, null, config.agent_id, config.private_key); rootHash = recordHash(genesis);
|
|
212
|
+
await store.registryTransaction(async () => {
|
|
213
|
+
await store.listLocal();
|
|
214
|
+
if (await exists(firstPath)) {
|
|
215
|
+
if (canonical(JSON.parse(await readFile(firstPath, 'utf8'))) !== canonical(genesis)) throw new SetupError('仓库已有另一团队版本,请重新导入同步。');
|
|
216
|
+
return { value: null };
|
|
217
|
+
}
|
|
218
|
+
return { files: [[versionFile(genesis), genesis]], value: null };
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const state = { id, role: team.agents[config.agent_id].role, agent_id: config.agent_id, repo: config.repo,
|
|
223
|
+
remote: await git(config.repo, 'remote', 'get-url', 'origin'), identity_path: identityPath, bootstrap_team: team, root_hash: rootHash,
|
|
224
|
+
test_argv: config.test_argv || ['sh', 'test.sh'], wecom: config.wecom, phase: 'ready', existing_config: resolve(config_path), data_dir: config.data_dir, attached: true };
|
|
225
|
+
await savePrivate(join(this.path(id), 'peer.json'), { ...config, team_registry_root: rootHash });
|
|
226
|
+
// Existing MCP processes keep using this path and the same private desktop state.
|
|
227
|
+
await savePrivate(resolve(config_path), { ...config, team_registry_root: rootHash });
|
|
228
|
+
await this.save(state); this.runCli('start', resolve(config_path)); return this.detail(id, { localOnly: true });
|
|
229
|
+
}
|
|
230
|
+
async resume(id) {
|
|
231
|
+
const state = await this.state(id);
|
|
232
|
+
try {
|
|
233
|
+
if (state.role === 'admin' && state.phase === 'initializing') {
|
|
234
|
+
const bootstrap = new PeerGitStore({ repo: state.repo, dataDir: join(this.path(id), 'data'), team: state.bootstrap_team, agentId: state.agent_id,
|
|
235
|
+
privateKey: (await readPrivateJson(state.identity_path)).private_key });
|
|
236
|
+
await bootstrap.init();
|
|
237
|
+
await bootstrap.registryTransaction(async () => {
|
|
238
|
+
const path = join(bootstrap.checkout, versionFile(state.genesis));
|
|
239
|
+
if (await exists(path)) {
|
|
240
|
+
if (canonical(JSON.parse(await readFile(path, 'utf8'))) !== canonical(state.genesis)) throw new SetupError('仓库已有另一团队,请导入该团队的接入包。');
|
|
241
|
+
return { value: null };
|
|
242
|
+
}
|
|
243
|
+
await bootstrap.listLocal(); // Existing tasks must replay against this exact genesis policy before adoption.
|
|
244
|
+
return { files: [[versionFile(state.genesis), state.genesis]], value: null };
|
|
245
|
+
});
|
|
246
|
+
state.phase = 'configuring'; await this.save(state);
|
|
247
|
+
}
|
|
248
|
+
if (state.role === 'member' && ['verified', 'waiting'].includes(state.phase)) {
|
|
249
|
+
let store = await this.profileStore(state, { enrollmentOnly: true });
|
|
250
|
+
if (state.phase === 'verified') {
|
|
251
|
+
await store.registryTransaction(async (history) => {
|
|
252
|
+
const invitation = JSON.parse(await readFile(join(store.checkout, invitationFile(state.request.invite_id)), 'utf8'));
|
|
253
|
+
if (canonical(invitation) !== canonical(state.bundle.invitation)) throw new SetupError('仓库邀请与接入包不一致。');
|
|
254
|
+
const approved = history.records.some((record) => record.enrollment?.invite_id === invitation.invite_id && record.enrollment.request_id === state.request.request_id)
|
|
255
|
+
&& history.latest.team.agents[state.agent_id]?.public_key === state.request.public_key;
|
|
256
|
+
validateEnrollment(state.request, invitation, history, { allowExpired: approved });
|
|
257
|
+
const path = requestFile(state.request.request_id);
|
|
258
|
+
if (await exists(join(store.checkout, path))) {
|
|
259
|
+
if (canonical(JSON.parse(await readFile(join(store.checkout, path), 'utf8'))) !== canonical(state.request)) throw new SetupError('入队申请内容冲突。');
|
|
260
|
+
return { value: null };
|
|
261
|
+
}
|
|
262
|
+
return { files: [[path, state.request]], value: null };
|
|
263
|
+
});
|
|
264
|
+
state.phase = 'waiting'; await this.save(state);
|
|
265
|
+
}
|
|
266
|
+
if (!store.team.agents[state.agent_id] && state.own_admin_id) {
|
|
267
|
+
const admin = await this.state(state.own_admin_id);
|
|
268
|
+
const invite = state.bundle.invitation;
|
|
269
|
+
if (admin.role !== 'admin' || admin.phase !== 'ready' || admin.agent_id !== invite.actor_id || admin.root_hash !== state.root_hash ||
|
|
270
|
+
!invite.owner_userid || invite.owner_userid !== store.team.agents[admin.agent_id]?.wecom_userid || state.request.wecom_userid !== invite.owner_userid) throw new SetupError('本人机器人接入身份不一致,请通过原管理员项目继续。');
|
|
271
|
+
await this.approve({ id: admin.id, request_id: state.request.request_id });
|
|
272
|
+
store = await this.profileStore(state, { enrollmentOnly: true });
|
|
273
|
+
}
|
|
274
|
+
const member = store.team.agents[state.agent_id];
|
|
275
|
+
const identity = await readPrivateJson(state.identity_path);
|
|
276
|
+
if (!member) return this.detail(id, { localOnly: true });
|
|
277
|
+
if (member.public_key !== createPublicKey(identity.private_key).export({ type: 'spki', format: 'pem' })) throw new SetupError('管理员已批准另一个身份,请联系管理员。');
|
|
278
|
+
state.bootstrap_team = store.team; state.phase = 'configuring'; await this.save(state);
|
|
279
|
+
}
|
|
280
|
+
if (state.phase === 'configuring') {
|
|
281
|
+
const store = await this.profileStore(state);
|
|
282
|
+
const teamPath = join(this.path(id), 'team.json'); await savePrivate(teamPath, store.team);
|
|
283
|
+
const configPath = join(this.path(id), 'peer.json');
|
|
284
|
+
if (!await exists(configPath)) await createMemberConfig({ teamPath, identityPath: state.identity_path, repoPath: state.repo,
|
|
285
|
+
dataDir: join(this.path(id), 'data'), configPath, testArgv: state.test_argv });
|
|
286
|
+
const config = await readPrivateJson(configPath);
|
|
287
|
+
await savePrivate(configPath, { ...config, team_registry_root: state.root_hash, wecom: state.wecom });
|
|
288
|
+
await diagnosePeer(configPath, { testPush: true });
|
|
289
|
+
if (!state.attached) {
|
|
290
|
+
const existing = await readFile(join(state.repo, '.codex', 'config.toml'), 'utf8').catch(() => '');
|
|
291
|
+
const template = await readFile(join(packageRoot, 'packages/codex-kit/templates/.agents/skills/team-collab-desktop/SKILL.md'), 'utf8');
|
|
292
|
+
const installed = await readFile(join(state.repo, '.agents/skills/team-collab-desktop/SKILL.md'), 'utf8').catch(() => '');
|
|
293
|
+
if (!existing.includes(JSON.stringify(configPath)) || installed !== template) await attachProject({ configPath, repoPath: state.repo });
|
|
294
|
+
state.attached = true; await this.save(state);
|
|
295
|
+
}
|
|
296
|
+
await savePrivate(join(this.path(id), 'binding.json'), { prompt: bindingPrompt(state.agent_id, state.repo) });
|
|
297
|
+
state.phase = 'ready'; state.last_error = null; await this.save(state);
|
|
298
|
+
}
|
|
299
|
+
if (state.phase === 'ready') this.runCli('start', state.existing_config || join(this.path(id), 'peer.json'));
|
|
300
|
+
state.last_error = null; await this.save(state);
|
|
301
|
+
return this.detail(id, { localOnly: true });
|
|
302
|
+
} catch (error) {
|
|
303
|
+
state.last_error = error instanceof SetupError ? error.message : '接入未完成,可检查 Git 权限/项目配置后点击继续。'; await this.save(state);
|
|
304
|
+
if (error instanceof SetupError) throw error;
|
|
305
|
+
throw new SetupError(state.last_error);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
async importBundle({ content, password, repo }) {
|
|
309
|
+
if (typeof content !== 'string' || content.length > 500000) throw new SetupError('请选择管理员提供的接入包。');
|
|
310
|
+
let envelope;
|
|
311
|
+
try { envelope = JSON.parse(content); } catch { throw new SetupError('接入包文件不是有效 JSON,请重新下载管理员提供的文件。'); }
|
|
312
|
+
return this.importAssignment(decryptBundle(envelope, password), repo);
|
|
313
|
+
}
|
|
314
|
+
async importAssignment(value, repo, ownAdminId) {
|
|
315
|
+
const { bundle, history } = validateBundle(value);
|
|
316
|
+
const selected = await ensureRepo(repo);
|
|
317
|
+
if (inside(selected.repo, this.home)) throw new SetupError('本机配置目录必须在业务仓库之外。');
|
|
318
|
+
if (remoteIdentity(selected.remote) !== remoteIdentity(bundle.remote)) throw new SetupError('该目录的 origin 与邀请仓库不一致,请选择正确仓库;工具不会改写 origin。');
|
|
319
|
+
for (const profile of (await this.overview()).profiles) {
|
|
320
|
+
const current = await this.state(profile.id);
|
|
321
|
+
if (current.bundle?.invitation.invite_id === bundle.invitation.invite_id) return this.detail(current.id, { localOnly: true });
|
|
322
|
+
if (current.repo === selected.repo) throw new SetupError('这个 clone 已接入其他身份,请选择独立 clone。');
|
|
323
|
+
}
|
|
324
|
+
const id = randomUUID(); const identity = await getOrCreateIdentityFiles(bundle.invitation.agent_id, join(this.home, 'identities'));
|
|
325
|
+
const state = { id, role: 'member', agent_id: bundle.invitation.agent_id, repo: selected.repo, remote: selected.remote, identity_path: identity.private_key_file,
|
|
326
|
+
bootstrap_team: history.latest.team, root_hash: bundle.root_hash, bundle, test_argv: bundle.test_argv, phase: 'imported',
|
|
327
|
+
...(ownAdminId ? { own_admin_id: ownAdminId } : {}) };
|
|
328
|
+
await this.save(state); return this.detail(id, { localOnly: true });
|
|
329
|
+
}
|
|
330
|
+
async verifyMember({ id, discovery_id }) {
|
|
331
|
+
const state = await this.state(id); if (state.phase !== 'imported') throw new SetupError('此成员已经完成群验证。');
|
|
332
|
+
const proof = this.discovery.consume(discovery_id); const invite = state.bundle.invitation;
|
|
333
|
+
if (proof.bot_id !== invite.bot_id || !invite.chat_ids.includes(proof.chatid)) throw new SetupError('请使用分配的机器人和指定协作群验证。');
|
|
334
|
+
if (invite.owner_userid !== undefined && proof.userid !== invite.owner_userid) throw new SetupError('请用初始化团队时的同一个企业微信账号验证自己的新机器人。');
|
|
335
|
+
const identity = await readPrivateJson(state.identity_path);
|
|
336
|
+
state.request = enrollmentRequest({ invite, publicKey: createPublicKey(identity.private_key).export({ type: 'spki', format: 'pem' }), userid: proof.userid,
|
|
337
|
+
chatid: proof.chatid, requestId: randomUUID(), privateKey: identity.private_key, claimPrivateKey: state.bundle.claim_private_key });
|
|
338
|
+
state.wecom = { bot_id: proof.bot_id, secret: proof.secret, chat_ids: invite.chat_ids, commands_enabled: invite.commands_enabled, allow_admin_commands: true,
|
|
339
|
+
default_workflow: [invite.capabilities[0]] };
|
|
340
|
+
state.phase = 'verified'; await this.save(state); return this.resume(id);
|
|
341
|
+
}
|
|
342
|
+
async issueBundle(input, { ownRepo } = {}) {
|
|
343
|
+
const state = await this.state(input.id); if (state.role !== 'admin' || state.phase !== 'ready') throw new SetupError('请先完成管理员接入。');
|
|
344
|
+
const agentId = input.agent_id?.trim(); if (!idPattern.test(agentId || '') || agentId === state.agent_id) throw new SetupError('请填写要加入的成员名称。');
|
|
345
|
+
const caps = capabilities(input.capabilities); const store = await this.profileStore(state);
|
|
346
|
+
if (store.team.agents[agentId]) throw new SetupError('成员已存在,请复用原有接入配置。');
|
|
347
|
+
if (typeof input.bot_id !== 'string' || !input.bot_id.trim() || typeof input.secret !== 'string' || !input.secret) throw new SetupError('请填写该成员机器人的 Bot ID 和 Secret。');
|
|
348
|
+
if (input.bot_id.trim() === state.wecom.bot_id) throw new SetupError('每个 Agent 必须使用独立机器人。');
|
|
349
|
+
const ownerUserid = ownRepo ? store.team.agents[state.agent_id]?.wecom_userid : undefined;
|
|
350
|
+
if (ownRepo && !ownerUserid) throw new SetupError('请先验证管理员的企业微信本人身份。');
|
|
351
|
+
if (!ownRepo) encryptBundle({}, input.password); // Validate password before publishing an invitation.
|
|
352
|
+
const claim = generateIdentity(); const identity = await readPrivateJson(state.identity_path);
|
|
353
|
+
const inviteId = randomUUID();
|
|
354
|
+
const bundle = await store.registryTransaction(async (history) => {
|
|
355
|
+
const files = await readdir(join(store.checkout, registryPath, 'invitations')).catch((error) => { if (error.code === 'ENOENT') return []; throw error; });
|
|
356
|
+
for (const file of files) {
|
|
357
|
+
const existing = JSON.parse(await readFile(join(store.checkout, registryPath, 'invitations', file), 'utf8'));
|
|
358
|
+
validateInvitation(existing, history);
|
|
359
|
+
if (existing.invite_id === inviteId) return { value: { invitation: existing } };
|
|
360
|
+
if ((existing.agent_id === agentId || existing.bot_id === input.bot_id.trim()) && (Date.now() < Date.parse(existing.expires_at) || history.records.some((record) => record.enrollment?.invite_id === existing.invite_id))) throw new SetupError('该成员或机器人已有有效接入包,请使用已生成的文件。');
|
|
361
|
+
}
|
|
362
|
+
const invitation = invitationRecord({ invite_id: inviteId, agent_id: agentId, capabilities: caps,
|
|
363
|
+
bot_id: input.bot_id.trim(), chat_ids: state.wecom.chat_ids, commands_enabled: input.commands_enabled !== false,
|
|
364
|
+
prefer: input.prefer !== false, claim_public_key: claim.public_key, expires_at: new Date(Date.now() + 7 * 86400000).toISOString(),
|
|
365
|
+
...(ownRepo ? { owner_userid: ownerUserid } : {}) }, history.latest, state.agent_id, identity.private_key);
|
|
366
|
+
const value = { schema_version: 1, kind: 'codex-collab-invite', root_hash: state.root_hash, remote: state.remote,
|
|
367
|
+
history: history.records, invitation, claim_private_key: claim.private_key,
|
|
368
|
+
wecom: { bot_id: invitation.bot_id, secret: input.secret }, test_argv: state.test_argv };
|
|
369
|
+
// Save export before push: a lost acknowledgment can be retried from disk.
|
|
370
|
+
await savePrivate(join(this.path(input.id), 'exports', `${inviteId}.json`), ownRepo
|
|
371
|
+
? { invitation, own_bundle: value, own_repo: ownRepo }
|
|
372
|
+
: { invitation, encrypted: encryptBundle(value, input.password) });
|
|
373
|
+
return { files: [[invitationFile(inviteId), invitation]], value };
|
|
374
|
+
});
|
|
375
|
+
return { invite_id: bundle.invitation.invite_id, agent_id: agentId, filename: `${agentId}-collab-invite.json` };
|
|
376
|
+
}
|
|
377
|
+
async publishAssignment(id, inviteId) {
|
|
378
|
+
if (!uuidPattern.test(inviteId || '')) throw new SetupError('无效邀请标识。');
|
|
379
|
+
const state = await this.state(id); const bundle = await readPrivateJson(join(this.path(id), 'exports', `${inviteId}.json`));
|
|
380
|
+
const store = await this.profileStore(state);
|
|
381
|
+
await store.registryTransaction(async (history) => {
|
|
382
|
+
const path = invitationFile(inviteId); if (await exists(join(store.checkout, path))) return { value: null };
|
|
383
|
+
validateInvitation(bundle.invitation, history);
|
|
384
|
+
if (history.latest.team.agents[bundle.invitation.agent_id] || Date.now() > Date.parse(bundle.invitation.expires_at)) throw new SetupError('邀请已失效,请重新生成。');
|
|
385
|
+
const files = await readdir(join(store.checkout, registryPath, 'invitations')).catch(() => []);
|
|
386
|
+
for (const file of files) {
|
|
387
|
+
const other = JSON.parse(await readFile(join(store.checkout, registryPath, 'invitations', file), 'utf8')); validateInvitation(other, history);
|
|
388
|
+
if ((other.agent_id === bundle.invitation.agent_id || other.bot_id === bundle.invitation.bot_id) && (Date.now() < Date.parse(other.expires_at) || history.records.some((record) => record.enrollment?.invite_id === other.invite_id))) throw new SetupError('邀请与另一已发布的接入包冲突。');
|
|
389
|
+
}
|
|
390
|
+
return { files: [[path, bundle.invitation]], value: null };
|
|
391
|
+
});
|
|
392
|
+
const published = JSON.parse(await readFile(join(store.checkout, invitationFile(inviteId)), 'utf8'));
|
|
393
|
+
if (canonical(published) !== canonical(bundle.invitation)) throw new SetupError('邀请尚未发布成功,请重新生成接入包。');
|
|
394
|
+
return bundle;
|
|
395
|
+
}
|
|
396
|
+
async exportedBundle(id, inviteId) {
|
|
397
|
+
const bundle = await this.publishAssignment(id, inviteId);
|
|
398
|
+
if (!bundle.encrypted) throw new SetupError('本人机器人无需下载接入包,请点击继续接入我的机器人。');
|
|
399
|
+
return { content: JSON.stringify(bundle.encrypted, null, 2), filename: `${bundle.invitation.agent_id}-collab-invite.json` };
|
|
400
|
+
}
|
|
401
|
+
async createOwnAgent(input) {
|
|
402
|
+
const state = await this.state(input.id);
|
|
403
|
+
if (state.role !== 'admin' || state.phase !== 'ready') throw new SetupError('请从已接入的管理员项目添加自己的机器人。');
|
|
404
|
+
const selected = await ensureRepo(input.repo);
|
|
405
|
+
if (inside(selected.repo, this.home)) throw new SetupError('本机配置目录必须在业务仓库之外。');
|
|
406
|
+
if (remoteIdentity(selected.remote) !== remoteIdentity(state.remote)) throw new SetupError('新 Agent 必须选择同一业务仓库的另一个 clone。');
|
|
407
|
+
const files = await readdir(join(this.path(input.id), 'exports')).catch((error) => { if (error.code === 'ENOENT') return []; throw error; });
|
|
408
|
+
for (const file of files) {
|
|
409
|
+
const saved = await readPrivateJson(join(this.path(input.id), 'exports', file));
|
|
410
|
+
if (saved.own_bundle && saved.invitation.agent_id === input.agent_id?.trim()) {
|
|
411
|
+
if (saved.own_repo !== selected.repo || saved.invitation.bot_id !== input.bot_id?.trim()) throw new SetupError('这个 Agent 已有本人接入配置,请从已生成的配置继续。');
|
|
412
|
+
return this.resumeOwnAgent({ id: input.id, invite_id: saved.invitation.invite_id });
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
for (const profile of (await this.overview()).profiles) if (profile.repo === selected.repo) throw new SetupError('这个 clone 已接入其他身份,请选择独立 clone。');
|
|
416
|
+
const issued = await this.issueBundle({ ...input, commands_enabled: true }, { ownRepo: selected.repo });
|
|
417
|
+
return this.resumeOwnAgent({ id: input.id, invite_id: issued.invite_id });
|
|
418
|
+
}
|
|
419
|
+
async resumeOwnAgent({ id, invite_id }) {
|
|
420
|
+
const state = await this.state(id);
|
|
421
|
+
if (state.role !== 'admin' || state.phase !== 'ready') throw new SetupError('请从原管理员项目继续接入。');
|
|
422
|
+
if (!uuidPattern.test(invite_id || '')) throw new SetupError('无效邀请标识。');
|
|
423
|
+
for (const profile of (await this.overview()).profiles) {
|
|
424
|
+
const existing = await this.state(profile.id);
|
|
425
|
+
if (existing.own_admin_id === id && existing.bundle?.invitation.invite_id === invite_id) return this.detail(profile.id, { localOnly: true });
|
|
426
|
+
}
|
|
427
|
+
const saved = await this.publishAssignment(id, invite_id);
|
|
428
|
+
if (!saved.own_bundle || !saved.own_repo || saved.invitation.owner_userid !== state.bootstrap_team.agents[state.agent_id]?.wecom_userid) throw new SetupError('这不是当前管理员的本人机器人配置。');
|
|
429
|
+
return this.importAssignment(saved.own_bundle, saved.own_repo, id);
|
|
430
|
+
}
|
|
431
|
+
async approve({ id, request_id }) {
|
|
432
|
+
if (!uuidPattern.test(request_id || '')) throw new SetupError('无效申请标识。');
|
|
433
|
+
const state = await this.state(id); if (state.role !== 'admin') throw new SetupError('需要管理员确认。');
|
|
434
|
+
const store = await this.profileStore(state); const identity = await readPrivateJson(state.identity_path);
|
|
435
|
+
await store.registryTransaction(async (history) => {
|
|
436
|
+
const request = JSON.parse(await readFile(join(store.checkout, requestFile(request_id)), 'utf8'));
|
|
437
|
+
const invite = JSON.parse(await readFile(join(store.checkout, invitationFile(request.invite_id)), 'utf8'));
|
|
438
|
+
const version = approveEnrollment(request, invite, history, state.agent_id, identity.private_key);
|
|
439
|
+
return version ? { files: [[versionFile(version), version]], value: version.version } : { value: history.latest.version };
|
|
440
|
+
});
|
|
441
|
+
// Local config is a cache; registry refresh also occurs in already running peers.
|
|
442
|
+
const configPath = state.existing_config || join(this.path(id), 'peer.json');
|
|
443
|
+
const { config } = await readPeerConfig(configPath);
|
|
444
|
+
await savePrivate(config.team_file, store.team);
|
|
445
|
+
return this.detail(id);
|
|
446
|
+
}
|
|
447
|
+
async setReviewRules({ id, expected_version, review_rules }) {
|
|
448
|
+
const state = await this.state(id);
|
|
449
|
+
if (state.role !== 'admin' || state.phase !== 'ready') throw new SetupError('只有已接入的管理员可设置审核规则。');
|
|
450
|
+
if (!Number.isSafeInteger(expected_version) || expected_version < 1) throw new SetupError('请刷新工作台后再保存审核规则。');
|
|
451
|
+
const rules = structuredClone(review_rules);
|
|
452
|
+
const store = await this.profileStore(state); const identity = await readPrivateJson(state.identity_path);
|
|
453
|
+
await store.registryTransaction(async (history) => {
|
|
454
|
+
if (history.latest.team.agents[state.agent_id]?.role !== 'admin') throw new SetupError('需要管理员权限。');
|
|
455
|
+
if (history.latest.version !== expected_version) throw new SetupError('团队规则已更新,请刷新工作台后重新设置。');
|
|
456
|
+
const team = { ...structuredClone(history.latest.team), schema_version: 3, review_rules: rules };
|
|
457
|
+
try { validateTeam(team); } catch { throw new SetupError('审核规则无效:每个职责都需要规则,指定同事必须已验证企业微信本人身份。'); }
|
|
458
|
+
if (canonical(team) === canonical(history.latest.team)) return { value: history.latest.version };
|
|
459
|
+
const version = policyVersion(team, history.latest, state.agent_id, identity.private_key);
|
|
460
|
+
return { files: [[versionFile(version), version]], value: version.version };
|
|
461
|
+
});
|
|
462
|
+
const { config } = await readPeerConfig(state.existing_config || join(this.path(id), 'peer.json'));
|
|
463
|
+
await savePrivate(config.team_file, store.team);
|
|
464
|
+
return this.detail(id);
|
|
465
|
+
}
|
|
466
|
+
async detail(id, { localOnly = false } = {}) {
|
|
467
|
+
const state = await this.state(id); const result = { id, agent_id: state.agent_id, role: state.role, repo: state.repo, phase: state.phase, own_agent: !!state.own_admin_id, error: state.last_error || null };
|
|
468
|
+
if (state.phase === 'imported') result.assignment = { capabilities: state.bundle.invitation.capabilities, bot_id: state.bundle.invitation.bot_id,
|
|
469
|
+
expires_at: state.bundle.invitation.expires_at };
|
|
470
|
+
if (state.role === 'admin' && state.phase === 'ready' && !localOnly) {
|
|
471
|
+
const store = await this.profileStore(state); const owners = new Map();
|
|
472
|
+
result.members = Object.entries(store.team.agents).map(([id, agent]) => {
|
|
473
|
+
if (agent.wecom_userid) {
|
|
474
|
+
const group = owners.get(agent.wecom_userid) || []; group.push(id); owners.set(agent.wecom_userid, group);
|
|
475
|
+
}
|
|
476
|
+
return { id, capabilities: agent.capabilities, role: agent.role, verified_owner: !!agent.wecom_userid };
|
|
477
|
+
});
|
|
478
|
+
result.owners = [...owners].map(([userid, agent_ids]) => ({ agent_ids, is_you: userid === store.team.agents[state.agent_id]?.wecom_userid }));
|
|
479
|
+
result.routes = store.team.routes; result.review_rules = store.team.review_rules || null; result.policy_schema_version = store.team.schema_version; result.version = store.registry.latest.version; result.requests = [];
|
|
480
|
+
const files = await readdir(join(store.checkout, registryPath, 'requests')).catch((error) => { if (error.code === 'ENOENT') return []; throw error; });
|
|
481
|
+
for (const file of files) {
|
|
482
|
+
try {
|
|
483
|
+
const request = JSON.parse(await readFile(join(store.checkout, registryPath, 'requests', file), 'utf8'));
|
|
484
|
+
const invite = JSON.parse(await readFile(join(store.checkout, invitationFile(request.invite_id)), 'utf8'));
|
|
485
|
+
validateEnrollment(request, invite, store.registry);
|
|
486
|
+
if (!store.registry.records.some((record) => record.enrollment?.invite_id === request.invite_id)) result.requests.push({ request_id: request.request_id, agent_id: request.agent_id,
|
|
487
|
+
capabilities: invite.capabilities, userid: request.wecom_userid, public_key_fingerprint: digest(request.public_key).slice(0, 16) });
|
|
488
|
+
} catch { /* Invalid/untrusted repository request never reaches the approval UI. */ }
|
|
489
|
+
}
|
|
490
|
+
result.exports = [];
|
|
491
|
+
const exported = await readdir(join(this.path(id), 'exports')).catch((error) => { if (error.code === 'ENOENT') return []; throw error; });
|
|
492
|
+
for (const file of exported) {
|
|
493
|
+
const value = await readPrivateJson(join(this.path(id), 'exports', file));
|
|
494
|
+
result.exports.push({ invite_id: value.invitation.invite_id, agent_id: value.invitation.agent_id,
|
|
495
|
+
filename: `${value.invitation.agent_id}-collab-invite.json`, expires_at: value.invitation.expires_at, own_agent: !!value.own_bundle });
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
if (state.phase === 'ready') {
|
|
499
|
+
result.config_path = state.existing_config || join(this.path(id), 'peer.json'); result.binding_prompt = bindingPrompt(state.agent_id, state.repo);
|
|
500
|
+
const config = (await readPeerConfig(result.config_path)).config;
|
|
501
|
+
const skillName = config.execution_mode === 'desktop_poll' ? 'team-collab-desktop' : 'team-collab';
|
|
502
|
+
const projectConfig = await readFile(join(state.repo, '.codex', 'config.toml'), 'utf8').catch((error) => error.code === 'ENOENT' ? '' : Promise.reject(error));
|
|
503
|
+
const installedSkill = await readFile(join(state.repo, '.agents', 'skills', skillName, 'SKILL.md'), 'utf8').catch((error) => error.code === 'ENOENT' ? '' : Promise.reject(error));
|
|
504
|
+
result.project_setup = { mcp_configured: projectMcpConfigured(projectConfig, result.config_path, config), skill_installed: !!installedSkill.trim() };
|
|
505
|
+
const lock = await readRunnerLock(join(config.data_dir, 'runner.pid.json'));
|
|
506
|
+
result.runner = { online: !!lock && alive(lock.pid), wecom: 'offline' };
|
|
507
|
+
if (result.runner.online) {
|
|
508
|
+
try { const health = JSON.parse(await readFile(join(config.data_dir, 'wecom-health.json'), 'utf8')); if (health.pid === lock.pid) result.runner.wecom = health.status; } catch {}
|
|
509
|
+
}
|
|
510
|
+
const desktop = new DesktopExecutor({ agentId: config.agent_id, store: { clock: Date.now } }, config);
|
|
511
|
+
result.desktop = await desktop.status();
|
|
512
|
+
result.last_poll_at = result.desktop.last_poll_at || null;
|
|
513
|
+
result.poll_observed = !!result.last_poll_at;
|
|
514
|
+
result.poll_recent = !!result.last_poll_at && Date.now() - Date.parse(result.last_poll_at) < 180000;
|
|
515
|
+
result.can_receive_tasks = result.project_setup.mcp_configured && result.project_setup.skill_installed && result.runner.online && result.runner.wecom === 'authenticated' && !!result.desktop.binding && result.poll_recent;
|
|
516
|
+
result.heartbeat_confirmed = false;
|
|
517
|
+
}
|
|
518
|
+
return result;
|
|
519
|
+
}
|
|
520
|
+
async runner({ id, command }) {
|
|
521
|
+
const state = await this.state(id); if (state.phase !== 'ready' || !['start', 'stop'].includes(command)) throw new SetupError('无效连接操作。');
|
|
522
|
+
try {
|
|
523
|
+
this.runCli(command, state.existing_config || join(this.path(id), 'peer.json'));
|
|
524
|
+
state.last_error = null; await this.save(state);
|
|
525
|
+
} catch {
|
|
526
|
+
state.last_error = `机器人${command === 'start' ? '启动' : '停止'}未完成,请检查本机 Runner 日志后重试。`;
|
|
527
|
+
await this.save(state); throw new SetupError(state.last_error);
|
|
528
|
+
}
|
|
529
|
+
return this.detail(id, { localOnly: true });
|
|
530
|
+
}
|
|
531
|
+
close() { this.discovery.closeAll(); }
|
|
532
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { SetupError } from './errors.mjs';
|
|
6
|
+
import { SetupManager } from './manager.mjs';
|
|
7
|
+
const assets = { '/': ['index.html', 'text/html; charset=utf-8'], '/app.js': ['app.js', 'text/javascript; charset=utf-8'], '/style.css': ['style.css', 'text/css; charset=utf-8'] };
|
|
8
|
+
const same = (a, b) => typeof a === 'string' && Buffer.byteLength(a) === Buffer.byteLength(b) && timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
9
|
+
export async function startSetupServer({ manager = new SetupManager(), port = 0 } = {}) {
|
|
10
|
+
const token = randomBytes(32).toString('hex'); const cookieName = `collab_${randomBytes(8).toString('hex')}`; let origin;
|
|
11
|
+
const server = http.createServer(async (req, res) => {
|
|
12
|
+
res.setHeader('Cache-Control', 'no-store'); res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('Referrer-Policy', 'no-referrer');
|
|
13
|
+
res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'");
|
|
14
|
+
const json = (status, value) => { res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(value)); };
|
|
15
|
+
try {
|
|
16
|
+
if (req.headers.host !== new URL(origin).host) return json(403, { error: '本机访问地址无效。' });
|
|
17
|
+
const url = new URL(req.url, origin);
|
|
18
|
+
if (req.method === 'GET' && assets[url.pathname]) {
|
|
19
|
+
const [file, type] = assets[url.pathname]; res.writeHead(200, { 'Content-Type': type }); res.end(await readFile(fileURLToPath(new URL(`./web/${file}`, import.meta.url)))); return;
|
|
20
|
+
}
|
|
21
|
+
if (req.method !== 'GET' && (req.headers.origin !== origin || req.headers['content-type']?.split(';')[0] !== 'application/json')) return json(403, { error: '请通过本机向导操作。' });
|
|
22
|
+
if (req.headers.origin && req.headers.origin !== origin) return json(403, { error: '访问来源无效。' });
|
|
23
|
+
let body = {};
|
|
24
|
+
if (req.method === 'POST') {
|
|
25
|
+
const chunks = []; let length = 0;
|
|
26
|
+
for await (const chunk of req) { length += chunk.length; if (length > 750000) { json(413, { error: '文件太大。' }); req.resume(); return; } chunks.push(chunk); }
|
|
27
|
+
try { body = JSON.parse(Buffer.concat(chunks).toString()); if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error(); }
|
|
28
|
+
catch { return json(400, { error: '请求格式无效。' }); }
|
|
29
|
+
}
|
|
30
|
+
if (req.method === 'POST' && url.pathname === '/api/session') {
|
|
31
|
+
if (!same(body.token, token)) return json(401, { error: '请从终端重新打开向导链接。' });
|
|
32
|
+
res.setHeader('Set-Cookie', `${cookieName}=${token}; HttpOnly; SameSite=Strict; Path=/`); return json(200, { ok: true });
|
|
33
|
+
}
|
|
34
|
+
const session = (req.headers.cookie || '').split(';').map((item) => item.trim()).find((item) => item.startsWith(`${cookieName}=`))?.slice(cookieName.length + 1);
|
|
35
|
+
if (!same(session, token)) return json(401, { error: '请从终端重新打开向导链接。' });
|
|
36
|
+
if (req.method === 'GET' && url.pathname === '/api/overview') return json(200, await manager.overview());
|
|
37
|
+
if (req.method === 'GET' && url.pathname === '/api/discovery') return json(200, manager.discovery.view(url.searchParams.get('id')));
|
|
38
|
+
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
|
+
if (req.method === 'GET' && url.pathname === '/api/export') {
|
|
40
|
+
const value = await manager.exclusive(() => manager.exportedBundle(url.searchParams.get('id'), url.searchParams.get('invite')));
|
|
41
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Disposition': `attachment; filename="${value.filename}"` }); res.end(value.content); return;
|
|
42
|
+
}
|
|
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 key = url.pathname.replace('/api/', ''); const action = Object.hasOwn(actions, key) ? actions[key] : null;
|
|
45
|
+
if (req.method === 'POST' && url.pathname.startsWith('/api/') && action) return json(200, await manager.exclusive(action));
|
|
46
|
+
return json(404, { error: '操作不存在。' });
|
|
47
|
+
} catch (error) {
|
|
48
|
+
// Tool/SDK/Git exceptions may include credentials or private input: do not reflect them.
|
|
49
|
+
const safe = error instanceof SetupError ? error.message : '操作未完成。请检查本机 Git 权限、文件和机器人连接,然后重试。';
|
|
50
|
+
json(400, { error: safe });
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve); });
|
|
54
|
+
origin = `http://127.0.0.1:${server.address().port}`;
|
|
55
|
+
return { server, origin, token, url: `${origin}/#${token}`, close: async () => { manager.close(); server.closeAllConnections(); await new Promise((resolve) => server.close(resolve)); } };
|
|
56
|
+
}
|