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 { APPROVAL_MODES, BuiltinAgentRegistry, SubagentError, SubagentErrorCode, SubagentManager, } from '@qwen-code/qwen-code-core';8import { writeStderrLine } from '../utils/stdioHelpers.js';9import { isServeDebugMode } from './debug-mode.js';10import { InvalidClientIdError, } from './acp-session-bridge.js';11import { safeLogValue } from './server/request-helpers.js';12/**13 * Pattern for the route-layer `:agentType` URL parameter. Matches the14 * `SubagentValidator.validateName` regex (`^[\p{L}\p{N}_-]+$`) so a15 * malformed path component (containing slashes, dots, control chars,16 * leading hyphen) is rejected at the boundary instead of trickling17 * through `findSubagentByNameAtLevel`'s readdir scan. Defense in18 * depth — `findSubagentByNameAtLevel` already prevents path traversal19 * via filename matching, but failing fast at the route layer keeps20 * surprising inputs out of downstream code paths.21 */22const AGENT_TYPE_PATTERN = /^[\p{L}\p{N}_-]+$/u;23/**24 * Cap on the route-layer name validator. SubagentValidator caps25 * payload-side names at 50 chars; the route check uses 64 to leave a26 * little headroom for legacy on-disk agents created with a longer27 * name (resolved via case-insensitive cascade) that a client tries28 * to GET / DELETE through the URL.29 */30const AGENT_TYPE_MAX_LENGTH = 64;31/**32 * Minimum agent-name length. Matches `SubagentValidator.validateName`33 * (which requires `trimmedName.length >= 2`). Keeping the same lower34 * bound at the route layer surfaces the constraint as a 422 instead35 * of letting core throw `VALIDATION_ERROR` (which the route also36 * 422s, but with a less specific message).37 */38const AGENT_TYPE_MIN_LENGTH = 2;39/**40 * Per-field size caps for create + update payloads. The Express body41 * parser caps the whole request at 10 MB but no per-field guard42 * existed, so a single payload could land a multi-megabyte43 * `systemPrompt` on disk and balloon every `GET /workspace/agents`44 * snapshot in memory. 256 KB is far above any realistic45 * user-authored prompt while keeping list-response cost bounded.46 */47const MAX_DESCRIPTION_BYTES = 256 * 1024;48const MAX_SYSTEM_PROMPT_BYTES = 256 * 1024;49const MAX_TOOLS_ENTRIES = 256;50const MAX_TOOL_ID_LENGTH = 256;51import { STATUS_SCHEMA_VERSION, } from '@qwen-code/acp-bridge/status';52export function mountWorkspaceAgentsRoutes(app, deps) {53 const manager = createDaemonSubagentManager(deps.boundWorkspace);54 app.get('/workspace/agents', async (_req, res) => {55 try {56 // `force: true` re-walks `.qwen/agents/` on every call so out-of-57 // band edits (a developer editing an agent file in their IDE58 // while the daemon is running) appear immediately. Without it59 // `SubagentManager.listSubagents()` serves a stale cache and60 // diverges from `GET /workspace/agents/:agentType`, which always61 // reads from disk (`loadSubagent → findSubagentByNameAtLevel →62 // listSubagentsAtLevel`). Bringing the LIST route to parity is63 // sub-millisecond for the typical 0-50 agents and matches the64 // detail route's "filesystem is the source of truth" contract.65 //66 // No TTL cache or `fs.watch`-based invalidation here despite the67 // 4-level walk per request. Reasoning:68 // - 4 levels × <50 agents on local SSD = sub-ms IO, well below69 // the per-request budget for any client UI.70 // - A short-TTL cache would re-introduce the exact stale-list71 // bug that was previously fixed (a recently-edited file invisible72 // until the TTL elapses); invalidation logic adds state to73 // the route handler that the audit / policy / mediator layer74 // is the proper home for.75 // - `fs.watch` is platform-fragile (recursive watch broken on76 // some macOS Node versions, inotify limits on Linux) and the77 // daemon's per-request semantics make watchers harder to78 // reason about than a fresh disk read.79 // - Burst protection lives at `--max-connections` (256) +80 // bearer auth on non-loopback, not at the route layer.81 // Revisit if profiling shows the LIST route is on the hot path.82 const agents = await manager.listSubagents({ force: true });83 const status = {84 v: STATUS_SCHEMA_VERSION,85 workspaceCwd: deps.boundWorkspace,86 agents: agents.map(toSummary),87 };88 res.status(200).json(status);89 }90 catch (err) {91 writeStderrLine(`qwen serve: GET /workspace/agents failed: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);92 res.status(500).json({93 error: 'Failed to list workspace agents',94 code: 'agent_list_failed',95 });96 }97 });98 app.post('/workspace/agents', deps.mutate({ strict: true }), async (req, res) => {99 const body = deps.safeBody(req);100 const clientIdResult = resolveOriginatorClientId(deps, req, res);101 if (clientIdResult === null)102 return;103 const originatorClientId = clientIdResult;104 const scope = body['scope'];105 if (scope !== 'workspace' && scope !== 'global') {106 res.status(400).json({107 error: '`scope` must be "workspace" or "global"',108 code: 'invalid_scope',109 });110 return;111 }112 const level = scope === 'workspace' ? 'project' : 'user';113 const config = parseAgentConfig(body, level, res);114 if (!config)115 return;116 // `manager.createSubagent` only checks whether the default117 // `<name>.md` file path is occupied. If a different on-disk118 // file at the same level shares the frontmatter `name`, the119 // duplicate-name collision wouldn't surface as 409. Preflight120 // through `loadSubagent(name, level)` so a same-name shadow at121 // either level returns `agent_already_exists` deterministically.122 const collision = await manager.loadSubagent(config.name, level);123 if (collision) {124 res.status(409).json({125 error: `Subagent "${config.name}" already exists at ${level} level`,126 code: 'agent_already_exists',127 name: config.name,128 level,129 });130 return;131 }132 try {133 await manager.createSubagent(config, { level });134 }135 catch (err) {136 if (err instanceof SubagentError) {137 if (err.code === SubagentErrorCode.ALREADY_EXISTS) {138 res.status(409).json({139 error: err.message,140 code: 'agent_already_exists',141 name: err.subagentName ?? config.name,142 });143 return;144 }145 if (err.code === SubagentErrorCode.VALIDATION_ERROR ||146 err.code === SubagentErrorCode.INVALID_CONFIG ||147 err.code === SubagentErrorCode.INVALID_NAME ||148 err.code === SubagentErrorCode.TOOL_NOT_FOUND) {149 res.status(422).json({150 error: err.message,151 code: 'invalid_config',152 name: err.subagentName ?? config.name,153 });154 return;155 }156 if (err.code === SubagentErrorCode.FILE_ERROR) {157 // `SubagentError(FILE_ERROR)` wraps Node fs error158 // messages like `"ENOENT: no such file or directory, open159 // '/Users/<x>/.qwen/agents/foo.md'"` — leaking the160 // operator's absolute filesystem layout through an161 // authenticated route response. Gate the message behind162 // `QWEN_SERVE_DEBUG` so default production responses163 // carry only the generic envelope; operators triaging164 // locally enable the toggle to get the path back.165 // Mirrors the workspaceMemory route's `file_error`166 // disclosure posture.167 const debug = isServeDebugMode();168 res.status(500).json({169 error: debug170 ? err.message171 : 'Failed to write workspace agent file',172 code: 'file_error',173 name: err.subagentName ?? config.name,174 });175 return;176 }177 }178 writeStderrLine(`qwen serve: POST /workspace/agents failed: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);179 res.status(500).json({180 error: 'Failed to create workspace agent',181 code: 'agent_create_failed',182 });183 return;184 }185 const created = await manager.loadSubagent(config.name, level);186 if (!created) {187 // Race window: createSubagent already wrote the file to disk,188 // but the subsequent loadSubagent walked the cache and found189 // nothing — typically a cache-refresh ordering bug. The file190 // persists (no rollback) because deleting on a half-failed191 // create would lose work for an agent that's actually fine on192 // disk. Operators MUST be able to correlate the orphan file193 // with the failed POST, so emit a stderr breadcrumb with the194 // path; a fresh `GET /workspace/agents` will surface the195 // agent on next request. PermissionMediator can layer196 // a proper rollback policy on top once mutation auditing197 // arrives.198 writeStderrLine(`qwen serve: agent_create_reload_failed (name=${safeLogValue(config.name)} ` +199 `level=${level}) — file likely persisted on disk; check ` +200 `\`GET /workspace/agents\` for a phantom entry`);201 res.status(500).json({202 error: 'Agent creation succeeded but reload failed',203 code: 'agent_create_reload_failed',204 name: config.name,205 level,206 });207 return;208 }209 deps.bridge.publishWorkspaceEvent({210 type: 'agent_changed',211 data: { change: 'created', name: config.name, level },212 ...(originatorClientId ? { originatorClientId } : {}),213 });214 res.status(201).json({ ok: true, agent: toDetail(created) });215 });216 app.post('/workspace/agents/generate', deps.mutate({ strict: true }), async (req, res) => {217 const body = deps.safeBody(req);218 const clientIdResult = resolveOriginatorClientId(deps, req, res);219 if (clientIdResult === null)220 return;221 const originatorClientId = clientIdResult;222 const description = body['description'];223 if (typeof description !== 'string' || description.trim().length === 0) {224 res.status(400).json({225 error: '`description` must be a non-empty string',226 code: 'invalid_description',227 });228 return;229 }230 if (Buffer.byteLength(description, 'utf8') > 4096) {231 res.status(400).json({232 error: '`description` exceeds the 4096-byte limit',233 code: 'invalid_description',234 });235 return;236 }237 try {238 const generated = await deps.bridge.generateWorkspaceAgent(description.trim(), originatorClientId);239 res.status(200).json(generated);240 }241 catch (err) {242 writeStderrLine(`qwen serve: POST /workspace/agents/generate failed: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);243 res.status(500).json({244 error: 'Failed to generate workspace agent',245 code: 'agent_generate_failed',246 });247 }248 });249 app.get('/workspace/agents/:agentType', async (req, res) => {250 const agentType = validateAgentType(req, res);251 if (agentType === null)252 return;253 try {254 const config = await manager.loadSubagent(agentType);255 if (!config) {256 res.status(404).json({257 error: `Subagent "${agentType}" not found`,258 code: 'agent_not_found',259 name: agentType,260 });261 return;262 }263 res.status(200).json(toDetail(config));264 }265 catch (err) {266 writeStderrLine(`qwen serve: GET /workspace/agents/${safeLogValue(agentType)} failed: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);267 res.status(500).json({268 error: 'Failed to read workspace agent',269 code: 'agent_read_failed',270 });271 }272 });273 app.post('/workspace/agents/:agentType', deps.mutate({ strict: true }), async (req, res) => {274 const agentType = validateAgentType(req, res);275 if (agentType === null)276 return;277 const clientIdResult = resolveOriginatorClientId(deps, req, res);278 if (clientIdResult === null)279 return;280 const originatorClientId = clientIdResult;281 const body = deps.safeBody(req);282 const updates = parseAgentUpdates(body, res);283 if (!updates)284 return;285 const preferredLevel = parseScopeQuery(req, res);286 if (preferredLevel === null)287 return;288 const existing = await manager.loadSubagent(agentType, preferredLevel);289 if (!existing) {290 res.status(404).json({291 error: `Subagent "${agentType}" not found`,292 code: 'agent_not_found',293 name: agentType,294 });295 return;296 }297 if (assertMutableLevel(existing, agentType, res)) {298 return;299 }300 // Empty / no-op update detection. An empty body or a body whose301 // recognized fields all match `existing` would otherwise rewrite302 // the file (mtime bump) AND fan out an `agent_changed` event for303 // a request that didn't change anything — the same misleading304 // signal the memory route avoids for whitespace-only appends.305 // Reject empty payloads with 400; short-circuit no-op updates306 // with 200 + `changed: false` so adapters can suppress redundant307 // toasts without re-fetching.308 if (Object.keys(updates).length === 0) {309 res.status(400).json({310 error: '`POST /workspace/agents/:agentType` requires at least one updatable field in the body',311 code: 'invalid_config',312 name: agentType,313 });314 return;315 }316 if (isNoOpUpdate(existing, updates)) {317 res.status(200).json({318 ok: true,319 agent: toDetail(existing),320 changed: false,321 });322 return;323 }324 try {325 await manager.updateSubagent(agentType, updates, existing.level);326 }327 catch (err) {328 if (err instanceof SubagentError) {329 if (err.code === SubagentErrorCode.NOT_FOUND) {330 res.status(404).json({331 error: err.message,332 code: 'agent_not_found',333 name: err.subagentName ?? agentType,334 });335 return;336 }337 if (err.code === SubagentErrorCode.INVALID_CONFIG) {338 res.status(403).json({339 error: err.message,340 code: 'agent_readonly',341 name: err.subagentName ?? agentType,342 });343 return;344 }345 if (err.code === SubagentErrorCode.VALIDATION_ERROR ||346 err.code === SubagentErrorCode.INVALID_NAME ||347 err.code === SubagentErrorCode.TOOL_NOT_FOUND) {348 res.status(422).json({349 error: err.message,350 code: 'invalid_config',351 name: err.subagentName ?? agentType,352 });353 return;354 }355 if (err.code === SubagentErrorCode.FILE_ERROR) {356 // Same path-disclosure gating as the create-path357 // FILE_ERROR handler above. Default response is the358 // generic envelope; `QWEN_SERVE_DEBUG` re-enables the359 // raw `err.message` for local triage.360 const debug = isServeDebugMode();361 res.status(500).json({362 error: debug363 ? err.message364 : 'Failed to write workspace agent file',365 code: 'file_error',366 name: err.subagentName ?? agentType,367 });368 return;369 }370 }371 writeStderrLine(`qwen serve: POST /workspace/agents/${safeLogValue(agentType)} failed: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);372 res.status(500).json({373 error: 'Failed to update workspace agent',374 code: 'agent_update_failed',375 });376 return;377 }378 const updated = await manager.loadSubagent(agentType, existing.level);379 if (!updated) {380 // Symmetric to the create-reload-failure branch above. The381 // disk write succeeded but the cache lookup raced; emit a382 // breadcrumb so operators can correlate the orphan in-flight383 // change with the failed POST. The file is in its updated384 // state on disk; subsequent reads will pick it up.385 writeStderrLine(`qwen serve: agent_update_reload_failed (name=${safeLogValue(agentType)} ` +386 `level=${existing.level}) — disk write completed; check ` +387 `\`GET /workspace/agents/${safeLogValue(agentType)}\` for the new state`);388 res.status(500).json({389 error: 'Agent update succeeded but reload failed',390 code: 'agent_update_reload_failed',391 name: agentType,392 level: existing.level,393 });394 return;395 }396 const eventLevel = existing.level === 'project' ? 'project' : 'user';397 deps.bridge.publishWorkspaceEvent({398 type: 'agent_changed',399 data: { change: 'updated', name: existing.name, level: eventLevel },400 ...(originatorClientId ? { originatorClientId } : {}),401 });402 res403 .status(200)404 .json({ ok: true, agent: toDetail(updated), changed: true });405 });406 app.delete('/workspace/agents/:agentType', deps.mutate({ strict: true }), async (req, res) => {407 const agentType = validateAgentType(req, res);408 if (agentType === null)409 return;410 const clientIdResult = resolveOriginatorClientId(deps, req, res);411 if (clientIdResult === null)412 return;413 const originatorClientId = clientIdResult;414 const scopedLevel = parseScopeQuery(req, res);415 if (scopedLevel === null)416 return;417 // Pre-check at every level we're going to try to delete. When418 // `scopedLevel` is given we touch just that level; when omitted,419 // `SubagentManager.deleteSubagent` iterates both `project` and420 // `user`, so we need to look at both to (a) reject built-in /421 // extension shadows and (b) emit one `agent_changed` event per422 // file actually removed.423 const levelsToCheck = scopedLevel424 ? [scopedLevel]425 : ['project', 'user'];426 const existingAtLevels = [];427 for (const lvl of levelsToCheck) {428 const found = await manager.loadSubagent(agentType, lvl);429 if (found)430 existingAtLevels.push(found);431 }432 for (const found of existingAtLevels) {433 if (assertMutableLevel(found, agentType, res))434 return;435 }436 try {437 await manager.deleteSubagent(agentType, scopedLevel);438 }439 catch (err) {440 if (err instanceof SubagentError) {441 if (err.code === SubagentErrorCode.NOT_FOUND) {442 res.status(404).json({443 error: err.message,444 code: 'agent_not_found',445 name: err.subagentName ?? agentType,446 });447 return;448 }449 if (err.code === SubagentErrorCode.INVALID_CONFIG) {450 res.status(403).json({451 error: err.message,452 code: 'agent_readonly',453 name: err.subagentName ?? agentType,454 });455 return;456 }457 }458 writeStderrLine(`qwen serve: DELETE /workspace/agents/${safeLogValue(agentType)} failed: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);459 res.status(500).json({460 error: 'Failed to delete workspace agent',461 code: 'agent_delete_failed',462 });463 return;464 }465 // `SubagentManager.deleteSubagent` swallows per-level466 // `fs.unlink()` failures and467 // returns success as long as ANY level was removed. Trusting468 // that signal would let us publish `agent_changed`/`deleted`469 // for a file still on disk (EACCES / EBUSY / EPERM) — the470 // client UI would drop a still-active definition from cache.471 // Verify each pre-checked level's file is actually gone via472 // `fs.access`; only fan out the event for confirmed removals.473 // If at least one level still has its file, return 500 with474 // the residual list so callers can act.475 const removed = [];476 const remaining = [];477 for (const found of existingAtLevels) {478 if (!found.filePath) {479 // Synthetic / no-file entries (impossible at project /480 // user levels, defensive guard) treat as "no verification481 // possible" → assume removed to match legacy behavior.482 removed.push(found);483 continue;484 }485 try {486 await fs.access(found.filePath);487 // Still present → unlink failed silently.488 remaining.push(found);489 }490 catch {491 // Any access error (typically ENOENT) means the file is492 // gone — count as successfully removed.493 removed.push(found);494 }495 }496 if (remaining.length > 0) {497 writeStderrLine(`qwen serve: DELETE /workspace/agents/${safeLogValue(agentType)} partial — ` +498 `removed=${removed.map((r) => r.level).join(',') || 'none'} ` +499 `remaining=${remaining500 .map((r) => `${r.level}:${r.filePath}`)501 .join(',')}`);502 // Still publish events for files we DID remove so subscribers503 // get partial-success signals — but emit them BEFORE the 500504 // so a client reading the response can correlate.505 for (const found of removed) {506 const evtLevel = found.level === 'project' ? 'project' : 'user';507 deps.bridge.publishWorkspaceEvent({508 type: 'agent_changed',509 data: {510 change: 'deleted',511 name: found.name,512 level: evtLevel,513 },514 ...(originatorClientId ? { originatorClientId } : {}),515 });516 }517 res.status(500).json({518 error: `Failed to delete every level of subagent "${agentType}" — ` +519 `${remaining.length} level(s) still have their file on disk`,520 code: 'agent_delete_partial',521 name: agentType,522 removedLevels: removed.map((r) => r.level),523 remainingLevels: remaining.map((r) => r.level),524 });525 return;526 }527 // Emit one event per level that was deleted so subscribers using528 // event metadata for toasts/audit/echo-suppression see the529 // complete picture. Without this split, an unscoped DELETE that530 // removed both project AND user shadows would publish only one531 // event with one level — misleading the receiver about which532 // file(s) actually went away.533 if (existingAtLevels.length === 0) {534 // `deleteSubagent` succeeded with no pre-checked level — could535 // happen if a file landed between the loadSubagent check and536 // the unlink. Emit a single best-effort event with the level537 // hint we know.538 const fallbackLevel = scopedLevel === 'user' ? 'user' : 'project';539 deps.bridge.publishWorkspaceEvent({540 type: 'agent_changed',541 data: {542 change: 'deleted',543 name: agentType,544 level: fallbackLevel,545 },546 ...(originatorClientId ? { originatorClientId } : {}),547 });548 }549 else {550 for (const found of removed) {551 const evtLevel = found.level === 'project' ? 'project' : 'user';552 deps.bridge.publishWorkspaceEvent({553 type: 'agent_changed',554 data: {555 change: 'deleted',556 name: found.name,557 level: evtLevel,558 },559 ...(originatorClientId ? { originatorClientId } : {}),560 });561 }562 }563 res.status(204).end();564 });565}566/**567 * Pull `:agentType` off the request and reject malformed values at568 * the route boundary. Returns the validated string, or `null` AFTER569 * sending its own 400 — caller must short-circuit on `null`.570 */571function validateAgentType(req, res) {572 const raw = req.params['agentType'];573 if (!raw || raw.length === 0) {574 res.status(400).json({575 error: '`agentType` path parameter is required',576 code: 'invalid_agent_type',577 });578 return null;579 }580 if (raw.length > AGENT_TYPE_MAX_LENGTH || !AGENT_TYPE_PATTERN.test(raw)) {581 res.status(400).json({582 error: '`agentType` must contain only letters, numbers, hyphens, or underscores (max 64 chars)',583 code: 'invalid_agent_type',584 agentType: raw,585 });586 return null;587 }588 return raw;589}590/**591 * Read the `?scope=` query, fail-closed on repeated/non-string592 * values, and translate `workspace`/`global` into the593 * `SubagentLevel` the manager expects. Returns:594 * - `undefined` when `scope` is absent (caller falls back to default595 * resolution / both levels);596 * - the resolved `SubagentLevel` when valid;597 * - `null` when the query was malformed AND the response was598 * already sent — caller must short-circuit.599 *600 * Centralizes the duplicated parser block from the POST update +601 * DELETE handlers so a future scope addition (e.g. `extension`)602 * stays in one place.603 */604function parseScopeQuery(req, res) {605 const raw = req.query['scope'];606 if (raw === undefined)607 return undefined;608 if (typeof raw !== 'string') {609 res.status(400).json({610 error: '`scope` query must be a single "workspace" or "global" value',611 code: 'invalid_scope',612 });613 return null;614 }615 if (raw !== 'workspace' && raw !== 'global') {616 res.status(400).json({617 error: '`scope` query must be "workspace" or "global"',618 code: 'invalid_scope',619 });620 return null;621 }622 return raw === 'workspace' ? 'project' : 'user';623}624/**625 * Reject mutation attempts targeting a read-only agent626 * (built-in / extension / session). Returns `true` after sending627 * the 403 — caller must short-circuit on `true`. Returns `false`628 * when the entry is mutable (`project` / `user`).629 *630 * Centralizes the duplicated guard from the POST update + DELETE631 * handlers; a future PR adding a new mutation route just calls this632 * helper instead of re-implementing the predicate.633 */634function assertMutableLevel(found, agentType, res) {635 if (found.isBuiltin ||636 found.level === 'builtin' ||637 found.level === 'extension' ||638 found.level === 'session') {639 res.status(403).json({640 error: `Cannot modify ${found.level}-level subagent "${agentType}"`,641 code: 'agent_readonly',642 name: found.name,643 level: found.level,644 });645 return true;646 }647 return false;648}649function resolveOriginatorClientId(deps, req, res) {650 const clientId = deps.parseClientId(req, res);651 if (clientId === null)652 return null;653 if (clientId === undefined)654 return undefined;655 if (!deps.bridge.knownClientIds().has(clientId)) {656 res.status(400).json({657 error: `Client id "${clientId}" is not registered for this workspace`,658 code: 'invalid_client_id',659 clientId,660 });661 return null;662 }663 return clientId;664}665function parseAgentConfig(body, level, res) {666 const rawName = body['name'];667 if (typeof rawName !== 'string' || rawName.trim().length === 0) {668 res.status(422).json({669 error: '`name` is required and must be a non-empty string',670 code: 'invalid_config',671 });672 return undefined;673 }674 // Trim leading/trailing whitespace BEFORE storing. Without this, a675 // client posting `{ name: " tester " }` would land a file whose676 // frontmatter `name` field literally contains the spaces; the677 // resolver's case-insensitive cascade still wouldn't match `/agents/678 // tester` because the lookup name and the on-disk name differ.679 // Better to normalize at the boundary than carry untrimmed names680 // through validation + serialization.681 const name = rawName.trim();682 // Apply the same regex + length contract `validateAgentType` uses683 // for `:agentType` URL parameters. Without this, a client could684 // `POST /workspace/agents` with `name: "my/agent"` or685 // `name: "a".repeat(100)` — names that the route's regex would686 // reject if echoed back through GET / DELETE, plus the core's687 // `SubagentValidator` would reject with a different error shape.688 // Failing at the body-validation boundary keeps the round-trip689 // (POST → GET → DELETE) coherent under one error shape.690 if (name.length < AGENT_TYPE_MIN_LENGTH ||691 name.length > AGENT_TYPE_MAX_LENGTH ||692 !AGENT_TYPE_PATTERN.test(name)) {693 res.status(422).json({694 error: `\`name\` must be ${AGENT_TYPE_MIN_LENGTH}-${AGENT_TYPE_MAX_LENGTH} characters of letters, numbers, hyphens, or underscores`,695 code: 'invalid_config',696 name,697 });698 return undefined;699 }700 // Reject names that shadow a built-in subagent. Without this check a701 // client could `POST /workspace/agents { name: "general-purpose" }`702 // and write a project-level file at `<workspace>/.qwen/agents/703 // general-purpose.md`. List/load resolve the project entry first704 // (project > builtin), but `SubagentManager.deleteSubagent` rejects705 // by name alone (`subagent-manager.ts:302`) — so DELETE returns 403706 // `agent_readonly` and the file becomes undeleteable through the707 // API. Surface the conflict at create time instead. The check is708 // case-insensitive (`BuiltinAgentRegistry.isBuiltinAgent` lowercases709 // both sides), matching `loadSubagent`'s case-insensitive cascade.710 if (BuiltinAgentRegistry.isBuiltinAgent(name)) {711 res.status(422).json({712 error: `"${name}" shadows a built-in subagent and cannot be used as a project- or user-level agent name. Choose a different name.`,713 code: 'invalid_config',714 name,715 });716 return undefined;717 }718 const description = body['description'];719 if (typeof description !== 'string' || description.trim().length === 0) {720 res.status(422).json({721 error: '`description` is required and must be a non-empty string',722 code: 'invalid_config',723 });724 return undefined;725 }726 if (Buffer.byteLength(description, 'utf8') > MAX_DESCRIPTION_BYTES) {727 res.status(422).json({728 error: `\`description\` exceeds the ${MAX_DESCRIPTION_BYTES}-byte limit`,729 code: 'invalid_config',730 });731 return undefined;732 }733 const systemPrompt = body['systemPrompt'];734 if (typeof systemPrompt !== 'string' || systemPrompt.trim().length === 0) {735 // Reject whitespace-only systemPrompts to match the description736 // field's `trim().length === 0` rule. A pure-whitespace prompt737 // would land on disk as effectively empty (the YAML serializer738 // collapses blank lines), and the agent can't operate without739 // instructions, so a 422 at the boundary is friendlier than a740 // mysterious downstream "agent does nothing" failure.741 res.status(422).json({742 error: '`systemPrompt` is required and must be a non-empty string (whitespace only is rejected)',743 code: 'invalid_config',744 });745 return undefined;746 }747 if (Buffer.byteLength(systemPrompt, 'utf8') > MAX_SYSTEM_PROMPT_BYTES) {748 res.status(422).json({749 error: `\`systemPrompt\` exceeds the ${MAX_SYSTEM_PROMPT_BYTES}-byte limit`,750 code: 'invalid_config',751 });752 return undefined;753 }754 const tools = parseStringArray(body['tools'], 'tools', res);755 if (tools === null)756 return undefined;757 const disallowedTools = parseStringArray(body['disallowedTools'], 'disallowedTools', res);758 if (disallowedTools === null)759 return undefined;760 const config = {761 name,762 description,763 systemPrompt,764 level,765 };766 if (tools !== undefined)767 config.tools = tools;768 if (disallowedTools !== undefined)769 config.disallowedTools = disallowedTools;770 // Optional scalar fields. Present-but-wrong-type fails closed (422)771 // rather than silently dropping the field — `SubagentValidator`772 // doesn't reject these, and `serializeSubagent` only writes recognized773 // values, so without explicit validation a `model: 123` payload would774 // 201 with no `model` field on the file (masking client-serialization775 // bugs).776 if (rejectIfPresentWrongType(body, 'model', 'string', res))777 return undefined;778 if (typeof body['model'] === 'string')779 config.model = body['model'];780 if (rejectIfPresentWrongType(body, 'color', 'string', res))781 return undefined;782 if (typeof body['color'] === 'string')783 config.color = body['color'];784 if (rejectIfPresentWrongType(body, 'approvalMode', 'string', res)) {785 return undefined;786 }787 if (typeof body['approvalMode'] === 'string') {788 if (!APPROVAL_MODES.includes(body['approvalMode'])) {789 res.status(422).json({790 error: `\`approvalMode\` must be one of ${JSON.stringify(APPROVAL_MODES)}`,791 code: 'invalid_config',792 });793 return undefined;794 }795 config.approvalMode = body['approvalMode'];796 }797 if (rejectIfPresentWrongType(body, 'background', 'boolean', res)) {798 return undefined;799 }800 if (typeof body['background'] === 'boolean') {801 config.background = body['background'];802 }803 const runConfig = body['runConfig'];804 if (runConfig !== undefined) {805 const sanitized = sanitizeRunConfig(runConfig, res);806 if (sanitized === null)807 return undefined;808 config.runConfig = sanitized;809 }810 return config;811}812function parseAgentUpdates(body, res) {813 const updates = {};814 if ('description' in body) {815 const value = body['description'];816 // Match the create-side rule: `description` is required and817 // non-empty after trim. The previous update path silently818 // accepted `" "` and let `mergeConfigurations` write a blank819 // description to the file — divergent from create which would820 // 422 the same payload.821 if (typeof value !== 'string' || value.trim().length === 0) {822 res.status(422).json({823 error: '`description` must be a non-empty string (whitespace only is rejected) when provided',824 code: 'invalid_config',825 });826 return undefined;827 }828 if (Buffer.byteLength(value, 'utf8') > MAX_DESCRIPTION_BYTES) {829 res.status(422).json({830 error: `\`description\` exceeds the ${MAX_DESCRIPTION_BYTES}-byte limit`,831 code: 'invalid_config',832 });833 return undefined;834 }835 updates.description = value;836 }837 if ('systemPrompt' in body) {838 const value = body['systemPrompt'];839 if (typeof value !== 'string' || value.trim().length === 0) {840 // Mirror create's `systemPrompt.trim().length === 0` check.841 // A whitespace-only prompt is effectively empty after YAML842 // serialization and the agent can't operate without843 // instructions, so reject at the boundary.844 res.status(422).json({845 error: '`systemPrompt` must be a non-empty string (whitespace only is rejected) when provided',846 code: 'invalid_config',847 });848 return undefined;849 }850 if (Buffer.byteLength(value, 'utf8') > MAX_SYSTEM_PROMPT_BYTES) {851 res.status(422).json({852 error: `\`systemPrompt\` exceeds the ${MAX_SYSTEM_PROMPT_BYTES}-byte limit`,853 code: 'invalid_config',854 });855 return undefined;856 }857 updates.systemPrompt = value;858 }859 if ('tools' in body) {860 const tools = parseStringArray(body['tools'], 'tools', res);861 if (tools === null)862 return undefined;863 if (tools !== undefined)864 updates.tools = tools;865 }866 if ('disallowedTools' in body) {867 const disallowedTools = parseStringArray(body['disallowedTools'], 'disallowedTools', res);868 if (disallowedTools === null)869 return undefined;870 if (disallowedTools !== undefined) {871 updates.disallowedTools = disallowedTools;872 }873 }874 // Optional scalar fields. Match the create-side fail-closed posture875 // so a typo like `model: 123` returns 422 instead of silently876 // succeeding with no model change.877 if (rejectIfPresentWrongType(body, 'model', 'string', res))878 return undefined;879 if (typeof body['model'] === 'string')880 updates.model = body['model'];881 if (rejectIfPresentWrongType(body, 'color', 'string', res))882 return undefined;883 if (typeof body['color'] === 'string')884 updates.color = body['color'];885 if (rejectIfPresentWrongType(body, 'approvalMode', 'string', res)) {886 return undefined;887 }888 if (typeof body['approvalMode'] === 'string') {889 if (!APPROVAL_MODES.includes(body['approvalMode'])) {890 res.status(422).json({891 error: `\`approvalMode\` must be one of ${JSON.stringify(APPROVAL_MODES)}`,892 code: 'invalid_config',893 });894 return undefined;895 }896 updates.approvalMode = body['approvalMode'];897 }898 if (rejectIfPresentWrongType(body, 'background', 'boolean', res)) {899 return undefined;900 }901 if (typeof body['background'] === 'boolean') {902 updates.background = body['background'];903 }904 if ('runConfig' in body) {905 const sanitized = sanitizeRunConfig(body['runConfig'], res);906 if (sanitized === null)907 return undefined;908 updates.runConfig = sanitized;909 }910 return updates;911}912function parseStringArray(value, field, res) {913 if (value === undefined)914 return undefined;915 if (!Array.isArray(value) || value.some((v) => typeof v !== 'string')) {916 res.status(422).json({917 error: `\`${field}\` must be an array of strings when provided`,918 code: 'invalid_config',919 });920 return null;921 }922 if (value.length > MAX_TOOLS_ENTRIES) {923 res.status(422).json({924 error: `\`${field}\` exceeds the ${MAX_TOOLS_ENTRIES}-entry limit`,925 code: 'invalid_config',926 });927 return null;928 }929 if (value.some((v) => v.length > MAX_TOOL_ID_LENGTH)) {930 res.status(422).json({931 error: `\`${field}\` entries must be at most ${MAX_TOOL_ID_LENGTH} characters`,932 code: 'invalid_config',933 });934 return null;935 }936 return value;937}938/**939 * Returns `true` and sends a 422 when `body[key]` is present but the940 * wrong scalar type. The caller then returns `undefined` to short-941 * circuit the route. `false` covers both "absent" and "right type" so942 * the caller proceeds. Used to give scalar fields the same fail-closed943 * posture as `parseStringArray` / `sanitizeRunConfig`.944 */945function rejectIfPresentWrongType(body, key, expected, res) {946 if (!(key in body))947 return false;948 if (typeof body[key] === expected)949 return false;950 res.status(422).json({951 error: `\`${key}\` must be a ${expected} when provided`,952 code: 'invalid_config',953 });954 return true;955}956/**957 * Detect a no-op update — every supplied field already matches the958 * existing agent's value. Without this check an empty (or959 * value-unchanged) PATCH still rewrites the file, bumps mtime, and960 * fans out a misleading `agent_changed` event. The recognized-field961 * comparison covers what `parseAgentUpdates` produces; unknown keys962 * are dropped upstream so we don't need to handle them here.963 */964function isNoOpUpdate(existing, updates) {965 if (updates.description !== undefined &&966 updates.description !== existing.description) {967 return false;968 }969 if (updates.systemPrompt !== undefined &&970 updates.systemPrompt !== existing.systemPrompt) {971 return false;972 }973 if (updates.tools !== undefined &&974 !shallowArrayEqual(updates.tools, existing.tools)) {975 return false;976 }977 if (updates.disallowedTools !== undefined &&978 !shallowArrayEqual(updates.disallowedTools, existing.disallowedTools)) {979 return false;980 }981 if (updates.model !== undefined && updates.model !== existing.model) {982 return false;983 }984 if (updates.color !== undefined && updates.color !== existing.color) {985 return false;986 }987 if (updates.approvalMode !== undefined &&988 updates.approvalMode !== existing.approvalMode) {989 return false;990 }991 if (updates.background !== undefined &&992 updates.background !== existing.background) {993 return false;994 }995 if (updates.runConfig !== undefined) {996 // `SubagentManager.mergeConfigurations` MERGES `updates.runConfig`997 // with `existing.runConfig` (existing keys preserved when not in998 // updates), so the no-op check must compare only the keys the999 // caller actually intends to change. Comparing every known field1000 // against `existing` would treat any partial update as non-no-op1001 // because absent keys would be `undefined` while existing has a1002 // value — a false positive that would re-emit `agent_changed`1003 // for a request that didn't actually mutate anything.1004 const e = existing.runConfig ?? {};1005 const u = updates.runConfig;1006 if ('max_time_minutes' in u) {1007 if (u['max_time_minutes'] !== e['max_time_minutes'])1008 return false;1009 }1010 if ('max_turns' in u) {1011 if (u['max_turns'] !== e['max_turns'])1012 return false;1013 }1014 }1015 return true;1016}1017function shallowArrayEqual(a, b) {1018 if (a === b)1019 return true;1020 if (!a || !b)1021 return false;1022 if (a.length !== b.length)1023 return false;1024 for (let i = 0; i < a.length; i++)1025 if (a[i] !== b[i])1026 return false;1027 return true;1028}1029/**1030 * Sanitize `runConfig` to only the documented fields. Without this1031 * filter `SubagentManager.serializeSubagent` writes whatever object the1032 * client sent into the agent's frontmatter, including unknown or1033 * YAML-sensitive keys that downstream parsers may choke on. Returning1034 * a fresh whitelist-shaped object also makes the wire contract1035 * self-documenting at the route boundary.1036 *1037 * - `undefined` is impossible here (caller checks `'runConfig' in body`).1038 * - `null` (sent) → 422 invalid_config (the route handler converts1039 * the null sentinel to a short-circuit).1040 * - Right-shape object → returns a new object with only `max_time_minutes`1041 * and `max_turns` if they validate as finite positive numbers.1042 */1043function sanitizeRunConfig(raw, res) {1044 if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {1045 res.status(422).json({1046 error: '`runConfig` must be an object when provided',1047 code: 'invalid_config',1048 });1049 return null;1050 }1051 const input = raw;1052 const out = {};1053 if ('max_time_minutes' in input) {1054 const v = input['max_time_minutes'];1055 if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0) {1056 res.status(422).json({1057 error: '`runConfig.max_time_minutes` must be a positive finite number when provided',1058 code: 'invalid_config',1059 });1060 return null;1061 }1062 out['max_time_minutes'] = v;1063 }1064 if ('max_turns' in input) {1065 const v = input['max_turns'];1066 if (typeof v !== 'number' ||1067 !Number.isFinite(v) ||1068 v <= 0 ||1069 !Number.isInteger(v)) {1070 res.status(422).json({1071 error: '`runConfig.max_turns` must be a positive integer when provided',1072 code: 'invalid_config',1073 });1074 return null;1075 }1076 out['max_turns'] = v;1077 }1078 return out;1079}1080export function toSummary(config) {1081 const summary = {1082 kind: 'agent',1083 name: config.name,1084 description: config.description,1085 level: config.level,1086 isBuiltin: config.isBuiltin === true || config.level === 'builtin',1087 hasTools: Array.isArray(config.tools) && config.tools.length > 0,1088 };1089 if (config.model)1090 summary.model = config.model;1091 if (config.color)1092 summary.color = config.color;1093 if (config.background !== undefined)1094 summary.background = config.background;1095 if (config.approvalMode)1096 summary.approvalMode = config.approvalMode;1097 if (config.extensionName)1098 summary.extensionName = config.extensionName;1099 if (config.filePath)1100 summary.filePath = config.filePath;1101 return summary;1102}1103export function toDetail(config) {1104 const detail = {1105 ...toSummary(config),1106 systemPrompt: config.systemPrompt,1107 };1108 if (config.tools)1109 detail.tools = [...config.tools];1110 if (config.disallowedTools) {1111 detail.disallowedTools = [...config.disallowedTools];1112 }1113 if (config.runConfig) {1114 // Explicit field pick rather than spread-with-cast. If1115 // `SubagentConfig.runConfig` gains new fields in core, the1116 // spread-then-cast pattern would silently leak them through the1117 // HTTP response without a compile error. Picking `max_time_minutes`1118 // and `max_turns` by name forces a deliberate schema bump if a1119 // future core field needs to surface on the daemon route.1120 const runConfig = {};1121 if (typeof config.runConfig.max_time_minutes === 'number') {1122 runConfig.max_time_minutes = config.runConfig.max_time_minutes;1123 }1124 if (typeof config.runConfig.max_turns === 'number') {1125 runConfig.max_turns = config.runConfig.max_turns;1126 }1127 detail.runConfig = runConfig;1128 }1129 return detail;1130}1131/**1132 * Build a CRUD-scoped `SubagentManager` for the daemon. The1133 * underlying manager only touches four `Config` methods on its1134 * read/write paths (`getSdkMode`, `getProjectRoot`,1135 * `getActiveExtensions`, `isSafeMode`); a `Proxy` makes any future expansion of1136 * that surface throw immediately rather than silently produce1137 * incorrect data.1138 */1139export function createDaemonSubagentManager(boundWorkspace, safeMode = false) {1140 const stub = {1141 getSdkMode: () => false,1142 getProjectRoot: () => boundWorkspace,1143 getActiveExtensions: () => [],1144 isSafeMode: () => safeMode,1145 };1146 const guarded = new Proxy(stub, {1147 get(target, prop) {1148 if (prop in target) {1149 return target[prop];1150 }1151 // `then` is queried by Promise resolution machinery on object1152 // returns; returning undefined keeps async paths happy without1153 // implementing every Config method.1154 if (prop === 'then')1155 return undefined;1156 throw new Error(`qwen serve workspace agents: SubagentManager touched Config.` +1157 `${String(prop)} which the daemon stub does not implement. ` +1158 `Add it to createDaemonSubagentManager and audit safety.`);1159 },1160 // Mirror the `get` trap. Without a `has` trap, a SubagentManager1161 // path that does `if ('someMethod' in this.config)` would consult1162 // `Reflect.has(target, prop)` directly and silently return false1163 // for unimplemented methods — bypassing the throw the `get` trap1164 // is supposed to surface. With the trap, an `in` check on an1165 // unknown method throws the same way a property access would, so1166 // both code paths behave consistently.1167 has(target, prop) {1168 if (prop in target)1169 return true;1170 // Allow `'then' in obj` so the runtime's thenable-detection1171 // continues to behave correctly.1172 if (prop === 'then')1173 return false;1174 throw new Error(`qwen serve workspace agents: SubagentManager probed Config.` +1175 `${String(prop)} via 'in' check; the daemon stub does not ` +1176 `implement it. Add it to createDaemonSubagentManager and ` +1177 `audit safety.`);1178 },1179 });1180 return new SubagentManager(guarded);1181}1182// Re-export the bridge error type used by route helpers so test files1183// can import it from a single module without reaching into1184// acp-session-bridge directly.1185export { InvalidClientIdError };1186//# sourceMappingURL=workspace-agents.js.map