codexunlimit 0.0.0-stage → 0.1.0-web.10
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 +94 -2
- package/account-pool.mjs +161 -0
- package/app.mjs +158 -0
- package/cli.mjs +108 -0
- package/codex-account-client.mjs +53 -0
- package/config.mjs +184 -0
- package/device.ps1 +22 -0
- package/doctor.mjs +20 -0
- package/image-http.mjs +22 -0
- package/io.mjs +5 -0
- package/license-client.mjs +150 -0
- package/package.json +14 -6
- package/product.mjs +5 -0
- package/protocol.mjs +28 -0
- package/quota.mjs +34 -0
- package/relay.mjs +83 -0
- package/state.mjs +23 -0
- package/updates.mjs +47 -0
- package/web/app.js +154 -0
- package/web/assets/apple-touch-icon.png +0 -0
- package/web/assets/favicon-16.png +0 -0
- package/web/assets/favicon-32.png +0 -0
- package/web/assets/favicon.ico +0 -0
- package/web/assets/fonts.css +48 -0
- package/web/assets/geist-LICENSE.txt +93 -0
- package/web/assets/geist-latin-ext-wght-normal.woff2 +0 -0
- package/web/assets/geist-latin-wght-normal.woff2 +0 -0
- package/web/assets/geist-vietnamese-wght-normal.woff2 +0 -0
- package/web/assets/icon-192.png +0 -0
- package/web/assets/icon-512.png +0 -0
- package/web/assets/mark.svg +1 -0
- package/web/assets/newsreader-LICENSE.txt +93 -0
- package/web/assets/newsreader-latin-ext-wght-normal.woff2 +0 -0
- package/web/assets/newsreader-latin-wght-normal.woff2 +0 -0
- package/web/assets/newsreader-vietnamese-wght-normal.woff2 +0 -0
- package/web/index.html +3 -0
- package/web/markdown.js +15 -0
- package/web/messages.js +63 -0
- package/web/pool-ui.js +53 -0
- package/web/stream.js +25 -0
- package/web/style.css +53 -0
- package/web/updates.js +26 -0
- package/windows.mjs +53 -0
- package/windows.ps1 +103 -0
package/config.mjs
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
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 ROOT_KEYS=new Set(['openai_base_url','model_provider','model_catalog_json']);
|
|
9
|
+
const digest=value=>crypto.createHash('sha256').update(value).digest('hex');
|
|
10
|
+
|
|
11
|
+
function rootKey(line){
|
|
12
|
+
const m=/^\s*(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+))\s*=/.exec(line);
|
|
13
|
+
return m?.[1]??m?.[2]??m?.[3];
|
|
14
|
+
}
|
|
15
|
+
function splitRoot(text){
|
|
16
|
+
const lines=text.split('\n');let end=lines.length;
|
|
17
|
+
for(let i=0;i<lines.length;i++){
|
|
18
|
+
const line=lines[i].trim();
|
|
19
|
+
if(line.startsWith('#'))continue;
|
|
20
|
+
if(line.startsWith('[')){end=i;break;}
|
|
21
|
+
// 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.
|
|
22
|
+
if(line.includes('"""')||line.includes("'''"))throw Error('multiline_root_config_requires_manual_setup');
|
|
23
|
+
}
|
|
24
|
+
return {lines,end};
|
|
25
|
+
}
|
|
26
|
+
function layout(text){
|
|
27
|
+
const bom=text.startsWith('\uFEFF')?'\uFEFF':'',crlf=text.includes('\r\n');
|
|
28
|
+
const {lines,end}=splitRoot(text.slice(bom.length).replace(/\r\n/g,'\n'));
|
|
29
|
+
const fields=new Map();
|
|
30
|
+
for(let i=0;i<end;i++){
|
|
31
|
+
const key=rootKey(lines[i]);
|
|
32
|
+
if(!ROOT_KEYS.has(key)&&key!=='profile')continue;
|
|
33
|
+
if(fields.has(key))throw Error('duplicate_provider_settings_review_required');
|
|
34
|
+
const value=lines[i].slice(lines[i].indexOf('=')+1).trim();
|
|
35
|
+
const match=/^(?:"([^"\\]*)"|'([^']*)')\s*(?:#.*)?$/.exec(value);
|
|
36
|
+
fields.set(key,{line:i,value:match?.[1]??match?.[2]});
|
|
37
|
+
}
|
|
38
|
+
return {bom,crlf,lines,end,fields,join:lines=>bom+(crlf?lines.join('\n').replace(/\n/g,'\r\n'):lines.join('\n'))};
|
|
39
|
+
}
|
|
40
|
+
function directRouting(text){
|
|
41
|
+
const {fields}=layout(text);
|
|
42
|
+
return !fields.has('profile')&&!fields.has('openai_base_url')&&!fields.has('model_catalog_json')&&
|
|
43
|
+
(!fields.has('model_provider')||fields.get('model_provider').value==='openai');
|
|
44
|
+
}
|
|
45
|
+
function directConfiguration(text,previous){
|
|
46
|
+
if(!directRouting(text))return null;
|
|
47
|
+
if(![BEGIN,END,PREVIOUS].some(marker=>text.includes(marker)))return text;
|
|
48
|
+
const {lines,end,join}=layout(text),source=layout(verifiedOriginal(previous));
|
|
49
|
+
const saved=source.lines.slice(0,source.end).filter(line=>ROOT_KEYS.has(rootKey(line)));
|
|
50
|
+
const begin=lines.filter(line=>line.trim()===BEGIN).length,finish=lines.filter(line=>line.trim()===END).length;
|
|
51
|
+
if(begin!==finish||begin>1)throw Error('managed_configuration_changed_review_required');
|
|
52
|
+
const filtered=lines.filter((line,i)=>{
|
|
53
|
+
const stripped=line.trimStart();
|
|
54
|
+
if(![BEGIN,END].includes(line.trim())&&!stripped.startsWith(PREVIOUS))return true;
|
|
55
|
+
if(i>=end)throw Error('managed_configuration_changed_review_required');
|
|
56
|
+
if(stripped.startsWith(PREVIOUS)&&!saved.some(value=>PREVIOUS+Buffer.from(value).toString('base64')===stripped))
|
|
57
|
+
throw Error('saved_root_line_corrupt');
|
|
58
|
+
return false;
|
|
59
|
+
});
|
|
60
|
+
// Reset có thể để lại comment của relay; chỉ bỏ metadata, không phục hồi provider cũ.
|
|
61
|
+
return join(filtered);
|
|
62
|
+
}
|
|
63
|
+
function verifiedOriginal(previous){
|
|
64
|
+
try{
|
|
65
|
+
if(fs.lstatSync(previous.backup).isSymbolicLink())throw Error();
|
|
66
|
+
const original=fs.readFileSync(previous.backup,'utf8');
|
|
67
|
+
if(digest(original)!==previous.original_hash)throw Error();
|
|
68
|
+
return original;
|
|
69
|
+
}catch{throw Error('config_backup_missing_or_changed_review_required');}
|
|
70
|
+
}
|
|
71
|
+
export function managedConfig(original,baseUrl){
|
|
72
|
+
if(original.includes('# BEGIN TDH Relay')||original.includes('# BEGIN Codexunlimit managed'))throw Error('existing_connector_review_required');
|
|
73
|
+
if(original.includes(BEGIN)||original.includes(END)||original.includes(PREVIOUS))throw Error('managed_configuration_already_present');
|
|
74
|
+
const bom=original.startsWith('\uFEFF')?'\uFEFF':'',crlf=original.includes('\r\n');
|
|
75
|
+
const {lines,end}=splitRoot(original.slice(bom.length).replace(/\r\n/g,'\n'));
|
|
76
|
+
const root=lines.slice(0,end).map(line=>ROOT_KEYS.has(rootKey(line))?PREVIOUS+Buffer.from(line).toString('base64'):line);
|
|
77
|
+
const block=[BEGIN,'openai_base_url = '+JSON.stringify(baseUrl),'model_provider = "openai"',END].join('\n');
|
|
78
|
+
let result=[block,...root,...lines.slice(end)].join('\n');
|
|
79
|
+
if(crlf)result=result.replace(/\n/g,'\r\n');
|
|
80
|
+
return bom+result;
|
|
81
|
+
}
|
|
82
|
+
export function restoreConfig(current,baseUrl,{original}={}){
|
|
83
|
+
const {lines,end,fields,join}=layout(current);
|
|
84
|
+
const begins=[],ends=[];
|
|
85
|
+
lines.forEach((line,i)=>{if(line.trim()===BEGIN)begins.push(i);if(line.trim()===END)ends.push(i);});
|
|
86
|
+
const marked=begins.length===1&&ends.length===1&&begins[0]<ends[0]&&ends[0]<end;
|
|
87
|
+
const unmarked=begins.length===0&&ends.length===0&&original!==undefined;
|
|
88
|
+
if(!marked&&!unmarked||fields.get('openai_base_url')?.value!==baseUrl||fields.get('model_provider')?.value!=='openai')
|
|
89
|
+
throw Error('managed_configuration_changed_review_required');
|
|
90
|
+
if(fields.has('model_catalog_json'))throw Error('new_provider_settings_review_required');
|
|
91
|
+
const source=original===undefined?null:layout(original);
|
|
92
|
+
const expected=source?.lines.slice(0,source.end).filter(line=>ROOT_KEYS.has(rootKey(line)))??null;
|
|
93
|
+
const restored=new Set(),result=[];
|
|
94
|
+
for(let i=0;i<lines.length;i++){
|
|
95
|
+
const line=lines[i];
|
|
96
|
+
if(begins.includes(i)||ends.includes(i)||i<end&&ROOT_KEYS.has(rootKey(line)))continue;
|
|
97
|
+
if(!line.trimStart().startsWith(PREVIOUS)){result.push(line);continue;}
|
|
98
|
+
if(i>=end)throw Error('saved_root_line_moved');
|
|
99
|
+
const encoded=line.trimStart().slice(PREVIOUS.length);
|
|
100
|
+
if(!/^[A-Za-z0-9+/]*={0,2}$/.test(encoded))throw Error('saved_root_line_corrupt');
|
|
101
|
+
const decoded=Buffer.from(encoded,'base64').toString('utf8');
|
|
102
|
+
const key=rootKey(decoded);
|
|
103
|
+
if(!ROOT_KEYS.has(key)||/[\r\n]/.test(decoded)||restored.has(key)||expected&&!expected.includes(decoded))throw Error('saved_root_line_corrupt');
|
|
104
|
+
restored.add(key);result.push(decoded);
|
|
105
|
+
}
|
|
106
|
+
// Nếu trình ghi TOML đã bỏ comment, lấy đúng root cũ từ backup đã kiểm tra hash.
|
|
107
|
+
if(expected)result.unshift(...expected.filter(line=>!restored.has(rootKey(line))));
|
|
108
|
+
return join(result);
|
|
109
|
+
}
|
|
110
|
+
function atomicWrite(file,content){
|
|
111
|
+
const temporary=file+'.cu-'+crypto.randomBytes(6).toString('hex');
|
|
112
|
+
fs.writeFileSync(temporary,content,{mode:0o600,flag:'wx'});
|
|
113
|
+
try{fs.renameSync(temporary,file);}catch(error){fs.unlinkSync(temporary);throw error;}
|
|
114
|
+
}
|
|
115
|
+
function backupConfig(state,original,label='codex-config'){
|
|
116
|
+
const backupDir=path.join(state.home,'backups');fs.mkdirSync(backupDir,{recursive:true,mode:0o700});
|
|
117
|
+
const backup=path.join(backupDir,label+'-'+Date.now()+'-'+crypto.randomBytes(4).toString('hex')+'.toml');
|
|
118
|
+
fs.writeFileSync(backup,original,{mode:0o600,flag:'wx'});return backup;
|
|
119
|
+
}
|
|
120
|
+
export function syncCodex(state){
|
|
121
|
+
const codexHome=state.get('codex_home');
|
|
122
|
+
if(!codexHome)throw Error('codex_home_missing');
|
|
123
|
+
fs.mkdirSync(codexHome,{recursive:true,mode:0o700});
|
|
124
|
+
const file=path.join(codexHome,'config.toml');
|
|
125
|
+
if(fs.existsSync(file)&&fs.lstatSync(file).isSymbolicLink())throw Error('config_symlink_requires_manual_setup');
|
|
126
|
+
const original=fs.existsSync(file)?fs.readFileSync(file,'utf8'):'';
|
|
127
|
+
const baseUrl='http://127.0.0.1:'+state.settings.get('relay.port')+'/v1';
|
|
128
|
+
const previous=state.get('codex_sync');
|
|
129
|
+
if(previous&&(path.resolve(previous.file)!==path.resolve(file)||previous.baseUrl!==baseUrl))throw Error('config_target_changed_review_required');
|
|
130
|
+
if(layout(original).fields.has('profile'))throw Error('active_codex_profile_requires_manual_setup');
|
|
131
|
+
const direct=previous?directConfiguration(original,previous):null;
|
|
132
|
+
if(previous&&direct===null){
|
|
133
|
+
const restored=restoreConfig(original,baseUrl,{original:verifiedOriginal(previous)});
|
|
134
|
+
const next=managedConfig(restored,baseUrl);
|
|
135
|
+
if(next===original)return {changed:false,file};
|
|
136
|
+
const backup=backupConfig(state,original,'codex-config-before-repair');
|
|
137
|
+
atomicWrite(file,next);
|
|
138
|
+
state.set('codex_sync',{...previous,managed_hash:digest(next),repair_backup:backup});
|
|
139
|
+
return {changed:true,file,backup,recovery:'equivalent_configuration'};
|
|
140
|
+
}
|
|
141
|
+
const baseline=direct??original;
|
|
142
|
+
const repairBackup=baseline!==original?backupConfig(state,original,'codex-config-before-repair'):null;
|
|
143
|
+
const next=managedConfig(baseline,baseUrl);
|
|
144
|
+
const backup=backupConfig(state,baseline);
|
|
145
|
+
state.set('codex_sync',{file,baseUrl,backup,original_hash:digest(baseline),managed_hash:digest(next),...(repairBackup?{repair_backup:repairBackup}:{})});
|
|
146
|
+
try{atomicWrite(file,next);}catch(error){state.set('codex_sync',previous);throw error;}
|
|
147
|
+
return {changed:true,file,backup,...(previous?{recovery:'direct_configuration'}:{})};
|
|
148
|
+
}
|
|
149
|
+
export function unsyncCodex(state){
|
|
150
|
+
const previous=state.get('codex_sync');
|
|
151
|
+
if(!previous)return {changed:false};
|
|
152
|
+
if(fs.lstatSync(previous.file).isSymbolicLink())throw Error('config_symlink_requires_manual_setup');
|
|
153
|
+
const current=fs.readFileSync(previous.file,'utf8');
|
|
154
|
+
const direct=directConfiguration(current,previous);
|
|
155
|
+
if(direct!==null){
|
|
156
|
+
if(direct!==current){backupConfig(state,current,'codex-config-before-repair');atomicWrite(previous.file,direct);}
|
|
157
|
+
state.set('codex_sync',null);return {changed:direct!==current,file:previous.file,recovery:'already_direct_configuration'};
|
|
158
|
+
}
|
|
159
|
+
atomicWrite(previous.file,restoreConfig(current,previous.baseUrl,{original:verifiedOriginal(previous)}));
|
|
160
|
+
state.set('codex_sync',null);
|
|
161
|
+
return {changed:true,file:previous.file};
|
|
162
|
+
}
|
|
163
|
+
export function codexConnection(state){
|
|
164
|
+
const previous=state.get('codex_sync');
|
|
165
|
+
try{
|
|
166
|
+
const codexHome=state.get('codex_home');if(!codexHome)throw Error('codex_home_missing');
|
|
167
|
+
const file=path.join(codexHome,'config.toml');
|
|
168
|
+
if(fs.existsSync(file)&&fs.lstatSync(file).isSymbolicLink())throw Error('config_symlink_requires_manual_setup');
|
|
169
|
+
const current=fs.existsSync(file)?fs.readFileSync(file,'utf8'):'';
|
|
170
|
+
const baseUrl='http://127.0.0.1:'+state.settings.get('relay.port')+'/v1';
|
|
171
|
+
if(layout(current).fields.has('profile'))throw Error('active_codex_profile_requires_manual_setup');
|
|
172
|
+
if(previous){
|
|
173
|
+
if(path.resolve(previous.file)!==path.resolve(file)||previous.baseUrl!==baseUrl)throw Error('config_target_changed_review_required');
|
|
174
|
+
if(directConfiguration(current,previous)!==null)return {configured:false,managed:true,repairable:true};
|
|
175
|
+
restoreConfig(current,baseUrl,{original:verifiedOriginal(previous)});
|
|
176
|
+
return {configured:true,managed:true,repairable:true};
|
|
177
|
+
}
|
|
178
|
+
managedConfig(current,baseUrl);
|
|
179
|
+
return {configured:false,managed:false,repairable:true};
|
|
180
|
+
}catch(error){
|
|
181
|
+
const code=['EACCES','EPERM','EBUSY'].includes(error.code)?'config_permission_denied':error.code==='ENOENT'?'config_missing':error.message;
|
|
182
|
+
return {configured:false,managed:!!previous,repairable:false,error:code};
|
|
183
|
+
}
|
|
184
|
+
}
|
package/device.ps1
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
$ErrorActionPreference = 'Stop'
|
|
2
|
+
try {
|
|
3
|
+
$Request = [Console]::In.ReadToEnd() | ConvertFrom-Json
|
|
4
|
+
Add-Type -AssemblyName System.Security
|
|
5
|
+
if ($Request.action -eq 'identity') {
|
|
6
|
+
$Value = (Get-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Cryptography' -Name MachineGuid).MachineGuid
|
|
7
|
+
} elseif ($Request.action -eq 'protect' -or $Request.action -eq 'unprotect') {
|
|
8
|
+
$Bytes = [Convert]::FromBase64String($Request.value)
|
|
9
|
+
$Entropy = [Text.Encoding]::UTF8.GetBytes('CodexunlimitWeb.device.v1')
|
|
10
|
+
if ($Request.action -eq 'protect') {
|
|
11
|
+
$Result = [Security.Cryptography.ProtectedData]::Protect($Bytes,$Entropy,[Security.Cryptography.DataProtectionScope]::CurrentUser)
|
|
12
|
+
} else {
|
|
13
|
+
$Result = [Security.Cryptography.ProtectedData]::Unprotect($Bytes,$Entropy,[Security.Cryptography.DataProtectionScope]::CurrentUser)
|
|
14
|
+
}
|
|
15
|
+
$Value = [Convert]::ToBase64String($Result)
|
|
16
|
+
[Array]::Clear($Bytes,0,$Bytes.Length)
|
|
17
|
+
} else { throw 'unsupported_action' }
|
|
18
|
+
[Console]::Out.Write($Value)
|
|
19
|
+
} catch {
|
|
20
|
+
[Console]::Error.Write('device_protection_failed')
|
|
21
|
+
exit 1
|
|
22
|
+
}
|
package/doctor.mjs
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import {execFileSync} from 'node:child_process';
|
|
5
|
+
import {codexExecutable} from './codex-account-client.mjs';
|
|
6
|
+
import {VERSION} from './product.mjs';
|
|
7
|
+
|
|
8
|
+
export async function diagnoseRuntime(){
|
|
9
|
+
const version=process.versions.node.split('.').map(Number),checks=[{name:'Node.js',ok:version[0]===22&&version[1]>=15||version[0]>=24,version:process.versions.node},{name:'Codex Unlimit',ok:true,version:VERSION}];
|
|
10
|
+
try{await import('node:sqlite');checks.push({name:'SQLite',ok:true});}catch{checks.push({name:'SQLite',ok:false});}
|
|
11
|
+
const home=fs.mkdtempSync(path.join(os.tmpdir(),'cu-runtime-check-'));
|
|
12
|
+
try{
|
|
13
|
+
const env={...process.env,CODEX_HOME:home};for(const key of Object.keys(env))if(/^(OPENAI_|CHATGPT_|CODEX_API_KEY|NODE_OPTIONS|NODE_PATH|NODE_AUTH_TOKEN)/i.test(key))delete env[key];
|
|
14
|
+
const expected=JSON.parse(fs.readFileSync(new URL('./package.json',import.meta.url),'utf8')).dependencies['@openai/codex'];
|
|
15
|
+
const output=execFileSync(codexExecutable(),['--version'],{env,encoding:'utf8',windowsHide:true,timeout:20000,stdio:['ignore','pipe','pipe']});
|
|
16
|
+
const actual=output.match(/codex-cli (\d+\.\d+\.\d+)/)?.[1];checks.push({name:'Codex runtime',ok:actual===expected,...(actual?{version:actual}:{})});
|
|
17
|
+
}catch{checks.push({name:'Codex runtime',ok:false});}
|
|
18
|
+
finally{if(path.dirname(home)===os.tmpdir())fs.rmSync(home,{recursive:true,force:true,maxRetries:5,retryDelay:100});}
|
|
19
|
+
return {ok:checks.every(check=>check.ok),platform:process.platform,architecture:process.arch,checks,accountCredentialsRead:false};
|
|
20
|
+
}
|
package/image-http.mjs
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import https from 'node:https';
|
|
3
|
+
import {Readable} from 'node:stream';
|
|
4
|
+
|
|
5
|
+
export const isImageRoute=route=>/^\/(?:v1\/)?images\/(?:generations|edits)$/.test(route);
|
|
6
|
+
export function imageRequest(value,options={}){
|
|
7
|
+
const url=new URL(value);
|
|
8
|
+
if(url.username||url.password||url.hash||url.search||!isImageRoute(url.pathname)||!(url.protocol==='https:'||url.protocol==='http:'&&url.hostname==='127.0.0.1'))throw Error('image_destination_denied');
|
|
9
|
+
const body=Buffer.from(options.body??''),headers=new Headers(options.headers);headers.set('content-length',String(body.length));headers.set('accept-encoding','identity');
|
|
10
|
+
for(const name of ['host','proxy-authorization','proxy-connection'])headers.delete(name);
|
|
11
|
+
return new Promise((resolve,reject)=>{
|
|
12
|
+
const request=(url.protocol==='https:'?https:http).request(url,{method:'POST',headers:Object.fromEntries(headers),signal:options.signal,agent:false,rejectUnauthorized:true},response=>{
|
|
13
|
+
clearTimeout(timer);const responseHeaders=new Headers();
|
|
14
|
+
for(const [name,value]of Object.entries(response.headers))if(value!==undefined)responseHeaders.set(name,Array.isArray(value)?value.join(', '):String(value));
|
|
15
|
+
if([204,205,304].includes(response.statusCode)){response.resume();resolve(new Response(null,{status:response.statusCode,headers:responseHeaders}));}
|
|
16
|
+
else resolve(new Response(Readable.toWeb(response),{status:response.statusCode,headers:responseHeaders}));
|
|
17
|
+
});
|
|
18
|
+
const timer=setTimeout(()=>request.destroy(Error('image_header_timeout')),600000);timer.unref();
|
|
19
|
+
request.once('error',error=>{clearTimeout(timer);reject(error);});request.end(body);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
export const gatewayRequest=(url,options)=>isImageRoute(new URL(url).pathname)?imageRequest(url,options):fetch(url,options);
|
package/io.mjs
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export async function boundedJson(response,limit,code='invalid_remote_response'){
|
|
2
|
+
const chunks=[];let size=0;
|
|
3
|
+
for await(const chunk of response.body){size+=chunk.length;if(size>limit)throw Object.assign(Error(code),{status:502});chunks.push(chunk);}
|
|
4
|
+
try{return JSON.parse(Buffer.concat(chunks).toString('utf8'));}catch{throw Object.assign(Error(code),{status:502});}
|
|
5
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import {execFileSync} from 'node:child_process';
|
|
5
|
+
import {fileURLToPath} from 'node:url';
|
|
6
|
+
import {LICENSE_PUBLIC_KEY,LICENSE_ORIGIN} from './product.mjs';
|
|
7
|
+
import {hash,publicText,signProof,verifyEnvelope} from './protocol.mjs';
|
|
8
|
+
import {boundedJson} from './io.mjs';
|
|
9
|
+
class RelayError extends Error{constructor(status,code){super(code);this.status=status;this.code=code;}}
|
|
10
|
+
const TERMINAL_DENIALS=new Set(['license_invalid_or_expired','device_revoked','activation_invalid','device_already_bound_or_revoked']);
|
|
11
|
+
const LOCAL_DENIALS=new Set(['activation_required','license_signature_invalid','clock_rollback_detected','license_invalid_or_expired','license_revoked']);
|
|
12
|
+
function serviceError(code,status=503,retryAfter=1){const error=new RelayError(status,code);error.transient=true;error.authoritative=false;error.retryAfter=retryAfter;return error;}
|
|
13
|
+
function normalizeFailure(error){
|
|
14
|
+
const code=typeof error?.code==='string'?error.code:error?.message;
|
|
15
|
+
if(code==='license_lease_expired')return serviceError(code,503,error?.retryAfter??1);
|
|
16
|
+
if(error?.transient||error?.status>=500||error?.status===429||['license_server_unavailable','license_response_invalid','license_service_busy','try_later','ingress_rate_limit','ingress_busy','ingress_capacity'].includes(code))return serviceError(code==='license_service_busy'||error?.status===429?'license_service_busy':'license_server_unavailable',error?.status===429?429:503,error?.retryAfter??1);
|
|
17
|
+
if(TERMINAL_DENIALS.has(code)||LOCAL_DENIALS.has(code)){const failure=new RelayError(403,code);failure.authoritative=error?.authoritative!==false;return failure;}
|
|
18
|
+
return serviceError('license_server_unavailable');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function windowsDevice(action,value){
|
|
22
|
+
const executable=path.join(process.env.SystemRoot??'C:/Windows','System32/WindowsPowerShell/v1.0/powershell.exe');
|
|
23
|
+
try{return execFileSync(executable,['-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-File',fileURLToPath(new URL('./device.ps1',import.meta.url))],
|
|
24
|
+
{input:JSON.stringify({action,value}),encoding:'utf8',windowsHide:true,timeout:15000,stdio:['pipe','pipe','pipe']}).trim();}
|
|
25
|
+
catch{throw Error('device_protection_failed');}
|
|
26
|
+
}
|
|
27
|
+
export function machineIdentity(){
|
|
28
|
+
let value;
|
|
29
|
+
if(process.platform==='win32')value=windowsDevice('identity');
|
|
30
|
+
else if(process.platform==='darwin'){
|
|
31
|
+
const text=execFileSync('/usr/sbin/ioreg',['-rd1','-c','IOPlatformExpertDevice'],{encoding:'utf8',timeout:10000});
|
|
32
|
+
value=text.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/)?.[1];
|
|
33
|
+
}else{
|
|
34
|
+
const file=fs.existsSync('/run/tdh-machine-id')?'/run/tdh-machine-id':'/etc/machine-id';
|
|
35
|
+
value=fs.readFileSync(file,'utf8').trim();
|
|
36
|
+
}
|
|
37
|
+
if(!value||value.length<16)throw Error('stable_machine_identity_required');
|
|
38
|
+
return hash('cu-web-device-v1:'+process.platform+':'+value.trim().toLowerCase());
|
|
39
|
+
}
|
|
40
|
+
export function loadDevice(state){
|
|
41
|
+
const deviceId=machineIdentity();let saved=state.get('license.device');
|
|
42
|
+
if(!saved){
|
|
43
|
+
const pair=crypto.generateKeyPairSync('ed25519');
|
|
44
|
+
const raw=pair.privateKey.export({type:'pkcs8',format:'der'}).toString('base64');
|
|
45
|
+
saved={deviceId,publicKey:publicText(pair.privateKey),format:process.platform==='win32'?'dpapi':'owner-permissions',
|
|
46
|
+
privateKey:process.platform==='win32'?windowsDevice('protect',raw):raw};
|
|
47
|
+
state.set('license.device',saved);
|
|
48
|
+
}
|
|
49
|
+
if(saved.deviceId!==deviceId)throw Error('activation_belongs_to_another_device');
|
|
50
|
+
const raw=saved.format==='dpapi'?windowsDevice('unprotect',saved.privateKey):saved.privateKey;
|
|
51
|
+
const privateKey=crypto.createPrivateKey({key:Buffer.from(raw,'base64'),format:'der',type:'pkcs8'});
|
|
52
|
+
if(publicText(privateKey)!==saved.publicKey)throw Error('device_key_mismatch');
|
|
53
|
+
return {deviceId,publicKey:saved.publicKey,privateKey};
|
|
54
|
+
}
|
|
55
|
+
export async function requestLicense(route,body,{request=fetch}={}){
|
|
56
|
+
if(!['challenge','activate','renew'].includes(route))throw Error('invalid_license_route');
|
|
57
|
+
let response;
|
|
58
|
+
try{response=await request(LICENSE_ORIGIN+'/api/licenses/'+route,{method:'POST',redirect:'error',
|
|
59
|
+
headers:{'content-type':'application/json'},body:JSON.stringify(body),signal:AbortSignal.timeout(10000)});}
|
|
60
|
+
catch{throw serviceError('license_server_unavailable');}
|
|
61
|
+
if(response.status===429||response.status>=500||response.status===408){response.body?.cancel().catch(()=>{});const value=Number(response.headers.get('retry-after'));throw serviceError(response.status===429?'license_service_busy':'license_server_unavailable',response.status===429?429:503,Number.isFinite(value)&&value>0?Math.min(60,Math.ceil(value)):1);}
|
|
62
|
+
let data;try{data=await boundedJson(response,16384,'license_response_invalid');}catch{throw serviceError('license_response_invalid');}
|
|
63
|
+
if(!response.ok){const error=new RelayError(403,typeof data.error==='string'&&/^[a-z_]+$/.test(data.error)?data.error:'license_request_rejected');error.authoritative=true;throw error;}
|
|
64
|
+
return data;
|
|
65
|
+
}
|
|
66
|
+
export class LicenseClient {
|
|
67
|
+
constructor(state,{publicKey=LICENSE_PUBLIC_KEY,request=requestLicense,device,now=Date.now}={}){
|
|
68
|
+
this.state=state;this.publicKey=publicKey;this.request=request;this.device=device??loadDevice(state);this.now=now;this.pending=null;this.nextAttempt=0;this.lastFailure=null;this.failures=0;
|
|
69
|
+
this.wallStart=now();this.monotonicStart=performance.now();
|
|
70
|
+
}
|
|
71
|
+
check(envelope=this.state.get('license.lease'),feature='desktop',{allowExpired=false}={}){
|
|
72
|
+
let claims;try{claims=verifyEnvelope(envelope,this.publicKey);}catch{throw Error('license_signature_invalid');}
|
|
73
|
+
const now=this.now(),last=this.state.get('license.clock')??0;
|
|
74
|
+
if(now<last-30000||now<this.wallStart+performance.now()-this.monotonicStart-30000)throw Error('clock_rollback_detected');
|
|
75
|
+
if(claims.kind!=='tdh-relay-lease-v1'||claims.product!=='tdh-relay'||claims.deviceId!==this.device.deviceId||
|
|
76
|
+
claims.keyHash!==hash(this.device.publicKey)||!Array.isArray(claims.features)||!claims.features.includes(feature)||!Number.isFinite(claims.licenseExpiresAt)||
|
|
77
|
+
!Number.isFinite(claims.issuedAt)||!Number.isFinite(claims.expiresAt)||claims.issuedAt>now+60000||
|
|
78
|
+
claims.expiresAt<=claims.issuedAt||claims.expiresAt>claims.issuedAt+600000||claims.expiresAt>claims.licenseExpiresAt)throw Error('license_invalid_or_expired');
|
|
79
|
+
if(!allowExpired&&claims.expiresAt<=now)throw serviceError('license_lease_expired');
|
|
80
|
+
if(now>last+10000)this.state.set('license.clock',now);
|
|
81
|
+
return claims;
|
|
82
|
+
}
|
|
83
|
+
async proof(action){
|
|
84
|
+
const challenge=await this.request('challenge',{deviceId:this.device.deviceId,publicKey:this.device.publicKey});
|
|
85
|
+
if(typeof challenge.id!=='string'||typeof challenge.nonce!=='string'||challenge.nonce.length>100)throw Error('challenge_invalid');
|
|
86
|
+
return {challengeId:challenge.id,proof:signProof(action,challenge.nonce,this.device.deviceId,this.device.publicKey,this.device.privateKey)};
|
|
87
|
+
}
|
|
88
|
+
save(response){
|
|
89
|
+
const claims=this.check(response.lease);
|
|
90
|
+
this.state.transaction(()=>{this.state.set('license.lease',response.lease);this.state.set('license.activation',claims.activationId);this.state.set('license.denied',false);});
|
|
91
|
+
this.nextAttempt=0;this.lastFailure=null;this.failures=0;return claims;
|
|
92
|
+
}
|
|
93
|
+
async activate(key){
|
|
94
|
+
if(typeof key!=='string'||!/^TDH-[A-Za-z0-9_-]{32}$/.test(key.trim()))throw Error('invalid_activation_key');
|
|
95
|
+
key=key.trim();const proof=await this.proof('activate:'+hash(key));
|
|
96
|
+
return this.save(await this.request('activate',{...proof,key}));
|
|
97
|
+
}
|
|
98
|
+
async renew(){
|
|
99
|
+
if(this.pending)return this.pending;
|
|
100
|
+
const activationId=this.state.get('license.activation');if(!activationId)throw Error('activation_required');
|
|
101
|
+
this.pending=(async()=>{
|
|
102
|
+
try{
|
|
103
|
+
for(let attempt=0;;attempt++){
|
|
104
|
+
const proof=await this.proof('renew:'+activationId);
|
|
105
|
+
const response=await this.request('renew',{...proof,activationId});
|
|
106
|
+
try{return this.save(response);}
|
|
107
|
+
catch(error){error.authoritative=false;if(error.code!=='license_lease_expired'||attempt>=1)throw error;}
|
|
108
|
+
}
|
|
109
|
+
}catch(error){
|
|
110
|
+
const failure=normalizeFailure(error);this.lastFailure=failure;
|
|
111
|
+
if(TERMINAL_DENIALS.has(failure.code)&&failure.authoritative){
|
|
112
|
+
this.state.set('license.denied',true);this.state.set('license.lease',null);
|
|
113
|
+
}
|
|
114
|
+
this.failures=Math.min(this.failures+1,6);const delay=failure.transient?Math.max((failure.retryAfter??1)*1000,Math.min(30000,1000*2**(this.failures-1))):30000;
|
|
115
|
+
this.nextAttempt=this.now()+delay;failure.retryAfter=Math.ceil(delay/1000);throw failure;
|
|
116
|
+
}finally{this.pending=null;}
|
|
117
|
+
})();
|
|
118
|
+
return this.pending;
|
|
119
|
+
}
|
|
120
|
+
async ensure(feature='desktop',{online=false}={}){
|
|
121
|
+
let current;try{if(!this.state.get('license.denied'))current=this.check(undefined,feature);}catch{}
|
|
122
|
+
if(online||!current||current.expiresAt-this.now()<300000){
|
|
123
|
+
if(this.now()>=this.nextAttempt){
|
|
124
|
+
try{await this.renew();}
|
|
125
|
+
catch(error){if(!current||this.state.get('license.denied')||online)throw normalizeFailure(error);}
|
|
126
|
+
}else if((!current||online)&&this.lastFailure)throw this.lastFailure;
|
|
127
|
+
}
|
|
128
|
+
if(this.state.get('license.denied'))throw new RelayError(403,'license_revoked');
|
|
129
|
+
try{return this.check(undefined,feature);}catch(error){if(this.lastFailure?.transient)throw this.lastFailure;throw normalizeFailure(error);}
|
|
130
|
+
}
|
|
131
|
+
async forRequest(feature='desktop'){
|
|
132
|
+
try{if(!this.state.get('license.denied'))return this.check(undefined,feature,{allowExpired:true});}catch{}
|
|
133
|
+
return this.ensure(feature);
|
|
134
|
+
}
|
|
135
|
+
acceptLease(encoded){
|
|
136
|
+
if(typeof encoded!=='string'||encoded.length>4096||this.state.get('license.denied'))return false;
|
|
137
|
+
try{
|
|
138
|
+
const envelope=JSON.parse(Buffer.from(encoded,'base64url')),claims=this.check(envelope);
|
|
139
|
+
if(claims.activationId!==this.state.get('license.activation'))return false;
|
|
140
|
+
const current=this.check(undefined,'desktop',{allowExpired:true});
|
|
141
|
+
if(claims.licenseId!==current.licenseId||claims.issuedAt<current.issuedAt||claims.issuedAt===current.issuedAt&&claims.expiresAt<=current.expiresAt)return false;
|
|
142
|
+
this.save({lease:envelope});return true;
|
|
143
|
+
}catch{return false;}
|
|
144
|
+
}
|
|
145
|
+
status(){
|
|
146
|
+
try{const c=this.check();return {active:!this.state.get('license.denied'),licenseId:c.licenseId,activationId:c.activationId,
|
|
147
|
+
leaseUntil:new Date(c.expiresAt).toISOString(),expiresAt:new Date(c.licenseExpiresAt).toISOString()};}
|
|
148
|
+
catch{return {active:false,activationRequired:!this.state.get('license.activation')};}
|
|
149
|
+
}
|
|
150
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "codexunlimit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "codexunlimit",
|
|
3
|
+
"version": "0.1.0-web.10",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Codex Unlimit — trò chuyện và quản lý kết nối trên máy của bạn",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"license": "UNLICENSED",
|
|
8
|
+
"homepage": "https://codexunlimit.tudonghoa.me",
|
|
9
|
+
"publishConfig": {"registry":"https://registry.npmjs.org","access":"public"},
|
|
10
|
+
"engines": {"node": "^22.15.0 || >=24.0.0"},
|
|
11
|
+
"bin": {"codexunlimit": "cli.mjs"},
|
|
12
|
+
"dependencies": {"@openai/codex":"0.166.0","markdown-it":"15.0.3"},
|
|
13
|
+
"files": ["app.mjs","account-pool.mjs","codex-account-client.mjs","quota.mjs","cli.mjs","config.mjs","device.ps1","doctor.mjs","image-http.mjs","license-client.mjs","io.mjs","product.mjs","protocol.mjs","relay.mjs","state.mjs","windows.mjs","windows.ps1","updates.mjs","web/","README.md"]
|
|
14
|
+
}
|
package/product.mjs
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export const VERSION='0.1.0-web.10';
|
|
2
|
+
export const PRODUCT='codexunlimit-connector';
|
|
3
|
+
export const DEFAULT_PORT=18457;
|
|
4
|
+
export const LICENSE_ORIGIN='https://codexunlimit.tudonghoa.me';
|
|
5
|
+
export const LICENSE_PUBLIC_KEY='MCowBQYDK2VwAyEAnOW-TAyqv57sVI-ZOW3m0jurLc6hHzzeJ-KbiU_9hxk';
|
package/protocol.mjs
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export const hash=value=>crypto.createHash('sha256').update(value).digest('hex');
|
|
4
|
+
export const publicText=key=>crypto.createPublicKey(key).export({type:'spki',format:'der'}).toString('base64url');
|
|
5
|
+
export function readPublic(text){
|
|
6
|
+
if(typeof text!=='string'||text.length>150)throw Error('invalid_device_key');
|
|
7
|
+
const key=crypto.createPublicKey({key:Buffer.from(text,'base64url'),type:'spki',format:'der'});
|
|
8
|
+
if(key.asymmetricKeyType!=='ed25519'&&!(key.asymmetricKeyType==='ec'&&key.asymmetricKeyDetails?.namedCurve==='prime256v1'))throw Error('invalid_device_key');
|
|
9
|
+
return key;
|
|
10
|
+
}
|
|
11
|
+
export function signEnvelope(value,privateKey){
|
|
12
|
+
const payload=Buffer.from(JSON.stringify(value)).toString('base64url');
|
|
13
|
+
return {payload,signature:crypto.sign(null,Buffer.from(payload),privateKey).toString('base64url')};
|
|
14
|
+
}
|
|
15
|
+
export function verifyEnvelope(envelope,publicKey){
|
|
16
|
+
if(!envelope||typeof envelope.payload!=='string'||envelope.payload.length>100000||
|
|
17
|
+
typeof envelope.signature!=='string'||!crypto.verify(null,Buffer.from(envelope.payload),readPublic(publicKey),Buffer.from(envelope.signature,'base64url')))throw Error('signature_invalid');
|
|
18
|
+
return JSON.parse(Buffer.from(envelope.payload,'base64url'));
|
|
19
|
+
}
|
|
20
|
+
export function proofText(action,nonce,deviceId,publicKey){
|
|
21
|
+
return Buffer.from(JSON.stringify(['tdh-relay-license-v1',action,nonce,deviceId,publicKey]));
|
|
22
|
+
}
|
|
23
|
+
export function signProof(action,nonce,deviceId,publicKey,privateKey){
|
|
24
|
+
return crypto.sign(privateKey.asymmetricKeyType==='ec'?'sha256':null,proofText(action,nonce,deviceId,publicKey),privateKey).toString('base64url');
|
|
25
|
+
}
|
|
26
|
+
export function verifyProof(action,nonce,deviceId,publicKey,signature){
|
|
27
|
+
try{const key=readPublic(publicKey);return crypto.verify(key.asymmetricKeyType==='ec'?'sha256':null,proofText(action,nonce,deviceId,publicKey),key,Buffer.from(signature,'base64url'));}catch{return false;}
|
|
28
|
+
}
|
package/quota.mjs
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export function accountIdentity(credentials){
|
|
4
|
+
let claims={};try{claims=JSON.parse(Buffer.from(credentials.token.split('.')[1],'base64url'));}catch{}
|
|
5
|
+
const auth=claims['https://api.openai.com/auth']??{},profile=claims['https://api.openai.com/profile']??{};
|
|
6
|
+
const accountId=credentials.accountId??auth.chatgpt_account_id,subject=typeof claims.sub==='string'?claims.sub:'';
|
|
7
|
+
const email=typeof (claims.email??profile.email)==='string'?(claims.email??profile.email).slice(0,254):null;
|
|
8
|
+
return {ref:crypto.createHash('sha256').update(subject||accountId?JSON.stringify([subject,accountId??'']):credentials.token).digest('hex'),email,plan:typeof auth.chatgpt_plan_type==='string'?auth.chatgpt_plan_type.slice(0,40):null,accountId};
|
|
9
|
+
}
|
|
10
|
+
function windowValue(minutes,used,resetAt){
|
|
11
|
+
if(![300,10080].includes(minutes)||typeof used!=='number'||!Number.isFinite(used)||used<0||used>100)return null;
|
|
12
|
+
return {minutes,used,resetAt:Number.isSafeInteger(resetAt)&&resetAt>0&&resetAt<8640000000000000?resetAt:null};
|
|
13
|
+
}
|
|
14
|
+
export function headerWindows(headers,now=Date.now()){
|
|
15
|
+
const number=value=>typeof value==='string'&&/^\d+(?:\.\d+)?$/.test(value)&&Number.isFinite(Number(value))?Number(value):null;
|
|
16
|
+
return ['primary','secondary'].map(prefix=>{
|
|
17
|
+
const get=name=>number(headers.get('x-codex-'+prefix+'-'+name));
|
|
18
|
+
const reset=get('reset-at'),after=get('reset-after-seconds');return windowValue(get('window-minutes'),get('used-percent'),reset!==null?reset*1000:after!==null?now+after*1000:null);
|
|
19
|
+
}).filter(Boolean);
|
|
20
|
+
}
|
|
21
|
+
export function rateLimitWindows(result){
|
|
22
|
+
const bucket=result?.rateLimitsByLimitId?.codex??result?.rateLimits;
|
|
23
|
+
if(!bucket||bucket.limitId&&bucket.limitId!=='codex')return [];
|
|
24
|
+
return [bucket.primary,bucket.secondary].filter(Boolean).map(window=>windowValue(window.windowDurationMins,window.usedPercent,typeof window.resetsAt==='number'?window.resetsAt*1000:null)).filter(Boolean);
|
|
25
|
+
}
|
|
26
|
+
export class LocalQuota{
|
|
27
|
+
constructor(state,{now=Date.now}={}){this.db=state.db;this.now=now;this.db.exec('CREATE TABLE IF NOT EXISTS local_account_quota(account_ref TEXT NOT NULL,minutes INTEGER NOT NULL,used REAL NOT NULL,reset_at INTEGER,observed_at INTEGER NOT NULL,source TEXT NOT NULL,PRIMARY KEY(account_ref,minutes))');}
|
|
28
|
+
save(ref,windows,source){
|
|
29
|
+
const now=this.now();for(const window of windows)this.db.prepare('INSERT INTO local_account_quota VALUES(?,?,?,?,?,?) ON CONFLICT(account_ref,minutes) DO UPDATE SET used=excluded.used,reset_at=excluded.reset_at,observed_at=excluded.observed_at,source=excluded.source WHERE excluded.observed_at>=observed_at').run(ref,window.minutes,window.used,window.resetAt,now,source);
|
|
30
|
+
this.db.prepare('DELETE FROM local_account_quota WHERE observed_at<?').run(now-30*86400000);
|
|
31
|
+
}
|
|
32
|
+
observe(credentials,response){if(response.ok||response.status===429)this.save(accountIdentity(credentials).ref,headerWindows(response.headers,this.now()),'response_headers');}
|
|
33
|
+
read(ref){return this.db.prepare('SELECT minutes,used,reset_at,observed_at,source FROM local_account_quota WHERE account_ref=? ORDER BY minutes').all(ref).map(window=>({...window,stale:this.now()-window.observed_at>15*60000||window.reset_at!==null&&window.reset_at<=this.now()}));}
|
|
34
|
+
}
|
package/relay.mjs
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import {Readable} from 'node:stream';
|
|
4
|
+
import {pipeline} from 'node:stream/promises';
|
|
5
|
+
import {PRODUCT,VERSION,LICENSE_ORIGIN} from './product.mjs';
|
|
6
|
+
import {hash} from './protocol.mjs';
|
|
7
|
+
import {isImageRoute,gatewayRequest} from './image-http.mjs';
|
|
8
|
+
|
|
9
|
+
const CONTEXT_HEADERS=['content-type','content-encoding','chatgpt-account-id','session_id','session-id','conversation_id','originator','version','openai-beta','x-codex-beta-features','x-codex-parent-thread-id','x-codex-turn-metadata','x-codex-turn-state'];
|
|
10
|
+
export function signedHeaders(license,state,method,route,authorization,body=Buffer.alloc(0),headers=new Headers()){
|
|
11
|
+
const envelope=state.get('license.lease');license.check(envelope,'desktop',{allowExpired:true});
|
|
12
|
+
const timestamp=Date.now(),nonce=crypto.randomBytes(24).toString('base64url'),key=license.device.privateKey;
|
|
13
|
+
const bodyHash=hash(body??Buffer.alloc(0));
|
|
14
|
+
const contextHeaders=isImageRoute(route)?[...CONTEXT_HEADERS,'x-codex-image-turn-id']:CONTEXT_HEADERS;
|
|
15
|
+
const text=Buffer.from(JSON.stringify(['cu-request-v2',envelope.payload,method,route,timestamp,nonce,hash(authorization),bodyHash,contextHeaders.map(name=>[name,headers.get(name)??''])]));
|
|
16
|
+
return {'x-cu-lease':Buffer.from(JSON.stringify(envelope)).toString('base64url'),'x-cu-time':String(timestamp),'x-cu-nonce':nonce,
|
|
17
|
+
'x-cu-proof-version':'2','x-cu-body-sha256':bodyHash,
|
|
18
|
+
'x-cu-proof':crypto.sign(key.asymmetricKeyType==='ec'?'sha256':null,text,key).toString('base64url')};
|
|
19
|
+
}
|
|
20
|
+
export function createConnector(state,{license,localApp,pool,onStop=()=>{},prepareStop=()=>{},origin=LICENSE_ORIGIN,request=gatewayRequest,maxBufferedBytes=128*1024*1024,bodyTimeoutMs=60000}={}){
|
|
21
|
+
if(new URL(origin).protocol!=='https:')throw Error('https_required');
|
|
22
|
+
let active=0,buffered=0;
|
|
23
|
+
const server=http.createServer(async(req,res)=>{
|
|
24
|
+
const controller=new AbortController(),started=Date.now();let counted=false,status=503,reserved=0,bodyTimer;
|
|
25
|
+
req.on('aborted',()=>controller.abort());res.on('close',()=>{if(!res.writableFinished)controller.abort();});
|
|
26
|
+
const json=(code,data)=>{res.writeHead(code,{'content-type':'application/json','cache-control':'no-store'});res.end(JSON.stringify(data));};
|
|
27
|
+
try{
|
|
28
|
+
if(!['127.0.0.1:'+server.address().port,'localhost:'+server.address().port].includes(req.headers.host))return json(403,{error:{code:'local_request_denied'}});
|
|
29
|
+
if(localApp&&await localApp(req,res,server))return;
|
|
30
|
+
if(req.headers.origin)return json(403,{error:{code:'local_request_denied'}});
|
|
31
|
+
if(req.method==='GET'&&req.url==='/healthz')return json(200,{ok:true,service:PRODUCT,version:VERSION,mode:'desktop'});
|
|
32
|
+
if(req.method==='GET'&&req.url==='/control/identity'){
|
|
33
|
+
const nonce=String(req.headers['x-cu-nonce']??'');if(!/^[a-f0-9]{48}$/.test(nonce))return json(400,{error:{code:'invalid_nonce'}});
|
|
34
|
+
return json(200,{proof:crypto.createHmac('sha256',state.get('control_token')).update('cu-instance:'+nonce).digest('hex')});
|
|
35
|
+
}
|
|
36
|
+
if(req.method==='POST'&&req.url==='/control/stop'){
|
|
37
|
+
const a=Buffer.from(state.get('control_token')??''),b=Buffer.from(String(req.headers['x-cu-control']??''));
|
|
38
|
+
if(!a.length||a.length!==b.length||!crypto.timingSafeEqual(a,b))return json(403,{error:{code:'control_denied'}});
|
|
39
|
+
const mode=req.headers['x-cu-stop-mode']??'exit';if(!['exit','update'].includes(mode))return json(400,{error:{code:'invalid_stop_mode'}});
|
|
40
|
+
await prepareStop(mode);json(200,{stopped:true});setImmediate(onStop);return;
|
|
41
|
+
}
|
|
42
|
+
if(!(req.method==='GET'&&/^\/v1\/models(?:\?client_version=[a-zA-Z0-9._%-]{1,100})?$/.test(req.url))&&!(req.method==='POST'&&(['/v1/responses','/v1/responses/compact'].includes(req.url)||isImageRoute(req.url))))return json(404,{error:{code:'route_not_found'}});
|
|
43
|
+
let authorization=req.headers.authorization??'';
|
|
44
|
+
const selectedAccount=pool?.active();
|
|
45
|
+
if(!/^Bearer [^\s]{16,32768}$/.test(authorization)||authorization.startsWith('Bearer sk-'))return json(401,{error:{code:'chatgpt_sign_in_required'}});
|
|
46
|
+
if(active>=state.settings.get('relay.max_concurrency'))return json(429,{error:{code:'too_many_requests'}});
|
|
47
|
+
const limit=state.settings.get('relay.max_body_bytes');if(Number(req.headers['content-length']??0)>limit){req.resume();return json(413,{error:{code:'request_too_large'}});}
|
|
48
|
+
const length=Number(req.headers['content-length']??limit);
|
|
49
|
+
if(!Number.isSafeInteger(length)||length<0)return json(400,{error:{code:'invalid_body_length'}});
|
|
50
|
+
// Account for input chunks and their contiguous copy together.
|
|
51
|
+
reserved=req.method==='POST'?2*length:0;
|
|
52
|
+
if(buffered+reserved>maxBufferedBytes){req.resume();return json(503,{error:{code:'client_memory_busy'}});}
|
|
53
|
+
buffered+=reserved;active++;counted=true;
|
|
54
|
+
bodyTimer=setTimeout(()=>{controller.abort();req.destroy();},bodyTimeoutMs);bodyTimer.unref();
|
|
55
|
+
const headers=new Headers();
|
|
56
|
+
for(const name of ['authorization','content-type','content-encoding','chatgpt-account-id','session_id','session-id','conversation_id','originator','user-agent','version','openai-beta','x-codex-beta-features','x-codex-parent-thread-id','x-codex-turn-metadata','x-codex-turn-state','x-codex-image-turn-id'])if(typeof req.headers[name]==='string')headers.set(name,req.headers[name]);
|
|
57
|
+
const localToken=Buffer.from(String(req.headers['x-cu-control']??'')),expectedToken=Buffer.from(state.get('control_token')??'');
|
|
58
|
+
const fromLocalApp=expectedToken.length>0&&localToken.length===expectedToken.length&&crypto.timingSafeEqual(localToken,expectedToken);
|
|
59
|
+
if(pool&&selectedAccount!=='codex'&&!fromLocalApp){
|
|
60
|
+
if(!state.get('web.privacyAcceptedAt'))throw Object.assign(Error('privacy_required'),{status:403});
|
|
61
|
+
const credentials=await pool.credentials(selectedAccount);authorization='Bearer '+credentials.token;headers.set('authorization',authorization);headers.delete('chatgpt-account-id');if(credentials.accountId)headers.set('chatgpt-account-id',credentials.accountId);
|
|
62
|
+
}
|
|
63
|
+
// Bound input in the connector too, before forwarding. No request content is persisted.
|
|
64
|
+
const chunks=[];let size=0;for await(const chunk of req){size+=chunk.length;if(size>limit)throw Object.assign(Error('request_too_large'),{status:413});chunks.push(chunk);}
|
|
65
|
+
clearTimeout(bodyTimer);const payload=req.method==='POST'?Buffer.concat(chunks):undefined;chunks.length=0;
|
|
66
|
+
// Sign only after upload completes, so a slow upload cannot age the proof out.
|
|
67
|
+
controller.signal.throwIfAborted();await license.forRequest('desktop');controller.signal.throwIfAborted();
|
|
68
|
+
for(const [key,value]of Object.entries(signedHeaders(license,state,req.method,req.url,authorization,payload,headers)))headers.set(key,value);
|
|
69
|
+
const response=await request(origin+req.url,{method:req.method,headers,body:payload,redirect:'error',signal:AbortSignal.any([controller.signal,AbortSignal.timeout(isImageRoute(req.url)?630000:1800000)])});
|
|
70
|
+
license.acceptLease(response.headers.get('x-cu-lease'));
|
|
71
|
+
pool?.observe({token:authorization.slice(7),accountId:headers.get('chatgpt-account-id')??undefined},response);
|
|
72
|
+
if(response.status>=400&&!(response.headers.get('content-type')??'').toLowerCase().startsWith('application/json')){
|
|
73
|
+
response.body?.cancel().catch(()=>{});status=response.status===429?429:503;const wait=Number(response.headers.get('retry-after'));res.setHeader('retry-after',String(Number.isFinite(wait)&&wait>0?Math.min(60,Math.ceil(wait)):3));
|
|
74
|
+
return json(status,{error:{code:'service_unavailable',message:'Máy chủ tạm thời chưa phản hồi hợp lệ. Không tự gửi lại tác vụ đang chạy; kiểm tra kết nối rồi thử lại.'}});
|
|
75
|
+
}
|
|
76
|
+
status=response.status;const outgoing={'cache-control':'no-store'};
|
|
77
|
+
for(const [key,value]of response.headers)if(['content-type','retry-after','x-cu-request-id'].includes(key)||key.startsWith('x-codex-')||isImageRoute(req.url)&&key==='x-relay-policy-mode')outgoing[key]=value;
|
|
78
|
+
res.writeHead(status,outgoing);if(response.body)await pipeline(Readable.fromWeb(response.body),res,{signal:controller.signal});else res.end();
|
|
79
|
+
}catch(error){status=controller.signal.aborted?499:error.status??502;if(res.headersSent)res.destroy();else{if([429,503].includes(status))res.setHeader('retry-after',String(error.retryAfter??3));json(status,{error:{code:error.status?error.code??error.message:'connection_failed'}});}}
|
|
80
|
+
finally{clearTimeout(bodyTimer);controller.abort();if(counted){active--;buffered-=reserved;state.record({status,duration:Date.now()-started});}}
|
|
81
|
+
});
|
|
82
|
+
server.headersTimeout=15000;server.requestTimeout=60000;return server;
|
|
83
|
+
}
|
package/state.mjs
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import {DatabaseSync} from 'node:sqlite';
|
|
5
|
+
import {DEFAULT_PORT,VERSION} from './product.mjs';
|
|
6
|
+
export class ClientState{
|
|
7
|
+
constructor(home){
|
|
8
|
+
this.home=path.resolve(home);fs.mkdirSync(path.join(this.home,'data'),{recursive:true,mode:0o700});
|
|
9
|
+
this.db=new DatabaseSync(path.join(this.home,'data','connector.sqlite'));
|
|
10
|
+
this.db.exec('PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000; CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY,value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS desktop_usage(id INTEGER PRIMARY KEY,ts INTEGER NOT NULL,status INTEGER NOT NULL,duration_ms INTEGER NOT NULL);');
|
|
11
|
+
this.settings={get:key=>this.get(key),set:(key,value)=>this.set(key,value)};
|
|
12
|
+
}
|
|
13
|
+
get(key){const row=this.db.prepare('SELECT value FROM settings WHERE key=?').get(key);return row?JSON.parse(row.value):null;}
|
|
14
|
+
set(key,value){this.db.prepare('INSERT INTO settings VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value').run(key,JSON.stringify(value));}
|
|
15
|
+
transaction(action){this.db.exec('BEGIN IMMEDIATE');try{const value=action();this.db.exec('COMMIT');return value;}catch(error){this.db.exec('ROLLBACK');throw error;}}
|
|
16
|
+
initialize({port=DEFAULT_PORT,codexHome}={}){
|
|
17
|
+
if(!this.get('initialized')){this.set('relay.port',port);this.set('control_token',crypto.randomBytes(32).toString('base64url'));this.set('initialized',true);}
|
|
18
|
+
this.set('relay.max_body_bytes',64*1024*1024);this.set('relay.max_concurrency',4);this.set('version',VERSION);
|
|
19
|
+
if(codexHome)this.set('codex_home',path.resolve(codexHome));
|
|
20
|
+
}
|
|
21
|
+
record(row){this.db.prepare('INSERT INTO desktop_usage(ts,status,duration_ms) VALUES(?,?,?)').run(Date.now(),row.status,row.duration);this.db.prepare('DELETE FROM desktop_usage WHERE id<(SELECT MAX(id)-2000 FROM desktop_usage)').run();}
|
|
22
|
+
close(){this.db.close();}
|
|
23
|
+
}
|