Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ FROM base AS runner
# an arm64 host builds the amd64 platform; 0.12.7 is the newest verified-good
# release, so re-verify a bump there before moving it.
ARG UV_VERSION=0.12.7
ARG OPENCODE_VERSION=2.2.1
ARG OPENCODE_VERSION=2.2.5
ARG OPENCODE_MIN_VERSION=2.2.5
ARG MICROSANDBOX_VERSION=0.7.2
ARG PLAYWRIGHT_VERSION=1.63.0
# Bump TOOLS_CACHEBUST (e.g. via --build-arg) to force a fresh uv/opencode
Expand All @@ -79,6 +80,8 @@ RUN echo "Installing uv=${UV_VERSION} opencode=${OPENCODE_VERSION} (cachebust=${
chmod +x /usr/local/bin/uv /usr/local/bin/uvx && \
test "$(uv --version | cut -d' ' -f2)" = "${UV_VERSION}" && \
. /usr/local/lib/ocm/opencode-release.sh && \
OPENCODE_SUPPORTED_FLOOR="${OPENCODE_MIN_VERSION}" && \
{ is_supported_opencode_version "${OPENCODE_VERSION}" || { echo "OPENCODE_VERSION ${OPENCODE_VERSION} is outside the supported range $(supported_opencode_range)" >&2; exit 1; }; } && \
echo "Downloading opencode ${OPENCODE_VERSION}..." && \
download_opencode_to "${OPENCODE_VERSION}" /opt/opencode/bin/opencode && \
ln -s /opt/opencode/bin/opencode /usr/local/bin/opencode && \
Expand Down Expand Up @@ -120,6 +123,7 @@ ENV DATABASE_PATH=/app/data/opencode.db
ENV WORKSPACE_PATH=/workspace
ENV XDG_CACHE_HOME=/home/node/.cache
ENV OPENCODE_BUNDLED_VERSION=${OPENCODE_VERSION}
ENV OPENCODE_MIN_VERSION=${OPENCODE_MIN_VERSION}
ENV MSB_PATH=/usr/local/bin/msb
ENV MSB_LIBKRUNFW_PATH=/opt/microsandbox/lib/libkrunfw.so
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
Expand Down
12 changes: 6 additions & 6 deletions backend/src/services/opencode-server-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,15 +449,15 @@ describe('OpenCodeServerRegistry', () => {

describe('test', () => {
it('reports a supported version', async () => {
fetchFn.mockResolvedValueOnce(jsonResponse({ version: '2.2.1' }))
fetchFn.mockResolvedValueOnce(jsonResponse({ version: '2.2.5' }))

const result = await registry.test({
baseUrl: 'https://studio.example.com/',
username: 'alice',
password: 'super-secret',
})

expect(result).toEqual({ ok: true, version: '2.2.1', versionSupported: true, error: null })
expect(result).toEqual({ ok: true, version: '2.2.5', versionSupported: true, error: null })
expect(fetchFn.mock.calls[0]?.[0]).toBe('https://studio.example.com/api/info')
const headers = new Headers(fetchFn.mock.calls[0]?.[1]?.headers)
expect(headers.get('authorization')).toBe(`Basic ${btoa('alice:super-secret')}`)
Expand All @@ -478,7 +478,7 @@ describe('OpenCodeServerRegistry', () => {
username: 'alice',
password: 'super-secret',
})
fetchFn.mockResolvedValueOnce(jsonResponse({ version: '2.2.1' }))
fetchFn.mockResolvedValueOnce(jsonResponse({ version: '2.2.5' }))

const result = await registry.test({ serverId: server.id })

Expand All @@ -493,7 +493,7 @@ describe('OpenCodeServerRegistry', () => {
baseUrl: 'https://studio.example.com',
password: 'super-secret',
})
fetchFn.mockResolvedValueOnce(jsonResponse({ version: '2.2.1' }))
fetchFn.mockResolvedValueOnce(jsonResponse({ version: '2.2.5' }))

const result = await registry.test({
baseUrl: 'https://studio.example.com/opencode',
Expand All @@ -513,7 +513,7 @@ describe('OpenCodeServerRegistry', () => {
baseUrl: 'https://studio.example.com',
password: 'super-secret',
})
fetchFn.mockResolvedValueOnce(jsonResponse({ version: '2.2.1' }))
fetchFn.mockResolvedValueOnce(jsonResponse({ version: '2.2.5' }))

const result = await registry.test({
baseUrl: 'https://relocated.example.com',
Expand All @@ -533,7 +533,7 @@ describe('OpenCodeServerRegistry', () => {
baseUrl: 'https://studio.example.com',
password: 'super-secret',
})
fetchFn.mockResolvedValueOnce(jsonResponse({ version: '2.2.1' }))
fetchFn.mockResolvedValueOnce(jsonResponse({ version: '2.2.5' }))

await registry.test({
baseUrl: 'https://studio.example.com',
Expand Down
2 changes: 1 addition & 1 deletion backend/test/helpers/opencode-binary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ vi.mock('child_process', () => ({
spawn: childProcessMock.spawn,
}))

const SUPPORTED_VERSION = '2.2.1'
const SUPPORTED_VERSION = '2.2.5'
const OPENCODE_BIN_FIXTURE = '/fixture/opencode-2'
const PATH_CANDIDATE = 'opencode'
const ABSOLUTE_CANDIDATES = ['/usr/local/bin/opencode', '/opt/opencode/bin/opencode']
Expand Down
106 changes: 53 additions & 53 deletions backend/test/routes/settings.test.ts

Large diffs are not rendered by default.

15 changes: 14 additions & 1 deletion backend/test/scripts/docker-entrypoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ const runOpenCodeSection = (snippet: string, env: Record<string, string> = {}) =
OCM_STUB_RUNUSER_EXEC: '1',
HOME: homeDir,
OPENCODE_BUNDLED_VERSION: '2.0.15',
OPENCODE_MIN_VERSION: '2.0.15',
...env,
},
})
Expand Down Expand Up @@ -459,16 +460,28 @@ exit 1`)
expect(curlLog()).toHaveLength(0)
})

it('treats every persisted binary as unsupported when the bundled version is unknown', () => {
it('treats every persisted binary as unsupported when neither the minimum nor the bundled version is known', () => {
mkdirSync(join(stubDir, 'home/.opencode/bin'), { recursive: true })
writeBinary(join(stubDir, 'home/.opencode/bin'), '2.0.15')
const res = runOpenCodeSection(`${installPrelude()}\nreconcile_persisted_opencode`, {
OPENCODE_MIN_VERSION: '',
OPENCODE_BUNDLED_VERSION: '',
})
expect(res.status).toBe(0)
expect(existsSync(homeBinPath())).toBe(false)
})

it('falls back to the bundled version as the floor when the minimum is unset', () => {
mkdirSync(join(stubDir, 'home/.opencode/bin'), { recursive: true })
writeBinary(join(stubDir, 'home/.opencode/bin'), '2.0.15')
const res = runOpenCodeSection(`${installPrelude()}\nreconcile_persisted_opencode`, {
OPENCODE_MIN_VERSION: '',
})
expect(res.status).toBe(0)
expect(res.stdout).toContain('Persisted OpenCode 2.0.15 is usable; retaining it')
expect(existsSync(homeBinPath())).toBe(true)
})

it('removes a malformed persisted home binary and selects the bundled binary without downloading', () => {
mkdirSync(join(stubDir, 'home/.opencode/bin'), { recursive: true })
writeBinary(join(stubDir, 'home/.opencode/bin'), 'garbage')
Expand Down
66 changes: 33 additions & 33 deletions backend/test/services/opencode-installer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,65 +131,65 @@ describe('resolveOpenCodeInstallTarget', () => {
describe('installOpenCodeVersion', () => {
it('downloads, verifies and installs the requested version', async () => {
const homeDirectory = join(workDirectory, 'home')
const archivePath = createArchive(workDirectory, '2.2.1')
const archivePath = createArchive(workDirectory, '2.2.5')
const { calls, fetchFn } = createFetch(archivePath)

const installedPath = await installOpenCodeVersion('2.2.1', { fetch: fetchFn, homeDirectory, target: linuxTarget })
const installedPath = await installOpenCodeVersion('2.2.5', { fetch: fetchFn, homeDirectory, target: linuxTarget })

expect(installedPath).toBe(resolveOpenCodeBinaryPath(homeDirectory))
expect(calls).toEqual(['https://opencode.ai/files/bin/2.2.1/opencode-linux-x64.tar.gz'])
expect(calls).toEqual(['https://opencode.ai/files/bin/2.2.5/opencode-linux-x64.tar.gz'])
expect(statSync(installedPath).mode & 0o777).toBe(0o755)
expect(readFileSync(installedPath, 'utf-8')).toContain('2.2.1')
expect(readFileSync(installedPath, 'utf-8')).toContain('2.2.5')
})

it('requests the musl asset for a musl linux target', async () => {
const homeDirectory = join(workDirectory, 'home')
const archivePath = createArchive(workDirectory, '2.2.1')
const archivePath = createArchive(workDirectory, '2.2.5')
const { calls, fetchFn } = createFetch(archivePath)

await installOpenCodeVersion('2.2.1', {
await installOpenCodeVersion('2.2.5', {
fetch: fetchFn,
homeDirectory,
target: { platform: 'linux', arch: 'arm64', musl: true },
})

expect(calls).toEqual(['https://opencode.ai/files/bin/2.2.1/opencode-linux-arm64-musl.tar.gz'])
expect(calls).toEqual(['https://opencode.ai/files/bin/2.2.5/opencode-linux-arm64-musl.tar.gz'])
})

it('extracts the darwin zip asset and installs it', async () => {
const homeDirectory = join(workDirectory, 'home')
const archivePath = createDarwinArchive(workDirectory, '2.2.1')
const archivePath = createDarwinArchive(workDirectory, '2.2.5')
const { calls, fetchFn } = createFetch(archivePath)

const installedPath = await installOpenCodeVersion('2.2.1', {
const installedPath = await installOpenCodeVersion('2.2.5', {
fetch: fetchFn,
homeDirectory,
target: { platform: 'darwin', arch: 'arm64', musl: false },
})

expect(calls).toEqual(['https://opencode.ai/files/bin/2.2.1/opencode-darwin-arm64.zip'])
expect(calls).toEqual(['https://opencode.ai/files/bin/2.2.5/opencode-darwin-arm64.zip'])
expect(installedPath).toBe(resolveOpenCodeBinaryPath(homeDirectory))
expect(statSync(installedPath).mode & 0o777).toBe(0o755)
expect(readFileSync(installedPath, 'utf-8')).toContain('2.2.1')
expect(readFileSync(installedPath, 'utf-8')).toContain('2.2.5')
})

it('rejects an OpenCode 1.x version before downloading anything', async () => {
const homeDirectory = join(workDirectory, 'home')
const { calls, fetchFn } = createFetch(null)

await expect(installOpenCodeVersion('1.18.32', { fetch: fetchFn, homeDirectory, target: linuxTarget }))
.rejects.toThrow('OpenCode 1.18.32 is not supported; OpenCode Manager requires OpenCode >=2.2.1 <3.0.0')
.rejects.toThrow('OpenCode 1.18.32 is not supported; OpenCode Manager requires OpenCode >=2.2.5 <3.0.0')
expect(calls).toEqual([])
})

it.each(['2.2.0', '3.0.0', '2.2.1-beta.1', '2.2.1; rm -rf /'])(
it.each(['2.2.4', '3.0.0', '2.2.5-beta.1', '2.2.5; rm -rf /'])(
'rejects %s outside the supported stable range before downloading anything',
async (version) => {
const homeDirectory = join(workDirectory, 'home')
const { calls, fetchFn } = createFetch(null)

await expect(installOpenCodeVersion(version, { fetch: fetchFn, homeDirectory, target: linuxTarget }))
.rejects.toThrow(/requires OpenCode >=2\.2\.1 <3\.0\.0/)
.rejects.toThrow(/requires OpenCode >=2\.2\.5 <3\.0\.0/)
expect(calls).toEqual([])
},
)
Expand All @@ -198,18 +198,18 @@ describe('installOpenCodeVersion', () => {
const homeDirectory = join(workDirectory, 'home')
const { fetchFn } = createFetch(null, 404)

await expect(installOpenCodeVersion('2.2.1', { fetch: fetchFn, homeDirectory, target: linuxTarget }))
await expect(installOpenCodeVersion('2.2.5', { fetch: fetchFn, homeDirectory, target: linuxTarget }))
.rejects.toThrow(/404/)
expect(() => statSync(resolveOpenCodeBinaryPath(homeDirectory))).toThrow()
})

it('fails and leaves no binary behind when the archive reports a different version', async () => {
const homeDirectory = join(workDirectory, 'home')
const archivePath = createArchive(workDirectory, '2.2.2')
const archivePath = createArchive(workDirectory, '2.2.6')
const { fetchFn } = createFetch(archivePath)

await expect(installOpenCodeVersion('2.2.1', { fetch: fetchFn, homeDirectory, target: linuxTarget }))
.rejects.toThrow(/2\.2\.1/)
await expect(installOpenCodeVersion('2.2.5', { fetch: fetchFn, homeDirectory, target: linuxTarget }))
.rejects.toThrow(/2\.2\.5/)
expect(() => statSync(resolveOpenCodeBinaryPath(homeDirectory))).toThrow()
})

Expand All @@ -218,21 +218,21 @@ describe('installOpenCodeVersion', () => {
mkdirSync(join(homeDirectory, '.opencode', 'bin'), { recursive: true })
writeFileSync(resolveOpenCodeBinaryPath(homeDirectory), '#!/bin/sh\necho "opencode v2.1.1"\n')

const archivePath = createArchive(workDirectory, '2.2.1')
const archivePath = createArchive(workDirectory, '2.2.5')
const { fetchFn } = createFetch(archivePath)

await installOpenCodeVersion('2.2.1', { fetch: fetchFn, homeDirectory, target: linuxTarget })
await installOpenCodeVersion('2.2.5', { fetch: fetchFn, homeDirectory, target: linuxTarget })

expect(readFileSync(resolveOpenCodeBinaryPath(homeDirectory), 'utf-8')).toContain('2.2.1')
expect(readFileSync(resolveOpenCodeBinaryPath(homeDirectory), 'utf-8')).toContain('2.2.5')
})

it('cleans up its staging directory after a successful install', async () => {
const homeDirectory = join(workDirectory, 'home')
const archivePath = createArchive(workDirectory, '2.2.1')
const archivePath = createArchive(workDirectory, '2.2.5')
const { fetchFn } = createFetch(archivePath)

const before = countStagingDirectories()
await installOpenCodeVersion('2.2.1', { fetch: fetchFn, homeDirectory, target: linuxTarget })
await installOpenCodeVersion('2.2.5', { fetch: fetchFn, homeDirectory, target: linuxTarget })

expect(countStagingDirectories()).toBe(before)
})
Expand All @@ -244,15 +244,15 @@ describe('listOpenCodeVersions', () => {
'1.18.32': {},
'2.0.14': {},
'2.1.1': {},
'2.2.1': {},
'2.2.2': {},
'2.2.1-beta.1': {},
'2.2.5': {},
'2.2.6': {},
'2.2.5-beta.1': {},
'3.0.0': {},
'not-a-version': {},
},
time: {
'2.2.1': '2026-02-01T00:00:00.000Z',
'2.2.2': '2026-03-01T00:00:00.000Z',
'2.2.5': '2026-02-01T00:00:00.000Z',
'2.2.6': '2026-03-01T00:00:00.000Z',
},
}

Expand All @@ -262,8 +262,8 @@ describe('listOpenCodeVersions', () => {
const versions = await listOpenCodeVersions({ fetch: fetchFn })

expect(versions).toEqual([
{ version: '2.2.2', publishedAt: '2026-03-01T00:00:00.000Z' },
{ version: '2.2.1', publishedAt: '2026-02-01T00:00:00.000Z' },
{ version: '2.2.6', publishedAt: '2026-03-01T00:00:00.000Z' },
{ version: '2.2.5', publishedAt: '2026-02-01T00:00:00.000Z' },
])
expect(calls).toEqual(['https://registry.npmjs.org/@opencode/cli'])
})
Expand Down Expand Up @@ -331,7 +331,7 @@ describe('OpenCodeInstallError swap signalling', () => {
const homeDirectory = join(workDirectory, 'home')
const { fetchFn } = createFetch(null, 404)

const promise = installOpenCodeVersion('2.2.1', { fetch: fetchFn, homeDirectory, target: linuxTarget })
const promise = installOpenCodeVersion('2.2.5', { fetch: fetchFn, homeDirectory, target: linuxTarget })

await expect(promise).rejects.toBeInstanceOf(OpenCodeInstallError)
await expect(promise).rejects.toMatchObject({ swapStarted: false })
Expand All @@ -341,10 +341,10 @@ describe('OpenCodeInstallError swap signalling', () => {
it('marks a failure while replacing the binary as having started the binary swap', async () => {
const homeDirectory = join(workDirectory, 'home')
mkdirSync(resolveOpenCodeBinaryPath(homeDirectory), { recursive: true })
const archivePath = createArchive(workDirectory, '2.2.1')
const archivePath = createArchive(workDirectory, '2.2.5')
const { fetchFn } = createFetch(archivePath)

const promise = installOpenCodeVersion('2.2.1', { fetch: fetchFn, homeDirectory, target: linuxTarget })
const promise = installOpenCodeVersion('2.2.5', { fetch: fetchFn, homeDirectory, target: linuxTarget })

await expect(promise).rejects.toBeInstanceOf(OpenCodeInstallError)
await expect(promise).rejects.toMatchObject({ swapStarted: true })
Expand Down
Loading