mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-06 06:28:10 +00:00
🧲 feat: Enable Secure Attached Environment Pairing (#15355)
* feat: add secure code environment pairing * fix: satisfy code environment type checks * fix: secure code environment administration * fix: isolate code pairing control plane * fix: validate code pairing control responses * fix: secure code pairing transport * fix: validate code pairing wire format * fix: harden pairing secret lookup
This commit is contained in:
parent
7533d138fa
commit
a9ccac8656
15 changed files with 810 additions and 0 deletions
|
|
@ -520,6 +520,7 @@ if (cluster.isMaster) {
|
|||
app.use('/api/admin/insights', routes.insights);
|
||||
app.use('/api/admin', routes.adminAuth);
|
||||
app.use('/api/admin/skills', routes.adminSkills);
|
||||
app.use('/api/admin/code-environments', routes.adminCodeEnvironments);
|
||||
app.use('/api/actions', routes.actions);
|
||||
app.use('/api/keys', routes.keys);
|
||||
app.use('/api/api-keys', routes.apiKeys);
|
||||
|
|
|
|||
|
|
@ -367,6 +367,7 @@ const startServer = async () => {
|
|||
app.use('/api/admin/insights', routes.insights);
|
||||
app.use('/api/admin', routes.adminAuth);
|
||||
app.use('/api/admin/config', routes.adminConfig);
|
||||
app.use('/api/admin/code-environments', routes.adminCodeEnvironments);
|
||||
app.use('/api/admin/langfuse', routes.adminLangfuse);
|
||||
app.use('/api/admin/grants', routes.adminGrants);
|
||||
app.use('/api/admin/groups', routes.adminGroups);
|
||||
|
|
|
|||
17
api/server/routes/admin/code.js
Normal file
17
api/server/routes/admin/code.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
const express = require('express');
|
||||
const { createAdminCodeEnvironmentHandlers } = require('@librechat/api');
|
||||
const { SystemCapabilities } = require('@librechat/data-schemas');
|
||||
const { requireCapability } = require('~/server/middleware/roles/capabilities');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
const { requireJwtAuth } = require('~/server/middleware');
|
||||
|
||||
const router = express.Router();
|
||||
const requireAdminAccess = requireCapability(SystemCapabilities.ACCESS_ADMIN);
|
||||
const requireCodeEnvironmentManage = requireCapability(SystemCapabilities.MANAGE_CODE_ENVIRONMENTS);
|
||||
const handlers = createAdminCodeEnvironmentHandlers({ getAppConfig });
|
||||
|
||||
router.use(requireJwtAuth, requireAdminAccess, requireCodeEnvironmentManage);
|
||||
router.post('/:environmentId/pairings', handlers.createPairing);
|
||||
router.post('/:environmentId/revoke', handlers.revokeWorker);
|
||||
|
||||
module.exports = router;
|
||||
74
api/server/routes/admin/code.test.js
Normal file
74
api/server/routes/admin/code.test.js
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
const middlewareCalls = [];
|
||||
const mockRequireJwtAuth = jest.fn((req, _res, next) => {
|
||||
middlewareCalls.push('jwt');
|
||||
req.user = { id: 'admin-1', role: 'ADMIN' };
|
||||
next();
|
||||
});
|
||||
const mockRequireCapability = jest.fn((capability) => (req, _res, next) => {
|
||||
middlewareCalls.push(capability);
|
||||
next();
|
||||
});
|
||||
const mockHandlers = {
|
||||
createPairing: jest.fn((req, res) =>
|
||||
res.status(200).json({ operation: 'pair', environmentId: req.params.environmentId }),
|
||||
),
|
||||
revokeWorker: jest.fn((req, res) =>
|
||||
res.status(200).json({ operation: 'revoke', environmentId: req.params.environmentId }),
|
||||
),
|
||||
};
|
||||
const mockGetAppConfig = jest.fn();
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
SystemCapabilities: {
|
||||
ACCESS_ADMIN: 'access:admin',
|
||||
MANAGE_CODE_ENVIRONMENTS: 'manage:code_environments',
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
createAdminCodeEnvironmentHandlers: jest.fn(() => mockHandlers),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/middleware/roles/capabilities', () => ({
|
||||
requireCapability: mockRequireCapability,
|
||||
}));
|
||||
|
||||
jest.mock('~/server/middleware', () => ({
|
||||
requireJwtAuth: mockRequireJwtAuth,
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Config', () => ({
|
||||
getAppConfig: mockGetAppConfig,
|
||||
}));
|
||||
|
||||
function createApp() {
|
||||
delete require.cache[require.resolve('./code')];
|
||||
const router = require('./code');
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/code-environments', router);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('admin code environment routes', () => {
|
||||
beforeEach(() => {
|
||||
middlewareCalls.length = 0;
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['pairings', 'createPairing', 'pair'],
|
||||
['revoke', 'revokeWorker', 'revoke'],
|
||||
])('protects and delegates the %s operation', async (path, handlerName, operation) => {
|
||||
const response = await request(createApp())
|
||||
.post(`/api/admin/code-environments/attached-vm/${path}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toEqual({ operation, environmentId: 'attached-vm' });
|
||||
expect(middlewareCalls).toEqual(['jwt', 'access:admin', 'manage:code_environments']);
|
||||
expect(mockHandlers[handlerName]).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -3,6 +3,7 @@ const assistants = require('./assistants');
|
|||
const categories = require('./categories');
|
||||
const adminAuth = require('./admin/auth');
|
||||
const adminConfig = require('./admin/config');
|
||||
const adminCodeEnvironments = require('./admin/code');
|
||||
const adminLangfuse = require('./admin/langfuse');
|
||||
const adminGrants = require('./admin/grants');
|
||||
const adminGroups = require('./admin/groups');
|
||||
|
|
@ -47,6 +48,7 @@ module.exports = {
|
|||
auth,
|
||||
adminAuth,
|
||||
adminConfig,
|
||||
adminCodeEnvironments,
|
||||
adminLangfuse,
|
||||
adminGrants,
|
||||
adminGroups,
|
||||
|
|
|
|||
|
|
@ -136,6 +136,9 @@ const baseEnv = {
|
|||
ASSISTANTS_API_KEY: 'e2e-mock-assistants-key',
|
||||
ASSISTANTS_BASE_URL: `http://127.0.0.1:${ASSISTANTS_PORT}/v1`,
|
||||
ASSISTANTS_MODELS: 'gpt-4o-mini',
|
||||
...(process.env.E2E_CODE_BRIDGE_ADMIN_TOKEN
|
||||
? { E2E_CODE_BRIDGE_ADMIN_TOKEN: process.env.E2E_CODE_BRIDGE_ADMIN_TOKEN }
|
||||
: {}),
|
||||
...vanillaOverrides,
|
||||
};
|
||||
|
||||
|
|
@ -210,6 +213,14 @@ function writeRuntimeMockConfig() {
|
|||
.replace('# __E2E_DYNAMIC_MCP_STDIO_ENV__', dynamicMcpConfig.stdioEnv)
|
||||
.replace('# __E2E_DYNAMIC_MCP_NETWORK_SERVERS__', dynamicMcpConfig.networkServers);
|
||||
const codeBridgeURL = process.env.E2E_CODE_BRIDGE_URL;
|
||||
const codeBridgePairing = process.env.E2E_CODE_BRIDGE_ADMIN_TOKEN
|
||||
? [
|
||||
' owner: deployment',
|
||||
' pairing:',
|
||||
' workerId: e2e-vm',
|
||||
' tokenEnv: E2E_CODE_BRIDGE_ADMIN_TOKEN',
|
||||
]
|
||||
: [];
|
||||
config = config.replace(
|
||||
'# __E2E_CODE_BRIDGE_CONFIG__',
|
||||
codeBridgeURL
|
||||
|
|
@ -223,6 +234,7 @@ function writeRuntimeMockConfig() {
|
|||
' type: attached',
|
||||
` baseURL: ${JSON.stringify(codeBridgeURL)}`,
|
||||
' default: true',
|
||||
...codeBridgePairing,
|
||||
].join('\n ')
|
||||
: '# __E2E_CODE_BRIDGE_CONFIG__',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,13 @@ import {
|
|||
|
||||
const CODE_VALUE = 'librechat-bridge-persisted';
|
||||
|
||||
interface PairingResponse {
|
||||
environmentId: string;
|
||||
workerId: string;
|
||||
code: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
test.describe('attached stateful code environment', () => {
|
||||
test.skip(!process.env.E2E_CODE_BRIDGE_URL, 'E2E_CODE_BRIDGE_URL is required');
|
||||
|
||||
|
|
@ -26,6 +33,21 @@ test.describe('attached stateful code environment', () => {
|
|||
|
||||
try {
|
||||
const token = await getAccessToken(page);
|
||||
if (process.env.E2E_CODE_BRIDGE_ADMIN_TOKEN) {
|
||||
const pairing = await requestJson<PairingResponse>(page, {
|
||||
path: '/api/admin/code-environments/e2e-vm/pairings',
|
||||
token,
|
||||
method: 'POST',
|
||||
});
|
||||
expect(pairing).toMatchObject({
|
||||
environmentId: 'e2e-vm',
|
||||
workerId: 'e2e-vm',
|
||||
code: expect.stringMatching(/^[A-Za-z0-9_-]{32}$/),
|
||||
expiresAt: expect.any(String),
|
||||
});
|
||||
expect(pairing).not.toHaveProperty('token');
|
||||
expect(Number.isFinite(Date.parse(pairing.expiresAt))).toBe(true);
|
||||
}
|
||||
const agent = await requestJson<AgentDetail>(page, {
|
||||
path: '/api/agents',
|
||||
token,
|
||||
|
|
|
|||
|
|
@ -578,6 +578,12 @@ endpoints:
|
|||
# name: Engineering VM
|
||||
# type: attached
|
||||
# baseURL: https://code-bridge.example.com/v1
|
||||
# # Enables the admin pairing API without placing the administrator
|
||||
# # secret in YAML or returning its environment-variable name to users.
|
||||
# owner: deployment
|
||||
# pairing:
|
||||
# workerId: engineering-vm
|
||||
# tokenEnv: CODE_BRIDGE_ADMIN_TOKEN
|
||||
# # (optional) Trusted origin for internal event delivery. By default, LibreChat
|
||||
# # uses its own bound listener; override only for an internal TLS/front-door route.
|
||||
# eventDriven:
|
||||
|
|
|
|||
313
packages/api/src/admin/code.spec.ts
Normal file
313
packages/api/src/admin/code.spec.ts
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
import type { AppConfig } from '@librechat/data-schemas';
|
||||
import type { Response } from 'express';
|
||||
import type { ServerRequest } from '~/types/http';
|
||||
|
||||
import { createAdminCodeEnvironmentHandlers } from './code';
|
||||
|
||||
interface MockResponse extends Response {
|
||||
statusCode: number;
|
||||
body?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function mockResponse(): MockResponse {
|
||||
const response = {
|
||||
statusCode: 200,
|
||||
body: undefined as Record<string, unknown> | undefined,
|
||||
status(code: number) {
|
||||
response.statusCode = code;
|
||||
return response;
|
||||
},
|
||||
json(body: Record<string, unknown>) {
|
||||
response.body = body;
|
||||
return response;
|
||||
},
|
||||
};
|
||||
return response as unknown as MockResponse;
|
||||
}
|
||||
|
||||
function request(): ServerRequest {
|
||||
return {
|
||||
params: { environmentId: 'attached-vm' },
|
||||
user: { id: 'admin-1', role: 'ADMIN' },
|
||||
} as unknown as ServerRequest;
|
||||
}
|
||||
|
||||
function config(): AppConfig {
|
||||
return {
|
||||
endpoints: {
|
||||
agents: {
|
||||
statefulCodeSessions: {
|
||||
allowedEnvironments: ['conversation'],
|
||||
environments: [
|
||||
{
|
||||
id: 'attached-vm',
|
||||
name: 'Attached VM',
|
||||
type: 'attached',
|
||||
baseURL: 'https://bridge.example.com/v1/',
|
||||
default: true,
|
||||
owner: 'deployment',
|
||||
pairing: {
|
||||
workerId: 'vm-1',
|
||||
tokenEnv: 'CODE_BRIDGE_ADMIN_TOKEN',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as AppConfig;
|
||||
}
|
||||
|
||||
describe('createAdminCodeEnvironmentHandlers', () => {
|
||||
it('creates a one-time pairing code without exposing the administrator token', async () => {
|
||||
const fetchImpl = jest.fn().mockResolvedValue(
|
||||
Response.json({
|
||||
protocolVersion: 1,
|
||||
workerId: 'vm-1',
|
||||
code: 'one-time-code-value-that-is-long',
|
||||
expiresAt: '2099-08-30T12:00:00.000Z',
|
||||
}),
|
||||
);
|
||||
const handlers = createAdminCodeEnvironmentHandlers({
|
||||
getAppConfig: jest.fn().mockResolvedValue(config()),
|
||||
readSecret: jest.fn().mockReturnValue('administrator-bootstrap-token'),
|
||||
fetchImpl,
|
||||
});
|
||||
const response = mockResponse();
|
||||
|
||||
await handlers.createPairing(request(), response);
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
'https://bridge.example.com/v1/bridge/pairings',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer administrator-bootstrap-token',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ workerId: 'vm-1' }),
|
||||
}),
|
||||
);
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
environmentId: 'attached-vm',
|
||||
workerId: 'vm-1',
|
||||
code: 'one-time-code-value-that-is-long',
|
||||
expiresAt: '2099-08-30T12:00:00.000Z',
|
||||
});
|
||||
expect(JSON.stringify(response.body)).not.toContain('administrator-bootstrap-token');
|
||||
});
|
||||
|
||||
it('uses only YAML config when resolving pairing secrets and destinations', async () => {
|
||||
const writableOverride = config();
|
||||
const overriddenEnvironment =
|
||||
writableOverride.endpoints?.agents?.statefulCodeSessions?.environments?.[0];
|
||||
if (overriddenEnvironment == null) {
|
||||
throw new Error('Expected the test code environment');
|
||||
}
|
||||
overriddenEnvironment.baseURL = 'https://attacker.example.com/v1';
|
||||
overriddenEnvironment.pairing = { workerId: 'vm-1', tokenEnv: 'DATABASE_URL' };
|
||||
const getAppConfig = jest.fn(async (options: { baseOnly?: boolean }) =>
|
||||
options.baseOnly === true ? config() : writableOverride,
|
||||
);
|
||||
const readSecret = jest.fn((name: string) =>
|
||||
name === 'CODE_BRIDGE_ADMIN_TOKEN' ? 'deployment-token' : 'sensitive-database-secret',
|
||||
);
|
||||
const fetchImpl = jest.fn().mockResolvedValue(
|
||||
Response.json({
|
||||
protocolVersion: 1,
|
||||
workerId: 'vm-1',
|
||||
code: 'one-time-code-value-that-is-long',
|
||||
expiresAt: '2099-08-30T12:00:00.000Z',
|
||||
}),
|
||||
);
|
||||
const handlers = createAdminCodeEnvironmentHandlers({
|
||||
getAppConfig,
|
||||
readSecret,
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
await handlers.createPairing(request(), mockResponse());
|
||||
|
||||
expect(getAppConfig).toHaveBeenCalledWith({ baseOnly: true });
|
||||
expect(readSecret).toHaveBeenCalledWith('CODE_BRIDGE_ADMIN_TOKEN');
|
||||
expect(readSecret).not.toHaveBeenCalledWith('DATABASE_URL');
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
'https://bridge.example.com/v1/bridge/pairings',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('fails closed before outbound traffic when the administrator token is unavailable', async () => {
|
||||
const fetchImpl = jest.fn();
|
||||
const handlers = createAdminCodeEnvironmentHandlers({
|
||||
getAppConfig: jest.fn().mockResolvedValue(config()),
|
||||
readSecret: jest.fn().mockReturnValue(undefined),
|
||||
fetchImpl,
|
||||
});
|
||||
const response = mockResponse();
|
||||
|
||||
await handlers.createPairing(request(), response);
|
||||
|
||||
expect(response.statusCode).toBe(503);
|
||||
expect(response.body).toEqual({ error: 'Code environment pairing is not configured' });
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats inherited process environment properties as missing secrets', async () => {
|
||||
const deploymentConfig = config();
|
||||
const environment = deploymentConfig.endpoints?.agents?.statefulCodeSessions?.environments?.[0];
|
||||
if (environment?.pairing == null) throw new Error('Expected the test pairing configuration');
|
||||
environment.pairing.tokenEnv = 'constructor';
|
||||
const fetchImpl = jest.fn();
|
||||
const handlers = createAdminCodeEnvironmentHandlers({
|
||||
getAppConfig: jest.fn().mockResolvedValue(deploymentConfig),
|
||||
fetchImpl,
|
||||
});
|
||||
const response = mockResponse();
|
||||
|
||||
await handlers.createPairing(request(), response);
|
||||
|
||||
expect(response.statusCode).toBe(503);
|
||||
expect(response.body).toEqual({ error: 'Code environment pairing is not configured' });
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('normalizes the bridge base URL before appending control paths', async () => {
|
||||
const deploymentConfig = config();
|
||||
const environment = deploymentConfig.endpoints?.agents?.statefulCodeSessions?.environments?.[0];
|
||||
if (environment == null) throw new Error('Expected the test code environment');
|
||||
environment.baseURL = ' https://bridge.example.com/v1/ ';
|
||||
const fetchImpl = jest.fn().mockResolvedValue(
|
||||
Response.json({
|
||||
protocolVersion: 1,
|
||||
workerId: 'vm-1',
|
||||
code: 'one-time-code-value-that-is-long',
|
||||
expiresAt: '2099-08-30T12:00:00.000Z',
|
||||
}),
|
||||
);
|
||||
const handlers = createAdminCodeEnvironmentHandlers({
|
||||
getAppConfig: jest.fn().mockResolvedValue(deploymentConfig),
|
||||
readSecret: jest.fn().mockReturnValue('administrator-bootstrap-token'),
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
await handlers.createPairing(request(), mockResponse());
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
'https://bridge.example.com/v1/bridge/pairings',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects insecure non-loopback pairing before reading or sending credentials', async () => {
|
||||
const deploymentConfig = config();
|
||||
const environment = deploymentConfig.endpoints?.agents?.statefulCodeSessions?.environments?.[0];
|
||||
if (environment == null) throw new Error('Expected the test code environment');
|
||||
environment.baseURL = 'http://bridge.example.com/v1';
|
||||
const readSecret = jest.fn().mockReturnValue('administrator-bootstrap-token');
|
||||
const fetchImpl = jest.fn();
|
||||
const handlers = createAdminCodeEnvironmentHandlers({
|
||||
getAppConfig: jest.fn().mockResolvedValue(deploymentConfig),
|
||||
readSecret,
|
||||
fetchImpl,
|
||||
});
|
||||
const response = mockResponse();
|
||||
|
||||
await handlers.createPairing(request(), response);
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.body).toEqual({
|
||||
error: 'Code environment pairing requires secure transport',
|
||||
});
|
||||
expect(readSecret).not.toHaveBeenCalled();
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an expired one-time pairing code', async () => {
|
||||
const fetchImpl = jest.fn().mockResolvedValue(
|
||||
Response.json({
|
||||
protocolVersion: 1,
|
||||
workerId: 'vm-1',
|
||||
code: 'expired-one-time-code-value',
|
||||
expiresAt: '2000-01-01T00:00:00.000Z',
|
||||
}),
|
||||
);
|
||||
const handlers = createAdminCodeEnvironmentHandlers({
|
||||
getAppConfig: jest.fn().mockResolvedValue(config()),
|
||||
readSecret: jest.fn().mockReturnValue('administrator-bootstrap-token'),
|
||||
fetchImpl,
|
||||
});
|
||||
const response = mockResponse();
|
||||
|
||||
await handlers.createPairing(request(), response);
|
||||
|
||||
expect(response.statusCode).toBe(502);
|
||||
expect(response.body).toEqual({ error: 'Code API returned an invalid pairing response' });
|
||||
});
|
||||
|
||||
it('rejects a pairing code outside the 32-character base64url wire format', async () => {
|
||||
const fetchImpl = jest.fn().mockResolvedValue(
|
||||
Response.json({
|
||||
protocolVersion: 1,
|
||||
workerId: 'vm-1',
|
||||
code: 'invalid code value with whitespace',
|
||||
expiresAt: '2099-08-30T12:00:00.000Z',
|
||||
}),
|
||||
);
|
||||
const handlers = createAdminCodeEnvironmentHandlers({
|
||||
getAppConfig: jest.fn().mockResolvedValue(config()),
|
||||
readSecret: jest.fn().mockReturnValue('administrator-bootstrap-token'),
|
||||
fetchImpl,
|
||||
});
|
||||
const response = mockResponse();
|
||||
|
||||
await handlers.createPairing(request(), response);
|
||||
|
||||
expect(response.statusCode).toBe(502);
|
||||
expect(response.body).toEqual({ error: 'Code API returned an invalid pairing response' });
|
||||
});
|
||||
|
||||
it('revokes the environment worker without returning bridge credentials', async () => {
|
||||
const fetchImpl = jest
|
||||
.fn()
|
||||
.mockResolvedValue(Response.json({ protocolVersion: 1, revoked: true }));
|
||||
const handlers = createAdminCodeEnvironmentHandlers({
|
||||
getAppConfig: jest.fn().mockResolvedValue(config()),
|
||||
readSecret: jest.fn().mockReturnValue('administrator-bootstrap-token'),
|
||||
fetchImpl,
|
||||
});
|
||||
const response = mockResponse();
|
||||
|
||||
await handlers.revokeWorker(request(), response);
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
'https://bridge.example.com/v1/bridge/workers/vm-1/revoke',
|
||||
expect.objectContaining({ method: 'POST', redirect: 'error' }),
|
||||
);
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
environmentId: 'attached-vm',
|
||||
workerId: 'vm-1',
|
||||
revoked: true,
|
||||
});
|
||||
expect(JSON.stringify(response.body)).not.toContain('administrator-bootstrap-token');
|
||||
});
|
||||
|
||||
it('rejects an invalid revocation acknowledgement', async () => {
|
||||
const fetchImpl = jest
|
||||
.fn()
|
||||
.mockResolvedValue(Response.json({ protocolVersion: 1, revoked: false }));
|
||||
const handlers = createAdminCodeEnvironmentHandlers({
|
||||
getAppConfig: jest.fn().mockResolvedValue(config()),
|
||||
readSecret: jest.fn().mockReturnValue('administrator-bootstrap-token'),
|
||||
fetchImpl,
|
||||
});
|
||||
const response = mockResponse();
|
||||
|
||||
await handlers.revokeWorker(request(), response);
|
||||
|
||||
expect(response.statusCode).toBe(502);
|
||||
expect(response.body).toEqual({ error: 'Code API returned an invalid revocation response' });
|
||||
});
|
||||
});
|
||||
212
packages/api/src/admin/code.ts
Normal file
212
packages/api/src/admin/code.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import { EModelEndpoint, isSecureCodeEnvironmentControlURL } from 'librechat-data-provider';
|
||||
|
||||
import type { AppConfig } from '@librechat/data-schemas';
|
||||
import type { Response } from 'express';
|
||||
import type { GetAppConfigOptions } from '~/app/service';
|
||||
import type { ServerRequest } from '~/types/http';
|
||||
|
||||
const CODE_BRIDGE_REQUEST_TIMEOUT_MS = 10_000;
|
||||
|
||||
type AgentsEndpointConfig = NonNullable<AppConfig['endpoints']>[EModelEndpoint.agents];
|
||||
type StatefulCodeSessionsConfig = NonNullable<
|
||||
NonNullable<AgentsEndpointConfig>['statefulCodeSessions']
|
||||
>;
|
||||
type ConfiguredCodeEnvironment = NonNullable<StatefulCodeSessionsConfig['environments']>[number];
|
||||
type FetchImpl = (
|
||||
input: Parameters<typeof fetch>[0],
|
||||
init?: Parameters<typeof fetch>[1],
|
||||
) => ReturnType<typeof fetch>;
|
||||
|
||||
interface CodePairingResponse {
|
||||
protocolVersion: number;
|
||||
workerId: string;
|
||||
code: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
interface CodeRevocationResponse {
|
||||
protocolVersion: number;
|
||||
revoked: true;
|
||||
}
|
||||
|
||||
export interface AdminCodeEnvironmentDeps {
|
||||
getAppConfig: (options: GetAppConfigOptions) => Promise<AppConfig>;
|
||||
readSecret?: (name: string) => string | undefined;
|
||||
fetchImpl?: FetchImpl;
|
||||
}
|
||||
|
||||
function environmentId(req: ServerRequest): string {
|
||||
const params = req.params as { environmentId?: string };
|
||||
return params.environmentId?.trim() ?? '';
|
||||
}
|
||||
|
||||
function findEnvironment(appConfig: AppConfig, id: string): ConfiguredCodeEnvironment | undefined {
|
||||
return appConfig.endpoints?.[EModelEndpoint.agents]?.statefulCodeSessions?.environments?.find(
|
||||
(environment) => environment.id === id,
|
||||
);
|
||||
}
|
||||
|
||||
function pairingConfig(environment: ConfiguredCodeEnvironment):
|
||||
| {
|
||||
workerId: string;
|
||||
tokenEnv: string;
|
||||
}
|
||||
| undefined {
|
||||
if (environment.type !== 'attached' || environment.owner !== 'deployment') {
|
||||
return undefined;
|
||||
}
|
||||
return environment.pairing;
|
||||
}
|
||||
|
||||
function bridgeUrl(environment: ConfiguredCodeEnvironment, path: string): string {
|
||||
return `${environment.baseURL.trim().replace(/\/+$/, '')}${path}`;
|
||||
}
|
||||
|
||||
function validPairingResponse(value: unknown, workerId: string): value is CodePairingResponse {
|
||||
if (typeof value !== 'object' || value == null) return false;
|
||||
const response = value as Partial<CodePairingResponse>;
|
||||
const expiresAt = typeof response.expiresAt === 'string' ? Date.parse(response.expiresAt) : NaN;
|
||||
return (
|
||||
response.protocolVersion === 1 &&
|
||||
response.workerId === workerId &&
|
||||
typeof response.code === 'string' &&
|
||||
/^[A-Za-z0-9_-]{32}$/.test(response.code) &&
|
||||
Number.isFinite(expiresAt) &&
|
||||
expiresAt > Date.now()
|
||||
);
|
||||
}
|
||||
|
||||
function validRevocationResponse(value: unknown): value is CodeRevocationResponse {
|
||||
if (typeof value !== 'object' || value == null) return false;
|
||||
const response = value as Partial<CodeRevocationResponse>;
|
||||
return response.protocolVersion === 1 && response.revoked === true;
|
||||
}
|
||||
|
||||
export function createAdminCodeEnvironmentHandlers(deps: AdminCodeEnvironmentDeps): {
|
||||
createPairing: (req: ServerRequest, res: Response) => Promise<Response>;
|
||||
revokeWorker: (req: ServerRequest, res: Response) => Promise<Response>;
|
||||
} {
|
||||
const fetchImpl = deps.fetchImpl ?? fetch;
|
||||
const readSecret =
|
||||
deps.readSecret ??
|
||||
((name: string) =>
|
||||
Object.prototype.hasOwnProperty.call(process.env, name) ? process.env[name] : undefined);
|
||||
|
||||
async function resolve(
|
||||
req: ServerRequest,
|
||||
res: Response,
|
||||
): Promise<
|
||||
| {
|
||||
id: string;
|
||||
environment: ConfiguredCodeEnvironment;
|
||||
pairing: { workerId: string; tokenEnv: string };
|
||||
token: string;
|
||||
}
|
||||
| Response
|
||||
> {
|
||||
const id = environmentId(req);
|
||||
/** Pairing credentials are deployment control-plane state. Resolve only
|
||||
* YAML-backed configuration; writable database overrides must never
|
||||
* choose tokenEnv or the outbound destination. */
|
||||
const appConfig = await deps.getAppConfig({ baseOnly: true });
|
||||
const environment = findEnvironment(appConfig, id);
|
||||
if (environment == null) {
|
||||
return res.status(404).json({ error: 'Code environment was not found' });
|
||||
}
|
||||
const pairing = pairingConfig(environment);
|
||||
if (pairing == null) {
|
||||
return res.status(409).json({ error: 'Code environment does not support pairing' });
|
||||
}
|
||||
if (!isSecureCodeEnvironmentControlURL(environment.baseURL)) {
|
||||
return res.status(409).json({ error: 'Code environment pairing requires secure transport' });
|
||||
}
|
||||
const token = readSecret(pairing.tokenEnv)?.trim();
|
||||
if (!token) {
|
||||
return res.status(503).json({ error: 'Code environment pairing is not configured' });
|
||||
}
|
||||
return { id, environment, pairing, token };
|
||||
}
|
||||
|
||||
async function createPairing(req: ServerRequest, res: Response): Promise<Response> {
|
||||
const resolved = await resolve(req, res);
|
||||
if ('statusCode' in resolved) return resolved;
|
||||
try {
|
||||
const response = await fetchImpl(bridgeUrl(resolved.environment, '/bridge/pairings'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${resolved.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ workerId: resolved.pairing.workerId }),
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(CODE_BRIDGE_REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) {
|
||||
return res.status(502).json({
|
||||
error: 'Code API rejected the pairing request',
|
||||
upstreamStatus: response.status,
|
||||
});
|
||||
}
|
||||
const payload = (await response.json()) as unknown;
|
||||
if (!validPairingResponse(payload, resolved.pairing.workerId)) {
|
||||
return res.status(502).json({ error: 'Code API returned an invalid pairing response' });
|
||||
}
|
||||
return res.status(200).json({
|
||||
environmentId: resolved.id,
|
||||
workerId: payload.workerId,
|
||||
code: payload.code,
|
||||
expiresAt: payload.expiresAt,
|
||||
});
|
||||
} catch (error) {
|
||||
const timedOut = error instanceof Error && error.name === 'TimeoutError';
|
||||
return res.status(timedOut ? 504 : 502).json({
|
||||
error: timedOut ? 'Code API pairing request timed out' : 'Code API pairing request failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeWorker(req: ServerRequest, res: Response): Promise<Response> {
|
||||
const resolved = await resolve(req, res);
|
||||
if ('statusCode' in resolved) return resolved;
|
||||
try {
|
||||
const workerId = encodeURIComponent(resolved.pairing.workerId);
|
||||
const response = await fetchImpl(
|
||||
bridgeUrl(resolved.environment, `/bridge/workers/${workerId}/revoke`),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${resolved.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: '{}',
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(CODE_BRIDGE_REQUEST_TIMEOUT_MS),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
return res.status(502).json({
|
||||
error: 'Code API rejected the revocation request',
|
||||
upstreamStatus: response.status,
|
||||
});
|
||||
}
|
||||
const payload = (await response.json()) as unknown;
|
||||
if (!validRevocationResponse(payload)) {
|
||||
return res.status(502).json({ error: 'Code API returned an invalid revocation response' });
|
||||
}
|
||||
return res.status(200).json({
|
||||
environmentId: resolved.id,
|
||||
workerId: resolved.pairing.workerId,
|
||||
revoked: true,
|
||||
});
|
||||
} catch (error) {
|
||||
const timedOut = error instanceof Error && error.name === 'TimeoutError';
|
||||
return res.status(timedOut ? 504 : 502).json({
|
||||
error: timedOut
|
||||
? 'Code API revocation request timed out'
|
||||
: 'Code API revocation request failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { createPairing, revokeWorker };
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ export { createAdminRolesHandlers } from './roles';
|
|||
export { createAdminSkillsSyncAccess, createAdminSkillsSyncHandlers } from './skills';
|
||||
export { createAdminUsersHandlers } from './users';
|
||||
export { createAdminAuditLogHandlers } from './auditLog';
|
||||
export { createAdminCodeEnvironmentHandlers } from './code';
|
||||
export { resolveConfigSecret, redactConfigSecretMaps } from './secrets';
|
||||
export type { AdminConfigDeps } from './config';
|
||||
export type { AdminLangfuseDeps } from './langfuse';
|
||||
|
|
@ -15,3 +16,4 @@ export type { AdminRolesDeps } from './roles';
|
|||
export type { AdminSkillSyncAccessDeps, AdminSkillSyncDeps } from './skills';
|
||||
export type { AdminUsersDeps } from './users';
|
||||
export type { AdminAuditLogDeps } from './auditLog';
|
||||
export type { AdminCodeEnvironmentDeps } from './code';
|
||||
|
|
|
|||
|
|
@ -106,12 +106,14 @@ describe('resolveCodeExecutionContext', () => {
|
|||
name: 'Managed',
|
||||
type: 'managed',
|
||||
baseURL: 'https://managed.example/v1',
|
||||
owner: 'deployment',
|
||||
},
|
||||
{
|
||||
id: 'my-vm',
|
||||
name: 'My VM',
|
||||
type: 'attached',
|
||||
baseURL: 'https://bridge.example/v1/',
|
||||
owner: 'deployment',
|
||||
},
|
||||
],
|
||||
userId: 'user-1',
|
||||
|
|
@ -137,6 +139,7 @@ describe('resolveCodeExecutionContext', () => {
|
|||
type: 'attached' as const,
|
||||
baseURL,
|
||||
default: true,
|
||||
owner: 'deployment' as const,
|
||||
});
|
||||
const first = resolveCodeExecutionContext({
|
||||
statefulSessions: true,
|
||||
|
|
@ -166,6 +169,7 @@ describe('resolveCodeExecutionContext', () => {
|
|||
type: 'attached',
|
||||
baseURL: 'https://bridge.example/v1',
|
||||
default: true,
|
||||
owner: 'deployment',
|
||||
},
|
||||
],
|
||||
userId: 'user-1',
|
||||
|
|
|
|||
|
|
@ -523,6 +523,108 @@ describe('agentsEndpointSchema', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('accepts deployment-owned pairing configuration for an attached environment', () => {
|
||||
const result = agentsEndpointSchema.safeParse({
|
||||
statefulCodeSessions: {
|
||||
allowedEnvironments: ['conversation'],
|
||||
environments: [
|
||||
{
|
||||
id: 'attached-vm',
|
||||
name: 'Attached VM',
|
||||
type: 'attached',
|
||||
baseURL: 'https://bridge.example.com/v1',
|
||||
default: true,
|
||||
owner: 'deployment',
|
||||
pairing: {
|
||||
workerId: 'vm-1',
|
||||
tokenEnv: 'CODE_BRIDGE_ADMIN_TOKEN',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.statefulCodeSessions?.environments?.[0]).toMatchObject({
|
||||
owner: 'deployment',
|
||||
pairing: {
|
||||
workerId: 'vm-1',
|
||||
tokenEnv: 'CODE_BRIDGE_ADMIN_TOKEN',
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects pairing configuration for a managed environment', () => {
|
||||
const result = agentsEndpointSchema.safeParse({
|
||||
statefulCodeSessions: {
|
||||
allowedEnvironments: ['conversation'],
|
||||
environments: [
|
||||
{
|
||||
id: 'managed',
|
||||
name: 'Managed',
|
||||
type: 'managed',
|
||||
baseURL: 'https://code.example.com/v1',
|
||||
default: true,
|
||||
pairing: {
|
||||
workerId: 'vm-1',
|
||||
tokenEnv: 'CODE_BRIDGE_ADMIN_TOKEN',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects pairing over insecure non-loopback transport', () => {
|
||||
const result = agentsEndpointSchema.safeParse({
|
||||
statefulCodeSessions: {
|
||||
allowedEnvironments: ['conversation'],
|
||||
environments: [
|
||||
{
|
||||
id: 'attached-vm',
|
||||
name: 'Attached VM',
|
||||
type: 'attached',
|
||||
baseURL: 'http://bridge.example.com/v1',
|
||||
default: true,
|
||||
pairing: {
|
||||
workerId: 'vm-1',
|
||||
tokenEnv: 'CODE_BRIDGE_ADMIN_TOKEN',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('allows loopback HTTP pairing for local development', () => {
|
||||
const result = agentsEndpointSchema.safeParse({
|
||||
statefulCodeSessions: {
|
||||
allowedEnvironments: ['conversation'],
|
||||
environments: [
|
||||
{
|
||||
id: 'attached-vm',
|
||||
name: 'Attached VM',
|
||||
type: 'attached',
|
||||
baseURL: 'http://127.0.0.1:23112/v1',
|
||||
default: true,
|
||||
pairing: {
|
||||
workerId: 'vm-1',
|
||||
tokenEnv: 'CODE_BRIDGE_ADMIN_TOKEN',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects ambiguous execution environment routing', () => {
|
||||
const environment = {
|
||||
id: 'attached-vm',
|
||||
|
|
|
|||
|
|
@ -1023,6 +1023,17 @@ const codeEnvironmentBaseURLSchema = z
|
|||
{ message: 'Code environment baseURL must be an HTTP(S) base URL without query or fragment' },
|
||||
);
|
||||
|
||||
export function isSecureCodeEnvironmentControlURL(baseURL: string): boolean {
|
||||
try {
|
||||
const url = new URL(baseURL.trim());
|
||||
if (url.protocol === 'https:') return true;
|
||||
if (url.protocol !== 'http:') return false;
|
||||
return url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const agentsEndpointSchema = baseEndpointSchema
|
||||
.omit({ baseURL: true })
|
||||
.merge(
|
||||
|
|
@ -1073,6 +1084,17 @@ export const agentsEndpointSchema = baseEndpointSchema
|
|||
type: z.enum(['managed', 'attached']),
|
||||
baseURL: codeEnvironmentBaseURLSchema,
|
||||
default: z.boolean().optional(),
|
||||
/** Ownership is explicit even though the first pairing control
|
||||
* plane supports deployment-owned workers only. */
|
||||
owner: z.literal('deployment').optional().default('deployment'),
|
||||
/** Server-only enrollment metadata. `tokenEnv` names an
|
||||
* environment variable and never contains the token itself. */
|
||||
pairing: z
|
||||
.object({
|
||||
workerId: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/),
|
||||
tokenEnv: z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
|
|
@ -1082,6 +1104,23 @@ export const agentsEndpointSchema = baseEndpointSchema
|
|||
const ids = new Set<string>();
|
||||
let defaults = 0;
|
||||
for (const environment of value.environments) {
|
||||
if (environment.pairing != null && environment.type !== 'attached') {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Only attached code environments may configure pairing',
|
||||
path: ['environments', environment.id, 'pairing'],
|
||||
});
|
||||
}
|
||||
if (
|
||||
environment.pairing != null &&
|
||||
!isSecureCodeEnvironmentControlURL(environment.baseURL)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Paired code environments require HTTPS outside loopback development',
|
||||
path: ['environments', environment.id, 'baseURL'],
|
||||
});
|
||||
}
|
||||
if (ids.has(environment.id)) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ export const SystemCapabilities = {
|
|||
READ_AGENTS: 'read:agents',
|
||||
MANAGE_AGENTS: 'manage:agents',
|
||||
MANAGE_MCP_SERVERS: 'manage:mcpservers',
|
||||
/** Enrolls and revokes deployment-owned Code API workers. */
|
||||
MANAGE_CODE_ENVIRONMENTS: 'manage:code_environments',
|
||||
READ_PROMPTS: 'read:prompts',
|
||||
MANAGE_PROMPTS: 'manage:prompts',
|
||||
READ_SKILLS: 'read:skills',
|
||||
|
|
@ -252,6 +254,7 @@ export const CAPABILITY_CATEGORIES: CapabilityCategory[] = [
|
|||
SystemCapabilities.MANAGE_ASSISTANTS,
|
||||
SystemCapabilities.READ_ASSISTANTS,
|
||||
SystemCapabilities.MANAGE_MCP_SERVERS,
|
||||
SystemCapabilities.MANAGE_CODE_ENVIRONMENTS,
|
||||
SystemCapabilities.MANAGE_SHARED_LINKS,
|
||||
SystemCapabilities.READ_SHARED_LINKS,
|
||||
],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue