Skip to content

Commit 9b807b5

Browse files
Refresh github tokens periodically using API
1 parent f59c3c2 commit 9b807b5

File tree

3 files changed

+315
-0
lines changed

3 files changed

+315
-0
lines changed

apps/api/src/app/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { PlaygroundsController } from './playgrounds/playgrounds.controller';
2222
import { PlaygroundsService } from './playgrounds/playgrounds.service';
2323
import { PlaygroundWatcherService } from './playgrounds/playground-watcher.service';
2424
import { PhoenixSyncService } from './phoenix-sync/phoenix-sync.service';
25+
import { GithubTokenRefreshService } from './github-token-refresh/github-token-refresh.service';
2526

2627
@Module({
2728
imports: [
@@ -55,6 +56,7 @@ import { PhoenixSyncService } from './phoenix-sync/phoenix-sync.service';
5556
PlaygroundsService,
5657
PlaygroundWatcherService,
5758
PhoenixSyncService,
59+
GithubTokenRefreshService,
5860
],
5961
})
6062
export class AppModule {}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test';
2+
import { GithubTokenRefreshService } from './github-token-refresh.service';
3+
4+
const mockConfig = {
5+
getPhoenixApiUrl: () => undefined as string | undefined,
6+
getPhoenixApiKey: () => undefined as string | undefined,
7+
getPhoenixAgentId: () => undefined as string | undefined,
8+
};
9+
10+
describe('GithubTokenRefreshService', () => {
11+
let service: GithubTokenRefreshService;
12+
const envBackup: Record<string, string | undefined> = {};
13+
14+
beforeEach(() => {
15+
envBackup.MCP_CONFIG_JSON = process.env.MCP_CONFIG_JSON;
16+
delete process.env.MCP_CONFIG_JSON;
17+
service = new GithubTokenRefreshService(mockConfig as never);
18+
});
19+
20+
afterEach(() => {
21+
service.onModuleDestroy();
22+
process.env.MCP_CONFIG_JSON = envBackup.MCP_CONFIG_JSON;
23+
});
24+
25+
test('skips refresh when Phoenix config is missing', async () => {
26+
mockConfig.getPhoenixApiUrl = () => undefined;
27+
mockConfig.getPhoenixApiKey = () => undefined;
28+
mockConfig.getPhoenixAgentId = () => undefined;
29+
30+
const result = await service.refreshToken();
31+
expect(result).toBeNull();
32+
});
33+
34+
test('fetches token and updates MCP_CONFIG_JSON', async () => {
35+
mockConfig.getPhoenixApiUrl = () => 'https://phoenix.test';
36+
mockConfig.getPhoenixApiKey = () => 'plgr_test123';
37+
mockConfig.getPhoenixAgentId = () => '42';
38+
39+
const originalFetch = globalThis.fetch;
40+
globalThis.fetch = mock(async () =>
41+
new Response(JSON.stringify({ token: 'ghs_fresh_token', expires_in: 3000 }), {
42+
status: 200,
43+
headers: { 'Content-Type': 'application/json' },
44+
})
45+
) as typeof fetch;
46+
47+
try {
48+
const result = await service.refreshToken();
49+
50+
expect(result).toBe('ghs_fresh_token');
51+
expect(globalThis.fetch).toHaveBeenCalledWith(
52+
'https://phoenix.test/api/agents/42/github_token',
53+
{
54+
method: 'GET',
55+
headers: { Authorization: 'Bearer plgr_test123' },
56+
}
57+
);
58+
59+
// Verify MCP_CONFIG_JSON was updated
60+
const config = JSON.parse(process.env.MCP_CONFIG_JSON!);
61+
expect(config.mcpServers.github.env.GITHUB_PERSONAL_ACCESS_TOKEN).toBe(
62+
'ghs_fresh_token'
63+
);
64+
} finally {
65+
globalThis.fetch = originalFetch;
66+
}
67+
});
68+
69+
test('returns null on 404 (no GitHub App installed)', async () => {
70+
mockConfig.getPhoenixApiUrl = () => 'https://phoenix.test';
71+
mockConfig.getPhoenixApiKey = () => 'plgr_test123';
72+
mockConfig.getPhoenixAgentId = () => '42';
73+
74+
const originalFetch = globalThis.fetch;
75+
globalThis.fetch = mock(async () =>
76+
new Response('Not Found', { status: 404 })
77+
) as typeof fetch;
78+
79+
try {
80+
const result = await service.refreshToken();
81+
expect(result).toBeNull();
82+
} finally {
83+
globalThis.fetch = originalFetch;
84+
}
85+
});
86+
87+
test('returns null on server error', async () => {
88+
mockConfig.getPhoenixApiUrl = () => 'https://phoenix.test';
89+
mockConfig.getPhoenixApiKey = () => 'plgr_test123';
90+
mockConfig.getPhoenixAgentId = () => '42';
91+
92+
const originalFetch = globalThis.fetch;
93+
globalThis.fetch = mock(async () =>
94+
new Response('Internal Server Error', { status: 500 })
95+
) as typeof fetch;
96+
97+
try {
98+
const result = await service.refreshToken();
99+
expect(result).toBeNull();
100+
} finally {
101+
globalThis.fetch = originalFetch;
102+
}
103+
});
104+
105+
test('returns null on network error', async () => {
106+
mockConfig.getPhoenixApiUrl = () => 'https://phoenix.test';
107+
mockConfig.getPhoenixApiKey = () => 'plgr_test123';
108+
mockConfig.getPhoenixAgentId = () => '42';
109+
110+
const originalFetch = globalThis.fetch;
111+
globalThis.fetch = mock(async () => {
112+
throw new Error('ECONNREFUSED');
113+
}) as typeof fetch;
114+
115+
try {
116+
const result = await service.refreshToken();
117+
expect(result).toBeNull();
118+
} finally {
119+
globalThis.fetch = originalFetch;
120+
}
121+
});
122+
123+
test('preserves existing MCP config when updating token', async () => {
124+
process.env.MCP_CONFIG_JSON = JSON.stringify({
125+
mcpServers: {
126+
'playgrounds-dev': { serverUrl: 'https://test/mcp' },
127+
Sentry: { serverUrl: 'https://sentry/mcp' },
128+
},
129+
});
130+
131+
mockConfig.getPhoenixApiUrl = () => 'https://phoenix.test';
132+
mockConfig.getPhoenixApiKey = () => 'plgr_test123';
133+
mockConfig.getPhoenixAgentId = () => '42';
134+
135+
const originalFetch = globalThis.fetch;
136+
globalThis.fetch = mock(async () =>
137+
new Response(JSON.stringify({ token: 'ghs_new_token', expires_in: 3000 }), {
138+
status: 200,
139+
headers: { 'Content-Type': 'application/json' },
140+
})
141+
) as typeof fetch;
142+
143+
try {
144+
await service.refreshToken();
145+
146+
const config = JSON.parse(process.env.MCP_CONFIG_JSON!);
147+
expect(config.mcpServers['playgrounds-dev'].serverUrl).toBe(
148+
'https://test/mcp'
149+
);
150+
expect(config.mcpServers.Sentry.serverUrl).toBe('https://sentry/mcp');
151+
expect(config.mcpServers.github.env.GITHUB_PERSONAL_ACCESS_TOKEN).toBe(
152+
'ghs_new_token'
153+
);
154+
} finally {
155+
globalThis.fetch = originalFetch;
156+
}
157+
});
158+
});
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
2+
import { execSync } from 'node:child_process';
3+
import { ConfigService } from '../config/config.service';
4+
import { writeMcpConfig } from '../config/mcp-config-writer';
5+
6+
const REFRESH_INTERVAL_MS = 50 * 60 * 1000; // 50 minutes
7+
8+
/**
9+
* Periodically fetches a fresh GitHub token from the Phoenix API
10+
* and updates the MCP config so the GitHub MCP server always has
11+
* a valid token. Runs every ~50 minutes (installation tokens last 1 hour).
12+
*
13+
* After updating the config files, kills the running server-github process
14+
* so the AI CLI respawns it with the fresh token.
15+
*/
16+
@Injectable()
17+
export class GithubTokenRefreshService implements OnModuleInit, OnModuleDestroy {
18+
private readonly logger = new Logger(GithubTokenRefreshService.name);
19+
private timer: ReturnType<typeof setInterval> | null = null;
20+
private isInitialRefresh = true;
21+
22+
constructor(private readonly config: ConfigService) {}
23+
24+
async onModuleInit(): Promise<void> {
25+
// Initial refresh at startup
26+
await this.refreshToken();
27+
this.isInitialRefresh = false;
28+
29+
// Schedule periodic refresh
30+
this.timer = setInterval(() => {
31+
void this.refreshToken();
32+
}, REFRESH_INTERVAL_MS);
33+
34+
this.logger.log(
35+
`GitHub token refresh scheduled every ${REFRESH_INTERVAL_MS / 60000} minutes`
36+
);
37+
}
38+
39+
onModuleDestroy(): void {
40+
if (this.timer) {
41+
clearInterval(this.timer);
42+
this.timer = null;
43+
}
44+
}
45+
46+
/**
47+
* Fetches a fresh GitHub installation token from Phoenix API
48+
* and rewrites the MCP config with the new token.
49+
*/
50+
async refreshToken(): Promise<string | null> {
51+
const apiUrl = this.config.getPhoenixApiUrl();
52+
const apiKey = this.config.getPhoenixApiKey();
53+
const agentId = this.config.getPhoenixAgentId();
54+
55+
if (!apiUrl || !apiKey || !agentId) {
56+
this.logger.debug(
57+
'Phoenix API config missing — skipping GitHub token refresh'
58+
);
59+
return null;
60+
}
61+
62+
const url = `${apiUrl}/api/agents/${agentId}/github_token`;
63+
64+
try {
65+
const res = await fetch(url, {
66+
method: 'GET',
67+
headers: {
68+
Authorization: `Bearer ${apiKey}`,
69+
},
70+
});
71+
72+
if (!res.ok) {
73+
// 404 = no GitHub App installed, not an error
74+
if (res.status === 404) {
75+
this.logger.debug('No GitHub App installation for this agent owner');
76+
return null;
77+
}
78+
this.logger.warn(
79+
`GitHub token refresh failed: ${res.status} ${res.statusText}`
80+
);
81+
return null;
82+
}
83+
84+
const data = (await res.json()) as { token?: string; expires_in?: number };
85+
const token = data.token;
86+
87+
if (!token) {
88+
this.logger.warn('GitHub token response missing token field');
89+
return null;
90+
}
91+
92+
// Update the MCP config JSON env var with the fresh token, then rewrite config
93+
this.updateGithubTokenInMcpConfig(token);
94+
writeMcpConfig();
95+
96+
// On periodic refreshes (not initial), kill the running GitHub MCP server
97+
// so the AI CLI respawns it with the fresh token from the updated config
98+
if (!this.isInitialRefresh) {
99+
this.killGithubMcpServer();
100+
}
101+
102+
this.logger.log(
103+
`GitHub token refreshed (expires in ${data.expires_in ?? '?'}s)`
104+
);
105+
return token;
106+
} catch (err) {
107+
this.logger.warn(`GitHub token refresh error: ${err}`);
108+
return null;
109+
}
110+
}
111+
112+
/**
113+
* Patches the MCP_CONFIG_JSON env var to include the fresh GitHub token.
114+
* If the github server entry already exists, updates the token.
115+
* If it doesn't exist, adds it.
116+
*/
117+
private updateGithubTokenInMcpConfig(token: string): void {
118+
const mcpRaw = process.env.MCP_CONFIG_JSON;
119+
let config: Record<string, unknown>;
120+
121+
try {
122+
config = mcpRaw ? JSON.parse(mcpRaw) : {};
123+
} catch {
124+
config = {};
125+
}
126+
127+
const servers = (config.mcpServers as Record<string, unknown>) ?? {};
128+
129+
servers['github'] = {
130+
command: 'npx',
131+
args: ['-y', '@modelcontextprotocol/server-github'],
132+
env: { GITHUB_PERSONAL_ACCESS_TOKEN: token },
133+
};
134+
135+
config.mcpServers = servers;
136+
process.env.MCP_CONFIG_JSON = JSON.stringify(config);
137+
}
138+
139+
/**
140+
* Kills any running server-github MCP process so the AI CLI will
141+
* respawn it with the fresh token from the updated config files.
142+
* Uses pkill to find processes matching the GitHub MCP server package name.
143+
* Failures are silently ignored (process may not be running).
144+
*/
145+
private killGithubMcpServer(): void {
146+
try {
147+
execSync('pkill -f "server-github" 2>/dev/null || true', {
148+
timeout: 5000,
149+
});
150+
this.logger.log('Killed running GitHub MCP server — will respawn with fresh token');
151+
} catch {
152+
// Process not running or pkill not available — both are fine
153+
}
154+
}
155+
}

0 commit comments

Comments
 (0)