codexunlimit 0.0.0-stage → 0.1.0-web.11

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.
Files changed (44) hide show
  1. package/README.md +94 -2
  2. package/account-pool.mjs +161 -0
  3. package/app.mjs +161 -0
  4. package/cli.mjs +108 -0
  5. package/codex-account-client.mjs +53 -0
  6. package/config.mjs +184 -0
  7. package/device.ps1 +22 -0
  8. package/doctor.mjs +20 -0
  9. package/image-http.mjs +22 -0
  10. package/io.mjs +5 -0
  11. package/license-client.mjs +150 -0
  12. package/package.json +14 -6
  13. package/product.mjs +5 -0
  14. package/protocol.mjs +28 -0
  15. package/quota.mjs +34 -0
  16. package/relay.mjs +83 -0
  17. package/state.mjs +23 -0
  18. package/updates.mjs +47 -0
  19. package/web/app.js +154 -0
  20. package/web/assets/apple-touch-icon.png +0 -0
  21. package/web/assets/favicon-16.png +0 -0
  22. package/web/assets/favicon-32.png +0 -0
  23. package/web/assets/favicon.ico +0 -0
  24. package/web/assets/fonts.css +48 -0
  25. package/web/assets/geist-LICENSE.txt +93 -0
  26. package/web/assets/geist-latin-ext-wght-normal.woff2 +0 -0
  27. package/web/assets/geist-latin-wght-normal.woff2 +0 -0
  28. package/web/assets/geist-vietnamese-wght-normal.woff2 +0 -0
  29. package/web/assets/icon-192.png +0 -0
  30. package/web/assets/icon-512.png +0 -0
  31. package/web/assets/mark.svg +1 -0
  32. package/web/assets/newsreader-LICENSE.txt +93 -0
  33. package/web/assets/newsreader-latin-ext-wght-normal.woff2 +0 -0
  34. package/web/assets/newsreader-latin-wght-normal.woff2 +0 -0
  35. package/web/assets/newsreader-vietnamese-wght-normal.woff2 +0 -0
  36. package/web/index.html +3 -0
  37. package/web/markdown.js +15 -0
  38. package/web/messages.js +63 -0
  39. package/web/pool-ui.js +53 -0
  40. package/web/stream.js +25 -0
  41. package/web/style.css +53 -0
  42. package/web/updates.js +26 -0
  43. package/windows.mjs +53 -0
  44. package/windows.ps1 +103 -0
package/README.md CHANGED
@@ -1,3 +1,95 @@
1
- # Temporary Holding Version
1
+ # Codex Unlimit · Web trên máy
2
+
3
+ Cài một gói trên Windows hoặc macOS, mở trình duyệt và bắt đầu sử dụng. Hỗ trợ Node.js 22 từ 22.15.0, hoặc Node.js 24 trở lên.
4
+
5
+ ## Cài và mở
6
+
7
+ Mở PowerShell (Windows) hoặc Terminal (macOS/Linux). Cài và mở bằng hai dòng:
8
+
9
+ ```sh
10
+ npm i -g codexunlimit
11
+ codexunlimit
12
+ ```
13
+
14
+ Trang tự mở. Chọn **Cài đặt & hỗ trợ**, nhập mã sử dụng, đăng nhập ChatGPT trong Codex rồi chọn **Kết nối tài khoản**. Có thể chat ngay trên trang. **Bật kết nối với Codex** là tùy chọn để dùng dịch vụ trong ứng dụng Codex.
15
+
16
+ Trên Windows, có thể dùng `npm.cmd` và `codexunlimit.cmd` nếu PowerShell chặn file `.ps1`.
17
+
18
+ Trang cài đặt công bố nguồn đang có hiệu lực. Khi gói chính thức trên npmjs đã được xác nhận tại đó, có thể dùng `npm i -g codexunlimit` hoặc thêm `@0.1.0-web.8` để cố định phiên bản. URL sản phẩm vẫn là phương án dự phòng. Không coi tên trần trên registry là chính thức trước thông báo xuất bản. Gói không có hook cài đặt tự chạy. Cần Node/npm có sẵn; không tải/chạy PowerShell từ web hay yêu cầu bộ cài .exe riêng. Kiểm tra môi trường bằng `codexunlimit doctor` (hoặc `--json`); lệnh không đọc token ChatGPT.
19
+
20
+ ## Ảnh và độ ổn định · web.7
21
+
22
+ Công cụ ImageGen gốc của Codex có thể gửi tạo/sửa ảnh qua `/v1/images/generations` và `/v1/images/edits` (cũng hỗ trợ đường dẫn không có `/v1`). Ảnh không đi qua luồng xử lý văn bản/SSE. Tài khoản đã chọn được giữ cho từng request; gateway không tự tạo lại ảnh hoặc đổi tài khoản khi upstream lỗi. Quyền tạo ảnh và quota vẫn do tài khoản upstream quyết định. Đây không phải nút tạo ảnh mới trong chat local và không phải API key OpenAI công khai.
23
+
24
+ Lỗi mạng khi gia hạn lease tạm thời trả 503/429, không tự kết luận key hết hạn. Lease còn hạn vẫn được dùng trong khi thử gia hạn; lease hết hạn không được dùng để vượt kiểm tra. Server từ chối/thu hồi thật vẫn có hiệu lực. Phản hồi HTML từ proxy được đổi thành lỗi JSON ngắn, có hướng dẫn chờ, không tự gửi lại request tốn quota.
25
+
26
+ Trước khi cập nhật, hoàn tất tác vụ, cài lại bằng lệnh npm đang công bố rồi chạy `codexunlimit restart`. Khởi động lại theo cách này giữ cấu hình kết nối, key/pool/hội thoại. Nếu npm báo file đang được dùng, chọn **Dừng để cập nhật** trong ứng dụng rồi cài lại. Với bản từ URL riêng chưa có công bố npmjs, không dùng `npm update -g codexunlimit` theo tên trần.
27
+
28
+ Nếu đã dùng bộ cài quản lý trước đây, không cần cài chồng: dùng `codexunlimit update` của bộ cài đó. Muốn chuyển hẳn sang npm, chuẩn bị Node/npm trước, gỡ bộ cài cũ bằng lệnh `uninstall` của nó (giữ dữ liệu), rồi cài npm. Tránh để hai launcher cùng tên trong PATH khiến mở nhầm phiên bản.
29
+
30
+ ## Tài khoản, quota và Markdown
31
+
32
+ Mở **Tài khoản & quota** trong trang local để xem mức sử dụng 5 giờ/1 tuần, thời điểm reset và lần cập nhật của từng tài khoản. Nút **Cập nhật quota** đọc hạn mức qua công cụ đăng nhập Codex, không gửi tin nhắn. Phản hồi chat/Codex cũng cập nhật số liệu local và bảng quota admin/khách online hiện có. Nếu chưa có dữ liệu hoặc dữ liệu đã cũ, giao diện ghi rõ; không suy thành 0%.
33
+
34
+ Chọn **Thêm tài khoản**, đồng ý sử dụng phiên và mở trang đăng nhập OpenAI. Có thể dùng đăng nhập bằng mã nếu cần. Mỗi tài khoản có phiên riêng, tối đa 10 tài khoản lưu thêm; không thay đổi phiên gốc trong Codex. Sau lần đăng nhập đầu, chọn **Dùng tài khoản này** hoặc chọn ở đầu trang chat. Lựa chọn áp dụng cho yêu cầu mới; yêu cầu đang chạy giữ tài khoản ban đầu. Khi hết quota, ứng dụng báo giới hạn và không tự gửi lại qua tài khoản khác.
35
+
36
+ Phiên trong pool được mã hóa bằng AES-GCM với khóa rút từ khóa thiết bị. Trên Windows, khóa thiết bị được bảo vệ bằng DPAPI của người dùng. Công cụ Codex chỉ dùng file phiên tạm trong thư mục riêng khi đăng nhập/làm mới và file được xóa khi tác vụ kết thúc. Token không được trả cho trang web, không ghi log và không gửi về server quản lý; yêu cầu xử lý vẫn gửi access token cần thiết qua gateway như trước. Tài khoản gốc được liên kết chỉ cung cấp access token tạm thời để đọc quota, không sao chép/luân chuyển refresh token của Codex gốc.
37
+
38
+ Gói npm tự cài thêm `@openai/codex` 0.166.0 cùng binary đúng hệ điều hành và `markdown-it` 15.0.3. Cần kết nối npm registry khi cài; không dùng `--omit=optional` vì sẽ thiếu binary đăng nhập. Dung lượng tải thực tế bao gồm các dependency, không chỉ file `.tgz` của Codex Unlimit.
39
+
40
+ Câu trả lời chat hỗ trợ Markdown khi nhận từng phần và khi mở lại lịch sử: tiêu đề, chữ đậm/nghiêng, danh sách, trích dẫn, bảng, liên kết và khối mã có nút sao chép. HTML thô không được thực thi, liên kết script bị chặn và ảnh bên ngoài không tự tải.
41
+
42
+ ## Khay Windows và tắt ứng dụng
43
+
44
+ Từ web.5, biểu tượng CU xuất hiện trong khay Windows khi ứng dụng chạy; có thể nằm trong dấu mũi tên lên ở thanh tác vụ. Nhấp đúp để mở trang, nhấp phải để chọn **Mở Codex Unlimit**, **Khởi động cùng Windows**, **Khởi động lại**, hoặc **Tắt ứng dụng**. Tự khởi động mặc định tắt; khi bật, chỉ chạy nền trong khay, không mở trình duyệt.
45
+
46
+ **Cài đặt & hỗ trợ → Ứng dụng trên máy** cũng có công tắc khởi động cùng Windows, nút hiện lại biểu tượng khay, hướng dẫn cập nhật và nút tắt. Đóng tab không dừng dịch vụ. **Tắt ứng dụng** hoặc `codexunlimit stop` khôi phục cấu hình Codex do bản này quản lý trước khi dừng, giữ key và dữ liệu. Nếu cấu hình bị bên khác chỉnh sửa, thao tác dừng sẽ báo rõ xung đột thay vì ghi đè.
47
+
48
+ `codexunlimit startup on` / `codexunlimit startup off` bật/tắt shortcut trong Startup của tài khoản Windows hiện tại; không cần quyền quản trị. `codexunlimit restart` khởi động lại. macOS/Linux vẫn dùng lệnh mở và tắt; bản này chưa có khay hay tự khởi động cho hai nền tảng đó.
49
+
50
+ ## Cập nhật và xử lý kết nối
51
+
52
+ Cài gói mới rồi chạy `codexunlimit`: CLI xác minh phiên web đang chạy bằng khóa điều khiển của cùng thư mục dữ liệu, dừng phiên khác phiên bản và mở bản mới. Giữ cấu hình, key và dữ liệu trong lần chuyển này. Không dừng dịch vụ lạ đang chiếm cổng. Với web.1 chưa có giao thức xác minh phiên, chạy `codexunlimit stop` trước. Bản native cũ cần khôi phục kết nối và thoát từ chính bản đó.
53
+
54
+ **Dừng để cập nhật** trong phần mở rộng của cài đặt sẽ giữ cấu hình nhưng dừng dịch vụ; Codex tạm thời mất kết nối cho tới khi mở bản mới. Gỡ ứng dụng: tắt **Khởi động cùng Windows**, chọn **Tắt ứng dụng**, rồi chạy `npm uninstall -g codexunlimit`. Dữ liệu riêng vẫn được giữ.
55
+
56
+ Khi không kết nối được, chọn **Kiểm tra kết nối**. Trang phân biệt key, tài khoản và cấu hình; profile riêng, cấu hình native, quyền ghi và bản sao khôi phục hỏng có hướng xử lý cụ thể. Nếu Codex đã reset cấu hình về trực tiếp, **Kết nối lại với Codex** tạo lại kết nối và sao lưu cấu hình hiện tại. Không tự sửa cấu hình đang được ứng dụng khác quản lý.
57
+
58
+ ## Dữ liệu và tương thích
59
+
60
+ - Nền tảng riêng: `https://codexunlimit.tudonghoa.me`, cổng local mặc định `18457`. Không dùng dịch vụ hay thư mục TDH Relay/toolcodex.
61
+ - Dữ liệu web ở `%LOCALAPPDATA%/CodexunlimitWeb` trên Windows, `~/Library/Application Support/CodexunlimitWeb` trên macOS. Khóa thiết bị dùng DPAPI trên Windows; file riêng tư theo quyền người dùng trên macOS/Linux.
62
+ - Lịch sử chat ở trình duyệt này, tối đa 30 cuộc / 100 tin mỗi cuộc. Khi gửi tin, nội dung và mã truy cập đi qua dịch vụ Codex Unlimit; server không lưu nội dung chat hoặc mã truy cập. Refresh token do Codex quản lý.
63
+ - Gói web dùng kích hoạt thiết bị riêng. Nếu chuyển từ native, khôi phục kết nối và dừng bản native trước; quản trị viên có thể giải phóng slot cũ. Không tự chuyển khóa Keychain/DPAPI từ ứng dụng native.
64
+ - Chat sử dụng phiên ChatGPT từ Codex; khi hết phiên, đăng nhập lại trong Codex. Chưa có đăng nhập ChatGPT độc lập trong web.
65
+ - Không yêu cầu Authenticode hoặc Apple Developer ID để chạy gói Node; xác minh license và chữ ký request vẫn thực hiện. Policy luôn ở server.
66
+
67
+ ## Kiểm tra / đóng gói từ repository
68
+
69
+ ```sh
70
+ npm run start:local
71
+ npm run pack:local
72
+ npm test
73
+ ```
74
+
75
+ `pack:local` chỉ đóng gói thư mục connector theo allowlist; không lấy mã server/engine hoặc dữ liệu vận hành. Package đang private để tránh phát hành nhầm lên npm. Sau khi được phân phối, khách có thể cài trực tiếp từ URL gói tải; không cần public server source.
2
76
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
77
+ ### Kiểm tra phiên bản và cập nhật
78
+
79
+ Từ web.10, góc dưới bên trái trang local hiển thị phiên bản đang chạy và báo khi npm có bản mới. Nhấp vào mục phiên bản để xem hoặc sao chép hai lệnh cập nhật và khởi động lại. Ứng dụng kiểm tra khi mở trang, sau đó mỗi giờ khi trang đang hiển thị; mất mạng sẽ ghi rõ chưa kiểm tra được. Việc kiểm tra không gửi key, tài khoản hay nội dung trò chuyện.
80
+
81
+ Windows:
82
+
83
+ ~~~powershell
84
+ npm.cmd i -g codexunlimit@latest
85
+ codexunlimit.cmd restart
86
+ ~~~
87
+
88
+ macOS/Linux:
89
+
90
+ ~~~sh
91
+ npm i -g codexunlimit@latest
92
+ codexunlimit restart
93
+ ~~~
94
+
95
+ Chờ câu trả lời đang chạy hoàn tất trước khi khởi động lại. Người dùng bản cũ cần chạy hai lệnh trên một lần để nhận mục thông báo phiên bản.
@@ -0,0 +1,161 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import {CodexAccountClient} from './codex-account-client.mjs';
5
+ import {LocalQuota,accountIdentity,rateLimitWindows} from './quota.mjs';
6
+
7
+ const validId=id=>typeof id==='string'&&/^[a-f0-9-]{36}$/.test(id);
8
+ const failure=code=>Object.assign(Error(code),{status:409});
9
+ function readAuth(file){
10
+ if(!fs.existsSync(file)||fs.lstatSync(file).isSymbolicLink()||fs.statSync(file).size>131072)throw failure('pool_account_expired');
11
+ const auth=JSON.parse(fs.readFileSync(file,'utf8').replace(/^\uFEFF/,''));
12
+ if(typeof auth.tokens?.access_token!=='string'||!/^\S{16,32768}$/.test(auth.tokens.access_token)||auth.OPENAI_API_KEY)throw failure('pool_account_expired');
13
+ return auth;
14
+ }
15
+ function credentialsOf(auth){
16
+ const token=auth.tokens.access_token;let claims={};try{claims=JSON.parse(Buffer.from(token.split('.')[1],'base64url'));}catch{}
17
+ const accountId=auth.tokens.account_id??claims['https://api.openai.com/auth']?.chatgpt_account_id;
18
+ return {token,accountId:typeof accountId==='string'?accountId:undefined,expiresAt:typeof claims.exp==='number'?claims.exp*1000:null};
19
+ }
20
+
21
+ export class AccountPool{
22
+ constructor(state,{privateKey,defaultAccount,clientFactory=(home,options)=>new CodexAccountClient(home,options),now=Date.now}={}){
23
+ this.state=state;this.db=state.db;this.defaultAccount=defaultAccount;this.clientFactory=clientFactory;this.now=now;this.pending=null;this.closed=false;this.operations=new Map();this.clients=new Set();this.refreshing=new Set();this.quotaErrors=new Map();
24
+ this.removing=new Set();this.refreshes=new Map();this.key=crypto.createHash('sha256').update(privateKey.export({type:'pkcs8',format:'der'})).update('codexunlimit.account-pool.v1').digest();
25
+ this.directory=path.join(state.home,'accounts');fs.mkdirSync(this.directory,{recursive:true,mode:0o700});if(fs.lstatSync(this.directory).isSymbolicLink())throw failure('pool_storage_invalid');
26
+ this.db.exec('CREATE TABLE IF NOT EXISTS pool_accounts(id TEXT PRIMARY KEY,label TEXT NOT NULL,account_ref TEXT,email TEXT,plan TEXT,secret TEXT,state TEXT NOT NULL,created_at INTEGER NOT NULL,last_error TEXT)');
27
+ this.quota=new LocalQuota(state,{now});
28
+ for(const row of this.rows()){
29
+ const file=path.join(this.profile(row.id),'auth.json');
30
+ if(fs.existsSync(file)){try{this.saveAuth(row.id,readAuth(file));fs.unlinkSync(file);}catch{}}
31
+ if(!row.secret)this.db.prepare("UPDATE pool_accounts SET state='needs_login',last_error='pool_login_interrupted' WHERE id=? AND secret IS NULL").run(row.id);
32
+ }
33
+ }
34
+ rows(){return this.db.prepare('SELECT * FROM pool_accounts ORDER BY created_at,id').all();}
35
+ row(id){if(!validId(id))throw failure('pool_account_missing');const row=this.db.prepare('SELECT * FROM pool_accounts WHERE id=?').get(id);if(!row)throw failure('pool_account_missing');return row;}
36
+ active(){return this.state.get('pool.active')??'codex';}
37
+ profile(id){
38
+ if(id!=='codex'&&!validId(id))throw failure('pool_account_missing');
39
+ const folder=path.join(this.directory,id);if(path.dirname(folder)!==this.directory||fs.existsSync(folder)&&fs.lstatSync(folder).isSymbolicLink())throw failure('pool_storage_invalid');
40
+ fs.mkdirSync(folder,{recursive:true,mode:0o700});return folder;
41
+ }
42
+ encrypt(id,auth){const iv=crypto.randomBytes(12),cipher=crypto.createCipheriv('aes-256-gcm',this.key,iv);cipher.setAAD(Buffer.from(id));const data=Buffer.concat([cipher.update(JSON.stringify(auth),'utf8'),cipher.final()]);return JSON.stringify({iv:iv.toString('base64'),tag:cipher.getAuthTag().toString('base64'),data:data.toString('base64')});}
43
+ decrypt(row){try{const secret=JSON.parse(row.secret),decipher=crypto.createDecipheriv('aes-256-gcm',this.key,Buffer.from(secret.iv,'base64'));decipher.setAAD(Buffer.from(row.id));decipher.setAuthTag(Buffer.from(secret.tag,'base64'));return JSON.parse(Buffer.concat([decipher.update(Buffer.from(secret.data,'base64')),decipher.final()]));}catch{throw failure('pool_account_expired');}}
44
+ saveAuth(id,auth,info={}){
45
+ const identity=accountIdentity(credentialsOf(auth));
46
+ if(this.db.prepare('SELECT id FROM pool_accounts WHERE account_ref=? AND id<>?').get(identity.ref,id))throw failure('pool_account_exists');
47
+ const email=typeof info.email==='string'?info.email.slice(0,254):identity.email,plan=typeof info.planType==='string'?info.planType.slice(0,40):identity.plan;
48
+ this.db.prepare("UPDATE pool_accounts SET secret=?,account_ref=?,email=?,plan=?,state='ready',last_error=NULL WHERE id=?").run(this.encrypt(id,auth),identity.ref,email,plan,id);return identity.ref;
49
+ }
50
+ async defaultCredentials(){return this.defaultAccount();}
51
+ list(){
52
+ let defaultRow={id:'codex',label:'Tài khoản trong Codex',state:'needs_login',windows:[]};
53
+ try{const credentials=this.defaultAccount(),identity=accountIdentity(credentials);defaultRow={...defaultRow,...identity,state:'ready',windows:this.quota.read(identity.ref)};delete defaultRow.ref;delete defaultRow.accountId;}catch{}
54
+ const rows=[defaultRow,...this.rows().map(row=>({id:row.id,label:row.label,email:row.email,plan:row.plan,state:row.state,error:row.last_error,windows:row.account_ref?this.quota.read(row.account_ref):[]}))].map(row=>({...row,active:row.id===this.active(),refreshing:this.refreshing.has(row.id),quotaError:this.quotaErrors.get(row.id)??null}));
55
+ const pending=this.pending?{accountId:this.pending.accountId,status:this.pending.status,type:this.pending.type,url:this.pending.status==='pending'?this.pending.url:undefined,userCode:this.pending.status==='pending'?this.pending.userCode:undefined,error:this.pending.error,expiresAt:this.pending.expiresAt}:null;
56
+ return {rows,activeId:this.active(),pending,maxAccounts:10,now:this.now()};
57
+ }
58
+ async credentials(id=this.active()){
59
+ if(this.removing.has(id))throw failure('pool_account_missing');
60
+ if(id==='codex')return {...await this.defaultCredentials(),poolId:'codex'};
61
+ const row=this.row(id);if(!row.secret||row.state!=='ready')throw failure('pool_account_expired');
62
+ let credentials=credentialsOf(this.decrypt(row));
63
+ if(credentials.expiresAt!==null&&credentials.expiresAt<=this.now()+120000){
64
+ let refresh=this.refreshes.get(id);if(!refresh){refresh=this.withProfile(id,client=>client.request('account/read',{refreshToken:true}));this.refreshes.set(id,refresh);refresh.finally(()=>{if(this.refreshes.get(id)===refresh)this.refreshes.delete(id);}).catch(()=>{});}
65
+ await refresh;credentials=credentialsOf(this.decrypt(this.row(id)));
66
+ }
67
+ if(credentials.expiresAt!==null&&credentials.expiresAt<=this.now())throw failure('pool_account_expired');
68
+ return {...credentials,poolId:id};
69
+ }
70
+ client(home,options={}){const client=this.clientFactory(home,options);this.clients.add(client);return client;}
71
+ async closeClient(client){try{await client.close();}finally{this.clients.delete(client);}}
72
+ withProfile(id,action){
73
+ if(this.closed)return Promise.reject(failure('pool_runtime_unavailable'));if(this.removing.has(id))return Promise.reject(failure('pool_account_missing'));
74
+ const previous=this.operations.get(id)??Promise.resolve();
75
+ const operation=previous.catch(()=>{}).then(async()=>{
76
+ if(this.closed)throw failure('pool_runtime_unavailable');if(this.removing.has(id))throw failure('pool_account_missing');
77
+ const row=this.row(id),folder=this.profile(id),file=path.join(folder,'auth.json');
78
+ if(this.pending?.accountId===id&&this.pending.status==='pending')throw failure('pool_login_pending');
79
+ if(fs.existsSync(file)&&fs.lstatSync(file).isSymbolicLink())throw failure('pool_storage_invalid');
80
+ fs.writeFileSync(file,JSON.stringify(this.decrypt(row)),{mode:0o600});
81
+ let client;
82
+ try{client=this.client(folder);await client.ready;return await action(client);}
83
+ finally{
84
+ if(client)await this.closeClient(client);
85
+ if(fs.existsSync(file)){try{this.saveAuth(id,readAuth(file));}finally{fs.unlinkSync(file);}}
86
+ }
87
+ });
88
+ this.operations.set(id,operation);operation.finally(()=>{if(this.operations.get(id)===operation)this.operations.delete(id);}).catch(()=>{});return operation;
89
+ }
90
+ async select(id){await this.credentials(id);if(this.removing.has(id))throw failure('pool_account_missing');if(id!=='codex')this.row(id);this.state.set('pool.active',id);this.state.set('web.connected',true);return this.list();}
91
+ rename(id,label){if(typeof label!=='string'||!label.trim()||label.length>60)throw failure('invalid_input');this.row(id);this.db.prepare('UPDATE pool_accounts SET label=? WHERE id=?').run(label.trim(),id);return this.list();}
92
+ async remove(id){
93
+ this.row(id);if(this.removing.has(id))throw failure('pool_account_missing');this.removing.add(id);
94
+ try{
95
+ if(this.pending?.accountId===id&&this.pending.status==='pending')await this.cancelLogin();await this.operations.get(id)?.catch(()=>{});
96
+ const folder=this.profile(id);if(path.dirname(fs.realpathSync(folder))!==fs.realpathSync(this.directory))throw failure('pool_storage_invalid');fs.rmSync(folder,{recursive:true,force:true,maxRetries:10,retryDelay:100});
97
+ this.db.prepare('DELETE FROM pool_accounts WHERE id=?').run(id);if(this.active()===id)this.state.set('pool.active','codex');return this.list();
98
+ }finally{this.removing.delete(id);}
99
+ }
100
+ async startLogin({id,type='chatgpt',label='Tài khoản mới'}={}){
101
+ if(this.closed)throw failure('pool_runtime_unavailable');if(this.pending?.status==='pending')throw failure('pool_login_pending');
102
+ if(!['chatgpt','chatgptDeviceCode'].includes(type)||typeof label!=='string'||!label.trim()||label.length>60)throw failure('invalid_input');
103
+ let prior=null;if(id){if(this.removing.has(id))throw failure('pool_account_missing');prior=this.row(id);}else if(this.rows().length>=10)throw failure('pool_full');
104
+ const accountId=id??crypto.randomUUID(),folder=this.profile(accountId),file=path.join(folder,'auth.json');
105
+ await this.operations.get(accountId)?.catch(()=>{});
106
+ if(!prior)this.db.prepare("INSERT INTO pool_accounts(id,label,state,created_at) VALUES(?,?,'pending',?)").run(accountId,label.trim(),this.now());
107
+ const pending={accountId,type,status:'pending',expiresAt:this.now()+10*60000,prior};this.pending=pending;
108
+ let client;
109
+ try{
110
+ client=this.client(folder,{onNotification:(method,params)=>{
111
+ if(method==='account/login/completed'){pending.completion=params;if(pending.loginId&&!pending.task)pending.task=this.finishLogin(pending).catch(()=>{});}
112
+ }});pending.client=client;
113
+ const login=await client.request('account/login/start',{type});
114
+ const url=new URL(login.authUrl??login.verificationUrl);if(url.protocol!=='https:'||!['auth.openai.com','auth0.openai.com'].includes(url.hostname)||url.username||url.password)throw failure('pool_login_unavailable');
115
+ pending.loginId=login.loginId;pending.url=url.href;pending.userCode=typeof login.userCode==='string'?login.userCode.slice(0,64):undefined;
116
+ pending.timer=setTimeout(()=>this.cancelLogin('pool_login_expired').catch(()=>{}),10*60000);pending.timer.unref();
117
+ if(pending.completion&&!pending.task)pending.task=this.finishLogin(pending).catch(()=>{});return this.list();
118
+ }catch(error){if(client)await this.closeClient(client);if(fs.existsSync(file)&&!fs.lstatSync(file).isSymbolicLink())fs.unlinkSync(file);if(!prior)this.db.prepare('DELETE FROM pool_accounts WHERE id=?').run(accountId);pending.status='failed';pending.error='pool_login_unavailable';throw failure('pool_login_unavailable');}
119
+ }
120
+ async finishLogin(pending){
121
+ if(this.closed||pending!==this.pending||pending.status!=='pending'||pending.finishing||pending.completion?.loginId!==pending.loginId)return;
122
+ pending.finishing=true;clearTimeout(pending.timer);const file=path.join(this.profile(pending.accountId),'auth.json');
123
+ try{
124
+ if(!pending.completion.success)throw failure('pool_login_failed');
125
+ const result=await pending.client.request('account/read',{refreshToken:false});if(result.account?.type!=='chatgpt')throw failure('pool_login_failed');
126
+ let limits;try{limits=await pending.client.request('account/rateLimits/read');}catch{}
127
+ await this.closeClient(pending.client);const auth=readAuth(file),ref=this.saveAuth(pending.accountId,auth,result.account);
128
+ if(limits)this.quota.save(ref,rateLimitWindows(limits),'account_api');pending.status='complete';
129
+ }catch(error){pending.status='failed';pending.error=error.message==='pool_account_exists'?'pool_account_exists':'pool_login_failed';await this.closeClient(pending.client);if(!pending.prior)this.db.prepare('DELETE FROM pool_accounts WHERE id=?').run(pending.accountId);}
130
+ finally{if(fs.existsSync(file)&&!fs.lstatSync(file).isSymbolicLink())fs.unlinkSync(file);}
131
+ }
132
+ async cancelLogin(reason=null){
133
+ const pending=this.pending;if(!pending||pending.status!=='pending')return this.list();
134
+ if(pending.finishing)throw failure('pool_login_pending');pending.status='cancelled';pending.error=reason;clearTimeout(pending.timer);
135
+ try{if(pending.loginId)await pending.client.request('account/login/cancel',{loginId:pending.loginId});}catch{}
136
+ await this.closeClient(pending.client);const file=path.join(this.profile(pending.accountId),'auth.json');if(fs.existsSync(file)&&!fs.lstatSync(file).isSymbolicLink())fs.unlinkSync(file);
137
+ if(!pending.prior)this.db.prepare('DELETE FROM pool_accounts WHERE id=?').run(pending.accountId);return this.list();
138
+ }
139
+ async refreshQuota(id=this.active()){
140
+ if(this.refreshing.has(id))return this.list();if(id!=='codex')this.row(id);
141
+ this.refreshing.add(id);this.quotaErrors.delete(id);
142
+ try{
143
+ let credentials,limits;
144
+ if(id==='codex'){
145
+ credentials=await this.defaultCredentials();if(!credentials.accountId)throw failure('pool_quota_unavailable');
146
+ const client=this.client(this.profile('codex'),{externalCredentials:()=>this.defaultCredentials()});
147
+ try{await client.request('account/login/start',{type:'chatgptAuthTokens',accessToken:credentials.token,chatgptAccountId:credentials.accountId});limits=await client.request('account/rateLimits/read');}finally{await this.closeClient(client);}
148
+ }else{
149
+ credentials=await this.credentials(id);limits=await this.withProfile(id,client=>client.request('account/rateLimits/read'));
150
+ }
151
+ const windows=rateLimitWindows(limits);if(!windows.length)throw failure('pool_quota_unavailable');this.quota.save(accountIdentity(credentials).ref,windows,'account_api');
152
+ }catch(error){this.quotaErrors.set(id,error.message==='pool_account_expired'?'pool_account_expired':'pool_quota_unavailable');throw failure(this.quotaErrors.get(id));}
153
+ finally{this.refreshing.delete(id);}
154
+ return this.list();
155
+ }
156
+ observe(credentials,response){try{this.quota.observe(credentials,response);}catch{}}
157
+ async close(){
158
+ if(this.pending?.status==='pending'&&!this.pending.finishing)await this.cancelLogin().catch(()=>{});
159
+ this.closed=true;await Promise.allSettled([...this.clients].map(client=>this.closeClient(client)));await Promise.allSettled([...this.operations.values(),this.pending?.task]);this.key.fill(0);
160
+ }
161
+ }
package/app.mjs ADDED
@@ -0,0 +1,161 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import {Readable} from 'node:stream';
5
+ import {pipeline} from 'node:stream/promises';
6
+ import {syncCodex,unsyncCodex,codexConnection} from './config.mjs';
7
+ import {errors} from './web/messages.js';
8
+ import {VERSION} from './product.mjs';
9
+ import {createUpdateChecker} from './updates.mjs';
10
+ import {boundedJson} from './io.mjs';
11
+ import {fileURLToPath} from 'node:url';
12
+
13
+ const random=()=>crypto.randomBytes(32).toString('base64url');
14
+ const digest=value=>crypto.createHash('sha256').update(value).digest('hex');
15
+ const fail=(code,status=400)=>Object.assign(Error(code),{status});
16
+ export function createBrowserTicket(state){
17
+ const ticket=random();state.set('web.ticket',{hash:digest(ticket),expires:Date.now()+120000});return ticket;
18
+ }
19
+ export function readCodexAccount(state){
20
+ try{
21
+ const file=path.join(state.get('codex_home'),'auth.json');
22
+ if(fs.statSync(file).size>131072)throw Error();
23
+ const auth=JSON.parse(fs.readFileSync(file,'utf8')),token=auth.tokens?.access_token;
24
+ if(typeof token!=='string'||!/^\S{16,32768}$/.test(token))throw Error();
25
+ let claims={};try{claims=JSON.parse(Buffer.from(token.split('.')[1],'base64url'));}catch{}
26
+ if(claims.exp&&claims.exp*1000<=Date.now())throw fail('account_expired');
27
+ const accountId=auth.tokens.account_id??claims['https://api.openai.com/auth']?.chatgpt_account_id;
28
+ return {token,accountId:typeof accountId==='string'?accountId:undefined};
29
+ }catch(e){throw fail(e.message==='account_expired'?'account_expired':'account_required',409);}
30
+ }
31
+ async function body(req){
32
+ if(!/^application\/json(?:;|$)/i.test(req.headers['content-type']??''))throw fail('json_required',415);
33
+ if(Number(req.headers['content-length']??0)>1048576){req.resume();throw fail('request_too_large',413);}
34
+ let size=0;const chunks=[];
35
+ for await(const chunk of req){size+=chunk.length;if(size>1048576)throw fail('request_too_large',413);chunks.push(chunk);}
36
+ try{const data=JSON.parse(Buffer.concat(chunks));if(!data||typeof data!=='object'||Array.isArray(data))throw Error();return data;}catch{throw fail('invalid_input');}
37
+ }
38
+ export function createLocalApp(state,{license,request=fetch,account=()=>readCodexAccount(state),desktop,pool,checkUpdates=createUpdateChecker(),onStop=()=>{}}={}){
39
+ const sessions=new Map(),assets={'/app/':'index.html','/app/app.js':'app.js','/app/style.css':'style.css','/app/stream.js':'stream.js','/app/messages.js':'messages.js'};
40
+ assets['/app/updates.js']='updates.js';
41
+ assets['/app/markdown.js']='markdown.js';assets['/app/pool-ui.js']='pool-ui.js';
42
+ assets['/app/vendor/markdown-it.js']=fileURLToPath(import.meta.resolve('markdown-it/browser'));
43
+ for(const name of fs.readdirSync(path.join(import.meta.dirname,'web/assets')))if(/^[a-z0-9.-]+\.(woff2|css|txt|svg|png|ico)$/.test(name))assets['/app/assets/'+name]='assets/'+name;
44
+ return async(req,res,server)=>{
45
+ const url=new URL(req.url,'http://127.0.0.1');
46
+ if(!url.pathname.startsWith('/app'))return false;
47
+ const headers={'cache-control':'no-store','x-content-type-options':'nosniff','referrer-policy':'no-referrer','x-frame-options':'DENY',
48
+ 'content-security-policy':"default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; font-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"};
49
+ const json=(status,data,extra={})=>{res.writeHead(status,{...headers,'content-type':'application/json; charset=utf-8',...extra});res.end(JSON.stringify(data));};
50
+ try{
51
+ const origin='http://'+req.headers.host;
52
+ // External links may navigate to the public shell; APIs still require same-origin access.
53
+ const entryNavigation=req.method==='GET'&&(url.pathname==='/app'||url.pathname==='/app/')&&
54
+ req.headers['sec-fetch-mode']==='navigate'&&req.headers['sec-fetch-dest']==='document';
55
+ if((req.headers.origin&&req.headers.origin!==origin)||(req.headers['sec-fetch-site']==='cross-site'&&!entryNavigation))throw fail('local_request_denied',403);
56
+ if(req.method==='GET'&&url.pathname==='/app'){res.writeHead(302,{...headers,location:'/app/'});res.end();return true;}
57
+ if(req.method==='GET'&&assets[url.pathname]){
58
+ const file=assets[url.pathname],type=file.endsWith('.html')?'text/html':file.endsWith('.css')?'text/css':file.endsWith('.svg')?'image/svg+xml':file.endsWith('.png')?'image/png':file.endsWith('.ico')?'image/x-icon':file.endsWith('.woff2')?'font/woff2':file.endsWith('.txt')?'text/plain':'text/javascript';
59
+ res.writeHead(200,{...headers,'content-type':type+'; charset=utf-8'});res.end(fs.readFileSync(path.isAbsolute(file)?file:path.join(import.meta.dirname,'web',file)));return true;
60
+ }
61
+ const write=req.method==='POST';
62
+ if(!['GET','POST'].includes(req.method))throw fail('method_not_allowed',405);
63
+ if(write&&req.headers.origin!==origin)throw fail('local_request_denied',403);
64
+ for(const [key,s]of sessions)if(s.expires<Date.now())sessions.delete(key);
65
+ if(url.pathname==='/app/api/session'&&write){
66
+ const input=await body(req),ticket=state.get('web.ticket');
67
+ if(typeof input.ticket!=='string'||input.ticket.length!==43||!ticket||ticket.expires<Date.now()||digest(input.ticket)!==ticket.hash)throw fail('open_from_command',401);
68
+ state.set('web.ticket',null);
69
+ const token=random(),s={csrf:random(),expires:Date.now()+8*3600000};
70
+ if(sessions.size>=8)sessions.delete(sessions.keys().next().value);
71
+ sessions.set(digest(token),s);json(200,{csrf:s.csrf},{'set-cookie':'cu-local='+token+'; Path=/app; HttpOnly; SameSite=Strict; Max-Age=28800'});return true;
72
+ }
73
+ const token=String(req.headers.cookie??'').split(';').map(x=>x.trim()).find(x=>x.startsWith('cu-local='))?.slice(9);
74
+ const session=token&&sessions.get(digest(token));if(!session)throw fail('open_from_command',401);
75
+ if(write&&req.headers['x-csrf-token']!==session.csrf)throw fail('session_expired',403);
76
+ const route=url.pathname.slice('/app/api/'.length);
77
+ if(!write&&route==='updates'){json(200,await checkUpdates());return true;}
78
+ if(!write&&route==='status'){
79
+ // A short-lived lease expiring does not mean the purchased service expired.
80
+ let licenseError=null;
81
+ if(state.get('license.activation'))try{await license.ensure();}catch(e){licenseError=e.message;}
82
+ const accounts=pool?.list()??null;
83
+ let connected=false;if(state.get('web.connected'))try{if(accounts)connected=accounts.rows.some(row=>row.active&&row.state==='ready');else{account();connected=true;}}catch{}
84
+ const usage=state.db.prepare('SELECT COUNT(*) total,COALESCE(SUM(status>=400),0) errors FROM desktop_usage').get();
85
+ const codex=codexConnection(state);if(codex.error&&!errors[codex.error])codex.error='operation_failed';
86
+ json(200,{version:VERSION,csrf:session.csrf,license:{...license.status(),...(licenseError?{error:errors[licenseError]?licenseError:'service_unavailable'}:{})},connected,codexConfigured:codex.configured,codex,desktop:desktop?.status()??{supported:false},accounts,usage,
87
+ recent:state.db.prepare('SELECT ts,status,duration_ms FROM desktop_usage ORDER BY id DESC LIMIT 12').all()});return true;
88
+ }
89
+ const input=write?await body(req):{};
90
+ if(route==='accounts'&&!write){if(!pool)throw fail('pool_runtime_unavailable',503);json(200,pool.list());return true;}
91
+ if(write&&(route.startsWith('accounts/')||route==='quota/refresh')){
92
+ if(!pool)throw fail('pool_runtime_unavailable',503);
93
+ let result;
94
+ if(route==='accounts/login'){
95
+ if(input.accepted!==true)throw fail('privacy_required');state.set('web.privacyAcceptedAt',Date.now());
96
+ result=await pool.startLogin({id:input.accountId,type:input.type,label:input.label});
97
+ }else if(route==='accounts/cancel')result=await pool.cancelLogin();
98
+ else if(route==='accounts/select'){if(!state.get('web.privacyAcceptedAt'))throw fail('privacy_required');result=await pool.select(input.accountId);}
99
+ else if(route==='accounts/remove')result=await pool.remove(input.accountId);
100
+ else if(route==='accounts/rename')result=pool.rename(input.accountId,input.label);
101
+ else if(route==='quota/refresh')result=await pool.refreshQuota(input.accountId);
102
+ else throw fail('route_not_found',404);
103
+ json(200,result);return true;
104
+ }
105
+ if(write&&route==='activate'){await license.activate(input.key);json(200,{ok:true});}
106
+ else if(write&&route==='renew'){await license.ensure('desktop',{online:true});json(200,{ok:true});}
107
+ else if(write&&route==='connect'){
108
+ if(input.accepted!==true)throw Object.assign(Error('privacy_required'),{status:400});
109
+ if(pool)await pool.credentials();else account();state.set('web.connected',true);state.set('web.privacyAcceptedAt',Date.now());json(200,{ok:true});
110
+ }
111
+ else if(write&&route==='disconnect'){state.set('web.connected',false);json(200,{ok:true});}
112
+ else if(write&&route==='sync'){if(!state.get('web.privacyAcceptedAt'))throw Object.assign(Error('privacy_required'),{status:400});await license.ensure();json(200,syncCodex(state));}
113
+ else if(write&&route==='unsync'){json(200,unsyncCodex(state));}
114
+ else if(write&&route==='desktop/startup'){
115
+ if(typeof input.enabled!=='boolean')throw fail('invalid_input');
116
+ if(!desktop)throw fail('startup_unavailable',409);
117
+ await desktop.setStartup(input.enabled);json(200,desktop.status());
118
+ }
119
+ else if(write&&route==='desktop/tray'){
120
+ if(!desktop)throw fail('tray_unavailable',409);
121
+ await desktop.showTray();json(200,desktop.status());
122
+ }
123
+ else if(write&&route==='stop'){
124
+ if(input.mode!=='exit'&&input.mode!=='update')throw fail('invalid_input');
125
+ if(input.mode==='exit')unsyncCodex(state);
126
+ json(200,{stopped:true});setImmediate(onStop);
127
+ }
128
+ else if((!write&&route==='models')||(write&&route==='chat')){
129
+ if(!state.get('web.connected'))throw fail('account_required',409);
130
+ const credentials=pool?await pool.credentials((write?input.accountId:url.searchParams.get('accountId'))??pool.active()):account();let payload;
131
+ if(write){
132
+ if(typeof input.model!=='string'||!input.model.length||input.model.length>160||!Array.isArray(input.messages)||!input.messages.length||input.messages.length>100||
133
+ input.messages.some(m=>!m||!['user','assistant'].includes(m.role)||typeof m.content!=='string'||!m.content.trim()||m.content.length>(m.role==='user'?32000:256000))||input.messages.at(-1).role!=='user')throw fail('invalid_message');
134
+ payload={model:input.model,stream:true,store:false,instructions:'Respond helpfully to the user.',input:input.messages.map(m=>({role:m.role,content:[{type:m.role==='assistant'?'output_text':'input_text',text:m.content}]}))};
135
+ }
136
+ const controller=new AbortController();res.on('close',()=>controller.abort());
137
+ const upstream=await request('http://127.0.0.1:'+server.address().port+(write?'/v1/responses':'/v1/models'),{
138
+ method:write?'POST':'GET',headers:{authorization:'Bearer '+credentials.token,'content-type':'application/json',...(pool?{'x-cu-control':state.get('control_token')}:{}) ,...(credentials.accountId?{'chatgpt-account-id':credentials.accountId}:{})},
139
+ body:write?JSON.stringify(payload):undefined,redirect:'error',signal:AbortSignal.any([controller.signal,AbortSignal.timeout(600000)])});
140
+ if(!upstream.ok){
141
+ let code;try{code=(await boundedJson(upstream,16384)).error?.code;}catch{}
142
+ const reason=upstream.status===426?'connector_update_required':['request_body_rejected','request_proof_rejected','request_body_unverified'].includes(code)?'request_integrity_failed':upstream.status===401?'account_expired':upstream.status===429?'usage_limit':upstream.status===403?'activation_required':'service_unavailable';
143
+ throw fail(reason,upstream.status);
144
+ }
145
+ if(write){res.writeHead(200,{...headers,'content-type':'text/event-stream; charset=utf-8'});await pipeline(Readable.fromWeb(upstream.body),res,{signal:controller.signal});}
146
+ else{
147
+ const data=await boundedJson(upstream,2*1024*1024),list=data?.models??data?.data??[];
148
+ if(!Array.isArray(list))throw fail('models_unavailable',503);
149
+ json(200,{models:list.filter(m=>m&&typeof m==='object'&&m.visibility!=='hide').map(m=>({id:m.slug??m.id,name:m.display_name??m.slug??m.id})).filter(m=>typeof m.id==='string'&&m.id.length<=160&&typeof m.name==='string'&&m.name.length<=160).slice(0,256)});
150
+ }
151
+ }else throw fail('route_not_found',404);
152
+ }catch(error){
153
+ const configuration=/^\/app\/api\/(?:sync|unsync|stop)$/.test(url.pathname);
154
+ const code=configuration&&['EACCES','EPERM','EBUSY'].includes(error.code)?'config_permission_denied':configuration&&error.code==='ENOENT'?'config_missing':error.message;
155
+ const publicCode=errors[code]?code:['json_required','request_too_large','invalid_input','local_request_denied','method_not_allowed','route_not_found'].includes(code)?code:'operation_failed';
156
+ const badInput=['invalid_activation_key','license_invalid_or_expired','license_revoked','device_limit_reached','activation_required'].includes(publicCode);
157
+ if(res.headersSent)res.destroy();else json(error.status??(configuration?409:badInput?400:503),{error:publicCode},error.retryAfter?{'retry-after':String(error.retryAfter)}:{});
158
+ }
159
+ return true;
160
+ };
161
+ }
package/cli.mjs ADDED
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import crypto from 'node:crypto';
6
+ import {spawn} from 'node:child_process';
7
+ import {fileURLToPath} from 'node:url';
8
+ import {ClientState} from './state.mjs';
9
+ import {LicenseClient} from './license-client.mjs';
10
+ import {createConnector} from './relay.mjs';
11
+ import {createLocalApp,createBrowserTicket,readCodexAccount} from './app.mjs';
12
+ import {AccountPool} from './account-pool.mjs';
13
+ import {DEFAULT_PORT,VERSION} from './product.mjs';
14
+ import {unsyncCodex} from './config.mjs';
15
+ import {createWindowsIntegration} from './windows.mjs';
16
+ import {errors} from './web/messages.js';
17
+ import {diagnoseRuntime} from './doctor.mjs';
18
+
19
+ const args=process.argv.slice(2),command=args[0]&&!args[0].startsWith('--')?args[0]:'open';
20
+ function option(name,fallback){const i=args.indexOf(name);if(i<0)return fallback;if(!args[i+1]||args[i+1].startsWith('--'))throw Error('Thiếu giá trị cho '+name);return args[i+1];}
21
+ const home=path.resolve(option('--home',process.platform==='win32'?path.join(process.env.LOCALAPPDATA??path.join(os.homedir(),'AppData/Local'),'CodexunlimitWeb'):process.platform==='darwin'?path.join(os.homedir(),'Library/Application Support/CodexunlimitWeb'):path.join(os.homedir(),'.local/share/codexunlimit')));
22
+ async function main(){
23
+ if(args.includes('--version')){console.log(VERSION);return;}
24
+ if(command==='doctor'){
25
+ const result=await diagnoseRuntime();if(args.includes('--json'))console.log(JSON.stringify(result));else{for(const check of result.checks)console.log((check.ok?'OK ':'THIẾU ')+check.name+(check.version?' '+check.version:''));console.log(result.ok?'Môi trường đã sẵn sàng. Key/tài khoản được kiểm tra trong ứng dụng.':'Cài lại gói npm, giữ dependency tùy chọn. Không cần xóa key, đăng xuất ChatGPT hoặc reset thiết bị.');}if(!result.ok)process.exitCode=1;return;
26
+ }
27
+ if(args.includes('--help')){console.log('Codex Unlimit\n codexunlimit doctor Kiểm tra Node, SQLite và Codex runtime\n codexunlimit Mở ứng dụng và khay Windows\n codexunlimit stop Khôi phục kết nối Codex và tắt ứng dụng\n codexunlimit restart Khởi động lại bằng bản đã cài mới nhất\n codexunlimit startup on Tự mở trong khay khi đăng nhập Windows\n codexunlimit startup off Tắt khởi động cùng Windows\n codexunlimit --no-open Chạy nền, không mở trình duyệt\n --no-tray Không mở khay (dành cho kiểm thử)\n --home <thư mục> Dữ liệu riêng\n --port <số cổng> Cổng cho lần cài đầu');return;}
28
+ if(!['open','serve','stop','restart','startup'].includes(command))throw Error('Lệnh chưa đúng. Xem codexunlimit --help');
29
+ const state=new ClientState(home);let serving=false;
30
+ try{
31
+ const port=Number(option('--port',DEFAULT_PORT));if(!Number.isInteger(port)||port<1024||port>65535)throw Error('Cổng cần nằm trong khoảng 1024–65535.');
32
+ state.initialize({port,codexHome:state.get('codex_home')??process.env.CODEX_HOME??path.join(os.homedir(),'.codex')});
33
+ const desktop=createWindowsIntegration(state);
34
+ if(command==='startup'){
35
+ if(!['on','off'].includes(args[1]))throw Error('Dùng codexunlimit startup on hoặc codexunlimit startup off.');
36
+ await desktop.setStartup(args[1]==='on');console.log(args[1]==='on'?'Đã bật khởi động cùng Windows.':'Đã tắt khởi động cùng Windows.');return;
37
+ }
38
+ const base='http://127.0.0.1:'+state.settings.get('relay.port');
39
+ async function health(){try{const r=await fetch(base+'/healthz',{signal:AbortSignal.timeout(1000)});return await r.json();}catch{return null;}}
40
+ async function ownInstance(){
41
+ const nonce=crypto.randomBytes(24).toString('hex'),expected=crypto.createHmac('sha256',state.get('control_token')).update('cu-instance:'+nonce).digest('hex');
42
+ const response=await fetch(base+'/control/identity',{headers:{'x-cu-nonce':nonce},signal:AbortSignal.timeout(1500)});
43
+ const value=await response.json();if(value.proof!==expected)throw Error('Cổng đang chạy một phiên khác. Dừng bản cũ trước hoặc chọn cổng khác.');
44
+ }
45
+ if(command==='serve'){
46
+ const license=new LicenseClient(state);
47
+ const pool=new AccountPool(state,{privateKey:license.device.privateKey,defaultAccount:()=>readCodexAccount(state)});
48
+ const server=createConnector(state,{license,pool,localApp:createLocalApp(state,{license,desktop,pool,onStop:shutdown}),prepareStop:mode=>{if(mode==='exit')unsyncCodex(state);},onStop:shutdown});
49
+ let stopping=false;
50
+ function shutdown(){if(stopping)return;stopping=true;server.close(async()=>{try{await pool.close();}finally{state.close();process.exit(0);}});server.closeIdleConnections();setTimeout(()=>server.closeAllConnections(),1500).unref();}
51
+ server.listen(state.settings.get('relay.port'),'127.0.0.1');await new Promise((resolve,reject)=>{server.once('listening',resolve);server.once('error',reject);});
52
+ serving=true;process.once('SIGINT',shutdown);process.once('SIGTERM',shutdown);
53
+ if(desktop.status().startup)desktop.setStartup(true).catch(()=>console.error(errors.startup_update_failed));
54
+ if(process.platform==='win32'&&!args.includes('--no-tray'))desktop.showTray().catch(()=>console.error(errors.tray_unavailable));
55
+ return;
56
+ }
57
+ let existing=await health();
58
+ if(existing&&(existing.service!=='codexunlimit-connector'||existing.mode!=='desktop'))throw Error('Cổng đang được ứng dụng khác sử dụng. Hãy chọn --port khác với --home riêng.');
59
+ // Compatibility for customers who installed the update before stopping web.1.
60
+ if(existing&&!(command==='stop'&&existing.version==='0.1.0-web.1'))await ownInstance();
61
+ async function stopInstance(mode){
62
+ if(mode==='exit')unsyncCodex(state);
63
+ if(!existing)return;
64
+ const response=await fetch(base+'/control/stop',{method:'POST',headers:{'x-cu-control':state.get('control_token'),'x-cu-stop-mode':mode},signal:AbortSignal.timeout(5000)});
65
+ if(!response.ok)throw Error('Chưa tắt được phiên đang chạy. Mở Cài đặt & hỗ trợ để kiểm tra kết nối.');
66
+ for(let attempt=0;attempt<50;attempt++){
67
+ if(!await health()){existing=null;return;}
68
+ await new Promise(resolve=>setTimeout(resolve,100));
69
+ }
70
+ throw Error('Phiên cũ chưa tắt hoàn toàn. Chờ một lát rồi mở lại Codex Unlimit.');
71
+ }
72
+ if(command==='stop'){await stopInstance('exit');console.log('Codex Unlimit đã tắt. Kết nối Codex đã được khôi phục.');return;}
73
+ if(existing&&(command==='restart'||existing.version!==VERSION)){
74
+ console.log('Đang khởi động lại Codex Unlimit '+VERSION+'…');await stopInstance('update');
75
+ }
76
+ if(existing){const page=await fetch(base+'/app/',{signal:AbortSignal.timeout(2000)});if(!page.ok)throw Error('Bản cũ đang chạy. Dừng Codex Unlimit cũ trước khi mở phiên bản web.');}
77
+ else{
78
+ const logs=path.join(home,'logs');fs.mkdirSync(logs,{recursive:true,mode:0o700});
79
+ const output=fs.openSync(path.join(logs,'web.log'),'a',0o600);
80
+ const child=spawn(process.execPath,[fileURLToPath(import.meta.url),'serve','--home',home,...(args.includes('--no-tray')?['--no-tray']:[])],{detached:true,windowsHide:true,stdio:['ignore',output,output]});
81
+ let launchError=false;child.once('error',()=>{launchError=true;});
82
+ child.unref();fs.closeSync(output);
83
+ for(let i=0;i<100;i++){existing=await health();if(existing||launchError)break;await new Promise(r=>setTimeout(r,150));}
84
+ if(!existing)throw Error('Chưa mở được dịch vụ. Xem '+path.join(logs,'web.log'));
85
+ await ownInstance();
86
+ }
87
+ const url=base+'/app/#ticket='+createBrowserTicket(state);
88
+ console.log('Codex Unlimit đang chạy trên máy của bạn.\n'+url+'\nGiữ đường dẫn này riêng tư; liên kết mở trang có hiệu lực 2 phút.');
89
+ if(!args.includes('--no-open')){
90
+ // Keep the complete local URL (including its ticket) out of shell source.
91
+ const windows=process.platform==='win32';
92
+ const exe=windows?path.join(process.env.SystemRoot??'C:/Windows','System32/WindowsPowerShell/v1.0/powershell.exe'):process.platform==='darwin'?'open':'xdg-open';
93
+ const launchArgs=windows?['-NoProfile','-NonInteractive','-WindowStyle','Hidden','-Command',"$ErrorActionPreference='Stop'; Start-Process -FilePath $env:CU_BROWSER_URL"]:[url];
94
+ try{
95
+ await new Promise((resolve,reject)=>{
96
+ const child=spawn(exe,launchArgs,{windowsHide:true,stdio:'ignore',env:{...process.env,CU_BROWSER_URL:url}});
97
+ const timeout=setTimeout(()=>{child.kill();reject(Error('browser_open_timeout'));},15000);
98
+ child.once('error',error=>{clearTimeout(timeout);reject(error);});
99
+ child.once('exit',code=>{clearTimeout(timeout);code===0?resolve():reject(Error('browser_open_failed'));});
100
+ });
101
+ }catch{
102
+ console.error('Không tự mở được trình duyệt. Sao chép đường dẫn local ở trên vào trình duyệt.');
103
+ process.exitCode=1;
104
+ }
105
+ }
106
+ }finally{if(!serving)state.close();}
107
+ }
108
+ main().catch(error=>{console.error(errors[error.message]??error.message);process.exitCode=1;});
@@ -0,0 +1,53 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import {createRequire} from 'node:module';
4
+ import {spawn} from 'node:child_process';
5
+ import {VERSION} from './product.mjs';
6
+
7
+ export function codexExecutable(){
8
+ const require=createRequire(import.meta.url),name='@openai/codex-'+process.platform+'-'+process.arch;
9
+ const targets={linux:{x64:'x86_64-unknown-linux-musl',arm64:'aarch64-unknown-linux-musl'},darwin:{x64:'x86_64-apple-darwin',arm64:'aarch64-apple-darwin'},win32:{x64:'x86_64-pc-windows-msvc',arm64:'aarch64-pc-windows-msvc'}};
10
+ const target=targets[process.platform]?.[process.arch];if(!target)throw Error('pool_platform_unavailable');
11
+ try{
12
+ const resolve=createRequire(require.resolve('@openai/codex/package.json'));
13
+ const entry=path.join(path.dirname(resolve.resolve(name+'/package.json')),'vendor',target,'bin',process.platform==='win32'?'codex.exe':'codex');
14
+ if(!fs.existsSync(entry))throw Error();return entry;
15
+ }catch{throw Error('pool_runtime_missing');}
16
+ }
17
+
18
+ export class CodexAccountClient{
19
+ constructor(home,{onNotification=()=>{},externalCredentials,spawnProcess=spawn,executable=codexExecutable(),timeout=30000}={}){
20
+ this.pending=new Map();this.nextId=0;this.closed=false;this.buffer='';this.timeout=timeout;this.onNotification=onNotification;
21
+ const env={...process.env,CODEX_HOME:home};for(const name of ['OPENAI_API_KEY','CODEX_API_KEY','OPENAI_BASE_URL','OPENAI_ORG_ID','OPENAI_ORGANIZATION','CODEX_API_BASE_URL','CHATGPT_BASE_URL'])delete env[name];
22
+ this.process=spawnProcess(executable,['-c','cli_auth_credentials_store="'+(externalCredentials?'ephemeral':'file')+'"','-c','forced_login_method="chatgpt"','app-server','--listen','stdio://'],{cwd:home,env,windowsHide:true,stdio:['pipe','pipe','pipe']});
23
+ this.exited=new Promise(resolve=>{this.process.once('close',()=>{this.fail();resolve();});this.process.once('error',()=>{this.fail();resolve();});});
24
+ this.process.stderr.resume();this.process.stdin.on('error',()=>this.fail());
25
+ this.process.stdout.setEncoding('utf8');this.process.stdout.on('data',chunk=>{
26
+ this.buffer+=chunk;if(this.buffer.length>2*1024*1024){this.fail();this.process.kill();return;}
27
+ let boundary;
28
+ while((boundary=this.buffer.indexOf('\n'))!==-1){
29
+ const line=this.buffer.slice(0,boundary);this.buffer=this.buffer.slice(boundary+1);if(!line.trim())continue;
30
+ let message;try{message=JSON.parse(line);}catch{this.fail();this.process.kill();return;}
31
+ if(message.id!==undefined&&!message.method){const waiting=this.pending.get(message.id);if(!waiting)continue;clearTimeout(waiting.timer);this.pending.delete(message.id);message.error?waiting.reject(Error('pool_provider_rejected')):waiting.resolve(message.result);}
32
+ else if(message.method&&message.id!==undefined){
33
+ if(message.method==='account/chatgptAuthTokens/refresh'&&externalCredentials){Promise.resolve().then(externalCredentials).then(credentials=>this.write({id:message.id,result:{accessToken:credentials.token,chatgptAccountId:credentials.accountId}})).catch(()=>this.write({id:message.id,error:{code:-32000,message:'account_refresh_required'}}));}
34
+ else this.write({id:message.id,error:{code:-32601,message:'unsupported_request'}});
35
+ }else if(message.method)Promise.resolve().then(()=>this.onNotification(message.method,message.params)).catch(()=>{});
36
+ }
37
+ });
38
+ this.ready=this.call('initialize',{clientInfo:{name:'codex_unlimit_accounts',title:'Codex Unlimit',version:VERSION},capabilities:{experimentalApi:!!externalCredentials}}).then(()=>this.write({method:'initialized',params:{}}));
39
+ this.ready.catch(()=>{});
40
+ }
41
+ write(message){if(!this.closed&&!this.process.stdin.destroyed)this.process.stdin.write(JSON.stringify(message)+'\n');}
42
+ call(method,params={}){
43
+ if(this.closed)return Promise.reject(Error('pool_runtime_unavailable'));
44
+ return new Promise((resolve,reject)=>{const id=++this.nextId,timer=setTimeout(()=>{this.pending.delete(id);reject(Error('pool_request_timeout'));},this.timeout);timer.unref();this.pending.set(id,{resolve,reject,timer});this.write({id,method,params});});
45
+ }
46
+ async request(method,params={}){await this.ready;return this.call(method,params);}
47
+ fail(){this.closed=true;for(const waiting of this.pending.values()){clearTimeout(waiting.timer);waiting.reject(Error('pool_runtime_unavailable'));}this.pending.clear();}
48
+ async close(){
49
+ if(!this.closed)this.process.stdin.end();
50
+ const timeout=setTimeout(()=>this.process.kill(),1500);timeout.unref();
51
+ await this.exited;clearTimeout(timeout);this.fail();
52
+ }
53
+ }