agent-ecosystem/test/main/services/infrastructure/OpenCodeRuntimeInstallerService.test.ts

368 lines
13 KiB
TypeScript

import { createHash } from 'crypto';
import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises';
import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { gzipSync } from 'zlib';
const execCliMock = vi.hoisted(() => vi.fn());
const buildMergedCliPathMock = vi.hoisted(() => vi.fn());
const getCachedShellEnvMock = vi.hoisted(() => vi.fn());
const resolveInteractiveShellEnvBestEffortMock = vi.hoisted(() => vi.fn());
vi.mock('@main/utils/childProcess', () => ({
execCli: execCliMock,
}));
vi.mock('@main/utils/cliPathMerge', () => ({
buildMergedCliPath: () => buildMergedCliPathMock(),
}));
vi.mock('@main/utils/shellEnv', () => ({
getCachedShellEnv: () => getCachedShellEnvMock(),
resolveInteractiveShellEnvBestEffort: (
...args: Parameters<typeof resolveInteractiveShellEnvBestEffortMock>
) => resolveInteractiveShellEnvBestEffortMock(...args),
}));
import {
extractOpenCodeRuntimeBinaryFromTarball,
getOpenCodeRuntimePlatformCandidates,
OpenCodeRuntimeInstallerService,
resolveAppManagedOpenCodeRuntimeBinaryPath,
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath,
resolveVerifiedOpenCodeRuntimeBinaryPath,
verifyOpenCodeRuntimePackageIntegrity,
} from '@main/services/infrastructure/OpenCodeRuntimeInstallerService';
import { setAppDataBasePath } from '@main/utils/pathDecoder';
let tempRoot: string | null = null;
let originalPath: string | undefined;
function writeOctal(header: Buffer, offset: number, length: number, value: number): void {
const encoded = value
.toString(8)
.padStart(length - 1, '0')
.slice(-(length - 1));
header.write(`${encoded}\0`, offset, length, 'ascii');
}
function createTarEntry(name: string, data: Buffer): Buffer {
const header = Buffer.alloc(512);
header.write(name, 0, Math.min(Buffer.byteLength(name), 100), 'utf8');
writeOctal(header, 100, 8, 0o755);
writeOctal(header, 108, 8, 0);
writeOctal(header, 116, 8, 0);
writeOctal(header, 124, 12, data.length);
writeOctal(header, 136, 12, 0);
header.fill(' ', 148, 156);
header.write('0', 156, 1, 'ascii');
header.write('ustar\0', 257, 6, 'ascii');
header.write('00', 263, 2, 'ascii');
const checksum = header.reduce((sum, byte) => sum + byte, 0);
const checksumText = checksum.toString(8).padStart(6, '0');
header.write(`${checksumText}\0 `, 148, 8, 'ascii');
const padding = Buffer.alloc((512 - (data.length % 512)) % 512);
return Buffer.concat([header, data, padding]);
}
function createTarball(entries: { name: string; data: string }[]): Buffer {
return gzipSync(
Buffer.concat([
...entries.map((entry) => createTarEntry(entry.name, Buffer.from(entry.data))),
Buffer.alloc(1024),
])
);
}
describe('OpenCodeRuntimeInstallerService resolver', () => {
beforeEach(async () => {
tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-runtime-resolver-'));
setAppDataBasePath(tempRoot);
originalPath = process.env.PATH;
process.env.PATH = '';
execCliMock.mockReset();
execCliMock.mockResolvedValue({ stdout: 'opencode 1.0.0\n', stderr: '' });
buildMergedCliPathMock.mockReset();
buildMergedCliPathMock.mockReturnValue('');
getCachedShellEnvMock.mockReset();
getCachedShellEnvMock.mockReturnValue(null);
resolveInteractiveShellEnvBestEffortMock.mockReset();
resolveInteractiveShellEnvBestEffortMock.mockResolvedValue(process.env);
});
afterEach(async () => {
setAppDataBasePath(null);
if (originalPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = originalPath;
}
originalPath = undefined;
if (tempRoot) {
await rm(tempRoot, { recursive: true, force: true });
tempRoot = null;
}
});
it('returns the current app-managed OpenCode binary path only when manifest and binary exist', async () => {
const binaryPath = path.join(
tempRoot!,
'data',
'runtimes',
'opencode',
'versions',
'1.0.0',
'opencode-test',
'opencode'
);
const manifestPath = path.join(tempRoot!, 'data', 'runtimes', 'opencode', 'current.json');
await mkdir(path.dirname(binaryPath), { recursive: true });
await mkdir(path.dirname(manifestPath), { recursive: true });
await writeFile(binaryPath, 'binary', { mode: 0o755 });
await writeFile(
manifestPath,
`${JSON.stringify({
schemaVersion: 1,
version: '1.0.0',
platformPackage: 'opencode-test',
binaryPath,
integrity: 'sha512-test',
installedAt: '2026-05-12T00:00:00.000Z',
})}\n`,
'utf8'
);
expect(resolveAppManagedOpenCodeRuntimeBinaryPath()).toBe(binaryPath);
});
it('ignores a manifest whose binary path is missing', async () => {
const manifestPath = path.join(tempRoot!, 'data', 'runtimes', 'opencode', 'current.json');
await mkdir(path.dirname(manifestPath), { recursive: true });
await writeFile(
manifestPath,
`${JSON.stringify({
schemaVersion: 1,
version: '1.0.0',
platformPackage: 'opencode-test',
binaryPath: path.join(tempRoot!, 'missing-opencode'),
integrity: 'sha512-test',
installedAt: '2026-05-12T00:00:00.000Z',
})}\n`,
'utf8'
);
expect(resolveAppManagedOpenCodeRuntimeBinaryPath()).toBeNull();
});
it('returns the verified app-managed binary path only when --version succeeds', async () => {
const binaryPath = path.join(
tempRoot!,
'data',
'runtimes',
'opencode',
'versions',
'1.0.0',
'opencode-test',
'opencode'
);
const manifestPath = path.join(tempRoot!, 'data', 'runtimes', 'opencode', 'current.json');
await mkdir(path.dirname(binaryPath), { recursive: true });
await mkdir(path.dirname(manifestPath), { recursive: true });
await writeFile(binaryPath, 'binary', { mode: 0o755 });
await writeFile(
manifestPath,
`${JSON.stringify({
schemaVersion: 1,
version: '1.0.0',
platformPackage: 'opencode-test',
binaryPath,
integrity: 'sha512-test',
installedAt: '2026-05-12T00:00:00.000Z',
})}\n`,
'utf8'
);
await expect(resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath()).resolves.toBe(binaryPath);
expect(execCliMock).toHaveBeenCalledWith(binaryPath, ['--version'], {
timeout: 10_000,
windowsHide: true,
});
execCliMock.mockRejectedValueOnce(new Error('broken binary'));
await expect(resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath()).resolves.toBeNull();
});
it('returns a verified OpenCode binary from best-effort shell PATH when app-managed runtime is absent', async () => {
const binaryPath = path.join(tempRoot!, 'homebrew', 'bin', 'opencode');
await mkdir(path.dirname(binaryPath), { recursive: true });
await writeFile(binaryPath, 'binary', { mode: 0o755 });
resolveInteractiveShellEnvBestEffortMock.mockResolvedValue({
PATH: path.dirname(binaryPath),
});
await expect(resolveVerifiedOpenCodeRuntimeBinaryPath({ shellEnvTimeoutMs: 0 })).resolves.toBe(
binaryPath
);
expect(resolveInteractiveShellEnvBestEffortMock).toHaveBeenCalledWith(
expect.objectContaining({
timeoutMs: 0,
fallbackEnv: process.env,
})
);
expect(execCliMock).toHaveBeenCalledWith(binaryPath, ['--version'], {
timeout: 10_000,
windowsHide: true,
});
});
it('returns a verified OpenCode binary from the merged CLI PATH without interactive shell env resolution', async () => {
const binaryPath = path.join(tempRoot!, 'merged-cli-path', 'bin', 'opencode');
await mkdir(path.dirname(binaryPath), { recursive: true });
await writeFile(binaryPath, 'binary', { mode: 0o755 });
buildMergedCliPathMock.mockReturnValue(path.dirname(binaryPath));
await expect(resolveVerifiedOpenCodeRuntimeBinaryPath({ shellEnvTimeoutMs: 0 })).resolves.toBe(
binaryPath
);
expect(resolveInteractiveShellEnvBestEffortMock).not.toHaveBeenCalled();
expect(execCliMock).toHaveBeenCalledWith(binaryPath, ['--version'], {
timeout: 10_000,
windowsHide: true,
});
});
it('reports PATH-installed OpenCode as installed after best-effort shell env resolution', async () => {
const binaryPath = path.join(tempRoot!, 'homebrew', 'bin', 'opencode');
await mkdir(path.dirname(binaryPath), { recursive: true });
await writeFile(binaryPath, 'binary', { mode: 0o755 });
resolveInteractiveShellEnvBestEffortMock.mockResolvedValue({
PATH: path.dirname(binaryPath),
});
await expect(new OpenCodeRuntimeInstallerService().getStatus()).resolves.toMatchObject({
installed: true,
source: 'path',
state: 'ready',
binaryPath,
version: 'opencode 1.0.0',
});
});
it('prefers a working PATH OpenCode binary over a broken app-managed manifest', async () => {
const appManagedBinaryPath = path.join(
tempRoot!,
'data',
'runtimes',
'opencode',
'versions',
'1.0.0',
'opencode-test',
'opencode'
);
const pathBinaryPath = path.join(tempRoot!, 'homebrew', 'bin', 'opencode');
const manifestPath = path.join(tempRoot!, 'data', 'runtimes', 'opencode', 'current.json');
await mkdir(path.dirname(appManagedBinaryPath), { recursive: true });
await mkdir(path.dirname(pathBinaryPath), { recursive: true });
await mkdir(path.dirname(manifestPath), { recursive: true });
await writeFile(appManagedBinaryPath, 'broken binary', { mode: 0o755 });
await writeFile(pathBinaryPath, 'path binary', { mode: 0o755 });
await writeFile(
manifestPath,
`${JSON.stringify({
schemaVersion: 1,
version: '1.0.0',
platformPackage: 'opencode-test',
binaryPath: appManagedBinaryPath,
integrity: 'sha512-test',
installedAt: '2026-05-12T00:00:00.000Z',
})}\n`,
'utf8'
);
buildMergedCliPathMock.mockReturnValue(path.dirname(pathBinaryPath));
execCliMock.mockImplementation(async (binaryPath: string) => {
if (binaryPath === appManagedBinaryPath) {
throw new Error('broken app-managed runtime');
}
return { stdout: 'opencode 1.0.0\n', stderr: '' };
});
await expect(new OpenCodeRuntimeInstallerService().getStatus()).resolves.toMatchObject({
installed: true,
source: 'path',
state: 'ready',
binaryPath: pathBinaryPath,
version: 'opencode 1.0.0',
});
});
});
describe('OpenCodeRuntimeInstallerService package safety helpers', () => {
it('selects expected platform packages with Linux musl and baseline fallbacks', () => {
expect(
getOpenCodeRuntimePlatformCandidates('darwin', 'arm64', false).map((item) => item.packageName)
).toEqual(['opencode-darwin-arm64']);
expect(
getOpenCodeRuntimePlatformCandidates('darwin', 'x64', false).map((item) => item.packageName)
).toEqual(['opencode-darwin-x64', 'opencode-darwin-x64-baseline']);
expect(
getOpenCodeRuntimePlatformCandidates('linux', 'x64', false).map((item) => item.packageName)
).toEqual(['opencode-linux-x64', 'opencode-linux-x64-baseline', 'opencode-linux-x64-musl']);
expect(
getOpenCodeRuntimePlatformCandidates('linux', 'x64', true).map((item) => item.packageName)
).toEqual([
'opencode-linux-x64-musl',
'opencode-linux-x64-baseline-musl',
'opencode-linux-x64',
]);
expect(
getOpenCodeRuntimePlatformCandidates('linux', 'arm64', false).map((item) => item.packageName)
).toEqual(['opencode-linux-arm64', 'opencode-linux-arm64-musl']);
expect(
getOpenCodeRuntimePlatformCandidates('linux', 'arm64', true).map((item) => item.packageName)
).toEqual(['opencode-linux-arm64-musl', 'opencode-linux-arm64']);
expect(
getOpenCodeRuntimePlatformCandidates('win32', 'x64', false).map((item) => item.packageName)
).toEqual(['opencode-windows-x64', 'opencode-windows-x64-baseline']);
expect(
getOpenCodeRuntimePlatformCandidates('win32', 'arm64', false).map((item) => item.packageName)
).toEqual(['opencode-windows-arm64']);
});
it('fails npm integrity mismatches', () => {
const payload = Buffer.from('actual package');
const wrongHash = createHash('sha512').update('different package').digest('base64');
expect(() => verifyOpenCodeRuntimePackageIntegrity(payload, `sha512-${wrongHash}`)).toThrow(
'integrity check failed'
);
});
it('extracts only the expected OpenCode binary from the package tarball', () => {
const tarball = createTarball([
{ name: 'package/bin/not-opencode', data: 'wrong' },
{
name: process.platform === 'win32' ? 'package/bin/opencode.exe' : 'package/bin/opencode',
data: 'right',
},
]);
expect(extractOpenCodeRuntimeBinaryFromTarball(tarball).toString()).toBe('right');
});
it('rejects tar path traversal before extraction', () => {
const tarball = createTarball([
{ name: '../opencode', data: 'unsafe' },
{
name: process.platform === 'win32' ? 'package/bin/opencode.exe' : 'package/bin/opencode',
data: 'right',
},
]);
expect(() => extractOpenCodeRuntimeBinaryFromTarball(tarball)).toThrow(
'Unsafe OpenCode package tar entry'
);
});
});