Skip to content

Commit d4f2637

Browse files
refactor: add git-credentials route and gh env plugin, pass internal token to OpenCode
1 parent 1ee3b47 commit d4f2637

11 files changed

Lines changed: 222 additions & 134 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { Hono } from 'hono'
2+
import type { Database } from 'bun:sqlite'
3+
import { CredentialProvider } from '../../services/credential-provider'
4+
5+
export function createInternalGitCredentialsRoutes(db: Database) {
6+
const app = new Hono()
7+
8+
app.get('/gh-env', (c) => {
9+
const provider = new CredentialProvider(db)
10+
return c.json(provider.getGhCliEnv())
11+
})
12+
13+
return app
14+
}

backend/src/routes/internal/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { createInternalRepoSyncRoutes } from './repo-sync'
1313
import { createInternalRepoMirrorRoutes as mirrorRoutes } from './repo-mirror'
1414
import { createInternalOpenCodeWorkspacesRoutes } from './opencode-workspaces'
1515
import { createInternalAssistantRoutes } from './assistant'
16+
import { createInternalGitCredentialsRoutes } from './git-credentials'
1617

1718
export function createInternalRoutes(
1819
db: Database,
@@ -34,5 +35,6 @@ export function createInternalRoutes(
3435
app.route('/repos', repos)
3536
app.route('/opencode-workspaces', createInternalOpenCodeWorkspacesRoutes(db))
3637
app.route('/assistant', createInternalAssistantRoutes(openCodeClient))
38+
app.route('/git-credentials', createInternalGitCredentialsRoutes(db))
3739
return app
3840
}

backend/src/services/credential-provider.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { SettingsService } from './settings'
44
import {
55
findPatCredentialForHost,
66
getSSHCredentialsForHost,
7-
findGitHubCredential,
87
createGhCliEnv,
98
type ResolvedGitCredential,
109
} from '../utils/git-auth'
@@ -29,10 +28,6 @@ export class CredentialProvider {
2928
return getSSHCredentialsForHost(this.getGitCredentials(), host)
3029
}
3130

32-
getGitHubCredential(): GitCredential | null {
33-
return findGitHubCredential(this.getGitCredentials())
34-
}
35-
3631
getGhCliEnv(): Record<string, string> {
3732
return createGhCliEnv(this.getGitCredentials())
3833
}

backend/src/services/git-auth.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,8 +177,6 @@ export class GitAuthService {
177177
}
178178
}
179179

180-
Object.assign(env, this.credentialProvider?.getGhCliEnv() || {})
181-
182180
return env
183181
}
184182
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { promises as fs } from 'fs'
2+
import path from 'path'
3+
import { logger } from '../utils/logger'
4+
5+
const PLUGIN_FILENAME = 'ocm-gh-env.js'
6+
7+
const PLUGIN_SOURCE = `const TTL_MS = 5000
8+
let cache = { expiry: 0, env: {} }
9+
10+
async function fetchGhEnv() {
11+
const baseUrl = process.env.OCM_INTERNAL_API_URL
12+
const token = process.env.OCM_INTERNAL_TOKEN
13+
if (!baseUrl || !token) return {}
14+
const now = Date.now()
15+
if (now < cache.expiry) return cache.env
16+
try {
17+
const res = await fetch(baseUrl + '/git-credentials/gh-env', {
18+
headers: { Authorization: 'Bearer ' + token },
19+
})
20+
if (!res.ok) return cache.env
21+
const env = await res.json()
22+
cache = { expiry: now + TTL_MS, env: env && typeof env === 'object' ? env : {} }
23+
return cache.env
24+
} catch {
25+
return cache.env
26+
}
27+
}
28+
29+
export default async function () {
30+
return {
31+
'shell.env': async (_input, output) => {
32+
const env = await fetchGhEnv()
33+
Object.assign(output.env, env)
34+
},
35+
}
36+
}
37+
`
38+
39+
export function getGhEnvPluginDir(configHome: string): string {
40+
return path.join(configHome, 'opencode', 'plugin')
41+
}
42+
43+
export async function installGhEnvPlugin(configHome: string): Promise<void> {
44+
try {
45+
const dir = getGhEnvPluginDir(configHome)
46+
await fs.mkdir(dir, { recursive: true })
47+
await fs.writeFile(path.join(dir, PLUGIN_FILENAME), PLUGIN_SOURCE, 'utf-8')
48+
} catch (error) {
49+
logger.warn('Failed to install gh-env OpenCode plugin:', error)
50+
}
51+
}

backend/src/services/opencode-single-server.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { spawn, execSync, spawnSync } from 'child_process'
22
import path from 'path'
33
import { promises as fs } from 'fs'
44
import { logger } from '../utils/logger'
5-
import { createGitEnv, createGitIdentityEnv, createGhCliEnv, resolveGitIdentity } from '../utils/git-auth'
5+
import { createGitEnv, createGitIdentityEnv, resolveGitIdentity } from '../utils/git-auth'
66
import type { GitCredential } from '@opencode-manager/shared'
77
import {
88
buildSSHCommandWithKnownHosts,
@@ -24,6 +24,8 @@ import { compareVersions } from '../utils/version-utils'
2424
import { patchConfigWithRecovery } from './opencode/config-recovery'
2525
import type { OpenCodeClient } from './opencode/client'
2626
import { writeFileContent } from './file-operations'
27+
import { getOrCreateInternalToken } from './internal-token'
28+
import { installGhEnvPlugin } from './opencode-gh-env-plugin'
2729

2830

2931
const MIN_OPENCODE_VERSION = '1.0.137'
@@ -327,6 +329,7 @@ class OpenCodeServerManager {
327329
logger.info(`OpenCode server GIT_SSH_COMMAND: ${gitSshCommand}`)
328330

329331
await this.initializeOpencodeBinDirectory()
332+
await installGhEnvPlugin(path.join(openCodeServerDirectory, '.config'))
330333
const configuredPlugins = await this.getConfiguredPlugins(openCodeConfigPath)
331334
await this.installConfiguredPlugins(configuredPlugins)
332335
const configuredPluginCount = configuredPlugins.length
@@ -351,8 +354,13 @@ class OpenCodeServerManager {
351354
...cleanEnv,
352355
...userEnvVars,
353356
...gitEnv,
354-
...createGhCliEnv(gitCredentials),
355357
...gitIdentityEnv,
358+
...(this.db
359+
? {
360+
OCM_INTERNAL_API_URL: `http://localhost:${ENV.SERVER.PORT}/api/internal`,
361+
OCM_INTERNAL_TOKEN: getOrCreateInternalToken(this.db),
362+
}
363+
: {}),
356364
GIT_SSH_COMMAND: gitSshCommand,
357365
XDG_DATA_HOME: path.join(openCodeServerDirectory, '.opencode/state'),
358366
XDG_STATE_HOME: path.join(openCodeServerDirectory, '.opencode/state'),
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { describe, it, expect, beforeEach } from 'vitest'
2+
import { Database } from 'bun:sqlite'
3+
import { migrate } from '../../../src/db/migration-runner'
4+
import { allMigrations } from '../../../src/db/migrations'
5+
import { SettingsService } from '../../../src/services/settings'
6+
import { createInternalGitCredentialsRoutes } from '../../../src/routes/internal/git-credentials'
7+
import type { GitCredential } from '@opencode-manager/shared'
8+
9+
describe('internal git-credentials routes', () => {
10+
let db: Database
11+
let settingsService: SettingsService
12+
let app: ReturnType<typeof createInternalGitCredentialsRoutes>
13+
14+
beforeEach(() => {
15+
db = new Database(':memory:')
16+
migrate(db, allMigrations)
17+
settingsService = new SettingsService(db)
18+
app = createInternalGitCredentialsRoutes(db)
19+
})
20+
21+
it('GET /gh-env returns GH_TOKEN and GITHUB_TOKEN for a GitHub PAT', async () => {
22+
settingsService.updateSettings({
23+
gitCredentials: [
24+
{ name: 'github', host: 'github.com', type: 'pat', token: 'ghp_test_token' } as GitCredential,
25+
],
26+
})
27+
28+
const res = await app.request('/gh-env')
29+
30+
expect(res.status).toBe(200)
31+
expect(await res.json()).toEqual({ GH_TOKEN: 'ghp_test_token', GITHUB_TOKEN: 'ghp_test_token' })
32+
})
33+
34+
it('GET /gh-env returns an empty object when no GitHub credential exists', async () => {
35+
const res = await app.request('/gh-env')
36+
37+
expect(res.status).toBe(200)
38+
expect(await res.json()).toEqual({})
39+
})
40+
})

backend/test/services/credential-provider.test.ts

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -72,18 +72,6 @@ describe('CredentialProvider', () => {
7272
it('getSshCredentialsForHost returns empty array for unmatched host', () => {
7373
expect(provider.getSshCredentialsForHost('gitlab.com')).toEqual([])
7474
})
75-
76-
it('getGitHubCredential returns the GitHub PAT', () => {
77-
const cred = provider.getGitHubCredential()
78-
expect(cred).not.toBeNull()
79-
expect(cred!.host).toBe('github.com')
80-
expect(cred!.token).toBe('ghp_test_token')
81-
})
82-
83-
it('getGitHubCredential excludes SSH credentials', () => {
84-
const cred = provider.getGitHubCredential()
85-
expect(cred!.type).not.toBe('ssh')
86-
})
8775
})
8876

8977
describe('with no credentials', () => {
@@ -98,10 +86,6 @@ describe('CredentialProvider', () => {
9886
it('getSshCredentialsForHost returns empty array', () => {
9987
expect(provider.getSshCredentialsForHost('github.com')).toEqual([])
10088
})
101-
102-
it('getGitHubCredential returns null', () => {
103-
expect(provider.getGitHubCredential()).toBeNull()
104-
})
10589
})
10690

10791
describe('getGitCredentials', () => {

backend/test/services/git-auth.integration.test.ts

Lines changed: 0 additions & 87 deletions
This file was deleted.

backend/test/services/git-auth.test.ts

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,6 @@ vi.mock('../../src/services/credential-provider', () => ({
108108
getGhCliEnv: vi.fn().mockReturnValue({}),
109109
getSshCredentialsForHost: vi.fn().mockReturnValue([]),
110110
getPatCredentialForHost: vi.fn().mockReturnValue(null),
111-
getGitHubCredential: vi.fn().mockReturnValue(null),
112111
getGitCredentials: vi.fn().mockReturnValue([]),
113112
})),
114113
}))
@@ -302,27 +301,6 @@ describe('GitAuthService with passphrase support', () => {
302301
})
303302
})
304303

305-
describe('getGitEnvironment', () => {
306-
it('includes GH_TOKEN and GITHUB_TOKEN when a GitHub PAT is configured', () => {
307-
const mockProvider = gitAuthService['credentialProvider']!
308-
vi.mocked(mockProvider.getGhCliEnv).mockReturnValue({
309-
GH_TOKEN: 'ghp_test_token',
310-
GITHUB_TOKEN: 'ghp_test_token',
311-
})
312-
313-
const env = gitAuthService.getGitEnvironment()
314-
315-
expect(env.GH_TOKEN).toBe('ghp_test_token')
316-
expect(env.GITHUB_TOKEN).toBe('ghp_test_token')
317-
})
318-
319-
it('does not include GH_TOKEN when no GitHub credential is available', () => {
320-
const env = gitAuthService.getGitEnvironment()
321-
expect(env.GH_TOKEN).toBeUndefined()
322-
expect(env.GITHUB_TOKEN).toBeUndefined()
323-
})
324-
})
325-
326304
describe('integration with passphrase-protected keys', () => {
327305
it('properly handles full SSH key lifecycle with passphrase', async () => {
328306
const privateKey = '-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACBmNkCQ0YDZiVJmHqMvK1PqjWxV4aRqK2Pm8RlV4aRqK2Pm8RlV4aRqK2Pm8RlV4\naRqK2Pm8RlV4aRqK2Pm8RlV4aRqK2Pm8RlV4aAAAAgBmNkCQ0YDZiVJmHqMvK1PqjWxV4aRqK2Pm8RlV4\naRqK2Pm8RlV4aRqK2Pm8RlV4aRqK2Pm8RlV4aAAAADHN0cmluZy1rZXktdGVzdAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4v\nMDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWpr\nbG1ub3BxcnN0dXZ3eHl6e3x9fn+AgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaan\nqKmqq6ytrq+wsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj\n5OXm5+jp6uvs7e7v8PHy8/T19vf4+fr7/P3+/w==\n-----END OPENSSH PRIVATE KEY-----'

0 commit comments

Comments
 (0)