diff --git a/docs/tui-capabilities.md b/docs/tui-capabilities.md index a2d1b6868..bdab30c8e 100644 --- a/docs/tui-capabilities.md +++ b/docs/tui-capabilities.md @@ -163,6 +163,28 @@ Offline regression tests cover cascade ownership, delivery suppression, late task creation, repeated stops, append completion/shutdown, and queue fallback. The host integration tests use a scripted runner and real background processes; they do not establish live-model or cross-platform acceptance. +## Agent spawn policy +Declarative agent-to-agent spawn rules are Kinetick Code behavior, carried by +a Custom Agent's canonical `agent.md` under `x-mavis`. They compose with — and +never replace — the runtime-owned built-in feature policy (`features.delegation`) +and the canonical role tool ceilings; those decide capability, this decides +permission between named Agents. + +- `x-mavis.spawnMode: subagent-only | master-only | both` (default `both`, + today's behavior). A `master-only` Agent rejects `task` spawns from a task + child; a `subagent-only` Agent rejects user-initiated Session creation + (TUI, CLI, and `POST /sessions`) and remains spawnable by other Agents. +- `x-mavis.canSpawn: [names]` restricts which parent Agents may spawn this + Agent through `task`. Entries are stable Agent names (case-insensitive). A + task-child spawn with an allowlist and an unresolvable parent identity is + denied (fail closed on an explicit restriction). +- An Agent with no `x-mavis` policy block behaves exactly as before: the gate + fails open on missing policy, missing port, and unreadable policy files. +- Built-in Agents (`mavis`, `explore`, `worker`, `verifier`) carry no Custom + file policy; their runtime-owned policy remains the only gate. +- `agent list` (native `mavis` tool) and the management `listAgents` response + expose `spawnMode`/`canSpawn` so clients can render the spawn graph. + ## Skill directory links Workspace `.agents/skills`, `.claude/skills`, and `.minimax/skills` support directory symlinks, both for the entire skill root and for individual skill diff --git a/packages/local-runtime-v2/src/application/agent/agent-application.ts b/packages/local-runtime-v2/src/application/agent/agent-application.ts index 95e2eec06..bd5bcf8bf 100644 --- a/packages/local-runtime-v2/src/application/agent/agent-application.ts +++ b/packages/local-runtime-v2/src/application/agent/agent-application.ts @@ -34,6 +34,9 @@ export interface AgentSessionPorts { readonly roots: RootAgentPort; readonly resolveWriteTarget: (requestRef: string) => Promise; readonly requireExactAgentKey: (requestRef: string) => Promise; + readonly getSpawnPolicy: ( + requestRef: string, + ) => Promise<{ readonly spawnMode?: "subagent-only" | "master-only" | "both" }>; } /** Existing Root application API; AgentApplication never reimplements root promotion. */ @@ -557,6 +560,7 @@ export function createAgentSessionPorts(service: LocalAgentService): AgentSessio roots, resolveWriteTarget: (requestRef) => service.resolveAgentWriteTarget(requestRef), requireExactAgentKey: (requestRef) => service.requireExactAgentKey(requestRef), + getSpawnPolicy: (requestRef) => service.getSpawnPolicy(requestRef), directory: { get: async (agentName) => { const view = await service.getPersistedOwner(agentName); diff --git a/packages/local-runtime-v2/src/application/initialize.ts b/packages/local-runtime-v2/src/application/initialize.ts index 6dc68e642..934fb55c7 100644 --- a/packages/local-runtime-v2/src/application/initialize.ts +++ b/packages/local-runtime-v2/src/application/initialize.ts @@ -44,6 +44,10 @@ export interface InitializeApplicationsOptions { readonly compatibility: V1SessionCompatibility; readonly attachmentRegistration: LocalAttachmentRegistrationPort; readonly resolveAgentWriteTarget: (requestRef: string) => Promise; + /** Declarative spawn policy read for the user-create subagent-only gate. */ + readonly getAgentSpawnPolicy?: ( + requestRef: string, + ) => Promise<{ readonly spawnMode?: "subagent-only" | "master-only" | "both" }>; readonly requireExactAgentKey: SessionRootApplicationOptions["requireExactAgentKey"]; readonly turn: SessionRootApplicationOptions["turn"] & { sessionDeletion( @@ -105,6 +109,9 @@ export const initializeApplications: InitializeApplications = (options) => { const createSideSession = options.conversationMutationWorkflow?.createSideSession; const lifecycle = new SessionLifecycleApplication({ + ...(options.getAgentSpawnPolicy + ? { getAgentSpawnPolicy: options.getAgentSpawnPolicy } + : {}), lifecycle: options.sessionSystem.session.lifecycle, ...(createSideSession ? { diff --git a/packages/local-runtime-v2/src/application/session/lifecycle-application.ts b/packages/local-runtime-v2/src/application/session/lifecycle-application.ts index 03a092159..fe32f0f47 100644 --- a/packages/local-runtime-v2/src/application/session/lifecycle-application.ts +++ b/packages/local-runtime-v2/src/application/session/lifecycle-application.ts @@ -66,6 +66,14 @@ export interface SessionLifecycleApplicationOptions }): Promise; }; readonly resolveAgentWriteTarget: (requestRef: string) => Promise; + /** + * Declarative spawn policy of one Agent, read at user-facing Session create + * to reject `subagent-only` targets. Absent = gate disabled (tests, hosts + * without an Agent service). + */ + readonly getAgentSpawnPolicy?: ( + requestRef: string, + ) => Promise<{ readonly spawnMode?: 'subagent-only' | 'master-only' | 'both' }> readonly runPluginHookSessionEndFence?: ( sessionId: string, operation: () => Promise, @@ -228,6 +236,7 @@ export class SessionLifecycleApplication { }); } const input = toCreateInput(req); + await this.assertUserCreatableAgent(req); return this.options.lifecycle.createSession({ ...input, ...whenDefined("isDefaultWorkspace", isDefaultWorkspace), @@ -245,6 +254,29 @@ export class SessionLifecycleApplication { ); } + /** + * A user-initiated Session (no parent) may not target a `subagent-only` + * Agent. Task children never reach this path (they create through the V1 + * agentRoutes); the task-spawn gate owns them. + */ + private async assertUserCreatableAgent(req: CreateSessionReq): Promise { + const getPolicy = this.options.getAgentSpawnPolicy; + if (!getPolicy || req.parentSessionId) return; + let policy; + try { + policy = await getPolicy(req.name); + } catch { + return; // fail open: unreadable policy keeps the Agent user-creatable + } + if (policy.spawnMode === "subagent-only") { + throw new AppError( + 403, + "AGENT_SPAWN_MODE_FORBIDDEN", + `Agent "${req.name}" is subagent-only and cannot start a user session.`, + ); + } + } + async updateSession( _context: ApplicationContext, req: UpdateSessionReq, diff --git a/packages/local-runtime-v2/src/local/agent-runtime-port.ts b/packages/local-runtime-v2/src/local/agent-runtime-port.ts index 13708d286..0717f9aea 100644 --- a/packages/local-runtime-v2/src/local/agent-runtime-port.ts +++ b/packages/local-runtime-v2/src/local/agent-runtime-port.ts @@ -44,8 +44,26 @@ export function createV2AgentRuntimeManagementPort(input: { const controller = createAgentManagementApplication(input.application); const context: ApplicationContext = {}; return { - listAgents: (request: ListAgentsReq): Promise => - controller.listAgents(context, request), + listAgents: async (request: ListAgentsReq): Promise => { + const resp = await controller.listAgents(context, request); + if (!resp.agents) return resp; + const agents = await Promise.all( + resp.agents.map(async (agent) => { + if (typeof agent.name !== "string") return agent; + try { + const policy = await input.service.getSpawnPolicy(agent.name); + return { + ...agent, + ...(policy.spawnMode ? { spawnMode: policy.spawnMode } : {}), + ...(policy.canSpawn ? { canSpawn: [...policy.canSpawn] } : {}), + }; + } catch { + return agent; + } + }), + ); + return { ...resp, agents }; + }, createAgent: (request: CreateAgentReq): Promise => controller.createAgent(context, request), getAgent: (request: GetAgentReq): Promise => @@ -78,6 +96,15 @@ export function createV2AgentRuntimeManagementPort(input: { (tools === undefined || tools.includes("bash")) ); }, + getAgentSpawnPolicy: async (agentName: string) => { + try { + return await input.service.getSpawnPolicy(agentName); + } catch { + // The spawn gate fails open: an unreadable policy must not make an + // Agent unspawnable, matching the port's no-policy = allow behavior. + return {}; + } + }, listLegacyPinnedAgentRefs: async () => [ ...(await input.application.listLegacyPinnedAgentRefs()), ], diff --git a/packages/local-runtime-v2/src/service/agent/application/agent.service.ts b/packages/local-runtime-v2/src/service/agent/application/agent.service.ts index 1c213c673..9ad74cdbc 100644 --- a/packages/local-runtime-v2/src/service/agent/application/agent.service.ts +++ b/packages/local-runtime-v2/src/service/agent/application/agent.service.ts @@ -88,6 +88,7 @@ import { toPrimaryFamilyMembers, type PrimaryExecutionIdentity, } from '../domain/primary-identity.js'; +import type { AgentMavisSpawnPolicy } from '../domain/spawn-policy.js'; import type { AgentConfigDocument, AgentConfigPutInput, @@ -271,6 +272,31 @@ export class LocalAgentService extends AgentConfigDocuments { return readCanonicalCustomAvatar(this.options.repository, meta.name); } + /** + * Declarative spawn policy for one Agent, read from its canonical file. + * Builtin Agents carry no Custom-file policy: the runtime-owned feature + * policy remains the only gate for them, so this returns no policy. + */ + async getSpawnPolicy(requestRef: string): Promise { + const scope = await this.resolveAgentReadScope(requestRef); + const meta = await this.requireMeta(scope.exactOwnerName); + if (this.isBuiltin(meta)) return {}; + try { + const canonical = await readCanonicalAgentConfig(this.options.repository, meta.name); + return canonical.xMavis ?? {}; + } catch (error) { + // A missing canonical file leaves the Agent runnable through its legacy + // profile surface; it must not become unspawnable. + if ( + error instanceof AgentServiceError && + error.code === 'AGENT_CONFIG_NOT_FOUND' + ) { + return {}; + } + throw error; + } + } + async create(input: AgentCreateInput): Promise { // The route-level name identifies the new owner. A complete definition may // carry a presentation name, but never chooses a different durable owner. diff --git a/packages/local-runtime-v2/src/service/agent/domain/spawn-policy.ts b/packages/local-runtime-v2/src/service/agent/domain/spawn-policy.ts new file mode 100644 index 000000000..9bb6eab04 --- /dev/null +++ b/packages/local-runtime-v2/src/service/agent/domain/spawn-policy.ts @@ -0,0 +1,137 @@ +import { resolveCanonicalSubagentRole } from '@mavis/agent-tools/desktop/subagent-roles'; + +/** + * The spawn-policy shape this module consumes. It is structurally identical + * to `CanonicalAgentMavisConfig`'s policy fields; declaring it locally keeps + * the V1 task runner host ports free of a storage-module import. + */ +export interface AgentMavisSpawnPolicy { + readonly spawnMode?: AgentSpawnMode; + readonly canSpawn?: readonly string[]; +} + +/** + * Declarative spawn policy carried by a Custom Agent's canonical file + * (`x-mavis.spawnMode` / `x-mavis.canSpawn`). It composes with — never + * replaces — the runtime-owned builtin feature policy: a Builtin Agent + * without an explicit policy keeps today's behavior exactly. + */ +export type AgentSpawnMode = 'subagent-only' | 'master-only' | 'both'; + +const SPAWN_MODES = new Set(['subagent-only', 'master-only', 'both']); + +/** Parses `x-mavis.spawnMode`; absent means 'both' (today's behavior). */ +export function parseAgentSpawnMode( + source: Record, + invalid: (field: string, message: string) => Error, +): AgentSpawnMode | undefined { + const value = source['spawnMode']; + if (value === undefined) return undefined; + if (typeof value !== 'string' || !SPAWN_MODES.has(value as AgentSpawnMode)) { + throw invalid( + 'x-mavis.spawnMode', + 'spawnMode must be one of subagent-only, master-only, both.', + ); + } + return value as AgentSpawnMode; +} + +/** Normalizes `x-mavis.canSpawn` to a trimmed, deduplicated, frozen list. */ +export function normalizeCanSpawn( + values: readonly string[] | undefined, +): readonly string[] | undefined { + if (values === undefined) return undefined; + const seen = new Set(); + for (const value of values) { + const normalized = value.trim().toLowerCase(); + if (normalized.length > 0) seen.add(normalized); + } + return Object.freeze([...seen]); +} + +export interface AgentSpawnGateInput { + /** Resolved stable name of the target Agent (canonical custom owner name). */ + readonly targetAgentName: string; + readonly targetPolicy: AgentMavisSpawnPolicy; + /** Resolved stable name of the requesting parent Agent; absent for user-initiated. */ + readonly parentAgentName?: string; + /** True when the requesting session is a task child (spawned via `task`). */ + readonly parentIsTaskChild: boolean; +} + +export type AgentSpawnGateDecision = + | { readonly allowed: true } + | { + readonly allowed: false; + readonly code: 'AGENT_SPAWN_MODE_FORBIDDEN' | 'AGENT_SPAWN_NOT_PERMITTED'; + readonly reason: string; + }; + +function targetMatchesEntries( + entries: readonly string[], + targetAgentName: string, +): boolean { + const normalized = targetAgentName.trim().toLowerCase(); + if (entries.includes(normalized)) return true; + // A canSpawn entry naming a canonical role selects the trusted builtin + // target; custom rows must be named by their stable name. + const role = resolveCanonicalSubagentRole(normalized); + return role !== undefined && entries.includes(role) && normalized === role; +} + +/** + * Single enforcement point for the declarative agent-to-agent spawn graph. + * Callers run this after name resolution, before creating the child Session. + * An absent policy (`undefined`) allows everything, so every agent without + * an explicit `x-mavis` block behaves exactly as before this feature. + */ +export function evaluateAgentSpawnGate( + input: AgentSpawnGateInput, +): AgentSpawnGateDecision { + const mode = input.targetPolicy.spawnMode ?? 'both'; + const parent = input.parentAgentName?.trim(); + + if (input.parentAgentName !== undefined && input.parentIsTaskChild) { + // A master-only Agent exists for user-initiated sessions only. + if (mode === 'master-only') { + return { + allowed: false, + code: 'AGENT_SPAWN_MODE_FORBIDDEN', + reason: `Agent "${input.targetAgentName}" is master-only and cannot be spawned by a subagent.`, + }; + } + const canSpawn = input.targetPolicy.canSpawn; + if (canSpawn !== undefined && parent && !targetMatchesEntries(canSpawn, parent)) { + return { + allowed: false, + code: 'AGENT_SPAWN_NOT_PERMITTED', + reason: `Agent "${input.targetAgentName}" only accepts spawns from: ${canSpawn.join(', ')}.`, + }; + } + } + + if (input.parentAgentName === undefined && mode === 'subagent-only') { + // A subagent-only Agent is not a user-facing session target. + return { + allowed: false, + code: 'AGENT_SPAWN_MODE_FORBIDDEN', + reason: `Agent "${input.targetAgentName}" is subagent-only and cannot start a user session.`, + }; + } + + return { allowed: true }; +} + +/** Convenience overload for callers holding only a full canonical config. */ +export function evaluateAgentSpawnGateForConfig( + input: Omit & { + readonly targetConfig: { readonly xMavis?: AgentMavisSpawnPolicy }; + }, +): AgentSpawnGateDecision { + return evaluateAgentSpawnGate({ + targetAgentName: input.targetAgentName, + targetPolicy: input.targetConfig.xMavis ?? {}, + parentAgentName: input.parentAgentName, + parentIsTaskChild: input.parentIsTaskChild, + }); +} diff --git a/packages/local-runtime-v2/src/service/agent/public.ts b/packages/local-runtime-v2/src/service/agent/public.ts index f9a1f77a3..787211bac 100644 --- a/packages/local-runtime-v2/src/service/agent/public.ts +++ b/packages/local-runtime-v2/src/service/agent/public.ts @@ -1,5 +1,15 @@ export * from './contracts.js'; export * from './errors.js'; +export { + evaluateAgentSpawnGate, + evaluateAgentSpawnGateForConfig, + normalizeCanSpawn, + parseAgentSpawnMode, + type AgentMavisSpawnPolicy, + type AgentSpawnGateDecision, + type AgentSpawnGateInput, + type AgentSpawnMode, +} from './domain/spawn-policy.js'; export { describeAgentPromptSnapshot } from './domain/prompt-snapshot.js'; export { LocalAgentService, type LocalAgentServiceOptions } from './application/agent.service.js'; export { AgentFavorites } from './application/favorites.js'; diff --git a/packages/local-runtime-v2/src/service/agent/storage/canonical-agent-config.ts b/packages/local-runtime-v2/src/service/agent/storage/canonical-agent-config.ts index 9d39db5e2..7be7cba41 100644 --- a/packages/local-runtime-v2/src/service/agent/storage/canonical-agent-config.ts +++ b/packages/local-runtime-v2/src/service/agent/storage/canonical-agent-config.ts @@ -6,6 +6,9 @@ import { TextDecoder } from 'node:util'; import { isDefaultAgentAvatarMarker } from '@mavis/shared/agent-avatar'; import yaml from 'yaml'; +import type { AgentSpawnMode } from '../domain/spawn-policy.js'; +import { normalizeCanSpawn, parseAgentSpawnMode } from '../domain/spawn-policy.js'; + import { logger } from '../../../infra/logging/index.js'; /** @@ -54,6 +57,10 @@ export interface CanonicalAgentMavisConfig { readonly maxOutputTokens?: number; readonly defaultWorkspaceDir?: string; readonly extensionSkills?: readonly string[]; + /** Declarative spawn policy; absent keeps today's behavior ('both', no allowlist). */ + readonly spawnMode?: AgentSpawnMode; + /** Stable names (or canonical roles) allowed to spawn this Agent. Absent = no allowlist. */ + readonly canSpawn?: readonly string[]; } interface AgentConfigDiagnostic { @@ -119,6 +126,8 @@ const KNOWN_MAVIS_FIELDS = new Set([ 'maxOutputTokens', 'defaultWorkspaceDir', 'extensionSkills', + 'spawnMode', + 'canSpawn', ]); const LEGACY_DESCRIPTION_NON_PLAIN_PREFIXES = '"\'[]{}&,*!|>@`#'; const loggedAgentDirectoryLinks = new Set(); @@ -946,6 +955,8 @@ function mavisFields( const contextWindow = optionalPositiveInteger(source, 'contextWindow'); const maxOutputTokens = optionalPositiveInteger(source, 'maxOutputTokens'); const extensionSkills = optionalTextArray(source, 'extensionSkills'); + const spawnMode = parseAgentSpawnMode(source, invalid); + const canSpawn = normalizeCanSpawn(optionalTextArray(source, 'canSpawn')); return { ...(displayName ? { displayName } : {}), ...(avatar ? { avatar } : {}), @@ -953,6 +964,8 @@ function mavisFields( ...(maxOutputTokens === undefined ? {} : { maxOutputTokens }), ...(defaultWorkspaceDir ? { defaultWorkspaceDir } : {}), ...(extensionSkills === undefined ? {} : { extensionSkills }), + ...(spawnMode === undefined ? {} : { spawnMode }), + ...(canSpawn === undefined ? {} : { canSpawn }), }; } @@ -967,6 +980,8 @@ function serializeMavis( if (value.maxOutputTokens !== undefined) result.maxOutputTokens = value.maxOutputTokens; if (value.defaultWorkspaceDir) result.defaultWorkspaceDir = value.defaultWorkspaceDir; if (value.extensionSkills !== undefined) result.extensionSkills = [...value.extensionSkills]; + if (value.spawnMode !== undefined) result.spawnMode = value.spawnMode; + if (value.canSpawn !== undefined) result.canSpawn = [...value.canSpawn]; return Object.keys(result).length === 0 ? undefined : result; } diff --git a/packages/local-runtime-v2/src/services.ts b/packages/local-runtime-v2/src/services.ts index aaafdfc90..29dabccbf 100644 --- a/packages/local-runtime-v2/src/services.ts +++ b/packages/local-runtime-v2/src/services.ts @@ -1233,6 +1233,7 @@ async function initializeSessionApplications( compatibility: input.options.compatibility.sessionV2, attachmentRegistration: input.options.compatibility.attachmentRegistration, resolveAgentWriteTarget: input.agentSessionPorts.resolveWriteTarget, + getAgentSpawnPolicy: input.agentSessionPorts.getSpawnPolicy, requireExactAgentKey: input.agentSessionPorts.requireExactAgentKey, ...createRuntimePluginSessionLifecycle(turnSystem, plugin), turn: turnSystem.turns, diff --git a/packages/local-runtime-v2/test/unit/agent/domain/spawn-policy.test.ts b/packages/local-runtime-v2/test/unit/agent/domain/spawn-policy.test.ts new file mode 100644 index 000000000..f5d9c84da --- /dev/null +++ b/packages/local-runtime-v2/test/unit/agent/domain/spawn-policy.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; + +import { + evaluateAgentSpawnGate, + normalizeCanSpawn, + parseAgentSpawnMode, +} from "../../../../src/service/agent/domain/spawn-policy.js"; +import { AgentConfigError } from "../../../../src/service/agent/storage/canonical-agent-config.js"; + +const invalid = (field: string, message: string): Error => + new AgentConfigError("AGENT_CONFIG_INVALID", field, message); + +describe("x-mavis spawn policy parsing", () => { + it("treats an absent spawnMode as no policy", () => { + expect(parseAgentSpawnMode({}, invalid)).toBeUndefined(); + }); + + it("accepts the three documented modes", () => { + for (const spawnMode of ["subagent-only", "master-only", "both"] as const) { + expect(parseAgentSpawnMode({ spawnMode }, invalid)).toBe(spawnMode); + } + }); + + it("rejects an unknown mode naming the accepted values", () => { + expect(() => parseAgentSpawnMode({ spawnMode: "solo" }, invalid)).toThrow( + /subagent-only, master-only, both/, + ); + }); + + it("normalizes canSpawn entries to trimmed, deduplicated, frozen names", () => { + const canSpawn = normalizeCanSpawn([" Orchestrator ", "orchestrator", "analyst"]); + expect(canSpawn).toEqual(["orchestrator", "analyst"]); + expect(Object.isFrozen(canSpawn)).toBe(true); + }); +}); + +describe("canonical spawn gate (agent.md level)", () => { + it("rejects a master-only spawn from a task-child parent", () => { + const decision = evaluateAgentSpawnGate({ + targetAgentName: "planner", + targetPolicy: { spawnMode: "master-only" }, + parentAgentName: "orchestrator", + parentIsTaskChild: true, + }); + expect(decision.allowed).toBe(false); + if (!decision.allowed) expect(decision.code).toBe("AGENT_SPAWN_MODE_FORBIDDEN"); + }); + + it("rejects a user session on a subagent-only agent", () => { + const decision = evaluateAgentSpawnGate({ + targetAgentName: "deep-lab", + targetPolicy: { spawnMode: "subagent-only" }, + parentIsTaskChild: false, + }); + expect(decision.allowed).toBe(false); + if (!decision.allowed) expect(decision.code).toBe("AGENT_SPAWN_MODE_FORBIDDEN"); + }); + + it("allows an allowlisted parent and denies the rest", () => { + const base = { + targetAgentName: "builder", + targetPolicy: { canSpawn: ["orchestrator"] }, + parentIsTaskChild: true as const, + }; + expect(evaluateAgentSpawnGate({ ...base, parentAgentName: "orchestrator" })).toEqual({ + allowed: true, + }); + const denied = evaluateAgentSpawnGate({ ...base, parentAgentName: "raider" }); + expect(denied.allowed).toBe(false); + if (!denied.allowed) expect(denied.code).toBe("AGENT_SPAWN_NOT_PERMITTED"); + }); +}); diff --git a/packages/local-runtime-v2/test/unit/agent/storage/canonical-agent-config.test.ts b/packages/local-runtime-v2/test/unit/agent/storage/canonical-agent-config.test.ts index fed9b0def..f18add723 100644 --- a/packages/local-runtime-v2/test/unit/agent/storage/canonical-agent-config.test.ts +++ b/packages/local-runtime-v2/test/unit/agent/storage/canonical-agent-config.test.ts @@ -252,6 +252,16 @@ function registerCanonicalConfigParsingTests(): void { "---\nname: researcher\ndescription: desc\nx-mavis:\n extensionSkills: research\n---\n", "extensionSkills", ], + [ + "invalid spawn mode", + "---\nname: researcher\ndescription: desc\nx-mavis:\n spawnMode: solo\n---\n", + "x-mavis.spawnMode", + ], + [ + "invalid canSpawn list", + "---\nname: researcher\ndescription: desc\nx-mavis:\n canSpawn: orchestrator\n---\n", + "canSpawn", + ], [ "relative workspace", "---\nname: researcher\ndescription: desc\nx-mavis:\n defaultWorkspaceDir: relative/workspace\n---\n", diff --git a/packages/local-runtime/src/agent/runtime-port.ts b/packages/local-runtime/src/agent/runtime-port.ts index baadb5880..96e404dea 100644 --- a/packages/local-runtime/src/agent/runtime-port.ts +++ b/packages/local-runtime/src/agent/runtime-port.ts @@ -82,7 +82,21 @@ export interface LocalAgentRuntimePort extends AgentReferenceResolver { ): Promise; } -export interface LocalAgentRuntimeManagementPort { +export interface LocalAgentMavisSpawnPolicy { + readonly spawnMode?: "subagent-only" | "master-only" | "both"; + readonly canSpawn?: readonly string[]; +} + +export interface LocalAgentSpawnPolicyPort { + /** + * Declarative spawn policy (`x-mavis.spawnMode` / `x-mavis.canSpawn`) for + * the task-spawn gate. A port without a bound management plane reports no + * policy, which allows every spawn (today's behavior). + */ + getAgentSpawnPolicy(agentName: string): Promise; +} + +export interface LocalAgentRuntimeManagementPort extends LocalAgentSpawnPolicyPort { listAgents: LocalAgentRuntimePort["listAgents"]; createAgent: LocalAgentRuntimePort["createAgent"]; getAgent: LocalAgentRuntimePort["getAgent"]; @@ -92,6 +106,7 @@ export interface LocalAgentRuntimeManagementPort { setMainSession: LocalAgentRuntimePort["setMainSession"]; agentExists: LocalAgentRuntimePort["agentExists"]; canDelegate: LocalAgentRuntimePort["canDelegate"]; + getAgentSpawnPolicy: LocalAgentSpawnPolicyPort["getAgentSpawnPolicy"]; listLegacyPinnedAgentRefs: LocalAgentRuntimePort["listLegacyPinnedAgentRefs"]; ensureBuiltinAgents: LocalAgentRuntimePort["ensureBuiltinAgents"]; retryBuiltinGreetings: LocalAgentRuntimePort["retryBuiltinGreetings"]; @@ -100,7 +115,9 @@ export interface LocalAgentRuntimeManagementPort { getSessionProjectIdentity: LocalAgentRuntimePort["getSessionProjectIdentity"]; } -export interface DeferredLocalAgentRuntimePort extends LocalAgentRuntimePort { +export interface DeferredLocalAgentRuntimePort + extends LocalAgentRuntimePort, + LocalAgentSpawnPolicyPort { bindResolver(resolver: AgentReferenceResolver): void; bindManagement(management: LocalAgentRuntimeManagementPort): void; } @@ -116,7 +133,7 @@ export class LocalAgentRuntimeUnavailableError extends Error { } /** A direct-usage V1 host gets a deterministic 503 instead of a hidden writer. */ -export function createFailClosedAgentRuntimePort(): LocalAgentRuntimePort { +export function createFailClosedAgentRuntimePort(): LocalAgentRuntimePort & LocalAgentSpawnPolicyPort { const unavailable = (operation: string): LocalAgentRuntimeUnavailableError => new LocalAgentRuntimeUnavailableError(operation); return { @@ -159,6 +176,7 @@ export function createFailClosedAgentRuntimePort(): LocalAgentRuntimePort { canDelegate: async () => { throw unavailable("canDelegate"); }, + getAgentSpawnPolicy: async () => ({}), listLegacyPinnedAgentRefs: async () => { throw unavailable("listLegacyPinnedAgentRefs"); }, @@ -188,6 +206,8 @@ export function createFailClosedAgentRuntimePort(): LocalAgentRuntimePort { export function createDeferredLocalAgentRuntimePort( resolver?: AgentReferenceResolver, ): DeferredLocalAgentRuntimePort { + // Management methods delegate to the bound management plane; the + // spawn-policy read is one of them (see getAgentSpawnPolicy below). const failClosed = createFailClosedAgentRuntimePort(); let currentResolver = resolver; let currentManagement: LocalAgentRuntimeManagementPort | undefined; @@ -215,6 +235,7 @@ export function createDeferredLocalAgentRuntimePort( management().setMainSession(agentName, sessionId), agentExists: (agentName) => management().agentExists(agentName), canDelegate: (agentName) => management().canDelegate(agentName), + getAgentSpawnPolicy: (agentName) => management().getAgentSpawnPolicy(agentName), listLegacyPinnedAgentRefs: () => management().listLegacyPinnedAgentRefs(), ensureBuiltinAgents: () => management().ensureBuiltinAgents(), retryBuiltinGreetings: () => management().retryBuiltinGreetings(), diff --git a/packages/local-runtime/src/agent/spawn-gate.ts b/packages/local-runtime/src/agent/spawn-gate.ts new file mode 100644 index 000000000..5274e5aad --- /dev/null +++ b/packages/local-runtime/src/agent/spawn-gate.ts @@ -0,0 +1,108 @@ +import type { AgentReferenceReadScope } from '@mavis/shared'; +import type { LocalTaskRunnerHostWithSessionLookup } from '../api/local-task-host.js'; +import type { LocalSessionRecord } from '../sessions/controller.js'; +import { resolveCanonicalSubagentRole } from '@mavis/agent-tools/desktop/subagent-roles'; + +/** + * Declarative spawn-policy gate evaluated after name resolution and before + * the child Session is created. It composes with the runtime-owned builtin + * tool ceilings (canonical-tool-policy) and the delegation feature gate: + * those decide *capability*, this decides *permission between named Agents*. + * + * The gate fails open on every resolution gap (no port, unresolvable parent, + * unreadable policy, builtin target): an Agent that declares no policy must + * behave exactly as before the feature. + */ +export interface AgentMavisSpawnPolicy { + spawnMode?: 'subagent-only' | 'master-only' | 'both'; + canSpawn?: readonly string[]; +} + +export interface AgentSpawnPolicyReader { + getAgentSpawnPolicy(agentName: string): Promise; +} + +export interface LocalSpawnGateFacts { + /** Stable name of the parent Agent, resolved from the parent Session. */ + parentAgentName?: string; + /** True when the parent Session is itself a task child. */ + parentIsTaskChild: boolean; +} + +export interface LocalSpawnGateDecision { + allowed: boolean; + reason?: string; +} + +function normalizeEntries(entries: readonly string[]): readonly string[] { + return entries.map((entry) => entry.trim().toLowerCase()).filter((entry) => entry.length > 0); +} + +function targetMatchesEntries(entries: readonly string[], target: string): boolean { + const normalized = target.trim().toLowerCase(); + if (entries.includes(normalized)) return true; + // An entry naming a canonical role selects the trusted builtin target only. + const role = resolveCanonicalSubagentRole(normalized); + return role !== undefined && entries.includes(role) && normalized === role; +} + +export function evaluateLocalAgentSpawnGate(input: { + targetAgentName: string; + targetPolicy: AgentMavisSpawnPolicy; + parent: LocalSpawnGateFacts; +}): LocalSpawnGateDecision { + const mode = input.targetPolicy.spawnMode ?? 'both'; + if (!input.parent.parentIsTaskChild) { + // User-initiated spawn: master-only and both are unrestricted here; + // subagent-only Agents stay user-facing at Session-create level. + return { allowed: true }; + } + if (mode === 'master-only') { + return { + allowed: false, + reason: `Agent "${input.targetAgentName}" is master-only and cannot be spawned by a subagent.`, + }; + } + const canSpawn = input.targetPolicy.canSpawn; + const parent = input.parent.parentAgentName?.trim() || undefined; + // Fail closed: a canSpawn allowlist is an explicit restriction, so a parent + // whose stable name cannot be resolved may not spawn the target. + if (canSpawn !== undefined && !parent) { + return { + allowed: false, + reason: `Agent "${input.targetAgentName}" restricts spawns to named Agents and the parent Agent identity is unavailable.`, + }; + } + if (canSpawn !== undefined && parent && !targetMatchesEntries(normalizeEntries(canSpawn), parent)) { + return { + allowed: false, + reason: `Agent "${input.targetAgentName}" only accepts spawns from: ${canSpawn.join(', ')}.`, + }; + } + return { allowed: true }; +} + +/** Resolves the parent facts best-effort; a missing lookup leaves parentAgentName undefined. */ +export async function resolveLocalSpawnGateFacts( + host: Pick & { + agentResolver?: { + resolveAgentReadScope(name: string): Promise>; + }; + }, + parentSession: LocalSessionRecord, +): Promise { + const parentIsTaskChild = parentSession.sessionKind === 'task'; + if (!parentIsTaskChild) return { parentIsTaskChild: false }; + let parentAgentName: string | undefined = parentSession.agentName?.trim() || undefined; + // The session row carries the requested agent name; resolve it to the + // stable owner so a canSpawn entry matches on canonical identity. + if (parentAgentName && host.agentResolver?.resolveAgentReadScope) { + try { + const scope = await host.agentResolver.resolveAgentReadScope(parentAgentName); + parentAgentName = scope.exactOwnerName?.trim() || parentAgentName; + } catch { + // Keep the raw name: matching is best-effort and fails open upstream. + } + } + return { parentIsTaskChild: true, ...(parentAgentName ? { parentAgentName } : {}) }; +} diff --git a/packages/local-runtime/src/api/host-helpers.ts b/packages/local-runtime/src/api/host-helpers.ts index cfded6d35..93972c014 100644 --- a/packages/local-runtime/src/api/host-helpers.ts +++ b/packages/local-runtime/src/api/host-helpers.ts @@ -20,7 +20,10 @@ import type { CronStorePort } from "@mavis/cron"; import type { PermissionRuleMatcher } from "@mavis/permission"; import type { GlobalEventPayloadMap } from "@mavis/shared/global-events"; import type { AgentReferenceResolver } from "../agent/port.js"; -import type { LocalAgentRuntimePort } from "../agent/runtime-port.js"; +import type { + LocalAgentRuntimePort, + LocalAgentSpawnPolicyPort, +} from "../agent/runtime-port.js"; import type { LocalBrowserBroker } from "../browser/api.js"; import type { LocalAccessControlStore } from "../channels/access-control-store.js"; import type { LocalFeishuChannelStore } from "../channels/feishu.js"; @@ -132,6 +135,8 @@ export interface LocalRuntimeApiHostOptions { agentResolver?: AgentReferenceResolver; /** Owner-injected Agent runtime port for legacy HTTP/Desktop consumers. */ agentRuntimePort?: LocalAgentRuntimePort; + /** Owner-injected spawn-policy source for the declarative task-spawn gate. */ + agentSpawnPolicyPort?: LocalAgentSpawnPolicyPort; /** Read-only prompt source bound by the V2 owner after prompt-config starts. */ promptSnapshots?: PromptSnapshotSource; modelResolver?: LocalModelResolverLike; diff --git a/packages/local-runtime/src/api/host.ts b/packages/local-runtime/src/api/host.ts index 779af4adc..7585f13f0 100644 --- a/packages/local-runtime/src/api/host.ts +++ b/packages/local-runtime/src/api/host.ts @@ -22,6 +22,7 @@ import { createFailClosedAgentRuntimePort, createLocalMavisAgentAdapter, type LocalAgentRuntimePort, + type LocalAgentSpawnPolicyPort, } from "../agent/runtime-port.js"; import { stopBackgroundLocalBashTask } from "../background-task/bash-runner.js"; import { closeLocalBackgroundTaskDelivery } from "../background-task/delivery.js"; @@ -376,6 +377,8 @@ export class LocalRuntimeApiHost { } /** Neutral reference port supplied by the owning runtime (V2 in production). */ public readonly agentResolver: AgentReferenceResolver; + /** Declarative spawn-policy source for the task-spawn gate. */ + public readonly agentSpawnPolicyPort: LocalAgentSpawnPolicyPort; public readonly memoryFacade: LocalMemoryFacade; public readonly skillService: LocalSkillService; public readonly localDataCollector: LocalDataCollector; @@ -478,6 +481,11 @@ export class LocalRuntimeApiHost { options.agentRuntimePort ?? createFailClosedAgentRuntimePort(); this.promptSnapshots = options.promptSnapshots; this.agentResolver = options.agentResolver ?? this.agentRuntimePort; + this.agentSpawnPolicyPort = + options.agentSpawnPolicyPort ?? + ("getAgentSpawnPolicy" in this.agentRuntimePort + ? (this.agentRuntimePort as LocalAgentSpawnPolicyPort) + : { getAgentSpawnPolicy: async () => ({}) }); this.cuScreenshotPruner = createCuScreenshotPrunerHook( 2, this.metricsClient, diff --git a/packages/local-runtime/src/api/local-task-host.ts b/packages/local-runtime/src/api/local-task-host.ts index 1e7e3aea9..e41e4fbf1 100644 --- a/packages/local-runtime/src/api/local-task-host.ts +++ b/packages/local-runtime/src/api/local-task-host.ts @@ -5,6 +5,7 @@ import type { import type { MetricsClient } from '../common/metrics.js'; import type { LocalBashCompletion } from '../runtime/bash-completion-correlation.js'; import type { AgentReferenceResolver } from '../agent/port.js'; +import type { LocalAgentSpawnPolicyPort } from '../agent/runtime-port.js'; import type { SubagentTelemetryHost } from '../agent/subagent-telemetry.js'; import type { LocalBackgroundTaskService } from '../background-task/service.js'; import type { LocalRuntimeConfig } from '../config/types.js'; @@ -14,6 +15,8 @@ import type { LocalRuntimeApiHostOptions } from './host-helpers.js'; /** Product capabilities used by Task and background Bash adapters. */ export interface LocalTaskRunnerHost extends SubagentTelemetryHost { readonly agentResolver: AgentReferenceResolver; + /** Declarative spawn-policy source for the task-spawn gate (may be absent). */ + readonly agentSpawnPolicyPort?: LocalAgentSpawnPolicyPort; readonly runtimeConversation?: RuntimeConversation; readonly agentRoutes: { createSession(input: { diff --git a/packages/local-runtime/src/api/local-task-runner.ts b/packages/local-runtime/src/api/local-task-runner.ts index 83a7a389a..63ec8cc12 100644 --- a/packages/local-runtime/src/api/local-task-runner.ts +++ b/packages/local-runtime/src/api/local-task-runner.ts @@ -18,12 +18,46 @@ import { requireTaskConversation } from './local-task-host.js'; import { normalizeLocalTaskInput, taskModelSelectionFor } from './local-task-input.js'; import { resolveLocalTaskAgentTarget } from './local-task-subagents.js'; import { resolveV2SessionArtifactPathsSync } from '../persistence/layout/v2-session-artifacts.js'; +import { + evaluateLocalAgentSpawnGate, + resolveLocalSpawnGateFacts, + type LocalSpawnGateDecision, +} from '../agent/spawn-gate.js'; export type { LocalTaskRunnerHost, LocalTaskRunnerHostWithSessionLookup, } from './local-task-host.js'; +/** + * Declarative spawn gate shared by the foreground and background runners: + * evaluated after name resolution, before any task row exists, so a denied + * spawn reads exactly like an unknown agent (no task handle, clear message). + */ +export async function evaluateLocalTaskSpawnGate(input: { + host: LocalTaskRunnerHostWithSessionLookup; + parentSession: LocalSessionRecord; + targetAgentName: string; +}): Promise { + const port = input.host.agentSpawnPolicyPort; + if (!port) return { allowed: true }; + let targetPolicy; + try { + targetPolicy = await port.getAgentSpawnPolicy(input.targetAgentName); + } catch { + return { allowed: true }; + } + if (targetPolicy.spawnMode === undefined && targetPolicy.canSpawn === undefined) { + return { allowed: true }; + } + const parent = await resolveLocalSpawnGateFacts(input.host, input.parentSession); + return evaluateLocalAgentSpawnGate({ + targetAgentName: input.targetAgentName, + targetPolicy, + parent, + }); +} + export function buildLocalTaskAdapter( host: LocalTaskRunnerHostWithSessionLookup, parentSession: LocalSessionRecord, @@ -60,6 +94,19 @@ export async function runForegroundLocalTask(input: { errorMessage: `Unknown agent: ${taskInput.agent_name}`, }; } + const gate = await evaluateLocalTaskSpawnGate({ + host: input.host, + parentSession: input.parentSession, + targetAgentName: target.resolvedAgentName, + }); + if (!gate.allowed) { + return { + status: 'failed', + requestedAgentName: target.requestedName, + resolvedAgentName: target.resolvedAgentName, + errorMessage: gate.reason ?? 'Spawn denied by the target Agent policy.', + }; + } const agentName = target.resolvedAgentName; const taskModelSelection = taskModelSelectionFor(taskInput); diff --git a/packages/local-runtime/src/background-task/runner.ts b/packages/local-runtime/src/background-task/runner.ts index 4fb6c4f1b..bb8d2d531 100644 --- a/packages/local-runtime/src/background-task/runner.ts +++ b/packages/local-runtime/src/background-task/runner.ts @@ -21,6 +21,7 @@ import { } from '../api/local-task-host.js'; import { normalizeLocalTaskInput, taskModelSelectionFor } from '../api/local-task-input.js'; import { resolveLocalTaskAgentTarget } from '../api/local-task-subagents.js'; +import { evaluateLocalTaskSpawnGate } from '../api/local-task-runner.js'; import { admitBackgroundTask, drainBackgroundTasks } from './lifecycle.js'; export async function startBackgroundLocalTask(input: { @@ -53,6 +54,17 @@ async function startAdmittedBackgroundLocalTask( errorMessage: `Unknown agent: ${taskInput.agent_name}`, }; } + const gate = await evaluateLocalTaskSpawnGate({ + host: input.host, + parentSession: input.parentSession, + targetAgentName: target.resolvedAgentName, + }); + if (!gate.allowed) { + return { + status: 'failed', + errorMessage: gate.reason ?? 'Spawn denied by the target Agent policy.', + }; + } const agentName = target.resolvedAgentName; const taskModelSelection = taskModelSelectionFor(taskInput); diff --git a/packages/local-runtime/src/index.ts b/packages/local-runtime/src/index.ts index 106c2ae84..c33a88a60 100644 --- a/packages/local-runtime/src/index.ts +++ b/packages/local-runtime/src/index.ts @@ -17,6 +17,13 @@ export { } from "./persistence/sqlite-persistence.js"; export { closeAgentDb } from "./agent/db.js"; export { resolveTaskTarget } from "./agent/port.js"; +export { + evaluateLocalAgentSpawnGate, + resolveLocalSpawnGateFacts, + type AgentMavisSpawnPolicy, + type LocalSpawnGateDecision, + type LocalSpawnGateFacts, +} from "./agent/spawn-gate.js"; export { withAgentNameConflictMigrationLock } from "./persistence/migration/agent-name-conflict-migration.js"; export { createDeferredLocalAgentRuntimePort, diff --git a/packages/local-runtime/test/unit/agent-spawn-gate.test.ts b/packages/local-runtime/test/unit/agent-spawn-gate.test.ts new file mode 100644 index 000000000..d4cc3ab7d --- /dev/null +++ b/packages/local-runtime/test/unit/agent-spawn-gate.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; + +import { + evaluateLocalAgentSpawnGate, + type LocalSpawnGateFacts, +} from "../../src/agent/spawn-gate.js"; + +const USER_SPAWN: LocalSpawnGateFacts = { parentIsTaskChild: false }; +const TASK_CHILD_SPAWN: LocalSpawnGateFacts = { + parentIsTaskChild: true, + parentAgentName: "orchestrator", +}; + +describe("declarative agent spawn gate", () => { + it("allows every spawn for an agent without a declared policy", () => { + expect( + evaluateLocalAgentSpawnGate({ + targetAgentName: "worker", + targetPolicy: {}, + parent: TASK_CHILD_SPAWN, + }), + ).toEqual({ allowed: true }); + }); + + it("denies a master-only target spawned by a task child", () => { + const decision = evaluateLocalAgentSpawnGate({ + targetAgentName: "planner", + targetPolicy: { spawnMode: "master-only" }, + parent: TASK_CHILD_SPAWN, + }); + expect(decision.allowed).toBe(false); + expect(decision.reason).toContain("master-only"); + }); + + it("allows a master-only target spawned by a user session", () => { + expect( + evaluateLocalAgentSpawnGate({ + targetAgentName: "planner", + targetPolicy: { spawnMode: "master-only" }, + parent: USER_SPAWN, + }), + ).toEqual({ allowed: true }); + }); + + it("denies a spawn whose parent is not in the canSpawn allowlist", () => { + const decision = evaluateLocalAgentSpawnGate({ + targetAgentName: "builder", + targetPolicy: { canSpawn: ["mavis", "orchestrator-x"] }, + parent: TASK_CHILD_SPAWN, + }); + expect(decision.allowed).toBe(false); + expect(decision.reason).toContain("orchestrator-x"); + }); + + it("allows a spawn whose parent is named in the canSpawn allowlist", () => { + expect( + evaluateLocalAgentSpawnGate({ + targetAgentName: "builder", + targetPolicy: { canSpawn: ["Orchestrator"] }, + parent: TASK_CHILD_SPAWN, + }), + ).toEqual({ allowed: true }); + }); + + it("matches canSpawn entries against the parent, not the target", () => { + // canSpawn names who may spawn the target; the target's own name is + // never an entry match — a custom row named like a role must not inherit + // the trusted builtin's allowlist. + expect( + evaluateLocalAgentSpawnGate({ + targetAgentName: "worker-clone", + targetPolicy: { canSpawn: ["worker"] }, + parent: { parentIsTaskChild: true, parentAgentName: "worker" }, + }), + ).toEqual({ allowed: true }); + const denied = evaluateLocalAgentSpawnGate({ + targetAgentName: "worker-clone", + targetPolicy: { canSpawn: ["worker"] }, + parent: { parentIsTaskChild: true, parentAgentName: "worker-clone" }, + }); + expect(denied.allowed).toBe(false); + }); + + it("fails closed when a canSpawn allowlist exists and the parent is unknown", () => { + const decision = evaluateLocalAgentSpawnGate({ + targetAgentName: "builder", + targetPolicy: { canSpawn: ["mavis"] }, + parent: { parentIsTaskChild: true }, + }); + expect(decision.allowed).toBe(false); + expect(decision.reason).toContain("parent Agent identity"); + }); + + it("does not gate subagent-only targets at the task-spawn entry point", () => { + expect( + evaluateLocalAgentSpawnGate({ + targetAgentName: "deep-lab", + targetPolicy: { spawnMode: "subagent-only" }, + parent: TASK_CHILD_SPAWN, + }), + ).toEqual({ allowed: true }); + }); +}); diff --git a/packages/protocol/src/local.ts b/packages/protocol/src/local.ts index 3bfd68d03..466c52888 100644 --- a/packages/protocol/src/local.ts +++ b/packages/protocol/src/local.ts @@ -1396,6 +1396,9 @@ export interface AgentDetail { name?: string; displayName?: string; agentRole?: string; + /** Declarative spawn policy from the Agent's canonical file (`x-mavis`). */ + spawnMode?: 'subagent-only' | 'master-only' | 'both'; + canSpawn?: string[]; rootSessionId?: string; agentConfigDir?: string; createdAt?: number; diff --git a/release/public-source.json b/release/public-source.json index f63fb6490..b4867a083 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -817,6 +817,7 @@ "packages/local-runtime-v2/src/service/agent/domain/primary-identity.ts", "packages/local-runtime-v2/src/service/agent/domain/prompt-snapshot.ts", "packages/local-runtime-v2/src/service/agent/domain/roles.ts", + "packages/local-runtime-v2/src/service/agent/domain/spawn-policy.ts", "packages/local-runtime-v2/src/service/agent/domain/validation.ts", "packages/local-runtime-v2/src/service/agent/errors.ts", "packages/local-runtime-v2/src/service/agent/index.ts", @@ -1533,6 +1534,7 @@ "packages/local-runtime-v2/test/integration/session-system-queue-repository.integration.test.ts", "packages/local-runtime-v2/test/unit/agent/agent-import.test.ts", "packages/local-runtime-v2/test/unit/agent/builtin/catalog.test.ts", + "packages/local-runtime-v2/test/unit/agent/domain/spawn-policy.test.ts", "packages/local-runtime-v2/test/unit/agent/storage/agent.repository.test.ts", "packages/local-runtime-v2/test/unit/agent/storage/canonical-agent-config.test.ts", "packages/local-runtime-v2/test/unit/compat/v1/runtime.test.ts", @@ -2115,6 +2117,7 @@ "packages/local-runtime/src/agent/port.ts", "packages/local-runtime/src/agent/resource-instance.ts", "packages/local-runtime/src/agent/runtime-port.ts", + "packages/local-runtime/src/agent/spawn-gate.ts", "packages/local-runtime/src/agent/subagent-telemetry.ts", "packages/local-runtime/src/api/channel-context.ts", "packages/local-runtime/src/api/child-model-config.ts", @@ -2710,6 +2713,7 @@ "packages/local-runtime/src/website-management/deployed-website-source.ts", "packages/local-runtime/src/website-management/local-website-management-client.ts", "packages/local-runtime/src/worktrees/branch.ts", + "packages/local-runtime/test/unit/agent-spawn-gate.test.ts", "packages/local-runtime/test/unit/background-task-test-helpers.ts", "packages/local-runtime/test/unit/child-bash-lifecycle.test.ts", "packages/local-runtime/test/unit/content-safety-api-fail-policy.test.ts", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index 62695fbca..43991ae5f 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -231,6 +231,8 @@ "packages/local-runtime-v2/test/integration/session-stop-cascade.integration.test.ts", "packages/local-runtime/test/unit/local-background-task-user-stop-cascade.test.ts", "packages/local-runtime/test/unit/local-task-append.test.ts", + "packages/local-runtime/test/unit/agent-spawn-gate.test.ts", + "packages/local-runtime-v2/test/unit/agent/domain/spawn-policy.test.ts", "packages/tui/test/unit/tui/controller/runtime/runtime-event-flow-stale-run.test.ts", "packages/tui/test/unit/tui/transcript/panel-literal-user-text.test.ts", "packages/tui/test/unit/tui/transcript/presentation/literal-text.test.ts"