Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions background.js
Original file line number Diff line number Diff line change
Expand Up @@ -928,6 +928,9 @@ function getStepDefinitionsForState(state = {}) {
plusModeEnabled: Boolean(resolvedState?.plusModeEnabled),
plusPaymentMethod: normalizePlusPaymentMethod(resolvedState?.plusPaymentMethod),
plusAccountAccessStrategy: normalizePlusAccountAccessStrategy(resolvedState?.plusAccountAccessStrategy),
...(String(resolvedState?.sub2apiImportMode || '').trim().toLowerCase() === 'agent_identity'
? { sub2apiImportMode: 'agent_identity' }
: {}),
signupMethod: getSignupMethodForStepDefinitions(resolvedState),
phoneVerificationEnabled: Boolean(resolvedState?.phoneVerificationEnabled),
phoneSignupReloginAfterBindEmailEnabled: Boolean(resolvedState?.phoneSignupReloginAfterBindEmailEnabled),
Expand Down Expand Up @@ -1233,6 +1236,7 @@ const PERSISTED_SETTING_DEFAULTS = {
sub2apiUrl: DEFAULT_SUB2API_URL,
sub2apiEmail: '',
sub2apiPassword: '',
sub2apiImportMode: 'oauth',
sub2apiGroupName: DEFAULT_SUB2API_GROUP_NAME,
sub2apiGroupNames: DEFAULT_SUB2API_GROUP_NAMES,
sub2apiAccountPriority: DEFAULT_SUB2API_ACCOUNT_PRIORITY,
Expand Down Expand Up @@ -1399,6 +1403,7 @@ const SETTINGS_SCHEMA_VIEW_KEYS = Object.freeze([
'sub2apiUrl',
'sub2apiEmail',
'sub2apiPassword',
'sub2apiImportMode',
'sub2apiGroupName',
'sub2apiGroupNames',
'sub2apiAccountPriority',
Expand Down Expand Up @@ -1442,7 +1447,7 @@ const SETTINGS_SCHEMA_VIEW_KEYS = Object.freeze([
const SETTINGS_SCHEMA_VIEW_KEY_SET = new Set(SETTINGS_SCHEMA_VIEW_KEYS);
const SETTINGS_EXPORT_SCHEMA_VERSION = 1;
const SETTINGS_EXPORT_FILENAME_PREFIX = 'multipage-settings';
const STEP6_REGISTRATION_SUCCESS_WAIT_MS = 20000;
const STEP6_REGISTRATION_SUCCESS_WAIT_MS = 1000;

const DEFAULT_STATE = {
flowId: DEFAULT_ACTIVE_FLOW_ID,
Expand Down Expand Up @@ -3218,6 +3223,8 @@ function normalizePersistentSettingValue(key, value) {
return String(value || '').trim();
case 'sub2apiPassword':
return String(value || '');
case 'sub2apiImportMode':
return String(value || '').trim().toLowerCase() === 'agent_identity' ? 'agent_identity' : 'oauth';
case 'sub2apiGroupName':
return String(value || '').trim();
case 'sub2apiGroupNames':
Expand Down Expand Up @@ -12268,7 +12275,7 @@ const AUTO_RUN_NODE_DELAYS = Object.freeze({
'fill-password': 3000,
'fetch-signup-code': 2000,
'fill-profile': 0,
'wait-registration-success': 3000,
'wait-registration-success': 0,
'plus-checkout-create': 3000,
'paypal-hosted-openai-checkout': 2000,
'paypal-hosted-email': 2000,
Expand Down Expand Up @@ -14211,6 +14218,7 @@ const stepExecutorsByKey = {
'paypal-approve': (state) => payPalApproveExecutor.executePayPalApprove(state),
'plus-checkout-return': (state) => plusReturnConfirmExecutor.executePlusReturnConfirm(state),
'sub2api-session-import': (state) => sub2ApiSessionImportExecutor.executeSub2ApiSessionImport(state),
'sub2api-agent-identity-import': (state) => sub2ApiSessionImportExecutor.executeSub2ApiAgentIdentityImport(state),
'cpa-session-import': (state) => cpaSessionImportExecutor.executeCpaSessionImport(state),
'openai-upload-session-to-webchat': (state) => openAiWebchatPublisher.executeOpenAiUploadSessionToWebchat(state),
'openai-upload-session-to-chatgpt2api': (state) => openAiChatgpt2ApiPublisher.executeOpenAiUploadSessionToChatgpt2Api(state),
Expand Down
157 changes: 157 additions & 0 deletions background/sub2api-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
fetchImpl = (...args) => fetch(...args),
setTimeoutImpl = (...args) => setTimeout(...args),
clearTimeoutImpl = (...args) => clearTimeout(...args),
cryptoImpl = globalThis.crypto,
} = deps;

const DEFAULT_REDIRECT_URI = 'http://localhost:1455/auth/callback';
Expand Down Expand Up @@ -558,6 +559,99 @@
}
}

function encodeBase64(bytes) {
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
if (typeof btoa === 'function') {
let binary = '';
for (let index = 0; index < view.length; index += 0x8000) {
binary += String.fromCharCode(...view.subarray(index, index + 0x8000));
}
return btoa(binary);
}
if (typeof Buffer !== 'undefined') {
return Buffer.from(view).toString('base64');
}
throw new Error('当前环境不支持 Base64 编码。');
}

function encodeSshEd25519PublicKey(rawPublicKey) {
const algorithm = new TextEncoder().encode('ssh-ed25519');
const publicKey = new Uint8Array(rawPublicKey);
const bytes = new Uint8Array(4 + algorithm.length + 4 + publicKey.length);
const view = new DataView(bytes.buffer);
view.setUint32(0, algorithm.length);
bytes.set(algorithm, 4);
view.setUint32(4 + algorithm.length, publicKey.length);
bytes.set(publicKey, 8 + algorithm.length);
return `ssh-ed25519 ${encodeBase64(bytes)}`;
}

function buildAgentIdentityContext(state = {}, session = null, accessToken = '') {
const token = normalizeString(accessToken || session?.accessToken);
const claims = parseCodexAccessTokenClaims(token);
const auth = claims?.['https://api.openai.com/auth'] || {};
const profile = claims?.['https://api.openai.com/profile'] || {};
const accountId = normalizeString(auth.chatgpt_account_id);
const userId = normalizeString(auth.chatgpt_user_id || auth.user_id || claims?.sub);
if (!accountId || !userId) {
throw new Error('accessToken 缺少 OpenAI account_id 或 user_id,无法生成 Agent Identity。');
}
return {
accessToken: token,
accountId,
userId,
email: normalizeEmailValue(profile.email)
|| resolveCodexSessionImportAccountName(state, session, token),
planType: normalizeString(auth.chatgpt_plan_type) || 'free',
};
}

async function createOpenAiAgentIdentity(state = {}, session = null, accessToken = '', options = {}) {
if (!cryptoImpl?.subtle) {
throw new Error('当前浏览器不支持 Web Crypto,无法生成 Agent Identity。');
}
const context = buildAgentIdentityContext(state, session, accessToken);
let keyPair;
try {
keyPair = await cryptoImpl.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
} catch {
throw new Error('当前浏览器不支持 Ed25519,无法生成 Agent Identity。');
}
const [privateKey, publicKey] = await Promise.all([
cryptoImpl.subtle.exportKey('pkcs8', keyPair.privateKey),
cryptoImpl.subtle.exportKey('raw', keyPair.publicKey),
]);
const registered = await requestJson('https://auth.openai.com', '/api/accounts/v1/agent/register', {
method: 'POST',
token: context.accessToken,
timeoutMs: options.agentTimeoutMs || options.timeoutMs,
body: {
abom: {
agent_version: '0.138.0-alpha.6',
agent_harness_id: 'codex-cli',
running_location: 'local',
},
agent_public_key: encodeSshEd25519PublicKey(publicKey),
},
});
const runtimeId = normalizeString(registered?.agent_runtime_id);
if (!runtimeId) {
throw new Error('OpenAI Agent 注册未返回 agent_runtime_id。');
}
return {
auth_mode: 'agent_identity',
agent_identity: {
agent_runtime_id: runtimeId,
agent_private_key: encodeBase64(privateKey),
account_id: context.accountId,
chatgpt_user_id: context.userId,
email: context.email,
plan_type: context.planType,
chatgpt_account_is_fedramp: false,
},
};
}

function resolveCodexSessionImportAccountName(state = {}, session = null, accessToken = '') {
const sessionObject = normalizeCodexSessionObject(session);
const claims = parseCodexAccessTokenClaims(accessToken || sessionObject?.accessToken);
Expand Down Expand Up @@ -960,9 +1054,71 @@
};
}

async function importCurrentChatGptSessionAsAgentIdentity(state = {}, options = {}) {
const logLabel = normalizeString(options.logLabel) || 'SUB2API Agent Identity 导入';
const session = normalizeCodexSessionObject(state?.session);
const accessToken = normalizeString(state?.accessToken || session?.accessToken);
if (!accessToken) {
throw new Error('未读取到 ChatGPT accessToken,无法生成 Agent Identity。');
}

await logWithOptions(`${logLabel}:正在使用当前 ChatGPT accessToken 注册 Agent...`, 'info', options);
const authJson = await createOpenAiAgentIdentity(state, session, accessToken, options);
const accountName = normalizeString(authJson?.agent_identity?.email)
|| resolveCodexSessionImportAccountName(state, session, accessToken);

await logWithOptions(`${logLabel}:正在登录 SUB2API 并解析分组、代理...`, 'info', options);
const { origin, token } = await loginSub2Api(state, options);
const groupNames = state.sub2apiGroupName || DEFAULT_SUB2API_GROUP_NAME;
const groups = await getGroupsByNames(origin, token, groupNames, options);
const proxyPreference = resolveSub2ApiProxyPreference(state);
const proxy = proxyPreference ? await resolveSub2ApiProxy(origin, token, proxyPreference, options) : null;
const proxyId = normalizeProxyId(proxy?.id);
const accountPriority = resolveSub2ApiAccountPriority(state);
const groupIds = groups
.map((group) => Number(group?.id))
.filter((id) => Number.isFinite(id) && id > 0);
if (!groupIds.length) {
throw new Error('SUB2API 返回的目标分组 ID 无效。');
}

const importPayload = {
content: JSON.stringify(authJson),
group_ids: groupIds,
...(accountName ? { name: accountName } : {}),
priority: accountPriority,
update_existing: true,
};
if (proxyId) {
importPayload.proxy_id = proxyId;
}

await logWithOptions(`${logLabel}:正在导入 Agent Identity 到 SUB2API...`, 'info', options);
const importResult = normalizeCodexSessionImportResult(await requestJson(origin, '/api/v1/admin/accounts/import/codex-session', {
method: 'POST',
token,
timeoutMs: options.importTimeoutMs || options.timeoutMs,
body: importPayload,
}));
if (importResult.failed > 0 || (importResult.created <= 0 && importResult.updated <= 0)) {
throw new Error(getCodexSessionImportFailureMessage(importResult));
}
const verifiedStatus = `SUB2API Agent Identity 导入完成:新建 ${importResult.created},更新 ${importResult.updated},跳过 ${importResult.skipped},失败 ${importResult.failed}`;
await logWithOptions(verifiedStatus, 'ok', options);
return {
verifiedStatus,
sub2apiImportTotal: importResult.total,
sub2apiImportCreated: importResult.created,
sub2apiImportUpdated: importResult.updated,
sub2apiImportSkipped: importResult.skipped,
sub2apiImportFailed: importResult.failed,
};
}

return {
buildDraftAccountName,
buildCodexSessionImportContent,
buildAgentIdentityContext,
buildOpenAiCredentials,
buildOpenAiExtra,
buildProxyDisplayName,
Expand All @@ -972,6 +1128,7 @@
prepareGrokOAuth,
createGrokAccountFromOAuth,
importCurrentChatGptSession,
importCurrentChatGptSessionAsAgentIdentity,
loginSub2Api,
normalizeProxyId,
normalizeRedirectUri,
Expand Down
80 changes: 77 additions & 3 deletions flows/openai/background/steps/sub2api-session-import.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundSub2ApiSessionImportModule() {
const PLUS_CHECKOUT_SOURCE = 'plus-checkout';
const PLUS_CHECKOUT_INJECT_FILES = ['content/utils.js', 'content/operation-delay.js', 'flows/openai/content/plus-checkout.js'];
const SESSION_READ_INITIAL_DELAY_MS = 1000;
const SESSION_READ_RETRY_DELAY_MS = 2000;
const SESSION_READ_MAX_ATTEMPTS = 11;

function createSub2ApiSessionImportExecutor(deps = {}) {
const {
Expand All @@ -15,6 +18,9 @@
normalizeSub2ApiUrl = (value) => value,
registerTab,
sendTabMessageUntilStopped,
sessionReadInitialDelayMs = SESSION_READ_INITIAL_DELAY_MS,
sessionReadRetryDelayMs = SESSION_READ_RETRY_DELAY_MS,
sessionReadMaxAttempts = SESSION_READ_MAX_ATTEMPTS,
sleepWithStop = async () => {},
throwIfStopped = () => {},
waitForTabCompleteUntilStopped = async () => {},
Expand Down Expand Up @@ -199,9 +205,7 @@
return tab;
}

async function readCurrentChatGptSession(tabId, visibleStep) {
await waitForTabCompleteUntilStopped(tabId);
await sleepWithStop(1000);
async function requestCurrentChatGptSession(tabId, visibleStep, options = {}) {
await ensureContentScriptReadyOnTabUntilStopped(PLUS_CHECKOUT_SOURCE, tabId, {
inject: PLUS_CHECKOUT_INJECT_FILES,
injectSource: PLUS_CHECKOUT_SOURCE,
Expand All @@ -227,6 +231,9 @@
sessionResult?.accessToken
|| session?.accessToken
);
if (options.requireAccessToken && !accessToken) {
throw new Error(`步骤 ${visibleStep}:未读取到 ChatGPT accessToken,请确认当前标签页仍处于已登录状态。`);
}
if (!session && !accessToken) {
throw new Error(`步骤 ${visibleStep}:未读取到有效的 ChatGPT 会话或 accessToken,请确认当前标签页仍处于已登录状态。`);
}
Expand All @@ -237,6 +244,39 @@
};
}

async function readCurrentChatGptSession(tabId, visibleStep, options = {}) {
await waitForTabCompleteUntilStopped(tabId);

const firstDelayMs = Math.max(0, Math.floor(Number(sessionReadInitialDelayMs) || 0));
const retryDelayMs = Math.max(0, Math.floor(Number(sessionReadRetryDelayMs) || 0));
const maxAttempts = Math.max(1, Math.floor(Number(sessionReadMaxAttempts) || SESSION_READ_MAX_ATTEMPTS));
let lastError = null;

for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
throwIfStopped();
const delayMs = attempt === 1 ? firstDelayMs : retryDelayMs;
if (delayMs > 0) {
await sleepWithStop(delayMs);
}

try {
return await requestCurrentChatGptSession(tabId, visibleStep, options);
} catch (error) {
lastError = error;
if (attempt >= maxAttempts) {
break;
}
await addStepLog(
visibleStep,
`未读取到 ChatGPT session/accessToken,${Math.round(retryDelayMs / 1000)} 秒后重试...`,
'warn'
);
}
}

throw lastError || new Error(`步骤 ${visibleStep}:未读取到有效的 ChatGPT 会话或 accessToken,请确认当前标签页仍处于已登录状态。`);
}

async function executeSub2ApiSessionImport(state = {}) {
throwIfStopped();
const visibleStep = resolveVisibleStep(state);
Expand Down Expand Up @@ -268,8 +308,42 @@
await completeNodeFromBackground(state?.nodeId || 'sub2api-session-import', result);
}

async function executeSub2ApiAgentIdentityImport(state = {}) {
throwIfStopped();
const visibleStep = resolveVisibleStep(state);
const api = getSub2ApiApi();
const importAgentIdentity = api.importCurrentChatGptSessionAsAgentIdentity
|| api.importCurrentChatGptSession;

await addStepLog(visibleStep, '正在定位当前 ChatGPT 会话页并读取 accessToken...', 'info');
const tabId = await resolveSessionTabId(state);
const tab = await getResolvedSessionTab(tabId, visibleStep);
if (chrome?.tabs?.update) {
await chrome.tabs.update(tab.id, { active: true }).catch(() => {});
}
const sessionState = await readCurrentChatGptSession(tab.id, visibleStep, {
requireAccessToken: true,
});
throwIfStopped();

const result = await importAgentIdentity({
...state,
session: sessionState.session,
accessToken: sessionState.accessToken,
}, {
visibleStep,
logLabel: `步骤 ${visibleStep}`,
logOptions: { step: visibleStep, stepKey: 'sub2api-agent-identity-import' },
timeoutMs: 120000,
agentTimeoutMs: 60000,
importTimeoutMs: 120000,
});
await completeNodeFromBackground(state?.nodeId || 'sub2api-agent-identity-import', result);
}

return {
executeSub2ApiSessionImport,
executeSub2ApiAgentIdentityImport,
isSupportedChatGptSessionUrl,
};
}
Expand Down
2 changes: 2 additions & 0 deletions flows/openai/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
"sub2apiUrl": "",
"sub2apiEmail": "",
"sub2apiPassword": "",
"sub2apiImportMode": "oauth",
"sub2apiGroupName": "codex",
"sub2apiGroupNames": [
"codex",
Expand Down Expand Up @@ -431,6 +432,7 @@
"row-sub2api-url",
"row-sub2api-email",
"row-sub2api-password",
"row-sub2api-import-mode",
"row-sub2api-group",
"row-sub2api-account-priority",
"row-sub2api-default-proxy"
Expand Down
Loading