codexunlimit 0.1.0-web.13 → 0.1.0-web.16
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 +97 -79
- package/cli.mjs +2 -110
- package/codex-desktop.ps1 +85 -0
- package/package.json +32 -14
- package/runtime.mjs +1 -0
- package/shared-ZNBXOIRW.mjs +1 -0
- package/web/app.js +1 -154
- package/web/privacy.html +1 -0
- package/web/style.css +5 -0
- package/account-pool.mjs +0 -161
- package/app.mjs +0 -161
- package/codex-account-client.mjs +0 -53
- package/config.mjs +0 -224
- package/doctor.mjs +0 -20
- package/image-http.mjs +0 -22
- package/io.mjs +0 -5
- package/license-client.mjs +0 -153
- package/product.mjs +0 -5
- package/protocol.mjs +0 -28
- package/quota.mjs +0 -34
- package/relay.mjs +0 -156
- package/state.mjs +0 -33
- package/updates.mjs +0 -47
- package/web/markdown.js +0 -15
- package/web/messages.js +0 -68
- package/web/pool-ui.js +0 -53
- package/web/stream.js +0 -25
- package/web/updates.js +0 -26
- package/windows.mjs +0 -53
package/account-pool.mjs
DELETED
|
@@ -1,161 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import crypto from 'node:crypto';
|
|
4
|
-
import {CodexAccountClient} from './codex-account-client.mjs';
|
|
5
|
-
import {LocalQuota,accountIdentity,rateLimitWindows} from './quota.mjs';
|
|
6
|
-
|
|
7
|
-
const validId=id=>typeof id==='string'&&/^[a-f0-9-]{36}$/.test(id);
|
|
8
|
-
const failure=code=>Object.assign(Error(code),{status:409});
|
|
9
|
-
function readAuth(file){
|
|
10
|
-
if(!fs.existsSync(file)||fs.lstatSync(file).isSymbolicLink()||fs.statSync(file).size>131072)throw failure('pool_account_expired');
|
|
11
|
-
const auth=JSON.parse(fs.readFileSync(file,'utf8').replace(/^\uFEFF/,''));
|
|
12
|
-
if(typeof auth.tokens?.access_token!=='string'||!/^\S{16,32768}$/.test(auth.tokens.access_token)||auth.OPENAI_API_KEY)throw failure('pool_account_expired');
|
|
13
|
-
return auth;
|
|
14
|
-
}
|
|
15
|
-
function credentialsOf(auth){
|
|
16
|
-
const token=auth.tokens.access_token;let claims={};try{claims=JSON.parse(Buffer.from(token.split('.')[1],'base64url'));}catch{}
|
|
17
|
-
const accountId=auth.tokens.account_id??claims['https://api.openai.com/auth']?.chatgpt_account_id;
|
|
18
|
-
return {token,accountId:typeof accountId==='string'?accountId:undefined,expiresAt:typeof claims.exp==='number'?claims.exp*1000:null};
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export class AccountPool{
|
|
22
|
-
constructor(state,{privateKey,defaultAccount,clientFactory=(home,options)=>new CodexAccountClient(home,options),now=Date.now}={}){
|
|
23
|
-
this.state=state;this.db=state.db;this.defaultAccount=defaultAccount;this.clientFactory=clientFactory;this.now=now;this.pending=null;this.closed=false;this.operations=new Map();this.clients=new Set();this.refreshing=new Set();this.quotaErrors=new Map();
|
|
24
|
-
this.removing=new Set();this.refreshes=new Map();this.key=crypto.createHash('sha256').update(privateKey.export({type:'pkcs8',format:'der'})).update('codexunlimit.account-pool.v1').digest();
|
|
25
|
-
this.directory=path.join(state.home,'accounts');fs.mkdirSync(this.directory,{recursive:true,mode:0o700});if(fs.lstatSync(this.directory).isSymbolicLink())throw failure('pool_storage_invalid');
|
|
26
|
-
this.db.exec('CREATE TABLE IF NOT EXISTS pool_accounts(id TEXT PRIMARY KEY,label TEXT NOT NULL,account_ref TEXT,email TEXT,plan TEXT,secret TEXT,state TEXT NOT NULL,created_at INTEGER NOT NULL,last_error TEXT)');
|
|
27
|
-
this.quota=new LocalQuota(state,{now});
|
|
28
|
-
for(const row of this.rows()){
|
|
29
|
-
const file=path.join(this.profile(row.id),'auth.json');
|
|
30
|
-
if(fs.existsSync(file)){try{this.saveAuth(row.id,readAuth(file));fs.unlinkSync(file);}catch{}}
|
|
31
|
-
if(!row.secret)this.db.prepare("UPDATE pool_accounts SET state='needs_login',last_error='pool_login_interrupted' WHERE id=? AND secret IS NULL").run(row.id);
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
rows(){return this.db.prepare('SELECT * FROM pool_accounts ORDER BY created_at,id').all();}
|
|
35
|
-
row(id){if(!validId(id))throw failure('pool_account_missing');const row=this.db.prepare('SELECT * FROM pool_accounts WHERE id=?').get(id);if(!row)throw failure('pool_account_missing');return row;}
|
|
36
|
-
active(){return this.state.get('pool.active')??'codex';}
|
|
37
|
-
profile(id){
|
|
38
|
-
if(id!=='codex'&&!validId(id))throw failure('pool_account_missing');
|
|
39
|
-
const folder=path.join(this.directory,id);if(path.dirname(folder)!==this.directory||fs.existsSync(folder)&&fs.lstatSync(folder).isSymbolicLink())throw failure('pool_storage_invalid');
|
|
40
|
-
fs.mkdirSync(folder,{recursive:true,mode:0o700});return folder;
|
|
41
|
-
}
|
|
42
|
-
encrypt(id,auth){const iv=crypto.randomBytes(12),cipher=crypto.createCipheriv('aes-256-gcm',this.key,iv);cipher.setAAD(Buffer.from(id));const data=Buffer.concat([cipher.update(JSON.stringify(auth),'utf8'),cipher.final()]);return JSON.stringify({iv:iv.toString('base64'),tag:cipher.getAuthTag().toString('base64'),data:data.toString('base64')});}
|
|
43
|
-
decrypt(row){try{const secret=JSON.parse(row.secret),decipher=crypto.createDecipheriv('aes-256-gcm',this.key,Buffer.from(secret.iv,'base64'));decipher.setAAD(Buffer.from(row.id));decipher.setAuthTag(Buffer.from(secret.tag,'base64'));return JSON.parse(Buffer.concat([decipher.update(Buffer.from(secret.data,'base64')),decipher.final()]));}catch{throw failure('pool_account_expired');}}
|
|
44
|
-
saveAuth(id,auth,info={}){
|
|
45
|
-
const identity=accountIdentity(credentialsOf(auth));
|
|
46
|
-
if(this.db.prepare('SELECT id FROM pool_accounts WHERE account_ref=? AND id<>?').get(identity.ref,id))throw failure('pool_account_exists');
|
|
47
|
-
const email=typeof info.email==='string'?info.email.slice(0,254):identity.email,plan=typeof info.planType==='string'?info.planType.slice(0,40):identity.plan;
|
|
48
|
-
this.db.prepare("UPDATE pool_accounts SET secret=?,account_ref=?,email=?,plan=?,state='ready',last_error=NULL WHERE id=?").run(this.encrypt(id,auth),identity.ref,email,plan,id);return identity.ref;
|
|
49
|
-
}
|
|
50
|
-
async defaultCredentials(){return this.defaultAccount();}
|
|
51
|
-
list(){
|
|
52
|
-
let defaultRow={id:'codex',label:'Tài khoản trong Codex',state:'needs_login',windows:[]};
|
|
53
|
-
try{const credentials=this.defaultAccount(),identity=accountIdentity(credentials);defaultRow={...defaultRow,...identity,state:'ready',windows:this.quota.read(identity.ref)};delete defaultRow.ref;delete defaultRow.accountId;}catch{}
|
|
54
|
-
const rows=[defaultRow,...this.rows().map(row=>({id:row.id,label:row.label,email:row.email,plan:row.plan,state:row.state,error:row.last_error,windows:row.account_ref?this.quota.read(row.account_ref):[]}))].map(row=>({...row,active:row.id===this.active(),refreshing:this.refreshing.has(row.id),quotaError:this.quotaErrors.get(row.id)??null}));
|
|
55
|
-
const pending=this.pending?{accountId:this.pending.accountId,status:this.pending.status,type:this.pending.type,url:this.pending.status==='pending'?this.pending.url:undefined,userCode:this.pending.status==='pending'?this.pending.userCode:undefined,error:this.pending.error,expiresAt:this.pending.expiresAt}:null;
|
|
56
|
-
return {rows,activeId:this.active(),pending,maxAccounts:10,now:this.now()};
|
|
57
|
-
}
|
|
58
|
-
async credentials(id=this.active()){
|
|
59
|
-
if(this.removing.has(id))throw failure('pool_account_missing');
|
|
60
|
-
if(id==='codex')return {...await this.defaultCredentials(),poolId:'codex'};
|
|
61
|
-
const row=this.row(id);if(!row.secret||row.state!=='ready')throw failure('pool_account_expired');
|
|
62
|
-
let credentials=credentialsOf(this.decrypt(row));
|
|
63
|
-
if(credentials.expiresAt!==null&&credentials.expiresAt<=this.now()+120000){
|
|
64
|
-
let refresh=this.refreshes.get(id);if(!refresh){refresh=this.withProfile(id,client=>client.request('account/read',{refreshToken:true}));this.refreshes.set(id,refresh);refresh.finally(()=>{if(this.refreshes.get(id)===refresh)this.refreshes.delete(id);}).catch(()=>{});}
|
|
65
|
-
await refresh;credentials=credentialsOf(this.decrypt(this.row(id)));
|
|
66
|
-
}
|
|
67
|
-
if(credentials.expiresAt!==null&&credentials.expiresAt<=this.now())throw failure('pool_account_expired');
|
|
68
|
-
return {...credentials,poolId:id};
|
|
69
|
-
}
|
|
70
|
-
client(home,options={}){const client=this.clientFactory(home,options);this.clients.add(client);return client;}
|
|
71
|
-
async closeClient(client){try{await client.close();}finally{this.clients.delete(client);}}
|
|
72
|
-
withProfile(id,action){
|
|
73
|
-
if(this.closed)return Promise.reject(failure('pool_runtime_unavailable'));if(this.removing.has(id))return Promise.reject(failure('pool_account_missing'));
|
|
74
|
-
const previous=this.operations.get(id)??Promise.resolve();
|
|
75
|
-
const operation=previous.catch(()=>{}).then(async()=>{
|
|
76
|
-
if(this.closed)throw failure('pool_runtime_unavailable');if(this.removing.has(id))throw failure('pool_account_missing');
|
|
77
|
-
const row=this.row(id),folder=this.profile(id),file=path.join(folder,'auth.json');
|
|
78
|
-
if(this.pending?.accountId===id&&this.pending.status==='pending')throw failure('pool_login_pending');
|
|
79
|
-
if(fs.existsSync(file)&&fs.lstatSync(file).isSymbolicLink())throw failure('pool_storage_invalid');
|
|
80
|
-
fs.writeFileSync(file,JSON.stringify(this.decrypt(row)),{mode:0o600});
|
|
81
|
-
let client;
|
|
82
|
-
try{client=this.client(folder);await client.ready;return await action(client);}
|
|
83
|
-
finally{
|
|
84
|
-
if(client)await this.closeClient(client);
|
|
85
|
-
if(fs.existsSync(file)){try{this.saveAuth(id,readAuth(file));}finally{fs.unlinkSync(file);}}
|
|
86
|
-
}
|
|
87
|
-
});
|
|
88
|
-
this.operations.set(id,operation);operation.finally(()=>{if(this.operations.get(id)===operation)this.operations.delete(id);}).catch(()=>{});return operation;
|
|
89
|
-
}
|
|
90
|
-
async select(id){await this.credentials(id);if(this.removing.has(id))throw failure('pool_account_missing');if(id!=='codex')this.row(id);this.state.set('pool.active',id);this.state.set('web.connected',true);return this.list();}
|
|
91
|
-
rename(id,label){if(typeof label!=='string'||!label.trim()||label.length>60)throw failure('invalid_input');this.row(id);this.db.prepare('UPDATE pool_accounts SET label=? WHERE id=?').run(label.trim(),id);return this.list();}
|
|
92
|
-
async remove(id){
|
|
93
|
-
this.row(id);if(this.removing.has(id))throw failure('pool_account_missing');this.removing.add(id);
|
|
94
|
-
try{
|
|
95
|
-
if(this.pending?.accountId===id&&this.pending.status==='pending')await this.cancelLogin();await this.operations.get(id)?.catch(()=>{});
|
|
96
|
-
const folder=this.profile(id);if(path.dirname(fs.realpathSync(folder))!==fs.realpathSync(this.directory))throw failure('pool_storage_invalid');fs.rmSync(folder,{recursive:true,force:true,maxRetries:10,retryDelay:100});
|
|
97
|
-
this.db.prepare('DELETE FROM pool_accounts WHERE id=?').run(id);if(this.active()===id)this.state.set('pool.active','codex');return this.list();
|
|
98
|
-
}finally{this.removing.delete(id);}
|
|
99
|
-
}
|
|
100
|
-
async startLogin({id,type='chatgpt',label='Tài khoản mới'}={}){
|
|
101
|
-
if(this.closed)throw failure('pool_runtime_unavailable');if(this.pending?.status==='pending')throw failure('pool_login_pending');
|
|
102
|
-
if(!['chatgpt','chatgptDeviceCode'].includes(type)||typeof label!=='string'||!label.trim()||label.length>60)throw failure('invalid_input');
|
|
103
|
-
let prior=null;if(id){if(this.removing.has(id))throw failure('pool_account_missing');prior=this.row(id);}else if(this.rows().length>=10)throw failure('pool_full');
|
|
104
|
-
const accountId=id??crypto.randomUUID(),folder=this.profile(accountId),file=path.join(folder,'auth.json');
|
|
105
|
-
await this.operations.get(accountId)?.catch(()=>{});
|
|
106
|
-
if(!prior)this.db.prepare("INSERT INTO pool_accounts(id,label,state,created_at) VALUES(?,?,'pending',?)").run(accountId,label.trim(),this.now());
|
|
107
|
-
const pending={accountId,type,status:'pending',expiresAt:this.now()+10*60000,prior};this.pending=pending;
|
|
108
|
-
let client;
|
|
109
|
-
try{
|
|
110
|
-
client=this.client(folder,{onNotification:(method,params)=>{
|
|
111
|
-
if(method==='account/login/completed'){pending.completion=params;if(pending.loginId&&!pending.task)pending.task=this.finishLogin(pending).catch(()=>{});}
|
|
112
|
-
}});pending.client=client;
|
|
113
|
-
const login=await client.request('account/login/start',{type});
|
|
114
|
-
const url=new URL(login.authUrl??login.verificationUrl);if(url.protocol!=='https:'||!['auth.openai.com','auth0.openai.com'].includes(url.hostname)||url.username||url.password)throw failure('pool_login_unavailable');
|
|
115
|
-
pending.loginId=login.loginId;pending.url=url.href;pending.userCode=typeof login.userCode==='string'?login.userCode.slice(0,64):undefined;
|
|
116
|
-
pending.timer=setTimeout(()=>this.cancelLogin('pool_login_expired').catch(()=>{}),10*60000);pending.timer.unref();
|
|
117
|
-
if(pending.completion&&!pending.task)pending.task=this.finishLogin(pending).catch(()=>{});return this.list();
|
|
118
|
-
}catch(error){if(client)await this.closeClient(client);if(fs.existsSync(file)&&!fs.lstatSync(file).isSymbolicLink())fs.unlinkSync(file);if(!prior)this.db.prepare('DELETE FROM pool_accounts WHERE id=?').run(accountId);pending.status='failed';pending.error='pool_login_unavailable';throw failure('pool_login_unavailable');}
|
|
119
|
-
}
|
|
120
|
-
async finishLogin(pending){
|
|
121
|
-
if(this.closed||pending!==this.pending||pending.status!=='pending'||pending.finishing||pending.completion?.loginId!==pending.loginId)return;
|
|
122
|
-
pending.finishing=true;clearTimeout(pending.timer);const file=path.join(this.profile(pending.accountId),'auth.json');
|
|
123
|
-
try{
|
|
124
|
-
if(!pending.completion.success)throw failure('pool_login_failed');
|
|
125
|
-
const result=await pending.client.request('account/read',{refreshToken:false});if(result.account?.type!=='chatgpt')throw failure('pool_login_failed');
|
|
126
|
-
let limits;try{limits=await pending.client.request('account/rateLimits/read');}catch{}
|
|
127
|
-
await this.closeClient(pending.client);const auth=readAuth(file),ref=this.saveAuth(pending.accountId,auth,result.account);
|
|
128
|
-
if(limits)this.quota.save(ref,rateLimitWindows(limits),'account_api');pending.status='complete';
|
|
129
|
-
}catch(error){pending.status='failed';pending.error=error.message==='pool_account_exists'?'pool_account_exists':'pool_login_failed';await this.closeClient(pending.client);if(!pending.prior)this.db.prepare('DELETE FROM pool_accounts WHERE id=?').run(pending.accountId);}
|
|
130
|
-
finally{if(fs.existsSync(file)&&!fs.lstatSync(file).isSymbolicLink())fs.unlinkSync(file);}
|
|
131
|
-
}
|
|
132
|
-
async cancelLogin(reason=null){
|
|
133
|
-
const pending=this.pending;if(!pending||pending.status!=='pending')return this.list();
|
|
134
|
-
if(pending.finishing)throw failure('pool_login_pending');pending.status='cancelled';pending.error=reason;clearTimeout(pending.timer);
|
|
135
|
-
try{if(pending.loginId)await pending.client.request('account/login/cancel',{loginId:pending.loginId});}catch{}
|
|
136
|
-
await this.closeClient(pending.client);const file=path.join(this.profile(pending.accountId),'auth.json');if(fs.existsSync(file)&&!fs.lstatSync(file).isSymbolicLink())fs.unlinkSync(file);
|
|
137
|
-
if(!pending.prior)this.db.prepare('DELETE FROM pool_accounts WHERE id=?').run(pending.accountId);return this.list();
|
|
138
|
-
}
|
|
139
|
-
async refreshQuota(id=this.active()){
|
|
140
|
-
if(this.refreshing.has(id))return this.list();if(id!=='codex')this.row(id);
|
|
141
|
-
this.refreshing.add(id);this.quotaErrors.delete(id);
|
|
142
|
-
try{
|
|
143
|
-
let credentials,limits;
|
|
144
|
-
if(id==='codex'){
|
|
145
|
-
credentials=await this.defaultCredentials();if(!credentials.accountId)throw failure('pool_quota_unavailable');
|
|
146
|
-
const client=this.client(this.profile('codex'),{externalCredentials:()=>this.defaultCredentials()});
|
|
147
|
-
try{await client.request('account/login/start',{type:'chatgptAuthTokens',accessToken:credentials.token,chatgptAccountId:credentials.accountId});limits=await client.request('account/rateLimits/read');}finally{await this.closeClient(client);}
|
|
148
|
-
}else{
|
|
149
|
-
credentials=await this.credentials(id);limits=await this.withProfile(id,client=>client.request('account/rateLimits/read'));
|
|
150
|
-
}
|
|
151
|
-
const windows=rateLimitWindows(limits);if(!windows.length)throw failure('pool_quota_unavailable');this.quota.save(accountIdentity(credentials).ref,windows,'account_api');
|
|
152
|
-
}catch(error){this.quotaErrors.set(id,error.message==='pool_account_expired'?'pool_account_expired':'pool_quota_unavailable');throw failure(this.quotaErrors.get(id));}
|
|
153
|
-
finally{this.refreshing.delete(id);}
|
|
154
|
-
return this.list();
|
|
155
|
-
}
|
|
156
|
-
observe(credentials,response){try{this.quota.observe(credentials,response);}catch{}}
|
|
157
|
-
async close(){
|
|
158
|
-
if(this.pending?.status==='pending'&&!this.pending.finishing)await this.cancelLogin().catch(()=>{});
|
|
159
|
-
this.closed=true;await Promise.allSettled([...this.clients].map(client=>this.closeClient(client)));await Promise.allSettled([...this.operations.values(),this.pending?.task]);this.key.fill(0);
|
|
160
|
-
}
|
|
161
|
-
}
|
package/app.mjs
DELETED
|
@@ -1,161 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import crypto from 'node:crypto';
|
|
4
|
-
import {Readable} from 'node:stream';
|
|
5
|
-
import {pipeline} from 'node:stream/promises';
|
|
6
|
-
import {syncCodex,unsyncCodex,codexConnection} from './config.mjs';
|
|
7
|
-
import {errors} from './web/messages.js';
|
|
8
|
-
import {VERSION} from './product.mjs';
|
|
9
|
-
import {createUpdateChecker} from './updates.mjs';
|
|
10
|
-
import {boundedJson} from './io.mjs';
|
|
11
|
-
import {fileURLToPath} from 'node:url';
|
|
12
|
-
|
|
13
|
-
const random=()=>crypto.randomBytes(32).toString('base64url');
|
|
14
|
-
const digest=value=>crypto.createHash('sha256').update(value).digest('hex');
|
|
15
|
-
const fail=(code,status=400)=>Object.assign(Error(code),{status});
|
|
16
|
-
export function createBrowserTicket(state){
|
|
17
|
-
const ticket=random();state.set('web.ticket',{hash:digest(ticket),expires:Date.now()+120000});return ticket;
|
|
18
|
-
}
|
|
19
|
-
export function readCodexAccount(state){
|
|
20
|
-
try{
|
|
21
|
-
const file=path.join(state.get('codex_home'),'auth.json');
|
|
22
|
-
if(fs.statSync(file).size>131072)throw Error();
|
|
23
|
-
const auth=JSON.parse(fs.readFileSync(file,'utf8')),token=auth.tokens?.access_token;
|
|
24
|
-
if(typeof token!=='string'||!/^\S{16,32768}$/.test(token))throw Error();
|
|
25
|
-
let claims={};try{claims=JSON.parse(Buffer.from(token.split('.')[1],'base64url'));}catch{}
|
|
26
|
-
if(claims.exp&&claims.exp*1000<=Date.now())throw fail('account_expired');
|
|
27
|
-
const accountId=auth.tokens.account_id??claims['https://api.openai.com/auth']?.chatgpt_account_id;
|
|
28
|
-
return {token,accountId:typeof accountId==='string'?accountId:undefined};
|
|
29
|
-
}catch(e){throw fail(e.message==='account_expired'?'account_expired':'account_required',409);}
|
|
30
|
-
}
|
|
31
|
-
async function body(req){
|
|
32
|
-
if(!/^application\/json(?:;|$)/i.test(req.headers['content-type']??''))throw fail('json_required',415);
|
|
33
|
-
if(Number(req.headers['content-length']??0)>1048576){req.resume();throw fail('request_too_large',413);}
|
|
34
|
-
let size=0;const chunks=[];
|
|
35
|
-
for await(const chunk of req){size+=chunk.length;if(size>1048576)throw fail('request_too_large',413);chunks.push(chunk);}
|
|
36
|
-
try{const data=JSON.parse(Buffer.concat(chunks));if(!data||typeof data!=='object'||Array.isArray(data))throw Error();return data;}catch{throw fail('invalid_input');}
|
|
37
|
-
}
|
|
38
|
-
export function createLocalApp(state,{license,request=fetch,account=()=>readCodexAccount(state),desktop,pool,checkUpdates=createUpdateChecker(),onStop=()=>{}}={}){
|
|
39
|
-
const sessions=new Map(),assets={'/app/':'index.html','/app/app.js':'app.js','/app/style.css':'style.css','/app/stream.js':'stream.js','/app/messages.js':'messages.js'};
|
|
40
|
-
assets['/app/updates.js']='updates.js';
|
|
41
|
-
assets['/app/markdown.js']='markdown.js';assets['/app/pool-ui.js']='pool-ui.js';
|
|
42
|
-
assets['/app/vendor/markdown-it.js']=fileURLToPath(import.meta.resolve('markdown-it/browser'));
|
|
43
|
-
for(const name of fs.readdirSync(path.join(import.meta.dirname,'web/assets')))if(/^[a-z0-9.-]+\.(woff2|css|txt|svg|png|ico)$/.test(name))assets['/app/assets/'+name]='assets/'+name;
|
|
44
|
-
return async(req,res,server)=>{
|
|
45
|
-
const url=new URL(req.url,'http://127.0.0.1');
|
|
46
|
-
if(!url.pathname.startsWith('/app'))return false;
|
|
47
|
-
const headers={'cache-control':'no-store','x-content-type-options':'nosniff','referrer-policy':'no-referrer','x-frame-options':'DENY',
|
|
48
|
-
'content-security-policy':"default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; font-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"};
|
|
49
|
-
const json=(status,data,extra={})=>{res.writeHead(status,{...headers,'content-type':'application/json; charset=utf-8',...extra});res.end(JSON.stringify(data));};
|
|
50
|
-
try{
|
|
51
|
-
const origin='http://'+req.headers.host;
|
|
52
|
-
// External links may navigate to the public shell; APIs still require same-origin access.
|
|
53
|
-
const entryNavigation=req.method==='GET'&&(url.pathname==='/app'||url.pathname==='/app/')&&
|
|
54
|
-
req.headers['sec-fetch-mode']==='navigate'&&req.headers['sec-fetch-dest']==='document';
|
|
55
|
-
if((req.headers.origin&&req.headers.origin!==origin)||(req.headers['sec-fetch-site']==='cross-site'&&!entryNavigation))throw fail('local_request_denied',403);
|
|
56
|
-
if(req.method==='GET'&&url.pathname==='/app'){res.writeHead(302,{...headers,location:'/app/'});res.end();return true;}
|
|
57
|
-
if(req.method==='GET'&&assets[url.pathname]){
|
|
58
|
-
const file=assets[url.pathname],type=file.endsWith('.html')?'text/html':file.endsWith('.css')?'text/css':file.endsWith('.svg')?'image/svg+xml':file.endsWith('.png')?'image/png':file.endsWith('.ico')?'image/x-icon':file.endsWith('.woff2')?'font/woff2':file.endsWith('.txt')?'text/plain':'text/javascript';
|
|
59
|
-
res.writeHead(200,{...headers,'content-type':type+'; charset=utf-8'});res.end(fs.readFileSync(path.isAbsolute(file)?file:path.join(import.meta.dirname,'web',file)));return true;
|
|
60
|
-
}
|
|
61
|
-
const write=req.method==='POST';
|
|
62
|
-
if(!['GET','POST'].includes(req.method))throw fail('method_not_allowed',405);
|
|
63
|
-
if(write&&req.headers.origin!==origin)throw fail('local_request_denied',403);
|
|
64
|
-
for(const [key,s]of sessions)if(s.expires<Date.now())sessions.delete(key);
|
|
65
|
-
if(url.pathname==='/app/api/session'&&write){
|
|
66
|
-
const input=await body(req),ticket=state.get('web.ticket');
|
|
67
|
-
if(typeof input.ticket!=='string'||input.ticket.length!==43||!ticket||ticket.expires<Date.now()||digest(input.ticket)!==ticket.hash)throw fail('open_from_command',401);
|
|
68
|
-
state.set('web.ticket',null);
|
|
69
|
-
const token=random(),s={csrf:random(),expires:Date.now()+8*3600000};
|
|
70
|
-
if(sessions.size>=8)sessions.delete(sessions.keys().next().value);
|
|
71
|
-
sessions.set(digest(token),s);json(200,{csrf:s.csrf},{'set-cookie':'cu-local='+token+'; Path=/app; HttpOnly; SameSite=Strict; Max-Age=28800'});return true;
|
|
72
|
-
}
|
|
73
|
-
const token=String(req.headers.cookie??'').split(';').map(x=>x.trim()).find(x=>x.startsWith('cu-local='))?.slice(9);
|
|
74
|
-
const session=token&&sessions.get(digest(token));if(!session)throw fail('open_from_command',401);
|
|
75
|
-
if(write&&req.headers['x-csrf-token']!==session.csrf)throw fail('session_expired',403);
|
|
76
|
-
const route=url.pathname.slice('/app/api/'.length);
|
|
77
|
-
if(!write&&route==='updates'){json(200,await checkUpdates());return true;}
|
|
78
|
-
if(!write&&route==='status'){
|
|
79
|
-
// A short-lived lease expiring does not mean the purchased service expired.
|
|
80
|
-
let licenseError=null;
|
|
81
|
-
if(state.get('license.activation'))try{await license.ensure();}catch(e){licenseError=e.message;}
|
|
82
|
-
const accounts=pool?.list()??null;
|
|
83
|
-
let connected=false;if(state.get('web.connected'))try{if(accounts)connected=accounts.rows.some(row=>row.active&&row.state==='ready');else{account();connected=true;}}catch{}
|
|
84
|
-
const usage=state.db.prepare('SELECT COUNT(*) total,COALESCE(SUM(status>=400),0) errors FROM desktop_usage').get();
|
|
85
|
-
const codex=codexConnection(state);if(codex.error&&!errors[codex.error])codex.error='operation_failed';
|
|
86
|
-
json(200,{version:VERSION,csrf:session.csrf,license:{...license.status(),...(licenseError?{error:errors[licenseError]?licenseError:'service_unavailable'}:{})},connected,codexConfigured:codex.configured,codex,desktop:desktop?.status()??{supported:false},accounts,usage,
|
|
87
|
-
recent:state.db.prepare('SELECT ts,status,duration_ms FROM desktop_usage ORDER BY id DESC LIMIT 12').all()});return true;
|
|
88
|
-
}
|
|
89
|
-
const input=write?await body(req):{};
|
|
90
|
-
if(route==='accounts'&&!write){if(!pool)throw fail('pool_runtime_unavailable',503);json(200,pool.list());return true;}
|
|
91
|
-
if(write&&(route.startsWith('accounts/')||route==='quota/refresh')){
|
|
92
|
-
if(!pool)throw fail('pool_runtime_unavailable',503);
|
|
93
|
-
let result;
|
|
94
|
-
if(route==='accounts/login'){
|
|
95
|
-
if(input.accepted!==true)throw fail('privacy_required');state.set('web.privacyAcceptedAt',Date.now());
|
|
96
|
-
result=await pool.startLogin({id:input.accountId,type:input.type,label:input.label});
|
|
97
|
-
}else if(route==='accounts/cancel')result=await pool.cancelLogin();
|
|
98
|
-
else if(route==='accounts/select'){if(!state.get('web.privacyAcceptedAt'))throw fail('privacy_required');result=await pool.select(input.accountId);}
|
|
99
|
-
else if(route==='accounts/remove')result=await pool.remove(input.accountId);
|
|
100
|
-
else if(route==='accounts/rename')result=pool.rename(input.accountId,input.label);
|
|
101
|
-
else if(route==='quota/refresh')result=await pool.refreshQuota(input.accountId);
|
|
102
|
-
else throw fail('route_not_found',404);
|
|
103
|
-
json(200,result);return true;
|
|
104
|
-
}
|
|
105
|
-
if(write&&route==='activate'){await license.activate(input.key);json(200,{ok:true});}
|
|
106
|
-
else if(write&&route==='renew'){await license.ensure('desktop',{online:true});json(200,{ok:true});}
|
|
107
|
-
else if(write&&route==='connect'){
|
|
108
|
-
if(input.accepted!==true)throw Object.assign(Error('privacy_required'),{status:400});
|
|
109
|
-
if(pool)await pool.credentials();else account();state.set('web.connected',true);state.set('web.privacyAcceptedAt',Date.now());json(200,{ok:true});
|
|
110
|
-
}
|
|
111
|
-
else if(write&&route==='disconnect'){state.set('web.connected',false);json(200,{ok:true});}
|
|
112
|
-
else if(write&&route==='sync'){if(!state.get('web.privacyAcceptedAt'))throw Object.assign(Error('privacy_required'),{status:400});await license.ensure();json(200,syncCodex(state));}
|
|
113
|
-
else if(write&&route==='unsync'){json(200,unsyncCodex(state));}
|
|
114
|
-
else if(write&&route==='desktop/startup'){
|
|
115
|
-
if(typeof input.enabled!=='boolean')throw fail('invalid_input');
|
|
116
|
-
if(!desktop)throw fail('startup_unavailable',409);
|
|
117
|
-
await desktop.setStartup(input.enabled);json(200,desktop.status());
|
|
118
|
-
}
|
|
119
|
-
else if(write&&route==='desktop/tray'){
|
|
120
|
-
if(!desktop)throw fail('tray_unavailable',409);
|
|
121
|
-
await desktop.showTray();json(200,desktop.status());
|
|
122
|
-
}
|
|
123
|
-
else if(write&&route==='stop'){
|
|
124
|
-
if(input.mode!=='exit'&&input.mode!=='update')throw fail('invalid_input');
|
|
125
|
-
if(input.mode==='exit')unsyncCodex(state);
|
|
126
|
-
json(200,{stopped:true});setImmediate(onStop);
|
|
127
|
-
}
|
|
128
|
-
else if((!write&&route==='models')||(write&&route==='chat')){
|
|
129
|
-
if(!state.get('web.connected'))throw fail('account_required',409);
|
|
130
|
-
const credentials=pool?await pool.credentials((write?input.accountId:url.searchParams.get('accountId'))??pool.active()):account();let payload;
|
|
131
|
-
if(write){
|
|
132
|
-
if(typeof input.model!=='string'||!input.model.length||input.model.length>160||!Array.isArray(input.messages)||!input.messages.length||input.messages.length>100||
|
|
133
|
-
input.messages.some(m=>!m||!['user','assistant'].includes(m.role)||typeof m.content!=='string'||!m.content.trim()||m.content.length>(m.role==='user'?32000:256000))||input.messages.at(-1).role!=='user')throw fail('invalid_message');
|
|
134
|
-
payload={model:input.model,stream:true,store:false,instructions:'Respond helpfully to the user.',input:input.messages.map(m=>({role:m.role,content:[{type:m.role==='assistant'?'output_text':'input_text',text:m.content}]}))};
|
|
135
|
-
}
|
|
136
|
-
const controller=new AbortController();res.on('close',()=>controller.abort());
|
|
137
|
-
const upstream=await request('http://127.0.0.1:'+server.address().port+(write?'/v1/responses':'/v1/models'),{
|
|
138
|
-
method:write?'POST':'GET',headers:{authorization:'Bearer '+credentials.token,'content-type':'application/json',...(pool?{'x-cu-control':state.get('control_token')}:{}) ,...(credentials.accountId?{'chatgpt-account-id':credentials.accountId}:{})},
|
|
139
|
-
body:write?JSON.stringify(payload):undefined,redirect:'error',signal:AbortSignal.any([controller.signal,AbortSignal.timeout(600000)])});
|
|
140
|
-
if(!upstream.ok){
|
|
141
|
-
let code;try{code=(await boundedJson(upstream,16384)).error?.code;}catch{}
|
|
142
|
-
const reason=['local_queue_full','local_queue_timeout','license_concurrency_limit'].includes(code)?'connection_busy':code==='request_proof_expired'?'request_clock_error':upstream.status===426?'connector_update_required':['request_body_rejected','request_proof_rejected','request_body_unverified'].includes(code)?'request_integrity_failed':upstream.status===401?'account_expired':upstream.status===429?'usage_limit':upstream.status===403?'activation_required':'service_unavailable';
|
|
143
|
-
throw fail(reason,upstream.status);
|
|
144
|
-
}
|
|
145
|
-
if(write){res.writeHead(200,{...headers,'content-type':'text/event-stream; charset=utf-8'});await pipeline(Readable.fromWeb(upstream.body),res,{signal:controller.signal});}
|
|
146
|
-
else{
|
|
147
|
-
const data=await boundedJson(upstream,2*1024*1024),list=data?.models??data?.data??[];
|
|
148
|
-
if(!Array.isArray(list))throw fail('models_unavailable',503);
|
|
149
|
-
json(200,{models:list.filter(m=>m&&typeof m==='object'&&m.visibility!=='hide').map(m=>({id:m.slug??m.id,name:m.display_name??m.slug??m.id})).filter(m=>typeof m.id==='string'&&m.id.length<=160&&typeof m.name==='string'&&m.name.length<=160).slice(0,256)});
|
|
150
|
-
}
|
|
151
|
-
}else throw fail('route_not_found',404);
|
|
152
|
-
}catch(error){
|
|
153
|
-
const configuration=/^\/app\/api\/(?:sync|unsync|stop)$/.test(url.pathname);
|
|
154
|
-
const code=configuration&&['EACCES','EPERM','EBUSY'].includes(error.code)?'config_permission_denied':configuration&&error.code==='ENOENT'?'config_missing':error.message;
|
|
155
|
-
const publicCode=errors[code]?code:['json_required','request_too_large','invalid_input','local_request_denied','method_not_allowed','route_not_found'].includes(code)?code:'operation_failed';
|
|
156
|
-
const badInput=['invalid_activation_key','license_invalid_or_expired','license_revoked','device_limit_reached','activation_required'].includes(publicCode);
|
|
157
|
-
if(res.headersSent)res.destroy();else json(error.status??(configuration?409:badInput?400:503),{error:publicCode},error.retryAfter?{'retry-after':String(error.retryAfter)}:{});
|
|
158
|
-
}
|
|
159
|
-
return true;
|
|
160
|
-
};
|
|
161
|
-
}
|
package/codex-account-client.mjs
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import {createRequire} from 'node:module';
|
|
4
|
-
import {spawn} from 'node:child_process';
|
|
5
|
-
import {VERSION} from './product.mjs';
|
|
6
|
-
|
|
7
|
-
export function codexExecutable(){
|
|
8
|
-
const require=createRequire(import.meta.url),name='@openai/codex-'+process.platform+'-'+process.arch;
|
|
9
|
-
const targets={linux:{x64:'x86_64-unknown-linux-musl',arm64:'aarch64-unknown-linux-musl'},darwin:{x64:'x86_64-apple-darwin',arm64:'aarch64-apple-darwin'},win32:{x64:'x86_64-pc-windows-msvc',arm64:'aarch64-pc-windows-msvc'}};
|
|
10
|
-
const target=targets[process.platform]?.[process.arch];if(!target)throw Error('pool_platform_unavailable');
|
|
11
|
-
try{
|
|
12
|
-
const resolve=createRequire(require.resolve('@openai/codex/package.json'));
|
|
13
|
-
const entry=path.join(path.dirname(resolve.resolve(name+'/package.json')),'vendor',target,'bin',process.platform==='win32'?'codex.exe':'codex');
|
|
14
|
-
if(!fs.existsSync(entry))throw Error();return entry;
|
|
15
|
-
}catch{throw Error('pool_runtime_missing');}
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export class CodexAccountClient{
|
|
19
|
-
constructor(home,{onNotification=()=>{},externalCredentials,spawnProcess=spawn,executable=codexExecutable(),timeout=30000}={}){
|
|
20
|
-
this.pending=new Map();this.nextId=0;this.closed=false;this.buffer='';this.timeout=timeout;this.onNotification=onNotification;
|
|
21
|
-
const env={...process.env,CODEX_HOME:home};for(const name of ['OPENAI_API_KEY','CODEX_API_KEY','OPENAI_BASE_URL','OPENAI_ORG_ID','OPENAI_ORGANIZATION','CODEX_API_BASE_URL','CHATGPT_BASE_URL'])delete env[name];
|
|
22
|
-
this.process=spawnProcess(executable,['-c','cli_auth_credentials_store="'+(externalCredentials?'ephemeral':'file')+'"','-c','forced_login_method="chatgpt"','app-server','--listen','stdio://'],{cwd:home,env,windowsHide:true,stdio:['pipe','pipe','pipe']});
|
|
23
|
-
this.exited=new Promise(resolve=>{this.process.once('close',()=>{this.fail();resolve();});this.process.once('error',()=>{this.fail();resolve();});});
|
|
24
|
-
this.process.stderr.resume();this.process.stdin.on('error',()=>this.fail());
|
|
25
|
-
this.process.stdout.setEncoding('utf8');this.process.stdout.on('data',chunk=>{
|
|
26
|
-
this.buffer+=chunk;if(this.buffer.length>2*1024*1024){this.fail();this.process.kill();return;}
|
|
27
|
-
let boundary;
|
|
28
|
-
while((boundary=this.buffer.indexOf('\n'))!==-1){
|
|
29
|
-
const line=this.buffer.slice(0,boundary);this.buffer=this.buffer.slice(boundary+1);if(!line.trim())continue;
|
|
30
|
-
let message;try{message=JSON.parse(line);}catch{this.fail();this.process.kill();return;}
|
|
31
|
-
if(message.id!==undefined&&!message.method){const waiting=this.pending.get(message.id);if(!waiting)continue;clearTimeout(waiting.timer);this.pending.delete(message.id);message.error?waiting.reject(Error('pool_provider_rejected')):waiting.resolve(message.result);}
|
|
32
|
-
else if(message.method&&message.id!==undefined){
|
|
33
|
-
if(message.method==='account/chatgptAuthTokens/refresh'&&externalCredentials){Promise.resolve().then(externalCredentials).then(credentials=>this.write({id:message.id,result:{accessToken:credentials.token,chatgptAccountId:credentials.accountId}})).catch(()=>this.write({id:message.id,error:{code:-32000,message:'account_refresh_required'}}));}
|
|
34
|
-
else this.write({id:message.id,error:{code:-32601,message:'unsupported_request'}});
|
|
35
|
-
}else if(message.method)Promise.resolve().then(()=>this.onNotification(message.method,message.params)).catch(()=>{});
|
|
36
|
-
}
|
|
37
|
-
});
|
|
38
|
-
this.ready=this.call('initialize',{clientInfo:{name:'codex_unlimit_accounts',title:'Codex Unlimit',version:VERSION},capabilities:{experimentalApi:!!externalCredentials}}).then(()=>this.write({method:'initialized',params:{}}));
|
|
39
|
-
this.ready.catch(()=>{});
|
|
40
|
-
}
|
|
41
|
-
write(message){if(!this.closed&&!this.process.stdin.destroyed)this.process.stdin.write(JSON.stringify(message)+'\n');}
|
|
42
|
-
call(method,params={}){
|
|
43
|
-
if(this.closed)return Promise.reject(Error('pool_runtime_unavailable'));
|
|
44
|
-
return new Promise((resolve,reject)=>{const id=++this.nextId,timer=setTimeout(()=>{this.pending.delete(id);reject(Error('pool_request_timeout'));},this.timeout);timer.unref();this.pending.set(id,{resolve,reject,timer});this.write({id,method,params});});
|
|
45
|
-
}
|
|
46
|
-
async request(method,params={}){await this.ready;return this.call(method,params);}
|
|
47
|
-
fail(){this.closed=true;for(const waiting of this.pending.values()){clearTimeout(waiting.timer);waiting.reject(Error('pool_runtime_unavailable'));}this.pending.clear();}
|
|
48
|
-
async close(){
|
|
49
|
-
if(!this.closed)this.process.stdin.end();
|
|
50
|
-
const timeout=setTimeout(()=>this.process.kill(),1500);timeout.unref();
|
|
51
|
-
await this.exited;clearTimeout(timeout);this.fail();
|
|
52
|
-
}
|
|
53
|
-
}
|
package/config.mjs
DELETED
|
@@ -1,224 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import crypto from 'node:crypto';
|
|
4
|
-
|
|
5
|
-
const BEGIN='# BEGIN Codexunlimit Web managed configuration';
|
|
6
|
-
const END='# END Codexunlimit Web managed configuration';
|
|
7
|
-
const PREVIOUS='# Codexunlimit Web saved root line: ';
|
|
8
|
-
const PROVIDER='codexunlimit';
|
|
9
|
-
const PROVIDER_BEGIN='# BEGIN Codexunlimit Web HTTP provider';
|
|
10
|
-
const PROVIDER_END='# END Codexunlimit Web HTTP provider';
|
|
11
|
-
const ROOT_KEYS=new Set(['openai_base_url','model_provider','model_catalog_json']);
|
|
12
|
-
const digest=value=>crypto.createHash('sha256').update(value).digest('hex');
|
|
13
|
-
|
|
14
|
-
function rootKey(line){
|
|
15
|
-
const m=/^\s*(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+))\s*=/.exec(line);
|
|
16
|
-
return m?.[1]??m?.[2]??m?.[3];
|
|
17
|
-
}
|
|
18
|
-
function splitRoot(text){
|
|
19
|
-
const lines=text.split('\n');let end=lines.length;
|
|
20
|
-
for(let i=0;i<lines.length;i++){
|
|
21
|
-
const line=lines[i].trim();
|
|
22
|
-
if(line.startsWith('#'))continue;
|
|
23
|
-
if(line.startsWith('[')){end=i;break;}
|
|
24
|
-
// Cấu hình root nhiều dòng cần được người dùng kiểm tra; không sửa bằng suy đoán.
|
|
25
|
-
if(line.includes('"""')||line.includes("'''"))throw Error('multiline_root_config_requires_manual_setup');
|
|
26
|
-
}
|
|
27
|
-
return {lines,end};
|
|
28
|
-
}
|
|
29
|
-
function layout(text){
|
|
30
|
-
const bom=text.startsWith('\uFEFF')?'\uFEFF':'',crlf=text.includes('\r\n');
|
|
31
|
-
const {lines,end}=splitRoot(text.slice(bom.length).replace(/\r\n/g,'\n'));
|
|
32
|
-
const fields=new Map();
|
|
33
|
-
for(let i=0;i<end;i++){
|
|
34
|
-
const key=rootKey(lines[i]);
|
|
35
|
-
if(!ROOT_KEYS.has(key)&&key!=='profile')continue;
|
|
36
|
-
if(fields.has(key))throw Error('duplicate_provider_settings_review_required');
|
|
37
|
-
const value=lines[i].slice(lines[i].indexOf('=')+1).trim();
|
|
38
|
-
const match=/^(?:"([^"\\]*)"|'([^']*)')\s*(?:#.*)?$/.exec(value);
|
|
39
|
-
fields.set(key,{line:i,value:match?.[1]??match?.[2]});
|
|
40
|
-
}
|
|
41
|
-
return {bom,crlf,lines,end,fields,join:lines=>bom+(crlf?lines.join('\n').replace(/\n/g,'\r\n'):lines.join('\n'))};
|
|
42
|
-
}
|
|
43
|
-
function providerHeader(line){
|
|
44
|
-
return /^\[\s*(?:model_providers|"model_providers"|'model_providers')\s*\.\s*(?:codexunlimit|"codexunlimit"|'codexunlimit')\s*\]\s*(?:#.*)?$/.test(line.trim());
|
|
45
|
-
}
|
|
46
|
-
function httpProvider(baseUrl){
|
|
47
|
-
return [PROVIDER_BEGIN,'[model_providers.'+PROVIDER+']','name = "Codex Unlimit"','base_url = '+JSON.stringify(baseUrl),
|
|
48
|
-
'wire_api = "responses"','requires_openai_auth = true','supports_websockets = false',PROVIDER_END];
|
|
49
|
-
}
|
|
50
|
-
function stripHttpProvider(text,baseUrl){
|
|
51
|
-
const {lines,join}=layout(text),headers=lines.flatMap((line,i)=>providerHeader(line)?[i]:[]);
|
|
52
|
-
const starts=lines.flatMap((line,i)=>line.trim()===PROVIDER_BEGIN?[i]:[]),ends=lines.flatMap((line,i)=>line.trim()===PROVIDER_END?[i]:[]);
|
|
53
|
-
if(!headers.length){if(starts.length||ends.length)throw Error('managed_configuration_changed_review_required');return {text,found:false};}
|
|
54
|
-
if(headers.length!==1||starts.length!==ends.length||starts.length>1)throw Error('managed_configuration_changed_review_required');
|
|
55
|
-
const first=headers[0];let end=first+1;while(end<lines.length&&!lines[end].trimStart().startsWith('['))end++;
|
|
56
|
-
const marked=starts.length===1;
|
|
57
|
-
if(marked&&(starts[0]!==first-1||ends[0]<=first||ends[0]>=end))throw Error('managed_configuration_changed_review_required');
|
|
58
|
-
const expected=new Map([['name','Codex Unlimit'],['base_url',baseUrl],['wire_api','responses'],['requires_openai_auth',true],['supports_websockets',false]]),found=new Map();
|
|
59
|
-
for(const line of lines.slice(first+1,marked?ends[0]:end)){
|
|
60
|
-
if(!line.trim()||line.trimStart().startsWith('#'))continue;
|
|
61
|
-
const key=rootKey(line),value=line.slice(line.indexOf('=')+1).trim(),literal=/^(?:"([^"\\]*)"|'([^']*)'|(true|false))\s*(?:#.*)?$/.exec(value);
|
|
62
|
-
const parsed=literal?.[3]?literal[3]==='true':literal?.[1]??literal?.[2];
|
|
63
|
-
if(!literal||!expected.has(key)||found.has(key)||parsed!==expected.get(key))throw Error('managed_configuration_changed_review_required');
|
|
64
|
-
found.set(key,parsed);
|
|
65
|
-
}
|
|
66
|
-
if(found.size!==expected.size)throw Error('managed_configuration_changed_review_required');
|
|
67
|
-
return {text:join(lines.filter((_,i)=>i<(marked?starts[0]:first)||i>=(marked?ends[0]+1:end))),found:true};
|
|
68
|
-
}
|
|
69
|
-
function hasProviderConflict(text){
|
|
70
|
-
return layout(text).lines.some(line=>providerHeader(line)||rootKey(line)==='model_providers'||/^\s*(?:model_providers|"model_providers"|'model_providers')\s*\.\s*(?:codexunlimit|"codexunlimit"|'codexunlimit')\s*[.=]/.test(line));
|
|
71
|
-
}
|
|
72
|
-
function directRouting(text){
|
|
73
|
-
const {fields}=layout(text);
|
|
74
|
-
return !fields.has('profile')&&!fields.has('openai_base_url')&&!fields.has('model_catalog_json')&&
|
|
75
|
-
(!fields.has('model_provider')||fields.get('model_provider').value==='openai');
|
|
76
|
-
}
|
|
77
|
-
function directConfiguration(text,previous){
|
|
78
|
-
if(!directRouting(text))return null;
|
|
79
|
-
text=stripHttpProvider(text,previous.baseUrl).text;
|
|
80
|
-
if(![BEGIN,END,PREVIOUS].some(marker=>text.includes(marker)))return text;
|
|
81
|
-
const {lines,end,join}=layout(text),source=layout(verifiedOriginal(previous));
|
|
82
|
-
const saved=source.lines.slice(0,source.end).filter(line=>ROOT_KEYS.has(rootKey(line)));
|
|
83
|
-
const begin=lines.filter(line=>line.trim()===BEGIN).length,finish=lines.filter(line=>line.trim()===END).length;
|
|
84
|
-
if(begin!==finish||begin>1)throw Error('managed_configuration_changed_review_required');
|
|
85
|
-
const filtered=lines.filter((line,i)=>{
|
|
86
|
-
const stripped=line.trimStart();
|
|
87
|
-
if(![BEGIN,END].includes(line.trim())&&!stripped.startsWith(PREVIOUS))return true;
|
|
88
|
-
if(i>=end)throw Error('managed_configuration_changed_review_required');
|
|
89
|
-
if(stripped.startsWith(PREVIOUS)&&!saved.some(value=>PREVIOUS+Buffer.from(value).toString('base64')===stripped))
|
|
90
|
-
throw Error('saved_root_line_corrupt');
|
|
91
|
-
return false;
|
|
92
|
-
});
|
|
93
|
-
// Reset có thể để lại comment của relay; chỉ bỏ metadata, không phục hồi provider cũ.
|
|
94
|
-
return join(filtered);
|
|
95
|
-
}
|
|
96
|
-
function verifiedOriginal(previous){
|
|
97
|
-
try{
|
|
98
|
-
if(fs.lstatSync(previous.backup).isSymbolicLink())throw Error();
|
|
99
|
-
const original=fs.readFileSync(previous.backup,'utf8');
|
|
100
|
-
if(digest(original)!==previous.original_hash)throw Error();
|
|
101
|
-
return original;
|
|
102
|
-
}catch{throw Error('config_backup_missing_or_changed_review_required');}
|
|
103
|
-
}
|
|
104
|
-
export function managedConfig(original,baseUrl){
|
|
105
|
-
if(original.includes('# BEGIN TDH Relay')||original.includes('# BEGIN Codexunlimit managed'))throw Error('existing_connector_review_required');
|
|
106
|
-
if(original.includes(BEGIN)||original.includes(END)||original.includes(PREVIOUS))throw Error('managed_configuration_already_present');
|
|
107
|
-
if(hasProviderConflict(original))throw Error('managed_provider_conflict');
|
|
108
|
-
const bom=original.startsWith('\uFEFF')?'\uFEFF':'',crlf=original.includes('\r\n');
|
|
109
|
-
const {lines,end}=splitRoot(original.slice(bom.length).replace(/\r\n/g,'\n'));
|
|
110
|
-
const root=lines.slice(0,end).map(line=>ROOT_KEYS.has(rootKey(line))?PREVIOUS+Buffer.from(line).toString('base64'):line);
|
|
111
|
-
const block=[BEGIN,'openai_base_url = '+JSON.stringify(baseUrl),'model_provider = "'+PROVIDER+'"',END].join('\n');
|
|
112
|
-
let result=[block,...root,...httpProvider(baseUrl),...lines.slice(end)].join('\n');
|
|
113
|
-
if(crlf)result=result.replace(/\n/g,'\r\n');
|
|
114
|
-
return bom+result;
|
|
115
|
-
}
|
|
116
|
-
export function restoreConfig(current,baseUrl,{original}={}){
|
|
117
|
-
const stripped=stripHttpProvider(current,baseUrl);
|
|
118
|
-
const {lines,end,fields,join}=layout(stripped.text);
|
|
119
|
-
const begins=[],ends=[];
|
|
120
|
-
lines.forEach((line,i)=>{if(line.trim()===BEGIN)begins.push(i);if(line.trim()===END)ends.push(i);});
|
|
121
|
-
const marked=begins.length===1&&ends.length===1&&begins[0]<ends[0]&&ends[0]<end;
|
|
122
|
-
const unmarked=begins.length===0&&ends.length===0&&original!==undefined;
|
|
123
|
-
if(!marked&&!unmarked||fields.get('openai_base_url')?.value!==baseUrl||!['openai',PROVIDER].includes(fields.get('model_provider')?.value)||fields.get('model_provider')?.value===PROVIDER&&!stripped.found)
|
|
124
|
-
throw Error('managed_configuration_changed_review_required');
|
|
125
|
-
if(fields.has('model_catalog_json'))throw Error('new_provider_settings_review_required');
|
|
126
|
-
const source=original===undefined?null:layout(original);
|
|
127
|
-
const expected=source?.lines.slice(0,source.end).filter(line=>ROOT_KEYS.has(rootKey(line)))??null;
|
|
128
|
-
const restored=new Set(),result=[];
|
|
129
|
-
for(let i=0;i<lines.length;i++){
|
|
130
|
-
const line=lines[i];
|
|
131
|
-
if(begins.includes(i)||ends.includes(i)||i<end&&ROOT_KEYS.has(rootKey(line)))continue;
|
|
132
|
-
if(!line.trimStart().startsWith(PREVIOUS)){result.push(line);continue;}
|
|
133
|
-
if(i>=end)throw Error('saved_root_line_moved');
|
|
134
|
-
const encoded=line.trimStart().slice(PREVIOUS.length);
|
|
135
|
-
if(!/^[A-Za-z0-9+/]*={0,2}$/.test(encoded))throw Error('saved_root_line_corrupt');
|
|
136
|
-
const decoded=Buffer.from(encoded,'base64').toString('utf8');
|
|
137
|
-
const key=rootKey(decoded);
|
|
138
|
-
if(!ROOT_KEYS.has(key)||/[\r\n]/.test(decoded)||restored.has(key)||expected&&!expected.includes(decoded))throw Error('saved_root_line_corrupt');
|
|
139
|
-
restored.add(key);result.push(decoded);
|
|
140
|
-
}
|
|
141
|
-
// Nếu trình ghi TOML đã bỏ comment, lấy đúng root cũ từ backup đã kiểm tra hash.
|
|
142
|
-
if(expected)result.unshift(...expected.filter(line=>!restored.has(rootKey(line))));
|
|
143
|
-
return join(result);
|
|
144
|
-
}
|
|
145
|
-
function atomicWrite(file,content){
|
|
146
|
-
const temporary=file+'.cu-'+crypto.randomBytes(6).toString('hex');
|
|
147
|
-
fs.writeFileSync(temporary,content,{mode:0o600,flag:'wx'});
|
|
148
|
-
try{fs.renameSync(temporary,file);}catch(error){fs.unlinkSync(temporary);throw error;}
|
|
149
|
-
}
|
|
150
|
-
function backupConfig(state,original,label='codex-config'){
|
|
151
|
-
const backupDir=path.join(state.home,'backups');fs.mkdirSync(backupDir,{recursive:true,mode:0o700});
|
|
152
|
-
const backup=path.join(backupDir,label+'-'+Date.now()+'-'+crypto.randomBytes(4).toString('hex')+'.toml');
|
|
153
|
-
fs.writeFileSync(backup,original,{mode:0o600,flag:'wx'});return backup;
|
|
154
|
-
}
|
|
155
|
-
export function syncCodex(state){
|
|
156
|
-
const codexHome=state.get('codex_home');
|
|
157
|
-
if(!codexHome)throw Error('codex_home_missing');
|
|
158
|
-
fs.mkdirSync(codexHome,{recursive:true,mode:0o700});
|
|
159
|
-
const file=path.join(codexHome,'config.toml');
|
|
160
|
-
if(fs.existsSync(file)&&fs.lstatSync(file).isSymbolicLink())throw Error('config_symlink_requires_manual_setup');
|
|
161
|
-
const original=fs.existsSync(file)?fs.readFileSync(file,'utf8'):'';
|
|
162
|
-
const baseUrl='http://127.0.0.1:'+state.settings.get('relay.port')+'/v1';
|
|
163
|
-
const previous=state.get('codex_sync');
|
|
164
|
-
if(previous&&(path.resolve(previous.file)!==path.resolve(file)||previous.baseUrl!==baseUrl))throw Error('config_target_changed_review_required');
|
|
165
|
-
if(layout(original).fields.has('profile'))throw Error('active_codex_profile_requires_manual_setup');
|
|
166
|
-
const direct=previous?directConfiguration(original,previous):null;
|
|
167
|
-
if(previous&&direct===null){
|
|
168
|
-
const restored=restoreConfig(original,baseUrl,{original:verifiedOriginal(previous)});
|
|
169
|
-
const next=managedConfig(restored,baseUrl);
|
|
170
|
-
if(next===original)return {changed:false,file};
|
|
171
|
-
const backup=backupConfig(state,original,'codex-config-before-repair');
|
|
172
|
-
atomicWrite(file,next);
|
|
173
|
-
state.set('codex_sync',{...previous,managed_hash:digest(next),repair_backup:backup});
|
|
174
|
-
return {changed:true,file,backup,recovery:'equivalent_configuration'};
|
|
175
|
-
}
|
|
176
|
-
const baseline=direct??original;
|
|
177
|
-
const repairBackup=baseline!==original?backupConfig(state,original,'codex-config-before-repair'):null;
|
|
178
|
-
const next=managedConfig(baseline,baseUrl);
|
|
179
|
-
const backup=backupConfig(state,baseline);
|
|
180
|
-
state.set('codex_sync',{file,baseUrl,backup,original_hash:digest(baseline),managed_hash:digest(next),...(repairBackup?{repair_backup:repairBackup}:{})});
|
|
181
|
-
try{atomicWrite(file,next);}catch(error){state.set('codex_sync',previous);throw error;}
|
|
182
|
-
return {changed:true,file,backup,...(previous?{recovery:'direct_configuration'}:{})};
|
|
183
|
-
}
|
|
184
|
-
export function unsyncCodex(state){
|
|
185
|
-
const previous=state.get('codex_sync');
|
|
186
|
-
if(!previous)return {changed:false};
|
|
187
|
-
if(fs.lstatSync(previous.file).isSymbolicLink())throw Error('config_symlink_requires_manual_setup');
|
|
188
|
-
const current=fs.readFileSync(previous.file,'utf8');
|
|
189
|
-
const direct=directConfiguration(current,previous);
|
|
190
|
-
if(direct!==null){
|
|
191
|
-
if(direct!==current){backupConfig(state,current,'codex-config-before-repair');atomicWrite(previous.file,direct);}
|
|
192
|
-
state.set('codex_sync',null);return {changed:direct!==current,file:previous.file,recovery:'already_direct_configuration'};
|
|
193
|
-
}
|
|
194
|
-
atomicWrite(previous.file,restoreConfig(current,previous.baseUrl,{original:verifiedOriginal(previous)}));
|
|
195
|
-
state.set('codex_sync',null);
|
|
196
|
-
return {changed:true,file:previous.file};
|
|
197
|
-
}
|
|
198
|
-
export function codexConnection(state){
|
|
199
|
-
const previous=state.get('codex_sync');
|
|
200
|
-
try{
|
|
201
|
-
const codexHome=state.get('codex_home');if(!codexHome)throw Error('codex_home_missing');
|
|
202
|
-
const file=path.join(codexHome,'config.toml');
|
|
203
|
-
if(fs.existsSync(file)&&fs.lstatSync(file).isSymbolicLink())throw Error('config_symlink_requires_manual_setup');
|
|
204
|
-
const current=fs.existsSync(file)?fs.readFileSync(file,'utf8'):'';
|
|
205
|
-
const baseUrl='http://127.0.0.1:'+state.settings.get('relay.port')+'/v1';
|
|
206
|
-
if(layout(current).fields.has('profile'))throw Error('active_codex_profile_requires_manual_setup');
|
|
207
|
-
if(previous){
|
|
208
|
-
if(path.resolve(previous.file)!==path.resolve(file)||previous.baseUrl!==baseUrl)throw Error('config_target_changed_review_required');
|
|
209
|
-
if(directConfiguration(current,previous)!==null)return {configured:false,managed:true,repairable:true};
|
|
210
|
-
restoreConfig(current,baseUrl,{original:verifiedOriginal(previous)});
|
|
211
|
-
return {configured:true,managed:true,repairable:true};
|
|
212
|
-
}
|
|
213
|
-
managedConfig(current,baseUrl);
|
|
214
|
-
return {configured:false,managed:false,repairable:true};
|
|
215
|
-
}catch(error){
|
|
216
|
-
const code=['EACCES','EPERM','EBUSY'].includes(error.code)?'config_permission_denied':error.code==='ENOENT'?'config_missing':error.message;
|
|
217
|
-
return {configured:false,managed:!!previous,repairable:false,error:code};
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
export function migrateCodexTransport(state){
|
|
222
|
-
if(!state.get('codex_sync')||!codexConnection(state).configured)return {changed:false};
|
|
223
|
-
return syncCodex(state);
|
|
224
|
-
}
|