CoolFace
Datasetpublic

basant307/AI_Governance_Project

sourceHugging Faceapache-2.0updated 2mo agoView on Hugging Face
0likes48downloads
workspace-agents.js1186 linesDownload Raw Back to serve
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
basant307/AI_Governance_Project · CoolFace