diff --git a/background.js b/background.js index fa8c68e..ce206b3 100644 --- a/background.js +++ b/background.js @@ -44,6 +44,7 @@ importScripts( 'flows/kiro/background/desktop-client.js', 'flows/kiro/background/desktop-authorize-runner.js', 'flows/kiro/background/publisher-kiro-rs.js', + 'flows/grok/background/publisher-webchat2api.js', 'background/generated-email-helpers.js', 'background/signup-flow-helpers.js', 'background/mail-rule-registry.js', @@ -1264,6 +1265,8 @@ const PERSISTED_SETTING_DEFAULTS = { activeFlowId: DEFAULT_ACTIVE_FLOW_ID, kiroRsUrl: String(self.MultiPageFlowRegistry?.DEFAULT_KIRO_RS_URL || '').trim(), kiroRsKey: '', + grokWebchat2ApiUrl: '', + grokWebchat2ApiAdminKey: '', vpsUrl: '', vpsPassword: '', localCpaStep9Mode: DEFAULT_LOCAL_CPA_STEP9_MODE, @@ -1481,6 +1484,8 @@ const SETTINGS_SCHEMA_VIEW_KEYS = Object.freeze([ 'ipProxyMode', 'kiroRsUrl', 'kiroRsKey', + 'grokWebchat2ApiUrl', + 'grokWebchat2ApiAdminKey', 'stepExecutionRangeByFlow', ]); const SETTINGS_SCHEMA_VIEW_KEY_SET = new Set(SETTINGS_SCHEMA_VIEW_KEYS); @@ -3158,8 +3163,10 @@ function normalizePersistentSettingValue(key, value) { } return String(value || '').trim().toLowerCase() === 'kiro' ? 'kiro' : DEFAULT_ACTIVE_FLOW_ID; case 'kiroRsUrl': + case 'grokWebchat2ApiUrl': return String(value || '').trim(); case 'kiroRsKey': + case 'grokWebchat2ApiAdminKey': return String(value || '').trim(); case 'vpsUrl': return String(value || '').trim(); @@ -3835,6 +3842,8 @@ function buildSettingsStatePatchFromFlatUpdates(updates = {}) { assignIfUpdated('ipProxyMode', ['services', 'proxy', 'mode']); assignIfUpdated('kiroRsUrl', ['flows', 'kiro', 'targets', 'kiro-rs', 'baseUrl']); assignIfUpdated('kiroRsKey', ['flows', 'kiro', 'targets', 'kiro-rs', 'apiKey']); + assignIfUpdated('grokWebchat2ApiUrl', ['flows', 'grok', 'targets', 'webchat2api', 'baseUrl']); + assignIfUpdated('grokWebchat2ApiAdminKey', ['flows', 'grok', 'targets', 'webchat2api', 'apiKey']); if (hasUpdate('stepExecutionRangeByFlow') && isPlainObjectValue(updates.stepExecutionRangeByFlow)) { Object.entries(updates.stepExecutionRangeByFlow).forEach(([rawFlowId, range]) => { @@ -4367,11 +4376,21 @@ async function clearGrokSsoCookies() { cookies: [], extractedAt: 0, }, + upload: { + status: '', + uploadedAt: 0, + message: '', + targetUrl: '', + }, }) : { grokSsoCookie: '', grokSsoCookies: [], grokSsoExtractedAt: 0, + grokWebchat2ApiUploadStatus: '', + grokWebchat2ApiUploadedAt: 0, + grokWebchat2ApiUploadMessage: '', + grokWebchat2ApiTargetUrl: '', }; await setState(patch); const nextState = await getState(); @@ -10936,6 +10955,7 @@ const AUTO_RUN_BACKGROUND_COMPLETED_STEP_KEYS = new Set([ 'grok-submit-verification-code', 'grok-submit-profile', 'grok-extract-sso-cookie', + 'grok-upload-sso-to-webchat2api', ]); const STEP_COMPLETION_SIGNAL_STEP_KEYS = new Set([ 'fill-password', @@ -14016,6 +14036,13 @@ const kiroPublisher = self.MultiPageBackgroundKiroPublisherKiroRs?.createKiroRsP maybeSubmitFlowContribution, setState, }); +const grokWebchat2ApiPublisher = self.MultiPageBackgroundGrokPublisherWebchat2Api?.createGrokWebchat2ApiPublisher({ + addLog, + completeNodeFromBackground, + fetchImpl: typeof fetch === 'function' ? fetch.bind(globalThis) : null, + getState, + setState, +}); const step10Executor = self.MultiPageBackgroundStep10?.createStep10Executor({ addLog, chrome, @@ -14115,6 +14142,7 @@ const stepExecutorsByKey = { 'grok-submit-verification-code': (state) => grokRegisterRunner.executeGrokSubmitVerificationCode(state), 'grok-submit-profile': (state) => grokRegisterRunner.executeGrokSubmitProfile(state), 'grok-extract-sso-cookie': (state) => grokRegisterRunner.executeGrokExtractSsoCookie(state), + 'grok-upload-sso-to-webchat2api': (state) => grokWebchat2ApiPublisher.executeGrokUploadSsoToWebchat2Api(state), }; const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter({ addLog, diff --git a/core/flow-kernel/settings-schema.js b/core/flow-kernel/settings-schema.js index 8571706..d5a41fd 100644 --- a/core/flow-kernel/settings-schema.js +++ b/core/flow-kernel/settings-schema.js @@ -295,6 +295,13 @@ apiKey: String(targetState.apiKey ?? ''), }; } + if (flowId === 'grok' && targetId === 'webchat2api') { + return { + ...targetState, + baseUrl: String(targetState.baseUrl ?? '').trim(), + apiKey: String(targetState.apiKey ?? ''), + }; + } return targetState; } @@ -601,6 +608,7 @@ }; const openaiState = normalizedState.flows.openai || buildDefaultFlowSettings('openai'); const kiroState = normalizedState.flows.kiro || buildDefaultFlowSettings('kiro'); + const grokState = normalizedState.flows.grok || buildDefaultFlowSettings('grok'); next.activeFlowId = normalizedState.activeFlowId; next.targetId = getSelectedTargetId(normalizedState, normalizedState.activeFlowId); next.vpsUrl = openaiState.targets.cpa?.vpsUrl || ''; @@ -631,6 +639,8 @@ next.ipProxyMode = normalizedState.services.proxy.mode; next.kiroRsUrl = kiroState.targets['kiro-rs']?.baseUrl || ''; next.kiroRsKey = kiroState.targets['kiro-rs']?.apiKey || ''; + next.grokWebchat2ApiUrl = grokState.targets.webchat2api?.baseUrl || ''; + next.grokWebchat2ApiAdminKey = grokState.targets.webchat2api?.apiKey || ''; next.stepExecutionRangeByFlow = buildStepExecutionRangeByFlow(normalizedState); next.settingsSchemaVersion = normalizedState.schemaVersion; next.settingsState = cloneValue(normalizedState); diff --git a/flows/grok/background/publisher-webchat2api.js b/flows/grok/background/publisher-webchat2api.js new file mode 100644 index 0000000..1c963ef --- /dev/null +++ b/flows/grok/background/publisher-webchat2api.js @@ -0,0 +1,311 @@ +(function attachBackgroundGrokPublisherWebchat2Api(root, factory) { + root.MultiPageBackgroundGrokPublisherWebchat2Api = factory(root); +})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundGrokPublisherWebchat2ApiModule(root) { + const grokStateApi = root?.MultiPageBackgroundGrokState || null; + const WEBCHAT2API_INJECT_PATH = '/api/remote-account/inject'; + const DEFAULT_SOURCE_ID = 'flowpilot-grok-sso'; + const DEFAULT_SOURCE_NAME = 'FlowPilot Grok SSO'; + + function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); + } + + function cloneValue(value) { + if (Array.isArray(value)) { + return value.map((entry) => cloneValue(entry)); + } + if (isPlainObject(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, entryValue]) => [key, cloneValue(entryValue)]) + ); + } + return value; + } + + function deepMerge(baseValue, patchValue) { + if (Array.isArray(patchValue)) { + return patchValue.map((entry) => cloneValue(entry)); + } + if (!isPlainObject(patchValue)) { + return patchValue === undefined ? cloneValue(baseValue) : patchValue; + } + + const baseObject = isPlainObject(baseValue) ? baseValue : {}; + const next = { + ...cloneValue(baseObject), + }; + Object.entries(patchValue).forEach(([key, value]) => { + next[key] = deepMerge(baseObject[key], value); + }); + return next; + } + + function cleanString(value = '') { + return String(value ?? '').trim(); + } + + function getErrorMessage(error) { + return error instanceof Error ? error.message : cleanString(error) || '未知错误'; + } + + async function readResponse(response) { + const text = await response.text(); + let json = null; + try { + json = text ? JSON.parse(text) : null; + } catch (_error) { + json = null; + } + return { text, json }; + } + + function readWebchat2ApiResponseMessage(body = {}, fallback = '') { + return cleanString( + body?.json?.error?.message + || body?.json?.error + || body?.json?.message + || fallback + ); + } + + function normalizeWebchat2ApiBaseUrl(value = '') { + const rawUrl = cleanString(value); + if (!rawUrl) { + throw new Error('缺少 webchat2api 地址。'); + } + const withProtocol = /^https?:\/\//i.test(rawUrl) ? rawUrl : `http://${rawUrl}`; + let parsed = null; + try { + parsed = new URL(withProtocol); + } catch (_error) { + throw new Error('webchat2api 地址格式无效,请检查配置。'); + } + if (!/^https?:$/.test(parsed.protocol)) { + throw new Error('webchat2api 地址只支持 http 或 https。'); + } + return parsed.origin; + } + + function buildWebchat2ApiInjectUrl(value = '') { + return `${normalizeWebchat2ApiBaseUrl(value)}${WEBCHAT2API_INJECT_PATH}`; + } + + function normalizeWebchat2ApiAdminKey(value = '') { + return cleanString(value); + } + + function readGrokRuntime(state = {}) { + return grokStateApi?.ensureRuntimeState + ? grokStateApi.ensureRuntimeState(state) + : (isPlainObject(state?.runtimeState?.flowState?.grok) + ? state.runtimeState.flowState.grok + : (isPlainObject(state?.flowState?.grok) ? state.flowState.grok : {})); + } + + function buildCanonicalRuntimePatch(currentState = {}, nextRuntimeState = {}) { + if (typeof grokStateApi?.buildRuntimeStatePatch === 'function') { + return grokStateApi.buildRuntimeStatePatch(currentState, nextRuntimeState); + } + const baseRuntimeState = isPlainObject(currentState?.runtimeState) + ? cloneValue(currentState.runtimeState) + : {}; + const baseFlowState = isPlainObject(baseRuntimeState.flowState) + ? cloneValue(baseRuntimeState.flowState) + : {}; + return { + runtimeState: { + ...baseRuntimeState, + flowState: { + ...baseFlowState, + grok: deepMerge(readGrokRuntime(currentState), nextRuntimeState), + }, + }, + }; + } + + function mergeRuntimePatch(currentState = {}, patch = {}) { + return buildCanonicalRuntimePatch( + currentState, + deepMerge(readGrokRuntime(currentState), patch) + ); + } + + function resolveGrokWebchat2ApiConfig(state = {}) { + const nestedConfig = state?.settingsState?.flows?.grok?.targets?.webchat2api || {}; + return { + baseUrl: cleanString(nestedConfig.baseUrl || state?.grokWebchat2ApiUrl), + apiKey: normalizeWebchat2ApiAdminKey(nestedConfig.apiKey ?? state?.grokWebchat2ApiAdminKey ?? ''), + }; + } + + function resolveGrokSsoCookie(state = {}) { + const runtimeState = readGrokRuntime(state); + return cleanString(runtimeState?.sso?.currentCookie || state?.grokSsoCookie); + } + + function buildGrokSsoInjectPayload(ssoCookie = '') { + const normalizedCookie = cleanString(ssoCookie); + if (!normalizedCookie) { + throw new Error('缺少 Grok SSO Cookie,请先完成步骤 5。'); + } + return { + accounts: [{ + token: normalizedCookie, + provider: 'grok', + type: 'sso', + }], + strategy: 'merge', + source_id: DEFAULT_SOURCE_ID, + source_name: DEFAULT_SOURCE_NAME, + provider: 'grok', + }; + } + + async function uploadGrokSsoToWebchat2Api(baseUrl, apiKey, ssoCookie, fetchImpl) { + const endpointUrl = buildWebchat2ApiInjectUrl(baseUrl); + const normalizedApiKey = normalizeWebchat2ApiAdminKey(apiKey); + if (!normalizedApiKey) { + throw new Error('缺少 webchat2api 管理密钥。'); + } + + const response = await fetchImpl(endpointUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: `Bearer ${normalizedApiKey}`, + }, + body: JSON.stringify(buildGrokSsoInjectPayload(ssoCookie)), + }); + const body = await readResponse(response); + if (!response.ok) { + const message = readWebchat2ApiResponseMessage(body, response.statusText) || `HTTP ${response.status}`; + throw new Error(`webchat2api SSO 上传失败:${message}`); + } + if (isPlainObject(body.json) && Object.prototype.hasOwnProperty.call(body.json, 'code') && Number(body.json.code) !== 0) { + const message = readWebchat2ApiResponseMessage(body, `code=${body.json.code}`); + throw new Error(`webchat2api SSO 上传失败:${message}`); + } + return { + endpointUrl, + message: readWebchat2ApiResponseMessage(body, '') || '上传成功', + raw: body.json, + }; + } + + function createGrokWebchat2ApiPublisher(deps = {}) { + const { + addLog = async () => {}, + completeNodeFromBackground, + fetchImpl = typeof fetch === 'function' ? fetch.bind(globalThis) : null, + getState = async () => ({}), + setState = async () => {}, + } = deps; + + if (typeof completeNodeFromBackground !== 'function') { + throw new Error('Grok webchat2api publisher requires completeNodeFromBackground.'); + } + if (typeof fetchImpl !== 'function') { + throw new Error('Grok webchat2api publisher requires fetch support.'); + } + + async function log(message, level = 'info', nodeId = '') { + await addLog(message, level, nodeId ? { nodeId } : {}); + } + + async function applyRuntimeState(currentState = {}, patch = {}) { + const nextPatch = mergeRuntimePatch(currentState, patch); + await setState(nextPatch); + return nextPatch; + } + + async function persistFailure(currentState = {}, message = '', targetUrl = '') { + const uploadPatch = { + status: 'error', + uploadedAt: 0, + message, + }; + const normalizedTargetUrl = cleanString(targetUrl); + if (normalizedTargetUrl) { + uploadPatch.targetUrl = normalizedTargetUrl; + } + const nextPatch = mergeRuntimePatch(currentState, { + session: { + lastError: message, + }, + upload: uploadPatch, + }); + await setState(nextPatch); + } + + async function executeGrokUploadSsoToWebchat2Api(state = {}) { + const nodeId = cleanString(state?.nodeId) || 'grok-upload-sso-to-webchat2api'; + const currentState = await getState(); + let failureTargetUrl = ''; + try { + const targetConfig = resolveGrokWebchat2ApiConfig(currentState); + const endpointUrl = buildWebchat2ApiInjectUrl(targetConfig.baseUrl); + failureTargetUrl = endpointUrl; + const apiKey = normalizeWebchat2ApiAdminKey(targetConfig.apiKey); + if (!apiKey) { + throw new Error('缺少 webchat2api 管理密钥。'); + } + const ssoCookie = resolveGrokSsoCookie(currentState); + if (!ssoCookie) { + throw new Error('缺少 Grok SSO Cookie,请先完成步骤 5。'); + } + + await applyRuntimeState(currentState, { + session: { + lastError: '', + }, + upload: { + status: 'uploading', + uploadedAt: 0, + message: '', + targetUrl: endpointUrl, + }, + }); + + await log('步骤 6:正在上传 Grok SSO 到 webchat2api...', 'info', nodeId); + const uploadResult = await uploadGrokSsoToWebchat2Api( + targetConfig.baseUrl, + apiKey, + ssoCookie, + fetchImpl + ); + const uploadedAt = Date.now(); + const payload = await applyRuntimeState(currentState, { + session: { + lastError: '', + }, + upload: { + status: 'uploaded', + uploadedAt, + message: uploadResult.message || '上传成功', + targetUrl: uploadResult.endpointUrl, + }, + }); + await log(`步骤 6:Grok SSO 已上传到 webchat2api,状态:${uploadResult.message || '上传成功'}。`, 'ok', nodeId); + await completeNodeFromBackground(nodeId, payload); + } catch (error) { + const message = getErrorMessage(error); + await persistFailure(currentState, message, failureTargetUrl); + await log(`步骤 6:${message}`, 'error', nodeId); + throw error; + } + } + + return { + executeGrokUploadSsoToWebchat2Api, + }; + } + + return { + buildGrokSsoInjectPayload, + buildWebchat2ApiInjectUrl, + createGrokWebchat2ApiPublisher, + normalizeWebchat2ApiBaseUrl, + uploadGrokSsoToWebchat2Api, + }; +}); diff --git a/flows/grok/background/register-runner.js b/flows/grok/background/register-runner.js index f4bb501..bfcdc81 100644 --- a/flows/grok/background/register-runner.js +++ b/flows/grok/background/register-runner.js @@ -613,22 +613,17 @@ throw new Error('未找到 x.ai/grok sso Cookie。'); } - const latestState = await getState(); - const existingSsoCookies = Array.isArray(latestState?.grokSsoCookies) - ? latestState.grokSsoCookies - .map((entry) => cleanString(entry)) - .filter(Boolean) - : []; - const nextSsoCookies = existingSsoCookies.includes(ssoCookie) - ? existingSsoCookies - : [...existingSsoCookies, ssoCookie]; const completedAt = Date.now(); const completionPatch = { grokSsoCookie: ssoCookie, - grokSsoCookies: nextSsoCookies, + grokSsoCookies: [ssoCookie], grokSsoExtractedAt: completedAt, grokCompletedAt: completedAt, grokRegisterStatus: 'completed', + grokWebchat2ApiUploadStatus: '', + grokWebchat2ApiUploadedAt: 0, + grokWebchat2ApiUploadMessage: '', + grokWebchat2ApiTargetUrl: '', ...buildGrokRuntimePatch({ register: { status: 'completed', @@ -636,9 +631,15 @@ }, sso: { currentCookie: ssoCookie, - cookies: nextSsoCookies, + cookies: [ssoCookie], extractedAt: completedAt, }, + upload: { + status: '', + uploadedAt: 0, + message: '', + targetUrl: '', + }, session: { lastError: '', }, diff --git a/flows/grok/background/state.js b/flows/grok/background/state.js index 8eb5507..4a7bb64 100644 --- a/flows/grok/background/state.js +++ b/flows/grok/background/state.js @@ -39,6 +39,26 @@ return String(value ?? '').trim(); } + function assignCleanString(target = {}, key = '', value = '') { + const normalized = cleanString(value); + if (normalized) { + target[key] = normalized; + } + } + + function assignPositiveInteger(target = {}, key = '', value) { + const numeric = Math.floor(Number(value)); + if (Number.isInteger(numeric) && numeric > 0) { + target[key] = numeric; + } + } + + function assignNonEmptyArray(target = {}, key = '', value) { + if (Array.isArray(value) && value.length) { + target[key] = value; + } + } + function normalizeInteger(value, fallback = 0) { const numeric = Math.floor(Number(value)); return Number.isInteger(numeric) ? numeric : fallback; @@ -87,6 +107,12 @@ cookies: [], extractedAt: 0, }, + upload: { + status: '', + uploadedAt: 0, + message: '', + targetUrl: '', + }, }; } @@ -115,6 +141,12 @@ cookies: normalizeSsoCookies(merged.sso?.cookies || merged.ssoCookies), extractedAt: Math.max(0, normalizeInteger(merged.sso?.extractedAt)), }, + upload: { + status: cleanString(merged.upload?.status), + uploadedAt: Math.max(0, normalizeInteger(merged.upload?.uploadedAt)), + message: cleanString(merged.upload?.message), + targetUrl: cleanString(merged.upload?.targetUrl), + }, }; } @@ -152,6 +184,10 @@ grokSsoCookie: normalizedRuntimeState.sso.currentCookie, grokSsoCookies: normalizedRuntimeState.sso.cookies, grokSsoExtractedAt: normalizedRuntimeState.sso.extractedAt, + grokWebchat2ApiUploadStatus: normalizedRuntimeState.upload.status, + grokWebchat2ApiUploadedAt: normalizedRuntimeState.upload.uploadedAt, + grokWebchat2ApiUploadMessage: normalizedRuntimeState.upload.message, + grokWebchat2ApiTargetUrl: normalizedRuntimeState.upload.targetUrl, }; } @@ -163,27 +199,29 @@ ? state.flowState.grok : {}; const flatRuntime = { - session: { - registerTabId: state.grokRegisterTabId, - pageState: state.grokPageState, - pageUrl: state.grokPageUrl || state.grokPostVerificationUrl, - }, - register: { - email: state.grokEmail || state.email, - firstName: state.grokFirstName, - lastName: state.grokLastName, - password: state.grokPassword, - verificationRequestedAt: state.grokVerificationRequestedAt, - verificationCode: state.grokVerificationCode, - status: state.grokRegisterStatus, - completedAt: state.grokCompletedAt, - }, - sso: { - currentCookie: state.grokSsoCookie, - cookies: state.grokSsoCookies, - extractedAt: state.grokSsoExtractedAt || state.grokCompletedAt, - }, + session: {}, + register: {}, + sso: {}, + upload: {}, }; + assignPositiveInteger(flatRuntime.session, 'registerTabId', state.grokRegisterTabId); + assignCleanString(flatRuntime.session, 'pageState', state.grokPageState); + assignCleanString(flatRuntime.session, 'pageUrl', state.grokPageUrl || state.grokPostVerificationUrl); + assignCleanString(flatRuntime.register, 'email', state.grokEmail || state.email); + assignCleanString(flatRuntime.register, 'firstName', state.grokFirstName); + assignCleanString(flatRuntime.register, 'lastName', state.grokLastName); + assignCleanString(flatRuntime.register, 'password', state.grokPassword); + assignPositiveInteger(flatRuntime.register, 'verificationRequestedAt', state.grokVerificationRequestedAt); + assignCleanString(flatRuntime.register, 'verificationCode', state.grokVerificationCode); + assignCleanString(flatRuntime.register, 'status', state.grokRegisterStatus); + assignPositiveInteger(flatRuntime.register, 'completedAt', state.grokCompletedAt); + assignCleanString(flatRuntime.sso, 'currentCookie', state.grokSsoCookie); + assignNonEmptyArray(flatRuntime.sso, 'cookies', state.grokSsoCookies); + assignPositiveInteger(flatRuntime.sso, 'extractedAt', state.grokSsoExtractedAt || state.grokCompletedAt); + assignCleanString(flatRuntime.upload, 'status', state.grokWebchat2ApiUploadStatus); + assignPositiveInteger(flatRuntime.upload, 'uploadedAt', state.grokWebchat2ApiUploadedAt); + assignCleanString(flatRuntime.upload, 'message', state.grokWebchat2ApiUploadMessage); + assignCleanString(flatRuntime.upload, 'targetUrl', state.grokWebchat2ApiTargetUrl); return normalizeRuntimeState(deepMerge(deepMerge(runtimeFlowState.grok || {}, legacyFlowState), flatRuntime)); } @@ -346,6 +384,18 @@ extractedAt: payload.grokSsoExtractedAt || payload.grokCompletedAt || 0, }; } + if (Object.prototype.hasOwnProperty.call(payload, 'grokWebchat2ApiUploadStatus')) { + patch.upload = { ...(patch.upload || {}), status: payload.grokWebchat2ApiUploadStatus }; + } + if (Object.prototype.hasOwnProperty.call(payload, 'grokWebchat2ApiUploadedAt')) { + patch.upload = { ...(patch.upload || {}), uploadedAt: payload.grokWebchat2ApiUploadedAt }; + } + if (Object.prototype.hasOwnProperty.call(payload, 'grokWebchat2ApiUploadMessage')) { + patch.upload = { ...(patch.upload || {}), message: payload.grokWebchat2ApiUploadMessage }; + } + if (Object.prototype.hasOwnProperty.call(payload, 'grokWebchat2ApiTargetUrl')) { + patch.upload = { ...(patch.upload || {}), targetUrl: payload.grokWebchat2ApiTargetUrl }; + } if (!Object.keys(patch).length) { return {}; } @@ -353,10 +403,7 @@ } function buildFreshKeepState(currentState = {}) { - const currentRuntimeState = ensureRuntimeState(currentState); - const nextRuntimeState = buildDefaultRuntimeState(); - nextRuntimeState.sso = currentRuntimeState.sso || buildDefaultRuntimeState().sso; - return buildRuntimeStatePatch(currentState, nextRuntimeState); + return buildRuntimeStatePatch(currentState, buildDefaultRuntimeState()); } return { diff --git a/flows/grok/index.js b/flows/grok/index.js index a06eac8..55fa451 100644 --- a/flows/grok/index.js +++ b/flows/grok/index.js @@ -43,6 +43,10 @@ groups: [ 'grok-target-webchat2api', ], + defaultState: { + baseUrl: '', + apiKey: '', + }, }, }, publicationTargets: {}, @@ -106,14 +110,26 @@ 'grok-extract-sso-cookie', ], }, + 'flows/grok/background/publisher-webchat2api': { + sourceId: 'grok-webchat2api', + commands: [ + 'grok-upload-sso-to-webchat2api', + ], + }, }, defaultTargetId: 'webchat2api', settingsDefaults: { + targets: { + webchat2api: { + baseUrl: '', + apiKey: '', + }, + }, autoRun: { stepExecutionRange: { enabled: false, fromStep: 1, - toStep: 5, + toStep: 6, }, }, }, @@ -122,6 +138,8 @@ id: 'grok-target-webchat2api', label: 'webchat2api', rowIds: [ + 'row-grok-webchat2api-url', + 'row-grok-webchat2api-key', 'row-grok-sso-settings', ], }, @@ -131,6 +149,7 @@ rowIds: [ 'row-grok-register-status', 'row-grok-sso-status', + 'row-grok-webchat2api-upload-status', ], }, }, diff --git a/flows/grok/workflow.js b/flows/grok/workflow.js index f872a75..41a66cd 100644 --- a/flows/grok/workflow.js +++ b/flows/grok/workflow.js @@ -64,6 +64,16 @@ command: 'grok-extract-sso-cookie', flowId: 'grok', }, + { + id: 6, + order: 60, + key: 'grok-upload-sso-to-webchat2api', + title: '上传 SSO 到 webchat2api', + sourceId: 'grok-webchat2api', + driverId: 'flows/grok/background/publisher-webchat2api', + command: 'grok-upload-sso-to-webchat2api', + flowId: 'grok', + }, ], }); diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index 6b0c79e..1b7cb69 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -299,12 +299,31 @@ SSO 状态 未提取 + + + diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index 56a3b01..219d1b0 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -186,6 +186,10 @@ const inputKiroRsKey = document.getElementById('input-kiro-rs-key'); const btnTestKiroRs = document.getElementById('btn-test-kiro-rs'); const rowKiroRsTestStatus = document.getElementById('row-kiro-rs-test-status'); const displayKiroRsTestStatus = document.getElementById('display-kiro-rs-test-status'); +const rowGrokWebchat2ApiUrl = document.getElementById('row-grok-webchat2api-url'); +const inputGrokWebchat2ApiUrl = document.getElementById('input-grok-webchat2api-url'); +const rowGrokWebchat2ApiKey = document.getElementById('row-grok-webchat2api-key'); +const inputGrokWebchat2ApiKey = document.getElementById('input-grok-webchat2api-key'); const rowKiroWebStatus = document.getElementById('row-kiro-web-status'); const displayKiroWebStatus = document.getElementById('display-kiro-web-status'); const rowKiroLoginUrl = document.getElementById('row-kiro-login-url'); @@ -196,10 +200,11 @@ const rowGrokRegisterStatus = document.getElementById('row-grok-register-status' const displayGrokRegisterStatus = document.getElementById('display-grok-register-status'); const rowGrokSsoStatus = document.getElementById('row-grok-sso-status'); const displayGrokSsoStatus = document.getElementById('display-grok-sso-status'); +const rowGrokWebchat2ApiUploadStatus = document.getElementById('row-grok-webchat2api-upload-status'); +const displayGrokWebchat2ApiUploadStatus = document.getElementById('display-grok-webchat2api-upload-status'); const rowGrokSsoSettings = document.getElementById('row-grok-sso-settings'); const displayGrokSsoCookie = document.getElementById('display-grok-sso-cookie'); const btnCopyGrokSso = document.getElementById('btn-copy-grok-sso'); -const btnExportGrokSso = document.getElementById('btn-export-grok-sso'); const btnClearGrokSso = document.getElementById('btn-clear-grok-sso'); const rowCustomPassword = document.getElementById('row-custom-password'); const rowPlusMode = document.getElementById('row-plus-mode'); @@ -1576,6 +1581,7 @@ const PRIVACY_MASKED_INPUT_IDS = Object.freeze([ 'input-sub2api-default-proxy', 'input-codex2api-url', 'input-kiro-rs-url', + 'input-grok-webchat2api-url', 'input-gpc-helper-api', 'input-gpc-helper-phone', 'input-gpc-helper-local-sms-url', @@ -2718,6 +2724,20 @@ function getGrokRegisterStatusLabel(value = '') { } } +function getGrokWebchat2ApiUploadStatusLabel(value = '') { + const normalized = String(value || '').trim().toLowerCase(); + switch (normalized) { + case 'uploading': + return '正在上传'; + case 'uploaded': + return '已上传'; + case 'error': + return '上传失败'; + default: + return String(value || '').trim() || '未开始'; + } +} + function renderGrokRuntimeState(state = latestState) { const runtimeState = getGrokRuntimeState(state); const registerStatus = String( @@ -2737,6 +2757,27 @@ function renderGrokRuntimeState(state = latestState) { || state?.grokSsoExtractedAt || 0 ) || 0; + const uploadState = runtimeState?.upload || {}; + const uploadStatus = String( + uploadState.status + || state?.grokWebchat2ApiUploadStatus + || '' + ).trim(); + const uploadedAt = Number( + uploadState.uploadedAt + || state?.grokWebchat2ApiUploadedAt + || 0 + ) || 0; + const uploadMessage = String( + uploadState.message + || state?.grokWebchat2ApiUploadMessage + || '' + ).trim(); + const uploadTargetUrl = String( + uploadState.targetUrl + || state?.grokWebchat2ApiTargetUrl + || '' + ).trim(); if (displayGrokRegisterStatus) { displayGrokRegisterStatus.textContent = getGrokRegisterStatusLabel(registerStatus); @@ -2750,9 +2791,15 @@ function renderGrokRuntimeState(state = latestState) { displayGrokSsoCookie.textContent = currentCookie ? `${currentCookie.slice(0, 8)}...${currentCookie.slice(-6)}` : '未提取'; - displayGrokSsoCookie.title = currentCookie ? '已隐藏完整 SSO Cookie,可使用复制或导出' : ''; + displayGrokSsoCookie.title = currentCookie ? '已隐藏完整 SSO Cookie,可使用复制' : ''; } - [btnCopyGrokSso, btnExportGrokSso, btnClearGrokSso].forEach((button) => { + if (displayGrokWebchat2ApiUploadStatus) { + const label = getGrokWebchat2ApiUploadStatusLabel(uploadStatus); + const suffix = uploadedAt ? `,${new Date(uploadedAt).toLocaleString()}` : ''; + displayGrokWebchat2ApiUploadStatus.textContent = `${label}${uploadMessage ? `:${uploadMessage}` : ''}${suffix}`; + displayGrokWebchat2ApiUploadStatus.title = uploadTargetUrl || ''; + } + [btnCopyGrokSso, btnClearGrokSso].forEach((button) => { if (button) { button.disabled = cookies.length === 0; } @@ -4915,6 +4962,12 @@ function collectSettingsPayload() { const currentKiroRsKeyValue = typeof inputKiroRsKey !== 'undefined' && inputKiroRsKey ? String(inputKiroRsKey.value ?? '').trim() : null; + const currentGrokWebchat2ApiUrlValue = typeof inputGrokWebchat2ApiUrl !== 'undefined' && inputGrokWebchat2ApiUrl + ? String(inputGrokWebchat2ApiUrl.value ?? '').trim() + : null; + const currentGrokWebchat2ApiKeyValue = typeof inputGrokWebchat2ApiKey !== 'undefined' && inputGrokWebchat2ApiKey + ? String(inputGrokWebchat2ApiKey.value ?? '').trim() + : null; const normalizeHostedCheckoutDelaySecondsSafe = typeof normalizePlusHostedCheckoutOauthDelaySeconds === 'function' ? normalizePlusHostedCheckoutOauthDelaySeconds : ((value) => { @@ -4933,6 +4986,12 @@ function collectSettingsPayload() { kiroRsKey: currentKiroRsKeyValue !== null ? currentKiroRsKeyValue : String(latestState?.kiroRsKey || '').trim(), + grokWebchat2ApiUrl: currentGrokWebchat2ApiUrlValue !== null + ? currentGrokWebchat2ApiUrlValue + : String(latestState?.grokWebchat2ApiUrl || '').trim(), + grokWebchat2ApiAdminKey: currentGrokWebchat2ApiKeyValue !== null + ? currentGrokWebchat2ApiKeyValue + : String(latestState?.grokWebchat2ApiAdminKey || '').trim(), vpsUrl: inputVpsUrl.value.trim(), vpsPassword: inputVpsPassword.value, localCpaStep9Mode: getSelectedLocalCpaStep9Mode(), @@ -11128,6 +11187,12 @@ function applySettingsState(state) { if (typeof inputKiroRsKey !== 'undefined' && inputKiroRsKey) { inputKiroRsKey.value = String(state?.kiroRsKey || ''); } + if (typeof inputGrokWebchat2ApiUrl !== 'undefined' && inputGrokWebchat2ApiUrl) { + inputGrokWebchat2ApiUrl.value = String(state?.grokWebchat2ApiUrl || '').trim(); + } + if (typeof inputGrokWebchat2ApiKey !== 'undefined' && inputGrokWebchat2ApiKey) { + inputGrokWebchat2ApiKey.value = String(state?.grokWebchat2ApiAdminKey || ''); + } if (typeof displayKiroRsTestStatus !== 'undefined' && displayKiroRsTestStatus) { displayKiroRsTestStatus.textContent = kiroRsConnectionTestStatusText; } @@ -14053,23 +14118,6 @@ btnCopyGrokSso?.addEventListener('click', async () => { } }); -btnExportGrokSso?.addEventListener('click', () => { - try { - const cookies = normalizeGrokSsoCookies(latestState); - if (!cookies.length) { - throw new Error('没有可导出的 Grok SSO Cookie。'); - } - downloadTextFile( - `${cookies.join('\n')}\n`, - `grok-sso-cookies-${new Date().toISOString().replace(/[:.]/g, '-')}.txt`, - 'text/plain;charset=utf-8' - ); - showToast('Grok SSO Cookie 已导出。', 'success'); - } catch (error) { - showToast(error?.message || '导出 Grok SSO Cookie 失败。', 'error'); - } -}); - btnClearGrokSso?.addEventListener('click', async () => { try { const cookies = normalizeGrokSsoCookies(latestState); @@ -15776,6 +15824,16 @@ selectPlusAccountAccessStrategy?.addEventListener('change', () => { }); }); +[inputGrokWebchat2ApiUrl, inputGrokWebchat2ApiKey].forEach((input) => { + input?.addEventListener('input', () => { + markSettingsDirty(true); + scheduleSettingsAutoSave(); + }); + input?.addEventListener('blur', () => { + saveSettings({ silent: true }).catch(() => { }); + }); +}); + function syncCurrentIpProxyServiceProfileToLatestState() { const selectedService = normalizeIpProxyService( selectIpProxyService?.value || latestState?.ipProxyService || DEFAULT_IP_PROXY_SERVICE diff --git a/tests/background-grok-publisher-webchat2api.test.js b/tests/background-grok-publisher-webchat2api.test.js new file mode 100644 index 0000000..b1b9718 --- /dev/null +++ b/tests/background-grok-publisher-webchat2api.test.js @@ -0,0 +1,274 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +function loadPublisherApi() { + const stateSource = fs.readFileSync('flows/grok/background/state.js', 'utf8'); + const publisherSource = fs.readFileSync('flows/grok/background/publisher-webchat2api.js', 'utf8'); + const globalScope = {}; + new Function('self', `${stateSource}; ${publisherSource}; return self;`)(globalScope); + return globalScope.MultiPageBackgroundGrokPublisherWebchat2Api; +} + +function createJsonResponse(payload, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + statusText: status >= 200 && status < 300 ? 'OK' : 'Error', + text: async () => JSON.stringify(payload), + }; +} + +function getGrokRuntime(state = {}) { + return state?.runtimeState?.flowState?.grok || {}; +} + +test('grok webchat2api publisher exposes helpers and normalizes to origin inject endpoint', () => { + const api = loadPublisherApi(); + + assert.equal(typeof api?.createGrokWebchat2ApiPublisher, 'function'); + assert.equal( + api.buildWebchat2ApiInjectUrl('https://remote.example.com/admin/deep/path'), + 'https://remote.example.com/api/remote-account/inject' + ); + assert.equal( + api.buildWebchat2ApiInjectUrl('remote.example.com/admin'), + 'http://remote.example.com/api/remote-account/inject' + ); +}); + +test('grok webchat2api publisher requires URL, admin key, and SSO cookie', async () => { + const api = loadPublisherApi(); + + assert.throws( + () => api.buildWebchat2ApiInjectUrl(''), + /缺少 webchat2api 地址/ + ); + assert.throws( + () => api.buildGrokSsoInjectPayload(''), + /缺少 Grok SSO Cookie/ + ); + await assert.rejects( + () => api.uploadGrokSsoToWebchat2Api('http://remote.example.com', '', 'sso-cookie', async () => createJsonResponse({})), + /缺少 webchat2api 管理密钥/ + ); +}); + +test('grok webchat2api publisher posts Grok SSO payload with bearer admin key', async () => { + const api = loadPublisherApi(); + const requests = []; + + const result = await api.uploadGrokSsoToWebchat2Api( + 'https://remote.example.com/admin/deep/path', + ' admin-secret ', + 'sso-cookie-001', + async (url, options = {}) => { + requests.push({ + url, + method: options.method, + authorization: options.headers?.Authorization, + contentType: options.headers?.['Content-Type'], + body: JSON.parse(options.body), + }); + return createJsonResponse({ code: 0, data: { total: 1 }, message: 'ok' }); + } + ); + + assert.equal(requests.length, 1); + assert.equal(requests[0].url, 'https://remote.example.com/api/remote-account/inject'); + assert.equal(requests[0].method, 'POST'); + assert.equal(requests[0].authorization, 'Bearer admin-secret'); + assert.equal(requests[0].contentType, 'application/json'); + assert.deepEqual(requests[0].body, { + accounts: [{ + token: 'sso-cookie-001', + provider: 'grok', + type: 'sso', + }], + strategy: 'merge', + source_id: 'flowpilot-grok-sso', + source_name: 'FlowPilot Grok SSO', + provider: 'grok', + }); + assert.equal(result.endpointUrl, 'https://remote.example.com/api/remote-account/inject'); + assert.equal(result.message, 'ok'); +}); + +test('grok webchat2api publisher accepts code-zero envelopes and rejects HTTP/API errors', async () => { + const api = loadPublisherApi(); + + const success = await api.uploadGrokSsoToWebchat2Api( + 'http://remote.example.com', + 'admin-secret', + 'sso-cookie', + async () => createJsonResponse({ code: 0, data: { added: 1 } }) + ); + assert.equal(success.message, '上传成功'); + + await assert.rejects( + () => api.uploadGrokSsoToWebchat2Api( + 'http://remote.example.com', + 'admin-secret', + 'sso-cookie', + async () => createJsonResponse({ error: 'invalid admin key' }, 403) + ), + /webchat2api SSO 上传失败:invalid admin key/ + ); + + await assert.rejects( + () => api.uploadGrokSsoToWebchat2Api( + 'http://remote.example.com', + 'admin-secret', + 'sso-cookie', + async () => createJsonResponse({ code: 4001, message: 'bad token' }) + ), + /webchat2api SSO 上传失败:bad token/ + ); +}); + +test('grok webchat2api executor reads latest state and writes upload runtime without leaking secrets to logs', async () => { + const api = loadPublisherApi(); + const requests = []; + const logs = []; + const completed = []; + let liveState = { + grokWebchat2ApiUrl: '', + grokWebchat2ApiAdminKey: '', + grokSsoCookie: '', + settingsState: { + flows: { + grok: { + targets: { + webchat2api: { + baseUrl: 'https://remote.example.com/admin', + apiKey: 'live-admin-key', + }, + }, + }, + }, + }, + runtimeState: { + flowState: { + grok: { + sso: { + currentCookie: 'live-sso-cookie', + }, + }, + }, + }, + }; + const publisher = api.createGrokWebchat2ApiPublisher({ + addLog: async (message, level) => { + logs.push({ message, level }); + }, + completeNodeFromBackground: async (nodeId, payload) => { + completed.push({ nodeId, payload }); + }, + fetchImpl: async (url, options = {}) => { + requests.push({ + url, + authorization: options.headers?.Authorization, + body: JSON.parse(options.body), + }); + return createJsonResponse({ code: 0, message: 'uploaded' }); + }, + getState: async () => ({ ...liveState }), + setState: async (updates = {}) => { + liveState = { + ...liveState, + ...updates, + runtimeState: { + ...(liveState.runtimeState || {}), + ...(updates.runtimeState || {}), + flowState: { + ...(liveState.runtimeState?.flowState || {}), + ...(updates.runtimeState?.flowState || {}), + }, + }, + }; + }, + }); + + await publisher.executeGrokUploadSsoToWebchat2Api({ + nodeId: 'grok-upload-sso-to-webchat2api', + grokWebchat2ApiAdminKey: 'stale-key', + grokSsoCookie: 'stale-sso-cookie', + }); + + assert.equal(requests.length, 1); + assert.equal(requests[0].url, 'https://remote.example.com/api/remote-account/inject'); + assert.equal(requests[0].authorization, 'Bearer live-admin-key'); + assert.equal(requests[0].body.accounts[0].token, 'live-sso-cookie'); + assert.equal(completed.length, 1); + assert.equal(completed[0].nodeId, 'grok-upload-sso-to-webchat2api'); + assert.equal(getGrokRuntime(completed[0].payload).upload.status, 'uploaded'); + assert.equal(getGrokRuntime(completed[0].payload).upload.message, 'uploaded'); + assert.equal(getGrokRuntime(completed[0].payload).upload.targetUrl, 'https://remote.example.com/api/remote-account/inject'); + assert.equal(typeof getGrokRuntime(completed[0].payload).upload.uploadedAt, 'number'); + assert.equal(logs.some(({ message }) => message.includes('live-sso-cookie') || message.includes('live-admin-key')), false); +}); + +test('grok webchat2api executor persists failure state without completing or leaking secrets', async () => { + const api = loadPublisherApi(); + const logs = []; + const completed = []; + let liveState = { + settingsState: { + flows: { + grok: { + targets: { + webchat2api: { + baseUrl: 'https://remote.example.com/admin', + apiKey: 'secret-admin-key', + }, + }, + }, + }, + }, + runtimeState: { + flowState: { + grok: { + sso: { + currentCookie: 'secret-sso-cookie', + }, + }, + }, + }, + }; + const publisher = api.createGrokWebchat2ApiPublisher({ + addLog: async (message, level) => { + logs.push({ message, level }); + }, + completeNodeFromBackground: async (nodeId, payload) => { + completed.push({ nodeId, payload }); + }, + fetchImpl: async () => createJsonResponse({ error: 'invalid admin key' }, 403), + getState: async () => ({ ...liveState }), + setState: async (updates = {}) => { + liveState = { + ...liveState, + ...updates, + runtimeState: { + ...(liveState.runtimeState || {}), + ...(updates.runtimeState || {}), + flowState: { + ...(liveState.runtimeState?.flowState || {}), + ...(updates.runtimeState?.flowState || {}), + }, + }, + }; + }, + }); + + await assert.rejects( + () => publisher.executeGrokUploadSsoToWebchat2Api({ nodeId: 'grok-upload-sso-to-webchat2api' }), + /webchat2api SSO 上传失败:invalid admin key/ + ); + + assert.equal(completed.length, 0); + assert.equal(getGrokRuntime(liveState).upload.status, 'error'); + assert.equal(getGrokRuntime(liveState).upload.uploadedAt, 0); + assert.equal(getGrokRuntime(liveState).upload.message, 'webchat2api SSO 上传失败:invalid admin key'); + assert.equal(getGrokRuntime(liveState).upload.targetUrl, 'https://remote.example.com/api/remote-account/inject'); + assert.equal(logs.some(({ message }) => message.includes('secret-sso-cookie') || message.includes('secret-admin-key')), false); +}); diff --git a/tests/background-grok-state-module.test.js b/tests/background-grok-state-module.test.js index 2eb3674..34eee8e 100644 --- a/tests/background-grok-state-module.test.js +++ b/tests/background-grok-state-module.test.js @@ -37,6 +37,12 @@ test('grok state view projects canonical runtime state into legacy flat read fie cookies: ['cookie-a', 'cookie-b', 'cookie-a'], extractedAt: 3000, }, + upload: { + status: 'uploaded', + uploadedAt: 4000, + message: 'ok', + targetUrl: 'https://remote.example.com/api/remote-account/inject', + }, }, }, }, @@ -55,9 +61,14 @@ test('grok state view projects canonical runtime state into legacy flat read fie assert.equal(view.grokSsoCookie, 'cookie-a'); assert.deepEqual(view.grokSsoCookies, ['cookie-a', 'cookie-b']); assert.equal(view.grokSsoExtractedAt, 3000); + assert.equal(view.grokWebchat2ApiUploadStatus, 'uploaded'); + assert.equal(view.grokWebchat2ApiUploadedAt, 4000); + assert.equal(view.grokWebchat2ApiUploadMessage, 'ok'); + assert.equal(view.grokWebchat2ApiTargetUrl, 'https://remote.example.com/api/remote-account/inject'); assert.equal(view.runtimeState.flowState.openai.preserved, true); assert.equal(view.runtimeState.flowState.grok.register.email, 'user@example.com'); assert.equal(view.flowState.grok.sso.currentCookie, 'cookie-a'); + assert.equal(view.flowState.grok.upload.status, 'uploaded'); assert.equal(view.flows.grok.sso.cookies.length, 2); }); @@ -69,6 +80,10 @@ test('grok completion payloads update canonical runtime state and flat compatibi grokSsoCookie: 'cookie-z', grokSsoCookies: ['cookie-z', 'cookie-z', 'cookie-y'], grokCompletedAt: 456, + grokWebchat2ApiUploadStatus: 'uploaded', + grokWebchat2ApiUploadedAt: 789, + grokWebchat2ApiUploadMessage: '上传成功', + grokWebchat2ApiTargetUrl: 'http://remote.example.com/api/remote-account/inject', }); assert.equal(patch.grokEmail, 'grok@example.com'); @@ -77,11 +92,39 @@ test('grok completion payloads update canonical runtime state and flat compatibi assert.deepEqual(patch.grokSsoCookies, ['cookie-z', 'cookie-y']); assert.equal(patch.grokCompletedAt, 456); assert.equal(patch.grokSsoExtractedAt, 456); + assert.equal(patch.grokWebchat2ApiUploadStatus, 'uploaded'); + assert.equal(patch.grokWebchat2ApiUploadedAt, 789); + assert.equal(patch.grokWebchat2ApiUploadMessage, '上传成功'); + assert.equal(patch.grokWebchat2ApiTargetUrl, 'http://remote.example.com/api/remote-account/inject'); assert.equal(patch.runtimeState.flowState.grok.register.email, 'grok@example.com'); assert.equal(patch.runtimeState.flowState.grok.sso.currentCookie, 'cookie-z'); + assert.equal(patch.runtimeState.flowState.grok.upload.status, 'uploaded'); }); -test('grok fresh keep-state reset preserves SSO cookies but clears registration runtime', () => { +test('grok state keeps canonical runtime values when flat compatibility fields are empty', () => { + const api = loadGrokStateApi(); + const runtimeState = api.ensureRuntimeState({ + grokSsoCookie: '', + grokSsoCookies: [], + runtimeState: { + flowState: { + grok: { + sso: { + currentCookie: 'canonical-cookie', + cookies: ['canonical-cookie'], + extractedAt: 1234, + }, + }, + }, + }, + }); + + assert.equal(runtimeState.sso.currentCookie, 'canonical-cookie'); + assert.deepEqual(runtimeState.sso.cookies, ['canonical-cookie']); + assert.equal(runtimeState.sso.extractedAt, 1234); +}); + +test('grok fresh keep-state reset clears registration, SSO, and upload runtime', () => { const api = loadGrokStateApi(); const patch = api.buildFreshKeepState({ runtimeState: { @@ -101,6 +144,12 @@ test('grok fresh keep-state reset preserves SSO cookies but clears registration cookies: ['cookie-a', 'cookie-b'], extractedAt: 2000, }, + upload: { + status: 'uploaded', + uploadedAt: 3000, + message: 'ok', + targetUrl: 'https://remote.example.com/api/remote-account/inject', + }, }, }, }, @@ -111,11 +160,16 @@ test('grok fresh keep-state reset preserves SSO cookies but clears registration assert.equal(patch.grokEmail, ''); assert.equal(patch.grokRegisterStatus, ''); assert.equal(patch.grokCompletedAt, 0); - assert.equal(patch.grokSsoCookie, 'cookie-a'); - assert.deepEqual(patch.grokSsoCookies, ['cookie-a', 'cookie-b']); - assert.equal(patch.grokSsoExtractedAt, 2000); + assert.equal(patch.grokSsoCookie, ''); + assert.deepEqual(patch.grokSsoCookies, []); + assert.equal(patch.grokSsoExtractedAt, 0); + assert.equal(patch.grokWebchat2ApiUploadStatus, ''); + assert.equal(patch.grokWebchat2ApiUploadedAt, 0); + assert.equal(patch.grokWebchat2ApiUploadMessage, ''); + assert.equal(patch.grokWebchat2ApiTargetUrl, ''); assert.equal(patch.runtimeState.flowState.grok.register.email, ''); - assert.equal(patch.runtimeState.flowState.grok.sso.currentCookie, 'cookie-a'); + assert.equal(patch.runtimeState.flowState.grok.sso.currentCookie, ''); + assert.equal(patch.runtimeState.flowState.grok.upload.status, ''); }); test('grok downstream reset clears only the state owned by the restarted tail', () => { diff --git a/tests/background-step-registry.test.js b/tests/background-step-registry.test.js index a3cf0c9..029eb85 100644 --- a/tests/background-step-registry.test.js +++ b/tests/background-step-registry.test.js @@ -19,12 +19,14 @@ test('background imports node registry and wires the rebuilt Kiro executors', () assert.match(source, /flows\/kiro\/background\/publisher-kiro-rs\.js/); assert.match(source, /flows\/grok\/background\/state\.js/); assert.match(source, /flows\/grok\/background\/register-runner\.js/); + assert.match(source, /flows\/grok\/background\/publisher-webchat2api\.js/); assert.doesNotMatch(source, /background\/steps\/kiro-device-auth\.js/); assert.match(source, /const kiroRegisterRunner = self\.MultiPageBackgroundKiroRegisterRunner\?\.createKiroRegisterRunner\(/); assert.match(source, /const kiroDesktopAuthorizeRunner = self\.MultiPageBackgroundKiroDesktopAuthorizeRunner\?\.createKiroDesktopAuthorizeRunner\(/); assert.match(source, /const kiroPublisher = self\.MultiPageBackgroundKiroPublisherKiroRs\?\.createKiroRsPublisher\(/); assert.match(source, /const grokRegisterRunner = self\.MultiPageBackgroundGrokRegisterRunner\?\.createGrokRegisterRunner\(/); + assert.match(source, /const grokWebchat2ApiPublisher = self\.MultiPageBackgroundGrokPublisherWebchat2Api\?\.createGrokWebchat2ApiPublisher\(/); assert.match(source, /'kiro-open-register-page': \(state\) => kiroRegisterRunner\.executeKiroOpenRegisterPage\(state\)/); assert.match(source, /'kiro-submit-email': \(state\) => kiroRegisterRunner\.executeKiroSubmitEmail\(state\)/); @@ -40,6 +42,7 @@ test('background imports node registry and wires the rebuilt Kiro executors', () assert.match(source, /'grok-submit-verification-code': \(state\) => grokRegisterRunner\.executeGrokSubmitVerificationCode\(state\)/); assert.match(source, /'grok-submit-profile': \(state\) => grokRegisterRunner\.executeGrokSubmitProfile\(state\)/); assert.match(source, /'grok-extract-sso-cookie': \(state\) => grokRegisterRunner\.executeGrokExtractSsoCookie\(state\)/); + assert.match(source, /'grok-upload-sso-to-webchat2api': \(state\) => grokWebchat2ApiPublisher\.executeGrokUploadSsoToWebchat2Api\(state\)/); assert.match( source, @@ -47,7 +50,7 @@ test('background imports node registry and wires the rebuilt Kiro executors', () ); assert.match( source, - /'grok-open-signup-page',[\s\S]*'grok-submit-email',[\s\S]*'grok-submit-verification-code',[\s\S]*'grok-submit-profile',[\s\S]*'grok-extract-sso-cookie'/ + /'grok-open-signup-page',[\s\S]*'grok-submit-email',[\s\S]*'grok-submit-verification-code',[\s\S]*'grok-submit-profile',[\s\S]*'grok-extract-sso-cookie',[\s\S]*'grok-upload-sso-to-webchat2api'/ ); }); diff --git a/tests/flow-registry-settings-schema.test.js b/tests/flow-registry-settings-schema.test.js index 0c9169c..384acc6 100644 --- a/tests/flow-registry-settings-schema.test.js +++ b/tests/flow-registry-settings-schema.test.js @@ -153,7 +153,7 @@ test('settings schema can project canonical state into a read view without legac assert.deepEqual(view.stepExecutionRangeByFlow.grok, { enabled: false, fromStep: 1, - toStep: 5, + toStep: 6, }); }); diff --git a/tests/grok-runner.test.js b/tests/grok-runner.test.js index 1b5d652..ded1d88 100644 --- a/tests/grok-runner.test.js +++ b/tests/grok-runner.test.js @@ -110,7 +110,7 @@ test('grok verification runner polls by flow node and submits normalized code', assert.equal(getGrokRuntime(completedPayload).register.status, 'verified'); }); -test('grok SSO extraction accumulates unique cookies without logging the secret value', async () => { +test('grok SSO extraction stores only the current cookie without logging the secret value', async () => { const api = loadGrokRunnerApi(); const logs = []; let completedPayload = null; @@ -118,7 +118,7 @@ test('grok SSO extraction accumulates unique cookies without logging the secret let currentState = { activeFlowId: 'grok', grokRegisterTabId: 202, - grokSsoCookies: ['existing-cookie', 'new-cookie'], + grokSsoCookies: ['old-cookie'], runtimeState: { flowState: { grok: { @@ -126,7 +126,13 @@ test('grok SSO extraction accumulates unique cookies without logging the secret registerTabId: 202, }, sso: { - cookies: ['existing-cookie', 'new-cookie'], + cookies: ['old-cookie'], + }, + upload: { + status: 'uploaded', + uploadedAt: 1000, + message: 'old upload', + targetUrl: 'https://old.example.com/api/remote-account/inject', }, }, }, @@ -169,9 +175,12 @@ test('grok SSO extraction accumulates unique cookies without logging the secret await runner.executeGrokExtractSsoCookie({ nodeId: 'grok-extract-sso-cookie', ...currentState }); assert.equal(completedPayload.grokSsoCookie, 'new-cookie'); - assert.deepEqual(completedPayload.grokSsoCookies, ['existing-cookie', 'new-cookie']); + assert.deepEqual(completedPayload.grokSsoCookies, ['new-cookie']); + assert.equal(completedPayload.grokWebchat2ApiUploadStatus, ''); assert.equal(getGrokRuntime(completedPayload).sso.currentCookie, 'new-cookie'); - assert.deepEqual(getGrokRuntime(completedPayload).sso.cookies, ['existing-cookie', 'new-cookie']); + assert.deepEqual(getGrokRuntime(completedPayload).sso.cookies, ['new-cookie']); + assert.equal(getGrokRuntime(completedPayload).upload.status, ''); + assert.equal(getGrokRuntime(completedPayload).upload.targetUrl, ''); assert.equal(markUsedPayload.grokSsoCookie, 'new-cookie'); assert.equal(logs.some(({ message }) => message.includes('new-cookie')), false); }); diff --git a/tests/sidepanel-flow-source-registry.test.js b/tests/sidepanel-flow-source-registry.test.js index f248545..159d8db 100644 --- a/tests/sidepanel-flow-source-registry.test.js +++ b/tests/sidepanel-flow-source-registry.test.js @@ -49,15 +49,17 @@ test('sidepanel html exposes flow selector and kiro source fields', () => { 'id="row-kiro-upload-status"', 'id="row-grok-register-status"', 'id="row-grok-sso-status"', + 'id="row-grok-webchat2api-upload-status"', + 'id="display-grok-webchat2api-upload-status"', 'id="row-grok-sso-settings"', 'id="btn-copy-grok-sso"', - 'id="btn-export-grok-sso"', 'id="btn-clear-grok-sso"', '', '', ].forEach((snippet) => { assert.match(sidepanelHtml, new RegExp(snippet.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); }); + assert.doesNotMatch(sidepanelHtml, /id="btn-export-grok-sso"/); assert.ok( sidepanelHtml.indexOf('') < sidepanelHtml.indexOf('') @@ -86,6 +88,7 @@ test('sidepanel renders Grok SSO status from canonical runtime state', () => { extractFunction(sidepanelSource, 'getGrokRuntimeState'), extractFunction(sidepanelSource, 'normalizeGrokSsoCookies'), extractFunction(sidepanelSource, 'getGrokRegisterStatusLabel'), + extractFunction(sidepanelSource, 'getGrokWebchat2ApiUploadStatusLabel'), extractFunction(sidepanelSource, 'renderGrokRuntimeState'), ].join('\n'); @@ -94,17 +97,17 @@ let latestState = {}; const displayGrokRegisterStatus = { textContent: '' }; const displayGrokSsoStatus = { textContent: '' }; const displayGrokSsoCookie = { textContent: '', title: '' }; +const displayGrokWebchat2ApiUploadStatus = { textContent: '', title: '' }; const buttons = []; const btnCopyGrokSso = { disabled: false }; -const btnExportGrokSso = { disabled: false }; const btnClearGrokSso = { disabled: false }; ${bundle} return { displayGrokRegisterStatus, displayGrokSsoStatus, displayGrokSsoCookie, + displayGrokWebchat2ApiUploadStatus, btnCopyGrokSso, - btnExportGrokSso, btnClearGrokSso, renderGrokRuntimeState, }; @@ -120,6 +123,12 @@ return { cookies: ['1234567890abcdef', 'second-cookie'], extractedAt: 0, }, + upload: { + status: 'uploaded', + uploadedAt: 0, + message: '上传成功', + targetUrl: 'https://remote.example.com/api/remote-account/inject', + }, }, }, }, @@ -128,8 +137,9 @@ return { assert.equal(api.displayGrokRegisterStatus.textContent, '已完成'); assert.match(api.displayGrokSsoStatus.textContent, /^已提取 2 条/); assert.equal(api.displayGrokSsoCookie.textContent, '12345678...abcdef'); + assert.equal(api.displayGrokWebchat2ApiUploadStatus.textContent, '已上传:上传成功'); + assert.equal(api.displayGrokWebchat2ApiUploadStatus.title, 'https://remote.example.com/api/remote-account/inject'); assert.equal(api.btnCopyGrokSso.disabled, false); - assert.equal(api.btnExportGrokSso.disabled, false); assert.equal(api.btnClearGrokSso.disabled, false); }); diff --git a/tests/source-registry-module.test.js b/tests/source-registry-module.test.js index 74ea244..3a40f31 100644 --- a/tests/source-registry-module.test.js +++ b/tests/source-registry-module.test.js @@ -197,4 +197,5 @@ test('shared source registry exposes canonical Kiro sources and drivers', () => assert.equal(registry.driverAcceptsCommand('flows/kiro/background/publisher-kiro-rs', 'kiro-upload-credential'), true); assert.equal(registry.driverAcceptsCommand('flows/grok/content/register-page', 'grok-submit-profile'), true); assert.equal(registry.driverAcceptsCommand('flows/grok/background/register-runner', 'grok-extract-sso-cookie'), true); + assert.equal(registry.driverAcceptsCommand('flows/grok/background/publisher-webchat2api', 'grok-upload-sso-to-webchat2api'), true); }); diff --git a/tests/step-definitions-module.test.js b/tests/step-definitions-module.test.js index ee5a466..805e636 100644 --- a/tests/step-definitions-module.test.js +++ b/tests/step-definitions-module.test.js @@ -220,6 +220,7 @@ test('step definitions module exposes ordered normal and Plus step metadata', () 'grok-submit-verification-code', 'grok-submit-profile', 'grok-extract-sso-cookie', + 'grok-upload-sso-to-webchat2api', ] ); assert.equal(grokSteps.every((step) => step.flowId === 'grok'), true); @@ -228,10 +229,10 @@ test('step definitions module exposes ordered normal and Plus step metadata', () assert.equal(grokSteps[2].mailRuleId, 'grok-submit-verification-code'); assert.deepStrictEqual( grokSteps.map((step) => step.title), - ['打开 Grok 注册页', '获取邮箱并继续', '获取验证码并继续', '填写资料并继续', '提取 SSO Cookie'] + ['打开 Grok 注册页', '获取邮箱并继续', '获取验证码并继续', '填写资料并继续', '提取 SSO Cookie', '上传 SSO 到 webchat2api'] ); - assert.deepStrictEqual(api.getStepIds({ activeFlowId: 'grok' }), [1, 2, 3, 4, 5]); - assert.equal(api.getLastStepId({ activeFlowId: 'grok' }), 5); + assert.deepStrictEqual(api.getStepIds({ activeFlowId: 'grok' }), [1, 2, 3, 4, 5, 6]); + assert.equal(api.getLastStepId({ activeFlowId: 'grok' }), 6); assert.deepStrictEqual( api.getNodes({ activeFlowId: 'grok' }).map((node) => node.next), [ @@ -239,6 +240,7 @@ test('step definitions module exposes ordered normal and Plus step metadata', () ['grok-submit-verification-code'], ['grok-submit-profile'], ['grok-extract-sso-cookie'], + ['grok-upload-sso-to-webchat2api'], [], ] ); diff --git a/项目完整链路说明.md b/项目完整链路说明.md index 90bb7d0..f457ee6 100644 --- a/项目完整链路说明.md +++ b/项目完整链路说明.md @@ -14,7 +14,7 @@ - `openai`:OpenAI / ChatGPT OAuth 注册、登录、平台绑定与 Plus 扩展链路 - `kiro`:AWS Builder ID 注册页 + 桌面授权 + `kiro.rs` 凭据上传链路 -- `grok`:Grok / xAI 注册页自动化 + `webchat2api` SSO Cookie 本地导出链路 +- `grok`:Grok / xAI 注册页自动化 + `webchat2api` SSO Cookie 远程上传链路 它的核心价值不是“打开一个页面点几个按钮”,而是把下面这些环节串成一条完整可恢复的自动化链路: @@ -45,7 +45,7 @@ - 接收后台广播并更新 UI - 动态渲染步骤列表 - 维护 `activeFlowId + targetId` 的双层选择;`openai` flow 继续把 `panelMode` 归一为 legacy integration target,`kiro` / `grok` flow 使用各自的 `flows..targetId` -- 按当前 flow capability 决定显隐分组;Kiro flow 只显示来源下拉、`kiro.rs URL / API Key`、共享邮箱服务、共享 IP 代理与 Kiro 运行状态;Grok flow 只显示 `webchat2api` SSO 导出状态、共享账号密码、共享邮箱服务和共享 IP 代理;二者都不显示 OpenAI 接码 / Plus / 平台绑定配置 +- 按当前 flow capability 决定显隐分组;Kiro flow 只显示来源下拉、`kiro.rs URL / API Key`、共享邮箱服务、共享 IP 代理与 Kiro 运行状态;Grok flow 只显示 `webchat2api` URL / 管理密钥、SSO 提取与上传状态、共享账号密码、共享邮箱服务和共享 IP 代理;二者都不显示 OpenAI 接码 / Plus / 平台绑定配置 - 管理顶部“贡献”按钮与贡献模式主面板;贡献模式本身是 sidepanel 的运行态 UI 模式,不是新的 `panelMode` 来源 - 在贡献模式下复用同一套主自动流程启动,并在面板内展示贡献链路的 `OAUTH / 回调 / 总状态` 三块实时状态 - 在顶部“贡献/使用”按钮下方展示一个非强制的内容更新轻提示;提示来源于 `flowpilot.qlhazycoder.top` 的公开公告 / 教程摘要,用户关闭后仅对当前 `promptVersion` 静默,下次内容版本变化后会重新出现 @@ -157,7 +157,7 @@ ### 3.4 步骤注册 -[data/step-definitions.js](./data/step-definitions.js) 提供共享步骤元数据,并按当前 `activeFlowId` 输出 flow-specific workflow。`openai` flow 会继续按 `plusPaymentMethod / signupMethod / plusAccountAccessStrategy` 动态解析步骤标题与节点集合:当 Plus 模式为邮箱注册且来源支持时,原本的 OAuth 尾链可以被替换成 `sub2api-session-import` 或 `cpa-session-import`;`kiro` flow 输出固定的 9 节点注册/桌面授权/上传 workflow;`grok` flow 输出固定的 5 节点注册与 SSO Cookie 提取 workflow。 +[data/step-definitions.js](./data/step-definitions.js) 提供共享步骤元数据,并按当前 `activeFlowId` 输出 flow-specific workflow。`openai` flow 会继续按 `plusPaymentMethod / signupMethod / plusAccountAccessStrategy` 动态解析步骤标题与节点集合:当 Plus 模式为邮箱注册且来源支持时,原本的 OAuth 尾链可以被替换成 `sub2api-session-import` 或 `cpa-session-import`;`kiro` flow 输出固定的 9 节点注册/桌面授权/上传 workflow;`grok` flow 输出固定的 6 节点注册、SSO Cookie 提取与 `webchat2api` 上传 workflow。 [core/flow-kernel/step-registry.js](./core/flow-kernel/step-registry.js) 负责把“workflow node 元数据”映射到“步骤执行器”,同时保留 `flowId / nodeId / displayOrder / executeKey` 等稳定节点元数据。 这意味着: @@ -216,7 +216,7 @@ - `contributionLastPollAt` - OAuth 链接 - Kiro 运行态:使用 `kiroRuntime.session / register / webAuth / desktopAuth / upload` 命名空间,保存 Kiro 官方登录入口、Builder ID 注册页状态、Kiro Web 登录态摘要、桌面授权 PKCE 凭据、上传状态和当前 Kiro 会话进度 -- Grok 运行态:权威状态位于 `runtimeState.flowState.grok`,包含 `session / register / sso`;顶层 `grokRegisterTabId / grokPageState / grokEmail / grokPassword / grokSsoCookie / grokSsoCookies` 只作为兼容投影,不应当再形成第二套状态机 +- Grok 运行态:权威状态位于 `runtimeState.flowState.grok`,包含 `session / register / sso / upload`;顶层 `grokRegisterTabId / grokPageState / grokEmail / grokPassword / grokSsoCookie / grokSsoCookies / grokWebchat2ApiUpload*` 只作为兼容投影,不应当再形成第二套状态机;兼容 flat 字段只有非空值才能覆盖 canonical runtime,避免空旧字段反向覆盖本轮 SSO - 当前轮冻结后的注册方式 `resolvedSignupMethod` - 当前统一账号标识 `accountIdentifierType / accountIdentifier` - 当前邮箱 / 密码 @@ -771,7 +771,7 @@ Kiro flow 的目标不是复用 OpenAI 注册链,而是独立完成“Kiro 官 ## 6.3 Grok Flow 链路 -Grok flow 的目标是完成 Grok / xAI 注册页自动化,并把 `sso` Cookie 作为 `webchat2api` 可用的本地导出产物。它不是 OpenAI 目标来源,也不是公开贡献 / 发布 flow。 +Grok flow 的目标是完成 Grok / xAI 注册页自动化,并把本轮提取到的 `sso` Cookie 上传到 `webchat2api` 的远程账号注入接口。它不是 OpenAI 目标来源,也不是公开贡献 / 发布 flow。 链路如下: @@ -780,15 +780,17 @@ Grok flow 的目标是完成 Grok / xAI 注册页自动化,并把 `sso` Cookie 3. 步骤 2 通过共享邮箱服务生成或复用注册邮箱,并提交到 xAI 登录/注册页 4. 步骤 3 调用 `background/flow-mail-polling.js`,由 `flows/grok/mail-rules.js` 构造 xAI / Grok 验证码规则并轮询邮箱验证码,再交给 Grok 内容脚本提交 5. 步骤 4 填写资料和密码,推进注册完成页状态 -6. 步骤 5 先通过 `chrome.cookies.get` 从 `x.ai / grok.com / accounts.x.ai` 查找 `sso` Cookie,必要时 fallback 到 Grok 内容脚本读取 `document.cookie`;提取到的值保存到 `runtimeState.flowState.grok.sso` 并投影到 `grokSsoCookie / grokSsoCookies` -7. sidepanel 只展示掩码后的 SSO 值,复制和导出才使用完整值;清空动作必须发 `CLEAR_GROK_SSO_COOKIES` 给后台,由 `flows/grok/background/state.js` 构造 patch,不能在 sidepanel 直接写 `chrome.storage` +6. 步骤 5 先通过 `chrome.cookies.get` 从 `x.ai / grok.com / accounts.x.ai` 查找 `sso` Cookie,必要时 fallback 到 Grok 内容脚本读取 `document.cookie`;提取到的当前值保存到 `runtimeState.flowState.grok.sso` 并投影到 `grokSsoCookie / grokSsoCookies`,其中 `grokSsoCookies` 只保留本轮当前 Cookie,不再累积历史列表 +7. 步骤 6 由 `flows/grok/background/publisher-webchat2api.js` 读取 `settingsState.flows.grok.targets.webchat2api.baseUrl / apiKey` 和当前 SSO,向 `${origin}/api/remote-account/inject` 发送 Bearer 管理密钥请求,payload 使用 `accounts[{ token, provider:'grok', type:'sso' }] + strategy:'merge'` +8. 上传结果保存到 `runtimeState.flowState.grok.upload` 并投影到 `grokWebchat2ApiUploadStatus / grokWebchat2ApiUploadedAt / grokWebchat2ApiUploadMessage / grokWebchat2ApiTargetUrl`;缺 URL、缺管理密钥或缺 SSO 都必须报错,不允许静默跳过 +9. sidepanel 只展示掩码后的 SSO 值与上传状态;复制按钮仅用于排障,不提供本地文件导出;清空动作必须发 `CLEAR_GROK_SSO_COOKIES` 给后台,由 `flows/grok/background/state.js` 构造 patch,并同步清空 upload 状态,不能在 sidepanel 直接写 `chrome.storage` 这条链路的关键边界是: - Grok 只复用共享账户密码、共享邮箱服务和共享 IP 代理服务,不复用 OpenAI 接码 / Plus / 平台绑定 / 贡献模式链路 -- Grok 自动运行走通用 linear node runner,执行器、运行态、页面驱动和邮件规则全部放在 `flows/grok/*` +- Grok 自动运行走通用 linear node runner,执行器、运行态、页面驱动、邮件规则和 `webchat2api` 上传器全部放在 `flows/grok/*` - Grok 内容脚本不能全局 patch 浏览器原型;点击事件需要的坐标只能在局部事件参数里补齐 -- Grok SSO Cookie 是本地导出产物,`flows/grok/index.js` 的 `publicationTargets` 保持为空,`supportsAccountContribution` 保持为 `false` +- Grok SSO 上传是 Grok flow 自己的收尾节点,`flows/grok/index.js` 的 `publicationTargets` 保持为空,`supportsAccountContribution` 保持为 `false` - `shared/contribution-registry.js` 的默认发布贡献校验只覆盖声明了发布目标或支持贡献的 flow;Grok 不应被自动纳入贡献 adapter 缺失检查。如果未来要把 Grok 改成正式发布 flow,必须先新增明确的 publication target 与 contribution adapter,再打开对应能力 ## 7. 邮箱与 provider 链路 @@ -1160,7 +1162,7 @@ Hide My Email 获取与管理链路: - 如果当前 `Mail = 自定义邮箱` 且配置了 `customMailProviderPool`,会先按当前目标轮次把号池中的对应邮箱写回运行态 - 如果当前生成方式是 `custom-pool`,会先按当前目标轮次把邮箱池中的对应邮箱写回运行态 - fresh-attempt reset 会保留 `stepExecutionRangeByFlow`,避免重置运行态时丢失用户设置的执行窗口 - - Grok fresh-attempt reset 会清理注册页 runtime,但保留已提取的 `grokSsoCookies`,避免用户导出前被下一轮准备动作清空 + - Grok fresh-attempt reset 会清理注册页 runtime、当前 SSO 与 upload 状态,避免下一轮误上传上一轮旧 SSO 5. 执行 `runAutoSequenceFromStep` - 如果当前 `activeFlowId !== openai`,后台会切到通用 linear node runner,按当前 flow 的 workflow node 顺序执行,并复用统一的完成状态与 idle watchdog - 如果 `stepExecutionRangeByFlow` 已启用,自动运行只会遍历允许范围内的 workflow node;范围外节点会被跳过,`getFirstUnfinishedNodeId` 与保存进度判断也只统计允许节点 diff --git a/项目开发规范(AI协作).md b/项目开发规范(AI协作).md index 2d608d7..8041001 100644 --- a/项目开发规范(AI协作).md +++ b/项目开发规范(AI协作).md @@ -171,7 +171,7 @@ - `resolvedSignupMethod` 是当前轮冻结结果,不等同于用户此刻 UI 上选择的 `signupMethod`。 - 强制绑定邮箱后重登用邮箱身份,只能通过单次执行参数覆盖登录身份,不能持久改写 `signupMethod`。 - flow 能力不足时必须在步骤定义层或启动校验层处理,不能等执行到不存在的节点后才报错。 -- 本地导出型 flow、发布型 flow、贡献型 flow 必须显式区分:只有声明了 `publicationTargets` 或明确支持贡献能力的 flow 才能进入发布/贡献 adapter 校验;例如 Grok / `webchat2api` SSO Cookie 导出属于本地导出 flow,不能因为有 target 就默认接入贡献模式。 +- flow 私有远程上传、发布型 flow、贡献型 flow 必须显式区分:只有声明了 `publicationTargets` 或明确支持贡献能力的 flow 才能进入发布/贡献 adapter 校验;例如 Grok / `webchat2api` SSO Cookie 上传属于 Grok flow 自己的收尾节点,不是 publication target,也不能因为有 target 就默认接入贡献模式。 ### 1.7 日志步骤号原则 @@ -286,7 +286,7 @@ - 贡献流程的后台公开 OAuth 状态机应优先收敛到独立模块,例如 `background/contribution-oauth.js` - 贡献模式的侧栏按钮、状态展示和轮询调度应优先收敛到独立 manager,例如 `sidepanel/contribution-mode.js` - 如果服务端当前返回“无需手动提交 callback”,扩展端必须把它当兼容成功态处理,不能简单按 HTTP 非 200 直接视为失败 -- 如果新增 flow 只是本地导出产物,例如 Cookie、JSON、会话文件、手动复制值,必须保持 `publicationTargets` 为空并关闭 `supportsAccountContribution`;如果后续产品决定发布到远端服务,必须先设计 publication target、贡献 adapter、敏感字段脱敏和测试,再打开能力开关。 +- 如果新增 flow 只是私有收尾产物,例如 Cookie、JSON、会话文件、手动复制值,或只上传到该 flow 自己的专属远端服务,必须保持 `publicationTargets` 为空并关闭 `supportsAccountContribution`;如果后续产品决定把它纳入正式发布 / 贡献体系,必须先设计 publication target、贡献 adapter、敏感字段脱敏和测试,再打开能力开关。 ### 3.4.1 iCloud Hide My Email 维护补充 @@ -497,7 +497,7 @@ npm test 15. 如果创建了 `docs/md/` 方案文件,我有没有确认它是否应被提交? 16. 如果改动涉及 flow、步骤、模式切换,我有没有同时检查 `getSteps / getNodes / getWorkflow`、后台 registry、sidepanel `workflowNodes` 和 node 状态? 17. 我有没有新增任何用数字步骤判断业务含义的代码?如果有,是否能改为 `key / nodeId`? -18. 如果新增或调整 flow target,我有没有确认它到底是本地导出、正式发布还是贡献 flow,并同步检查 `publicationTargets / supportsAccountContribution / contributionAdapterIds / shared/contribution-registry.js`? +18. 如果新增或调整 flow target,我有没有确认它到底是 flow 私有收尾目标、正式发布目标还是贡献 flow,并同步检查 `publicationTargets / supportsAccountContribution / contributionAdapterIds / shared/contribution-registry.js`? ## 9. 完成标准 diff --git a/项目文件结构说明.md b/项目文件结构说明.md index af6564e..8c8669e 100644 --- a/项目文件结构说明.md +++ b/项目文件结构说明.md @@ -21,7 +21,7 @@ - `.gitignore`:定义仓库忽略规则,当前忽略 `docs/md/`、`.github/`、`_metadata/`、`.vscode/` 等目录。 - `LICENSE`:项目许可证文件。 - `README.md`:面向使用者的精简说明文档,主要介绍项目用途、功能范围、快速开始与文档入口;不再承载过多技术实现细节。 -- `background.js`:扩展后台 Service Worker 入口壳,负责模块装配、初始化、全局常量、少量保留的领域函数与运行入口;当前已按 `activeFlowId` 装配 flow-aware 的步骤定义、执行注册表、自动运行与状态同步,`openai` flow 继续承接 `oauthFlowTimeoutEnabled` 与 `stepExecutionRangeByFlow`,`kiro` flow 走独立的“注册页 1-6 步 -> 桌面授权 7-8 步 -> `kiro.rs` 上传 9 步”链路,`grok` flow 走独立的“注册页 1-4 步 -> SSO Cookie 提取 5 步”链路。 +- `background.js`:扩展后台 Service Worker 入口壳,负责模块装配、初始化、全局常量、少量保留的领域函数与运行入口;当前已按 `activeFlowId` 装配 flow-aware 的步骤定义、执行注册表、自动运行与状态同步,`openai` flow 继续承接 `oauthFlowTimeoutEnabled` 与 `stepExecutionRangeByFlow`,`kiro` flow 走独立的“注册页 1-6 步 -> 桌面授权 7-8 步 -> `kiro.rs` 上传 9 步”链路,`grok` flow 走独立的“注册页 1-4 步 -> SSO Cookie 提取 5 步 -> `webchat2api` 上传 6 步”链路。 - `cloudmail-utils.js`:Cloud Mail / SkyMail 相关的纯工具函数,负责 API 地址、域名、鉴权头、邮件列表响应与邮件正文归一化。 - `cloudflare-temp-email-utils.js`:Cloudflare Temp Email 相关的纯工具函数,负责 URL、域名、邮件内容与 MIME 数据归一化。 - `gopay-utils.js`:GoPay / GPC Plus 支付相关纯工具函数,负责支付方式归一化、GoPay 手机号/OTP/PIN 归一化、GPC API 地址归一化、API Key 请求头、任务创建/查询/OTP/PIN/停止 URL 与余额响应解析。 @@ -59,8 +59,9 @@ - `flows/kiro/background/desktop-client.js`:Kiro 桌面授权协议层,负责桌面 OIDC client 注册、PKCE 参数生成、授权地址组装、callback 参数校验和授权码换 token。 - `flows/kiro/background/desktop-authorize-runner.js`:Kiro 桌面授权执行器,负责步骤 7~8 的标签页打开、授权页轮询、localhost callback 捕获,以及桌面授权完成后的凭据落库。 - `flows/kiro/background/publisher-kiro-rs.js`:Kiro 发布器,负责 `kiro.rs` 地址归一化、连通性探测、BuilderId profileArn 固定映射、machineId 计算、上传 payload 构建与最终凭据上传。 -- `flows/grok/background/state.js`:Grok 独立运行态模型与状态工具,负责 `runtimeState.flowState.grok.session / register / sso` 的默认值、归一化、兼容字段投影、节点完成回写、下游重置和 fresh-attempt keep-state 构建。 -- `flows/grok/background/register-runner.js`:Grok 注册页执行器,负责步骤 1~5 的编排、Grok/xAI 注册标签页管理、邮箱提交、验证码轮询提交、资料/密码提交、SSO Cookie 提取、去重保存与账号记录收尾。 +- `flows/grok/background/state.js`:Grok 独立运行态模型与状态工具,负责 `runtimeState.flowState.grok.session / register / sso / upload` 的默认值、归一化、兼容字段投影、节点完成回写、下游重置和 fresh-attempt keep-state 构建。 +- `flows/grok/background/register-runner.js`:Grok 注册页执行器,负责步骤 1~5 的编排、Grok/xAI 注册标签页管理、邮箱提交、验证码轮询提交、资料/密码提交、当前 SSO Cookie 提取与账号记录收尾。 +- `flows/grok/background/publisher-webchat2api.js`:Grok 专属 `webchat2api` 上传器,负责地址归一化到 `/api/remote-account/inject`、Bearer 管理密钥请求头、SSO payload 构建、成功/失败响应解析与 Grok upload 运行态回写。 - `background/ip-proxy-core.js`:IP 代理核心模块,负责代理条目解析、账号/接口代理池运行态、PAC 应用与清除、代理鉴权回填、出口探测、失败时的目标站点 fail-close 防漏规则,以及自动运行成功阈值后的代理切换支撑。 - `background/ip-proxy-provider-711proxy.js`:711Proxy provider 规则模块,负责从账号串中识别和写回 `region / session / sessTime` 等参数,并在固定账号模式下把侧栏配置转换为最终生效的代理账号。 - `background/logging-status.js`:后台日志、步骤状态、错误信息和若干状态判断的公共工具层;日志条目统一写入结构化 `step / stepKey`,sidepanel 只读取该元数据渲染步骤标签,不再从日志正文解析步骤号;当前额外承接 `add-phone / 手机号页` 这类认证 fatal 错误的共享判定,并会把 Step 2 的“手机号输入模式未切成功”与真正的 auth `add-phone` 页面区分开,避免自动运行误停机。 @@ -156,8 +157,8 @@ - `flows/openai/mail-rules.js`:OpenAI flow 的验证码邮件规则定义,负责按注册/登录节点输出 code pattern、关键词、目标邮箱提示、2925 receive 弱匹配与轮询参数。 - `flows/kiro/mail-rules.js`:Kiro flow 的 AWS Builder ID 邮件规则定义,负责按注册验证码节点与桌面授权验证码节点输出 AWS 发件人、主题、关键词、code pattern、目标邮箱提示、2925 receive 弱匹配与轮询参数。 -- `flows/grok/index.js`:Grok flow 定义,声明 `webchat2api` target、Grok 注册页 runtime source、Grok 专属设置分组、能力边界与本地 SSO 导出属性;该 flow 当前没有 publication target,也不支持贡献 adapter。 -- `flows/grok/workflow.js`:Grok workflow 定义,固定输出打开注册页、提交邮箱、提交验证码、提交资料、提取 SSO Cookie 五个节点。 +- `flows/grok/index.js`:Grok flow 定义,声明 `webchat2api` target、Grok 注册页 runtime source、Grok 专属设置分组、能力边界与 flow 私有远程上传配置;该 flow 当前没有 publication target,也不支持贡献 adapter。 +- `flows/grok/workflow.js`:Grok workflow 定义,固定输出打开注册页、提交邮箱、提交验证码、提交资料、提取 SSO Cookie、上传 SSO 到 `webchat2api` 六个节点。 - `flows/grok/mail-rules.js`:Grok flow 的 xAI / Grok 验证码邮件规则定义,负责输出发件人、主题、关键词、`ABC-123` 与 6 位码 pattern、目标邮箱提示、2925 receive 弱匹配与轮询参数。 - `flows/grok/content/register-page.js`:Grok 注册页内容脚本,负责五个 Grok 节点的 DOM 操作、页面状态读取与局部点击事件坐标补齐,不修改全局浏览器原型。 @@ -188,7 +189,7 @@ - `core/flow-kernel/flow-capabilities.js`:flow 能力矩阵归一化模块,负责根据 `activeFlowId` 与来源解析 sidepanel 的显隐能力、来源作用域、贡献模式边界、步骤范围 UI 作用域,以及 Plus 模式下 `账号接入策略` 的可选项、可编辑性与最终生效值。 - `core/flow-kernel/flow-registry.js`:flow/source/settings group 总注册表,定义 `openai / kiro / grok` 三套 flow、各自 target/runtime source、可见分组、driver 定义,以及默认 target 和默认 `kiro.rs` 配置。 - `shared/kiro-timeouts.js`:Kiro 共享超时常量与归一化工具,负责注册页/桌面授权链路复用的页面加载超时配置。 -- `shared/contribution-registry.js`:贡献 adapter 与教程入口注册表,负责 OpenAI / Kiro 贡献 adapter、教程入口和发布贡献 flow 校验;默认发布校验只覆盖声明了 publication target 或支持贡献的 flow,Grok 这类本地 SSO 导出 flow 不自动纳入贡献 adapter 缺失检查。 +- `shared/contribution-registry.js`:贡献 adapter 与教程入口注册表,负责 OpenAI / Kiro 贡献 adapter、教程入口和发布贡献 flow 校验;默认发布校验只覆盖声明了 publication target 或支持贡献的 flow,Grok 这类 flow 私有远程上传链路不自动纳入贡献 adapter 缺失检查。 - `core/flow-kernel/settings-schema.js`:统一设置 schema 与归一化层,负责把持久配置收敛到 `settingsState.services.*` 与 `settingsState.flows.*` 结构,并维护 `activeFlowId`、OpenAI 的 integration target、Kiro/Grok 的 `flows..targetId / targets` 与 `stepExecutionRangeByFlow`。 - `core/flow-kernel/source-registry.js`:运行时来源注册表,负责合并 flow runtime source 与共享 mail source,统一 source family、driver、URL 归属和 cleanup owner 判定。 @@ -281,7 +282,8 @@ - `tests/background-mail2925-signup-flow.test.js`:测试注册页辅助层在 2925 provider 下,会先确保账号池里已分配可用账号,再继续生成别名邮箱。 - `tests/background-flow-mail-polling-module.test.js`:测试 flow-aware 邮件轮询调度层对 API provider、网页邮箱 provider、2925 会话准备和 2925 上限恢复的共享分派行为。 - `tests/background-mail-rule-registry-module.test.js`:测试邮件规则注册表与 OpenAI / Kiro mail rules 模块接入,并覆盖各 flow 验证码轮询 payload 构造。 -- `tests/background-grok-state-module.test.js`:测试 Grok 运行态 helper 的默认值、兼容字段投影、节点完成回写、下游重置、fresh-attempt keep-state 与 SSO Cookie 保留规则。 +- `tests/background-grok-state-module.test.js`:测试 Grok 运行态 helper 的默认值、兼容字段投影、节点完成回写、下游重置、fresh-attempt keep-state 与 SSO/upload 清理规则。 +- `tests/background-grok-publisher-webchat2api.test.js`:测试 Grok `webchat2api` 上传器的地址归一化、必填配置校验、SSO payload、响应处理、运行态回写和敏感值日志保护。 - `tests/background-message-router-module.test.js`:测试消息路由模块已接入且导出工厂。 - `tests/background-message-router-plus-final-step.test.js`:测试消息路由会按 Plus 当前最终节点追加成功记录,不再写死步骤 10。 - `tests/background-message-router-step2-skip.test.js`:测试步骤 2 直接落到验证码页时的跳步与状态保护逻辑,并覆盖邮箱身份写入时清理旧手机号注册运行态。 @@ -369,7 +371,7 @@ - `tests/sidepanel-contribution-button.test.js`:测试侧边栏顶部 `贡献` 按钮的 HTML 接线、更新提示气泡接线,以及相关脚本加载顺序。 - `tests/sidepanel-account-records-manager.test.js`:测试侧边栏账号记录覆盖层的 HTML 接入、helper 地址归一化与 manager 渲染逻辑。 - `tests/contribution-content-update-service.test.js`:测试贡献内容更新服务对公开摘要接口的归一化、版本提取与失败回退缓存逻辑。 -- `tests/contribution-registry.test.js`:测试贡献 adapter 注册、教程入口解析、默认发布贡献 flow 校验,以及 Grok 本地 SSO 导出 flow 不被自动纳入发布贡献检查。 +- `tests/contribution-registry.test.js`:测试贡献 adapter 注册、教程入口解析、默认发布贡献 flow 校验,以及 Grok 私有 `webchat2api` 上传 flow 不被自动纳入发布贡献检查。 - `tests/flow-capabilities-module.test.js`:测试 flow 能力矩阵模块对来源作用域、显隐能力和贡献模式边界的归一化。 - `tests/flow-registry-settings-schema.test.js`:测试 flow registry 与 settings schema 之间的默认值、来源映射和 flow-specific 配置结构保持一致。 - `tests/sidepanel-custom-email-pool.test.js`:测试侧边栏自定义邮箱池、自定义邮箱服务号池的 HTML 接线,以及邮箱池长度与自动轮数之间的联动规则。