feat(opax-web): add read-only Git BFF and hardened UI
Replace the generic mcp-call socket path with two authenticated, read-only Git actions. Add response normalization and error masking, a purpose-built frontend, container build configuration, and socket handler unit tests.
This commit is contained in:
parent
bb2f61beb4
commit
dc9f7b6626
15
opax-web/.dockerignore
Normal file
15
opax-web/.dockerignore
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# Ekskluder lokale avhengighetsmapper fra build context
|
||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
|
||||||
|
# Ekskluder lokale build-artefakter
|
||||||
|
dist
|
||||||
|
**/dist
|
||||||
|
|
||||||
|
# Ekskluder miljøfiler og Git-historikk
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
.git
|
||||||
|
|
||||||
|
# Ekskluder npm-debug-logger
|
||||||
|
npm-debug.log*
|
||||||
50
opax-web/Dockerfile
Normal file
50
opax-web/Dockerfile
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
# === STADIUM 1: BUILD ===
|
||||||
|
# Bygger frontend-appen og installerer alle workspace-dependencies for build
|
||||||
|
FROM node:20-slim AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 1. Kopier kun manifests for å utnytte Docker layer caching
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY frontend/package.json ./frontend/
|
||||||
|
COPY backend/package.json ./backend/
|
||||||
|
|
||||||
|
# 2. Installer ALLE avhengigheter (inkl. dev) basert på lockfilen
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# 3. Kopier resten av frontend-kilden og bygg den
|
||||||
|
COPY frontend/ ./frontend/
|
||||||
|
RUN npm run build --workspace=frontend
|
||||||
|
|
||||||
|
|
||||||
|
# === STADIUM 2: RUNTIME ===
|
||||||
|
# Bygger et slankt produksjons-image med kun det nødvendigste
|
||||||
|
FROM node:20-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
# 1. Kopier kun manifests for en ren produksjonsinstallasjon
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY frontend/package.json ./frontend/
|
||||||
|
COPY backend/package.json ./backend/
|
||||||
|
|
||||||
|
# 2. Installer KUN produksjonsavhengigheter for hele workspacet
|
||||||
|
# --omit=dev skal hindre at frontend dev-dependencies (vite, etc.) installeres
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
# 3. Kopier backend-kildekoden, eid av non-root 'node'-bruker
|
||||||
|
COPY --chown=node:node backend/ ./backend/
|
||||||
|
|
||||||
|
# 4. Kopier det ferdigbygde frontend-resultatet fra BUILD-stadiet
|
||||||
|
COPY --chown=node:node --from=build /app/frontend/dist ./frontend/dist
|
||||||
|
|
||||||
|
# 5. Bruk non-root bruker for å kjøre applikasjonen
|
||||||
|
USER node
|
||||||
|
|
||||||
|
# Port-dokumentasjon (Cloud Run vil bruke PORT env var)
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Start backend-serveren
|
||||||
|
CMD ["node", "backend/server.js"]
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { randomUUID } from 'node:crypto';
|
import { callAgent } from './agentClient.js';
|
||||||
import { callAgent, AgentNotConfiguredError } from './agentClient.js';
|
import { registerBrowserSocketHandlers } from './socketHandlers.js';
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
|
|
@ -7,6 +7,8 @@ import session from 'express-session';
|
||||||
import { OAuth2Client } from 'google-auth-library';
|
import { OAuth2Client } from 'google-auth-library';
|
||||||
import { createServer } from 'node:http';
|
import { createServer } from 'node:http';
|
||||||
import { Server } from 'socket.io';
|
import { Server } from 'socket.io';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const http = createServer(app);
|
const http = createServer(app);
|
||||||
|
|
@ -14,6 +16,10 @@ const front = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||||
const emails = new Set((process.env.ALLOWED_EMAILS || '').split(',').filter(Boolean));
|
const emails = new Set((process.env.ALLOWED_EMAILS || '').split(',').filter(Boolean));
|
||||||
const oauth = new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, `${front}/auth/callback`);
|
const oauth = new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, `${front}/auth/callback`);
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const frontendDistPath = path.join(__dirname, '../frontend/dist');
|
||||||
|
const frontendIndexPath = path.join(frontendDistPath, 'index.html');
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
app.set('trust proxy', 1);
|
app.set('trust proxy', 1);
|
||||||
}
|
}
|
||||||
|
|
@ -34,10 +40,10 @@ const sm = session({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const io = new Server(http, { cors: { origin: front, credentials: true } });
|
const io = new Server(http, { cors: { origin: front, credentials: true } });
|
||||||
const allowed = new Set(['get_health', 'get_build_status', 'get_state', 'get_telemetry', 'get_git_status', 'get_git_branch', 'get_git_log', 'get_git_diff', 'list_repo_files', 'read_repo_file']);
|
|
||||||
|
|
||||||
app.use(cors({ origin: front, credentials: true }));
|
app.use(cors({ origin: front, credentials: true }));
|
||||||
app.use(sm);
|
app.use(sm);
|
||||||
|
app.use(express.static(frontendDistPath));
|
||||||
io.use((s, n) => sm(s.request, {}, n));
|
io.use((s, n) => sm(s.request, {}, n));
|
||||||
|
|
||||||
app.get('/health', (_, r) => r.json({ status: 'ok' }));
|
app.get('/health', (_, r) => r.json({ status: 'ok' }));
|
||||||
|
|
@ -60,50 +66,33 @@ app.get('/auth/me', (q, r) => (q.session.user ? r.json(q.session.user) : r.statu
|
||||||
app.post('/auth/logout', (q, r) => q.session.destroy(() => r.json({ ok: true })));
|
app.post('/auth/logout', (q, r) => q.session.destroy(() => r.json({ ok: true })));
|
||||||
|
|
||||||
io.on('connection', (s) => {
|
io.on('connection', (s) => {
|
||||||
let u = s.request.session?.user;
|
const u = s.request.session?.user;
|
||||||
if (!u) return s.disconnect();
|
if (!u) return s.disconnect();
|
||||||
|
|
||||||
s.on('mcp-call', async ({ id, tool, args }) => {
|
registerBrowserSocketHandlers(s, { callAgentFn: callAgent });
|
||||||
if (typeof id !== 'string' || id.length > 128) {
|
});
|
||||||
return s.emit('mcp-result', { id, error: 'Invalid request id' });
|
|
||||||
}
|
|
||||||
if (typeof tool !== 'string' || !allowed.has(tool)) {
|
|
||||||
return s.emit('mcp-result', { id, error: 'Tool denied by server allowlist' });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (typeof args !== 'object' || args === null || Array.isArray(args)) {
|
|
||||||
return s.emit('mcp-result', { id, error: 'Invalid arguments' });
|
|
||||||
}
|
|
||||||
const argsString = JSON.stringify(args);
|
|
||||||
if (argsString.length > 16384) {
|
|
||||||
return s.emit('mcp-result', { id, error: 'Invalid arguments' });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return s.emit('mcp-result', { id, error: 'Invalid arguments' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const request_id = randomUUID();
|
app.get('*' , (req, res) => {
|
||||||
try {
|
if (req.method !== 'GET') {
|
||||||
const result = await callAgent(tool, args);
|
return res.status(404).end();
|
||||||
s.emit('mcp-result', { id, result });
|
}
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof AgentNotConfiguredError) {
|
const isApiRoute = /^\/(auth|socket\.io|health|api)(\/.*|\/?)$/.test(req.path);
|
||||||
console.log(JSON.stringify({
|
if (isApiRoute) {
|
||||||
request_id,
|
return res.status(404).end();
|
||||||
event: 'agent_not_configured',
|
}
|
||||||
category: 'agent_not_configured',
|
|
||||||
}));
|
if (req.accepts('html')) {
|
||||||
s.emit('mcp-result', { id, error: 'Agent integration is not configured.' });
|
res.sendFile(frontendIndexPath, (err) => {
|
||||||
} else {
|
if (err && !res.headersSent) {
|
||||||
console.log(JSON.stringify({
|
res.status(404).end();
|
||||||
request_id,
|
|
||||||
event: 'agent_request_failed',
|
|
||||||
category: 'agent_request_failed',
|
|
||||||
}));
|
|
||||||
s.emit('mcp-result', { id, error: 'An unexpected error occurred.' });
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(404).end();
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
http.listen(Number(process.env.PORT || 8080), '0.0.0.0');
|
http.listen(Number(process.env.PORT || 8080), '0.0.0.0');
|
||||||
|
|
|
||||||
100
opax-web/backend/socketHandlers.js
Normal file
100
opax-web/backend/socketHandlers.js
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
// FORSLAG TIL opax-web/backend/socketHandlers.js (ENDELIG KORRIGERT)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normaliserer resultatet fra MCP-toolen 'get_git_status'.
|
||||||
|
* Kaster feil hvis responsen ikke har forventet format.
|
||||||
|
* @param {any} data - Rådata fra agentClient.
|
||||||
|
* @returns {{status: string, details: string}} Normalisert status-objekt.
|
||||||
|
*/
|
||||||
|
function normalizeGitStatus(data) {
|
||||||
|
if (
|
||||||
|
!data ||
|
||||||
|
typeof data !== 'object' ||
|
||||||
|
Array.isArray(data) ||
|
||||||
|
typeof data.status !== 'string' ||
|
||||||
|
typeof data.details !== 'string'
|
||||||
|
) {
|
||||||
|
throw new Error('Unexpected Git status response shape.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: data.status,
|
||||||
|
details: data.details,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normaliserer resultatet fra MCP-toolen 'get_git_branch'.
|
||||||
|
* Kaster feil hvis responsen ikke har forventet format.
|
||||||
|
* @param {any} data - Rådata fra agentClient.
|
||||||
|
* @returns {{current_branch: string}} Normalisert branch-objekt.
|
||||||
|
*/
|
||||||
|
function normalizeGitBranch(data) {
|
||||||
|
if (
|
||||||
|
!data ||
|
||||||
|
typeof data !== 'object' ||
|
||||||
|
Array.isArray(data) ||
|
||||||
|
typeof data.current_branch !== 'string'
|
||||||
|
) {
|
||||||
|
// I motsetning til forrige versjon, tillater vi ikke lenger null her.
|
||||||
|
// Hvis branchen er borte, bør MCP-toolen returnere en tom string.
|
||||||
|
// Ugyldig format skal alltid feile.
|
||||||
|
throw new Error('Unexpected Git branch response shape.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
current_branch: data.current_branch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const SAFE_ACTIONS = {
|
||||||
|
'git:getStatus': {
|
||||||
|
tool: 'get_git_status',
|
||||||
|
responseEvent: 'git:status',
|
||||||
|
errorMessage: 'Kunne ikke hente Git-status.',
|
||||||
|
normalize: normalizeGitStatus,
|
||||||
|
},
|
||||||
|
'git:getBranch': {
|
||||||
|
tool: 'get_git_branch',
|
||||||
|
responseEvent: 'git:branch',
|
||||||
|
errorMessage: 'Kunne ikke hente aktiv branch.',
|
||||||
|
normalize: normalizeGitBranch,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registrerer sikre, nettleser-eksponerte Socket.IO-handlere.
|
||||||
|
* @param {import('socket.io').Socket} socket - Den tilkoblede socketen.
|
||||||
|
* @param {{callAgentFn: function}} dependencies - Injisert avhengighet for å kalle MCP.
|
||||||
|
*/
|
||||||
|
export function registerBrowserSocketHandlers(socket, { callAgentFn }) {
|
||||||
|
// Avvis den gamle, usikre mcp-call-handleren
|
||||||
|
socket.on('mcp-call', (payload) => {
|
||||||
|
const safeId = (payload && typeof payload.id === 'string' && payload.id.length <= 128) ? payload.id : null;
|
||||||
|
socket.emit('mcp-result', {
|
||||||
|
id: safeId,
|
||||||
|
error: 'Denne funksjonen er ikke tilgjengelig.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Registrer de nye, sikre handlerne
|
||||||
|
for (const eventName in SAFE_ACTIONS) {
|
||||||
|
const config = SAFE_ACTIONS[eventName];
|
||||||
|
|
||||||
|
socket.on(eventName, async () => {
|
||||||
|
if (!socket.request.session?.user) {
|
||||||
|
return socket.emit(`${config.responseEvent}:error`, { message: 'Du må være logget inn.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await callAgentFn(config.tool, {});
|
||||||
|
const normalizedData = config.normalize(result);
|
||||||
|
socket.emit(`${config.responseEvent}:result`, { data: normalizedData });
|
||||||
|
} catch (e) {
|
||||||
|
// e.message logges ikke eller sendes til klienten.
|
||||||
|
socket.emit(`${config.responseEvent}:error`, { message: config.errorMessage });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
204
opax-web/backend/socketHandlers.test.js
Normal file
204
opax-web/backend/socketHandlers.test.js
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
import { describe, it, beforeEach } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { registerBrowserSocketHandlers } from './socketHandlers.js';
|
||||||
|
|
||||||
|
// --- Test-hjelpere ---
|
||||||
|
|
||||||
|
class FakeSocket {
|
||||||
|
constructor(user = null) {
|
||||||
|
this.request = { session: { user } };
|
||||||
|
this.handlers = new Map();
|
||||||
|
this.emitted = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
on(eventName, handler) {
|
||||||
|
this.handlers.set(eventName, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(eventName, payload) {
|
||||||
|
this.emitted.push({ eventName, payload });
|
||||||
|
}
|
||||||
|
|
||||||
|
async trigger(eventName, payload) {
|
||||||
|
const handler = this.handlers.get(eventName);
|
||||||
|
if (handler) {
|
||||||
|
await handler(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
findEmitted(eventName) {
|
||||||
|
return this.emitted.find(e => e.eventName === eventName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFakeCallAgent() {
|
||||||
|
const fakeCallAgent = async (tool, args) => {
|
||||||
|
fakeCallAgent.calls.push({ tool, args });
|
||||||
|
if (fakeCallAgent.nextError) {
|
||||||
|
throw fakeCallAgent.nextError;
|
||||||
|
}
|
||||||
|
return fakeCallAgent.nextResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
fakeCallAgent.calls = [];
|
||||||
|
fakeCallAgent.nextResult = null;
|
||||||
|
fakeCallAgent.nextError = null;
|
||||||
|
|
||||||
|
fakeCallAgent.setNextResult = (result) => {
|
||||||
|
fakeCallAgent.nextResult = result;
|
||||||
|
fakeCallAgent.nextError = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
fakeCallAgent.setNextError = (error) => {
|
||||||
|
fakeCallAgent.nextError = error;
|
||||||
|
fakeCallAgent.nextResult = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return fakeCallAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tester ---
|
||||||
|
|
||||||
|
describe('registerBrowserSocketHandlers', () => {
|
||||||
|
let fakeSocket;
|
||||||
|
let fakeCallAgent;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fakeCallAgent = createFakeCallAgent();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('for unauthenticated user', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fakeSocket = new FakeSocket(null); // Ingen bruker i session
|
||||||
|
registerBrowserSocketHandlers(fakeSocket, { callAgentFn: fakeCallAgent });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('git:getStatus is rejected and does not call agent', async () => {
|
||||||
|
await fakeSocket.trigger('git:getStatus');
|
||||||
|
const errorEvent = fakeSocket.findEmitted('git:status:error');
|
||||||
|
assert.deepStrictEqual(errorEvent?.payload, { message: 'Du må være logget inn.' });
|
||||||
|
assert.strictEqual(fakeCallAgent.calls.length, 0);
|
||||||
|
assert.strictEqual(fakeSocket.emitted.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('git:getBranch is rejected and does not call agent', async () => {
|
||||||
|
await fakeSocket.trigger('git:getBranch');
|
||||||
|
const errorEvent = fakeSocket.findEmitted('git:branch:error');
|
||||||
|
assert.deepStrictEqual(errorEvent?.payload, { message: 'Du må være logget inn.' });
|
||||||
|
assert.strictEqual(fakeCallAgent.calls.length, 0);
|
||||||
|
assert.strictEqual(fakeSocket.emitted.length, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('for authenticated user', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fakeSocket = new FakeSocket({ email: 'test@example.com' });
|
||||||
|
registerBrowserSocketHandlers(fakeSocket, { callAgentFn: fakeCallAgent });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('git:getStatus calls agent correctly and returns normalized data', async () => {
|
||||||
|
const mcpResult = { status: 'dirty', details: 'M file.txt', secret: 'should-be-stripped' };
|
||||||
|
fakeCallAgent.setNextResult(mcpResult);
|
||||||
|
|
||||||
|
await fakeSocket.trigger('git:getStatus');
|
||||||
|
|
||||||
|
assert.strictEqual(fakeCallAgent.calls.length, 1);
|
||||||
|
assert.deepStrictEqual(fakeCallAgent.calls[0], { tool: 'get_git_status', args: {} });
|
||||||
|
|
||||||
|
const resultEvent = fakeSocket.findEmitted('git:status:result');
|
||||||
|
assert.ok(resultEvent, 'Expected a result event');
|
||||||
|
assert.deepStrictEqual(resultEvent.payload.data, { status: 'dirty', details: 'M file.txt' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('git:getBranch calls agent correctly and returns normalized data', async () => {
|
||||||
|
const mcpResult = { current_branch: 'main', url: 'https://internal.example.invalid' };
|
||||||
|
fakeCallAgent.setNextResult(mcpResult);
|
||||||
|
|
||||||
|
await fakeSocket.trigger('git:getBranch');
|
||||||
|
|
||||||
|
assert.strictEqual(fakeCallAgent.calls.length, 1);
|
||||||
|
assert.deepStrictEqual(fakeCallAgent.calls[0], { tool: 'get_git_branch', args: {} });
|
||||||
|
|
||||||
|
const resultEvent = fakeSocket.findEmitted('git:branch:result');
|
||||||
|
assert.ok(resultEvent, 'Expected a result event');
|
||||||
|
assert.deepStrictEqual(resultEvent.payload.data, { current_branch: 'main' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits static error if git:getStatus agent response is malformed', async () => {
|
||||||
|
const malformedResults = [null, {}, [], 'a string', { status: 123, details: 'a' }];
|
||||||
|
for (const mcpResult of malformedResults) {
|
||||||
|
fakeCallAgent.setNextResult(mcpResult);
|
||||||
|
await fakeSocket.trigger('git:getStatus');
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.strictEqual(fakeSocket.emitted.length, malformedResults.length);
|
||||||
|
assert.ok(fakeSocket.emitted.every(e => e.eventName === 'git:status:error'));
|
||||||
|
assert.ok(fakeSocket.emitted.every(e => e.payload.message === 'Kunne ikke hente Git-status.'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits static error if git:getBranch agent response is malformed', async () => {
|
||||||
|
const malformedResults = [null, {}, [], 'a string', { current_branch: 123 }];
|
||||||
|
for (const mcpResult of malformedResults) {
|
||||||
|
fakeCallAgent.setNextResult(mcpResult);
|
||||||
|
await fakeSocket.trigger('git:getBranch');
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.strictEqual(fakeSocket.emitted.length, malformedResults.length);
|
||||||
|
assert.ok(fakeSocket.emitted.every(e => e.eventName === 'git:branch:error'));
|
||||||
|
assert.ok(fakeSocket.emitted.every(e => e.payload.message === 'Kunne ikke hente aktiv branch.'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('masks agent error for git:getStatus and returns static message', async () => {
|
||||||
|
const internalError = new Error('Contains test-secret-must-not-leak');
|
||||||
|
fakeCallAgent.setNextError(internalError);
|
||||||
|
|
||||||
|
await fakeSocket.trigger('git:getStatus');
|
||||||
|
|
||||||
|
const errorEvent = fakeSocket.findEmitted('git:status:error');
|
||||||
|
assert.ok(errorEvent, 'Expected an error event');
|
||||||
|
assert.deepStrictEqual(errorEvent.payload, { message: 'Kunne ikke hente Git-status.' });
|
||||||
|
assert.strictEqual(fakeSocket.findEmitted('git:status:result'), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('masks agent error for git:getBranch and returns static message', async () => {
|
||||||
|
const internalError = new Error('Another secret for https://internal.example.invalid');
|
||||||
|
fakeCallAgent.setNextError(internalError);
|
||||||
|
|
||||||
|
await fakeSocket.trigger('git:getBranch');
|
||||||
|
|
||||||
|
const errorEvent = fakeSocket.findEmitted('git:branch:error');
|
||||||
|
assert.ok(errorEvent, 'Expected an error event');
|
||||||
|
assert.deepStrictEqual(errorEvent.payload, { message: 'Kunne ikke hente aktiv branch.' });
|
||||||
|
assert.strictEqual(fakeSocket.findEmitted('git:branch:result'), undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('legacy mcp-call handler', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fakeSocket = new FakeSocket({ email: 'test@example.com' });
|
||||||
|
registerBrowserSocketHandlers(fakeSocket, { callAgentFn: fakeCallAgent });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects calls and does not call agent', async () => {
|
||||||
|
const payload = { id: 'test-id', tool: 'commit_and_push_files', args: { message: 'evil' } };
|
||||||
|
await fakeSocket.trigger('mcp-call', payload);
|
||||||
|
|
||||||
|
assert.strictEqual(fakeCallAgent.calls.length, 0);
|
||||||
|
const resultEvent = fakeSocket.findEmitted('mcp-result');
|
||||||
|
assert.ok(resultEvent, 'Expected a result event');
|
||||||
|
assert.deepStrictEqual(resultEvent.payload, { id: 'test-id', error: 'Denne funksjonen er ikke tilgjengelig.' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles malformed payloads gracefully and uses null id', async () => {
|
||||||
|
const malformed = [null, 'a string', { id: 'a'.repeat(129) }];
|
||||||
|
for (const payload of malformed) {
|
||||||
|
await fakeSocket.trigger('mcp-call', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.strictEqual(fakeCallAgent.calls.length, 0);
|
||||||
|
assert.strictEqual(fakeSocket.emitted.length, malformed.length);
|
||||||
|
assert.ok(fakeSocket.emitted.every(e => e.eventName === 'mcp-result'));
|
||||||
|
assert.ok(fakeSocket.emitted.every(e => e.payload.id === null));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,18 +1,44 @@
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { io, Socket } from 'socket.io-client';
|
import { io, Socket } from 'socket.io-client';
|
||||||
|
|
||||||
const tools: string[] = [
|
interface GitStatusData {
|
||||||
'get_health',
|
status: string;
|
||||||
'get_build_status',
|
details: string;
|
||||||
'get_state',
|
}
|
||||||
'get_telemetry',
|
|
||||||
'get_git_status',
|
interface GitBranchData {
|
||||||
'get_git_branch',
|
current_branch: string;
|
||||||
'get_git_log',
|
}
|
||||||
'get_git_diff',
|
|
||||||
'list_repo_files',
|
function isDataEnvelope(payload: unknown): payload is { data: unknown } {
|
||||||
'read_repo_file',
|
return (
|
||||||
];
|
typeof payload === 'object' &&
|
||||||
|
payload !== null &&
|
||||||
|
!Array.isArray(payload) &&
|
||||||
|
'data' in payload
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGitStatusData(data: unknown): data is GitStatusData {
|
||||||
|
const d = data as GitStatusData;
|
||||||
|
return (
|
||||||
|
!!d &&
|
||||||
|
typeof d === 'object' &&
|
||||||
|
!Array.isArray(d) &&
|
||||||
|
typeof d.status === 'string' &&
|
||||||
|
typeof d.details === 'string'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGitBranchData(data: unknown): data is GitBranchData {
|
||||||
|
const d = data as GitBranchData;
|
||||||
|
return (
|
||||||
|
!!d &&
|
||||||
|
typeof d === 'object' &&
|
||||||
|
!Array.isArray(d) &&
|
||||||
|
typeof d.current_branch === 'string'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
email: string;
|
email: string;
|
||||||
|
|
@ -21,8 +47,13 @@ interface User {
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [output, setOutput] = useState<string[]>(['OPAX Web TUI', 'Type tools or: mcp get_health', '']);
|
const [statusData, setStatusData] = useState<GitStatusData | null>(null);
|
||||||
const [input, setInput] = useState('');
|
const [branchData, setBranchData] = useState<GitBranchData | null>(null);
|
||||||
|
const [statusLoading, setStatusLoading] = useState<boolean>(false);
|
||||||
|
const [branchLoading, setBranchLoading] = useState<boolean>(false);
|
||||||
|
const [statusError, setStatusError] = useState<string | null>(null);
|
||||||
|
const [branchError, setBranchError] = useState<string | null>(null);
|
||||||
|
|
||||||
const socketRef = useRef<Socket | null>(null);
|
const socketRef = useRef<Socket | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -37,15 +68,79 @@ export default function App() {
|
||||||
const socket = io('/');
|
const socket = io('/');
|
||||||
socketRef.current = socket;
|
socketRef.current = socket;
|
||||||
|
|
||||||
socket.on('mcp-result', (x) => {
|
const onStatusResult = (payload: unknown) => {
|
||||||
setOutput((a) => [...a, x.error ? 'ERROR ' + x.error : JSON.stringify(x.result, null, 2), '']);
|
setStatusLoading(false);
|
||||||
});
|
if (isDataEnvelope(payload) && isGitStatusData(payload.data)) {
|
||||||
|
setStatusData({ status: payload.data.status, details: payload.data.details });
|
||||||
|
} else {
|
||||||
|
setStatusData(null);
|
||||||
|
setStatusError('Ugyldig svar for Git-status.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onStatusError = () => {
|
||||||
|
setStatusData(null);
|
||||||
|
setStatusLoading(false);
|
||||||
|
setStatusError('Kunne ikke hente Git-status.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBranchResult = (payload: unknown) => {
|
||||||
|
setBranchLoading(false);
|
||||||
|
if (isDataEnvelope(payload) && isGitBranchData(payload.data)) {
|
||||||
|
setBranchData({ current_branch: payload.data.current_branch });
|
||||||
|
} else {
|
||||||
|
setBranchData(null);
|
||||||
|
setBranchError('Ugyldig svar for aktiv branch.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBranchError = () => {
|
||||||
|
setBranchData(null);
|
||||||
|
setBranchLoading(false);
|
||||||
|
setBranchError('Kunne ikke hente aktiv branch.');
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on('git:status:result', onStatusResult);
|
||||||
|
socket.on('git:status:error', onStatusError);
|
||||||
|
socket.on('git:branch:result', onBranchResult);
|
||||||
|
socket.on('git:branch:error', onBranchError);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
socket.off('git:status:result', onStatusResult);
|
||||||
|
socket.off('git:status:error', onStatusError);
|
||||||
|
socket.off('git:branch:result', onBranchResult);
|
||||||
|
socket.off('git:branch:error', onBranchError);
|
||||||
socket.close();
|
socket.close();
|
||||||
|
socketRef.current = null;
|
||||||
};
|
};
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
|
const handleGetStatus = () => {
|
||||||
|
if (!socketRef.current) {
|
||||||
|
setStatusData(null);
|
||||||
|
setStatusLoading(false);
|
||||||
|
setStatusError('Kunne ikke hente Git-status.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatusError(null);
|
||||||
|
setStatusData(null);
|
||||||
|
setStatusLoading(true);
|
||||||
|
socketRef.current.emit('git:getStatus');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGetBranch = () => {
|
||||||
|
if (!socketRef.current) {
|
||||||
|
setBranchData(null);
|
||||||
|
setBranchLoading(false);
|
||||||
|
setBranchError('Kunne ikke hente aktiv branch.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBranchError(null);
|
||||||
|
setBranchData(null);
|
||||||
|
setBranchLoading(true);
|
||||||
|
socketRef.current.emit('git:getBranch');
|
||||||
|
};
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<main className="login">
|
<main className="login">
|
||||||
|
|
@ -55,29 +150,6 @@ export default function App() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function go(e: React.FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
let v = input.trim();
|
|
||||||
setInput('');
|
|
||||||
setOutput((a) => [...a, '$ ' + v]);
|
|
||||||
if (v === 'tools') {
|
|
||||||
return setOutput((a) => [...a, ...tools, '']);
|
|
||||||
}
|
|
||||||
let m = v.match(/^mcp\s+(\S+)(?:\s+(.+))?$/);
|
|
||||||
if (!m) {
|
|
||||||
return setOutput((a) => [...a, 'Use mcp <tool> [JSON]', '']);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
socketRef.current?.emit('mcp-call', {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
tool: m[1],
|
|
||||||
args: m[2] ? JSON.parse(m[2]) : {},
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
setOutput((a) => [...a, 'JSON arguments invalid', '']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<header>
|
<header>
|
||||||
|
|
@ -85,18 +157,30 @@ export default function App() {
|
||||||
<span>{user.email}</span>
|
<span>{user.email}</span>
|
||||||
</header>
|
</header>
|
||||||
<section>
|
<section>
|
||||||
<aside>
|
<div>
|
||||||
{tools.map((tool) => (
|
<h2>Git Status</h2>
|
||||||
<button key={tool} onClick={() => setInput('mcp ' + tool)}>
|
<button onClick={handleGetStatus} disabled={statusLoading}>
|
||||||
{tool}
|
{statusLoading ? 'Laster...' : 'Hent Git-status'}
|
||||||
</button>
|
</button>
|
||||||
))}
|
{statusError && <p className="error">{statusError}</p>}
|
||||||
</aside>
|
{statusData && (
|
||||||
<div className="term">
|
<pre>
|
||||||
<pre>{output.join('\n')}</pre>
|
<strong>Status:</strong> {statusData.status}\n\n
|
||||||
<form onSubmit={go}>
|
<strong>Detaljer:</strong>\n{statusData.details || '(ingen endringer)'}
|
||||||
$ <input autoFocus value={input} onChange={(e) => setInput(e.target.value)} />
|
</pre>
|
||||||
</form>
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>Aktiv Branch</h2>
|
||||||
|
<button onClick={handleGetBranch} disabled={branchLoading}>
|
||||||
|
{branchLoading ? 'Laster...' : 'Hent aktiv branch'}
|
||||||
|
</button>
|
||||||
|
{branchError && <p className="error">{branchError}</p>}
|
||||||
|
{branchData && (
|
||||||
|
<p>
|
||||||
|
<strong>Nåværende branch:</strong> {branchData.current_branch}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user