,
+) {
+ 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 {