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
4 changes: 2 additions & 2 deletions api/server/services/ToolService.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ const {
isFatalAgentInitializationError,
codeExecutionAuthHeaders,
createLaneGitRecorder,
isPullRequestFeatureActive,
resolvePullRequestRecording,
createAttachedWorkspaceBashTool,
createRepositoryInstructionSource,
createRepositoryInstructionLoader,
Expand Down Expand Up @@ -2369,7 +2369,7 @@ async function loadToolsForExecution({
workspaceId: codeExecutionContext.codeWorkspace.workspaceId,
workspaceInstanceId: codeExecutionContext.codeWorkspace.workspaceInstanceId,
onLaneGit: await createLaneGitRecorder({
enabled: isPullRequestFeatureActive(
...resolvePullRequestRecording(
req.config?.endpoints?.agents?.pullRequests,
process.env,
),
Expand Down
8 changes: 8 additions & 0 deletions e2e/config/librechat.e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,14 @@ endpoints:
- run_in_background
- stateful_code_sessions
# __E2E_CODE_BRIDGE_CONFIG__
# Pull request lookups reach the fake GitHub Enterprise host
# (e2e/setup/fake-git-host-server.js), and only for the repository the pull request
# scenarios seed, so no other conversation ever starts a lookup.
pullRequests:
host: localhost:8793
token: '${E2E_GIT_HOST_TOKEN}'
allowedRepositories:
- e2e-org/e2e-app
# Keep the shared mock profile non-interactive except for the dedicated
# approval probe. This exercises real HITL pause/resume without wedging the
# existing file-authoring, steering, background-tool, or MCP specs.
Expand Down
89 changes: 89 additions & 0 deletions e2e/playwright.config.mock.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { defineConfig, devices } from '@playwright/test';
import { execFileSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import { getLocalE2EEnv, getE2EBaseURL } from './setup/env';
Expand Down Expand Up @@ -38,6 +39,12 @@ const ragServerPath = path.resolve(rootPath, 'e2e/setup/fake-rag-server.js');
* Kept clear of the MCP (8765/8766) and label (8889) fixtures. */
const CODE_API_PORT = process.env.E2E_CODE_API_PORT || '8790';
const RAG_API_PORT = process.env.E2E_RAG_API_PORT || '8791';
/** The fake GitHub Enterprise host pull request lookups reach (`pullRequests.host`). The template
* hard-codes 8793; `writeRuntimeMockConfig` substitutes any override into the generated copy. */
const gitHostServerPath = path.resolve(rootPath, 'e2e/setup/fake-git-host-server.js');
const GIT_HOST_PORT = process.env.E2E_GIT_HOST_PORT || '8793';
const gitHostKeyPath = path.resolve(rootPath, 'e2e/.generated/git-host.key');
const gitHostCertPath = path.resolve(rootPath, 'e2e/.generated/git-host.crt');
const fakeModelHookPath = path.resolve(rootPath, 'e2e/setup/fake-model.js');
/** Model-fixture record mode: the run hook taps the REAL provider stream into a
* replayable fixture instead of overriding the model (e2e/setup/model-replay.js). */
Expand Down Expand Up @@ -219,6 +226,9 @@ const baseEnv = {
...(externalCodeBaseUrl ? { LIBRECHAT_CODE_BASEURL_STATEFUL: externalCodeBaseUrl } : {}),
...codeApiKeyEnv,
RAG_API_URL: `http://127.0.0.1:${RAG_API_PORT}`,
/** Pull request lookups go to the fake git host, which trusts any bearer token. */
E2E_GIT_HOST_TOKEN: 'e2e-git-host-token',
NODE_EXTRA_CA_CERTS: gitHostCertPath,
...vanillaOverrides,
};

Expand Down Expand Up @@ -383,6 +393,9 @@ function writeRuntimeMockConfig() {
if (enableDynamicMcp && MCP_DYNAMIC_PORT !== '8766') {
config = config.split('127.0.0.1:8766').join(`127.0.0.1:${MCP_DYNAMIC_PORT}`);
}
if (GIT_HOST_PORT !== '8793') {
config = config.split('localhost:8793').join(`localhost:${GIT_HOST_PORT}`);
}
if (MCP_OAUTH_PORT !== '8767') {
config = config.split('127.0.0.1:8767').join(`127.0.0.1:${MCP_OAUTH_PORT}`);
}
Expand All @@ -394,6 +407,65 @@ function writeRuntimeMockConfig() {
}
}

/** A kept certificate is replaced when it expires within this window, so no run outlives it. */
const GIT_HOST_CERT_MIN_SECONDS = 7 * 24 * 60 * 60;

function hasFreshGitHostCertificate(): boolean {
if (!fs.existsSync(gitHostKeyPath) || !fs.existsSync(gitHostCertPath)) {
return false;
}
try {
/** Exits non-zero when the certificate expires within the window, or cannot be read. */
execFileSync(
'openssl',
['x509', '-checkend', String(GIT_HOST_CERT_MIN_SECONDS), '-noout', '-in', gitHostCertPath],
{ stdio: 'ignore' },
);
return true;
} catch {
return false;
}
}

/**
* The fake git host speaks TLS, as every pull request host must, with a certificate for
* `localhost` the app server trusts through `NODE_EXTRA_CA_CERTS`. Node reads that file once at
* startup, so it is made here, before any web server starts, and kept while it is valid for a
* week more. The config module loads again in every worker, which must not swap the pair under a
* running server, so only the main process ever replaces one.
*/
function ensureGitHostCertificate() {
const exists = fs.existsSync(gitHostKeyPath) && fs.existsSync(gitHostCertPath);
if (exists && process.env.TEST_WORKER_INDEX != null) {
return;
}
if (hasFreshGitHostCertificate()) {
return;
}
fs.mkdirSync(path.dirname(gitHostCertPath), { recursive: true });
execFileSync(
'openssl',
[
'req',
'-x509',
'-newkey',
'rsa:2048',
'-nodes',
'-keyout',
gitHostKeyPath,
'-out',
gitHostCertPath,
'-days',
'30',
'-subj',
'/CN=localhost',
'-addext',
'subjectAltName=DNS:localhost',
],
{ stdio: 'ignore' },
);
}

function neutralizeCredentialEnv(env: NodeJS.ProcessEnv, keep: Set<string>) {
for (const key of Object.keys(env)) {
if (!keep.has(key) && SECRET_KEY_PATTERN.test(key)) {
Expand Down Expand Up @@ -424,6 +496,7 @@ function neutralizeDotenvSecrets(envFile: string, keep: Set<string>) {
}

writeRuntimeMockConfig();
ensureGitHostCertificate();
neutralizeCredentialEnv(process.env, preservedCredentialEnvKeys);
Object.assign(process.env, baseEnv);
neutralizeDotenvSecrets(path.resolve(rootPath, '.env'), preservedCredentialEnvKeys);
Expand Down Expand Up @@ -549,6 +622,22 @@ export default defineConfig({
timeout: 60_000,
reuseExistingServer: false,
},
{
// Fake GitHub Enterprise host for the pull request scenarios (`pullRequests.host`).
command: `node ${gitHostServerPath}`,
cwd: rootPath,
env: {
...process.env,
E2E_GIT_HOST_PORT: GIT_HOST_PORT,
E2E_GIT_HOST_KEY: gitHostKeyPath,
E2E_GIT_HOST_CERT: gitHostCertPath,
},
url: `https://localhost:${GIT_HOST_PORT}/`,
ignoreHTTPSErrors: true,
stdout: 'pipe',
timeout: 60_000,
reuseExistingServer: false,
},
{
// Fake RAG (vector DB) API for file-provisioning specs (RAG_API_URL).
command: `node ${ragServerPath}`,
Expand Down
126 changes: 126 additions & 0 deletions e2e/setup/fake-git-host-server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/**
* HTTPS fixture standing in for a GitHub Enterprise Server host, for the pull request scenarios.
*
* The mock config points `endpoints.agents.pullRequests.host` at `localhost:<port>`, so the
* server's GitHub source sends every request of a lookup to `/api/v3` here, over TLS with the
* self-signed certificate `playwright.config.mock.ts` generates and hands the app server through
* `NODE_EXTRA_CA_CERTS`. It serves one repository with one open pull request per branch named in
* `PULLS`, and records what it was asked so a scenario can prove which host a lookup reached.
*
* `GET /__requests` lists the recorded requests (method, path, whether a bearer token came with
* it); `POST /__reset` clears them. Neither is under `/api/v3`, so a lookup never reaches them.
*/
const fs = require('fs');
const https = require('https');

const PORT = Number(process.env.E2E_GIT_HOST_PORT) || 8793;
const HOST = `localhost:${PORT}`;
const REPO = 'e2e-org/e2e-app';
const API_PREFIX = '/api/v3';

/** One open pull request per branch; the scenarios record the same head on their lane. */
const PULLS = {
'feat/enterprise-header': {
number: 7,
title: 'Enterprise header pull request',
sha: 'a'.repeat(40),
},
'feat/override-ignored': {
number: 8,
title: 'Override ignored pull request',
sha: 'b'.repeat(40),
},
};

const requests = [];

const byNumber = (number) =>
Object.entries(PULLS).find(([, pull]) => pull.number === number) ?? null;

function listItem([, pull]) {
return { number: pull.number, state: 'open', head: { sha: pull.sha } };
}

function detail([branch, pull]) {
return {
number: pull.number,
title: pull.title,
html_url: `https://${HOST}/${REPO}/pull/${pull.number}`,
state: 'open',
merged: false,
draft: false,
additions: 12,
deletions: 3,
mergeable: true,
head: { sha: pull.sha, ref: branch, repo: { full_name: REPO } },
};
}

function send(res, status, body) {
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));
}

function route(url) {
const repoBase = `${API_PREFIX}/repos/${REPO}`;
if (!url.pathname.startsWith(repoBase)) return null;
const rest = url.pathname.slice(repoBase.length);
if (rest === '/pulls') {
const state = url.searchParams.get('state');
const headFilter = url.searchParams.get('head') ?? '';
const branch = headFilter.slice(headFilter.indexOf(':') + 1);
const pull = PULLS[branch];
return [200, state === 'open' && pull != null ? [listItem([branch, pull])] : []];
}
const pullMatch = /^\/pulls\/(\d+)$/.exec(rest);
if (pullMatch) {
const found = byNumber(Number(pullMatch[1]));
return found ? [200, detail(found)] : [404, { message: 'Not Found' }];
}
if (/^\/commits\/[a-f0-9]{40}\/check-runs$/.test(rest)) {
return [200, { total_count: 1, check_runs: [{ status: 'completed', conclusion: 'success' }] }];
}
if (/^\/commits\/[a-f0-9]{40}\/pulls$/.test(rest)) {
return [200, []];
}
return null;
}

const server = https.createServer(
{
key: fs.readFileSync(process.env.E2E_GIT_HOST_KEY),
cert: fs.readFileSync(process.env.E2E_GIT_HOST_CERT),
},
(req, res) => {
const url = new URL(req.url ?? '/', `https://${HOST}`);
if (url.pathname === '/') {
send(res, 200, { ok: true });
return;
}
if (url.pathname === '/__requests' && req.method === 'GET') {
send(res, 200, requests);
return;
}
if (url.pathname === '/__reset' && req.method === 'POST') {
requests.length = 0;
send(res, 200, { ok: true });
return;
}
requests.push({
method: req.method,
path: `${url.pathname}${url.search}`,
authorized: (req.headers.authorization ?? '').startsWith('Bearer '),
});
const answer = req.method === 'GET' ? route(url) : null;
if (answer == null) {
send(res, 404, { message: 'Not Found' });
return;
}
send(res, answer[0], answer[1]);
},
);

/** Every local address, since `localhost` may resolve to either family. */
server.listen(PORT, () => {
console.log(`[fake-git-host] listening on https://${HOST}`);
});
Loading
Loading