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 backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@ app.route('/api/auth-info', createAuthInfoRoutes(auth, db))
app.route('/api/health', createHealthRoutes(db, openCodeSupervisor))

app.route('/api/mcp-oauth-proxy', createMcpOauthProxyRoutes(openCodeClient, requireAuth))
app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, sessionPermissionModeService, repoWorkspaces))
app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, sessionPermissionModeService, repoWorkspaces, gitAuthService))
app.route('/api/opencode-proxy', createOpenCodeProxyRoutes(db, settingsService))

const protectedApi = new Hono()
Expand Down
4 changes: 3 additions & 1 deletion backend/src/routes/internal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { createInternalGitCredentialsRoutes } from './git-credentials'
import { createInternalSandboxRoutes } from './sandbox'
import type { SessionPermissionModeService } from '../../services/session-permission-modes'
import type { RepoWorkspaceService } from '../../services/repo-workspace'
import type { GitAuthService } from '../../services/git-auth'

export function createInternalRoutes(
db: Database,
Expand All @@ -28,6 +29,7 @@ export function createInternalRoutes(
openCodeClient: OpenCodeClient,
permissionModes: SessionPermissionModeService,
repoWorkspaces: RepoWorkspaceService,
gitAuthService: GitAuthService,
) {
const app = new Hono()
app.use('/*', createInternalTokenMiddleware(db))
Expand All @@ -36,7 +38,7 @@ export function createInternalRoutes(
app.route('/settings', createInternalSettingsRoutes(settingsService))
app.route('/opencode-config', createOpenCodeConfigRoutes(settingsService, openCodeClient, { redactSecrets: true }))
const repos = new Hono()
repos.route('/', createInternalRepoRoutes(db, settingsService))
repos.route('/', createInternalRepoRoutes(db, settingsService, gitAuthService))
repos.route('/:id/schedules', createScheduleRoutes(scheduleService))
repos.route('/', createInternalRepoSyncRoutes(db))
repos.route('/', mirrorRoutes(db))
Expand Down
25 changes: 22 additions & 3 deletions backend/src/routes/internal/repos.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { Hono } from 'hono'
import type { ContentfulStatusCode } from 'hono/utils/http-status'
import type { Database } from 'bun:sqlite'
import { InternalCloneRepoRequestSchema } from '@opencode-manager/shared/schemas'
import type { SettingsService } from '../../services/settings'
import type { GitAuthService } from '../../services/git-auth'
import { listRepos } from '../../db/queries'
import { cloneRepo } from '../../services/repo'
import { logger } from '../../utils/logger'
import { getErrorMessage } from '../../utils/error-utils'
import { getErrorMessage, getStatusCode } from '../../utils/error-utils'

export function createInternalRepoRoutes(db: Database, settingsService: SettingsService) {
export function createInternalRepoRoutes(db: Database, settingsService: SettingsService, gitAuthService: GitAuthService) {
const app = new Hono()

app.get('/', (c) => {
Expand All @@ -19,5 +23,20 @@ export function createInternalRepoRoutes(db: Database, settingsService: Settings
}
})

app.post('/', async (c) => {
const parsed = InternalCloneRepoRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return c.json({ error: parsed.error.issues[0]?.message ?? 'Invalid request' }, 400)
}
try {
const { repoUrl, branch, directoryName } = parsed.data
const repo = await cloneRepo(db, gitAuthService, repoUrl, { branch, directoryName })
return c.json(repo)
} catch (error) {
logger.error('Failed to clone internal repo:', error)
return c.json({ error: getErrorMessage(error) }, getStatusCode(error) as ContentfulStatusCode)
}
})

return app
}
}
35 changes: 32 additions & 3 deletions backend/src/services/assistant-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -831,12 +831,12 @@ Only changes to how the OpenCode process is launched need a user restart from Se
export function buildReposSkill(): string {
return `---
name: repo-management
description: List repos available to OpenCode Manager with the ${MANAGER_TOOL_NAME} tool
description: List and clone repos in OpenCode Manager with the ${MANAGER_TOOL_NAME} tool
---

## When to Load

Load this skill when you need to discover repos, look up repo IDs, or need to reference repo information before managing schedules. Load it before the schedule-management skill if you don't know the repo ID.
Load this skill when you need to discover repos, look up repo IDs, add a repo by cloning a git URL, or need to reference repo information before managing schedules. Load it before the schedule-management skill if you don't know the repo ID.

## Tool

Expand Down Expand Up @@ -892,11 +892,40 @@ List all repos available to OpenCode Manager. The repos are returned in the orde
}
\`\`\`

### POST /repos

Clone a git repository into the repos root and register it with OpenCode Manager. Accepts HTTPS and SSH URLs. If the same URL and branch is already registered, the existing repo is returned instead of cloning again.

**Body:**
\`\`\`ts
{
repoUrl: string // e.g. https://github.com/owner/name
branch?: string // branch to check out; created locally if the remote has none
directoryName?: string // directory under the repos root; defaults to the repo name
}
\`\`\`

**Example:**
\`\`\`json
{
"action": "request",
"params": {
"method": "POST",
"path": "/repos",
"body": { "repoUrl": "https://github.com/owner/name" }
}
}
\`\`\`

**Response:** the repo object (same shape as an entry in \`GET /repos\`) with \`cloneStatus: 'ready'\` when the clone has finished, or \`'cloning'\` when an identical clone is still running. When it is \`'cloning'\`, poll \`GET /repos\` until it becomes \`'ready'\` before using the repo.

## Notes

- Use \`id\` as \`:repoId\` in other API endpoints (e.g., \`/repos/:repoId/schedules\`)
- \`fullPath\` is the absolute local path - use it for file operations
- This endpoint is read-only - there are no POST/PUT/DELETE operations for repos
- Only clone a repo the user asked for; there is no delete or update operation for repos through this tool
- Large clones can outlast the tool request timeout while the clone keeps running; if the request times out, poll \`GET /repos\` until the repo shows \`cloneStatus: 'ready'\`
- A \`409\` means the target directory already holds a different repository; retry with a different \`directoryName\`
- \`currentBranch\` is not included in the response - it requires git operations to determine
- Repo order is controlled by the \`repoOrder\` preference in settings
`
Expand Down
3 changes: 2 additions & 1 deletion backend/src/services/opencode-manager-tool-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const MANAGER_TOOL_ALLOWED_ROUTES = [
'PATCH /opencode-config',
'POST /assistant/reload',
'GET /repos',
'POST /repos',
'GET /repos/*/git-info',
'GET /opencode-workspaces',
'GET /sessions',
Expand Down Expand Up @@ -111,7 +112,7 @@ function buildManagerToolDescription(): string {
'The action runs inside OpenCode Manager itself, so it needs no token and no network access from the agent shell, and it works in sandboxed sessions and scheduled runs.',
'Actions:',
'- send_notification: send a push notification to every device the user has registered.',
'- request: call an allow-listed internal API route to read and manage settings, the OpenCode configuration file, repos, OpenCode workspaces, sessions (list, create, follow up, read the latest reply, fork), and schedules.',
'- request: call an allow-listed internal API route to read and manage settings, the OpenCode configuration file, repos (list, inspect, clone from a git URL with POST /repos and body { repoUrl, branch?, directoryName? }), OpenCode workspaces, sessions (list, create, follow up, read the latest reply, fork), and schedules.',
'Allowed request routes:',
]
.concat(MANAGER_TOOL_ALLOWED_ROUTES.map((route) => `- ${route}`))
Expand Down
3 changes: 2 additions & 1 deletion backend/test/routes/internal-assistant.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import type { SessionPermissionModeService } from '../../src/services/session-permission-modes'
import type { RepoWorkspaceService } from '../../src/services/repo-workspace'
import type { GitAuthService } from '../../src/services/git-auth'
import { Hono } from 'hono'
import { Database } from 'bun:sqlite'
import { createInternalRoutes } from '../../src/routes/internal'
Expand Down Expand Up @@ -45,7 +46,7 @@ describe('internal/assistant routes', () => {
notificationService = new NotificationService(db)
settingsService = new SettingsService(db)
app = new Hono()
app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService))
app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService))
token = getOrCreateInternalToken(db)
})

Expand Down
3 changes: 2 additions & 1 deletion backend/test/routes/internal-notifications.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, vi } from 'bun:test'
import type { SessionPermissionModeService } from '../../src/services/session-permission-modes'
import type { RepoWorkspaceService } from '../../src/services/repo-workspace'
import type { GitAuthService } from '../../src/services/git-auth'
import { Hono } from 'hono'
import { Database } from 'bun:sqlite'
import { createInternalRoutes } from '../../src/routes/internal'
Expand Down Expand Up @@ -31,7 +32,7 @@ describe('internal/notifications routes', () => {
notificationService = new NotificationService(db)
settingsService = new SettingsService(db)
app = new Hono()
app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService))
app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService))
token = getOrCreateInternalToken(db)
})

Expand Down
3 changes: 2 additions & 1 deletion backend/test/routes/internal-opencode-config.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'bun:test'
import type { SessionPermissionModeService } from '../../src/services/session-permission-modes'
import type { RepoWorkspaceService } from '../../src/services/repo-workspace'
import type { GitAuthService } from '../../src/services/git-auth'
import { Hono } from 'hono'
import { Database } from 'bun:sqlite'
import { readFile, writeFile } from 'fs/promises'
Expand Down Expand Up @@ -61,7 +62,7 @@ describe('internal/opencode-config routes', () => {
const notificationService = new NotificationService(db)
const settingsService = new SettingsService(db)
app = new Hono()
app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService))
app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService))
token = getOrCreateInternalToken(db)
})

Expand Down
3 changes: 2 additions & 1 deletion backend/test/routes/internal-opencode-workspaces.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import type { SessionPermissionModeService } from '../../src/services/session-permission-modes'
import type { RepoWorkspaceService } from '../../src/services/repo-workspace'
import type { GitAuthService } from '../../src/services/git-auth'
import { Hono } from 'hono'
import type { Database } from 'bun:sqlite'
import { createInternalRoutes } from '../../src/routes/internal'
Expand Down Expand Up @@ -84,7 +85,7 @@ describe('internal-opencode-workspaces routes', () => {
forwardRaw: vi.fn(),
} as unknown as OpenCodeClient
app = new Hono()
app.route('/api/internal', createInternalRoutes(mockDb, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService))
app.route('/api/internal', createInternalRoutes(mockDb, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService))
token = 'test-internal-token'
})

Expand Down
43 changes: 42 additions & 1 deletion backend/test/routes/internal-repos.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach } from 'vitest'
import type { SessionPermissionModeService } from '../../src/services/session-permission-modes'
import type { RepoWorkspaceService } from '../../src/services/repo-workspace'
import type { GitAuthService } from '../../src/services/git-auth'
import { Hono } from 'hono'
import { Database } from 'bun:sqlite'
import { createInternalRoutes } from '../../src/routes/internal'
Expand Down Expand Up @@ -32,7 +33,7 @@ describe('internal-repos routes', () => {
notificationService = new NotificationService(db)
settingsService = new SettingsService(db)
app = new Hono()
app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService))
app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService))
token = getOrCreateInternalToken(db)
})

Expand Down Expand Up @@ -109,6 +110,46 @@ describe('internal-repos routes', () => {
expect(body.repos[1]?.id).toBe(repo1.id)
})

it('POST /api/internal/repos returns 401 without bearer token', async () => {
const res = await app.request('/api/internal/repos', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ repoUrl: 'https://github.com/owner/name' }),
})
expect(res.status).toBe(401)
})

it('POST /api/internal/repos rejects a missing repoUrl, unknown keys, and invalid JSON', async () => {
const bodies = [JSON.stringify({}), JSON.stringify({ repoUrl: 'https://github.com/owner/name', localPath: '/etc' }), 'not json']
for (const body of bodies) {
const res = await app.request('/api/internal/repos', {
method: 'POST',
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
body,
})
expect(res.status).toBe(400)
}
})

it('POST /api/internal/repos returns the already registered repo for the same URL', async () => {
const existing = createRepo(db, {
repoUrl: 'https://github.com/owner/name',
localPath: 'name',
defaultBranch: 'main',
cloneStatus: 'ready',
clonedAt: Date.now(),
})

const res = await app.request('/api/internal/repos', {
method: 'POST',
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
body: JSON.stringify({ repoUrl: 'https://github.com/owner/name.git' }),
})
expect(res.status).toBe(200)
const body = await res.json() as { id: number }
expect(body.id).toBe(existing.id)
})

it('GET /api/internal/repos/:id/schedules still works after adding repos route', async () => {
const repoInput: CreateRepoInput = {
localPath: 'test-repo',
Expand Down
3 changes: 2 additions & 1 deletion backend/test/routes/internal-sandbox.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, mock, vi } from 'bun:test'
import type { SessionPermissionModeService } from '../../src/services/session-permission-modes'
import type { RepoWorkspaceService } from '../../src/services/repo-workspace'
import type { GitAuthService } from '../../src/services/git-auth'
import { Hono } from 'hono'
import { Database } from 'bun:sqlite'
import { mkdirSync, rmSync } from 'node:fs'
Expand Down Expand Up @@ -95,7 +96,7 @@ describe('internal sandbox routes', () => {
const notificationService = new NotificationService(db)
settingsService = new SettingsService(db)
app = new Hono()
app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService))
app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService))
token = getOrCreateInternalToken(db)
repoDir = path.join(getReposPath(), 'sandbox-route-test')
mkdirSync(repoDir, { recursive: true })
Expand Down
3 changes: 2 additions & 1 deletion backend/test/routes/internal-schedules.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach } from 'vitest'
import type { SessionPermissionModeService } from '../../src/services/session-permission-modes'
import type { RepoWorkspaceService } from '../../src/services/repo-workspace'
import type { GitAuthService } from '../../src/services/git-auth'
import { Hono } from 'hono'
import { Database } from 'bun:sqlite'
import { createInternalRoutes } from '../../src/routes/internal'
Expand Down Expand Up @@ -30,7 +31,7 @@ describe('internal-schedules routes', () => {
notificationService = new NotificationService(db)
settingsService = new SettingsService(db)
app = new Hono()
app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService))
app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService))
token = getOrCreateInternalToken(db)
})

Expand Down
3 changes: 2 additions & 1 deletion backend/test/routes/internal-sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { SettingsService } from '../../src/services/settings'
import type { OpenCodeClient } from '../../src/services/opencode/client'
import type { SessionPermissionModeService } from '../../src/services/session-permission-modes'
import type { RepoWorkspaceService } from '../../src/services/repo-workspace'
import type { GitAuthService } from '../../src/services/git-auth'
import type { Repo } from '../../src/types/repo'

const mockDb = {
Expand Down Expand Up @@ -157,7 +158,7 @@ describe('internal-sessions routes', () => {
app = new Hono()
app.route(
'/api/internal',
createInternalRoutes(mockDb, scheduleService, notificationService, settingsService, openCodeClient, permissionModes, {} as unknown as RepoWorkspaceService),
createInternalRoutes(mockDb, scheduleService, notificationService, settingsService, openCodeClient, permissionModes, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService),
)
token = 'test-internal-token'
})
Expand Down
3 changes: 2 additions & 1 deletion backend/test/routes/internal-settings.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach } from 'bun:test'
import type { SessionPermissionModeService } from '../../src/services/session-permission-modes'
import type { RepoWorkspaceService } from '../../src/services/repo-workspace'
import type { GitAuthService } from '../../src/services/git-auth'
import { Hono } from 'hono'
import { Database } from 'bun:sqlite'
import { createInternalRoutes } from '../../src/routes/internal'
Expand Down Expand Up @@ -31,7 +32,7 @@ describe('internal/settings routes', () => {
notificationService = new NotificationService(db)
settingsService = new SettingsService(db)
app = new Hono()
app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService))
app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService))
token = getOrCreateInternalToken(db)
})

Expand Down
Loading