From c0c1694659e6fb7645da878cf18a8d0bebe0b37d Mon Sep 17 00:00:00 2001 From: chengluyu <2239547+chengluyu@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:22:57 +0800 Subject: [PATCH 1/2] fix(agent-core-v2): reject subagent goals --- .changeset/reject-subagent-goals.md | 5 ++ .../agent-core-v2/src/agent/goal/errors.ts | 7 ++ packages/agent-core-v2/src/agent/goal/goal.ts | 5 +- .../src/agent/goal/goalService.ts | 43 ++++++++-- .../test/agent/goal/goal.test.ts | 78 +++++++++++++++++++ .../test/agent/goal/goalOps.test.ts | 8 +- packages/kap-server/src/transport/errors.ts | 1 + .../kap-server/test/transport-errors.test.ts | 7 ++ .../protocol/src/__tests__/envelope.test.ts | 8 ++ packages/protocol/src/error-codes.ts | 3 + packages/protocol/src/events.ts | 2 + 11 files changed, 159 insertions(+), 8 deletions(-) create mode 100644 .changeset/reject-subagent-goals.md diff --git a/.changeset/reject-subagent-goals.md b/.changeset/reject-subagent-goals.md new file mode 100644 index 0000000000..68e846e92f --- /dev/null +++ b/.changeset/reject-subagent-goals.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Reject subagent goal requests consistently instead of starting goals they cannot finish. diff --git a/packages/agent-core-v2/src/agent/goal/errors.ts b/packages/agent-core-v2/src/agent/goal/errors.ts index 5f38075c99..95a91cbc75 100644 --- a/packages/agent-core-v2/src/agent/goal/errors.ts +++ b/packages/agent-core-v2/src/agent/goal/errors.ts @@ -13,6 +13,7 @@ export const GoalErrors = { GOAL_STATUS_INVALID: 'goal.status_invalid', GOAL_METADATA_RESERVED: 'goal.metadata_reserved', GOAL_NOT_RESUMABLE: 'goal.not_resumable', + GOAL_UNSUPPORTED_AGENT: 'goal.unsupported_agent', }, info: { 'goal.already_exists': { @@ -57,6 +58,12 @@ export const GoalErrors = { public: true, action: 'Only paused goals can be resumed.', }, + 'goal.unsupported_agent': { + title: 'Goals are unavailable for subagents', + retryable: false, + public: true, + action: 'Run goal lifecycle commands on the main agent.', + }, }, } as const satisfies ErrorDomain; diff --git a/packages/agent-core-v2/src/agent/goal/goal.ts b/packages/agent-core-v2/src/agent/goal/goal.ts index 0da70a91af..a6aee9bba5 100644 --- a/packages/agent-core-v2/src/agent/goal/goal.ts +++ b/packages/agent-core-v2/src/agent/goal/goal.ts @@ -1,8 +1,9 @@ /** - * `goal` domain (L4) — per-agent goal lifecycle contract. + * `goal` domain (L4) — main-agent goal lifecycle contract. * * Defines the commands and snapshots used to create, inspect, update, and clear - * the durable goal state. Bound at Agent scope. + * the durable goal state. Bound at Agent scope; subagent callers are rejected + * with `goal.unsupported_agent`. */ import { createDecorator } from "#/_base/di/instantiation"; import type { diff --git a/packages/agent-core-v2/src/agent/goal/goalService.ts b/packages/agent-core-v2/src/agent/goal/goalService.ts index 73aec20a9b..2ee672f0b1 100644 --- a/packages/agent-core-v2/src/agent/goal/goalService.ts +++ b/packages/agent-core-v2/src/agent/goal/goalService.ts @@ -1,7 +1,7 @@ /** * `goal` domain (L4) — `IAgentGoalService` implementation. * - * Owns the per-agent goal lifecycle; persists the goal in the `wire` + * Owns the main-agent goal lifecycle; persists the goal in the `wire` * `GoalModel` (`GoalState | null`) through the `goal.create` / `goal.update` / * `goal.clear` Ops (`wire.dispatch`), reads it through `wire.getModel`, * publishes `goal.updated` live to `IEventBus`, and forces a replayed `active` @@ -18,10 +18,13 @@ * `contextInjector`, drives continuation turns by enqueueing `newTurn` * `StepRequest`s onto `loop` (the continuation message materializes when the * loop pops it), accounts live - * turn usage through `usage`, writes system reminders through - * `systemReminder`, registers model tools through `toolRegistry`, and reports - * telemetry through `telemetry`. Measures time and arms hard deadlines through - * `goal`'s App-scoped deadline scheduler. Bound at Agent scope. + * turn usage through `usage`, observes terminal goal tool results through + * `toolExecutor`, writes system reminders through `systemReminder`, reports + * telemetry through `telemetry`, and checks main-agent eligibility through + * `scopeContext`. Measures time and arms hard deadlines through `goal`'s + * App-scoped deadline scheduler. Bound at Agent scope. + * Subagent instances reject every goal command and do not install goal + * injection, accounting, budget, or continuation hooks. */ import { randomUUID } from 'node:crypto'; @@ -44,6 +47,7 @@ import { import { LOOP_CONTROL_SECTION, type LoopControl } from '#/agent/loop/configSection'; import { ContinuationStepRequest, MessageStepRequest } from '#/agent/loop/stepRequest'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import type { ExecutableToolResult } from '#/tool/toolContract'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import type { ToolBeforeExecuteContext } from '#/agent/toolExecutor/toolHooks'; @@ -223,8 +227,10 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { @IAgentUsageService usageService: IAgentUsageService, @IConfigService private readonly config: IConfigService, @IGoalDeadlineScheduler private readonly deadlineScheduler: IGoalDeadlineScheduler, + @IAgentScopeContext private readonly agentContext: IAgentScopeContext, ) { super(); + if (!this.isSupportedAgent) return; this._register( new GoalInjection( { @@ -296,26 +302,43 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { ); } + private get isSupportedAgent(): boolean { + return this.agentContext.agentId === 'main'; + } + + private assertSupportedAgent(): void { + if (this.isSupportedAgent) return; + throw new Error2( + ErrorCodes.GOAL_UNSUPPORTED_AGENT, + 'Goals are only supported by the main agent', + { details: { agentId: this.agentContext.agentId } }, + ); + } + private get goalState(): GoalState | null { return this.wire.getModel(GoalModel) as GoalState | null; } getGoal(): GoalToolResult { + this.assertSupportedAgent(); const state = this.goalState; return { goal: state === null ? null : this.toSnapshot(state) }; } getActiveGoal(): GoalSnapshot | null { + this.assertSupportedAgent(); const state = this.goalState; if (state === null || state.status !== 'active') return null; return this.toSnapshot(state); } isGoalToolTarget(turnId: number, goalId: string): boolean { + this.assertSupportedAgent(); return this.goalTurnTargets.get(turnId) === goalId; } async createGoal(input: CreateGoalInput, actor: GoalActor = 'user'): Promise { + this.assertSupportedAgent(); const objective = this.validateObjective(input.objective); this.prepareForGoalCreation(input.replace === true); const wallClockResumedAt = Date.now(); @@ -362,6 +385,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { } async pauseGoal(input: GoalReasonInput = {}, actor: GoalActor = 'user'): Promise { + this.assertSupportedAgent(); const state = this.requireState(); if (state.status === 'paused') return this.toSnapshot(state); if (state.status !== 'active') { @@ -377,12 +401,14 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { input: GoalReasonInput = {}, actor: GoalActor = 'runtime', ): Promise { + this.assertSupportedAgent(); const state = this.goalState; if (state === null || state.status !== 'active') return null; return this.applyLifecycle(state, 'paused', input.reason, actor); } async resumeGoal(input: ResumeGoalInput = {}, actor: GoalActor = 'user'): Promise { + this.assertSupportedAgent(); const state = this.requireState(); if (state.status === 'active') return this.toSnapshot(state); if (state.status !== 'paused' && state.status !== 'blocked') { @@ -412,6 +438,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { input: { readonly budgetLimits: GoalBudgetLimits }, actor: GoalActor = 'user', ): Promise { + this.assertSupportedAgent(); const state = this.requireState(); const budgetLimits = { ...state.budgetLimits, ...input.budgetLimits }; this.wire.dispatch(updateGoal({ budgetLimits })); @@ -428,6 +455,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { } async cancelGoal(_input: GoalReasonInput = {}, actor: GoalActor = 'user'): Promise { + this.assertSupportedAgent(); const state = this.requireState(); const snapshot = this.toSnapshot(state); if (state.status === 'active' && this.liveTurnId !== undefined) { @@ -447,6 +475,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { input: GoalReasonInput = {}, actor: GoalActor = 'runtime', ): Promise { + this.assertSupportedAgent(); const state = this.goalState; if (state === null || state.status !== 'active') return null; const snapshot = this.applyLifecycle(state, 'blocked', input.reason, actor, { @@ -459,6 +488,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { input: GoalReasonInput = {}, actor: GoalActor = 'model', ): Promise { + this.assertSupportedAgent(); const state = this.goalState; if (state === null || state.status !== 'active') return null; this.dispatchCompletion(state, input.reason, actor); @@ -491,10 +521,12 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { } async pauseOnInterrupt(input: GoalReasonInput = {}): Promise { + this.assertSupportedAgent(); return this.pauseActiveGoal(input, 'user'); } async recordTokenUsage(tokenDelta: number): Promise { + this.assertSupportedAgent(); return this.accountTokenUsage(tokenDelta); } @@ -508,6 +540,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { } async incrementTurn(): Promise { + this.assertSupportedAgent(); return this.incrementGoalTurn(); } diff --git a/packages/agent-core-v2/test/agent/goal/goal.test.ts b/packages/agent-core-v2/test/agent/goal/goal.test.ts index 7688f2292e..a016f115a7 100644 --- a/packages/agent-core-v2/test/agent/goal/goal.test.ts +++ b/packages/agent-core-v2/test/agent/goal/goal.test.ts @@ -17,6 +17,7 @@ import { type AgentGoalService } from '#/agent/goal/goalService'; import { UpdateGoalTool, UpdateGoalToolInputSchema } from '#/agent/goal/tools/update-goal'; import { IAgentLoopService, type AfterStepContext, type EnqueueReceipt, type Step, type Turn } from '#/agent/loop/loop'; import { MessageStepRequest } from '#/agent/loop/stepRequest'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentToolExecutorService, type ToolExecutionResult, @@ -1495,6 +1496,83 @@ describe('goal error catalog metadata', () => { public: true, action: 'Only paused goals can be resumed.', }); + expect(errorInfo('goal.unsupported_agent')).toEqual({ + title: 'Goals are unavailable for subagents', + retryable: false, + public: true, + action: 'Run goal lifecycle commands on the main agent.', + }); + }); +}); + +describe('AgentGoalService agent eligibility', () => { + let ctx: TestAgentContext; + + beforeEach(() => { + ctx = createTestAgent( + agentService(IAgentScopeContext, { + _serviceBrand: undefined, + agentId: 'sub-1', + scope: (subKey?: string) => + subKey === undefined ? 'test/agents/sub-1' : `test/agents/sub-1/${subKey}`, + }), + ); + }); + + afterEach(async () => { + await ctx.dispose(); + }); + + it.each([ + ['getGoal', (goals: IAgentGoalService) => goals.getGoal()], + ['isGoalToolTarget', (goals: IAgentGoalService) => goals.isGoalToolTarget(1, 'goal-1')], + ['createGoal', (goals: IAgentGoalService) => goals.createGoal({ objective: 'work' })], + ['pauseGoal', (goals: IAgentGoalService) => goals.pauseGoal()], + ['resumeGoal', (goals: IAgentGoalService) => goals.resumeGoal()], + ['setBudgetLimits', (goals: IAgentGoalService) => + goals.setBudgetLimits({ budgetLimits: { turnBudget: 1 } })], + ['cancelGoal', (goals: IAgentGoalService) => goals.cancelGoal()], + ['markBlocked', (goals: IAgentGoalService) => goals.markBlocked()], + ['markComplete', (goals: IAgentGoalService) => goals.markComplete()], + ] as const)( + '%s rejects direct goal service access when the agent is a subagent', + async (_name, call) => { + const goals = ctx.get(IAgentGoalService); + await expect(Promise.resolve().then(() => call(goals))).rejects.toMatchObject({ + code: 'goal.unsupported_agent', + details: { agentId: 'sub-1' }, + }); + }, + ); + + it.each([ + ['createGoal', () => ctx.rpc.createGoal({ objective: 'work' })], + ['getGoal', () => ctx.rpc.getGoal({})], + ['pauseGoal', () => ctx.rpc.pauseGoal({})], + ['resumeGoal', () => ctx.rpc.resumeGoal({})], + ['cancelGoal', () => ctx.rpc.cancelGoal({})], + ] as const)( + '%s rejects subagent goal RPC access with the stable goal error', + async (_name, call) => { + await expect(call()).rejects.toMatchObject({ + code: 'goal.unsupported_agent', + details: { agentId: 'sub-1' }, + }); + }, + ); + + it('does not continue a previously persisted goal when the agent is a subagent', async () => { + await ctx.restore([ + { type: 'goal.create', goalId: 'legacy-subagent-goal', objective: 'work' }, + ]); + ctx.mockNextResponse({ type: 'text', text: 'Handled as one normal subagent turn.' }); + + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'continue' }] }); + await ctx.untilTurnEnd(); + await Promise.resolve(); + await Promise.resolve(); + + expect(ctx.llmCalls).toHaveLength(1); }); }); diff --git a/packages/agent-core-v2/test/agent/goal/goalOps.test.ts b/packages/agent-core-v2/test/agent/goal/goalOps.test.ts index b55c4aa053..5746cc3373 100644 --- a/packages/agent-core-v2/test/agent/goal/goalOps.test.ts +++ b/packages/agent-core-v2/test/agent/goal/goalOps.test.ts @@ -21,6 +21,7 @@ import { GoalDeadlineSchedulerService } from '#/agent/goal/goalDeadlineScheduler import { AgentGoalService } from '#/agent/goal/goalService'; import { GoalModel } from '#/agent/goal/goalOps'; import { IAgentLoopService } from '#/agent/loop/loop'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import { IAgentUsageService } from '#/agent/usage/usage'; @@ -123,11 +124,16 @@ function buildHost(key: string): { ix.stub(IAgentToolExecutorService, createToolExecutorStub()); ix.stub(IConfigService, createConfigStub()); ix.set(IGoalDeadlineScheduler, new SyncDescriptor(GoalDeadlineSchedulerService)); - ix.set(IAgentGoalService, new SyncDescriptor(AgentGoalService)); const wire = registerTestAgentWire(ix, testWireScope(SCOPE, key), { log: ix.get(IAppendLogStore), eventBus: ix.get(IEventBus), }); + ix.stub(IAgentScopeContext, { + _serviceBrand: undefined, + agentId: 'main', + scope: () => 'wire/agents/main', + }); + ix.set(IAgentGoalService, new SyncDescriptor(AgentGoalService)); return { wire, svc: ix.get(IAgentGoalService), diff --git a/packages/kap-server/src/transport/errors.ts b/packages/kap-server/src/transport/errors.ts index 235f48378c..b56d08168f 100644 --- a/packages/kap-server/src/transport/errors.ts +++ b/packages/kap-server/src/transport/errors.ts @@ -44,6 +44,7 @@ const KIMI_TO_PROTOCOL: Record = { [ErrorCodes.GOAL_NOT_RESUMABLE]: ErrorCode.GOAL_NOT_RESUMABLE, [ErrorCodes.GOAL_OBJECTIVE_EMPTY]: ErrorCode.GOAL_OBJECTIVE_EMPTY, [ErrorCodes.GOAL_OBJECTIVE_TOO_LONG]: ErrorCode.GOAL_OBJECTIVE_TOO_LONG, + [ErrorCodes.GOAL_UNSUPPORTED_AGENT]: ErrorCode.GOAL_UNSUPPORTED_AGENT, // hostFs / storage codes → closest v1 wire equivalent (ENOTDIR collapses // into path-not-found); codes without an equivalent fall back to 50001. [ErrorCodes.OS_FS_NOT_FOUND]: ErrorCode.FS_PATH_NOT_FOUND, diff --git a/packages/kap-server/test/transport-errors.test.ts b/packages/kap-server/test/transport-errors.test.ts index 3df2909199..9bceca93d9 100644 --- a/packages/kap-server/test/transport-errors.test.ts +++ b/packages/kap-server/test/transport-errors.test.ts @@ -1,3 +1,9 @@ +/** + * Scenario: `/api/v2` transport error translation. + * Responsibilities: verify stable domain-to-wire mappings and the internal-error fallback. + * Wiring: real error mapper with in-process coded errors; no external boundaries. + * Run: `pnpm --filter @moonshot-ai/kap-server exec vitest run test/transport-errors.test.ts`. + */ import { Error2, ErrorCodes } from '@moonshot-ai/agent-core-v2'; import { ErrorCode } from '@moonshot-ai/protocol'; import { describe, expect, it } from 'vitest'; @@ -13,6 +19,7 @@ describe('/api/v2 transport mapError', () => { [ErrorCodes.OS_FS_PERMISSION_DENIED, ErrorCode.FS_PERMISSION_DENIED], [ErrorCodes.STORAGE_IO_FAILED, ErrorCode.PERSISTENCE_FAILURE], [ErrorCodes.STORAGE_LOCKED, ErrorCode.PERSISTENCE_FAILURE], + [ErrorCodes.GOAL_UNSUPPORTED_AGENT, ErrorCode.GOAL_UNSUPPORTED_AGENT], ])('maps domain code %s to its wire equivalent', (code, wire) => { const env = mapError(new Error2(code, 'boom'), 'req-1'); expect(env.code).toBe(wire); diff --git a/packages/protocol/src/__tests__/envelope.test.ts b/packages/protocol/src/__tests__/envelope.test.ts index ba41350929..cc72a134bb 100644 --- a/packages/protocol/src/__tests__/envelope.test.ts +++ b/packages/protocol/src/__tests__/envelope.test.ts @@ -1,3 +1,9 @@ +/** + * Scenario: protocol success/error envelopes and canonical error codes. + * Responsibilities: verify schema round-trips, stable numeric codes, and reason labels. + * Wiring: pure protocol schemas and constructors; no external boundaries. + * Run: `pnpm --filter @moonshot-ai/protocol exec vitest run src/__tests__/envelope.test.ts`. + */ import { describe, expect, it } from 'vitest'; import { z } from 'zod'; @@ -78,6 +84,7 @@ describe('error-codes', () => { expect(ErrorCode.SUCCESS).toBe(0); expect(ErrorCode.VALIDATION_FAILED).toBe(40001); expect(ErrorCode.SESSION_NOT_FOUND).toBe(40401); + expect(ErrorCode.GOAL_UNSUPPORTED_AGENT).toBe(40920); expect(ErrorCode.APPROVAL_EXPIRED).toBe(41001); expect(ErrorCode.FS_WATCH_LIMIT_EXCEEDED).toBe(42902); expect(ErrorCode.INTERNAL_ERROR).toBe(50001); @@ -90,6 +97,7 @@ describe('error-codes', () => { expect(ErrorCodeReason[ErrorCode.MODEL_NOT_FOUND]).toBe('model.not_found'); expect(ErrorCodeReason[ErrorCode.VALIDATION_FAILED]).toBe('validation.failed'); expect(ErrorCodeReason[ErrorCode.FS_WATCH_LIMIT_EXCEEDED]).toBe('fs.watch_limit_exceeded'); + expect(ErrorCodeReason[ErrorCode.GOAL_UNSUPPORTED_AGENT]).toBe('goal.unsupported_agent'); }); it('reserved codes are not redefined (40101, 50002 absent)', () => { diff --git a/packages/protocol/src/error-codes.ts b/packages/protocol/src/error-codes.ts index 9205830f15..9c4eaccce1 100644 --- a/packages/protocol/src/error-codes.ts +++ b/packages/protocol/src/error-codes.ts @@ -97,6 +97,8 @@ export const ErrorCode = { GOAL_OBJECTIVE_TOO_LONG: 40918, /** fs.mkdir 目标路径已存在(文件或目录) */ FS_ALREADY_EXISTS: 40919, + /** goal 只允许主 agent 使用 */ + GOAL_UNSUPPORTED_AGENT: 40920, /** approval 60s 超时 */ APPROVAL_EXPIRED: 41001, @@ -191,6 +193,7 @@ export const ErrorCodeReason: Readonly> = { [ErrorCode.GOAL_OBJECTIVE_EMPTY]: 'goal.objective_empty', [ErrorCode.GOAL_OBJECTIVE_TOO_LONG]: 'goal.objective_too_long', [ErrorCode.FS_ALREADY_EXISTS]: 'fs.already_exists', + [ErrorCode.GOAL_UNSUPPORTED_AGENT]: 'goal.unsupported_agent', [ErrorCode.APPROVAL_EXPIRED]: 'approval.expired', [ErrorCode.QUESTION_EXPIRED]: 'question.expired', diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts index 4091443fd8..9700783e61 100644 --- a/packages/protocol/src/events.ts +++ b/packages/protocol/src/events.ts @@ -245,6 +245,7 @@ export type KimiErrorCode = | 'goal.status_invalid' | 'goal.metadata_reserved' | 'goal.not_resumable' + | 'goal.unsupported_agent' | 'model.not_configured' | 'model.config_invalid' | 'profile.thinking_alias_conflict' @@ -1148,6 +1149,7 @@ export const kimiErrorCodeSchema = z.enum([ 'goal.status_invalid', 'goal.metadata_reserved', 'goal.not_resumable', + 'goal.unsupported_agent', 'model.not_configured', 'model.config_invalid', 'profile.thinking_alias_conflict', From 4c04c6b36ca671ceda931e455363d52452bde928 Mon Sep 17 00:00:00 2001 From: chengluyu <2239547+chengluyu@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:00:18 +0800 Subject: [PATCH 2/2] fix(kap-server): guard reflected subagent goals --- .../kap-server/src/transport/dispatcher.ts | 12 +++++++++++ packages/kap-server/test/rpc.test.ts | 20 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/packages/kap-server/src/transport/dispatcher.ts b/packages/kap-server/src/transport/dispatcher.ts index 2fcdc568dd..568efb2781 100644 --- a/packages/kap-server/src/transport/dispatcher.ts +++ b/packages/kap-server/src/transport/dispatcher.ts @@ -7,6 +7,7 @@ import { ErrorCodes, + IAgentGoalService, IAgentLifecycleService, ISessionLifecycleService, Error2, @@ -85,6 +86,17 @@ export async function resolveService( if (id === undefined) { throw new Error2(ErrorCodes.REQUEST_INVALID, `unknown service: ${serviceName}`); } + if ( + scopeKind === 'agent' && + id === IAgentGoalService && + params['agent_id'] !== MAIN_AGENT_ID + ) { + throw new Error2( + ErrorCodes.GOAL_UNSUPPORTED_AGENT, + 'Goals are only supported by the main agent', + { details: { agentId: params['agent_id'] ?? '' } }, + ); + } try { return scope.accessor.get(id) as object; } catch { diff --git a/packages/kap-server/test/rpc.test.ts b/packages/kap-server/test/rpc.test.ts index a46ad02d21..fe88c21690 100644 --- a/packages/kap-server/test/rpc.test.ts +++ b/packages/kap-server/test/rpc.test.ts @@ -134,6 +134,12 @@ describe('server-v2 /api/v2 RPC', () => { await session.accessor.get(IAgentLifecycleService).create({ agentId: 'main' }); } + async function createSubagent(sessionId: string, agentId: string): Promise { + const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId); + if (session === undefined) throw new Error(`session ${sessionId} not found`); + await session.accessor.get(IAgentLifecycleService).create({ agentId }); + } + // --- Core scope ----------------------------------------------------------- it('describes all channels via GET /api/v2/channels', async () => { @@ -433,6 +439,20 @@ describe('server-v2 /api/v2 RPC', () => { expect(duplicate.body.code).toBe(40913); }); + it('rejects reflected goal helper access for subagents', async () => { + const id = await createSession(home as string); + await createSubagent(id, 'sub-1'); + + const { body } = await call( + 'POST', + rpc('agent', IAgentGoalService, 'clearInternal', { sid: id, aid: 'sub-1' }), + 'user', + ); + + expect(body.code).toBe(40920); + expect(body.msg).toBe('Goals are only supported by the main agent'); + }); + it('lists and installs plugins through RPC', async () => { const pluginRoot = await mkdtemp(join(tmpdir(), 'server-v2-plugin-source-')); try {