mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
970 lines
36 KiB
TypeScript
970 lines
36 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { promises as fs } from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import express from 'express';
|
|
import request from 'supertest';
|
|
import {
|
|
afterEach,
|
|
beforeEach,
|
|
describe,
|
|
expect,
|
|
it,
|
|
vi,
|
|
type MockInstance,
|
|
} from 'vitest';
|
|
import { Storage, QWEN_DIR } from '@qwen-code/qwen-code-core';
|
|
import { createMutationGate } from './auth.js';
|
|
import type { AcpSessionBridge } from './acp-session-bridge.js';
|
|
import type { BridgeEvent } from './event-bus.js';
|
|
import { mountWorkspaceAgentsRoutes } from './workspace-agents.js';
|
|
|
|
type RecordedEvent = Omit<BridgeEvent, 'id' | 'v'>;
|
|
|
|
function buildBridgeStub(
|
|
opts: { knownIds?: Iterable<string> } = {},
|
|
): AcpSessionBridge & {
|
|
events: RecordedEvent[];
|
|
} {
|
|
const events: RecordedEvent[] = [];
|
|
const known = new Set<string>(opts.knownIds ?? []);
|
|
return {
|
|
events,
|
|
publishWorkspaceEvent(event: RecordedEvent) {
|
|
events.push(event);
|
|
},
|
|
knownClientIds() {
|
|
return new Set(known);
|
|
},
|
|
spawnOrAttach: () => {
|
|
throw new Error('not implemented');
|
|
},
|
|
loadSession: () => {
|
|
throw new Error('not implemented');
|
|
},
|
|
resumeSession: () => {
|
|
throw new Error('not implemented');
|
|
},
|
|
sendPrompt: () => {
|
|
throw new Error('not implemented');
|
|
},
|
|
cancelSession: () => {
|
|
throw new Error('not implemented');
|
|
},
|
|
subscribeEvents: () => {
|
|
throw new Error('not implemented');
|
|
},
|
|
closeSession: () => {
|
|
throw new Error('not implemented');
|
|
},
|
|
updateSessionMetadata: () => {
|
|
throw new Error('not implemented');
|
|
},
|
|
respondToPermission: () => {
|
|
throw new Error('not implemented');
|
|
},
|
|
respondToSessionPermission: () => {
|
|
throw new Error('not implemented');
|
|
},
|
|
listWorkspaceSessions: () => {
|
|
throw new Error('not implemented');
|
|
},
|
|
recordHeartbeat: () => {
|
|
throw new Error('not implemented');
|
|
},
|
|
getHeartbeatState: () => undefined,
|
|
getWorkspaceMcpStatus: async () => {
|
|
throw new Error('not implemented');
|
|
},
|
|
getWorkspaceSkillsStatus: async () => {
|
|
throw new Error('not implemented');
|
|
},
|
|
getWorkspaceProvidersStatus: async () => {
|
|
throw new Error('not implemented');
|
|
},
|
|
getSessionContextStatus: async () => {
|
|
throw new Error('not implemented');
|
|
},
|
|
getSessionSupportedCommandsStatus: async () => {
|
|
throw new Error('not implemented');
|
|
},
|
|
setSessionModel: async () => {
|
|
throw new Error('not implemented');
|
|
},
|
|
killSession: async () => {},
|
|
detachClient: async () => {},
|
|
sessionCount: 0,
|
|
pendingPermissionCount: 0,
|
|
killAllSync: () => {},
|
|
shutdown: async () => {},
|
|
preheat: async () => {},
|
|
} as unknown as AcpSessionBridge & { events: RecordedEvent[] };
|
|
}
|
|
|
|
function buildApp(opts: {
|
|
bridge: AcpSessionBridge;
|
|
boundWorkspace: string;
|
|
strictNoToken?: boolean;
|
|
}) {
|
|
const app = express();
|
|
app.use(express.json({ limit: '10mb' }));
|
|
const mutate = createMutationGate({
|
|
tokenConfigured: !opts.strictNoToken,
|
|
requireAuth: false,
|
|
});
|
|
mountWorkspaceAgentsRoutes(app, {
|
|
bridge: opts.bridge,
|
|
boundWorkspace: opts.boundWorkspace,
|
|
mutate,
|
|
parseClientId: (req, res) => {
|
|
const raw = req.get('x-qwen-client-id');
|
|
if (raw === undefined || raw === '') return undefined;
|
|
if (raw.length > 128 || !/^[A-Za-z0-9._:-]+$/.test(raw)) {
|
|
res.status(400).json({
|
|
error: '`X-Qwen-Client-Id` must be a non-empty token',
|
|
code: 'invalid_client_id',
|
|
});
|
|
return null;
|
|
}
|
|
return raw;
|
|
},
|
|
safeBody: (req) => {
|
|
const raw = req.body;
|
|
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
return Object.create(null) as Record<string, unknown>;
|
|
}
|
|
const out = Object.create(null) as Record<string, unknown>;
|
|
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
|
|
if (k === '__proto__' || k === 'constructor' || k === 'prototype') {
|
|
continue;
|
|
}
|
|
out[k] = v;
|
|
}
|
|
return out;
|
|
},
|
|
});
|
|
return app;
|
|
}
|
|
|
|
describe('workspace agents routes', () => {
|
|
let tmp: string;
|
|
let workspace: string;
|
|
let globalDir: string;
|
|
let getGlobalQwenDirSpy: MockInstance<() => string>;
|
|
|
|
beforeEach(async () => {
|
|
tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-serve-agents-'));
|
|
workspace = path.join(tmp, 'workspace');
|
|
globalDir = path.join(tmp, 'global');
|
|
await fs.mkdir(workspace, { recursive: true });
|
|
await fs.mkdir(globalDir, { recursive: true });
|
|
getGlobalQwenDirSpy = vi
|
|
.spyOn(Storage, 'getGlobalQwenDir')
|
|
.mockReturnValue(globalDir);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
getGlobalQwenDirSpy.mockRestore();
|
|
await fs.rm(tmp, { recursive: true, force: true });
|
|
});
|
|
|
|
it('lists built-in agents alongside on-disk project agents', async () => {
|
|
const projectAgentsDir = path.join(workspace, QWEN_DIR, 'agents');
|
|
await fs.mkdir(projectAgentsDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(projectAgentsDir, 'reviewer.md'),
|
|
`---\nname: reviewer\ndescription: reviews PRs\n---\nyou are a reviewer agent\n`,
|
|
'utf8',
|
|
);
|
|
|
|
const bridge = buildBridgeStub();
|
|
const app = buildApp({ bridge, boundWorkspace: workspace });
|
|
const res = await request(app).get('/workspace/agents');
|
|
|
|
expect(res.status).toBe(200);
|
|
const names = (res.body.agents as Array<{ name: string }>).map(
|
|
(a) => a.name,
|
|
);
|
|
expect(names).toContain('reviewer');
|
|
expect(names).toContain('general-purpose');
|
|
const reviewerEntry = (
|
|
res.body.agents as Array<{
|
|
name: string;
|
|
level: string;
|
|
systemPrompt?: string;
|
|
}>
|
|
).find((a) => a.name === 'reviewer');
|
|
expect(reviewerEntry?.level).toBe('project');
|
|
// Listings exclude the systemPrompt for bounded payload.
|
|
expect(reviewerEntry?.systemPrompt).toBeUndefined();
|
|
});
|
|
|
|
it('GET /workspace/agents reflects out-of-band agent file changes', async () => {
|
|
const bridge = buildBridgeStub();
|
|
const app = buildApp({ bridge, boundWorkspace: workspace });
|
|
|
|
// First call populates SubagentManager's cache.
|
|
let res = await request(app).get('/workspace/agents');
|
|
expect(res.status).toBe(200);
|
|
const before = (res.body.agents as Array<{ name: string }>).map(
|
|
(a) => a.name,
|
|
);
|
|
expect(before).not.toContain('fresh-out-of-band');
|
|
|
|
// Out-of-band: a developer / IDE adapter writes a new agent file
|
|
// directly to disk, bypassing the daemon's POST route. Without
|
|
// `force: true` on the LIST handler, `listSubagents()` would
|
|
// serve the stale cache from the first call and silently miss
|
|
// the new entry — diverging from the detail route, which always
|
|
// re-reads disk.
|
|
const projectAgentsDir = path.join(workspace, QWEN_DIR, 'agents');
|
|
await fs.mkdir(projectAgentsDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(projectAgentsDir, 'fresh-out-of-band.md'),
|
|
`---\nname: fresh-out-of-band\ndescription: out-of-band agent description\n---\nyou are the fresh out-of-band agent\n`,
|
|
'utf8',
|
|
);
|
|
|
|
res = await request(app).get('/workspace/agents');
|
|
expect(res.status).toBe(200);
|
|
const after = (res.body.agents as Array<{ name: string }>).map(
|
|
(a) => a.name,
|
|
);
|
|
expect(after).toContain('fresh-out-of-band');
|
|
});
|
|
|
|
it('returns the full detail (with systemPrompt) on GET /workspace/agents/:agentType', async () => {
|
|
const bridge = buildBridgeStub();
|
|
const app = buildApp({ bridge, boundWorkspace: workspace });
|
|
const res = await request(app).get('/workspace/agents/general-purpose');
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.name).toBe('general-purpose');
|
|
expect(typeof res.body.systemPrompt).toBe('string');
|
|
expect(res.body.isBuiltin).toBe(true);
|
|
expect(res.body.level).toBe('builtin');
|
|
});
|
|
|
|
it('returns 404 agent_not_found for unknown agent', async () => {
|
|
const bridge = buildBridgeStub();
|
|
const app = buildApp({ bridge, boundWorkspace: workspace });
|
|
const res = await request(app).get('/workspace/agents/no-such-agent');
|
|
expect(res.status).toBe(404);
|
|
expect(res.body.code).toBe('agent_not_found');
|
|
});
|
|
|
|
it('matches frontmatter name case-insensitively', async () => {
|
|
const projectAgentsDir = path.join(workspace, QWEN_DIR, 'agents');
|
|
await fs.mkdir(projectAgentsDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(projectAgentsDir, 'casey.md'),
|
|
`---\nname: CaseInsensitive-Agent\ndescription: case insensitive lookup test\n---\nyou are a test agent\n`,
|
|
'utf8',
|
|
);
|
|
|
|
const bridge = buildBridgeStub();
|
|
const app = buildApp({ bridge, boundWorkspace: workspace });
|
|
const res = await request(app).get(
|
|
'/workspace/agents/caseinsensitive-agent',
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.name).toBe('CaseInsensitive-Agent');
|
|
});
|
|
|
|
it('creates a project-level agent and emits agent_changed', async () => {
|
|
const bridge = buildBridgeStub();
|
|
const app = buildApp({ bridge, boundWorkspace: workspace });
|
|
const res = await request(app).post('/workspace/agents').send({
|
|
name: 'tester',
|
|
description: 'runs tests in the project',
|
|
systemPrompt: 'you are a tester agent',
|
|
scope: 'workspace',
|
|
});
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.ok).toBe(true);
|
|
expect(res.body.agent.name).toBe('tester');
|
|
expect(res.body.agent.level).toBe('project');
|
|
|
|
const events = (bridge as unknown as { events: RecordedEvent[] }).events;
|
|
expect(events).toHaveLength(1);
|
|
expect(events[0]?.type).toBe('agent_changed');
|
|
expect(events[0]?.data).toMatchObject({
|
|
change: 'created',
|
|
name: 'tester',
|
|
level: 'project',
|
|
});
|
|
|
|
// File was actually written.
|
|
const onDisk = await fs.readFile(
|
|
path.join(workspace, QWEN_DIR, 'agents', 'tester.md'),
|
|
'utf8',
|
|
);
|
|
expect(onDisk).toContain('name: tester');
|
|
});
|
|
|
|
it('creates a user-level agent when scope=global', async () => {
|
|
const bridge = buildBridgeStub();
|
|
const app = buildApp({ bridge, boundWorkspace: workspace });
|
|
const res = await request(app).post('/workspace/agents').send({
|
|
name: 'global-helper',
|
|
description: 'cross-workspace helper',
|
|
systemPrompt: 'you are a helper agent',
|
|
scope: 'global',
|
|
});
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.agent.level).toBe('user');
|
|
const onDisk = await fs.readFile(
|
|
path.join(globalDir, 'agents', 'global-helper.md'),
|
|
'utf8',
|
|
);
|
|
expect(onDisk).toContain('name: global-helper');
|
|
});
|
|
|
|
it('returns 409 agent_already_exists when name collides at the same level', async () => {
|
|
const bridge = buildBridgeStub();
|
|
const app = buildApp({ bridge, boundWorkspace: workspace });
|
|
const body = {
|
|
name: 'duplicate',
|
|
description: 'first description',
|
|
systemPrompt: 'you are the duplicate agent',
|
|
scope: 'workspace' as const,
|
|
};
|
|
const first = await request(app).post('/workspace/agents').send(body);
|
|
expect(first.status).toBe(201);
|
|
const second = await request(app).post('/workspace/agents').send(body);
|
|
expect(second.status).toBe(409);
|
|
expect(second.body.code).toBe('agent_already_exists');
|
|
});
|
|
|
|
it('rejects 422 invalid_config when create uses a builtin agent name', async () => {
|
|
const bridge = buildBridgeStub();
|
|
const app = buildApp({ bridge, boundWorkspace: workspace });
|
|
const res = await request(app).post('/workspace/agents').send({
|
|
name: 'general-purpose',
|
|
description: 'a description longer than ten chars',
|
|
systemPrompt: 'this is a system prompt',
|
|
scope: 'workspace',
|
|
});
|
|
expect(res.status).toBe(422);
|
|
expect(res.body.code).toBe('invalid_config');
|
|
expect(res.body.error).toMatch(/built-in/i);
|
|
|
|
// BuiltinAgentRegistry.isBuiltinAgent is case-insensitive — both
|
|
// `Explore` and `explore` must reject so a project-level shadow
|
|
// can never land regardless of how the client cases the name.
|
|
const res2 = await request(app).post('/workspace/agents').send({
|
|
name: 'explore',
|
|
description: 'a description longer than ten chars',
|
|
systemPrompt: 'this is a system prompt',
|
|
scope: 'workspace',
|
|
});
|
|
expect(res2.status).toBe(422);
|
|
expect(res2.body.code).toBe('invalid_config');
|
|
});
|
|
|
|
it('returns 422 invalid_config for missing required fields', async () => {
|
|
const bridge = buildBridgeStub();
|
|
const app = buildApp({ bridge, boundWorkspace: workspace });
|
|
const res = await request(app)
|
|
.post('/workspace/agents')
|
|
.send({ scope: 'workspace' });
|
|
expect(res.status).toBe(422);
|
|
expect(res.body.code).toBe('invalid_config');
|
|
});
|
|
|
|
it('returns 400 invalid_scope for bad scope value', async () => {
|
|
const bridge = buildBridgeStub();
|
|
const app = buildApp({ bridge, boundWorkspace: workspace });
|
|
const res = await request(app).post('/workspace/agents').send({
|
|
name: 'a-name',
|
|
description: 'a description longer than ten chars',
|
|
systemPrompt: 'this is the system prompt',
|
|
scope: 'project',
|
|
});
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.code).toBe('invalid_scope');
|
|
});
|
|
|
|
it('updates an existing project-level agent and emits agent_changed', async () => {
|
|
const bridge = buildBridgeStub();
|
|
const app = buildApp({ bridge, boundWorkspace: workspace });
|
|
await request(app).post('/workspace/agents').send({
|
|
name: 'updatable',
|
|
description: 'old description',
|
|
systemPrompt: 'you are an updatable agent',
|
|
scope: 'workspace',
|
|
});
|
|
const res = await request(app)
|
|
.post('/workspace/agents/updatable')
|
|
.send({ description: 'new description' });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.agent.description).toBe('new description');
|
|
const events = (bridge as unknown as { events: RecordedEvent[] }).events;
|
|
const changeEvents = events.filter((e) => e.type === 'agent_changed');
|
|
expect(changeEvents).toHaveLength(2);
|
|
expect(changeEvents[1]?.data).toMatchObject({
|
|
change: 'updated',
|
|
name: 'updatable',
|
|
level: 'project',
|
|
});
|
|
});
|
|
|
|
it('returns 404 agent_not_found when updating an unknown agent', async () => {
|
|
const bridge = buildBridgeStub();
|
|
const app = buildApp({ bridge, boundWorkspace: workspace });
|
|
const res = await request(app)
|
|
.post('/workspace/agents/no-such-agent')
|
|
.send({ description: 'x' });
|
|
expect(res.status).toBe(404);
|
|
expect(res.body.code).toBe('agent_not_found');
|
|
});
|
|
|
|
it('returns 403 agent_readonly when updating a built-in agent', async () => {
|
|
const bridge = buildBridgeStub();
|
|
const app = buildApp({ bridge, boundWorkspace: workspace });
|
|
const res = await request(app)
|
|
.post('/workspace/agents/general-purpose')
|
|
.send({ description: 'rewritten' });
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.code).toBe('agent_readonly');
|
|
});
|
|
|
|
it('deletes a project-level agent and emits agent_changed', async () => {
|
|
const bridge = buildBridgeStub();
|
|
const app = buildApp({ bridge, boundWorkspace: workspace });
|
|
await request(app).post('/workspace/agents').send({
|
|
name: 'temporary',
|
|
description: 'temp description',
|
|
systemPrompt: 'you are a temp agent',
|
|
scope: 'workspace',
|
|
});
|
|
const res = await request(app).delete('/workspace/agents/temporary');
|
|
expect(res.status).toBe(204);
|
|
const events = (bridge as unknown as { events: RecordedEvent[] }).events;
|
|
const changeEvents = events.filter((e) => e.type === 'agent_changed');
|
|
expect(changeEvents.at(-1)?.data).toMatchObject({
|
|
change: 'deleted',
|
|
name: 'temporary',
|
|
level: 'project',
|
|
});
|
|
});
|
|
|
|
it('returns 403 agent_readonly when deleting a built-in agent', async () => {
|
|
const bridge = buildBridgeStub();
|
|
const app = buildApp({ bridge, boundWorkspace: workspace });
|
|
const res = await request(app).delete('/workspace/agents/general-purpose');
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.code).toBe('agent_readonly');
|
|
});
|
|
|
|
it('returns 404 when deleting a missing agent', async () => {
|
|
const bridge = buildBridgeStub();
|
|
const app = buildApp({ bridge, boundWorkspace: workspace });
|
|
const res = await request(app).delete('/workspace/agents/no-such-agent');
|
|
expect(res.status).toBe(404);
|
|
expect(res.body.code).toBe('agent_not_found');
|
|
});
|
|
|
|
it('refuses POST with 401 token_required on no-token loopback strict mode', async () => {
|
|
const bridge = buildBridgeStub();
|
|
const app = buildApp({
|
|
bridge,
|
|
boundWorkspace: workspace,
|
|
strictNoToken: true,
|
|
});
|
|
const res = await request(app).post('/workspace/agents').send({
|
|
name: 'a-name',
|
|
description: 'a description longer than ten chars',
|
|
systemPrompt: 'this is the system prompt',
|
|
scope: 'workspace',
|
|
});
|
|
expect(res.status).toBe(401);
|
|
expect(res.body.code).toBe('token_required');
|
|
});
|
|
|
|
it('rejects 400 invalid_client_id for unknown X-Qwen-Client-Id', async () => {
|
|
const bridge = buildBridgeStub({ knownIds: ['client_known'] });
|
|
const app = buildApp({ bridge, boundWorkspace: workspace });
|
|
const res = await request(app)
|
|
.post('/workspace/agents')
|
|
.set('X-Qwen-Client-Id', 'client_stranger')
|
|
.send({
|
|
name: 'a-name',
|
|
description: 'a description longer than ten chars',
|
|
systemPrompt: 'this is the system prompt',
|
|
scope: 'workspace',
|
|
});
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.code).toBe('invalid_client_id');
|
|
});
|
|
|
|
it('trims leading/trailing whitespace on the agent name', async () => {
|
|
const bridge = buildBridgeStub();
|
|
const app = buildApp({ bridge, boundWorkspace: workspace });
|
|
const res = await request(app).post('/workspace/agents').send({
|
|
name: ' trimmed-name ',
|
|
description: 'a description longer than ten chars',
|
|
systemPrompt: 'you are a trimmed name agent',
|
|
scope: 'workspace',
|
|
});
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.agent.name).toBe('trimmed-name');
|
|
// File on disk uses the trimmed name; the original-with-spaces
|
|
// version must NOT exist (would otherwise be unfindable via
|
|
// case-insensitive lookup).
|
|
const onDisk = await fs.readFile(
|
|
path.join(workspace, QWEN_DIR, 'agents', 'trimmed-name.md'),
|
|
'utf8',
|
|
);
|
|
expect(onDisk).toContain('name: trimmed-name');
|
|
});
|
|
|
|
it('returns 422 invalid_config when scalar field has wrong type on create', async () => {
|
|
const bridge = buildBridgeStub();
|
|
const app = buildApp({ bridge, boundWorkspace: workspace });
|
|
const res = await request(app).post('/workspace/agents').send({
|
|
name: 'wrong-type',
|
|
description: 'a description longer than ten chars',
|
|
systemPrompt: 'you are a wrong-type test agent',
|
|
scope: 'workspace',
|
|
model: 123,
|
|
});
|
|
expect(res.status).toBe(422);
|
|
expect(res.body.code).toBe('invalid_config');
|
|
expect(res.body.error).toMatch(/model.*string/);
|
|
});
|
|
|
|
it('returns 422 invalid_config for unknown approvalMode', async () => {
|
|
const bridge = buildBridgeStub();
|
|
const app = buildApp({ bridge, boundWorkspace: workspace });
|
|
const res = await request(app).post('/workspace/agents').send({
|
|
name: 'bad-mode',
|
|
description: 'a description longer than ten chars',
|
|
systemPrompt: 'you are a bad-mode test agent',
|
|
scope: 'workspace',
|
|
approvalMode: 'rampage',
|
|
});
|
|
expect(res.status).toBe(422);
|
|
expect(res.body.code).toBe('invalid_config');
|
|
expect(res.body.error).toMatch(/approvalMode/);
|
|
});
|
|
|
|
it('strips unknown runConfig keys and rejects malformed values', async () => {
|
|
const bridge = buildBridgeStub();
|
|
const app = buildApp({ bridge, boundWorkspace: workspace });
|
|
// Unknown keys are silently dropped, valid known keys preserved.
|
|
const res = await request(app)
|
|
.post('/workspace/agents')
|
|
.send({
|
|
name: 'run-config',
|
|
description: 'a description longer than ten chars',
|
|
systemPrompt: 'you are a run-config test agent',
|
|
scope: 'workspace',
|
|
runConfig: { max_turns: 5, mystery_field: 'oops' },
|
|
});
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.agent.runConfig).toEqual({ max_turns: 5 });
|
|
|
|
// Malformed known field fails closed.
|
|
const res2 = await request(app)
|
|
.post('/workspace/agents')
|
|
.send({
|
|
name: 'run-config-bad',
|
|
description: 'a description longer than ten chars',
|
|
systemPrompt: 'you are a run-config bad agent',
|
|
scope: 'workspace',
|
|
runConfig: { max_turns: -1 },
|
|
});
|
|
expect(res2.status).toBe(422);
|
|
expect(res2.body.code).toBe('invalid_config');
|
|
});
|
|
|
|
it('rejects 400 invalid_scope on repeated ?scope= query', async () => {
|
|
const bridge = buildBridgeStub();
|
|
const app = buildApp({ bridge, boundWorkspace: workspace });
|
|
// Express parses repeated query params as an array; we should
|
|
// fail-closed rather than treating it as absent.
|
|
const res = await request(app).delete(
|
|
'/workspace/agents/some-name?scope=workspace&scope=global',
|
|
);
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.code).toBe('invalid_scope');
|
|
});
|
|
|
|
it('rejects 400 invalid_config for empty update body', async () => {
|
|
const bridge = buildBridgeStub();
|
|
const app = buildApp({ bridge, boundWorkspace: workspace });
|
|
await request(app).post('/workspace/agents').send({
|
|
name: 'has-fields',
|
|
description: 'a description longer than ten chars',
|
|
systemPrompt: 'you are a has-fields test agent',
|
|
scope: 'workspace',
|
|
});
|
|
const res = await request(app)
|
|
.post('/workspace/agents/has-fields')
|
|
.send({});
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.code).toBe('invalid_config');
|
|
});
|
|
|
|
it('stamps originatorClientId on agent_changed for known clients (create / update / delete)', async () => {
|
|
const bridge = buildBridgeStub({ knownIds: ['client_audit'] });
|
|
const app = buildApp({ bridge, boundWorkspace: workspace });
|
|
|
|
// Create with a stamped client id.
|
|
const createRes = await request(app)
|
|
.post('/workspace/agents')
|
|
.set('X-Qwen-Client-Id', 'client_audit')
|
|
.send({
|
|
name: 'audited',
|
|
description: 'a description longer than ten chars',
|
|
systemPrompt: 'you are an audited agent',
|
|
scope: 'workspace',
|
|
});
|
|
expect(createRes.status).toBe(201);
|
|
|
|
// Update with the same client id.
|
|
const updateRes = await request(app)
|
|
.post('/workspace/agents/audited')
|
|
.set('X-Qwen-Client-Id', 'client_audit')
|
|
.send({ description: 'a NEW description longer than ten chars' });
|
|
expect(updateRes.status).toBe(200);
|
|
expect(updateRes.body.changed).toBe(true);
|
|
|
|
// Delete with the same client id.
|
|
const deleteRes = await request(app)
|
|
.delete('/workspace/agents/audited')
|
|
.set('X-Qwen-Client-Id', 'client_audit');
|
|
expect(deleteRes.status).toBe(204);
|
|
|
|
const events = (bridge as unknown as { events: RecordedEvent[] }).events;
|
|
const agentEvents = events.filter((e) => e.type === 'agent_changed');
|
|
expect(agentEvents).toHaveLength(3);
|
|
// All three must be stamped with the originator id so audit /
|
|
// echo-suppression on the SDK side can attribute them.
|
|
for (const evt of agentEvents) {
|
|
expect(evt.originatorClientId).toBe('client_audit');
|
|
}
|
|
// Sequence: created → updated → deleted.
|
|
expect(
|
|
agentEvents.map((e) => (e.data as { change: string }).change),
|
|
).toEqual(['created', 'updated', 'deleted']);
|
|
});
|
|
|
|
it('returns 400 invalid_agent_type for path-traversal-shaped agentType', async () => {
|
|
const bridge = buildBridgeStub();
|
|
const app = buildApp({ bridge, boundWorkspace: workspace });
|
|
// The readdir-based scan in `findSubagentByNameAtLevel` already
|
|
// protects against path traversal (filenames are matched, not
|
|
// joined-and-resolved), but the route-level regex check fails
|
|
// fast at the boundary so unsafe-shaped names never reach
|
|
// SubagentManager.
|
|
const res = await request(app).get('/workspace/agents/..%2Fetc%2Fpasswd');
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.code).toBe('invalid_agent_type');
|
|
});
|
|
|
|
it('rejects 400 invalid_agent_type for over-long agentType', async () => {
|
|
const bridge = buildBridgeStub();
|
|
const app = buildApp({ bridge, boundWorkspace: workspace });
|
|
const longName = 'a'.repeat(65);
|
|
const res = await request(app).delete(`/workspace/agents/${longName}`);
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.code).toBe('invalid_agent_type');
|
|
});
|
|
|
|
it('returns 500 agent_delete_partial when one level unlink silently fails', async () => {
|
|
// Windows ignores Unix-style permission bits passed to
|
|
// `fs.chmod` — the user-agents directory stays writable, the
|
|
// unlink succeeds, and the partial-delete path this test
|
|
// exercises is unreachable. SubagentManager's `unlink` import
|
|
// (`import * as fs from 'fs/promises'`) creates a sealed
|
|
// namespace object that vitest can't `spyOn`, so a per-platform
|
|
// mock is also off-limits. The route logic itself is
|
|
// platform-agnostic; the Ubuntu + macOS runs cover it. Mirrors
|
|
// the `process.platform === 'win32'` early-return idiom used in
|
|
// `customBanner.test.ts:232`.
|
|
if (process.platform === 'win32') return;
|
|
|
|
const bridge = buildBridgeStub();
|
|
const app = buildApp({ bridge, boundWorkspace: workspace });
|
|
|
|
// Set up a project-level agent.
|
|
await request(app).post('/workspace/agents').send({
|
|
name: 'partial-target',
|
|
description: 'a description longer than ten chars',
|
|
systemPrompt: 'you are a partial-target agent',
|
|
scope: 'workspace',
|
|
});
|
|
// Set up a user-level shadow with the same name.
|
|
await request(app).post('/workspace/agents').send({
|
|
name: 'partial-target',
|
|
description: 'a description longer than ten chars',
|
|
systemPrompt: 'you are a partial-target user agent',
|
|
scope: 'global',
|
|
});
|
|
|
|
// Lock the user-level agent's containing directory so the unlink
|
|
// raises EACCES — `SubagentManager.deleteSubagent` swallows the
|
|
// error and returns "success" because the project-level unlink
|
|
// worked. Without this PR's per-level `fs.access` verification,
|
|
// the route would 204 and publish a misleading `agent_changed`
|
|
// event for the user-level file that's still on disk.
|
|
const userAgentsDir = path.join(globalDir, 'agents');
|
|
const userPath = path.join(userAgentsDir, 'partial-target.md');
|
|
const originalMode = (await fs.stat(userAgentsDir)).mode;
|
|
await fs.chmod(userAgentsDir, 0o555); // r-x: blocks unlink
|
|
try {
|
|
const res = await request(app).delete('/workspace/agents/partial-target');
|
|
expect(res.status).toBe(500);
|
|
expect(res.body.code).toBe('agent_delete_partial');
|
|
expect(res.body.removedLevels).toEqual(['project']);
|
|
expect(res.body.remainingLevels).toEqual(['user']);
|
|
|
|
// Event fan-out: only one event for the level that actually
|
|
// disappeared. The remaining level (still on disk) must NOT
|
|
// emit a misleading deleted event.
|
|
const events = (bridge as unknown as { events: RecordedEvent[] }).events;
|
|
const deletedEvents = events.filter(
|
|
(e) =>
|
|
e.type === 'agent_changed' &&
|
|
(e.data as { change: string }).change === 'deleted',
|
|
);
|
|
expect(deletedEvents).toHaveLength(1);
|
|
expect((deletedEvents[0]?.data as { level: string }).level).toBe(
|
|
'project',
|
|
);
|
|
|
|
// Verify the user-level file is still on disk.
|
|
await expect(fs.access(userPath)).resolves.toBeUndefined();
|
|
} finally {
|
|
// Restore permissions so afterEach's rmdir succeeds.
|
|
await fs.chmod(userAgentsDir, originalMode);
|
|
}
|
|
});
|
|
|
|
it('DELETE /workspace/agents/:agentType?scope=workspace removes only the project shadow', async () => {
|
|
const bridge = buildBridgeStub();
|
|
const app = buildApp({ bridge, boundWorkspace: workspace });
|
|
await request(app).post('/workspace/agents').send({
|
|
name: 'scoped-delete',
|
|
description: 'a description longer than ten chars',
|
|
systemPrompt: 'you are a scoped-delete project agent',
|
|
scope: 'workspace',
|
|
});
|
|
await request(app).post('/workspace/agents').send({
|
|
name: 'scoped-delete',
|
|
description: 'a description longer than ten chars',
|
|
systemPrompt: 'you are a scoped-delete user agent',
|
|
scope: 'global',
|
|
});
|
|
|
|
const res = await request(app).delete(
|
|
'/workspace/agents/scoped-delete?scope=workspace',
|
|
);
|
|
expect(res.status).toBe(204);
|
|
|
|
// Project file gone; user file still exists.
|
|
await expect(
|
|
fs.access(path.join(workspace, QWEN_DIR, 'agents', 'scoped-delete.md')),
|
|
).rejects.toMatchObject({ code: 'ENOENT' });
|
|
await expect(
|
|
fs.access(path.join(globalDir, 'agents', 'scoped-delete.md')),
|
|
).resolves.toBeUndefined();
|
|
|
|
// Exactly one agent_changed event, at project level.
|
|
const events = (bridge as unknown as { events: RecordedEvent[] }).events;
|
|
const deleteEvents = events.filter(
|
|
(e) =>
|
|
e.type === 'agent_changed' &&
|
|
(e.data as { change: string }).change === 'deleted',
|
|
);
|
|
expect(deleteEvents).toHaveLength(1);
|
|
expect((deleteEvents[0]?.data as { level: string }).level).toBe('project');
|
|
});
|
|
|
|
it('POST /workspace/agents/:agentType?scope=global updates the user shadow', async () => {
|
|
const bridge = buildBridgeStub();
|
|
const app = buildApp({ bridge, boundWorkspace: workspace });
|
|
await request(app).post('/workspace/agents').send({
|
|
name: 'scoped-update',
|
|
description: 'a description longer than ten chars',
|
|
systemPrompt: 'you are a scoped-update project agent',
|
|
scope: 'workspace',
|
|
});
|
|
await request(app).post('/workspace/agents').send({
|
|
name: 'scoped-update',
|
|
description: 'a description longer than ten chars',
|
|
systemPrompt: 'you are a scoped-update user agent',
|
|
scope: 'global',
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post('/workspace/agents/scoped-update?scope=global')
|
|
.send({ description: 'NEW user-level description (longer than ten)' });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.changed).toBe(true);
|
|
expect(res.body.agent.level).toBe('user');
|
|
expect(res.body.agent.description).toBe(
|
|
'NEW user-level description (longer than ten)',
|
|
);
|
|
|
|
// Project-level definition is untouched.
|
|
const projectFile = await fs.readFile(
|
|
path.join(workspace, QWEN_DIR, 'agents', 'scoped-update.md'),
|
|
'utf8',
|
|
);
|
|
expect(projectFile).toContain('a description longer than ten chars');
|
|
});
|
|
|
|
it('rejects 422 when create has whitespace-only systemPrompt', async () => {
|
|
const bridge = buildBridgeStub();
|
|
const app = buildApp({ bridge, boundWorkspace: workspace });
|
|
const res = await request(app).post('/workspace/agents').send({
|
|
name: 'whitespace-prompt',
|
|
description: 'a description longer than ten chars',
|
|
systemPrompt: ' \n \t ',
|
|
scope: 'workspace',
|
|
});
|
|
expect(res.status).toBe(422);
|
|
expect(res.body.code).toBe('invalid_config');
|
|
expect(res.body.error).toMatch(/systemPrompt.*non-empty/);
|
|
});
|
|
|
|
it('rejects 422 when update has whitespace-only systemPrompt', async () => {
|
|
const bridge = buildBridgeStub();
|
|
const app = buildApp({ bridge, boundWorkspace: workspace });
|
|
await request(app).post('/workspace/agents').send({
|
|
name: 'prompt-target',
|
|
description: 'a description longer than ten chars',
|
|
systemPrompt: 'you are a prompt-target agent',
|
|
scope: 'workspace',
|
|
});
|
|
const res = await request(app)
|
|
.post('/workspace/agents/prompt-target')
|
|
.send({ systemPrompt: '\n\n \t' });
|
|
expect(res.status).toBe(422);
|
|
expect(res.body.code).toBe('invalid_config');
|
|
});
|
|
|
|
it('toDetail.runConfig only emits the documented fields', async () => {
|
|
const bridge = buildBridgeStub();
|
|
const app = buildApp({ bridge, boundWorkspace: workspace });
|
|
await request(app)
|
|
.post('/workspace/agents')
|
|
.send({
|
|
name: 'detail-pick',
|
|
description: 'a description longer than ten chars',
|
|
systemPrompt: 'you are a detail-pick agent',
|
|
scope: 'workspace',
|
|
runConfig: { max_time_minutes: 5, max_turns: 7 },
|
|
});
|
|
const res = await request(app).get('/workspace/agents/detail-pick');
|
|
expect(res.status).toBe(200);
|
|
// Detail must contain ONLY the whitelisted runConfig keys; if
|
|
// `SubagentConfig.runConfig` ever gains a new field in core, this
|
|
// assertion fails until the route schema is updated explicitly.
|
|
expect(Object.keys(res.body.runConfig).sort()).toEqual([
|
|
'max_time_minutes',
|
|
'max_turns',
|
|
]);
|
|
});
|
|
|
|
it('rejects 422 when update body has whitespace-only description', async () => {
|
|
const bridge = buildBridgeStub();
|
|
const app = buildApp({ bridge, boundWorkspace: workspace });
|
|
await request(app).post('/workspace/agents').send({
|
|
name: 'whitespace-target',
|
|
description: 'a description longer than ten chars',
|
|
systemPrompt: 'you are a whitespace target agent',
|
|
scope: 'workspace',
|
|
});
|
|
// Update path used to silently accept " " and overwrite the
|
|
// description with blank — divergent from create which 422s.
|
|
const res = await request(app)
|
|
.post('/workspace/agents/whitespace-target')
|
|
.send({ description: ' ' });
|
|
expect(res.status).toBe(422);
|
|
expect(res.body.code).toBe('invalid_config');
|
|
expect(res.body.error).toMatch(/non-empty/);
|
|
});
|
|
|
|
it('detects no-op partial runConfig update (preserves omitted keys)', async () => {
|
|
const bridge = buildBridgeStub();
|
|
const app = buildApp({ bridge, boundWorkspace: workspace });
|
|
await request(app)
|
|
.post('/workspace/agents')
|
|
.send({
|
|
name: 'runconfig-noop',
|
|
description: 'a description longer than ten chars',
|
|
systemPrompt: 'you are a runconfig-noop agent',
|
|
scope: 'workspace',
|
|
runConfig: { max_time_minutes: 30, max_turns: 10 },
|
|
});
|
|
const eventsBefore = (bridge as unknown as { events: RecordedEvent[] })
|
|
.events.length;
|
|
|
|
// Partial update with the SAME max_time_minutes value. Without the
|
|
// fix, isNoOpUpdate compared `undefined !== existing.max_turns` →
|
|
// true and re-wrote the file + emitted agent_changed.
|
|
const res = await request(app)
|
|
.post('/workspace/agents/runconfig-noop')
|
|
.send({ runConfig: { max_time_minutes: 30 } });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.changed).toBe(false);
|
|
|
|
const events = (bridge as unknown as { events: RecordedEvent[] }).events;
|
|
expect(events.length).toBe(eventsBefore);
|
|
});
|
|
|
|
it('detects real partial runConfig change and writes', async () => {
|
|
const bridge = buildBridgeStub();
|
|
const app = buildApp({ bridge, boundWorkspace: workspace });
|
|
await request(app)
|
|
.post('/workspace/agents')
|
|
.send({
|
|
name: 'runconfig-real',
|
|
description: 'a description longer than ten chars',
|
|
systemPrompt: 'you are a runconfig-real agent',
|
|
scope: 'workspace',
|
|
runConfig: { max_time_minutes: 30, max_turns: 10 },
|
|
});
|
|
const res = await request(app)
|
|
.post('/workspace/agents/runconfig-real')
|
|
.send({ runConfig: { max_time_minutes: 45 } });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.changed).toBe(true);
|
|
// Merged result preserves max_turns from existing.
|
|
expect(res.body.agent.runConfig).toEqual({
|
|
max_time_minutes: 45,
|
|
max_turns: 10,
|
|
});
|
|
});
|
|
|
|
it('short-circuits no-op updates with changed: false and no event', async () => {
|
|
const bridge = buildBridgeStub();
|
|
const app = buildApp({ bridge, boundWorkspace: workspace });
|
|
await request(app).post('/workspace/agents').send({
|
|
name: 'noop-target',
|
|
description: 'a description longer than ten chars',
|
|
systemPrompt: 'you are a noop-target agent',
|
|
scope: 'workspace',
|
|
});
|
|
const eventsBefore = (bridge as unknown as { events: RecordedEvent[] })
|
|
.events.length;
|
|
|
|
const res = await request(app)
|
|
.post('/workspace/agents/noop-target')
|
|
.send({ description: 'a description longer than ten chars' });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.changed).toBe(false);
|
|
|
|
// No new agent_changed event for the no-op update.
|
|
const events = (bridge as unknown as { events: RecordedEvent[] }).events;
|
|
expect(events.length).toBe(eventsBefore);
|
|
});
|
|
});
|