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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ jobs:
- name: Install OpenCode CLI
run: |
source scripts/lib/opencode-release.sh
download_opencode_to 2.0.24 "$RUNNER_TEMP/opencode/bin/opencode"
download_opencode_to 2.1.2 "$RUNNER_TEMP/opencode/bin/opencode"
echo "$RUNNER_TEMP/opencode/bin" >> "$GITHUB_PATH"

- name: Verify OpenCode CLI
Expand Down
5 changes: 3 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ RUN apt-get update && apt-get install -y \
python3 \
python3-pip \
python3-venv \
tini \
&& rm -rf /var/lib/apt/lists/*

RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
Expand Down Expand Up @@ -62,7 +63,7 @@ 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.0.15
ARG OPENCODE_VERSION=2.1.2
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 Down Expand Up @@ -155,5 +156,5 @@ EXPOSE 5003 5004 5100 5101 5102 5103
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD curl -f http://localhost:5003/api/health || exit 1

ENTRYPOINT ["/docker-entrypoint.sh"]
ENTRYPOINT ["/usr/bin/tini", "--", "/docker-entrypoint.sh"]
CMD ["bun", "backend/src/index.ts"]
21 changes: 21 additions & 0 deletions backend/src/auth/client-ip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { Context } from 'hono'
import { getConnInfo } from '@hono/node-server/conninfo'

export const AUTH_SOCKET_IP_HEADER = 'x-ocm-auth-socket-ip'

export function resolveSocketIp(c: Context): string | undefined {
try {
return getConnInfo(c).remote.address ?? undefined
} catch {
return undefined
}
}

export function applyClientIpHeaders(headers: Headers, socketIp: string | undefined): Headers {
headers.delete(AUTH_SOCKET_IP_HEADER)
if (socketIp) {
headers.set(AUTH_SOCKET_IP_HEADER, socketIp)
}

return headers
}
4 changes: 4 additions & 0 deletions backend/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { passkey } from '@better-auth/passkey'
import { Database } from 'bun:sqlite'
import { ENV, getTrustedOrigins } from '@opencode-manager/shared/config/env'
import { AUTH_COOKIE_PREFIX } from './cookies'
import { AUTH_SOCKET_IP_HEADER } from './client-ip'

export type AuthInstance = ReturnType<typeof createAuth>

Expand Down Expand Up @@ -90,6 +91,9 @@ export function createAuth(db: Database) {
advanced: {
cookiePrefix: AUTH_COOKIE_PREFIX,
useSecureCookies: ENV.AUTH.SECURE_COOKIES,
ipAddress: {
ipAddressHeaders: [AUTH_SOCKET_IP_HEADER],
},
},
})

Expand Down
7 changes: 7 additions & 0 deletions backend/src/db/change-walkthroughs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,13 @@ export function updateChangeWalkthrough(
return getChangeWalkthrough(db, sessionId, sourceKey)
}

export function listChangeWalkthroughSessionIds(db: Database): string[] {
const rows = db
.prepare('SELECT DISTINCT walkthrough_session_id FROM change_walkthroughs WHERE walkthrough_session_id IS NOT NULL')
.all() as Array<{ walkthrough_session_id: string }>
return rows.map((row) => row.walkthrough_session_id)
}

export function listChangeWalkthroughs(db: Database, sessionId: string): ChangeWalkthrough[] {
const rows = db
.prepare(`SELECT ${ROW_COLUMNS} FROM change_walkthroughs WHERE session_id = ?`)
Expand Down
6 changes: 5 additions & 1 deletion backend/src/routes/auth.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Hono } from 'hono'
import { isAdminConfigured, type AuthInstance } from '../auth'
import { applyClientIpHeaders, resolveSocketIp } from '../auth/client-ip'
import { Database } from 'bun:sqlite'
import { ENV } from '@opencode-manager/shared/config/env'
import { logger } from '../utils/logger'
Expand All @@ -8,7 +9,10 @@ import { hashPassword } from 'better-auth/crypto'
export function createAuthRoutes(auth: AuthInstance): Hono {
const app = new Hono()

app.all('/*', (c) => auth.handler(c.req.raw))
app.all('/*', (c) => {
applyClientIpHeaders(c.req.raw.headers, resolveSocketIp(c))
return auth.handler(c.req.raw)
})

return app
}
Expand Down
4 changes: 4 additions & 0 deletions backend/src/routes/change-walkthroughs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import { ServiceError } from '../utils/service-error'
export function createChangeWalkthroughRoutes(service: ChangeWalkthroughService) {
const app = new Hono()

app.get('/helper-sessions', (c) => {
return c.json({ sessionIds: service.listHelperSessionIds() })
})

app.get('/:sessionId', async (c) => {
const sourceParam = c.req.query('source')
const source = sourceParam === undefined ? DEFAULT_WALKTHROUGH_SOURCE : parseWalkthroughSourceKey(sourceParam)
Expand Down
12 changes: 10 additions & 2 deletions backend/src/routes/repos.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,11 @@ describe('DELETE /api/repos/:id/workspaces', () => {
const client = createStubOpenCodeClient()
client.api.worktree.list = mock(async () => ([{ directory: worktreeDir, strategy: 'git' }])) as any
client.api.worktree.remove = mock(async () => {
throw Object.assign(new Error('cannot remove'), { name: 'WorktreeError', data: { message: 'cannot remove' } })
throw Object.assign(new Error('cannot remove'), {
_tag: 'WorktreeError',
name: 'WorktreeError',
data: { message: 'cannot remove' },
})
}) as any
const app = createTestApp(db, client)

Expand Down Expand Up @@ -519,7 +523,11 @@ describe('POST /api/repos/:id/workspaces', () => {
createRepo(db, { localPath: 'repo-a', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true })
const client = createStubOpenCodeClient()
client.api.worktree.create = mock(async () => {
throw Object.assign(new Error('cannot create'), { name: 'WorktreeError', data: { message: 'cannot create' } })
throw Object.assign(new Error('cannot create'), {
_tag: 'WorktreeError',
name: 'WorktreeError',
data: { message: 'cannot create' },
})
}) as any
const app = createTestApp(db, client)

Expand Down
2 changes: 1 addition & 1 deletion backend/src/routes/repos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,7 +498,7 @@ app.get('/', async (c) => {
return c.json({ ...updatedRepo, currentBranch })
} catch (error: unknown) {
logger.error('Failed to create branch:', error)
return c.json({ error: getErrorMessage(error) }, 500)
return c.json({ error: getErrorMessage(error) }, getStatusCode(error) as ContentfulStatusCode)
}
})

Expand Down
21 changes: 18 additions & 3 deletions backend/src/routes/session-sandbox.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,23 @@
import { Hono } from 'hono'
import { Hono, type Context } from 'hono'
import type { ContentfulStatusCode } from 'hono/utils/http-status'
import { SetSessionSandboxRequestSchema } from '@opencode-manager/shared/schemas'
import { openCodeErrorStatus } from '@opencode-manager/shared/opencode'
import { SessionSandboxError, type SessionSandboxService } from '../services/session-sandbox'
import { getErrorMessage } from '../utils/error-utils'
import { handleServiceError, parseJsonBody } from '../utils/route-helpers'

/**
* Maps both sandbox errors and the OpenCode errors raised while resolving a session (for example
* `SessionNotFoundError` or `InvalidRequestError` for a malformed id) to their real status codes.
*/
function handleSessionSandboxError(c: Context, error: unknown, fallback: string) {
const status = openCodeErrorStatus(error)
if (status !== undefined) {
return c.json({ error: getErrorMessage(error) || fallback }, status as ContentfulStatusCode)
}
return handleServiceError(c, error, fallback, SessionSandboxError)
}

export function createSessionSandboxRoutes(service: SessionSandboxService) {
const app = new Hono()

Expand All @@ -11,7 +26,7 @@ export function createSessionSandboxRoutes(service: SessionSandboxService) {
const state = await service.getState(c.req.param('sessionId'))
return c.json(state)
} catch (error) {
return handleServiceError(c, error, 'Failed to read session sandbox state', SessionSandboxError)
return handleSessionSandboxError(c, error, 'Failed to read session sandbox state')
}
})

Expand All @@ -25,7 +40,7 @@ export function createSessionSandboxRoutes(service: SessionSandboxService) {
const state = await service.update(c.req.param('sessionId'), parsed.data)
return c.json(state)
} catch (error) {
return handleServiceError(c, error, 'Failed to set session sandbox state', SessionSandboxError)
return handleSessionSandboxError(c, error, 'Failed to set session sandbox state')
}
})

Expand Down
32 changes: 31 additions & 1 deletion backend/src/routes/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { OpenCodeClient } from '../services/opencode/client'
import { getAgentsMdPath } from '@opencode-manager/shared/config/env'
import {
UserPreferencesSchema,
isValidGitIdentityEmail,
type SandboxPreferences,
type UserPreferences,
} from '../types/settings'
Expand Down Expand Up @@ -299,9 +300,38 @@ function spawnWithTimeout(args: string[], timeoutMs: number, env?: Record<string
return { output, timedOut: false }
}

const trimGitIdentityEmail = <T extends { email: string }>(identity: T): T => ({
...identity,
email: identity.email.trim(),
})

const UpdateSettingsSchema = z.object({
preferences: UserPreferencesSchema.partial(),
})
}).superRefine(({ preferences }, ctx) => {
if (preferences.gitIdentity && !isValidGitIdentityEmail(preferences.gitIdentity.email)) {
ctx.addIssue({
code: 'custom',
path: ['preferences', 'gitIdentity', 'email'],
message: 'Invalid email address',
})
}

preferences.gitIdentities?.forEach((identity, index) => {
if (!isValidGitIdentityEmail(identity.email)) {
ctx.addIssue({
code: 'custom',
path: ['preferences', 'gitIdentities', index, 'email'],
message: 'Invalid email address',
})
}
})
}).transform(({ preferences }) => ({
preferences: {
...preferences,
...(preferences.gitIdentity ? { gitIdentity: trimGitIdentityEmail(preferences.gitIdentity) } : {}),
...(preferences.gitIdentities ? { gitIdentities: preferences.gitIdentities.map(trimGitIdentityEmail) } : {}),
},
}))

const CreateCustomCommandSchema = z.object({
name: z.string().min(1).max(255),
Expand Down
8 changes: 8 additions & 0 deletions backend/src/services/change-walkthroughs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
deleteChangeWalkthroughs,
getChangeWalkthrough,
getChangeWalkthroughByWalkthroughSession,
listChangeWalkthroughSessionIds,
listChangeWalkthroughs,
updateChangeWalkthrough,
type ChangeWalkthroughPatch,
Expand All @@ -56,6 +57,7 @@ import type { SettingsService } from './settings'
import type { SSEEvent } from './sse-aggregator'

const WALKTHROUGH_TIMEOUT_MESSAGE = 'The walkthrough session did not publish within 15 minutes'
const EMPTY_CHANGES_HASH = computeChangesHash([])

export interface ChangeWalkthroughServiceOptions {
generationTimeoutMs?: number
Expand Down Expand Up @@ -170,6 +172,11 @@ export class ChangeWalkthroughService {
this.walkthroughsRoot = options.walkthroughsRoot ?? getWalkthroughsPath()
}

/** Sessions the walkthrough runs were created in, which are helper sessions rather than user sessions. */
listHelperSessionIds(): string[] {
return listChangeWalkthroughSessionIds(this.db)
}

async getState(
sessionId: string,
source: WalkthroughSource = DEFAULT_WALKTHROUGH_SOURCE,
Expand All @@ -187,6 +194,7 @@ export class ChangeWalkthroughService {
return {
walkthrough,
currentDiffHash,
hasChanges: currentDiffHash === null || currentDiffHash !== EMPTY_CHANGES_HASH,
stale: walkthrough !== null && currentDiffHash !== null && walkthrough.diffHash !== currentDiffHash,
generating: walkthrough?.status === 'generating',
running: walkthrough !== null && walkthrough.executionStartedAt !== null,
Expand Down
47 changes: 38 additions & 9 deletions backend/src/services/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,15 @@ interface FileUploadResult {
mimeType: string
}

const BINARY_MIME_TYPE = 'application/octet-stream'
const SNIFF_BYTE_COUNT = 8192
const MAX_INVALID_UTF8_RATIO = 0.1

type DetectedMimeType = AllowedMimeType | typeof BINARY_MIME_TYPE

export interface RawFileMetadata {
size: number
mimeType: AllowedMimeType
mimeType: DetectedMimeType
lastModified: Date
}

Expand Down Expand Up @@ -85,7 +91,7 @@ export async function getRawFileMetadata(userPath: string): Promise<RawFileMetad

return {
size: stats.size,
mimeType: getMimeType(validatedPath),
mimeType: await detectMimeType(validatedPath),
lastModified: stats.lastModified,
}
} catch (error) {
Expand Down Expand Up @@ -141,12 +147,10 @@ export async function getFile(userPath: string): Promise<FileInfo> {
} else {
// It's a file - get content
let content = ''
const mimeType = getMimeType(validatedPath)
const mimeType = await detectMimeType(validatedPath)

if (stats.size < FILE_LIMITS.MAX_SIZE_BYTES) {
try {
const mimeType = getMimeType(validatedPath)

if (mimeType.startsWith('image/') || !mimeType.startsWith('text/')) {
content = await readFileAsBase64(validatedPath)
} else {
Expand Down Expand Up @@ -179,7 +183,7 @@ export async function uploadFile(userPath: string, file: File, relativePath?: st
throw new Error('File too large')
}

const mimeType = (file.type || getMimeType(file.name)) as AllowedMimeType
const mimeType = (file.type || getKnownMimeType(file.name) || 'text/plain') as AllowedMimeType
if (!ALLOWED_MIME_TYPES.includes(mimeType) && !mimeType.startsWith('text/')) {
throw new Error('File type not allowed')
}
Expand Down Expand Up @@ -288,7 +292,7 @@ function validatePath(userPath: string): string {
return resolved
}

function getMimeType(filePath: string): AllowedMimeType {
function getKnownMimeType(filePath: string): AllowedMimeType | undefined {
const ext = path.extname(filePath).toLowerCase()

const mimeTypes: Record<string, AllowedMimeType> = {
Expand All @@ -315,7 +319,32 @@ function getMimeType(filePath: string): AllowedMimeType {
'.mdx': 'text/markdown',
}

return mimeTypes[ext] || 'text/plain'
return mimeTypes[ext]
}

async function detectMimeType(filePath: string): Promise<DetectedMimeType> {
const known = getKnownMimeType(filePath)
if (known) return known
return isBinaryContent(await readFilePrefix(filePath)) ? BINARY_MIME_TYPE : 'text/plain'
}

async function readFilePrefix(filePath: string): Promise<Buffer> {
const handle = await fs.open(filePath, 'r')
try {
const buffer = Buffer.alloc(SNIFF_BYTE_COUNT)
const { bytesRead } = await handle.read(buffer, 0, SNIFF_BYTE_COUNT, 0)
return buffer.subarray(0, bytesRead)
} finally {
await handle.close()
}
}

function isBinaryContent(buffer: Buffer): boolean {
if (buffer.length === 0) return false
if (buffer.includes(0)) return true
const decoded = buffer.toString('utf8')
const invalidCharacters = decoded.split('\uFFFD').length - 1
return invalidCharacters / decoded.length > MAX_INVALID_UTF8_RATIO
}

async function readFileLinesAndCount(
Expand Down Expand Up @@ -356,7 +385,7 @@ export async function getFileRange(userPath: string, startLine: number, endLine:

const { lines, totalLines } = await readFileLinesAndCount(validatedPath, startLine, endLine)
const clampedEnd = Math.min(endLine, totalLines)
const mimeType = getMimeType(validatedPath)
const mimeType = await detectMimeType(validatedPath)

return {
name: path.basename(validatedPath),
Expand Down
Loading