diff --git a/api/server/experimental.js b/api/server/experimental.js index b4083feeda..ff653bd973 100644 --- a/api/server/experimental.js +++ b/api/server/experimental.js @@ -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); diff --git a/api/server/index.js b/api/server/index.js index 85ff068b5e..e12f213f1e 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -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); diff --git a/api/server/routes/admin/code.js b/api/server/routes/admin/code.js new file mode 100644 index 0000000000..455a388a03 --- /dev/null +++ b/api/server/routes/admin/code.js @@ -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; diff --git a/api/server/routes/admin/code.test.js b/api/server/routes/admin/code.test.js new file mode 100644 index 0000000000..3105fdc8d3 --- /dev/null +++ b/api/server/routes/admin/code.test.js @@ -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); + }); +}); diff --git a/api/server/routes/index.js b/api/server/routes/index.js index 6b305686b6..a22b065e7a 100644 --- a/api/server/routes/index.js +++ b/api/server/routes/index.js @@ -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, diff --git a/e2e/playwright.config.mock.ts b/e2e/playwright.config.mock.ts index e01fc01925..0354cc7a69 100644 --- a/e2e/playwright.config.mock.ts +++ b/e2e/playwright.config.mock.ts @@ -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__', ); diff --git a/e2e/specs/mock/stateful-code-bridge.spec.ts b/e2e/specs/mock/stateful-code-bridge.spec.ts index 3ca71beaae..f3b0bcd556 100644 --- a/e2e/specs/mock/stateful-code-bridge.spec.ts +++ b/e2e/specs/mock/stateful-code-bridge.spec.ts @@ -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(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(page, { path: '/api/agents', token, diff --git a/librechat.example.yaml b/librechat.example.yaml index 90c8991658..4bc675a8e7 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -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: diff --git a/packages/api/src/admin/code.spec.ts b/packages/api/src/admin/code.spec.ts new file mode 100644 index 0000000000..38b22c7deb --- /dev/null +++ b/packages/api/src/admin/code.spec.ts @@ -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; +} + +function mockResponse(): MockResponse { + const response = { + statusCode: 200, + body: undefined as Record | undefined, + status(code: number) { + response.statusCode = code; + return response; + }, + json(body: Record) { + 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' }); + }); +}); diff --git a/packages/api/src/admin/code.ts b/packages/api/src/admin/code.ts new file mode 100644 index 0000000000..f84c45a177 --- /dev/null +++ b/packages/api/src/admin/code.ts @@ -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[EModelEndpoint.agents]; +type StatefulCodeSessionsConfig = NonNullable< + NonNullable['statefulCodeSessions'] +>; +type ConfiguredCodeEnvironment = NonNullable[number]; +type FetchImpl = ( + input: Parameters[0], + init?: Parameters[1], +) => ReturnType; + +interface CodePairingResponse { + protocolVersion: number; + workerId: string; + code: string; + expiresAt: string; +} + +interface CodeRevocationResponse { + protocolVersion: number; + revoked: true; +} + +export interface AdminCodeEnvironmentDeps { + getAppConfig: (options: GetAppConfigOptions) => Promise; + 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; + 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; + return response.protocolVersion === 1 && response.revoked === true; +} + +export function createAdminCodeEnvironmentHandlers(deps: AdminCodeEnvironmentDeps): { + createPairing: (req: ServerRequest, res: Response) => Promise; + revokeWorker: (req: ServerRequest, res: Response) => Promise; +} { + 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 { + 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 { + 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 }; +} diff --git a/packages/api/src/admin/index.ts b/packages/api/src/admin/index.ts index 2f52e1cccf..c5259aeab0 100644 --- a/packages/api/src/admin/index.ts +++ b/packages/api/src/admin/index.ts @@ -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'; diff --git a/packages/api/src/agents/execution.spec.ts b/packages/api/src/agents/execution.spec.ts index 1a6ffabc72..859e231d33 100644 --- a/packages/api/src/agents/execution.spec.ts +++ b/packages/api/src/agents/execution.spec.ts @@ -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', diff --git a/packages/data-provider/specs/config-schemas.spec.ts b/packages/data-provider/specs/config-schemas.spec.ts index 9749f1cb43..33c4fc8ba1 100644 --- a/packages/data-provider/specs/config-schemas.spec.ts +++ b/packages/data-provider/specs/config-schemas.spec.ts @@ -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', diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 987f9d6d7a..b0f7a97eef 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -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(); 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, diff --git a/packages/data-schemas/src/admin/capabilities.ts b/packages/data-schemas/src/admin/capabilities.ts index 1cca28804e..d9492655af 100644 --- a/packages/data-schemas/src/admin/capabilities.ts +++ b/packages/data-schemas/src/admin/capabilities.ts @@ -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, ],