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:
@@ -0,0 +1,149 @@
|
|||||||
|
const { existsSync } = require("node:fs");
|
||||||
|
const {
|
||||||
|
mkdirSync,
|
||||||
|
readFileSync,
|
||||||
|
renameSync,
|
||||||
|
rmSync,
|
||||||
|
statSync,
|
||||||
|
writeFileSync,
|
||||||
|
} = require("node:fs");
|
||||||
|
const { join } = require("node:path");
|
||||||
|
const { spawnSync } = require("node:child_process");
|
||||||
|
|
||||||
|
const opencodeVersion = "1.18.29";
|
||||||
|
const opencodePluginPackage = "@opencode-ai/plugin";
|
||||||
|
|
||||||
|
function npmInvocation() {
|
||||||
|
const npmCli = process.env.npm_execpath;
|
||||||
|
if (npmCli) {
|
||||||
|
return { command: process.execPath, prefixArgs: [npmCli] };
|
||||||
|
}
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
throw new Error("npm_execpath is required to prepare OpenCode config");
|
||||||
|
}
|
||||||
|
return { command: "npm", prefixArgs: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pluginIntegrity(projectDir) {
|
||||||
|
const lock = JSON.parse(
|
||||||
|
readFileSync(join(projectDir, "package-lock.json"), "utf8"),
|
||||||
|
);
|
||||||
|
const entry = lock.packages?.[`node_modules/${opencodePluginPackage}`];
|
||||||
|
if (
|
||||||
|
entry?.version !== opencodeVersion ||
|
||||||
|
typeof entry.integrity !== "string"
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Missing locked ${opencodePluginPackage}@${opencodeVersion} integrity`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return entry.integrity;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareBundledOpenCodeConfig(projectDir) {
|
||||||
|
const targetDirectory = join(
|
||||||
|
projectDir,
|
||||||
|
".runtime-resources",
|
||||||
|
"opencode-config",
|
||||||
|
);
|
||||||
|
const identity = {
|
||||||
|
packageName: opencodePluginPackage,
|
||||||
|
version: opencodeVersion,
|
||||||
|
integrity: pluginIntegrity(projectDir),
|
||||||
|
};
|
||||||
|
const markerPath = join(targetDirectory, ".goodbuddy-ready.json");
|
||||||
|
try {
|
||||||
|
const ready = JSON.parse(readFileSync(markerPath, "utf8"));
|
||||||
|
if (
|
||||||
|
ready.packageName === identity.packageName &&
|
||||||
|
ready.version === identity.version &&
|
||||||
|
ready.integrity === identity.integrity &&
|
||||||
|
statSync(
|
||||||
|
join(
|
||||||
|
targetDirectory,
|
||||||
|
"node_modules",
|
||||||
|
"@opencode-ai",
|
||||||
|
"plugin",
|
||||||
|
"package.json",
|
||||||
|
),
|
||||||
|
).isFile()
|
||||||
|
) {
|
||||||
|
return targetDirectory;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Rebuild an incomplete or stale offline config template.
|
||||||
|
}
|
||||||
|
|
||||||
|
const stagingDirectory = `${targetDirectory}.staging-${process.pid}`;
|
||||||
|
rmSync(stagingDirectory, { recursive: true, force: true });
|
||||||
|
mkdirSync(stagingDirectory, { recursive: true });
|
||||||
|
try {
|
||||||
|
writeFileSync(
|
||||||
|
join(stagingDirectory, "package.json"),
|
||||||
|
`${JSON.stringify(
|
||||||
|
{
|
||||||
|
private: true,
|
||||||
|
dependencies: {
|
||||||
|
[opencodePluginPackage]: opencodeVersion,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
writeFileSync(join(stagingDirectory, ".gitignore"), "node_modules\n");
|
||||||
|
const npm = npmInvocation();
|
||||||
|
const environment = { ...process.env };
|
||||||
|
delete environment.NODE_TLS_REJECT_UNAUTHORIZED;
|
||||||
|
const result = spawnSync(
|
||||||
|
npm.command,
|
||||||
|
[
|
||||||
|
...npm.prefixArgs,
|
||||||
|
"install",
|
||||||
|
"--ignore-scripts",
|
||||||
|
"--omit=dev",
|
||||||
|
"--omit=optional",
|
||||||
|
"--no-audit",
|
||||||
|
"--no-fund",
|
||||||
|
"--save-exact",
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: stagingDirectory,
|
||||||
|
encoding: "utf8",
|
||||||
|
env: environment,
|
||||||
|
maxBuffer: 16 * 1024 * 1024,
|
||||||
|
shell: false,
|
||||||
|
windowsHide: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Unable to prepare bundled OpenCode config: ${
|
||||||
|
result.error?.message ||
|
||||||
|
result.stderr ||
|
||||||
|
result.stdout ||
|
||||||
|
`npm exited with ${result.status}`
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
writeFileSync(
|
||||||
|
join(stagingDirectory, ".goodbuddy-ready.json"),
|
||||||
|
`${JSON.stringify(identity)}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
rmSync(targetDirectory, { recursive: true, force: true });
|
||||||
|
mkdirSync(join(targetDirectory, ".."), { recursive: true });
|
||||||
|
renameSync(stagingDirectory, targetDirectory);
|
||||||
|
return targetDirectory;
|
||||||
|
} finally {
|
||||||
|
if (existsSync(stagingDirectory)) {
|
||||||
|
rmSync(stagingDirectory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
opencodeVersion,
|
||||||
|
prepareBundledOpenCodeConfig,
|
||||||
|
};
|
||||||
+471
-546
File diff suppressed because it is too large
Load Diff
+110
-224
@@ -1,4 +1,4 @@
|
|||||||
const { createHash } = require('node:crypto')
|
const { createHash } = require("node:crypto");
|
||||||
const {
|
const {
|
||||||
chmod,
|
chmod,
|
||||||
mkdir,
|
mkdir,
|
||||||
@@ -6,102 +6,100 @@ const {
|
|||||||
rename,
|
rename,
|
||||||
rm,
|
rm,
|
||||||
stat,
|
stat,
|
||||||
writeFile
|
writeFile,
|
||||||
} = require('node:fs/promises')
|
} = require("node:fs/promises");
|
||||||
const { existsSync } = require('node:fs')
|
const { existsSync } = require("node:fs");
|
||||||
const { join } = require('node:path')
|
const { join } = require("node:path");
|
||||||
const { spawnSync } = require('node:child_process')
|
const { spawnSync } = require("node:child_process");
|
||||||
const tar = require('tar')
|
const tar = require("tar");
|
||||||
const { sha256File } = require('./file-hash.cjs')
|
const { sha256File } = require("./file-hash.cjs");
|
||||||
|
const {
|
||||||
|
opencodeVersion,
|
||||||
|
prepareBundledOpenCodeConfig,
|
||||||
|
} = require("./opencode-config.cjs");
|
||||||
|
|
||||||
const opencodeVersion = '1.18.29'
|
|
||||||
const opencodePluginPackage = '@opencode-ai/plugin'
|
|
||||||
const architectureNames = {
|
const architectureNames = {
|
||||||
1: 'x64',
|
1: "x64",
|
||||||
3: 'arm64'
|
3: "arm64",
|
||||||
}
|
};
|
||||||
const platformNames = {
|
const platformNames = {
|
||||||
darwin: 'darwin',
|
darwin: "darwin",
|
||||||
linux: 'linux',
|
linux: "linux",
|
||||||
win32: 'windows'
|
win32: "windows",
|
||||||
}
|
};
|
||||||
|
|
||||||
function sha512Integrity(contents) {
|
function sha512Integrity(contents) {
|
||||||
return `sha512-${createHash('sha512').update(contents).digest('base64')}`
|
return `sha512-${createHash("sha512").update(contents).digest("base64")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function lockedIntegrity(projectDir, packageName) {
|
async function lockedIntegrity(projectDir, packageName) {
|
||||||
const lock = JSON.parse(
|
const lock = JSON.parse(
|
||||||
await readFile(join(projectDir, 'package-lock.json'), 'utf8')
|
await readFile(join(projectDir, "package-lock.json"), "utf8"),
|
||||||
)
|
);
|
||||||
const entry = lock.packages?.[`node_modules/${packageName}`]
|
const entry = lock.packages?.[`node_modules/${packageName}`];
|
||||||
if (
|
if (
|
||||||
entry?.version !== opencodeVersion ||
|
entry?.version !== opencodeVersion ||
|
||||||
typeof entry.integrity !== 'string'
|
typeof entry.integrity !== "string"
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Missing locked ${packageName}@${opencodeVersion} integrity`
|
`Missing locked ${packageName}@${opencodeVersion} integrity`,
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
return entry.integrity
|
return entry.integrity;
|
||||||
}
|
}
|
||||||
|
|
||||||
function npmInvocation() {
|
function npmInvocation() {
|
||||||
const npmCli = process.env.npm_execpath
|
const npmCli = process.env.npm_execpath;
|
||||||
if (npmCli) {
|
if (npmCli) {
|
||||||
return {
|
return {
|
||||||
command: process.execPath,
|
command: process.execPath,
|
||||||
prefixArgs: [npmCli]
|
prefixArgs: [npmCli],
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === "win32") {
|
||||||
throw new Error('npm_execpath is required to prepare bundled runtimes')
|
throw new Error("npm_execpath is required to prepare bundled runtimes");
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
command: 'npm',
|
command: "npm",
|
||||||
prefixArgs: []
|
prefixArgs: [],
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadPackage(projectDir, packageName, integrity) {
|
async function downloadPackage(projectDir, packageName, integrity) {
|
||||||
const cacheDirectory = join(
|
const cacheDirectory = join(projectDir, ".runtime-resources", "cache");
|
||||||
projectDir,
|
await mkdir(cacheDirectory, { recursive: true });
|
||||||
'.runtime-resources',
|
|
||||||
'cache'
|
|
||||||
)
|
|
||||||
await mkdir(cacheDirectory, { recursive: true })
|
|
||||||
const archivePath = join(
|
const archivePath = join(
|
||||||
cacheDirectory,
|
cacheDirectory,
|
||||||
`${packageName}-${opencodeVersion}.tgz`
|
`${packageName}-${opencodeVersion}.tgz`,
|
||||||
)
|
);
|
||||||
if (existsSync(archivePath)) {
|
if (existsSync(archivePath)) {
|
||||||
const cached = await readFile(archivePath)
|
const cached = await readFile(archivePath);
|
||||||
if (sha512Integrity(cached) === integrity) {
|
if (sha512Integrity(cached) === integrity) {
|
||||||
return archivePath
|
return archivePath;
|
||||||
}
|
}
|
||||||
await rm(archivePath, { force: true })
|
await rm(archivePath, { force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const npm = npmInvocation()
|
const npm = npmInvocation();
|
||||||
const result = spawnSync(
|
const result = spawnSync(
|
||||||
npm.command,
|
npm.command,
|
||||||
[
|
[
|
||||||
...npm.prefixArgs,
|
...npm.prefixArgs,
|
||||||
'pack',
|
"pack",
|
||||||
`${packageName}@${opencodeVersion}`,
|
`${packageName}@${opencodeVersion}`,
|
||||||
'--ignore-scripts',
|
"--ignore-scripts",
|
||||||
'--json',
|
"--json",
|
||||||
'--pack-destination',
|
"--pack-destination",
|
||||||
cacheDirectory
|
cacheDirectory,
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
cwd: projectDir,
|
cwd: projectDir,
|
||||||
encoding: 'utf8',
|
encoding: "utf8",
|
||||||
maxBuffer: 16 * 1024 * 1024,
|
maxBuffer: 16 * 1024 * 1024,
|
||||||
shell: false,
|
shell: false,
|
||||||
windowsHide: true
|
windowsHide: true,
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
if (result.status !== 0) {
|
if (result.status !== 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Unable to fetch ${packageName}: ${
|
`Unable to fetch ${packageName}: ${
|
||||||
@@ -109,219 +107,107 @@ async function downloadPackage(projectDir, packageName, integrity) {
|
|||||||
result.stderr ||
|
result.stderr ||
|
||||||
result.stdout ||
|
result.stdout ||
|
||||||
`npm exited with ${result.status}`
|
`npm exited with ${result.status}`
|
||||||
}`
|
}`,
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
const output = JSON.parse(result.stdout)
|
const output = JSON.parse(result.stdout);
|
||||||
const downloadedPath = join(cacheDirectory, output[0].filename)
|
const downloadedPath = join(cacheDirectory, output[0].filename);
|
||||||
const contents = await readFile(downloadedPath)
|
const contents = await readFile(downloadedPath);
|
||||||
if (sha512Integrity(contents) !== integrity) {
|
if (sha512Integrity(contents) !== integrity) {
|
||||||
await rm(downloadedPath, { force: true })
|
await rm(downloadedPath, { force: true });
|
||||||
throw new Error(`Integrity verification failed for ${packageName}`)
|
throw new Error(`Integrity verification failed for ${packageName}`);
|
||||||
}
|
}
|
||||||
if (downloadedPath !== archivePath) {
|
if (downloadedPath !== archivePath) {
|
||||||
await rm(archivePath, { force: true })
|
await rm(archivePath, { force: true });
|
||||||
await rename(downloadedPath, archivePath)
|
await rename(downloadedPath, archivePath);
|
||||||
}
|
|
||||||
return archivePath
|
|
||||||
}
|
|
||||||
|
|
||||||
async function prepareBundledOpenCodeConfig(projectDir) {
|
|
||||||
const targetDirectory = join(
|
|
||||||
projectDir,
|
|
||||||
'.runtime-resources',
|
|
||||||
'opencode-config'
|
|
||||||
)
|
|
||||||
const pluginIntegrity = await lockedIntegrity(
|
|
||||||
projectDir,
|
|
||||||
opencodePluginPackage
|
|
||||||
)
|
|
||||||
const identity = {
|
|
||||||
packageName: opencodePluginPackage,
|
|
||||||
version: opencodeVersion,
|
|
||||||
integrity: pluginIntegrity
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const ready = JSON.parse(
|
|
||||||
await readFile(join(targetDirectory, '.goodbuddy-ready.json'), 'utf8')
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
ready.packageName === identity.packageName &&
|
|
||||||
ready.version === identity.version &&
|
|
||||||
ready.integrity === identity.integrity &&
|
|
||||||
(await stat(
|
|
||||||
join(
|
|
||||||
targetDirectory,
|
|
||||||
'node_modules',
|
|
||||||
'@opencode-ai',
|
|
||||||
'plugin',
|
|
||||||
'package.json'
|
|
||||||
)
|
|
||||||
)).isFile()
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Rebuild an incomplete or stale offline config template.
|
|
||||||
}
|
|
||||||
|
|
||||||
const stagingDirectory = `${targetDirectory}.staging-${process.pid}`
|
|
||||||
await rm(stagingDirectory, { recursive: true, force: true })
|
|
||||||
await mkdir(stagingDirectory, { recursive: true })
|
|
||||||
try {
|
|
||||||
await writeFile(
|
|
||||||
join(stagingDirectory, 'package.json'),
|
|
||||||
`${JSON.stringify(
|
|
||||||
{
|
|
||||||
private: true,
|
|
||||||
dependencies: {
|
|
||||||
[opencodePluginPackage]: opencodeVersion
|
|
||||||
}
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2
|
|
||||||
)}\n`,
|
|
||||||
'utf8'
|
|
||||||
)
|
|
||||||
const npm = npmInvocation()
|
|
||||||
const environment = { ...process.env }
|
|
||||||
delete environment.NODE_TLS_REJECT_UNAUTHORIZED
|
|
||||||
const result = spawnSync(
|
|
||||||
npm.command,
|
|
||||||
[
|
|
||||||
...npm.prefixArgs,
|
|
||||||
'install',
|
|
||||||
'--ignore-scripts',
|
|
||||||
'--omit=dev',
|
|
||||||
'--omit=optional',
|
|
||||||
'--no-audit',
|
|
||||||
'--no-fund',
|
|
||||||
'--save-exact'
|
|
||||||
],
|
|
||||||
{
|
|
||||||
cwd: stagingDirectory,
|
|
||||||
encoding: 'utf8',
|
|
||||||
env: environment,
|
|
||||||
maxBuffer: 16 * 1024 * 1024,
|
|
||||||
shell: false,
|
|
||||||
windowsHide: true
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if (result.status !== 0) {
|
|
||||||
throw new Error(
|
|
||||||
`Unable to prepare bundled OpenCode config: ${
|
|
||||||
result.error?.message ||
|
|
||||||
result.stderr ||
|
|
||||||
result.stdout ||
|
|
||||||
`npm exited with ${result.status}`
|
|
||||||
}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
await writeFile(
|
|
||||||
join(stagingDirectory, '.goodbuddy-ready.json'),
|
|
||||||
`${JSON.stringify(identity)}\n`,
|
|
||||||
'utf8'
|
|
||||||
)
|
|
||||||
await rm(targetDirectory, { recursive: true, force: true })
|
|
||||||
await rename(stagingDirectory, targetDirectory)
|
|
||||||
} finally {
|
|
||||||
await rm(stagingDirectory, { recursive: true, force: true })
|
|
||||||
}
|
}
|
||||||
|
return archivePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = async function prepareBundledRuntimes(context) {
|
module.exports = async function prepareBundledRuntimes(context) {
|
||||||
const platform = context.electronPlatformName
|
const platform = context.electronPlatformName;
|
||||||
const architecture = architectureNames[context.arch]
|
const architecture = architectureNames[context.arch];
|
||||||
const packagePlatform = platformNames[platform]
|
const packagePlatform = platformNames[platform];
|
||||||
if (!architecture || !packagePlatform) {
|
if (!architecture || !packagePlatform) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Bundled OpenCode does not support ${platform}/${context.arch}`
|
`Bundled OpenCode does not support ${platform}/${context.arch}`,
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const suffix =
|
const suffix =
|
||||||
architecture === 'x64' ? `${architecture}-baseline` : architecture
|
architecture === "x64" ? `${architecture}-baseline` : architecture;
|
||||||
const packageName = `opencode-${packagePlatform}-${suffix}`
|
const packageName = `opencode-${packagePlatform}-${suffix}`;
|
||||||
const projectDir = context.packager.projectDir
|
const projectDir = context.packager.projectDir;
|
||||||
await prepareBundledOpenCodeConfig(projectDir)
|
await prepareBundledOpenCodeConfig(projectDir);
|
||||||
const projectPackage = JSON.parse(
|
const projectPackage = JSON.parse(
|
||||||
await readFile(join(projectDir, 'package.json'), 'utf8')
|
await readFile(join(projectDir, "package.json"), "utf8"),
|
||||||
)
|
);
|
||||||
await writeFile(
|
await writeFile(
|
||||||
join(projectDir, 'out', 'main', 'package.json'),
|
join(projectDir, "out", "main", "package.json"),
|
||||||
`${JSON.stringify(
|
`${JSON.stringify(
|
||||||
{
|
{
|
||||||
name: '@deepseek-ai/dsh-llm',
|
name: "@deepseek-ai/dsh-llm",
|
||||||
version:
|
version: projectPackage.dependencies["@deepseek-ai/dsh-llm"],
|
||||||
projectPackage.dependencies['@deepseek-ai/dsh-llm'],
|
|
||||||
private: true,
|
private: true,
|
||||||
type: 'module'
|
type: "module",
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2
|
2,
|
||||||
)}\n`,
|
)}\n`,
|
||||||
'utf8'
|
"utf8",
|
||||||
)
|
);
|
||||||
const integrity = await lockedIntegrity(projectDir, packageName)
|
const integrity = await lockedIntegrity(projectDir, packageName);
|
||||||
const targetDirectory = join(
|
const targetDirectory = join(projectDir, ".runtime-resources", architecture);
|
||||||
projectDir,
|
const readyPath = join(targetDirectory, ".ready.json");
|
||||||
'.runtime-resources',
|
const executable = platform === "win32" ? "opencode.exe" : "opencode";
|
||||||
architecture
|
const preparedPath = join(targetDirectory, executable);
|
||||||
)
|
|
||||||
const readyPath = join(targetDirectory, '.ready.json')
|
|
||||||
const executable = platform === 'win32' ? 'opencode.exe' : 'opencode'
|
|
||||||
const preparedPath = join(targetDirectory, executable)
|
|
||||||
const identity = {
|
const identity = {
|
||||||
packageName,
|
packageName,
|
||||||
version: opencodeVersion,
|
version: opencodeVersion,
|
||||||
integrity
|
integrity,
|
||||||
}
|
};
|
||||||
try {
|
try {
|
||||||
const ready = JSON.parse(await readFile(readyPath, 'utf8'))
|
const ready = JSON.parse(await readFile(readyPath, "utf8"));
|
||||||
if (
|
if (
|
||||||
ready.packageName === identity.packageName &&
|
ready.packageName === identity.packageName &&
|
||||||
ready.version === identity.version &&
|
ready.version === identity.version &&
|
||||||
ready.integrity === identity.integrity &&
|
ready.integrity === identity.integrity &&
|
||||||
typeof ready.executableSha256 === 'string' &&
|
typeof ready.executableSha256 === "string" &&
|
||||||
(platform === 'win32' ||
|
(platform === "win32" ||
|
||||||
((await stat(preparedPath)).mode & 0o111) !== 0) &&
|
((await stat(preparedPath)).mode & 0o111) !== 0) &&
|
||||||
(await sha256File(preparedPath)) === ready.executableSha256
|
(await sha256File(preparedPath)) === ready.executableSha256
|
||||||
) {
|
) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Rebuild an incomplete or stale runtime cache.
|
// Rebuild an incomplete or stale runtime cache.
|
||||||
}
|
}
|
||||||
|
|
||||||
const archivePath = await downloadPackage(
|
const archivePath = await downloadPackage(projectDir, packageName, integrity);
|
||||||
projectDir,
|
const stagingDirectory = `${targetDirectory}.staging-${process.pid}`;
|
||||||
packageName,
|
await rm(stagingDirectory, { recursive: true, force: true });
|
||||||
integrity
|
await mkdir(stagingDirectory, { recursive: true });
|
||||||
)
|
|
||||||
const stagingDirectory = `${targetDirectory}.staging-${process.pid}`
|
|
||||||
await rm(stagingDirectory, { recursive: true, force: true })
|
|
||||||
await mkdir(stagingDirectory, { recursive: true })
|
|
||||||
try {
|
try {
|
||||||
await tar.x({
|
await tar.x({
|
||||||
file: archivePath,
|
file: archivePath,
|
||||||
cwd: stagingDirectory,
|
cwd: stagingDirectory,
|
||||||
strip: 1
|
strip: 1,
|
||||||
})
|
});
|
||||||
const sourcePath = join(stagingDirectory, 'bin', executable)
|
const sourcePath = join(stagingDirectory, "bin", executable);
|
||||||
const stagingExecutable = join(stagingDirectory, executable)
|
const stagingExecutable = join(stagingDirectory, executable);
|
||||||
await rename(sourcePath, stagingExecutable)
|
await rename(sourcePath, stagingExecutable);
|
||||||
if (platform !== 'win32') {
|
if (platform !== "win32") {
|
||||||
await chmod(stagingExecutable, 0o755)
|
await chmod(stagingExecutable, 0o755);
|
||||||
}
|
}
|
||||||
const executableSha256 = await sha256File(stagingExecutable)
|
const executableSha256 = await sha256File(stagingExecutable);
|
||||||
await writeFile(
|
await writeFile(
|
||||||
join(stagingDirectory, '.ready.json'),
|
join(stagingDirectory, ".ready.json"),
|
||||||
JSON.stringify({ ...identity, executableSha256 }),
|
JSON.stringify({ ...identity, executableSha256 }),
|
||||||
'utf8'
|
"utf8",
|
||||||
)
|
);
|
||||||
await rm(targetDirectory, { recursive: true, force: true })
|
await rm(targetDirectory, { recursive: true, force: true });
|
||||||
await rename(stagingDirectory, targetDirectory)
|
await rename(stagingDirectory, targetDirectory);
|
||||||
} finally {
|
} finally {
|
||||||
await rm(stagingDirectory, { recursive: true, force: true })
|
await rm(stagingDirectory, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| [通用助手工作栏与执行空间 PRD](./prd.md) | 工作栏整体产品范围、执行空间和各工作面板归属 |
|
| [通用助手工作栏与执行空间 PRD](./prd.md) | 工作栏整体产品范围、执行空间和各工作面板归属 |
|
||||||
| [多终端页签 PRD](./terminal-tabs-prd.md) | 多终端 Tab、目标选择、关闭确认和终端生命周期 |
|
| [多终端页签 PRD](./terminal-tabs-prd.md) | 多终端 Tab、目标选择、关闭确认和终端生命周期 |
|
||||||
| [实现与验证进度](./progress.md) | 工作区改动的验证证据与剩余阻塞 |
|
| [实现与验证进度](./progress.md) | 工作区与 Runtime 相关改动的实现事实、验证证据与剩余阻塞 |
|
||||||
|
|
||||||
当前尚未拆出独立 User Stories、功能逻辑、UI 设计和技术设计文档;相关内容仍保留在
|
当前尚未拆出独立 User Stories、功能逻辑、UI 设计和技术设计文档;相关内容仍保留在
|
||||||
两份现有 PRD 中,后续实际维护该功能时按职责拆分。
|
两份现有 PRD 中,后续实际维护该功能时按职责拆分。
|
||||||
|
|||||||
@@ -1,5 +1,23 @@
|
|||||||
# 工作栏实现与验证进度
|
# 工作栏实现与验证进度
|
||||||
|
|
||||||
|
## 2026-09-08 OpenCode 复用与原生控件恢复
|
||||||
|
|
||||||
|
- `OpenCodeRuntime` 复用缓存的 embedded server 前执行有超时的健康检查;健康则保留
|
||||||
|
client,检查失败、抛错或超时则清除 client、会话映射、会话初始化及待答问题,关闭旧
|
||||||
|
server 后重建。并发调用共享检查与重建,调用方取消只结束自身等待。
|
||||||
|
- 请求级 MCP 注册与断开通过实例内 `mutateMcp` 队列串行执行。仅实际发出 `mcp.add`
|
||||||
|
的名称进入清理列表;注册尚在排队时取消不追加断开。断开的 1 秒超时从出队后开始。
|
||||||
|
- `App` 按本机/远程、连接方式、项目和 Runtime 选择组成 scope 缓存原生清单,用请求
|
||||||
|
代次阻止旧 scope 结果回写;同 scope 会话刷新不清空重取,切换会话重置消息级选择,
|
||||||
|
Runtime 切换状态也按 generation 隔离。清单请求失败或返回 `unavailable`/`partial`
|
||||||
|
时保留已有可用缓存并有界重试;没有可用缓存时保留新返回的部分数据,因此
|
||||||
|
`unavailable` 重试为可用 `partial` 后可显示 Agent 和 Command。
|
||||||
|
|
||||||
|
验证记录:首轮定向测试 273 项通过、5 项跳过;补充修复后的 Runtime 与生命周期测试
|
||||||
|
77 项通过,界面定向测试 6 项通过,类型检查通过。修复前的 lint 检查通过。
|
||||||
|
全量测试超过 120 秒被终止,期间出现一项 DS 本地 stdio MCP 测试失败,尚待定位。
|
||||||
|
真实系统休眠恢复和当前源码的 GoodBuddy Agent 实机路径尚未完成验证。
|
||||||
|
|
||||||
## 2026-09-06 项目工作区
|
## 2026-09-06 项目工作区
|
||||||
|
|
||||||
范围见 [PRD 工作区](./prd.md#76-工作区)。
|
范围见 [PRD 工作区](./prd.md#76-工作区)。
|
||||||
|
|||||||
@@ -890,6 +890,26 @@ function credentialFreeHelperEnvironment(
|
|||||||
}
|
}
|
||||||
environment[name] = value
|
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
|
return environment
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { EventEmitter } from 'node:events'
|
||||||
import {
|
import {
|
||||||
chmodSync,
|
chmodSync,
|
||||||
existsSync,
|
existsSync,
|
||||||
@@ -21,8 +22,12 @@ import {
|
|||||||
import {
|
import {
|
||||||
createOpenCodeModelBridgeProviderConfig,
|
createOpenCodeModelBridgeProviderConfig,
|
||||||
MODEL_BRIDGE_SDK_AUTH_SENTINEL,
|
MODEL_BRIDGE_SDK_AUTH_SENTINEL,
|
||||||
ModelBridgeLoopbackProxy
|
ModelBridgeLoopbackProxy,
|
||||||
|
runOpenCodeModelBridgeHelper,
|
||||||
|
type ModelBridgeHelperSpawn
|
||||||
} from './model-bridge-helper'
|
} from './model-bridge-helper'
|
||||||
|
import { createOpenCodeLaunchProfile } from './opencode-runtime-profile'
|
||||||
|
import { createRuntimeBundleTestFixture } from './runtime-bundle-test-fixture'
|
||||||
import {
|
import {
|
||||||
createUnixModelBridgeExchange,
|
createUnixModelBridgeExchange,
|
||||||
MODEL_BRIDGE_BROKER_SOCKET_NAME,
|
MODEL_BRIDGE_BROKER_SOCKET_NAME,
|
||||||
@@ -51,6 +56,89 @@ afterEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('model bridge loopback helper', () => {
|
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 () => {
|
it('forwards only the bounded request contract and returns the response', async () => {
|
||||||
const exchange = vi.fn<ModelBridgeExchange>(
|
const exchange = vi.fn<ModelBridgeExchange>(
|
||||||
async () => validResponse
|
async () => validResponse
|
||||||
|
|||||||
@@ -1,230 +1,216 @@
|
|||||||
import {
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||||
dirname,
|
|
||||||
isAbsolute,
|
|
||||||
join,
|
|
||||||
relative,
|
|
||||||
resolve,
|
|
||||||
sep
|
|
||||||
} from 'node:path'
|
|
||||||
import {
|
import {
|
||||||
OPENCODE_REMOTE_RUNTIME_ENVIRONMENT_NAMES,
|
OPENCODE_REMOTE_RUNTIME_ENVIRONMENT_NAMES,
|
||||||
remoteRuntimeBundleManifestSchema,
|
remoteRuntimeBundleManifestSchema,
|
||||||
type RemoteRuntimeBundleManifest
|
type RemoteRuntimeBundleManifest,
|
||||||
} from '../shared/remote-runtime-launch-contracts'
|
} from "../shared/remote-runtime-launch-contracts";
|
||||||
import {
|
import {
|
||||||
modelBridgePolicySchema,
|
modelBridgePolicySchema,
|
||||||
type ModelBridgePolicy
|
type ModelBridgePolicy,
|
||||||
} from '../shared/model-bridge-contracts'
|
} from "../shared/model-bridge-contracts";
|
||||||
import {
|
import { assertAbsoluteManagedPath } from "./managed-paths";
|
||||||
assertAbsoluteManagedPath
|
|
||||||
} from './managed-paths'
|
|
||||||
|
|
||||||
const OPEN_CODE_ASK_CONFIG = JSON.stringify({
|
const OPEN_CODE_ASK_CONFIG = JSON.stringify({
|
||||||
permission: 'ask',
|
permission: "ask",
|
||||||
agent: {
|
agent: {
|
||||||
build: {
|
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 = {
|
export type OpenCodeLaunchProfile = {
|
||||||
executable: string
|
executable: string;
|
||||||
processExecutable: string
|
processExecutable: string;
|
||||||
args: readonly string[]
|
args: readonly string[];
|
||||||
cwd: string
|
cwd: string;
|
||||||
env: Readonly<NodeJS.ProcessEnv>
|
env: Readonly<NodeJS.ProcessEnv>;
|
||||||
workMode: 'ask' | 'execute'
|
workMode: "ask" | "execute";
|
||||||
}
|
};
|
||||||
|
|
||||||
export function createOpenCodeLaunchProfile(input: {
|
export function createOpenCodeLaunchProfile(input: {
|
||||||
manifest: RemoteRuntimeBundleManifest
|
manifest: RemoteRuntimeBundleManifest;
|
||||||
bundleDirectory: string
|
bundleDirectory: string;
|
||||||
workspaceDirectory: string
|
workspaceDirectory: string;
|
||||||
workMode: 'ask' | 'execute'
|
workMode: "ask" | "execute";
|
||||||
modelBridge?: {
|
modelBridge?: {
|
||||||
agentExecutablePath: string
|
agentExecutablePath: string;
|
||||||
bridgeDirectory: string
|
bridgeDirectory: string;
|
||||||
socketPath: string
|
socketPath: string;
|
||||||
policy: ModelBridgePolicy
|
policy: ModelBridgePolicy;
|
||||||
}
|
};
|
||||||
}): OpenCodeLaunchProfile {
|
}): OpenCodeLaunchProfile {
|
||||||
const manifest = remoteRuntimeBundleManifestSchema.parse(
|
const manifest = remoteRuntimeBundleManifestSchema.parse(input.manifest);
|
||||||
input.manifest
|
|
||||||
)
|
|
||||||
if (
|
if (
|
||||||
manifest.runtimeId !== 'opencode' ||
|
manifest.runtimeId !== "opencode" ||
|
||||||
manifest.provider !== 'opencode' ||
|
manifest.provider !== "opencode" ||
|
||||||
manifest.entrypoint.identity !== 'opencode-acp' ||
|
manifest.entrypoint.identity !== "opencode-acp" ||
|
||||||
manifest.entrypoint.argvPrefix.length !== 1 ||
|
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 (
|
if (
|
||||||
[...manifest.allowedEnvironmentNames].sort().join('\0') !==
|
[...manifest.allowedEnvironmentNames].sort().join("\0") !==
|
||||||
[...OPENCODE_REMOTE_RUNTIME_ENVIRONMENT_NAMES]
|
[...OPENCODE_REMOTE_RUNTIME_ENVIRONMENT_NAMES].sort().join("\0")
|
||||||
.sort()
|
|
||||||
.join('\0')
|
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
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(
|
const bundleDirectory = assertAbsoluteManagedPath(
|
||||||
resolve(input.bundleDirectory)
|
resolve(input.bundleDirectory),
|
||||||
)
|
);
|
||||||
const workspaceDirectory = normalizedAbsolutePath(
|
const workspaceDirectory = normalizedAbsolutePath(
|
||||||
input.workspaceDirectory,
|
input.workspaceDirectory,
|
||||||
'Workspace'
|
"Workspace",
|
||||||
)
|
);
|
||||||
const modelBridge =
|
const modelBridge =
|
||||||
input.modelBridge === undefined
|
input.modelBridge === undefined
|
||||||
? undefined
|
? undefined
|
||||||
: {
|
: {
|
||||||
agentExecutablePath: normalizedAbsolutePath(
|
agentExecutablePath: normalizedAbsolutePath(
|
||||||
input.modelBridge.agentExecutablePath,
|
input.modelBridge.agentExecutablePath,
|
||||||
'Agent executable'
|
"Agent executable",
|
||||||
),
|
),
|
||||||
agentInstallationDirectory: normalizedAbsolutePath(
|
agentInstallationDirectory: normalizedAbsolutePath(
|
||||||
dirname(input.modelBridge.agentExecutablePath),
|
dirname(input.modelBridge.agentExecutablePath),
|
||||||
'Agent installation'
|
"Agent installation",
|
||||||
),
|
),
|
||||||
bridgeDirectory: normalizedAbsolutePath(
|
bridgeDirectory: normalizedAbsolutePath(
|
||||||
input.modelBridge.bridgeDirectory,
|
input.modelBridge.bridgeDirectory,
|
||||||
'Model bridge'
|
"Model bridge",
|
||||||
),
|
),
|
||||||
socketPath: normalizedAbsolutePath(
|
socketPath: normalizedAbsolutePath(
|
||||||
input.modelBridge.socketPath,
|
input.modelBridge.socketPath,
|
||||||
'Model bridge socket'
|
"Model bridge socket",
|
||||||
),
|
),
|
||||||
policy: modelBridgePolicySchema.parse(
|
policy: modelBridgePolicySchema.parse(input.modelBridge.policy),
|
||||||
input.modelBridge.policy
|
};
|
||||||
)
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
modelBridge !== undefined &&
|
modelBridge !== undefined &&
|
||||||
(!isStrictDescendant(
|
(!isStrictDescendant(modelBridge.bridgeDirectory, modelBridge.socketPath) ||
|
||||||
modelBridge.bridgeDirectory,
|
|
||||||
modelBridge.socketPath
|
|
||||||
) ||
|
|
||||||
!isStrictDescendant(
|
!isStrictDescendant(
|
||||||
modelBridge.agentInstallationDirectory,
|
modelBridge.agentInstallationDirectory,
|
||||||
modelBridge.agentExecutablePath
|
modelBridge.agentExecutablePath,
|
||||||
))
|
))
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
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(
|
const executablePath = resolve(
|
||||||
bundleDirectory,
|
bundleDirectory,
|
||||||
...manifest.entrypoint.path.split('/')
|
...manifest.entrypoint.path.split("/"),
|
||||||
)
|
);
|
||||||
if (!isStrictDescendant(bundleDirectory, executablePath)) {
|
if (!isStrictDescendant(bundleDirectory, executablePath)) {
|
||||||
throw new Error('OpenCode entrypoint escapes its bundle directory')
|
throw new Error("OpenCode entrypoint escapes its bundle directory");
|
||||||
}
|
}
|
||||||
|
|
||||||
const runtimeCommand =
|
const runtimeCommand =
|
||||||
modelBridge === undefined
|
modelBridge === undefined
|
||||||
? {
|
? {
|
||||||
executable: executablePath,
|
executable: executablePath,
|
||||||
args: [...manifest.entrypoint.argvPrefix]
|
args: [...manifest.entrypoint.argvPrefix],
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
executable: join(
|
executable: join(modelBridge.agentInstallationDirectory, "node"),
|
||||||
modelBridge.agentInstallationDirectory,
|
|
||||||
'node'
|
|
||||||
),
|
|
||||||
args: [
|
args: [
|
||||||
join(
|
join(modelBridge.agentInstallationDirectory, "lib", "agent.cjs"),
|
||||||
modelBridge.agentInstallationDirectory,
|
"model-bridge-helper",
|
||||||
'lib',
|
"--socket-path",
|
||||||
'agent.cjs'
|
|
||||||
),
|
|
||||||
'model-bridge-helper',
|
|
||||||
'--socket-path',
|
|
||||||
modelBridge.socketPath,
|
modelBridge.socketPath,
|
||||||
'--protocol',
|
"--protocol",
|
||||||
modelBridge.policy.protocol,
|
modelBridge.policy.protocol,
|
||||||
'--model',
|
"--model",
|
||||||
modelBridge.policy.model,
|
modelBridge.policy.model,
|
||||||
'--supports-image-input',
|
"--supports-image-input",
|
||||||
modelBridge.policy.supportsImageInput ? 'true' : 'false',
|
modelBridge.policy.supportsImageInput ? "true" : "false",
|
||||||
'--work-mode',
|
"--work-mode",
|
||||||
input.workMode,
|
input.workMode,
|
||||||
'--opencode-entrypoint',
|
"--opencode-entrypoint",
|
||||||
executablePath
|
executablePath,
|
||||||
]
|
],
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const environmentBase = {
|
||||||
|
...executeEnvironment(process.env, workspaceDirectory),
|
||||||
|
...OPEN_CODE_RUNTIME_ENVIRONMENT,
|
||||||
|
OPENCODE_CONFIG_DIR: join(bundleDirectory, "config", "opencode"),
|
||||||
|
};
|
||||||
const environment =
|
const environment =
|
||||||
input.workMode === 'ask'
|
input.workMode === "ask"
|
||||||
? {
|
? {
|
||||||
...executeEnvironment(process.env, workspaceDirectory),
|
...environmentBase,
|
||||||
OPENCODE_CONFIG_CONTENT: OPEN_CODE_ASK_CONFIG
|
OPENCODE_CONFIG_CONTENT: OPEN_CODE_ASK_CONFIG,
|
||||||
}
|
}
|
||||||
: executeEnvironment(process.env, workspaceDirectory)
|
: environmentBase;
|
||||||
return {
|
return {
|
||||||
...runtimeCommand,
|
...runtimeCommand,
|
||||||
processExecutable: runtimeCommand.executable,
|
processExecutable: runtimeCommand.executable,
|
||||||
cwd: workspaceDirectory,
|
cwd: workspaceDirectory,
|
||||||
env: environment,
|
env: environment,
|
||||||
workMode: input.workMode
|
workMode: input.workMode,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizedAbsolutePath(value: string, label: string): string {
|
function normalizedAbsolutePath(value: string, label: string): string {
|
||||||
if (
|
if (value.includes("\0") || !isAbsolute(value) || resolve(value) !== value) {
|
||||||
value.includes('\0') ||
|
throw new Error(`${label} path must be a normalized absolute path`);
|
||||||
!isAbsolute(value) ||
|
|
||||||
resolve(value) !== value
|
|
||||||
) {
|
|
||||||
throw new Error(`${label} path must be a normalized absolute path`)
|
|
||||||
}
|
}
|
||||||
return value
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function executeEnvironment(
|
function executeEnvironment(
|
||||||
source: Readonly<NodeJS.ProcessEnv>,
|
source: Readonly<NodeJS.ProcessEnv>,
|
||||||
workspaceDirectory: string
|
workspaceDirectory: string,
|
||||||
): Readonly<NodeJS.ProcessEnv> {
|
): Readonly<NodeJS.ProcessEnv> {
|
||||||
const home = usableEnvironmentValue(source.HOME)
|
const home = usableEnvironmentValue(source.HOME)
|
||||||
? source.HOME!
|
? source.HOME!
|
||||||
: workspaceDirectory
|
: workspaceDirectory;
|
||||||
const defaults = {
|
const defaults = {
|
||||||
HOME: home,
|
HOME: home,
|
||||||
LANG: 'C.UTF-8',
|
LANG: "C.UTF-8",
|
||||||
LC_ALL: 'C.UTF-8',
|
LC_ALL: "C.UTF-8",
|
||||||
PATH: '/usr/bin:/bin',
|
PATH: "/usr/bin:/bin",
|
||||||
TMPDIR: '/tmp',
|
TMPDIR: "/tmp",
|
||||||
XDG_CACHE_HOME: join(home, '.cache'),
|
XDG_CACHE_HOME: join(home, ".cache"),
|
||||||
XDG_CONFIG_HOME: join(home, '.config'),
|
XDG_CONFIG_HOME: join(home, ".config"),
|
||||||
XDG_DATA_HOME: join(home, '.local', 'share'),
|
XDG_DATA_HOME: join(home, ".local", "share"),
|
||||||
XDG_STATE_HOME: join(home, '.local', 'state')
|
XDG_STATE_HOME: join(home, ".local", "state"),
|
||||||
} as const
|
} as const;
|
||||||
const environment: NodeJS.ProcessEnv = { ...source }
|
const environment: NodeJS.ProcessEnv = { ...source };
|
||||||
for (const [name, fallback] of Object.entries(defaults)) {
|
for (const [name, fallback] of Object.entries(defaults)) {
|
||||||
if (!usableEnvironmentValue(environment[name])) {
|
if (!usableEnvironmentValue(environment[name])) {
|
||||||
environment[name] = fallback
|
environment[name] = fallback;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return environment
|
return environment;
|
||||||
}
|
}
|
||||||
|
|
||||||
function usableEnvironmentValue(
|
function usableEnvironmentValue(value: string | undefined): value is string {
|
||||||
value: string | undefined
|
return value !== undefined && value.length > 0 && !value.includes("\0");
|
||||||
): value is string {
|
|
||||||
return value !== undefined && value.length > 0 && !value.includes('\0')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isStrictDescendant(parent: string, candidate: string): boolean {
|
function isStrictDescendant(parent: string, candidate: string): boolean {
|
||||||
const child = relative(parent, candidate)
|
const child = relative(parent, candidate);
|
||||||
return (
|
return (
|
||||||
child.length > 0 &&
|
child.length > 0 &&
|
||||||
child !== '..' &&
|
child !== ".." &&
|
||||||
!child.startsWith(`..${sep}`) &&
|
!child.startsWith(`..${sep}`) &&
|
||||||
!isAbsolute(child)
|
!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