* opencode-v2.test.mjs —— CLI 侧 OpenCode V1/V2 适配单测
*
* 覆盖:
* - parseBinVersion / getCliBinVersion 版本号解析
* - probeServeApi 版本感知探针(V1 /session JSON、V2 /api/info 401/200、
* Web 页面占用、无服务)
* - resolveServePassword(env 优先 → V2 服务注册文件 url 匹配,localhost 归一化)
* - checkPermissionConfig / fixPermissionConfig 版本感知权限检查与补写
* (opencode 2.x → permissions allow-all 数组;1.x / deveco → permission 字符串)
* - CliServeManager 连接信息传播(host/port/密码写入 process.env)
* 与 ensureRunning 复用 V2 服务(env 密码认证)/ 无密码失败指引
* - precheckEnv 集成(serve 检查识别 V2 需密码并阻断)
*
* 全部基于 Mock HTTP 服务与临时目录(XDG_CONFIG_HOME / XDG_STATE_HOME 隔离),
* 不依赖真实 opencode/deveco 安装,不拉起子进程。
*/
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const LIB = path.join(__dirname, '..', 'lib');
const {
probeServeApi,
resolveServePassword,
CliServeManager,
} = await import(path.join(LIB, 'opencode.js'));
const {
parseBinVersion,
checkPermissionConfig,
fixPermissionConfig,
precheckEnv,
} = await import(path.join(LIB, 'doctor.js'));
async function withEnv(overrides, fn) {
const saved = {};
for (const [k, v] of Object.entries(overrides)) {
saved[k] = process.env[k];
if (v === undefined) delete process.env[k];
else process.env[k] = String(v);
}
try {
return await fn();
} finally {
for (const [k, v] of Object.entries(saved)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
}
}
function tmpDir(prefix) {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
function listenMock(handler) {
return new Promise(resolve => {
const server = createServer(handler);
server.listen(0, '127.0.0.1', () => {
server.baseUrl = `http://127.0.0.1:${server.address().port}`;
resolve(server);
});
});
}
function closeMock(server) {
return new Promise(resolve => server.close(resolve));
}
function v1Handler(req, res) {
if (req.url === '/api/info') {
res.writeHead(404, { 'content-type': 'application/json' });
return res.end('{"error":"not found"}');
}
if (req.url === '/session') {
res.writeHead(200, { 'content-type': 'application/json' });
return res.end(JSON.stringify([{ id: 'ses_1', title: 'demo' }]));
}
res.writeHead(404); res.end();
}
function makeV2Handler(password) {
const expected = 'Basic ' + Buffer.from(`opencode:${password}`).toString('base64');
return (req, res) => {
if (req.url === '/api/info') {
if (req.headers.authorization === expected) {
res.writeHead(200, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ version: '2.0.26', name: 'opencode' }));
}
res.writeHead(401, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ _tag: 'UnauthorizedError', message: 'Authentication required' }));
}
res.writeHead(404); res.end();
};
}
function htmlHandler(req, res) {
res.writeHead(200, { 'content-type': 'text/html' });
res.end('<!doctype html><html><title>Web</title></html>');
}
test('parseBinVersion 兼容多种 --version 输出格式', () => {
assert.equal(parseBinVersion('opencode v2.0.26'), '2.0.26');
assert.equal(parseBinVersion('opencode 1.0.200'), '1.0.200');
assert.equal(parseBinVersion('0.1.12'), '0.1.12');
assert.equal(parseBinVersion('opencode v2.1.0-beta.3'), '2.1.0-beta.3');
assert.equal(parseBinVersion('no version here'), null);
assert.equal(parseBinVersion(''), null);
assert.equal(parseBinVersion(null), null);
});
test('probeServeApi: V1 服务(/session 返回 JSON)→ running/v1', async () => {
const server = await listenMock(v1Handler);
try {
const r = await probeServeApi(server.baseUrl, { timeoutMs: 1000 });
assert.equal(r.state, 'running');
assert.equal(r.apiVersion, 'v1');
assert.equal(r.authRequired, false);
} finally {
await closeMock(server);
}
});
test('probeServeApi: V2 服务无密码 → running/v2 且 authRequired', async () => {
const server = await listenMock(makeV2Handler('pw123'));
try {
const r = await probeServeApi(server.baseUrl, { timeoutMs: 1000 });
assert.equal(r.state, 'running');
assert.equal(r.apiVersion, 'v2');
assert.equal(r.authRequired, true);
assert.match(r.detail, /需服务密码/);
} finally {
await closeMock(server);
}
});
test('probeServeApi: V2 服务带正确密码 → running/v2 取到版本号', async () => {
const server = await listenMock(makeV2Handler('pw123'));
try {
const r = await probeServeApi(server.baseUrl, { password: 'pw123', timeoutMs: 1000 });
assert.equal(r.state, 'running');
assert.equal(r.apiVersion, 'v2');
assert.equal(r.authRequired, false);
assert.equal(r.version, '2.0.26');
} finally {
await closeMock(server);
}
});
test('probeServeApi: V2 服务密码错误 → authRequired(密码无效)', async () => {
const server = await listenMock(makeV2Handler('pw123'));
try {
const r = await probeServeApi(server.baseUrl, { password: 'wrong', timeoutMs: 1000 });
assert.equal(r.state, 'running');
assert.equal(r.apiVersion, 'v2');
assert.equal(r.authRequired, true);
assert.match(r.detail, /服务密码无效/);
} finally {
await closeMock(server);
}
});
test('probeServeApi: 端口被 Web 页面占用 → error', async () => {
const server = await listenMock(htmlHandler);
try {
const r = await probeServeApi(server.baseUrl, { timeoutMs: 1000 });
assert.equal(r.state, 'error');
assert.equal(r.apiVersion, null);
} finally {
await closeMock(server);
}
});
test('probeServeApi: 无服务 → down', async () => {
const server = await listenMock(() => {});
const port = server.address().port;
await closeMock(server);
const r = await probeServeApi(`http://127.0.0.1:${port}`, { timeoutMs: 500 });
assert.equal(r.state, 'down');
assert.equal(r.apiVersion, null);
});
test('resolveServePassword: OPENCODE_PASSWORD 环境变量优先', async () => {
await withEnv({ OPENCODE_PASSWORD: 'from-env' }, async () => {
assert.equal(resolveServePassword('http://127.0.0.1:4096'), 'from-env');
});
});
test('resolveServePassword: 注册文件 url 匹配时取其密码(localhost 归一化)', async () => {
const stateRoot = tmpDir('oc-state-');
fs.mkdirSync(path.join(stateRoot, 'opencode'), { recursive: true });
fs.writeFileSync(path.join(stateRoot, 'opencode', 'service.json'), JSON.stringify({
version: '2.0.26',
url: 'http://127.0.0.1:49374',
pid: 123,
password: 'reg-pw',
}));
await withEnv({ OPENCODE_PASSWORD: undefined, XDG_STATE_HOME: stateRoot }, async () => {
assert.equal(resolveServePassword('http://localhost:49374'), 'reg-pw');
assert.equal(resolveServePassword('http://127.0.0.1:49374'), 'reg-pw');
assert.equal(resolveServePassword('http://127.0.0.1:4096'), null);
});
});
test('resolveServePassword: 无 env 且无注册文件 → null', async () => {
const stateRoot = tmpDir('oc-state-empty-');
await withEnv({ OPENCODE_PASSWORD: undefined, XDG_STATE_HOME: stateRoot }, async () => {
assert.equal(resolveServePassword('http://127.0.0.1:4096'), null);
});
});
function writeCliConfig(configRoot, name, file, data) {
const dir = path.join(configRoot, name);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, file), JSON.stringify(data, null, 2));
}
test('权限检查: opencode 2.x 语义——缺 permissions 可补写,补写 allow-all 后通过', async () => {
const configRoot = tmpDir('oc-cfg-');
await withEnv({ XDG_CONFIG_HOME: configRoot }, async () => {
let r = checkPermissionConfig('opencode', { opencodeIsV2: true });
assert.equal(r.passed, false);
assert.equal(r.fixable, true);
assert.match(r.fix, /permissions/);
const fix = fixPermissionConfig({ opencodeIsV2: true });
assert.equal(fix.ok, true);
r = checkPermissionConfig('opencode', { opencodeIsV2: true });
assert.equal(r.passed, true);
const written = JSON.parse(fs.readFileSync(
path.join(configRoot, 'opencode', 'opencode.json'), 'utf8'));
assert.deepEqual(written.permissions, [
{ action: '*', resource: '*', effect: 'allow' },
]);
});
});
test('权限检查: opencode 2.x 已有自定义规则(无 allow-all)→ 不覆盖仅提示', async () => {
const configRoot = tmpDir('oc-cfg-');
writeCliConfig(configRoot, 'opencode', 'opencode.json', {
permissions: [{ action: 'shell', resource: 'git status *', effect: 'allow' }],
});
await withEnv({ XDG_CONFIG_HOME: configRoot }, async () => {
const r = checkPermissionConfig('opencode', { opencodeIsV2: true });
assert.equal(r.passed, false);
assert.match(r.detail, /无 allow-all/);
assert.equal(r.fixable, true);
const fix = fixPermissionConfig({ opencodeIsV2: true });
assert.equal(fix.ok, true);
const data = JSON.parse(fs.readFileSync(
path.join(configRoot, 'opencode', 'opencode.json'), 'utf8'));
assert.equal(data.permissions.length, 1);
assert.equal(data.permissions[0].action, 'shell');
assert.ok(fs.existsSync(path.join(configRoot, 'deveco', 'deveco.jsonc')));
});
});
test('权限检查: opencode 1.x 语义——permission 字符串(回归)', async () => {
const configRoot = tmpDir('oc-cfg-');
writeCliConfig(configRoot, 'opencode', 'opencode.json', { permission: 'allow' });
await withEnv({ XDG_CONFIG_HOME: configRoot }, async () => {
let r = checkPermissionConfig('opencode', { opencodeIsV2: false });
assert.equal(r.passed, true);
r = checkPermissionConfig('opencode', { opencodeIsV2: true });
assert.equal(r.passed, false);
assert.equal(r.fixable, true);
fixPermissionConfig({ opencodeIsV2: true });
const data = JSON.parse(fs.readFileSync(
path.join(configRoot, 'opencode', 'opencode.json'), 'utf8'));
assert.equal(data.permission, 'allow');
assert.deepEqual(data.permissions, [{ action: '*', resource: '*', effect: 'allow' }]);
});
});
test('权限检查: deveco 配置恒为 V1 语义(opencode 2.x 不影响 deveco 文件)', async () => {
const configRoot = tmpDir('oc-cfg-');
writeCliConfig(configRoot, 'deveco', 'deveco.jsonc', { permission: 'allow' });
await withEnv({ XDG_CONFIG_HOME: configRoot }, async () => {
const r = checkPermissionConfig('deveco-code', { opencodeIsV2: true });
assert.equal(r.passed, true);
});
});
test('CliServeManager: 连接信息传播(拉起场景密码覆盖 / 复用场景补缺)', async () => {
await withEnv({
OPENCODE_HOST: undefined, OPENCODE_PORT: undefined,
OPENCODE_PASSWORD: undefined, DEVECO_HOST: undefined, DEVECO_PORT: undefined,
}, async () => {
const mgr = new CliServeManager({ projectRoot: tmpDir('oc-proj-'), cliType: 'opencode' });
process.env.OPENCODE_PASSWORD = 'user-pw';
mgr.password = 'captured-pw';
mgr._propagateConnectionEnv();
assert.equal(process.env.OPENCODE_HOST, 'localhost');
assert.equal(process.env.OPENCODE_PORT, '4096');
assert.equal(process.env.OPENCODE_PASSWORD, 'user-pw');
mgr.spawnedByUs = true;
mgr._propagateConnectionEnv();
assert.equal(process.env.OPENCODE_PASSWORD, 'captured-pw');
});
});
test('CliServeManager: ensureRunning 复用运行中的 V2 服务(env 密码认证 + env 传播)', async () => {
const server = await listenMock(makeV2Handler('pw123'));
const stateRoot = tmpDir('oc-state-');
try {
await withEnv({
OPENCODE_HOST: '127.0.0.1',
OPENCODE_PORT: String(server.address().port),
OPENCODE_PASSWORD: 'pw123',
XDG_STATE_HOME: stateRoot,
}, async () => {
const mgr = new CliServeManager({ projectRoot: tmpDir('oc-proj-'), cliType: 'opencode' });
const r = await mgr.ensureRunning();
assert.equal(r.ok, true);
assert.equal(r.spawned, false);
assert.equal(mgr.apiVersion, 'v2');
assert.equal(mgr.password, 'pw123');
assert.match(r.message, /V2 API v2\.0\.26/);
assert.equal(process.env.OPENCODE_PORT, String(server.address().port));
assert.equal(process.env.OPENCODE_PASSWORD, 'pw123');
});
} finally {
await closeMock(server);
}
});
test('CliServeManager: ensureRunning 遇 V2 需密码且无法获取 → 失败并给指引(不拉起)', async () => {
const server = await listenMock(makeV2Handler('pw123'));
const stateRoot = tmpDir('oc-state-');
try {
await withEnv({
OPENCODE_HOST: '127.0.0.1',
OPENCODE_PORT: String(server.address().port),
OPENCODE_PASSWORD: undefined,
XDG_STATE_HOME: stateRoot,
}, async () => {
const mgr = new CliServeManager({ projectRoot: tmpDir('oc-proj-'), cliType: 'opencode' });
const r = await mgr.ensureRunning();
assert.equal(r.ok, false);
assert.equal(r.spawned, false);
assert.match(r.message, /需要密码/);
assert.ok(r.hint, '应给出处理指引');
assert.match(r.hint, /OPENCODE_PASSWORD/);
});
} finally {
await closeMock(server);
}
});
test('CliServeManager: checkHealth 对无服务端口返回 down(不拉起)', async () => {
const server = await listenMock(() => {});
const port = server.address().port;
await closeMock(server);
const mgr = new CliServeManager({
projectRoot: tmpDir('oc-proj-'), cliType: 'opencode', host: '127.0.0.1', port,
});
const r = await mgr.checkHealth();
assert.equal(r.ok, false);
assert.equal(r.reason, 'down');
});
test('precheckEnv: CLI_TYPE=opencode 时 serve 检查识别 V2 需密码并阻断', async () => {
const server = await listenMock(makeV2Handler('pw123'));
const configRoot = tmpDir('oc-cfg-');
const stateRoot = tmpDir('oc-state-');
const skillDir = tmpDir('oc-skill-');
const dataDir = tmpDir('oc-data-');
try {
await withEnv({
CLI_TYPE: 'opencode',
OPENCODE_HOST: '127.0.0.1',
OPENCODE_PORT: String(server.address().port),
OPENCODE_PASSWORD: undefined,
XDG_CONFIG_HOME: configRoot,
XDG_STATE_HOME: stateRoot,
GIT_TOKEN: 'dummy-token',
}, async () => {
const { ok, failures } = await precheckEnv({
projectRoot: tmpDir('oc-proj-'),
skillDir,
dataDir,
});
assert.equal(ok, false);
const serveFailure = failures.find(f => f.name === 'opencode serve 服务');
assert.ok(serveFailure, 'serve 检查失败应出现在 failures 中');
assert.match(serveFailure.detail, /V2 API/);
assert.match(serveFailure.fix, /OPENCODE_PASSWORD/);
});
} finally {
await closeMock(server);
}
});
test('precheckEnv: V1 serve 运行中(/session JSON)→ serve 检查通过', async () => {
const server = await listenMock(v1Handler);
const configRoot = tmpDir('oc-cfg-');
const stateRoot = tmpDir('oc-state-');
const skillDir = tmpDir('oc-skill-');
const dataDir = tmpDir('oc-data-');
try {
await withEnv({
CLI_TYPE: 'opencode',
OPENCODE_HOST: '127.0.0.1',
OPENCODE_PORT: String(server.address().port),
OPENCODE_PASSWORD: undefined,
XDG_CONFIG_HOME: configRoot,
XDG_STATE_HOME: stateRoot,
GIT_TOKEN: 'dummy-token',
}, async () => {
const { failures } = await precheckEnv({
projectRoot: tmpDir('oc-proj-'),
skillDir,
dataDir,
});
const serveFailure = failures.find(f => f.name === 'opencode serve 服务');
assert.equal(serveFailure, undefined);
});
} finally {
await closeMock(server);
}
});