mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-10-16 15:34:08 +08:00
fix: restore agent app studio cloud upload login
This commit is contained in:
@@ -255,6 +255,8 @@ await lime.agent.startTask({
|
||||
|
||||
2026-05-16 10:08 补充:`contentFactorySdkRegression.test.ts` 已改为只从 `src/features/agent-app/sdk/index.ts` 导入 SDK 类型和 helper,证明 Lime-side 内容工厂回归消费的是 SDK-only public surface,而不是 `capabilityContract.ts` / `capabilityAdapters.ts` 等内部深文件。该调整仍不代表外部 `content-factory-app` package 已迁移。
|
||||
|
||||
2026-05-21 补充:Agent App Studio 云端发布链路已收敛到宿主开放平台基础能力,不把发布业务代理进宿主。Studio 通过 `lime.cloudSession.getAccessToken` 显式获取宿主当前 session token,未登录或过期时通过 `lime.cloudSession.requestLogin` 拉起宿主登录。Lime GUI / DevBridge 模式下登录不再使用 `lime://oauth/callback` 作为浏览器回跳,而是在宿主侧启动 `127.0.0.1:<port>/oauth/callback` 一次性本机回调桥;Rust 命令 `start_oem_cloud_oauth_callback_bridge` 收到回调后 emit `oem-cloud-oauth-callback`,前端完成 session 写入并继续 Studio 上传。该命令必须作为 bridge truth 走真实 DevBridge / Rust,不允许在浏览器模式优先回退 mock,否则会出现“返回了 localhost 地址但没有真实监听服务”的假成功。2026-05-21 实测 `content-factory-app` 本地目录 `/Users/coso/Documents/dev/ai/limecloud/agentapp/docs/examples/content-factory-app` 可直接识别并一键上传,发布为开发构建 `0.8.0+studio.20260520182133563`,Release `agent-app-release-2368`。
|
||||
|
||||
迁移规则:
|
||||
|
||||
1. App package 业务文件不能直接构造 `lime.agentApp.bridge` message。
|
||||
|
||||
Generated
+3
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "lime",
|
||||
"version": "1.45.0",
|
||||
"version": "1.45.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "lime",
|
||||
"version": "1.45.0",
|
||||
"version": "1.45.1",
|
||||
"license": "GPL-3.0-only",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
@@ -13915,7 +13915,7 @@
|
||||
},
|
||||
"packages/lime-cli-npm": {
|
||||
"name": "@limecloud/lime-cli",
|
||||
"version": "1.45.0",
|
||||
"version": "1.45.1",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "lime",
|
||||
"private": true,
|
||||
"workspaces": ["packages/*"],
|
||||
"version": "1.45.0",
|
||||
"version": "1.45.1",
|
||||
"description": "AI content workspace for Chinese creators: desktop writing, research, prompt management, knowledge base, and multi-model workflows.",
|
||||
"keywords": [
|
||||
"ai",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@limecloud/lime-cli",
|
||||
"version": "1.45.0",
|
||||
"version": "1.45.1",
|
||||
"description": "Lime 官方任务 CLI",
|
||||
"bin": {
|
||||
"lime": "scripts/run.js"
|
||||
|
||||
Generated
+18
-18
@@ -5131,7 +5131,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime"
|
||||
version = "1.45.0"
|
||||
version = "1.45.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -5238,7 +5238,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-agent"
|
||||
version = "1.45.0"
|
||||
version = "1.45.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aster-core",
|
||||
@@ -5267,7 +5267,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-browser-runtime"
|
||||
version = "1.45.0"
|
||||
version = "1.45.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"futures",
|
||||
@@ -5284,7 +5284,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-cli"
|
||||
version = "1.45.0"
|
||||
version = "1.45.1"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"lime-core",
|
||||
@@ -5296,7 +5296,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-config"
|
||||
version = "1.45.0"
|
||||
version = "1.45.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"lime-core",
|
||||
@@ -5312,7 +5312,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-core"
|
||||
version = "1.45.0"
|
||||
version = "1.45.1"
|
||||
dependencies = [
|
||||
"aster-models",
|
||||
"async-trait",
|
||||
@@ -5366,7 +5366,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-gateway"
|
||||
version = "1.45.0"
|
||||
version = "1.45.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"axum 0.7.9",
|
||||
@@ -5396,7 +5396,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-infra"
|
||||
version = "1.45.0"
|
||||
version = "1.45.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dashmap 5.5.3",
|
||||
@@ -5416,7 +5416,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-knowledge"
|
||||
version = "1.45.0"
|
||||
version = "1.45.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hex",
|
||||
@@ -5429,7 +5429,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-mcp"
|
||||
version = "1.45.0"
|
||||
version = "1.45.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dirs 5.0.1",
|
||||
@@ -5445,7 +5445,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-media-runtime"
|
||||
version = "1.45.0"
|
||||
version = "1.45.1"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"base64 0.22.1",
|
||||
@@ -5478,7 +5478,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-processor"
|
||||
version = "1.45.0"
|
||||
version = "1.45.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"lime-core",
|
||||
@@ -5497,7 +5497,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-providers"
|
||||
version = "1.45.0"
|
||||
version = "1.45.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -5552,7 +5552,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-server"
|
||||
version = "1.45.0"
|
||||
version = "1.45.1"
|
||||
dependencies = [
|
||||
"aster-core",
|
||||
"async-stream",
|
||||
@@ -5596,7 +5596,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-server-utils"
|
||||
version = "1.45.0"
|
||||
version = "1.45.1"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"futures",
|
||||
@@ -5611,7 +5611,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-services"
|
||||
version = "1.45.0"
|
||||
version = "1.45.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aster-core",
|
||||
@@ -5656,7 +5656,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-skills"
|
||||
version = "1.45.0"
|
||||
version = "1.45.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dirs 5.0.1",
|
||||
@@ -5674,7 +5674,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-websocket"
|
||||
version = "1.45.0"
|
||||
version = "1.45.1"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
|
||||
@@ -9,7 +9,7 @@ exclude = [
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "1.45.0"
|
||||
version = "1.45.1"
|
||||
edition = "2021"
|
||||
authors = ["coso"]
|
||||
repository = "https://github.com/aiclientproxy/lime"
|
||||
@@ -199,7 +199,7 @@ version = "2.4.9"
|
||||
|
||||
[package]
|
||||
name = "lime"
|
||||
version = "1.45.0"
|
||||
version = "1.45.1"
|
||||
description = "AI API Proxy Desktop App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1639,6 +1639,7 @@ pub fn run() {
|
||||
commands::external_tools_cmd::open_codex_cli_login,
|
||||
commands::external_tools_cmd::open_codex_cli_logout,
|
||||
commands::external_tools_cmd::open_external_url,
|
||||
commands::external_tools_cmd::start_oem_cloud_oauth_callback_bridge,
|
||||
commands::external_tools_cmd::get_external_tools,
|
||||
// Voice Input commands
|
||||
crate::voice::commands::get_voice_input_config,
|
||||
|
||||
@@ -3,9 +3,20 @@
|
||||
//! 管理 Codex CLI 等外部工具的状态检查和配置
|
||||
//! 这些工具有自己的认证系统,不通过 Lime 凭证池管理
|
||||
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::{StatusCode, Uri};
|
||||
use axum::response::{Html, IntoResponse};
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::{oneshot, Mutex};
|
||||
|
||||
/// Codex CLI 状态
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
@@ -24,6 +35,70 @@ pub struct CodexCliStatus {
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OemCloudOAuthCallbackBridgeStartResponse {
|
||||
pub callback_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OemCloudOAuthCallbackBridgePayload {
|
||||
pub source_path: String,
|
||||
pub tenant_id: Option<String>,
|
||||
pub token: Option<String>,
|
||||
pub next: Option<String>,
|
||||
pub error: Option<String>,
|
||||
pub device_code: Option<String>,
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
const OEM_CLOUD_OAUTH_CALLBACK_BRIDGE_EVENT: &str = "oem-cloud-oauth-callback";
|
||||
const OEM_CLOUD_OAUTH_CALLBACK_PATH: &str = "/oauth/callback";
|
||||
const OEM_CLOUD_OAUTH_CALLBACK_BRIDGE_TTL: Duration = Duration::from_secs(10 * 60);
|
||||
const OEM_CLOUD_OAUTH_CALLBACK_HTML: &str = r#"<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Lime 登录回调</title>
|
||||
<script>
|
||||
(function () {
|
||||
if (window.location.hash && window.location.hash.length > 1) {
|
||||
var params = new URLSearchParams(window.location.hash.slice(1));
|
||||
var search = new URLSearchParams(window.location.search);
|
||||
params.forEach(function (value, key) {
|
||||
if (!search.has(key)) search.set(key, value);
|
||||
});
|
||||
window.location.replace(window.location.pathname + "?" + search.toString());
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<p>Lime 登录结果已返回,可以关闭此页面。</p>
|
||||
</body>
|
||||
</html>"#;
|
||||
|
||||
#[derive(Debug, Default, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct OemCloudOAuthCallbackBridgeQuery {
|
||||
#[serde(alias = "tenant_id")]
|
||||
tenant_id: Option<String>,
|
||||
token: Option<String>,
|
||||
next: Option<String>,
|
||||
error: Option<String>,
|
||||
#[serde(alias = "device_code")]
|
||||
device_code: Option<String>,
|
||||
status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct OemCloudOAuthCallbackBridgeState {
|
||||
app: AppHandle,
|
||||
shutdown_tx: Arc<Mutex<Option<oneshot::Sender<()>>>>,
|
||||
}
|
||||
|
||||
/// 检查 Lime CLI 状态
|
||||
#[tauri::command]
|
||||
pub async fn check_codex_cli_status() -> Result<CodexCliStatus, String> {
|
||||
@@ -140,6 +215,114 @@ pub async fn open_external_url(url: String) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_callback_bridge_base_url(addr: SocketAddr) -> Result<String, String> {
|
||||
if !matches!(addr.ip(), IpAddr::V4(ip) if ip.is_loopback()) {
|
||||
return Err("OAuth 本地回调桥必须绑定到 IPv4 loopback 地址".to_string());
|
||||
}
|
||||
Ok(format!(
|
||||
"http://127.0.0.1:{}{}",
|
||||
addr.port(),
|
||||
OEM_CLOUD_OAUTH_CALLBACK_PATH
|
||||
))
|
||||
}
|
||||
|
||||
fn normalize_callback_bridge_value(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|value| {
|
||||
let normalized = value.trim();
|
||||
if normalized.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(normalized.to_string())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn build_oem_cloud_oauth_callback_payload(
|
||||
uri: &Uri,
|
||||
query: OemCloudOAuthCallbackBridgeQuery,
|
||||
) -> OemCloudOAuthCallbackBridgePayload {
|
||||
OemCloudOAuthCallbackBridgePayload {
|
||||
source_path: uri.path().to_string(),
|
||||
tenant_id: normalize_callback_bridge_value(query.tenant_id),
|
||||
token: normalize_callback_bridge_value(query.token),
|
||||
next: normalize_callback_bridge_value(query.next),
|
||||
error: normalize_callback_bridge_value(query.error),
|
||||
device_code: normalize_callback_bridge_value(query.device_code),
|
||||
status: normalize_callback_bridge_value(query.status),
|
||||
}
|
||||
}
|
||||
|
||||
fn should_emit_oem_cloud_oauth_callback(payload: &OemCloudOAuthCallbackBridgePayload) -> bool {
|
||||
payload.tenant_id.is_some()
|
||||
|| payload.token.is_some()
|
||||
|| payload.error.is_some()
|
||||
|| payload.device_code.is_some()
|
||||
|| payload.status.is_some()
|
||||
}
|
||||
|
||||
async fn handle_oem_cloud_oauth_callback_bridge_request(
|
||||
State(state): State<OemCloudOAuthCallbackBridgeState>,
|
||||
Query(query): Query<OemCloudOAuthCallbackBridgeQuery>,
|
||||
uri: Uri,
|
||||
) -> impl IntoResponse {
|
||||
let payload = build_oem_cloud_oauth_callback_payload(&uri, query);
|
||||
if should_emit_oem_cloud_oauth_callback(&payload) {
|
||||
if let Err(error) = state
|
||||
.app
|
||||
.emit(OEM_CLOUD_OAUTH_CALLBACK_BRIDGE_EVENT, payload)
|
||||
{
|
||||
tracing::warn!("[OAuthCallbackBridge] 发送 OAuth 回调事件失败: {}", error);
|
||||
}
|
||||
|
||||
if let Some(shutdown_tx) = state.shutdown_tx.lock().await.take() {
|
||||
let _ = shutdown_tx.send(());
|
||||
}
|
||||
|
||||
return (StatusCode::OK, Html(OEM_CLOUD_OAUTH_CALLBACK_HTML)).into_response();
|
||||
}
|
||||
|
||||
(StatusCode::OK, Html(OEM_CLOUD_OAUTH_CALLBACK_HTML)).into_response()
|
||||
}
|
||||
|
||||
/// 启动一次性 OEM Cloud OAuth 本机回调桥。
|
||||
#[tauri::command]
|
||||
pub async fn start_oem_cloud_oauth_callback_bridge(
|
||||
app: AppHandle,
|
||||
) -> Result<OemCloudOAuthCallbackBridgeStartResponse, String> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.map_err(|error| format!("无法启动 OAuth 本地回调桥: {error}"))?;
|
||||
let addr = listener
|
||||
.local_addr()
|
||||
.map_err(|error| format!("无法读取 OAuth 本地回调地址: {error}"))?;
|
||||
let callback_url = normalize_callback_bridge_base_url(addr)?;
|
||||
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
|
||||
let state = OemCloudOAuthCallbackBridgeState {
|
||||
app,
|
||||
shutdown_tx: Arc::new(Mutex::new(Some(shutdown_tx))),
|
||||
};
|
||||
let router = Router::new()
|
||||
.route(
|
||||
OEM_CLOUD_OAUTH_CALLBACK_PATH,
|
||||
get(handle_oem_cloud_oauth_callback_bridge_request),
|
||||
)
|
||||
.with_state(state);
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let server = axum::serve(listener, router).with_graceful_shutdown(async move {
|
||||
tokio::select! {
|
||||
_ = shutdown_rx => {}
|
||||
_ = tokio::time::sleep(OEM_CLOUD_OAUTH_CALLBACK_BRIDGE_TTL) => {}
|
||||
}
|
||||
});
|
||||
if let Err(error) = server.await {
|
||||
tracing::warn!("[OAuthCallbackBridge] OAuth 本地回调桥运行失败: {}", error);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(OemCloudOAuthCallbackBridgeStartResponse { callback_url })
|
||||
}
|
||||
|
||||
/// 外部工具列表
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExternalTool {
|
||||
|
||||
@@ -117,7 +117,7 @@ pub async fn handle_command(
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
if let Some(result) = external_tools::try_handle(cmd, args.as_ref()).await? {
|
||||
if let Some(result) = external_tools::try_handle(state, cmd, args.as_ref()).await? {
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use super::{args_or_default, get_string_arg};
|
||||
use super::{args_or_default, get_string_arg, require_app_handle};
|
||||
use crate::dev_bridge::DevBridgeState;
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
pub(super) async fn try_handle(
|
||||
state: &DevBridgeState,
|
||||
cmd: &str,
|
||||
args: Option<&JsonValue>,
|
||||
) -> Result<Option<JsonValue>, Box<dyn std::error::Error>> {
|
||||
@@ -15,6 +17,16 @@ pub(super) async fn try_handle(
|
||||
.map_err(|error| format!("打开外部链接失败: {error}"))?,
|
||||
)?
|
||||
}
|
||||
"start_oem_cloud_oauth_callback_bridge" => {
|
||||
let app_handle = require_app_handle(state)?;
|
||||
serde_json::to_value(
|
||||
crate::commands::external_tools_cmd::start_oem_cloud_oauth_callback_bridge(
|
||||
app_handle,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| format!("启动 OAuth 本地回调桥失败: {error}"))?,
|
||||
)?
|
||||
}
|
||||
_ => return Ok(None),
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Lime",
|
||||
"version": "1.45.0",
|
||||
"version": "1.45.1",
|
||||
"identifier": "com.limecloud.lime.headless",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev:web-bridge",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Lime",
|
||||
"version": "1.45.0",
|
||||
"version": "1.45.1",
|
||||
"identifier": "com.limecloud.lime",
|
||||
"build": {
|
||||
"beforeDevCommand": "node scripts/start-tauri-dev-server.mjs",
|
||||
|
||||
@@ -1144,6 +1144,125 @@ describe("createAgentAppCapabilityDispatcher", () => {
|
||||
expect(loginLauncherMocks.startOemCloudLogin).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("lime.cloudSession.requestLogin 支持强制刷新已有宿主会话", async () => {
|
||||
const dispatch = buildDispatcherWithCloudSessionCapability();
|
||||
window.__LIME_OEM_CLOUD__ = {
|
||||
enabled: true,
|
||||
baseUrl: "https://user.limeai.run",
|
||||
tenantId: "tenant-0001",
|
||||
sessionToken: "stale-host-session-token",
|
||||
};
|
||||
loginLauncherMocks.startOemCloudLogin.mockImplementation(async () => {
|
||||
window.__LIME_SESSION_TOKEN__ = "fresh-host-session-token";
|
||||
window.__LIME_OEM_CLOUD__ = {
|
||||
enabled: true,
|
||||
baseUrl: "https://user.limeai.run",
|
||||
tenantId: "tenant-0001",
|
||||
sessionToken: "fresh-host-session-token",
|
||||
};
|
||||
return {
|
||||
mode: "desktop_auth",
|
||||
openedUrl: "lime://oauth/callback",
|
||||
};
|
||||
});
|
||||
|
||||
await expect(
|
||||
dispatch({
|
||||
appId: "content-factory-app",
|
||||
entryKey: "dashboard",
|
||||
capability: "lime.cloudSession",
|
||||
method: "requestLogin",
|
||||
input: { force: true },
|
||||
rawPayload: {
|
||||
capability: "lime.cloudSession",
|
||||
method: "requestLogin",
|
||||
input: { force: true },
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
tenantId: "tenant-0001",
|
||||
hasSession: true,
|
||||
controlPlaneBaseUrl: "https://user.limeai.run/api",
|
||||
});
|
||||
expect(loginLauncherMocks.startOemCloudLogin).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("lime.cloudSession.getAccessToken 不返回已过期的宿主会话令牌", async () => {
|
||||
const dispatch = buildDispatcherWithCloudSessionCapability();
|
||||
const expiredToken = [
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9",
|
||||
"eyJzdWIiOiJ1c2VyLTAwMDEiLCJ0ZW5hbnRJZCI6InRlbmFudC0wMDAxIiwiZXhwIjoxfQ",
|
||||
"signature",
|
||||
].join(".");
|
||||
window.__LIME_OEM_CLOUD__ = {
|
||||
enabled: true,
|
||||
baseUrl: "https://user.limeai.run",
|
||||
tenantId: "tenant-0001",
|
||||
sessionToken: expiredToken,
|
||||
};
|
||||
|
||||
await expect(
|
||||
dispatch({
|
||||
appId: "content-factory-app",
|
||||
entryKey: "dashboard",
|
||||
capability: "lime.cloudSession",
|
||||
method: "getAccessToken",
|
||||
rawPayload: {
|
||||
capability: "lime.cloudSession",
|
||||
method: "getAccessToken",
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: "SESSION_REQUIRED",
|
||||
});
|
||||
});
|
||||
|
||||
it("lime.cloudSession.requestLogin 遇到过期宿主会话令牌时会刷新", async () => {
|
||||
const dispatch = buildDispatcherWithCloudSessionCapability();
|
||||
const expiredToken = [
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9",
|
||||
"eyJzdWIiOiJ1c2VyLTAwMDEiLCJ0ZW5hbnRJZCI6InRlbmFudC0wMDAxIiwiZXhwIjoxfQ",
|
||||
"signature",
|
||||
].join(".");
|
||||
window.__LIME_OEM_CLOUD__ = {
|
||||
enabled: true,
|
||||
baseUrl: "https://user.limeai.run",
|
||||
tenantId: "tenant-0001",
|
||||
sessionToken: expiredToken,
|
||||
};
|
||||
loginLauncherMocks.startOemCloudLogin.mockImplementation(async () => {
|
||||
window.__LIME_SESSION_TOKEN__ = "fresh-host-session-token";
|
||||
window.__LIME_OEM_CLOUD__ = {
|
||||
enabled: true,
|
||||
baseUrl: "https://user.limeai.run",
|
||||
tenantId: "tenant-0001",
|
||||
sessionToken: "fresh-host-session-token",
|
||||
};
|
||||
return {
|
||||
mode: "desktop_auth",
|
||||
openedUrl: "lime://oauth/callback",
|
||||
};
|
||||
});
|
||||
|
||||
await expect(
|
||||
dispatch({
|
||||
appId: "content-factory-app",
|
||||
entryKey: "dashboard",
|
||||
capability: "lime.cloudSession",
|
||||
method: "requestLogin",
|
||||
rawPayload: {
|
||||
capability: "lime.cloudSession",
|
||||
method: "requestLogin",
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
tenantId: "tenant-0001",
|
||||
hasSession: true,
|
||||
controlPlaneBaseUrl: "https://user.limeai.run/api",
|
||||
});
|
||||
expect(loginLauncherMocks.startOemCloudLogin).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("应通过 lime.models / lime.usage 投影 AgentRuntime 模型与用量事实", async () => {
|
||||
const dispatch = buildRuntimeProjectionDispatcher();
|
||||
|
||||
|
||||
@@ -569,6 +569,14 @@ function readOptionalInputRecord(
|
||||
return {};
|
||||
}
|
||||
|
||||
function readBooleanOption(
|
||||
request: AgentAppHostBridgeCapabilityRequest,
|
||||
key: string,
|
||||
): boolean {
|
||||
const input = readOptionalInputRecord(request);
|
||||
return input[key] === true;
|
||||
}
|
||||
|
||||
function readStringParam(
|
||||
request: AgentAppHostBridgeCapabilityRequest,
|
||||
key: string,
|
||||
@@ -1154,6 +1162,36 @@ function buildCloudSessionSnapshot() {
|
||||
};
|
||||
}
|
||||
|
||||
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
||||
const payload = token.split(".")[1];
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = base64.padEnd(
|
||||
base64.length + ((4 - (base64.length % 4)) % 4),
|
||||
"=",
|
||||
);
|
||||
const decoded = atob(padded);
|
||||
const parsed = JSON.parse(decoded);
|
||||
return isRecord(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isExpiredJwt(token: string, nowMs = Date.now()): boolean {
|
||||
const payload = decodeJwtPayload(token);
|
||||
const exp = payload?.exp;
|
||||
if (typeof exp !== "number") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return exp * 1000 <= nowMs + 60_000;
|
||||
}
|
||||
|
||||
async function dispatchCloudSession(
|
||||
request: AgentAppHostBridgeCapabilityRequest,
|
||||
): Promise<unknown> {
|
||||
@@ -1162,7 +1200,11 @@ async function dispatchCloudSession(
|
||||
}
|
||||
if (request.method === "getAccessToken") {
|
||||
const runtime = resolveOemCloudRuntimeContext();
|
||||
if (!runtime?.sessionToken || !runtime.tenantId) {
|
||||
if (
|
||||
!runtime?.sessionToken ||
|
||||
!runtime.tenantId ||
|
||||
isExpiredJwt(runtime.sessionToken)
|
||||
) {
|
||||
throw new AgentAppCapabilityDispatcherError(
|
||||
"SESSION_REQUIRED",
|
||||
"Host cloud session is not available.",
|
||||
@@ -1182,7 +1224,12 @@ async function dispatchCloudSession(
|
||||
"Host cloud login is not configured.",
|
||||
);
|
||||
}
|
||||
if (!runtime.sessionToken) {
|
||||
const force = readBooleanOption(request, "force");
|
||||
if (
|
||||
force ||
|
||||
!runtime.sessionToken ||
|
||||
isExpiredJwt(runtime.sessionToken)
|
||||
) {
|
||||
await startOemCloudLogin(runtime, { waitForCompletion: true });
|
||||
}
|
||||
return buildCloudSessionSnapshot();
|
||||
|
||||
@@ -31,6 +31,7 @@ const controlPlaneMocks = vi.hoisted(() => ({
|
||||
const shellOpenMock = vi.hoisted(() => vi.fn());
|
||||
const systemBrowserMocks = vi.hoisted(() => ({
|
||||
openExternalUrlWithSystemBrowser: vi.fn(),
|
||||
startOemCloudOAuthCallbackBridge: vi.fn(),
|
||||
}));
|
||||
const tauriRuntimeMocks = vi.hoisted(() => ({
|
||||
hasTauriInvokeCapability: vi.fn(),
|
||||
@@ -86,8 +87,11 @@ vi.mock("@tauri-apps/plugin-shell", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/externalUrl", () => ({
|
||||
OEM_CLOUD_OAUTH_CALLBACK_BRIDGE_EVENT: "oem-cloud-oauth-callback",
|
||||
openExternalUrlWithSystemBrowser:
|
||||
systemBrowserMocks.openExternalUrlWithSystemBrowser,
|
||||
startOemCloudOAuthCallbackBridge:
|
||||
systemBrowserMocks.startOemCloudOAuthCallbackBridge,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/tauri-runtime", () => ({
|
||||
@@ -257,6 +261,10 @@ describe("useOemCloudAccess", () => {
|
||||
systemBrowserMocks.openExternalUrlWithSystemBrowser.mockResolvedValue(
|
||||
undefined,
|
||||
);
|
||||
systemBrowserMocks.startOemCloudOAuthCallbackBridge.mockReset();
|
||||
systemBrowserMocks.startOemCloudOAuthCallbackBridge.mockResolvedValue({
|
||||
callbackUrl: "http://127.0.0.1:18081/oauth/callback",
|
||||
});
|
||||
tauriRuntimeMocks.hasTauriInvokeCapability.mockReturnValue(true);
|
||||
tauriRuntimeMocks.hasTauriRuntimeMarkers.mockReturnValue(true);
|
||||
});
|
||||
@@ -651,18 +659,28 @@ describe("useOemCloudAccess", () => {
|
||||
await latestState?.handleGoogleLogin();
|
||||
});
|
||||
|
||||
expect(
|
||||
systemBrowserMocks.startOemCloudOAuthCallbackBridge,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
controlPlaneMocks.createClientDesktopAuthSession,
|
||||
).toHaveBeenCalledWith("tenant-0001", {
|
||||
clientId: "desktop-client",
|
||||
provider: "google",
|
||||
desktopRedirectUri: "lime://oauth/callback",
|
||||
desktopRedirectUri: "http://127.0.0.1:18081/oauth/callback",
|
||||
});
|
||||
expect(
|
||||
systemBrowserMocks.openExternalUrlWithSystemBrowser,
|
||||
).toHaveBeenCalledWith(
|
||||
"https://user.limeai.run/oauth/desktop/device-code-001/signin",
|
||||
);
|
||||
expect(
|
||||
controlPlaneMocks.createClientDesktopAuthSession,
|
||||
).toHaveBeenCalledWith("tenant-0001", {
|
||||
clientId: "desktop-client",
|
||||
provider: "google",
|
||||
desktopRedirectUri: "http://127.0.0.1:18081/oauth/callback",
|
||||
});
|
||||
expect(shellOpenMock).not.toHaveBeenCalled();
|
||||
expect(controlPlaneMocks.pollClientDesktopAuthSession).toHaveBeenCalledWith(
|
||||
"device-code-001",
|
||||
@@ -759,7 +777,7 @@ describe("useOemCloudAccess", () => {
|
||||
expect(parsedUrl.searchParams.get("tenant")).toBe("tenant-0001");
|
||||
expect(parsedUrl.searchParams.get("tenantId")).toBe("tenant-0001");
|
||||
expect(parsedUrl.searchParams.get("redirectUrl")).toBe(
|
||||
"lime://oauth/callback",
|
||||
"http://127.0.0.1:18081/oauth/callback",
|
||||
);
|
||||
expect(parsedUrl.searchParams.get("redirect")).toBe("/welcome");
|
||||
expect(latestState?.errorMessage).toBeNull();
|
||||
@@ -814,13 +832,16 @@ describe("useOemCloudAccess", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("Google 桌面登录不再接受 localhost 本地回调配置", async () => {
|
||||
it("Tauri 桌面登录会用本机回调桥覆盖静态 localhost 回调配置", async () => {
|
||||
window.__LIME_OEM_CLOUD__ = {
|
||||
enabled: true,
|
||||
baseUrl: "https://user.limeai.run",
|
||||
tenantId: "tenant-0001",
|
||||
desktopOauthRedirectUrl: "http://localhost:17834/callback",
|
||||
};
|
||||
controlPlaneMocks.createClientDesktopAuthSession.mockRejectedValue(
|
||||
new Error("desktop client not found"),
|
||||
);
|
||||
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
@@ -836,9 +857,22 @@ describe("useOemCloudAccess", () => {
|
||||
await latestState?.handleGoogleLogin();
|
||||
});
|
||||
|
||||
expect(
|
||||
systemBrowserMocks.startOemCloudOAuthCallbackBridge,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
controlPlaneMocks.createClientDesktopAuthSession,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(latestState?.errorMessage).toContain("localhost 本地回调");
|
||||
).toHaveBeenCalledWith("tenant-0001", {
|
||||
clientId: "desktop-client",
|
||||
provider: "google",
|
||||
desktopRedirectUri: "http://127.0.0.1:18081/oauth/callback",
|
||||
});
|
||||
const openedUrl = systemBrowserMocks.openExternalUrlWithSystemBrowser.mock
|
||||
.calls[0]?.[0] as string;
|
||||
const parsedUrl = new URL(openedUrl);
|
||||
expect(parsedUrl.searchParams.get("redirectUrl")).toBe(
|
||||
"http://127.0.0.1:18081/oauth/callback",
|
||||
);
|
||||
expect(latestState?.errorMessage).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { safeInvoke } from "@/lib/dev-bridge";
|
||||
import { openExternalUrlWithSystemBrowser } from "./externalUrl";
|
||||
import {
|
||||
openExternalUrlWithSystemBrowser,
|
||||
startOemCloudOAuthCallbackBridge,
|
||||
} from "./externalUrl";
|
||||
|
||||
vi.mock("@/lib/dev-bridge", () => ({
|
||||
safeInvoke: vi.fn(),
|
||||
@@ -22,4 +25,18 @@ describe("externalUrl API", () => {
|
||||
url: "https://user.limeai.run/login",
|
||||
});
|
||||
});
|
||||
|
||||
it("应通过 native 命令启动 OEM Cloud OAuth 本机回调桥", async () => {
|
||||
vi.mocked(safeInvoke).mockResolvedValueOnce({
|
||||
callbackUrl: "http://127.0.0.1:18081/oauth/callback",
|
||||
});
|
||||
|
||||
await expect(startOemCloudOAuthCallbackBridge()).resolves.toEqual({
|
||||
callbackUrl: "http://127.0.0.1:18081/oauth/callback",
|
||||
});
|
||||
|
||||
expect(safeInvoke).toHaveBeenCalledWith(
|
||||
"start_oem_cloud_oauth_callback_bridge",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,30 @@
|
||||
import { safeInvoke } from "@/lib/dev-bridge";
|
||||
|
||||
export const OEM_CLOUD_OAUTH_CALLBACK_BRIDGE_EVENT =
|
||||
"oem-cloud-oauth-callback";
|
||||
|
||||
export interface OemCloudOAuthCallbackBridgeStartResponse {
|
||||
callbackUrl: string;
|
||||
}
|
||||
|
||||
export interface OemCloudOAuthCallbackBridgePayload {
|
||||
sourcePath: string;
|
||||
tenantId?: string | null;
|
||||
token?: string | null;
|
||||
next?: string | null;
|
||||
error?: string | null;
|
||||
deviceCode?: string | null;
|
||||
status?: string | null;
|
||||
}
|
||||
|
||||
export async function openExternalUrlWithSystemBrowser(
|
||||
url: string,
|
||||
): Promise<void> {
|
||||
await safeInvoke("open_external_url", { url });
|
||||
}
|
||||
|
||||
export async function startOemCloudOAuthCallbackBridge(): Promise<OemCloudOAuthCallbackBridgeStartResponse> {
|
||||
return safeInvoke<OemCloudOAuthCallbackBridgeStartResponse>(
|
||||
"start_oem_cloud_oauth_callback_bridge",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,14 @@ describe("mockPriorityCommands", () => {
|
||||
false,
|
||||
);
|
||||
expect(shouldPreferMockInBrowser("agent_app_stop_ui_runtime")).toBe(false);
|
||||
expect(
|
||||
shouldPreferMockInBrowser("start_oem_cloud_oauth_callback_bridge"),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldDisallowMockFallbackInBrowser(
|
||||
"start_oem_cloud_oauth_callback_bridge",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("图层设计工程目录命令在浏览器模式必须走真实桥接", () => {
|
||||
|
||||
@@ -59,6 +59,7 @@ const mockPriorityCommands = new Set<string>([
|
||||
const bridgeTruthCommands = new Set<string>([
|
||||
"aster_agent_init",
|
||||
"aster_agent_status",
|
||||
"start_oem_cloud_oauth_callback_bridge",
|
||||
"agent_generate_title",
|
||||
"get_or_create_default_project",
|
||||
"workspace_list",
|
||||
|
||||
@@ -126,7 +126,8 @@
|
||||
"memory_runtime_prefetch_for_turn"
|
||||
],
|
||||
"systemUtilityCommands": [
|
||||
"open_external_url"
|
||||
"open_external_url",
|
||||
"start_oem_cloud_oauth_callback_bridge"
|
||||
],
|
||||
"fileBrowserCommands": [
|
||||
"list_dir",
|
||||
|
||||
@@ -14,11 +14,16 @@ const controlPlaneMocks = vi.hoisted(() => ({
|
||||
}));
|
||||
const systemBrowserMocks = vi.hoisted(() => ({
|
||||
openExternalUrlWithSystemBrowser: vi.fn(),
|
||||
startOemCloudOAuthCallbackBridge: vi.fn(),
|
||||
}));
|
||||
const tauriRuntimeMocks = vi.hoisted(() => ({
|
||||
hasTauriInvokeCapability: vi.fn(),
|
||||
hasTauriRuntimeMarkers: vi.fn(),
|
||||
}));
|
||||
const devBridgeMocks = vi.hoisted(() => ({
|
||||
isDevBridgeAvailable: vi.fn(),
|
||||
safeListen: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/oemCloudControlPlane", async (importOriginal) => {
|
||||
const actual =
|
||||
@@ -38,8 +43,11 @@ vi.mock("@tauri-apps/plugin-shell", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/externalUrl", () => ({
|
||||
OEM_CLOUD_OAUTH_CALLBACK_BRIDGE_EVENT: "oem-cloud-oauth-callback",
|
||||
openExternalUrlWithSystemBrowser:
|
||||
systemBrowserMocks.openExternalUrlWithSystemBrowser,
|
||||
startOemCloudOAuthCallbackBridge:
|
||||
systemBrowserMocks.startOemCloudOAuthCallbackBridge,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/tauri-runtime", () => ({
|
||||
@@ -47,6 +55,15 @@ vi.mock("@/lib/tauri-runtime", () => ({
|
||||
hasTauriRuntimeMarkers: tauriRuntimeMocks.hasTauriRuntimeMarkers,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/dev-bridge", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/dev-bridge")>();
|
||||
return {
|
||||
...actual,
|
||||
isDevBridgeAvailable: devBridgeMocks.isDevBridgeAvailable,
|
||||
safeListen: devBridgeMocks.safeListen,
|
||||
};
|
||||
});
|
||||
|
||||
function createOpenedWindow() {
|
||||
return {
|
||||
closed: false,
|
||||
@@ -73,16 +90,28 @@ describe("oemCloudLoginLauncher", () => {
|
||||
systemBrowserMocks.openExternalUrlWithSystemBrowser.mockResolvedValue(
|
||||
undefined,
|
||||
);
|
||||
systemBrowserMocks.startOemCloudOAuthCallbackBridge.mockReset();
|
||||
systemBrowserMocks.startOemCloudOAuthCallbackBridge.mockResolvedValue({
|
||||
callbackUrl: "http://127.0.0.1:18081/oauth/callback",
|
||||
});
|
||||
controlPlaneMocks.createClientDesktopAuthSession.mockReset();
|
||||
controlPlaneMocks.pollClientDesktopAuthSession.mockReset();
|
||||
tauriRuntimeMocks.hasTauriInvokeCapability.mockReset();
|
||||
tauriRuntimeMocks.hasTauriRuntimeMarkers.mockReset();
|
||||
tauriRuntimeMocks.hasTauriInvokeCapability.mockReturnValue(false);
|
||||
tauriRuntimeMocks.hasTauriRuntimeMarkers.mockReturnValue(false);
|
||||
devBridgeMocks.isDevBridgeAvailable.mockReset();
|
||||
devBridgeMocks.isDevBridgeAvailable.mockReturnValue(false);
|
||||
devBridgeMocks.safeListen.mockReset();
|
||||
devBridgeMocks.safeListen.mockResolvedValue(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
Object.defineProperty(window, "parent", {
|
||||
configurable: true,
|
||||
value: window,
|
||||
});
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -165,6 +194,109 @@ describe("oemCloudLoginLauncher", () => {
|
||||
expect(windowOpenSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("Tauri 桌面登录应启动本机回调桥并把 callbackUrl 传给 desktop auth session", async () => {
|
||||
tauriRuntimeMocks.hasTauriInvokeCapability.mockReturnValue(true);
|
||||
tauriRuntimeMocks.hasTauriRuntimeMarkers.mockReturnValue(true);
|
||||
controlPlaneMocks.createClientDesktopAuthSession.mockResolvedValue({
|
||||
authSessionId: "auth-session-001",
|
||||
deviceCode: "device-001",
|
||||
tenantId: "tenant-0001",
|
||||
clientId: "desktop-client",
|
||||
clientName: "Lime Desktop",
|
||||
provider: "google",
|
||||
desktopRedirectUri: "http://127.0.0.1:18081/oauth/callback",
|
||||
status: "pending_login",
|
||||
expiresInSeconds: 600,
|
||||
pollIntervalSeconds: 1,
|
||||
authorizeUrl: "https://user.limeai.run/oauth/desktop/device-001/signin",
|
||||
});
|
||||
controlPlaneMocks.pollClientDesktopAuthSession.mockReturnValue(
|
||||
new Promise(() => undefined),
|
||||
);
|
||||
|
||||
await startOemCloudLogin(
|
||||
{
|
||||
baseUrl: "https://user.limeai.run",
|
||||
controlPlaneBaseUrl: "https://user.limeai.run/api",
|
||||
sceneBaseUrl: "https://user.limeai.run/scene-api",
|
||||
gatewayBaseUrl: "https://llm.limeai.run",
|
||||
tenantId: "tenant-0001",
|
||||
sessionToken: null,
|
||||
hubProviderName: null,
|
||||
loginPath: "/login",
|
||||
desktopClientId: "desktop-client",
|
||||
desktopOauthRedirectUrl: "lime://oauth/callback",
|
||||
desktopOauthNextPath: "/welcome",
|
||||
},
|
||||
{ waitForCompletion: false },
|
||||
);
|
||||
|
||||
expect(
|
||||
systemBrowserMocks.startOemCloudOAuthCallbackBridge,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
controlPlaneMocks.createClientDesktopAuthSession,
|
||||
).toHaveBeenCalledWith("tenant-0001", {
|
||||
clientId: "desktop-client",
|
||||
provider: "google",
|
||||
desktopRedirectUri: "http://127.0.0.1:18081/oauth/callback",
|
||||
});
|
||||
});
|
||||
|
||||
it("DevBridge 浏览器模式应使用宿主命令打开登录页并启用本机回调桥", async () => {
|
||||
devBridgeMocks.isDevBridgeAvailable.mockReturnValue(true);
|
||||
controlPlaneMocks.createClientDesktopAuthSession.mockResolvedValue({
|
||||
authSessionId: "auth-session-001",
|
||||
deviceCode: "device-001",
|
||||
tenantId: "tenant-0001",
|
||||
clientId: "desktop-client",
|
||||
clientName: "Lime Desktop",
|
||||
provider: "google",
|
||||
desktopRedirectUri: "http://127.0.0.1:18081/oauth/callback",
|
||||
status: "pending_login",
|
||||
expiresInSeconds: 600,
|
||||
pollIntervalSeconds: 1,
|
||||
authorizeUrl: "https://user.limeai.run/oauth/desktop/device-001/signin",
|
||||
});
|
||||
controlPlaneMocks.pollClientDesktopAuthSession.mockReturnValue(
|
||||
new Promise(() => undefined),
|
||||
);
|
||||
|
||||
await startOemCloudLogin(
|
||||
{
|
||||
baseUrl: "https://user.limeai.run",
|
||||
controlPlaneBaseUrl: "https://user.limeai.run/api",
|
||||
sceneBaseUrl: "https://user.limeai.run/scene-api",
|
||||
gatewayBaseUrl: "https://llm.limeai.run",
|
||||
tenantId: "tenant-0001",
|
||||
sessionToken: null,
|
||||
hubProviderName: null,
|
||||
loginPath: "/login",
|
||||
desktopClientId: "desktop-client",
|
||||
desktopOauthRedirectUrl: "lime://oauth/callback",
|
||||
desktopOauthNextPath: "/welcome",
|
||||
},
|
||||
{ waitForCompletion: false },
|
||||
);
|
||||
|
||||
expect(
|
||||
systemBrowserMocks.startOemCloudOAuthCallbackBridge,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
controlPlaneMocks.createClientDesktopAuthSession,
|
||||
).toHaveBeenCalledWith("tenant-0001", {
|
||||
clientId: "desktop-client",
|
||||
provider: "google",
|
||||
desktopRedirectUri: "http://127.0.0.1:18081/oauth/callback",
|
||||
});
|
||||
expect(
|
||||
systemBrowserMocks.openExternalUrlWithSystemBrowser,
|
||||
).toHaveBeenCalledWith(
|
||||
"https://user.limeai.run/oauth/desktop/device-001/signin",
|
||||
);
|
||||
expect(shellOpenMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("浏览器弹窗被拦截时应抛出可感知错误", async () => {
|
||||
vi.spyOn(window, "open").mockReturnValue(null);
|
||||
|
||||
@@ -326,4 +458,61 @@ describe("oemCloudLoginLauncher", () => {
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
it("requestLogin fallback 打开普通登录页后应等待本机回调完成", async () => {
|
||||
tauriRuntimeMocks.hasTauriInvokeCapability.mockReturnValue(true);
|
||||
tauriRuntimeMocks.hasTauriRuntimeMarkers.mockReturnValue(true);
|
||||
controlPlaneMocks.createClientDesktopAuthSession.mockRejectedValue(
|
||||
new Error("desktop client not found"),
|
||||
);
|
||||
|
||||
const loginPromise = startOemCloudLogin(
|
||||
{
|
||||
baseUrl: "https://user.limeai.run",
|
||||
controlPlaneBaseUrl: "https://user.limeai.run/api",
|
||||
sceneBaseUrl: "https://user.limeai.run/scene-api",
|
||||
gatewayBaseUrl: "https://llm.limeai.run",
|
||||
tenantId: "tenant-0001",
|
||||
sessionToken: null,
|
||||
hubProviderName: null,
|
||||
loginPath: "/login",
|
||||
desktopClientId: "desktop-client",
|
||||
desktopOauthRedirectUrl: "lime://oauth/callback",
|
||||
desktopOauthNextPath: "/welcome",
|
||||
},
|
||||
{ waitForCompletion: true },
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
systemBrowserMocks.openExternalUrlWithSystemBrowser,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
const openedUrl = systemBrowserMocks.openExternalUrlWithSystemBrowser.mock
|
||||
.calls[0]?.[0] as string;
|
||||
expect(new URL(openedUrl).searchParams.get("redirectUrl")).toBe(
|
||||
"http://127.0.0.1:18081/oauth/callback",
|
||||
);
|
||||
|
||||
setStoredOemCloudSessionState({
|
||||
token: "session-token",
|
||||
tenant: { id: "tenant-0001", slug: "tenant-0001" },
|
||||
user: { id: "user-001" },
|
||||
session: { id: "session-001", provider: "google" },
|
||||
});
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("lime:oem-cloud-oauth-completed", {
|
||||
detail: {
|
||||
tenantId: "tenant-0001",
|
||||
nextPath: "/welcome",
|
||||
provider: "google",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(loginPromise).resolves.toEqual({
|
||||
mode: "login_url",
|
||||
openedUrl,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,8 +6,14 @@ import {
|
||||
createClientDesktopAuthSession,
|
||||
pollClientDesktopAuthSession,
|
||||
} from "@/lib/api/oemCloudControlPlane";
|
||||
import { openExternalUrlWithSystemBrowser } from "@/lib/api/externalUrl";
|
||||
import {
|
||||
OEM_CLOUD_OAUTH_CALLBACK_BRIDGE_EVENT,
|
||||
type OemCloudOAuthCallbackBridgePayload,
|
||||
openExternalUrlWithSystemBrowser,
|
||||
startOemCloudOAuthCallbackBridge,
|
||||
} from "@/lib/api/externalUrl";
|
||||
import {
|
||||
DEFAULT_OEM_CLOUD_DESKTOP_OAUTH_NEXT_PATH,
|
||||
resolveOemCloudRuntimeContext,
|
||||
type OemCloudRuntimeContext,
|
||||
} from "@/lib/api/oemCloudRuntime";
|
||||
@@ -21,6 +27,7 @@ import {
|
||||
hasTauriInvokeCapability,
|
||||
hasTauriRuntimeMarkers,
|
||||
} from "@/lib/tauri-runtime";
|
||||
import { isDevBridgeAvailable, safeListen } from "@/lib/dev-bridge";
|
||||
|
||||
const DESKTOP_AUTH_LEGACY_CLIENT_IDS: Record<string, string[]> = {
|
||||
"limehub-desktop": ["lobehub-desktop"],
|
||||
@@ -152,9 +159,7 @@ export async function openExternalUrl(
|
||||
url: string,
|
||||
options: OpenExternalUrlOptions = {},
|
||||
): Promise<void> {
|
||||
const shouldUseTauriShell =
|
||||
hasTauriInvokeCapability() || hasTauriRuntimeMarkers();
|
||||
if (shouldUseTauriShell) {
|
||||
if (hasHostDesktopBridge()) {
|
||||
let nativeOpenError: unknown = null;
|
||||
try {
|
||||
await openExternalUrlWithSystemBrowser(url);
|
||||
@@ -274,7 +279,7 @@ function isDesktopClientNotFound(error: unknown) {
|
||||
);
|
||||
}
|
||||
|
||||
function isLoopbackDesktopOauthRedirectUrl(value: string) {
|
||||
export function isLoopbackDesktopOauthRedirectUrl(value: string) {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (!["http:", "https:"].includes(parsed.protocol)) {
|
||||
@@ -287,6 +292,42 @@ function isLoopbackDesktopOauthRedirectUrl(value: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function shouldUseOAuthCallbackBridge() {
|
||||
return hasHostDesktopBridge();
|
||||
}
|
||||
|
||||
function hasHostDesktopBridge() {
|
||||
return (
|
||||
hasTauriInvokeCapability() ||
|
||||
hasTauriRuntimeMarkers() ||
|
||||
isDevBridgeAvailable()
|
||||
);
|
||||
}
|
||||
|
||||
function withDesktopOauthRedirectUrl(
|
||||
runtime: OemCloudRuntimeContext,
|
||||
desktopOauthRedirectUrl: string,
|
||||
): OemCloudRuntimeContext {
|
||||
return {
|
||||
...runtime,
|
||||
desktopOauthRedirectUrl,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveLoginRuntime(runtime: OemCloudRuntimeContext) {
|
||||
if (!shouldUseOAuthCallbackBridge()) {
|
||||
return runtime;
|
||||
}
|
||||
|
||||
const { callbackUrl } = await startOemCloudOAuthCallbackBridge();
|
||||
const normalizedCallbackUrl = callbackUrl?.trim();
|
||||
if (!normalizedCallbackUrl) {
|
||||
return runtime;
|
||||
}
|
||||
|
||||
return withDesktopOauthRedirectUrl(runtime, normalizedCallbackUrl);
|
||||
}
|
||||
|
||||
function resolveDesktopClientIdCandidates(clientId: string) {
|
||||
const primaryClientId = clientId.trim();
|
||||
const fallbackClientIds =
|
||||
@@ -297,12 +338,6 @@ function resolveDesktopClientIdCandidates(clientId: string) {
|
||||
async function createGoogleDesktopAuthSession(
|
||||
runtime: OemCloudRuntimeContext,
|
||||
): Promise<OemCloudDesktopAuthSessionStartResponse> {
|
||||
if (isLoopbackDesktopOauthRedirectUrl(runtime.desktopOauthRedirectUrl)) {
|
||||
throw new Error(
|
||||
"桌面登录回跳地址仍是 localhost 本地回调,请改为 lime://oauth/callback。",
|
||||
);
|
||||
}
|
||||
|
||||
const payload: CreateClientDesktopAuthSessionPayload = {
|
||||
clientId: runtime.desktopClientId,
|
||||
provider: "google",
|
||||
@@ -477,6 +512,121 @@ function isGoogleOauthCompletionForRuntime(
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCallbackBridgeText(value: unknown): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const normalized = value.trim();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
function toDesktopOAuthPayloadFromBridgeEvent(
|
||||
payload: OemCloudOAuthCallbackBridgePayload | null,
|
||||
) {
|
||||
return {
|
||||
tenantId: normalizeCallbackBridgeText(payload?.tenantId),
|
||||
token: normalizeCallbackBridgeText(payload?.token),
|
||||
nextPath:
|
||||
normalizeCallbackBridgeText(payload?.next) ??
|
||||
DEFAULT_OEM_CLOUD_DESKTOP_OAUTH_NEXT_PATH,
|
||||
error: normalizeCallbackBridgeText(payload?.error),
|
||||
};
|
||||
}
|
||||
|
||||
function subscribeOauthCallbackBridge(
|
||||
runtime: OemCloudRuntimeContext,
|
||||
onComplete: () => void,
|
||||
onError: (error: Error) => void,
|
||||
) {
|
||||
if (typeof window === "undefined" || !shouldUseOAuthCallbackBridge()) {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
let nativeUnlisten: (() => void) | null = null;
|
||||
|
||||
const handleCallback = (
|
||||
payload: OemCloudOAuthCallbackBridgePayload | null,
|
||||
) => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const callbackPayload = toDesktopOAuthPayloadFromBridgeEvent(payload);
|
||||
if (callbackPayload.error) {
|
||||
onError(new Error(callbackPayload.error));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!callbackPayload.tenantId || !callbackPayload.token) {
|
||||
return;
|
||||
}
|
||||
|
||||
void completeOemCloudDesktopOAuthLogin(callbackPayload)
|
||||
.then(() => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isGoogleOauthCompletionForRuntime(
|
||||
{
|
||||
tenantId: callbackPayload.tenantId!,
|
||||
nextPath: callbackPayload.nextPath,
|
||||
provider: "google",
|
||||
},
|
||||
runtime,
|
||||
)
|
||||
) {
|
||||
onComplete();
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
onError(
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error("OAuth 本地回调同步失败"),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const handleWindowCallback = (event: Event) => {
|
||||
const detail =
|
||||
event instanceof CustomEvent
|
||||
? (event.detail as OemCloudOAuthCallbackBridgePayload)
|
||||
: null;
|
||||
handleCallback(detail);
|
||||
};
|
||||
|
||||
window.addEventListener(
|
||||
OEM_CLOUD_OAUTH_CALLBACK_BRIDGE_EVENT,
|
||||
handleWindowCallback,
|
||||
);
|
||||
|
||||
void safeListen<OemCloudOAuthCallbackBridgePayload>(
|
||||
OEM_CLOUD_OAUTH_CALLBACK_BRIDGE_EVENT,
|
||||
(event) => {
|
||||
handleCallback(event.payload);
|
||||
},
|
||||
)
|
||||
.then((unlisten) => {
|
||||
if (disposed) {
|
||||
unlisten();
|
||||
return;
|
||||
}
|
||||
nativeUnlisten = unlisten;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
nativeUnlisten?.();
|
||||
window.removeEventListener(
|
||||
OEM_CLOUD_OAUTH_CALLBACK_BRIDGE_EVENT,
|
||||
handleWindowCallback,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function monitorGoogleDesktopAuthCompletion(
|
||||
runtime: OemCloudRuntimeContext,
|
||||
authSession: OemCloudDesktopAuthSessionStartResponse,
|
||||
@@ -519,6 +669,17 @@ export async function openConfiguredOemCloudLoginUrl(
|
||||
};
|
||||
}
|
||||
|
||||
function shouldWaitForLoginUrlCompletion(
|
||||
runtime: OemCloudRuntimeContext,
|
||||
options: OemCloudLoginLaunchOptions,
|
||||
) {
|
||||
return (
|
||||
options.waitForCompletion === true &&
|
||||
shouldUseOAuthCallbackBridge() &&
|
||||
isLoopbackDesktopOauthRedirectUrl(runtime.desktopOauthRedirectUrl)
|
||||
);
|
||||
}
|
||||
|
||||
export async function startOemCloudLogin(
|
||||
runtime = resolveOemCloudRuntimeContext(),
|
||||
options: OemCloudLoginLaunchOptions = {},
|
||||
@@ -526,33 +687,56 @@ export async function startOemCloudLogin(
|
||||
if (!runtime) {
|
||||
throw new Error("当前版本未配置云端登录入口。");
|
||||
}
|
||||
const loginRuntime = await resolveLoginRuntime(runtime);
|
||||
|
||||
let oauthCompleted = false;
|
||||
let disposeOauthCompletedListener: () => void = () => undefined;
|
||||
const oauthCompletedPromise =
|
||||
typeof window === "undefined"
|
||||
? new Promise<void>(() => undefined)
|
||||
: new Promise<void>((resolve) => {
|
||||
disposeOauthCompletedListener = subscribeOauthCompleted(
|
||||
runtime,
|
||||
: new Promise<void>((resolve, reject) => {
|
||||
const complete = () => {
|
||||
oauthCompleted = true;
|
||||
disposeOauthCompletedListener();
|
||||
resolve();
|
||||
};
|
||||
const fail = (error: Error) => {
|
||||
oauthCompleted = true;
|
||||
disposeOauthCompletedListener();
|
||||
reject(error);
|
||||
};
|
||||
const disposeOauthCompleted = subscribeOauthCompleted(
|
||||
loginRuntime,
|
||||
() => {
|
||||
oauthCompleted = true;
|
||||
disposeOauthCompletedListener();
|
||||
resolve();
|
||||
complete();
|
||||
},
|
||||
);
|
||||
const disposeCallbackBridge = subscribeOauthCallbackBridge(
|
||||
loginRuntime,
|
||||
complete,
|
||||
fail,
|
||||
);
|
||||
disposeOauthCompletedListener = () => {
|
||||
disposeOauthCompleted();
|
||||
disposeCallbackBridge();
|
||||
};
|
||||
});
|
||||
|
||||
let authSession: OemCloudDesktopAuthSessionStartResponse | null = null;
|
||||
try {
|
||||
authSession = await createGoogleDesktopAuthSession(runtime);
|
||||
authSession = await createGoogleDesktopAuthSession(loginRuntime);
|
||||
} catch (error) {
|
||||
disposeOauthCompletedListener();
|
||||
if (error instanceof Error && /localhost 本地回调/.test(error.message)) {
|
||||
options.browserTarget?.close();
|
||||
throw error;
|
||||
const result = await openConfiguredOemCloudLoginUrl(loginRuntime, options);
|
||||
if (shouldWaitForLoginUrlCompletion(loginRuntime, options)) {
|
||||
try {
|
||||
await oauthCompletedPromise;
|
||||
} finally {
|
||||
disposeOauthCompletedListener();
|
||||
}
|
||||
} else {
|
||||
disposeOauthCompletedListener();
|
||||
}
|
||||
return openConfiguredOemCloudLoginUrl(runtime, options);
|
||||
return result;
|
||||
}
|
||||
|
||||
await openExternalUrl(authSession.authorizeUrl, {
|
||||
@@ -561,7 +745,7 @@ export async function startOemCloudLogin(
|
||||
});
|
||||
|
||||
const completionPromise = monitorGoogleDesktopAuthCompletion(
|
||||
runtime,
|
||||
loginRuntime,
|
||||
authSession,
|
||||
oauthCompletedPromise,
|
||||
() => oauthCompleted,
|
||||
|
||||
@@ -92,6 +92,9 @@ export const fileSystemMocks: Record<string, (args?: any) => any> = {
|
||||
}
|
||||
return {};
|
||||
},
|
||||
start_oem_cloud_oauth_callback_bridge: () => ({
|
||||
callbackUrl: "http://127.0.0.1:1420/oauth/callback",
|
||||
}),
|
||||
delete_file: () => ({ success: true }),
|
||||
create_file: () => ({ success: true }),
|
||||
create_directory: () => ({ success: true }),
|
||||
|
||||
Reference in New Issue
Block a user