fix: stabilize OpenCode reuse and session switching
Check cached embedded servers before reuse, isolate concurrent cancellation, and serialize request MCP registration and cleanup. Preserve native controls across session switches and recover usable partial inventories. Share the bundled offline OpenCode configuration with desktop and Agent launch paths, including model bridge environment forwarding. Validation: focused tests and typecheck passed; lint passed before the final fixes. The full test run exceeded 120 seconds and reported a DS stdio MCP failure. Current-source Agent host and real suspend/resume validation remain pending. Release note: 修复切换会话后 OpenCode 原生控件消失及并发取消相互影响的问题,并完善内嵌服务复用检查和桌面、Agent 的离线依赖配置。
This commit is contained in:
@@ -890,6 +890,26 @@ function credentialFreeHelperEnvironment(
|
||||
}
|
||||
environment[name] = value
|
||||
}
|
||||
for (const name of [
|
||||
'OPENCODE_CONFIG_DIR',
|
||||
'OPENCODE_DISABLE_AUTOUPDATE',
|
||||
'OPENCODE_DISABLE_CLAUDE_CODE_SKILLS',
|
||||
'OPENCODE_DISABLE_EMBEDDED_WEB_UI',
|
||||
'OPENCODE_DISABLE_EXTERNAL_SKILLS',
|
||||
'OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER',
|
||||
'OPENCODE_DISABLE_LSP_DOWNLOAD',
|
||||
'OPENCODE_DISABLE_MODELS_FETCH',
|
||||
'OPENCODE_DISABLE_PROJECT_CONFIG',
|
||||
'OPENCODE_DISABLE_SHARE'
|
||||
]) {
|
||||
const value = source[name]
|
||||
if (value !== undefined) {
|
||||
if (value.includes('\0')) {
|
||||
throw new Error(`Invalid helper environment value for ${name}`)
|
||||
}
|
||||
environment[name] = value
|
||||
}
|
||||
}
|
||||
return environment
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
@@ -21,8 +22,12 @@ import {
|
||||
import {
|
||||
createOpenCodeModelBridgeProviderConfig,
|
||||
MODEL_BRIDGE_SDK_AUTH_SENTINEL,
|
||||
ModelBridgeLoopbackProxy
|
||||
ModelBridgeLoopbackProxy,
|
||||
runOpenCodeModelBridgeHelper,
|
||||
type ModelBridgeHelperSpawn
|
||||
} from './model-bridge-helper'
|
||||
import { createOpenCodeLaunchProfile } from './opencode-runtime-profile'
|
||||
import { createRuntimeBundleTestFixture } from './runtime-bundle-test-fixture'
|
||||
import {
|
||||
createUnixModelBridgeExchange,
|
||||
MODEL_BRIDGE_BROKER_SOCKET_NAME,
|
||||
@@ -51,6 +56,89 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('model bridge loopback helper', () => {
|
||||
it.each(['ask', 'execute'] as const)(
|
||||
'preserves the %s launch profile offline resources in the final OpenCode spawn',
|
||||
async (workMode) => {
|
||||
const fixture = await createRuntimeBundleTestFixture()
|
||||
temporaryPaths.push(fixture.root)
|
||||
const socketPath = join(fixture.root, 'bridge', 'model.sock')
|
||||
const profile = createOpenCodeLaunchProfile({
|
||||
...fixture,
|
||||
workspaceDirectory: fixture.root,
|
||||
workMode,
|
||||
modelBridge: {
|
||||
agentExecutablePath: join(fixture.root, 'agent', 'goodbuddy-agent'),
|
||||
bridgeDirectory: join(fixture.root, 'bridge'),
|
||||
socketPath,
|
||||
policy: {
|
||||
protocol: 'anthropic-messages',
|
||||
model: 'private-model',
|
||||
modelProfileDigest: `sha256:${'9'.repeat(64)}`,
|
||||
supportsImageInput: false
|
||||
}
|
||||
}
|
||||
})
|
||||
const spawn = vi.fn<ModelBridgeHelperSpawn>(() => {
|
||||
const child = new EventEmitter()
|
||||
queueMicrotask(() => child.emit('close', 0, null))
|
||||
return child
|
||||
})
|
||||
const resources = {
|
||||
HOME: fixture.root,
|
||||
PATH: join(fixture.root, 'tools'),
|
||||
TMPDIR: join(fixture.root, 'tmp'),
|
||||
XDG_CACHE_HOME: join(fixture.root, 'cache'),
|
||||
XDG_CONFIG_HOME: join(fixture.root, 'config'),
|
||||
XDG_DATA_HOME: join(fixture.root, 'data'),
|
||||
XDG_STATE_HOME: join(fixture.root, 'state')
|
||||
}
|
||||
await expect(runOpenCodeModelBridgeHelper({
|
||||
socketPath,
|
||||
protocol: 'anthropic-messages',
|
||||
model: 'private-model',
|
||||
supportsImageInput: false,
|
||||
workMode,
|
||||
opencodeEntrypoint: join(fixture.bundleDirectory, 'bin', 'opencode'),
|
||||
environment: {
|
||||
...profile.env,
|
||||
...resources,
|
||||
OPENCODE_CONFIG_CONTENT: '{"model":"must-not-be-used"}',
|
||||
OPENCODE_CONFIG: '/unrelated/config.json',
|
||||
ANTHROPIC_API_KEY: 'must-not-be-forwarded',
|
||||
NODE_OPTIONS: '--inspect'
|
||||
},
|
||||
spawn
|
||||
})).resolves.toBe(0)
|
||||
expect(spawn).toHaveBeenCalledOnce()
|
||||
const [executable, args, options] = spawn.mock.calls[0]!
|
||||
expect(executable).toBe(join(fixture.bundleDirectory, 'bin', 'opencode'))
|
||||
expect(args).toEqual(['acp'])
|
||||
expect(options).toMatchObject({ shell: false, stdio: 'inherit' })
|
||||
expect(options.env).toMatchObject({
|
||||
...resources,
|
||||
LANG: profile.env.LANG,
|
||||
LC_ALL: profile.env.LC_ALL,
|
||||
OPENCODE_CONFIG_DIR: join(fixture.bundleDirectory, 'config', 'opencode'),
|
||||
OPENCODE_DISABLE_AUTOUPDATE: '1',
|
||||
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS: '1',
|
||||
OPENCODE_DISABLE_EMBEDDED_WEB_UI: '1',
|
||||
OPENCODE_DISABLE_EXTERNAL_SKILLS: '1',
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: '1',
|
||||
OPENCODE_DISABLE_LSP_DOWNLOAD: '1',
|
||||
OPENCODE_DISABLE_MODELS_FETCH: '1',
|
||||
OPENCODE_DISABLE_PROJECT_CONFIG: '1',
|
||||
OPENCODE_DISABLE_SHARE: '1'
|
||||
})
|
||||
expect(options.env.OPENCODE_CONFIG).toBeUndefined()
|
||||
expect(options.env.ANTHROPIC_API_KEY).toBeUndefined()
|
||||
expect(options.env.NODE_OPTIONS).toBeUndefined()
|
||||
const config = JSON.parse(options.env.OPENCODE_CONFIG_CONTENT!)
|
||||
expect(config.model).toBe('goodbuddy-anthropic/private-model')
|
||||
expect(config.permission).toBe(workMode === 'ask' ? 'ask' : undefined)
|
||||
expect(config.plugin).toHaveLength(1)
|
||||
}
|
||||
)
|
||||
|
||||
it('forwards only the bounded request contract and returns the response', async () => {
|
||||
const exchange = vi.fn<ModelBridgeExchange>(
|
||||
async () => validResponse
|
||||
|
||||
@@ -1,230 +1,216 @@
|
||||
import {
|
||||
dirname,
|
||||
isAbsolute,
|
||||
join,
|
||||
relative,
|
||||
resolve,
|
||||
sep
|
||||
} from 'node:path'
|
||||
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||
import {
|
||||
OPENCODE_REMOTE_RUNTIME_ENVIRONMENT_NAMES,
|
||||
remoteRuntimeBundleManifestSchema,
|
||||
type RemoteRuntimeBundleManifest
|
||||
} from '../shared/remote-runtime-launch-contracts'
|
||||
type RemoteRuntimeBundleManifest,
|
||||
} from "../shared/remote-runtime-launch-contracts";
|
||||
import {
|
||||
modelBridgePolicySchema,
|
||||
type ModelBridgePolicy
|
||||
} from '../shared/model-bridge-contracts'
|
||||
import {
|
||||
assertAbsoluteManagedPath
|
||||
} from './managed-paths'
|
||||
type ModelBridgePolicy,
|
||||
} from "../shared/model-bridge-contracts";
|
||||
import { assertAbsoluteManagedPath } from "./managed-paths";
|
||||
|
||||
const OPEN_CODE_ASK_CONFIG = JSON.stringify({
|
||||
permission: 'ask',
|
||||
permission: "ask",
|
||||
agent: {
|
||||
build: {
|
||||
permission: 'ask'
|
||||
}
|
||||
}
|
||||
})
|
||||
permission: "ask",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const OPEN_CODE_RUNTIME_ENVIRONMENT = Object.freeze({
|
||||
OPENCODE_DISABLE_AUTOUPDATE: "1",
|
||||
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS: "1",
|
||||
OPENCODE_DISABLE_EMBEDDED_WEB_UI: "1",
|
||||
OPENCODE_DISABLE_EXTERNAL_SKILLS: "1",
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "1",
|
||||
OPENCODE_DISABLE_LSP_DOWNLOAD: "1",
|
||||
OPENCODE_DISABLE_MODELS_FETCH: "1",
|
||||
OPENCODE_DISABLE_PROJECT_CONFIG: "1",
|
||||
OPENCODE_DISABLE_SHARE: "1",
|
||||
});
|
||||
|
||||
export type OpenCodeLaunchProfile = {
|
||||
executable: string
|
||||
processExecutable: string
|
||||
args: readonly string[]
|
||||
cwd: string
|
||||
env: Readonly<NodeJS.ProcessEnv>
|
||||
workMode: 'ask' | 'execute'
|
||||
}
|
||||
executable: string;
|
||||
processExecutable: string;
|
||||
args: readonly string[];
|
||||
cwd: string;
|
||||
env: Readonly<NodeJS.ProcessEnv>;
|
||||
workMode: "ask" | "execute";
|
||||
};
|
||||
|
||||
export function createOpenCodeLaunchProfile(input: {
|
||||
manifest: RemoteRuntimeBundleManifest
|
||||
bundleDirectory: string
|
||||
workspaceDirectory: string
|
||||
workMode: 'ask' | 'execute'
|
||||
manifest: RemoteRuntimeBundleManifest;
|
||||
bundleDirectory: string;
|
||||
workspaceDirectory: string;
|
||||
workMode: "ask" | "execute";
|
||||
modelBridge?: {
|
||||
agentExecutablePath: string
|
||||
bridgeDirectory: string
|
||||
socketPath: string
|
||||
policy: ModelBridgePolicy
|
||||
}
|
||||
agentExecutablePath: string;
|
||||
bridgeDirectory: string;
|
||||
socketPath: string;
|
||||
policy: ModelBridgePolicy;
|
||||
};
|
||||
}): OpenCodeLaunchProfile {
|
||||
const manifest = remoteRuntimeBundleManifestSchema.parse(
|
||||
input.manifest
|
||||
)
|
||||
const manifest = remoteRuntimeBundleManifestSchema.parse(input.manifest);
|
||||
if (
|
||||
manifest.runtimeId !== 'opencode' ||
|
||||
manifest.provider !== 'opencode' ||
|
||||
manifest.entrypoint.identity !== 'opencode-acp' ||
|
||||
manifest.runtimeId !== "opencode" ||
|
||||
manifest.provider !== "opencode" ||
|
||||
manifest.entrypoint.identity !== "opencode-acp" ||
|
||||
manifest.entrypoint.argvPrefix.length !== 1 ||
|
||||
manifest.entrypoint.argvPrefix[0] !== 'acp'
|
||||
manifest.entrypoint.argvPrefix[0] !== "acp"
|
||||
) {
|
||||
throw new Error('Runtime manifest is not the fixed OpenCode ACP profile')
|
||||
throw new Error("Runtime manifest is not the fixed OpenCode ACP profile");
|
||||
}
|
||||
if (
|
||||
[...manifest.allowedEnvironmentNames].sort().join('\0') !==
|
||||
[...OPENCODE_REMOTE_RUNTIME_ENVIRONMENT_NAMES]
|
||||
.sort()
|
||||
.join('\0')
|
||||
[...manifest.allowedEnvironmentNames].sort().join("\0") !==
|
||||
[...OPENCODE_REMOTE_RUNTIME_ENVIRONMENT_NAMES].sort().join("\0")
|
||||
) {
|
||||
throw new Error(
|
||||
'OpenCode Runtime environment allowlist does not match the fixed profile'
|
||||
)
|
||||
"OpenCode Runtime environment allowlist does not match the fixed profile",
|
||||
);
|
||||
}
|
||||
|
||||
const bundleDirectory = assertAbsoluteManagedPath(
|
||||
resolve(input.bundleDirectory)
|
||||
)
|
||||
resolve(input.bundleDirectory),
|
||||
);
|
||||
const workspaceDirectory = normalizedAbsolutePath(
|
||||
input.workspaceDirectory,
|
||||
'Workspace'
|
||||
)
|
||||
"Workspace",
|
||||
);
|
||||
const modelBridge =
|
||||
input.modelBridge === undefined
|
||||
? undefined
|
||||
: {
|
||||
agentExecutablePath: normalizedAbsolutePath(
|
||||
input.modelBridge.agentExecutablePath,
|
||||
'Agent executable'
|
||||
"Agent executable",
|
||||
),
|
||||
agentInstallationDirectory: normalizedAbsolutePath(
|
||||
dirname(input.modelBridge.agentExecutablePath),
|
||||
'Agent installation'
|
||||
"Agent installation",
|
||||
),
|
||||
bridgeDirectory: normalizedAbsolutePath(
|
||||
input.modelBridge.bridgeDirectory,
|
||||
'Model bridge'
|
||||
"Model bridge",
|
||||
),
|
||||
socketPath: normalizedAbsolutePath(
|
||||
input.modelBridge.socketPath,
|
||||
'Model bridge socket'
|
||||
"Model bridge socket",
|
||||
),
|
||||
policy: modelBridgePolicySchema.parse(
|
||||
input.modelBridge.policy
|
||||
)
|
||||
}
|
||||
policy: modelBridgePolicySchema.parse(input.modelBridge.policy),
|
||||
};
|
||||
if (
|
||||
modelBridge !== undefined &&
|
||||
(!isStrictDescendant(
|
||||
modelBridge.bridgeDirectory,
|
||||
modelBridge.socketPath
|
||||
) ||
|
||||
(!isStrictDescendant(modelBridge.bridgeDirectory, modelBridge.socketPath) ||
|
||||
!isStrictDescendant(
|
||||
modelBridge.agentInstallationDirectory,
|
||||
modelBridge.agentExecutablePath
|
||||
modelBridge.agentExecutablePath,
|
||||
))
|
||||
) {
|
||||
throw new Error(
|
||||
'Model bridge socket or Agent executable escapes its managed directory'
|
||||
)
|
||||
"Model bridge socket or Agent executable escapes its managed directory",
|
||||
);
|
||||
}
|
||||
const executablePath = resolve(
|
||||
bundleDirectory,
|
||||
...manifest.entrypoint.path.split('/')
|
||||
)
|
||||
...manifest.entrypoint.path.split("/"),
|
||||
);
|
||||
if (!isStrictDescendant(bundleDirectory, executablePath)) {
|
||||
throw new Error('OpenCode entrypoint escapes its bundle directory')
|
||||
throw new Error("OpenCode entrypoint escapes its bundle directory");
|
||||
}
|
||||
|
||||
const runtimeCommand =
|
||||
modelBridge === undefined
|
||||
? {
|
||||
executable: executablePath,
|
||||
args: [...manifest.entrypoint.argvPrefix]
|
||||
args: [...manifest.entrypoint.argvPrefix],
|
||||
}
|
||||
: {
|
||||
executable: join(
|
||||
modelBridge.agentInstallationDirectory,
|
||||
'node'
|
||||
),
|
||||
executable: join(modelBridge.agentInstallationDirectory, "node"),
|
||||
args: [
|
||||
join(
|
||||
modelBridge.agentInstallationDirectory,
|
||||
'lib',
|
||||
'agent.cjs'
|
||||
),
|
||||
'model-bridge-helper',
|
||||
'--socket-path',
|
||||
join(modelBridge.agentInstallationDirectory, "lib", "agent.cjs"),
|
||||
"model-bridge-helper",
|
||||
"--socket-path",
|
||||
modelBridge.socketPath,
|
||||
'--protocol',
|
||||
"--protocol",
|
||||
modelBridge.policy.protocol,
|
||||
'--model',
|
||||
"--model",
|
||||
modelBridge.policy.model,
|
||||
'--supports-image-input',
|
||||
modelBridge.policy.supportsImageInput ? 'true' : 'false',
|
||||
'--work-mode',
|
||||
"--supports-image-input",
|
||||
modelBridge.policy.supportsImageInput ? "true" : "false",
|
||||
"--work-mode",
|
||||
input.workMode,
|
||||
'--opencode-entrypoint',
|
||||
executablePath
|
||||
]
|
||||
}
|
||||
"--opencode-entrypoint",
|
||||
executablePath,
|
||||
],
|
||||
};
|
||||
|
||||
const environmentBase = {
|
||||
...executeEnvironment(process.env, workspaceDirectory),
|
||||
...OPEN_CODE_RUNTIME_ENVIRONMENT,
|
||||
OPENCODE_CONFIG_DIR: join(bundleDirectory, "config", "opencode"),
|
||||
};
|
||||
const environment =
|
||||
input.workMode === 'ask'
|
||||
input.workMode === "ask"
|
||||
? {
|
||||
...executeEnvironment(process.env, workspaceDirectory),
|
||||
OPENCODE_CONFIG_CONTENT: OPEN_CODE_ASK_CONFIG
|
||||
...environmentBase,
|
||||
OPENCODE_CONFIG_CONTENT: OPEN_CODE_ASK_CONFIG,
|
||||
}
|
||||
: executeEnvironment(process.env, workspaceDirectory)
|
||||
: environmentBase;
|
||||
return {
|
||||
...runtimeCommand,
|
||||
processExecutable: runtimeCommand.executable,
|
||||
cwd: workspaceDirectory,
|
||||
env: environment,
|
||||
workMode: input.workMode
|
||||
}
|
||||
workMode: input.workMode,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizedAbsolutePath(value: string, label: string): string {
|
||||
if (
|
||||
value.includes('\0') ||
|
||||
!isAbsolute(value) ||
|
||||
resolve(value) !== value
|
||||
) {
|
||||
throw new Error(`${label} path must be a normalized absolute path`)
|
||||
if (value.includes("\0") || !isAbsolute(value) || resolve(value) !== value) {
|
||||
throw new Error(`${label} path must be a normalized absolute path`);
|
||||
}
|
||||
return value
|
||||
return value;
|
||||
}
|
||||
|
||||
function executeEnvironment(
|
||||
source: Readonly<NodeJS.ProcessEnv>,
|
||||
workspaceDirectory: string
|
||||
workspaceDirectory: string,
|
||||
): Readonly<NodeJS.ProcessEnv> {
|
||||
const home = usableEnvironmentValue(source.HOME)
|
||||
? source.HOME!
|
||||
: workspaceDirectory
|
||||
: workspaceDirectory;
|
||||
const defaults = {
|
||||
HOME: home,
|
||||
LANG: 'C.UTF-8',
|
||||
LC_ALL: 'C.UTF-8',
|
||||
PATH: '/usr/bin:/bin',
|
||||
TMPDIR: '/tmp',
|
||||
XDG_CACHE_HOME: join(home, '.cache'),
|
||||
XDG_CONFIG_HOME: join(home, '.config'),
|
||||
XDG_DATA_HOME: join(home, '.local', 'share'),
|
||||
XDG_STATE_HOME: join(home, '.local', 'state')
|
||||
} as const
|
||||
const environment: NodeJS.ProcessEnv = { ...source }
|
||||
LANG: "C.UTF-8",
|
||||
LC_ALL: "C.UTF-8",
|
||||
PATH: "/usr/bin:/bin",
|
||||
TMPDIR: "/tmp",
|
||||
XDG_CACHE_HOME: join(home, ".cache"),
|
||||
XDG_CONFIG_HOME: join(home, ".config"),
|
||||
XDG_DATA_HOME: join(home, ".local", "share"),
|
||||
XDG_STATE_HOME: join(home, ".local", "state"),
|
||||
} as const;
|
||||
const environment: NodeJS.ProcessEnv = { ...source };
|
||||
for (const [name, fallback] of Object.entries(defaults)) {
|
||||
if (!usableEnvironmentValue(environment[name])) {
|
||||
environment[name] = fallback
|
||||
environment[name] = fallback;
|
||||
}
|
||||
}
|
||||
return environment
|
||||
return environment;
|
||||
}
|
||||
|
||||
function usableEnvironmentValue(
|
||||
value: string | undefined
|
||||
): value is string {
|
||||
return value !== undefined && value.length > 0 && !value.includes('\0')
|
||||
function usableEnvironmentValue(value: string | undefined): value is string {
|
||||
return value !== undefined && value.length > 0 && !value.includes("\0");
|
||||
}
|
||||
|
||||
function isStrictDescendant(parent: string, candidate: string): boolean {
|
||||
const child = relative(parent, candidate)
|
||||
const child = relative(parent, candidate);
|
||||
return (
|
||||
child.length > 0 &&
|
||||
child !== '..' &&
|
||||
child !== ".." &&
|
||||
!child.startsWith(`..${sep}`) &&
|
||||
!isAbsolute(child)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
+2914
-2744
File diff suppressed because it is too large
Load Diff
+1786
-1885
File diff suppressed because it is too large
Load Diff
+7226
-7659
File diff suppressed because it is too large
Load Diff
+6520
-6955
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user