diff --git a/apps/server/src/mcp/McpToolAccess.ts b/apps/server/src/mcp/McpToolAccess.ts index 20a9274798d0f..0a71cd8773573 100644 --- a/apps/server/src/mcp/McpToolAccess.ts +++ b/apps/server/src/mcp/McpToolAccess.ts @@ -31,6 +31,7 @@ import { assertLiveCaller, assertTargetWithinLimits, type Caller, + limitsForOwnChild, loadCaller, readCaller, unavailable, @@ -199,25 +200,38 @@ export const writesWithCapability = (handle: (params: P) => Effect.E export const writesThreads = ( threads: (params: P) => ReadonlyArray, handle: (params: P) => Effect.Effect, + /** `ownChildren` lets a thread reach a child it delegated that runs above its runtime mode. */ + options?: { readonly ownChildren?: "at-their-runtime-mode" }, ) => declare( (params: P) => Effect.gen(function* () { const caller = yield* writingCaller; + let dispatchLimits = caller.limits; for (const threadId of threads(params)) { if (threadId === undefined || threadId === caller.scope.thread?.threadId) continue; const target = yield* caller.threads .getThreadShell(threadId) .pipe(Effect.mapError(unavailable)); if (target !== null && target.deletedAt === null) { - yield* assertTargetWithinLimits(caller.limits, target); + const limits = + options?.ownChildren === undefined + ? caller.limits + : yield* limitsForOwnChild( + caller.threads, + caller.caller?.id, + caller.limits, + target, + ); + yield* assertTargetWithinLimits(limits, target); + if (limits !== caller.limits) dispatchLimits = limits; yield* assertSameLink(caller, target); } } // Modes can rise after this check; the orchestrator rechecks under lock and records refusal before handlers wrap errors. const refused = yield* Ref.make(undefined); return yield* handle(params).pipe( - Effect.provideService(DispatchModeLimit, { ...caller.limits, refused }), + Effect.provideService(DispatchModeLimit, { ...dispatchLimits, refused }), Effect.catch((error) => Effect.flatMap(Ref.get(refused), (refusal) => Effect.fail( diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index cf5efb0ab333d..7932cceda2d3e 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -132,7 +132,12 @@ import { runtimeModeRank, taskPrompt, } from "./delegatedTaskModes.ts"; -import { assertTargetWithinLimits, isLiveCallerShell, threadNotFound } from "./threadAccess.ts"; +import { + assertTargetWithinLimits, + isLiveCallerShell, + limitsForOwnChild, + threadNotFound, +} from "./threadAccess.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; import * as PeerForwarding from "../peer/PeerForwarding.ts"; import * as RemoteDelegation from "../peer/RemoteDelegation.ts"; @@ -3389,7 +3394,10 @@ const make = Effect.gen(function* () { sendToThread: Effect.fn("OrchestratorMcpService.sendToThread")(function* (scope, input) { const { parent, limits, target } = yield* loadScopedThread(scope, input.threadId, ["runs"]); yield* assertLiveCallerForOtherThread(scope, parent, target); - yield* assertTargetWithinLimits(limits, target.thread); + yield* assertTargetWithinLimits( + yield* limitsForOwnChild(threadManagement, parent?.thread.id, limits, target.thread), + target.thread, + ); const mode = input.mode ?? "auto"; const key = yield* requestKey(input.clientRequestId); diff --git a/apps/server/src/mcp/threadAccess.ts b/apps/server/src/mcp/threadAccess.ts index 8cce38949cdd0..f3f694e077f71 100644 --- a/apps/server/src/mcp/threadAccess.ts +++ b/apps/server/src/mcp/threadAccess.ts @@ -4,6 +4,7 @@ import type { } from "../orchestration-v2/ProjectionStore.ts"; import { CommandId, + isRuntimeModeAbove, OrchestratorMcpFailure, type ProjectId, type ProviderInstanceId, @@ -158,6 +159,29 @@ export const assertTargetWithinLimits = ( ), ); +/** + * A thread acts on a child it delegated at the runtime mode that child already has, so it can + * move the child to another account and resume it after its own mode was lowered. + */ +export const limitsForOwnChild = Effect.fn("mcp.limitsForOwnChild")(function* ( + threads: Pick, + callerThreadId: ThreadId | undefined, + limits: CallerLimits, + target: Pick, +) { + if ( + callerThreadId === undefined || + !isRuntimeModeAbove(target.runtimeMode, limits.runtimeMode) || + target.lineage.parentThreadId !== callerThreadId || + target.lineage.relationshipToParent !== "subagent" + ) + return limits; + const delegated = yield* threads + .listAppOwnedChildThreadIds(callerThreadId) + .pipe(Effect.mapError(unavailable)); + return delegated.includes(target.id) ? { ...limits, runtimeMode: target.runtimeMode } : limits; +}); + /** Background subagents retain credentials until provider exit; archived threads and other instances cannot act. */ export const isLiveCallerShell = ( shell: Pick< diff --git a/apps/server/src/mcp/toolkits/attachment/handlers.ts b/apps/server/src/mcp/toolkits/attachment/handlers.ts index 032ab3a1d0ed6..803ed9cfa6a75 100644 --- a/apps/server/src/mcp/toolkits/attachment/handlers.ts +++ b/apps/server/src/mcp/toolkits/attachment/handlers.ts @@ -128,6 +128,7 @@ export const layer = McpToolAccess.toLayer( status: result.run.status, }; }), + { ownChildren: "at-their-runtime-mode" }, ), }; }), diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 81614e5eb4274..de6c023421322 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -44,6 +44,7 @@ import { import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; import * as ServerConfig from "../../config.ts"; +import { DispatchModeLimit } from "../../orchestration-v2/DispatchModeLimit.ts"; import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ThreadLaunchService from "../../orchestration-v2/ThreadLaunchService.ts"; import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; @@ -1262,6 +1263,114 @@ it.effect.each(["t3_thread_interrupt", "t3_workflow_stop"])( ), ); +it.effect.each([ + ["t3_thread_configure", "default", true, "full-access/default"], + ["t3_thread_send", "default", true, "full-access/default"], + ["t3_thread_send_attachments", "default", true, "full-access/default"], + ["t3_thread_send", "default", false, "runtime_mode_escalation_denied"], + ["t3_thread_configure", "plan", true, "interaction_mode_escalation_denied"], +] as const)( + "%s from an Auto/%s parent to its Full-access child (delegated: %s) gives %s", + ([toolName, interactionMode, delegated, expected]) => { + const childId = ThreadId.make("mcp-core-own-child"); + const parent = McpToolAccessTestkit.liveThreadShell(threadId, { + runtimeMode: "auto", + interactionMode, + }); + const child = { + ...McpToolAccessTestkit.liveThreadShell(childId), + lineage: { + rootThreadId: threadId, + parentThreadId: threadId, + relationshipToParent: "subagent" as const, + }, + }; + const sent = { type: "image", id: "sent", name: "a.png", mimeType: "image/png", sizeBytes: 1 }; + // The limit the orchestrator rechecks the child against under its lock. + const dispatchLimit = DispatchModeLimit.pipe( + Effect.map((limit) => `${limit?.runtimeMode}/${limit?.interactionMode}`), + ); + let limit: string | undefined; + return Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const result = yield* server + .callTool({ + name: toolName, + arguments: { + threadId: childId, + ...(toolName === "t3_thread_configure" + ? { modelSelection: { instanceId: "codex-b", model: "gpt-5" } } + : toolName === "t3_thread_send" + ? { message: "Go." } + : { attachments: [sent] }), + }, + }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, scope), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(declaredFailure(result)?.code ?? limit).toBe(expected); + }).pipe( + Effect.provide( + Layer.mergeAll( + layerThreadToolkit, + layerOrchestratorToolkit, + McpHttpServer.layerAttachmentToolkit, + ).pipe( + Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), + Layer.provide( + Layer.mergeAll( + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: (id) => Effect.succeed(id === childId ? child : parent), + getThreadRecords: () => + Effect.succeed(McpToolAccessTestkit.idleThreadProjection(parent) as never), + getProjectThreadRecords: () => + Effect.succeed({ + ...McpToolAccessTestkit.idleThreadProjection(child), + messages: [{ attachments: [sent] }], + } as never), + listAppOwnedChildThreadIds: () => Effect.succeed(delegated ? [childId] : []), + dispatch: () => + dispatchLimit.pipe( + Effect.map((seen) => { + limit = seen; + return { sequence: 1, storedEvents: [] }; + }), + ), + sendToThread: () => + dispatchLimit.pipe( + Effect.map((seen) => { + limit = seen; + return { + run: { id: RunId.make("run:own-child"), status: "running" }, + message: { attachments: [] }, + delivery: "started", + } as never; + }), + ), + }), + ServerSettings.layerTest(), + Layer.mock(ProviderRegistry.ProviderRegistry)({}), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), + Layer.mock(PeerForwarding.PeerForwarding)({}), + Layer.mock(PeerLinkRequests.PeerLinkRequests)({}), + Layer.mock(PeerLinks.PeerLinks)({}), + Layer.mock(DocumentText.DocumentText)({}), + AssetSigner.layer.pipe(Layer.provideMerge(ServerSecretStore.layer)), + ), + ), + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-own-child-" })), + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), + ); + }, +); + it.effect("discovers workflow stop targets and requires orchestration capability", () => { const threadId = ThreadId.make("workflow-parent"); const shell = McpToolAccessTestkit.liveThreadShell(threadId); diff --git a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts index dfa1ad58545ee..ed2b874baa179 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts @@ -182,6 +182,7 @@ const handlers = { const service = yield* OrchestratorMcpService.OrchestratorMcpService; return yield* service.sendToThread(scope, input); }), + { ownChildren: "at-their-runtime-mode" }, ), // There the thread's parent is here, so only this side can make a follow-up a task. (input, environmentId, sent) => diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index 972174638e10f..a0a1b0ff79500 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -105,6 +105,12 @@ const required = (value: A | undefined, name: string) => const writesThread =

( handle: (params: P) => Effect.Effect, ) => McpToolAccess.writesThreads((params: P) => [params.threadId], handle); +const writesOwnChildThread =

( + handle: (params: P) => Effect.Effect, +) => + McpToolAccess.writesThreads((params: P) => [params.threadId], handle, { + ownChildren: "at-their-runtime-mode", + }); export const layer = McpToolAccess.toLayer(ThreadToolkit, { run_scheduled_task_now: McpToolAccess.writesEnvironment((input) => @@ -229,7 +235,7 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, { }; }), ), - writesThread((input) => + writesOwnChildThread((input) => Effect.gen(function* () { const modelSelection = yield* required(input.modelSelection, "modelSelection"); const {