basant307/AI_Governance_Project
048
1/**2 * @license3 * Copyright 2025 Qwen Team4 * SPDX-License-Identifier: Apache-2.05 */6import { promises as fs } from 'node:fs';7import * as os from 'node:os';8import * as path from 'node:path';9import express from 'express';10import request from 'supertest';11import { afterEach, beforeEach, describe, expect, it, vi, } from 'vitest';12import { AGENT_CONTEXT_FILENAME, DEFAULT_CONTEXT_FILENAME, Storage, setGeminiMdFilename, } from '@qwen-code/qwen-code-core';13import { createMutationGate } from './auth.js';14import { InvalidClientIdError, } from './acp-session-bridge.js';15import { mountWorkspaceMemoryRoutes } from './workspace-memory.js';16function buildBridgeStub(opts = {}) {17 const events = [];18 const known = new Set(opts.knownIds ?? []);19 return {20 events,21 publishWorkspaceEvent(event) {22 events.push(event);23 },24 knownClientIds() {25 return new Set(known);26 },27 // Methods below are not used by the memory routes; throw to keep28 // unrelated tests from accidentally relying on them.29 spawnOrAttach: () => {30 throw new Error('not implemented');31 },32 loadSession: () => {33 throw new Error('not implemented');34 },35 resumeSession: () => {36 throw new Error('not implemented');37 },38 sendPrompt: () => {39 throw new Error('not implemented');40 },41 cancelSession: () => {42 throw new Error('not implemented');43 },44 subscribeEvents: () => {45 throw new Error('not implemented');46 },47 closeSession: () => {48 throw new Error('not implemented');49 },50 updateSessionMetadata: () => {51 throw new Error('not implemented');52 },53 respondToPermission: () => {54 throw new Error('not implemented');55 },56 respondToSessionPermission: () => {57 throw new Error('not implemented');58 },59 listWorkspaceSessions: () => {60 throw new Error('not implemented');61 },62 recordHeartbeat: () => {63 throw new Error('not implemented');64 },65 getHeartbeatState: () => undefined,66 getWorkspaceMcpStatus: async () => {67 throw new Error('not implemented');68 },69 getWorkspaceSkillsStatus: async () => {70 throw new Error('not implemented');71 },72 getWorkspaceProvidersStatus: async () => {73 throw new Error('not implemented');74 },75 getSessionContextStatus: async () => {76 throw new Error('not implemented');77 },78 getSessionSupportedCommandsStatus: async () => {79 throw new Error('not implemented');80 },81 setSessionModel: async () => {82 throw new Error('not implemented');83 },84 killSession: async () => { },85 detachClient: async () => { },86 sessionCount: 0,87 pendingPermissionCount: 0,88 killAllSync: () => { },89 shutdown: async () => { },90 preheat: async () => { },91 };92}93function buildApp(opts) {94 const app = express();95 app.use(express.json({ limit: '10mb' }));96 const mutate = createMutationGate({97 tokenConfigured: opts.strictNoToken !== true,98 requireAuth: false,99 });100 mountWorkspaceMemoryRoutes(app, {101 bridge: opts.bridge,102 boundWorkspace: opts.boundWorkspace,103 ...(opts.collectStatus ? { collectStatus: opts.collectStatus } : {}),104 mutate,105 parseClientId: (req, res) => {106 const raw = req.get('x-qwen-client-id');107 if (raw === undefined || raw === '')108 return undefined;109 if (raw.length > 128 || !/^[A-Za-z0-9._:-]+$/.test(raw)) {110 res.status(400).json({111 error: '`X-Qwen-Client-Id` must be a non-empty token',112 code: 'invalid_client_id',113 });114 return null;115 }116 return raw;117 },118 safeBody: (req) => {119 const raw = req.body;120 if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {121 return Object.create(null);122 }123 const out = Object.create(null);124 for (const [k, v] of Object.entries(raw)) {125 if (k === '__proto__' || k === 'constructor' || k === 'prototype') {126 continue;127 }128 out[k] = v;129 }130 return out;131 },132 });133 return app;134}135function resetContextFilenames() {136 setGeminiMdFilename([DEFAULT_CONTEXT_FILENAME, AGENT_CONTEXT_FILENAME]);137}138describe('workspace memory routes', () => {139 let tmp;140 let workspace;141 let globalDir;142 let getGlobalQwenDirSpy;143 beforeEach(async () => {144 tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-serve-memory-'));145 workspace = path.join(tmp, 'workspace');146 globalDir = path.join(tmp, 'global');147 await fs.mkdir(workspace, { recursive: true });148 getGlobalQwenDirSpy = vi149 .spyOn(Storage, 'getGlobalQwenDir')150 .mockReturnValue(globalDir);151 resetContextFilenames();152 });153 afterEach(async () => {154 resetContextFilenames();155 getGlobalQwenDirSpy.mockRestore();156 await fs.rm(tmp, { recursive: true, force: true });157 });158 describe('GET /workspace/memory', () => {159 it('returns idle status when no QWEN.md or AGENTS.md exists anywhere', async () => {160 const bridge = buildBridgeStub();161 const app = buildApp({ bridge, boundWorkspace: workspace });162 const res = await request(app).get('/workspace/memory');163 expect(res.status).toBe(200);164 expect(res.body).toEqual({165 v: 1,166 workspaceCwd: workspace,167 initialized: false,168 files: [],169 totalBytes: 0,170 fileCount: 0,171 ruleCount: 0,172 });173 });174 it('reports workspace and global QWEN.md files with byte counts', async () => {175 const wsFile = path.join(workspace, 'QWEN.md');176 const wsContent = 'workspace memory\n';177 await fs.writeFile(wsFile, wsContent, 'utf8');178 await fs.mkdir(globalDir, { recursive: true });179 const globalFile = path.join(globalDir, 'QWEN.md');180 const globalContent = 'global memory\n';181 await fs.writeFile(globalFile, globalContent, 'utf8');182 const bridge = buildBridgeStub();183 const app = buildApp({ bridge, boundWorkspace: workspace });184 const res = await request(app).get('/workspace/memory');185 expect(res.status).toBe(200);186 expect(res.body.initialized).toBe(true);187 expect(res.body.fileCount).toBe(2);188 expect(res.body.ruleCount).toBe(0);189 expect(res.body.totalBytes).toBe(Buffer.byteLength(wsContent) + Buffer.byteLength(globalContent));190 const paths = res.body.files.map((f) => f.path);191 expect(paths).toEqual(expect.arrayContaining([wsFile, globalFile]));192 });193 });194 describe('POST /workspace/memory', () => {195 it('appends to workspace QWEN.md and emits memory_changed', async () => {196 const bridge = buildBridgeStub();197 const app = buildApp({ bridge, boundWorkspace: workspace });198 const res = await request(app)199 .post('/workspace/memory')200 .send({ scope: 'workspace', mode: 'append', content: '- entry one' });201 expect(res.status).toBe(200);202 expect(res.body.ok).toBe(true);203 expect(res.body.mode).toBe('append');204 expect(res.body.filePath).toBe(path.join(workspace, 'QWEN.md'));205 const written = await fs.readFile(path.join(workspace, 'QWEN.md'), 'utf8');206 expect(written).toContain('- entry one');207 const events = bridge.events;208 expect(events).toHaveLength(1);209 expect(events[0]?.type).toBe('memory_changed');210 const data = events[0]?.data;211 expect(data['scope']).toBe('workspace');212 expect(data['mode']).toBe('append');213 expect(data['filePath']).toBe(path.join(workspace, 'QWEN.md'));214 });215 it('replaces workspace QWEN.md when mode=replace', async () => {216 const bridge = buildBridgeStub();217 const app = buildApp({ bridge, boundWorkspace: workspace });218 const filePath = path.join(workspace, 'QWEN.md');219 await fs.writeFile(filePath, 'old\n', 'utf8');220 const res = await request(app)221 .post('/workspace/memory')222 .send({ scope: 'workspace', mode: 'replace', content: 'new\n' });223 expect(res.status).toBe(200);224 const written = await fs.readFile(filePath, 'utf8');225 expect(written).toBe('new\n');226 });227 it('writes to the global ~/.qwen directory when scope=global', async () => {228 const bridge = buildBridgeStub();229 const app = buildApp({ bridge, boundWorkspace: workspace });230 const res = await request(app)231 .post('/workspace/memory')232 .send({ scope: 'global', mode: 'append', content: '- global note' });233 expect(res.status).toBe(200);234 expect(res.body.filePath).toBe(path.join(globalDir, 'QWEN.md'));235 const written = await fs.readFile(path.join(globalDir, 'QWEN.md'), 'utf8');236 expect(written).toContain('- global note');237 });238 it('rejects 400 invalid_scope on unknown scope value', async () => {239 const bridge = buildBridgeStub();240 const app = buildApp({ bridge, boundWorkspace: workspace });241 const res = await request(app)242 .post('/workspace/memory')243 .send({ scope: 'all', content: 'x' });244 expect(res.status).toBe(400);245 expect(res.body.code).toBe('invalid_scope');246 });247 it('rejects 400 invalid_mode on unknown mode value', async () => {248 const bridge = buildBridgeStub();249 const app = buildApp({ bridge, boundWorkspace: workspace });250 const res = await request(app)251 .post('/workspace/memory')252 .send({ scope: 'workspace', mode: 'merge', content: 'x' });253 expect(res.status).toBe(400);254 expect(res.body.code).toBe('invalid_mode');255 });256 it('rejects 400 invalid_content for non-string content', async () => {257 const bridge = buildBridgeStub();258 const app = buildApp({ bridge, boundWorkspace: workspace });259 const res = await request(app)260 .post('/workspace/memory')261 .send({ scope: 'workspace', content: 123 });262 expect(res.status).toBe(400);263 expect(res.body.code).toBe('invalid_content');264 });265 it('rejects 400 content_too_large above the 1 MB limit', async () => {266 const bridge = buildBridgeStub();267 const app = buildApp({ bridge, boundWorkspace: workspace });268 const big = 'x'.repeat(1024 * 1024 + 1);269 const res = await request(app)270 .post('/workspace/memory')271 .send({ scope: 'workspace', content: big });272 expect(res.status).toBe(400);273 expect(res.body.code).toBe('content_too_large');274 });275 it('returns 401 token_required when strict gate fires on no-token loopback', async () => {276 const bridge = buildBridgeStub();277 const app = buildApp({278 bridge,279 boundWorkspace: workspace,280 strictNoToken: true,281 });282 const res = await request(app)283 .post('/workspace/memory')284 .send({ scope: 'workspace', content: '- x' });285 expect(res.status).toBe(401);286 expect(res.body.code).toBe('token_required');287 });288 it('rejects 400 invalid_client_id when X-Qwen-Client-Id is unknown', async () => {289 const bridge = buildBridgeStub({ knownIds: ['client_known'] });290 const app = buildApp({ bridge, boundWorkspace: workspace });291 const res = await request(app)292 .post('/workspace/memory')293 .set('X-Qwen-Client-Id', 'client_unknown')294 .send({ scope: 'workspace', content: '- x' });295 expect(res.status).toBe(400);296 expect(res.body.code).toBe('invalid_client_id');297 });298 it('suppresses memory_changed event when append content is whitespace only', async () => {299 const bridge = buildBridgeStub();300 const app = buildApp({ bridge, boundWorkspace: workspace });301 const res = await request(app)302 .post('/workspace/memory')303 .send({ scope: 'workspace', mode: 'append', content: '\n\n \n' });304 expect(res.status).toBe(200);305 expect(res.body.changed).toBe(false);306 const events = bridge.events;307 expect(events).toHaveLength(0);308 });309 it('returns 413 memory_file_too_large when existing QWEN.md exceeds the 16 MB cap', async () => {310 // Write a 17 MB existing QWEN.md, then attempt append. The311 // helper's pre-read `fs.stat` must refuse with the typed312 // error → the route maps it to 413.313 const filePath = path.join(workspace, 'QWEN.md');314 // 17 MB of `x` characters. Bypass the helper's mutex / cap by315 // writing directly via fs (simulating an externally-grown file316 // outside the daemon's control).317 const big = 'x'.repeat(17 * 1024 * 1024);318 await fs.writeFile(filePath, big, 'utf8');319 const bridge = buildBridgeStub();320 const app = buildApp({ bridge, boundWorkspace: workspace });321 const res = await request(app)322 .post('/workspace/memory')323 .send({ scope: 'workspace', mode: 'append', content: '- entry' });324 expect(res.status).toBe(413);325 expect(res.body.code).toBe('memory_file_too_large');326 expect(res.body.scope).toBe('workspace');327 expect(res.body.mode).toBe('append');328 expect(res.body.bytes).toBe(17 * 1024 * 1024);329 expect(res.body.limit).toBe(16 * 1024 * 1024);330 // Default response: no filePath, no path-embedding error message.331 expect(res.body.filePath).toBeUndefined();332 expect(res.body.error).not.toContain(filePath);333 });334 it('omits errorMessage + filePath in 500/413 responses unless QWEN_SERVE_DEBUG is on', async () => {335 // Windows ignores Unix-style permission bits passed to336 // `fs.chmod` — the directory stays writable, the POST succeeds337 // with 200, and the EACCES path this test exercises is338 // unreachable. The route logic itself is platform-agnostic; the339 // Ubuntu + macOS runs cover it. Mirrors the340 // `process.platform === 'win32'` early-return idiom already used341 // in `customBanner.test.ts:232`.342 if (process.platform === 'win32')343 return;344 // Default: production response carries no `errorMessage` or345 // `filePath` fields — operators read the daemon stderr log346 // for the path. Setting QWEN_SERVE_DEBUG=1 enables both.347 const bridge = buildBridgeStub();348 const app = buildApp({ bridge, boundWorkspace: workspace });349 // Force a 500 by making the workspace QWEN.md unwritable. We350 // chmod the WORKSPACE directory (not the file) so `mkdir` and351 // `writeFile` will fail with EACCES.352 const before = await fs.stat(workspace);353 await fs.chmod(workspace, 0o555);354 const prevDebug = process.env['QWEN_SERVE_DEBUG'];355 try {356 delete process.env['QWEN_SERVE_DEBUG'];357 const res = await request(app).post('/workspace/memory').send({358 scope: 'workspace',359 mode: 'append',360 content: '- entry',361 });362 expect(res.status).toBe(500);363 expect(res.body.code).toBe('file_error');364 expect(res.body.scope).toBe('workspace');365 expect(res.body.mode).toBe('append');366 // Default response: no errorMessage, no filePath.367 expect(res.body.errorMessage).toBeUndefined();368 expect(res.body.filePath).toBeUndefined();369 // Toggle debug back on; the same payload now carries the370 // detail.371 process.env['QWEN_SERVE_DEBUG'] = '1';372 const debugRes = await request(app).post('/workspace/memory').send({373 scope: 'workspace',374 mode: 'append',375 content: '- entry',376 });377 expect(debugRes.status).toBe(500);378 expect(typeof debugRes.body.errorMessage).toBe('string');379 }380 finally {381 if (prevDebug === undefined)382 delete process.env['QWEN_SERVE_DEBUG'];383 else384 process.env['QWEN_SERVE_DEBUG'] = prevDebug;385 await fs.chmod(workspace, before.mode);386 }387 });388 it('returns 500 memory_discovery_failed when GET helper throws unexpectedly', async () => {389 const bridge = buildBridgeStub();390 const app = buildApp({391 bridge,392 boundWorkspace: workspace,393 collectStatus: async () => {394 throw new Error('boom');395 },396 });397 const res = await request(app).get('/workspace/memory');398 expect(res.status).toBe(500);399 expect(res.body.code).toBe('memory_discovery_failed');400 });401 it('stamps originatorClientId on the memory_changed event for known clients', async () => {402 const bridge = buildBridgeStub({ knownIds: ['client_a'] });403 const app = buildApp({ bridge, boundWorkspace: workspace });404 const res = await request(app)405 .post('/workspace/memory')406 .set('X-Qwen-Client-Id', 'client_a')407 .send({ scope: 'workspace', mode: 'append', content: '- x' });408 expect(res.status).toBe(200);409 const events = bridge.events;410 expect(events[0]?.originatorClientId).toBe('client_a');411 });412 // Reference InvalidClientIdError in case future refactors rename413 // it — keeps the import non-tree-shakeable surface a real symbol.414 it('exposes InvalidClientIdError from the bridge module', () => {415 expect(typeof InvalidClientIdError).toBe('function');416 });417 });418});419//# sourceMappingURL=workspace-memory.test.js.map