@fxz26284407/yuanma-gateway 0.1.8 → 0.1.9
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/dist/codex.js +42 -17
- package/dist/desktop-sync.js +163 -0
- package/dist/project-access.js +4 -1
- package/dist/server.js +2 -2
- package/package.json +1 -1
package/dist/codex.js
CHANGED
|
@@ -28,7 +28,7 @@ export class CodexBackend {
|
|
|
28
28
|
directoryScopes = new Map();
|
|
29
29
|
nativeProjects = new Set();
|
|
30
30
|
directoryId(path) { return 'codex-directory:' + createHash('sha256').update(path).digest('hex'); }
|
|
31
|
-
nativeProjectId(id) { if (this.directoryScopes.has(id))
|
|
31
|
+
nativeProjectId(id) { if (id === 'codex:default' || this.directoryScopes.has(id))
|
|
32
32
|
return null; requireValue(!platform.codex.directoryScopes || this.nativeProjects.has(id), 'PROJECT_FORBIDDEN', '不是当前 Codex 目录或项目', 403); return id; }
|
|
33
33
|
ready = false;
|
|
34
34
|
permissionSelections = new Map();
|
|
@@ -178,7 +178,17 @@ export class CodexBackend {
|
|
|
178
178
|
if (this.directoryScopes.has(id))
|
|
179
179
|
return id;
|
|
180
180
|
}
|
|
181
|
-
|
|
181
|
+
if (legacy)
|
|
182
|
+
return null;
|
|
183
|
+
const defaults = this.projects.find(p => p.id === 'codex:default');
|
|
184
|
+
if (!defaults)
|
|
185
|
+
return null;
|
|
186
|
+
const cwd = typeof thread.cwd === 'string' ? await realpath(thread.cwd).catch(() => null) : null;
|
|
187
|
+
if (!cwd)
|
|
188
|
+
return null;
|
|
189
|
+
if (!defaults.roots.includes(cwd))
|
|
190
|
+
defaults.roots.push(cwd);
|
|
191
|
+
return defaults.id;
|
|
182
192
|
}
|
|
183
193
|
async inspect(projectId, threadId) {
|
|
184
194
|
if (this.fresh.has(threadId))
|
|
@@ -489,12 +499,21 @@ export class CodexBackend {
|
|
|
489
499
|
requireValue(false, 'INVALID_REQUEST', 'Unknown thread action', 400);
|
|
490
500
|
return this.threadMeta(projectId, threadId);
|
|
491
501
|
}
|
|
502
|
+
async navigationThreads(cursor, limit = 100) {
|
|
503
|
+
return this.call('thread/list', { limit, useStateDbOnly: true, sortKey: 'updated_at', ...(cursor && cursor !== 'start' ? { cursor } : {}) });
|
|
504
|
+
}
|
|
492
505
|
async navigation(grants) {
|
|
493
|
-
const [projects, sections, threads, state] = await Promise.all([this.call('project/list', { limit: 100 }), this.call('threadSection/list', { limit: 100 }), this.
|
|
506
|
+
const [projects, sections, threads, state] = await Promise.all([this.call('project/list', { limit: 100 }), this.call('threadSection/list', { limit: 100 }), this.navigationThreads(), desktopSidebarState()]);
|
|
494
507
|
await this.desktopSync?.recover(projects.data);
|
|
495
|
-
const directoryProjects = this.projects.filter(p => this.directoryScopes.has(p.id));
|
|
496
|
-
const assigned =
|
|
497
|
-
|
|
508
|
+
const directoryProjects = this.projects.filter(p => p.id === 'codex:default' || this.directoryScopes.has(p.id));
|
|
509
|
+
const assigned = await Promise.all(threads.data.map(async (t) => ({ ...t, projectId: await this.assignedProject(t) })));
|
|
510
|
+
const navigation = buildSidebar([...projects.data, ...directoryProjects], sections.data, assigned, state, grants, this.desktopSync?.placements());
|
|
511
|
+
const defaults = navigation.projects.find(p => p.id === 'codex:default');
|
|
512
|
+
if (defaults) {
|
|
513
|
+
defaults.threads = [];
|
|
514
|
+
Object.assign(defaults, { nextCursor: 'start' });
|
|
515
|
+
}
|
|
516
|
+
return { ...navigation, desktopSync: this.desktopSync?.status(grants) };
|
|
498
517
|
}
|
|
499
518
|
async manageProject(projectId, action, value) {
|
|
500
519
|
requireValue(this.projects.some(p => p.id === projectId), 'PROJECT_FORBIDDEN', '没有此项目的操作权限', 403);
|
|
@@ -541,11 +560,9 @@ export class CodexBackend {
|
|
|
541
560
|
} while (cursor && projects.length < 2000);
|
|
542
561
|
this.nativeProjects = new Set(projects.map(p => p.id));
|
|
543
562
|
if (platform.codex.directoryScopes) {
|
|
544
|
-
const directories = new Map();
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
do {
|
|
548
|
-
const page = await this.call('thread/list', { limit: 100, useStateDbOnly: true, ...(cursor ? { cursor } : {}) });
|
|
563
|
+
const directories = new Map(this.directoryScopes);
|
|
564
|
+
{
|
|
565
|
+
const page = await this.navigationThreads();
|
|
549
566
|
for (const thread of page.data) {
|
|
550
567
|
if (thread.projectId || typeof thread.cwd !== 'string')
|
|
551
568
|
continue;
|
|
@@ -553,13 +570,21 @@ export class CodexBackend {
|
|
|
553
570
|
if (path)
|
|
554
571
|
directories.set(this.directoryId(path), path);
|
|
555
572
|
}
|
|
556
|
-
|
|
557
|
-
cursor = page.nextCursor ?? undefined;
|
|
558
|
-
} while (cursor && seen < 2000);
|
|
573
|
+
}
|
|
559
574
|
this.directoryScopes = directories;
|
|
560
575
|
for (const [id, path] of directories)
|
|
561
576
|
projects.push({ id, name: basename(path) || path, roots: [path] });
|
|
562
577
|
}
|
|
578
|
+
const roots = [...(this.projects.find(p => p.id === 'codex:default')?.roots ?? [])];
|
|
579
|
+
const state = await desktopSidebarState();
|
|
580
|
+
for (const thread of (await this.navigationThreads()).data) {
|
|
581
|
+
if (thread.projectId || state.assignments[thread.id]?.projectId || typeof thread.cwd !== 'string')
|
|
582
|
+
continue;
|
|
583
|
+
const path = await realpath(thread.cwd).catch(() => null);
|
|
584
|
+
if (path && !roots.includes(path))
|
|
585
|
+
roots.push(path);
|
|
586
|
+
}
|
|
587
|
+
projects.push({ id: 'codex:default', name: '默认项目', roots: roots.length ? roots : [...(projects[0]?.roots ?? this.projects[0]?.roots ?? [])] });
|
|
563
588
|
return projects;
|
|
564
589
|
}
|
|
565
590
|
replaceProjects(projects) { this.projects.splice(0, this.projects.length, ...projects); }
|
|
@@ -675,11 +700,11 @@ export class CodexBackend {
|
|
|
675
700
|
await this.authorize(projectId, result.thread.id);
|
|
676
701
|
return { id: result.thread.id, name: result.thread.name ?? '新会话', status: result.thread.status?.type ?? 'idle' };
|
|
677
702
|
}
|
|
678
|
-
async listThreads(projectId) {
|
|
703
|
+
async listThreads(projectId, cursor) {
|
|
679
704
|
const project = this.projects.find(p => p.id === projectId);
|
|
680
705
|
requireValue(project, 'PROJECT_FORBIDDEN', 'Project is not locally authorized', 403);
|
|
681
706
|
const roots = await Promise.all(project.roots.map(p => realpath(p)));
|
|
682
|
-
const page = await this.
|
|
707
|
+
const page = await this.navigationThreads(cursor, projectId === 'codex:default' ? 20 : 100);
|
|
683
708
|
const data = [];
|
|
684
709
|
for (const thread of page.data) {
|
|
685
710
|
if (await this.assignedProject(thread) !== projectId)
|
|
@@ -687,7 +712,7 @@ export class CodexBackend {
|
|
|
687
712
|
const cwd = await realpath(thread.cwd).catch(() => null);
|
|
688
713
|
if (!cwd)
|
|
689
714
|
continue;
|
|
690
|
-
if (!roots.some(root => { const r = relative(root, cwd); return r === '' || (!isAbsolute(r) && r !== '..' && !r.startsWith(`..${sep}`)); }))
|
|
715
|
+
if (projectId !== 'codex:default' && !roots.some(root => { const r = relative(root, cwd); return r === '' || (!isAbsolute(r) && r !== '..' && !r.startsWith(`..${sep}`)); }))
|
|
691
716
|
continue;
|
|
692
717
|
data.push({ id: thread.id, name: threadDisplayName(thread), status: thread.status?.type ?? 'notLoaded', model: thread.model ?? null });
|
|
693
718
|
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { readFile, writeFile, rename, lstat, unlink, open } from 'node:fs/promises';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { join, isAbsolute } from 'node:path';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { requireValue } from './domain.js';
|
|
8
|
+
const execute = promisify(execFile);
|
|
9
|
+
const statePath = join(homedir(), '.codex/.codex-global-state.json');
|
|
10
|
+
async function desktopRunning() {
|
|
11
|
+
const { stdout } = await execute('/bin/ps', ['-axo', 'comm=']);
|
|
12
|
+
return stdout.split('\n').some(p => /\/(?:ChatGPT|Codex)\.app\/Contents\/MacOS\/(?:ChatGPT|Codex)$/.test(p.trim()));
|
|
13
|
+
}
|
|
14
|
+
async function regularFile(path, optional = false) {
|
|
15
|
+
const st = await lstat(path).catch(e => { if (optional && e.code === 'ENOENT')
|
|
16
|
+
return null; throw e; });
|
|
17
|
+
requireValue(!st || (st.isFile() && !st.isSymbolicLink() && st.nlink === 1 && st.uid === process.getuid?.()), 'UNSAFE_STORAGE', '桌面同步文件必须由当前用户持有,且不能是符号链接');
|
|
18
|
+
return st;
|
|
19
|
+
}
|
|
20
|
+
async function replaceFile(path, value, mode = 0o600) {
|
|
21
|
+
const temp = path + '.' + randomUUID() + '.tmp';
|
|
22
|
+
try {
|
|
23
|
+
const f = await open(temp, 'wx', mode);
|
|
24
|
+
try {
|
|
25
|
+
await f.writeFile(value);
|
|
26
|
+
await f.sync();
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
await f.close();
|
|
30
|
+
}
|
|
31
|
+
await rename(temp, path);
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
await unlink(temp).catch(e => { if (e.code !== 'ENOENT')
|
|
35
|
+
throw e; });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Only project registration, names, order and section membership change. All other state stays intact.
|
|
39
|
+
export function applyDesktopProjectChanges(state, changes) {
|
|
40
|
+
const next = structuredClone(state), identity = 'local:' + join(homedir(), '.codex');
|
|
41
|
+
const projects = next['local-projects'] ??= {};
|
|
42
|
+
const mappings = next['app-server-project-id-by-legacy-project-id-by-host'] ??= {};
|
|
43
|
+
const mapping = mappings[identity] ??= {};
|
|
44
|
+
const atoms = next['electron-persisted-atom-state'] ??= {};
|
|
45
|
+
const order = next['project-order'] ??= [];
|
|
46
|
+
const sidebarOrder = atoms['unified-sidebar-project-order-v1'] ??= [];
|
|
47
|
+
for (const change of changes) {
|
|
48
|
+
requireValue(/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(change.id) && change.name.trim().length > 0 && change.roots.length > 0 && change.roots.every(isAbsolute), 'INVALID_PROJECT', '待同步的项目记录不完整');
|
|
49
|
+
const id = Object.keys(mapping).find(id => mapping[id] === change.id && projects[id]) ?? change.id;
|
|
50
|
+
requireValue(!mapping[id] || mapping[id] === change.id, 'PROJECT_CONFLICT', '桌面项目映射已变化,请重新核对');
|
|
51
|
+
const deletions = next['app-server-pending-project-deletions-by-host']?.[identity] ?? {};
|
|
52
|
+
requireValue(!deletions[id] && !Object.values(deletions).some((d) => d.projectId === change.id), 'PROJECT_CONFLICT', '此项目正在桌面删除,请先在桌面处理');
|
|
53
|
+
mapping[id] = change.id;
|
|
54
|
+
if (!projects[id])
|
|
55
|
+
projects[id] = { id, name: change.name, rootPaths: change.roots, createdAt: Date.now(), updatedAt: Date.now() };
|
|
56
|
+
else if (change.rename && projects[id].name !== change.name)
|
|
57
|
+
projects[id] = { ...projects[id], name: change.name, updatedAt: Date.now() };
|
|
58
|
+
if (!order.includes(id))
|
|
59
|
+
order.push(id);
|
|
60
|
+
const key = 'codex:project:' + id;
|
|
61
|
+
if (!sidebarOrder.includes(key))
|
|
62
|
+
sidebarOrder.push(key);
|
|
63
|
+
if (change.sectionId === undefined)
|
|
64
|
+
continue;
|
|
65
|
+
const scopes = Object.values(atoms['sidebar-custom-sections-v3'] ?? {});
|
|
66
|
+
const belongs = (k) => k.startsWith('codex:project:') && (mapping[k.slice(14)] ?? k.slice(14)) === change.id;
|
|
67
|
+
const matches = scopes.filter(s => s.sections?.some((g) => change.sectionId ? g.hostSectionIds?.local === change.sectionId : (g.itemKeys ?? []).some(belongs)));
|
|
68
|
+
requireValue(change.sectionId ? matches.length === 1 : matches.length <= 1, 'SECTION_CONFLICT', '桌面分区已变化或存在多个账号归属,请重新选择分区');
|
|
69
|
+
for (const scope of matches)
|
|
70
|
+
for (const group of scope.sections) {
|
|
71
|
+
group.itemKeys = (group.itemKeys ?? []).filter((k) => !belongs(k));
|
|
72
|
+
if (change.sectionId && group.hostSectionIds?.local === change.sectionId)
|
|
73
|
+
group.itemKeys.push(key);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return next;
|
|
77
|
+
}
|
|
78
|
+
export class DesktopProjectSync {
|
|
79
|
+
directory;
|
|
80
|
+
journal = { version: 1, pending: {}, imported: [], appliedAt: 0 };
|
|
81
|
+
work = Promise.resolve();
|
|
82
|
+
timer;
|
|
83
|
+
problem = '';
|
|
84
|
+
closed = false;
|
|
85
|
+
constructor(directory) {
|
|
86
|
+
this.directory = directory;
|
|
87
|
+
}
|
|
88
|
+
get path() { return join(this.directory, 'desktop-project-sync.json'); }
|
|
89
|
+
run(action) { const result = this.work.then(action); this.work = result.then(() => { }, () => { }); return result; }
|
|
90
|
+
async start() {
|
|
91
|
+
await regularFile(this.path, true);
|
|
92
|
+
try {
|
|
93
|
+
const value = JSON.parse(await readFile(this.path, 'utf8'));
|
|
94
|
+
requireValue(value.version === 1 && value.pending && Array.isArray(value.imported) && typeof value.appliedAt === 'number', 'UNSAFE_STORAGE', '无法读取桌面待同步记录');
|
|
95
|
+
this.journal = value;
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
if (e.code !== 'ENOENT')
|
|
99
|
+
throw e;
|
|
100
|
+
}
|
|
101
|
+
// ponytail: one queue serializes this small state file; separate locks only if multiple writers are introduced.
|
|
102
|
+
this.timer = setInterval(() => { if (Object.keys(this.journal.pending).length)
|
|
103
|
+
this.flush().catch(e => { this.problem = e instanceof Error ? e.message : '桌面同步失败,请刷新后重试'; }); }, 2000);
|
|
104
|
+
this.timer.unref();
|
|
105
|
+
}
|
|
106
|
+
async persist(value = this.journal) { await regularFile(this.path, true); await replaceFile(this.path, JSON.stringify(value)); }
|
|
107
|
+
enqueue(change) {
|
|
108
|
+
return this.run(async () => {
|
|
109
|
+
const next = structuredClone(this.journal);
|
|
110
|
+
next.pending[change.id] = { ...next.pending[change.id], ...change, rename: next.pending[change.id]?.rename || change.rename };
|
|
111
|
+
if (!next.imported.includes(change.id))
|
|
112
|
+
next.imported.push(change.id);
|
|
113
|
+
await this.persist(next);
|
|
114
|
+
this.journal = next;
|
|
115
|
+
this.problem = '';
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
recover(projects) {
|
|
119
|
+
return this.run(async () => {
|
|
120
|
+
let changed = false;
|
|
121
|
+
const next = structuredClone(this.journal);
|
|
122
|
+
for (const p of projects) {
|
|
123
|
+
const sectionId = p.metadata?.['mobile.sectionId'];
|
|
124
|
+
if (typeof sectionId !== 'string' || next.imported.includes(p.id))
|
|
125
|
+
continue;
|
|
126
|
+
if (!next.pending[p.id])
|
|
127
|
+
next.pending[p.id] = { id: p.id, name: p.name, roots: p.roots.map((r) => r.path), sectionId };
|
|
128
|
+
next.imported.push(p.id);
|
|
129
|
+
changed = true;
|
|
130
|
+
}
|
|
131
|
+
if (changed) {
|
|
132
|
+
await this.persist(next);
|
|
133
|
+
this.journal = next;
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
status(grants) { const pending = Object.values(this.journal.pending).filter(p => !!grants[p.id]); return { pending: pending.length, appliedAt: this.journal.appliedAt, error: pending.length ? this.problem : '' }; }
|
|
138
|
+
placements() { return Object.values(this.journal.pending).filter(p => p.sectionId !== undefined).map(p => ({ id: p.id, sectionId: p.sectionId })); }
|
|
139
|
+
flush() {
|
|
140
|
+
return this.run(async () => {
|
|
141
|
+
if (this.closed || !Object.keys(this.journal.pending).length || await desktopRunning())
|
|
142
|
+
return;
|
|
143
|
+
const stat = (await regularFile(statePath));
|
|
144
|
+
await regularFile(statePath + '.bak', true);
|
|
145
|
+
const original = await readFile(statePath, 'utf8'), state = JSON.parse(original);
|
|
146
|
+
requireValue(state && typeof state === 'object' && !Array.isArray(state), 'UNSAFE_STORAGE', '桌面状态格式无效');
|
|
147
|
+
const updated = JSON.stringify(applyDesktopProjectChanges(state, Object.values(this.journal.pending)));
|
|
148
|
+
const backup = join(this.directory, 'desktop-state-before-sync-' + Date.now() + '.json');
|
|
149
|
+
await writeFile(backup, original, { flag: 'wx', mode: 0o600 });
|
|
150
|
+
// Desktop must stay closed until the phone reports completion; it does not reload external file changes.
|
|
151
|
+
if (this.closed || await desktopRunning() || await readFile(statePath, 'utf8') !== original)
|
|
152
|
+
return;
|
|
153
|
+
await replaceFile(statePath, updated, stat.mode & 0o777);
|
|
154
|
+
await replaceFile(statePath + '.bak', updated, 0o600);
|
|
155
|
+
const next = { ...this.journal, pending: {}, appliedAt: Date.now() };
|
|
156
|
+
await this.persist(next);
|
|
157
|
+
this.journal = next;
|
|
158
|
+
this.problem = '';
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
async close() { this.closed = true; if (this.timer)
|
|
162
|
+
clearInterval(this.timer); await this.work; }
|
|
163
|
+
}
|
package/dist/project-access.js
CHANGED
|
@@ -42,7 +42,10 @@ export class ProjectAccess {
|
|
|
42
42
|
return;
|
|
43
43
|
this.syncing = (async () => {
|
|
44
44
|
const results = await Promise.allSettled(this.catalogBackends.filter(b => b.projectCatalog).map(b => b.projectCatalog()));
|
|
45
|
-
const catalog = results.flatMap(r => r.status === 'fulfilled' ? r.value : []), next = this.projects.map(p =>
|
|
45
|
+
const catalog = results.flatMap(r => r.status === 'fulfilled' ? r.value : []), next = this.projects.map(p => {
|
|
46
|
+
const current = catalog.find(c => c.id === p.id);
|
|
47
|
+
return { ...p, name: current?.name ?? p.name, roots: p.id === 'codex:default' && current ? current.roots : p.roots };
|
|
48
|
+
}), seen = new Set(next.map(p => p.id));
|
|
46
49
|
for (const p of catalog) {
|
|
47
50
|
if (seen.has(p.id))
|
|
48
51
|
continue;
|
package/dist/server.js
CHANGED
|
@@ -677,7 +677,7 @@ export async function startGateway(options) {
|
|
|
677
677
|
const projectId = text(url.searchParams.get('projectId'), 'projectId');
|
|
678
678
|
allowed(projectId, actor);
|
|
679
679
|
requireValue(current().backend.listThreads, 'UNSUPPORTED_COMMAND', 'Thread catalog unavailable', 503);
|
|
680
|
-
return current().backend.listThreads(projectId);
|
|
680
|
+
return current().backend.listThreads(projectId, url.searchParams.get('cursor') ?? undefined);
|
|
681
681
|
}
|
|
682
682
|
if (req.method === 'GET' && url.pathname === '/v1/approvals') {
|
|
683
683
|
const projectId = text(url.searchParams.get('projectId'), 'projectId');
|
|
@@ -817,7 +817,7 @@ export async function startGateway(options) {
|
|
|
817
817
|
const projectId = text(url.searchParams.get('projectId'), 'projectId');
|
|
818
818
|
allowed(projectId);
|
|
819
819
|
requireValue(current().backend.listThreads, 'UNSUPPORTED_COMMAND', 'Thread catalog unavailable', 503);
|
|
820
|
-
send(res, 200, { requestId, data: await current().backend.listThreads(projectId) });
|
|
820
|
+
send(res, 200, { requestId, data: await current().backend.listThreads(projectId, url.searchParams.get('cursor') ?? undefined) });
|
|
821
821
|
return;
|
|
822
822
|
}
|
|
823
823
|
const opMatch = url.pathname.match(/^\/v1\/operations\/([^/]+)$/);
|