xuym-codex-collab 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -110
- 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 +405 -0
- package/apps/onboarding/server.mjs +56 -0
- package/apps/onboarding/web/app.js +128 -0
- package/apps/onboarding/web/index.html +13 -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 +70 -21
- package/bin/codex-collab.mjs +10 -7
- package/docs/architecture.md +3 -1
- package/docs/desktop-poll.md +15 -7
- package/docs/legacy-center.md +109 -0
- package/docs/onboarding.md +74 -72
- package/docs/peer.md +12 -6
- package/docs/protocol.md +2 -0
- package/docs/team-registry.md +55 -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 +4 -1
- package/packages/peer/protocol.mjs +22 -7
- package/packages/peer/registry.mjs +130 -0
- package/packages/peer/review.mjs +51 -0
- package/packages/peer/service.mjs +24 -16
- package/packages/peer/store.mjs +50 -5
- 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 +86 -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 +1 -1
|
@@ -0,0 +1,405 @@
|
|
|
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
|
+
async function ensureRepo(path) {
|
|
40
|
+
if (typeof path !== 'string' || !isAbsolute(path)) throw new SetupError('请选择业务仓库的本机绝对路径。');
|
|
41
|
+
const repo = await realpath(path);
|
|
42
|
+
if (await git(repo, 'rev-parse', '--show-toplevel') !== repo) throw new SetupError('请选择 Git 仓库根目录。');
|
|
43
|
+
const remote = await git(repo, 'remote', 'get-url', 'origin');
|
|
44
|
+
if (!await git(repo, 'ls-remote', '--heads', 'origin', 'refs/heads/main')) throw new SetupError('无法读取 origin/main,请检查仓库和 Git 权限。');
|
|
45
|
+
return { repo, remote };
|
|
46
|
+
}
|
|
47
|
+
function validateBundle(value) {
|
|
48
|
+
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('接入包格式无效。');
|
|
49
|
+
const history = validateHistory(value.history, value.root_hash);
|
|
50
|
+
validateInvitation(value.invitation, history); testArgv(value.test_argv);
|
|
51
|
+
if (Date.now() > Date.parse(value.invitation.expires_at)) throw new SetupError('接入包已过期,请管理员重新生成。');
|
|
52
|
+
const claim = createPublicKey(value.claim_private_key).export({ type: 'spki', format: 'pem' });
|
|
53
|
+
if (claim !== value.invitation.claim_public_key || value.wecom.bot_id !== value.invitation.bot_id) throw new SetupError('接入包验证失败。');
|
|
54
|
+
return { bundle: value, history };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class SetupManager {
|
|
58
|
+
constructor({ home = join(homedir(), '.config', 'codex-collab', 'workspace'), discovery = new BotDiscovery(), runCli } = {}) {
|
|
59
|
+
this.home = resolve(home); this.discovery = discovery; this.queue = Promise.resolve();
|
|
60
|
+
this.runCli = runCli || ((command, config) => {
|
|
61
|
+
const result = spawnSync(process.execPath, [join(packageRoot, 'bin/codex-collab.mjs'), command, config], { encoding: 'utf8', timeout: 35000, maxBuffer: 200000 });
|
|
62
|
+
if (result.status !== 0) throw new Error(`本机 ${command} 操作未完成,请检查项目 Git 权限或 Runner 日志。`);
|
|
63
|
+
return result.stdout.trim();
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
async exclusive(fn) { const action = this.queue.then(fn); this.queue = action.catch(() => {}); return action; }
|
|
67
|
+
path(id) { if (!uuidPattern.test(id || '')) throw new SetupError('无效项目标识。'); return join(this.home, 'profiles', id); }
|
|
68
|
+
async state(id) { return readPrivateJson(join(this.path(id), 'setup.json')); }
|
|
69
|
+
async save(state) { return savePrivate(join(this.path(state.id), 'setup.json'), state); }
|
|
70
|
+
async profileStore(state, { enrollmentOnly = false } = {}) {
|
|
71
|
+
const identity = await readPrivateJson(state.identity_path);
|
|
72
|
+
const initial = state.bootstrap_team;
|
|
73
|
+
return new PeerGitStore({ repo: state.repo, dataDir: state.data_dir || join(this.path(state.id), 'data'), team: initial,
|
|
74
|
+
agentId: state.agent_id, privateKey: identity.private_key, registryRoot: state.root_hash, enrollmentOnly }).init();
|
|
75
|
+
}
|
|
76
|
+
async overview() {
|
|
77
|
+
await mkdir(join(this.home, 'profiles'), { recursive: true, mode: 0o700 });
|
|
78
|
+
const entries = (await readdir(join(this.home, 'profiles'))).filter((id) => uuidPattern.test(id));
|
|
79
|
+
const profiles = [];
|
|
80
|
+
for (const id of entries) {
|
|
81
|
+
const state = await this.state(id);
|
|
82
|
+
profiles.push({ id, agent_id: state.agent_id, role: state.role, repo: state.repo, phase: state.phase, error: state.last_error || null });
|
|
83
|
+
}
|
|
84
|
+
return { profiles };
|
|
85
|
+
}
|
|
86
|
+
async checkBotAvailable(botId) {
|
|
87
|
+
for (const profile of (await this.overview()).profiles) {
|
|
88
|
+
const state = await this.state(profile.id);
|
|
89
|
+
const lock = await readRunnerLock(join(state.data_dir || join(this.path(profile.id), 'data'), 'runner.pid.json'));
|
|
90
|
+
if (state.wecom?.bot_id === botId && lock && alive(lock.pid)) throw new SetupError('此机器人已有本机 Runner 连接,请先在项目状态中停止机器人连接。');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async beginDiscovery(input) {
|
|
94
|
+
let credentials = input;
|
|
95
|
+
if (input.profile_id) {
|
|
96
|
+
const state = await this.state(input.profile_id);
|
|
97
|
+
if (!state.bundle) throw new SetupError('此项目没有待验证的接入包。');
|
|
98
|
+
credentials = { ...state.bundle.wecom, chat_ids: state.bundle.invitation.chat_ids };
|
|
99
|
+
}
|
|
100
|
+
await this.checkBotAvailable(credentials.bot_id);
|
|
101
|
+
if (input.repo) {
|
|
102
|
+
const { repo } = await ensureRepo(input.repo);
|
|
103
|
+
const toml = await readFile(join(repo, '.codex/config.toml'), 'utf8').catch(() => '');
|
|
104
|
+
for (const match of toml.matchAll(/"((?:[^"\\]|\\.)*\.json)"/g)) {
|
|
105
|
+
try {
|
|
106
|
+
const path = JSON.parse(match[0]); const { config } = await readPeerConfig(path);
|
|
107
|
+
const lock = await readRunnerLock(join(config.data_dir, 'runner.pid.json'));
|
|
108
|
+
if (config.wecom?.bot_id === credentials.bot_id && lock && alive(lock.pid)) throw new SetupError('此项目原有机器人仍在连接,请先停止原 Runner 或导入已有配置。');
|
|
109
|
+
} catch (error) { if (error instanceof SetupError) throw error; }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return this.discovery.begin(credentials);
|
|
113
|
+
}
|
|
114
|
+
async createAdmin(input) {
|
|
115
|
+
const agentId = input.agent_id?.trim(); if (!idPattern.test(agentId || '')) throw new SetupError('请填写有效的成员名称。');
|
|
116
|
+
const caps = capabilities(input.capabilities); const argv = testArgv(input.test_argv || ['sh', 'test.sh']);
|
|
117
|
+
const { repo, remote } = await ensureRepo(input.repo);
|
|
118
|
+
if (inside(repo, this.home)) throw new SetupError('本机配置目录必须在业务仓库之外。');
|
|
119
|
+
for (const profile of (await this.overview()).profiles) if (profile.repo === repo) throw new SetupError('这个 clone 已接入其他身份,请选择独立 clone。');
|
|
120
|
+
const proof = this.discovery.consume(input.discovery_id);
|
|
121
|
+
const id = randomUUID(); const directory = this.path(id);
|
|
122
|
+
const identity = await getOrCreateIdentityFiles(agentId, join(this.home, 'identities'));
|
|
123
|
+
const team = buildTeamPolicy({ repo: input.repo_id?.trim() || remote, reviewer: agentId, defaultRoute: caps[0],
|
|
124
|
+
agents: [{ id: agentId, role: 'admin', capabilities: caps, publicKey: await readFile(identity.public_key_file, 'utf8'), wecomUserid: proof.userid }],
|
|
125
|
+
routes: Object.fromEntries(caps.map((cap) => [cap, [agentId]])) });
|
|
126
|
+
team.schema_version = 3; team.review_rules = defaultReviewRules(team.routes);
|
|
127
|
+
const record = policyVersion(team, null, agentId, (await readPrivateJson(identity.private_key_file)).private_key);
|
|
128
|
+
const state = { id, role: 'admin', agent_id: agentId, repo, remote, identity_path: identity.private_key_file, bootstrap_team: team,
|
|
129
|
+
root_hash: recordHash(record), genesis: record, test_argv: argv,
|
|
130
|
+
wecom: { bot_id: proof.bot_id, secret: proof.secret, chat_ids: [proof.chatid], commands_enabled: true, allow_admin_commands: true }, phase: 'initializing' };
|
|
131
|
+
await this.save(state);
|
|
132
|
+
return this.resume(id);
|
|
133
|
+
}
|
|
134
|
+
async importExisting({ config_path }) {
|
|
135
|
+
if (typeof config_path !== 'string' || !isAbsolute(config_path)) throw new SetupError('请选择已有配置的完整路径。');
|
|
136
|
+
const { config, team } = await readPeerConfig(config_path);
|
|
137
|
+
if (config.execution_mode !== 'desktop_poll') throw new SetupError('目前向导导入需要已有 desktop_poll 配置。');
|
|
138
|
+
for (const profile of (await this.overview()).profiles) if (profile.repo === config.repo) {
|
|
139
|
+
const existing = await this.state(profile.id); if (existing.existing_config === resolve(config_path)) return this.resume(profile.id);
|
|
140
|
+
throw new SetupError('该项目已导入。');
|
|
141
|
+
}
|
|
142
|
+
const lock = await readRunnerLock(join(config.data_dir, 'runner.pid.json'));
|
|
143
|
+
if (lock && alive(lock.pid)) throw new SetupError('请先用原配置停止该机器人的 Runner,再导入;原有对话绑定会保留。');
|
|
144
|
+
if (!config.wecom) throw new SetupError('已有配置需要包含企业微信机器人。');
|
|
145
|
+
const identity = { schema_version: 1, agent_id: config.agent_id, private_key: config.private_key };
|
|
146
|
+
const id = randomUUID(); const identityPath = join(this.path(id), 'identity.json');
|
|
147
|
+
await savePrivate(identityPath, identity);
|
|
148
|
+
let genesis, rootHash = config.team_registry_root;
|
|
149
|
+
const store = await new PeerGitStore({ repo: config.repo, dataDir: config.data_dir, team, agentId: config.agent_id, privateKey: config.private_key, registryRoot: rootHash }).init();
|
|
150
|
+
if (!rootHash) {
|
|
151
|
+
const firstPath = join(store.checkout, '.agent-collab/team/versions/000001.json');
|
|
152
|
+
if (await exists(firstPath)) {
|
|
153
|
+
const first = JSON.parse(await readFile(firstPath, 'utf8'));
|
|
154
|
+
if (digest(first.team) !== digest(team)) throw new SetupError('仓库团队根策略与本机已信任策略不一致,请联系管理员。');
|
|
155
|
+
rootHash = recordHash(first); await readRegistry(store.checkout, rootHash);
|
|
156
|
+
} else {
|
|
157
|
+
if (team.agents[config.agent_id].role !== 'admin') throw new SetupError('请先由旧团队管理员导入,之后成员也可导入原配置。');
|
|
158
|
+
genesis = policyVersion(team, null, config.agent_id, config.private_key); rootHash = recordHash(genesis);
|
|
159
|
+
await store.registryTransaction(async () => {
|
|
160
|
+
await store.listLocal();
|
|
161
|
+
if (await exists(firstPath)) {
|
|
162
|
+
if (canonical(JSON.parse(await readFile(firstPath, 'utf8'))) !== canonical(genesis)) throw new SetupError('仓库已有另一团队版本,请重新导入同步。');
|
|
163
|
+
return { value: null };
|
|
164
|
+
}
|
|
165
|
+
return { files: [[versionFile(genesis), genesis]], value: null };
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const state = { id, role: team.agents[config.agent_id].role, agent_id: config.agent_id, repo: config.repo,
|
|
170
|
+
remote: await git(config.repo, 'remote', 'get-url', 'origin'), identity_path: identityPath, bootstrap_team: team, root_hash: rootHash,
|
|
171
|
+
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 };
|
|
172
|
+
await savePrivate(join(this.path(id), 'peer.json'), { ...config, team_registry_root: rootHash });
|
|
173
|
+
// Existing MCP processes keep using this path and the same private desktop state.
|
|
174
|
+
await savePrivate(resolve(config_path), { ...config, team_registry_root: rootHash });
|
|
175
|
+
await this.save(state); this.runCli('start', resolve(config_path)); return this.detail(id, { localOnly: true });
|
|
176
|
+
}
|
|
177
|
+
async resume(id) {
|
|
178
|
+
const state = await this.state(id);
|
|
179
|
+
try {
|
|
180
|
+
if (state.role === 'admin' && state.phase === 'initializing') {
|
|
181
|
+
const bootstrap = new PeerGitStore({ repo: state.repo, dataDir: join(this.path(id), 'data'), team: state.bootstrap_team, agentId: state.agent_id,
|
|
182
|
+
privateKey: (await readPrivateJson(state.identity_path)).private_key });
|
|
183
|
+
await bootstrap.init();
|
|
184
|
+
await bootstrap.registryTransaction(async () => {
|
|
185
|
+
const path = join(bootstrap.checkout, versionFile(state.genesis));
|
|
186
|
+
if (await exists(path)) {
|
|
187
|
+
if (canonical(JSON.parse(await readFile(path, 'utf8'))) !== canonical(state.genesis)) throw new SetupError('仓库已有另一团队,请导入该团队的接入包。');
|
|
188
|
+
return { value: null };
|
|
189
|
+
}
|
|
190
|
+
await bootstrap.listLocal(); // Existing tasks must replay against this exact genesis policy before adoption.
|
|
191
|
+
return { files: [[versionFile(state.genesis), state.genesis]], value: null };
|
|
192
|
+
});
|
|
193
|
+
state.phase = 'configuring'; await this.save(state);
|
|
194
|
+
}
|
|
195
|
+
if (state.role === 'member' && ['verified', 'waiting'].includes(state.phase)) {
|
|
196
|
+
const store = await this.profileStore(state, { enrollmentOnly: true });
|
|
197
|
+
if (state.phase === 'verified') {
|
|
198
|
+
await store.registryTransaction(async (history) => {
|
|
199
|
+
const invitation = JSON.parse(await readFile(join(store.checkout, invitationFile(state.request.invite_id)), 'utf8'));
|
|
200
|
+
if (canonical(invitation) !== canonical(state.bundle.invitation)) throw new SetupError('仓库邀请与接入包不一致。');
|
|
201
|
+
const approved = history.records.some((record) => record.enrollment?.invite_id === invitation.invite_id && record.enrollment.request_id === state.request.request_id)
|
|
202
|
+
&& history.latest.team.agents[state.agent_id]?.public_key === state.request.public_key;
|
|
203
|
+
validateEnrollment(state.request, invitation, history, { allowExpired: approved });
|
|
204
|
+
const path = requestFile(state.request.request_id);
|
|
205
|
+
if (await exists(join(store.checkout, path))) {
|
|
206
|
+
if (canonical(JSON.parse(await readFile(join(store.checkout, path), 'utf8'))) !== canonical(state.request)) throw new SetupError('入队申请内容冲突。');
|
|
207
|
+
return { value: null };
|
|
208
|
+
}
|
|
209
|
+
return { files: [[path, state.request]], value: null };
|
|
210
|
+
});
|
|
211
|
+
state.phase = 'waiting'; await this.save(state);
|
|
212
|
+
}
|
|
213
|
+
const member = store.team.agents[state.agent_id];
|
|
214
|
+
const identity = await readPrivateJson(state.identity_path);
|
|
215
|
+
if (!member) return this.detail(id, { localOnly: true });
|
|
216
|
+
if (member.public_key !== createPublicKey(identity.private_key).export({ type: 'spki', format: 'pem' })) throw new SetupError('管理员已批准另一个身份,请联系管理员。');
|
|
217
|
+
state.bootstrap_team = store.team; state.phase = 'configuring'; await this.save(state);
|
|
218
|
+
}
|
|
219
|
+
if (state.phase === 'configuring') {
|
|
220
|
+
const store = await this.profileStore(state);
|
|
221
|
+
const teamPath = join(this.path(id), 'team.json'); await savePrivate(teamPath, store.team);
|
|
222
|
+
const configPath = join(this.path(id), 'peer.json');
|
|
223
|
+
if (!await exists(configPath)) await createMemberConfig({ teamPath, identityPath: state.identity_path, repoPath: state.repo,
|
|
224
|
+
dataDir: join(this.path(id), 'data'), configPath, testArgv: state.test_argv });
|
|
225
|
+
const config = await readPrivateJson(configPath);
|
|
226
|
+
await savePrivate(configPath, { ...config, team_registry_root: state.root_hash, wecom: state.wecom });
|
|
227
|
+
await diagnosePeer(configPath, { testPush: true });
|
|
228
|
+
if (!state.attached) {
|
|
229
|
+
const existing = await readFile(join(state.repo, '.codex', 'config.toml'), 'utf8').catch(() => '');
|
|
230
|
+
const template = await readFile(join(packageRoot, 'packages/codex-kit/templates/.agents/skills/team-collab-desktop/SKILL.md'), 'utf8');
|
|
231
|
+
const installed = await readFile(join(state.repo, '.agents/skills/team-collab-desktop/SKILL.md'), 'utf8').catch(() => '');
|
|
232
|
+
if (!existing.includes(JSON.stringify(configPath)) || installed !== template) await attachProject({ configPath, repoPath: state.repo });
|
|
233
|
+
state.attached = true; await this.save(state);
|
|
234
|
+
}
|
|
235
|
+
await savePrivate(join(this.path(id), 'binding.json'), { prompt: bindingPrompt(state.agent_id, state.repo) });
|
|
236
|
+
state.phase = 'ready'; state.last_error = null; await this.save(state);
|
|
237
|
+
}
|
|
238
|
+
if (state.phase === 'ready') this.runCli('start', state.existing_config || join(this.path(id), 'peer.json'));
|
|
239
|
+
state.last_error = null; await this.save(state);
|
|
240
|
+
return this.detail(id, { localOnly: true });
|
|
241
|
+
} catch (error) {
|
|
242
|
+
state.last_error = '接入未完成,可检查 Git 权限/项目配置后点击继续。'; await this.save(state);
|
|
243
|
+
throw error;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
async importBundle({ content, password, repo }) {
|
|
247
|
+
if (typeof content !== 'string' || content.length > 500000) throw new SetupError('请选择管理员提供的接入包。');
|
|
248
|
+
const { bundle, history } = validateBundle(decryptBundle(JSON.parse(content), password));
|
|
249
|
+
const selected = await ensureRepo(repo);
|
|
250
|
+
if (inside(selected.repo, this.home)) throw new SetupError('本机配置目录必须在业务仓库之外。');
|
|
251
|
+
if (remoteIdentity(selected.remote) !== remoteIdentity(bundle.remote)) throw new SetupError('该目录的 origin 与邀请仓库不一致,请选择正确仓库;工具不会改写 origin。');
|
|
252
|
+
for (const profile of (await this.overview()).profiles) {
|
|
253
|
+
const current = await this.state(profile.id);
|
|
254
|
+
if (current.bundle?.invitation.invite_id === bundle.invitation.invite_id) return this.detail(current.id, { localOnly: true });
|
|
255
|
+
if (current.repo === selected.repo) throw new SetupError('这个 clone 已接入其他身份,请选择独立 clone。');
|
|
256
|
+
}
|
|
257
|
+
const id = randomUUID(); const identity = await getOrCreateIdentityFiles(bundle.invitation.agent_id, join(this.home, 'identities'));
|
|
258
|
+
const state = { id, role: 'member', agent_id: bundle.invitation.agent_id, repo: selected.repo, remote: selected.remote, identity_path: identity.private_key_file,
|
|
259
|
+
bootstrap_team: history.latest.team, root_hash: bundle.root_hash, bundle, test_argv: bundle.test_argv, phase: 'imported' };
|
|
260
|
+
await this.save(state); return this.detail(id, { localOnly: true });
|
|
261
|
+
}
|
|
262
|
+
async verifyMember({ id, discovery_id }) {
|
|
263
|
+
const state = await this.state(id); if (state.phase !== 'imported') throw new SetupError('此成员已经完成群验证。');
|
|
264
|
+
const proof = this.discovery.consume(discovery_id); const invite = state.bundle.invitation;
|
|
265
|
+
if (proof.bot_id !== invite.bot_id || !invite.chat_ids.includes(proof.chatid)) throw new SetupError('请使用分配的机器人和指定协作群验证。');
|
|
266
|
+
const identity = await readPrivateJson(state.identity_path);
|
|
267
|
+
state.request = enrollmentRequest({ invite, publicKey: createPublicKey(identity.private_key).export({ type: 'spki', format: 'pem' }), userid: proof.userid,
|
|
268
|
+
chatid: proof.chatid, requestId: randomUUID(), privateKey: identity.private_key, claimPrivateKey: state.bundle.claim_private_key });
|
|
269
|
+
state.wecom = { bot_id: proof.bot_id, secret: proof.secret, chat_ids: invite.chat_ids, commands_enabled: invite.commands_enabled, allow_admin_commands: true,
|
|
270
|
+
default_workflow: [invite.capabilities[0]] };
|
|
271
|
+
state.phase = 'verified'; await this.save(state); return this.resume(id);
|
|
272
|
+
}
|
|
273
|
+
async issueBundle(input) {
|
|
274
|
+
const state = await this.state(input.id); if (state.role !== 'admin' || state.phase !== 'ready') throw new SetupError('请先完成管理员接入。');
|
|
275
|
+
const agentId = input.agent_id?.trim(); if (!idPattern.test(agentId || '') || agentId === state.agent_id) throw new SetupError('请填写要加入的成员名称。');
|
|
276
|
+
const caps = capabilities(input.capabilities); const store = await this.profileStore(state);
|
|
277
|
+
if (store.team.agents[agentId]) throw new SetupError('成员已存在,请复用原有接入配置。');
|
|
278
|
+
if (typeof input.bot_id !== 'string' || !input.bot_id.trim() || typeof input.secret !== 'string' || !input.secret) throw new SetupError('请填写该成员机器人的 Bot ID 和 Secret。');
|
|
279
|
+
if (input.bot_id.trim() === state.wecom.bot_id) throw new SetupError('每位成员必须使用独立机器人。');
|
|
280
|
+
encryptBundle({}, input.password); // Validate password before publishing an invitation.
|
|
281
|
+
const claim = generateIdentity(); const identity = await readPrivateJson(state.identity_path);
|
|
282
|
+
const inviteId = randomUUID();
|
|
283
|
+
const bundle = await store.registryTransaction(async (history) => {
|
|
284
|
+
const files = await readdir(join(store.checkout, registryPath, 'invitations')).catch((error) => { if (error.code === 'ENOENT') return []; throw error; });
|
|
285
|
+
for (const file of files) {
|
|
286
|
+
const existing = JSON.parse(await readFile(join(store.checkout, registryPath, 'invitations', file), 'utf8'));
|
|
287
|
+
validateInvitation(existing, history);
|
|
288
|
+
if (existing.invite_id === inviteId) return { value: { invitation: existing } };
|
|
289
|
+
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('该成员或机器人已有有效接入包,请使用已生成的文件。');
|
|
290
|
+
}
|
|
291
|
+
const invitation = invitationRecord({ invite_id: inviteId, agent_id: agentId, capabilities: caps,
|
|
292
|
+
bot_id: input.bot_id.trim(), chat_ids: state.wecom.chat_ids, commands_enabled: input.commands_enabled !== false,
|
|
293
|
+
prefer: input.prefer !== false, claim_public_key: claim.public_key, expires_at: new Date(Date.now() + 7 * 86400000).toISOString() }, history.latest, state.agent_id, identity.private_key);
|
|
294
|
+
const value = { schema_version: 1, kind: 'codex-collab-invite', root_hash: state.root_hash, remote: state.remote,
|
|
295
|
+
history: history.records, invitation, claim_private_key: claim.private_key,
|
|
296
|
+
wecom: { bot_id: invitation.bot_id, secret: input.secret }, test_argv: state.test_argv };
|
|
297
|
+
// Save export before push: a lost acknowledgment can be retried from disk.
|
|
298
|
+
await savePrivate(join(this.path(input.id), 'exports', `${inviteId}.json`), { invitation, encrypted: encryptBundle(value, input.password) });
|
|
299
|
+
return { files: [[invitationFile(inviteId), invitation]], value };
|
|
300
|
+
});
|
|
301
|
+
return { invite_id: bundle.invitation.invite_id, agent_id: agentId, filename: `${agentId}-collab-invite.json` };
|
|
302
|
+
}
|
|
303
|
+
async exportedBundle(id, inviteId) {
|
|
304
|
+
if (!uuidPattern.test(inviteId || '')) throw new SetupError('无效邀请标识。');
|
|
305
|
+
const state = await this.state(id); const bundle = await readPrivateJson(join(this.path(id), 'exports', `${inviteId}.json`));
|
|
306
|
+
const store = await this.profileStore(state);
|
|
307
|
+
await store.registryTransaction(async (history) => {
|
|
308
|
+
const path = invitationFile(inviteId); if (await exists(join(store.checkout, path))) return { value: null };
|
|
309
|
+
validateInvitation(bundle.invitation, history);
|
|
310
|
+
if (history.latest.team.agents[bundle.invitation.agent_id] || Date.now() > Date.parse(bundle.invitation.expires_at)) throw new SetupError('邀请已失效,请重新生成。');
|
|
311
|
+
const files = await readdir(join(store.checkout, registryPath, 'invitations')).catch(() => []);
|
|
312
|
+
for (const file of files) {
|
|
313
|
+
const other = JSON.parse(await readFile(join(store.checkout, registryPath, 'invitations', file), 'utf8')); validateInvitation(other, history);
|
|
314
|
+
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('邀请与另一已发布的接入包冲突。');
|
|
315
|
+
}
|
|
316
|
+
return { files: [[path, bundle.invitation]], value: null };
|
|
317
|
+
});
|
|
318
|
+
const published = JSON.parse(await readFile(join(store.checkout, invitationFile(inviteId)), 'utf8'));
|
|
319
|
+
if (canonical(published) !== canonical(bundle.invitation)) throw new SetupError('邀请尚未发布成功,请重新生成接入包。');
|
|
320
|
+
return { content: JSON.stringify(bundle.encrypted, null, 2), filename: `${bundle.invitation.agent_id}-collab-invite.json` };
|
|
321
|
+
}
|
|
322
|
+
async approve({ id, request_id }) {
|
|
323
|
+
if (!uuidPattern.test(request_id || '')) throw new SetupError('无效申请标识。');
|
|
324
|
+
const state = await this.state(id); if (state.role !== 'admin') throw new SetupError('需要管理员确认。');
|
|
325
|
+
const store = await this.profileStore(state); const identity = await readPrivateJson(state.identity_path);
|
|
326
|
+
await store.registryTransaction(async (history) => {
|
|
327
|
+
const request = JSON.parse(await readFile(join(store.checkout, requestFile(request_id)), 'utf8'));
|
|
328
|
+
const invite = JSON.parse(await readFile(join(store.checkout, invitationFile(request.invite_id)), 'utf8'));
|
|
329
|
+
const version = approveEnrollment(request, invite, history, state.agent_id, identity.private_key);
|
|
330
|
+
return version ? { files: [[versionFile(version), version]], value: version.version } : { value: history.latest.version };
|
|
331
|
+
});
|
|
332
|
+
// Local config is a cache; registry refresh also occurs in already running peers.
|
|
333
|
+
const configPath = state.existing_config || join(this.path(id), 'peer.json');
|
|
334
|
+
const { config } = await readPeerConfig(configPath);
|
|
335
|
+
await savePrivate(config.team_file, store.team);
|
|
336
|
+
return this.detail(id);
|
|
337
|
+
}
|
|
338
|
+
async setReviewRules({ id, expected_version, review_rules }) {
|
|
339
|
+
const state = await this.state(id);
|
|
340
|
+
if (state.role !== 'admin' || state.phase !== 'ready') throw new SetupError('只有已接入的管理员可设置审核规则。');
|
|
341
|
+
if (!Number.isSafeInteger(expected_version) || expected_version < 1) throw new SetupError('请刷新工作台后再保存审核规则。');
|
|
342
|
+
const rules = structuredClone(review_rules);
|
|
343
|
+
const store = await this.profileStore(state); const identity = await readPrivateJson(state.identity_path);
|
|
344
|
+
await store.registryTransaction(async (history) => {
|
|
345
|
+
if (history.latest.team.agents[state.agent_id]?.role !== 'admin') throw new SetupError('需要管理员权限。');
|
|
346
|
+
if (history.latest.version !== expected_version) throw new SetupError('团队规则已更新,请刷新工作台后重新设置。');
|
|
347
|
+
const team = { ...structuredClone(history.latest.team), schema_version: 3, review_rules: rules };
|
|
348
|
+
try { validateTeam(team); } catch { throw new SetupError('审核规则无效:每个职责都需要规则,指定同事必须已验证企业微信本人身份。'); }
|
|
349
|
+
if (canonical(team) === canonical(history.latest.team)) return { value: history.latest.version };
|
|
350
|
+
const version = policyVersion(team, history.latest, state.agent_id, identity.private_key);
|
|
351
|
+
return { files: [[versionFile(version), version]], value: version.version };
|
|
352
|
+
});
|
|
353
|
+
const { config } = await readPeerConfig(state.existing_config || join(this.path(id), 'peer.json'));
|
|
354
|
+
await savePrivate(config.team_file, store.team);
|
|
355
|
+
return this.detail(id);
|
|
356
|
+
}
|
|
357
|
+
async detail(id, { localOnly = false } = {}) {
|
|
358
|
+
const state = await this.state(id); const result = { id, agent_id: state.agent_id, role: state.role, repo: state.repo, phase: state.phase, error: state.last_error || null };
|
|
359
|
+
if (state.phase === 'imported') result.assignment = { capabilities: state.bundle.invitation.capabilities, bot_id: state.bundle.invitation.bot_id,
|
|
360
|
+
expires_at: state.bundle.invitation.expires_at };
|
|
361
|
+
if (state.role === 'admin' && state.phase === 'ready' && !localOnly) {
|
|
362
|
+
const store = await this.profileStore(state); result.members = Object.entries(store.team.agents).map(([id, agent]) => ({ id, capabilities: agent.capabilities, role: agent.role, verified_owner: !!agent.wecom_userid }));
|
|
363
|
+
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 = [];
|
|
364
|
+
const files = await readdir(join(store.checkout, registryPath, 'requests')).catch((error) => { if (error.code === 'ENOENT') return []; throw error; });
|
|
365
|
+
for (const file of files) {
|
|
366
|
+
try {
|
|
367
|
+
const request = JSON.parse(await readFile(join(store.checkout, registryPath, 'requests', file), 'utf8'));
|
|
368
|
+
const invite = JSON.parse(await readFile(join(store.checkout, invitationFile(request.invite_id)), 'utf8'));
|
|
369
|
+
validateEnrollment(request, invite, store.registry);
|
|
370
|
+
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,
|
|
371
|
+
capabilities: invite.capabilities, userid: request.wecom_userid, public_key_fingerprint: digest(request.public_key).slice(0, 16) });
|
|
372
|
+
} catch { /* Invalid/untrusted repository request never reaches the approval UI. */ }
|
|
373
|
+
}
|
|
374
|
+
result.exports = [];
|
|
375
|
+
const exported = await readdir(join(this.path(id), 'exports')).catch((error) => { if (error.code === 'ENOENT') return []; throw error; });
|
|
376
|
+
for (const file of exported) {
|
|
377
|
+
const value = await readPrivateJson(join(this.path(id), 'exports', file));
|
|
378
|
+
result.exports.push({ invite_id: value.invitation.invite_id, agent_id: value.invitation.agent_id,
|
|
379
|
+
filename: `${value.invitation.agent_id}-collab-invite.json`, expires_at: value.invitation.expires_at });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
if (state.phase === 'ready') {
|
|
383
|
+
result.config_path = state.existing_config || join(this.path(id), 'peer.json'); result.binding_prompt = bindingPrompt(state.agent_id, state.repo);
|
|
384
|
+
const config = (await readPeerConfig(result.config_path)).config;
|
|
385
|
+
const lock = await readRunnerLock(join(config.data_dir, 'runner.pid.json'));
|
|
386
|
+
result.runner = { online: !!lock && alive(lock.pid), wecom: 'offline' };
|
|
387
|
+
if (result.runner.online) {
|
|
388
|
+
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 {}
|
|
389
|
+
}
|
|
390
|
+
const desktop = new DesktopExecutor({ agentId: config.agent_id, store: { clock: Date.now } }, config);
|
|
391
|
+
result.desktop = await desktop.status();
|
|
392
|
+
result.last_poll_at = result.desktop.last_poll_at || null;
|
|
393
|
+
result.poll_observed = !!result.last_poll_at;
|
|
394
|
+
result.poll_recent = !!result.last_poll_at && Date.now() - Date.parse(result.last_poll_at) < 180000;
|
|
395
|
+
result.can_receive_tasks = result.runner.online && result.runner.wecom === 'authenticated' && !!result.desktop.binding && result.poll_recent;
|
|
396
|
+
result.heartbeat_confirmed = false;
|
|
397
|
+
}
|
|
398
|
+
return result;
|
|
399
|
+
}
|
|
400
|
+
async runner({ id, command }) {
|
|
401
|
+
const state = await this.state(id); if (state.phase !== 'ready' || !['start', 'stop'].includes(command)) throw new SetupError('无效连接操作。');
|
|
402
|
+
this.runCli(command, state.existing_config || join(this.path(id), 'peer.json')); return this.detail(id, { localOnly: true });
|
|
403
|
+
}
|
|
404
|
+
close() { this.discovery.closeAll(); }
|
|
405
|
+
}
|
|
@@ -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 = { 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
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
const $ = (id) => document.getElementById(id);
|
|
2
|
+
let currentId, pending, discovery, timer, busy = false, reviewVersion = null;
|
|
3
|
+
function notice(text) { $('notice').textContent = text; $('notice').hidden = !text; }
|
|
4
|
+
function show(view) { for (const section of document.querySelectorAll('main>section')) section.hidden = section.id !== view; }
|
|
5
|
+
async function api(path, body) {
|
|
6
|
+
const response = await fetch(`/api/${path}`, body ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : {});
|
|
7
|
+
const value = await response.json(); if (!response.ok) throw new Error(value.error); return value;
|
|
8
|
+
}
|
|
9
|
+
async function action(fn) {
|
|
10
|
+
if (busy) return; busy = true; notice(''); for (const button of document.querySelectorAll('button')) button.disabled = true;
|
|
11
|
+
try { await fn(); } catch (error) { notice(error.message); } finally { busy = false; for (const button of document.querySelectorAll('button')) button.disabled = false; }
|
|
12
|
+
}
|
|
13
|
+
const values = (form) => Object.fromEntries(new FormData(form));
|
|
14
|
+
function text(tag, value) { const node = document.createElement(tag); node.textContent = value; return node; }
|
|
15
|
+
function button(label, fn) { const node = text('button', label); node.onclick = () => action(fn); return node; }
|
|
16
|
+
async function overview() {
|
|
17
|
+
const value = await api('overview'); $('profiles').replaceChildren();
|
|
18
|
+
if (!value.profiles.length) $('profiles').append(text('p', '还没有接入项目。管理员选择“初始化团队”,成员选择“加入团队”。'));
|
|
19
|
+
for (const profile of value.profiles) {
|
|
20
|
+
const card = document.createElement('div'); card.className = 'card'; const info = document.createElement('div');
|
|
21
|
+
info.append(text('strong', `${profile.agent_id} · ${profile.role === 'admin' ? '管理员' : '成员'}`), text('p', profile.repo));
|
|
22
|
+
card.append(info, button('打开项目', () => loadProfile(profile.id))); $('profiles').append(card);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async function verifyView(session) {
|
|
26
|
+
discovery = session; show('verification'); $('code').textContent = session.message; updateConnection(session);
|
|
27
|
+
clearInterval(timer); timer = setInterval(async () => { if (busy) return; try { const value = await api(`discovery?id=${session.id}`); updateConnection(value); if (['verified', 'failed', 'expired', 'disconnected'].includes(value.status)) clearInterval(timer); } catch (error) { clearInterval(timer); notice(error.message); } }, 2000);
|
|
28
|
+
}
|
|
29
|
+
function updateConnection(value) { discovery = value; $('retry-code').hidden = !['failed', 'expired', 'disconnected'].includes(value.status); const labels = { connecting: '正在连接企业微信…', authenticated: '机器人已连接,请发送下面的验证码。', verified: '群验证成功,点击继续完成配置。', failed: '连接失败,请检查凭据。', expired: '验证码已过期,请重新连接。', disconnected: '连接已断开,请重新连接。' }; $('connection').textContent = value.error || labels[value.status] || value.status; }
|
|
30
|
+
async function loadProfile(id) {
|
|
31
|
+
currentId = id; show('profile'); let value;
|
|
32
|
+
try { value = await api(`profile?id=${id}`); } catch (error) { value = await api(`profile?id=${id}&local=1`); notice(`团队同步暂未成功,本机状态仍可查看。${error.message}`); }
|
|
33
|
+
$('profile-title').textContent = `${value.agent_id} · ${value.role === 'admin' ? '管理员' : '成员'}`; $('profile-repo').textContent = value.repo;
|
|
34
|
+
$('profile-diagnostics').hidden = !value.config_path; $('config-path').textContent = value.config_path || '';
|
|
35
|
+
$('checks').replaceChildren(); $('progress').replaceChildren(); $('binding').hidden = value.phase !== 'ready'; $('runner-actions').hidden = value.phase !== 'ready'; $('admin-tools').hidden = value.role !== 'admin' || value.phase !== 'ready';
|
|
36
|
+
if (value.phase === 'ready') {
|
|
37
|
+
$('binding-text').textContent = value.binding_prompt;
|
|
38
|
+
for (const [label, ok, wait] of [['项目 MCP 和 Skill', true, '待配置'], ['企业微信机器人', value.runner?.wecom === 'authenticated', value.runner?.online ? '连接中' : '已停止'], ['目标 Codex 对话', !!value.desktop?.binding, '待绑定'], ['对话实际轮询', value.poll_recent, value.poll_observed ? '最近未观察到轮询' : '待观察']]) {
|
|
39
|
+
const row = document.createElement('div'); row.className = 'check'; const badge = text('span', ok ? '已就绪' : wait); badge.className = `badge${ok ? '' : ' pending'}`; row.append(text('span', label), badge); $('checks').append(row);
|
|
40
|
+
}
|
|
41
|
+
$('checks').append(text('p', '轮询状态来自实际调用记录。自动化是否启用,请在绑定对话的 Codex 自动化中确认。'));
|
|
42
|
+
} else {
|
|
43
|
+
const phases = { imported: '接入包已导入,等待群身份验证。', initializing: '团队初始化尚未完成。', configuring: '正在配置本机项目。', verified: '正在提交入队申请。', waiting: '申请已提交,请管理员刷新工作台并确认,然后点击继续。' };
|
|
44
|
+
$('progress').append(text('p', phases[value.phase] || value.phase), button(value.phase === 'imported' ? '连接分配的机器人' : '继续接入', async () => {
|
|
45
|
+
if (value.phase === 'imported') { pending = { type: 'member', id }; await verifyView(await api('discovery', { profile_id: id })); }
|
|
46
|
+
else { await api('resume', { id }); await loadProfile(id); }
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
if (value.error) notice(value.error);
|
|
50
|
+
$('members').replaceChildren(); $('routes').replaceChildren();
|
|
51
|
+
for (const member of value.members || []) $('members').append(text('p', `${member.id} · ${member.role === 'admin' ? '管理员' : '成员'} · ${member.capabilities.join(', ')}`));
|
|
52
|
+
for (const [capability, candidates] of Object.entries(value.routes || {})) $('routes').append(text('p', `${capability}:${candidates.join(' → ')}(首选 → 候补)`));
|
|
53
|
+
renderReviewRules(value);
|
|
54
|
+
$('exports').replaceChildren(); $('requests').replaceChildren();
|
|
55
|
+
for (const invitation of value.exports || []) {
|
|
56
|
+
const row = document.createElement('div'); row.className = 'card'; row.append(text('span', `${invitation.agent_id} · 有效至 ${new Date(invitation.expires_at).toLocaleDateString()}`));
|
|
57
|
+
const link = text('a', '下载加密接入包'); link.href = `/api/export?id=${id}&invite=${invitation.invite_id}`; link.download = invitation.filename; row.append(link); $('exports').append(row);
|
|
58
|
+
}
|
|
59
|
+
for (const request of value.requests || []) {
|
|
60
|
+
const row = document.createElement('div'); row.className = 'card'; row.append(text('span', `${request.agent_id} · ${request.capabilities.join(', ')} · userid: ${request.userid || '只接通知'}`), button('确认此成员入队', async () => { await api('approve', { id, request_id: request.request_id }); await loadProfile(id); })); $('requests').append(row);
|
|
61
|
+
}
|
|
62
|
+
if (!(value.requests || []).length) $('requests').append(text('p', '暂无待确认申请。'));
|
|
63
|
+
}
|
|
64
|
+
function renderReviewRules(profile) {
|
|
65
|
+
$('review-rules').replaceChildren(); reviewVersion = null;
|
|
66
|
+
const ready = profile.role === 'admin' && profile.phase === 'ready' && profile.routes && Number.isSafeInteger(profile.version);
|
|
67
|
+
$('review-form').hidden = !ready;
|
|
68
|
+
if (!ready) { $('review-policy-note').textContent = '团队同步成功后可设置审核规则。'; return; }
|
|
69
|
+
reviewVersion = profile.version;
|
|
70
|
+
$('review-policy-note').textContent = profile.policy_schema_version === 3
|
|
71
|
+
? '默认由执行者本人审核。没有本人群身份的测试机器人由管理员审核。'
|
|
72
|
+
: '此团队使用旧版规则。保存后升级审核协议;请先让所有成员升级工具并重启机器人与 MCP。';
|
|
73
|
+
const members = (profile.members || []).filter((member) => member.verified_owner);
|
|
74
|
+
function select(label, field, selected, options) {
|
|
75
|
+
const wrapper = text('label', label), input = document.createElement('select'); input.dataset.field = field;
|
|
76
|
+
for (const [value, title] of options) { const option = text('option', title); option.value = value; input.append(option); }
|
|
77
|
+
input.value = selected; wrapper.append(input); return wrapper;
|
|
78
|
+
}
|
|
79
|
+
for (const route of Object.keys(profile.routes)) {
|
|
80
|
+
const rule = profile.review_rules?.[route] || { required: true, reviewer: 'owner', notify: 'owner' };
|
|
81
|
+
const card = document.createElement('fieldset'); card.dataset.route = route; card.append(text('legend', route));
|
|
82
|
+
const check = text('label', '中间阶段提交后需要真人审核'); check.className = 'checkbox';
|
|
83
|
+
const required = document.createElement('input'); required.type = 'checkbox'; required.dataset.field = 'required'; required.checked = rule.required; check.prepend(required);
|
|
84
|
+
const row = document.createElement('div'); row.className = 'row';
|
|
85
|
+
const people = members.map((member) => [`member:${member.id}`, `指定同事:${member.id}`]);
|
|
86
|
+
row.append(select('审核人', 'reviewer', rule.reviewer, [['owner', '执行者本人'], ['admin', '管理员'], ...people]),
|
|
87
|
+
select('额外通知对象', 'notify', rule.notify, [['owner', '执行者本人'], ['reviewer', '审核人'], ['none', '不额外通知'], ...people]));
|
|
88
|
+
card.append(check, row); $('review-rules').append(card);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
$('review-form').onsubmit = (event) => { event.preventDefault(); action(async () => {
|
|
92
|
+
if (!Number.isSafeInteger(reviewVersion)) throw new Error('请先刷新工作台获取团队规则。');
|
|
93
|
+
const review_rules = {};
|
|
94
|
+
for (const row of $('review-rules').querySelectorAll('fieldset')) {
|
|
95
|
+
const field = (name) => row.querySelector(`[data-field="${name}"]`);
|
|
96
|
+
review_rules[row.dataset.route] = { required: field('required').checked, reviewer: field('reviewer').value, notify: field('notify').value };
|
|
97
|
+
}
|
|
98
|
+
await api('review-rules', { id: currentId, expected_version: reviewVersion, review_rules });
|
|
99
|
+
await loadProfile(currentId); notice('审核规则已保存,新任务将使用此规则。');
|
|
100
|
+
}); };
|
|
101
|
+
for (const button of document.querySelectorAll('[data-view]')) button.onclick = () => action(async () => { clearInterval(timer); show(button.dataset.view); if (button.dataset.view === 'home') await overview(); });
|
|
102
|
+
$('admin-form').onsubmit = (event) => { event.preventDefault(); action(async () => { const input = values(event.target); input.test_argv = JSON.parse(input.test_argv); pending = { type: 'admin', input }; const session = await api('discovery', { bot_id: input.bot_id, secret: input.secret, repo: input.repo }); pending.credentials = { bot_id: input.bot_id, secret: input.secret, repo: input.repo }; delete pending.input.secret; event.target.elements.secret.value = ''; await verifyView(session); }); };
|
|
103
|
+
$('join-form').onsubmit = (event) => { event.preventDefault(); action(async () => { const input = values(event.target); input.content = await input.file.text(); delete input.file; const profile = await api('import', input); event.target.elements.password.value = ''; currentId = profile.id; pending = { type: 'member', id: profile.id }; await verifyView(await api('discovery', { profile_id: profile.id })); }); };
|
|
104
|
+
$('retry-code').onclick = () => action(async () => { await verifyView(await api('discovery', pending.type === 'member' ? { profile_id: pending.id } : pending.credentials)); });
|
|
105
|
+
$('confirm-code').onclick = () => action(async () => {
|
|
106
|
+
const session = await api(`discovery?id=${discovery.id}`);
|
|
107
|
+
if (session.status !== 'verified') { updateConnection(session); throw new Error('请先完成群验证码验证。'); }
|
|
108
|
+
clearInterval(timer);
|
|
109
|
+
try {
|
|
110
|
+
const profile = pending.type === 'admin' ? await api('admin', { ...pending.input, discovery_id: discovery.id }) : await api('verify', { id: pending.id, discovery_id: discovery.id });
|
|
111
|
+
pending = null; await loadProfile(profile.id);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
// The server persists setup before Git/MCP work, so resume from that profile after an interruption.
|
|
114
|
+
const profiles = (await api('overview')).profiles;
|
|
115
|
+
const profile = profiles.find((value) => pending.type === 'member' ? value.id === pending.id : value.agent_id === pending.input.agent_id && value.role === 'admin');
|
|
116
|
+
if (profile) { pending = null; await loadProfile(profile.id); notice(`${error.message} 可在此继续接入。`); }
|
|
117
|
+
else throw error;
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
$('existing-form').onsubmit = (event) => { event.preventDefault(); action(async () => { const profile = await api('existing', values(event.target)); await loadProfile(profile.id); }); };
|
|
121
|
+
$('issue-form').onsubmit = (event) => { event.preventDefault(); action(async () => { const input = values(event.target); input.id = currentId; input.prefer = event.target.elements.prefer.checked; input.commands_enabled = event.target.elements.commands_enabled.checked; await api('issue', input); event.target.reset(); await loadProfile(currentId); notice('接入包已生成。下载文件交给指定成员,密码请单独发送。'); }); };
|
|
122
|
+
$('refresh').onclick = () => action(() => loadProfile(currentId));
|
|
123
|
+
$('start').onclick = () => action(async () => { await api('runner', { id: currentId, command: 'start' }); await loadProfile(currentId); });
|
|
124
|
+
$('stop').onclick = () => action(async () => { await api('runner', { id: currentId, command: 'stop' }); await loadProfile(currentId); });
|
|
125
|
+
$('copy-config-path').onclick = () => action(() => navigator.clipboard.writeText($('config-path').textContent));
|
|
126
|
+
$('copy-code').onclick = () => action(() => navigator.clipboard.writeText($('code').textContent));
|
|
127
|
+
$('copy-binding').onclick = () => action(() => navigator.clipboard.writeText($('binding-text').textContent));
|
|
128
|
+
action(async () => { const token = location.hash.slice(1); if (token) { await api('session', { token }); history.replaceState(null, '', '/'); } await overview(); });
|