feat: keep managed SSH prompts running on Hosts
Managed SSH prompts previously depended on Desktop-owned provider and transcript state, so accepted work could stop or become uncertain when the Desktop, network, or power disappeared. The Agent now owns accepted OpenCode prompt execution, model and tool rounds, cancellation, and the durable semantic transcript, while Desktop remains authoritative for permanent projects, conversations, tasks, and messages. Recovered work attaches to the original conversation without replay. The project switcher also groups managed SSH projects by saved Host and shows live Agent connectivity beside each group without opening a connection merely to read status. Remote project subtitles now show only the path, and the Agent lock advances to 0.11.14 with Runtime ACP v4. Release note: 托管 SSH 任务在桌面端、网络或电源中断后可继续在主机上运行,并能回到原会话精确恢复;项目菜单现按主机分组并显示实时连接状态。
This commit is contained in:
@@ -24,6 +24,10 @@ import {
|
||||
BoundedResponseTooLargeError,
|
||||
readBoundedResponseBytes
|
||||
} from './bounded-response'
|
||||
import {
|
||||
assertTextModelRequestPolicy,
|
||||
ModelRequestPolicyError
|
||||
} from '../../shared/model-request-policy'
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 60_000
|
||||
const RESPONSE_HEADER_NAMES = [
|
||||
@@ -404,17 +408,6 @@ function normalizeProviderRequestBody(
|
||||
} catch {
|
||||
throw requestPolicyError()
|
||||
}
|
||||
if (!isRecord(value) || value.model !== profile.modelName) {
|
||||
throw requestPolicyError()
|
||||
}
|
||||
if (
|
||||
profile.supportsImageInput !== true &&
|
||||
containsImageInput(value)
|
||||
) {
|
||||
throw requestPolicyError()
|
||||
}
|
||||
const request = { ...value }
|
||||
assertProviderBodyPolicy(profile, request)
|
||||
if (
|
||||
profile.protocol !== 'anthropic-messages' &&
|
||||
profile.protocol !== 'openai-chat-completions' &&
|
||||
@@ -422,6 +415,22 @@ function normalizeProviderRequestBody(
|
||||
) {
|
||||
throw requestPolicyError()
|
||||
}
|
||||
try {
|
||||
assertTextModelRequestPolicy(
|
||||
{
|
||||
protocol: profile.protocol,
|
||||
model: profile.modelName,
|
||||
supportsImageInput: profile.supportsImageInput === true
|
||||
},
|
||||
value
|
||||
)
|
||||
} catch (error) {
|
||||
if (!(error instanceof ModelRequestPolicyError)) {
|
||||
throw error
|
||||
}
|
||||
throw requestPolicyError()
|
||||
}
|
||||
const request = { ...value }
|
||||
return Buffer.from(JSON.stringify(request), 'utf8')
|
||||
}
|
||||
|
||||
@@ -432,58 +441,6 @@ function requestPolicyError(): RemoteModelGatewayError {
|
||||
)
|
||||
}
|
||||
|
||||
function assertProviderBodyPolicy(
|
||||
profile: ResolvedModelProfile,
|
||||
request: Record<string, unknown>
|
||||
): void {
|
||||
if (
|
||||
request.background === true ||
|
||||
request.store === true ||
|
||||
request.web_search_options !== undefined ||
|
||||
request.mcp_servers !== undefined ||
|
||||
request.conversation !== undefined ||
|
||||
request.previous_response_id !== undefined ||
|
||||
(request.service_tier !== undefined &&
|
||||
request.service_tier !== 'auto' &&
|
||||
request.service_tier !== 'default') ||
|
||||
(Array.isArray(request.modalities) &&
|
||||
request.modalities.some((value) => value !== 'text'))
|
||||
) {
|
||||
throw requestPolicyError()
|
||||
}
|
||||
if (
|
||||
Array.isArray(request.include) &&
|
||||
request.include.some(
|
||||
(value) =>
|
||||
typeof value !== 'string' ||
|
||||
/(?:web_search|file_search|computer|code_interpreter|mcp|image_generation)/iu.test(
|
||||
value
|
||||
)
|
||||
)
|
||||
) {
|
||||
throw requestPolicyError()
|
||||
}
|
||||
if (request.tools === undefined) {
|
||||
return
|
||||
}
|
||||
if (!Array.isArray(request.tools)) {
|
||||
throw requestPolicyError()
|
||||
}
|
||||
for (const tool of request.tools) {
|
||||
if (!isRecord(tool)) {
|
||||
throw requestPolicyError()
|
||||
}
|
||||
const type = tool.type
|
||||
if (profile.protocol === 'anthropic-messages') {
|
||||
if (type !== undefined && type !== 'custom') {
|
||||
throw requestPolicyError()
|
||||
}
|
||||
} else if (type !== 'function') {
|
||||
throw requestPolicyError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function providerUrl(
|
||||
profile: ResolvedModelProfile,
|
||||
path: string
|
||||
@@ -653,46 +610,6 @@ function isRecordArray(value: unknown): value is Record<string, unknown>[] {
|
||||
return Array.isArray(value) && value.every(isRecord)
|
||||
}
|
||||
|
||||
function containsImageInput(value: unknown): boolean {
|
||||
const maximumNodes = 100_000
|
||||
const pending: unknown[] = [value]
|
||||
let visited = 0
|
||||
while (pending.length > 0) {
|
||||
visited += 1
|
||||
if (visited > maximumNodes) {
|
||||
throw requestPolicyError()
|
||||
}
|
||||
const current = pending.pop()
|
||||
if (Array.isArray(current)) {
|
||||
if (visited + pending.length + current.length > maximumNodes) {
|
||||
throw requestPolicyError()
|
||||
}
|
||||
for (const item of current) {
|
||||
pending.push(item)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (!isRecord(current)) {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
current.type === 'image' ||
|
||||
current.type === 'image_url' ||
|
||||
current.type === 'input_image'
|
||||
) {
|
||||
return true
|
||||
}
|
||||
const values = Object.values(current)
|
||||
if (visited + pending.length + values.length > maximumNodes) {
|
||||
throw requestPolicyError()
|
||||
}
|
||||
for (const item of values) {
|
||||
pending.push(item)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function sha256(value: string | Uint8Array): string {
|
||||
return `sha256:${createHash('sha256').update(value).digest('hex')}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user