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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions TODO/done/34-fix-desktop-proxy-prompt-and-crash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# MVP-34:修复 Desktop 代理、首条消息和会话崩溃

- 状态:已完成
- 优先级:P0

## 目标

修复 deb 安装版无法自动读取 Shell 或 v2rayN 本地 HTTP 代理、新 Session 首条用户消息不显示,以及 Assistant Turn 结束时的 Renderer 崩溃。

## 任务

- [x] 自动代理在 Login Shell 未返回 HTTP 代理时,补充读取交互 Shell 和 v2rayN 配置。
- [x] 新 Session 发送时立即显示用户消息,失败时恢复输入。
- [x] 避免 Agent 运行中扫描正在写入的 Session 文件。
- [x] 修复活动 Assistant Turn 转入历史时的 assistant-ui 索引越界。
- [x] 增加三个问题的回归测试。

## 完成条件

- [x] `pnpm check` 通过。
- [x] `pnpm build` 通过。
- [x] Linux AppImage 和 deb 生成及包检查通过。
- [x] 源码版和解包安装版的真实 Runtime smoke 通过。
- [x] PR Quality 通过。

## 验证记录

- PR:#49
- PR Quality:30341670776
- 本地测试:25 个测试文件、162 个测试通过。
- Linux AppImage 和 deb 生成、包检查、源码版与解包安装版 Runtime smoke 通过。
4 changes: 2 additions & 2 deletions docs/product-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ OMP Desktop 不为 Electron 自身建立一套泛化代理。MVP 的网络设置
Runtime Network Profile 提供三种模式:

1. 不使用代理。
2. 使用系统代理
2. 自动代理
3. 手动代理。

手动代理配置包括:
Expand All @@ -238,7 +238,7 @@ Runtime Network Profile 提供三种模式:
三种模式的解析规则:

- 不使用代理:从最终环境中显式移除大小写代理变量。
- 使用系统代理:保留 Desktop 启动环境中已有的大小写代理变量;未发现时明确报错,不静默直连
- 自动代理:先读取 Login Shell 和交互 Shell 的 HTTP 代理变量;未发现时读取 v2rayN `guiNConfig.json` 中的入站端口,仅在本地端口能返回 HTTP 代理响应时使用。不扫描其他端口;未发现时保持直连并在界面显示未注入代理
- 手动代理:用用户输入的值覆盖继承的代理变量。

### 9.1 OMP Runtime 与 RPC Bash
Expand Down
149 changes: 139 additions & 10 deletions src/main/runtime-environment.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { execFile } from 'node:child_process'
import { access } from 'node:fs/promises'
import { access, readFile } from 'node:fs/promises'
import { constants } from 'node:fs'
import { createConnection } from 'node:net'
import { homedir } from 'node:os'
import { delimiter, join } from 'node:path'
import { promisify } from 'node:util'
import type {
Expand Down Expand Up @@ -38,6 +39,51 @@ export type ResolvedRuntimeEnvironment = {
sourceError?: string
}

type ReadShellEnvironment = (
shell: string,
args: string[],
env: NodeJS.ProcessEnv
) => Promise<string>

type DiscoveredLocalProxy = { url: string; source: string }
type DiscoverLocalProxy = () => Promise<DiscoveredLocalProxy | undefined>

export function extractV2rayNPorts(value: unknown): number[] {
if (!value || typeof value !== 'object') return []
const inbounds = (value as Record<string, unknown>)['Inbound']
if (!Array.isArray(inbounds)) return []
return [
...new Set(
inbounds.flatMap((item) => {
if (!item || typeof item !== 'object') return []
const port = (item as Record<string, unknown>)['LocalPort']
return typeof port === 'number' &&
Number.isInteger(port) &&
port >= 1 &&
port <= 65_535
? [port]
: []
})
)
]
}

export async function discoverV2rayNProxy(
dataHome = process.env['XDG_DATA_HOME'] || join(homedir(), '.local', 'share')
): Promise<DiscoveredLocalProxy | undefined> {
const configPath = join(dataHome, 'v2rayN', 'guiConfigs', 'guiNConfig.json')
const config = await readFile(configPath, 'utf8')
.then((text) => JSON.parse(text) as unknown)
.catch(() => undefined)
for (const port of extractV2rayNPorts(config)) {
if (await checkLocalHttpProxy(port, 700)) {
const url = `http://127.0.0.1:${port}`
return { url, source: `v2rayN (${url})` }
}
}
return undefined
}

function removeProxyVariables(env: NodeJS.ProcessEnv): void {
for (const key of PROXY_KEYS) delete env[key]
}
Expand Down Expand Up @@ -86,7 +132,22 @@ export class RuntimeEnvironmentResolver {
constructor(
readonly runtimePath: string,
private readonly electronEnv: NodeJS.ProcessEnv = process.env,
private readonly timeoutMs = 5_000
timeoutMs = 5_000,
private readonly readShellEnvironment: ReadShellEnvironment = async (
shell,
args,
env
) =>
(
await execFileAsync(shell, args, {
encoding: 'utf8',
env,
maxBuffer: 4 * 1024 * 1024,
timeout: timeoutMs
})
).stdout,
private readonly discoverLocalProxy: DiscoverLocalProxy = () =>
discoverV2rayNProxy(electronEnv['XDG_DATA_HOME'])
) {}

async resolve(
Expand All @@ -96,14 +157,15 @@ export class RuntimeEnvironmentResolver {
let base: NodeJS.ProcessEnv
let source: RuntimeNetworkStatus['source'] = 'login-shell'
let sourceError: string | undefined
let discoveredProxySource: string | undefined
try {
const { stdout } = await execFileAsync(shell, ['-ilc', 'env -0'], {
encoding: 'utf8',
env: this.electronEnv,
maxBuffer: 4 * 1024 * 1024,
timeout: this.timeoutMs
})
base = parseShellEnvironment(stdout)
base = parseShellEnvironment(
await this.readShellEnvironment(
shell,
['-ilc', 'env -0'],
this.electronEnv
)
)
} catch (error) {
base = { ...this.electronEnv }
source = 'electron-fallback'
Expand All @@ -113,8 +175,42 @@ export class RuntimeEnvironmentResolver {
: 'Login Shell 探测失败'
}

if (
config.mode === 'auto' &&
this.detectProxy(base, config, source).result !== 'http-proxy'
) {
try {
const interactive = parseShellEnvironment(
await this.readShellEnvironment(
shell,
['-ic', 'env -0'],
this.electronEnv
)
)
for (const key of PROXY_KEYS) {
if (interactive[key]) base[key] = interactive[key]
}
} catch {
// Login Shell 仍可用时,交互 Shell 探测失败不影响其他环境。
}
}

if (
config.mode === 'auto' &&
this.detectProxy(base, config, source).result !== 'http-proxy'
) {
const discovered = await this.discoverLocalProxy().catch(() => undefined)
if (discovered) {
base['HTTPS_PROXY'] = discovered.url
discoveredProxySource = discovered.source
}
}

const env = { ...base }
const detected = this.detectProxy(base, config, source)
const environmentDetection = this.detectProxy(base, config, source)
const detected = discoveredProxySource
? { ...environmentDetection, proxySource: discoveredProxySource }
: environmentDetection
removeProxyVariables(env)
if (config.mode === 'manual') {
if (!config.manualPort) throw new Error('请输入 1–65535 的本地代理端口')
Expand Down Expand Up @@ -303,3 +399,36 @@ export function checkLocalProxyPort(
socket.once('error', () => finish(false))
})
}

export function checkLocalHttpProxy(
port: number,
timeoutMs = 1_500
): Promise<boolean> {
if (!Number.isInteger(port) || port < 1 || port > 65_535)
return Promise.resolve(false)
return new Promise((resolve) => {
const socket = createConnection({ host: '127.0.0.1', port })
let settled = false
let response = ''
const finish = (result: boolean): void => {
if (settled) return
settled = true
socket.destroy()
resolve(result)
}
socket.setTimeout(timeoutMs, () => finish(false))
socket.once('connect', () => {
socket.write(
'GET http://127.0.0.1:1/ HTTP/1.1\r\nHost: 127.0.0.1:1\r\nConnection: close\r\n\r\n'
)
})
socket.on('data', (chunk) => {
response += String(chunk)
if (response.includes('\r\n'))
finish(/^HTTP\/1\.[01] \d{3}/u.test(response))
else if (response.length > 256) finish(false)
})
socket.once('end', () => finish(false))
socket.once('error', () => finish(false))
})
}
9 changes: 5 additions & 4 deletions src/main/runtime-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1919,9 +1919,7 @@ export function registerRuntimeIpc(
await supervisor.restart(approvalMode)
supervisor.setApprovalState(approvalMode, false)
}
await supervisor.newSession()
await supervisor.prompt(input)
const snapshot = await supervisor.getState()
const snapshot = await supervisor.newSession()
if (!snapshot.sessionId)
throw new RuntimeFailure(
'PROTOCOL_ERROR',
Expand Down Expand Up @@ -1949,8 +1947,11 @@ export function registerRuntimeIpc(
workspace.id,
snapshot.sessionId
)
await supervisor.prompt(input)
return success({
snapshot: approvalModeSaved ? snapshot : supervisor.snapshot,
snapshot: approvalModeSaved
? supervisor.snapshot
: supervisor.setApprovalState(approvalMode, false, false),
session: stateStore.applyPreferences(workspace.id, session)
})
} catch (error) {
Expand Down
Loading