feat: open local runtime clients from the composer
Add composer shortcuts for local Continue and OpenCode terminals and the official DeepSeek Harness Web client, using the current project and model bridge. Manage browser service reuse, stop actions and terminal cleanup. Upgrade DeepSeek Harness to 0.1.7-rc.2 and Cordis to 4.0.4. Include the coordinated IPC handler extraction and related tests and documentation. Remote native-client coordinator wiring, managed Node consolidation, Ask MCP mappings and full desktop/package acceptance remain unfinished; this commit records the implemented local workflow and supporting SSH groundwork. Validation: typecheck and lint passed; 267 focused tests passed and 5 skipped. No new real-provider calls were made for this commit. Prior real-model evidence and remaining acceptance work are documented in the feature progress files. Release note: 本地项目选择 Continue 或 OpenCode 后,可从输入框下方打开原生终端;选择 DS 后,可打开使用当前项目和模型的官方 Web 客户端。客户端使用独立会话,DS 网页关闭后服务继续运行,可在 GoodBuddy 中停止;远程入口尚未完成。
This commit is contained in:
@@ -9,7 +9,7 @@ export type BundledRuntimePaths = {
|
||||
}
|
||||
|
||||
export const bundledContinueVersion = '1.5.47'
|
||||
export const bundledDeepSeekHarnessVersion = '0.1.2-rc.1'
|
||||
export const bundledDeepSeekHarnessVersion = '0.1.7-rc.2'
|
||||
|
||||
export function resolveBundledRuntimePaths(input: {
|
||||
appPath: string
|
||||
|
||||
@@ -126,19 +126,13 @@ function toolResultText(
|
||||
callId: string
|
||||
): string | undefined {
|
||||
for (const message of options.messages) {
|
||||
for (const block of message.content) {
|
||||
if (
|
||||
block.type !== 'tool-result' ||
|
||||
block.toolCallId !== callId
|
||||
) {
|
||||
continue
|
||||
}
|
||||
return block.content
|
||||
if (message.role === 'tool' && message.toolCallId === callId) {
|
||||
return message.content
|
||||
.filter(
|
||||
(
|
||||
content
|
||||
): content is Extract<
|
||||
(typeof block.content)[number],
|
||||
(typeof message.content)[number],
|
||||
{ type: 'text' }
|
||||
> => content.type === 'text'
|
||||
)
|
||||
@@ -154,7 +148,7 @@ function latestUserText(options: GenerateOptions): string {
|
||||
.filter(
|
||||
(message) =>
|
||||
message.role === 'user' &&
|
||||
message.source.kind === 'user'
|
||||
message.source?.kind === 'user'
|
||||
)
|
||||
.flatMap((message) =>
|
||||
message.content
|
||||
@@ -396,7 +390,7 @@ function createInProcessLaunch(
|
||||
model: options.model,
|
||||
supportsImageInput: options.supportsImageInput,
|
||||
requestHeaders: options.requestHeaders,
|
||||
harnessVersion: '0.1.2-rc.1',
|
||||
harnessVersion: '0.1.7-rc.2',
|
||||
credentialRefs: options.credentialRefs,
|
||||
skillPackages: options.skillPackages,
|
||||
extensionPackages: options.extensionPackages,
|
||||
@@ -838,9 +832,14 @@ describe('DeepSeek Harness real ACP control-plane E2E', () => {
|
||||
)
|
||||
|
||||
expect(observedRequest?.maxTokens).toBeUndefined()
|
||||
expect(observedRequest?.system).toContain(
|
||||
'act through the available tools'
|
||||
)
|
||||
expect(
|
||||
observedRequest?.messages
|
||||
.filter((message) => message.role === 'system')
|
||||
.flatMap((message) => message.content)
|
||||
.filter((block) => block.type === 'text')
|
||||
.map((block) => block.text)
|
||||
.join('\n')
|
||||
).toContain('act through the available tools')
|
||||
expect(reasoning).toHaveLength(8)
|
||||
expect(
|
||||
reasoning.map((event) => event.delta).join('')
|
||||
|
||||
@@ -159,7 +159,7 @@ function setup(
|
||||
if (method === 'goodbuddy/handshake') {
|
||||
return {
|
||||
controlProtocolVersion: 1,
|
||||
harnessVersion: '0.1.2-rc.1',
|
||||
harnessVersion: '0.1.7-rc.2',
|
||||
acpProtocolVersion: 1,
|
||||
supports: {
|
||||
cancellation: true,
|
||||
|
||||
@@ -95,7 +95,7 @@ describe.skipIf(!enabled)('DSH marketplace live E2E', () => {
|
||||
api: 'openai-completions',
|
||||
provider: 'goodbuddy',
|
||||
model: 'deepseek-test',
|
||||
harnessVersion: '0.1.2-rc.1',
|
||||
harnessVersion: '0.1.7-rc.2',
|
||||
credentialRefs: ['GOODBUDDY_API_KEY'],
|
||||
skillPackages: [],
|
||||
extensionPackages: extensions,
|
||||
|
||||
@@ -22,7 +22,7 @@ function controlPlane() {
|
||||
provider: 'goodbuddy',
|
||||
model: 'deepseek-test',
|
||||
workspace: resolve('workspace'),
|
||||
harnessVersion: '0.1.2-rc.1',
|
||||
harnessVersion: '0.1.7-rc.2',
|
||||
execution: { mode: 'host' },
|
||||
credentialRefs: ['GOODBUDDY_API_KEY'],
|
||||
skills: []
|
||||
@@ -75,7 +75,7 @@ function stubAgentContext() {
|
||||
provider: 'goodbuddy',
|
||||
model: 'deepseek-test',
|
||||
workspace: resolve('workspace'),
|
||||
harnessVersion: '0.1.2-rc.1',
|
||||
harnessVersion: '0.1.7-rc.2',
|
||||
execution: { mode: 'host' },
|
||||
credentialRefs: ['GOODBUDDY_API_KEY'],
|
||||
skills: [],
|
||||
@@ -169,7 +169,7 @@ describe('GoodBuddy Harness internal control plane', () => {
|
||||
model: 'vision-test',
|
||||
supportsImageInput: true,
|
||||
workspace: resolve('workspace'),
|
||||
harnessVersion: '0.1.2-rc.1',
|
||||
harnessVersion: '0.1.7-rc.2',
|
||||
execution: { mode: 'host' },
|
||||
credentialRefs: ['GOODBUDDY_API_KEY'],
|
||||
skills: []
|
||||
@@ -337,37 +337,33 @@ describe('GoodBuddy Harness internal control plane', () => {
|
||||
it('delivers more than 4 MiB of cumulative output without cancelling', async () => {
|
||||
const { listeners, extNotification, handle, internals } =
|
||||
stubAgentContext()
|
||||
const sessionEvent = listeners.get('session/event')!
|
||||
const assistantStream = listeners.get('agent/assistant-stream')!
|
||||
for (let index = 0; index < 65; index++) {
|
||||
sessionEvent(
|
||||
handle.agent.session,
|
||||
{
|
||||
type: 'assistant/chunk',
|
||||
data: {
|
||||
chunk: {
|
||||
type: 'text-delta',
|
||||
text: 'x'.repeat(65_536)
|
||||
assistantStream({
|
||||
agent: handle.agent,
|
||||
frame: {
|
||||
type: 'chunk',
|
||||
chunk: {
|
||||
type: 'text-delta',
|
||||
text: 'x'.repeat(65_536)
|
||||
}
|
||||
}
|
||||
})
|
||||
assistantStream({
|
||||
agent: handle.agent,
|
||||
frame: {
|
||||
type: 'chunk',
|
||||
chunk: {
|
||||
type: 'usage',
|
||||
usage: {
|
||||
inputTokens: 1,
|
||||
outputTokens: 1,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
sessionEvent(
|
||||
handle.agent.session,
|
||||
{
|
||||
type: 'assistant/chunk',
|
||||
data: {
|
||||
chunk: {
|
||||
type: 'usage',
|
||||
usage: {
|
||||
inputTokens: 1,
|
||||
outputTokens: 1,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
await internals.sessions.get('session-output')!.inflight.eventTail
|
||||
}
|
||||
|
||||
|
||||
@@ -730,6 +730,26 @@ export class GoodBuddyHarnessControlPlane {
|
||||
}
|
||||
return next()
|
||||
})
|
||||
this.ctx.on('agent/assistant-stream', ({ agent, frame }) => {
|
||||
const record = this.sessions.get(agent.session.id)
|
||||
if (
|
||||
record?.handle.agent !== agent ||
|
||||
!record.inflight ||
|
||||
frame.type !== 'chunk'
|
||||
) {
|
||||
return
|
||||
}
|
||||
const chunk = frame.chunk
|
||||
if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') {
|
||||
this.queueDelta(
|
||||
agent.session.id,
|
||||
chunk.type === 'text-delta' ? 'text' : 'reasoning',
|
||||
chunk.text
|
||||
)
|
||||
} else if (chunk.type === 'usage') {
|
||||
this.queueUsage(agent.session.id, chunk.usage)
|
||||
}
|
||||
})
|
||||
this.ctx.on(
|
||||
'session/event',
|
||||
(session, event: SessionEvent) => {
|
||||
@@ -741,23 +761,7 @@ export class GoodBuddyHarnessControlPlane {
|
||||
if (!inflight) {
|
||||
return
|
||||
}
|
||||
if (event.type === 'assistant/chunk') {
|
||||
const chunk = event.data.chunk
|
||||
if (
|
||||
chunk.type === 'text-delta' ||
|
||||
chunk.type === 'reasoning-delta'
|
||||
) {
|
||||
this.queueDelta(
|
||||
session.header.id,
|
||||
chunk.type === 'text-delta'
|
||||
? 'text'
|
||||
: 'reasoning',
|
||||
chunk.text
|
||||
)
|
||||
} else if (chunk.type === 'usage') {
|
||||
this.queueUsage(session.header.id, chunk.usage)
|
||||
}
|
||||
} else if (event.type === 'tool/call') {
|
||||
if (event.type === 'tool/call') {
|
||||
this.queueEvent(session.header.id, {
|
||||
type: 'tool',
|
||||
callId: event.data.callId,
|
||||
@@ -769,15 +773,13 @@ export class GoodBuddyHarnessControlPlane {
|
||||
)
|
||||
})
|
||||
} else if (event.type === 'tool/result') {
|
||||
const toolResult = event.data.message.content.find(
|
||||
(content) => content.type === 'tool-result'
|
||||
)
|
||||
const toolResult = event.data.message
|
||||
this.queueEvent(session.header.id, {
|
||||
type: 'tool',
|
||||
callId: toolResult?.toolCallId ?? 'unknown-tool-call',
|
||||
callId: toolResult.toolCallId,
|
||||
name: 'tool',
|
||||
state:
|
||||
event.data.error || toolResult?.isError === true
|
||||
event.data.error || toolResult.isError === true
|
||||
? 'failed'
|
||||
: 'completed',
|
||||
output: boundedJson(event.data.message.content)
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NativeClientCoordinator } from './native-client-coordinator'
|
||||
import { NativeDshWebClientService, type NativeDshWebClientOptions } from './native-dsh-web-client'
|
||||
import type { LocalToolRuntimeSelection } from '../../shared/local-tool-environment-contracts'
|
||||
|
||||
const coordinators: NativeClientCoordinator[] = []
|
||||
afterEach(async () => {
|
||||
await Promise.all(coordinators.splice(0).map(coordinator => coordinator.dispose()))
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function fixture() {
|
||||
const profile = { id: 'selected', name: 'Selected', protocol: 'openai-chat-completions',
|
||||
authentication: 'api-key', apiKey: 'secret', baseUrl: 'https://example.com/v1', modelName: 'selected-model' }
|
||||
const settings = { provider: 'deepseek-harness', modelProfiles: [profile], deepseekHarnessModelProfile: profile }
|
||||
const conversation = { projectId: 'project', knowledgeLibraryIds: ['library'], workMode: 'ask' }
|
||||
const project = { id: 'project', runtimeSelection: { provider: 'deepseek-harness', profileId: profile.id } }
|
||||
const application = { magicNotesEnabled: true, localToolEnvironment: { node: { source: 'managed' } as LocalToolRuntimeSelection } }
|
||||
const gateway = { start: vi.fn(), dispose: vi.fn(), grant: vi.fn(() => 'builtin-token'),
|
||||
grantCustomMcp: vi.fn(() => 'custom-token'), getEndpoint: () => 'http://127.0.0.1:1234/mcp',
|
||||
getAvailableToolNames: vi.fn(() => ['knowledge_search', 'obsidian_read_note']) }
|
||||
const handles = new Map<string, { id: string; url: string }>()
|
||||
const start = vi.spyOn(NativeDshWebClientService.prototype, 'start').mockImplementation(async () => {
|
||||
const handle = { id: `service-${handles.size}`, url: 'http://127.0.0.1:1234/?token=private' }
|
||||
handles.set(handle.id, handle)
|
||||
return handle
|
||||
})
|
||||
vi.spyOn(NativeDshWebClientService.prototype, 'get').mockImplementation(async id => handles.get(id) ?? null)
|
||||
const stop = vi.spyOn(NativeDshWebClientService.prototype, 'stop').mockImplementation(async id => { handles.delete(id) })
|
||||
const openExternal = vi.fn(async () => {})
|
||||
const coordinator = new NativeClientCoordinator({
|
||||
database: { getConversation: () => conversation, getProject: () => project },
|
||||
settingsStore: { getResolvedSettings: async () => settings },
|
||||
applicationSettingsStore: { get: async () => application },
|
||||
capabilities: { getRuntimeSkillContext: async () => ({ packages: [{ directory: '/skills/test', id: 'test', digest: 'one' }] }),
|
||||
getResolvedMcpServers: async () => [{ id: 'custom' }], getEnabledBuiltinMcpServerIds: async () => ['knowledge-base', 'obsidian'],
|
||||
getObsidianSettings: async () => ({}) },
|
||||
executionSpaceResolver: { resolveProject: () => ({ kind: 'local', rootPath: '/workspace', cacheIdentity: '/workspace' }) },
|
||||
terminalManager: { closeOwner: vi.fn() }, localEnvironment: { launchEnvironmentProvider: () => process.env },
|
||||
rootDirectory: '/clients', createGateway: () => gateway, openExternal
|
||||
} as unknown as ConstructorParameters<typeof NativeClientCoordinator>[0])
|
||||
coordinators.push(coordinator)
|
||||
return { coordinator, profile, conversation, application, gateway, start, stop, openExternal }
|
||||
}
|
||||
|
||||
describe('native client coordinator', () => {
|
||||
it('retains a ready service and its gateway after browser failure, then reopens and stops it', async () => {
|
||||
const { coordinator, gateway, start, stop, openExternal } = fixture()
|
||||
openExternal.mockRejectedValueOnce(new Error('Browser unavailable'))
|
||||
await expect(coordinator.open(1, 'conversation')).rejects.toThrow('Browser unavailable')
|
||||
expect(await coordinator.get(1, 'conversation')).toEqual({ serviceId: 'service-0' })
|
||||
expect(gateway.dispose).not.toHaveBeenCalled()
|
||||
expect(stop).not.toHaveBeenCalled()
|
||||
await expect(coordinator.open(1, 'conversation')).resolves.toEqual({ kind: 'browser', serviceId: 'service-0' })
|
||||
expect(start).toHaveBeenCalledOnce()
|
||||
await coordinator.stop(2, 'service-0')
|
||||
expect(stop).not.toHaveBeenCalled()
|
||||
await coordinator.stop(1, 'service-0')
|
||||
expect(gateway.dispose).toHaveBeenCalledOnce()
|
||||
expect(await coordinator.get(1, 'conversation')).toBeNull()
|
||||
})
|
||||
|
||||
it('matches get and reuse against current credentials, mode and Node selection', async () => {
|
||||
const { coordinator, profile, conversation, application, start } = fixture()
|
||||
await Promise.all([coordinator.open(1, 'conversation'), coordinator.open(1, 'conversation')])
|
||||
expect(start).toHaveBeenCalledOnce()
|
||||
profile.apiKey = 'rotated'
|
||||
expect(await coordinator.get(1, 'conversation')).toBeNull()
|
||||
await coordinator.open(1, 'conversation')
|
||||
expect(start.mock.calls[1]![0].profile.apiKey).toBe('rotated')
|
||||
expect(start.mock.calls[1]![0].configurationKey).not.toBe(start.mock.calls[0]![0].configurationKey)
|
||||
conversation.workMode = 'execute'
|
||||
expect(await coordinator.get(1, 'conversation')).toBeNull()
|
||||
conversation.workMode = 'ask'
|
||||
expect(await coordinator.get(1, 'conversation')).toEqual({ serviceId: 'service-1' })
|
||||
application.localToolEnvironment.node = { source: 'custom', executablePath: '/new/node' }
|
||||
expect(await coordinator.get(1, 'conversation')).toBeNull()
|
||||
expect(await coordinator.get(2, 'conversation')).toBeNull()
|
||||
})
|
||||
|
||||
it('maps gateway read grants in Ask and custom MCP only in Execute', async () => {
|
||||
const { coordinator, gateway, start, conversation } = fixture()
|
||||
await coordinator.open(1, 'conversation')
|
||||
expect(gateway.grantCustomMcp).not.toHaveBeenCalled()
|
||||
expect(start.mock.calls[0]![0]).toMatchObject({ skillDirectories: ['/skills/test'], mcpServers: [
|
||||
{ serverName: 'goodbuddy-0', readOnlyTools: ['knowledge_search', 'obsidian_read_note'] }
|
||||
] })
|
||||
conversation.workMode = 'execute'
|
||||
await coordinator.open(1, 'conversation')
|
||||
expect(gateway.grantCustomMcp).toHaveBeenCalledOnce()
|
||||
expect(start.mock.calls[1]![0].mcpServers).toHaveLength(2)
|
||||
await coordinator.closeOwner(1)
|
||||
await expect(coordinator.open(1, 'conversation')).rejects.toThrow('closed')
|
||||
})
|
||||
|
||||
it('releases the gateway after startup failure and allows retry', async () => {
|
||||
const { coordinator, start, gateway } = fixture()
|
||||
start.mockRejectedValueOnce(new Error('DS startup failed'))
|
||||
await expect(coordinator.open(1, 'conversation')).rejects.toThrow('DS startup failed')
|
||||
expect(gateway.dispose).toHaveBeenCalledOnce()
|
||||
await expect(coordinator.open(1, 'conversation')).resolves.toMatchObject({ kind: 'browser' })
|
||||
})
|
||||
|
||||
it('uses the current custom Node selection without installing a separate runtime', async () => {
|
||||
const { coordinator, application, start } = fixture()
|
||||
const resolved: string[] = []
|
||||
start.mockImplementation(async function (this: NativeDshWebClientService) {
|
||||
const options = (this as unknown as { options: NativeDshWebClientOptions }).options
|
||||
resolved.push((await options.resolveLaunchEnvironment()).nodeExecutablePath)
|
||||
return { id: `custom-${resolved.length}`, url: 'http://127.0.0.1:1234/' }
|
||||
})
|
||||
application.localToolEnvironment.node = { source: 'custom', executablePath: '/selected/node' }
|
||||
await coordinator.open(1, 'conversation')
|
||||
application.localToolEnvironment.node = { source: 'custom', executablePath: '/changed/node' }
|
||||
await coordinator.open(1, 'conversation')
|
||||
expect(resolved).toEqual(['/selected/node', '/changed/node'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,229 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { mkdir, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { normalizeInteractiveWorkMode } from '../../shared/assistant-contracts'
|
||||
import { getDefaultRuntimeSelection } from '../../shared/runtime-selection-contracts'
|
||||
import type { RuntimeNativeClientResult } from '../../shared/runtime-native-client-contracts'
|
||||
import type { AssistantDatabase } from '../assistant/assistant-database'
|
||||
import type { ApplicationSettingsStore } from '../application-settings-store'
|
||||
import type { CapabilityService } from '../capabilities/capability-service'
|
||||
import type { ExecutionSpaceResolver } from '../execution-space/execution-space-resolver'
|
||||
import type { LocalToolEnvironmentService } from '../local-tool-environment/local-tool-environment-service'
|
||||
import type { RuntimeSettingsStore } from '../runtime-settings-store'
|
||||
import type { TerminalSessionManager } from '../terminal/terminal-session-manager'
|
||||
import type { BundledRuntimePaths } from './bundled-runtimes'
|
||||
import type { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
|
||||
import { NativeDshWebClientService } from './native-dsh-web-client'
|
||||
import { NativeTerminalClient } from './native-terminal-client'
|
||||
import { applyRuntimeSelection } from './runtime-selection'
|
||||
|
||||
type Options = {
|
||||
database: AssistantDatabase
|
||||
settingsStore: RuntimeSettingsStore
|
||||
applicationSettingsStore: ApplicationSettingsStore
|
||||
capabilities: CapabilityService
|
||||
executionSpaceResolver: ExecutionSpaceResolver
|
||||
terminalManager: TerminalSessionManager
|
||||
localEnvironment: LocalToolEnvironmentService
|
||||
bundledRuntimePaths: BundledRuntimePaths
|
||||
rootDirectory: string
|
||||
managedNodeDirectory: string
|
||||
npmCliPath: string
|
||||
resourcesPath?: string
|
||||
createGateway: () => KnowledgeMcpGateway
|
||||
openExternal: (url: string) => Promise<void>
|
||||
}
|
||||
|
||||
export class NativeClientCoordinator {
|
||||
private readonly browser: NativeDshWebClientService
|
||||
private readonly services = new Map<string, { ownerId: number; id: string; dispose: () => Promise<void> }>()
|
||||
private readonly pending = new Map<string, Promise<RuntimeNativeClientResult>>()
|
||||
private readonly closedOwners = new Set<number>()
|
||||
private nodePreparation?: Promise<string>
|
||||
private readonly controller = new AbortController()
|
||||
|
||||
constructor(private readonly options: Options) {
|
||||
this.browser = new NativeDshWebClientService({
|
||||
rootDirectory: join(options.rootDirectory, 'dsh'),
|
||||
resourcesPath: options.resourcesPath,
|
||||
resolveLaunchEnvironment: async () => ({
|
||||
nodeExecutablePath: await this.prepareNode(),
|
||||
environment: options.localEnvironment.launchEnvironmentProvider()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private async prepareNode(): Promise<string> {
|
||||
const selection = (await this.options.applicationSettingsStore.get()).localToolEnvironment.node
|
||||
if (selection.source === 'custom') return selection.executablePath
|
||||
this.nodePreparation ??= (async () => {
|
||||
const directory = this.options.managedNodeDirectory
|
||||
const packageName = `node-${process.platform === 'win32' ? 'win' : process.platform}-${process.arch}`
|
||||
const executable = join(directory, 'node_modules', packageName, 'bin', process.platform === 'win32' ? 'node.exe' : 'node')
|
||||
if (!(await stat(executable).catch(() => undefined))?.isFile()) {
|
||||
await mkdir(directory, { recursive: true })
|
||||
await promisify(execFile)(process.execPath, [this.options.npmCliPath, 'install', '--prefix', directory,
|
||||
'--no-save', '--no-package-lock', '--ignore-scripts', '--no-audit', '--no-fund', '--prefer-offline', `${packageName}@22.22.0`], {
|
||||
env: { ...this.options.localEnvironment.launchEnvironmentProvider(), ELECTRON_RUN_AS_NODE: '1' },
|
||||
cwd: directory, windowsHide: true, timeout: 180_000, signal: this.controller.signal, maxBuffer: 1024 * 1024
|
||||
})
|
||||
}
|
||||
const { stdout } = await promisify(execFile)(executable, ['-p', 'JSON.stringify({node:process.versions.node,electron:process.versions.electron})'], {
|
||||
env: { ...this.options.localEnvironment.launchEnvironmentProvider() },
|
||||
windowsHide: true, timeout: 5_000, signal: this.controller.signal
|
||||
})
|
||||
const runtime = JSON.parse(stdout.trim()) as { node?: string; electron?: string }
|
||||
if (runtime.node !== '22.22.0' || runtime.electron) throw new Error('Native clients require managed standard Node 22.22.0')
|
||||
return executable
|
||||
})().catch(error => { this.nodePreparation = undefined; throw error })
|
||||
return this.nodePreparation
|
||||
}
|
||||
|
||||
private async resolve(conversationId: string) {
|
||||
const conversation = this.options.database.getConversation(conversationId)
|
||||
if (!conversation.projectId) throw new Error('Native clients require a saved project conversation')
|
||||
const project = this.options.database.getProject(conversation.projectId)
|
||||
const settings = await this.options.settingsStore.getResolvedSettings()
|
||||
const saved = conversation.runtimeSelection ?? project.runtimeSelection
|
||||
const selected = applyRuntimeSelection(settings, saved && saved.provider !== 'auto' ? saved : getDefaultRuntimeSelection(settings))
|
||||
const space = this.options.executionSpaceResolver.resolveProject(project)
|
||||
const workMode = normalizeInteractiveWorkMode(conversation.workMode ?? project.defaultWorkMode)
|
||||
const [skills, mcpServers, builtin, application, obsidian] = await Promise.all([
|
||||
this.options.capabilities.getRuntimeSkillContext(selected.target),
|
||||
this.options.capabilities.getResolvedMcpServers(selected.target),
|
||||
this.options.capabilities.getEnabledBuiltinMcpServerIds(selected.target),
|
||||
this.options.applicationSettingsStore.get(),
|
||||
this.options.capabilities.getObsidianSettings()
|
||||
])
|
||||
const key = createHash('sha256').update(JSON.stringify({ projectId: project.id, space: space.cacheIdentity,
|
||||
target: selected.target, profile: selected.target === 'deepseek-harness' ? selected.settings.deepseekHarnessModelProfile
|
||||
: selected.target === 'continue' ? selected.settings.continueModelProfile : selected.settings.opencodeModelProfile,
|
||||
workMode, skills, mcpServers, builtin,
|
||||
libraries: conversation.knowledgeLibraryIds, magicNotes: application.magicNotesEnabled, obsidian,
|
||||
node: application.localToolEnvironment.node,
|
||||
binary: selected.target === 'continue' ? selected.settings.continueBinaryPath
|
||||
: selected.target === 'opencode' ? selected.settings.opencodeBinaryPath : undefined,
|
||||
config: selected.target === 'continue' ? selected.settings.continueConfigPath : undefined })).digest('hex')
|
||||
return { conversation, project, selected, space, workMode, skills, mcpServers, builtin, application, obsidian, key }
|
||||
}
|
||||
|
||||
async get(ownerId: number, conversationId: string): Promise<{ serviceId: string } | null> {
|
||||
const context = await this.resolve(conversationId)
|
||||
const entry = this.services.get(`${ownerId}:${context.key}`)
|
||||
if (!entry) return null
|
||||
if (await this.browser.get(entry.id)) return { serviceId: entry.id }
|
||||
await entry.dispose()
|
||||
this.services.delete(`${ownerId}:${context.key}`)
|
||||
return null
|
||||
}
|
||||
|
||||
async open(ownerId: number, conversationId: string): Promise<RuntimeNativeClientResult> {
|
||||
this.assertOwner(ownerId)
|
||||
const context = await this.resolve(conversationId)
|
||||
this.assertOwner(ownerId)
|
||||
const key = `${ownerId}:${context.key}`
|
||||
const pending = this.pending.get(key)
|
||||
if (pending) return pending
|
||||
const operation = this.launch(ownerId, key, context).finally(() => this.pending.delete(key))
|
||||
this.pending.set(key, operation)
|
||||
return operation
|
||||
}
|
||||
|
||||
private assertOwner(ownerId: number): void {
|
||||
this.controller.signal.throwIfAborted()
|
||||
if (this.closedOwners.has(ownerId)) throw new Error('Native client window is closed')
|
||||
}
|
||||
|
||||
private async launch(ownerId: number, key: string, context: Awaited<ReturnType<NativeClientCoordinator['resolve']>>): Promise<RuntimeNativeClientResult> {
|
||||
const { project, selected, space, workMode, skills, mcpServers, builtin, application, conversation, obsidian } = context
|
||||
if (selected.target === 'model') throw new Error('This runtime has no native client')
|
||||
if (space.kind !== 'local') throw new Error('Native client requires a local execution space')
|
||||
const existing = this.services.get(key)
|
||||
if (existing) {
|
||||
const handle = await this.browser.get(existing.id)
|
||||
if (handle) {
|
||||
this.assertOwner(ownerId)
|
||||
await this.options.openExternal(handle.url)
|
||||
return { kind: 'browser', serviceId: handle.id }
|
||||
}
|
||||
await existing.dispose()
|
||||
this.services.delete(key)
|
||||
}
|
||||
const gateway = this.options.createGateway()
|
||||
const controller = new AbortController()
|
||||
const signal = AbortSignal.any([controller.signal, this.controller.signal])
|
||||
const dispose = async (): Promise<void> => { controller.abort(); await gateway.dispose() }
|
||||
try {
|
||||
await gateway.start()
|
||||
const requestId = randomUUID()
|
||||
const access = workMode === 'execute' ? 'write' : 'read'
|
||||
const token = gateway.grant(requestId, builtin.includes('knowledge-base') ? conversation.knowledgeLibraryIds ?? [] : [], signal,
|
||||
builtin.includes('magic-notes') && application.magicNotesEnabled ? access : 'none',
|
||||
builtin.includes('goodbuddy-config') ? { access, workspacePath: space.rootPath } : undefined,
|
||||
undefined, undefined, undefined, builtin.includes('obsidian') ? { settings: obsidian, access } : undefined)
|
||||
const customToken = workMode === 'execute' ? gateway.grantCustomMcp(requestId, mcpServers, signal) : undefined
|
||||
const endpoints = [token, customToken].flatMap((value, index) => value ? [{ name: `goodbuddy-${index}`, url: gateway.getEndpoint()!, headers: { Authorization: `Bearer ${value}` } }] : [])
|
||||
if (selected.target === 'deepseek-harness') {
|
||||
const profile = selected.settings.deepseekHarnessModelProfile
|
||||
if (!profile) throw new Error('DS Web requires a text model connection')
|
||||
const readOnlyTools = token && workMode === 'ask' ? gateway.getAvailableToolNames(token) : []
|
||||
const handle = await this.browser.start({ projectId: project.id, ownerId, configurationKey: context.key, workspace: space.rootPath, profile, workMode,
|
||||
skillDirectories: skills.packages.map(skill => skill.directory),
|
||||
mcpServers: endpoints.map(endpoint => ({ serverName: endpoint.name, transport: 'streamable-http', url: endpoint.url, headers: endpoint.headers,
|
||||
readOnlyTools: endpoint.name === 'goodbuddy-0' ? readOnlyTools : [] })) })
|
||||
this.services.set(key, { ownerId, id: handle.id, dispose })
|
||||
this.assertOwner(ownerId)
|
||||
await this.options.openExternal(handle.url)
|
||||
return { kind: 'browser', serviceId: handle.id }
|
||||
}
|
||||
const nodeExecutable = selected.target === 'continue' ? await this.prepareNode() : undefined
|
||||
this.assertOwner(ownerId)
|
||||
const terminal = await new NativeTerminalClient({
|
||||
rootDirectory: join(this.options.rootDirectory, 'terminal'), bundledRuntimePaths: this.options.bundledRuntimePaths,
|
||||
nodeExecutable, launchEnvironmentProvider: this.options.localEnvironment.launchEnvironmentProvider,
|
||||
terminalManager: { create: async (owner, input, launch) => {
|
||||
if (!launch) throw new Error('Native terminal launch is missing')
|
||||
return this.options.terminalManager.create(owner, input, { ...launch, dispose: async () => {
|
||||
try { await launch.dispose() } finally { await dispose() }
|
||||
} })
|
||||
} }
|
||||
}).open(ownerId, { projectId: project.id, projectName: project.name, directory: space.rootPath,
|
||||
runtime: selected.target, settings: selected.settings, workMode, skillPackages: skills.packages, mcpServers: endpoints })
|
||||
if (this.closedOwners.has(ownerId) || this.controller.signal.aborted) {
|
||||
await this.options.terminalManager.closeOwner(ownerId)
|
||||
throw new Error('Native client window is closed')
|
||||
}
|
||||
return { kind: 'terminal', terminal }
|
||||
} catch (error) {
|
||||
// Once ready, the service owns its gateway even if the OS browser cannot open.
|
||||
if (!this.services.has(key)) await dispose()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async stop(ownerId: number, serviceId: string): Promise<void> {
|
||||
for (const [key, entry] of this.services) {
|
||||
if (entry.id !== serviceId || entry.ownerId !== ownerId) continue
|
||||
await this.browser.stop(entry.id)
|
||||
await entry.dispose()
|
||||
this.services.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
async closeOwner(ownerId: number): Promise<void> {
|
||||
this.closedOwners.add(ownerId)
|
||||
await Promise.allSettled([...this.pending.entries()].filter(([key]) => key.startsWith(`${ownerId}:`)).map(([, operation]) => operation))
|
||||
await Promise.all([...this.services.values()].filter(entry => entry.ownerId === ownerId).map(entry => this.stop(ownerId, entry.id)))
|
||||
await this.options.terminalManager.closeOwner(ownerId)
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.controller.abort()
|
||||
await this.browser.dispose()
|
||||
await Promise.allSettled(this.pending.values())
|
||||
await Promise.all([...this.services.values()].map(entry => entry.dispose()))
|
||||
this.services.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
// @vitest-environment node
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { createServer } from 'node:http'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NativeDshWebClientService, resolveNativeDshCli, type NativeDshWebRequest } from './native-dsh-web-client'
|
||||
import { nativeDshMcpToolName, nativeDshWebPolicySource } from './native-dsh-web-policy'
|
||||
|
||||
const roots: string[] = []
|
||||
const services: NativeDshWebClientService[] = []
|
||||
afterEach(async () => {
|
||||
await Promise.all(services.splice(0).map(service => service.dispose()))
|
||||
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
async function temporary(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-native-dsh-'))
|
||||
roots.push(root)
|
||||
return root
|
||||
}
|
||||
const profile: NativeDshWebRequest['profile'] = {
|
||||
id: 'test', name: 'Test', protocol: 'openai-chat-completions',
|
||||
authentication: 'api-key', apiKey: 'provider-secret-must-stay-in-main',
|
||||
baseUrl: 'http://127.0.0.1:1', modelName: 'test-model'
|
||||
}
|
||||
|
||||
describe('native DS Web mode', () => {
|
||||
it('enforces Ask tool names independently of agent mode and permits Execute', async () => {
|
||||
const root = await temporary()
|
||||
const file = join(root, 'policy.mjs')
|
||||
await writeFile(file, nativeDshWebPolicySource)
|
||||
const plugin = await import(/* @vite-ignore */ pathToFileURL(file).href)
|
||||
for (const mode of ['ask', 'execute']) {
|
||||
let execute!: (execution: unknown, next: () => string) => string
|
||||
plugin.apply({ on: (_name: string, callback: typeof execute) => { execute = callback } }, {
|
||||
workMode: mode, readOnlyTools: ['mcp__docs__lookup']
|
||||
})
|
||||
for (const name of ['read', 'skill', 'mcp__docs__lookup']) {
|
||||
expect(execute({ name, agent: { mode: 'execute' } }, () => 'ran')).toBe('ran')
|
||||
}
|
||||
for (const name of ['write', 'edit', 'pwsh', 'bash', 'subagent', 'mcp__docs__delete']) {
|
||||
const run = () => execute({ name, agent: { mode: 'execute' } }, () => 'ran')
|
||||
if (mode === 'ask') expect(run).toThrow('GoodBuddy Ask mode')
|
||||
else expect(run()).toBe('ran')
|
||||
}
|
||||
}
|
||||
})
|
||||
it('matches official MCP names without collisions after normalization', () => {
|
||||
expect(nativeDshMcpToolName('docs', 'lookup')).toBe('mcp__docs__lookup')
|
||||
expect(nativeDshMcpToolName('docs', 'a.b')).not.toBe(nativeDshMcpToolName('docs', 'a_b'))
|
||||
expect(nativeDshMcpToolName('docs', 'a'.repeat(100))).toHaveLength(64)
|
||||
})
|
||||
it('resolves the packaged CLI outside ASAR', () => {
|
||||
expect(resolveNativeDshCli('/resources')).toBe(join('/resources', 'runtimes/dsh/node_modules/@deepseek-ai/dsh/lib/bin.js'))
|
||||
})
|
||||
})
|
||||
|
||||
const standardNode = process.env.NATIVE_DSH_TEST_NODE ?? (!process.versions.electron ? process.execPath : undefined)
|
||||
describe.skipIf(!standardNode)('official DS Web process', () => {
|
||||
it('initializes the workspace, coalesces starts, isolates modes and preserves history on stop', async () => {
|
||||
const root = await temporary()
|
||||
const workspace = join(root, 'workspace')
|
||||
await mkdir(workspace)
|
||||
const service = new NativeDshWebClientService({
|
||||
rootDirectory: join(root, 'homes'),
|
||||
cliPath: process.env.NATIVE_DSH_TEST_CLI,
|
||||
resolveLaunchEnvironment: async () => ({ nodeExecutablePath: standardNode!, environment: process.env })
|
||||
})
|
||||
services.push(service)
|
||||
const request: NativeDshWebRequest = { projectId: 'one', workspace, profile, workMode: 'ask' }
|
||||
const [first, repeated] = await Promise.all([service.start(request), service.start(request)])
|
||||
expect(first).toEqual(repeated)
|
||||
const config = await readFile(join(root, 'homes', first.id, 'cordis.patch.yml'), 'utf8')
|
||||
expect(config).toContain('test-model')
|
||||
expect(config).not.toContain(profile.apiKey)
|
||||
const auth = await fetch(first.url, { redirect: 'manual' })
|
||||
const cookie = auth.headers.getSetCookie().map(value => value.split(';')[0]).join('; ')
|
||||
const page = await fetch(new URL('/', first.url), { headers: { cookie } })
|
||||
expect(page.status).toBe(200)
|
||||
expect(await page.text()).toContain('<html')
|
||||
expect(await service.start(request)).toEqual(first)
|
||||
const history = join(root, 'homes', first.id, 'history-marker')
|
||||
await writeFile(history, 'retained')
|
||||
const execute = await service.start({ ...request, workMode: 'execute' })
|
||||
expect(execute.id).not.toBe(first.id)
|
||||
const changedConfiguration = await service.start({ ...request, configurationKey: 'updated-skill-digest' })
|
||||
expect(changedConfiguration.id).not.toBe(first.id)
|
||||
expect(await service.start({ ...request, configurationKey: 'updated-skill-digest' })).toEqual(changedConfiguration)
|
||||
await service.stop(changedConfiguration.id)
|
||||
await service.stop(first.id)
|
||||
await expect(fetch(first.url)).rejects.toThrow()
|
||||
expect(await readFile(history, 'utf8')).toBe('retained')
|
||||
await expect(readFile(join(root, 'homes', first.id, 'cordis.patch.yml'))).rejects.toThrow()
|
||||
expect((await fetch(execute.url, { redirect: 'manual' })).status).toBe(303)
|
||||
const restarted = await service.start(request)
|
||||
expect(restarted.id).toBe(first.id)
|
||||
expect(restarted.url).not.toBe(first.url)
|
||||
await service.dispose()
|
||||
await expect(service.start(request)).rejects.toThrow('disposed')
|
||||
}, 120_000)
|
||||
|
||||
it('routes official Web tool calls through the model bridge and enforces Ask at execution', async () => {
|
||||
const root = await temporary()
|
||||
const skills = join(root, 'skills')
|
||||
await mkdir(skills)
|
||||
await writeFile(join(skills, 'SKILL.md'), '---\nname: native-test\ndescription: Native test skill\n---\nSKILL_LOADED_MARKER')
|
||||
const toolResults: string[] = []
|
||||
let mcpCalls = 0
|
||||
const mcp = createServer(async (request, response) => {
|
||||
if (request.method !== 'POST') { response.writeHead(405).end(); return }
|
||||
let body = ''
|
||||
for await (const chunk of request) body += chunk
|
||||
const message = JSON.parse(body)
|
||||
if (message.id === undefined) { response.writeHead(202).end(); return }
|
||||
let result: unknown = {}
|
||||
if (message.method === 'initialize') result = { protocolVersion: message.params.protocolVersion,
|
||||
capabilities: { tools: {} }, serverInfo: { name: 'test', version: '1' } }
|
||||
if (message.method === 'tools/list') result = { tools: [
|
||||
{ name: 'lookup', description: 'Read test data', inputSchema: { type: 'object', properties: {} } },
|
||||
{ name: 'mutate', description: 'Write test data', inputSchema: { type: 'object', properties: {} } }
|
||||
] }
|
||||
if (message.method === 'tools/call') { mcpCalls++; result = { content: [{ type: 'text', text: 'MCP_CALLED_MARKER' }] } }
|
||||
response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ jsonrpc: '2.0', id: message.id, result }))
|
||||
})
|
||||
await new Promise<void>(resolve => mcp.listen(0, '127.0.0.1', resolve))
|
||||
const address = mcp.address() as { port: number }
|
||||
const fetcher: typeof fetch = async (_url, init) => {
|
||||
expect(new Headers(init?.headers).get('authorization')).toBe(`Bearer ${profile.apiKey}`)
|
||||
const body = JSON.parse(String(init?.body))
|
||||
expect(body.model).toBe(profile.modelName)
|
||||
const toolResult = body.messages.findLast((message: { role: string }) => message.role === 'tool')
|
||||
const user = JSON.stringify(body.messages)
|
||||
const toolName = user.includes('Load skill') ? 'skill' : user.includes('MCP read') ? 'mcp__test__lookup'
|
||||
: user.includes('MCP write') ? 'mcp__test__mutate' : 'write'
|
||||
const write = !toolResult && body.tools?.some((tool: { function: { name: string } }) => tool.function.name === toolName)
|
||||
if (toolResult) toolResults.push(JSON.stringify(toolResult.content))
|
||||
const delta = write ? { role: 'assistant', tool_calls: [{ index: 0, id: 'call_write', type: 'function',
|
||||
function: { name: toolName, arguments: JSON.stringify(toolName === 'write'
|
||||
? { file_path: join(root, 'written.txt'), content: 'EXECUTED' } : toolName === 'skill' ? { name: 'native-test' } : {}) } }] }
|
||||
: { role: 'assistant', content: 'Finished' }
|
||||
const event = (value: unknown) => `data: ${JSON.stringify(value)}\n\n`
|
||||
return new Response(event({ id: 'test', object: 'chat.completion.chunk', model: profile.modelName,
|
||||
choices: [{ index: 0, delta, finish_reason: null }] }) + event({ id: 'test', object: 'chat.completion.chunk',
|
||||
choices: [{ index: 0, delta: {}, finish_reason: write ? 'tool_calls' : 'stop' }],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 10, total_tokens: 20 } }) + 'data: [DONE]\n\n',
|
||||
{ headers: { 'content-type': 'text/event-stream' } })
|
||||
}
|
||||
const service = new NativeDshWebClientService({ rootDirectory: join(root, 'homes'), fetcher,
|
||||
cliPath: process.env.NATIVE_DSH_TEST_CLI,
|
||||
resolveLaunchEnvironment: async () => ({ nodeExecutablePath: standardNode!, environment: process.env }) })
|
||||
services.push(service)
|
||||
try {
|
||||
for (const workMode of ['ask', 'execute'] as const) {
|
||||
const handle = await service.start({ projectId: 'tools', workspace: root, profile, workMode,
|
||||
skillDirectories: [skills], mcpServers: [{ serverName: 'test', transport: 'streamable-http',
|
||||
url: `http://127.0.0.1:${address.port}/mcp`, readOnlyTools: ['lookup'] }] })
|
||||
const auth = await fetch(handle.url, { redirect: 'manual' })
|
||||
const cookie = auth.headers.getSetCookie().map(value => value.split(';')[0]).join('; ')
|
||||
async function rpc(method: string, request: unknown): Promise<Record<string, unknown>> {
|
||||
const response = await fetch(new URL(`/api/${method}`, handle.url), { method: 'POST',
|
||||
headers: { 'content-type': 'application/json', cookie, origin: new URL(handle.url).origin },
|
||||
body: JSON.stringify({ type: 'client-request', rpcId: crypto.randomUUID(), method, payload: { args: { request } } }) })
|
||||
const payload = await response.json() as { result?: { ok?: boolean; value: Record<string, unknown> } }
|
||||
expect(payload.result?.ok, JSON.stringify(payload)).toBe(true)
|
||||
return payload.result!.value
|
||||
}
|
||||
const session = await rpc('session/create', { cwd: root })
|
||||
const previous = toolResults.length
|
||||
await rpc('session/prompt', { sessionId: session.sessionId, requestId: crypto.randomUUID(), mode: 'queue',
|
||||
content: [{ type: 'text', text: 'Write the requested marker file.' }] })
|
||||
await vi.waitFor(() => expect(toolResults.length).toBeGreaterThan(previous), { timeout: 15_000 })
|
||||
if (workMode === 'ask') {
|
||||
expect(toolResults.at(-1)).toContain('GoodBuddy Ask mode')
|
||||
await expect(readFile(join(root, 'written.txt'))).rejects.toThrow()
|
||||
} else {
|
||||
expect(await readFile(join(root, 'written.txt'), 'utf8')).toBe('EXECUTED')
|
||||
}
|
||||
for (const prompt of ['Load skill', 'MCP read', 'MCP write']) {
|
||||
const next = await rpc('session/create', { cwd: root })
|
||||
const before = toolResults.length
|
||||
const callsBefore = mcpCalls
|
||||
await rpc('session/prompt', { sessionId: next.sessionId, requestId: crypto.randomUUID(), mode: 'queue',
|
||||
content: [{ type: 'text', text: prompt }] })
|
||||
await vi.waitFor(() => expect(toolResults.length).toBeGreaterThan(before), { timeout: 15_000 })
|
||||
if (prompt === 'Load skill') await vi.waitFor(() => expect(toolResults.slice(before).join()).toContain('SKILL_LOADED_MARKER'), { timeout: 15_000 })
|
||||
else if (prompt === 'MCP write' && workMode === 'ask') {
|
||||
await vi.waitFor(() => expect(toolResults.slice(before).join()).toContain('does not allow tool: mcp__test__mutate'), { timeout: 15_000 })
|
||||
expect(mcpCalls).toBe(callsBefore)
|
||||
} else {
|
||||
await vi.waitFor(() => expect(toolResults.slice(before).join()).toContain('MCP_CALLED_MARKER'), { timeout: 15_000 })
|
||||
expect(mcpCalls).toBe(callsBefore + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await service.dispose()
|
||||
mcp.closeAllConnections()
|
||||
await new Promise<void>(resolve => mcp.close(() => resolve()))
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it('cleans failed launches and permits retry', async () => {
|
||||
const root = await temporary()
|
||||
const service = new NativeDshWebClientService({ rootDirectory: join(root, 'homes'), cliPath: join(root, 'missing.js'),
|
||||
resolveLaunchEnvironment: async () => ({ nodeExecutablePath: standardNode!, environment: process.env }) })
|
||||
services.push(service)
|
||||
const request: NativeDshWebRequest = { projectId: 'one', workspace: root, profile, workMode: 'ask' }
|
||||
await expect(service.start(request)).rejects.toThrow()
|
||||
await expect(service.start(request)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('cancels an in-progress launch when disposed', async () => {
|
||||
const root = await temporary()
|
||||
const cliPath = join(root, 'waiting.mjs')
|
||||
await writeFile(cliPath, 'setInterval(() => {}, 1000)')
|
||||
const service = new NativeDshWebClientService({ rootDirectory: join(root, 'homes'), cliPath,
|
||||
resolveLaunchEnvironment: async () => ({ nodeExecutablePath: standardNode!, environment: process.env }) })
|
||||
services.push(service)
|
||||
const starting = service.start({ projectId: 'pending', workspace: root, profile, workMode: 'ask' })
|
||||
const rejected = expect(starting).rejects.toThrow()
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
await service.dispose()
|
||||
await rejected
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,232 @@
|
||||
import { execFile, spawn, type ChildProcess } from 'node:child_process'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { mkdir, realpath, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { AgentModelCallLedger, AgentModelGateway } from '../../agent-daemon/agent-model-gateway'
|
||||
import { MODEL_BRIDGE_SDK_AUTH_SENTINEL, ModelBridgeLoopbackProxy } from '../../agent-daemon/model-bridge-helper'
|
||||
import { canonicalJson } from '../../shared/agent-protocol/canonical'
|
||||
import type { ResolvedModelProfile } from '../runtime-settings-store'
|
||||
import { createManagedModelBridge } from '../remote-agent/managed-model-bridge'
|
||||
import { terminateProcessTreeAndWait } from './child-process-termination'
|
||||
import { buildCredentialFilteredUserEnvironment } from './process-environment'
|
||||
import { nativeDshMcpToolName, nativeDshWebPatch, nativeDshWebPolicySource, type NativeDshMcpServer } from './native-dsh-web-policy'
|
||||
|
||||
export type { NativeDshMcpServer } from './native-dsh-web-policy'
|
||||
|
||||
export type NativeDshWebRequest = {
|
||||
projectId: string
|
||||
ownerId?: number
|
||||
/** Includes the coordinator's current capability digests and launch settings. */
|
||||
configurationKey?: string
|
||||
workspace: string
|
||||
profile: ResolvedModelProfile
|
||||
workMode: 'ask' | 'execute'
|
||||
/** Directories containing SKILL.md, or roots containing skill bundles. */
|
||||
skillDirectories?: string[]
|
||||
/** Already resolved by Main; GoodBuddy-only tools need a caller-owned MCP adapter. */
|
||||
mcpServers?: NativeDshMcpServer[]
|
||||
}
|
||||
|
||||
export type NativeDshWebHandle = { id: string; url: string }
|
||||
export type NativeDshWebClientOptions = {
|
||||
rootDirectory: string
|
||||
resolveLaunchEnvironment: () => Promise<{
|
||||
nodeExecutablePath: string
|
||||
environment: Readonly<NodeJS.ProcessEnv>
|
||||
}>
|
||||
/** Packaged: process.resourcesPath. Omit in development. */
|
||||
resourcesPath?: string
|
||||
cliPath?: string
|
||||
startupTimeoutMs?: number
|
||||
/** Existing gateway supports injecting fetch for focused protocol tests. */
|
||||
fetcher?: typeof fetch
|
||||
}
|
||||
|
||||
type Instance = {
|
||||
id: string
|
||||
controller: AbortController
|
||||
ready: Promise<NativeDshWebHandle>
|
||||
child?: ChildProcess
|
||||
proxy?: ModelBridgeLoopbackProxy
|
||||
ledger?: AgentModelCallLedger
|
||||
cleanup?: Promise<void>
|
||||
home: string
|
||||
}
|
||||
|
||||
export function resolveNativeDshCli(resourcesPath?: string): string {
|
||||
return resourcesPath
|
||||
? join(resourcesPath, 'runtimes', 'dsh', 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js')
|
||||
: join(dirname(createRequire(import.meta.url).resolve('@deepseek-ai/dsh/package.json')), 'lib', 'bin.js')
|
||||
}
|
||||
|
||||
export class NativeDshWebClientService {
|
||||
private readonly instances = new Map<string, Instance>()
|
||||
private disposed = false
|
||||
|
||||
constructor(private readonly options: NativeDshWebClientOptions) {}
|
||||
|
||||
async start(input: NativeDshWebRequest): Promise<NativeDshWebHandle> {
|
||||
if (this.disposed) throw new Error('DS Web service is disposed')
|
||||
const request = structuredClone(input)
|
||||
request.workspace = await realpath(request.workspace)
|
||||
if (!(await stat(request.workspace)).isDirectory()) throw new Error('DS Web workspace is not a directory')
|
||||
if (this.disposed) throw new Error('DS Web service is disposed')
|
||||
// Include credentials in the in-memory match hash so credential rotation starts
|
||||
// a fresh bridge. Only the digest is used as a directory name.
|
||||
const key = createHash('sha256').update(canonicalJson(JSON.parse(JSON.stringify(request)))).digest('hex')
|
||||
const existing = this.instances.get(key)
|
||||
if (existing) {
|
||||
if (existing.controller.signal.aborted || (existing.child &&
|
||||
(existing.child.exitCode !== null || existing.child.signalCode !== null))) {
|
||||
await existing.ready.catch(() => undefined)
|
||||
await this.cleanup(existing)
|
||||
return this.start(request)
|
||||
}
|
||||
return existing.ready
|
||||
}
|
||||
const instance: Instance = {
|
||||
id: key, controller: new AbortController(),
|
||||
home: join(this.options.rootDirectory, key),
|
||||
ready: Promise.resolve({ id: key, url: '' })
|
||||
}
|
||||
this.instances.set(key, instance)
|
||||
instance.ready = this.launch(instance, request).catch(async error => {
|
||||
await this.cleanup(instance)
|
||||
throw error
|
||||
})
|
||||
return instance.ready
|
||||
}
|
||||
|
||||
async stop(id: string): Promise<void> {
|
||||
const instance = this.instances.get(id)
|
||||
if (!instance) return
|
||||
instance.controller.abort(new Error('DS Web service stopped'))
|
||||
await instance.ready.catch(() => undefined)
|
||||
await this.cleanup(instance)
|
||||
}
|
||||
|
||||
async get(id: string): Promise<NativeDshWebHandle | null> {
|
||||
const instance = this.instances.get(id)
|
||||
if (!instance || instance.controller.signal.aborted) return null
|
||||
const handle = await instance.ready.catch(() => null)
|
||||
return instance.controller.signal.aborted || (instance.child &&
|
||||
(instance.child.exitCode !== null || instance.child.signalCode !== null)) ? null : handle
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.disposed = true
|
||||
await Promise.all([...this.instances.keys()].map(id => this.stop(id)))
|
||||
}
|
||||
|
||||
private async cleanup(instance: Instance): Promise<void> {
|
||||
instance.cleanup ??= (async () => {
|
||||
instance.controller.abort(new Error('DS Web service stopped'))
|
||||
if (instance.child && instance.child.signalCode === null) {
|
||||
await terminateProcessTreeAndWait(instance.child, { processGroup: process.platform !== 'win32' })
|
||||
}
|
||||
await instance.proxy?.close()
|
||||
instance.ledger?.close()
|
||||
// Native history and profile settings are persistent; only launch material is disposable.
|
||||
await Promise.all(['cordis.patch.yml', 'goodbuddy-mode.mjs', 'bridge.sqlite', 'bridge.sqlite-wal', 'bridge.sqlite-shm']
|
||||
.map(name => rm(join(instance.home, name), { force: true })))
|
||||
if (this.instances.get(instance.id) === instance) this.instances.delete(instance.id)
|
||||
})()
|
||||
return instance.cleanup
|
||||
}
|
||||
|
||||
private async launch(instance: Instance, request: NativeDshWebRequest): Promise<NativeDshWebHandle> {
|
||||
const signal = AbortSignal.any([instance.controller.signal, AbortSignal.timeout(this.options.startupTimeoutMs ?? 60_000)])
|
||||
const launch = await this.options.resolveLaunchEnvironment()
|
||||
signal.throwIfAborted()
|
||||
if (!launch.nodeExecutablePath) throw new Error('DS Web requires a standard Node executable from the local tool environment')
|
||||
const environment = buildCredentialFilteredUserEnvironment({ ...launch.environment })
|
||||
const { stdout } = await promisify(execFile)(launch.nodeExecutablePath,
|
||||
['-p', 'JSON.stringify({node:process.versions.node,electron:process.versions.electron})'],
|
||||
{ env: { ...environment, ELECTRON_RUN_AS_NODE: '1' }, signal, timeout: 5_000, windowsHide: true })
|
||||
const runtime = JSON.parse(stdout.trim()) as { node?: string; electron?: string }
|
||||
if (!runtime.node || runtime.electron) throw new Error('DS Web requires standard Node, not Electron')
|
||||
const cli = this.options.cliPath ?? resolveNativeDshCli(this.options.resourcesPath)
|
||||
await stat(cli)
|
||||
await mkdir(instance.home, { recursive: true, mode: 0o700 })
|
||||
const managed = createManagedModelBridge({ profile: request.profile })
|
||||
instance.ledger = new AgentModelCallLedger(join(instance.home, 'bridge.sqlite'))
|
||||
const gateway = new AgentModelGateway({ ledger: instance.ledger, fetcher: this.options.fetcher })
|
||||
const operationId = randomUUID()
|
||||
let roundIndex = 0
|
||||
instance.proxy = new ModelBridgeLoopbackProxy({ exchange: (message, context) => gateway.dispatch({
|
||||
bindingId: instance.id, operationId, promptSequence: 0, roundIndex: roundIndex++,
|
||||
profileDigest: managed.profile.modelProfileDigest, profile: managed.profile
|
||||
}, message, context.signal) })
|
||||
const origin = await instance.proxy.listen()
|
||||
const policyPath = join(instance.home, 'goodbuddy-mode.mjs')
|
||||
const mcpServers = request.mcpServers ?? []
|
||||
const patch = nativeDshWebPatch({
|
||||
model: request.profile.modelName,
|
||||
api: request.profile.protocol === 'anthropic-messages' ? 'anthropic-messages'
|
||||
: request.profile.protocol === 'openai-responses' ? 'openai-responses' : 'openai-completions',
|
||||
baseURL: `${origin}/v1`, workMode: request.workMode, policyPath,
|
||||
skillDirectories: request.skillDirectories ?? [], mcpServers,
|
||||
readOnlyTools: mcpServers.flatMap(server => (server.readOnlyTools ?? []).map(name => nativeDshMcpToolName(server.serverName, name)))
|
||||
})
|
||||
await writeFile(policyPath, nativeDshWebPolicySource, 'utf8')
|
||||
await writeFile(join(instance.home, 'cordis.patch.yml'), JSON.stringify(patch), 'utf8')
|
||||
signal.throwIfAborted()
|
||||
for (const name of Object.keys(environment)) if (name.startsWith('DSH_')) delete environment[name]
|
||||
instance.child = spawn(launch.nodeExecutablePath, [cli, 'web', '--no-open', '--host', '127.0.0.1', '--port', '0'], {
|
||||
cwd: request.workspace, shell: false, windowsHide: true,
|
||||
detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...environment, DSH_HOME: instance.home, DSH_TELEMETRY_DISABLED: '1',
|
||||
DSH_PERMISSION_MODE: 'danger-full-access', GOODBUDDY_DSH_MODEL_KEY: MODEL_BRIDGE_SDK_AUTH_SENTINEL }
|
||||
})
|
||||
const child = instance.child
|
||||
const url = await new Promise<string>((resolve, reject) => {
|
||||
let output = ''
|
||||
const finish = (error?: Error, address?: string): void => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
child.removeListener('error', onError)
|
||||
child.removeListener('exit', onExit)
|
||||
child.stdout?.removeListener('data', onData)
|
||||
child.stderr?.removeListener('data', onData)
|
||||
if (error) reject(error)
|
||||
else resolve(address!)
|
||||
}
|
||||
const onAbort = (): void => finish(new Error('DS Web startup cancelled or timed out', { cause: signal.reason }))
|
||||
const onError = (error: Error): void => finish(new Error(`DS Web process could not start: ${error.message}`))
|
||||
const onExit = (code: number | null): void => finish(new Error(`DS Web exited during startup (${code})`))
|
||||
const onData = (chunk: Buffer): void => {
|
||||
output = (output + chunk.toString()).slice(-32_768)
|
||||
// Wait for the full line; stdout can split in the middle of the token.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const match = output.match(/http:\/\/127\.0\.0\.1:\d+[^\s\x1b]*(?=[\s\x1b])/u)
|
||||
if (match) finish(undefined, match[0])
|
||||
}
|
||||
child.once('error', onError)
|
||||
child.once('exit', onExit)
|
||||
child.stdout?.on('data', onData)
|
||||
child.stderr?.on('data', onData)
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
if (signal.aborted) onAbort()
|
||||
})
|
||||
// Drain subsequent logs without retaining token-bearing output.
|
||||
child.stdout?.resume()
|
||||
child.stderr?.resume()
|
||||
child.once('exit', () => { void this.cleanup(instance) })
|
||||
const auth = await fetch(url, { redirect: 'manual', signal })
|
||||
if (auth.status !== 303) throw new Error(`DS Web authentication failed (HTTP ${auth.status})`)
|
||||
const cookie = auth.headers.getSetCookie().map(value => value.split(';')[0]).join('; ')
|
||||
const response = await fetch(new URL('/api/workspace/create', url), {
|
||||
method: 'POST', signal,
|
||||
headers: { 'content-type': 'application/json', cookie, origin: new URL(url).origin },
|
||||
body: JSON.stringify({ type: 'client-request', rpcId: randomUUID(), method: 'workspace/create',
|
||||
payload: { args: { request: { path: request.workspace } } } })
|
||||
})
|
||||
if (!response.ok) throw new Error(`DS Web workspace creation failed (HTTP ${response.status})`)
|
||||
const result = await response.json() as { result?: { ok?: boolean } }
|
||||
if (result.result?.ok !== true) throw new Error('DS Web workspace creation was rejected')
|
||||
signal.throwIfAborted()
|
||||
if (child.exitCode !== null || child.signalCode !== null) throw new Error('DS Web exited during workspace initialization')
|
||||
return { id: instance.id, url }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
// Official dsh-mcp-client 0.1.7-rc.2 naming contract; its helper is private.
|
||||
export function nativeDshMcpToolName(serverName: string, rawName: string): string {
|
||||
const joined = `mcp__${serverName}__${rawName}`
|
||||
const normalized = joined.replace(/[^A-Za-z0-9_-]/gu, '_')
|
||||
if (joined === normalized && normalized.length <= 64) return joined
|
||||
const hash = createHash('sha256').update(`${serverName}\0${rawName}`).digest('hex').slice(0, 12)
|
||||
return `${normalized.slice(0, 51)}_${hash}`
|
||||
}
|
||||
|
||||
export type NativeDshMcpServer = {
|
||||
serverName: string
|
||||
readOnlyTools?: string[]
|
||||
} & (
|
||||
| { transport: 'stdio'; command: string; args?: string[]; env?: Record<string, string>; cwd?: string }
|
||||
| { transport: 'streamable-http'; url: string; headers?: Record<string, string> }
|
||||
)
|
||||
|
||||
// Loaded by the official Cordis loader, outside Electron and its ASAR bundle.
|
||||
// The launch mode applies to every session, including delegated agents.
|
||||
export const nativeDshWebPolicySource = `
|
||||
export const name = 'goodbuddy-native-mode';
|
||||
export function apply(ctx, config) {
|
||||
const allowed = new Set(['read', 'skill', ...config.readOnlyTools]);
|
||||
ctx.on('tools/execute', (exec, next) => {
|
||||
if (config.workMode === 'ask' && !allowed.has(exec.name)) {
|
||||
throw new Error('GoodBuddy Ask mode does not allow tool: ' + exec.name);
|
||||
}
|
||||
return next();
|
||||
});
|
||||
}
|
||||
`
|
||||
|
||||
export function nativeDshWebPatch(options: {
|
||||
model: string
|
||||
api: string
|
||||
baseURL: string
|
||||
workMode: 'ask' | 'execute'
|
||||
policyPath: string
|
||||
skillDirectories: string[]
|
||||
mcpServers: NativeDshMcpServer[]
|
||||
readOnlyTools: string[]
|
||||
}): unknown[] {
|
||||
return [
|
||||
{
|
||||
id: 'llm-pi-ai',
|
||||
config: { providers: { goodbuddy: {
|
||||
displayName: 'GoodBuddy', api: options.api,
|
||||
apiKeyEnv: 'GOODBUDDY_DSH_MODEL_KEY', baseURL: options.baseURL,
|
||||
models: [{ id: options.model, name: options.model }]
|
||||
} } }
|
||||
},
|
||||
{ id: 'agent-default-model', config: { provider: 'goodbuddy', model: options.model } },
|
||||
{ id: 'sandbox-policy', config: { mode: 'danger-full-access' } },
|
||||
{ id: 'approval', config: { policy: 'never' } },
|
||||
// The launch mode owns permissions. Native Plan remains a planning aid.
|
||||
{ id: 'ui-permission', disabled: true },
|
||||
{ insert: [
|
||||
{ id: 'goodbuddy-native-mode', name: options.policyPath,
|
||||
config: { workMode: options.workMode, readOnlyTools: options.readOnlyTools } },
|
||||
...(options.skillDirectories.length ? [{
|
||||
id: 'goodbuddy-skills', name: '@deepseek-ai/dsh-skill-filesystem',
|
||||
config: { providerName: 'goodbuddy', includeDefaultRoots: false,
|
||||
customSkillDirs: options.skillDirectories }
|
||||
}] : []),
|
||||
...options.mcpServers.map((server, index) => {
|
||||
const config = { ...server, failOnStartupError: true }
|
||||
delete config.readOnlyTools
|
||||
return { id: `goodbuddy-mcp-${index}`, name: '@deepseek-ai/dsh-mcp-client', config }
|
||||
})
|
||||
] }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// @vitest-environment node
|
||||
import { spawn } from 'node:child_process'
|
||||
import { mkdtemp, rm, stat } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ResolvedRuntimeSettings } from '../runtime-settings-store'
|
||||
import type { TerminalSnapshot } from '../../shared/terminal-contracts'
|
||||
import type { NativeTerminalLaunch } from '../terminal/terminal-session-manager'
|
||||
import { LocalTerminalSession } from '../terminal/local-terminal-session'
|
||||
import { resolveBundledRuntimePaths } from './bundled-runtimes'
|
||||
import { NativeTerminalClient } from './native-terminal-client'
|
||||
|
||||
// Opt-in because these tests execute the installed native clients and a real PTY.
|
||||
describe.skipIf(!process.env.GOODBUDDY_NATIVE_NODE)('installed native terminal clients', () => {
|
||||
for (const runtime of ['continue', 'opencode'] as const) {
|
||||
for (const workMode of ['ask', 'execute'] as const) {
|
||||
it(`${runtime} ${workMode} enforces tool mode and starts its interactive PTY`, async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'native-client-e2e-'))
|
||||
let launch: NativeTerminalLaunch | undefined
|
||||
let pty: LocalTerminalSession | undefined
|
||||
const requests: Array<Record<string, unknown>> = []
|
||||
const profile = { id: 'native-test', name: 'Native Test Model', modelName: 'native-test-model', baseUrl: 'https://native-test.invalid/v1', protocol: 'openai-chat-completions' as const, authentication: 'api-key' as const, apiKey: 'test-provider-secret' }
|
||||
const client = new NativeTerminalClient({
|
||||
rootDirectory: root,
|
||||
nodeExecutable: process.env.GOODBUDDY_NATIVE_NODE,
|
||||
bundledRuntimePaths: resolveBundledRuntimePaths({ appPath: process.cwd(), resourcesPath: '', packaged: false }),
|
||||
fetcher: async (_url, options) => {
|
||||
const request = JSON.parse(String(options?.body))
|
||||
requests.push(request)
|
||||
if (!request.stream) return new Response(JSON.stringify({ id: 'native-test', object: 'chat.completion', created: 1, model: profile.modelName, choices: [{ index: 0, message: { role: 'assistant', content: 'NATIVE_BRIDGE_OK' }, finish_reason: 'stop' }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } }), { headers: { 'content-type': 'application/json' } })
|
||||
const chunk = (delta: object, finish_reason: string | null) => `data: ${JSON.stringify({ id: 'native-test', object: 'chat.completion.chunk', created: 1, model: profile.modelName, choices: [{ index: 0, delta, finish_reason }] })}\n\n`
|
||||
if (requests.length === 1) return new Response(chunk({ role: 'assistant', tool_calls: [{ index: 0, id: 'write-attempt', type: 'function', function: { name: runtime === 'continue' ? 'Bash' : 'bash', arguments: JSON.stringify({ command: 'echo NATIVE_WRITE > native-write-probe.txt', description: 'Write the test-owned marker' }) } }] }, null) + chunk({}, 'tool_calls') + 'data: [DONE]\n\n', { headers: { 'content-type': 'text/event-stream' } })
|
||||
return new Response(chunk({ role: 'assistant', content: 'NATIVE_BRIDGE_OK' }, null) + chunk({}, 'stop') + 'data: [DONE]\n\n', { headers: { 'content-type': 'text/event-stream' } })
|
||||
},
|
||||
terminalManager: { create: async (_owner, _request, prepared) => {
|
||||
launch = prepared
|
||||
return { sessionId: 'native-test', state: 'running' } as TerminalSnapshot
|
||||
} }
|
||||
})
|
||||
try {
|
||||
await client.open(1, {
|
||||
projectId: 'project', projectName: 'Native Test', directory: root, runtime, workMode,
|
||||
settings: { continueBinaryPath: '', opencodeBinaryPath: '', continueConfigPath: '', continueModelProfile: profile, opencodeModelProfile: profile } as ResolvedRuntimeSettings
|
||||
})
|
||||
const spec = launch!.spawnSpec
|
||||
spec.env.NODE_ENV = 'production'
|
||||
delete spec.env.VITEST
|
||||
delete spec.env.VITEST_WORKER_ID
|
||||
delete spec.env.VITEST_POOL_ID
|
||||
const output = await new Promise<string>((resolve, reject) => {
|
||||
const child = spawn(spec.executable, [...spec.args, ...(runtime === 'continue' ? ['-p', 'Reply with NATIVE_BRIDGE_OK'] : ['--print-logs', 'run', 'Reply with NATIVE_BRIDGE_OK'])], { cwd: root, env: spec.env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let text = ''
|
||||
const timeout = setTimeout(() => { child.kill(); reject(new Error(`Native CLI timeout: ${text.slice(-2000)}`)) }, 45_000)
|
||||
child.stdout.on('data', data => { text += data })
|
||||
child.stderr.on('data', data => { text += data })
|
||||
child.on('error', error => { clearTimeout(timeout); reject(error) })
|
||||
child.on('close', code => { clearTimeout(timeout); if (code === 0) resolve(text); else reject(new Error(`Native CLI exit ${code}: ${text.slice(-2000)}`)) })
|
||||
})
|
||||
expect(output, `requests: ${requests.length}; executable: ${spec.executable}; args: ${JSON.stringify(spec.args)}`).toContain('NATIVE_BRIDGE_OK')
|
||||
expect(requests.length).toBeGreaterThan(0)
|
||||
expect(requests.every(request => request.model === profile.modelName)).toBe(true)
|
||||
if (runtime === 'continue') {
|
||||
const tools = requests.flatMap(request => (request.tools ?? []) as Array<{ function: { name: string } }>).map(tool => tool.function.name)
|
||||
expect(tools).toContain('Read')
|
||||
if (workMode === 'ask') expect(tools).not.toContain('Write')
|
||||
}
|
||||
if (workMode === 'ask') await expect(stat(join(root, 'native-write-probe.txt'))).rejects.toThrow()
|
||||
else expect((await stat(join(root, 'native-write-probe.txt'))).isFile()).toBe(true)
|
||||
expect(requests.length).toBeGreaterThan(1)
|
||||
pty = await LocalTerminalSession.create({ target: { type: 'project', projectId: 'project' }, targetLabel: 'Native Test', title: runtime, size: { cols: 100, rows: 30 }, projectDirectory: root, spawnSpec: spec })
|
||||
expect(pty.snapshot().state).toBe('running')
|
||||
const session = pty
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let text = ''
|
||||
const timeout = setTimeout(() => { remove(); reject(new Error(`Interactive PTY did not become ready: ${text.slice(-1000)}`)) }, 15_000)
|
||||
const remove = session.onEvent(event => {
|
||||
session.acknowledge(event.sequence)
|
||||
if (event.type === 'output') {
|
||||
text += event.data
|
||||
if (/Continue|OpenCode|opencode|Native Test Model|native-test-model/u.test(text)) { clearTimeout(timeout); resolve() }
|
||||
}
|
||||
if (event.type === 'exit') { clearTimeout(timeout); reject(new Error(`Interactive PTY exited: ${text.slice(-1000)}`)) }
|
||||
})
|
||||
})
|
||||
} finally {
|
||||
await pty?.close()
|
||||
await launch?.dispose()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 70_000)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
// @vitest-environment node
|
||||
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ResolvedRuntimeSettings } from '../runtime-settings-store'
|
||||
import type { NativeTerminalLaunch } from '../terminal/terminal-session-manager'
|
||||
import type { TerminalSnapshot } from '../../shared/terminal-contracts'
|
||||
import { NativeTerminalClient, type NativeTerminalClientInput } from './native-terminal-client'
|
||||
|
||||
const roots: string[] = []
|
||||
const launches: NativeTerminalLaunch[] = []
|
||||
afterEach(async () => {
|
||||
await Promise.all(launches.splice(0).map(launch => launch.dispose()))
|
||||
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'native-terminal-test-'))
|
||||
roots.push(root)
|
||||
const create = vi.fn(async (_owner, _request, launch: NativeTerminalLaunch | undefined) => {
|
||||
launches.push(launch!)
|
||||
return { sessionId: 'terminal', state: 'running' } as TerminalSnapshot
|
||||
})
|
||||
const fetcher = vi.fn<typeof fetch>(async () => new Response(JSON.stringify({ choices: [{ message: { role: 'assistant', content: 'ok' } }] }), { headers: { 'content-type': 'application/json' } }))
|
||||
const client = new NativeTerminalClient({
|
||||
terminalManager: { create }, rootDirectory: root,
|
||||
bundledRuntimePaths: { opencode: process.execPath, continue: process.execPath, ripgrep: '', deepseekHarness: '' }, fetcher
|
||||
})
|
||||
const profile = { id: 'selected', name: 'Selected model', modelName: 'selected-model', baseUrl: 'https://provider.example/v1', authentication: 'api-key' as const, protocol: 'openai-chat-completions' as const, apiKey: 'provider-secret', requestHeaders: { 'x-provider-option': 'configured' } }
|
||||
const input: NativeTerminalClientInput = {
|
||||
projectId: 'project', projectName: 'Project', directory: root, runtime: 'opencode', workMode: 'ask',
|
||||
settings: { opencodeBinaryPath: '', opencodeModelProfile: profile } as unknown as ResolvedRuntimeSettings
|
||||
}
|
||||
return { root, client, create, fetcher, input }
|
||||
}
|
||||
|
||||
describe('native terminal client', () => {
|
||||
it('bridges successive native requests with the selected model and keeps credentials in Main', async () => {
|
||||
const { client, input, create, fetcher, root } = await fixture()
|
||||
const first = client.open(1, input)
|
||||
expect(client.open(1, input)).toBe(first)
|
||||
await first
|
||||
expect(create).toHaveBeenCalledOnce()
|
||||
const launch = launches[0]!
|
||||
expect(JSON.stringify(launch.spawnSpec)).not.toContain('provider-secret')
|
||||
const config = JSON.parse(launch.spawnSpec.env.OPENCODE_CONFIG_CONTENT!)
|
||||
const provider = Object.values(config.provider)[0] as { options: { baseURL: string; apiKey: string } }
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const response = await fetch(`${provider.options.baseURL}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${provider.options.apiKey}` }, body: JSON.stringify({ model: 'selected-model', messages: [{ role: 'user', content: 'hi' }] }) })
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.text()).toContain('ok')
|
||||
}
|
||||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||||
expect(String(fetcher.mock.calls[0]?.[0])).toBe('https://provider.example/v1/chat/completions')
|
||||
const headers = new Headers(fetcher.mock.calls[0]?.[1]?.headers)
|
||||
expect(headers.get('authorization')).toBe('Bearer provider-secret')
|
||||
expect(headers.get('x-provider-option')).toBe('configured')
|
||||
const configDirectory = launch.spawnSpec.env.OPENCODE_CONFIG_DIR!
|
||||
const pluginPath = new URL(config.plugin[0])
|
||||
const plugin = await import(/* @vite-ignore */ pluginPath.href)
|
||||
const hooks = await plugin.default()
|
||||
await expect(hooks['tool.execute.before']({ tool: 'read' })).resolves.toBeUndefined()
|
||||
await expect(hooks['tool.execute.before']({ tool: 'bash' })).rejects.toThrow('read-only')
|
||||
await expect(hooks['tool.execute.before']({ tool: 'mcp_write' })).rejects.toThrow('read-only')
|
||||
await launch.dispose()
|
||||
await expect(readdir(configDirectory)).rejects.toThrow()
|
||||
expect(await readdir(join(root, 'opencode'))).toHaveLength(1)
|
||||
await expect(fetch(`${provider.options.baseURL}/chat/completions`, { method: 'POST' })).rejects.toThrow()
|
||||
await client.open(1, input)
|
||||
expect(create).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('releases prepared resources after terminal creation fails and permits retry', async () => {
|
||||
const { client, input, create, root } = await fixture()
|
||||
create.mockImplementationOnce(async (_owner, _request, launch) => {
|
||||
launches.push(launch!)
|
||||
throw new Error('PTY unavailable')
|
||||
})
|
||||
await expect(client.open(1, input)).rejects.toThrow('PTY unavailable')
|
||||
expect((await readdir(root)).filter(name => name.startsWith('launch-'))).toEqual([])
|
||||
await expect(client.open(1, input)).resolves.toMatchObject({ state: 'running' })
|
||||
})
|
||||
|
||||
it('uses Execute permissions and explicit session MCP endpoints', async () => {
|
||||
const { client, input } = await fixture()
|
||||
await client.open(1, { ...input, workMode: 'execute', mcpServers: [{ name: 'tools', url: 'http://127.0.0.1:12345/mcp', headers: { Authorization: 'Bearer local-token' } }] })
|
||||
const launch = launches[0]!
|
||||
const config = JSON.parse(launch.spawnSpec.env.OPENCODE_CONFIG_CONTENT!)
|
||||
expect(config.permission).toBe('allow')
|
||||
expect(config.mcp.tools.url).toBe('http://127.0.0.1:12345/mcp')
|
||||
expect(await readFile(new URL(config.plugin[0]), 'utf8')).toContain('execute')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,213 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { cp, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { promisify } from 'node:util'
|
||||
import { AgentModelCallLedger, AgentModelGateway } from '../../agent-daemon/agent-model-gateway'
|
||||
import {
|
||||
ModelBridgeLoopbackProxy,
|
||||
MODEL_BRIDGE_SDK_AUTH_SENTINEL,
|
||||
createOpenCodeModelBridgeProviderConfig
|
||||
} from '../../agent-daemon/model-bridge-helper'
|
||||
import type { TerminalSize, TerminalSnapshot } from '../../shared/terminal-contracts'
|
||||
import type { ResolvedRuntimeSettings } from '../runtime-settings-store'
|
||||
import type { RuntimeSkillPackage } from '../capabilities/capability-service'
|
||||
import type { LaunchEnvironmentProvider } from '../local-tool-environment/launch-environment-provider'
|
||||
import { createManagedModelBridge, createResolvedModelProfileDigest } from '../remote-agent/managed-model-bridge'
|
||||
import type { TerminalSessionManager, NativeTerminalLaunch } from '../terminal/terminal-session-manager'
|
||||
import type { BundledRuntimePaths } from './bundled-runtimes'
|
||||
import { ContinueHostAdapter, loadContinueConfig } from './continue-host-adapter'
|
||||
import { detectRuntimeBinary } from './runtime-discovery'
|
||||
import { applyLaunchEnvironmentPath, buildCredentialFilteredUserEnvironment, runtimePrivacyEnvironment } from './process-environment'
|
||||
import { stageRuntimeSkillPackages } from './runtime-skill-packages'
|
||||
|
||||
export type NativeTerminalClientInput = {
|
||||
projectId: string
|
||||
projectName: string
|
||||
directory: string
|
||||
runtime: 'continue' | 'opencode'
|
||||
workMode: 'ask' | 'execute'
|
||||
settings: ResolvedRuntimeSettings
|
||||
size?: TerminalSize
|
||||
skillPackages?: RuntimeSkillPackage[]
|
||||
/** Session-lived, Main-owned MCP endpoints, such as the existing knowledge gateway. */
|
||||
mcpServers?: Array<{ name: string; url: string; headers: Record<string, string> }>
|
||||
}
|
||||
|
||||
export type NativeTerminalClientOptions = {
|
||||
terminalManager: Pick<TerminalSessionManager, 'create'>
|
||||
/** GoodBuddy user-data directory; native history is retained here. */
|
||||
rootDirectory: string
|
||||
bundledRuntimePaths: BundledRuntimePaths
|
||||
/** Standard Node resolved by the local tool environment, never Electron. */
|
||||
nodeExecutable?: string
|
||||
launchEnvironmentProvider?: LaunchEnvironmentProvider
|
||||
fetcher?: typeof fetch
|
||||
}
|
||||
|
||||
// Same native read permissions as the existing remote OpenCode adapter.
|
||||
const openCodeReadTools = ['read', 'glob', 'grep', 'list', 'lsp', 'webfetch', 'websearch', 'codesearch', 'question', 'external_directory']
|
||||
const continueReadTools = ['Read', 'List', 'Search', 'Fetch', 'Diff', 'AskQuestion', 'CheckBackgroundJob', 'Skills']
|
||||
|
||||
export class NativeTerminalClient {
|
||||
private readonly pending = new Map<string, Promise<TerminalSnapshot>>()
|
||||
|
||||
constructor(private readonly options: NativeTerminalClientOptions) {}
|
||||
|
||||
open(ownerId: number, input: NativeTerminalClientInput): Promise<TerminalSnapshot> {
|
||||
const profile = input.runtime === 'continue'
|
||||
? input.settings.continueModelProfile
|
||||
: input.settings.opencodeModelProfile
|
||||
if (!profile) return Promise.reject(new Error('Native client requires the selected text model connection'))
|
||||
const key = JSON.stringify([ownerId, input.projectId, input.runtime, input.workMode, createResolvedModelProfileDigest(profile)])
|
||||
const existing = this.pending.get(key)
|
||||
if (existing) return existing
|
||||
const operation = this.launch(ownerId, structuredClone(input)).finally(() => this.pending.delete(key))
|
||||
this.pending.set(key, operation)
|
||||
return operation
|
||||
}
|
||||
|
||||
private async launch(ownerId: number, input: NativeTerminalClientInput): Promise<TerminalSnapshot> {
|
||||
const launch = await this.prepare(input)
|
||||
try {
|
||||
const snapshot = await this.options.terminalManager.create(ownerId, {
|
||||
target: { type: 'project', projectId: input.projectId },
|
||||
...(input.size ?? { cols: 100, rows: 30 })
|
||||
}, launch)
|
||||
if (snapshot.state === 'failed') {
|
||||
await launch.dispose()
|
||||
throw new Error(snapshot.error?.message ?? 'Native terminal failed to start')
|
||||
}
|
||||
return snapshot
|
||||
} catch (error) {
|
||||
await launch.dispose()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async prepare(input: NativeTerminalClientInput): Promise<NativeTerminalLaunch> {
|
||||
if (!(await stat(input.directory)).isDirectory()) throw new Error('Native client project directory is unavailable')
|
||||
const selected = input.runtime === 'continue' ? input.settings.continueModelProfile : input.settings.opencodeModelProfile
|
||||
if (!selected) throw new Error('Native client requires a model connection')
|
||||
const managed = createManagedModelBridge({ profile: selected })
|
||||
const detection = await detectRuntimeBinary({
|
||||
binaryPath: input.runtime === 'continue' ? input.settings.continueBinaryPath : input.settings.opencodeBinaryPath,
|
||||
bundledPath: this.options.bundledRuntimePaths[input.runtime],
|
||||
binaryNames: input.runtime === 'continue' ? ['cn'] : ['opencode'],
|
||||
label: input.runtime,
|
||||
validation: 'canonical-file'
|
||||
})
|
||||
if (!detection.path) throw new Error(detection.detail)
|
||||
const env = applyLaunchEnvironmentPath({
|
||||
...buildCredentialFilteredUserEnvironment(), ...runtimePrivacyEnvironment
|
||||
}, this.options.launchEnvironmentProvider)
|
||||
// Do not inherit another native client's provider/config selection.
|
||||
for (const name of Object.keys(env)) {
|
||||
if (/^(OPENCODE_|CONTINUE_)/u.test(name)) delete env[name]
|
||||
}
|
||||
let executable = detection.path
|
||||
const args: string[] = []
|
||||
if (input.runtime === 'continue') {
|
||||
const node = this.options.nodeExecutable
|
||||
if (!node) throw new Error('Continue interactive terminal requires a standard Node executable from the local tool environment')
|
||||
const { stdout } = await promisify(execFile)(node, ['-p', 'JSON.stringify({node:process.versions.node,electron:process.versions.electron})'], { env, timeout: 5_000, windowsHide: true })
|
||||
const version = JSON.parse(stdout.trim()) as { node?: string; electron?: string }
|
||||
if (!version.node || version.electron) throw new Error('Continue interactive terminal requires standard Node, not Electron')
|
||||
executable = node
|
||||
args.push(detection.path)
|
||||
}
|
||||
const root = this.options.rootDirectory
|
||||
await mkdir(root, { recursive: true })
|
||||
const temporary = await mkdtemp(join(root, 'launch-'))
|
||||
const history = join(root, input.runtime, randomUUID())
|
||||
let ledger: AgentModelCallLedger | undefined
|
||||
let gateway: AgentModelGateway
|
||||
const bindingId = randomUUID()
|
||||
const proxy = new ModelBridgeLoopbackProxy({ exchange: (request, context) => gateway.dispatch({
|
||||
bindingId, operationId: context.requestId, promptSequence: 0, roundIndex: 0,
|
||||
profileDigest: managed.profile.modelProfileDigest, profile: managed.profile
|
||||
}, request, context.signal) })
|
||||
let disposal: Promise<void> | undefined
|
||||
const dispose = (): Promise<void> => disposal ??= (async () => {
|
||||
try { await proxy.close() } finally {
|
||||
ledger?.close()
|
||||
await rm(temporary, { recursive: true, force: true })
|
||||
}
|
||||
})()
|
||||
try {
|
||||
ledger = new AgentModelCallLedger(join(temporary, 'model-calls.sqlite'))
|
||||
gateway = new AgentModelGateway({ ledger, fetcher: this.options.fetcher })
|
||||
await mkdir(history, { recursive: true })
|
||||
const origin = await proxy.listen()
|
||||
const skills = input.skillPackages ?? []
|
||||
const mcp = input.workMode === 'execute' ? input.mcpServers ?? [] : []
|
||||
if (input.runtime === 'continue') {
|
||||
// Reuse the pinned adapter's protocol, permission-order and Windows fixes.
|
||||
const adapter = new ContinueHostAdapter({ binaryPath: detection.path,
|
||||
configPath: '', workspace: input.directory, cacheRoot: temporary, mode: 'chat' })
|
||||
const prepared = await adapter.getPreparedHost()
|
||||
const bundlePath = join(dirname(prepared.entryPath), 'index.js')
|
||||
const bundle = await readFile(bundlePath, 'utf8')
|
||||
const executionMarker = 'async function hti(e,t={parallelToolCallCount:1}){'
|
||||
if (bundle.split(executionMarker).length !== 2) throw new Error('Continue native tool execution entry point is incompatible')
|
||||
await writeFile(bundlePath, bundle.replace(executionMarker, `${executionMarker}if(${JSON.stringify(input.workMode)}==="ask"&&!${JSON.stringify(continueReadTools)}.includes(e.name))throw new Error("GoodBuddy Ask mode is read-only");`))
|
||||
args[0] = prepared.entryPath
|
||||
const configured = input.settings.continueConfigPath.trim()
|
||||
? await loadContinueConfig(input.settings.continueConfigPath.trim()) : {}
|
||||
const configPath = join(temporary, 'continue.json')
|
||||
await writeFile(configPath, JSON.stringify({
|
||||
name: 'GoodBuddy Native Client', version: '1.0.0', schema: 'v1',
|
||||
models: [{ name: selected.name, model: selected.modelName,
|
||||
provider: selected.protocol === 'anthropic-messages' ? 'anthropic' : 'openai',
|
||||
apiBase: `${origin}/v1`, apiKey: MODEL_BRIDGE_SDK_AUTH_SENTINEL,
|
||||
roles: ['chat'], capabilities: selected.supportsImageInput ? ['image_input'] : [],
|
||||
...(selected.protocol !== 'anthropic-messages' ? { useResponsesApi: selected.protocol === 'openai-responses' } : {})
|
||||
}],
|
||||
...(configured.rules ? { rules: configured.rules } : {}),
|
||||
...(configured.prompts ? { prompts: configured.prompts } : {}),
|
||||
mcpServers: mcp.map(server => ({ name: server.name, type: 'streamable-http', url: server.url, requestOptions: { headers: server.headers } }))
|
||||
}), { mode: 0o600 })
|
||||
await stageRuntimeSkillPackages(history, skills, 'Continue')
|
||||
Object.assign(env, { CONTINUE_GLOBAL_DIR: history, GOODBUDDY_DISABLE_CONTINUE_UPDATES: '1', CONTINUE_CLI_AUTO_UPDATED: '1', CONTINUE_CLI_ENABLE_TELEMETRY: '0', CONTINUE_METRICS_ENABLED: '0', CONTINUE_CLI_DISABLE_COMMIT_SIGNATURE: '1' })
|
||||
args.push('--config', configPath)
|
||||
if (input.workMode === 'execute') args.push('--auto')
|
||||
else args.push('--readonly', ...continueReadTools.flatMap(name => ['--allow', name]), '--exclude', '*')
|
||||
} else {
|
||||
const configDirectory = join(temporary, 'config')
|
||||
if (this.options.bundledRuntimePaths.opencodeConfig) {
|
||||
await cp(this.options.bundledRuntimePaths.opencodeConfig, configDirectory, { recursive: true })
|
||||
}
|
||||
const skillsRoot = await stageRuntimeSkillPackages(configDirectory, skills, 'OpenCode')
|
||||
const config = createOpenCodeModelBridgeProviderConfig({
|
||||
protocol: managed.profile.protocol, model: selected.modelName, name: selected.name,
|
||||
loopbackOrigin: origin, supportsImageInput: selected.supportsImageInput, workMode: input.workMode
|
||||
})
|
||||
const permission = input.workMode === 'execute' ? 'allow' : Object.fromEntries([
|
||||
['*', 'deny'], ...openCodeReadTools.map(name => [name, 'allow']), ['skill', 'allow']
|
||||
])
|
||||
// A tool hook keeps the launch mode fixed even when the TUI changes agents.
|
||||
const pluginPath = join(temporary, 'work-mode.mjs')
|
||||
await writeFile(pluginPath, `export default async () => ({'tool.execute.before': async ({tool}) => { if (${JSON.stringify(input.workMode)} === 'ask' && !${JSON.stringify([...openCodeReadTools, 'skill'])}.includes(tool)) throw new Error('GoodBuddy Ask mode is read-only'); }});`, { mode: 0o600 })
|
||||
Object.assign(env, {
|
||||
OPENCODE_CONFIG_DIR: configDirectory,
|
||||
OPENCODE_CONFIG_CONTENT: JSON.stringify({ ...config, permission,
|
||||
agent: { ...config.agent, build: { permission }, plan: { permission } },
|
||||
plugin: [pathToFileURL(pluginPath).href], skills: { paths: [skillsRoot] },
|
||||
mcp: Object.fromEntries(mcp.map(server => [server.name, { type: 'remote', url: server.url, headers: server.headers, enabled: true }]))
|
||||
}),
|
||||
OPENCODE_DISABLE_AUTOUPDATE: '1', OPENCODE_DISABLE_PROJECT_CONFIG: '1',
|
||||
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS: '1', OPENCODE_DISABLE_EXTERNAL_SKILLS: '1',
|
||||
OPENCODE_DISABLE_MODELS_FETCH: '1', OPENCODE_DISABLE_LSP_DOWNLOAD: '1', OPENCODE_DISABLE_SHARE: '1',
|
||||
XDG_CONFIG_HOME: join(temporary, 'xdg-config'), XDG_CACHE_HOME: join(root, 'cache'),
|
||||
XDG_DATA_HOME: join(history, 'data'), XDG_STATE_HOME: join(history, 'state')
|
||||
})
|
||||
args.push('--model', config.model)
|
||||
}
|
||||
return { spawnSpec: { executable, args, cwd: input.directory, env, label: input.runtime }, title: `${input.runtime} · ${input.projectName}`, dispose }
|
||||
} catch (error) {
|
||||
await dispose()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -219,7 +219,7 @@ describe('runtime discovery', () => {
|
||||
binaryPath: '',
|
||||
bundledPath: bundledScript,
|
||||
bundledValidation: 'canonical-file',
|
||||
bundledVersion: '0.1.2-rc.1',
|
||||
bundledVersion: '0.1.7-rc.2',
|
||||
binaryNames: [],
|
||||
label: 'GoodBuddy DeepSeek Harness Host'
|
||||
})
|
||||
@@ -227,7 +227,7 @@ describe('runtime discovery', () => {
|
||||
expect(detection).toMatchObject({
|
||||
available: true,
|
||||
path: await realpath(bundledScript),
|
||||
version: '0.1.2-rc.1',
|
||||
version: '0.1.7-rc.2',
|
||||
source: 'bundled'
|
||||
})
|
||||
expect(detection.detail).toContain('内置')
|
||||
|
||||
Reference in New Issue
Block a user