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/doctor.mjs DELETED
@@ -1,20 +0,0 @@
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 DELETED
@@ -1,22 +0,0 @@
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 DELETED
@@ -1,5 +0,0 @@
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
- }
@@ -1,153 +0,0 @@
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_clock_skew','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'||code==='license_response_invalid')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
- // Local validation cannot establish that the issuer revoked or expired a key.
75
- if(!claims||claims.kind!=='tdh-relay-lease-v1'||claims.product!=='tdh-relay'||claims.deviceId!==this.device.deviceId||
76
- typeof claims.licenseId!=='string'||!claims.licenseId||typeof claims.activationId!=='string'||!claims.activationId||
77
- claims.keyHash!==hash(this.device.publicKey)||!Array.isArray(claims.features)||!claims.features.includes(feature)||!Number.isSafeInteger(claims.licenseExpiresAt)||
78
- !Number.isSafeInteger(claims.issuedAt)||!Number.isSafeInteger(claims.expiresAt)||
79
- claims.expiresAt<=claims.issuedAt||claims.expiresAt>claims.issuedAt+600000||claims.expiresAt>claims.licenseExpiresAt)throw serviceError('license_response_invalid');
80
- if(now<last-30000||now<this.wallStart+performance.now()-this.monotonicStart-30000)throw Error('clock_rollback_detected');
81
- if(claims.issuedAt>now+60000)throw Error('license_clock_skew');
82
- if(!allowExpired&&claims.expiresAt<=now)throw serviceError('license_lease_expired');
83
- if(now>last+10000)this.state.set('license.clock',now);
84
- return claims;
85
- }
86
- async proof(action){
87
- const challenge=await this.request('challenge',{deviceId:this.device.deviceId,publicKey:this.device.publicKey});
88
- if(typeof challenge.id!=='string'||typeof challenge.nonce!=='string'||challenge.nonce.length>100)throw Error('challenge_invalid');
89
- return {challengeId:challenge.id,proof:signProof(action,challenge.nonce,this.device.deviceId,this.device.publicKey,this.device.privateKey)};
90
- }
91
- save(response){
92
- let claims;try{claims=this.check(response?.lease??null);}catch(error){error.authoritative=false;throw error;}
93
- this.state.transaction(()=>{this.state.set('license.lease',response.lease);this.state.set('license.activation',claims.activationId);this.state.set('license.denied',false);});
94
- this.nextAttempt=0;this.lastFailure=null;this.failures=0;return claims;
95
- }
96
- async activate(key){
97
- if(typeof key!=='string'||!/^TDH-[A-Za-z0-9_-]{32}$/.test(key.trim()))throw Error('invalid_activation_key');
98
- key=key.trim();const proof=await this.proof('activate:'+hash(key));
99
- return this.save(await this.request('activate',{...proof,key}));
100
- }
101
- async renew(){
102
- if(this.pending)return this.pending;
103
- const activationId=this.state.get('license.activation');if(!activationId)throw Error('activation_required');
104
- this.pending=(async()=>{
105
- try{
106
- for(let attempt=0;;attempt++){
107
- const proof=await this.proof('renew:'+activationId);
108
- const response=await this.request('renew',{...proof,activationId});
109
- try{return this.save(response);}
110
- catch(error){error.authoritative=false;if(error.code!=='license_lease_expired'||attempt>=1)throw error;}
111
- }
112
- }catch(error){
113
- const failure=normalizeFailure(error);this.lastFailure=failure;
114
- if(TERMINAL_DENIALS.has(failure.code)&&failure.authoritative){
115
- this.state.set('license.denied',true);this.state.set('license.lease',null);
116
- }
117
- 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;
118
- this.nextAttempt=this.now()+delay;failure.retryAfter=Math.ceil(delay/1000);throw failure;
119
- }finally{this.pending=null;}
120
- })();
121
- return this.pending;
122
- }
123
- async ensure(feature='desktop',{online=false}={}){
124
- let current;try{if(!this.state.get('license.denied'))current=this.check(undefined,feature);}catch{}
125
- if(online||!current||current.expiresAt-this.now()<300000){
126
- if(this.now()>=this.nextAttempt){
127
- try{await this.renew();}
128
- catch(error){if(!current||this.state.get('license.denied')||online)throw normalizeFailure(error);}
129
- }else if((!current||online)&&this.lastFailure)throw this.lastFailure;
130
- }
131
- if(this.state.get('license.denied'))throw new RelayError(403,'license_revoked');
132
- try{return this.check(undefined,feature);}catch(error){if(this.lastFailure?.transient)throw this.lastFailure;throw normalizeFailure(error);}
133
- }
134
- async forRequest(feature='desktop'){
135
- try{if(!this.state.get('license.denied'))return this.check(undefined,feature,{allowExpired:true});}catch{}
136
- return this.ensure(feature);
137
- }
138
- acceptLease(encoded){
139
- if(typeof encoded!=='string'||encoded.length>4096||this.state.get('license.denied'))return false;
140
- try{
141
- const envelope=JSON.parse(Buffer.from(encoded,'base64url')),claims=this.check(envelope);
142
- if(claims.activationId!==this.state.get('license.activation'))return false;
143
- const current=this.check(undefined,'desktop',{allowExpired:true});
144
- if(claims.licenseId!==current.licenseId||claims.issuedAt<current.issuedAt||claims.issuedAt===current.issuedAt&&claims.expiresAt<=current.expiresAt)return false;
145
- this.save({lease:envelope});return true;
146
- }catch{return false;}
147
- }
148
- status(){
149
- try{const c=this.check();return {active:!this.state.get('license.denied'),licenseId:c.licenseId,activationId:c.activationId,
150
- leaseUntil:new Date(c.expiresAt).toISOString(),expiresAt:new Date(c.licenseExpiresAt).toISOString()};}
151
- catch{return {active:false,activationRequired:!this.state.get('license.activation')};}
152
- }
153
- }
package/product.mjs DELETED
@@ -1,5 +0,0 @@
1
- export const VERSION='0.1.0-web.13';
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 DELETED
@@ -1,28 +0,0 @@
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 DELETED
@@ -1,34 +0,0 @@
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 DELETED
@@ -1,156 +0,0 @@
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
- import {boundedJson} from './io.mjs';
9
-
10
- 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'];
11
- export function signedHeaders(license,state,method,route,authorization,body=Buffer.alloc(0),headers=new Headers(),timestamp=Date.now()){
12
- const envelope=state.get('license.lease');license.check(envelope,'desktop',{allowExpired:true});
13
- const nonce=crypto.randomBytes(24).toString('base64url'),key=license.device.privateKey;
14
- const bodyHash=hash(body??Buffer.alloc(0));
15
- const contextHeaders=isImageRoute(route)?[...CONTEXT_HEADERS,'x-codex-image-turn-id']:CONTEXT_HEADERS;
16
- 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)??''])]));
17
- return {'x-cu-lease':Buffer.from(JSON.stringify(envelope)).toString('base64url'),'x-cu-time':String(timestamp),'x-cu-nonce':nonce,
18
- 'x-cu-proof-version':'2','x-cu-body-sha256':bodyHash,
19
- 'x-cu-proof':crypto.sign(key.asymmetricKeyType==='ec'?'sha256':null,text,key).toString('base64url')};
20
- }
21
- const connectionError=(status,code)=>Object.assign(Error(code),{status,code});
22
- const requestId=value=>typeof value==='string'&&/^[a-f0-9-]{36}$/i.test(value)?value:null;
23
- function waitForRetry(ms,signal){
24
- return new Promise((resolve,reject)=>{
25
- if(signal.aborted)return reject(signal.reason);
26
- const finish=error=>{clearTimeout(timer);signal.removeEventListener('abort',abort);error?reject(error):resolve();};
27
- const abort=()=>finish(signal.reason??connectionError(499,'client_cancelled'));
28
- const timer=setTimeout(()=>finish(),ms);signal.addEventListener('abort',abort,{once:true});
29
- });
30
- }
31
- function connectionQueue(limit,{maxQueued,queueTimeoutMs}){
32
- const active={inference:0,models:0},pending=[];let capacity=Math.min(2,limit);
33
- const drain=()=>{for(let i=0;i<pending.length;){const item=pending[i];if(active[item.lane]<(item.lane==='models'?1:capacity)){pending.splice(i,1);item.grant();}else i++;}};
34
- return {
35
- observe(response){const value=response.headers.get('x-cu-max-concurrent');if(value&&/^[1-9][0-9]{0,2}$/.test(value)){capacity=Math.min(limit,Number(value));drain();}},
36
- acquire(lane,signal){return new Promise((resolve,reject)=>{
37
- if(signal.aborted)return reject(signal.reason);
38
- if(pending.length>=maxQueued)return reject(connectionError(429,'local_queue_full'));
39
- let timer,settled=false;
40
- const clean=()=>{clearTimeout(timer);signal.removeEventListener('abort',abort);};
41
- const fail=error=>{if(settled)return;settled=true;clean();const i=pending.indexOf(item);if(i>=0)pending.splice(i,1);reject(error);};
42
- const abort=()=>fail(signal.reason??connectionError(499,'client_cancelled'));
43
- const item={lane,grant(){if(settled)return;settled=true;clean();active[lane]++;let released=false;resolve(()=>{if(released)return;released=true;active[lane]--;drain();});}};
44
- signal.addEventListener('abort',abort,{once:true});timer=setTimeout(()=>fail(connectionError(429,'local_queue_timeout')),queueTimeoutMs);
45
- pending.push(item);drain();
46
- })}
47
- };
48
- }
49
- export function createConnector(state,{license,localApp,pool,onStop=()=>{},prepareStop=()=>{},origin=LICENSE_ORIGIN,request=gatewayRequest,maxBufferedBytes=128*1024*1024,bodyTimeoutMs=60000,maxQueued=32,queueTimeoutMs=15000,retryBudgetMs=15000,retryDelayMs=1000}={}){
50
- if(new URL(origin).protocol!=='https:')throw Error('https_required');
51
- let buffered=0,clockOffsetMs=0;
52
- const queue=connectionQueue(state.settings.get('relay.max_concurrency')??4,{maxQueued,queueTimeoutMs});
53
- const server=http.createServer(async(req,res)=>{
54
- const controller=new AbortController(),started=Date.now(),localRequestId=crypto.randomUUID();
55
- let status=503,reserved=0,bodyTimer,release,tracked=false,stage='routing',errorCode=null,serverRequestId=null,attempts=0,queueMs=0,uploadMs=0,proofAgeMs=null;
56
- req.on('aborted',()=>controller.abort());res.on('close',()=>{if(!res.writableFinished)controller.abort();});
57
- const json=(code,data)=>{status=code;errorCode=data?.error?.code??errorCode;res.writeHead(code,{'content-type':'application/json','cache-control':'no-store'});res.end(JSON.stringify(data));};
58
- try{
59
- if(!['127.0.0.1:'+server.address().port,'localhost:'+server.address().port].includes(req.headers.host))return json(403,{error:{code:'local_request_denied'}});
60
- if(localApp&&await localApp(req,res,server))return;
61
- if(req.headers.origin)return json(403,{error:{code:'local_request_denied'}});
62
- if(req.method==='GET'&&req.url==='/healthz')return json(200,{ok:true,service:PRODUCT,version:VERSION,mode:'desktop'});
63
- if(req.method==='GET'&&req.url==='/control/identity'){
64
- const nonce=String(req.headers['x-cu-nonce']??'');if(!/^[a-f0-9]{48}$/.test(nonce))return json(400,{error:{code:'invalid_nonce'}});
65
- return json(200,{proof:crypto.createHmac('sha256',state.get('control_token')).update('cu-instance:'+nonce).digest('hex')});
66
- }
67
- if(req.method==='POST'&&req.url==='/control/stop'){
68
- const a=Buffer.from(state.get('control_token')??''),b=Buffer.from(String(req.headers['x-cu-control']??''));
69
- if(!a.length||a.length!==b.length||!crypto.timingSafeEqual(a,b))return json(403,{error:{code:'control_denied'}});
70
- const mode=req.headers['x-cu-stop-mode']??'exit';if(!['exit','update'].includes(mode))return json(400,{error:{code:'invalid_stop_mode'}});
71
- await prepareStop(mode);json(200,{stopped:true});setImmediate(onStop);return;
72
- }
73
- tracked=/^\/v1\/(?:responses(?:\/compact)?|models(?:\?.*)?|images\/(?:generations|edits))$/.test(req.url)||isImageRoute(req.url);
74
- if(tracked)res.setHeader('x-cu-local-request-id',localRequestId);
75
- 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'}});
76
- let authorization=req.headers.authorization??'';
77
- const selectedAccount=pool?.active();
78
- if(!/^Bearer [^\s]{16,32768}$/.test(authorization)||authorization.startsWith('Bearer sk-'))return json(401,{error:{code:'chatgpt_sign_in_required'}});
79
- stage='queue';const queuedAt=Date.now();
80
- release=await queue.acquire(req.method==='GET'?'models':'inference',controller.signal);queueMs=Date.now()-queuedAt;
81
- controller.signal.throwIfAborted();
82
- 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'}});}
83
- const length=Number(req.headers['content-length']??limit);
84
- if(!Number.isSafeInteger(length)||length<0)return json(400,{error:{code:'invalid_body_length'}});
85
- // Account for input chunks and their contiguous copy together.
86
- reserved=req.method==='POST'?2*length:0;
87
- if(buffered+reserved>maxBufferedBytes){reserved=0;req.resume();return json(503,{error:{code:'client_memory_busy'}});}
88
- buffered+=reserved;stage='upload';const uploadStarted=Date.now();
89
- bodyTimer=setTimeout(()=>{controller.abort(connectionError(408,'local_upload_timeout'));req.destroy();},bodyTimeoutMs);bodyTimer.unref();
90
- const headers=new Headers();
91
- 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]);
92
- const localToken=Buffer.from(String(req.headers['x-cu-control']??'')),expectedToken=Buffer.from(state.get('control_token')??'');
93
- const fromLocalApp=expectedToken.length>0&&localToken.length===expectedToken.length&&crypto.timingSafeEqual(localToken,expectedToken);
94
- if(pool&&selectedAccount!=='codex'&&!fromLocalApp){
95
- if(!state.get('web.privacyAcceptedAt'))throw Object.assign(Error('privacy_required'),{status:403});
96
- 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);
97
- }
98
- // Bound input in the connector too, before forwarding. No request content is persisted.
99
- 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);}
100
- clearTimeout(bodyTimer);uploadMs=Date.now()-uploadStarted;const payload=req.method==='POST'?Buffer.concat(chunks):undefined;chunks.length=0;
101
- // Queueing and upload finish before the timestamp/nonce are minted. A retry
102
- // is allowed only after an authenticated gateway's explicit pre-upstream denial.
103
- stage='license';controller.signal.throwIfAborted();await license.forRequest('desktop');controller.signal.throwIfAborted();
104
- const retryDeadline=Date.now()+retryBudgetMs;let response,proofRetries=0;
105
- for(;;){
106
- stage='gateway';const sentAt=Date.now();
107
- for(const [key,value]of Object.entries(signedHeaders(license,state,req.method,req.url,authorization,payload,headers,Math.round(sentAt+clockOffsetMs))))headers.set(key,value);
108
- attempts++;
109
- 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)])});
110
- queue.observe(response);serverRequestId=requestId(response.headers.get('x-cu-request-id'));
111
- const receivedAt=Date.now(),serverDate=Date.parse(response.headers.get('date')??'');
112
- if(Number.isFinite(serverDate)){
113
- proofAgeMs=serverDate-Number(headers.get('x-cu-time'));
114
- const offset=serverDate+500-(sentAt+receivedAt)/2;
115
- if(serverRequestId&&receivedAt-sentAt<5000&&Math.abs(offset)<=300000)clockOffsetMs=Math.round(offset);
116
- }
117
- let gatewayCode=null;
118
- if(serverRequestId&&response.status>=400&&(response.headers.get('content-type')??'').toLowerCase().startsWith('application/json')){
119
- const data=await boundedJson(response,32768,'gateway_response_invalid').catch(()=>{throw connectionError(502,'gateway_response_invalid');});
120
- gatewayCode=typeof data?.error?.code==='string'&&/^[a-z_]{1,80}$/.test(data.error.code)?data.error.code:null;
121
- response=new Response(JSON.stringify(data),{status:response.status,headers:response.headers});
122
- }
123
- const stale=response.status===408&&gatewayCode==='request_proof_expired'&&proofRetries<1;
124
- const busy=response.status===429&&gatewayCode==='license_concurrency_limit';
125
- const requestedDelay=Number(response.headers.get('retry-after'))*1000;
126
- const delay=stale?0:Math.max(retryDelayMs,Number.isFinite(requestedDelay)&&requestedDelay>0?requestedDelay:0);
127
- if((stale||busy)&&attempts<8&&Date.now()+delay<retryDeadline){
128
- await response.body?.cancel();if(stale)proofRetries++;
129
- stage='retry_wait';await waitForRetry(delay,controller.signal);continue;
130
- }
131
- errorCode=gatewayCode;break;
132
- }
133
- license.acceptLease(response.headers.get('x-cu-lease'));
134
- pool?.observe({token:authorization.slice(7),accountId:headers.get('chatgpt-account-id')??undefined},response);
135
- if(response.status>=400&&!(response.headers.get('content-type')??'').toLowerCase().startsWith('application/json')){
136
- 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));
137
- 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.'}});
138
- }
139
- stage='stream';status=response.status;const outgoing={'cache-control':'no-store'};
140
- 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;
141
- res.writeHead(status,outgoing);if(response.body)await pipeline(Readable.fromWeb(response.body),res,{signal:controller.signal});else res.end();stage='complete';
142
- }catch(error){
143
- const reason=controller.signal.reason?.status?controller.signal.reason:error;
144
- status=reason?.status??(controller.signal.aborted?499:502);
145
- errorCode=(typeof reason?.code==='string'?reason.code.toLowerCase():typeof reason?.cause?.code==='string'?reason.cause.code.toLowerCase():null)??(controller.signal.aborted?'client_cancelled':stage==='stream'?'stream_interrupted':'connection_failed');
146
- if(res.headersSent||status===499||res.destroyed)res.destroy();
147
- else{if([429,503].includes(status))res.setHeader('retry-after',String(reason?.retryAfter??3));json(status,{error:{code:errorCode}});}
148
- }finally{
149
- if(stage==='queue')queueMs=Date.now()-started;
150
- if(stage==='upload')uploadMs=Math.max(0,Date.now()-started-queueMs);
151
- clearTimeout(bodyTimer);controller.abort();buffered-=reserved;release?.();
152
- if(tracked){try{state.record({status,duration:Date.now()-started,requestId:localRequestId,serverRequestId,route:req.url.split('?')[0],method:req.method,errorCode,stage,queueMs,uploadMs,attempts,proofAgeMs});}catch{}}
153
- }
154
- });
155
- server.headersTimeout=15000;server.requestTimeout=bodyTimeoutMs+queueTimeoutMs;return server;
156
- }
package/state.mjs DELETED
@@ -1,33 +0,0 @@
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.db.exec('CREATE TABLE IF NOT EXISTS connection_diagnostics(id INTEGER PRIMARY KEY,at INTEGER NOT NULL,request_id TEXT,server_request_id TEXT,route TEXT,method TEXT,status INTEGER,error_code TEXT,stage TEXT,duration_ms INTEGER,queue_ms INTEGER,upload_ms INTEGER,attempts INTEGER,proof_age_ms INTEGER);');
12
- this.settings={get:key=>this.get(key),set:(key,value)=>this.set(key,value)};
13
- }
14
- get(key){const row=this.db.prepare('SELECT value FROM settings WHERE key=?').get(key);return row?JSON.parse(row.value):null;}
15
- set(key,value){this.db.prepare('INSERT INTO settings VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value').run(key,JSON.stringify(value));}
16
- 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;}}
17
- initialize({port=DEFAULT_PORT,codexHome}={}){
18
- if(!this.get('initialized')){this.set('relay.port',port);this.set('control_token',crypto.randomBytes(32).toString('base64url'));this.set('initialized',true);}
19
- this.set('relay.max_body_bytes',64*1024*1024);this.set('relay.max_concurrency',4);this.set('version',VERSION);
20
- if(codexHome)this.set('codex_home',path.resolve(codexHome));
21
- }
22
- record(row){
23
- const now=Date.now();this.db.prepare('INSERT INTO desktop_usage(ts,status,duration_ms) VALUES(?,?,?)').run(now,row.status,row.duration);this.db.prepare('DELETE FROM desktop_usage WHERE id<(SELECT MAX(id)-2000 FROM desktop_usage)').run();
24
- if(!/^\/(?:v1\/)?(?:responses(?:\/compact)?|models|images\/(?:generations|edits))$/.test(row.route??''))return;
25
- const code=value=>typeof value==='string'&&/^[a-z_]{1,80}$/.test(value)?value:null;
26
- const id=value=>typeof value==='string'&&/^[a-f0-9-]{36}$/i.test(value)?value:null;
27
- const number=value=>Number.isSafeInteger(value)?value:null;
28
- this.db.prepare('INSERT INTO connection_diagnostics(at,request_id,server_request_id,route,method,status,error_code,stage,duration_ms,queue_ms,upload_ms,attempts,proof_age_ms) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)').run(now,id(row.requestId),id(row.serverRequestId),row.route,['GET','POST'].includes(row.method)?row.method:null,number(row.status),code(row.errorCode),code(row.stage),number(row.duration),number(row.queueMs),number(row.uploadMs),number(row.attempts),number(row.proofAgeMs));
29
- this.db.prepare('DELETE FROM connection_diagnostics WHERE id<=(SELECT MAX(id)-2000 FROM connection_diagnostics) OR at<?').run(now-7*86400000);
30
- }
31
- diagnostics(){return this.db.prepare('SELECT * FROM connection_diagnostics ORDER BY id DESC LIMIT 100').all();}
32
- close(){this.db.close();}
33
- }
package/updates.mjs DELETED
@@ -1,47 +0,0 @@
1
- import {VERSION} from './product.mjs';
2
- import {boundedJson} from './io.mjs';
3
-
4
- function parseVersion(value){
5
- if(typeof value!=='string'||value.length>120)return null;
6
- const match=/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(value);
7
- if(!match)return null;
8
- const pre=match[4]?.split('.')??[];
9
- if(pre.some(id=>/^0\d+$/.test(id)))return null;
10
- return {core:match.slice(1,4).map(BigInt),pre};
11
- }
12
- export function compareVersions(left,right){
13
- const a=parseVersion(left),b=parseVersion(right);if(!a||!b)throw Error('invalid_version');
14
- const compare=(x,y)=>x<y?-1:x>y?1:0;
15
- for(let i=0;i<3;i++){const result=compare(a.core[i],b.core[i]);if(result)return result;}
16
- if(!a.pre.length||!b.pre.length)return compare(Number(!a.pre.length),Number(!b.pre.length));
17
- for(let i=0;i<Math.max(a.pre.length,b.pre.length);i++){
18
- if(a.pre[i]===undefined)return -1;if(b.pre[i]===undefined)return 1;
19
- const x=a.pre[i],y=b.pre[i],xn=/^\d+$/.test(x),yn=/^\d+$/.test(y);
20
- const result=xn&&yn?compare(BigInt(x),BigInt(y)):xn!==yn?(xn?-1:1):compare(x,y);
21
- if(result)return result;
22
- }
23
- return 0;
24
- }
25
- export function createUpdateChecker({request=fetch,now=Date.now,version=VERSION,platform=process.platform}={}){
26
- let cached=null,pending=null,nextCheck=0;
27
- const commands={installCommand:(platform==='win32'?'npm.cmd':'npm')+' i -g codexunlimit@latest',restartCommand:platform==='win32'?'codexunlimit.cmd restart':'codexunlimit restart'};
28
- return function check(){
29
- if(pending)return pending;
30
- if(cached&&now()<nextCheck)return Promise.resolve(cached);
31
- pending=(async()=>{
32
- try{
33
- const response=await request('https://registry.npmjs.org/codexunlimit/latest',{headers:{accept:'application/json'},redirect:'error',signal:AbortSignal.timeout(5000)});
34
- if(!response.ok)throw Error('update_check_failed');
35
- const release=await boundedJson(response,65536);
36
- if(release.name!=='codexunlimit'||!parseVersion(release.version))throw Error('invalid_release');
37
- cached={currentVersion:version,latestVersion:release.version,available:compareVersions(release.version,version)>0,status:'checked',checkedAt:now(),...commands};
38
- nextCheck=now()+3600000;
39
- }catch{
40
- cached={currentVersion:version,latestVersion:null,available:false,status:'unavailable',checkedAt:null,...commands};
41
- nextCheck=now()+60000;
42
- }
43
- return cached;
44
- })().finally(()=>{pending=null;});
45
- return pending;
46
- };
47
- }
package/web/markdown.js DELETED
@@ -1,15 +0,0 @@
1
- import markdownit from '/app/vendor/markdown-it.js';
2
-
3
- const markdown=markdownit({html:false,linkify:false,breaks:false});
4
- const escape=markdown.utils.escapeHtml;
5
- markdown.validateLink=value=>/^(?:https?:\/\/|mailto:|#)/i.test(value);
6
- markdown.renderer.rules.image=(tokens,index)=>'<span class="markdown-image">[Ảnh: '+escape(tokens[index].content||'không có mô tả')+']</span>';
7
- markdown.renderer.rules.link_open=(tokens,index,options,environment,renderer)=>{
8
- tokens[index].attrSet('target','_blank');tokens[index].attrSet('rel','noopener noreferrer');return renderer.renderToken(tokens,index,options);
9
- };
10
- const fence=markdown.renderer.rules.fence;
11
- markdown.renderer.rules.fence=(tokens,index,options,environment,renderer)=>{
12
- const name=tokens[index].info.trim().split(/\s+/)[0],language=/^[a-z0-9_+-]{1,24}$/i.test(name)?name:'Code';
13
- return '<div class="markdown-code"><div class="markdown-code-head"><span>'+escape(language)+'</span><button type="button" data-copy-code>Sao chép</button></div>'+fence(tokens,index,options,environment,renderer)+'</div>';
14
- };
15
- export function renderMarkdown(text){return markdown.render(String(text??''));}